从一条切线到 384 字节的 GT:拆开 SM9 的 R-ate pairing

4 阅读23分钟

本文是「国密SM9与 WebAssembly」系列第 2 篇。

第 1 篇《为什么网页端几乎看不到 SM9?我用 WebAssembly 把验签做到 8ms》讲的是端到端结果:为什么网页端缺 SM9、这个库覆盖了哪些能力、签名 / 验签 / 加密 / 密钥交换分别多快。

这一篇只做一件事:把一次 pairing 从输入点到 GT 元素的过程拆开,解释它为什么贵、贵在哪一段,以及哪些优化真正有效。文中的数字和代码都来自 sm-crypto-wasm 0.7.1 的 src/sm9/assembly/。

写在前面:先给结论

SM9 用的是 R-ate pairing。一次配对大致是:

G2 上的 Q ──┐
            ├── Miller loop(65 轮)── f ∈ Fp12 ── final exponent ── GT 元素
G1 上的 P ──┘

几个关键数字:

  • 循环参数是 a = 6t + 2,66 位;去掉最高位后,Miller loop 跑 65 轮;
  • 65 轮里,每轮做 1 次 Fp12 平方、1 次切线 line function、1 次稀疏乘、1 次 G2 倍点;
  • 去掉最高位后的 65 位里有 15 个 1,对应 15 次割线 line function 和 15 次 G2 点加;
  • 循环结束后还有 π1 / π2 两次校正,再加 2 次 line function;
  • Miller loop 只累乘分子,line function 的分母会被 final exponent 的 easy part 精确消掉;
  • final exponent 分 easy part 和 hard part,hard part 里两个最大的指数分别是 6t + 5 和 6t² + 1。

本机(Apple M4 / macOS 27 / Node v26.7.0)对 0.7.1 的 wasm 做拆分测量:

阶段最小耗时占一次 pairing
millerOut(Miller loop)1.366 ms46.8%
finalExpOut(final exponent)1.314 ms45.0%
pairingOut(完整 pairing)2.917 ms100%

Miller loop 和 final exponent 几乎各占一半。这个比例很重要:只优化其中一段,端到端最多也就接近 2 倍。很多“pairing 优化”看起来热闹,最后只提升几个百分点,原因就在这里。

一、pairing 在 SM9 里出现在哪

SM9 是标识密码(IBC)。签名、加密、KEM、密钥交换都围绕同一条双线性关系展开:

e: G1 × G2 → GT

GT 是 Fp12 的乘法子群。SM9 的很多协议步骤,本质上都是在比较两个 pairing 值,或者用 pairing 值去派生密钥。因此 pairing 不是一个“偶尔用到的底层函数”,而是主路径。

本实现的内部函数签名是 sm9Pairing(Q: G2, P: G1),把 G2 点放在第一个参数;上面的协议描述沿用 SM9 标准的 e(P1, P2) 写法。两者只是参数顺序不同,双线性性质不变。

操作pairing 次数说明
签名1(缓存命中 0)计算公开的 g = e(Ppubs, P1)
验签1(首次 2)g 按主公钥缓存;缓存 miss 时多一次
加密1(缓存命中 0)计算 g = e(P2, Ppube),同样可缓存
解密1e(C1, de),de 是用户私钥点
KEM 封装 / 解封装1 / 132 B 会话密钥,pairing 主导
密钥交换多次双方各自的计算里都有 pairing

第 1 篇提到的“公开配对值缓存”,缓存的就是这些只取决于主公钥的 e(Ppubs, P1) / e(P2, Ppube)。它们是公开值,不含用户秘密;缓存只省时间,不改变安全边界。

二、数学骨架:BN 曲线与 12 次扩域

SM9 的曲线是一条 BN 曲线。基域素数 p、曲线阶 n 和一个曲线参数 t 满足:

p = 36t⁴ + 36t³ + 24t² + 6t + 1
n = 36t⁴ + 36t³ + 18t² + 6t + 1

t = 0x600000000058F98A
p = 0xB640000002A3A6F1D603AB4FF58EC74521F2934B1A7AEEDBE56F9B27E351457D
n = 0xB640000002A3A6F1D603AB4FF58EC74449F2934B18EA8BEEE56EE19CD69ECF25

p 和 n 都是 256 位。曲线的嵌入次数是 12,所以 GT 落在 Fp12 里。

实现里的扩域塔不是常见的 1-2-6-12,而是 1-2-4-12:

Fp    = 256 位素域
Fp2   = Fp[u] / (u² + 2)          u² = -2
Fp4   = Fp2[v] / (v² - u)         v² = u
Fp12  = Fp4[w] / (w³ - v)         w³ = v,因此 w⁶ = u

对应的字节布局:

域元素大小布局
Fp32 B一个 256 位整数
Fp264 Ba0、a1 各 32 B
Fp4128 Ba0、a1 两个 Fp2
Fp12384 Ba0、a1、a2 三个 Fp4

整个实现用连续裸指针表示扩域元素。比如一个 Fp12 就是 384 字节,a0 在 a,a1 在 a + 128,a2 在 a + 256。这样做有三个直接好处:

  1. 内存布局可预测,WASM 里没有对象头和间接寻址;
  2. 乘法的输入输出可以原地别名,省掉大量临时对象;
  3. 与 GmSSL 的 fp12_* 实现逐字节对齐,方便对拍。

为什么不用更常见的 1-2-6-12?因为这条塔是 GmSSL 官方 SM9 实现的选择,line function 在 Fp4[w]/(w³ - v) 下正好呈现出“三个非零 Fp2 系数”的稀疏结构,后面的稀疏乘会直接受益。

基域 Fp 全程使用 Montgomery 表示。SM9 的 p 不是伪 Mersenne 素数,不能像 SM2 的 p 那样做 Solinas 展开;通用 Barrett 归约一次 512→256 需要约 153 次 32×32 乘法,而 CIOS Montgomery 只需要 2×64 次。项目里的实测是单次模乘约 179 ns → 98 ns,端到端提升 1.6–1.8 倍。

这里有一个容易忽略的工程约定:Fp 元素在模块内部一律是 Montgomery 域。加减、取负、除以 2 在两种表示下形式相同;乘、平方、逆、幂按 Montgomery 语义执行;只有和外部字节或常量打交道时才显式转换一次。

三、循环参数:65 轮从哪来

R-ate pairing 的 Miller loop 长度由参数 a 决定。对 BN 曲线,这个参数取:

a = 6t + 2

代入 t = 0x600000000058F98A:

a = 0x2400000000215D93E

写成二进制是 66 位:

100100000000000000000000000000000000000010000101011101100100111110

Miller loop 从 T = Q、f = 1 开始,最高位对应初始化,不进入循环。去掉最高位后,剩下 65 位:

00100000000000000000000000000000000000010000101011101100100111110

这就是 pairing.ts 里的 ABITS。它里面有 15 个 1。于是 Miller loop 的账可以提前算出来:

操作次数
Fp12 平方65
切线 line function65
割线 line function15
稀疏 line 乘82(65 + 15 + 末尾 2)
G2 倍点65
G2 点加15
末尾 g2AddFull2

循环体长这样:

export function millerLoop(Q: G2, P: G1): Fp12 {
  const T = g2Copy(Q);
  const f = fp12one();

  for (let i = 0; i < ABITS_LEN; i++) {
    fp12SqrTo(f, f);
    evalGTangentInto(T, P);
    fp12MulLineTo(f, f, L_A0, L_A1, L_A4);

    g2DoubleInto(T, T);

    if (load<i32>(ABITS_BITS + ((<usize>i) << 2)) === 1) {
      evalGLineInto(T, Q, P);
      fp12MulLineTo(f, f, L_A0, L_A1, L_A4);
      g2AddInto(T, T, Q);
    }
  }

  const Q1 = twistPi1(Q);
  const Q2 = twistNegPi2(Q);

  evalGLineInto(T, Q1, P);
  fp12MulLineTo(f, f, L_A0, L_A1, L_A4);
  g2AddFullInto(T, T, Q1);

  evalGLineInto(T, Q2, P);
  fp12MulLineTo(f, f, L_A0, L_A1, L_A4);
  g2AddFullInto(T, T, Q2);

  return f;
}

