#!/usr/bin/env python3
"""Compare semantic dumps with alternating before/after release binaries.
Uses generated local fixtures, Linux wait4 peak RSS, three measured repetitions
by default, and SHA-256 checks for every output. Run without concurrent builds.
Example:
python3 scripts/benchmark-ax-compact.py --before /path/before/moli \
--after /path/after/moli --output target/ax-compact-benchmark/results
"""
import argparse
import functools
import hashlib
import html
import http.server
import json
import multiprocessing
import os
from pathlib import Path
import platform
import statistics
import subprocess
import threading
import time
def fixtures(root):
root.mkdir(parents=True, exist_ok=True)
def page(name, body):
(root / f"{name}.html").write_text(
f"
{name}"
f"{body}", encoding="utf-8"
)
for count in (500, 40000, 200000):
page(f"rows-{count}", "".join(
f'' for i in range(count)
))
page("table-10000", 'Results'
'| Item | Count | Status |
' + "".join(
f'| Item {i} | {i} | Ready |
'
for i in range(10000)
) + '
')
page("forms-300", "".join(
f''
f''
f''
f''
f''
for i in range(300)
))
page("ignored-10000", "".join(
f'Text {i}
'
for i in range(10000)
for attribute in [('aria-hidden="true"', 'inert',
'style="visibility:hidden"')[i % 3]]
) + 'Visible sibling
')
page("markers-10000", '' + "".join(
f'- Bullet {i}
' for i in range(5000)
) + '
' + "".join(
f'- Number {i}
' for i in range(5000)
) + '
')
child = 'Frame heading
' + "".join(
f'Frame row {i}
' for i in range(200)
) + ''
page("frames", 'Main heading
' + "".join(
f''
for i in range(4)
))
page("mixed", '- Number
'
''
''
'Inert
Invisible
'
'Boolean hidden
Until found
'
'Locked
'
''
'')
def run(binary, url, dump, frames, destination, timeout, env):
args = ["moli", "fetch", "--timeout", "120000", "--dump", dump]
if frames:
args.append("--with-frames")
args.append(url)
started = time.monotonic()
with destination.open("wb") as out, destination.with_suffix(".stderr").open("wb") as err:
process = subprocess.Popen(args, executable=binary, stdout=out, stderr=err, env=env)
timed_out = threading.Event()
def terminate():
timed_out.set()
process.kill()
watchdog = threading.Timer(timeout, terminate)
watchdog.daemon = True
watchdog.start()
try:
_, status, usage = os.wait4(process.pid, 0)
process.returncode = os.waitstatus_to_exitcode(status)
finally:
watchdog.cancel()
elapsed = time.monotonic() - started
if timed_out.is_set():
raise TimeoutError(f"{binary}: {url} {dump}")
if process.returncode:
raise RuntimeError(destination.with_suffix(".stderr").read_text())
with destination.open("rb") as output:
digest = hashlib.file_digest(output, "sha256").hexdigest()
return {"maxrss_bytes": usage.ru_maxrss * 1024, "wall_seconds": elapsed,
"output_bytes": destination.stat().st_size, "sha256": digest}
def distribution(path):
with path.open() as file:
nodes = json.load(file)
return {
"nodes": len(nodes),
"ignored_reasons": sum("ignoredReasons" in node for node in nodes),
"values": sum("value" in node for node in nodes),
"nonempty_properties": sum(bool(node.get("properties")) for node in nodes),
"rare_payloads": sum("ignoredReasons" in node or "value" in node
or bool(node.get("properties")) for node in nodes),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--before", type=Path, required=True)
parser.add_argument("--after", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--repeats", type=int, default=3)
parser.add_argument("--timeout", type=float, default=180)
parser.add_argument("--cases", nargs="+")
parser.add_argument("--resume", action="store_true",
help="reuse complete samples after verifying binary hashes")
args = parser.parse_args()
if platform.system() != "Linux":
parser.error("peak RSS reporting currently requires Linux")
if args.repeats < 1:
parser.error("--repeats must be positive")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
root = output / "fixtures"
# A large fixture's temporary strings must not inflate the RSS inherited
# by subsequently forked benchmark processes.
generator = multiprocessing.get_context("spawn").Process(target=fixtures, args=(root,))
generator.start()
generator.join()
if generator.exitcode:
raise RuntimeError("fixture generation failed")
binaries = {"before": str(args.before.resolve()), "after": str(args.after.resolve())}
manifest = {"binaries": {}, "platform": platform.platform(), "repeats": args.repeats}
for label, binary in binaries.items():
with open(binary, "rb") as file:
manifest["binaries"][label] = {
"path": binary, "sha256": hashlib.file_digest(file, "sha256").hexdigest()
}
if args.resume:
previous_manifest = json.loads((output / "manifest.json").read_text())
if previous_manifest != manifest:
parser.error("resume requires the same binaries, platform and repeat count")
(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
env = {key: value for key, value in os.environ.items()
if key.lower() not in ("http_proxy", "https_proxy", "all_proxy")}
class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *unused):
pass
server = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), functools.partial(Handler, directory=str(root))
)
threading.Thread(target=server.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{server.server_port}"
cases = ["rows-500", "rows-40000", "table-10000", "forms-300",
"ignored-10000", "markers-10000", "frames", "mixed", "rows-200000"]
if args.cases:
unknown = set(args.cases) - set(cases)
if unknown:
parser.error(f"unknown cases: {sorted(unknown)}")
cases = args.cases
records = []
completed = set()
if args.resume:
previous = [json.loads(line) for line in (output / "results.jsonl").read_text().splitlines()]
expected_trials = {(label, repeat) for label in binaries for repeat in range(args.repeats)}
for case, dump in dict.fromkeys((row["case"], row["dump"]) for row in previous):
group = [row for row in previous if (row["case"], row["dump"]) == (case, dump)]
trials = {(row["binary"], row["repeat"]) for row in group}
if trials == expected_trials and len(group) == len(expected_trials):
if len({row["sha256"] for row in group}) != 1:
raise AssertionError(f"existing output mismatch: {case} {dump}")
completed.add((case, dump))
records = [row for row in previous if (row["case"], row["dump"]) in completed]
try:
# Both binaries receive the same argv[0], URL, environment and cwd.
for label, binary in binaries.items():
run(binary, base + "/rows-500.html", "semantic_tree_text", False,
output / f"warmup-{label}.out", args.timeout, env)
with (output / "results.jsonl").open("w") as log:
for record in records:
log.write(json.dumps(record) + "\n")
log.flush()
for case in cases:
dumps = ("semantic_tree_text",) if case == "rows-200000" else (
"semantic_tree_text", "semantic_tree"
)
if case == "rows-40000":
dumps += ("markdown",)
for dump in dumps:
if (case, dump) in completed:
print(f"Reusing complete samples: {case} {dump}", flush=True)
continue
hashes = set()
for repeat in range(args.repeats):
order = ("before", "after") if repeat % 2 == 0 else ("after", "before")
for label in order:
destination = output / f"{case}-{dump}-{repeat}-{label}.out"
record = {"case": case, "dump": dump, "repeat": repeat, "binary": label,
**run(binaries[label], base + f"/{case}.html", dump,
case in ("frames", "mixed"), destination, args.timeout, env)}
hashes.add(record["sha256"])
records.append(record)
line = json.dumps(record)
log.write(line + "\n")
log.flush()
print(line, flush=True)
if len(hashes) != 1:
raise AssertionError(f"output mismatch: {case} {dump}")
finally:
server.shutdown()
server.server_close()
lines = ["# AX compact benchmark", "",
"Generated local fixtures; alternating runs; median of "
f"{args.repeats} runs; RSS in MiB. Before is the existing typed AX implementation.", "",
"| Case | Dump | RSS before | RSS after | RSS change | Time before | Time after | Time change |",
"|---|---|---:|---:|---:|---:|---:|---:|"]
for case, dump in dict.fromkeys((row["case"], row["dump"]) for row in records):
stats = {}
for label in binaries:
rows = [row for row in records if (row["case"], row["dump"], row["binary"]) == (case, dump, label)]
stats[label] = {key: statistics.median(row[key] for row in rows)
for key in ("maxrss_bytes", "wall_seconds")}
before, after = stats["before"], stats["after"]
rss_change = (after["maxrss_bytes"] / before["maxrss_bytes"] - 1) * 100
wall_change = (after["wall_seconds"] / before["wall_seconds"] - 1) * 100
lines.append(f"| {case} | {dump} | {before['maxrss_bytes']/2**20:.1f} | "
f"{after['maxrss_bytes']/2**20:.1f} | {rss_change:+.1f}% | "
f"{before['wall_seconds']:.3f}s | {after['wall_seconds']:.3f}s | {wall_change:+.1f}% |")
lines += ["", "Every output is byte-for-byte identical across binaries and repetitions.", "",
"Small-page timings include process startup and are sensitive to noise.", "",
"| Case | AX nodes | Ignored reasons | Values | Nonempty properties | Nodes with rare payload |",
"|---|---:|---:|---:|---:|---:|"]
distributions = {}
for case in cases:
path = output / f"{case}-semantic_tree-0-after.out"
if not path.exists():
continue
stats = distributions[case] = distribution(path)
lines.append(f"| {case} | {stats['nodes']} | {stats['ignored_reasons']} | "
f"{stats['values']} | {stats['nonempty_properties']} | "
f"{stats['rare_payloads'] / max(stats['nodes'], 1):.1%} |")
(output / "node-distribution.json").write_text(json.dumps(distributions, indent=2) + "\n")
lines += ["", "Raw samples and binary hashes: `results.jsonl`, `manifest.json`.", ""]
report = "\n".join(lines)
(output / "report.md").write_text(report)
print(report)
if __name__ == "__main__":
main()