Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions changelog.d/7871-interp-round5-alloc-and-barrier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
### `interp` 1.095 → 0.844 s, `iso_miss` 1.464 → 1.234 s: two independent codegen gates

Quiet M1 mini, best-of-5, exit-checked, outputs byte-compared to
`node --experimental-strip-types`. The four binaries that come out
**byte-identical** across the two arms (`churn`, `push_cls`, `retain`, `fib40`)
set the run's noise floor at −0.1%/+0.3%, which is what makes the rest credible.

| bench | before | after | delta |
|---|--:|--:|--:|
| `interp` | 1.0945 | **0.8441** | **−22.9%** |
| `cycles` | 0.1115 | **0.0866** | **−22.3%** |
| `iso_miss` | 1.4635 | **1.2338** | **−15.7%** |
| `tree` | 1.1665 | 1.0222 | −12.4% |
| `tree_wide` | 1.6464 | 1.5216 | −7.6% |
| `deeplist` | 0.1071 | 0.1018 | −4.9% |
| `pipeline` | 0.2738 | 0.2643 | −3.5% |
| the other 12 | — | — | within ±0.3% |

#### 1. The class-field write barrier now tests the parent's generation

`expr/write_barrier.rs::emit_jsvalue_slot_store_pointer_tested` (#7511) put the
store's three bookkeeping calls behind one live test of the stored **value** —
"does this publish a heap pointer at all". It never asked the barrier's other
question, "is the parent old enough for anyone to care", even though
`emit_parent_may_need_remembering_check` sits 400 lines up in the same file.
That predicate had exactly one caller: `expr/array_push.rs`.

HIR rewrites every closed-shape object literal into a `new` of a synthesized
anon-shape class, so `{ kind: "num", num: n }` reaches a shared
`<class>_constructor` that writes its fields into an instance allocated a few
instructions earlier **in the nursery** — the `!TENURED` case, where the minor
GC retraces the parent anyway and the remembered-set record is pure cost. The
same predicate now gates the class-field store, on the identical argument
(`Old ⟹ TENURED`, plus the incremental-cycle count so SATB shading is never
skipped). It stays a **live header test**: a parent promoted between its
allocation and the store reads `TENURED` and takes the call.

#### 2. A hot recursive function may inline its bump allocator

`lower_call/new_alloc.rs::new_site_is_in_loop` admitted a `new` site to the
inline bump allocator only if it was lexically in a loop or its function was in
`collect_hot_loop_callees` — a set capped at **4 direct call sites module-wide**.
That cap is `inlinehint`'s anti-bloat backstop, where cost scales with call
sites because LLVM duplicates the body at each one. The inline bump allocator
costs ~268 bytes **per `new` site in the function**, once, whatever the caller
count, so the cap priced a cost that does not exist — and excluded exactly the
functions that earn it. `interp.ts`'s `evalNode` is the shape: the hottest
function in the program, one allocation per invocation, and 11 direct call
sites because ten of them are its own recursion.

New collector `collect_alloc_hot_functions` answers the allocator's question
with the allocator's cost model: ≥1 in-loop direct call site (**uncapped**), or
direct self-recursion — a function that calls itself is a loop the lexical test
cannot see. It is a second set, not a widening of `hot_loop_callees`: raising
the shared cap to 32 instead buys `interp` −26.5% but **regresses `iso_miss`
+4.0%**, because it also moves `inlinehint`.

`interp`'s compiled binary grows 16 KB (+0.13%).

#### Validation

19-program corpus, both arms: outputs byte-identical to node and exit 0,
including the `iso_miss` `misses 0` counter and `shapes`' `1176000`. 10 of 19
binaries are byte-identical across the arms, so only 9 needed timing at all.
The nine that differ also pass under `PERRY_GC_VERIFY_EVACUATION=1
PERRY_GC_FORCE_EVACUATE=1`, and five of them additionally under
`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800
PERRY_GC_SCHEDULE_RATE=1`.

`expr/class_field_barrier_tests.rs` is the gate, and it is **sabotage-verified**
rather than merely green. Its first draft asserted the TENURED mask and the
incremental-count load were *present in the branching block* — and passed a
sabotage that hard-wired the branch to `br i1 false` while leaving the dead
predicate instructions behind it. It now walks the def chain from the branch
condition (`or i1` → `icmp ne i8 … , 0` → `and i8 …, 32` → `load i8`; and
`icmp ne i32` → the atomic count load), and both sabotages — constant condition,
swapped successors — go red with the diagnostic that names the failure.

#### Refuted

`PERRY_WRITE_BARRIERS=0` is **not** a ceiling probe for barrier cost. It makes
`interp` 4.3× and `iso_miss` 5.4× *slower*: the knob is compile-time and the
GC's evacuation policy requires generated barriers to be active, so turning
them off makes the copying minor ineligible and the program falls back to full
mark-sweeps. It measures "no generational GC", not "no barrier".
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@ pub(super) fn compile_function(
// threshold move" — an `alwaysinline` callee is excluded from the latter
// and is the hottest possible case for the former.
lf.hot_loop_callee = cross_module.hot_loop_callees.contains(&f.id);
// #7871: the allocator's hotness set. Set from the same well-ordered point
// as `hot_loop_callee` (before the entry block exists and before any
// expression is lowered), for the same reason.
lf.alloc_hot = cross_module.alloc_hot_functions.contains(&f.id);
if !lf.force_inline
&& inline_hot_small_enabled()
&& (INLINE_HOT_SMALL_MIN..=inline_hot_small_size_cap()).contains(&f.body.len())
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
hir,
crate::codegen::helpers::inline_hot_small_max_call_sites(),
),
// #7871: the allocator's own "is this hot" set — same in-loop proxy,
// no call-site cap (the cap prices `inlinehint`'s duplication, which
// the inline bump allocator does not incur), plus direct recursion.
alloc_hot_functions: crate::collectors::collect_alloc_hot_functions(hir),
clamp3_functions: hir
.functions
.iter()
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -976,4 +976,10 @@ pub(crate) struct CrossModuleCtx {
/// `PERRY_INLINE_HOT_SMALL` is off (the flag is checked at the decision
/// site, so the set is still populated but simply not consulted).
pub hot_loop_callees: std::collections::HashSet<u32>,
/// #7871: `FuncId`s in THIS module whose `new` sites earn the inline bump
/// allocator — `collectors::collect_alloc_hot_functions`. Deliberately a
/// SECOND set rather than a widening of `hot_loop_callees`: that one gates
/// `inlinehint`, whose cost scales with call sites, and the two must not
/// share an admission rule. See the collector's doc comment.
pub alloc_hot_functions: std::collections::HashSet<u32>,
}
84 changes: 84 additions & 0 deletions crates/perry-codegen/src/collectors/hot_callees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,90 @@ pub fn collect_hot_loop_callees(hir: &Module, max_call_sites: u32) -> HashSet<u3
.collect()
}

