Skip to content

perf: bitwise operators on destructured, number-annotated or untyped values are 56× slower than Node (a js_dynamic_bit* call per operator, ToInt32 via software fmod) #10511

Open

Description

@proggeramlug

Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. A bitwise operator (& | ^ << >> >>>, and ~) whose operand is
not proven non-BigInt compiles to an out-of-line js_dynamic_bit* call. Every such call converts both operands with
trunc().rem_euclid(2^32), which links to a software fmod. A sha256-style round over locals destructured from this
is 56× slower than Node. The same loop with | 0-seeded locals runs 7.7× fewer instructions.

Reproduction

bench.ts (30 lines):

const variant = process.argv[2] || "destructure"; const N = Number(process.argv[3] || "20000000");
class St { A: number = 0x6a09e667 | 0; B: number = 0xbb67ae85 | 0; C: number = 0x3c6ef372 | 0; D: number = 0xa54ff53a | 0; }
function rotr(w: number, s: number): number { return (w << (32 - s)) | (w >>> s); }
function destructure(st: St, n: number): number { // noble SHA2_32B.process: `let { A, B, C, D } = this`
  let { A, B, C, D } = st;
  for (let i = 0; i < n; i++) { const t = (rotr(A, 7) ^ ((B & C) ^ (~B & D))) + i | 0; D = C; C = B; B = A; A = t; }
  st.A = A; st.B = B; st.C = C; st.D = D; return A;
}
function annotated(st: St, n: number): number { // same, locals declared `: number`
  let A: number = st.A, B: number = st.B, C: number = st.C, D: number = st.D;
  for (let i = 0; i < n; i++) { const t = (rotr(A, 7) ^ ((B & C) ^ (~B & D))) + i | 0; D = C; C = B; B = A; A = t; }
  st.A = A; st.B = B; st.C = C; st.D = D; return A;
}
function or0(st: St, n: number): number { // CONTROL: same, locals seeded with `| 0`
  let A = st.A | 0, B = st.B | 0, C = st.C | 0, D = st.D | 0;
  for (let i = 0; i < n; i++) { const t = (rotr(A, 7) ^ ((B & C) ^ (~B & D))) + i | 0; D = C; C = B; B = A; A = t; }
  st.A = A; st.B = B; st.C = C; st.D = D; return A;
}
function prop(o: any, n: number): number { // bitops on an untyped property read
  let h = o.seed; for (let i = 0; i < n; i++) { h ^= o.k; h = (h << 13) | (h >>> 19); h = (h * 5 + 0xe6546b64) & 0xffffffff; } return h;
}
function bigmask(o: any, n: number): number { // jsbn am1: `v & 0x3ffffff` with |v| ~ 2^48 (untyped)
  let s = 0; for (let i = 0; i < n; i++) { const v = o.xs[i & 63] * o.x + o.c; s = (s + (v & 0x3ffffff)) % 1000000007; } return s;
}
function bigarith(o: any, n: number): number { // CONTROL for bigmask: same value via v - floor(v/2^26)*2^26
  let s = 0; for (let i = 0; i < n; i++) { const v = o.xs[i & 63] * o.x + o.c; s = (s + (v - Math.floor(v / 67108864) * 67108864)) % 1000000007; } return s;
}
const fns: any = { destructure, annotated, or0 }; const st = new St(); const o = { seed: 0x1234567, k: 0x5bd1e995 | 0, x: 4194301, c: 7, xs: [] as any[] }; for (let i = 0; i < 64; i++) o.xs.push((i * 2654435761) % 67108864);
const run = (n: number) => fns[variant] ? fns[variant](st, n) : variant === "prop" ? prop(o, n) : variant === "bigmask" ? bigmask(o, n) : bigarith(o, n);
run(N / 5 | 0); const t0 = performance.now(); const cs = run(N); console.log(`${variant} checksum=${cs} ms=${(performance.now() - t0).toFixed(1)}`);
PERRY_NO_AUTO_OPTIMIZE=1 perry compile bench.ts -o bench
for v in destructure annotated or0 prop bigmask bigarith; do node bench.ts $v 20000000; ./bench $v 20000000; done

Measurements

These are medians of 3 runs on a shared, loaded host (load average around 100 on 64 threads). The instruction counts do
not depend on host load, so they are the reliable figures. Perry's count is the whole-process instructions:u. The
per-iteration figure divides that count by 1.2·N, because the N/5 warm-up is included. N = 20,000,000.

variant Node loop ms Perry loop ms ratio Perry instructions (per iter) Node wall Perry wall
destructure 49.7 2,793 56× 32.70 G (1,363) 186 ms 3,305 ms
annotated (let A: number = st.A) 54.2 2,811 52× 32.70 G (1,363) 208 ms 3,420 ms
or0 (control) 58.3 437.6 7.5× 4.24 G (177) 208 ms 574 ms
prop (untyped property operand) 21.9 1,375 63× 15.97 G (665) 195 ms 1,686 ms
bigmask (v & 0x3ffffff, |v|≈2^48) 78.4 1,714 22× 22.31 G (930) 222 ms 2,079 ms
bigarith (control for bigmask) 278.9 1,099 3.9× 15.01 G (625) 470 ms 1,394 ms

