120 lines
5.9 KiB
Python
120 lines
5.9 KiB
Python
"""Reproducible native builds and a checked, model-specific executable cache."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from hashlib import sha256
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
from .compiler import NativeProgram
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
NATIVE = ROOT / "native"
|
|
CACHE = ROOT / "app/data/native-builds"
|
|
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NativeBuild:
|
|
executable: Path
|
|
manifest: dict
|
|
cache_hit: bool
|
|
seconds: float
|
|
|
|
|
|
def _hash(path: Path) -> str:
|
|
return sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def toolchain() -> tuple[str, Path, str]:
|
|
compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
|
|
if not compiler:
|
|
raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.")
|
|
base = Path(os.environ.get("SUNDIALS_ROOT", str(Path(sys.base_prefix) / "Library")))
|
|
if not (base / "include/cvode/cvode.h").is_file():
|
|
raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.")
|
|
version = subprocess.run([compiler, "--version"], capture_output=True, text=True, check=True, timeout=15).stdout.splitlines()[0]
|
|
return compiler, base, version
|
|
|
|
|
|
def build_native(program: NativeProgram, *, cache_dir: Path | None = None) -> NativeBuild:
|
|
start = time.perf_counter()
|
|
compiler, sundials, compiler_version = toolchain()
|
|
runtime = sorted(NATIVE.rglob("*.c")) + sorted((NATIVE / "include").glob("*.h"))
|
|
flags = ["-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math"]
|
|
if os.name == "nt":
|
|
flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"]
|
|
libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES]
|
|
dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES]
|
|
vc_runtime = sundials / "bin/vcruntime140.dll"
|
|
if vc_runtime.is_file():
|
|
dlls.append(vc_runtime)
|
|
else:
|
|
raise RuntimeError("Native v1 build packaging currently supports Windows x64; Linux packaging is pending.")
|
|
sources = {str(p.relative_to(ROOT)): _hash(p) for p in runtime}
|
|
sources["native/THIRD_PARTY_NOTICES.txt"] = _hash(NATIVE / "THIRD_PARTY_NOTICES.txt")
|
|
dependencies = {str(p.name): _hash(p) for p in libraries + dlls}
|
|
# Header hashes include precision/index ABI settings as well as library APIs.
|
|
for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol"):
|
|
for path in sorted((sundials / "include" / directory).glob("*.h")):
|
|
dependencies[f"{directory}/{path.name}"] = _hash(path)
|
|
identity = dict(source=program.source, header=program.header, contract=program.manifest(), sources=sources,
|
|
dependencies=dependencies, compiler=compiler_version, flags=flags,
|
|
platform=sys.platform, abi=1)
|
|
signature = sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
|
cache = (cache_dir or CACHE).resolve()
|
|
target = cache / signature
|
|
manifest_path = target / "manifest.json"
|
|
if manifest_path.is_file():
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
if all((target / name).is_file() and _hash(target / name) == digest
|
|
for name, digest in manifest["artifacts"].items()):
|
|
return NativeBuild(target / "model.exe", manifest, True, time.perf_counter()-start)
|
|
raise RuntimeError(f"Native cache integrity check failed: {target}")
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
stage = Path(tempfile.mkdtemp(prefix="building-", dir=cache))
|
|
(stage / "model.c").write_text(program.source, encoding="utf-8")
|
|
(stage / "model.h").write_text(program.header, encoding="utf-8")
|
|
command = [compiler, *flags, "-I", str(stage), "-I", str(NATIVE / "include"),
|
|
"-I", str(sundials / "include"), str(stage / "model.c"),
|
|
*[str(p) for p in runtime if p.suffix == ".c"],
|
|
*map(str, libraries), "-lm", "-o", str(stage / "model.exe")]
|
|
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
|
(stage / "build.log").write_text(result.stdout + result.stderr, encoding="utf-8")
|
|
if result.returncode:
|
|
raise RuntimeError(f"Native compilation failed; see {stage / 'build.log'}: {result.stderr[-3000:]}")
|
|
for library in dlls:
|
|
shutil.copy2(library, stage / library.name)
|
|
shutil.copy2(NATIVE / "THIRD_PARTY_NOTICES.txt", stage / "THIRD_PARTY_NOTICES.txt")
|
|
manifest = {
|
|
**program.manifest(), "buildKey": signature, "compiler": compiler_version,
|
|
"compilerFlags": flags, "sourceHashes": sources, "dependencyHashes": dependencies,
|
|
"artifacts": {p.name: _hash(p) for p in stage.iterdir() if p.name != "build.log"},
|
|
}
|
|
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
try:
|
|
stage.rename(target)
|
|
except OSError:
|
|
# A concurrent compiler may have published the identical cache first.
|
|
if not manifest_path.is_file():
|
|
raise
|
|
published = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
# PE linker timestamps can differ between concurrent equivalent builds.
|
|
# Validate the winning build against its own hashes and our identity.
|
|
if published.get("buildKey") != signature or not all(
|
|
(target / name).is_file() and _hash(target / name) == digest
|
|
for name, digest in published["artifacts"].items()
|
|
):
|
|
raise RuntimeError("Concurrent native build did not produce the expected artifacts.")
|
|
manifest = published
|
|
if stage.resolve().parent != cache:
|
|
raise RuntimeError("Unexpected native build staging directory.")
|
|
shutil.rmtree(stage)
|
|
return NativeBuild(target / "model.exe", manifest, False, time.perf_counter()-start)
|