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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ jobs:
- name: GC matrix liveness gate
if: ${{ !cancelled() }}
run: |
./scripts/gc_repsel_matrix.sh --self-test-liveness-parser
python3 scripts/gc_matrix_liveness_check.py --self-test
python3 scripts/gc_matrix_liveness_check.py --check-registry

Expand Down
3 changes: 3 additions & 0 deletions changelog.d/8056-copy-minor-liveness-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed moving-GC gate liveness accounting for the current copying-minor
diagnostic format. The gates now count ordinary survivor copies and promotions
as relocation while continuing to reject non-moving whole-block promotions.
104 changes: 86 additions & 18 deletions crates/perry/tests/gc_copy_minor_under_heap_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,34 +26,99 @@
//! *pre*-#7019 non-moving collector.
//!
//! What this test pins is the observable that distinguishes those two worlds:
//! `copied_objects > 0`. Asserting "a GC cycle happened" does not — the broken
//! build collected too. Exit 0 does not either; the broken build exited 0.
//! a non-in-place copying minor relocated at least one object. Asserting "a GC
//! cycle happened" does not — the broken build collected too. Exit 0 does not
//! either; the broken build exited 0.

use std::path::PathBuf;
use std::process::Command;

// `Command` inherits the test runner's environment. Several of these knobs
// affect generated code as well as runtime collector policy, so clear the
// established override family from BOTH subprocesses before applying this
// test's intended heap-limit arm.
const GC_ENV_OVERRIDES: &[&str] = &[
"PERRY_GEN_GC",
"PERRY_GC_SCAVENGE",
"PERRY_GC_SCAVENGE_NURSERY_MB",
"PERRY_GC_MOVING_SAFEPOINT",
"PERRY_GC_MOVING_LOOP_POLLS",
"PERRY_GC_FORCE_EVACUATE",
"PERRY_CONSERVATIVE_STACK_SCAN",
"PERRY_WRITE_BARRIERS",
"PERRY_GC_INCREMENTAL",
"PERRY_GC_HEAP_LIMIT",
];

fn remove_gc_env_overrides(command: &mut Command) {
for key in GC_ENV_OVERRIDES {
command.env_remove(key);
}
}

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

/// Sum of every `[gc-copy-minor] ran copied_objects=N` in the collector's
/// `PERRY_GC_DIAG` output. This is the copying minor's OWN counter: the
/// Sum objects relocated by `[gc-copy-minor] ran ...` records in the
/// collector's `PERRY_GC_DIAG` output. This uses the copying minor's OWN
/// `copied_objects` and `promoted_objects` counters: the
/// `moved_objects=` counter that also appears there belongs to the C4b
/// evacuation policy inside the mark-sweep collector, a different collector
/// entirely, and summing the two is how a green result was once reported for a
/// run that scavenged nothing (#7025).
fn copied_objects(stderr: &str) -> u64 {
///
/// The diagnostic is a key/value record, not a positional format. Ordinary
/// object-by-object promotion relocates survivors just like a nursery copy;
/// whole-block `in_place=true` promotion deliberately does not move them.
fn copy_minor_relocated_objects(stderr: &str) -> u64 {
stderr
.lines()
.filter_map(|line| line.strip_prefix("[gc-copy-minor] ran copied_objects="))
.filter_map(|rest| {
rest.split_whitespace()
.next()
.and_then(|n| n.parse::<u64>().ok())
.filter_map(|line| line.strip_prefix("[gc-copy-minor] ran "))
.map(|fields| {
let mut in_place = false;
let mut copied = 0;
let mut promoted = 0;

for field in fields.split_whitespace() {
let Some((key, value)) = field.split_once('=') else {
continue;
};
match key {
"in_place" => in_place = value == "true",
"copied_objects" => copied = value.parse::<u64>().unwrap_or(0),
"promoted_objects" => promoted = value.parse::<u64>().unwrap_or(0),
_ => {}
}
}

if in_place {
0
} else {
copied + promoted
}
})
.sum()
}

#[test]
fn copy_minor_parser_reads_current_field_order() {
let stderr = "[gc-copy-minor] ran in_place=false survival_permille=350 copied_objects=17 copied_bytes=272 promoted_objects=0 promoted_bytes=0\n";
assert_eq!(copy_minor_relocated_objects(stderr), 17);
}

#[test]
fn copy_minor_parser_counts_object_by_object_promotions() {
let stderr = "[gc-copy-minor] ran in_place=false copied_objects=0 promoted_objects=23 promoted_bytes=368\n";
assert_eq!(copy_minor_relocated_objects(stderr), 23);
}

#[test]
fn copy_minor_parser_rejects_in_place_promotion() {
let stderr = "[gc-copy-minor] ran in_place=true copied_objects=0 promoted_objects=999 promoted_bytes=15984\n";
assert_eq!(copy_minor_relocated_objects(stderr), 0);
}

#[test]
fn copying_minor_runs_under_an_explicit_heap_limit() {
let dir = tempfile::tempdir().expect("tempdir");
Expand All @@ -80,24 +145,27 @@ console.log("checksum:", checksum);
)
.expect("write entry");

let compile = Command::new(perry_bin())
let mut compile_command = Command::new(perry_bin());
compile_command
.current_dir(dir.path())
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.arg("--no-cache")
.output()
.expect("run perry compile");
.arg("--no-cache");
remove_gc_env_overrides(&mut compile_command);
let compile = compile_command.output().expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

let run = Command::new(&output)
.current_dir(dir.path())
let mut run_command = Command::new(&output);
run_command.current_dir(dir.path());
remove_gc_env_overrides(&mut run_command);
let run = run_command
// 8 MB is the pressure setting `scripts/gc_repsel_matrix.sh` uses, and
// the one on which the copying minor was measured to never run.
.env("PERRY_GC_HEAP_LIMIT", "8")
Expand All @@ -117,9 +185,9 @@ console.log("checksum:", checksum);
"the workload must still produce its result under a heap limit"
);

let copied = copied_objects(&stderr);
let relocated = copy_minor_relocated_objects(&stderr);
assert!(
copied > 0,
relocated > 0,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"no copying minor ran under PERRY_GC_HEAP_LIMIT=8 (#7024). The \
allocation-point deferral to the precise-root safepoint is \
unreachable again — check that the moving-defer allowance is still a \
Expand Down
37 changes: 26 additions & 11 deletions scripts/gc_parse_churn_layout_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
line, which on the gate's own invocation manifests as an aborting
nonzero exit -- see `scripts/gc_parse_churn_layout_gate.sh`).
2. LIVENESS -- at least one copying minor actually relocated objects
(`copied_objects` summed across every `[gc-copy-minor] ran ...` line is
nonzero). A run that never triggers the moving collector cannot fail
(`copied_objects + promoted_objects` across non-in-place
`[gc-copy-minor] ran ...` lines is nonzero). A run that never triggers the moving collector cannot fail
however broken the layout state is.
3. EAGERNESS -- the from-space scan's own `objects=` census reached at
least `--records` objects. `js_json_parse`'s Auto mode routes a
Expand Down Expand Up @@ -58,7 +58,8 @@

SUCCESS_SENTINEL = "PARSE_CHURN_LAYOUT_GATE_OK"
MISMATCH_RE = re.compile(r"^MISMATCHES (\d+)$", re.MULTILINE)
COPIED_OBJECTS_RE = re.compile(r"\[gc-copy-minor\] ran copied_objects=(\d+)")
COPY_MINOR_LINE_RE = re.compile(r"^\[gc-copy-minor\] ran\s+(.*)$", re.MULTILINE)
FIELD_RE = re.compile(r"\b([a-z_]+)=([^\s]+)")
SCAN_LINE_RE = re.compile(r"^\[gc-fromspace-scan (\S+)\] objects=(\d+)", re.MULTILINE)
OFFENDER_PHASES = {"OFFENDERS", "abort"}

Expand Down Expand Up @@ -109,12 +110,20 @@ def evaluate(exit_code: int, stdout: str, stderr: str, records: int) -> Verdict:
f"real defect the from-space scan can miss."
)

copied = [int(n) for n in COPIED_OBJECTS_RE.findall(stderr)]
total_copied = sum(copied)
if total_copied == 0:
moved = 0
for raw_fields in COPY_MINOR_LINE_RE.findall(stderr):
fields = dict(FIELD_RE.findall(raw_fields))
# #7744 whole-block promotion deliberately leaves every object at the
# same address. Ordinary promotion is an object-by-object copy and is
# relocation evidence just like `copied_objects` (#7657).
if fields.get("in_place") == "true":
continue
moved += int(fields.get("copied_objects", "0"))
moved += int(fields.get("promoted_objects", "0"))
if moved == 0:
v.fail(
"no copying minor relocated anything (sum of every "
"'[gc-copy-minor] ran copied_objects=' line in stderr is 0). "
"no copying minor relocated anything (copied+promoted objects "
"across every non-in-place '[gc-copy-minor] ran' line is 0). "
"The subject of this gate -- the moving collector -- never ran, "
"so a clean scan proves nothing (CLAUDE.md's 'four ways a gate "
"can be unable to fail', #4). Check PERRY_GC_MOVING_LOOP_POLLS=1 "
Expand Down Expand Up @@ -178,9 +187,9 @@ def _self_test() -> int:

ok_stdout = "BLOB_BYTES 245781\nPARSED_LENGTH 4000\nCHURN_TOUCH 240000\nMISMATCHES 0\n" + SUCCESS_SENTINEL + "\n"
ok_stderr = (
"[gc-copy-minor] ran copied_objects=537 copied_bytes=46112\n"
"[gc-copy-minor] ran in_place=false survival_permille=350 copied_objects=537 copied_bytes=46112 promoted_objects=0 promoted_bytes=0\n"
"[gc-fromspace-scan clean] objects=6041 words=86354 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n"
"[gc-copy-minor] ran copied_objects=612 copied_bytes=51200\n"
"[gc-copy-minor] ran in_place=false survival_permille=970 copied_objects=0 copied_bytes=0 promoted_objects=612 promoted_bytes=51200\n"
"[gc-fromspace-scan clean] objects=9210 words=120000 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n"
)
cases.append(("clean run passes", 0, ok_stdout, ok_stderr, 4000, True))
Expand All @@ -204,13 +213,19 @@ def _self_test() -> int:
)
cases.append(("no copying minor ever ran -> FAIL (liveness)", 0, ok_stdout, no_copy_stderr, 4000, False))

in_place_only_stderr = (
"[gc-copy-minor] ran in_place=true copied_objects=0 promoted_objects=4000\n"
"[gc-fromspace-scan clean] objects=6041 words=86354 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n"
)
cases.append(("whole-block promotion moved nothing -> FAIL (liveness)", 0, ok_stdout, in_place_only_stderr, 4000, False))

no_scan_stderr = "\n".join(
line for line in ok_stderr.splitlines() if "gc-fromspace-scan" not in line
)
cases.append(("scan never ran at all -> FAIL (ABORT-alone-inert class)", 0, ok_stdout, no_scan_stderr, 4000, False))

lazy_stderr = (
"[gc-copy-minor] ran copied_objects=4 copied_bytes=512\n"
"[gc-copy-minor] ran in_place=false copied_objects=4 copied_bytes=512 promoted_objects=0 promoted_bytes=0\n"
"[gc-fromspace-scan clean] objects=9 words=88 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n"
)
cases.append(("tape stayed lazy: scan sees ~9 objects not 4000 -> FAIL (eagerness/vacuity)", 0, ok_stdout, lazy_stderr, 4000, False))
Expand Down
59 changes: 51 additions & 8 deletions scripts/gc_repsel_matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,32 @@
# [--pressure <MB>] [--jobs N] [--no-build]
# [--profile <cargo profile>] [--json <path>]
# [--list-arms] [--liveness-report-only]
# [--self-test-liveness-parser]
set -uo pipefail

# Sum objects actually relocated by completed copying minors. The diagnostic
# line is a key/value record, not a positional format: #7744 inserted
# `in_place=...` before `copied_objects`, which made the old exact-prefix grep
# read every live run as zero. Object-by-object promotions MOVE just like
# survivor copies (#7657); whole-block in-place promotions do not.
sum_copy_minor_moved() {
awk '
/\[gc-copy-minor\] ran / {
copied = 0
promoted = 0
in_place = "false"
for (i = 1; i <= NF; i++) {
split($i, kv, "=")
if (kv[1] == "copied_objects") copied = kv[2] + 0
if (kv[1] == "promoted_objects") promoted = kv[2] + 0
if (kv[1] == "in_place") in_place = kv[2]
}
if (in_place != "true") moved += copied + promoted
}
END { print moved + 0 }
' "$@"
}

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
Expand All @@ -87,6 +111,7 @@ DO_BUILD=1
JSON_OUT=""
PROFILE="release"
LIVENESS_REPORT_ONLY=0
SELF_TEST_LIVENESS_PARSER=0

while [ $# -gt 0 ]; do
case "$1" in
Expand All @@ -98,6 +123,7 @@ while [ $# -gt 0 ]; do
--no-build) DO_BUILD=0; shift ;;
--json) JSON_OUT="$2"; shift 2 ;;
--list-arms) ARMS_SEL="__list__"; shift ;;
--self-test-liveness-parser) SELF_TEST_LIVENESS_PARSER=1; shift ;;
# Local exploration only (e.g. a `--filter` narrow enough that an arm
# legitimately has nothing to bite). CI never passes this: the whole
# point of #7255 is that an inert arm must be able to turn a run red.
Expand All @@ -107,15 +133,31 @@ while [ $# -gt 0 ]; do
esac
done

if [ "$SELF_TEST_LIVENESS_PARSER" = 1 ]; then
got="$(sum_copy_minor_moved <<'EOF'
[gc-copy-minor] eligible=true fallback=none
[gc-copy-minor] ran copied_objects=4 copied_bytes=64 promoted_objects=3 promoted_bytes=48
[gc-copy-minor] ran in_place=false untraced=false copied_objects=0 copied_bytes=0 promoted_objects=5 promoted_bytes=80
[gc-copy-minor] ran in_place=true untraced=false copied_objects=0 copied_bytes=0 promoted_objects=999 promoted_bytes=15984
EOF
)"
if [ "$got" != 12 ]; then
echo "gc_repsel_matrix liveness parser self-test: expected 12 moved objects, got $got" >&2
exit 1
fi
echo "gc_repsel_matrix liveness parser self-test: OK (legacy, current, promoted, and in-place forms)"
exit 0
fi

RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m'
[ -t 1 ] || { RED=""; GREEN=""; YELLOW=""; NC=""; }

# ---------------------------------------------------------------------------
# Arms. Format: id | compile-env | run-env | liveness-requirement | note
#
# liveness requirement:
# scavenge the arm claims the COPYING MINOR runs -> require
# `[gc-copy-minor] ran copied_objects=` > 0. Strictly stronger than
# scavenge the arm claims the COPYING MINOR runs -> require a non-in-place
# `[gc-copy-minor] ran` with copied+promoted objects > 0. Strictly stronger than
# `move`, which the C4b mark-sweep evacuation satisfies on its own
# (#7025) -- `default` reported `moved=7 610 512` while running zero
# copying minors. Any arm whose subject is the relocating young-gen
Expand Down Expand Up @@ -458,17 +500,18 @@ while [ "$ai" -lt "$NARMS" ]; do
# inside the mark-sweep collector -- the pre-existing
# non-moving-minor path that relocates tenured objects
# during a full cycle.
# scavenged= : `[gc-copy-minor] ran copied_objects=` from the
# copying young-gen minor -- the path #7019 made
# default-on, and the one the evacuating arms exist
# to exercise.
# scavenged= : copied+promoted objects from non-in-place
# `[gc-copy-minor] ran` records. Both destinations
# relocate (#7657); whole-block promotion does not.
# This is the copying young-gen minor -- the path
# #7019 made default-on, and the one the evacuating
# arms exist to exercise.
# A cell showing `evacuated=N scavenged=0` did relocate something,
# but it did NOT run a copying minor, and the distinction is exactly
# what tells you whether the arm bit.
evacuated=$(grep -oE 'moved_objects=[0-9]+' "$WORK/out/$b.$id.err" 2>/dev/null \
| grep -oE '[0-9]+$' | awk '{s+=$1} END {print s+0}')
scavenged=$(grep -oE '\[gc-copy-minor\] ran copied_objects=[0-9]+' "$WORK/out/$b.$id.err" 2>/dev/null \
| grep -oE '[0-9]+$' | awk '{s+=$1} END {print s+0}')
scavenged=$(sum_copy_minor_moved "$WORK/out/$b.$id.err")
# #7017: `cycles>0` cannot tell a mid-program collection from a
# teardown one. On a small corpus file the shipped configuration
# completes exactly one cycle, at the event-loop boundary AFTER the
Expand Down
Loading