Skip to content

Commit 8d37ec2

Browse files
committed
test(gms): add reproducible vLLM and SGLang E2E coverage
Signed-off-by: mkhadkevich <mkhadkevich@nvidia.com>
1 parent 5c33eec commit 8d37ec2

5 files changed

Lines changed: 417 additions & 72 deletions

File tree

tests/router/common.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2149,6 +2149,7 @@ def _test_router_decisions_disagg(
21492149
durable_kv_events: bool = False,
21502150
router_aic_config: Optional[dict[str, Any]] = None,
21512151
enable_bootstrap: bool = False,
2152+
strict_timing: bool = True,
21522153
):
21532154
"""Validate KV cache prefix reuse in disaggregated prefill-decode setup via HTTP frontend.
21542155
@@ -2172,6 +2173,7 @@ def _test_router_decisions_disagg(
21722173
store_backend: Storage backend to use ("etcd" or "file"). Defaults to "etcd".
21732174
durable_kv_events: If True, use durable KV events (JetStream). Defaults to False.
21742175
router_aic_config: Optional AIC router perf-model config for frontend KV routing.
2176+
strict_timing: If False, allow backend responses that omit optional timing fields.
21752177
21762178
Raises:
21772179
AssertionError: If prefill_worker_ids differ across requests (prefix reuse failure)
@@ -2216,7 +2218,7 @@ def _test_router_decisions_disagg(
22162218
)
22172219

22182220
async def send_progressive_requests():
2219-
"""Send 4 progressive requests with overlapping prefixes and collect worker IDs."""
2221+
"""Send progressive requests with overlapping prefixes and collect worker IDs."""
22202222
prefill_worker_ids = []
22212223
decode_worker_ids = []
22222224

@@ -2252,10 +2254,13 @@ async def send_progressive_requests():
22522254
response.status == 200
22532255
), f"Request {i + 1} failed with status {response.status}"
22542256

2255-
# Collect all chunks and look for nvext with worker_id and timing
2257+
# Collect all chunks and look for nvext with worker_id, timing,
2258+
# and actual generated text. A backend can otherwise surface a
2259+
# transfer failure as a 200 stream with worker IDs but no tokens.
22562260
prefill_wid = None
22572261
decode_wid = None
22582262
timing_info = None
2263+
generated_parts: list[str] = []
22592264

22602265
async for line in response.content:
22612266
if not line:
@@ -2271,6 +2276,12 @@ async def send_progressive_requests():
22712276

22722277
try:
22732278
data = json.loads(data_str)
2279+
for choice in data.get("choices", []):
2280+
delta = choice.get("delta") or {}
2281+
content = delta.get("content")
2282+
if content:
2283+
generated_parts.append(content)
2284+
22742285
# Check for nvext in the response
22752286
nvext = data.get("nvext", {})
22762287
if nvext:
@@ -2291,9 +2302,15 @@ async def send_progressive_requests():
22912302
except json.JSONDecodeError:
22922303
continue
22932304

2305+
generated_text = "".join(generated_parts)
22942306
logger.info(
22952307
f"Request {i + 1}: prefill_worker_id={prefill_wid}, "
2296-
f"decode_worker_id={decode_wid}, timing={timing_info}"
2308+
f"decode_worker_id={decode_wid}, timing={timing_info}, "
2309+
f"generated_chars={len(generated_text)}"
2310+
)
2311+
assert generated_text.strip(), (
2312+
f"Request {i + 1}: Expected generated content in the response stream, "
2313+
"but the backend returned no text tokens"
22972314
)
22982315

22992316
if prefill_wid is not None:
@@ -2307,7 +2324,12 @@ async def send_progressive_requests():
23072324
assert (
23082325
timing_info is not None
23092326
), f"Request {i + 1}: Expected timing info in final chunk, got None"
2310-
verify_response_timing(timing_info, disagg=not enable_bootstrap)
2327+
verify_response_timing(
2328+
timing_info,
2329+
disagg=not enable_bootstrap,
2330+
require_ttft=strict_timing,
2331+
require_kv_transfer_latency=strict_timing,
2332+
)
23112333

23122334
# Small delay between requests
23132335
await asyncio.sleep(1)

tests/router/e2e_harness.py

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import time
7+
from contextlib import ExitStack, contextmanager
78
from typing import Any, Callable, ContextManager
89

910
from tests.router.common import (
@@ -19,6 +20,71 @@
1920

2021
logger = logging.getLogger(__name__)
2122

23+
24+
def env_int(name: str, default: int) -> int:
25+
return int(os.environ.get(name, default))
26+
27+
28+
def env_optional_int(name: str) -> int | None:
29+
raw = os.environ.get(name)
30+
return None if raw is None else int(raw)
31+
32+
33+
def env_float(name: str, default: float) -> float:
34+
return float(os.environ.get(name, default))
35+
36+
37+
def env_bool(name: str, default: bool) -> bool:
38+
raw = os.environ.get(name)
39+
return default if raw is None else raw.lower() not in {"0", "false", "no", "off"}
40+
41+
42+
def resolve_router_gpu_start_index(gpu_start_index: int) -> int:
43+
override = os.environ.get("DYNAMO_ROUTER_E2E_GPU_START_INDEX")
44+
if override is None:
45+
return gpu_start_index
46+
try:
47+
base_index = int(override)
48+
except ValueError as exc:
49+
raise ValueError(
50+
"DYNAMO_ROUTER_E2E_GPU_START_INDEX must be an integer"
51+
) from exc
52+
return base_index + gpu_start_index
53+
54+
55+
def _resolve_router_e2e_num_requests(default: int = 10) -> int:
56+
override = os.environ.get("DYNAMO_ROUTER_E2E_NUM_REQUESTS")
57+
if override is None:
58+
return default
59+
try:
60+
value = int(override)
61+
except ValueError as exc:
62+
raise ValueError("DYNAMO_ROUTER_E2E_NUM_REQUESTS must be an integer") from exc
63+
if value <= 0:
64+
raise ValueError("DYNAMO_ROUTER_E2E_NUM_REQUESTS must be positive")
65+
return value
66+
67+
68+
@contextmanager
69+
def maybe_router_gms_servers():
70+
if os.environ.get("DYNAMO_ROUTER_E2E_ENABLE_GMS") != "1":
71+
yield
72+
return
73+
74+
from tests.gpu_memory_service.common.gms import GMSServer
75+
76+
devices = os.environ.get("DYNAMO_ROUTER_E2E_GMS_DEVICES", "0,1")
77+
tags = os.environ.get("DYNAMO_ROUTER_E2E_GMS_TAGS", "weights,kv_cache")
78+
device_ids = [int(part.strip()) for part in devices.split(",") if part.strip()]
79+
tag_names = [part.strip() for part in tags.split(",") if part.strip()]
80+
81+
with ExitStack() as stack:
82+
for device in device_ids:
83+
for tag in tag_names:
84+
stack.enter_context(GMSServer(device=device, tag=tag))
85+
yield
86+
87+
2288
TEST_PROMPT = (
2389
"In a quiet meadow tucked between rolling hills, a plump gray rabbit nibbled on "
2490
"clover beneath the shade of a gnarled oak tree. Its ears twitched at the faint "
@@ -214,15 +280,17 @@ def run_basic_router_test(
214280
},
215281
engine_process_kwargs=engine_process_kwargs,
216282
)
217-
with process as engine_workers:
283+
with ExitStack() as stack:
284+
stack.enter_context(maybe_router_gms_servers())
285+
engine_workers = stack.enter_context(process)
218286
frontend_port = allocate_frontend_ports(request, 1)[0]
219287
_test_router_basic(
220288
engine_workers=engine_workers,
221289
block_size=block_size,
222290
request=request,
223291
frontend_port=frontend_port,
224292
test_payload=test_payload or build_test_payload(model_name),
225-
num_requests=num_requests,
293+
num_requests=_resolve_router_e2e_num_requests(num_requests),
226294
frontend_timeout=frontend_timeout,
227295
store_backend="etcd",
228296
request_plane=request_plane,
@@ -263,7 +331,9 @@ def run_router_decisions_test(
263331
default_process_kwargs=default_process_kwargs,
264332
engine_process_kwargs=engine_process_kwargs,
265333
)
266-
with process as engine_workers:
334+
with ExitStack() as stack:
335+
stack.enter_context(maybe_router_gms_servers())
336+
engine_workers = stack.enter_context(process)
267337
endpoint = get_engine_endpoint(engine_workers, request_plane, component_name)
268338
scenario_kwargs = dict(test_kwargs or {})
269339
for argument, attribute in (
@@ -302,6 +372,7 @@ def run_disagg_router_decisions_test(
302372
| None = None,
303373
test_payload: dict[str, Any] | None = None,
304374
test_kwargs: dict[str, Any] | None = None,
375+
strict_timing: bool = True,
305376
):
306377
shared_namespace = f"test-namespace-{generate_random_suffix()}"
307378
frontend_port = allocate_frontend_ports(request, 1)[0]
@@ -316,6 +387,7 @@ def run_disagg_router_decisions_test(
316387
}
317388

318389
def run_test(prefill_workers, decode_workers):
390+
scenario_kwargs = {"strict_timing": strict_timing, **(test_kwargs or {})}
319391
_test_router_decisions_disagg(
320392
prefill_workers=prefill_workers,
321393
decode_workers=decode_workers,
@@ -324,29 +396,35 @@ def run_test(prefill_workers, decode_workers):
324396
frontend_port=frontend_port,
325397
test_payload=test_payload or build_test_payload(model_name),
326398
request_plane=request_plane,
327-
**(test_kwargs or {}),
399+
**scenario_kwargs,
328400
)
329401

330-
if worker_context_factory is not None:
331-
with worker_context_factory(shared_namespace) as workers:
402+
with ExitStack() as stack:
403+
stack.enter_context(maybe_router_gms_servers())
404+
if worker_context_factory is not None:
405+
workers = stack.enter_context(worker_context_factory(shared_namespace))
332406
run_test(*workers)
333-
return
334-
335-
with engine_process_cls(
336-
request,
337-
num_workers=num_prefill_workers,
338-
request_plane=request_plane,
339-
**{engine_args_name: engine_args},
340-
**prefill_kwargs,
341-
) as prefill_workers:
342-
with engine_process_cls(
343-
request,
344-
num_workers=num_decode_workers,
345-
request_plane=request_plane,
346-
**{engine_args_name: engine_args},
347-
**decode_kwargs,
348-
) as decode_workers:
349-
run_test(prefill_workers, decode_workers)
407+
return
408+
409+
prefill_workers = stack.enter_context(
410+
engine_process_cls(
411+
request,
412+
num_workers=num_prefill_workers,
413+
request_plane=request_plane,
414+
**{engine_args_name: engine_args},
415+
**prefill_kwargs,
416+
)
417+
)
418+
decode_workers = stack.enter_context(
419+
engine_process_cls(
420+
request,
421+
num_workers=num_decode_workers,
422+
request_plane=request_plane,
423+
**{engine_args_name: engine_args},
424+
**decode_kwargs,
425+
)
426+
)
427+
run_test(prefill_workers, decode_workers)
350428

351429

352430
def run_indexers_sync_test(
@@ -383,7 +461,9 @@ def run_indexers_sync_test(
383461
},
384462
engine_process_kwargs=engine_process_kwargs,
385463
)
386-
with process as engine_workers:
464+
with ExitStack() as stack:
465+
stack.enter_context(maybe_router_gms_servers())
466+
engine_workers = stack.enter_context(process)
387467
_test_router_indexers_sync(
388468
engine_workers=engine_workers,
389469
block_size=block_size,

0 commit comments

Comments
 (0)