【FHE】(九):逐层尺度自适应——为什么每层都要单独定标
项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm
0. 一句话结论
密文槽位的可表示范围是固定的(scale = 2^60,且要求 |z| < 0.5)。但 28 层的中间值分布千差万别——有的层输出在 1 附近,有的层能到 55。
所以每层需要一个自己的尺度因子 F{L},把该层的值压进可表示范围。而这个定标不是随便取的,它被两个硬约束夹住:
| 约束 | 值 | 来源 |
|---|---|---|
| 上限:压完后的 max | ≤ ~0.35(目标 TGT_MAX = 0.40) |
decode / boot 的可表示界 |
| 下限:压完后的 rms | ≥ ~0.02 |
信噪比 ≥ 20,对抗约 1e-3 的噪声地板 |
上限防溢出,下限防"压太小被噪声吃掉"。 两层之间的空间不宽,这就是这项工作的核心难点。
1. 为什么"全链一个尺度"行不通
如果所有层共用一个 F:
- 按最大的那层选
F→ 其他层被压得过小 → rms 掉到噪声地板以下 → 精度被噪声吞掉; - 按最小的那层选
F→ 大值层直接溢出 → 整个槽位废掉。
这不是理论担忧。实际链上确实存在"爆炸层"——脚本注释里点名列了一个(真链 y2 附近能到 ~55)。一层爆炸,全链定标策略就得改。
2. 链状态的定义
脚本 tools/preproc/_adap_scale.py 的核心变量是"链状态"(原文):
s{L} = y{L} / F{L}
y{L}:第 L 层的真实输出(明文侧算出来的)F{L}:该层的尺度因子,取 2 的幂s{L}:真正被送进密文链的量
用 2 的幂有两个好处:与 scale = 2^60 的二进制定标天然对齐(乘以 2 的幂只改指数);而且 F 的选择可以离散化成"取到最近的 2 的幂",避免浮点抖动导致不同机器选出不同的 F。
3. 两个硬约束的来源
3.1 上限 s max ≤ ~0.35(TGT_MAX = 0.40)
理由写在脚本注释里:decode 与 boot 的兼容界。
回顾第 5 篇:scale = 2^60、槽位要求 |z| < 0.5,这样 2^60 · z < 2^59 小于任一 60-bit 素数,中间层解密无需 CRT。所以密文槽位能装的值上限就是 0.5——而 0.40 是留了余量的工程目标值。
又因为 F 取 2 的幂,向上取整后实际的 max 落在 (TGT_MAX/2, TGT_MAX] 区间内,所以目标实质上被实现成 s max ≤ ~0.35。
3.2 下限 s rms ≥ ~0.02(SNR_MIN = 0.02)
这一条是很多人会漏掉的。 把值压小虽然能防溢出,但密文里始终存在噪声——本项目的噪声地板约 1e-3。如果 s 的均方根被压到和噪声同量级,信号就被噪声淹没。
脚本给的判据是 s rms ≥ 0.02,对应信噪比 ≥ 20。
所以定标是一个双侧夹逼问题,不是"压得越小越安全"。
3.3 还有一条硬界:至少压到 ≤ 0.5
除了 0.40 的目标界,脚本里还单独算了一个 need50:至少把 max 压到 0.5 以下(否则 decode/boot 根本不能用)。当目标界与硬界冲突时,硬界优先。
4. F 的调度规则
脚本里的规则可以完整复述出来(这也是本文最实用的部分):
| 规则 | 内容 |
|---|---|
| 取幂 | F{L} = 2^ceil(log2(ymax / TGT_MAX)),即按该层 max 取到 2 的幂 |
| 单调不减 | 只允许"下缩",不允许回缩(F 单调不减) |
| 相邻比 ≤ 4 | 相邻两层的 F 比值上限 RATIO = 4,避免尺度突变引入额外误差 |
| L0 例外 | lay0 的输入是明文(没有被前层稀释过),所以只按 max 定 F,不做单调 clamp |
| 爆炸层例外 | 如果某层真的爆了(如 y2 ~ 55),允许跳升到 need50,此时可以超过 4× 比值 |
最后一条是"规则为现实让路"的典型:4× 比值是一条经验约束,不是不变式。当某层确实越界时,宁可破例,也不让整层溢出。
5. 一个有价值的工程性质:幂等重建
定标脚本会覆盖一部分已有文件(残差路径的参考值 l{L}_u1 / l{L}_u2_ref / l{L}_mid16,以及 ln / m2c 的拟合系数),所以"可重复运行"是一个必须保证的性质。
脚本用的办法很朴素但很有效:
- 先把原始文件备份到
_g1024_bak/; - 每次运行时,先把备份恢复回去;
- 然后从
embed4 + o + mlp重新前向积分出真值y{L}。
为什么这能保证幂等?因为重建用的 o(attention 输出)与 mlp_out 永远不会被这个脚本覆盖。所以"真值的来源"不受上一次运行影响——输入不变,输出必然不变。
而"以为自己在重建、其实用的上次结果"是这类脚本最常见的隐性 bug。这条设计是刻意的。
顺带一个已被验证的前提(脚本注释原文):明文恒等式
y{L} = y{L-1} + o{L} + mlp_out{L} 已验 1.5e-5
也就是说"残差链路的加法关系"在明文侧是对得上的(误差 1.5e-5)——先证明明文恒等式成立,再去调整密文定标,顺序不能反。
6. 一个不愿加 eps 的细节
ln 分支(求 1/√u)的拟合里有一行注释值得单独抄出来(原文):
y = 1.0 / np.sqrt(u) / 128.0 # 无 eps: m2c~1e-6 时 +1e-6 会污染 ~20% (u>=0.5*m2c 永不触 0)
含义:通常写数值代码时,1/sqrt(u) 会加一个 eps 防止 u = 0 除零。但这里故意不加,因为:
- 拟合区间已经限定为
u ∈ [0.5·m2c, 1.5·m2c]——从不触及 0; - 而
m2c的量级约1e-6,此时加1e-6的eps相当于把分母改变了一倍以上,会污染约 20% 的取值。
这是一个"防御性编程反而造成系统性偏差"的实例。 加了 eps 更"安全",但更错。拟合区间 z = (u − c0)/(8·s0) 的标准化、以及 deg-8 多项式,都是为了在已知的确定区间上把误差压住,而不是靠 eps 兜底。
7. 安全边界(务请读完)
本文所述参数为机制验证级,远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本文主张的是:定标约束与调度规则。
本文不主张:安全强度、性能优越性。
8. 这一篇的未解问题
TGT_MAX = 0.40/SNR_MIN = 0.02是经验值。它们分别来自"可表示界留余量"与"信噪比 ≥20",但留多少余量、信噪比为什么是 20 而不是 15 或 30,没有推导。这两个数是整条链精度的总闸门,却是拍的。RATIO = 4同样是拍的。它约束的是"相邻层尺度不要突变",但突变到多少会真的伤精度,没有测。- 定标是逐层独立做的,没有全局优化。严格说,每层可用误差预算应该由"下游还剩多少层"反推,我们目前是逐层局部决策 + 事后整链验收。
- 换模型不能自动适配。这套规则依赖对明文参考的统计,统计要重跑,中间还有人工判断(比如爆炸层要不要破例)。
下一篇我们看 attention:密文里没有 max,那 softmax 的数值稳定技巧怎么办?答案是"用一个巨大的定标常数把它换掉"。
浙公网安备 33010602011771号