Skip to content

Commit 99705c9

Browse files
proggeramlugRalph Küpper
andauthored
perf(gc): don't mint per-object pointer masks for single-slot payloads (#7812)
`interp.ts` spent ~19% of its runtime in `layout_forget_object`, plus ~6% in the hashbrown probe underneath it, against a `LAYOUT_SLOT_MASKS` that had grown past 400,000 live entries. Instrumented on the isolated FIB half, the `PER_OBJECT_LAYOUTS_NONEMPTY` fast path fired 52 times in 15,000,000 calls. The interpreter allocates `{ names: [p], vals: [a], parent }` per interpreted call, so `layout_note_slot`'s "first pointer into a POINTER_FREE object" arm minted 1.8M masks over payloads of exactly one slot. A mask over one slot can skip nothing -- the tracer tag-checks that slot either way -- but the entry it creates keeps the emptiness flag armed, which puts a two-map hash probe back on every allocation in the program for as long as it lives. Both mint sites now decline the mask below DEFAULT_MASK_MIN_SLOTS and use GC_LAYOUT_UNKNOWN, the tag-checked scan-all-slots state that is already the established fallback on this path. The tag check is exact here: neither site is reachable for an object with an intact typed descriptor, so no raw-f64 slot can be misread as a pointer. Quiet M1 mini, best-of-5, interleaved against the same binaries with the policy disabled: interp 1.894 -> 1.697, iso_miss 2.371 -> 2.157, new bench/mask_tax probe 0.1218 -> 0.1049 with its numeric-element control flat at 1.000. No regression on the rest of the 19-benchmark corpus, including tree, tree_wide, retain*, cycles and deeplist. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 1d22273 commit 99705c9

5 files changed

Lines changed: 377 additions & 22 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
### GC: stop minting per-object pointer masks for single-slot payloads
2+
3+
`interp.ts` — the tree-walking interpreter that best resembles real software in
4+
the benchmark corpus — spent **~19% of its runtime in `layout_forget_object`**,
5+
plus another ~6% in the hashbrown probe underneath it. That is side-table
6+
bookkeeping, not user work, and by design it should have been ~zero: #7510's
7+
`PER_OBJECT_LAYOUTS_NONEMPTY` flag exists so that the allocation, store, death
8+
and relocation paths can skip both per-object layout maps whenever they are
9+
empty, which "on a monomorphic workload they are".
10+
11+
They were not. Instrumented on `iso_FIB.ts` (the isolated FIB half):
12+
13+
```
14+
forget_total=15,000,000 fast=52 slow=14,999,948
15+
residency: masks=313,875 -> 381,505 -> 400,430 (still climbing)
16+
```
17+
18+
The disarmed fast path fired **52 times in 15 million calls**. Every other call
19+
took two `RefCell` round-trips and two hashes against a 400k-entry, cache-cold
20+
map — once per allocation, program-wide.
21+
22+
**Cause.** `layout_note_slot`'s "first pointer stored into a `POINTER_FREE`
23+
object" arm minted a per-object entry in `LAYOUT_SLOT_MASKS`. The interpreter
24+
allocates `{ names: [p], vals: [a], parent }` per interpreted call, so it minted
25+
two masks per call — **1.8M of them**, each a mask over a payload of exactly
26+
**one slot**. A mask over one slot cannot skip anything: the tracer consults
27+
`layout_pointer_bearing_bits` on that slot either way, so the entry was the
28+
mask's entire contribution. The entries also outlive their arrays — they are
29+
only reclaimed when the recycled address is allocated over — so residency grew
30+
without bound, and a single live entry anywhere keeps the flag armed for every
31+
allocation in the program. This is #7510's "one immortal entry nullifies
32+
`is_empty()`" a second time, from the other direction.
33+
34+
**Fix.** Both mint sites (`layout_note_slot` and
35+
`layout_rebuild_from_slots_with_policy`) now decline the mask when the payload
36+
is below `DEFAULT_MASK_MIN_SLOTS` (2, i.e. single-slot payloads only) and use
37+
`GC_LAYOUT_UNKNOWN` — the tag-checked scan-all-slots state — instead. That state
38+
is already the established fallback on this exact path, and the tag check is
39+
exact here: neither site is reachable for an object with an intact typed
40+
descriptor, so there are no raw-f64 slots whose bits could be misread as a
41+
pointer. `PERRY_LAYOUT_MASK_MIN_SLOTS` overrides the threshold for bisection.
42+
43+
Two details worth keeping:
44+
45+
- An array reports its `length`, but **only for a store into an already-formed
46+
array**. Every append protocol notes the slot *before* bumping `length`, so
47+
mid-construction `length` is the pre-append value; judging on it stranded
48+
every incrementally built array — a `push` loop, a JSON parse — in the scan
49+
state regardless of final size. Capacity is not a substitute either:
50+
`MIN_ARRAY_CAPACITY` is 16, so a one-element literal reports 16 and the
51+
distinction disappears entirely.
52+
- An object reports a bound derived from `GcHeader::size`, not `field_count`,
53+
because `size` is maintained for every GC allocation whatever its
54+
type-specific header holds.
55+
56+
Both directions of error are *correct*, only differently priced: over-estimating
57+
mints a mask that was not needed (the old behaviour), and under-estimating
58+
routes the object to a scan that visits a superset of what the mask would have
59+
selected. Neither can hide a live child.
60+
61+
**Measured** (quiet M1 mini, best-of-5, interleaved against the same binaries
62+
with the policy disabled, outputs byte-identical to node and exit codes checked):
63+
64+
| bench | before | after |
65+
|---|--:|--:|
66+
| `interp` | 1.894 | **1.697** |
67+
| `iso_miss` | 2.371 | **2.157** |
68+
| `bench/mask_tax` (new probe) | 0.1218 | **0.1049** |
69+
| `bench/mask_tax_nopointer` (control) | 0.0929 | 0.0929 |
70+
71+
No regression anywhere on the 19-benchmark corpus, including the GC-heavy
72+
`tree`, `tree_wide`, `retain*`, `cycles` and `deeplist`. The correctness canary
73+
(`iso_miss` printing `checksum 437840 misses 0`) holds plain and under
74+
`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`,
75+
`PERRY_GC_VERIFY_EVACUATION=1` and `PERRY_GC_FORCE_EVACUATE=1`.
76+
77+
**New probe.** `gc-handoff/bench/mask_tax.ts` reduces the interpreter's
78+
environment chain to the shape that mints the masks, with
79+
`mask_tax_nopointer.ts` as a numeric-element control that holds flat at 1.000.
80+
The arrays have to genuinely escape: a first version kept them in a local,
81+
codegen scalar-replaced the array away, and the probe measured a 1.000 ratio
82+
while the bug was fully intact.
83+
84+
**Left on the table, deliberately.** Raising the threshold to 9 or above pays
85+
roughly twice as much (`interp` 1.619, `iso_miss` 2.046) with still no
86+
regression on the corpus, but 21 tests in this crate encode "a small mixed
87+
payload uses a mask" as a precondition (5 do at 2, 11 at 3, saturating at 21
88+
from 9). That is a contract change worth making deliberately rather than as a
89+
side effect of a perf patch.

crates/perry-runtime/src/gc/layout.rs

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -909,16 +909,25 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits
909909
set_layout_state(header, GC_LAYOUT_SIDE_MASK);
910910
}
911911
} else if (*header)._reserved & GC_LAYOUT_STATE_MASK == GC_LAYOUT_POINTER_FREE {
912-
if super::layout_tables::immortal_layout_scope_active() {
913-
// An object built inside an `ImmortalLayoutScope` is
912+
if super::layout_tables::immortal_layout_scope_active()
913+
|| super::layout_tables::layout_prefers_scan_over_mask(
914+
header,
915+
parent_user,
916+
slot_index,
917+
)
918+
{
919+
// Two reasons to decline the mask, one fallback. An
920+
// object built inside an `ImmortalLayoutScope` is
914921
// rooted for the life of the process, so the entry it
915922
// would mint here is never removed — and one such
916923
// entry disables `PER_OBJECT_LAYOUTS_NONEMPTY` for
917-
// every allocation the program will ever make. Take
924+
// every allocation the program will ever make (see
925+
// `ImmortalLayoutScope`). And a payload too small for
926+
// the mask to earn its side-table entry
927+
// (`layout_prefers_scan_over_mask`) skips nothing the
928+
// tag-checked scan would not check anyway. Both take
918929
// the same `GC_LAYOUT_UNKNOWN` fallback the `else`
919-
// arm below uses for this exact situation; see
920-
// `ImmortalLayoutScope` for why that is the safe
921-
// state and not a weaker one.
930+
// arm below uses for this exact situation.
922931
set_layout_state(header, GC_LAYOUT_UNKNOWN);
923932
} else {
924933
let mut mask = LayoutSlotMask::Inline(0);
@@ -1354,13 +1363,17 @@ pub(super) unsafe fn layout_rebuild_from_slots_with_policy(
13541363
if mask.is_empty() {
13551364
set_layout_state(header, GC_LAYOUT_POINTER_FREE);
13561365
slot_masks_remove(user_ptr as usize);
1357-
} else if super::layout_tables::immortal_layout_scope_active() {
1358-
// Same reasoning as the `layout_note_slot` branch: an object built
1359-
// inside an `ImmortalLayoutScope` never dies, so the mask it would
1360-
// install here is a permanent tenant of a side table whose emptiness
1361-
// is a process-wide fast path. Falling back to the tag-checked scan is
1362-
// sound *for this rebuild specifically* because the mask above is
1363-
// itself derived from `layout_pointer_bearing_bits` — exactly the test
1366+
} else if super::layout_tables::immortal_layout_scope_active()
1367+
|| slot_count < super::layout_tables::layout_mask_min_slots()
1368+
{
1369+
// Same two reasons as the `layout_note_slot` branch, same fallback. An
1370+
// object built inside an `ImmortalLayoutScope` never dies, so the mask
1371+
// it would install here is a permanent tenant of a side table whose
1372+
// emptiness is a process-wide fast path; and too few slots means the
1373+
// mask cannot earn its side-table entry — the tag-checked scan is
1374+
// exact and costs the program nothing globally. Falling back is sound
1375+
// *for this rebuild specifically* because the mask above is itself
1376+
// derived from `layout_pointer_bearing_bits` — exactly the test
13641377
// `GC_LAYOUT_UNKNOWN` re-runs per slot. (This is why the scope may not
13651378
// be applied to a TYPED descriptor, whose raw-f64 slots the tag test
13661379
// would misread; see `ImmortalLayoutScope`.)

crates/perry-runtime/src/gc/layout_tables.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
3232
use super::hot_tls::{hot_layout_slot_masks, hot_per_object_layout_hint, hot_typed_layouts};
3333
use super::layout::{LayoutSlotMask, TypedLayoutDescriptor};
34+
use super::types::{GcHeader, GC_HEADER_SIZE, GC_TYPE_ARRAY, GC_TYPE_OBJECT};
3435
use std::cell::{Cell, RefCell};
3536

3637
thread_local! {
@@ -301,6 +302,71 @@ pub(crate) fn per_object_layout_table_sizes() -> (usize, usize) {
301302
)
302303
}
303304

305+
/// Smallest payload slot count for which minting a **per-object pointer mask**
306+
/// is worth its side-table entry. Below it the object takes
307+
/// `GC_LAYOUT_UNKNOWN` — the tag-checked scan-all-slots state — instead.
308+
///
309+
/// The two sides are not symmetric. A mask's benefit is bounded by the object:
310+
/// it can skip at most `slots - pointers` tag checks per trace. Its cost is
311+
/// **program-global and unbounded** — one live entry arms
312+
/// [`PER_OBJECT_LAYOUTS_NONEMPTY`], which puts a two-map hash probe back on
313+
/// every allocation anywhere in the program for as long as that entry lives
314+
/// (see the module docs, and #7510's "one immortal entry nullifies
315+
/// `is_empty()`"). At the bottom of the range the asymmetry is total rather
316+
/// than merely lopsided: over a **single** slot a mask cannot skip anything at
317+
/// all, because the tracer consults `layout_pointer_bearing_bits` on that one
318+
/// slot either way, so the entry is the mask's entire contribution.
319+
///
320+
/// A tag check is exact at both mint sites: neither is reached for an object
321+
/// with an intact typed descriptor, so there are no raw-f64 slots whose bits a
322+
/// tag check could misread as a pointer. #7630 recorded the same conclusion for
323+
/// the materialiser cohort — "a pointer mask can never skip anything a tag
324+
/// check would not reject anyway ... the mask machinery buys nothing here".
325+
///
326+
/// `PERRY_LAYOUT_MASK_MIN_SLOTS` overrides it for bisection.
327+
#[inline(always)]
328+
pub(in crate::gc) fn layout_mask_min_slots() -> usize {
329+
use std::sync::atomic::{AtomicUsize, Ordering};
330+
/// `usize::MAX` = "not yet read from the environment".
331+
static N: AtomicUsize = AtomicUsize::new(usize::MAX);
332+
match N.load(Ordering::Relaxed) {
333+
usize::MAX => {
334+
let v = std::env::var("PERRY_LAYOUT_MASK_MIN_SLOTS")
335+
.ok()
336+
.and_then(|s| s.parse::<usize>().ok())
337+
.unwrap_or(DEFAULT_MASK_MIN_SLOTS);
338+
N.store(v, Ordering::Relaxed);
339+
v
340+
}
341+
v => v,
342+
}
343+
}
344+
345+
/// Only single-slot payloads take the scan. This is deliberately the
346+
/// *provable* end of the range: at one slot the mask demonstrably skips
347+
/// nothing, so no judgement about tracing cost is being made.
348+
///
349+
/// Measured on the 19-benchmark corpus (quiet M1 mini, best-of-5, interleaved
350+
/// against the same binaries with the policy disabled):
351+
///
352+
/// | bench | before | after |
353+
/// |---|--:|--:|
354+
/// | `interp` | 1.894 | **1.697** |
355+
/// | `iso_miss` | 2.371 | **2.157** |
356+
/// | `bench/mask_tax` | 0.1218 | **0.1049** |
357+
/// | `bench/mask_tax_nopointer` (control) | 0.0929 | 0.0929 |
358+
///
359+
/// Every other benchmark — including the GC-heavy `tree`, `tree_wide`,
360+
/// `retain*`, `cycles`, `deeplist` — is unchanged within noise.
361+
///
362+
/// Raising it pays roughly twice as much and costs test churn, both measured:
363+
/// `9` and above gives `interp` 1.619 / `iso_miss` 2.046 with still no
364+
/// regression on the corpus, but 21 tests in this crate encode "a small mixed
365+
/// payload uses a mask" as a precondition (5 do at `2`, 11 at `3`, saturating
366+
/// at 21 from `9`). That is a contract change worth making on purpose rather
367+
/// than as a side effect of a perf patch.
368+
pub(in crate::gc) const DEFAULT_MASK_MIN_SLOTS: usize = 2;
369+
304370
/// True when either per-object side table may hold an entry. `false` is a
305371
/// proof of emptiness (see [`PER_OBJECT_LAYOUTS_NONEMPTY`]); `true` is only a
306372
/// hint, so every caller still has to handle a miss.
@@ -499,3 +565,68 @@ pub(in crate::gc) fn layout_forget_object(user_ptr: usize) {
499565
pub(in crate::gc) fn test_per_object_tables_are_empty() -> bool {
500566
hot_layout_slot_masks().borrow().is_empty() && hot_typed_layouts().borrow().is_empty()
501567
}
568+
569+
/// An upper bound on the payload slots the tracer would enumerate for
570+
/// `user_ptr`, or `usize::MAX` when this module cannot cheaply tell.
571+
///
572+
/// Both directions of error are *correct*, only differently priced, which is
573+
/// what lets this be a bound rather than an exact count: over-estimating mints
574+
/// a mask that was not needed (the pre-existing behaviour), and
575+
/// under-estimating routes the object to `GC_LAYOUT_UNKNOWN`, where the tracer
576+
/// scans every slot and so visits a superset of what a mask would have
577+
/// selected. Neither can hide a live child.
578+
///
579+
/// An array reports its `length` — exactly the range the tracer walks, and so
580+
/// exactly the bound on what a mask could skip — but **only for a store into an
581+
/// already-formed array**. A store at the append position (`slot_index >=
582+
/// length`) reports `usize::MAX` instead, because every append protocol writes
583+
/// the element and notes the slot *before* bumping `length` (see
584+
/// [`layout_all_pointer_array_append`]): mid-construction `length` is the
585+
/// pre-append value, usually 0 or 1, and judging on it would strand every
586+
/// incrementally built array — a `push` loop, a JSON parse — in the scan state
587+
/// no matter how large it eventually grew. Capacity is not a substitute:
588+
/// `MIN_ARRAY_CAPACITY` is 16, so a one-element literal reports 16 and the
589+
/// distinction this is drawing disappears.
590+
///
591+
/// An object reports the bound derived from [`GcHeader::size`] rather than its
592+
/// `field_count`: `size` is maintained for every GC allocation whatever its
593+
/// type-specific header says, so this stays correct for a payload that is not a
594+
/// well-formed `ObjectHeader`, and it errs high — towards the old mask path.
595+
#[inline]
596+
pub(in crate::gc) unsafe fn layout_payload_slot_count(
597+
header: *const GcHeader,
598+
user_ptr: usize,
599+
slot_index: usize,
600+
) -> usize {
601+
match (*header).obj_type {
602+
GC_TYPE_ARRAY => {
603+
let arr = user_ptr as *const crate::array::ArrayHeader;
604+
let length = (*arr).length as usize;
605+
let capacity = (*arr).capacity as usize;
606+
if length > capacity || length > 16_000_000 || slot_index >= length {
607+
usize::MAX
608+
} else {
609+
length
610+
}
611+
}
612+
GC_TYPE_OBJECT => {
613+
let size = (*header).size as usize;
614+
match size.checked_sub(GC_HEADER_SIZE) {
615+
Some(payload) => payload / 8,
616+
None => usize::MAX,
617+
}
618+
}
619+
_ => usize::MAX,
620+
}
621+
}
622+
623+
/// True when `user_ptr` is small enough that a tag-checked scan of every slot
624+
/// beats a per-object pointer mask. See [`layout_mask_min_slots`].
625+
#[inline]
626+
pub(in crate::gc) unsafe fn layout_prefers_scan_over_mask(
627+
header: *const GcHeader,
628+
user_ptr: usize,
629+
slot_index: usize,
630+
) -> bool {
631+
layout_payload_slot_count(header, user_ptr, slot_index) < layout_mask_min_slots()
632+
}

0 commit comments

Comments
 (0)