第四节 第一个CUDA程序:向量加法的完整实践

从这一节开始,我们正式进入CUDA C的编码世界。向量加法(Vector Addition)是并行计算领域的“Hello World”,它虽然简单,却涵盖了CUDA编程模型中最核心的四个要素:内核函数(Kernel)、线程层次结构(Thread Hierarchy)、内存模型(Memory Model)以及主机与设备之间的数据搬运。通过亲手编写并运行这个程序,你将直观地理解<<< >>>启动语法背后的执行逻辑,掌握线程索引与数据下标之间的映射关系,并建立起一套完整的编译、运行与调试流程。本节将按照“原理剖析 → 代码实现 → 编译运行 → 调试排障”的路径,带你完成这一关键里程碑。

CUDA向量加法执行模型与内存映射 主机端 (Host) float h_A[N] float h_B[N] float h_C[N] cudaMalloc / cudaMemcpy (Host→Device) 设备端 (Device) float *d_A float *d_B float *d_C cudaMemcpy (Device→Host) 结果回传 cudaMemcpy(..., cudaMemcpyHostToDevice) cudaMemcpy(..., cudaMemcpyDeviceToHost) GPU 内核执行 (Kernel: vecAdd) Grid Block(0,0) Block(1,0) Block(2,0) ... Block(N/256,0) Block(0,0) Thread 0 Thread 1 Thread 2 ... Thread 255 索引映射 i = blockIdx.x * blockDim.x + threadIdx.x 边界检查 d_A, d_B 按索引读取 d_C[i] = d_A[i] + d_B[i] → 写回全局内存

上图展示了向量加法程序的核心执行模型。主机端负责准备数据并通过PCIe总线拷贝到设备端全局内存,然后以网格(Grid)和线程块(Block)的形式组织成千上万个线程并行执行内核函数。每个线程通过内置变量blockIdx.x、threadIdx.x和blockDim.x计算出自己负责的数据元素下标,从而实现对数组元素的并行处理。理解这张图,就理解了CUDA并行编程的“骨架”。

4.1 内核启动语法与线程层次结构

4.1.1 核心原理:从串行到并行的思维转变

在传统C语言中,一个简单的for循环即可完成向量加法:

for (int i = 0; i < N; i++) {
    C[i] = A[i] + B[i];
}

这段代码在单核CPU上按顺序执行N次加法。CUDA的并行思路是:将循环体“向量化”为内核函数,让GPU的数千个线程同时执行这个循环体。每个线程只处理一个(或少数几个)数组元素,所有线程并行推进,从而将时间复杂度从O(N)降为O(1)(理想情况下)。

内核函数使用__global__修饰符声明,其返回值必须为void。启动内核时使用尖括号语法<<<gridDim, blockDim>>>,其中:

  • gridDim:网格维度,即线程块(Block)的数量,可以是整数或dim3类型(支持二维/三维网格)。
  • blockDim:线程块维度,即每个Block内的线程数量,同样支持dim3类型。

例如vecAdd<<<4, 256>>>(d_A, d_B, d_C, N);表示启动4个Block,每个Block包含256个线程,总共1024个线程参与计算。硬件上,GPU会将Block调度到流多处理器(SM)上执行,每个Block内的线程进一步划分为32个线程一组的线程束(Warp)进行调度。

4.1.2 线程索引的计算方法

线程在网格中的全局唯一ID(即数组下标)由以下公式计算:

int i = blockIdx.x * blockDim.x + threadIdx.x;

这个公式是CUDA编程中使用频率最高的表达式。其含义是:当前线程所在Block的编号(blockIdx.x)乘以每个Block的线程数(blockDim.x),再加上线程在Block内的编号(threadIdx.x)。对于一维场景,这个公式直观且高效。对于二维或三维场景,需要分别计算x、y、z三个维度的索引,并通常将y维度乘以x维度的宽度来线性化。

值得注意的是,网格和Block的维度上限:每个Grid的x/y/z维度最大为2^31-1、65535、65535;每个Block的线程总数不能超过1024,且x维度不能超过1024,y/z维度不能超过1024。这些限制由硬件架构决定,编程时必须遵守。

4.1.3 案例拆解:一个典型的向量加法内核

下面是一个完整的向量加法内核函数,注意其中包含边界检查——这是生产级代码的必备要素,因为当N不能被blockDim.x整除时,最后一个Block的部分线程会越界:

__global__ void vecAdd(float *A, float *B, float *C, int N) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {  // 边界检查,防止越界访问
        C[i] = A[i] + B[i];
    }
}

假设N=1000,我们设置blockDim.x=256,则需要的Block数量为ceil(1000/256)=4。前3个Block(共768个线程)处理下标0~767,第4个Block的256个线程中只有前232个(下标768~999)执行有效计算,其余24个线程因不满足i < N而直接退出。这种“部分线程闲置”的现象在并行计算中不可避免,但通过合理选择Block大小可以最小化浪费。

经验提示:Block大小通常选择128、256或512。过小(如32)会导致调度开销占比过高;过大(如1024)可能降低资源利用率。对于简单的算术密集型内核,256是兼顾占用率和调度效率的常用选择。

4.2 完整程序编写与编译运行流程

4.2.1 程序骨架:主机代码与设备代码的协作

一个完整的CUDA程序包含主机代码(运行在CPU上)和设备代码(运行在GPU上)。主机代码负责内存管理、数据搬运和内核启动。以下是向量加法的完整实现:

#include <stdio.h>
#include <cuda_runtime.h>

#define N 1000
#define BLOCK_SIZE 256

// 内核函数(设备代码)
__global__ void vecAdd(float *A, float *B, float *C, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        C[i] = A[i] + B[i];
    }
}

int main() {
    // 1. 分配主机内存并初始化数据
    float *h_A = (float*)malloc(N * sizeof(float));
    float *h_B = (float*)malloc(N * sizeof(float));
    float *h_C = (float*)malloc(N * sizeof(float));
    for (int i = 0; i < N; i++) {
        h_A[i] = i * 1.0f;
        h_B[i] = i * 2.0f;
    }

    // 2. 分配设备全局内存
    float *d_A, *d_B, *d_C;
    cudaMalloc((void**)&d_A, N * sizeof(float));
    cudaMalloc((void**)&d_B, N * sizeof(float));
    cudaMalloc((void**)&d_C, N * sizeof(float));

    // 3. 将输入数据从主机拷贝到设备
    cudaMemcpy(d_A, h_A, N * sizeof(float), cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, N * sizeof(float), cudaMemcpyHostToDevice);

    // 4. 计算网格和Block维度,启动内核
    int gridSize = (N + BLOCK_SIZE - 1) / BLOCK_SIZE;
    vecAdd<<<gridSize, BLOCK_SIZE>>>(d_A, d_B, d_C, N);

    // 5. 将结果从设备拷贝回主机
    cudaMemcpy(h_C, d_C, N * sizeof(float), cudaMemcpyDeviceToHost);

    // 6. 验证结果
    for (int i = 0; i < N; i++) {
        if (h_C[i] != h_A[i] + h_B[i]) {
            printf("Error at index %d: %f + %f != %f\n", i, h_A[i], h_B[i], h_C[i]);
            break;
        }
    }
    printf("Vector addition completed successfully!\n");

    // 7. 释放内存
    free(h_A); free(h_B); free(h_C);
    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    return 0;
}

4.2.2 编译与运行:nvcc编译器使用详解

CUDA程序使用nvcc编译器进行编译。nvcc是NVIDIA提供的专用编译器驱动,它会将设备代码(__global__函数)编译为PTX(并行线程执行)中间代码,再进一步编译为适用于具体GPU架构的SASS(流处理器汇编)代码。编译命令如下:

nvcc -arch=sm_80 -o vecAdd vecAdd.cu

参数说明:

  • -arch=sm_80:指定目标GPU架构。sm_80对应Ampere架构(如A100),sm_86对应RTX 30系列,sm_90对应Hopper架构(H100)。如果不指定,nvcc会使用默认值,但可能无法发挥新硬件的全部特性。
  • -o vecAdd:指定输出可执行文件名。
  • vecAdd.cu:源文件,.cu扩展名是CUDA源文件的标准后缀。

