Inférence CPU #1 : les trois régimes
Le débit d'inférence n'est pas un scalaire.
Tout le monde mesure l'inférence d'un LLM avec un seul nombre : "X tokens par seconde." Je faisais pareil. Puis j'ai construit un moteur d'inférence CPU pour un modèle de 30 milliards de paramètres en partant de zéro (en Rust, avec de l'assembleur écrit à la main) et ce nombre a cessé d'avoir un sens.
Ce n'est pas une question de prompting. Ni de choix de modèle. C'est une question d'allocation de ressources au moment de l'inférence, là où le vrai goulot d'étranglement se déplace selon ce que le matériel est réellement en train de faire. J'ai identifié trois régimes physiques distincts. Même moteur, mêmes poids. Trois ensembles de lois différents qui gouvernent le débit.
Voici ce que j'ai mesuré.
Les trois regimes d'inference
- Streaming Prefill : prompt long, grandes matrices. Le CPU calcule. Limite par la bande passante L2.
- Dispatch-Bound Prefill : prompt court, petites matrices. Le surcout de synchronisation domine le calcul.
- Memory-Bound Decode : un token a la fois. Le CPU attend les donnees. Limite par la bande passante DRAM.
Le modèle est Qwen3-30B-A3B. Une architecture Mixture-of-Experts. 48 couches transformer. 32 têtes d'attention, 4 têtes KV (GQA 8:1). Dimension cachée 2048. C'est un vrai modèle de production et je l'exécute sur deux machines : un AMD Ryzen 9 7900 avec AVX-512 (12 cœurs, Zen 4) et un Apple M3 Ultra avec NEON (28 cœurs). Pas de GPU. Juste des cœurs CPU, des hiérarchies de cache et des bus mémoire.
Quand j'ai benchmarké le moteur en Q4, les résultats ne formaient aucune ligne droite :
Ryzen 9 7900 M3 Ultra Prompt tokens Prefill Decode Prefill Decode 13 47.4 21.7 34.9 28.1 36 68.6 21.6 103.9 28.0 81 76.0 21.6 171.4 27.9 182 87.9 21.5 244.4 26.2 461 88.5 22.4 342.3 28.1 1 096 89.8 21.6 347.7 26.5 1 795 87.2 20.8 309.2 25.2 4 239 79.5 18.3 223.5 21.7 8 134 69.0 15.2 152.2 17.6
Le prefill monte, atteint un plateau entre 500 et 1 100 tokens, puis décline. Le M3 Ultra culmine à 348 tok/s (près de 4x le Ryzen) grâce à ses 28 cœurs et sa bande passante mémoire unifiée. Le decode reste plat autour de 22 tok/s (Ryzen) et 28 tok/s (M3 Ultra) sur presque tout le spectre, puis chute au-delà de 2 000 tokens quand le KV cache déborde en DRAM.
Prefill vs Decode : débit par longueur de prompt
Même modèle. Même code. Deux courbes qui ne se ressemblent pas. Ces nombres cachent trois réalités complètement différentes.
Le prefill est un problème de calcul. Votre prompt contient S tokens. Chaque couche effectue trois multiplications matricielles (projections Q, K, V), un calcul d'attention et un réseau feed-forward. Les matrices sont grandes : [S, 2048] x [2048, 4096] pour la projection Q seule. Cela fait 16 millions de multiplications-additions par couche. Multiplié par 48 couches. Le CPU fait du vrai travail arithmétique. Tous les cœurs sont occupés. L'IPC est élevé.
Le decode est un problème de mémoire. Vous générez un token à la fois. Chaque couche exécute toujours les mêmes opérations, mais maintenant S=1. Une multiplication matricielle devient un produit matrice-vecteur. [1, 2048] x [2048, 4096] ne représente que 8 millions de multiplications-additions, mais vous ne pouvez réutiliser chaque poids qu'une seule fois. L'intégralité du tenseur de poids du modèle doit transiter par le CPU une fois par token généré. Pour un modèle de 30B en quantification Q4, cela représente environ 500 Mo de trafic DRAM par token généré. Le CPU ne calcule pas. Il attend les données.
Même modèle. Même chemin de code. Deux goulots d'étranglement complètement différents. C'est la première frontière de régime : le Streaming Prefill est saturé en calcul, le Memory-Bound Decode est affamé en bande passante. Optimiser pour l'un, c'est ignorer l'autre. Optimiser pour les deux à la fois, c'est n'optimiser pour aucun.
J'ai appris cela à mes dépens. Chaque optimisation qui aidait le prefill était sans effet sur le decode et réciproquement. Certaines aidaient l'un et nuisaient activement à l'autre.
Mais ça empire. Même au sein d'une seule phase, la performance n'est pas constante.
J'ai profilé chaque couche du modèle pendant le prefill avec un prompt de 1954 tokens. Voici ce que j'ai observé :
Layer 0: attn=6442ms ffn=552ms total=7004ms Layer 8: attn=3203ms ffn=520ms total=3726ms Layer 17: attn=3837ms ffn=400ms total=4240ms Layer 29: attn=6448ms ffn=561ms total=7015ms Layer 47: attn=6187ms ffn=567ms total=6759ms
Les couches 8, 17, 27, 36, 45 sont presque 2x plus rapides que leurs voisines. Même architecture, même format de poids, mais des patterns de routage MoE différents. Certaines couches activent des ensembles d'experts plus creux, produisant moins de travail. Le temps d'exécution par couche varie d'un facteur deux au sein de la même passe d'inférence.
Puis j'ai mesuré le débit du kernel d'attention en isolation, en faisant varier la taille du KV-cache sur un seul cœur :
10 positions (0.0 MB KV in L1) → 92 GFLOP/s 100 positions (0.4 MB KV in L2) → 84 GFLOP/s 1000 positions (3.9 MB KV in L3) → 91 GFLOP/s 10000 positions (39 MB KV in DRAM) → 56 GFLOP/s 17000 positions (66 MB KV in DRAM) → 45 GFLOP/s
Même kernel. Même cœur. Même flux d'instructions. Même fréquence d'horloge. 50% de variation de débit selon l'endroit où le KV cache se trouve dans la hiérarchie mémoire. Et la relation n'est même pas monotone : le L3 est plus rapide que le L2, parce que le prefetcher matériel excelle sur le streaming séquentiel L3 mais la capacité du L2 force des évictions.
Ensuite vous ajoutez des threads. Et la physique change à nouveau.
J'ai mesuré le passage à l'échelle sur le Ryzen 9 (12 cœurs physiques, 24 logiques avec SMT, répartis sur deux chiplets Zen 4) avec un prompt de 1 000 tokens :
Threads Prefill tok/s Decode tok/s 1 9.7 (1.0x) 5.4 (1.0x) 2 19.5 (2.0x) 10.3 (1.9x) 4 38.2 (3.9x) 16.8 (3.1x) 6 54.3 (5.6x) 17.8 (3.3x) 8 68.6 (7.1x) 21.6 (4.0x) 10 80.3 (8.3x) 21.6 (4.0x) 12 89.7 (9.2x) 21.6 (4.0x) ← 12 cœurs physiques 14 63.8 (6.6x) 20.8 (3.9x) ← CLIFF SMT 16 71.4 (7.4x) 20.5 (3.8x) 20 76.3 (7.9x) 20.1 (3.7x) 24 79.9 (8.2x) 18.9 (3.5x)
Thread scaling : prefill vs decode sur Ryzen 9
Le prefill passe à l'échelle quasi linéairement jusqu'à 12 cœurs physiques (9.2x pour 12x). Puis à 14 threads, le SMT entre en jeu et le prefill s'effondre de 29%. Le cache L2 fait 1 Mo par cœur, partagé entre les siblings SMT. Chaque couche d'attention streame 3.8 Mo de données KV, près de 4x ce que le L2 peut contenir. Ajouter un second thread logique par cœur divise par deux le L2 effectif à 512 Ko/thread et le prefetcher matériel s'effondre. Le prefill ne récupère jamais son pic de 12 threads, même à 24T.
Le decode, lui, sature dès 8 threads (21.6 tok/s) et ne bouge plus. Au-delà, chaque thread logique ajouté dégrade légèrement le débit : 20.8 à 14T, 18.9 à 24T. Le SMT n'aide pas non plus, parce que le decode est déjà limité par la bande passante DRAM, pas par la latence d'une instruction. Il n'y a pas de bulle de pipeline à remplir.
Puis vous changez le format de quantification. Et les deux régimes réagissent en sens inverse.
J'ai comparé Q4 (4 bits, avec déquantification) et int8 (8 bits, arithmétique entière native) sur le Ryzen avec 12 threads :
Q4 Int8 Prompt tokens Prefill Decode Prefill Decode 81 76.1 21.7 83.2 14.7 1 096 89.6 21.6 107.7 14.5 8 134 69.5 15.2 80.4 11.4
Q4 vs Int8 : le compromis prefill/decode
Le prefill int8 est 20% plus rapide : il évite l'étape de déquantification Q4→f32 et utilise directement les instructions VNNI du Ryzen. Mais le decode Q4 est 49% plus rapide : les poids Q4 font deux fois moins de trafic DRAM que les poids int8.
Le trade-off est limpide : int8 pour le calcul, Q4 pour la bande passante. Et comme le decode domine le temps total en inférence conversationnelle, le Q4 gagne globalement.
Mais le vrai pipeline n'est pas linéaire.
Tout ce qui précède suppose une séquence propre : un prefill, puis du decode. En pratique, avec les modèles augmentés d'outils, ça ne se passe jamais ainsi.
Prenons un scénario concret. L'utilisateur demande : "Quelle est l'erreur dans ce fichier ?" : 20 tokens, plus un system prompt et un historique de conversation d'environ 200 tokens. Prefill total : 220 tokens. Les matrices sont petites. Le surcoût de dispatch et de synchronisation des threads domine l'arithmétique réelle. C'est le Dispatch-Bound Prefill, le troisième régime, celui qui n'apparaît pas dans les benchmarks ci-dessus parce que je mesurais avec des prompts de 100 tokens et plus. Mais l'indice est déjà là : à 81 tokens, le prefill tourne à 76 tok/s sur le Ryzen. À 1 096 tokens, il culmine à 90, soit 18% plus rapide malgré 13x plus de travail. Et sur le M3 Ultra, de 171 à 348 tok/s. Les matrices à 81 tokens sont déjà limites. En dessous, le surcoût de dispatch domine.
Le modèle décode un appel d'outil : 30 tokens. Chaque token streame l'intégralité du tenseur de poids à travers la DRAM. Memory-Bound Decode.
L'outil retourne 5 000 tokens de contenu de fichier. Le moteur exécute un second prefill, mais cette fois les matrices sont grandes. [5000, 2048] × [2048, 4096]. Tous les cœurs saturent. Le prefetcher matériel streame le L2 à pleine bande passante. Streaming Prefill, un régime physique complètement différent du premier prefill, déclenché au sein de la même passe d'inférence.
Le decode reprend. Mais maintenant le KV cache contient 5 250 positions au lieu de 220. Le kernel d'attention qui tournait à 92 GFLOP/s avec le petit cache est maintenant plus proche de 56 GFLOP/s, parce que 5 250 positions poussent les données KV du L3 vers la DRAM.
Même binaire. Mêmes poids. Même prompt. Même tour de conversation. Quatre transitions de phase à travers les trois régimes. Et le nombre de tokens par seconde change à chaque transition.
C'est pourquoi le modèle de régimes importe au-delà du benchmarking. Une seule passe d'inférence augmentée d'outils ne vit pas dans un seul régime : elle les traverse tous. Le "tokens par seconde" que vous mesurez dépend entièrement de la phase que vous êtes en train de chronométrer.
La vraie leçon ne concerne pas une optimisation unique. C'est que l'inférence LLM sur CPU n'est pas une seule charge de travail. Ce sont au moins trois régimes physiques distincts :
Streaming Prefill (prompt long, grandes matrices) : limité par la bande passante L2. Le prefetcher matériel compte. Le SMT nuit. Le calcul est abondant. Le CPU travaille.
Dispatch-Bound Prefill (prompt court, KV cache chaud) : dominé par le surcoût. Les unités de travail sont si petites que la synchronisation des threads coûte plus que le calcul lui-même. Passer de 1 à 8 threads ne donne que 4x en decode.
Memory-Bound Decode (un token à la fois) : limité par la bande passante DRAM pour le streaming des poids. Le SMT aide. ~535 Mo de trafic DRAM par token généré. Le CPU attend principalement.
Même moteur. Même binaire. Trois ensembles différents de lois physiques.
Ce n'est pas une distinction académique. Les conséquences en ingénierie sont directes : dimensionnement du pool de threads, stratégie de pages mémoire, partitionnement du cache, format de quantification. Chaque décision dépend du régime dominant. Un chatbot qui répond à des questions courtes vit en Dispatch-Bound Prefill et Memory-Bound Decode. Un pipeline RAG qui traite de longs documents vit en Streaming Prefill. La configuration optimale de chacun est différente, parfois opposée.
Quand quelqu'un vous dit que son moteur d'inférence tourne à "X tokens par seconde", demandez-lui : à quelle longueur de séquence ? Pendant quelle phase ? Sur combien de threads ? Avec quel état de cache ? À froid ou à chaud ?
Points cles
- Le debit d'inference est souvent limite par la bande passante memoire, pas par la puissance de calcul.
- Les phases de prefill et de decode obeissent a des contraintes physiques differentes.
- La strategie d'optimisation depend du regime dominant : pool de threads, format de quantification, politique de cache.
- Un meme tour de conversation peut traverser les trois regimes.
Le débit d'inférence n'est pas un nombre. C'est une fonction du régime.
Et si vous optimisez sans savoir dans quel régime vous êtes, vous optimisez à l'aveugle.
Le code source des experiences presentees dans cet article est disponible sur GitHub.