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/loading.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Turn a CLI model argument into something the export layer can use.
|
|
2
|
+
|
|
3
|
+
Accepted forms:
|
|
4
|
+
model.onnx pre-built ONNX, served as-is (UNVERIFIED without --reference)
|
|
5
|
+
pkg.module:attr import spec; attr is a module instance or a zero-arg factory.
|
|
6
|
+
A sibling `make_inputs` in the same module is picked up automatically.
|
|
7
|
+
weights.pt state dict; needs --model-class pkg.module:Class. Loaded with
|
|
8
|
+
weights_only=True. A pickled full module needs --unsafe-load.
|
|
9
|
+
org/repo Hugging Face hub id (needs the [hf] extra)
|
|
10
|
+
path/to/repo/dir Locally downloaded Hugging Face repo, i.e. a directory containing
|
|
11
|
+
a config.json (needs the [hf] extra)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from importlib import import_module
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
from torch import nn
|
|
22
|
+
|
|
23
|
+
_IMPORT_SPEC = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
|
|
24
|
+
_STATE_DICT_SUFFIXES = {".pt", ".pth", ".bin", ".ckpt"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LoadError(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class LoadedModel:
|
|
33
|
+
source: str
|
|
34
|
+
model: nn.Module | None = None
|
|
35
|
+
onnx_path: Path | None = None
|
|
36
|
+
example_inputs: tuple | None = None
|
|
37
|
+
adapter_hint: str | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def source_path(self) -> Path | None:
|
|
41
|
+
path = Path(self.source)
|
|
42
|
+
return path if path.exists() else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def import_object(spec: str) -> Any:
|
|
46
|
+
if not _IMPORT_SPEC.match(spec):
|
|
47
|
+
raise LoadError(f"{spec!r} is not an import spec of the form package.module:attr")
|
|
48
|
+
module_name, _, attr = spec.partition(":")
|
|
49
|
+
try:
|
|
50
|
+
module = import_module(module_name)
|
|
51
|
+
except ImportError as exc:
|
|
52
|
+
raise LoadError(f"can't import {module_name!r}: {exc}") from exc
|
|
53
|
+
try:
|
|
54
|
+
return getattr(module, attr)
|
|
55
|
+
except AttributeError as exc:
|
|
56
|
+
raise LoadError(f"{module_name!r} has no attribute {attr!r}") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _instantiate(obj: Any) -> nn.Module:
|
|
60
|
+
if isinstance(obj, nn.Module):
|
|
61
|
+
return obj
|
|
62
|
+
if callable(obj):
|
|
63
|
+
model = obj()
|
|
64
|
+
if isinstance(model, nn.Module):
|
|
65
|
+
return model
|
|
66
|
+
raise LoadError(f"{obj!r}() returned {type(model).__name__}, not an nn.Module")
|
|
67
|
+
raise LoadError(f"{obj!r} is neither an nn.Module nor a callable that builds one")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_inputs(spec: str) -> tuple:
|
|
71
|
+
inputs = import_object(spec)
|
|
72
|
+
if callable(inputs):
|
|
73
|
+
inputs = inputs()
|
|
74
|
+
return inputs if isinstance(inputs, tuple) else (inputs,)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_from_import_spec(spec: str, inputs_spec: str | None) -> LoadedModel:
|
|
78
|
+
model = _instantiate(import_object(spec))
|
|
79
|
+
inputs: tuple | None = None
|
|
80
|
+
if inputs_spec:
|
|
81
|
+
inputs = load_inputs(inputs_spec)
|
|
82
|
+
else:
|
|
83
|
+
module_name = spec.partition(":")[0]
|
|
84
|
+
if callable(getattr(import_module(module_name), "make_inputs", None)):
|
|
85
|
+
inputs = load_inputs(f"{module_name}:make_inputs")
|
|
86
|
+
return LoadedModel(source=spec, model=model, example_inputs=inputs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_checkpoint(path: Path, model_class: str | None, unsafe_load: bool) -> nn.Module:
|
|
90
|
+
try:
|
|
91
|
+
payload = torch.load(path, map_location="cpu", weights_only=not unsafe_load)
|
|
92
|
+
except Exception as exc: # torch raises a few different types here
|
|
93
|
+
if unsafe_load:
|
|
94
|
+
raise LoadError(f"failed to load {path}: {exc}") from exc
|
|
95
|
+
raise LoadError(
|
|
96
|
+
f"{path} isn't loadable with weights_only=True ({type(exc).__name__}). If it "
|
|
97
|
+
"holds a pickled nn.Module from a source you trust, re-run with --unsafe-load."
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
if isinstance(payload, nn.Module):
|
|
101
|
+
return payload
|
|
102
|
+
if isinstance(payload, dict):
|
|
103
|
+
state = payload.get("state_dict", payload)
|
|
104
|
+
if model_class is None:
|
|
105
|
+
raise LoadError(
|
|
106
|
+
f"{path} is a state dict; pass --model-class package.module:Class so it can "
|
|
107
|
+
"be instantiated and the weights loaded into it."
|
|
108
|
+
)
|
|
109
|
+
model = _instantiate(import_object(model_class))
|
|
110
|
+
model.load_state_dict(state)
|
|
111
|
+
return model
|
|
112
|
+
raise LoadError(f"{path} contained {type(payload).__name__}, expected a state dict")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_model(
|
|
116
|
+
spec: str,
|
|
117
|
+
inputs: str | None = None,
|
|
118
|
+
model_class: str | None = None,
|
|
119
|
+
unsafe_load: bool = False,
|
|
120
|
+
) -> LoadedModel:
|
|
121
|
+
path = Path(spec)
|
|
122
|
+
if path.suffix == ".onnx":
|
|
123
|
+
if not path.exists():
|
|
124
|
+
raise LoadError(f"{path} does not exist")
|
|
125
|
+
return LoadedModel(source=spec, onnx_path=path)
|
|
126
|
+
|
|
127
|
+
if path.exists() and path.suffix in _STATE_DICT_SUFFIXES:
|
|
128
|
+
model = _load_checkpoint(path, model_class, unsafe_load)
|
|
129
|
+
return LoadedModel(
|
|
130
|
+
source=spec, model=model, example_inputs=load_inputs(inputs) if inputs else None
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if _IMPORT_SPEC.match(spec):
|
|
134
|
+
return _load_from_import_spec(spec, inputs)
|
|
135
|
+
|
|
136
|
+
if path.is_dir():
|
|
137
|
+
if not (path / "config.json").exists():
|
|
138
|
+
raise LoadError(f"{path} has no config.json — not a Hugging Face repo")
|
|
139
|
+
return _load_hf(spec, inputs)
|
|
140
|
+
|
|
141
|
+
if path.exists():
|
|
142
|
+
raise LoadError(f"don't know how to load {path} (suffix {path.suffix!r})")
|
|
143
|
+
if path.suffix or path.is_absolute():
|
|
144
|
+
raise LoadError(f"{path} does not exist")
|
|
145
|
+
|
|
146
|
+
return _load_hf(spec, inputs)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _load_hf(spec: str, inputs: str | None) -> LoadedModel:
|
|
150
|
+
try:
|
|
151
|
+
from downshift.adapters import hf
|
|
152
|
+
except ImportError as exc:
|
|
153
|
+
raise LoadError(
|
|
154
|
+
f"{spec!r} isn't a file or an import spec; loading it as a Hugging Face repo "
|
|
155
|
+
"needs the [hf] extra: pip install 'downshift-server[hf]'"
|
|
156
|
+
) from exc
|
|
157
|
+
try:
|
|
158
|
+
model = hf.load_pretrained(spec)
|
|
159
|
+
except (OSError, ValueError) as exc: # hub errors and bad local repos surface as either
|
|
160
|
+
raise LoadError(f"can't load {spec!r} as a Hugging Face repo: {exc}") from exc
|
|
161
|
+
return LoadedModel(
|
|
162
|
+
source=spec,
|
|
163
|
+
model=model,
|
|
164
|
+
example_inputs=load_inputs(inputs) if inputs else None,
|
|
165
|
+
adapter_hint="hf",
|
|
166
|
+
)
|
|
File without changes
|
downshift/serve/app.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""FastAPI app over a ServingState. The same routes regardless of which backend is behind it."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from fastapi import FastAPI, HTTPException
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
import downshift
|
|
11
|
+
from downshift.serve.engine import ServingState
|
|
12
|
+
from downshift.serve.middleware import load_middleware
|
|
13
|
+
from downshift.serve.schemas import (
|
|
14
|
+
GraphPredictRequest,
|
|
15
|
+
HealthResponse,
|
|
16
|
+
MetadataResponse,
|
|
17
|
+
PredictRequest,
|
|
18
|
+
PredictResponse,
|
|
19
|
+
ReadyResponse,
|
|
20
|
+
to_numpy,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_predict(state: ServingState, inputs: dict[str, Any]) -> PredictResponse:
|
|
25
|
+
"""Validate, convert, infer. Raises HTTPException(400) for anything the client got wrong."""
|
|
26
|
+
missing = [n for n in state.input_names if n not in inputs]
|
|
27
|
+
if missing:
|
|
28
|
+
raise HTTPException(400, f"missing inputs: {missing}")
|
|
29
|
+
|
|
30
|
+
declared = {spec.name: spec.dtype for spec in state.backend.metadata().inputs}
|
|
31
|
+
try:
|
|
32
|
+
feeds = {n: to_numpy(n, inputs[n], declared.get(n)) for n in state.input_names}
|
|
33
|
+
outputs = state.backend.infer(feeds)
|
|
34
|
+
except HTTPException:
|
|
35
|
+
raise
|
|
36
|
+
except Exception as exc: # shape/dtype errors from ORT or torch are the client's problem
|
|
37
|
+
raise HTTPException(400, str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
arrays = {name: np.asarray(arr) for name, arr in outputs.items()}
|
|
40
|
+
return PredictResponse(
|
|
41
|
+
outputs={name: arr.tolist() for name, arr in arrays.items()},
|
|
42
|
+
shapes={name: list(arr.shape) for name, arr in arrays.items()},
|
|
43
|
+
dtypes={name: arr.dtype.name for name, arr in arrays.items()},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_app(state: ServingState, middleware: Sequence[str] = ()) -> FastAPI:
|
|
48
|
+
app = FastAPI(title="downshift", version=downshift.__version__)
|
|
49
|
+
app.state.serving = state
|
|
50
|
+
load_middleware(app, middleware)
|
|
51
|
+
|
|
52
|
+
@app.get("/health", response_model=HealthResponse)
|
|
53
|
+
def health() -> HealthResponse:
|
|
54
|
+
return HealthResponse()
|
|
55
|
+
|
|
56
|
+
@app.get("/ready", response_model=ReadyResponse)
|
|
57
|
+
def ready() -> JSONResponse:
|
|
58
|
+
status = 200 if state.ready else 503
|
|
59
|
+
return JSONResponse({"ready": state.ready}, status_code=status)
|
|
60
|
+
|
|
61
|
+
@app.get("/metadata", response_model=MetadataResponse)
|
|
62
|
+
def metadata() -> MetadataResponse:
|
|
63
|
+
return MetadataResponse(
|
|
64
|
+
model=state.source,
|
|
65
|
+
family=state.verdict.model_family,
|
|
66
|
+
verdict=state.verdict.to_dict(),
|
|
67
|
+
backend=state.backend.metadata().to_dict(),
|
|
68
|
+
input_names=list(state.input_names),
|
|
69
|
+
notes=list(state.notes),
|
|
70
|
+
version=downshift.__version__,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@app.post("/predict", response_model=PredictResponse)
|
|
74
|
+
def predict(req: PredictRequest) -> PredictResponse:
|
|
75
|
+
return run_predict(state, req.inputs)
|
|
76
|
+
|
|
77
|
+
@app.post("/predict/graph", response_model=PredictResponse)
|
|
78
|
+
def predict_graph(req: GraphPredictRequest) -> PredictResponse:
|
|
79
|
+
if not {"x", "edge_index"} <= set(state.input_names):
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
400,
|
|
82
|
+
f"model is not graph-shaped: inputs are {list(state.input_names)}, "
|
|
83
|
+
"expected at least 'x' and 'edge_index'",
|
|
84
|
+
)
|
|
85
|
+
inputs: dict[str, Any] = {
|
|
86
|
+
"x": req.x,
|
|
87
|
+
"edge_index": {"data": req.edge_index, "dtype": "int64"},
|
|
88
|
+
}
|
|
89
|
+
if req.edge_attr is not None:
|
|
90
|
+
inputs["edge_attr"] = req.edge_attr
|
|
91
|
+
return run_predict(state, inputs)
|
|
92
|
+
|
|
93
|
+
return app
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Inference backends. Both take and return dicts of numpy arrays keyed by input name,
|
|
2
|
+
so the HTTP layer doesn't care which one is behind it.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import onnxruntime as ort
|
|
11
|
+
import torch
|
|
12
|
+
from torch import nn
|
|
13
|
+
|
|
14
|
+
from downshift.export.verdict import BackendName
|
|
15
|
+
|
|
16
|
+
_CUDA_EP = "CUDAExecutionProvider"
|
|
17
|
+
_CPU_EP = "CPUExecutionProvider"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class IOSpec:
|
|
22
|
+
name: str
|
|
23
|
+
dtype: str | None
|
|
24
|
+
shape: list[int | str | None] | None
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class BackendMeta:
|
|
32
|
+
name: BackendName
|
|
33
|
+
device: str
|
|
34
|
+
inputs: list[IOSpec]
|
|
35
|
+
outputs: list[IOSpec]
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"name": self.name,
|
|
40
|
+
"device": self.device,
|
|
41
|
+
"inputs": [i.to_dict() for i in self.inputs],
|
|
42
|
+
"outputs": [o.to_dict() for o in self.outputs],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Backend(Protocol):
|
|
47
|
+
name: BackendName
|
|
48
|
+
input_names: list[str]
|
|
49
|
+
|
|
50
|
+
def infer(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: ...
|
|
51
|
+
|
|
52
|
+
def metadata(self) -> BackendMeta: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def output_names(count: int) -> list[str]:
|
|
56
|
+
return [f"output_{i}" for i in range(count)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_device(device: str) -> str:
|
|
60
|
+
if device == "auto":
|
|
61
|
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
62
|
+
return device
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ort_providers(device: str) -> list[str]:
|
|
66
|
+
available = ort.get_available_providers()
|
|
67
|
+
if device == "cuda" and _CUDA_EP in available:
|
|
68
|
+
return [_CUDA_EP, _CPU_EP]
|
|
69
|
+
return [_CPU_EP]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _session_options(intra_op_threads: int, inter_op_threads: int) -> ort.SessionOptions:
|
|
73
|
+
"""Max graph optimization always on. Thread counts of 0 mean "let ONNX Runtime choose",
|
|
74
|
+
which is also its own default, so this is safe to set unconditionally."""
|
|
75
|
+
options = ort.SessionOptions()
|
|
76
|
+
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
77
|
+
options.intra_op_num_threads = intra_op_threads
|
|
78
|
+
options.inter_op_num_threads = inter_op_threads
|
|
79
|
+
return options
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OnnxRuntimeBackend:
|
|
83
|
+
name = BackendName.onnxruntime
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
model: bytes | str | Path,
|
|
88
|
+
device: str = "auto",
|
|
89
|
+
intra_op_threads: int = 0,
|
|
90
|
+
inter_op_threads: int = 0,
|
|
91
|
+
) -> None:
|
|
92
|
+
source = model if isinstance(model, bytes) else str(model)
|
|
93
|
+
providers = _ort_providers(resolve_device(device))
|
|
94
|
+
options = _session_options(intra_op_threads, inter_op_threads)
|
|
95
|
+
self.session = ort.InferenceSession(source, sess_options=options, providers=providers)
|
|
96
|
+
self.provider = self.session.get_providers()[0]
|
|
97
|
+
self.input_names = [i.name for i in self.session.get_inputs()]
|
|
98
|
+
self.onnx_output_names = [o.name for o in self.session.get_outputs()]
|
|
99
|
+
# Outputs are keyed positionally so responses look the same from either backend.
|
|
100
|
+
self.output_names = output_names(len(self.onnx_output_names))
|
|
101
|
+
|
|
102
|
+
def infer(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
|
|
103
|
+
outputs = self.session.run(self.onnx_output_names, inputs)
|
|
104
|
+
return dict(zip(self.output_names, outputs, strict=True))
|
|
105
|
+
|
|
106
|
+
def metadata(self) -> BackendMeta:
|
|
107
|
+
def spec(name: str, node) -> IOSpec:
|
|
108
|
+
return IOSpec(name, node.type, list(node.shape) if node.shape else None)
|
|
109
|
+
|
|
110
|
+
return BackendMeta(
|
|
111
|
+
name=self.name,
|
|
112
|
+
device=self.provider,
|
|
113
|
+
inputs=[spec(i.name, i) for i in self.session.get_inputs()],
|
|
114
|
+
outputs=[
|
|
115
|
+
spec(name, o)
|
|
116
|
+
for name, o in zip(self.output_names, self.session.get_outputs(), strict=True)
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TorchBackend:
|
|
122
|
+
"""Eager PyTorch. The fallback path, and a first-class one: same contract as ORT."""
|
|
123
|
+
|
|
124
|
+
name = BackendName.torch
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
module: nn.Module,
|
|
129
|
+
input_names: tuple[str, ...],
|
|
130
|
+
device: str = "auto",
|
|
131
|
+
example_inputs: tuple | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.device = resolve_device(device)
|
|
134
|
+
self.module = module.eval().to(self.device)
|
|
135
|
+
self.input_names = list(input_names)
|
|
136
|
+
self._input_specs = [IOSpec(n, None, None) for n in self.input_names]
|
|
137
|
+
self._output_specs: list[IOSpec] = []
|
|
138
|
+
if example_inputs is not None:
|
|
139
|
+
# One pass over the example fills in dtypes and shapes for /metadata.
|
|
140
|
+
self._input_specs = [
|
|
141
|
+
_spec_from_tensor(n, t)
|
|
142
|
+
for n, t in zip(self.input_names, example_inputs, strict=True)
|
|
143
|
+
]
|
|
144
|
+
feeds = {n: t.numpy() for n, t in zip(self.input_names, example_inputs, strict=True)}
|
|
145
|
+
self._output_specs = [_spec_from_array(n, a) for n, a in self.infer(feeds).items()]
|
|
146
|
+
|
|
147
|
+
def infer(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
|
|
148
|
+
missing = [n for n in self.input_names if n not in inputs]
|
|
149
|
+
if missing:
|
|
150
|
+
raise KeyError(f"missing inputs: {missing}")
|
|
151
|
+
args = [
|
|
152
|
+
torch.from_numpy(np.ascontiguousarray(inputs[n])).to(self.device)
|
|
153
|
+
for n in self.input_names
|
|
154
|
+
]
|
|
155
|
+
with torch.inference_mode():
|
|
156
|
+
out = self.module(*args)
|
|
157
|
+
if isinstance(out, torch.Tensor):
|
|
158
|
+
tensors = [out]
|
|
159
|
+
else:
|
|
160
|
+
tensors = [t for t in out if isinstance(t, torch.Tensor)]
|
|
161
|
+
return {
|
|
162
|
+
name: t.detach().cpu().numpy()
|
|
163
|
+
for name, t in zip(output_names(len(tensors)), tensors, strict=True)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def metadata(self) -> BackendMeta:
|
|
167
|
+
return BackendMeta(self.name, self.device, self._input_specs, self._output_specs)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _dynamic_shape(shape: tuple[int, ...]) -> list[int | str | None]:
|
|
171
|
+
# Axis 0 is dynamic for anything we serve; report it the way ORT does.
|
|
172
|
+
return ["batch", *shape[1:]] if shape else []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _spec_from_tensor(name: str, t: torch.Tensor) -> IOSpec:
|
|
176
|
+
dtype = str(t.dtype).removeprefix("torch.")
|
|
177
|
+
return IOSpec(name, f"tensor({dtype})", _dynamic_shape(tuple(t.shape)))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _spec_from_array(name: str, a: np.ndarray) -> IOSpec:
|
|
181
|
+
return IOSpec(name, f"tensor({a.dtype.name})", _dynamic_shape(a.shape))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""From a loaded model to a warmed-up backend. The CLI's `serve` is render(prepare_serving())."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
from downshift.export.prevalidated import intake
|
|
12
|
+
from downshift.export.verdict import BackendName, ExportVerdict, build_verdict, prepare_model
|
|
13
|
+
from downshift.loading import LoadedModel
|
|
14
|
+
from downshift.serve.backends import Backend, OnnxRuntimeBackend, TorchBackend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BackendChoice(str, Enum):
|
|
18
|
+
"""What the caller asked for; "auto" defers to the verdict's recommendation."""
|
|
19
|
+
|
|
20
|
+
auto = "auto"
|
|
21
|
+
onnxruntime = "onnxruntime"
|
|
22
|
+
torch = "torch"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ServeOptions:
|
|
27
|
+
backend: BackendChoice = BackendChoice.auto
|
|
28
|
+
force_onnx: bool = False # serve a DEGRADED graph via ORT anyway
|
|
29
|
+
device: str = "auto"
|
|
30
|
+
warmup: int = 3
|
|
31
|
+
k: int = 8
|
|
32
|
+
adapter: str | None = None
|
|
33
|
+
dynamic: dict[str, list[int]] | None = None
|
|
34
|
+
intra_op_threads: int = 0 # ORT SessionOptions; 0 = let ONNX Runtime choose
|
|
35
|
+
inter_op_threads: int = 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ServingState:
|
|
40
|
+
source: str
|
|
41
|
+
verdict: ExportVerdict
|
|
42
|
+
backend: Backend
|
|
43
|
+
input_names: tuple[str, ...]
|
|
44
|
+
options: ServeOptions
|
|
45
|
+
example_inputs: tuple | None = None
|
|
46
|
+
ready: bool = False
|
|
47
|
+
notes: list[str] = field(default_factory=list) # things the banner should say
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def backend_auto_selected(self) -> bool:
|
|
51
|
+
return self.options.backend == BackendChoice.auto and not self.forced_onnx
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def forced_onnx(self) -> bool:
|
|
55
|
+
return self.options.force_onnx and self.verdict.status == "DEGRADED"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _verdict_for(
|
|
59
|
+
loaded: LoadedModel, reference: LoadedModel | None, opts: ServeOptions
|
|
60
|
+
) -> ExportVerdict:
|
|
61
|
+
adapter = opts.adapter or loaded.adapter_hint
|
|
62
|
+
if loaded.onnx_path is not None:
|
|
63
|
+
ref_model = reference.model if reference else None
|
|
64
|
+
ref_inputs = reference.example_inputs if reference else None
|
|
65
|
+
return intake(
|
|
66
|
+
loaded.onnx_path, ref_model, ref_inputs, adapter, k=opts.k, dynamic=opts.dynamic
|
|
67
|
+
)
|
|
68
|
+
assert loaded.model is not None
|
|
69
|
+
prepared = prepare_model(loaded.model, loaded.example_inputs, adapter, opts.dynamic)
|
|
70
|
+
if opts.backend == BackendChoice.torch:
|
|
71
|
+
# Skip the export entirely; the user asked for eager.
|
|
72
|
+
return ExportVerdict(
|
|
73
|
+
status="UNVERIFIED",
|
|
74
|
+
model_family=prepared.family,
|
|
75
|
+
capture_strategy=None,
|
|
76
|
+
opset=None,
|
|
77
|
+
op_types=[],
|
|
78
|
+
numerics=None,
|
|
79
|
+
recommended_backend=BackendName.torch,
|
|
80
|
+
reason="--backend torch: export skipped",
|
|
81
|
+
input_names=prepared.input_names,
|
|
82
|
+
dynamic_dims=prepared.dynamic_dims,
|
|
83
|
+
prepared=prepared,
|
|
84
|
+
)
|
|
85
|
+
return build_verdict(prepared, k=opts.k)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def choose_backend(verdict: ExportVerdict, opts: ServeOptions) -> tuple[BackendName, list[str]]:
|
|
89
|
+
"""Return (backend name, notes for the banner)."""
|
|
90
|
+
notes: list[str] = []
|
|
91
|
+
wanted: BackendName = (
|
|
92
|
+
verdict.recommended_backend
|
|
93
|
+
if opts.backend == BackendChoice.auto
|
|
94
|
+
else BackendName(opts.backend)
|
|
95
|
+
)
|
|
96
|
+
if opts.force_onnx and verdict.status == "DEGRADED":
|
|
97
|
+
wanted = BackendName.onnxruntime
|
|
98
|
+
notes.append("--force-onnx: serving a DEGRADED graph; outputs may be wrong")
|
|
99
|
+
|
|
100
|
+
has_onnx = verdict.onnx_program is not None or verdict.onnx_path is not None
|
|
101
|
+
has_torch = verdict.prepared is not None
|
|
102
|
+
if wanted == BackendName.onnxruntime and not has_onnx:
|
|
103
|
+
notes.append("no ONNX graph available; falling back to torch")
|
|
104
|
+
wanted = BackendName.torch
|
|
105
|
+
if wanted == BackendName.torch and not has_torch:
|
|
106
|
+
raise ValueError("torch backend requested but there is no PyTorch model to run")
|
|
107
|
+
return wanted, notes
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_backend(name: BackendName, verdict: ExportVerdict, opts: ServeOptions) -> Backend:
|
|
111
|
+
if name == BackendName.onnxruntime:
|
|
112
|
+
if verdict.onnx_path is not None:
|
|
113
|
+
source: bytes | Path = verdict.onnx_path
|
|
114
|
+
else:
|
|
115
|
+
program: Any = verdict.onnx_program
|
|
116
|
+
source = program.model_proto.SerializeToString()
|
|
117
|
+
return OnnxRuntimeBackend(source, opts.device, opts.intra_op_threads, opts.inter_op_threads)
|
|
118
|
+
prepared = verdict.prepared
|
|
119
|
+
assert prepared is not None
|
|
120
|
+
return TorchBackend(prepared.model, prepared.input_names, opts.device, prepared.inputs)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def prepare_serving(
|
|
124
|
+
loaded: LoadedModel, opts: ServeOptions | None = None, reference: LoadedModel | None = None
|
|
125
|
+
) -> ServingState:
|
|
126
|
+
opts = opts or ServeOptions()
|
|
127
|
+
verdict = _verdict_for(loaded, reference, opts)
|
|
128
|
+
name, notes = choose_backend(verdict, opts)
|
|
129
|
+
backend = _build_backend(name, verdict, opts)
|
|
130
|
+
|
|
131
|
+
if verdict.prepared is not None:
|
|
132
|
+
input_names = verdict.prepared.input_names
|
|
133
|
+
example_inputs = verdict.prepared.inputs
|
|
134
|
+
else:
|
|
135
|
+
input_names = tuple(backend.input_names)
|
|
136
|
+
example_inputs = None
|
|
137
|
+
|
|
138
|
+
state = ServingState(
|
|
139
|
+
loaded.source, verdict, backend, input_names, opts, example_inputs, notes=notes
|
|
140
|
+
)
|
|
141
|
+
warmup(state, opts.warmup)
|
|
142
|
+
return state
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def warmup(state: ServingState, n: int) -> None:
|
|
146
|
+
"""Run a few inferences before /ready flips; first-call costs shouldn't hit users."""
|
|
147
|
+
if state.example_inputs is not None:
|
|
148
|
+
feeds = {
|
|
149
|
+
name: t.numpy() if isinstance(t, torch.Tensor) else np.asarray(t)
|
|
150
|
+
for name, t in zip(state.input_names, state.example_inputs, strict=True)
|
|
151
|
+
}
|
|
152
|
+
for _ in range(n):
|
|
153
|
+
state.backend.infer(feeds)
|
|
154
|
+
state.ready = True
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Attach user-supplied middleware given as "pkg.module:attr" import specs."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
|
|
9
|
+
from downshift.loading import import_object
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_middleware(app: FastAPI, specs: Sequence[str]) -> None:
|
|
13
|
+
"""Each spec names a BaseHTTPMiddleware subclass or an async (request, call_next) function."""
|
|
14
|
+
for spec in specs:
|
|
15
|
+
obj = import_object(spec)
|
|
16
|
+
if inspect.isclass(obj) and issubclass(obj, BaseHTTPMiddleware):
|
|
17
|
+
app.add_middleware(obj)
|
|
18
|
+
elif inspect.iscoroutinefunction(obj):
|
|
19
|
+
app.middleware("http")(obj)
|
|
20
|
+
else:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"{spec!r} is not middleware: expected a BaseHTTPMiddleware subclass or an "
|
|
23
|
+
f"async function taking (request, call_next), got {type(obj).__name__}"
|
|
24
|
+
)
|