# C10 Engine Compiler — Per-Model TRT Compile + Hardware-Tied Cache Reuse **Task**: AZ-321_c10_engine_compiler **Name**: C10 Engine Compiler **Description**: Implement `EngineCompiler`, the C10-internal phase that compiles or re-uses TensorRT engines for every backbone the corpus needs (DINOv2 reduced for VPR, LightGlue, ALIKED descriptor head, plus any C7-runtime-required model). For each backbone, computes the AZ-281 self-describing filename `{model}_{sm}_{jp}_{trt}_{precision}.engine`, looks for an existing engine + sidecar at that path, and either re-uses it (cache hit, D-C10-6) or invokes AZ-298's TensorRT runtime to compile from the ONNX source + calibration cache. Writes each new engine via AZ-280's `Sha256Sidecar` for the takeoff content-hash gate. Returns a `list[EngineCacheEntry]` recording the per-backbone outcome (built / reused) plus the cache hit ratio. The compile is hardware-tied: SM, Jetpack, TRT version, and precision flags are baked into the filename so re-running on a different device produces a cache miss (correct behaviour, not a bug). **Complexity**: 5 points **Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-280_sha256_sidecar, AZ-281_engine_filename_schema, AZ-298_c7_tensorrt_runtime **Component**: c10_provisioning (epic AZ-252 / E-C10) **Tracker**: AZ-321 **Epic**: AZ-252 (E-C10) ### Document Dependencies - `_docs/02_document/contracts/shared_helpers/engine_filename_schema.md` — filename shape + parser (AZ-281). - `_docs/02_document/contracts/shared_helpers/sha256_sidecar.md` — atomic write + sidecar pattern (AZ-280). - `_docs/02_document/contracts/c7_inference/inference_runtime_protocol.md` — engine compile API (AZ-298). - `_docs/02_document/components/11_c10_provisioning/description.md` — § 5 error handling, § 7 caveats (D-C10-6 hardware-tied). ## Problem Without a real engine compiler: - AC-NEW-1 (no engine deserialization at takeoff before manifest verify) collapses on the build side — F1 cannot produce the `.engine` artifacts the airborne C7 deserialise step expects. - D-C10-6 (calibration cache reuse on identical hardware) is unobservable — every build re-compiles from scratch, blowing the C10-PT-01 ≤ 12 min cold target on warm runs. - D-C10-7 (self-describing engine filename) has no producer — without `{model}_{sm}_{jp}_{trt}_{precision}.engine`, hardware mismatches between operator workstation and Jetson airborne would silently load wrong-arch engines. - The C10-PT-01 warm idempotent re-run target (≤ 1 min) cannot be hit; engines dominate build time. - C10-IT-05 (Tier-2 build produces SM 87 / JP 6.2 / TRT 10.3 / FP16 engines) has no implementation. - Operators have no way to inspect which engines came from cache vs. were rebuilt — a critical signal for diagnosing GPU-OOM or calibration regressions. This task delivers the per-model compile + cache-reuse logic. It does NOT own the orchestration (T5 owns `build_cache_artifacts`), the descriptor batching (T2), or the manifest writing (T3). ## Outcome - An `EngineCompiler` class at `src/gps_denied_onboard/components/c10_provisioning/engine_compiler.py`: - Constructor: `__init__(self, *, inference_runtime: InferenceRuntime, sidecar: Sha256Sidecar, filename_schema: EngineFilenameSchema, logger: Logger)`. - Public method: `compile_engines_for_corpus(request: EngineCompileRequest) -> list[EngineCacheEntry]`. - `EngineCompileRequest` (`@dataclass(frozen=True)`): `backbones: tuple[BackboneSpec, ...]`, `calibration_path: Path`, `cache_root: Path`, `precision: enum {fp16, int8}`. - `BackboneSpec` (`@dataclass(frozen=True)`): `model_name: str`, `onnx_path: Path`, `expected_input_shape: tuple[int, ...]`. - `EngineCacheEntry` (`@dataclass(frozen=True)`): `model_name: str`, `engine_path: Path`, `sidecar_path: Path`, `outcome: enum {built, reused}`, `compile_duration_s: float | None`, `engine_sha256_hex: str`. - Method flow: 1. For each `BackboneSpec`: a. Detect runtime hardware (SM, JP, TRT version) via `inference_runtime.host_info()`. b. Compute the target filename via `filename_schema.format(...)`: `{model}_{sm}_{jp}_{trt}_{precision}.engine`. c. Compute the target path: `{cache_root}/engines/{filename}`. d. If `target_path.exists()` AND `sidecar.verify(target_path)` returns `True`: - Outcome = `reused`; emit INFO log `kind="c10.engine.cache.hit"`; append `EngineCacheEntry`; continue. e. Else (cache miss): - Emit WARN log `kind="c10.engine.cache.miss"` with `{model_name, target_filename}`. - Call `inference_runtime.compile_engine(onnx_path, calibration_path, precision, expected_input_shape) -> bytes` (raises `EngineBuildError` or `CalibrationCacheError` on failure — propagate). - Write the engine bytes via `sidecar.write_with_sidecar(target_path, engine_bytes)` (atomic write + SHA-256 sidecar at `{target_path}.sha256`). - Outcome = `built`; record `compile_duration_s` from `time.monotonic()` deltas; append `EngineCacheEntry`. 2. Return the list. Aggregate count: `engines_built`, `engines_reused`, total cache hit ratio. INFO log `kind="c10.engine.compile.summary"` with the totals. - The composition root constructs `EngineCompiler` and injects it into the T5 CacheProvisioner. Factory: `build_engine_compiler(config) -> EngineCompiler`. - A `BackboneSpec` registry at `src/gps_denied_onboard/runtime_root/c10_factory.py` enumerates the project's backbones (initially DINOv2-VPR + LightGlue + ALIKED — cross-referenced against E-C2/E-C2.5/E-C3 component descriptions). The list is config-driven via `config.c10.backbones: list[BackboneSpec]` so a future model addition does not require code change. - INFO log on every cache hit; WARN on every cache miss; ERROR on `EngineBuildError` / `CalibrationCacheError` with the offending model name. ## Scope ### Included - `EngineCompiler` class with the single public method. - The 3 DTOs (`EngineCompileRequest`, `BackboneSpec`, `EngineCacheEntry`) plus their enum types. - Hardware-tied filename construction via AZ-281's schema. - Cache-hit detection via `sidecar.verify` (sha256 sidecar matches). - Cache-miss compile via AZ-298's `InferenceRuntime.compile_engine`. - Atomic engine write + sidecar via AZ-280. - Composition-root factory. - Conformance test: a fake `InferenceRuntime` returns scripted engine bytes; the test asserts cache hit / miss outcomes for the documented matrix. - Per-cache-entry timing instrumentation. - `config.c10.backbones` schema extension on AZ-269's loader. ### Excluded - The orchestration of when to compile (T5 owns `build_cache_artifacts`). - Descriptor generation (T2 owns). - Manifest writing (T3 owns). - TensorRT internals — owned by AZ-298 (the `compile_engine` impl); this task only consumes the protocol. - Engine deserialization at takeoff — owned by AZ-298 (load side) + the C7 component runtime self-check. - Engine version compatibility checks across deployments — out of scope; the filename schema (AZ-281) carries enough signal that mismatches surface as cache miss. - Multi-GPU compile — operator workstation is single-GPU per RESTRICT-OPS-2. - A re-build-now CLI flag — operator workflow goes through T5; force-rebuild is achieved by deleting the engine cache directory. ## Acceptance Criteria **AC-1: Cold cache compiles every backbone** Given an empty `cache_root/engines/` and 3 backbones in `BackboneSpec[]` When `compile_engines_for_corpus(request)` is called Then 3 `EngineCacheEntry` are returned, all with `outcome = built`; 3 `.engine` files + 3 `.sha256` sidecars are present at `cache_root/engines/`; ONE WARN log per backbone (`c10.engine.cache.miss`); ONE INFO log summary with `engines_built=3, engines_reused=0` **AC-2: Warm cache reuses every backbone** Given the same `cache_root/engines/` populated by a prior cold run When `compile_engines_for_corpus(request)` is called with identical request Then 3 `EngineCacheEntry` are returned, all `outcome = reused`; ZERO calls to `inference_runtime.compile_engine` (verifiable via spy); ONE INFO log per backbone (`c10.engine.cache.hit`); summary log shows `engines_reused=3` **AC-3: Mixed cache (1 hit + 2 miss)** Given the cache contains only the DINOv2 engine; LightGlue and ALIKED are missing When `compile_engines_for_corpus(request)` is called Then DINOv2 → reused, LightGlue + ALIKED → built; the report shows `engines_built=2, engines_reused=1` **AC-4: Hardware change invalidates cache** Given a cache populated for `(sm=87, jp=6.2, trt=10.3, fp16)` and the runtime now reports `(sm=89, jp=6.3, trt=10.5, fp16)` When `compile_engines_for_corpus(request)` is called Then ALL backbones have `outcome = built` (the filename differs, so the existing engines are not even consulted); the existing engines remain on disk (this task does NOT delete stale engines — that's the orchestrator's call) **AC-5: Tampered sidecar invalidates that one engine** Given a `.engine` file matches its sidecar but a malicious actor flipped a bit in the sidecar (or the engine bytes drifted) When `compile_engines_for_corpus(request)` is called Then `sidecar.verify` returns `False` for that entry; that backbone is recompiled (`outcome = built`); ONE WARN log `kind="c10.engine.sidecar.mismatch"` with the offending path **AC-6: `EngineBuildError` propagates without partial state** Given `inference_runtime.compile_engine` raises `EngineBuildError("CUDA OOM")` on the second of 3 backbones When `compile_engines_for_corpus(request)` is called Then `EngineBuildError` is raised; the first backbone's engine + sidecar ARE present (already-written cache reuse from prior runs); the second backbone's engine is NOT half-written (atomic write); the third backbone is NOT attempted; ONE ERROR log with the model name **AC-7: `CalibrationCacheError` propagates with diagnostic** Given `inference_runtime.compile_engine` raises `CalibrationCacheError("calibration table missing for INT8")` When the compiler hits the failing backbone Then the error propagates; ONE ERROR log with `{model_name, calibration_path}`; partial state is consistent (atomic writes guarantee no half-engine on disk) **AC-8: Filename schema + sidecar layout matches spec** Given a freshly-built DINOv2 engine on Tier-2 hardware (SM 87, JP 6.2, TRT 10.3, FP16) When inspecting `cache_root/engines/` Then the file is named `dinov2_vpr_sm87_jp62_trt103_fp16.engine`; the sidecar at `dinov2_vpr_sm87_jp62_trt103_fp16.engine.sha256` contains the 64-char hex digest; both match `EngineFilenameSchema.parse` and `Sha256Sidecar.verify` **AC-9: `compile_duration_s` recorded for built; None for reused** Given a mix of hits and misses When inspecting `EngineCacheEntry` Then `compile_duration_s is not None` for every `built` entry; `compile_duration_s is None` for every `reused` entry; built durations are positive floats **AC-10: Empty `backbones` list returns empty result** Given `request.backbones == ()` When `compile_engines_for_corpus(request)` is called Then `[]` is returned; ZERO calls to `inference_runtime.compile_engine`; ZERO files written; ONE INFO log summary with all-zero counts ## Non-Functional Requirements **Performance** - Cache-hit path per backbone ≤ 100 ms (one filename construction + one `Path.exists` + one sidecar verify dominated by SHA-256 of the engine file, which is bounded by disk read bandwidth). For a 200 MB engine, this is ~1 s on NVMe — measure and document. - Cold compile is dominated by AZ-298's TensorRT runtime; this task imposes no additional time budget beyond AZ-298's. **Compatibility** - AZ-281 (`EngineFilenameSchema`) and AZ-280 (`Sha256Sidecar`) are the schema and atomic-write helpers; this task introduces NO new third-party dependencies. **Reliability** - Atomic writes via AZ-280 guarantee no half-engine on disk after a process kill. - Cache-miss recompile is idempotent — running the same compile twice produces identical bytes (TRT engine determinism is owned by AZ-298; this task assumes it). ## Unit Tests | AC Ref | What to Test | Required Outcome | |--------|-------------|-----------------| | AC-1 | Empty cache_root + 3 backbones | All `built`; sidecars present | | AC-2 | Warm cache + identical request | All `reused`; zero `compile_engine` calls | | AC-3 | Cache populated for 1 of 3 backbones | 1 reused + 2 built | | AC-4 | Hardware change (different SM in fake runtime) | All `built`; old engines untouched | | AC-5 | Tampered sidecar (flip 1 byte) | That engine rebuilds; WARN log | | AC-6 | Fake runtime raises `EngineBuildError` mid-run | Error propagates; partial state consistent | | AC-7 | Fake runtime raises `CalibrationCacheError` | Error propagates with diagnostic | | AC-8 | Inspect filename + sidecar layout | Matches schema; both verify | | AC-9 | Compile_duration recorded | Set on `built`, None on `reused` | | AC-10 | Empty backbones | Empty result; zero side effects | | NFR-perf-cache-hit | Microbench cache-hit path × 100 with 200 MB engine | p99 ≤ 1.5 s (mostly SHA-256 read) | | NFR-reliability-atomic-write | Kill process mid-`compile_engine` | No half-engine on disk after restart | ## Constraints - The filename schema is canonical via AZ-281; this task does NOT invent its own (per `coderule.mdc` "follow established project patterns"). - The atomic-write + sidecar pattern is canonical via AZ-280; this task does NOT use `open(...).write()` or naked `pathlib.Path.write_bytes()`. - Cache hit is decided by `sidecar.verify` (file SHA-256 matches sidecar value); filename match alone is NOT sufficient (defends against bit-rot or bit-flip). - The `BackboneSpec` registry is config-driven; adding a new model is a config change, not a code change. - This task does NOT clean up stale engines (the orchestrator T5 may emit `ManifestCoverageError` on orphan files; cleanup is the operator's call). - This task introduces no new third-party dependencies. ## Risks & Mitigation **Risk 1: SHA-256 verification of large engines is slow on warm path** - *Risk*: 200 MB engine × 5 backbones = 1 GB SHA-256 per warm idempotent run; on slow disks, this exceeds C10-PT-01's 1 min budget alone. - *Mitigation*: AZ-280's `Sha256Sidecar.verify` uses `sendfile` / `mmap` paths where available; benchmark documented in AZ-280. If still too slow, a future task adds an `mtime + size` quick-check fallback (out of scope this cycle). **Risk 2: Partial cache after `EngineBuildError` on backbone N** - *Risk*: Backbones 1..N-1 are `built` and on disk; the N-th fails; backbones N+1..M are never attempted. The cache is "partially valid" — the orchestrator (T5) sees inconsistent state. - *Mitigation*: T5's coverage check + `ManifestCoverageError` surface this. The compiler does NOT delete the partial state; T5 decides whether to retry, fail, or roll back per the operator's request mode. **Risk 3: TensorRT engine determinism not guaranteed across builds** - *Risk*: Two compiles of the same ONNX + calibration produce different bytes; cache-hit detection via SHA-256 fails post-rebuild. - *Mitigation*: TRT engine determinism is AZ-298's contract obligation; if it fails, this task's cache-hit ratio drops to 0 and operators see WARN logs. AZ-298's tests assert determinism; this task assumes it. **Risk 4: Operator manually edits engine file but not sidecar** - *Risk*: Hand-debugging or manual tuning leaves an engine file whose bytes don't match its sidecar; AC-5 covers detection. - *Mitigation*: AC-5 + WARN log `c10.engine.sidecar.mismatch` surface the case immediately on next compile run; operators should re-generate via the build command. ## Runtime Completeness - **Named capability**: TRT engine compile + hardware-tied cache reuse per D-C10-6 + D-C10-7 (description.md § 5; epic § Acceptance C10-IT-05; AC-NEW-1). - **Production code that must exist**: real `EngineCompiler` orchestrating real AZ-298 `compile_engine` + real AZ-280 atomic write/verify + real AZ-281 filename construction; real config-driven `BackboneSpec` registry. - **Allowed external stubs**: tests MAY use a fake `InferenceRuntime` that returns scripted bytes + a fake `host_info()` for hardware variation; production wiring uses the real AZ-298 runtime + real Sha256Sidecar. - **Unacceptable substitutes**: a Python-level `pickle` of a "fake engine" object (TRT engines are opaque CUDA blobs; faking them in production breaks AC-NEW-1's takeoff verify); skipping the sidecar (loses bit-rot detection); inventing a new filename scheme inside this task (defeats D-C10-7); `Path.write_bytes()` instead of AZ-280 (no atomicity guarantee).