aprendizaje de programación cuda: optimización del rendimiento de la memoria compartida CUDA (9)

prefacio

Referencias:

El blog de Gao Sheng
"Guía autorizada de programación de CUDA C"
y el documento oficial de CUDA
Programación de CUDA: conceptos básicos y práctica Fan Zheyong

Todos los códigos del artículo están disponibles en mi GitHub y se actualizarán lentamente en el futuro.

Los artículos y videos explicativos se actualizan simultáneamente para el público "AI Knowledge Story", estación B: sal a comer tres tazones de arroz

1: memoria compartida

La memoria compartida es un tipo de caché que los programadores pueden manipular directamente. Tiene dos funciones principales:
(1) una es reducir el número de accesos a la memoria global en las funciones del kernel y realizar una comunicación interna eficiente de los bloques de subprocesos;
(2) Uno de ellos es la fusión mejorada de los accesos a la memoria global.

El siguiente es un cálculo de reducción escrito en C++: un
arreglo x con N elementos, si necesitamos calcular la suma de todos los elementos del arreglo,
es decir, sum = x[0] + x[1] + ... + x[N-1]

#include<stdint.h>
#include<cuda.h>
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
#include <math.h>
#include <stdio.h>

#define CHECK(call)                                   \
do                                                    \
{
      
                                                           \
    const cudaError_t error_code = call;              \
    if (error_code != cudaSuccess)                    \
    {
      
                                                       \
        printf("CUDA Error:\n");                      \
        printf("    File:       %s\n", __FILE__);     \
        printf("    Line:       %d\n", __LINE__);     \
        printf("    Error code: %d\n", error_code);   \
        printf("    Error text: %s\n",                \
            cudaGetErrorString(error_code));          \
        exit(1);                                      \
    }                                                 \
} while (0)



#ifdef USE_DP
typedef double real;
#else
typedef float real;
#endif

const int NUM_REPEATS = 20;
void timing(const real* x, const int N);
real reduce(const real* x, const int N);

int main(void)
{
    
    
    const int N = 100000000;
    const int M = sizeof(real) * N;
    real* x = (real*)malloc(M);
    for (int n = 0; n < N; ++n)
    {
    
    
        x[n] = 1.23;
    }

    timing(x, N);

    free(x);
    return 0;
}

void timing(const real* x, const int N)
{
    
    
    real sum = 0;

    for (int repeat = 0; repeat < NUM_REPEATS; ++repeat)
    {
    
    
        cudaEvent_t start, stop;
        CHECK(cudaEventCreate(&start));
        CHECK(cudaEventCreate(&stop));
        CHECK(cudaEventRecord(start));
        cudaEventQuery(start);

        sum = reduce(x, N);

        CHECK(cudaEventRecord(stop));
        CHECK(cudaEventSynchronize(stop));
        float elapsed_time;
        CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));
        printf("Time = %g ms.\n", elapsed_time);

        CHECK(cudaEventDestroy(start));
        CHECK(cudaEventDestroy(stop));
    }

    printf("sum = %f.\n", sum);
}

real reduce(const real* x, const int N)
{
    
    
    real sum = 0.0;
    for (int n = 0; n < N; ++n)
    {
    
    
        sum += x[n];
    }
    return sum;
}

inserte la descripción de la imagen aquí

2: mecanismo de sincronización de subprocesos

Para programas de subprocesos múltiples, el orden de ejecución de las instrucciones en dos subprocesos diferentes puede ser diferente del orden que se muestra en el código.
Para asegurar que el orden de ejecución de las sentencias en la función del kernel sea consistente con el orden de aparición, se debe usar algún tipo de mecanismo de sincronización. En CUDA, se proporciona una función de sincronización __syncthreads. Esta función solo se puede usar en funciones del kernel y su uso más simple es sin ningún parámetro:
__syncthreads();
Esta función puede garantizar que todos los subprocesos en un bloque de subprocesos ejecuten completamente la declaración antes de ejecutar la declaración que sigue a la declaración anterior. Sin embargo, esta función es solo para subprocesos en el mismo bloque de subprocesos, y el orden de ejecución de subprocesos en diferentes bloques de subprocesos aún es incierto.

3: Utilice la sincronización de subprocesos para reducir los cálculos

Suponiendo que la cantidad de elementos del arreglo es una potencia de 2 (eliminaremos esta suposición más adelante), podemos agregar cada elemento en la segunda mitad del arreglo al elemento del arreglo correspondiente en la primera mitad. Si se repite este proceso, el primer elemento resultante de la matriz es la suma de los elementos de la matriz original. Este es el llamado método de reducción binaria.

3.1 Cálculo de reducción bajo la condición de memoria global

void __global__ reduce_global(real* d_x, real* d_y)
{
    
    
    const int tid = threadIdx.x;
 //定义指针X,右边表示 d_x 数组第  blockDim.x * blockIdx.x个元素的地址
 //该情况下x 在不同线程块,指向全局内存不同的地址---》使用不同的线程块对dx数组不同部分分别进行处理   
    real* x = d_x + blockDim.x * blockIdx.x;

    //blockDim.x >> 1  等价于 blockDim.x /2,核函数中,位操作比 对应的整数操作高效
    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
    {
    
    
        if (tid < offset)
        {
    
    
            x[tid] += x[tid + offset];
        }
        //同步语句,作用:同一个线程块内的线程按照代码先后执行指令(块内同步,块外不用同步)
        __syncthreads();
    }

    if (tid == 0)
    {
    
    
        d_y[blockIdx.x] = x[0];
    }
}

3.2 Cálculo de reducción bajo la condición de memoria compartida estática

void __global__ reduce_shared(real* d_x, real* d_y)
{
    
    
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    const int n = bid * blockDim.x + tid;
    //定义了共享内存数组 s_y[128],注意关键词  __shared__
    __shared__ real s_y[128];
    s_y[tid] = (n < N) ? d_x[n] : 0.0;
    __syncthreads();
    //归约计算用共享内存变量替换了原来的全局内存变量。这里也要记住: 每个线程块都对其中的共享内存变量副本进行操作。在归约过程结束后,每一个线程
    //块中的 s_y[0] 副本就保存了若干数组元素的和
    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
    {
    
    

        if (tid < offset)
        {
    
    
            s_y[tid] += s_y[tid + offset];
        }
        __syncthreads();
    }

    if (tid == 0)
    {
    
    
        d_y[bid] = s_y[0];
    }
}

3.3 Cálculo de reducción bajo la condición de memoria compartida dinámica

En la función del núcleo anterior, especificamos una longitud fija (128) al definir la matriz de memoria compartida. Nuestro programa asume que esta longitud es la misma que la del parámetro de configuración de ejecución block_size de la función kernel (es decir, blockDim.x en la función kernel). Si accidentalmente escribe la longitud incorrecta de la matriz al definir la variable de memoria compartida, puede causar errores o reducir el rendimiento de la función del kernel.

Una forma de reducir la probabilidad de este error es usar memoria compartida dinámica

  1. Escriba el tercer parámetro en la configuración de ejecución que llama a la función del kernel:
<<<grid_size, block_size, sizeof(real) * block_size>>>
前面2个参数网格大小和线程块大小,
第三个参数就是核函数中每个线程块需要 定义的动态共享内存的字节数
  1. Para usar la memoria compartida dinámica, también debe cambiar la declaración de las variables de memoria compartida en la función del kernel
extern __shared__ real s_y[];这是动态声明
__shared__ real s_y[128]; 这是静态声明

Código de programa de cálculo de reducción

#include<stdint.h>
#include<cuda.h>
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
#include <math.h>
#include <stdio.h>

#define CHECK(call)                                   \
do                                                    \
{
      
                                                           \
    const cudaError_t error_code = call;              \
    if (error_code != cudaSuccess)                    \
    {
      
                                                       \
        printf("CUDA Error:\n");                      \
        printf("    File:       %s\n", __FILE__);     \
        printf("    Line:       %d\n", __LINE__);     \
        printf("    Error code: %d\n", error_code);   \
        printf("    Error text: %s\n",                \
            cudaGetErrorString(error_code));          \
        exit(1);                                      \
    }                                                 \
} while (0)

#ifdef USE_DP
typedef double real;
#else
typedef float real;
#endif

const int NUM_REPEATS = 100;
const int N = 100000000;
const int M = sizeof(real) * N;
const int BLOCK_SIZE = 128;

void timing(real* h_x, real* d_x, const int method);

int main(void)
{
    
    
    real* h_x = (real*)malloc(M);
    for (int n = 0; n < N; ++n)
    {
    
    
        h_x[n] = 1.23;
    }
    real* d_x;
    CHECK(cudaMalloc(&d_x, M));

    printf("\nUsing global memory only:\n");
    timing(h_x, d_x, 0);
    printf("\nUsing static shared memory:\n");
    timing(h_x, d_x, 1);
    printf("\nUsing dynamic shared memory:\n");
    timing(h_x, d_x, 2);

    free(h_x);
    CHECK(cudaFree(d_x));
    return 0;
}

void __global__ reduce_global(real* d_x, real* d_y)
{
    
    
    const int tid = threadIdx.x;
 //定义指针X,右边表示 d_x 数组第  blockDim.x * blockIdx.x个元素的地址
 //该情况下x 在不同线程块,指向全局内存不同的地址---》使用不同的线程块对dx数组不同部分分别进行处理   
    real* x = d_x + blockDim.x * blockIdx.x;

    //blockDim.x >> 1  等价于 blockDim.x /2,核函数中,位操作比 对应的整数操作高效
    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
    {
    
    
        if (tid < offset)
        {
    
    
            x[tid] += x[tid + offset];
        }
        //同步语句,作用:同一个线程块内的线程按照代码先后执行指令(块内同步,块外不用同步)
        __syncthreads();
    }

    if (tid == 0)
    {
    
    
        d_y[blockIdx.x] = x[0];
    }
}

void __global__ reduce_shared(real* d_x, real* d_y)
{
    
    
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    const int n = bid * blockDim.x + tid;
    //定义了共享内存数组 s_y[128],注意关键词  __shared__
    __shared__ real s_y[128];
    //将全局内存中的数据复制到共享内存中
    //共享内存的特 征:每个线程块都有一个共享内存变量的副本
    s_y[tid] = (n < N) ? d_x[n] : 0.0;
    //调用函数 __syncthreads 进行线程块内的同步
    __syncthreads();
    //归约计算用共享内存变量替换了原来的全局内存变量。这里也要记住: 每个线程块都对其中的共享内存变量副本进行操作。在归约过程结束后,每一个线程
    //块中的 s_y[0] 副本就保存了若干数组元素的和
    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
    {
    
    

        if (tid < offset)
        {
    
    
            s_y[tid] += s_y[tid + offset];
        }
        __syncthreads();
    }

    if (tid == 0)
    {
    
    
        d_y[bid] = s_y[0];
    }
}

void __global__ reduce_dynamic(real* d_x, real* d_y)
{
    
    
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    const int n = bid * blockDim.x + tid;
    //声明 动态共享内存 s_y[]  限定词 extern,不能指定数组大小
    extern __shared__ real s_y[];
    s_y[tid] = (n < N) ? d_x[n] : 0.0;
    __syncthreads();

    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
    {
    
    

        if (tid < offset)
        {
    
    
            s_y[tid] += s_y[tid + offset];
        }
        __syncthreads();
    }

    if (tid == 0)
    {
    
    //将每一个线程块中归约的结果从共享内存 s_y[0] 复制到全局内 存d_y[bid]
        d_y[bid] = s_y[0];
    }
}

real reduce(real* d_x, const int method)
{
    
    
    int grid_size = (N + BLOCK_SIZE - 1) / BLOCK_SIZE;
    const int ymem = sizeof(real) * grid_size;
    const int smem = sizeof(real) * BLOCK_SIZE;
    real* d_y;
    CHECK(cudaMalloc(&d_y, ymem));
    real* h_y = (real*)malloc(ymem);

    switch (method)
    {
    
    
    case 0:
        reduce_global << <grid_size, BLOCK_SIZE >> > (d_x, d_y);
        break;
    case 1:
        reduce_shared << <grid_size, BLOCK_SIZE >> > (d_x, d_y);
        break;
    case 2:
        reduce_dynamic << <grid_size, BLOCK_SIZE, smem >> > (d_x, d_y);
        break;
    default:
        printf("Error: wrong method\n");
        exit(1);
        break;
    }

    CHECK(cudaMemcpy(h_y, d_y, ymem, cudaMemcpyDeviceToHost));

    real result = 0.0;
    for (int n = 0; n < grid_size; ++n)
    {
    
    
        result += h_y[n];
    }

    free(h_y);
    CHECK(cudaFree(d_y));
    return result;
}

void timing(real* h_x, real* d_x, const int method)
{
    
    
    real sum = 0;

    for (int repeat = 0; repeat < NUM_REPEATS; ++repeat)
    {
    
    
        CHECK(cudaMemcpy(d_x, h_x, M, cudaMemcpyHostToDevice));

        cudaEvent_t start, stop;
        CHECK(cudaEventCreate(&start));
        CHECK(cudaEventCreate(&stop));
        CHECK(cudaEventRecord(start));
        cudaEventQuery(start);

        sum = reduce(d_x, method);

        CHECK(cudaEventRecord(stop));
        CHECK(cudaEventSynchronize(stop));
        float elapsed_time;
        CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));
        printf("Time = %g ms.\n", elapsed_time);

        CHECK(cudaEventDestroy(start));
        CHECK(cudaEventDestroy(stop));
    }

    printf("sum = %f.\n", sum);
}

Comparación de resultados:
la memoria global tarda 25 ms, el resultado del cálculo es incorrecto, debe ser 1,23*10^8, hay muchos decimales detrás de ella
inserte la descripción de la imagen aquí
Memoria estática compartida, tarda 28 ms, el resultado también es incorrecto
inserte la descripción de la imagen aquí

La memoria compartida dinámica
tarda 29 ms
inserte la descripción de la imagen aquí
Conclusión:
(1) La velocidad de acceso a la memoria global es la más baja entre todas las memorias , y su uso debe minimizarse. De toda la memoria del dispositivo, los registros son los más eficientes, pero en problemas que requieren la cooperación de subprocesos, el uso de registros que solo son visibles para un único subproceso no es suficiente. Necesitamos usar memoria compartida que sea visible para todo el bloque de subprocesos.
(2) Casi no hay diferencia en el tiempo de ejecución entre una función del kernel que usa memoria compartida dinámica y una función del kernel que usa memoria compartida estática. Por lo tanto, el uso de la memoria compartida dinámica no afectará el rendimiento del programa, pero a veces puede mejorar la capacidad de mantenimiento del programa.
(3) El uso de la memoria compartida para mejorar el acceso a la memoria global no necesariamente mejora el rendimiento de las funciones del núcleo. Por lo tanto, al optimizar programas CUDA, generalmente es necesario
probar y comparar diferentes esquemas de optimización.
(4) Hay un error sobre el resultado del cálculo SUMA, que se debe a que el llamado fenómeno "los números grandes se comen a los números pequeños" ocurre en el cálculo de acumulación. Los números de punto flotante de precisión simple tienen solo 6 o 7 cifras significativas precisas. En la función reduce anterior, después de acumular el valor de la variable sum a más de 30 millones, y luego sumarlo a 1.23, su valor ya no aumentará (el número pequeño es "comido" por el número grande, pero el número grande no es
Variedad).
Algunas soluciones actuales como: Algoritmo de suma de Kahan

Supongo que te gusta

Origin blog.csdn.net/qq_40514113/article/details/130989649
Recomendado
Clasificación