Salut les amis !
Aujourd’hui on parle TurboQuant, et plus précisément de comment le faire tourner sur un modèle hybride (Qwen3.6-27B avec ses couches Gated DeltaNet + attention) sur une carte Blackwell consumer 24 Go. Vous me direz : “mais Aurélien, c’est une niche très étroite, ton truc”. Et vous avez raison ! La plupart des configs documentées sont sur Ampere data-center 80 Go ou DGX Spark. Sauf que c’est exactement la carte que j’ai dans mon Olares One, et vous êtes peut-être dans le même cas. Du coup, voilà où ça nous mène.
TL;DR — les chiffres
Bench sur Olares One (RTX 5090M, 24 Go GDDR7, 896 Go/s, sm_120 Blackwell). Modèle : Qwen3.6-27B Lorbus/Qwen3.6-27B-int4-AutoRound. 3 prompts × 800 tokens (Space Invaders HTML, guide Go REST API, B-tree PostgreSQL). temperature=0.6, top_p=0.95.
| Stack | Cold (Space Invaders) | AVG (3 runs) | KV pool | Context |
|---|---|---|---|---|
| Dense MTP + fp8_e5m2 (référence v2.2.2) | ~90 t/s | ~90 t/s | 24K tokens | 75K |
| Turbo TQ K8V4 sans MTP | 40 t/s | 40 t/s | 149K tokens | 128K |
| Turbo TQ K8V4 + MTP n=3 | 37 t/s | 38 t/s | 120K tokens | 80K |
| Turbo TQ 4bit_nc sans MTP | 28 t/s | — | 224K tokens | 128K |
| Turbo TQ 4bit_nc + MTP n=3 | 46 t/s | 60 t/s [46-73] | 177K tokens | 100K |
La config gagnante (dernière ligne) double le pool KV par rapport à la dense référence (177K vs 24K), au prix de ~33 % de débit en mode froid. Trade-off très pertinent quand vous tapez dans des prompts longs ou que vous accumulez du contexte d’agent. Bref, on signe !
Le bloqueur initial : NotImplementedError sur hybrid
Premier essai, vanilla vLLM 0.20-nightly avec PR #38479 (TurboQuant) mergée. Et là, baffe :
NotImplementedError: TurboQuant KV cache is not supported for hybrid
(attention + Mamba) models. Boundary layer protection requires uniform
attention layers.
Pourquoi ? Parce que Qwen3.5/3.6 mélangent du Gated DeltaNet (24 couches sur 32 pour 3.5, similaire sur 3.6) avec de l’attention pleine. L’algo de boundary layer protection de TurboQuant suppose des couches uniformes — et donc refus net en upstream. Voilà.
C’est là que Sandermage Genesis entre en jeu. C’est un set de 28 monkey-patches runtime qui adressent justement ce trou. Le repo (Sandermage/genesis-vllm-patches, MIT, tag v7.51-stable-2026-04-27) est testé sur Ampere (RTX A5000 80 Go). Personne n’avait encore validé sur Blackwell consumer. Première inconnue : est-ce que les patches s’appliquent sans casser sur sm_120 ?
Le résultat de Genesis sur sm_120
Spoiler : ça passe sans broncher.
[INFO:genesis.apply_all] Genesis platform:
compute_capability: [12, 0]
is_blackwell: false # Sandermage classifie sm_120 comme "non-Blackwell" mais ça marche
has_native_fp8: true
[INFO:genesis.apply_all] Genesis Results: 26 applied, 32 skipped, 0 failed
Zéro échec sur 26 patches appliqués. Les 32 skips sont soit des opt-in qu’on n’active pas, soit des patches Ampere-specific (FP8 Marlin fallback) qui auto-skip parce qu’on a du FP8 natif sur Blackwell. Tout simplement.
Le patch critique pour nous c’est P4 — TurboQuant hybrid model support : il bypasse le NotImplementedError, route les couches GDN à travers le bon path, et fix les page-size mismatches entre couches d’attention et couches recurrent. Bref, exactement ce dont on avait besoin.
Une fois Genesis appliqué, l’engine vLLM accepte --kv-cache-dtype turboquant_k8v4 ou turboquant_4bit_nc et boote sur Qwen3.6-27B. Premier objectif atteint !
Quatre pièges spécifiques à notre stack
1. P65 n’est pas optionnel — c’est une dépendance fonctionnelle
GENESIS_ENABLE_P65_TURBOQUANT_SPEC_CG_DOWNGRADE=1 est documenté chez Sandermage comme un opt-in pour résoudre issue vLLM #40880 (MTP × TurboQuant × cudagraph degenerate output). En pratique sur Blackwell + MTP + 4bit_nc, sans P65 le pod ne boote même pas. Regardez :
torch._dynamo.exc.TorchRuntimeError: RuntimeError when making fake tensor call
Explanation: Dynamo failed to run FX node with fake tensors:
call_function <built-in function mul>(*(
FakeTensor(..., device='cuda:0', size=(196608, 128)),
FakeTensor(..., device='cuda:0', size=(48*s72, 128))
), **{}): got RuntimeError(
'The size of tensor a (196608) must match the size of tensor b (48*s72)
at non-singleton dimension 0'
)
Le bug est dans le path cudagraph capture quand on combine MTP draft tensors et TurboQuant kernels. P65 route les spec-verify batches en eager (pas de cudagraph), ce qui contourne la zone qui plante. Sans P65 → TorchDynamo trip, engine init failed. Pas de panique : on l’active, ça boote.
Coût de P65 : vous perdez le speedup que cudagraph donnerait au spec-decode. C’est pour ça que MTP+TQ ne donne pas le boost qu’on attendrait par rapport à un baseline sans MTP. Regardez la table — l’écart entre Turbo K8V4 sans MTP (40 t/s) et avec MTP n=3 (37-38 t/s) est négatif. MTP coûte plus que ce qu’il rapporte sous P65. Aïe.
C’est uniquement avec TQ 4bit_nc que MTP redevient net-positive (46 t/s cold vs 28 t/s sans MTP). Probablement parce que la dispatch interne de 4bit_nc est plus uniforme (MSE quant pour K et V) et joue mieux avec les batches eager que K8V4 (FP8 keys + 4-bit values, dispatch hétérogène). On y reviendra dans les notes.
2. turboquant_3bit_nc casse au compile
J’ai voulu pousser plus loin la compression (4.9× vs 3.8× pour 4bit_nc, vs 2.6× pour K8V4). Échec immédiat, même config par ailleurs :
torch._dynamo.exc.TorchRuntimeError: RuntimeError when making fake tensor call
...same shape mismatch (196608, 128) vs (48*s72, 128)...
Désactiver Genesis P5B (la stratégie pad-smaller-to-max KV) ne change rien — c’est intrinsèque à la combo MTP draft tensors × 3-bit kernel reshape. Probablement un bug upstream vLLM ou Genesis spécifique aux 3-bit blocks. À surveiller si Sandermage publie un P67+ qui adresse ça.
Donc pour l’instant : 3bit_nc + MTP = no-go sur cette stack. Si vous voulez 3bit_nc, faut désactiver MTP — et là on tombe à 28 t/s (la ligne Turbo TQ 4bit_nc sans MTP mais en pire à cause du kernel overhead 3-bit). Pas intéressant. Suivant !
3. --max-num-batched-tokens doit dépasser le block_size Mamba
Le block_size de la Mamba cache change selon le KV dtype. Genesis P5 (page-size unification) calcule le block_size en alignant sur le LCM de tous les patterns d’attention :
| KV dtype | Mamba block_size |
|---|---|
| fp8_e5m2 (réf dense) | 2080 |
| turboquant_k8v4 | 2080 |
| turboquant_4bit_nc | ~4096 |
| turboquant_3bit_nc | ~4128 |
Et vLLM enforce block_size <= max_num_batched_tokens. Donc sur 4bit_nc faut au moins 4096 (j’ai mis 8192 pour la marge), et sur K8V4 4096 suffit. Si vous démarrez avec la valeur Sandermage prod (4096) sur 3bit_nc, vous prenez une AssertionError au boot. Adaptez selon le dtype, c’est tout simple.
4. Le prefix caching change tout
Sur le bench multi-prompt, le run 2 (Go REST API) est passé à 73 t/s alors que le run 1 (Space Invaders, à froid) était à 46 t/s. Plot twist : les deux prompts sont différents par leur contenu utilisateur, mais ils partagent le system prompt + le tokenizer init de Qwen3, et --enable-prefix-caching --prefix-caching-hash-algo xxhash permet à vLLM de réutiliser les KV des tokens en commun. D’où le bond.
C’est utile à savoir quand vous annoncez des chiffres : un t/s “warm” en agent qui itère sur le même contexte est ~50-60 % plus rapide qu’un t/s “cold” sur prompt vierge. Pour la cold reference (la pire), prenez le run 1.
La recette complète
Allez, on entre dans le dur. Voilà tout ce qu’il vous faut.
Image Docker
docker.io/aamsellem/vllm-qwen36-blackwell:0.20.0-genesis — basée sur vllm/vllm-openai:cu130-nightly-fe9c3d6c5f66c873d196800384ed6880687b9e52 (post #38479 merge le 15 avril 2026).
Le Dockerfile :
FROM vllm/vllm-openai:cu130-nightly-fe9c3d6c5f66c873d196800384ed6880687b9e52
RUN apt-get update && apt-get install -y --no-install-recommends git && \
git clone --depth 1 --branch v7.51-stable-2026-04-27 \
https://github.com/Sandermage/genesis-vllm-patches.git /tmp/genesis && \
cd /tmp/genesis && \
pip install --no-deps --no-cache-dir ./genesis_vllm_plugin && \
VLLM_DIR="$(python3 -c 'import vllm, os; print(os.path.dirname(vllm.__file__))')" && \
cp -r vllm/_genesis "$VLLM_DIR/_genesis" && \
rm -rf /tmp/genesis && apt-get purge -y git && \
apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
COPY patch_tolist_cudagraph.py /patches/patch_tolist_cudagraph.py
RUN echo '#!/bin/sh\nset -e\npython3 -m vllm._genesis.patches.apply_all || true\npython3 /patches/patch_tolist_cudagraph.py || true\nexec vllm "$@"' > /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["serve"]
Args vLLM
--model Lorbus/Qwen3.6-27B-int4-AutoRound
--quantization auto_round
--dtype float16
--kv-cache-dtype turboquant_4bit_nc
--max-model-len 100000
--gpu-memory-utilization 0.97
--max-num-seqs 1
--max-num-batched-tokens 8192
--language-model-only
--enable-prefix-caching
--prefix-caching-hash-algo xxhash
--enable-chunked-prefill
--enable-auto-tool-choice
--tool-call-parser qwen3_coder
--reasoning-parser qwen3
--performance-mode interactivity
--async-scheduling
--no-scheduler-reserve-full-isl
--attention-config.flash_attn_version 2
--speculative-config '{"method":"mtp","num_speculative_tokens":3}'
Variables d’env
VLLM_USE_FLASHINFER_SAMPLER=1
VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=1
VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
VLLM_MARLIN_USE_ATOMIC_ADD=1
VLLM_FLOAT32_MATMUL_PRECISION=high
GENESIS_ENABLE_P5B_KV=1
GENESIS_ENABLE_P65_TURBOQUANT_SPEC_CG_DOWNGRADE=1
GENESIS_ENABLE_P66_CUDAGRAPH_SIZE_FILTER=1
GENESIS_ENABLE_P64_QWEN3CODER_MTP_STREAMING=1
GENESIS_ENABLE_P60_GDN_NGRAM_FIX=1
GENESIS_ENABLE_P62_STRUCT_OUT_SPEC_TIMING=1
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:512
NCCL_CUMEM_ENABLE=0
NCCL_P2P_DISABLE=1
OMP_NUM_THREADS=1
CUDA_DEVICE_MAX_CONNECTIONS=8
Bench harness
import urllib.request, json, time
prompts = [
"Build a complete Space Invaders game in a single HTML file...",
"Write a comprehensive guide to building a REST API in Go...",
"Explain how a B-tree index works in PostgreSQL...",
]
results = []
for i, p in enumerate(prompts):
data = json.dumps({
"model": "qwen3.6-27b",
"messages": [{"role": "user", "content": p}],
"max_tokens": 800,
"temperature": 0.6, "top_p": 0.95
}).encode()
req = urllib.request.Request("http://localhost:8000/v1/chat/completions",
data=data,
headers={"Content-Type": "application/json"})
t0 = time.time()
r = json.loads(urllib.request.urlopen(req).read())
el = time.time() - t0
toks = r["usage"]["completion_tokens"]
print(f"RUN{i+1} TOKENS={toks} ELAPSED={el:.2f}s TPS={toks/el:.2f}")
results.append(toks/el)
print(f"AVG={sum(results)/len(results):.2f} MIN={min(results):.2f} MAX={max(results):.2f}")
Sur Olares K8s, on lance depuis l’intérieur du pod pour bypass l’auth sidecar :
kubectl exec -n vllmqwen36turbo27bone-aurelien deploy/vllmqwen36turbo27bone -c vllm-server -- python3 -c "..."
Métriques live (steady state)
Avg generation throughput: 60 t/s [46-73 range across 3 prompts]
KV cache pool: 177,840 tokens
KV cache usage during generation: 5-15 %
Mean acceptance length: variable (P65 force eager → métriques moins parlantes)
Engine init: 100s (compilation cudagraph 42s + load weights 7s + KV alloc + warmup)
Model loading: 16.65 GiB
Quelques notes en vrac
- Pourquoi 4bit_nc bat K8V4 sur le combo MTP — théorie : 4bit_nc fait du MSE quant sur K et V (uniforme), K8V4 mélange FP8 keys + 4-bit values (hétérogène). La dispatch eager spec-verify (forcée par P65) navigue mieux dans le path uniforme. C’est observationnel, pas confirmé par le code Genesis. Si quelqu’un a une explication kernel-level, je suis preneur !
- Pourquoi pas K3V4_NC — pas testé. Sur le papier intermédiaire entre K8V4 et 4bit_nc, mais la doc Sandermage le mentionne peu. Si vous le testez, dites-moi ce que ça donne !
- Pourquoi pas Lucebox — Lucebox (Luce-Org/lucebox-hub, MIT) annonce 78 t/s sur Qwen3.6-27B avec un drafter DFlash matched + leur custom ggml fork. Pas de Docker public, faut compiler. C’est mon prochain post !
- Limites de la mesure — t/s mesurés depuis l’intérieur du pod (no envoy auth gate, no network round-trip). Variance prompt-dépendante (run 2 GoREST plus rapide que run 1 SpaceInv probablement à cause d’un prefix cache hit partiel sur le system prompt + tokens communs). Pour un chiffre sans cache hit, prenez le run 1.
Crédits
- Sandro / Sandermage pour les 28 monkey-patches Genesis qui ont rendu tout ça possible. Un travail de plusieurs semaines de reverse-engineering en sa qualité. Merci !
- vibhavagarwal5 pour le PR vLLM #38479 mergé le 15 avril 2026 — TurboQuant 2-bit KV cache est upstream depuis cette date.
- Lorbus pour le quant AutoRound INT4 qui dequantize le
mtp.fchead en BF16 dans le fichier — ce qui fait passer la mémoire MTP de 2.37 GiB (fresh buffer) à ~280 MiB (read from disk). - Wasif Basharat et u/Kindly-Cantaloupe978 pour les recettes RTX 3090 24 Go et RTX 5090 32 Go qui m’ont donné le point de départ.
Voilà ! Côté reproductibilité, tout est dans le repo aamsellem/olares-one-market (chart Helm vllmqwen36turbo27bone v2.2.0). Image Docker publique aamsellem/vllm-qwen36-blackwell:0.20.0-genesis. Si quelque chose ne reproduit pas, ouvrez une issue ou laissez un commentaire ici, je corrige. À très vite !
Disclosure — Tous les benchmarks de ce post tournent sur mon propre Olares One. Si le contenu vous a été utile et que vous envisagez d’en acheter un, commander via ce lien de parrainage vous donne 400 $ de réduction (3 599 $ au lieu de 3 999 $) et me rapporte 200 $. Je le mentionne par transparence — et oui, accessoirement, ça aide à faire vivre le blog (hébergement, domaine, et le temps que je passe à écrire ici). Lien valable jusqu’à fin juin 2026 environ.