Skip to content
Draft
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
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).
15 changes: 11 additions & 4 deletions crates/perry-codegen/src/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,16 @@ impl LlFunction {
self.stack_map_requested
}

/// Whether this function is rendered with `gc "statepoint-example"`, i.e.
/// whether its frames are described by the native GC map. The one
/// predicate the renderer and the GC map's zero-record listing share
/// (`gc_map::note_statepoint_functions`).
pub(crate) fn uses_statepoint_strategy(&self) -> bool {
self.stack_map_requested
&& !self.force_shadow_frame
&& crate::codegen::helpers::native_stack_roots_enabled()
}

/// Label of the last-created block — convenience for expression codegen
/// that needs to feed a phi node the predecessor label after compiling a
/// sub-expression whose control flow may have split.
Expand Down Expand Up @@ -1010,10 +1020,7 @@ impl LlFunction {
// on it and reintroduce the relocation fan-out the spill avoids. Its
// `stack_map_requested` is already false (enable_shadow_frame_inner
// took the shadow branch), so this is belt-and-braces.
let gc_strategy = if self.stack_map_requested
&& !self.force_shadow_frame
&& crate::codegen::helpers::native_stack_roots_enabled()
{
let gc_strategy = if self.uses_statepoint_strategy() {
" gc \"statepoint-example\""
} else {
""
Expand Down
105 changes: 103 additions & 2 deletions crates/perry-codegen/src/gc_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,105 @@ struct GcMapStats {
roots: usize,
}

/// Names of every statepoint-strategy function codegen rendered in this
/// process, recorded only while [`list_unrecorded_functions`] holds. Names are
/// module-prefixed, so a process-wide union across modules is unambiguous.
static STATEPOINT_FUNCTIONS: std::sync::Mutex<Option<std::collections::HashSet<String>>> =
std::sync::Mutex::new(None);

/// RFC deferred collection S5 (the unmapped-frame verifier): list, in the GC
/// map, the statepoint-strategy functions that have NO records — functions
/// whose every call is a `gc-leaf-function`. The runtime keeps them out of the
/// record index (v6 already skips zero-record entries) and uses them only to
/// recognise a generated frame that a collection found at an unmapped call.
///
/// Instrumented builds only: `PERRY_GC_INSTRUMENTS=1`, `PERRY_GC_VERIFY_FRAMES`
/// or `PERRY_GC_SCHEDULE_SEED` set at compile time — the same condition that
/// links the runtime's instruments. A shipped binary pays no map bytes for it.
pub(crate) fn list_unrecorded_functions() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| {
let set =
|value: Result<String, std::env::VarError>| value.is_ok_and(|v| !v.trim().is_empty());
std::env::var("PERRY_GC_INSTRUMENTS")
.is_ok_and(|v| matches!(v.trim(), "1" | "true" | "on" | "yes"))
|| set(std::env::var("PERRY_GC_VERIFY_FRAMES"))
|| set(std::env::var("PERRY_GC_SCHEDULE_SEED"))
})
}

/// Record `functions`' statepoint-strategy members for
/// [`append_unrecorded_functions`]. A no-op unless listing is on.
pub(crate) fn note_statepoint_functions(functions: &[&crate::function::LlFunction]) {
if !list_unrecorded_functions() {
return;
}
let mut guard = STATEPOINT_FUNCTIONS
.lock()
.unwrap_or_else(|p| p.into_inner());
let set = guard.get_or_insert_with(Default::default);
for function in functions {
// The latest render is authoritative: the RS4GC budget retry can move a
// function onto a shadow frame and render it again, and a stale entry
// would make the verifier treat its unmapped frames as a leaf bug.
if function.uses_statepoint_strategy() {
set.insert(function.name.clone());
} else {
set.remove(&function.name);
}
}
}

/// Append a zero-record entry for every recorded statepoint-strategy function
/// DEFINED in this assembly that the stack map does not already list.
fn append_unrecorded_functions(
lines: &[&str],
block: &RawBlock,
target: &str,
functions: &mut Vec<FunctionMap>,
) {
if !list_unrecorded_functions() {
return;
}
let guard = STATEPOINT_FUNCTIONS
.lock()
.unwrap_or_else(|p| p.into_inner());
let Some(names) = guard.as_ref() else {
return;
};
let prefix = if matches!(format_for(target), ObjectFormat::MachO) {
"_"
} else {
""
};
let mut listed: std::collections::HashSet<String> =
functions.iter().map(|f| f.symbol.clone()).collect();
for (index, line) in lines.iter().enumerate() {
if (block.start_line..block.end_line).contains(&index) {
continue;
}
let Some((label, rest)) = line.split_once(':') else {
continue;
};
if !(rest.is_empty() || rest.starts_with([' ', '\t']))
|| label.starts_with(['\t', ' ', '.', '"'])
{
continue;
}
let Some(name) = label.strip_prefix(prefix) else {
continue;
};
if names.contains(name) && listed.insert(label.to_string()) {
functions.push(FunctionMap {
symbol: label.to_string(),
stack_size: 0,
records: Vec::new(),
});
}
}
}

/// Rewrite the LLVM stack-map block in `asm` into the compact map.
///
/// Returns `None` when there is no stack-map block to rewrite (the common case
Expand All @@ -1229,7 +1328,8 @@ fn compact_stack_map_asm(asm: &str, target: &str) -> Result<Option<(String, GcMa
return Ok(None);
}
let block = parse_block(&lines, word_width_for(target))?;
let functions = decode_v3(&block)?;
let mut functions = decode_v3(&block)?;
append_unrecorded_functions(&lines, &block, target, &mut functions);
let stream = encode_stream(&functions)?;
verify_roundtrip(&functions, &stream)?;

Expand Down Expand Up @@ -1306,7 +1406,8 @@ pub(crate) fn decode_stack_map_roots(
return Err("assembly carries no stack-map section".to_string());
}
let block = parse_block(&lines, word_width_for(target))?;
let functions = decode_v3(&block)?;
let mut functions = decode_v3(&block)?;
append_unrecorded_functions(&lines, &block, target, &mut functions);
let stream = encode_stream(&functions)?;
verify_roundtrip(&functions, &stream)?;
Ok(functions
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ impl LlModule {
ir.push('\n');

let funcs = self.deduped_function_refs();
crate::gc_map::note_statepoint_functions(&funcs);
let gc_leaf_callees = if crate::codegen::helpers::native_stack_roots_enabled() {
crate::gc_call_effects::transitive_leaf_functions(&funcs)
} else {
Expand Down Expand Up @@ -767,6 +768,7 @@ impl LlModule {
/// into one object, keeping `compile_module`'s single-object API.
pub(crate) fn codegen_unit_parts(&self, n: usize) -> Vec<CodegenUnitPart<'_>> {
let funcs = self.deduped_function_refs();
crate::gc_map::note_statepoint_functions(&funcs);
let gc_leaf_callees = Arc::new(if crate::codegen::helpers::native_stack_roots_enabled() {
crate::gc_call_effects::transitive_leaf_functions(&funcs)
} else {
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/native_emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ fn build_native_module<'ctx>(context: &'ctx Context, llmod: &LlModule) -> Result
skeleton.push_str(&format!("declare {} @{}({})\n", f.return_type, f.name, tys));
}
let module = crate::inprocess::parse_ir_text(context, &skeleton, "perry_native_module")?;
crate::gc_map::note_statepoint_functions(&funcs);
let gc_leaf_callees = crate::gc_call_effects::transitive_leaf_functions(&funcs);
let (typed_insts, raw_insts) =
stream_functions(context, &module, &funcs, false, &gc_leaf_callees)?;
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/arena/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,14 @@ pub(crate) fn new_object_start_bitmap(size: usize) -> Box<[u64]> {
/// violation [`arena_cell_alloc`] exists to avoid, on the out-of-memory path.
/// `arena_cell_alloc` is the only caller; every `&mut self` path uses
/// [`alloc_block_no_gc`].
#[inline(never)]
pub(crate) fn reserve_arena_block(min_size: usize) -> ArenaBlock {
// Decision 10 of RFC deferred collection: growth an unsafe zone forced (no
// poll or valve can collect inside one). Diagnostic, and here rather than
// in `arena_cell_alloc`: that function is inlined into every allocation,
// and a call added there stops it being inlined (measured +3.6% retired
// instructions on bench_string_heavy).
crate::gc::note_block_if_unsafe_zone(block_size_for(min_size));
if let Some(block) = try_alloc_block(min_size, true) {
return block;
}
Expand Down
Loading
Loading