【FHE 同态加密】我们如何实现同态加密推理(八):SiLU 的密文化——两条路径,和一个 8 字节的开关(密文非线性激活)
关键词:同态加密 | FHE | SiLU | 非线性激活 | 多项式拟合 | 密文激活 | 折叠 | 大模型推理 | 纯 C11
导读:密文里没有"非线性函数",只有多项式,所以同态加密推理必须把 SiLU 换成多项式逼近——而一条多项式无法同时覆盖浅层窄动态范围与深层宽动态范围。本篇讲两条拟合路径、那个决定路径的 8 字节文件,以及它最危险的一面:文件缺失被静默当成"深层方案",误差放大几十倍,引擎却照样打 PASS。
项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm相关文档
术语与数据口径 |
性能与基准 |
构建与复现 |
快速上手 |
架构总览
0. 一句话结论
密文里没有"非线性函数",只有多项式。所以 SiLU 必须被换成多项式——而一条多项式不可能同时覆盖浅层的窄动态范围和深层的宽动态范围。
我们的做法是逐层选路径,并把选择固化成一个 8 字节的文件:
tail/silu{L}.bin内容 | 走的路径 | 用在哪 |
|---|---|---|
1.0 | 直接拟合(|gate| ≤ 8,锚定 0) | 浅层 |
0.0或文件缺失 | ÷21 折叠(把动态范围压进逼近窗口) | 深层 |
而这里有一个致命的默认值:文件缺失 = 走折叠路径。于是"文件没生成"这件事,被静默解释成了"请用深层方案"——浅层用错方案,结果是误差放大几十倍,驱动却照样打PASS。
1. 为什么一条多项式不够用
SiLU 是x · σ(x)。要把它密文化,就得在某个区间上做多项式逼近。
难点在于动态范围:
- 浅层的
gate值域窄(|gate| ≤ 8),而且锚定在 0 附近——此时可以针对这个窄区间做一条精确的拟合; - 深层的
gate值域被前层不断稀释/放大,可能宽到让窄区间拟合完全失效——必须先把输入除以一个常数(这里取 21),把它压进一个固定的逼近窗口,算完再乘回来。
这两种需求是互斥的:为窄区间优化的多项式,放到宽区间上会炸;为宽区间设计的折叠方案,在窄区间上精度差。
1.1 折叠方案的具体缺陷(作者当时写下的理由)
源码注释写得很具体(原文):
silu 模式: silu{lay}.bin=1 → 直接拟合(|gate|<=8, 锚定0, M1c 路径; ÷21 拟合有 ~0.0127 DC 偏移 → 浅层小 gate 相对误差 12-35%); =0/缺省 → ÷21 折叠(M3b 深层)拆开看这条论证:
- ÷21 折叠路径的拟合带一个
~0.0127的直流(DC)偏移; - 在浅层,
gate本身很小,于是这个固定偏移相对于信号而言很大 →相对误差 12%–35%。
所以在浅层走折叠路径,不是"精度差一点",而是错得离谱。
2. 开关是怎么被读的
完整代码(t23_m3p.c):
/* silu 模式: silu{lay}.bin=1 → 直接拟合(...); =0/缺省 → ÷21 折叠(M3b 深层) */g_silu_direct=0;snprintf(sp,sizeofsp,".tmp_tok/tail/silu%d.bin",lay);fp=fopen(sp,"rb");if(fp){doublesm=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);三个必须注意的地方:
- 默认值是 0(
g_silu_direct = 0),也就是"默认走折叠"; fopen失败不报错,直接沿用默认值;- 唯一的外部可见信号是那一行
[silu] lay%d direct——只在走直接拟合时才打印。走折叠时什么都不打,和"本来就是深层"长得一模一样。
第 3 点是最阴的:日志里"没有这行"既可能是"这一层本来就该走折叠",也可能是"文件不存在"。
3. 生成端:谁写这些文件
tools/preproc/_silu_mode.py负责产出全部 26 个文件(silu0.bin…silu25.bin),每个 8 字节f64。它的核心就是把"哪些层走直接拟合"写成一个集合:
LAY_MAX=25# 产出已发布结果的逐层选择:集合内 = 直接拟合(direct),其余 = ÷21 折叠。DIRECT_LAYERS={0,1,2,5,6,8,10,11,12,13,14,15,16,18}forLinlayers:v=1.0ifLinDIRECT_LAYERSelse0.0withopen(os.path.join(a.out,'silu%d.bin'%L),'wb')asf:f.write(struct.pack('<d',v))我们核对过:生成的 26 个文件与已分发数据 26/26 逐字节相同,文件名集合完全一致。
3.1 这个集合是"调出来的",不是"算出来的"
按本项目纪律,这点必须明说:DIRECT_LAYERS是一组固化下来的调参结果,不是从模型推导出的规则。
它意味着两件事:
- 换模型就要重新调这组数字(不能自动适配);
- 它是一份"经验资产"而不是"算法"。
我们把这条写在脚本头部注释里,也写在这里,免得后来者以为背后有推导。
4. 事故:一个文件的缺失如何劣化两个数量级
我们重跑lay0时漏了silu0.bin(0 层在DIRECT_LAYERS里,本该走直接拟合)。
结果:
| 指标 | 数值 |
|---|---|
| 驱动输出 | RESULT=PASS (0)✅ |
| 独立复核 | FAIL 0/8❌ |
实测max|err| | 4.2e-2~8.5e-2 |
[U2 t3 h0]单项 | 4.460e-02 |
而补上那个 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误差从4.5e-2回到5.6e-4——差两个数量级。
而这整个过程里,驱动一句抱怨都没有。
(这也是本系列第 14 篇的起点:为什么RESULT=PASS不能当判据。)
5. 我们从这件事里改了什么
| 改动 | 内容 |
|---|---|
| 文档 | 在数据自检清单里显式列出tail/silu{L}.bin,并标注"缺了不报错但数值错" |
| 脚本 | 补上_silu_mode.py(原来只有驱动读文件、没有生成脚本),并在脚本头写危险警告 |
| 口径 | 把"RESULT=PASS≠ 数值通过"写进工具文档与复现指南的判据一节 |
| 复核 | 明确要求验收必须跑verify_layer(独立于驱动的报错逻辑) |
6. 安全边界(务请读完)
本文所述参数为机制验证级,远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。 本文主张的是:非线性函数密文化时的路径选择与一个真实的失效模式。 本文不主张:安全强度、性能优越性。7. 这一篇的未解问题
DIRECT_LAYERS应该被推导出来,而不是枚举。判据其实很明确——“浅层窄值域 → 直接拟合;宽值域 → 折叠”,而值域是可以从明文参考统计出来的。这件事在原理上可以自动化,我们还没做。- "缺失即默认"这个模式没有清理。全树里同类的"读不到就用默认值"的文件读取,值得逐一审查。沉默的默认值比崩溃危险得多——崩溃会被发现,默认值不会。
- 缺文件时可以自动报错。最直接的止血办法:驱动发现该层在"应有文件"的名单里、而文件不存在时,直接退出。这需要一个"应有清单",目前没有。
- 两种路径的误差没有跨层汇总。我们知道单层的差异(
0.0127DC 偏移 → 12–35% 相对误差),但"整条 28 层的链上,路径选择对最终 logits 的影响有多大"没有量化。
下一篇我们看同一类问题的另一面:逐层尺度自适应——为什么"每层用同一个尺度"是行不通的,以及定标时的两个硬约束从哪来。