← 返回学习路线 ◆ 贯穿项目
计算机与工程基础 · 阶段 2 · 计算机组成原理
Stage 02 / 17 · 计算机与工程基础

计算机组成原理 Computer Organization & Architecture

这一阶段看起来「不像 AI」,但它是你后面所有性能工作的物理底座。当你问「为什么这个 attention 实现比那个慢 3 倍」「为什么 batch 加到 64 就 OOM」「为什么 FP8 能省一半显存却几乎不掉点」,答案全在这里。不补这一课,你调优就只能在「换个库试试」的层面上碰运气;补上之后,你能算出理论上限、定位到具体受限资源、并预判一个优化能带来几倍收益。

⏱ 6–8 周 🎯 入门–进阶 ◆ 里程碑 M2 2026-09-29
IEEE 754bf16/fp8Cache流水线内存墙RooflineSIMTTensor CoreProfiling

阶段总览

✔
学完你能做到
  • 把任意一个十进制小数写成 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 改后」的量化对比
阶段知识结构总览 · 从数据表示到容量模型的性能直觉
阶段 2 · 计算机组成原理Computer Organization & Architecture · 7 大章 · 125 个知识点
1. 数据表示与浮点精度补码与溢出bf16 / fp16 / fp8灾难性抵消loss scaling量化误差 scale/2
2. 指令执行与 CPU 微架构五级流水线数据 / 控制冒险分支预测 95%乱序执行 ROBbranchless
3. 存储层次与 Cache访问延迟数量级cache line 64B分块 tilingAoS vs SoAfalse sharing
4. 内存墙与 Roofline算术强度 FLOP/Byte拐点 ≈ 295KV Cache 公式算子融合PCIe 隐形势
5. 并行体系结构Amdahl 定律DP / TP / PP / EPSIMD 车道向量化改写Gustafson
6. GPU 体系结构SIMT / warp 32分支发散coalescingbank conflictTensor Core
7. 性能剖析与容量规划profiler 工具链基准三纪律容量模型decode 上限M2 报告
贯穿项目 · M2 性能剖析与容量模型第 7–11 周对 matmul / attention / layer-norm 三个算子做 ro…实测显存占用曲线,推导 KV Cache 公式并与实测对比(误差 <15%)把一次端到端请求拆成网络 / 排队 / 预填充 / 解码四段,给出每段目标预算把检索阶段的热点数据结构改成结构体数组(SoA)布局,实测提速并记录数字
学习路径
周次主题动手产出
第 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 的性能报告与显存容量模型
✔
有 10 年 Java 背景的人怎么走:你的 JVM 经验迁移度出乎意料地高:JIT 与分支预测、对象布局与 cache line(false sharing)、GC 与内存层级、JMH 基准测试方法论——这些直接对应本阶段的一半内容。你需要真正补的是三块:① 浮点与低精度数值(Java 里几乎只用 double,而 AI 里 bf16/fp8 是日常);② GPU 的 SIMT 模型(与 CPU 的线程模型是两套直觉);③ roofline 这种定量分析框架(而不是「压测看看」)。预计 4 周可完成。

1. 数据表示与浮点精度

知识结构图 · 数据表示与浮点精度
数据表示与浮点精度3 大知识域 · 14 个知识点
整数与溢出补码减法即加法int8 对称量化scale = a/127int32 静默回绕索引一律 int64
学习路径
  1. 读 1.1:跟着代码理解补码「减法即加法」与 int32 静默回绕
  2. 跑内置代码,复现 int32 溢出回绕为负数的真实例子
  3. 完成动手练习:手写 sym_quant 对称量化,报出 scale 与量化误差上限
  4. 对接 M2:在 bench_memory 与索引计算里一律用 int64 防溢出
✔ 能不查文档写出 int8 对称量化并讲清 scale=a/127 与误差上限为 scale/2
核心知识点详解
  • 补码把减法变成加法:整数减法 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 越细,所以逐通道量化优于全局量化。
浮点格式IEEE 754 位宽预算bf16 偏范围 / fp16 偏精度E4M3 vs E5M2fp16 需 loss scalingε ≈ 1.19e-7
学习路径
  1. 读 1.2:用位运算拆开一个 float32,看懂符号/指数/尾数预算
  2. 跑内置代码,观察 fp16 溢出成 inf 而 bf16 只失精度的差异
  3. 完成动手练习:判断某训练任务该用 bf16 还是 fp16、优化器状态为何要 fp32
  4. 对接 M2:在选择算力上限时对齐「位宽 + 是否稀疏」的标注
✔ 能手写浮点转换并判断一个训练任务该用哪种精度、为何 fp16 要 loss scaling
核心知识点详解
  • 位宽是预算:指数位定动态范围、尾数位定精度,二者互相挤占。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 先减最大值的原因。
精度陷阱0.1+0.2 ≠ 0.3累加误差 Kahan灾难性抵消逐张量缩放 FP8
学习路径
  1. 读 1.2:理解十进制小数在二进制里是无限循环、必然被截断
  2. 跑内置代码,复现 0.1+0.2 ≠ 0.3 与 1000 万元素累加误差爆炸
  3. 完成动手练习:复现灾难性抵消并解释 softmax 为何要先减最大值
  4. 对接 M2:用逐张量缩放实现 FP8 前向,验证小值不会被压成 0
✔ 能复现累加误差并用 Kahan / pairwise 把它压回去,讲清抵消的机制
核心知识点详解
  • 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 中的典型用途
uint880 ~ 255量化权重、图像像素、token 字节级压缩
int88−128 ~ 127对称量化的权重/激活
int3232±2.1e9张量索引、步长、绝大多数 shape 计算
int6464±9.2e18超大张量索引、token 总数统计、时间戳
bool/uint8 mask80/1attention mask(注意:1 bit 的信息占 1 byte)
⚠
两个高频坑:① 把 mask 存成 float32:一个 [1, 32, 4096, 4096] 的 float mask 要 2 GB,改成 bool 只要 512 MB,而它往往只在 softmax 前用一次。② stride 溢出:当 batch × seq × hidden 超过 2^31 时,某些框架的 int32 步长计算会出错,表现为结果错乱而不是崩溃。看到「加长序列后结果莫名不对」,先查这里。
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 更细

1.2 IEEE 754 与低精度数值:bf16 / fp16 / fp8 的真实差别

这是本阶段最直接影响 AI 工作的一节。浮点数的位宽分配决定了它「能表示多大的数」和「能表示多精细的数」——这两件事是互相挤占的。理解了这个取舍,你才能回答「为什么 bf16 训练稳、fp16 训练容易炸」「为什么 fp8 要分 E4M3 和 E5M2 两种」。

格式总位宽符号指数尾数动态范围最小正规数有效十进制位
fp32321823±3.4e381.2e−38≈ 7.2
tf3219(存32)1810±3.4e38(同 fp32)同 fp32≈ 3.3
bf1616187±3.4e38(同 fp32)同 fp32≈ 2.4
fp16161510±655046.1e−5≈ 3.3
fp8 E4M38143±4481.95e−3≈ 1.3
fp8 E5M28152±573441.5e−5≈ 1.1
int881(符号)—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(精度安全)
★
一句话记住这节:位宽是预算,指数和尾数是两个互相竞争的支出项。范围要够(指数位)、精度要够(尾数位),二者不可兼得 —— 这就是为什么会有 bf16(偏范围)、fp16(偏精度)、E4M3(偏精度)与 E5M2(偏范围)这几套并存的设计,也是为什么 fp8 训练需要「逐张量缩放」来动态贴合数值范围。
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
ℹ
2026 前沿:FP8 / FP4 已是训练与推理的默认选项:Hopper(H100/H200)原生支持 E4M3 与 E5M2 两种 FP8;Blackwell(B200/GB200)进一步把 FP4(NVFP4,逐块 16 元素 + 二级缩放) 做进 Tensor Core,官方宣称 FP4 预训练的 loss 与 bf16 相当,吞吐再翻一倍。Llama 3 / Qwen3 都发布了 FP8 权重,DeepSeek-V3 更是全程 FP8 混合精度训练(671B 总参数、每 token 仅激活 37B),把显存与通信量同时压下来。结论:低精度在 2026 年不是「能省则省」,而是大模型工程的基本盘。
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 动手练习与自测

✔
练习目标:做完你应能:手写浮点转换、判断一个训练任务该用哪种精度、并解释「为什么要 loss scaling」与「为什么要逐张量缩放」。
  1. 把十进制 0.15625 写成 IEEE 754 单精度二进制,给出符号位、指数位(含偏移)、尾数位,并说明它为什么能被精确表示。
  2. 训练任务:7B 模型、长上下文 32k、要求范围安全且开箱即用。权重与激活选 bf16 还是 fp16?主权重与优化器状态呢?
  3. 对取值区间 [−0.5, 0.5] 的张量做 int8 对称量化,scale 与最大量化误差分别是多少?
  4. 用「灾难性抵消」解释 softmax 为什么必须先减去最大值。
  5. 把 attention mask 从 bool 改成 float32,会带来什么后果(显存与数值两方面)?
自测题关键结论 / 数字易错点
0.15625 的 IEEE7541.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 微架构

知识结构图 · 指令执行与 CPU 微架构
指令执行与 CPU 微架构3 大知识域 · 16 个知识点
五级流水线IF / ID / EX / MEM / WB结构冒险数据冒险 RAWload-use 停 1-2 拍前递 forwarding依赖链决定 IPC
学习路径
  1. 读 2.1:画出 IF→WB 五级流水线并标出三类冒险
  2. 跑内置代码,用 timeit 测浮点乘 / 除 / 累加的量级差异
  3. 完成动手练习:画 load-use 冒险时序、标出停顿拍数、说明前递为何消不掉它
  4. 对接 M2:把「token 解码串行链 = 流水线数据依赖」写进性能报告
✔ 能不查文档讲清三种冒险与 load-use 停顿、并量化一条依赖链的 IPC 上限
核心知识点详解
  • 五级流水线:IF→ID→EX→MEM→WB,理想每拍进 1 条指令(IPC=1)。三类冒险都会插气泡:结构(资源冲突)、数据 RAW(后继要前继结果)、控制(分支方向未知)。
  • 前递(forwarding):ALU 结果可旁路给下一条、消掉多数数据冒险。但 load-use 无法消除——load 结果要等 MEM/WB 才落地,紧接操作必须停 1~2 拍。
  • 依赖链决定 IPC 上限:一段代码最长依赖链长度 ÷ 发射宽度即理论最少周期数。打断依赖链往往比多加核更有效;LLM 解码的 token 串行即天然依赖链,只能靠 batch 摊薄。
分支与乱序预测准确率 95%+猜错清空 ~20 拍ROB 重排序缓冲超标量 IPC 4-6Spectre 侧信道branchless 快 1.5-3x
学习路径
  1. 读 2.2:理解分支预测 / 乱序 / 超标量并解释猜错要清空流水线
  2. 跑内置代码,对比有序 vs 随机数据上的分支判断耗时
  3. 完成动手练习:把随机分支改写成 branchless 并实测加速、说明适用边界
  4. 对接 M2:在容量模型里用「每 token 读一遍权重」解释 decode 上限
✔ 能用实测解释「有序快随机慢」、并给出 branchless 改写与其失效前提
核心知识点详解
  • 分支预测命中率:现代预测器准确率 95%+,但分支密集且数据随机时会失效,每次猜错清空流水线约 20 拍。有序数据快、随机数据慢就此而来。
  • 乱序执行与 ROB:超标量每拍发射多条、用重排序缓冲(ROB)乱序执行、顺序提交。窗口仅几百条 μop,依赖链太长仍会卡住;利用推测执行副作用的侧信道攻击即 Spectre/Meltdown。
  • branchless 是选择运算:用条件传送/位运算替代分支,预测器无压力。分支密集且随机时 branchless 快 1.5~3x;但数据有序时预测器本来就准,可能反而略慢——先看数据分布再决定。
对 AI 的启示token 解码串行链先向量化再多线程if-conversion 谓词化MoE 路由发散
学习路径
  1. 读 2.1:把数据依赖链、分支发散映射到 LLM 解码与 MoE 路由
  2. 跑内置代码,用向量化替代热路径里的 if 分支并测收益
  3. 完成动手练习:解释 LLM 解码为何不能并行,只能靠 batch 摊薄
  4. 对接 M2:按「先向量化 → 再布局 → 才多线程」的顺序优化数据管线
✔ 能把分支 / 依赖原理落到 token 串行与 MoE 发散的定量解释上
核心知识点详解
  • token 解码是串行依赖链:第 t+1 个 token 依赖第 t 个的输出,无法在同一请求内并行——对应流水线的数据/控制依赖,所以只能靠批处理(并行服务不同请求)摊薄。
  • 优化优先级:先向量化:先向量化(消灭分支)→再数据布局(局部性)→最后才多线程,顺序反了收益差一个数量级。MoE 路由是数据相关长分支的重灾区。
  • if-conversion 与谓词化:编译器(LLVM/GCC 条件传送)与 GPU predication 已自动消除简单分支,但数据相关分支仍会发散——这正是 MoE 推理要 capacity factor 与 token 重排的原因。
学习路径

2.1 指令集与五级流水线

CPU 提高性能的两条路:提高频率(受功耗墙限制,基本走到头了)和提高每周期执行的指令数(IPC)。流水线是把一条指令拆成多个阶段、让多条指令重叠执行——这是 IPC 的第一层来源。

取指 IF(Instruction Fetch)
按 PC 从 L1I 取指令;命中与否直接决定这一拍是否停等。
译码 ID(Instruction Decode)
解析操作码与操作数,读取寄存器。现代 x86 会把复杂指令拆成微操作(μop)。
执行 EX(Execute)
ALU 运算、地址计算。延迟差异极大:整数加 1 拍,整数乘 3 拍,浮点除法几十拍。
访存 MEM(Memory)
访问 L1D;未命中则多级 Cache 逐层找,最坏情况要访问主存(上百拍)。
写回 WB(Write Back)
把结果写回寄存器堆。

