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 ADDED
@@ -0,0 +1,60 @@
1
+ """downshift: check whether a PyTorch model survives ONNX export, then serve it."""
2
+
3
+ from pathlib import Path
4
+
5
+ from downshift.adapters.base import Adapter, Prepared
6
+ from downshift.export.manifest import write_manifest
7
+ from downshift.export.prevalidated import intake
8
+ from downshift.export.verdict import ExportVerdict, build_verdict, check, prepare_model
9
+ from downshift.export.verify import NumericsReport
10
+
11
+ __version__ = "0.2.0"
12
+
13
+
14
+ def export(
15
+ model,
16
+ output: str | Path,
17
+ example_inputs: tuple | None = None,
18
+ k: int = 8,
19
+ adapter: Adapter | str | None = None,
20
+ dynamic: dict[str, list[int]] | None = None,
21
+ fp16: bool = False,
22
+ source_path: Path | None = None,
23
+ verify_numerics: bool = True,
24
+ ) -> ExportVerdict:
25
+ """check() plus writing the .onnx and its manifest. `output` is the .onnx path.
26
+
27
+ A FAILED verdict writes nothing; a DEGRADED one still writes the artifact because
28
+ the manifest records exactly how far off it is.
29
+ """
30
+ verdict = check(
31
+ model,
32
+ example_inputs,
33
+ k=k,
34
+ adapter=adapter,
35
+ dynamic=dynamic,
36
+ fp16=fp16,
37
+ verify_numerics=verify_numerics,
38
+ )
39
+ if verdict.onnx_program is None:
40
+ return verdict
41
+ output = Path(output)
42
+ output.parent.mkdir(parents=True, exist_ok=True)
43
+ verdict.onnx_program.save(str(output)) # type: ignore[attr-defined]
44
+ verdict.onnx_path = output
45
+ write_manifest(output, verdict, source_path, __version__)
46
+ return verdict
47
+
48
+
49
+ __all__ = [
50
+ "Adapter",
51
+ "ExportVerdict",
52
+ "NumericsReport",
53
+ "Prepared",
54
+ "build_verdict",
55
+ "check",
56
+ "export",
57
+ "intake",
58
+ "prepare_model",
59
+ "__version__",
60
+ ]
File without changes
@@ -0,0 +1,40 @@
1
+ """Shim that wraps a model taking a container argument (a dataclass, a PyG Data, ...) so
2
+ torch.export sees a plain fixed-arity tensor signature. Export the shim, not the model.
3
+ """
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from typing import Any
7
+
8
+ import torch
9
+ from torch import nn
10
+
11
+
12
+ class FlattenShimBase(nn.Module):
13
+ def __init__(
14
+ self,
15
+ model: nn.Module,
16
+ rebuild: Callable[[Mapping[str, torch.Tensor]], Any],
17
+ field_names: Sequence[str],
18
+ ) -> None:
19
+ super().__init__()
20
+ self.model = model
21
+ self._rebuild = rebuild
22
+ self._field_names = field_names
23
+ self.train(model.training)
24
+
25
+ def _call(self, tensors: tuple[torch.Tensor, ...]) -> Any:
26
+ return self.model(self._rebuild(dict(zip(self._field_names, tensors, strict=True))))
27
+
28
+
29
+ def build_shim_class(field_names: Sequence[str]) -> type[FlattenShimBase]:
30
+ """Generate a subclass whose forward() has one named positional parameter per field.
31
+
32
+ A `def forward(self, *tensors)` would bind everything into one VAR_POSITIONAL arg and
33
+ torch.export would see a single tuple input, which doesn't line up with a per-input
34
+ dynamic_shapes tuple. The parameter names also become the ONNX graph's input names.
35
+ """
36
+ params = ", ".join(n if n.isidentifier() else f"t{i}" for i, n in enumerate(field_names))
37
+ src = f"def forward(self, {params}):\n return self._call(({params},))\n"
38
+ namespace: dict[str, Any] = {}
39
+ exec(src, namespace) # noqa: S102 - field names only; no user-controlled text
40
+ return type("FlattenShim", (FlattenShimBase,), {"forward": namespace["forward"]})
@@ -0,0 +1,47 @@
1
+ """What an adapter is.
2
+
3
+ An adapter knows one model family well enough to (a) build example inputs when the user
4
+ didn't give any, and (b) turn the model + inputs into something torch.export can trace:
5
+ a module with a flat, fixed-arity tensor signature, plus the dynamic-shape spec and a way
6
+ to generate more samples for verification.
7
+ """
8
+
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from torch import nn
14
+
15
+ VaryFn = Callable[[int], tuple]
16
+
17
+
18
+ @dataclass
19
+ class Prepared:
20
+ model: nn.Module # export-ready module; forward takes flat tensors
21
+ inputs: tuple # flat example inputs, one per input_names entry
22
+ input_names: tuple[str, ...]
23
+ dynamic_shapes: tuple # per input: {axis: torch.export.Dim} or None
24
+ vary_fn: VaryFn | None # sample i -> inputs; None means use the shared-axis-0 default
25
+ family: str
26
+
27
+ @property
28
+ def dynamic_dims(self) -> dict[str, list[int]]:
29
+ return {
30
+ name: sorted(spec)
31
+ for name, spec in zip(self.input_names, self.dynamic_shapes, strict=True)
32
+ if spec
33
+ }
34
+
35
+
36
+ @runtime_checkable
37
+ class Adapter(Protocol):
38
+ name: str
39
+ family: str
40
+
41
+ def matches(self, model: nn.Module, example_inputs: tuple | None) -> bool: ...
42
+
43
+ def example_inputs(self, model: nn.Module) -> tuple | None:
44
+ """Adapter-derived example inputs, used when the user gave none. None means no guess."""
45
+ ...
46
+
47
+ def prepare(self, model: nn.Module, example_inputs: tuple) -> Prepared: ...
@@ -0,0 +1,99 @@
1
+ """Generic adapter: any nn.Module.
2
+
3
+ Handles two things. A single dataclass positional argument gets flattened into plain
4
+ tensors (torch.export rejects unregistered dataclasses outright). And when no example
5
+ inputs are given, it guesses a shape from the first Linear/Conv layer, which is enough
6
+ for the torchvision-style single-tensor case.
7
+ """
8
+
9
+ import dataclasses
10
+ import inspect
11
+
12
+ import torch
13
+ from torch import nn
14
+
15
+ from downshift.adapters._flatten import build_shim_class
16
+ from downshift.adapters.base import Prepared
17
+ from downshift.export.shapes import infer_dynamic_shapes
18
+
19
+ _GUESS_SPATIAL = 32
20
+
21
+
22
+ def _forward_param_names(model: nn.Module) -> tuple[str, ...]:
23
+ params = inspect.signature(model.forward).parameters.values()
24
+ return tuple(
25
+ p.name
26
+ for p in params
27
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) and p.name != "self"
28
+ )
29
+
30
+
31
+ def _guess_single_tensor_input(model: nn.Module) -> torch.Tensor | None:
32
+ for layer in model.modules():
33
+ if isinstance(layer, nn.Linear):
34
+ return torch.randn(1, layer.in_features)
35
+ if isinstance(layer, nn.Conv1d):
36
+ return torch.randn(1, layer.in_channels, _GUESS_SPATIAL)
37
+ if isinstance(layer, nn.Conv2d):
38
+ return torch.randn(1, layer.in_channels, _GUESS_SPATIAL, _GUESS_SPATIAL)
39
+ if isinstance(layer, nn.Conv3d):
40
+ return torch.randn(1, layer.in_channels, 8, 8, 8)
41
+ if isinstance(layer, nn.Embedding):
42
+ return torch.randint(0, layer.num_embeddings, (1, 8))
43
+ return None
44
+
45
+
46
+ class GenericAdapter:
47
+ name = "generic"
48
+ family = "generic-torch"
49
+
50
+ def matches(self, model: nn.Module, example_inputs: tuple | None) -> bool:
51
+ return True
52
+
53
+ def example_inputs(self, model: nn.Module) -> tuple | None:
54
+ if len(_forward_param_names(model)) != 1:
55
+ return None
56
+ guess = _guess_single_tensor_input(model)
57
+ return (guess,) if guess is not None else None
58
+
59
+ def prepare(self, model: nn.Module, example_inputs: tuple) -> Prepared:
60
+ flattened = _flatten_dataclass(model, example_inputs)
61
+ if flattened is not None:
62
+ model, inputs, names = flattened
63
+ else:
64
+ inputs = example_inputs
65
+ param_names = _forward_param_names(model)
66
+ names = tuple(
67
+ param_names[i] if i < len(param_names) else f"input_{i}"
68
+ for i in range(len(inputs))
69
+ )
70
+ return Prepared(
71
+ model=model,
72
+ inputs=inputs,
73
+ input_names=names,
74
+ dynamic_shapes=infer_dynamic_shapes(inputs),
75
+ vary_fn=None,
76
+ family=self.family,
77
+ )
78
+
79
+
80
+ def _flatten_dataclass(
81
+ model: nn.Module, example_inputs: tuple
82
+ ) -> tuple[nn.Module, tuple, tuple[str, ...]] | None:
83
+ if len(example_inputs) != 1:
84
+ return None
85
+ (arg,) = example_inputs
86
+ if not dataclasses.is_dataclass(arg) or isinstance(arg, type):
87
+ return None
88
+
89
+ names = tuple(f.name for f in dataclasses.fields(arg))
90
+ tensors = tuple(getattr(arg, n) for n in names)
91
+ if not all(isinstance(t, torch.Tensor) for t in tensors):
92
+ return None # can't flatten a non-tensor field; let export produce the real error
93
+
94
+ dataclass_type = type(arg)
95
+ shim = build_shim_class(names)(model, lambda fields: dataclass_type(**fields), names)
96
+ return shim, tensors, names
97
+
98
+
99
+ ADAPTER = GenericAdapter()
@@ -0,0 +1,95 @@
1
+ """Hugging Face adapter, encoder-only models.
2
+
3
+ Builds input_ids / attention_mask straight from the model config rather than pulling in
4
+ optimum. The export shim unwraps the ModelOutput so torch.export sees a plain tensor
5
+ (last_hidden_state for base models, logits for heads).
6
+
7
+ Only imported when transformers is installed.
8
+ """
9
+
10
+ import random
11
+
12
+ import torch
13
+ from torch import nn
14
+ from transformers import AutoModel, PreTrainedModel
15
+
16
+ from downshift.adapters.base import Prepared, VaryFn
17
+ from downshift.export.shapes import alternative_sizes
18
+
19
+ INPUT_NAMES = ("input_ids", "attention_mask")
20
+ _GUESS_BATCH = 2
21
+ _GUESS_SEQ = 8
22
+
23
+
24
+ class _FirstOutputShim(nn.Module):
25
+ def __init__(self, model: nn.Module) -> None:
26
+ super().__init__()
27
+ self.model = model
28
+ self.train(model.training)
29
+
30
+ def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
31
+ out = self.model(input_ids=input_ids, attention_mask=attention_mask)
32
+ first: torch.Tensor = out[0]
33
+ return first
34
+
35
+
36
+ def load_pretrained(repo_id_or_path: str) -> PreTrainedModel:
37
+ """repo_id_or_path is either a Hugging Face hub id or a local directory containing a
38
+ previously downloaded repo (config.json, weights, etc.) -- from_pretrained handles both."""
39
+ model: PreTrainedModel = AutoModel.from_pretrained(repo_id_or_path)
40
+ return model
41
+
42
+
43
+ class HFAdapter:
44
+ name = "hf"
45
+ family = "hf-transformers"
46
+
47
+ def matches(self, model: nn.Module, example_inputs: tuple | None) -> bool:
48
+ return isinstance(model, PreTrainedModel)
49
+
50
+ def example_inputs(self, model: nn.Module) -> tuple | None:
51
+ vocab = getattr(getattr(model, "config", None), "vocab_size", None)
52
+ if vocab is None:
53
+ return None
54
+ input_ids = torch.randint(0, vocab, (_GUESS_BATCH, _GUESS_SEQ))
55
+ return input_ids, torch.ones_like(input_ids)
56
+
57
+ def prepare(self, model: nn.Module, example_inputs: tuple) -> Prepared:
58
+ input_ids, attention_mask = example_inputs
59
+ config = getattr(model, "config")
60
+ # Position embeddings cap the sequence length; a looser bound trips export's guards.
61
+ max_seq = int(getattr(config, "max_position_embeddings", 1 << 12))
62
+ batch = torch.export.Dim("batch", min=1, max=1 << 12)
63
+ seq = torch.export.Dim("seq", min=1, max=max_seq)
64
+ spec = {0: batch, 1: seq}
65
+ vocab = int(config.vocab_size)
66
+ inputs = (input_ids, attention_mask)
67
+ return Prepared(
68
+ model=_FirstOutputShim(model),
69
+ inputs=inputs,
70
+ input_names=INPUT_NAMES,
71
+ dynamic_shapes=(spec, spec),
72
+ vary_fn=make_vary_fn(inputs, vocab),
73
+ family=self.family,
74
+ )
75
+
76
+
77
+ def make_vary_fn(base_inputs: tuple, vocab_size: int, seed: int = 0) -> VaryFn:
78
+ input_ids, _ = base_inputs
79
+ base_batch, base_seq = input_ids.shape
80
+ rng = random.Random(seed)
81
+ batch_candidates = alternative_sizes(base_batch)
82
+ seq_candidates = alternative_sizes(base_seq)
83
+
84
+ def vary(i: int) -> tuple:
85
+ if i == 0:
86
+ return base_inputs
87
+ b = rng.choice(batch_candidates) if batch_candidates else base_batch
88
+ s = rng.choice(seq_candidates) if seq_candidates else base_seq
89
+ ids = torch.randint(0, vocab_size, (b, s), dtype=input_ids.dtype)
90
+ return ids, torch.ones_like(ids)
91
+
92
+ return vary
93
+
94
+
95
+ ADAPTER = HFAdapter()
@@ -0,0 +1,120 @@
1
+ """PyG adapter: flattens torch_geometric.data.Data into (x, edge_index[, edge_attr]).
2
+
3
+ Node count N and edge count E are independent dynamic dims. Tying them to one Dim is the
4
+ classic way to get a GNN export that works on the example graph and throws
5
+ INVALID_ARGUMENT on the next one.
6
+
7
+ Only imported when a PyG Data input actually shows up, so torch_geometric stays optional.
8
+ """
9
+
10
+ import random
11
+
12
+ import torch
13
+ from torch import nn
14
+ from torch_geometric.data import Data
15
+ from torch_geometric.nn import MessagePassing
16
+
17
+ from downshift.adapters._flatten import build_shim_class
18
+ from downshift.adapters.base import Prepared, VaryFn
19
+ from downshift.export.shapes import alternative_sizes
20
+
21
+ BASE_FIELD_NAMES = ("x", "edge_index") # edge_attr appended when present on the input Data
22
+ _GUESS_NODES = 8
23
+ _GUESS_EDGES = 16
24
+
25
+
26
+ def is_pyg_data(example_inputs: tuple | None) -> bool:
27
+ if example_inputs is None or len(example_inputs) != 1:
28
+ return False
29
+ return isinstance(example_inputs[0], Data)
30
+
31
+
32
+ def _first_in_channels(model: nn.Module) -> int | None:
33
+ for layer in model.modules():
34
+ if isinstance(layer, MessagePassing):
35
+ in_channels = getattr(layer, "in_channels", None)
36
+ if isinstance(in_channels, tuple):
37
+ in_channels = in_channels[0]
38
+ if isinstance(in_channels, int):
39
+ return in_channels
40
+ return None
41
+
42
+
43
+ class PyGAdapter:
44
+ name = "pyg"
45
+ family = "pyg"
46
+
47
+ def matches(self, model: nn.Module, example_inputs: tuple | None) -> bool:
48
+ if is_pyg_data(example_inputs):
49
+ return True
50
+ if example_inputs is not None:
51
+ return False
52
+ return any(isinstance(m, MessagePassing) for m in model.modules())
53
+
54
+ def example_inputs(self, model: nn.Module) -> tuple | None:
55
+ in_channels = _first_in_channels(model)
56
+ if in_channels is None:
57
+ return None
58
+ x = torch.randn(_GUESS_NODES, in_channels)
59
+ edge_index = torch.randint(0, _GUESS_NODES, (2, _GUESS_EDGES))
60
+ return (Data(x=x, edge_index=edge_index),)
61
+
62
+ def prepare(self, model: nn.Module, example_inputs: tuple) -> Prepared:
63
+ (data,) = example_inputs
64
+ names: tuple[str, ...] = BASE_FIELD_NAMES
65
+ if getattr(data, "edge_attr", None) is not None:
66
+ names += ("edge_attr",)
67
+
68
+ inputs = tuple(getattr(data, n) for n in names)
69
+ shim = build_shim_class(names)(model, lambda fields: Data(**fields), names)
70
+
71
+ n_dim = torch.export.Dim("num_nodes", min=1, max=1 << 16)
72
+ e_dim = torch.export.Dim("num_edges", min=1, max=1 << 16)
73
+ axis_by_field = {"x": {0: n_dim}, "edge_index": {1: e_dim}, "edge_attr": {0: e_dim}}
74
+
75
+ return Prepared(
76
+ model=shim,
77
+ inputs=inputs,
78
+ input_names=names,
79
+ dynamic_shapes=tuple(axis_by_field[n] for n in names),
80
+ vary_fn=make_vary_fn(inputs, names),
81
+ family=self.family,
82
+ )
83
+
84
+
85
+ def make_vary_fn(base_inputs: tuple, field_names: tuple[str, ...], seed: int = 0) -> VaryFn:
86
+ """Regenerate (x, edge_index[, edge_attr]) with independently varied N and E.
87
+
88
+ edge_index is redrawn against the sample's own node count, not the original tensor's
89
+ value range, so a shrunken graph never references nodes it doesn't have.
90
+ """
91
+ x_idx = field_names.index("x")
92
+ ei_idx = field_names.index("edge_index")
93
+ ea_idx = field_names.index("edge_attr") if "edge_attr" in field_names else None
94
+
95
+ base_x, base_ei = base_inputs[x_idx], base_inputs[ei_idx]
96
+ base_n, in_channels = base_x.shape
97
+ base_e = base_ei.shape[1]
98
+
99
+ rng = random.Random(seed)
100
+ n_candidates = alternative_sizes(base_n)
101
+ e_candidates = alternative_sizes(base_e)
102
+
103
+ def vary(i: int) -> tuple:
104
+ if i == 0:
105
+ return base_inputs
106
+ n = rng.choice(n_candidates) if n_candidates else base_n
107
+ e = rng.choice(e_candidates) if e_candidates else base_e
108
+
109
+ sample: list = [None] * len(field_names)
110
+ sample[x_idx] = torch.randn(n, in_channels, dtype=base_x.dtype)
111
+ sample[ei_idx] = torch.randint(0, n, (2, e), dtype=base_ei.dtype)
112
+ if ea_idx is not None:
113
+ base_ea = base_inputs[ea_idx]
114
+ sample[ea_idx] = torch.randn(e, base_ea.shape[1], dtype=base_ea.dtype)
115
+ return tuple(sample)
116
+
117
+ return vary
118
+
119
+
120
+ ADAPTER = PyGAdapter()
@@ -0,0 +1,116 @@
1
+ """Adapter lookup: built-ins, anything registered under the `downshift.adapters` entry-point
2
+ group, and one-off adapters loaded straight from a user's .py file. Adapters whose optional
3
+ dependency is missing are skipped silently.
4
+ """
5
+
6
+ import importlib.util
7
+ from importlib import import_module
8
+ from importlib.metadata import entry_points
9
+ from pathlib import Path
10
+
11
+ from torch import nn
12
+
13
+ from downshift.adapters.base import Adapter
14
+ from downshift.loading import LoadError
15
+
16
+ ENTRY_POINT_GROUP = "downshift.adapters"
17
+
18
+ # Most specific first; generic last so it only wins when nothing else matches.
19
+ _BUILTIN_SPECS = (
20
+ "downshift.adapters.hf:ADAPTER",
21
+ "downshift.adapters.pyg:ADAPTER",
22
+ "downshift.adapters.generic:ADAPTER",
23
+ )
24
+
25
+
26
+ def _load_spec(spec: str) -> Adapter | None:
27
+ module_name, _, attr = spec.partition(":")
28
+ try:
29
+ adapter: Adapter = getattr(import_module(module_name), attr)
30
+ except ImportError:
31
+ return None
32
+ return adapter
33
+
34
+
35
+ def _split_file_spec(name: str) -> tuple[str, str] | None:
36
+ """Split path/to/adapter.py[:attr] into (path, attr); None if `name` isn't a .py spec.
37
+
38
+ Splits on the literal ".py:" rather than the last ":", so a Windows drive letter's
39
+ colon (`C:\\...`) is never mistaken for the path:attr separator.
40
+ """
41
+ path, sep, attr = name.partition(".py:")
42
+ if sep:
43
+ return path + ".py", attr
44
+ if name.endswith(".py"):
45
+ return name, "ADAPTER"
46
+ return None
47
+
48
+
49
+ def load_from_file(path_str: str, attr: str = "ADAPTER") -> Adapter:
50
+ """Load a user's adapter from a standalone .py file, outside any installed package.
51
+
52
+ `attr` names either an `Adapter`-shaped instance — the `generic`/`pyg`/`hf` convention of
53
+ a module-level `ADAPTER = MyAdapter()` — or the class itself, instantiated with no args.
54
+ """
55
+ path = Path(path_str)
56
+ if not path.is_file():
57
+ raise LoadError(f"{path} does not exist")
58
+ spec = importlib.util.spec_from_file_location(f"downshift._custom_adapter_{path.stem}", path)
59
+ if spec is None or spec.loader is None:
60
+ raise LoadError(f"can't import {path} as a Python module")
61
+ module = importlib.util.module_from_spec(spec)
62
+ try:
63
+ spec.loader.exec_module(module)
64
+ except Exception as exc:
65
+ raise LoadError(f"error while loading {path}: {exc}") from exc
66
+
67
+ try:
68
+ obj = getattr(module, attr)
69
+ except AttributeError as exc:
70
+ raise LoadError(f"{path} has no attribute {attr!r}") from exc
71
+
72
+ if isinstance(obj, type):
73
+ obj = obj()
74
+ if not isinstance(obj, Adapter):
75
+ raise LoadError(
76
+ f"{path}:{attr} is a {type(obj).__name__}, not an Adapter — it needs `name`, "
77
+ "`family`, matches(), example_inputs(), and prepare(); see GenericAdapter for "
78
+ "the shape to implement."
79
+ )
80
+ return obj
81
+
82
+
83
+ def available() -> dict[str, Adapter]:
84
+ adapters: dict[str, Adapter] = {}
85
+ for ep in entry_points(group=ENTRY_POINT_GROUP):
86
+ try:
87
+ adapter = ep.load()
88
+ except ImportError:
89
+ continue
90
+ adapters[adapter.name] = adapter
91
+ for spec in _BUILTIN_SPECS:
92
+ adapter = _load_spec(spec)
93
+ if adapter is not None:
94
+ adapters.setdefault(adapter.name, adapter)
95
+ # Generic must be tried last regardless of registration order.
96
+ generic = adapters.pop("generic", None)
97
+ if generic is not None:
98
+ adapters["generic"] = generic
99
+ return adapters
100
+
101
+
102
+ def get(name: str) -> Adapter:
103
+ file_spec = _split_file_spec(name)
104
+ if file_spec is not None:
105
+ return load_from_file(*file_spec)
106
+ adapters = available()
107
+ if name not in adapters:
108
+ raise KeyError(f"unknown adapter {name!r}; available: {', '.join(adapters)}")
109
+ return adapters[name]
110
+
111
+
112
+ def detect(model: nn.Module, example_inputs: tuple | None) -> Adapter:
113
+ for adapter in available().values():
114
+ if adapter.matches(model, example_inputs):
115
+ return adapter
116
+ raise RuntimeError("no adapter matched and the generic adapter is unavailable")
File without changes