Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
757cb2d
wip: S5 runtime invariant
Sep 28, 2026
fcaaed3
wip: S5 safety net codegen + valve gate
Sep 28, 2026
88e54a3
wip: S5 fixes
Sep 28, 2026
7c74076
wip: S5 poll-wait metric
Sep 28, 2026
b9f39a1
wip: S5 verifier arm + intent skip
Sep 28, 2026
de5748e
wip: lock-flush tests serve at the poll
Sep 28, 2026
d4f4884
wip: keep poll armed for an owed request
Sep 28, 2026
e477ae9
wip: gc map listing follows re-render
Sep 28, 2026
4945608
wip: custody inventories, test-only helpers
Sep 28, 2026
3a4eb34
perf: keep arena_cell_alloc inlinable (unsafe-zone note on the block-…
Sep 28, 2026
259fdb3
style: cargo fmt
Sep 28, 2026
bbc27bc
changelog: #11630 allocation-point invariant
Sep 28, 2026
7553c70
gc: allocation-point and scan-request thread-locals use perry_thread_…
Sep 28, 2026
6638c71
gc: a poll serves a parked root scan one host-sized slice at a time
Sep 28, 2026
3cfe20f
census pin: re-audit after S5 follow-ups
Sep 28, 2026
95385cb
wip: S5 entry polls
Sep 28, 2026
cb4ea0f
wip: S5 entry poll tests + coverage checker
Sep 28, 2026
7615deb
wip: typed neutralise
Sep 28, 2026
3c3f73c
wip: regen wasm abi
Sep 28, 2026
95a13bf
wip: SCC poll only where the recursion allocates uncovered
Sep 28, 2026
fe288d2
gc effects: entry polls are declared polls
Sep 28, 2026
ddbb3bd
test: shadow_inline register pins account for the entry-poll scaffold…
Sep 28, 2026
74dbb08
statepoint report: entry-poll counts
Sep 28, 2026
d2198ab
style: cargo fmt
Sep 28, 2026
ffa03b2
changelog: #11631 entry polls
Sep 28, 2026
a0e4342
test: entry-poll placement asserts per clone family; poll-coverage ch…
Sep 28, 2026
0ff3c28
style: cargo fmt
Sep 28, 2026
f5710a4
test: entry-poll leaf expectations match what codegen can prove
Sep 28, 2026
593a229
census pin: re-audit for entry polls
Sep 28, 2026
7daff50
entry polls: not in class constructors (direct new-calls would become…
Sep 28, 2026
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
16 changes: 16 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2289,6 +2289,11 @@ jobs:
- name: GC rooting-bug instruments (inert-when-off, live-when-on)
run: ./scripts/gc_instrument_smoke.sh target/release/perry

# GATING (RFC deferred collection, decision 5): no allocation-point valve
# fires on the GC ratchet probes, and every probe reports.
- name: GC valve ledger over the ratchet probes
run: ./scripts/gc_valve_ratchet_probes.sh target/release/perry

- name: Run GC write-barrier stress tests
# Informational: these are ~200s nondeterministic corruption-window
# hunts (#5029). Kept out of the gate so a flake never blocks a PR.
Expand Down Expand Up @@ -3109,11 +3114,22 @@ jobs:
export PERRY_BIN="$PWD/target/release/perry"
export PERRY_RUNTIME_DIR="$PWD/target/release"
fi
# RFC deferred collection, decision 5: an allocation-point GC valve
# firing on the gap suite is a hard failure. Every Perry binary the
# harness runs appends one line to this ledger at exit; the check
# below requires a line per passing test (the proof it ran) and
# zero valve firings on every line.
export PERRY_GC_VALVE_LEDGER="$RUNNER_TEMP/gc-valve-ledger.txt"
rm -f "$PERRY_GC_VALVE_LEDGER"
python3 scripts/gc_valve_ledger_check.py --self-test
if [ "$GAP_TOTAL" = "1" ]; then
./scripts/run_gap_tests.sh
else
./scripts/run_gap_tests.sh --shard "$GAP_SHARD/$GAP_TOTAL"
fi
python3 scripts/gc_valve_ledger_check.py \
--ledger "$PERRY_GC_VALVE_LEDGER" \
--expect-min-from-report test-parity/reports/latest.json

# Only produced by a `workflow_dispatch` with update_gap_snapshot=true
# (one shard, whole suite). Download it and commit test-parity/
Expand Down
14 changes: 14 additions & 0 deletions changelog.d/11630-gc-alloc-point-invariant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
- **GC: an allocation never begins a precise or moving collection phase (RFC deferred collection, step S5, runtime half).** The allocation point is the dynamic extent of `gc_check_trigger`, which every allocation slow path, the JSON mid-parse checks and the root-lock flush of a trigger check funnel into. Inside it the collector may now only take a block, arm the poll, run heap-only budgeted work, or run one of the conservative non-moving arms (the nursery slack valve, the old-gen reclaim arm, which decision 1 keeps at the allocation point, and the emergency reclaim). Each allocation-point entry was routed:
- **A-assist** (`policy.rs` `gc_budgeted_start_or_step`, `cycle.rs` `step_root_scan` / the `FinalRootRemark` subphase): a budgeted cycle whose next step reads frame roots is parked, the poll is armed, and the next declared poll serves the phase (`gc_safepoint_moving_minor`). Heap-only phases still advance from assists. A parked cycle has its own valve: after the nursery slack (64 MiB, budget-scaled) with no poll, the phase is served at the allocation point and counted (`parked_valve_fires`). That valve is sound only while allocating calls are statepoints; it must be proven unreachable or replaced before S6.
- **D** (`flush_deferred_gc_request`): a root-lock exit no longer runs a deferred minor, full or manual `gc()`. It hands it to the next poll (option 2 of #11523). A deferred trigger check still runs, as an allocation-point evaluation.
- **A-old, A-valve, the polls-off direct minor, A-emerg** keep their forced conservative scan. A synchronous collection begun at an allocation point without requesting that scan now panics in every build (`gc/alloc_point.rs`, `assert_d2_synchronous_collection`). The check reads the request, so the unit-test isolation guards and `PERRY_CONSERVATIVE_STACK_SCAN=off` do not trip it.
- **`PERRY_GC_SAFEPOINT_ONLY`'s runtime contract is deleted.** Its heal and strict arms are gone, with `ConservativeScanSite::SafepointContractHeal`, because D2 is now the default. Codegen still reads the variable as its research switch for `AllocNoReentry` leaves; S6 replaces that.
- **Unmapped-frame verifier** (`gc/roots/stack_maps_frame_verify.rs`). A precise collection that walks a generated statepoint function suspended at a call with no stack-map record now panics instead of skipping the frame. That is the #11522 shape: a `gc-leaf-function` call whose callee collected. It is armed by `PERRY_GC_VERIFY_FRAMES=1` (a new GC instrument), by `PERRY_GC_SCHEDULE_SEED`, and in `debug_assertions` builds.
- Function membership comes from the unwinder's region start, or `_Unwind_Find_FDE` on the x29-chain walker.
- An instrumented compile (`PERRY_GC_INSTRUMENTS=1`, `PERRY_GC_VERIFY_FRAMES` or a seed at compile time) also lists zero-record statepoint functions in the GC map. v6 runtimes already skip zero-record entries, so the format is unchanged.
- It is off in release: every unmatched frame costs an unwind-table lookup, and the listing costs map bytes.
- **Valve gate (decision 5).** With `PERRY_GC_VALVE_LEDGER=<file>`, every process appends one line at exit recording its valve firings. `scripts/gc_valve_ledger_check.py` fails on any firing, and on fewer lines than passing tests, which proves the check ran. The pr-tier gap-suite shards and a new gc-stress step over the 14 ratchet probes (`scripts/gc_valve_ratchet_probes.sh`) run it. `OldReclaimAllocPoint` is reported, not gated.
- **Diagnostics.** `PERRY_GC_DIAG=1` prints `[gc-alloc-point]` and `[gc-verify-frames]`:
- valve firings, parked and served root phases, and owed requests;
- arena growth inside `GC_UNSAFE_ZONES` (decision 10, diagnostic only);
- `max_poll_wait_bytes`, the most the arena grew between arming a collection and reaching a poll (decision 3's measurement).
7 changes: 7 additions & 0 deletions changelog.d/11631-gc-entry-polls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **GC: function-entry polls (RFC deferred collection, step S5, decision 2).** Loop back-edge polls bound allocation in iteration, and two more kinds of repetition now pass a poll:
- **Recursion.** Each recursive SCC of a module's direct call graph gets one entry poll, in the member with the most incoming edges from inside the SCC. A loop-free recursive allocator now drains its nursery at that poll instead of growing to the valve.
- **Indirect entry.** Closure bodies, instance and static methods, and the `__perry_wrap_*` forwarders that make a top-level function a callback all poll at entry. A runtime loop over a callback (`map`, `sort`, iterators) reaches a poll once per element.

A non-recursive, directly called function gets none. The census measured 12.18 M relocations for polls at every entry against 7.39 M for this placement. Lowering emits a scaffold (the armed-word load, a branch, and a call to the new `js_gc_entry_safepoint`) after parameters are rooted. The whole-module pass `entry_polls::finalize_module` keeps a scaffold only in a function that the module leaf analysis, with entry polls ignored, already proves collecting. So no call changes classification: a kept poll sits only where every call was already a statepoint. A dropped scaffold's load becomes a constant and LLVM folds it away. A wrapper has no root slots, so it spills its arguments to a buffer, which `js_gc_entry_safepoint_args` roots across the collection and writes back. The kill switch is the loop polls' own (`PERRY_GC_MOVING_LOOP_POLLS=0`).
- **Poll-coverage checker** (`scripts/gc_poll_coverage_check.py`). Over emitted IR it reports every natural loop whose body may allocate without a poll, and every allocating recursive SCC without an entry poll. `--max-uncovered-*` makes those counts a ratchet, and `--self-test` proves the checker can fail.
- `gc_root_dominance_check.py` treats the entry polls as moving, like the back-edge poll. `PERRY_GC_DIAG=1` reports `entry_polls=` on `[gc-alloc-point]`.
8 changes: 8 additions & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,14 @@ pub(super) fn compile_closure(
buffer_alias_base,
};

// RFC deferred collection S5: the indirect-entry poll. After every
// parameter, capture and self-pointer root is bound (and after the entry
// reads through `%this_closure`); before `arguments`, which can allocate.
crate::entry_polls::emit_entry_poll(
&mut ctx,
body,
crate::entry_polls::EntryPollKind::Indirect,
);
super::arguments::materialize_arguments_object(
&mut ctx,
params,
Expand Down
19 changes: 16 additions & 3 deletions crates/perry-codegen/src/codegen/export_value_wrappers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,29 @@ pub(super) fn emit_export_value_wrappers(c: ExportValueWrapperCtx<'_>) {
let wrap_name = format!("__perry_wrap_{}", original_name);
let wf = llmod.define_function(&wrap_name, DOUBLE, wrap_params);
let _ = wf.create_block("entry");
let blk = wf.block_mut(0).unwrap();
// RFC deferred collection S5: this forwarder is how a top-level
// function is entered as a callback, so it carries the indirect-entry
// poll. The wrapper has no root slots, so the poll spills its
// arguments for the runtime to root and hands back the (possibly
// relocated) values; `finalize_module` drops it when the forwarded
// function is a proven leaf.
let (this_closure, forwarded) =
if crate::entry_polls::entry_polls_enabled() && !f.body.is_empty() {
crate::entry_polls::emit_wrapper_entry_poll(wf, "%this_closure", &arg_names)
} else {
("%this_closure".to_string(), arg_names.clone())
};
let last = wf.num_blocks() - 1;
let blk = wf.block_mut(last).unwrap();
// Call the underlying function with just the arg doubles.
let call_args: Vec<(LlvmType, &str)> =
arg_names.iter().map(|n| (DOUBLE, n.as_str())).collect();
forwarded.iter().map(|n| (DOUBLE, n.as_str())).collect();
let mut result = blk.call(DOUBLE, &original_name, &call_args);
if function_body_returns_generator_object(&f.body) {
result = blk.call(
DOUBLE,
"js_generator_attach_closure_prototype",
&[(DOUBLE, &result), (I64, "%this_closure")],
&[(DOUBLE, &result), (I64, &this_closure)],
);
}
blk.ret(DOUBLE, &result);
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,15 @@ pub(super) fn compile_function(
buffer_alias_base,
};

// RFC deferred collection S5: a top-level function is entered directly,
// so its entry poll survives only as the poll of a recursive SCC
// (`crate::entry_polls::finalize_module`). Parameters are rooted above;
// `arguments` materialisation below can already allocate.
crate::entry_polls::emit_entry_poll(
&mut ctx,
&f.body,
crate::entry_polls::EntryPollKind::Direct,
);
let wrapper_name = format!("__perry_wrap_{}", public_llvm_name);
super::arguments::materialize_arguments_object(
&mut ctx,
Expand Down
18 changes: 18 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,24 @@ pub(super) fn compile_method(
}
}

// RFC deferred collection S5: methods are entered through the class
// tables, so they carry the indirect-entry poll (see `crate::entry_polls`).
//
// NOT constructors. `new C(...)` calls the constructor body DIRECTLY, and
// an in-body poll would make every such call a MOVING collection point.
// Several `new` lowerings hold a slot-loaded value in a register across
// that call (the stale-register checker found three in the corpus), which
// was sound only because the call could not move. The RFC's stub design
// (a poll only on the indirect entry) is what makes a constructor poll
// safe; until it exists a constructor's allocation stays bounded by the
// polls of the loops and calls inside it, and by the valve.
if method.name != format!("{}_constructor", class.name) {
crate::entry_polls::emit_entry_poll(
&mut ctx,
method_body,
crate::entry_polls::EntryPollKind::Indirect,
);
}
super::arguments::materialize_arguments_object(
&mut ctx,
&method.params,
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/codegen/method_static.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,13 @@ pub(in crate::codegen) fn compile_static_method(
known_noalias_buffer_locals: native_facts.known_noalias_buffer_locals(),
buffer_alias_base,
};
// RFC deferred collection S5: static methods are entered through the
// class tables too — the indirect-entry poll (`crate::entry_polls`).
crate::entry_polls::emit_entry_poll(
&mut ctx,
&f.body,
crate::entry_polls::EntryPollKind::Indirect,
);
crate::codegen::arguments::materialize_arguments_object(
&mut ctx,
&f.params,
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3759,6 +3759,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
// See `crate::root_reload`.
progress.phase(1, "lowering complete; finalizing generated IR");
crate::root_reload::apply_to_module(&mut llmod);
// RFC deferred collection S5: keep the entry polls that make recursion and
// indirect entry reach a poll, drop the rest. Whole-module (it needs the
// leaf set and the call graph), and before any rendering path for the same
// reason as the pass above.
{
let mut functions: Vec<&mut crate::function::LlFunction> = llmod.functions_mut().collect();
crate::entry_polls::finalize_module(&mut functions);
}

crate::typed_feedback_profile::finish_module(&mut llmod.native_rep_records);

Expand Down
Loading
Loading