GPU 架构学习

第六课:GPU 中的数据依赖、数据冒险与同步

这一课讲 GPU 中的数据依赖与数据冒险,重点区分“依赖本身”和“流水线造成的冲突”,并说明 Scoreboard、原子操作和内存屏障如何保证正确性。

目录一、为什么流水线会产生数据冒险二、三种常见数据依赖1. RAW:Read After Write2. WAR:Write After Read3. WAW:Write After Write三种依赖的总结三、GPU 中的寄存器依赖四、Scoreboard 如何处理 RAW 依赖五、Load-Use Hazard六、连续依赖链为什么影响性能七、同一 Warp 中不同线程的寄存器是否会冲突八、不同 Warp 之间有寄存器依赖吗九、内存依赖比寄存器依赖更复杂十、什么是数据竞争十一、使用 __syncthreads() 解决 Block 内依赖十二、Barrier 和 Fence 不是同一回事Barrier:屏障Fence:内存栅栏十三、常见 Memory Fence十四、原子操作是什么十五、原子操作解决什么问题十六、原子操作为什么可能很慢十七、原子操作不等于全局同步十八、不同 Block 能否使用 __syncthreads() 同步十九、Kernel 边界为什么可以用于全局同步二十、同一个 Warp 内是否需要同步二十一、存储顺序问题二十二、寄存器 Scoreboard 不能解决内存数据竞争二十三、GPU 为什么不大量使用 CPU 式乱序执行二十四、指令依赖和 Warp 调度的完整过程二十五、流水线停顿的几种依赖来源1. 短延迟执行依赖2. 长延迟内存依赖3. Shared Memory 依赖4. Tensor Core 累加依赖5. 原子操作依赖6. 同步依赖二十六、依赖不一定意味着性能很差二十七、依赖、冒险和停顿的区别数据依赖数据冒险流水线停顿二十八、GPU 是否使用数据转发二十九、从 RTL 角度看依赖处理三十、本节核心结论第七课:GPU 到底是算力受限还是带宽受限

导语:这一课讲 GPU 中的数据依赖与数据冒险,重点区分“依赖本身”和“流水线造成的冲突”,并说明 Scoreboard、原子操作和内存屏障如何保证正确性。

这一部分需要先区分两个容易混淆的概念:

数据依赖是程序本身的关系;数据冒险是硬件并行执行这些指令时可能发生的问题。

例如:

a = b + c;
d = a * e;

第二条语句必须使用第一条语句产生的 a,因此二者存在数据依赖。

即使在一个完全非流水线、一次只执行一条指令的处理器中,这种依赖仍然存在。但因为第二条指令一定在第一条完成后才开始,所以不会形成流水线数据冒险。


一、为什么流水线会产生数据冒险

假设加法需要四个流水级:

取指 → 译码 → 执行 → 写回

现在有两条指令:

I1:ADD R1, R2, R3
I2:MUL R4, R1, R5

I2 需要读取 I1 产生的 R1

在流水线中,它们可能重叠:

周期    I1                I2
1       取指
2       译码              取指
3       执行              译码
4       写回 R1           准备读取 R1

如果 I2R1 写回之前就读取,就可能得到旧值。

这就是数据冒险。

所以更准确地说:

数据依赖
    +
指令重叠执行

可能产生数据冒险

二、三种常见数据依赖

1. RAW:Read After Write

读发生在写之后,也称为真依赖

a = b + c;
d = a * e;

对应:

I1:写 a
I2:读 a

I2 必须等待 I1。

这是最重要、最常见的一类依赖。


2. WAR:Write After Read

写发生在读之后,也称为反依赖

d = a + b;
a = e + f;

对应:

I1:读 a
I2:写 a

程序要求 I1 先读取旧的 a,然后 I2 才能覆盖它。

如果 I2 提前写入,I1 可能读到新值。

WAR 不表示数据真正从 I1 流向 I2,主要是因为二者使用了同一个名字 a


3. WAW:Write After Write

两条指令写同一个位置,也称为输出依赖

a = b + c;
a = d * e;

最终必须保留第二次写入的结果。

