系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:9-3《q8 KV 精度对照——量化进注意力,输出差多少》 | 下一篇:10-2《sparse top-k 块选择:sparse_attn_head 的实现》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:大模型推理的长上下文为什么必须稀疏:decode 每词耗时约等于常数 GEMM 加线性增长的注意力,用板端三档实测看注意力占比从约 18% 涨到 71%,讲清块稀疏把每词成本压成与 n 无关的平台。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、长上下文、稀疏注意力、sparse-attn、O(n²)、RK3588
导语:长上下文为什么必须稀疏,账要先算清:decode 的 GEMM 是常数、注意力却随上下文线性增长,RK3588 上实测注意力占比从约 18% 涨到 71%。本篇用 O(n²) 与 O(n·k) 的对比,讲清块稀疏如何把每词成本压成与 n 无关的平台,为 Qwen3-VL 这类千问大模型的长文推理铺路。
9-2 说 decode 是"单 q 单遍扫全 KV",9-3 给了它精度背书——可这个"全"字有个代价:上下文每涨一倍,每个新词都要多扫一倍的 KV。今天先不写代码,把账算清楚:长上下文里,为什么全量注意力注定扛不住、而稀疏(sparse)是唯一划算的解。
1. 知识点:decode 的 GEMM 是常数,注意力是线性——占比会一路涨
引擎一次 decode(生成一个词)干两件事:
- GEMM 部分(qkv 投影 / O / gate+up / down / lm_head):读一遍权重矩阵,与上下文长度无关。本板实测"每词总耗时 − 注意力"恒为 ~43ms(第 3 节表 B 可复算),即 GEMM 是常数;
- 注意力部分:query 要对历史每一个 token 算 Q·K^T、再按权重累加 V(9-2 的"单遍扫全 KV")。历史有 n 个 token,就扫 n 个——随 n 线性增长。
于是每个词的耗时 ≈ C_GEMM + C_attn × n。n 越大,注意力占比越高(下表就是本板实测的 decode 注意力占比,数据见第 3 节表 B):
| 上下文 n | 每词注意力(精确) | 每词总耗时(精确) | 注意力占比 |
|---|---|---|---|
| 506 | 9.7 ms | 52.5 ms | ~18%(低) |
| 2046 | 48.9 ms | 92.3 ms | ~53%(中) |
| 4059 | 110.7 ms | 155.2 ms | ~71%(高) |
这不是数学题,是内存带宽题:q8 KV 每个 (token, 层) 要 2112 字节(9-1),n=8K 时每层一次全扫就是 ~17MB,28 层 ~480MB(还要按头重复读)。所以同样的模型,短上下文跑得动,长上下文被注意力拖垮——这正是本项目基准报告里 llama.cpp 的实测走势(RK3588 同板):decode 从 1K 档 98.6ms/词涨到 8K 档 411.6ms/词(RK3588_性能基准报告.md)。
稀疏思路:注意力矩阵里绝大多数 token 的权重接近 0——大部分历史对当前词的判断没用。与其扫全量,不如:
- 把历史 KV 切成块(block,默认 32 token/块);
- 先用极廉价的"探针"找出最该看的前 k 块;
- 只对选中的 k 块做全量精确注意力。
这样每词注意力成本从 O(n) 变成 O(n/block + k×block):第一项是找块的探针(每块只算几个点积,很便宜),第二项是与 n 无关的常数(k=32、block=32 → 视野固定在 ~1024 token)。上下文再长,每词注意力都封顶在这个常数里。prefill 侧同理:一批 nb 个 token 对全历史的分数矩阵是 O(nb×n),块稀疏后只看 O(nb×k×block)。
2. 对应代码:稀疏开关藏在哪、什么时候生效
先看全局默认与开关(vllm_safetensors.c 第 235–238 行):
int g_sparse_attn = 0; /* 0 = exact attention (default) */
int g_sparse_k = 32; /* top KV blocks kept per head ... */
int g_sparse_block = 32; /* positions per KV block */
int g_sparse_probe = 8; /* probe samples per block (max-dot fusion) */
默认 OFF——不传参数时引擎跑的是精确注意力(与前 9 天所有实验同口径)。要打开用 --sparse-attn(main.c 第 4872 行起解析 --sparse-attn / --sparse-k / --sparse-block / --sparse-probe)。
真正的门控在两条主路径上,条件一模一样:
decode(vllm_safetensors.c 第 9176 行):
if (g_sparse_attn && !st->use_kv_q4 && c->seq_len > g_sparse_block * 2) {
/* block-probe + top-k 选择 + 有界注意力(sparse_attn_head,10-2 拆) */
prefill(第 10805 行):
if (g_sparse_attn && seq_len > g_sparse_block * 2) {
st_attn_batched_packed_sparse(...); /* 块稀疏批量注意力 */
} else {
st_attn_batched_packed(...); /* 精确批量注意力 */
}
两个要点:
seq_len > 2×block(即 >64 token)才进入稀疏。短于 64 token 时没得省,直接走精确路径——这个"边界"是 10-3 的关键材料;- decode 侧多一个
!use_kv_q4:KV 被压成 q4(--kv-q4)时稀疏不可用(10-3 细讲)。
开关、默认参数与门控,都汇总在 优化配置与边界说明.md 第 23、31 行的统一口径里。
3. 改动后果:同一台板子,稀疏开/关的实测差
口径:RK3588(Orange Pi 5 Plus)/ Qwen3-VL-2B-Instruct / 2026-09 / serve 进程内直测 /
/v1/completions贪婪 /VLLM_DEC_PROF=1引擎逐词计时 / 默认 dual 权重 / 默认 4 线程 / 进程串行、无并发。精确(exact)与稀疏(--sparse-attn --sparse-k 32)用完全相同的 prompt 文本各跑一遍,只有开关不同。上下文 n 取引擎日志[PREFILL-TIMING] n=...的真实值。
表 A:prefill(TTFT 的注意力部分)
| 上下文 n | 精确 prefill | 稀疏 prefill(k=32) | 精确 GEMM/ATTN | 稀疏 GEMM/ATTN | 总时长变化 |
|---|---|---|---|---|---|
| 506 | 13.09 s | 13.65 s | 87.8% / 9.0% | 85.8% / 11.1% | +4.3%(负优化) |
| 2046 | 51.84 s | 51.50 s | 61.2% / 35.9% | 61.8% / 34.9% | −0.6%(无差别) |
| 4059 | 148.50 s | 106.11 s | 39.1% / 58.9% | 54.3% / 42.6% | −28.6% |
表 B:decode(每词中位数;精确= q8 KV 单遍全扫,稀疏= sparse_attn_head)
| 上下文 n | 精确 attn ms/词 | 稀疏(k32) attn ms/词 | 精确每词总耗时 | 稀疏(k32)每词总耗时 |
|---|---|---|---|---|
| 506 | 9.7 | 13.4 | 52.5 ms | 56.6 ms |
| 2046 | 48.9 | 39.1 | 92.3 ms | 82.3 ms |
| 4059 | 110.7 | 55.4 | 155.2 ms | 100.0 ms |
怎么读这两张表(判读比数字重要):
- decode 的 GEMM 真的是常数:把"每词总耗时 − attn"算出来——精确三档分别是 52.5−9.7=42.8ms、92.3−48.9=43.4ms、155.2−110.7=44.5ms,≈43ms 恒定。变的全是注意力;
- 精确的注意力随上下文线性涨:n 从 506 涨到 4059(×8),精确 attn 从 9.7ms 涨到 110.7ms(×11)——q8 KV 单遍扫全量(9-2)也救不了"全量"本身;
- 稀疏把注意力压成一个平台:k=32 时 n 从 2046 到 4059 只让 attn 从 39.1→55.4ms(涨的是探针扫的块数,不是全量 KV);到 8K/16K 这条曲线会继续摊平(10-3 有边界说明);
- 但总时长里 GEMM ~43ms 是地板:n=506 时精确 attn 才 9.7ms、sparse 反而 13.4ms——短上下文开稀疏是负优化(10-3 细讲)。
测量纪律说明:表 B 的"每词中位数"来自引擎
[DEC-SINGLE]逐词日志(VLLM_DEC_PROF=1);模型生成长度因配置而异(11–32 词不等,提前撞 EOS 即停),中位数不受词数影响。全部为同机串行单轮运行;板子长时间满载有热节流风险(仓库文档已注明可到 ~1.4×),跨时间段绝对值仅供参考,同条件对照结论有效——这也是每张表都用"同一 prompt、只切开关"的原因。更大规模旁证(仓库文档,配置与平台各异、仅供趋势参考):x86 基准机(Qwen3-VL-8B/16 线程/2026-08-28,优化配置与边界说明.md 第 33–39 行)同条件前后对照:8K prefill 的 ATTN 时间 61.6s→16.0s(-74%),prefill 总时长 232.5s→206.2s(-11.3%,GEMM 占大头所以总时长只省 11%);RK3588 板端基准报告(同一仓库)中 vllm 优化档(sparse k32 + spec 等组合)decode 8K 为 131.9–136.7ms/词,llama.cpp 全量注意力到 8K 涨到 409.4–411.6ms/词——全量注意力的线性成本在长上下文把 decode 拖垮,是两套引擎都验证过的方向。
4. 学员调试任务
- A 档(板端动手):按第 3 节口径自己复测一组"同一 prompt、精确 vs
--sparse-attn --sparse-k 32":短档(n≈0.5K)与长档(n≈4K)各一对。记录每对里[PREFILL-TIMING]的n=与[DEC-SINGLE]的attn=,回答:n 涨 8 倍时,精确 attn 涨几倍?稀疏 attn 呢? - B 档(纯读源码):在 9176/10805 两处门控前各打一个逻辑断点,回答:上下文 32 token 时会不会进稀疏分支?64 token 时?为什么门槛是
2×block而不是更早?(提示:bs=32 时 64 token 只有 2 块,而 k 默认 32——先算算n_blocks与k的关系,答案和第 3 节 n=506 那行"想剪枝却剪不掉"是同一个机制。)
预期输出:你能画出一条"精确 attn ~ 线性、稀疏 attn ~ 平台"的曲线,并说清门槛 2×block 的意义。
收尾
- 本篇源码点名:vllm_safetensors.c(第 235–238 行默认参数、第 9176/10805 行门控)、main.c(第 4872–4882 行 CLI)、优化配置与边界说明.md(第 23、31 行)。
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:"只扫该看的块"说得轻巧——怎么选出"该看的块"才是稀疏注意力的灵魂:10-2 拆
sparse_attn_head,看它怎么用探针、重要性信号和 top-k 把 8K 上下文裁成 32 块。