9fdc4cacff
Second half of the form-a-default-authoring milestone-close iter
(Boss-decided strategy C, big-bang). All seven tasks DONE; cargo
test --workspace green at every per-task boundary.
T6 — Bench-driver suffix flip from .ail.json to .ail across 4
Python scripts + run.sh. compile_check.py + cross_lang.py exit 0.
T7 — Re-author e2e.rs raw-JSON-inspect tests:
- diff_detects_changed_def — derive sum.ail.json on-the-fly via
`ail parse examples/sum.ail` into tempdir, then mutate + diff.
- borrow_own_demo_modes_are_metadata_only — same pattern.
- reuse_as_demo_under_rc_uses_inplace_rewrite — same pattern.
- render_parse_round_trip_canonical — RETIRED (subsumed by T1's
cli_parse_then_render_then_parse_is_idempotent over whole corpus).
- ail_run_accepts_ail_source_with_same_stdout_as_ail_json —
re-authored to derive hello.ail.json in a per-process tempdir
from hello.ail via `ail parse`, then assert dual-form stdout.
T8 — Bulk-delete 156 non-carve-out .ail.json. Inventory:
8 .ail.json (carve-outs, alphabetical: broken_unbound + prelude
+ 3× test_22b2_* + 3× test_ct1_*) + 157 .ail. carve_out_inventory
test un-#[ignore]'d and green. Forward-pulled 20 repairs that the
T1-5 dispatch's recon missed (12 Group-B suffix + 5 Group-A
load_workspace + 3 ail_run sites). Also forward-pulled T9 Step 5
(schema_coverage corpus flip from .ail.json to .ail) to satisfy
T8's green-gate.
T9 — Retire obsolete roundtrip tests:
- print_then_parse_round_trips_every_fixture (round_trip.rs)
- every_ail_fixture_matches_its_json_counterpart (round_trip.rs)
- cli_render_then_parse_preserves_canonical_bytes_on_every_fixture
- Dead helpers: list_json_fixtures ×2, round_trip_one,
strip_trailing_newlines.
Schema-coverage corpus already flipped in T8 (forward-pull).
T10 — DESIGN.md §"Roundtrip Invariant" (lines 2027-2109) restated
with parse-determinism + idempotency + CLI-pipeline-idempotency +
carve-out-anchor framing. Five surviving enforcement tests named.
§"Float literals" and §"Why anchored at top level" preserved.
T11 — §A4 doctrine edits: CLAUDE.md:5-6 + DESIGN.md:465-466.
Canonical form remains JSON-AST; authoring projection is .ail;
build derives JSON-AST in-process via ailang_surface::parse.
T12 — Milestone close:
- WhatsNew entry: user-facing language, lead with the change.
- Roadmap: [milestone] form-a struck [x] with closing note.
- Final inventory verified: 8 .ail.json + 157 .ail.
- Final cargo test --workspace: 557 passed, 0 failed, 3 ignored.
- bench/compile_check.py + bench/cross_lang.py: exit 0.
Test math: pre-iter 558 baseline + 3 new T1 tests = 561, − 1
(T7 retire) − 3 (T9 retire) = 557 final.
INDEX.md appended with the full iter summary covering T1-T12 (the
T1-5 commit at 77b28ad deferred the INDEX line to full-iter close).
Milestone [Form-A as the default authoring surface] structurally
closed. The compile-time-embed carve-out (prelude.ail.json) is
the subject of the queued follow-up milestone [Prelude embed:
Form-A as compile-time source]. audit-form-a runs as the next
dispatch.
243 lines
8.8 KiB
Python
Executable File
243 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# Cross-language reference bench.
|
||
#
|
||
# For each fixture in CORPUS, builds:
|
||
# - AILang binary at -O2 --alloc=rc (canonical default)
|
||
# - AILang binary at -O2 --alloc=bump (no-free upper bound)
|
||
# - hand-C binary via clang -O2 from bench/reference/<fixture>.c
|
||
# Times each, drops the slowest of N runs, takes the median, computes
|
||
# AILang/C ratios. Diffs against bench/baseline_cross_lang.json or, with
|
||
# --update-baseline, captures fresh numbers as the new baseline.
|
||
#
|
||
# What this answers (and what it does not): the AILang/C ratio is the
|
||
# honest answer to CLAUDE.md's "LLVM-linkable, performance is extremely
|
||
# important" claim. Ratios near 1.0 mean AILang's IR composes with
|
||
# LLVM's optimizer at the same level as a hand-C source; ratios of
|
||
# 2-3x are JIT-quality territory; ratios of 10x+ are a real problem.
|
||
# The bench DOES NOT distinguish "AILang's IR is slow" from "the
|
||
# discriminated-union representation is wider than C's hand-tuned
|
||
# struct"; that's a representation question, not an optimizer one.
|
||
# Each fixture's .c file documents its representation choices so the
|
||
# orchestrator can read the ratio with that context in mind.
|
||
#
|
||
# Usage:
|
||
# bench/cross_lang.py # run + diff
|
||
# bench/cross_lang.py -n 10 # tighter median
|
||
# bench/cross_lang.py --update-baseline
|
||
# bench/cross_lang.py --baseline path
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import resource
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
DEFAULT_BASELINE = ROOT / "bench" / "baseline_cross_lang.json"
|
||
AIL = ROOT / "target" / "release" / "ail"
|
||
EXAMPLES = ROOT / "examples"
|
||
REFERENCE = ROOT / "bench" / "reference"
|
||
OUTDIR = ROOT / "target" / "cross_lang"
|
||
|
||
# (ailang fixture stem, C reference stem) — both must exist as
|
||
# `examples/<ail>.ail` and `bench/reference/<c>.c`.
|
||
CORPUS = [
|
||
("bench_list_sum", "list_sum"),
|
||
("bench_tree_walk", "tree_walk"),
|
||
("bench_compute_intsum", "compute_intsum"),
|
||
("bench_compute_collatz", "compute_collatz"),
|
||
("bench_list_sum_explicit", "list_sum_explicit_free"),
|
||
]
|
||
|
||
|
||
def time_one(bin_path: Path) -> float:
|
||
"""Run binary, return wall-time in seconds. Stdout discarded."""
|
||
t0 = time.monotonic()
|
||
proc = subprocess.run([str(bin_path)], stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL)
|
||
t1 = time.monotonic()
|
||
if proc.returncode != 0:
|
||
raise RuntimeError(f"{bin_path} exited {proc.returncode}")
|
||
return t1 - t0
|
||
|
||
|
||
def median_drop_slowest(runs: list[float]) -> float:
|
||
if len(runs) < 2:
|
||
return runs[0] if runs else 0.0
|
||
kept = sorted(runs)[:-1]
|
||
n = len(kept)
|
||
if n % 2 == 1:
|
||
return kept[n // 2]
|
||
return 0.5 * (kept[n // 2 - 1] + kept[n // 2])
|
||
|
||
|
||
def build_ailang(stem: str, alloc: str) -> Path:
|
||
src = EXAMPLES / f"{stem}.ail"
|
||
if not src.is_file():
|
||
print(f"missing AILang fixture: {src}", file=sys.stderr)
|
||
sys.exit(2)
|
||
bin_path = OUTDIR / f"{stem}_{alloc}"
|
||
subprocess.run(
|
||
[str(AIL), "build", "--opt=-O2", f"--alloc={alloc}", str(src), "-o", str(bin_path)],
|
||
check=True, capture_output=True,
|
||
)
|
||
return bin_path
|
||
|
||
|
||
def build_c(stem: str) -> Path:
|
||
src = REFERENCE / f"{stem}.c"
|
||
if not src.is_file():
|
||
print(f"missing C reference: {src}", file=sys.stderr)
|
||
sys.exit(2)
|
||
bin_path = OUTDIR / f"{stem}_c"
|
||
subprocess.run(["clang", "-O2", "-o", str(bin_path), str(src)],
|
||
check=True, capture_output=True)
|
||
return bin_path
|
||
|
||
|
||
def measure(num_runs: int) -> dict[str, dict[str, float]]:
|
||
if not AIL.is_file():
|
||
subprocess.run(["cargo", "build", "--release", "-p", "ail"],
|
||
cwd=str(ROOT), check=True, capture_output=True)
|
||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
out: dict[str, dict[str, float]] = {}
|
||
for ail_stem, c_stem in CORPUS:
|
||
print(f">>> {ail_stem}", file=sys.stderr)
|
||
ail_rc = build_ailang(ail_stem, "rc")
|
||
ail_bump = build_ailang(ail_stem, "bump")
|
||
c_bin = build_c(c_stem)
|
||
|
||
rc_runs = [time_one(ail_rc) for _ in range(num_runs)]
|
||
bump_runs = [time_one(ail_bump) for _ in range(num_runs)]
|
||
c_runs = [time_one(c_bin) for _ in range(num_runs)]
|
||
|
||
rc_s = median_drop_slowest(rc_runs)
|
||
bump_s = median_drop_slowest(bump_runs)
|
||
c_s = median_drop_slowest(c_runs)
|
||
|
||
out[ail_stem] = {
|
||
"ail_rc_s": rc_s,
|
||
"ail_bump_s": bump_s,
|
||
"c_s": c_s,
|
||
"rc_over_c": (rc_s / c_s) if c_s > 0 else 0.0,
|
||
"bump_over_c": (bump_s / c_s) if c_s > 0 else 0.0,
|
||
}
|
||
print(f" ail_rc={rc_s*1000:7.2f}ms ail_bump={bump_s*1000:7.2f}ms "
|
||
f"c={c_s*1000:7.2f}ms rc/c={out[ail_stem]['rc_over_c']:5.2f}× "
|
||
f"bump/c={out[ail_stem]['bump_over_c']:5.2f}×", file=sys.stderr)
|
||
return out
|
||
|
||
|
||
def diff_report(measured: dict, baseline: dict) -> tuple[str, bool]:
|
||
rows = []
|
||
has_regression = False
|
||
for fixture, spec_dict in baseline.get("fixtures", {}).items():
|
||
actual = measured.get(fixture)
|
||
if actual is None:
|
||
continue
|
||
for metric, spec in spec_dict.items():
|
||
base = spec["baseline"]
|
||
tol = spec["tolerance_pct"]
|
||
a = actual.get(metric)
|
||
if a is None:
|
||
continue
|
||
diff = 100.0 * (a - base) / base if base else 0.0
|
||
if diff > tol:
|
||
status = "REGRESSION"
|
||
has_regression = True
|
||
elif diff < -tol:
|
||
status = "improvement"
|
||
else:
|
||
status = "ok"
|
||
rows.append((f"{fixture}.{metric}", base, a, diff, tol, status))
|
||
|
||
lines = []
|
||
lines.append(f"{'metric':<48} {'baseline':>10} {'actual':>10} {'diff':>9} {'tol':>6} status")
|
||
lines.append("-" * 100)
|
||
for m, b, a, d, t, s in rows:
|
||
lines.append(f"{m:<48} {b:>10.4f} {a:>10.4f} {d:>+8.2f}% {t:>5.1f}% {s}")
|
||
regressed = sum(1 for r in rows if r[5] == "REGRESSION")
|
||
improved = sum(1 for r in rows if r[5] == "improvement")
|
||
stable = len(rows) - regressed - improved
|
||
lines.append("")
|
||
lines.append(f"summary: {len(rows)} metrics; "
|
||
f"{regressed} regressed, {improved} improved beyond tolerance, "
|
||
f"{stable} stable")
|
||
return "\n".join(lines), has_regression
|
||
|
||
|
||
def write_baseline(measured: dict, path: Path) -> None:
|
||
today = subprocess.check_output(["date", "+%Y-%m-%d"], text=True).strip()
|
||
if path.exists():
|
||
existing = json.loads(path.read_text())
|
||
existing_tols = existing.get("fixtures", {})
|
||
else:
|
||
existing_tols = {}
|
||
|
||
DEFAULT_TOLS = {
|
||
"ail_rc_s": 15,
|
||
"ail_bump_s": 15,
|
||
"c_s": 15,
|
||
"rc_over_c": 12,
|
||
"bump_over_c": 12,
|
||
}
|
||
|
||
new = {
|
||
"version": 1,
|
||
"captured": today,
|
||
"captured_via": "bench/cross_lang.py",
|
||
"note": "Cross-language wall-time baseline. Per-fixture: AILang at --alloc=rc, AILang at --alloc=bump, hand-C at clang -O2. Ratios rc/c and bump/c are the headline answer to CLAUDE.md's LLVM-linkable performance claim. The C reference uses malloc-and-leak to mirror AILang's implicit-mode RC; an explicit-mode + free() variant is queued.",
|
||
"fixtures": {},
|
||
}
|
||
for fixture, metrics in measured.items():
|
||
existing_fix = existing_tols.get(fixture, {})
|
||
new["fixtures"][fixture] = {
|
||
metric: {
|
||
"baseline": round(value, 6),
|
||
"tolerance_pct": existing_fix.get(metric, {}).get(
|
||
"tolerance_pct", DEFAULT_TOLS.get(metric, 15)
|
||
),
|
||
}
|
||
for metric, value in metrics.items()
|
||
}
|
||
path.write_text(json.dumps(new, indent=2) + "\n")
|
||
print(f">>> wrote new baseline to {path}", file=sys.stderr)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("-n", "--runs", type=int, default=5,
|
||
help="runs per binary; min 2, default 5")
|
||
ap.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
|
||
ap.add_argument("--update-baseline", action="store_true")
|
||
args = ap.parse_args()
|
||
if args.runs < 2:
|
||
print("--runs must be >= 2", file=sys.stderr)
|
||
return 2
|
||
|
||
measured = measure(args.runs)
|
||
|
||
if args.update_baseline:
|
||
write_baseline(measured, args.baseline)
|
||
return 0
|
||
|
||
if not args.baseline.exists():
|
||
print(f"no baseline at {args.baseline}; create one with --update-baseline",
|
||
file=sys.stderr)
|
||
return 2
|
||
|
||
baseline = json.loads(args.baseline.read_text())
|
||
report, has_regression = diff_report(measured, baseline)
|
||
print(report)
|
||
return 1 if has_regression else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|