Skip to content

Commit 87911ac

Browse files
author
Ralph Küpper
committed
perf(class): stop disarming every dispatch guard when a class prototype is materialized
`class_decl_prototype_value()` lazily materializes a declared class's prototype object the first time anything demands it — `instanceof`, `Object.getPrototypeOf`, a `super` chain. It called `invalidate_class_prototype_fast_guards()`, which trips a process-global, MONOTONIC latch that makes every `js_method_direct_shape_guard` / `js_typed_feedback_method_direct_call_guard` answer "miss" for the rest of the run, retires every element-shape record (`invalidate_all_element_shapes`, #7480), and bumps `VTABLE_GEN`, retiring the `vtable_ic` / `obj_dispatch_ic` caches (#7769). That latch is for prototype SURGERY (`Class.prototype.m = fn`) — the two call sites in `class_registry/prototype_methods.rs`, which keep it. Materialization changes nothing about which member `recv.m()` resolves to: the object is fresh and unobserved, and the writes below it install `constructor` plus exactly the methods the class already declares. But because any demand lands there, an ordinary class-hierarchy program disarmed its own speculation during startup and then ran every method call and every array element read on the slow path. Measured on `gc-handoff/apps/shapes.ts` with per-precondition counters on the guard: 384,000 of 384,000 probes failed on this latch and on nothing else. Also adds `js_method_direct_shape_class`, the class-id half of `js_method_direct_shape_guard` (which is now defined in terms of it, so its single-pair semantics are unchanged by construction), and uses it to widen the shape-guarded direct call from one arm — the declared receiver class — to the declared class plus its subclass closure, capped at 8 arms. For a base-typed collection the single-arm bet loses on every element. shapes 0.2256 -> 0.1976 s on the quiet mini (best-of-5, output byte-identical to node, exit 0). Four allocation-heavy programs regress 3.4-4.2%; see the PR body — this is a draft for that reason.
1 parent 0a2bf15 commit 87911ac

7 files changed

