TensorSharp runs the Wan 2.1 and
Wan 2.2 video diffusion models natively:
prompt (plus an optional first-frame image on the Wan 2.2 models) in, H.264 MP4
out, from both TensorSharp.Cli and TensorSharp.Server (OpenAI-style API +
the bundled Web UI chat).
Wan is the video-only family. The newer video model in TensorSharp is MiniMax-H3, which generates video and native 32 kHz stereo audio together in one packed latent — text-to-video, image-to-video, first/last frame and reference-to-video, CFG-free at 4–8 steps. Wan stays the family to reach for when you want video alone, and it is the one with the step-distilled checkpoints that cut the 100-DiT-pass recipe to 4.
Supported checkpoint families (auto-detected from the DiT GGUF):
| Family | Latent | VAE | Modes | Notes |
|---|---|---|---|---|
| Wan 2.1 T2V (1.3B / 14B) | 16 ch, 8×8×4 | wan_2.1_vae | T2V | single DiT |
| Wan 2.2 TI2V-5B | 48 ch, 16×16×4 | Wan2.2_VAE | T2V + I2V | dense 5B, 24 fps, 720p-capable |
| Wan 2.2 A14B (T2V / I2V) | 16 ch (36 ch I2V input) | wan_2.1_vae | T2V + I2V | two 14B experts switched at a timestep boundary |
For image-to-video the uploaded image becomes the first frame of the video and the text prompt drives motion, camera work, atmosphere and scene changes.
The networks cooperate per generation (all resolved automatically when placed
in one folder — subfolders like VAE/, HighNoise/, LowNoise/ included):
| Piece | File | Source |
|---|---|---|
DiT (the --model GGUF, general.architecture = wan) |
e.g. Wan2.2-TI2V-5B-Q8_0.gguf |
QuantStack/Wan2.2-TI2V-5B-GGUF, QuantStack/Wan2.2-I2V-A14B-GGUF, city96/Wan2.1-T2V-14B-gguf |
| second A14B expert (A14B only) | the matching …HighNoise…/…LowNoise… GGUF |
same repo (TS_WAN_DIT2 overrides; auto-found by name in the same/sibling folder) |
| UMT5-XXL text encoder | umt5-xxl-encoder-Q8_0.gguf |
city96/umt5-xxl-encoder-gguf (--video-text-encoder / TS_WAN_TE) |
| Wan 2.1 video VAE (2.1 + A14B) | wan_2.1_vae.safetensors |
Comfy-Org/Wan_2.1_ComfyUI_repackaged (--video-vae / TS_WAN_VAE) |
| Wan 2.2 video VAE (TI2V-5B) | Wan2.2_VAE.safetensors |
bundled in QuantStack/Wan2.2-TI2V-5B-GGUF |
The text encoder is released from VRAM before the denoise starts, the VAE
encoder (I2V) before the DiT loads, and the DiT before the VAE decode, so peak
VRAM is roughly max(TE, DiT + attention, VAE) — TI2V-5B Q8_0 generates
81-frame 480p image-to-video on a 16 GB GPU in under 8 minutes, and both A14B
Q4_K_M experts run sequentially on the same card.
Because several files have to line up, the ready-made configs in
config/ name every network and
download whatever is missing on the first run:
# Text-to-video AND image-to-video, 4-step distilled (~9.5 GB on first run).
TensorSharp.Server --config config/wan-video-ti2v-5b-turbo.json
TensorSharp.Cli --config config/wan-video-ti2v-5b-turbo.json \
--prompt "a red fox trotting through falling snow" --output fox.mp4
# Image-to-video: the image becomes frame 0, the prompt drives the motion.
TensorSharp.Cli --config config/wan-video-ti2v-5b-turbo.json \
--image first_frame.png --prompt "she turns and smiles" --output clip.mp4| Config | DiT | VAE | Text encoder | Modes | First-run download |
|---|---|---|---|---|---|
wan-video-ti2v-5b-turbo.json |
TI2V-5B Turbo (4-step) | Wan 2.2 | UMT5-XXL | T2V + I2V | ~9.5 GB |
wan-video-ti2v-5b.json |
TI2V-5B (50-step) | Wan 2.2 | UMT5-XXL | T2V + I2V | ~11.4 GB |
wan-video-i2v-a14b.json |
A14B high + low noise | Wan 2.1 | UMT5-XXL | I2V only | ~24 GB |
The models are stored wherever TENSORSHARP_MODELS points, or in a models/
folder next to the repository when it is unset — the configs contain no absolute
paths, so the same file works on Windows, Linux and macOS.
The two VAEs are not interchangeable: TI2V-5B needs the Wan 2.2 VAE (48-channel latent, 16×16×4), while A14B and the Wan 2.1 models need the Wan 2.1 VAE (16-channel, 8×8×4). Loading the wrong one fails at startup rather than producing bad video. UMT5-XXL is shared by all of them, so it downloads once.
To point at files you already have, use --video-vae, --video-text-encoder and (for A14B)
--video-dit2, or drop everything in one folder next to the DiT and let the
same-directory scan find it.
Video generation runs on every TensorSharp backend except MLX (--backend mlx
is rejected at load):
--backend |
Path | Notes |
|---|---|---|
ggml_cuda |
GGML whole-graph kernels | fastest; persistent DiT graph + CUDA-graph capture |
ggml_metal |
GGML whole-graph kernels | Apple Silicon; the VAE convolutions go through MPSGraph |
ggml_vulkan |
GGML whole-graph kernels | AMD/Intel/NVIDIA via Vulkan; the VAE stays on the banded conv path (Vulkan drivers reject multi-GB arenas) |
ggml_cpu |
GGML whole-graph kernels | CPU-only machines; slow but exact |
cuda |
direct CUDA (WanDirect*) |
ggml-independent: quantized weights resident via TensorSharp's own MMQ/dp4a/cuBLAS routing, streaming online-softmax attention kernels, channels-last im2col VAE |
cpu |
direct pure-C# (WanDirect*) |
no native dependencies beyond the managed runtime; parallel SIMD GEMM/attention kernels |
All backends produce numerically equivalent videos (verified against each other
and against diffusers' reference implementation on identical seeds/embeddings —
final-latent cosine ≥ 0.999 between backends, ≈ 0.994 vs the bf16 diffusers
reference with quantized checkpoints). The cpu / ggml_cpu backends are for
functional use — a 480p multi-second video takes tens of minutes on a
high-core-count machine.
Reference timings (RTX 2000 Ada 16 GB, Wan2.1-1.3B F16, 832×480, 33 frames,
30 UniPC steps — the official 480p recipe): ggml_cuda 12.0 s/step (445 s
total), ggml_vulkan 17.2 s/step (625 s), direct cuda 19.3 s/step (700 s).
On shorter sequences the direct backend matches ggml_cuda (TI2V-5B
480×480×9f: 1.1 s/step on both); at the 14k-token recipe its F32 attention
path trails ggml's F16 flash attention. Long-sequence attention routes through
chunked cuBLAS GEMM+softmax automatically (TS_WAN_DIRECT_FUSED_ATTN=0 forces
it everywhere).
Text-to-video (any family):
TensorSharp.Cli --model Wan2.2-TI2V-5B-Q8_0.gguf --backend ggml_cuda \
--prompt "a cute fluffy orange cat walking through a sunny garden with flowers" \
--output cat.mp4 --width 832 --height 480 --video-frames 49 --diffusion-seed 7Image-to-video (Wan 2.2 models — pass --image):
TensorSharp.Cli --model Wan2.2-TI2V-5B-Q8_0.gguf --backend ggml_cuda \
--prompt "the cat runs toward the camera, dynamic tracking shot, golden hour" \
--image first_frame.png --output cat_run.mp4 --video-frames 81# A14B: point --model at either expert; the sibling is found automatically
TensorSharp.Cli --model HighNoise/Wan2.2-I2V-A14B-HighNoise-Q4_K_M.gguf \
--backend ggml_cuda --prompt "the ship sails into the storm, waves crashing" \
--image ship.jpg --output ship.mp4--image <file>— first-frame conditioning (PNG/JPEG/WebP/…). EXIF orientation is honored. When no explicit--width/--heightis given, the output resolution follows the image's aspect ratio at the model's native area (TI2V-5B: 1280×704 = the official 720p recipe; others: 832×480).--video-frames N— frames, snapped to4k+1;--video-frames 1produces a still image (use--output out.png).--sampler unipc|euler— UniPC (default) is the official Wan sampler, verified numerically against diffusers'UniPCMultistepScheduler.--cfg/--flow-shift/--diffusion-stepsdefault to each model's official recipe: TI2V-5B cfg 5.0, shift 5.0, 50 steps, 24 fps; A14B I2V cfg 3.5 (both experts), shift 5.0, 40 steps; A14B T2V cfg 4.0/3.0, shift 12.0; Wan 2.1 cfg 6.0, shift 8.0 (1.3B video) or 3.0/5.0, 30 steps, 16 fps.--negative-promptdefaults to the official Wan negative prompt.- Step-distilled checkpoints are auto-detected and are by far the biggest
speed lever — 100 DiT passes become 4, for the same video. No flag: it is an
ordinary
--modelGGUF. See the fast lane below. --cfg-cache-stride N(default off) — approximate speedup. A guided step isv = v_cond + (cfg-1)·dwithd = v_cond - v_uncond; the guidance directiondchanges much more slowly across the schedule thanvdoes, so the unconditional pass can run on one step inNand the cacheddcover the rest. At 50 steps,2runs 77 of the 100 passes (1.30× faster) and3runs 70 (1.43×). The first three steps and the last always recomputed. Leave it off when matching a reference sample matters. Server:"cfgCacheStride": 2.- MP4 writing prefers
ffmpegonPATH(orTS_FFMPEG=<path>, or anffmpegfolder next to the executable) — near-lossless CRF 17 H.264. Without it the OS codec via OpenCV is used at its default bitrate, which is visibly worse; the CLI warns when that happens.
This is the difference between a 5-second 720p video costing three and a half hours and costing seventeen minutes, and it is a one-word change to a file path.
TensorSharp reads the DiT GGUF's file name. A name carrying Turbo /
distill / Lightning / lightx2v / FastWan / -dmd, or an explicit step
count (…-4steps-…, …8step…, accepted for 1–16), switches the sampling
defaults to that step count with guidance off — which is what those
checkpoints are trained for. On load the console confirms it:
step-distilled checkpoint detected -> 4 steps, guidance off (--diffusion-steps / --cfg override)
The official 50-step + CFG recipe costs 50 × 2 = 100 DiT passes; a 4-step
guidance-free checkpoint costs 4 — 1/25th of the denoising work. Everything
else (companion files, flags, endpoints, the Web UI) is unchanged, and
--diffusion-steps / --cfg still override the detected values.
Drop-in GGUFs. The distilled repos ship the DiT only, so the VAE and the text encoder still come from the base repos:
# TI2V-5B, 4 steps — note the Wan2_2 underscore in the file name
hf download hum-ma/Wan2.2-TI2V-5B-Turbo-GGUF Wan2_2-TI2V-5B-Turbo-Q8_0.gguf --local-dir models
hf download QuantStack/Wan2.2-TI2V-5B-GGUF VAE/Wan2.2_VAE.safetensors --local-dir models
hf download city96/umt5-xxl-encoder-gguf umt5-xxl-encoder-Q8_0.gguf --local-dir models
TensorSharp.Cli --model models/Wan2_2-TI2V-5B-Turbo-Q8_0.gguf --backend ggml_metal \
--prompt "the cat runs toward the camera, cinematic" --image first_frame.png \
--output cat.mp4 --video-frames 121# I2V-A14B, 4 steps — both experts; --model either one, the sibling is found by name
hf download jayn7/WAN2.2-I2V_A14B-DISTILL-LIGHTX2V-4STEP-GGUF high_noise/wan2.2_i2v_A14b_high_noise_lightx2v_4step-Q4_K_M.gguf --local-dir models
hf download jayn7/WAN2.2-I2V_A14B-DISTILL-LIGHTX2V-4STEP-GGUF low_noise/wan2.2_i2v_A14b_low_noise_lightx2v_4step-Q4_K_M.gguf --local-dir models
hf download QuantStack/Wan2.2-I2V-A14B-GGUF VAE/Wan2.1_VAE.safetensors --local-dir modelshum-ma/Wan2.2-TI2V-5B-Turbo-GGUF
also offers Q6_K (4.22 GB) through Q2_K (1.86 GB);
jayn7/WAN2.2-I2V_A14B-DISTILL-LIGHTX2V-4STEP-GGUF
carries the Lightning distillation already merged into both experts, Q8_0
(15.4 GB/expert) through Q2_K (5.31 GB).
Green-Sky/FastWan2.2-TI2V-5B-FullAttn-GGUF
is a second TI2V-5B option. Note that
lightx2v/Wan2.2-Lightning
publishes LoRA .safetensors only, and TensorSharp has no Wan LoRA option — use
the pre-merged GGUFs above.
- Resolution is the biggest lever. Wan is trained at 480p (832×480 area)
and 720p (1280×704); TI2V-5B is natively a 720p model. Well below the 480p
area the DiT is out of distribution and produces soft, unstable, color-drifting
video no matter how many steps you spend — the pipeline warns below ~0.3 MP.
Generate at a supported resolution and downscale afterwards instead of
generating small (e.g. use
--width 480 --height 704rather than 320×480). - Describe the shot. Wan's prompt adherence rewards detailed prompts — subject, action, camera, lighting, style — over 3-4 word fragments; the official demos run prompts through an LLM "prompt extend" step first. Short prompts leave the model underconstrained and drift-prone.
- More
--diffusion-stepsbeyond the recipe (50) buys little; prefer fixing resolution and prompt first.
TensorSharp.Server --model Wan2.2-TI2V-5B-Q8_0.gguf --backend ggml_cuda \
--video-frames 121 --fps 24--video-frames and --fps set server-wide defaults for the Web UI and all
three video endpoints below. The Web UI does not send either field, so the
example produces 121 frames at 24 fps (about 5.04 seconds of playback). An API
request that supplies frames or fps overrides the corresponding startup
default independently; these are defaults, not caps. If the flags and request
fields are both omitted, the model recipes apply: 49 frames at 24 fps for
TI2V-5B, and 33 frames at 16 fps otherwise. Frame counts are snapped to 4k+1.
Keep the model's native FPS and adjust the frame count when changing duration;
changing only FPS changes playback speed.
The step-distilled fast lane is a property of the --model GGUF, not of the
request, so it applies to the Web UI and to every endpoint with no client-side
change at all — point --model at a Turbo/Lightning checkpoint and nothing else
moves:
TensorSharp.Server --model Wan2_2-TI2V-5B-Turbo-Q8_0.gguf --backend ggml_metal \
--video-frames 121 --fps 24This matters most from the browser, where the request is a prompt and an image
and the cost is invisible until the ETA appears. The same Web UI request —
a 1024×768 first frame, 121 frames at 24 fps, which resolves to 1088×832 and
27 404 tokens — takes ≈ 3 h 30 m on the base Wan2.2-TI2V-5B-Q8_0.gguf and
17 m 30 s on Wan2_2-TI2V-5B-Turbo-Q8_0.gguf (M5 Pro, ggml_metal; see
Performance). If a run's ETA looks impossible, check the startup
log for the step-distilled checkpoint detected line before changing anything
else.
- Web UI (
http://localhost:5000): type a prompt in the chat — attach an image to get image-to-video (the image becomes the first frame); the reply is the generated video with live denoising progress. - API:
curl http://localhost:5000/v1/videos/generations -H "Content-Type: application/json" -d '{
"prompt": "the cat runs toward the camera, cinematic",
"image": "data:image/png;base64,....",
"size": "832x480", "frames": 81, "steps": 50, "seed": 7
}'
# -> { "created": ..., "data": [ { "url": "/uploads/video-....mp4" } ], "frames": 81, ... }image accepts base64 (with or without a data: URL prefix); omit it for
text-to-video. Also POST /api/video-generate (same JSON, { ok, url, ... }
envelope, plus imagePath referencing a previously uploaded file) and
POST /api/video-generate/stream (SSE progress, used by the Web UI). A14B
accepts cfg2 (low-noise-expert guidance) on every endpoint.
The networks each run as ONE resident-weight ggml graph per invocation
(TensorSharp.GGML.Native/ggml_ops_wan.cpp):
TSGgml_WanT5Encode— 24-layer UMT5 encoder with per-layer relative attention bias; tokens in, hidden states out (conditioning is zero-padded to 512 tokens, Wan semantics).TSGgml_WanDitForward— one denoising-step velocity prediction; persistent per shape on CUDA so ggml's CUDA-graph capture removes the launch overhead; flash attention over the 3D-RoPE self-attention. For TI2V-5B image-to-video the graph modulates two token segments at different timesteps (the conditioning frame's tokens at t=0, diffusersexpand_timestepssemantics) — attention still runs over the full joint sequence.TSGgml_WanVaeDecode— the causal 3D video VAE decoder (both generations), iterating temporal chunks in-graph with the causal feature cache carried between chunks; convs run as banded im2col+GEMM under a scratch budget so peak VRAM stays bounded (TS_WAN_VAE_GEMM_MAX_MB; 384 MB on CUDA — sizing it from free memory measured 2.2x slower, because the scratch then eats the headroom the rest of the graph needs and WDDM pages the overflow — and free/8 capped at 8 GB on Metal, which has no PCIe cliff;0forces direct convolution). The kd temporal taps of a causal conv share ONE im2col instead of lowering the same pixels once each, and bands are split evenly rather than walked at a fixed height; both are bit-identical. With the cross-chunk feature caches stored F16. The Wan 2.2 decoder adds the weight-free DupUp3D residual shortcuts and the final 2×2 pixel unpatchify. Above ~0.5 MP the decode is additionally tiled into full-width horizontal bands blended over an 8-latent-row overlap (the diffusersenable_tilingapproach, ~59 dB vs the untiled decode): a 720p plane's activations plus causal caches are otherwise ~12 GB device-resident, which pushes 16 GB WDDM cards into shared-memory paging (33 min → ~3 min for 69-frame 720p).TS_WAN_VAE_TILE=0disables the tiling.TSGgml_WanVaeEncode— the causal 3D VAE encoder (both generations) behind image-to-video conditioning: 1+4k pixel-frame chunks, stride-2 spatial and temporal downsampling with the causal caches, AvgDown3D shortcuts and 2×2 pixel patchify for Wan 2.2.
I2V conditioning follows the reference pipelines exactly: TI2V-5B replaces the
first latent frame with the VAE-encoded image every step and modulates its
tokens at timestep 0; A14B concatenates a 4-channel first-frame mask plus the
VAE-encoded [image, zeros…] clip behind the 16 noise channels (36-channel DiT
input) and switches from the high-noise to the low-noise expert at
t < boundary·1000 (0.9 I2V / 0.875 T2V), releasing the first expert's VRAM at
the switch.
Numerics are verified against the reference implementations by guarded tests
(InferenceWeb.Tests/WanVideoOracleTests.cs, fixtures generated by
InferenceWeb.Tests/tools/wan22_oracle.py): the TI2V-5B DiT (Q8_0) matches
diffusers' WanTransformer3DModel
at cosine > 0.995 in both uniform and per-token-timestep modes, both VAE
encoders match AutoencoderKLWan at cosine > 0.999, the Wan 2.2 VAE decode at
35 dB PSNR (Wan 2.1 decode: 59.9 dB), and the tokenizer reproduces the HuggingFace T5 ids exactly on English and CJK prompts.
Environment knobs: TS_WAN_DIT_CAPTURE=0 (disable the persistent captured DiT
graph), TS_WAN_DIT_FLASH=0 (materialized attention), TS_WAN_DIT_KV_F16=0
(F32 attention keys/values — the old, ~2x slower default), TS_WAN_HEARTBEAT_S
(progress tick interval, default 30; 0 silences the ticks),
TS_WAN_VAE_GEMM_MAX_MB (im2col budget), TS_WAN_DIT_FFN_CHUNK_MB (DiT
feed-forward token-chunk budget, default 256 MB; 0 disables chunking),
TS_WAN_VAE_TAP_SHARE=0 (one im2col per temporal tap instead of one shared
across them — A/B only; the shared lowering is bit-identical),
TS_VAE_CUDNN_CONV=1 (opt in to cuDNN for the VAE convolutions on CUDA; OFF by
default because it wins ~1.2x on a short clip and loses ~4.5x on a 121-frame one
-- each convolution runs outside the graph, so the host round trips scale with
the temporal chunk count), TS_CUDNN_DIR (cuDNN install to use),
TS_WAN_VAE_MPS_CONV=0
(ggml conv lowering instead of MPSGraph on Metal),
TS_WAN_VAE/TS_WAN_TE/TS_WAN_DIT2 (companion paths), TS_FFMPEG (ffmpeg
path for MP4 export), TS_WAN_DIT_TRACE=<file> (per-stage activation stats for
debugging).
The headline: use a step-distilled checkpoint. Every other lever on this page is worth tens of percent; that one is worth 25×. The same 5-second 720p image-to-video request runs in 3 h 30 m on a base checkpoint and 17 m 30 s on a Turbo one, on the same machine, with the same flags — see the fast lane for where to get one and the M5 Pro table below for the breakdown.
RTX 3080 Laptop 16 GB (Windows/WDDM), ggml_cuda:
| Model / workload | Text enc | Image enc | Denoise | VAE decode | Total |
|---|---|---|---|---|---|
| Wan2_2-TI2V-5B-Turbo Q4_0 I2V 1088×832×121f, 4 steps (720p, 5 s) | 5.9 s | 6.1 s | 27.8 s/pass (27 404 tokens) | 305 s | 429 s |
| Wan2.2-TI2V-5B Q8_0 I2V 832×480×81f, 30 steps | 3.5 s | 8.5 s | 11.5 s/step (CFG ×2, 8190 tokens) | 103 s | 464 s |
| Wan2.2-TI2V-5B Q8_0 T2V 640×384×25f, 20 steps | 3.7 s | — | 1.5 s/step | 23 s | 65 s |
| Wan2.2-I2V-A14B Q4_K_M 480×480×9f, 6 steps (smoke) | 3.4 s | 5 s | 7–8 s/step + 2 expert loads | 7.4 s | 89 s |
| Wan2.1-T2V-1.3B F16 832×480×33f, 30 steps | 3.1 s | — | 9.4 s/step (CFG ×2) | 51 s | 337 s |
That 720p/121-frame row was 1 143 s before the 2026-08 pass: the DiT graph
needed 11.4 GiB at 27 404 tokens and the card has 16 GB, so the whole denoise ran
against a working set that did not fit. Chunking the feed-forward over tokens
(TS_WAN_DIT_FFN_CHUNK_MB) took the 1.5 GiB intermediate out of the peak and the
per-pass time went 110.4 s -> 26.1 s; capping the VAE im2col scratch and sharing
one lowering across the causal taps took the decode 712 s -> 305 s. Both are
exact: the DiT still matches diffusers at cosine 0.99997 and the decode at
80.2 dB PSNR, unchanged from before.
The TI2V-5B model's 16×16 spatial compression gives it ~2.7× fewer DiT tokens than Wan 2.1 at the same resolution — it is both the fastest and the highest-quality option for consumer GPUs, and the only 720p-24fps one.
Token count is latent_frames × (h/2) × (w/2), and DiT self-attention costs
O(tokens²). Past a few thousand tokens attention — not the weight matmuls —
is where the time goes, so the frame count and the frame area both matter far
more than the step count:
| Request (TI2V-5B) | Tokens | Attention work |
|---|---|---|
| 640×384×25f | 1 200 | 1× |
| 832×480×81f | 8 190 | 47× |
| 1088×832×121f (5 s at 24 fps, 720p class) | 27 404 | 520× |
The full 5-second 720p recipe is a genuinely large job: 50 steps × 2 CFG passes = 100 DiT passes over 27 k tokens. The pipeline prints the token count, a per-pass timing and a running ETA, and heartbeats every 30 s while a pass is in flight, so a long run is visibly progressing rather than apparently hung:
Wan 2.2-TI2V I2V: 1088x832x121f (31x26x34 = 27404 tokens), 50 steps, unipc, cfg 5, shift 5, seed ...
[wan] large request: 100 DiT passes over 27404 tokens. Self-attention costs O(tokens^2) ...
step 1/50 t=999.0 (249.0s: cond 124.6s + uncond 124.4s) — ~3h 24m left
[wan] …denoise step 2/50 (cond pass) 60s in this pass, 315s total, ~3h 24m left
To make such a request cheaper, in order of effect:
- Use a step-distilled checkpoint. This dwarfs everything else: 100 DiT passes become 4. See the fast lane — TensorSharp detects them by name and switches the recipe automatically.
- Fewer frames. 121 → 61 frames roughly quarters the attention work and halves the VAE decode; the video is 2.5 s instead of 5 s at 24 fps.
- Smaller frame area, but not below Wan's training resolutions — under
~0.3 MP the model is out of distribution and the result gets worse, not
just cheaper.
--width 480 --height 704is a good portrait target. - Fewer steps (base checkpoints only). 50 is the official TI2V recipe; 30 is visibly close and 1.7× cheaper.
--cfg-cache-stride 2or3— 1.30× / 1.43× by reusing the guidance direction between steps (approximate; see the CLI section). Pointless on a distilled checkpoint, which already runs guidance-free.
M5 Pro (20-core GPU, 48 GB unified), ggml_metal, Wan2.2-TI2V-5B Q8_0,
1088×832×121f = 27 404 tokens, image-to-video — the request this was all
measured against (5 s at 24 fps, 720p class):
| Base checkpoint, before | Base checkpoint, now | Turbo checkpoint, now | |
|---|---|---|---|
| DiT passes | 100 (50 steps × CFG) | 100 | 4 (4 steps, guidance-free) |
| per pass | 206.2 s | 120.2 s | 120.2 s |
| denoise total | 20 615 s | 12 020 s | 481 s |
| VAE decode, 121 frames | 863 s | 563 s | 563 s |
| end to end | ≈ 5 h 58 m | ≈ 3 h 30 m | 17 m 30 s |
The two levers are independent: ~1.7× per pass from the attention and VAE work below, and 25× fewer passes from running a step-distilled checkpoint. Once distilled, the VAE decode is the bottleneck (~55% of the run), not the DiT.
Frame count and frame area then set the rest, and the VAE scales linearly with output pixels while attention scales with the square of the token count. All measured on the same machine with the same Turbo checkpoint and image:
| Output | Tokens | Denoise | VAE decode | Total |
|---|---|---|---|---|
| 736×544 × 81f (3.4 s, 480p class) | 8 211 | 84 s | 159 s | 4 m 09 s |
| 736×544 × 121f (5 s, 480p class) | 12 121 | 137 s | 237 s | 6 m 19 s |
| 1088×832 × 121f (5 s, 720p class) | 27 404 | 481 s | 563 s | 17 m 30 s |
480p (≈0.4 MP) is a resolution Wan is trained at, so the middle rows are in-distribution, not a degraded mode — that is the setting to reach for when a few minutes matters. Going below ~0.3 MP is where quality actually falls off.
Three changes account for the per-pass part.
F16 attention keys and values. Every backend's flash-attention kernel is
built around an F16 KV cache: ggml-metal instantiates the F32 variant with
simdgroup_float8x8 accumulators where the F16 one uses simdgroup_half8x8,
and the F32 tiles also cost twice the bandwidth in a kernel that re-streams K
and V once per 8-query threadgroup. The permute into flash-attention layout and
the narrowing happen in one ggml_cpy, so the F16 path also writes half the
bytes the old ggml_cont did. TS_WAN_DIT_KV_F16=0 restores F32.
VAE decode band layout. Tiling picks the band count from the memory budget and then splits the plane evenly, instead of walking a fixed band height and letting the last band land wherever the stride puts it. At 52 latent rows that was three 24-row bands — 72 rows decoded to produce 52 — and is now two 30-row bands (60 rows, one seam instead of two) within the same per-band budget.
VAE convolutions on MPSGraph (Metal). ggml lowers conv2d to im2col +
mul_mat, and a decode profile (WanVideoBench, 640×480×9f) puts 44.4% of
the graph in MUL_MAT and a further 30.2% in IM2COL — 74.6% in the convolution,
with im2col moving 865 MB per node at ~57 GB/s. The lowering is pure overhead: a
3×3 conv materialises 9× its input before the GEMM starts. Apple's tuned
convolution runs the same shapes far faster:
| conv shape | ggml im2col+GEMM | MPSGraph | |
|---|---|---|---|
| 512→512 k3 320×240 t9 | 666 ms | 108 ms | 6.2× |
| 256→256 k3 640×480 t9 | 947 ms | 110 ms | 8.6× |
| 160→160 k3 640×480 t9 | 491 ms | 47 ms | 10.4× |
| 512→512 k1 320×240 t9 | 184 ms | 14 ms | 13.9× |
MPS reaches ~30 TFLOP/s where ggml's Metal GEMM gets ~4.9 — and it does so
without ggml's mul_mm, the kernel whose Metal 4 tensor path corrupts this
exact graph. It buys matrix-unit throughput while stepping around that defect.
Layout costs nothing: ggml's activation [W,H,C,T] and kernel [KW,KH,IC,OC]
are byte-for-byte MPS NCHW and OIHW, and NCHW measured as fast as NHWC here, so
no transpose is inserted. On this path the VAE emits un-lowered CONV_2D nodes,
which also skips the horizontal banding and its leading-pad shim entirely; the
decode graph is then run in segments with each CONV_2D handed to MPSGraph
(ggml_graph_view, the same mechanism the node profiler uses, so it is safe
against gallocr reuse). Measured end to end: VAE decode 159 s → 80 s (1.99×)
at 736×544×81f. Numerics are unchanged — ggml already convolves in F16 with F32
accumulation and so does this, agreeing to 93.9 dB PSNR / max Δ 0.128 of 255
over a whole decode. TS_WAN_VAE_MPS_CONV=0 restores the ggml lowering.
VAE conv im2col budget, and the tiling threshold itself, sized from device memory. Both were pinned at a 16 GB card's budget on every non-CUDA backend. Metal now derives them from free device memory like CUDA does (Vulkan keeps the floor — its drivers reject multi-GB arenas). A bigger im2col budget turns many small banded GEMMs into few large ones: 56.4 s → 49.7 s for a 1088×832×9f decode. And tiling is not free — the bands overlap, so the decoder runs more latent rows than the plane has, and the band buffer lives alongside the canvas. Decoding 1088×832×121f whole measured both faster and lighter than two bands: 565 s vs 655 s, peak RSS 4.85 vs 5.37 GB. Small cards still tile.
One self-attention at this shape (seq 27 404, 24 heads, head dim 128), same run:
| KV type | time | vs F32 |
|---|---|---|
| F32 (before) | 4993 ms | 1.00× |
| F16 (now) | 2467 ms | 2.02× |
| Q8_0 | 2652 ms | 1.88× |
| Q4_0 | 2395 ms | 2.08× |
Q8_0 keys/values are slower than F16 — the dequantization outweighs the
bandwidth saved — and Q4_0 buys 3% for a real precision cost, so F16 is the
sweet spot rather than a compromise. Materialized chunked attention
(mul_mat + soft_max_ext, which unlike flash attention can use the Metal 4
tensor API) came in at 1.08×: at this size attention is bound by score traffic,
not by the GEMM rate. Dequantizing the DiT weights from Q8_0 to F16 moved the
matmuls by <5% (25.2 → 26.4 ms for an attention projection).
The most tempting knob here, and it stays off for TI2V-5B. It measures 1.20×
on the DiT (121.4 → 100.8 s/pass) and 1.66× on the VAE decode, because it routes
mul_mm through Metal 4 tensor operations — but on M5 / macOS 26.6 it also
miscomputes the VAE's conv GEMMs and the video comes out uniformly black.
The trap is that this does not reproduce in isolation. WanVideoBench vae-decode was run at five latent shapes, including the 32×32 layout recorded
as the original all-NaN repro, dumping raw pixels with the tensor API on and
off: they agreed to 91–93 dB PSNR, max delta 0.18/255, zero NaN. On that
evidence the workaround was removed — and the very next full 1088×832×121f
generation produced 121 black frames. The defect follows the process's
allocation history (the DiT is loaded and released before the decode), so a
fresh-process decode probe reproduces the wrong buffer layout.
It was then re-tested a second time with a much more faithful harness —
WanVideoBench pipeline, which runs VAE-encode → DiT forward → release → VAE
decode in one process at the exact production shapes, including the full
121-frame decode. That came back clean too, and 1.50× faster (374 s vs 563 s).
The real generation still produced flat frames. The only remaining difference is
the UMT5-XXL text encoder that loads and releases before everything else, which
is the largest allocation in the pipeline.
So: re-enabling it requires a full end-to-end video, and nothing less has ever
been sufficient. The pipeline now refuses to return a uniformly flat video and
names this cause (AssertFramesAreNotDegenerate), which is what caught the
second attempt automatically instead of costing another eyeballed PNG.
F16 keys and values change the DiT's output by less than the sampler's own sensitivity to floating-point reassociation:
- One DiT forward against the diffusers
WanTransformer3DModelreference (WanVideoOracleTests): cosine 0.999964 with F16 K/V and 0.999964 with F32 K/V — the per-pass accuracy is unchanged. - Full 25-frame generations from an identical seed: F16 vs F32 K/V differ by 39.08 dB mean PSNR. The control — F32 K/V with flash attention off, i.e. the same arithmetic in a different summation order — differs by 39.40 dB. The two are the same magnitude, so the pixel difference is an 8-step diffusion trajectory diverging from a rounding difference, not a loss of accuracy.
The speedup only appears where attention dominates: at 2 310 tokens (480×704×25f) F16 and F32 K/V both run 5.0 s per pass.
Wan 2.1 vs stable-diffusion.cpp (master-769, identical GGUFs/settings, 33-frame 480p): sampling is near parity (281 s vs 258 s) but sd.cpp's VAE decode materializes ≈8 GB of 3D im2col and oversubscribes a 16 GB card into WDDM paging (1762 s vs 51 s) — TensorSharp is 6.0× faster end to end.