[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 = −1
  • w = ψ²: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^64R² 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)做三件事:

  1. 往返位级iNTT(NTT(x)) == x,逐系数比较;
  2. 与教科书 O(n²) 朴素 negacyclic 卷积逐系数对照ntt_negacyclic_mul() 的结果必须与"两层循环硬算"的结果完全一致
  3. 性能计时:确认加速确实存在。

第 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. 这一篇的未解问题

  1. radix-2 而已,没有做 radix-4 或 SIMD 化。头注释里"教科书 O(n²) 优先保证正确性"的阶段已经过去,但 NTT 本身仍有明显的常数级优化空间——这是本项目里少数几个"题做完了但没做完"的地方
  2. use_mont 这条双路径缺一个交叉验证:我们验证了"两条路径各自与教科书一致",但没有验证"同一个多项式在两条路径下结果一致"(理论上不需要,因为模数不同;但作为实现健壮性检查,它有价值)。
  3. 没有形式化验证。目前的可信性来自"逐系数位级对照",不是数学证明。对于原型目标是够的,但对一个要被第三方长期依赖的内核,这不够。

下一篇我们往上一层:RNS-CKKS 的模数链管理——rescalemodswitchmodraise 这三个最容易混淆的操作到底各干了什么。

posted @ 2026-09-19 23:34  haliu  阅读(0)  评论(0)    收藏  举报