Skip to main content

View on GitHub

Full source for this example in fal-serverless-examples.
Every GPU cold start pays three costs before it can serve a request. It downloads the weights, deserializes them onto the GPU, and compiles kernels for the shapes the model will run. On WAN 2.1 text-to-video, the compile step dominates. 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.
FlashPack packs the state_dict into dtype-grouped contiguous macroblocks with a weight map at the file end, then streams the file disk-to-GPU in overlapping chunks and reconstructs tensors as zero-copy views — versus safetensors' tensor-by-tensor CPU deserializationFlashPack packs the state_dict into dtype-grouped contiguous macroblocks with a weight map at the file end, then streams the file disk-to-GPU in overlapping chunks and reconstructs tensors as zero-copy views — versus safetensors' tensor-by-tensor CPU deserialization

Running the Example

  1. Install fal:
  1. Authenticate (if not already done):
  1. Clone the examples repository and enter this example’s directory:
  1. Run the app:
This starts an ephemeral app on a real H100 and prints its playground and API URLs. 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.
To compare directly, run 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
Next comes the request shape. Every 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
On the response side, we return the CDN-hosted video, the seed that produced it, and one server-side timing. timings.generate_s covers denoise, VAE decode, and mp4 encode combined, and excludes the upload.
app.py
A live deploy of this exact code returned the response below for a fast, cheap request (num_frames=17, num_inference_steps=8, seed=42). fal CDN links have limited retention, so treat the url as illustrative, not durable:
Finally, one more schema exists outside the generation contract. /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 wraps torch.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
With all three optimizations in place, one thing is still missing: a way to know whether both caches are complete. 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

With setup() 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

This registers 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
JavaScript
curl (queue API)

Measuring the Before and After

Here is what a real measured deploy of both apps looked like:
Stacked bar chart of measured cold-start stage timings on GPU-H100 (2026-08-12). Baseline setup() takes 275.25 s: imports 26.26, weights 46.09, compile + warmup 202.9. The optimized first boot, one-time per account per pack version, takes 467.95 s: imports 54.61, weights (download, pack, publish, load) 214.49, compile + warmup 198.75. The optimized steady state takes 96.46 s, roughly 3x faster than the baseline: imports 11.0, FlashPack weights load 7.66, cached compile + warmup 77.79.Stacked bar chart of measured cold-start stage timings on GPU-H100 (2026-08-12). Baseline setup() takes 275.25 s: imports 26.26, weights 46.09, compile + warmup 202.9. The optimized first boot, one-time per account per pack version, takes 467.95 s: imports 54.61, weights (download, pack, publish, load) 214.49, compile + warmup 198.75. The optimized steady state takes 96.46 s, roughly 3x faster than the baseline: imports 11.0, FlashPack weights load 7.66, cached compile + warmup 77.79.
Three disclosures apply to this chart:
  • 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 -> healthy spread (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_VERSION bump, or a brand-new account, pays it again.
These are one-time measurements, not a guarantee. The example repo’s 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=60 holds a warm runner for 60 seconds after the last request. Raise it, or set min_concurrency, to cut cold starts further on traffic with longer gaps. See Adjust Scaling Parameters.
  • Bump PACK_VERSION whenever MODEL_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.compile step
    • the app rarely scales from zero, because a high keep_alive or min_concurrency solves that more simply
    • the model is small and a cold start already takes a few seconds
    See Optimize Cold Starts for the general playbook.

Next Steps

For more examples, from LoRA serving to 3D streaming, browse the fal-serverless-examples repository.