C内核按库功能拆解,编译结果缓存区构建,编译过程与已有缓存结果对照功能实现

This commit is contained in:
lujingze committed 2026-09-12 05:24:48 +00:00
1 parent aa4951b14e
commit 151e6e4b97
30 files changed
+4856 -604

No files matched your search

@@ -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()