Files
ww/test/run
Hojun-Cho 08b210d4b4 test/run: phase-2 byte-id gates run parallel; rule-14 NNN constraint retired (test-perf T3b)
With intermediates following the output stem, concurrent wwstage-driver
builds no longer share paths - 950/990-997 join the xargs -P group.
Measured: 202.7s -> 153.8s full-test wall (x2 runs race-clean); the
serial tail (57.7% of wall) is gone, remainder is CPU-bound. CLAUDE.md
rule 14 documents the new reality: NNN range no longer a correctness
constraint, still keys phase membership / skip set / cache set.
2026-06-10 17:02:28 +09:00

269 lines
11 KiB
Bash
Executable File

#!/bin/sh
# test/run — driver for `make test`. Plan 9 rc-flavoured but plain sh.
#
# Walks test/wcc/<NNN>_<name>.c → out/bin/test_<name> binary, fans out
# across $JOBS (default $(nproc)) workers via xargs -P. Each worker
# writes its status to a per-job file; the collector enumerates them in
# lexicographic order so output stays deterministic across runs even
# though execution interleaves. Exit non-zero if any worker failed.
BIN=${BIN:-out/bin}
WW=${WW:-$BIN/ww}
# Worker mode: invoked once per test by xargs. Argv: --one <prefix> <test.c>.
# Writes <prefix>.status; touches <prefix>.fail on a non-zero binary exit.
if [ "$1" = "--one" ]; then
prefix=$2
t=$3
name=${t##*/}
name=${name%.c}
short=${name#[0-9][0-9][0-9]_}
bin=$BIN/test_$short
if [ ! -x "$bin" ]; then
# A missing binary means an unwired test/wcc file: `make test`
# builds every $(TESTS) target before this runs, so the only way
# to get here is a .c with no Makefile rule. That used to SKIP
# and still count toward "all N tests passed" — 953_arrlit_slice
# sat dark for weeks under a green gate. Fail loud instead.
{
printf 'FAIL %s (no binary %s)\n' "$name" "$bin"
printf 'unwired test: add $(BIN)/test_%s to TESTS + a build rule in Makefile\n' "$short"
} > "$prefix.status"
: > "$prefix.fail"
exit 0
fi
# Per-test wall-clock (task #10 baseline). Stamp around the binary run
# only; build cost lives in `make all`, not here. Duration lands in
# <prefix>.dur as "<sec>\t<name>" for the collector's timing section —
# kept out of <prefix>.status so the ok/FAIL log format is untouched.
t0=$(date +%s.%N)
if "$bin" > "$prefix.out" 2> "$prefix.err"; then
t1=$(date +%s.%N)
printf 'ok %s\n' "$name" > "$prefix.status"
else
rc=$?
t1=$(date +%s.%N)
{
printf 'FAIL %s (rc=%d)\n' "$name" "$rc"
echo '--- stdout ---'
cat "$prefix.out"
echo '--- stderr ---'
cat "$prefix.err"
} > "$prefix.status"
: > "$prefix.fail"
fi
awk -v a="$t0" -v b="$t1" -v n="$name" 'BEGIN{printf "%.3f\t%s\n", b-a, n}' \
> "$prefix.dur"
exit 0
fi
set -e
RESULTS=$(mktemp -d "${TMPDIR:-/tmp}/wwtest.XXXXXX")
trap 'rm -rf "$RESULTS"' EXIT
JOBS=${JOBS:-$(nproc 2>/dev/null || echo 4)}
# ---- content-keyed gate cache (task #10 T1; .ai/rob-testperf-go.md L1) -------
# `make test-commit` (COMMIT=1) reports the expensive wwstage byte-id /
# self-compile gates as `cached` when their full input set is byte-identical to
# the last green run. Those gate outcomes are a PURE function of (stage binaries
# + selfhost/lib/cmd/rt sources + test fixtures + this harness + the Makefile);
# if that content hash equals the recorded last-green key the result is provably
# unchanged, so `cached` is sound — not a guess. `make test` NEVER reads the
# cache (it stays the no-skip pre-push bar) but a green run of the gates DOES
# record the key, as the natural primer. A red run never records: the write is
# guarded on fail=0 (and the fail>0 path exits before it). Cache lives under
# out/.testcache (out/ is gitignored); `make clean` wipes out/ and thus the
# cache, which is correct — the key is keyed on the just-built binaries, so a
# from-scratch rebuild must start from a clean cache.
OUT_DIR=$(dirname "$BIN")
CACHE_DIR=$OUT_DIR/.testcache
CACHE_FILE=$CACHE_DIR/last-green
# The exact cached set, confirmed from the Makefile/test/run: the UNIT-guarded
# phase-2 gates (950 + 990-997) plus the single phase-1 lib byte-id gate
# (989_lib_byteid). The other 989_*/998/999 files are cheap functional _run
# tests, NOT byte-id gates, and stay always-on. Every member is a deterministic
# pure function of the hashed inputs (no /tmp/env/clock/network reads); a future
# gate that reads outside that set must be kept OUT of this list (Go marks such
# tests uncacheable for the same reason).
is_cached_gate() {
case $1 in
950_selfcheck.c|989_lib_byteid.c|\
990_*|991_*|992_*|993_*|994_*|995_*|996_*|997_*) return 0 ;;
esac
return 1
}
emit_cached() {
printf 'cached %s\n' "$1" > "$RESULTS/$1.status"
: > "$RESULTS/$1.cached"
}
# Content key over EVERYTHING that determines the cached gates' outcomes: the
# stage binary md5s first (fixed order), then tracked source/fixture/harness
# content (git ls-files is sorted → deterministic; md5sum hashes working-tree
# bytes, so uncommitted edits flip the key too). Any changed byte flips the key
# → the gates RUN. A missed input would be a false green (rob's veto), so the
# set is a deliberate conservative superset (wwstage binary md5s overlap the
# selfhost/lib source hashes, rt/ is hashed though it only reaches the gates via
# libwwrt); an extra input only ever costs a needless honest re-run. examples/
# is hashed because 996_dyn_ww links examples/mandelbrot/mandelbrot.{ww,o} — a
# real gate input outside the source tree.
testcache_key() {
for b in ww w6c w6a w6l wwdump ww_ww w6c_ww w6a_ww w6l_ww wwdump_ww; do
[ -f "$BIN/$b" ] && md5sum "$BIN/$b"
done
git ls-files selfhost lib cmd rt examples test/wcc test/run Makefile | xargs md5sum
}
KEY=""
if [ "${UNIT:-0}" != "1" ]; then
KEY=$(testcache_key 2>/dev/null | md5sum 2>/dev/null | cut -d' ' -f1) || KEY=""
fi
CACHE_HIT=0
if [ "${COMMIT:-0}" = "1" ] && [ -n "$KEY" ] && [ -f "$CACHE_FILE" ] \
&& [ "$(cat "$CACHE_FILE" 2>/dev/null)" = "$KEY" ]; then
CACHE_HIT=1
fi
# Phase wall-clock stamps (task #10 baseline). Each phase records start/end
# so the collector can report wall shares onto ken's three poles.
run_start=$(date +%s.%N)
# Phase 2 runs the wwstage byte-id / self-compile gates (990-997) +
# 950_selfcheck. They're kept out of phase 1 only so the heavy 43k
# self-compiles don't contend with the phase-1 fixture swarm; within
# phase 2 they all run in parallel (post-T3 each build's intermediates
# follow its output via `-o`/temp, so no member writes a path another
# reads — see the phase-2 block below).
for t in test/wcc/*.c; do
[ -f "$t" ] || continue
case ${t##*/} in
950_*|990_*|991_*|992_*|993_*|994_*|995_*|996_*|997_*) continue ;;
esac
name=${t##*/}; name=${name%.c}
# The only cached gate that lives in phase 1 is 989_lib_byteid; on a
# content-key hit report it cached instead of re-running it.
if [ "$CACHE_HIT" = 1 ] && is_cached_gate "${t##*/}"; then
emit_cached "$name"
continue
fi
printf '%s/%s\0%s\0' "$RESULTS" "$name" "$t"
done | xargs -0 -n2 -P "$JOBS" "$0" --one || true
p1_end=$(date +%s.%N)
# UNIT=1 (make test-unit) is the inner-loop short path: skip the wwstage
# byte-id gates (990-997) and 950_selfcheck entirely. Pre-push uses
# `make test` for the full suite.
if [ "${UNIT:-0}" != "1" ]; then
if [ "$CACHE_HIT" = 1 ]; then
# Content key matched last-green → the wwstage byte-id / self-compile
# gates are provably unchanged. Report them cached; do NOT re-run.
for t in test/wcc/990_*.c test/wcc/991_*.c test/wcc/992_*.c \
test/wcc/993_*.c test/wcc/994_*.c test/wcc/995_*.c \
test/wcc/996_*.c test/wcc/997_*.c test/wcc/950_*.c; do
[ -f "$t" ] || continue
name=${t##*/}; name=${name%.c}
emit_cached "$name"
done
else
p2par_start=$(date +%s.%N)
# Phase 2 — the wwstage byte-id / self-compile gates, all parallel.
# Pre-T3 the source-tree writers (993/995/950) ran in a serial tail
# because ww_ww wrote intermediates next to every traversed source, so
# they raced each other AND the 990/991/992/994 readers. T3 made every
# build's .combined.ww/.s/.o follow its OUTPUT (`-o`/per-pid temp): 993
# → its dc/dw workdirs, 995 → /tmp/wwsr_<pid>_<tool>, 950 → /tmp only.
# No member now writes a stem another reads (the readers see only the
# stable make-all regen), so the whole group fans out via xargs -P.
for t in test/wcc/990_*.c test/wcc/991_*.c test/wcc/992_*.c \
test/wcc/993_*.c test/wcc/994_*.c test/wcc/995_*.c \
test/wcc/996_*.c test/wcc/997_*.c test/wcc/950_*.c; do
[ -f "$t" ] || continue
name=${t##*/}; name=${name%.c}
printf '%s/%s\0%s\0' "$RESULTS" "$name" "$t"
done | xargs -0 -n2 -P "$JOBS" "$0" --one || true
p2par_end=$(date +%s.%N)
fi
# combined.ww freshness gate (#110): 990/995 above unconditionally
# regenerate every tracked combined.ww amalgamation, so a diff vs HEAD
# here means an embedded source changed without its derived artifact
# being regenerated + committed (the #28 staleness hole the per-file
# byte-id gates were blind to). git ls-files keeps new combined.ww
# auto-covered (rule-13 SSoT). Lives inside the UNIT guard because
# test-unit skips 990/995 and thus the regen.
name=combined_ww_fresh
if git diff --exit-code -- $(git ls-files '*.combined.ww') > /dev/null 2>&1; then
printf 'ok %s\n' "$name" > "$RESULTS/$name.status"
else
{
printf 'FAIL %s (stale; regen + commit the derived amalgamation)\n' "$name"
echo '--- stale combined.ww ---'
git diff --name-only -- $(git ls-files '*.combined.ww')
} > "$RESULTS/$name.status"
: > "$RESULTS/$name.fail"
fi
fi
fail=0
ran=0
cached=0
for s in "$RESULTS"/*.status; do
[ -f "$s" ] || continue
cat "$s"
prefix=${s%.status}
if [ -f "$prefix.cached" ]; then
cached=$((cached + 1))
else
ran=$((ran + 1))
fi
[ -f "$prefix.fail" ] && fail=$((fail + 1))
done
# ---- ww source-level tests ----------------------------------------------
if [ -d test/lang ]; then
for f in test/lang/*/*.ww; do
[ -f "$f" ] || continue
echo "skip $f (ww run not online yet)"
done
fi
# ---- timing (task #10 baseline) -----------------------------------------
# Per-test durations + phase walls. Emitted after the per-test ok/FAIL dump
# but before the summary line's exit checks, so timing prints even on the
# failure path; the "all N tests passed" summary stays the last line. No log
# consumer is disturbed: the "---" delimiters and "<sec>\t<name>" rows match
# neither the "all N tests passed" nor the "FAIL" grep keys.
run_end=$(date +%s.%N)
echo '--- timing: per-test (sec, desc) ---'
cat "$RESULTS"/*.dur 2>/dev/null | sort -rn
echo '--- timing: phase walls (sec) ---'
awk -v rs="$run_start" -v p1e="$p1_end" \
-v ppar_s="$p2par_start" -v ppar_e="$p2par_end" \
-v pser_s="$p2ser_start" -v pser_e="$p2ser_end" \
-v re="$run_end" 'BEGIN{
printf "phase1_parallel\t%.3f\n", p1e - rs
if (ppar_e != "") printf "phase2_parallel\t%.3f\n", ppar_e - ppar_s
if (pser_e != "") printf "phase2_serial_tail\t%.3f\n", pser_e - pser_s
printf "total\t%.3f\n", re - rs
}'
if [ $ran -eq 0 ] && [ $cached -eq 0 ]; then
echo "no tests were run"
exit 1
fi
if [ $fail -gt 0 ]; then
echo "$fail test(s) failed"
exit 1
fi
# Green and the gates actually RAN (not a cache hit, not test-unit): record the
# content key as the last-green primer. A red run exits above, never here.
if [ "${UNIT:-0}" != "1" ] && [ "$CACHE_HIT" != 1 ] && [ -n "$KEY" ]; then
mkdir -p "$CACHE_DIR" && printf '%s\n' "$KEY" > "$CACHE_FILE"
fi
[ $cached -gt 0 ] && echo "$cached gate(s) cached (content key matched last green)"
echo "all $ran tests passed"