Skip to content

number arithmetic in a local is evaluated past double precision — (x * 1103515245 + 12345) & 0x7fffffff diverges from Node #7232

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions