ml4gw-hermes 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ml4gw_hermes-0.0.1/LICENSE +21 -0
- ml4gw_hermes-0.0.1/PKG-INFO +27 -0
- ml4gw_hermes-0.0.1/hermes/__init__.py +0 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/client/__init__.py +1 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/client/client.py +616 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/monitor/__init__.py +3 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/monitor/logging.py +38 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/monitor/monitor.py +337 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/monitor/process.py +223 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/monitor/utils.py +68 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/serve/__init__.py +1 -0
- ml4gw_hermes-0.0.1/hermes/aeriel/serve/serve.py +247 -0
- ml4gw_hermes-0.0.1/hermes/quiver/__init__.py +9 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/__init__.py +6 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/ensemble.py +28 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/exporter.py +273 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/keras_savedmodel.py +108 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/tensorrt/__init__.py +0 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/tensorrt/onnx.py +155 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/torch_onnx.py +152 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/torch_tensorrt.py +163 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/torchscript.py +149 -0
- ml4gw_hermes-0.0.1/hermes/quiver/exporters/utils.py +55 -0
- ml4gw_hermes-0.0.1/hermes/quiver/io/__init__.py +3 -0
- ml4gw_hermes-0.0.1/hermes/quiver/io/exceptions.py +3 -0
- ml4gw_hermes-0.0.1/hermes/quiver/io/file_system.py +56 -0
- ml4gw_hermes-0.0.1/hermes/quiver/io/gcs.py +209 -0
- ml4gw_hermes-0.0.1/hermes/quiver/io/local.py +102 -0
- ml4gw_hermes-0.0.1/hermes/quiver/model.py +456 -0
- ml4gw_hermes-0.0.1/hermes/quiver/model_config.py +274 -0
- ml4gw_hermes-0.0.1/hermes/quiver/model_repository.py +177 -0
- ml4gw_hermes-0.0.1/hermes/quiver/platform.py +18 -0
- ml4gw_hermes-0.0.1/hermes/quiver/streaming/__init__.py +2 -0
- ml4gw_hermes-0.0.1/hermes/quiver/streaming/streaming_input.py +122 -0
- ml4gw_hermes-0.0.1/hermes/quiver/streaming/streaming_output.py +201 -0
- ml4gw_hermes-0.0.1/hermes/quiver/streaming/utils.py +66 -0
- ml4gw_hermes-0.0.1/hermes/quiver/types.py +5 -0
- ml4gw_hermes-0.0.1/pyproject.toml +86 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 ML4GW
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: ml4gw-hermes
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Inference-as-a-Service deployment made simple
|
|
5
|
+
Author: Alec Gunny
|
|
6
|
+
Author-email: alec.gunny@ligo.org
|
|
7
|
+
Requires-Python: >=3.8,<3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Provides-Extra: gcs
|
|
14
|
+
Provides-Extra: tensorflow
|
|
15
|
+
Provides-Extra: tensorrt
|
|
16
|
+
Provides-Extra: torch
|
|
17
|
+
Requires-Dist: google-cloud-storage (>=1.38,<2.0) ; extra == "gcs"
|
|
18
|
+
Requires-Dist: numpy (>=1.22,<2.0)
|
|
19
|
+
Requires-Dist: nvidia-tensorrt (>=8.0,<9.0) ; extra == "tensorrt"
|
|
20
|
+
Requires-Dist: protobuf (>=3.17,<4.0)
|
|
21
|
+
Requires-Dist: requests (>=2.26.0,<3.0.0)
|
|
22
|
+
Requires-Dist: spython (>=0.2,<0.3)
|
|
23
|
+
Requires-Dist: tblib (>=1.7,<2.0)
|
|
24
|
+
Requires-Dist: tensorflow (>=2.3,<3.0) ; extra == "tensorflow"
|
|
25
|
+
Requires-Dist: torch (>=1.7,<2.0) ; extra == "torch"
|
|
26
|
+
Requires-Dist: tritonclient[all] (>=2.22,<3.0)
|
|
27
|
+
Requires-Dist: urllib3 (>=1.26,<2.0) ; extra == "torch"
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .client import InferenceClient
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
# TODO: create custom future object which inherits from
|
|
2
|
+
# concurrent.futures.Future and store in an
|
|
3
|
+
# `InferenceClient.futures` dictionary that maps from a
|
|
4
|
+
# (request_id, sequence_id) to the corresponding Future,
|
|
5
|
+
# use request_id and sequence_id in callback to pop the
|
|
6
|
+
# corresponding future and use `Future.set_result` or
|
|
7
|
+
# `Future.set_exception` as required. Set request_id
|
|
8
|
+
# and sequence_id attributes on the Future for use by
|
|
9
|
+
# calling processes.
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import random
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from copy import deepcopy
|
|
17
|
+
from queue import Empty, Queue
|
|
18
|
+
from typing import (
|
|
19
|
+
TYPE_CHECKING,
|
|
20
|
+
Any,
|
|
21
|
+
Callable,
|
|
22
|
+
Dict,
|
|
23
|
+
List,
|
|
24
|
+
Optional,
|
|
25
|
+
Tuple,
|
|
26
|
+
Union,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import tritonclient.grpc as triton
|
|
31
|
+
from tritonclient.utils import InferenceServerException
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from tritonclient.grpc.model_config_pb2 import InferInput
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SHAPE = Tuple[int, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProfilerClock:
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self._start_times = {}
|
|
43
|
+
self.latencies = defaultdict(list)
|
|
44
|
+
|
|
45
|
+
def tick(self, request_id, sequence_id):
|
|
46
|
+
self._start_times[(request_id, sequence_id)] = time.time()
|
|
47
|
+
|
|
48
|
+
def tock(self, request_id, sequence_id):
|
|
49
|
+
end_time = time.time()
|
|
50
|
+
start_time = self._start_times.pop((request_id, sequence_id))
|
|
51
|
+
self.latencies[sequence_id].append(end_time - start_time)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_ready(
|
|
55
|
+
client: triton.InferenceServerClient, model_name: str, model_version: str
|
|
56
|
+
) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Check if the given model and version are ready on an inference
|
|
59
|
+
service instance which is being connected to by `client`
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
if not client.is_model_ready(model_name, model_version):
|
|
63
|
+
# the desired model and version aren't ready, so
|
|
64
|
+
# see if we can load the model into the service
|
|
65
|
+
try:
|
|
66
|
+
logging.info(f"Attempting to load model {model_name}")
|
|
67
|
+
client.load_model(model_name)
|
|
68
|
+
logging.info(f"Model {model_name} loaded")
|
|
69
|
+
except InferenceServerException as e:
|
|
70
|
+
# catch an exeption indicating that Triton doesn't
|
|
71
|
+
# have explicit control mode on, and raise it
|
|
72
|
+
# more explicitly
|
|
73
|
+
if str(e).endswith("polling is enabled"):
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"Model {}, version {} isn't ready on server "
|
|
76
|
+
"and explicit model control not enabled".format(
|
|
77
|
+
model_name, model_version
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
if not client.is_model_ready(model_name, model_version):
|
|
84
|
+
# evidently we were able to load the model, but
|
|
85
|
+
# the desired version didn't get loaded with it,
|
|
86
|
+
# so we're out of options here and need to bail
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"Model {} is available but not version {}".format(
|
|
89
|
+
model_name, model_version
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InferenceClient:
|
|
95
|
+
"""Connect to a Triton server instance and make requests to it
|
|
96
|
+
|
|
97
|
+
Connect to a Triton inference service instance running at
|
|
98
|
+
the specified address for performing inference with the
|
|
99
|
+
indicated model. Dynamically infer the names, shapes, and
|
|
100
|
+
datatypes of the inputs to the model, as well as whether
|
|
101
|
+
any of those inputs are meant to be stateful.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
address:
|
|
105
|
+
The url at which the service is being hosted
|
|
106
|
+
model_name:
|
|
107
|
+
The name of the model to which to make requests
|
|
108
|
+
model_version:
|
|
109
|
+
The version of the model to which to make requests. If
|
|
110
|
+
set to `-1`, the latest version of the model will be
|
|
111
|
+
dynamically inferred.
|
|
112
|
+
batch_size:
|
|
113
|
+
The batch size to use for the non-stateful inputs
|
|
114
|
+
to the model. Inputs with batch sizes smaller than
|
|
115
|
+
this will need to be padded since the full batch
|
|
116
|
+
dimension will be strictly enforced.
|
|
117
|
+
callback:
|
|
118
|
+
Optional function to call on the parsed response
|
|
119
|
+
from the inference service
|
|
120
|
+
profile:
|
|
121
|
+
Whether to record round-trip latencies to and from
|
|
122
|
+
the server in a `self.clock` attribute.
|
|
123
|
+
client_kwargs:
|
|
124
|
+
Additional kwargs to pass to the Triton
|
|
125
|
+
`InferenceServerClient`
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
address: str,
|
|
131
|
+
model_name: str,
|
|
132
|
+
model_version: int = -1,
|
|
133
|
+
batch_size: int = 1,
|
|
134
|
+
callback: Optional[Callable] = None,
|
|
135
|
+
profile: bool = False,
|
|
136
|
+
**client_kwargs,
|
|
137
|
+
) -> None:
|
|
138
|
+
client = triton.InferenceServerClient(address, **client_kwargs)
|
|
139
|
+
try:
|
|
140
|
+
if not client.is_server_live():
|
|
141
|
+
raise RuntimeError(f"Server at url {address} isn't live")
|
|
142
|
+
except triton.InferenceServerException:
|
|
143
|
+
raise RuntimeError(f"Couldn't connect to server at {address}")
|
|
144
|
+
self.client = client
|
|
145
|
+
self.address = address
|
|
146
|
+
|
|
147
|
+
# `inputs` will be a list of Triton `InferInput`
|
|
148
|
+
# objects representing the non-stateful inputs
|
|
149
|
+
# to the model
|
|
150
|
+
# `states` will be a list of tuples where the first
|
|
151
|
+
# input will be a Triton `InferInput` object representing
|
|
152
|
+
# an input to a snapshotter model, and the second
|
|
153
|
+
# will be a dictionary mapping from downstream input names
|
|
154
|
+
# that the snapshotter feeds to the shape of that input
|
|
155
|
+
self.model_name = model_name
|
|
156
|
+
self.inputs, self.states = self._build_inputs(batch_size)
|
|
157
|
+
self.num_states = sum([len(i[1]) for i in self.states])
|
|
158
|
+
|
|
159
|
+
# infer the model version and make sure that the desired
|
|
160
|
+
# model is ready to have requests made to it
|
|
161
|
+
if model_version == -1:
|
|
162
|
+
model_version = max(map(int, self.metadata.versions))
|
|
163
|
+
_check_ready(self.client, model_name, str(model_version))
|
|
164
|
+
self.model_version = model_version
|
|
165
|
+
|
|
166
|
+
# set some things up for the streaming callback
|
|
167
|
+
self.callback = callback
|
|
168
|
+
if profile:
|
|
169
|
+
self.clock = ProfilerClock()
|
|
170
|
+
else:
|
|
171
|
+
self.clock = None
|
|
172
|
+
self.callback_q = Queue()
|
|
173
|
+
self._sequences = {}
|
|
174
|
+
|
|
175
|
+
def _build_inputs(
|
|
176
|
+
self, batch_size: int
|
|
177
|
+
) -> Tuple[
|
|
178
|
+
List["InferInput"],
|
|
179
|
+
List[Tuple["InferInput", Dict[str, SHAPE]]],
|
|
180
|
+
]:
|
|
181
|
+
"""Build Triton InferInputs for the inputs to the model
|
|
182
|
+
|
|
183
|
+
Use the metadata returned by the inference server about
|
|
184
|
+
the model to infer the names, shapes, and datatypes of
|
|
185
|
+
model inputs and build the corresponding protobuf objects
|
|
186
|
+
using this info.
|
|
187
|
+
|
|
188
|
+
For models with streaming input states, infer the different
|
|
189
|
+
input states associated with a snapshot and the order in
|
|
190
|
+
which they're concatenated along the channel axis.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
# TODO: confirm that batch_size is not larger
|
|
194
|
+
# than config.max_batch_size?
|
|
195
|
+
config = self.client.get_model_config(self.model_name).config
|
|
196
|
+
metadata = self.client.get_model_metadata(self.model_name)
|
|
197
|
+
|
|
198
|
+
# TODO: check for stateful outputs for enforcing a
|
|
199
|
+
# sequence_id value in `InferenceClient.infer`
|
|
200
|
+
states, inputs = [], []
|
|
201
|
+
for config_input, metadata_input in zip(config.input, metadata.inputs):
|
|
202
|
+
shape = [i if i > 0 else batch_size for i in metadata_input.shape]
|
|
203
|
+
input = triton.InferInput(
|
|
204
|
+
name=metadata_input.name,
|
|
205
|
+
shape=shape,
|
|
206
|
+
datatype=metadata_input.datatype,
|
|
207
|
+
)
|
|
208
|
+
for step in config.ensemble_scheduling.step:
|
|
209
|
+
# see if this input corresponds to
|
|
210
|
+
# the input for a snapshotter model
|
|
211
|
+
input_map = list(step.input_map.values())
|
|
212
|
+
if len(input_map) == 1 and input_map[0] == config_input.name:
|
|
213
|
+
model_config = self.client.get_model_config(
|
|
214
|
+
step.model_name
|
|
215
|
+
)
|
|
216
|
+
model_config = model_config.config
|
|
217
|
+
if len(model_config.sequence_batching.state) == 0:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# only support streaming with batch size 1
|
|
221
|
+
shape[0] = 1
|
|
222
|
+
|
|
223
|
+
# iterate through the outputs of the snapshotter
|
|
224
|
+
# and figure out how many channels each of its
|
|
225
|
+
# states need to have. Record them in a dict
|
|
226
|
+
# mapping from the name of the state to the
|
|
227
|
+
# number of channels in the state
|
|
228
|
+
channel_map = {}
|
|
229
|
+
for x in model_config.output:
|
|
230
|
+
map_key = step.output_map[x.name]
|
|
231
|
+
|
|
232
|
+
# look for the model whose input
|
|
233
|
+
# gets fed by this output
|
|
234
|
+
for s in config.ensemble_scheduling.step:
|
|
235
|
+
for key, val in s.input_map.items():
|
|
236
|
+
if val == map_key:
|
|
237
|
+
channel_name = f"{s.model_name}/{key}"
|
|
238
|
+
shape = list(metadata_input.shape)
|
|
239
|
+
shape[1] = x.dims[1]
|
|
240
|
+
channel_map[channel_name] = tuple(shape)
|
|
241
|
+
break
|
|
242
|
+
else:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# add this state to our states
|
|
246
|
+
states.append((input, channel_map))
|
|
247
|
+
break
|
|
248
|
+
else:
|
|
249
|
+
# the loop didn't break, which means
|
|
250
|
+
# that this input didn't meet the criterion
|
|
251
|
+
# for being a snapshotter input, so just
|
|
252
|
+
# add it as a regular input
|
|
253
|
+
inputs.append(input)
|
|
254
|
+
|
|
255
|
+
# record the config and metadata for external reference
|
|
256
|
+
self.config = config
|
|
257
|
+
self.metadata = metadata
|
|
258
|
+
|
|
259
|
+
return inputs, states
|
|
260
|
+
|
|
261
|
+
def get(self, until_empty: bool = False) -> Union[Any, List[Any], None]:
|
|
262
|
+
"""Check the if the callback thread has produced anything
|
|
263
|
+
|
|
264
|
+
Makes a check to see if the callback thread has produced
|
|
265
|
+
any inference responses since the last check. If any
|
|
266
|
+
errors have been raised in the callback, they will get
|
|
267
|
+
raised with their traceback here.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
until_empty:
|
|
271
|
+
If `True`, grab server responses from the
|
|
272
|
+
`callback_q` until `queue.Empty` gets raised,
|
|
273
|
+
and return a list containing all the responses.
|
|
274
|
+
Otherwise, return the first reponse produced by
|
|
275
|
+
the `callback_q`.
|
|
276
|
+
Returns:
|
|
277
|
+
If `until_empty` is `True`, returns a `list` of
|
|
278
|
+
each of the server responses waiting in the
|
|
279
|
+
`callback_q`. Otherwise, if there is a response
|
|
280
|
+
in the `callback_q`, return it, returning `None`
|
|
281
|
+
if the queue is empty.
|
|
282
|
+
Raises:
|
|
283
|
+
Exception:
|
|
284
|
+
If `sys.exc_info()` is retrieved from the
|
|
285
|
+
`callback_q`, the exception will be raised
|
|
286
|
+
with its traceback in the callback thread.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
responses = []
|
|
290
|
+
while True:
|
|
291
|
+
try:
|
|
292
|
+
response = self.callback_q.get_nowait()
|
|
293
|
+
except Empty:
|
|
294
|
+
# there's nothing in the queue
|
|
295
|
+
if until_empty:
|
|
296
|
+
# stop looking for more responses and
|
|
297
|
+
# return whatever we found, even if
|
|
298
|
+
# its an empty list
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
# otherwise return `None`
|
|
302
|
+
return
|
|
303
|
+
else:
|
|
304
|
+
if (
|
|
305
|
+
isinstance(response, tuple)
|
|
306
|
+
and len(response) == 3
|
|
307
|
+
and isinstance(response[1], Exception)
|
|
308
|
+
):
|
|
309
|
+
# complicated check that this looks like the
|
|
310
|
+
# return from sys.exc_info(), indicating that
|
|
311
|
+
# something went wrong in the callback
|
|
312
|
+
_, exc, tb = response
|
|
313
|
+
raise exc.with_traceback(tb)
|
|
314
|
+
|
|
315
|
+
if not until_empty:
|
|
316
|
+
# we got a response and we only asked
|
|
317
|
+
# for one, so return it
|
|
318
|
+
return response
|
|
319
|
+
else:
|
|
320
|
+
# otherwise add it to our running list
|
|
321
|
+
responses.append(response)
|
|
322
|
+
|
|
323
|
+
# this must mean `until_empty is True`, so
|
|
324
|
+
# return all the responses we got, even if
|
|
325
|
+
# there were no responses at all
|
|
326
|
+
return responses
|
|
327
|
+
|
|
328
|
+
def __enter__(self):
|
|
329
|
+
self.client.__enter__()
|
|
330
|
+
if len(self.states) > 0:
|
|
331
|
+
self.client.start_stream(callback=self._callback)
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
def __exit__(self, *exc_args):
|
|
335
|
+
# if we've run into an error, stop sending
|
|
336
|
+
# requests to the server. Clear the client's
|
|
337
|
+
# internal request queue and add its sentinel,
|
|
338
|
+
# None, to indicate to kill its thread
|
|
339
|
+
if len(self.states) > 0 and exc_args[0] is not None:
|
|
340
|
+
self.client._stream._request_queue.queue.clear()
|
|
341
|
+
self.client._stream._request_queue.put(None)
|
|
342
|
+
self.client.__exit__(*exc_args)
|
|
343
|
+
|
|
344
|
+
def _validate_inputs(
|
|
345
|
+
self,
|
|
346
|
+
x: Union[np.ndarray, Dict[str, np.ndarray]],
|
|
347
|
+
sequence_id: Optional[int] = None,
|
|
348
|
+
):
|
|
349
|
+
"""
|
|
350
|
+
Normalize an inference input and grab the gRPC
|
|
351
|
+
InferenceInput objects it will we used to fill
|
|
352
|
+
with data in a thread-safe fashion.
|
|
353
|
+
"""
|
|
354
|
+
# if we just passed a single array, make sure we only
|
|
355
|
+
# have one input or state that we need to pass it to
|
|
356
|
+
if not isinstance(x, dict):
|
|
357
|
+
if len(self.inputs) + self.num_states > 1:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"Only passed a single input array, but "
|
|
360
|
+
"model {} has {} inputs and {} states".format(
|
|
361
|
+
self.model_name, len(self.inputs), len(self.states)
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
elif len(self.inputs) > 0:
|
|
365
|
+
# if we only have a non-stateful input, set `x`
|
|
366
|
+
# up to keep the parsing methods below more standard
|
|
367
|
+
x = {self.inputs[0].name(): x}
|
|
368
|
+
else:
|
|
369
|
+
# same for if we only have a stateful input
|
|
370
|
+
state_name = list(self.states[0][1].keys())[0]
|
|
371
|
+
x = {state_name: x}
|
|
372
|
+
|
|
373
|
+
# now do some checks on the sequence that this input
|
|
374
|
+
# belongs to (if any) and grab or create the corresponding
|
|
375
|
+
# InferenceInputs it will be used to fill with data
|
|
376
|
+
if sequence_id is None and len(self.states) > 0:
|
|
377
|
+
# enforce that we provide a sequence id if
|
|
378
|
+
# there are any stateful inputs. TODO: should
|
|
379
|
+
# we do the same if there are any stateful outputs?
|
|
380
|
+
# Or just states somewhere in the model in general?
|
|
381
|
+
raise ValueError(
|
|
382
|
+
"Must provide sequence id for model with states {}".format(
|
|
383
|
+
[i[0].name() for i in self.states]
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
elif sequence_id is not None and self.num_states == 0:
|
|
387
|
+
raise ValueError(
|
|
388
|
+
"Specified sequence id {} for request to "
|
|
389
|
+
"non-stateful model {}".format(sequence_id, self.model_name)
|
|
390
|
+
)
|
|
391
|
+
elif sequence_id is not None and sequence_id not in self._sequences:
|
|
392
|
+
# this is a new sequence, so create a fresh set of inputs for it
|
|
393
|
+
# to make doing inference across multiple streams thread-safe
|
|
394
|
+
logging.debug(
|
|
395
|
+
f"Creating new inputs and states for sequence {sequence_id}"
|
|
396
|
+
)
|
|
397
|
+
inputs, states = deepcopy(self.inputs), deepcopy(self.states)
|
|
398
|
+
self._sequences[sequence_id] = (inputs, states)
|
|
399
|
+
elif sequence_id is not None:
|
|
400
|
+
# otherwise this is an existing sequence, so grab
|
|
401
|
+
# the corresponding inputs and states
|
|
402
|
+
inputs, states = self._sequences[sequence_id]
|
|
403
|
+
elif self.num_states == 0:
|
|
404
|
+
# we're not doing stateful inference, so there's
|
|
405
|
+
# no sequences to keep track of in the first place
|
|
406
|
+
inputs, states = self.inputs, []
|
|
407
|
+
return x, inputs, states, sequence_id
|
|
408
|
+
|
|
409
|
+
def infer(
|
|
410
|
+
self,
|
|
411
|
+
x: Union[np.ndarray, Dict[str, np.ndarray]],
|
|
412
|
+
request_id: Optional[int] = None,
|
|
413
|
+
sequence_id: Optional[int] = None,
|
|
414
|
+
sequence_start: bool = False,
|
|
415
|
+
sequence_end: bool = False,
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Make an asynchronous inference request to the service
|
|
418
|
+
|
|
419
|
+
Use the indicated input or inputs to make an inference
|
|
420
|
+
request to the model sitting on the inference service.
|
|
421
|
+
If this model requires multiple inputs or states, `x`
|
|
422
|
+
should be a dictionary mapping from the name of each
|
|
423
|
+
input or state to the corresponding value. Otherwise,
|
|
424
|
+
`x` may be a single numpy array representing the input
|
|
425
|
+
data for this inference request.
|
|
426
|
+
|
|
427
|
+
Responses from the inference service will be handled
|
|
428
|
+
in an asynchronous callback thread, with the parsed and
|
|
429
|
+
postprocessed values placed in this object's `callback_q`.
|
|
430
|
+
As a simple way to retrieve the values from that queue,
|
|
431
|
+
consider using the `InferenceClient.get` method.
|
|
432
|
+
|
|
433
|
+
Responses follow the same structure as the inputs: if
|
|
434
|
+
there are multiple outputs, the response will be a
|
|
435
|
+
dictionary mapping from output names to values. If there
|
|
436
|
+
is only a single output, it will be returned as a NumPy
|
|
437
|
+
array.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
x:
|
|
441
|
+
The inputs to the model sitting on the server.
|
|
442
|
+
If the model has multiple inputs (stateful or
|
|
443
|
+
stateless), this should be a `dict` mapping from
|
|
444
|
+
input names to corresopnding NumPy arrays. If
|
|
445
|
+
the model has only a single input, this may be
|
|
446
|
+
a single NumPy array containing that input's value.
|
|
447
|
+
request_id:
|
|
448
|
+
An identifier to associate with this inference request.
|
|
449
|
+
Will be passed along to the `callback` specified
|
|
450
|
+
at initialization, or if this is `None` it will
|
|
451
|
+
be placed in the `callback_q` alongside the response
|
|
452
|
+
values.
|
|
453
|
+
sequence_id:
|
|
454
|
+
An identifier to associate this request with a particular
|
|
455
|
+
state on the inference server. Required if the
|
|
456
|
+
indicated model has any stateful inputs, otherwise
|
|
457
|
+
won't do anything.
|
|
458
|
+
sequence_start:
|
|
459
|
+
Indicates whether this request is the first in a
|
|
460
|
+
new sequence. Won't do anything if the
|
|
461
|
+
model has no stateful inputs.
|
|
462
|
+
sequence_end:
|
|
463
|
+
Indicates whether this request is the final one
|
|
464
|
+
in a sequence. Won't do anything if the model has
|
|
465
|
+
no stateful inputs.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
x, inputs, states, sequence_id = self._validate_inputs(x, sequence_id)
|
|
469
|
+
|
|
470
|
+
# if we have any non-stateful inputs, set their
|
|
471
|
+
# input value using the corresponding package
|
|
472
|
+
for input in inputs:
|
|
473
|
+
name = input.name()
|
|
474
|
+
try:
|
|
475
|
+
value = x[name]
|
|
476
|
+
except KeyError:
|
|
477
|
+
raise ValueError(f"Missing input {name}")
|
|
478
|
+
|
|
479
|
+
# TODO: Should pad inputs with batches smaller than
|
|
480
|
+
# batch size, and record it somewhere for
|
|
481
|
+
# slicing in the callback? Also raise an error
|
|
482
|
+
# for batches that are larger than batch size?
|
|
483
|
+
input.set_data_from_numpy(value)
|
|
484
|
+
|
|
485
|
+
# for any streaming input states, collect all
|
|
486
|
+
# the updates for each state and set the input
|
|
487
|
+
# message value using those updates. Do checks
|
|
488
|
+
# on the sequence start and end values
|
|
489
|
+
for state, channel_map in states:
|
|
490
|
+
state_values = []
|
|
491
|
+
|
|
492
|
+
# for each update in the state, try to
|
|
493
|
+
# get the update for it and do the checks
|
|
494
|
+
for name, shape in channel_map.items():
|
|
495
|
+
try:
|
|
496
|
+
value = x[name]
|
|
497
|
+
except KeyError:
|
|
498
|
+
raise ValueError(f"Missing state {name}")
|
|
499
|
+
|
|
500
|
+
# add the update to our running list of updates
|
|
501
|
+
state_values.append(value[None])
|
|
502
|
+
|
|
503
|
+
# if we have more than one state, combine them
|
|
504
|
+
# into a single tensor along the channel axis
|
|
505
|
+
if len(state_values) > 1:
|
|
506
|
+
state = np.concatenate(state_values, axis=1)
|
|
507
|
+
state.set_data_from_numpy(state)
|
|
508
|
+
else:
|
|
509
|
+
state.set_data_from_numpy(state_values[0])
|
|
510
|
+
|
|
511
|
+
# if a request_id wasn't specified, give it a random
|
|
512
|
+
# one that will (probably) be unique
|
|
513
|
+
if request_id is None:
|
|
514
|
+
request_id = random.randint(0, 1e16)
|
|
515
|
+
|
|
516
|
+
# keep track of in-flight times if we're profiling
|
|
517
|
+
if self.clock is not None:
|
|
518
|
+
self.clock.tick(request_id, sequence_id)
|
|
519
|
+
|
|
520
|
+
# the server response won't contain info about
|
|
521
|
+
# the sequence id, so if we have one attach it
|
|
522
|
+
# to the request id so we can parse in the callback
|
|
523
|
+
if sequence_id is not None:
|
|
524
|
+
request_id = f"{request_id}_{sequence_id}"
|
|
525
|
+
else:
|
|
526
|
+
request_id = str(request_id)
|
|
527
|
+
|
|
528
|
+
if len(self.states) > 0:
|
|
529
|
+
# make a streaming inference if we have input states
|
|
530
|
+
# TODO: don't restrict ourselves just to input states
|
|
531
|
+
# but having stateful behavior anywhere in the model
|
|
532
|
+
self.client.async_stream_infer(
|
|
533
|
+
self.model_name,
|
|
534
|
+
model_version=str(self.model_version),
|
|
535
|
+
inputs=inputs + [x[0] for x in states],
|
|
536
|
+
request_id=request_id,
|
|
537
|
+
sequence_id=sequence_id,
|
|
538
|
+
sequence_start=sequence_start,
|
|
539
|
+
sequence_end=sequence_end,
|
|
540
|
+
timeout=60,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
if sequence_end:
|
|
544
|
+
# remove the inputs for this sequence if it's complete
|
|
545
|
+
self._sequences.pop(sequence_id)
|
|
546
|
+
else:
|
|
547
|
+
self.client.async_infer(
|
|
548
|
+
self.model_name,
|
|
549
|
+
model_version=str(self.model_version),
|
|
550
|
+
inputs=self.inputs,
|
|
551
|
+
request_id=request_id,
|
|
552
|
+
callback=self._callback,
|
|
553
|
+
timeout=60,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
def _callback(self, result, error=None) -> None:
|
|
557
|
+
"""Callback for Triton async inference calls"""
|
|
558
|
+
|
|
559
|
+
# wrap everything in a try catch in case either
|
|
560
|
+
# error is not `None` or something else goes
|
|
561
|
+
# wrong so we can report the issue downstream.
|
|
562
|
+
try:
|
|
563
|
+
# raise the error if anything went wrong
|
|
564
|
+
if error is not None:
|
|
565
|
+
error = RuntimeError(str(error))
|
|
566
|
+
raise error
|
|
567
|
+
|
|
568
|
+
# read the request id from the server response
|
|
569
|
+
request_id = result.get_response().id
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
# see if there is a sequence associated
|
|
573
|
+
# with this request and try to get its id
|
|
574
|
+
request_id, sequence_id = map(int, request_id.split("_"))
|
|
575
|
+
except ValueError:
|
|
576
|
+
# if not, return None for the sequence id
|
|
577
|
+
request_id = int(request_id)
|
|
578
|
+
sequence_id = None
|
|
579
|
+
|
|
580
|
+
# if we're profiling, record the in-flight latency
|
|
581
|
+
# associated with this request
|
|
582
|
+
if self.clock is not None:
|
|
583
|
+
self.clock.tock(request_id, sequence_id)
|
|
584
|
+
|
|
585
|
+
logging.debug(
|
|
586
|
+
"Received response for request {} from sequence {}".format(
|
|
587
|
+
request_id, sequence_id
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# parse the numpy arrays from the response and
|
|
592
|
+
# package them into a dictionary mapping from
|
|
593
|
+
# the name of the output to the array
|
|
594
|
+
np_output = {}
|
|
595
|
+
for output in result._result.outputs:
|
|
596
|
+
np_output[output.name] = result.as_numpy(output.name)
|
|
597
|
+
|
|
598
|
+
# if there's only one output, just return the numpy
|
|
599
|
+
# array and spare the user the need to keep track
|
|
600
|
+
# of input and output names
|
|
601
|
+
if len(np_output) == 1:
|
|
602
|
+
np_output = np_output[output.name]
|
|
603
|
+
|
|
604
|
+
# send these parsed outputs to downstream processes
|
|
605
|
+
response = (np_output, request_id, sequence_id)
|
|
606
|
+
if self.callback is not None:
|
|
607
|
+
response = self.callback(*response)
|
|
608
|
+
|
|
609
|
+
# give callbacks the option of returning `None`
|
|
610
|
+
# if all they have is some intermediate product
|
|
611
|
+
# that they don't want shipped back to the main thread.
|
|
612
|
+
if response is not None:
|
|
613
|
+
self.callback_q.put(response)
|
|
614
|
+
|
|
615
|
+
except Exception:
|
|
616
|
+
self.callback_q.put(sys.exc_info())
|