干GPU这行时间长了,你会注意到一个特别有意思的现象:显存容量是8GB、16GB、32GB,线程块大小是128、256、512,FFT长度是1024、2048,模型里的hidden size是512、1024、4096。一开始我以为这只是大家约定俗成,直到有一次我把推理服务的batch size从15调到16,延迟反而下降了近10%,这才意识到2的幂次在GPU世界里不是巧合,而是从硬件设计、驱动实现到框架分配层层埋下的“底层协议”。这篇文章就把这个协议从头到尾拆一遍,讲清楚GPU为什么偏爱2的幂次,以及我们在实际写代码、调模型、排显存问题的时候,怎么利用这个规律。
这篇内容适合几类人看:刚接触CUDA编程、对block size和显存对齐一头雾水的开发者;训练或推理时遇到显存占用异常、性能反直觉下降的人;还有单纯好奇GPU内部设计逻辑的硬件爱好者。我会从硬件寻址讲到线程调度,再讲到分配器和算法库,最后给出一份可以直接照做的避坑清单。
1. 从硬件寻址到显存容量:2的幂次是硬件的“本能”
1.1 地址线和“省钱”的硬件逻辑
要理解GPU为什么偏爱2的幂次,得先从最底层的寻址说起。处理器访问内存靠的是地址总线,一根地址线只能表示0或1两个状态,n根地址线就能组合出2的n次方个不同地址。比如32根地址线,寻址空间就是2^32 = 4GB。这个“2的幂次”不是谁规定的,是二进制本质决定的。
那如果硬件想支持一个不是2的幂次的寻址空间,比如3GB,会发生什么?地址解码器需要额外加比较逻辑,判断地址是否落在合法范围内,超出部分要触发异常。这些额外逻辑需要更多的晶体管、更大的芯片面积,还会让译码路径变长、延迟变高。硬件工程师做芯片设计时,第一原则就是“能省则省”,能用简单译码器解决的事情,绝不加比较器。所以在芯片设计里,存储容量、寄存器数量、队列深度,凡是和“数量”相关的硬件资源,几乎清一色是2的幂次。
这个规律不止GPU有,CPU的缓存容量、内存条容量、甚至SSD的闪存块大小,底层都是2的幂次。只不过GPU因为并行度极高、对带宽极度敏感,把这个倾向放大到了极致。
1.2 显存容量是怎么算出来的
你看市面上的显卡显存规格:8GB、12GB、16GB、24GB、32GB、48GB、80GB。8和16和32是2的幂次,但12、24、48不是。那是不是说2的幂次规律被打破了?其实不是,得看显存容量是怎么组成的。
显存容量的计算公式很简单:单颗显存颗粒容量 × 颗粒数量。但颗粒数量本身又由显存位宽决定。NVIDIA显卡显存位宽常见的有128-bit、192-bit、256-bit、384-bit、512-bit,而单颗GDDR6/GDDR6X颗粒的接口位数是32-bit。所以:
- 128-bit位宽 = 4颗颗粒,配2GB单颗 = 8GB
- 192-bit位宽 = 6颗颗粒,配2GB单颗 = 12GB
- 384-bit位宽 = 12颗颗粒,配2GB单颗 = 24GB
这里的关键在于:单颗显存颗粒本身的容量是严格的2的幂次。2GB = 16Gb = 2^34 bit,颗粒内部的bank数量、row/column地址空间,全都是2的幂次。而总线位宽192-bit、384-bit这些不是2的幂次,是因为它要兼顾成本和带宽,用更少的颗粒达到更高的位宽。所以“24GB不是2的幂次”这个表面对,底层每一颗颗粒仍然严格遵循2的幂次。
如果你去看GPU的L2缓存容量,比如A100是40MB,H100是50MB,也不是2的幂次。这是因为L2缓存切成了很多个slice,每个slice有自己的bank和地址交织逻辑,最终容量会做一些非2的幂次的取舍。但这属于架构层面的特例,不影响“存储阵列内部结构是2的幂次”这个基本事实。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CUDA线程块里的2的幂次:从warp到block size的选择
2.1 warp=32,硬件为什么要用32个线程一组
写CUDA kernel的都知道,GPU调度的最小单位不是单个线程,而是warp,一个warp包含32个线程。32这个数字是硬件拍板的,不是软件定的。GPU内部是一个SIMT(单指令多线程)架构,一个指令发射后,会同时驱动一个warp里的32个线程执行。32这个数字的选取,和寄存器堆的读写端口数、指令发射队列深度、ALU(算术逻辑单元)阵列宽度都有关系,最终工程师在面积、功耗、频率之间取了一个平衡点。
正是因为硬件以32为单位调度,所以block size设置为32的倍数就特别重要。假设一个thread block有100个线程,硬件会把它分成3.125个warp,实际上就是4个warp,但最后一个warp只激活了4个线程,剩下28个线程位置空转。这不仅浪费了调度槽位,还可能让整个block占用的warp数量少于应有的workload,降低occupancy。
我在实际写kernel时踩过这个坑。之前有个边缘检测的kernel,block size设成100,自认为“刚好能覆盖100行图像”,结果性能比256的版本差了差不多两倍。后来把block size改成128,性能立刻上来了。从那以后我写CUDA代码,block size一律从32的倍数里选,最常用的就是128和256。
2.2 block size到底该选多大:128、256还是512
这是一个特别经典的问题。答案是“看kernel”,但可以从两个维度快速估算。
第一个维度是寄存器占用。每个SM(流式多处理器)的寄存器文件是固定的,比如Ampere架构的SM有65536个32-bit寄存器。如果一个kernel每个线程用32个寄存器,那一个SM最多容纳2048个线程(65536 / 32 = 2048)。如果你的block size是256,那一个SM能放8个block。如果每个线程用64个寄存器,SM最多容纳1024个线程,block size是256时只能放4个block。这时候调小block size到128,能放的block数量变成8个,occupancy反而更高。
第二个维度是线程数上限。CUDA规定一个block最多1024个线程,一个SM最多2048个线程。在设计block size时,可以先算一下目标occupancy:occupancy = 实际活跃warp数 / SM最大warp数。比如SM最大warp数是64(2048/32),如果你一个block是256线程(8个warp),occupancy = active warps / 64。这里需要考虑寄存器、共享内存会不会卡住,最靠谱的办法是直接用CUDA提供的API算:
cpp复制int numBlocks;
int blockSize;
cudaOccupancyMaxPotentialBlockSize(&numBlocks, &blockSize, myKernel, 0, 0);
这个函数会根据kernel的寄存器用量、共享内存用量,自动算出能达到最大occupancy的block size。实测下来它给出的结果通常不是玄学,是硬件资源约束下的最优解。
个人经验:如果kernel逻辑简单、寄存器占用低,256或512都可以,256通常是稳妥之选。如果kernel复杂、每个线程要存很多中间变量,128往往比256更好地平衡occupancy。如果涉及shared memory,还需要结合下文的bank conflict一起考虑。
2.3 共享内存bank conflict:2的幂次也有翻车的时候
说到shared memory,2的幂次就不总是“好朋友”了。GPU的shared memory被分成32个bank,每个bank每周期可以读取4字节。一个warp里的32个线程同时访问shared memory时,如果它们访问的地址落在不同的bank,硬件可以一个周期内完成。但如果多个线程访问同一个bank的地址,就会产生bank conflict,硬件不得不把访问拆成多个周期串行处理,性能直接打折。
最经典的翻车场景是矩阵转置。假设一个32x32的浮点矩阵存在shared memory里,数组声明为float tile[32][32]。在线程读取tile[tid][col]或者写入tile[col][tid]时,你可能会发现某些访问模式下,整个warp的线程都在访问同一个bank。因为tile[tid][col]的地址等于tid * 32 + col,而shared memory按地址模32映射到bank,tid * 32这个偏移量对32取模永远是0,所有线程都在同一时刻撞到同一个bank上。
解决办法也很经典:把数组声明成float tile[32][33],即每行多padding一个元素。这样tile[tid][col]的地址变成tid * 33 + col,对32取模后,相邻线程分布在不同的bank上,冲突就解除了。
这个例子说明了什么?2的幂次在硬件资源量上是“默认配置”,但当你在编程中主动使用2的幂次作为访问步长时,可能会正好踩中硬件bank的排列周期。所以在shared memory编程里,反而要刻意避开32的倍数步长,用33、35这种“非2的幂次”做padding。这个点我会在第5章的速查表里单独列出来。
3. 显存分配、内存对齐与缓存行:一层一层的对齐洁癖
3.1 cudaMalloc的256字节对齐规则
除了线程调度,显存分配也充满了对齐要求。CUDA的cudaMalloc返回的指针,地址至少是256字节对齐的。这意味着如果你分配了100字节,驱动实际给你的是一个更大的、起始地址对齐到256字节边界的内存块。这背后的原因主要是:
第一,现代GPU访问显存时,缓存行(cache line)是固定大小的,比如A100的L2缓存行是128字节。如果数据起始地址不对齐,一个数据可能需要跨两个缓存行加载,多一次访存。第二,GPU的向量化访存指令(比如float4一次读16字节)要求地址16字节对齐,否则会触发异常。256字节对齐等于给所有后续操作留足了余量。
实际编程中,很多人不会直接用cudaMalloc,而是用PyTorch、CUDA Graph等框架。但这些框架底层依然保留了对齐逻辑。比如你在PyTorch里创建一个很小的tensor,nvidia-smi看显存占用,可能发现显存涨了几百MB——那是CUDA context初始化占用的,和tensor本身无关。但如果反复创建大量小tensor,每次分配都会被向上取整到某个对齐边界,显存碎片就会越来越严重。
注意:cudaMalloc本身是有固定开销的,不要在循环里频繁调用。写CUDA代码时建议用内存池,或者一次性分配大块显存后自己切分,否则仅仅分配释放就能拖慢整体速度。
3.2 PyTorch缓存分配器的“向上取整”逻辑
PyTorch的内存管理用的是caching allocator,它的核心思路是:分配过的显存不会立刻还给驱动,而是缓存起来供后续复用。这套机制里有一个特别关键的设计——bucket。PyTorch把分配请求按照2的幂次分成不同的桶,比如512B、1KB、2KB、4KB,一直到1MB、2MB、4MB……当你请求一个1000字节的tensor时,分配器会找一个足够大的空闲block给它,而这个block很可能是从2KB的桶里出来的。
这就导致了一个现象:你创建了一堆大小各异的tensor,最终reserved显存远大于所有tensor size加起来。因为每次分配都被“向上取整”到了最近的2的幂次桶。如果你创建大量不均匀的小tensor,碎片会非常严重。
应对办法有几个。一个是尽量把数据打包成规整的shape,比如把多个小tensor合并成一个tensor。另一个是显式控制分配器的行为,设置环境变量:
bash复制PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
这个参数限制了大块内存被切分的阈值,可以减少碎片块的产生。实测在训练大模型时,如果遇到OOM后重试成功但显存利用率很低的情况,调一下max_split_size_mb往往有奇效。
还有一个小技巧:排查显存问题时,用torch.cuda.memory_reserved()看的是分配器真正从驱动那儿拿到的显存,torch.cuda.memory_allocated()看的是实际tensor占用的显存。两者之差就是当前缓存中空闲但未归还的显存。如果差很多,说明你的分配模式产生了大量碎片。
3.3 缓存行、纹理和mipmap:图像处理里的2的幂次
再往上层走,图像处理和纹理单元也对2的幂次情有独钟。GPU的纹理硬件从诞生起就内置了对mipmap的支持。mipmap是啥?就是预先给纹理生成一系列逐渐缩小分辨率的版本,比如512x512、256x256、128x128……一直缩到1x1。渲染时GPU根据物体距离,自动选择合适的分辨率级别,避免远处物体出现闪烁噪点。
这个金字塔结构天然就是2的幂次递减。如果纹理尺寸是512x512,mipmap每一级都是整数缩放,处理起来非常干净。如果纹理是500x500,mipmap生成时就要做非整数比例的缩放,比如500x500要缩到250x250还是249x249?怎么对齐?边界怎么处理?都会引入额外计算和采样偏差。
所以工业界做纹理资源时,标准做法是把图片pad or resize到2的幂次尺寸,常见的如256x256、512x512、1024x1024。这不只是个人偏好,贴图压缩算法(如BC格式)的block大小也是4x4或8x8这种2的幂次,非对齐纹理在压缩、采样、硬件加速上都会吃亏。
缓存行对齐同样会影响访存效率。GPU的L2缓存行一般是128字节,如果你连续访问的内存地址能够按128字节对齐,每次缓存行填充都能装满有效数据,访存效率就高。反之,如果地址在128字节边界上跳来跳去,每次取数据都要拉两个缓存行,带宽利用率大幅下降。这也是为什么很多高性能kernel在写数据时,会故意把每行数据padding到某个对齐长度,而不是严格按逻辑宽度存储。
4. 算法和框架层面:cuDNN、FFT、卷积中的2的幂次
4.1 cuDNN为什么对2的幂次“友好”
训练深度学习模型时,绝大多数卷积运算由cuDNN负责。cuDNN内部有好多卷积算法,常见的包括implicit GEMM(把卷积转成矩阵乘法)、Winograd、FFT等。算法选择不是固定的,cuDNN会根据输入shape、显存大小、硬件架构,甚至当前显存碎片情况,选一个它认为最快的算法。
但2的幂次维度会显著扩大算法选择空间。以implicit GEMM为例,它要把卷积操作重组成大矩阵乘法,矩阵的M、N、K维度如果能被tile size整除(比如tile是16x16),计算时就不用处理边界,所有计算单元都在干有效活。如果维度不是2的幂次,矩阵乘法kernel的每个tile都可能踩到边界条件,编译器要插入分支判断,GPU的SIMT架构最怕分支分歧,性能立即掉档。
这也是为什么我建议在训练CNN时,输入图片尺寸尽量选64、128、224、256这种2的幂次或2的幂次加padding的数值。224是ImageNet标准尺寸,但它其实是256 crop出来的,背后的设计逻辑就是让卷积feature map在大多数层里保持可整除的结构。如果换成225x225,每一层卷积的padding和stride计算都会变得别扭,cuDNN能选的高效算法路径也会变少。
4.2 FFT、矩阵乘法、Transformer设计里的2的幂次
FFT(快速傅里叶变换)是另一个重度依赖2的幂次的领域。经典FFT算法用的是radix-2分治,它要求信号长度是2的幂次。如果你的数据长度是1024,FFT可以直接做10级蝶形运算,每一级都规规整整。如果长度是1000,FFT库通常要把长度拆分成2^3 × 5^3,使用混合基算法,或者在两端补零到1024再算,这些都会增加计算量。
实际使用中,如果你做频谱分析、卷积加速、音频处理,尽量把数据长度pad到2的幂次。有些库(比如PyTorch的torch.fft)会帮你自动处理非2的幂次的情况,但生成的规划器(plan)有可能更慢,因为底层要么补零,要么用Bluestein算法转换,多绕了一圈。
矩阵乘法也有类似规律。GPU上的矩阵乘法kernel普遍采用分片(tiling)策略,tile大小通常是16x16、32x32、64x64,这些都是2的幂次。如果矩阵的M、N、K维度能整除tile大小,内层循环就不需要边界判断,可以直接用向量化指令搬数据。我在自己写GEMM kernel时做过对比,同样规模下,把矩阵维度从1000x1000改成1024x1024,性能提升大概有15%到20%。这个提升完全来自simpler control flow和更好的对齐访存。
Transformer架构里也处处是2的幂次。ViT的patch size是16x16,Attention的head_dim是64或128,FFN的中间层维度通常是hidden_size的4倍,而这些hidden_size又常常是512、768、1024、4096。768不是2的幂次,但它是3×256,能被128整除,依然保持了很好的向量化对齐性质。Google选择768估计是精度和模型大小的折中,但它能保持维度上对tile size的友好性。
4.3 什么时候可以不用2的幂次
讲了这么多2的幂次的好处,也得说说什么情况下不用刻意凑2的幂次。
第一个例外就是前面提到的shared memory bank conflict。在shared memory里,访问步长如果恰好是2的幂次且大于等于32,就会踩中bank冲突。这种场景反而要用非2的幂次做padding。
第二个例外是卷积padding。给feature map加padding时,不要为了凑2的幂次而加一大堆无意义的边界。padding本身是为了保持空间尺寸,你只需要让卷积输出尺寸能整除tile size,或者干脆接受边界分支的存在。强行凑2的幂次可能增加大量无效计算,得不偿失。
第三个例外是batch size。虽然2的幂次batch size通常表现不错,但它不是绝对的。有些数据集最后一个batch很小,补齐到2的幂次会浪费显存。更关键的是,当你有多个GPU做分布式训练时,batch size必须能被GPU数量和梯度累积步数整除。这时候2的幂次不一定能同时满足所有约束,算法上满足约束优先级更高,性能损失可以接受。
我的建议是:设计阶段优先考虑2的幂次,但现实约束优先。硬件偏好应该被利用,但不要被它绑架。
5. 常见问题与排查技巧实录
5.1 显存预测总比模型文件大很多
很多人问:模型文件才2GB,为什么加载到GPU后显存占用变成6GB甚至更多?这里面有几层原因叠加。
第一层是CUDA context占用。任何进程第一次使用GPU,驱动都要初始化上下文,包括命令队列、内存映射、错误处理等,这部分通常要占200MB到600MB显存,16GB的卡尤其明显。第二层是框架缓存分配器向上取整,PyTorch分配显存是按2的幂次桶分配的,还有block内的对齐填充。第三层是推理时的中间激活值,推理虽然没有反向传播,但Transformer的KV cache会随序列长度线性增长,这部分往往远超模型权重本身。
排查方法:先用空模型跑一次,记录baseline显存占用(context开销)。再加载真实模型,看增量。最后跑一次真实推理,看KV cache和激活值的变化。三步一减,就知道显存到底花在哪里了。
实操时用这两个API:
python复制import torch
# 运行一次空模型,记录baseline
print(torch.cuda.memory_allocated())
print(torch.cuda.memory_reserved())
如果reserved和allocated差距过大,说明碎片严重,按上面说的方法调PYTORCH_CUDA_ALLOC_CONF。
5.2 为什么在WSL里程序报NVML初始化失败
热词里有人遇到WSL下报“failed to initialize nvml: GPU access blocked by the operating system”。这类问题虽然和2的幂次没有直接关系,但它在GPU环境搭建里太常见了,一起说下。
这个报错通常意味着Windows侧NVIDIA驱动没有正确支持WSL的GPU透传。解决办法是:在Windows里安装最新的NVIDIA驱动(包含WSL支持),然后在WSL里安装CUDA toolkit,但不要在WSL里单独安装显卡驱动。安装完在WSL里跑nvidia-smi,能看到显卡信息就说明透传成功了。
这类环境问题和显存分配方式是两回事,但排查时有一个通用技巧:先确认环境和驱动,再怀疑自己的代码。不要一上来就调kernel,先跑一个最简单的cudaMemcpy或者torch.cuda.is_available(),确认基础环境没问题,再定位上层问题。
5.3 一张速查表:什么时候该用2的幂次,什么时候避开
我整理了一份自己平时参照的速查表,按场景分类,方便直接抄作业。
| 场景 | 推荐做法 | 原因 | 备注 |
|---|---|---|---|
| CUDA block size | 32的倍数,常用128/256 | warp对齐,避免部分warp空转 | 复杂kernel先用cudaOccupancyMaxPotentialBlockSize算 |
| shared memory数组 | 行数/列数不要都是32的倍数 | 避免bank conflict | 矩阵转置用[N][N+1] padding |
| tensor shape | 对齐到16/64/256 | 向量化访存和缓存行友好 | 以需求为先,能对齐就对齐 |
| 显存预估 | 按2的幂次向上取整预留 | 分配器bucket向上取整 | 预留时再加context开销 |
| 纹理尺寸 | 使用256/512/1024等2的幂次 | mipmap和贴图压缩友好 | 游戏资源制作的标准做法 |
| FFT长度 | 优先2的幂次 | radix-2算法最高效 | 非2的幂次库也能算,但会慢 |
| 卷积padding | 按需要padding,不硬凑 | 硬凑可能增加无效计算 | 需要整除tile size时再对齐 |
| 共享内存访问步长 | 避开32的倍数 | 防止bank冲突 | 这个场景2的幂次是坑 |
5.4 实操里最值得养成的几个习惯
最后分享几个我踩了无数坑之后养成的习惯。
第一个,写任何CUDA kernel,先写一个block=256、grid用grid-stride loop的版本,跑通功能后再考虑调优。不要一上来就用512或1024,也不要根据“感觉”选block size,用API算。grid-stride loop的意思是让grid里的线程循环处理多个元素,这样block size和grid size可以解耦,不影响正确性。
第二个,多进程或多卡训练时,先用一个空进程跑一下,确认每张卡的CUDA context开销。不然你很可能以为自己显存算得很准,结果一上多进程就OOM,因为每张卡都额外付出了一份context内存。
第三个,凡遇到性能反直觉下降或者显存占用异常,先检查维度是不是2的幂次,再检查shared memory有没有bank conflict。这两个问题都非常隐蔽,但定位起来也不难。维度问题可以临时把输入pad到2的幂次对比测试;bank conflict可以用CUDA自带的Nsight Compute看shared memory冲突计数,一眼就知道。
最后再分享一个小技巧。在PyTorch里,如果你有一个动态shape的模型,每次输入size都不同,caching allocator的碎片会越来越严重。可以尝试把输入先pad到一个固定的大尺寸(比如batch size pad到32、序列长度pad到128的倍数),模型内部处理真实长度,padding部分用mask忽略掉。虽然理论计算量略增,但实际因为减少了分配碎片和分支分歧,整体反而更快。这个方法在多个NLP推理服务上实测过,收益非常稳定。
我个人在实际操作中的体会是:GPU的“2的幂次偏好”不是哪个人拍的规则,而是从硅片上的寄存器堆、内存bank、缓存行、调度器一路长出来的性征。我们做软件的人,顺着这个性征写代码,就能少踩坑、多榨出性能。下次你看到nvidia-smi里那个8.00GB、16.00GB,或者kernel里那串128、256,不妨多想一下——那不是巧合,是硬件在你耳边说的悄悄话。