循环结束后还要做两次 π1 / π2 校正。它们把 twist 点的 Frobenius 像补回累积值里,抵消 Miller loop 截断和自同构产生的多余因子。π1 对 Fp2 坐标取共轭并把 Z 乘一个常量,π2 对 Z 乘 twistor 常量;两个常量都来自 GmSSL 的 SM9 实现。

ABITS 在模块初始化时被展开成一张位图,热循环里用下标访问代替逐字符 charCodeAt。这是一个很小但很典型的优化:字符串本身只读一次,循环里不应该再做字符解码。

四、line function:一条切线如何变成三个 Fp2 系数

Miller loop 的核心是 line function。先看几何意义:

  • T 是 G2 上的当前点,用 Jacobian 坐标 (X : Y : Z) 表示;
  • P 是 G1 上的点,仿射坐标 (xP, yP);
  • 切线 l_{T,T} 是曲线在 T 处的切线;
  • 割线 l_{T,Q} 是过 T 和 Q 的直线。

配对里的 line function 不是“把直线方程写出来再求值”,而是把它在 P 点的取值直接算成一个 Fp12 元素。对本实现使用的塔,这个元素有非常固定的稀疏结构:

num = a0 + a1·v + a4·w²
den = b1·v

其中 a0、a1、a4、b1 都是 Fp2。也就是说,分子的 384 字节里只有三个 Fp2 系数非零,分母更是只有一个非零系数。

切线版本的计算在 evalGTangentInto 里:

fp2SqrTo(LE0, ZP);              // t0 = Z²
fp2MulTo(LE1, LE0, ZP);         // t1 = Z³
fp2MulTo(LE2, LE1, YP);         // b1v = Z³·Y
fp2MulFpTo(L_A1, LE2, yQ);
fp2NegTo(L_A1, L_A1);           // a1 = -(Z³·Y·yQ)

fp2SqrTo(LE1, XP);              // t1 = X²
fp2MulTo(LE0, LE0, LE1);        // t0 = Z²·X²
fp2MulFpTo(LE0, LE0, xQ);
fp2TriTo(LE2, LE0);
fp2Div2To(L_A4, LE2);           // a4 = 3·Z²·X²·xQ / 2

fp2MulTo(LE1, LE1, XP);         // t1 = X³
fp2TriTo(LE2, LE1);
fp2Div2To(LE1, LE2);            // t1 = 3·X³ / 2
fp2SqrTo(LE0, YP);
fp2SubTo(L_A0, LE0, LE1);       // a0 = Y² - 3·X³ / 2

写成公式就是:

a0 = Y² - (3/2)·X³
a1 = -(Z³·Y)·yP
a4 = (3/2)·(Z²·X²)·xP
den = (Z³·Y)·v

割线版本 evalGLineInto 的项更多,但结构完全一样:输入当前 G2 点 T、另一个 G2 点(原始 Q 或它的 Frobenius 像)的 Jacobian 坐标,以及 G1 点的仿射坐标,输出仍然只有 a0、a1、a4 三个非零 Fp2 系数。

这里有一个关键点:line function 的分母始终是 b1v · v 的形状,b1v ∈ Fp2。后面的“只累乘分子”就是建立在这个不变量上的。

五、Miller loop:为什么可以只累乘分子

如果按照教科书写法,Miller loop 要同时维护分子和分母:

f = f² · num / den

GmSSL 的实现也是这样做的。但本实现只维护 f 的分子,理由是一个精确的代数恒等式。

final exponent 的 easy part 是:

f ← f^(p⁶ - 1)
f ← f^(p² + 1)

先看分母 d = b1v · v 经过 easy part 会变成什么。

v ∈ Fp4,而 Fp4 的 Frobenius² 就是共轭。因为 v² = u 且 u 在 Fp2 里,v^(p²) 只能是 v 或 -v;v 不属于 Fp2,所以:

v^(p²) = -v
v^(p² - 1) = -1

对任意 b1v ∈ Fp2,b1v^(p² - 1) = 1。于是:

d^(p² - 1) = (b1v · v)^(p² - 1) = 1 · (-1) = -1

再看 p⁶。对 x ∈ Fp4*,x^(p⁴) = x,所以 p⁶ 在指数上等价于 p²:

p⁶ - p² = p²(p⁴ - 1)

因此 d^(p⁶ - 1) = d^(p² - 1) = -1。最后再取 p² + 1 次幂:

d^((p⁶ - 1)(p² + 1)) = (-1)^(p² + 1) = 1

p 是奇数,p² + 1 是偶数,所以 -1 的偶数次幂就是 1。分母被 easy part 完整消掉,不是近似,也不是忽略。

这就是 6 次 twist 曲线的分母消除。GmSSL 保留分子 / 分母两路;本实现省掉分母后:

  • 每轮少一次 Fp12 平方和一次 Fp12 乘法;
  • 循环结束后少一次 Fp12 求逆;
  • 实测 millerOut 从 57.3 ms 降到 34.4 ms(-40%),完整 pairingOut 从 98.9 ms 降到 75.4 ms(-24%)。

注意这里的百分比来自项目历史上的同进程 A/B,不同提交的绝对耗时不能横向比较。

5.1 稀疏乘:15 次 Fp2 乘法

line function 的分子只有三个非零 Fp2 系数。如果把它补成一个完整的 Fp12 再调用通用乘法,就会浪费大量零。fp12MulLineTo 直接按稀疏结构展开:

L0 = a0 + a1·v
L1 = 0
L2 = a4

r0 = f0·L0 + v·(f1·L2)
r1 = f1·L0 + v·(f2·L2)
r2 = f2·L0 + f0·L2

其中 f_i·L0 是稠密 × 稠密(3 次 Fp2 乘法),f_i·L2 是稠密 × 稀疏(2 次 Fp2 乘法),总共 15 次,而通用 Fp12 乘法需要 18 次。更重要的是,调用方不再需要为了喂给乘法而构造一个 384 字节的 Fp12。

5.2 零分配:热循环里不碰堆

Miller loop 里每轮都会产生大量中间值。早期实现每轮分配十几个 Fp2,GC 和内存增长会把 pairing 拖慢。现在的做法是:

  • evalGLineInto / evalGTangentInto 把结果写进模块级 scratch L_A0、L_A1、L_A4;
  • line function 的内部工作区是 LE0..LE4;
  • Fp2 / Fp4 / Fp12 的算子都拆成 *To(out, ...) 原地版本;
  • millerLoop 用 fp12SqrTo(f, f) 和 fp12MulLineTo(f, f, ...),整个循环零分配。

这些 scratch 是非重入的:To 算子内部只调用下一层的 To 算子,不会回调同层,因此一次调用期间可以独占。这个约束必须写进注释,否则以后有人在 To 算子之间插入回调,就会出现很难查的别名错误。

顺便说一个真实的 bug:fp2TriTo 最早写成先 fp2AddTo(out, a, a) 再 fp2AddTo(out, out, a)。当 out === a 时,第二次加法读到的是已经被覆盖的 a,结果是 4a 而不是 3a。这个问题在 Fp2 / Fp4 层没有触发,直到 point 层以别名方式调用 TriTo 才暴露。别名安全不能靠“看起来没问题”,每个 To 函数都要么显式支持别名,要么在注释里禁止。

六、final exponent:easy part 与 hard part

Miller loop 的输出 f 还在 Fp12 的一般位置,需要做最终幂:

f ← f^((p⁴ - p² + 1) / n)

指数很大,直接做 256 位幂不现实。标准做法是拆成两段:

6.1 easy part

f ← f^(p⁶ - 1)
f ← f^(p² + 1)

代码里对应:

let t0 = fp12Frobenius6(f);
const t1 = fp12Inv(f);
t0 = fp12Mul(t0, t1);                                  // f^(p⁶ - 1)
const t1b = fp12Frobenius2(t0, ALPHA2_U, ALPHA4_U);
t0 = fp12Mul(t0, t1b);                                 // f^(p² + 1)
return finalExponentHardPart(t0);

easy part 的作用是把元素送入 G_{Φ6(p²)} 子群。这个子群里的元素满足:

a^(p⁴) · a = a^(p²)

也就是后面 cyclotomic 平方的前提。

Frobenius 映射不是真的做 256 位幂,而是利用扩域结构把每个分量乘上预计算的系数。ALPHA1..5 就是这些 Frobenius 系数。

6.2 hard part

hard part 的指数是:

(p⁴ - p² + 1) / n

直接展开成加法链并不直观,工程实现通常用曲线参数 t 拆成几个较小的幂。本实现的两个大指数常量是:

