Skip to content

Commit cd893c9

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

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
@@ -2355,6 +2355,7 @@ def _test_router_decisions_disagg(
23552355
durable_kv_events: bool = False,
23562356
router_aic_config: Optional[dict[str, Any]] = None,
23572357
enable_bootstrap: bool = False,
2358+
strict_timing: bool = True,
23582359
):
23592360
"""Validate KV cache prefix reuse in disaggregated prefill-decode setup via HTTP frontend.
23602361
@@ -2378,6 +2379,7 @@ def _test_router_decisions_disagg(
23782379
store_backend: Storage backend to use ("etcd" or "file"). Defaults to "etcd".
23792380
durable_kv_events: If True, use durable KV events (JetStream). Defaults to False.
23802381
router_aic_config: Optional AIC router perf-model config for frontend KV routing.
2382+
strict_timing: If False, allow backend responses that omit optional timing fields.
23812383
23822384
Raises:
23832385
AssertionError: If prefill_worker_ids differ across requests (prefix reuse failure)
@@ -2421,7 +2423,7 @@ def _test_router_decisions_disagg(
24212423
)
24222424

24232425
async def send_progressive_requests():
2424-
"""Send 4 progressive requests with overlapping prefixes and collect worker IDs."""
2426+
"""Send progressive requests with overlapping prefixes and collect worker IDs."""
24252427
prefill_worker_ids = []
24262428
decode_worker_ids = []
24272429

@@ -2457,10 +2459,13 @@ async def send_progressive_requests():
24572459
response.status == 200
24582460
), f"Request {i + 1} failed with status {response.status}"
24592461

2460-
# Collect all chunks and look for nvext with worker_id and timing
2462+
# Collect all chunks and look for nvext with worker_id, timing,
2463+
# and actual generated text. A backend can otherwise surface a
2464+
# transfer failure as a 200 stream with worker IDs but no tokens.
24612465
prefill_wid = None
24622466
decode_wid = None
24632467
timing_info = None
2468+
generated_parts: list[str] = []
24642469

24652470
async for line in response.content:
24662471
if not line:
@@ -2476,6 +2481,12 @@ async def send_progressive_requests():
24762481

24772482
try:
24782483
data = json.loads(data_str)
2484+
for choice in data.get("choices", []):
2485+
delta = choice.get("delta") or {}
2486+
content = delta.get("content")
2487+
if content:
2488+
generated_parts.append(content)
2489+
24792490
# Check for nvext in the response
24802491
nvext = data.get("nvext", {})
24812492
if nvext:
@@ -2496,9 +2507,15 @@ async def send_progressive_requests():
24962507
except json.JSONDecodeError:
24972508
continue
24982509

2510+
generated_text = "".join(generated_parts)
24992511
logger.info(
25002512
f"Request {i + 1}: prefill_worker_id={prefill_wid}, "
2501-
f"decode_worker_id={decode_wid}, timing={timing_info}"
2513+
f"decode_worker_id={decode_wid}, timing={timing_info}, "
2514+
f"generated_chars={len(generated_text)}"
2515+
)
2516+
assert generated_text.strip(), (
2517+
f"Request {i + 1}: Expected generated content in the response stream, "
2518+
"but the backend returned no text tokens"
25022519
)
25032520

25042521
if prefill_wid is not None:
@@ -2512,7 +2529,12 @@ async def send_progressive_requests():
25122529
assert (
25132530
timing_info is not None
25142531
), f"Request {i + 1}: Expected timing info in final chunk, got None"
2515-
verify_response_timing(timing_info, disagg=not enable_bootstrap)
2532+
verify_response_timing(
2533+
timing_info,
2534+
disagg=not enable_bootstrap,
2535+
require_ttft=strict_timing,
2536+
require_kv_transfer_latency=strict_timing,
2537+
)
25162538

25172539
# Small delay between requests
25182540
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)