CUDA 执行模型与调度 Q&A
CUDA 执行模型与调度 Q&A
Q1:什么是 Warp Divergence(线程束分歧)?如何减少其影响?
什么是 Warp Divergence
GPU 以 Warp(32 个线程)为单位同时发射指令。当 Warp 内的线程由于条件分支(if/else、switch、while)而走向不同的代码路径时,就发生了 Warp Divergence(线程束分歧)。
发生分歧时,GPU 必须串行执行每条分支路径——先执行走某条路径的线程(其余线程被屏蔽),再执行走另一条路径的线程。所有路径都执行完毕后,Warp 才重新汇合(reconverge)继续往下执行。
极端情况: 若 Warp 内 32 个线程走了 32 条完全不同的路径,性能退化至理论峰值的 1/32。
分歧仅在 Warp 内部发生,不同 Warp 之间完全独立。 两个 Warp 各自走不同分支是完全并行的,没有任何性能损失。
CC 7.0 前后的差异
在 CC 7.0(Volta)之前,Warp 内所有 32 个线程共享一个程序计数器,必须严格锁步(lockstep)执行。
CC 7.0 起引入了独立线程调度(Independent Thread Scheduling),每个线程拥有独立的程序计数器和调用栈,可以在子 Warp 粒度上分散和重聚,灵活性更高。但这也意味着:依赖"同一 Warp 内线程一定锁步"隐式假设的老代码,在 CC 7.0+ 上可能行为改变,需要显式加 __syncwarp() 保证正确性。
如何减少 Warp Divergence
1. 让同一 Warp 内的线程走相同分支
最根本的方法:让分支条件依赖于 blockIdx 或 Warp ID,而不是 threadIdx——这样同一 Warp 内的线程会整体走同一条路径。
1 | // 差:按线程 ID 分支,Warp 内一半线程走 if,一半走 else |
2. 将分支粒度从线程级提升到线程块级
1 | // 按块分工,每个块只做一种任务,块内无分歧 |
3. 利用谓词指令(Predication)
对于短小的 if/else(如单行赋值),编译器有时会把分支转换为谓词指令——两条分支都执行,但通过掩码控制哪条结果生效。这消除了分歧,但代价是执行了多余的指令。当分支体很小时,这比串行执行两条路径更快。
4. 重新组织数据或算法
有时根本解决方案是调整数据布局或算法结构,让处理同类数据的线程自然落在同一个 Warp 里。例如在排序后再并行处理,或对稀疏数据做压缩。
5. 用 Cooperative Groups 的 coalesced_threads
1 | #include <cooperative_groups.h> |
Q2:CUDA Kernel 的启动配置(Grid/Block 大小)如何设置?有何原则?
Kernel 的启动配置决定了线程的组织方式,直接影响 SM 利用率、内存访问效率和整体性能。没有万能公式,但有明确的设计原则。
线程块大小(blockDim)的原则
1. 必须是 32 的整数倍
线程块大小必须是 Warp 大小(32)的整数倍,否则最后一个 Warp 有若干 lane 空闲,浪费计算资源。常用值:128、256、512。
2. 每块至少 128 个线程
线程数过少,每个 SM 承载的 Warp 数不足以隐藏内存延迟。
3. 不超过 1024 个线程
这是每个线程块的硬件上限。
4. 考虑共享内存和寄存器的限制
每块使用的共享内存越多、每线程使用的寄存器越多,每个 SM 能并发的线程块数越少,Occupancy 越低。
1 | // 查询 SM 资源限制 |
Grid 大小(gridDim)的原则
1. 覆盖所有数据元素
最基本的要求:Grid 中的总线程数 ≥ 问题规模,且每个线程处理一个或固定数量的元素。
1 | int threads = 256; |
2. 让所有 SM 都有活可干
Grid 中的线程块数应至少是 GPU 中 SM 数量的若干倍,让每个 SM 都能获得线程块。若线程块数少于 SM 数,部分 SM 空闲。
1 | cudaDeviceProp prop; |
3. 使用 Occupancy API 自动确定最优配置
1 | int numBlocksPerSm = 0; |
4. 固定线程块数 + grid-stride 循环(高级模式)
对于大规模数据,可以固定启动数量为 SM 数 × 每 SM 最大块数 的线程块,每个线程块通过循环处理多批数据。这减少了线程块启动开销,并支持更好的负载均衡:
1 | __global__ void gridStrideKernel(float* data, int N) { |
多维 Grid/Block
对于 2D 图像或矩阵处理,使用 2D 配置更直观,但对性能无影响:
1 | dim3 block(16, 16); // 16×16=256 线程/块 |
Q3:什么是 CUDA Stream?同步 Stream 与异步 Stream 的区别?
CUDA Stream 的定义
CUDA Stream 是一个操作队列的抽象——程序向 Stream 中提交操作(kernel 启动、内存拷贝等),Stream 按入队顺序依次执行这些操作。
- 同一 Stream 内:操作严格按顺序执行,前一个操作完成后才执行下一个。
- 不同 Stream 间:在硬件资源允许的情况下,可以并发执行。
所有提交到 Stream 的操作都是对 CPU 线程异步的——API 调用立即返回,CPU 不等待 GPU 完成。
默认 Stream(Default Stream)
没有指定 Stream 时,操作进入默认 Stream(也称 NULL Stream 或 Stream 0)。
1 | // 这两行等价,都进入默认 Stream |
阻塞 Stream vs 非阻塞 Stream
这里的"阻塞/非阻塞"是指与默认 Stream 的同步关系,而不是对 CPU 是否阻塞:
阻塞 Stream(Blocking Stream)——cudaStreamCreate 默认创建
- 与默认 Stream 互相等待:向默认 Stream 提交操作时,必须等待所有阻塞 Stream 中的操作完成;阻塞 Stream 中的操作也必须等待默认 Stream 的操作完成。
- 实际效果:有默认 Stream 操作时,阻塞 Stream 不能真正并发。
1 | cudaStream_t stream1, stream2; |
非阻塞 Stream(Non-blocking Stream)——需要显式指定标志
1 | cudaStreamCreateWithFlags(&stream1, cudaStreamNonBlocking); |
Per-thread Default Stream(每线程独立默认 Stream)
从 CUDA 7 起,通过编译选项 --default-stream per-thread,每个 CPU 线程有独立的默认 Stream,不再共享同一个默认 Stream,解决了多线程编程中默认 Stream 的同步问题。
Stream 同步 API
1 | // 阻塞等待某个 Stream 中的所有操作完成 |
Q4:如何利用多 Stream 实现计算与数据传输的重叠?
现代 GPU 有独立的 DMA 引擎负责数据传输,可以与 SM 上的 kernel 执行并发进行。利用多 Stream 可以实现"计算 + 传输"的流水线,消除数据传输开销。
前提条件
- CPU 端内存必须是 Pinned Memory(通过
cudaMallocHost分配),否则cudaMemcpyAsync会退化为同步行为。 - 数据传输使用
cudaMemcpyAsync(而非cudaMemcpy)。 - 传输和计算必须在不同的 Stream 中。
基本流水线模式
将大数据集分成若干 chunk,在处理第 i 个 chunk 的同时传输第 i+1 个 chunk:

1 | const int NUM_STREAMS = 2; |
用 Event 精确控制跨 Stream 依赖
当不同 Stream 之间存在数据依赖时,使用 Event 建立精确的同步点:
1 | cudaEvent_t event; |
cudaStreamWaitEvent 是非阻塞的 CPU 调用——它只是告诉 GPU 调度器在 event 完成前不要启动 stream2 里的后续工作,CPU 线程本身不等待。
Q5:CUDA Event 的用途是什么?如何用于计时和同步?
CUDA Event 是插入到 Stream 中的时间戳标记。当 Event 到达 Stream 队列的队首并被执行时,GPU 记录一个时间戳,并将 Event 标记为已完成(triggered)。
Event 有两大用途:精确计时和跨 Stream 同步。
用途一:精确计时
CPU 端的 clock() 或 chrono 计时会把 CPU 等待时间也算进去,无法准确反映 GPU kernel 的真实执行时间。CUDA Event 是在 GPU 端打时间戳,精度可达微秒级:
1 | cudaStream_t stream; |
用途二:跨 Stream 细粒度同步
Event 可以让一个 Stream 等待另一个 Stream 中某个特定操作完成,而不必等待整个 Stream 清空:
1 | cudaEvent_t event; |
非阻塞查询 Event 状态
1 | // 非阻塞查询,不等待 GPU |
这种轮询模式适合在等待 GPU 的同时,持续推进 CPU 端的工作(见 Q4 中的示例)。
Event 的创建与销毁
1 | cudaEventCreate(&event); // 默认标志 |
若只用于同步而不需要计时,使用 cudaEventDisableTiming 可以减少 GPU 端的时间戳记录开销,略微提升性能。
Q6:__syncthreads() 的作用是什么?在哪些场景下必须使用?
作用
__syncthreads() 是块内的**屏障同步(Barrier Synchronization)**原语。调用时:
- 块内所有线程必须先到达这个调用点,才能继续往后执行。
- 保证在调用之前所有线程对共享内存和全局内存的写操作,在调用之后对块内所有线程可见。
用一句话概括:"所有线程都做完了前面的事,才能一起做后面的事。"
__syncthreads() 只同步同一线程块内的线程,不能同步不同线程块,也不能同步不同 SM 上的线程。CUDA 编程模型不支持跨块同步(Cooperative Groups 在一定程度上扩展了这一限制)。
必须使用的场景
场景 1:写入共享内存后供其他线程读取
这是最典型的使用场景。线程 A 写入共享内存,线程 B 读取同一块数据——必须保证 A 写完后 B 才能读:
1 | __global__ void kernel(int* input, int* output) { |
不加 __syncthreads(),线程读到的可能是其他线程还未写入的旧值,结果未定义。
场景 2:规约(Reduction)操作
经典的并行规约需要多轮迭代,每轮都必须等所有线程完成当前轮次的计算再进入下一轮:
1 | __global__ void reduce(float* data, float* result) { |
场景 3:共享内存中的矩阵转置
先把数据从全局内存加载进共享内存(写),同步后再以不同的访问模式写出:
1 | __shared__ float tile[32][33]; |
使用注意事项
__syncthreads() 必须被块内所有线程执行,且必须执行相同次数。 如果放在条件分支内,且不同线程可能走不同路径,就会导致死锁或未定义行为:
1 | // 错误:只有部分线程调用 __syncthreads(),死锁! |
Q7:__syncwarp() 与 __syncthreads() 的区别?
对比总结
| 维度 | __syncthreads() |
__syncwarp() |
|---|---|---|
| 同步范围 | 整个线程块(所有 Warp) | 单个 Warp(32 个线程) |
| 适用场景 | 共享内存读写屏障、块内归约 | Warp 内细粒度同步、Warp 级规约 |
| 开销 | 较高(等待块内所有线程) | 极低(只等 Warp 内线程) |
| 引入版本 | 所有版本 | CC 9.0 之前就有,CC 7.0 起因独立线程调度变得必要 |
__syncwarp() 的使用场景
__syncwarp() 的出现主要是为了应对 CC 7.0 引入独立线程调度后的代码兼容性问题。
背景: CC 7.0 之前,Warp 内线程锁步执行,写完共享/寄存器数据后,同 Warp 内其他线程立即可见。很多代码依赖这一"隐式同步"行为。CC 7.0 后,线程可以在 Warp 内独立调度,这种隐式保证不再成立,需要显式调用 __syncwarp() 恢复。
1 | // Warp 级规约:只需同步 Warp 内的线程,不必同步整个块 |
注意:__shfl_down_sync、__shfl_xor_sync 等 Warp-level 原语自带同步语义(通过 mask 参数),在大多数现代 Warp 级规约中不再需要单独调用 __syncwarp()。
__syncwarp() 的实际使用场景:
1 | // 场景:Warp 内一个线程写共享内存,其他线程读 |
__syncwarp(mask) 接受一个掩码参数,指定哪些线程参与同步,支持 Warp 内部分线程的细粒度同步。若要同步整个 Warp,传入 0xffffffff。
选择建议
- 涉及共享内存在线程块范围内的读写:用
__syncthreads() - 只涉及同一 Warp 内的数据交换或规约:用
__syncwarp()或带 mask 的 Warp 级 intrinsic - 在 CC 7.0+ 上维护旧代码中隐式的 Warp 同步逻辑:必须补上
__syncwarp()
Q8:什么是 Dynamic Parallelism(动态并行)?
定义
CUDA 动态并行(CDP,CUDA Dynamic Parallelism) 是 CUDA 编程模型的一项特性,允许 GPU 上正在运行的 kernel(设备代码)直接启动新的子 kernel,而无需返回 CPU。
1 | __global__ void childKernel(float* data, int N) { |
解决了什么问题
在 CDP 出现之前,所有 kernel 都必须从 CPU 启动。对于数据依赖的、工作量在运行时才能确定的算法(如递归分治、自适应网格细化、不规则树遍历),必须在 CPU 端反复做"GPU 计算 → CPU 分析结果 → 再启动 GPU"的循环,CPU-GPU 之间的往返开销严重影响性能。
CDP 让工作量的动态分配决策可以在 GPU 端运行时作出,避免了 CPU-GPU 控制流切换,适合:
- 递归算法(如快排、树遍历)
- 不规则并行(如稀疏矩阵、自适应细化网格)
- 工作量只能在运行时确定的场景
父子 Grid 的关系

- 子 kernel 由父 Grid 中的线程启动,父 Grid 只有等所有子 Grid 都完成后才算结束(运行时保证隐式同步)。
- 父 Grid 和子 Grid 共享全局内存和常量内存,但各自有独立的局部内存和共享内存。
- 不能把父 kernel 的局部变量(栈上变量)指针传给子 kernel,必须使用全局内存分配(
cudaMalloc/__device__全局变量)。
内存一致性
| 内存类型 | 父 Grid 可见 | 子 Grid 可见 |
|---|---|---|
| 全局内存 | ✓ | ✓(同一指针可用) |
| 常量内存 | ✓ | ✓ |
| 共享内存 | ✓(自己的) | ✗(无法访问父的) |
| 局部内存 | ✓(自己的) | ✗(禁止传递指针) |
使用限制
- 需要 CC 3.5 及以上(CDP1)或 CC 9.0 及以上(CDP2)
- CDP2 是 CUDA 12.0 起的默认版本,仅 CC 9.0+ 支持;对 CC < 9.0 需用 CDP1(将来会移除)
- 设备端的 CUDA Runtime API 是主机端的子集,部分 API 不可用
- 动态并行的启动开销大于从 CPU 端启动,不适合频繁启动小 kernel 的场景
Q9:CUDA 中的原子操作(Atomic Operations)有哪些?使用时需注意什么?
什么是原子操作
原子操作(Atomic Operation)是不可被其他线程打断的读-改-写操作。GPU 上多个线程可能同时读写同一个内存地址,普通操作会产生数据竞争(Race Condition),结果不确定。原子操作保证了整个"读取旧值 → 计算 → 写回新值"的过程是原子的,不会被其他线程的操作插入。
原子函数只能在设备代码(kernel)中使用,作用于全局内存或共享内存中的 32/64/128 位数据。
完整的原子操作列表
算术类:
| 函数 | 操作 | 支持类型 |
|---|---|---|
atomicAdd(addr, val) |
*addr += val,返回旧值 |
int, uint, ulong long, float, double, half, half2 等 |
atomicSub(addr, val) |
*addr -= val,返回旧值 |
int, uint |
atomicInc(addr, val) |
*addr = (*addr >= val) ? 0 : (*addr + 1),返回旧值 |
uint |
atomicDec(addr, val) |
*addr = (old==0 \|\| old>val) ? val : (old-1),返回旧值 |
uint |
atomicMin(addr, val) |
*addr = min(*addr, val),返回旧值 |
int, uint, ulong long, long long |
atomicMax(addr, val) |
*addr = max(*addr, val),返回旧值 |
int, uint, ulong long, long long |
位操作类:
| 函数 | 操作 | 支持类型 |
|---|---|---|
atomicAnd(addr, val) |
*addr &= val,返回旧值 |
int, uint, ulong long |
atomicOr(addr, val) |
*addr \|= val,返回旧值 |
int, uint, ulong long |
atomicXor(addr, val) |
*addr ^= val,返回旧值 |
int, uint, ulong long |
交换类:
| 函数 | 操作 | 支持类型 |
|---|---|---|
atomicExch(addr, val) |
*addr = val,返回旧值 |
int, uint, ulong long, float |
atomicCAS(addr, cmp, val) |
*addr = (*addr==cmp) ? val : *addr,返回旧值 |
int, uint, ulong long, uint short |
作用域后缀
原子操作有三个作用域级别,通过后缀区分:
| 后缀 | 作用域 | 示例 |
|---|---|---|
| 无后缀 | 设备级(所有 SM 的线程可见) | atomicAdd(addr, 1) |
_block |
块级(同一线程块内可见) | atomicAdd_block(addr, 1) |
_system |
系统级(CPU + 所有 GPU 可见) | atomicAdd_system(addr, 1) |
块级原子在共享内存上性能最好;设备级原子作用于全局内存;系统级原子用于 CPU-GPU 协同场景(需要支持并发访问的统一内存)。
atomicCAS:实现自定义原子操作的万能工具
任何原子操作都可以基于 atomicCAS(Compare and Swap)实现。例如,float 类型的 atomicAdd(老版本 CUDA 不支持 float 原子加)可以这样手动实现:
1 | __device__ float atomicAddFloat(float* addr, float val) { |
CAS 循环的逻辑:读取当前值 → 计算期望的新值 → 尝试用 CAS 写入(若当前值没变则写入成功,否则重试)。
使用注意事项
1. 原子操作会序列化,是性能瓶颈
对同一地址的多个原子操作在硬件上仍然是串行的,大量线程竞争同一地址时性能很差。
经典优化:先在共享内存中做局部规约,再对全局内存做一次原子操作:
1 | __global__ void histogram(int* input, int* globalHist, int N) { |
2. 原子操作无返回顺序保证
多个线程对同一地址的原子操作是串行的,结果确定,但执行顺序不确定。
3. 浮点原子加的精度问题
atomicAdd 的浮点版本执行顺序不确定,而浮点加法不满足结合律,因此结果可能因运行顺序不同而有微小差异(非确定性)。对精度敏感的科学计算需注意这一点。
4. 共享内存原子操作要注意 Bank 冲突
对共享内存的原子加如果多个线程命中同一 Bank,会同时产生 Bank 冲突和原子序列化的双重开销。尽量分散访问地址(如使用线程 ID 取模分配 Bank)。
