真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:自检门与确定性测试:回答刚编译完、没有模型文件时如何证明代码算得对,拆解无模型 PASS/FAIL 自检与标量参考对拍、固定种子随机的机制,落点在 --run-tests 入口与 vllm_l3.c 的对拍实现。
关键词:自检门、确定性测试、PASS/FAIL、参考对拍、RK3588
1. 引言:没有模型,怎么证明算得对?
1-2 你跑通了构建,得到了 vllm_kestrel 这个二进制文件。现在问题来了:怎么知道它算得对不对?
一个推理引擎的正确性验证有个天然的难点:真正跑模型需要加载几 GB 的权重文件、耗时很长、还需要一张"标准答案表"来比对。而我们想要的是------刚编译完、手里没有任何模型文件时,就能立刻回答"这版核心代码对不对" 。本仓库的做法是设立一道"自检门"(Self-test):一组无模型、确定性、PASS/FAIL 的内置测试。这一篇,我们就带你拆解并读懂它。
2. 知识点:为什么必须要有"自检门"?
在工业级 C 工程里,自检门主要解决三个痛点:
**1. 参考对拍(Reference Cross-check)是位级正确性的兜底。**手写 NEON 汇编或内联内核之前,你通常需要先写一个"不管多慢但逻辑绝对正确"的朴素 C 实现(Scalar Reference)。然后让两个实现吃同样的输入,比对输出差多少。
- 误差在
1e-7量级:说明浮点运算路径一致。 - 误差爆掉:说明新写的 NEON 内核有 bug。
这是"没有标准答案也能自证"的通用方法------后面的第 5、9 天还会反复用到它。
**2. 确定性(Determinism)是引擎的工程红线。**本引擎对外承诺"同输入、同参数,输出逐位一致"。如果没有自检门,重构一版内核后,你根本不知道是否破坏了确定性(比如引入了未初始化的内存或竞态条件)。
**3. 成本最低的回归测试(Regression Test)。**不需要下载几 GB 的权重,不需要 GPU,只要编译完就能立刻跑。它非常适合做 CI(持续集成),也是"换到一块新板子后第一件要做的事"(第 29 天讲换板验证时,我们也会先敲这道门)。
3. 对应代码:自检门长什么样?
3.1 测试入口:构建脚本的 --run-tests
打开 build_rk3588.sh 第 81--92 行,当你传入 --run-tests 参数时,脚本会依次跑四个自检,并把结果打印成直观的 PASS/FAIL。下面是我们 RK3588 板端(Orange Pi 5 Plus / gcc 11.4 / 2026-09)实测的真实输出(未加任何修饰):
text
test-l3 : PASS
test-sparse : FAIL
bench-mixed : PASS
npu-selftest : PASS (SKIP = runtime absent)
说明:test-sparse : FAIL 是仓库如实记录的已知缺陷 (见下文 3.3),不是你的环境问题;npu-selftest 行尾的 (SKIP = runtime absent) 是脚本的固定提示语------本机 NPU(rk3588 direct 驱动)在位时该项会真实运行 并输出 [ALL TESTS COMPLETE],无 NPU 运行时的机器则表现为跳过式通过。
3.2 读一个具体的自检:--test-l3
--test-l3 的实现在 src/core/vllm_l3.c。它主要测的是 L3(磁盘级 KV 缓存)的量化点积与淘汰逻辑。文件里有一段核心注释(第 77 行附近):
c
/* Scalar reference dot (also the non-AVX2 fallback). */
"Scalar reference dot" 即朴素 C 参考实现 (注释里的 AVX2 是 x86 时代的迁移残留,理解成"安全回退实现"即可)。自检程序做的事,就是让手写的高速向量内核 和这个参考实现进行对拍:
dot64 vs scalar reference(第 538 行附近):给双方喂相同的量化块,比较点积结果。eviction 测试:验证"淘汰哪些缓存块"在相同输入下逐次完全一致 (板端真实输出为eviction deterministic (11 == 11 blocks evicted))。
板端真实运行(Release 构建,即 build_rk3588.sh 的默认档)的完整输出共 9 行 PASS,这里节选与本文最相关的三行:
text
[PASS] q4 dot vs scalar reference (rel 7.68e-08)
[PASS] eviction deterministic (11 == 11 blocks evicted)
=== L3 self-test PASSED (0 failures) ===
(其余 6 项:pack/dequant 往返、饱和钳位、vacc 手工累加对拍、hot-needle 保留策略、K 块磁盘往返、batch==per-token 位级一致,全部 PASS,完整日志见 --test-l3 直接运行输出。)
rel 7.68e-08 表示"相对误差 7.7×10⁻⁸"------这说明高速向量路径与安全参考路径几乎逐位一致。这不仅是正确性证明,也是后面所有 NEON 加速工作的"及格线"。
3.3 诚实的 FAIL:test-sparse 已知问题
细心的你会发现脚本输出里有一行 test-sparse : FAIL。请注意:这不是你的环境配置问题,而是仓库如实记录的已知缺陷 。板端实测 --test-sparse 会崩溃------本次运行表现为 Bus error(exit 135),此前另一次表现为 Segmentation fault(exit 139),崩溃形态随运行时的内存对齐情况而变,但结论一致:退出码非 0,脚本如实标出 FAIL。它不影响推理主路径。我们选择把已知缺陷明明白白写进文档,而不是通过删减测试用例把它藏起来。一个敢把 FAIL 暴露出来的自检门,比一个永远只报 PASS 的系统更可信------这也是本教程与开源项目一贯的诚实基调。
3.4 关键代码逐行:读懂一次真正的对拍(Q4 dot64)
刚才只让你看了 PASS 输出,现在我们把 --test-l3 里最核心的一段对拍代码摊开。先看参考实现 (vllm_l3.c 第 77--94 行,注释即代码):
c
/* Scalar reference dot (also the non-AVX2 fallback). */
static float q4_dot64_scalar(const uint8_t *payload, const float *act) {
float sum = 0.0f;
float scale_a, scale_b;
memcpy(&scale_a, payload, 4); // payload[0..3] = 前半块的 scale
memcpy(&scale_b, payload + 20, 4); // payload[20..23] = 后半块的 scale
for (int i = 0; i < 16; i++) {
uint8_t b = payload[4 + i]; // 第 i 字节里藏着两个 4bit 量化值
sum += (float)((int)(b & 0x0F) - 8) * scale_a * act[i * 2]; // 低 4bit
sum += (float)((int)((b >> 4) & 0x0F) - 8) * scale_a * act[i * 2 + 1]; // 高 4bit
}
// ... 后半块同构:payload[24..39],用 scale_b,act 下标从 32 起
return sum;
}
**【内存布局解剖图】**这段代码揭示了 Q4 量化的经典内存布局(Payload Layout),第 5--7 天我们会反复用到:
text
[ 40 Bytes Q4 Payload 内存布局 ]
偏移 00..03 : scale_a (float, 4字节)
偏移 04..19 : 32 个量化元素 (每个4-bit,紧凑存入 16 字节)
偏移 20..23 : scale_b (float, 4字节)
偏移 24..39 : 32 个量化元素 (每个4-bit,紧凑存入 16 字节)
代码逐点解读:
1. 为什么一个字节存两个元素? (b & 0x0F) 取低 4-bit 对应元素 2i,(b >> 4) & 0x0F 取高 4-bit 对应元素 2i+1。
2. - 8 的作用 :把无符号的 4-bit(0--15)平移成有符号(-8...+7),即量化值的"零点偏移",最后乘以对应的 scale 还原成 float。
3. 为什么切成两半(scale_a/scale_b)? 一行 32 个权重共享一个 scale,量化误差在可接受范围内;64 个元素拆成两半各自配 scale,精度更好(GGUF 的 Q4_0 也是这种"块级共享 scale"的设计,第 7 天我们会详细对齐)。
再看对拍是怎么进行的(同文件第 538--558 行,节选):
c
/* ---- (2) dot64 vs scalar reference ---- */
l3_q4_pack64(p, src); // (a) 数据准备:把随机 float 打包成 Q4 payload
float got = l3_q4_dot64(p, act); // (b) 被测对象:NEON 高速向量内核
float ref = q4_dot64_scalar(p, act); // (c) 裁 判 官:朴素 C 参考实现
// (d) 误差计算与及格线断言
float rel = fabsf(got - ref) / fabsf(ref);
assert(rel < 1e-6f); // 相对误差小于 1e-6 即判 PASS
这段代码的四个步骤,就是"参考对拍"的完整范式:
- (a) 数据准备:用固定种子生成随机 float,再打包成 Q4 payload------固定种子保证每次运行输入完全一致,这是"确定性"的前提。
- (b) 被测对象 :调用手写的高速向量内核
l3_q4_dot64,这是我们要验证正确性的代码。 - (c) 裁判官 :调用朴素 C 参考实现
q4_dot64_scalar,逻辑简单、绝对正确,但慢。 - (d) 误差断言 :计算相对误差,小于
1e-6即判 PASS。这个及格线比实测的7.68e-08宽松一个数量级,给不同编译优化档留了余量。
这就是"没有标准答案也能自证"的完整闭环:两个实现吃同样的输入,输出足够接近,就说明高速路径没有算错。
4. 小结:自检门的三层意义
回到开篇的问题------"刚编译完、没有模型文件,怎么知道算得对不对?"现在答案很清晰:
-
对拍:用朴素参考实现当裁判官,验证高速内核的位级正确性。
-
确定性:固定种子 + 逐次一致的淘汰结果,守住"同输入同输出"的工程红线。
-
诚实:连已知的 FAIL 都如实暴露,这样的自检门才真正可信。
-
开源仓库 :Kestrel-LLM (Gitee)(源码可得双许可:学习 / 学术研究免费)
下篇预告: C11 _Static_assert:编译期守卫内存布局,看看如何用编译期断言把内存布局的约定"焊死"在代码里。