cuda-oxide:hello-constant 拆解 11(完结)——GPU 执行 + 结果回传

拆 hello-constant 系列最终站。cuLaunchKernel 返回后 GPU 接管,256 个线程并行执行 SASS。本文讲清楚 SM 调度 + warp 锁步、printf buffer 的延迟 flush 机制、benign race(良性竞争)、cuStreamSynchronize + cuMemcpyDtoH 的回传链路,以及为什么 thread xxx: 42 在 Output: 42 之前出现。

📚 系列 cuda-oxide · 第 16 篇

上一篇(host runtime)结束在 cuLaunchKernel 异步返回——host 把命令塞进 stream 队列就走了。这一篇 GPU 接管:256 个线程在 SM 上并行跑 SASS、写 printf buffer、写 device 内存。然后 host 通过 cuStreamSynchronize 等 GPU 跑完、cuMemcpyDtoH 把结果拷回——Output: 42 终于出现。

0. 几个名词先说清楚

缩写 / 术语英文全称中文含义
SMStreaming Multiprocessor流式多处理器GPU 上一个独立的并行执行单元,包含 warp scheduler、寄存器、shared memory 等
warpwarp(经线 / 线束)线束32 个 lane(线程)组成的最小调度单位,锁步执行
lanelane(车道)通道warp 里的一个执行通道,跑一个 “thread”
locksteplockstep锁步warp 内 32 个 lane 严格同步执行同一条指令
benign racebenign race condition良性竞争多个线程同时写同一地址,但因为写的都是同样的值,结果”恰好正确”
printf bufferprintf bufferprintf 缓冲区device 端循环缓冲区,vprintf 写入,driver 在同步时拷回 host
DtoHDevice to Host设备→主机数据从 GPU 显存拷回 host 内存
stream syncstream synchronization流同步阻塞 host,等 stream 队列里所有命令完成

1. GPU 接管之后发生了什么

cuLaunchKernel 调完,host 这边继续往下跑。GPU 这边收到 launch 命令,做的事:

1. 找一个空闲 SM 分配这个 block
2. 把 block 的 256 个 thread 切成 8 个 warp(256 ÷ 32 = 8)
3. 给每个 warp 分配寄存器(从 SM 的寄存器文件)
4. warp 排队等 warp scheduler 发射指令
5. 每个 cycle scheduler 选一个 ready warp,发射一条指令
   → warp 内 32 个 lane 锁步执行

2. SM 内部结构

SM(Streaming Multiprocessor)
├── warp scheduler × 4         ← 每 cycle 选一个 ready warp 发指令
├── 寄存器文件                  ← 所有活跃 warp 的寄存器都常驻
│     (65536 个 32-bit reg,大约)
├── shared memory(SRAM)        ← 100KB+,block 内 thread 共享
├── L1 cache
└── 大量执行单元(ALU、FPU、tensor core 等)

8 个 warp 排队:

warp 0  (lane 0..31)    ← 256 个 thread 切成 8 个 warp
warp 1  (lane 32..63)
warp 2  (lane 64..95)
...
warp 7  (lane 224..255)

每个 lane 都从 SASS 第一条指令开始执行——执行的是同一段代码,只是各自的寄存器状态不同。

3. 每个 lane 跑的 SASS(对应 PTX)

简化的 PTX 流程:

ld.param.b64    %rd2, [hello_constant_param_0];    ; 读 out 参数(指针)
cvta.to.global.u64  %rd1, %rd2;                     ; 通用 → global 地址

mov.u32 %r1, 42;                                    ; 你的 inline asm

cvt.u64.u32     %rd3, %r1;                          ; xxx as i64
... 构造 printf 参数 + 调 vprintf ...

st.global.b32   [%rd1], 42;                         ; *out = 42
ret;

256 个 lane 各自走一遍这段代码

4. 关于 256 路 race

st.global.b32   [%rd1], 42;     ← 所有 256 个线程都执行这条

256 个线程同时写同一个地址(都写 42)。理论上这是经典的 race condition(数据竞争)——多个写者同时访问同一内存。

但这里race 是良性的(benign race),因为:

  • 所有线程写一样的值 —— 不会出现”谁覆盖谁”的不确定
  • 32-bit store 是原子的(一条 PTX 指令对应一次 4-byte 总线事务)

最终结果恰好正确——*out 收到 42。

但如果你写的是 *out = tid(每个线程写自己的 id),结果就不确定了 —— 取决于最后一个完成的线程是哪个。CUDA 里写 kernel 必须自己保证内存访问语义,否则会得到不可预测的结果。

5. printf 链路

gpu_printf!("thread xxx: {}", xxx) 在 device 端展开成调用 vprintf 这个 CUDA runtime 提供的 device 函数。vprintf 做的事:

1. 把格式串 + 参数序列化成一段二进制 payload
2. 写到 device 端一个 printf buffer(环形缓冲区)
   ── buffer 默认 1MB,可以用 cudaDeviceSetLimit 调整
3. ... 继续执行 ...
4. (kernel 结束 或 buffer 快满时)driver 把 buffer 拷回 host
5. host 端解码 + puts 到 stdout

所以你看到的 256 行 42 <> 42 <> ... 输出不是 kernel 实时打出来的 —— 是 256 个线程各自往 printf buffer 里塞了一条记录,host 端在同步时统一打印

这就是为什么 printf 输出经常跟普通 host stdout 顺序错乱 —— 它们走完全不同的路径。

