You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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):
constvariant=process.argv[2]||"destructure";constN=Number(process.argv[3]||"20000000");classSt{A: number=0x6a09e667|0;B: number=0xbb67ae85|0;C: number=0x3c6ef372|0;D: number=0xa54ff53a|0;}functionrotr(w: number,s: number): number{return(w<<(32-s))|(w>>>s);}functiondestructure(st: St,n: number): number{// noble SHA2_32B.process: `let { A, B, C, D } = this`let{ A, B, C, D }=st;for(leti=0;i<n;i++){constt=(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;returnA;}functionannotated(st: St,n: number): number{// same, locals declared `: number`letA: number=st.A,B: number=st.B,C: number=st.C,D: number=st.D;for(leti=0;i<n;i++){constt=(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;returnA;}functionor0(st: St,n: number): number{// CONTROL: same, locals seeded with `| 0`letA=st.A|0,B=st.B|0,C=st.C|0,D=st.D|0;for(leti=0;i<n;i++){constt=(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;returnA;}functionprop(o: any,n: number): number{// bitops on an untyped property readleth=o.seed;for(leti=0;i<n;i++){h^=o.k;h=(h<<13)|(h>>>19);h=(h*5+0xe6546b64)&0xffffffff;}returnh;}functionbigmask(o: any,n: number): number{// jsbn am1: `v & 0x3ffffff` with |v| ~ 2^48 (untyped)lets=0;for(leti=0;i<n;i++){constv=o.xs[i&63]*o.x+o.c;s=(s+(v&0x3ffffff))%1000000007;}returns;}functionbigarith(o: any,n: number): number{// CONTROL for bigmask: same value via v - floor(v/2^26)*2^26lets=0;for(leti=0;i<n;i++){constv=o.xs[i&63]*o.x+o.c;s=(s+(v-Math.floor(v/67108864)*67108864))%1000000007;}returns;}constfns: any={ destructure, annotated, or0 };constst=newSt();consto={seed: 0x1234567,k: 0x5bd1e995|0,x: 4194301,c: 7,xs: []asany[]};for(leti=0;i<64;i++)o.xs.push((i*2654435761)%67108864);construn=(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);constt0=performance.now();constcs=run(N);console.log(`${variant} checksum=${cs} ms=${(performance.now()-t0).toFixed(1)}`);
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 : number changes nothing (same 1,363 instructions per iteration).
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
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).
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).
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
Runtime: ToInt32 without fmod.
If v is exactly an i32, return v 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
single cvttsd2si.
Keep the generic path only for |v| ≥ 2^63 and non-finite values.
Target: js_dynamic_bitand on int32 inputs costs ≤ ~15 instructions.
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 isnot proven non-BigInt compiles to an out-of-line
js_dynamic_bit*call. Every such call converts both operands withtrunc().rem_euclid(2^32), which links to a softwarefmod. A sha256-style round over locals destructured fromthisis 56× slower than Node. The same loop with
| 0-seeded locals runs 7.7× fewer instructions.Reproduction
bench.ts(30 lines):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. Theper-iteration figure divides that count by 1.2·N, because the N/5 warm-up is included. N = 20,000,000.
let A: number = st.A)v & 0x3ffffff, |v|≈2^48)Checksums are identical in every variant.
: numberchanges nothing (same 1,363 instructions per iteration).~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 runs1,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 recordon thedestructurevariant (PERRY_KEEP_SYMBOLS=1):compiler_builtins::math::libm_math::fmod::fmodfmodshimtruncjs_dynamic_bitandjs_dynamic_bitxorjs_dynamic_ushrjs_dynamic_shljs_dynamic_bitnotjs_number_coerceThe software
fmod/truncpath is 38 % of the total. On thepropvariant,fmod+truncis 40 %.Impact
The audit profiles below were taken on v0.5.1587.
hashSyncat 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).let { A, B, C, D, E, F, G, H } = this, followed byChi/rotr/| 0in the round. The same construct is 5.9 % ofblake3.
jsbn.js:85,
w.data[j++] = v&0x3ffffff. Removing that single&from the verbatimam1microbenchmark cut Perry'stime by 24 %.
js_dynamic_bitand+fmod+truncare about 14–16 % of Perry time. The trigger isbuffer[i] & maskwith a closure-capturedlet mask(index.js:100). This is from the objects-group profile; thebcryptjs, noble and node-forge figures are from the numeric-group profiles.
Mechanism
crates/perry-codegen/src/expr/binary.rs:1159-1213, verified).ToInt32 <op> ToInt32path is taken only ifis_provably_not_bigintholds for both operands(lines 1192-1195).
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 nosuch arm, so every evaluation is a call.
is_provably_not_bigint(crates/perry-codegen/src/type_analysis/numeric.rs:718) accepts aLocalGetonly throughnot_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 byall of its writes.
let { A } = this,let A: number = st.A,o.k) hits_ => false(line 185), so the local is dropped from the set.
numbertype is consulted only for leaf reads of other locals (lines 180-182).dyn_to_int32/dyn_to_uint32(crates/perry-runtime/src/value/dynamic_arith.rs:785-800) computev.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 thesefunctions.
nmshowst fmod: a shim whose body callscompiler_builtins::math::libm_math::fmod::fmod, a bit-loop software remainder. It also showsW trunc: asoftware bit-mask implementation.
fmoditerations grows with |v|/2^32, which is why jsbn's ~2^48 digit products are the worst case.What fast looks like
fmod.vis exactly an i32, returnv as i32.(v as i64) as i32. Truncation plus two's-complement wrap is exact here, and this is asingle
cvttsd2si.js_dynamic_bitandon int32 inputs costs ≤ ~15 instructions.sitofp.be trusted.
destructure,annotatedandpropwithin 2× of theor0control. That is ≤ ~40 instructions periteration 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.Notes
~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.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.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.rem_euclidToInt32 for correctness.+ - * /.*.