Skip to content
/ airelien.dev
Go back
Aurélien AMSELLEM

Genesis sur Blackwell consumer — TurboQuant débloqué pour Qwen3.6-27B sur 24 Go

Patches Sandermage Genesis validés sur RTX 5090M (sm_120). TurboQuant 4-bit + MTP n=3 sur Qwen3.6-27B → 60 t/s, 100K contexte, 177K tokens KV.

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.

StackCold (Space Invaders)AVG (3 runs)KV poolContext
Dense MTP + fp8_e5m2 (référence v2.2.2)~90 t/s~90 t/s24K tokens75K
Turbo TQ K8V4 sans MTP40 t/s40 t/s149K tokens128K
Turbo TQ K8V4 + MTP n=337 t/s38 t/s120K tokens80K
Turbo TQ 4bit_nc sans MTP28 t/s224K tokens128K
Turbo TQ 4bit_nc + MTP n=346 t/s60 t/s [46-73]177K tokens100K

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 dtypeMamba block_size
fp8_e5m2 (réf dense)2080
turboquant_k8v42080
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

Crédits

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.

Share this post on:

Commentaires