【FHE】(十一):密文链协议——两条不变式,和 112 与 2100 的分工
项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm
0. 一句话结论
整条链的契约窄到可以写在信封背面:
layL : u(L-1)r112 ──→ uL
bootL: uL ──→ u(L)r112
每一跳都是一个幂等的纯函数:给定输入密文 + 编译好的驱动 + 同一把私钥,输出唯一。这就是"接力"在工程上能成立的全部理由——下一棒不需要知道上一棒是谁,也不需要信任他。
1. 两条不变式
| 不变式 | 内容 | 为什么重要 |
|---|---|---|
| 输入形态固定 | 每一跳的输入永远是"已刷新、链长 112"的密文 | 任一棒拿到的都是同一种东西,不用为上游差异写分支 |
| 输出形态固定 | 每一跳产出 8 枚密文(4 个 token 位置 × 2 个分量) | 交接包结构统一,验证脚本可以通用 |
第二条里的"8"值得解释:这一层同时处理 4 个 token 位置,而 CKKS 密文有 2 个分量(comps = 2,重线性化之后)。所以 4 × 2 = 8。
2. 阶段表:T23_PHASE
驱动用环境变量 T23_PHASE 选择"这一跳是哪一种",源码里的定义(原文):
int phase = 0; /* 0=l0 1=boot 2=l1(M2 layer1) 3=t26(layer26) 4=t27(layer27) 5=fin 6=lay */
int lay = -1; /* phase==6: T23_LAY 指定中间层 0..25 */
T23_PHASE |
含义 | 参考前缀 | 权重目录 |
|---|---|---|---|
0 |
l0(第一层,生成私钥) |
l0_ |
tail/w0 |
1 |
boot(自举刷新) |
— | — |
2 |
l1(M2 阶段的第一层) |
— | .tmp_tok/l1w |
3 |
t26(第 26 层) |
l26_ |
tail/w26 |
4 |
t27(第 27 层) |
l27_ |
tail/w27 |
5 |
fin(收尾:norm / logits) |
— | — |
6 |
lay(中间层,用 T23_LAY 指定) |
l{L}_ |
tail/w{L} |
中间层的层号由 T23_LAY 指定,合法范围 [0, 27](代码里有显式检查,越界直接退出)。阶段 3/4/6 走同一套参数:
g_fold = 1024.0; g_chk = 4096.0; g_refpre = pref; g_causal = 1;
注意 g_causal = 1:中间层与尾层都是因果注意力(这符合自回归语言模型的事实),而 g_fold / g_chk 是这一个阶段的折叠与校验尺度。
3. 为什么 lay 只要 112,而 boot 要 2100
这是最初最让人困惑的一点:同一层数据,为什么两个程序编的链长差近 20 倍?
| 程序 | 编译期链长 | 实际看到的链长 | 原因 |
|---|---|---|---|
t23lay |
112 | 输入 112 → 输出 16 | 层内前向只需要"够用"的链 |
t23boot |
2100 | 输入 16 → 输出 112 | 自举内部的 sin 折叠约需 31 层深度(本系列第 7 篇) |
关键区分:
- 编译期链长 = 该程序"支持的最大链长",也就是它的内存上界;
- 实际链长 = 这一刻密文上真正剩多少素数。
所以 t23boot 编到 2100,不代表它跑的时候链上真有 2100 个素数——它只是必须准备那么多空间,因为 modraise 那一步(第 7 篇)会先把链抬满,再做几十层深的折叠。
这也直接解释了为什么 boot 比 lay 慢得多、吃内存多得多:
lay0 : 1920 s,工作集 ≈1.0 GB
boot0 : 4285 s,链长 np=2100(建议内存 ≥8 GB)
4. 私钥从哪来:一把钥匙贯穿全链
这是个容易被忽略但很关键的设计点:
if (phase == 0) ckks_sk_gen(&sk, (uint32_t)n);
else if (t23_sk_load(&sk, ".tmp_tok/chain/sk.bin") != 0) { printf("sk load fail\n"); return 1; }
只有 phase=0(第一层)生成私钥,其余所有跳都从 .tmp_tok/chain/sk.bin 读同一把。
含义有两面:
- 好的一面:接力者之间不需要传密钥分发协议——
sk.bin就在数据包里(也因此,这个包不能公开分发,它只适合熟人/内部接力); - 必须诚实的一面:
sk.bin出现在接力包里,意味着任何拿到包的人都能解密整条链。这不是设计缺陷,而是"机制验证"这一阶段的必然——我们还没有做密钥托管或分段解密。
另外,只想生成密钥时可以走 T23_KEYONLY=1:驱动会打印 KEYONLY: sk.bin saved 后退出。这对搭建测试环境很有用(注意:生成目录必须先建好,驱动用裸 fopen 落盘、不会自建目录)。
5. 交接包与断点续跑
一跳的产物只有三样:
chain/u{L}r112_{t}_{h}.ct # 8 枚密文,单文件 ≈3.5 MB
relay_rerun/{lay,boot}{L}.out # 日志
manifest.sha256 # 逐文件哈希,可自校验
而"跑层"脚本(rerun5_lay_boot.ps1)有一个很实用的性质:已有产物自动跳过。也就是说:
- 跑到一半中断,重跑不会从头再来;
- 层与层之间天然可并行(不同的人跑不同的层,互不阻塞)。
这是"横向切链"这个设计的直接红利——它不只是为了省内存,也是为了让人可以各跑一段。
6. 一处照实标注的未核实项
boot 的日志里会打出:
out np=2083
按文档口径,这一行出现 8 次(每层 4 token × 2 分量)视为"刷回满链"的判据。
但2083 与编译期 2100、落盘 112 之间的精确对应关系,本文不做断言——它涉及 modraise 抬升到多少、以及折叠过程中又消耗了多少个素数。这个数字需要以源码为准逐行确认,我不在这里凭推测解释它。
这条按本项目纪律标注:未核实的东西不写结论,宁可留一个洞,不给一个猜测。
7. 安全边界(务请读完)
本文所述参数为机制验证级(n=2048、112 素数内层链、2100 素数自举链),
远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本文主张的是:链协议的不变式与工具链行为。
本文不主张:安全强度、性能优越性。
特别提醒 §4:sk.bin 与密文一起在接力包里流转——这套机制目前不具备"服务提供方看不到数据"的性质,请勿据此宣传任何隐私保证。
8. 这一篇的未解问题
- 协议没有版本号。如果引擎参数变了(比如链长从 112 改成 128),旧包和新包长得一样,但互不兼容。目前靠"文档约定 + 论文式对齐"来避免混用,没有机制上的防护。
- 交接包不带"输入指纹"。下一棒无法验证"我拿到的
u{L-1}r112就是上一棒声明的那个"——只能验哈希(如果上一棒把哈希公开的话)。这在本系列第 14 篇里作为验证学的第 4 条未解问题出现过。 phase 2 (l1)与phase 6 (lay)的语义有重叠。它们都做"中间层前向",区别来自开发阶段(M2 阶段只有一层)。对新人来说这是历史包袱。fin阶段的门槛与前面几层不同(它的输出是对齐 logits 而不是密文链的下一跳),这一点在协议文档里描述得不够显眼——我们自己在复现时就撞过。
下一篇回到工程侧:为什么我们把 OpenMP 换成了自研线程池。
浙公网安备 33010602011771号