如果硬件因为执行延迟不同,导致第一条指令反而最后写回,就会产生错误。


三种依赖的总结

类型前一条指令后一条指令性质
RAW真依赖
WAR名字依赖
WAW名字依赖

在 GPU 中,RAW 是最常见、最直接需要硬件等待的依赖。

WAR 和 WAW 往往可以通过以下方式减少:

  • 编译器重新分配寄存器;
  • 使用不同的物理寄存器;
  • 保证 Warp 内指令按顺序发射;
  • 硬件依赖跟踪。

三、GPU 中的寄存器依赖

考虑一个 GPU 线程中的代码:

float a = x + y;
float b = a * z;
float c = p + q;

简化指令可能是:

I0:ADD R1, R2, R3
I1:MUL R4, R1, R5
I2:ADD R6, R7, R8

其中:

I1 依赖 I0
I2 不依赖 I0 和 I1

当 Warp 发射 I0 后,R1 的结果还未产生。

Scoreboard 会记录:

R1:Not Ready

因此 I1 不能立即发射。

但 I2 使用的是其他寄存器,理论上可以执行。

实际是否能够越过 I1 发射 I2,取决于具体 GPU 架构、编译结果和指令调度方式。通常 GPU 更依赖编译器提前安排独立指令,而不是采用 CPU 那样复杂的动态乱序执行。


四、Scoreboard 如何处理 RAW 依赖

Scoreboard 是 GPU 内部的依赖跟踪结构。

假设 Warp 的指令为:

LOAD R1, [A]
ADD  R2, R1, R3

当 LOAD 发射后:

R1 = Pending

ADD 需要读取 R1,因此不能发射。

Scoreboard 的工作过程可以简化为:

检查下一条指令的源寄存器

源寄存器是否全部 Ready

是:Warp 可以参与调度
否:Warp 暂时停顿

当内存数据返回:

R1 = Ready

Warp 才重新成为可发射 Warp。


五、Load-Use Hazard

GPU 中很常见的一种 RAW 冒险是:

float x = A[i];
float y = x + 1.0f;

简化后:

LOAD R1, [A+i]
ADD  R2, R1, 1

ADD 必须等待 LOAD 返回。

而 Global Memory 访问可能有较长延迟,所以这个等待可能远长于普通加法依赖。

执行过程可能是:

五、Load-Use Hazard

图示:五、Load-Use Hazard

这就是多 Warp 延迟隐藏的典型场景。


六、连续依赖链为什么影响性能

考虑:

a = b + c;
d = a + e;
f = d + g;
h = f + i;

形成一条长依赖链:

b,c → a → d → f → h

每一条指令都必须等待上一条完成。

即使执行流水线吞吐率很高,也无法让这一条依赖链中的指令完全重叠。

这种情况称为较低的指令级并行性。

相对而言:

a = b + c;
d = e + f;
g = h + i;
j = k + l;

这些指令相互独立,可以更好地填充流水线。

所以优化 GPU 程序时,有时会采用多个独立累加器。

较差:

sum += a0;
sum += a1;
sum += a2;
sum += a3;

形成连续依赖:

sum0 → sum1 → sum2 → sum3

较好的思路:

sum0 += a0;
sum1 += a1;
sum2 += a2;
sum3 += a3;

sum = sum0 + sum1 + sum2 + sum3;

这样可以增加独立指令数量。


七、同一 Warp 中不同线程的寄存器是否会冲突

通常不会。

每个线程都有自己的逻辑寄存器状态。

例如同一个 Warp 中执行:

x = x + 1;

虽然所有线程的源代码都叫 x,但实际是:

Thread 0 的 x
Thread 1 的 x
Thread 2 的 x
……

它们属于不同线程的寄存器上下文。

所以:

Thread 0 写自己的 R1

不会直接覆盖:

Thread 1 的 R1

寄存器依赖通常首先是同一个线程指令流内部的依赖。


八、不同 Warp 之间有寄存器依赖吗

通常没有直接寄存器依赖。

