Aller au contenu principal
own2pwn
gpu/gpu-modele.tsx

Coder pour le GPU : threads, blocks, grids

Kernels, threads, warps, grilles de thread-blocks, mémoire unifiée : tout le modèle de programmation GPU illustré via SAXPY en CUDA. Mise en regard de PTX et SASS pour l'architecture NVIDIA.

Maxime Jérôme··10 min de lecture

Prérequis

  • Comprendre le fonctionnement d'un CPU
    • Unités de calcul (ALU, IFU, SFU, FPU, ...)
    • Pipelining et exécution spéculative
    • Caches
  • Introduction aux GPUs

Hello ! o/

Dans cet article on verra comment est fait le modèle de programmation GPU. C'est-à-dire comment on programme dessus et comment le comprendre grossièrement. Le GPU peut être considéré comme une extension du CPU qui permet de l'aider dans ses calculs. Le CPU envoie les instructions au GPU et attend ses résultats sans pour autant que ça soit une action bloquante.

Aujourd'hui il y a plusieurs choses qui font que les CPUs ne sont plus aussi évolutifs qu'avant : l'augmentation de la fréquence entrainait forcément une réduction du voltage pour une consommation modérée, mais aujourd'hui le voltage ne peut plus descendre plus bas. La relation est simple : Puissance (W) = Fréquence x Voltage². Et si aujourd'hui on descendait encore le voltage, la distinction des entrées binaires ne se ferait plus correctement : il serait complexe de faire la différence entre un 0 et un 1. Du coup, au lieu d'augmenter la fréquence, on a exploité le principe de parallélisme. On a besoin d'un composant qui permette de faire plusieurs opérations en même temps avec un coût en puissance le plus faible possible : le GPU.

Par conséquent le CPU et le GPU sont techniquement proches et loin en même temps :

  • La fréquence du GPU est inférieure à celle du CPU.
  • Le GPU utilise de la mémoire GDDR ou HBM2 alors que le CPU utilise du DDR.
  • La latence du GPU est camouflée par son haut niveau de multithreading.
  • Le CPU peut partager sa mémoire avec un autre CPU, contrairement aux GPUs.
  • Les GPUs sont programmables, ils sont Turing-complete.
  • Pour une puce performante sur des opérations parallèles, faire une seule opération sera lente, d'autant plus que la mémoire GPU n'est pas partageable.
  • Les GPUs sont architecturalement plus simples que les CPUs : pas de prédiction de branche, pas d'exécution out-of-order.
Le GPU, une puce d'accélération
Le GPU ne remplace pas le CPU. C'est une puce d'accélération : le CPU reste le chef d'orchestre, le GPU exécute les gros calculs en parallèle.
cpu-out-of-order.txt
  CPU - Exécution out-of-order (OoO)
  ─────────────────────────────────────────────
  Programme (ordre logique)     Pipeline CPU
  ─────────────────────────     ─────────────────────────────────────
  1. LOAD  R1, [addr_A]    →    [Fetch] [Decode] [Issue queue]
  2. ADD   R2, R1, R3           ↓ dépend de R1 (attente)
  3. LOAD  R4, [addr_B]    →    exécutée EN AVANCE (indépendante)
  4. MUL   R5, R4, R6      →    exécutée EN AVANCE
  5. ADD   R7, R2, R5           ↓ attend R2 et R5

  Résultat : instructions 3 et 4 s'exécutent avant 2
             sans changer le résultat final.
  GPU : SIMT, pas d'OoO. Chaque warp suit un chemin linéaire.
Exécution out-of-order sur CPU : les instructions peuvent se réordonner librement pour maximiser le débit. Le GPU n'a pas ce mécanisme.

Mémoire unifiée et flow d'exécution

Une application dédiée au GPU commence à s'exécuter sur le CPU. Quand les GPUs partageaient le même cache que le CPU, les deux unités se partageaient la mémoire : c'était un GPU intégré. Aujourd'hui on a des GPU discrets avec leur propre mémoire GDDR. C'est l'architecture Pascal de Nvidia qui a introduit le support hardware et software pour transférer automatiquement les données CPU vers GPU via la mémoire virtuelle que Nvidia appelle la mémoire unifiée.

