← retour
Écoutez cet article
Voici un court extrait. La version audio complète, les articles intégraux, les chiffres et les cadeaux sont réservés aux inscrits à l'Espace Abonnés.

Pour écouter la version complète, connectez-vous ou créez votre compte gratuit.

Discutez de cet article avec votre IA
Le bouton copie l'article avec une courte introduction pour le modèle. Collez le tout dans Claude, ChatGPT, Le Chat ou une autre IA, puis posez-lui vos questions.

On descend d'un cran : #001 un compilateur pour moteur de LLM

Décodage de Qwen3.5-4B sur un EPYC 4344P, le 8 octobre 2026 : quatre moteurs sous le plafond physique de la mémoire

Herbert, c'est le moteur que j'écris pour faire tourner des modèles de langage dans mAIstrow, notre système d'IA. Sa première version publique faisait comme tout le monde : des kernels écrits à l'avance, un par opération. Développer ce code prend énormément de temps. Et ces kernels finissent par devenir trop spécifiques à un modèle en particulier.

Chercher où un programme passe ses cycles, combien de registres il occupe, quelle unité du processeur travaille vraiment, comment les caches sont sollicités : ce genre de question m'attire depuis mes premières lignes d'assembleur sur un Z80. Alors quand, avec Rodrigue, on a décidé que j'écrirais notre propre moteur, je savais que je finirais par me les poser. On ne l'a pas écrit pour refaire ce qui existe. On l'a écrit pour savoir exactement ce que fait la machine à chaque cycle. Et pour pouvoir le changer.

Et pourtant, la version que je présente ici n'embarque pas une ligne d'assembleur écrite en dur. C'est Herbert lui-même qui écrit le code machine, au démarrage, sur votre machine.

Ce numéro dit comment. Et ce que ça donne face à trois autres moteurs. Il s'arrête juste avant le détail, qui aura ses propres articles.

Un compilateur, pas une bibliothèque de kernels

Un moteur d'inférence classique embarque des kernels écrits à l'avance. Un pour la multiplication de matrices, un pour la normalisation, un pour l'attention, avec des boucles qui lisent leurs bornes au moment de tourner, parce que personne ne connaissait la forme du modèle quand le kernel a été écrit.

Herbert n'embarque rien de tel. Il embarque des émetteurs. Un émetteur par famille de calcul : la projection, la norme, l'attention, la récurrence du mixer Gated DeltaNet, la tête de sortie. Un émetteur ne calcule pas. Il écrit le code qui calculera, pour une forme précise.

Au chargement du modèle, tout est connu. La largeur de chaque couche, le nombre de têtes, la dimension d'une tête, la taille du vocabulaire. Et au démarrage sur une machine, le reste est connu aussi : la largeur des registres, les instructions que ce processeur accepte, le nombre de cœurs physiques. Les émetteurs reçoivent tout ça et posent des constantes là où un kernel classique aurait une variable. Le nombre de tours d'une boucle est un immédiat. Le décalage vers le bloc de poids suivant est un immédiat. Il n'y a pas de code de fin de boucle pour le reste d'une division, parce que la division a été faite une fois, à l'émission.

Une seule dimension reste décidée à l'exécution : le nombre de lignes d'un bloc, c'est-à-dire combien de tokens passent en même temps. Au prefill, c'est le prompt par blocs. Au décodage, c'est une ligne. Pour tout le reste, le code est spécialisé.

C'est pour ça que je parle d'un compilateur pour moteur de LLM. Il prend un modèle et une machine. Il produit le programme qui fait tourner ce modèle sur cette machine. Ce programme n'a plus rien à lire au moment de tourner.

Ces émetteurs me servent aussi d'instrument de mesure. Les idées sur la façon de calculer une multiplication de matrices, d'organiser les poids en mémoire, de choisir quelle quantification appliquer à quels poids, tout le monde les a. La seule chose qui compte, c'est ce que le réel veut bien en dire. Un processeur qui promet une instruction ne promet pas qu'elle soit pertinente : ça dépend de sa micro-architecture, de la façon dont il a été conçu. Retenez bien ça, c'est ce qui reviendra tout au long de la série. Ça se mesure.

Ensuite, ces noyaux sont assemblés. Pour chaque régime, le prefill et le décodage, le moteur construit un plan : une liste de nœuds, un noyau chacun, avec son nombre de tâches, ses dépendances. Puis le plan est écrit dans une seule page de code machine. Chaque thread du pool exécute cette page du premier bloc au dernier. Entre deux nœuds, une barrière, émise dans le code lui-même. Il n'y a pas d'ordonnanceur qui distribue du travail à des exécuteurs. Il y a un programme et des participants qui le parcourent ensemble.

Lire le même émetteur rempli deux fois

Une option écrit sur le disque le code émis pour les deux régimes. Je l'ai lancée sur le Ryzen 9 9900X, en AVX-512, avec deux tailles du même modèle, Qwen3.5-0.8B et Qwen3.5-4B. Voici la même projection, celle de la première couche au décodage, dans les deux cas.

target/release/herbert-bf16-chat ~/models/Qwen3.5-0.8B --dump-asm listings-0.8B
target/release/herbert-bf16-chat ~/models/Qwen3.5-4B --dump-asm listings-4B

Sur le 0.8B, la boucle qui accumule un bloc de seize colonnes de poids :

vxorps        zmm0,zmm0,zmm0
mov           rcx,0x200
prefetcht0    [rsi+0x40]
vmovdqu16     zmm1,[rsi]
vpbroadcastd  zmm2,[rdi]
vdpbf16ps     zmm0,zmm2,zmm1
add           rdi,4
add           rsi,0x40
dec           rcx
jne           short 0x12f

Sur le 4B, la même projection :

vxorps        zmm0,zmm0,zmm0
vxorps        zmm1,zmm1,zmm1
mov           rcx,0x500
prefetcht0    [rsi+0x40]
prefetcht0    [rsi+0x14040]
vmovdqu16     zmm2,[rsi]
vmovdqu16     zmm3,[rsi+0x14000]
vpbroadcastd  zmm4,[rdi]
vdpbf16ps     zmm0,zmm4,zmm2
vdpbf16ps     zmm1,zmm4,zmm3
add           rdi,4
add           rsi,0x40
dec           rcx
jne           short 0x135

Deux choses ont changé. Aucune n'a été écrite par quelqu'un. Le nombre de tours, chargé dans rcx : 0x200, soit 512, d'un côté, 0x500, soit 1 280, de l'autre. C'est la largeur de la couche divisée par deux, puisque chaque vdpbf16ps avale deux valeurs par voie : 1 024 pour le 0.8B, 2 560 pour le 4B. Et la forme du noyau : sur le 4B, l'émetteur a choisi d'accumuler deux blocs de seize colonnes à la fois, dans zmm0 et zmm1, le second bloc lu 0x14000 octets plus loin, soit seize colonnes de 2 560 valeurs bf16. Cette distance aussi est un immédiat.

Même émetteur, deux formes, deux codes. Vous pouvez les refaire chez vous avec la même commande et les comparer aux miens.

Ce que coûte un token

Un moteur a deux régimes. Le prefill traite la question : ces tokens sont déjà connus, on les a tous sous la main, ce qui ouvre des optimisations impossibles autrement. Le décodage avance token par token, de concert avec l'échantillonneur, le petit bout de code qui choisit le suivant. Mêmes modules, deux méthodes. Pour celui qui écrit les kernels, c'est deux fois plus de travail, au minimum. Et les deux régimes n'ont pas les mêmes contraintes physiques.

Au décodage, pour écrire un seul token, le modèle relit tous ses poids. Pour Qwen3.5 en bf16, la partie langage pèse 1,505 Go sur le 0.8B, 3,764 Go sur le 2B, 8,412 Go sur le 4B. Je compte les embeddings en entier, puisqu'ils sont liés à la tête de sortie et relus par elle. Ces chiffres sortent des en-têtes des fichiers safetensors.

Aucun cache de processeur ne retient ça. À chaque token, les poids traversent la mémoire. La vitesse maximale d'un moteur au décodage est donc la bande passante de la mémoire divisée par ces octets. Personne ne passe au-dessus, quel que soit le talent du code.

Compter les tokens par seconde d'un moteur, c'est compter la mémoire de la machine autant que le moteur. La mesure utile est la part de la bande passante que le moteur arrive à tirer.

Tout ça vaut pour une requête à la fois. Quand un serveur sert plusieurs personnes en parallèle, chaque relecture des poids sert à plusieurs tokens. Faire servir plusieurs personnes par Herbert demande des optimisations spécifiques. Ce sera l'objet d'un autre article.

Et tout ça vaut pour des poids en bf16. La quantification, qui réduit les octets à relire à chaque token, tourne déjà en production dans mAIstrow. Elle sera l'objet d'un ou de plusieurs articles, avec la méthode de la série : la mesure d'abord. C'est là que le JIT sert : choisir, machine par machine, le format des poids et le code qui les lit, selon ce que propose le jeu d'instructions du processeur.

Mesurer la limite plutôt que la supposer

La bande passante d'une machine, je ne la lis pas sur la fiche du constructeur. Une sonde la mesure : une lecture séquentielle de la mémoire, émise par le JIT elle aussi, sur tous les cœurs physiques, meilleure de plusieurs passes. Elle dit ce que la machine livre à un programme qui lit comme un moteur lit.

Sur un EPYC 4344P, huit cœurs Zen 4 et de la DDR5-5200, la sonde donne 54,1 Go/s. Sur un Ryzen 9 9900X, douze cœurs Zen 5 avec une DDR5 bridée à 3 600 MT/s, elle donne 44 à 46 Go/s dès deux threads et 50 avec un seul. Sur le 9900X, un seul cœur lit plus vite que douze, parce que la mémoire est le goulot et que les threads se marchent dessus. Du coup, la part de bande passante tirée par un moteur dépend de la sonde qu'on prend comme référence : la sonde au même nombre de threads que le moteur, ou la meilleure sonde de la machine. Avec la première, Herbert est à 98 % sur le 9900X pour le 4B. Avec la seconde, à 86 %. Je donne les deux. Pour le titre, je retiens la sonde au même nombre de threads, parce que c'est elle qui dit ce que ce moteur, avec ces threads, pouvait espérer. L'autre dit ce qu'il reste à gagner en changeant de stratégie. Ce sera un autre article.

Les prédictions, écrites avant

Le 25 septembre 2026, avant de mesurer le 2B, le 4B et la bande passante, j'ai écrit cinq prédictions dans un fichier daté, publié tel quel avec les données. Chaque prédiction dit ce qui la réfuterait.

La première est la borne physique : aucun moteur ne décode plus vite que la mémoire ne livre les poids. La deuxième est la thèse : Herbert tire au moins les trois quarts de la bande passante mesurée sur le 2B et le 4B. La troisième dit que le décodage suit la taille du modèle. La quatrième porte sur le prefill de llama.cpp. La cinquième dit que le décodage suit la machine : le rapport entre le 9900X et l'EPYC suit le rapport de leurs bandes passantes.

Aucune n'a été réfutée. Pour cet article, j'ai refait la campagne le 8 octobre 2026 avec le binaire publié, cinq répétitions au lieu de trois et deux machines de plus. J'ai ajouté des prédictions avant de lancer, dont une qui dit que le binaire publié reproduit l'ancienne campagne à 2 % près au décodage, puis une qui dit qu'à réglages par défaut Herbert décode devant les trois autres partout. La première tient au décodage, à 0,6 % près ; au prefill elle est réfutée, le binaire publié fait mieux ; j'explique pourquoi plus bas. La seconde est réfutée sur une machine, j'y reviens aussi. Le fichier d'évaluation, prédiction par prédiction, est publié avec les données.

Les chiffres

Pour situer Herbert, je le compare à trois moteurs. llama.cpp, le plus répandu, celui que tout le monde fait tourner chez soi, à l'origine de LM Studio ou d'Ollama. Un travail remarquable. OpenVINO, le moteur d'Intel pour son propre matériel. Et vLLM, la référence des serveurs, conçu pour servir plusieurs personnes à la fois. Ici, avec une requête à la fois, je ne l'exploite pas au mieux de sa puissance. Il reste excellent, y compris pour une seule personne.

La comparaison n'est pas là pour désigner le meilleur. Elle sert d'étalon : elle me dit si ce que je mesure est pertinent et si une idée tient face à ce que d'autres ont déjà fait. Ce qui est vrai le 8 octobre 2026 ne le sera pas forcément demain. Tous ces moteurs évoluent, chacun à sa manière et à sa vitesse. Il y a peut-être une compétition, je trouve ça sain. Il y a surtout, pour chaque moteur, un point de vue différent sur le même problème.

Deux machines AVX-512, trois tailles de modèle, quatre moteurs, bf16 partout, une requête à la fois, la même invite de 148 tokens, 64 tokens générés. Les versions : llama.cpp b11177, vLLM 0.30.0 pour CPU avec torch 2.13.0, OpenVINO 2026.4.1 avec le backend SDPA, Herbert au tag v0.1.0. Médianes de cinq répétitions entrelacées, l'ordre des moteurs tourné à chaque répétition, un processus par mesure, dix secondes de repos et une garde d'inactivité avant chaque run. Chaque moteur à son nombre de threads par défaut, un par cœur physique.

Au décodage, sur l'EPYC 4344P, le plafond physique pour le 4B est de 6,43 tokens par seconde. Herbert en sort 6,15. llama.cpp 5,95, OpenVINO 5,83, vLLM 5,60. Les quatre sont sous le plafond. Celui qui s'en approche le plus est le mien, le 8 octobre 2026, avec ces versions. Ça ne sera peut-être plus vrai quand vous lirez ceci. Et l'écart entre les trois premiers est mince. C'est le résultat attendu : quand la mémoire décide, les bons moteurs se ressemblent.

Sur le 2B et le 4B, Herbert tire 95 à 98 % de la bande passante mesurée au même nombre de threads, sur les deux machines : 95,5 et 95,8 % sur l'EPYC, 98,2 et 98,0 % sur le 9900X. Sur le 0.8B, c'est 93 à 95 % : le modèle est petit, les coûts fixes pèsent plus.

décodage, tokens par secondeHerbertllama.cppvLLMOpenVINO
EPYC 4344P, 0.8B33,3031,8826,4328,70
EPYC 4344P, 2B13,7113,5011,9612,79
EPYC 4344P, 4B6,155,955,605,83
Ryzen 9 9900X, 0.8B27,7125,8724,0223,74
Ryzen 9 9900X, 2B11,5111,1510,6310,54
Ryzen 9 9900X, 4B5,124,914,884,81

Le binaire publié reproduit l'ancienne campagne au décodage à moins de 1 % près sur les deux machines. Au prefill, non : il fait 13 à 30 % de mieux que le binaire de septembre. Je sais d'où ça vient. Une modification entre les deux aligne les paquets de poids, le cache KV et les activations sur une ligne de cache. Je l'ai isolée pas à pas : tout l'écart tient à elle, le code émis est identique listing pour listing et le décodage ne bouge pas. Un lecteur qui reproduit trouvera les chiffres de cet article, pas ceux de septembre.

Au prefill, c'est une autre histoire, parce que le prefill est du calcul. Sur le 4B, Herbert fait 282 tokens par seconde sur l'EPYC et 461 sur le 9900X ; vLLM 262 et 418 ; llama.cpp 133 et 276. llama.cpp est entre la moitié et les deux tiers d'Herbert, vLLM un peu derrière. Je donne ces chiffres avec leur machine et leur réglage, pas autrement. Ils valent sur ces deux processeurs AVX-512 BF16, avec ces versions, à un thread par cœur.

Sur une invite de trente-deux tokens en décodage glouton, Herbert et vLLM produisent exactement les tokens de la référence float32 de Hugging Face, sur les trois modèles et les trois machines. llama.cpp bascule au treizième token du 0.8B sur les machines AVX-512, un quasi-ex æquo en bf16, pas sur AVX2. OpenVINO est juste avec le backend SDPA et faux avec son backend par défaut, la paged attention, dès le deuxième token du 0.8B et dès le premier du 4B, en 2026.4.1 comme en 2026.4.0. Je prépare un signalement.

Ce que je n'explique pas encore

D'où vient l'écart au prefill, je ne le dis pas ici. J'ai une hypothèse, elle n'est pas mesurée, donc elle ne vaut rien. Elle sera testée en remplaçant les noyaux de Herbert un par un, dans un article à part.

