【FHE】我们如何实现同态加密推理(五):RNS-CKKS 的模数链管理——rescale、modswitch、modraise 到底谁是谁

项目仓库
Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm
GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm

相关文档
术语与数据口径 |
性能与基准 |
构建与复现 |
快速上手 |
架构总览

0. 一句话结论

CKKS 的模数链上有三个操作,名字很像、作用完全不同,混起来会直接算错:

操作 一句话 什么时候用
rescale 切掉一个素数,并把 scale 归一 每次乘法之后(scale 从 2^60 翻到 2^120,必须拉回来)
modswitch 切掉一个素数,不归一 只想降级、不想改 scale 时
modraise 用 Garner 扩展提升回满链 自举的起点(本系列第 7 篇)

rescale = modswitch + 缩放归一。这个差别就是全部。


1. 为什么是 60-bit 素数,且必须 q_i ≡ 1 (mod 2n)

CKKS 那代用的是 RNS 表示:不把模数 Q 当成一个大整数,而是拆成一串小素数 q_0 … q_{k-1},所有运算在每个 q_i 上独立进行(RNS = 剩余类系统)。

两条硬约束:

① 为什么选 60-bit

不是 64-bit,也不是 32-bit。60-bit 是我们这个实现里"乘法安全"的上界——q_i 之间做乘法时,中间量必须能落进 64-bit 字里且留出余量。这也是为什么底层 NTT 对 60-bit 素数走 Montgomery、对 64-bit Goldilocks 只能走 __int128(见本系列第 3 篇)。

② 为什么必须 q_i ≡ 1 (mod 2n)

因为槽位打包需要 2n 次本原单位根(第 3 篇的那条约束一路传导到这里)。素数不满足这个同余式,那一个"槽"就根本不存在。

头文件里对 q 数组的注释就一句:模数链(递减)。注意是递减——链是按顺序消费的,rescale 每次砍掉最上面那个。


2. RNS 密文的内存布局

typedef struct {
    ckks_ctx_t ctx;      /* 密文自身模数链状态 */
    int comps;           /* 2 或 3 */
    uint64_t *c;         /* [comps*nprimes*n] 排列:c[(comp*nprimes+p)*n + i] */
} ckks_ct_t;

c 是一个平坦数组,索引公式是 (comp × nprimes + p) × n + i:分量 → 素数 → 系数。

这个布局选择有实际后果:同一分量的同一素数上的所有系数是连续的,所以一次 NTT 可以在这段内存上原地跑完,不用跳着访问。在所有可能的排列里,这是对缓存最友好的那一种。

还有一个细节值得注意(原文):

明文槽位 |z| < 0.5(2^60·z < 2^59 < 任一 q_i,保证中间层解密无需 CRT)

也就是说 scale 的取值与槽位取值范围是配套设计的:2^60 × 0.5 = 2^59,小于任何一个 60-bit 素数,于是中间层想解密看看时,不需要先做 CRT 重组。


3. scale 的收支:一进一出

CKKS 的实数编码是 X = round(x · scale),本项目 scale = 2^60(CKKS_SCALE_BITS 60)。

问题出在乘法上:

乘法前:  X_a = round(a · S)、X_b = round(b · S)
相乘后:  X_a · X_b ≈ a·b · S²      ← scale 变成了 S²

于是每次乘法都要做一次收支平衡:

乘法 → scale = 2^120 → rescale(切一个素数、除以该素数)→ scale 回到 2^60

代价是链长少 1。这就是"精度不是免费的"的具体样子:每一次乘法都在烧链长。

加密/解密这一进一出,也定义了两个边界操作的语义:

int ckks_encode(uint64_t *poly, const double *z, const ckks_ctx_t *ctx);
int ckks_decode(double *z, const uint64_t *poly, const ckks_ctx_t *ctx);
int ckks_encrypt(ckks_ct_t *ct, const ckks_sk_t *sk, const uint64_t *poly, const ckks_ctx_t *ctx);
int ckks_decrypt(uint64_t *poly, const ckks_sk_t *sk, const ckks_ct_t *ct);

4. 三个操作的辨析(本文核心)

int ckks_rescale(ckks_ct_t *ct, const ckks_ct_t *in);              /* 模数切换(切一个素数,scale 归一) */
int ckks_modswitch(ckks_ct_t *ct, const ckks_ct_t *in);            /* 降级:切素数不 rescale */
int ckks_modraise(ckks_ct_t *ct, const ckks_ct_t *in, int target); /* ModRaise:Garner 扩展提升回满链 */
rescale modswitch modraise
链长变化 −1 −1 提升到 target
scale 变化 除以被切掉的素数 不变 不变
加密语义 保持(近似误差重新定标) 引入额外近似误差 保持
方向 往下 往下 往上
典型用途 乘法之后 对齐两条链长不同的密文 自举第一步

最容易搞混的是 rescale 与 modswitch。 记法:rescale 里有 "scale",所以它动 scale;modswitch 只动 modulus。

modraise 是唯一一个"往上"的操作,也是自举能不能成立的关键:它把已经被消耗到很短的链,用 Garner 扩展重新抬回满链。注意这不是"免费恢复精度"——它不恢复已经损失的精度,只是把模数空间还给你,让你还有地方继续算。
这一点在本系列第 7 篇有一个反直觉的实测证据。


5. 链长就是油箱

把链长理解成"油箱表",整条流水线就清楚了:

阶段 链长(素数个数) 编译期链长
输入(上一跳产出) 112 —
lay 消费后 16 112(t23lay)
boot 刷新后 112 2100(t23boot)

lay 一路做乘法,把 112 个素数吃到只剩 16;boot 把油箱加回 112。

![图 5-1 模数链的收支与三个操作的辨析]

fig05_modulus_chain

图 5-1 怎么读:上半是链长收支,三根柱子的高度按素数个数成比例(所以中间那根只有 14% 高)——lay 一路乘法每次 rescale 切掉一个素数,一层吃掉 96 个;boot 的 modraise 用 Garner 扩展把模数空间还回来。下半是三个操作的辨析:rescale 与 modswitch 都切一个素数(顶端那块被划掉),差别只在动没动 scale;只有 modraise 是往上抬的。

矢量版 figs/fig05_modulus_chain.svg|Graphviz 源码 figs/fig05_modulus_chain.dot

而 CKKS_NPRIMES 是编译期量(该程序支持的最大链长),可以覆盖:

-DCKKS_NPRIMES=112     # lay
-DCKKS_NPRIMES=2100    # boot(自举内部折叠需要极深的链)

头注释里给了标定参考:32 个素数约合常规深度 10;而 sin 折叠函数约 31 深,所以折叠测试与全链自举要用 40 ~ 128 覆盖。

这就是"编译期链长"与"实际链长"的区别:t23boot 编到 2100,不代表跑到一半时链上有 2100 个素数——那只是它支持的上限,也是它需要的内存上界(所以 boot 比 lay 吃内存得多,也慢得多)。


6. 一处照实写的文档不一致

vllm_ckks.h 顶部「架构」段写的是:

模数链:4 个 60-bit 素数 q0..q3,总 240-bit,支持深 3。

但同一份文件里 CKKS_NPRIMES 的默认值已经是 32,注释也写明"60-bit×32=1920-bit,常规深 10"。

架构描述停留在最初 4 素数的原型阶段,没有跟着默认值更新。 这不是 bug(代码走的是 CKKS_NPRIMES),但它会误导刚读代码的人——包括我们自己。按本项目"不漂亮的结论照实写"的纪律,记在这里。


7. 安全边界(务请读完)

本文所述参数为机制验证级,远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本文主张的是:模数链管理的实现事实。
本文不主张:安全强度、性能优越性。

8. 这一篇的未解问题

  1. 没有自动的链长规划器。哪些操作消耗几个素数、整层总共消耗多少,目前靠"编译时给足 + 跑挂了下调"来定。112 与 16 这两个数字更像是调出来的,不是算出来的。
  2. modswitch 在我们这条链上几乎没用。它目前的实际价值是"对齐两条链长不同的密文",而这个场景在我们的流水线里很少出现。它更像是一个为了语义完整而留的接口。
  3. 素数链的选取没有做数值优化。q_i 的具体数值对噪声增长有影响,我们目前是按约束(素数、60-bit、≡1 mod 2n、递减)生成的,没有做"选哪一组更好"的搜索。

下一篇讲一个把"数量级"讲清楚的例子:Key-Switch——为什么不做数字分解,噪声会到 2^73,而 2^73 会直接爆掉 60-bit 素数。

posted @ 2026-09-20 17:54  haliu  阅读(23)  评论(0)    收藏  举报