← retour
Ne ratez plus rien.
Un email à chaque nouvel article, les annonces des formations à venir, rien d'autre. Les news complètes de la veille, les chiffres et les cadeaux sont réservés aux inscrits à l'Espace Abonnés.
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.

Layout tile-major et allocations mémoire d'un transformer

Quand on écrit un moteur d'inférence CPU, deux questions reviennent en permanence : comment sont stockées les matrices en mémoire, et combien de mémoire faut-il au total. Cet article couvre les deux. D'abord le layout tile-major utilisé pour toutes les matrices de projection, puis le dimensionnement complet -- poids du modèle, buffers runtime et KV cache -- pour mon moteur d'inférence Qwen3.


Layout tile-major

Le problème du row-major

En layout row-major classique, une matrice [N, K] est stockée ligne par ligne : les K éléments de la ligne 0, puis ceux de la ligne 1, etc. Pour un embedding lookup (lire la ligne correspondant à un token), c'est parfait : on lit K valeurs contiguës en mémoire.

Mais pour une multiplication matrice-vecteur (matvec), la situation est différente. Le calcul de y = W * x nécessite, pour chaque élément de sortie y[i], le produit scalaire de la ligne i de W avec le vecteur x. Jusque-là, pas de problème. Mais si on veut vectoriser en traitant 32 lignes simultanément (ce qui est naturel avec AVX-512 en f16), il faut lire la même colonne de 32 lignes adjacentes. En row-major, ces 32 valeurs sont espacées de K éléments chacune -- des accès stridés qui détruisent la localité du cache.

Le principe du tile-major

Le layout tile-major réorganise une matrice [N, K] en [N/32, K, 32]. Concrètement :

C'est exactement une ligne de cache. Un seul accès mémoire charge les 32 éléments nécessaires pour un produit scalaire partiel sur 32 lignes simultanées.

Pourquoi ça change tout

En row-major, traiter 32 lignes en parallèle sur une colonne nécessite 32 accès mémoire (un par ligne, avec un stride de K × 2 octets). En tile-major, c'est un seul accès contigu de 64 octets. Pour une matrice [1024, 1024] en f16, on passe de 32 cache misses potentiels à 1 seul par colonne traitée. Sur des milliers de colonnes et des dizaines de couches, le gain est massif.


Matrices par couche

Chaque couche transformer stocke les matrices suivantes :

MatriceDimensionsTailleLayout
q_proj[1024, 1024]2 MoTile-major
k_proj[512, 1024]1 MoTile-major
v_proj[512, 1024]1 MoTile-major
o_proj[1024, 1024]2 MoTile-major
gate_proj[3072, 1024]6 MoTile-major
up_proj[3072, 1024]6 MoTile-major
down_proj[1024, 3072]6 MoTile-major

Total par couche : ~24 Mo de matrices.

Les quatre premières (q_proj, k_proj, v_proj, o_proj) sont les projections de l'attention. Q et O ont la dimension complète du modèle (1024) en sortie, tandis que K et V projettent vers une dimension réduite (512) grâce au Grouped-Query Attention.

Les trois suivantes (gate_proj, up_proj, down_proj) forment le réseau feed-forward (FFN). gate_proj et up_proj expandent de 1024 vers 3072 (facteur 3x). down_proj ramène de 3072 vers 1024. Le gate_proj contrôle l'activation via SiLU avant multiplication avec la sortie de up_proj.

Toutes ces matrices sont stockées en tile-major. Aucune exception.


Allocations mémoire totales

Poids du modèle

ComposantCalculTaille
Embeddings (row-major)151 936 × 1 024 × 2311 Mo
Embeddings (tile-major)151 936 × 1 024 × 2311 Mo
Final norm1 024 × 22 Ko
28 couches28 × 24 Mo672 Mo
Total~1,3 Go

Pourquoi deux copies des embeddings ? La matrice d'embeddings sert à deux opérations distinctes :

  1. Embedding lookup (début du modèle) : on lit la ligne correspondant à un token. Le row-major est optimal -- une lecture séquentielle de 1024 valeurs f16.
  2. LM head (fin du modèle) : multiplication matrice-vecteur pour projeter le hidden state vers le vocabulaire (151 936 classes). Le tile-major est optimal -- accès contigus par tuiles de 32 lignes.

Dupliquer la matrice coûte 311 Mo supplémentaires. C'est un compromis mémoire/performance délibéré : chaque layout est optimal pour son usage spécifique. La copie tile-major est réorganisée à partir de embed_tokens au chargement du modèle.

Buffers runtime

Mode decode (génération token par token) :

BufferTaille
Buffers temporaires~320 Ko
Cache RoPE~16 Ko (pour max_seq_len = 32 768)
KV cacheallocation dynamique

Mode prefill (traitement du prompt par batch) :

BufferTaille
Buffers batch~12 Mo (pour MAX_PREFILL_LEN = 512)
KV cacheallocation dynamique

Les buffers de prefill sont plus grands car on traite jusqu'à 512 tokens simultanément. En decode, on ne traite qu'un seul token à la fois, d'où des buffers bien plus compacts.


KV cache par couche

Le KV cache est alloué par chunks de 256 tokens. Pas de réallocation à chaque token -- on alloue un chunk entier quand le précédent est plein.

Dimensionnement d'un chunk

Un chunk couvre 256 positions pour toutes les têtes d'attention :

Exemples concrets

Pour une séquence de longueur L :

SéquenceChunksKV cache par coucheKV cache total (28 couches)
5 tokensceil(5/256) = 1512 Ko14 Mo
8 tokens (5 + 3)ceil(8/256) = 1512 Ko14 Mo
300 tokensceil(300/256) = 21 Mo28 Mo
1024 tokensceil(1024/256) = 42 Mo56 Mo

Avec 5 tokens, un seul chunk est alloué. En ajoutant 3 tokens (pour un total de 8), on reste dans le même chunk -- pas de nouvelle allocation. Le chunk ne sera étendu que lorsqu'on dépassera 256 tokens.

Pourquoi des chunks

L'allocation par chunks de 256 tokens évite la réallocation à chaque token généré. C'est un compromis entre fragmentation mémoire (un chunk de 512 Ko pour 5 tokens, c'est du gaspillage) et coût d'allocation (réallouer à chaque token serait catastrophique). 256 est un bon point d'équilibre : assez grand pour amortir les allocations, assez petit pour ne pas gaspiller trop de mémoire en fin de chunk.


Récapitulatif

Pour un prompt de 8 tokens sur Qwen3 (28 couches, 1024 hidden, 8 KV heads × 64 dim) :

ComposantTaille
Poids du modèle~1,3 Go
Buffers runtime (decode)~336 Ko
KV cache (28 couches × 1 chunk)14 Mo
Total~1,3 Go

Le KV cache est négligeable pour des séquences courtes. C'est sur des contextes longs (milliers de tokens) qu'il commence à peser. À 32 768 tokens (la limite du modèle), il atteindrait 28 × 128 × 512 Ko = 1,75 Go -- presque autant que les poids du modèle.