-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix: support approx routing in mm router #8135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8e4f2b7
d9bfffc
e96984b
946d54d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| #!/bin/bash | ||
| # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Aggregated multimodal serving for Qwen3.5 hybrid models with MM-aware approximate KV routing. | ||
| # | ||
| # Qwen3.5 is a multimodal hybrid model (GatedDeltaNet + Gated Attention + Vision Encoder). | ||
| # It supports images and video but has hybrid architecture constraints: | ||
| # | ||
| # 1. DO NOT pass --kv-events-config or --enable-kv-cache-events: | ||
| # vLLM disables the Hybrid KV Cache Manager when kv_events_config is set, | ||
| # but Qwen3.5's mixed KV cache specs (GDN + FullAttention) cannot be unified | ||
| # into one type. | ||
| # | ||
| # 2. Use --mamba-cache-mode align (not "all"): | ||
| # Qwen3.5 raises NotImplementedError with mamba_cache_mode="all". | ||
| # | ||
| # 3. Approximate KV routing (--no-router-kv-events): | ||
| # Hybrid models cannot emit KV events to the router. The MM Router Worker | ||
| # predicts cache state from its own routing decisions using prefix hashing. | ||
| # | ||
| # 4. Disaggregated P/D is NOT supported for hybrid models in vLLM: | ||
| # HybridKVCacheCoordinator asserts dcp_world_size == 1. | ||
| # | ||
| # 5. Use TCP transport for multimodal payloads (NATS has 1MB limit). | ||
| # | ||
| # Architecture: | ||
| # Frontend (--router-mode round-robin) | ||
| # -> MM Router Worker (approximate KV routing + multimodal hash) | ||
| # -> vLLM Worker (--enable-multimodal --mamba-cache-mode align) | ||
|
|
||
| set -e | ||
| trap 'echo Cleaning up...; kill 0' EXIT | ||
|
|
||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| source "$SCRIPT_DIR/../../../common/gpu_utils.sh" | ||
| source "$SCRIPT_DIR/../../../common/launch_utils.sh" | ||
|
|
||
| MODEL="${MODEL:-Qwen/Qwen3.5-0.8B}" | ||
|
|
||
| EXTRA_ARGS=() | ||
| while [[ $# -gt 0 ]]; do | ||
| case $1 in | ||
| --model) MODEL="$2"; shift 2 ;; | ||
| *) EXTRA_ARGS+=("$1"); shift ;; | ||
| esac | ||
| done | ||
|
|
||
| MAX_MODEL_LEN="${MAX_MODEL_LEN:-4096}" | ||
| MAX_CONCURRENT_SEQS="${MAX_CONCURRENT_SEQS:-2}" | ||
| HTTP_PORT="${DYN_HTTP_PORT:-8000}" | ||
| BLOCK_SIZE="${BLOCK_SIZE:-16}" | ||
|
|
||
| # TCP transport: avoids NATS 1MB payload limit for base64-encoded images | ||
| export DYN_REQUEST_PLANE=tcp | ||
|
|
||
| print_launch_banner --no-curl "Launching Qwen3.5 Multimodal + MM Router + Approx KV (1 GPU)" "$MODEL" "$HTTP_PORT" \ | ||
| "Backend: dynamo.vllm --enable-multimodal --mamba-cache-mode align" \ | ||
| "MM Router: MM-aware approximate KV routing (--no-router-kv-events)" \ | ||
| "Frontend: round-robin to MM Router" \ | ||
| "Transport: TCP (multimodal payloads)" | ||
|
|
||
| print_curl_footer <<CURL | ||
| # Text-only request | ||
| curl http://localhost:${HTTP_PORT}/v1/chat/completions \\ | ||
| -H 'Content-Type: application/json' \\ | ||
| -d '{ | ||
| "model": "${MODEL}", | ||
| "messages": [{"role": "user", "content": "What is 2+2?"}], | ||
| "max_tokens": 32 | ||
| }' | ||
|
|
||
| # Multimodal request (image) | ||
| curl http://localhost:${HTTP_PORT}/v1/chat/completions \\ | ||
| -H 'Content-Type: application/json' \\ | ||
| -d '{ | ||
| "model": "${MODEL}", | ||
| "messages": [{"role": "user", "content": [ | ||
| {"type": "text", "text": "Describe the image"}, | ||
| {"type": "image_url", "image_url": {"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/300px-PNG_transparency_demonstration_1.png"}} | ||
| ]}], | ||
| "max_tokens": 50 | ||
| }' | ||
| CURL | ||
|
|
||
| GPU_MEM_ARGS=$(build_vllm_gpu_mem_args) | ||
|
|
||
| # vLLM worker: hybrid multimodal model | ||
| # --served-model-name __internal: hides from frontend so traffic goes through MM Router | ||
| # --mamba-cache-mode align: required for GDN+Attention hybrid architecture | ||
| # --enable-multimodal: enables vision encoder / multimodal data handling | ||
| # NOTE: do NOT use --is-decode-worker here. It causes the handler to enter | ||
| # decode-only mode which silently drops image data for models not in | ||
| # QWEN_VL_MODELS (Qwen3.5 is not listed), leading to incorrect prefix | ||
| # cache hits across different images. | ||
| CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0} \ | ||
| DYN_SYSTEM_PORT=${DYN_SYSTEM_PORT:-8081} \ | ||
| python -m dynamo.vllm \ | ||
| --model "$MODEL" \ | ||
| --served-model-name "${MODEL}__internal" \ | ||
| --enable-multimodal \ | ||
| --mamba-cache-mode align \ | ||
| --block-size "$BLOCK_SIZE" \ | ||
| --enforce-eager \ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we leave this out? |
||
| --max-model-len "$MAX_MODEL_LEN" \ | ||
| --max-num-seqs "$MAX_CONCURRENT_SEQS" \ | ||
| $GPU_MEM_ARGS \ | ||
| "${EXTRA_ARGS[@]}" & | ||
|
|
||
| # MM Router Worker: multimodal-aware approximate KV routing | ||
| # --namespace dynamo: must match frontend/vllm default (DYN_NAMESPACE defaults to "dynamo") | ||
| # --no-router-kv-events: Qwen3.5 hybrid model cannot emit KV events | ||
| DYN_SYSTEM_PORT=${MM_ROUTER_SYSTEM_PORT:-8082} \ | ||
| python -m examples.backends.vllm.mm_router_worker \ | ||
| --model "$MODEL" \ | ||
| --namespace dynamo \ | ||
| --block-size "$BLOCK_SIZE" \ | ||
| --no-router-kv-events & | ||
|
|
||
| # Frontend: round-robin dispatch to MM Router (KV routing happens inside MM Router) | ||
| python -m dynamo.frontend \ | ||
| --router-mode round-robin & | ||
|
|
||
| wait_any_exit | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -99,30 +99,34 @@ def _prepare_log_dir(request, suffix: str) -> str: | |
|
|
||
|
|
||
| class VLLMWorkerProcess(ManagedProcess): | ||
| """vLLM backend worker that emits KV events.""" | ||
|
|
||
| def __init__(self, request, *, system_port: int, kv_event_port: int): | ||
| super().__init__( | ||
| command=[ | ||
| "python3", | ||
| "-m", | ||
| "dynamo.vllm", | ||
| "--model", | ||
| VLLM_MM_MODEL, | ||
| "--enable-multimodal", | ||
| "--gpu-memory-utilization", | ||
| "0.85", | ||
| "--max-model-len", | ||
| "8192", | ||
| "--served-model-name", | ||
| f"{VLLM_MM_MODEL}__internal", | ||
| """vLLM backend worker that optionally emits KV events.""" | ||
|
|
||
| def __init__(self, request, *, system_port: int, kv_event_port: int | None = None): | ||
| cmd = [ | ||
| "python3", | ||
| "-m", | ||
| "dynamo.vllm", | ||
| "--model", | ||
| VLLM_MM_MODEL, | ||
| "--enable-multimodal", | ||
| "--gpu-memory-utilization", | ||
| "0.85", | ||
| "--max-model-len", | ||
| "8192", | ||
| "--served-model-name", | ||
| f"{VLLM_MM_MODEL}__internal", | ||
| ] | ||
| if kv_event_port is not None: | ||
| cmd += [ | ||
| "--kv-events-config", | ||
| ( | ||
| f'{{"publisher":"zmq","topic":"kv-events",' | ||
| f'"endpoint":"tcp://*:{kv_event_port}",' | ||
| f'"enable_kv_cache_events": true}}' | ||
| ), | ||
| ], | ||
| ] | ||
| super().__init__( | ||
| command=cmd, | ||
| env=_make_process_env(DYN_SYSTEM_PORT=str(system_port)), | ||
| health_check_urls=[ | ||
| (f"http://localhost:{system_port}/health", _check_ready) | ||
|
|
@@ -135,29 +139,32 @@ def __init__(self, request, *, system_port: int, kv_event_port: int): | |
|
|
||
|
|
||
| class VLLMMMRouterWorkerProcess(ManagedProcess): | ||
| """vLLM MM router worker.""" | ||
|
|
||
| def __init__(self, request, *, system_port: int): | ||
| """vLLM MM router worker (exact or approximate KV routing).""" | ||
|
|
||
| def __init__(self, request, *, system_port: int, approx_routing: bool = False): | ||
| cmd = [ | ||
| "python3", | ||
| "-m", | ||
| "examples.backends.vllm.mm_router_worker", | ||
| "--model", | ||
| VLLM_MM_MODEL, | ||
| "--namespace", | ||
| NAMESPACE, | ||
| "--component", | ||
| "mm_router", | ||
| "--endpoint", | ||
| "generate", | ||
| "--downstream-component", | ||
| "backend", | ||
| "--downstream-endpoint", | ||
| "generate", | ||
| "--block-size", | ||
| str(BLOCK_SIZE), | ||
| ] | ||
| if approx_routing: | ||
| cmd.append("--no-router-kv-events") | ||
| super().__init__( | ||
| command=[ | ||
| "python3", | ||
| "-m", | ||
| "examples.backends.vllm.mm_router_worker", | ||
| "--model", | ||
| VLLM_MM_MODEL, | ||
| "--namespace", | ||
| NAMESPACE, | ||
| "--component", | ||
| "mm_router", | ||
| "--endpoint", | ||
| "generate", | ||
| "--downstream-component", | ||
| "backend", | ||
| "--downstream-endpoint", | ||
| "generate", | ||
| "--block-size", | ||
| str(BLOCK_SIZE), | ||
| ], | ||
| command=cmd, | ||
| env=_make_process_env( | ||
| DYN_SYSTEM_USE_ENDPOINT_HEALTH_STATUS='["generate"]', | ||
| DYN_SYSTEM_PORT=str(system_port), | ||
|
|
@@ -207,17 +214,27 @@ def mm_runtime_services(request): | |
| os.environ.pop("ETCD_ENDPOINTS", None) | ||
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| @pytest.fixture(scope="module", params=[False, True], ids=["exact_kv", "approx_kv"]) | ||
| def start_vllm_mm_services( | ||
| request, mm_runtime_services | ||
| ) -> Generator[tuple[int, ManagedProcess], None, None]: | ||
| frontend_port, vllm_port, router_port, kv_event_port = allocate_ports( | ||
| count=4, start_port=10000 | ||
| ) | ||
| approx_routing = request.param | ||
|
|
||
| if approx_routing: | ||
| frontend_port, vllm_port, router_port = allocate_ports( | ||
| count=3, start_port=10000 | ||
| ) | ||
| kv_event_port = None | ||
| else: | ||
| frontend_port, vllm_port, router_port, kv_event_port = allocate_ports( | ||
| count=4, start_port=10000 | ||
| ) | ||
|
Comment on lines
+217
to
+231
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the repo’s dynamic service/port fixtures here instead of extending the hand-rolled setup. Adding the exact/approx split on top of manual As per coding guidelines, "Use 🤖 Prompt for AI Agents |
||
|
|
||
| with VLLMWorkerProcess(request, system_port=vllm_port, kv_event_port=kv_event_port): | ||
| time.sleep(10) | ||
| with VLLMMMRouterWorkerProcess(request, system_port=router_port) as router_proc: | ||
| with VLLMMMRouterWorkerProcess( | ||
| request, system_port=router_port, approx_routing=approx_routing | ||
| ) as router_proc: | ||
| time.sleep(3) | ||
| with FrontendProcess(request, frontend_port=frontend_port): | ||
| yield frontend_port, router_proc | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pass the resolved frontend port into
dynamo.frontend.HTTP_PORTdrives the banner and curl examples, but the frontend command never consumes it. That makes the documented port override path rely on implicit frontend behavior and can leave the printed curl command pointing at the wrong socket.🔧 Proposed fix
python -m dynamo.frontend \ + --http-port "$HTTP_PORT" \ --router-mode round-robin &Also applies to: 121-122
🤖 Prompt for AI Agents