Hacker News 中文摘要

RSS订阅

运行CUDA内核时会发生什么? -- What happens when you run a CUDA kernel?

文章摘要

运行一个简单的CUDA向量加法程序,将两个包含一百万个浮点数的向量相加,结果正确输出2.0。该过程涉及数千万条CPU指令、设备文件操作、ioctl调用和内存映射寄存器,最终在GPU上以4096个块、每块256线程并行执行。

文章总结

好的,这是根据您的要求,对原文主要内容进行的中文重述,保留了关键细节,并删减了与主题无关的评论和附录中的技术细节。


运行一个CUDA内核时发生了什么?

本文以一个简单的CUDA向量加法程序为例,详细追踪了从编写代码到GPU执行并返回结果的完整技术路径。

1. 编译过程

nvcc 编译器并非单一编译器,而是一个驱动程序,它会调用多个子编译器。对于设备代码(vadd内核),流程如下: - cicc:基于LLVM的编译器,将CUDA代码转换为PTX(一种虚拟指令集架构,拥有无限寄存器,与具体硬件无关)。 - ptxas:将PTX转换为SASS(特定于GPU架构的机器码)。例如,PTX中需要三条指令(类型转换、乘法、加法)才能完成的地址计算,在SASS中会被融合为一条 IMAD.WIDE 指令。 - fatbinary:将SASS和PTX打包成一个“fatbin”文件。SASS是实际执行的代码,而PTX作为向前兼容的备选方案,当程序运行在SASS不适配的GPU架构上时,驱动会即时编译(JIT)PTX。 - 最终,这个fatbin被嵌入到主机可执行文件中。

2. 主机如何触发GPU

  • 注册:程序启动时,一个隐藏的构造函数会将嵌入的fatbin注册到CUDA运行时,并建立主机端函数指针与设备端内核名称的映射。
  • 启动桩:当执行 vadd<<<4096, 256>>> 时,编译器会将其替换为一个启动桩函数。该函数将内核参数(指针 da, db, dc 和整数 n)打包到主机内存的一个缓冲区中。
  • 调用运行时:启动桩调用 __cudaLaunch,运行时通过查询注册表找到对应的设备端内核,并调用用户态驱动 libcuda.so
  • 延迟加载:从CUDA 12.2开始,模块加载默认是惰性的。驱动会推迟将内核的SASS代码上传到GPU显存,直到该内核第一次被实际启动。
  • 内核态驱动libcuda 通过 ioctl 系统调用与内核态驱动 nvidia.ko 通信。

3. 将工作提交到GPU

GPU不接收函数调用,而是从主机内存中读取命令流。启动内核的关键是构建一个“通道”(channel),它包含两个重要结构: - Pushbuffer:驱动写入GPU命令(称为“方法”)的内存区域。 - GPFIFO:一个由GPU和CPU共同维护的环形缓冲区,用于协调工作进度。CPU写入新工作后,会更新 GP_PUT 指针。

启动过程如下: 1. 填充QMD:驱动在pushbuffer中写入一系列方法,这些方法共同构成了一个“队列元数据”(QMD)。QMD是计算网格的启动描述符,包含了网格/块维度(4096和256)、寄存器/线程数、程序起始地址(SASS代码位置)以及存放内核参数的常量内存地址。 2. 更新GPFIFO:驱动在GPFIFO中放入一个指向该pushbuffer段的条目,并更新 GP_PUT 指针。 3. 触发门铃:由于现代GPU不会主动轮询 GP_PUT,驱动需要通过一个内存映射的“门铃”寄存器来通知GPU。驱动向该寄存器写入一个工作提交令牌。 4. GPU接收:GPU的主机引擎收到门铃信号后,读取新的 GP_PUT,通过DMA从pushbuffer中取出方法,并将QMD交给“计算工作分配器”。

从CPU角度看,cuLaunchKernel 在门铃被触发后立即返回,这是一个异步调用。

4. GPU上的指令执行

  • 工作分配:计算工作分配器负责将4096个块(每个块256个线程)分配到GPU的128个流式多处理器(SM)上。每个SM最多可同时驻留6个块(48个线程束),这是由线程容量限制决定的。
  • 指令调度:每个SM有4个处理子分区,每个子分区有一个线程束调度器,管理12个活跃线程束。调度器通过硬件记分牌和编译器写入的静态控制码来决定哪个线程束可以执行下一条指令。
    • 静态停顿计数:对于固定延迟的指令,编译器会编码一个精确的周期数,让调度器在此周期内暂停该线程束。
    • 依赖屏障:对于可变延迟操作(如全局内存加载 LDG),编译器会设置硬件记分牌屏障。后续指令需要等待这些屏障清除后才能执行,否则线程束被视为“不合格”,调度器会切换到其他线程束。
  • 内存访问:当一个线程束执行加载指令时,SM的加载/存储单元会检测到连续的访问模式,并将32个线程的请求合并为4个32字节的扇区请求。这些请求首先检查L1数据缓存,未命中则通过交叉开关网络访问L2缓存,最后才访问物理GDDR6X显存。
  • 性能分析:通过NVIDIA Nsight Compute分析,该内核的算术强度极低(一次浮点加法对应12字节数据传输)。GPU运行时间为10.78微秒,其中DRAM利用率达到峰值的79.65%,而指令发射时间仅占5.17%,大部分时间花在等待数据上。

5. 返回CPU

  • 完成信号:当最后一个块执行完毕,GPU会通过QMD中指定的“完成信号量”通知CPU。
  • 数据回传cudaMemcpy 操作会等待该信号量。由于计算结果 c 仍驻留在L2缓存中,GPU的拷贝引擎直接从L2读取数据,并通过PCIe总线将其传回主机内存,无需经过DRAM。
  • 最终输出cudaMemcpy 完成后,主机端的 c 数组包含了结果,printf 函数将其输出到终端,显示 c[0]=2.000000 c[n-1]=2.000000

总结:整个流程涉及了从高级语言到机器码的多次编译、主机与设备间的复杂通信协议、GPU内部的工作分配与指令调度,以及多级缓存和显存的数据流动,最终完成了一次简单的向量加法运算。

评论总结

根据评论内容,主要观点和论据如下:

1. 硬件文档与开源透明度 - 评论1指出NVIDIA硬件有部分开源文档,无需阅读内核源码即可获取方法文档和qmd格式。 - 关键引用:"The hardware has some open documentation. You don't actually need to read the kernel source..." - 链接指向NVIDIA open-gpu-doc仓库。

2. CUDA API选择与可见性 - 评论2认为使用CUDA驱动API和运行时编译器可提升操作可见性,减少用户空间“黑魔法”。 - 关键引用:"a lot of the user-space 'voodoo' is gone if you don't go through CUDA's 'runtime API'." - 推荐使用vectorAdd_nvrtc示例和cuda-api-wrappers库。

3. 内核优化与商业前景 - 评论3质疑专业内核优化公司是否会被开源库取代,或通过被收购成为大厂“护城河”。 - 关键引用:"I wonder if those companies are going to be dethroned by some sort of like open source library..." - 认为NVIDIA可能随时发布此类开源库。

4. CUDA同步机制对比 - 评论4赞赏CUDA隐式处理命令同步,默认流自动管理,而Vulkan将同步复杂性完全交给用户。 - 关键引用:"It's great that cuda implicitly handles syncing of commands for users..." - 对比Vulkan“unloads the full complexity of syncing to users right from the start”。

5. 学习价值与内容深度 - 评论5认为文章对学习CUDA、MPI+CUDA、OpenCL很有帮助,尤其关于warp eligibility的部分。 - 关键引用:"Reading an article like this before the classes would have been a lot helpful!" - 特别提及“What does it mean for a warp to be eligible?”部分。