第二章:硬件基础与计算模型——CPU/GPU/NPU架构差异、内存层次结构、计算与访存比
各位同学,咱们今天聊点硬核的。算子优化这事儿,说白了就是跟硬件打交道。你如果不了解CPU、GPU、NPU这三兄弟的脾气秉性,那优化起来就像闭着眼睛开车——能跑,但迟早要翻。
我个人习惯,在动手写任何算子之前,先花半小时把目标硬件的架构图翻出来看看。别嫌麻烦,这半小时能帮你省下后面几天的调试时间。好,咱们开始。
2.1 CPU:通用但“娇气”的调度大师
CPU的设计哲学是“什么都能干,但一次干好一件”。它的核心数不多,但每个核心都配备了完整的控制单元、缓存和强大的分支预测能力。
架构特点:
- 控制单元占比高:大约40%的芯片面积用于控制逻辑和缓存。为什么?因为它要处理复杂的指令流、分支跳转、乱序执行。
- 缓存层次深:L1(32KB左右)、L2(256KB-1MB)、L3(8MB-30MB)。每一级延迟差异巨大,L1访问只要1ns,L3可能要10-20ns。
- SIMD向量化:AVX-512、SSE等指令集,一次能处理8个float或16个int。但说实话,编译器自动向量化经常翻车,我建议你手写intrinsic。
核心指标:计算与访存比
CPU的算力(FLOPS)和内存带宽(GB/s)的比值通常较低。举个例子,一颗Intel Xeon Platinum 8380,双精度算力约2.3 TFLOPS,内存带宽约200 GB/s。算一下:2.3T / 200G ≈ 11.5。这意味着每从内存读一个float(4字节),你只能做11.5次浮点运算。如果算子计算密度低于这个比值,就会变成“访存瓶颈”。
我的经验:在CPU上优化矩阵乘法时,我遇到过一个问题:明明算力够,但跑起来就是慢。后来用perf工具一查,发现L1 cache miss率高达40%。原因是我把大矩阵直接塞进了循环,没有做分块(tiling)。改成16x16的小块后,性能直接翻倍。记住:CPU怕的是“数据喂不饱计算单元”。
2.2 GPU:并行怪兽,但怕“分支”和“小批量”
GPU的设计哲学是“简单的事情重复做,做一万遍”。它有成百上千个流处理器(SM/CU),每个核心都很“笨”,但堆数量。
架构特点:
- SIMT执行模型:32个线程组成一个warp,它们必须执行同一条指令。如果遇到if-else分支,一半线程干活,另一半闲着——这就是所谓的“warp divergence”。
- 内存层次更复杂:全局显存(HBM2e/HBM3,带宽可达2TB/s)、L2缓存(几MB)、共享内存(每个SM几十KB到上百KB)、寄存器。共享内存是程序员可以手动控制的“片上SRAM”,延迟极低(约5ns),但容量有限。
- 计算与访存比更高:以NVIDIA A100为例,FP16 Tensor Core算力约312 TFLOPS,显存带宽约2 TB/s。比值:312T / 2T ≈ 156。比CPU高了一个数量级。
避坑指南:我曾经在优化一个卷积算子时,天真地把所有数据都放在全局显存里,结果kernel跑起来只有理论峰值的5%。后来把输入特征图和权重分批加载到共享内存,再用寄存器缓存中间结果,性能直接拉到60%。GPU优化的核心就一句话:让数据尽可能靠近计算单元。
内存层次结构对比(GPU视角):
| 层级 | 容量 | 延迟 | 带宽 | 程序员控制 |
|---|---|---|---|---|
| 全局显存 | 40-80 GB | ~400 ns | ~2 TB/s | 显式分配/释放 |
| L2缓存 | 40 MB | ~200 ns | ~4 TB/s | 透明 |
| 共享内存 | 48-164 KB/SM | ~5 ns | ~20 TB/s | 显式声明 |
| 寄存器 | 256 KB/SM | ~1 ns | 极高 | 编译器分配 |
2.3 NPU:为AI而生的“专用加速器”
NPU(比如华为昇腾、Google TPU)是专门为神经网络设计的。它放弃了通用性,换来了极致的能效比。
架构特点:
- 脉动阵列(Systolic Array):这是NPU的灵魂。一个二维的乘法器阵列,数据像波浪一样在阵列中流动,每个时钟周期完成一次矩阵乘法。以昇腾910为例,它的Cube单元是16x16x16的3D阵列,一次完成4096个MAC操作。
- 片上缓存巨大:NPU通常有几十MB的片上SRAM(比如TPU v4有144MB),可以放下整个模型的权重。这意味着很多情况下不需要频繁访问片外DRAM。
- 计算与访存比极高:NPU的算力带宽比可以轻松超过1000。以昇腾910为例,FP16算力约256 TFLOPS,HBM带宽约1.2 TB/s,比值约213。但实际中,由于脉动阵列的利用率问题,这个比值会打折扣。
关键差异:CPU是“冯·诺依曼”架构,指令和数据分开存储;GPU是“SIMT”架构,靠大量线程隐藏延迟;NPU是“数据流”架构,计算由数据驱动。你想想看,这三种架构对算子优化的要求完全不同。
2.4 计算与访存比:算子优化的“罗盘”
这个比值(Arithmetic Intensity,简称AI)决定了你的算子是“计算密集型”还是“访存密集型”。
怎么算?
Arithmetic Intensity = 总计算量(FLOPs) / 总数据访问量(Bytes)
举个例子,一个矩阵乘法 C = A * B,A是MxK,B是KxN,C是MxN。
- 计算量:2 * M * N * K(乘加各算一次)
- 访存量:M*K + K*N + M*N(读A、B,写C),每个元素4字节(FP32)
- AI = (2*M*N*K) / (4*(M*K + K*N + M*N))
当M=N=K=1024时,AI ≈ 170。这个值远高于CPU的11.5,但低于GPU的156。嗯,这里要注意:实际运行时,由于缓存命中率、带宽利用率等因素,有效AI会低于理论值。
我的判断方法:拿到一个算子,先算AI。如果AI < 10,那大概率是访存瓶颈,优化方向是减少数据搬运(比如算子融合、内存复用)。如果AI > 100,那就是计算瓶颈,优化方向是提高计算密度(比如向量化、循环展开、使用Tensor Core)。
2.5 一张图看懂三者的核心差异
下面这张SVG图,我画了三者的核心架构对比。你仔细看看,就能明白为什么同一个算子在不同硬件上表现天差地别。
2.6 实战中的“计算与访存比”应用
我记得有一次优化一个LayerNorm算子。原始实现是逐元素操作,每个元素都要读一次、写一次。算下来AI只有可怜的2.3。在GPU上跑,显存带宽利用率只有30%。
后来我做了两件事:
- 算子融合:把多个逐元素操作合并成一个kernel,减少中间结果的读写。
- 向量化访存:用float4一次性读4个元素,提高带宽利用率。
优化后AI提升到了8.5,带宽利用率到了70%,整体性能快了3倍。你看,有时候不需要动算法,把数据流理顺了,效果立竿见影。
曾经踩过的坑:我在一个项目里,为了追求极致的计算密度,把矩阵分块做得特别小(4x4)。结果呢?计算单元确实跑满了,但地址计算和循环开销占了总时间的40%。这就是典型的“过度优化”。记住:计算与访存比是一个指导指标,不是目标。最终要看端到端的延迟。
好了,这一章的内容就到这里。硬件基础是算子优化的地基,地基不稳,上层建筑再漂亮也白搭。下一章我们会深入具体的算子优化技巧,到时候这些硬件知识会反复用到。
想要更多资料,联系可可