C内核按库功能拆解,编译结果缓存区构建,编译过程与已有缓存结果对照功能实现
This commit is contained in:
1 parent
aa4951b14e
commit
151e6e4b97
30 files changed
+4856
-604
No files matched your search
+7
-2
@@ -69,6 +69,11 @@ SimulationProgressEmitter = Callable[
|
||||
]
|
||||
SIMULATION_PHASE_MESSAGES = {
|
||||
"initializing": "正在初始化状态与方程",
|
||||
"native-generation": "正在生成原生模型代码",
|
||||
"native-cache-check": "正在检查已编译模型缓存",
|
||||
"native-cache-hit": "已复用模型缓存,正在启动求解器",
|
||||
"native-compilation": "正在编译模型与缺失的原生模块",
|
||||
"native-linking": "正在链接模型可执行文件",
|
||||
"integrating": "正在进行时间积分与压力流量求解",
|
||||
"postprocessing": "正在整理采样结果",
|
||||
"cancelled": "正在整理已终止仿真的部分结果",
|
||||
@@ -746,9 +751,9 @@ def _run_system_xml_simulation_profiled(
|
||||
emit(0, "validation", "正在校验 System XML")
|
||||
report = validate_system_xml_document(xml_bytes)
|
||||
document = _validated_xml_document_or_422(report)
|
||||
emit(0, "compilation", "正在编译组件与连接关系")
|
||||
emit(0, "compilation", "正在解析组件与连接关系")
|
||||
network = _compile_xml_document_or_422(document)
|
||||
emit(0, "initialization", "模型编译完成,正在准备求解器")
|
||||
emit(0, "initialization", "连接关系已解析,正在准备原生模型")
|
||||
|
||||
def report_system_progress(progress: float, phase: str) -> None:
|
||||
bounded_progress = min(1.0, max(0.0, progress))
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
"""Reproducible native builds and a checked, model-specific executable cache."""
|
||||
"""On-demand native modules, checked object reuse and bounded executable caches."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from .compiler import NativeProgram
|
||||
from .modules import component_modules
|
||||
from .cache_storage import acquire_cache_lease, touch_cache_entry, prune_cache
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
NATIVE = ROOT / "native"
|
||||
CACHE = ROOT / "app/data/native-builds"
|
||||
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
|
||||
COMPILER_FLAGS = ("-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math")
|
||||
RUNTIME_SOURCES = ("runtime/main.c", "runtime/common.c", "runtime/rk45.c",
|
||||
"runtime/cvode_solver.c", "runtime/json_numbers.c", "encoding/ryu/d2s.c")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -26,12 +34,115 @@ class NativeBuild:
|
||||
manifest: dict
|
||||
cache_hit: bool
|
||||
seconds: float
|
||||
details: dict = field(default_factory=dict)
|
||||
_lease: Any = field(default=None, repr=False, compare=False)
|
||||
_cache: Path | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release this caller's executable pin after its last use."""
|
||||
if self._lease is not None:
|
||||
self._lease.close()
|
||||
if self._cache is not None:
|
||||
_prune(self._cache)
|
||||
|
||||
|
||||
def _hash(path: Path) -> str:
|
||||
return sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _key(value: object) -> str:
|
||||
return sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _runtime_sources(program: NativeProgram) -> list[Path]:
|
||||
return [NATIVE / name for name in RUNTIME_SOURCES] + [
|
||||
NATIVE / "components/modules" / f"{name}.c"
|
||||
for name in component_modules(program.source + "\n" + program.header)
|
||||
]
|
||||
|
||||
|
||||
def _budget(name: str, default: int) -> int:
|
||||
value = os.environ.get(name, str(default))
|
||||
if not re.fullmatch(r"[0-9]+", value):
|
||||
raise ValueError(f"{name} must be a nonnegative integer in MiB.")
|
||||
return int(value) * 1024**2
|
||||
|
||||
|
||||
def _prune(cache: Path) -> dict:
|
||||
return prune_cache(cache,
|
||||
model_limit_bytes=_budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256),
|
||||
object_limit_bytes=_budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128))
|
||||
|
||||
|
||||
def _checked_manifest(target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict | None:
|
||||
if not target.exists() and not target.is_symlink():
|
||||
return None
|
||||
message = f"Native cache integrity check failed: {target}"
|
||||
if target.is_symlink() or not target.is_dir():
|
||||
raise RuntimeError(message)
|
||||
try:
|
||||
path = target / "manifest.json"
|
||||
if path.is_symlink():
|
||||
raise ValueError("linked manifest")
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(manifest, dict) or manifest.get(key_name) != key or manifest.get("cacheVersion") != version:
|
||||
raise ValueError("identity mismatch")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, dict) or set(artifacts) != required:
|
||||
raise ValueError("incomplete artifacts")
|
||||
if key_name == "objectKey":
|
||||
recipe = {name: manifest[name] for name in ("cacheVersion", "sourceName", "preprocessedSha256", "compiler")}
|
||||
if _key(recipe) != key:
|
||||
raise ValueError("object identity mismatch")
|
||||
else:
|
||||
recipe = manifest.get("buildIdentity")
|
||||
if not isinstance(recipe, dict) or _key(recipe) != key or manifest.get("objectKeys") != recipe["objectKeys"]:
|
||||
raise ValueError("model identity mismatch")
|
||||
if artifacts["model.c"] != recipe["sourceSha256"] or artifacts["model.h"] != recipe["headerSha256"]:
|
||||
raise ValueError("model source identity mismatch")
|
||||
if _key({name: manifest[name] for name in recipe["contractKeys"]}) != recipe["contractSha256"]:
|
||||
raise ValueError("model contract mismatch")
|
||||
if artifacts["THIRD_PARTY_NOTICES.txt"] != recipe["notice"]:
|
||||
raise ValueError("license artifact mismatch")
|
||||
for name in required:
|
||||
if name.lower().endswith(".dll") and artifacts[name] != recipe["dependencies"][name]:
|
||||
raise ValueError("runtime dependency artifact mismatch")
|
||||
for name, digest in artifacts.items():
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in (".", ".."):
|
||||
raise ValueError("invalid artifact name")
|
||||
path = target / name
|
||||
if path.is_symlink() or not path.is_file() or not isinstance(digest, str) or _hash(path) != digest:
|
||||
raise ValueError("artifact mismatch")
|
||||
return manifest
|
||||
except (OSError, ValueError, TypeError, KeyError) as exc:
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
|
||||
def _publish(stage: Path, target: Path, key_name: str, key: str, version: int, required: set[str]) -> dict:
|
||||
try:
|
||||
stage.rename(target)
|
||||
except OSError:
|
||||
# Another reader/build may publish the same content while we compile.
|
||||
# Its artifacts need not be byte-identical (e.g. PE timestamps), but
|
||||
# must satisfy the same complete identity and its own recorded hashes.
|
||||
winner = _checked_manifest(target, key_name, key, version, required)
|
||||
if winner is None:
|
||||
raise
|
||||
shutil.rmtree(stage)
|
||||
return winner
|
||||
manifest = _checked_manifest(target, key_name, key, version, required)
|
||||
assert manifest is not None
|
||||
return manifest
|
||||
|
||||
|
||||
def _command(command: list[str], *, log: list[str], timeout: float = 120) -> bytes:
|
||||
result = subprocess.run(command, capture_output=True, timeout=timeout)
|
||||
log.append(" ".join(command) + "\n" + result.stderr.decode("utf-8", errors="replace"))
|
||||
if result.returncode:
|
||||
raise RuntimeError("Native compilation failed: " + result.stderr.decode("utf-8", errors="replace")[-3000:])
|
||||
return result.stdout
|
||||
|
||||
|
||||
def toolchain() -> tuple[str, Path, str]:
|
||||
if os.name != "nt" and not sys.platform.startswith("linux"):
|
||||
raise RuntimeError("Native builds support Windows and Linux; this platform is not supported.")
|
||||
@@ -49,90 +160,190 @@ def toolchain() -> tuple[str, Path, str]:
|
||||
return compiler, base, version
|
||||
|
||||
|
||||
def build_native(program: NativeProgram, *, cache_dir: Path | None = None) -> NativeBuild:
|
||||
start = time.perf_counter()
|
||||
def build_native(program: NativeProgram, *, cache_dir: Path | None = None,
|
||||
progress_callback: Callable[[str], None] | None = None) -> NativeBuild:
|
||||
started = time.perf_counter()
|
||||
if progress_callback:
|
||||
progress_callback("native-cache-check")
|
||||
compiler, sundials, compiler_version = toolchain()
|
||||
runtime = sorted(NATIVE.rglob("*.c")) + sorted((NATIVE / "include").rglob("*.h"))
|
||||
flags = ["-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math"]
|
||||
flags = list(COMPILER_FLAGS)
|
||||
executable_name = "model.exe" if os.name == "nt" else "model"
|
||||
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)
|
||||
elif sys.platform.startswith("linux"):
|
||||
flags += ["-D_POSIX_C_SOURCE=200809L"]
|
||||
library_dirs = [sundials / "lib", sundials / "lib64", sundials / "lib/x86_64-linux-gnu"]
|
||||
library_dir = next((path for path in library_dirs if all(
|
||||
(path / f"libsundials_{name}.a").is_file() for name in LIBRARIES)), None)
|
||||
if library_dir is None:
|
||||
raise RuntimeError("SUNDIALS static libraries not found; build with BUILD_STATIC_LIBS=ON and set SUNDIALS_ROOT.")
|
||||
libraries = [library_dir / f"libsundials_{name}.a" for name in LIBRARIES]
|
||||
dlls = []
|
||||
if (sundials / "bin/vcruntime140.dll").is_file():
|
||||
dlls.append(sundials / "bin/vcruntime140.dll")
|
||||
else:
|
||||
raise RuntimeError("Native builds support Windows and Linux; this platform is not supported.")
|
||||
link_libraries = list(map(str, libraries))
|
||||
if sys.platform.startswith("linux"):
|
||||
link_libraries = ["-Wl,--start-group", *link_libraries, "-Wl,--end-group"]
|
||||
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()
|
||||
flags += ["-D_POSIX_C_SOURCE=200809L"]
|
||||
candidates = [sundials / "lib", sundials / "lib64", sundials / "lib/x86_64-linux-gnu"]
|
||||
directory = next((p for p in candidates if all((p / f"libsundials_{name}.a").is_file() for name in LIBRARIES)), None)
|
||||
if directory is None:
|
||||
raise RuntimeError("SUNDIALS static libraries not found; set SUNDIALS_ROOT.")
|
||||
libraries = [directory / f"libsundials_{name}.a" for name in LIBRARIES]
|
||||
dlls = []
|
||||
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 / executable_name, 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"],
|
||||
*link_libraries, "-lm", "-o", str(stage / executable_name)]
|
||||
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")
|
||||
for kind in ("models", "objects"):
|
||||
path = cache / kind
|
||||
if path.is_symlink():
|
||||
raise RuntimeError(f"Native cache directory must not be a symbolic link: {path}")
|
||||
path.mkdir(exist_ok=True)
|
||||
_budget("SIMULATION_NATIVE_MODEL_CACHE_MB", 256)
|
||||
_budget("SIMULATION_NATIVE_OBJECT_CACHE_MB", 128)
|
||||
work_key = sha256(os.urandom(32)).hexdigest()
|
||||
work_lease = acquire_cache_lease(cache, "models", work_key)
|
||||
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 / executable_name, manifest, False, time.perf_counter()-start)
|
||||
stage = Path(tempfile.mkdtemp(prefix=f"building-{work_key}-", dir=cache))
|
||||
except BaseException:
|
||||
work_lease.close()
|
||||
raise
|
||||
logs: list[str] = []
|
||||
model_lease = None
|
||||
try:
|
||||
(stage / "model.c").write_text(program.source, encoding="utf-8", newline="\n")
|
||||
(stage / "model.h").write_text(program.header, encoding="utf-8", newline="\n")
|
||||
compiler_path = Path(shutil.which(compiler) or compiler).resolve()
|
||||
compiler_identity = {"version": compiler_version, "binarySha256": _hash(compiler_path),
|
||||
"target": _command([compiler, "-dumpmachine"], log=logs).decode().strip(),
|
||||
"flags": flags, "platform": sys.platform}
|
||||
runtime = _runtime_sources(program)
|
||||
sources = [stage / "model.c", *runtime]
|
||||
native_headers = {p: _hash(p) for p in (NATIVE / "include").rglob("*.h")}
|
||||
dependency_headers = {p: _hash(p) for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol")
|
||||
for p in (sundials / "include" / directory).glob("*.h")}
|
||||
preprocessing_start = time.perf_counter()
|
||||
# Preprocessed bytes include the actual transitive headers and all
|
||||
# effective macros/ABI switches. Compile precisely these bytes, so a
|
||||
# header edit cannot race the cache identity and the compiler input.
|
||||
def preprocess(source: Path) -> dict:
|
||||
local_log: list[str] = []
|
||||
source_hash = _hash(source)
|
||||
data = _command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={stage}=/generated",
|
||||
"-I", str(stage), "-I", str(NATIVE / "include"), "-I", str(sundials / "include"),
|
||||
str(source)], log=local_log)
|
||||
name = "model.c" if source == stage / "model.c" else "native/" + source.relative_to(NATIVE).as_posix()
|
||||
if _hash(source) != source_hash:
|
||||
raise RuntimeError(f"Native source changed during preprocessing: {source}")
|
||||
digest = sha256(data).hexdigest()
|
||||
identity = {"cacheVersion": 1, "sourceName": name, "preprocessedSha256": digest, "compiler": compiler_identity}
|
||||
return {"sourceName": name, "sourceSha256": source_hash, "data": data,
|
||||
"objectKey": _key(identity), "identity": identity, "log": local_log}
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(sources))) as pool:
|
||||
units = list(pool.map(preprocess, sources))
|
||||
for unit in units:
|
||||
logs.extend(unit.pop("log"))
|
||||
preprocessing_seconds = time.perf_counter() - preprocessing_start
|
||||
if any(_hash(path) != digest for path, digest in {**native_headers, **dependency_headers}.items()):
|
||||
raise RuntimeError("Native headers changed during preprocessing; retry with stable sources.")
|
||||
dependencies = {p.name: _hash(p) for p in libraries + dlls}
|
||||
notice = (NATIVE / "THIRD_PARTY_NOTICES.txt").read_bytes()
|
||||
identity = {"cacheVersion": 2, "sourceSha256": sha256(program.source.encode()).hexdigest(),
|
||||
"headerSha256": sha256(program.header.encode()).hexdigest(),
|
||||
"contractSha256": _key(program.manifest()), "contractKeys": sorted(program.manifest()),
|
||||
"objectKeys": [unit["objectKey"] for unit in units],
|
||||
"compiler": compiler_identity, "dependencies": dependencies, "notice": sha256(notice).hexdigest()}
|
||||
signature = _key(identity)
|
||||
target = cache / "models" / signature
|
||||
required = {"model.c", "model.h", executable_name, "THIRD_PARTY_NOTICES.txt", *(p.name for p in dlls)}
|
||||
model_lease = acquire_cache_lease(cache, "models", signature)
|
||||
manifest = _checked_manifest(target, "buildKey", signature, 2, required)
|
||||
details = {"selectedModules": list(component_modules(program.source + "\n" + program.header)),
|
||||
"unitCount": len(units), "objectCacheHits": 0, "objectCompilations": 0,
|
||||
"preprocessSeconds": preprocessing_seconds, "compileSeconds": 0.0,
|
||||
"compileWallSeconds": 0.0, "linkSeconds": 0.0, "modelCacheHit": manifest is not None}
|
||||
if manifest is not None:
|
||||
if progress_callback:
|
||||
progress_callback("native-cache-hit")
|
||||
touch_cache_entry(cache, "models", signature)
|
||||
details["pruning"] = _prune(cache)
|
||||
result = NativeBuild(target / executable_name, manifest, True, time.perf_counter() - started,
|
||||
details, model_lease, cache)
|
||||
model_lease = None
|
||||
return result
|
||||
if progress_callback:
|
||||
progress_callback("native-compilation")
|
||||
compile_start = time.perf_counter()
|
||||
def compile_unit(index_unit: tuple[int, dict]) -> dict:
|
||||
index, unit = index_unit
|
||||
key = unit["objectKey"]
|
||||
destination = cache / "objects" / key
|
||||
with acquire_cache_lease(cache, "objects", key):
|
||||
object_manifest = _checked_manifest(destination, "objectKey", key, 1, {"unit.o"})
|
||||
hit = object_manifest is not None
|
||||
elapsed = 0.0
|
||||
local_log: list[str] = []
|
||||
if not hit:
|
||||
temporary = Path(tempfile.mkdtemp(prefix=f"building-{key}-", dir=cache / "objects"))
|
||||
try:
|
||||
(temporary / "unit.i").write_bytes(unit["data"])
|
||||
before = time.perf_counter()
|
||||
_command([compiler, *flags, "-x", "cpp-output", "-c", str(temporary / "unit.i"),
|
||||
"-o", str(temporary / "unit.o")], log=local_log)
|
||||
elapsed = time.perf_counter() - before
|
||||
(temporary / "unit.i").unlink()
|
||||
object_manifest = {**unit["identity"], "objectKey": key, "artifacts": {"unit.o": _hash(temporary / "unit.o")}}
|
||||
(temporary / "manifest.json").write_text(json.dumps(object_manifest, indent=2) + "\n")
|
||||
_publish(temporary, destination, "objectKey", key, 1, {"unit.o"})
|
||||
finally:
|
||||
if temporary.exists():
|
||||
shutil.rmtree(temporary)
|
||||
# Linking uses private copies, so another model's LRU pass can
|
||||
# safely reclaim the shared object after this lease is released.
|
||||
local_object = stage / f"object-{index}.o"
|
||||
shutil.copyfile(destination / "unit.o", local_object)
|
||||
touch_cache_entry(cache, "objects", key)
|
||||
return {"path": local_object, "hit": hit, "seconds": elapsed, "log": local_log}
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(units))) as pool:
|
||||
objects = list(pool.map(compile_unit, enumerate(units)))
|
||||
details["compileWallSeconds"] = time.perf_counter() - compile_start
|
||||
details["compileSeconds"] = sum(item["seconds"] for item in objects)
|
||||
details["objectCacheHits"] = sum(item["hit"] for item in objects)
|
||||
details["objectCompilations"] = len(objects) - details["objectCacheHits"]
|
||||
for item in objects:
|
||||
logs.extend(item["log"])
|
||||
link_libraries = list(map(str, libraries))
|
||||
if sys.platform.startswith("linux"):
|
||||
link_libraries = ["-Wl,--start-group", *link_libraries, "-Wl,--end-group"]
|
||||
if progress_callback:
|
||||
progress_callback("native-linking")
|
||||
before_link = time.perf_counter()
|
||||
_command([compiler, *flags, *[str(item["path"]) for item in objects], *link_libraries,
|
||||
"-lm", "-o", str(stage / executable_name)], log=logs)
|
||||
details["linkSeconds"] = time.perf_counter() - before_link
|
||||
if {p.name: _hash(p) for p in libraries + dlls} != dependencies:
|
||||
raise RuntimeError("Native dependencies changed during linking; retry with a stable toolchain.")
|
||||
for item in objects:
|
||||
item["path"].unlink()
|
||||
for library in dlls:
|
||||
shutil.copyfile(library, stage / library.name)
|
||||
if _hash(stage / library.name) != dependencies[library.name]:
|
||||
raise RuntimeError("Native runtime dependency changed during copying; retry with a stable toolchain.")
|
||||
(stage / "THIRD_PARTY_NOTICES.txt").write_bytes(notice)
|
||||
(stage / "build.log").write_text("\n".join(logs), encoding="utf-8")
|
||||
manifest = {**program.manifest(), "cacheVersion": 2, "buildKey": signature, "buildIdentity": identity,
|
||||
"compiler": compiler_version, "compilerFlags": flags,
|
||||
"sourceHashes": {unit["sourceName"]: unit["sourceSha256"] for unit in units},
|
||||
"nativeHeaderHashes": {"native/" + p.relative_to(NATIVE).as_posix(): digest
|
||||
for p, digest in native_headers.items()},
|
||||
"dependencyHashes": dependencies, "selectedModules": details["selectedModules"],
|
||||
"objectKeys": [unit["objectKey"] for unit in units],
|
||||
"artifacts": {name: _hash(stage / name) for name in sorted(required)}}
|
||||
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
manifest = _publish(stage, target, "buildKey", signature, 2, required)
|
||||
touch_cache_entry(cache, "models", signature)
|
||||
details["pruning"] = _prune(cache)
|
||||
result = NativeBuild(target / executable_name, manifest, False, time.perf_counter() - started,
|
||||
details, model_lease, cache)
|
||||
model_lease = None
|
||||
return result
|
||||
finally:
|
||||
if model_lease is not None:
|
||||
model_lease.close()
|
||||
# Failed compilations never publish partial entries or retain unlimited
|
||||
# model sources/preprocessed files. Error stderr is included in the exception.
|
||||
try:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
finally:
|
||||
work_lease.close()
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Leases and bounded LRU eviction for immutable native cache entries.
|
||||
|
||||
Builds publish and validate their own artifacts. Readers hold a shared lease
|
||||
before checking an entry and until they finish using it. Eviction only removes
|
||||
an entry while holding the corresponding nonblocking exclusive lease.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import errno
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import weakref
|
||||
|
||||
|
||||
MODEL_LIMIT_BYTES = 256 * 1024**2
|
||||
OBJECT_LIMIT_BYTES = 128 * 1024**2
|
||||
LOCK_SHARDS = 128
|
||||
_KINDS = frozenset(("models", "objects"))
|
||||
_KEY = re.compile(r"[0-9a-f]{64}\Z")
|
||||
# tempfile.mkdtemp uses eight lowercase letters, digits or underscores. Older
|
||||
# stages lacking a lease key are deliberately outside automatic cleanup.
|
||||
_STAGE = re.compile(r"building-([0-9a-f]{64})-[a-z0-9_]{8}\Z")
|
||||
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import msvcrt
|
||||
|
||||
class _Offset(ctypes.Structure):
|
||||
_fields_ = [("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD)]
|
||||
|
||||
class _OffsetUnion(ctypes.Union):
|
||||
_fields_ = [("offset", _Offset), ("Pointer", ctypes.c_void_p)]
|
||||
|
||||
class _Overlapped(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
|
||||
("offset", _OffsetUnion), ("hEvent", wintypes.HANDLE),
|
||||
]
|
||||
|
||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_lock_file = _kernel32.LockFileEx
|
||||
_lock_file.argtypes = [
|
||||
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_Overlapped),
|
||||
]
|
||||
_lock_file.restype = wintypes.BOOL
|
||||
_unlock_file = _kernel32.UnlockFileEx
|
||||
_unlock_file.argtypes = [
|
||||
wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.DWORD, ctypes.POINTER(_Overlapped),
|
||||
]
|
||||
_unlock_file.restype = wintypes.BOOL
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
|
||||
def _validate(kind: str, key: str) -> None:
|
||||
if kind not in _KINDS or not isinstance(key, str) or _KEY.fullmatch(key) is None:
|
||||
raise ValueError("Native cache leases require models/objects and a lowercase SHA-256 key.")
|
||||
|
||||
|
||||
def _reparse(info: os.stat_result) -> bool:
|
||||
# Junctions as well as symbolic links are excluded on Windows.
|
||||
return stat.S_ISLNK(info.st_mode) or bool(
|
||||
getattr(info, "st_file_attributes", 0)
|
||||
& getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
||||
)
|
||||
|
||||
|
||||
def _directory(path: Path) -> os.stat_result | None:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
|
||||
return None
|
||||
return info
|
||||
|
||||
|
||||
def _require_directory(path: Path, *, create: bool = False) -> None:
|
||||
if create:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if _directory(path) is None:
|
||||
raise RuntimeError(f"Native cache directory must be a real directory: {path}")
|
||||
|
||||
|
||||
def _lock(fd: int, *, exclusive: bool, blocking: bool) -> bool:
|
||||
if os.name == "nt":
|
||||
# os.open supplies a synchronous handle. LockFileEx therefore really
|
||||
# waits without FAIL_IMMEDIATELY; unlike msvcrt.locking it also supports
|
||||
# overlapping shared leases, including multiple builds in one process.
|
||||
# The one-byte lock may extend beyond EOF, so lock files remain empty.
|
||||
overlapped = _Overlapped()
|
||||
flags = (2 if exclusive else 0) | (0 if blocking else 1)
|
||||
if _lock_file(msvcrt.get_osfhandle(fd), flags, 0, 1, 0, ctypes.byref(overlapped)):
|
||||
return True
|
||||
code = ctypes.get_last_error()
|
||||
if not blocking and code == 33: # ERROR_LOCK_VIOLATION
|
||||
return False
|
||||
raise ctypes.WinError(code)
|
||||
operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
||||
if not blocking:
|
||||
operation |= fcntl.LOCK_NB
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, operation)
|
||||
return True
|
||||
except InterruptedError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if not blocking and exc.errno in (errno.EACCES, errno.EAGAIN):
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def _release(fd: int, owner_pid: int) -> None:
|
||||
# A forked child's finalizer must not explicitly unlock its parent's flock.
|
||||
# Closing the inherited descriptor alone leaves the parent's lease intact.
|
||||
with suppress(OSError):
|
||||
if os.getpid() == owner_pid:
|
||||
if os.name == "nt":
|
||||
overlapped = _Overlapped()
|
||||
_unlock_file(msvcrt.get_osfhandle(fd), 0, 1, 0, ctypes.byref(overlapped))
|
||||
else:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
with suppress(OSError):
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class CacheLease:
|
||||
"""An acquired cache-use lock; close explicitly or use as a context manager."""
|
||||
|
||||
__slots__ = ("path", "kind", "key", "exclusive", "_finalizer", "__weakref__")
|
||||
|
||||
def __init__(self, fd: int, path: Path, kind: str, key: str, exclusive: bool):
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.key = key
|
||||
self.exclusive = exclusive
|
||||
self._finalizer = weakref.finalize(self, _release, fd, os.getpid())
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return not self._finalizer.alive
|
||||
|
||||
def close(self) -> None:
|
||||
self._finalizer()
|
||||
|
||||
def __enter__(self) -> CacheLease:
|
||||
if self.closed:
|
||||
raise RuntimeError("Cannot reuse a closed native cache lease.")
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def acquire_cache_lease(
|
||||
cache_dir: Path, kind: str, key: str, *, exclusive: bool = False,
|
||||
blocking: bool = True,
|
||||
) -> CacheLease | None:
|
||||
"""Acquire a shared-use/exclusive-eviction lease; return None only if busy.
|
||||
|
||||
Locks use 128 stable shards per namespace, bounding metadata as model keys
|
||||
accumulate. A collision can defer eviction but cannot admit unsafe deletion.
|
||||
Lock files must never be unlinked while the application is running: waiters
|
||||
may already hold the old inode. There are at most 256 empty lock files.
|
||||
"""
|
||||
_validate(kind, key)
|
||||
shard = int(key[:8], 16) % LOCK_SHARDS
|
||||
return _acquire_lock_file(
|
||||
Path(cache_dir).absolute(), kind, key, f"{kind}-{shard:03d}.lock",
|
||||
exclusive=exclusive, blocking=blocking,
|
||||
)
|
||||
|
||||
|
||||
def _acquire_lock_file(
|
||||
cache: Path, kind: str, key: str, lock_name: str, *, exclusive: bool,
|
||||
blocking: bool,
|
||||
) -> CacheLease | None:
|
||||
_require_directory(cache, create=True)
|
||||
lock_dir = cache / ".locks"
|
||||
_require_directory(lock_dir, create=True)
|
||||
lock_path = lock_dir / lock_name
|
||||
try:
|
||||
info = lock_path.lstat()
|
||||
except FileNotFoundError:
|
||||
info = None
|
||||
if info is not None and (_reparse(info) or not stat.S_ISREG(info.st_mode)):
|
||||
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
|
||||
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
||||
fd = os.open(lock_path, flags, 0o600)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise RuntimeError(f"Native cache lock must be a regular file: {lock_path}")
|
||||
os.set_inheritable(fd, False)
|
||||
if not _lock(fd, exclusive=exclusive, blocking=blocking):
|
||||
os.close(fd)
|
||||
return None
|
||||
return CacheLease(fd, cache / kind / key, kind, key, exclusive)
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def touch_cache_entry(cache_dir: Path, kind: str, key: str) -> None:
|
||||
"""Mark an immutable entry as recently used while its caller holds a lease."""
|
||||
_validate(kind, key)
|
||||
cache = Path(cache_dir).absolute()
|
||||
_require_directory(cache)
|
||||
_require_directory(cache / kind)
|
||||
entry = cache / kind / key
|
||||
_require_directory(entry)
|
||||
# Directory mtime is separate from the checksummed artifact/manifest bytes.
|
||||
os.utime(entry, None, follow_symlinks=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Entry:
|
||||
path: Path
|
||||
size: int
|
||||
mtime_ns: int
|
||||
device: int
|
||||
inode: int
|
||||
|
||||
|
||||
def _tree_size(path: Path) -> int | None:
|
||||
"""Count regular file bytes without reading files or following any links."""
|
||||
size = 0
|
||||
try:
|
||||
with os.scandir(path) as children:
|
||||
for child in children:
|
||||
info = child.stat(follow_symlinks=False)
|
||||
if _reparse(info):
|
||||
return None
|
||||
if stat.S_ISREG(info.st_mode):
|
||||
size += info.st_size
|
||||
elif stat.S_ISDIR(info.st_mode):
|
||||
nested = _tree_size(Path(child.path))
|
||||
if nested is None:
|
||||
return None
|
||||
size += nested
|
||||
else:
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
return None # A concurrent sweep may already have removed this entry.
|
||||
return size
|
||||
|
||||
|
||||
def _scan(namespace: Path) -> tuple[list[_Entry], int]:
|
||||
entries = []
|
||||
skipped = 0
|
||||
if _directory(namespace) is None:
|
||||
try:
|
||||
namespace.lstat()
|
||||
except FileNotFoundError:
|
||||
return entries, 0
|
||||
return entries, 1
|
||||
with os.scandir(namespace) as children:
|
||||
for child in children:
|
||||
if _KEY.fullmatch(child.name) is None:
|
||||
skipped += 1
|
||||
continue
|
||||
path = Path(child.path)
|
||||
info = _directory(path)
|
||||
if info is None:
|
||||
skipped += 1
|
||||
continue
|
||||
size = _tree_size(path)
|
||||
if size is None:
|
||||
skipped += 1
|
||||
continue
|
||||
entries.append(_Entry(path, size, info.st_mtime_ns, info.st_dev, info.st_ino))
|
||||
return entries, skipped
|
||||
|
||||
|
||||
def _empty_report(limit: int) -> dict:
|
||||
return {
|
||||
"limitBytes": limit, "beforeBytes": 0, "afterBytes": 0,
|
||||
"removedBytes": 0, "removedEntries": 0, "skippedInUse": 0,
|
||||
"skippedChanged": 0, "skippedUnmanaged": 0, "oversizedEntries": 0,
|
||||
"overLimitBytes": 0, "skippedConcurrentSweep": False, "errors": [],
|
||||
}
|
||||
|
||||
|
||||
def _prune_kind(cache: Path, kind: str, limit: int) -> dict:
|
||||
report = _empty_report(limit)
|
||||
namespace = cache / kind
|
||||
try:
|
||||
entries, skipped = _scan(namespace)
|
||||
except OSError as exc:
|
||||
report["errors"].append(str(exc))
|
||||
return report
|
||||
report["skippedUnmanaged"] = skipped
|
||||
remaining = report["beforeBytes"] = sum(entry.size for entry in entries)
|
||||
remaining_count = len(entries)
|
||||
for entry in sorted(entries, key=lambda item: (item.mtime_ns, item.path.name)):
|
||||
if remaining <= limit:
|
||||
break
|
||||
# Keep the last entry if it alone exceeds the budget. Older oversized
|
||||
# entries are still evictable, so they cannot accumulate unboundedly.
|
||||
if remaining_count == 1 and entry.size > limit:
|
||||
continue
|
||||
try:
|
||||
lease = acquire_cache_lease(cache, kind, entry.path.name, exclusive=True, blocking=False)
|
||||
if lease is None:
|
||||
report["skippedInUse"] += 1
|
||||
continue
|
||||
with lease:
|
||||
info = _directory(entry.path)
|
||||
if info is None:
|
||||
report["skippedChanged"] += 1
|
||||
continue
|
||||
if (info.st_dev, info.st_ino, info.st_mtime_ns) != (
|
||||
entry.device, entry.inode, entry.mtime_ns,
|
||||
):
|
||||
# It was republished or reused after sorting; defer it to
|
||||
# the next sweep instead of evicting a freshly used build.
|
||||
report["skippedChanged"] += 1
|
||||
continue
|
||||
size = _tree_size(entry.path)
|
||||
if size is None:
|
||||
report["skippedUnmanaged"] += 1
|
||||
continue
|
||||
shutil.rmtree(entry.path)
|
||||
remaining -= entry.size
|
||||
remaining_count -= 1
|
||||
report["removedBytes"] += size
|
||||
report["removedEntries"] += 1
|
||||
except (OSError, RuntimeError) as exc:
|
||||
# Cleanup is best effort. A locked executable, permissions or a
|
||||
# damaged unrelated cache entry must not fail a valid simulation.
|
||||
report["errors"].append(str(exc))
|
||||
try:
|
||||
current, _ = _scan(namespace)
|
||||
report["afterBytes"] = sum(entry.size for entry in current)
|
||||
report["oversizedEntries"] = sum(entry.size > limit for entry in current)
|
||||
except OSError as exc:
|
||||
report["errors"].append(str(exc))
|
||||
report["afterBytes"] = remaining
|
||||
report["overLimitBytes"] = max(0, report["afterBytes"] - limit)
|
||||
return report
|
||||
|
||||
|
||||
|
||||
def _empty_orphan_report() -> dict:
|
||||
return {
|
||||
"orphanStagesRemoved": 0, "orphanStagesBytes": 0,
|
||||
"skippedInUse": 0, "skippedUnmanaged": 0,
|
||||
"skippedConcurrentSweep": False, "errors": [],
|
||||
}
|
||||
|
||||
|
||||
def _prune_orphan_stages(cache: Path) -> dict:
|
||||
"""Remove only named stages whose builder's lease is no longer held.
|
||||
|
||||
Callers acquire the embedded key's shared lease BEFORE creating the stage
|
||||
and retain it until publication or cleanup. The OS releases that protection
|
||||
if the builder is killed; no age threshold or unreliable PID test is needed.
|
||||
Root/models stages use models leases; objects stages use objects leases.
|
||||
"""
|
||||
report = _empty_orphan_report()
|
||||
for namespace, kind in ((cache, "models"), (cache / "models", "models"),
|
||||
(cache / "objects", "objects")):
|
||||
try:
|
||||
if _directory(namespace) is None:
|
||||
continue
|
||||
with os.scandir(namespace) as children:
|
||||
candidates = []
|
||||
for child in children:
|
||||
match = _STAGE.fullmatch(child.name)
|
||||
if match is not None:
|
||||
candidates.append((Path(child.path), match.group(1)))
|
||||
elif child.name.startswith("building-"):
|
||||
report["skippedUnmanaged"] += 1
|
||||
except OSError as exc:
|
||||
report["errors"].append(f"{namespace}: {exc}")
|
||||
continue
|
||||
for stage, key in candidates:
|
||||
try:
|
||||
lease = acquire_cache_lease(cache, kind, key, exclusive=True, blocking=False)
|
||||
if lease is None:
|
||||
report["skippedInUse"] += 1
|
||||
continue
|
||||
with lease:
|
||||
if _directory(stage) is None:
|
||||
report["skippedUnmanaged"] += 1
|
||||
continue
|
||||
size = _tree_size(stage)
|
||||
if size is None:
|
||||
report["skippedUnmanaged"] += 1
|
||||
continue
|
||||
shutil.rmtree(stage)
|
||||
report["orphanStagesRemoved"] += 1
|
||||
report["orphanStagesBytes"] += size
|
||||
except (OSError, RuntimeError) as exc:
|
||||
report["errors"].append(f"{stage}: {exc}")
|
||||
return report
|
||||
|
||||
|
||||
def prune_cache(
|
||||
cache_dir: Path, *, model_limit_bytes: int = MODEL_LIMIT_BYTES,
|
||||
object_limit_bytes: int = OBJECT_LIMIT_BYTES,
|
||||
) -> dict:
|
||||
"""Apply separate soft LRU byte budgets to managed models and objects.
|
||||
|
||||
In-use entries (including conservative shard collisions), the final entry
|
||||
when it alone exceeds the budget, unknown names, links and legacy root-level
|
||||
builds are retained. Returned counts explain any remaining overage. The
|
||||
cache may transiently exceed its budgets while builds or solvers are active.
|
||||
Strictly named stages abandoned by killed builders are removed under their
|
||||
matching lease and counted separately in the orphanStages report.
|
||||
"""
|
||||
for limit in (model_limit_bytes, object_limit_bytes):
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
|
||||
raise ValueError("Native cache byte limits must be nonnegative integers.")
|
||||
cache = Path(cache_dir).absolute()
|
||||
empty = {
|
||||
"models": _empty_report(model_limit_bytes),
|
||||
"objects": _empty_report(object_limit_bytes),
|
||||
"orphanStages": _empty_orphan_report(),
|
||||
}
|
||||
try:
|
||||
if _directory(cache) is None:
|
||||
# Do not create a missing cache or inspect an alias to a directory.
|
||||
return empty
|
||||
# One additional stable lock file (257 total) serializes sweep decisions
|
||||
# without serializing readers/builds. Otherwise simultaneous sweeps can
|
||||
# each remove a different entry and incorrectly discard the final one.
|
||||
sweep = _acquire_lock_file(
|
||||
cache, "models", "0" * 64, "prune.lock", exclusive=True, blocking=False,
|
||||
)
|
||||
if sweep is None:
|
||||
for report in empty.values():
|
||||
report["skippedConcurrentSweep"] = True
|
||||
return empty
|
||||
with sweep:
|
||||
orphan_stages = _prune_orphan_stages(cache)
|
||||
return {
|
||||
"models": _prune_kind(cache, "models", model_limit_bytes),
|
||||
"objects": _prune_kind(cache, "objects", object_limit_bytes),
|
||||
"orphanStages": orphan_stages,
|
||||
}
|
||||
except (OSError, RuntimeError) as exc:
|
||||
for report in empty.values():
|
||||
report["errors"].append(str(exc))
|
||||
return empty
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Reviewed native component module exports and their compilation dependencies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
EXPORTS = {
|
||||
"properties": frozenset({
|
||||
"native_properties_init", "native_temperature_ph_context", "native_density_context",
|
||||
"native_gas_init", "native_gas", "native_gas_context", "native_medium_init",
|
||||
"native_density", "native_temperature_ph", "native_viscosity",
|
||||
"native_medium_gas", "native_medium_gas_context",
|
||||
}),
|
||||
"orifice": frozenset({"native_orifice", "native_orifice_context", "native_medium_orifice", "native_medium_orifice_context"}),
|
||||
"pipe": frozenset({"native_pipe_resistance", "native_pipe_flow", "native_pipe_flow_context",
|
||||
"native_pipe_flow_cached", "native_pipe_flow_cached_context",
|
||||
"native_pipe_diagnostics", "native_pipe_diagnostics_context"}),
|
||||
"mechanics": frozenset({"native_contact", "native_stop_motion", "native_limit_force"}),
|
||||
"signal": frozenset({"native_signal", "native_signal_break"}),
|
||||
}
|
||||
DEPENDENCIES = {"orifice": ("properties",), "pipe": ("properties",)}
|
||||
|
||||
|
||||
def component_modules(source: str) -> tuple[str, ...]:
|
||||
# Generated C refers to these functions directly. Include identifier uses
|
||||
# (also function pointers), conservatively including any comments/strings.
|
||||
identifiers = set(re.findall(r"\bnative_[A-Za-z0-9_]+\b", source))
|
||||
selected = {name for name, exports in EXPORTS.items() if identifiers & exports}
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
for dependency in DEPENDENCIES.get(pending.pop(), ()):
|
||||
if dependency not in selected:
|
||||
selected.add(dependency)
|
||||
pending.append(dependency)
|
||||
return tuple(name for name in EXPORTS if name in selected)
|
||||
@@ -105,6 +105,7 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
|
||||
payload["buildKey"] = build.manifest["buildKey"]
|
||||
payload["cacheHit"] = build.cache_hit
|
||||
payload["buildSeconds"] = build.seconds
|
||||
payload["buildDetails"] = build.details
|
||||
if activity_tracker is not None:
|
||||
activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"])
|
||||
return payload
|
||||
@@ -115,13 +116,20 @@ def simulate_native(network, config, *, sample_step, progress_callback=None,
|
||||
if config.method not in ("RK45", "BDF"):
|
||||
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
|
||||
if progress_callback:
|
||||
progress_callback(0.0, "initializing")
|
||||
progress_callback(0.0, "native-generation")
|
||||
program = compile_native_program(network)
|
||||
build = build_native(program)
|
||||
with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory:
|
||||
data = execute_native(build, config, sample_step, run_dir=Path(directory),
|
||||
cancel_check=cancel_check, progress_callback=progress_callback,
|
||||
activity_tracker=activity_tracker, raw_series=raw_series)
|
||||
build = build_native(program, progress_callback=(
|
||||
(lambda phase: progress_callback(0.0, phase)) if progress_callback else None
|
||||
))
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory:
|
||||
data = execute_native(build, config, sample_step, run_dir=Path(directory),
|
||||
cancel_check=cancel_check, progress_callback=progress_callback,
|
||||
activity_tracker=activity_tracker, raw_series=raw_series)
|
||||
finally:
|
||||
# Keep the executable pinned through process exit and result reading;
|
||||
# only then may cache eviction reclaim this completed model version.
|
||||
build.close()
|
||||
totals = {
|
||||
"nfev": data["nfev"], "njev": data["njev"], "nlu": data["nlu"],
|
||||
"acceptedStepCount": data["acceptedSteps"], "rejectedStepCount": data["rejectedSteps"],
|
||||
|
||||
Reference in new issue
Block a user