Files
vllm-frontdoor/.claude/memory/router-front-door-plan.md
bing 80eef4ce6a Initial commit: router front-door vLLM stack
Three always-running vLLM services (text TP=2 GPU0+1, ocr + embed on GPU2,
sleep mode) behind a FastAPI router that auto-wakes models on request.
Tiered idle (sleep 15 min / offload 3 h), depth-aware 503s with
Retry-After, persisted wake-intent recovery, admin API on 127.0.0.1:8010.
Routine control via vllmctl is pure HTTP — no docker on the request path.

Verified: 91 router unit tests + 15-test E2E on real hardware
(measurements in CALIBRATION.md; design record in
.claude/memory/router-front-door-plan.md).

Old nginx stack files removed before git init; design survives in
.claude/memory/sleep-mode-implementation-plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:17:42 +00:00

19 KiB
Raw Permalink Blame History

Plan v3.3 — Router Front Door (auto wake-on-request)

Date: 2026-08-14, revised 2026-08-17 (v3.3) Status: IMPLEMENTED & VERIFIED 2026-08-17 — v3.2 passed two review rounds (5 critical + 6 major fixed and confirmed; round-2 minors applied). v3.3 = hardware-driven placement change (GPU2 cleared for single-GPU services → OCR+embed on GPU2, text exclusive on GPU0/1). Implementation record and verification trail: ../../TODO.md; measured numbers: ../../CALIBRATION.md. One post-plan correctness fix: persisted wake-intent + startup recovery (E2E case 12), see TODO "Verification trail". Decisions made with user: multiple models may be awake concurrently; router = Python FastAPI; tiered idle (level-1 sleep after 15 min, level-2 offload after 3 hr); depth-aware 503 errors.

1. Problem & constraints

Calling services must be able to hit http://<host>:8000/v1/... with any of our three models at any time, with no knowledge of load state. Hard constraints:

  1. Native to the Docker stack — the entire request path runs inside containers; no host-side cron, watchers, or helpers.
  2. No docker permissions on the request path — model load/unload/switch is pure HTTP (vLLM Sleep Mode). Docker is only needed for rare manual ops (image upgrade, pulling new models).
  3. Highly reliable — failures degrade to depth-aware 503 + Retry-After, never a hang, never a proxy to a half-awake backend.

The Plan v2 nginx front door hid dev endpoints but did not wake models on request; that inverted priority is what this plan fixes.

2. Hardware reality (re-verified 2026-08-17 — see NOTES-2026-08-17-gpu2-recheck.md)

  • 3× A800-SXM4-80GB. P2P is still broken host-wide, 4/6 paths corrupt, but the fault MOVED: now every transfer sourced from GPU0 or GPU1 corrupts; GPU2-sourced transfers are the only clean ones (was the inverse on 2026-08-13). Per-GPU integrity (H2D/D2H, matmul, stress) passes on all three, twice.
  • Consequences: NCCL_P2P_DISABLE=1 + --disable-custom-all-reduce stay mandatory for any TP>1 workload (host-staged copies verify clean — which is why TP=2 on GPU0+1 with P2P off works). GPU2 is usable for single-GPU services (no P2P involved; H2D/D2H verified clean).
  • On-disk models (verified): Qwen3.6-35B-A3B-FP8 (43GB dir), OvisOCR2 (1.8GB), Qwen3-Embedding-8B (12GB). User confirmed 2026-08-14: the 8B embedding model is the intended one (earlier docs mentioning a 0.6B were wrong; slices are sized for the 8B).

3. Architecture

                        ┌──────────────────── Docker network ────────────────────┐
