Il y a trois jours j’ai shippé Qwen3.6 35B-A3B MTP à 249 t/s sur Olares One. Text-only, mais nouveau champion.
Hier j’ai shippé Gemma 4 26B à 250 t/s avec vision et tool calling.
Aujourd’hui le champion Qwen reçoit aussi la vision.
Même GPU 24 Go. Même fichier modèle. 243 t/s text + 262K context + vision input qui marche + Hermes Agent ready, le tout sur un seul endpoint.
Le déclencheur c’est pas un autre modèle. C’est une feature 1-commit que spiritbuun a mergé dans sa fork de llama.cpp le 22 mai, appelée --mmproj-gpu-swap. Je l’ai loupé la première fois. Aujourd’hui j’ai rescanné les news, vu, par coïncidence buildé l’image hier, et ce soir tout s’est aligné.
Le problème qu’on avait
Qwen3.6 35B-A3B est nativement multimodal — Alibaba ship un fichier mmproj avec. Donc en théorie il suffit d’ajouter --mmproj mmproj-BF16.gguf à llama-server pour avoir la vision.
En pratique sur 24 Go consumer Blackwell mobile, ça ne rentre pas. J’ai essayé en v1.0.4 et v1.0.5 de mon app Olares :
- Modèle : 17.2 Go (Q3_K_XL)
- mmproj BF16 : 1.0 Go
- KV cache @ 262K q4_0 : 3.2 Go
- Buffer compute draft MTP @ ubatch 2048 : ~4 Go
- Total : ~25 Go nécessaires, 23.4 Go utilisables
OOM au premier decode. L’encodeur vision a besoin de --ubatch-size ≥ image_max_tokens (≈2048 pour Qwen3.6), et le buffer compute MTP à ubatch 2048 c’est le tueur. J’ai essayé de réduire le context à 64K pour faire de la place. OOM aussi. v1.0.6 a été un revert complet : drop le mmproj, drop la vision, retour au champion text-only à 250 t/s.
Ça avait été classé en memory qwen36-a3b-mtp-vision-fails dans mes notes. “Bloqué sur text-only champion. Utiliser le sibling BeeLlama 27B Vision ou le sibling Gemma Vision pour les use cases image.”
Pendant deux jours, c’était le verdict.
Ce que spiritbuun a compris
spiritbuun/buun-llama-cpp c’est une fork downstream de ggml-org/llama.cpp qui ship des expérimentations speed/features. Ils s’asseyent entre upstream et BeeLlama d’Anbeeld (qui s’assied par-dessus spiritbuun). Le 22 mai à 16:46 UTC, spiritbuun a mergé le commit 8e64d7a :
add --mmproj-gpu-swap: temporarily swap MTP↔mmproj VRAM for vision requests
La documentation README (commit 316e88e, pushée 46 min plus tard) est la description la plus claire que j’aie vue d’un insight engineering d’un paragraphe :
On VRAM-constrained GPUs, MTP speculative decoding and the vision encoder (mmproj) may not fit in VRAM simultaneously. For example, Qwen3.6-27B Q6_K + MTP uses ~22.6 GiB on a 24 GiB RTX 3090, leaving no room for mmproj’s ~1.1 GiB GPU footprint.
--mmproj-gpu-swapsolves this by keeping mmproj on CPU at startup, then temporarily swapping MTP out of VRAM when an image arrives, loading mmproj to GPU for fast encoding (~1-2s instead of 30-60s on CPU), and swapping back afterward. MTP has no persistent state, so the swap is lossless.
Le truc c’est que les deux choses qui se battent pour la VRAM ne sont jamais utilisées en même temps. Quand tu décodes du texte, t’as pas besoin de l’encodeur vision. Quand tu traites une image, t’as pas besoin des têtes de draft MTP. Donc garder une sur GPU, une sur CPU, et swap selon ce que la requête courante demande.
Le côté lossless compte aussi. Les têtes MTP draft n’ont pas d’état persistant entre requêtes — c’est juste des matrices de poids qui tournent en parallèle du modèle target. Donc les démonter de la VRAM et les reconstruire plus tard ne perd rien.
Ce que j’ai shippé
J’avais buildé l’image spiritbuun hier dans le cadre d’une expérimentation non-liée (tester si le path spiritbuun-direct battait le path Anbeeld-layer sur le variant DFlash — il ne le battait pas, mais l’image traînait sur disque). L’image c’est aamsellem/buun-llama-cpp:316e88e. Buildée pour amd64+CUDA13+sm_120 depuis spiritbuun HEAD au commit qui ajoute --mmproj-gpu-swap.
Config bench :
--model Qwen3.6-35B-A3B-UD-Q3_K_XL.gguf
--mmproj mmproj-BF16.gguf
--mmproj-gpu-swap
--spec-type draft-mtp --spec-draft-n-max 3
--cache-type-k q4_0 --cache-type-v q4_0
--parallel 1 --flash-attn on --jinja
Pas de --ctx-size, pas de --batch-size, pas de --ubatch-size. Avec --mmproj-gpu-swap activé, le server tourne en auto-fit — il calcule combien de VRAM est disponible en tenant compte du headroom de swap, et pick le max context qui rentre.
Il a pick 262144 (262K full natif). C’est la fenêtre entière que le modèle supporte. Comparé aux 64K que j’avais dû accepter dans la tentative v1.0.4 ratée, c’est un bump de 4× le context depuis un seul flag.
Le bench
10 runs back-to-back de complétion Space Invaders HTML, 2500 tokens chacun, single user :
run1: 243.44 t/s | run6: 241.62 t/s
run2: 242.52 t/s | run7: 237.60 t/s
run3: 242.79 t/s | run8: 247.18 t/s
run4: 251.39 t/s | run9: 243.64 t/s
run5: 244.69 t/s | run10: 241.01 t/s
AVG 243.59 t/s. Range 13.79. σ ≈ 4.
vs le baseline v1.0.6 text-only (250.54 t/s) qui était ma référence pour “le LLM local le plus rapide sur Olares One” : -2.8%.
Je prends, à toute heure. Les 7 t/s que j’ai perdus c’est le coût de gestion à tourner avec --mmproj-gpu-swap toujours activé même quand aucune image n’est traitée. En échange j’ai le support vision, la fenêtre 262K complète, et un endpoint unifié qui gère toutes les modalités.
Ensuite le test vision. PNG 64×64 rouge plein, “What single color is this image? One word only.” :
elapsed: 1.13s
response: "Red"
Server logs de cette requête :
srv swap_mtp_to_: swapping MTP out, loading mmproj to GPU...
srv swap_mtp_to_: MTP→mmproj swap done in 213 ms
srv swap_mmproj_: unloading mmproj from GPU, recreating MTP...
srv swap_mmproj_: mmproj→MTP swap done in 540 ms
213 ms in + 540 ms out = ~750 ms d’overhead swap au total. Encoder l’image elle-même c’est rapide une fois mmproj sur GPU. End-to-end 1.13s pour l’aller-retour complet sur une image 64×64 incluant HTTP, swap-in, encode, generate, swap-out.
Le nouveau leaderboard
| Stack | t/s | Context | Vision | Tool calling |
|---|---|---|---|---|
| Qwen3.6 35B-A3B MTP + Vision (v2.0.0, aujourd’hui) | 243.59 | 262K | ✓ | ✓ |
| Gemma 4 26B Vision MTP (v1.0.5, hier) | 250.54 | 128K | ✓ | ✓ |
| Qwen3.6 35B-A3B MTP text-only (v1.0.7) | 250.54 | 262K | ✗ | ✓ |
| BeeLlama Qwen 3.6 27B Vision | 106.43 | 200K | ✓ | ✓ |
| Gemma 4 26B no-spec (baseline v1.0.0) | 135.97 | 128K | ✓ | ✓ |
Pour les agents qui mixent texte + image + tool calling, le variant Qwen gagne maintenant :
- Légèrement plus lent que l’endpoint Gemma Vision (243 vs 250 t/s)
- Mais 2× plus de context (262K vs 128K)
- Et les scores BFCL/MCPMark de Qwen 3.6 sont plus hauts que ceux de Gemma 4 pour la tool calling reliability
Pour les agents pure-texte qui n’ont pas besoin de vision, le variant text-only Qwen garde l’avantage throughput (250 vs 243). Je vais garder les deux shippés — pick selon le use case.
Pourquoi ça compte au-delà d’Olares One
Le consumer Blackwell mobile c’est une des cibles les plus VRAM-constrained intéressantes qui existent. 24 Go sur une seule GPU c’est assez pour un modèle MoE-A3B 17 Go + KV + MTP, mais pas tout à fait assez pour mmproj en plus. Le trick --mmproj-gpu-swap c’est exactement le genre d’engineering last-mile qui transforme “presque” en “rentre avec marge confortable”.
Ça marche aussi sur n’importe quelle autre carte 24 Go consumer. RTX 3090, RTX 4090, RTX 5090 desktop — même problème, même fix. Si tu fais tourner Qwen 3.6 ou n’importe quel MoE-A3B avec MTP sur une carte 24 Go et veux aussi la vision, ce flag c’est ton unlock.
L’approche entière généralise. Une fois qu’on accepte que les têtes MTP draft n’ont pas d’état persistant et peuvent être démontées + reconstruites, on peut imaginer d’autres choses VRAM-resident qui pourraient swap pour des requêtes occasionnelles :
- Pourrait swap MTP pour un drafter différent sur certains patterns de prompt
- Pourrait swap mmproj pour un encodeur audio quand un input audio arrive
- Pourrait swap un mixture d’experts pour un autre quand un hint de domaine est donné
Curieux de voir jusqu’où va cette idée. Pour l’instant, le swap vision c’est la win immédiate.
Ce que j’ai shippé
llamacppqwen36a3bone v1.0.7 → v2.0.0 sur ma source market Olares.
Bump major parce que le support vision c’est une capability fondamentalement nouvelle — même app, catégorie différente. Studio Market la montre maintenant sous les filtres “LLM Chat” et “Vision”.
- Image :
aamsellem/buun-llama-cpp:316e88e - Target :
unsloth/Qwen3.6-35B-A3B-MTP-GGUFUD-Q3_K_XL (17.2 Go) - Encodeur vision :
mmproj-BF16.ggufdu même repo (~900 Mo) - Args :
--mmproj-gpu-swap --spec-type draft-mtp --spec-draft-n-max 3 --cache-type-k q4_0 --cache-type-v q4_0 --parallel 1 --flash-attn on --jinja - Context : auto-fit (résout à 262K full natif)
Pull depuis https://orales-one-market.aamsellem.workers.dev si t’as Olares One.
Coda
Je continue à le dire et ça continue à être vrai : le hardware est fixe, le software continue à bouffer le problème. Même RTX 5090M 24 Go. Même fichier modèle Qwen 3.6 35B-A3B sur disque. Il y a trois jours : 250 t/s champion text-only. Hier : champion Gemma ajoute vision. Aujourd’hui : champion Qwen ajoute vision. Aucun de ces wins n’a requis que j’écrive une seule ligne de CUDA — des contributeurs communautaires ont fait le boulot upstream et downstream. Je dois juste continuer à redéployer.
Prochain à surveiller : quand Anbeeld va rebase sa fork BeeLlama sur le nouveau commit spiritbuun. Ça stackerait --mmproj-gpu-swap avec le control adaptatif DFlash drafter d’Anbeeld + la polish Hermes-friendly. Si ça atterrit, le sibling BeeLlama Vision pourrait potentiellement battre les deux endpoints sur l’axe speed. On verra.