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

+7 -2
View File
@@ -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))
+290 -79
View File
@@ -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
+34
View File
@@ -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)
+14 -6
View File
@@ -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"],