callers ─► :8000 ─► ┌─────────┐   ┌──────────────────────┐ ┌───────────────────┐
                    │ router  │ ─►│ vllm-text (TP=2)     │ │ vllm-ocr  GPU2    │
                    │ FastAPI │   │  GPU0 + GPU1 only    │ │ vllm-embed GPU2   │
                    └─────────┘   └──────────────────────┘ └───────────────────┘
           127.0.0.1:8010 (admin)
  • One always-running vLLM service per model, each with --enable-sleep-mode + VLLM_SERVER_DEV_MODE=1, each pinned to a GPU memory slice so all three can be awake simultaneously.
  • Router is the only public ingress (port 8000). It owns: model-name → service mapping, wake-on-request, streaming proxy, tiered idle management, and dev-endpoint hiding (404 for anything not an allowed path, matched on the normalized path to defeat /v1/../sleep traversal).
  • Admin API is a separate listener published only on 127.0.0.1:8010 — NOT paths on the public port (review C5: a public /admin/sleep would be a trivial remote DoS).
  • nginx is removed. The router replaces it.
  • Placement (v3.3, user-approved 2026-08-17): text TP=2 gets GPU0+GPU1 exclusively (0.85 slice, full 262K context); OCR and embed run TP=1 on GPU2 — single-GPU services never issue P2P transfers, and GPU2's H2D/D2H paths verify clean (§2). CUDA_VISIBLE_DEVICES is set explicitly per service. Rollback if GPU2 ever misbehaves: change one env var to move a service back to GPU0/1 (slices would need re-tightening).
  • All services keep restart: unless-stopped and ipc: host (NCCL shm). Each service's TP TCP store is isolated by its own network namespace — no distributed-init port collision (would only become a risk if someone later sets network_mode: host; do not).

4. Port layout

Port Binding Purpose
8000 0.0.0.0 (host) → router Public API (only public ingress)
8010 127.0.0.1 (host) → router admin listener vllmctl / debugging
8001 127.0.0.1 (host) → vllm-text :8000 Direct debug access
8002 127.0.0.1 (host) → vllm-ocr :8000 Direct debug access
8003 127.0.0.1 (host) → vllm-embed :8000 Direct debug access
Docker net only router → vllm-*

Security boundary, stated explicitly: the loopback debug ports 80018003 expose each service's dev endpoints (/sleep, /wake_up, …) to any local user on this multi-user host, bypassing the router's 404s. Same exposure as the current stack's port 8001; accepted, but "dev endpoints unreachable" means "unreachable from the network," not "unreachable locally."

5. GPU memory budget (3× 80GB in use; calibration gates final numbers)

gpu_memory_utilization is a fraction of total GPU memory, and each process accounts only for itself. Budget per GPU:

Σ(slices on that GPU) + (CUDA ctx per process ~12GB) + boot/wake transients ≤ 0.88

Initial slices (recalibrated in Step 1):

Service Weights on disk TP / GPU util slice Notes
vllm-text 43GB (FP8) 2 / GPU0+1 (exclusive) 0.85 full 262K context; KV ~25GB — no 131K fallback needed
vllm-ocr 1.8GB 1 / GPU2 0.10 small vision model
vllm-embed 12GB (8B) 1 / GPU2 0.25 --max-model-len 8192; raisable at calibration

Per-GPU: GPU0 = GPU1 = 0.85 + 1 ctx ≈ 0.87; GPU2 = 0.10 + 0.25 = 0.35 + 2 ctx. Disjoint pools — text never competes with the small services, and the small services have an entire idle GPU of headroom.

Sleeping (level 2) residual is ~2.5GB per process (E2E measured ~5GB total for one TP=2 instance = 1 proc/GPU). Under v3.3 placement: GPU0/GPU1 host 1 process each (text) → ~2.5GB residual; GPU2 hosts 2 (ocr + embed) → ~5GB. Measured in Step 1.

6. Router design

Python 3.12-slim container, pinned fastapi/httpx/uvicorn versions, restart: unless-stopped. ~300 lines.

Single process, single event loop, two listening sockets (public :8000 + admin :8010): run two uvicorn.Server instances as coroutines on one asyncio loop (uvicorn can't bind two ports in one worker, and two separate processes would break the in-process wake locks — the admin listener MUST share the same locks and depth tracking as the request path).

6.1 Model registry (env or models.json)

text : { service: "vllm-text",  model: "Qwen3.6-35B-A3B-FP8", aliases: ["qwen3.6-35b-a3b-fp8", ...] }
ocr  : { service: "vllm-ocr",   model: "OvisOCR2" }
embed: { service: "vllm-embed", model: "Qwen3-Embedding-8B" }

(No 0.6B alias — the 8B is the confirmed model (2026-08-14); an alias would mislead clients into thinking they got a different model than they did.)

Matching is case-insensitive. Unknown model → OpenAI-style 404 (model_not_found), no wake triggered.

6.2 Request path

  1. Resolve target service:
    • Content-Type application/json → parse body, use model field; missing model defaults to text only for /v1/chat/completions and /v1/completions; other endpoints require an explicit model.
    • Non-JSON bodies (e.g. multipart/form-data image uploads for OCR — review C2) → never fully parse; extract only the small model text field from the multipart parts (without reading file parts) and forward the raw buffered body unchanged. A multipart request on /v1/chat/completions with no resolvable model defaults to OCR (that's the image-bearing path).
    • /v1/embeddings → embed service regardless of body.
  2. Check service state (cached briefly — don't add a backend round trip to every request):
    • awake → proxy immediately (httpx streaming passthrough, no buffering).
    • sleeping/offloading → single-flight per service under an asyncio lock; concurrent callers await the same event. Re-check is_sleeping after acquiring the lock (another path may have just woken or slept it).
  3. Wake sequence (proven in E2E on 2026-08-14): POST /wake_upPOST /collective_rpc {"method":"reload_weights"}POST /reset_prefix_cache → poll GET /health until 200.
  4. Proxy the held request.

Proxy timeouts (review M4): httpx connect timeout 5s; read timeout 900s (matching today's nginx — capped, never disabled, so a wedged backend stream can't hang forever) for both normal and streaming paths; connection pool sized for ≥16 concurrent requests. A 5s default read timeout would kill nearly every generation.

Other public endpoints:

  • /v1/models → aggregated list of all three registered models (clients commonly list before calling).
  • /health → router liveness + per-service state summary (uptime checks).
  • /metrics → pass through from all services, or omit; decide at implementation (was unauthenticated under nginx anyway).

6.2.1 Depth-aware error semantics

The router tracks each service's sleep depth from its own actions. Depth loss after router restart or direct vllmctl admin calls: if sleeping but depth unknown → treat as offloaded (conservative — worst case the client waits slightly longer than needed, never gets a too-early retry).

Hold-first policy: requests queue during wake and only error if the depth-dependent hold deadline is exceeded.

Situation Hold deadline then Status Retry-After body sleep_depth
Wake from level 1 (sleep, ~16s) 30s 503 10 "sleeping"
Wake from level 2 (offload, ~1560s+) 180s 503 60 "offloaded"
Container restarting (cold start = 210 min NFS load) 300s 503 600 "restarting"
Unknown model 404 model_not_found

503 body (OpenAI-style, parseable):

{"error": {"type": "model_waking", "code": "model_waking",
           "message": "Model 'OvisOCR2' is waking from offload; retry shortly",
           "sleep_depth": "offloaded", "estimated_wake_seconds": 45}}

Wake-sequence failure: retry the sequence once, then the depth-aware 503. Never proxy to a half-awake backend. All deadlines/estimates get re-tuned from latencies logged during calibration (Step 1).

6.3 Tiered idle management (replaces vllmctl idle-watch)

Tier Trigger (idle) Action State after Wake cost
Sleep 15 min (IDLE_SLEEP_MIN) POST /sleep?level=1 weights → host RAM ~16s
Offload 3 hr (IDLE_OFFLOAD_MIN) POST /sleep?level=2 RAM freed; ~5GB/GPU ctx ~1560s (NFS)

Race-safety (review M1): the idle manager takes the same per-service asyncio lock as the wake path and re-checks under the lock that (a) active-request count == 0 and (b) last-activity is still past the threshold. Last-activity is refreshed at request completion as well as arrival, and an active-request counter guards long streaming generations — a 20-minute stream must not trigger sleep mid-flight.

Verify at implementation: /sleep?level=2 on a service already sleeping at level 1 must discard the offloaded weights (not no-op/error). Fallback: wake → re-sleep at level 2.

Verify at implementation: host RAM — level 1 holds a full weights copy (text 43GB + ocr + embed ≈ 57GB total if all three nap simultaneously). Check free -g headroom.

NFS risk (accepted, documented): level-2 wake requires a healthy NFS mount. If NFS is down, the model is unwakeable and the router 503s (with restarting-grade Retry-After) until NFS returns. Mitigation option: cap the text model at level 1 (weights stay in RAM). Decide after calibration.

6.4 Router restart mid-wake (review M3)

The wake lock is in-process and dies with the router. The new instance must not fire a second concurrent wake at a backend already waking. Mitigation: the wake sequence is effectively idempotent from the router's view (re-POST /wake_up on an already-waking backend is safe; vLLM serializes it), and the request path re-checks is_sleeping under the lock before acting.

6.5 Admin API — separate listener, 127.0.0.1:8010 only

  • GET /admin/status — per-service: running?, sleeping?, depth, last activity, wake in progress
  • POST /admin/wake/{key} / POST /admin/sleep/{key}[?level=1|2] — manual control (updates the router's depth tracking)

6.6 Failure modes

Failure Router behavior
vLLM container down/crashing Detect unhealthy; hold up to 300s (restart: unless-stopped recovers it — but note cold start is 210 min for text); then 503 Retry-After: 600
Wake sequence fails Retry once, then depth-aware 503 (6.2.1); never proxy half-awake
Router crash restart: unless-stopped; vLLM services unaffected; at most in-flight requests lost; depth resets to conservative offloaded
Host reboot All three vLLM containers cold-start AWAKE (vLLM has no boot-asleep) — see §7; text on GPU0/1 and small services on GPU2 initialize in parallel without racing; recovery takes minutes, router serves restarting 503s meanwhile
NFS outage Level-2 unwakeable → 503s until NFS returns (§6.3)
Request without model field Defaults to text only on chat/completions; elsewhere 400

7. Compose changes

  • Add router service (build ./router/): public 8000:8000, admin 127.0.0.1:8010:8010, depends_on all vllm services (condition: service_started).
  • Boot ordering (simplified in v3.3): no staggering needed — text initializes on GPU0/1 while OCR and embed initialize on GPU2, disjoint memory pools, no race. All services use plain depends_on: service_started (sleep makes healthchecks flappy, so never service_healthy). The earlier wait-for-healthy entrypoint choreography (review-2 issue #1) is obsolete under this placement.
  • Split vllm into vllm-text / vllm-ocr / vllm-embed with per-model args baked in; MODEL_NAME/EXTRA_ARGS/TP_SIZE/MAX_MODEL_LEN in .env become obsolete for serving (kept only for rollback).
  • Remove nginx service and nginx.conf.
  • All services: restart: unless-stopped, ipc: host, NCCL_P2P_DISABLE=1, explicit CUDA_VISIBLE_DEVICES.

Per-model serving args (all include --disable-custom-all-reduce --enable-sleep-mode, explicit --max-model-len each — review M2):

  • text: --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder --max-model-len 262144 --gpu-memory-utilization 0.85
  • ocr: --max-model-len 32768 --gpu-memory-utilization 0.10 (+ vision args from OvisOCR2 model card, e.g. --limit-mm-per-prompt)
  • embed: --max-model-len 8192 --gpu-memory-utilization 0.25

8. vllmctl changes

  • up [MODEL] / down [MODEL] → HTTP to 127.0.0.1:8010 admin API (no docker needed for routine control).
  • statusGET 127.0.0.1:8010/admin/status pretty-printed.
  • pull unchanged (docker, rare, manual).
  • idle-watch removed (router owns idle management).

9. Implementation order

  1. Backups + cutover plan (review M5): cp compose.yml compose.yml.v2.bak; cp vllmctl vllmctl.v2.bak. Status 2026-08-14: renbaibing is now in the docker group (works directly in fresh login shells; use sg docker -c "…" in sessions started before the change — .runas.py/.user.env are obsolete). Old stack already brought down by user; GPUs idle. Rollback: restore .v2.bak files, docker compose up -d.
  2. Calibration (gates everything): bring up the three services with initial slices (old stack stopped); measure awake footprints, sleeping residuals, level-1 and level-2 wake latencies, host RAM cost; adjust slices so each GPU ≤ 0.88 including contexts. Log latencies → tune the §6.2.1 deadlines from data.
  3. router/ app (registry, wake single-flight, raw-body-safe routing, streaming proxy with proper timeouts, tiered idle with lock, admin listener) + compose rewiring; nginx removed.
  4. vllmctl rewrite of up/down/status.
  5. E2E verification (below).
  6. Docs: README, TODO, memory.

10. E2E test matrix

  1. All asleep → POST /v1/chat/completions (text) → 200 after wake; latency logged.
  2. While text awake → POST /v1/embeddings → embed wakes independently; both serve.
  3. OCR request with multipart/form-data body (not just JSON base64) → routes correctly, no body parsing error.
  4. Idle tiers (shortened timers): 1 min → level-1 sleep (host RAM grows by weights); 3 min → level-2 offload (RAM freed); auto-wake from both.
  5. Streaming request during wake → first chunk after wake, no buffering.
  6. Unknown model name → 404 model_not_found, no wake triggered.
  7. Dev endpoints (/sleep, /wake_up, /collective_rpc, /reset_prefix_cache) and /admin/* on public :8000 → 404; admin reachable only via 127.0.0.1:8010.
  8. Kill vllm-text container → request → restarting 503 (Retry-After: 600) during recovery → 200 after restart completes.
  9. Concurrent 10× requests while sleeping → exactly one wake sequence (router logs).
  10. Depth-aware errors (hold deadline forced to 1s): level-1 wake → 503 Retry-After: 10, "sleeping"; level-2 → Retry-After: 60, "offloaded"; body parses with estimated_wake_seconds.
  11. Idle-race: fire a request at the moment the idle timer expires → request served, no sleep mid-request (lock + active-counter verified).
  12. Router restart mid-wake → no double-wake crash; request eventually served.
  13. Two services waking concurrently (text + embed requested simultaneously) → both complete, no lock cross-talk.
  14. Path traversal: /v1/../sleep, /v1%2f..%2fsleep → 404.
  15. Host reboot (if feasible to test): parallel boot (text on GPU0/1, small services on GPU2 — no race), router serves restarting 503s, all three become available.

11. Risks / open items

  • GPU slice calibration gates final numbers (§5, Step 1). v3.3 placement (text exclusive on GPU0/1 at 0.85) removes the KV-starvation concern; calibration confirms.
  • GPU2 trust: single-GPU tests pass (2026-08-17, twice), but it has a fault history (Aug 11 driver re-probe). Mitigation: E2E test 3+4 exercise it before rollout; if GPU2 degrades in production, move OCR/embed back to GPU0/1 with one env-var change each (slices re-tighten to v3.2 values).
  • Which embedding modelresolved 2026-08-14: Qwen3-Embedding-8B.
  • Level 1→2 escalation and host RAM (~57GB if all three nap) need verification (§6.3).
  • NFS outage strands level-2 models — accepted risk or cap text at level 1 (§6.3).
  • Wake latency must fit calling services' client timeouts — confirm their settings before rollout.