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
origin/main(f3bddb31f28) failspyre/check.py --backend dynasm: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 touchespyre-jit/pyre-interpreter/pyre-object.loops_compiledbridges_compiledloops_abortedguard_failurese6e2b25a715(#930) — #956's parent28880890a7e(#956)A/B
Interleaved, alternating binaries, min-of-5, user+sys CPU, same host,
exception_reraise_tb_depth_jitstress:e6e2b25a71528880890a7e3.7x slower. The
_hottwin is unaffected (4 / 9 / 0 / 1803on both): the regression only appears underpypyjit.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 gatesInvalidLoop("quasi immutable field changed during tracing")at theQuasiimmutFieldarm.The
heap.py:794-796precondition 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:The decision is instance identity — invalidation nulls the hidden
mutate_*field, soget_current_qmut_instancehands back a fresh object andis notfires. The field-value comparison is only the assertion backing that up, and it is unreachable when the identity test already returnedFalse.pyre has no per-read
QuasiImmutDescrto 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 unrelatedcompile_entry_bridgeexits as one number — attributes it exactly:ceb_entered = 2396,ceb_invalidloop = 2396, all other exits0So 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_backedge1202 vs 6 on the parent), andcompile_loopgives the retrace up at its ownhas_compiled_targets(pyjitpl.rs:5576-5591, upstreampyjitpl.py:3185-3189) →cl_hct_giveup = loops_aborted = 1198ABORT_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/0baseline againste6e2b25a715, where it was correct. #956 and #929 merged between #947's CI run and #947's merge: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
Expected
loops_compiled=805 … loops_aborted=0; actualloops_compiled=305 … loops_aborted=1198.— reported by Claude