FE_A3 = 0x2400000000215D941 = 6t + 5
FE_A2 = 0xD8000000019062ED0000B98B0CB27659 = 6t² + 1

注意 FE_A3 不是 6t + 2。Miller loop 用 6t + 2,hard part 用 6t + 5;这两个参数容易混。FE_A2 也不是 6t² + 2,而是 6t² + 1。常量抄错不会让代码崩溃,只会让配对值错得“看起来仍然像随机数”,所以必须和参考实现对拍。

hard part 的代码如下(省略了注释):

let t0 = fp12Pow(f, a3);
t0 = fp12Inv(t0);
let t1 = fp12Frobenius(t0, ALPHAS);
t1 = fp12Mul(t0, t1);

t0 = fp12Mul(t0, t1);
let t2 = fp12Frobenius(f, ALPHAS);
let t3 = fp12Mul(t2, f);
t3 = fp12PowSmall(t3, 9);

t0 = fp12Mul(t0, t3);
t3 = fp12PowSmall(f, 4);
t0 = fp12Mul(t0, t3);
t2 = fp12Sqr(t2);
t2 = fp12Mul(t2, t1);
t1 = fp12Frobenius2(f, ALPHA2_U, ALPHA4_U);
t1 = fp12Mul(t1, t2);

t2 = fp12Pow(t1, a2);
t0 = fp12Mul(t2, t0);
t1 = fp12Frobenius3(f, beta);
t1 = fp12Mul(t1, t0);
return t1;

这里有两个优化点。

6.3 NAF:把乘法次数降三分之一

fp12Pow 处理的是 256 位指数。最朴素的 square-and-multiply 会从最高位开始,每一位做一次平方,遇到 1 再做一次乘法。随机标量的 popcount 大约是 128,也就是约 128 次乘法和 256 次平方。

本实现把指数先转成 NAF(非邻接形式),数字只取 {0, ±1},不允许两个非零位相邻。NAF 的平均非零密度是 1/3,所以:

  • 随机 256 位标量:非零位约 85 个,乘法次数约降 1/3;
  • hard part 的 a3:66 位,popcount 17 → NAF 权重 11;
  • hard part 的 a2:128 位,popcount 40 → NAF 权重 34。

NAF 里的 -1 需要乘 a^{-1}。逆元只算一次存进 INV_HOLD,循环全程原地。fp12Inv 的成本是一次 Fp4 逆加若干乘法,比为了消掉一个 -1 而多做的 8 次 fp12Mul 便宜,所以净收益为正。

还有一个很容易被忽略的优化:跳过前导零。早期 fp12Pow 无条件跑满 256 次平方,即使指数只有 66 位。k = 1 也要 38.8 ms,a3 要 42 ms,a2 要 48 ms;改成先找最高有效位之后,k = 1 只需 10.5 µs,a3 12.0 ms,a2 26.9 ms。这一项让 pairing 整体下降约 31%。

6.4 cyclotomic 平方:hard part 的平方成本减半

easy part 之后,元素已经在 G_{Φ6(p²)} 子群里。这个子群有一个特殊关系:

a^(p⁴) · a = a^(p²)

把它逐分量展开,再代入通用平方公式,可以化简出子群专用的平方:

r0 = 3·g0² - 2·conj(g0)
r1 = 3·g0·g1 - conj(g1)
r2 = 3·g1² - 2·conj(g2)

其中 conj 是 Fp4 共轭,几乎免费。代价是 2 次 fp4Sqr + 1 次 fp4Mul,约 9 个 Fp2 乘 / 平方;通用 Fp12 平方是 6 次 fp4Sqr,约 18 个 Fp2 乘 / 平方。平方成本接近减半。

这个优化有一个硬前提:输入必须在子群里。fp12Pow 的所有调用点都满足:

  • hard part 的中间值来自 easy part 之后;
  • 所有 pairing 值本身就在 GT 里;
  • 子群对乘法、平方、Frobenius 封闭,所以循环过程中不会跑出去。

如果谁把 fp12Pow 用在任意 Fp12 元素上,结果会错,而且错得很隐蔽。代码注释里明确写了这个前提,测试里也有 sqrCycSelfTest 用随机子群元素做逐字节对拍。

6.5 常量也有“域语义”

这一节值得单独提醒。pairing.ts 里有两类常量:

  • ALPHA1..5、PI1_C、PI2_C:Fp 系数,参与 fp*MulFp,必须转成 Montgomery 表示;
  • FE_A2、FE_A3:幂指数,喂给 fp12Pow,必须保持标准域整数。

如果把指数也转成 Montgomery 表示,幂次会整条错掉;反过来,如果把 Frobenius 系数留在标准域,乘法会错。两者在代码里都是十六进制字符串常量,长度和用途却不同,所以这里专门用注释和不同的变量名区分。

七、优化账本:哪些真的有用

下面这些优化都来自项目的提交历史。数字是各自提交里同进程 A/B 的实测结果,不同提交的绝对耗时不能直接比较。

优化作用段机制历史实测
扩域乘法换 KaratsubaFp2 / Fp4 / Fp12Fp2 4→3、Fp4 4→3、Fp12 9→6 次下层乘法pairing 约 2×
缓存 p / n 常量Fp不再每次 fpAdd / fpSub 重建 U256配合上一项
Miller loop 只累乘分子Miller分母被 easy part 消掉millerOut -40%,pairingOut -24%
fp12Sqr 交叉项 Karatsubafinal exponent6 次 fp4Sqr 替代 3 次 fp4Sqr + 3 次 fp4MulpairingOut -4.2%
跳过前导零fp12Pow平方次数从固定 256 次降到实际位数pairing -31%
NAF 带符号幂fp12Pow非零位约降 1/3,逆元只算一次与零分配同批,sign / verify 约 -30%
cyclotomic 平方hard part平方成本约减半验签 -15% 量级
稀疏 line 乘Miller15 次 Fp2 乘,通用要 18 次每轮固定收益
To 算子 + 模块级 scratch扩域 / point热路径零堆分配显著,且消除 GC 抖动
固定基点 combG1 / G2只对固定基点有效标量乘 -50% 以上,端到端 6–13%

也有一个“看起来应该有用、实际上没用”的例子:给 fpSqr 补 32×32 部分积特化。单项 micro-bench 确实快了 1.19×,但 pairing 的热路径平方走的是 fp12Sqr / fp4Sqr / fp2Sqr,最终都落到 fpMul,根本不经过 fpSqr。端到端同进程 A/B 的 pairing 反而在噪声内略慢,最后回退了。

这个教训很直接:micro-bench 的单点加速不必然传递到工作负载。先确认热路径调用了哪个函数,再决定优化谁。

八、正确性怎么保证

pairing 的输出是一个 384 字节的 Fp12 元素,肉眼完全看不出对错。所以验证不能靠“签名能验过”这一条,必须分层:

  1. GmSSL KAT 逐字节对拍:签名、加密、KEM、密钥交换都用 GmSSL 生成的固定输入 / 固定随机数向量,输出逐字节比较。锚定的是权威 C 实现的行为。
  2. 扩域等价性:Fp2 / Fp4 / Fp12 的乘法、平方用 BigInt 按域的定义独立展开,不照抄实现公式,再和 WASM 输出逐字节比较。这一层专门用来抓 Karatsuba 之类的改造错误。
  3. cyclotomic 平方自测:用 LCG 随机生成 Fp12,先做 easy part 送进子群,再比较 fp12Sqr 和 fp12SqrCyc 是否逐字节一致。KAT 只覆盖具体消息 / 密钥组合,这个自测覆盖任意子群元素。
  4. 逐步诊断:millerStepT / millerStepF 可以跑到 Miller loop 的第 step 步,pairingTraceOut 可以把中间 T 和 fNum / fDen 导出来。line function、twist 自同构、final exponent 也都有独立的诊断导出(tangentOut、lineOut、twistAutoOut、finalExpOut、millerOut)。
  5. 失败路径:错误 ID、错误主私钥、篡改密文 / 确认值、密钥范围越界、PEM OID 不匹配,都要有测试。

这些诊断导出最初就是为了定位 pairing 问题加的。pairing 出错时,最有效的办法不是盯着最终结果,而是把 Miller loop 的中间状态和 GmSSL 的对应步骤逐步对齐。

九、性能是怎么测的

SM9 的绝对值比 SM2 / SM3 更容易骗人。同一台机器上,跨进程的波动可以达到 4–11 倍:同一个 g2MulGOut,两次运行分别测出 98 µs 和 425 µs。拿两次独立运行的绝对值做前后对比,结论基本等于抛硬币。

所以所有优化数字都采用:同进程、同一时段、交替 A/B、各取 min。两个版本装进同一个进程,轮流跑,输入由各自实例生成,并逐字节比对输出。

本文开头的拆分数据来自本机的临时探针:

机器:Apple M4,macOS 27.0.1
Node:v26.7.0
包:sm-crypto-wasm 0.7.1
方法:固定 Q / P,预热 2 次,计时 25 次,取最小值

结果:

调用minmedian
millerOut1.366 ms1.541 ms
pairingOut2.917 ms3.098 ms
finalExpOut(对 Miller 输出)1.314 ms1.424 ms

millerOut + finalExpOut 两次调用合计 2.838 ms,和一次 pairingOut 的 2.917 ms 基本一致,说明序列化和调用开销在毫秒尺度上可以忽略。

这也解释了为什么第 1 篇的 x86 数字(验签 8.00 ms)和本文的 Apple M4 数字(pairing 2.92 ms)不能直接比较:机型、Node 版本、编译产物、缓存状态都不同。看优化收益要看同进程比例,看绝对性能要标注完整环境。

十、还能往哪优化

pairing 的时间大致被 Miller loop 和 final exponent 平分,所以下一步的收益也分两半看:

  • Miller loop:65 轮的结构性成本很难再大幅下降。可以做的是更少的 Fp2 乘法、更好的点运算公式、把 line function 的中间量复用得更彻底。收益是百分比级。
  • final exponent:hard part 的平方已经用了 cyclotomic 专用公式,剩下的是加法链和乘法次数。把 6t + 5 / 6t² + 1 的链再缩短,或者用多核并行,是少数还有空间的方向。
  • 架构级:WASM threads + SharedArrayBuffer 可以把独立的 Miller loop 或批量求逆切到多核,但需要 COOP/COEP 响应头和 Worker 加载改造,复杂度和部署成本都远大于域运算优化。

还有一个不适合优化的方向:不要把 pairing 结果当秘密来缓存。项目里缓存的 e(Ppubs, P1) / e(P2, Ppube) 只取决于主公钥,是公开值;用户私钥相关的 pairing 不应该进入任何全局缓存。这个边界比省几毫秒重要。

十一、总结

把一次 SM9 pairing 拆开,可以看到它并不神秘:

  • 它是一个 65 轮的 Miller loop,每轮一条切线、一次稀疏乘、一次倍点;
  • 15 个非零位额外做割线;
  • line function 的分母有固定形状,被 final exponent 的 easy part 精确消掉;
  • final exponent 先做 easy part 进入子群,再用 NAF + cyclotomic 平方完成 hard part;
  • 真正的性能收益来自几处结构性优化:Karatsuba、稀疏乘、分母消除、NAF、子群平方、零分配。

如果只记住一件事:pairing 的贵不是因为某一行代码慢,而是因为它在 65 轮里重复了几十次 Fp12 乘法和平方。 优化必须作用在这些重复发生的地方,并且用同进程 A/B 证明它真的传递到了端到端。


复现

pnpm install
pnpm build
pnpm build:wasm:all   # 只改了 AssemblyScript 时
pnpm test:unit
pnpm bench

pairing 相关的诊断导出在 src/sm9/assembly/index.ts:millerOut、finalExpOut、pairingOut、tangentOut、lineOut、twistAutoOut、pairingTraceOut。本文的拆分探针是一个未提交的临时脚本,直接加载 dist/sm9/sm9.wasm,通过 alloc 写入 Q / P 后分别调用这些导出。

参考

  • GM/T 0044-2016《SM9 标识密码算法》
  • GmSSL src/sm9_*.c 与 tests/sm9test.c
  • 项目源码:src/sm9/assembly/fp.ts、fp2.ts、fp4.ts、fp12.ts、point.ts、pairing.ts
  • 项目测试:tests/sm9/cyc-sqr.spec.ts、field-equiv.spec.ts、sm9-kem-exch-kat.spec.ts