CUDA 入门第一课:从线程索引到向量加法与朴素矩阵乘法

CUDA 入门第一课:从线程索引到向量加法与朴素矩阵乘法

这是我的 NVIDIA CUDA Training Series 第一节课学习笔记与 Homework 1 实践记录。

这一节的核心目标很明确:理解 CUDA 的基本执行模型,学会在 GPU 上分配内存、在主机与设备之间搬运数据、启动核函数,并用线程索引把计算任务映射到数据上。作业从 Hello World 开始,随后完成向量加法和朴素矩阵乘法。

1. CUDA 程序最基本的结构

CUDA 程序通常同时包含两部分代码:

  • Host(主机)代码:由 CPU 执行,负责准备数据、分配 GPU 内存、搬运数据、启动核函数以及读取结果。
  • Device(设备)代码 :由 GPU 执行。使用 __global__ 修饰的函数称为 kernel(核函数),由主机端启动,并由大量 GPU 线程并行执行。

最小的核函数可以写成:

cpp 复制代码
__global__ void mykernel() {
}

主机端使用 CUDA 特有的 kernel launch syntax 启动它:

cpp 复制代码
mykernel<<<1, 1>>>();

<<<...>>> 中最常用的两个参数分别为:<<<线程块数量, 每个线程块中的线程数量>>>

例如:

cpp 复制代码
add<<<N, 1>>>(d_a, d_b, d_c);

表示启动 N 个 block,每个 block 只有 1 个 thread。

cpp 复制代码
add<<<1, N>>>(d_a, d_b, d_c);

表示启动 1 个 block,其中包含 N 个 thread。

两种配置都可以产生 N 个线程,但线程在 grid 和 block 中的组织方式不同。


2. Block、Thread 与线程索引

CUDA 提供了一组内建变量,让每个 GPU 线程知道自己位于哪里。

2.1 blockIdx:当前线程块编号

如果每个 block 只放一个线程,可以直接把 blockIdx.x 当作数组索引:

cpp 复制代码
__global__ void add(int *a, int *b, int *c) {
    c[blockIdx.x] = a[blockIdx.x] + b[blockIdx.x];
}

配合启动参数:add<<<N, 1>>>(d_a, d_b, d_c);

就得到:

text 复制代码
block 0 -> c[0] = a[0] + b[0]
block 1 -> c[1] = a[1] + b[1]
...
block N-1 -> c[N-1] = a[N-1] + b[N-1]

2.2 threadIdx:线程在当前 block 内的编号

如果只启动一个 block,把所有线程都放在其中,则可以使用 threadIdx.x

cpp 复制代码
__global__ void add(int *a, int *b, int *c) {
    c[threadIdx.x] = a[threadIdx.x] + b[threadIdx.x];
}

启动参数为:add<<<1, N>>>(d_a, d_b, d_c);

这时:

text 复制代码
thread 0 -> c[0]
thread 1 -> c[1]
...
thread N-1 -> c[N-1]

2.3 一维 CUDA 程序最常用的全局索引

实际程序一般会同时使用多个 block 和多个 thread。此时常用:

cpp 复制代码
int idx = blockIdx.x * blockDim.x + threadIdx.x;

其中:

  • blockIdx.x:当前 block 的编号;
  • blockDim.x:每个 block 中的线程数;
  • threadIdx.x:当前线程在 block 内的编号。

这样,每个线程都能得到一个全局唯一的一维索引。

假设:

cpp 复制代码
blockDim.x = 256;

那么:

text 复制代码
block 0: idx =   0 ~ 255
block 1: idx = 256 ~ 511
block 2: idx = 512 ~ 767
...

这就是把并行线程映射到数组元素上的基本方法。


3. 如何确定 block 数量

假设数组长度为 N,每个 block 使用 256 个线程:

cpp 复制代码
const int block_size = 256;

为了覆盖全部元素,可以写:

cpp 复制代码
int grid_size = (N + block_size - 1) / block_size;

随后启动:

cpp 复制代码
kernel<<<grid_size, block_size>>>(...);

