feat(wan2.2): memory_mode=relay — run A14B bf16 on 48GB (one expert resident at a time)#41
Open
lBroth wants to merge 4 commits into
Open
feat(wan2.2): memory_mode=relay — run A14B bf16 on 48GB (one expert resident at a time)#41lBroth wants to merge 4 commits into
lBroth wants to merge 4 commits into
Conversation
The end-of-denoise cleanup deletes the model variables but not the loop locals: `_call` (the mx.compile wrapper) retains the model through its traced graph, and `rcs`/`ctx` alias per-model tensors. As a result the last-used transformer (~14GB quantized, ~28GB bf16 for A14B) stays fully resident through the entire VAE decode, and the gc.collect() + mx.clear_cache() pair frees nothing of it. Clearing the aliases first restores the intended free and lowers the VAE-stage peak by the size of one transformer.
The A14B dual model switches experts once, at a deterministic timestep boundary, but both were loaded eagerly and kept resident for the whole denoise (~2x weights). Relay mode builds the high-noise expert, runs the high phase, frees it at the boundary and builds the low-noise expert: peak ~ one expert + activations, at the cost of one extra weight load per generation (~3s from a warm page cache for a 27GB bf16 expert). Measured on an M5 Pro 48GB at 832x480x81f: bf16 A14B runs at 36.8GB peak (Lightning/no-CFG; 44.3GB with CFG) — previously impossible without quantization on 48GB machines - with no wall-clock penalty (bf16 relay was the fastest configuration tested, 27.9 min at 20 steps guide=1). Correctness contract: relay changes only WHEN weights are resident, never the math. relay and parallel produce BIT-IDENTICAL pre-VAE latents for the same seed (MD5-verified on 4-bit A14B; test included, opt-in via WAN22_A14B_DIR). To make the guarantee structural, the initial noise is now drawn before any transformer construction — model loading consumes the global PRNG stream by a loader-dependent amount, so drawing first makes the sample independent of residency mode and loader internals. NOTE: same-seed outputs change relative to earlier versions. - memory_mode: auto (relay for dual models) | relay | parallel; validated early so typos fail fast instead of silently keeping both experts - --memory-mode / --dump-latents CLI flags - dump_latents: float32 .npy of the final pre-VAE latents (contract testing) - tests/test_wan_relay.py: mode validation, CLI surface, and the opt-in bit-identity contract test
…on matrix The bit-identity test ran T2V on an I2V checkpoint (in_dim=36 needs image conditioning) and crashed on shapes; a solid-color synthetic keyframe keeps the test self-contained. Matrix (see regression_matrix.py): 5B single CFG, 5B T2V, Q4+Lightning parallel, Q4+CFG relay, bf16 relay no_compile — all green, no black clips. trim_first_frames+I2V crashes identically on stock (pre-existing upstream limitation, not a relay regression).
…loader replicas Replaces the draw-noise-first approach (which changed same-seed outputs vs prior releases). The initial noise returns to its historical stream position; relay now pre-consumes the construction-time PRNG draws with two discarded LAZY replicas of the loader path (WanModel construction + the nn.quantize stub constructors, which also consume RNG — the reason plain skeletons were not enough). Arrays are never evaluated: near-zero cost. Verified on-device: - stock main vs this branch in parallel mode: byte-identical mp4 (MD5) - parallel vs relay: bit-identical latents (pytest contract, Q4 weights) => relay == previous releases, same seed, same video. Known exception (documented): quantized + LoRA in relay mode is deterministic per-mode but not seed-identical to parallel (the LoRA dequant-merge constructs extra layers, RNG use depends on LoRA configs).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Up-front honesty, in the spirit of #20: this work was done with heavy AI assistance (Claude — Fable 5) and I'm not a professional ML engineer. I've validated everything I could on-device (numbers below are all real measured runs I can share), and I'd genuinely welcome expert scrutiny on anything I may have missed.
Overview
The Wan2.2 A14B dual model switches experts once, at a deterministic timestep boundary — but both experts are loaded eagerly and stay resident for the whole denoise (~2× weights). That forces quantization on 48GB machines, and quantization currently costs real quality (#40: the quantized CFG path also produces black clips at 81f).
This PR adds
memory_mode="relay": build the high-noise expert, run the high phase, free it at the boundary, build the low-noise expert. Peak ≈ one expert + activations.Headline: Wan2.2 A14B bf16 goes from impossible-on-48GB to 36.8GB peak (832×480, 81f, Lightning) / 44.3GB with CFG — with no wall-clock penalty (bf16 relay was the fastest config tested: 27.9 min at 20 steps vs 28.7–29.1 for the quantized variants; the boundary reload costs ~3s from a warm page cache).
Modes
auto(default) — relay for dual models, unchanged behavior for singlerelay— one expert resident, swapped at the boundaryparallel— both resident (previous behavior)ValueError(a silently-unknown mode would degenerate into both-resident with no warning)Correctness contract — full seed reproducibility
Relay changes only when weights are resident, never the math. Verified on-device, all three directions:
WAN22_A14B_DIR);How: model construction consumes the global PRNG stream (keyless layer inits AND the
nn.quantizestub constructors), and the noise is drawn after both experts are built. Relay defers the real builds, so it pre-consumes the same draws with two discarded lazy replicas of the loader path (never evaluated, near-zero cost). Known exception, documented in code: quantized + LoRA in relay mode stays deterministic per-mode but is not seed-identical to parallel (the LoRA dequant-merge constructs extra layers whose RNG use depends on the LoRA configs).Second commit (standalone fix)
fix(wan2.2): release transformer weights before VAE decode— the cleanup deletes the model variables but not the loop locals (_call— the mx.compile wrapper — retains the model through its traced graph), so the last-used transformer stayed fully resident through the whole VAE decode. Applies to stock parallel mode too.Measured (M5 Pro 48GB, macOS 26.5.1, mlx 0.31.2, seed 42, 832×480×81f)
Quality context (same seed/prompt across a precision ladder): rendered TEXT with quantization ≤8-bit came out as broken glyphs in every mix we tried; bf16 renders real letters.
Before/after clips (5s, seed 42):
Usage
python -m mlx_video.models.wan_2.generate \ --model-dir Wan2.2-I2V-A14B-MLX-bf16 --image key.png \ --prompt "..." --num-frames 81 --steps 20 --guide-scale 3.5,3.5 \ --memory-mode relay --tiling aggressiveTests
tests/test_wan_relay.py: mode validation, CLI surface, opt-in bit-identity contract test (WAN22_A14B_DIR)42 passed, 1 skipped(note:tests/test_generate_dev.pyfails collection on main too — it importsmlx_video.generate_dev, which no longer exists; happy to fix in a follow-up if wanted)trim_first_frames+ I2V crashes identically on current main — pre-existing, unrelated.)