Lines changed: 380 additions & 22 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
### Fixed
2+
3+
**Materializing `Class.prototype` no longer disarms class-dispatch speculation and every element-shape proof for the rest of the process.**
4+
5+
`class_decl_prototype_value()` — the lazy materializer that creates a declared
6+
class's prototype object on first demand — called
7+
`invalidate_class_prototype_fast_guards()`. That is not a hint; it trips a
8+
process-global, **monotonic** latch that:
9+
10+
* makes `js_method_direct_shape_guard` and
11+
`js_typed_feedback_method_direct_call_guard` return "miss" for every receiver,
12+
for the rest of the run, so every `recv.m()` on a declared class falls into
13+
the `js_native_call_method` dispatch tower;
14+
* calls `crate::array::invalidate_all_element_shapes()`, retiring every
15+
outstanding element-shape record (#7480), so `arr[i]` reads fall back to the
16+
generic `js_require_object_coercible` + `js_is_symbol` +
17+
`js_object_get_index_polymorphic` path;
18+
* bumps `VTABLE_GEN`, retiring the `vtable_ic` / `obj_dispatch_ic` dispatch
19+
caches (#7769).
20+
21+
The latch exists for the one event that can change which member `recv.m()`
22+
resolves to: a **write** to a prototype (`Class.prototype.m = fn`). Those are
23+
the two call sites in `class_registry/prototype_methods.rs`, and they keep it.
24+
Reaching the materializer changes none of it — the object is fresh and
25+
unobserved, and the writes immediately below install `constructor` plus exactly
26+
the methods the class already declares, which are the same answers the vtable
27+
already gives.
28+
29+
What reaches the materializer, measured with a name-printing probe on the
30+
materializer itself: `new` on a class that `extends` anything, which
31+
materializes the instance's whole prototype ancestor chain (`class B extends A`
32+
+ `new B()` = 2 materializations; a three-level chain = 3). NOT `instanceof`
33+
and NOT `Object.getPrototypeOf` — both trip zero. So an ordinary
34+
class-hierarchy program disarmed its own dispatch speculation the first time it
35+
constructed a subclass.
36+
37+
Measured on `gc-handoff/apps/shapes.ts` with a counter on each precondition of
38+
the guard: **384,000 of 384,000 probes failed on this latch and on nothing
39+
else** (`descriptors_in_use`, the GC-header checks, and the object-type check
40+
all rejected zero). `gc-handoff/bench/shapes_dispatch.ts` shows the same for a
41+
program containing no `instanceof` at all.
42+
43+
### Added
44+
45+
`js_method_direct_shape_class` — the class-id half of
46+
`js_method_direct_shape_guard`, factored out so a call site can test more than
47+
one `(class id, keys token)` pair per probe. `js_method_direct_shape_guard` is
48+
now defined in terms of it, so the single-pair semantics are unchanged by
49+
construction.
50+
51+
Codegen uses it to widen the shape-guarded direct call at a method callsite
52+
from ONE arm (the declared receiver class) to the declared class plus its
53+
subclass closure, each paired with the body the method resolves to when walked
54+
from that class. The declared-class guard is a bet that the receiver's dynamic
55+
class equals its static class; for a base-typed collection — `nodes: Node2D[]`
56+
holding `Rect` / `Circle` / `Square` / `Marker` / `Group` — that bet loses on
57+
every element. Arms are capped at `MAX_SUBCLASS_DISPATCH_ARMS` (8) so a wide
58+
hierarchy keeps today's single-arm form rather than growing a long compare
59+
chain, and only the shape-only guard is widened (the typed-feedback guard
60+
records a single-contract observation per site and keeps its one arm).
61+
62+
### Notes for the next reader
63+
64+
`gc-handoff/bench/shapes_{build,describe,dispatch,dispatch_static}.ts` are the
65+
committed decomposition of `apps/shapes.ts`, each annotated with its measured
66+
seconds. They record, among other things, that **class dispatch is not where
67+
`shapes.ts` loses**: on the quiet mini the whole `.area()` term is 0.013 s of a
68+
0.224 s program, and widening the dispatch guard alone moved it 0.2237 →
69+
0.2241 s. The two cost centres that decomposition does find are `build()`
70+
(0.1035 s, 46%) and `describe()`'s string concatenation (0.074 s, 33%, ~620 ns
71+
per `"lit" + this.stringField`).
72+
73+
74+
### Blast radius
75+
76+
The latch is monotonic in production (the only `store(false)` is `#[cfg(test)]`),
77+
and nearly every class-hierarchy program trips it, so the obvious worry is that it
78+
silently disarms the element-shape repsel work (#7770/#7771/#7766/#7702). Measured:
79+
it does not. `invalidate_all_element_shapes()` bumps a GENERATION; each record
80+
carries the generation it was installed under and `ensure_element_shape`
81+
re-establishes it on the next query, so one bump costs at most one
82+
re-establishment per array. Adding a single `instanceof` or `Object.getPrototypeOf`
83+
before an otherwise identical hot loop moves nothing: 0.0222 s for a `churn_read`
84+
-shaped element-read loop over object literals AND over class instances, 2.46 s
85+
for a method-call-per-element loop. Only the dispatch-guard half is permanent, and
86+
on its own it is worth 1.0% on `shapes.ts`; it reaches 16.6% only combined with the
87+
multi-arm widening.

crates/perry-codegen/src/lower_call/method_override.rs

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,28 @@ pub(super) fn emit_own_method_override_check(
155155
)
156156
}
157157

158+
/// One additional `(class id, keys token) -> concrete method` arm for the
159+
/// shape-guarded direct call, describing a class in the DECLARED receiver
160+
/// class's subclass closure.
161+
///
162+
/// The declared-class guard speculates that the receiver's dynamic class is
163+
/// exactly its static class. For a receiver typed as the base of a hierarchy —
164+
/// `nodes: Node2D[]`, every element a `Rect` / `Circle` / `Square` / `Marker` /
165+
/// `Group` — that speculation is wrong for EVERY element, so the guard misses
166+
/// 100% of the time and each call pays a wasted guard plus the full
167+
/// `js_native_call_method` dispatch tower. Each arm here is the same proof the
168+
/// declared-class guard performs (exact class id + exact keys token), applied
169+
/// to one more class whose implementation of the method codegen already
170+
/// resolved statically.
171+
pub(super) struct SubclassDispatchArm {
172+
/// `class_id` of the concrete subclass this arm matches.
173+
pub class_id: u32,
174+
/// Name of the module global holding that subclass's canonical keys array.
175+
pub keys_global: String,
176+
/// The method body `property` resolves to when walked from that subclass.
177+
pub target_fn: String,
178+
}
179+
158180
/// Emit a typed-feedback runtime guard before a known class-method direct call.
159181
///
160182
/// The guard validates that the receiver still has the expected class shape,
@@ -175,9 +197,16 @@ pub(super) fn emit_guarded_direct_method_call(
175197
typed_i1_direct_fn: Option<(&str, Vec<crate::codegen::TypedParamRep>)>,
176198
typed_string_direct_fn: Option<(&str, Vec<crate::codegen::TypedParamRep>)>,
177199
shape_only_guard: bool,
200+
subclass_arms: &[SubclassDispatchArm],
178201
) -> Option<String> {
179202
let expected_class_id = *ctx.class_ids.get(receiver_class_name)?;
180203
let keys_global_name = ctx.class_keys_globals.get(receiver_class_name)?.clone();
204+
// Only the shape-only guard is widened. The typed-feedback guard records an
205+
// observation keyed to ONE (class, method, func ptr) contract per site; a
206+
// multi-class site would feed it a stream of "different class" observations
207+
// and it would (correctly) mark the site polymorphic. That form keeps its
208+
// single-arm shape.
209+
let subclass_arms: &[SubclassDispatchArm] = if shape_only_guard { subclass_arms } else { &[] };
181210

182211
// Representation-selection Phase 5a: the proven-`this` clone for this
183212
// (class, method), when the emission loop produced one.
@@ -228,18 +257,82 @@ pub(super) fn emit_guarded_direct_method_call(
228257
))
229258
};
230259