理想情况下五级流水线每拍完成一条指令(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 倍——差别就在「依赖链长度」和「访存局部性」
ℹ
为什么要给 AI 工程师讲流水线:因为「数据依赖链」这个概念贯穿全部性能工作:在 CPU 上是流水线停顿;在 GPU 上是 occupancy 不足导致的延迟暴露;在 LLM 解码上是 token 之间的串行依赖(无法并行,所以必须靠批处理摊薄)。同一个思想,三种表现形式。
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 计时

2.2 分支预测、乱序执行与超标量

现代 CPU 的实际 IPC 能到 4~6,靠的是超标量(一拍发射多条)+ 乱序执行(按数据就绪顺序执行)+ 推测执行(猜分支方向先跑)。这三者的共同代价是:一旦猜错,要回滚。

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 向量化后差异就消失了——
# 因为向量化根本没有分支。这就是「用数据布局换分支」的最简单例子。
✔
给你的实际抓手:在 Hamauls Orion 里,当你要写热路径的数据处理(token 过滤、长度分桶、mask 构造)时,记住这个优先级:先向量化(消灭分支)→ 再考虑数据布局(局部性)→ 最后才考虑多线程。顺序反了,收益差一个数量级。
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。
ℹ
2026 前沿:编译器与 GPU 已在自动做这件事:现代编译器(LLVM/GCC)会用 if-conversion 自动把简单分支转成条件传送;GPU 上 NVIDIA 的 warp-level primitives(__shfl / __ballot / __any)让「warp 内统一决策」成为可能,避免发散。到 CUDA 12 / Triton 时代,许多冗余分支已被 predication(谓词化) 消除。但数据相关的长分支仍是发散重灾区——如 MoE 路由、早期退出(early-exit)、投机解码的接受/拒绝判定。这正是 MoE 推理要专门做 capacity factor 与 token 重排的原因。
冒险类型触发条件典型代价消除手段
结构冒险硬件资源被占用1+ 拍分离指令/数据 Cache、多端口
数据冒险(RAW)后指令用前指令结果0~2 拍前递、编译器调度、寄存器重命名
load-use 冒险紧跟一次 load1~2 拍(无法完全消除)插入无关指令、软件预取
控制冒险分支方向未知猜对 0 拍 / 猜错 15~20+ 拍深度预测器、延迟分支槽、无分支化
★
2.3 动手练习与自测:把本节当作「CPU 为什么快 / 为什么卡」的基本盘,能答出下面各题的判据就算过关。
  1. 画出一条 load-use 数据冒险的 5 级流水线时序,标出需要插入的停顿拍数,并说明前递为什么消不掉它。
  2. 对 if (x > 0) 则累加、否则累减的代码:数据随机与数据有序两种情况下,预测器表现差多少?如何改写成 branchless?
  3. 为什么「IPC 从 3 提到 4」和「频率从 3GHz 提到 4GHz」对吞吐是等效的?各自的代价是什么?
  4. LLM 解码时 token 之间为什么不能并行?这对应流水线的什么概念?
  5. 若推理服务 90% 时间在 GPU 算、10% 在 CPU 预处理,用 Amdahl 说明优化 CPU 预处理最多能省多少。

3. 存储层次与 Cache:局部性就是性能

知识结构图 · 存储层次与 Cache
存储层次与 Cache4 大知识域 · 22 个知识点
层级与延迟L1 32-64KB ~1nsL2 ~4nsL3 8-64MB ~20nsDRAM ~100ns延迟 ≠ 带宽指针追逐基准
学习路径
  1. 读 3.1:记住各层容量 / 延迟数量级并分清「延迟 ≠ 带宽」
  2. 跑内置代码,用指针追逐实测 L1 / L2 / L3 / DRAM 延迟台阶
  3. 完成动手练习:顺序 vs 随机访问 512MB 数组并归因差异
  4. 对接 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。
Cache 组织cache line 64BN 路组相联write-back + write-allocate32KB 8 路 = 64 setcompulsory/capacity/conflict
学习路径
  1. 读 3.2:推算出 32KB / 8 路 L1D 的 set 数与地址划分
  2. 跑内置代码,算 cache line、映射方式与三类 miss 的来源
  3. 完成动手练习:用 padding 打散矩阵转置的冲突 miss 并测速
  4. 对接 M2:把 miss 类型判断套用到检索热路径的访存模式
✔ 能算出 set 数并判断一段代码遇的是哪种 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 简单但写流量大、带宽吃紧。
分块与循环序i-k-j 循环重排tiling BS=64padding 打散冲突FlashAttention 同源3·BS² ≤ L2
学习路径
  1. 读 3.2:理解 i-k-j 重排与 tiling 分块如何提升缓存命中
  2. 跑内置代码,对比直三重循环与分块矩阵乘的耗时
  3. 完成动手练习:定出 BS(3·BS² ≤ L2)并实测分块的加速比
  4. 对接 M2:把 tiling 归入「减少访存」并量化其收益
✔ 能指出分块与 FlashAttention 同源、并实测出分块带来的提速
核心知识点详解
  • 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——同一原理三次应用。能识别「这是分块问题」是本阶段最重要的收获。
数据布局AoS vs SoA先 SoA 筛选再聚合false sharing 对齐 64B.contiguous() 拷贝代价NCHW vs NHWCPagedAttention 分块
学习路径
  1. 读 3.3:分清 AoS vs SoA 各自面向的访存场景
  2. 跑内置代码,用 NumPy 对比 top-k 排序在 SoA vs AoS 下的耗时
  3. 完成动手练习:把检索候选从 dict 列表改列式数组并测加速
  4. 对接 M2:完成检索热点数据结构的 SoA 布局改造并记录数字
✔ 能在检索热路径完成一次 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 ns1编译器 / 调度器
L1 Cache32–64 KB 每核~1 ns3–4硬件
L2 Cache512 KB–2 MB 每核~4 ns12–15硬件
L3 Cache8–64 MB 共享~15–40 ns50–130硬件
主存 DDR564 GB–1 TB~80–120 ns250–400硬件 + 你
NVMe SSD1–8 TB~50–200 μs10 万+操作系统 / 你
网络(同机房)—~0.1–1 ms100 万级你的协议设计
跨地域网络—~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 是串行依赖,测的是「延迟」而非「带宽」——
#      这与吞吐型负载(顺序读大数组)看到的瓶颈完全不同,别混为一谈
★
延迟 ≠ 带宽,别搞反:很多「优化」之所以无效,是因为把两者搞混了:延迟决定「一次串行依赖要等多久」(指针追逐、图遍历、LLM 解码的 token 链),带宽决定「单位时间能搬运多少字节」(顺序读大张量、GEMM、prefill)。提高并行度能掩盖延迟,但会加剧带宽争抢。判断属于哪种,是选优化手段前的第一步。

3.2 Cache 的组织方式:line、set、映射与写策略

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 太大反而退化
★
分块(tiling)是理解 GPU 与 FlashAttention 的钥匙:「把大矩阵切成能放进快速存储的小块,让数据在被替换出去之前尽可能被重用」——这个思想在 CPU 上叫 cache blocking,在 GPU 上叫 shared-memory tiling,在 attention 上就是 FlashAttention 的分块在线 softmax。三者是同一个原理的三次应用。能识别出「这是一个分块问题」,是本阶段最重要的收获。
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 打散)
★
3.4 动手练习与自测:做完你应能:算 Cache 地址划分、判断 miss 类型、并解释分块为什么有效。
  1. 一个 64B line、8 路组相联、容量 32KB 的 L1D 共有多少 set?地址如何划分为 tag / set / offset?
  2. 顺序访问 vs 随机访问一个 512MB 数组,实测差多少倍?给出归因。
  3. 矩阵转置为什么慢?如何用 padding 消除冲突 miss?
  4. AoS 与 SoA 各适合什么场景?解释「先 SoA 筛选、再 AoS 聚合」的理由。
  5. 分块的块大小 BS 怎么定?给出经验公式并说明 BS 过大的后果。

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 字节对齐隔离
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 ——
#   这是向量检索、推荐召回、日志分析里通用的布局策略
ℹ
2026 前沿:KV Cache 的布局之争:vLLM 的 PagedAttention 把 KV Cache 按固定大小 block 管理(类似操作系统分页),解决了「预分配到底、浪费 60%+ 显存」的问题;SGLang 的 RadixAttention 进一步用前缀树复用共享前缀的 KV。二者的共同点是把「连续大张量」改成「分块 + 索引」,牺牲一点访存局部性,换来显存利用率与前缀命中率的大幅提升。这是 2026 年推理引擎吞吐差距的重要来源,也是「数据布局决定性能」在 AI 系统里最贵的应用之一。

