Skip to content

Silent wrong answers: hot relational comparison with an object operand returns false after ~726 iterations (default GC config) #8393

Description

@proggeramlug

Silent wrong answers: relational comparison with an object operand, after ~726 iterations, in the DEFAULT configuration

A hot relational comparison whose left operand is an object (e.g. an array) starts
returning false unconditionally after roughly 11,600 executions. No crash, no
diagnostic — the program just computes wrong booleans from then on.

Found while profiling iso_miss/interp for #8384 follow-up work. Not caused by any
recent PR: it reproduces on clean main at d1d992966.

Reproducer (self-checking, no expectations to argue about)

Each iteration computes the same 16 comparisons and packs them into a bitmask. A correct
engine returns an identical mask every iteration; this reports the first iteration whose
mask differs from iteration 0.

const vals: unknown[] = ["0", []];
const ops = ["<", ">", "<=", ">="];
const REPS = 4000;
let mask0 = -1; let firstDiffRep = -1; let diffMask = -1;
for (let r = 0; r < REPS; r++) {
  let mask = 0; let bit = 0;
  for (let i = 0; i < vals.length; i++)
    for (let j = 0; j < vals.length; j++)
      for (let k = 0; k < ops.length; k++) {
        const a = vals[i] as never, b = vals[j] as never;
        const v = k === 0 ? (a < b) : k === 1 ? (a > b) : k === 2 ? (a <= b) : (a >= b);
        if (v) mask = mask | (1 << bit);
        bit = bit + 1;
      }
  if (r === 0) mask0 = mask;
  else if (mask !== mask0 && firstDiffRep < 0) { firstDiffRep = r; diffMask = mask; }
}
console.log("mask0=" + mask0 + " firstDiffRep=" + firstDiffRep + " diffMask=" + diffMask);
node  26.5.1 : mask0=50604 firstDiffRep=-1  diffMask=-1     <- stable for all 4000 reps
perry d1d9929: mask0=50604 firstDiffRep=726 diffMask=172    <- correct for 725 reps, then wrong

Which comparisons break

correct 50604 = 1100010110101100
after     172 = 0000000010101100
bit comparison correct after
8 [] < "0" true false
10 [] <= "0" true false
14 [] <= [] true false
15 [] >= [] true false

Every flipped bit has the object on the LEFT, and every flip is true -> false.
Comparisons with the object on the right are unaffected. That is the signature of the
left operand's NaN-boxed pointer being consumed as a raw double: a pointer payload reads
as NaN, and NaN makes all four relational operators false.

It is the generational GC, and it needs write barriers

configuration result
default wrong at rep 726
PERRY_GEN_GC=0 correct, all 4000 reps
PERRY_WRITE_BARRIERS=0 correct, all 4000 reps
PERRY_GC_FORCE_EVACUATE=1 wrong at rep 726 (unchanged)

Disabling either the generational collector or write barriers makes it vanish, so this
lives on the generational/remembered-set path — it is not a codegen tier-up. Note
PERRY_NO_AUTO_OPTIMIZE=1 was set throughout, so the auto-optimize reoptimizer is not
involved either.

The from-space instruments are clean

Both report no offender while the wrong answer still occurs:

[gc-fromspace-scan clean] objects=6397 words=39250 missing_rewrites=0 dangling=0 owners=0
[gc-fromspace-protect] mode=ProtectPages retired_set=#0 blocks=11 bytes_protected=11534336

Per docs/src/internals/gc-rooting-invariant.md this pattern — reproducible, silent,
invisible to the from-space scan — points away from an unrooted register and toward a
cached/derived value or a missed remembered-set entry rather than a stale pointer the
scanner can see.

Why this matters more than the benchmark it came from

This is a silent data-corruption bug in the shipped default configuration. It produces
no crash and no diagnostic; a long-running program simply begins computing wrong booleans.
Any comparison-driven code — sorting, range checks, search — is exposed once the site is
hot enough, and object-vs-primitive relational comparison is ordinary JS.

The wider divergence it was found through: a 32-value x 4-operator matrix (4096 cases)
accumulated into a string diverges from Node on 218 cases, all with an array operand.
The identical matrix printed per-case diverges on zero — the bug only appears once the
site runs hot enough, which is why an ordinary conformance test would not catch it.

Reproducers: the snippet above, and the 4096-case matrix version.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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