Day 1·3 确定性测试门:推理引擎的PASS/FAIL自检机制

真机实测通过:本文实验已在 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:编译期守卫内存布局,看看如何用编译期断言把内存布局的约定"焊死"在代码里。

上一篇: Day 1·2 ARM交叉编译踩坑实录:-march=armv8.2-a+dotprod+fp16写错会怎样

下一篇: Day 2·1 C11 _Static_assert:编译期守卫内存布局

相关推荐
回眸&啤酒鸭1 小时前
【回眸】私人定制旅游路线助手
人工智能
知几蜗牛1 小时前
HydraFusion的Single、Cascade与Critique如何落到工程门禁
人工智能
mit6.8241 小时前
plz直接提交 pull equest
人工智能
Solara1 小时前
29 条回复永远没送到:翻完 108 条投递台账,我才发现「微信限流」是我取错的名字
人工智能·agent·ai编程
lucas_AI1 小时前
别再无脑堆数据了:腾讯 WeVisDoc 把文档解析卷到 95 分,token 是按预算花的
人工智能
橘和柠1 小时前
一台笔记本上的三国杀:eNSP、VirtualBox 与 Docker,我让它们共存了
人工智能
achong1 小时前
AI 写代码比你还啰嗦?用 ponytail「懒人法则」治它
人工智能
知几蜗牛1 小时前
从ATOF事件配对理解Agent工具调用的可观测性
人工智能
画绛集美术1 小时前
用开源AI语音合成做课程口播音频:一间美术教室的技术笔记
人工智能·笔记·音视频