downshift-server 0.2.0__py3-none-any.whl
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.
- downshift/__init__.py +60 -0
- downshift/adapters/__init__.py +0 -0
- downshift/adapters/_flatten.py +40 -0
- downshift/adapters/base.py +47 -0
- downshift/adapters/generic.py +99 -0
- downshift/adapters/hf.py +95 -0
- downshift/adapters/pyg.py +120 -0
- downshift/adapters/registry.py +116 -0
- downshift/cli/__init__.py +0 -0
- downshift/cli/main.py +428 -0
- downshift/cli/render.py +174 -0
- downshift/export/__init__.py +0 -0
- downshift/export/capture.py +93 -0
- downshift/export/inputs.py +25 -0
- downshift/export/manifest.py +89 -0
- downshift/export/prevalidated.py +70 -0
- downshift/export/shapes.py +61 -0
- downshift/export/verdict.py +211 -0
- downshift/export/verify.py +162 -0
- downshift/loading.py +166 -0
- downshift/serve/__init__.py +0 -0
- downshift/serve/app.py +93 -0
- downshift/serve/backends.py +181 -0
- downshift/serve/engine.py +154 -0
- downshift/serve/middleware.py +24 -0
- downshift/serve/schemas.py +124 -0
- downshift/settings.py +69 -0
- downshift_server-0.2.0.dist-info/METADATA +265 -0
- downshift_server-0.2.0.dist-info/RECORD +32 -0
- downshift_server-0.2.0.dist-info/WHEEL +4 -0
- downshift_server-0.2.0.dist-info/entry_points.txt +7 -0
- downshift_server-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Example-input synthesis ladder.
|
|
2
|
+
|
|
3
|
+
1. user-supplied always wins
|
|
4
|
+
2. adapter-derived the adapter knows its family (HF config, PyG in_channels, ...)
|
|
5
|
+
3. signature guess the generic adapter's first-Linear/Conv heuristic
|
|
6
|
+
4. fail loudly say exactly what to pass
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from torch import nn
|
|
10
|
+
|
|
11
|
+
from downshift.adapters.base import Adapter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def synthesize(model: nn.Module, adapter: Adapter, user_inputs: tuple | None) -> tuple:
|
|
15
|
+
if user_inputs is not None:
|
|
16
|
+
return user_inputs
|
|
17
|
+
guessed = adapter.example_inputs(model)
|
|
18
|
+
if guessed is not None:
|
|
19
|
+
return guessed
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"Couldn't work out example inputs for {type(model).__name__} with the "
|
|
22
|
+
f"{adapter.name!r} adapter. Pass them explicitly: from Python, "
|
|
23
|
+
"check(model, example_inputs=(tensor, ...)); from the CLI, --inputs module:function "
|
|
24
|
+
"where the function returns a tuple of forward() arguments."
|
|
25
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Provenance sidecar written next to every exported .onnx."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import onnx
|
|
10
|
+
import onnxruntime
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
from downshift.export.verdict import ExportVerdict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _DTYPE_NAMES(IntEnum):
|
|
17
|
+
"""ONNX TensorProto dtype codes we can label, keyed by their short name."""
|
|
18
|
+
|
|
19
|
+
fp32 = onnx.TensorProto.FLOAT
|
|
20
|
+
fp16 = onnx.TensorProto.FLOAT16
|
|
21
|
+
fp64 = onnx.TensorProto.DOUBLE
|
|
22
|
+
bf16 = onnx.TensorProto.BFLOAT16
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# A plain value->name dict, so a miss is a dict lookup rather than an IntEnum ValueError;
|
|
26
|
+
# most initializers (int64 indices, bools, ...) are misses, and this runs in a loop.
|
|
27
|
+
_DTYPE_LOOKUP: dict[int, str] = {member.value: member.name for member in _DTYPE_NAMES}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sha256(path: Path) -> str:
|
|
31
|
+
digest = hashlib.sha256()
|
|
32
|
+
with path.open("rb") as f:
|
|
33
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
34
|
+
digest.update(chunk)
|
|
35
|
+
return digest.hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _dtype_name(code: int) -> str | None:
|
|
39
|
+
return _DTYPE_LOOKUP.get(code)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def observed_dtype(onnx_path: Path) -> str | None:
|
|
43
|
+
"""Float dtype of the graph's weights, i.e. what the export actually produced."""
|
|
44
|
+
proto = onnx.load(str(onnx_path), load_external_data=False)
|
|
45
|
+
for init in proto.graph.initializer:
|
|
46
|
+
name = _dtype_name(init.data_type)
|
|
47
|
+
if name is not None:
|
|
48
|
+
return name
|
|
49
|
+
for inp in proto.graph.input:
|
|
50
|
+
name = _dtype_name(inp.type.tensor_type.elem_type)
|
|
51
|
+
if name is not None:
|
|
52
|
+
return name
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_manifest(
|
|
57
|
+
onnx_path: Path, verdict: ExportVerdict, source_path: Path | None, package_version: str
|
|
58
|
+
) -> dict:
|
|
59
|
+
return {
|
|
60
|
+
"downshift_version": package_version,
|
|
61
|
+
"created_utc": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
62
|
+
"onnx_file": onnx_path.name,
|
|
63
|
+
"onnx_sha256": _sha256(onnx_path),
|
|
64
|
+
"source_model": str(source_path) if source_path else None,
|
|
65
|
+
"source_sha256": _sha256(source_path) if source_path and source_path.is_file() else None,
|
|
66
|
+
"versions": {
|
|
67
|
+
"torch": torch.__version__,
|
|
68
|
+
"onnx": onnx.__version__,
|
|
69
|
+
"onnxruntime": onnxruntime.__version__,
|
|
70
|
+
},
|
|
71
|
+
"opset": verdict.opset,
|
|
72
|
+
"observed_dtype": observed_dtype(onnx_path),
|
|
73
|
+
"execution_providers_available": onnxruntime.get_available_providers(),
|
|
74
|
+
"verdict": verdict.to_dict(),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def manifest_path_for(onnx_path: Path) -> Path:
|
|
79
|
+
return onnx_path.with_suffix(".manifest.json")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_manifest(
|
|
83
|
+
onnx_path: Path, verdict: ExportVerdict, source_path: Path | None, package_version: str
|
|
84
|
+
) -> Path:
|
|
85
|
+
path = manifest_path_for(onnx_path)
|
|
86
|
+
path.write_text(
|
|
87
|
+
json.dumps(build_manifest(onnx_path, verdict, source_path, package_version), indent=2)
|
|
88
|
+
)
|
|
89
|
+
return path
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Intake for a .onnx file someone else produced (Olive, a notebook, whatever).
|
|
2
|
+
|
|
3
|
+
No reference model -> UNVERIFIED. We serve it, we just say we never checked it.
|
|
4
|
+
With --reference -> the normal verify path, exactly as for a fresh export.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import onnx
|
|
10
|
+
import torch
|
|
11
|
+
|
|
12
|
+
from downshift.adapters.base import Adapter
|
|
13
|
+
from downshift.export.verdict import BackendName, ExportVerdict, numerics_outcome, prepare_model
|
|
14
|
+
from downshift.export.verify import verify
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _graph_summary(onnx_path: Path) -> tuple[int | None, list[str], tuple[str, ...]]:
|
|
18
|
+
proto = onnx.load(str(onnx_path), load_external_data=False)
|
|
19
|
+
opset = next((imp.version for imp in proto.opset_import if imp.domain in ("", "ai.onnx")), None)
|
|
20
|
+
initializers = {init.name for init in proto.graph.initializer}
|
|
21
|
+
input_names = tuple(i.name for i in proto.graph.input if i.name not in initializers)
|
|
22
|
+
return opset, [n.op_type for n in proto.graph.node], input_names
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def intake(
|
|
26
|
+
onnx_path: str | Path,
|
|
27
|
+
reference: torch.nn.Module | None = None,
|
|
28
|
+
example_inputs: tuple | None = None,
|
|
29
|
+
adapter: Adapter | str | None = None,
|
|
30
|
+
k: int = 8,
|
|
31
|
+
dynamic: dict[str, list[int]] | None = None,
|
|
32
|
+
) -> ExportVerdict:
|
|
33
|
+
onnx_path = Path(onnx_path)
|
|
34
|
+
opset, op_types, input_names = _graph_summary(onnx_path)
|
|
35
|
+
|
|
36
|
+
if reference is None:
|
|
37
|
+
return ExportVerdict(
|
|
38
|
+
status="UNVERIFIED",
|
|
39
|
+
model_family="onnx",
|
|
40
|
+
capture_strategy=None,
|
|
41
|
+
opset=opset,
|
|
42
|
+
op_types=op_types,
|
|
43
|
+
numerics=None,
|
|
44
|
+
recommended_backend=BackendName.onnxruntime,
|
|
45
|
+
reason="no reference model supplied; served as-is, numerics never checked",
|
|
46
|
+
input_names=input_names,
|
|
47
|
+
onnx_path=onnx_path,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
prepared = prepare_model(reference, example_inputs, adapter, dynamic)
|
|
51
|
+
numerics = verify(
|
|
52
|
+
prepared.model, onnx_path, prepared.inputs, prepared.dynamic_shapes, prepared.vary_fn, k=k
|
|
53
|
+
)
|
|
54
|
+
status, backend, reason = numerics_outcome(
|
|
55
|
+
numerics, "pre-built ONNX matches reference", "pre-built ONNX diverges from reference"
|
|
56
|
+
)
|
|
57
|
+
return ExportVerdict(
|
|
58
|
+
status=status,
|
|
59
|
+
model_family=prepared.family,
|
|
60
|
+
capture_strategy=None,
|
|
61
|
+
opset=opset,
|
|
62
|
+
op_types=op_types,
|
|
63
|
+
numerics=numerics,
|
|
64
|
+
recommended_backend=backend,
|
|
65
|
+
reason=reason,
|
|
66
|
+
input_names=prepared.input_names,
|
|
67
|
+
dynamic_dims=prepared.dynamic_dims,
|
|
68
|
+
onnx_path=onnx_path,
|
|
69
|
+
prepared=prepared,
|
|
70
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Dynamic-shape inference and the `--dynamic` override.
|
|
2
|
+
|
|
3
|
+
Default heuristic: axis 0 of every tensor input is dynamic and they all share one Dim
|
|
4
|
+
(the batch case). Adapters override this where it's wrong, e.g. PyG's independent N/E.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def alternative_sizes(base_size: int) -> list[int]:
|
|
11
|
+
"""Sizes to exercise a dynamic axis with, excluding the export-time size."""
|
|
12
|
+
return sorted({1, 2, 3, base_size + 1, base_size * 2} - {base_size})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def infer_dynamic_shapes(inputs: tuple) -> tuple:
|
|
16
|
+
dim0 = torch.export.Dim("dim0", min=1, max=1 << 16)
|
|
17
|
+
return tuple({0: dim0} if isinstance(t, torch.Tensor) and t.ndim > 0 else None for t in inputs)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_dynamic_spec(spec: str) -> dict[str, list[int]]:
|
|
21
|
+
"""Parse "x:0,edge_index:1" or "x:0:1" into {name: [axes]}."""
|
|
22
|
+
result: dict[str, list[int]] = {}
|
|
23
|
+
for item in filter(None, (s.strip() for s in spec.split(","))):
|
|
24
|
+
name, _, axes = item.partition(":")
|
|
25
|
+
if not name or not axes:
|
|
26
|
+
raise ValueError(f"bad --dynamic entry {item!r}; expected name:axis[:axis...]")
|
|
27
|
+
result.setdefault(name, []).extend(int(a) for a in axes.split(":"))
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply_dynamic_override(
|
|
32
|
+
input_names: tuple[str, ...], inputs: tuple, override: dict[str, list[int]]
|
|
33
|
+
) -> tuple:
|
|
34
|
+
"""Build a dynamic_shapes tuple from an explicit {name: [axes]} spec. Every
|
|
35
|
+
(name, axis) pair gets its own independent Dim."""
|
|
36
|
+
unknown = set(override) - set(input_names)
|
|
37
|
+
if unknown:
|
|
38
|
+
raise ValueError(f"--dynamic names {sorted(unknown)} not in inputs {list(input_names)}")
|
|
39
|
+
shapes: list[dict[int, torch.export.Dim] | None] = []
|
|
40
|
+
for name, tensor in zip(input_names, inputs, strict=True):
|
|
41
|
+
axes = override.get(name)
|
|
42
|
+
if not axes or not isinstance(tensor, torch.Tensor):
|
|
43
|
+
shapes.append(None)
|
|
44
|
+
continue
|
|
45
|
+
shapes.append(
|
|
46
|
+
{axis: torch.export.Dim(f"{name}_{axis}", min=1, max=1 << 16) for axis in axes}
|
|
47
|
+
)
|
|
48
|
+
return tuple(shapes)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def safe_capture_inputs(inputs: tuple, dynamic_shapes: tuple) -> tuple:
|
|
52
|
+
"""torch.export specialises a size-1 dim to a constant even when it's marked dynamic.
|
|
53
|
+
Double any such axis for the trace only; verification still uses the real sizes."""
|
|
54
|
+
safe = []
|
|
55
|
+
for t, spec in zip(inputs, dynamic_shapes, strict=True):
|
|
56
|
+
if isinstance(t, torch.Tensor) and spec:
|
|
57
|
+
for axis in spec:
|
|
58
|
+
if t.shape[axis] == 1:
|
|
59
|
+
t = torch.cat([t, t], dim=axis)
|
|
60
|
+
safe.append(t)
|
|
61
|
+
return tuple(safe)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""ExportVerdict: the one object everything else reads.
|
|
2
|
+
|
|
3
|
+
CLEAN exports, numerics match, survives shapes it wasn't traced on -> serve via ORT
|
|
4
|
+
DEGRADED exports but numerics drift past tolerance -> serve via torch
|
|
5
|
+
FAILED won't export -> serve via torch
|
|
6
|
+
UNVERIFIED a .onnx handed to us with no reference model -> serve via ORT, say so
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
|
|
17
|
+
from downshift.adapters import registry
|
|
18
|
+
from downshift.adapters.base import Adapter, Prepared
|
|
19
|
+
from downshift.export.capture import capture
|
|
20
|
+
from downshift.export.inputs import synthesize
|
|
21
|
+
from downshift.export.shapes import apply_dynamic_override, safe_capture_inputs
|
|
22
|
+
from downshift.export.verify import NumericsReport, verify
|
|
23
|
+
|
|
24
|
+
Status = Literal["CLEAN", "DEGRADED", "FAILED", "UNVERIFIED"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BackendName(str, Enum):
|
|
28
|
+
"""The concrete backends a verdict can recommend/serve; never "auto" (that's a CLI-only
|
|
29
|
+
selection sentinel, not a real backend) - see engine.BackendChoice."""
|
|
30
|
+
|
|
31
|
+
onnxruntime = "onnxruntime"
|
|
32
|
+
torch = "torch"
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
# Python 3.11 made str(Enum)/format(Enum) print "BackendName.onnxruntime" instead of
|
|
36
|
+
# the plain value for any (str, Enum) mixin that isn't ReprEnum; banners embed this
|
|
37
|
+
# in f-strings, so pin it back to the value.
|
|
38
|
+
return self.value
|
|
39
|
+
|
|
40
|
+
EXIT_CODES: dict[str, int] = {"CLEAN": 0, "FAILED": 1, "DEGRADED": 2, "UNVERIFIED": 3}
|
|
41
|
+
|
|
42
|
+
_ATEN_OP = re.compile(r"(?:torch\.ops\.)?aten\.(\w+)(?:\.\w+)?")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ExportVerdict:
|
|
47
|
+
status: Status
|
|
48
|
+
model_family: str
|
|
49
|
+
capture_strategy: str | None
|
|
50
|
+
opset: int | None
|
|
51
|
+
op_types: list[str]
|
|
52
|
+
numerics: NumericsReport | None
|
|
53
|
+
recommended_backend: BackendName
|
|
54
|
+
reason: str
|
|
55
|
+
input_names: tuple[str, ...] = ()
|
|
56
|
+
dynamic_dims: dict[str, list[int]] = field(default_factory=dict)
|
|
57
|
+
unsupported_ops: list[str] = field(default_factory=list)
|
|
58
|
+
warnings: list[str] = field(default_factory=list)
|
|
59
|
+
onnx_path: Path | None = None
|
|
60
|
+
onnx_program: object | None = field(default=None, repr=False) # torch.onnx.ONNXProgram
|
|
61
|
+
prepared: Prepared | None = field(default=None, repr=False)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def shape_generalization(self) -> bool | None:
|
|
65
|
+
return self.numerics.shape_generalization if self.numerics else None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def exit_code(self) -> int:
|
|
69
|
+
return EXIT_CODES[self.status]
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"status": self.status,
|
|
74
|
+
"model_family": self.model_family,
|
|
75
|
+
"capture_strategy": self.capture_strategy,
|
|
76
|
+
"opset": self.opset,
|
|
77
|
+
"op_types": self.op_types,
|
|
78
|
+
"numerics": self.numerics.to_dict() if self.numerics else None,
|
|
79
|
+
"shape_generalization": self.shape_generalization,
|
|
80
|
+
"recommended_backend": self.recommended_backend,
|
|
81
|
+
"reason": self.reason,
|
|
82
|
+
"input_names": list(self.input_names),
|
|
83
|
+
"dynamic_dims": self.dynamic_dims,
|
|
84
|
+
"unsupported_ops": self.unsupported_ops,
|
|
85
|
+
"warnings": self.warnings,
|
|
86
|
+
"onnx_path": str(self.onnx_path) if self.onnx_path else None,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def numerics_outcome(
|
|
91
|
+
numerics: NumericsReport, passed_prefix: str, failed_prefix: str
|
|
92
|
+
) -> tuple[Status, BackendName, str]:
|
|
93
|
+
"""Numerics decide the verdict: pass -> CLEAN via ORT, fail -> DEGRADED via torch.
|
|
94
|
+
|
|
95
|
+
The prefixes open the reason string; the sample counts and error are appended.
|
|
96
|
+
"""
|
|
97
|
+
err = f"(max abs err {numerics.max_abs_err:.2e})"
|
|
98
|
+
if numerics.passed:
|
|
99
|
+
reason = f"{passed_prefix} across {numerics.samples_tested} samples {err}"
|
|
100
|
+
return "CLEAN", BackendName.onnxruntime, reason
|
|
101
|
+
reason = f"{failed_prefix} on {numerics.failures}/{numerics.samples_tested} samples {err}"
|
|
102
|
+
return "DEGRADED", BackendName.torch, reason
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tied_weight_warnings(model: torch.nn.Module) -> list[str]:
|
|
106
|
+
seen: dict[int, str] = {}
|
|
107
|
+
tied: list[str] = []
|
|
108
|
+
for name, param in model.named_parameters(remove_duplicate=False):
|
|
109
|
+
first = seen.setdefault(id(param), name)
|
|
110
|
+
if first != name:
|
|
111
|
+
tied.append(f"tied weights: {name} shares storage with {first}")
|
|
112
|
+
return tied
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prepare_model(
|
|
116
|
+
model: torch.nn.Module,
|
|
117
|
+
example_inputs: tuple | None = None,
|
|
118
|
+
adapter: Adapter | str | None = None,
|
|
119
|
+
dynamic: dict[str, list[int]] | None = None,
|
|
120
|
+
) -> Prepared:
|
|
121
|
+
"""Pick an adapter, synthesise inputs if needed, and flatten into export form."""
|
|
122
|
+
if isinstance(adapter, str):
|
|
123
|
+
adapter = registry.get(adapter)
|
|
124
|
+
if adapter is None:
|
|
125
|
+
adapter = registry.detect(model, example_inputs)
|
|
126
|
+
example_inputs = synthesize(model, adapter, example_inputs)
|
|
127
|
+
prepared = adapter.prepare(model, example_inputs)
|
|
128
|
+
if dynamic:
|
|
129
|
+
prepared.dynamic_shapes = apply_dynamic_override(
|
|
130
|
+
prepared.input_names, prepared.inputs, dynamic
|
|
131
|
+
)
|
|
132
|
+
return prepared
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_verdict(prepared: Prepared, k: int = 8, verify_numerics: bool = True) -> ExportVerdict:
|
|
136
|
+
"""Capture, then verify. verify_numerics=False is the --no-verify escape hatch: the
|
|
137
|
+
graph is still produced but the verdict is UNVERIFIED, never CLEAN."""
|
|
138
|
+
warnings = _tied_weight_warnings(prepared.model)
|
|
139
|
+
if prepared.model.training:
|
|
140
|
+
warnings.append("model was in training mode; switched to eval() for export")
|
|
141
|
+
prepared.model.eval()
|
|
142
|
+
|
|
143
|
+
result = capture(
|
|
144
|
+
prepared.model,
|
|
145
|
+
safe_capture_inputs(prepared.inputs, prepared.dynamic_shapes),
|
|
146
|
+
prepared.dynamic_shapes,
|
|
147
|
+
)
|
|
148
|
+
verdict = ExportVerdict(
|
|
149
|
+
status="FAILED",
|
|
150
|
+
model_family=prepared.family,
|
|
151
|
+
capture_strategy=result.capture_strategy,
|
|
152
|
+
opset=result.opset,
|
|
153
|
+
op_types=result.op_types,
|
|
154
|
+
numerics=None,
|
|
155
|
+
recommended_backend=BackendName.torch,
|
|
156
|
+
reason="",
|
|
157
|
+
input_names=prepared.input_names,
|
|
158
|
+
dynamic_dims=prepared.dynamic_dims,
|
|
159
|
+
warnings=warnings,
|
|
160
|
+
onnx_program=result.onnx_program,
|
|
161
|
+
prepared=prepared,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if not result.success:
|
|
165
|
+
exc = result.exception
|
|
166
|
+
message = f"{type(exc).__name__}: {exc}" if exc is not None else "export failed"
|
|
167
|
+
verdict.reason = message.splitlines()[0]
|
|
168
|
+
verdict.unsupported_ops = sorted(set(_ATEN_OP.findall(message)))
|
|
169
|
+
return verdict
|
|
170
|
+
|
|
171
|
+
if not verify_numerics:
|
|
172
|
+
verdict.status, verdict.recommended_backend = "UNVERIFIED", BackendName.onnxruntime
|
|
173
|
+
verdict.reason = f"exported via {result.capture_strategy}; numerics never checked"
|
|
174
|
+
return verdict
|
|
175
|
+
|
|
176
|
+
numerics = verify(
|
|
177
|
+
prepared.model,
|
|
178
|
+
result.onnx_program,
|
|
179
|
+
prepared.inputs,
|
|
180
|
+
prepared.dynamic_shapes,
|
|
181
|
+
vary_fn=prepared.vary_fn,
|
|
182
|
+
k=k,
|
|
183
|
+
)
|
|
184
|
+
verdict.numerics = numerics
|
|
185
|
+
verdict.status, verdict.recommended_backend, verdict.reason = numerics_outcome(
|
|
186
|
+
numerics,
|
|
187
|
+
f"exported via {result.capture_strategy}; numerics ok",
|
|
188
|
+
f"exported via {result.capture_strategy} but numerics diverge",
|
|
189
|
+
)
|
|
190
|
+
return verdict
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def check(
|
|
194
|
+
model: torch.nn.Module,
|
|
195
|
+
example_inputs: tuple | None = None,
|
|
196
|
+
k: int = 8,
|
|
197
|
+
adapter: Adapter | str | None = None,
|
|
198
|
+
dynamic: dict[str, list[int]] | None = None,
|
|
199
|
+
fp16: bool = False,
|
|
200
|
+
verify_numerics: bool = True,
|
|
201
|
+
) -> ExportVerdict:
|
|
202
|
+
"""Export in memory, verify, and return the verdict. Writes nothing to disk."""
|
|
203
|
+
if fp16:
|
|
204
|
+
model = model.half()
|
|
205
|
+
if example_inputs is not None:
|
|
206
|
+
example_inputs = tuple(
|
|
207
|
+
t.half() if isinstance(t, torch.Tensor) and t.is_floating_point() else t
|
|
208
|
+
for t in example_inputs
|
|
209
|
+
)
|
|
210
|
+
prepared = prepare_model(model, example_inputs, adapter, dynamic)
|
|
211
|
+
return build_verdict(prepared, k=k, verify_numerics=verify_numerics)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Numerical verification. Mandatory: an export isn't a success until this passes.
|
|
2
|
+
|
|
3
|
+
Runs K samples through the torch model and the ONNX graph, varying dynamic dims so at
|
|
4
|
+
least some samples have shapes the exporter never saw. That's what catches a graph that
|
|
5
|
+
traced fine but froze a shape or specialised a data-dependent branch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import onnxruntime as ort
|
|
14
|
+
import torch
|
|
15
|
+
|
|
16
|
+
from downshift.adapters.base import VaryFn
|
|
17
|
+
from downshift.export.shapes import alternative_sizes
|
|
18
|
+
from downshift.settings import TOLERANCES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class NumericsReport:
|
|
23
|
+
samples_tested: int
|
|
24
|
+
max_abs_err: float
|
|
25
|
+
max_rel_err: float
|
|
26
|
+
failures: int
|
|
27
|
+
shape_generalization: bool # did every non-baseline-shape sample also pass?
|
|
28
|
+
tolerance_abs: float
|
|
29
|
+
tolerance_rel: float
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def passed(self) -> bool:
|
|
33
|
+
return self.failures == 0
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return asdict(self) | {"passed": self.passed}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_tolerances(model: torch.nn.Module) -> tuple[float, float]:
|
|
40
|
+
dtypes = {p.dtype for p in model.parameters() if p.is_floating_point()}
|
|
41
|
+
for dtype in (torch.bfloat16, torch.float16):
|
|
42
|
+
if dtype in dtypes:
|
|
43
|
+
return TOLERANCES[dtype]
|
|
44
|
+
return TOLERANCES[torch.float32]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resize_dim0(tensor: torch.Tensor, new_size: int) -> torch.Tensor:
|
|
48
|
+
if tensor.ndim == 0 or tensor.shape[0] == new_size:
|
|
49
|
+
return tensor
|
|
50
|
+
shape = list(tensor.shape)
|
|
51
|
+
shape[0] = new_size
|
|
52
|
+
if tensor.is_floating_point():
|
|
53
|
+
return torch.randn(*shape, dtype=tensor.dtype)
|
|
54
|
+
# Integer inputs are usually indices; stay inside the observed range.
|
|
55
|
+
lo = int(tensor.min().item())
|
|
56
|
+
hi = max(int(tensor.max().item()) + 1, lo + 1)
|
|
57
|
+
return torch.randint(lo, hi, shape, dtype=tensor.dtype)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def make_shared_axis0_vary_fn(base_inputs: tuple, dynamic_shapes: tuple, seed: int = 0) -> VaryFn:
|
|
61
|
+
"""Default sampler: every dynamic tensor shares one axis-0 size (the batch case)."""
|
|
62
|
+
base_size = next(
|
|
63
|
+
(t.shape[0] for t, spec in zip(base_inputs, dynamic_shapes, strict=True) if spec),
|
|
64
|
+
None,
|
|
65
|
+
)
|
|
66
|
+
rng = random.Random(seed)
|
|
67
|
+
candidates = alternative_sizes(base_size) if base_size is not None else []
|
|
68
|
+
|
|
69
|
+
def vary(i: int) -> tuple:
|
|
70
|
+
if i == 0 or not candidates:
|
|
71
|
+
return base_inputs
|
|
72
|
+
size = rng.choice(candidates)
|
|
73
|
+
return tuple(
|
|
74
|
+
_resize_dim0(t, size) if isinstance(t, torch.Tensor) and spec else t
|
|
75
|
+
for t, spec in zip(base_inputs, dynamic_shapes, strict=True)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return vary
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _to_session(onnx_model) -> ort.InferenceSession:
|
|
82
|
+
if isinstance(onnx_model, (str, Path)):
|
|
83
|
+
source: str | bytes = str(onnx_model)
|
|
84
|
+
elif isinstance(onnx_model, bytes):
|
|
85
|
+
source = onnx_model
|
|
86
|
+
else: # torch.onnx.ONNXProgram
|
|
87
|
+
source = onnx_model.model_proto.SerializeToString()
|
|
88
|
+
return ort.InferenceSession(source, providers=["CPUExecutionProvider"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _as_tensor_list(output) -> list[torch.Tensor]:
|
|
92
|
+
if isinstance(output, torch.Tensor):
|
|
93
|
+
return [output]
|
|
94
|
+
if isinstance(output, (tuple, list)):
|
|
95
|
+
return [t for t in output if isinstance(t, torch.Tensor)]
|
|
96
|
+
raise TypeError(f"can't compare model output of type {type(output).__name__}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def verify(
|
|
100
|
+
model: torch.nn.Module,
|
|
101
|
+
onnx_model,
|
|
102
|
+
base_inputs: tuple,
|
|
103
|
+
dynamic_shapes: tuple | None = None,
|
|
104
|
+
vary_fn: VaryFn | None = None,
|
|
105
|
+
k: int = 8,
|
|
106
|
+
atol: float | None = None,
|
|
107
|
+
rtol: float | None = None,
|
|
108
|
+
seed: int = 0,
|
|
109
|
+
) -> NumericsReport:
|
|
110
|
+
if vary_fn is None:
|
|
111
|
+
if dynamic_shapes is None:
|
|
112
|
+
raise ValueError("verify() needs either dynamic_shapes or an explicit vary_fn")
|
|
113
|
+
vary_fn = make_shared_axis0_vary_fn(base_inputs, dynamic_shapes, seed=seed)
|
|
114
|
+
|
|
115
|
+
default_atol, default_rtol = default_tolerances(model)
|
|
116
|
+
atol = default_atol if atol is None else atol
|
|
117
|
+
rtol = default_rtol if rtol is None else rtol
|
|
118
|
+
|
|
119
|
+
session = _to_session(onnx_model)
|
|
120
|
+
input_names = [inp.name for inp in session.get_inputs()]
|
|
121
|
+
|
|
122
|
+
model.eval()
|
|
123
|
+
max_abs_err = 0.0
|
|
124
|
+
max_rel_err = 0.0
|
|
125
|
+
failures = 0
|
|
126
|
+
non_baseline_failures = 0
|
|
127
|
+
|
|
128
|
+
# Seed inside a forked RNG so callers' global random state is untouched afterwards.
|
|
129
|
+
with torch.random.fork_rng(devices=[]):
|
|
130
|
+
torch.manual_seed(seed)
|
|
131
|
+
for i in range(k):
|
|
132
|
+
sample = vary_fn(i)
|
|
133
|
+
with torch.inference_mode():
|
|
134
|
+
torch_outs = _as_tensor_list(model(*sample))
|
|
135
|
+
feeds = {name: t.numpy() for name, t in zip(input_names, sample, strict=True)}
|
|
136
|
+
ort_outs = session.run(None, feeds)
|
|
137
|
+
|
|
138
|
+
sample_abs = 0.0
|
|
139
|
+
sample_rel = 0.0
|
|
140
|
+
for expected, got in zip(torch_outs, ort_outs, strict=True):
|
|
141
|
+
expected_np = expected.detach().numpy().astype(np.float64)
|
|
142
|
+
abs_err = np.abs(expected_np - np.asarray(got).astype(np.float64))
|
|
143
|
+
rel_err = abs_err / (np.abs(expected_np) + 1e-8)
|
|
144
|
+
sample_abs = max(sample_abs, float(abs_err.max(initial=0.0)))
|
|
145
|
+
sample_rel = max(sample_rel, float(rel_err.max(initial=0.0)))
|
|
146
|
+
|
|
147
|
+
max_abs_err = max(max_abs_err, sample_abs)
|
|
148
|
+
max_rel_err = max(max_rel_err, sample_rel)
|
|
149
|
+
if sample_abs > atol and sample_rel > rtol:
|
|
150
|
+
failures += 1
|
|
151
|
+
if i > 0:
|
|
152
|
+
non_baseline_failures += 1
|
|
153
|
+
|
|
154
|
+
return NumericsReport(
|
|
155
|
+
samples_tested=k,
|
|
156
|
+
max_abs_err=max_abs_err,
|
|
157
|
+
max_rel_err=max_rel_err,
|
|
158
|
+
failures=failures,
|
|
159
|
+
shape_generalization=non_baseline_failures == 0,
|
|
160
|
+
tolerance_abs=atol,
|
|
161
|
+
tolerance_rel=rtol,
|
|
162
|
+
)
|