Skip to content

Commit f102677

Browse files
committed
Add RL + batch-inference interleaving guide
Time-slices one verl fully-async RL training job with a stock vLLM batch-inference server on the same GPU: the trainer has absolute priority (cuda-checkpoint C/R), vLLM harvests the trainer's idle valleys via its native sleep mode driven through the snapshot agent's workload channel. The RL job manifest uses the timeslice-verl package (pkg/integrations/verl: a FullyAsyncTrainer subclass registered as trainer name "timeslice" via verl's fully-async lifecycle hooks + trainer registry, selected with async_training.trainer_name=timeslice; ray_pg_extra_resources PG pinning). The vLLM supervisor and load generator live under examples/ and are example-only: the supervisor demonstrates the polite-tenant pattern (waiter poll, readiness-gate drain before /sleep - required, vllm#28714 - workload-channel registration); production batch serving should sit behind a queue-based front-end with retries. Requires a snapshot-agent build with workload-channel default routing (config-less Snapshot/Restore resolves the job's registered workload channel) - see values-timeslice.yaml note.
1 parent d1b37de commit f102677

7 files changed

Lines changed: 2745 additions & 0 deletions

guides/rl-batch-interleaving/README.md

Lines changed: 313 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Continuous batch-inference client: hammers the shadow vLLM Service and
2+
# reports throughput every 5 s. Errors while the trainer holds the GPU are
3+
# EXPECTED (the server is asleep) — production clients should queue/retry;
4+
# this demo client just counts them.
5+
apiVersion: v1
6+
kind: Pod
7+
metadata:
8+
name: batch-load-generator
9+
namespace: default
10+
labels:
11+
app: batch-load-generator
12+
spec:
13+
restartPolicy: Always
14+
containers:
15+
- name: loadgen
16+
image: python:3.11-slim
17+
command: ["/bin/bash", "-c"]
18+
args:
19+
- |
20+
pip install --quiet requests
21+
python3 - <<'PY'
22+
import datetime, time, requests
23+
24+
URL = "http://shadow-vllm.default.svc.cluster.local:8000/v1/completions"
25+
PROMPT = "Explain, in three sentences, why sharing a GPU between RL training and batch inference raises utilization."
26+
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
27+
28+
completed = errors = 0
29+
window = time.time()
30+
print("batch load generator started", flush=True)
31+
while True:
32+
try:
33+
r = requests.post(URL, json={"model": MODEL, "prompt": PROMPT,
34+
"max_tokens": 48}, timeout=10)
35+
if r.status_code == 200:
36+
completed += 1
37+
else:
38+
errors += 1
39+
except Exception:
40+
errors += 1
41+
time.sleep(0.5)
42+
if time.time() - window >= 5.0:
43+
ts = datetime.datetime.now().strftime("%H:%M:%S")
44+
print(f"[{ts}] last 5s: completed={completed} errors_or_asleep={errors}",
45+
flush=True)
46+
completed = errors = 0
47+
window = time.time()
48+
PY
49+
resources:
50+
requests:
51+
cpu: "1"
52+
memory: 1Gi
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
# EXAMPLE ONLY — demo-quality reference, not a supported platform component.
2+
# The embedded supervisor shows how a batch-inference server becomes a polite
3+
# time-slicing tenant; a production deployment should use a queue-based batch
4+
# front-end with retries (see README §8) and treat this file as a template.
5+
#
6+
# Shadow vLLM: a STOCK vLLM OpenAI-compatible server that harvests the RL
7+
# trainer's idle time on the shared GPU, plus a ~100-line supervisor that
8+
# makes it a polite time-slicing tenant.
9+
#
10+
# How it yields: vLLM runs with --enable-sleep-mode. The supervisor registers
11+
# the workload with the node's snapshot agent over the WORKLOAD CHANNEL
12+
# (timeslice.snapshot_agent.register_workload) with callbacks that POST to
13+
# vLLM's own /sleep and /wake_up endpoints. When the orchestrator hands the
14+
# GPU to the trainer it drives those callbacks through the agent — weights and
15+
# KV cache move to host RAM in ~1-2 s, and back in ~100 ms. The vLLM process
16+
# is never killed.
17+
#
18+
# Priority is structural: the supervisor holds the group lock ONLY while no
19+
# one is waiting (it polls waiter_queue_depth every 500 ms; when the trainer
20+
# queues it DRAINS — closes the readiness gate so the pod leaves the Service
21+
# endpoints and waits for in-flight requests to finish, ~3 s — then
22+
# releases), and re-requests the lock afterwards. The trainer therefore
23+
# never waits more than ~0.5 s poll + ~3 s drain + one vLLM sleep (~1-2 s).
24+
# The drain is REQUIRED: vLLM's /sleep does not drain the scheduler
25+
# (vllm#28714) and sleeping with a request mid-decode kills the server.
26+
#
27+
# Runs on ${TRAINER_NODE} — the 1-GPU node it shares with the RL head pod
28+
# (rl-head). The node has a single GPU, so no device selection is needed.
29+
#
30+
# Render with: envsubst '${TRAINER_NODE}'
31+
apiVersion: v1
32+
kind: ConfigMap
33+
metadata:
34+
name: shadow-vllm-scripts
35+
namespace: default
36+
data:
37+
supervisor.py: |
38+
#!/usr/bin/env python3
39+
"""Queue-depth-preemption supervisor for a stock vLLM server."""
40+
import os
41+
import subprocess
42+
import sys
43+
import time
44+
import urllib.request
45+
46+
JOB_ID = os.environ.get("TIMESLICE_JOB_ID", "shadow-vllm")
47+
GROUP = os.environ.get("TIMESLICE_GROUP", "trainers")
48+
ORCH = os.environ["TIMESLICE_ORCH_ADDR"]
49+
AGENT = os.environ["TIMESLICE_AGENT_ADDR"] # node-local, <hostIP>:9001
50+
MODEL = os.environ.get("VLLM_MODEL", "Qwen/Qwen2.5-0.5B-Instruct")
51+
PORT = int(os.environ.get("VLLM_PORT", "8000"))
52+
GPU_FRAC = os.environ.get("VLLM_GPU_FRAC", "0.7")
53+
POLL_S = float(os.environ.get("WAITER_POLL_SECONDS", "0.5"))
54+
# Readiness gate file (see the pod's readinessProbe): while it exists the
55+
# pod is a Service endpoint; removing it makes new batch requests fail
56+
# fast at the Service (connection refused) within ~1-2 s.
57+
GATE = "/tmp/serving"
58+
59+
try:
60+
from timeslice import OrchestratorClient
61+
except ImportError: # client class was renamed upstream; API identical
62+
from timeslice import TimeSliceOrchestratorClient as OrchestratorClient
63+
from timeslice.snapshot_agent import register_workload
64+
65+
def log(msg):
66+
print(f"[supervisor] {msg}", flush=True)
67+
68+
def http(method, path, timeout):
69+
req = urllib.request.Request(
70+
f"http://127.0.0.1:{PORT}{path}", data=b"" if method == "POST" else None,
71+
method=method)
72+
return urllib.request.urlopen(req, timeout=timeout)
73+
74+
def gate_open():
75+
open(GATE, "w").close()
76+
77+
def gate_close():
78+
try:
79+
os.remove(GATE)
80+
except FileNotFoundError:
81+
pass
82+
83+
def inflight():
84+
"""running + waiting requests, from /metrics (None if unreadable)."""
85+
try:
86+
with http("GET", "/metrics", timeout=2) as r:
87+
text = r.read().decode()
88+
n = 0.0
89+
for line in text.splitlines():
90+
if line.startswith(("vllm:num_requests_running",
91+
"vllm:num_requests_waiting")):
92+
n += float(line.rsplit(" ", 1)[1])
93+
return n
94+
except Exception:
95+
return None
96+
97+
def drain(settle_s=2.5, max_wait_s=12.0):
98+
"""Stop new batch traffic and wait for in-flight requests to finish.
99+
REQUIRED before sleeping: vLLM's /sleep does NOT drain the scheduler
100+
(vllm-project/vllm#28714, unfixed as of v0.10.x) — freeing weights
101+
with a request mid-decode is a fatal CUDA error that kills the
102+
server. Gate first (readinessProbe pulls the pod out of the Service
103+
in ~1-2 s; new requests then fail fast), then wait for running+
104+
waiting == 0."""
105+
t0 = time.time()
106+
gate_close()
107+
zeros = 0
108+
while time.time() - t0 < max_wait_s:
109+
n = inflight()
110+
if n == 0 and time.time() - t0 >= settle_s:
111+
zeros += 1
112+
if zeros >= 2:
113+
log(f"drained in {time.time()-t0:.2f}s")
114+
return
115+
else:
116+
zeros = 0
117+
time.sleep(0.25)
118+
log(f"drain timed out after {max_wait_s}s (in_flight={inflight()}); sleeping anyway")
119+
120+
def vllm_sleep(mode=None, tags=None):
121+
t0 = time.time()
122+
http("POST", "/sleep?level=1", timeout=300)
123+
log(f"vLLM slept (HBM -> host RAM) in {time.time()-t0:.2f}s")
124+
125+
def vllm_wake(tags=None):
126+
t0 = time.time()
127+
http("POST", "/wake_up", timeout=300)
128+
gate_open() # resume batch traffic only after weights are back
129+
log(f"vLLM woke in {time.time()-t0:.2f}s")
130+
131+
def vllm_is_sleeping():
132+
try:
133+
import json
134+
with http("GET", "/is_sleeping", timeout=5) as r:
135+
return bool(json.load(r).get("is_sleeping"))
136+
except Exception:
137+
return None # endpoint unavailable — assume unknown
138+
139+
def launch_vllm():
140+
env = dict(os.environ)
141+
env["VLLM_SERVER_DEV_MODE"] = "1" # exposes /sleep, /wake_up, /is_sleeping
142+
proc = subprocess.Popen(
143+
["vllm", "serve", MODEL,
144+
"--port", str(PORT),
145+
"--enable-sleep-mode",
146+
"--gpu-memory-utilization", GPU_FRAC,
147+
"--max-model-len", "4096"],
148+
env=env)
149+
deadline = time.time() + 900
150+
while time.time() < deadline:
151+
if proc.poll() is not None:
152+
raise RuntimeError(f"vLLM exited rc={proc.returncode} during startup")
153+
try:
154+
http("GET", "/health", timeout=2)
155+
log("vLLM is up")
156+
return proc
157+
except Exception:
158+
time.sleep(2)
159+
raise RuntimeError("vLLM did not become healthy in 900s")
160+
161+
def waiter_depth(client):
162+
st = client.get_status(group_id=GROUP)
163+
g = getattr(st, "group", st)
164+
return int(getattr(g, "waiter_queue_depth", 0))
165+
166+
def main():
167+
client = OrchestratorClient(target=ORCH, job_id=JOB_ID, group_id=GROUP)
168+
169+
# 1) Take the lock BEFORE creating any GPU context (cold start).
170+
log("requesting lock for cold start...")
171+
res = client.acquire()
172+
log(f"lock acquired (waited {getattr(res, 'waited_ms', '?')} ms); launching vLLM")
173+
proc = launch_vllm()
174+
gate_open() # vLLM healthy -> become a Service endpoint
175+
176+
# 2) Register with the node's snapshot agent: the platform drives our
177+
# sleep/wake through these callbacks at every lock handoff.
178+
handle = register_workload(
179+
AGENT, job_id=JOB_ID, group=GROUP,
180+
on_snapshot=vllm_sleep, on_restore=vllm_wake,
181+
supported_modes=["offload"], default_mode="offload")
182+
log(f"workload registered with agent {AGENT}")
183+
184+
try:
185+
while True:
186+
# -- serving phase: hold the lock only while nobody waits --
187+
while True:
188+
if proc.poll() is not None:
189+
log(f"vLLM died rc={proc.returncode}; releasing lock and exiting")
190+
client.release()
191+
sys.exit(1)
192+
try:
193+
if waiter_depth(client) > 0:
194+
log("trainer is waiting - yielding GPU")
195+
break
196+
except Exception as e:
197+
log(f"status poll error (transient): {e}")
198+
time.sleep(POLL_S)
199+
200+
drain() # stop + flush batch traffic (sleep is NOT drain-safe)
201+
client.release() # platform snapshots us via the workload channel
202+
time.sleep(1.0)
203+
204+
# -- reacquire: blocks until the trainer finishes its burst --
205+
res = client.acquire()
206+
log(f"lock reacquired (waited {getattr(res, 'waited_ms', '?')} ms, "
207+
f"context_restored={getattr(res, 'context_restored', '?')})")
208+
# Belt and suspenders: if the platform didn't wake us, do it locally.
209+
if vllm_is_sleeping():
210+
log("still sleeping after acquire - waking locally")
211+
vllm_wake()
212+
gate_open() # idempotent; re-admit batch traffic
213+
finally:
214+
handle.close()
215+
proc.terminate()
216+
217+
if __name__ == "__main__":
218+
main()
219+
---
220+
apiVersion: v1
221+
kind: Pod
222+
metadata:
223+
name: shadow-vllm
224+
namespace: default
225+
labels:
226+
app: shadow-vllm
227+
# Exact keys the platform selects on:
228+
timeslice.io/job-id: shadow-vllm
229+
timeslice.io/group: trainers
230+
spec:
231+
restartPolicy: Always
232+
nodeName: ${TRAINER_NODE}
233+
tolerations:
234+
- key: nvidia.com/gpu
235+
operator: Exists
236+
effect: NoSchedule
237+
containers:
238+
- name: vllm
239+
image: vllm/vllm-openai:v0.9.2
240+
command: ["/bin/bash", "-c"]
241+
args:
242+
- |
243+
set -euo pipefail
244+
# GPU access via hostPath driver mounts (no device plugin resource):
245+
export PATH="/usr/local/nvidia/bin:$PATH"
246+
export LD_LIBRARY_PATH="/usr/local/nvidia/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
247+
ldconfig /usr/local/nvidia/lib64 2>/dev/null || true
248+
# 1-GPU node: the shared GPU is the only one visible — no device mask.
249+
nvidia-smi -L
250+
pip install --quiet "git+https://github.com/llm-d-incubation/llm-d-rl-time-slicing.git#subdirectory=pkg/client/python" \
251+
"grpcio>=1.81.0" "protobuf>=7.35.0"
252+
exec python3 /scripts/supervisor.py
253+
env:
254+
- name: TIMESLICE_JOB_ID
255+
value: "shadow-vllm" # must equal the timeslice.io/job-id label
256+
- name: TIMESLICE_GROUP
257+
value: "trainers"
258+
- name: TIMESLICE_ORCH_ADDR
259+
value: "timeslice-timesliceorchestrator.timeslice-system.svc:50051"
260+
- name: NODE_IP
261+
valueFrom:
262+
fieldRef:
263+
fieldPath: status.hostIP
264+
- name: TIMESLICE_AGENT_ADDR
265+
value: "$(NODE_IP):9001" # snapshot agent is hostNetwork on :9001
266+
- name: VLLM_MODEL
267+
value: "Qwen/Qwen2.5-0.5B-Instruct"
268+
- name: VLLM_PORT
269+
value: "8000"
270+
- name: VLLM_GPU_FRAC
271+
value: "0.7"
272+
securityContext:
273+
privileged: true
274+
# Readiness gate: the supervisor removes /tmp/serving before yielding the
275+
# GPU (drain) and recreates it after wake. NotReady -> the pod leaves the
276+
# Service endpoints, so batch requests fail fast (connection refused)
277+
# instead of reaching a sleeping engine — required because vLLM's /sleep
278+
# does not drain in-flight requests (vllm#28714; a request mid-decode at
279+
# sleep time is a fatal CUDA error).
280+
readinessProbe:
281+
exec:
282+
command: ["cat", "/tmp/serving"]
283+
periodSeconds: 1
284+
failureThreshold: 1
285+
ports:
286+
- containerPort: 8000
287+
resources:
288+
requests:
289+
cpu: "6"
290+
memory: "60Gi" # sleep mode offloads weights+KV here; sized to share the node with rl-head
291+
volumeMounts:
292+
- name: scripts
293+
mountPath: /scripts
294+
- name: nvidia-driver
295+
mountPath: /usr/local/nvidia
296+
readOnly: true
297+
- name: nvidia-devices
298+
mountPath: /dev
299+
- name: dshm
300+
mountPath: /dev/shm
301+
volumes:
302+
- name: scripts
303+
configMap:
304+
name: shadow-vllm-scripts
305+
defaultMode: 0755
306+
- name: nvidia-driver
307+
hostPath:
308+
path: /home/kubernetes/bin/nvidia
309+
type: Directory
310+
- name: nvidia-devices
311+
hostPath:
312+
path: /dev
313+
type: Directory
314+
- name: dshm
315+
emptyDir:
316+
medium: Memory
317+
sizeLimit: 8Gi
318+
---
319+
apiVersion: v1
320+
kind: Service
321+
metadata:
322+
name: shadow-vllm
323+
namespace: default
324+
spec:
325+
selector:
326+
app: shadow-vllm
327+
ports:
328+
- port: 8000
329+
targetPort: 8000

0 commit comments

Comments
 (0)