Skip to content

Performance ​

Hot paths (big-integer modular arithmetic, hash compression, block-cipher rounds, bilinear pairings) execute inside WASM. The table covers every public operation; the gmkitx column is a pure-JS reference implementation on the same machine, with the same parameters and messages (operations it does not have are left as —). Measured on AMD Ryzen 7 7800X3D / Node 24.15 / Windows 11; reproduce with pnpm bench.

AlgorithmOperationsm-crypto-wasmPure-JS reference (gmkitx)Ratio
SM3hash 32 B / 1 KB / 64 KB872 ns / 4.75 µs / 245 µs14.7 µs (1 KB)3.1× faster
SM3hmac 1 KB5.58 µs19.4 µs3.5× faster
SM4ECB 16 B / 1 KB0.94 µs / 6.98 µs37.3 µs (1 KB)5.3× faster
SM4CBC 1 KB10.5 µs41.6 µs4.0× faster
SM4CTR 1 KB7.43 µs36.5 µs4.9× faster
SM4CFB 1 KB10.0 µs37.9 µs3.8× faster
SM4OFB 1 KB9.98 µs36.3 µs3.6× faster
SM4GCM 1 KB19.8 µs221 µs11.2× faster
SM2key pair generation151 µs——
SM2public key derivation110 µs197 µs1.8× faster
SM2sign 64 B142 µs446 µs3.1× faster
SM2verify 64 B402 µs2.85 ms7.1× faster
SM2encrypt 64 B499 µs2.96 ms5.9× faster
SM2decrypt 64 B343 µs2.59 ms7.6× faster
SM2KEM encapsulate / decapsulate (16 B key)496 / 328 µs——
SM2hybrid encrypt / decrypt 1 MB37.3 / 36.4 ms——
SM2key exchange (full A↔B)1.52 ms11.4 ms7.5× faster
SM9master key pair (sign / enc)2.07 / 1.73 ms——
SM9master public key (sign / enc)97.1 / 49.1 µs——
SM9user public key derivation563 µs——
SM9sign 32 B4.11 ms——
SM9verify 32 B8.00 ms——
SM9encrypt 32 B3.69 ms——
SM9decrypt 32 B5.40 ms——
SM9KEM encapsulate / decapsulate3.42 / 4.92 ms——
SM9hybrid encrypt / decrypt 10 KB3.21 / 4.77 ms——
SM9key exchange (full A↔B)19.6 ms——
ZUCEEA3 encrypt 1 KB9.07 µs20.5 µs2.3× faster
ZUCEEA3 keystream 1 KB6.15 µs——
ZUCEIA3 MAC 1 KB11.2 µs——

The SM2 row fluctuates

SM2 sits behind a TS facade (signing uses the public-key cache, encryption still does hex and curve-point codecs plus a host CSPRNG call). Run-to-run variation reaches ±20%, much larger than SM3 / SM4 / SM9 / ZUC. The table takes the whole SM2 row from a single run.

SM2 with a fixed public key: another 1.9×

After await sm2.preparePublicKey(pub) builds a comb table for that key, encrypt switches k·P from wNAF (256 doublings) to comb (52 doublings): 64 B encryption goes 486 → 260 µs (mitata, this machine). The table costs ≈ 280 µs, so it pays off after ≈ 1.2 encryptions. The cache has 8 slots with LRU by default; rotating 8 keys measures the same as a single hit. Only an explicit prepare builds a table; a miss falls back to wNAF without slowing down. kemEncrypt / hybridEncrypt use the same path. Bench: encrypt 64B (prepared pk), encrypt 64B (8 prepared keys, rotating).

Head-to-head with two other GM libraries ​

The table above benchmarks against the pure-JS gmkitx. Swapping in two other real Chinese-cryptography libraries, with identical parameters, messages and keys:

  • Cubelrti/sm-crypto-wasm — Rust + wasm-bindgen compiled to WebAssembly (⚠️ same package name as this project, but a different codebase);
  • sm-crypto-v2 — TypeScript + noble-curves, among the fastest pure-JS options today.

Each side is its own release build from source, and the benchmark ships a self-check that compares all three byte-for-byte (SM3 / SM4 must be identical, SM2 cross-verification must pass) — any mismatch throws instead of reporting a bogus number.

⚠️ The Cubelrti column carries two numbers. Its Cargo.toml pins the release profile to size (opt-level = "z" + wasm-opt = ["-Oz"]), and the pre-built artifact committed to its repo is exactly that build (175 KB, vs the 177 KB I get from the same config). Switch to speed (opt-level = 3 + wasm-opt = ["-O3"], 263 KB) and it becomes 26%~53% faster (53% on SM3). So both are given: the default config is "what you actually get", -O3 is "the ceiling of that Rust codebase". Ratio columns read how many times faster this project is.

