SM2 从 252 µs 到 24 µs:固定公钥、comb 表与签名点池

3 阅读23分钟

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

第 1 篇《国密算法跑进 WebAssembly》讲的是全景:为什么前端做国密会撞上性能墙、五个算法各自的瓶颈、跨 Node / 浏览器 / 小程序的工程代价。

这一篇只拆一条曲线:SM2。一次 64 字节签名,怎么从 252 µs 走到 24 µs;哪些改动真的贡献了时间,哪些只是把成本搬了个位置;以及为什么“快了 13 倍”和“吞吐没变”可以同时成立。

写在前面:先给结论

SM2 的性能优化可以拆成四个互不重叠的动作:

计算层     k·G 从 255 次倍点降到 51 次倍点(comb 表)
门面层     签名不再每次重复派生公钥(私钥 → 公钥缓存)
加密层     对端公钥固定时,k·P 也走 comb 表(preparePublicKey)
调用层     把 k·G 挪出签名关键路径(签名点池)

第 2、4 步作用在签名上:一次 64 字节签名的延迟从 252 µs 降到 24 µs。这是立项时在 AMD Ryzen 7 7800X3D / Node 24.15 / Windows 11 上记录的数字。

同一份代码(0.7.1)换到本机 Apple M4 / macOS / Node v26.7.0 复测,对应的是 193 µs → 7.3 µs:

阶段Ryzen 7 7800X3D / Node 24Apple M4 / Node 26
初始(无缓存、无点池)252 µs193 µs
公钥派生缓存上线138 µs113 µs
签名点池命中24 µs7.3 µs
相对初始10.5×26×

绝对值和倍数都不同,但省下来的东西是同一件:一次固定基点标量乘,加上门面层的随机数与编解码。k·G 本身在两台机器上都是 80–90 µs 量级,是硬成本;门面层的开销随 Node 版本变化,所以快机器上点池的“倍数”更大。

本文所有数字都标注来源:

  • 本机:Apple M4 / macOS / Node v26.7.0 / sm-crypto-wasm 0.7.1,pnpm build 后用临时 probe 跑(min-of-5,或同进程交替 A/B 的中位数);
  • 历史:0.7.1 / AMD Ryzen 7 7800X3D / Node 24.15 / Windows 11,pnpm bench 或仓库里的实验记录。

SM2 的绝对值在不同进程之间能差 10% 以上,跨机器更不可比。只有同进程、同时段交替 A/B 的比例可信,这一点第七节会专门讲。

先声明:这是一个算法实现库,不是经过商用密码产品认证的合规产品。生产环境怎么用、要不要过测评,请按你所在行业的要求来。


一、先把一次签名拆开:时间到底花在哪

SM2 签名(GM/T 0003.2)的核心只有几步:

e  = SM3(Z ‖ M)                       Z 由私钥和公钥一起算出来
k  ∈ [1, n-1]                         随机数,由宿主 CSPRNG 提供
(x1, y1) = k·G                        固定基点标量乘 ← 主项
r  = (e + x1) mod n
s  = ((1+d)⁻¹ · (k − r·d)) mod n
签名 = r ‖ s

纸面上 k·G 只是其中一行。实际耗时里,它是最大的一行。

本机把每一步单独量出来(64 字节消息;除标注中位数的两行外为 min-of-5):

层操作耗时说明
计算层scalarMulGOut(k·G,真实标量)89.1 µscomb,51 次倍点 + 至多 51 次点加
计算层signWithPointOut(k·G 由外部给)5.35 µs只剩 SM3(Z‖M) 与少量模 n 运算
计算层signOut(自己算 k·G)93.6 µs(交替 A/B 中位数;min 80.3)5.35 + 约 88
门面层sign(公钥缓存命中)112.7 µs再加随机数、编解码、分配
门面层sign(公钥缓存未命中)193.1 µs再加一次公钥派生(89 µs)
门面层getPublicKey89.1 µs就是一次 k·G + 一次求逆

(signOut 的中位数来自交替 A/B,与 min-of-5 的列不混用;绝对值差 10% 属正常漂移。)

这张表解释了三件事。