这里利用整数除法完成向上取整,相当于ceil(N / block_size),例如 N = 1000

text 复制代码
grid_size = (1000 + 255) / 256 = 4

最终会启动 4 × 256 = 1024 个线程。

由于线程数可能多于数据量,因此核函数中需要边界保护:

cpp 复制代码
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
    // 处理第 idx 个元素
}

多出来的线程会直接跳过计算,避免访问数组之外的内存。

<<<>>> 中的 block 数和 thread 数可以由运行时变量决定,并不要求全部是编译期常量。


4. 向量加法为什么非常适合 GPU

向量加法:

text 复制代码
C[i] = A[i] + B[i]

每个元素的计算彼此独立:计算 C[10] 不需要先知道 C[9],也不需要与其他线程交换数据。

这种任务通常称为 embarrassingly parallel(易并行)。最直接的 CUDA 写法就是让一个线程处理一个元素:

cpp 复制代码
__global__ void vadd(const float *A, const float *B, float *C, int ds) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < ds) {
        C[idx] = A[idx] + B[idx];
    }
}

这也是理解 CUDA 数据并行最直观的例子。

4.1 例子

N = 512 的整型数组,在主机端生成随机数:

cpp 复制代码
#define N 512

void random_ints(int *a, int n) {
    for (int i = 0; i < n; i++) {
        a[i] = rand() % 100;
    }
}

随后分别尝试了两种线程组织方式:

cpp 复制代码
__global__ void addByBlock(int *a, int *b, int *c) {
    c[blockIdx.x] = a[blockIdx.x] + b[blockIdx.x];
}

addByBlock<<<N, 1>>>(d_a, d_b, d_c);

以及:

cpp 复制代码
__global__ void addByThread(int *a, int *b, int *c) {
    c[threadIdx.x] = a[threadIdx.x] + b[threadIdx.x];
}

addByThread<<<1, N>>>(d_a, d_b, d_c);

这两个例子很适合第一次建立 blockIdxthreadIdx 与数组索引之间的直觉。继续扩展到多个 block、每个 block 多个 thread 后,就自然得到通用的一维全局索引公式。


5. CUDA 的基本数据处理流程

以向量加法为例,CUDA 程序最核心的数据处理过程可以概括为三个步骤:

  1. 将数据从 Host 传到 Device
  2. 在 Device 上进行计算
  3. 将计算结果从 Device 传回 Host

在这三个核心步骤之前,通常还需要准备主机端数据并分配设备内存;计算完成之后,则需要释放相关资源。

5.1 准备数据并分配内存、显存

首先在 Host 上准备数据:

cpp 复制代码
int *a, *b, *c;
int size = N * sizeof(int);

a = (int *)malloc(size);
b = (int *)malloc(size);
c = (int *)malloc(size);

这里的 abc 位于主机内存中,由 CPU 访问。

随后在 Device 上分配对应的显存:

cpp 复制代码
int *d_a, *d_b, *d_c;

cudaMalloc((void **)&d_a, size);
cudaMalloc((void **)&d_b, size);
cudaMalloc((void **)&d_c, size);

习惯上常用 d_ 表示 device pointer,用 h_ 表示 host pointer。

5.2 第一步:Host → Device

GPU 要处理的数据通常首先位于主机内存中,因此需要将数据复制到设备内存:

cpp 复制代码
cudaMemcpy(d_a, a, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, b, size, cudaMemcpyHostToDevice);

这里的:cudaMemcpyHostToDevice表示数据传输方向为 Host → Device。

5.3 第二步:在 Device 上进行计算

数据进入显存后,通过启动 kernel 让 GPU 进行计算:

cpp 复制代码
add<<<grid_size, block_size>>>(d_a, d_b, d_c);

例如向量加法 kernel 中,每个线程负责计算一个元素:

cpp 复制代码
__global__ void add(int *a, int *b, int *c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;

    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

数据已经位于 Device 上,GPU 线程直接对设备内存中的数据进行并行计算。

5.4 第三步:Device → Host

计算结果仍然保存在设备内存中。如果后续需要由 CPU 读取或处理,就要把结果复制回主机:

cpp 复制代码
cudaMemcpy(c, d_c, size, cudaMemcpyDeviceToHost);

其中cudaMemcpyDeviceToHost表示数据传输方向为 Device → Host。

5.5 释放资源

程序使用完数据后,还需要释放 Host 和 Device 上申请的内存:

cpp 复制代码
free(a);
free(b);
free(c);

cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);

因此,把准备和收尾也包括进来,一个完整的 CUDA 基本流程可以写成:

text 复制代码
准备 Host 数据、分配 Device 内存
        ↓
① Host → Device
        ↓
② Device 上执行 kernel 函数
        ↓
③ Device → Host
        ↓
释放 Host / Device 资源

6. cudaMemset:初始化设备内存

如果需要直接初始化 GPU 显存,可以使用:

cpp 复制代码
cudaError_t cudaMemset(void *devPtr, int value, size_t count);

其中:

  • devPtr是设备内存地址;
  • value是按字节填充的值;
  • count是需要填充的字节数。

它的使用方式类似 C 的 memset,作用对象是设备内存。例如把一段设备内存清零:cudaMemset(d_a, 0, size);


7. Host 与 Device 之间的数据传输要尽量少

GPU 显存内部的数据访问带宽很高,而 CPU 与 GPU 之间的数据传输需要经过 PCIe 或 NVLink 等互连链路。因此,主机与设备之间的数据传输通常更慢,延迟也更高。

因此,实际 CUDA 优化中需要关注一个很重要的问题:尽量减少 Host 与 Device 之间不必要的数据移动。

如果每做一点计算就把数据在 CPU 和 GPU 之间来回搬运,传输开销可能抵消并行计算带来的收益。

Homework 1

课程第一份作业包含三个练习:

  1. CUDA Hello World;
  2. Vector Add;
  3. Naive Matrix Multiply。

这三个练习刚好能让我们理解课上的知识并逐步扩展:

text 复制代码
认识 block / thread
    ↓
一维线程索引 + GPU 内存管理
    ↓
二维 grid / block + 矩阵索引

作业链接:https://github.com/olcf/cuda-training-series

8. 作业 1:Hello World

8.1 代码

cpp 复制代码
#include <stdio.h>

__global__ void hello() {
    printf("Hello from block: %u, thread: %u\n",
           blockIdx.x, threadIdx.x);
}

int main() {
    hello<<<2, 2>>>();
    cudaDeviceSynchronize();
    return 0;
}

hello<<<2, 2>>>(); 会启动 2 个 block × 每个 block 2 个 thread = 4 个线程

因此四个线程分别对应:

text 复制代码
(block 0, thread 0)
(block 0, thread 1)
(block 1, thread 0)
(block 1, thread 1)

8.2 为什么需要 cudaDeviceSynchronize()

CUDA kernel launch 对 Host 线程是异步的。CPU 发出 kernel 启动请求之后,可以继续执行后面的主机代码,并不会默认等待 GPU 执行完毕。

这里 GPU 线程需要执行 printf。如果主机程序过早结束,设备端输出可能还没有完成,所以作业在 kernel launch 后调用:

cpp 复制代码
cudaDeviceSynchronize();

它会让主机等待此前提交的设备工作完成。

8.3 实际运行结果

text 复制代码
Hello from block: 0, thread: 0
Hello from block: 0, thread: 1
Hello from block: 1, thread: 0
Hello from block: 1, thread: 1

线程的实际执行顺序并没有保证,因此输出行的先后顺序发生变化也不代表程序错误。


9. 作业 2:Vector Add

这一题要求补全一个完整的向量加法程序,同时加入 CUDA Runtime API 的错误检查。

9.1 完整核函数

cpp 复制代码
__global__ void vadd(const float *A, const float *B, float *C, int ds) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;

    if (idx < ds) {
        C[idx] = A[idx] + B[idx];
    }
}

这里已经使用了前面的一维全局索引公式。

9.2 完整程序

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

