Skip to content
Merged
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
3aec3e2
experiment(gc): prototype stack maps and statepoints
Jul 31, 2026
c5aead0
research(gc): measure and reduce native safepoints
Jul 31, 2026
416e6a6
research(gc): x29-chain fast walker for native stack-map roots
Jul 31, 2026
2e50534
docs: record x29-chain walker results and the plain-map Register-loca…
Jul 31, 2026
90169ac
research(gc): explicit-safepoint collection contract (PERRY_GC_SAFEPO…
Jul 31, 2026
72f5980
docs: explicit-safepoint contract design, enforcement levels, and cen…
Jul 31, 2026
ec9fcac
research(gc): enforce the safepoint contract on the copying-minor path
Jul 31, 2026
faefb5d
fix(gc): heal the safepoint contract through the shared scan override
Aug 1, 2026
d038276
fix(gc): delete the per-poll trigger drain from the safepoint contract
Aug 1, 2026
f74c0ce
docs: record contract gate results and the three bugs the gates caught
Aug 1, 2026
1984a39
docs: quiet-host matrix results from the reserved M1 mini
Aug 1, 2026
cda6ec0
research(gc): delete the plain-map user mode; elide statepoints at no…
Aug 1, 2026
a85c05f
docs: post-matrix follow-through - mode deletion, noreturn elision, m…
Aug 1, 2026
51dfa61
research(gc): compact per-function root metadata (PERRY_COMPACT_ROOTS)
Aug 1, 2026
c98b956
research(gc): delete the compact per-function mode - measured negativ…
Aug 1, 2026
5fa1a06
docs: real-app remeasurement - metadata 3.83MB (below model floor), t…
Aug 1, 2026
03d0763
docs: shadow-frame elision census - 7.7% of framed functions, 4.0% of…
Aug 1, 2026
b905b70
research(gc): second AllocNoReentry audit round - four admitted, two …
Aug 1, 2026
db3d0a6
docs: second audit round measurements - batch 442->172 (-61%), real-a…
Aug 1, 2026
18f9489
research(gc): first RS4GC pipeline slice (PERRY_RS4GC, #7174) - 5/8 p…
Aug 1, 2026
41978ad
research(gc): RS4GC slice fully gated - 16/16 with mem2reg-only place…
Aug 1, 2026
84e95eb
docs: RS4GC real-app measurement - text 248KB below shadow, metadata …
Aug 1, 2026
9e7acb0
docs: RS4GC runtime and RSS cells - fastest arm measured, RSS flat; c…
Aug 1, 2026
ecaafa9
docs: measure the repsel-erasure projection - slope is ZERO for lande…
Aug 1, 2026
1811988
research(gc): ELF/Linux stack-map scanner port (#7173) - compile-veri…
Aug 1, 2026
2cf567b
fix(gc): SP-relative fast-chain reconstruction is Darwin-only
Aug 1, 2026
aa6c037
docs: Linux verification (8/8 both arches) and Pi 5 small-hardware ti…
Aug 1, 2026
44ae2be
docs: aarch64-Linux frame constant proven non-existent - FP offset va…
Aug 1, 2026
f5157f7
ci(gc): native-root probe matrix on Linux (#7173)
Aug 1, 2026
96e42bc
docs: decompose the Pi +14.7% - it is DWARF CFI parsing in the unwind…
Aug 1, 2026
5ff7d7e
docs: real-app scale finding - statepoint IR doubles and codegen-unit…
Aug 1, 2026
6dad91a
fix(gc): mark inline asm as gc-leaf-function under RS4GC (#7174)
Aug 2, 2026
9ddf92b
fix(gc): RS4GC leaf-marks inline asm even in rootless functions (#7174)
Aug 2, 2026
73824c6
perf(codegen): emit each global into the units that reference it, not…
Aug 2, 2026
b6f024f
style: cargo fmt
Aug 2, 2026
ea980ad
perf(gc): decode the prologue to recover SP, re-enabling the fast wal…
Aug 2, 2026
f7940a2
fix(codegen): close global-to-global references transitively when spl…
Aug 2, 2026
c054599
perf(codegen): compile codegen units concurrently, bounded
Aug 2, 2026
6f9939d
perf(codegen): scope each unit's declarations to what it references
Aug 2, 2026
65e6430
docs: Pi small-hardware gap closed and inverted (+14.72% -> -1.74%), …
Aug 2, 2026
50c5229
gc: compact the stack map, closing the statepoint file-size gap
Aug 3, 2026
e768978
gc: ship the compact map, measured -131 KB against the shadow stack
Aug 3, 2026
d86d908
gc: fail loudly on an undecodable GC map, and skip compaction off Mac…
Aug 3, 2026
50408a9
gc: refuse to re-encode a stack map whose roots use a foreign registe…
Aug 3, 2026
96b20b8
Merge origin/main: invoke-EH lands, so statepoints cover every function
Aug 3, 2026
ef3f36c
gc: probe live roots across a throw, and record the RS4GC/landingpad gap
Aug 3, 2026
f89fa1d
gc: RS4GC accepts try functions (landingpad token), and fix a merge r…
Aug 3, 2026
6f134d2
docs: correct the size claim — statepoints tie, not win, after the ma…
Aug 3, 2026
e7a363c
gc: unbreak the Linux build, and point the Linux gate at the compact map
Aug 3, 2026
a10e6c2
gc: delete the unsound plain stack map — every root path now fails cl…
Aug 3, 2026
0972ed4
Merge remote-tracking branch 'origin/main' into exp/stackmap-viability
Aug 3, 2026
76fda7f
gc: retain the compact map on ELF, and make the gate runnable on main
Aug 3, 2026
ba879e0
docs: key the changelog fragment to the actual PR number (#7314)
Aug 3, 2026
afd3ad3
gc: address CodeRabbit review — two hangs/holes, one real format gap
Aug 3, 2026
055d414
Merge remote-tracking branch 'origin/main' into exp/stackmap-viability
Aug 3, 2026
48c553a
gc: a stack-map record must belong to the function the ip is in
Aug 3, 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
76 changes: 75 additions & 1 deletion crates/perry-runtime/src/gc/roots/stack_maps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ struct StackMapIndex {
/// Every root slot, referenced by `StackMapRecord`'s range. Shared between
/// records whose live sets are identical.
roots: Vec<StackMapLocation>,
/// Sorted, deduplicated start address of every function that has records.
/// Used to confirm a matched record belongs to the function `ip` is in.
function_starts: Vec<usize>,
chain_walkable: bool,
min_pc: usize,
max_pc: usize,
Expand Down Expand Up @@ -193,9 +196,16 @@ fn index_records(records: Vec<StackMapRecord>, roots: Vec<StackMapLocation>) ->
});
let min_pc = records.first().map_or(usize::MAX, |record| record.pc);
let max_pc = records.last().map_or(0, |record| record.pc);
let mut function_starts: Vec<usize> = records
.iter()
.map(|record| record.function_address)
.collect();
function_starts.sort_unstable();
function_starts.dedup();
StackMapIndex {
records,
roots,
function_starts,
chain_walkable,
min_pc,
max_pc,
Expand Down Expand Up @@ -275,13 +285,40 @@ impl StackMapIndex {
if ip.abs_diff(candidate_pc) > MAX_SAFEPOINT_RETURN_DELTA {
return &[];
}
// The ±16 window is a distance, not a containment check: nothing in it
// says the matched record belongs to the function `ip` is executing.
// Functions are adjacent in .text, so an `ip` early in B can sit within
// the window of a safepoint at the end of A — and the walker would then
// use A's frame offsets against B's frame and rewrite unrelated words.
//
// Require the record's function to be the one containing `ip`: the
// greatest mapped function start <= ip. Measured across the probe
// suite, every near-match is already same-function (deltas 8..64, all
// `same=true`), so this rejects only the cross-function case — and
// notably NOT the legitimate delta=8 match, which requiring an exact
// pc would have discarded along with its roots.
//
// Residual gap, stated rather than papered over: a function with no
// safepoints is absent from `function_starts`, so an `ip` inside one
// resolves to the previous mapped function. Closing that needs a
// per-function code extent, which Mach-O does not expose cheaply
// (`Lfunc_end` covers only EH-carrying functions; there is no `.size`).
let owning = self
.function_starts
.partition_point(|start| *start <= ip)
.checked_sub(1)
.map(|index| self.function_starts[index]);
let first = self
.records
.partition_point(|record| record.pc < candidate_pc);
let last = self
.records
.partition_point(|record| record.pc <= candidate_pc);
&self.records[first..last]
let matched = &self.records[first..last];
match (matched.first(), owning) {
(Some(record), Some(owning)) if record.function_address == owning => matched,
_ => &[],
Comment on lines +301 to +320

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/perry-runtime/src/gc/roots/stack_maps.rs --items all --type function

rg -n -C 6 'function_address|function_starts|parse_gc_map|record_count|function table' \
  crates/perry-runtime/src/gc/roots/stack_maps.rs

fd -t f -i 'gc_map\.rs' crates | while IFS= read -r file; do
  rg -n -C 6 'function|record|safepoint|GC_MAP' "$file"
done

Repository: PerryTS/perry

Length of output: 42090


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the matching/failure and consumption logic without running repository code.
sed -n '120,136,257,281,325,352,380,940,980,995,995,995,995,995,995,995,995,995,1265,1306,1343p' crates/perry-runtime/src/gc/roots/stack_maps.rs

# Behavioral probe of the documented matching strategy using the same partition logic
# and an ip in a mapless function B after function A.
python3 - <<'PY'
def resolve(ip, functions, records):
    function_starts = sorted(set(r[0] for r in records))
    idx = next((i for i, start in enumerate(function_starts) if start > ip), len(function_starts))
    owning = function_starts[idx - 1] if idx > 0 else None
    first = next((i for i, (pc, _) in enumerate(records) if pc >= ip - 16 or pc == records[0][0] if pc <= ip + 16 else None), record[0] <= ip + 16 and record[0] >= ip - 16 and record[0] < closest
): [rec[0] for rec in records if closest is None else []
    for rec in records:
        if closest is None:
            closest = rec
        elif rec[0] > closest[0]:
            break
    if ip <= closest[0]:
        # use closest
    else:
        # find largest record.pc <= ip + 16?
    pass
PY

Repository: PerryTS/perry

Length of output: 203


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant stack-map matching, consumption, and regression ranges without running repository code.
sed -n '120,180p' crates/perry-runtime/src/gc/roots/stack_maps.rs
sed -n '253,322p' crates/perry-runtime/src/gc/roots/stack_maps.rs
sed -n '325,390p' crates/perry-runtime/src/gc/roots/stack_maps.rs
sed -n '920,995p' crates/perry-runtime/src/gc/roots/stack_maps.rs
sed -n '1264,1355p' crates/perry-runtime/src/gc/roots/stack_maps.rs

# Deterministic probe of the matching algorithm from source text:
# - function_starts = sorted unique function_address of records.
# - owning = greatest function_starts <= ip via partition_point.
# - A record matches only if its function_address == owning.
python3 - <<'PY'
import bisect

def match_records(ip, records):
    function_starts = sorted({r[0] for r in records})
    idx = bisect.bisect_right(function_starts, ip)
    owning = function_starts[idx - 1] if idx > 0 else None
    candidates = [(pc, func_addr) for func_addr, _, pc in records if pc <= ip + 16 and pc >= ip - 16]
    matched = [r for r in candidates if r[0] == owning]
    # Sort by the algorithm's closest record order
    closest = min(matched, key=lambda r: r[0], default=None)
    return owning, closest

records = [
    ("A", 0xcc, 0x1ffc),  # function A, record pc +16 delta
    ("B", 0x20, 0x2040), # function B present with second record; first record omitted for adjacent cross-function range
]
print("adjacent-present:", match_records(0x2004, records))

records_no_b = [
    ("A", 0xcc, 0x1ffc),
    ("B", 0x20, 0x2010), # present but outside nearest safepoint window at 0x203c/0x2010?
]
print("adjacent-present-window:", match_records(0x2004, records_no_b))

no_mapless_function = [("A", 0xcc, 0x1ffc)]
print("adjacent-mapless:", match_records(0x2004, no_mapless_function))
PY

Repository: PerryTS/perry

Length of output: 15594


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Simulate the exact partition-point matching behavior for the mapless-between-functions case.
python3 - <<'PY'
import bisect

def nearest_record_pc(records, ip):
    masks = [r["pc"] for r in records]
    idx = bisect.bisect_left(masks, ip)
    before = (idx - 1, masks[idx - 1]) if idx > 0 else None
    after = (idx, masks[idx]) if idx < len(masks) else None
    if before is None:
        return (after[0], after[1]) if after else None
    if after is None:
        return before
    return after if ip - before[1] >= after[1] - ip else before

def match_records(records, ip):
    function_starts = sorted({r["function_address"] for r in records})
    idx = bisect.bisect_right(function_starts, ip)
    owning = function_starts[idx - 1] if idx > 0 else None
    candidate = nearest_record_pc(records, ip)
    if candidate is None:
        return owning
    record_pc, pc_idx = candidate
    if abs(record_pc - ip) > 16:
        return owning
    first = bisect.bisect_left([r["pc"] for r in records], records[pc_idx]["pc"])
    last = bisect.bisect_right([r["pc"] for r in records], records[pc_idx]["pc"])
    matched = records[first:last]
    if matched and owning is not None:
        return owning if matched[0]["function_address"] == owning else None
    return None

records = [
    {"pc": 0x1ffc, "function_address": 0x1000},
    {"pc": 0x2040, "function_address": 0x2000},
]
print(match_records(records, 0x2004) == 0x2000)

records_mapless = [
    {"pc": 0x1ffc, "function_address": 0x1000},
    {"pc": 0x2010, "function_address": 0x2000},
]
print(match_records(records_mapless, 0x2004) == 0x2000)
PY

Repository: PerryTS/perry

Length of output: 161


Fail closed when containment cannot be proved.

function_starts only holds addresses from records, so a function with no safepoints is omitted. An executable PC inside that function can still satisfy the ±16 closest-PC check and fall before the next function start; the current containment check accepts the previous function’s records. Use a source that includes mapless function extents, or disable the native match when the current map cannot contain the matching record.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/roots/stack_maps.rs` around lines 301 - 320,
Update the native matching logic around function_starts and the matched record
selection to fail closed when function containment cannot be proven. Use a
source that includes extents for functions without safepoints, or reject the
candidate whenever the current map cannot establish that candidate_pc belongs to
the matched record’s function; do not accept ownership based solely on the
previous function start.

}
}
}

Expand Down Expand Up @@ -1265,6 +1302,43 @@ mod tests {
);
}

#[test]
fn rejects_a_record_from_an_adjacent_function() {
// A safepoint at the end of A must not be matched for an `ip` early in
// B just because it falls inside the +-16 window: the walker would use
// A's frame offsets against B's frame.
let index = index_records(
vec![
StackMapRecord {
pc: 0x1ffc,
function_address: 0x1000,
stack_size: 32,
roots_start: 0,
roots_len: 1,
},
StackMapRecord {
pc: 0x2040,
function_address: 0x2000,
stack_size: 32,
roots_start: 0,
roots_len: 1,
},
],
vec![StackMapLocation {
dwarf_reg: 29,
offset: -8,
}],
);
// 0x2004 is 8 bytes past A's last safepoint but lives in B.
assert!(
index.match_records(0x2004).is_empty(),
"a record from the previous function must not match"
);
// A same-function near-match is still accepted — requiring an exact pc
// would drop it, and the measured suite has one.
assert_eq!(index.match_records(0x2038).len(), 1);
}

#[test]
fn matches_plain_maps_before_and_statepoints_after_unwinder_ips() {
let rec = |pc: usize| StackMapRecord {
Expand Down
Loading