Bug #22396
openUsing rb_postponed_job_trigger causes YJIT/ZJIT compiled methods side exits
Description
Hey 👋. So at Datadog, while working on the datadog ruby profiler we've uncovered something that may-or-may-not be a bug, but really surprised us so I wanted to open up a ticket to discuss.
Our profiler uses a design where we interrupt Ruby using a SIGPROF signal, collect frames from inside the signal handler (using our homegrown copy of rb_profile_frames) and then use rb_postponed_job_trigger to get to a safepoint where we can process the data.
Unfortunately, we weren't aware of rb_postponed_job_trigger causing JIT side exits! For small methods this is hard to measure, but with a reasonably long-running loop, this becomes measurable.
I've built (with AI friend) a reproducer for this. Out of curiosity I also added support for stackprof and rperf which also use a similar design (vernier doesn't! :D) to discard it being something else in the Datadog profiler design.
Here's how it looks with just interpreter:
$ PROFILER=datadog be ruby --disable-yjit --disable-zjit profiling_jit.rb
Profiler: datadog
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux]
Calculating -------------------------------------
Warmup 21.511 (± 4.6%) i/s (46.49 ms/i) - 108.000 in 5.020785s
Calculating -------------------------------------
Profiler off 21.974 (± 4.6%) i/s (45.51 ms/i) - 110.000 in 5.005813s
Calculating -------------------------------------
Profiler on 20.865 (± 4.8%) i/s (47.93 ms/i) - 105.000 in 5.032376s
Calculating -------------------------------------
Profiler off again 21.455 (± 9.3%) i/s (46.61 ms/i) - 108.000 in 5.033813s
Comparison:
Profiler off: 22.0 i/s
Profiler off again: 21.5 i/s - same-ish: difference falls within error
Profiler on: 20.9 i/s - same-ish: difference falls within error
Here's how it looks with YJIT (ZJIT shows similar results):
$ PROFILER=datadog bundle exec ruby --yjit --yjit-call-threshold=1 profiling_jit.rb
Profiler: datadog
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [x86_64-linux]
Calculating -------------------------------------
Warmup 192.433 (± 5.7%) i/s (5.20 ms/i) - 963.000 in 5.004347s
Calculating -------------------------------------
Profiler off 192.959 (± 5.2%) i/s (5.18 ms/i) - 965.000 in 5.001050s
Calculating -------------------------------------
Profiler on 50.250 (± Inf%) i/s (19.90 ms/i) - 253.000 in 5.034844s
Calculating -------------------------------------
Profiler off again 192.072 (± 6.2%) i/s (5.21 ms/i) - 961.000 in 5.003330s
Comparison:
Profiler off: 193.0 i/s
Profiler off again: 192.1 i/s - same-ish: difference falls within error
Profiler on: 50.2 i/s - 3.84x slower
...and here's with YJIT stats to show the side exits count going up:
$ PROFILER=datadog bundle exec ruby --yjit --yjit-call-threshold=1 --yjit-stats=quiet profiling_jit.rb
Profiler: datadog
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [x86_64-linux]
Calculating -------------------------------------
Warmup 2.246 (± 0.0%) i/s (445.30 ms/i) - 12.000 in 5.343558s
Side exits: 2
Calculating -------------------------------------
Profiler off 2.512 (± 0.0%) i/s (398.08 ms/i) - 13.000 in 5.175023s
Side exits: 1
Calculating -------------------------------------
Profiler on 19.632 (± 5.1%) i/s (50.94 ms/i) - 99.000 in 5.042706s
Side exits: 101
Calculating -------------------------------------
Profiler off again 2.505 (± 0.0%) i/s (399.12 ms/i) - 13.000 in 5.188599s
Side exits: 1
Comparison:
Profiler on: 19.6 i/s
Profiler off: 2.5 i/s - 7.82x slower
Profiler off again: 2.5 i/s - 7.84x slower
(Funnily enough, because --yjit-stats=quiet makes the JITted code slower than interpreter, the fact that the profiler forces the code to go back to the interpreter means the code speeds up since interpreter goes vroom)
Thus my question is -- is this "rb_postponed_job_trigger always triggers side exit" behavior something that we'll need to work with (possibly adopting a design closer to vernier), or is this something that could be fixed in YJIT or ZJIT to avoid the footgun?
Some extra data points:
- stackprof + master + yjit:
$ PROFILER=stackprof ruby --yjit --yjit-call-threshold=1 profiling_jit.rb
Profiler: stackprof
ruby 4.1.0dev (2026-09-28T09:36:41Z master d939272e7d) +YJIT +PRISM [x86_64-linux]
Calculating -------------------------------------
Warmup 190.831 (± 7.9%) i/s (5.24 ms/i) - 954.000 in 4.999178s
Calculating -------------------------------------
Profiler off 192.096 (± 6.2%) i/s (5.21 ms/i) - 961.000 in 5.002707s
Calculating -------------------------------------
Profiler on 2.621 (± 0.0%) i/s (381.54 ms/i) - 14.000 in 5.341527s
Calculating -------------------------------------
Profiler off again 187.173 (± 8.5%) i/s (5.34 ms/i) - 936.000 in 5.000721s
Comparison:
Profiler off: 192.1 i/s
Profiler off again: 187.2 i/s - same-ish: difference falls within error
Profiler on: 2.6 i/s - 73.29x slower
- stackprof + master + zjit:
$ PROFILER=stackprof ruby --zjit --zjit-call-threshold=2 profiling_jit.rb
Profiler: stackprof
ruby 4.1.0dev (2026-09-28T09:36:41Z master d939272e7d) +ZJIT +PRISM [x86_64-linux]
Calculating -------------------------------------
Warmup 178.615 (±11.2%) i/s (5.60 ms/i) - 893.000 in 4.999570s
Calculating -------------------------------------
Profiler off 196.171 (± 3.6%) i/s (5.10 ms/i) - 981.000 in 5.000751s
Calculating -------------------------------------
Profiler on 2.584 (± 0.0%) i/s (386.97 ms/i) - 13.000 in 5.030555s
Calculating -------------------------------------
Profiler off again 192.681 (± 6.2%) i/s (5.19 ms/i) - 964.000 in 5.003085s
Comparison:
Profiler off: 196.2 i/s
Profiler off again: 192.7 i/s - same-ish: difference falls within error
Profiler on: 2.6 i/s - 75.91x slower
Files
No data to display