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 -- 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ême binaire, mêmes poids -- trois ensembles de lois différents qui gouvernent le débit.

Voici ce que j'ai mesuré.


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 sur le Ryzen, les résultats étaient contradictoires :

Longueur prompt   Prefill tok/s   Decode tok/s
100 tokens        191             21.6
1 000 tokens      247             20.8
10 000 tokens     134             14.8

Le prefill accélère quand on multiplie le prompt par 10 -- puis s'effondre quand on recommence. Le decode se dégrade lentement, presque indifférent aux variations sauvages du prefill. Même modèle, même code, même machine.

Sur le M3 Ultra, le même schéma -- encore plus prononcé :

Longueur prompt   Prefill tok/s   Decode tok/s
100 tokens        224             19.5
1 000 tokens      316             19.0
10 000 tokens     145             13.8

Prefill vs Decode : débit par longueur de prompt

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. Chaque optimisation qui aidait le decode était sans effet sur le prefill. Certaines optimisations 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 code, 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 flux d'instructions. Même cœur. 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 du prefill sur le Ryzen 9 (12 cœurs physiques, 24 logiques avec SMT, répartis sur deux chiplets Zen 4) :

1 thread:   29.7 tok/s     (1.00x)
4 threads:  95.8 tok/s     (3.23x, 81% efficiency)
8 threads:  151.2 tok/s    (5.09x, 64% efficiency)
12 threads: 141.8 tok/s    (4.77x — PLUS LENT que 8)
16 threads: 147.7 tok/s    (4.97x — récupère à peine)

Dépasser 8 threads nuit au prefill. L'architecture Zen 4 répartit les 12 cœurs sur deux chiplets (CCD), chacun avec son propre cache L3 de 32 Mo. Franchir la frontière CCD à 12 threads introduit de la latence inter-chiplets. Puis à 16 threads, le SMT entre en jeu : 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.

Mais pour le decode, la situation est inversée :

8 threads:  10.2 tok/s
12 threads: 10.5 tok/s
16 threads: 11.1 tok/s (+8.8% vs 8T)
24 threads: 9.0 tok/s  (oversubscription collapse)

Le SMT aide le decode de 9%. Même machine. Comportement de passage à l'échelle opposé. Parce que le decode est limité par la latence (en attente de la DRAM), et le SMT remplit les bulles de pipeline pendant que l'autre thread attend. Le prefill est limité par la bande passante (streaming à travers le L2), et le SMT entre en compétition pour cette bande passante.


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à : à 100 tokens, le prefill tourne à 191 tok/s sur le Ryzen. À 1 000 tokens, il culmine à 247 -- 29% plus rapide malgré 10x plus de travail. Les matrices à 100 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 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 :

  1. 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.

  2. 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 2.35x.

  3. 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 ?

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.