-
Notifications
You must be signed in to change notification settings - Fork 457
[perf]: MiniMax H3 - build the Qwen3-VL encoder only as far as it is read (-13.7 GB) #1711
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
SolitaryThinker
merged 2 commits into
hao-ai-lab:main
from
KyleNeverGivesUp:truncate-h3-text-encoder
Aug 21, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
207 changes: 207 additions & 0 deletions
207
fastvideo/tests/encoders/test_minimax_h3_qwen3_vl_truncation.py
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """The Qwen3-VL stack is built only as far as MiniMax H3 reads. | ||
|
|
||
| H3 conditions on one intermediate hidden state. The layers above it were built, | ||
| weight-loaded and then discarded, which is 13.7 GB in bf16 and the difference | ||
| between fitting and not fitting on a 121 GB unified-memory device. | ||
|
|
||
| The dangerous part is not the truncation, it is getting the tuple index wrong. | ||
| `hidden_states` records each layer's *input*, so entry N is the output of layer | ||
| N-1, and the final entry comes from the norm that sits above the whole stack. A | ||
| truncated stack that still applies that norm puts a normalised tensor where the | ||
| raw one belongs: the length check in the conditioning stage still passes, and | ||
| conditioning silently changes. These tests pin the index, the content, and the | ||
| constant the two sides agree on. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
|
|
||
| import pytest | ||
| import torch | ||
|
|
||
| # Matches the other encoder tests: the module registry these build against wants | ||
| # a process group, and a single-rank one needs a rendezvous address. | ||
| os.environ.setdefault("MASTER_ADDR", "localhost") | ||
| os.environ.setdefault("MASTER_PORT", "29513") | ||
|
|
||
| from fastvideo.configs.models.encoders.minimax_h3_qwen3_vl import ( | ||
| MiniMaxH3Qwen3VLArchConfig, | ||
| MiniMaxH3Qwen3VLConfig, | ||
| ) | ||
| from fastvideo.models.encoders.minimax_h3_qwen3_vl import MiniMaxH3Qwen3VLLanguageModel | ||
| from fastvideo.pipelines.basic.minimax_h3.packing import MINIMAX_H3_TEXT_ENCODER_LAYER | ||
|
|
||
|
|
||
| def _small_arch(**overrides) -> MiniMaxH3Qwen3VLArchConfig: | ||
| """A stack small enough to run on CPU but shaped like the real one. | ||
|
|
||
| Everything goes through the constructor so ``__post_init__`` validates the | ||
| small shape the same way it validates the real one. | ||
| """ | ||
| kwargs: dict = dict( | ||
| vocab_size=64, | ||
| hidden_size=16, | ||
| intermediate_size=32, | ||
| num_hidden_layers=8, | ||
| num_attention_heads=2, | ||
| num_key_value_heads=1, | ||
| head_dim=8, | ||
| # __post_init__ reads the sections out of rope_scaling, and they must | ||
| # cover exactly half of each head. | ||
| rope_scaling={ | ||
| "mrope_interleaved": True, | ||
| "mrope_section": [2, 1, 1], | ||
| "rope_type": "default", | ||
| }, | ||
| vision_out_hidden_size=16, | ||
| ) | ||
| kwargs.update(overrides) | ||
| return MiniMaxH3Qwen3VLArchConfig(**kwargs) | ||
|
|
||
|
|
||
| def _small_config(**overrides) -> MiniMaxH3Qwen3VLConfig: | ||
| """The outer config, which is what the modules take. | ||
|
|
||
| ``ModelConfig.__getattr__`` forwards the architecture fields, so the modules | ||
| read ``prefix`` off this object and everything else off ``arch_config``. | ||
| """ | ||
| config = MiniMaxH3Qwen3VLConfig() | ||
| config.arch_config = _small_arch(**overrides) | ||
| return config | ||
|
|
||
|
|
||
| def test_default_matches_the_index_the_pipeline_reads() -> None: | ||
| """The two sides cannot import each other, so pin them here instead. | ||
|
|
||
| `fastvideo/models/` must not import from `fastvideo/pipelines/`, so the tap | ||
| is written down twice. If they drift, conditioning reads a hidden state that | ||
| was never built and the run dies with an index error at generation time, | ||
| after a full model load. | ||
| """ | ||
| assert MiniMaxH3Qwen3VLArchConfig().num_hidden_layers_override == MINIMAX_H3_TEXT_ENCODER_LAYER | ||
|
|
||
|
|
||
| def test_builds_only_up_to_the_override(distributed_setup) -> None: | ||
| model = MiniMaxH3Qwen3VLLanguageModel(_small_config(num_hidden_layers_override=5)) | ||
|
|
||
| assert model.num_layers == 5 | ||
| assert len(model.layers) == 5 | ||
| # The norm sits above the tap, so a truncated stack must not keep it. | ||
| assert model.norm is None | ||
|
|
||
|
|
||
| def test_override_none_keeps_the_full_stack(distributed_setup) -> None: | ||
| model = MiniMaxH3Qwen3VLLanguageModel(_small_config(num_hidden_layers_override=None)) | ||
|
|
||
| assert model.num_layers == 8 | ||
| assert model.norm is not None | ||
|
|
||
|
|
||
| def test_override_above_the_stack_does_not_over_build(distributed_setup) -> None: | ||
| # num_hidden_layers comes from the checkpoint's config.json via | ||
| # update_model_arch, so a smaller variant must clamp rather than ask for | ||
| # layers that do not exist. | ||
| model = MiniMaxH3Qwen3VLLanguageModel(_small_config(num_hidden_layers_override=99)) | ||
|
|
||
| assert model.num_layers == 8 | ||
| assert model.norm is not None | ||
|
|
||
|
|
||
| def test_override_equal_to_the_stack_keeps_the_norm(distributed_setup) -> None: | ||
| """The exact boundary of the clamp: a stack cut at its own depth is full. | ||
|
|
||
| A checkpoint with exactly ``override`` layers taps its final layer, whose | ||
| tuple entry sits after the norm in the full model, so the norm must stay | ||
| and nothing may be filtered from the checkpoint. | ||
| """ | ||
| model = MiniMaxH3Qwen3VLLanguageModel(_small_config(num_hidden_layers_override=8)) | ||
|
|
||
| assert model.num_layers == 8 | ||
| assert model.norm is not None | ||
|
|
||
|
|
||
| def test_non_positive_override_is_rejected() -> None: | ||
| """A non-positive override would build no decoder layers at all. | ||
|
|
||
| Worse, a negative one makes ``num_layers`` disagree with the built stack | ||
| and the surplus-key filter would then drop every layer key, so the | ||
| conditioner would load "successfully" with no transformer. Reject it at | ||
| config construction, and again when update_model_arch re-validates. | ||
| """ | ||
| for override in (0, -1): | ||
| with pytest.raises(ValueError, match="num_hidden_layers_override"): | ||
| _small_arch(num_hidden_layers_override=override) | ||
|
|
||
| config = _small_config() | ||
| with pytest.raises(ValueError, match="num_hidden_layers_override"): | ||
| config.update_model_arch({"num_hidden_layers_override": 0}) | ||
|
|
||
|
|
||
| def test_tapped_hidden_state_is_unchanged_by_truncation(distributed_setup) -> None: | ||
| """The whole point: entry `tap` must be bit-identical either way.""" | ||
| tap = 5 | ||
| full = MiniMaxH3Qwen3VLLanguageModel(_small_config(num_hidden_layers_override=None)) | ||
| cut = MiniMaxH3Qwen3VLLanguageModel(_small_config(num_hidden_layers_override=tap)) | ||
|
|
||
| # These modules allocate uninitialised storage and expect a checkpoint, so | ||
| # give them finite weights before running anything through them. | ||
| torch.manual_seed(0) | ||
| for parameter in full.parameters(): | ||
| parameter.data.normal_(std=0.02) | ||
| # Then make the shared prefix identical, which is the only part the tapped | ||
| # hidden state depends on. | ||
| for (_, a), (_, b) in zip(full.layers[:tap].named_parameters(), | ||
| cut.layers[:tap].named_parameters(), | ||
| strict=True): | ||
| b.data.copy_(a.data) | ||
| torch.manual_seed(1) | ||
| inputs_embeds = torch.randn(1, 6, 16) | ||
| # mRoPE indexes three axes (t, h, w); text tokens share the same position on | ||
| # all three. | ||
| position_ids = torch.arange(6).view(1, 1, 6).expand(3, 1, 6) | ||
| with torch.no_grad(): | ||
| full_out = full(inputs_embeds, position_ids, None, True, None, None) | ||
| cut_out = cut(inputs_embeds, position_ids, None, True, None, None) | ||
|
|
||
| assert torch.equal(full_out.hidden_states[tap], cut_out.hidden_states[tap]) | ||
| # And the truncated model must not offer states it never computed. | ||
| assert len(cut_out.hidden_states) == tap + 1 | ||
| # The whole shared prefix must match, not just the tap: this is the same | ||
| # comparison the production-loader parity gate runs against the official | ||
| # model, and it is what catches a truncated stack that still applied the | ||
| # final norm to its last entry. | ||
| for index, (cut_state, full_state) in enumerate(zip(cut_out.hidden_states, full_out.hidden_states, | ||
| strict=False)): | ||
| assert torch.equal(cut_state, full_state), f"hidden state {index} changed under truncation" | ||
|
|
||
|
|
||
| def test_truncated_model_drops_the_surplus_checkpoint_keys(distributed_setup) -> None: | ||
| """The unexpected-key check is strict on purpose, so the surplus keys have | ||
| to be filtered rather than the check relaxed.""" | ||
| from fastvideo.models.encoders.minimax_h3_qwen3_vl import MiniMaxH3Qwen3VLConditioner | ||
|
|
||
| conditioner = MiniMaxH3Qwen3VLConditioner(_small_config(num_hidden_layers_override=5)) | ||
|
|
||
| assert conditioner._is_above_the_tap("language_model.layers.5.mlp.gate_proj.weight") | ||
| assert conditioner._is_above_the_tap("language_model.layers.7.self_attn.q_proj.weight") | ||
| assert conditioner._is_above_the_tap("language_model.norm.weight") | ||
| # Kept: layers we built, the embeddings, and the vision tower. | ||
| assert not conditioner._is_above_the_tap("language_model.layers.4.mlp.gate_proj.weight") | ||
| assert not conditioner._is_above_the_tap("language_model.embed_tokens.weight") | ||
| assert not conditioner._is_above_the_tap("visual.blocks.0.attn.qkv.weight") | ||
| # The filter only drops indexes the full stack would have built. A key at | ||
| # or above the checkpoint's own num_hidden_layers is corrupt, and it must | ||
| # keep raising as unexpected exactly as it does without truncation. | ||
| assert not conditioner._is_above_the_tap("language_model.layers.8.mlp.gate_proj.weight") | ||
| with pytest.raises(ValueError, match="Unexpected"): | ||
| conditioner.load_weights([("model.language_model.layers.8.mlp.gate_proj.weight", torch.zeros(1))]) | ||
|
|
||
|
|
||
| def test_full_stack_filters_nothing(distributed_setup) -> None: | ||
| from fastvideo.models.encoders.minimax_h3_qwen3_vl import MiniMaxH3Qwen3VLConditioner | ||
|
|
||
| conditioner = MiniMaxH3Qwen3VLConditioner(_small_config(num_hidden_layers_override=None)) | ||
|
|
||
| assert not conditioner._is_above_the_tap("language_model.layers.7.mlp.gate_proj.weight") | ||
| assert not conditioner._is_above_the_tap("language_model.norm.weight") |
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
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
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
MINIMAX_H3_RUN_ENCODER_PARITY=1, the existingtests/local_tests/encoders/test_minimax_h3_qwen3_vl_parity.pytest 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 withzip(..., 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 👍 / 👎.