260+
// Per-arm keys tokens, loaded through the same entry-block init the
261+
// declared class's token uses (module-init populates `@perry_class_keys_*`
262+
// after the prelude, so the load may not be hoisted above it).
263+
let subclass_keys: Vec<String> = subclass_arms
264+
.iter()
265+
.map(|arm| {
266+
let slot = ctx.func.entry_init_load_global(&arm.keys_global, I64);
267+
ctx.block().load(I64, &slot)
268+
})
269+
.collect();
270+
231271
let guard_idx = ctx.new_block("method_direct.guard");
232272
let fast_idx = ctx.new_block("method_direct.fast");
273+
// One test block and one case block per subclass arm. The declared class's
274+
// own test lives in the guard block, so arm 0's test block is the guard's
275+
// false edge.
276+
let sub_test_idxs: Vec<usize> = (0..subclass_arms.len())
277+
.map(|i| ctx.new_block(&format!("method_direct.subtest{i}")))
278+
.collect();
279+
let sub_case_idxs: Vec<usize> = (0..subclass_arms.len())
280+
.map(|i| ctx.new_block(&format!("method_direct.sub{i}")))
281+
.collect();
233282
let fallback_idx = ctx.new_block("method_direct.fallback");
234283
let merge_idx = ctx.new_block("method_direct.merge");
235284
let guard_label = ctx.block_label(guard_idx);
236285
let fast_label = ctx.block_label(fast_idx);
237286
let fallback_label = ctx.block_label(fallback_idx);
238287
let merge_label = ctx.block_label(merge_idx);
288+
let sub_test_labels: Vec<String> = sub_test_idxs.iter().map(|&i| ctx.block_label(i)).collect();
289+
let sub_case_labels: Vec<String> = sub_case_idxs.iter().map(|&i| ctx.block_label(i)).collect();
239290
ctx.block().br(&guard_label);
240291

