C内核按库功能拆解,编译结果缓存区构建,编译过程与已有缓存结果对照功能实现
This commit is contained in:
1 parent
aa4951b14e
commit
151e6e4b97
30 files changed
+4856
-604
No files matched your search
@@ -291,7 +291,11 @@ def main():
|
||||
'mode':'plain' if args.plain else 'profile','port':args.port,
|
||||
'python':sys.version,'platform':sys.platform,
|
||||
'frontendFiles':{str(p.relative_to(out/'frontend')):sha256(p.read_bytes()).hexdigest() for p in (out/'frontend').rglob('*') if p.is_file()},
|
||||
'productionKernelSha256':sha256((ROOT/'native/components/kernels.c').read_bytes()).hexdigest()}
|
||||
# Retain the historical entry hash; module/header hashes now
|
||||
# identify the numerical sources behind that diagnostic entry.
|
||||
'productionKernelSha256':sha256((ROOT/'native/components/kernels.c').read_bytes()).hexdigest(),
|
||||
'productionNativeSourceHashes':{p.relative_to(ROOT/'native').as_posix():sha256(p.read_bytes()).hexdigest()
|
||||
for p in sorted((ROOT/'native').rglob('*')) if p.is_file() and p.suffix in ('.c','.h')}}
|
||||
(out/'environment.json').write_text(json.dumps(metadata,indent=2)+'\n')
|
||||
import uvicorn
|
||||
uvicorn.run(profile.app(api.app) if profile else api.app,host='127.0.0.1',port=args.port)
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Prepare or serially measure frozen/current native builds; never run a model.
|
||||
|
||||
Example (fresh output directory; omit --run for preparation only)::
|
||||
|
||||
.venv/bin/python tests/manual/benchmark_native_build_cache.py \
|
||||
--baseline-root test/incremental-build-20260912/baseline-source \
|
||||
--output-dir test/incremental-build-20260912/benchmark \
|
||||
--warmups 1 --repeats 3 --run
|
||||
|
||||
Each version/round gets an empty application cache, followed by A/full hit,
|
||||
A-prime/changed pipe length, A/full hit, four branches/first build, A/full hit.
|
||||
Application-cache cold does not mean OS page-cache cold. Versions run in separate
|
||||
Python processes, with alternating version order by round. Preparation, imports,
|
||||
hashing and recording are outside build timing. NativeBuild.close() is measured
|
||||
separately and as part of an adjacent build-and-release wall interval. Compilation
|
||||
retains each production builder's concurrency and flags. No solver, frontend,
|
||||
strace or previous diagnostic timings are invoked/combined.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import csv
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = Path(__file__).resolve()
|
||||
SCENARIOS = (
|
||||
("cold", "a", False), ("same_model", "a", True),
|
||||
("parameter_change", "a_prime", False), ("return_to_a", "a", True),
|
||||
("four_branches", "four", False), ("return_from_four", "a", True),
|
||||
)
|
||||
METRICS = ("buildSeconds", "buildCallWallSeconds", "releaseWallSeconds",
|
||||
"buildAndReleaseWallSeconds", "executableBytes", "cacheBytes",
|
||||
"preprocessSeconds", "compileSeconds", "compileWallSeconds",
|
||||
"linkSeconds", "unitCount", "objectCacheHits", "objectCompilations")
|
||||
ENVIRONMENT = ("SIMULATION_NATIVE_CC", "SUNDIALS_ROOT",
|
||||
"SIMULATION_NATIVE_MODEL_CACHE_MB", "SIMULATION_NATIVE_OBJECT_CACHE_MB")
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
value = sha256()
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
value.update(block)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def json_digest(value) -> str:
|
||||
return sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value) -> None:
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def source_files(root: Path) -> dict:
|
||||
paths = list((root / "app").rglob("*.py"))
|
||||
paths += [p for directory in ("native", "schemas") for p in (root / directory).rglob("*") if p.is_file()]
|
||||
return {p.relative_to(root).as_posix(): digest(p) for p in sorted(paths)
|
||||
if "__pycache__" not in p.parts and not p.relative_to(root).as_posix().startswith("app/data/")}
|
||||
|
||||
|
||||
def source_record(root: Path) -> dict:
|
||||
if not (root / "app/simulation/native_codegen/build.py").is_file() or not (root / "native").is_dir():
|
||||
raise ValueError(f"Incomplete source root: {root}")
|
||||
frozen = root / "source-manifest.json"
|
||||
if frozen.exists():
|
||||
manifest = json.loads(frozen.read_text())
|
||||
for name, expected in manifest["trackedFiles"].items():
|
||||
if digest(root / name) != expected:
|
||||
raise ValueError(f"Frozen source integrity failure: {root / name}")
|
||||
revision = {"gitHead": manifest["head"], "frozenManifestSha256": digest(frozen), "worktreeStatus": None}
|
||||
else:
|
||||
revision = {"gitHead": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip(),
|
||||
"worktreeStatus": subprocess.check_output(["git", "status", "--porcelain", "--", "app", "native", "schemas"], cwd=root, text=True)}
|
||||
files = source_files(root)
|
||||
return {"root": str(root), **revision, "sourceFiles": files, "sourceFilesSha256": json_digest(files)}
|
||||
|
||||
|
||||
def assert_source(record: dict) -> None:
|
||||
if source_files(Path(record["root"])) != record["sourceFiles"]:
|
||||
raise RuntimeError(f"Source changed after preparation: {record['root']}")
|
||||
|
||||
|
||||
def make_inputs(args, output: Path) -> tuple[dict, dict]:
|
||||
directory = output / "inputs"
|
||||
directory.mkdir()
|
||||
paths = {"a": directory / "model-a.json", "a_prime": directory / "model-a-prime.json", "four": directory / "model-four.json"}
|
||||
shutil.copyfile(args.input, paths["a"])
|
||||
shutil.copyfile(args.four_input, paths["four"])
|
||||
original = json.loads(paths["a"].read_text(encoding="utf-8"))
|
||||
variant = deepcopy(original)
|
||||
matches = [(i, node) for i, node in enumerate(variant["nodes"]) if node["id"] == args.variant_node]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected exactly one variant node: {args.variant_node}")
|
||||
index, node = matches[0]
|
||||
data = node["data"]
|
||||
if data["modelType"] != "amesim_pnl0001":
|
||||
raise ValueError("The controlled length variant requires an amesim_pnl0001 pipe")
|
||||
old = data["parameters"]["le"]
|
||||
if type(old) not in (int, float) or not math.isfinite(old) or old <= 0:
|
||||
raise ValueError("Pipe le must be a positive numeric SI value")
|
||||
new = old * 1.001
|
||||
if not math.isfinite(new) or new == old:
|
||||
raise ValueError("Pipe length variant did not produce a finite changed value")
|
||||
data["parameters"]["le"] = new
|
||||
write_json(paths["a_prime"], variant)
|
||||
data["parameters"]["le"] = old
|
||||
if variant != original:
|
||||
raise RuntimeError("Variant changed fields beyond the selected pipe length")
|
||||
inputs = {key: {"path": str(path), "sha256": digest(path)} for key, path in paths.items()}
|
||||
change = {"nodeId": node["id"], "modelType": data["modelType"], "jsonPointer": f"/nodes/{index}/data/parameters/le",
|
||||
"before": old, "after": new, "storedUnit": "m", "factor": 1.001,
|
||||
"note": "Stored numeric values are SI; editor parameterUnits do not rescale them."}
|
||||
return inputs, change
|
||||
|
||||
|
||||
def generate(config: dict, version: str, *, save: bool):
|
||||
source = config["versions"][version]
|
||||
assert_source(source)
|
||||
root = Path(source["root"])
|
||||
# Every worker starts a fresh interpreter; neither version imports the other.
|
||||
sys.path.insert(0, str(root))
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
from app.simulation.native_codegen import build as builder
|
||||
for name, module in tuple(sys.modules.items()):
|
||||
location = getattr(module, "__file__", None)
|
||||
if (name == "app" or name.startswith("app.")) and location and not Path(location).resolve().is_relative_to(root):
|
||||
raise RuntimeError(f"Mixed source imports: {name}: {location}")
|
||||
if builder.NATIVE.resolve() != root / "native":
|
||||
raise RuntimeError("Builder native source root differs from the selected version")
|
||||
generated = Path(config["outputDir"]) / version / "generated"
|
||||
if save:
|
||||
generated.mkdir(parents=True)
|
||||
programs, records = {}, {}
|
||||
for key, item in config["inputs"].items():
|
||||
path = Path(item["path"])
|
||||
if digest(path) != item["sha256"]:
|
||||
raise RuntimeError(f"Input changed after preparation: {path}")
|
||||
started = time.perf_counter()
|
||||
xml, document = load_input(path)
|
||||
program = compile_native_program(compile_system_xml_network(document))
|
||||
preparation = time.perf_counter() - started
|
||||
contract = program.manifest()
|
||||
record = {"inputSha256": item["sha256"], "xmlSha256": sha256(xml).hexdigest(),
|
||||
"sourceSha256": sha256(program.source.encode()).hexdigest(), "headerSha256": sha256(program.header.encode()).hexdigest(),
|
||||
"contractSha256": json_digest(contract), "stateCount": len(program.state_keys),
|
||||
"variableCount": len(program.variables)}
|
||||
if save:
|
||||
directory = generated / key
|
||||
directory.mkdir()
|
||||
(directory / "input.xml").write_bytes(xml)
|
||||
(directory / "model.c").write_text(program.source, encoding="utf-8")
|
||||
(directory / "model.h").write_text(program.header, encoding="utf-8")
|
||||
write_json(directory / "contract.json", contract)
|
||||
write_json(directory / "preparation.json", {**record, "generationSeconds": preparation})
|
||||
programs[key], records[key] = program, record
|
||||
if programs["a"].header != programs["a_prime"].header or programs["a"].state_keys != programs["a_prime"].state_keys:
|
||||
raise RuntimeError("Length variant changed model header/state layout")
|
||||
if programs["a"].source == programs["a_prime"].source:
|
||||
raise RuntimeError("Length variant did not change generated C")
|
||||
if not save and records != config["generated"][version]:
|
||||
raise RuntimeError("Regenerated model differs from prepared artifacts")
|
||||
assert_source(source)
|
||||
return builder, programs, records
|
||||
|
||||
|
||||
def cache_size(cache: Path) -> dict:
|
||||
files = [p for p in cache.rglob("*") if p.is_file()]
|
||||
return {"logicalFileBytes": sum(p.stat().st_size for p in files), "fileCount": len(files),
|
||||
"modelsBytes": sum(p.stat().st_size for p in files if p.relative_to(cache).parts[0] == "models"),
|
||||
"objectsBytes": sum(p.stat().st_size for p in files if p.relative_to(cache).parts[0] == "objects")}
|
||||
|
||||
|
||||
def worker(args) -> None:
|
||||
config = json.loads(args.worker_config.read_text())
|
||||
if digest(SCRIPT) != config["scriptSha256"]:
|
||||
raise RuntimeError("Benchmark script changed after preparation")
|
||||
if {key: os.environ.get(key) for key in ENVIRONMENT} != config["environment"]:
|
||||
raise RuntimeError("Build environment changed after preparation")
|
||||
builder, programs, records = generate(config, args.version, save=args.worker == "prepare")
|
||||
if args.worker == "prepare":
|
||||
write_json(Path(config["outputDir"]) / args.version / "prepared-models.json", records)
|
||||
return
|
||||
directory = Path(config["outputDir"]) / args.version / args.label
|
||||
directory.mkdir()
|
||||
cache = directory / "cache"
|
||||
if cache.exists():
|
||||
raise RuntimeError("Cold build requires a previously nonexistent cache directory")
|
||||
rows = []
|
||||
for scenario, model, expected_hit in SCENARIOS:
|
||||
case = directory / scenario
|
||||
case.mkdir()
|
||||
before = time.perf_counter()
|
||||
built = builder.build_native(programs[model], cache_dir=cache)
|
||||
returned = time.perf_counter()
|
||||
release_start = time.perf_counter()
|
||||
if hasattr(built, "close"):
|
||||
built.close()
|
||||
released = time.perf_counter()
|
||||
manifest = built.manifest
|
||||
copied_manifest = case / "build-manifest.json"
|
||||
write_json(copied_manifest, manifest)
|
||||
details = getattr(built, "details", {})
|
||||
size = cache_size(cache)
|
||||
row = {"version": args.version, "run": args.label, "warmup": args.label.startswith("warmup-"),
|
||||
"scenario": scenario, "model": model, "buildSeconds": built.seconds,
|
||||
"buildCallWallSeconds": returned - before, "releaseWallSeconds": released - release_start,
|
||||
"buildAndReleaseWallSeconds": released - before, "cacheHit": built.cache_hit,
|
||||
"buildKey": manifest["buildKey"], "details": details,
|
||||
"executable": str(built.executable), "executableSha256": digest(built.executable),
|
||||
"executableBytes": built.executable.stat().st_size, "cacheBytes": size["logicalFileBytes"], "cacheSize": size,
|
||||
"sourceFilesSha256": config["versions"][args.version]["sourceFilesSha256"],
|
||||
"generated": records[model], "compiler": manifest["compiler"], "compilerFlags": manifest["compilerFlags"],
|
||||
"dependencyHashes": manifest["dependencyHashes"], "sourceHashes": manifest["sourceHashes"],
|
||||
"nativeHeaderHashes": manifest.get("nativeHeaderHashes"),
|
||||
"manifest": str(copied_manifest.relative_to(Path(config["outputDir"]))), "manifestSha256": digest(copied_manifest)}
|
||||
row["checks"] = {"expectedCacheHit": built.cache_hit == expected_hit,
|
||||
"parameterChangeReusesOtherObjects": (details.get("objectCompilations") == 1 and
|
||||
details.get("objectCacheHits") == details.get("unitCount", 0) - 1)
|
||||
if scenario == "parameter_change" and "objectCompilations" in details else None}
|
||||
write_json(case / "measurement.json", row)
|
||||
rows.append(row)
|
||||
if any(value is False for value in row["checks"].values()):
|
||||
raise RuntimeError(f"Unexpected cache behavior in {args.version}/{args.label}/{scenario}; measurement preserved")
|
||||
assert_source(config["versions"][args.version])
|
||||
write_json(directory / "measurements.json", {"complete": True, "solverExecuted": False, "rows": rows})
|
||||
|
||||
|
||||
def invoke(config: dict, mode: str, version: str, label: str | None = None) -> dict:
|
||||
output = Path(config["outputDir"])
|
||||
command = [sys.executable, str(SCRIPT), "--worker", mode, "--worker-config", str(output / "prepared.json"), "--version", version]
|
||||
if label:
|
||||
command += ["--label", label]
|
||||
log = output / "logs" / (version + "-" + (label or mode))
|
||||
start = time.perf_counter()
|
||||
with log.with_suffix(".stdout.log").open("wb") as stdout, log.with_suffix(".stderr.log").open("wb") as stderr:
|
||||
process = subprocess.run(command, cwd=config["versions"][version]["root"], stdout=stdout, stderr=stderr, timeout=config["workerTimeout"])
|
||||
observed = {"command": command, "processWallSeconds": time.perf_counter() - start, "exitCode": process.returncode,
|
||||
"version": version, "run": label, "scope": "Whole worker including import/generation/checks/recording; not build latency."}
|
||||
write_json(log.with_suffix(".process.json"), observed)
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"Worker failed ({process.returncode}); see {log.with_suffix('.stderr.log')}")
|
||||
return observed
|
||||
|
||||
|
||||
def stat(values: list) -> dict:
|
||||
return {"n": len(values), "min": min(values), "median": statistics.median(values), "max": max(values)} if values else {"n": 0, "min": None, "median": None, "max": None}
|
||||
|
||||
|
||||
def metric(row: dict, name: str):
|
||||
return row[name] if name in row else row["details"].get(name)
|
||||
|
||||
|
||||
def summarize(config: dict, rows: list, processes: list) -> dict:
|
||||
expected = 2 * (config["warmups"] + config["repeats"]) * len(SCENARIOS)
|
||||
if len(rows) != expected:
|
||||
raise RuntimeError(f"Incomplete benchmark: expected {expected} rows, found {len(rows)}")
|
||||
compilers = {(r["compiler"], tuple(r["compilerFlags"])) for r in rows}
|
||||
libraries = {json_digest({k: v for k, v in r["dependencyHashes"].items() if "/" not in k}) for r in rows}
|
||||
if len(compilers) != 1 or len(libraries) != 1:
|
||||
raise RuntimeError("Compiler/flags or linked library bytes differ across benchmark runs")
|
||||
statistics_by_version = {}
|
||||
for version in config["versions"]:
|
||||
statistics_by_version[version] = {}
|
||||
for scenario, _, _ in SCENARIOS:
|
||||
selected = [r for r in rows if r["version"] == version and r["scenario"] == scenario and not r["warmup"]]
|
||||
if len(selected) != config["repeats"]:
|
||||
raise RuntimeError(f"Incomplete formal group: {version}/{scenario}")
|
||||
statistics_by_version[version][scenario] = {key: stat([metric(r, key) for r in selected if metric(r, key) is not None]) for key in METRICS}
|
||||
changes = {}
|
||||
for scenario, _, _ in SCENARIOS:
|
||||
changes[scenario] = {}
|
||||
for key in ("buildSeconds", "buildCallWallSeconds", "buildAndReleaseWallSeconds"):
|
||||
old = statistics_by_version["baseline"][scenario][key]["median"]
|
||||
new = statistics_by_version["candidate"][scenario][key]["median"]
|
||||
changes[scenario][key] = {"baselineMedian": old, "candidateMedian": new,
|
||||
"reductionPercent": 100 * (1 - new / old) if old else None,
|
||||
"speedup": old / new if new else None, "method": "ratio of group medians"}
|
||||
return {"complete": True, "solverExecuted": False, "prepared": "prepared.json", "scriptSha256": config["scriptSha256"],
|
||||
"contract": config["contract"], "checks": {"compilerAndFlagsIdentical": True, "linkedLibrariesIdentical": True,
|
||||
"generatedModelsIdenticalAcrossVersions": True, "variantHeaderAndStateLayoutUnchanged": True},
|
||||
"statistics": statistics_by_version, "changes": changes, "rows": rows, "workers": processes}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--baseline-root", type=Path, default=ROOT / "test/incremental-build-20260912/baseline-source")
|
||||
parser.add_argument("--candidate-root", type=Path, default=ROOT)
|
||||
parser.add_argument("--input", type=Path, default=ROOT / "tests/data/test-mql-8-corrected.json")
|
||||
parser.add_argument("--four-input", type=Path, default=ROOT / "tests/data/test-mql-4-corrected.json")
|
||||
parser.add_argument("--variant-node", default="amesim_pnl0001_1")
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--repeats", type=int, default=3)
|
||||
parser.add_argument("--worker-timeout", type=float, default=300)
|
||||
parser.add_argument("--run", action="store_true")
|
||||
parser.add_argument("--worker", choices=("prepare", "round"), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--worker-config", type=Path, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--version", choices=("baseline", "candidate"), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--label", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.worker:
|
||||
if not args.worker_config or not args.version or (args.worker == "round" and not args.label):
|
||||
parser.error("Internal worker arguments are incomplete")
|
||||
worker(args)
|
||||
return
|
||||
if not args.output_dir or args.warmups < 1 or args.repeats < 1 or not math.isfinite(args.worker_timeout) or args.worker_timeout <= 0:
|
||||
parser.error("A fresh --output-dir, warmups/repeats >= 1 and a positive worker timeout are required")
|
||||
output = args.output_dir.resolve()
|
||||
if not output.is_relative_to(ROOT / "test") or output.exists():
|
||||
parser.error("Choose a previously nonexistent directory under the ignored test/ tree")
|
||||
versions = {"baseline": source_record(args.baseline_root.resolve()), "candidate": source_record(args.candidate_root.resolve())}
|
||||
output.mkdir(parents=True)
|
||||
(output / "logs").mkdir()
|
||||
try:
|
||||
inputs, change = make_inputs(args, output)
|
||||
config = {"createdAtUtc": datetime.now(timezone.utc).isoformat(), "outputDir": str(output), "versions": versions,
|
||||
"script": str(SCRIPT), "scriptSha256": digest(SCRIPT), "python": sys.version, "pythonExecutable": sys.executable,
|
||||
"platform": platform.platform(), "cpuCount": os.cpu_count(), "environment": {key: os.environ.get(key) for key in ENVIRONMENT},
|
||||
"inputs": inputs, "variant": change, "warmups": args.warmups, "repeats": args.repeats,
|
||||
"workerTimeout": args.worker_timeout, "runRequested": args.run,
|
||||
"contract": {"cold": "Independent empty application cache per version/round; OS caches are not flushed.",
|
||||
"sequence": [name for name, _, _ in SCENARIOS], "solverExecuted": False,
|
||||
"fourBranchCase": "Switch from the full eight-branch project to the corrected four-branch project, then restore A; no cached files are manually deleted.",
|
||||
"wallTiming": "Builder call and adjacent release are measured separately; generation, hashing, imports and recording are outside these spans.",
|
||||
"overlap": "buildSeconds is inside buildCallWallSeconds, which is inside buildAndReleaseWallSeconds. Detail spans are nested; compileSeconds sums concurrent TU walls and is not additive to compileWallSeconds.",
|
||||
"unavailable": "Old builder has no object/stage details; absent measurements are null, never inferred from differences.",
|
||||
"comparison": "Formal group-median ratios; warmups excluded. No old strace timings or solver performance claims."}}
|
||||
write_json(output / "prepared.json", config)
|
||||
for version in versions:
|
||||
invoke(config, "prepare", version)
|
||||
config["generated"] = {version: json.loads((output / version / "prepared-models.json").read_text()) for version in versions}
|
||||
if config["generated"]["baseline"] != config["generated"]["candidate"]:
|
||||
raise RuntimeError("Generated model/XML/header/contract differs between source versions")
|
||||
write_json(output / "prepared.json", config)
|
||||
if not args.run:
|
||||
print(json.dumps({"preparedOnly": True, "outputDir": str(output), "compiled": False, "solverExecuted": False}))
|
||||
return
|
||||
rows, processes = [], []
|
||||
for index in range(args.warmups + args.repeats):
|
||||
label = f"warmup-{index + 1}" if index < args.warmups else f"run-{index - args.warmups + 1}"
|
||||
order = ("baseline", "candidate") if index % 2 == 0 else ("candidate", "baseline")
|
||||
for version in order:
|
||||
processes.append(invoke(config, "round", version, label))
|
||||
rows.extend(json.loads((output / version / label / "measurements.json").read_text())["rows"])
|
||||
for record in versions.values():
|
||||
assert_source(record)
|
||||
summary = summarize(config, rows, processes)
|
||||
write_json(output / "summary.json", summary)
|
||||
with (output / "timings.csv").open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=["version", "run", "warmup", "scenario", "metric", "value", "n", "min", "median", "max"])
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
for key in METRICS:
|
||||
writer.writerow({**{k: row[k] for k in ("version", "run", "warmup", "scenario")}, "metric": key, "value": metric(row, key)})
|
||||
for version, groups in summary["statistics"].items():
|
||||
for scenario, values in groups.items():
|
||||
for key, value in values.items():
|
||||
writer.writerow({"version": version, "run": "formal-statistics", "warmup": False, "scenario": scenario, "metric": key, **value})
|
||||
print(json.dumps({"complete": True, "summary": str(output / "summary.json"), "solverExecuted": False}))
|
||||
except Exception as exc:
|
||||
write_json(output / "failure.json", {"complete": False, "error": str(exc), "type": type(exc).__name__, "solverExecuted": False})
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -33,6 +33,76 @@ NEW_CALL = '''double base=area*p*cm/sqrt(T),K=pow(4*base/den,2)*d/length;
|
||||
return sign*native_pipe_resistance(K,rr,den/4,NULL)*den/4;'''
|
||||
|
||||
|
||||
def pipe_source_path(native: Path) -> Path:
|
||||
"""Locate executable pipe formulas, never a modular aggregation entry."""
|
||||
module = native / 'components/modules/pipe.c'
|
||||
legacy = native / 'components/kernels.c'
|
||||
if module.is_file():
|
||||
target = module
|
||||
elif (native / 'components/modules').exists():
|
||||
raise ValueError(f'Incomplete modular native snapshot: {module} is missing')
|
||||
else:
|
||||
target = legacy
|
||||
if not target.is_file():
|
||||
raise ValueError(f'Native snapshot has no audited pipe source: {native}')
|
||||
source = target.read_text()
|
||||
if source.count('double native_pipe_resistance(') != 1 or source.count('double native_pipe_flow(') != 1:
|
||||
raise ValueError(f'Unsupported pipe source layout: {target}; refusing an unmodified or uninstrumented run')
|
||||
return target
|
||||
|
||||
|
||||
def working_native_paths() -> list[str]:
|
||||
return sorted('native/' + path.relative_to(ROOT / 'native').as_posix()
|
||||
for path in (ROOT / 'native').rglob('*') if path.is_file())
|
||||
|
||||
|
||||
def build_snapshot(program, native: Path, cache: Path, *, required_sources=()):
|
||||
"""Build exact snapshot sources and verify the modified files were compiled.
|
||||
|
||||
Historical monolithic trees retain their original set of C translation
|
||||
units. This temporary selector override is confined to the standalone
|
||||
manual process; production source selection is always restored afterwards.
|
||||
An incompatible old model/runtime ABI fails compilation explicitly.
|
||||
"""
|
||||
native = native.resolve()
|
||||
pipe = pipe_source_path(native)
|
||||
required = (pipe, *required_sources)
|
||||
old_native = builder.NATIVE
|
||||
old_selector = getattr(builder, '_runtime_sources', None)
|
||||
legacy = pipe.name == 'kernels.c'
|
||||
try:
|
||||
builder.NATIVE = native
|
||||
if legacy and old_selector is not None:
|
||||
builder._runtime_sources = lambda _: sorted(native.rglob('*.c'))
|
||||
elif not legacy and old_selector is None:
|
||||
raise ValueError('A modular snapshot requires the incremental native source selector; use its matching checkout')
|
||||
selected = (builder._runtime_sources(program) if old_selector is not None
|
||||
else sorted(native.rglob('*.c')))
|
||||
if any(path.resolve() not in {source.resolve() for source in selected} for path in required):
|
||||
raise ValueError('The model/build selector does not compile the modified pipe/RHS source; refusing a misleading run')
|
||||
try:
|
||||
build = builder.build_native(program, cache_dir=cache)
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(
|
||||
f'Cannot build this native snapshot ({native}). Its source layout or model/runtime ABI may be incompatible; '
|
||||
'use matching compiler/runtime revisions. No benchmark was executed.'
|
||||
) from exc
|
||||
recorded = build.manifest.get('sourceHashes', {})
|
||||
for path in required:
|
||||
suffix = 'native/' + path.resolve().relative_to(native).as_posix()
|
||||
matches = [digest for name, digest in recorded.items()
|
||||
if name == suffix or name.replace('\\', '/').endswith('/' + suffix)]
|
||||
if matches != [sha256(path.read_bytes()).hexdigest()]:
|
||||
if hasattr(build, 'close'):
|
||||
build.close()
|
||||
raise ValueError(f'Build manifest does not prove the modified source was compiled: {suffix}')
|
||||
return build
|
||||
finally:
|
||||
builder.NATIVE = old_native
|
||||
if old_selector is not None:
|
||||
builder._runtime_sources = old_selector
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('input',type=Path)
|
||||
@@ -42,7 +112,8 @@ def main():
|
||||
args=parser.parse_args()
|
||||
if args.runs<1:parser.error('--runs must be positive')
|
||||
out=args.output_dir.resolve();out.mkdir(parents=True,exist_ok=True)
|
||||
if (out/'summary.json').exists():parser.error('Choose a fresh output directory')
|
||||
if (out/'summary.json').exists() or any((out/name).exists() for name in ('fixed-point','previous-newton','guarded-newton')):
|
||||
parser.error('Choose a fresh output directory; existing variant sources must not be mixed')
|
||||
revision=subprocess.check_output(['git','rev-parse',args.baseline_ref],cwd=ROOT,text=True).strip()
|
||||
xml,doc=load_input(args.input)
|
||||
program=compile_native_program(compile_system_xml_network(doc))
|
||||
@@ -55,16 +126,16 @@ def main():
|
||||
try:
|
||||
for variant in ('fixed-point','previous-newton','guarded-newton'):
|
||||
directory=out/variant
|
||||
for name in paths:
|
||||
variant_paths = working_native_paths() if variant == 'guarded-newton' else paths
|
||||
for name in variant_paths:
|
||||
if variant=='guarded-newton':data=(ROOT/name).read_bytes()
|
||||
else:data=subprocess.check_output(['git','show',f'{revision}:{name}'],cwd=ROOT)
|
||||
target=directory/name;target.parent.mkdir(parents=True,exist_ok=True);target.write_bytes(data)
|
||||
if variant=='fixed-point':
|
||||
target=directory/'native/components/kernels.c';source=target.read_text()
|
||||
target=pipe_source_path(directory/'native');source=target.read_text()
|
||||
if source.count(NEW_CALL)!=1:raise ValueError('Baseline pipe flow layout does not match the audited fixed-point substitution')
|
||||
target.write_text(source.replace(NEW_CALL,OLD_ITERATION))
|
||||
builder.NATIVE=directory/'native'
|
||||
variants[variant]=builder.build_native(program,cache_dir=out/'cache')
|
||||
variants[variant]=build_snapshot(program,directory/'native',out/'cache')
|
||||
finally:builder.NATIVE=original_native
|
||||
rows=[]
|
||||
for index in range(args.runs+1):
|
||||
@@ -80,7 +151,9 @@ def main():
|
||||
summary={'baselineRef':revision,'input':str(args.input.resolve()),
|
||||
'inputSha256':sha256(args.input.read_bytes()).hexdigest(),'xmlSha256':sha256(xml).hexdigest(),
|
||||
'settings':vars(config),'sampleStep':doc.simulation.sample_step,'rows':rows,
|
||||
'variants':{name:{'sourceSha256':sha256((out/name/'native/components/kernels.c').read_bytes()).hexdigest(),
|
||||
'variants':{name:{'sourceSha256':sha256(pipe_source_path(out/name/'native').read_bytes()).hexdigest(),
|
||||
'pipeSource':str(pipe_source_path(out/name/'native').relative_to(out/name)),
|
||||
'nativeSourceHashes':build.manifest['sourceHashes'],
|
||||
'buildKey':build.manifest['buildKey'],
|
||||
'medianSolveSeconds':statistics.median(row['solveSeconds'] for row in rows if row['variant']==name and row['run']>0),
|
||||
'medianProcessSeconds':statistics.median(row['processWallSeconds'] for row in rows if row['variant']==name and row['run']>0)}
|
||||
|
||||
@@ -220,9 +220,15 @@ def prepare(args: argparse.Namespace) -> dict:
|
||||
# Reject numerical/runtime drift; a sparse timing-only cached main is allowed
|
||||
# because control and our current writer share the numeric model contract.
|
||||
differences = []
|
||||
for name, expected in manifest["sourceHashes"].items():
|
||||
recorded_sources = dict(manifest["sourceHashes"])
|
||||
if manifest.get("cacheVersion", 1) >= 2:
|
||||
headers = manifest.get("nativeHeaderHashes")
|
||||
if not isinstance(headers, dict) or not headers:
|
||||
raise RuntimeError("Cached build lacks native header hashes; rebuild before profiling")
|
||||
recorded_sources.update(headers)
|
||||
for name, expected in recorded_sources.items():
|
||||
relative = name.split("native/", 1)[-1]
|
||||
current = ROOT / "native" / relative
|
||||
current = cache / relative if relative == "model.c" else ROOT / "native" / relative
|
||||
if digest(current) != expected:
|
||||
differences.append(relative)
|
||||
if any(name != "runtime/main.c" for name in differences):
|
||||
@@ -243,7 +249,16 @@ def prepare(args: argparse.Namespace) -> dict:
|
||||
for name in ("model.c", "model.h", "manifest.json"):
|
||||
shutil.copy2(cache / name, output / name)
|
||||
instrument(native)
|
||||
command = [compiler, *manifest["compilerFlags"], "-I", str(output), "-I", str(native / "include"), "-I", str(sundials / "include"), str(output / "model.c"), *map(str, sorted(native.rglob("*.c"))), "-Wl,--start-group", *map(str, libraries), "-Wl,--end-group", "-lm", "-o", str(output / "profiled-model")]
|
||||
# Match the cached translation units. Compiling the compatibility entry
|
||||
# together with its new modules would define every component twice.
|
||||
sources = [native / name.split("native/", 1)[-1]
|
||||
for name in manifest["sourceHashes"]
|
||||
if name.endswith(".c") and name != "model.c"]
|
||||
if not sources or (native / "components/kernels.c" in sources and
|
||||
any(path.parent == native / "components/modules" for path in sources)):
|
||||
raise RuntimeError("Cached translation units are missing or mix amalgamation and modules")
|
||||
sources.append(native / "runtime/compute_profile.c")
|
||||
command = [compiler, *manifest["compilerFlags"], "-I", str(output), "-I", str(native / "include"), "-I", str(sundials / "include"), str(output / "model.c"), *map(str, sources), "-Wl,--start-group", *map(str, libraries), "-Wl,--end-group", "-lm", "-o", str(output / "profiled-model")]
|
||||
prepared = {"controlExecutable": str(cache / "model"), "profiledExecutable": str(output / "profiled-model"), "buildCommand": command, "runtimeArguments": numerical_arguments, "verifyJacobian": "--verify-jacobian" in numerical_arguments, "algorithm": "production-automatic", "cacheDir": str(cache), "cachedMainDifference": differences, "stateCount": len(manifest["stateKeys"]), "modelSha256": digest(output / "model.c"), "compiler": compiler_version, "warmups": args.warmups, "repeats": args.repeats, "scope": "Inclusive times overlap. Exclusive scope times partition integration including instrumentation overhead. No component or libc sub-cost inference.", "sourceHashes": {str(p.relative_to(output)): digest(p) for p in sorted(native.rglob("*")) if p.is_file()}}
|
||||
write_json(output / "prepared.json", prepared)
|
||||
return prepared
|
||||
|
||||
@@ -24,7 +24,9 @@ from app.simulation.backends import simulation_config
|
||||
from app.simulation.native_codegen import build as builder
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
from tests.manual.benchmark_native_pipe_solver import build_snapshot, pipe_source_path, working_native_paths
|
||||
|
||||
SNAPSHOT_HELPER = ROOT / 'tests/manual/benchmark_native_pipe_solver.py'
|
||||
VARIANTS = ('guarded-newton', 'previous-newton', 'fixed-point')
|
||||
FIELDS = '''cache_requests cache_hits cache_misses flow_calls zero_pressure_calls
|
||||
pnl00r_analytic_calls resistance_calls scalar_analytic_calls iterative_calls
|
||||
@@ -214,7 +216,7 @@ def instrument(source: str, variant: str) -> str:
|
||||
limit = '(kind==0?64:16)' if fixed else '80' if variant == 'previous-newton' else '128'
|
||||
prefix = PROFILE_PREFIX.replace('@VARIANT@', variant).replace('@FIXED@', str(int(fixed)))
|
||||
prefix = prefix.replace('@LIMIT@', limit).replace('@FIELDS@', ' '.join(f'X({name})' for name in FIELDS))
|
||||
source = replace_once(source, '#include <stddef.h>', '#include <stddef.h>\n' + prefix)
|
||||
source = replace_once(source, '#include <math.h>', '#include <math.h>\n' + prefix)
|
||||
# Exhaustion is marked at the actual loop fall-through, not inferred from
|
||||
# visiting the last allowed iteration (which can still converge).
|
||||
start = source.index('double native_pipe_resistance(')
|
||||
@@ -259,6 +261,19 @@ def instrument(source: str, variant: str) -> str:
|
||||
return source
|
||||
|
||||
|
||||
|
||||
def instrument_common(source: str) -> str:
|
||||
"""Count both ordinary and canonical Jacobian RHS evaluations exactly once."""
|
||||
calls = [('model_eval', ' return model_eval(t,y,dy,w);')]
|
||||
if 'int native_jacobian_rhs(' in source:
|
||||
calls.append(('model_eval_jacobian', ' return model_eval_jacobian(t,y,dy,w);'))
|
||||
for function, fragment in calls:
|
||||
source = replace_once(source, fragment,
|
||||
' extern void pipe_profile_rhs_enter(void),pipe_profile_rhs_leave(void);\n'
|
||||
f' pipe_profile_rhs_enter();int ok={function}(t,y,dy,w);pipe_profile_rhs_leave();return ok;')
|
||||
return source
|
||||
|
||||
|
||||
def git(*arguments: str) -> str:
|
||||
return subprocess.check_output(['git', *arguments], cwd=ROOT, text=True).strip()
|
||||
|
||||
@@ -268,11 +283,14 @@ def prepare(args, out: Path) -> dict:
|
||||
metadata = json.loads((out / 'prepared.json').read_text())
|
||||
if metadata['input_sha256'] != sha256(args.input.read_bytes()).hexdigest():
|
||||
raise ValueError('Prepared model no longer matches the input file')
|
||||
if metadata['script_sha256'] != sha256(Path(__file__).read_bytes()).hexdigest():
|
||||
if (metadata['script_sha256'] != sha256(Path(__file__).read_bytes()).hexdigest()
|
||||
or metadata.get('snapshot_helper_sha256') != sha256(SNAPSHOT_HELPER.read_bytes()).hexdigest()):
|
||||
raise ValueError('Diagnostic helper changed; choose a fresh output directory')
|
||||
return metadata
|
||||
if any((out / variant).exists() for variant in VARIANTS):
|
||||
raise ValueError('Partially prepared variant directories exist; choose a fresh output directory')
|
||||
previous = git('rev-parse', args.previous_ref)
|
||||
current = git('rev-parse', args.current_ref)
|
||||
current = 'working-tree' if args.current_ref == 'working-tree' else git('rev-parse', args.current_ref)
|
||||
xml, doc = load_input(args.input)
|
||||
config = simulation_config(doc.simulation)
|
||||
if config.rtol != 1e-8 or config.t_start != 0 or config.t_stop != 10:
|
||||
@@ -281,37 +299,40 @@ def prepare(args, out: Path) -> dict:
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / 'input.xml').write_bytes(xml)
|
||||
(out / 'input.json').write_bytes(args.input.read_bytes())
|
||||
paths = git('ls-tree', '-r', '--name-only', current, 'native').splitlines()
|
||||
metadata = dict(input=str(args.input.resolve()), input_sha256=sha256(args.input.read_bytes()).hexdigest(),
|
||||
xml_sha256=sha256(xml).hexdigest(), script_sha256=sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
snapshot_helper_sha256=sha256(SNAPSHOT_HELPER.read_bytes()).hexdigest(),
|
||||
previous_revision=previous, current_revision=current, settings=vars(config),
|
||||
sample_step=doc.simulation.sample_step, variants={},
|
||||
measurement_note='Instrumented times are diagnostic overhead and MUST NOT be used as production benchmark results.',
|
||||
counter_scope='rhs counts native_rhs/model_eval calls including rejected trials and Jacobian differences; non_rhs includes initialization/output/probe evaluations.',
|
||||
counter_scope='rhs counts native_rhs/model_eval and, where present, native_jacobian_rhs/model_eval_jacobian calls including rejected trials and Jacobian differences; non_rhs includes initialization/output/probe evaluations.',
|
||||
replay_scope='All guarded RHS resistance calls, including scalar analytic low-Re cases; no sampling or deduplication. Cache hits, zero pressure difference and direct PNL00R analytic calls are excluded and counted separately.',
|
||||
capture_format='Native-endian IEEE-754 binary64 records: base, diameter, length, relative roughness, den=pi*d*mu, kind (six doubles, 48 bytes). Replay on the same host.',
|
||||
residual_test='At actual returned q, independently factored long-double f(Re) evaluates abs(Re^2*f(Re)/K-1)<=1e-9. Separate from each algorithm stopping rule.')
|
||||
original_native = builder.NATIVE
|
||||
prepared_builds = [] # Pin earlier variants while later variants are built.
|
||||
try:
|
||||
for variant in VARIANTS:
|
||||
directory = out / variant
|
||||
revision = current if variant == 'guarded-newton' else previous
|
||||
paths = (working_native_paths() if revision == 'working-tree'
|
||||
else git('ls-tree', '-r', '--name-only', revision, 'native').splitlines())
|
||||
for name in paths:
|
||||
target = directory / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(subprocess.check_output(['git', 'show', f'{revision}:{name}'], cwd=ROOT))
|
||||
kernel = directory / 'native/components/kernels.c'
|
||||
data = ((ROOT / name).read_bytes() if revision == 'working-tree'
|
||||
else subprocess.check_output(['git', 'show', f'{revision}:{name}'], cwd=ROOT))
|
||||
target.write_bytes(data)
|
||||
kernel = pipe_source_path(directory / 'native')
|
||||
original_hash = sha256(kernel.read_bytes()).hexdigest()
|
||||
kernel.write_text(instrument(kernel.read_text(), variant))
|
||||
common = directory / 'native/runtime/common.c'
|
||||
common.write_text(replace_once(common.read_text(), ' return model_eval(t,y,dy,w);',
|
||||
''' extern void pipe_profile_rhs_enter(void),pipe_profile_rhs_leave(void);
|
||||
pipe_profile_rhs_enter();int ok=model_eval(t,y,dy,w);pipe_profile_rhs_leave();return ok;'''))
|
||||
common.write_text(instrument_common(common.read_text()))
|
||||
main = directory / 'native/runtime/main.c'
|
||||
main.write_text(replace_once(main.read_text(), 'int main(int argc, char **argv) {',
|
||||
'int main(int argc, char **argv) {\n extern void pipe_profile_install(void);pipe_profile_install();'))
|
||||
builder.NATIVE = directory / 'native'
|
||||
build = builder.build_native(program, cache_dir=out / 'cache')
|
||||
build = build_snapshot(program, directory / 'native', out / 'cache', required_sources=(common, main))
|
||||
prepared_builds.append(build)
|
||||
replay_source = directory / 'replay.c'
|
||||
replay_source.write_text(REPLAY_MAIN)
|
||||
replay = directory / ('replay.exe' if os.name == 'nt' else 'replay')
|
||||
@@ -323,7 +344,8 @@ def prepare(args, out: Path) -> dict:
|
||||
if compiled.returncode:
|
||||
raise RuntimeError(f'Replay compilation failed: {compiled.stderr}')
|
||||
metadata['variants'][variant] = dict(executable=str(build.executable), replay=str(replay),
|
||||
build_key=build.manifest['buildKey'], original_kernel_sha256=original_hash,
|
||||
build_key=build.manifest['buildKey'], pipe_source=str(kernel.relative_to(directory)),
|
||||
compiled_source_hashes=build.manifest['sourceHashes'], original_kernel_sha256=original_hash,
|
||||
instrumented_kernel_sha256=sha256(kernel.read_bytes()).hexdigest())
|
||||
print(f'Prepared {variant}', flush=True)
|
||||
finally:
|
||||
@@ -423,7 +445,8 @@ def main():
|
||||
parser.add_argument('--input', type=Path, default=ROOT / 'tests/data/test-mql-8-corrected.json')
|
||||
parser.add_argument('--output-dir', type=Path, required=True)
|
||||
parser.add_argument('--previous-ref', default='5d5a2e1')
|
||||
parser.add_argument('--current-ref', default='808c484')
|
||||
parser.add_argument('--current-ref', default='808c484',
|
||||
help='Audited guarded-solver revision, or working-tree for the current modular sources')
|
||||
parser.add_argument('--timeout', type=float, default=120)
|
||||
parser.add_argument('--prepare-only', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
Reference in new issue
Block a user