CUDA 编程完全理解系列(第四篇):硬件视角下的索引变量与分级内存机制
前言
前三篇文章中,我们理解了 GPU 的设计哲学(用并发隐藏延迟)和硬件的工作流程(GigaThread 分配 Block,Warp Scheduler 轮流执行)以及dim3的底层逻辑。
但我们还没有真正"看到"数据在 GPU 上是如何流动的。当一个线程执行 global_id = blockIdx.x * blockDim.x + threadIdx.x 时,硬件在做什么?当它访问 global_data[global_id] 时,数据要经过多少层存储才能到达计算单元?
这一篇的任务很专一:从硬件的角度讲清楚"索引变量是什么"和"内存是如何分层的"。
第一部分:索引变量的硬件映射
代码中的索引变量
以一维的情况为例,每个 CUDA kernel 的开头都会这样写:
cpp
__global__ void processPoints(float* points, float* output, int n) {
int block_id = blockIdx.x; // 这个 Block 的 ID
int block_dim = blockDim.x; // 每个 Block 有多少个 thread
int thread_id = threadIdx.x; // 当前 thread 在 Block 内的位置
// 计算全局线程 ID
int global_id = block_id * block_dim + thread_id;
// 边界检查
if (global_id >= n) return;
// 处理数据
output[global_id] = points[global_id] * 2.0f;
}
这看起来像是"普通的算术计算",但关键问题是:blockIdx.x 和 threadIdx.x 在硬件上真的是"变量"吗?
答案是:不是。它们是硬件特殊寄存器。
特殊寄存器 vs 普通寄存器
在 CUDA 硬件中,寄存器分两类:
普通寄存器:
├─ 通用途途,存储线程的局部变量
├─ 由编译器分配
├─ 每个线程有自己的副本
└─ 数量有限(每 SM 共 65536 个)
特殊寄存器:
├─ 由 GPU 硬件直接设置和维护
├─ 存储线程的"身份信息"
└─ 包括:
├─ SR_CTAID.X/Y/Z (Current Thread block ID)
├─ SR_TID.X/Y/Z (Thread ID within block)
├─ SR_NTID.X/Y/Z (Number of Threads per block)
├─ SR_GRIDID.X/Y (Grid ID)
└─ 其他系统状态寄存器
GPU 硬件的初始化过程
当 GigaThread 调度器将一个 Block 分配给 SM 时,发生了什么?
【第 0 步】GigaThread 做决策
├─ 决定:Block 3905 分配给 SM 0
└─ 创建 Block 描述符:{blockIdx = 3905, blockDim = 256, ...}
【第 1 步】SM 硬件初始化
Block 3905 分配到 SM 0 后,SM 的硬件会:
├─ 设置特殊寄存器:SR_CTAID.X = 3905
│ (这个值在 Block 执行期间保持不变)
│ (Block 内所有 Warp 都能读到 SR_CTAID.X = 3905)
│
└─ 为 Block 内的每个 Warp 分别设置 SR_TID.X
Warp 0(线程 0-31):
├─ 线程 0: SR_TID.X = 0
├─ 线程 1: SR_TID.X = 1
├─ ...
└─ 线程 31: SR_TID.X = 31
Warp 1(线程 32-63):
├─ 线程 32: SR_TID.X = 32
├─ 线程 33: SR_TID.X = 33
├─ ...
└─ 线程 63: SR_TID.X = 63
【关键洞察】
SR_CTAID.X 对整个 Block 相同
SR_TID.X 对每个线程不同
这就是为什么所有线程执行同样的代码,但计算出不同的 global_id
编译器如何处理索引变量
当 NVIDIA 的编译器(nvcc)编译 kernel 时,会发生什么?
cpp
// 源代码
int block_id = blockIdx.x;
int thread_id = threadIdx.x;
int global_id = block_id * block_dim + thread_id;
被编译成(PTX 伪汇编):
ld.u32 %r1, [%clock] // 示意:某条指令
mov.u32 %r2, %ctaid.x // blockIdx.x → 读 SR_CTAID.X → 存到通用寄存器 r2
mov.u32 %r3, %tid.x // threadIdx.x → 读 SR_TID.X → 存到通用寄存器 r3
mov.u32 %r4, 256 // blockDim.x → 直接用常数 256(编译时已知)
mul.u32 %r5, %r2, %r4 // r2 × 256 → 结果存 r5
add.u32 %r6, %r5, %r3 // r5 + r3 → global_id 存 r6
setp.ge.u32 %p0, %r6, %r7 // if (global_id >= n)...
关键观察:
1. blockIdx.x 不是"普通变量",而是直接从硬件寄存器读取
2. threadIdx.x 也是直接从硬件寄存器读取
3. 两次读取都只需 1-2 个 cycle(特殊寄存器访问非常快)
4. 乘法和加法也只需 2-3 个 cycle
总耗时:< 10 cycles
执行时的完整流程
现在看完整的执行时间线:
【时刻 T】Warp 0 开始执行 kernel(线程 0-31 并行执行同一段代码)
线程 0:
周期 1: 读 SR_CTAID.X (值 3905) → 通用寄存器 r2
周期 2: 读 SR_TID.X (值 0) → 通用寄存器 r3
周期 3: 乘法 3905 × 256 = 999680 → r5
周期 4: 加法 999680 + 0 = 999680 → global_id = 999680
线程 1:
周期 1: 读 SR_CTAID.X (值 3905) → 通用寄存器 r2
周期 2: 读 SR_TID.X (值 1) → 通用寄存器 r3
周期 3: 乘法 3905 × 256 = 999680 → r5
周期 4: 加法 999680 + 1 = 999681 → global_id = 999681
...
线程 31:
周期 1: 读 SR_CTAID.X (值 3905) → 通用寄存器 r2
周期 2: 读 SR_TID.X (值 31) → 通用寄存器 r3
周期 3: 乘法 3905 × 256 = 999680 → r5
周期 4: 加法 999680 + 31 = 999711 → global_id = 999711
【SIMT 的本质】
32 个线程执行相同的指令,但每个线程读到不同的 SR_TID.X
所以计算出不同的 global_id
所以可以并行处理不同的数据项
为什么这对性能很重要
发现 1:索引计算的成本极低
global_id = blockIdx.x * blockDim.x + threadIdx.x
所需 cycles: < 10
对比:一次全局内存访问 = 400 cycles
相对成本:< 2.5%
结论:无论你的 Block 有多大,索引计算的开销可以完全忽略不计
性能瓶颈永远不会是"索引计算"
发现 2:blockIdx 对整个 Block 相同,threadIdx 对每个线程不同
这有深刻的设计意义:
Block 级别的信息(blockIdx):
├─ 对所有 Warp 相同
├─ 存在一个共享的特殊寄存器中
├─ 支持 Block 级别的协作
Thread 级别的信息(threadIdx):
├─ 对每个线程不同
├─ 每个线程有私有的特殊寄存器
├─ 支持线程级别的差异化
这就是 CUDA 编程模型的硬件基础
发现 3:特殊寄存器是"免费的"
虽然每个 SM 有 65536 个寄存器需要被分配给线程,
但特殊寄存器(存储 blockIdx、threadIdx 等)不计入这个限制。
为什么?
├─ 因为特殊寄存器是硬件固定的,不需要编译器分配
├─ 它们在 Block 分配时由硬件自动初始化
└─ 所以不会加重寄存器压力
结论:
你可以放心地使用 blockIdx、threadIdx,完全不用担心寄存器占用
第二部分:GPU 分级内存的硬件映射与性能差异
内存层次的物理位置
当你在 kernel 中声明不同类型的变量时,它们在 GPU 硬件上的位置是完全不同的:
cpp
__global__ void kernel() {
// 【类型 1】寄存器变量
int local_var = 42;
// 【类型 2】共享内存
__shared__ float shared_data[256];
// 【类型 3】全局内存指针
float* global_ptr;
// 【类型 4】常量内存
// __constant__ float const_data[1024];
}
这些变量在硬件上的位置和访问方式完全不同:
┌──────────────────────────────────────────────────────────┐
│ GPU 物理位置 │
├──────────────────────────────────────────────────────────┤
│ │
│ 【SM 内部】 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 寄存器堆 (Register File) │ │
│ │ ├─ 容量:65536 × 32-bit │ │
│ │ ├─ 归属:线程私有 │ │
│ │ ├─ 延迟:1 cycle │ │
│ │ └─ 例:local_var = 42 │ │
│ │ │ │
│ │ 共享内存 (Shared Memory) │ │
│ │ ├─ 容量:96-100 KB │ │
│ │ ├─ 归属:Block 内所有线程共享 │ │
│ │ ├─ 延迟:~30 cycles │ │
│ │ └─ 例:__shared__ shared_data[256] │ │
│ │ │ │
│ │ L1 缓存 (L1 Cache) │ │
│ │ ├─ 容量:128 KB │ │
│ │ ├─ 工作方式:自动(开发者无法控制) │ │
│ │ ├─ 延迟:~30 cycles (命中) │ │
│ │ │ ~400 cycles (未命中) │ │
│ │ └─ 缓存的是全局内存访问 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 【GPU 全局(所有 SM 共享)】 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ L2 缓存 (L2 Cache) │ │
│ │ ├─ 容量:5.3 MB(RTX 3090) │ │
│ │ ├─ 工作方式:自动(所有 SM 共享) │ │
│ │ ├─ 延迟:~200 cycles │ │
│ │ └─ 缓存 L1 未命中的访问 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 【GPU 显存(GDDR6X)】 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 全局内存 (Global Memory) │ │
│ │ ├─ 容量:24 GB(RTX 3090) │ │
│ │ ├─ 延迟:~400 cycles │ │
│ │ ├─ 带宽:936 GB/s(理论峰值) │ │
│ │ └─ 例:global_data[global_id] │ │
│ │ │ │
│ │ 常量内存 (Constant Memory) │ │
│ │ ├─ 容量:64 KB │ │
│ │ ├─ 特点:只读,缓存良好 │ │
│ │ ├─ 延迟:~30 cycles(如果缓存命中) │ │
│ │ └─ 例:__constant__ float params[1024] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
每层的详细特性
【第 1 层】寄存器(Register File)
硬件特性:
├─ 位置:SM 内,与执行单元紧密相连
├─ 访问方式:直接从 ALU(算术逻辑单元)读写
├─ 延迟:1 cycle(几乎没有延迟)
├─ 容量:65536 个 32-bit 寄存器/SM = 256 KB/SM
└─ 所有权:每个线程私有,不能被其他线程访问
性能数据:
├─ 带宽:理论上无限(每个线程一个私有副本)
├─ 延迟-带宽积:1 cycle × 无限 = 接近 0(最优)
└─ 典型使用:局部变量、中间计算结果
代码示例:
int local_var = 42; // 分配到某个寄存器
local_var += 1; // 1 cycle 完成
实际编译后的指令:
add.u32 %r0, %r0, 1 // %r0 是通用寄存器
【第 2 层】共享内存(Shared Memory)
硬件特性:
├─ 位置:SM 内,与寄存器共用同一个存储结构(但不竞争访问端口)
├─ 容量:96-100 KB/SM
├─ 所有权:Block 内所有线程共享(不同 Block 的共享内存独立)
├─ 访问方式:通过内存单元,需要地址计算
├─ 同步方式:__syncthreads() 确保一致性
└─ 延迟:~30 cycles(比寄存器多 30 倍)
性能数据:
├─ 带宽:~2-3 TB/s
├─ 延迟-带宽积:30 × 256 byte = 可观的
└─ 适用场景:Block 内线程的数据交换
代码示例:
__shared__ float s_data[256];
s_data[threadIdx.x] = input[threadIdx.x]; // 写到共享内存
__syncthreads(); // 等待所有线程
float neighbor = s_data[(threadIdx.x + 1) % 256]; // 读其他线程的数据
【第 3 层】L1 缓存
硬件特性:
├─ 位置:SM 内
├─ 容量:128 KB/SM
├─ 工作方式:自动缓存全局内存访问(开发者无法直接控制)
├─ 所有权:SM 内所有 Block 共享
├─ 一致性:自动维护,但可能导致缓存一致性问题
└─ 行大小:128 bytes
延迟特性:
├─ 缓存命中:~30 cycles
├─ 缓存未命中(转到 L2):~200 cycles
└─ 合并访问的缓存命中率:通常 > 90%
代码示例:
float val = global_data[threadIdx.x]; // 第一次访问:未命中,从全局内存读
float val2 = global_data[threadIdx.x+1]; // 第二次访问:命中(同一缓存行)
【第 4 层】L2 缓存
硬件特性:
├─ 位置:GPU 全局(所有 SM 共享)
├─ 容量:5.3 MB(RTX 3090,A100 更大)
├─ 工作方式:自动缓存 L1 未命中的访问
├─ 所有权:所有 SM 共享
└─ 行大小:32 bytes
延迟特性:
├─ 缓存命中:~200 cycles
├─ 缓存未命中(转到全局内存):~400 cycles
└─ 命中率取决于数据的重用性
代码示例:
// 当多个 SM 的 Block 访问相同数据时,L2 缓存提供加速
float val = global_data[some_index]; // 可能从 L2 缓存读
【第 5 层】全局内存
硬件特性:
├─ 位置:GPU 显存(GDDR6X 芯片)
├─ 容量:大(24 GB on RTX 3090)
├─ 带宽:936 GB/s(理论峰值)
├─ 延迟:~400 cycles(取决于是否命中缓存)
└─ 访问方式:必须遵守合并访问规则
合并访问的规则:
├─ 一个 Warp(32 线程)的访问应该尽可能靠近
├─ 最优情况:线程 0-31 访问地址 0-127(一个缓存行)
│ → 一个内存请求搞定
├─ 差情况:线程 0-31 访问随机地址
│ → 32 个独立的内存请求
└─ 后者的成本是前者的 32 倍
代码示例:
// 好的访问模式(合并)
float val = global_data[threadIdx.x]; // 线程 i 读地址 i
// 差的访问模式(分散)
float val = global_data[threadIdx.x * 1000]; // 线程 i 读地址 i*1000
性能对比表:从快到慢
| 内存类型 | 延迟 | 带宽 | 容量 | 何时用 |
|---|---|---|---|---|
| 寄存器 | 1 cycle | 无限 | 256 KB/SM | 局部变量 |
| 共享内存 | 30 cycles | 2-3 TB/s | 100 KB/SM | Block 内通信 |
| L1 缓存 | 30 cycles | 1-2 TB/s | 128 KB/SM | 自动 |
| L2 缓存 | 200 cycles | 200 GB/s | 5.3 MB | 自动 |
| 全局内存 | 400 cycles | 936 GB/s | 24 GB | 大数据 |
延迟金字塔:为什么需要隐藏延迟
现在理解为什么 GPU 需要大量线程:
寄存器: 1 cycle
↓ (30 倍差距)
共享内存: 30 cycles
↓ (10 倍差距)
L1 缓存: 30 cycles
↓ (7 倍差距)
L2 缓存: 200 cycles
↓ (2 倍差距)
全局内存: 400 cycles ← 与寄存器相差 400 倍!
关键洞察:
当一个 Warp 发出全局内存读请求时:
├─ 需要等待 400 个 cycle 数据才能返回
├─ 在这 400 个 cycle 中,Warp 处于"等待"状态
├─ 如果没有其他 Warp 可以执行,SM 就会闲置
└─ 所以需要大量 Warp(>= 64)轮流执行
这正是第一篇讲的"用并发隐藏延迟"的硬件基础
访问延迟的实际示例
让我们看一个真实的数据访问场景:
【Warp 0 执行场景】
周期 0: 发射指令:float val = global_data[global_id];
请求发送到内存系统
Warp 0 状态 → "等待内存"
周期 1-399: Warp 0 处于等待
Warp Scheduler 检查 Warp 0:状态为"等待",跳过
Warp Scheduler 转向 Warp 1、2、3...
如果 Warp 1-63 中有就绪的,则执行它们的指令
周期 400: 全局内存返回数据
Warp 0 的状态 → "就绪"
下个周期 Warp Scheduler 可以选择 Warp 0
周期 401: Warp 0 被 Scheduler 选中,执行后续指令
例如:val = val * 2.0f;
周期 402: 下一条指令...
【如果没有其他 Warp】
假设只有 8 个 Warp(太少):
周期 0: 8 个 Warp 都发射内存读
周期 1-399: 所有 Warp 都在等待,Scheduler 无事可做,SM 闲置
周期 400: 数据返回,但此时已浪费 399 个周期的计算机会
【如果有 64 个 Warp】
周期 0: Warp 0-3 发射内存读
周期 1: Warp 4-7 发射内存读(Warp 0-3 开始等待)
周期 2: Warp 8-11 发射内存读(Warp 0-7 在等待)
...
周期 16: Warp 60-63 发射内存读(所有前面的 Warp 都在等待)
周期 17: 所有 64 个 Warp 都在等待...
周期 400: Warp 0 数据到达 → 转为"就绪"
周期 401: Warp 0 被选中执行 val = val * 2.0f;
周期 402: Warp 1 数据到达 → 转为"就绪"
周期 403: Warp 1 被选中执行 val = val * 2.0f;
...
这个过程中,Scheduler 始终有就绪的 Warp 可以执行
延迟被完全隐藏
第三部分:内存访问模式的性能影响
现在理解了内存层次,但还有一个关键问题:同样的访问延迟,为什么不同的访问模式性能差异巨大?
合并访问 vs 分散访问
合并访问的例子(最优)
cpp
__global__ void goodMemoryAccess(float* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= n) return;
// 线程 i 读地址 i
float val = data[idx];
}
执行流程:
Warp 0(线程 0-31)的访问:
线程 0: 读 data[0] → 地址 0x00000
线程 1: 读 data[1] → 地址 0x00004
...
线程 31: 读 data[31] → 地址 0x0007C
硬件看到的:
├─ 32 个地址都在连续的 128 字节范围内
├─ 正好落在一个 L1 缓存行中
└─ 硬件合并成一个内存请求
结果:
├─ 1 次内存请求 + 30 cycle (L1 命中)
├─ 或 1 次内存请求 + 200 cycle (L2 命中)
└─ 或 1 次内存请求 + 400 cycle (全局内存)
分散访问的例子(最差)
cpp
__global__ void badMemoryAccess(float* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= n) return;
// 线程 i 读地址 i*1000(分散分布)
float val = data[idx * 1000];
}
执行流程:
Warp 0(线程 0-31)的访问:
线程 0: 读 data[0] → 地址 0x000000
线程 1: 读 data[1000] → 地址 0x003E80
线程 2: 读 data[2000] → 地址 0x007D00
...
线程 31: 读 data[31000] → 地址 0x1E1A80
硬件看到的:
├─ 32 个地址分散在内存的各个角落
├─ 无法落在一个或两个缓存行中
├─ 必须发射多个独立的内存请求
结果:
├─ 32 次独立的内存请求(或最坏情况下分多个波次)
├─ 每次都可能产生 ~400 cycle 的延迟
└─ 总耗时:远远大于合并访问的 32 倍
带宽饱和的影响
虽然理论带宽很高(936 GB/s),但实际往往用不满:
合并访问:
32 个线程读 32 个 float(128 字节)
延迟:~400 cycle
带宽利用率:128 B / 400 cycle = 理论峰值的 > 80% ✅
分散访问:
32 个线程读 32 个 float(128 字节)
延迟:~400 cycle × 多个波次(可能 10-20 倍)
带宽利用率:128 B / (400 cycle × 15) = 理论峰值的 < 5% ❌
总结:硬件视角的核心认识
三个关键发现
发现 1:索引计算是"免费的"
blockIdx、threadIdx 由硬件特殊寄存器提供
索引计算成本 < 10 cycles
相对全局内存访问(400 cycles)可以忽略不计
发现 2:分级内存的目的是妥协
寄存器太小(256 KB/SM)无法存储所有数据
全局内存太大(24 GB)但太慢(400 cycles)
分级内存的设计:
├─ 快速小容量层(寄存器、L1)让频繁访问的数据快速返回
└─ 大容量慢层(全局内存)存储所有数据
只有理解了这个权衡,才能写出高性能代码
发现 3:缓存和带宽是双刀剑
L1/L2 缓存可以加速重复访问(缓存命中)
但随机访问或过度工作集会导致缓存完全无用
带宽虽然高,但需要合并访问才能利用
分散访问会浪费 95% 以上的潜在带宽
快速检查清单
- 理解 blockIdx、threadIdx 是硬件特殊寄存器,不是普通变量
- 知道特殊寄存器的访问不计入线程的寄存器占用
- 能列举 GPU 内存的 5 个层次及其延迟
- 理解为什么全局内存延迟是寄存器的 400 倍
- 明白为什么需要 64 个 Warp 来隐藏 400 cycle 的延迟
- 知道合并访问和分散访问的性能差异可达 32 倍
- 理解缓存命中对延迟的影响(30 cycle vs 400 cycle)
下一篇预告
第五篇将在本篇的基础上,讨论:**"基于这些内存特性,如何选择 Block 大小?计算型和 IO 型任务为什么需要不同的策略?"**以及如何用 Profiler 工具(Nsight Compute、nvprof)来验证你的优化效果。