You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Polymorphic attr_readers end up doing slow-path IV reads.
For example this code:
classAattr_reader:namedefinitialize@name="a"endendclassBattr_reader:namedefinitialize@name="a"endenddefget_nameobj;obj.name;endobjs=[A.new,B.new]n=03000.times{objs.each{ |o| n += 1ifget_name(o)}}
Details
ZJIT: add --zjit-trace-fallbacks to trace JIT/interpreter execution
Emit Perfetto duration slices for time spent in JIT code versus the
interpreter, so a trace shows where a workload's time goes across the
JIT/interpreter boundary. The codegen plants hooks at the boundary
crossings:
the interpreter->JIT entry (gen_entry_point, interpreter entry only)
each send fallback to the interpreter (rb_vm_send and friends),
bracketed by begin/end calls around the C call
the JIT exit trampoline (the common funnel for side exits)
These call into stats.rs helpers that keep at most one execution span
open at a time (depth <= 1), so slices never overlap: every transition
ends the current span before beginning the next. Recording is limited to
single-Ractor mode, where the GVL serializes JIT execution and tracer
access is safe.
Three things are deliberately not fully accounted for, and a trace
should be read with them in mind:
The "interpreter" spans are really fallback spans. They are only
opened at send-fallback call sites, so they measure interpreter time
reached via a JIT fallback, not all interpreter execution. The slice
name is kept constant ("interpreter") for easy filtering; the
specific fallback kind (e.g. "invokesuper") rides along in the kind
argument.
Interpreter time that never enters JIT code is unaccounted for. With
no JIT entry there is no span to open, so top-level interpreter
execution that never reaches a compiled method produces no slices at
all.
jit->fallback->jit nesting leaves gaps. When a fallback's interpreter
work itself calls a compiled method, that inner JIT entry ends the
interpreter span and the inner exit ends the JIT span; the remaining
interpreter time before control returns to the original fallback site
is left with no open span. So a jit->fallback->jit sequence can have
unaccounted-for portions.
WITH shared_sq_0 AS (
SELECT*FROM thread_or_process_slice
),
shared_sq_1014 AS (
SELECT*FROM shared_sq_0
WHERE name ='interpreter'
),
shared_sq_1015 AS (
SELECT id AS id, ts AS ts, dur AS dur, name AS name, track_id AS track_id, track_name AS track_name, process_name AS process_name, upid AS upid, pid AS pid, thread_name AS thread_name, utid AS utid, tid AS tid, depth AS depth, parent_id AS parent_id, category AS category, arg_set_id AS arg_set_id, extract_arg(arg_set_id, 'rb_src_loc') AS rb_src_loc, extract_arg(arg_set_id, 'kind') AS kind
FROM shared_sq_1014
),
total_dur AS ( SELECTSUM(dur) as total_dur FROM shared_sq_1015 )
SELECT kind, rb_src_loc, SUM(dur) as sum_dur, CAST(SUM(dur) AS FLOAT) / total_dur *100as percentage
FROM shared_sq_1015 JOIN total_dur
GROUP BY kind, rb_src_loc
ORDER BY sum_dur DESCLIMIT20
I got:
kind
rb_src_loc
sum_dur
percentage
invokeblock
each@internal:array:222
3559903556
11.057846
invokeblock
map@internal:array:240
1676811762
5.208547
send
_read_attribute@/Users/alan/.g
1429947740
4.441733
send_without_block
hash@/Users/alan/.gem/ruby/dev
1016660559
3.157972
send_without_block
build_arel@/Users/alan/.gem/ru
370303559
1.150245
send_without_block
visit@/Users/alan/.gem/ruby/de
347445947
1.079244
send
mon_synchronize@/Users/alan/.r
333434204
1.035720
send_without_block
attribute_aliases@/Users/alan/
294062384
0.913423
send_without_block
to_sql_and_binds@/Users/alan/.
293645491
0.912128
send_without_block
_reflections@/Users/alan/.gem/
286159398
0.888874
send_without_block
for@/Users/alan/.gem/ruby/dev/
282288717
0.876851
send_without_block
association@/Users/alan/.gem/r
279158587
0.867128
send_without_block
new!@/Users/alan/.gem/ruby/dev
262693720
0.815985
send_without_block
block in inspect@/Users/alan/.
260729278
0.809883
send_without_block
block in handler_for@/Users/al
258343982
0.802473
invokeblock
select@internal:array:266
250344457
0.777625
send
_run@/Users/alan/.gem/ruby/dev
236843425
0.735688
send_without_block
spawn@/Users/alan/.gem/ruby/de
219825515
0.682827
send_without_block
method_missing@/Users/alan/.ge
219410725
0.681538
send_without_block
block in initialize@/Users/ala
212540646
0.660198
So, there is a few clear major contributors and a long tail. Tracing was done on 10 iterations of lobsters in ruby-bench
When running with the inliner enabled I've observed the number of dynamic ivar reads jumps 69% and ivar writes 74%. There's no side-exit involved so Perfetto can't really help. Instrumenting a build and tracing lobsters, it looks like ActiveRecord and ActionView are the largest triggers. However, I've managed to demonstrate the problem with a far smaller example:
I applied ruby#17455 to get finer-grained detail on what the source of the dynamic ivar reads is. Running the example above, I see ~1 M dynamic ivar reads that are all no_profile cases. When I enable inlining (--zjit-inline-threshold=30), I see the dynamic ivar read count jump to 2 M (also all no_profile). So, there are two problems I wanted to dig into: 1) why are there so many no_profile cases, and 2) why does inlining cause that count to double?
I think the problem is that a polymorphic method on a hot path can hit the compilation threshold before callees even start profiling. If I run the above with --zjit-num-profiles=15, both dynamic_getivar_count and the no_profile fallback cut in half. Running with --zjit-num-profiles=25, the dynamic_getivar_count goes to 0.
The problem scales with the number of unique types we see at a call site, so adjusting --zjit-num-profiles isn't a viable solution to this problem. Rails has some hot paths, such as template rendering, where a dispatcher is going to see tons of unique types (e.g., the @output_buffer). The lobsters benchmark trips over this in a few places.
Separately, there's a bug in inlining that will count the same no_profile read multiple times. E.g., in this case ZJIT stats show that there were 2 M dynamic ivar reads with inlining enabled and 1 M without inlining. But, that doesn't make sense because there are only 1 M ivar reads in total. The culprit is the optimize loop. Each pass through the loop will run optimize_getivar on the accumulated function. That runs RPO on the result of inlining and for any non-profiled GetIvar it'll push an IncrCounter for getivar_fallback_not_monomorphic. If we inline we're guaranteed to run through the optimize loop at least two times¹. Since there's no marker indicating a given instruction has already been accounted for, we just push a new IncrCounter. I suspect this is a problem in other areas as well.
So, it turns out there's a problem with dynamic ivar accesses in lobsters irrespective of inlining. The huge jump in dynamic ivar access counts, however, isn't a major regression -- we're just counting events incorrectly.
¹ -- Not really guaranteed. Someone could set --zjit-inline-max-iterations to prevent more than one optimization pass, but that's not the default and not expected in typical use.
Activity
tenderworks commentedon Jun 23, 2026
Polymorphic attr_readers end up doing slow-path IV reads.
For example this code:
The HIR for the
get_namemethod looks like this:The call site is polymorphic (which is fine), but we end up with slow path
GetIvarinstructions.XrXr commentedon Jun 23, 2026
I made a jig to trace fallbacks to get a clearer picture of where the fallbacks are at the ruby level and how much time they take: ruby/ruby@master...Shopify:ruby:perfetto-trace-fallbacks
Details
ZJIT: add --zjit-trace-fallbacks to trace JIT/interpreter execution Emit Perfetto duration slices for time spent in JIT code versus the interpreter, so a trace shows where a workload's time goes across the JIT/interpreter boundary. The codegen plants hooks at the boundary crossings:bracketed by begin/end calls around the C call
These call into stats.rs helpers that keep at most one execution span
open at a time (depth <= 1), so slices never overlap: every transition
ends the current span before beginning the next. Recording is limited to
single-Ractor mode, where the GVL serializes JIT execution and tracer
access is safe.
Three things are deliberately not fully accounted for, and a trace
should be read with them in mind:
The "interpreter" spans are really fallback spans. They are only
opened at send-fallback call sites, so they measure interpreter time
reached via a JIT fallback, not all interpreter execution. The slice
name is kept constant ("interpreter") for easy filtering; the
specific fallback kind (e.g. "invokesuper") rides along in the
kindargument.
Interpreter time that never enters JIT code is unaccounted for. With
no JIT entry there is no span to open, so top-level interpreter
execution that never reaches a compiled method produces no slices at
all.
jit->fallback->jit nesting leaves gaps. When a fallback's interpreter
work itself calls a compiled method, that inner JIT entry ends the
interpreter span and the inner exit ends the JIT span; the remaining
interpreter time before control returns to the original fallback site
is left with no open span. So a jit->fallback->jit sequence can have
unaccounted-for portions.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Using the following query:
I got:
355990355611.05784616768117625.20854714299477404.44173310166605593.1579723703035591.1502453474459471.0792443334342041.0357202940623840.9134232936454910.9121282861593980.8888742822887170.8768512791585870.8671282626937200.8159852607292780.8098832583439820.8024732503444570.7776252368434250.7356882198255150.6828272194107250.6815382125406460.660198So, there is a few clear major contributors and a long tail. Tracing was done on 10 iterations of
lobstersin ruby-benchnirvdrum commentedon Jun 24, 2026
When running with the inliner enabled I've observed the number of dynamic ivar reads jumps 69% and ivar writes 74%. There's no side-exit involved so Perfetto can't really help. Instrumenting a build and tracing lobsters, it looks like ActiveRecord and ActionView are the largest triggers. However, I've managed to demonstrate the problem with a far smaller example:
I applied ruby#17455 to get finer-grained detail on what the source of the dynamic ivar reads is. Running the example above, I see ~1 M dynamic ivar reads that are all
no_profilecases. When I enable inlining (--zjit-inline-threshold=30), I see the dynamic ivar read count jump to 2 M (also allno_profile). So, there are two problems I wanted to dig into: 1) why are there so manyno_profilecases, and 2) why does inlining cause that count to double?I think the problem is that a polymorphic method on a hot path can hit the compilation threshold before callees even start profiling. If I run the above with
--zjit-num-profiles=15, bothdynamic_getivar_countand theno_profilefallback cut in half. Running with--zjit-num-profiles=25, thedynamic_getivar_countgoes to 0.The problem scales with the number of unique types we see at a call site, so adjusting
--zjit-num-profilesisn't a viable solution to this problem. Rails has some hot paths, such as template rendering, where a dispatcher is going to see tons of unique types (e.g., the@output_buffer). The lobsters benchmark trips over this in a few places.Separately, there's a bug in inlining that will count the same
no_profileread multiple times. E.g., in this case ZJIT stats show that there were 2 M dynamic ivar reads with inlining enabled and 1 M without inlining. But, that doesn't make sense because there are only 1 M ivar reads in total. The culprit is the optimize loop. Each pass through the loop will runoptimize_getivaron the accumulated function. That runs RPO on the result of inlining and for any non-profiledGetIvarit'll push anIncrCounterforgetivar_fallback_not_monomorphic. If we inline we're guaranteed to run through theoptimizeloop at least two times¹. Since there's no marker indicating a given instruction has already been accounted for, we just push a newIncrCounter. I suspect this is a problem in other areas as well.So, it turns out there's a problem with dynamic ivar accesses in lobsters irrespective of inlining. The huge jump in dynamic ivar access counts, however, isn't a major regression -- we're just counting events incorrectly.
¹ -- Not really guaranteed. Someone could set
--zjit-inline-max-iterationsto prevent more than one optimization pass, but that's not the default and not expected in typical use.tekknolagi commentedon Jun 24, 2026
@rwstauner I don't think you can compare
devbuild compile or runtimes. We do some extensive checking in dev buildstekknolagi commentedon Jun 24, 2026
@nirvdrum FYI I think we hit a problem in that code even without inlining, which is that we don't handle (polymorphic method x polymorphic IV) well