【FHE 同态加密】我们如何实现同态加密推理(十四):为什么 RESULT=PASS 不是判据(纯 C11 · 零依赖)
关键词:同态加密 | FHE | 验证 | 可复现性 | RESULT=PASS | max|err| | SHA256 | 密文验收 | 大模型推理
导读:在同态加密推理这条链上,驱动打出的 RESULT=PASS 只表示"流程走完",不表示"数值是对的"。我们真的被它骗过一次:一个 8 字节文件缺失,引擎静默换了计算路径,照样打 PASS,而实际 max|err| 已超出验收容差。本篇讲正确的两层判据,以及如何做到"不信任打包者"。
项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm
相关文档
术语与数据口径 |
性能与基准 |
构建与复现 |
快速上手 |
0. 一句话结论
在我们这套密文链里,驱动打出的 RESULT=PASS 只表示"流程走完了",它不表示"数值是对的"。
这不是理论担忧。我们真的被它骗过一次:一个 8 字节的文件缺失 ,驱动静默换了计算路径,仍然打出 RESULT=PASS (0)------而实际的 max|err| 已经是 4.2e-2 ~ 8.5e-2,超出验收容差。
所以在这条链上,判据和数值是两把不同的尺子,必须分别量。
1. 事故复盘:8 个字节如何制造一次"假通过"
1.1 事件的经过
我们重跑层 0(lay0)做闭环验证。驱动正常退出、正常输出:
RESULT=PASS (0)
按当时文档的口径,这就是"通过"。但同一批密文用独立复核工具一量:
[U2 t3 h0] max|err|=4.460e-02 FAIL [E2E:ref-deviate-ok]
verify_layer: FAIL 0/8
8 项全挂 ,而且产出的 u0 与归档版本逐字节不同。
1.2 根因
引擎用一个 8 字节的 f64 文件来决定非线性激活走哪条拟合路径:
| 文件内容 | 走的路径 | 适用场景 |
|---|---|---|
1.0 |
直接拟合(` | gate |
0.0 或文件缺失 |
÷21 折叠(把动态范围压进逼近窗口) | M3b 深层 |
而 tools/drivers/t23_m3p.c 里这两个分支直接读文件、不设"缺失即报错"。原文(含作者当时写的理由):
c
/* silu 模式: silu{lay}.bin=1 → 直接拟合(|gate|<=8, 锚定0, M1c 路径; ÷21 拟合有 ~0.0127
* DC 偏移 → 浅层小 gate 相对误差 12-35%); =0/缺省 → ÷21 折叠(M3b 深层) */
g_silu_direct = 0;
snprintf(sp, sizeof sp, ".tmp_tok/tail/silu%d.bin", lay);
fp = fopen(sp, "rb");
if (fp) {
double sm = 0;
if (fread(&sm, 8, 1, fp) == 1 && sm == 1.0) g_silu_direct = 1;
fclose(fp);
}
if (g_silu_direct) printf(" [silu] lay%d direct\n", lay);
这段注释顺带解释了为什么必须有两条路径 :÷21 折叠的拟合带一个 ~0.0127 的直流偏移,在浅层小 gate 上会造成 12--35% 的相对误差。所以浅层必须走直接拟合。
于是"文件没生成"这件事,被静默解释成"请用折叠路径"------而折叠路径恰恰是浅层的错误选择。
1.3 为什么第二道防线也没拦住
严格说,驱动的输出里确实 有异常信号------段级误差项是 FAIL。但我们的判据设计里有两条规则,把这两个信号都过滤掉了:
- 段级
A/B/C/D的FAIL不作为结论。这是有道理的:分段误差是"参考口径产物",中间层误差大不等于最终结果错。 - 端到端模式会降级偏差 :在
T23_E2EMODE模式下,C/D/U2这类项只报告误差,不计入fails。
两条规则各自都合理,叠加起来就形成了一个盲区 :一个错误路径产出的、8 项全挂的结果,可以一路绿灯走到 RESULT=PASS。
修好路径(补上那个 8 字节文件)之后,同一层的日志头变成 [silu] lay0 direct,段级误差立刻回到正常量级:
[A:xnorm t3] max|err|=5.642e-04 PASS
[B:q0 t3] max|err|=1.167e-03 PASS
[C:attn t0] max|err|=2.858e-04 PASS
2. 为什么会这样:判据的设计意图与它的边界
把话说公道:RESULT=PASS 的原始设计意图是"流程验收",不是"数值验收"。 它回答的是"这一步跑完了吗、有没有崩、有没有缺文件(它知道的那几种)"。
在流水线调度里这很有用------接力者需要的是"我这跳交付了没有"。但对结论级的正确性,它必须让位给独立复核。
我们后来把这条写进了文档,并且明确要求:
判据日志只能用来排障,验收必须看
verify_layer的max|err|。
3. 正确的判据是两层
| 层 | 工具 | 它回答什么 | 通过标准 |
|---|---|---|---|
| 第一层:流程(跳内不崩) | 驱动 t23lay / t23boot |
这一跳跑完了吗? | lay 看 RESULT=PASS;boot 看 BOOT=PASS 且 out np=2083 出现 8 次 |
| 第二层:数值(算对了吗) | verify_layer(独立于驱动) |
解密后对明文参考的误差是多少? | `max |
第二层的实现刻意"不信任任何人":
读 sk.bin + 密文 → 解密 → 解码 → 与明文参考逐项比 → max|err| → PASS/FAIL
它不读驱动的判据 ,只读密文和私钥。默认容差 -Tol 3e-2,也可显式覆盖:
bash
# 复核层 0 的产出
.tmp_tok/verify_layer.exe -L 0 -Ct u0
# 复核刷新后的密文(前缀 u0r112)
.tmp_tok/verify_layer.exe -L 0 -Ct u0r112
# 位级比对:两个密文是不是逐字节相同
.tmp_tok/verify_layer.exe -BitA u5 -BitB u5b -L 5
3.1 实测输出(本机复核,非引用)
| 对象 | 链长 | 结果 | max|err| |
|-----------------------|----------|-----------------------|--------------------------------------------------|
| u0 | np=16 | VERIFY_PASS (8/8) | 1.2712e-03 |
| u0r112(自举刷新后) | np=112 | VERIFY_PASS (8/8) | 1.2706e-03 |
| u26 / u26r112(链尾) | --- | PASS 8/8 | 4.4707e-03 ~ 2.2993e-02 |
| u27(链尾) | --- | ⚠️ 6/8 | t1h1=3.8216e-02、t3h1=7.9687e-02 超 3e-2 |
一个细节值得注意:自举刷新(boot)几乎不改变数值 ------u0 是 1.2712e-03,刷新成 u0r112 后是 1.2706e-03。刷新的作用是重置模数链,不是"提高精度"。
4. 让"第三方复核"真正成立的四块砖
如果复核逻辑还是打包者自己写的,那只是把信任问题往后挪了一层。所以交付物层做了四件事:
-
逐文件 SHA256 + 自校验 manifest
归档实测:主包 104/104 、链尾 45/45 、工具包 6/6。
-
复核工具独立于驱动
verify_layer.c与t23_m3p.c/t23_chain.c是不同程序,只共享底层的 NTT/CKKS 与那把私钥。 -
参考值可自助生成
复核要对的"明文参考"不是只有我们才有。约 7 GB 的数据包可以用
tools/preproc/从模型自助生成------我们核对过生成物与分发物逐字节相同 (权重 9/9、26 个逐层模式文件 26/26、embed4.bin、参考值、sk.bin全部一致)。 -
产物位级可复现
这是最强的一块,见下节。
5. 最强的一块砖:产物位级复现
复核只能证明"数值在容差内"。真正让"不信任"变得没必要的是------你能算出和我一模一样的东西。
我们用两份独立工作区(一份含既有 .tmp_tok,一份全新克隆)从自助生成的数据出发,重跑了 lay0 与 boot0:
| 比对对象 | 结果 |
|---|---|
u0_{t}_{h}.ct × 8 |
identical = 8,diff = 0 |
u0r112_{t}_{h}.ct × 8 |
identical = 8,diff = 0 |
lay0.out 日志 |
213 / 213 行,差异 4 处,全部是耗时行 |
lay0.err 日志 |
1 / 1 行,完全相同 |
boot0.out 日志 |
19 / 19 行,差异 1 处([boot profile] 总耗时) |
boot0.err 日志 |
65 / 65 行,62 处差异全是 cts: u0=N done (X.Xs) 进度耗时 |
也就是说:所有数值、判据、分阶段链长逐行相同,唯一的差异是时间。
前提条件(必须一起说) :位级复现要求线程数一致 。
T23_NT=4与T23_NT=8的结果在位级上不同------根因是线程局部 RNG 用固定常量播种,见本系列第 13 篇。所以"位级一致"这句话,永远要带上线程数一起说。
6. 安全边界(务请读完)
本文所述参数为机制验证级(n=2048、112 素数内层链、2100 素数自举链),
远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本文主张的是:验证方法与可复现性。
本文不主张:安全强度、性能优越性。
特别说明:本文讨论的"可复现""可验证"都是正确性层面 的,与密码学安全无关。一个能被第三方完全复现的系统,和一个安全的系统,是两件事------在本项目里,前者是我们的目标,后者我们明确不主张。
7. 这一篇的未解问题
u27那两项超阈怎么定性 ------它们是"这一段确实算坏了",还是"容差3e-2定得不合理"?我们没有定论。目前的处理是照实记账并公开,而不是调容差把它变成通过。-Tol 3e-2是一个选择,不是定理。它从哪来、对哪一层的哪一类值合适,我们只有经验值,没有推导。逐层容差应该是多少,是本项目最缺的一块理论。- 验证还停留在"产出对不对",没管"输入有没有被换掉" 。如果上一棒交来的密文被替换成另一个合法密文,当前的复核发现不了。要堵这个口子,需要额外的机制(比如对输入的承诺),我们还没做。
- "缺失即默认"这个设计模式我们没清理干净 。这次是
silu{L}.bin,同类的"读不到就用默认值"的文件读取,值得全树扫一遍------沉默的默认值比崩溃更危险。
下一篇我们讲工程侧的另一条线:为什么我们把 OpenMP 换成了自研线程池------以及它和"位级可复现"这件事的隐秘关联。