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

+5 -1
View File
@@ -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()
+79 -6
View File
@@ -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)}
+18 -3
View File
@@ -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
+37 -14
View File
@@ -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()
+25
View File
@@ -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"))
+528
View File
@@ -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()
+376
View File
@@ -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()
+2 -1
View File
@@ -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(
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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'),