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.
Files changed (38) hide show
  1. ml4gw_hermes-0.0.1/LICENSE +21 -0
  2. ml4gw_hermes-0.0.1/PKG-INFO +27 -0
  3. ml4gw_hermes-0.0.1/hermes/__init__.py +0 -0
  4. ml4gw_hermes-0.0.1/hermes/aeriel/client/__init__.py +1 -0
  5. ml4gw_hermes-0.0.1/hermes/aeriel/client/client.py +616 -0
  6. ml4gw_hermes-0.0.1/hermes/aeriel/monitor/__init__.py +3 -0
  7. ml4gw_hermes-0.0.1/hermes/aeriel/monitor/logging.py +38 -0
  8. ml4gw_hermes-0.0.1/hermes/aeriel/monitor/monitor.py +337 -0
  9. ml4gw_hermes-0.0.1/hermes/aeriel/monitor/process.py +223 -0
  10. ml4gw_hermes-0.0.1/hermes/aeriel/monitor/utils.py +68 -0
  11. ml4gw_hermes-0.0.1/hermes/aeriel/serve/__init__.py +1 -0
  12. ml4gw_hermes-0.0.1/hermes/aeriel/serve/serve.py +247 -0
  13. ml4gw_hermes-0.0.1/hermes/quiver/__init__.py +9 -0
  14. ml4gw_hermes-0.0.1/hermes/quiver/exporters/__init__.py +6 -0
  15. ml4gw_hermes-0.0.1/hermes/quiver/exporters/ensemble.py +28 -0
  16. ml4gw_hermes-0.0.1/hermes/quiver/exporters/exporter.py +273 -0
  17. ml4gw_hermes-0.0.1/hermes/quiver/exporters/keras_savedmodel.py +108 -0
  18. ml4gw_hermes-0.0.1/hermes/quiver/exporters/tensorrt/__init__.py +0 -0
  19. ml4gw_hermes-0.0.1/hermes/quiver/exporters/tensorrt/onnx.py +155 -0
  20. ml4gw_hermes-0.0.1/hermes/quiver/exporters/torch_onnx.py +152 -0
  21. ml4gw_hermes-0.0.1/hermes/quiver/exporters/torch_tensorrt.py +163 -0
  22. ml4gw_hermes-0.0.1/hermes/quiver/exporters/torchscript.py +149 -0
  23. ml4gw_hermes-0.0.1/hermes/quiver/exporters/utils.py +55 -0
  24. ml4gw_hermes-0.0.1/hermes/quiver/io/__init__.py +3 -0
  25. ml4gw_hermes-0.0.1/hermes/quiver/io/exceptions.py +3 -0
  26. ml4gw_hermes-0.0.1/hermes/quiver/io/file_system.py +56 -0
  27. ml4gw_hermes-0.0.1/hermes/quiver/io/gcs.py +209 -0
  28. ml4gw_hermes-0.0.1/hermes/quiver/io/local.py +102 -0
  29. ml4gw_hermes-0.0.1/hermes/quiver/model.py +456 -0
  30. ml4gw_hermes-0.0.1/hermes/quiver/model_config.py +274 -0
  31. ml4gw_hermes-0.0.1/hermes/quiver/model_repository.py +177 -0
  32. ml4gw_hermes-0.0.1/hermes/quiver/platform.py +18 -0
  33. ml4gw_hermes-0.0.1/hermes/quiver/streaming/__init__.py +2 -0
  34. ml4gw_hermes-0.0.1/hermes/quiver/streaming/streaming_input.py +122 -0
  35. ml4gw_hermes-0.0.1/hermes/quiver/streaming/streaming_output.py +201 -0
  36. ml4gw_hermes-0.0.1/hermes/quiver/streaming/utils.py +66 -0
  37. ml4gw_hermes-0.0.1/hermes/quiver/types.py +5 -0
  38. 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())
@@ -0,0 +1,3 @@
1
+ from .monitor import ServerMonitor
2
+ from .process import Pipeline, PipelineProcess
3
+ from .utils import ExceptionWrapper