因为:

  • 每个线程有自己的寄存器;
  • 每个 Warp 由不同线程组成;
  • Warp 之间不能直接读取彼此的寄存器。

例如:

Warp 0 的 Thread 0 的 R1

与:

Warp 1 的 Thread 0 的 R1

是不同的物理状态。

不同 Warp 如果需要交换数据,通常必须通过:

  • Shared Memory;
  • Global Memory;
  • 原子操作;
  • Warp/Block 同步机制。

因此,跨 Warp 的依赖主要是内存依赖,而不是寄存器依赖。


九、内存依赖比寄存器依赖更复杂

考虑两个线程:

// Thread 0
shared_data[0] = 100;

// Thread 1
int x = shared_data[0];

Thread 1 希望读取 Thread 0 写入的结果。

这里存在跨线程的 RAW 依赖:

Thread 0 写 shared_data[0]
Thread 1 读 shared_data[0]

但 GPU 的寄存器 Scoreboard 通常不会自动替程序员解决这种跨线程关系。

硬件并不知道程序员是否希望:

  • Thread 1 读取旧值;
  • Thread 1 读取新值;
  • 两个线程谁先执行。

因此需要程序显式同步。


十、什么是数据竞争

考虑:

__shared__ int x;

if (threadIdx.x == 0) {
    x = 100;
}

if (threadIdx.x == 1) {
    int y = x;
}

这里没有保证 Thread 0 一定先写完。

可能出现:

情况一:
Thread 0 先写
Thread 1 后读
→ y = 100

也可能:

情况二:
Thread 1 先读
Thread 0 后写
→ y 可能是旧值

这种结果取决于执行时序的情况称为:

Data Race,数据竞争。

数据竞争通常包含:

  1. 多个线程访问同一个存储位置;
  2. 至少一个访问是写操作;
  3. 缺少正确的同步或原子操作;
  4. 执行结果依赖实际时序。

十一、使用 __syncthreads() 解决 Block 内依赖

正确写法可以是:

__shared__ int x;

if (threadIdx.x == 0) {
    x = 100;
}

__syncthreads();

if (threadIdx.x == 1) {
    int y = x;
}

执行过程:

十一、使用 __syncthreads() 解决 Block 内依赖

图示:十一、使用 __syncthreads() 解决 Block 内依赖

__syncthreads() 同时提供两个重要功能:

  1. 执行同步:Block 内参与线程都到达后才继续;
  2. 内存可见性:同步点前的相关内存操作按规则对 Block 内线程可见。

十二、Barrier 和 Fence 不是同一回事

这两个概念容易混淆。

Barrier:屏障

屏障要求线程相互等待。

例如:

__syncthreads();

可以理解为:

所有线程都到齐
才能继续向后执行

Fence:内存栅栏

Fence 主要约束内存操作的顺序和可见性。

它不一定要求其他线程也停下来等待。

可以理解为:

保证我之前的写操作
在我之后的某些操作之前完成或可见

但它不代表其他线程已经执行到某个位置。

因此:

Barrier = 线程执行同步
Fence   = 内存顺序约束

有些同步原语会同时具有两种效果,但概念上需要区分。


十三、常见 Memory Fence

CUDA 中常见的内存栅栏概念包括:

__threadfence_block();
__threadfence();
__threadfence_system();

可以粗略理解为:

操作主要约束范围
__threadfence_block()当前 Block
__threadfence()当前 GPU
__threadfence_system()更大的系统范围,包括主机等参与者

它们主要保证当前线程的内存访问顺序。

但需要特别记住:

Fence 本身通常不会让其他线程等待。

例如:

data = 100;
__threadfence();
flag = 1;

其意图是让观察到 flag == 1 的线程,有机会按照正确协议确认 data 已经写入。

但读取方仍需要正确的原子操作、可见性规则或同步协议。


十四、原子操作是什么

考虑多个线程同时执行:

counter = counter + 1;

这条语句实际包含:

1. 读取 counter
2. 加 1
3. 写回 counter

假设初始值为 0。

Thread 0 和 Thread 1 同时执行:

Thread 0 读取 0
Thread 1 读取 0