#define cudaCheckErrors(msg) \
    do { \
        cudaError_t __err = cudaGetLastError(); \
        if (__err != cudaSuccess) { \
            fprintf(stderr, "Fatal error: %s (%s at %s:%d)\n", \
                    msg, cudaGetErrorString(__err), __FILE__, __LINE__); \
            fprintf(stderr, "*** FAILED - ABORTING\n"); \
            exit(1); \
        } \
    } while (0)

const int DSIZE = 4096;
const int block_size = 256;

__global__ void vadd(const float *A, const float *B, float *C, int ds) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;

    if (idx < ds) {
        C[idx] = A[idx] + B[idx];
    }
}

int main() {
    float *h_A, *h_B, *h_C;
    float *d_A, *d_B, *d_C;

    h_A = new float[DSIZE];
    h_B = new float[DSIZE];
    h_C = new float[DSIZE];

    for (int i = 0; i < DSIZE; i++) {
        h_A[i] = rand() / (float)RAND_MAX;
        h_B[i] = rand() / (float)RAND_MAX;
        h_C[i] = 0;
    }

    cudaMalloc(&d_A, DSIZE * sizeof(float));
    cudaMalloc(&d_B, DSIZE * sizeof(float));
    cudaMalloc(&d_C, DSIZE * sizeof(float));
    cudaCheckErrors("cudaMalloc failure");

    cudaMemcpy(d_A, h_A, DSIZE * sizeof(float), cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, DSIZE * sizeof(float), cudaMemcpyHostToDevice);
    cudaCheckErrors("cudaMemcpy H2D failure");

    int grid_size = (DSIZE + block_size - 1) / block_size;
    vadd<<<grid_size, block_size>>>(d_A, d_B, d_C, DSIZE);
    cudaCheckErrors("kernel launch failure");

    cudaMemcpy(h_C, d_C, DSIZE * sizeof(float), cudaMemcpyDeviceToHost);
    cudaCheckErrors("kernel execution or D2H copy failure");

    printf("A[0] = %f\n", h_A[0]);
    printf("B[0] = %f\n", h_B[0]);
    printf("C[0] = %f\n", h_C[0]);

    delete[] h_A;
    delete[] h_B;
    delete[] h_C;
    cudaFree(d_A);
    cudaFree(d_B);
    cudaFree(d_C);

    return 0;
}

9.3 实际输出

text 复制代码
A[0] = 0.001251
B[0] = 0.563585
C[0] = 0.564837

从打印出来的 6 位小数直接相加时,末位可能看起来存在一点差异。这里涉及浮点数表示以及输出时的小数位截取/舍入;判断浮点结果时不应依赖十进制打印值完全逐位相等。


10. CUDA 错误检查

这一题额外引入了 CUDA 错误检查:CUDA Runtime API 会返回错误状态,因此调试 CUDA 程序时应尽量检查调用结果。

作业提供的宏为:

cpp 复制代码
#define cudaCheckErrors(msg) \
    do { \
        cudaError_t __err = cudaGetLastError(); \
        if (__err != cudaSuccess) { \
            fprintf(stderr, "Fatal error: %s (%s at %s:%d)\n", \
                    msg, cudaGetErrorString(__err), __FILE__, __LINE__); \
            fprintf(stderr, "*** FAILED - ABORTING\n"); \
            exit(1); \
        } \
    } while (0)

在程序中分别检查:

cpp 复制代码
cudaCheckErrors("cudaMalloc failure");
cudaCheckErrors("cudaMemcpy H2D failure");
cudaCheckErrors("kernel launch failure");

kernel launch 的错误检查值得单独注意,因为 kernel 启动本身具有异步特征。启动配置等问题可以在 launch 后检查,而设备执行阶段产生的问题通常需要在后续同步操作或数据传输后才能暴露出来。


11. 作业 3:Naive Matrix Multiply

第三题把一维线程索引扩展到了二维。

对于矩阵乘法 C = A × B,希望输出矩阵 C 中的元素C[row][col],需要计算 A 的第 row 行与 B 的第 col 列的点积。

