Skip to content

Commit 9dd1ea0

Browse files
discobotbhimrazy
andauthored
Support context argument in batch and unbatch methods (#711)
* Support context argument in batch and unbatch methods The batched loops called lit_api.batch and lit_api.unbatch directly, so user implementations declaring a context parameter failed with a TypeError. Route both calls through _inject_context, the same way decode_request, predict and encode_response are already handled, in both the regular and streaming batched loops. Document the optional context argument in the LitAPI.batch/unbatch docstrings and add regression tests covering both loops. Fixes #617 * test: exercise a real batch in the batch/unbatch context test The test set max_batch_size=2 but sent a single request, so every batch had size 1 and the ordering between contexts and requests was never checked. Send two concurrent requests with different inputs instead and assert each one gets its own context value back. Each context also records the batch size, so the test fails loudly if the two requests ever stop sharing a batch rather than silently degrading. * docs: show how to use context in batch/unbatch docstrings Say which signature to write and note that the argument has to be named context, since _inject_context matches on the parameter name and `**kwargs` never receives it. Drops the RST markup for the plain prose and indented examples used elsewhere in the file. --------- Co-authored-by: Bhimraj Yadav <bhimrajyadav977@gmail.com>
1 parent b44a9a1 commit 9dd1ea0

4 files changed

Lines changed: 81 additions & 5 deletions

File tree

src/litserve/api.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,12 @@ def decode_request(self, request, **kwargs):
252252
return request
253253

254254
def batch(self, inputs):
255-
"""Convert a list of inputs to a batched input."""
255+
"""Convert a list of inputs to a batched input.
256+
257+
Add a context argument, batch(self, inputs, context), to also get the request context: one dict per input, in
258+
the same order. The argument must be named context.
259+
260+
"""
256261
# consider assigning an implementation when starting server
257262
# to avoid the runtime cost of checking (should be negligible)
258263
if hasattr(inputs[0], "__torch_function__"):
@@ -325,6 +330,14 @@ def unbatch(self, output):
325330
If you need to return dictionaries, return a list of dicts:
326331
[{"key1": val1, "key2": val3}, {"key1": val2, "key2": val4}] # Correct
327332
333+
Add a context argument to also get the request context, one dict per request in the same
334+
order as the outputs:
335+
336+
def unbatch(self, output, context):
337+
return [ctx["input"] for ctx in context]
338+
339+
The argument must be named context.
340+
328341
"""
329342
if self._default_unbatch is None:
330343
raise ValueError(

src/litserve/loops/simple_loops.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,13 +366,13 @@ def run_batched_loop(
366366
]
367367
callback_runner.trigger_event(EventTypes.AFTER_DECODE_REQUEST.value, lit_api=lit_api)
368368

369-
x = lit_api.batch(x)
369+
x = _inject_context(contexts, lit_api.batch, x)
370370

371371
callback_runner.trigger_event(EventTypes.BEFORE_PREDICT.value, lit_api=lit_api)
372372
y = _inject_context(contexts, lit_api.predict, x)
373373
callback_runner.trigger_event(EventTypes.AFTER_PREDICT.value, lit_api=lit_api)
374374

375-
outputs = lit_api.unbatch(y)
375+
outputs = _inject_context(contexts, lit_api.unbatch, y)
376376

377377
if len(outputs) != num_inputs:
378378
actual = len(outputs)

src/litserve/loops/streaming_loops.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,13 +347,13 @@ def run_batched_streaming_loop(
347347
]
348348
callback_runner.trigger_event(EventTypes.AFTER_DECODE_REQUEST.value, lit_api=lit_api)
349349

350-
x = lit_api.batch(x)
350+
x = _inject_context(contexts, lit_api.batch, x)
351351

352352
callback_runner.trigger_event(EventTypes.BEFORE_PREDICT.value, lit_api=lit_api)
353353
y_iter = _inject_context(contexts, lit_api.predict, x)
354354
callback_runner.trigger_event(EventTypes.AFTER_PREDICT.value, lit_api=lit_api)
355355

356-
unbatched_iter = lit_api.unbatch(y_iter)
356+
unbatched_iter = _inject_context(contexts, lit_api.unbatch, y_iter)
357357

358358
callback_runner.trigger_event(EventTypes.BEFORE_ENCODE_RESPONSE.value, lit_api=lit_api)
359359
y_enc_iter = _inject_context(contexts, lit_api.encode_response, unbatched_iter)

tests/unit/test_lit_server.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,39 @@ def encode_response(self, output_stream, context):
395395
yield [{"output": ctx["input"]} for ctx in context]
396396

397397

398+
class BatchUnbatchContextAPI(ls.test_examples.SimpleBatchedAPI):
399+
def batch(self, inputs, context):
400+
for c, x in zip(context, inputs):
401+
c["input"] = float(x)
402+
c["batch_size"] = len(inputs)
403+
return super().batch(inputs)
404+
405+
def unbatch(self, output, context):
406+
return [{"input": c["input"], "batch_size": c["batch_size"]} for c in context]
407+
408+
def encode_response(self, output):
409+
return {"output": output["input"], "batch_size": output["batch_size"]}
410+
411+
412+
class BatchUnbatchContextStreamingAPI(ls.test_examples.SimpleBatchedAPI):
413+
def batch(self, inputs, context):
414+
for c, x in zip(context, inputs):
415+
c["input"] = float(x)
416+
c["batch_size"] = len(inputs)
417+
return super().batch(inputs)
418+
419+
def predict(self, x_batch):
420+
yield self.model(x_batch)
421+
422+
def unbatch(self, output_stream, context):
423+
for _ in output_stream:
424+
yield [{"input": c["input"], "batch_size": c["batch_size"]} for c in context]
425+
426+
def encode_response(self, output_stream):
427+
for outputs in output_stream:
428+
yield [{"output": output["input"], "batch_size": output["batch_size"]} for output in outputs]
429+
430+
398431
class PredictErrorAPI(ls.test_examples.SimpleLitAPI):
399432
def predict(self, x, y, context):
400433
context["input"] = x
@@ -444,6 +477,36 @@ async def test_inject_context():
444477
assert resp.status_code == 500, "predict() missed 1 required positional argument: 'y'"
445478

446479

480+
@pytest.mark.asyncio
481+
async def test_inject_context_in_batch_and_unbatch():
482+
# Two requests at once, so each one must get its own context back. batched loop:
483+
server = LitServer(BatchUnbatchContextAPI(max_batch_size=2, batch_timeout=4), timeout=10)
484+
with wrap_litserve_start(server) as server:
485+
async with (
486+
LifespanManager(server.app) as manager,
487+
AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://test") as ac,
488+
):
489+
resp1 = ac.post("/predict", json={"input": 5.0})
490+
resp2 = ac.post("/predict", json={"input": 7.0})
491+
resp1, resp2 = await asyncio.gather(resp1, resp2)
492+
# batch_size == 2 proves they really shared a batch
493+
assert resp1.json() == {"output": 5.0, "batch_size": 2}
494+
assert resp2.json() == {"output": 7.0, "batch_size": 2}
495+
496+
# ...and the same for the batched streaming loop:
497+
server = LitServer(BatchUnbatchContextStreamingAPI(max_batch_size=2, batch_timeout=4, stream=True), timeout=10)
498+
with wrap_litserve_start(server) as server:
499+
async with (
500+
LifespanManager(server.app) as manager,
501+
AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://test") as ac,
502+
):
503+
resp1 = ac.post("/predict", json={"input": 5.0})
504+
resp2 = ac.post("/predict", json={"input": 7.0})
505+
resp1, resp2 = await asyncio.gather(resp1, resp2)
506+
assert resp1.json() == {"output": 5.0, "batch_size": 2}
507+
assert resp2.json() == {"output": 7.0, "batch_size": 2}
508+
509+
447510
def test_custom_api_path():
448511
with pytest.raises(ValueError, match="api_path must start with '/'"):
449512
LitServer(ls.test_examples.SimpleLitAPI(api_path="predict"))

0 commit comments

Comments
 (0)