两个很常见的性能优化场景背后其实是同一句话瓶颈往往不在算力而在数据搬运。一个发生在显存内部HBM ↔ 计算单元一个发生在 CPU 到 GPU 的传输路上。搞懂这一层很多优化了半天没效果的怪事就解释得通了。一、一个反直觉的现象把 LayerNorm 从 FP32 换成 FP16速度一点没涨先看一个真实会踩的坑。某个模型里 LayerNorm 占了不少时间于是有人想当然地优化既然 FP16 算力是 FP32 的两倍那把 LayerNorm 的计算精度从 FP32 换成 FP16是不是就能快一倍改完一测——速度纹丝不动。这不是 bug而是因为优化方向从一开始就错了。LayerNorm 的瓶颈根本不在算力上。要讲清楚这件事得先请出一个判断算子到底卡在哪的工具Roofline 模型。二、Roofline 模型先定位瓶颈再谈优化Roofline 模型用一张图回答一个问题一个算子的性能到底是被算力卡住还是被显存带宽卡住它的横轴是算术强度单位是 FLOPs/Byte意思是每从显存搬运 1 个字节能顺带做多少次浮点运算。纵轴是实际能达到的性能FLOPs/s。这张图被分成两个区域左侧的斜坡区Memory-bound带宽受限算术强度很低数据读进来没做几次运算就得写回去。这时性能 算术强度 × 峰值带宽性能被斜率也就是显存带宽牢牢摁住。右侧的平台区Compute-bound算力受限算术强度足够高数据复用得很充分。这时性能顶到峰值算力的天花板被算力卡住。两个区域的交界处叫拐点。一个算子落在拐点左边还是右边决定了你该往哪个方向使劲。用大白话比喻就是一个厨房算力是切菜厨师的手速显存带宽是食材从仓库运到灶台的传送带速度算术强度是每运来一筐菜厨师要在灶台上忙活多久。如果厨师每筐菜要颠勺炒十分钟算术强度高那瓶颈在厨师手速给他换个手快的提算力确实有用——这是 compute-bound。 如果厨师拿到一筐菜两秒钟就处理完、然后干等下一筐算术强度低那瓶颈在传送带这时候你把厨师换成动作快一倍的菜上得也不会更快——这就是 memory-bound。优化哲学先用 Roofline 判断每个 kernel 是 compute-bound 还是 memory-bound然后 对症下药 ——compute-bound 的用 FP8 增大算力memory-bound 的用融合减少访存。三、为什么 LayerNorm 是 Memory-bound把 LayerNorm 拆开看一眼就明白了它对每个元素做的事无非是读进来、参与求均值和方差、减均值除标准差、再乘 γ 加 β然后写回去。每个元素摊到的浮点运算就那么十来次但读和写的字节数实打实地少不了。算下来它的算术强度FLOPs/Byte非常低稳稳地落在 Roofline 图的左侧斜坡区。作为对比矩阵乘法GEMM因为数据复用率极高算术强度能到几百妥妥落在右侧平台区——这才是该上 FP16/Tensor Core 的地方。现在回到开头那个坑把 LayerNorm 的计算从 FP32 换成 FP16本质上只是把算力天花板抬高了一截可它既没改变峰值带宽也没改变算术强度。你人还卡在斜坡上呢天花板抬得再高也够不着。一句话对 memory-bound 的算子提算力等于给一个被传送带卡住的厨房换了个手更快的厨师——白搭。四、那 FP16 到底怎么用才对—— 让搬运的字节数减半FP16 不是没用而是用错了地方。对 memory-bound 算子正确的用法不是算得更快而是搬得更少。具体做法是把显存里存的数据格式本身存成 FP16。这样每次读写,传输的字节数直接砍掉一半。为什么这就真能提速因为对一个 memory-bound 的算子来说它的运行时间约等于搬运的总字节数 ÷ 峰值带宽。字节数减半时间也大致减半实际中还有归约、γ/β、固定的 kernel 启动开销所以是接近减半而非精确两倍。换个角度看分母字节减半算术强度直接翻倍——这才是把算子往拐点方向推。关键区别一定要记牢FP16 提速的来源不是算得快而是过总线的字节少了一半。计算精度对 memory-bound 算子几乎无所谓传输数据量才是命门。五、更治本的办法算子融合Kernel Fusion减少字节数还有一招更狠的——算子融合。一个没融合的网络算子之间是这么干活的LayerNorm 把结果写回显存HBM下一个算子比如加 Bias、过激活函数再把它从显存读回来。每一次写回去 读回来都是一趟昂贵的显存往返。对 memory-bound 的算子来说这种中间结果的反复进出就是时间的大头。融合的思路是把 LayerNorm 和它前后的操作残差相加、加 Bias、激活函数等合并成一个 Kernel让中间结果待在寄存器或片上 SRAM 里根本不落地到 HBM。少一趟显存往返就省下一笔带宽。举个直观的账原本残差相加 → LayerNorm → Dropout三个独立算子中间结果要在 HBM 上来回好几趟融合成一个算子后数据读进来在片上一口气算完再写出去HBM 流量可能直接降到原来的三分之一。对带宽受限的算子HBM 流量降多少时间基本就降多少。这也正是 FlashAttention 这类工作快的根本原因——它把整个 attention 的计算融进一个 Kernel避免了巨大的中间矩阵在 HBM 上反复进出。本质上还是那句话省的不是算力是访存。六、把镜头拉远CPU → GPU 的搬运藏着一模一样的坑上面讲的是显存内部的内存墙。它有个孪生兄弟藏在更外层——CPU 到 GPU 的数据通路上。同样的道理换个楼层重演一遍。场景是这样的训练一个图像模型GPU 利用率只有 60%。打开 Nsight Systemsnsys 看 timeline发现 GPU每算完一个 batch就空闲一小段然后才接着算下一个。num_workers已经开到 8prefetch_factor2也加了GPU 还是有空闲。再看代码发现有人写了这么一段并坚称这样能让数据搬运异步进行for images, labels in dataloader: # 以为 non_blockingTrue 就异步了 images images.cuda(non_blockingTrue) labels labels.cuda(non_blockingTrue) outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step()non_blockingTrue加了那段空闲却没消失。问题出在哪答案是漏了一个绝对的先决条件。七、non_blockingTrue的隐藏前提源数据必须是 Pinned Memory要讲清楚得先分清两种 CPU 内存可分页内存普通申请的内存操作系统可以随时把它换页到磁盘。CPU 张量默认就在这儿。页锁定内存被钉死在物理内存里、不会被换页的内存。GPU 的异步拷贝cudaMemcpyAsync靠的是DMA——绕过 CPU让硬件直接把数据从内存搬到显存。而 DMA 有个硬性要求源地址必须是页锁定的因为只有钉死的物理地址硬件才敢放心地直接搬。所以如果你的源张量在可分页内存里会发生什么CUDA 驱动没法直接 DMA它只能先把数据同步地拷到一块内部的 pinned 暂存缓冲区再从那儿往显存搬。这个先同步拷一次的动作会阻塞住 CPU。结果就是你的non_blockingTrue等于白写了——传输实际上还是同步的这个标志被悄悄忽略掉。CPU 卡在拷贝上 → 拷完才去排下一步的计算 → GPU 算完当前 batch 只能干等下一批数据。那段空闲就是这么来的。修复只要一行——在 DataLoader 里打开pin_memorydataloader DataLoader( dataset, batch_size..., num_workers8, prefetch_factor2, pin_memoryTrue, # ← 关键的一行 )打开之后DataLoader通过专门的 pin_memory 线程会提前把取好的 batch 拷进页锁定内存。这下images.cuda(non_blockingTrue)才能走真正的异步 DMA拷贝本身更快——pinned DMA 不用经过同步暂存带宽通常能到可分页拷贝的近两倍CPU 不再被拷贝阻塞——non_blocking调用立刻返回CPU 得以一路往前跑把后面的前向、反向 kernel 源源不断地塞进 GPU 的命令队列。CPU 跑在前面、队列里始终有活GPU 就不会饿着——那段空闲随之消失。八、再进一步prefetch 与双流把拷贝彻底藏进计算里pin_memoryTruenon_blockingTrue是消除空闲的关键修复对绝大多数情况已经够用。如果还想把吞吐压到极致可以再往前一步用一个手写的 prefetcher在一条独立的 CUDA Stream 上提前发起下一个 batch的 H2D 拷贝让它和当前 batch的计算真正并行重叠——计算在跑的同时下一批数据已经在悄悄搬运拷贝延迟被完全藏进计算里。NVIDIA 的data_prefetcher写法、以及 DALI 这类数据加载库做的就是这件事。但顺序别搞反先用pin_memoryTrue把地基打好让non_blocking真正生效双流 overlap 是锦上添花的进阶项。总结一切优化从定位瓶颈开始回头看这两个场景会发现它们是同一个故事的两个版本现象容易误判的病因真正的瓶颈正确解法LayerNorm 换 FP16 不变快算力没拉满显存带宽memory-bound算子融合 / 用 FP16存储减少搬运字节GPU 算完一个 batch 就空闲num_workers不够多CPU→GPU 搬运 同步阻塞pin_memoryTrue让non_blocking真正生效两个坑的共同点都是误把算力/并行度不够当成病因实际上瓶颈在数据搬运。所以做性能优化养成一个习惯动手改之前先用 Roofline 模型或者 Nsight 的 timeline把瓶颈定准——这个算子是被算力卡住还是被带宽卡住数据是没喂上来还是计算本身慢先看清楚卡在哪再决定往哪使劲。不然就像那个换了手更快的厨师——忙活半天菜还是上得一样慢。