运行程序:

./vecAdd

如果一切正常,终端将输出“Vector addition completed successfully!”。

4.2.3 调试流程:从错误信息到问题定位

CUDA编程中,错误排查是必备技能。常见的错误类型包括:

错误类型典型错误信息可能原因排查方法
内存分配失败"cudaMalloc failed: out of memory"请求的显存超过GPU可用容量检查N的大小,使用cudaGetDeviceProperties查询显存
非法内存访问"CUDA error: an illegal memory access was encountered"内核中数组越界或空指针检查边界条件,使用cuda-memcheck工具
内核启动失败"CUDA error: invalid configuration argument"Block维度超过1024或Grid维度超限检查<<< >>>参数
数据未同步结果全为0或随机值忘记调用cudaMemcpy回传,或未使用cudaDeviceSynchronize检查内存拷贝方向,添加同步点

推荐在每次CUDA API调用后检查返回值:

cudaError_t err = cudaMemcpy(d_A, h_A, N * sizeof(float), cudaMemcpyHostToDevice);
if (err != cudaSuccess) {
    printf("cudaMemcpy failed: %s\n", cudaGetErrorString(err));
    exit(1);
}

此外,cuda-memcheck工具(现已更名为compute-sanitizer)可以检测内核中的越界访问和内存泄漏:

compute-sanitizer ./vecAdd
风险警示:内核启动是异步的!CPU不会等待GPU执行完毕就继续执行后续代码。如果内核出错,错误信息可能直到下一次同步点(如cudaMemcpy或cudaDeviceSynchronize)才会被捕获。因此,在调试阶段,建议在内核启动后立即调用cudaDeviceSynchronize(),以便快速定位错误发生的时机。

4.3 性能观察与优化初探

4.3.1 使用CUDA事件测量内核执行时间

向量加法虽然简单,但测量其执行时间有助于建立性能基准。CUDA事件(Event)是官方推荐的计时工具,它基于GPU硬件时钟,精度远高于CPU端的clock()函数:

cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start);
vecAdd<<<gridSize, BLOCK_SIZE>>>(d_A, d_B, d_C, N);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float milliseconds = 0;
cudaEventElapsedTime(&milliseconds, start, stop);
printf("Kernel execution time: %f ms\n", milliseconds);

对于N=1000的向量加法,内核执行时间通常在微秒级别,甚至低于计时精度。为了获得有意义的测量结果,建议将N增大到100万以上,并重复执行内核多次取平均值。

4.3.2 内存带宽与计算强度的初步分析

向量加法是典型的内存密集型(Memory-Bound)任务:每个线程执行一次浮点加法(2 FLOPs),但需要读取两个浮点数(8字节)并写入一个浮点数(4字节),总共12字节的内存流量。其算术强度仅为2 FLOPs / 12 Bytes ≈ 0.17 FLOPs/B,远低于现代GPU的平衡点(通常为10~20 FLOPs/B)。因此,向量加法的性能瓶颈在于内存带宽,而非计算能力。

以NVIDIA A100为例,其HBM2e显存带宽约为2TB/s。理论上,处理100万个元素的向量加法需要传输100万 × 12字节 = 12MB数据,耗时约12MB / 2TB/s = 6微秒。实际测量值通常会高于理论值,因为还存在内核启动开销、内存访问延迟等因素。

复盘总结:通过向量加法,我们应掌握以下核心知识点:
1. __global__定义内核,<<<grid, block>>>启动内核,两者缺一不可。
2. 线程索引公式i = blockIdx.x * blockDim.x + threadIdx.x是数据并行映射的基石。
3. 边界检查是防止越界访问的“安”,任何生产级内核都必须包含。
4. 主机与设备之间的数据搬运(cudaMemcpy)是PCIe带宽瓶颈,应尽量减少传输次数。
5. 使用CUDA事件计时和compute-sanitizer工具,是性能分析和正确性验证的起点。