Aller au contenu principal
own2pwn
Coder pour le GPU : threads, blocks, grids

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 J10 min de lecture

Un doute sur votre exposition ?

On audite votre surface d'attaque externe et on vous dit ce qui est exploitable.

Parler à un pentester

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
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
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
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 : 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
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
}

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
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
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
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
Comparaison des chaines de compilation ISA Nvidia et AMD. AMD distingue explicitement instructions scalaires et vectorielles.

Source : General-Purpose Graphics Processor Architecture

Veille sécurité

La suite, une fois par mois

Ce qui bouge vraiment sur la surface d'attaque externe, NIS2 et la sécurité applicative, écrit par le pentester qui signe ces articles. Un envoi par mois, désinscription en un clic.

Votre adresse ne sert qu'à cet envoi. Voir la politique de confidentialité.