Operationsm-crypto-wasmCubelrti
default (-Oz)
Cubelrti
-O3
sm-crypto-v2vs Cubelrti
(-Oz)
vs Cubelrti
(-O3)
vs v2
SM2 sign 64 B143.6 µs4.51 ms3.03 ms297 µs31.4×21.1×2.07×
SM2 verify 64 B406.6 µs4.49 ms3.05 ms2.83 ms11.0×7.50×6.96×
SM2 encrypt 64 B506.5 µs4.43 ms2.95 ms2.67 ms8.75×5.82×5.27×
SM2 decrypt 64 B337.0 µs2.26 ms1.51 ms2.49 ms6.71×4.48×7.39×
SM2 key pair generation147.8 µs2.25 ms1.54 ms210 µs15.2×10.4×1.42×
SM3 hash 1 KB4.75 µs24.7 µs11.5 µs6.44 µs5.19×2.42×1.36×
SM3 hmac 1 KB5.61 µs29.3 µs13.7 µs9.27 µs5.22×2.44×1.65×
SM4 ECB encrypt 1 KB6.78 µs12.6 µs8.83 µs12.6 µs1.85×1.30×1.85×
SM4 CBC encrypt 1 KB10.4 µs19.5 µs12.5 µs14.9 µs1.88×1.21×1.44×
SM4 CBC decrypt 1 KB10.8 µs22.2 µs16.3 µs14.0 µs2.06×1.51×1.30×
SM4 CTR encrypt 1 KB7.09 µs12.6 µs10.4 µs— (not implemented)1.77×1.46×—
SM4 GCM encrypt 1 KB19.9 µs27.5 µs19.7 µs30.4 µs1.39×0.99×1.53×

Note the last row: under -O3, Cubelrti essentially ties this project on SM4-GCM. Per operation, the SM4 modes all converge to 1.2~1.5× with -O3; only SM2 (a structural gap, see below) and SM3 stay in a different league.

Reproduce:

bash
bash bench/build-cubelrti-wasm.sh                # upstream default (-Oz)
SM_BUILD_O3=1 bash bench/build-cubelrti-wasm.sh  # speed-first (-O3), for the 4th column above
pnpm bench:cubelrti
Three observations worth noting
  • Rust→WASM is not automatically faster: Cubelrti's SM2 trails the pure-JSsm-crypto-v2 across the board (15× slower on signing). Its hot path uses num-bigint (a general-purpose, heap-allocating big integer) instead of fixed-width field arithmetic; every API call re-derives the public key (its keypair at 2.20 ms is about half its sign at 4.56 ms, i.e. signing performs two scalar multiplications); and every exported function runs a console::log_1.
  • sm-crypto-v2 is the pure-JS ceiling: on SM4 it roughly ties Cubelrti, and on SM3 it is only 1.35× behind this project.
  • Bulk signing has a dedicated path: sm2.createSignaturePointPool(n) amortises the fixed-base scalar multiplication (56% of a signature) ahead of time, taking a signature from 141 µs to 24 µs (5.9×) when the pool is hit.

Why it looks like this ​

  • SM3 got another round: compress used to materialise W′ (W[j] ^ W[j+4]) in a separate pass of 64 stores + 128 loads; it is now computed in-place inside the round loop. The round loops use pointer increments, and the padding buffer is module-level reused (previously every hash call did new StaticArray<u8>(totalLen) and zeroed the whole length, while only the padding region actually needs zeroing). HMAC now runs entirely inside WASM (the ipad/opad concatenation and the two digest calls used to happen in JS: four boundary crossings plus three temporary arrays), backed by a two-segment input entry that lets ipad ‖ msg feed the padding directly. Net effect: hash 26% faster, hmac 45% faster.
  • SM4 / ZUC are S-box bound: both are limited by the 8-bit S-box lookup, and WebAssembly exposes neither ARMv8's SM4E nor AES-NI. SM4 briefly tried v128 SIMD and it measured slower than 8-way scalar interleaving (four independent dependency chains per block, hiding L1 latency via out-of-order execution); that path was removed. SM3 is MD-chained (blocks cannot be parallelised) with a serial 64-round compression function.
  • SM4's CFB / OFB used to be slower than pure JS — they called into wasm block-by-block (64 crossings per KB). Batch exports brought them to ~10 µs, i.e. 3.6~3.8× faster.
  • SM2's compute layer went through nine rounds of work (Solinas reduction, Comba squaring, comb/wNAF scalar multiplication, in-place point arithmetic, mixed coordinates, joint scalar multiplication for verification, word-level inversion, public-key derivation caching for sign, and a prepared comb table for a fixed public key). Details in docs/performance-ceiling.md.
  • SM9 is pairing-dominated. Moving the base field to a Montgomery domain (CIOS) took one modular multiplication from 179 ns to 98 ns and sped up every end-to-end operation by 1.6~1.8×. There is no comparable pure-JS SM9 implementation, so only absolute numbers are given.
  • hex codecs mattered: switching encoding to Buffer.toString('hex') (with a lookup-table fallback in browsers) is ~27× faster; the input side now validates and parses in one pass. Together that made hex-input SM3 / SM4 / ZUC 27~32% faster end to end.

Numbers vary by version and machine

The values above come from one specific machine — use pnpm bench / pnpm bench:cubelrti on your own. The full experiment log and the "how much room is left" assessment live in docs/performance-ceiling.md.

Released under the Apache-2.0 License