Repository navigation
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
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; doneMeasurements
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.
- Annotation: declaring the locals
: numberchanges nothing (same 1,363 instructions per iteration). - Isolating this issue: rewriting
~BasB ^ -1removes perf: bitwise NOT~xon int32 values is 10–15× slower than Node (lowered through double + a 25-instruction ToInt32 tower and breaks the native i32 chain;x ^ -1is at parity) #10512. After that rewrite,destructurestill runs
1,276 instructions per iteration (2.8 s).or0drops to 20 instructions per iteration (30.8 ms, faster than Node).
That 60× instruction gap comes from this issue alone.
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,
hashSyncat cost 10: 30× Node. Dynamic numeric ops are 57–64 % of Perry time (_encipher,
index.js:738, a Feistel loop over untypedlr/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 byChi/rotr/| 0in 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 verbatimam1microbenchmark cut Perry's
time by 24 %. - nanoid 6.0.0:
js_dynamic_bitand+fmod+truncare about 14–16 % of Perry time. The trigger is
buffer[i] & maskwith a closure-capturedlet 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
- 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> ToInt32path is taken only ifis_provably_not_bigintholds 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.
- The proof ignores declarations and property-read initializers (verified).
is_provably_not_bigint(crates/perry-codegen/src/type_analysis/numeric.rs:718) accepts aLocalGetonly through
not_bigint_locals,integer_localsorunsigned_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
numbertype is consulted only for leaf reads of other locals (lines 180-182).
- 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,
nmshowst fmod: a shim whose body calls
compiler_builtins::math::libm_math::fmod::fmod, a bit-loop software remainder. It also showsW trunc: a
software bit-mask implementation. - The number of
fmoditerations grows with |v|/2^32, which is why jsbn's ~2^48 digit products are the worst case.
What fast looks like
- Runtime: ToInt32 without
fmod.- If
vis exactly an i32, returnv as i32. - Else if |v| < 2^63, return
(v as i64) as i32. Truncation plus two's-complement wrap is exact here, and this is a
singlecvttsd2si. - Keep the generic path only for |v| ≥ 2^63 and non-finite values.
- Target:
js_dynamic_bitandon int32 inputs costs ≤ ~15 instructions.
- If
- Codegen: the perf(codegen): two-leaf dynamic + takes the guarded fadd too — 3.44 → 1.33 ns/add (#9157) #9159 guarded diamond for the six bitwise operators.
- The hot arm tests that both operand bit patterns are plain doubles, then runs inline ToInt32 + op +
sitofp. - The cold arm calls the existing helper, so BigInt semantics (Numeric residuals: toFixed/toPrecision rounding, toString(radix), Number(\"0x…\">u64), clz32/dyn-bitops ToInt32, string order #6079) are unchanged and no declared type needs to
be trusted. - Target:
destructure,annotatedandpropwithin 2× of theor0control. That is ≤ ~40 instructions per
iteration once perf: bitwise NOT~xon int32 values is 10–15× slower than Node (lowered through double + a 25-instruction ToInt32 tower and breaks the native i32 chain;x ^ -1is at parity) #10512 is fixed, and ≤2× Node.
- The hot arm tests that both operand bit patterns are plain doubles, then runs inline ToInt32 + op +
Notes
- perf: bitwise NOT
~xon int32 values is 10–15× slower than Node (lowered through double + a 25-instruction ToInt32 tower and breaks the native i32 chain;x ^ -1is at parity) #10512 is the~xcost that remains inside theor0control. - perf:
arr[i] = von an untyped plain Array is 52× slower than Node (no inline store: one js_dyn_index_set_strict call per element; the same loop with anumber[]annotation is 1.7×) #10513 is the untyped array read inbigmask. - perf:
u8[i]/buf[i]on a Uint8Array or Buffer parameter is 53× slower than Node (out-of-line js_uint8array_get/set probing the buffer registries) #10515 covers nanoid's element access; its& maskis this issue. - Context from closed issues:
- Numeric residuals: toFixed/toPrecision rounding, toString(radix), Number(\"0x…\">u64), clz32/dyn-bitops ToInt32, string order #6079 introduced the
rem_euclidToInt32 for correctness. - perf(codegen): two-leaf dynamic + takes the guarded fadd too — 3.44 → 1.33 ns/add (#9157) #9159 added the guarded diamond for
+ - * /. - perf(codegen): 15_mandelbrot's inner loop routes every FP multiply through js_dynamic_mul — 201 instructions/iteration vs #7128's ~13 #8105 was the same "dynamic helper per op" shape for
*.
- Numeric residuals: toFixed/toPrecision rounding, toString(radix), Number(\"0x…\">u64), clz32/dyn-bitops ToInt32, string order #6079 introduced the
Activity
changelog: key the #10511 fragment on PR 11384
perf: bitwise ops on unproven operands skip fmod and the ToInt32 tower (
changelog: key the #10511 fragment on PR 11384
perf: bitwise ops on unproven operands skip fmod and the ToInt32 tower (
changelog: key the #10511 fragment on PR 11384
proggeramlug commentedon Sep 30, 2026
Current target (2026-09-30): bitwise ops on destructured locals cost 218 instr/iteration, against 20 for the
| 0control and 41 on NodePackage 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:
sha256.jsround loop (closure 20), 2.51Mbig.mjs:872 for (c = new Array(j = a + b); j--;) c[j] = 0;33.5Mdecimal.mjs:1908 for (i = rL; i--;) r.push(0);jsbn.jsam-loop (closure 14), 2.60Gbignumber.mjs:2314 … zc.push(0)aes.js(closure 29), 4.47MThe 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:
%→js_dynamic_mod→ libmfmod(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
fmod, the guarded diamond is one|v| < 2^63compare plusfptosi, and bitwise results carry an int32 fact. Its PR says the target is not reached. The remainder is that destructured locals live in boxed, GC-rooted slots, so eachD = C; C = B; …shuffle pays a root store, a barrier check and a string-addref test.Reproducer, fresh numbers (the issue's
bench.tswithout its warm-up pass, instructions per iteration)or0(control)destructureprop(untyped property operand)bigmask(v & 0x3ffffff, |v| ≈ 2^48)(The issue's column used whole-process ÷ 1.2·N. Main's uses two-N.)
Acceptance target
destructure≤ 2×or0(≤ ~40 instr per iteration; now 10.9×).propandbigmask≤ 2× Node.Package check (Linux, needs
perf). Build withcargo 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.jsonRun it once on a base-commit build and once on the branch, and compare instructions per iteration.
--filteris a workload-id substring, andcontrol/*always runs. For attribution, runprofile --callgraphonPERRY_KEEP_SYMBOLS=1binaries (seebenchmarks/packages/PROFILE.md).Fresh numbers were measured on
origin/main5fbc2c3 (v0.5.1654) withcargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static. Binaries were compiled withPERRY_NO_AUTO_OPTIMIZE=1and compared with Node 26.5.1 on Linux x86-64 usingperf 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 frombenchmarks/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.perf: bitwise ops on unproven locals run native in a Number-versioned…
proggeramlug commentedon Oct 3, 2026
Partial progress landed in #11788 (bitwise destructure/annotated locals 212→57 instructions). Leaving this open for the remaining cases.