[FHE]我们如何实现同态加密推理(三):手写 negacyclic NTT——以及怎么证明自己写对了
项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm
0. 一句话结论
全同态加密里最底层的原语是环上多项式乘法。我们没用一个现成 FHE 库,而是自己写了负循环 NTT(vllm_ntt.c),理由是位级可控。
而"自己写对了"这件事,在我们的语境里有可操作的定义:不是"数值上接近",而是与教科书 O(n²) 朴素卷积逐系数位级相同。这条判据比任何单元测试断言都硬。
1. 为什么是"负循环" NTT
我们工作的环不是 Z_q[x]/(x^n - 1),而是
R_q = Z_q[x] / (x^n + 1)
多出来的那个减号变成加号,就是"负循环"的由来。其数学后果是:普通的 n 次单位根不管用了,你需要一个 2n 次本原单位根 ψ,满足 ψ^n = −1。
于是:
ψ:2n 次本原单位根,ψ^n = −1w = ψ²:n 次单位根(用于常规蝶形)
头注释把变换式子写得很明确(原文):
fwd: A[i] = Σ_j a[j]·ψ^j·w^(ij) (先乘 ψ^j,再 DIT radix-2 蝶形)
inv: a[j] = n^{-1}·ψ^{-j}·Σ_i A[i]·w^(-ij) (蝶形用 w^{-1},最后归一)
"先乘 ψ^j"这一步是负循环 NTT 的核心:它把 x^n+1 上的卷积"扭"成普通循环卷积,之后就能用标准 radix-2 蝶形。
1.1 前提条件:模数必须"配合"
ψ 存在是有条件的:模数 q 必须是素数,且 2n | q − 1。
这条约束会一路传导到上层的参数选择——本系列第 5 篇会看到,CKKS 那代的每个 60-bit 素数都必须满足 q_i ≡ 1 mod 2n,否则 2n 次本原单位根根本不存在,整条链无从谈起。
2. 两种模乘路径(一个必须踩的坑)
NTT 的每个蝶形都含模乘。模乘怎么写,取决于 q 有多大:
| 情形 | 路径 | 原因 |
|---|---|---|
q < 2^63(我们的 60-bit 素数) |
Montgomery 乘法(R = 2^64) |
q 足够小,Montgomery 归约安全且快 |
q = 2^64 − 2^32 + 1(Goldilocks,64-bit) |
__int128 直接取模 |
Montgomery 的中间量 t + m·q 会溢出 2^128,不能走 |
头注释把这条决策写在了结构体里(原文):
int use_mont; /* q < 2^63 时启用 Montgomery(60-bit 素数安全;
64-bit Goldilocks 的 t+m·q 可溢 2^128,走 __int128) */
这是一个典型的"两代实现共用一层底层"的代价:BFV 那代用 Goldilocks(64-bit),CKKS 这代用 60-bit 素数——同一个 NTT 模块必须同时支持两种模乘路径。代价是多一个分支,收益是两代实现能共享同一份(且已被位级验证过的)卷积内核。
3. 预计算表:把能提前算的都提前算
ntt_ctx_t 里全是预计算表,这是 NTT 性能的关键:
| 字段 | 内容 |
|---|---|
q, n, psi, n_inv |
模数、次数、2n 次本原根、n^{-1} |
qinv, r2 |
Montgomery 常数:−q^{-1} mod 2^64、R² mod q |
wlen_fwd / wlen_inv |
[log2(n)+1] 个每级的 w^(n/len) / w^(-n/len) |
psi_pow / inv_psi_pow |
[n] 个 ψ^j / ψ^{-j}(负循环的"扭转"因子) |
rev |
[n] 位反转表 |
wpow_fwd / wpow_inv |
[(lg+1)·n/2] 每级预计算的 w^(j) |
bufA / bufB |
[n] 工作区(避免每次分配) |
注意所有表都是"Montgomery 域内"的。这意味着变换内的模乘全部是同域运算,不需要反复进出 Montgomery 域——少一层转换,少一次出错的机会。
4. 怎么证明它是对的:与教科书版位级对照
这是本文最想讲的部分。
浮点世界里,"写对了"通常意味着"误差在容差内"。但在我们这里不行——整数模运算没有"接近"这回事:a·b mod q 要么完全正确,要么完全错误,没有中间状态。
所以自测 ntt_self_test()(代号 T13)做三件事:
- 往返位级:
iNTT(NTT(x)) == x,逐系数比较; - 与教科书 O(n²) 朴素 negacyclic 卷积逐系数对照:
ntt_negacyclic_mul()的结果必须与"两层循环硬算"的结果完全一致; - 性能计时:确认加速确实存在。
第 2 条是关键。教科书实现(O(n²))慢但显然正确,NTT(O(n log n))快但不显然正确。用前者锚定后者,就得到了一条不依赖任何外部参考实现的可信链。
这套思路在本项目里是通用的:用慢而显然正确的东西,去锚定快而不显然正确的东西。 后面你会看到同一招出现在别的层——
verify_layer用"解密后对明文参考"去锚定整条密文链,本质上是一回事。
5. 安全边界(务请读完)
本文所述参数为机制验证级(n=2048、112 素数内层链、2100 素数自举链),
远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本文主张的是:实现选择与验证方法。
本文不主张:安全强度、性能优越性(本文不含任何跨实现性能对比)。
另外说明:n 仅支持 2 的幂(本项目用 1024 与 2048)。这是 radix-2 蝶形的直接结果,不是疏漏。
6. 这一篇的未解问题
- radix-2 而已,没有做 radix-4 或 SIMD 化。头注释里"教科书 O(n²) 优先保证正确性"的阶段已经过去,但 NTT 本身仍有明显的常数级优化空间——这是本项目里少数几个"题做完了但没做完"的地方。
use_mont这条双路径缺一个交叉验证:我们验证了"两条路径各自与教科书一致",但没有验证"同一个多项式在两条路径下结果一致"(理论上不需要,因为模数不同;但作为实现健壮性检查,它有价值)。- 没有形式化验证。目前的可信性来自"逐系数位级对照",不是数学证明。对于原型目标是够的,但对一个要被第三方长期依赖的内核,这不够。
下一篇我们往上一层:RNS-CKKS 的模数链管理——rescale、modswitch、modraise 这三个最容易混淆的操作到底各干了什么。
浙公网安备 33010602011771号