Files
vllm-frontdoor/CALIBRATION.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

14 KiB
Raw Permalink Blame History

Step 1 calibration report — router front door plan v3.3

Date: 2026-08-17 Scope: plan §5 (GPU budget), §7 (compose), §9 step 1 (calibration). State at end of calibration: vllm-text, vllm-ocr, vllm-embed all Up and awake, serving on debug ports 8001/8002/8003. Router not built (another work stream); its service block is defined in compose but not started.

0. Headline results

Metric vllm-text (TP=2) vllm-ocr (TP=1) vllm-embed (TP=1)
GPUs 0+1 2 2
util slice (final) 0.85 0.10 0.25
Awake GPU footprint 67.870.3 GB per GPU (0.830.86) 7.9 GB (0.097) 21.0 GB (0.256)
Sleeping GPU residual (per process) 5.55.7 GB per GPU 3.4 GB ~1.0 GB
Sleep L1 latency (first/warm) 6.7 s / ~1.5 s 0.8 s / 0.1 s 6.5 s / <0.1 s
Sleep L2 latency (from awake) 0.2 s 0.1 s 0.04 s
Wake from L1 — wake_up only 2.53.8 s ~0.3 s ~1.1 s
Wake from L1 — full sequence (with reload) 23.4 s 0.7 s 4.0 s
Wake from L2 — full sequence (mandatory) 22.9 s 0.6 s 3.3 s
Host RAM cost of L1 sleep (first cycle) 39.7 GB 2.5 GB 20.7 GB
Boot time (weights → serving) ~5 min (warm cache) ~2.5 min ~1.5 min

No slice changes were needed. The initial v3.3 slices all fit (§2).

1. Compose config actually used

