系列:《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 篇 · 总纲(阿里云社区)
上一篇:5-1《为什么必须 Q4/Q8:边缘推理的带宽瓶颈》 | 下一篇:5-3《参考实现对拍:怎么读 rel err》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎 Q8 对称量化的标定:解决量化后的整数如何还原回浮点的问题,讲清 scale 由块内最大绝对值除以 127 得出并为何要存成 f16,落点在复刻三种存 scale 姿势观察往返误差的实验。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Q8 对称量化、scale、标定公式、量化误差、Qwen3-VL、零依赖纯 C
导语:量化后的整数怎么还原回浮点?靠一个标量 scale。本篇拆开引擎的 Q8 对称量化:scale 由块内最大绝对值除以 127 得出,为什么要存成 f16 而非整数,并用三档「存 scale」的往返实验,看误差如何从 0.033 放大到 0.44,整数存又为何直接归零。
5-1 把"为什么要量化"讲透了。今天解决"怎么量化"里最要命的一个细节:量化后的整数,怎么还原回浮点? 答案靠一个标量——scale。这个数选不好,量化误差会从"可接受"瞬间变成"全错"。
1. 知识点:Q8 对称量化的两步公式
Q8_0(对称 8bit 量化)的玩法是:把一段浮点数值整体缩小到 [-127, +127] 的整数范围,存整数;用的时候再乘回 scale。三个量各司其职:
- scale:这段数里"1 个整数单位 = 多少浮点",由块内最大绝对值决定;
- 量化值 q:
round(原始值 / scale),钳到 [-127, 127]; - 反量化:
原始值 ≈ q × scale。
为什么"对称"?因为只用一个 scale 同时管正负(正负同宽),不需要 offset——简单、向量化友好。为什么块(block)内算 scale、而不是整行一个?因为权重数值局部有起伏,块越小 scale 越贴本地,误差越小(5-1 里 Q8_0 是每 32 个元素一块)。
2. 对应代码:f32_to_q8_0 的逐行拆解
引擎里这段的"官方实现"在 vllm_safetensors.c 第 2916–2945 行(格式注释在 2907–2914 行):
/* Each block of 32 elements → 34 bytes: { f16 scale; int8 qs[32]; }
* scale = max_abs(block) / 127
* qs[i] = round(w[i] / scale), clamped to [-127, 127]
* ================================================================ */
void f32_to_q8_0(uint8_t *q8_out, const float *f32_in, int n_elements) {
int block_size = 32;
int n_blocks = n_elements / block_size;
for (int b = 0; b < n_blocks; b++) {
float max_abs = 1e-10f;
for (int i = 0; i < block_size; i++) {
float v = f32_in[b * block_size + i];
float av = fabsf(v);
if (av > max_abs) max_abs = av; /* (1) 找块内 |x|max */
}
float scale = max_abs / 127.0f; /* (2) scale = max/127 */
if (scale < 1e-10f) scale = 1e-10f; /* 防空块除零 */
uint16_t h = f32_to_f16_bits(scale); /* (3) scale 存 f16 */
memcpy(q8_out + (size_t)b * 34, &h, 2); /* 2 字节 */
int8_t *qs = (int8_t *)(q8_out + (size_t)b * 34 + 2);
for (int i = 0; i < block_size; i++) {
float v = f32_in[b * block_size + i];
int q = (int)(v / scale + 0.5f); /* (4) round(v/scale) */
if (q > 127) q = 127; /* 钳位 [-127,127] */
if (q < -127) q = -127;
qs[i] = (int8_t)q;
}
}
}
三个容易被忽略的点:
- 为什么除 127 而不是 128? 因为
int8的可表示范围是 [-128,127],但量化要留一个"干净"的 128 位避免-128的取负歧义(abs(-128)越界),所以对称量化的幅值只用到 127。代价是丢掉 1/128 ≈ 0.8% 的动态范围——换安全,值。 - scale 用 f16 存:2 字节,不能省。它是乘在最终结果上的系数,精度直接影响输出(见第 3 节实验)。
- q 的取整是
+0.5截断(等价于四舍五入到零的方向处理),而且钳位在取整之后——这是"确定性量化"的一部分:同输入必同输出(Day 1 的红线在这里落地成具体的一行公式)。
反量化时,引擎读到 2 字节 f16 scale + 32 个 int8,值 ≈ q[i] * d,乘完进累加器——Q4_0 的同构版本在第 2947–2956 行注释里(每 32 元素 18 字节、nibble 存储,Day 7 展开)。
3. 改动后果:把 scale 的精度降级,看量化误差怎么爆
我们复刻引擎公式做一个 32 元素块的实验(板端运行同一个 C 程序,gcc 11.4 / 2026-09),数据是 [-3,3] 的正弦采样,真实 scale 约 0.0236。三种"存 scale 的姿势":
f16 scale (engine) : d=0.023438 roundtrip max|err|=0.03325
2-decimal scale : d=0.020000 roundtrip max|err|=0.43830
naive int scale : d=0.000000 roundtrip max|err|=2.97830
- f16(引擎做法):scale 保留约 10 位有效精度,往返最大误差 0.033——这是 Q8 本身的量化粒度(
0.0234/2≈0.012量级 + 舍入),正常水平; - 保留两位小数(把 scale 存成 0.02 而不是 0.0234):误差涨到 0.44,13 倍——scale 差 15%,误差被"离群值钳位 + 整体缩放错位"放大;
- 整数存 scale:0.0236 直接截成 0,整块 scale 归零 → 量化值全变 0、反量化全 0,误差等于原始数据幅度(3.0)。这不是精度问题,是灾难。
结论一句话:scale 是"值 + 标定"问题的另一半——量化公式只是把数值映射到整数,真正决定误差的是这个 2 字节的小数存得准不准。所以引擎用 f16 而不是"省成 8bit",也不是"图省事用 int"。
更进一步的教训:任何"把 scale 塞进更小的类型"的优化,都必须先做第 3 节这种往返误差测试再上——这就是第 6–7 天会反复出现的"改量化先看 rel err"的纪律。
4. 学员调试任务
- A 档(板端动手):复刻第 3 节实验——取你的模型里任意一段真实浮点权重(或在代码里造一个 [-3,3] 的块),分别用 f16 / 两位小数 / int 存 scale,打印三档往返误差,确认"误差 13 倍 / 归零"现象。
- B 档(纯读源码):读
f32_to_q8_0(vllm_safetensors.c 第 2916–2945 行),手算一个块:若max_abs=3.0,scale、第 i 个元素的 q 各是多少;再读 2947 行起 Q4_0 注释,对比它为什么用d = max_signed / -8(注意是负的,Day 7 细讲)。
预期输出:你能不看代码写出 Q8 对称量化的三步公式,并解释"为什么 scale 必须用 f16/f32 存,用整数会直接归零"。
收尾
- 本篇源码点名:vllm_safetensors.c(
f32_to_q8_0第 2916–2945 行、Q8_0/Q4_0 格式注释第 2907–2956 行)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:量化值算出来了,怎么证明"量化没把数值弄坏"?下一篇 5-3 讲参考实现对拍——naive C 与手写内核比,误差 1e-7 到底意味着什么。