Checksums are identical in every variant.

perf record on the destructure variant (PERRY_KEEP_SYMBOLS=1):

symbol share
compiler_builtins::math::libm_math::fmod::fmod 21.6 %
fmod shim 5.5 %
trunc 11.2 %
js_dynamic_bitand 13.6 %
js_dynamic_bitxor 12.1 %
js_dynamic_ushr 7.5 %
js_dynamic_shl 6.4 %
js_dynamic_bitnot 4.2 %
js_number_coerce 2.0 %
the compiled loop 15.0 %

The software fmod/trunc path is 38 % of the total. On the prop variant, fmod + trunc is 40 %.

Impact

The audit profiles below were taken on v0.5.1587.

  • bcryptjs 3.0.3, hashSync at cost 10: 30× Node. Dynamic numeric ops are 57–64 % of Perry time (_encipher,
    index.js:738, a Feistel loop over untyped lr/P/S).
  • @noble/hashes 2.2.0 sha256: 133× Node. This construct is 30.8 % of Perry time. The trigger is sha2.js:60,
    let { A, B, C, D, E, F, G, H } = this, followed by Chi/rotr/| 0 in the round. The same construct is 5.9 % of
    blake3.
  • node-forge 1.4.0 RSA-2048 sign / keygen: 98× / 80× Node, with 23.6 % / 28.3 % of Perry time here. The trigger is
    jsbn.js:85, w.data[j++] = v&0x3ffffff. Removing that single & from the verbatim am1 microbenchmark cut Perry's
    time by 24 %.
  • nanoid 6.0.0: js_dynamic_bitand + fmod + trunc are about 14–16 % of Perry time. The trigger is
    buffer[i] & mask with a closure-captured let mask (index.js:100). This is from the objects-group profile; the
    bcryptjs, noble and node-forge figures are from the numeric-group profiles.

Mechanism

  1. Codegen bails to the helper (crates/perry-codegen/src/expr/binary.rs:1159-1213, verified).
    • This applies to BitAnd/BitOr/BitXor/Shl/Shr/UShr when an operand is not statically numeric.
    • The inline ToInt32 <op> ToInt32 path is taken only if is_provably_not_bigint holds for both operands
      (lines 1192-1195).
    • Otherwise the operator lowers to lower_rooted_dynamic_binary(ctx, "js_dynamic_bit…") (line 1213).
    • -, * and / get the tag-guarded inline diamond from perf(codegen): two-leaf dynamic + takes the guarded fadd too — 3.44 → 1.33 ns/add (#9157) #9159 (lines 1206-1211). The bitwise operators have no
      such arm, so every evaluation is a call.
  2. The proof ignores declarations and property-read initializers (verified).
    • is_provably_not_bigint (crates/perry-codegen/src/type_analysis/numeric.rs:718) accepts a LocalGet only through
      not_bigint_locals, integer_locals or unsigned_i32_locals (lines 834-838). It does not check the declared type.
    • collect_not_bigint_locals (crates/perry-codegen/src/collectors/not_bigint_locals.rs:98-186) judges a local by
      all of its writes.
    • An initializer that is a property read (let { A } = this, let A: number = st.A, o.k) hits _ => false
      (line 185), so the local is dropped from the set.
    • A declared number type is consulted only for leaf reads of other locals (lines 180-182).
  3. The runtime ToInt32 is soft-float (verified).
    • dyn_to_int32/dyn_to_uint32 (crates/perry-runtime/src/value/dynamic_arith.rs:785-800) compute
      v.trunc().rem_euclid(4_294_967_296.0) for every operand, including values that are already int32.
    • js_dynamic_bitand (line 853) and its siblings take a plain-double fast path, but that path still calls these
      functions.
    • In the linked binary, nm shows t fmod: a shim whose body calls
      compiler_builtins::math::libm_math::fmod::fmod, a bit-loop software remainder. It also shows W trunc: a
      software bit-mask implementation.
    • The number of fmod iterations grows with |v|/2^32, which is why jsbn's ~2^48 digit products are the worst case.

What fast looks like

Notes

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
  2. proggeramlug commented on Sep 30, 2026

    @proggeramlug
    ContributorAuthor

    Current target (2026-09-30): bitwise ops on destructured locals cost 218 instr/iteration, against 20 for the | 0 control and 41 on Node

    Package impact (#11464)

    Bucket row 7, generated code itself (numeric kernels), is 4.5% of equal-weight excess. It counts the instructions of the compiled JS itself, not runtime calls. It is ≥5% in 5 packages. Share of each package's excess: big.js 21, node-forge 17, bignumber.js 13, decimal.js 9, nanoid 6.

    Per workload, with the hottest inline site:

    workload inline instr/iter % of excess hottest inline site
    node-forge/sha256 2.66M 34.0 sha256.js round loop (closure 20), 2.51M
    big.js/arith_chain 45.0M 21.6 big.mjs:872 for (c = new Array(j = a + b); j--;) c[j] = 0; 33.5M
    decimal.js/arith_chain 688k 16.6 decimal.mjs:1908 for (i = rL; i--;) r.push(0);
    node-forge/rsa_sign 3.23G 16.0 jsbn.js am-loop (closure 14), 2.60G
    bignumber.js/arith_chain 743k 14.3 bignumber.mjs:2314 … zc.push(0)
    node-forge/aes_cbc 5.41M 13.4 aes.js (closure 29), 4.47M

    The bucket does not say which construct inside the inline code costs the instructions, whether bitwise ToInt32, element access, or loop shape. The split between #10511, #10718 and #10741 is therefore by site, not measured.

    Two neighbouring rows:

    • Row 4 dynamic index get/set (5.3%: big.js 35, bignumber.js 19, decimal.js 11, node-forge 11).
    • Row 13 numeric conversion (2.1%), whose largest item is untyped % → js_dynamic_mod → libm fmod (bignumber.js 18.8%, big.js 17.4%, decimal.js 9.2% of excess). It is still unfiled for lack of a package-free repro.

    The node-forge figures come from 2febf42 binaries, which predate #11384.

    For this issue the bitwise-heavy sites are the node-forge sha256 and aes rounds and jsbn am1 (w.data[j++] = v&0x3ffffff).

    What landed since the issue was filed

    Reproducer, fresh numbers (the issue's bench.ts without its warm-up pass, instructions per iteration)

    class St { A: number = 0x6a09e667 | 0; B: number = 0xbb67ae85 | 0; C: number = 0x3c6ef372 | 0; D: number = 0xa54ff53a | 0; }
    function rotr(w: number, s: number): number { return (w << (32 - s)) | (w >>> s); }
    function destructure(st: St, n: number): number { let { A, B, C, D } = st;          // or0: let A = st.A | 0, …
      for (let i = 0; i < n; i++) { const t = (rotr(A, 7) ^ ((B & C) ^ (~B & D))) + i | 0; D = C; C = B; B = A; A = t; }
      st.A = A; st.B = B; st.C = C; st.D = D; return A; }
    function prop(o: any, n: number): number { let h = o.seed; for (let i = 0; i < n; i++) { h ^= o.k; h = (h << 13) | (h >>> 19); h = (h * 5 + 0xe6546b64) & 0xffffffff; } return h; }
    variant Perry, 7661bc0 (issue) Perry, main Node Perry/Node
    or0 (control) 177 20 39 0.5×
    destructure 1,363 218 41 5.3×
    prop (untyped property operand) 665 78 9.9 7.9×
    bigmask (v & 0x3ffffff, |v| ≈ 2^48) 930 186 42 4.4×

    (The issue's column used whole-process ÷ 1.2·N. Main's uses two-N.)

    Acceptance target

    Package check (Linux, needs perf). Build with cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static, then run:

    (cd benchmarks/packages && npm ci --ignore-scripts)
    python3 scripts/package_bench.py compile --perry-bin-dir /tmp/pb --filter node-forge --filter big.js --filter bignumber.js --filter decimal.js --filter nanoid
    python3 scripts/package_bench.py run --perry-bin-dir /tmp/pb --arms node,perry --modes instr --filter node-forge --filter big.js --filter bignumber.js --filter decimal.js --filter nanoid --out /tmp/pb/instr.json

    Run it once on a base-commit build and once on the branch, and compare instructions per iteration. --filter is a workload-id substring, and control/* always runs. For attribution, run profile --callgraph on PERRY_KEEP_SYMBOLS=1 binaries (see benchmarks/packages/PROFILE.md).

    Fresh numbers were measured on origin/main 5fbc2c3 (v0.5.1654) with cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static. Binaries were compiled with PERRY_NO_AUTO_OPTIMIZE=1 and compared with Node 26.5.1 on Linux x86-64 using perf stat -e instructions:u. Each figure is the median of 3 runs at two sizes of N, with per-op = ΔI/ΔN. N1 is at least 200k operations (20k calls for the factory bench), which keeps most of Node's JIT warm-up inside the constant term. Output was byte-identical to Node on every row. The #11464 figures come from benchmarks/packages/profile/callgraph.{md,json}, measured at Perry 36420d2 with auto-optimize. node-forge was measured from 2febf42 binaries. "% of excess" means the share of a package's (Perry − Node) instructions per iteration.

  3. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    Partial progress landed in #11788 (bitwise destructure/annotated locals 212→57 instructions). Leaving this open for the remaining cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions