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()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Read the diagnostic kernel translation unit before test-only injection.
|
||||
|
||||
Only component module C includes are expanded. Header includes and the
|
||||
amalgamation linkage switch remain for the ordinary C preprocessor to handle.
|
||||
An older, unsplit kernels.c is returned unchanged.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
NATIVE = Path(__file__).resolve().parents[1] / "native"
|
||||
_SOURCE_INCLUDE = re.compile(r'^[ \t]*#[ \t]*include[ \t]+"([^"\n]+\.c)"[ \t]*$', re.MULTILINE)
|
||||
|
||||
|
||||
def native_kernel_source(native: Path = NATIVE) -> str:
|
||||
entry = native / "components/kernels.c"
|
||||
modules = (native / "components/modules").resolve()
|
||||
|
||||
def expand(match: re.Match[str]) -> str:
|
||||
source = (entry.parent / match[1]).resolve()
|
||||
if not source.is_relative_to(modules):
|
||||
return match[0]
|
||||
return source.read_text(encoding="utf-8")
|
||||
|
||||
return _SOURCE_INCLUDE.sub(expand, entry.read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,528 @@
|
||||
"""Exercise the native build cache with real, tiny C translation units.
|
||||
|
||||
These tests isolate their sources, libraries and caches in a temporary directory.
|
||||
They execute the linked programs to check behavior, and observe compiler commands
|
||||
to distinguish actual object reuse from a reported cache hit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import replace
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.simulation.native_codegen import build as native_build
|
||||
from app.simulation.native_codegen.compiler import NativeProgram
|
||||
from app.simulation.native_codegen.cache_storage import prune_cache
|
||||
from app.simulation.native_codegen.runner import execute_native
|
||||
from app.simulation.config import SolveIVPConfig
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux") and shutil.which("gcc"),
|
||||
"Small real-compiler fixtures currently require Linux gcc")
|
||||
class NativeBuildCacheTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="native-build-cache-tests-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.native = self.root / "native"
|
||||
self.cache = self.root / "cache"
|
||||
self.sundials = self.root / "sundials"
|
||||
self.compiler = str(Path(shutil.which("gcc")).resolve())
|
||||
self.real_run = subprocess.run
|
||||
self.compiler_version = self.real_run(
|
||||
[self.compiler, "--version"], capture_output=True, text=True,
|
||||
check=True, timeout=10,
|
||||
).stdout.splitlines()[0]
|
||||
self.commands: list[tuple[str, ...]] = []
|
||||
self.command_lock = threading.Lock()
|
||||
self._write("native/THIRD_PARTY_NOTICES.txt", "Fixture code belongs to this test.\n")
|
||||
self._write("native/include/values.h", '#include "nested/value.h"\n')
|
||||
self._write("native/include/nested/value.h", "#define COMMON_VALUE 3\n")
|
||||
self._write("native/include/signal_config.h", "#define SIGNAL_VALUE 5\n")
|
||||
self._write("native/runtime/main.c", '''#include "model.h"
|
||||
#include <stdio.h>
|
||||
int common_value(void);
|
||||
int main(void) {
|
||||
printf("%.17g\\n", model_value() + common_value() + HEADER_OFFSET);
|
||||
return 0;
|
||||
}
|
||||
''')
|
||||
self._write("native/runtime/common.c", '''#include "values.h"
|
||||
int common_value(void) { return COMMON_VALUE; }
|
||||
''')
|
||||
for name in ("rk45", "cvode_solver", "json_numbers"):
|
||||
self._write(f"native/runtime/{name}.c", "/* unused runtime fixture */\n")
|
||||
self._write("native/encoding/ryu/d2s.c", "/* unused encoder fixture */\n")
|
||||
self._write("native/components/modules/signal.c", '''#include "signal_config.h"
|
||||
double native_signal(void) { return SIGNAL_VALUE; }
|
||||
''')
|
||||
for name in ("properties", "pipe", "orifice", "mechanics"):
|
||||
self._write(f"native/components/modules/{name}.c", "/* unneeded component */\n")
|
||||
self._write("sundials/include/cvode/cvode.h", "/* fixture */\n")
|
||||
self._write("sundials/include/sundials/sundials_config.h", "/* fixture */\n")
|
||||
# An empty archive is sufficient: the fixture does not call SUNDIALS.
|
||||
for name in native_build.LIBRARIES:
|
||||
self._write(f"sundials/lib/libsundials_{name}.a", b"!<arch>\n")
|
||||
self.program = NativeProgram(
|
||||
source='#include "model.h"\ndouble model_value(void) { return native_signal() + 1; }\n',
|
||||
header="#define HEADER_OFFSET 0\ndouble model_value(void);\ndouble native_signal(void);\n",
|
||||
state_keys=(), variables=(), component_types=(),
|
||||
)
|
||||
self.patches = ExitStack()
|
||||
self.addCleanup(self.patches.close)
|
||||
self.patches.enter_context(patch.object(native_build, "ROOT", self.root))
|
||||
self.patches.enter_context(patch.object(native_build, "NATIVE", self.native))
|
||||
self.patches.enter_context(patch.object(
|
||||
native_build, "toolchain", return_value=(self.compiler, self.sundials, self.compiler_version),
|
||||
))
|
||||
self.patches.enter_context(patch.object(native_build.subprocess, "run", side_effect=self._run))
|
||||
|
||||
def _write(self, relative: str, content: str | bytes) -> Path:
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if isinstance(content, bytes):
|
||||
path.write_bytes(content)
|
||||
else:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def _run(self, command, *args, **kwargs):
|
||||
with self.command_lock:
|
||||
self.commands.append(tuple(map(str, command)))
|
||||
return self.real_run(command, *args, **kwargs)
|
||||
|
||||
def _build(self, program=None):
|
||||
return native_build.build_native(program or self.program, cache_dir=self.cache)
|
||||
|
||||
def _compiled_count(self) -> int:
|
||||
return sum("-c" in command for command in self.commands)
|
||||
|
||||
def _linked_count(self) -> int:
|
||||
return sum("-o" in command and "-c" not in command and "-E" not in command
|
||||
for command in self.commands)
|
||||
|
||||
def _output(self, build) -> float:
|
||||
completed = self.real_run([str(build.executable)], capture_output=True, text=True,
|
||||
check=True, timeout=10)
|
||||
return float(completed.stdout.strip())
|
||||
|
||||
def _parameter_program(self, value: int):
|
||||
return replace(self.program, source=self.program.source.replace(" + 1;", f" + {value};"))
|
||||
|
||||
def _objects(self):
|
||||
return sorted(path for path in (self.cache / "objects").iterdir()
|
||||
if len(path.name) == 64 and (path / "manifest.json").is_file())
|
||||
|
||||
def _object_for(self, suffix: str) -> Path:
|
||||
matches = [path for path in self._objects()
|
||||
if json.loads((path / "manifest.json").read_text())["sourceName"].endswith(suffix)]
|
||||
self.assertEqual(len(matches), 1, suffix)
|
||||
return matches[0]
|
||||
|
||||
def test_complete_hit_executes_same_program_without_compilation_or_linking(self):
|
||||
first = self._build()
|
||||
self.assertFalse(first.cache_hit)
|
||||
self.assertEqual(self._output(first), 9)
|
||||
self.assertGreater(self._compiled_count(), 1)
|
||||
self.commands.clear()
|
||||
second = self._build()
|
||||
self.assertTrue(second.cache_hit)
|
||||
self.assertEqual(first.manifest["buildKey"], second.manifest["buildKey"])
|
||||
self.assertEqual(self._output(second), 9)
|
||||
self.assertEqual(self._compiled_count(), 0)
|
||||
self.assertEqual(self._linked_count(), 0)
|
||||
|
||||
def test_parameter_edit_reuses_shared_objects_and_recompiles_only_model(self):
|
||||
first = self._build()
|
||||
self.commands.clear()
|
||||
second = self._build(self._parameter_program(11))
|
||||
self.assertFalse(second.cache_hit)
|
||||
self.assertNotEqual(first.manifest["buildKey"], second.manifest["buildKey"])
|
||||
self.assertEqual(self._output(second), 19)
|
||||
self.assertEqual(self._compiled_count(), 1)
|
||||
self.assertEqual(self._linked_count(), 1)
|
||||
self.commands.clear()
|
||||
self.assertTrue(self._build().cache_hit)
|
||||
self.assertEqual(self._compiled_count(), 0)
|
||||
|
||||
def test_model_header_edit_invalidates_only_translation_units_that_include_it(self):
|
||||
self._build()
|
||||
self.commands.clear()
|
||||
changed = replace(self.program, header=self.program.header.replace("OFFSET 0", "OFFSET 7"))
|
||||
result = self._build(changed)
|
||||
self.assertEqual(self._output(result), 16)
|
||||
# Both include model.h, but the model TU's preprocessed tokens are
|
||||
# unchanged because it does not use HEADER_OFFSET. Only main must rebuild.
|
||||
self.assertEqual(self._compiled_count(), 1)
|
||||
|
||||
def test_model_header_declaration_edit_rebuilds_all_actual_dependents(self):
|
||||
self._build()
|
||||
self.commands.clear()
|
||||
changed = replace(self.program, header=self.program.header + "extern int unused_model_state;\n")
|
||||
result = self._build(changed)
|
||||
self.assertEqual(self._output(result), 9)
|
||||
self.assertEqual(self._compiled_count(), 2)
|
||||
|
||||
def test_indirect_header_change_invalidates_the_actual_runtime_object(self):
|
||||
first = self._build()
|
||||
self.commands.clear()
|
||||
self._write("native/include/nested/value.h", "#define COMMON_VALUE 13\n")
|
||||
second = self._build()
|
||||
self.assertFalse(second.cache_hit)
|
||||
self.assertNotEqual(first.manifest["buildKey"], second.manifest["buildKey"])
|
||||
self.assertEqual(self._output(second), 19)
|
||||
self.assertEqual(self._compiled_count(), 1)
|
||||
|
||||
def test_unneeded_modules_and_headers_do_not_invalidate_or_enter_the_build(self):
|
||||
first = self._build()
|
||||
self.commands.clear()
|
||||
self._write("native/components/modules/pipe.c", "this is deliberately invalid C\n")
|
||||
self._write("native/components/modules/new_unused.c", "another deliberately invalid unit\n")
|
||||
self._write("native/include/unused_new_header.h", "#error should never be included\n")
|
||||
second = self._build()
|
||||
self.assertTrue(second.cache_hit)
|
||||
self.assertEqual(first.manifest["buildKey"], second.manifest["buildKey"])
|
||||
self.assertEqual(self._output(second), 9)
|
||||
self.assertEqual(self._compiled_count(), 0)
|
||||
|
||||
def test_used_module_change_rebuilds_one_object_and_changes_executable(self):
|
||||
first = self._build()
|
||||
self.commands.clear()
|
||||
self._write("native/include/signal_config.h", "#define SIGNAL_VALUE 15\n")
|
||||
second = self._build()
|
||||
self.assertNotEqual(first.manifest["buildKey"], second.manifest["buildKey"])
|
||||
self.assertEqual(self._output(second), 19)
|
||||
self.assertEqual(self._compiled_count(), 1)
|
||||
|
||||
def test_compile_flags_and_compiler_identity_cannot_reuse_old_objects(self):
|
||||
first = self._build()
|
||||
original_count = self._compiled_count()
|
||||
self.commands.clear()
|
||||
with patch.object(native_build, "COMPILER_FLAGS", (*native_build.COMPILER_FLAGS, "-DTEST_CACHE_VARIANT=1")):
|
||||
flags_build = self._build()
|
||||
self.assertNotEqual(first.manifest["buildKey"], flags_build.manifest["buildKey"])
|
||||
self.assertEqual(self._compiled_count(), original_count)
|
||||
self.assertEqual(self._output(flags_build), 9)
|
||||
self.commands.clear()
|
||||
with patch.object(native_build, "toolchain", return_value=(self.compiler, self.sundials, self.compiler_version + " test revision")):
|
||||
compiler_build = self._build()
|
||||
self.assertNotEqual(first.manifest["buildKey"], compiler_build.manifest["buildKey"])
|
||||
self.assertEqual(self._compiled_count(), original_count)
|
||||
self.assertEqual(self._output(compiler_build), 9)
|
||||
|
||||
def test_library_change_relinks_but_reuses_compiled_objects(self):
|
||||
first = self._build()
|
||||
self.commands.clear()
|
||||
# Valid ar symbol-table member with no entries, changing bytes only.
|
||||
empty_object = self.root / "library-member.c"
|
||||
empty_object.write_text("int library_fixture_symbol(void) { return 1; }\n")
|
||||
object_path = self.root / "library-member.o"
|
||||
self.real_run([self.compiler, "-c", str(empty_object), "-o", str(object_path)], check=True)
|
||||
self.real_run(["ar", "r", str(self.sundials / "lib/libsundials_core.a"), str(object_path)],
|
||||
capture_output=True, check=True)
|
||||
second = self._build()
|
||||
self.assertNotEqual(first.manifest["buildKey"], second.manifest["buildKey"])
|
||||
self.assertEqual(self._compiled_count(), 0)
|
||||
self.assertEqual(self._linked_count(), 1)
|
||||
self.assertEqual(self._output(second), 9)
|
||||
|
||||
def test_model_manifest_requires_identity_and_complete_safe_artifacts(self):
|
||||
built = self._build()
|
||||
manifest_path = built.executable.parent / "manifest.json"
|
||||
original = manifest_path.read_text()
|
||||
manifest = json.loads(original)
|
||||
mutations = {
|
||||
"empty artifacts": lambda value: value.update(artifacts={}),
|
||||
"missing executable": lambda value: value["artifacts"].pop(built.executable.name),
|
||||
"missing generated header": lambda value: value["artifacts"].pop("model.h"),
|
||||
"wrong build key": lambda value: value.update(buildKey="0" * 64),
|
||||
"wrong cache version": lambda value: value.update(cacheVersion=-1),
|
||||
"wrong object identities": lambda value: value.update(objectKeys=["0" * 64]),
|
||||
"wrong result contract": lambda value: value.update(stateKeys=["unexpected.state"]),
|
||||
"parent traversal": lambda value: value["artifacts"].update({"../outside": "0" * 64}),
|
||||
"absolute path": lambda value: value["artifacts"].update({str(self.root / "outside"): "0" * 64}),
|
||||
}
|
||||
for label, mutate in mutations.items():
|
||||
with self.subTest(label=label):
|
||||
modified = json.loads(original)
|
||||
mutate(modified)
|
||||
manifest_path.write_text(json.dumps(modified))
|
||||
try:
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build()
|
||||
finally:
|
||||
manifest_path.write_text(original)
|
||||
self.assertEqual(manifest["cacheVersion"], 2)
|
||||
self.assertTrue(self._build().cache_hit)
|
||||
|
||||
def test_model_source_cannot_be_replaced_by_updating_its_artifact_digest(self):
|
||||
built = self._build()
|
||||
source = built.executable.parent / "model.c"
|
||||
source.write_text(self._parameter_program(12).source)
|
||||
manifest_path = built.executable.parent / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
manifest["artifacts"]["model.c"] = sha256(source.read_bytes()).hexdigest()
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build()
|
||||
|
||||
def test_changed_executable_and_artifact_symlink_are_rejected(self):
|
||||
built = self._build()
|
||||
executable = built.executable
|
||||
original = executable.read_bytes()
|
||||
executable.write_bytes(original + b"corrupt")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build()
|
||||
executable.write_bytes(original)
|
||||
target = self.root / "outside-executable"
|
||||
target.write_bytes(original)
|
||||
executable.unlink()
|
||||
executable.symlink_to(target)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build()
|
||||
|
||||
def test_object_bytes_and_object_manifest_are_checked_when_reused(self):
|
||||
first = self._build()
|
||||
object_dir = self._object_for("signal.c")
|
||||
unit = object_dir / "unit.o"
|
||||
manifest_path = object_dir / "manifest.json"
|
||||
original_object = unit.read_bytes()
|
||||
original_manifest = manifest_path.read_text()
|
||||
changed_program = self._parameter_program(2)
|
||||
unit.write_bytes(original_object + b"corrupt")
|
||||
# Complete executable reuse need not inspect disposable intermediate objects.
|
||||
self.assertTrue(self._build().cache_hit)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build(changed_program)
|
||||
unit.write_bytes(original_object)
|
||||
for label, mutate in {
|
||||
"empty artifacts": lambda value: value.update(artifacts={}),
|
||||
"wrong key": lambda value: value.update(objectKey="0" * 64),
|
||||
"wrong version": lambda value: value.update(cacheVersion=-1),
|
||||
"wrong source": lambda value: value.update(sourceName="native/another.c"),
|
||||
"wrong preprocessing": lambda value: value.update(preprocessedSha256="0" * 64),
|
||||
"wrong compiler": lambda value: value.update(compiler={}),
|
||||
"parent traversal": lambda value: value["artifacts"].update({"../unit.o": "0" * 64}),
|
||||
}.items():
|
||||
with self.subTest(label=label):
|
||||
modified = json.loads(original_manifest)
|
||||
mutate(modified)
|
||||
manifest_path.write_text(json.dumps(modified))
|
||||
try:
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build(changed_program)
|
||||
finally:
|
||||
manifest_path.write_text(original_manifest)
|
||||
target = self.root / "outside-unit.o"
|
||||
target.write_bytes(original_object)
|
||||
unit.unlink()
|
||||
unit.symlink_to(target)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build(changed_program)
|
||||
self.assertEqual(self._output(first), 9)
|
||||
|
||||
def test_library_change_during_link_is_rejected_before_publication(self):
|
||||
first = self._build()
|
||||
before = {path.name for path in (self.cache / "models").iterdir() if len(path.name) == 64}
|
||||
source = self._write("replacement-library.c", "int replacement_library(void) { return 7; }\n")
|
||||
object_path = self.root / "replacement-library.o"
|
||||
alternate = self.root / "alternate.a"
|
||||
self.real_run([self.compiler, "-c", str(source), "-o", str(object_path)], check=True)
|
||||
self.real_run(["ar", "rc", str(alternate), str(object_path)], capture_output=True, check=True)
|
||||
library = self.sundials / "lib/libsundials_core.a"
|
||||
original = library.read_bytes()
|
||||
linked_successfully = []
|
||||
|
||||
def replace_library_at_link(command, *args, **kwargs):
|
||||
is_link = "-o" in command and "-c" not in command and "-E" not in command
|
||||
if is_link:
|
||||
library.write_bytes(alternate.read_bytes())
|
||||
result = self._run(command, *args, **kwargs)
|
||||
if is_link:
|
||||
linked_successfully.append(result.returncode == 0)
|
||||
return result
|
||||
|
||||
try:
|
||||
with patch.object(native_build.subprocess, "run", side_effect=replace_library_at_link):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build(self._parameter_program(2))
|
||||
self.assertEqual(linked_successfully, [True])
|
||||
published = {path.name for path in (self.cache / "models").iterdir() if len(path.name) == 64}
|
||||
self.assertEqual(published, before)
|
||||
self.assertEqual(self._output(first), 9)
|
||||
finally:
|
||||
library.write_bytes(original)
|
||||
self.assertTrue(self._build().cache_hit)
|
||||
|
||||
def test_failed_compile_or_link_does_not_publish_a_model_or_break_old_cache(self):
|
||||
first = self._build()
|
||||
before = {path.name for path in (self.cache / "models").iterdir() if len(path.name) == 64}
|
||||
for source in (
|
||||
self.program.source + "this is invalid C;\n",
|
||||
self.program.source + "int unresolved(void); int force_link_failure(void) { return unresolved(); }\n",
|
||||
):
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._build(replace(self.program, source=source))
|
||||
published = {path.name for path in (self.cache / "models").iterdir() if len(path.name) == 64}
|
||||
self.assertEqual(published, before)
|
||||
self.assertTrue(self._build().cache_hit)
|
||||
self.assertEqual(self._output(first), 9)
|
||||
|
||||
def test_concurrent_cold_builds_publish_complete_models_and_share_objects(self):
|
||||
gate = threading.Barrier(2)
|
||||
|
||||
def build_together(program):
|
||||
gate.wait(timeout=10)
|
||||
return self._build(program)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(build_together, self.program) for _ in range(2)]
|
||||
first, second = [future.result(timeout=60) for future in futures]
|
||||
self.assertEqual(first.executable, second.executable)
|
||||
self.assertEqual(self._output(first), 9)
|
||||
self.assertEqual(self._output(second), 9)
|
||||
self.assertTrue(self._build().cache_hit)
|
||||
gate = threading.Barrier(2)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(build_together, self._parameter_program(value)) for value in (2, 3)]
|
||||
changed = [future.result(timeout=60) for future in futures]
|
||||
self.assertEqual([self._output(item) for item in changed], [10, 11])
|
||||
self.assertNotEqual(changed[0].manifest["buildKey"], changed[1].manifest["buildKey"])
|
||||
for path in self._objects():
|
||||
self.assertTrue((path / "unit.o").is_file())
|
||||
for item in (first, *changed):
|
||||
manifest = json.loads((item.executable.parent / "manifest.json").read_text())
|
||||
self.assertEqual(manifest["buildKey"], item.manifest["buildKey"])
|
||||
self.assertTrue(all((item.executable.parent / name).is_file() for name in manifest["artifacts"]))
|
||||
|
||||
|
||||
def test_generated_sources_keep_exact_lf_under_windows_default_translation(self):
|
||||
write_text = Path.write_text
|
||||
|
||||
def windows_write_text(path, data, encoding=None, errors=None, newline=None):
|
||||
# Python text output on Windows expands LF when newline is omitted.
|
||||
# Emulate that default on Linux while honoring explicit newline="\n".
|
||||
if newline is None:
|
||||
data = data.replace("\n", "\r\n")
|
||||
return write_text(path, data, encoding=encoding, errors=errors, newline="\n")
|
||||
|
||||
with patch.object(Path, "write_text", windows_write_text):
|
||||
probe = self._write("platform-newline-probe.txt", "first\nsecond\n")
|
||||
self.assertEqual(probe.read_bytes(), b"first\r\nsecond\r\n")
|
||||
first = self._build()
|
||||
self.assertEqual((first.executable.parent / "model.c").read_bytes(), self.program.source.encode())
|
||||
self.assertEqual((first.executable.parent / "model.h").read_bytes(), self.program.header.encode())
|
||||
self.assertEqual(self._output(first), 9)
|
||||
self.commands.clear()
|
||||
second = self._build()
|
||||
self.assertTrue(second.cache_hit)
|
||||
self.assertEqual(second.manifest["buildKey"], first.manifest["buildKey"])
|
||||
self.assertEqual(self._output(second), 9)
|
||||
self.assertEqual(self._compiled_count(), 0)
|
||||
self.assertEqual(self._linked_count(), 0)
|
||||
|
||||
def test_capacity_sweep_protects_build_leases_and_close_allows_eviction(self):
|
||||
older = self._build()
|
||||
newer = self._build(self._parameter_program(2))
|
||||
try:
|
||||
report = prune_cache(self.cache, model_limit_bytes=0)
|
||||
self.assertGreaterEqual(report["models"]["skippedInUse"], 2)
|
||||
self.assertTrue(older.executable.is_file())
|
||||
self.assertTrue(newer.executable.is_file())
|
||||
self.assertEqual(self._output(older), 9)
|
||||
# Release the newer lease first under the normal budget, avoiding
|
||||
# any assumption that two model keys occupy distinct lock shards.
|
||||
newer.close()
|
||||
with patch.dict(os.environ, {"SIMULATION_NATIVE_MODEL_CACHE_MB": "0"}):
|
||||
older.close()
|
||||
self.assertFalse(older.executable.exists())
|
||||
self.assertTrue(newer.executable.is_file())
|
||||
self.assertEqual(self._output(newer), 10)
|
||||
report = prune_cache(self.cache, model_limit_bytes=0)
|
||||
self.assertEqual(report["models"]["removedEntries"], 0)
|
||||
self.assertEqual(report["models"]["oversizedEntries"], 1)
|
||||
self.assertGreater(report["models"]["overLimitBytes"], 0)
|
||||
finally:
|
||||
older.close()
|
||||
newer.close()
|
||||
|
||||
def test_executing_native_worker_keeps_its_model_during_capacity_sweep(self):
|
||||
self._write("native/runtime/main.c", r"""#include "model.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
int common_value(void);
|
||||
int main(int argc, char **argv) {
|
||||
const char *output = NULL;
|
||||
const char *release = getenv("NATIVE_CACHE_TEST_RELEASE");
|
||||
for (int i = 1; i + 1 < argc; ++i)
|
||||
if (strcmp(argv[i], "--output") == 0) output = argv[i + 1];
|
||||
if (!output || !release) return 64;
|
||||
fprintf(stderr, "{\"phase\":\"integrating\",\"time\":0,\"nfev\":0,\"acceptedSteps\":0}\n");
|
||||
fflush(stderr);
|
||||
struct timespec pause = {0, 1000000};
|
||||
int released = 0;
|
||||
for (int i = 0; i < 10000; ++i) {
|
||||
FILE *gate = fopen(release, "r");
|
||||
if (gate) { fclose(gate); released = 1; break; }
|
||||
nanosleep(&pause, NULL);
|
||||
}
|
||||
if (!released) return 70;
|
||||
FILE *result = fopen(output, "w");
|
||||
if (!result) return 74;
|
||||
int written = fprintf(result,
|
||||
"{\"simulatedUntil\":0.1,\"nfev\":1,\"acceptedSteps\":1,\"value\":%.17g}",
|
||||
model_value() + common_value() + HEADER_OFFSET);
|
||||
return fclose(result) != 0 || written < 0;
|
||||
}
|
||||
""")
|
||||
active = self._build()
|
||||
other = self._build(self._parameter_program(2))
|
||||
release = self.root / "release-worker"
|
||||
worker_running = threading.Event()
|
||||
try:
|
||||
with patch.dict(os.environ, {"NATIVE_CACHE_TEST_RELEASE": str(release)}):
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(
|
||||
execute_native, active, SolveIVPConfig(t_stop=0.1), 0.1,
|
||||
run_dir=self.root / "worker-result", timeout=10,
|
||||
progress_callback=lambda fraction, phase: worker_running.set(),
|
||||
)
|
||||
try:
|
||||
self.assertTrue(worker_running.wait(timeout=5), "Native worker did not report readiness")
|
||||
self.assertFalse(future.done())
|
||||
report = prune_cache(self.cache, model_limit_bytes=0)
|
||||
self.assertGreaterEqual(report["models"]["skippedInUse"], 2)
|
||||
self.assertTrue(active.executable.is_file())
|
||||
self.assertTrue((active.executable.parent / "manifest.json").is_file())
|
||||
finally:
|
||||
release.write_text("finish\n")
|
||||
payload = future.result(timeout=15)
|
||||
self.assertEqual(payload["value"], 9)
|
||||
self.assertEqual(payload["buildKey"], active.manifest["buildKey"])
|
||||
other.close()
|
||||
with patch.dict(os.environ, {"SIMULATION_NATIVE_MODEL_CACHE_MB": "0"}):
|
||||
active.close()
|
||||
self.assertFalse(active.executable.exists())
|
||||
self.assertTrue(other.executable.is_file())
|
||||
finally:
|
||||
release.write_text("finish\n")
|
||||
active.close()
|
||||
other.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Use/eviction races and byte budgets for the immutable native build cache."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.simulation.native_codegen import cache_storage as storage
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def key(number: int) -> str:
|
||||
# Distinct first words make shard collisions explicit in the tests.
|
||||
return f"{number:08x}" + "0" * 56
|
||||
|
||||
|
||||
class NativeCacheStorageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="native-cache-storage-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.folder = Path(self.temporary.name)
|
||||
self.cache = self.folder / "cache"
|
||||
|
||||
def entry(self, number: int, size: int, age: int, kind: str = "models") -> Path:
|
||||
path = self.cache / kind / key(number)
|
||||
path.mkdir(parents=True)
|
||||
(path / "artifact").write_bytes(b"x" * size)
|
||||
os.utime(path, ns=(1_000_000_000 + age, 1_000_000_000 + age))
|
||||
return path
|
||||
|
||||
def lease(self, number: int, **kwargs):
|
||||
lease = storage.acquire_cache_lease(self.cache, "models", key(number), **kwargs)
|
||||
if lease is not None:
|
||||
self.addCleanup(lease.close)
|
||||
return lease
|
||||
|
||||
def test_overlapping_shared_leases_and_nonblocking_exclusion(self):
|
||||
first = self.lease(0)
|
||||
second = self.lease(0, blocking=False)
|
||||
self.assertIsNotNone(second)
|
||||
self.assertIsNone(self.lease(0, exclusive=True, blocking=False))
|
||||
first.close()
|
||||
self.assertTrue(first.closed)
|
||||
self.assertIsNone(self.lease(0, exclusive=True, blocking=False))
|
||||
second.close()
|
||||
writer = self.lease(0, exclusive=True, blocking=False)
|
||||
self.assertIsNotNone(writer)
|
||||
self.assertIsNone(self.lease(0, blocking=False))
|
||||
writer.close()
|
||||
writer.close() # Explicit close and cleanup are idempotent.
|
||||
with self.assertRaises(RuntimeError):
|
||||
with writer:
|
||||
pass
|
||||
|
||||
def test_separate_process_sees_shared_and_exclusive_lock_contract(self):
|
||||
self.lease(1)
|
||||
script = r'''
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
from app.simulation.native_codegen.cache_storage import acquire_cache_lease
|
||||
cache, key = Path(sys.argv[1]), sys.argv[2]
|
||||
shared = acquire_cache_lease(cache, 'models', key, blocking=False)
|
||||
exclusive = acquire_cache_lease(cache, 'models', key, exclusive=True, blocking=False)
|
||||
print(json.dumps({'shared': shared is not None, 'exclusive': exclusive is not None}))
|
||||
if shared: shared.close()
|
||||
if exclusive: exclusive.close()
|
||||
'''
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script, str(self.cache), key(1)], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=15, check=True,
|
||||
)
|
||||
self.assertEqual(json.loads(result.stdout), {"shared": True, "exclusive": False})
|
||||
|
||||
def test_blocking_process_proceeds_when_last_reader_releases(self):
|
||||
lease = self.lease(2)
|
||||
script = r'''
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from app.simulation.native_codegen.cache_storage import acquire_cache_lease
|
||||
print('waiting', flush=True)
|
||||
with acquire_cache_lease(Path(sys.argv[1]), 'models', sys.argv[2], exclusive=True):
|
||||
print('acquired', flush=True)
|
||||
'''
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-c", script, str(self.cache), key(2)], cwd=ROOT,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
self.addCleanup(lambda: process.kill() if process.poll() is None else None)
|
||||
self.assertEqual(process.stdout.readline().strip(), "waiting")
|
||||
self.assertIsNone(process.poll())
|
||||
lease.close()
|
||||
stdout, stderr = process.communicate(timeout=15)
|
||||
self.assertEqual(process.returncode, 0, stderr)
|
||||
self.assertEqual(stdout.strip(), "acquired")
|
||||
|
||||
def test_finalizer_releases_abandoned_lease(self):
|
||||
lease = storage.acquire_cache_lease(self.cache, "models", key(3))
|
||||
self.assertIsNone(self.lease(3, exclusive=True, blocking=False))
|
||||
del lease
|
||||
gc.collect()
|
||||
self.assertIsNotNone(self.lease(3, exclusive=True, blocking=False))
|
||||
|
||||
def test_lru_removes_oldest_until_separate_budgets_are_met(self):
|
||||
oldest = self.entry(0, 60, 1)
|
||||
middle = self.entry(1, 60, 2)
|
||||
newest = self.entry(2, 60, 3)
|
||||
object_old = self.entry(3, 40, 1, "objects")
|
||||
object_new = self.entry(4, 40, 2, "objects")
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=120, object_limit_bytes=40)
|
||||
self.assertFalse(oldest.exists())
|
||||
self.assertTrue(middle.exists() and newest.exists())
|
||||
self.assertFalse(object_old.exists())
|
||||
self.assertTrue(object_new.exists())
|
||||
self.assertEqual(report["models"]["afterBytes"], 120)
|
||||
self.assertEqual(report["objects"]["afterBytes"], 40)
|
||||
self.assertEqual(report["models"]["removedEntries"], 1)
|
||||
self.assertEqual(report["models"]["errors"], [])
|
||||
|
||||
def test_held_entry_survives_and_recent_touch_changes_lru_order(self):
|
||||
first = self.entry(0, 60, 1)
|
||||
second = self.entry(1, 60, 2)
|
||||
third = self.entry(2, 60, 3)
|
||||
held = self.lease(0)
|
||||
with self.lease(1):
|
||||
storage.touch_cache_entry(self.cache, "models", key(1))
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=60)
|
||||
self.assertTrue(first.exists())
|
||||
self.assertFalse(second.exists() or third.exists())
|
||||
self.assertEqual(report["models"]["skippedInUse"], 1)
|
||||
held.close()
|
||||
|
||||
# A separate round verifies touch order without an active-entry override.
|
||||
old = self.entry(3, 60, 3)
|
||||
with self.lease(0):
|
||||
storage.touch_cache_entry(self.cache, "models", key(0))
|
||||
storage.prune_cache(self.cache, model_limit_bytes=60)
|
||||
self.assertTrue(first.exists())
|
||||
self.assertFalse(old.exists())
|
||||
|
||||
def test_in_use_overage_is_reported_and_later_sweep_recovers(self):
|
||||
first = self.entry(0, 60, 1)
|
||||
second = self.entry(1, 60, 2)
|
||||
a, b = self.lease(0), self.lease(1)
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=60)
|
||||
self.assertTrue(first.exists() and second.exists())
|
||||
self.assertEqual(report["models"]["overLimitBytes"], 60)
|
||||
self.assertEqual(report["models"]["skippedInUse"], 2)
|
||||
a.close()
|
||||
b.close()
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=60)
|
||||
self.assertFalse(first.exists())
|
||||
self.assertTrue(second.exists())
|
||||
self.assertEqual(report["models"]["overLimitBytes"], 0)
|
||||
|
||||
def test_only_final_oversized_entry_is_retained(self):
|
||||
old = self.entry(0, 200, 1)
|
||||
new = self.entry(1, 200, 2)
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=100)
|
||||
self.assertFalse(old.exists())
|
||||
self.assertTrue(new.exists())
|
||||
self.assertEqual(report["models"]["oversizedEntries"], 1)
|
||||
self.assertEqual(report["models"]["overLimitBytes"], 100)
|
||||
|
||||
def test_lock_shards_are_bounded_and_collisions_only_defer_eviction(self):
|
||||
for number in range(512):
|
||||
with storage.acquire_cache_lease(self.cache, "models", key(number)):
|
||||
pass
|
||||
self.assertEqual(len(list((self.cache / ".locks").iterdir())), storage.LOCK_SHARDS)
|
||||
self.assertTrue(all(path.stat().st_size == 0 for path in (self.cache / ".locks").iterdir()))
|
||||
colliding = self.entry(128, 60, 1)
|
||||
other = self.entry(1, 60, 2)
|
||||
self.lease(0)
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=60)
|
||||
self.assertTrue(colliding.exists())
|
||||
self.assertFalse(other.exists())
|
||||
self.assertEqual(report["models"]["skippedInUse"], 1)
|
||||
|
||||
def test_legacy_unknown_and_linked_entries_are_not_followed_or_removed(self):
|
||||
old = self.entry(0, 60, 1)
|
||||
self.entry(1, 60, 2)
|
||||
legacy = self.cache / key(50)
|
||||
legacy.mkdir()
|
||||
(legacy / "model").write_text("old cache")
|
||||
unknown = self.cache / "models" / "notes.txt"
|
||||
unknown.write_text("user notes")
|
||||
outside = self.folder / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "important").write_text("keep")
|
||||
direct = self.cache / "models" / key(3)
|
||||
nested = self.cache / "models" / key(4)
|
||||
nested.mkdir()
|
||||
try:
|
||||
direct.symlink_to(outside, target_is_directory=True)
|
||||
(nested / "outside").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"Symlinks unavailable: {exc}")
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=60)
|
||||
self.assertFalse(old.exists())
|
||||
self.assertEqual((legacy / "model").read_text(), "old cache")
|
||||
self.assertEqual(unknown.read_text(), "user notes")
|
||||
self.assertEqual((outside / "important").read_text(), "keep")
|
||||
self.assertTrue(direct.is_symlink() and (nested / "outside").is_symlink())
|
||||
self.assertEqual(report["models"]["skippedUnmanaged"], 3)
|
||||
|
||||
def test_invalid_keys_and_symlink_lock_files_are_rejected(self):
|
||||
for kind, digest in (("../models", key(0)), ("models", "../outside"),
|
||||
("models", "A" * 64), ("models", key(0) + "\n")):
|
||||
with self.subTest(kind=kind, key=digest):
|
||||
with self.assertRaises(ValueError):
|
||||
storage.acquire_cache_lease(self.cache, kind, digest)
|
||||
lock_dir = self.cache / ".locks"
|
||||
lock_dir.mkdir(parents=True)
|
||||
target = self.folder / "outside-lock"
|
||||
target.write_text("untouched")
|
||||
try:
|
||||
(lock_dir / "models-000.lock").symlink_to(target)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"Symlinks unavailable: {exc}")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.lease(0)
|
||||
self.assertEqual(target.read_text(), "untouched")
|
||||
|
||||
def test_prune_serializes_sweeps_without_blocking_readers(self):
|
||||
old = self.entry(0, 200, 1)
|
||||
new = self.entry(1, 200, 2)
|
||||
import shutil
|
||||
original = shutil.rmtree
|
||||
concurrent = []
|
||||
|
||||
def removing(path, *args, **kwargs):
|
||||
concurrent.append(storage.prune_cache(self.cache, model_limit_bytes=100))
|
||||
# Reader leases do not contend with the global sweep lock.
|
||||
with self.lease(2, blocking=False):
|
||||
pass
|
||||
return original(path, *args, **kwargs)
|
||||
|
||||
with patch.object(storage.shutil, "rmtree", side_effect=removing):
|
||||
storage.prune_cache(self.cache, model_limit_bytes=100)
|
||||
self.assertFalse(old.exists())
|
||||
self.assertTrue(new.exists())
|
||||
self.assertEqual(len(concurrent), 1)
|
||||
self.assertTrue(concurrent[0]["models"]["skippedConcurrentSweep"])
|
||||
|
||||
def stage(self, number: int, size: int, namespace: str = "root") -> Path:
|
||||
parent = self.cache if namespace == "root" else self.cache / namespace
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix=f"building-{key(number)}-", dir=parent))
|
||||
(stage / "unfinished.o").write_bytes(b"x" * size)
|
||||
return stage
|
||||
|
||||
def test_orphan_stages_are_removed_in_all_three_managed_locations(self):
|
||||
root = self.stage(10, 30)
|
||||
model = self.stage(11, 40, "models")
|
||||
obj = self.stage(12, 50, "objects")
|
||||
legacy = self.cache / "building-old12345"
|
||||
legacy.mkdir()
|
||||
unknown = self.cache / "models" / f"building-{key(13)}-short"
|
||||
unknown.mkdir()
|
||||
# Similar names inside an unrelated entry are outside the sweep scope.
|
||||
nested = self.cache / key(14) / f"building-{key(15)}-abcdefgh"
|
||||
nested.mkdir(parents=True)
|
||||
report = storage.prune_cache(self.cache)
|
||||
self.assertFalse(root.exists() or model.exists() or obj.exists())
|
||||
self.assertTrue(legacy.exists() and unknown.exists() and nested.exists())
|
||||
self.assertEqual(report["orphanStages"]["orphanStagesRemoved"], 3)
|
||||
self.assertEqual(report["orphanStages"]["orphanStagesBytes"], 120)
|
||||
self.assertEqual(report["orphanStages"]["skippedUnmanaged"], 2)
|
||||
self.assertEqual(report["orphanStages"]["errors"], [])
|
||||
|
||||
def test_active_stages_use_their_correct_namespace_and_survive(self):
|
||||
model_lease = self.lease(10)
|
||||
object_lease = storage.acquire_cache_lease(self.cache, "objects", key(11))
|
||||
self.addCleanup(object_lease.close)
|
||||
root_active = self.stage(10, 30)
|
||||
model_active = self.stage(10, 40, "models")
|
||||
object_active = self.stage(11, 50, "objects")
|
||||
collision = self.stage(10 + storage.LOCK_SHARDS, 20)
|
||||
# Holding models/key(10) does not protect objects/key(10), or vice versa.
|
||||
object_orphan = self.stage(10, 60, "objects")
|
||||
model_orphan = self.stage(11, 70)
|
||||
report = storage.prune_cache(self.cache)["orphanStages"]
|
||||
self.assertTrue(all(path.exists() for path in (
|
||||
root_active, model_active, object_active, collision,
|
||||
)))
|
||||
self.assertFalse(object_orphan.exists() or model_orphan.exists())
|
||||
self.assertEqual(report["skippedInUse"], 4)
|
||||
self.assertEqual(report["orphanStagesRemoved"], 2)
|
||||
self.assertEqual(report["orphanStagesBytes"], 130)
|
||||
model_lease.close()
|
||||
object_lease.close()
|
||||
report = storage.prune_cache(self.cache)["orphanStages"]
|
||||
self.assertEqual(report["orphanStagesRemoved"], 4)
|
||||
self.assertEqual(report["orphanStagesBytes"], 140)
|
||||
|
||||
def test_killed_builder_releases_stage_protection_for_next_sweep(self):
|
||||
script = r"""
|
||||
import sys, tempfile, time
|
||||
from pathlib import Path
|
||||
from app.simulation.native_codegen.cache_storage import acquire_cache_lease
|
||||
cache, key = Path(sys.argv[1]), sys.argv[2]
|
||||
lease = acquire_cache_lease(cache, 'models', key)
|
||||
stage = Path(tempfile.mkdtemp(prefix='building-' + key + '-', dir=cache))
|
||||
(stage / 'unfinished.o').write_bytes(b'x' * 37)
|
||||
print(stage.name, flush=True)
|
||||
time.sleep(60)
|
||||
"""
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-c", script, str(self.cache), key(16)], cwd=ROOT,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
self.addCleanup(lambda: process.kill() if process.poll() is None else None)
|
||||
stage = self.cache / process.stdout.readline().strip()
|
||||
self.assertTrue(stage.is_dir())
|
||||
report = storage.prune_cache(self.cache)["orphanStages"]
|
||||
self.assertTrue(stage.exists())
|
||||
self.assertEqual(report["skippedInUse"], 1)
|
||||
process.kill()
|
||||
process.communicate(timeout=15)
|
||||
report = storage.prune_cache(self.cache)["orphanStages"]
|
||||
self.assertFalse(stage.exists())
|
||||
self.assertEqual(report["orphanStagesRemoved"], 1)
|
||||
self.assertEqual(report["orphanStagesBytes"], 37)
|
||||
|
||||
def test_orphan_stage_symlinks_and_symlink_namespaces_are_not_followed(self):
|
||||
self.cache.mkdir()
|
||||
outside = self.folder / "outside-stage"
|
||||
outside.mkdir()
|
||||
artifact = outside / "important"
|
||||
artifact.write_text("keep")
|
||||
direct = self.cache / f"building-{key(20)}-abcdefgh"
|
||||
nested = self.stage(21, 10)
|
||||
object_namespace = self.cache / "objects"
|
||||
try:
|
||||
direct.symlink_to(outside, target_is_directory=True)
|
||||
(nested / "link").symlink_to(artifact)
|
||||
object_namespace.symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"Symlinks unavailable: {exc}")
|
||||
# A matching name behind a namespace link is never inspected or removed.
|
||||
outside_stage = outside / f"building-{key(22)}-abcdefgh"
|
||||
outside_stage.mkdir()
|
||||
report = storage.prune_cache(self.cache)["orphanStages"]
|
||||
self.assertEqual(report["orphanStagesRemoved"], 0)
|
||||
self.assertEqual(report["skippedUnmanaged"], 2)
|
||||
self.assertTrue(direct.is_symlink() and nested.exists() and outside_stage.exists())
|
||||
self.assertEqual(artifact.read_text(), "keep")
|
||||
|
||||
def test_orphan_cleanup_failure_is_reported_without_removing_other_data(self):
|
||||
stage = self.stage(24, 80)
|
||||
with patch.object(storage.shutil, "rmtree", side_effect=PermissionError("busy stage")):
|
||||
report = storage.prune_cache(self.cache)["orphanStages"]
|
||||
self.assertTrue(stage.exists())
|
||||
self.assertEqual(report["orphanStagesRemoved"], 0)
|
||||
self.assertEqual(report["orphanStagesBytes"], 0)
|
||||
self.assertEqual(len(report["errors"]), 1)
|
||||
self.assertIn("busy stage", report["errors"][0])
|
||||
|
||||
def test_filesystem_cleanup_errors_do_not_fail_simulation(self):
|
||||
self.entry(0, 60, 1)
|
||||
self.entry(1, 60, 2)
|
||||
with patch.object(storage.shutil, "rmtree", side_effect=PermissionError("busy")):
|
||||
report = storage.prune_cache(self.cache, model_limit_bytes=60)
|
||||
self.assertEqual(report["models"]["overLimitBytes"], 60)
|
||||
self.assertTrue(report["models"]["errors"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import unittest
|
||||
|
||||
from app.simulation.native_codegen.build import toolchain
|
||||
from tests.native_kernel_source import native_kernel_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -16,7 +17,7 @@ class NativePipeCacheTests(unittest.TestCase):
|
||||
compiler = toolchain()[0]
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
self.skipTest(f"Native toolchain unavailable: {exc}")
|
||||
source = (ROOT / "native/components/kernels.c").read_text()
|
||||
source = native_kernel_source()
|
||||
signature = "double d, double length, double rr, int kind) {"
|
||||
self.assertEqual(source.count(signature), 1)
|
||||
source = "static int pipe_calls;\n" + source.replace(
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.native_codegen.build import build_native, toolchain
|
||||
from app.simulation.native_codegen.extended import compile_extended_program
|
||||
from tests.test_native_catalog import Circuit
|
||||
from tests.native_kernel_source import native_kernel_source
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -109,7 +110,7 @@ int main(void) {
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='native-pipe-root-') as tmp:
|
||||
directory=Path(tmp);source=directory/'check.c';exe=directory/'check.exe'
|
||||
source.write_text((ROOT/'native/components/kernels.c').read_text()+harness)
|
||||
source.write_text(native_kernel_source()+harness)
|
||||
build=subprocess.run([self.compiler,'-std=c11','-O3','-Wall','-Wextra','-Werror',
|
||||
'-ffp-contract=off','-fno-fast-math','-static-libgcc','-I',str(ROOT/'native/include'),
|
||||
str(source),'-lm','-o',str(exe)],capture_output=True,text=True,timeout=60)
|
||||
|
||||
@@ -8,6 +8,7 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from tests.native_kernel_source import native_kernel_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -67,7 +68,7 @@ class NativePipeSolverTests(unittest.TestCase):
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix='native-pipe-solver-')
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.compiler = command
|
||||
source = (ROOT / 'native/components/kernels.c').read_text()
|
||||
source = native_kernel_source()
|
||||
cls.library = cls.build_library(source, 'ordinary')
|
||||
|
||||
# Fault injection only in the temporary test translation unit. The
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import unittest
|
||||
|
||||
from app.simulation.native_codegen.build import toolchain
|
||||
from tests.native_kernel_source import native_kernel_source
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -15,7 +16,7 @@ class NativePropertyTests(unittest.TestCase):
|
||||
compiler = toolchain()[0]
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
self.skipTest(f"Native toolchain unavailable: {exc}")
|
||||
source = (ROOT / 'native/components/kernels.c').read_text()
|
||||
source = native_kernel_source()
|
||||
for signature, counter in (
|
||||
('static double temperature_ph(double p,double h) {', 'ph_calls'),
|
||||
('static double z_factor(double p,double T) {', 'z_calls'),
|
||||
|
||||
Reference in new issue
Block a user