Thread 0 计算 1
Thread 1 计算 1

Thread 0 写入 1
Thread 1 写入 1

最终结果是 1,而不是 2。

这叫做丢失更新。

使用原子操作:

atomicAdd(&counter, 1);

硬件保证这个读—改—写过程不可被其他线程插入破坏。

执行结果相当于:

Thread 0:counter 0 → 1
Thread 1:counter 1 → 2

十五、原子操作解决什么问题

原子操作常用于:

  • 全局计数器;
  • 直方图;
  • 工作队列;
  • 锁;
  • 引用计数;
  • 并行归约的部分阶段;
  • 多线程更新同一数据。

常见原子操作包括:

atomicAdd
atomicSub
atomicMax
atomicMin
atomicCAS
atomicExch

其中 atomicCAS 是 Compare-And-Swap:

如果内存中的旧值等于期望值
就写入新值
否则不写入

它可以用来构建锁和其他同步结构。


十六、原子操作为什么可能很慢

如果许多线程同时更新同一个地址:

atomicAdd(&counter, 1);

这些操作必须以某种方式序列化。

例如 32 个线程访问同一个计数器:

Thread 0 更新
Thread 1 更新
Thread 2 更新
……

即使硬件能够优化,热点地址仍可能成为瓶颈。

因此常见优化方式是:

每个 Warp 或 Block 先局部累加

只让少数线程执行全局原子更新

例如:

32 个线程

Warp 内归约

1 个线程 atomicAdd

这样可以把 32 次原子操作减少为 1 次。


十七、原子操作不等于全局同步

假设执行:

atomicAdd(&counter, 1);

它只保证该原子操作本身具有规定的原子性和内存语义。

它不表示:

  • 所有线程都执行到这里了;
  • 所有线程的其他计算都完成了;
  • 所有 Block 都同步了;
  • 整个 Kernel 到达统一屏障。

所以:

Atomic ≠ Barrier

原子操作用于保护共享数据更新,屏障用于协调执行阶段。


十八、不同 Block 能否使用 __syncthreads() 同步

不能。

__syncthreads() 只作用于一个 Block。

原因是不同 Block:

  • 可能位于不同 SM;
  • 可能不是同时驻留;
  • 某些 Block 甚至还没有开始执行。

假设 GPU 一次只能运行部分 Block:

Block 0~7:正在执行
Block 8~15:尚未调度

如果前面的 Block 等待后面的 Block 到达屏障,就可能永远无法继续。

因此普通 Kernel 内通常不能依赖所有 Block 同时执行。

跨 Block 的全局同步通常采用:

  1. 结束当前 Kernel;
  2. 由 CPU 或运行时启动下一个 Kernel。

Kernel 边界天然形成一个全局执行阶段边界。

也可以使用协作组等特殊机制,但需要硬件和启动方式支持。


十九、Kernel 边界为什么可以用于全局同步

例如:

Kernel 1:所有 Block 生成中间结果
Kernel 1 完成
Kernel 2:读取中间结果继续计算

只有 Kernel 1 的全部 Block 完成后,依赖它的 Kernel 2 才会开始,前提是它们位于同一有序执行流并正确建立依赖。

因此:

Kernel 1 完成

全局结果已经形成

Kernel 2 开始

这是 GPU 编程中很常见的阶段划分方式。


二十、同一个 Warp 内是否需要同步

过去常假设同一 Warp 中线程严格锁步执行。

但现代 NVIDIA GPU 支持更灵活的线程调度,因此当 Warp 内线程通过 Shared Memory 或其他方式交换数据时,不应盲目依赖隐式锁步。

例如:

shared[lane] = value;

__syncwarp();

value = shared[lane ^ 1];

__syncwarp() 明确保证参与线程在 Warp 范围内同步。

如果只使用 Shuffle:

value = __shfl_sync(mask, value, source_lane);

该指令自身已经定义了参与线程和通信行为,但仍需要保证掩码和控制流使用正确。


二十一、存储顺序问题

考虑一个生产者线程:

data = 100;
flag = 1;

消费者线程:

if (flag == 1) {
    use(data);
}

程序员希望:

消费者看到 flag == 1

也一定能看到 data == 100

但在并行系统中,以下因素可能使事情更复杂:

  • 编译器重排;
  • 硬件内存流水线;
  • Cache;
  • 写缓冲;
  • 不同地址的访问完成顺序;
  • 不同线程的观察顺序。

因此生产者—消费者通信通常需要:

  • 原子变量;
  • 合适的内存顺序;
  • Fence;
  • Barrier;
  • 明确的同步协议。

不能只依赖普通的 flag = 1


二十二、寄存器 Scoreboard 不能解决内存数据竞争

Scoreboard 能处理:

同一个 Warp 中
某条指令等待前面指令产生寄存器结果

但它不能自动推断:

Thread 0 写 shared[5]
Thread 17 读取 shared[5]

是否应该等待。

原因是:

  • 地址可能运行时才能计算;
  • 不同线程的语义由程序员定义;
  • 两个访问也可能本来就允许并发;
  • 自动同步所有潜在冲突会严重降低性能。

因此可以总结为:

寄存器依赖
→ 主要由硬件 Scoreboard 和编译器管理

跨线程内存依赖
→ 主要由程序员使用同步机制管理

二十三、GPU 为什么不大量使用 CPU 式乱序执行

高性能 CPU 会在单线程内部寻找可独立执行的指令,并动态乱序执行。

这需要复杂结构,例如:

  • 寄存器重命名;
  • 保留站;
  • Reorder Buffer;
  • 动态调度;
  • 推测执行;
  • 异常恢复。

GPU 的主要思路不同:

一个 Warp 等待

执行另一个 Warp

也就是使用线程级并行性代替复杂的单线程乱序机制。

这样可以把更多芯片面积用于:

  • 更多执行单元;
  • 更大寄存器文件;
  • 更多 Warp 状态;
  • 更高显存带宽;
  • Tensor Core。

GPU 并非完全没有动态调度,但通常不会像高性能 CPU 那样,为单个线程配备极其复杂的乱序执行窗口。


二十四、指令依赖和 Warp 调度的完整过程

假设 SM 中有四个 Warp:

Warp 0:
LOAD R1
ADD R2, R1, R3

Warp 1:
FP32 ADD

Warp 2:
等待同步

Warp 3:
Tensor Core MMA

执行过程:

1. Warp 0 发射 LOAD
2. Scoreboard 标记 R1 未就绪
3. Warp 0 的 ADD 暂时不能执行
4. 调度器选择 Warp 1
5. Warp 1 发射 FP32 ADD
6. 调度器选择 Warp 3
7. Warp 3 发射 Tensor Core 指令
8. Warp 0 的数据返回
9. R1 标记为 Ready
10. Warp 0 再次成为 Eligible Warp
11. 发射 ADD

这说明 GPU 并不一定消除某条指令的等待时间,而是用其他 Warp 的工作覆盖等待时间。


二十五、流水线停顿的几种依赖来源

1. 短延迟执行依赖

例如:

a = b + c;
d = a + e;

等待普通算术结果。

2. 长延迟内存依赖

例如:

a = global_memory[i];
d = a + e;

等待 Global Memory 数据。

3. Shared Memory 依赖

例如访问 Shared Memory 后立即使用结果,或者发生 Bank Conflict。

4. Tensor Core 累加依赖

连续矩阵累加使用同一累加寄存器时,也可能形成依赖链。

5. 原子操作依赖

多个线程对同一地址执行原子操作,可能产生序列化。

6. 同步依赖

Warp 等待:

__syncthreads();

直到其他线程到达。


二十六、依赖不一定意味着性能很差

几乎所有实际程序都有依赖。

例如:

x = A[i];
y = x * scale;
z = y + bias;

这是正常的计算链。

性能是否受严重影响,取决于:

  • 依赖链有多长;
  • 指令延迟有多大;
  • 是否有其他独立指令;
  • 是否有足够多可执行 Warp;
  • 执行单元是否有其他工作;
  • 访存是否命中缓存。