(注:格式串是 %lld <> \0,所以输出是 42 <> 42 <> 42 <> ... 连成一串,而不是带换行的多行。)

6. to_host_vec — 同步 + 拷贝回传

let result = out_dev.to_host_vec(&stream).expect("Failed to copy result");
println!("Output: {}", result[0]);

底层做两件事:

cuStreamSynchronize(stream);            // ① 等 stream 上所有命令完成
cuMemcpyDtoH_v2(host_buf, dptr, 4);     // ② device → host 复制 4 字节

6.1 cuStreamSynchronize 的角色

之前 cuLaunchKernel 是异步的,host 这边继续往下跑。cuMemcpyDtoH 之前必须先等 stream 上所有命令完成——否则可能拷到”还没写完”的内存。

cuStreamSynchronize阻塞 host 线程,直到 GPU 把 stream 队列上的所有命令(包括 kernel + 之前的 memset)都干完。

6.2 printf flush 也在这一步

cuStreamSynchronize 是 driver 触发 printf buffer flush 的时机之一。256 条 42 <> ... 记录在同步时从 device 拷回 host、解码、写到 stdout——所以 printf 输出都在 Output: 42 之前出现(因为 sync 早于 memcpy 早于 println!)。

7. host 端剩余执行

println!("Output: {}", result[0]);   // 输出 42

到这里整个 hello_constant example 就跑完了。后续:

  • CudaContext drop → 自动 cuCtxDestroy
  • DeviceBuffer drop → 自动 cuMemFree
  • 进程退出

8. 完整时间线

host 端                                  GPU 端
──────                                   ──────

[PHASE 9/9 a] 启动                       
[PHASE 9/9 b] cuInit/cuCtxCreate         
              cuMemAlloc                 
[PHASE 9/9 c] cuModuleLoad               ←── JIT:PTX → SASS,SASS 拷到 GPU
[PHASE 9/9 d] cuLaunchKernel ──────────→ block 分配到 SM
              (异步,立刻返回)              ↓
                ↓                          warp 0..7 排队
              继续往下                      ↓
                ↓                          每个 lane 跑 SASS:
              to_host_vec(&stream)         ├── 读参数
                ↓                          ├── inline asm (mov.u32 %r1, 42)
              cuStreamSynchronize ◀──── 等 ├── 写 printf buffer
              (阻塞 host)                  └── st.global.b32 [%rd1], 42
                ↓                          ↓
              driver flush printf buffer   GPU 完成

              (stdout 出现 42 <> 42 <> ...)

              cuMemcpyDtoH(host_buf, dptr, 4)

              println!("Output: {}", result[0])

              (stdout 出现 Output: 42)

              进程退出

实际运行时:

INFO hello_constant: [PHASE 9/9 e] copying device buffer back to host
42 <> 42 <> 42 <> 42 <> ... (256 次) ... Output: 42

9. 整套 cuda-oxide 流程总览

到这里 11 步走完了。整条链路串起来:

源码  *out = 42;

  ▼ rustc 前端                            (Step 03)
MIR Statement::Assign(*_1, const 42)

  ▼ cargo-oxide / rustc 启动              (Step 02)
  ▼ codegen_crate 拦截                    (Step 04)
  ▼ collector + stable MIR                (Step 05)

  ▼ mir-importer(statement)               (Step 06)
dialect-mir  mir.store %ptr, 42 : i32

  ▼ mir-importer(块尾控制流 + intrinsic)  (Step 07)
dialect-nvvm nvvm.read_ptx_xxx (for xxx())

  ▼ mir-lower(DialectConversion)          (Step 08)
dialect-llvm llvm.store / llvm.inline_asm

  ▼ llvm-export                           (Step 09)
LLVM IR      store i32 42, ptr %v1

  ▼ llc / NVPTX backend                   (Step 09)
PTX          st.global.b32 [%rd1], 42

  ▼ CUDA driver JIT                       (Step 10)
SASS         (sm_61 真实机器码)

  ▼ cuLaunchKernel + GPU 执行             (Step 11)
GPU          256 线程各自写 *out = 42

  ▼ cuMemcpyDtoH                          (Step 11)
Host         println!("Output: {}", 42)

10. 一句话总结

cuLaunchKernel 返回后 GPU 接管,256 个 thread 在 SM 上被切成 8 个 warp,每个 warp 32 个 lane 锁步执行 SASS——所有 lane 都做同一件事:读参数、跑 inline asm 拿到 42、写 printf buffer、st.global [out], 42。256 个线程同时写同一地址是良性 race(写的都是 42)。host 端 to_host_vec 触发 cuStreamSynchronize 阻塞等待 GPU 完成(同时 driver flush printf buffer 把 256 条 42 <> ... 输出到 stdout),然后 cuMemcpyDtoH 把 4 字节拷回 host,println!("Output: {}", 42) 完成整个 example。整套 cuda-oxide 流程到此结束——从 Rust 源码 *out = 42 一路追到 GPU 上一条 SASS 指令。

下一步推荐:用本系列开头给的 dump 命令亲自跑一遍 hello_constant,对照每一站的真实输出复盘。这样能把 11 篇文章的知识真正变成你自己的。

系列上一篇: cuda-oxide:hello-constant 拆解 10——host binary 启动 + CUDA driver 加载 PTX

评论区
评论功能即将上线, 敬请期待。