[perf]: MiniMax H3 - build the Qwen3-VL encoder only as far as it is read (-13.7 GB) - #1711
Conversation
H3 conditions on one intermediate hidden state and reads nothing above it.
`MINIMAX_H3_TEXT_ENCODER_LAYER = 50` (pipelines/basic/minimax_h3/packing.py:31)
is consumed once, at minimax_h3_conditioning.py:184, and no other tensor from
the encoder has a consumer anywhere in the tree, `last_hidden_state` included.
The remaining 14 decoder layers and the final norm are built, weight-loaded and
then discarded.
Per layer, from the shipped config (hidden 5120, intermediate 25600, 64 q heads
and 8 kv heads at head_dim 128, no attention bias):
q 5120x8192 41,943,040
k, v 2 x 5120x1024 10,485,760
o 8192x5120 41,943,040
q_norm + k_norm 256
gate/up/down 3x5120x25600 393,216,000
2 x RMSNorm 10,240
-----------
487,598,336
x 14 layers = 6.83 B parameters, 13.7 GB in bf16, 21.7 percent of the encoder.
Add `num_hidden_layers_override` to the arch config, defaulted to the tapped
index, following the same field that clip.py and siglip.py already carry (CLIP
ships it set to 31). `min(num_hidden_layers, override)` rather than a bare
override, because `num_hidden_layers` is rewritten from the checkpoint's
config.json by `update_model_arch`, and a smaller variant must clamp rather than
ask for layers that do not exist.
The tapped entry stays bit-identical. The hidden-state tuple appends before each
layer runs, so entry N is the input to layer N, i.e. the output of layer N-1.
Stopping after N layers and appending the running tensor once more puts the same
value at the same index.
The final norm has to go with the layers. It sits above the tap, and a truncated
stack that still applied it would put a normalised tensor at index 50 while the
length check at minimax_h3_conditioning.py:181 still passed: conditioning would
change with nothing raised. `self.norm` is therefore None when truncated, which
is also what `load_weights` keys off to drop the surplus checkpoint entries. The
unexpected-key check there is strict on purpose, so the surplus is filtered
rather than the check relaxed.
Not affected: training reads precomputed embeddings from parquet and never runs
the encoder; preprocessing runs it but reads the same index; FL2VA and Ref2VA
share the conditioning stage and the same index. Setting the override to None
restores the full stack for anyone who needs the top of the tower.
Load time improves less than memory does: the weights are still read off disk,
only the copy into a parameter disappears.
fastvideo/tests/encoders/test_minimax_h3_qwen3_vl_truncation.py: 7 passed
Not verified here: an end to end H3 run. The device this was found on cannot
load H3 at all yet, which is the point; measurement to follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge Protections🔴 1 of 1 protections blocking · waiting on 👀 reviews and 🤖 CI
🔴 PR merge requirementsWaiting for
This rule is failing.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ec849a4e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| override = config.num_hidden_layers_override | ||
| self.num_layers = (config.num_hidden_layers | ||
| if override is None else min(config.num_hidden_layers, override)) |
There was a problem hiding this comment.
Update the production-loader parity test for truncation
When MINIMAX_H3_RUN_ENCODER_PARITY=1, the existing tests/local_tests/encoders/test_minimax_h3_qwen3_vl_parity.py test now deterministically fails: the official 64-layer model returns 65 hidden states, while this default truncation returns 51, but lines 224–226 still require equal tuple lengths and compare every layer with zip(..., strict=True). Adjust that required production-loader parity check to compare the shared prefix or specifically layer 50; otherwise the real-checkpoint numerical gate cannot validate this optimization.
Useful? React with 👍 / 👎.
…ader's Every offload flag moves weights to host memory so the device can drop them. That trade only pays when the two are separate pools. On a unified-memory device they are one, so the move frees nothing, the copy is a pure loss, and the peak becomes the sum instead of the max. hao-ai-lab#1710 added `Platform.has_unified_memory()` and used it at one site: the text encoder's FSDP offload during loading. That is not enough, because a single flag acts in more than one place. `text_encoder_cpu_offload` does three things: component_loader.py:353 picks the load-time target device, so the model is placed on the host component_loader.py:407 gates the FSDP offload path, which hao-ai-lab#1710 covers minimax_h3_conditioning.py:292-302 gates a `.to(device)` before the conditioning forward and a `.to("cpu")` after it Gating only the loader leaves the model on the host and moves the copy to inference time. On a DGX Spark loading MiniMax H3 that is a 48 GB move in the middle of generation with about 11 GiB free, and the worker is killed. Decide it once, in `check_fastvideo_args`, so every call site sees the same answer. `UNIFIED_MEMORY_OFFLOAD_FLAGS` names the five flags and a test asserts the tuple still matches the dataclass, because missing one is silent: the run works and quietly pays twice, which is exactly how the text encoder survived the first pass at this. `use_fsdp_inference` is deliberately left alone. Sharding across ranks is a separate decision from where the weights live, and a unified-memory host can have more than one of these devices. MPS keeps its own branch, which does disable it, and reaches this one through `elif`. hao-ai-lab#1710's loader guard is not made redundant. `TextEncoderLoader.load` takes an explicit `cpu_offload` argument that bypasses the args-level decision, and that path still needs it. Measured on a DGX Spark, GB10, 121 GiB unified memory, one GPU, with hao-ai-lab#1710, hao-ai-lab#1711 and hao-ai-lab#1714 also applied. Before this change the conditioning stage is terminated 1.5 s in, every time, with nothing reported. After it, the run gets through input preparation, conditioning, latent preparation and the full denoising loop, and is terminated in the video decode instead. Getting past that last step also needs the video VAE decoder in fp16 rather than the fp32 it is pinned to at `models/vaes/minimax_h3_video.py:565`. With that as a local patch on top, MiniMax H3 completes a text to video generation on one GB10: 320 by 192, 124 frames, video and audio, 11.69 s. It could not load at all before this series. The VAE change is not in this PR because the encode path has to stay fp32 for FL2VA and Ref2VA, so it needs its own design and its own measurements. fastvideo/tests/platforms/test_unified_memory_offload.py: 11 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alidation, key-filter bound Review follow-ups for the layer-50 truncation of the Qwen3-VL conditioner: - tests/local_tests/encoders/test_minimax_h3_qwen3_vl_parity.py compared all 65 hidden states with equal lengths and zip(strict=True), so the gate failed deterministically once the production stack builds only 51. It now pins the production tuple to language_model.num_layers + 1 entries and compares every built state bit-exactly against the official value at the same index, which still catches a truncated stack that applies the final norm, and degrades to the original full comparison when the override is None. - num_hidden_layers_override <= 0 was accepted: 0 built an empty decoder stack, and a negative value additionally left num_layers inconsistent with the built layers, making _is_above_the_tap drop every layer key so the conditioner loaded "successfully" with no transformer at all. The arch config __post_init__ now rejects non-positive overrides, both at construction and when update_model_arch re-validates a checkpoint config. - _is_above_the_tap dropped any layer index >= num_layers, including indexes at or above the checkpoint's own num_hidden_layers that the full stack would reject as unexpected. The filter is now bounded to [num_layers, num_hidden_layers) so truncation only drops keys the full model would have accepted. - The CPU truncation test now compares the whole shared hidden-state prefix, mirroring the production-loader parity gate, and covers the override == num_hidden_layers boundary. The conditioner's num_hidden_layers property documents that it reports the checkpoint's nominal depth, not the built depth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Reviewed in depth — the tap math is right (
9/9 truncation tests pass on CPU; pre-commit (incl. mypy) passes on the five |
|
Ran the GPU gate for this PR at head
Net: everything this PR touches is bit-exact on the real checkpoint; the memory saving is as advertised. 🤖 Generated with Claude Code |
Purpose
MiniMax H3 conditions on one intermediate hidden state of its Qwen3-VL encoder
and reads nothing above it. The remaining 14 decoder layers and the final norm
are built, weight-loaded and then discarded: 13.7 GB in bf16, 21.7 percent of
the encoder.
Part of #1709.
The finding
MINIMAX_H3_TEXT_ENCODER_LAYER = 50(pipelines/basic/minimax_h3/packing.py:31)is consumed once:
Nothing else reads the encoder.
last_hidden_stateis produced atmodels/encoders/minimax_h3_qwen3_vl.py:264and has no consumer anywhere in thetree. Meanwhile the stack is built in full:
Per layer, from the shipped config (hidden 5120, intermediate 25600, 64 q heads
and 8 kv heads at head_dim 128, no attention bias):
14 layers = 6.83 B parameters = 13.7 GB in bf16.
Changes
num_hidden_layers_overrideon the arch config, defaulted to the tapped index.This is the same field
clip.pyandsiglip.pyalready carry, and CLIP ships itset to 31, so the mechanism and the name are established rather than new.
min(num_hidden_layers, override)rather than a bare override:num_hidden_layersis rewritten from the checkpoint's
config.jsonbyupdate_model_arch, so avariant with fewer layers must clamp rather than ask for layers that do not exist.
The tapped entry is bit-identical
The hidden-state tuple appends before each layer runs, so entry N is the input
to layer N, which is the output of layer N-1:
Stopping after N layers and appending the running tensor once more puts the same
value at the same index.
The final norm has to go with the layers
This is the part that fails quietly if you get it wrong.
self.normsits abovethe tap. A truncated stack that still applied it would append a normalised
tensor at index 50, the length check at
minimax_h3_conditioning.py:181wouldstill pass, and conditioning would change with nothing raised. So
self.normisNonewhen truncated, which is also whatload_weightskeys off to drop thesurplus checkpoint entries. The unexpected-key check there is strict on purpose,
so the surplus is filtered rather than the check relaxed.
Test Plan
pytest fastvideo/tests/encoders/test_minimax_h3_qwen3_vl_truncation.py -q pre-commit run --files fastvideo/configs/models/encoders/minimax_h3_qwen3_vl.py \ fastvideo/models/encoders/minimax_h3_qwen3_vl.py \ fastvideo/tests/encoders/test_minimax_h3_qwen3_vl_truncation.pyCPU only. Covered: the default equals the constant the pipeline reads, which the
two sides cannot import from each other; the stack builds to the override and
drops the norm;
Nonekeeps the full stack; an override above the stack clamps;the tapped hidden state is
torch.equalacross truncated and full models giventhe same weights; the surplus checkpoint keys are filtered while the built
layers, embeddings and vision tower are not.
Test Results
Test output
mypy could not run in my checkout, whose directory name contains a hyphen and so
is not a valid package name. It passes in CI.
Measured on a DGX Spark (GB10, 121 GB unified memory), loading
noctuashap/MiniMax-H3-pruned-r16, memory sampled every 5 s withfree, bothruns stacked on #1710 so the offload path is out of the way:
Loaded module text_encoder15 GB against the 13.7 GB the arithmetic predicts; the difference is allocator
granularity. The real checkpoint loads without raising on the surplus keys,
which is the part the unit tests cannot cover.
Not verified: a completed generation. That device cannot load H3 at all yet.
With #1710 and this change it now gets through the text encoder, tokenizer,
processor, VAE and audio VAE and dies partway into the transformer, at 113 GB of
121 with 26 GB already taken by another user. Extrapolating from the component
sizes, an idle machine needs about 109 GB, so it should complete; I have not yet
had the machine to myself to show it. Happy to post that when I do.
Scope
Training reads precomputed embeddings from parquet and never runs the encoder.
Preprocessing runs it but reads the same index. FL2VA and Ref2VA share the same
conditioning stage and the same index, so they are consistent rather than
broken. Setting the override to
Nonerestores the full stack for anyone whowants the top of the tower, at the cost of the 13.7 GB.
Load time improves less than memory does:
weight_utilsstill reads everytensor off disk, only the copy into a parameter disappears.
Checklist