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
33 changes: 20 additions & 13 deletions .github/workflows/gc-native-roots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,19 +273,26 @@ jobs:
"$py" scripts/statepoint_report_assert.py /tmp/rs4gc-report.json \
--only-backend rs4gc

# #7354: the Windows walker liveness gate. It is the one walker with
# no Itanium unwinder under it and no verify-mode cross-check, and a
# walker that visits zero frames still lets most probes print the
# right answer (other root sources cover them). Non-zero
# frames/records/locations telemetry is the only proof it ran.
if [ "$RUNNER_OS" = "Windows" ]; then
PERRY_GC_TRACE=1 PERRY_RS4GC=1 \
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
"/tmp/rs4gc-04_dead_after_deep_stack$exe" > /dev/null 2> /tmp/rs4gc-trace.err
"$py" scripts/gc_walker_trace_assert.py /tmp/rs4gc-trace.err \
--require-locations
fi
# Walker liveness, on EVERY arm. A walker that visits zero frames
# still lets most probes print the right answer, because other root
# sources cover them — so a green matrix is not evidence the walker
# ran. Only non-zero frames/records/locations telemetry is.
#
# This used to be Windows-only (#7354) for a good reason: it was the
# only arm that could pass it. Measured on `04_dead_after_deep_stack`,
# macOS and Linux reported 7 frames and ZERO locations, because every
# probe in the suite collected from a shallow stack at exit. Windows
# only walked deep by accident of heap sizing.
#
# `11_collect_at_depth` collects at maximum recursion depth with a
# live root in every frame, so all three arms now walk a real stack —
# 228 frames and 221 locations on macOS, where the old best was 0.
PERRY_GC_TRACE=1 PERRY_RS4GC=1 \
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
"/tmp/rs4gc-11_collect_at_depth$exe" > /dev/null 2> /tmp/rs4gc-trace.err
"$py" scripts/gc_walker_trace_assert.py /tmp/rs4gc-trace.err \
--require-locations

# #7327. Everything above pins PERRY_LLVM_OPT + PERRY_LLVM_CLANG to one
# brew install, because RS4GC piped IR through an external `opt` and a
Expand Down
70 changes: 70 additions & 0 deletions benchmarks/gc_ratchet/probes/11_collect_at_depth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// GC ratchet probe: collect WHILE the stack is deep, with a live root in
// every frame.
//
// Every other probe in this suite calls `gc()` at the end, from a shallow
// stack, so the native-root walker has almost nothing to walk. Measured on the
// same suite: `04_dead_after_deep_stack` reports 7 frames visited and **zero**
// root locations on macOS and Linux — those arms would pass unchanged with a
// walker that visited nothing at all. The Windows arm only exercises a deep
// walk (5,626 frames, 5,449 records) by accident of heap sizing, not by design.
//
// This probe makes that coverage deliberate and portable. `descend` holds a
// heap value live ACROSS its recursive call, and the collection happens at the
// deepest point — so at collection time there is one live root per frame, all
// of them mid-frame rather than in the leaf. A walker that stops early, or a
// map whose bases are wrong, loses roots that are still read afterwards, and
// the checksum diverges from the oracle instead of the run merely being slower.
//
// Under `PERRY_GC_FORCE_EVACUATE=1` every survivor also MOVES, so a stale
// pointer is a wrong value rather than a lucky one.

declare function gc(): void;

const DEPTH = 220;
const ROUNDS = 3;

class Payload {
tag: number;
body: string;
constructor(tag: number) {
this.tag = tag;
this.body = "d" + tag;
}
value(): number {
return (this.tag + this.body.length) | 0;
}
}

let escape: Payload | null = null;

function descend(depth: number): number {
// Live across the recursive call below, which is where the collection
// happens. Its contents are read after that call returns, so the collector
// must have found and relocated this slot.
const mine = new Payload(depth);

if (depth === 0) {
// Deepest frame: collect with ~DEPTH live roots resident on the stack.
escape = mine;
gc();
escape = null;
return mine.value();
}

const deeper = descend(depth - 1);
return (mine.value() + deeper) | 0;
}

let checksum = 0;
for (let round = 0; round < ROUNDS; round++) {
checksum = (checksum + descend(DEPTH)) | 0;
}

gc();
const mu = process.memoryUsage();

console.log("probe:11_collect_at_depth");
console.log("checksum:" + checksum);
console.error("#gcmetric heap_used_bytes=" + mu.heapUsed);
console.error("#gcmetric heap_total_bytes=" + mu.heapTotal);
console.error("#gcmetric rss_bytes=" + mu.rss);
35 changes: 35 additions & 0 deletions changelog.d/7359-deep-stack-collect-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
### Tests

**GC ratchet: a probe that collects at stack depth, and walker-liveness gating on every platform.**

The native-root stack walker had no probe that exercised it. Every probe in
`benchmarks/gc_ratchet/probes/` calls `gc()` at the end, from a shallow stack,
so on macOS and Linux even `04_dead_after_deep_stack` reported
`frames_visited: 7, locations_visited: 0` — **zero** root locations. Both arms
would have passed the whole suite unchanged with a walker that visited nothing,
because other root sources covered those probes. Windows walked a deep stack
(5,626 frames) only by accident of heap sizing, and that accident is the sole
reason the `--require-locations` gate added in #7354 could be applied there and
nowhere else. This is the fourth failure mode in CLAUDE.md's list: the gate ran,
but its subject never did.

`11_collect_at_depth` makes the coverage deliberate. `descend` holds a heap
value live *across* its recursive call and collects at the deepest point, so at
collection time there is one live root per frame, all mid-frame rather than in
the leaf. Each slot is read after the collection returns, so a walker that stops
early — or a map with a wrong base register — yields a wrong checksum rather
than a merely slower run; under `PERRY_GC_FORCE_EVACUATE=1` every survivor
moves, so a stale pointer cannot be accidentally correct.

Measured against the pinned Node oracle, byte-identical on both:

| arm | frames | locations | before |
|---|---|---|---|
| macOS aarch64 | 228 | 221 | 7 / 0 |
| x86-64 Linux | 231 | 221 | 7 / 0 |

With both Unix arms now walking a real stack, `gc_walker_trace_assert.py
--require-locations` moves off the Windows-only branch in
`.github/workflows/gc-native-roots.yml` and gates all three arms. Full ratchet
suite: 11/11 byte-identical under `PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1
PERRY_GC_VERIFY_EVACUATION=1`.
Loading