本文是「国密与 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 24 | Apple M4 / Node 26 |
|---|---|---|
| 初始(无缓存、无点池) | 252 µs | 193 µs |
| 公钥派生缓存上线 | 138 µs | 113 µs |
| 签名点池命中 | 24 µs | 7.3 µs |
| 相对初始 | 10.5× | 26× |
绝对值和倍数都不同,但省下来的东西是同一件:一次固定基点标量乘,加上门面层的随机数与编解码。k·G 本身在两台机器上都是 80–90 µs 量级,是硬成本;门面层的开销随 Node 版本变化,所以快机器上点池的“倍数”更大。
本文所有数字都标注来源:
- 本机:Apple M4 / macOS / Node v26.7.0 /
sm-crypto-wasm0.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 µs | comb,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) |
| 门面层 | getPublicKey | 89.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 24 | 138 µs | 234 µs | 96 µs / 41% |
| Apple M4 / Node 26 | 113 µs | 193 µs | 80 µ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 24 | Apple M4 / Node 26 |
|---|---|---|
底层 encryptOut,wNAF → comb | 423.6 → 201.4 µs(2.10×) | — |
端到端 encrypt 64 B | 486 → 260 µs(1.86×) | 395 → 209 µs(1.89×) |
单次 k·P | 309.4 → 约 85 µs | 261 → — |
| 建表(一次性) | 280 µs | 345 µ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 的设计目标不是“提醒调用方别复用”,而是让复用做不到:
k不放进SignaturePoint对象,而是存在模块私有的WeakMap里。调用方拿到的只有一个公开的point(k·G的坐标),读不到 k,也没法复制或序列化;- 取走即
delete:同一个点位对象喂第二次会当场抛错,而不是悄悄复用; SignaturePoint的构造函数是私有的,伪造对象同样会被WeakMap查不到而抛错;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(中位数):
| 层 | 普通路径 | 点池命中 | 倍数 |
|---|---|---|---|
门面 sign | 108.2 µs | 8.0 µs | 13.5× |
计算层 signOut / signWithPointOut | 93.6 µs | 6.2 µs | 15.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,报标量形状。
八、一张决策表
把前面几节收成一张表。所有“值不值得”都可以归结为:预计算能不能摊薄。
| 你的场景 | 用法 | 本机效果 |
|---|---|---|
| 偶尔签名 | 默认 sign | 113 µs |
| 反复用同一私钥签名 | 默认(公钥缓存自动生效) | 已是 113 µs;关缓存会回到 193 µs |
| 批量签名,要低单次延迟 | createSignaturePointPool(n) + pointPool | 8 µ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,都没入库(跑完即删)。核心思路:
ensureLoaded()后先跑一轮热身;sign缓存命中用固定私钥,未命中轮换 40 把(缓存上限 32,保证淘汰);- 点池先
createSignaturePointPool(12000),测量时每个样本pop()一个; - 底层对比直接调
globalThis.__sm2Wasm.exports,同一进程、同一实例; encrypt的 prepared / wNAF 用交替批次(清空与重建成本不计入样本)。
一个容易忽略的细节:loadWasm 用 process.cwd() 定位 wasm,probe 必须从仓库根目录运行。
写在最后
回头看,SM2 这条曲线上的四步优化,性质完全不同:
- comb 是算法层的:把“点固定”这个前提变成预计算表,砍掉 4/5 的倍点;
- 公钥缓存 是工程层的:发现一个 41% 的重复计算,用 20 行代码修掉;
- 固定公钥 comb 是把算法层的结论推广到新场景(任意固定点),难点在内存管理;
- 签名点池 是调度层的:不减少总工作量,只改变它发生的时间。
只有第二条是“免费的”。第一条要重写点运算和域运算;第三条要设计槽位、LRU、指纹和失败回退;第四条要处理一次性 k 的安全语义。
这也是我对优化这件事的判断标准:先问这个工作量能不能消失,再问能不能挪走,最后才问能不能算快一点。 大多数“优化”卡在第三步,而收益最大的往往是前两步。
下一篇写同一份代码怎么跑在 Node / 浏览器 / Web Worker / 小程序里:加载器怎么按环境分流、wasm 文件怎么进打包器产物、小程序为什么必须在主包外、以及那些只在真机上才出现的问题。
本文数字来自
sm-crypto-wasm0.7.1。项目地址:GitHub · npm · 文档。再次说明:按标准实现 ≠ 通过商用密码产品认证。生产使用请按所在行业的合规要求评估。
转载请注明出处。