关键词:同态加密 | FHE | CKKS | RNS | 模数链 | rescale | modswitch | modraise | 60-bit 素数 | 大模型密文推理
导读:RNS-CKKS 是同态加密推理引擎的主干。它的模数链上有三个名字很像、作用完全不同的操作——rescale、modswitch、modraise,混起来会直接算错。本篇讲清三者的差别,以及两条硬约束:为什么素数选 60-bit,为什么每个素数必须满足 q_i ≡ 1 (mod 2n)。
项目仓库
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 怎么读:上半是链长收支,三根柱子的高度按素数个数成比例(所以中间那根只有 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. 这一篇的未解问题
- 没有自动的链长规划器。哪些操作消耗几个素数、整层总共消耗多少,目前靠"编译时给足 + 跑挂了下调"来定。112 与 16 这两个数字更像是调出来的,不是算出来的。
modswitch在我们这条链上几乎没用。它目前的实际价值是"对齐两条链长不同的密文",而这个场景在我们的流水线里很少出现。它更像是一个为了语义完整而留的接口。- 素数链的选取没有做数值优化。
q_i的具体数值对噪声增长有影响,我们目前是按约束(素数、60-bit、≡1 mod 2n、递减)生成的,没有做"选哪一组更好"的搜索。
下一篇讲一个把"数量级"讲清楚的例子:Key-Switch——为什么不做数字分解,噪声会到 2^73,而 2^73 会直接爆掉 60-bit 素数。