第19章 小模型 LLM 解码 + Agent 自动优化
本章导读
本章把 Agent 应用到 LLM 解码场景。受书基线 16GB 显存限制,选型 MoE 小模型 LFM2.5-8B-A1B(A1B:每 token 只激活 1 个专家)的 GGUF 量化版本(Q4_K_M,4.79 GiB),用 llama.cpp ROCm 后端实测。decode 基线、RPB/nwarps 扫描与 Q4 vs Q8 精度对比均来自真实实验(RX 7900 XTX,数字以标注平台为准)。PagedAttention、多卡 TP 等留给 hello-mlsys。
19.1 显存约束下的模型选型
16GB 显存是第一约束。一个 8B 参数的稠密模型,fp16 权重约 17 GB——放不下。所以选型逻辑分两步:先定模型结构,再定量化格式。
为什么是 MoE(A1B)而不是更小的稠密模型:Qwen 0.5B/1.8B 这类小模型能全驻留,但稠密模型的 decode 吞吐完全由「全量权重 ÷ 带宽」决定,没有优化空间可讲。MoE A1B(如 LFM2.5-8B-A1B:8.47B 总参数、每 token 只激活 1 个专家)让「每 token 读取量」远小于全量权重——这是第 19.4 节吞吐主线的关键变量,也是 Agent 真正的优化空间所在。
为什么走 llama.cpp + GGUF,而不是 vLLM 量化:在 AMD 消费卡上,vLLM 的量化路径几乎全灭。7900 XTX 上的实测(2026-07):
| 量化路线 | 状态 | 原因 |
|---|---|---|
| AWQ (4-bit) | 可用 | Triton 后端自动启用 |
| GPTQ | 可能可用 | 纯 PyTorch fallback,未经测试 |
| FP8 | 不可用 | 三层锁:AITER gfx9 gate、CK gfx1100 ISA 不兼容(15 个月未修)、torch._scaled_mm 只认 MI300+ |
| Marlin | 不可用 | CUDA only,ROCm 明确拒绝 |
| MXFP4 | 不可用 | gfx1100 缺少 MXFP4 微码 |
根因是 RDNA3 的 WMMA 与 NVIDIA Tensor Core 不同:FP16/INT8/INT4 共用同一条 16×16×16 流水线(吞吐相同),没有 FP8 指令、没有硬件 scale 融合、没有 INT8/INT4 翻倍。消费卡上的量化精度选择空间,实际上只有 GGUF(Q4/Q8 档)和 AWQ。
llama.cpp + Q4_K_M 的收益是实打实的:同一批测试里(7900 XTX,pp512+tg128),llama.cpp Q4_K_M 的端到端耗时约为 vLLM FP16 基线的 1/2.4~1/5.7——注意 MoE 行的对比对象是不同模型(vLLM 侧只有 Qwen2.5-7B FP16 基线),量级对比仅供参考:LFM2.5-8B-A1B 为 667ms vs 3250ms,约 4.9 倍。LFM2.5-8B-A1B 的 Q4_K_M 为 4.79 GiB,16GB 卡上可以全量驻留(-ngl 99)。这就是本章的模型。
19.2 LLM 推理流程拆解
一次生成式推理分成两个阶段,瓶颈性质完全不同:
| 阶段 | 计算形态 | 算术强度 | 瓶颈 |
|---|---|---|---|
| prefill | 整段 prompt 的矩阵乘(GEMM) | 高 | 算力 |
| decode | 每生成一个 token 的矩阵×向量(GEMV) | 低 | 带宽 |
decode 为什么是「算子密集型」?因为每生成一个 token,都要把模型权重读一遍——本章的 LFM2.5-8B-A1B Q4_K_M 有 4.79 GiB 权重(按全量读口径,每 token 就是 4.79 GiB 流量;MoE 稀疏激活会减少实际读取量,见第 19.4 节)。而计算量只有一次矩阵×向量:M=1 的 GEMM,即 GEMV(General Matrix-Vector multiplication)。
对比算术强度(第 4.4.2 节的定义):prefill 的 GEMM 每次读取可以复用多次(大 M 分块),算术强度高;decode 的 GEMV 每个权重元素只被一个 token 用一次,读出来用完即弃。所以 decode 的吞吐几乎完全由「每 token 读权重的字节数 ÷ 显存带宽」决定——这是理解 decode 性能的唯一主线。
19.3 建立 decode baseline
decode 的测量口径:固定 prompt、固定生成长度,测每 token 时间(TPOT),通常报告为 tokens/s。测量时要固定生成长度并重复多次——和 Part 1 的检查清单一样,单次结果不可信。
下面是一次真实的 MoE 模型 decode 调参实验(llama.cpp,ROCm 后端,-p 0 -n 64 -r 5 即 64 token 生成、5 次重复)。模型是 8.47B 参数、MoE A1B(每 token 只激活 1 个专家)的量化版本(Q4_K_M,4.79 GiB):
| 配置 | t/s(mean ± std) |
|---|---|
| 默认(RPB=1, NW=8) | 214.60 ± 0.45 |
这是基线。注意 std 只有 0.45——测量本身很稳,接下来任何配置差异都可以归因于配置而不是噪声。
19.4 decode 的算子视角
把 decode 的每步拆成算子:attention(读取 KV cache,第 17 章 FA Decode 的 M=1 场景)、MoE 专家 GEMV(投影)、输出投影。当模型是 MoE 时,GEMV 是流量主力——decode 的带宽大部分花在读权重,就为了一次矩阵×向量。
decode 的理论吞吐上限由带宽和「每 token 读多少字节」决定:
先看一个错误示范:把整个模型权重(4.79 GiB)都算进「每 token 读取量」,用 7900 XTX 的显存带宽 960 GB/s:
但实测是 214.6 t/s——比这个「上限」还快。矛盾说明假设错了:MoE A1B 每层只激活 1 个专家,每 token 读的是共享权重 + 激活专家,远小于 4.79 GiB 总量。反推:如果 214.6 t/s 真是全量读取,所需带宽是 214.6 × 5.1 GB ≈ 1.1 TB/s,超过 960 GB/s 的物理上限——所以稀疏激活让每 token 的实际读取量明显小于全量权重。这给优化留下了两个方向:压「每 token 读取量」(更强量化、更少激活参数),以及让 GEMV 访存更饱和(下表)。真实实验对 block 配置(每块处理的行数 RPB、每块的 warp 数)做了单变量扫描:
RPB(每块处理行数)扫描,固定 num_warps=8:
| RPB | t/s | 相对默认 |
|---|---|---|
| 1(默认) | 214.60 ± 0.45 | 1.00x |
| 2 | 276.90 ± 5.73 | 1.29x |
| 4 | 293.82 ± 1.82 | 1.37x |
| 8 | 285.53 ± 0.49 | 1.33x |
num_warps 扫描,固定 RPB=1:
| num_warps | t/s | 相对默认 |
|---|---|---|
| 8(默认) | 214.52 ± 0.33 | 1.00x |
| 4 | 254.58 ± 0.75 | 1.19x |
| 2 | 308.85 ± 2.08 | 1.44x |
| 1 | 290.17 ± 1.07 | 1.35x |
两个维度的结论一致:block 配置对 MoE decode 吞吐有 ~1.4x 的影响,且最优不在默认值。RPB=4 或 num_warps=2 都能把 214 t/s 提到 290-310 t/s,成本为零(只改配置不改算法)。注意两张扫描是两次独立测量,各自带默认配置行,两次默认值(214.60 与 214.52)的微小差异在噪声范围内。
这组数据给 Agent 优化(第 19.5-19.6 节)提供了第一个抓手:decode 性能不是「算法没选对」,而是「访存没吃饱」——优化方向是让 GEMV 的加载更饱和,而不是换更好的 GEMM。这也呼应第 11.12.5 节:decode 形状算术强度低(I < 10),属于访存受限类,tile/num_warps 的选择要以占用率和访存吞吐为先。
19.5 Agent 优化 KV cache 访问
decode 的第二个算子是 attention:每生成一个 token,query 要和 KV cache 里的全部历史 token 做相似度计算。它的访问模式是 M=1 的读取——读多写一、写完不回读。KV cache 随 context 线性增长:context 越长,KV 读取在 decode 流量里的占比越高。
KV cache 优化的完整轨迹在第 17 章(FA Decode:naive → split-KV,4.17x/5.13x/5.26x;Paged Attention 3.75x),本章不重复,只提炼两个对 Agent 有用的结论:
- KV cache 优化的杠杆是结构改变,不是配置搜索。FA Decode 轨迹里 1.83x → 3.4x 来自 block/warp 配置,4.35x → 5.26x 来自 split-KV(把 N 维切成 4 份、grid 翻倍、加一个轻量归约 kernel)。结构改变的前提是 profiling 信号——「grid=32 只填了 GPU 一半的 SM」这个观察才是方向来源。
- 优化手段的有效性取决于题目形状。Paged Attention 在 B=64 时 3.75x,但小 N(N=2048)只有 1.46x——split-KV 的多次 dispatch 开销在小题上盖过了并行度收益。Agent 做 KV cache 优化时,第一件事是确认题目大小在哪个区间。
为什么 KV cache 值得单独拆出来?decode 的时间预算里,attention、norm、launch、sync 这类与格式无关的固定开销约占 30%(时间分解推导见第 19.6.1 节)。KV cache 读取属于其中;当 context 变长,它从固定开销变成增长项——这是 decode 吞吐主线(权重带宽,第 19.4 节)之外的第二个变量。
19.6 Agent 优化精度选择
decode 的吞吐主线是「每 token 读取量 ÷ 带宽」(第 19.4 节),那是不是量化越低越好?下面这个真实实验会推翻这个直觉——精度选择和调度配置是组合实验,单独看任何一个都会得出错误结论。
实验:LFM2.5-8B-A1B 的 Q4_K_M(4.79 GiB)vs Q8_0(8.4 GiB),llama.cpp ROCm 后端,RX 7900 XTX,512 prompt / 128 gen,5 次重复:
| 格式 | nwarps | decode t/s | vs 该格式默认 |
|---|---|---|---|
| Q4_K_M | 默认 | 250.8 ± 0.7 | ref |
| Q4_K_M | 1 | 257.3 ± 1.0 | +2.6% |
| Q4_K_M | 2 | 251.2 ± 1.5 | +0.2% |
| Q8_0 | 默认 | 225.4 ± 0.5 | ref |
| Q8_0 | 1 | 345.5 ± 1.2 | +53.3% |
| Q8_0 | 2 | 333.4 ± 1.8 | +47.9% |
| Q8_0 | 4 | 284.1 ± 1.1 | +26.0% |
两个反直觉结论:
- 默认配置下 Q4 只比 Q8 快 11%(250.8 vs 225.4)——权重小了 1.75 倍,速度只快 11%。原因是 decode 不是纯带宽受限:时间预算里权重 DRAM 读取约占 57%,Q4 的反量化(格式税)约 13%,attention/norm/launch 等固定开销约 30%(推导见 19.6.1)。权重减半 ≠ 时间减半。
- 统一 nw=1 后 Q8 反超 Q4(345.5 vs 257.3,Q4/Q8 = 0.745x)——Q8 反而快 34%,与 NVIDIA 完全相反(3080: 1.148x、5060 Ti: 1.232x,都是 Q4 更快)。
原因不在格式本身,而在默认调度:llama.cpp #23528 之后,Q4_K_M 进了 RDNA3 的 nw=1 白名单(默认就单 warp),Q8_0 不在白名单(默认 nw=8)——所以 Q8 切 nw=1 释放了 +53.3%,Q4 只有 +2.6%。把两者都拉到 nw=1 的公平起点,AMD 上 Q8 的简单反量化路径(8-bit 直接转 fp16、无 scale/min 位域解包)反而更吃流水线:
| 指标 | 3080 | 5060 Ti | 7900 XTX |
|---|---|---|---|
| Q4/Q8 @ nw=1 | 1.148x | 1.232x | 0.745x |
| Q8 nw=1 增益 | +84.5% | +141.4% | +53.3% |
19.6.1 Q4 反量化贵在哪:vecdot 指令级实测
E2E 表格只能看到「结果」。Q4 反量化到底消耗多少计算量,可以直接采样:用 HIP 微基准对 Q4_K 和 Q8_0 的 vec_dot 路径做 2048 个 block、每轮 16.4 亿次调用的实测(RX 7900 XTX,gfx1100):
| 路径 | ps/block | 相对 |
|---|---|---|
| Q8_0 full vec_dot | 5.1 | 1.00x |
| Q4_K full vec_dot | 9.1 | 1.77x |
单次调用慢 77%。看每条路径的指令构成(源码静态分析,每 block):
| 操作 | Q8_0 | Q4_K |
|---|---|---|
| dp4a(整数点积) | 2 | 8 |
| 浮点乘加 | 1 | 10 |
| 4-bit nibble 解包 | 0 | 4 |
| scale 位域解包 | 0 | 4 条位操作 |
| 每次调用的数据读取 | ~16 B | ~34 B |
Q4_K 的指令数是 Q8_0 的 10 倍(K-quant 的双 dot product、逐 sub-block 的 scale/min 修正),但实测只慢 1.77 倍——原因:dp4a 在 RDNA3 上只需 1 个周期,浮点操作 4 周期延迟但可被编译器与 dp4a 交错隐藏,真正的差异大头是内存:Q4_K 每次调用读 ~34B vs Q8_0 的 ~16B(2.1 倍流量),且 144B 的 block 跨 2 条 128B 的 L1 cache line(34B 只需 1 条)。
但在 MMVQ kernel 的每一行(4096 元素)里,这个差距又被调用次数抵消:
| Q8_0 | Q4_K | |
|---|---|---|
| vec_dot 调用/线程/行 | 16 | 8 |
| 每行 vec_dot 时间/线程 | 16 × 5.1 ps = 82 ps | 8 × 9.1 ps = 73 ps |
vec_dot 层面 Q4_K 每行反而略快(调用次数减半抵消了单次慢 1.77x)。所以 Q4 的「格式税」不在 vec_dot 内部,而在 kernel 外层:更大的 block 跨 cache line、MMVQ 主循环里的索引与 scale 解包开销、以及共享内存归约相对占比的变化——这些都只能在 E2E 层观察到,微基准测不到。
时间占比:decode 的时间不是「读权重」一项。从 3080 上的实测反推(Q8_0 为 baseline,nw=1):
- 权重 DRAM 读取约 57%;
- Q4 反量化(格式税)约 13%;
- attention / norm / launch / sync 等固定开销约 30%。
由此可以算出 Q4 的理论上限:如果反量化开销为零,加速上限是 1 / (0.43 + 0.57/1.75) = 1.32x;加上 13% 的反量化税,实际只有 1 / (0.43 + 0.57/1.75 + 0.13) = 1.13x——与实测的 1.12x 吻合。权重减半带来的带宽红利,大部分被 30% 的固定开销和 13% 的格式税吃掉了。 这也解释了为什么 nw=1 会缩小 Q4/Q8 的差距(3080 上从默认的 1.37x 缩到 1.12x):wave/occupancy 开销越少,Q4 的反量化占比越大,带宽优势越被稀释。
prefill 的结论也不同:Q4 7022 t/s vs Q8 3088 t/s——Q4 在 prefill 上快 2.3 倍(Q4 走更简单的量化路径)。所以「哪个精度更好」在 decode 和 prefill 是两个答案。
还有一个测量方法论的教训:热缓存微基准会系统性高估小权重的量化收益。7900 XTX 上同一批权重的冷/热缓存对照显示,量化收益的交叉点从热缓存下的 ~105MB 左移到冷缓存下的 ~50MB;而真实 decode 的权重以 GB 计,远大于 96MB Infinity Cache,是纯 DRAM 流式访问。Agent 评估精度选择时,必须用真实 e2e 的测量,不能用微基准的数字外推。
给 Agent 的落地结论:
- 精度 × 调度(nwarps/RPB)是组合实验:只换格式不换调度,会得到「Q4 更好」的错误结论;
- 工具封装(第 15 章)时,benchmark 工具要支持「格式 + 调度参数」同时作为输入,输出按组合标注;
- AMD 消费卡的精度选择空间有限(FP8/INT4 张量核路径不可用,见第 19.1 节),GGUF Q4/Q8 与 AWQ 之外没有更细的档位。
19.7 单卡边界与下一步
本章在单卡上能做的就到这里,三重边界:
- 显存边界:Q4_K_M 4.79 GiB 全量驻留是前提;batch 或 context 增长时,KV cache 与权重争 16GB 总量。fp16 8B(约 17 GB)在单卡上不可行——量化不是可选项。
- 生态边界:消费卡(RDNA3)没有 FP8 指令、没有 INT4 翻倍吞吐(第 19.1 节),精度选择止步于 GGUF/AWQ;PagedAttention、多卡 TP、并发调度均留给 hello-mlsys(第 19.5 节的 Paged Attention 只是教学 kernel,不是生产实现)。
- Agent 边界:本章 Agent 能做的是受控实验(换配置、换格式、跑对比),不能发明新算子结构——第 15-17 章已经实证了这个边界(纯 Agent 自动优化贡献接近 0%,关键洞察全部来自人)。
本章小结
- decode 与 prefill 瓶颈不同:prefill 是算力受限的 GEMM,decode 是访存受限的 GEMV。
- decode 的吞吐主线是「每 token 读权重的字节数 ÷ 显存带宽」,MoE 量化模型尤其明显(A1B 稀疏激活让每 token 读取量远小于全量权重)。
- block 配置(RPB / num_warps)对 MoE decode 有 ~1.4x 影响,且默认值不是最优——这是零成本的调优空间。
- 精度选择必须与调度配置一起看:默认配置下 Q4 只比 Q8 快 11%,统一 nw=1 后 Q8 反超 34%(AMD 与 NVIDIA 结论相反);prefill 上 Q4 快 2.3 倍。
- decode 不是纯带宽受限:权重读取 ~57%、格式税 ~13%、固定开销 ~30%——权重减半 ≠ 时间减半。
- 本章实验数据在 RX 7900 XTX(gfx1100)+ llama.cpp 上实测,书基线 RX 9070 XT 上可用同样流程复测;热缓存微基准不可外推。
延伸阅读
- llama.cpp ROCm 构建与 GGUF 量化格式(官方 README 的 ROCm 构建说明与 GGUF 文档)
- 第 17 章 多轮优化实战 — FA Decode 的 M=1 注意力优化轨迹,decode 的另一半算子
- 第 11.12 节 tile 形状怎么选 — 访存受限 shape 的配置选择规则
- llama.cpp
llama-bench工具与LLAMA_MMVQ_NWARPS/LLAMA_MMVQ_RPB环境变量——本章 RPB/nwarps 扫描所用的实验协议 - 第 15 章 工具封装 — 把本章的受控实验协议封装成 Agent 可调用的工具