通过一个完整的向量加法案例,走通 CUDA 编程的全流程:内存分配、数据传输、Kernel 编写、错误检查和性能测量,并对比 CPU 与 GPU 的执行效率。
📑 目录
1. 从 CPU 到 GPU:一个完整的开发流程
将计算任务从 CPU 搬到 GPU 就像把工作从一个能干的员工交给一支千人团队——你需要准备材料、分配任务、等待完成、收回成果。对应到 CUDA 编程,完整流程是:
graph LR
A["1. 分配 GPU 内存"] --> B["2. CPU→GPU 传数据"]
B --> C["3. 启动 Kernel"]
C --> D["4. GPU→CPU 取结果"]
D --> E["5. 释放 GPU 内存"]
每一步都有对应的 CUDA API:
| 步骤 |
API |
说明 |
| 分配 GPU 内存 |
cudaMalloc() |
类似 CPU 的 malloc() |
| Host→Device 传输 |
cudaMemcpy(..., HostToDevice) |
CPU 数据拷贝到 GPU |
| 启动 Kernel |
kernel<<<grid, block>>>(args) |
GPU 并行执行 |
| Device→Host 传输 |
cudaMemcpy(..., DeviceToHost) |
结果拷回 CPU |
| 释放 GPU 内存 |
cudaFree() |
类似 CPU 的 free() |
2. 向量加法 Kernel 实现
向量加法是最简单的并行问题——每个元素的计算完全独立,天然适合 GPU。给定两个长度为 N 的向量 A 和 B,计算:
$$
C[i] = A[i] + B[i], \quad i = 0, 1, \ldots, N-1
$$
2.1 Kernel 函数
1 2 3 4 5 6 7 8 9
| __global__ void vectorAdd(const float* A, const float* B, float* C, int N) { int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) { C[idx] = A[idx] + B[idx]; } }
|
代码解析:
const float*:A 和 B 是只读输入
int idx:每个线程计算一个唯一的全局索引
if (idx < N):最后一个 Block 可能有多余线程,必须检查边界
C[idx] = A[idx] + B[idx]:每个线程独立完成一个元素的加法
2.2 为什么需要边界检查
假设 N = 1000,Block 大小 = 256:
- 需要 Block 数 = $\lceil 1000/256 \rceil = 4$
- 总线程数 = 4 × 256 = 1024
- 多出 24 个线程的索引为 1000~1023,超出数组范围
没有 if (idx < N) 检查,这些线程会访问非法内存地址,导致未定义行为或程序崩溃。
3. 主机端完整代码
3.1 内存管理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| #include <cstdio> #include <cstdlib> #include <cuda_runtime.h>
int main() { int N = 1 << 20; size_t size = N * sizeof(float);
float* h_A = (float*)malloc(size); float* h_B = (float*)malloc(size); float* h_C = (float*)malloc(size);
for (int i = 0; i < N; i++) { h_A[i] = (float)i; h_B[i] = (float)(i * 2); }
float *d_A, *d_B, *d_C; cudaMalloc(&d_A, size); cudaMalloc(&d_B, size); cudaMalloc(&d_C, size);
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice); cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);
int blockSize = 256; int gridSize = (N + blockSize - 1) / blockSize; vectorAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
for (int i = 0; i < N; i++) { if (fabsf(h_C[i] - (h_A[i] + h_B[i])) > 1e-5f) { printf("Verification FAILED at index %d!\n", i); break; } } printf("Verification PASSED!\n");
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C); free(h_A); free(h_B); free(h_C);
return 0; }
|
3.2 编译与运行
1 2 3
| $ nvcc vectorAdd.cu -o vectorAdd $ ./vectorAdd Verification PASSED!
|
4. CUDA 错误检查
CUDA API 调用可能失败(内存不足、参数错误等),生产代码中必须检查每个调用的返回值。
4.1 宏定义
1 2 3 4 5 6 7 8 9
| #define CUDA_CHECK(call) \ do { \ cudaError_t err = call; \ if (err != cudaSuccess) { \ fprintf(stderr, "CUDA error at %s:%d - %s\n", \ __FILE__, __LINE__, cudaGetErrorString(err)); \ exit(EXIT_FAILURE); \ } \ } while (0)
|
4.2 使用方式
1 2 3 4 5 6 7
| CUDA_CHECK(cudaMalloc(&d_A, size)); CUDA_CHECK(cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice));
vectorAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N); CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaDeviceSynchronize());
|
4.3 常见错误码
| 错误码 |
含义 |
常见原因 |
cudaErrorMemoryAllocation |
内存分配失败 |
显存不足 |
cudaErrorInvalidValue |
参数无效 |
size=0 或空指针 |
cudaErrorInvalidConfiguration |
启动配置无效 |
Block 超过 1024 线程 |
cudaErrorIllegalAddress |
非法内存访问 |
越界/野指针 |
cudaErrorLaunchTimeout |
Kernel 执行超时 |
死循环或计算量过大 |
💡 提示:开发阶段建议在每个 CUDA API 后都加上错误检查。发布时可以用编译宏控制,在 Release 模式下去掉检查开销。
5. 性能测量:CPU vs GPU
5.1 CPU 基线实现
1 2 3 4 5
| void vectorAddCPU(const float* A, const float* B, float* C, int N) { for (int i = 0; i < N; i++) { C[i] = A[i] + B[i]; } }
|
5.2 使用 CUDA Event 计时
CUDA Event 是 GPU 端精确计时的标准方法,精度达微秒级:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop);
cudaEventRecord(start);
vectorAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);
cudaEventRecord(stop); cudaEventSynchronize(stop);
float milliseconds = 0; cudaEventElapsedTime(&milliseconds, start, stop); printf("GPU Kernel time: %.4f ms\n", milliseconds);
cudaEventDestroy(start); cudaEventDestroy(stop);
|
5.3 包含数据传输的端到端计时
1 2 3 4 5 6 7 8 9 10 11 12 13
| cudaEventRecord(start);
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice); cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice); vectorAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N); cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
cudaEventRecord(stop); cudaEventSynchronize(stop);
float totalTime = 0; cudaEventElapsedTime(&totalTime, start, stop); printf("GPU total (with memcpy): %.4f ms\n", totalTime);
|
5.4 性能结果对比(参考值)
在 RTX 4090 上,N = $2^{24}$(约 1600 万元素)的典型结果:
| 📊 指标 |
CPU (单核) |
GPU (仅 Kernel) |
GPU (含传输) |
| 耗时 |
~25 ms |
~0.3 ms |
~8 ms |
| 有效带宽 |
~5 GB/s |
~400 GB/s |
~15 GB/s |
⚠️ 注意:向量加法是内存密集型操作(每个元素只做 1 次加法但需要读 2 个 float、写 1 个 float)。包含数据传输时,PCIe 带宽成为瓶颈。GPU 的优势在数据已经在显存上时最明显。
6. 完整可运行代码
将以上所有部分整合为一个完整的、可直接编译运行的程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
| #include <cstdio> #include <cstdlib> #include <cmath> #include <chrono> #include <cuda_runtime.h>
#define CUDA_CHECK(call) \ do { \ cudaError_t err = call; \ if (err != cudaSuccess) { \ fprintf(stderr, "CUDA error at %s:%d - %s\n", \ __FILE__, __LINE__, cudaGetErrorString(err)); \ exit(EXIT_FAILURE); \ } \ } while (0)
__global__ void vectorAdd(const float* A, const float* B, float* C, int N) { int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < N) { C[idx] = A[idx] + B[idx]; } }
void vectorAddCPU(const float* A, const float* B, float* C, int N) { for (int i = 0; i < N; i++) { C[i] = A[i] + B[i]; } }
int main() { const int N = 1 << 24; const size_t size = N * sizeof(float); printf("Vector size: %d elements (%.1f MB)\n", N, size / (1024.0 * 1024.0));
float* h_A = (float*)malloc(size); float* h_B = (float*)malloc(size); float* h_C_cpu = (float*)malloc(size); float* h_C_gpu = (float*)malloc(size);
for (int i = 0; i < N; i++) { h_A[i] = sinf(i * 0.001f); h_B[i] = cosf(i * 0.001f); }
auto cpu_start = std::chrono::high_resolution_clock::now(); vectorAddCPU(h_A, h_B, h_C_cpu, N); auto cpu_end = std::chrono::high_resolution_clock::now(); float cpu_ms = std::chrono::duration<float, std::milli>(cpu_end - cpu_start).count(); printf("CPU time: %.4f ms\n", cpu_ms);
float *d_A, *d_B, *d_C; CUDA_CHECK(cudaMalloc(&d_A, size)); CUDA_CHECK(cudaMalloc(&d_B, size)); CUDA_CHECK(cudaMalloc(&d_C, size));
cudaEvent_t start, stop, kernel_start, kernel_stop; cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventCreate(&kernel_start); cudaEventCreate(&kernel_stop);
cudaEventRecord(start);
CUDA_CHECK(cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice)); CUDA_CHECK(cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice));
int blockSize = 256; int gridSize = (N + blockSize - 1) / blockSize;
cudaEventRecord(kernel_start); vectorAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N); cudaEventRecord(kernel_stop);
CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaMemcpy(h_C_gpu, d_C, size, cudaMemcpyDeviceToHost));
cudaEventRecord(stop); cudaEventSynchronize(stop);
float gpu_total_ms = 0, kernel_ms = 0; cudaEventElapsedTime(&gpu_total_ms, start, stop); cudaEventElapsedTime(&kernel_ms, kernel_start, kernel_stop);
printf("GPU kernel time: %.4f ms\n", kernel_ms); printf("GPU total time (with memcpy): %.4f ms\n", gpu_total_ms); printf("Speedup (kernel only): %.1fx\n", cpu_ms / kernel_ms); printf("Speedup (end-to-end): %.1fx\n", cpu_ms / gpu_total_ms);
int errors = 0; for (int i = 0; i < N; i++) { if (fabsf(h_C_gpu[i] - h_C_cpu[i]) > 1e-5f) { if (errors < 5) { printf("Mismatch at %d: CPU=%.6f, GPU=%.6f\n", i, h_C_cpu[i], h_C_gpu[i]); } errors++; } } if (errors == 0) { printf("Verification PASSED!\n"); } else { printf("Verification FAILED: %d mismatches\n", errors); }
float bandwidth_gb = (3.0f * size) / (kernel_ms * 1e-3f) / 1e9f; printf("Effective bandwidth: %.1f GB/s\n", bandwidth_gb);
cudaEventDestroy(start); cudaEventDestroy(stop); cudaEventDestroy(kernel_start); cudaEventDestroy(kernel_stop); CUDA_CHECK(cudaFree(d_A)); CUDA_CHECK(cudaFree(d_B)); CUDA_CHECK(cudaFree(d_C)); free(h_A); free(h_B); free(h_C_cpu); free(h_C_gpu);
return 0; }
|
1 2 3 4 5
| nvcc -O3 -arch=sm_89 vectorAdd.cu -o vectorAdd
./vectorAdd
|
7. 性能分析与讨论
7.1 向量加法的算术强度
算术强度(Arithmetic Intensity)= 计算量 / 内存访问量:
$$
\text{AI} = \frac{\text{FLOPs}}{\text{Bytes}} = \frac{1 \text{ FLOP}}{12 \text{ Bytes}} \approx 0.083 \text{ FLOP/Byte}
$$
这个值极低,说明向量加法是典型的内存密集型操作,性能受限于内存带宽而非计算能力。
7.2 带宽利用率分析
如果 Kernel 时间为 0.3 ms,处理 $3 \times 2^{24} \times 4$ Bytes = 201 MB 数据:
$$
\text{有效带宽} = \frac{201 \text{ MB}}{0.3 \text{ ms}} \approx 670 \text{ GB/s}
$$
对比 RTX 4090 的理论峰值带宽 1008 GB/s,利用率约 66%——对于如此简单的 Kernel 来说已经不错。
7.3 PCIe 传输的开销
| 📊 环节 |
典型耗时 |
占比 |
| H2D 传输 (64MB × 2) |
~5 ms |
~60% |
| Kernel 执行 |
~0.3 ms |
~4% |
| D2H 传输 (64MB) |
~3 ms |
~36% |
📌 关键点:对于简单的逐元素操作,数据传输往往是主要瓶颈。实际应用中应尽量让数据”留在 GPU 上”——多个 Kernel 串联处理同一批数据,避免反复搬运。
7.4 何时值得用 GPU
| 场景 |
CPU 更好 |
GPU 更好 |
| 数据量小(< 10K 元素) |
✅ |
❌ |
| 单次简单操作 + 数据不在 GPU |
✅ |
❌ |
| 大数据 + 数据已在 GPU |
❌ |
✅ |
| 大数据 + 多步串联计算 |
❌ |
✅ |
| 计算密集型(如矩阵乘法) |
❌ |
✅ |
8. 进阶练习
完成向量加法后,尝试以下练习来巩固所学:
8.1 练习 1:向量点积
实现两个向量的点积 $\text{result} = \sum_{i=0}^{N-1} A[i] \times B[i]$。
提示:这需要规约(Reduction)——每个线程计算部分乘积,然后在 Block 内用共享内存累加。
8.2 练习 2:SAXPY
实现 SAXPY 操作:$Y[i] = \alpha \times X[i] + Y[i]$
这是 BLAS 的基础操作,比向量加法多一个标量乘法。
8.3 练习 3:Grid-stride Loop 版本
将向量加法改为 Grid-stride loop 写法,启动固定数量的线程处理任意大小的数组:
1 2 3 4 5 6 7 8 9 10 11 12
| __global__ void vectorAddStride(const float* A, const float* B, float* C, int N) { int idx = blockIdx.x * blockDim.x + threadIdx.x; int stride = blockDim.x * gridDim.x; for (int i = idx; i < N; i += stride) { C[i] = A[i] + B[i]; } }
vectorAddStride<<<128, 256>>>(d_A, d_B, d_C, N);
|
8.4 练习 4:异步传输与计算重叠
使用 CUDA Stream 让数据传输和计算并行执行:
1 2 3 4 5 6 7 8 9 10 11
| cudaStream_t stream; cudaStreamCreate(&stream);
cudaMemcpyAsync(d_A, h_A, size, cudaMemcpyHostToDevice, stream); cudaMemcpyAsync(d_B, h_B, size, cudaMemcpyHostToDevice, stream); vectorAdd<<<gridSize, blockSize, 0, stream>>>(d_A, d_B, d_C, N); cudaMemcpyAsync(h_C, d_C, size, cudaMemcpyDeviceToHost, stream);
cudaStreamSynchronize(stream); cudaStreamDestroy(stream);
|
💡 提示:要实现真正的重叠,主机端内存必须使用 cudaMallocHost()(Pinned Memory)分配。
📝 总结
通过向量加法这个完整案例,我们掌握了 CUDA 编程的核心流程:
- 五步标准流程:cudaMalloc → cudaMemcpy(H2D) → Kernel Launch → cudaMemcpy(D2H) → cudaFree
- Kernel 编写三要素:全局索引计算 + 边界检查 + 并行计算
- 错误检查:用
CUDA_CHECK 宏包裹每个 API 调用
- 性能度量:CUDA Event 计时 + 有效带宽计算
- 性能认知:简单操作的瓶颈在内存传输,GPU 优势在数据驻留和计算密集场景
向量加法虽然简单,但它建立起了 CUDA 编程的完整框架。后续学习矩阵乘法、卷积、规约等高级 Kernel 时,都是在这个框架上增加复杂度。
🎯 自我检验清单
- 能独立写出 CUDA 向量加法的完整代码(含内存管理和错误检查)
- 能解释为什么 Kernel 中需要
if (idx < N) 边界检查
- 能使用 CUDA Event 精确测量 Kernel 执行时间
- 能计算给定 Kernel 的有效内存带宽并与理论峰值对比
- 能判断一个操作是计算密集型还是内存密集型
- 能解释为什么包含数据传输的端到端时间远大于纯 Kernel 时间
- 能使用 Grid-stride loop 模式处理任意大小的数据
- 能使用
CUDA_CHECK 宏编写健壮的 CUDA 代码
📚 参考资料