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

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

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