大数运算基础:SM2 性能的基石

做 SM2 优化,说白了就是在跟大数打交道。SM2 的密钥、签名、加密,底层全是 256 位的大整数运算。你想想看,一个 256 位的数,用 uint64_t 数组表示也得 4 个元素。如果这些基础运算写不好,上层再怎么优化也是白搭。

我个人习惯把大数运算比作「搭积木」。Montgomery 模乘、Barrett 约减、快速模逆,这三块积木搭得稳,SM2 的性能才能上去。今天我们就把这几个基础模块拆开来看。

大整数的表示方式

256 位的大整数,C 语言里没有原生类型。常见的做法是用数组表示:

// 小端表示:a[0] 存最低 64 位
typedef uint64_t bn_t[4];

// 大端表示:a[0] 存最高 64 位
typedef uint64_t bn_be_t[4];

我个人推荐用小端序。为什么?因为加法、乘法都是从低位开始算,小端序的索引和运算顺序一致,代码写起来更顺。我在项目中遇到过用大端序的库,调试时看内存 dump 简直想哭——高位低位反着来,脑子得转个弯。

关键点:SM2 的 256 位素数 p = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF,用 4 个 64 位字表示时,小端序的数组是:

p[0] = 0xFFFFFFFFFFFFFFFF
p[1] = 0x00000000FFFFFFFF
p[2] = 0xFFFFFFFFFFFFFFFF
p[3] = 0xFFFFFFFEFFFFFFFF

Montgomery 模乘

模乘是 SM2 里最频繁的操作。直接算 a * b % p,需要先做 512 位乘法,再做 512 位除法取余。除法太慢了,硬件不友好。

Montgomery 模乘的核心思路是:把模数 p 映射到 R 的剩余系里,用移位代替除法。R 选 2^256,刚好是 4 个 64 位字的长度。

算法流程是这样的:

  1. 将 a、b 转换到 Montgomery 域:a' = a * R mod p
  2. 计算 t = a' * b'
  3. 计算 m = t * p' mod R(p' 是 p 对 R 的模逆)
  4. 计算 u = (t + m * p) / R
  5. 如果 u >= p,则 u -= p

嗯,这里要注意:第 3 步的 p' 是预计算的,只需要算一次。我在项目中踩过一个坑——p' 算错了,结果所有 Montgomery 运算都偏移了一个常数,排查了两天才发现。

性能技巧:CIOS(Coarsely Integrated Operand Scanning)方法是 Montgomery 模乘的高效实现。它把乘法和约减交织在一起,减少内存访问次数。我实测过,CIOS 比教科书实现快 30% 左右。

Barrett 约减

Barrett 约减是另一种避免除法的方法。它用预计算的 μ = floor(2^(2k) / p) 来近似除法,然后通过乘法和移位得到余数。

公式很简单:

q = floor((a * μ) >> 2k)
r = a - q * p
while (r >= p) r -= p

Barrett 的好处是不需要转换域,直接对普通整数做约减。但它的精度受 μ 的位数影响。我建议 μ 用 2k+1 位,这样误差能控制在 1 以内,最多减一次 p 就够了。

Montgomery 和 Barrett 怎么选?我个人的经验是:

场景 推荐方法 原因
大量连续模乘 Montgomery 一次转换,多次运算,均摊成本
零星模运算 Barrett 无需转换域,直接计算
模数固定 Montgomery 预计算 p',后续运算快
模数变化频繁 Barrett 每次重新算 μ 比重新转换域便宜

SM2 的模数 p 是固定的,所以我建议用 Montgomery。但如果你在做 SM2 的扩展运算(比如模逆),Barrett 偶尔也能派上用场。

快速模逆算法

模逆是 SM2 里最慢的运算之一。SM2 签名需要算 k^(-1) mod n,这个 n 是 256 位的椭圆曲线阶。如果直接算,一个模逆可能要几微秒,比一次点乘还慢。

常用的快速模逆算法有两种:

  • 扩展欧几里得算法(EEA):经典算法,但需要大量除法,对硬件不友好。
  • 费马小定理:a^(-1) ≡ a^(p-2) mod p,用快速幂实现。SM2 的 p 是素数,所以这个成立。

费马小定理的模逆,说白了就是做一次模幂运算。256 位的指数,用 Montgomery 模乘配合快速幂,大概需要 256 次模乘。这个速度已经可以接受了。

但我个人更推荐一种混合方法——Montgomery 模逆。它直接在 Montgomery 域里算逆,省去了进出域的转换开销。算法是这样的:

// 输入:a' (Montgomery 域), p (模数)
// 输出:a'^(-1) * R mod p
function montgomery_inv(a', p):
    u = a'
    v = p
    x1 = 1
    x2 = 0
    
    while (u != 1 && v != 1):
        if (u % 2 == 0):
            u = u / 2
            if (x1 % 2 == 0):
                x1 = x1 / 2
            else:
                x1 = (x1 + p) / 2
        else:
            v = v / 2
            if (x2 % 2 == 0):
                x2 = x2 / 2
            else:
                x2 = (x2 + p) / 2
        // 交换逻辑省略...
    return x1

避坑指南:我曾经在实现 Montgomery 模逆时,忘记处理 u 和 v 同时为奇数的情况。结果算法陷入了死循环。正确的做法是:当 u 和 v 都是奇数时,比较大小,用大的减小的,保证至少有一个能变成偶数。

知识体系总览

下面这张图把本章的核心逻辑串起来了。你一看就明白:大数表示是基础,Montgomery 和 Barrett 是两种模约减路线,模逆则依赖模乘的优化。

大数运算基础 · 知识体系 大整数表示(256位) Montgomery 模乘 Barrett 约减 快速模逆算法 💡 核心思路 Montgomery:转换到 R 剩余系,用移位代替除法,适合大量连续运算。 Barrett:预计算 μ 近似除法,适合零星运算,无需转换域。 模逆:依赖 Montgomery 模乘加速,费马小定理或二进制扩展欧几里得。

实战建议

说了这么多,落地到 SM2 优化,我给出三条建议:

  • 优先优化模乘:SM2 里 80% 的运算时间花在模乘上。把 Montgomery 模乘用 CIOS 实现,用汇编或 SIMD 指令优化,收益最大。
  • 模逆用 Montgomery 域做:SM2 签名需要算 k^(-1),直接在 Montgomery 域里算逆,省去两次域转换。我实测过,能省 15% 的时间。
  • 预计算能省则省:p'、μ、R^2 mod p 这些常数,在初始化时算好存起来。别在每次运算时重复算。

一个小技巧:如果你用 Montgomery 模乘,可以把 a 和 b 都提前转换到 Montgomery 域,然后全程在域里运算。最后需要输出时,再做一次逆转换。这样中间的所有加减乘都不需要额外约减。