unified-memory.txt
  Avant Pascal (GPU discret classique)      Depuis Pascal (mémoire unifiée)
  ──────────────────────────────────        ───────────────────────────────────
  CPU RAM  ←─ PCIe ─→  GPU VRAM            CPU RAM  ─┐
  cudaMemcpy() explicite                   GPU VRAM ─┤  espace d'adressage
  h_ptr ≠ d_ptr                            cuDMA     │  virtuel UNIFIE
                                           ptr unique┘
  Code :                                   Code :
  cudaMalloc(&d_x, size);                  cudaMallocManaged(&x, size);
  cudaMemcpy(d_x, h_x, ...);              saxpy<<<...>>>(n, a, x, y);
  saxpy<<<...>>>(n, a, d_x, d_y);         // pas de cudaMemcpy !
  cudaMemcpy(h_x, d_x, ...);
Mémoire unifiée (Nvidia Pascal+) : le driver gère les transferts CPU-GPU de manière transparente via la mémoire virtuelle.

Avec l'aide d'un driver qui s'exécute sur le CPU, le code de l'application GPU (aussi appelé kernel ou noyau) définit quelles portions de code devront s'exécuter sur le GPU, le nombre de threads à lancer et où sont les données à utiliser. Le driver fait donc beaucoup d'opérations pour donner au GPU toutes les informations nécessaires.

Les coeurs Nvidia sont appelés Streaming Multiprocessors (SM) et les coeurs AMD Compute Units. Chaque coeur GPU exécute un programme SIMT (Single Instruction, Multiple Thread), par exemple une opération d'addition sur un millier de données : le kernel. Chaque coeur GPU peut exécuter environ 1 000 threads.

gpu-sm-architecture.txt
  GPU
  ┌──────────────────────────────────────────────────────┐
  │  SM 0          SM 1          SM 2         SM N-1     │
  │ ┌──────┐      ┌──────┐      ┌──────┐     ┌──────┐   │
  │ │Block │      │Block │      │Block │     │Block │   │
  │ │  0   │      │  1   │      │  2   │     │ ...  │   │
  │ │256 th│      │256 th│      │256 th│     │256 th│   │
  │ └──────┘      └──────┘      └──────┘     └──────┘   │
  │  SIMT          SIMT          SIMT          SIMT      │
  │                                                      │
  │         Mémoire GDDR / HBM2 (globale)                │
  └──────────────────────────────────────────────────────┘
  Chaque SM = ~1000 threads actifs en même temps
Architecture SM (Streaming Multiprocessor) : chaque SM exécute un ou plusieurs thread-blocks en parallèle via son modèle SIMT.

Source : Wikipédia

SIMD, MIMD et SIMT

Selon la Taxonomie de Flynn, les GPUs modernes sont basés sur un système SIMD (Single Instruction, Multiple Data), ce qui leur permet d'exploiter le parallélisme au niveau des données. Mais au lieu d'exposer un système SIMD brut au programmeur, les APIs comme CUDA (Compute Unified Device Architecture) et OpenCL offrent un modèle de programmation MIMD (Multiple Instructions, Multiple Data) pour exécuter un grand nombre de threads scalaires sur le GPU.

flynn-taxonomy.txt
  ┌──────────────────────────────────────────────────────────────┐
  │           Taxonomie de Flynn                                 │
  │                                                              │
  │  SISD                        SIMD                           │
  │  Single Instr / Single Data  Single Instr / Multiple Data   │
  │  → CPU classique (1 coeur)   → GPU interne, SSE/AVX CPU     │
  │                                                              │
  │  MISD                        MIMD                           │
  │  Multiple Instr / Single D.  Multiple Instr / Multiple D.   │
  │  → rare (tolérance de faute) → CUDA/OpenCL vu du prog.      │
  │                              → multi-coeurs CPU, clusters   │
  └──────────────────────────────────────────────────────────────┘
