[Bug #22224] Ractors, YJIT: Skip re-reporting recorded EP escapes to the JITs - #18176
[Bug #22224] Ractors, YJIT: Skip re-reporting recorded EP escapes to the JITs#18176yaroslav wants to merge 5 commits into
Conversation
|
@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. |
…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
left a comment
There was a problem hiding this comment.
We were aware of this issue and I was going to take a look at it this week actually. Thank you for doing it! 😄
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).
cfa84f4 to
cebb702
Compare
|
@luke-gru thanks for the review! Force-pushed. |
|
This just needs approval from a member of the JIT team now. |
|
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.
|
@k0kubun please see my latest commit. Is this what you meant? |
Tracker: https://bugs.ruby-lang.org/issues/22224
See also: yaroslav/kino#6
Assisted by: Claude Fable 5.
Problem
vm_make_env_eachreports every EP escape to the JITs (rb_yjit_invalidate_ep_is_bp/rb_zjit_invalidate_no_ep_escape). Both functions take the VM lock throughrb_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'sno_ep_escape_iseqsentry is an empty set forever (an escaped iseq never registers new blocks, becausejit.ep_is_bp()returns false for it), and ZJIT'sep_escape_iseqs.insertand 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_recordedtorb_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_eachchecks 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 minirubyof this branch vs its merge base, 3 s per cell):--yjit--yjitBefore the patch,
--yjitwith 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).