第一,计算层已经很薄。 signWithPointOut 只有 5.35 µs:一次 SM3(Z‖M)、三次模加、一次模减、两次模乘、一次模逆。SM2 签名的数学部分就这么多,剩下的 90% 是那次 k·G。

第二,门面层不是“薄薄一层”。 sign 比 signOut 多出约 15 µs(同一口径的交替 A/B:108.2 对 93.6):随机 k 的生成与转换、私钥/消息/签名的编解码、7 次 wasm 内存分配、公钥缓存查找。单看每一项都不大,加起来是签名计算层的两成。

第三,也是最有意思的:公钥派生被重复做了。 sign 需要公钥来算 Z 值,而公钥只由私钥决定。第一版每次都重新 getPublicKey(d),再把它转回字节——等于每次签名白付一次 89 µs 的 k·G。这就是第三节要修的东西。

计算层的九件事

在动门面层之前,计算层已经做过九轮优化。它们不是本文重点,但需要知道边界在哪:

优化作用
Solinas 归约SM2 的 p 是伪 Mersenne,512→256 归约只用加减移位
Comba 平方32×32 部分积一次列扫描 + 统一进位
comb 标量乘固定基点 k·G 用 32 项表
wNAF 标量乘任意点 k·P / t·P,宽度 5
联合标量乘验签的 s·G + t·P 共享一条倍点链
混合坐标点加表项转仿射后走 8M+3S,而非 12M+4S
批量求逆32 项表一次 Montgomery 批量归一化
字级求逆二进制扩展欧几里得,状态放 wasm 局部 4×u64
点运算原地化*Into 算子 + 模块级工作区,循环内零分配

其中对 SM2 影响最直接的是 Solinas。SM2 的素域

p = 2²⁵⁶ − 2²²⁴ − 2⁹⁶ + 2⁶⁴ − 1

是伪 Mersenne 形状,于是

2²⁵⁶ ≡ 2²²⁴ + 2⁹⁶ − 2⁶⁴ + 1  (mod p)

512 位乘积里每个位置 ≥ 8 的 32-bit 字,都可以按

2³²ⁱ ≡ 2³²⁽ⁱ⁻¹⁾ + 2³²⁽ⁱ⁻⁵⁾ − 2³²⁽ⁱ⁻⁶⁾ + 2³²⁽ⁱ⁻⁸⁾

折叠回低位。全程只有加减和移位,没有乘法。通用 Barrett 一次归约要付 q1·μ(9×9)加 q3·m(9×8)约 153 次 32×32 乘法;Solinas 是零次。一次标量乘要做几千次模乘,归约正是大头。

但要注意:只有 SM2 的 p 是这个形状。SM2 的 n、SM9 的 p/n 都还是 Barrett,这也是第 1 篇里 SM9 换 Montgomery 域能拿到 1.8× 的原因。


二、k·G 的三种算法,以及它们各自的边界

同样是标量乘,本库里有三条路径。选哪条不取决于“哪个更先进”,而取决于点是否固定、标量有几个。

2.1 逐位双倍-点加:基线

最直白的做法:从最高位开始,每轮一次倍点,遇到 1 再加一次基点。256 位标量约 255 次倍点 + 128 次点加。这是所有优化的参照系。

2.2 comb:专治“点固定”

基点 G 是常量,可以预先做表。comb 的思路是把标量的位重新排布:

k·G = Σ_{j=0}^{d−1} 2^j · G[c_j]
c_j = Σ_{t=0}^{w−1} b_{j + t·d} · 2^t
G[c] = Σ_{t: c_t = 1} (2^d)^t · G

取 w = 5、d = ceil(256/5) = 52,表只有 2⁵ = 32 项。一次标量乘从 255 次倍点降到 51 次倍点 + 至多 51 次点加。

export function scalarMulG(k: U256): JPoint {
  const G = combTable();
  const Q = clonePoint(G[combIndex(k, COMB_D - 1)]);
  for (let j = COMB_D - 2; j >= 0; j--) {
    doubleInto(Q, Q);
    const c = combIndex(k, j);
    if (c !== 0) addMixedInto(Q, Q, G[c]);   // 表项是仿射点
  }
  return Q;
}

两个细节让 89 µs 这个数字成立:

  • 表项转仿射。构造表时用一次 Montgomery 批量求逆(32 项逐点求逆要约 400 µs,批量只要约 25 µs),之后每轮点加可以走混合坐标 madd-2007-bl,8M+3S 而不是 12M+4S;
  • 表深拷贝进永久区。wasm 侧的 arena 会在每次导出调用结束时回卷,缓存表如果留在 arena 里,下一次调用就被覆盖了。

构造表本身约 (w−1)·d = 208 次倍点,再加 31 次点加,一次性成本在 250–350 µs 之间(本机 preparePublicKey 实测 345 µs,其中还包含一次公钥合法性校验和槽位拷贝)。

2.3 wNAF:任意点,每次都得现算

对端公钥 P 每次都不同,没法预计算整张表。宽度 5 的 wNAF 把非零密度从 1/2 降到约 1/6:仍然 256 次倍点,但点加从约 128 次降到约 43 次。

本机 scalarMulOut(k·P,wNAF)261 µs,对比 k·G 的 89 µs——差距几乎全在倍点次数上(256 对 51)。

2.4 验签的联合标量乘:省的是倍点链

验签要算 s·G + t·P。直觉做法是“comb 算 s·G,wNAF 算 t·P,再相加”,代价是 51 + 256 = 307 次倍点。

更好的做法是让两条 wNAF 共享同一条倍点链:每轮只做一次倍点,然后按各自的 digit 各加一次。倍点数降到 256,代价是 s 侧要多做约 43 次点加(G 的 wNAF 表),而点加远比倍点便宜。

G 的 wNAF 表是 G, 3G, …, 15G 加它们的负,共 16 项,同样转仿射、缓存在永久区。P 侧的表每次现算。

实测 verifyOut 321.5 µs,与一次 wNAF k·P 的 261 µs 同量级——说明验签的时间基本就是一次 256 次倍点的联合标量乘,加上一次求逆和一次 SM3。

2.5 一个反例:小标量会骗你

这是我在写这篇文章时复测发现的一个陷阱,值得单独说。

scalarMulGOut 用真实私钥(0x3945…)是 89.1 µs;换成一个“看起来很长”的小标量 0x1111…1111,只要 48.3 µs。同一个函数,同一位宽,差 1.8 倍。

原因是 comb 的初值取 G[combIndex(k, 51)],这个索引不是“最高 5 位”,而是跨步采样第 51、103、155、207 位(第 5 个 259 越界,按 0 处理)。0x1111… 的 1 只落在位号 ≡ 0 或 4 (mod 8) 的位置,这五位恰好全是 0,于是初始 Q 是无穷远;而 52 与 8 同余 4,索引是否为零基本只取决于 j mod 4:j ≡ 0 时非零(多在 15 到 31 之间),其余为 0。于是 51 轮里约四分之三直接跳过点加,前几轮还在无穷远上做倍点。真实私钥不会给你这个便宜。

这解释了两件事:

  • 为什么 benchmark 里的固定标量必须用真实随机值,不能用 0x1111… 这种好写的数;
  • 为什么 signOut 在本机看起来只要 53.8 µs,而用真实私钥是 93.6 µs。53.8 那个数字是 k = 0x1111… 测出来的,不能拿去和门面层的 113 µs 做减法。

benchmark 的标量本身就是负载的一部分。 第 1 篇提到过 comb 对小标量有固定开销;这里补上另一半:小标量还可能把倍点链变短。


三、门面层:一次签名里 41% 是重复劳动

3.1 问题

sign 需要 Z 值:

Z = SM3(ENTL ‖ ID ‖ a ‖ b ‖ xG ‖ yG ‖ xA ‖ yA)

其中 xA ‖ yA 是公钥。而公钥只由私钥决定。第一版每次签名都走一遍 getPublicKey(d),再把 128 个 hex 字符解析回 64 字节:

签名 → 派生公钥(89 µs)→ 公钥 hex → 公钥字节 → 算 Z → 算 r/s

在 Ryzen 那台机器上,getPublicKey 105 µs、sign 252 µs,也就是 42% 的签名时间花在一个每次都能从缓存拿到的量上。本机 k·G 占一次未命中签名的 89 / 193 = 46%,实测省下 80 µs、41%(差额来自命中路径顺带省掉的 hex 往返)。

3.2 改法

src/sm2/index.ts 里加一个模块级 Map,键是私钥的 hex,值是 64 字节公钥:

const PUB_CACHE = new Map<string, Uint8Array>();

function derivePublicKeyBytes(d: Uint8Array): Uint8Array {
  if (!cacheEnabled) return derivePublicKeyRaw(d);
  const key = bytesToHex(d);
  const hit = PUB_CACHE.get(key);
  if (hit !== undefined) return hit;
  const pub = derivePublicKeyRaw(d);
  PUB_CACHE.set(key, pub);
  evictPubCache();
  return pub;
}

sign 改走 derivePublicKeyBytes,顺带省掉 hex → 字节 的往返。公开的 getPublicKey 仍然每次真算——“派生公钥”这个操作的语义不该被缓存悄悄改掉,bench 里这一项也要保持可测。

3.3 实测

同进程 A/B:命中用固定私钥,未命中轮换 40 把不同私钥(缓存上限 32,间隔 40 必然被淘汰)。两组唯一差异就是是否要做那次 k·G。

环境命中未命中省下
Ryzen 7 7800X3D / Node 24138 µs234 µs96 µs / 41%
Apple M4 / Node 26113 µs193 µs80 µs / 41%

两代机器的比例都是 41%,因为分子分母对应的是同一次固定基点标量乘。

3.4 取舍:这是一个真实的安全语义变化

缓存键是私钥的 hex——意味着私钥的 hex 形态和它的派生结果会驻留进程内存。原先“每次重算”是无状态的,现在不是了。

当前实现的处理:

  • 默认开启,上限 32 条,满时按插入顺序 FIFO 淘汰(不做 LRU:密钥集合通常很小,不需要命中统计);
  • 提供 configurePublicKeyCache({ enabled: false }) 回到无状态行为,以及 clearPublicKeyCache() 立即清空;
  • 只服务内部派生路径,不改变公开 API 的语义。

如果你的威胁模型不接受“私钥派生结果常驻内存”,就关掉它——代价是每次签名多 89 µs。这类开关的意义不是“默认值对不对”,而是让调用方能明确表达自己的安全边界。


四、固定公钥:把 comb 表推广到任意点

4.1 动机

encrypt 要做两次标量乘:

C1 = k·G            固定基点,comb,51 次倍点
(x2, y2) = k·P      对端公钥,wNAF,256 次倍点

在底层 encryptOut 里,k·P 占了约 73%。而对端公钥在“同一接收方反复加密”的场景里是固定的——小程序反复给同一个服务端上报、客户端反复给同一个网关发请求。

既然 comb 只要求“点是固定的”,没理由只给 G 用。

4.2 实现

把 buildCombFor(p) 抽出来,任意点都能构表:

表 32 项
构建:S[t] = 2^(52t)·P(t = 0..4),共 208 次倍点;再 31 次点加填满 G[1..31]
使用:k·P = 51 次倍点 + 至多 51 次点加

工程上的难点不是数学,是内存。

  • 固定 8 个槽,所有槽落在永久区一块固定的 8 × 32 × 96 = 24576 字节里。prepare 只覆盖槽位,不追加分配——否则反复换公钥会把 1 MiB 永久区吃光;
  • 每槽存 64 字节公钥指纹(x ‖ y),encrypt 每次线性扫描最多 8 槽;
  • 满了按 LRU 淘汰(SLOT_TICK 最小者);
  • 不命中直接回退 wNAF,绝不隐式建表。这一点很关键:公钥是外部输入,如果 encrypt 会自动为陌生公钥建表,攻击者就能用不同公钥反复触发 345 µs 的建表,形成一个放大面;
  • 显式入口 preparePublicKey(pub),进表前先做一次 isOnCurve 校验。
await sm2.preparePublicKey(publicKeyPem);   // 一次性建表
const ct = sm2.encrypt(publicKeyPem, '616263');

4.3 实测

指标Ryzen / Node 24Apple M4 / Node 26
底层 encryptOut,wNAF → comb423.6 → 201.4 µs(2.10×)—
端到端 encrypt 64 B486 → 260 µs(1.86×)395 → 209 µs(1.89×)
单次 k·P309.4 → 约 85 µs261 → —
建表(一次性)280 µs345 µs
回本点约 1.2 次加密约 1.8 次加密
8 把公钥轮换278.9 µs(与单把命中同档)—

密文逐字节一致:同一个 k 下,wNAF、comb、旧版 dist 三方输出相同。

本机的回本点比 Ryzen 高,因为建表贵了(345 对 280)、每次省下的少一点(186 对 226)。但这仍然意味着“加密两次就回本”,对固定对端的场景几乎没有门槛。

4.4 和 noble 的 precompute 不是一回事

sm-crypto-v2(noble 系)也有 precomputePublicKey(pk, 8)。它构建的是 256 项 BigInt 表,耗时 29 ms;而且 wNAF 的倍点数不随窗口减少,仍然是 256 次——它省的是“每次调用重建 255 次点加”,预计算后 encrypt 约 425 µs。

本实现直接砍倍点(256 → 51),建表便宜约 100 倍,预热后 encrypt 209–260 µs。

差别来自表的结构:comb 表按“跨步位”组织,一张 32 项的表就能消掉 4/5 的倍点;wNAF 的滑动窗口表只影响点加,消不掉倍点链。

4.5 什么时候不该开

  • 工作集超过 8 把公钥:会退化成每次 prepare,反而更慢;
  • 一次性加密:建表 345 µs 摊不回来;
  • 公钥来自不可信输入:库不会隐式建表,但你也不该对每个陌生公钥显式 prepare。

公钥是公开信息,缓存它不引入新的密钥材料驻留;8 槽共占永久区固定 24 KiB,不随调用增长。


五、签名点池:把 k·G 挪出关键路径

5.1 为什么签名对延迟敏感

固定公钥优化解决的是“加密同一个对端”。签名没有对端公钥可以预计算——但签名有另一个固定量:基点 G。

一次签名里,k·G 占 80–90 µs(本机),而 k 是随机的。如果不允许复用 k(绝对不能),似乎没法预计算。

可以预计算的是 k·G 这个点,只要 k 本身被安全地保管、且只使用一次。

5.2 API

await sm2.ensureLoaded();

const pool = await sm2.createSignaturePointPool(100);   // 预生成 100 个一次性点
for (const msg of messages) {
  const sig = sm2.sign(privateKey, msg, { pointPool: pool });  // 每签一次消费一个
}

createSignaturePointPool(n) 为每个点做一次 k·G(本机 106 µs/点),sign 命中时只取 (k·G).x,跳过标量乘,也跳过随机数生成。

5.3 内部:只传 x1

计算层有两个入口:

// 自己算 k·G
export function sign(privPtr, zPtr, msgPtr, msgLen, kPtr, rPtr, sPtr): i32 {
  const k = ld(kPtr);
  return signCore(privPtr, zPtr, msgPtr, msgLen, k, toAffine(scalarMulG(k)).x, rPtr, sPtr);
}

// k·G 由调用方给,只取 x 坐标
export function signWithPoint(privPtr, zPtr, msgPtr, msgLen, kPtr, kxPtr, rPtr, sPtr): i32 {
  return signCore(privPtr, zPtr, msgPtr, msgLen, ld(kPtr), ld(kxPtr), rPtr, sPtr);
}

signCore 剩下的全部工作就是 e = SM3(Z‖M)、r = (e + x1) mod n、s = ((1+d)⁻¹(k − rd)) mod n,以及三处“极小概率重试”检查(r = 0、r + k = n、s = 0)。本机 5.35 µs。

5.4 安全设计:让“复用 k”在 API 层面不可能

复用 k 会直接泄露私钥(两条签名解方程即可)。所以这个 API 的设计目标不是“提醒调用方别复用”,而是让复用做不到:

  1. k 不放进 SignaturePoint 对象,而是存在模块私有的 WeakMap 里。调用方拿到的只有一个公开的 point(k·G 的坐标),读不到 k,也没法复制或序列化;
  2. 取走即 delete:同一个点位对象喂第二次会当场抛错,而不是悄悄复用;
  3. SignaturePoint 的构造函数是私有的,伪造对象同样会被 WeakMap 查不到而抛错;
  4. sign 在 finally 里抹掉 wasm 内存中的 k,JS 侧的 k 副本取走后立即 fill(0)。
