ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CKKS同态加密实战指南:从原理到参数调优

CKKS同态加密实战指南:从原理到参数调优 1. 为什么是 CKKS它解决了同态加密的哪块短板1.1 从整数到浮点BFV/BGV 的天然局限很多刚接触全同态加密的人第一次跑通的是 BFV 或 BGV 方案因为它们逻辑直白把一个整数当作多项式系数加密加法和乘法在密文上精确对应明文整数运算。至少在数学层面这套逻辑非常干净。但一旦你想拿它做点真实业务问题立刻冒出来——真实业务里几乎全是浮点数。模型的权重是 0.731、1.2047 这种小数统计指标是平均值、方差距离计算是欧氏距离。BFV/BGV 不是不能处理小数而是处理起来非常别扭。你得给每个数手动乘一个缩放因子把它变成整数然后祈祷乘法次数不多。因为每做一次乘法缩放因子的量级会指数膨胀你不得不算着位宽定期做截断稍不留神精度就崩了。我当时第一次尝试用 BFV 模拟一个简单的线性回归推理模型只有 3 个特征、1 层乘法还能勉强跑通。但换成 2 层神经网络中间要经过激活函数、多层矩阵乘法缩放因子的管理直接失控。那段时间我最大的感受是方案在数学上没问题工程上几乎没法用。CKKS 就是冲着这个痛点来的。1.2 近似计算思路把误差当特性而不是缺陷CKKS 全称是 Cheon-Kim-Kim-Song按 2017 年论文的名字翻译过来就是“近似数算术的同态加密”。它和 BFV/BGV 最本质的区别在于CKKS 主动承认计算结果是近似的误差是设计的一部分而不是方案的副作用。它的做法可以类比成定点数运算把一个浮点数放大 2^40 倍变成一个大整数参与环上的运算做完后再缩小回去。每次乘法后所有数据自动进入一个新的缩放级别这个机制在方案层面被固定下来叫作 rescale。你不用像用 BFV 那样手动维护缩放因子也不用担心哪天忘了截断导致整数溢出。方案内部就把“小数点位置”管理好了。解密时你得到的不再是精确的明文而是“明文 一个小噪声”。这个噪声在参数合理时非常小比如小数点后 10 位以内。对绝大多数机器学习推理、统计分析场景来说这个精度完全够用。这套设计思想在密码学界其实很激进。传统同态加密追求的是“密文上算的和明文上算的完全一致”CKKS 直接把这个目标改成了“足够接近”。恰好现实世界里有大量计算本身就不需要精确结果。1.3 CKKS 的适用边界CKKS 不是万能药。它擅长浮点计算、向量批量处理、深度较大的算术电路但不擅长精确比较、取整、求余这类操作。金额结算这种一分钱都不能差的场景千万别用 CKKS老老实实回去用 BFV 或者别做同态。此外CKKS 的 bootstrapping自举虽然已经实用化但开销依然很高能通过参数设计避开就避开。适合用 CKKS 的场景主要有几类隐私保护的机器学习推理输入是密文模型参数可以明文也可以密文联邦学习场景下的安全聚合把各参与方的梯度加密后聚合医疗数据、基因数据的加密统计分析金融风控中的加密查询或加密评分这篇文章后面会围绕原理、实操、参数调优和踩坑链路展开。如果你是第一次接触 CKKS建议先跑通最小代码再回头看数学如果你已经在跑 CKKS 但精度不对、性能太差可以直接跳到第 5 章。2. 拆开 CKKS 引擎编码、缩放与噪声预算2.1 复数向量如何“装进”多项式所有基于 RLWE环学习错误问题的同态加密方案都工作在同一个代数结构上多项式环 R Z[X]/(X^N 1)。这里 N 是 2 的幂常见取值是 8192、16384、32768。明文、密文、密钥全都是这个环上的元素也就是次数小于 N 的整数多项式。CKKS 的巧妙之处在编码环节。虽然在环上只能处理整数多项式但 CKKS 能把一个长度为 N/2 的复数向量编码进一个多项式里。大致过程是这样的你有一个复数向量 z长度是 N/2对它做共轭对称扩展变成一个长度为 N 的向量对这个向量做逆傅里叶变换实际操作中就是逆 FFT把结果乘以缩放因子 Δ四舍五入成整系数多项式解码就是逆过程多项式系数除以 Δ做傅里叶变换取前 N/2 个分量。为什么必须做共轭对称扩展因为逆傅里叶变换的结果天然是关于原点共轭对称的而我们要编码的向量只有 N/2 个自由度必须保证编码后的多项式落在环的某个特殊子空间里解密后取回的那 N/2 个值才刚好是原始数据。我第一次看这个编码过程时绕了好久才转过弯来。它本质上就是“把数据藏进系数的频域表示里”所以 CKKS 里对数据的操作最后都会对应到频域上的逐点操作。这也是为什么后来打包、旋转、矩阵乘法这些操作都有非常优雅的实现方式。有个细节容易忽略编码时是逆 FFT不是逆 NTT。虽然 BFV 也做类似变换但那是为了快速多项式乘法用的是模素数域上的 NTTCKKS 编码是真正在复数域上做傅里叶变换。理解这一点你就明白为什么 CKKS 天然支持复数而不只是实数了。2.2 scale 机制为什么乘法后一定要 rescalescale 是 CKKS 里最重要的概念没有之一。你可以把 scale 理解为定点数里的小数点位置。编码时乘的那个 Δ 就是初始 scale典型值设成 2^40也就是 40 位二进制精度大约相当于 12 位十进制小数。两个 scale 为 Δ 的密文相加结果的 scale 还是 Δ没问题。但两个密文相乘时数学上对应的是两个多项式乘积明文的乘积自然变成了“明文 × 明文”它的 scale 变成了 Δ²。如果不做任何处理再来一次乘法就变成 Δ³再来几次系数大小直接突破模数上限数据彻底损坏。CKKS 的方案机制是每次乘法后做一个 rescale 操作把密文的每个系数除以 Δ近似地同时把 scale 从 Δ² 降回 Δ。这样下一轮乘法依然面对的是 scale 为 Δ 的密文整个计算可以无限继续下去直到模数链耗尽。关键点rescale 在实现时不是真的做除法而是通过模数切换完成的。这就是为什么 CKKS 的系数模数必须设计成多个素数的乘积q q0 × q1 × q2 × ... × qL每做一次 rescale模数就换成除以一个素数后的数scale 约等于去掉的那个素数本身。这里的“约等于”就是 CKKS 近似性的来源之一因为素数不可能恰好是 2 的幂所以每个素数位的 scale 其实是 2^40 附近的一个数。我建议新手第一次写代码时把 scale、模数、rescale 这三者的关系用一张表写下来初始 scale 是多少、乘法后变成多少、rescale 后模数剩多少、scale 回到多少。你会发现整个流程突然就清晰了。2.3 Level 与噪声预算计算深度的天花板每个 CKKS 密文都有一个“当前处于模数链哪一层”的概念叫作 Level。初始密文在最高层 L每做一次 rescale 就降一层降到 0 就不能再做乘法了。所以模数链里素数的数量直接决定了密文能承受的连续乘法次数。这就是为什么要做深度预估如果你的计算图里最长路径是连乘 5 次模数链至少要预留 5 次 rescale 的余量通常再留 1 层作为精度缓冲。噪声预算则是另一个维度。RLWE 密文里每个系数都带有一个随机小噪声解密时需要用私钥把噪声“滤掉”才能看到明文。加法和乘法都会让噪声增长而且乘法的噪声增长比加法快得多。当累积噪声超过某个阈值时明文就淹没在噪声里解密出来就是一坨乱码。可以通过 API 查看某个密文的噪声预算。SEAL 里能看到初始噪声预算大概是 log2(q) 减去某个常数。比如 coeff modulus 总位宽 240 位初始噪声预算大概在 200 位左右每做一次乘法噪声预算会掉几十位。当噪声预算掉到接近 0加密就是废的。实操中我习惯把“噪声预算还有多少”作为判断一次计算是否健康的第一指标而不是先去看结果对不对。结果不对大概率是噪声打穿了具体怎么排查第 5 章会展开讲。2.4 密钥体系与运算原语CKKS 的密钥有三件套私钥secret key解密用必须保密且只存在于一方公钥public key加密用可以公开分发求值密钥evaluation key也叫重线性化密钥relin key乘法后用来把密文尺寸从 3 个多项式压缩回 2 个多项式求值密钥的存在是因为两个密文相乘在数学上产生一个二次多项式直接展开就太长了。为了不破坏“密文固定由 2 个多项式组成”的结构需要用重线性化技术在密钥切换后把它压回去。这个操作本身是性能大头SEAL 库里目前的实现已经相当快了但在大参数下依然可能占据大部分计算时间。除了加法和乘法CKKS 还支持密钥切换key switching和旋转rotation。旋转是后面第 4 章要讲的重点它是实现打包和矩阵运算的基础。旋转需要额外的 rotation key用私钥生成按需分发不能复用别的场景的 key。到这里CKKS 的核心机制已经讲清了它是一个支持近似浮点计算、具备完整加减乘和旋转原语的同态加密方案。接下来直接进入实操。3. 实机演练用开源库跑通第一个 CKKS 计算3.1 库选型对比从哪个库入手成本最低业内常用的 CKKS 实现库有这几个库语言特点建议Microsoft SEALC文档最全、社区最活跃、API 设计清晰首选适合系统学习OpenFHEC支持多方案从 PALISADE 演进而来生产环境可考虑HEAAN / OpenHEEAANCCKKS 原作者团队的实现偏研究参考LattigoGo纯 Go适合 Go 后端集成后端是 Go 时推荐TensealPython封装 SEALAPI 更简单快速原型验证首选PyfhelPython封装 SEAL老牌原型可以凑合用我的建议是正儿八经理解 CKKS 用 SEAL这个库的错误提示比较友好参数校验严格能帮你省掉很多自我怀疑的时间。只是快速验证想法、不想碰 C那就用 Tenseal。3.2 环境准备和初始化以 SEAL 4.x 的 C API 为例初始化的代码骨架如下#include seal/seal.h using namespace seal; EncryptionParameters parms(scheme_type::ckks); size_t poly_modulus_degree 8192; parms.set_poly_modulus_degree(poly_modulus_degree); parms.set_coeff_modulus(CoeffModulus::Create(poly_modulus_degree, {60, 40, 40, 60})); auto context SEALContext::Create(parms); KeyGenerator keygen(context); auto secret_key keygen.secret_key(); PublicKey public_key; keygen.create_public_key(public_key); RelinKeys relin_keys; keygen.create_relin_keys(relin_keys); Encryptor encryptor(context, public_key); Evaluator evaluator(context); Decryptor decryptor(context, secret_key); CKKSEncoder encoder(context); double scale pow(2.0, 40);这里的 poly_modulus_degree 是 N取 8192coeff modulus 配的是 60、40、40、60代表四个素数每个大约 60 位或 40 位初始总模数约 200 位。这个配置是 SEAL 官方推荐的入门参数安全强度约 128 位最大乘法深度为 2。scale 取 2^40意味着编码时保留 40 位左右的精度。注意coeff modulus 的第一个和最后一个素数通常设得比其他大原因是第一个素数影响初始噪声预算最后一个素数影响最终精度。中间区间的素数大小就是每次乘法后 scale 的近似值这里设成 40 位和 scale 相呼应。3.3 完整加密加乘流程假设我们要算 0.5 × 1.5明文域结果是 0.75现在全程在密文上完成Plaintext plain1, plain2; encoder.encode(0.5, scale, plain1); encoder.encode(1.5, scale, plain2); Ciphertext c1, c2; encryptor.encrypt(plain1, c1); encryptor.encrypt(plain2, c2); evaluator.multiply_inplace(c1, c2); evaluator.relinearize_inplace(c1, relin_keys); evaluator.rescale_to_next_inplace(c1); Plaintext plain_result; decryptor.decrypt(c1, plain_result); vectordouble result; encoder.decode(plain_result, result); cout result[0] endl; // 约 0.75这段代码里有三个操作不能省略也不建议调整顺序multiply_inplace密文相乘此时 scale 变成 Δ²密文变成 3 个多项式relinearize_inplace用求值密钥把 3 个多项式压回 2 个rescale_to_next_inplace把 scale 从 Δ² 降回 Δ同时 Level 减 1为什么先重线性化再 rescale因为重线性化是对当前模数下的多项式做密钥切换密文项数少一项后续计算量自然更小。反过来先 rescale 再重线性化也可以做到数学上等价但是后者需要先把三个多项式都切到低模数白白多算一轮不划算。再说一遍 scale 对齐的问题两个密文相加要求两者的 scale 一致。如果一个是 2^40另一个乘过一次后 rescale 回 2^40那没问题。但如果一个密文刚乘过还没 rescalescale 是 2^80另一个是 2^40直接相加得到的结果 scale 是乱的后续再乘再 rescale 基本就废了。很多精度问题其实是这一步埋下的雷。3.4 解码与精度观察跑完上面这段代码你大概率会得到一个类似 0.7499999999999 的结果。这个“差一点”就是 CKKS 的常态不是 bug。误差来源有三块编码时把浮点数放大并取整这一步已经损失了低于 2^-40 的尾数乘法过程中噪声累积导致最低几位抖动rescale 时除以的素数不是精确等于 2^40引入了微量偏差这三个误差源里第一个是可预测的第二个可以通过参数控制第三个是方案固有属性。实践时不需要强求结果和明文完全一致只需要确认误差在应用可接受范围内即可。我第一次跑通这个最小示例时盯着那个 0.7499999 看了半天一度怀疑是不是哪里写错了还专门拿明文算了三遍。后来才意识到这正是 CKKS 的“近似算术”设计在起作用。搞清楚这个心理预期后面排查问题会省很多力气。4. 从单值到批量打包和旋转的正确打开方式4.1 插槽和 SIMD 打包CKKS 单个密文可以装 N/2 个复数N 8192 时就是 4096 个。这些位置叫 slot插槽。对密文做一次乘法相当于同时对 4096 个值做乘法开销和只算 1 个值几乎一样。这就是 CKKS 实用化的关键。全同态加密的性能再差一次操作能同时处理几千个数据摊薄下来每个数据的成本就能降到可用范围。所有认真的项目几乎都会用批处理技术把计算图矢量化。编码时直接把一个 vectordouble 传进去即可vectordouble input {1.0, 2.0, 3.0, 4.0}; Plaintext plain; encoder.encode(input, scale, plain);解码时拿出来的也是一个 vector。要注意的是长度必须小于等于 N/2多了编码器会直接报错。这里有个常见的性能误区不少人在初期只打包一个值结果性能惨不忍睹误以为 CKKS 完全不可用。其实应该养成一个习惯拿到一个计算任务先问自己能往一个密文里塞多少个独立样本。能塞进去性能问题就小一半。4.2 旋转操作密文里的位移批处理有一个绕不开的配套操作旋转。旋转的作用是让密文里的第 i 个 slot 移动到第 i1 个或任意偏移位置。为什么要旋转因为很多时候一个数据点的计算要跨 slot 访问其他数据。典型的例子是卷积。卷积核要扫过整个特征图对于每个输出位置都要把周围邻域的值取出来做加权和。相邻位置的输入在打包时存在不同 slot 里不旋转根本取不到。SEAL 里旋转需要先生成 rotation keyvectorint steps {1, -1, 2, -2}; // 旋转步长集合 RotationKeys rot_keys; keygen.create_rotations_keys(steps, rot_keys);之后对密文做旋转evaluator.rotate_vector_inplace(c, 1, rot_keys); // 左移一个 slot evaluator.rotate_vector_inplace(c, -1, rot_keys); // 右移一个 slot生成 rotation key 时需要规划好哪些步长会用到。步长越多样key 文件越大生成时间也越长。一个项目里用到 1、2、4、8 这种 2 的幂次步长很常见既能覆盖各种偏移又不会生成太多 key。旋转操作的开销比乘法大不少因为它内部要做密钥切换。实际优化中要尽量把旋转次数压缩到最少比如把连续两次旋转合并成一次大偏移的旋转。4.3 矩阵乘法的实现思路矩阵乘法是 CKKS 在机器学习推理中最常用的操作。假设输入是向量 x权重是矩阵 W要算 y Wx。最常见的做法是对角线打包法diagonal packing把矩阵 W 拆成若干条“对角线”每条对角线打包进一个明文把输入向量 x 的对应元素通过旋转对齐到正确位置把每个对角线的乘积累加得到结果向量的密文具体来说一个 4×4 矩阵可以拆成 4 条对角线每条对角线对应一个偏移步长。计算时对夹具输入向量做对应步长的旋转然后逐对角线做乘法和累加。整个过程只需要 4 次乘法、4 次旋转和若干次加法。这个方法的优点是乘法次数和旋转次数都等于矩阵的“非零对角线数”远小于逐元素展开的乘法次数。缺点是涉及大量旋转而旋转慢。如果矩阵有特殊结构比如稀疏、低秩还能进一步优化。另一个思路是直接用 CKKS 的“明文矩阵”乘法接口但通用性差一些。先跑通最基础的对角线打包再根据实际矩阵形状做定制优化是比较合理的路径。5. 参数调优与精度事故的排查链路5.1 参数搭配的核心原则CKKS 的参数选择会影响三个方面安全性、性能、精度。三者之间是矛盾关系需要根据应用场景做取舍。我平时配置参数的顺序是先确定需要的乘法深度 d。看计算图里最长的一条乘法链别只看层数要具体数每一层的乘法次数。一个深度为 d 再加初始精度的模数链通常至少需要 d 个“中间素数”。再确定需要的精度也就是 scale 的位宽。普通机器学习推理用 2^40 足够如果只需要少量乘法2^30 也能跑。scale 设得越高每个中间素数就越大同样位宽下能放的素数数量就越少。综合深度和精度算出 coeff modulus 的总位宽。再根据总位宽反推 poly_modulus_degree。SEAL 会直接告诉你某些参数组合不安全可以直接信任它的报错。举个例子假设要跑 4 次乘法每次乘法后都需要做 scale 回落到 2^40模数链结构可以是 {60, 40, 40, 40, 60}总位宽 240 位。这个总位宽下 poly_modulus_degree 至少要 16384因为 8192 能承载的安全模数上限大约只有 218 位左右。核心原则是不要自己造参数组合先用官方推荐的安全参数再逐步微调。SEAL 的CoeffModulus::Create会根据 poly_modulus_degree 自动判断素数位数是否过界最终的安全级别也可以从context里查出来。5.2 精度崩溃的排查链路CKKS 跑着跑着结果变得离谱这个问题几乎每个使用者都会遇到。我踩过几次坑之后整理出一套固定排查顺序按这个顺序检查通常能快速定位第一步查噪声预算。解密失败、结果完全错乱多半是噪声打穿了。在关键操作前打印一下密文的噪声预算看看是不是已经掉到 20 位以下。如果是说明乘法深度超出了模数链能支撑的上限。第二步查 scale 一致性。把所有加在一起的密文 scale 打出来看看是否一致。注意不是看数值接近程度而是看是否完全相等因为 2^40 和 2.0000000001^40 在后续乘法中的表现是截然不同的。加操作的两个输入 scale 不一致是最常见的 Bug。第三步查 rescale 是否遗漏。乘法之后是否做了 rescale如果连续做了几次乘法而中间没有 rescalescale 会指数增长后果类似整数溢出。第四步检查编码端的明文值。解码前先在明文域算一遍确认期望值在合理范围。有时候不是密文算错了而是初始数据编码时就已经引入了不可接受的误差。第五步检查模数链设计。乘法深度估计是否准确中间素数太小导致每次 rescale 精度掉得太快这些通常需要回到第 5.1 节重新推演。按这套顺序排查绝大多数精度问题都能在半小时内找到根源比起对着代码发呆效率高很多。5.3 性能优化经验CKKS 的性能瓶颈通常集中在三个地方多项式乘法NTT、重线性化密钥切换、旋转。前两个是密码学原语优化空间不大但可以通过减少操作次数来降低总开销。旋转的优化空间相对更大。几个实测下来效果明显的优化手段优先做批处理。同样的计算一个密文装 4096 个值和装 1 个值代价几乎一样。批处理是所有性能优化的前提。减少计算图里的乘法深度。有些数学公式可以改写比如把多个乘法的连乘结构改成加法树结构或者用乘法次数更少的算法实现多项式求值。把多个旋转合并。如果计算中先后要旋转 1 位和 2 位考虑是否可以直接旋转 3 位一次到位。尽量用明文乘法。CKKS 支持密文乘明文这个操作比重线性化密文乘法快很多因为不需要重线性化。模型推理时把权重作为明文输入作为密文能省下大量开销。减少 key 的生成数量。rotation key 和 relin key 的生成并不便宜每个 key 都要做密钥切换的预处理。key 的管理和发送也要规划好避免每个参与方都生成全套 key。一个典型的优化案例我在做一个 3 层 MLP 推理时一开始把模型权重也加密了整个推理耗时 6 秒。后来改成权重明文、输入密文同样结果耗时降到 1.8 秒。再通过打包把多个输入样本同时推理单样本延迟降到几十毫秒。性能和精度一样都需要从全局视角去调整而不是指望某一个参数能救回来。6. 落地视角密文浮点计算在项目中的位置6.1 隐私机器学习推理的典型结构CKKS 在隐私保护机器学习推理中的角色很清晰数据持有方把输入加密成密文发给计算方计算方在密文上执行模型推理返回加密结果只有数据持有方能解密看到结果。这里模型权重可以有两种处理方式一种是明文计算方直接把模型参数以明文形式参与密文乘法效率高适用于“计算方拥有模型、数据方想用模型”的场景另一种是权重也加密适用于双方都不希望对方知道模型参数的场景但计算开销明显增大。实际工程中还有一种混合模式把神经网络的前几层用 CKKS 做密文推理后面的层直接以明文形式在解密后进行避免整个网络都跑在密文域里。这带来的安全边界变化需要谨慎评估但确实是性能和安全的常见折中点。6.2 与其他隐私计算技术结合全同态加密不一定要单打独斗。CKKS 擅长浮点算术但在比较、取整、条件分支这些操作上很弱安全多方计算MPC恰好擅长这些逻辑操作缺点是通信量大。两者结合是现在工业界的主流思路之一。常见分工是大的浮点矩阵运算交给 CKKS利用它的批处理和近似算术特性涉及比较、截断、ReLU 激活这类操作时用 MPC 协议轮换处理比如神经网络推理里的 ReLU 无法用 CKKS 高效实现一种做法是把 ReLU 近似成多项式全程留在 CKKS 域里另一种做法是通过秘密分享切到 MPC 域里做精确比较再切回来。前者快但精度有损后者精确但慢。具体选哪种取决于模型对激活函数精度的敏感度。联邦学习也是 CKKS 的典型搭配。各参与方本地训练后把梯度用 CKKS 加密上传聚合方在密文上求平均再把结果返回解密。这个过程中聚合方全程接触不到任何一方的明文梯度比单纯传输梯度要安全得多。6.3 什么时候不该用 CKKS给项目做技术选型时知道“什么时候不选”比知道“什么时候选”更重要。以下几种情况CKKS 大概率不是最优解需要精确整数结果任何一位小数的偏差都不可接受回退到 BFV 更稳妥计算主要是字符串处理、数据库查询、条件分支CKKS 完全不擅长数据量极小、乘法深度极大、但只需要算一次自举开销可能让整体方案不划算参与方之间可以高频交互MPC 或两方安全计算可能更高效只是对单条记录做简单聚合也许差分隐私或可信执行环境更合适CKKS 的价值在于“在不可信环境下完成浮点稠密计算”抓住这个定位选型和方案设计就不容易跑偏。还有一个常被忽略的点同态加密会放大一切计算成本甚至连“把结果发送回数据方解密”这个环节都要设计好参与方。不是所有项目都需要端到端全程加密有些场景在特定节点解密后进入明文处理会比强行全文加密更务实。最后关于入坑路径的一点个人体会如果让我给完全没接触过 CKKS 的人一条学习路径我的建议是不要先啃论文直接装 SEAL把第 3 章的最小示例跑通然后打印每个阶段的噪声预算和 scale 变化体会一下“近似”到底是怎么一步步发生的。接着再回头读论文里的编码和重线性化部分你会发现原本晦涩的数学突然变得好懂了。我见过不少同行卡在最开始是因为期望 CKKS 像普通浮点运算一样“想当然”。实际上只要接受三个前提——结果有噪声、scale 要管理、乘法次数是硬资源——CKKS 用起来并不比传统密码学库更复杂。等到你开始独立调参数、排查精度问题时再去看一些深度优化方案比如 bootstrapping、打包自动编排这些进阶内容的技术前提在前面已经铺好了。到那个阶段你手边至少应该有一个能跑通的 CKKS 项目而不是停留在概念层面。
返回列表