241292
ctx.current_block = guard_idx;
242-
let guard_ok = if shape_only_guard {
293+
// Multi-arm form: ONE probe resolves the receiver's class id and keys
294+
// token (every precondition `js_method_direct_shape_guard` checks except
295+
// the comparison itself), then an inline compare chain picks the arm. The
296+
// single-arm form keeps its original single call.
297+
let multi_arm = !subclass_arms.is_empty();
298+
if multi_arm {
299+
let keys_slot = ctx.func.alloca_entry(I64);
300+
let cid = ctx.block().call(
301+
I32,
302+
"js_method_direct_shape_class",
303+
&[(DOUBLE, recv_box), (crate::types::PTR, &keys_slot)],
304+
);
305+
let keys = ctx.block().load(I64, &keys_slot);
306+
{
307+
let next = sub_test_labels[0].clone();
308+
let blk = ctx.block();
309+
let cid_ok = blk.icmp_eq(I32, &cid, &expected_class_id_str);
310+
let keys_ok = blk.icmp_eq(I64, &keys, &expected_keys);
311+
let pass = blk.and(I1, &cid_ok, &keys_ok);
312+
blk.cond_br(&pass, &fast_label, &next);
313+
}
314+
for (i, arm) in subclass_arms.iter().enumerate() {
315+
ctx.current_block = sub_test_idxs[i];
316+
let next = sub_test_labels
317+
.get(i + 1)
318+
.cloned()
319+
.unwrap_or_else(|| fallback_label.clone());
320+
let case_label = sub_case_labels[i].clone();
321+
let class_id_str = arm.class_id.to_string();
322+
let arm_keys = subclass_keys[i].clone();
323+
let blk = ctx.block();
324+
let cid_ok = blk.icmp_eq(I32, &cid, &class_id_str);
325+
let keys_ok = blk.icmp_eq(I64, &keys, &arm_keys);
326+
let pass = blk.and(I1, &cid_ok, &keys_ok);
327+
blk.cond_br(&pass, &case_label, &next);
328+
}
329+
ctx.current_block = guard_idx;
330+
}
331+
let guard_ok = if multi_arm {
332+
// The chain above already terminated the guard block and every test
333+
// block; `fast_idx` / `fallback_idx` are entered from it unchanged.
334+
String::new()
335+
} else if shape_only_guard {
243336
ctx.block().call(
244337
I32,
245338
"js_method_direct_shape_guard",
@@ -267,9 +360,11 @@ pub(super) fn emit_guarded_direct_method_call(
267360
],
268361
)
269362
};
270-
let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0");
271-
ctx.block()
272-
.cond_br(&guard_pass, &fast_label, &fallback_label);
363+
if !multi_arm {
364+
let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0");
365+
ctx.block()
366+
.cond_br(&guard_pass, &fast_label, &fallback_label);
367+
}
273368

274369
ctx.current_block = fast_idx;
275370
let fast_value = {
@@ -795,6 +890,21 @@ pub(super) fn emit_guarded_direct_method_call(
795890
ctx.block().br(&merge_label);
796891
}
797892

893+
// One direct call per subclass arm. Reached only from that arm's test,
894+
// which proved the receiver's class id AND keys token exactly — the same
895+
// proof the declared-class arm rests on, so the statically resolved body
896+
// is the one the dispatch tower would have found.
897+
let mut sub_values: Vec<(String, String)> = Vec::with_capacity(subclass_arms.len());
898+
for (i, arm) in subclass_arms.iter().enumerate() {
899+
ctx.current_block = sub_case_idxs[i];
900+
let value = ctx.block().call(DOUBLE, &arm.target_fn, direct_arg_slices);
901+
let after = ctx.block().label.clone();
902+
if !ctx.block().is_terminated() {
903+
ctx.block().br(&merge_label);
904+
}
905+
sub_values.push((value, after));
906+
}
907+
798908
ctx.current_block = fallback_idx;
799909
let (args_ptr, args_len) = if fallback_user_args.is_empty() {
800910
("null".to_string(), "0".to_string())
@@ -838,11 +948,11 @@ pub(super) fn emit_guarded_direct_method_call(
838948
}
839949

840950
ctx.current_block = merge_idx;
841-
Some(ctx.block().phi(
842-
DOUBLE,
843-
&[
844-
(fast_value.as_str(), after_fast.as_str()),
845-
(fallback_value.as_str(), after_fallback.as_str()),
846-
],
847-
))
951+
let mut phi_inputs: Vec<(&str, &str)> = Vec::with_capacity(sub_values.len() + 2);
952+
phi_inputs.push((fast_value.as_str(), after_fast.as_str()));
953+
for (value, label) in &sub_values {
954+
phi_inputs.push((value.as_str(), label.as_str()));
955+
}
956+
phi_inputs.push((fallback_value.as_str(), after_fallback.as_str()));
957+
Some(ctx.block().phi(DOUBLE, &phi_inputs))
848958
}

crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ use crate::types::{DOUBLE, I32, I64};
1515
// Reach the override-emit helpers (`pub(super)` of `lower_call`) by their
1616
// canonical crate-relative path.
1717
use crate::lower_call::method_override::{
18-
emit_guarded_direct_method_call, emit_own_method_override_check,
18+
emit_guarded_direct_method_call, emit_own_method_override_check, SubclassDispatchArm,
1919
};
2020

21+
/// Cap on the number of extra `(class id, keys token)` arms a shape-guarded
22+
/// direct call may carry. A wide hierarchy would turn every callsite into a
23+
/// long inline compare chain — more instruction cache than the single tower
24+
/// call it replaces — so past this width the site keeps the single-arm guard.
25+
const MAX_SUBCLASS_DISPATCH_ARMS: usize = 8;
26+
2127
/// #7142: the proven-`this` clone a class-id dispatch-tower case may route to,
2228
/// plus the keys token the routed path must re-check inline.
2329
struct TowerPshapeRoute {
@@ -892,6 +898,95 @@ pub(crate) fn try_lower_instance_method_call(
892898
let arg_slices: Vec<(crate::types::LlvmType, &str)> =
893899
lowered_args.iter().map(|s| (DOUBLE, s.as_str())).collect();
894900

901+
// Arms for the shape-guarded direct call: every class in
902+
// `class_name`'s subclass closure, paired with the body `property`
903+
// resolves to from THAT class. Includes subclasses that do NOT
904+
// override — a `Marker` receiver fails a `Node2D` class-id guard
905+
// just as hard as a `Rect` one does, and the tower it falls into
906+
// costs the same either way.
907+
//
908+
// The declared-class guard alone is a bet that the receiver's
909+
// dynamic class equals its static class. Where a base-typed
910+
// collection is the whole point of the hierarchy that bet loses
911+
// every single time, and the miss is not free: it pays a guard
912+
// call AND the full `js_native_call_method` tower.
913+
let mut subclass_arms: Vec<SubclassDispatchArm> = Vec::new();
914+
{
915+
let mut seen_ids: Vec<u32> = vec![*ctx.class_ids.get(&class_name).unwrap_or(&0)];
916+
let mut roots: Vec<(&String, u32)> =
917+
ctx.class_ids.iter().map(|(k, &v)| (k, v)).collect();
918+
roots.sort_unstable_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0)));
919+
for (sub_name, sub_id) in roots {
920+
if *sub_name == class_name || sub_id == 0 || seen_ids.contains(&sub_id) {
921+
continue;
922+
}
923+
let mut parent = ctx
924+
.classes
925+
.get(sub_name)
926+
.and_then(|c| c.extends_name.clone());
927+
let mut is_subclass = false;
928+
while let Some(p) = parent {
929+
if p == class_name {
930+
is_subclass = true;
931+
break;
932+
}
933+
parent = ctx.classes.get(&p).and_then(|c| c.extends_name.clone());
934+
}
935+
if !is_subclass {
936+
continue;
937+
}
938+
let Some(keys_global) = ctx.class_keys_globals.get(sub_name).cloned() else {
939+
continue;
940+
};
941+
// Resolve through the SUBCLASS's own chain, and remember
942+
// where it landed: the rest-param shape is a property of
943+
// the declaring class, and a rest-bearing target cannot be
944+
// called with this site's flat, base-arity argument list.
945+
let mut cur = Some(sub_name.clone());
946+
let mut resolved: Option<(String, String)> = None;
947+
while let Some(c) = cur {
948+
let key = (c.clone(), property.to_string());
949+
if let Some(fname) = ctx.methods.get(&key).cloned() {
950+
resolved = Some((c, fname));
951+
break;
952+
}
953+
cur = ctx.classes.get(&c).and_then(|c| c.extends_name.clone());
954+
}
955+
let Some((decl_class, target_fn)) = resolved else {
956+
continue;
957+
};
958+
if target_fn.starts_with("perry_static_") {
959+
continue;
960+
}
961+
if matches!(
962+
ctx.method_has_rest
963+
.get(&(decl_class.clone(), property.to_string())),
964+
Some(&true)
965+
) {
966+
continue;
967+
}
968+
if ctx
969+
.method_param_counts
970+
.get(&(decl_class, property.to_string()))
971+
.is_some_and(|&n| n > max_explicit_arity)
972+
{
973+
continue;
974+
}
975+
seen_ids.push(sub_id);
976+
subclass_arms.push(SubclassDispatchArm {
977+
class_id: sub_id,
978+
keys_global,
979+
target_fn,
980+
});
981+
}
982+
}
983+
// A wide hierarchy would turn every callsite into a long compare
984+
// chain — more instruction cache than the tower call it replaces.
985+
// Beyond the cap the site keeps today's single-arm guard.
986+
if subclass_arms.len() > MAX_SUBCLASS_DISPATCH_ARMS {
987+
subclass_arms.clear();
988+
}
989+
895990
if !method_has_rest {
896991
let typed_method_key = (class_name.clone(), property.to_string());
897992
let typed_formal_count = ctx
@@ -1145,6 +1240,7 @@ pub(crate) fn try_lower_instance_method_call(
11451240
typed_i1_direct,
11461241
typed_string_direct,
11471242
shape_only_guard,
1243+
&subclass_arms,
11481244
) {
11491245
return Ok(Some(guarded));
11501246
}

0 commit comments

Comments
 (0)