Skip to content

Commit c408675

Browse files
committed
ZJIT: Call megamorphic ISEQ targets without leaving JIT code
The send class table made the *lookup* at a megamorphic site cheap, but the call itself still left JIT code: `rb_zjit_send_cached_without_block`, `vm_call_iseq_setup`'s generic argument setup, an interpreter frame push, the `setjmp` in `vm_exec`, and a trip through `jit_exec` and the entry trampoline -- all to arrive at code ZJIT had already compiled. On lobsters that is 4.4M calls a run, and instrumenting what they resolve to shows 45% of them are the same small shape: an ISEQ method with fixed arity, no locals beyond its parameters, and a compiled entry point sitting ready. ISEQ 2,488,687 of which simple, no extra locals, compiled: 2,065,410 CFUNC 743,217 IVAR 639,155 MISSING 478,689 OPTIMIZED 269,758 So probe the table inline and, for that shape, push the frame and call the callee from JIT code, the way SendDirect does for a statically known target. The entry point it calls is the *interpreter* one, `ISEQ_BODY(iseq)->jit_entry`: a JIT-to-JIT entry takes its arguments in C registers and its address only exists inside the compiler's patching machinery, while the interpreter entry reads its parameters back out of the frame -- which is exactly where a simple callee's arguments already are, because its local table is its parameter list and the caller's operand stack holds them in order. The frame push is then the whole calling convention, and the entry address is a field on the ISEQ. Correctness rests on re-deriving everything per call rather than caching it. The callcache validates itself against `CLASS_OF(recv)` and `METHOD_ENTRY_INVALIDATED`, the two checks `vm_cc_hit_p` makes, which is what covers redefinition, undef, aliasing, visibility changes, include/prepend, refinements and object death. `body->jit_entry` is loaded fresh, so an ISEQ whose code was reset -- by a TracePoint, a patch point, a recompile -- reads as null and takes the slow path. And because the path skips `vm_call_method`, the fill side only marks a target directly callable when that function would have let the call straight through: public, or private from an FCALL site, never protected. The slot grows a second word for that decision, holding the method entry rather than the ISEQ so JIT code can check it against the one the callcache produced. That makes the pair self-validating: a reader that catches another ractor's fill half-done sees a mismatch and searches, so neither word needs a barrier or an atomic. lobsters, 15 iterations: ccall rb_zjit_send_cached_without_block 4,410,274 -> 2,423,746 send_megamorphic_direct 0 -> 1,986,528 send_megamorphic 4,187,398 -> 4,187,397 `--zjit-disable-megamorphic-direct` turns the new path off for A/B runs. The wider slot doubles the tables' memory, 6.7MB to 13.3MB on lobsters; they are 98% empty at 512 slots, so `--zjit-send-cache-entries=256` gets that back with no measurable change in direct dispatches. [zjit/min port note] Uses asm.new_block() directly: the FrameCaches-inheriting JITState::new_block() wrapper the original called comes from an excluded commit (66ff4fd) and master has no per-LIR-block frame caches. state.rs picks up only gen_jit_entry_call_trampoline from the original's import list; the rest of that list belongs to excluded commits.
1 parent 8928b41 commit c408675

8 files changed

Lines changed: 1004 additions & 28 deletions

File tree

vm_insnhelper.c

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6312,6 +6312,70 @@ zjit_send_cache_cacheable_p(const struct rb_callcache *cc)
63126312
&& !vm_cc_refinement_p(cc);
63136313
}
63146314

