The text encoder is bigger than the video model
The obvious assumption is that a video generator is mostly a video model. It is not. Wan2.1 T2V 1.3B pairs a 1.3-billion-parameter diffusion transformer with UMT5-XXL, a text encoder several times its size.
These are the actual file sizes Saient distributes, all three packs verified by SHA-256 per file:
Diffusion transformer
Q4_K. The model that actually generates video. Identical in every pack.
Text encoder
UMT5-XXL at Q4_K_M. Roughly 4.5× the size of the video model.
VAE decoder
Turns latents into pixels. Also identical in every pack.
This inverts how you optimise. Saient ships three packs — 2.93 GB, 3.93 GB and 4.73 GB — and the diffusion transformer and VAE are byte-identical in all three. The only thing that changes is how hard the text encoder is squeezed: Q2_K at 1.86 GB, Q3_K_S at 2.86 GB, or Q4_K_M at 3.66 GB.
That is the right trade, because the two models have completely different duty cycles. The encoder runs once per prompt. The diffusion transformer runs once per step, twice per step if you use classifier-free guidance. Quantising the encoder harder costs you some prompt nuance and saves a lot of storage; quantising the transformer harder degrades every frame of every generation.
The one-billion-parameter lookup table
Here is the failure that is genuinely hard to diagnose, because the model files fit comfortably and the app still dies.
UMT5-XXL's token embedding table is 256,384 tokens by 4,096 dimensions. That is a single matrix with over a billion parameters. Quantised it is a manageable slice of that 3.66 GB file. Dequantised to 32-bit floats it is about 4.2 GB of RAM.
An embedding lookup is conceptually trivial — you want the rows for maybe five tokens. But if the runtime's row-gather routine does not support the quantisation format in question, it silently falls back to dequantising the entire table first, then indexing into it. You asked for five rows and materialised a billion parameters.
We hit exactly this. The bundled row-gather supported only one K-quant format, so the Q4_K and Q6_K paths fell through to a full dequantisation. Peak memory dropped from about 5,113 MB to 2,661 MB once rows were read directly from quantised storage — the difference between an app that gets killed by Android's low-memory daemon on launch and one that runs.
The general lesson for on-device inference: never let a vocabulary-sized matrix become a dense float array. Read the rows you need, in the format they are stored in. If you are debugging an out-of-memory kill on a model that should fit, check the embedding path before anything else.
The three models, and what each one does
1. UMT5-XXL — reading the prompt
A 24-block encoder, 4,096 model dimensions, a gated feed-forward of 10,240, and 64 attention heads of 64 dimensions each. It turns your prompt into a 512×4096 context matrix that the diffusion transformer attends to.
One detail matters and is easy to get wrong: this is UMT5, not standard T5. Standard T5 computes a relative-position bias once in the first layer and shares it across all layers. UMT5 has a separate bias table in every one of its 24 blocks. Implement the shared-bias variant by mistake and the model still runs, produces plausible-looking numbers, and is subtly wrong everywhere — the worst category of bug.
The prompt is padded to the full 512-token context. Because padding is masked and zeroed, valid positions only ever depend on other valid positions, which means the whole stack can be evaluated over the real tokens alone. For a five-token prompt that is a 100× reduction in work, with no approximation.
2. The diffusion transformer — generating the video
30 blocks, 1,536 dimensions, 12 heads of 128, and a feed-forward of 8,960. The latent video is chopped into patches of (1,2,2) — one frame deep, two by two in space — and each patch becomes a token.
Position is three-dimensional here, which is the main thing that separates video from image generation. The 128-dimension head is split 44 + 42 + 42 across time, height and width, and each axis gets its own rotary embedding over its slice. Get the split order wrong and every patch is placed in the wrong part of spacetime.
Wan uses flow matching rather than classic DDPM-style noise prediction: the model predicts a velocity field and you integrate it. That is why the step counts are so low — useful output in 8 steps where an older sampler wanted 30 or 50. On a phone, where every step is expensive, that difference is the entire reason this is feasible.
3. The 3D causal VAE — turning latents into pixels
The decoder upsamples 4× in time and 8× in space, so a small latent grid becomes a real video. It is causal: its 3D convolutions pad two frames at the front and none at the back, so a frame never depends on frames that come after it.
That causality is what makes chunked decoding possible. The decoder carries a 32-slot feature cache holding the trailing context of each convolution, so it can decode a couple of frames at a time and still produce output identical to decoding everything at once. On a device that cannot hold the full decoded video in memory, this is not an optimisation — it is the only way the stage runs at all.
Why Vulkan, and not the alternatives
CUDA is not an option — it is NVIDIA hardware only, and no phone has it. Mobile NPU paths through NNAPI are designed around the operations common in vision and language models, and a video diffusion transformer with 3D rotary embeddings, adaptive layer normalisation and causal 3D convolutions is not a comfortable fit. CPU inference is possible and far too slow to be pleasant.
That leaves Vulkan compute: available on essentially every modern Android GPU regardless of vendor, and low-level enough to express whatever the model needs. The cost is that you write the kernels yourself. Quartz, the engine behind this, ships 22 hand-written GLSL compute shaders — GEMM, attention, convolution, normalisation, rotary embedding, patch layout, temporal cache operations — compiled to SPIR-V and embedded in the binary.
Memory is the other half. A phone shares one memory pool between the GPU, the OS and every other app, and Android will kill you long before you exhaust it. So weights are staged: a block's matrices are uploaded, used, and released before the next block is staged. That keeps a multi-gigabyte encoder resident in tens of megabytes at any instant, at the cost of moving a lot of data. It is the correct trade on a device where being killed is the failure mode.
How do you know the output is right?
This is the part that gets skipped, and it is the part that matters most.
You cannot debug a video diffusion model by looking at the frames. A wrong rotary embedding, a wrong modulation and a wrong VAE scale factor all produce output that looks like the same species of garbage. If your only test is "does it look plausible", you will ship something subtly broken and never know.
So Saient rebuilt the entire pipeline from scratch in Rust — scheduler, tokenizer, UMT5 encoder, rotary embeddings, the 30-block transformer and the 3D causal VAE — against a numerical parity harness. Each stage is compared against the reference engine independently, on captured real tensors, before the next stage is written.
The measured agreement, by cosine similarity against the reference:
UMT5 encoder
24 blocks over the full 512-token context.
Diffusion transformer
30 blocks, 780 tokens of latent video.
3D causal VAE
Full-resolution decode to 240×416, 5 frames.
Whole pipeline
Prompt to pixels, no external runtime.
That approach caught bugs that were completely invisible in the output: a transposed bias table, unzeroed padding, an inverted channel order in the patch reassembly. Each one produced frames that looked exactly as wrong as correct ones, and each one was obvious as a number.
There is no single correct answer
While measuring the above, we found something worth publishing, because it changes how anyone should interpret this class of comparison.
We took the reference engine and ran it twice — same binary, same weights, same prompt, same seed — changing exactly one thing: whether flash attention was enabled. Both are legitimate, widely used code paths for the same mathematics.
The engine disagreed with itself:
Velocity, max difference
Between the engine's own two attention paths.
Pixels, max difference
On a 0–1 scale, from that same single flag.
Our independent Rust implementation differs from that engine by 0.184 in the same pixel measurement — less than the engine differs from itself. Higher cosine similarity, smaller maximum error, smaller mean error, on all three metrics.
The practical consequence: at this precision there is no unique target to converge on, only a band. Anyone chasing a stage-level numerical difference to zero against a reference implementation should first measure what that reference does when you flip one of its own arithmetic flags. The answer may be larger than the difference being chased.
Both figures come from controlled runs: with the flag matched to the original capture, the rebuilt binary reproduced the stored reference output bit-exactly, so the comparison isolates the arithmetic path and nothing else.
What ships today, and what does not
The Android app currently generates video through a pinned, MIT-licensed C++ engine cross-compiled for Android and Vulkan. That is what runs on the phone right now, and it is fast.
The from-scratch Rust implementation described above is numerically complete and verified, and not yet fast enough to replace it. On a desktop GPU it takes around 639 seconds for a five-frame clip, against roughly a second for the C++ engine. The Vulkan graph is deliberately unfused — every operation dispatched separately — because that is what made it possible to identify which primitive diverged first. Fusing those dispatches into real kernels is the next piece of work, and it is substantial.
We would rather say that plainly than imply the rewrite is finished. Correct came first, on purpose. Fast is next.
Where the code is
Quartz, the inference engine, is MIT licensed and public. The parity harness and its reference fixtures are in the repository, so the numbers on this page can be checked rather than taken on faith.
Credit where it is due. Wan2.1 is the work of the Wan-Video team and this page describes their architecture, not ours. The reference engine used throughout for verification is a fork of the MIT-licensed stable-diffusion.cpp. Our contribution is the on-device engineering, the independent reimplementation, and the measurements above.
Wan2.1 on mobile — questions
Why is the text encoder bigger than the video model?
Wan2.1 T2V 1.3B pairs a 1.3-billion-parameter transformer with UMT5-XXL. Quantised, the video model is about 816 MB and the encoder about 3.66 GB. The encoder runs once per prompt while the transformer runs on every step, so the larger model is also the cheaper one overall.
How much storage do I need?
Between about 2.93 GB and 4.73 GB, depending on the pack. The video model and VAE are identical across all of them; only the text encoder quantisation differs.
Why does it run out of memory when the files clearly fit?
Almost always the embedding table. A 256,384 × 4,096 matrix dequantised to floats is about 4.2 GB of RAM for a lookup that needs a handful of rows. Read rows directly from quantised storage instead.
Will output match a desktop GPU exactly?
No, and no two implementations do. As measured above, a single reference engine disagrees with itself by up to 0.234 on a 0–1 pixel scale depending on one attention flag. Expect a band, not an exact match.
What resolution and length can a phone manage?
Saient's on-device profile is 416×240 at 8 frames per second, from 5 up to 41 frames. Video generation cost scales with pixels and frames together, so on-device work stays deliberately modest.