Skip to content

Commit 05edeac

Browse files
proggeramlugRalph Küpper
andauthored
perf(codegen): the PIC miss block re-derived the whole receiver ladder — interp −11.2% instructions (#7907)
* perf(codegen): make the PIC miss block dominated by the token block The polymorphic-way block re-derived every value the way compares need -- four header loads, keys_array, parent_class_id, the token select and a second pair of epoch loads -- because #7883 routed the two receiver-validation failures into the same block, leaving those values live on only some edges. Route those two edges to a new pic.miss.cold instead: a receiver that fails them also fails way_hit, so the compares were dead work for it, and with them gone pic.miss is dominated by pic.token and can use its values directly. Also spell the cached-slot bound as slot < FLOOR || slot < field_count instead of materialising max(field_count, FLOOR); identical predicate, one fewer dependency on the field_count load. * perf(codegen): reduce the PIC way (token, slot) match as a balanced tree The left fold made way_slot a chain of PIC_WAYS dependent selects whose last node is the operand of the bounds compare that gates the branch out of pic.ways. At most one way can hold a given token, so the association is free to change. Adds the codegen contracts for both halves of #7902. * docs(codegen): reference PR #7907 and add the changelog fragment Also fixes the pic.ways slice in way_slot_reduction_is_a_balanced_tree: block labels carry a numeric suffix, so the search for the next block has to start past this block's own label or it matches itself. --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 98e9ecd commit 05edeac

3 files changed

Lines changed: 291 additions & 73 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
### Performance
2+
3+
**The generic property-get IC's miss block re-derived the whole receiver ladder.**
4+
`interp.ts` retires **11.2% fewer instructions**, `iso_miss.ts` **7.6% fewer**, and
5+
`evalNode`'s emitted code shrinks from 6516 to 5348 instructions.
6+
7+
#7883 routed all four of the guard chain's failure edges — small-handle receiver,
8+
non-object receiver, MRU token mismatch, cached slot out of bounds — into a single
9+
`pic.miss` block. That left `token`, `token_nonnull` and `epoch_eq` live on only
10+
some of those edges, so the block **recomputed** them: four header loads and
11+
compares, the `keys_array` and `parent_class_id` loads, the token select, a second
12+
pair of `cache[2]` / `@PERRY_IC_EPOCH` loads, and a `select` substituting a safe
13+
address for a small-handle receiver.
14+
15+
It was justified as cold. It is not cold. On a site whose receiver rotates over
16+
more shapes than the MRU entry holds — the shape #7753's polymorphic ways exist
17+
for, i.e. every discriminated-union dispatch — that block runs on nearly every
18+
read. An `xctrace` profile of `gc-handoff/apps/interp.ts` put the **single hottest
19+
instruction in the whole program** inside the recomputation: the `csel`
20+
materialising `max(field_count, INLINE_SLOT_FLOOR)`, at 4.65% of `evalNode`, itself
21+
56.6% of the program. (`sample` cannot profile a deeply recursive function and
22+
attributes that time to the return addresses of `evalNode`'s own recursive calls.)
23+
24+
Two of the four edges are receiver-validation failures, and a receiver that fails
25+
them can never resolve a way — `way_hit` ANDs `is_object` in, so the compares were
26+
dead work for it. Routing just those two to a new `pic.miss.cold` (which records the
27+
same two typed-feedback counters and goes straight to `js_object_get_field_ic_miss`)
28+
makes `pic.miss` **dominated by `pic.token`**, and every re-derived value is
29+
deletable: they are the values that block already computed, from the same memory
30+
with no intervening store, and `is_object` is statically true.
31+
32+
Two smaller changes ride along, both value-preserving:
33+
34+
* The cached-slot bound is spelled `slot < INLINE_SLOT_FLOOR || slot < field_count`
35+
instead of `slot < max(field_count, INLINE_SLOT_FLOOR)`. Identical predicate
36+
(`x < max(a, b)``x < a ∨ x < b`), but the `max` had to be materialised and its
37+
`csel` sat on the dependency chain out of the `field_count` load; LLVM folds the
38+
disjunction into `cmp` + `ccmp`, and the `slot < 4` half does not depend on the
39+
load at all.
40+
* The way `(token, slot)` match reduces as a balanced tree rather than a left fold,
41+
halving the depth of the `select` chain whose last node feeds the bounds compare
42+
that gates the branch out of `pic.ways`. At most one way can hold a given token
43+
(`pic_prime_get` evicts a duplicate before writing one, and a zero token is
44+
excluded by `token_nonnull`), so reassociating is value-preserving.
45+
46+
Codegen only — nothing under `perry-runtime` / `perry-stdlib` changes. Validated
47+
with all 19 `gc-handoff` corpus programs byte-exact against
48+
`node --experimental-strip-types` and exit 0, the `iso_miss` canary at
49+
`checksum 437840 misses 0`, the whole corpus byte-exact under
50+
`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=200
51+
PERRY_GC_VERIFY_EVACUATION=1` with the instrument shown live (38 retired page-sets
52+
on `interp`, 50 on `iso_miss`), and a differential run of the whole `test_gap_*`
53+
suite compiled AND executed under both compilers with stdout and exit code compared.
54+
55+
Three new codegen contracts in `expr/property_get/tests.rs` assert the consequences
56+
rather than the block names — one `@PERRY_IC_EPOCH` load per generic read, no
57+
small-handle sentinel `ptrtoint`, no materialised `max`, and one lane select per way
58+
— and all three go red against the pre-change lowering.

crates/perry-codegen/src/expr/property_get/generic_dispatch.rs

Lines changed: 123 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@ pub(crate) const PIC_WAYS: usize = 4;
3333
/// both skip them. Mirrors the runtime's `PIC_WAY_STATE`.
3434
pub(crate) const PIC_WAY_STATE: usize = 3;
3535

36+
/// `slot < max(field_count, INLINE_SLOT_FLOOR)` — the per-receiver
37+
/// inline-capacity bound both the MRU hit path and the polymorphic ways apply
38+
/// to a cached slot (#6804).
39+
///
40+
/// Spelled as the equivalent disjunction `slot < FLOOR || slot < field_count`
41+
/// rather than as a `max` followed by one compare. The predicate is identical
42+
/// for every input (`x < max(a, b)` ⟺ `x < a ∨ x < b`), but the `max` had to be
43+
/// materialised — `mov w, #4` / `cmp` / `csel` — and that `csel` was the single
44+
/// hottest instruction in `interp.ts` (4.65% of `evalNode`, #7907), because it
45+
/// sits on the dependency chain out of the `field_count` load. The disjunction
46+
/// has no such node: LLVM folds the pair into `cmp` + `ccmp`, and the
47+
/// `slot < 4` half does not depend on the load at all.
48+
fn emit_slot_in_bounds(ctx: &mut FnCtx<'_>, slot: &str, field_count: &str) -> String {
49+
let below_floor = ctx.block().icmp_ult(I64, slot, "4"); // INLINE_SLOT_FLOOR
50+
let below_count = ctx.block().icmp_ult(I64, slot, field_count);
51+
ctx.block().or(I1, &below_floor, &below_count)
52+
}
53+
3654
/// The generic per-site monomorphic inline-cache dispatch for `obj.property`.
3755
/// This is the fall-through tail of the general catch-all arm: all earlier
3856
/// specializations have been ruled out.
@@ -329,9 +347,16 @@ pub(crate) fn lower_generic_property_get(
329347
// what the flat predicate computed there).
330348
let hit_idx = ctx.new_block("pic.hit");
331349
let miss_idx = ctx.new_block("pic.miss");
350+
// #7907: the two receiver-validation failures get their own landing block
351+
// so `pic.miss` is dominated by `pic.token`. See the comment on
352+
// `pic.miss.cold` below for why that is the whole point of this split.
353+
let cold_idx = ctx.new_block("pic.miss.cold");
354+
let call_idx = ctx.new_block("pic.miss.call");
332355
let merge_idx = ctx.new_block("pic.merge");
333356
let hit_label = ctx.block_label(hit_idx);
334357
let miss_label = ctx.block_label(miss_idx);
358+
let cold_label = ctx.block_label(cold_idx);
359+
let call_label = ctx.block_label(call_idx);
335360
let merge_label = ctx.block_label(merge_idx);
336361
let hdr_idx = ctx.new_block("pic.recv_hdr");
337362
let hdr_label = ctx.block_label(hdr_idx);
@@ -344,8 +369,10 @@ pub(crate) fn lower_generic_property_get(
344369
// materialisation) in front of every real object read. The miss path
345370
// still substitutes the sentinel, because the way compares below load
346371
// `field_count` unconditionally.
347-
// (edge labels are no longer needed: the miss block recomputes.)
348-
ctx.block().cond_br(&is_real_ptr, &hdr_label, &miss_label);
372+
// A small-handle receiver can never resolve a way (`way_hit` requires a
373+
// real object), so it leaves for `pic.miss.cold` and never enters the
374+
// block the ways live in.
375+
ctx.block().cond_br(&is_real_ptr, &hdr_label, &cold_label);
349376
ctx.current_block = hdr_idx;
350377

351378
// GcHeader sits 8 bytes before the user pointer; obj_type is the
@@ -421,7 +448,13 @@ pub(crate) fn lower_generic_property_get(
421448
// below: the keys load, the token select and the two epoch loads all
422449
// hang off the same predicate, so a non-object receiver used to execute
423450
// them before the flat `hit` could reject it.
424-
ctx.block().cond_br(&is_object, &tok_label, &miss_label);
451+
//
452+
// #7907: the false edge goes to `pic.miss.cold`, not `pic.miss` — a
453+
// receiver that is not a plain descriptor-free `ObjectHeader` fails
454+
// `way_hit` by construction, so consulting the ways for it was always dead
455+
// work, and keeping it out is what lets `pic.miss` reuse this block's
456+
// values instead of re-deriving them.
457+
ctx.block().cond_br(&is_object, &tok_label, &cold_label);
425458
ctx.current_block = tok_idx;
426459

427460
// Load obj->keys_array at offset 16 of ObjectHeader.
@@ -510,9 +543,7 @@ pub(crate) fn lower_generic_property_get(
510543
let fc_ptr = ctx.block().inttoptr(I64, &fc_addr);
511544
let fc = ctx.block().load(I32, &fc_ptr);
512545
let fc64 = ctx.block().zext(I32, &fc, I64);
513-
let fc_floor = ctx.block().icmp_ult(I64, &fc64, "4"); // INLINE_SLOT_FLOOR
514-
let limit = ctx.block().select(I1, &fc_floor, I64, "4", &fc64);
515-
let slot_in_bounds = ctx.block().icmp_ult(I64, &slot, &limit);
546+
let slot_in_bounds = emit_slot_in_bounds(ctx, &slot, &fc64);
516547
let bounds_hit = ctx.new_block("pic.hit.load");
517548
let bounds_hit_label = ctx.block_label(bounds_hit);
518549
ctx.block()
@@ -557,62 +588,34 @@ pub(crate) fn lower_generic_property_get(
557588
// way hit still reports guard-fail + fallback-call exactly as it did when
558589
// it was a real miss — the feedback heuristics see an unchanged signal
559590
// (the site IS polymorphic; only the cost of that changed).
560-
ctx.current_block = miss_idx;
561-
// #7883: the guard chain now branches out at three points, so the values
562-
// the polymorphic way compares consult are no longer live on every edge
563-
// into this block — and phi-ing them would drag their materialisation
564-
// (`cset`/`csinc` per value) back onto the hot path, which is the whole
565-
// point of branching. They are recomputed here instead, from the SAME
566-
// memory with no intervening store, so every one is bit-identical to
567-
// what the pre-#7883 flat predicate computed. This block is cold — every
568-
// path out of it either loads a way slot or calls the miss handler.
569591
//
570-
// The small-handle sentinel substitution lives here for the same reason:
571-
// the way compares load `field_count` unconditionally, and a native
572-
// registry-id receiver reaches this block without ever being a pointer.
573-
let cache_addr = ctx.block().ptrtoint(&cache_ref, I64);
574-
let safe_obj_handle = ctx
575-
.block()
576-
.select(I1, &is_real_ptr, I64, &obj_handle, &cache_addr);
577-
let m_gc_type_addr = ctx.block().sub(I64, &safe_obj_handle, "8");
578-
let m_gc_type_ptr = ctx.block().inttoptr(I64, &m_gc_type_addr);
579-
let m_gc_type = ctx.block().load(I8, &m_gc_type_ptr);
580-
let m_gc_type_ok = ctx.block().icmp_eq(I8, &m_gc_type, "2");
581-
let is_object = ctx.block().and(I1, &is_real_ptr, &m_gc_type_ok);
582-
let m_magic_addr = ctx.block().add(I64, &safe_obj_handle, "12");
583-
let m_magic_ptr = ctx.block().inttoptr(I64, &m_magic_addr);
584-
let m_magic = ctx.block().load(I32, &m_magic_ptr);
585-
let m_is_closure = ctx.block().icmp_eq(I32, &m_magic, "1129268819");
586-
let m_not_closure = ctx.block().xor(I1, &m_is_closure, "true");
587-
let is_object = ctx.block().and(I1, &is_object, &m_not_closure);
588-
let m_ot_ptr = ctx.block().inttoptr(I64, &safe_obj_handle);
589-
let m_ot = ctx.block().load(I32, &m_ot_ptr);
590-
let m_ot_ok = ctx.block().icmp_eq(I32, &m_ot, "1");
591-
let is_object = ctx.block().and(I1, &is_object, &m_ot_ok);
592-
let m_res_addr = ctx.block().sub(I64, &safe_obj_handle, "6");
593-
let m_res_ptr = ctx.block().inttoptr(I64, &m_res_addr);
594-
let m_res = ctx.block().load(crate::types::I16, &m_res_ptr);
595-
let m_has_desc = ctx.block().and(crate::types::I16, &m_res, "2048");
596-
let m_no_desc = ctx.block().icmp_eq(crate::types::I16, &m_has_desc, "0");
597-
let is_object = ctx.block().and(I1, &is_object, &m_no_desc);
598-
let m_keys_addr = ctx.block().add(I64, &safe_obj_handle, "16");
599-
let m_keys_ptr = ctx.block().inttoptr(I64, &m_keys_addr);
600-
let m_keys = ctx.block().load(I64, &m_keys_ptr);
601-
let m_pcid_addr = ctx.block().add(I64, &safe_obj_handle, "8");
602-
let m_pcid_ptr = ctx.block().inttoptr(I64, &m_pcid_addr);
603-
let m_pcid = ctx.block().load(I32, &m_pcid_ptr);
604-
let m_pcid_rel = ctx.block().add(I32, &m_pcid, "-2147483648");
605-
let m_is_stamp = ctx.block().icmp_ult(I32, &m_pcid_rel, "1073741824");
606-
let m_pcid64 = ctx.block().zext(I32, &m_pcid, I64);
607-
let m_id_token = ctx.block().or(I64, &m_pcid64, "4611686018427387904");
608-
let token = ctx
609-
.block()
610-
.select(I1, &m_is_stamp, I64, &m_id_token, &m_keys);
611-
let token_nonnull = ctx.block().icmp_ne(I64, &token, "0");
612-
let m_cache_epoch_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "2")]);
613-
let m_cache_epoch = ctx.block().load(I64, &m_cache_epoch_ptr);
614-
let m_live_epoch = ctx.block().load(I64, "@PERRY_IC_EPOCH");
615-
let epoch_eq = ctx.block().icmp_eq(I64, &m_cache_epoch, &m_live_epoch);
592+
// # Why this block is DOMINATED by `pic.token` (#7907)
593+
//
594+
// Its only predecessors are `pic.token` (the MRU token did not match) and
595+
// `pic.hit` (it matched but the cached slot is outside this receiver's
596+
// inline capacity), and `pic.hit` is itself dominated by `pic.token`. So
597+
// `token`, `token_nonnull` and `epoch_eq` — everything the way compares
598+
// need — are already in scope here, and `is_object` is statically TRUE.
599+
//
600+
// #7883 could not rely on that: it routed the two receiver-validation
601+
// failures here as well, which left the values live on only some edges, so
602+
// the block **re-derived them** — four header loads, the `keys_array` and
603+
// `parent_class_id` loads, the token select, a second pair of epoch loads,
604+
// and a `select` substituting a safe address for a small-handle receiver.
605+
// That was correct, and it was justified as cold. It is not cold: on a site
606+
// whose receiver rotates over more shapes than the MRU entry holds — the
607+
// shape #7753's ways exist for — this block runs on nearly every read, so
608+
// the duplicate ladder sat on the hot path. Measured on `interp.ts`'s
609+
// `evalNode`, the single hottest instruction in the whole program was the
610+
// `csel` materialising `max(field_count, INLINE_SLOT_FLOOR)` *inside this
611+
// recomputation*.
612+
//
613+
// Sending the two validation failures to `pic.miss.cold` instead is what
614+
// establishes the dominance. Nothing about the predicate changed: a
615+
// receiver that fails either check also fails `way_hit` (which ANDs
616+
// `is_object` in), so it could never have resolved a way — the compares
617+
// were dead work for it.
618+
ctx.current_block = miss_idx;
616619
crate::expr::emit_typed_feedback_record_call(
617620
ctx.block(),
618621
"js_typed_feedback_record_guard_fail",
@@ -655,16 +658,23 @@ pub(crate) fn lower_generic_property_get(
655658
let way_state = ctx.block().load(I64, &state_ptr);
656659
let ways_live = ctx.block().icmp_sgt(I64, &way_state, "0");
657660
let ways_idx = ctx.new_block("pic.ways");
658-
let call_idx = ctx.new_block("pic.miss.call");
659661
let ways_label = ctx.block_label(ways_idx);
660-
let call_label = ctx.block_label(call_idx);
661662
ctx.block().cond_br(&ways_live, &ways_label, &call_label);
662663

663664
ctx.current_block = ways_idx;
664-
let mut way_hit = ctx.block().and(I1, &is_object, &epoch_eq);
665-
way_hit = ctx.block().and(I1, &way_hit, &token_nonnull);
666-
let mut way_any = String::from("false");
667-
let mut way_slot = String::from("0");
665+
// `is_object` is not ANDed in any more: it is statically true on every edge
666+
// that reaches here (#7907 — see the dominance note above). `epoch_eq` and
667+
// `token_nonnull` are the values `pic.token` computed, from the same memory
668+
// with no intervening store, so the predicate is unchanged.
669+
let mut way_hit = ctx.block().and(I1, &epoch_eq, &token_nonnull);
670+
// Reduced as a BALANCED TREE, not as a left fold. At most one way can hold
671+
// a given token (`pic_prime_get` evicts a duplicate before it writes one,
672+
// and a zero token is excluded by `token_nonnull`), so the association is
673+
// free to change — but the fold made `way_slot` a chain of `PIC_WAYS`
674+
// dependent `csel`s whose last node is the operand of the bounds compare
675+
// that gates the branch out of this block. On `interp.ts` that node was the
676+
// hottest instruction in `evalNode` (#7907). The tree halves the chain.
677+
let mut lanes: Vec<(String, String)> = Vec::with_capacity(PIC_WAYS);
668678
for w in 0..PIC_WAYS {
669679
let tok_ptr = ctx.block().gep(
670680
I64,
@@ -679,20 +689,40 @@ pub(crate) fn lower_generic_property_get(
679689
&[(I64, &(PIC_WAY_BASE + w * 2 + 1).to_string())],
680690
);
681691
let way_slot_val = ctx.block().load(I64, &slot_ptr);
682-
way_slot = ctx.block().select(I1, &eq, I64, &way_slot_val, &way_slot);
683-
way_any = ctx.block().or(I1, &way_any, &eq);
692+
let lane_slot = ctx.block().select(I1, &eq, I64, &way_slot_val, "0");
693+
lanes.push((eq, lane_slot));
694+
}
695+
while lanes.len() > 1 {
696+
let mut merged: Vec<(String, String)> = Vec::with_capacity(lanes.len().div_ceil(2));
697+
for pair in lanes.chunks(2) {
698+
match pair {
699+
[(a_any, a_slot), (b_any, b_slot)] => {
700+
let any = ctx.block().or(I1, a_any, b_any);
701+
let slot = ctx.block().select(I1, a_any, I64, a_slot, b_slot);
702+
merged.push((any, slot));
703+
}
704+
[single] => merged.push(single.clone()),
705+
_ => unreachable!("chunks(2) yields one or two elements"),
706+
}
707+
}
708+
lanes = merged;
684709
}
710+
let (way_any, way_slot) = lanes
711+
.pop()
712+
.expect("PIC_WAYS is non-zero, so the reduction leaves exactly one lane");
685713
way_hit = ctx.block().and(I1, &way_hit, &way_any);
686714
// Same per-receiver inline-capacity bound the MRU hit path applies: a slot
687715
// primed from a larger-capacity sibling of the same shape must not drive a
688716
// raw load past this receiver's field region (#6804).
689-
let way_fc_addr = ctx.block().add(I64, &safe_obj_handle, "12");
717+
//
718+
// The load is off `obj_handle` rather than the deleted small-handle
719+
// sentinel, so it is the SAME address `pic.recv_hdr` already read for the
720+
// closure-magic check and GVN folds the two together.
721+
let way_fc_addr = ctx.block().add(I64, &obj_handle, "12");
690722
let way_fc_ptr = ctx.block().inttoptr(I64, &way_fc_addr);
691723
let way_fc = ctx.block().load(I32, &way_fc_ptr);
692724
let way_fc64 = ctx.block().zext(I32, &way_fc, I64);
693-
let way_fc_floor = ctx.block().icmp_ult(I64, &way_fc64, "4"); // INLINE_SLOT_FLOOR
694-
let way_limit = ctx.block().select(I1, &way_fc_floor, I64, "4", &way_fc64);
695-
let way_in_bounds = ctx.block().icmp_ult(I64, &way_slot, &way_limit);
725+
let way_in_bounds = emit_slot_in_bounds(ctx, &way_slot, &way_fc64);
696726
let way_ok = ctx.block().and(I1, &way_hit, &way_in_bounds);
697727
let way_load_idx = ctx.new_block("pic.way.load");
698728
let way_load_label = ctx.block_label(way_load_idx);
@@ -707,6 +737,26 @@ pub(crate) fn lower_generic_property_get(
707737
let way_end_label = ctx.block().label.clone();
708738
ctx.block().br(&merge_label);
709739

740+
// #7907: receiver-validation failure. `way_hit` requires a real pointer to
741+
// a plain descriptor-free `ObjectHeader`, so a receiver that got here can
742+
// never match a way — it goes straight to the handler, which reproduces the
743+
// whole ladder anyway (proxy band, closure magic, buffer/typed-array
744+
// registries, small-handle dispatch). The typed-feedback counters are the
745+
// same two `pic.miss` records on the same edges, so the feedback signal is
746+
// byte-identical to what the merged block reported.
747+
ctx.current_block = cold_idx;
748+
crate::expr::emit_typed_feedback_record_call(
749+
ctx.block(),
750+
"js_typed_feedback_record_guard_fail",
751+
&[(I64, &feedback_site_id)],
752+
);
753+
crate::expr::emit_typed_feedback_record_call(
754+
ctx.block(),
755+
"js_typed_feedback_record_fallback_call",
756+
&[(I64, &feedback_site_id)],
757+
);
758+
ctx.block().br(&call_label);
759+
710760
// PIC miss: slow path with cache population.
711761
ctx.current_block = call_idx;
712762
let val_miss = ctx.block().call(

0 commit comments

Comments
 (0)