6315+
/* The callee's method entry when a hit on `cc` is one that JIT code may enter
6316+
* with a direct call, or NULL.
6317+
*
6318+
* The inline dispatch path in gen_send_megamorphic_direct() reproduces exactly
6319+
* what vm_call_iseq_setup() does for the simplest possible callee, and nothing
6320+
* else, so the callee has to be one this list of conditions describes:
6321+
*
6322+
* - an ordinary ISEQ method, not a bmethod, alias, refinement or anything else
6323+
* with its own frame shape;
6324+
* - rb_simple_iseq_p(), i.e. no optional, rest, post, keyword or block
6325+
* parameters and not `...`-forwardable, so vm_callee_setup_arg() would take
6326+
* its no-op fast path and the frame is no taller than the local table;
6327+
* - arity exactly the site's argc, since the inline path cannot raise
6328+
* ArgumentError;
6329+
* - no locals beyond the parameters, so the frame's local area is exactly the
6330+
* arguments already sitting on the caller's stack: nothing to nil-fill, and
6331+
* the frame size is a compile-time constant at the call site;
6332+
* - a `stack_max` within ZJIT_MEGA_DIRECT_MAX_STACK, the bound the call site's
6333+
* stack overflow check is compiled against.
6334+
*
6335+
* The *method entry* rather than the ISEQ is what goes in the slot, and JIT code
6336+
* checks it against the one it read out of the callcache. That is what makes the
6337+
* two-word slot safe to fill with two plain stores: a reader that sees a fresh
6338+
* `cc` beside a previous class's `direct_cme` (or the reverse) fails the compare
6339+
* and searches, exactly as it would for an empty slot. No store ordering, and so
6340+
* no atomics or barriers, are needed on either side.
6341+
*
6342+
* Nothing about the compiled code is cached: the entry point is re-read from
6343+
* ISEQ_BODY(iseq)->jit_entry on every call, which rb_iseq_reset_jit_func()
6344+
* clears whenever that code stops being valid, and the ISEQ is only reached
6345+
* through a method entry that has already validated. */
6346+
static const rb_callable_method_entry_t *
6347+
zjit_send_cache_direct_cme(const struct rb_zjit_send_cache *cache, const struct rb_callcache *cc)
6348+
{
6349+
if (!cache->direct_ok) return NULL;
6350+
6351+
const rb_callable_method_entry_t *cme = vm_cc_cme(cc);
6352+
if (cme->def->type != VM_METHOD_TYPE_ISEQ) return NULL;
6353+
6354+
/* The direct path skips vm_call_method(), so it must only take over calls
6355+
* that function would have let straight through to vm_call_method_each_type().
6356+
* A protected method's check reads the *caller's* self, which the table
6357+
* cannot be keyed on, and a private method is only callable without an
6358+
* explicit receiver. Everything else raises NoMethodError through
6359+
* vm_call_method_missing(), which is very much not a frame push. */
6360+
switch (METHOD_ENTRY_VISI(cme)) {
6361+
case METHOD_VISI_PUBLIC:
6362+
break;
6363+
case METHOD_VISI_PRIVATE:
6364+
if (!(cache->direct_flags & VM_CALL_FCALL)) return NULL;
6365+
break;
6366+
default:
6367+
return NULL;
6368+
}
6369+
6370+
const rb_iseq_t *iseq = def_iseq_ptr(cme->def);
6371+
const struct rb_iseq_constant_body *body = ISEQ_BODY(iseq);
6372+
if (!rb_simple_iseq_p(iseq)) return NULL;
6373+
if (body->param.lead_num != (int)cache->direct_argc) return NULL;
6374+
if (body->local_table_size != (unsigned int)body->param.lead_num) return NULL;
6375+
if (body->stack_max > ZJIT_MEGA_DIRECT_MAX_STACK) return NULL;
6376+
return cme;
6377+
}
6378+
63156379
/* Resolve the callcache for `recv` at this site, out of `cache` when possible.
63166380
*
63176381
* The hit condition is exactly vm_cc_hit_p()'s: the cache's own class matches
@@ -6326,7 +6390,7 @@ zjit_send_cache_search(rb_control_frame_t *reg_cfp, struct rb_call_data *cd,
63266390
{
63276391
VALUE klass = CLASS_OF(recv);
63286392
unsigned int slot = zjit_send_cache_slot(cache, klass);
6329-
const struct rb_callcache *cached = cache->slots[slot];
6393+
const struct rb_callcache *cached = cache->slots[slot].cc;
63306394

63316395
if (LIKELY(cached != NULL && cached->klass == klass &&
63326396
!METHOD_ENTRY_INVALIDATED(vm_cc_cme(cached)))) {
@@ -6340,11 +6404,17 @@ zjit_send_cache_search(rb_control_frame_t *reg_cfp, struct rb_call_data *cd,
63406404
const struct rb_callcache *cc = vm_search_method_fastpath(reg_cfp, cd, klass);
63416405
bool cacheable = zjit_send_cache_cacheable_p(cc);
63426406
if (LIKELY(cacheable)) {
6343-
/* One naturally-aligned pointer store, published the same way
6407+
/* Two naturally-aligned stores, published the same way
63446408
* vm_search_method_slowpath0() publishes cd->cc. No write barrier: the
63456409
* table is a GC root, scanned on every collection, not a heap object
6346-
* that could be missed by a minor GC. */
6347-
cache->slots[slot] = cc;
6410+
* that could be missed by a minor GC.
6411+
*
6412+
* The two words need no ordering between them either: JIT code checks
6413+
* `direct_cme` against the method entry it read out of `cc`, so a reader
6414+
* that catches this fill half-done sees a mismatched pair and searches.
6415+
* See zjit_send_cache_direct_cme(). */
6416+
cache->slots[slot].direct_cme = zjit_send_cache_direct_cme(cache, cc);
6417+
cache->slots[slot].cc = cc;
63486418
}
63496419

