系列:《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 篇 · 总纲(阿里云社区)
上一篇:10-1《长上下文为什么必须稀疏:O(n²) vs O(n·k)》 | 下一篇:10-3《诚实的坑:哪些场景稀疏无收益》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎里 sparse_attn_head 的 top-k 块选择:拆探针均匀抽样、prefill 重要性预留一半预算、贪心补满与 recency 保险四段代码,讲清它如何锁定针、保证逐位确定,以及 k=1 板端翻车的机制。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、sparse-attn、sparse_attn_head、top-k、长上下文、RK3588
导语:sparse_attn_head 凭什么判断哪块该看?本篇拆开它的 top-k 块选择:探针、prefill 重要性、贪心补满与 recency 保险四段代码,讲清它如何锁定埋在长上下文里的"针"、保证每次选块逐位一致,以及把 k 调到 1 时板端为何肉眼可见地翻车。
10-1 画了饼:把 KV 切成 32 token 的块,只对"最该看的 k 块"做精确注意力。今天拆实现——sparse_attn_head(vllm_safetensors.c 第 8482 行起):它凭什么判断哪块该看?怎么保证同样输入每次选的块一样?把 k 调小到 1 会怎样?
1. 知识点:块选择的三个信号 + 一个保险
"该看的块"不是猜的,引擎用三种信息决定:
- 探针(probe):query 和块内均匀抽样的几个 K 算点积,取最大值当"这块的潜在注意力分"。为什么抽样而不是整块全算?因为全算就是精确注意力了——探针要的是"O(1)/块"的廉价筛选。为什么取 max 而不是第一个 token?10-1 里那颗"needle"(需要被记住的针)可能藏在块中间:块头 dull 不代表整块没戏,多抽几个位置才不漏针(代码注释原话在第 8536–8539 行)。
- prefill 重要性(importance):prefill 阶段真实分配了多少注意力给每个 token,会按头归约留一份记录(
imp_head→prefill_importance,分配缓冲在第 8289 行)。口径注意:prefill 用精确注意力时它就是精确的;prefill 也开稀疏时它就是稀疏的——记录的和跑的一致。decode 选块时,给"prefill 真看过"的块预留一半预算。为什么?探针看的是"当前 query 觉得谁重要",prefill 记录的是"内容本身谁重要"——针的注意力质量可能不大(softmax 质量摊平),但位置是真被读过的。两路信号互补。 - top-k 兜底:剩下的一半预算,按探针分从高到低补满。
再加一个工程保险:最近一块永远保留(recency insurance)——解码位置附近的 KV 几乎必然被当前词高强度关注,直接锁定,防止探针抖动把它挤掉。
确定性红线:同样 query + 同样的 KV,选的块必须逐位一致(否则没法做"可复现推理",Day 23 起的地基)。实现上:并列分按块下标决胜,不用任何随机数——函数头注释把它记为 gumbel_argmax_001 红线(第 233 行)。
代价也在这:softmax 只在选中的块上重归一化。如果该看的高分块被漏选,输出就错——这是 k 太小会翻车(第 3 节)的根本原因。
2. 对应代码:逐段导读(vllm_safetensors.c 第 8482–8781 行)
探针循环(第 8534–8585 行)——每块抽 n_probe(默认 8)个均匀位置:
if (n_probe < 1) n_probe = 1;
for (int b = 0; b < n_blocks; b++) {
/* 每块 */
float best = -1e30f;
int p0 = b * bs;
int p_end = p0 + bs; if (p_end > seq_len) p_end = seq_len;
int step = (p_end - p0) / n_probe; if (step < 1) step = 1;
for (int ps = p0; ps < p_end; ps += step) {
/* 块内均匀抽 n_probe 个 */
... dot = query · K[ps][kh]; /* NEON vfma 点积 */
if (dot > best) best = dot;
}
probe[b] = best; /* 块分 = 抽样中的最大点积 */
if (importance)
for (int ps = p0; ps < p_end; ps++) imp_sum[b] += importance[ps];
}
要点:step = (p_end-p0)/n_probe 保证抽样均匀覆盖整块;点积只算 K 的当前头(kh)128 维,成本 ~8×128 MAC/块,整段下来 ≈ 一次全量注意力的几十分之一。
重要性预选(第 8587–8605 行):
int n_imp = 0;
if (importance) {
int budget = (k_blocks + 1) / 2; /* 预算一半,至少 1 块 */
for (int i = 0; i < n_blocks && n_imp < budget; i++) {
int best = -1;
for (int j = 0; j < n_blocks; j++) {
/* 贪心挑 imp_sum 最大且未用的块 */
if (used[j]) continue;
if (best < 0 || imp_sum[j] > imp_sum[best] ||
(imp_sum[j] == imp_sum[best] && j < best)) best = j;
}
...
sel[n_imp++] = best; used[best] = 1;
}
}
budget = (k+1)/2:k=32 时先锁 16 块给重要性,k=1 时预算 =1——唯一的名额被重要性吃掉,探针完全插不上手(第 3 节 k=1 的翻车与这有关)。
top-k 补满(第 8608–8619 行):同样贪心,按 probe[] 从大到小挑,probe 相等时下标小的赢——这就是"确定性"的落点。挑不满 k 块时(比如块数本来就少)自然停止,不强行凑数。
recency 保险(第 8621–8631 行):最后一块(n_blocks-1)没被选上时,用它替换掉探针选中的最低分块;重要性预留的块不可被挤掉。
之后三段是"对选中的块做精确注意力":逐 token 打分(第 8633–8695 行,q8 KV 走 int8×int8 点积 + scale,fp32 KV 走 f32)、在选中集上减 max + softmax(第 8697–8706 行)、按权重累加 V(第 8708–8763 行)——与 9-2 同款内核纪律,只是循环范围从 [0, seq_len) 变成 sel[] 指向的块。
decode 侧调用点(第 9176–9191 行)把默认参数原样传入;prefill_importance 是 prefill 结束时按头归约出来的(第 11020–11030 行,imp_head 各头累加到 prefill_importance[s])。
3. 改动后果:把 --sparse-k 从 32 调到 1,板端肉眼可见地翻车
口径:RK3588 / Qwen3-VL-2B / 2026-09 /
/v1/chat/completions贪婪 / 同一段 2061-token 长上下文(事实填充 + 中段埋针"Zara's favorite food is ramen" + 尾部提问),只切稀疏开关与 k。质量判据 = 针是否被召回(needle 一词是否出现在输出里)。
| 配置 | needle 召回 | 板端输出(截断) | decode attn ms/词 | decode 总耗时 ms/词 | prefill |
|---|---|---|---|---|---|
| 精确 | YES | Zara's favorite food is ramen.(9 词) |
49.4 | 93.0 | 47.5 s |
| 稀疏 k=32 | YES | Zara's favorite food is ramen.(9 词,与精确逐字节一致) |
44.1 | 92.4 | 47.7 s |
| 稀疏 k=1 | NO | The Leo as is is in Sydney is I the horse Leo is Leo is …(40 词复读碎语,触 max_tokens 上限仍未收尾) |
12.8 | 56.2 | 30.0 s |
判读:
- k=32 与精确在输出上逐字节一致——长上下文里"针"被成功锁定,召回 YES;这正是文档里"稀疏 k=32 输出与精确一致"口径的板端复现(10-1 表 B 的 4K 档还把 decode attn 从 110.7 压到 55.4ms);
- k=1 最快,但输出塌了:模型丢掉了几乎全部上下文,开始复读附近碎片("Leo is … Leo is")——肉眼可见的劣化,不是数值抖动。它 decode 只要 56.2ms/词(attn 12.8ms),比 k=32 快 39%——省下的不是白拿的,是把注意力分布砍到只剩 1 块(32 token 视野)换来的;
- 为什么 k=1 必翻车:回到第 1、2 节——k=1 时唯一名额先被"prefill 重要性"锁走(预算
(1+1)/2=1),重要性最高的是尾部高频被注意的块;埋在中间、只被问题真正需要一次的针,既挤不进重要性名额,又因探针名额为 0 而根本没机会。注意力预算小于注意力分布的真实支撑面,必然翻车。
诚实标注①:最初我们用
/v1/completions的裸补全格式(Question:…\nAnswer:)测同一批场景,结果连精确注意力都答不出(模型输出"no information about Zarra",针明明在上下文里)——这是 2B instruct 模型对"非聊天模板"输入的拒答行为,不是引擎/稀疏的问题。换成标准聊天模板后精确与 k=32 立即逐字节答对。这个坑值得记住:测长上下文检索,先用 chat 模板确认模型"本来就能答",再谈引擎优化对质量的影响。诚实标注②:质量"下边界"的完整扫描(k=16/8/4…)本文档在板端只做到 k=1 这一个断层点;仓库 x86 基准机(8B/2026-08-28,优化配置与边界说明.md 第 42 行)的 S=4096 needle 扫描为:k=32 召回 YES、ppl 3.37;k=16 即失败(NO、ppl 6.2);k=8 严重退化(答案错、ppl 7.6)——本项目默认 k=32 是这么定下来的。若你的模型/任务与 8B 不同,边界要自己重扫。
4. 学员调试任务
- A 档(板端动手):取同一段 ≈2K 的 needle prompt(事实填充 + 中段埋针 + 尾部提问,走
/v1/chat/completions),分别跑精确、--sparse-attn --sparse-k 32、--sparse-k 1,记录:needle 是否召回、生成文本、[DEC-SINGLE]的 attn ms/词。把 k 再调成 2、4、8、16,找你这段 prompt 的召回临界 k(参考:x86 8B 的临界是 32,见第 3 节标注②)。 - B 档(纯读源码):在
sparse_attn_head的"探针/重要性/top-k/recency"四段各标注起止行号,回答三个问题:① 重要性预算公式(k+1)/2在 k=1 与 k=32 时各锁几块?② "并列按下标决胜"落在哪几行?③ recency 为什么只能挤掉探针块、不能挤掉重要性块?
预期输出:你能对着代码讲清"一块为什么被选中"的完整链路(重要性 → 探针 top-k → recency),并解释 k=1 翻车的机制。
收尾
- 本篇源码点名:vllm_safetensors.c(
sparse_attn_head第 8482 行起;探针 8534、重要性 8587、top-k 8608、recency 8621、门控 9176;prefill 稀疏入口 10210/10805;prefill_importance归约 11020)、main.c(--sparse-k第 4874 行)、优化配置与边界说明.md(质量下边界第 42 行)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:稀疏又准又省?别急着全开——10-3 是诚实的反面清单:哪些上下文长度、哪些模型形态、哪些开关组合下,稀疏没有收益甚至更慢。