Building an LLM Inference Engine from ScratchPart V / Module 15
Part V · Performance

Module 15:先测量,再优化

性能工程首先要求稳定测量。先建立 benchmark,分别记录 prefill / decode、显存、readback 与 submission,再确定优化顺序。

学习目标

  • 建立可重复的 benchmark:固定 prompt、max tokens、seed、采样策略和模型。
  • 区分 prefill tok/s 与 decode tok/s,并知道它们代表不同瓶颈。
  • 记录 GPU weight memory、KV cache memory、readback 大小和 submission 数量。
  • 学习从提交开销、显存带宽、readback 三个角度定位瓶颈。
  • 建立 regression gate:任何优化都必须保持 parity tests 通过。

15.1Benchmark 要可重复

benchmark 的前提是可复现。prompt、采样参数、模型和运行模式任一变化,都会使前后数字失去直接可比性。最小配置应固定:

  • 模型路径,例如models\\Qwen2.5-0.5B-Instruct
  • prompt;
  • max_new_tokens
  • 采样策略:greedy 或固定 temperature/top-k/top-p;
  • 随机种子;
  • resident 或 streaming 模式。
xinfer generate --model models\Qwen2.5-0.5B-Instruct \
  --prompt "Count from one to twenty." \
  --max-tokens 60 --no-chat --seed 1

runtime 应分别记录两个吞吐指标:

prefill tok/s=Nprompttprefill,decode tok/s=Ngeneratedtdecode\text{prefill tok/s} = \frac{N_{\text{prompt}}}{t_{\text{prefill}}}, \qquad \text{decode tok/s} = \frac{N_{\text{generated}}}{t_{\text{decode}}}
一个可复现 benchmark 的组成 Modelcheckpoint Promptfixed text Samplinggreedy / seed Runtimeresident/streaming Metricsprefill tok/s · decode tok/s · memory
图 15-1:Benchmark 配置必须固定,否则优化前后的数字不可比较。

15.2内存报告:不要只看 tok/s

LLM 推理同时受计算、显存容量和显存带宽约束。benchmark 至少应报告以下项目:

  • resident GPU weights 大小;
  • KV cache 大小;
  • 是否 streaming,以及 resident layer slots 数;
  • 每 token 是否 readback 完整 logits;
  • 权重 dtype:f32/f16/int8/int4。

KV cache 显存公式:

bytesKV=2LTnkvdheadbytes(dtype)\operatorname{bytes}_{KV} =2LTn_{\text{kv}}d_{\text{head}}\operatorname{bytes(dtype)}

因此,上下文长度 TT、层数 LL 和 KV heads 数量都会线性影响 KV cache 容量。

图 15-2:推理内存不仅是权重;KV cache 会随上下文增长。

15.3找瓶颈:submission、bandwidth、readback

xinfer 的优化记录表明,瓶颈需要通过测量确认。早期实现首先暴露的并非单个 matmul kernel,而是三类系统性开销:过多 GPU submission、f32 权重带宽,以及完整 logits readback。

症状可能瓶颈典型修复
GPU 利用率低,CPU 等 fence 多submission / synchronization overhead批量记录 command list,减少 submit
matmul/GEMV 慢,算术强度低显存带宽f16/量化、coalesced loads、tiling
每 token 都要大量 readbackCPU/GPU 传输GPU argmax / GPU top-k
长上下文变慢attention 读 KV cachesingle-pass attention、paged KV、FlashAttention 类优化
图 15-3:优化前先分类瓶颈;不同瓶颈对应不同修复路径。

15.4CPU wall-clock vs GPU timestamp

CPU wall-clock 表示端到端等待时间,包含 CPU 准备、command submission、GPU 执行、fence wait 与 readback。GPU timestamp 表示 GPU 命令区间的执行时间。二者都需要记录,但用途不同。

计时方式回答的问题包含不包含
CPU wall-clock用户实际等待多久?提交、等待、readback、CPU 逻辑难以分辨 GPU 本体耗时
GPU timestamp某段 GPU 工作花多久?GPU command 执行区间CPU 调度、readback、等待开销

完整的 benchmark 应同时使用两类计时。例如 Tier 1 主要降低 CPU/GPU submission 开销;Tier 3 改善 GPU kernel 的内存访问;Tier 6 减少 logits readback。

15.5Regression gate:正确性永远是门槛

性能优化必须服从正确性约束。每次修改后都应运行 parity tests:

maxiyigpuyicpu<ε\max_i |y_i^{gpu}-y_i^{cpu}| < \varepsilon

对生成系统,还应检查:

  • KV cache incremental decode 是否等于 full forward;
  • streaming 是否等于 resident;
  • GPU argmax 是否等于 CPU argmax;
  • 真实 prompt 是否仍然输出合理答案。
不要只追 tok/s

速度提升不能替代正确性。优化报告必须同时列出 correctness gate 与性能数据。

Lab 15捕获 baseline 与 per-kernel profile

本实验要求记录一份可复现 baseline,并提出可验证的优化假设。

  1. 固定模型、prompt、max tokens、seed。
  2. 运行 CLI,记录 prefill tok/s、decode tok/s、GPU weights、KV cache。
  3. 选择一个怀疑瓶颈:submission、bandwidth、readback、attention。
  4. 用 CPU wall-clock 或 GPU timestamp 进一步验证。
  5. 写出优化前后对比表。
# 示例 baseline 命令
cargo run --release -p xinfer-cli -- generate \
  --model models\Qwen2.5-0.5B-Instruct \
  --prompt "Count from one to twenty." \
  --max-tokens 60 --no-chat --seed 1

小结

本章建立性能工程的基本流程:固定 benchmark 条件,分别报告 prefill 与 decode 吞吐,同时记录 GPU weights、KV cache、readback 和 submission 等资源指标。xinfer 在 Qwen2.5-0.5B 上的优化历程显示,decode 从 3.6 tok/s 提升到约 30.9 tok/s,关键来自减少 submission、降低权重带宽和消除 greedy 路径的完整 logits readback。所有优化都必须通过 parity tests,避免以错误输出换取吞吐。

思考与练习

基础为什么 prefill tok/s 和 decode tok/s 不能混在一起报告?

因为两者性能特征不同。prefill 并行处理多个 token,算术强度高、接近计算受限,tok/s 很高;decode 每次一个 token(GEMV),带宽受限、还有固定提交开销,tok/s 低得多。把它们平均会同时掩盖 prefill 的高吞吐和 decode 的瓶颈,对优化没有指导意义。应分别报告(如 xinfer 的GenerateStats拆成 prefill ms 与 decode tok/s)。

基础列出一个可复现 benchmark 至少需要固定的 4 个参数。

① 模型与精度(如 Qwen2.5-0.5B、f16 权重);② prompt 长度 / 生成 token 数;③ 采样设置(greedy 还是带温度,影响是否走 GPU argmax fast path);④ 硬件与驱动 + 是否预热(warmup 后取稳定态、排除首次编译/上传)。其他还有 batch size、上下文长度、是否 streaming。固定这些才能让前后对比有意义。

进阶如果优化后 GPU timestamp 变快但 wall-clock 没变,可能说明什么?

说明瓶颈不在 GPU kernel 执行本身,而在 GPU 之外:可能是 CPU 侧提交开销、驱动/同步、每 token 的 readback 与 CPU 采样、或 CPU/GPU 没有重叠(GPU 算得快但大部分时间在等 CPU)。此时应优化命令提交批处理、减少 readback,或增加 CPU/GPU 重叠,而不应继续只修改 kernel。这正是 T1 批处理(530→24 submits/token)带来最大单项提升的原因。

进阶为什么 GPU argmax 优化主要改善 readback,而不是 matmul 本身?

因为 LM head matmul 仍要算出全部 151936 个 logits,计算量不变;GPU argmax 只是改变“取结果”的方式——在 GPU 上归约出最大下标,把 readback 从 608 KB 降到 4 字节。它省的是 PCIe 回传与 CPU 端处理时间,不是 matmul 的 FLOPs。所以它归类为 readback/数据传输优化,而非计算优化。

挑战设计一个实验区分“submission overhead”与“kernel bandwidth bottleneck”。

思路是分别放大两个变量,观察 tok/s 的响应。

测 submission overhead:保持 kernel 工作量不变,改变每 token 的提交次数(如对比 per-op flush 与 per-layer/per-token 批处理)。若 tok/s 随提交次数下降而显著上升,说明此前受提交开销支配。也可插入大量极小的空 dispatch,测纯提交成本。

测 bandwidth bottleneck:保持提交结构不变,改变权重字节数(f32→f16 减半带宽,或人为加大/缩小矩阵)。若耗时随读取字节数近似线性变化、且 GPU timestamp 与理论带宽吻合,则是带宽受限。

结合 GPU timestamp(测 kernel 执行)与 wall-clock(含提交)两套计时:若 wall≫timestamp 之和 → 提交/同步主导;若 timestamp 随字节数线性 → 带宽主导。xinfer 的实测显示,T1 先减少 submission(3.6→8.5),T2 再通过 f16 weights 降低带宽压力(→10.3)。