[CI] Add e2e test for scale-out EC connector flow (#54973)
Signed-off-by: zhouyou9505 <zhouyou9505@gmail.com> Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai> Co-authored-by: zhouyou9505 <zhouyou9505@gmail.com> Co-authored-by: Cursor Grok 4.6 <noreply@cursor.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
Nicolò Lucchesi committed
Sep 9, 2026 at 16:38 UTC
9ffb8cea96369e2164af184a10faaa618eaf6b92
5 files changed
+617
-2
.buildkite/test_areas/disaggregated_ec.yaml
new
+16
@@ -0,0 +1,16 @@
1
+group: Disaggregated EC
2
+depends_on:
3
+ - image-build
4
+steps:
5
+- label: ":nvidia: (L4) Scale-out EC Connector E2E"
6
+ key: scale-out-ec-e2e-2-gpus
7
+ timeout_in_minutes: 40
8
+ working_dir: "/vllm-workspace/tests"
9
+ device: l4
10
+ num_devices: 2
11
+ source_file_dependencies:
12
+ - vllm/distributed/ec_transfer/
13
+ - vllm/entrypoints/scale_out/
14
+ - tests/entrypoints/scale_out/ec_integration/
15
+ commands:
16
+ - bash entrypoints/scale_out/ec_integration/run_scale_out_ec_e2e_test.sh
tests/entrypoints/scale_out/ec_integration/README.md
new
+43
@@ -0,0 +1,43 @@
1
+# Scale-out EC connector e2e tests
2
+
3
+End-to-end tests for the disaggregated multimodal (encoder-cache) flow over
4
+the scale-out endpoints, in the bash-script style of
5
+`tests/v1/kv_connector/nixl_integration`.
6
+
7
+## Topology
8
+
9
+```text
10
+render (GPU-less) encode (EC producer)
11
+ /v1/chat/completions/render /inference/v1/generate (kwargs_data)
12
+ /v1/chat/completions/derender |
13
+ prefill (EC consumer)
14
+ /inference/v1/generate (mm_metadata + ec_transfer_params)
15
+```
16
+
17
+- The **render** server (`vllm launch render`) preprocesses the chat request
18
+ into token ids and multimodal features (`kwargs_data` + `mm_metadata`).
19
+- The **encode** instance (`ec_role: ec_producer`, encode-only) runs the
20
+ vision encoder on `kwargs_data` and publishes embeddings through the
21
+ `ECExampleConnector` shared storage. Its response carries
22
+ `ec_transfer_params`.
23
+- The **prefill** instance (`ec_role: ec_consumer`) receives metadata-only
24
+ features (`mm_metadata`, no `kwargs_data`) plus `ec_transfer_params`, loads
25
+ the embeddings through the EC connector, and generates output token ids.
26
+- The **derender** endpoint turns the output token ids back into a chat
27
+ completion response.
28
+
29
+## Running
30
+
31
+```bash
32
+bash tests/entrypoints/scale_out/ec_integration/run_scale_out_ec_e2e_test.sh
33
+```
34
+
35
+The script first collects baseline outputs from a single `vllm serve`
36
+instance, then runs the scale-out topology and compares the derendered
37
+outputs for exact equality. It also checks that metadata-only features are
38
+rejected when `ec_transfer_params` is missing.
39
+
40
+Defaults: `Qwen/Qwen3-VL-2B-Instruct`, encode on GPU 0, prefill on GPU 1.
41
+Override via environment variables (`MODEL`, `GPU_E`, `GPU_PD`,
42
+`EC_SHARED_STORAGE_PATH`, `PYTHON=.venv/bin/python`, ...); see the header of
43
+the script for the full list.
tests/entrypoints/scale_out/ec_integration/run_scale_out_ec_e2e_test.sh
new
+275
@@ -0,0 +1,275 @@
1
+#!/bin/bash
2
+# SPDX-License-Identifier: Apache-2.0
3
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4
+#
5
+# Scale-out EC connector E2E test
6
+#
7
+# Tests the disaggregated multimodal flow over the scale-out endpoints,
8
+# following the bash-script style of tests/v1/kv_connector/nixl_integration:
9
+#
10
+# 1. Baseline: a single vLLM instance serving /v1/chat/completions
11
+# 2. Scale-out:
12
+# render (GPU-less, `vllm launch render`)
13
+# encode (EC producer, encode-only)
14
+# prefill (EC consumer)
15
+#
16
+# The Python client renders each multimodal request once, sends the full
17
+# kwargs_data to the encode instance, then sends metadata-only features
18
+# (mm_metadata, no kwargs_data) plus ec_transfer_params to the prefill
19
+# instance, and derenders the output tokens. Outputs are compared against
20
+# the baseline for exact equality.
21
+#
22
+# Usage:
23
+# bash tests/entrypoints/scale_out/ec_integration/run_scale_out_ec_e2e_test.sh
24
+#
25
+# Requires 2 GPUs by default (encode on GPU 0, prefill on GPU 1); the
26
+# baseline reuses GPU 0 after the encode instance is stopped.
27
+
28
+set -euo pipefail
29
+
30
+# Resolve the repository root from the script location instead of `.git`.
31
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
32
+GIT_ROOT="${GIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../../../.." && pwd -P)}"
33
+
34
+# Model and engine configuration
35
+MODEL="${MODEL:-Qwen/Qwen3-VL-2B-Instruct}"
36
+MAX_MODEL_LEN="${MAX_MODEL_LEN:-8192}"
37
+GPU_MEMORY_UTILIZATION="${GPU_MEMORY_UTILIZATION:-0.7}"
38
+MAX_NUM_SEQS="${MAX_NUM_SEQS:-128}"
39
+
40
+# GPU configuration. encode + prefill run concurrently on separate GPUs;
41
+# the baseline reuses GPU_E after the scale-out instances are stopped.
42
+GPU_E="${GPU_E:-0}"
43
+GPU_PD="${GPU_PD:-1}"
44
+GPU_SINGLE="${GPU_SINGLE:-$GPU_E}"
45
+
46
+# Ports
47
+RENDER_PORT="${RENDER_PORT:-19600}"
48
+ENCODE_PORT="${ENCODE_PORT:-19601}"
49
+PREFILL_PORT="${PREFILL_PORT:-19602}"
50
+BASELINE_PORT="${BASELINE_PORT:-19603}"
51
+
52
+# Storage path for the ECExampleConnector encoder cache
53
+EC_SHARED_STORAGE_PATH="${EC_SHARED_STORAGE_PATH:-/tmp/ec_scale_out_test}"
54
+
55
+# Python interpreter (override to .venv/bin/python for local runs)
56
+PYTHON="${PYTHON:-python3}"
57
+
58
+# Output files and logs
59
+LOG_PATH="${LOG_PATH:-/tmp}"
60
+BASELINE_FILE="${BASELINE_FILE:-/tmp/vllm_scale_out_ec_baseline.txt}"
61
+TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-600}"
62
+BASELINE_LOG="${LOG_PATH}/scale_out_ec_baseline.log"
63
+RENDER_LOG="${LOG_PATH}/scale_out_ec_render.log"
64
+ENCODE_LOG="${LOG_PATH}/scale_out_ec_encode.log"
65
+PREFILL_LOG="${LOG_PATH}/scale_out_ec_prefill.log"
66
+
67
+mkdir -p "$LOG_PATH"
68
+
69
+# Trap the SIGINT signal (triggered by Ctrl+C)
70
+trap 'kill $(jobs -pr) 2>/dev/null || true' SIGINT SIGTERM EXIT
71
+
72
+wait_for_server() {
73
+ local port=$1
74
+ local pid=$2
75
+ local name=$3
76
+ local deadline=$((SECONDS + TIMEOUT_SECONDS))
77
+
78
+ while ((SECONDS < deadline)); do
79
+ if ! kill -0 "$pid" 2>/dev/null; then
80
+ echo "$name instance exited before becoming healthy"
81
+ return 1
82
+ fi
83
+ if curl --max-time 2 -fsS "http://localhost:${port}/health" \
84
+ > /dev/null 2>&1; then
85
+ return 0
86
+ fi
87
+ sleep 1
88
+ done
89
+
90
+ echo "Timed out waiting for $name instance on port $port"
91
+ return 1
92
+}
93
+
94
+dump_log() {
95
+ local name=$1
96
+ local log=$2
97
+
98
+ echo "--- $name log (last 200 lines)"
99
+ if [[ -f "$log" ]]; then
100
+ tail -n 200 "$log" || true
101
+ else
102
+ echo "Log file not found: $log"
103
+ fi
104
+}
105
+
106
+dump_scale_out_logs() {
107
+ dump_log "render" "$RENDER_LOG"
108
+ dump_log "encode" "$ENCODE_LOG"
109
+ dump_log "prefill" "$PREFILL_LOG"
110
+}
111
+
112
+cleanup_instances() {
113
+ echo "Cleaning up any running vLLM instances..."
114
+ pkill -TERM -f "vllm serve" || true
115
+ pkill -TERM -f "vllm launch render" || true
116
+ sleep 3
117
+ pkill -9 -f "vllm serve" || true
118
+ pkill -9 -f "vllm launch render" || true
119
+ sleep 2
120
+}
121
+
122
+# Step 1: baseline single instance
123
+run_baseline() {
124
+ echo "================================"
125
+ echo "Running BASELINE (single instance)"
126
+ echo "================================"
127
+
128
+ cleanup_instances
129
+ rm -rf "$EC_SHARED_STORAGE_PATH"
130
+
131
+ echo "Starting baseline instance on GPU $GPU_SINGLE, port $BASELINE_PORT"
132
+ env CUDA_VISIBLE_DEVICES="$GPU_SINGLE" vllm serve "$MODEL" \
133
+ --port "$BASELINE_PORT" \
134
+ --max-model-len "$MAX_MODEL_LEN" \
135
+ --enforce-eager \
136
+ --gpu-memory-utilization 0.9 \
137
+ --max-num-seqs "$MAX_NUM_SEQS" \
138
+ > "$BASELINE_LOG" 2>&1 &
139
+ local BASELINE_PID=$!
140
+
141
+ echo "Waiting for baseline instance to start..."
142
+ if ! wait_for_server "$BASELINE_PORT" "$BASELINE_PID" "baseline"; then
143
+ dump_log "baseline" "$BASELINE_LOG"
144
+ return 1
145
+ fi
146
+
147
+ if ! "$PYTHON" "${SCRIPT_DIR}/test_scale_out_ec_e2e.py" \
148
+ --mode baseline \
149
+ --service_url "http://localhost:$BASELINE_PORT" \
150
+ --model_name "$MODEL" \
151
+ --baseline_file "$BASELINE_FILE"; then
152
+ dump_log "baseline" "$BASELINE_LOG"
153
+ return 1
154
+ fi
155
+
156
+ echo "Stopping baseline instance..."
157
+ kill "$BASELINE_PID" 2>/dev/null || true
158
+ sleep 2
159
+ cleanup_instances
160
+}
161
+
162
+# Step 2: render + encode (EC producer) + prefill (EC consumer)
163
+run_scale_out_ec() {
164
+ echo "================================"
165
+ echo "Running SCALE-OUT EC (render + encode + prefill)"
166
+ echo "================================"
167
+
168
+ cleanup_instances
169
+ rm -rf "$EC_SHARED_STORAGE_PATH"
170
+ mkdir -p "$EC_SHARED_STORAGE_PATH"
171
+
172
+ declare -a PIDS=()
173
+
174
+ # GPU-less render/derender server
175
+ echo "Starting render server on port $RENDER_PORT"
176
+ vllm launch render "$MODEL" \
177
+ --port "$RENDER_PORT" \
178
+ > "$RENDER_LOG" 2>&1 &
179
+ PIDS+=("$!")
180
+
181
+ # Encode-only EC producer instance. It runs the vision encoder and
182
+ # publishes embeddings through the ECExampleConnector shared storage.
183
+ echo "Starting encode instance on GPU $GPU_E, port $ENCODE_PORT"
184
+ env CUDA_VISIBLE_DEVICES="$GPU_E" VLLM_ENABLE_SCALE_OUT_ENDPOINTS=1 \
185
+ vllm serve "$MODEL" \
186
+ --port "$ENCODE_PORT" \
187
+ --max-model-len "$MAX_MODEL_LEN" \
188
+ --enforce-eager \
189
+ --gpu-memory-utilization 0.01 \
190
+ --no-enable-prefix-caching \
191
+ --max-num-batched-tokens 114688 \
192
+ --max-num-seqs "$MAX_NUM_SEQS" \
193
+ --ec-transfer-config '{
194
+ "ec_connector": "ECExampleConnector",
195
+ "ec_role": "ec_producer",
196
+ "ec_connector_extra_config": {
197
+ "shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
198
+ }
199
+ }' \
200
+ > "$ENCODE_LOG" 2>&1 &
201
+ PIDS+=("$!")
202
+
203
+ # Prefill/decode EC consumer instance. It receives metadata-only
204
+ # features plus ec_transfer_params and loads the embeddings that the
205
+ # encode instance published.
206
+ echo "Starting prefill instance on GPU $GPU_PD, port $PREFILL_PORT"
207
+ env CUDA_VISIBLE_DEVICES="$GPU_PD" VLLM_ENABLE_SCALE_OUT_ENDPOINTS=1 \
208
+ vllm serve "$MODEL" \
209
+ --port "$PREFILL_PORT" \
210
+ --max-model-len "$MAX_MODEL_LEN" \
211
+ --enforce-eager \
212
+ --enable-mm-embeds \
213
+ --gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
214
+ --max-num-seqs "$MAX_NUM_SEQS" \
215
+ --ec-transfer-config '{
216
+ "ec_connector": "ECExampleConnector",
217
+ "ec_role": "ec_consumer",
218
+ "ec_connector_extra_config": {
219
+ "shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
220
+ }
221
+ }' \
222
+ > "$PREFILL_LOG" 2>&1 &
223
+ PIDS+=("$!")
224
+
225
+ echo "Waiting for render instance..."
226
+ if ! wait_for_server "$RENDER_PORT" "${PIDS[0]}" "render"; then
227
+ dump_scale_out_logs
228
+ return 1
229
+ fi
230
+ echo "Waiting for encode instance..."
231
+ if ! wait_for_server "$ENCODE_PORT" "${PIDS[1]}" "encode"; then
232
+ dump_scale_out_logs
233
+ return 1
234
+ fi
235
+ echo "Waiting for prefill instance..."
236
+ if ! wait_for_server "$PREFILL_PORT" "${PIDS[2]}" "prefill"; then
237
+ dump_scale_out_logs
238
+ return 1
239
+ fi
240
+
241
+ echo "All scale-out EC services are up!"
242
+
243
+ if ! "$PYTHON" "${SCRIPT_DIR}/test_scale_out_ec_e2e.py" \
244
+ --mode disagg \
245
+ --render_url "http://localhost:$RENDER_PORT" \
246
+ --encode_url "http://localhost:$ENCODE_PORT" \
247
+ --prefill_url "http://localhost:$PREFILL_PORT" \
248
+ --model_name "$MODEL" \
249
+ --baseline_file "$BASELINE_FILE"; then
250
+ dump_scale_out_logs
251
+ return 1
252
+ fi
253
+
254
+ echo "Stopping scale-out EC instances..."
255
+ for pid in "${PIDS[@]}"; do
256
+ kill "$pid" 2>/dev/null || true
257
+ done
258
+ sleep 2
259
+ cleanup_instances
260
+}
261
+
262
+# Main execution
263
+echo "================================"
264
+echo "Scale-out EC E2E Test Suite"
265
+echo "Model: $MODEL"
266
+echo "================================"
267
+
268
+run_baseline
269
+run_scale_out_ec
270
+
271
+rm -f "$BASELINE_FILE"
272
+
273
+echo "================================"
274
+echo "All scale-out EC e2e tests finished!"
275
+echo "================================"
tests/entrypoints/scale_out/ec_integration/test_scale_out_ec_e2e.py
new
+276
@@ -0,0 +1,276 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""E2E test for the scale-out EC connector flow (render -> encode -> prefill).
4
+
5
+Mimics the bash-script style of ``tests/v1/kv_connector/nixl_integration``:
6
+the instances are started by ``run_scale_out_ec_e2e_test.sh`` and this file is
7
+the HTTP client that drives the disaggregated request path and compares its
8
+output against a single-instance baseline.
9
+
10
+Topology under test (started by the bash script):
11
+
12
+ render (GPU-less) -- /v1/chat/completions/render, /derender
13
+ encode (EC producer) -- /inference/v1/generate
14
+ prefill (EC consumer) -- /inference/v1/generate
15
+
16
+The client renders each multimodal chat request once, sends the full
17
+``kwargs_data`` to the encode instance, then sends metadata-only features
18
+(``mm_metadata`` without ``kwargs_data``) plus the encode response's
19
+``ec_transfer_params`` to the prefill instance, and finally derenders the
20
+output token ids. The derendered text must match the baseline exactly.
21
+
22
+Usage (from the bash script, or manually):
23
+ # Baseline mode (saves outputs):
24
+ python test_scale_out_ec_e2e.py \
25
+ --mode baseline \
26
+ --service_url http://localhost:19603 \
27
+ --model_name Qwen/Qwen3-VL-2B-Instruct \
28
+ --baseline_file /tmp/vllm_scale_out_ec_baseline.txt
29
+
30
+ # Disagg mode (compares outputs):
31
+ python test_scale_out_ec_e2e.py \
32
+ --mode disagg \
33
+ --render_url http://localhost:19600 \
34
+ --encode_url http://localhost:19601 \
35
+ --prefill_url http://localhost:19602 \
36
+ --model_name Qwen/Qwen3-VL-2B-Instruct \
37
+ --baseline_file /tmp/vllm_scale_out_ec_baseline.txt
38
+"""
39
+
40
+import argparse
41
+import json
42
+import sys
43
+import time
44
+
45
+import requests
46
+
47
+from vllm.assets.image import ImageAsset
48
+from vllm.multimodal.utils import encode_image_url
49
+
50
+MAX_OUTPUT_LEN = 128
51
+REQUEST_TIMEOUT = 300
52
+
53
+image_1 = ImageAsset("stop_sign").pil_image.resize((1280, 720))
54
+image_2 = ImageAsset("cherry_blossom").pil_image.resize((1280, 720))
55
+image_1_url = encode_image_url(image_1)
56
+image_2_url = encode_image_url(image_2)
57
+
58
+# Each prompt is a chat completion request body; the same body is sent to the
59
+# baseline instance, to /render, and to /derender (as chat_request).
60
+SAMPLE_PROMPTS: list[dict] = [
61
+ {
62
+ "messages": [
63
+ {
64
+ "role": "user",
65
+ "content": [
66
+ {"type": "image_url", "image_url": {"url": image_1_url}},
67
+ {"type": "text", "text": "What's in this image?"},
68
+ ],
69
+ }
70
+ ],
71
+ "description": "single image",
72
+ },
73
+ {
74
+ "messages": [
75
+ {
76
+ "role": "user",
77
+ "content": [
78
+ {"type": "image_url", "image_url": {"url": image_2_url}},
79
+ {"type": "image_url", "image_url": {"url": image_1_url}},
80
+ {"type": "text", "text": "Describe these 2 images in detail."},
81
+ ],
82
+ }
83
+ ],
84
+ "description": "two images",
85
+ },
86
+ {
87
+ "messages": [{"role": "user", "content": "What is the capital of France?"}],
88
+ "description": "text only",
89
+ },
90
+]
91
+
92
+
93
+def chat_request(prompt: dict) -> dict:
94
+ return {
95
+ "model": None, # filled by the caller
96
+ "messages": prompt["messages"],
97
+ "max_tokens": MAX_OUTPUT_LEN,
98
+ "temperature": 0.0,
99
+ "seed": 42,
100
+ }
101
+
102
+
103
+def post_json(url: str, payload: dict) -> requests.Response:
104
+ response = requests.post(url, json=payload, timeout=REQUEST_TIMEOUT)
105
+ if response.status_code != 200:
106
+ raise RuntimeError(
107
+ f"POST {url} failed with {response.status_code}: {response.text}"
108
+ )
109
+ return response
110
+
111
+
112
+def run_baseline(args) -> None:
113
+ outputs = []
114
+ for prompt in SAMPLE_PROMPTS:
115
+ request = chat_request(prompt) | {"model": args.model_name}
116
+ response = post_json(f"{args.service_url}/v1/chat/completions", request).json()
117
+ outputs.append(response["choices"][0]["message"]["content"])
118
+ print(f"[baseline] {prompt['description']}: {outputs[-1][:80]!r}")
119
+
120
+ with open(args.baseline_file, "w") as f:
121
+ json.dump(outputs, f)
122
+ print(f"Baseline saved to {args.baseline_file}")
123
+
124
+
125
+def _assert_mm_features(features: dict) -> None:
126
+ kwargs_data = features.get("kwargs_data") or {}
127
+ mm_metadata = features.get("mm_metadata")
128
+ assert any(item is not None for items in kwargs_data.values() for item in items), (
129
+ f"render response has no kwargs_data items: {features}"
130
+ )
131
+ assert mm_metadata, "render response is missing mm_metadata"
132
+ for modality, hashes in features["mm_hashes"].items():
133
+ assert modality in mm_metadata, f"mm_metadata missing modality {modality}"
134
+ assert len(mm_metadata[modality]) == len(hashes)
135
+
136
+
137
+def run_disagg(args) -> None:
138
+ with open(args.baseline_file) as f:
139
+ baseline = json.load(f)
140
+
141
+ failures = []
142
+ for prompt, expected in zip(SAMPLE_PROMPTS, baseline, strict=True):
143
+ request = chat_request(prompt) | {"model": args.model_name}
144
+ got = run_disagg_one(args, request)
145
+ if got != expected:
146
+ failures.append(
147
+ f"{prompt['description']}:\n"
148
+ f" baseline: {expected!r}\n disagg: {got!r}"
149
+ )
150
+ else:
151
+ print(f"[disagg] {prompt['description']}: matched baseline")
152
+
153
+ if failures:
154
+ print("Output mismatch:")
155
+ for failure in failures:
156
+ print(failure)
157
+ sys.exit(1)
158
+ print("All scale-out EC outputs matched the baseline")
159
+
160
+
161
+def run_disagg_one(args, request: dict) -> str:
162
+ # 1. Render: chat request -> token ids + multimodal features (GPU-less).
163
+ rendered = post_json(
164
+ f"{args.render_url}/v1/chat/completions/render", request
165
+ ).json()
166
+ token_ids = rendered["token_ids"]
167
+ features = rendered.get("features")
168
+ request_id = rendered["request_id"]
169
+
170
+ # 2. Encode: full kwargs_data so the vision encoder can run. The
171
+ # response carries ec_transfer_params for the prefill instance.
172
+ ec_transfer_params = None
173
+ if features is not None:
174
+ _assert_mm_features(features)
175
+ encode_payload = {
176
+ "request_id": request_id,
177
+ "token_ids": token_ids,
178
+ "features": {
179
+ "mm_hashes": features["mm_hashes"],
180
+ "mm_placeholders": features["mm_placeholders"],
181
+ "kwargs_data": features["kwargs_data"],
182
+ },
183
+ "sampling_params": {"max_tokens": 1, "temperature": 0.0},
184
+ }
185
+ encode_response = post_json(
186
+ f"{args.encode_url}/inference/v1/generate", encode_payload
187
+ ).json()
188
+ ec_transfer_params = encode_response.get("ec_transfer_params")
189
+ assert ec_transfer_params, (
190
+ f"encode response has no ec_transfer_params: {encode_response}"
191
+ )
192
+
193
+ # 3. Prefill: metadata-only features. kwargs_data is dropped; the
194
+ # embeddings arrive through the EC connector.
195
+ if features is not None:
196
+ prefill_features = {
197
+ "mm_hashes": features["mm_hashes"],
198
+ "mm_placeholders": features["mm_placeholders"],
199
+ "mm_metadata": features["mm_metadata"],
200
+ }
201
+ _check_metadata_only_rejected_without_ec(args, token_ids, prefill_features)
202
+ else:
203
+ prefill_features = None
204
+
205
+ prefill_payload = {
206
+ "request_id": request_id,
207
+ "token_ids": token_ids,
208
+ "sampling_params": {"max_tokens": MAX_OUTPUT_LEN, "temperature": 0.0},
209
+ }
210
+ if prefill_features is not None:
211
+ prefill_payload["features"] = prefill_features
212
+ prefill_payload["ec_transfer_params"] = ec_transfer_params
213
+ prefill_response = post_json(
214
+ f"{args.prefill_url}/inference/v1/generate", prefill_payload
215
+ ).json()
216
+ output_token_ids = prefill_response["choices"][0]["token_ids"]
217
+ assert output_token_ids, f"prefill returned no token ids: {prefill_response}"
218
+
219
+ # 4. Derender: token ids -> chat completion response (GPU-less).
220
+ derendered = post_json(
221
+ f"{args.render_url}/v1/chat/completions/derender",
222
+ {
223
+ "model": args.model_name,
224
+ "generate_response": prefill_response,
225
+ "prompt_tokens": len(token_ids),
226
+ "chat_request": request,
227
+ },
228
+ ).json()
229
+ return derendered["choices"][0]["message"]["content"]
230
+
231
+
232
+def _check_metadata_only_rejected_without_ec(
233
+ args, token_ids: list[int], prefill_features: dict
234
+) -> None:
235
+ """mm_metadata without kwargs_data must be rejected without EC params."""
236
+ payload = {
237
+ "token_ids": token_ids,
238
+ "features": prefill_features,
239
+ "sampling_params": {"max_tokens": 1, "temperature": 0.0},
240
+ }
241
+ response = requests.post(
242
+ f"{args.prefill_url}/inference/v1/generate",
243
+ json=payload,
244
+ timeout=REQUEST_TIMEOUT,
245
+ )
246
+ assert response.status_code in (400, 422), (
247
+ "metadata-only features without ec_transfer_params should be rejected, "
248
+ f"got {response.status_code}: {response.text}"
249
+ )
250
+
251
+
252
+def main() -> None:
253
+ parser = argparse.ArgumentParser(description=__doc__)
254
+ parser.add_argument("--mode", choices=["baseline", "disagg"], required=True)
255
+ parser.add_argument("--service_url", help="baseline single-instance URL")
256
+ parser.add_argument("--render_url", help="GPU-less render server URL")
257
+ parser.add_argument("--encode_url", help="EC producer (encode) URL")
258
+ parser.add_argument("--prefill_url", help="EC consumer (prefill) URL")
259
+ parser.add_argument("--model_name", required=True)
260
+ parser.add_argument("--baseline_file", required=True)
261
+ args = parser.parse_args()
262
+
263
+ start = time.time()
264
+ if args.mode == "baseline":
265
+ assert args.service_url, "--service_url is required in baseline mode"
266
+ run_baseline(args)
267
+ else:
268
+ assert args.render_url, "--render_url is required in disagg mode"
269
+ assert args.encode_url, "--encode_url is required in disagg mode"
270
+ assert args.prefill_url, "--prefill_url is required in disagg mode"
271
+ run_disagg(args)
272
+ print(f"Mode {args.mode} finished in {time.time() - start:.1f}s")
273
+
274
+
275
+if __name__ == "__main__":
276
+ main()
vllm/distributed/ec_transfer/ec_connector/example_connector.py
+7
-2
@@ -17,6 +17,7 @@ from vllm.distributed.ec_transfer.ec_connector.utils import (
17
collect_ec_item_metadata,
18
)
19
from vllm.logger import init_logger
20
+from vllm.utils.gpu_sync_debug import gpu_sync_allowed
21
from vllm.v1.core.sched.output import SchedulerOutput
22
23
if TYPE_CHECKING:
@@ -99,7 +100,10 @@ class ECExampleConnector(ECConnectorBase):
100
if mm_data.mm_hash in encoder_cache:
101
continue
102
filename = self._generate_filename_debug(mm_data.mm_hash)
102
- ec_cache = safetensors.torch.load_file(filename, device=device)["ec_cache"]
103
+ with gpu_sync_allowed():
104
+ ec_cache = safetensors.torch.load_file(filename, device=device)[
105
+ "ec_cache"
106
+ ]
107
encoder_cache[mm_data.mm_hash] = ec_cache
108
logger.debug("Success load encoder cache for hash %s", mm_data.mm_hash)
109
@@ -121,7 +125,8 @@ class ECExampleConnector(ECConnectorBase):
125
return
126
filename = self._generate_filename_debug(mm_hash)
127
ec_cache = encoder_cache[mm_hash]
124
- tensors = {"ec_cache": ec_cache.detach().cpu()}
128
+ with gpu_sync_allowed():
129
+ tensors = {"ec_cache": ec_cache.detach().cpu()}
130
safetensors.torch.save_file(tensors, filename)
131
logger.debug("Save cache successful for mm_hash %s", mm_hash)
132