Et trois résultats ne vont pas dans mon sens. Sur le 9900X, llama.cpp réglé à un seul thread décode plus vite que Herbert à douze : 28,60 contre 27,71 sur le 0.8B, 12,46 contre 11,51 sur le 2B, 5,37 contre 5,12 sur le 4B. Un seul cœur suffit à saturer cette mémoire bridée. Un seul cœur ne paie aucune barrière. Herbert, lui, s'effondre quand on lui donne plus de threads que de cœurs physiques. Je sais pourquoi, ce sera aussi un article. Sur un Core Ultra 7 258V, un portable Lunar Lake qui n'a que l'AVX2, personne n'approche la bande passante, 45 à 69 % de la sonde. vLLM y décode devant Herbert sur le 2B et le 4B, de 0,5 et 2,9 %. J'avais prédit le contraire, c'est écrit. Sur AVX2, au prefill, llama.cpp est devant Herbert partout : d'un facteur 1,7 à 1,9 sur le Lunar Lake, de 37 à 45 % sur un Ryzen 5700G Zen 3. Le chemin AVX2 d'Herbert n'a pas eu le travail du chemin AVX-512. Le code émis n'est pas magique. Il est bon là où il a été travaillé.

Ce Zen 3 dit aussi autre chose. Avec une DDR4 à 34 Go/s, le décodage d'Herbert y tire 91, 97 et 95 % de la sonde, devant llama.cpp de 3 à 6 %. La thèse tient sur une mémoire lente avec un chemin AVX2 pas travaillé. Le même binaire, construit sous Windows 11 avec MSVC sur un Ryzen 5 5500GT, donne la même image : 92, 97 et 97 % de la sonde au décodage, les mêmes tokens que sous Linux et llama.cpp devant au prefill. Le code émis ne sait pas sur quel système il tourne.

Les trois machines AVX2, mêmes réglages, médianes de cinq répétitions ; vLLM n'a tourné que sur le Lunar Lake.

AVX2, tokens par secondedécodage Herbertllama.cppvLLMprefill Herbertllama.cppvLLM
Core Ultra 7 258V, 0.8B38,8730,9536,90141260133
Core Ultra 7 258V, 2B17,3114,4817,406410783
Core Ultra 7 258V, 4B7,596,507,81254232
Ryzen 7 5700G, 0.8B20,7819,61·287417·
Ryzen 7 5700G, 2B8,638,38·134184·
Ryzen 7 5700G, 4B3,873,74·5274·
Ryzen 5 5500GT, Windows, 0.8B18,5017,58·154300·
Ryzen 5 5500GT, Windows, 2B7,837,64·88136·
Ryzen 5 5500GT, Windows, 4B3,503,40·3455·

La question qui se pose

Si le décodage est borné par la mémoire et si le prefill est du calcul, alors un GPU devrait pouvoir en faire autant, ou plus. Sa mémoire est plus rapide et il calcule plus vite. La question n'est pas là.

La question, c'est : avec le même principe ? Du code émis au démarrage, sans bibliothèque du constructeur, avec le même contrat numérique, bf16 partout et accumulateurs f32, des sorties identiques token par token. Est-ce que ça tient sur un GPU, face aux moteurs GPU de référence ? Je ne réponds pas ici. C'est ce que la série va regarder, backend par backend, en commençant par Vulkan.

Reproduire chez soi

Le dépôt est en ligne : https://github.com/xigh/herbert-jit-bf16/. Construire, lancer la sonde, lancer le décodage :

git clone --branch v0.1.0 https://github.com/xigh/herbert-jit-bf16.git
cd herbert-jit-bf16 && cargo build --release --locked
target/release/herbert-bf16-chat ~/models/Qwen3.5-4B --no-think --ask 'Quelle est la capitale de la France ?'

La sonde est un petit programme à part, probe-bw, dans le dépôt des mesures : une boucle de lectures séquentielles émise par iced-x86, un thread par cœur physique, la meilleure de plusieurs passes.

Les scripts de la campagne, les prédictions et les données brutes, machine par machine, sont publiés avec cet article : github.com/xigh/herbert-cpu-bench, tag c1-2026-10-08, dossier campaign-c1/. Si un chiffre vous semble faux, vous avez de quoi le vérifier.

On descend d'un cran, ou plusieurs...

  1. On descend d'un cran : #000 un moteur d'inférence qui écrit son propre code
  2. On descend d'un cran : #001 un compilateur pour moteur de LLM