OpenCode commited on
Commit
c7dc151
Β·
1 Parent(s): df69d2f

perf(modal): cold-start on demand with eager-mode and warmup ping

Browse files

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

Files changed (4) hide show
  1. AGENTS.md +3 -2
  2. README.md +6 -4
  3. app.py +43 -0
  4. modal_app.py +70 -15
AGENTS.md CHANGED
@@ -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=1` while budget allows, so the critical-path LLMs stay warm for demos.
57
- - TTS also uses `min_containers=1` and runs on `L4` instead of A10G because VoxCPM2 is small enough for a cheaper/newer GPU class.
 
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
 
README.md CHANGED
@@ -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 (Nemotron's tool-call dialect isn't a vLLM built-in)
98
- - **TTS** (L4) β€” VoxCPM2 wrapped in a tiny FastAPI app, `min_containers=1` so Read-aloud has no cold start
 
 
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
 
app.py CHANGED
@@ -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
 
modal_app.py CHANGED
@@ -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
- All models live on the same Modal Volume (fabella-models) with distinct
15
- sub-directories so we only pay for one download per model.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # - The two 4B LLM endpoints sit on the critical path for every explanation, so
137
- # keep one warm replica while budget allows.
138
- # - TTS is also kept warm while budget allows; VoxCPM2 is small enough
139
- # for a cheaper/newer L4 instead of A10G.
140
- LLM_MIN_CONTAINERS = 1
141
- TTS_MIN_CONTAINERS = 1
 
 
 
 
 
 
 
142
  TTS_GPU = "L4"
 
143
 
144
 
145
  def _vllm_cmd(model_dir: Path, served_name: str, port: int, extra: list[str]) -> list[str]:
146
- return [
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
- *extra,
156
  ]
 
 
 
 
 
157
 
158
 
159
  @app.function(
160
  image=vllm_image,
161
  gpu="A10G",
162
  min_containers=LLM_MIN_CONTAINERS,
163
- scaledown_window=10 * MINUTES,
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=10 * MINUTES,
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=10 * MINUTES,
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
  )