63506420
if (UNLIKELY(cache->hit_counter != NULL)) {
@@ -6407,6 +6477,20 @@ rb_zjit_send_cached(rb_execution_context_t *ec, rb_control_frame_t *reg_cfp,
64076477
VM_EXEC(ec, val);
64086478
return val;
64096479
}
6480+
6481+
/* Field offsets and flag masks that the inline send-cache probe bakes into JIT
6482+
* code. See the declarations in zjit.h. */
6483+
size_t rb_zjit_cc_klass_offset(void) { return offsetof(struct rb_callcache, klass); }
6484+
size_t rb_zjit_cc_cme_offset(void) { return offsetof(struct rb_callcache, cme_); }
6485+
size_t rb_zjit_iseq_body_offset(void) { return offsetof(struct rb_iseq_struct, body); }
6486+
size_t rb_zjit_iseq_body_jit_entry_offset(void) { return offsetof(struct rb_iseq_constant_body, jit_entry); }
6487+
size_t rb_zjit_send_cache_entry_size(void) { return sizeof(struct rb_zjit_send_cache_entry); }
6488+
size_t rb_zjit_send_cache_entry_direct_cme_offset(void) { return offsetof(struct rb_zjit_send_cache_entry, direct_cme); }
6489+
size_t rb_zjit_cme_def_offset(void) { return offsetof(rb_callable_method_entry_t, def); }
6490+
size_t rb_zjit_def_iseqptr_offset(void) { return offsetof(rb_method_definition_t, body.iseq.iseqptr); }
6491+
VALUE rb_zjit_method_entry_invalidated_flag(void) { return IMEMO_FL_USER5; }
6492+
size_t rb_zjit_mega_direct_max_stack(void) { return ZJIT_MEGA_DIRECT_MAX_STACK; }
6493+
64106494
#endif // USE_ZJIT
64116495

64126496
VALUE

zjit.h

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,48 @@ ZJIT_STACK_MAP_BASE_PTR_STACK_SIZE(VALUE entry)
108108

109109
// Class -> callcache table for a send site that dispatches over too many
110110
// classes for ZJIT's inline class-guard chain. Allocated and owned by Rust; the
111-
// layout of these four fields must stay in step with `struct SendCache` in
111+
// layout of these fields must stay in step with `struct SendCache` in
112112
// zjit/src/send_cache.rs, which documents what the table caches and why a stale
113113
// entry cannot be wrong.
114+
//
115+
// One slot of the table. Two words so that a hit can answer both questions a
116+
// megamorphic send asks: which callcache dispatches this class (`cc`), and, when
117+
// the answer is an ISEQ method JIT code can enter without the interpreter,
118+
// which method that is (`direct_cme`). See `gen_send_megamorphic_direct()` in
119+
// zjit/src/codegen.rs for the inline probe that reads both.
120+
struct rb_zjit_send_cache_entry {
121+
// The cached callcache, or NULL when the slot is empty. Validates itself:
122+
// see zjit_send_cache_search().
123+
const struct rb_callcache *cc;
124+
// `vm_cc_cme(cc)` when that method is one JIT code may enter with a direct
125+
// call (see zjit_send_cache_direct_cme()), NULL otherwise. JIT code checks
126+
// it against the method entry it read out of `cc`, so a slot caught
127+
// half-written by another ractor reads as a miss rather than as a call to
128+
// the wrong method -- which is what lets both words be plain stores.
129+
const rb_callable_method_entry_t *direct_cme;
130+
};
131+
114132
struct rb_zjit_send_cache {
115133
// Number of slots. A power of two.
116134
uint32_t len;
117135
// 64 - log2(len): the shift that turns the hash product into a slot index.
118136
uint32_t shift;
119-
// Slot 0. One callcache pointer per slot, NULL when the slot is empty.
120-
const struct rb_callcache **slots;
137+
// Slot 0.
138+
struct rb_zjit_send_cache_entry *slots;
121139
// The ZJIT hit counter under --zjit-stats, NULL otherwise. Doubles as the
122140
// flag for whether to report misses to rb_zjit_send_cache_record_miss(), so
123141
// that a build without stats pays a never-taken branch rather than a call.
124142
uint64_t *hit_counter;
143+
// `argc` of the call shape this table serves, and whether that shape lets a
144+
// hit be dispatched with a direct JIT-to-JIT call at all. Both are constant
145+
// for the life of the table; Rust sets them when it allocates it.
146+
uint32_t direct_argc;
147+
// Zero when the call shape rules direct dispatch out (a splat, a block
148+
// argument, a tail call, ...), so `direct_cme` stays NULL in every slot.
149+
uint32_t direct_ok;
150+
// The call shape's `vm_ci_flag()`, for the visibility test the fill path
151+
// runs (a private method is directly callable only from an FCALL site).
152+
uint32_t direct_flags;
125153
};
126154

127155
// Why a probe of a `struct rb_zjit_send_cache` did not produce a callcache.
@@ -137,6 +165,25 @@ struct rb_zjit_send_cache {
137165

138166
void rb_zjit_send_cache_record_miss(int kind);
139167

168+
// Field offsets and flag masks the inline send-cache probe in JIT code needs.
169+
// They are functions rather than bindgen constants because the structs they
170+
// reach into (rb_callcache, rb_iseq_constant_body) are opaque to Rust.
171+
size_t rb_zjit_cc_klass_offset(void);
172+
size_t rb_zjit_cc_cme_offset(void);
173+
size_t rb_zjit_iseq_body_offset(void);
174+
size_t rb_zjit_iseq_body_jit_entry_offset(void);
175+
size_t rb_zjit_send_cache_entry_size(void);
176+
size_t rb_zjit_send_cache_entry_direct_cme_offset(void);
177+
size_t rb_zjit_cme_def_offset(void);
178+
size_t rb_zjit_def_iseqptr_offset(void);
179+
VALUE rb_zjit_method_entry_invalidated_flag(void);
180+
size_t rb_zjit_mega_direct_max_stack(void);
181+
182+
// Largest `stack_max` a callee may have and still be entered by the inline
183+
// megamorphic dispatch path, which checks for stack overflow against this bound
184+
// instead of the callee's own (unknown at compile time) requirement.
185+
#define ZJIT_MEGA_DIRECT_MAX_STACK 64
186+
140187
extern void *rb_zjit_entry;
141188
extern bool rb_zjit_compiling_p;
142189
extern const zjit_jit_frame_t rb_zjit_c_frame;

0 commit comments

Comments
 (0)