Skip to content

[bugfix]: apply FP8 to MiniMax H3's feed-forward - #1780

Open
KyleNeverGivesUp wants to merge 4 commits into
hao-ai-lab:mainfrom
KyleNeverGivesUp:fp8-h3-ffn-suffix
Open

[bugfix]: apply FP8 to MiniMax H3's feed-forward#1780
KyleNeverGivesUp wants to merge 4 commits into
hao-ai-lab:mainfrom
KyleNeverGivesUp:fp8-h3-ffn-suffix

Conversation

@KyleNeverGivesUp

@KyleNeverGivesUp KyleNeverGivesUp commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

FP8Config.get_quant_method chooses what to quantize by substring-matching a layer's state-dict name against _FP8_SUFFIXES. That tuple carried ffn.fc_in and ffn.fc_out, which fits WanVideo and LTX-2. MiniMax H3 mounts its feed-forward on .ff, so the layer is transformer_blocks.0.ff.fc_in and "ffn.fc_in" in "transformer_blocks.0.ff.fc_in" is False.

All 104 H3 feed-forward linears were skipped, 50 main blocks and 2 refiner blocks at two linears each, and kept their bf16 weights.

Nothing surfaced it. H3's attention projections are named to_q, to_k, to_v and to_out, which the tuple already matched, so quantization ran without error, freed real memory, and produced correct video while covering 8.01B of the checkpoint's 22.09B parameters. Partial coverage was indistinguishable from full coverage.

Every model added so far has had its feed-forward registered, including Kandinsky5's in #1601. H3 is the only one where it is missing, so this is an omission against a consistent pattern rather than a deliberate exclusion.

What this changes

The two H3 names are matched by true suffix, not substring. They live in a separate _FP8_EXACT_SUFFIXES tuple checked with endswith. DiTConfig.prefix is settable from the CLI, so under a substring rule a root prefix containing .ff.fc_in would tag every descendant, including adaln_proj.linear, whose forward reads self.linear.weight.dtype after the converter has removed that weight. The original tuple keeps substring matching because it depends on it: to_q is also how Kandinsky5's to_query is reached.

WanVideo and GLM-Image are unaffected even though their state dicts also carry .ff.fc_in. Both build that module without passing quant_config or a prefix, so get_quant_method is never consulted for it. param_names_mapping only renames checkpoint keys during weight loading and takes no part in quantization selection.

Partial coverage is now reported. convert_model_to_fp8 counts what it quantized, what was handed an FP8Config but matched no entry, and what was never a candidate at all, then logs all three with the distinct trailing names of the unmatched layers. On H3 that reads:

FP8: quantized 312 linear layers holding 20.04B parameters; left 108 requested
linear layers holding 2.05B parameters in their loaded dtype because their names
match no _FP8_SUFFIXES entry: adaln_proj.linear, attn.to_gate_compress,
audio_proj_in, audio_proj_out, context_embedder, norm_out.linear, proj_in,
proj_out, time_embedder.fc_in, time_embedder.fc_out; 1 further linear layers
were never handed an FP8Config

The names are exactly what a new _FP8_SUFFIXES entry would have to spell, so the next model with different naming is one log line away from a fix rather than a code read.

Zero coverage warns. _maybe_quantize_model only calls the converter once it finds a layer carrying an FP8QuantizeMethod. If FP8 is requested and not one layer matches, the converter never runs and the log above never appears, so the worst case was the quietest. It now warns and names the layers it saw.

Measured

One GB10, 121 GiB unified memory, and KyleNeverGivesUp/FastH3-4-step-Preview-v1-r16, which is FastH3 Preview v1 with the rank-reduced AdaLN converter applied, at 22.09B parameters. Every number below can be reproduced against that checkpoint.

before after
quantized parameters 8.01B 20.04B
quantized linear layers 208 312
resident weights 44.23 GiB 32.99 GiB

This is not a speed lever, and the patch does not claim to be one. Generation is 1587 s against 1643 s at 512x896 with 124 frames, which is noise. That agrees with the existing guidance in docs/getting_started/installation/spark_performance.md that quantizing linear layers buys almost nothing on an attention-bound video model. What changes is what fits: on this machine the 11.24 GiB is the difference between a workload that is killed and one that completes.

Output quality

Same seed, resolution and frame count, with --fp8 as the only variable.

SSIM is 0.669 and PSNR is 19.3 dB against the bf16 run, but neither measures degradation here. FP8 perturbs the model's prediction at each step, so the trajectory diverges and the result is a different video rather than a degraded copy of the same one.

What does transfer: mean luma is 106.4 against 106.2 and standard deviation is 61.2 against 61.1 across three sampled frames, so there is no exposure or contrast drift. Visual inspection of those frames shows no blocking, no banding, no texture collapse and no structural error. The chroma channels track closely, PSNR u 32.1 and v 34.8, while luma carries the divergence, which is where the difference in sampled content lives.

Callers who want tighter accuracy already have granularity="channel", documented in docs/inference/optimizations.md.

Tests

fastvideo/tests/ops/quantization/test_fp8_ffn_suffix_wiring.py, six CPU-only cases in a directory unit_test.sh already collects.

  • both real H3 block prefixes get an FP8QuantizeMethod, transformer_blocks.N.ff and token_refiner.refiner_blocks.N.ff
  • a real MiniMaxH3TokenRefinerBlock is constructed, so removing the quant_config or .ff propagation from the block fails even with the tuple intact
  • WanVideo's ffn.fc_in and ffn.fc_out still match
  • an unrelated projection still falls back to UnquantizedLinearMethod
  • a root prefix of tenant.ff.fc_in does not drag adaln_proj.linear into FP8
  • the conversion log reports the right layer counts, parameter totals and unmatched names, asserted on the LogRecord arguments rather than the rendered string, which formats both totals to 0.00B at test scale

Reverting the two new entries turns three of the six red and leaves the three that should stay green.

On the GB10: 34 passed across fastvideo/tests/ops/quantization/, 5 passed in fastvideo/tests/loader/test_fsdp_load_releases_checkpoint.py, mypy clean, and an end-to-end H3 generation confirming the new log and unchanged component residency.

Not in this patch

The underlying shape is a hardcoded allowlist where partial coverage is indistinguishable from full coverage. A deeper fix would have models declare their own quantizable layers, but that touches WanVideo, LTX-2 and Kandinsky5 and belongs in its own change. The reporting added here makes the current mechanism's misses visible in the meantime.

This module uses logging.getLogger rather than init_logger, matching all five quantization modules beside it. Switching it would make this file the odd one out, so that also belongs in its own change.

@mergify mergify Bot added type: bugfix Bug fix scope: infra CI, tests, Docker, build scope: model Model architecture (DiTs, encoders, VAEs) labels Aug 28, 2026
@mergify

mergify Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 1 protections blocking · waiting on 👀 reviews and 🤖 CI

Protection Waiting on
🔴 PR merge requirements 👀 reviews and 🤖 CI

🔴 PR merge requirements

Waiting for

  • #approved-reviews-by>=1
  • check-success=full-suite-passed
This rule is failing.
  • #approved-reviews-by>=1
  • check-success=full-suite-passed
  • check-success=fastcheck-passed
  • check-success~=pre-commit
  • title~=(?i)^\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model|skill|skills|infra)\]

@KyleNeverGivesUp KyleNeverGivesUp changed the title [bugfix]: apply FP8 to MiniMax H3's feed-forward, freeing 11.24 GiB of weights [bugfix]: apply FP8 to MiniMax H3's feed-forward Aug 28, 2026

@SolitaryThinker SolitaryThinker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FP8 matching and coverage-reporting code looks sound in focused review: five of the six exact remote tests passed on this CPU host, a separate zero-coverage probe emitted the expected warning, and fastcheck/pre-commit are green. I am requesting changes for the sixth test, which is described as CPU-only but fails before its FP8 assertion on a CPU-only machine. Please also amend the commit to remove the Co-Authored-By: Claude Opus 5 trailer; FastVideo maintainer policy does not allow AI agents as co-authors.

from fastvideo.models.dits.minimax_h3 import MiniMaxH3TokenRefinerBlock
from fastvideo.platforms import AttentionBackendEnum

block = MiniMaxH3TokenRefinerBlock(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: This test is not CPU-only as written. At exact head 6fd11aa, the suite gives 5 passed, 1 failed on a CPU host: constructing MiniMaxH3TokenRefinerBlock reaches get_attn_backend(...) and raises ValueError: Invalid attention backend for CPU before either FP8 assertion runs. The exact minimax_h3.py and selector blobs match the versions used in the reproduction. Please mock the backend/platform selection or isolate the block-to-FF wiring so this test honors the file's CPU-only contract.

".ff.fc_in",
".ff.fc_out",
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: These new H3 suffixes expose the existing post-load FP8 converter to sharded FSDP inference. maybe_load_fsdp_model calls fully_shard before _maybe_quantize_model; conversion then quantizes only weight.to_local(), registers that local shard as an ordinary _fp8_weight buffer, and removes the FSDP-managed parameter. FP8QuantizeMethod.apply reads the local buffer directly, so no FSDP all-gather occurs. I reproduced this on two CPU ranks: logical (8,4) / (4,8) DTensor weights became local (4,4) / (2,8) buffers, and both ranks failed the FF forward with mat1 and mat2 shapes cannot be multiplied (2x4 and 8x2). use_fsdp_inference=True is a documented multi-GPU path, so please reject FP8 when the FSDP shard dimension is greater than one until supported, or keep the quantized representation under FSDP unshard/all-gather semantics. Add a multi-rank regression test before reporting these layers as successfully quantized.

…f weights

FP8Config.get_quant_method picks layers by substring-matching their
state-dict name against _FP8_SUFFIXES. The list carried "ffn.fc_in" and
"ffn.fc_out", which fits WanVideo and LTX-2, but MiniMax H3 mounts its
feed-forward on .ff, so "ffn.fc_in" in "blocks.0.ff.fc_in" is False and
every H3 feed-forward kept its bf16 weights.

Nothing surfaced it. H3's attention projections are named to_q/to_k/to_v/
to_out, which the list already matched, so quantization ran, saved memory
and produced video while covering 8.01B of the checkpoint's 22.09B
parameters. Partial coverage was indistinguishable from full coverage.

Add the two H3 names. The leading dot keeps them from matching an
unrelated "stuff.fc_in", and WanVideo and GLM-Image are unaffected even
though their state dicts also carry ".ff.fc_in": both build that module
without a quant_config or a prefix, so get_quant_method never runs for it.

Also report what was left behind. The conversion now logs the quantized
and skipped parameter totals along with the distinct trailing names that
matched no entry, so the next model with different naming shows up as
"left 14.08B parameters ... ff.fc_in, ff.fc_out" rather than as silence.

Measured on one GB10 with the r16 MiniMax H3 checkpoint: quantized share
8.01B to 20.04B, resident weights 44.23 to 32.99 GiB. Generation time is
unchanged, 1587 s against 1643 s at 512x896, consistent with the existing
guidance that linear quantization is not a speed lever on long-sequence
video models.
…U-only

Both items from the review on hao-ai-lab#1780.

The wiring test declared itself CPU-only but built a real
MiniMaxH3TokenRefinerBlock, whose attention resolves a backend through the
platform. A CPU-only host answers with an empty qualname and the selector
raises before either FP8 assertion runs, so on such a host the file reported
5 passed 1 failed instead of exercising the contract. A fixture resolves that
one lookup to SDPA. Nothing in the test runs attention.

_maybe_quantize_model runs after shard_model, so convert_model_to_fp8 sees
sharded DTensor weights. It reads weight.to_local, registers that shard as a
plain _fp8_weight buffer and pops the FSDP-managed parameter, which leaves
nothing for FSDP to all-gather and a forward that multiplies mismatched
shapes. The loader now refuses when hsdp_shard_dim is greater than one and the
model carries layers the converter would touch.

The check keys on FP8QuantizeMethod rather than on FP8Config, so a model that
requested FP8 and matched no suffix converts nothing and is not rejected, and
AbsMaxFP8, FP8QAT and NVFP4QAT are left alone. It sits inside the use_fsdp
branch and ahead of the device mesh, which is where the sharding decision is
made and the only point a CPU host can reach, since the mesh is hard-coded to
CUDA for every non-NPU platform.

A two-rank gloo test records the conversion behaviour that makes the refusal
necessary and will fail if FP8 ever becomes FSDP-aware, which is the signal to
drop the guard. Two further cases assert the helper does not over-fire.
Resolves the one conflict, in fsdp_load.py. hao-ai-lab#1771 added _summarize_param_names
at the same position where this branch adds _has_fp8_convertible_layers. Both
are new and unrelated, so both are kept. The to_cpu=cpu_offload change from
hao-ai-lab#1793 comes in from main unchanged.
…ER_PORT

Fixes three problems in the tests added by the previous commit.

The block-wiring fixture swapped in a hand-written platform stub that defined
only is_mps and get_attn_backend_cls. Constructing the block reaches
set_weight_attrs, which calls current_platform.is_tpu on every linear, so the
test raised AttributeError inside the attention to_q before the feed-forward
was built and before either FP8 assertion ran. It now pins the real
CpuPlatform, which answers the whole current Platform interface rather than
the two methods that happened to be needed.

The two-rank test wrote MASTER_ADDR and MASTER_PORT into the environment.
test_gpu_tests_preserve_a_launcher_assigned_rendezvous_port scans every file
under fastvideo/tests and fails on a written MASTER_PORT, because that
clobbers the port the CI runner leased to the lane. The rendezvous now goes
through init_method, and the os import is gone with it.

The loader guard test passed device=cpu, which does not change the global
platform. maybe_load_fsdp_model turns FSDP off on MPS, so on a Mac the guard
sat inside a branch that never ran and the test failed later for an unrelated
reason. It pins CpuPlatform too, so every host takes the same path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: infra CI, tests, Docker, build scope: model Model architecture (DiTs, encoders, VAEs) type: bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants