810 lines
27 KiB
Python
810 lines
27 KiB
Python
import asyncio
|
|
import os
|
|
from typing import List
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.test_utils import SignalActor, wait_for_condition
|
|
from ray._common.utils import get_or_create_event_loop
|
|
from ray.serve._private.common import DeploymentID, ReplicaID
|
|
from ray.serve._private.config import DeploymentConfig
|
|
from ray.serve._private.constants import SERVE_MULTIPLEXED_MODEL_ID
|
|
from ray.serve._private.request_router import RequestRouter
|
|
from ray.serve.context import _get_internal_replica_context
|
|
from ray.serve.handle import DeploymentHandle
|
|
from ray.serve.multiplex import _ModelMultiplexWrapper
|
|
|
|
|
|
def _get_request_router(handle: DeploymentHandle) -> RequestRouter:
|
|
# TODO(edoakes): we shouldn't be reaching into private fields, but better
|
|
# to isolate it to one place (this function).
|
|
return handle._router._asyncio_router._request_router
|
|
|
|
|
|
@pytest.fixture()
|
|
def start_serve_with_context():
|
|
serve.start()
|
|
ray.serve.context._set_internal_replica_context(
|
|
replica_id=ReplicaID(
|
|
"fake_replica_id",
|
|
deployment_id=DeploymentID(name="fake_deployment", app_name="fake_app"),
|
|
),
|
|
servable_object=None,
|
|
_deployment_config=DeploymentConfig(),
|
|
rank=0,
|
|
world_size=1,
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
serve.shutdown()
|
|
ray.serve.context._set_request_context()
|
|
ray.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestMultiplexWrapper:
|
|
async def test_failed_to_get_replica_context(self):
|
|
async def model_load_func(model_id: str):
|
|
return model_id
|
|
|
|
with pytest.raises(RuntimeError, match="can only be used within a deployment"):
|
|
_ModelMultiplexWrapper(model_load_func, None, max_num_models_per_replica=2)
|
|
|
|
async def test_push_model_ids_info(self, start_serve_with_context):
|
|
async def model_load_func(model_id: str):
|
|
return model_id
|
|
|
|
multiplexer = _ModelMultiplexWrapper(
|
|
model_load_func, None, max_num_models_per_replica=1
|
|
)
|
|
await multiplexer.metrics_pusher.graceful_shutdown()
|
|
assert multiplexer._push_multiplexed_replica_info is False
|
|
multiplexer._push_multiplexed_replica_info = True
|
|
multiplexer._push_model_ids_info()
|
|
assert multiplexer._push_multiplexed_replica_info is False
|
|
|
|
async def test_collect_model_ids(self):
|
|
multiplexer = _ModelMultiplexWrapper(None, None, max_num_models_per_replica=1)
|
|
multiplexer.models = {"1": "1", "2": "2"}
|
|
assert sorted(multiplexer._get_loading_and_loaded_model_ids()) == ["1", "2"]
|
|
multiplexer._model_load_tasks = {"3"}
|
|
assert sorted(multiplexer._get_loading_and_loaded_model_ids()) == [
|
|
"1",
|
|
"2",
|
|
"3",
|
|
]
|
|
|
|
async def test_multiplex_wrapper(self, start_serve_with_context):
|
|
"""Test multiplex wrapper with LRU caching."""
|
|
|
|
async def model_load_func(model_id: str):
|
|
return model_id
|
|
|
|
multiplexer = _ModelMultiplexWrapper(
|
|
model_load_func, None, max_num_models_per_replica=2
|
|
)
|
|
await multiplexer.metrics_pusher.graceful_shutdown()
|
|
|
|
# Load model1
|
|
await multiplexer.load_model("1")
|
|
assert multiplexer.models == {"1": "1"}
|
|
assert multiplexer._push_multiplexed_replica_info
|
|
multiplexer._push_multiplexed_replica_info = False
|
|
|
|
# Load model2
|
|
await multiplexer.load_model("2")
|
|
assert multiplexer.models == {"1": "1", "2": "2"}
|
|
assert multiplexer._push_multiplexed_replica_info
|
|
multiplexer._push_multiplexed_replica_info = False
|
|
|
|
# Load model3, model1 should be unloaded
|
|
await multiplexer.load_model("3")
|
|
assert multiplexer.models == {"2": "2", "3": "3"}
|
|
assert multiplexer._push_multiplexed_replica_info
|
|
multiplexer._push_multiplexed_replica_info = False
|
|
|
|
# reload model2, model2 should be moved to the end of the LRU cache
|
|
# _push_multiplexed_replica_info should be False.
|
|
await multiplexer.load_model("2")
|
|
assert multiplexer.models == {"3": "3", "2": "2"}
|
|
assert multiplexer._push_multiplexed_replica_info is False
|
|
|
|
# Load model4, model3 should be unloaded
|
|
await multiplexer.load_model("4")
|
|
assert multiplexer._push_multiplexed_replica_info
|
|
assert multiplexer.models == {"2": "2", "4": "4"}
|
|
|
|
async def test_bad_call_multiplexed_func(self, start_serve_with_context):
|
|
"""Test bad call to multiplexed function"""
|
|
|
|
async def model_load_func(model_id: str):
|
|
return model_id
|
|
|
|
multiplexer = _ModelMultiplexWrapper(
|
|
model_load_func, None, max_num_models_per_replica=2
|
|
)
|
|
with pytest.raises(TypeError):
|
|
await multiplexer.load_model(1)
|
|
with pytest.raises(TypeError):
|
|
await multiplexer.load_model()
|
|
|
|
async def test_unload_model_call_del(self, start_serve_with_context):
|
|
class MyModel:
|
|
def __init__(self, model_id):
|
|
self.model_id = model_id
|
|
|
|
def __del__(self):
|
|
raise Exception(f"{self.model_id} is dead")
|
|
|
|
def __eq__(self, model):
|
|
return model.model_id == self.model_id
|
|
|
|
async def model_load_func(model_id: str) -> MyModel:
|
|
return MyModel(model_id)
|
|
|
|
multiplexer = _ModelMultiplexWrapper(
|
|
model_load_func, None, max_num_models_per_replica=1
|
|
)
|
|
await multiplexer.metrics_pusher.graceful_shutdown()
|
|
await multiplexer.load_model("1")
|
|
assert multiplexer.models == {"1": MyModel("1")}
|
|
with pytest.raises(Exception, match="1 is dead"):
|
|
await multiplexer.load_model("2")
|
|
|
|
async def test_push_model_ids_info_after_unload_model(self):
|
|
"""
|
|
Push the model ids info right after the model is unloaded, even though
|
|
new model is not loaded yet.
|
|
"""
|
|
signal = SignalActor.remote()
|
|
|
|
async def model_load_func(model_id: str):
|
|
if model_id == "1":
|
|
return model_id
|
|
await signal.wait.remote()
|
|
return
|
|
|
|
multiplexer = _ModelMultiplexWrapper(
|
|
model_load_func, None, max_num_models_per_replica=1
|
|
)
|
|
await multiplexer.metrics_pusher.graceful_shutdown()
|
|
await multiplexer.load_model("1")
|
|
assert multiplexer._push_multiplexed_replica_info
|
|
multiplexer._push_multiplexed_replica_info = False
|
|
|
|
loop = get_or_create_event_loop()
|
|
loop.create_task(multiplexer.load_model("2"))
|
|
# _push_multiplexed_replica_info is True right after model1 is unloaded.
|
|
# and model2 is not finished loading.
|
|
await asyncio.sleep(1)
|
|
assert len(multiplexer.models) == 0
|
|
assert "2" in multiplexer._model_load_tasks
|
|
assert multiplexer._push_multiplexed_replica_info
|
|
signal.send.remote()
|
|
|
|
async def test_load_models_concurrently(self, start_serve_with_context):
|
|
"""
|
|
Test load models concurrently. models info should include loading models and
|
|
loaded models.
|
|
And the models cache should not execeed the limit.
|
|
"""
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
async def model_load_func(model_id: str):
|
|
await signal.wait.remote()
|
|
return
|
|
|
|
multiplexer = _ModelMultiplexWrapper(
|
|
model_load_func, None, max_num_models_per_replica=1
|
|
)
|
|
await multiplexer.metrics_pusher.graceful_shutdown()
|
|
|
|
loop = get_or_create_event_loop()
|
|
tasks = [
|
|
loop.create_task(multiplexer.load_model("1")),
|
|
loop.create_task(multiplexer.load_model("2")),
|
|
loop.create_task(multiplexer.load_model("3")),
|
|
]
|
|
await asyncio.sleep(1)
|
|
assert len(multiplexer.models) == 0
|
|
assert len(multiplexer._model_load_tasks) == len(tasks)
|
|
assert multiplexer._push_multiplexed_replica_info
|
|
signal.send.remote()
|
|
done, _ = await asyncio.wait(tasks, timeout=1)
|
|
assert len(done) == len(tasks)
|
|
assert len(multiplexer.models) == 1
|
|
assert "3" in multiplexer.models
|
|
assert len(multiplexer._model_load_tasks) == 0
|
|
|
|
|
|
class TestBasicAPI:
|
|
def test_decorator_validation(self):
|
|
@serve.multiplexed
|
|
async def get_model(model: str):
|
|
return
|
|
|
|
@serve.multiplexed(max_num_models_per_replica=1)
|
|
async def get_model2(model: str):
|
|
return
|
|
|
|
@serve.deployment
|
|
class MyModel:
|
|
@serve.multiplexed
|
|
async def get_model(model: str):
|
|
return
|
|
|
|
@serve.deployment
|
|
class MyModel2:
|
|
@serve.multiplexed(max_num_models_per_replica=1)
|
|
async def get_model(self, model: str):
|
|
return
|
|
|
|
# multiplex can only be used with func or method.
|
|
with pytest.raises(TypeError):
|
|
|
|
@serve.deployment
|
|
@serve.multiplexed
|
|
class BadDecorator:
|
|
pass
|
|
|
|
# max_num_models_per_replica must be an integer
|
|
with pytest.raises(TypeError):
|
|
|
|
@serve.multiplexed(max_num_models_per_replica="1")
|
|
async def get_model3(model: str):
|
|
pass
|
|
|
|
# max_num_models_per_replica must be positive
|
|
with pytest.raises(ValueError):
|
|
|
|
@serve.multiplexed(max_num_models_per_replica=0)
|
|
async def get_model4(model: str):
|
|
pass
|
|
|
|
# multiplexed function must be async def
|
|
with pytest.raises(TypeError):
|
|
|
|
@serve.multiplexed
|
|
def get_model5(model: str):
|
|
pass
|
|
|
|
with pytest.raises(TypeError):
|
|
|
|
@serve.deployment
|
|
class MyModel3:
|
|
@serve.multiplexed
|
|
def get_model(self, model: str):
|
|
return
|
|
|
|
# no model_id argument in multiplexed function
|
|
with pytest.raises(TypeError):
|
|
|
|
@serve.multiplexed
|
|
def get_model6():
|
|
pass
|
|
|
|
with pytest.raises(TypeError):
|
|
|
|
@serve.deployment
|
|
class MyModel4:
|
|
@serve.multiplexed
|
|
def get_model(self):
|
|
return
|
|
|
|
def test_get_multiplexed_model_id(self):
|
|
"""Test get_multiplexed_model_id() API"""
|
|
assert serve.get_multiplexed_model_id() == ""
|
|
ray.serve.context._serve_request_context.set(
|
|
ray.serve.context._RequestContext(multiplexed_model_id="1")
|
|
)
|
|
assert serve.get_multiplexed_model_id() == "1"
|
|
|
|
|
|
def test_request_routing_info(serve_instance):
|
|
"""Test RequestRoutingInfo is passed to the controller & router"""
|
|
|
|
@serve.deployment
|
|
class MyModel:
|
|
@serve.multiplexed(max_num_models_per_replica=2)
|
|
async def get_model(self, model_id: str):
|
|
return
|
|
|
|
async def __call__(self, model_id: str):
|
|
_ = await self.get_model(model_id)
|
|
return _get_internal_replica_context().replica_id
|
|
|
|
handle = serve.run(MyModel.bind())
|
|
replica_id = handle.remote("model1").result()
|
|
|
|
def check_replica_information(
|
|
model_ids: List[str],
|
|
):
|
|
if not handle.is_initialized:
|
|
handle._init()
|
|
|
|
request_router = _get_request_router(handle)
|
|
for replica in request_router.curr_replicas.values():
|
|
if (
|
|
replica.replica_id != replica_id
|
|
or model_ids != replica.multiplexed_model_ids
|
|
):
|
|
return False
|
|
|
|
return True
|
|
|
|
wait_for_condition(
|
|
check_replica_information,
|
|
model_ids={
|
|
"model1",
|
|
},
|
|
)
|
|
|
|
handle.remote("model2").result()
|
|
wait_for_condition(
|
|
check_replica_information,
|
|
model_ids={
|
|
"model1",
|
|
"model2",
|
|
},
|
|
)
|
|
|
|
# LRU remove the model1
|
|
handle.remote("model3").result()
|
|
wait_for_condition(
|
|
check_replica_information,
|
|
model_ids={
|
|
"model2",
|
|
"model3",
|
|
},
|
|
)
|
|
|
|
|
|
def check_model_id_in_replicas(handle: DeploymentHandle, model_id: str) -> bool:
|
|
if not handle.is_initialized:
|
|
handle._init()
|
|
|
|
request_router = _get_request_router(handle)
|
|
replica_to_model_ids = {
|
|
tag: replica.multiplexed_model_ids
|
|
for tag, replica in request_router.curr_replicas.items()
|
|
}
|
|
msg = (
|
|
f"Model ID '{model_id}' not found in replica_to_model_ids: "
|
|
f"{replica_to_model_ids}"
|
|
)
|
|
assert any(model_id in rep for rep in replica_to_model_ids.values()), msg
|
|
return True
|
|
|
|
|
|
def test_multiplexed_e2e(serve_instance):
|
|
"""Test multiplexed function end to end"""
|
|
|
|
@serve.deployment(num_replicas=2)
|
|
class Model:
|
|
@serve.multiplexed(max_num_models_per_replica=1)
|
|
async def get_model(self, tag):
|
|
return tag
|
|
|
|
async def __call__(self, request):
|
|
tag = serve.get_multiplexed_model_id()
|
|
await self.get_model(tag)
|
|
# return pid to check if the same model is used
|
|
return os.getpid()
|
|
|
|
model_id = "1"
|
|
handle = serve.run(Model.bind())
|
|
headers = {SERVE_MULTIPLEXED_MODEL_ID: model_id}
|
|
resp = httpx.get("http://localhost:8000", headers=headers)
|
|
initial_pid = resp.json()
|
|
|
|
wait_for_condition(check_model_id_in_replicas, handle=handle, model_id=model_id)
|
|
|
|
# Check that the same replica is used repeatedly for the same model_id.
|
|
for _ in range(10):
|
|
resp = httpx.get("http://localhost:8000", headers=headers)
|
|
assert resp.json() == initial_pid
|
|
|
|
for _ in range(10):
|
|
assert (
|
|
handle.options(multiplexed_model_id="1").remote("blabla").result()
|
|
== initial_pid
|
|
)
|
|
|
|
|
|
def test_multiplexed_lru_policy(serve_instance):
|
|
"""Test multiplexed function LRU policy"""
|
|
|
|
@serve.deployment
|
|
class Model:
|
|
@serve.multiplexed(max_num_models_per_replica=2)
|
|
async def get_model(self, tag):
|
|
return tag
|
|
|
|
async def __call__(self, request):
|
|
tag = serve.get_multiplexed_model_id()
|
|
await self.get_model(tag)
|
|
# return pid to check if the same model is used
|
|
return os.getpid()
|
|
|
|
handle = serve.run(Model.bind())
|
|
headers = {SERVE_MULTIPLEXED_MODEL_ID: "1"}
|
|
httpx.get("http://localhost:8000", headers=headers)
|
|
headers = {SERVE_MULTIPLEXED_MODEL_ID: "2"}
|
|
httpx.get("http://localhost:8000", headers=headers)
|
|
# Make sure model2 will be evicted
|
|
headers = {SERVE_MULTIPLEXED_MODEL_ID: "1"}
|
|
httpx.get("http://localhost:8000", headers=headers)
|
|
headers = {SERVE_MULTIPLEXED_MODEL_ID: "3"}
|
|
httpx.get("http://localhost:8000", headers=headers)
|
|
|
|
wait_for_condition(
|
|
(
|
|
lambda: check_model_id_in_replicas(handle, "1")
|
|
and check_model_id_in_replicas(handle, "3")
|
|
)
|
|
)
|
|
|
|
|
|
def test_multiplexed_multiple_replicas(serve_instance):
|
|
"""Test multiplexed traffic can be sent to multiple replicas"""
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(num_replicas=2, max_ongoing_requests=1)
|
|
class Model:
|
|
@serve.multiplexed(max_num_models_per_replica=2)
|
|
async def get_model(self, tag):
|
|
return tag
|
|
|
|
async def __call__(self):
|
|
tag = serve.get_multiplexed_model_id()
|
|
await self.get_model(tag)
|
|
await signal.wait.remote()
|
|
# return pid to check if the same model is used
|
|
return os.getpid()
|
|
|
|
handle = serve.run(Model.bind()).options(multiplexed_model_id="1")
|
|
|
|
# Each request should go to different replicas.
|
|
pid1_ref = handle.remote()
|
|
pid2_ref = handle.remote()
|
|
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 2)
|
|
|
|
# Unblock both requests to finish.
|
|
ray.get(signal.send.remote())
|
|
assert pid1_ref.result() != pid2_ref.result()
|
|
|
|
wait_for_condition(check_model_id_in_replicas, handle=handle, model_id="1")
|
|
|
|
|
|
def test_setting_model_id_on_handle_does_not_set_it_locally(serve_instance):
|
|
"""
|
|
Verify that `.options(multiplexed_model_id="foo")` on a ServeHandle sets it in the
|
|
downstream but does not update the model ID in the caller.
|
|
"""
|
|
|
|
@serve.deployment
|
|
class Downstream:
|
|
def __call__(self):
|
|
return serve.get_multiplexed_model_id()
|
|
|
|
@serve.deployment
|
|
class Upstream:
|
|
def __init__(self, downstream: DeploymentHandle):
|
|
self._h = downstream
|
|
|
|
async def __call__(self):
|
|
model_id_before = serve.get_multiplexed_model_id()
|
|
|
|
# Make a call with another model ID, verify it's set properly.
|
|
other_model_id = await self._h.options(multiplexed_model_id="bar").remote()
|
|
assert other_model_id == "bar"
|
|
|
|
# Model ID shouldn't change after the handle call.
|
|
model_id_after = serve.get_multiplexed_model_id()
|
|
assert model_id_before == model_id_after
|
|
|
|
return model_id_before
|
|
|
|
handle = serve.run(Upstream.bind(Downstream.bind()))
|
|
assert handle.options(multiplexed_model_id="foo").remote().result() == "foo"
|
|
|
|
|
|
def test_replica_upgrade_to_cleanup_resource(serve_instance):
|
|
"""When replica is upgraded, we need to make sure model resources are released."""
|
|
|
|
@serve.deployment
|
|
class Recorder:
|
|
def __init__(self):
|
|
self.call_record = set()
|
|
|
|
def add(self, model_id):
|
|
self.call_record.add(model_id)
|
|
|
|
def get_call_record(self):
|
|
return self.call_record
|
|
|
|
record_handle = serve.run(
|
|
Recorder.bind(), name="recorder", route_prefix="/recorder"
|
|
)
|
|
|
|
class MyModel:
|
|
def __init__(self, model_id, record_handle):
|
|
self.model_id = model_id
|
|
self.record_handle = record_handle
|
|
|
|
async def __del__(self):
|
|
await self.record_handle.add.remote(self.model_id)
|
|
|
|
def __eq__(self, model):
|
|
return model.model_id == self.model_id
|
|
|
|
@serve.deployment(num_replicas=1)
|
|
class Model:
|
|
def __init__(self, record_handle):
|
|
self.record_handle = record_handle
|
|
|
|
@serve.multiplexed(max_num_models_per_replica=1)
|
|
async def get_model(self, tag):
|
|
return MyModel(tag, self.record_handle)
|
|
|
|
async def __call__(self, request):
|
|
tag = serve.get_multiplexed_model_id()
|
|
await self.get_model(tag)
|
|
# return pid to check if the same model is used
|
|
return os.getpid()
|
|
|
|
serve.run(Model.bind(record_handle))
|
|
|
|
model_id = "1"
|
|
headers = {"serve_multiplexed_model_id": model_id}
|
|
httpx.get("http://localhost:8000", headers=headers)
|
|
assert record_handle.get_call_record.remote().result() == set()
|
|
serve.run(Model.bind(record_handle))
|
|
assert record_handle.get_call_record.remote().result() == {"1"}
|
|
|
|
|
|
def test_multiplexed_with_batching_splits_by_model_id(serve_instance):
|
|
"""Test that batching with multiplexing splits batches by model ID.
|
|
|
|
When using model multiplexing with batching, requests for different models
|
|
may end up on the same replica. This test verifies that such requests are
|
|
processed in separate batches, ensuring each batch only contains requests
|
|
for the same model.
|
|
"""
|
|
|
|
@serve.deployment(num_replicas=1, max_ongoing_requests=20)
|
|
class BatchedMultiplexModel:
|
|
def __init__(self):
|
|
self.batch_info = []
|
|
|
|
@serve.multiplexed(max_num_models_per_replica=3)
|
|
async def get_model(self, model_id: str):
|
|
return model_id
|
|
|
|
@serve.batch(max_batch_size=10, batch_wait_timeout_s=1.0)
|
|
async def batched_predict(self, inputs: List[str]):
|
|
# Get the model ID from the request context
|
|
model_id = serve.get_multiplexed_model_id()
|
|
|
|
# Record the batch info for verification
|
|
batch_size = len(inputs)
|
|
self.batch_info.append(
|
|
{
|
|
"model_id": model_id,
|
|
"batch_size": batch_size,
|
|
"inputs": inputs,
|
|
}
|
|
)
|
|
|
|
# Load the model (would fail if different model_ids were in same batch)
|
|
model = await self.get_model(model_id)
|
|
|
|
# Return results
|
|
return [f"{model}:{inp}" for inp in inputs]
|
|
|
|
async def __call__(self, request):
|
|
return await self.batched_predict(request)
|
|
|
|
def get_batch_info(self):
|
|
return self.batch_info
|
|
|
|
handle = serve.run(BatchedMultiplexModel.bind())
|
|
|
|
# Send concurrent requests with different model IDs
|
|
# If batching doesn't split by model_id, requests for different models
|
|
# would end up in the same batch, which would be incorrect.
|
|
refs = []
|
|
for i in range(6):
|
|
# Alternate between model_a and model_b
|
|
model_id = "model_a" if i % 2 == 0 else "model_b"
|
|
refs.append(handle.options(multiplexed_model_id=model_id).remote(f"input_{i}"))
|
|
|
|
# Wait for all results
|
|
results = [ref.result() for ref in refs]
|
|
|
|
# Verify results are correct - each result should have the correct model prefix
|
|
for i, result in enumerate(results):
|
|
expected_model = "model_a" if i % 2 == 0 else "model_b"
|
|
assert result.startswith(
|
|
f"{expected_model}:"
|
|
), f"Expected result to start with '{expected_model}:', got '{result}'"
|
|
assert f"input_{i}" in result
|
|
|
|
# Verify batch info - each batch should only contain requests for one model
|
|
batch_info = handle.get_batch_info.remote().result()
|
|
for batch in batch_info:
|
|
# Each batch should have a non-empty model_id
|
|
# (all requests in batch have the same model_id)
|
|
assert batch["model_id"] in [
|
|
"model_a",
|
|
"model_b",
|
|
], f"Unexpected model_id in batch: {batch['model_id']}"
|
|
# Batch size should be > 0
|
|
assert batch["batch_size"] == 3
|
|
|
|
# Verify total requests processed equals what we sent
|
|
total_processed = sum(b["batch_size"] for b in batch_info)
|
|
assert total_processed == 6, f"Expected 6 requests processed, got {total_processed}"
|
|
assert len(batch_info) == 2
|
|
|
|
|
|
def test_multiplexed_with_batching_same_model_batches_together(serve_instance):
|
|
"""Test that requests for the same model are batched together.
|
|
|
|
This test verifies that when multiple requests for the same model arrive,
|
|
they are correctly batched together (the split-by-model-id logic doesn't
|
|
prevent normal batching behavior).
|
|
"""
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(num_replicas=1, max_ongoing_requests=20)
|
|
class BatchedModel:
|
|
def __init__(self):
|
|
self.batch_sizes = []
|
|
|
|
@serve.batch(max_batch_size=10, batch_wait_timeout_s=1.0)
|
|
async def batched_predict(self, inputs: List[str]):
|
|
model_id = serve.get_multiplexed_model_id()
|
|
self.batch_sizes.append((model_id, len(inputs)))
|
|
await signal.wait.remote()
|
|
return [f"{model_id}:{inp}" for inp in inputs]
|
|
|
|
async def __call__(self, request):
|
|
return await self.batched_predict(request)
|
|
|
|
def get_batch_sizes(self):
|
|
return self.batch_sizes
|
|
|
|
handle = serve.run(BatchedModel.bind())
|
|
|
|
# Send multiple requests for the same model - they should batch together
|
|
refs = []
|
|
for i in range(5):
|
|
refs.append(
|
|
handle.options(multiplexed_model_id="same_model").remote(f"input_{i}")
|
|
)
|
|
|
|
# Wait for the batch to form
|
|
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
|
|
|
|
# Unblock processing
|
|
ray.get(signal.send.remote())
|
|
|
|
# Wait for results
|
|
results = [ref.result() for ref in refs]
|
|
assert len(results) == 5
|
|
|
|
# Check batch sizes - all requests should have been in one batch
|
|
batch_sizes = handle.get_batch_sizes.remote().result()
|
|
total_in_batches = sum(size for _, size in batch_sizes)
|
|
assert total_in_batches == 5
|
|
|
|
# All batches should be for the same model
|
|
for model_id, _ in batch_sizes:
|
|
assert model_id == "same_model"
|
|
|
|
assert len(batch_sizes) == 1
|
|
|
|
|
|
def test_multiplexed_batching_concurrent_subbatches_context_isolation(serve_instance):
|
|
# Two signals for two-phase synchronization
|
|
signal_barrier = SignalActor.remote()
|
|
|
|
@serve.deployment(num_replicas=1, max_ongoing_requests=100)
|
|
class ConcurrentBatchedModel:
|
|
def __init__(self):
|
|
self.model_id_readings = []
|
|
|
|
@serve.multiplexed(max_num_models_per_replica=5)
|
|
async def get_model(self, model_id: str):
|
|
return model_id
|
|
|
|
@serve.batch(max_batch_size=10, batch_wait_timeout_s=1.0)
|
|
async def batched_predict(self, inputs: List[str]):
|
|
# Phase 1: Wait at the barrier.
|
|
await signal_barrier.wait.remote()
|
|
|
|
# Phase 2: NOW read the model_id.
|
|
model_id_read = serve.get_multiplexed_model_id()
|
|
|
|
# Record for verification
|
|
self.model_id_readings.append(
|
|
{
|
|
"model_id": model_id_read,
|
|
"batch_size": len(inputs),
|
|
"inputs": inputs,
|
|
}
|
|
)
|
|
|
|
return [f"{model_id_read}:{inp}" for inp in inputs]
|
|
|
|
async def __call__(self, request):
|
|
return await self.batched_predict(request)
|
|
|
|
def get_model_id_readings(self):
|
|
return self.model_id_readings
|
|
|
|
handle = serve.run(ConcurrentBatchedModel.bind())
|
|
|
|
# Send concurrent requests with different model IDs.
|
|
# These will be split into separate sub-batches and processed concurrently.
|
|
refs = []
|
|
model_ids = ["model_a", "model_b", "model_c"]
|
|
requests_per_model = 3
|
|
|
|
for model_id in model_ids:
|
|
for i in range(requests_per_model):
|
|
refs.append(
|
|
handle.options(multiplexed_model_id=model_id).remote(
|
|
f"{model_id}_input_{i}"
|
|
)
|
|
)
|
|
|
|
# Wait for all sub-batches to be at the barrier
|
|
wait_for_condition(
|
|
lambda: ray.get(signal_barrier.cur_num_waiters.remote()) == len(model_ids)
|
|
)
|
|
|
|
# Release all sub-batches to read their model_id
|
|
ray.get(signal_barrier.send.remote())
|
|
|
|
# Collect results
|
|
results = [ref.result() for ref in refs]
|
|
|
|
# Verify each result has the correct model prefix
|
|
# With the bug, all results might have the same (wrong) model prefix
|
|
for i, result in enumerate(results):
|
|
expected_model = model_ids[i // requests_per_model]
|
|
assert result.startswith(f"{expected_model}:"), (
|
|
f"Expected result to start with '{expected_model}:', got '{result}'. "
|
|
"This indicates context isolation failure - a sub-batch read another "
|
|
"sub-batch's model_id because they share the same context."
|
|
)
|
|
|
|
# Verify model ID readings
|
|
readings = handle.get_model_id_readings.remote().result()
|
|
|
|
# Count how many different model_ids were read
|
|
read_model_ids = {r["model_id"] for r in readings}
|
|
|
|
# With the bug: all sub-batches read the same model_id (only 1 unique)
|
|
# With the fix: each sub-batch reads its own model_id (3 unique)
|
|
assert len(read_model_ids) == len(model_ids), (
|
|
f"Expected {len(model_ids)} different model_ids to be read, but got "
|
|
f"{len(read_model_ids)}: {read_model_ids}. "
|
|
f"This indicates context isolation failure - multiple sub-batches "
|
|
f"read the same model_id because they share context. "
|
|
f"Full readings: {readings}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|