/data/home/renbaibing/vllm/compose.yml — four services, nginx removed.

  • Common to all three vLLM services: image vllm/vllm-openai:v0.27.1, ${MODEL_ROOT}:/models:ro, ipc: host, gpus: all + explicit CUDA_VISIBLE_DEVICES (the plan's one-env-var rollback mechanism), VLLM_SERVER_DEV_MODE=1, NCCL_P2P_DISABLE=1, HF_TOKEN, restart: unless-stopped, loopback-only debug port 127.0.0.1:800X:8000, --disable-custom-all-reduce --enable-sleep-mode, explicit --max-model-len each. No healthchecks anywhere (sleep makes /health semantics misleading — see §5 — and service_healthy gating is forbidden by plan §7).
  • vllm-text (GPU 0,1; debug 8001): /models/Qwen3.6-35B-A3B-FP8 --served-model-name Qwen3.6-35B-A3B-FP8 --tensor-parallel-size 2 --max-model-len 262144 --gpu-memory-utilization 0.85 --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder --disable-custom-all-reduce --enable-sleep-mode
  • vllm-ocr (GPU 2; debug 8002): /models/OvisOCR2 --served-model-name OvisOCR2 --tensor-parallel-size 1 --max-model-len 32768 --gpu-memory-utilization 0.10 --max-num-seqs 256 --disable-custom-all-reduce --enable-sleep-mode (--max-num-seqs 256 is the one added flag — see §3.)
  • vllm-embed (GPU 2; debug 8003): /models/Qwen3-Embedding-8B --served-model-name Qwen3-Embedding-8B --tensor-parallel-size 1 --max-model-len 8192 --gpu-memory-utilization 0.25 --disable-custom-all-reduce --enable-sleep-mode (no task flag needed — auto-detected, see §3.)
  • router (defined, NOT started): build: ./router, 8000:8000 public + 127.0.0.1:8010:8010 admin, depends_on the three vLLM services with condition: service_started.

2. GPU budget verification (plan §5)

Per-GPU sums, all awake (measured nvidia-smi, 81920 MiB per A800):

GPU Contents Slice sum Measured used Fraction
0 text (TP worker 0) 0.85 + ctx 6777970249 MiB 0.8270.857
1 text (TP worker 1) 0.85 + ctx 6777970249 MiB 0.8270.857
2 ocr 0.10 + embed 0.25 0.35 + 2 ctx 28929 MiB 0.353

All ≤ 0.88 target. GPU0/1 peak at fresh boot 70249 MiB (0.857) — inside budget with ~11.7 GB headroom. Sleeping (all processes L1/L2): GPU0/1 → 5.7 GB each, GPU2 → 4.5 GB total.

Text KV cache (from logs): Available KV cache 41.72 GiB per GPU, GPU KV cache 4,309,772 tokens, "Maximum concurrency for 262,144 tokens per request: 16.44x", CUDA graphs 1.62 GiB, weights 17.48 GiB/GPU (FP8). Far above the ~20 GB concern threshold — the plan's KV-starvation worry is fully retired: full 262K context served with 16x concurrency. (vLLM notes util 0.85 with CUDA-graph profiling ≈ 0.8316 effective; no action needed.)

OCR KV: 2.11 GiB → 173,056 tokens → 5.28x at 32K. Embed: pooling model (KV small); max_model_len 8192 as planned.

No OOM at any point; no slice adjustments made.

3. Model-specific flags discovered

OvisOCR2 (vllm-ocr):

  • Architecture Qwen3_5ForConditionalGeneration is natively registered in v0.27.1 → no --trust-remote-code needed (left out).
  • dtype: auto resolves to bfloat16 from config; no flag needed.
  • Vision limits: defaults fine (tested with a 640x160 PNG via image_url base64 — correct OCR text back). No --limit-mm-per-prompt.
  • The README's gdn_prefill_backend="triton" (written for vllm 0.22.1) was NOT needed; v0.27.1 resolves the GDN backend automatically (auto).
  • Required addition: --max-num-seqs 256. Without it boot fails: max_num_seqs (1024) exceeds available Mamba cache blocks (316) — the hybrid GDN model needs one Mamba cache block per decode sequence and the 0.10 slice only fits 316. 256 < 316; OCR concurrency of 256 is far beyond any realistic page-parsing load. (Alternative would have been raising util; unnecessary.)

Qwen3-Embedding-8B (vllm-embed):

  • No flag needed. v0.27.1 auto-detects: "Found pooling configuration" (reads sentence-transformers modules.json), logs --runner auto → pooling, --convert auto → embed, and builds PoolerConfig(seq_pooling_type='LAST', use_activation=True) — i.e. exactly Qwen3-Embedding semantics (last-token pooling + normalized output). Verified: /v1/embeddings returns dim=4096, norm=1.0000.
  • Note for the router team: v0.27.1 has no --task flag (it was replaced by --runner/--convert). If explicitness is ever wanted, the equivalent is --runner pooling --convert embed; do NOT pass --task embed (unknown arg → boot failure).

Qwen3.6-35B-A3B-FP8 (vllm-text): booted with plan args verbatim; fp8 block quantization auto-detected (quantization=fp8), runs on A800 (Ampere) via the fp8/marlin path, no flags required.

4. Sleep/wake measurements (per service)

All numbers from the loopback debug ports; wake "full sequence" = POST /wake_upPOST /collective_rpc {"method":"reload_weights"}POST /reset_prefix_cacheGET /health 200.

vllm-text

Step Latency
sleep L1 (first, allocates pinned pool) 6.69 s
sleep L1 (warm, pool reused) ~1.5 s
GPU after L1 5477 MiB per GPU (from 70249)
RAM delta after first L1 39.7 GB (weights 35 GB + buffers)
wake L1, wake_up only 2.47 s (earlier run 3.7 s)
wake L1, full sequence 23.42 s (reload_weights alone 20.94 s)
sleep L2 from awake 0.22 s; GPU 5717 MiB; RAM 0.1 GB
wake L2, full sequence 22.94 s (wake_up 1.75 + reload 21.17)
L1→L2 escalation HTTP 200 in 0.01 s, no RAM freed, re-wake 23.7 s

vllm-ocr

Step Latency
sleep L1 0.82 s; GPU 8305→3459 MiB; RAM 2.5 GB
wake L1 full sequence 0.68 s
sleep L2 0.11 s; GPU 3459 MiB
wake L2 full sequence 0.63 s
L1→L2 escalation 200 in 0.01 s, RAM retained, re-wake 0.67 s

vllm-embed

Step Latency
sleep L1 6.51 s (D2H copy); GPU 20996→~1050 MiB (GPU2 total 8981 with OCR awake); RAM 20.7 GB
wake L1 full sequence 4.00 s (wake_up 1.06 + reload 2.93)
sleep L2 0.04 s
wake L2 full sequence 3.31 s (reload 3.08 s from page-cached NFS)
L1→L2 escalation 200 in 0.01 s, RAM retained, re-wake 4.00 s

Host RAM (plan §6.3 verification)

Host total 1082 GB (free -g: 1007 GiB total column, 903 GiB available with all three awake and page cache warm). Worst case all three nap at L1 simultaneously (first cycle after boot): 39.7 + 20.7 + 2.5 ≈ 63 GB of pinned host memory (plan estimated ~57 GB). With ~900 GB still available, simultaneous L1 naps are entirely safe. Note: the pinned pools are reused across sleep cycles — after the first L1→wake cycle, subsequent L1 sleeps barely move free (allocator reuse), so don't alarm if the second nap shows a ~0 delta.

5. Router-relevant behavior findings (important)

  1. /health returns 200 while a service is SLEEPING (and /v1/models too). Health is a liveness check of the API server, NOT an awake check. The router must gate on /is_sleeping.
  2. A request sent to a sleeping service does not error — it HANGS (queued behind the paused scheduler; verified >2 min). Never proxy to a backend without first checking is_sleeping.
  3. Requests admitted between wake_up and reload_weights get HTTP 200 with GARBAGE content (verified twice: OCR and text, reasoning field full of !!!!...). The full wake sequence must COMPLETE before the held request is proxied. "Never proxy to a half-awake backend" is not theoretical — it produces silently-wrong 200s.
  4. L1 wake does NOT need reload_weights. After wake_up alone, text produced bit-identical output to the reloaded state (same prompt, temperature 0, identical reasoning text; correct answers 1723=391, 67=42). reload_weights after L1 costs ~21 s for text and adds nothing. → Router rule: L1 wake = wake_up (+ optional reset_prefix_cache), ~2.54 s; L2 wake = full sequence with reload_weights, ~23 s (text). If depth is unknown (router restart), use the full sequence.
  5. POST /sleep?level=2 on a service already at level 1 is a well-behaved NO-OP (plan §6.3 "verify"): HTTP 200 in ~0.01 s, is_sleeping stays true, the L1 host-RAM copy is retained (allocator code: level-2 sleep(offload_tags=()) never touches existing cpu_backup_tensors), and the next wake is still RAM-fast. It does NOT free host RAM. → Tier escalation must be implemented as wake (cheap, from RAM) then sleep?level=2 from awake, not as a direct L1→L2 call.
  6. Concurrent wake_ups are safe: 5 parallel POST /wake_up at a sleeping text service all returned 200 in ~4.03 s each (serialized by the engine). Combined with finding 3, this confirms plan §6.4's idempotent-re-wake assumption.
  7. reset_prefix_cache is instant (≤0.01 s) and harmless (prefix caching is disabled for the hybrid text model anyway; enabled for embed).
  8. /sleep also accepts ?mode= (default abort discards in-flight requests at sleep time) and level 0 (scheduler pause only, no memory change); wake_up accepts ?tags= filtering. Not needed by the router today, but available.
Service GPU(s) util Notes
vllm-text 0,1 (TP=2) 0.85 KV 41.7 GiB/GPU, 16.4x @ 262K — ample
vllm-ocr 2 0.10 with --max-num-seqs 256
vllm-embed 2 0.25 21 GB measured; ~1516 GB is weights

GPU2 has ~50 GB of headroom if embed ever needs --max-model-len above 8192.

Measured: L1 wake (wake_up only) worst case ~4 s; L2 wake (full sequence) text ~23 s warm — all files page-cached; a true cold-NFS L2 wake has not been measured (NFS streams at ~16 GB/s here depending on cache; 37.5 GB cold could add ~3060 s). Boot from scratch: text ~5 min warm, 210 min cold (NOTES-2026-08-13).

Situation Plan value Recommendation
Hold, wake from L1 30 s keep 30 s (comfortable; measured ≤4 s). Could drop to 15 s. Retry-After: 10 fine. estimated_wake_seconds: 5
Hold, wake from L2 180 s keep 180 s (measured 23 s warm; covers ~2 min cold-NFS margin). Retry-After: 60 fine. estimated_wake_seconds: 30 (60 if NFS suspected cold)
Hold, container restarting 300 s raise to 600 s — cold text boot can reach 10 min; with 300 s the client gets a 503 exactly while recovery is still in progress, then must re-poll anyway. Keep Retry-After: 600

Also recommend the router implement the depth-aware fast path from §5.4 (skip reload_weights for known-L1 wakes) — it turns the common case (15-min idle tier) into a ~3 s wait for the text model instead of ~23 s.

8. Incidents and resolutions during calibration

  • Qwen3.6-35B-A3B-FP8 on disk was corrupt/incomplete (16 layer files wrong-sized, 18 files missing/zero-byte, tokenizer/index empty) — caused by six stale, hung hf download processes from 2026-08-14 that had raced each other on the same --local-dir. Resolution: sha256-verified every file against the HF manifest, salvaged 14 intact files, re-downloaded the remaining 24.8 GB (HuggingFace via proxy was ~13 MB/s; ModelScope's mirror of the same public repo, direct + anonymous, averaged 36 MB/s; every byte sha256-verified against the official HF manifest — ModelScope was only a transport). New verified copy now lives at /data/home/renbaibing/huggingface/Qwen3.6-35B-A3B-FP8; the corrupt original is preserved at .../Qwen3.6-35B-A3B-FP8.corrupt-20260817 (36 GB — safe to delete once trusted). Two stale-download casualties were also found and fixed: Qwen3-Embedding-8B/model-00003-of-00004.safetensors was truncated (re-fetched, verified) and OvisOCR2 verified fully intact. The six stale hf download processes are STILL hung (killing them was outside this session's permissions) — an operator should kill them; their target dir no longer exists, so they are harmless but should not be restarted as-is.
  • vllm-embed boot crash (SafetensorError: incomplete metadata) — the truncated shard above; resolved by re-download.
  • vllm-ocr boot crash (max_num_seqs (1024) exceeds available Mamba cache blocks (316)) — resolved with --max-num-seqs 256 (kept util 0.10).

9. Final functional verification (all three awake)

  • text: POST /v1/chat/completions → correct answers ("Paris", 391, 42), reasoning parser active, ~146157 tok/s decode. Tool-call/reasoning args accepted at boot.
  • ocr: chat completion with base64 PNG image → correct text extraction ("vLLM calibration 2026-08-17 / GPU2 sleep-mode test image"), 0.4 s.
  • embed: POST /v1/embeddings → 4096-dim unit-norm vectors.
  • All three: GET /health 200, GET /v1/models lists the right model, GET /is_sleeping false.