View on GitHub
Full source for this example in fal-serverless-examples.
torch.compile
alone takes roughly three-quarters of a from-scratch boot, more than the download.
In this example, we serve the same model twice: once with every optimization
applied, and once with none.
Running the Example
- Install fal:
- Authenticate (if not already done):
- Clone the examples repository and enter this example’s directory:
- Run the app:
fal run keeps the app alive until you press Ctrl-C.
The first run downloads the ~29 GB fp32 repo, builds the FlashPack pack, and compiles
kernels. This takes roughly nine minutes from spawn to healthy, once per account per pack
version. The first-boot bar in the measured
chart below shows where
setup() spends that
time. That measurement used fal’s warm platform Hugging Face cache. A brand-new account’s
first download runs meaningfully longer. Every run after that reuses both /data
artifacts, effectively bypassing the time needed for this step entirely.fal run app_baseline.py alongside it. The baseline app
serves the identical schema with none of the optimizations applied.
Sharing One /data Volume Across Every Runner
This example applies three optimizations, and all three build on one shared/data
volume, an account-wide disk
that outlives any single runner. The first runner to boot builds each artifact once.
Every runner after it reads that artifact instead of rebuilding it. The code
comments label the three optimizations LEVER 1 to LEVER 3.
Optimization 1 — /data persistent storage. The Hub repo ships fp32 weights. The first
boot for a given pack version downloads them, rebuilds the pipeline at serving
dtypes, and packs it. Every later boot skips this step entirely.
Optimization 2 — FlashPack loading.
FlashPack streams packed weights disk-to-GPU and rebuilds each tensor as a zero-copy
view, with no CPU deserialization step. The baseline pays safetensors’ slower
tensor-by-tensor load instead. The diagram at the top of this page contrasts both
load paths. See the FlashPack doc for the packing format.
Optimization 3 — a shared kernel cache.
synchronized_inductor_cache
compiles torch.compile kernels once per GPU type and syncs them to /data. Every
later boot on that GPU type unpacks the cached kernels instead of recompiling.
Kernel caches are organized per GPU
type. A cache built on an H100 gives an
L40 boot nothing. This app pins machine_type to one type, so every boot hits the
same cache and the measured numbers stay comparable. A fallback list works too.
Each GPU type then pays its own one-time compile.
From here, we walk app.py from top to bottom.
Defining the Request and Response Schemas
We start with the imports and the constants that everything else in the app builds on.wan_flashpack defines the four FlashPack-mixed-in classes this example needs
(wan_flashpack.py
— referenced here, not walked line by line). Importing it does nothing by itself.
setup() calls wan_flashpack.get_classes(), and only then does the module build
the classes and register itself in sys.modules. That self-registration matters
because local_python_modules ships the file to the runner via cloudpickle
by-value, which reconstructs the module without a real sys.modules entry. Both
transformers’ model loading and diffusers’ pipeline loader need that entry. See
FlashPack for the packing
format these classes wrap.
PACK_DIR and INDUCTOR_CACHE_KEY both carry PACK_VERSION. We bump the version
whenever MODEL_ID, serving dtypes, or the torch/diffusers/flashpack pins change, so
new pins can never load an old pack or old kernels. READY_MARKER is the file that later marks both
caches complete.
app.py
Field carries a description, and fields with
examples power both the generated playground
form and this page’s own deterministic requests below. The validator enforces WAN
2.1’s frame rule: num_frames must satisfy (n - 1) % 4 == 0. seed is optional.
The server picks one when omitted, and echoes it back so the same seed and inputs
reproduce a comparable clip.
app.py
timings.generate_s covers denoise, VAE decode, and mp4
encode combined, and excludes the upload.
app.py
num_frames=17, num_inference_steps=8, seed=42). fal CDN links have
limited retention, so treat the url as illustrative, not durable:
/setup-report
is a diagnostic endpoint. It returns what a given runner’s setup() spent its time
on: which variant produced it, which GPU it booted on, whether this boot built the
caches or found them warm, and the per-stage timings. Ephemeral runners expose only
coarse scheduling states over the platform API, so the per-stage figures in the
measured cold starts chart below come from these
in-app timers, not from the platform. The spawn-to-healthy times in the chart’s
disclosures are the one exception, because no in-app timer can see them. The
example’s bench.py measures those from outside, as wall-clock time from deploy
to first healthy response.
app.py
Configuring the App and Pinning One GPU
With the schemas defined, we can turn to the app class itself.machine_type = "GPU-H100" pins exactly one GPU type
rather than a fallback list, for the cache-consistency reasons described above.
local_python_modules = ["wan_flashpack"] ships that file to the runner.
startup_timeout = 2700 covers the slowest boot this app will ever see, the
account’s first-ever run. That run downloads, packs, and compiles for real.
requirements is the authoritative dependency list fal installs at deploy time.
requirements.txt mirrors it exactly for local runs and CI.
app.py
setup() times every stage with a small stage() helper and reports all of them
through /setup-report. The first stage calls wan_flashpack.get_classes(), which
builds the four FlashPack classes and installs the sys.modules entry described
above. This call is the only place in app.py where that import actually resolves.
app.py
Packing the Model onto /data, Once
Now the first optimization.self.built_caches_this_boot records whether
READY_MARKER was missing when this runner booted. /setup-report surfaces this
flag, so a caller can tell a cache-building boot from a steady-state one. When
PACK_DIR does not exist, this runner downloads the fp32 repo, rebuilds the
pipeline at serving dtypes (bf16 transformer and text encoder, fp32 VAE), and packs
it with FlashPack’s save_pretrained_flashpack. The runner writes the pack to
local disk first, then publishes it to /data with one bulk copy and an atomic
rename. Chunked writes straight to /data are very slow. The atomic rename keeps
concurrently booting runners from reading a half-written pack. The runner that
loses the race cleans up its own copy.
app.py
Loading 15 GB in Seconds with FlashPack
Every boot, first or steady-state, reaches this stage.from_pretrained_flashpack streams the pack from /data to the GPU directly, with
no CPU deserialization step. This is the
FlashPack loading path in the
diagram at the top of this page. One integration caveat applies here, as of
flashpack==0.4.0 (the version this example pins). transformers ties
UMT5EncoderModel’s encoder.embed_tokens.weight to shared.weight. FlashPack’s
loader assigns packed tensors by replacing each Parameter object, which silently
breaks that tie (the pack deliberately omits the tied name). setup() loads the
text encoder itself, restores the tie through the official set_input_embeddings
API, and hands the finished module to the pipeline loader as a keyword argument.
The loader uses a component passed that way as-is and skips the broken path. The
loop right after checks every component for leftover meta tensors and fails loudly
instead of crashing later with an opaque device error.
app.py
Compiling Once, Reusing the Kernels Everywhere
The third optimization wrapstorch.compile(self.pipe.transformer, dynamic=True) inside
synchronized_inductor_cache.
The first boot for INDUCTOR_CACHE_KEY compiles for real and syncs the kernels to
/data when the context exits. Every later boot on the same GPU type unpacks those
kernels instead of recompiling, and the warmup loop becomes a cache-hit execution
instead of a compile. Warmup must run inside the context. Kernels compiled outside
it are never captured.
app.py
READY_MARKER answers that question. setup()
writes it atomically, through a temp file and a rename, only after both the /data
pack and the synced kernel cache exist. A runner that crashes mid-setup() leaves
no marker, so the next boot finds an incomplete cache and rebuilds it instead of
trusting a half-finished one. The final three lines record this boot’s per-stage
timings, its total setup() time, and its GPU name. All three feed
/setup-report.
app.py
Serving Requests
Withsetup() done, two endpoints are left. generate is the endpoint every
request hits. It denoises at the caller’s frame count and step count, encodes the
frames to mp4, and uploads the file to fal’s CDN.
app.py
setup_report is the diagnostic endpoint behind the measured cold
starts chart further down this page. It returns
this runner’s timings without running any generation.
app.py
Deploying to Production
cold-start-optimized and prints its playground and API URLs. fal
derives the registered name from the App class name, ColdStartOptimized, not
from the filename. Video generation takes tens of seconds to minutes at production
settings, so always call the endpoint through the queue
API, never through raw synchronous
HTTP.
Python
Measuring the Before and After
Here is what a real measured deploy of both apps looked like:- A warm Hugging Face cache. Two accounts produced these numbers: one ran the
baseline and steady-state measurements, and a second ran the dedicated first
boot. Both had a warm
/data-backed platform HF cache on every download stage. The pack build (179.79 s) and the baseline weights segment reflect that cache, not a genuinely cold network fetch. A brand-new account sees those stages run meaningfully higher. - Allocation contention, not model work. The steady-state
spawn -> healthyspread (114.7 s to 267.3 s) came from GPU-allocation contention on a shared account. The model work itself was stable. - The first boot repeats. The first-boot bar is one-time per account per
pack version. The next
PACK_VERSIONbump, or a brand-new account, pays it again.
bench.py
reproduces these numbers against your own account, one real ephemeral deploy per run.
Tuning Cost and Keep-Alive
-
Both apps bill from
setup()start through teardown. A cold start is not free just because no request has landed yet. See fal pricing. -
keep_alive=60holds a warm runner for 60 seconds after the last request. Raise it, or setmin_concurrency, to cut cold starts further on traffic with longer gaps. See Adjust Scaling Parameters. -
Bump
PACK_VERSIONwheneverMODEL_ID, serving dtypes, or the torch/diffusers/flashpack pins change. A stale pack or kernel cache built under old pins must never load under new ones. -
When to skip these optimizations:
- the model has little or no
torch.compilestep - the app rarely scales from zero, because a high
keep_aliveormin_concurrencysolves that more simply - the model is small and a cold start already takes a few seconds
- the model has little or no
Next Steps
- FlashPack: the packing format optimization 2 builds on.
- Optimize Startup with Compiled
Caches:
synchronized_inductor_cachein depth. - Optimize Cold Starts: the general cold-start playbook this example specializes.
- Adjust Scaling
Parameters:
keep_aliveandmin_concurrency, the settings that avoid cold starts entirely. - Persistent Storage: the
/datavolume both cached artifacts live on.