寄存器数量:编译器决定的,不是你自己分配的
你写代码时只声明变量,编译器自动决定每个线程用多少个寄存器。比如你写:
cuda
float sum = 0.0f; // 编译器分配 1 个寄存器
float regA[4]; // 编译器分配 4 个寄存器
float threadResults[4][4]; // 编译器分配 16 个寄存器
你在代码里写多少局部变量,编译器就分配多少寄存器(假设放得下)。你不能 像 cudaMalloc 那样手动申请一块寄存器。
但你可以影响编译器的决策,有三种方式:
方式一:__launch_bounds__(最常用)
cuda
// 告诉编译器:每个 block 最多 256 线程,每个 SM 至少跑 2 个 block
__global__ __launch_bounds__(256, 2)
void myKernel(...) { ... }
这会引导编译器优化寄存器分配------minBlocksPerMultiprocessor=2 意味着编译器知道每 SM 至少要容纳 2 个 block,会控制每线程的寄存器数量别太多,以保留 occupancy。
方式二:编译选项 -maxrregcount
bash
nvcc -O3 -maxrregcount=64 my_kernel.cu
强制限制每线程最多用 64 个寄存器。超出的变量会被溢出到 Local Memory(慢!)。
方式三:什么都不写(默认行为)
编译器自己权衡寄存器使用量和 occupancy,通常效果不错。
关键约束
不管编译器怎么分配,都有硬上限:
| 约束 | 值 |
|---|---|
| 每线程最多寄存器数 | 255 个(几乎所有架构) |
| 每 SM 总寄存器数 | A100: 65536,H100: 65536 |
| 寄存器分配粒度 | 2 的倍数或 4 的倍数(不能分配奇数个) |
如果变量太多超过 255 个寄存器,多余的会被 spill 到 Local Memory------名字叫 Local,实际是 Global Memory 的一部分,非常慢(~300 cycles)。这是要避免的。
每个寄存器存多少?
一个寄存器是 32 bit,所以:
| 数据类型 | 占几个寄存器 |
|---|---|
float(32 bit) |
1 个 |
int(32 bit) |
1 个 |
half(16 bit) |
1 个(浪费了一半,但最小单位就是 1 个寄存器) |
double(64 bit) |
2 个 |
float *(指针,64 bit) |
2 个 |
所以 float regA[4] 就是 4 个寄存器,每个存 1 个 float。float threadResults[4][4] 就是 16 个寄存器。
寄存器不能存多个元素 ------即使 half 只有 16 bit,也独占一个 32 bit 寄存器。这也是为什么 AI Infra 领域很多量化工作(FP16→INT8)不仅省显存,在 kernel 层面也有寄存器效率的收益。
回到 Register Tiling 的上下文
我们的 kernel 里每线程用了多少寄存器?
cuda
float threadResults[4][4]; // 16 个寄存器(累积结果)
float regA[4]; // 4 个寄存器(A 的一行)
float regB[4]; // 4 个寄存器(B 的一列)
float sum (循环变量等); // ~4-6 个寄存器(索引、临时值)
// 合计约 28-30 个寄存器
30 个寄存器,远低于 255 的上限。对于 RTX 5060 Ti(假设每 SM 65536 个寄存器),每线程 30 个寄存器 → 每 SM 可以容纳 65536/30 ≈ 2184 个线程。而 SM 最大线程数是 2048,所以寄存器不是瓶颈------瓶颈在别的地方(比如 Shared Memory 容量)。
这就是为什么理解寄存器分配很重要:它决定了你的 SM 能同时跑多少线程(occupancy),进而影响 GPU 隐藏内存延迟的能力。