【FHE】我们如何实现同态加密推理(一):把 2B 大模型拆成 28 段密文链条来算(纯 C11 · 零依赖)
项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm
0. 一句话结论
我们让一个 2B 参数的大模型(Qwen3-VL-2B)在密文上做前向推理,做法不是"造一台更强的机器",而是把模型切成 28 段,每段各自成为一个可交接、可独立验证的密文包——于是一台普通 CPU、跑 1.8 小时,就能为整条链贡献一段。
成功的部分:前 5 层(层 0–4)已在纯 CPU 上可复现、可独立验证;链尾(层 26–27 + 收尾)也已跑通;交接是逐字节可验证的——第三方不需要信任打包者。
没做到的部分:参数是机制验证级,不是安全级;精度余量不宽;中间 21 层(层 5–25)在写这篇时仍待认领。
1. 为什么"单机跑完"从一开始就不是目标
同态加密推理的工程现实是这样的:模型有多大,密文就有多大;层数有多深,噪声预算就有多紧。
以我们这个配置为例(§2 会给完整口径):
| 项 | 值 |
|---|---|
| 模型 | Qwen3-VL-2B-Instruct,dim=2048 layers=28 heads=16 ff=6144 vocab=151936 |
| 多项式次数 | n=2048 |
| 槽位数 | 1024 |
| 缩放因子 | scale = 2^60 |
| 层内前向链长(编译期) | 112 个素数 |
| 自举链长(编译期) | 2100 个素数 |
一枚"密文"是一个 RNS 表示的多项式环元素。n=2048、112 个 60-bit 素数、2 个分量的密文,落盘就是 ≈3.5 MB。而一层要处理 8 枚(4 个 token 位置 × 2 个分量)——一层就是几十 MB 的密文,而且噪声预算会随着深度被吃掉,必须周期性地"自举"把链刷回去。
换句话说:这条链的瓶颈不是算力总量,而是噪声预算的消耗速度与单机内存。
所以我们的设计选择是——按层横向切开。
2. 观测口径(先交代前提,再上数字)
按本项目《术语与数据口径》§2.5 的纪律,先声明口径。
2.1 硬件与软件
| 项 | 值 |
|---|---|
| 开发/基准平台 | AMD Ryzen 7 9800X3D(8 核 16 线程) |
| 编译器 | gcc,-O2 -fopenmp |
| 依赖 | 仅 libc + libm,无 GPU / 无 NPU / 无第三方 FHE 库 |
| 引擎 | 纯 C11 自研,NTT / CKKS / 线程池均为本仓源码 |
2.2 参数口径
| 项 | 值 | 说明 |
|---|---|---|
CKKS_N |
2048 | 编译期覆盖(头文件默认 1024) |
CKKS_NPRIMES |
lay=112 / boot=2100 / verify_layer=112 |
编译期量,指该程序支持的最大链长 |
| 交接密文链长 | np=112 |
落盘的 u{L}r112_*.ct |
scale |
2^60 |
|
| 线程数 | T23_NT |
⚠️ 线程数是结果变量,见 §4 |
2.3 可比性纪律(重要)
- 跨轮次的绝对耗时不可比。本文所有耗时均为同轮交错 A/B,不引用历史会话的绝对值。
- 耗时数字必须与线程数绑定陈述,否则无意义。
3. 一次"跳"(hop)的契约
整条链的定义非常窄,窄到可以写在信封背面:
layL : u(L-1)r112 ──→ uL
bootL: uL ──→ u(L)r112
uX表示"第 X 层的输出密文";后缀r112表示已刷新、链长为 112。- 一跳 =
lay(层内前向:QKV、attention、FFN、激活)+boot(自举刷新)。 - 一跳产出 8 枚密文(4 个 token 位置 × 2 个分量),打包交给下一位接力者。
- 交接包是自描述的:密文 + 日志 + 元数据 +
manifest.sha256。
为什么必须交替?因为 lay 会把模数链吃掉,而 boot 把它刷回去。"链长"就是这条流水线上的油箱表:
| 阶段 | 链长(素数个数) | 编译期链长 |
|---|---|---|
| 输入(上一跳产出) | 112 | — |
lay 消费后 |
16 | 112(t23lay) |
boot 刷新后 |
112 | 2100(t23boot) |
boot 之所以需要 2100 这么长的编译期链,是因为自举内部的折叠逼近需要很深的乘法深度;而落到盘上的交接密文只需要 112。
未核实项:
boot日志里会打出out np=2083作为"刷回满链"的标志串(每层 8 次)。这个 2083 与编译期 2100、落盘 112 之间的精确对应关系,本文不做断言——引用时请以源码为准。
4. 为什么"拆开"不等于"不可信"
这是整个设计里最容易被质疑的一点:把密文交给不认识的人算,凭什么信?
我们的答案不是"信",而是让信任不必要:
- 逐文件 SHA256。交接包内每个密文、每个日志都有哈希,
manifest.sha256自校验。归档实测:主包 104/104、链尾 45/45、工具包 6/6。 - 独立复核工具。
verify_layer自己做"解密 → 解码 → 对明文参考算max|err|",刻意不采信驱动的RESULT=PASS(这一点值得单独写一篇,见本系列第 14 篇)。 - 位级可复现。同一份源码、同一线程数,在 x86-64 与 aarch64 上产出逐系数位级一致。
- 数据可自助生成。约 7 GB 的数据包不随仓库分发,但可以用
tools/preproc/的脚本从模型自助生成——我们验证过生成物与分发物逐字节相同(权重 9/9、26 个逐层模式文件 26/26)。
一个必须讲清的坑:线程数是结果变量
T23_NT=4 与 T23_NT=8 跑出来的结果在位级上不同。
根因不是"浮点误差",而是:vllm_ckks.c 里使用了 _Thread_local 随机数状态、并以固定常量播种——哪个线程抽到哪几个随机数,取决于调度器。于是"同一个计算"在不同线程数下消耗了不同的随机性,结果自然位级不同。
修复方案是按自同构指数 k 播种,让输出成为数学的函数,而不是调度器的函数。
但必须如实说明:截至本文写作,这个修复还没有落地。源码里 ckks_rng_state 仍然由固定常量初始化:
/* vllm_ckks.c */
static _Thread_local uint64_t ckks_rng_state = 0x243F6A8885A308D3ull;
(文件里唯一一处时间相关播种在 ckks_self_test() 内,不在推理路径上。)所以"不同线程数结果位级不同"这条限制当前仍然成立,我们的交付口径也因此绑定线程数:所有可复现性声明都必须在固定 T23_NT 的前提下讲。
这件事的教训比 bug 本身重要:任何"并行 + 随机化 + 比对结果"的流水线,都有同一个陷阱,而且它不会体现在 max|err| 上——你不去比字节,就永远看不见它。(详见本系列第 13 篇。)
5. 安全边界(务请读完)
本文所述参数为机制验证级(n=2048、112 素数内层链、2100 素数自举链),
远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本系列主张的是:可复现性与工程量账本。
本系列不主张:安全强度、性能优越性、与任何其他方案的优劣对比。
补充两点,避免任何误读:
n=2048与自研的非密码学 RNG(原型级xorshift)意味着这套东西不具备密码学安全强度。它的价值在于验证"机制能不能跑通、能不能被第三方复现"。- 我们不声称比任何其他同态加密推理方案更快或更优。本文出现的所有耗时数字,都是我们自己这份实现在我们自己这台机器上的账本。
6. 现在的进度(照实写)
| 部分 | 状态 |
|---|---|
| 层 0–4(链头) | ✅ 可复现、可独立验证;lay 判据 RESULT=PASS、boot 判据 BOOT=PASS |
| 层 26–27 + 收尾 | ✅ 已跑通并归档 |
| 层 5–25(21 跳) | ⏳ 待认领 |
| 单跳耗时(实测) | lay0 1920 s、boot0 4285 s → 一跳约 1.8 小时(8 核 CPU) |
| 精度余量 | ⚠️ 不宽。链尾抽样中 u27 为 8 项通过 6 项,其中 t1h1 = 3.82e-2、t3h1 = 7.97e-2,超出 verify_layer 默认容差 3e-2 |
最后一行我们本来可以不写。但按本项目的纪律——不漂亮的结论照实写。
7. 这一篇的未解问题
- 自举是绝对瓶颈:
boot0的分段 profile 显示,coeff_to_slot(1688 s)与slot_to_coeff(1716 s)合计占了 约 80% 的时间,sin_fold约 850 s。也就是说,继续优化乘法几乎不解决问题,瓶颈在域变换。 - 误差预算缺少统一理论:我们目前是逐层实测 + 逐层定标(
tools/preproc/_adap_scale.py),没有一套"从 28 层反推每层可用误差预算"的推导。链尾那两项超阈,是否应该判定为失败而不是记账,我们还没定论。 - "下一棒不信任上一棒"在极端情况下是否成立:目前的验证是"产出对不对",还没有做到"上一棒的输入有没有被替换"——这一点需要额外的机制。
下一篇我们讲一个更底层的问题:为什么我们最终放弃了 BFV 方案——以及一个看上去无关紧要的细节(明文模上的除法语义)是怎么把 GELU 逼死的。
浙公网安备 33010602011771号