Taxonomie de Flynn : les 4 modèles d'execution. Le GPU expose MIMD au programmeur mais exécute en SIMD en interne.

Source : Parallel Programming, Concepts and Practice

Chacun de ces threads scalaires peut suivre son propre chemin d'exécution et peut accéder arbitrairement à des emplacements mémoire. A l'exécution, un groupe de threads scalaires est nommé warp chez Nvidia et wavefront chez AMD. Ces warps sont exécutés en même temps sur le système SIMD, d'où le terme SIMT.

warps-wavefronts.txt
  Thread-block (256 threads)
  ─────────────────────────────────────────────────────
  Warp 0  : threads  0 -  31  ──→  SIMD unit (32 lanes)
  Warp 1  : threads 32 -  63  ──→  SIMD unit
  Warp 2  : threads 64 -  95  ──→  SIMD unit
  ...
  Warp 7  : threads 224 - 255 ──→  SIMD unit

  Nvidia : warp     = 32 threads
  AMD    : wavefront = 64 threads

  Tous les threads d'un warp exécutent la MEME instruction
  au même cycle (lock-step). Si des threads divergent
  (if/else), les branches sont sérialisées : SIMT divergence.
Warp (Nvidia) / Wavefront (AMD) : groupe de threads scalaires exécutés en lock-step sur le hardware SIMD.

Source : Rendered Insecure: GPU Side Channel Attacks are Practical

SAXPY : exemple concret CPU vs GPU

Chaque kernel est composé de milliers de threads, et chaque thread exécute le même programme, mais certains threads vont suivre un flow de contrôle différent selon les dépendances du programme. Un exemple basique : SAXPY (Single precision scalar value A times vector value X Plus vector value Y), soit grossièrement A * X + Y. SAXPY fait partie de la bibliothèque BLAS (Basic Linear Algebra Subprograms). Voici le code CPU :

c
void
saxpy (int n,
       float a,
       float * x,
       float * y)
{
    for (int i = 0; i < n; ++i)
    {
        y[i] = a * x[i] + y[i];
    }
}

void
main (void)
{
    float * x, * y;
    int n;
    // malloc x et y et mettre les données dedans
    saxpy(n, 2.0, x, y) ;
    // free x et y
}

Sur un code dédié au GPU, avec l'API CUDA de Nvidia, le code est totalement différent :

cuda
__global__ void
saxpy (int n,
       float a,
       float * x,
       float * y)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n)
        y[i] = a * x[i] + y[i];
}

void
main (void)
{
    float * h_x, * h_y; // host memory pointer
    int n;
    // malloc h_x et h_y et initialiser les données
    float * d_x, * d_y ; // device memory pointer
    int nblocks = (n + 255) / 256 ;
    cudaMalloc(&d_x, n * sizeof(float));
    cudaMalloc(&d_y, n * sizeof(float));
    cudaMemcpy(d_x, h_x, n * sizeof(float), cudaMemcpyHostToDevice);
    cudaMemcpy(d_y, h_y, n * sizeof(float), cudaMemcpyHostToDevice);
    saxpy<<<nblocks, 256>>>(n, 2.0, d_x, d_y);
    cudaMemcpy(h_x, d_x, n * sizeof(float), cudaMemcpyDeviceToHost);
    // free h_x, h_y, d_x, d_y
}

Ce qu'il faut comprendre ici : on divise notre tableau en chunks de taille 256 appelés thread-blocks. Chaque thread-block est exécuté sur un coeur GPU (un SM). Un groupe de thread-blocks est appelé une grid de thread-blocks. Lors de l'appel saxpy<<<nblocks, 256>>>, nblocks représente la taille de la grid et 256 le nombre de threads par thread-block.