function takeNonce(p: SignaturePoint): Uint8Array {
  const nonce = POINT_NONCE.get(p);
  if (nonce === undefined) {
    throw new Error('sm2: 该签名点不可用(已被使用过,或不是 createSignaturePointPool 产出的);签名点是一次性的。');
  }
  POINT_NONCE.delete(p);
  return nonce;
}

这套设计有一个前提:signWithPointOut 信任传入的 x1 与 k 匹配(计算层不重新验证,验证要一次 k·G,等于白省)。信任来自“外部拿不到 k,也构造不出合法点”,而不是来自运行时检查。测试里覆盖了伪造对象、二次使用、池耗尽回退三种情况。

这里顺手提一句:签名点池不支持 signWithID(自定义用户标识)。X.509 签发走的是另一条路径,批量签发的收益不值得为它加一套平行的池。

5.5 实测:13 倍延迟,吞吐没变

同进程交替 A/B(中位数):

层普通路径点池命中倍数
门面 sign108.2 µs8.0 µs13.5×
计算层 signOut / signWithPointOut93.6 µs6.2 µs15.2×

历史记录里是 141 → 24 µs(5.9×)。倍数不同,原因在上一节已经说过:k·G 的绝对成本两台机器接近,而门面层的随机数与编解码在 Node 26 上快得多,所以分母变小、倍数变大。

然后是我觉得这篇文章最该讲清楚的一句话:

点池不省总时间,它把 k·G 从“签名时”搬到了“建池时”。

本机批量 2000 次签名:

普通签名                     112.0 µs/次
建池(106.2 µs/点)+ 签名(7.9 µs/次)= 114.1 µs/次

总成本几乎一样(点池路径略慢 1.9%,因为建池比签名多一次 hex 编码和对象分配)。

所以点池买到的是延迟,不是吞吐:

  • 单次签名从 112 µs 降到 8 µs,交互式场景(登录、支付确认、设备上报的实时签名)感知明显;
  • 批量场景总时间不变;如果建池放在空闲时段或启动阶段,则峰值的平均延迟下降;
  • 池耗尽后自动回退到随机 k 路径,行为正确但延迟回到 112 µs——所以 n 要覆盖一个批次。

如果只想提高吞吐,点池帮不上忙;该做的是减少每次签名的重复工作(第三节的公钥缓存),或者把签名放进 Worker/多线程。

5.6 什么时候值得用

场景建议
单次、低频签名不用。建池比签名贵一个数量级
批量签名,要求总时间最短不用。总成本基本持平,见上
批量签名,要求单次延迟低用。n 取一批的大小
启动时可预热的客户端用。启动时建池,用户操作时 8 µs
内存/对象数受限(小程序)慎用。每个点一个对象 + 32 字节 k + 64 字节坐标

六、验签为什么更难

验签要算 (x1, y1) = s·G + t·P,其中 P 是签名者的公钥。

  • s·G 可以预计算,但它只占联合运算的一部分;
  • t·P 的 P 每次都可能不同,没法建 comb 表;
  • 两条标量乘共享倍点链后,倍点数已经是 256——和一次纯 wNAF 相同,没有进一步压缩空间。

本机 verifyOut 321.5 µs,scalarMulOut(一次 wNAF)261 µs。验签已经接近“一次 256 次倍点 + 少量开销”的下界。想做批量验签,只能走多签名随机线性组合(把 n 个验签方程合成一个),那是协议层的事,不是库该默认提供的。

所以这一节的结论是:验签没有对应的“点池”。公开 API 里也没有 verify 的预计算选项——不是没做,是做了收益不够。


七、方法学:三个会让你得出错误结论的坑

这一节可能比具体优化更值得看,因为 SM2 的数字很容易骗人。

7.1 坑一:跨进程比较

同一台机器,同一个 g2MulGOut(SM9 的 G2 标量乘),两次独立运行分别测出 98 µs 和 425 µs。SM2 没这么夸张,但 ±20% 是常态。

拿两次独立运行的绝对值做对比,等于抛硬币。正确做法是同进程、同时段、交替 A/B:两个版本装进同一个进程,轮流跑,各取 min 或中位数;输入由各自实例生成,输出逐字节比对。