/// #7871: the set of `FuncId`s whose bodies should be treated as **hot enough
/// to inline the bump allocator** at their `new` sites
/// (`lower_call/new_alloc.rs::new_site_is_in_loop`).
///
/// ## Why this is not [`collect_hot_loop_callees`]
///
/// It is the same "is this code hot" question **without the
/// `max_call_sites` cap**, because the cap answers a question the allocator
/// does not ask. `inlinehint` duplicates the whole callee body at every one of
/// its call sites, so its cost scales with call-site count and the cap is the
/// only thing bounding it. The inline bump allocator emits ~268 bytes **per
/// `new` site in the function itself**, once, whatever the call-site count —
/// so capping on call sites prices a cost that does not exist and, worse,
/// excludes precisely the functions that earn it.
///
/// `gc-handoff/apps/interp.ts`'s `evalNode` is the shape: it is *the* hot
/// function of the program (~20M invocations), it allocates a `Value` per
/// invocation, and it has 11 direct call sites — 10 of them its own recursion —
/// so the ≤4 cap excluded it and all eight of its object literals took the
/// outlined `js_object_alloc_class_inline_keys` call. Measured on the whole
/// 19-program corpus with `PERRY_INLINE_NEW=1` (which forces the inline form
/// everywhere): `interp` −16.2%, `iso_miss` −10.4%, `pipeline` −8.4%, and the
/// other 16 within a ±1.6% noise floor established by the 15 binaries that
/// come out byte-identical.
///
/// ## The two admission rules
///
/// 1. **≥1 direct call site inside a loop** — the existing proxy for "runs many
/// times", now uncapped.
/// 2. **Directly self-recursive** — a function that calls itself IS a loop, and
/// the existing lexical test cannot see it. `parseExpr`/`evalNode` are both;
/// a recursive descent whose entry call happens to sit in straight-line code
/// would otherwise pay the outlined allocator at every level of the
/// recursion.
///
/// Direction of error is unchanged from the sibling: under-inclusion forgoes
/// speed, never correctness — the outlined call performs the identical bump
/// alloc + header init and returns the identical user pointer.
pub fn collect_alloc_hot_functions(hir: &Module) -> HashSet<u32> {
let mut scan = HotCalleeScan::default();
walk_stmts(&hir.init, false, &mut scan);
for f in &hir.functions {
walk_stmts(&f.body, false, &mut scan);
}
for c in &hir.classes {
if let Some(ctor) = &c.constructor {
walk_stmts(&ctor.body, false, &mut scan);
}
for m in c.methods.iter().chain(c.static_methods.iter()) {
walk_stmts(&m.body, false, &mut scan);
}
for (_, g) in &c.getters {
walk_stmts(&g.body, false, &mut scan);
}
for (_, s) in &c.setters {
walk_stmts(&s.body, false, &mut scan);
}
for cm in &c.computed_members {
walk_expr(&cm.key_expr, false, &mut scan);
walk_stmts(&cm.function.body, false, &mut scan);
}
for field in c.fields.iter().chain(c.static_fields.iter()) {
if let Some(key) = &field.key_expr {
walk_expr(key, false, &mut scan);
}
if let Some(init) = &field.init {
walk_expr(init, false, &mut scan);
}
}
}
let mut hot = scan.in_loop;
// Rule 2: a direct self-call. Scanned per function so the recursion is
// attributed to the caller it actually appears in, which a whole-module
// call-count table cannot express.
for f in &hir.functions {
let mut self_scan = HotCalleeScan::default();
walk_stmts(&f.body, false, &mut self_scan);
if self_scan.call_counts.contains_key(&f.id) {
hot.insert(f.id);
}
}
hot
}

fn record_callee(callee: &Expr, in_loop: bool, scan: &mut HotCalleeScan) {
if let Expr::FuncRef(id) = callee {
*scan.call_counts.entry(*id).or_insert(0) += 1;
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ pub(crate) use hir_facts::{
collect_native_region_fact_graph, collect_native_region_fact_graph_with_spec_lens,
NativeRegionFactGraph,
};
pub(crate) use hot_callees::collect_hot_loop_callees;
pub(crate) use hot_callees::{collect_alloc_hot_functions, collect_hot_loop_callees};
pub(crate) use i32_locals::{
collect_integer_let_ids, collect_localset_ids_in_stmts, is_strictly_i32_bounded_expr,
is_ushr_zero,
Expand Down
Loading