【CUDA编程】hello world 级入门(一)

最近参考 CUDA 入门教程,想记录自己学习和思考的过程。根据这份参考教程,整体分为将以下两个部分:

  1. CPU 版本 vs CUDA 版本代码及要点
  2. CUDA 版本 vs CUDA 并行版本代码及性能对比

在这篇博客中围绕第一部分进行展开讨论。

参考文档:CUDA 入门教程:更简单的介绍 (更新版)

官方手册:CUDA C 编程官方文档

CPU 版本 vs CUDA 版本代码及要点

内存分配

为什么需要传递指针的地址?

在下面这行代码中:

c 复制代码
float *x;
cudaMallocManaged(&x, N * sizeof(float));

x 是一个指针变量,如果想要在函数内部(这里是 cudaMallocManaged修改它的值 (即让它指向新分配的内存),就必须把它的地址传进去。这是因为函数参数默认是值传递,直接传x无法修改它本身的值。

将上面的接口类比 C 中的行为,如下:

arduino 复制代码
void myMalloc(float **ptr, size_t size) {
    *ptr = (float*) malloc(size);
}

float **ptr 声明了 ptr,是一个指向指针的指针,*ptr 则是 ptr 指向的那个指针本身

malloc(size) 是申请了 size 大小的地址,返回 void * 类型的指针,将其转换成 (float *) 类型的指针;

将转换后的指针的值,赋值给 *ptr,即赋值给 ptr 指向的那个指针本身

那么 ptr 指向的指针本身就是申请的这块内存的地址;

如果我们调用 myMalloc(&x, size),&x 就是 ptr,那么 x 这个指针的地址,是一个被指向的指针;

那么 ptr 指向的就是 x,*ptr 就是 x;

那么前面得到的 (float*) malloc(size),就赋值给了 x 本身;

一个指针本身,代表的就是它指向哪里,那么就可以理解为,x 指向的内存地址,就是 (float*) malloc(size)。

Tips:

cudaMallocManaged 不是直接 malloc 一段主机内存,而是申请一段可以在 CPU 和 GPU 上都访问的统一内存。

为什么不是像 malloc() 一样直接返回指针?

在 C 语言中,我们常见的内存申请方式是:

c 复制代码
float *x = (float *) malloc(N * sizeof(float))

malloc 会返回一段主机内存的地址,我们直接将它赋值给指针变量即可,非常简洁。但在 CUDA 中我们申请统一内存(Unified Memory)时,需要使用这样的方式:

c 复制代码
float *x;
cudaMallocManaged(&x, N * sizeof(float))

这就引出一个疑问:为什么 cudaMallocManaged 不直接返回一个指针,而是通过参数修改传入变量的值?其原因主要在于以下两个方面。

原因一:CUDA API 遵循显式错误处理风格

CUDA 设计中的一个核心思想:将错误处理显式化 。CUDA 的内存分配函数(包括 cudaMalloccudaMallocManaged 等)返回的是错误码,不是指针。例如 cudaMallocManaged 的函数签名如下:

arduino 复制代码
__host__ cudaError_t cudaMallocManaged(void **devPtr, size_t size);

这比 malloc() 的返回 NULL 要更可靠,因为 CUDA 的返回值可以给出详细的错误信息(如内存不足、非法参数、无设备支持等)。

原因二:统一内存涉及设备管理,不能简单用返回值表示

cudaMallocManaged 分配的是统一内存,它是由 CUDA 驱动管理、CPU 与 GPU 可以共同访问的一段内存区域:

  • 需要设备支持驱动协调
  • 可能涉及延迟分配页面迁移等高级机制;
  • 分配不只是创建一个地址,还可能需要额外的上下文绑定、同步等控制信息。

因此,CUDA 选择将所有这些复杂行为封装进 API 调用中,而不是通过简单的 void* 返回值暴露出来。

什么是主机端(Host)与设备端(Device)?

在 CUDA 编程模型中,整个程序运行环境分为两个世界:

名称 英文名 位置 运行内容
主机端 Host CPU 主函数、数据初始化、内存申请等
设备端 Device GPU 并行计算核函数(kernel),执行数据加速任务

简单来说:

  • 主机端(host) :就是电脑 CPU 所执行的代码部分,比如 main() 函数、new/deletefor 循环等;

  • 设备端(device) :就是 GPU 运行的代码,通常以 global 修饰符定义的核函数(kernel);

为什么要区分主机和设备?

在 C++ 中我们不会区分 CPU 和 GPU,因为一切都是在 CPU 上运行。但在 CUDA 中,GPU 与 CPU 是两个独立的处理单元,各有自己的:

  • 指令集(CPU 是通用指令,GPU 是并行计算指令)
  • 内存(CPU 有主存,GPU 有显存)
  • 执行空间(Host 执行 main(),Device 执行 kernel)

主机和设备之间默认是不能直接共享内存的 ,所以通常需要通过 cudaMemcpy 显式地拷贝数据。但为了简化开发,CUDA 提供了统一内存(Unified Memory)机制,比如使用的 cudaMallocManaged(),它在底层自动处理主机设备的数据迁移,让开发者看起来像 CPU 和 GPU 在用同一块内存。

主机设备的数据迁移是如何处理的?

基本的 CUDA 内存模型下的数据迁移方式

在最基本的 CUDA 内存模型中,主机和设备拥有各自独立的内存空间。这就意味着必须显式地拷贝数据:

c 复制代码
float *h_data = (float*)malloc(N * sizeof(float));     // Host memory
float *d_data;
cudaMalloc(&d_data, N * sizeof(float));                // Device memory

cudaMemcpy(d_data, h_data, N * sizeof(float), cudaMemcpyHostToDevice); // 拷贝数据到 GPU

kernel<<<blocks, threads>>>(d_data); // GPU 计算

cudaMemcpy(h_data, d_data, N * sizeof(float), cudaMemcpyDeviceToHost); // 拷贝回主机

这种方式能精确控制内存位置和拷贝时间,同时性能也比较可控,但是程序复杂,容易出错,也不适合频繁的数据共享场景。

这就引入了统一内存,即自动数据迁移机制。

统一内存:自动数据迁移机制

当我们使用 cudaMallocManaged() 分配内存时:

c 复制代码
float *x;
cudaMallocManaged(&x, N * sizeof(float));

这段内存看起来是"共享"的,但实际上:CUDA 驱动会根据访问行为在主机和设备之间 "按需" 迁移数据页(page migration) ,使得当前执行的处理器始终访问自己本地的副本。

那么,这种自动迁移是如何发生的呢?

CUDA 使用类似于操作系统的虚拟内存机制,在页粒度(通常是 4KB) 上做管理:

  1. 初始化 x[i] = 1.0f,这在主机上运行,CUDA 标记这部分页面"属于主机";

  2. GPU 执行 kernel 并首次访问 x[i] 时,CUDA 检测到这是设备对"主机页面"的访问,于是:

    1. 将这些页面从主机迁移到设备;
    2. 将页面标记为"只读"或"设备拥有";
    3. 触发硬件页面迁移(类似 page fault);
  3. 如果主机之后再次访问这些数据(比如结果检查),则会再次发生迁移(从 GPU → CPU);

  4. 为了减少频繁迁移,可以手动调用:

    1. cudaMemPrefetchAsync(ptr, size, device_id):提示 CUDA 把数据提前迁移到某个设备;
    2. cudaMemAdvise(...):提供页面的访问模式建议(例如只读、多设备共享等)。
css 复制代码
主机端代码初始化数据      →      内存页面驻留在 Host
        ↓
GPU kernel 访问 x[i]        →      Page Fault!
        ↓
CUDA 驱动将页面迁移到 Device
        ↓
GPU 正常运行 kernel
        ↓
主机读取 y[i] 结果         →      页面再次迁回 Host

Tips:

频繁主机-设备切换会导致大量迁移,严重拖慢性能。

<<<x, y>>>是什么含义?

<<<x, y>>> 是 CUDA 中调用 GPU 核函数(kernel)时的特殊语法 ,叫做:执行配置语法(execution configuration syntax)。 它用于告诉 CUDA 需要多少个线程来并行执行这个核函数,以及这些线程如何组织。

kernel 函数的基本形式

其 kernel 的基本形式如下:

bash 复制代码
kernel<<<numBlocks, threadsPerBlock>>>(args...);

含义:

  • numBlocks:表示启动多少个线程块(block)
  • threadsPerBlock:每个线程块中包含多少个线程(thread)

最终总的线程数就是 numBlocks × threadsPerBlock。这些线程会并行 执行所定义的 global 核函数。在前面的代码中:

bash 复制代码
add<<<1, 1>>>(N, x, y);

表示启动 1 个线程块,每个块里 1 个线程,所以 GPU 只启动了 1 个线程来执行这个核函数。因此这个版本的 CUDA 代码实际上并没有并行加速,但结构上已经完整展示了如何定义和调用 GPU 上的代码。

由于这部分展开的篇幅会较大,因此在下一篇【CUDA编程】hello world 级入门(二)中,再详细探讨多线程块多线程的并行版本。

global 是怎么生效的?工作原理是什么?

global 限定词声明一个函数为 kernel。这样的一个函数具有以下特点:

  • 在设备上执行
  • 被主机端调用
  • 对于计算能力 5.0 或更高的设备,也可以从设备调用
  • global 函数的调用是异步的,这意味着在设备完成其执行之前调用端即返回了

那么,global 内部是如何生效的呢?

编译阶段:nvcc 生成两种代码

当编写带有 global 的函数并用 nvcc 编译时,CUDA 编译器会:

  • 将主机端代码编译成普通的 CPU 机器码;
  • 将标记为 global 的核函数(kernel)编译成 GPU 设备代码;
  • 把 kernel 调用(如 kernel<<<...>>>(...))转化为一个隐藏的 API 调用(类似 cudaLaunchKernel());

这就完成了主机和设备代码的双重编译流程

执行阶段:kernel 启动流程

当主机端调用 kernel 函数时,例如:

bash 复制代码
add<<<numBlocks, threadsPerBlock>>>(N, x, y);

这并不是一个真正的 C++ 函数调用,而是 CUDA 语法糖(Syntactic sugar),底层做了以下几步:

  1. CUDA 运行时通过 cudaLaunchKernel() 等 API 向驱动发送一个 GPU 执行请求;

  2. CUDA 驱动接收到这个请求后:

    1. 将 kernel 函数的设备地址、参数、执行配置等传给 GPU;
    2. 启动请求的 blocks × threads 数量的线程;
  3. 每个线程在 GPU 上并发执行 add 函数体;

  4. 内建变量(如 threadIdx.xblockIdx.x)在每个线程的执行上下文中被自动设置。

为什么需要同步等待?

前面我们提过,CUDA 的 kernel 调用是异步的,在这样一系列逻辑中:

c 复制代码
add<<<...>>>(...);          // GPU 还在跑
cudaDeviceSynchronize();    // 等待 GPU 跑完
std::cout << y[0] << std::endl; // 主机安全读取结果

假如不调用 ****cudaDeviceSynchronize() ,那主机代码可能访问的是 还没被 GPU 写入的值 。所以,cudaDeviceSynchronize() 会阻塞主机线程,直到 GPU 执行完成,是一种安全的做法,这能确保主机读取或处理的数据是有效的,否则主机可能读取错误或未完成计算的数据。

TODO:

是否有不需要同步等待的方式?

内存释放

比较 CUDA 和 C/C++ 在内存管理上的不同点如下:

内容 delete[] cudaFree()
内存分配方式 用 new[] 在主机内存分配 用 cudaMalloc 或 cudaMallocManaged 在 GPU 或统一内存上分配
内存位置 主机内存 设备内存或统一内存
释放方式 delete[] cudaFree()
操作对象是否要一致 必须和分配方式成对使用 必须和 CUDA 分配函数配对使用

CUDA 的内存管理是显式的,需要开发者自己负责释放,如果用 cudaMalloccudaMallocManaged 申请 CUDA 内存,必须用 cudaFree 来释放。

Tips:

CUDA 编程中,还有 cuda-memcheck 等工具来检查内存泄露等问题风险

总结

通过对比 CPU 和 CUDA 实现两个数组加法的示例,逐步理解了 CUDA 编程中的几个关键概念,包括:为什么要传递指针地址、为何 cudaMallocManaged 不直接返回指针、线程块语法 <<<x, y>>> 的作用,以及 global 函数的幕后机制等等。总之,对 CUDA 编程有了初步的认知。下一篇再深入探讨 CUDA 并行化相关的内容。

相关推荐
杰克逊的日记6 天前
GPU运维常见问题处理
linux·运维·gpu
胡耀超7 天前
基于Docker的GPU版本飞桨PaddleOCR部署深度指南(国内镜像)2025年7月底测试好用:从理论到实践的完整技术方案
运维·python·docker·容器·ocr·paddlepaddle·gpu
雪碧聊技术8 天前
CPU(中央处理器)和GPU(图形处理器)的区别
人工智能·cpu·gpu·中央处理器·图形处理器
POLOAPI9 天前
GpuGeek 实操指南:So-VITS-SVC与Stable Diffusion详解和音画同步生成实战
人工智能·gpu
杰克逊的日记11 天前
大数据集群运维常见的一些问题以及处理方式
大数据·运维·gpu·算力
weiwei2284413 天前
也玩音频克隆IndexTTS
gpu·wsl·语音生成·index-tts
赖亦无16 天前
【水动力学】04 二维洪水淹没模型Pypims安装
c++·python·gpu·水动力·洪水
Eloudy21 天前
cuDNN 的 IMPLICIT_GEMM 算法
算法·gpu·gemm