function ab(labelA, fnA, labelB, fnB, rounds) {
  for (let i = 0; i < 50; i++) { fnA(); fnB(); }   // 预热
  const a = [], b = [];
  for (let i = 0; i < rounds; i++) {
    let t = performance.now();
    fnA();
    a.push((performance.now() - t) * 1000);
    t = performance.now();
    fnB();
    b.push((performance.now() - t) * 1000);
  }
  const ra = Math.min(...a), rb = Math.min(...b);
  console.log(labelA, ra, labelB, rb, `比例 ${(ra / rb).toFixed(2)}×`);
}

7.2 坑二:把 Uint8Array 当指针

exports.scalarMulGOut(pubBytes, out) 这种写法看起来合理,实际是把 JS 对象当成了 wasm 的 usize 指针。标量变成 0,测出过“comb 快 19×”的假信号。

正确写法是先 alloc,再把字节写进 new Uint8Array(memory.buffer, ptr, len)。所有跨边界的参数都必须是 wasm 内存里的偏移。

7.3 坑三:标量本身是负载

见 2.5:0x1111… 让 k·G 从 89 µs 变成 48 µs。benchmark 里用固定标量没问题,但要知道你测的是“这个标量”的耗时,不是“随机私钥”的耗时。

三条合起来是一句话:报比例,报同进程 A/B,报标量形状。


八、一张决策表

把前面几节收成一张表。所有“值不值得”都可以归结为:预计算能不能摊薄。

你的场景用法本机效果
偶尔签名默认 sign113 µs
反复用同一私钥签名默认(公钥缓存自动生效)已是 113 µs;关缓存会回到 193 µs
批量签名,要低单次延迟createSignaturePointPool(n) + pointPool8 µs/次,总成本持平
反复给同一公钥加密preparePublicKey(pub)395 → 209 µs,约 1.8 次回本
加密的工作集 > 8 把公钥不要 prepare命中率低会退化
验签默认324 µs,无可预计算项
合规要求私钥派生结果不驻留configurePublicKeyCache({ enabled: false })每次签名 +89 µs
一次性加密陌生公钥默认 wNAF不要显式 prepare

九、复现

pnpm install
pnpm build
pnpm bench        # 全量基准(SM2 那组包含 prepared / 点池 / 8 公钥轮换)

本文的本机数字来自三个临时 probe,都没入库(跑完即删)。核心思路:

  1. ensureLoaded() 后先跑一轮热身;
  2. sign 缓存命中用固定私钥,未命中轮换 40 把(缓存上限 32,保证淘汰);
  3. 点池先 createSignaturePointPool(12000),测量时每个样本 pop() 一个;
  4. 底层对比直接调 globalThis.__sm2Wasm.exports,同一进程、同一实例;
  5. encrypt 的 prepared / wNAF 用交替批次(清空与重建成本不计入样本)。

一个容易忽略的细节:loadWasm 用 process.cwd() 定位 wasm,probe 必须从仓库根目录运行。


写在最后

回头看,SM2 这条曲线上的四步优化,性质完全不同:

  • comb 是算法层的:把“点固定”这个前提变成预计算表,砍掉 4/5 的倍点;
  • 公钥缓存 是工程层的:发现一个 41% 的重复计算,用 20 行代码修掉;
  • 固定公钥 comb 是把算法层的结论推广到新场景(任意固定点),难点在内存管理;
  • 签名点池 是调度层的:不减少总工作量,只改变它发生的时间。

只有第二条是“免费的”。第一条要重写点运算和域运算;第三条要设计槽位、LRU、指纹和失败回退;第四条要处理一次性 k 的安全语义。

这也是我对优化这件事的判断标准:先问这个工作量能不能消失,再问能不能挪走,最后才问能不能算快一点。 大多数“优化”卡在第三步,而收益最大的往往是前两步。

下一篇写同一份代码怎么跑在 Node / 浏览器 / Web Worker / 小程序里:加载器怎么按环境分流、wasm 文件怎么进打包器产物、小程序为什么必须在主包外、以及那些只在真机上才出现的问题。


本文数字来自 sm-crypto-wasm 0.7.1。项目地址:GitHub · npm · 文档。

再次说明:按标准实现 ≠ 通过商用密码产品认证。生产使用请按所在行业的合规要求评估。

转载请注明出处。