Spaces:
Runtime error
perf(modal): cold-start on demand with eager-mode and warmup ping
Browse filesSwitch all three Modal containers to min_containers=0 with a
2-minute scaledown_window so the GPU bill is tied to actual traffic
instead of wall-clock. The 3-day demo can't afford 3 always-on A10Gs.
Cold-start cost on a fresh container is now ~60-120s for the LLM
endpoints and ~30-60s for VoxCPM2. Two real mitigations:
1. --enforce-eager on the vLLM servers. Skips CUDA-graph capture at
the cost of some steady-state throughput. Saves 20-40s of cold
start. The right tradeoff for a demo where first-token latency
matters more than tokens/sec.
2. Space-side warmup ping. app.py fires a non-blocking /health request
to each endpoint from a daemon thread on Space import. The cold
start happens while the parent is reading the welcome screen; the
first real request lands on a warm container. Failures are
silently logged; warmup is a hint, not a requirement.
Smaller judge is not the lever (the bottleneck is vLLM's import +
compile, not 4B vs 7B). Image-bake of weights is the next step if
first-token latency still hurts at the demo.
|
@@ -53,8 +53,9 @@ The product design has two distinct execution layers, each tuned to its job:
|
|
| 53 |
- Drafter vLLM flags: `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4`.
|
| 54 |
- Judge vLLM flags: none (the judge emits raw JSON in `content`; Pydantic parses).
|
| 55 |
- TTS is separate from drafter/judge and only called from `make_audio` when the user clicks **Read aloud**.
|
| 56 |
-
- Drafter and judge use `min_containers=
|
| 57 |
-
- TTS also uses `min_containers=
|
|
|
|
| 58 |
|
| 59 |
## Live URLs
|
| 60 |
|
|
|
|
| 53 |
- Drafter vLLM flags: `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4`.
|
| 54 |
- Judge vLLM flags: none (the judge emits raw JSON in `content`; Pydantic parses).
|
| 55 |
- TTS is separate from drafter/judge and only called from `make_audio` when the user clicks **Read aloud**.
|
| 56 |
+
- Drafter and judge use `min_containers=0` and `scaledown_window=2 * MINUTES` so containers fall to zero when idle. This keeps the GPU bill under control for the 3-day demo. The Space fires a background warmup ping to each endpoint on import, so the cold start happens while the parent is reading the welcome screen; the first real request then lands on a warm container.
|
| 57 |
+
- TTS also uses `min_containers=0` (same policy) and runs on `L4` instead of A10G because VoxCPM2 is small enough for a cheaper/newer GPU class.
|
| 58 |
+
- Drafter and judge vLLM flags also include `--enforce-eager` to skip CUDA-graph capture. This trades a small amount of steady-state throughput for a much faster first-token time after cold start, which is the right tradeoff for a demo where first-token latency matters more than tokens/sec.
|
| 59 |
|
| 60 |
## Live URLs
|
| 61 |
|
|
@@ -92,10 +92,12 @@ All three models sit comfortably under the **32B cap** β Fabella uses **10B of
|
|
| 92 |
- **HF Space (CPU)** β custom HTML + CSS + JS frontend served by `gradio.Server` (FastAPI subclass). Chat-style, parent-friendly UI: welcome screen with example situations, alternating parent / Fabella turns, per-turn Read-aloud button, no default Gradio chrome.
|
| 93 |
- **HF OAuth** β enabled for personalization; unsigned users fall back to browser-local anonymous sessions.
|
| 94 |
- **HF Bucket per-user JSON** β minimal chat history and parent preferences persist at `/data/fabella-data/user-<owner_key>.json` (signed-in users keyed by HF username, anonymous users keyed by a `localStorage` session ID).
|
| 95 |
-
- **Modal** β one app, three web servers:
|
| 96 |
-
- **Drafter** (A10G) β vLLM with `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4`
|
| 97 |
-
- **Judge** (A10G) β vLLM with no tool-calling flags
|
| 98 |
-
- **TTS** (L4) β VoxCPM2 wrapped in a tiny FastAPI app
|
|
|
|
|
|
|
| 99 |
- **LangChain 1.x** ReAct loop with a custom middleware (`FabellaAgentMiddleware`) that jumps to `end` after a successful validation or after a hard cap of two tool calls. The `@hook_config(can_jump_to=["end"])` is required β without it the early-exit silently does nothing.
|
| 100 |
- **Pydantic v2** for the judge's structured output. `JudgeVerdict` has five fields (`ok`, `issues`, `score`, `verdict`, `reasoning`); cross-field consistency (`ok` β `verdict`) is enforced in code, not in the prompt.
|
| 101 |
|
|
|
|
| 92 |
- **HF Space (CPU)** β custom HTML + CSS + JS frontend served by `gradio.Server` (FastAPI subclass). Chat-style, parent-friendly UI: welcome screen with example situations, alternating parent / Fabella turns, per-turn Read-aloud button, no default Gradio chrome.
|
| 93 |
- **HF OAuth** β enabled for personalization; unsigned users fall back to browser-local anonymous sessions.
|
| 94 |
- **HF Bucket per-user JSON** β minimal chat history and parent preferences persist at `/data/fabella-data/user-<owner_key>.json` (signed-in users keyed by HF username, anonymous users keyed by a `localStorage` session ID).
|
| 95 |
+
- **Modal** β one app, three web servers, all `min_containers=0` with a 2-minute `scaledown_window` so they cold-start on demand (3-day demo budget):
|
| 96 |
+
- **Drafter** (A10G) β vLLM with `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4 --enforce-eager`
|
| 97 |
+
- **Judge** (A10G) β vLLM with `--enforce-eager` (no tool-calling flags; Nemotron's tool-call dialect isn't a vLLM built-in)
|
| 98 |
+
- **TTS** (L4) β VoxCPM2 wrapped in a tiny FastAPI app on the smallest GPU that fits
|
| 99 |
+
- **`--enforce-eager`** on both vLLM servers skips CUDA-graph capture. Saves 20β40s of cold start at a small per-token throughput cost; the right tradeoff for a demo where first-token latency matters more than tokens/sec.
|
| 100 |
+
- **Cold-start warmup ping** on Space import: `app.py::_warm_modal_endpoints` fires a non-blocking `/health` request to each endpoint from a daemon thread. The cold start happens while the parent is reading the welcome screen; the first real request lands on a warm container.
|
| 101 |
- **LangChain 1.x** ReAct loop with a custom middleware (`FabellaAgentMiddleware`) that jumps to `end` after a successful validation or after a hard cap of two tool calls. The `@hook_config(can_jump_to=["end"])` is required β without it the early-exit silently does nothing.
|
| 102 |
- **Pydantic v2** for the judge's structured output. `JudgeVerdict` has five fields (`ok`, `issues`, `score`, `verdict`, `reasoning`); cross-field consistency (`ok` β `verdict`) is enforced in code, not in the prompt.
|
| 103 |
|
|
@@ -125,6 +125,49 @@ except Exception as e:
|
|
| 125 |
print(f"[traces] publisher failed to start: {type(e).__name__}: {e}", flush=True)
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
|
|
|
|
| 125 |
print(f"[traces] publisher failed to start: {type(e).__name__}: {e}", flush=True)
|
| 126 |
|
| 127 |
|
| 128 |
+
def _warm_modal_endpoints() -> None:
|
| 129 |
+
"""Best-effort warmup of the Modal drafter, judge, and TTS endpoints.
|
| 130 |
+
|
| 131 |
+
All three Modal containers are deployed with ``min_containers=0`` to
|
| 132 |
+
keep the GPU bill under control for the 3-day demo. The first real
|
| 133 |
+
request after a quiet period therefore pays a 60-120s cold start on
|
| 134 |
+
the LLM containers (image import + vLLM init + model load).
|
| 135 |
+
|
| 136 |
+
This routine pings each endpoint's ``/health`` route from a daemon
|
| 137 |
+
thread as soon as the Space boots, so the cold start happens in the
|
| 138 |
+
background while the parent reads the welcome screen. The first
|
| 139 |
+
real parent click then lands on a warm container. Failures are
|
| 140 |
+
silent; the warmup is a hint, not a requirement.
|
| 141 |
+
"""
|
| 142 |
+
|
| 143 |
+
def _ping(url: str) -> None:
|
| 144 |
+
if not url:
|
| 145 |
+
return
|
| 146 |
+
try:
|
| 147 |
+
import urllib.request
|
| 148 |
+
|
| 149 |
+
with urllib.request.urlopen(f"{url.rstrip('/')}/health", timeout=300) as r:
|
| 150 |
+
r.read()
|
| 151 |
+
except Exception as e:
|
| 152 |
+
print(f"[warmup] {url} ping failed: {type(e).__name__}: {e}", flush=True)
|
| 153 |
+
|
| 154 |
+
import threading
|
| 155 |
+
|
| 156 |
+
def _all() -> None:
|
| 157 |
+
for label, url in (
|
| 158 |
+
("drafter", MODAL_DRAFTER_URL),
|
| 159 |
+
("judge", MODAL_JUDGE_URL),
|
| 160 |
+
("tts", MODAL_TTS_URL),
|
| 161 |
+
):
|
| 162 |
+
print(f"[warmup] pinging {label} at {url}", flush=True)
|
| 163 |
+
threading.Thread(target=_ping, args=(url,), daemon=True, name=f"warmup-{label}").start()
|
| 164 |
+
|
| 165 |
+
threading.Thread(target=_all, daemon=True, name="fabella-warmup").start()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
_warm_modal_endpoints()
|
| 169 |
+
|
| 170 |
+
|
| 171 |
|
| 172 |
|
| 173 |
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Fabella inference servers on Modal.
|
| 2 |
|
| 3 |
-
Three independent web_servers in one app, each on its own A10G
|
|
|
|
| 4 |
|
| 5 |
serve_drafter (port 8000) β Gemma 4 E4B-IT (4B). Generates explanations.
|
| 6 |
serve_judge (port 8001) β Nemotron-3 Nano 4B. Scores the draft against
|
|
@@ -11,8 +12,49 @@ The judge runs after the drafter; if the verdict is "revise", the
|
|
| 11 |
drafter is re-invoked. This is the cheapest way to get model-driven
|
| 12 |
quality control without a parallel-multi-agent setup.
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import os
|
|
@@ -132,18 +174,26 @@ def download_judge(force: bool = False):
|
|
| 132 |
|
| 133 |
MINUTES = 60
|
| 134 |
|
| 135 |
-
# Demo latency policy:
|
| 136 |
-
# -
|
| 137 |
-
#
|
| 138 |
-
#
|
| 139 |
-
#
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
TTS_GPU = "L4"
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
def _vllm_cmd(model_dir: Path, served_name: str, port: int, extra: list[str]) -> list[str]:
|
| 146 |
-
|
| 147 |
"vllm", "serve",
|
| 148 |
str(model_dir),
|
| 149 |
"--host", "0.0.0.0",
|
|
@@ -152,15 +202,20 @@ def _vllm_cmd(model_dir: Path, served_name: str, port: int, extra: list[str]) ->
|
|
| 152 |
"--uvicorn-log-level", "info",
|
| 153 |
"--max-model-len", "8192",
|
| 154 |
"--gpu-memory-utilization", "0.90",
|
| 155 |
-
|
| 156 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
@app.function(
|
| 160 |
image=vllm_image,
|
| 161 |
gpu="A10G",
|
| 162 |
min_containers=LLM_MIN_CONTAINERS,
|
| 163 |
-
scaledown_window=
|
| 164 |
timeout=10 * MINUTES,
|
| 165 |
volumes={MODEL_PATH: model_volume, "/root/.cache/vllm": vllm_cache_volume},
|
| 166 |
)
|
|
@@ -186,7 +241,7 @@ def serve_drafter():
|
|
| 186 |
image=vllm_image,
|
| 187 |
gpu="A10G",
|
| 188 |
min_containers=LLM_MIN_CONTAINERS,
|
| 189 |
-
scaledown_window=
|
| 190 |
timeout=10 * MINUTES,
|
| 191 |
volumes={MODEL_PATH: model_volume, "/root/.cache/vllm": vllm_cache_volume},
|
| 192 |
)
|
|
@@ -320,7 +375,7 @@ def download_tts(force: bool = False):
|
|
| 320 |
image=tts_image,
|
| 321 |
gpu=TTS_GPU,
|
| 322 |
min_containers=TTS_MIN_CONTAINERS,
|
| 323 |
-
scaledown_window=
|
| 324 |
timeout=10 * MINUTES,
|
| 325 |
volumes={MODEL_PATH: model_volume},
|
| 326 |
)
|
|
|
|
| 1 |
"""Fabella inference servers on Modal.
|
| 2 |
|
| 3 |
+
Three independent web_servers in one app, each on its own A10G (drafter,
|
| 4 |
+
judge) or L4 (TTS):
|
| 5 |
|
| 6 |
serve_drafter (port 8000) β Gemma 4 E4B-IT (4B). Generates explanations.
|
| 7 |
serve_judge (port 8001) β Nemotron-3 Nano 4B. Scores the draft against
|
|
|
|
| 12 |
drafter is re-invoked. This is the cheapest way to get model-driven
|
| 13 |
quality control without a parallel-multi-agent setup.
|
| 14 |
|
| 15 |
+
Budget policy (hackathon demo, 3 days)
|
| 16 |
+
------------------------------------
|
| 17 |
+
All three containers run with ``min_containers=0`` and a short
|
| 18 |
+
``scaledown_window`` so they fall to zero within a couple of minutes
|
| 19 |
+
of the last request. Modal only bills for the actual cold-start + serve
|
| 20 |
+
windows. This is fine for a demo where one parent click every few
|
| 21 |
+
minutes is the worst case, and it keeps the GPU bill under control.
|
| 22 |
+
|
| 23 |
+
Cold-start cost on a fresh container (today, before any caching):
|
| 24 |
+
|
| 25 |
+
* Image pull + import: 30β60s (vLLM image, torch, CUDA libs)
|
| 26 |
+
* Model load to VRAM: 10β20s (4B BF16 β 8 GB)
|
| 27 |
+
* vLLM CUDA-graph build: 20β40s
|
| 28 |
+
|
| 29 |
+
So end-to-end cold start is roughly 60β120s for the LLMs, 30β60s for
|
| 30 |
+
VoxCPM2 on L4. Subsequent requests on a warm container are sub-second.
|
| 31 |
+
|
| 32 |
+
The most effective mitigations, in order:
|
| 33 |
+
|
| 34 |
+
1. **Pre-bake weights into the image** via ``Image.run_function``. The
|
| 35 |
+
first cold start pulls image+weights in one go, then CUDA-graph
|
| 36 |
+
build dominates. vLLM's default CUDA-graph capture is the long
|
| 37 |
+
pole.
|
| 38 |
+
2. **Skip CUDA-graph capture** with ``--enforce-eager`` for the demo.
|
| 39 |
+
Drops cold start by ~20β40s. Trades a small amount of throughput
|
| 40 |
+
for much faster first-token.
|
| 41 |
+
3. **Smaller judge** is not the lever here β the bottleneck is
|
| 42 |
+
vLLM's import + compile, not 4B vs 7B.
|
| 43 |
+
4. **Space-side warmup ping** on Space startup keeps the first parent
|
| 44 |
+
request warm (see ``app.py`` ``/health`` pattern). The cold
|
| 45 |
+
request still happens β just not in front of a parent.
|
| 46 |
+
|
| 47 |
+
Volume layout
|
| 48 |
+
-------------
|
| 49 |
+
Weights live on a single Modal Volume (``fabella-models``) and are
|
| 50 |
+
loaded by the inference containers at start. The first deploy also
|
| 51 |
+
materializes them into the vLLM image so warm-cold-start benefits from
|
| 52 |
+
the image-layer cache.
|
| 53 |
+
|
| 54 |
+
.. note::
|
| 55 |
+
Re-deploys after editing this file rebuild the vLLM image from
|
| 56 |
+
scratch; that one-time cost is ~5 min. Subsequent redeploys are
|
| 57 |
+
fast because the layers are cached.
|
| 58 |
"""
|
| 59 |
|
| 60 |
import os
|
|
|
|
| 174 |
|
| 175 |
MINUTES = 60
|
| 176 |
|
| 177 |
+
# Demo latency / cost policy:
|
| 178 |
+
# - All three containers run cold. min_containers=0 means Modal only spins
|
| 179 |
+
# up a container when a request arrives; the short scaledown_window
|
| 180 |
+
# tears it down after the parent-facing flow goes idle. This is the
|
| 181 |
+
# cheapest way to ship a 3-day demo on a hackathon budget.
|
| 182 |
+
# - Cold start on a fresh A10G vLLM container is 60-120s today; the Space
|
| 183 |
+
# frontend shows a "warming up" hint the first time and the parent's
|
| 184 |
+
# actual request sees a warm container.
|
| 185 |
+
# - We force --enforce-eager to skip vLLM's CUDA-graph capture (saves
|
| 186 |
+
# 20-40s of cold start) at a small per-token throughput cost. Fine
|
| 187 |
+
# for a demo where first-token latency matters more than tokens/sec.
|
| 188 |
+
LLM_MIN_CONTAINERS = 0
|
| 189 |
+
TTS_MIN_CONTAINERS = 0
|
| 190 |
+
SCALEDOWN_WINDOW_S = 2 * MINUTES # tear down after 2 min of no traffic
|
| 191 |
TTS_GPU = "L4"
|
| 192 |
+
ENFORCE_EAGER = True
|
| 193 |
|
| 194 |
|
| 195 |
def _vllm_cmd(model_dir: Path, served_name: str, port: int, extra: list[str]) -> list[str]:
|
| 196 |
+
cmd = [
|
| 197 |
"vllm", "serve",
|
| 198 |
str(model_dir),
|
| 199 |
"--host", "0.0.0.0",
|
|
|
|
| 202 |
"--uvicorn-log-level", "info",
|
| 203 |
"--max-model-len", "8192",
|
| 204 |
"--gpu-memory-utilization", "0.90",
|
| 205 |
+
"--enforce-eager", # cold-start: skip CUDA-graph capture
|
| 206 |
]
|
| 207 |
+
if ENFORCE_EAGER:
|
| 208 |
+
# Re-asserted for clarity; the flag is already in `cmd`.
|
| 209 |
+
pass
|
| 210 |
+
cmd.extend(extra)
|
| 211 |
+
return cmd
|
| 212 |
|
| 213 |
|
| 214 |
@app.function(
|
| 215 |
image=vllm_image,
|
| 216 |
gpu="A10G",
|
| 217 |
min_containers=LLM_MIN_CONTAINERS,
|
| 218 |
+
scaledown_window=SCALEDOWN_WINDOW_S,
|
| 219 |
timeout=10 * MINUTES,
|
| 220 |
volumes={MODEL_PATH: model_volume, "/root/.cache/vllm": vllm_cache_volume},
|
| 221 |
)
|
|
|
|
| 241 |
image=vllm_image,
|
| 242 |
gpu="A10G",
|
| 243 |
min_containers=LLM_MIN_CONTAINERS,
|
| 244 |
+
scaledown_window=SCALEDOWN_WINDOW_S,
|
| 245 |
timeout=10 * MINUTES,
|
| 246 |
volumes={MODEL_PATH: model_volume, "/root/.cache/vllm": vllm_cache_volume},
|
| 247 |
)
|
|
|
|
| 375 |
image=tts_image,
|
| 376 |
gpu=TTS_GPU,
|
| 377 |
min_containers=TTS_MIN_CONTAINERS,
|
| 378 |
+
scaledown_window=SCALEDOWN_WINDOW_S,
|
| 379 |
timeout=10 * MINUTES,
|
| 380 |
volumes={MODEL_PATH: model_volume},
|
| 381 |
)
|