所以优化目标不是“消灭所有依赖”,而是:

提供足够的独立工作,覆盖无法避免的依赖延迟。


二十七、依赖、冒险和停顿的区别

这三个概念可以这样区分:

数据依赖

程序语义上的先后关系。

后一条指令需要前一条结果

数据冒险

因为指令重叠或乱序执行,可能违反依赖关系。

结果还没准备好,下一条就想读取

流水线停顿

硬件为了避免错误,暂时不发射或不推进相关指令。

等待数据准备完成

完整关系是:

流水线停顿

图示:流水线停顿


二十八、GPU 是否使用数据转发

普通 CPU 流水线常使用 Forwarding 或 Bypassing:

执行单元结果

直接送给下一条指令

而不是等结果先写回寄存器文件,再重新读取。

GPU 的具体转发网络取决于架构,通常也会有各种旁路和结果转发机制,以减少不必要的等待。

但从程序员角度,不需要显式控制这些通路。

入门时只需要理解:

  • Scoreboard 判断结果是否可用;
  • 可用结果可能通过写回或旁路提供;
  • 如果结果尚不可用,Warp 会等待;
  • 调度器转去执行其他 Warp。

二十九、从 RTL 角度看依赖处理

一个简化的依赖检查逻辑可以表示为:

二十九、从 RTL 角度看依赖处理

图示:二十九、从 RTL 角度看依赖处理

结果写回时:

执行单元完成

写回 dst

Scoreboard 将 dst 标记为 Ready

对于多个 Warp,Scoreboard 状态必须带有 Warp 标识:

Warp ID + Register ID

因为不同 Warp 的同名寄存器属于不同上下文。


三十、本节核心结论

第一,数据依赖由程序本身决定,不是由流水线创造的。

第二,流水线和并行执行会使数据依赖表现为数据冒险。

第三,非流水线处理器中仍然存在数据依赖,但通常不会出现流水线数据冒险。

第四,RAW 是真依赖,后一条指令必须等待前一条结果。

第五,WAR 和 WAW 主要是寄存器或存储位置名称复用造成的依赖。

第六,GPU 使用 Scoreboard 跟踪 Warp 的寄存器结果是否准备好。

第七,一个 Warp 因依赖停顿时,Warp Scheduler 会尝试执行其他 Warp。

第八,同一线程的寄存器依赖通常由编译器和硬件自动处理。

第九,不同线程之间通过 Shared Memory 或 Global Memory 交换数据时,程序员必须正确同步。

第十,__syncthreads() 是 Block 级屏障,而 __syncwarp() 是 Warp 级同步。

第十一,Memory Fence 主要保证内存顺序,不等于线程屏障。

第十二,原子操作保证一次共享数据更新不可被其他线程破坏,但不等于全局同步。

第十三,不同 Block 通常不能在普通 Kernel 中使用 Block 屏障直接同步。

第十四,长依赖链会降低指令级并行性,但可以利用其他 Warp 隐藏延迟。

第十五,GPU 主要通过大量 Warp 调度,而不是复杂的 CPU 式单线程乱序执行来隐藏延迟。

下一课将进入 GPU 性能分析的核心模型:

第七课:GPU 到底是算力受限还是带宽受限

主要包括:

FLOPS 与显存带宽
Arithmetic Intensity
Compute Bound
Memory Bound
Roofline Model
为什么矩阵乘法适合 GPU
为什么数组加法通常跑不满算力
如何判断一个 Kernel 的性能瓶颈

第四课:SM 内部结构与指令执行过程

这一部分进入 SM 内部,重点讲清楚 Warp Scheduler、发射、执行单元、Scoreboard 和流水线之间的关系。

第二十七课:GPU 架构学习总结与统一分析框架

最后一课将把前面所有内容压缩成一套统一分析框架,并给出面向 GPU 架构与 RTL 设计的后续实践路线。

第二十六课:如何从零设计一个简化的 SIMT GPU

这一课从 RTL 设计角度搭建一颗最小可运行的 SIMT GPU。重点是模块边界、状态机和实现顺序,而不是一开始复制完整商用 GPU。