grid-blocks-threads.txt
  Grid (nblocks thread-blocks)
  ┌─────────────────────────────────────────────────────┐
  │  Block 0          Block 1          Block nblocks-1  │
  │ ┌───────────┐    ┌───────────┐    ┌───────────┐     │
  │ │ t0  t1    │    │ t0  t1    │    │ t0  t1    │     │
  │ │ t2  t3    │    │ t2  t3    │    │ t2  t3    │     │
  │ │ ...       │    │ ...       │    │ ...       │     │
  │ │ t254 t255 │    │ t254 t255 │    │ t254 t255 │     │
  │ └───────────┘    └───────────┘    └───────────┘     │
  │   → SM 0           → SM 1          → SM k           │
  └─────────────────────────────────────────────────────┘

  Chaque thread calcule son index global :
  i = blockIdx.x * blockDim.x + threadIdx.x
      (numéro du bloc × taille du bloc + position dans le bloc)
Hiérarchie grid / thread-block / thread dans CUDA. Chaque thread connait sa position via blockIdx, blockDim et threadIdx.

Mémoire partagée et synchronisation

Il y a plusieurs types de mémoire dans la programmation GPU/parallèle :

TypeEmplacementPortéeVitesse
RegistresSM (on-chip)1 threadTres rapide
Mémoire partagéeSM (on-chip)1 thread-blockRapide
Mémoire localeGDDR/HBM (off-chip)1 threadLente
Mémoire globaleGDDR/HBM (off-chip)Tous les threadsLente

La mémoire partagée est plus rapide que la mémoire locale et globale (stockées off-chip dans la GDDR ou en cache CPU). Elle est directement sur le GPU (on-chip). Tous les threads d'un thread-block ont accès à la même mémoire partagée. Les accès concurrents ne posent pas de problème grâce à un accès sérialisé : les requêtes concurrentes sont séparées en plusieurs requêtes sans conflit, ce qui réduit l'efficacité de la bande passante GPU. L'exception : quand tous les threads d'un warp ont la même adresse de mémoire partagée, ça résulte en un broadcast, très utile pour la synchronisation.

La synchronisation de threads est supportée par des unités hardware qui font barrière à l'exécution d'instructions.

shared-vs-global-memory.txt
  SM (Streaming Multiprocessor)
  ┌────────────────────────────────────────────────────────┐
  │                                                        │
  │  Thread-Block 0                                        │
  │  ┌──────────────────────────────────────────────┐     │
  │  │  t0    t1    t2    ...    t255                │     │
  │  │   │     │     │            │                  │     │
  │  │   └─────┴──┬──┴────────────┘                  │     │
  │  │            ▼                                  │     │
  │  │    Shared Memory (on-chip, ~48KB)             │     │
  │  │    acces rapide, portee = 1 block             │     │
  │  └──────────────────────────────────────────────┘     │
  │             │                                          │
  └─────────────│──────────────────────────────────────────┘
                │
                ▼
  GDDR / HBM - Mémoire Globale (off-chip, GBs)
  acces lent, portee = tous les threads de la grid
  ─────────────────────────────────────────────────────────
  Broadcast : si tous les threads d'un warp lisent
  la meme adresse → 1 seule lecture + diffusion
Mémoire partagée (shared) vs mémoire globale dans le modèle CUDA. La shared memory est on-chip, partagée au sein du thread-block.

Source : ArmorAll: Compiler-based Resilience Targeting GPU Applications

ISA GPU : PTX et SASS

Le GPU, comme le CPU, contient son propre ISA (Instruction Set Architecture). Depuis l'introduction de CUDA, l'ISA est haut niveau et virtuel, aussi appelé PTX (Parallel Thread Execution ISA). Le PTX est assez similaire aux instructions d'une architecture processeur RISC (ARM, MIPS, SPARC, etc). Voici le PTX du code CUDA SAXPY ci-dessus :