4. 内存墙与 Roofline:判断瓶颈的定量框架

知识结构图 · 内存墙与 Roofline
内存墙与 Roofline4 大知识域 · 20 个知识点
RooflineAI = FLOP / Byte拐点 = 峰值/带宽H100 拐点 ≈ 295AI < 295 即带宽受限GEMM AI ≈ 1365GEMV AI ≈ 2
学习路径
  1. 读 4.1:写下算术强度公式与 roofline 上界的判定方法
  2. 跑内置代码,给 matmul / attention / layernorm 各算一版 roofline
  3. 完成动手练习:算 H100 拐点并判断 70B 解码为何 AI≈1
  4. 对接 M2:产出 docs/perf/roofline.md,逐个算子标 bound 与理论上限
✔ 能看到算子就当口报出算术强度、判定 memory/compute-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。解码速度 ≈ 带宽÷每步字节,这正是量化近似线性提速的原因。
内存墙与硬件DDR5 50-100 GB/sHBM3e 4.8 TB/sPCIe 5.0 ~64 GB/sNVLink ~900 GB/sPCIe 差 50 倍
学习路径
  1. 读 4.2:理解内存墙成因,记住 PCIe 与 HBM 的百倍量级差
  2. 跑内置代码,实测本机真实内存带宽并对比标称百分比
  3. 完成动手练习:判断数据管线是否卡在 PCIe 搬运并给优化方向
  4. 对接 M2:在 latency-budget 里把「带宽 / 搬运」记为一段预算
✔ 能区分 DDR5 / HBM / PCIe / NVLink 的量级并指出哪段会先到瓶颈
核心知识点详解
  • 内存墙成因: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 的主要收益来源。
显存核算KV = 2·L·H·S·d·bytes70B 4k KV ≈ 1.25GB70B bf16 权重 140GB训练 16-20 B/参数LoRA / QLoRA 的由来
学习路径
  1. 读 4.2:手推 KV Cache 公式与「权重 + 梯度 + 优化器 + 激活」拆解
  2. 跑内置代码,用 vram_budget 算 70B 推理显存并改参观察变化
  3. 完成动手练习:算 7B 全参微调显存、说明 LoRA 为何能救
  4. 对接 M2:交付 scripts/bench_memory.py 并与公式比对误差 <15%
✔ 能手推 KV 体积与训练 / 推理显存,并量化一个降本手段省了多少
核心知识点详解
  • 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 存在的理由。
减访存手段算子融合 6→2 遍decode AI ≈ 1量化线性提速解码batch 摊薄权重读取
学习路径
  1. 读 4.2:理解融合 / 量化 / batch 摊薄为何是带宽受限的通用解法
  2. 跑内置代码,实测融合算子后 3 遍访存降到 2 遍的耗时
  3. 完成动手练习:算量化或增大 batch 后解码 tok/s 的变化
  4. 对接 M2:给出至少一项已验证的「减少访存」优化及对比数字
✔ 能从带宽视角解释融合 / 量化 / batch 的收益并拿出一个实测优化
核心知识点详解
  • 量化近似线性提速: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+bN3N×2=6N≈ 0.17极度 memory-bound
元素级激活(ReLU/GELU)~N2N×2=4N≈ 0.25memory-bound
LayerNorm~5N2N×2≈ 1.25memory-bound
矩阵×向量 GEMV2MNMN×2 + M×2≈ 2memory-bound(LLM 解码就是它)
矩阵×矩阵 GEMM (N=4096)2N³3N²×2≈ N/3 ≈ 1365compute-bound
Attention (短序列)~4·s²·d~4·s·d≈ ss 小时 memory-bound,s 大时 compute-bound
★
这条推论值得刻在脑子里:大模型解码(decode)阶段是典型的 memory-bound,而且是极度不对称的:每生成一个 token,要把全部权重从 HBM 读一遍,但只做 2·参数量 次浮点运算。以 70B 模型为例,读 140 GB 权重(bf16)做 140 GFLOP —— 算术强度只有 1 FLOP/Byte,远低于拐点 299。所以解码速度几乎完全由显存带宽 ÷ 权重体积决定,而不是算力。这直接解释了:为什么量化能线性提升解码速度(权重变小,读取变快)、为什么批处理能提高吞吐(一次读权重服务多个请求,摊薄带宽成本)、以及为什么投机解码有效(用少量算力换更少的权重读取轮次)。
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 TFLOPS2.0 TB/s~156 FLOP/B80 GB HBM2e
H100 SXM~989 TFLOPS3.35 TB/s~295 FLOP/B80 GB HBM3
H200~989 TFLOPS4.8 TB/s~206 FLOP/B141 GB HBM3e
B200~2250 TFLOPS(dense)8.0 TB/s~281 FLOP/B192 GB HBM3e
GB200 单超芯~2500 TFLOPS8.0 TB/s~312 FLOP/B192 GB HBM3e
★
2026 案例:FlashAttention 就是把 attention 从带宽受限拉回可用区间:朴素 attention 的中间矩阵 S = QKᵀ(s×s) 要写回 HBM 再读进来做 softmax,长序列下访存是 O(s²) 且反复读写,算术强度极低 → 严重 memory-bound。FlashAttention-3 用分块 + 在线 softmax(online rescaling),把中间结果留在 shared memory / 寄存器里,HBM 访存降到 O(s²/BC) 级别,在 H100 上前向(fp16)达到约 740 TFLOPS,约为理论峰值的 75%。同一原理还用于 FlashDecoding(长上下文解码)与反向传播。结论:attention 优化的主线不是「算得更少」,而是「少搬字节」。

4.2 内存墙:为什么算力涨得比带宽快

过去几十年,CPU 算力大约每 2 年翻倍,而内存带宽与延迟的改善速率慢得多——这个差距被称为内存墙(Memory Wall)。后果是:越来越多的程序从「算得慢」变成「等数据」。AI 是这个趋势的最极端案例。

内存类型典型带宽典型容量用途
DDR5(CPU 侧)~50–100 GB/s64 GB–1 TB主机内存、数据加载
HBM3 / HBM3e(GPU 侧)~1.5–4.8 TB/s40–192 GB 每卡GPU 显存
NVLink / NVSwitch~900 GB/s 双向(每卡)—多卡互联、张量并行
PCIe 5.0 x16~64 GB/s 单向—CPU↔GPU 传输(常见瓶颈!)
NVMe SSD~7 GB/sTB 级数据集存储
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 涨了多少倍
⚠
训练显存 ≠ 推理显存:上面算的是推理。训练时还要加上:梯度(同权重体积)、Adam 优化器状态(fp32 的 m 和 v,共 8 字节/参数)、激活值(与 batch × seq 成正比,可用梯度检查点换显存)、通信缓冲(分布式训练)。粗略经验:全参微调的显存 ≈ 参数量 × 16~20 字节。所以 7B 模型全参微调需要 ~120 GB 以上 —— 这才是 LoRA / QLoRA 存在的理由。
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 或没打满通道
ℹ
2026 前沿:内存墙正在被三种手段围攻:① HBM3e / HBM4:H200 把带宽拉到 4.8 TB/s,Blackwell 到 8 TB/s,靠堆叠层数与更宽接口;② 片上存储与近存计算:Groq LPU 用超大 SRAM 换带宽(放弃 HBM,靠确定性调度),主打低延迟解码;③ 算法侧减少访存:KV Cache 量化(FP8/INT4)、GQA/MQA、MoE 稀疏激活、前缀缓存。2026 年评估一个推理方案,几乎一定要同时看带宽、KV Cache 体积、前缀命中率这三个数。
★
4.3 动手练习与自测:做完你应能:算 roofline 拐点、判断算子受限资源、并手推 KV Cache 体积。
  1. 算 H100 的 roofline 拐点(bf16 989 TFLOPS / 3.35 TB/s),说明 70B 解码的算术强度为何远低于它。
  2. 一个逐元素 GELU 算子的算术强度约 0.25,它受限于什么?给出三种优化手段。
  3. 推导 70B、80 层、8 个 KV 头、head_dim 128、4096 序列、bf16 的单条请求 KV Cache 大小。
  4. 为什么算子融合能把 3 个 memory-bound 算子的时间降到约 1/3?
  5. FlashAttention 把 attention 的 HBM 访存从 O(s²) 降到什么量级?靠什么机制?

5. 并行体系结构:从 ILP 到 SIMD 到多核

知识结构图 · 并行体系结构
并行体系结构:从 ILP 到 SIMD 到多核3 大知识域 · 17 个知识点
并行层次与定律ILP / DLP / TLPAmdahl 定律p=0.95 上限 20xGustafson 弱扩展强扩展 vs 弱扩展
学习路径
  1. 读 5.1:分清 ILP / DLP / TLP 与 Amdahl / Gustafson 的适用场景
  2. 跑内置代码,用 amdahl() 跑 p=0.95 / 0.99 的上限表
  3. 完成动手练习:算 p=0.9 部分加速 10x 时的总加速比并给出判据
  4. 对接 M2:先 profile 找占比大的一段再并行,避免优化 5% 的盲区
✔ 能用 Amdahl 当场算出可优化上限、不再去优化占比 5% 的代码
核心知识点详解
  • 三类并行:ILP 指令级(超标量/乱序)、DLP 数据级(SIMD/warp/向量化)、TLP 线程级(多核/多卡)。AI 主要靠 DLP 与 TLP。
  • Amdahl 定律:加速比 = 1 / ((1−p) + p/s)。p=0.95 时即使可并行部分无限快上限也只有 20x。先 profile 找大占比段再并行,别优化占 5% 的盲区。
  • Gustafson 弱扩展:Amdahl 假设问题规模固定;LLM 训练规模随卡同步增长 → 属弱扩展、趋近线性。加卡可以训更大的模型,而非只能快一点点。
分布式并行维度DP all-reduce 2×参数TP 每层两次 all-reducePP 只传激活EP all-to-all 路由TP 限 NVLink 单机3D/4D 混合 + ZeRO
学习路径
  1. 读 5.1:对比 DP / TP / PP / EP 各自的通信量级
  2. 跑内置代码,验证「TP 通信最密、限制在 NVLink 单机」的结论
  3. 完成动手练习:解释为何 PP 跨机可行而 TP 不能、DP 通信多大
  4. 对接 M2:单卡跑不下时就按通信密度规划多卡 / 量化
✔ 能按通信量级选对并行维度,并说清 TP 必须关在高带宽域的原因
核心知识点详解
  • 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 代价。结论:按通信密度匹配互联带宽。
SIMD 与向量化AVX-512 16×fp32向量化快 30-80x无循环依赖前提跨步 xs[::2] 退化coalescing 合并访存广播物化中间张量
学习路径
  1. 读 5.2:理解 SIMD 车道、无依赖 + 连续访存两个前提
  2. 跑内置代码,实测 Python 循环 vs NumPy 向量化的数量级差距
  3. 完成动手练习:向量化改写一段标量代码、避开跨步与隐式广播
  4. 对接 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,再动手。
✔
Gustafson 定律与 AI 的实际情况:Amdahl 假设问题规模固定,而 AI 的现实是规模会随资源增长(更大的 batch、更长的序列、更大的模型)。Gustafson 定律描述的正是这种「规模可扩展」的情形:并行度增加时,可以处理更大的问题,所以实际加速远好于 Amdahl 的悲观估计。这在 AI 里表现为「加卡就能训更大的模型」,而不是「加卡就只能快一点点」。
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 训练
并行维度切分对象通信量级典型对应
数据并行 DPbatch每步 all-reduce 梯度(2×参数量)DDP / ZeRO
张量并行 TP单层权重矩阵每层两次 all-reduce(高,需 NVLink)Megatron / 单机多卡
流水并行 PP层只在层边界传激活(低)跨机 / 多机
专家并行 EPMoE 专家all-to-all 路由(不规则)MoE 大模型
序列并行 SPseq 维配合 TP 的 all-gather长上下文训练
ℹ
2026 前沿:并行策略已收敛为「3D/4D 混合 + ZeRO」:训练千亿级模型的事实标准是 TP × PP × DP(可能再叠 EP):单机内用 NVLink/NVSwitch(GB200 单超芯内互联约 1.8 TB/s 双向)做 TP,因为 TP 的 all-reduce 最频繁;跨机用 InfiniBand 做 PP/DP,因为它们通信稀疏。DeepSeek-V3 的 671B 训练在通信上做了大量工程(含 FP8 通信、细粒度 EP),把 all-to-all 的代价压到可接受。结论:并行不是「加卡就行」,而是按通信密度匹配互联带宽。

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) 往往贡献更大。
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 时收益被带宽封顶
ℹ
2026 前沿:从 AVX 到 Tensor Core 再到 Triton:CPU 上 AVX-512 只有 16 个 fp32 车道;GPU 上一次 Tensor Core MMA 就是 16×16×16;而 Triton 让你用 Python 写分块 kernel、自动向量化与流水化。2026 年写自定义算子的事实标准是「先用 torch.compile,不够再上 Triton,最后才手写 CUDA」。理解 SIMD 的「车道」概念,是读懂 Triton 里 BLOCK_SIZE、tl.load、tl.dot 语义的前提。
★
5.3 动手练习与自测:做完你应能:用 Amdahl 定量判断可优化空间、说清三种并行的通信代价、并写出向量化改写。
  1. 用 Amdahl 计算:p=0.9、并行部分加速 10x 时的总加速比。
  2. 写出 AVX-512 一条指令能处理多少个 fp32 / fp64,以及对应的理论加速比。
  3. 对比数据并行、张量并行、流水并行的通信量级,并说明为什么 TP 通常限制在单机 NVLink 内。
  4. 弱扩展(Gustafson)为什么比强扩展(Amdahl)更接近 LLM 训练的现实?
  5. 向量化改写的两个前提条件是什么?跨步访问为什么会让它退化?

6. GPU 体系结构:为什么它适合 AI

知识结构图 · GPU 体系结构
GPU 体系结构:为什么它适合 AI4 大知识域 · 20 个知识点
SIMT 与延迟隐藏warp = 32 线程 lockstep分支发散最坏 32×occupancy 隐藏延迟FlashAttention 低占用CPU 低延迟 / GPU 高吞吐
学习路径
  1. 读 6.1:理解 warp 锁步、分支发散与 occupancy 隐藏延迟
  2. 跑内置代码,用 torch.profiler 观察同一个算子前后的 kernel 时间
  3. 完成动手练习:算一个 warp 走两条分支的最坏效率并说明避免法
  4. 对接 M2:判断 GPU 利用率低是数据供给还是发散同步所致
✔ 能用 warp 发散与 occupancy 解释真实 kernel 的慢因,而非说「换个库」
核心知识点详解
  • warp=32 锁步:以 32 线程为调度单位、锁步执行同一指令。GPU 把面积给 ALU(>80%) 用海量线程切换隐藏延迟;CPU 把面积给控制逻辑与 Cache 降单线程延迟——两种完全不同的设计哲学。
  • 分支发散最坏 32×:warp 内线程走不同分支时硬件把每条路径各执行一遍并用 mask 屏蔽,最坏效率 1/32。用 select/where 替代分支、让同 warp 处理同质数据规避。
  • occupancy 只是手段:SM 驻留 warp 越多越能切换隐藏延迟;但寄存器/shared 用量大则占用低。FlashAttention 故意高寄存器低占用(约 25~50%)却更快——目标是「够隐藏延迟」而非占满。
访存三个陷阱coalescing 相邻地址bank conflict padding 33.item() 同步断裂pinned memory 快 2-3xH2D ~64 GB/s 墙shared memory / L1
学习路径
  1. 读 6.2:记住 coalescing、bank conflict、host↔device 同步三大陷阱
  2. 跑内置代码,实测 pinned vs pageable 的 H2D 差距
  3. 完成动手练习:把训练循环里的 .item() 改成每 N 步同步一次
  4. 对接 M2:给出探针输出的同步点清单并逐一消除
✔ 能找出代码里破坏 GPU 异步流水线的同步点并给出正确写法
核心知识点详解
  • 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 倍。
Tensor CoreMMA 16×16×16hidden 取 16 倍数tf32 快 2-3xbf16 GEMM 快 10-16x算力看位宽 + 稀疏Blackwell FP4 / NVFP4
学习路径
  1. 读 6.3:理解 Tensor Core 的 MMA 形状约束与低精度通路
  2. 跑内置代码,实测 fp32 / tf32 / bf16 GEMM 的 TFLOPS 差异
  3. 完成动手练习:解释 hidden 取 16 倍数更快、读懂算力标注对齐位宽
  4. 对接 M2:在容量模型里按「位宽 + 稀疏」对齐标称算力
✔ 能解释 Tensor Core 对齐约束、并把算力标注和位宽对齐后再算上限
核心知识点详解
  • 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%occupancy 是手段非目标
学习路径
  1. 完成动手练习:自测验算 warp 双路径 ≤ 50%、步长 32 float 利用率 ≈ 3%
  2. 跑内置代码,用随机 gather 复现散乱访问慢 3-10x
  3. 对接 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 追求高吞吐(单位时间完成尽量多的运算),所以把绝大多数面积花在运算单元上,用海量线程切换来隐藏延迟。这是两种完全不同的设计哲学。

维度CPUGPU
设计目标降低单线程延迟最大化整体吞吐
核心数8–64 个强核数千个弱核(分组成 SM)
面积分配大量给控制逻辑与 Cache大量给 ALU(>80%)
延迟隐藏方式乱序执行、推测、深 Cache线程切换(1 条指令的延迟被其他 warp 填满)
分支处理精密的预测器warp 内分支发散则串行化执行
编程抽象线程 = 独立指令流线程 = 数据元素(SIMT)
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 重排的作用)
ℹ
2026 前沿:occupancy 与「故意低占用」的高性能 kernel:FlashAttention-3、Fused MoE、Grouped GEMM 这些 Hopper/Blackwell 上的明星 kernel,register / 共享内存用量都很高,occupancy 常只有 25%~50%,却比「高占用」的朴素实现快得多。原因是它们用片上存储换访存、用 warp specialization(生产者/消费者 warp 分工)与 TMA(Tensor Memory Accelerator)做异步流水。这印证了一句话:occupancy 只是手段,目标是隐藏延迟、少搬字节。

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需显式拷贝你的数据管线
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/SMbank conflict 会降 32 倍
L2~200 cycle~10 TB/s 级全卡共享,容量数十 MB
HBM~400–800 cycle3.35 TB/s(H100)合并访存是命门
PCIe H2Dμs 级~64 GB/s(Gen5 x16)必须 pinned + 异步重叠
ℹ
2026 前沿:GPU 之间的「内存墙」比卡内更陡:训练 671B 级模型时,卡间通信常常比卡内访存更卡:PCIe 只有 ~64 GB/s,而 NVLink 5(GB200)每卡双向约 1.8 TB/s,差近 30 倍。所以「TP 放单机 NVLink 内、跨机只做 PP/DP」不是偏好,是带宽约束的必然。这也是 GB200 NVL72 把 72 张卡放进一个 NVLink 域的商业理由:把通信密集的并行维度关进高带宽域。

6.3 Tensor Core、混合精度与硬件加速单元

Tensor Core 是专门为矩阵乘累加(MMA)设计的硬件单元。它能在若干周期内完成一个 16×16×16 的矩阵乘累加——这是「AI 算力爆炸」的直接来源。理解它的约束条件,才能解释为什么某些 shape 跑得飞快、某些却慢得离谱。

★
把这一阶段压缩成三句话:① 算得快不快,先看算术强度——强度低就一定是带宽瓶颈,堆算力无用。② 带宽瓶颈的通用解法是减少访存——融合算子、分块、量化、增大 batch。③ GPU 编程的所有坑都归结为「连续性」——地址连续(coalescing)、布局连续(contiguous)、形状对齐(Tensor Core)。把这三句话用在 Hamauls Orion 的性能剖析里,你就能对任何慢算子给出方向正确的判断。
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 只省带宽,算力优势几乎体现不出来
ℹ
2026 前沿:Blackwell 的 FP4 与 NVFP4:Blackwell(B200/GB200)的 Tensor Core 新增 FP4(E2M1) 通路,并用 NVFP4 做「逐块 16 元素 + 二级缩放因子」,把 4 位量化的精度损失压到可用范围。结合 HBM3e 192GB 与第五代 NVLink(~1.8 TB/s 双向/卡),单超芯 GB200 能一口气放下更大模型的权重。但要注意:FP4 的收益集中在 compute-bound 的 GEMM;decode 这种 memory-bound 阶段,收益主要来自「权重体积进一步减半」而非算力。
★
6.4 动手练习与自测:做完你应能:算 warp 发散效率、判断访存利用率、并解释 Tensor Core 的形状约束。
  1. 一个 warp 的 32 线程走 2 条分支路径,最坏效率是多少?如何避免?
  2. 为什么降低 register 用量会提高 occupancy?但高 occupancy 为什么不一定更快?
  3. coalescing 是什么?一个 warp 访问步长为 32 个 float 的地址,带宽利用率约多少?
  4. Tensor Core 对矩阵维度有何要求?由此解释「hidden 取 16 的倍数更快」。
  5. 为什么训练循环里调用 .item() 会拖慢?给出正确写法。

7. 性能剖析与容量规划(里程碑 M2)

知识结构图 · 性能剖析与容量规划
性能剖析与容量规划4 大知识域 · 16 个知识点
剖析工具链torch.profiler 时间线ncu 带宽利用率nvidia-smi dmontorch.utils.bottleneck带宽利用率 > 80% 即受限
学习路径
  1. 读 7.1:按「测 → 定位 → 建模 → 预测 → 改 → 再测」的顺序走
  2. 跑内置代码,用 torch.profiler 导出时间线并读 kernel 耗时
  3. 完成动手练习:给一个真实算子定位受限资源并报出带宽/算力利用率
  4. 对接 M2:用探针输出一份 kernel 级耗时表与融合建议
✔ 能独立跑通一次 profile 并给出「受限资源 + 理论上限 + 差几倍」
核心知识点详解
  • 按顺序走:测量→定位→建模→预测→实施→再测。工具顺序:dmon 看宏观 → torch.utils.bottleneck 看构成 → profiler 看 kernel → ncu 看受限资源。
  • 带宽利用率判 memory-bound:achieved/peak bandwidth >80% 且算力利用率低 → 带宽瓶颈,优化方向是减少访存而非加并行。
  • 用 profiler 读 kernel:torch.profiler + export_chrome_trace 看 kernel 级耗时与哪些小 kernel 可融合;Memory Snapshot 可视化显存碎片。
基准测试纪律必须预热多次取中位数 / P95固定变量单一改动单次测量多是噪声
学习路径
  1. 读 7.1:背下「预热、多次取分位数、固定变量」三条纪律
  2. 跑内置代码,对比单次无预热 vs 预热 + 中位数,看出噪声量级
  3. 完成动手练习:对一次优化出具改前 / 改后对比并排除噪声
  4. 对接 M2:报告里所有性能数字按该纪律给出中位数与 P95
✔ 能指出一份跑分结论为何不可信、并给出符合纪律的测法
核心知识点详解
  • 必须预热:torch.compile / Triton 编译、CUDA module 加载、cudnn autotune 都需要热身,首次运行无参考价值(可能被拖成 10x)。
  • 多次取中位数 / P95:单次测量的噪声可能比要测的差异还大。取中位数压尾噪、报 P95 留上限,否则结论不可信。
  • 固定变量:同一机器 / 数据 / 随机种子、关闭其他负载、每次只改一个变量否则无法归因。「改前 vs 改后」没对比等于没做。
容量模型权重 + 梯度 + Adam + 激活decode 上限 = 带宽 / 每步字节eff 0.75 经验效率理论 tok/s × 40-70%先算再跑
学习路径
  1. 读 7.2:把「能不能跑、能跑多快」变成可计算的函数
  2. 跑内置代码,跑通 capacity_model 并跑几个典型配置建立量级感
  3. 完成动手练习:先算再跑,预测 batch=32/seq=8k 时 70B 能否放进 192GB
  4. 对接 M2:交付容量模型函数并写进代码带单测、结论可复现
✔ 能手推出显存与解码上限、并用实测校准差额、把模型写进代码
核心知识点详解
  • decode 上限公式:tok/s = 带宽 × eff / 每步需读字节,每 token 至少读一遍权重。eff≈0.75 为经验效率(memory-bound 常用标称宽度 70~85%)。
  • 理论与实测差额:理论 tok/s × 40%~70% ≈ 线上实测,差额来自批调度、前缀未命中、长尾请求。能把差额归因到某因素就是高级容量规划能力。
  • 先算再跑:写代码之前就估算显存与吞吐,把 capacity_model() 写进代码、带单测、参数变自动重算——文档会过期,代码不会。
M2 交付自查改前 vs 改后量化对比容量模型写进代码带单测
学习路径
  1. 完成动手练习:对照 7.3 自测逐条核验我能否同步给出估算与实测
  2. 完成动手练习:把「估算 vs 实测」差额讲清、并把差额归因到某因素
  3. 对接 M2:按 done 判据交付——任意慢算子能定量解释并含一项已验证优化
✔ 能把 M2 的每条判据跑通并自证到「先算再跑、改有对比」
核心知识点详解
  • 改前 vs 改后:每项优化必须有改前/改后量化数字与中位数、P95,无对比等于没做。
  • 估算 vs 实测双线:同时给出估算与实测并把差额归因到某因素(激活值 / 碎片 / 框架开销),能自证「先算再跑、改有对比」即过关。
  • 任慢算子可解释:M2 判据:任意慢算子能定量解释其受限资源,并含至少一项已实测验证的优化。
学习路径

7.1 度量方法:不要猜,要测

性能工作最大的浪费是「凭直觉优化」。正确的顺序永远是:测量 → 定位瓶颈 → 建立模型 → 预测收益 → 实施 → 再测量。跳过前两步的优化,通常是在优化不重要的地方。

层次工具看什么
端到端耗时time / py-spy / 自建计时哪一段占了大头(先粗后细)
Python 层热点cProfile / py-spy / line_profiler哪个函数调用最多、最耗时
GPU 算子级torch.profiler / nsight systemskernel 数量、每个 kernel 的耗时与占用率
硬件计数器ncu / nsight compute(或 rocm 对应工具)实际带宽利用率、L2 命中率、warp stall 原因
系统级nvidia-smi dmon / nvtop / iostat / sarGPU 利用率、显存、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 改后」:没有对比数字的优化等于没做
#    每次只改一个变量,否则归因不了
⚠
CPU 上写基准测试的三个纪律:(你从 JMH 带过来的经验,在 Python/AI 场景同样适用)① 必须预热——JIT(这里的 JIT 指 torch.compile、Triton 编译、CUDA module 加载)与 Cache 都需要热身,第一次运行的时间毫无参考价值。② 必须多次迭代取中位数与 P95——单次测量的噪声可能比你要测的差异还大。③ 必须固定变量——同一台机器、同一份数据、同一个随机种子、关闭其他负载。做不到这三条,你的「优化效果」大概率是噪声。
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 + 固定变量,否则量到的多半是噪声
ℹ
2026 前沿:工具链已进化到「不写一行 CUDA 也能定位瓶颈」:torch.profiler + chrome trace 看时间线与 kernel 级耗时;Nsight Compute(ncu)给出 achieved bandwidth、L2 命中率、warp stall 原因、Tensor Core 利用率;Nsight Systems 看 CPU/GPU 重叠与同步点。2026 年还多了 PyTorch Memory Snapshot(可视化显存碎片)与 torch.compile 的 autotune 报告。工作流口诀:先 Systems 看全局重叠 → 再 Compute 看单 kernel 受限资源 → 最后回代码改一处再测。

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 ''))
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
# 结论:预测量级很准、绝对值有偏差 —— 用实测校准系数,是工程容量的正确用法
ℹ
2026 前沿:容量模型已经进化为「端到端吞吐预算」:2026 年的推理容量规划不再只算显存,而是算三本账:① 显存(权重 + KV + 激活,决定能放多大、多长上下文);② 带宽/算力(决定 ttft 与 tok/s 上限,roofline 判定);③ 前缀命中率与批调度(vLLM/SGLang 连续批处理 + PagedAttention 决定实际吞吐是理论上限的百分之几)。一个常见现实是:理论 tok/s 上限 × 40%~70% ≈ 线上实测,差额来自调度、前缀未命中、长尾请求。能把这三本账算清并解释差额,就是高级推理工程师的核心能力。
★
7.3 动手练习与自测(M2 收尾):这一组题直接对应 M2 交付:能同时给出「估算」与「实测」并把差额解释清楚,就算过关。
  1. 基准测试三纪律是什么?各为什么重要?
  2. 用 roofline 判断你的算子是否 memory-bound,需要哪两个实测指标?
  3. 写出 7B 模型全参微调的显存估算(按 16 字节/参数),并说明 LoRA 为什么能救它。
  4. 一次优化改动后如何证明收益不是噪声?至少列出三条要求。
  5. 先算再跑:预测 batch=32、seq=8k 时 70B(bf16)能否放进 192GB 的 B200。

项目里程碑

贯穿项目 · Hamauls Orion
M2 性能剖析与容量模型 第 7–11 周

用计算机组成原理的知识,给 Hamauls Orion 的推理与训练链路做一次真实的性能剖析:找出瓶颈是算力、带宽还是延迟;核算 KV Cache 与权重显存;建立「单卡能跑多大规模、多少并发」的容量模型。这份报告将决定后面所有架构选择。

本阶段产出(直接进入项目仓库)
验收标准:能对任意一个慢算子给出「为什么慢」的定量解释(受限于什么资源、理论上限是多少、还差几倍),并给出至少一项已验证的优化。

阶段练习项目

PROJECT 1
缓存行实验台
写一组基准:顺序 vs 随机访问、行优先 vs 列优先矩阵遍历、AoS vs SoA 布局、有无 padding 的矩阵转置。输出一张带数字的对比表,并解释每一个差异背后的硬件原因。
要达成的效果
  • 对 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 的范畴)。

PROJECT 2
手写浮点工具
实现 float32 / bf16 / fp16 / fp8 的十进制↔二进制转换与可视化(符号位、指数位、尾数位分布),并复现「累加误差」「灾难性抵消」「fp16 溢出」三个经典实验。
要达成的效果
  • 四种格式都能手动拆出符号位 / 指数位 / 尾数位并反向还原,误差为 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 细节(够工程用即可)。

PROJECT 3
Roofline 分析器
对你机器上能跑的 5 个算子(向量加、LayerNorm、GEMV、GEMM、Attention)测出实际 FLOP/s,画出 roofline 图,标出每个算子处于 compute-bound 还是 memory-bound,并给出各自的优化方向。
要达成的效果
  • 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。

PROJECT 4
GPU 探针脚本
用 torch.profiler 写一个可复用的性能探针,输出 kernel 级耗时表、算子融合建议(哪些小 kernel 可以合并)、以及 CPU↔GPU 同步点清单。
要达成的效果
  • 对任意一段模型代码一键产出 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 的硬件计数器级分析(那是可选的深入项);不做自动代码改写。

常见误区

面试高频问题速答

为什么大模型推理的解码阶段通常不是算力瓶颈?

因为解码是 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 的序列长度分块对齐、以及卷积的通道对齐。这不是玄学参数调优,而是硬件约束的直接后果。

学习资源

CSAPP — Computer Systems: A Programmer's Perspective(第 3 版)书 csapp.cs.cmu.edu/ 本阶段的主教材。重点章节:第 2 章(数据表示)、第 5 章(程序性能优化:局部性、流水线、SIMD)、第 6 章(存储层次)。第 5、6 章几乎就是为 AI 工程师写的性能入门。 CMU 15-213 / 15-513 课程(CSAPP 配套)课程 www.cs.cmu.edu/~213/ 有完整讲义与 lab(Data Lab、Cache Lab、Performance Lab)。Cache Lab 做完你会彻底理解 cache 命中与分块优化。 NVIDIA CUDA C Programming Guide — 存储层次与执行模型章节文档 docs.nvidia.com/cuda/cuda-c-programming-guide/ 哪怕不写 CUDA,这两章也值得读:SIMT 执行模型、coalescing、shared memory bank conflict、occupancy。是理解一切 GPU 性能讨论的原始出处。 Roofline: An Insightful Visual Performance Model(论文)论文 dl.acm.org/doi/10.1145/1498765.1498785 roofline 模型的原始论文。读它才知道为什么这个模型能被广泛使用,以及它的局限(不建模 cache 层级、不建模延迟)。 What Every Programmer Should Know About Memory(Ulrich Drepper)论文 people.freebsd.org/~lstewart/articles/cpumemory.pdf 存储层次的经典长文。Cache 结构、TLB、预取、NUMA、以及「内存是瓶颈」的第一手论述。 PyTorch Profiler 官方教程文档 pytorch.org/tutorials/recipes/recipes/profiler_recipe.html 上手 torch.profiler 的最短路径:kernel 级耗时、内存快照、chrome trace 导出。可直接用于 Hamauls Orion 的 M2 里程碑。 Computer Architecture: A Quantitative Approach(Hennessy & Patterson)书 shop.elsevier.com/books/computer-architecture/hennessy/978-0-12-811905-1 想深入微架构(流水线、乱序、预测器、多核一致性)就读这本。第 2 章(存储层次)与附录(GPU 架构)对本计划最有价值。 tinygrad / llm.c — 用极少代码跑通训练仓库 github.com/karpathy/llm.c 读一遍 llm.c 或 tinygrad 的实现,能看到「内存布局、kernel 融合、张量步长」这些概念在真实训练代码里是如何落地的。
★
这一阶段真正的收获:学完你应该拥有一种新的条件反射:看到「慢」,先问「受限于什么资源」——算力、带宽、延迟、还是并行度?然后算出理论上限,再决定值不值得优化。2026 年 AI 岗位的面试里,「你觉得这个会慢在哪」这类问题远比「你会用哪个库」更能区分候选人。而这一阶段的知识,正是回答它的唯一途径。