计算机组成原理 Computer Organization & Architecture
这一阶段看起来「不像 AI」,但它是你后面所有性能工作的物理底座。当你问「为什么这个 attention 实现比那个慢 3 倍」「为什么 batch 加到 64 就 OOM」「为什么 FP8 能省一半显存却几乎不掉点」,答案全在这里。不补这一课,你调优就只能在「换个库试试」的层面上碰运气;补上之后,你能算出理论上限、定位到具体受限资源、并预判一个优化能带来几倍收益。
阶段总览
- 把任意一个十进制小数写成 IEEE 754 二进制,并解释它为什么不能被精确表示
- 说清 fp32 / tf32 / bf16 / fp16 / fp8 的位宽分配、动态范围与精度差异,并判断某个训练任务该用哪个
- 画出五级流水线,解释数据冒险、控制冒险与分支预测失败为什么会造成停顿
- 给定一段代码,通过局部性原理判断它是 cache-friendly 还是 cache-hostile,并给出改造方案
- 用算术强度与 roofline 模型判断一个算子是 compute-bound 还是 memory-bound,并算出理论上限
- 说清 SIMT 执行模型:warp、分支发散、合并访存、bank conflict、occupancy 如何影响实际性能
- 核算一份模型的显存占用:权重 + 梯度 + 优化器状态 + 激活值 + KV Cache,解释每一项为什么是那个量级
- 用 profiler 定位真实瓶颈,并拿出「改前 vs 改后」的量化对比
| 周次 | 主题 | 动手产出 |
|---|---|---|
| 第 1 周 | 数据表示:补码、定点、IEEE 754、精度陷阱 | 手写浮点转换器;复现 0.1+0.2 与累加误差实验 |
| 第 2 周 | 指令执行:ISA、流水线、冒险、分支预测、乱序执行 | 写一个分支友好 vs 分支密集的对比基准,测出差异 |
| 第 3 周 | 存储层次:Cache 结构、映射、局部性、写策略 | 矩阵乘法三种循环顺序的耗时对比(行优先/列优先/分块) |
| 第 4 周 | 内存墙与 roofline:算术强度、带宽、bound 判定 | 给 matmul / attention / layernorm 画 roofline 图 |
| 第 5 周 | 并行体系结构:ILP / TLP / DLP、SIMD、多核、Amdahl | 向量化改写一段标量 Python/NumPy 代码并测加速比 |
| 第 6 周 | GPU 架构:SIMT、warp、存储层次、Tensor Core | 用 PyTorch profiler + ncu 风格指标分析一个真实算子 |
| 第 7–8 周 | 性能剖析与容量规划(里程碑 M2) | 输出 Hamauls Orion 的性能报告与显存容量模型 |
1. 数据表示与浮点精度
学习路径
- 读 1.1:跟着代码理解补码「减法即加法」与 int32 静默回绕
- 跑内置代码,复现 int32 溢出回绕为负数的真实例子
- 完成动手练习:手写 sym_quant 对称量化,报出 scale 与量化误差上限
- 对接 M2:在 bench_memory 与索引计算里一律用 int64 防溢出
核心知识点详解
- 补码把减法变成加法:整数减法
a−b硬件用a + (~b) + 1实现,只需一套加法器。int8 对称量化范围取 −128~127 也源于此:−128 无对应正数,反量化要用 −127 兜底。 - int32 静默回绕:
np.int32(2**31-1)+1会静默变成-2147483648,不报错——是最危险的一类 bug。当n_seq×n_len超过 2^31 时,索引与计数必须升到 int64。常见坑:用 int32 索引超大张量时结果是错乱而非崩溃,加长序列后莫名不对先查这里。 - 对称量化 scale=a/127:
scale = max|w| / 127,量化误差上限为scale/2。局部最大绝对值越小 scale 越细,所以逐通道量化优于全局量化。
学习路径
- 读 1.2:用位运算拆开一个 float32,看懂符号/指数/尾数预算
- 跑内置代码,观察 fp16 溢出成 inf 而 bf16 只失精度的差异
- 完成动手练习:判断某训练任务该用 bf16 还是 fp16、优化器状态为何要 fp32
- 对接 M2:在选择算力上限时对齐「位宽 + 是否稀疏」的标注
核心知识点详解
- 位宽是预算:指数位定动态范围、尾数位定精度,二者互相挤占。bf16(1+8+7) 指数同 fp32 所以范围安全、精度低;fp16(1+5+10) 尾数多精度高但最大值仅 65504。
- fp16 为何要 loss scaling:激活稍大就溢出成 inf、污染梯度,所以必须缩放。bf16 指数位与 fp32 一样宽、范围完全够用,训练开箱即用无需 loss scaling。
- E4M3 vs E5M2:fp8 里 E4M3(1+4+3) 偏精度、最大值 448、适合前向/权重;E5M2(1+5+2) 偏范围、最大值 57344、适合反向累积。常见坑:把 bf16 当「便宜的 fp32」存优化器状态,细小更新会被尾数舍入掉。
- ε 是判断误差的标尺:fp32 机器精度 ε≈1.19e−7。任何「两个接近的大数相减」都会灾难性抵消、有效位数掉光,这正是 softmax 先减最大值的原因。
学习路径
- 读 1.2:理解十进制小数在二进制里是无限循环、必然被截断
- 跑内置代码,复现 0.1+0.2 ≠ 0.3 与 1000 万元素累加误差爆炸
- 完成动手练习:复现灾难性抵消并解释 softmax 为何要先减最大值
- 对接 M2:用逐张量缩放实现 FP8 前向,验证小值不会被压成 0
核心知识点详解
- 0.1+0.2≠0.3:0.1 的二进制是无限循环小数
0.000110011...,23 位尾数只能截断,故0.1+0.2=0.30000000000000004。判据:分母只含因子 2 的小数(0.5/0.25/0.15625)才精确。 - 累加误差与 Kahan:当累加和远大于单个 0.1 时高位吃掉低位,1000 万元素逐项累加实测约 1.09e6 而非 1e6。Kahan 用补偿项追回丢失的低位,或 pairwise 分块求和。常见坑:并行归约累加顺序不定,同代码两次运行有 1e-6 级差异≠有 bug。
- 灾难性抵消:
|a|≈|b|时a−b有效位数骤减。softmax 先减最大值让指数 ≤0,既防exp溢出又保精度,且数学结果不变。 - FP8 需逐张量缩放:E4M3 最小正规数约 0.002,小值直接存会被压成 0。先用
scale=448/max|w|把最大值顶到 448 吃满动态范围,反向时还原 scale。
1.1 整数、补码与溢出:为什么 AI 里到处是整数
AI 系统里真正耗资源的不只是矩阵乘法,还有大量的整数运算:token id、位置索引、attention mask、分页表、张量步长(stride)、量化缩放因子。这些都用定长整数表示,而定长整数会溢出——这不是理论问题,是真实事故来源(比如用 int32 索引一个超大张量时静默回绕)。
python# 定长整数的溢出是「静默回绕」,不是报错——这是最危险的一类 bug
import numpy as np
a = np.int32(2**31 - 1)
print(a + 1) # -2147483648 ← 没有异常,直接变成负数
# 真实场景:算 token 总数时溢出,导致分配了错误的 buffer 大小
n_seq, n_len = 70000, 40000
total = np.int32(n_seq) * np.int32(n_len) # 期望 2.8e9
print(int(total), '应为', n_seq * n_len) # 溢出成负数或错误值
# 正确做法:索引与计数一律用 int64;只有确定不会溢出的热路径才降位宽
total = np.int64(n_seq) * np.int64(n_len)
| 类型 | 位宽 | 表示范围 | AI 中的典型用途 |
|---|---|---|---|
| uint8 | 8 | 0 ~ 255 | 量化权重、图像像素、token 字节级压缩 |
| int8 | 8 | −128 ~ 127 | 对称量化的权重/激活 |
| int32 | 32 | ±2.1e9 | 张量索引、步长、绝大多数 shape 计算 |
| int64 | 64 | ±9.2e18 | 超大张量索引、token 总数统计、时间戳 |
| bool/uint8 mask | 8 | 0/1 | attention mask(注意:1 bit 的信息占 1 byte) |
python# 把 int8 量化的缩放关系算清楚(AI 里最重要的整数运算之一)
import numpy as np
# 对称量化:把浮点区间 [-a, a] 线性映射到 int8 的 [-127, 127]
def sym_quant(x, a): # a = 该张量的最大绝对值
s = a / 127.0
q = np.clip(np.round(x / s), -127, 127).astype(np.int8)
return q, s
w = np.array([0.0, 0.01, -0.02, 0.35, -1.0], dtype=np.float32)
q, s = sym_quant(w, a=float(np.abs(w).max()))
print('scale =', round(float(s), 6)) # ≈ 0.007874 (= 1/127)
print('int8 =', q.tolist()) # [0, 1, -3, 44, -127]
print('反量化=', (q.astype(np.float32) * s).round(4).tolist())
# → [0.0, 0.0079, -0.0236, 0.3465, -1.0],最大误差 ≈ 0.0035 = scale/2
# 关键结论:量化误差上限 = scale/2,而 scale 由「该张量最大绝对值」决定
# → 逐通道 / 逐张量量化优于全局量化,因为局部最大绝对值更小、scale 更细
- 补码的价值:把减法变成加法(a − b = a + (~b) + 1),硬件只需一套加法器。这也是 int8 对称范围取 −128 ~ 127 的来由:−128 没有对应正数,反量化时要用 −127 兜底。
- 溢出的三种行为:C/汇编里是有符号溢出(未定义行为,编译器甚至可能把溢出判断优化掉);NumPy/PyTorch 是静默回绕;Python 原生 int 是任意精度、永不上溢。跨语言迁移时这是最常见的事故源。
- 经验数字:token id 空间 1e5~2e5 用 int32 足够;当 batch × seq 逼近 2^31(约 21 亿 token)时索引必须升到 int64;而 GPU kernel 内用 int32 索引通常更快(省寄存器、省带宽),所以框架常「先检查 shape 保证不溢出,再降位宽」。
1.2 IEEE 754 与低精度数值:bf16 / fp16 / fp8 的真实差别
这是本阶段最直接影响 AI 工作的一节。浮点数的位宽分配决定了它「能表示多大的数」和「能表示多精细的数」——这两件事是互相挤占的。理解了这个取舍,你才能回答「为什么 bf16 训练稳、fp16 训练容易炸」「为什么 fp8 要分 E4M3 和 E5M2 两种」。
| 格式 | 总位宽 | 符号 | 指数 | 尾数 | 动态范围 | 最小正规数 | 有效十进制位 |
|---|---|---|---|---|---|---|---|
| fp32 | 32 | 1 | 8 | 23 | ±3.4e38 | 1.2e−38 | ≈ 7.2 |
| tf32 | 19(存32) | 1 | 8 | 10 | ±3.4e38(同 fp32) | 同 fp32 | ≈ 3.3 |
| bf16 | 16 | 1 | 8 | 7 | ±3.4e38(同 fp32) | 同 fp32 | ≈ 2.4 |
| fp16 | 16 | 1 | 5 | 10 | ±65504 | 6.1e−5 | ≈ 3.3 |
| fp8 E4M3 | 8 | 1 | 4 | 3 | ±448 | 1.95e−3 | ≈ 1.3 |
| fp8 E5M2 | 8 | 1 | 5 | 2 | ±57344 | 1.5e−5 | ≈ 1.1 |
| int8 | 8 | 1(符号) | — | 7 位定点 | −128 ~ 127 | — | 取决于 scale |
读这张表的正确方式:bf16 和 fp16 都是 16 位,但 bf16 把位宽给了指数(范围大、精度低),fp16 给了尾数(精度高、范围小)。fp16 的最大值是 65504,训练中只要出现稍大的激活值就会溢出成 inf,进而污染梯度——这就是「fp16 训练需要 loss scaling」的原因。bf16 因为指数位和 fp32 一样宽,范围完全够用,所以开箱即用、不需要 loss scaling,代价是精度低一些(对训练几乎无感,因为梯度更新本身是统计量)。
pythonimport numpy as np, torch
# ---- 实验 1:十进制小数的二进制表示误差(为什么 0.1+0.2 != 0.3)----
print(0.1 + 0.2 == 0.3) # False
print(f'{0.1:.20f}') # 0.10000000000000000555
print(f'{0.1+0.2:.20f}') # 0.30000000000000004441
# 0.1 的二进制是无限循环小数 0.0001100110011...,23 位尾数只能截断 → 必然有误差
# ---- 实验 2:累加误差(为什么大规模求和要用 Kahan / pairwise)----
xs = np.full(10_000_000, 0.1, dtype=np.float32)
print(np.float32(0.1) * 10_000_000) # 1000000.0(先乘,误差小)
print(xs.sum()) # 1087937.0 ← 逐项累加,误差爆炸!
# 原因:当累加和远大于单个 0.1 时,0.1 的高位被吃掉,低位直接丢失
# 解决:用 float64 累加,或分块 pairwise 求和(torch.sum 内部已做)
# ---- 实验 3:fp16 溢出与 bf16 的稳定性 ----
x = torch.tensor([1e5], dtype=torch.float16)
print(x) # tensor([inf], dtype=torch.float16) ← 溢出
y = torch.tensor([1e5], dtype=torch.bfloat16)
print(y) # tensor([99840.], dtype=torch.bfloat16) ← 只是不精确
# 这也是为什么混合精度训练的惯用做法是:
# 前向/反向用 bf16(范围安全)→ 主权重副本保持 fp32(精度安全)
ε(机器精度)比你想的更重要:fp32 的 ε ≈ 1.19e−7。任何「两个大数相减得到小数」的运算(如a - b当 |a|≈|b|)都会出现灾难性抵消,有效位数直接掉光。softmax 里减最大值就是为了避免这个。- 累加顺序会改变结果:并行归约(GPU 上)的累加顺序不确定,所以同一份代码两次运行可能有 1e-6 级差异。调试时不要以为「结果不一样 = 有 bug」。
- 量化不是简单截断:int8 量化需要 scale(和 zero-point),把浮点区间线性映射到 −128~127。对称量化(weight 常用)只有 scale;非对称量化(激活常用)还要 zero-point。
- bf16 不适合存小数值:bf16 的尾数只有 7 位,表示 (1.0, 1.01) 之间的数只能落到很稀疏的点上。所以优化器状态和主权重必须用 fp32,否则细小的更新会被直接舍入掉。
- 降低精度换到的是带宽和显存,不总是算力:省显存和带宽是确定的(位宽减半);算力是否翻倍取决于硬件是否有对应的加速单元(Tensor Core 支持 bf16/fp8,但普通 CUDA core 不一定)。
python# FP8 训练为什么必须「逐张量缩放」:E4M3 只有 3 位尾数、最大值 448
import numpy as np
E4M3_MAX = 448.0
def to_fp8_e4m3(x):
amax = float(np.abs(x).max()) + 1e-12
scale = E4M3_MAX / amax # 把最大绝对值顶到 448,吃满动态范围
q = np.clip(x * scale, -E4M3_MAX, E4M3_MAX)
return q, scale
x = np.array([0.001, 0.5, 8.0, -300.0], dtype=np.float32)
q, s = to_fp8_e4m3(x)
print('scale =', round(float(s), 4)) # 448/300 ≈ 1.4933
print('缩放后=', np.round(q, 2).tolist()) # [0.0, 0.75, 11.95, -448.0]
# 不缩放直接存 → 0.001 与 0.5 会一起被压成 0(E4M3 最小正规数 ~0.002)
# 代价:额外保存 scale(每张量 4 字节,可忽略),反向要能还原
# → FP8 训练常配「延迟缩放」或逐块缩放(Blackwell 的 NVFP4)
# 相对误差量级:E4M3 有效十进制位 ≈ 1.3 → 单次量化相对误差 ~2^-4 ≈ 6%
# 所以 FP8 只用在前向 / GEMM,主权重与累加仍需 fp32 / bf16
python# 用位运算拆开一个 float32 的符号 / 指数 / 尾数(把公式变成可见的比特)
import struct
def dissect(x):
u = struct.unpack('<I', struct.pack('<f', x))[0]
sign = u >> 31
exp = (u >> 23) & 0xFF
frac = u & 0x7FFFFF
return sign, exp, frac, exp - 127
print(dissect(0.15625)) # (0, 124, 0x200000, -3) → 0.15625 = 1.25 × 2^-3,精确
print(dissect(0.1)) # (0, 123, 0x4CCCCD, -4) → 尾数被截断,故不精确
# 判据:一个十进制小数能被 float32 精确表示,当且仅当它是「整数 × 2^-k」形式
# 分母只有因子 2 的小数(0.5 / 0.25 / 0.15625 …)才精确;
# 0.1 / 0.2 / 0.3 的分母含因子 5,二进制是无限循环 → 必然截断
1.3 动手练习与自测
- 把十进制 0.15625 写成 IEEE 754 单精度二进制,给出符号位、指数位(含偏移)、尾数位,并说明它为什么能被精确表示。
- 训练任务:7B 模型、长上下文 32k、要求范围安全且开箱即用。权重与激活选 bf16 还是 fp16?主权重与优化器状态呢?
- 对取值区间 [−0.5, 0.5] 的张量做 int8 对称量化,scale 与最大量化误差分别是多少?
- 用「灾难性抵消」解释 softmax 为什么必须先减去最大值。
- 把 attention mask 从 bool 改成 float32,会带来什么后果(显存与数值两方面)?
- 参考 1:0.15625 = 1.25 × 2^−3 → 符号 0;指数 127−3=124=0b01111100;尾数 0.25=0b010…0(23 位)。判据:分母只含因子 2 的小数才精确。
- 参考 2:权重与激活用 bf16(指数位同 fp32、范围安全、无需 loss scaling);主权重副本与 Adam 状态必须是 fp32,否则细小更新会被舍入掉。
- 参考 3:a=0.5 → scale = 0.5/127 ≈ 0.003937;最大量化误差 = scale/2 ≈ 0.00197。
- 参考 4:若 logits 很大,exp(x) 会溢出;而 大数之间相减会出现抵消,有效位骤减。减去 max 后指数都 ≤ 0,既防溢出又保精度,数学上结果不变。
- 参考 5:显存上 [1,32,s,s] 的 float mask 是 bool 的 4 倍;数值上 float mask 常用大负数(如 −1e9)填充,一旦参与乘法/加法会污染梯度、更易产生 NaN,同时白白消耗带宽。
| 自测题 | 关键结论 / 数字 | 易错点 |
|---|---|---|
| 0.15625 的 IEEE754 | 1.25×2^−3;指数 124;尾数 0.25 | 误把偏移量写成 128 |
| 训练精度选择 | 权重/激活 bf16;主权重/优化器 fp32 | 用 bf16 存 optimizer state |
| int8 对称量化 | scale=0.003937;误差 ≤ scale/2 | 忘记用 a/127 而非 a/128 |
| softmax 减最大值 | 防 exp 溢出 + 防灾难性抵消 | 以为影响数学结果 |
| mask 用 float32 | 显存 ×4 + 数值风险 + 带宽浪费 | 忽略它只在 softmax 前用一次 |
2. 指令执行与 CPU 微架构
学习路径
- 读 2.1:画出 IF→WB 五级流水线并标出三类冒险
- 跑内置代码,用 timeit 测浮点乘 / 除 / 累加的量级差异
- 完成动手练习:画 load-use 冒险时序、标出停顿拍数、说明前递为何消不掉它
- 对接 M2:把「token 解码串行链 = 流水线数据依赖」写进性能报告
核心知识点详解
- 五级流水线:IF→ID→EX→MEM→WB,理想每拍进 1 条指令(IPC=1)。三类冒险都会插气泡:结构(资源冲突)、数据 RAW(后继要前继结果)、控制(分支方向未知)。
- 前递(forwarding):ALU 结果可旁路给下一条、消掉多数数据冒险。但 load-use 无法消除——load 结果要等 MEM/WB 才落地,紧接操作必须停 1~2 拍。
- 依赖链决定 IPC 上限:一段代码最长依赖链长度 ÷ 发射宽度即理论最少周期数。打断依赖链往往比多加核更有效;LLM 解码的 token 串行即天然依赖链,只能靠 batch 摊薄。
学习路径
- 读 2.2:理解分支预测 / 乱序 / 超标量并解释猜错要清空流水线
- 跑内置代码,对比有序 vs 随机数据上的分支判断耗时
- 完成动手练习:把随机分支改写成 branchless 并实测加速、说明适用边界
- 对接 M2:在容量模型里用「每 token 读一遍权重」解释 decode 上限
核心知识点详解
- 分支预测命中率:现代预测器准确率 95%+,但分支密集且数据随机时会失效,每次猜错清空流水线约 20 拍。有序数据快、随机数据慢就此而来。
- 乱序执行与 ROB:超标量每拍发射多条、用重排序缓冲(ROB)乱序执行、顺序提交。窗口仅几百条 μop,依赖链太长仍会卡住;利用推测执行副作用的侧信道攻击即 Spectre/Meltdown。
- branchless 是选择运算:用条件传送/位运算替代分支,预测器无压力。分支密集且随机时
branchless快 1.5~3x;但数据有序时预测器本来就准,可能反而略慢——先看数据分布再决定。
学习路径
- 读 2.1:把数据依赖链、分支发散映射到 LLM 解码与 MoE 路由
- 跑内置代码,用向量化替代热路径里的 if 分支并测收益
- 完成动手练习:解释 LLM 解码为何不能并行,只能靠 batch 摊薄
- 对接 M2:按「先向量化 → 再布局 → 才多线程」的顺序优化数据管线
核心知识点详解
- token 解码是串行依赖链:第 t+1 个 token 依赖第 t 个的输出,无法在同一请求内并行——对应流水线的数据/控制依赖,所以只能靠批处理(并行服务不同请求)摊薄。
- 优化优先级:先向量化:先向量化(消灭分支)→再数据布局(局部性)→最后才多线程,顺序反了收益差一个数量级。MoE 路由是数据相关长分支的重灾区。
- if-conversion 与谓词化:编译器(LLVM/GCC 条件传送)与 GPU predication 已自动消除简单分支,但数据相关分支仍会发散——这正是 MoE 推理要 capacity factor 与 token 重排的原因。
2.1 指令集与五级流水线
CPU 提高性能的两条路:提高频率(受功耗墙限制,基本走到头了)和提高每周期执行的指令数(IPC)。流水线是把一条指令拆成多个阶段、让多条指令重叠执行——这是 IPC 的第一层来源。
理想情况下五级流水线每拍完成一条指令(IPC=1)。但只要有冒险(hazard)就会插入气泡(bubble),IPC 掉下来。三类冒险:结构冒险(硬件资源冲突)、数据冒险(后一条指令要用前一条的结果)、控制冒险(分支导致不知道下一条取哪)。
c// 数据冒险的经典例子:load-use
int a = arr[i]; // load:结果要到 MEM/WB 阶段才可用
int b = a + 1; // 紧接着就要用 a → 必须停顿 1~2 拍(除非有转发/forwarding)
// 编译器为何要「指令调度」:把无关指令插进停顿槽
// 差: 好(编译器重排后):
// load r1, [r0] load r1, [r0]
// add r2, r1, #1 <停顿> load r3, [r4] ← 无关指令填充
// store [r5], r2 add r2, r1, #1
// store [r5], r2
// 这也解释了一个反直觉现象:
// 两个循环版本在数学上等价,但一个快 3 倍——差别就在「依赖链长度」和「访存局部性」
python# 用 timeit 测不同运算的「延迟量级」——比背表格更直观
import timeit
setup = 'x=1.234; y=5.678; xs=[i*0.1 for i in range(1000)]'
for stmt, name in [
('x*y', '浮点乘'),
('x/y', '浮点除'),
('sum(xs)', '1000 元素求和'),
('[v*2 for v in xs]', 'Python 逐元素循环'),
]:
t = timeit.timeit(stmt, setup=setup, number=10000) / 10000
print(name, round(t * 1e9, 1), 'ns/次')
# 典型量级(x86 笔记本):浮点乘 ~30~50 ns;浮点除约为乘的 1.5~3 倍
# 注意:在解释器里,「一次除法 ≈ 几十拍」的差异被淹没了。
# 要看到流水线 / 冒险的真实代价,必须去 C / Rust / CUDA 里测。
# 结论:语言抽象层会掩盖微架构差异——这也是 GPU 调优必须用 ncu 而非 Python 计时
- 前递 / 转发(forwarding)能消掉大部分数据冒险:ALU 结果可以旁路给下一条指令,不必等写回。真正无法消除的是 load-use 冒险(要先从内存取数)——这是「指针追逐」类代码慢的根本原因。
- 关键路径决定 IPC 上限:一段代码里最长的依赖链长度除以并行宽度,就是理论最少周期数。「把依赖链打断」往往比「多用几个核」更有效。
- 在 LLM 解码里,token 之间的依赖是天然的串行链:第 t+1 个 token 依赖第 t 个的输出,无法并行。所以解码只能靠 batch(并行服务不同请求)来摊薄,而不是靠更多线程算同一个请求。
2.2 分支预测、乱序执行与超标量
现代 CPU 的实际 IPC 能到 4~6,靠的是超标量(一拍发射多条)+ 乱序执行(按数据就绪顺序执行)+ 推测执行(猜分支方向先跑)。这三者的共同代价是:一旦猜错,要回滚。
- 分支预测:现代预测器准确率能到 95%+,但分支密集且随机的代码会让它失效——每次失败要清空流水线(几十拍)。所以「有序数据上跑得快、随机数据上跑得慢」在 CPU 上是常见现象。
- 乱序执行(OoO):CPU 用重排序缓冲区(ROB)在窗口内乱序执行、顺序提交。窗口大小有限(几百条 μop),所以依赖链太长时仍然会卡住。
- 推测执行与安全:Spectre / Meltdown 这类侧信道攻击就是利用推测执行的副作用。这是「性能与安全的取舍」的经典案例。
- 对 AI 数据管线的启示:如果你的 tokenize / 清洗代码里有大量
if 类型 == X的分支,考虑改成查表 / 位运算 / 分支无关(branchless)写法,或者干脆交给向量化框架。
python# 一个能自己跑出结论的实验:分支预测的影响
import random, time
def sum_branchy(xs, threshold):
s = 0
for x in xs:
if x >= threshold: # 数据随机时,这个分支预测失败率约 50%
s += x
return s
n = 20_000_000
sorted_data = sorted(random.random() for _ in range(n))
random_data = [random.random() for _ in range(n)]
for name, data in [('有序数据', sorted_data), ('随机数据', random_data)]:
t0 = time.perf_counter(); sum_branchy(data, 0.5); dt = time.perf_counter() - t0
print(f'{name}: {dt:.2f}s')
# 在编译型语言(C / Rust / Java)上差异会非常明显(可达 2~4 倍);
# 在 CPython 上被解释器开销掩盖,但换成 NumPy 向量化后差异就消失了——
# 因为向量化根本没有分支。这就是「用数据布局换分支」的最简单例子。
text// 为什么「分支无关(branchless)」写法更快 —— 用选择运算替代分支
// 有分支:数据随机时预测失败率约 50%
int clamp_branchy(int x) {
if (x < 0) return 0;
if (x > 255) return 255;
return x;
}
// 无分支:用位运算,预测器无压力
int clamp_branchless(int x) {
x = x & ~(x >> 31); // 负数清零:算术右移得到全 1 掩码
return x ^ ((x ^ 255) & -((x > 255))); // 上限裁剪
}
// 经验:分支密集且数据随机时,branchless 可快 1.5~3x;
// 但数据有序时预测器本来就准,branchless 反而可能略慢。
// 结论:先看数据分布,再决定要不要 branchless。
| 冒险类型 | 触发条件 | 典型代价 | 消除手段 |
|---|---|---|---|
| 结构冒险 | 硬件资源被占用 | 1+ 拍 | 分离指令/数据 Cache、多端口 |
| 数据冒险(RAW) | 后指令用前指令结果 | 0~2 拍 | 前递、编译器调度、寄存器重命名 |
| load-use 冒险 | 紧跟一次 load | 1~2 拍(无法完全消除) | 插入无关指令、软件预取 |
| 控制冒险 | 分支方向未知 | 猜对 0 拍 / 猜错 15~20+ 拍 | 深度预测器、延迟分支槽、无分支化 |
- 画出一条 load-use 数据冒险的 5 级流水线时序,标出需要插入的停顿拍数,并说明前递为什么消不掉它。
- 对 if (x > 0) 则累加、否则累减的代码:数据随机与数据有序两种情况下,预测器表现差多少?如何改写成 branchless?
- 为什么「IPC 从 3 提到 4」和「频率从 3GHz 提到 4GHz」对吞吐是等效的?各自的代价是什么?
- LLM 解码时 token 之间为什么不能并行?这对应流水线的什么概念?
- 若推理服务 90% 时间在 GPU 算、10% 在 CPU 预处理,用 Amdahl 说明优化 CPU 预处理最多能省多少。
- 判据 1:load 的结果要到 MEM/WB 才可用,紧接的 add 至少停 1~2 拍;前递只能旁路 ALU/EX 结果,无法旁路「还没从内存拿回来」的数据。
- 判据 2:随机数据预测失败率约 50%、每次清空流水线几十拍;有序数据预测几乎全中。改写:用条件传送(三元运算)或 s += x * sign。
- 判据 3:吞吐 ≈ 频率 × IPC,二者相乘所以等效;提频受功耗/散热限制(电压墙),提 IPC 受并行度、依赖链与面积成本限制。
- 判据 4:自回归解码是串行依赖链(token t+1 依赖 t 的输出),无法在同一请求内并行——对应流水线的数据/控制依赖,只能靠 batch 掩盖。
- 判据 5:Amdahl 下即使 CPU 部分无限快,上限是 1/0.9 ≈ 1.11x,即最多省约 10%;所以应先优化占 90% 的 GPU 侧。
3. 存储层次与 Cache:局部性就是性能
学习路径
- 读 3.1:记住各层容量 / 延迟数量级并分清「延迟 ≠ 带宽」
- 跑内置代码,用指针追逐实测 L1 / L2 / L3 / DRAM 延迟台阶
- 完成动手练习:顺序 vs 随机访问 512MB 数组并归因差异
- 对接 M2:据此判断多核同写一行导致的 false sharing 是否需处理
核心知识点详解
- 延迟的数量级跳跃:CPU 一拍约 0.3ns,DRAM 约 100ns,相差 300 倍;主存→SSD 又一跳约 1000 倍,SSD→网络再 1000 倍。能缓存的绝不重算,能批量的绝不逐条。
- 延迟≠带宽:延迟决定「一次串行依赖等多久」(指针追逐/解码 token 链);带宽决定「单位时间搬多少字节」(顺序读/prefill)。提高并行度掩盖延迟但加剧带宽争抢——先分清哪一种再选手段。
- 指针追逐基准:构造随机置换访问链使预取失效,循环
p=np.int64(a[p])是串行依赖、测的是延迟。典型台阶:32KB≈1ns、256KB≈4ns、8MB≈20ns、64MB≈90ns。
学习路径
- 读 3.2:推算出 32KB / 8 路 L1D 的 set 数与地址划分
- 跑内置代码,算 cache line、映射方式与三类 miss 的来源
- 完成动手练习:用 padding 打散矩阵转置的冲突 miss 并测速
- 对接 M2:把 miss 类型判断套用到检索热路径的访存模式
核心知识点详解
- cache line 是 64B:取数最小单位是整条 line,用 1 字节也搬进 64B。步长恰等于 line 大小且只用一元素时利用率仅 1/64——既是浪费也是空间局部性受益的来源。
- 算出 set 数:
set = 容量 / (line大小 × 路数),如 32KB/8 路/64B → 64 个 set。地址划分 offset 6 位 + index 6 位 + tag。常见坑:步长恰为 4096B(=64set×64B) 的访问全落同一 set → 冲突 miss。 - 三类 miss 三种手段:compulsory 首次访问不可避免(用预取);capacity 工作集超容(分块 tiling);conflict 映射冲突(padding 打散,如行宽 32→33)。调优前先判断是哪种。
- 写策略:现代 L1/L2 为 write-back + write-allocate(脏位标记、延迟写出)而非 write-through。write-through 简单但写流量大、带宽吃紧。
学习路径
- 读 3.2:理解 i-k-j 重排与 tiling 分块如何提升缓存命中
- 跑内置代码,对比直三重循环与分块矩阵乘的耗时
- 完成动手练习:定出 BS(3·BS² ≤ L2)并实测分块的加速比
- 对接 M2:把 tiling 归入「减少访存」并量化其收益
核心知识点详解
- i-k-j 重排:朴素 i-j-k 内层按列访 B、每次跨 N 个元素(cache 全 miss),极差。换成 i-k-j 后 C 和 B 都连续访问,实测快 3~8 倍。
- tiling 分块:把大矩阵切进能进 L1/L2 的小块、让数据在用前不被替换。经验取
3·BS²·8B ≤ L2,BS=64→98KB≈L2;BS 过大反过来退化成往返主存。 - 与 FlashAttention 同源:CPU 叫 cache blocking、GPU 叫 shared-memory tiling、attention 上是 FlashAttention 分块在线 softmax——同一原理三次应用。能识别「这是分块问题」是本阶段最重要的收获。
学习路径
- 读 3.3:分清 AoS vs SoA 各自面向的访存场景
- 跑内置代码,用 NumPy 对比 top-k 排序在 SoA vs AoS 下的耗时
- 完成动手练习:把检索候选从 dict 列表改列式数组并测加速
- 对接 M2:完成检索热点数据结构的 SoA 布局改造并记录数字
核心知识点详解
- AoS vs SoA:AoS 一次 cache line 拿全单条记录的字段;SoA 只读某字段时带宽 100%。热路径先 SoA 筛选/排序、再对命中结果转 AoS 聚合(向量检索标配)。
- false sharing 需 64B 对齐:多线程写同一 cache line 的不同变量会让整条 line 来回失效。解法:把每线程热变量按 64B 对齐隔离,或每核独立计数再合并。
- .contiguous() 是一次真拷贝:非连续切片(如
x[:,::2])在算子里退化成逐元素拷贝;.contiguous()触发真实拷贝,热路径里频繁调用是隐形性能杀手。 - PagedAttention 分块:vLLM 把 KV Cache 按固定 block 管理(类 OS 分页),解决预分配到底浪费 60%+ 显存;RadixAttention 用前缀树复用共享前缀——牺牲一点局部性换显存利用率与前缀命中率。
3.1 层级结构与访问延迟:数量级差异才是重点
这节的核心不是记住具体数字,而是记住数量级。CPU 一拍约 0.3 ns,而一次主存访问是 100 ns 量级——相差 300 倍。这意味着「算 100 次加减」可能比「从主存取一个数」还快。整个存储层次的存在意义,就是让绝大多数访问落在快的层。
| 层级 | 典型容量 | 典型延迟 | 相当于 CPU 多少拍 | 谁在管 |
|---|---|---|---|---|
| 寄存器 | KB 级(每核) | ~0.3 ns | 1 | 编译器 / 调度器 |
| L1 Cache | 32–64 KB 每核 | ~1 ns | 3–4 | 硬件 |
| L2 Cache | 512 KB–2 MB 每核 | ~4 ns | 12–15 | 硬件 |
| L3 Cache | 8–64 MB 共享 | ~15–40 ns | 50–130 | 硬件 |
| 主存 DDR5 | 64 GB–1 TB | ~80–120 ns | 250–400 | 硬件 + 你 |
| NVMe SSD | 1–8 TB | ~50–200 μs | 10 万+ | 操作系统 / 你 |
| 网络(同机房) | — | ~0.1–1 ms | 100 万级 | 你的协议设计 |
| 跨地域网络 | — | ~50–200 ms | 亿级 | 你的架构 |
注意最后两行:从主存到 SSD 是一次 1000 倍的跳跃,从 SSD 到网络是又一次 1000 倍的跳跃。这就解释了一个架构原则——能缓存的绝不重算,能批量的绝不逐条。你在 Hamauls Orion 里设计的检索结果缓存、前缀缓存、语义缓存,收益量级就是「省掉一次下游 100 ms 的调用」。
bash# 亲手测一遍延迟差异(Linux)——比看表格印象深得多
# 顺序访问 vs 随机访问同一块内存
$ python - <<'PY'
import time, random
import numpy as np
N = 64 * 1024 * 1024 # 64M 个 int64 = 512MB,远超 L3
a = np.zeros(N, dtype=np.int64)
# 顺序遍历:硬件预取友好
t0 = time.perf_counter()
for i in range(0, N, 16): a[i] += 1
print('顺序步进访问:', round(time.perf_counter() - t0, 3), 's')
# 随机访问:预取完全失效,每次都可能 miss 到主存
idx = list(range(0, N, 16)); random.shuffle(idx)
t0 = time.perf_counter()
for i in idx: a[i] += 1
print('随机访问: ', round(time.perf_counter() - t0, 3), 's')
PY
# 典型结果:随机访问慢 5~20 倍(在 Python 里被解释开销稀释,C/Rust 里差距更大)
# 但真正的指导意义在于:这就是 embedding 查表、稀疏索引、图遍历慢的根本原因
python# 用「指针追逐」测各级 Cache 的延迟台阶(经典 cache latency benchmark)
import time
import numpy as np
def chase(n, iters=3_000_000):
# 构造随机置换的访问链,硬件预取器无法命中
idx = np.random.permutation(n)
a = np.zeros(n, dtype=np.int64)
a[idx[:-1]] = idx[1:]
a[idx[-1]] = idx[0]
p = 0
t0 = time.perf_counter()
for _ in range(iters):
p = int(a[p]) # 串行依赖:每次都要等上一次访存返回
return (time.perf_counter() - t0) / iters * 1e9
for kb in [32, 256, 2048, 8192, 65536]: # 工作集 (KB)
n = kb * 1024 // 8
print(f'{kb:6d} KB ->', round(chase(n), 1), 'ns/次')
# 典型台阶:32KB ≈ 1 ns(L1)、256KB ≈ 4 ns(L2)、8MB ≈ 20 ns(L3)、64MB ≈ 90 ns(DRAM)
# 关键:p 是串行依赖,测的是「延迟」而非「带宽」——
# 这与吞吐型负载(顺序读大数组)看到的瓶颈完全不同,别混为一谈
3.2 Cache 的组织方式:line、set、映射与写策略
- Cache line(块)通常是 64 字节:这是取数据的最小单位。所以哪怕你只要 1 个字节,也会把整条 64 字节搬进来——这既是机会(空间局部性受益)也是浪费(步长刚好等于 line 大小且只用一个元素时,利用率只有 1/64)。
- 三种映射方式:直接映射(一个主存块只能放一个位置,冲突多)、组相联(N 路,主流,如 8 路 / 16 路)、全相联(任意位置,硬件贵,只用于小容量如 TLB)。
- 替换策略:LRU 及其近似(伪 LRU、随机)。这与你第 3 阶段要手写的 LRU 缓存是同一个思想——缓存设计是「用一小块快存储假装成一大块慢存储」的通用技巧,CPU 用它、数据库用它、你的向量索引也要用它。
- 写策略:write-through(写穿,简单但流量大)vs write-back(写回,脏位标记,延迟写出)。常识:现代 CPU 的 L1/L2 基本都是 write-back + write-allocate。
- 三种 miss:compulsory(首次访问,无法避免)、capacity(容量不够,只能换更大的 Cache 或改算法分块)、conflict(映射冲突,可以通过 padding 缓解)。性能调优时要能判断你遇的是哪一种。
python# 用循环顺序把「局部性」变成可测量的数字
import numpy as np, time
N = 2048
A = np.random.rand(N, N).astype(np.float64)
B = np.random.rand(N, N).astype(np.float64)
# ⚠️ 注意:NumPy 内部是优化过的,要看到差异得用下面「分块」的思路
# 这里演示的是原理;真正的对比实验建议用 C 或 Numba 写朴素三重循环
# 朴素三重循环里,访问顺序决定了 cache 命中率:
# for i: for j: for k: C[i][j] += A[i][k]*B[k][j]
# → A 行优先连续(好),B 按列访问(每次跨 N 个元素 = 跨 N/8 条 cache line,极差)
# 交换循环顺序(i-k-j)后 C 和 B 都变成连续访问,实测可快 3~8 倍
# 分块(tiling):把大矩阵切成能塞进 L1/L2 的小块,让每个元素被复用时都还在 Cache 里
def blocked_matmul(A, B, BS=64):
N = A.shape[0]
C = np.zeros((N, N))
for i0 in range(0, N, BS):
for k0 in range(0, N, BS):
for j0 in range(0, N, BS):
# 小块内做矩阵乘:三个小块都在 Cache 里
C[i0:i0+BS, j0:j0+BS] += A[i0:i0+BS, k0:k0+BS] @ B[k0:k0+BS, j0:j0+BS]
return C
# 分块大小 BS 的经验:让 3 个 BS×BS 的块(A块+B块+C块)× 8 字节 ≤ L2 容量
# BS=64 → 3*64*64*8 = 98KB ≈ L2 大小;BS 太大反而退化
c// 分块矩阵乘:一个能直接看到 cache 收益的经典实验(gcc -O2 -march=native)
// naive: 三重循环 i-j-k,B 按列访问,cache line 利用率极低
for (i=0;i<N;i++) for (j=0;j<N;j++) { float s=0;
for (k=0;k<N;k++) s += A[i*N+k]*B[k*N+j]; // B 每次跨 N 个元素
C[i*N+j]=s; }
// blocked: 切成 BS×BS 的小块,三个块都能常驻 L1/L2
for (i0=0;i0<N;i0+=BS) for (k0=0;k0<N;k0+=BS) for (j0=0;j0<N;j0+=BS)
for (i=i0;i<i0+BS;i++) for (k=k0;k<k0+BS;k++){ float a=A[i*N+k];
for (j=j0;j<j0+BS;j++) C[i*N+j] += a*B[k*N+j]; }
// 实测(N=2048,单核):naive ≈ 3.2s,BS=64 分块 ≈ 0.4s → 约 8x
// 经验:BS 取 32/64/128,取决于 L1(32~48KB)/L2 大小;太大 → 往返主存,退化
// 这就是「同样 2N^3 次浮点运算,耗时能差一个数量级」的来源
| Miss 类型 | 成因 | 能否消除 | 手段 |
|---|---|---|---|
| compulsory(冷启动) | 数据第一次被访问 | 不能 | 预取、预热、增大 batch 摊薄 |
| capacity(容量) | 工作集 > 缓存容量 | 可缓解 | 分块(tiling)、降精度、减少中间张量 |
| conflict(冲突) | 映射到同一组 | 可消除 | padding 打散(如转置矩阵行宽 32→33) |
| coherence(一致性) | 多核写同一行(false sharing) | 可消除 | 按 64B 对齐 / 每核独立计数再合并 |
text// Cache 地址划分:给定参数就能算出 set 数(面试常考)
// 例:64B line、8 路组相联、总容量 32KB 的 L1D
// set 数 = 32KB / (64B × 8 路) = 64 个 set
// 地址位划分(48 位虚拟 / 物理地址):
// [ offset 6 位 ] + [ set index 6 位 ] + [ tag 其余高位 ]
// 6 位 offset → 64B;6 位 index → 64 个 set;tag 用于命中比对
// 推论:步长恰为 4096B(64 set × 64B)的访问会全落在同一个 set → 冲突 miss
// 这就是「矩阵按列步进访问慢」在硬件层的解释(可通过 padding 打散)
- 一个 64B line、8 路组相联、容量 32KB 的 L1D 共有多少 set?地址如何划分为 tag / set / offset?
- 顺序访问 vs 随机访问一个 512MB 数组,实测差多少倍?给出归因。
- 矩阵转置为什么慢?如何用 padding 消除冲突 miss?
- AoS 与 SoA 各适合什么场景?解释「先 SoA 筛选、再 AoS 聚合」的理由。
- 分块的块大小 BS 怎么定?给出经验公式并说明 BS 过大的后果。
- 判据 1:set 数 = 32×1024 / (64×8) = 64;offset 6 位、set index 6 位、其余为 tag。
- 判据 2:随机路径慢 5~20 倍(C/Rust 更明显);顺序访问触发硬件预取(可能 4~8 条 line/次)并吃满带宽,随机访问每次都可能 miss 到 DRAM 且预取失效。
- 判据 3:按列访问步长 = 行宽,若行宽是 4096B 的整数倍就会全部落在同一 set 造成 conflict miss;把行宽 padding 到 33/65 个元素(非 2 的幂)即可打散。
- 判据 4:只扫一列 → SoA(连续流式、带宽 100%);要取整条记录 → AoS(一次 line 拿全)。所以「先 SoA 排序/筛选,再对少量命中结果转 AoS 聚合」最优。
- 判据 5:让 A块+B块+C块(3×BS²×sizeof)之和 ≤ L2、或让 A 行块+B 行块 ≤ L1;BS 过大会超出 Cache 容量,退化成反复往返主存,反而更慢。
3.3 为 AI 优化数据布局:AoS vs SoA 与对齐
在 AI 的预处理与检索热路径里,数据结构的内存布局往往比算法复杂度更决定性能。经典对比是 AoS(Array of Structs,结构体数组)与 SoA(Struct of Arrays,数组的结构体)。
pythonimport numpy as np
# AoS:每个样本的所有字段连续存放 —— 单个样本的完整信息一次拿到(cache 友好)
# [ (id0,len0,score0), (id1,len1,score1), ... ]
aos = np.zeros(1_000_000, dtype=[('id', 'i8'), ('len', 'i4'), ('score', 'f4')])
# SoA:每个字段各自连续 —— 只读某一个字段时,带宽利用率 100%
# ids[...], lens[...], scores[...]
soa = {'id': np.zeros(1_000_000, 'i8'),
'len': np.zeros(1_000_000, 'i4'),
'score': np.zeros(1_000_000, 'f4')}
# 场景决定选择:
# 「按 score 排序取 top-k」 → SoA 快得多(只扫 scores,连续流式读取)
# 「取出第 i 条样本的所有字段」 → AoS 快得多(一次 cache line 拿全)
# 实践中常见做法:热路径用 SoA 做筛选/排序,最后对选中的少量结果再按 AoS 聚合
# 另一个高频技巧:结构体填充(padding)避免 cache line 跨行与 false sharing
# 多线程下,两个线程各自写同一 cache line 的不同变量 → 缓存行来回失效(false sharing)
# 解决:把每个线程的热变量按 64 字节对齐隔离
- 对齐(alignment):现代 SIMD 指令要求 32/64 字节对齐才能发挥全部吞吐。NumPy / PyTorch 张量默认已对齐,但当你做
x[:, ::2]这类非连续切片时,底层会退化成逐元素拷贝——这就是为什么「切片后变慢」很常见。 - 连续性是硬约束:很多算子(reshape、view)要求张量是内存连续的。
.contiguous()会触发一次真实拷贝,代价可能是几十毫秒。在热路径里频繁调用它是最常见的隐形性能杀手。 - layout 选择(NCHW vs NHWC):图像任务里 NHWC 通常对卷积更友好(通道维连续,便于向量化);而某些推理引擎在特定硬件上偏好 NCHW。这不是数学问题,是访存问题。
- 在 Hamauls Orion 里怎么落地:M2 里程碑要求你对检索候选集做一次布局改造——把「每行一个 dict」改成「列式数组」,并测出排序阶段的加速比。这个改动通常能带来数倍收益,且完全不需要换算法。
python# AoS vs SoA 的真实排序加速比(NumPy 一行搞定)
import numpy as np, time
n = 5_000_000
rng = np.random.default_rng(0)
ids = rng.integers(0, 1_000_000, n)
scores = rng.random(n).astype(np.float32)
# SoA:只扫 score 一列(连续流式),再按 index 取 id
t0 = time.perf_counter()
order = np.argsort(scores)[::-1]
top = order[:100]
dt_soa = time.perf_counter() - t0
print('SoA top-100 耗时:', round(dt_soa, 3), 's')
print('命中 id:', ids[top][:5].tolist())
# AoS 版(record array)做同样的事通常慢 2~5x:
# 因为每个候选都要把整条记录(含未用到的字段)搬进 cache line
# 经验结论:筛选/排序阶段用 SoA,聚合输出阶段再转 AoS ——
# 这是向量检索、推荐召回、日志分析里通用的布局策略
4. 内存墙与 Roofline:判断瓶颈的定量框架
学习路径
- 读 4.1:写下算术强度公式与 roofline 上界的判定方法
- 跑内置代码,给 matmul / attention / layernorm 各算一版 roofline
- 完成动手练习:算 H100 拐点并判断 70B 解码为何 AI≈1
- 对接 M2:产出 docs/perf/roofline.md,逐个算子标 bound 与理论上限
核心知识点详解
- 核心公式:算术强度
AI = FLOP / 访存字节,性能上限= min(峰值算力, AI × 带宽)。H100 拐点 = 989e12 / 3.35e12 ≈ 295,AI<295 就是带宽受限、堆算力无用。 - GEMM vs GEMV:GEMM(N=4096) AI≈N/3≈1365 → compute-bound;GEMV AI≈2、向量加≈0.17、LayerNorm≈1.25 → 全 memory-bound。
- decode 极不对称:每生成 1 token 读全部权重(70B 读 140GB)只做 140 GFLOP → AI≈1,远低于 295。解码速度 ≈ 带宽÷每步字节,这正是量化近似线性提速的原因。
学习路径
- 读 4.2:理解内存墙成因,记住 PCIe 与 HBM 的百倍量级差
- 跑内置代码,实测本机真实内存带宽并对比标称百分比
- 完成动手练习:判断数据管线是否卡在 PCIe 搬运并给优化方向
- 对接 M2:在 latency-budget 里把「带宽 / 搬运」记为一段预算
核心知识点详解
- 内存墙成因:CPU 算力约每 2 年翻倍而带宽/延迟改善慢得多,越来越多程序从「算得慢」变「等数据」。AI 是这个趋势的最极端案例。
- PCIe 是隐形墙:HBM ~3TB/s vs PCIe ~64GB/s 相差约 50 倍。CPU→GPU 搬运往往才是数据管线的真实瓶颈——用 prefetch、pinned memory+异步、合并小传输缓解。
- 算子融合的收益:把 LayerNorm+Add+GELU 三个 memory-bound 算子的「读+写」从 6 遍降到 2 遍,理论上快 3 倍。这是 torch.compile / TensorRT 的主要收益来源。
学习路径
- 读 4.2:手推 KV Cache 公式与「权重 + 梯度 + 优化器 + 激活」拆解
- 跑内置代码,用 vram_budget 算 70B 推理显存并改参观察变化
- 完成动手练习:算 7B 全参微调显存、说明 LoRA 为何能救
- 对接 M2:交付 scripts/bench_memory.py 并与公式比对误差 <15%
核心知识点详解
- KV Cache 公式:
KV_bytes = 2 × layers × n_kv_heads × head_dim × seq × batch × dtype_bytes。70B/80 层/8KV 头/head 128 /seq 4k/bf16 ≈ 1.25GB,长上下文下比权重还吃显存。 - 推理显存拆解:70B bf16 权重 = 70e9 × 2B ≈ 140GB,单张 80G 放不下 → 必须量化(140→70GB)或张量并行。
- 训练显存≈16–20 B/参数:权重 2 + 梯度 2 + Adam(m,v 各 4) + 主权重 4 ≈ 16B,再加激活(与 batch×seq 成正比、可用梯度检查点压缩)。7B 全参微调需 ~112GB+ → 这就是 LoRA/QLoRA 存在的理由。
学习路径
- 读 4.2:理解融合 / 量化 / batch 摊薄为何是带宽受限的通用解法
- 跑内置代码,实测融合算子后 3 遍访存降到 2 遍的耗时
- 完成动手练习:算量化或增大 batch 后解码 tok/s 的变化
- 对接 M2:给出至少一项已验证的「减少访存」优化及对比数字
核心知识点详解
- 量化近似线性提速:decode 是带宽受限、AI≈1,权重体积减半(bf16→fp8→int4/2)则每步读取字节同步减半 → 解码 tok/s 近似线性上涨(140→70→35GB)。
- batch 摊薄权重读取:一次读权重服务多个请求,摊薄每 token 带宽成本 → 提升吞吐。代价是 batch 拉高延迟、KV Cache 体积按
batch×seq涨。 - 融合是基本盘:任何 memory-bound 链先查能否算子融合(6 遍→2 遍访存)再谈降精度和增大 batch。输出必须带改前 vs 改后量化对比才算完成。
4.1 算术强度与 Roofline 模型
这是本阶段最有用的一个分析工具,也是最该背下来的一个公式。它回答的问题是:这个算子到底是算力受限还是带宽受限?以及理论上还能快多少?
text算术强度 (Arithmetic Intensity, AI) = 浮点运算次数 / 访问字节数 单位: FLOP/Byte
性能上限 = min( 峰值算力 , 算术强度 × 内存带宽 )
log(性能 FLOP/s)
▲
│ ╱ ← 带宽受限区(斜率 = 内存带宽)
│ ╱
│ ╱
峰值算力 ├───────●──────────── ← compute-bound 天花板
│ ╱ ↑
│ ╱ 拐点 = 峰值算力 / 内存带宽
└───┴──────────────────► log(算术强度)
带宽受限 算力受限
例子(H100 数量级):峰值 bf16 算力 ≈ 1000 TFLOPS,HBM 带宽 ≈ 3.35 TB/s
→ 拐点算术强度 = 1000e12 / 3.35e12 ≈ 299 FLOP/Byte
→ 算术强度 < 299 的算子就是 memory-bound,再堆算力也没用
把常见算子的算术强度算一遍,你就理解了 GPU 利用率为什么提不上去:
| 算子 | FLOP 数 | 访存字节数 | 算术强度(约) | 结论 |
|---|---|---|---|---|
| 向量加法 c=a+b | N | 3N×2=6N | ≈ 0.17 | 极度 memory-bound |
| 元素级激活(ReLU/GELU) | ~N | 2N×2=4N | ≈ 0.25 | memory-bound |
| LayerNorm | ~5N | 2N×2 | ≈ 1.25 | memory-bound |
| 矩阵×向量 GEMV | 2MN | MN×2 + M×2 | ≈ 2 | memory-bound(LLM 解码就是它) |
| 矩阵×矩阵 GEMM (N=4096) | 2N³ | 3N²×2 | ≈ N/3 ≈ 1365 | compute-bound |
| Attention (短序列) | ~4·s²·d | ~4·s·d | ≈ s | s 小时 memory-bound,s 大时 compute-bound |
python# 给算子画 roofline(无需 GPU 也能算上界)
def roofline(flops, bytes_moved, peak_tflops, bw_gbs):
ai = flops / bytes_moved # FLOP/Byte
mem_bound = ai * bw_gbs * 1e9 / 1e12 # 折算成 TFLOPS
kind = 'memory' if mem_bound < peak_tflops else 'compute'
return ai, min(peak_tflops, mem_bound), kind
# 以 H100(SXM, bf16) 为例:峰值 ≈ 989 TFLOPS,HBM3 ≈ 3.35 TB/s,拐点 ≈ 295
PEAK, BW = 989.0, 3350.0
cases = [
('向量加 ', 2*4e9, 3*4e9*4), # 1e9 元素,读2写1,fp32
('LayerNorm ', 5*4e9, 2*4e9*4),
('GEMV(4096^2)', 2*4096**2, 4096**2*4 + 4096*4),
('GEMM(4096^2)', 2*4096**3, 3*4096**2*4),
]
for name, f, by in cases:
ai, cap, kind = roofline(f, by, PEAK, BW)
print(name, 'AI=', round(ai, 2), ' 上限=', round(cap, 1), 'TFLOPS ', kind)
# 预期:向量加 / LayerNorm / GEMV 全是 memory-bound;GEMM(4096) AI≈1365 → compute-bound
# 记住判据:AI < 295(H100)就是带宽受限,堆算力无效
| 加速卡 | bf16 密集算力 | HBM 带宽 | roofline 拐点 | 显存 |
|---|---|---|---|---|
| A100 80G | ~312 TFLOPS | 2.0 TB/s | ~156 FLOP/B | 80 GB HBM2e |
| H100 SXM | ~989 TFLOPS | 3.35 TB/s | ~295 FLOP/B | 80 GB HBM3 |
| H200 | ~989 TFLOPS | 4.8 TB/s | ~206 FLOP/B | 141 GB HBM3e |
| B200 | ~2250 TFLOPS(dense) | 8.0 TB/s | ~281 FLOP/B | 192 GB HBM3e |
| GB200 单超芯 | ~2500 TFLOPS | 8.0 TB/s | ~312 FLOP/B | 192 GB HBM3e |
4.2 内存墙:为什么算力涨得比带宽快
过去几十年,CPU 算力大约每 2 年翻倍,而内存带宽与延迟的改善速率慢得多——这个差距被称为内存墙(Memory Wall)。后果是:越来越多的程序从「算得慢」变成「等数据」。AI 是这个趋势的最极端案例。
| 内存类型 | 典型带宽 | 典型容量 | 用途 |
|---|---|---|---|
| DDR5(CPU 侧) | ~50–100 GB/s | 64 GB–1 TB | 主机内存、数据加载 |
| HBM3 / HBM3e(GPU 侧) | ~1.5–4.8 TB/s | 40–192 GB 每卡 | GPU 显存 |
| NVLink / NVSwitch | ~900 GB/s 双向(每卡) | — | 多卡互联、张量并行 |
| PCIe 5.0 x16 | ~64 GB/s 单向 | — | CPU↔GPU 传输(常见瓶颈!) |
| NVMe SSD | ~7 GB/s | TB 级 | 数据集存储 |
- PCIe 是一道极常见的隐形墙:HBM 有 3 TB/s,而 PCIe 只有 64 GB/s —— 差 50 倍。所以把数据从 CPU 内存搬到 GPU 显存这一步,往往就是数据管线的真实瓶颈。优化方向:提前搬运(prefetch)、在 GPU 侧做增广、用 pinned memory + 异步拷贝、合并小传输。
- Roofline 的实用读法:先算出你这个算子的算术强度,再和拐点比。远离拐点的一侧就是你的约束;如果是 memory-bound,优化方向是减少访存(融合算子、分块、量化、增大 batch 摊薄),而不是「多写几个 CUDA core 并行」。
- 算子融合(fusion)为什么有效:把「LayerNorm → 加法 → GELU」三个 memory-bound 算子融合成一个,访存从 6 遍降到 2 遍,理论上直接快 3 倍。这是
torch.compile/ TensorRT / 手写 kernel 的主要收益来源。 - KV Cache 是显存的主要消费者之一:公式
2 × layers × heads × head_dim × seq_len × batch × dtype_bytes。对 70B 模型、4096 序列、bf16,单条请求的 KV Cache 就约 1.25 GB。长上下文场景下它比权重还吃显存——这正是 PagedAttention / GQA / KV 量化要解决的问题。
python# Hamauls Orion M2 的核心交付:一份能自己算出「跑不跑得动」的容量模型
def vram_budget(params_b: float, bytes_per_param: int = 2,
n_layers: int = 80, n_kv_heads: int = 8, head_dim: int = 128,
seq: int = 4096, batch: int = 1, kv_bytes: int = 2):
"""返回各部分的显存占用(GB)。参数按十亿计,返回按 GB。"""
GB = 1024 ** 3
weights = params_b * 1e9 * bytes_per_param / GB
# KV Cache: 2(K和V) × 层数 × KV头数 × head_dim × 序列长 × 批大小 × 位宽
kv = 2 * n_layers * n_kv_heads * head_dim * seq * batch * kv_bytes / GB
return {
'weights_GB': round(weights, 2),
'kv_cache_GB': round(kv, 2),
'kv_per_token_MB': round(2 * n_layers * n_kv_heads * head_dim * kv_bytes / 1024**2, 3),
'total_GB': round(weights + kv, 2),
}
print(vram_budget(70, n_layers=80, n_kv_heads=8, head_dim=128, seq=4096, batch=1))
# → weights 130.4GB(bf16),kv_cache 0.625GB,单条 4096 上下文合计 ≈ 131GB
# 结论:70B 的 bf16 权重连一张 80GB 卡都放不下 → 必须量化或张量并行
# 练习:把 kv_bytes 改成 1(fp8 KV)看省了多少;把 seq 改成 32768 再看 kv 涨了多少倍
python# 测你机器的真实内存带宽(达到标称的百分之几?)
import numpy as np, time
def bandwidth_gbs(n_read, n_write, dt):
return (n_read + n_write) / dt / 1e9
N = 200_000_000 # 200M float32 = 800MB,远超 L3
a = np.ones(N, dtype=np.float32)
b = np.ones(N, dtype=np.float32)
# 顺序 copy:读 N + 写 N
t0 = time.perf_counter(); c = a + b; dt = time.perf_counter() - t0
print('copy 带宽 :', round(bandwidth_gbs(4*N, 4*N, dt), 1), 'GB/s')
# 归约:读 N(写出极少)
t0 = time.perf_counter(); s = a.sum(); dt = time.perf_counter() - t0
print('reduce 带宽:', round(bandwidth_gbs(4*N, 0, dt), 1), 'GB/s')
# 典型结果:单通道 DDR5 约 30~45 GB/s;双通道 60~90;GPU HBM 2000~4000
# 达到标称 60%+ 就算不错;差距说明访存模式、NUMA 或没打满通道
- 算 H100 的 roofline 拐点(bf16 989 TFLOPS / 3.35 TB/s),说明 70B 解码的算术强度为何远低于它。
- 一个逐元素 GELU 算子的算术强度约 0.25,它受限于什么?给出三种优化手段。
- 推导 70B、80 层、8 个 KV 头、head_dim 128、4096 序列、bf16 的单条请求 KV Cache 大小。
- 为什么算子融合能把 3 个 memory-bound 算子的时间降到约 1/3?
- FlashAttention 把 attention 的 HBM 访存从 O(s²) 降到什么量级?靠什么机制?
- 判据 1:拐点 = 989e12 / 3.35e12 ≈ 295 FLOP/Byte;70B 解码每步读 140GB 权重、只做 140 GFLOP → AI ≈ 1,远小于 295,故是带宽受限。
- 判据 2:GELU 是逐元素算子,AI ≈ 0.25 < 拐点 → memory-bound。手段:① 与前后算子融合;② 降精度(bf16/fp8)减少字节;③ 增大 batch 摊薄权重读取。
- 判据 3:2( K/V ) × 80 × 8 × 128 × 4096 × 2 bytes = 1,342,177,280 B ≈ 1.25 GB;与权重同量级,长上下文下更吃显存。
- 判据 4:融合把「读+写」的次数从每个算子各一遍(6 遍)降到 2 遍(1 读 1 写),访存减少 3 倍 → 时间约 1/3(带宽受限时近似线性)。
- 判据 5:降到约 O(s²·d/BC) 且中间矩阵不再落 HBM;机制是分块(tiling)+ 在线 softmax(online rescaling)+ 把 S、P 留在 shared memory / 寄存器。
5. 并行体系结构:从 ILP 到 SIMD 到多核
学习路径
- 读 5.1:分清 ILP / DLP / TLP 与 Amdahl / Gustafson 的适用场景
- 跑内置代码,用 amdahl() 跑 p=0.95 / 0.99 的上限表
- 完成动手练习:算 p=0.9 部分加速 10x 时的总加速比并给出判据
- 对接 M2:先 profile 找占比大的一段再并行,避免优化 5% 的盲区
核心知识点详解
- 三类并行:ILP 指令级(超标量/乱序)、DLP 数据级(SIMD/warp/向量化)、TLP 线程级(多核/多卡)。AI 主要靠 DLP 与 TLP。
- Amdahl 定律:加速比
= 1 / ((1−p) + p/s)。p=0.95 时即使可并行部分无限快上限也只有 20x。先 profile 找大占比段再并行,别优化占 5% 的盲区。 - Gustafson 弱扩展:Amdahl 假设问题规模固定;LLM 训练规模随卡同步增长 → 属弱扩展、趋近线性。加卡可以训更大的模型,而非只能快一点点。
学习路径
- 读 5.1:对比 DP / TP / PP / EP 各自的通信量级
- 跑内置代码,验证「TP 通信最密、限制在 NVLink 单机」的结论
- 完成动手练习:解释为何 PP 跨机可行而 TP 不能、DP 通信多大
- 对接 M2:单卡跑不下时就按通信密度规划多卡 / 量化
核心知识点详解
- DP 的通信:每步 all-reduce 梯度约 2×参数量、量大但可用 ZeRO 分片压低;通信相对稀疏,适合跨机。
- TP 需 NVLink:张量并行把单层矩阵切开,每层两次 all-reduce、通信最密。必须关进 NVLink(~1.8TB/s 双向)域,跨机 InfiniBand 撑不住——这是 TP 限单机的硬约束。
- PP 通信最低:只在层边界传激活、通信稀疏可跨机,但存在气泡(bubble)降利用率,需做流水线调度优化。MoE 的 EP 走 all-to-all 不规则路由。
- 事实标准:
TP×PP×DP(+EP)。DeepSeek-V3 671B 用 FP8 通信 + 细粒度 EP 压 all-to-all 代价。结论:按通信密度匹配互联带宽。
学习路径
- 读 5.2:理解 SIMD 车道、无依赖 + 连续访存两个前提
- 跑内置代码,实测 Python 循环 vs NumPy 向量化的数量级差距
- 完成动手练习:向量化改写一段标量代码、避开跨步与隐式广播
- 对接 M2:在数据管线里「先向量化再考虑多线程」
核心知识点详解
- SIMD 车道:一条指令处理多个元素:AVX-512(512 位)=16 个 fp32 / 8 个 fp64。NumPy/PyTorch 快主要来自 SIMD+零解释器开销,实测 30~80x。
- 向量化前提:① 无循环依赖(每轮不依赖上轮结果);② 访问连续。跨步
xs[::2]让相邻车道取不到相邻内存、退化回标量。 - coalescing 是 GPU 版:一个 warp 的 32 个线程访问连续地址可合并成极少内存事务;散乱则每次独立请求、带宽利用率掉到 1/32——「连续性」在 GPU 上的名字。
- 小心广播物化:
(n,1) × (1,m)会先物化成 (n,m) 中间张量、可能直接 OOM。大矩阵用 einsum 或显式分块,避免隐式广播。
5.1 三种并行与 Amdahl 定律
| 并行层次 | 含义 | 典型机制 | AI 中的体现 |
|---|---|---|---|
| ILP(指令级) | 同一条指令流内部并行 | 超标量、乱序、流水线 | 编译器调度、循环展开 |
| DLP(数据级) | 同一条指令作用于多个数据 | SIMD(AVX/NEON)、GPU 的 warp | 向量化、NumPy、CUDA kernel |
| TLP(线程级) | 多条指令流并行 | 多核、超线程、多线程 | 数据加载、并行评测、多卡 |
textAmdahl 定律:加速比 = 1 / ( (1-p) + p/s )
p = 可并行部分占比,s = 该部分的加速倍数
例子:如果 95% 的代码可以并行、这 95% 部分做到无限快
→ 最大加速比 = 1/(0.05) = 20 倍(而不是无限)
这是「先找瓶颈,再并行」的数学依据:
优化一个只占 5% 时间的部分,即使做到极致也只省 5%。
→ 所以必须先 profile,再动手。
python# 强扩展 vs 弱扩展:为什么「加卡」有时几乎不涨吞吐
def amdahl(p, s):
return 1 / ((1 - p) + p / s)
for p in [0.5, 0.9, 0.95, 0.99]:
vals = [round(amdahl(p, s), 2) for s in (2, 8, 100, 1e9)]
print(f'p={p:<5} 加速 s=2/8/100/∞ → {vals}')
# 关键数字:p=0.95 时即使并行部分无限快,上限也只有 20x;p=0.99 → 100x
# → 分布式训练里通信 / 同步 / 数据加载这些「串行部分」才是扩展性天花板
# 强扩展:固定问题规模、加资源 → 受 Amdahl 限制,收益递减
# 弱扩展:问题规模随资源同比例增长 → 更接近线性(Gustafson),更像 LLM 训练
| 并行维度 | 切分对象 | 通信量级 | 典型对应 |
|---|---|---|---|
| 数据并行 DP | batch | 每步 all-reduce 梯度(2×参数量) | DDP / ZeRO |
| 张量并行 TP | 单层权重矩阵 | 每层两次 all-reduce(高,需 NVLink) | Megatron / 单机多卡 |
| 流水并行 PP | 层 | 只在层边界传激活(低) | 跨机 / 多机 |
| 专家并行 EP | MoE 专家 | all-to-all 路由(不规则) | MoE 大模型 |
| 序列并行 SP | seq 维 | 配合 TP 的 all-gather | 长上下文训练 |
5.2 SIMD 与向量化:为什么 NumPy 快
SIMD(Single Instruction Multiple Data)让一条指令同时处理 8/16/32 个数据元素。这是 NumPy、PyTorch 底层、以及一切「向量化改写」的硬件基础。理解了 SIMD,你就理解了为什么「用 for 循环写 Python」和「用 NumPy 写」能差 100 倍。
pythonimport numpy as np, time
n = 10_000_000
xs = np.random.rand(n).astype(np.float32)
# ① 纯 Python 循环:每轮有解释器开销,且完全没有 SIMD
t0 = time.perf_counter()
out = [x * 2.0 + 1.0 for x in xs]
print('Python 循环: ', round(time.perf_counter() - t0, 3), 's')
# ② NumPy 向量化:底层是 SIMD 指令 + 循环零解释开销
t0 = time.perf_counter()
out = xs * 2.0 + 1.0
print('NumPy 向量化:', round(time.perf_counter() - t0, 3), 's')
# 典型差距 30~80 倍。注意:这个差距的两个来源是
# (a) 消除了逐元素的解释器开销(大头)
# (b) SIMD 让一条指令处理多个元素(几倍)
# 很多教程只强调 (b),其实 (a) 往往贡献更大。
- 向量化的前提是「无循环依赖」:每轮迭代不能依赖上一轮的结果。有依赖时必须换算法(前缀和、扫描),或用专门实现。
- 注意内存布局:向量化读连续内存效率最高。
xs[::2]这类跨步访问会让 SIMD 退化甚至回退到标量。 - 在 GPU 上这叫 coalescing:一个 warp 的 32 个线程访问连续地址时可以合并成一次事务;散乱访问则要发多次请求,带宽利用率暴跌。这是同一个「连续性」原则在 GPU 上的名字。
- 广播语义要看清:
(n,1) * (1,m)会先物化成 (n,m),内存可能直接爆掉。大矩阵场景优先用einsum或显式分块,避免隐式广播的中间张量。
c// SIMD 加速比从哪来:一条指令处理 8 / 16 个 float(AVX2 / AVX-512)
#include <immintrin.h>
void add_scalar(float* a, float* b, float* c, int n) {
for (int i=0;i<n;i++) c[i] = a[i] + b[i]; // 每拍 1 个元素
}
void add_avx2(float* a, float* b, float* c, int n) {
for (int i=0;i+8<=n;i+=8)
_mm256_storeu_ps(c+i, _mm256_add_ps(_mm256_loadu_ps(a+i),
_mm256_loadu_ps(b+i))); // 8 个 / 条
}
// 理论加速 8x(AVX2)或 16x(AVX-512);实测多为 4~7x
// 因为向量加已受内存带宽限制(见 roofline),数据能常驻 L1 才接近理论值
// 结论:SIMD 提高的是「算力」,算子 bandwidth-bound 时收益被带宽封顶
BLOCK_SIZE、tl.load、tl.dot 语义的前提。- 用 Amdahl 计算:p=0.9、并行部分加速 10x 时的总加速比。
- 写出 AVX-512 一条指令能处理多少个 fp32 / fp64,以及对应的理论加速比。
- 对比数据并行、张量并行、流水并行的通信量级,并说明为什么 TP 通常限制在单机 NVLink 内。
- 弱扩展(Gustafson)为什么比强扩展(Amdahl)更接近 LLM 训练的现实?
- 向量化改写的两个前提条件是什么?跨步访问为什么会让它退化?
- 判据 1:1.1 / (1−0.9 + 0.9/10) = 1/(0.1+0.09) = 5.26x。
- 判据 2:AVX-512 有 512 位宽 → 16 个 fp32 或 8 个 fp64;理论加速分别 16x / 8x(实测受带宽限制)。
- 判据 3:DP 每步 all-reduce 约 2×参数量(通信量大);TP 每层两次 all-reduce(最大);PP 只在层边界传激活(最小)。TP 通信最密,需要 NVLink 级带宽,跨机 IB 撑不住,故通常限单机。
- 判据 4:LLM 训练的 batch/序列/模型规模随卡数同步增长,问题规模不固定 → 属于弱扩展场景,趋近线性;Amdahl 的悲观上界不适用。
- 判据 5:前提是「无循环依赖(可重排)」与「访问连续(可合并)」。跨步 xs[::2] 时相邻车道取不到相邻内存,无法打包成一条向量指令,退化成标量。
6. GPU 体系结构:为什么它适合 AI
学习路径
- 读 6.1:理解 warp 锁步、分支发散与 occupancy 隐藏延迟
- 跑内置代码,用 torch.profiler 观察同一个算子前后的 kernel 时间
- 完成动手练习:算一个 warp 走两条分支的最坏效率并说明避免法
- 对接 M2:判断 GPU 利用率低是数据供给还是发散同步所致
核心知识点详解
- warp=32 锁步:以 32 线程为调度单位、锁步执行同一指令。GPU 把面积给 ALU(>80%) 用海量线程切换隐藏延迟;CPU 把面积给控制逻辑与 Cache 降单线程延迟——两种完全不同的设计哲学。
- 分支发散最坏 32×:warp 内线程走不同分支时硬件把每条路径各执行一遍并用 mask 屏蔽,最坏效率 1/32。用 select/where 替代分支、让同 warp 处理同质数据规避。
- occupancy 只是手段:SM 驻留 warp 越多越能切换隐藏延迟;但寄存器/shared 用量大则占用低。FlashAttention 故意高寄存器低占用(约 25~50%)却更快——目标是「够隐藏延迟」而非占满。
学习路径
- 读 6.2:记住 coalescing、bank conflict、host↔device 同步三大陷阱
- 跑内置代码,实测 pinned vs pageable 的 H2D 差距
- 完成动手练习:把训练循环里的 .item() 改成每 N 步同步一次
- 对接 M2:给出探针输出的同步点清单并逐一消除
核心知识点详解
- coalescing 合并访存:相邻线程读相邻地址合并为极少内存事务;散乱则每次独立事务、带宽利用率可能掉到 1/32。让相邻线程读相邻内存是 GPU 第一原则。
- bank conflict 用 padding:shared memory 分 32 个 bank,同 warp 访问同一 bank 的不同地址会串行化。矩阵转置是经典受害者,行宽 padding 到 33(而非 32)即可打散。
- .item() 打断流水线:
tensor.item()/print(tensor)触发 device→host 同步、让 GPU 空转等待。正确做法:把 loss 累积到 GPU 标量,每 N 步再同步一次。 - pinned memory 快 2–3x:锁页内存 H2D 比分页快 2~3x 且支持 non_blocking 真异步。PCIe 5.0 x16 单向上限 ~64GB/s,未开 pinned 常走到理论 3~5 倍。
学习路径
- 读 6.3:理解 Tensor Core 的 MMA 形状约束与低精度通路
- 跑内置代码,实测 fp32 / tf32 / bf16 GEMM 的 TFLOPS 差异
- 完成动手练习:解释 hidden 取 16 倍数更快、读懂算力标注对齐位宽
- 对接 M2:在容量模型里按「位宽 + 稀疏」对齐标称算力
核心知识点详解
- MMA 形状约束:Tensor Core 做 16×16×16 矩阵乘累加,维度需 8/16 对齐。hidden 取 16 倍数更快不是玄学而是硬件约束,否则要 padding 或退化到慢通路。
- tf32 零成本提速:fp32 指数范围不变、尾数砍到 10 位。Ampere 及以后默认开(
torch.backends.cuda.matmul.allow_tf32=True),GEMM 快 2~3x 且多数任务精度可忽略。 - bf16 GEMM 快 10–16x:同一张卡 bf16 密集算力是 fp32 的 10~16 倍;但 memory-bound 算子只省带宽。看厂商标称算力必须对齐「位宽 + 是否稀疏」,fp8 稀疏数字可能高估几十倍。
学习路径
- 完成动手练习:自测验算 warp 双路径 ≤ 50%、步长 32 float 利用率 ≈ 3%
- 跑内置代码,用随机 gather 复现散乱访问慢 3-10x
- 对接 M2:把这些判断用于核对外部报告的占用率结论
核心知识点详解
- warp 双路径 ≤50%:32 线程走 2 条分支会串行执行两条路径、各屏蔽一半 → 最坏效率 ≤1/2(实为 1/路径数)。避免:select 替代、让同一 warp 处理同质数据。
- 步长 32 float 利用率≈3%:步长 32 float = 128 字节,每线程落进不同 cache line、32 条 line 只用到 4 字节 → 利用率约
4/128 ≈ 3%。这就是散乱 gather 慢 3~10x 的根源。 - occupancy 不是目标:occupancy 只决定能否隐藏延迟;低占用高频 kachange 用片上存储换访存反而更快。目标是少搬字节、够隐藏延迟,善用随机 gather 复现验证。
6.1 SIMT 执行模型:warp、分支发散与延迟隐藏
CPU 追求低延迟(让单条指令流尽快跑完),所以把面积花在分支预测、乱序执行、大 Cache 上。GPU 追求高吞吐(单位时间完成尽量多的运算),所以把绝大多数面积花在运算单元上,用海量线程切换来隐藏延迟。这是两种完全不同的设计哲学。
| 维度 | CPU | GPU |
|---|---|---|
| 设计目标 | 降低单线程延迟 | 最大化整体吞吐 |
| 核心数 | 8–64 个强核 | 数千个弱核(分组成 SM) |
| 面积分配 | 大量给控制逻辑与 Cache | 大量给 ALU(>80%) |
| 延迟隐藏方式 | 乱序执行、推测、深 Cache | 线程切换(1 条指令的延迟被其他 warp 填满) |
| 分支处理 | 精密的预测器 | warp 内分支发散则串行化执行 |
| 编程抽象 | 线程 = 独立指令流 | 线程 = 数据元素(SIMT) |
- warp 是调度的最小单位:通常 32 个线程一组,锁步(lockstep)执行同一条指令。
- 分支发散(divergence):如果 warp 内 32 个线程走了不同的分支,硬件会把每条路径各执行一遍,用 mask 屏蔽不参与的线程。最坏情况 32 倍效率损失。所以 GPU 代码要尽量避免数据相关的分支,或者用「查表 / 选择运算」替代分支。
- 延迟隐藏的关键是 occupancy(占用率):SM 上同时驻留的 warp 越多,越能在某些 warp 等内存时切换到其他 warp 干活。occupancy 太低(比如寄存器用太多、shared memory 占太多)就直接暴露延迟,性能腰斩。
- 但 occupancy 不是越高越好:很多高性能 kernel(如 FlashAttention)故意用高寄存器数换更少的访存,occupancy 只有 25% 却更快。目标是「足够隐藏延迟」,不是「占满」。
python# 用 PyTorch 观察 GPU 行为(无需写 CUDA)
import torch
device = 'cuda'
x = torch.randn(4096, 4096, device=device)
# ① 连续 vs 非连续:同一个数学运算,速度可以差数倍
a = torch.randn(8192, 8192, device=device)
contig = a.clone()
strided = a.t() # 转置视图:非连续
print('contiguous:', contig.sum().item())
print('strided: ', strided.sum().item())
# 用 torch.profiler 看 kernel 时间,通常 strided 版本慢 2~10 倍
# → 需要时显式 .contiguous(),但要清楚它触发了一次拷贝
# ② 元素级算子融合:3 个 memory-bound 算子 vs 1 个融合算子
def unfused(x):
h = torch.nn.functional.layer_norm(x, x.shape[-1:])
return torch.nn.functional.gelu(h + 1.0)
fused = torch.compile(unfused, mode='max-autotune')
# 对比两者在 profiler 里的 kernel 数量与总时间
# 未融合:layernorm / add / gelu 至少 3 次全量读写
# 融合后:1 次读 + 1 次写 → 理论快 3 倍,实测通常 2~3 倍
# ③ 用 profiler 看真实的 kernel 级耗时(这是所有优化的起点)
from torch.profiler import profile, ProfilerActivity
with profile(activities=[ProfilerActivity.CUDA]) as prof:
for _ in range(20):
unfused(x)
print(prof.key_averages().table(sort_by='cuda_time_total', row_limit=10))
python# 量化「合并访存」的代价(用 PyTorch 索引表达)
import torch, time
dev = 'cuda'
x = torch.randn(1_000_000, device=dev)
idx_contig = torch.arange(0, 1_000_000, device=dev) # 连续 → 合并访存
idx_random = torch.randperm(1_000_000, device=dev) # 散乱 → 每次多发事务
def bench(fn, iters=50):
fn(); torch.cuda.synchronize()
t0 = time.perf_counter()
for _ in range(iters): fn()
torch.cuda.synchronize()
return (time.perf_counter() - t0) / iters * 1e3 # ms
print('连续 gather:', round(bench(lambda: x[idx_contig]), 3), 'ms')
print('随机 gather:', round(bench(lambda: x[idx_random]), 3), 'ms')
# 典型:随机 gather 慢 3~10x —— 这就是 embedding 查表、稀疏注意力的成本来源
# 启示:能重排成连续访问的先重排(这正是 MoE 推理里 token 重排的作用)
6.2 GPU 存储层次与三个致命陷阱
| 层次 | 容量 | 带宽(相对) | 谁可见 | 管理方式 |
|---|---|---|---|---|
| 寄存器 | 每线程数十个 | 最快 | 单线程私有 | 编译器分配,决定 occupancy |
| Shared Memory / L1 | 每 SM 数十–两百 KB | 很高(片上) | 同一 block 内共享 | 显式声明(shared)或自动(L1) |
| L2 Cache | 数十 MB(全卡共享) | 高 | 全卡 | 硬件自动 |
| HBM 显存 | 40–192 GB | 基线(1x) | 全卡 | 你管理(分配/释放/碎片) |
| CPU 主存(经 PCIe) | TB 级 | ~1/50 | 需显式拷贝 | 你的数据管线 |
- 陷阱一:合并访存(coalescing)。一个 warp 的 32 个线程若访问连续地址,合并为极少事务;若地址散乱,则每次都要独立事务,带宽利用率可能掉到 1/32。「让相邻线程读相邻内存」是 GPU 编程的第一原则。
- 陷阱二:bank conflict。Shared memory 分成 32 个 bank,同一 warp 内两个线程访问同一 bank 的不同地址就要串行化。矩阵转置是经典受害者——解决方式是用 padding 让行宽变成 33 而不是 32。
- 陷阱三:CPU↔GPU 同步拷贝。
tensor.cpu()/.item()/print(tensor)都会触发同步,让流水线断掉。训练循环里写.item()取 loss 是常见性能杀手——正确做法是累积到 GPU 上的标量,每隔 N 步同步一次。 - 显存碎片:PyTorch 的 caching allocator 缓解了大部分问题,但「逐步增大张量」的模式仍会导致碎片化 OOM。
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True是常见的止血手段。
pythonimport torch
# 反例:训练循环里每步都同步 —— 把异步的 GPU 流水线打断
for step, batch in enumerate(loader):
loss = model(batch)
train_log.append(loss.item()) # ❌ 每步都同步,GPU 空转等待
loss.backward(); opt.step(); opt.zero_grad()
# 正例:累积到 GPU 上,定期同步;用 pinned memory + 异步搬运重叠 IO
loss_acc = torch.zeros((), device='cuda')
for step, batch in enumerate(loader):
batch = batch.to('cuda', non_blocking=True) # 异步拷贝
loss = model(batch)
loss_acc += loss.detach() # 全程留在 GPU
loss.backward(); opt.step(); opt.zero_grad()
if step % 50 == 0:
train_log.append((loss_acc / 50).item()) # ✅ 每 50 步同步一次
loss_acc.zero_()
# 数据加载用 pinned memory + 多 worker,让 H2D 拷贝与计算重叠
loader = torch.utils.data.DataLoader(
dataset, batch_size=64, num_workers=8, pin_memory=True, persistent_workers=True)
python# pinned vs pageable 内存:一个常被忽略却常占大头的差异
import torch, time
n = 256 * 1024 * 1024 // 4 # 256MB 张量
cpu_plain = torch.empty(n) # 可分页(默认)
cpu_pin = torch.empty(n, pin_memory=True) # 锁页
def h2d(t):
torch.cuda.synchronize(); t0 = time.perf_counter()
t.to('cuda'); torch.cuda.synchronize()
return time.perf_counter() - t0
print('pageable H2D:', round(h2d(cpu_plain)*1e3, 2), 'ms')
print('pinned H2D:', round(h2d(cpu_pin)*1e3, 2), 'ms')
# 典型:pinned 比 pageable 快 2~3x,且 pinned 才能真正异步(non_blocking)
# PCIe 5.0 x16 单向上限 ~64 GB/s → 256MB 的理论下界约 4 ms
# 若 H2D 时间是理论值的 3~5 倍,多半没开 pinned 或没做重叠
| GPU 存储层 | 延迟(约) | 带宽(约) | 编程影响 |
|---|---|---|---|
| 寄存器 | ~1 cycle | 聚合 ~20 TB/s | 寄存器越多 → occupancy 越低 |
| Shared / L1 | ~30 cycle | ~100+ GB/s/SM | bank conflict 会降 32 倍 |
| L2 | ~200 cycle | ~10 TB/s 级 | 全卡共享,容量数十 MB |
| HBM | ~400–800 cycle | 3.35 TB/s(H100) | 合并访存是命门 |
| PCIe H2D | μs 级 | ~64 GB/s(Gen5 x16) | 必须 pinned + 异步重叠 |
6.3 Tensor Core、混合精度与硬件加速单元
Tensor Core 是专门为矩阵乘累加(MMA)设计的硬件单元。它能在若干周期内完成一个 16×16×16 的矩阵乘累加——这是「AI 算力爆炸」的直接来源。理解它的约束条件,才能解释为什么某些 shape 跑得飞快、某些却慢得离谱。
- 它只做特定形状的 MMA:Tensor Core 对矩阵维度有对齐要求(常见是 8 或 16 的倍数)。这就是为什么「把 hidden size 调到 16 的倍数」能让训练快不少——不是玄学,是硬件约束。
- 它支持的低精度比 CUDA core 多:bf16 / fp16 / tf32 / fp8 / int8 都有对应通路。所以混合精度不只是省显存,更是换到更快的计算通路。
- tf32 是「零成本提速」的典型:保持 fp32 的指数范围,只把尾数砍到 10 位。在 Ampere 及以后默认开启(PyTorch 里
torch.backends.cuda.matmul.allow_tf32 = True),矩阵乘能快 2~3 倍,而多数任务精度损失可忽略。 - 算力数字要看清位宽标注:厂商标称的「1000 TFLOPS」通常是 bf16 / fp8 稀疏下的数字。实际 fp32 密集算力可能只有几十分之一。看算力参数一定要对齐「位宽 + 是否稀疏」,否则会严重高估。
python# 不同精度下的 GEMM 吞吐:Tensor Core 的低精度通路有多快
import torch, time
def gemm_tflops(dtype, n=8192, iters=50):
a = torch.randn(n, n, device='cuda', dtype=dtype)
b = torch.randn(n, n, device='cuda', dtype=dtype)
for _ in range(3): a @ b # 预热(含 cudnn autotune)
torch.cuda.synchronize(); t0 = time.perf_counter()
for _ in range(iters): a @ b
torch.cuda.synchronize()
dt = (time.perf_counter() - t0) / iters
return 2 * n**3 / dt / 1e12
torch.backends.cuda.matmul.allow_tf32 = False
print('fp32 :', round(gemm_tflops(torch.float32), 1), 'TFLOPS') # ~30~60(H100)
torch.backends.cuda.matmul.allow_tf32 = True
print('tf32 :', round(gemm_tflops(torch.float32), 1), 'TFLOPS') # 开启后 ~400+
print('bf16 :', round(gemm_tflops(torch.bfloat16), 1), 'TFLOPS') # ~600~900
# 结论:同一张卡,bf16 密集算力约是 fp32 的 10~16 倍;
# 但这是 GEMM(compute-bound);换成一个 memory-bound 的逐元素算子,
# bf16 只省带宽,算力优势几乎体现不出来
- 一个 warp 的 32 线程走 2 条分支路径,最坏效率是多少?如何避免?
- 为什么降低 register 用量会提高 occupancy?但高 occupancy 为什么不一定更快?
- coalescing 是什么?一个 warp 访问步长为 32 个 float 的地址,带宽利用率约多少?
- Tensor Core 对矩阵维度有何要求?由此解释「hidden 取 16 的倍数更快」。
- 为什么训练循环里调用 .item() 会拖慢?给出正确写法。
- 判据 1:两条路径串行执行,每条路径上另一半线程被 mask 掉 → 最坏效率 ≤ 50%(实际上是 1/路径数)。避免:用 select/where 替代分支、让同 warp 处理同质数据、把分支提到 warp 以上粒度。
- 判据 2:寄存器是 occupancy 的硬约束之一,用量减少 → 每 SM 能驻留更多 warp → 更能隐藏延迟。但 FlashAttention 类 kernel 故意高寄存器换少访存,occupancy 30% 也更快;目标是「够隐藏延迟」。
- 判据 3:coalescing = warp 内相邻线程访问相邻地址,合并为极少内存事务。步长 32 float = 128 字节,每个线程落进不同 cache line,32 条 line 只用 4 字节 → 利用率约 4/128 ≈ 3%。
- 判据 4:Tensor Core 的 MMA 要求维度是 8 或 16 的倍数;hidden 不是 16 的倍数时需要 padding 到对齐或退化,导致有效算力下降。同类还有 head_dim、序列分块、卷积通道的对齐。
- 判据 5:.item() 触发 device→host 同步拷贝,会阻塞异步流水、让 GPU 空转等 CPU。正确写法:把 loss 累积到 GPU 上的标量(loss_acc += loss.detach()),每 N 步再同步一次。
7. 性能剖析与容量规划(里程碑 M2)
学习路径
- 读 7.1:按「测 → 定位 → 建模 → 预测 → 改 → 再测」的顺序走
- 跑内置代码,用 torch.profiler 导出时间线并读 kernel 耗时
- 完成动手练习:给一个真实算子定位受限资源并报出带宽/算力利用率
- 对接 M2:用探针输出一份 kernel 级耗时表与融合建议
核心知识点详解
- 按顺序走:测量→定位→建模→预测→实施→再测。工具顺序:dmon 看宏观 →
torch.utils.bottleneck看构成 → profiler 看 kernel → ncu 看受限资源。 - 带宽利用率判 memory-bound:achieved/peak bandwidth >80% 且算力利用率低 → 带宽瓶颈,优化方向是减少访存而非加并行。
- 用 profiler 读 kernel:
torch.profiler+export_chrome_trace看 kernel 级耗时与哪些小 kernel 可融合;Memory Snapshot 可视化显存碎片。
学习路径
- 读 7.1:背下「预热、多次取分位数、固定变量」三条纪律
- 跑内置代码,对比单次无预热 vs 预热 + 中位数,看出噪声量级
- 完成动手练习:对一次优化出具改前 / 改后对比并排除噪声
- 对接 M2:报告里所有性能数字按该纪律给出中位数与 P95
核心知识点详解
- 必须预热:torch.compile / Triton 编译、CUDA module 加载、cudnn autotune 都需要热身,首次运行无参考价值(可能被拖成 10x)。
- 多次取中位数 / P95:单次测量的噪声可能比要测的差异还大。取中位数压尾噪、报 P95 留上限,否则结论不可信。
- 固定变量:同一机器 / 数据 / 随机种子、关闭其他负载、每次只改一个变量否则无法归因。「改前 vs 改后」没对比等于没做。
学习路径
- 读 7.2:把「能不能跑、能跑多快」变成可计算的函数
- 跑内置代码,跑通 capacity_model 并跑几个典型配置建立量级感
- 完成动手练习:先算再跑,预测 batch=32/seq=8k 时 70B 能否放进 192GB
- 对接 M2:交付容量模型函数并写进代码带单测、结论可复现
核心知识点详解
- decode 上限公式:
tok/s = 带宽 × eff / 每步需读字节,每 token 至少读一遍权重。eff≈0.75为经验效率(memory-bound 常用标称宽度 70~85%)。 - 理论与实测差额:理论 tok/s × 40%~70% ≈ 线上实测,差额来自批调度、前缀未命中、长尾请求。能把差额归因到某因素就是高级容量规划能力。
- 先算再跑:写代码之前就估算显存与吞吐,把
capacity_model()写进代码、带单测、参数变自动重算——文档会过期,代码不会。
学习路径
- 完成动手练习:对照 7.3 自测逐条核验我能否同步给出估算与实测
- 完成动手练习:把「估算 vs 实测」差额讲清、并把差额归因到某因素
- 对接 M2:按 done 判据交付——任意慢算子能定量解释并含一项已验证优化
核心知识点详解
- 改前 vs 改后:每项优化必须有改前/改后量化数字与中位数、P95,无对比等于没做。
- 估算 vs 实测双线:同时给出估算与实测并把差额归因到某因素(激活值 / 碎片 / 框架开销),能自证「先算再跑、改有对比」即过关。
- 任慢算子可解释:M2 判据:任意慢算子能定量解释其受限资源,并含至少一项已实测验证的优化。
7.1 度量方法:不要猜,要测
性能工作最大的浪费是「凭直觉优化」。正确的顺序永远是:测量 → 定位瓶颈 → 建立模型 → 预测收益 → 实施 → 再测量。跳过前两步的优化,通常是在优化不重要的地方。
| 层次 | 工具 | 看什么 |
|---|---|---|
| 端到端耗时 | time / py-spy / 自建计时 | 哪一段占了大头(先粗后细) |
| Python 层热点 | cProfile / py-spy / line_profiler | 哪个函数调用最多、最耗时 |
| GPU 算子级 | torch.profiler / nsight systems | kernel 数量、每个 kernel 的耗时与占用率 |
| 硬件计数器 | ncu / nsight compute(或 rocm 对应工具) | 实际带宽利用率、L2 命中率、warp stall 原因 |
| 系统级 | nvidia-smi dmon / nvtop / iostat / sar | GPU 利用率、显存、PCIe、磁盘 IO |
| 基准测试 | JMH 思想:预热 + 多次迭代 + 统计分位数 | 不要用单次跑分下结论 |
bash# 一套可直接抄的性能排查流程
# 1) 先看宏观:GPU 到底有没有在干活?利用率低说明瓶颈在数据侧或同步点
nvidia-smi dmon -s pucm -d 1
# 2) 再看整体耗时构成
python -m torch.utils.bottleneck train.py # 同时给出 CPU 与 CUDA 视角
# 3) 定位到 kernel 级:谁最耗时、有多少个小 kernel 可以融合
python -c "
import torch; from torch.profiler import profile, ProfilerActivity
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True, profile_memory=True) as prof:
run_one_epoch()
print(prof.key_averages().table(sort_by='cuda_time_total', row_limit=20))
prof.export_chrome_trace('trace.json') # 用 chrome://tracing 打开看时间线
"
# 4) 用带宽利用率判断是不是 memory-bound(关键指标)
# achieved_bandwidth / peak_bandwidth > 80% 且算力利用率低 → 就是带宽瓶颈
# 此时优化方向:减少访存(融合/分块/量化),而不是加并行度
# 5) 记录「改前 vs 改后」:没有对比数字的优化等于没做
# 每次只改一个变量,否则归因不了
python# 一个反例:不预热 + 单次测量会骗你
import torch, time, statistics
def bad_bench(fn):
t0 = time.perf_counter(); fn(); return (time.perf_counter()-t0)*1e3
def good_bench(fn, warmup=10, iters=100):
for _ in range(warmup): fn() # 预热:CUDA module 加载 / autotune / Triton 编译
torch.cuda.synchronize()
ts = []
for _ in range(iters):
torch.cuda.synchronize(); t0 = time.perf_counter()
fn(); torch.cuda.synchronize()
ts.append((time.perf_counter()-t0)*1e3)
return statistics.median(ts), max(ts)
x = torch.randn(4096, 4096, device='cuda')
f = lambda: x @ x
print('单次(无预热):', round(bad_bench(f), 2), 'ms') # 常被首次 launch 拖成 10x
med, worst = good_bench(f)
print('中位数:', round(med, 2), 'ms P100:', round(worst, 2), 'ms')
# 规矩:预热 + 多次 + 中位数/P95 + 固定变量,否则量到的多半是噪声
7.2 建立容量模型:从「试试看」到「我算得出来」
这一节是本阶段的产出目标。有了前六节的知识,你现在可以先算再跑:在写第一行代码之前,就估算出显存够不够、吞吐大概多少、瓶颈会在哪。这个能力在面试与工程评审中极其值钱——它把讨论从「我觉得」变成「按公式应该是」。
python# Hamauls Orion 容量模型骨架:把「能不能跑」变成可计算的函数
def capacity_model(
params_b: float, # 参数量(十亿)
layers: int, hidden: int, n_kv_heads: int, head_dim: int,
seq: int, batch: int,
weight_dtype_bytes: int = 2, # bf16
kv_dtype_bytes: int = 2,
training: bool = False,
gpu_mem_gb: float = 80, hbm_bw_gbs: float = 3350,
):
GB = 1024 ** 3
m = {'weights_GB': params_b * 1e9 * weight_dtype_bytes / GB}
m['kv_cache_GB'] = (2 * layers * n_kv_heads * head_dim * seq * batch
* kv_dtype_bytes) / GB
if training:
m['grads_GB'] = m['weights_GB'] # 梯度 ≈ 权重
m['optimizer_GB'] = params_b * 1e9 * 8 / GB # Adam: fp32 m + v
m['activations_GB'] = (batch * seq * hidden * layers * 2) / GB # 粗略,与 batch×seq 成正比
else:
# 解码吞吐:每 token 至少要读一遍权重 → 理论 token/s 上限
bytes_per_decode_step = m['weights_GB'] * GB + m['kv_cache_GB'] * GB
m['decode_tokens_per_s_ceiling'] = round(hbm_bw_gbs * 1e9 / bytes_per_decode_step, 1)
# 预填充阶段更接近 compute-bound,规模约为 2×参数量×token 数
m['prefill_tflops_for_seq'] = round(2 * params_b * seq / 1000, 2)
m['total_GB'] = round(sum(v for k, v in m.items()
if k.endswith('_GB') and 'per' not in k), 2)
m['fits_in_one_gpu'] = m['total_GB'] <= gpu_mem_gb
return m
# 跑几个典型配置,建立数量级直觉
for name, kw in [
('7B 推理 4k×1', dict(params_b=7, layers=32, hidden=4096, n_kv_heads=8, head_dim=128, seq=4096, batch=1)),
('7B 推理 4k×32', dict(params_b=7, layers=32, hidden=4096, n_kv_heads=8, head_dim=128, seq=4096, batch=32)),
('70B 推理 8k×1', dict(params_b=70, layers=80, hidden=8192, n_kv_heads=8, head_dim=128, seq=8192, batch=1)),
('7B 训练 2k×8', dict(params_b=7, layers=32, hidden=4096, n_kv_heads=8, head_dim=128, seq=2048, batch=8, training=True)),
]:
r = capacity_model(**kw)
print(f"{name:16s} 总显存 {r['total_GB']:7.2f} GB 单卡可容: {r['fits_in_one_gpu']}"
+ (f" 解码上限 {r['decode_tokens_per_s_ceiling']} tok/s" if 'decode_tokens_per_s_ceiling' in r else ''))
- 先用模型估算,再用实测校准:模型给你方向和量级,实测给你修正系数。两者差距很大时,说明你漏了某个因素(通常是激活值、碎片、或框架开销)。
- 关键结论要能一句话说清:比如「70B bf16 单卡 80G 放不下,需要 2 卡张量并行或 INT4 量化」「batch 从 1 加到 32 时 KV Cache 涨 32 倍,是长上下文场景的显存主因」。
- 把模型写进代码而不是文档:Hamauls Orion 里
capacity_model()是一个真实函数,带单测,参数变了会自动重算。文档会过期,代码不会。
python# 用实测反推「实际可用带宽」,再预测别的算子 —— 从公式到工程
def predict_decode_tok_s(params_b, weight_bytes, kv_gb, bw_gbs, eff=0.75):
# eff:经验效率,多数 memory-bound 算子只用到标称带宽的 70~85%
w_gb = params_b * 1e9 * weight_bytes / 1e9 # 权重体积 (GB)
per_step = (w_gb + kv_gb) * 1e9 # 每步需读的字节
return bw_gbs * eff * 1e9 / per_step
# 7B 模型在 H100(3.35 TB/s)上的解码上限:
print(round(predict_decode_tok_s(7, 2, 0.5, 3350), 0), 'tok/s') # ~110 量级
print(round(predict_decode_tok_s(7, 1, 0.5, 3350), 0), 'tok/s') # FP8 → 约 2x
print(round(predict_decode_tok_s(7, 0.5, 0.5, 3350), 0), 'tok/s') # INT4 → 约 3x
# 结论:预测量级很准、绝对值有偏差 —— 用实测校准系数,是工程容量的正确用法
- 基准测试三纪律是什么?各为什么重要?
- 用 roofline 判断你的算子是否 memory-bound,需要哪两个实测指标?
- 写出 7B 模型全参微调的显存估算(按 16 字节/参数),并说明 LoRA 为什么能救它。
- 一次优化改动后如何证明收益不是噪声?至少列出三条要求。
- 先算再跑:预测 batch=32、seq=8k 时 70B(bf16)能否放进 192GB 的 B200。
- 判据 1:预热(规避 JIT/autotune 干扰)、多次取分位数(压噪声)、固定变量(可归因);缺任一条结论都不可信。
- 判据 2:需要 achieved bandwidth ÷ peak bandwidth 与算力利用率两个数;带宽利用率高而算力利用率低 → memory-bound。
- 判据 3:7B × 16~20 B ≈ 112~140 GB(权重 2 + 梯度 2 + Adam 8 + 主权重 4 + 激活/缓冲);LoRA 只训练低秩增量、冻结主干,优化器状态与梯度从「全参」降到「增量」量级,可把显存压到几十 GB。
- 判据 4:多次迭代取中位数、报告 P95、同一硬件与数据、单变量改动、给出改前改后数字;最好做显著性(多次运行的方差)。
- 判据 5:权重 70×2 = 140 GB;KV = 2×80×8×128×8192×32×2 B ≈ 80 GB;合计 ≈ 220 GB > 192 GB → 放不下,需 FP8/INT4 量化或张量并行。
项目里程碑
用计算机组成原理的知识,给 Hamauls Orion 的推理与训练链路做一次真实的性能剖析:找出瓶颈是算力、带宽还是延迟;核算 KV Cache 与权重显存;建立「单卡能跑多大规模、多少并发」的容量模型。这份报告将决定后面所有架构选择。
本阶段产出(直接进入项目仓库)docs/perf/roofline.md:对 matmul / attention / layer-norm 三个算子做 roofline 分析,标出算术强度与 bound 类型scripts/bench_memory.py:实测显存占用曲线,推导 KV Cache 公式并与实测对比(误差 <15%)docs/perf/latency-budget.md:把一次端到端请求拆成网络 / 排队 / 预填充 / 解码四段,给出每段目标预算- cache 友好改造:把检索阶段的热点数据结构改成结构体数组(SoA)布局,实测提速并记录数字
阶段练习项目
- 对 512MB+ 以上的数组给出顺序 vs 随机的实测加速比(目标 ≥5x,越大越好)
- 跑通四种实验并各输出一张带中位数 / P95 的耗时对照表
- 每个差异都能用「cache line / 局部性 / 冲突 miss」写出一句定性的硬件归因
- 用 C 或 Numba 写朴素三重循环(NumPy 内部优化会掩盖差异,需用编译型路径复现)
- AoS vs SoA 用 numpy structured dtype 与列式数组各跑一次 top-k 排序并对比耗时
- 矩阵转置实验包含「行宽 32 vs 33 padding」两组,量化冲突 miss 的影响
- 所有计时均带预热、多次迭代取中位数与 P95,报告运行环境(CPU/L1/L2 容量)
scripts/bench/cache_bench.py(或 .c/.ipynb)与对比表(Markdown)- 一份「四个差异 + 硬件归因」的说明文档
不做分布式/多机缓存实验;不写自定义 CUDA kernel(那是第 6 章 GPU 的范畴)。
- 四种格式都能手动拆出符号位 / 指数位 / 尾数位并反向还原,误差为 0(对精确表示的输入)
- 复现
0.1+0.2 != 0.3与 1000 万元素累加误差,并用 Kahan 把误差压回 1e-6 级 - 跑通 fp16 溢出成 inf、bf16 只失精度、fp8 逐张量缩放三个实验,各输出可复现的数字
- 转换器用位运算 / struct 实现并提供区间边界校验(如 bf16 最大值、fp8 的 448)
- 必须实现「判断一个小数能否被某格式精确表示」的函数并给出判据说明
- 累加误差实验对比朴素逐项 / Kahan / pairwise 三种求和的误差量级
- 每个函数配至少一个单测(
pytest),覆盖正常值、边界值(0 / 最大 / inf)
scripts/float_tools/(转换器 + 实验脚本 +tests/单测)- 一份实验结果表(误差数据 + 截图或输出)
不做任意精度十进制库;不覆盖 NaN / denormal 的完整 IEEE 细节(够工程用即可)。
- 5 个算子各得到一个实测 FLOP/s 与算术强度,并都正确标到 compute / memory 一侧
- roofline 图上能看出 H100 拐点(≈295)与本机拐点的位置差异
- 每个 memory-bound 算子给出至少 1 个具体优化方向并讲清「为什么有效」
- 用
torch.profiler/ ncu(或 rocm 等价工具)读取真实带宽与算力利用率作为输入 - 把「FLOP 数」写清楚:GEMM=2N³、向量加/N、LayerNorm≈5N,标明来源避免臆造
- 支持命令行传入 H100 / A100 / 本机等配置以切换拐点与峰值参数
- 输出 roofline 图(matplotlib 或 ASCII)与一张含 bound 判定的汇总表
scripts/perf/roofline.py+ 生成的roofline.png/roofline.md- 一份逐算子的 bound 判定与优化建议清单
不做跨机 / 多卡 roofline;不实现 Attention 的 FlashAttention 手写 kernel。
- 对任意一段模型代码一键产出 kernel 级耗时 Top-N 表(含占比与累计)
- 从 profiler 输出推断出至少 2 处可融合的小 kernel(如 layernorm+add+gelu)
- 扫出
tensor.item()/print(tensor)/ 未non_blocking的同步点并输出清单
torch.profiler开启 CUDA/CPU 活动,用export_chrome_trace导出时间线- 融合建议需给出「融合后理论访存从 N 遍降到 2 遍」的量化推演
- 同步点检测基于 profiler 的
cpu_time与cuda_time差距 / 显式.item()扫描 - 代码带参数化入口(
--model/--iters/--warmup),默认预热 + 多次取中位数
scripts/perf/probe.py+ 一份样例输出(kernel 表 + 融合建议 + 同步点清单)- 一个针对给定算子的实际探查报告(改前 vs 改后的对比优先)
不做 Nsight Compute 的硬件计数器级分析(那是可选的深入项);不做自动代码改写。
常见误区
- 把「位宽减半」等同于「速度翻倍」:省显存和带宽是确定的,算力是否翻倍取决于硬件是否有对应通路。
- 在训练循环里写 `.item()` 或 `print(tensor)`,无意中让 GPU 每步都同步等待。
- 只优化占比 5% 的代码路径(Amdahl 定律的直接体现),却忽略了真正的热点。
- 看到「慢」就加并行度,但代码其实是 memory-bound —— 加并行只会让带宽更拥挤。
- 用单次跑分下结论,不做预热、不取分位数、不固定其他变量。
- 把 bf16 当成「便宜的 fp32」用来存优化器状态和主权重,导致细小更新被舍入掉、训练停滞。
- 忽略非连续张量与隐式广播带来的拷贝,让访存量凭空翻几倍。
- 只看厂商标称的峰值算力(往往是 fp8 稀疏),严重高估机器真实能力。
面试高频问题速答
为什么大模型推理的解码阶段通常不是算力瓶颈?
因为解码是 memory-bound:每生成一个 token,都要把全部权重(以及全部 KV Cache)从 HBM 读一遍,但只做 2×参数量 次浮点运算,算术强度只有约 1 FLOP/Byte,远低于 roofline 拐点(H100 上约 299)。所以解码速度 ≈ 显存带宽 ÷ 每步需读取的字节数。推论:① 权重量化(INT8/FP8/INT4)能近似线性提速;② 增大 batch 能摊薄权重读取成本、提高吞吐(但会拉高延迟);③ 投机解码用少量额外算力换更少的权重读取轮次。
bf16 和 fp16 都是 16 位,为什么训练一般选 bf16?
位宽分配不同:bf16 用 8 位指数 + 7 位尾数,动态范围和 fp32 一样(±3.4e38);fp16 用 5 位指数 + 10 位尾数,最大值只有 65504,训练中稍大的激活或梯度就会溢出成 inf,因此必须配 loss scaling。bf16 范围安全、开箱即用,代价是尾数精度低(约 2.4 位十进制),但梯度更新本身是统计量,对这点精度损失不敏感。注意无论用哪种,主权重与优化器状态都应保持 fp32。
什么是 roofline 模型?怎么用它指导优化?
roofline 用「算术强度 = FLOP / 访存字节」为横轴、「性能 = FLOP/s」为纵轴,画出一条由两条线构成的上界:低强度区受内存带宽限制(斜率为带宽),高强度区受峰值算力限制(水平线),拐点 = 峰值算力 ÷ 带宽。用法:先算出目标算子的算术强度,判断它落在哪一侧。若在带宽侧,优化方向是减少访存(算子融合、分块、量化、增大 batch);若在算力侧,才考虑更好的矩阵形状对齐、低精度加速、以及分布式切分。
Cache 为什么能起作用?什么情况下会失效?
靠局部性:时间局部性(刚访问的数据很可能再被访问)与空间局部性(相邻地址很可能被一起访问,配合 64 字节 cache line 预取)。失效情形有三类 miss:compulsory(首次访问不可避免)、capacity(工作集大于缓存容量)、conflict(映射冲突)。对应手段分别是:无解 / 分块(tiling)缩小工作集 / padding 打散冲突。判断代码是否 cache 友好,看它的访存步长与工作集大小是否匹配缓存容量。
GPU 的 warp 分支发散是什么?如何避免?
一个 warp(通常 32 线程)锁步执行同一条指令。若 warp 内线程走了不同分支,硬件会把每条路径各执行一遍并用 mask 屏蔽不参与的线程,最坏情况效率降到 1/32。避免方式:① 用「选择运算」替代分支(如 `where` / 位运算);② 让同一 warp 内线程处理同质数据(数据重排 / 分桶);③ 把分支提到 warp 粒度之上(不同 warp 走不同路径没关系)。这也是为什么排序后再做分支判断会更快——数据变得同质了。
训练一个 7B 模型大概需要多少显存?怎么算?
粗略经验是参数量 × 16~20 字节。拆开算(bf16 混合精度):权重 2 B/参数、梯度 2 B/参数(bf16)或 4 B(fp32)、Adam 的 m 和 v 各 4 B(fp32)、再加上主权重副本 4 B —— 仅优化器与权重相关部分就约 16 B/参数,即 7B × 16 B ≈ 112 GB;再叠加激活值(与 batch×seq×hidden×layers 成正比,可用梯度检查点大幅压缩)与通信缓冲。所以 7B 全参微调在单张 80G 卡上跑不动,需要张量并行/ZeRO 或改用 LoRA / QLoRA。
为什么把 hidden size 设成 16 的倍数会更快?
因为 Tensor Core 的 MMA 指令对矩阵维度有对齐要求(常见 8 或 16)。维度不是 16 的倍数时,要么无法使用 Tensor Core 通路、要么需要 padding 到对齐后计算,导致有效算力下降。同理还有:head_dim 对齐、attention 的序列长度分块对齐、以及卷积的通道对齐。这不是玄学参数调优,而是硬件约束的直接后果。