Skip to content

jit: #956's quasi-immut revalidation uses a value comparison where upstream tests qmut identity — InvalidLoop storm, 3.7x slower, main is red #964

Description

@youknowone

origin/main (f3bddb31f28) fails pyre/check.py --backend dynasm:

FAIL dynasm synth/exception_reraise_tb_depth_jitstress
     jit-stats regression: loops_aborted 0 -> 1198, loops_compiled 805 -> 305

Bisected to #956 (28880890a7e). It is a real slowdown, not a shape change.

Bisect

Every point rebuilt with a full LLBC regeneration (python3 pyre/scripts/extract-llbc.py), since #956 touches pyre-jit / pyre-interpreter / pyre-object.

commit loops_compiled bridges_compiled loops_aborted guard_failures
e6e2b25a715 (#930) — #956's parent 805 1 0 1798
28880890a7e (#956) 305 0 1198 1199

A/B

Interleaved, alternating binaries, min-of-5, user+sys CPU, same host, exception_reraise_tb_depth_jitstress:

build CPU
e6e2b25a715 0.380s
28880890a7e 1.410s

3.7x slower. The _hot twin is unaffected (4 / 9 / 0 / 1803 on both): the regression only appears under pypyjit.set_param("threshold=1,function_threshold=1"), i.e. when tracing is aggressive.

Mechanism

#956 added OptHeap::quasiimmut_field_still_valid (majit/majit-metainterp/src/optimizeopt/heap.rs), which gates InvalidLoop("quasi immutable field changed during tracing") at the QuasiimmutField arm.

The heap.py:794-796 precondition is ported correctly — a struct that did not fold to a constant returns "still valid". The divergence is the test itself.

Upstream rpython/jit/metainterp/quasiimmut.py:146-158:

def is_still_valid_for(self, structconst):
    assert self.struct
    if self.struct != structconst.getref_base():
        return False
    cpu = self.cpu
    qmut = get_current_qmut_instance(cpu, self.struct, self.mutatefielddescr)
    if qmut is not self.qmut:
        return False
    else:
        currentbox = self.get_current_constant_fieldvalue()
        assert self.constantfieldbox.same_constant(currentbox)   # <- an assert
        return True

The decision is instance identity — invalidation nulls the hidden mutate_* field, so get_current_qmut_instance hands back a fresh object and is not fires. The field-value comparison is only the assertion backing that up, and it is unreachable when the identity test already returned False.

pyre has no per-read QuasiImmutDescr to hang that identity on, so #956 substitutes the value comparison for the test — its own doc comment states this. That is strictly stronger than upstream: it reports "changed" whenever the value merely differs, not only when an invalidation actually occurred.

Consequence chain (measured)

Splitting ct_entry_bridge_failed — which today counts five unrelated compile_entry_bridge exits as one number — attributes it exactly:

  • ceb_entered = 2396, ceb_invalidloop = 2396, all other exits 0

So every interpreter entry bridge is discarded in optimize_bridge, before any backend is involved. With no front door into the compiled loop, the interpreter re-reaches the back-edge decision every iteration (caro_backedge 1202 vs 6 on the parent), and compile_loop gives the retrace up at its own has_compiled_targets (pyjitpl.rs:5576-5591, upstream pyjitpl.py:3185-3189) → cl_hct_giveup = loops_aborted = 1198 ABORT_BAD_LOOP.

The abort is orthodox; the missing entry bridge is the defect.

Why no CI caught it

A semantic merge conflict. #947 recorded the 805/0 baseline against e6e2b25a715, where it was correct. #956 and #929 merged between #947's CI run and #947's merge:

git merge-base --is-ancestor 28880890a7e a115ce65a6e   # CI-tested branch tip -> NO
git merge-base --is-ancestor 28880890a7e 44e0c9ccb9a   # #947 merge on main  -> YES

GitHub validates the PR merge ref at PR time, not at merge time, so the combination was never built by anyone. Both PRs were individually green.

Reproduce

git checkout f3bddb31f28
python3 pyre/scripts/extract-llbc.py
cargo build --release -p pyrex --bin pyre-dynasm --no-default-features --features dynasm
MAJIT_STATS=1 ./target/release/pyre-dynasm pyre/bench/synth/exception_reraise_tb_depth_jitstress.py 2>&1 | grep loops_compiled

Expected loops_compiled=805 … loops_aborted=0; actual loops_compiled=305 … loops_aborted=1198.

reported by Claude

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions