|
| 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