Tech

Grasp CUDA: Para engenheiros de aprendizado de máquina

CUDA para Aprendizado de Máquina: Aplicações Práticas

Estrutura de um aplicativo CUDA C/C++, onde o código do host (CPU) gerencia a execução do código paralelo no dispositivo (GPU).

Agora que abordamos o básico, vamos explorar como o CUDA pode ser aplicado a tarefas comuns de aprendizado de máquina.

  1. Multiplicação de matrizes

A multiplicação de matrizes é uma operação elementary em muitos algoritmos de aprendizado de máquina, particularmente em redes neurais. CUDA pode acelerar significativamente essa operação. Aqui está uma implementação simples:

__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 perform to arrange 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);
}

Esta implementação divide a matriz de saída em blocos, com cada thread computando um elemento do resultado. Embora esta versão básica já seja mais rápida do que uma implementação de CPU para matrizes grandes, há espaço para otimização usando memória compartilhada e outras técnicas.

  1. Operações de Convolução

Redes Neurais Convolucionais (CNNs) dependem muito de operações de convolução. CUDA pode acelerar drasticamente essas computações. Aqui está um kernel de convolução 2D simplificado:

__global__ void convolution2DKernel(float *enter, 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 += enter(inputY * inputWidth + inputX) * 
                           kernel(ky * kernelWidth + kx);
                }
            }
        }
        output(y * inputWidth + x) = sum;
    }
}

Este kernel realiza uma convolução 2D, com cada thread computando um pixel de saída. Na prática, implementações mais sofisticadas usariam memória compartilhada para reduzir acessos globais à memória e otimizar para vários tamanhos de kernel.

  1. Descida de gradiente estocástico (SGD)

SGD é um algoritmo de otimização elementary em machine studying. CUDA pode paralelizar a computação de gradientes em vários pontos de dados. Aqui está um exemplo simplificado para regressão linear:

__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);
    }
}

Esta implementação atualiza os pesos em paralelo para cada ponto de dados. O atomicAdd A função é usada para manipular atualizações simultâneas dos pesos com segurança.

Otimizando CUDA para aprendizado de máquina

Embora os exemplos acima demonstrem os princípios básicos do uso de CUDA para tarefas de aprendizado de máquina, existem diversas técnicas de otimização que podem melhorar ainda mais o desempenho:

  1. Acesso à memória coalescida

As GPUs atingem o desempenho máximo quando threads em um warp acessam locais de memória contíguos. Garanta que suas estruturas de dados e padrões de acesso promovam acesso à memória coalescida.

  1. Uso de memória compartilhada

A memória compartilhada é muito mais rápida que a memória world. Use-a para armazenar em cache dados acessados ​​com frequência dentro de um bloco de thread.

Entender a hierarquia da memória é crucial ao trabalhar com CUDA

Compreendendo a hierarquia de memória com CUDA

Este diagrama ilustra a arquitetura de um sistema multiprocessador com memória compartilhada. Cada processador tem seu próprio cache, permitindo acesso rápido a dados usados ​​com frequência. Os processadores se comunicam por meio de um barramento compartilhado, que os conecta a um espaço maior de memória compartilhada.

Por exemplo, na multiplicação de matrizes:

__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 ok = 0; ok < TILE_SIZE; ok++)
            sum += sharedA(ty)(ok) * sharedB(ok)(tx);
        
        __syncthreads();
    }
    
    if (row < N && col < N)
        C(row * N + col) = sum;
}

Esta versão otimizada usa memória compartilhada para reduzir os acessos à memória world, melhorando significativamente o desempenho de matrizes grandes.

  1. Operações Assíncronas

CUDA suporta operações assíncronas, permitindo que você sobreponha computação com transferência de dados. Isso é particularmente útil em pipelines de machine studying, onde você pode preparar o próximo lote de dados enquanto o lote atual está sendo processado.

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
// Asynchronous reminiscence transfers and kernel launches
cudaMemcpyAsync(d_data1, h_data1, dimension, cudaMemcpyHostToDevice, stream1);
myKernel<<<grid, block, 0, stream1>>>(d_data1, ...);
cudaMemcpyAsync(d_data2, h_data2, dimension, cudaMemcpyHostToDevice, stream2);
myKernel<<<grid, block, 0, stream2>>>(d_data2, ...);
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
  1. Núcleos Tensores

Para cargas de trabalho de machine studying, os Tensor Cores da NVIDIA (disponíveis em arquiteturas de GPU mais recentes) podem fornecer acelerações significativas para operações de multiplicação de matrizes e convolução. Bibliotecas como cuDNN e cuBLAS aproveitam automaticamente os Tensor Cores quando disponíveis.

Desafios e Considerações

Embora o CUDA ofereça enormes benefícios para o aprendizado de máquina, é importante estar ciente dos possíveis desafios:

  1. Gerenciamento de memória: A memória da GPU é limitada em comparação à memória do sistema. O gerenciamento eficiente da memória é essential, especialmente ao trabalhar com grandes conjuntos de dados ou modelos.
  2. Sobrecarga de transferência de dados: Transferir dados entre CPU e GPU pode ser um gargalo. Reduce as transferências e use operações assíncronas quando possível.
  3. Precisão: GPUs tradicionalmente se destacam em cálculos de precisão simples (FP32). Embora o suporte para precisão dupla (FP64) tenha melhorado, ele é frequentemente mais lento. Muitas tarefas de aprendizado de máquina podem funcionar bem com precisão menor (por exemplo, FP16), que GPUs modernas lidam de forma muito eficiente.
  4. Complexidade do código: Escrever código CUDA eficiente pode ser mais complexo do que código CPU. Aproveitar bibliotecas como cuDNN, cuBLAS e frameworks como TensorFlow ou PyTorch pode ajudar a abstrair parte dessa complexidade.

À medida que os modelos de machine studying crescem em tamanho e complexidade, uma única GPU pode não ser mais suficiente para lidar com a carga de trabalho. O CUDA torna possível dimensionar seu aplicativo em várias GPUs, seja em um único nó ou em um cluster.

Estrutura de programação CUDA

Para utilizar CUDA de forma eficaz, é essencial entender sua estrutura de programação, que envolve escrever kernels (funções executadas na GPU) e gerenciar a memória entre o host (CPU) e o dispositivo (GPU).

Memória do host vs. dispositivo

Em CUDA, a memória é gerenciada separadamente para o host e o dispositivo. A seguir estão as funções primárias usadas para gerenciamento de memória:

  • cudaMalloc: Aloca memória no dispositivo.
  • cudaMemcpy: Copia dados entre o host e o dispositivo.
  • cudaLivre: Libera memória no dispositivo.

Exemplo: somando duas matrizes

Vejamos um exemplo que soma duas matrizes usando 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 foremost() {
    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;
}

Neste exemplo, a memória é alocada no host e no dispositivo, os dados são transferidos para o dispositivo e o kernel é iniciado para executar o cálculo.

Conclusão

CUDA é uma ferramenta poderosa para engenheiros de machine studying que buscam acelerar seus modelos e lidar com conjuntos de dados maiores. Ao entender o modelo de memória CUDA, otimizar o acesso à memória e aproveitar várias GPUs, você pode melhorar significativamente o desempenho de seus aplicativos de machine studying.

Artigos relacionados

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button