系列:《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 篇 · 总纲(阿里云社区)
上一篇:6-1《ARMv8.2 dotprod:vdotq_s32 一条指令做 4 个点积》 | 下一篇:6-3《4x4 asm 内核:从 llama.cpp 提取的 MIT 代码》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 8x8 布局重排:把权重的排队方式在转换期改成 8 行交织布局,讲清它为何让宽矩阵 GEMM 快约 1.7 倍、窄 QKV 不受影响,且两种布局位级一致。
关键词:手搓 Qwen 推理引擎、千问大模型推理、8x8 布局、权重重排、GEMM、Q8 量化、RK3588、Qwen3-VL、零依赖纯 C
导语:GEMM 快不快,一半取决于权重在内存里怎么排队。本篇讲引擎的 8x8 布局重排:转换期把行优先权重改成 8 行交织,让内核一次突发读就把整块喂给 vdot。--bench-mixed 实测——宽矩阵快约 1.7 倍、窄 QKV 不受影响,且两种布局位级一致。
6-1 里那条"微基准会骗人"的伏笔,今天兑现:GEMM 快不快,一半取决于权重在内存里怎么排队。引擎把权重的排队方式(布局)当作一等公民——在文件转换期就重排好,运行期只负责"按排好的顺序读"。
1. 知识点:为什么"天然布局"喂不动 vdot
naive 的 Q8_0 权重是"一行一行存":{scale0, qs[32]} {scale1, qs[32]} ...(每行 32 个元素一块)。但 vdot 一次吃 16 个 int8,要算"8 行 × 8 列"的块,需要同时拿到8 行同一列的 16 个元素——按行存的话,这 16 个元素散在 8 个不同的块里,每个都要一次独立的 cache-line 访问。
解法是重排(repack):转换模型时把权重从"行优先"变成"8 行 × 32 列为一组、8 行的第 k 段排在一起"的交织布局。这样内核运行时,8 行的数据是连续相邻的——一次突发读就把一整块喂给 vdot。重排只做一次(转换期),换来的却是每次推理都不再付"随机取数"的代价。
引擎里这个 8x8 布局的注释写得很清楚(vllm_safetensors.c 第 3408–3413 行):
/* Q8_0 8x8 tiled layout (block_q8_0x8, 272 B/block): P0 tile upgrade.
* { d[8] f16; qs[256] }, qs[k*32 + m*4 + i] = row m's qs[k*4+i].
* Same per-output int32/fp32 accumulation order as the 4x4 kernel =>
* results are bit-identical (verified on board 2026-08-29, 1.6x GEMM /
* 1.17x GEMV). Gate: VLLM_Q8_8X8=0 falls back to the 4x4 layout.
*/
static int g_q8_8x8 = -1;
static int q8_8x8_enabled(void) {
if (g_q8_8x8 < 0) {
const char *e = getenv("VLLM_Q8_8X8");
g_q8_8x8 = (e && e[0] == '0') ? 0 : 1; /* default 8x8 */
}
return g_q8_8x8;
}
两个值得记的点:
- "位级一致"是布局切换的前提:8x8 与 4x4 的累加顺序设计成相同,所以换布局不改结果(注释里写了"bit-identical, verified on board")——布局是纯性能开关,不是精度开关;
- 有开关就有退路:
VLLM_Q8_8X8=0让引擎退回 4x4 布局。第 3 节的实验就靠它。
重排函数 repack_q8_0_8x8_inplace(第 3426 行起)在内存里做原位置换,把 legacy 的 [rows][nb*34] 变成 8x8 的 272 B/block——它跑在模型转换期,每个权重只重排一次。
2. 对应代码:内核怎么"消费"重排后的布局
gemm_q8_0_8x8_neon(第 4418 行起)按 8 行一组推进,每组读一个 272 B 的块,8 个 f16 scale 一次取齐、256 个 int8 连续喂给 vdot——没有一条指令是在"找数据"。这正是 5-1 的教训在代码层的落地:紧凑 + 连续 = 有效带宽最大化。
3. 改动后果:关掉 8x8 重排,看宽矩阵内核慢多少
引擎内置的 --bench-mixed 用同一份合成 Q8 权重分别以 8x8(默认)与 VLLM_Q8_8X8=0(退回 4x4 布局)跑同一组 decode 内核。板端实测(RK3588 / 2026-09):
--- 默认(8x8 布局) ---
Q8 fused QKV (dec) 1.641 ms 30.7 GFLOPS
Q8 fused gate+up (dec) 3.922 ms 51.3 GFLOPS
Q8 fused down+res (dec) 1.988 ms 50.6 GFLOPS
--- VLLM_Q8_8X8=0(退回 4x4 布局) ---
Q8 fused QKV (dec) 1.592 ms 31.6 GFLOPS
Q8 fused gate+up (dec) 6.660 ms 30.2 GFLOPS
Q8 fused down+res (dec) 3.361 ms 30.0 GFLOPS
读数字要分形状:
- gate+up / down+res(宽矩阵,行数远多于 QKV):8x8 比 4x4 快 约 1.7×(51.3 vs 30.2、50.6 vs 30.0 GFLOPS)——宽矩阵里"取数连贯性"就是命根子,布局重排的收益在这显形(与代码注释"1.6x GEMM"同量级);
- QKV(窄矩阵):两者几乎一样(30.7 vs 31.6)——窄矩阵的瓶颈不在取权重,布局救不了它。
这就是"把取数提前到转换期"的完整证据:同样的指令、同样的数据量,只是权重排队方式不同,宽内核差 1.7 倍;而且这不是以牺牲正确性换的——两种布局输出位级一致(代码注释背书)。
动手验证姿势:
VLLM_Q8_8X8=0 ./build-rk3588/vllm_kestrel --bench-mixed | grep "Q8 fused",与默认跑一次对比 gate+up 那两行。
4. 学员调试任务
- A 档(板端动手):复现第 3 节——默认与
VLLM_Q8_8X8=0各跑一次--bench-mixed,记录三对Q8 fused数字与 GFLOPS,说出哪些内核受布局影响、哪些不受。 - B 档(纯读源码):读
repack_q8_0_8x8_inplace(第 3426 行起),画出"8 行 legacy 34B/块 → 272B 8x8 块"的字节搬运图(哪 8 个 f16 放前面、256 个 int8 怎么交织)。
预期输出:你能解释"8x8 重排为什么让宽矩阵内核快 1.7×、而 QKV 不受影响",并理解"布局与正确性解耦(位级一致)"这个工程选择的意义。
收尾
- 本篇源码点名:vllm_safetensors.c(8x8 布局注释与
q8_8x8_enabled第 3408–3422 行、repack_q8_0_8x8_inplace第 3426 行、gemm_q8_0_8x8_neon第 4418 行)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:8x8 是引擎自己排的;可 x86/llama 侧还有一种更狠的 4x4 手写 asm——引擎直接"提取"了它。下一篇 6-3 讲提取第三方内核的工程与许可边界。