Skip to content

Commit e9e3b8e

Browse files
aoshen02claude
andcommitted
[CI][RL] Add conftest.py with shared server harness and HTTP helpers
Extract duplicated _server(), _gen(), _ok(), sleep/wake/pause/resume helpers, gpu_free_bytes(), sleep_metrics(), and weight-transfer helpers into a single conftest.py shared by all RL lifecycle test modules. Also adds: - dummy_weights=True mode (--load-format dummy) for fast T0 state-machine tests - poll_until() helper as a 200-lie workaround for tests that need to verify state after an operation where the HTTP 200 may precede completion - gen_with_logprobs() for precision tests RFC: vllm-project#45585 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 0f8d1c5 commit e9e3b8e

1 file changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
"""
4+
Shared fixtures and helpers for the RL lifecycle test suite.
5+
6+
All test modules under this directory import from here to avoid duplication.
7+
8+
RFC: https://github.com/vllm-project/vllm/issues/45585
9+
PR: https://github.com/vllm-project/vllm/pull/45586
10+
"""
11+
12+
import contextlib
13+
import os
14+
import subprocess
15+
import sys
16+
import time
17+
from contextlib import contextmanager
18+
from typing import Callable
19+
20+
import requests
21+
22+
# ---------------------------------------------------------------------------
23+
# Model / server defaults
24+
# ---------------------------------------------------------------------------
25+
26+
MODEL_NAME = os.environ.get("VLLM_TEST_MODEL", "meta-llama/Llama-3.2-1B-Instruct")
27+
28+
_BASE_ARGS = [
29+
"--dtype",
30+
"bfloat16",
31+
"--max-model-len",
32+
"2048",
33+
"--max-num-seqs",
34+
"32",
35+
"--gpu-memory-utilization",
36+
"0.75",
37+
"--enable-sleep-mode",
38+
"--enforce-eager",
39+
]
40+
41+
# Lightweight args for state-machine / protocol tests that don't need real
42+
# weights (avoids spending time downloading a 1B checkpoint in T0 tests).
43+
_DUMMY_ARGS = [
44+
"--dtype",
45+
"bfloat16",
46+
"--max-model-len",
47+
"128",
48+
"--max-num-seqs",
49+
"8",
50+
"--gpu-memory-utilization",
51+
"0.5",
52+
"--enable-sleep-mode",
53+
"--enforce-eager",
54+
"--load-format",
55+
"dummy",
56+
]
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Server harness
61+
# ---------------------------------------------------------------------------
62+
63+
64+
@contextmanager
65+
def server(
66+
extra_args=None,
67+
port: int = 8770,
68+
timeout: float = 180.0,
69+
dummy_weights: bool = False,
70+
):
71+
"""Launch a vLLM server with the dev router; yield its base URL.
72+
73+
Args:
74+
extra_args: Additional CLI flags appended after the base args.
75+
port: HTTP port to bind (caller is responsible for uniqueness).
76+
timeout: Seconds to wait for /health before giving up.
77+
dummy_weights: If True, use --load-format dummy (fast, no real weights).
78+
"""
79+
env = {**os.environ, "VLLM_SERVER_DEV_MODE": "1"}
80+
base = _DUMMY_ARGS if dummy_weights else _BASE_ARGS
81+
cmd = [
82+
sys.executable,
83+
"-m",
84+
"vllm.entrypoints.openai.api_server",
85+
"--model",
86+
MODEL_NAME,
87+
"--port",
88+
str(port),
89+
"--served-model-name",
90+
"m",
91+
*(base + (extra_args or [])),
92+
]
93+
proc = subprocess.Popen(
94+
cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
95+
)
96+
url = f"http://localhost:{port}"
97+
try:
98+
deadline = time.time() + timeout
99+
while time.time() < deadline:
100+
if proc.poll() is not None:
101+
err = (
102+
proc.stderr.read(4000).decode(errors="replace")
103+
if proc.stderr
104+
else ""
105+
)
106+
raise RuntimeError(f"vllm server exited during startup:\n{err}")
107+
with contextlib.suppress(Exception):
108+
if requests.get(f"{url}/health", timeout=3).status_code == 200:
109+
break
110+
time.sleep(1)
111+
else:
112+
proc.terminate()
113+
raise RuntimeError("vllm server did not start in time")
114+
yield url
115+
finally:
116+
proc.terminate()
117+
with contextlib.suppress(subprocess.TimeoutExpired):
118+
proc.wait(timeout=10)
119+
if proc.poll() is None:
120+
proc.kill()
121+
122+
123+
# ---------------------------------------------------------------------------
124+
# Polling helper (200-lie workaround)
125+
# ---------------------------------------------------------------------------
126+
127+
128+
def poll_until(
129+
predicate: Callable[[], bool],
130+
timeout: float = 10.0,
131+
interval: float = 0.5,
132+
) -> bool:
133+
"""Poll predicate() until it returns True or timeout expires.
134+
135+
Workaround for the vLLM sleep/wake "200-lie" — the HTTP endpoints may
136+
return 200 before the underlying operation is complete, so callers that
137+
need to verify state *after* an operation can use this helper instead of
138+
assuming the 200 means completion.
139+
140+
Returns True if predicate became true within timeout, False otherwise.
141+
"""
142+
deadline = time.time() + timeout
143+
while time.time() < deadline:
144+
try:
145+
if predicate():
146+
return True
147+
except Exception:
148+
pass
149+
time.sleep(interval)
150+
return False
151+
152+
153+
# ---------------------------------------------------------------------------
154+
# HTTP helpers — generation
155+
# ---------------------------------------------------------------------------
156+
157+
158+
def gen(url, prompt="The capital of France is", max_tokens=8, timeout=30):
159+
"""Fire a /v1/completions request; return JSON or None on any error."""
160+
try:
161+
r = requests.post(
162+
f"{url}/v1/completions",
163+
json={
164+
"model": "m",
165+
"prompt": prompt,
166+
"max_tokens": max_tokens,
167+
"temperature": 0,
168+
},
169+
timeout=timeout,
170+
)
171+
return r.json()
172+
except Exception:
173+
return None
174+
175+
176+
def gen_with_logprobs(url, prompt="The capital of France is", max_tokens=8,
177+
logprobs=5, timeout=30):
178+
"""Fire a /v1/completions request with logprobs; return JSON or None."""
179+
try:
180+
r = requests.post(
181+
f"{url}/v1/completions",
182+
json={
183+
"model": "m",
184+
"prompt": prompt,
185+
"max_tokens": max_tokens,
186+
"temperature": 0,
187+
"logprobs": logprobs,
188+
},
189+
timeout=timeout,
190+
)
191+
return r.json()
192+
except Exception:
193+
return None
194+
195+
196+
def ok(resp) -> bool:
197+
"""True iff resp is a successful completion (has choices, no error key)."""
198+
return (
199+
resp is not None
200+
and "choices" in resp
201+
and bool(resp["choices"])
202+
and "error" not in resp
203+
)
204+
205+
206+
# ---------------------------------------------------------------------------
207+
# HTTP helpers — sleep / wake / pause / resume
208+
# ---------------------------------------------------------------------------
209+
210+
211+
def sleep(url, level=1, mode="abort"):
212+
return requests.post(
213+
f"{url}/sleep", params={"level": level, "mode": mode}, timeout=15
214+
).status_code
215+
216+
217+
def wake(url, tags=None):
218+
params = {"tags": tags} if tags else {}
219+
return requests.post(f"{url}/wake_up", params=params, timeout=20).status_code
220+
221+
222+
def pause(url, mode="abort", clear_cache=True):
223+
return requests.post(
224+
f"{url}/pause",
225+
params={"mode": mode, "clear_cache": clear_cache},
226+
timeout=15,
227+
).status_code
228+
229+
230+
def resume(url):
231+
return requests.post(f"{url}/resume", timeout=10).status_code
232+
233+
234+
def is_sleeping(url) -> bool:
235+
return requests.get(f"{url}/is_sleeping", timeout=5).json()["is_sleeping"]
236+
237+
238+
def is_paused(url) -> bool:
239+
return requests.get(f"{url}/is_paused", timeout=5).json()["is_paused"]
240+
241+
242+
def health(url) -> int:
243+
try:
244+
return requests.get(f"{url}/health", timeout=5).status_code
245+
except Exception:
246+
return 0
247+
248+
249+
# ---------------------------------------------------------------------------
250+
# HTTP helpers — weight transfer
251+
# ---------------------------------------------------------------------------
252+
253+
254+
def start_weight_update(url, is_checkpoint_format=True):
255+
return requests.post(
256+
f"{url}/start_weight_update",
257+
json={"is_checkpoint_format": is_checkpoint_format},
258+
timeout=10,
259+
)
260+
261+
262+
def finish_weight_update(url):
263+
return requests.post(f"{url}/finish_weight_update", timeout=10)
264+
265+
266+
def get_world_size(url, include_dp=True):
267+
return requests.get(
268+
f"{url}/get_world_size",
269+
params={"include_dp": include_dp},
270+
timeout=5,
271+
)
272+
273+
274+
# ---------------------------------------------------------------------------
275+
# GPU / metrics helpers
276+
# ---------------------------------------------------------------------------
277+
278+
279+
def gpu_free_bytes(device: int = 0) -> int:
280+
"""Read GPU free bytes via subprocess to avoid import-time torch init."""
281+
out = subprocess.check_output(
282+
[
283+
sys.executable,
284+
"-c",
285+
f"import torch; f,_=torch.cuda.mem_get_info({device}); print(f)",
286+
],
287+
timeout=10,
288+
)
289+
return int(out.strip())
290+
291+
292+
def sleep_metrics(url):
293+
"""Return (awake, weights_offloaded, discard_all) from /metrics."""
294+
try:
295+
from prometheus_client.parser import text_string_to_metric_families
296+
except ImportError:
297+
return None, None, None
298+
299+
r = requests.get(f"{url}/metrics", timeout=5)
300+
vals: dict = {}
301+
for family in text_string_to_metric_families(r.text):
302+
if family.name == "vllm:engine_sleep_state":
303+
for s in family.samples:
304+
vals[s.labels.get("sleep_state", "")] = s.value
305+
return vals.get("awake"), vals.get("weights_offloaded"), vals.get("discard_all")

0 commit comments

Comments
 (0)