因此,一个很自然的 GPU 映射方式是:一个 CUDA 线程负责计算输出矩阵中的一个元素。

11.1 二维 block 与 grid

程序中定义:

cpp 复制代码
const int block_size = 16;

dim3 block(block_size, block_size);
dim3 grid(
    (DSIZE + block.x - 1) / block.x,
    (DSIZE + block.y - 1) / block.y
);

因此每个 block 中有 16 × 16 = 256 个线程。

dim3 可以保存 x、y、z 三个维度;这里使用 x 和 y,而 z 保持默认值 1。CUDA 没有单独的 dim2 类型,因此二维配置同样使用 dim3

对于 DSIZE = 4096

text 复制代码
grid.x = 4096 / 16 = 256
grid.y = 4096 / 16 = 256

于是得到 256 × 256 个 block,每个 block 包含 16 × 16 个线程。

11.2 二维全局索引

核函数中:

cpp 复制代码
int idx = threadIdx.x + blockDim.x * blockIdx.x;
int idy = threadIdx.y + blockDim.y * blockIdx.y;

可以把它们理解成:idx 是全局列索引 col,idy 是全局行索引 row。x 与 y 两个维度各自独立,x 决定列,y 决定行。

同样需要边界保护:

cpp 复制代码
if (idx < ds && idy < ds) {
    ...
}

11.3 一维内存如何表示二维矩阵

虽然逻辑上使用二维矩阵,但程序中的 float *Afloat *Bfloat *C 仍然指向一维连续内存。这是因为在行主序存储中,matrix[row][col] 对应的一维地址为 matrix[row * ds + col]。因此 C[idy * ds + idx] 就是 C[idy][idx]

11.4 朴素矩阵乘法核函数

cpp 复制代码
__global__ void mmul(const float *A,
                     const float *B,
                     float *C,
                     int ds) {

    int idx = threadIdx.x + blockDim.x * blockIdx.x;
    int idy = threadIdx.y + blockDim.y * blockIdx.y;

    if (idx < ds && idy < ds) {
        float temp = 0;

        for (int i = 0; i < ds; i++) {
            temp += A[idy * ds + i] * B[i * ds + idx];
        }

        C[idy * ds + idx] = temp;
    }
}

循环中的核心公式 A[idy * ds + i] * B[i * ds + idx] 分别取 A 的第 idy 行第 i 列和 B 的第 i 行第 idx 列,并对 i = 0 ... ds-1 累加,得到一个完整的行列点积。

二维索引直接对应矩阵的 row / col,代码与数学表达之间的映射非常直观。


12. 朴素矩阵乘法完整程序

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define cudaCheckErrors(msg) \
    do { \
        cudaError_t __err = cudaGetLastError(); \
        if (__err != cudaSuccess) { \
            fprintf(stderr, "Fatal error: %s (%s at %s:%d)\n", \
                    msg, cudaGetErrorString(__err), __FILE__, __LINE__); \
            fprintf(stderr, "*** FAILED - ABORTING\n"); \
            exit(1); \
        } \
    } while (0)

const int DSIZE = 4096;
const int block_size = 16;
const float A_val = 1.0f;
const float B_val = 2.0f;

__global__ void mmul(const float *A,
                     const float *B,
                     float *C,
                     int ds) {
    int idx = threadIdx.x + blockDim.x * blockIdx.x;
    int idy = threadIdx.y + blockDim.y * blockIdx.y;

    if (idx < ds && idy < ds) {
        float temp = 0;

        for (int i = 0; i < ds; i++) {
            temp += A[idy * ds + i] * B[i * ds + idx];
        }

        C[idy * ds + idx] = temp;
    }
}

