第二个CUDA程序:Hello World内核与主机设备代码分离

说实话,学CUDA编程最让人兴奋的,就是亲手写出第一个能在GPU上跑的内核。我记得当年第一次看到屏幕上打出"Hello World"时,心里那个激动——虽然只是个简单的打印,但背后是CPU和GPU协同工作的完整流程。

这一章,我们就来写这个经典的入门程序。不过我不会只给你看代码,我会把背后的编译流程、API选择这些坑,都一一说清楚。

1. 一个完整的Hello World内核

先看代码,再慢慢拆解。

// hello.cu
#include <stdio.h>

// 这是GPU上运行的kernel函数
__global__ void helloFromGPU()
{
    printf("Hello World from GPU! blockIdx.x = %d, threadIdx.x = %d\n",
           blockIdx.x, threadIdx.x);
}

int main()
{
    // 主机端代码
    printf("Hello World from CPU!\n");

    // 启动kernel:1个block,每个block里5个线程
    helloFromGPU<<<1, 5>>>();

    // 同步,确保GPU执行完毕
    cudaDeviceSynchronize();

    return 0;
}

嗯,这里要注意几个关键点。

  • __global__:这个修饰符告诉编译器,这个函数要在GPU上跑,并且可以从CPU调用。我刚开始写的时候,经常忘记加这个,结果编译报错半天找不到原因。
  • <<<1, 5>>>:这是CUDA特有的语法,叫"执行配置"。第一个参数是block数量,第二个参数是每个block里的线程数。说白了,就是告诉GPU:你给我开1个工作组,每个组里5个工人干活。
  • cudaDeviceSynchronize():这个函数让CPU等着,直到GPU把活干完。我曾经踩过一个坑——不加这行,CPU直接跑完了,程序退出,GPU上的printf还没输出呢。

2. 主机代码与设备代码分离

你可能会问:为什么要把CPU代码和GPU代码写在一起?

其实,这是CUDA编程模型的核心思想——异构计算。CPU负责控制逻辑、I/O这些串行任务,GPU负责大规模并行计算。代码混在一起,但编译时会分开处理。

我个人习惯把kernel函数单独放在一个文件里,比如kernel.cu,主机代码放在main.cpp里。这样项目结构清晰,也方便复用。

关键概念:主机(Host)指CPU及其内存,设备(Device)指GPU及其显存。两者通过PCIe总线通信,数据传输是开销最大的操作之一。

3. nvcc编译流程——到底发生了什么?

你写了一个.cu文件,然后执行nvcc hello.cu -o hello,就生成了可执行文件。但背后发生了什么?

我画了一张图,帮你理清整个流程。

hello.cu (源文件) nvcc 编译器 分离主机/设备代码 设备代码 (GPU) ptx / cubin 主机代码 (CPU) C++ 中间代码 链接 (nvlink + host linker) 生成可执行文件 hello (可执行文件) 流程说明: 1. nvcc 解析 .cu 文件,分离主机代码和设备代码 2. 设备代码编译为 PTX 或 cubin(GPU 可执行格式) 3. 主机代码交给宿主 C++ 编译器(如 g++)处理 4. 最后链接在一起,生成最终的可执行文件

说白了,nvcc就是个"翻译官"。它把.cu文件拆成两部分:GPU代码交给它自己的编译器,CPU代码丢给系统自带的C++编译器(比如g++或cl.exe)。最后再把两边的结果拼起来。

小技巧:你可以用 nvcc --keep 选项保留中间文件。这样能看到生成的PTX代码和C++中间文件,对理解编译过程很有帮助。我调试复杂kernel时经常用这招。

4. 运行时API vs 驱动API

CUDA提供了两套API来跟GPU打交道。你猜怎么着?我们刚才用的cudaDeviceSynchronize()就是运行时API的函数。

特性 运行时API (Runtime API) 驱动API (Driver API)
易用性 高,函数封装好,上手快 低,需要手动管理更多细节
初始化 自动初始化,无需显式调用 需要 cuInit() 手动初始化
错误处理 返回 cudaError_t 返回 CUresult
模块管理 编译时链接,简单 支持运行时加载 .cubin 或 .ptx
适用场景 大多数应用开发 需要精细控制、动态加载的场景

我个人建议:初学者先用运行时API。它封装得好,代码简洁,不容易出错。等你把CUDA玩熟了,再去看驱动API——它给你更多控制权,但代价是代码量翻倍。

避坑指南:我曾经在一个项目里混用了两套API,结果出了个诡异的bug——运行时API自动初始化了上下文,驱动API又手动初始化了一次,导致上下文冲突。排查了两天才找到原因。所以,一个程序里只用一套API,别混着来。

5. 动手试试:编译和运行

把上面的代码保存为hello.cu,然后执行:

nvcc hello.cu -o hello
./hello

你会看到类似这样的输出:

Hello World from CPU!
Hello World from GPU! blockIdx.x = 0, threadIdx.x = 0
Hello World from GPU! blockIdx.x = 0, threadIdx.x = 1
Hello World from GPU! blockIdx.x = 0, threadIdx.x = 2
Hello World from GPU! blockIdx.x = 0, threadIdx.x = 3
Hello World from GPU! blockIdx.x = 0, threadIdx.x = 4

看到了吗?CPU打印了一次,GPU打印了5次——因为我们在配置里写了<<<1, 5>>>,启动了5个线程。每个线程都执行了helloFromGPU函数,只是threadIdx.x不同。

你想想看,如果我把配置改成<<<2, 3>>>,会输出几行?答案是6行——2个block,每个block里3个线程。这就是CUDA最基本的并行模型:网格(Grid) → 块(Block) → 线程(Thread)。

嗯,这一章的内容就到这里。代码虽短,但背后的编译流程、API选择这些知识,是后续所有CUDA编程的基础。把这些搞明白了,后面的路就好走了。

想要更多资料,联系可可