inference-artifact-lab 0.1.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inference_artifact_lab-0.1.0.dev0/LICENSE +21 -0
- inference_artifact_lab-0.1.0.dev0/PKG-INFO +100 -0
- inference_artifact_lab-0.1.0.dev0/README.md +84 -0
- inference_artifact_lab-0.1.0.dev0/pyproject.toml +26 -0
- inference_artifact_lab-0.1.0.dev0/setup.cfg +4 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab/__init__.py +14 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab/__main__.py +85 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab/adapters.py +296 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab/gate.py +163 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab/models.py +264 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab/release.py +70 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab/runtime.py +177 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab.egg-info/PKG-INFO +100 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab.egg-info/SOURCES.txt +22 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab.egg-info/dependency_links.txt +1 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab.egg-info/entry_points.txt +2 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab.egg-info/requires.txt +7 -0
- inference_artifact_lab-0.1.0.dev0/src/inference_artifact_lab.egg-info/top_level.txt +1 -0
- inference_artifact_lab-0.1.0.dev0/tests/test_adapters.py +171 -0
- inference_artifact_lab-0.1.0.dev0/tests/test_gate.py +151 -0
- inference_artifact_lab-0.1.0.dev0/tests/test_models.py +52 -0
- inference_artifact_lab-0.1.0.dev0/tests/test_package.py +5 -0
- inference_artifact_lab-0.1.0.dev0/tests/test_release.py +78 -0
- inference_artifact_lab-0.1.0.dev0/tests/test_runtime.py +44 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ray Carter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inference-artifact-lab
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: Reproducible validation gates for machine-learning inference artifacts
|
|
5
|
+
Author: Ray Carter
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
12
|
+
Provides-Extra: runtime-cpu
|
|
13
|
+
Requires-Dist: numpy>=1.26; extra == "runtime-cpu"
|
|
14
|
+
Requires-Dist: onnxruntime==1.30.0; extra == "runtime-cpu"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Inference Artifact Lab
|
|
18
|
+
|
|
19
|
+
Inference Artifact Lab is a clean-room, public-by-design project for validating
|
|
20
|
+
machine-learning deployment artifacts before release. Its first product increment
|
|
21
|
+
is the **Model Release Gate**: a reproducible gate for artifact integrity,
|
|
22
|
+
input/output contracts, runtime correctness, and environment compatibility.
|
|
23
|
+
|
|
24
|
+
The project uses public models, public datasets, and generated fixtures. It does
|
|
25
|
+
not train models, provide a general model-serving gateway, or claim model quality
|
|
26
|
+
beyond the declared validation evidence.
|
|
27
|
+
|
|
28
|
+
## Current status
|
|
29
|
+
|
|
30
|
+
Development preview: public SqueezeNet CPU and TensorRT smoke runs are recorded.
|
|
31
|
+
Phase 1 acceptance remains incomplete; report delivery and clean reproduction
|
|
32
|
+
need further work. See [publication review](docs/publication-review.md) for
|
|
33
|
+
known limitations. The commands below are development examples, not a verified
|
|
34
|
+
from-scratch reproduction procedure.
|
|
35
|
+
|
|
36
|
+
## Planned flow
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
public model
|
|
40
|
+
-> source and artifact manifest
|
|
41
|
+
-> export/build
|
|
42
|
+
-> integrity and contract checks
|
|
43
|
+
-> reference/runtime equivalence checks
|
|
44
|
+
-> environment compatibility checks
|
|
45
|
+
-> resource benchmark
|
|
46
|
+
-> machine-readable release report
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run the public-reference smoke gate with:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
uv run --with torch --with torchvision --with onnx --with onnxruntime python scripts/run_torchvision_gate.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It writes `reports/phase-1/squeezenet11-torchvision-onnx-cpu.json`.
|
|
56
|
+
|
|
57
|
+
Build and verify the TensorRT profile after pulling the pinned public image:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
pwsh scripts/build_tensorrt_engine.ps1
|
|
61
|
+
pwsh scripts/benchmark_tensorrt_engine.ps1
|
|
62
|
+
docker run --rm --gpus all -v "${PWD}:/workspace" -w /workspace `
|
|
63
|
+
-e MODEL_RELEASE_GATE_CONTAINER_DIGEST=sha256:814325e2b8a653f354c30bbcf5ecc8d4c780cf878a88a320ae648fbfdd9dd82d `
|
|
64
|
+
nvcr.io/nvidia/tensorrt:25.02-py3 bash -lc `
|
|
65
|
+
"python -m pip install --quiet --index-url https://pypi.org/simple cuda-python==12.8.0; `
|
|
66
|
+
PYTHONPATH=/workspace/src python scripts/run_tensorrt_in_container.py `
|
|
67
|
+
--engine artifacts/squeezenet1.1-fp32.engine `
|
|
68
|
+
--fixture artifacts/squeezenet11-fixture.npy `
|
|
69
|
+
--output artifacts/squeezenet11-tensorrt-output.json"
|
|
70
|
+
uv run --with numpy==2.4.6 python scripts/compose_tensorrt_report.py `
|
|
71
|
+
--trtexec-log reports/phase-1/tensorrt-trtexec-benchmark.log
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The generated TensorRT report includes the contract, engine digest, fixture
|
|
75
|
+
equivalence, declared GPU/container fingerprint, and `trtexec` benchmark scope.
|
|
76
|
+
|
|
77
|
+
Render any JSON report for review with:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
uv run python scripts/render_report.py reports/phase-1/squeezenet11-tensorrt.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The report contract is defined by
|
|
84
|
+
`schemas/release-report.schema.json`. A clean CPU reproduction starts with
|
|
85
|
+
`pwsh scripts/clean_reproduction.ps1` in a fresh checkout.
|
|
86
|
+
|
|
87
|
+
After generating the public fixture and reference output, the package CLI can
|
|
88
|
+
execute the ONNX CPU adapter directly:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
python -m inference_artifact_lab examples/squeezenet11-torchvision.manifest.json `
|
|
92
|
+
--runtime onnx-cpu --inputs-npy artifacts/squeezenet11-fixture.npy `
|
|
93
|
+
--reference-output reference-output.json --environment environment.json `
|
|
94
|
+
--report reports/phase-1/cli-onnx-cpu.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The authoritative development documentation follows the same phase/stage model
|
|
98
|
+
used by the other portfolio repositories. Start at the [Codex document index](docs/codex/README.md), then read the [product contract](docs/codex/product-contract.md) and [Phase 1 plan](docs/codex/phases/phase-1-model-release-gate/README.md).
|
|
99
|
+
|
|
100
|
+
For the human-readable brief and clean-room record, see [Product Requirements](docs/product-requirements.md), [Acceptance Contract](docs/acceptance-contract.md), and [Clean-room Record](docs/clean-room-record.md).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Inference Artifact Lab
|
|
2
|
+
|
|
3
|
+
Inference Artifact Lab is a clean-room, public-by-design project for validating
|
|
4
|
+
machine-learning deployment artifacts before release. Its first product increment
|
|
5
|
+
is the **Model Release Gate**: a reproducible gate for artifact integrity,
|
|
6
|
+
input/output contracts, runtime correctness, and environment compatibility.
|
|
7
|
+
|
|
8
|
+
The project uses public models, public datasets, and generated fixtures. It does
|
|
9
|
+
not train models, provide a general model-serving gateway, or claim model quality
|
|
10
|
+
beyond the declared validation evidence.
|
|
11
|
+
|
|
12
|
+
## Current status
|
|
13
|
+
|
|
14
|
+
Development preview: public SqueezeNet CPU and TensorRT smoke runs are recorded.
|
|
15
|
+
Phase 1 acceptance remains incomplete; report delivery and clean reproduction
|
|
16
|
+
need further work. See [publication review](docs/publication-review.md) for
|
|
17
|
+
known limitations. The commands below are development examples, not a verified
|
|
18
|
+
from-scratch reproduction procedure.
|
|
19
|
+
|
|
20
|
+
## Planned flow
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
public model
|
|
24
|
+
-> source and artifact manifest
|
|
25
|
+
-> export/build
|
|
26
|
+
-> integrity and contract checks
|
|
27
|
+
-> reference/runtime equivalence checks
|
|
28
|
+
-> environment compatibility checks
|
|
29
|
+
-> resource benchmark
|
|
30
|
+
-> machine-readable release report
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Run the public-reference smoke gate with:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
uv run --with torch --with torchvision --with onnx --with onnxruntime python scripts/run_torchvision_gate.py
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It writes `reports/phase-1/squeezenet11-torchvision-onnx-cpu.json`.
|
|
40
|
+
|
|
41
|
+
Build and verify the TensorRT profile after pulling the pinned public image:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
pwsh scripts/build_tensorrt_engine.ps1
|
|
45
|
+
pwsh scripts/benchmark_tensorrt_engine.ps1
|
|
46
|
+
docker run --rm --gpus all -v "${PWD}:/workspace" -w /workspace `
|
|
47
|
+
-e MODEL_RELEASE_GATE_CONTAINER_DIGEST=sha256:814325e2b8a653f354c30bbcf5ecc8d4c780cf878a88a320ae648fbfdd9dd82d `
|
|
48
|
+
nvcr.io/nvidia/tensorrt:25.02-py3 bash -lc `
|
|
49
|
+
"python -m pip install --quiet --index-url https://pypi.org/simple cuda-python==12.8.0; `
|
|
50
|
+
PYTHONPATH=/workspace/src python scripts/run_tensorrt_in_container.py `
|
|
51
|
+
--engine artifacts/squeezenet1.1-fp32.engine `
|
|
52
|
+
--fixture artifacts/squeezenet11-fixture.npy `
|
|
53
|
+
--output artifacts/squeezenet11-tensorrt-output.json"
|
|
54
|
+
uv run --with numpy==2.4.6 python scripts/compose_tensorrt_report.py `
|
|
55
|
+
--trtexec-log reports/phase-1/tensorrt-trtexec-benchmark.log
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The generated TensorRT report includes the contract, engine digest, fixture
|
|
59
|
+
equivalence, declared GPU/container fingerprint, and `trtexec` benchmark scope.
|
|
60
|
+
|
|
61
|
+
Render any JSON report for review with:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
uv run python scripts/render_report.py reports/phase-1/squeezenet11-tensorrt.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The report contract is defined by
|
|
68
|
+
`schemas/release-report.schema.json`. A clean CPU reproduction starts with
|
|
69
|
+
`pwsh scripts/clean_reproduction.ps1` in a fresh checkout.
|
|
70
|
+
|
|
71
|
+
After generating the public fixture and reference output, the package CLI can
|
|
72
|
+
execute the ONNX CPU adapter directly:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
python -m inference_artifact_lab examples/squeezenet11-torchvision.manifest.json `
|
|
76
|
+
--runtime onnx-cpu --inputs-npy artifacts/squeezenet11-fixture.npy `
|
|
77
|
+
--reference-output reference-output.json --environment environment.json `
|
|
78
|
+
--report reports/phase-1/cli-onnx-cpu.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The authoritative development documentation follows the same phase/stage model
|
|
82
|
+
used by the other portfolio repositories. Start at the [Codex document index](docs/codex/README.md), then read the [product contract](docs/codex/product-contract.md) and [Phase 1 plan](docs/codex/phases/phase-1-model-release-gate/README.md).
|
|
83
|
+
|
|
84
|
+
For the human-readable brief and clean-room record, see [Product Requirements](docs/product-requirements.md), [Acceptance Contract](docs/acceptance-contract.md), and [Clean-room Record](docs/clean-room-record.md).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "inference-artifact-lab"
|
|
3
|
+
version = "0.1.0.dev0"
|
|
4
|
+
description = "Reproducible validation gates for machine-learning inference artifacts"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [{name = "Ray Carter"}]
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
model-release-gate = "inference_artifact_lab.__main__:main"
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
test = ["pytest>=8.0"]
|
|
16
|
+
runtime-cpu = ["numpy>=1.26", "onnxruntime==1.30.0"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["setuptools>=75"]
|
|
20
|
+
build-backend = "setuptools.build_meta"
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Inference Artifact Lab package."""
|
|
2
|
+
|
|
3
|
+
from .gate import canonical_manifest_digest, current_environment, load_manifest, run_gate, run_gate_from_file, sha256_file
|
|
4
|
+
from .adapters import OnnxRuntimeAdapter, RuntimeUnavailable, TensorRTAdapter
|
|
5
|
+
from .models import ArtifactSpec, CheckResult, GateReport, GateStatus, Manifest, ManifestError, TensorSpec
|
|
6
|
+
from .release import run_release_gate
|
|
7
|
+
from .runtime import BenchmarkResult, benchmark, compare_outputs
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"__version__", "ArtifactSpec", "CheckResult", "GateReport", "GateStatus", "Manifest", "ManifestError", "TensorSpec",
|
|
11
|
+
"canonical_manifest_digest", "current_environment", "load_manifest", "run_gate", "run_gate_from_file", "run_release_gate", "sha256_file",
|
|
12
|
+
"BenchmarkResult", "benchmark", "compare_outputs", "OnnxRuntimeAdapter", "TensorRTAdapter", "RuntimeUnavailable",
|
|
13
|
+
]
|
|
14
|
+
__version__ = "0.1.0.dev0"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command line entry point: ``python -m inference_artifact_lab``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .gate import run_gate_from_file
|
|
12
|
+
from .release import run_release_gate
|
|
13
|
+
from .runtime import benchmark
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(description="Run core Model Release Gate checks")
|
|
18
|
+
parser.add_argument("manifest", help="path to a JSON manifest")
|
|
19
|
+
parser.add_argument("--artifact", help="artifact path (defaults to manifest artifact.path)")
|
|
20
|
+
parser.add_argument("--observed-contract", help="JSON file containing observed inputs and outputs")
|
|
21
|
+
parser.add_argument("--environment", help="JSON file containing the observed environment fingerprint")
|
|
22
|
+
parser.add_argument("--reference-output", help="JSON file containing reference runtime output")
|
|
23
|
+
parser.add_argument("--target-output", help="JSON file containing target runtime output")
|
|
24
|
+
parser.add_argument("--benchmark-evidence", help="JSON file containing benchmark evidence")
|
|
25
|
+
parser.add_argument("--report", help="write the release report JSON to this path")
|
|
26
|
+
parser.add_argument("--runtime", choices=["onnx-cpu"], help="run a real package adapter before composing the release report")
|
|
27
|
+
parser.add_argument("--inputs-npy", help="NumPy .npy input for the selected runtime adapter")
|
|
28
|
+
args = parser.parse_args(argv)
|
|
29
|
+
observed = None
|
|
30
|
+
if args.observed_contract:
|
|
31
|
+
with open(args.observed_contract, encoding="utf-8") as handle:
|
|
32
|
+
observed = json.load(handle)
|
|
33
|
+
environment = None
|
|
34
|
+
if args.environment:
|
|
35
|
+
with open(args.environment, encoding="utf-8") as handle:
|
|
36
|
+
environment = json.load(handle)
|
|
37
|
+
def load_json(path: str | None):
|
|
38
|
+
if not path:
|
|
39
|
+
return None
|
|
40
|
+
with open(path, encoding="utf-8") as handle:
|
|
41
|
+
return json.load(handle)
|
|
42
|
+
try:
|
|
43
|
+
reference = load_json(args.reference_output)
|
|
44
|
+
target = load_json(args.target_output)
|
|
45
|
+
benchmark_evidence = load_json(args.benchmark_evidence)
|
|
46
|
+
if args.runtime:
|
|
47
|
+
if not args.inputs_npy or reference is None or environment is None:
|
|
48
|
+
raise ValueError("--runtime requires --inputs-npy, --reference-output, and --environment")
|
|
49
|
+
if args.runtime != "onnx-cpu": # argparse currently prevents this; retain an explicit guard.
|
|
50
|
+
raise ValueError(f"unsupported runtime {args.runtime}")
|
|
51
|
+
import numpy as np
|
|
52
|
+
from .adapters import OnnxRuntimeAdapter
|
|
53
|
+
from .gate import load_manifest
|
|
54
|
+
manifest = load_manifest(args.manifest)
|
|
55
|
+
manifest_file = Path(args.manifest)
|
|
56
|
+
artifact = Path(args.artifact) if args.artifact else Path(manifest.artifact.path)
|
|
57
|
+
if not artifact.is_absolute():
|
|
58
|
+
artifact = Path(os.path.normpath(str(manifest_file.parent / artifact)))
|
|
59
|
+
values = np.load(args.inputs_npy, allow_pickle=False)
|
|
60
|
+
adapter = OnnxRuntimeAdapter(artifact, providers=["CPUExecutionProvider"])
|
|
61
|
+
output_name = adapter.contract()["outputs"][0]["name"]
|
|
62
|
+
input_name = adapter.contract()["inputs"][0]["name"]
|
|
63
|
+
target = adapter.run({input_name: values})[output_name]
|
|
64
|
+
reference_value = reference.get(output_name) if isinstance(reference, dict) else reference
|
|
65
|
+
measured = benchmark(lambda: adapter.run({input_name: values}), warmup_runs=1, measured_runs=5)
|
|
66
|
+
report = run_release_gate(manifest, artifact, observed_contract=adapter.contract(), observed_environment=environment, reference_outputs=reference_value, target_outputs=target, benchmark_evidence=measured.to_dict())
|
|
67
|
+
elif reference is not None or target is not None or benchmark_evidence is not None:
|
|
68
|
+
from .gate import load_manifest
|
|
69
|
+
manifest = load_manifest(args.manifest)
|
|
70
|
+
report = run_release_gate(manifest, args.artifact, observed_contract=observed, observed_environment=environment, reference_outputs=reference, target_outputs=target, benchmark_evidence=benchmark_evidence)
|
|
71
|
+
else:
|
|
72
|
+
report = run_gate_from_file(args.manifest, args.artifact, observed_contract=observed, observed_environment=environment)
|
|
73
|
+
except (OSError, ValueError) as exc:
|
|
74
|
+
print(json.dumps({"status": "fail", "error": str(exc)}), file=sys.stdout)
|
|
75
|
+
return 2
|
|
76
|
+
rendered = json.dumps(report.to_dict(), indent=2, sort_keys=True)
|
|
77
|
+
if args.report:
|
|
78
|
+
with open(args.report, "w", encoding="utf-8") as handle:
|
|
79
|
+
handle.write(rendered + "\n")
|
|
80
|
+
print(rendered)
|
|
81
|
+
return 0 if report.status.value == "pass" else 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Optional runtime adapters used by the Phase 1 evidence harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import platform
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuntimeUnavailable(RuntimeError):
|
|
12
|
+
"""Raised when a declared runtime dependency is not installed."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OnnxRuntimeAdapter:
|
|
16
|
+
"""Small ONNX Runtime adapter with no dependency on the gate domain."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, artifact: str | Path, *, providers: list[str] | None = None) -> None:
|
|
19
|
+
try:
|
|
20
|
+
import onnxruntime as ort
|
|
21
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
22
|
+
raise RuntimeUnavailable("install the runtime-cpu extra for ONNX Runtime") from exc
|
|
23
|
+
self._session = ort.InferenceSession(str(artifact), providers=providers)
|
|
24
|
+
self._ort_version = ort.__version__
|
|
25
|
+
self._requested_providers = tuple(providers) if providers is not None else None
|
|
26
|
+
|
|
27
|
+
def contract(self) -> dict[str, list[dict[str, Any]]]:
|
|
28
|
+
def describe(value: Any) -> dict[str, Any]:
|
|
29
|
+
return {"name": value.name, "dtype": value.type, "shape": list(value.shape)}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"artifact_format": "onnx",
|
|
33
|
+
"inputs": [describe(value) for value in self._session.get_inputs()],
|
|
34
|
+
"outputs": [describe(value) for value in self._session.get_outputs()],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def runtime_info(self) -> dict[str, Any]:
|
|
38
|
+
"""Return selected providers, including an explicit fallback provider."""
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"runtime": "onnxruntime",
|
|
42
|
+
"version": self._ort_version,
|
|
43
|
+
"requested_providers": list(self._requested_providers) if self._requested_providers is not None else None,
|
|
44
|
+
"providers": list(self._session.get_providers()),
|
|
45
|
+
"python": platform.python_version(),
|
|
46
|
+
"system": platform.system(),
|
|
47
|
+
"machine": platform.machine(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def run(self, inputs: Mapping[str, Any]) -> dict[str, Any]:
|
|
51
|
+
values = self._session.run(None, dict(inputs))
|
|
52
|
+
return {
|
|
53
|
+
spec["name"]: value.tolist() if hasattr(value, "tolist") else value
|
|
54
|
+
for spec, value in zip(self.contract()["outputs"], values)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _CtypesCudaRuntime:
|
|
59
|
+
"""Minimal libcudart binding used when the optional cuda-python is absent."""
|
|
60
|
+
|
|
61
|
+
binding_name = "ctypes-libcudart"
|
|
62
|
+
|
|
63
|
+
class _MemcpyKind:
|
|
64
|
+
cudaMemcpyHostToDevice = 1
|
|
65
|
+
cudaMemcpyDeviceToHost = 2
|
|
66
|
+
|
|
67
|
+
cudaMemcpyKind = _MemcpyKind
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
last_error: OSError | None = None
|
|
71
|
+
for name in ("libcudart.so", "libcudart.so.12", "cudart64_*.dll"):
|
|
72
|
+
try:
|
|
73
|
+
self._lib = ctypes.CDLL(name)
|
|
74
|
+
break
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
last_error = exc
|
|
77
|
+
else:
|
|
78
|
+
raise RuntimeUnavailable("TensorRTAdapter requires libcudart or cuda-python") from last_error
|
|
79
|
+
self._configure()
|
|
80
|
+
|
|
81
|
+
def _configure(self) -> None:
|
|
82
|
+
ptr, size = ctypes.c_void_p, ctypes.c_size_t
|
|
83
|
+
self._lib.cudaStreamCreate.argtypes = [ctypes.POINTER(ptr)]
|
|
84
|
+
self._lib.cudaStreamCreate.restype = ctypes.c_int
|
|
85
|
+
self._lib.cudaStreamDestroy.argtypes = [ptr]
|
|
86
|
+
self._lib.cudaStreamDestroy.restype = ctypes.c_int
|
|
87
|
+
self._lib.cudaStreamSynchronize.argtypes = [ptr]
|
|
88
|
+
self._lib.cudaStreamSynchronize.restype = ctypes.c_int
|
|
89
|
+
self._lib.cudaMalloc.argtypes = [ctypes.POINTER(ptr), size]
|
|
90
|
+
self._lib.cudaMalloc.restype = ctypes.c_int
|
|
91
|
+
self._lib.cudaFree.argtypes = [ptr]
|
|
92
|
+
self._lib.cudaFree.restype = ctypes.c_int
|
|
93
|
+
self._lib.cudaMemcpyAsync.argtypes = [ptr, ptr, size, ctypes.c_int, ptr]
|
|
94
|
+
self._lib.cudaMemcpyAsync.restype = ctypes.c_int
|
|
95
|
+
|
|
96
|
+
def cudaStreamCreate(self) -> tuple[int, int | None]:
|
|
97
|
+
value = ctypes.c_void_p()
|
|
98
|
+
return self._lib.cudaStreamCreate(ctypes.byref(value)), value.value
|
|
99
|
+
|
|
100
|
+
def cudaStreamDestroy(self, stream: int | None) -> int:
|
|
101
|
+
return self._lib.cudaStreamDestroy(ctypes.c_void_p(stream))
|
|
102
|
+
|
|
103
|
+
def cudaStreamSynchronize(self, stream: int | None) -> int:
|
|
104
|
+
return self._lib.cudaStreamSynchronize(ctypes.c_void_p(stream))
|
|
105
|
+
|
|
106
|
+
def cudaMalloc(self, size: int) -> tuple[int, int | None]:
|
|
107
|
+
value = ctypes.c_void_p()
|
|
108
|
+
return self._lib.cudaMalloc(ctypes.byref(value), ctypes.c_size_t(size)), value.value
|
|
109
|
+
|
|
110
|
+
def cudaFree(self, device: int | None) -> int:
|
|
111
|
+
return self._lib.cudaFree(ctypes.c_void_p(device))
|
|
112
|
+
|
|
113
|
+
def cudaMemcpyAsync(self, destination: int, source: int, size: int, kind: int, stream: int | None) -> int:
|
|
114
|
+
return self._lib.cudaMemcpyAsync(ctypes.c_void_p(destination), ctypes.c_void_p(source), ctypes.c_size_t(size), int(kind), ctypes.c_void_p(stream))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_cuda_runtime() -> Any:
|
|
118
|
+
try:
|
|
119
|
+
from cuda import cudart # type: ignore[import-not-found]
|
|
120
|
+
except ImportError:
|
|
121
|
+
return _CtypesCudaRuntime()
|
|
122
|
+
# Kept as evidence in runtime_info; provider selection is never implicit.
|
|
123
|
+
try:
|
|
124
|
+
cudart.binding_name = "cuda-python" # type: ignore[attr-defined]
|
|
125
|
+
except AttributeError:
|
|
126
|
+
pass
|
|
127
|
+
return cudart
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _status_is_success(status: Any) -> bool:
|
|
131
|
+
value = getattr(status, "value", status)
|
|
132
|
+
if isinstance(value, bool):
|
|
133
|
+
return value
|
|
134
|
+
if isinstance(value, int):
|
|
135
|
+
return value == 0
|
|
136
|
+
name = str(getattr(status, "name", status)).lower()
|
|
137
|
+
return name in {"success", "cudasuccess", "cuda_success"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_cuda(result: Any, operation: str) -> Any:
|
|
141
|
+
"""Check cuda-python/libcudart status and return an optional payload."""
|
|
142
|
+
|
|
143
|
+
if isinstance(result, tuple):
|
|
144
|
+
status, payload = result[0], result[1] if len(result) > 1 else None
|
|
145
|
+
else:
|
|
146
|
+
status, payload = result, None
|
|
147
|
+
if not _status_is_success(status):
|
|
148
|
+
raise RuntimeError(f"{operation} failed with CUDA status {status!r}")
|
|
149
|
+
return payload
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TensorRTAdapter:
|
|
153
|
+
"""TensorRT engine adapter with explicit CUDA ownership and validation."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, artifact: str | Path, *, trt_module: Any | None = None, cuda_runtime: Any | None = None, numpy_module: Any | None = None) -> None:
|
|
156
|
+
try:
|
|
157
|
+
trt = trt_module or __import__("tensorrt")
|
|
158
|
+
np = numpy_module or __import__("numpy")
|
|
159
|
+
except ImportError as exc: # pragma: no cover - optional container dependency
|
|
160
|
+
raise RuntimeUnavailable("TensorRTAdapter requires the pinned NVIDIA TensorRT container") from exc
|
|
161
|
+
self._trt, self._np = trt, np
|
|
162
|
+
self._cuda = cuda_runtime or _load_cuda_runtime()
|
|
163
|
+
self._logger = trt.Logger(trt.Logger.ERROR)
|
|
164
|
+
with Path(artifact).open("rb") as handle:
|
|
165
|
+
self._runtime = trt.Runtime(self._logger)
|
|
166
|
+
self._engine = self._runtime.deserialize_cuda_engine(handle.read())
|
|
167
|
+
if self._engine is None:
|
|
168
|
+
raise RuntimeError("TensorRT engine deserialization failed")
|
|
169
|
+
self._context = self._engine.create_execution_context()
|
|
170
|
+
if self._context is None:
|
|
171
|
+
raise RuntimeError("TensorRT execution context creation failed")
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _dtype_name(dtype: Any, *, np: Any, trt: Any) -> str:
|
|
175
|
+
try:
|
|
176
|
+
value = np.dtype(trt.nptype(dtype))
|
|
177
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
178
|
+
raise RuntimeError(f"unsupported TensorRT tensor dtype {dtype!r}") from exc
|
|
179
|
+
names = {"float32": "float", "float16": "float16", "float64": "double", "int8": "int8", "int16": "int16", "int32": "int32", "int64": "int64", "uint8": "uint8", "uint16": "uint16", "uint32": "uint32", "uint64": "uint64", "bool": "bool"}
|
|
180
|
+
try:
|
|
181
|
+
return f"tensor({names[value.name]})"
|
|
182
|
+
except KeyError as exc:
|
|
183
|
+
raise RuntimeError(f"unsupported TensorRT tensor dtype {value}") from exc
|
|
184
|
+
|
|
185
|
+
def _tensor_shape(self, name: str) -> list[int | None]:
|
|
186
|
+
return [None if int(dimension) < 0 else int(dimension) for dimension in self._engine.get_tensor_shape(name)]
|
|
187
|
+
|
|
188
|
+
def contract(self) -> dict[str, list[dict[str, Any]]]:
|
|
189
|
+
inputs: list[dict[str, Any]] = []
|
|
190
|
+
outputs: list[dict[str, Any]] = []
|
|
191
|
+
for index in range(self._engine.num_io_tensors):
|
|
192
|
+
name = self._engine.get_tensor_name(index)
|
|
193
|
+
value = {"name": name, "dtype": self._dtype_name(self._engine.get_tensor_dtype(name), np=self._np, trt=self._trt), "shape": self._tensor_shape(name)}
|
|
194
|
+
target = inputs if self._engine.get_tensor_mode(name) == self._trt.TensorIOMode.INPUT else outputs
|
|
195
|
+
target.append(value)
|
|
196
|
+
return {"artifact_format": "tensorrt-engine", "inputs": inputs, "outputs": outputs}
|
|
197
|
+
|
|
198
|
+
def optimization_profiles(self) -> list[dict[str, Any]]:
|
|
199
|
+
"""Return engine profile bounds without inventing a manifest schema."""
|
|
200
|
+
count = int(getattr(self._engine, "num_optimization_profiles", 0) or 0)
|
|
201
|
+
profiles: list[dict[str, Any]] = []
|
|
202
|
+
for profile_index in range(count):
|
|
203
|
+
tensors: dict[str, dict[str, list[int]]] = {}
|
|
204
|
+
for index in range(self._engine.num_io_tensors):
|
|
205
|
+
name = self._engine.get_tensor_name(index)
|
|
206
|
+
if self._engine.get_tensor_mode(name) != self._trt.TensorIOMode.INPUT:
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
minimum, optimum, maximum = self._engine.get_tensor_profile_shape(name, profile_index)
|
|
210
|
+
except (AttributeError, RuntimeError):
|
|
211
|
+
continue
|
|
212
|
+
tensors[name] = {"min": list(minimum), "opt": list(optimum), "max": list(maximum)}
|
|
213
|
+
profiles.append({"index": profile_index, "inputs": tensors})
|
|
214
|
+
return profiles
|
|
215
|
+
|
|
216
|
+
def runtime_info(self) -> dict[str, Any]:
|
|
217
|
+
return {"runtime": "tensorrt", "tensorrt_version": getattr(self._trt, "__version__", None), "cuda_binding": getattr(self._cuda, "binding_name", type(self._cuda).__name__), "optimization_profiles": self.optimization_profiles(), "python": platform.python_version(), "system": platform.system(), "machine": platform.machine()}
|
|
218
|
+
|
|
219
|
+
def _names(self, mode: Any) -> list[str]:
|
|
220
|
+
return [self._engine.get_tensor_name(index) for index in range(self._engine.num_io_tensors) if self._engine.get_tensor_mode(self._engine.get_tensor_name(index)) == mode]
|
|
221
|
+
|
|
222
|
+
def run(self, inputs: Mapping[str, Any]) -> dict[str, Any]:
|
|
223
|
+
expected_inputs, expected_outputs = self._names(self._trt.TensorIOMode.INPUT), self._names(self._trt.TensorIOMode.OUTPUT)
|
|
224
|
+
missing, extra = [name for name in expected_inputs if name not in inputs], [name for name in inputs if name not in expected_inputs]
|
|
225
|
+
if missing or extra:
|
|
226
|
+
raise ValueError(f"TensorRT inputs do not match engine (missing={missing}, extra={extra})")
|
|
227
|
+
prepared: dict[str, Any] = {}
|
|
228
|
+
for name in expected_inputs:
|
|
229
|
+
value = self._np.asarray(inputs[name])
|
|
230
|
+
expected_dtype = self._np.dtype(self._trt.nptype(self._engine.get_tensor_dtype(name)))
|
|
231
|
+
if value.dtype != expected_dtype:
|
|
232
|
+
raise TypeError(f"input {name!r} has dtype {value.dtype}, expected {expected_dtype}")
|
|
233
|
+
if not value.flags.c_contiguous:
|
|
234
|
+
raise ValueError(f"input {name!r} must be C-contiguous")
|
|
235
|
+
engine_shape = self._engine.get_tensor_shape(name)
|
|
236
|
+
if len(value.shape) != len(engine_shape) or any(int(want) >= 0 and int(want) != got for want, got in zip(engine_shape, value.shape)):
|
|
237
|
+
raise ValueError(f"input {name!r} shape {tuple(value.shape)} does not satisfy engine shape {tuple(engine_shape)}")
|
|
238
|
+
prepared[name] = value
|
|
239
|
+
if self._context.set_input_shape(name, tuple(value.shape)) is False:
|
|
240
|
+
raise ValueError(f"TensorRT rejected input shape for {name!r}: {tuple(value.shape)}")
|
|
241
|
+
|
|
242
|
+
allocations: list[tuple[str, int, Any]] = []
|
|
243
|
+
stream: Any = None
|
|
244
|
+
synchronized, active_error = False, None
|
|
245
|
+
try:
|
|
246
|
+
stream = _check_cuda(self._cuda.cudaStreamCreate(), "cudaStreamCreate")
|
|
247
|
+
if stream is None:
|
|
248
|
+
raise RuntimeError("cudaStreamCreate returned a null stream")
|
|
249
|
+
for name in expected_inputs + expected_outputs:
|
|
250
|
+
if name in prepared:
|
|
251
|
+
value = prepared[name]
|
|
252
|
+
else:
|
|
253
|
+
shape = tuple(int(dimension) for dimension in self._context.get_tensor_shape(name))
|
|
254
|
+
if any(dimension < 0 for dimension in shape):
|
|
255
|
+
raise ValueError(f"TensorRT output {name!r} has unresolved dynamic shape {shape}")
|
|
256
|
+
value = self._np.empty(shape, dtype=self._np.dtype(self._trt.nptype(self._engine.get_tensor_dtype(name))))
|
|
257
|
+
if value.nbytes <= 0:
|
|
258
|
+
raise ValueError(f"TensorRT tensor {name!r} has an empty buffer")
|
|
259
|
+
device = _check_cuda(self._cuda.cudaMalloc(value.nbytes), f"cudaMalloc({name})")
|
|
260
|
+
if not device:
|
|
261
|
+
raise RuntimeError(f"cudaMalloc({name}) returned a null pointer")
|
|
262
|
+
allocations.append((name, int(device), value))
|
|
263
|
+
self._context.set_tensor_address(name, int(device))
|
|
264
|
+
if name in prepared:
|
|
265
|
+
_check_cuda(self._cuda.cudaMemcpyAsync(int(device), int(value.ctypes.data), value.nbytes, self._cuda.cudaMemcpyKind.cudaMemcpyHostToDevice, stream), f"cudaMemcpyAsync(HtoD:{name})")
|
|
266
|
+
if self._context.execute_async_v3(stream_handle=stream) is False:
|
|
267
|
+
raise RuntimeError("TensorRT execution failed")
|
|
268
|
+
for name, device, value in allocations:
|
|
269
|
+
if name not in expected_outputs:
|
|
270
|
+
continue
|
|
271
|
+
_check_cuda(self._cuda.cudaMemcpyAsync(int(value.ctypes.data), device, value.nbytes, self._cuda.cudaMemcpyKind.cudaMemcpyDeviceToHost, stream), f"cudaMemcpyAsync(DtoH:{name})")
|
|
272
|
+
_check_cuda(self._cuda.cudaStreamSynchronize(stream), "cudaStreamSynchronize")
|
|
273
|
+
synchronized = True
|
|
274
|
+
return {name: value.tolist() for name, _, value in allocations if name in expected_outputs}
|
|
275
|
+
except BaseException as exc:
|
|
276
|
+
active_error = exc
|
|
277
|
+
raise
|
|
278
|
+
finally:
|
|
279
|
+
cleanup_errors: list[str] = []
|
|
280
|
+
if stream is not None and not synchronized:
|
|
281
|
+
try:
|
|
282
|
+
_check_cuda(self._cuda.cudaStreamSynchronize(stream), "cudaStreamSynchronize(cleanup)")
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
cleanup_errors.append(str(exc))
|
|
285
|
+
for name, device, _ in reversed(allocations):
|
|
286
|
+
try:
|
|
287
|
+
_check_cuda(self._cuda.cudaFree(device), f"cudaFree({name})")
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
cleanup_errors.append(str(exc))
|
|
290
|
+
if stream is not None:
|
|
291
|
+
try:
|
|
292
|
+
_check_cuda(self._cuda.cudaStreamDestroy(stream), "cudaStreamDestroy")
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
cleanup_errors.append(str(exc))
|
|
295
|
+
if active_error is None and cleanup_errors:
|
|
296
|
+
raise RuntimeError("TensorRT CUDA cleanup failed: " + "; ".join(cleanup_errors))
|