项目实战
CUDA 编程基础教程(面向 Java/Python 开发者)
目标读者:熟悉 Java,会一些 Python,但没有 C/C++ 系统经验,想入门 GPU 编程的开发者。 学习定位:本教程不会一次性讲完所有 CUDA 特性,而是帮你建立"从 CPU 串行思维到 GPU 并行思维"的桥梁,并写出第一个真正能运行的 CUDA 程序。
先搞清楚一件事:CUDA 到底是什么?
0.1 一句话定义
CUDA(Compute Unified Device Architecture) 是 NVIDIA 推出的一套并行计算平台和编程模型。它让你可以用一种"类似 C/C++“的语言来指挥 GPU 进行大规模并行计算。
0.2 和你熟悉的语言对比
| 特性 | Java | Python | CUDA C/C++ |
|---|---|---|---|
| 运行方式 | JVM 字节码,跨平台 | 解释执行(或 JIT) | 编译型,直接生成 GPU 机器码 |
| 内存管理 | 自动垃圾回收(GC) | 自动垃圾回收(GC) | 手动管理(程序员自己申请/释放) |
| 指针 | 没有(只有引用) | 没有(变量即引用) | 有指针(这是最大的学习门槛) |
| 并行方式 | ThreadPoolExecutor | multiprocessing | 成千上万个线程同时执行(Single Instruction, Multiple Threads) |
| 执行设备 | CPU | CPU | GPU |
核心难点预警:CUDA 代码本质上是 C/C++ 代码,所以你必须先理解 C 语言的指针和手动内存管理。不要慌,下面我会用 Java/Python 的视角帮你翻译这些概念。
一、环境准备与检查
1.1 你需要什么?
一块 NVIDIA 显卡(RTX 系列、GTX 系列、或专业卡如 A100/H100 均可)
CUDA Toolkit(可从 NVIDIA 官网 下载安装)
C/C++ 编译器:
- Windows:Visual Studio(推荐 2019/2022 社区版)
- Linux:
gcc/g++
1.2 检查 CUDA 是否安装成功
打开命令行(Windows 用 PowerShell/CMD,Linux/Mac 用 Terminal):
nvcc --version
如果看到类似下面的输出,说明 CUDA 编译器已就绪:
nvcc: NVIDIA (R) Cuda compiler driver
Copyright (c) 2005-2024 NVIDIA Corporation
Built on ...
Cuda compilation tools, release 12.x, V12.x.x
再检查你的 GPU 信息:
nvidia-smi
这会显示你的显卡型号、驱动版本、CUDA 版本和 GPU 显存使用情况。
二、C 语言指针与内存管理:Java/Python 开发者的"翻译课”
在写 CUDA 之前,必须先过 C 语言的两道坎:指针和手动内存管理。
2.1 指针(Pointer):到底是个啥?
Java/Python 视角
在 Java 里,你写:
int[] arr = new int[5]; // arr 是一个"引用",指向堆里的数组对象
在 Python 里:
arr = [0] * 5 # arr 是一个变量名,指向内存中的列表对象
arr 本身不是数组,而是数组的"地址"或"门牌号"。
C 语言视角
C 语言把这件事说得更直白——它有一个专门的数据类型叫指针,里面存的就是内存地址。
int a = 10; // 在栈上分配一个整数,值是 10
int* p = &a; // p 是一个"指向 int 的指针",&a 表示取 a 的地址
printf("%d\n", *p); // *p 表示"解引用":去 p 指向的地址里取值,输出 10
*p = 20; // 通过指针修改原变量的值,现在 a 变成 20 了
| 符号 | 含义 | Java/Python 类比 |
|---|---|---|
int* p | 定义一个指向 int 的指针 | Integer p(引用类型) |
&a | 取变量 a 的内存地址 | 类似 System.identityHashCode(a)(但 C 里是真实的物理地址) |
*p | 解引用:访问指针指向地址里的值 | p.value(访问引用指向的对象内容) |
2.2 堆内存分配:malloc / free
Java/Python 里,你 new 一个对象或创建一个列表,用完不用管,垃圾回收器(GC)会自动清理。
C 语言里,你必须亲自去操作系统申请内存,用完亲自归还。否则就是内存泄漏。
#include <stdlib.h>
// 在堆上申请能放 5 个 int 的内存空间(5 * 4 = 20 字节)
int* arr = (int*)malloc(5 * sizeof(int));
if (arr == NULL) {
// 申请失败的处理
}
// 使用这块内存...
arr[0] = 100;
arr[1] = 200;
// 用完了必须手动释放!否则内存泄漏。
free(arr);
关键概念对照表:
| 操作 | C 语言 | Java | Python |
|---|---|---|---|
| 申请堆内存 | malloc(size) | new int[5] | [0] * 5 |
| 释放堆内存 | free(ptr) | 自动 GC | 自动 GC |
| 忘记释放 | 内存泄漏 | GC 帮你处理 | GC 帮你处理 |
CUDA 里所有 GPU 内存操作都是"C 语言风格"的手动管理,所以
malloc/free这个模式你一定要熟悉。
三、第一个 CUDA 程序:Hello GPU
3.1 代码
创建文件 hello_cuda.cu(.cu 是 CUDA 源文件的后缀):
#include <stdio.h>
// __global__ 表示这个函数在 GPU 上执行,但由 CPU 调用
__global__ void helloFromGPU() {
// threadIdx.x 是当前线程在 Block 中的编号
printf("Hello from GPU thread %d!\n", threadIdx.x);
}
int main() {
printf("Hello from CPU!\n");
// <<<1, 4>>> 是 CUDA 的"启动配置":
// 1 个 Block,每个 Block 里有 4 个线程
helloFromGPU<<<1, 4>>>();
// cudaDeviceSynchronize() 等待 GPU 执行完毕,
// 否则 CPU 可能不等 GPU 跑完就退出了
cudaDeviceSynchronize();
printf("All GPU threads finished.\n");
return 0;
}
3.2 编译与运行
nvcc hello_cuda.cu -o hello_cuda
hello_cuda.exe # Windows
./hello_cuda # Linux
预期输出:
Hello from CPU!
Hello from GPU thread 0!
Hello from GPU thread 1!
Hello from GPU thread 2!
Hello from GPU thread 3!
All GPU threads finished.
3.3 关键语法拆解
| 语法 | 名称 | 作用 |
|---|---|---|
__global__ | 执行空间标识符 | 表示该函数是 Kernel 函数:在 GPU 上执行,由 CPU 调用 |
<<<1, 4>>> | 执行配置(Launch Configuration) | <<<grid大小, block大小>>>,这里表示启动 1 个 Block,每个 Block 4 个线程 |
threadIdx.x | 内置变量 | 当前线程在其所在 Block 内的索引(从 0 开始) |
cudaDeviceSynchronize() | 同步函数 | 阻塞 CPU,直到 GPU 上所有任务执行完毕 |
四、CUDA 线程层次结构:Grid → Block → Thread
这是 CUDA 编程中最重要的概念,理解了它,你就理解了 CUDA 的并行思维。
4.1 类比:公司组织架构
想象 GPU 是一个大型工厂:
Grid(整个工厂)
├── Block 0(车间 0)
│ ├── Thread 0(工人 0)
│ ├── Thread 1(工人 1)
│ ├── Thread 2(工人 2)
│ └── ...
├── Block 1(车间 1)
│ ├── Thread 0
│ ├── Thread 1
│ └── ...
└── Block 2(车间 2)
└── ...
| 概念 | 类比 | 关键特性 |
|---|---|---|
| Grid | 整个工厂 | 一次 Kernel 启动对应一个 Grid |
| Block | 车间 | 同一个 Block 内的线程可以协作(通过 Shared Memory),不同 Block 之间不能互相通信 |
| Thread | 工人 | 最小的执行单元,每个线程执行一份 Kernel 代码的副本 |
4.2 三维索引系统
CUDA 允许你用一维、二维或三维的方式来组织线程。最常用的是一维和二维。
内置变量:
| 变量 | 含义 | 范围 |
|---|---|---|
threadIdx.x/y/z | 当前线程在 Block 内的索引 | [0, blockDim.x/y/z) |
blockIdx.x/y/z | 当前 Block 在 Grid 内的索引 | [0, gridDim.x/y/z) |
blockDim.x/y/z | 每个 Block 在各个维度上的线程数 | 你自己设定的 |
gridDim.x/y/z | Grid 在各个维度上的 Block 数 | 你自己设定的 |
计算全局唯一线程 ID(一维情况):
global_id = blockIdx.x * blockDim.x + threadIdx.x
4.3 线程配置示例
// 例 1:一维,处理 1024 个元素
// 每个 Block 256 个线程,需要 4 个 Block
myKernel<<<4, 256>>>();
// 线程全局 ID:blockIdx.x * 256 + threadIdx.x,范围 [0, 1023]
// 例 2:二维,处理图像像素(宽高各 16)
dim3 blockSize(4, 4); // 每个 Block 4x4 = 16 个线程
dim3 gridSize(4, 4); // Grid 4x4 = 16 个 Block,总共 256 个线程
myKernel<<<gridSize, blockSize>>>();
// 线程二维坐标:(blockIdx.x * 4 + threadIdx.x, blockIdx.y * 4 + threadIdx.y)
五、GPU 内存管理:cudaMalloc / cudaMemcpy / cudaFree
GPU 有自己的显存(HBM),CPU 有自己的内存(DRAM)。两者物理分离,数据不能自动共享。
5.1 数据传输流程
CPU 内存(Host Memory) GPU 显存(Device Memory)
↑ ↑
malloc cudaMalloc
| |
|-------- cudaMemcpy H2D -------->|
| (Host to Device:CPU → GPU) |
| |
| Kernel 在 GPU 上执行计算 |
| |
|<------- cudaMemcpy D2H ---------|
| (Device to Host:GPU → CPU) |
| |
free cudaFree
5.2 核心 API
// 1. 在 GPU 上分配内存
// 参数 1:void** devPtr(指针的指针,类似 Java 里的 AtomicReference)
// 参数 2:size_t size(要分配的字节数)
cudaError_t cudaMalloc(void** devPtr, size_t size);
// 2. 在 CPU 和 GPU 之间拷贝数据
// 参数 1:目标地址
// 参数 2:源地址
// 参数 3:拷贝字节数
// 参数 4:方向(cudaMemcpyHostToDevice / cudaMemcpyDeviceToHost / cudaMemcpyDeviceToDevice)
cudaError_t cudaMemcpy(void* dst, const void* src, size_t count, cudaMemcpyKind kind);
// 3. 释放 GPU 内存
cudaError_t cudaFree(void* devPtr);
5.3 完整示例:向量加法(Vector Addition)
这是 CUDA 的"Hello World"级算法。我们要计算 C[i] = A[i] + B[i]。
#include <stdio.h>
#include <stdlib.h>
// ---------------------- Kernel 函数 ----------------------
// __global__ 表示这是 GPU 上执行的函数,由 CPU 调用
__global__ void vectorAdd(const float* A, const float* B, float* C, int numElements) {
// 计算当前线程的全局唯一 ID
int i = blockIdx.x * blockDim.x + threadIdx.x;
// 防止越界(因为线程数通常会向上取整到 Block 大小的整数倍)
if (i < numElements) {
C[i] = A[i] + B[i];
}
}
// ---------------------- 主函数 ----------------------
int main() {
int numElements = 50000; // 向量长度
size_t size = numElements * sizeof(float); // 总字节数
// ========== 1. 在 CPU 上分配内存(Host Memory) ==========
float* h_A = (float*)malloc(size);
float* h_B = (float*)malloc(size);
float* h_C = (float*)malloc(size);
// 初始化数据
for (int i = 0; i < numElements; ++i) {
h_A[i] = rand() / (float)RAND_MAX;
h_B[i] = rand() / (float)RAND_MAX;
}
// ========== 2. 在 GPU 上分配内存(Device Memory) ==========
float* d_A = NULL;
float* d_B = NULL;
float* d_C = NULL;
// cudaMalloc 的第一个参数是 void**(指针的指针),所以要传 &d_A
cudaMalloc((void**)&d_A, size);
cudaMalloc((void**)&d_B, size);
cudaMalloc((void**)&d_C, size);
// ========== 3. 将数据从 CPU 拷贝到 GPU ==========
// cudaMemcpyHostToDevice:Host(CPU) → Device(GPU)
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);
// ========== 4. 启动 Kernel ==========
// 每个 Block 256 个线程
int threadsPerBlock = 256;
// Block 数量:向上取整,确保所有元素都被处理
int blocksPerGrid = (numElements + threadsPerBlock - 1) / threadsPerBlock;
printf("CUDA kernel launch with %d blocks of %d threads\n", blocksPerGrid, threadsPerBlock);
// 启动!<<<blocksPerGrid, threadsPerBlock>>>
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, numElements);
// 检查 Kernel 是否启动成功
cudaError_t err = cudaGetLastError();
if (err != cudaSuccess) {
printf("Kernel launch failed: %s\n", cudaGetErrorString(err));
return -1;
}
// ========== 5. 将结果从 GPU 拷贝回 CPU ==========
// cudaMemcpyDeviceToHost:Device(GPU) → Host(CPU)
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
// ========== 6. 验证结果 ==========
for (int i = 0; i < numElements; ++i) {
if (fabs(h_A[i] + h_B[i] - h_C[i]) > 1e-5) {
printf("Verification failed at element %d!\n", i);
return -1;
}
}
printf("Test PASSED!\n");
// ========== 7. 释放内存 ==========
// GPU 内存
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
// CPU 内存
free(h_A);
free(h_B);
free(h_C);
printf("Done!\n");
return 0;
}
5.4 编译与运行
nvcc vector_add.cu -o vector_add
vector_add.exe # Windows
./vector_add # Linux
5.5 代码逐行解析(Java/Python 视角)
| 代码行 | 含义 | 类比 |
|---|---|---|
float* h_A = (float*)malloc(size); | 在 CPU 内存申请数组 | float[] h_A = new float[numElements]; |
cudaMalloc((void**)&d_A, size); | 在 GPU 显存申请数组 | 类似 cudaMalloc 返回一个 GPU 内存的"引用" |
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice); | 显式数据搬运 | Java 里没有这个概念,因为内存是统一的。这里必须手动搬。 |
vectorAdd<<<blocks, threads>>>(...) | 启动 50000 个线程同时做加法 | Java 里你要用线程池提交 50000 个任务,CUDA 一句搞定 |
if (i < numElements) | 边界检查 | 非常重要!因为线程数是 256 的整数倍,会多出一些"空闲线程" |
cudaFree(d_A); | 释放 GPU 内存 | 相当于 h_A = null,但 CUDA 里没有 GC,必须显式调用 |
六、深入理解:为什么 GPU 代码要这样写?
6.1 并行思维的转变
Java/Python 思维(串行/小并行):
// Java 写法:一个 for 循环搞定
for (int i = 0; i < 50000; i++) {
C[i] = A[i] + B[i];
}
CUDA 思维(大规模并行):
// CUDA 写法:没有 for 循环!
// 每个线程负责一个 i,50000 个线程同时执行
int i = blockIdx.x * blockDim.x + threadIdx.x;
C[i] = A[i] + B[i];
核心差异:
- Java:一个线程 sequentially 跑完 50000 次迭代
- CUDA:同时启动 50000 个线程,每个线程只做一次加法
6.2 为什么要手动管理内存?
Java/Python 里,对象在堆上分配,GC 决定何时回收。GPU 编程不能这样——因为:
- GPU 和 CPU 内存物理分离,GC 无法跨设备工作
- 性能敏感,自动 GC 的不可预测性会毁掉 GPU 计算性能
- 显存更宝贵,必须精确控制分配和释放
6.3 指针为什么用 void**?
cudaMalloc((void**)&d_A, size);
这是一个让很多初学者困惑的地方。解释如下:
d_A是一个float*类型的变量(它里面存的是 GPU 内存的地址)&d_A是取这个变量本身的地址,类型是float**cudaMalloc需要一个void**参数(通用指针的指针)- 所以把
&d_A强制转换为(void**)
Java 类比:
// 假设有个方法:void allocate(GpuMemoryRef ref)
// 你需要传一个"能被修改的引用"进去
AtomicReference<float[]> d_A = new AtomicReference<>();
cudaMalloc(d_A); // 方法内部会把新分配的 GPU 地址写回 d_A
void** 本质上就是 C 语言实现"按引用传参"的方式。
七、实战:二维线程网格——矩阵加法
向量加法是 1D 的,矩阵操作通常用 2D 线程网格更直观。
#include <stdio.h>
#include <stdlib.h>
#define WIDTH 16 // 矩阵宽高都是 16
// 2D Kernel:每个线程负责计算结果矩阵的一个元素
__global__ void matrixAdd(const float* A, const float* B, float* C, int width) {
// 计算当前线程负责的二维坐标
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < width && col < width) {
int idx = row * width + col; // 二维坐标转一维索引
C[idx] = A[idx] + B[idx];
}
}
int main() {
int size = WIDTH * WIDTH * sizeof(float);
// CPU 内存
float* h_A = (float*)malloc(size);
float* h_B = (float*)malloc(size);
float* h_C = (float*)malloc(size);
for (int i = 0; i < WIDTH * WIDTH; i++) {
h_A[i] = 1.0f;
h_B[i] = 2.0f;
}
// GPU 内存
float* d_A; cudaMalloc(&d_A, size);
float* d_B; cudaMalloc(&d_B, size);
float* d_C; cudaMalloc(&d_C, size);
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);
// 2D 启动配置:
// 每个 Block 是 4x4 = 16 个线程
dim3 blockSize(4, 4);
// Grid 是 4x4 = 16 个 Block,总共 256 个线程处理 16x16=256 个元素
dim3 gridSize((WIDTH + 3) / 4, (WIDTH + 3) / 4);
matrixAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, WIDTH);
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
// 验证:1.0 + 2.0 = 3.0
printf("C[0] = %f, C[%d] = %f\n", h_C[0], WIDTH * WIDTH - 1, h_C[WIDTH * WIDTH - 1]);
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
free(h_A); free(h_B); free(h_C);
return 0;
}
二维索引的可视化:
矩阵 C(16x16):
(0,0) (0,1) ... (0,15)
(1,0) (1,1) ... (1,15)
...
(15,0) (15,15)
线程 (blockIdx.x=0, blockIdx.y=0, threadIdx.x=0, threadIdx.y=0) 计算 (0,0)
线程 (blockIdx.x=0, blockIdx.y=0, threadIdx.x=1, threadIdx.y=0) 计算 (0,1)
...
每个线程各算一个"格子",全部并行!
八、错误处理:别让你的程序"静默失败"
CUDA 的 Kernel 启动是异步的,错误可能不会立即暴露。推荐封装一个检查宏:
#include <stdio.h>
// 封装错误检查宏
#define CHECK_CUDA(call) \
do { \
cudaError_t err = call; \
if (err != cudaSuccess) { \
printf("CUDA error at %s:%d - %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err)); \
exit(EXIT_FAILURE); \
} \
} while (0)
// 使用示例:
int main() {
float* d_data;
CHECK_CUDA(cudaMalloc(&d_data, 1024));
CHECK_CUDA(cudaMemcpy(d_data, h_data, 1024, cudaMemcpyHostToDevice));
myKernel<<<blocks, threads>>>(d_data);
// Kernel 启动本身不返回错误,需要用 cudaGetLastError 捕获
CHECK_CUDA(cudaGetLastError());
CHECK_CUDA(cudaDeviceSynchronize());
CHECK_CUDA(cudaFree(d_data));
return 0;
}
九、CUDA 关键字与内置变量速查表
9.1 函数执行空间标识符
| 关键字 | 在谁上面执行 | 被谁调用 | 用途 |
|---|---|---|---|
__global__ | GPU (Device) | CPU (Host) | Kernel 函数,必须返回 void |
__device__ | GPU (Device) | GPU (Device) | GPU 内部调用的辅助函数 |
__host__ | CPU (Host) | CPU (Host) | 普通 C++ 函数(默认就是 host) |
9.2 内置变量(只能在 __global__ 和 __device__ 函数中使用)
| 变量 | 类型 | 含义 |
|---|---|---|
threadIdx.x/y/z | dim3 | 当前线程在 Block 内的索引 |
blockIdx.x/y/z | dim3 | 当前 Block 在 Grid 内的索引 |
blockDim.x/y/z | dim3 | Block 的尺寸(各维度线程数) |
gridDim.x/y/z | dim3 | Grid 的尺寸(各维度 Block 数) |
9.3 核心 API 速查
| API | 作用 |
|---|---|
cudaMalloc(void** devPtr, size_t size) | 在 GPU 上分配内存 |
cudaFree(void* devPtr) | 释放 GPU 内存 |
cudaMemcpy(dst, src, count, kind) | CPU↔GPU 数据传输 |
cudaMemset(devPtr, value, count) | 初始化 GPU 内存 |
cudaDeviceSynchronize() | 阻塞 CPU,等待 GPU 完成 |
cudaGetLastError() | 获取最近一次 CUDA 调用的错误码 |
cudaGetErrorString(err) | 将错误码转为可读字符串 |
十、常见问题 FAQ(Java/Python 开发者特供版)
Q1:CUDA 代码能和 Java/Python 混写吗?
不能直接混写。CUDA 是 C/C++ 的扩展,你需要:
- Java:通过 JNI / Panama / JCuda 调用编译好的 CUDA 动态库(.dll / .so)
- Python:通过 PyCUDA、Numba、或 CuPy 来调用 CUDA,或者写 C++/CUDA 扩展给 Python 调用(如 PyTorch 的做法)
入门阶段建议先直接用 C/CUDA 写,理解原理后再用高级封装。
Q2:为什么 GPU 代码里看不到 return 值?
Kernel 函数(__global__)的返回类型必须是 void。如果你想把结果传出来,只能通过指针参数写到 GPU 内存里,然后再 cudaMemcpy 回 CPU。
Q3:线程数设多少合适?
- 每个 Block 的线程数最好是 32 的整数倍(Warp 大小是 32)
- 常见选择:128、256、512、1024
- 一个 Block 最多 1024 个线程(不同架构上限略有不同)
- Grid 的 Block 数通常设成"刚好覆盖所有数据",没有严格上限
Q4:GPU 的"线程"和 CPU 的"线程"是一回事吗?
不是一回事!
- CPU 线程:重量级,上下文切换开销大,通常同时跑几十个就很多了
- GPU 线程:轻量级,硬件级调度,可以同时跑几万个甚至几十万个
- GPU 线程切换几乎没有开销,因为是由硬件 Warp Scheduler 自动管理的
Q5:我的电脑没有 NVIDIA 显卡怎么办?
- 用 Google Colab(免费提供 T4 GPU)
- 用 Kaggle Notebooks(每周免费 GPU 时长)
- 租云服务器(AutoDL、阿里云、腾讯云等)
十一、下一步学习路线建议
Phase 1(现在):理解本教程的所有代码,能手写向量加法和矩阵加法
↓
Phase 2:学习 Shared Memory,实现 Tiled Matrix Multiplication(性能提升 10x+)
↓
Phase 3:学习 CUDA 流(Stream)和事件(Event),理解异步执行与并发
↓
Phase 4:学习 cuBLAS / cuDNN / Thrust 等高级库,不要重复造轮子
↓
Phase 5:学习 TensorRT / Triton / CUTLASS,进入 AI 推理/训练优化深水区
参考资源
- CUDA C++ Programming Guide — NVIDIA 官方文档
- CUDA Runtime API 文档
- 《CUDA by Example》(经典入门书,Jason Sanders 著)
- NVIDIA CUDA Samples(官方示例代码库)
- Udacity: Intro to Parallel Programming(免费 CUDA 课程)
评论区