Skip to content

Commit 5b40a9c

Browse files
committed
test(router): add GMS placement E2E coverage
Signed-off-by: Maksim Khadkevich <mkhadkevich@nvidia.com>
1 parent fef342b commit 5b40a9c

14 files changed

Lines changed: 1187 additions & 146 deletions

docs/components/frontend/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The Rust HTTP server also reads these environment variables (not exposed as CLI
4343
| `--router-assume-kv-reuse` / `--no-router-assume-kv-reuse` | `DYN_ROUTER_ASSUME_KV_REUSE` | `true` | Assume KV cache reuse when tracking active blocks |
4444
| `--router-track-output-blocks` / `--no-router-track-output-blocks` | `DYN_ROUTER_TRACK_OUTPUT_BLOCKS` | `false` | Track output blocks with fractional decay during generation |
4545
| `--router-track-prefill-tokens` / `--no-router-track-prefill-tokens` | `DYN_ROUTER_TRACK_PREFILL_TOKENS` | `true` | Track prompt-side prefill load in worker load accounting |
46+
| `--router-gms-decode-transfer` / `--no-router-gms-decode-transfer` | `DYN_ROUTER_GMS_DECODE_TRANSFER` | `false` | Experimental GMS decode-to-decode transfer orchestration for GMS-capable aggregated decode workers |
4647
| `--router-prefill-load-model` | `DYN_ROUTER_PREFILL_LOAD_MODEL` | `none` | Prompt-side load model: `none` for static load, `aic` for oldest-prefill decay using an AIC prediction |
4748
| `--router-event-threads` | `DYN_ROUTER_EVENT_THREADS` | `4` | KV indexer worker threads. >1 enables the concurrent radix tree, including with `--no-router-kv-events` |
4849
| `--router-queue-threshold` | `DYN_ROUTER_QUEUE_THRESHOLD` | `16.0` | Queue threshold fraction of prefill capacity. Priority hints only affect requests waiting in this queue |
@@ -73,6 +74,7 @@ For MoE models, AIC requires `aic_tp_size * aic_attention_dp_size == aic_moe_tp_
7374
| CLI Argument | Env Var | Default | Description |
7475
|-------------|---------|---------|-------------|
7576
| `--migration-limit` | `DYN_MIGRATION_LIMIT` | `0` | Max request migrations per worker disconnect. 0 = disabled |
77+
|| `DYN_MIGRATION_RETRY_CONCURRENCY` | `0` | Optional process-wide cap for concurrent migrated retry streams. 0 = unlimited |
7678
| `--active-decode-blocks-threshold` | `DYN_ACTIVE_DECODE_BLOCKS_THRESHOLD` | `1.0` | KV cache utilization fraction (0.0–1.0) for busy detection. Pass `None` to disable |
7779
| `--active-prefill-tokens-threshold` | `DYN_ACTIVE_PREFILL_TOKENS_THRESHOLD` | `10000000` | Absolute token count for prefill busy detection. Pass `None` to disable |
7880
| `--active-prefill-tokens-threshold-frac` | `DYN_ACTIVE_PREFILL_TOKENS_THRESHOLD_FRAC` | `64.0` | Fraction of `max_num_batched_tokens` for prefill busy detection. OR logic with absolute threshold. Pass `None` to disable |

docs/components/router/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ For Kubernetes, set `DYN_ROUTER_MODE=kv` on the Frontend service. For event-driv
3030
| `--router-queue-threshold` | `16.0` | Backpressure queue threshold; priority hints only reorder requests while this queue is non-empty |
3131
| `--router-queue-policy` | `fcfs` | Queue scheduling policy: `fcfs` (tail TTFT), `wspt` (avg TTFT), or `lcfs` (comparison-only reverse ordering) |
3232
| `--no-router-track-prefill-tokens` | disabled | Ignore prompt-side prefill tokens in router load accounting; useful for decode-only routing paths |
33+
| `--router-gms-decode-transfer` | disabled | Experimental GMS decode-to-decode transfer orchestration for GMS-capable aggregated decode workers |
3334

3435
### Standalone Router
3536

docs/components/router/router-configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This page collects the main router flags for frontend-embedded and standalone de
1414
- `--load-aware`: Preset for load-aware KV routing without cache-reuse signals. On the frontend, it implies `--router-mode kv`. It sets `overlap_score_credit=0`, disables KV events, durable KV events, and KV reuse assumptions, enables active-block and prefill-token load tracking, disables remote/shared cache indexers, and preserves `--router-prefill-load-scale`.
1515
- `--router-temperature`: Controls worker selection randomness through softmax sampling of normalized router cost logits. A value of 0 (default) ensures deterministic selection of the lowest-cost worker, while higher values introduce more randomness.
1616
- `--router-track-prefill-tokens`: Enables prompt-side load accounting in the worker cost model. This should stay enabled if you want queue thresholds, `active_prefill_tokens`, and AIC prefill load decay to reflect prompt work.
17+
- `--router-gms-decode-transfer`: **Experimental, disabled by default.** Enables GMS decode-to-decode KV transfer orchestration for GMS-capable aggregated decode workers. Vanilla Dynamo routing is unchanged unless this flag is explicitly enabled.
1718
- `--router-prefill-load-model`: Selects the router's prompt-side load model. `none` keeps the existing static prompt load accounting. `aic` predicts one expected prefill duration per admitted request and lazily decays only the oldest active prefill request on each worker.
1819
- `--router-queue-threshold`: Queue threshold fraction for prefill token capacity (default: 16.0). The router holds incoming requests in a priority queue while all eligible workers exceed `threshold * max_num_batched_tokens`, releasing them when capacity frees up. This defers dispatch rather than rejecting work, so routing decisions use the freshest load metrics at the moment a request is actually sent to a worker. `nvext.agent_hints.priority` only affects ordering while requests are pending in this queue. Must be greater than or equal to 0; use `0.0` for maximum queueing sensitivity. Set to `None` to disable queueing. See the SGLang note under [Tuning Guidelines](#tuning-guidelines) for caveats around how `max_num_batched_tokens` is populated on that backend, and see [Priority Scheduling](../../agents/priority-scheduling.md) for how router priority differs from backend engine priority.
1920
- `--router-queue-policy`: Scheduling policy for the router queue (default: `fcfs`).

docs/components/router/router-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Backend workers register themselves using the `register_model` API. For accurate
4141
| `--router-kv-overlap-score-credit <float>` | `1.0` | Credit multiplier for device-local prefix overlap, from 0.0 to 1.0 |
4242
| `--router-prefill-load-scale <float>` | `1.0` | Scale adjusted prompt-side prefill load before adding decode blocks |
4343
| `--router-track-prefill-tokens` / `--no-router-track-prefill-tokens` | `--router-track-prefill-tokens` | Include prompt-side load in active worker load accounting |
44+
| `--router-gms-decode-transfer` / `--no-router-gms-decode-transfer` | `--no-router-gms-decode-transfer` | Experimental GMS decode-to-decode transfer orchestration for GMS-capable aggregated decode workers |
4445
| `--router-prefill-load-model <none\|aic>` | `none` | Prompt-side load model; see [Routing Concepts](router-concepts.md#active-load-modeling) and [Configuration and Tuning](router-configuration.md#aic-prefill-load-model) |
4546
| `--router-queue-threshold <float>` | `16.0` | Queue threshold fraction; priority hints only reorder requests while this queue is non-empty |
4647
| `--router-queue-policy <str>` | `fcfs` | Scheduling policy for the queue: `fcfs` (tail TTFT), `wspt` (avg TTFT), or `lcfs` (comparison-only reverse ordering) |

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,10 @@ def pytest_runtestloop(session: pytest.Session) -> bool | None:
407407
@pytest.fixture()
408408
def set_ucx_tls_no_mm():
409409
"""Set UCX env defaults for all tests."""
410+
if os.environ.get("DYNAMO_TEST_PRESERVE_UCX_ENV") == "1":
411+
yield
412+
return
413+
410414
mp = pytest.MonkeyPatch()
411415
# CI note:
412416
# - Affected test: tests/fault_tolerance/cancellation/test_vllm.py::test_request_cancellation_vllm_decode_cancel
@@ -454,6 +458,10 @@ def download_models(model_list=None, ignore_weights=False):
454458

455459
failures = []
456460
for model_id in model_list:
461+
if Path(str(model_id)).exists():
462+
logging.info("Using local model path; skipping pre-download: %s", model_id)
463+
continue
464+
457465
logging.info(
458466
f"Pre-downloading {'model (no weights)' if ignore_weights else 'model'}: {model_id}"
459467
)

tests/router/common.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,6 +1969,8 @@ def _test_router_decisions_disagg(
19691969
durable_kv_events: bool = False,
19701970
router_aic_config: Optional[dict[str, Any]] = None,
19711971
enable_bootstrap: bool = False,
1972+
strict_timing: bool = True,
1973+
progressive_request_count: int = 4,
19721974
):
19731975
"""Validate KV cache prefix reuse in disaggregated prefill-decode setup via HTTP frontend.
19741976
@@ -1992,6 +1994,8 @@ def _test_router_decisions_disagg(
19921994
store_backend: Storage backend to use ("etcd" or "file"). Defaults to "etcd".
19931995
durable_kv_events: If True, use durable KV events (JetStream). Defaults to False.
19941996
router_aic_config: Optional AIC router perf-model config for frontend KV routing.
1997+
strict_timing: If False, allow backend responses that omit optional timing fields.
1998+
progressive_request_count: Number of overlapping-prefix requests to send.
19951999
19962000
Raises:
19972001
AssertionError: If prefill_worker_ids differ across requests (prefix reuse failure)
@@ -2030,16 +2034,19 @@ def _test_router_decisions_disagg(
20302034
)
20312035
)
20322036

2037+
if progressive_request_count < 2:
2038+
raise ValueError("progressive_request_count must be at least 2")
2039+
20332040
async def send_progressive_requests():
2034-
"""Send 4 progressive requests with overlapping prefixes and collect worker IDs."""
2041+
"""Send progressive requests with overlapping prefixes and collect worker IDs."""
20352042
prefill_worker_ids = []
20362043
decode_worker_ids = []
20372044

20382045
# Generate base tokens for progressive prefix extension
20392046
base_content = test_payload["messages"][0]["content"]
20402047

20412048
async with aiohttp.ClientSession() as session:
2042-
for i in range(4):
2049+
for i in range(progressive_request_count):
20432050
# Build progressive content by repeating base content
20442051
# Each iteration adds more content to extend the prefix
20452052
progressive_content = " ".join([base_content] * (i + 1))
@@ -2058,7 +2065,7 @@ async def send_progressive_requests():
20582065
}
20592066

20602067
logger.info(
2061-
f"Sending request {i + 1}/4 with progressive prefix "
2068+
f"Sending request {i + 1}/{progressive_request_count} with progressive prefix "
20622069
f"(~{len(progressive_content)} chars)"
20632070
)
20642071

@@ -2067,10 +2074,13 @@ async def send_progressive_requests():
20672074
response.status == 200
20682075
), f"Request {i + 1} failed with status {response.status}"
20692076

2070-
# Collect all chunks and look for nvext with worker_id and timing
2077+
# Collect all chunks and look for nvext with worker_id, timing,
2078+
# and actual generated text. A backend can otherwise surface a
2079+
# transfer failure as a 200 stream with worker IDs but no tokens.
20712080
prefill_wid = None
20722081
decode_wid = None
20732082
timing_info = None
2083+
generated_parts: list[str] = []
20742084

20752085
async for line in response.content:
20762086
if not line:
@@ -2086,6 +2096,12 @@ async def send_progressive_requests():
20862096

20872097
try:
20882098
data = json.loads(data_str)
2099+
for choice in data.get("choices", []):
2100+
delta = choice.get("delta") or {}
2101+
content = delta.get("content")
2102+
if content:
2103+
generated_parts.append(content)
2104+
20892105
# Check for nvext in the response
20902106
nvext = data.get("nvext", {})
20912107
if nvext:
@@ -2106,9 +2122,15 @@ async def send_progressive_requests():
21062122
except json.JSONDecodeError:
21072123
continue
21082124

2125+
generated_text = "".join(generated_parts)
21092126
logger.info(
21102127
f"Request {i + 1}: prefill_worker_id={prefill_wid}, "
2111-
f"decode_worker_id={decode_wid}, timing={timing_info}"
2128+
f"decode_worker_id={decode_wid}, timing={timing_info}, "
2129+
f"generated_chars={len(generated_text)}"
2130+
)
2131+
assert generated_text.strip(), (
2132+
f"Request {i + 1}: Expected generated content in the response stream, "
2133+
"but the backend returned no text tokens"
21122134
)
21132135

21142136
if prefill_wid is not None:
@@ -2122,7 +2144,12 @@ async def send_progressive_requests():
21222144
assert (
21232145
timing_info is not None
21242146
), f"Request {i + 1}: Expected timing info in final chunk, got None"
2125-
verify_response_timing(timing_info, disagg=not enable_bootstrap)
2147+
verify_response_timing(
2148+
timing_info,
2149+
disagg=not enable_bootstrap,
2150+
require_ttft=strict_timing,
2151+
require_kv_transfer_latency=strict_timing,
2152+
)
21262153

21272154
# Small delay between requests
21282155
await asyncio.sleep(1)
@@ -2136,8 +2163,8 @@ async def send_progressive_requests():
21362163
logger.info(f"Collected decode_worker_ids: {decode_ids}")
21372164

21382165
# Verify we got worker IDs from all requests
2139-
assert len(prefill_ids) == 4, (
2140-
f"Expected 4 prefill_worker_ids, got {len(prefill_ids)}. "
2166+
assert len(prefill_ids) == progressive_request_count, (
2167+
f"Expected {progressive_request_count} prefill_worker_ids, got {len(prefill_ids)}. "
21412168
f"Make sure nvext.extra_fields=['worker_id'] is being processed."
21422169
)
21432170

@@ -2148,12 +2175,12 @@ async def send_progressive_requests():
21482175
# the second request is routed before the first request's KV "stored" events have been
21492176
# fully ingested. After ingestion, routing stabilizes.
21502177
#
2151-
# So for TCP we assert that requests 2-4 converge to the same prefill worker; for NATS
2178+
# So for TCP we assert that requests 2-N converge to the same prefill worker; for NATS
21522179
# request plane we keep the stronger assertion that all 4 match.
21532180
if request_plane == "tcp":
21542181
unique_prefill_ids = set(prefill_ids[1:])
21552182
assert len(unique_prefill_ids) == 1, (
2156-
f"Expected prefill requests 2-4 to route to the same worker due to prefix reuse, "
2183+
f"Expected prefill requests 2-{progressive_request_count} to route to the same worker due to prefix reuse, "
21572184
f"but found {len(unique_prefill_ids)} unique prefill_worker_ids: {unique_prefill_ids}. "
21582185
f"Full list: {prefill_ids}"
21592186
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Extra-engine-args for aggregate TRT-LLM router e2e tests.
5+
#
6+
# The TRTLLM attention backend currently takes the FMHA NVRTC path on B200 for
7+
# TinyLlama generation and can fail when runtime include discovery does not
8+
# populate cuda.h search paths. VANILLA keeps the test on the PyTorch backend
9+
# path while still exercising real TRT-LLM workers,
10+
# router discovery, request forwarding, KV events, and local indexer behavior.
11+
attn_backend: VANILLA

tests/router/configs/trtllm_disagg_decode.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@
44
# Extra-engine-args for the decode side of disaggregated TRT-LLM in router
55
# e2e tests. Only the transceiver backend is required here; the overlap
66
# scheduler can stay on for decode workers.
7+
attn_backend: VANILLA
8+
79
cache_transceiver_config:
8-
backend: DEFAULT
10+
backend: UCX
11+
max_tokens_in_buffer: 8192

tests/router/configs/trtllm_disagg_prefill.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# "Context only requests are not supported in pytorch backend when
1414
# overlap is enabled with block reuse").
1515
disable_overlap_scheduler: true
16+
attn_backend: VANILLA
1617

1718
cache_transceiver_config:
18-
backend: DEFAULT
19+
backend: UCX
20+
max_tokens_in_buffer: 8192

0 commit comments

Comments
 (0)