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

340 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_up``POST /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):
```json
{"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).
- `status``GET 127.0.0.1:8010/admin/status` pretty-printed.
- `pull` unchanged (docker, rare, manual).
- `idle-watch` removed (router owns idle management).
## 9. Implementation order
0. **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`.
1. **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.
2. `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.
3. `vllmctl` rewrite of up/down/status.
4. E2E verification (below).
5. 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 model~~ — **resolved 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.