Skip to content

[Bug #22224] Ractors, YJIT: Skip re-reporting recorded EP escapes to the JITs - #18176

Open
yaroslav wants to merge 5 commits into
ruby:masterfrom
yaroslav:yjit-ep-escape-recorded-flag
Open

[Bug #22224] Ractors, YJIT: Skip re-reporting recorded EP escapes to the JITs#18176
yaroslav wants to merge 5 commits into
ruby:masterfrom
yaroslav:yjit-ep-escape-recorded-flag

Conversation

@yaroslav

@yaroslav yaroslav commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Tracker: https://bugs.ruby-lang.org/issues/22224

See also: yaroslav/kino#6

Assisted by: Claude Fable 5.

Problem

vm_make_env_each reports every EP escape to the JITs (rb_yjit_invalidate_ep_is_bp / rb_zjit_invalidate_no_ep_escape). Both functions take the VM lock through rb_jit_vm_lock_then_barrier, so every call stops all Ractors. But for a given iseq only the first report does real work. After that, YJIT's no_ep_escape_iseqs entry is an empty set forever (an escaped iseq never registers new blocks, because jit.ep_is_bp() returns false for it), and ZJIT's ep_escape_iseqs.insert and patch point invalidation also only do something the first time.

So with several Ractors, every Proc/lambda that materializes an environment stops the whole process, and throughput collapses on code that creates Procs: in the benchmark below, 8 Ractors with YJIT are ~150x slower than 1 Ractor. Rails 8.1 turns YJIT on by default, so a server that runs requests on Ractors hits this immediately. Profiles and Rails numbers are in Bug #22224.

Fix idea

Add a one-byte flag jit_ep_escape_recorded to rb_iseq_constant_body. Each JIT sets it with a small C helper inside its locked section, right after it records the escape. vm_make_env_each checks the flag before making the calls. For an already-escaped iseq the cost drops from lock + barrier + hash lookup to one byte check.

Benchmarks

Micro-benchmark from the bug report (one lambda per iteration, so every iteration materializes a frame environment; Apple M1 Pro, 10 cores, macOS, make miniruby of this branch vs its merge base, 3 s per cell):

ractors master --yjit patched --yjit master interp patched interp
1 6.89M iters/s 7.27M iters/s 4.66M 4.72M
4 0.14M 11.67M (83x) - -
8 0.04M 11.07M (277x) 10.02M 9.94M

Before the patch, --yjit with 4-8 Ractors was ~150x slower than with a single Ractor. After the patch it is faster than the interpreter with the same Ractor count, so YJIT helps again instead of hurting. The single Ractor case also gets ~6% (before, it paid for the lock even without contention).

@yaroslav

yaroslav commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@jhawthorn could you take a look? I've seen you committing Ractor-related stuff lately, that's why I am asking.

The issue is at https://bugs.ruby-lang.org/issues/22224

There is a failing test, but it seems to be a.. flake, or something that was recently introduced.

DDKatch added a commit to DDKatch/ractor-rails-shim-test-app that referenced this pull request Aug 3, 2026
…ri barriers (kino issue #6)

Adds the /posts_plain endpoint (limit/offset instead of Kaminari's
.page().per()) and a BENCH_YJIT_OFF env toggle (config.yjit = false) so both
YJIT states run from one codebase. Bench harness warms, measures, and
reports /posts_plain alongside /posts.

Reproduces both findings from kino issue #6 comment 5165496321:
  - config.yjit=false: /up 2,838 -> 12,930 rps (4.6x) under kino :ractor
  - .page() -> .limit/.offset: /posts_plain 3.7x faster than /posts (YJIT on)

New BENCHMARKS.md section 'YJIT and Kaminari method-table barriers' keeps
the stock-defaults headline table as the real-app baseline and adds an A/B
plus a full 9-scenario YJIT-off matrix. With both blockers sidestepped,
kino :ractor (-w5 -t1) leads puma clustered and falcon forked on /up
(13,329 vs 10,074/7,672), /posts_plain (3,604 vs 2,290/2,117), and POST
(2,567 vs 1,371/1,358) at ~4x lower memory.

Upstream: ruby#22224, ruby/ruby#18176

@luke-gru luke-gru left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We were aware of this issue and I was going to take a look at it this week actually. Thank you for doing it! 😄

Comment thread jit.c Outdated
Comment thread vm_core.h Outdated
Comment thread vm.c Outdated
Comment thread zjit/src/invariants.rs Outdated
Comment thread zjit/src/cruby.rs Outdated
Comment thread yjit/src/invariants.rs Outdated
Comment thread yjit/src/cruby.rs Outdated
vm_make_env_each reports every EP escape, and both
rb_yjit_invalidate_ep_is_bp and rb_zjit_invalidate_no_ep_escape take
the VM lock with a stop-all-Ractors barrier on every call. For a given
iseq only the first report does anything: afterwards YJIT's
no_ep_escape_iseqs entry is permanently an empty set and ZJIT's
ep_escape_iseqs insert is a no-op, so the barrier is paid for nothing.
Under multiple Ractors this serializes the whole process on every Proc
created from an already-escaped iseq: a workload creating one lambda
per iteration runs 150x slower on 8 Ractors than on 1 with YJIT
enabled.

Record the report in a jit_ep_escape_recorded flag on the iseq body,
set by the active JIT while it holds the VM lock, and skip the calls
in vm_make_env_each once it is set. The flag sits in existing padding
and the body is ZALLOC'd, so it starts false and dies with the iseq.
Setting it in the JIT rather than in the caller keeps mid-process
RubyVM::YJIT.enable correct: escapes reported while no JIT is
initialized are recorded by nobody and must not set the flag. The
unlocked read is benign: a stale false causes one redundant,
idempotent report.

On the micro-benchmark from the bug, --yjit at 8 Ractors goes from
0.04M to 11.07M iterations/s (277x).
@yaroslav
yaroslav force-pushed the yjit-ep-escape-recorded-flag branch from cfa84f4 to cebb702 Compare August 3, 2026 14:53
@yaroslav

yaroslav commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@luke-gru thanks for the review! Force-pushed.

@yaroslav
yaroslav requested a review from luke-gru August 3, 2026 20:16
@luke-gru

luke-gru commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This just needs approval from a member of the JIT team now.

@luke-gru
luke-gru requested review from XrXr and k0kubun August 3, 2026 20:24
@k0kubun k0kubun changed the title [22224] Ractors, YJIT: Skip re-reporting recorded EP escapes to the JITs [Bug #22224] Ractors, YJIT: Skip re-reporting recorded EP escapes to the JITs Aug 4, 2026
@k0kubun

k0kubun commented Aug 4, 2026

Copy link
Copy Markdown
Member

This is a per-ISEQ flag for both YJIT and ZJIT. Now that you have it on the ISEQ itself, we should be able to stop writing a copy of it on the JITs side to save some memory. Can you consider de-duplicating the storage of this flag?

The jit_ep_escape_recorded flag introduced in the previous commit is
now the JITs' only record of an EP escape: YJIT's no_ep_escape_iseqs
keeps only iseqs with live assumptions (the empty-set escape markers
are gone), and ZJIT's ep_escape_iseqs set is removed together with its
update_ep_escape_iseqs compaction pass, since the flag lives in the
iseq body, which the GC does not move. Both JITs read the flag back at
compile time through a new rb_jit_iseq_ep_escape_recorded_p helper.
@yaroslav

yaroslav commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@k0kubun please see my latest commit. Is this what you meant?

@k0kubun k0kubun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, this one looks good to me.

I'll leave it to @XrXr to give further feedback.

Comment thread yjit/src/invariants.rs Outdated
Comment thread jit.c Outdated
@XrXr
XrXr enabled auto-merge (squash) August 5, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

jit YJIT, ZJIT, ...

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants