性能

线程束分化warp divergence

线程束分化 (warp divergence) 发生在 线程束 (warp) 内的线程因控制流语句而执行不同路径时出现的现象。

例如,考虑以下 内核 (kernel)

__global__ void divergent_kernel(float* data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        if (data[idx] > 0.5f) {
		    // A
            data[idx] = data[idx] * 4.0f;
        } else {
		    // B
            data[idx] = data[idx] + 2.0f;
        }
        data[idx] = data[idx] * data[idx];
    }
}

线程束 (warp) 内的 线程 (threads) 遇到数据相关的条件判断时,根据 data[idx] 的值,一些 线程 (threads) 必须执行代码块 A,而其他线程必须执行代码块 B。由于这种数据依赖性以及 CUDA 编程模型 (CUDA programming model) 及其在 PTX 机器模型 (PTX machine model) 中实现的结构性约束,程序员或编译器无法避免 线程束 (warp) 内部出现这种控制流分裂。

此时,线程束调度器 (warp scheduler) 必须处理这些发散代码路径的并发执行,具体通过 “屏蔽” 部分 线程 (threads) 使其不执行指令来实现这一点。这是通过使用 谓词寄存器 (predicate registers) 实现的。

让我们检查生成的 SASS (Streaming Assembler) (Godbolt 链接) 来理解执行流程:

LDG.E.SYS R4, [R2]                       // L1 load data[idx]
FSETP.GT.AND P0, PT, R4.reuse, 0.5, PT   // L2 set P0 to data[idx] > 0.5
FADD R0, R4, 2                           // L3 store 2 + data[idx] in R0
@P0 FMUL R0, R4, 4                       // L4 in some threads, store 4 * data[idx] in R0
FMUL R5, R0, R0                          // L5 store R0 * R0 in R5
STG.E.SYS [R2], R5                       // L6 store R5 in data[idx]

将数据加载到 R4 (L1 行) 后,线程束 (warp) 中的所有 32 个 线程 (threads) 并发执行 FSETP.GT.AND (L2 行),每个 线程 (thread) 根据 R4 中的 data 值获得自己的 P0 值。随后是 编译器 (nvcc) 的一个巧妙设计之处:在 L3 行,所有 线程 (threads) 都执行代码块 A 的代码,写入 R0。只有那些 P0 为真的线程会继续执行代码块 B 的代码 (L4 行),覆盖在 L3 行中写入 R0 的值。此时,线程束 (warp) 被称为处于 "分化" 的状态。在 L5 行,所有 线程 (threads) 回到同一执行路径。当 线程束调度器 (warp scheduler) 通过在同一时钟周期发射相同指令使线程重新对齐后,线程束就 "收敛" 了。

这种实现可能比将分支直接编码为 SASS (Streaming Assembler) 中更高效,后者会对 L3 行 和 L4 行两行都进行谓词判断 — 我们有理由相信 编译器 (nvcc) 的优化,并且启发式地看,这种设计是以廉价且足够的 CUDA 核心 (CUDA Core) 计算来换取更昂贵的流控制操作。正如在 GPU 编程中常见的那样,即使只是简单的谓词化,浪费计算资源(每次执行 L4 行时进行一次不必要的 FADD)也往往比增加控制流复杂度要好!

编译器可能积极避免分化的一个原因是,在早期(Volta 架构之前)的 GPU 中,分化的 线程束 (warps) 总是完全串执行的。虽然线程束分化仍然会降低效率,但具有独立线程调度的现代 GPU 不一定会经历完全串行化带来的性能损失。

英文原文(Modal GPU Glossary)

Warp divergence occurs when threads within a warp take different execution paths due to control flow statements.

For example, consider this kernel:

__global__ void divergent_kernel(float* data, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        if (data[idx] > 0.5f) {
		    // A
            data[idx] = data[idx] * 4.0f;
        } else {
		    // B
            data[idx] = data[idx] + 2.0f;
        }
        data[idx] = data[idx] * data[idx];
    }
}

When the threads within a warp encounter the data-dependent conditional, some threads must execute block A while others must execute block B, depending on the value at data[idx]. Because of this data-dependency and the structural constraints of the CUDA programming model and its implementation in the PTX machine model, there is no way for a programmer or a compiler to avoid this split in control flow inside of the warp.

Instead, the warp scheduler must handle concurrent execution of these divergent code paths, which it achieves by "masking" some threads so that they don't execute the instruction. This is achieved using predicate registers.

Let's examine the generated SASS (Godbolt link) to understand the execution flow:

LDG.E.SYS R4, [R2]                       // L1 load data[idx]
FSETP.GT.AND P0, PT, R4.reuse, 0.5, PT   // L2 set P0 to data[idx] > 0.5
FADD R0, R4, 2                           // L3 store 2 + data[idx] in R0
@P0 FMUL R0, R4, 4                       // L4 in some threads, store 4 * data[idx] in R0
FMUL R5, R0, R0                          // L5 store R0 * R0 in R5
STG.E.SYS [R2], R5                       // L6 store R5 in data[idx]

After loading the data into R4 (L1), all 32 threads in the warp execute FSETP.GT.AND concurrently (L2), and each thread gets its own P0 value based on the data value in R4. Then, we have a bit of compiler cleverness: in L3 _all_ threads execute the code in A, writing to R0. Only those for whom P0 is true then execute the code in B (L4), over-writing the value written to R0 in L3. On this instruction, the warp is said to be "divergent". On L5, all threads are back to executing the same code. Once the warp scheduler brings them back into alignment by issuing the same instruction on the same clock cycle, the warp has "converged".

This is presumably more efficient than the naïve encoding of the branch into SASS, which would instead predicate both lines L3 and L4 — "presumably" in that we can trust the compiler and in that, heuristically, we are trading use of cheap, plentiful CUDA Core computation for more expensive flow control. As often in GPU programming, it's better to waste compute (an unnecessary FADD for every execution of L4) than to add complexity, even if it's just a simple predication!

One reason compilers might aggressively avoid divergence is that in early (pre-Volta) GPUs, divergent warps were always fully serialized. While warp divergence still reduces efficiency, modern GPUs with independent thread scheduling don't necessarily experience the full serialization penalties.

相关词条

本词条改编自 Modal GPU Glossary(CC BY 4.0)· 中文翻译 miter6/gpu-glossary-zh,MAE 整理排版。