Skip to content

jit: apply every elapsed counter-decay interval, and drain it before every table access - #1207

Merged
youknowone merged 2 commits into
mainfrom
perf-bridge
Aug 14, 2026
Merged

jit: apply every elapsed counter-decay interval, and drain it before every table access#1207
youknowone merged 2 commits into
mainfrom
perf-bridge

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Follow-up to the #1188 review. Two review findings on JitCounter's deferred decay turned out to be the same defect, so they are fixed together.

The defect

counter.py:104-121 decays inside the minor collection, so a value written after a collection is never decayed by it. pyre defers the decay to the next table access, and the deferral was visible in two ways:

  • Only one interval was ever applied. A bool sentinel recorded "a decay is pending", so 32 collections and 320 collections cost the same single decay_all_counters(). Upstream applies one per 32-collection interval.
  • Counter boosts were retro-decayed. change_current_fraction writes entry.times[0] = 0.98trace_next_iteration (warmstate.py:617-619) uses it to schedule tracing on the next iteration. It did not drain the pending decay first, so the next tick decayed the fresh 0.98 down to 0.9408. Upstream cannot do this: the decay already happened at collection time.

The fix

DECAY_PENDING: AtomicBool becomes DECAY_GENERATION: AtomicUsize, and each JitCounter tracks the last generation it applied. apply_pending_decay steps the generation forward one interval at a time, decaying once per step, and runs first in tick, change_current_fraction, reset, reset_all and set_decay — the last before the multiplier is replaced, so the pending intervals use the multiplier that was in force.

The generation is per-counter rather than global because JIT_DRIVER in eval.rs is thread-local, so each mutator thread has its own JitCounter; one thread's tick must not consume another's decay.

Stepping one interval at a time is deliberate: decay_all_counters rounds back to f32 after every step, so mult^N applied once differs in the last bits from N applications, and upstream performs N. would_tick_fire is &self and cannot drain, so it decay-adjusts its read the same way — the predicate must not disagree with the tick that drains the same generations.

The deferral itself stays. driver_pair() mints a &'static mut JitDriverPair from the JIT_DRIVER cell, and a minor collection can be triggered by an allocation the metainterp makes while already holding one, so decaying from inside the collector would alias it. The counter.rs comment that described this as a borrow the GC does not hold is corrected.

Baselines

Counters now cool at the upstream rate, so three fixtures settle differently. Re-recorded from a full local gate:

backend fixture change
dynasm + cranelift inline_chain_depth_typeflip bridges_compiled 19 → 18, guard_failures 3820 → 3681
wasm exception_traceback_loop_forms guard_failures 810 → 811

loops_compiled is unchanged at 6, and both native backends move to the same pair — the compile shape is the same, the counter schedule is not.

check.py's baseline-resolution note

It claimed a fresh local build reports the ubuntu str_fstring pair. It reports the macos-latest pair: two full local gates pass against the plain .darwin overlays holding dynasm 659 and cranelift 658. The darwin GitHub-runner overlay for cranelift held the same value as the platform-wide one, so it could never be reached and is removed.

Verification

Full local darwin gate at the fix's base: dynasm 423/425, cranelift 424/425, wasm 417/418, with the three rows above accounting for the jit-stats failures. The remaining dynasm failure is the intermittent minor_remembered_set GC panic, which fires on a different bench each full run and is unrelated. cargo test -p majit-trace 64 passed.

The gate ran before the two comment corrections, which are comment-only, and the branch has since been rebased onto current main.

authored by Claude

…apply every elapsed interval

counter.py:104-121 calls `decay_all_counters()` inside the 32nd minor
collection. pyre defers it, and the deferral diverged in two ways.

`tick` applied one decay however many 32-collection intervals had elapsed;
it now applies one per interval, stepping the generation forward one at a
time so each step rounds back to f32 the way `decay_all_counters` does.

`change_current_fraction`, `reset`, `reset_all` and `set_decay` did not drain
at all, so a pending decay landed on values written after the collection that
scheduled it: `_trace_next_iteration` (warmstate.py:617-619) writes 0.98 and
the next `tick` turned it into 0.98 * 0.96. All four now drain first, and
`set_decay` drains before it replaces the multiplier.

`would_tick_fire` takes `&self` and answers from a decay-adjusted read,
stepping the same number of generations so it agrees with the `tick` that
would drain them.

Assisted-by: Claude
…ct two stale notes

The decay drain lands one more decay interval per counter than before, so
two synthetic fixtures settle on different counters:

  inline_chain_depth_typeflip  dynasm+cranelift  bridges_compiled 19 -> 18,
                                                 guard_failures 3820 -> 3681
  exception_traceback_loop_forms  wasm           guard_failures 810 -> 811

Re-recorded from a full local gate at 4a391053e79 (dynasm 423/425,
cranelift 424/425, wasm 417/418; the remaining dynasm failure is the
intermittent minor_remembered_set GC panic, which fires on a different
bench each full run).

check.py's baseline-resolution note claimed a fresh local build reports
the ubuntu str_fstring pair. It reports the macos-latest pair: two full
local gates pass against the plain `.darwin` overlays holding dynasm 659
and cranelift 658. The darwin GitHub-runner overlay for cranelift held
the same value as the platform-wide one, so it could never be reached
and is removed.

counter.rs described the deferred decay as avoiding a borrow the GC does
not hold. The accessor for the JIT_DRIVER cell mints a
`&'static mut JitDriverPair`, and a minor collection can be triggered by
an allocation the metainterp makes while already holding one, so
decaying from inside the collector would alias it.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 24c2b690-cc62-4458-aa3a-316cfb87bc1a

📥 Commits

Reviewing files that changed from the base of the PR and between 3233b3c and 521efc3.

📒 Files selected for processing (6)
  • majit/majit-trace/src/counter.rs
  • pyre/bench/synth/exception_traceback_loop_forms.wasm.jitstats
  • pyre/bench/synth/inline_chain_depth_typeflip.cranelift.jitstats
  • pyre/bench/synth/inline_chain_depth_typeflip.dynasm.jitstats
  • pyre/bench/synth/str_fstring.cranelift.darwin.github-actions.jitstats
  • pyre/check.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 521efc3950

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +291 to +293
while self.last_decay_generation != generation {
self.last_decay_generation = self.last_decay_generation.wrapping_add(1);
self.decay_all_counters();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the work spent replaying decay generations

When one JIT thread remains idle while another thread performs many minor collections, the process-global generation can advance arbitrarily far beyond this counter's per-thread generation. Its next table access then performs one full 2,048-entry scan per elapsed interval in a single foreground operation; with decay=0, every scan is a no-op, and after values have underflowed to zero further scans are likewise unnecessary. Long-running allocation-heavy processes can therefore incur an unbounded pause when an idle thread resumes. Preserve the per-interval rounding while skipping intervals once they cannot change the table (and directly consume the backlog for multiplier 1.0 or an all-zero table).

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 521efc3).
Updated: 2026-08-13T23:27:05.472Z

Files in the reviewed diff
majit/majit-trace/src/counter.rs
pyre/check.py

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

3. Pre-existing mismatches (already present before this patch)

  • majit/majit-trace/src/counter.rs:280 ↔ rpython/jit/metainterp/counter.py:286-293 — Rust computes each decay as f64 multiplication then casts the result to f32; PyPy first casts decay_by_mult to a C float and performs float multiplication. The decay bits, and therefore eventual compilation timing, can differ. This line predates the patch; the patch merely causes all pending generations to be applied rather than saturating at one.

4. Structural adaptations

  • majit/majit-trace/src/counter.rs:197,289-294 ↔ rpython/jit/metainterp/counter.py:109-116 — PyPy’s minor-GC callback closes over its single JitCounter and decays it synchronously. Pyre records a process-global generation and drains it before table accesses because reaching the thread-local Rust driver/counter during collection would create aliased mutable access. This is a Rust ownership/GC-callback adaptation; the new drain-before-read/write ordering preserves the upstream sequential timing.
  • majit/majit-trace/src/counter.rs:21-32,68-70 ↔ rpython/jit/metainterp/counter.py:105-120 — PyPy’s callback state is owned by the counter/translator; Pyre shares the collection generation across a process-global collector while retaining one counter per thread-local JIT driver. This is a free-threading adaptation, not a PyPy-parity regression.

@youknowone
youknowone merged commit 08f1b39 into main Aug 14, 2026
13 of 17 checks passed
@youknowone
youknowone deleted the perf-bridge branch August 14, 2026 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant