diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b2405ffaa..c752f6b139 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -752,18 +752,34 @@ jobs: # poor fit for the blocking per-PR `cargo-test` gate (one flake blocked # every unrelated PR). They are `#[ignore]`d there and run here instead. # - # Opt-in + informational: `continue-on-error` so a flake never fails the - # workflow; triggered by the `run-extended-tests` PR label, a - # `workflow_dispatch` with `run_extended_tests=true`, or a tag push. + # The write-barrier stress tests stay opt-in + informational + # (`continue-on-error` on THEIR step, so a flake never fails the workflow). + # + # The job itself is no longer informational. It also runs the GC x + # representation-selection stress matrix (`scripts/gc_repsel_matrix.sh`), + # which IS a gate: until it existed, a representation could regress GC + # correctness and no CI job would say a word. Three weaknesses were fixed + # deliberately, and re-introducing any of them re-opens that hole: + # 1. job-level `continue-on-error: true` is gone (a gate that cannot fail + # is not a gate); it now sits on the legacy write-barrier step only; + # 2. the `if:` no longer requires a `run-extended-tests` label, which is + # why this job "skipped" on the representation PRs (#6911, #6925); + # 3. it no longer runs write-barrier stress *only* — nothing about the + # representation corpus was covered before. + # + # Cost split: a PR runs the 4-arm subset, whose arms all share one + # compile-time environment, so the corpus is compiled ONCE and run four + # times. push / workflow_dispatch run the full arm list (19 arms, 9 compile + # groups) as the deeper net. NOTE: this job is not yet in branch protection's + # required contexts — adding it there is what makes the gate blocking. # --------------------------------------------------------------------------- gc-stress: - continue-on-error: true if: >- github.event_name == 'push' || - (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || - (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) + github.event_name == 'pull_request' || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 90 steps: - uses: actions/checkout@v7 @@ -780,13 +796,54 @@ jobs: sudo apt-get update sudo apt-get install -y clang + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + # Single source of truth: .node-version at the repo root. Node is the + # matrix oracle (we byte-diff against it), so the version is a + # correctness input. scripts/gc_repsel_matrix.sh refuses to run when + # the running node disagrees with the pin — a test the oracle cannot + # run would drop out of the gate silently. + node-version-file: .node-version + + - name: Build perry + runtime staticlibs (release) + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" + run: | + cargo build --release \ + -p perry -p perry-runtime -p perry-stdlib \ + -p perry-runtime-static -p perry-stdlib-static + + # GATING. Fails the job on any new untriaged red cell. Cells whose GC arm + # was measurably inert are reported UNVERIFIED, never green (#6942, + # #6946, #6950) — the script asserts liveness from the collector's own + # PERRY_GC_TRACE / PERRY_GC_DIAG output rather than trusting the env var. + - name: GC x representation-selection matrix (PR subset) + if: github.event_name == 'pull_request' + run: ./scripts/gc_repsel_matrix.sh --no-build --arms pr --json gc-repsel-matrix.json + + - name: GC x representation-selection matrix (full) + if: github.event_name != 'pull_request' + run: ./scripts/gc_repsel_matrix.sh --no-build --arms all --json gc-repsel-matrix.json + - 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. + continue-on-error: true env: # Match the cargo-test gate's linker workaround (lld SIGBUS on the # shared runner during large test links). CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" run: cargo test -p perry --test gc_write_barrier_stress -- --ignored + - name: Upload matrix report + if: always() + uses: actions/upload-artifact@v7 + with: + name: gc-repsel-matrix + path: gc-repsel-matrix.json + if-no-files-found: ignore + # --------------------------------------------------------------------------- # Compiler-output regression gate # diff --git a/changelog.d/6954-gc-repsel-stress-matrix.md b/changelog.d/6954-gc-repsel-stress-matrix.md new file mode 100644 index 0000000000..b919b192f5 --- /dev/null +++ b/changelog.d/6954-gc-repsel-stress-matrix.md @@ -0,0 +1,17 @@ +**GC × representation-selection stress matrix — the campaign's closing correctness gate, and the measurement that the GC stress arms were inert (#6954).** + +Six representations shipped during the representation-selection campaign (canonical unboxed i32 locals #6903, tagged-at-rest `Str` #6909, `Ptr` #6911, `Ptr` #6915/#6916, spec-ABI raw params #6905, native-i32 residency #6898). Each argued its own GC safety in its own PR, verified once by hand at merge time, while the collector changed underneath all of them (#6910 root-word parity, #6921 typed-shape layout on the ctor exit, #6892 minor-sweep finalization, #6655 operand rooting). The cross-product was never verified, and no CI job could have caught a regression in it: `gc-stress` was `continue-on-error: true` (it could never fail a build), required a `run-extended-tests` label (it did not run on ordinary PRs), and ran only `gc_write_barrier_stress` (nothing about the representation corpus). + +`scripts/gc_repsel_matrix.sh` runs the corpus (`test-parity/gc_repsel_corpus.txt`) against 19 GC arms — force/verify evacuation, `PERRY_GEN_GC=0`, `PERRY_WRITE_BARRIERS=0`, `PERRY_CONSERVATIVE_STACK_SCAN=off`, `PERRY_GC_MOVING_LOOP_POLLS=1`, their combinations, and each representation flag OFF × evacuation — byte-exact against the pinned Node oracle, non-zero exit on any new untriaged red cell. Portable to bash 3.2. Compile-time arms are grouped so the corpus compiles once per distinct compile environment (every one of those vars is object-cache-keyed, so arms cannot silently share objects). + +**Liveness is part of every cell, and that is the point.** Setting a GC env var does not prove the GC did anything. Each run carries `PERRY_GC_TRACE=1` (one marker per completed collection) and `PERRY_GC_DIAG=1` (`moved_objects`), and a cell that matched the oracle under a measurably inert arm reports **UNVERIFIED**, never green. Full run at 83a6767ff (macOS arm64, release, Node 26.5.0): **361 cells, FAIL=0, PASS=26, UNVERIFIED=333, XFAIL=2**, all 361 byte-exact. + +The 333 UNVERIFIED cells are the finding. The 18 pre-existing corpus files perform **zero** collections under every arm; ordinary allocation pressure does not reach the collector below ~1M escaping allocations, and `PERRY_GC_HEAP_LIMIT` does not lower that first-collection point; and **no reachable configuration in a compiled program performs an evacuating minor** — every automatic collection is a full mark-sweep taken under `ManualGcScanGuard::force_full_scan()`, which also pins raw locals conservatively (filed as #6950, extending #6946 from the `gc()` path to the allocation-driven one). "Byte-exact under `PERRY_GC_FORCE_EVACUATE=1`", the standard validation sentence across this campaign, therefore asserts nothing on either reachable path. The harness flips those cells to green with no change once #6942 lands. + +`test-files/test_gap_repsel_gc_stress.ts` is the corpus member built to be live: each representation's local is held across escaping allocation churn heavy enough to reach the collector (2 collections at default settings, 11 under `--pressure 8`). All six representation gates change its emitted binary, so every representation is genuinely engaged. `test-parity/gc_repsel_corpus.txt` doubles as the enforcement point — the script fails when a `test_gap_repsel_*` / `test_gap_specabi_*` file exists that is not registered, the GC-side counterpart of #6910's single-decoder rule that adding a representation teaches all paths at once. RFC §5.6.1 documents the contract and what the matrix cannot verify today. + +Also found and filed: **#6951** — with the conservative stack scan disabled, a plain non-moving mark-sweep drops `console.log` string-literal argument temporaries (15-line deterministic repro, no evacuation or manual `minor()` needed). Reproduces byte-identically with all six representation gates off, so it is a runtime rooting gap rather than a representation defect; it is carried as the matrix's two triaged XFAILs and is what currently blocks `cons_scan_off`, the only arm that can observe a missing shadow-slot binding on a representation-selected pointer local. + +CI: `gc-stress` loses its job-level `continue-on-error` (it now sits on the legacy write-barrier step only, which stays informational), runs on ordinary pull requests, and runs the matrix — a 4-arm subset on PRs (all four share one compile environment, so the corpus compiles once) and the full arm list on push. Timeout 30 → 90 min. The job is not yet in branch protection's required contexts; adding it there is what makes the gate blocking. + +No wall-clock or performance claims: this is a correctness gate. diff --git a/docs/representation-selection-rfc.md b/docs/representation-selection-rfc.md index c26460353f..90ea53c460 100644 --- a/docs/representation-selection-rfc.md +++ b/docs/representation-selection-rfc.md @@ -186,6 +186,40 @@ pointer-rep rules: - Unwinding runs the existing frame-pop path; registered slots die with the frame — no new exception machinery. +#### 5.6.1 Enforcement — the GC x representation matrix + +Each representation above shipped its GC-safety argument in its own PR, verified once by hand at +merge time, while the collector changed underneath all of them (#6910 mark/rewrite root-word +parity, #6921 typed-shape layout on the ctor exit, #6892 minor-sweep finalization, #6655 operand +rooting). Nobody verified the cross-product. It is now a maintained gate rather than a set of +one-time arguments: + +- **The matrix.** `scripts/gc_repsel_matrix.sh` runs the whole representation corpus against every + GC arm — `PERRY_GC_FORCE_EVACUATE`, `PERRY_GC_VERIFY_EVACUATION`, `PERRY_GEN_GC=0`, + `PERRY_WRITE_BARRIERS=0`, `PERRY_CONSERVATIVE_STACK_SCAN=off`, `PERRY_GC_MOVING_LOOP_POLLS=1`, + their combinations, and *each representation flag OFF x evacuation* — byte-exact against the + pinned Node oracle. Wired into the `gc-stress` CI job: a fast 4-arm subset gates every PR, the + full arm list runs on push. +- **A NEW REPRESENTATION MUST REGISTER ITS GAP FILE** in `test-parity/gc_repsel_corpus.txt`. The + script fails when a `test_gap_repsel_*` / `test_gap_specabi_*` file exists that is not registered. + This is the GC-side counterpart of the single-decoder refactor #6910 established for mark/rewrite: + adding a representation teaches all the paths at once, or CI says so. +- **Liveness is part of the result.** Setting a GC env var does not prove the GC did anything. The + first automatic collection needs ~1M escaping allocations, so a small gap test performs *zero* + collections and every GC arm against it is inert (#6942, #6946, #6950). The harness therefore + asserts liveness from the collector's own `PERRY_GC_TRACE` / `PERRY_GC_DIAG` output and reports an + output-matching cell under an inert arm as **UNVERIFIED**, never green. + `test-files/test_gap_repsel_gc_stress.ts` is the corpus member built to be live: it holds each + representation's local across escaping allocation churn heavy enough to reach the collector. A new + representation should extend *that* file as well as adding its own, or its GC arms stay inert. +- **What the matrix cannot verify today.** No reachable configuration in an AOT-compiled program + performs an *evacuating minor*: every automatic collection is a full mark-sweep taken under + `ManualGcScanGuard::force_full_scan()`, which additionally pins raw locals conservatively (#6950, + extending #6946 from the `gc()` path). The rebase-after-safepoint contract in the bullets above — + the core GC claim of every pointer representation — is therefore still argued, not tested. #6942 + tracks making it testable; when that lands, the matrix's evacuating arms flip from UNVERIFIED to + green with no change to the harness. + ### 5.7 Typed heap (Phase 4) Unboxed storage extends to heap slots where the *container's* shape is proven and stable: - **Eligibility:** an object qualifies for unboxed field layout only if its shape is diff --git a/scripts/gc_repsel_matrix.sh b/scripts/gc_repsel_matrix.sh new file mode 100755 index 0000000000..c65bd0420c --- /dev/null +++ b/scripts/gc_repsel_matrix.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +# GC x representation-selection stress matrix. +# +# WHY THIS EXISTS +# --------------- +# The representation-selection campaign shipped several new value +# representations (canonical unboxed i32 locals #6903, tagged-at-rest `Str` +# #6909, `Ptr` #6911, `Ptr` #6915/#6916, spec-ABI raw params +# #6905, native-i32 residency #6898). Each made its own GC-safety argument in +# its own PR, verified once by hand at merge time. Meanwhile the collector +# changed underneath all of them (#6910 mark/rewrite root-word parity, #6921 +# typed-shape layout on the ctor exit, #6892 minor-sweep finalization, #6655 +# operand rooting). Nobody verified the cross-product. This script is that +# cross-product, as a maintained gate. +# +# ***LIVENESS IS PART OF THE RESULT.*** +# Setting a GC env var does not prove the GC did anything (#6942, #6946, #6950). +# Measured on main: the whole representation corpus performs ZERO collections -- +# the first-GC trigger needs ~1M escaping allocations and a gap test allocates a +# few KB. So every GC arm is INERT against it, and "passes under +# PERRY_GC_FORCE_EVACUATE=1" asserts nothing. +# +# Therefore every run is executed with `PERRY_GC_TRACE=1` (one `[gc] cycle` +# marker per completed collection -- a sound cycle counter) and `PERRY_GC_DIAG=1` +# (`moved_objects=` / `[gc-copy-minor] ran` -- evacuation evidence), and each +# cell is reported as one of: +# +# PASS output byte-exact vs the pinned Node oracle AND the arm's liveness +# requirement was met on this test +# UNVER output byte-exact but the arm was INERT here (no collection, or +# nothing moved for an evacuating arm) -- NOT green, by design +# XFAIL a triaged, justified expected-red (test-parity/gc_repsel_triage.txt) +# FAIL output mismatch, crash, non-zero exit, or compile failure +# +# A cell that matched the oracle under an inert arm is UNVER, never PASS. A +# matrix of green cells from inert arms would license exactly the false +# confidence this gate exists to remove. +# +# `test_gap_repsel_gc_stress` is the corpus member deliberately built to be +# LIVE (measured: 2 collections at default settings, 11 under `--pressure 8`). +# If a collector change stops it collecting, its cells go UNVER and the arm +# liveness summary shows 0/N -- that is the signal to re-tune its churn budget. +# +# ADDING A REPRESENTATION: register its gap file in +# test-parity/gc_repsel_corpus.txt. This script FAILS if a `test_gap_repsel_*` +# or `test_gap_specabi_*` file exists that is not registered (see +# docs/representation-selection-rfc.md 5.6). +# +# Portable to bash 3.2 (macOS system bash): no associative arrays, no mapfile. +# +# Usage: +# scripts/gc_repsel_matrix.sh [--arms pr|all|] [--filter ] +# [--pressure ] [--jobs N] [--no-build] +# [--profile ] [--json ] +# [--list-arms] +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +MANIFEST="test-parity/gc_repsel_corpus.txt" +TRIAGE="test-parity/gc_repsel_triage.txt" +ARMS_SEL="pr" +FILTER="" +PRESSURE_MB="8" +JOBS="$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)" +DO_BUILD=1 +JSON_OUT="" +PROFILE="release" + +while [ $# -gt 0 ]; do + case "$1" in + --arms) ARMS_SEL="$2"; shift 2 ;; + --filter) FILTER="$2"; shift 2 ;; + --pressure) PRESSURE_MB="$2"; shift 2 ;; + --jobs) JOBS="$2"; shift 2 ;; + --profile) PROFILE="$2"; shift 2 ;; + --no-build) DO_BUILD=0; shift ;; + --json) JSON_OUT="$2"; shift 2 ;; + --list-arms) ARMS_SEL="__list__"; shift ;; + -h|--help) sed -n '1,56p' "$0"; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac +done + +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: +# move the arm claims to evacuate -> require moved/copied objects > 0 +# collect the arm claims to collect -> require at least one GC cycle +# none no GC claim of its own (an explicit control) +# +# %P% expands to the pressure env (PERRY_GC_HEAP_LIMIT=) unless +# --pressure 0. `-` means "no run env at all". Compile-time vars change emitted +# IR; all of them are keyed into the object cache +# (perry/src/commands/compile/object_cache.rs), so arms never silently share +# cached objects. +# --------------------------------------------------------------------------- +ARMS=( +"default||%P%|collect|as-shipped GC configuration under allocation pressure" +"force_evac||%P% PERRY_GC_FORCE_EVACUATE=1|move|stress-copy every marked non-pinned nursery object" +"verify_evac||%P% PERRY_GC_VERIFY_EVACUATION=1|collect|panic if a live slot still points at a forwarded object" +"force_verify||%P% PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|move|force + verify" +"gen_gc_off||%P% PERRY_GEN_GC=0|collect|full mark-sweep only; no nursery => no evacuation by construction" +"wb_off|PERRY_WRITE_BARRIERS=0|%P% PERRY_WRITE_BARRIERS=0|collect|no codegen write barriers => copying nursery ineligible by construction" +"gen_off_verify||%P% PERRY_GEN_GC=0 PERRY_GC_VERIFY_EVACUATION=1|collect|full mark-sweep + evacuation verifier" +"wb_off_force|PERRY_WRITE_BARRIERS=0|%P% PERRY_WRITE_BARRIERS=0 PERRY_GC_FORCE_EVACUATE=1|collect|force-evacuate is a documented no-op without barriers (barriers_inactive)" +"all_four|PERRY_WRITE_BARRIERS=0|%P% PERRY_GEN_GC=0 PERRY_WRITE_BARRIERS=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|collect|every escape hatch at once" +"cons_scan_off||%P% PERRY_CONSERVATIVE_STACK_SCAN=off|collect|PRECISE ROOTS ONLY -- removes the conservative-stack pinning that every automatic collection otherwise forces (ManualGcScanGuard::force_full_scan). The only arm that can observe a missing shadow-slot binding." +"cons_scan_off_force||%P% PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|collect|precise roots + force/verify evacuation" +"loop_polls|PERRY_GC_MOVING_LOOP_POLLS=1|%P% PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_FORCE_EVACUATE=1|move|defer the alloc-point collection to a loop back-edge precise-root safepoint, where the copying minor may MOVE survivors" +"rep_i32_off|PERRY_CANONICAL_I32_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 1 OFF x evacuation" +"rep_str_off|PERRY_CANONICAL_STR_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3a OFF x evacuation" +"rep_ptr_shape_off|PERRY_PTR_SHAPE_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3b OFF x evacuation" +"rep_ptr_numarray_off|PERRY_PTR_NUMARRAY_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 4a.3 OFF x evacuation" +"rep_spec_abi_off|PERRY_SPECIALIZED_ABI=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 2 OFF x evacuation" +"rep_int_valued_off|PERRY_INT_VALUED_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|native-i32 residency (#6898) OFF x evacuation" +"shipped_default||-|none|control: exactly the as-shipped configuration -- no pressure knob, no GC env at all" +) + +# PR-gating subset: the arms with the most detection power per second -- +# as-shipped under pressure, force+verify evacuation, precise-roots-only, and +# the untouched shipped configuration as a control. +PR_ARMS="default,force_verify,cons_scan_off,shipped_default" + +arm_field() { # $1 = arm record, $2 = 1..5 + printf '%s' "$1" | cut -d'|' -f"$2" +} + +if [ "$ARMS_SEL" = "__list__" ]; then + printf '%-24s %-9s %s\n' "ARM" "REQUIRES" "NOTE" + for rec in "${ARMS[@]}"; do + printf '%-24s %-9s %s\n' "$(arm_field "$rec" 1)" "$(arm_field "$rec" 4)" "$(arm_field "$rec" 5)" + done + exit 0 +fi + +case "$ARMS_SEL" in + pr) SELECTED="$PR_ARMS" ;; + all) SELECTED="$(for rec in "${ARMS[@]}"; do printf '%s,' "$(arm_field "$rec" 1)"; done)" ;; + *) SELECTED="$ARMS_SEL" ;; +esac +SELECTED="${SELECTED%,}" + +# --------------------------------------------------------------------------- +# Corpus + registration enforcement. +# --------------------------------------------------------------------------- +[ -f "$MANIFEST" ] || { echo "missing corpus manifest $MANIFEST" >&2; exit 2; } +CORPUS=() +while IFS= read -r line; do + line="${line%%#*}" + line="$(printf '%s' "$line" | tr -d '[:space:]')" + [ -n "$line" ] && CORPUS+=("$line") +done < "$MANIFEST" + +missing_reg=0 +for f in test-files/test_gap_repsel_*.ts test-files/test_gap_specabi_*.ts; do + [ -f "$f" ] || continue + b="$(basename "$f" .ts)" + found=0 + for c in "${CORPUS[@]}"; do [ "$c" = "$b" ] && found=1 && break; done + if [ "$found" = 0 ]; then + echo "${RED}UNREGISTERED${NC} $f is a representation-selection gap file but is not in $MANIFEST" >&2 + missing_reg=1 + fi +done +if [ "$missing_reg" = 1 ]; then + echo "A NEW REPRESENTATION MUST REGISTER ITS GAP FILE in $MANIFEST." >&2 + echo "See docs/representation-selection-rfc.md 5.6 (GC under unboxed representations)." >&2 + exit 3 +fi + +for b in "${CORPUS[@]}"; do + [ -f "test-files/$b.ts" ] || { echo "manifest lists $b but test-files/$b.ts does not exist" >&2; exit 3; } +done + +if [ -n "$FILTER" ]; then + FILTERED=() + for b in "${CORPUS[@]}"; do + case "$b" in *"$FILTER"*) FILTERED+=("$b") ;; esac + done + CORPUS=(${FILTERED[@]+"${FILTERED[@]}"}) +fi +[ "${#CORPUS[@]}" -gt 0 ] || { echo "empty corpus after filter" >&2; exit 2; } + +# --------------------------------------------------------------------------- +# Oracle + compiler. THE ORACLE VERSION IS LOAD-BEARING: a test the oracle +# cannot run would drop out of the gate silently, so refuse to run at all. +# --------------------------------------------------------------------------- +PINNED_NODE="$(tr -d 'v \n' < .node-version 2>/dev/null || true)" +NODE_V="$(node --version 2>/dev/null | tr -d 'v \n')" +[ -n "$NODE_V" ] || { echo "node not on PATH" >&2; exit 2; } +if [ -n "$PINNED_NODE" ] && [ "$NODE_V" != "$PINNED_NODE" ]; then + echo "${RED}ORACLE MISMATCH${NC}: node $NODE_V but .node-version pins $PINNED_NODE" >&2 + exit 2 +fi + +TARGET_DIR="${CARGO_TARGET_DIR:-target}" +PERRY_BIN="$TARGET_DIR/$PROFILE/perry" +if [ "$DO_BUILD" = 1 ]; then + echo "==> cargo build --profile $PROFILE (perry + runtime/stdlib staticlibs)" + cargo build --profile "$PROFILE" --quiet \ + -p perry -p perry-runtime -p perry-stdlib \ + -p perry-runtime-static -p perry-stdlib-static \ + || { echo "${RED}build failed${NC}" >&2; exit 2; } +fi +[ -x "$PERRY_BIN" ] || { echo "${RED}missing $PERRY_BIN${NC}" >&2; exit 2; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/perry-gcmatrix.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/oracle" "$WORK/bin" "$WORK/out" + +echo "==> oracle: node $NODE_V x ${#CORPUS[@]} corpus files" +oracle_fail=0 +for b in "${CORPUS[@]}"; do + if ! node --experimental-strip-types "test-files/$b.ts" > "$WORK/oracle/$b.out" 2>/dev/null; then + echo "${RED}ORACLE FAIL${NC} node cannot run test-files/$b.ts -- it would drop out of the gate" >&2 + oracle_fail=1 + fi +done +[ "$oracle_fail" = 0 ] || exit 2 + +# --------------------------------------------------------------------------- +# Arm selection + compile groups (one compile pass per distinct compile env). +# --------------------------------------------------------------------------- +ARM_IDS=(); ARM_CENVS=(); ARM_RENVS=(); ARM_LIVES=(); ARM_NOTES=(); ARM_SLUGS=() +GROUP_SLUGS=(); GROUP_ENVS=() +for rec in "${ARMS[@]}"; do + id="$(arm_field "$rec" 1)" + case ",$SELECTED," in *",$id,"*) ;; *) continue ;; esac + cenv="$(arm_field "$rec" 2)" + slug="$(printf '%s' "${cenv:-_base}" | tr -c 'A-Za-z0-9' '_')" + ARM_IDS+=("$id"); ARM_CENVS+=("$cenv"); ARM_RENVS+=("$(arm_field "$rec" 3)") + ARM_LIVES+=("$(arm_field "$rec" 4)"); ARM_NOTES+=("$(arm_field "$rec" 5)") + ARM_SLUGS+=("$slug") + known=0 + for g in ${GROUP_SLUGS[@]+"${GROUP_SLUGS[@]}"}; do [ "$g" = "$slug" ] && known=1 && break; done + if [ "$known" = 0 ]; then GROUP_SLUGS+=("$slug"); GROUP_ENVS+=("$cenv"); fi +done +NARMS="${#ARM_IDS[@]}" +[ "$NARMS" -gt 0 ] || { echo "no arms selected ($ARMS_SEL)" >&2; exit 2; } + +# Warm the auto-optimize archive serially first: the parallel fan-out below +# would otherwise have N processes racing to build the same target/perry-auto-* +# archive on a cold tree. The harness deliberately does NOT set +# PERRY_NO_AUTO_OPTIMIZE, so the binaries under test are linked exactly the way +# a shipped `perry file.ts` links them (that also decides whether the runtime +# carries the `diagnostics` feature, which changes the GC trace format -- both +# are handled below). +echo "==> warming the auto-optimize archive" +mkdir -p "$WORK/bin/_warm" +"$PERRY_BIN" "test-files/${CORPUS[0]}.ts" -o "$WORK/bin/_warm/warm" > "$WORK/bin/_warm/warm.log" 2>&1 \ + || { echo "${RED}warm-up compile failed${NC} (see $WORK/bin/_warm/warm.log)" >&2; } + +echo "==> compiling ${#CORPUS[@]} files x ${#GROUP_SLUGS[@]} compile-env groups (jobs=$JOBS)" +gi=0 +while [ "$gi" -lt "${#GROUP_SLUGS[@]}" ]; do + slug="${GROUP_SLUGS[$gi]}"; cenv="${GROUP_ENVS[$gi]}" + mkdir -p "$WORK/bin/$slug" + printf '%s\n' "${CORPUS[@]}" | WORK="$WORK" PERRY_BIN="$PERRY_BIN" CENV="$cenv" SLUG="$slug" \ + xargs -P "$JOBS" -I{} sh -c \ + 'env $CENV "$PERRY_BIN" "test-files/$1.ts" -o "$WORK/bin/$SLUG/$1" > "$WORK/bin/$SLUG/$1.log" 2>&1 || echo "COMPILEFAIL $SLUG $1"' _ {} + gi=$((gi+1)) +done + +# --------------------------------------------------------------------------- +# Run + classify. CELLS / EVID are flat arrays indexed test*NARMS + arm. +# --------------------------------------------------------------------------- +PRESSURE_ENV="" +[ "$PRESSURE_MB" != "0" ] && PRESSURE_ENV="PERRY_GC_HEAP_LIMIT=$PRESSURE_MB" + +triage_reason() { # $1 test, $2 arm + [ -f "$TRIAGE" ] || return 1 + grep -v '^[[:space:]]*#' "$TRIAGE" 2>/dev/null \ + | awk -F'|' -v t="$1" -v a="$2" ' + { gsub(/^[ \t]+|[ \t]+$/, "", $1); gsub(/^[ \t]+|[ \t]+$/, "", $2); + if ($1 == t && $2 == a) { sub(/^[ \t]+/, "", $3); print $3; found=1 } } + END { exit(found ? 0 : 1) }' +} + +CELLS=(); EVID=() +n_pass=0; n_unver=0; n_fail=0; n_xfail=0 +ai=0 +while [ "$ai" -lt "$NARMS" ]; do + id="${ARM_IDS[$ai]}"; slug="${ARM_SLUGS[$ai]}"; live="${ARM_LIVES[$ai]}" + renv="$(printf '%s' "${ARM_RENVS[$ai]}" | sed "s/%P%/$PRESSURE_ENV/")" + [ "$renv" = "-" ] && renv="" + echo "==> arm $id" + ti=0 + while [ "$ti" -lt "${#CORPUS[@]}" ]; do + b="${CORPUS[$ti]}"; bin="$WORK/bin/$slug/$b"; idx=$((ti*NARMS+ai)) + if [ ! -x "$bin" ]; then + result="FAIL"; ev="compile-failed" + else + # One run per cell: stdout is the parity artifact, stderr carries + # both liveness signals (PERRY_GC_TRACE=1 -> one `[gc] cycle` + # marker per collection; PERRY_GC_DIAG=1 -> evacuation counters). + # shellcheck disable=SC2086 + env $renv PERRY_GC_TRACE=1 PERRY_GC_DIAG=1 "$bin" \ + > "$WORK/out/$b.$id.out" 2> "$WORK/out/$b.$id.err" + rc=$? + # Two trace formats exist: a runtime staticlib built WITHOUT the + # `diagnostics` feature prints one `[gc] cycle (…disabled…)` marker + # per collection; one built WITH it prints the full JSON trace + # object. Count either -- both are exactly one line per cycle. + cycles=$(grep -cE '^\[gc\] cycle|^\{.*"phase_progression"' "$WORK/out/$b.$id.err" 2>/dev/null | tr -d ' ') + moved=$( { grep -oE 'moved_objects=[0-9]+' "$WORK/out/$b.$id.err" 2>/dev/null || true; \ + grep -oE '\[gc-copy-minor\] ran copied_objects=[0-9]+' "$WORK/out/$b.$id.err" 2>/dev/null || true; } \ + | grep -oE '[0-9]+$' | awk '{s+=$1} END {print s+0}') + : "${cycles:=0}"; : "${moved:=0}" + ev="cycles=$cycles moved=$moved" + if [ "$rc" -ne 0 ]; then + result="FAIL"; ev="exit=$rc $ev" + elif ! cmp -s "$WORK/out/$b.$id.out" "$WORK/oracle/$b.out"; then + result="FAIL"; ev="output-mismatch $ev" + else + case "$live" in + move) [ "$moved" -gt 0 ] && result="PASS" || result="UNVER" ;; + collect) [ "$cycles" -gt 0 ] && result="PASS" || result="UNVER" ;; + *) result="PASS" ;; + esac + fi + fi + if [ "$result" = "FAIL" ]; then + if reason="$(triage_reason "$b" "$id")"; then + result="XFAIL"; ev="$reason | $ev" + fi + fi + CELLS[$idx]="$result"; EVID[$idx]="$ev" + case "$result" in + PASS) n_pass=$((n_pass+1)) ;; + UNVER) n_unver=$((n_unver+1)) ;; + XFAIL) n_xfail=$((n_xfail+1)); echo " ${YELLOW}XFAIL${NC} $b" ;; + FAIL) n_fail=$((n_fail+1)); echo " ${RED}FAIL${NC} $b ($ev)" ;; + esac + ti=$((ti+1)) + done + ai=$((ai+1)) +done + +# --------------------------------------------------------------------------- +# Table + arm liveness summary. +# --------------------------------------------------------------------------- +echo +printf '%-40s' "test \\ arm" +for id in "${ARM_IDS[@]}"; do printf '%-8s' "$(printf '%s' "$id" | cut -c1-7)"; done +echo +ti=0 +while [ "$ti" -lt "${#CORPUS[@]}" ]; do + b="${CORPUS[$ti]}" + printf '%-40s' "${b#test_gap_}" + ai=0 + while [ "$ai" -lt "$NARMS" ]; do + c="${CELLS[$((ti*NARMS+ai))]:-?}" + case "$c" in + PASS) printf '%s%-8s%s' "$GREEN" "PASS" "$NC" ;; + UNVER) printf '%s%-8s%s' "$YELLOW" "UNVER" "$NC" ;; + XFAIL) printf '%s%-8s%s' "$YELLOW" "XFAIL" "$NC" ;; + *) printf '%s%-8s%s' "$RED" "$c" "$NC" ;; + esac + ai=$((ai+1)) + done + echo + ti=$((ti+1)) +done + +echo +echo "arm liveness across the corpus (cells where the arm actually bit):" +ai=0 +while [ "$ai" -lt "$NARMS" ]; do + tot=0; livec=0; livem=0; ti=0 + while [ "$ti" -lt "${#CORPUS[@]}" ]; do + ev="${EVID[$((ti*NARMS+ai))]:-}" + cy="$(printf '%s' "$ev" | sed -nE 's/.*cycles=([0-9]+).*/\1/p')" + mv="$(printf '%s' "$ev" | sed -nE 's/.*moved=([0-9]+).*/\1/p')" + tot=$((tot+1)) + [ "${cy:-0}" -gt 0 ] 2>/dev/null && livec=$((livec+1)) + [ "${mv:-0}" -gt 0 ] 2>/dev/null && livem=$((livem+1)) + ti=$((ti+1)) + done + printf ' %-24s requires=%-8s collected %2d/%2d moved-objects %2d/%2d\n' \ + "${ARM_IDS[$ai]}" "${ARM_LIVES[$ai]}" "$livec" "$tot" "$livem" "$tot" + ai=$((ai+1)) +done + +echo +# Two DIFFERENT properties are being reported and they must not be conflated: +# * byte-exactness vs the pinned Node oracle -- verified in every cell that +# is not FAIL, including cells whose GC arm was inert. For the +# representation-flag arms this IS the meaningful result: the rep's ON and +# OFF lowerings agree byte-for-byte. +# * the GC-stress property -- only verified where the arm was measurably +# live. That is what PASS vs UNVER distinguishes. +n_byte_exact=$((n_pass + n_unver)) +n_cells=$((n_pass + n_unver + n_xfail + n_fail)) +echo "byte-exact vs node $NODE_V: $n_byte_exact/$n_cells cells (the parity property)" +echo "summary: PASS=$n_pass UNVER=$n_unver XFAIL=$n_xfail FAIL=$n_fail" +echo " (pressure=${PRESSURE_MB}MB, node $NODE_V, $PERRY_BIN)" +echo " UNVER = output matched but the arm was inert here; see #6942 / #6946 / #6950." + +if [ -n "$JSON_OUT" ]; then + { + printf '{"node":"%s","pressure_mb":"%s","arms":[' "$NODE_V" "$PRESSURE_MB" + ai=0 + while [ "$ai" -lt "$NARMS" ]; do + [ "$ai" = 0 ] || printf ',' + printf '{"id":"%s","requires":"%s"}' "${ARM_IDS[$ai]}" "${ARM_LIVES[$ai]}" + ai=$((ai+1)) + done + printf '],"cells":[' + first=1; ti=0 + while [ "$ti" -lt "${#CORPUS[@]}" ]; do + ai=0 + while [ "$ai" -lt "$NARMS" ]; do + [ "$first" = 1 ] || printf ','; first=0 + printf '{"test":"%s","arm":"%s","result":"%s","evidence":"%s"}' \ + "${CORPUS[$ti]}" "${ARM_IDS[$ai]}" "${CELLS[$((ti*NARMS+ai))]:-?}" "${EVID[$((ti*NARMS+ai))]:-}" + ai=$((ai+1)) + done + ti=$((ti+1)) + done + printf '],"summary":{"pass":%d,"unverified":%d,"xfail":%d,"fail":%d}}\n' \ + "$n_pass" "$n_unver" "$n_xfail" "$n_fail" + } > "$JSON_OUT" + echo "json: $JSON_OUT" +fi + +[ "$n_fail" = 0 ] || exit 1 +exit 0 diff --git a/test-files/test_gap_repsel_gc_stress.ts b/test-files/test_gap_repsel_gc_stress.ts new file mode 100644 index 0000000000..40790d8d0a --- /dev/null +++ b/test-files/test_gap_repsel_gc_stress.ts @@ -0,0 +1,139 @@ +// GC x representation-selection stress. +// +// Every other file in the representation corpus is a small, fast correctness +// test -- and measurably performs ZERO garbage collections, which makes every +// GC env-var arm inert against it (see docs/representation-selection-rfc.md +// 5.6 and issues #6942 / #6946). This file is the corpus member that actually +// reaches the collector: each representation-selected local is held live +// ACROSS escaping allocation churn, so a collection can land while the +// representation's value is in flight. +// +// The shape of every function here is deliberate: +// * the representation-selected local is initialized BEFORE the churn loop, +// * `churn()` allocates objects that escape into a module-level sink (so the +// allocations cannot be optimized away and the arena actually grows), +// * the local is READ AFTER the churn call in the same iteration, so a +// collection at the allocation point must not have invalidated it. +// +// Keep the churn budget in sync with the harness: `scripts/gc_repsel_matrix.sh` +// reports how many GC cycles each arm actually drove (`PERRY_GC_TRACE=1`), and +// this file is the corpus member expected to be LIVE. If a collector change +// raises the trigger, this test silently stops collecting -- the harness +// reports it as UNVERIFIED rather than green, which is the signal to re-tune. + +class Point { + x: number; + y: number; + tag: string; + constructor(x: number, y: number, tag: string) { + this.x = x; + this.y = y; + this.tag = tag; + } + norm(): number { + return this.x * this.x + this.y * this.y; + } +} + +// Escaping allocation churn. The sink is module-level and reassigned, so the +// allocations are genuinely live for a while and then genuinely dead -- the +// pattern that grows the arena and produces sweepable garbage. +let churnSink: unknown[] = []; +let churnAcc = 0; + +function churn(i: number): void { + churnSink.push({ i: i, s: "c" + (i & 1023), a: [i, i + 1] }); + if (churnSink.length > 4096) { + churnAcc = (churnAcc + churnSink.length) | 0; + churnSink = []; + } +} + +// (1) canonical unboxed i32 locals + (2) Ptr proven object local. +// `p` is a shape-proven object local read on every iteration after the churn +// call; `acc` / `i` are canonical i32 locals with no shadow binding at all. +function shapeAndI32(n: number): number { + const p = new Point(3, 4, "point"); + let acc = 0; + let i = 0; + while (i < n) { + churn(i); + acc = (acc + p.x * 2 + p.y) | 0; + i = (i + 1) | 0; + } + return (acc + p.norm() + p.tag.length) | 0; +} + +// (3) canonical string locals (Str tagged-at-rest). `s` is a string local +// re-assigned and read across the churn call; short values stay in the NaN box +// (SSO), longer ones are heap strings that a collection can sweep or move. +function strLocals(n: number): number { + let s = ""; + let total = 0; + for (let i = 0; i < n; i++) { + churn(i); + s = i & 1 ? "v" + (i & 63) : "a-heap-resident-string-value-" + (i & 63); + total = (total + s.length) | 0; + if (s === "v7") { + total = (total + 1) | 0; + } + } + return total | 0; +} + +// (4) Ptr — guard-free numeric element access on a proven local. +// `arr` is allocated with a static length and only ever element-read/written +// with numeric values, which is the Phase 4a.3 eligibility shape. +function numArray(n: number): number { + const arr = new Array(64); + for (let k = 0; k < 64; k++) { + arr[k] = k; + } + let sum = 0; + for (let i = 0; i < n; i++) { + churn(i); + const k = i & 63; + arr[k] = (arr[k] + 1) | 0; + sum = (sum + arr[k]) | 0; + } + return sum | 0; +} + +// (5) spec-ABI raw typed-array param (`TaPtr`). `buf` arrives as a raw +// storage pointer and is re-read after every churn call -- if the callee-side +// binding were dropped and the header moved, this is where it would show. +function taSum(buf: Int32Array, n: number): number { + let acc = 0; + for (let i = 0; i < n; i++) { + churn(i); + acc = (acc + buf[i & 255]) | 0; + } + return acc | 0; +} + +// A second spec-ABI shape: raw param + an escaping object allocated in the +// same frame, so the frame has both a raw pointer param and a movable object. +function taMix(buf: Int32Array, n: number): number { + const p = new Point(7, 9, "mix"); + let acc = 0; + for (let i = 0; i < n; i++) { + churn(i); + acc = (acc + buf[i & 255] + p.x) | 0; + } + return (acc + p.y) | 0; +} + +const N = 320000; + +const ta = new Int32Array(256); +for (let i = 0; i < 256; i++) { + ta[i] = (i * 3) & 255; +} + +console.log("shapeAndI32", shapeAndI32(N)); +console.log("strLocals", strLocals(N)); +console.log("numArray", numArray(N)); +console.log("taSum", taSum(ta, N)); +console.log("taMix", taMix(ta, N)); +console.log("churnAcc", churnAcc); +console.log("churnSink", churnSink.length); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt new file mode 100644 index 0000000000..cce8252d95 --- /dev/null +++ b/test-parity/gc_repsel_corpus.txt @@ -0,0 +1,49 @@ +# Corpus for the GC x representation-selection stress matrix +# (scripts/gc_repsel_matrix.sh). +# +# ***IF YOU ARE ADDING A REPRESENTATION, ADD ITS GAP FILE HERE.*** +# The matrix script FAILS if a `test_gap_repsel_*` / `test_gap_specabi_*` file +# exists in test-files/ that is not registered below. That is the enforcement +# point behind docs/representation-selection-rfc.md 5.6: a new representation +# must be exercised against every GC arm, not just verified once by hand in its +# own PR. Files outside those two prefixes (typed-array param reads, int-valued +# typed-array locals) are not auto-detected, so register them explicitly. +# +# One basename per line, without the .ts extension. `#` starts a comment. + +# --- Phase 1: canonical unboxed i32 locals (#6903) -------------------------- +test_gap_repsel_canonical_i32 +test_gap_int_valued_ta_locals + +# --- Phase 2: specialized calling convention / spec-ABI raw params (#6905) -- +test_gap_specabi_polymorphic_coexist +test_gap_specabi_reassign +test_gap_specabi_recursion_escape +test_gap_specabi_view_detach +test_gap_ta_param_numeric_read +test_gap_typedarray_param_read + +# --- Phase 3a: canonical string locals, tagged-at-rest Str (#6909) ---------- +test_gap_repsel_canonical_str_locals + +# --- Phase 3b: Ptr proven object locals (#6911) ---------------------- +test_gap_repsel_ptr_shape_locals +test_gap_repsel_ptr_shape_barriers + +# --- Phase 4a / 4a.3: Ptr numeric arrays (#6915, #6916) ----------- +test_gap_repsel_p4a_holes_axis +test_gap_repsel_p4a_inline_tiers +test_gap_repsel_p4a_logical_numeric +test_gap_repsel_p4a3_numarray_barriers +test_gap_repsel_p4a3_numarray_growth +test_gap_repsel_p4a3_ptr_numarray + +# --- Phase 4b: class-field store note/addref elision (#6919) ---------------- +test_gap_repsel_p4b_field_store_elision + +# --- The GC-live member ------------------------------------------------------ +# Every file above performs ZERO collections (measured, #6950), which makes the +# GC arms inert against them. This one holds each representation's local live +# across escaping allocation churn heavy enough to reach the collector, so the +# "collect" arms actually bite. Keep it registered and keep it collecting. +test_gap_repsel_gc_stress diff --git a/test-parity/gc_repsel_triage.txt b/test-parity/gc_repsel_triage.txt new file mode 100644 index 0000000000..4399fddcd1 --- /dev/null +++ b/test-parity/gc_repsel_triage.txt @@ -0,0 +1,11 @@ +# Triaged expected-red cells for scripts/gc_repsel_matrix.sh. +# +# Format: | | +# +# A cell listed here reports XFAIL instead of FAIL and does not fail the gate. +# EVERY entry must name an open issue and say why the redness is not a +# representation defect. Do not add an entry to make a table green: an +# untriaged red cell is the whole point of this gate. + +test_gap_repsel_gc_stress | cons_scan_off | #6951 -- with the conservative stack scan disabled, console.log argument temporaries are not precise roots and string-literal labels are dropped. Reproduces byte-identically with ALL representation gates off, so it is a runtime rooting gap, not a representation defect. This arm becomes usable (and becomes the highest-value arm in the matrix) once #6951 is fixed. +test_gap_repsel_gc_stress | cons_scan_off_force | #6951 -- same root cause as the cons_scan_off arm; the evacuation flags add nothing because no automatic collection ever evacuates (#6950).