Found incidentally while validating a string-sort microbenchmark for #7229 (the
benchmark's LCG key generator produced different keys under Perry than under Node).
Reproduces on main @ 65a64f428; not introduced by #7229 — both arms of that
A/B behave identically.
Repro
// lcg2.ts
// straight-line, no loop
let s0 = 12345;
let s1 = (s0 * 1103515245 + 12345) & 0x7fffffff;
let s2 = (s1 * 1103515245 + 12345) & 0x7fffffff;
console.log("straight s1=" + s1 + " s2=" + s2);
// same thing in a loop
let t = 12345;
for (let i = 0; i < 2; i++) { t = (t * 1103515245 + 12345) & 0x7fffffff; }
console.log("loop t=" + t);
// forced through a function boundary
function step(x: number): number { return (x * 1103515245 + 12345) & 0x7fffffff; }
console.log("fn " + step(step(12345)));
$ node --experimental-strip-types lcg2.ts # 26.5.1
straight s1=1406932606 s2=654583808
loop t=654583808
fn 654583808
$ perry lcg2.ts -o lcg2 && ./lcg2 # main @ 65a64f428, aarch64 Linux, release
straight s1=1406932606 s2=654583775 # WRONG
loop t=654583775 # WRONG
fn 654583808 # correct
What's going on
s1 * 1103515245 is 1406932606 * 1103515245 ≈ 1.55e18, which is past 2^53, so
in IEEE-754 double the product is rounded — and + 12345 then falls partly
inside the ulp (256 at that magnitude). ECMAScript numbers are doubles, so the
rounding is observable and Node's & 0x7fffffff sees the rounded value.
Perry gets the right answer only when the intermediate crosses a function
boundary (where it is NaN-boxed as an f64 and therefore rounded). When the
product stays in an SSA local — straight-line or loop-carried — the low bits
survive, i.e. the multiply is being evaluated at wider-than-double precision
(i64-shaped), and ToInt32 then reads bits that JS has already discarded.
654583808 - 654583775 = 33, i.e. the two answers differ only in the low bits
that double rounding is supposed to have erased.
Note this is not a ToInt32-of-a-large-double bug on its own — a literal
double round-trips correctly:
const a = 1552571579408578600;
console.log((a + 12345) | 0); // node 654583808, perry 654583808 ✅
It needs the arithmetic to happen in a local.
Why it matters
Any JS that relies on double rounding past 2^53 silently diverges: LCG/PRNG
seeds (the case that surfaced this), hash mixing, checksum accumulators, ID
arithmetic. It is quiet — no throw, no warning, just different numbers — and it
is exactly the failure mode the representation-selection work has to stay clear
of, so it is worth a regression test regardless of which pass turns out to own it.
Suggested acceptance
A parity case asserting (x * 1103515245 + 12345) & 0x7fffffff agrees with Node
for a loop-carried x, plus the straight-line and through-a-call variants so a
future fix can't pass by accident on only one of the three shapes.
Found incidentally while validating a string-sort microbenchmark for #7229 (the
benchmark's LCG key generator produced different keys under Perry than under Node).
Reproduces on
main@65a64f428; not introduced by #7229 — both arms of thatA/B behave identically.
Repro
What's going on
s1 * 1103515245is1406932606 * 1103515245 ≈ 1.55e18, which is past 2^53, soin IEEE-754 double the product is rounded — and
+ 12345then falls partlyinside the ulp (256 at that magnitude). ECMAScript numbers are doubles, so the
rounding is observable and Node's
& 0x7fffffffsees the rounded value.Perry gets the right answer only when the intermediate crosses a function
boundary (where it is NaN-boxed as an f64 and therefore rounded). When the
product stays in an SSA local — straight-line or loop-carried — the low bits
survive, i.e. the multiply is being evaluated at wider-than-double precision
(i64-shaped), and
ToInt32then reads bits that JS has already discarded.654583808 - 654583775 = 33, i.e. the two answers differ only in the low bitsthat double rounding is supposed to have erased.
Note this is not a
ToInt32-of-a-large-double bug on its own — a literaldouble round-trips correctly:
It needs the arithmetic to happen in a local.
Why it matters
Any JS that relies on double rounding past 2^53 silently diverges: LCG/PRNG
seeds (the case that surfaced this), hash mixing, checksum accumulators, ID
arithmetic. It is quiet — no throw, no warning, just different numbers — and it
is exactly the failure mode the representation-selection work has to stay clear
of, so it is worth a regression test regardless of which pass turns out to own it.
Suggested acceptance
A parity case asserting
(x * 1103515245 + 12345) & 0x7fffffffagrees with Nodefor a loop-carried
x, plus the straight-line and through-a-call variants so afuture fix can't pass by accident on only one of the three shapes.