int main() {
    float *h_A, *h_B, *h_C;
    float *d_A, *d_B, *d_C;

    clock_t t0, t1, t2;
    double t1sum = 0.0;
    double t2sum = 0.0;

    t0 = clock();

    h_A = new float[DSIZE * DSIZE];
    h_B = new float[DSIZE * DSIZE];
    h_C = new float[DSIZE * DSIZE];

    for (int i = 0; i < DSIZE * DSIZE; i++) {
        h_A[i] = A_val;
        h_B[i] = B_val;
        h_C[i] = 0;
    }

    t1 = clock();
    t1sum = ((double)(t1 - t0)) / CLOCKS_PER_SEC;
    printf("Init took %f seconds. Begin compute\n", t1sum);

    cudaMalloc(&d_A, DSIZE * DSIZE * sizeof(float));
    cudaMalloc(&d_B, DSIZE * DSIZE * sizeof(float));
    cudaMalloc(&d_C, DSIZE * DSIZE * sizeof(float));
    cudaCheckErrors("cudaMalloc failure");

    cudaMemcpy(d_A, h_A,
               DSIZE * DSIZE * sizeof(float),
               cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B,
               DSIZE * DSIZE * sizeof(float),
               cudaMemcpyHostToDevice);
    cudaCheckErrors("cudaMemcpy H2D failure");

    dim3 block(block_size, block_size);
    dim3 grid(
        (DSIZE + block.x - 1) / block.x,
        (DSIZE + block.y - 1) / block.y
    );

    mmul<<<grid, block>>>(d_A, d_B, d_C, DSIZE);
    cudaCheckErrors("kernel launch failure");

    cudaMemcpy(h_C, d_C,
               DSIZE * DSIZE * sizeof(float),
               cudaMemcpyDeviceToHost);

    t2 = clock();
    t2sum = ((double)(t2 - t1)) / CLOCKS_PER_SEC;
    printf("Done. Compute took %f seconds\n", t2sum);

    cudaCheckErrors("kernel execution or D2H copy failure");

    for (int i = 0; i < DSIZE * DSIZE; i++) {
        if (h_C[i] != A_val * B_val * DSIZE) {
            printf("mismatch at index %d, was: %f, should be: %f\n",
                   i, h_C[i], A_val * B_val * DSIZE);
            return -1;
        }
    }

    printf("Success!\n");

    delete[] h_A;
    delete[] h_B;
    delete[] h_C;
    cudaFree(d_A);
    cudaFree(d_B);
    cudaFree(d_C);

    return 0;
}

13.1 实际运行结果

text 复制代码
Init took 0.058000 seconds. Begin compute
Done. Compute took 2.140000 seconds
Success!

程序最后逐元素验证:h_C[i] == A_val * B_val * DSIZE,最终得到 Success!,说明本次计算结果通过了程序内置检查。这是因为 A 中所有元素 = 1,B 中所有元素 = 2,每一个输出元素都应该为:1 × 2 × 4096 = 8192。

课堂与课后答疑整理

13. Kernel 参数到底怎么传

核函数参数可以分成两类理解。

标量按值传递

例如:

cpp 复制代码
__global__ void kernel(float *data, int n)

其中 n 是普通标量,可以按值传入。

Device 数据通过设备指针访问

如果核函数需要访问 GPU 显存中的数组,应把对应的 device pointer 传进去:

cpp 复制代码
kernel<<<grid, block>>>(d_data, N);

指针参数本身同样是按值传递,传递的是地址值;这个地址必须指向设备代码能够合法访问的内存。

因此要始终区分主机内存和设备内存,不能因为二者在 C/C++ 类型上都写作 float *,就忽略它们所指向的实际内存域。


14. Kernel 可以放在不同源文件中

核函数不要求与调用它的主机代码写在同一个 .cu 文件里。

和普通 C++ 函数类似,可以:

  1. 在头文件中声明;
  2. 在一个模块中调用;
  3. 在另一个 CUDA 源文件中定义。

声明时保留 __global__

cpp 复制代码
// kernels.cuh
__global__ void add(float *a, float *b, float *c, int n);

也就是说 CUDA 项目同样可以按正常工程方式拆分模块,而不需要把所有 kernel 都堆进一个文件。


15. CUDA Kernel 也可以使用 C++ 模板

例如:

cpp 复制代码
template <typename T>
__global__ void add(T *a, T *b, T *c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;

    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

这样同一个 kernel 可以用于 intfloatdouble 等类型,具体模板参数由编译器实例化。


16. CUDA 与 C++ 智能指针、标准库

主机端仍然可以正常使用现代 C++ 的 std::unique_ptrstd::shared_ptr 等工具,但进入设备代码后,需要区分哪些语言特性和标准库组件真正可以用于设备的。

一个实用的原则是:不要默认主机端可用的 std:: 组件在 kernel 中也可以直接使用。

如果主机端使用智能指针管理一段需要传给 CUDA Runtime API 的内存,调用 CUDA API 时通常仍需要取得裸指针,例如 ptr.get();而由 cudaMalloc 分配的显存本身直接使用 device pointer 管理和传递。

所以遇到需要在 kernel 中使用的 C++ 标准库能力时,应单独确认 CUDA 对该组件的设备端支持情况。


17. C++ 对象能不能传给 GPU

可以,但需要关注对象本身的内存布局以及它依赖的数据。

对于只包含简单成员的对象,例如:

cpp 复制代码
struct Vec2 {
    float x, y;

    __host__ __device__
    Vec2(float _x, float _y) : x(_x), y(_y) {}

    __device__
    float length() const {
        return sqrtf(x * x + y * y);
    }
};

可以考虑两种方式。

17.1 按值传递

cpp 复制代码
__global__ void processByValue(Vec2 v, float *output) {
    output[threadIdx.x] = v.length() + threadIdx.x;
}

使用processByValue<<<1, N>>>(hostVec, d_out);适合体积较小的简单对象。

17.2 复制到设备内存后通过指针传递

cpp 复制代码
Vec2 *d_vec;
cudaMalloc(&d_vec, sizeof(Vec2));
cudaMemcpy(d_vec, &hostVec, sizeof(Vec2), cudaMemcpyHostToDevice);

核函数:

cpp 复制代码
__global__ void processByPointer(Vec2 *d_vec, float *output) {
    output[threadIdx.x] = d_vec->length() * (threadIdx.x + 1);
}

如果成员函数需要在设备端执行,需要使用 __device__,或者根据需求使用:

cpp 复制代码
__host__ __device__

需要特别小心含有 std::vectorstd::string 等动态资源成员的对象。 直接 cudaMemcpy 一个对象只会复制对象当前的字节内容,其中保存的主机上的地址并不能自动变成 GPU 上可以访问的有效设备数据。


18. Thrust:CUDA 生态中的高级并行算法库

Thrust 提供了类似 C++ STL 使用体验的并行容器和算法,例如 sortreducescancopy。它可以让很多常见 GPU 并行操作不必从零手写 kernel。

一个简单的排序例子:

cpp 复制代码
#include <thrust/host_vector.h>
#include <thrust/device_vector.h>
#include <thrust/sort.h>
#include <thrust/copy.h>

int main() {
    thrust::host_vector<int> h_vec(5);

    h_vec[0] = 5;
    h_vec[1] = 1;
    h_vec[2] = 4;
    h_vec[3] = 2;
    h_vec[4] = 3;

    thrust::device_vector<int> d_vec = h_vec;

    thrust::sort(d_vec.begin(), d_vec.end());
    thrust::copy(d_vec.begin(), d_vec.end(), h_vec.begin());

    return 0;
}

其中:

  • host_vector 管理主机端数据;
  • device_vector 管理设备端数据;
  • h_vec 构造 d_vec 时完成数据传输;
  • thrust::sort 在设备数据上执行排序;
  • thrust::copy 把结果复制回主机。

Thrust 的接口大量由头文件提供,使用时通常直接 #include 对应头文件,不需要像普通二进制库那样额外链接一套 .lib/.so。这种 header-only 形式配置简单、跨平台方便;代价是模板代码会在使用它的编译单元中实例化,可能增加编译时间,也可能让最终程序体积略有增加。

对于许多常见并行原语,Thrust 的性能可以接近经过良好优化的手写 CUDA 实现,我查到的效率是大约 90%~95%。这个数字适合作为学习时的直觉,不应理解为所有算法、数据规模和 GPU 上都固定成立的性能保证。

命名空间方面,建议明确写出 thrust::sort(...)thrust::copy(...) 等调用,而不要在大范围内直接using namespace thrust;

因为 sortcopy 等名称与 std 中的常用算法容易发生冲突,显式前缀也更容易看出一段代码是在使用哪套库。

课堂中把 Thrust 的使用体验类比成 SDL 一类"开箱即用"的库,重点在于使用便利性;而二者的功能领域并不相同:Thrust 面向并行计算,SDL 面向多媒体。


19. Host 与 Device 的基础数据类型需要一致

C/C++ 标准并没有为 intlong 等所有基础整数类型规定跨平台完全相同的具体大小,不同平台和编译器可能采用 LP64、LLP64 等不同数据模型。

CUDA 程序同时包含 Host 编译和 Device 编译,因此两端必须在支持的工具链组合下保持一致的数据布局和调用约定。实际安装 CUDA Toolkit 时,应使用 NVIDIA 官方支持的主机编译器及版本组合,而不要随意混搭未经支持的编译环境。

这也是 CUDA 对 host compiler 版本有明确兼容范围的原因之一。


20. 回顾

如果只保留这一节最核心的内容,我认为可以重点理解以下内容:

  1. Kernel 是由 CPU 启动、在 GPU 上由大量线程执行的函数。
  2. <<<grid, block>>> 决定线程如何组织。
  3. 一维全局索引最常用的公式:
cpp 复制代码
   int idx = blockIdx.x * blockDim.x + threadIdx.x;
  1. 线程总数可能多于数据量,因此需要边界检查:
cpp 复制代码
   if (idx < N)
  1. 典型 CUDA 数据流程:
text 复制代码
   数据准备
       ↓
 cudaMemcpy H2D
       ↓
 kernel launch
       ↓
 cudaMemcpy D2H
       ↓
     后处理
  1. Kernel launch 对 Host 是异步的,需要在确实要求等待 GPU 完成时进行同步。
  2. 向量加法属于典型的易并行问题,一个线程可以直接对应一个元素。
  3. 二维 grid/block 可以自然映射到矩阵的行与列。
  4. Host 与 Device 之间的数据传输可能成为性能瓶颈,应尽量减少不必要的数据搬运。
  5. CUDA 仍然可以使用很多 C++ 能力,但设备端代码需要单独考虑 CUDA 对语言特性、标准库和对象布局的支持。

第一节课完成之后,CUDA 程序已经不再只是一个抽象的"GPU 加速"概念:线程如何编号、数据怎样进入显存、kernel 怎样启动、结果怎样取回,以及一维问题如何扩展到二维问题,都已经可以通过实际代码完整串起来了。

相关推荐
xier_ran3 天前
【infra之路】GPU 执行与存储层次全景关系图
人工智能·深度学习·cuda
椒颜皮皮虾྅7 天前
【TensorRTtSharp v4.0】在 C# 中使用 TensorRT CSharp API v4.0 动态编译并运行 CUDA Kernel
android·开发语言·人工智能·深度学习·c#·cuda
Albart5757 天前
vLLM多卡部署终极踩坑:CUDA error worker进程异常退出 完整定位&生产根治方案
cuda·nccl·vllm·大模型部署·多卡推理·大模型踩坑
帅气的小峰9 天前
pybind11与nanobind可以共存吗?如何迁移?
c++·python·cuda·推理引擎
(initial)12 天前
C-05. Kernel Fusion 代价边界:少写回 vs 寄存器压力与 occupancy
c语言·开发语言·cuda
June`15 天前
并发内核执行
c++·人工智能·算法·cuda
Olafur_zbj16 天前
【AI】CUDA的新编程模型:tile编程模型
人工智能·cuda·tile
(initial)18 天前
C-02. Cooperative Groups:安全分组、tile 集体与 coalesced 聚合
cuda
June`21 天前
warp shuffle指令
c++·人工智能·算法·cuda