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
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()
|
downshift/adapters/hf.py
ADDED
|
@@ -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
|