text
.visible .entry _Z5saxpyifPfS_(
.param .u32 _Z5saxpyifPfS__param_0,
.param .f32 _Z5saxpyifPfS__param_1,
.param .u64 _Z5saxpyifPfS__param_2,
.param .u64 _Z5saxpyifPfS__param_3
)
{
.reg .pred %p<2>;
.reg .f32 %f<5>;
.reg .b32 %r<6>;
.reg .b64 %rd<8>;


ld.param.u32 %r2, [_Z5saxpyifPfS__param_0];
ld.param.f32 %f1, [_Z5saxpyifPfS__param_1];
ld.param.u64 %rd1, [_Z5saxpyifPfS__param_2];
ld.param.u64 %rd2, [_Z5saxpyifPfS__param_3];
mov.u32 %r3, %ctaid.x;
mov.u32 %r4, %ntid.x;
mov.u32 %r5, %tid.x;
mad.lo.s32 %r1, %r4, %r3, %r5;
setp.ge.s32 %p1, %r1, %r2;
@%p1 bra BB0_2;

cvta.to.global.u64 %rd3, %rd2;
cvta.to.global.u64 %rd4, %rd1;
mul.wide.s32 %rd5, %r1, 4;
add.s64 %rd6, %rd4, %rd5;
ld.global.f32 %f2, [%rd6];
add.s64 %rd7, %rd3, %rd5;
ld.global.f32 %f3, [%rd7];
fma.rn.f32 %f4, %f2, %f1, %f3;
st.global.f32 [%rd7], %f4;

BB0_2:
ret;
}

Comme tout code haut niveau, il faut le compiler pour avoir l'ISA compatible avec le GPU ciblé. On transforme donc le PTX en langage assembleur intermédiaire RISC appelé SASS (Streaming ASSembler). Ce process est effectué par un programme nommé ptxas pour les GPU Nvidia avec CUDA.

ptx-to-sass-pipeline.txt
  Code CUDA (.cu)
       │
       ▼  nvcc (compilateur Nvidia)
  PTX - ISA virtuel haut niveau (portable)
       │
       ▼  ptxas (compilateur PTX -> SASS)
  SASS - ISA machine (specifique a l'architecture)
       │
       ├── Tesla  → SASS v1.x
       ├── Fermi  → SASS v2.x
       ├── Pascal → SASS v6.x
       └── Ampere → SASS v8.x

  Le PTX est stable entre architectures.
  Le SASS change a chaque generation de GPU.
Chaine de compilation CUDA : code source -> PTX (ISA virtuel) -> SASS (ISA machine) via ptxas. SASS est specifique a chaque architecture.

Source : General-Purpose Graphics Processor Architecture

SASS : peu documenté, difficile à reverse

Le SASS est peu documenté. C'est grâce à la communauté informatique qui a développé des outils comme le projet decuda que de la documentation partielle est arrivée. Nvidia a ensuite introduit l'outil cuobjdump pour la documentation du SASS. Aujourd'hui cette documentation correspond juste à la liste des opcodes, sans détail sur la sémantique, ce qui rend le travail de rétro-ingénierie assez complexe pour comprendre comment Nvidia optimise le code.

Le code PTX et le code SASS sont très différents. Pour chaque nouvelle architecture Nvidia (Tesla, Fermi, Pascal, ...) le SASS est différent.

Nvidia vs AMD : transparence ISA
Contrairement à Nvidia, AMD fournit une documentation complète sur leur ISA, ce qui a aidé les chercheurs académiques pour leurs travaux de simulation bas-niveau. Le flow de compilation AMD fournit aussi un ISA virtuel : HSAIL (Heterogeneous System Architecture Intermediate Language). Une différence notoire entre les architectures Nvidia et AMD : AMD sépare les instructions scalaires (s_) et vectorielles (v_).
nvidia-vs-amd-isa.txt
  NVIDIA                             AMD
  ────────────────────────────       ────────────────────────────────
  Source CUDA (.cu)                  Source OpenCL / HIP (.cl)
       │                                  │
       ▼ nvcc                             ▼ clang / hipcc
  PTX (ISA virtuel)                  HSAIL (ISA virtuel)
       │                                  │
       ▼ ptxas                            ▼ LC codegen
  SASS (ISA machine)                 GCN ISA (ISA machine)
       │                                  │
       ├── opcodes listés seulement       ├── spec publique complète
       └── sémantique non documentée      ├── s_ = instructions scalaires
                                          └── v_ = instructions vectorielles
Comparaison des chaines de compilation ISA Nvidia et AMD. AMD distingue explicitement instructions scalaires et vectorielles.

Source : General-Purpose Graphics Processor Architecture