Skip to content

i64 function specialization evaluates number arithmetic in i64 and truncates fractional arguments #7238

Description

@proggeramlug

Found while root-causing #7232. Same invariant, different pass — and this one is
worse, because it also truncates fractional arguments.

emit_i64_specializations (crates/perry-codegen/src/codegen/i64_spec.rs)
re-emits a whole function body in i64 arithmetic whenever
is_integer_specializable (crates/perry-codegen/src/collectors/clamp_detect.rs:154)
accepts it, and wraps it in an f64 shim that fptosis every argument and
sitofps the result. The admission rule is:

  • return_type == Number and every param Number, and
  • the body is Add/Sub/Mul/Compare/Conditional over locals, params, integer
    literals, exactly-representable number literals, and self-calls
    (i64s_expr, same file, line 278).

Nothing in that rule proves the arguments are integers, and nothing bounds any
intermediate. So both halves of the contract are unchecked.

Repro

// probe.ts
function grow(n: number, acc: number): number {
  return n === 0 ? acc : grow(n - 1, acc * 3 + 1);
}
console.log("grow:", grow(40, 1));

function frac(n: number, acc: number): number {
  return n === 0 ? acc : frac(n - 1, acc * 2);
}
console.log("frac:", frac(3, 0.5));
$ node --experimental-strip-types probe.ts     # 26.5.1
grow: 18236498188585394000
frac: 4

$ perry probe.ts -o probe && ./probe           # main @ c9cd73ba5, aarch64 macOS
grow: -210245885124158400                      # WRONG — i64 overflow wraparound
frac: 0                                        # WRONG — fptosi truncated 0.5 to 0

Two distinct failures:

  1. grow — the accumulator passes 2^53 and then 2^63. JS rounds each *
    and + to the nearest double; the i64 body computes exactly and then wraps
    at 2^63, so the sign flips. (Below 2^63 but above 2^53 the answer is merely
    wrong rather than negative — the number arithmetic in a local is evaluated past double precision — (x * 1103515245 + 12345) & 0x7fffffff diverges from Node #7232 divergence, in i64 form.)
  2. fracfrac(3, 0.5) is 0.5 * 2 * 2 * 2 = 4 in JS. The wrapper's
    fptosi double %arg to i64 truncates 0.5 to 0 on entry, so the whole
    computation runs on the wrong value. A number param is a double; nothing
    here proves otherwise.

The emitted IR shows both directly:

define double @perry_fn_probe_ts__frac(double %arg0, double %arg1) alwaysinline {
  %r1 = fptosi double %arg0 to i64          ; 0.5 -> 0
  %r2 = fptosi double %arg1 to i64
  %r3 = call i64 @perry_fn_probe_ts__frac_i64(i64 %r1, i64 %r2)
  %r4 = sitofp i64 %r3 to double
  ret double %r4
}

Why it has stayed quiet

The specialization is alwaysinline and non-recursive call sites get inlined at
HIR level before it matters — function mulAdd(x, y) { return x * y + 1 } called
straight-line emits an fmul and the _i64 body is dead-stripped. Self-recursion
is what makes the specialized body actually reachable, which is also the case the
pass exists for (the doc comment says "pure numeric recursive fn ... like
fibonacci"). Fibonacci's values are small and integral, so the motivating example
never exercises either hole.

Pre-dates #7232's fix — reproduced identically at c9cd73ba5 and on the #7232
branch, which touches a different pass (expr/i32_fast_path.rs).

The invariant it needs

The same one #7232's fix states, one width up:

Integer arithmetic may stand in for a JS number computation only while every
intermediate is exactly representable as a double (|v| <= 2^53) — and only
when every input is provably an integer to begin with.

Neither is currently proven. A fix has to either (a) prove both — integer-valued
params (there is already collectors/integer_locals.rs and #7122's canonical-i32
machinery to build on) plus a magnitude bound on the body, reusing
i32_chain_magnitude_bits's composition at the i64/2^53 ceiling — or (b) delete
the pass, since per CLAUDE.md's kill-policy an unproven mode that nothing
exercises is a decision that has not been made. Worth measuring what it actually
buys on benchmarks/suite/05_fibonacci.ts before choosing.

Suggested acceptance

A parity case with a self-recursive accumulator that crosses 2^53, one that
crosses 2^63, and one called with a fractional argument — so a fix cannot pass
by handling only the overflow half.

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