系列:《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 篇 · 总纲(阿里云社区)
上一篇:8-2《在线 softmax:为什么不能先算完 e^x 再除》 | 下一篇:第 9 天《q8 KV 与单查询路径》
源码精读篇:本文为源码/方法论精读,无独立实测;文中数字均引述仓库 docs 的板端实测记录
一句话导读:推理引擎的 KV 缓存结构:token-major 与 head-major 两种排法的地址公式与访存差异,读引擎 token 优先加按块分配的下标代码,讲清按 token 还是按头为何是带宽问题。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、KV cache、token-major、head-major、decode、带宽
导语:打分要回看前面所有 token 的 K、V,而这些缓存"怎么排"大有讲究:排错一个维度,访存就从顺序流变成跳着读。手搓 Qwen 推理引擎选的是 token-major 布局,因为它要同时服务 prefill 的整行乘与 decode 的单 token 整块扫。这篇从一行地址公式里读出布局真相。
打分要"回看"前面所有 token 的 K、V。这些 K/V 不是散落各处的,它们住在一个叫 KV cache 的连续内存里——但"怎么排"大有讲究:排错一个维度,访存就从顺序流变成跳着读。今天把生产引擎的真实 KV 布局拆给你看。
1. 知识点:两种正交的排法
一个 token 的 K/V 是 [n_kv_heads × head_dim] 的矩阵。这么多 token 的缓存,排法有两个极端:
- token-major(token 连续):先按 token 排,每个 token 里再按
[head][dim]排——cache[t][h*dim + d]。第 t 个 token 的整个 K 行是连续的一段kv_dim = n_kv_heads*head_dim字节; - head-major(头连续):先按头排,每个头里再按 token 排——
cache[h][t*dim + d]。第 h 个头在所有 token 上的同一维是连续的。
选谁取决于主访存模式:
- prefill(一批 token 同时算):要对每个 token 的整行 K 做矩阵乘(Q@K^T),token-major 让"一次取一行"变成顺序读;
- decode(单 token 逐词生成):每个新 token 只产生一行 Q,要扫遍历史所有 token 的 K——两种排法都要跨 token 跳着读,但 token-major 下每跳读的是"连续的一整块头"(SIMD 友好)。
引擎选的是 token-major(8-1 那段代码已经露过脸),因为它同时服务 prefill 的整行乘与 decode 的单 token 整块扫。
2. 对应代码:一行代码里的布局真相
证据在 8-1 引用的参考实现里(vllm_transformer.c 第 101–102 行与 122–123 行):
const float *kh = k_cache + t * kv_dim + h * head_dim; /* K:第 t token、第 h 头 */
const float *vh = v_cache + t * kv_dim + h * head_dim; /* V:同式 */
下标 t * kv_dim 说明 token 在最外层(t 每加一,地址跳 kv_dim),h * head_dim 是内层头偏移——这就是 token-major + head 内联 dim 的布局。kv_dim = n_kv_heads * head_dim(一个 token 的 K 总长)。
生产 q8 内核(vllm_safetensors.c 第 6633–6636 行)也完全同构,只是多了一层"按块":
const int8_t *kt = k_cache_q8[t / kv_bs] + (size_t)(t % kv_bs) * kv_dim + kh_off;
float ks = kscale[(size_t)t * nkv] * KVQ_INV; /* 每个 (token, head) 一个 scale */
t / kv_bs 定位块、t % kv_bs 定位块内 token——块外 token 连续、块内也是 token 连续,本质还是 token-major,只是加了分块以便随 token 数增长按需分配(每 token 每层只存一份,9-3 会讲块内布局)。
3. 改动后果:把布局改成 head-major,decode 会发生什么
想象把缓存改成 head-major:k_cache + h * seq_cap * head_dim + t * head_dim。功能上完全等价(8-1 的参考代码改一行下标照样算对),但代价藏在访存里:
- decode 单 token 扫全历史:对每个头 h,要按
t步进读取t*head_dim跳一块——从"连续块跳读"退化成"每 head_dim 个元素跨一大段",TLB/cache line 命中率下降; - prefill 的整行乘:一次要 gather 跨 head 的数据,SIMD 连续加载退化成低效的逐段取;
- q8 版本更惨:q8 KV 的 scale 是"每 token 每头"独立存的(
kscale[t*nkv]),head-major 会让"同一 token 的 nkv 个 scale"彼此远离,随机访问加倍。
结论:布局不改数学、只改访存,但访存决定吞吐——"按 token 还是按头"不是风格问题,是 bandwidth 问题(5-1 的尺子再次适用:同样的字节,排法不同,有效带宽差几倍)。
画图任务预告:这个布局光看代码记不住,动手画一遍(任务 A 给了 2 头 × 4 token 的模板)。
4. 学员调试任务
- A 档(画图 + 读码):以引擎真实参数为例(
n_kv_heads=8、head_dim=128,kv_dim=1024)手画 2 头 × 4 token 的 token-major KV 布局:- 标出每个
(t, h)的起始地址公式t*1024 + h*128; - 用不同颜色标 decode 时"对 head 0 扫 4 个 token"的访存顺序,数数它跳了几次;
- 再画一遍 head-major 版,对比跳转次数。
- 标出每个
- B 档(纯读源码):在
vllm_transformer.c与vllm_safetensors.c各找一处 KV 读取下标,用"外层是什么、内层是什么"一句话描述其布局,并回答:kv_bs(块大小)为什么也要乘进去。
预期输出:你能不看代码画出 token-major 的 KV 地址公式,并解释"decode 扫历史时两种布局的访存差异"为什么影响吞吐。
收尾
- 本篇源码点名:vllm_transformer.c(KV 下标第 101–102/122–123 行)、vllm_safetensors.c(q8 KV 分块读取第 6633–6636 行)。
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:KV 存在内存里,可它太肥了——f16 存 8K 上下文要 ~560MB,板上伤不起。第 9 天把 KV 也量化成 q8:带宽省一半以上,还顺带把 decode 的单查询路径一起讲了。