2 · Le Tensor Memory Accelerator¶
Le mécanisme le plus important de Hopper. Une unité matérielle dédiée qui copie des tenseurs multidimensionnels entre mémoire globale et mémoire partagée, sur ordre d'un seul thread.
2.1 Le problème que ça résout¶
Avec cp.async, charger une tuile \(128 \times 64\) de bf16 demande :
- que chaque thread calcule son adresse source et destination ;
- de gérer les bords (que faire si la tuile dépasse la matrice ?) ;
- de gérer le swizzling pour éviter les conflits de banc ;
- d'émettre une instruction par thread, soit 256 instructions pour un bloc.
Tout ce calcul d'adresses consomme des registres et des cycles d'exécution, dans une boucle qui devrait ne faire que du produit matriciel.
TMA supprime tout cela.
2.2 Le principe¶
TMA repose sur un descripteur de tenseur (CUtensorMap), une structure de
128 octets construite côté hôte, qui décrit une fois pour toutes :
- l'adresse de base du tenseur en mémoire globale ;
- son nombre de dimensions (jusqu'à 5) ;
- la taille de chaque dimension ;
- les pas (strides) de chaque dimension ;
- la forme de la tuile à copier ;
- le mode de swizzle appliqué à la destination ;
- le mode de remplissage des bords (zéros ou NaN) ;
- le type d'élément.
Ensuite, dans le noyau, un seul thread émet la copie en donnant juste des coordonnées :
cp.async.bulk.tensor.2d.shared::cluster.global.mbarrier::complete_tx::bytes
[%dst_smem], [%tensor_map, {%coord_x, %coord_y}], [%mbarrier];
Le matériel fait le reste : calcul des adresses, gestion des bords, swizzling,
et incrémentation du compteur d'octets de la mbarrier.
L'idée à retenir
TMA transforme « 256 threads calculent 256 adresses » en « 1 thread donne 2 coordonnées ». Le calcul d'adresses passe du logiciel au matériel.
C'est ce que la documentation appelle un modèle mono-thread : a single thread in a warp is elected to issue an asynchronous TMA operation, et tout le bloc continue à travailler pendant que les données sont en vol.
2.3 Construire un descripteur¶
Côté hôte, avec l'API pilote :
#include <cuda.h>
CUtensorMap creer_descripteur(void* base, int lignes, int colonnes) {
CUtensorMap desc;
uint64_t taille[2] = { (uint64_t)colonnes, (uint64_t)lignes };
uint64_t pas[1] = { (uint64_t)colonnes * sizeof(__nv_bfloat16) };
uint32_t tuile[2] = { 64, 128 }; // forme de la tuile en mém. partagée
uint32_t pas_elem[2]= { 1, 1 };
cuTensorMapEncodeTiled(
&desc,
CU_TENSOR_MAP_DATA_TYPE_BFLOAT16,
2, // rang
base,
taille,
pas,
tuile,
pas_elem,
CU_TENSOR_MAP_INTERLEAVE_NONE,
CU_TENSOR_MAP_SWIZZLE_128B, // ← swizzle géré par le matériel
CU_TENSOR_MAP_L2_PROMOTION_L2_128B,
CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE // bords remplis de zéros
);
return desc;
}
Le descripteur est ensuite passé au noyau, soit par argument (il doit être
aligné sur 64 octets et déclaré const __grid_constant__), soit via la mémoire
constante.
__global__ void noyau(const __grid_constant__ CUtensorMap desc_A,
const __grid_constant__ CUtensorMap desc_B) { ... }
Les contraintes du descripteur
- L'adresse de base doit être alignée sur 16 octets ;
- le pas de la dimension la plus interne doit être un multiple de 16 octets ;
- la destination en mémoire partagée doit être alignée sur 128 octets ;
- le mode de swizzle contraint la largeur de la tuile (32, 64 ou 128 octets).
Une violation ne produit pas d'erreur claire : elle produit des données fausses. C'est la principale raison pour laquelle on utilise CUTLASS.
2.4 Émettre une copie dans le noyau¶
#include <cuda/barrier>
#include <cudaTypedefs.h>
__global__ void gemm_tma(const __grid_constant__ CUtensorMap desc_A, ...) {
__shared__ alignas(128) __nv_bfloat16 As[128][64];
__shared__ cuda::barrier<cuda::thread_scope_block> bar;
if (threadIdx.x == 0) {
init(&bar, blockDim.x);
cuda::device::experimental::fence_proxy_async_shared_cta();
}
__syncthreads();
// Un seul thread émet la copie
if (threadIdx.x == 0) {
cuda::device::experimental::cp_async_bulk_tensor_2d_global_to_shared(
&As[0][0], &desc_A, coord_x, coord_y, bar);
// Annoncer les octets attendus
cuda::device::barrier_expect_tx(bar, sizeof(As));
}
// Tout le bloc attend l'arrivée des données
bar.arrive_and_wait();
// Utiliser As[][]
}
Le point crucial est barrier_expect_tx : il indique à la mbarrier combien
d'octets doivent arriver. Le matériel TMA incrémente le compteur au fur et à
mesure ; l'attente se débloque quand le compte est atteint.
C'est exactement l'usage pour lequel mbarrier a été conçue.
2.5 Ce que TMA sait faire d'autre¶
Copies dans les deux sens¶
TMA gère aussi mémoire partagée → mémoire globale
(cp.async.bulk.tensor...global.shared), avec les mêmes descripteurs. Utile pour
l'épilogue d'une GEMM.
Multicast vers un cluster¶
C'est la fonctionnalité la plus sous-estimée. Une seule copie TMA peut écrire dans la mémoire partagée de plusieurs blocs d'un cluster :
cp.async.bulk.tensor.2d.shared::cluster.global.multicast::cluster ...
Dans une GEMM, la même tuile de \(\mathbf{A}\) est nécessaire à tous les blocs d'une même ligne de tuiles. Sans multicast, chaque bloc la lit depuis la HBM. Avec multicast, elle est lue une fois et distribuée.
Gain : divise le trafic HBM par le nombre de blocs du cluster (typiquement 2 ou 4).
Remplissage des bords¶
Le mode out-of-bounds fill remplit automatiquement de zéros les parties de la tuile qui dépassent le tenseur. Cela supprime toutes les gardes de bord du noyau — un gain de code et de divergence considérable dans les convolutions et les GEMM de dimensions non multiples de la tuile.
im2col¶
TMA a un mode dédié à la convolution qui effectue la transformation im2col à
la volée pendant la copie. La convolution devient une GEMM sans matérialiser la
matrice intermédiaire.
2.6 Ce que TMA change dans le raisonnement¶
1. Le calcul d'adresses disparaît du budget d'instructions. Dans une GEMM classique, une fraction non négligeable des instructions sert à calculer des adresses. Elles disparaissent.
2. Les registres se libèrent. Plus d'adresses ni de valeurs en transit dans les registres. Ces registres vont aux accumulateurs, donc à des tuiles plus grandes, donc à une intensité arithmétique supérieure.
3. L'occupancy cesse d'être le levier. Avec un seul thread qui alimente le bloc en permanence, il n'est plus nécessaire d'avoir 64 warps pour maintenir la mémoire occupée. On peut descendre à 4 ou 8 warps et tout donner aux registres et à la mémoire partagée.
4. Le modèle producteur/consommateur devient naturel. Un warp dédié émet les TMA, les autres calculent. C'est la spécialisation des warps.
Le lien avec wgmma
TMA et wgmma sont conçus ensemble. wgmma lit ses opérandes directement
en mémoire partagée via un descripteur ; TMA les y dépose avec le bon
swizzle. La combinaison donne un pipeline où les données ne touchent jamais
les registres entre la HBM et le tensor core.
C'est ce qui permet à wgmma d'atteindre 95 % du pic Hopper contre 62,9 %
pour l'instruction mma classique
(arXiv:2501.12084).
2.7 Le prix à payer¶
TMA est difficile
- Les descripteurs doivent être construits côté hôte, ce qui complique le code dynamique (formes variables).
- Les contraintes d'alignement et de swizzle sont nombreuses et leur violation est silencieuse.
- Le débogage est pénible : la copie est asynchrone et effectuée par une unité matérielle séparée.
- Le code n'est pas portable :
sm_90aminimum, pas de repli automatique.
En pratique, on utilise :
- CUTLASS / CuTe (
SM90_TMA_LOAD,TiledCopy) ; - ThunderKittens, qui expose
tma::load_async(tuile, ...); - Triton, dont le compilateur émet TMA depuis
tl.loadsur des descripteurs (tl.make_tensor_descriptor) ; - CuTe DSL en Python.
2.8 Blackwell : ce qui s'y ajoute¶
Blackwell conserve TMA et lui ajoute :
- le support des copies vers la Tensor Memory (
tcgen05.cp) ; - le Cluster Launch Control, qui permet à un cluster de demander dynamiquement le prochain lot de travail au lieu d'un mapping statique — utile pour l'équilibrage de charge dans les noyaux persistants ;
- des modes de swizzle supplémentaires adaptés aux formats à échelle par blocs (MXFP).
Le chapitre wgmma et tcgen05 détaille l'articulation.
Résumé du chapitre¶
À retenir
- TMA copie des tenseurs jusqu'à 5D entre mémoire globale et mémoire partagée, sur ordre d'un seul thread.
- Le descripteur (
CUtensorMap, 128 octets) est construit côté hôte et encode tailles, pas, forme de tuile, swizzle et remplissage des bords. - Il se synchronise avec une
mbarrieren modeexpect_tx: on attend l'arrivée effective des octets. - Le multicast vers un cluster divise le trafic HBM par le nombre de blocs.
- Le remplissage automatique des bords supprime les gardes du noyau.
- Conséquence stratégique : les registres se libèrent, les tuiles grandissent, et l'occupancy cesse d'être le levier principal.
- À utiliser via CUTLASS, ThunderKittens ou Triton, pas à la main.
Vérifiez que vous avez compris¶
Pourquoi TMA a-t-il besoin d'une mbarrier plutôt que d'un simple __syncthreads() ?
Parce que __syncthreads() synchronise les threads, et qu'ici il n'y a
qu'un seul thread impliqué dans l'émission. Après le __syncthreads(), tous
les threads auraient franchi la barrière alors que les données ne sont pas
encore arrivées : la copie est effectuée par une unité matérielle
asynchrone.
La mbarrier en mode expect_tx attend un nombre d'octets, ce qui est
la condition réelle. C'est précisément pour TMA que ce mécanisme a été
conçu.
Dans une GEMM avec un cluster de 2 blocs, le multicast TMA divise le trafic HBM par combien ?
Par 2 sur les opérandes partagés. Concrètement, si les deux blocs du cluster calculent des tuiles de \(\mathbf{C}\) sur la même bande de lignes, ils ont besoin de la même tuile de \(\mathbf{A}\). Sans multicast, elle est lue deux fois depuis la HBM ; avec, une seule fois.
Sur une GEMM où \(\mathbf{A}\) et \(\mathbf{B}\) contribuent également au trafic, le gain global est de ~25 % (moitié du trafic divisée par 2). Avec des clusters de 4, ~37 %.
Pourquoi le mode « remplissage des bords » de TMA est-il plus intéressant qu'il n'y paraît ?
Trois gains cumulés.
- Suppression du code de garde : plus de
if (i < N)dans la boucle chaude. - Suppression de la divergence : les gardes de bord font diverger le dernier warp de chaque bloc.
- Uniformité des tuiles : toutes les tuiles ont la même forme, donc le même chemin de code, donc pas de spécialisation ni de branchement.
C'est aussi ce qui permet aux noyaux wgmma d'avoir une boucle interne
entièrement déroulée et sans branchement — condition nécessaire pour
approcher le pic.
Chapitre suivant : 3 · Clusters et mémoire partagée distribuée
Sources de ce chapitre¶
- CUDA C++ Programming Guide — TMA
- Colfax Research, CUTLASS Tutorial: Mastering the NVIDIA Tensor Memory Accelerator
- Deep Dive on the Hopper TMA Unit for FP8 GEMMs, PyTorch blog
- NVIDIA Hopper Architecture In-Depth
- PTX ISA — Bulk asynchronous copy
- Dissecting the NVIDIA Hopper Architecture, arXiv:2501.12084