Skip to content

Commit 84adb7f

Browse files
proggeramlugRalph Küpper
andauthored
perf(codegen): skip canonical-shape own-method scans (#8406) (#8505)
Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 9aee500 commit 84adb7f

4 files changed

Lines changed: 231 additions & 33 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Optimize polymorphic instance-method dispatch by using the receiver's compiler-published class and ShapeId pair to prove that canonical instances have no own-method override. Eligible call sites now read the object header once and bypass `js_object_get_own_field_or_undef`'s keys-array scan, while declared/computed fields, dynamic parent chains, mutated shapes, non-instance receivers, and wide dispatch towers retain the existing guarded fallback. The class id obtained by the shape probe is reused by the bounded dispatch tower. This cuts the `shapes` corpus row's retired instructions by 9.14% and CPU cycles by 9.93%, with every corpus program still byte-exact; focused integration coverage keeps both declared function fields and post-construction method overrides on the correct path (#8406).

crates/perry-codegen/src/collectors/proven_this_routing_tests.rs

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -800,35 +800,44 @@ fn tower_route_is_guarded_by_the_class_shape_id() {
800800
panic!("nothing conditionally branches to {clone_block} — the clone is reached unguarded:\n{ir}")
801801
});
802802

803-
// 1. the class ShapeId global is loaded once at function entry …
804-
let global_load = ir
805-
.lines()
806-
.find(|l| l.contains("= load i32, ptr @perry_class_shape_id_"))
807-
.unwrap_or_else(|| panic!("the class ShapeId is never read:\n{ir}"));
808-
let global_reg = global_load.trim().split(' ').next().expect("ssa name");
803+
// 1. a class ShapeId global is loaded at function entry …
809804
// 2. … and parked in an entry slot …
810-
let store = ir
805+
// 3. … which THIS guard block reloads …
806+
// 4. … and compares against the receiver's live ShapeId.
807+
//
808+
// There may be another hoisted load of the same global for an earlier
809+
// dynamic-dispatch shape probe (#8406), so follow each candidate's
810+
// dataflow into this guard instead of assuming the first load owns it.
811+
let (slot, expected) = ir
811812
.lines()
812-
.find(|l| l.contains(&format!("store i32 {}, ptr ", global_reg)))
813-
.unwrap_or_else(|| panic!("the hoisted ShapeId is never stored:\n{ir}"));
814-
let slot = store.rsplit(' ').next().expect("slot name");
813+
.filter(|line| line.contains("= load i32, ptr @perry_class_shape_id_"))
814+
.find_map(|global_load| {
815+
let global_reg = global_load.trim().split(' ').next()?;
816+
let store = ir
817+
.lines()
818+
.find(|line| line.contains(&format!("store i32 {global_reg}, ptr ")))?;
819+
let slot = store.rsplit(' ').next()?;
820+
let expected = guard_body.iter().find_map(|line| {
821+
let line = line.trim();
822+
line.ends_with(&format!("load i32, ptr {slot}"))
823+
.then(|| line.split(' ').next().map(str::to_string))
824+
.flatten()
825+
})?;
826+
guard_body
827+
.iter()
828+
.any(|line| line.contains("icmp eq i32") && line.contains(&expected))
829+
.then(|| (slot.to_string(), expected))
830+
})
831+
.unwrap_or_else(|| {
832+
panic!(
833+
"the routed call is not dominated by the hoisted ShapeId's reload and compare:\n{guard_body:#?}"
834+
)
835+
});
815836
assert!(
816837
!ir.lines()
817-
.any(|line| line.contains("call void @js_shadow_slot_bind") && line.contains(slot)),
838+
.any(|line| line.contains("call void @js_shadow_slot_bind") && line.contains(&slot)),
818839
"a ShapeId scalar must not be registered as a moving GC root:\n{ir}"
819840
);
820-
// 3. … which the guard block reloads …
821-
let expected = guard_body
822-
.iter()
823-
.find_map(|l| {
824-
let l = l.trim();
825-
l.ends_with(&format!("load i32, ptr {}", slot))
826-
.then(|| l.split(' ').next().expect("ssa name").to_string())
827-
})
828-
.unwrap_or_else(|| {
829-
panic!("the guard block never reads the hoisted ShapeId:\n{guard_body:#?}")
830-
});
831-
// 4. … and compares against the receiver's live ShapeId.
832841
assert!(
833842
guard_body
834843
.iter()

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

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use perry_hir::Expr;
1010
use crate::expr::{lower_expr, nanbox_pointer_inline, unbox_to_i64, FnCtx};
1111
use crate::nanbox::double_literal;
1212
use crate::type_analysis::receiver_class_name;
13-
use crate::types::{DOUBLE, I32, I64};
13+
use crate::types::{DOUBLE, I1, I32, I64};
1414

1515
// Reach the override-emit helpers (`pub(super)` of `lower_call`) by their
1616
// canonical crate-relative path.
@@ -24,6 +24,49 @@ use crate::lower_call::method_override::{
2424
/// call it replaces — so past this width the site keeps the single-arm guard.
2525
const MAX_SUBCLASS_DISPATCH_ARMS: usize = 8;
2626

27+
/// Can an exact canonical shape prove that `property` is not an own field?
28+
///
29+
/// A post-construction assignment such as `this.run = f` mints a successor
30+
/// ShapeId, so an exact canonical-shape match excludes that override. A
31+
/// declared field is different: it is already part of the canonical shape and
32+
/// may intentionally shadow a prototype method (#620). Computed fields and
33+
/// incomplete/dynamic parent chains are similarly unknowable here and retain
34+
/// the runtime own-property probe.
35+
fn canonical_shape_excludes_own_property(
36+
ctx: &FnCtx<'_>,
37+
class_name: &str,
38+
property: &str,
39+
) -> bool {
40+
let mut current = Some(class_name.to_string());
41+
let mut seen = std::collections::HashSet::new();
42+
while let Some(name) = current {
43+
if !seen.insert(name.clone()) {
44+
return false;
45+
}
46+
let Some(class) = ctx.classes.get(&name) else {
47+
return false;
48+
};
49+
if class
50+
.fields
51+
.iter()
52+
.any(|field| field.key_expr.is_some() || field.name == property)
53+
{
54+
return false;
55+
}
56+
if class.extends_expr.is_some() || class.native_extends.is_some() {
57+
return false;
58+
}
59+
current = class.extends_name.clone().or_else(|| {
60+
class.extends.and_then(|parent_id| {
61+
ctx.classes
62+
.iter()
63+
.find_map(|(name, candidate)| (candidate.id == parent_id).then(|| name.clone()))
64+
})
65+
});
66+
}
67+
true
68+
}
69+
2770
/// A declared class may select the direct-method guard, but never prove the
2871
/// direct call. The guard validates the live class id, keys token, own
2972
/// override, and resolved method pointer; every miss uses dynamic dispatch.
@@ -338,6 +381,11 @@ pub(crate) fn try_lower_instance_method_call(
338381
// instance with a longer chain. Inherited dispatch gets `None` and
339382
// keeps today's lowering.
340383
let mut impl_owner: Vec<Option<String>> = Vec::new();
384+
// Concrete receiver class for each implementor entry. Unlike
385+
// `impl_owner`, this is present for inherited implementations too and
386+
// lets the override probe compare the receiver against that class's
387+
// canonical ShapeId.
388+
let mut impl_class: Vec<String> = Vec::new();
341389
let mut seen_pairs: std::collections::HashSet<(u32, String)> =
342390
std::collections::HashSet::new();
343391
// Walk `class_ids` in a FIXED order, not `HashMap` order (#7622). Each
@@ -371,6 +419,7 @@ pub(crate) fn try_lower_instance_method_call(
371419
crate::codegen::arguments::method_has_user_rest(ctx, &c, property);
372420
let decl = ctx.method_param_counts.get(&key).copied().unwrap_or(0);
373421
impl_owner.push((c == *start_cls).then(|| start_cls.clone()));
422+
impl_class.push(start_cls.clone());
374423
implementors.push((start_cid, fname));
375424
impl_meta.push((has_rest, has_synthetic_arguments, has_user_rest, decl));
376425
}
@@ -449,6 +498,69 @@ pub(crate) fn try_lower_instance_method_call(
449498
let probe_entry = ctx.strings.entry(key_idx_probe);
450499
let probe_bytes_global = format!("@{}", probe_entry.bytes_global);
451500
let probe_name_len_str = probe_entry.byte_len.to_string();
501+
let probe_override_idx = ctx.new_block("idisp.override");
502+
let probe_dispatch_idx = ctx.new_block("idisp.dispatch");
503+
let probe_outer_merge_idx = ctx.new_block("idisp.outer_merge");
504+
let probe_override_label = ctx.block_label(probe_override_idx);
505+
let probe_dispatch_label = ctx.block_label(probe_dispatch_idx);
506+
let probe_outer_merge_label = ctx.block_label(probe_outer_merge_idx);
507+
508+
// #8406: an exact compiler-published (class id, ShapeId) pair can
509+
// prove that no post-construction own-method override was added.
510+
// Probe the receiver once and bypass the keys-array scan for those
511+
// canonical shapes. Classes whose canonical layout itself may
512+
// contain `property` stay on the old probe, as do wide towers to
513+
// keep code-size growth bounded.
514+
let shape_probe_arms: Vec<(u32, String)> = if implementors.len()
515+
<= MAX_SUBCLASS_DISPATCH_ARMS
516+
{
517+
implementors
518+
.iter()
519+
.zip(impl_class.iter())
520+
.filter_map(|((class_id, _), class_name)| {
521+
if !canonical_shape_excludes_own_property(ctx, class_name, property) {
522+
return None;
523+
}
524+
let keys_global = ctx.class_keys_globals.get(class_name)?;
525+
let expected_shape =
526+
crate::typed_shape::load_class_shape_id(ctx, class_name, keys_global);
527+
Some((*class_id, expected_shape))
528+
})
529+
.collect()
530+
} else {
531+
Vec::new()
532+
};
533+
let mut shape_probe_cid: Option<String> = None;
534+
if !shape_probe_arms.is_empty() {
535+
let shape_slot = ctx.func.alloca_entry(I32);
536+
let cid = ctx.block().call(
537+
I32,
538+
"js_method_direct_shape_class",
539+
&[(DOUBLE, &recv_box), (crate::types::PTR, &shape_slot)],
540+
);
541+
let shape_id = ctx.block().load(I32, &shape_slot);
542+
shape_probe_cid = Some(cid.clone());
543+
let own_idx = ctx.new_block("idisp.own_probe");
544+
let test_idxs: Vec<usize> = (1..shape_probe_arms.len())
545+
.map(|i| ctx.new_block(&format!("idisp.shape_test{i}")))
546+
.collect();
547+
for (i, (class_id, expected_shape)) in shape_probe_arms.iter().enumerate() {
548+
if i > 0 {
549+
ctx.current_block = test_idxs[i - 1];
550+
}
551+
let miss_label = test_idxs
552+
.get(i)
553+
.map(|&idx| ctx.block_label(idx))
554+
.unwrap_or_else(|| ctx.block_label(own_idx));
555+
let blk = ctx.block();
556+
let cid_ok = blk.icmp_eq(I32, &cid, &class_id.to_string());
557+
let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape);
558+
let exact = blk.and(I1, &cid_ok, &shape_ok);
559+
blk.cond_br(&exact, &probe_dispatch_label, &miss_label);
560+
}
561+
ctx.current_block = own_idx;
562+
}
563+
452564
let own_method_probe = ctx.block().call(
453565
DOUBLE,
454566
"js_object_get_own_field_or_undef",
@@ -461,12 +573,6 @@ pub(crate) fn try_lower_instance_method_call(
461573
let own_bits_probe = ctx.block().bitcast_double_to_i64(&own_method_probe);
462574
let undef_bits_str = format!("{}", crate::nanbox::TAG_UNDEFINED as i64);
463575
let is_undef_probe = ctx.block().icmp_eq(I64, &own_bits_probe, &undef_bits_str);
464-
let probe_override_idx = ctx.new_block("idisp.override");
465-
let probe_dispatch_idx = ctx.new_block("idisp.dispatch");
466-
let probe_outer_merge_idx = ctx.new_block("idisp.outer_merge");
467-
let probe_override_label = ctx.block_label(probe_override_idx);
468-
let probe_dispatch_label = ctx.block_label(probe_dispatch_idx);
469-
let probe_outer_merge_label = ctx.block_label(probe_outer_merge_idx);
470576
ctx.block().cond_br(
471577
&is_undef_probe,
472578
&probe_dispatch_label,
@@ -570,9 +676,17 @@ pub(crate) fn try_lower_instance_method_call(
570676
// closure-call fallback would also handle this but
571677
// returning a sentinel is cheaper).
572678
ctx.current_block = tower_idx;
573-
let blk = ctx.block();
574-
let recv_handle = unbox_to_i64(blk, &recv_box);
575-
let cid = blk.call(I32, "js_object_get_class_id", &[(I64, &recv_handle)]);
679+
let recv_handle = unbox_to_i64(ctx.block(), &recv_box);
680+
let cid = if let Some(probed_cid) = shape_probe_cid {
681+
// Reuse the class id that the shape probe already validated.
682+
// Zero is intentional: it sends descriptor/prototype
683+
// invalidation and every non-instance receiver to the runtime
684+
// fallback instead of re-entering this hard-coded tower.
685+
probed_cid
686+
} else {
687+
ctx.block()
688+
.call(I32, "js_object_get_class_id", &[(I64, &recv_handle)])
689+
};
576690

577691
for (i, (case_cid, _)) in implementors.iter().enumerate() {
578692
let case_label = ctx.block_label(case_idxs[i]);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! Regression coverage for #8406's dynamic-dispatch shape shortcut.
2+
//!
3+
//! An exact canonical class shape may bypass the runtime own-property scan,
4+
//! but only when that canonical layout cannot itself contain the method name.
5+
//! A declared function field and a later own-property assignment must both
6+
//! continue to override an inherited prototype method.
7+
8+
use std::path::PathBuf;
9+
use std::process::Command;
10+
11+
fn perry_bin() -> PathBuf {
12+
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
13+
}
14+
15+
#[test]
16+
fn canonical_and_mutated_own_method_overrides_survive_shape_shortcut() {
17+
let dir = tempfile::tempdir().expect("tempdir");
18+
let entry = dir.path().join("main.ts");
19+
let output = dir.path().join("main_bin");
20+
std::fs::write(
21+
&entry,
22+
r#"
23+
interface Runner { run(): string }
24+
25+
class Base {
26+
run(): string { return "base"; }
27+
}
28+
29+
class FieldOverride extends Base {
30+
run = (): string => "field";
31+
}
32+
33+
class MutatedOverride extends Base {}
34+
35+
function invoke(value: Runner): string {
36+
return value.run();
37+
}
38+
39+
const mutated: any = new MutatedOverride();
40+
mutated.run = (): string => "mutated";
41+
42+
console.log(invoke(new Base()), invoke(new FieldOverride()), invoke(mutated));
43+
"#,
44+
)
45+
.expect("write source");
46+
47+
let compile = Command::new(perry_bin())
48+
.current_dir(dir.path())
49+
.arg("compile")
50+
.arg(&entry)
51+
.arg("-o")
52+
.arg(&output)
53+
.arg("--no-cache")
54+
.output()
55+
.expect("run perry compile");
56+
assert!(
57+
compile.status.success(),
58+
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
59+
String::from_utf8_lossy(&compile.stdout),
60+
String::from_utf8_lossy(&compile.stderr)
61+
);
62+
63+
let run = Command::new(&output)
64+
.current_dir(dir.path())
65+
.output()
66+
.expect("run compiled binary");
67+
assert!(
68+
run.status.success(),
69+
"compiled binary failed\nstdout:\n{}\nstderr:\n{}",
70+
String::from_utf8_lossy(&run.stdout),
71+
String::from_utf8_lossy(&run.stderr)
72+
);
73+
assert_eq!(String::from_utf8_lossy(&run.stdout), "base field mutated\n");
74+
}

0 commit comments

Comments
 (0)