系列:《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-3《4x4 asm 内核:从 llama.cpp 提取的 MIT 代码》 | 下一篇:7-2《混合精度路由:flags 决定谁用 Q8》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 Q4_0 格式:拆 32 个浮点如何装进 18 字节(f16 scale 加 16 个 nibble),讲清低半字节归位规则、与 GGUF 对齐的两层含义,并给出往返误差约为 Q8 十一倍的板端实测。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、Q4_0、GGUF、nibble 打包、Q8 量化、RK3588
导语:权重是推理引擎里最大的一笔开销:手搓 Qwen 推理引擎选用与 GGUF 对齐的 Q4_0 给它瘦身——32 个浮点如何装进 18 个字节、f16 scale 与 nibble 打包怎样配合、低半字节如何归位。这篇把这套 4bit 布局拆开讲清。
5-1 说引擎文件里 Q4_0 占了近 1GB(大头),可"4bit"到底是怎么把 32 个浮点塞进 18 个字节的?今天拆 Q4_0 的字节布局——并且强调一件事:这个布局和 GGUF 的 Q4_0 是同一个,因为引擎要能直接读 GGUF 权重。
1. 知识点:一个字节装两个数(nibble 打包)
Q4_0 每个元素只有 4 bit(半字节,nibble),表示范围 [-8, +7](借用第 8 个值做"干净"的符号位,同 Q8 用 127 的动机)。32 个元素 × 4bit = 128 bit = 16 字节,加上 2 字节的 f16 scale,整块 32 元素只占 18 字节:
Q8_0: 32 f32 (128B) -> 34 B = { f16 d; int8 qs[32] } (每元素 1 B)
Q4_0: 32 f32 (128B) -> 18 B = { f16 d; uint8 qs[16] } (每元素 0.5 B)
关键在 qs[16]:每个字节的低 4 bit 存一个元素、高 4 bit 存另一个。两个 4bit 数挤在一个字节里,就是"nibble 打包"。ggml/GGUF 的 Q4_0 反量化里,这个排布是标准化的(vllm_gguf.c 第 302 行与第 334–344 行):
typedef struct {
uint16_t d; uint8_t qs[16]; } gf_block_q4_0; /* 18 B/32 元素 */
case GGUF_T_Q4_0: {
const gf_block_q4_0 *x = (const gf_block_q4_0 *)src;
int64_t nb = n / 32;
for (int64_t i = 0; i < nb; i++) {
const float d = gf_f16_to_f32(x[i].d);
for (int j = 0; j < 16; ++j) {
const int x0 = (x[i].qs[j] & 0x0F) - 8; /* 低半字节 -> elem j */
const int x1 = (x[i].qs[j] >> 4) - 8; /* 高半字节 -> elem j+16 */
dst[i*32 + j] = x0 * d;
dst[i*32 + j + 16] = x1 * d;
}
}
break;
}
注意元素的归位规则:byte j 的低半字节给元素 j,高半字节给元素 j+16(前 16 个元素在低半字节、后 16 个在高半字节)——不是"偶数在低、奇数在高"。写错这个归位,反量化出来就是"两个错位的半波"。(引擎自己的 f32_to_q4_0,vllm_safetensors.c 第 2957 行起,注释里的排布与这里一致——因为引擎要读 GGUF,两边的 Q4_0 必须同构,第 2951–2956 行的注释写得很清楚。)
2. 对应代码:转换路径里 Q4_0 怎么进来
引擎加载 GGUF 时,是把 GGUF 的 k-quant 块逐张量反量化成 f32,再用自己的 f32_to_q8_0/q4_0 重新量化(vllm_gguf.c 第 21 行注释:逐张量 dequant(F32) → f32_to_q8_0/q4_0 → repack)。所以"与 GGUF 对齐"有两层含义:
- 格式层:
gf_block_q4_0与引擎的 Q4_0 块同构(都 18B、都低半字节在前 16 元素); - 转换层:即使 GGUF 里是 Q4_K/Q5_K 等花式 k-quant,引擎也是先解回 f32 再按自己的块布局重量化——引擎只认一种 Q4_0,复杂度被"先解再量"消掉了(代价是转换期算一遍,换来运行期单一格式,这是第 17 天讲转换时会细算的账)。
3. 改动后果:同一段数据,Q4 与 Q8 的代价差多少
用 5-2 同一段测试数据([-3,3] 正弦 32 点),做 Q4_0 往返,板端实测(RK3588 / gcc 11.4 / 2026-09):
Q4_0: d=0.372287 | 32 f32 -> 18 bytes (f16 d + 16 nibbles)
roundtrip max|err|=0.37229 (same vector, Q8_0 err was 0.03325)
byte[3]=0xdf -> low nibble 15 (=elem3), high 13 (=elem19)
- 字节:128B → 18B,只有 Q8_0(34B)的一半——这就是 Q4 在 5-1 里占大头却仍被选中的原因(decode 每词少读一半权重字节);
- 误差:往返最大误差 0.372 vs Q8 的 0.033——约 11 倍。这是 4bit 的物理代价:量化步长是 Q8 的两倍(
d=max/8vsmax/127),误差天然大一档。
Q4 不是"免费的 Q8 减半",它是"用 11 倍误差换一半字节"的交易。所以引擎不会让所有层都上 Q4——哪些层能用 Q4、哪些必须 Q8,是 7-2 混合精度路由要回答的问题。
4. 学员调试任务
- A 档(板端动手):复刻第 3 节 demo(任意 [-3,3] 的 32 点向量),打印 Q4 与 Q8 各自的字节数与往返误差,确认"18 vs 34 字节、误差约 11 倍";再手写解码
byte[3]=0xdf验证低半字节=15 对应 elem3。 - B 档(纯读源码):读
vllm_gguf.c第 302 行gf_block_q4_0与第 334–344 行反量化,画出"一个 18B 块 → 32 个浮点"的字节地图(哪 2 字节是 d、哪 16 字节装谁)。
预期输出:你能不看资料说出 Q4_0 的块结构(18B = f16 d + 16 nibble)、nibble 的归位规则,以及"4bit 换来 11 倍误差"这笔交易。
收尾
- 本篇源码点名:vllm_gguf.c(
gf_block_q4_0第 302 行、反量化第 334–344 行)、vllm_safetensors.c(f32_to_q4_0第 2957 行起)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:Q4 省字节但误差 11 倍——那为什么模型里还有 Q8?下一篇 7-2 讲混合精度路由:flags 怎么决定"哪层 Q8、哪层 Q4"。