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

+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()