Salut les amis !
J’avais pas prévu d’écrire ce soir.
Il était 23h passées, ma Cuisinart faisait infuser un thé que je n’aurais pas le courage de boire, et je rangeais les notes d’une journée que je voulais oublier rapidement. Vous savez ces journées où tu as l’impression d’avoir fait beaucoup de mouvements et d’être resté exactement à la même place ? Voilà.
Le matin, je m’étais dit : aujourd’hui, je casse le plafond. Mon app Qwen3.6 27B Long Context tournait sur mon Olares à 65 tokens/seconde sur 128K de contexte. Honnête mais pas top. Et sur Reddit, depuis quelques semaines, il y avait cette ref qui me hantait — 80 t/s sur 262K, faisable sur une 4090 24GB. Même VRAM que mon 5090M Laptop. Donc en théorie, faisable chez moi aussi. Sauf que dans la vraie vie, j’étais à 65 t/s sur la moitié du contexte. Frustrant.
Le matin
Café, terminal, j’attaque par le truc le plus prometteur sur le papier : les modèles NVFP4, qui exploitent les nouveaux tensor cores natifs de Blackwell. C’est ce que NVIDIA pousse pour leur gen actuelle. J’ai trouvé une variante du Qwen3.6 qui pèse 20 GB en NVFP4. Ça devrait fitter dans mes 24 GB, plus le drafter MTP, plus le KV cache.
Je télécharge, je deploy. Le target charge bien. Le drafter externe (3.5 GB en BF16) se charge ensuite. Et là, OOM. J’ai 1.9 GiB de libre, le drafter en demande 2.4. Cinq cents méga-octets de manque.
Bon. Je baisse la mémoire utilization à 0.92, je reload. OOM. Je passe en --enforce-eager pour économiser les CUDA graphs. OOM. Je teste max-model-len à 8K pour réduire les buffers. OOM, toujours pareil, à la même hauteur.
Le problème est mathématique : le target NVFP4 occupe 20.96 GiB en VRAM (PyTorch overhead inclus), il reste 1.9 GiB pour tout le reste, et le drafter à lui seul demande 2.4. Pas la peine de continuer.
Midi
Je passe au plan B : builder ma propre image llama.cpp. Sur GitHub, j’ai repéré un fork qui combine officiellement les deux choses qui me manquent — la nouvelle technique MTP en cours de merge dans llama.cpp upstream, et la quantization TurboQuant qui compresse le KV cache. Sur le papier, c’est exactement la recette Reddit dont je rêve.
Je clone, j’écris un Dockerfile, je lance le buildx en émulation amd64 sur mon Mac. Et là, première leçon de la journée : un build CUDA en émulation, c’est lent. Vraiment lent. 45 minutes pour compiler les kernels. Pendant ce temps, je relis mes notes, je code autre chose, je réfléchis à ma to-do du week-end.
Le binaire compile sans erreur. Je push l’image sur Docker Hub. Je deploy le pod. Je lance mon bench standard — générer un Space Invaders en HTML, 2000 tokens, trois passes consécutives.
Premier run : 53 t/s. Plus lent que ma version actuelle. Bon, c’est un peu décevant mais pas absurde — peut-être un cold start. Deuxième run : 55 t/s. Ok. Troisième run.
Le troisième run prend 171 secondes pour 2000 tokens. 11 t/s. Je regarde les logs : l’acceptance MTP est passée à 0%. Le drafter a complètement collapse. Le fork marchait sur les RTX 4090 où il avait été développé, mais sur le Blackwell consumer mobile, quelque chose dans le cache Mamba se dégrade au fur et à mesure de l’utilisation. Régression silencieuse, invisible quand tu fais un seul bench.
Je nettoie. 45 minutes de build, deux heures de troubleshooting, et je retourne au point zéro. Avec en bonus la connaissance précieuse que ce truc-là ne marchera pas, ce qui ferme une avenue mais n’en ouvre pas.
Après-midi
Je teste d’autres trucs. Genesis (la stack vLLM custom de Sandermage dont je parle souvent ici) avec un autre format de KV cache, du 3-bit lossless plutôt que 4-bit : bug de couche layernorm dans le code FLA, le bug est upstream et il n’y a pas de fix dispo aujourd’hui. Je teste un boost de mémoire utilization à 0.98 : je manque de 20 mégaoctets, vingt. Vingt méga sur 24 giga. Soupir.
Je teste num_speculative_tokens=6 au lieu de 3, parce qu’un build Windows prétend que ça donne 158 t/s sur RTX 5090 desktop. Échec : Qwen3.6 a une seule couche MTP, et la réutiliser six fois fait baisser l’acceptance, c’est même écrit explicitement dans un warning vLLM que je n’avais pas pris au sérieux.
Vers 17h, j’ai épuisé toutes mes idées techniques pour aujourd’hui. Je décide de poster proprement deux commentaires upstream — un sur la PR MTP de llama.cpp, un sur l’issue du fork buun-llama-cpp (un autre fork expérimental qu’on a déjà croisé sur ce blog) — pour signaler la régression Blackwell consumer et pousser pour un fix. C’est une consolation. Au moins j’ai contribué quelque chose, même si je n’ai rien gagné côté perf.
Le soir
Je rangeais mes notes pour mettre à jour la mémoire de mon agent (oui, je code mes outils dev avec leur propre mémoire persistante, c’est devenu une habitude depuis qu’ils gardent des memory files), et je me suis dit : tant qu’à faire, je relis les findings HuggingFace de la semaine.
Et là, dans une liste de modèles que j’avais survolée trois jours plus tôt, un nom : havenoammo.
Un repo sobrement nommé Qwen3.6-27B-MTP-UD-GGUF. La date de publication, deux jours plus tôt. Je clique parce que j’ai envie d’avoir mal une dernière fois avant de fermer le laptop.
Trois choses m’ont fait dresser l’oreille en lisant la fiche.
D’abord, la taille. La version Q3_K_XL pèse 15 GB. Mon target actuel pèse 17 GB. Cette différence de 2 GB, c’est exactement la VRAM qui me manquait pour étendre mon contexte. Ça mérite déjà l’attention.
Ensuite, le tag “Unsloth Dynamic”. Je connaissais le nom, je n’avais jamais creusé ce que ça veut dire concrètement. Je clique sur la page Unsloth, je lis. Et là je comprends ce que je n’avais jamais vraiment compris : Unsloth Dynamic, ce n’est pas “un Q3 plus petit”. C’est un mix de précisions par couche. Les couches qui comptent pour la qualité — l’attention, l’embedding, le head qui produit les tokens, la tête MTP qui drive le speedup en speculative decoding — restent en 6 ou 8 bits. C’est uniquement les couches FFN dense, qui sont quasi-redondantes avec assez de paramètres, qui descendent à 3 bits. Le résultat moyen c’est “Q3”, mais en interne c’est tout sauf uniforme.
Pour mon use case ça veut dire que la tête MTP — celle qui conditionne tout mon speedup — reste en haute précision alors qu’elle était en 4-bit avec mon target précédent.
Troisième chose : aucun bench public sur consumer Blackwell pour ce repo. Personne ne semble l’avoir testé en combinaison avec MTP sur du sm_120. Vide statistique. Si ça marche ou si ça casse, ce sera moi qui le saurai en premier.
Je copie l’URL, je modifie une ligne dans mon chart Helm. Je remplace le path de mon GGUF actuel par le path du Q3_K_XL d’havenoammo. Je passe le contexte à 262144. Je save. Je deploy.
L’instant
Le pod boot en deux minutes. Le modèle se charge, llama.cpp lit la tête MTP, l’enregistre, configure le draft head. Je lance mon bench standard.
J’attends, le terminal tourne. Le premier prompt génère son Space Invaders. 25 secondes pour 2000 tokens.
80 tokens par seconde.
Je relis le chiffre trois fois. C’est plus haut que tout ce que j’ai vu aujourd’hui. C’est la barre Reddit. Je m’attends à une régression au deuxième run, comme tout à l’heure avec le fork custom — le moment où le drafter collapse et où la magie disparaît. Je relance.
Run 2 : 75 t/s. Run 3 : 76 t/s. Pas de collapse. Pas de dégradation. L’acceptance affichée par les logs reste stable autour de 75-80% sur les trois runs.
Je vérifie qu’il tourne bien sur les 262K de contexte. Oui, full natif. C’est dans les logs, c’est dans la config. Pas un fallback à 128K masqué.
Je regarde l’heure. 23h47. La journée vient de pivoter en cinq minutes.
Pourquoi ça marche
Je vais essayer d’expliquer simplement ce qui se passe parce que c’est important pour comprendre que ce n’est pas un coup de chance.
Quand on fait du speculative decoding avec MTP, c’est le drafter qui propose les tokens en avance. Plus il propose juste, plus on génère vite — parce que le target n’a qu’à valider en parallèle au lieu de générer un par un. Si l’acceptance est de 50%, on a un facteur de speedup limité. Si elle monte à 80%, on multiplie pratiquement la vitesse par deux.
Or l’acceptance dépend directement de la qualité des prédictions du drafter. Et la qualité des prédictions dépend de la précision dans laquelle la tête MTP a été quantizée. En quant standard 4-bit, la tête MTP est à la même précision que le reste du modèle : 4 bits. Ça suffit pour 64% d’acceptance, ce que j’avais.
En Unsloth Dynamic Q3_K_XL, le target moyen est plus petit (3-bit pour les FFN), mais la tête MTP est à 6 ou 8 bits — donc plus précise que dans mon ancien target Q4. Le drafter “voit plus net”. Acceptance qui passe à 75-80%.
L’effet contre-intuitif c’est que le quant moyen plus petit donne un drafter plus précis, parce que ce n’est pas la moyenne qui compte mais la précision de la sous-partie qui drive le speedup.
L’autre cadeau, c’est les 2 GB de VRAM économisés sur le target moyen. Ces 2 GB, c’est le KV cache supplémentaire qui me manquait pour passer de 128K à 262K. Donc même contexte étendu, et même speedup amélioré, en même temps. Les deux choses que j’essayais d’avoir séparément depuis des semaines.
L’honnêteté qualité
Je ne vais pas vous vendre du rêve : Q3, c’est plus agressif que Q4. Sur les benchmarks de qualité standards, la perplexity perd entre 1 et 3% par rapport à Q4_K_M. C’est mesurable.
Unsloth Dynamic mitige fortement parce que les couches qui font la qualité restent en haute précision. Pour du chat ou du code, la différence est invisible à l’usage. Pour de la math compétition ou du raisonnement très précis, il existe une variante Q4_K_XL plus grosse (18 GB) qui sera plus safe — au prix de retomber à 128-160K de contexte.
Pendant que j’écris ces lignes, j’ai démarré un bench de cette version Q4_K_XL en parallèle. Si elle tient ses promesses sans trop sacrifier le speedup, je publie un follow-up sur le compromis exact.
La vraie leçon
Pendant huit heures, j’ai essayé d’attaquer le problème par le code. Builder une image custom, débugger des kernels, merger des forks, lire des PR upstream. Toute la complexité technique que je connais bien.
Et la solution, c’était de regarder les données. Une page HuggingFace que j’avais survolée trois jours plus tôt sans m’arrêter. Un nom de quelqu’un que je ne connaissais pas — havenoammo, je n’ai même pas encore réussi à savoir qui c’est exactement, juste un username — qui avait fait un travail très propre de quantization avec une approche qui résout exactement mon problème.
Le réflexe que je vais essayer d’installer, c’est : avant de toucher au code, ratisser HuggingFace avec les bons termes. UD, Dynamic, MTP-preserved, Heretic. Voir si quelqu’un n’a pas déjà résolu mon problème par la donnée plutôt que par le runtime. Parce que dans cet écosystème, il y a beaucoup de gens silencieux qui font des choses extrêmement précises, et qui ne font pas de bruit sur Twitter ou Reddit.
Cette fois, c’était havenoammo. Un nom que je ne connaissais pas il y a vingt-quatre heures. Qui débloque un cap qu’aucune optimisation runtime n’avait passé sur ce hardware. Et qui me rappelle qu’en 2026, dans cet écosystème open-source, les vrais leviers sont parfois ailleurs que là où on regarde par défaut.
Comment l’installer
Si vous avez ma source de market sur Olares :
Olares Studio → Market → Settings → Add source
https://orales-one-market.aamsellem.workers.dev
L’app Qwen36 27B Long Context v1.0.5 est dans la catégorie AI. Premier launch télécharge le 15 GB de GGUF depuis HuggingFace (HF token requis dans vos settings Olares pour le pull). Endpoint OpenAI-compatible sur port 8000 une fois booté.
Pour ceux sur autre chose qu’Olares mais sur 24 GB de VRAM, le combo essentiel : havenoammo/Qwen3.6-27B-MTP-UD-GGUF + le fichier Q3_K_XL + n’importe quelle build llama.cpp avec support MTP + --ctx-size 262144 --cache-type-k q4_0 --spec-type mtp --spec-draft-n-max 5. Reproduit l’essentiel.
À très vite avec le follow-up sur la version Q4_K_XL.