Skip to content

Commit 90699a5

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 5399c6b commit 90699a5

5 files changed

Lines changed: 1684 additions & 0 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
# Interleaving RL Training with Batch Inference on a Shared GPU
2+
3+
This guide time-slices **one verl fully-async RL training job** with a
4+
**stock vLLM batch-inference server** on the same GPU. The RL trainer has
5+
absolute priority; vLLM harvests the trainer's idle valleys (sample waits
6+
between training bursts — 40–70% of wall-clock for async RL workloads) to
7+
serve latency-tolerant batch traffic. Neither workload's code is modified:
8+
verl is integrated through its fully-async lifecycle hooks: the
9+
`timeslice-verl` package from this repo ships a FullyAsyncTrainer subclass
10+
registered under the trainer name `timeslice`
11+
(`async_training.trainer_name=timeslice`), and vLLM runs its normal
12+
OpenAI-compatible server wrapped by a ~100-line supervisor (shipped in
13+
`examples/shadow-vllm.yaml` — demo-quality, example-only).
14+
15+
What makes the batch side cheap to yield: vLLM's native **sleep mode** moves
16+
weights + KV cache to host RAM in ~1–2 s and back in ~100 ms — no process
17+
restart, no model reload. The RL trainer side uses `cuda-checkpoint`
18+
(~10–35 s per swap for a 1.5 B trainer), which is amortized against training
19+
bursts that run for minutes.
20+
21+
**Topology** (two 1-GPU nodes; H100-class):
22+
23+
```
24+
ROLLOUT NODE (dedicated) TRAINER NODE (SHARED, time-sliced)
25+
┌──────────────────────────┐ ┌──────────────────────────────────┐
26+
│ rl-rollout │ ray │ rl-head shadow-vllm │
27+
│ RL rollout engine (vLLM, │◀───────▶│ RL trainer ⇄ vLLM server │
28+
│ generates continuously) │ │ cuda-checkpoint sleep mode │
29+
└──────────────────────────┘ │ C/R (~1-2 s swaps)│
30+
└──────────────────────────────────┘
31+
lock owner over time on the trainer node's GPU:
32+
vLLM ████████░T██████████░T█████████░T████████ (T = training burst)
33+
▲ trainer queues → vLLM yields in ≤ poll(0.5s) + drain(~3s) + sleep(~1-2s)
34+
```
35+
36+
The RL job is a **2-node Ray cluster**: head pod `rl-head` (verl driver +
37+
FSDP trainer) on the shared trainer node, rollout pod `rl-rollout` (vLLM
38+
rollout engine) on a dedicated node. Placement is pinned with Ray custom
39+
resources (`trainer_node` / `rollout_node`) through verl's per-pool
40+
placement-group bundle resources (hydra override `ray_pg_extra_resources`,
41+
verified at startup by `TIMESLICE_REQUIRE_PG_PINNING=1`), and a per-pod role
42+
watchdog kills the run loudly if a process of the wrong role ever touches a
43+
node's GPU.
44+
45+
## 1. Components
46+
47+
| Piece | What it is | Where it runs |
48+
|---|---|---|
49+
| TimeSlice Orchestrator | gRPC group-lock service | `timeslice-system` ns |
50+
| Snapshot Agent | DaemonSet; executes cuda-checkpoint C/R AND relays sleep/wake to registered apps (workload channel) | every GPU node |
51+
| RL job (`examples/rl-job.yaml`) | verl `fully_async_policy` code-RLVR training (Eurus-2 code split, rewards = live test execution), integrated via verl lifecycle hooks (trainer subclass registered as `timeslice`); head pod + rollout pod + headless Service | `default` ns |
52+
| Shadow vLLM (`examples/shadow-vllm.yaml`) | EXAMPLE-ONLY: stock `vllm serve` + supervisor: holds the lock only while nobody waits; registers sleep/wake callbacks with the agent | `default` ns, trainer node |
53+
| Load generator (`examples/load-generator.yaml`) | EXAMPLE-ONLY: continuous `/v1/completions` client + throughput log | `default` ns |
54+
55+
How a handoff works, end to end:
56+
57+
1. Trainer's sample batch becomes ready → the timeslice trainer's hook calls
58+
`acquire()`.
59+
2. Supervisor's 0.5 s poll sees `waiter_queue_depth > 0` → it **drains**
60+
(closes its readiness gate, so the pod leaves the Service endpoints and
61+
new batch requests fail fast; waits ~3 s for in-flight requests to
62+
finish — vLLM's `/sleep` does NOT drain the scheduler, and sleeping with
63+
a request mid-decode is a fatal CUDA error, vllm#28714) → calls
64+
`release()`.
65+
3. Orchestrator snapshots the vLLM job — the agent's per-job backend
66+
resolution (explicit config → live workload channel → pod annotation →
67+
default cuda) lands on its **registered workload channel** → the agent
68+
invokes the supervisor's callback → `POST /sleep` → HBM freed in ~1–2 s.
69+
4. Orchestrator restores the trainer (cuda-checkpoint, trainer node only) and
70+
grants the lock. Trainer runs its burst (minutes), then yields; the
71+
platform snapshots it and wakes vLLM the same way in reverse; the
72+
supervisor reopens its readiness gate after the wake.
73+
5. Batch requests sent while vLLM is draining or asleep fail fast at the
74+
Service (connection refused — the pod is NotReady); the demo client
75+
counts them (`errors_or_asleep`) and carries on. Production clients
76+
should queue and retry — see §8.
77+
78+
## 2. Prerequisites
79+
80+
- Kubernetes cluster (validated on GKE/COS) with **two free 1-GPU nodes**
81+
(H100-class recommended; reference shape GKE `a3-highgpu-1g`, 26 vCPU /
82+
234 Gi each): one shared trainer node (RL head pod + shadow vLLM pod) and
83+
one dedicated RL rollout node.
84+
- Cluster-admin; `kubectl`, `helm` v3, `git`, `envsubst` on your workstation.
85+
- Pods can reach Docker Hub, GitHub, HuggingFace. No HF token needed (models
86+
are fetched by HF id per node).
87+
- Host RAM on the trainer node: the RL head pod requests 80 Gi (110 Gi
88+
limit; trainer checkpoint ≈ full GPU allocation) + 60 Gi for the shadow
89+
vLLM's sleep offload.
90+
91+
**Version pins** (keep on first run): verl branch
92+
[`feat/fully-async-lifecycle-hooks`](https://github.com/aishukamal/verl/tree/feat/fully-async-lifecycle-hooks)
93+
of `github.com/aishukamal/verl` — upstream
94+
`983cb0f24443f87b3d161fad318445130a620b07` plus two feature commits
95+
(fully-async lifecycle hooks + trainer registry; per-pool PG bundle
96+
resources); temporary fork until the commits land upstream. Job image
97+
`verlai/verl:vllm020.dev2`; shadow vLLM image `vllm/vllm-openai:v0.9.2`;
98+
platform images: official `ghcr.io/llm-d-incubation/llm-d-rl-time-slicing/*`
99+
containing the IDLE cold-start fix AND per-job backend resolution in the
100+
agent (config-less requests resolve: explicit config → live workload
101+
channel → `timeslice.io/backend` pod annotation → default cuda; the
102+
workload-channel step is required by this guide — see
103+
`examples/values-timeslice.yaml`);
104+
integration package `timeslice-verl` from this repo,
105+
`pkg/integrations/verl/` (ConfigMap install, see §5); trainer
106+
model `deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B`; batch model
107+
`Qwen/Qwen2.5-0.5B-Instruct`.
108+
109+
## 3. Step 1 — Install the platform
110+
111+
```bash
112+
git clone https://github.com/llm-d-incubation/llm-d-rl-time-slicing.git
113+
cd llm-d-rl-time-slicing
114+
helm dependency update ./deploy
115+
# If a previous `timeslice` release exists, `helm uninstall` it first —
116+
# helm install fails on an existing release name, and stale in-memory
117+
# orchestrator lock state confuses fresh runs. NOTE: the chart owns the
118+
# timeslice-system namespace, so uninstall deletes it; wait for the
119+
# namespace to finish terminating (~30-60 s) or the reinstall fails with
120+
# "namespace is being terminated".
121+
helm install timeslice ./deploy -n timeslice-system --create-namespace \
122+
-f ./deploy/values-gke.yaml \
123+
-f /path/to/this/guide/examples/values-timeslice.yaml
124+
125+
kubectl -n timeslice-system get pods -o wide # orchestrator + agents Running
126+
```
127+
128+
No snapshot-device filter is needed in this topology: every node has one GPU,
129+
and only the labeled pods on the trainer node (rl-head, shadow-vllm) are
130+
snapshot targets — the rollout pod is unlabeled on another node.
131+
132+
## 4. Step 2 — Pick the nodes and label the trainer node
133+
134+
```bash
135+
export TRAINER_NODE=<the shared 1-GPU node (RL trainer + shadow vLLM)>
136+
export ROLLOUT_NODE=<the RL rollout's dedicated 1-GPU node>
137+
138+
kubectl label node "$TRAINER_NODE" group.timeslice.io/trainers=true --overwrite
139+
```
140+
141+
Only the trainer node gets the label (the rollout node must NOT carry it).
142+
Verify an agent runs on the trainer node and the orchestrator synced the
143+
group:
144+
145+
```bash
146+
AGENT_POD=$(kubectl -n timeslice-system get pods -l app.kubernetes.io/name=snapshot-agent \
147+
--field-selector spec.nodeName=$TRAINER_NODE -o jsonpath='{.items[0].metadata.name}')
148+
echo "$AGENT_POD"
149+
kubectl -n timeslice-system logs deploy/timeslice-timesliceorchestrator --tail=50 | grep -i trainers | tail -3
150+
```
151+
152+
## 5. Step 3 — Launch (order matters)
153+
154+
First, publish the `timeslice-verl` integration package into the cluster as
155+
ConfigMaps (the RL job pods install it from these — no GitHub fetch):
156+
157+
```bash
158+
# Package source: the repo you cloned in §3
159+
PKG=llm-d-rl-time-slicing/pkg/integrations/verl
160+
kubectl create configmap timeslice-verl-root --from-file=$PKG/pyproject.toml
161+
kubectl create configmap timeslice-verl-src --from-file=$PKG/timeslice_verl/
162+
```
163+
164+
ConfigMap keys cannot contain `/`, so the package tree is split into two maps
165+
(project root + module sources) and reassembled inside the pod; this also
166+
works in air-gapped clusters.
167+
168+
**verl source**: the RL pods clone the `feat/fully-async-lifecycle-hooks`
169+
branch of `github.com/aishukamal/verl` and install it with
170+
`pip install -e ".[gpu]"`. Once the commits land upstream, point the
171+
`VERL_REPO`/`VERL_REF` pod envs at mainline verl instead.
172+
173+
Then start the **shadow vLLM first** so it owns the GPU during the trainer's
174+
long CPU-side setup, then the RL job (both pods at once — the rollout pod's
175+
installs run in parallel with the head's), then the load:
176+
177+
```bash
178+
export RUN_SECONDS=5400 # RL training budget (~90 min)
179+
180+
envsubst '${TRAINER_NODE}' < examples/shadow-vllm.yaml | kubectl apply -f -
181+
# wait for "[supervisor] vLLM is up" (first model download ~2-4 min):
182+
kubectl logs shadow-vllm --tail=20
183+
184+
envsubst '${TRAINER_NODE} ${ROLLOUT_NODE} ${RUN_SECONDS}' < examples/rl-job.yaml | kubectl apply -f -
185+
kubectl apply -f examples/load-generator.yaml
186+
```
187+
188+
The RL job spends 20–45 min on setup (verl install on both pods, the head's
189+
wait for the rollout pod to join the ray cluster, model + dataset prep —
190+
**both pods prepare the dataset locally**: the `FullyAsyncRollouter` actor
191+
runs on the rollout pod and reads the parquet there; no shared volume, same
192+
seed ⇒ identical files) before it first requests the GPU — vLLM serves batch
193+
traffic the whole time.
194+
195+
## 6. Step 4 — Watch it work
196+
197+
```bash
198+
kubectl logs batch-load-generator --tail=6
199+
# [12:01:05] last 5s: completed=41 errors_or_asleep=0 <- vLLM holds GPU
200+
# [12:04:35] last 5s: completed=0 errors_or_asleep=12 <- trainer burst
201+
# [12:08:10] last 5s: completed=38 errors_or_asleep=1 <- vLLM back
202+
203+
kubectl logs shadow-vllm --tail=10
204+
# [supervisor] trainer is waiting - yielding GPU
205+
# [supervisor] drained in 2.82s
206+
# [supervisor] vLLM slept (HBM -> host RAM) in 0.97s
207+
# [supervisor] lock reacquired (waited 214380 ms, context_restored=True)
208+
# [supervisor] vLLM woke in 0.05s
209+
210+
# NOTE: rl_metrics.jsonl is written by a CPU-only driver actor that Ray may
211+
# place on EITHER RL pod — check both:
212+
kubectl exec rl-head -- tail -4 /workspace/results/rl_metrics.jsonl
213+
kubectl exec rl-rollout -- tail -4 /workspace/results/rl_metrics.jsonl
214+
# acquire/yield events with wait_ms / held_ms per training burst
215+
```
216+
217+
Healthy steady state: the load generator alternates between full-throughput
218+
windows (trainer idle) and error windows a few minutes long (trainer burst);
219+
supervisor drain ≈ 3 s, sleep ≤ 3 s, wake ≤ 1 s; trainer `wait_ms` at
220+
acquire ≈ 5–11 s (poll latency + drain + vLLM sleep + the trainer's own
221+
cuda-checkpoint context restore, ~5 s once training state is large — the
222+
trainer never queues behind batch work).
223+
224+
Success criteria to check after ~3 training steps:
225+
226+
- Trainer step time within ~10% of a solo run (compare `train.log` timing to
227+
a run without the shadow vLLM, or to the reference: median 570 s/step).
228+
- Batch throughput > 0 in every trainer-idle window.
229+
- `ROLE-OK` lines recurring in both RL pod logs, no `ROLE-FATAL`; no
230+
cuda-checkpoint operations targeting the rollout node in any agent log
231+
(the rollout node must never show cuda-checkpoint activity).
232+
233+
## 7. Step 5 — Collect results
234+
235+
`rl_metrics.jsonl` may be on either RL pod (the CPU-only driver actor that
236+
writes it can be placed on either node by Ray) — collect from both and keep
237+
whichever is non-empty:
238+
239+
```bash
240+
mkdir -p results
241+
for POD in rl-head rl-rollout; do
242+
mkdir -p "results/$POD"
243+
kubectl exec "$POD" -- sh -c \
244+
'cd /workspace/results && tar cf - rl_metrics.jsonl train.log gpu_util.csv 2>/dev/null' \
245+
| tar xf - -C "results/$POD" 2>/dev/null || true
246+
done
247+
kubectl logs batch-load-generator --timestamps > results/batch_throughput.log
248+
kubectl logs shadow-vllm --timestamps > results/supervisor.log
249+
```
250+
251+
`rl_metrics.jsonl` (trainer bursts) + `batch_throughput.log` (harvested
252+
inference) together give the full shared-GPU timeline: every second is either
253+
a training burst, batch serving, or a swap.
254+
255+
## 8. Troubleshooting
256+
257+
| Symptom | Cause | Fix |
258+
|---|---|---|
259+
| Supervisor's first `acquire()` hangs | Trainer node not labeled into the group | §4; relabel after any node replacement |
260+
| Acquire hangs; orchestrator logs `waiting for job ... IDLE` | Platform image without PR #152 fix | Use the pinned images in `examples/values-timeslice.yaml` |
261+
| vLLM never sleeps at handoff; trainer restore then fails with OOM on the shared GPU | Workload registration didn't reach the agent (check supervisor log for `workload registered`) | Verify `TIMESLICE_AGENT_ADDR` resolves to the node IP :9001 and agent logs show the registration; the supervisor's local wake fallback covers restores but sleep MUST go through the channel |
262+
| `POST /sleep` returns 404 | vLLM started without dev endpoints | `VLLM_SERVER_DEV_MODE=1` and `--enable-sleep-mode` are both required (set in the manifest) |
263+
| vLLM dies seconds after a sleep (`CUDA error: an illegal memory access` in EngineCore), then the group faults on the failed wake | `/sleep` was called with requests in flight — vLLM does not drain the scheduler on sleep (vllm#28714, also affects ≥0.10.x; requests sent to a sleeping engine crash it too, vllm#15483) | Keep the supervisor's readiness-gate drain (in the manifest): gate closed + `running+waiting==0` BEFORE `release()`. Never call `/sleep` on a serving engine directly |
264+
| Trainer NCCL error in `transport/nvls.cc` after a restore | Missing `NCCL_CUMEM_ENABLE=0` / `NCCL_NVLS_ENABLE=0` | Set on both RL pods in `examples/rl-job.yaml`; keep if you edit |
265+
| Startup aborts with `TIMESLICE_REQUIRE_PG_PINNING=1 but no extra bundle resources resolve` | The `ray_pg_extra_resources` hydra override is missing/typo'd, or the verl build lacks the feature | Keep the override in `examples/rl-job.yaml`'s `run_head.sh` intact and use the pinned verl branch (§2) |
266+
| Trainer placement group lands on the rollout node (role watchdog FATAL within ~1 min of training start) | PG pinning not active — custom resources missing on ray start, or the pinning check was disabled | Verify both `ray start` lines carry `--resources` and both RL pods set `TIMESLICE_REQUIRE_PG_PINNING=1` |
267+
| cuda-checkpoint activity on the rollout node's agent, or rollout throughput collapses during handoffs | The rollout pod/node got labeled into the group | The rollout node must never show cuda-checkpoint activity: keep `timeslice.io/*` labels off rl-rollout and the group label off `$ROLLOUT_NODE` |
268+
| `rl-head` log stuck at `Phase 2b: wait for the rollout pod to join`, then `FATAL: rollout pod did not join` | rl-rollout Pending/crashed, or headless Service `rl-head` missing | `kubectl get pod rl-rollout; kubectl logs rl-rollout --tail=50`; apply the whole rendered `examples/rl-job.yaml` |
269+
| Load generator: 100% errors even when trainer is idle | vLLM crashed (supervisor exits, pod restarts) or Service selector mismatch | `kubectl logs shadow-vllm --previous`; check `kubectl get endpoints shadow-vllm` |
270+
| Both tenants hang after one crashed mid-handoff | Orchestrator in-memory state wedged | Delete both workloads, `helm uninstall` + reinstall platform, relaunch (§10) |
271+
| RL head or shadow vLLM pod Pending | Trainer node CPU/RAM too small for 8 CPU + 80 Gi (head) alongside 6 CPU + 60 Gi (vLLM) | Use an a3-highgpu-1g-class node (26 vCPU / 234 Gi), or shrink requests |
272+
273+
Demo-client caveat, worth stating to any customer: while vLLM is draining or
274+
asleep, new batch requests fail fast at the Service (the pod is NotReady).
275+
The demo load generator just counts those errors. A production batch
276+
front-end should be queue-based with retries — this composes naturally with
277+
[llm-d async processor](https://github.com/llm-d/llm-d-async) as the batch
278+
dispatcher, which is the planned productization path.
279+
280+
## 9. Adapting
281+
282+
- **Bigger batch model**: anything that fits in `VLLM_GPU_FRAC` of the shared
283+
GPU alongside zero trainer residency (they never co-reside). Sleep/wake
284+
time scales with weight size (~1 s per 15 GB to host RAM).
285+
- **Your RL workload**: swap model/dataset in `examples/rl-job.yaml` exactly as in the
286+
two-RL-jobs guide (§9 there); everything under "Required" comments stays.
287+
- **Yield latency vs. poll cost**: `WAITER_POLL_SECONDS` (default 0.5) is the
288+
worst-case extra wait the trainer sees.
289+
- **Trainer solo baseline** (for the ≤10% overhead check): run `examples/rl-job.yaml`
290+
alone — without the shadow vLLM the trainer acquires instantly every step
291+
and the platform defers snapshots (nothing contends), so it behaves like an
292+
unshared run.
293+
294+
## 10. Teardown
295+
296+
```bash
297+
kubectl delete pod shadow-vllm batch-load-generator --ignore-not-found
298+
kubectl delete pod rl-head rl-rollout --ignore-not-found
299+
kubectl delete service shadow-vllm rl-head --ignore-not-found
300+
kubectl delete configmap shadow-vllm-scripts rlbatch-trainer --ignore-not-found
301+
kubectl delete configmap timeslice-verl-root timeslice-verl-src --ignore-not-found
302+
helm uninstall timeslice -n timeslice-system
303+
kubectl label node "$TRAINER_NODE" group.timeslice.io/trainers- || true
304+
```
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

0 commit comments

Comments
 (0)