本文是「国密SM9与 WebAssembly」系列第 2 篇。
第 1 篇《为什么网页端几乎看不到 SM9?我用 WebAssembly 把验签做到 8ms》讲的是端到端结果:为什么网页端缺 SM9、这个库覆盖了哪些能力、签名 / 验签 / 加密 / 密钥交换分别多快。
这一篇只做一件事:把一次 pairing 从输入点到 GT 元素的过程拆开,解释它为什么贵、贵在哪一段,以及哪些优化真正有效。文中的数字和代码都来自
sm-crypto-wasm0.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 ms | 46.8% |
finalExpOut(final exponent) | 1.314 ms | 45.0% |
pairingOut(完整 pairing) | 2.917 ms | 100% |
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),同样可缓存 |
| 解密 | 1 | e(C1, de),de 是用户私钥点 |
| KEM 封装 / 解封装 | 1 / 1 | 32 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
对应的字节布局:
| 域 | 元素大小 | 布局 |
|---|---|---|
| Fp | 32 B | 一个 256 位整数 |
| Fp2 | 64 B | a0、a1 各 32 B |
| Fp4 | 128 B | a0、a1 两个 Fp2 |
| Fp12 | 384 B | a0、a1、a2 三个 Fp4 |
整个实现用连续裸指针表示扩域元素。比如一个 Fp12 就是 384 字节,a0 在 a,a1 在 a + 128,a2 在 a + 256。这样做有三个直接好处:
- 内存布局可预测,WASM 里没有对象头和间接寻址;
- 乘法的输入输出可以原地别名,省掉大量临时对象;
- 与 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 function | 65 |
| 割线 line function | 15 |
| 稀疏 line 乘 | 82(65 + 15 + 末尾 2) |
| G2 倍点 | 65 |
| G2 点加 | 15 |
末尾 g2AddFull | 2 |
循环体长这样:
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把结果写进模块级 scratchL_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 的实测结果,不同提交的绝对耗时不能直接比较。
| 优化 | 作用段 | 机制 | 历史实测 |
|---|---|---|---|
| 扩域乘法换 Karatsuba | Fp2 / Fp4 / Fp12 | Fp2 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 交叉项 Karatsuba | final exponent | 6 次 fp4Sqr 替代 3 次 fp4Sqr + 3 次 fp4Mul | pairingOut -4.2% |
| 跳过前导零 | fp12Pow | 平方次数从固定 256 次降到实际位数 | pairing -31% |
| NAF 带符号幂 | fp12Pow | 非零位约降 1/3,逆元只算一次 | 与零分配同批,sign / verify 约 -30% |
| cyclotomic 平方 | hard part | 平方成本约减半 | 验签 -15% 量级 |
| 稀疏 line 乘 | Miller | 15 次 Fp2 乘,通用要 18 次 | 每轮固定收益 |
To 算子 + 模块级 scratch | 扩域 / point | 热路径零堆分配 | 显著,且消除 GC 抖动 |
| 固定基点 comb | G1 / 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 元素,肉眼完全看不出对错。所以验证不能靠“签名能验过”这一条,必须分层:
- GmSSL KAT 逐字节对拍:签名、加密、KEM、密钥交换都用 GmSSL 生成的固定输入 / 固定随机数向量,输出逐字节比较。锚定的是权威 C 实现的行为。
- 扩域等价性:Fp2 / Fp4 / Fp12 的乘法、平方用 BigInt 按域的定义独立展开,不照抄实现公式,再和 WASM 输出逐字节比较。这一层专门用来抓 Karatsuba 之类的改造错误。
- cyclotomic 平方自测:用 LCG 随机生成 Fp12,先做 easy part 送进子群,再比较
fp12Sqr和fp12SqrCyc是否逐字节一致。KAT 只覆盖具体消息 / 密钥组合,这个自测覆盖任意子群元素。 - 逐步诊断:
millerStepT/millerStepF可以跑到 Miller loop 的第step步,pairingTraceOut可以把中间T和fNum/fDen导出来。line function、twist 自同构、final exponent 也都有独立的诊断导出(tangentOut、lineOut、twistAutoOut、finalExpOut、millerOut)。 - 失败路径:错误 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 次,取最小值
结果:
| 调用 | min | median |
|---|---|---|
millerOut | 1.366 ms | 1.541 ms |
pairingOut | 2.917 ms | 3.098 ms |
finalExpOut(对 Miller 输出) | 1.314 ms | 1.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