CUDA pour l'apprentissage automatique : applications pratiques

Structure d'une application CUDA C/C++, où le code hôte (CPU) gère l'exécution du code parallèle sur l'appareil (GPU).
Maintenant que nous avons couvert les bases, explorons comment CUDA peut être appliqué aux tâches courantes d’apprentissage automatique.
-
Multiplication matricielle
La multiplication de matrices est une opération fondamentale dans de nombreux algorithmes d'apprentissage automatique, notamment dans les réseaux de neurones. CUDA peut accélérer considérablement cette opération. Voici une implémentation simple :
__global__ void matrixMulKernel(float *A, float *B, float *C, int N)
{
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
if (row < N && col < N) {
for (int i = 0; i < N; i++) {
sum += A[row * N + i] * B[i * N + col];
}
C[row * N + col] = sum;
}
}
// Host function to set up and launch the kernel
void matrixMul(float *A, float *B, float *C, int N)
{
dim3 threadsPerBlock(16, 16);
dim3 numBlocks((N + threadsPerBlock.x - 1) / threadsPerBlock.x,
(N + threadsPerBlock.y - 1) / threadsPerBlock.y);
matrixMulKernelnumBlocks, threadsPerBlock(A, B, C, N);
}
Cette implémentation divise la matrice de sortie en blocs, chaque thread calculant un élément du résultat. Bien que cette version de base soit déjà plus rapide qu'une implémentation CPU pour les grandes matrices, des optimisations sont possibles grâce à la mémoire partagée et à d'autres techniques.
-
Opérations de convolution
Réseaux de neurones convolutifs (CNN) s'appuient fortement sur les opérations de convolution. CUDA peut accélérer considérablement ces calculs. Voici un noyau de convolution 2D simplifié :
__global__ void convolution2DKernel(float *input, float *kernel, float *output,
int inputWidth, int inputHeight,
int kernelWidth, int kernelHeight)
{
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < inputWidth && y < inputHeight) {
float sum = 0.0f;
for (int ky = 0; ky < kernelHeight; ky++) {
for (int kx = 0; kx < kernelWidth; kx++) {
int inputX = x + kx - kernelWidth / 2;
int inputY = y + ky - kernelHeight / 2;
if (inputX >= 0 && inputX < inputWidth && inputY >= 0 && inputY < inputHeight) {
sum += input[inputY * inputWidth + inputX] *
kernel[ky * kernelWidth + kx];
}
}
}
output[y * inputWidth + x] = sum;
}
}
Ce noyau effectue une convolution 2D, chaque thread calculant un pixel de sortie. En pratique, des implémentations plus sophistiquées utiliseraient la mémoire partagée pour réduire les accès à la mémoire globale et optimiser les différentes tailles de noyau.
-
Descente de gradient stochastique (SGD)
SGD est un algorithme d'optimisation fondamental en apprentissage automatique. CUDA permet de paralléliser le calcul des gradients sur plusieurs points de données. Voici un exemple simplifié de régression linéaire :
__global__ void sgdKernel(float *X, float *y, float *weights, float learningRate, int n, int d)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
float prediction = 0.0f;
for (int j = 0; j < d; j++) {
prediction += X[i * d + j] * weights[j];
}
float error = prediction - y[i];
for (int j = 0; j < d; j++) {
atomicAdd(&weights[j], -learningRate * error * X[i * d + j]);
}
}
}
void sgd(float *X, float *y, float *weights, float learningRate, int n, int d, int iterations)
{
int threadsPerBlock = 256;
int numBlocks = (n + threadsPerBlock - 1) / threadsPerBlock;
for (int iter = 0; iter < iterations; iter++) {
sgdKernel<<<numBlocks, threadsPerBlock>>>(X, y, weights, learningRate, n, d);
}
}
Cette implémentation met à jour les poids en parallèle pour chaque point de données. atomicAdd la fonction est utilisée pour gérer les mises à jour simultanées des poids en toute sécurité.
Optimisation de CUDA pour l'apprentissage automatique
Bien que les exemples ci-dessus illustrent les bases de l'utilisation de CUDA pour les tâches d'apprentissage automatique, il existe plusieurs techniques d'optimisation qui peuvent encore améliorer les performances :
-
Accès à la mémoire coalescée
Les GPU atteignent des performances optimales lorsque les threads d'une chaîne accèdent à des emplacements de mémoire contigus. Assurez-vous que vos structures de données et vos modèles d'accès favorisent l'accès à la mémoire fusionnée.
-
Utilisation de la mémoire partagée
La mémoire partagée est beaucoup plus rapide que la mémoire globale. Utilisez-la pour mettre en cache les données fréquemment consultées dans un bloc de thread.

Comprendre la hiérarchie de la mémoire avec CUDA
Ce schéma illustre l'architecture d'un système multiprocesseur avec mémoire partagée. Chaque processeur possède son propre cache, ce qui permet un accès rapide aux données fréquemment utilisées. Les processeurs communiquent via un bus partagé, qui les relie à un espace mémoire partagé plus grand.
Par exemple, dans la multiplication de matrices :
__global__ void matrixMulSharedKernel(float *A, float *B, float *C, int N)
{
__shared__ float sharedA[TILE_SIZE][TILE_SIZE];
__shared__ float sharedB[TILE_SIZE][TILE_SIZE];
int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
int row = by * TILE_SIZE + ty;
int col = bx * TILE_SIZE + tx;
float sum = 0.0f;
for (int tile = 0; tile < (N + TILE_SIZE - 1) / TILE_SIZE; tile++) {
if (row < N && tile * TILE_SIZE + tx < N)
sharedA[ty][tx] = A[row * N + tile * TILE_SIZE + tx];
else
sharedA[ty][tx] = 0.0f;
if (col < N && tile * TILE_SIZE + ty < N)
sharedB[ty][tx] = B[(tile * TILE_SIZE + ty) * N + col];
else
sharedB[ty][tx] = 0.0f;
__syncthreads();
for (int k = 0; k < TILE_SIZE; k++)
sum += sharedA[ty][k] * sharedB[k][tx];
__syncthreads();
}
if (row < N && col < N)
C[row * N + col] = sum;
}
Cette version optimisée utilise la mémoire partagée pour réduire les accès à la mémoire globale, améliorant considérablement les performances des grandes matrices.
-
Opérations asynchrones
CUDA prend en charge les opérations asynchrones, ce qui vous permet de superposer le calcul avec le transfert de données. Cela est particulièrement utile dans les pipelines d'apprentissage automatique où vous pouvez préparer le prochain lot de données pendant que le lot actuel est en cours de traitement.
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
// Asynchronous memory transfers and kernel launches
cudaMemcpyAsync(d_data1, h_data1, size, cudaMemcpyHostToDevice, stream1);
myKernel<<<grid, block, 0, stream1>>>(d_data1, ...);
cudaMemcpyAsync(d_data2, h_data2, size, cudaMemcpyHostToDevice, stream2);
myKernel<<<grid, block, 0, stream2>>>(d_data2, ...);
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
-
Noyaux de tenseurs
Pour les charges de travail d'apprentissage automatique, Tensor Cores de NVIDIA (disponible dans les architectures GPU plus récentes) peut fournir des accélérations significatives pour les opérations de multiplication de matrice et de convolution. Des bibliothèques comme cuDNN et cuBLAS exploite automatiquement les cœurs Tensor lorsqu'ils sont disponibles.
Défis et considérations
Bien que CUDA offre d’énormes avantages pour l’apprentissage automatique, il est important d’être conscient des défis potentiels :
- Gestion de la mémoire:La mémoire du GPU est limitée par rapport à la mémoire système. Une gestion efficace de la mémoire est essentielle, en particulier lorsque vous travaillez avec de grands ensembles de données ou de grands modèles.
- Frais généraux de transfert de données:Transfert de données entre CPU et GPU peut constituer un goulot d'étranglement. Réduisez les transferts et utilisez des opérations asynchrones lorsque cela est possible.
- La précisionLes GPU excellent traditionnellement dans les calculs en simple précision (FP32). Bien que la prise en charge de la double précision (FP64) se soit améliorée, elle est souvent plus lente. De nombreuses tâches d'apprentissage automatique fonctionnent bien avec une précision inférieure (par exemple, FP16), que les GPU modernes gèrent très efficacement.
- Complexité du code: L'écriture d'un code CUDA efficace peut être plus complexe que celle d'un code CPU. Tirer parti de bibliothèques telles que cuDNN, cuBLAS et des frameworks comme TensorFlow ou PyTorch peuvent aider à éliminer une partie de cette complexité.
À mesure que les modèles d'apprentissage automatique augmentent en taille et en complexité, un seul GPU peut ne plus suffire à gérer la charge de travail. CUDA permet de faire évoluer votre application sur plusieurs GPU, soit au sein d'un seul nœud, soit sur un cluster.
Structure de programmation CUDA
Pour utiliser efficacement CUDA, il est essentiel de comprendre sa structure de programmation, qui implique l'écriture de noyaux (fonctions exécutées sur le GPU) et la gestion de la mémoire entre l'hôte (CPU) et le périphérique (GPU).
Mémoire de l'hôte et mémoire du périphérique
Dans CUDA, la mémoire est gérée séparément pour l'hôte et le périphérique. Voici les principales fonctions utilisées pour la gestion de la mémoire :
- cudaMalloc: Alloue de la mémoire sur l'appareil.
- cudaMemcpy: Copie les données entre l'hôte et le périphérique.
- cudaGratuit: Libère de la mémoire sur l'appareil.
Exemple : addition de deux tableaux
Regardons un exemple qui additionne deux tableaux à l’aide de CUDA :
__global__ void sumArraysOnGPU(float *A, float *B, float *C, int N) {
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < N) C[idx] = A[idx] + B[idx];
}
int main() {
int N = 1024;
size_t bytes = N * sizeof(float);
float *h_A, *h_B, *h_C;
h_A = (float*)malloc(bytes);
h_B = (float*)malloc(bytes);
h_C = (float*)malloc(bytes);
float *d_A, *d_B, *d_C;
cudaMalloc(&d_A, bytes);
cudaMalloc(&d_B, bytes);
cudaMalloc(&d_C, bytes);
cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);
int blockSize = 256;
int gridSize = (N + blockSize - 1) / blockSize;
sumArraysOnGPU<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);
cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
free(h_A);
free(h_B);
free(h_C);
return 0;
}
Dans cet exemple, la mémoire est allouée à la fois sur l'hôte et sur le périphérique, les données sont transférées vers le périphérique et le noyau est lancé pour effectuer le calcul.
Conclusion
CUDA est un outil puissant pour les ingénieurs en machine learning qui cherchent à accélérer leurs modèles et à gérer des ensembles de données plus volumineux. En comprenant le modèle de mémoire CUDA, en optimisant l'accès à la mémoire et en exploitant plusieurs GPU, vous pouvez améliorer considérablement les performances de vos applications de machine learning.