项目实战

CUDA 编程基础教程(面向 Java/Python 开发者)

12 分钟阅读

目标读者:熟悉 Java,会一些 Python,但没有 C/C++ 系统经验,想入门 GPU 编程的开发者。 学习定位:本教程不会一次性讲完所有 CUDA 特性,而是帮你建立"从 CPU 串行思维到 GPU 并行思维"的桥梁,并写出第一个真正能运行的 CUDA 程序。

先搞清楚一件事:CUDA 到底是什么?

0.1 一句话定义

CUDA(Compute Unified Device Architecture) 是 NVIDIA 推出的一套并行计算平台和编程模型。它让你可以用一种"类似 C/C++“的语言来指挥 GPU 进行大规模并行计算。

0.2 和你熟悉的语言对比

特性JavaPythonCUDA C/C++
运行方式JVM 字节码,跨平台解释执行(或 JIT)编译型,直接生成 GPU 机器码
内存管理自动垃圾回收(GC)自动垃圾回收(GC)手动管理(程序员自己申请/释放)
指针没有(只有引用)没有(变量即引用)有指针(这是最大的学习门槛)
并行方式ThreadPoolExecutormultiprocessing成千上万个线程同时执行(Single Instruction, Multiple Threads)
执行设备CPUCPUGPU

核心难点预警:CUDA 代码本质上是 C/C++ 代码,所以你必须先理解 C 语言的指针手动内存管理。不要慌,下面我会用 Java/Python 的视角帮你翻译这些概念。


一、环境准备与检查

1.1 你需要什么?

  1. 一块 NVIDIA 显卡(RTX 系列、GTX 系列、或专业卡如 A100/H100 均可)

  2. CUDA Toolkit(可从 NVIDIA 官网 下载安装)

  3. 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 语言JavaPython
申请堆内存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/zGrid 在各个维度上的 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 编程不能这样——因为:

  1. GPU 和 CPU 内存物理分离,GC 无法跨设备工作
  2. 性能敏感,自动 GC 的不可预测性会毁掉 GPU 计算性能
  3. 显存更宝贵,必须精确控制分配和释放

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/zdim3当前线程在 Block 内的索引
blockIdx.x/y/zdim3当前 Block 在 Grid 内的索引
blockDim.x/y/zdim3Block 的尺寸(各维度线程数)
gridDim.x/y/zdim3Grid 的尺寸(各维度 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 显卡怎么办?

  1. Google Colab(免费提供 T4 GPU)
  2. Kaggle Notebooks(每周免费 GPU 时长)
  3. 租云服务器(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 推理/训练优化深水区

参考资源

评论区