MiniMax-H3-Pruned / README.md
multimodalart's picture
multimodalart HF Staff
Answer time_embedder.linear_1.weight.dtype for diffusers before #14398
1a0ef5e verified
|
Raw
History Blame Contribute Delete
27.5 kB
---
pipeline_tag: image-text-to-video
license: other
license_name: minimax-h3-community-license-agreement
license_link: https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/LICENSE
library_name: diffusers
base_model: MiniMaxAI/MiniMax-H3
tags:
- text-to-video
- image-to-video
- image-text-to-video
- text-to-audio-video
- image-to-audio-video
- reference-to-audio-video
- audio-video-generation
- multimodal
- lora
---
# MiniMax-H3, AdaLN-pruned
[MiniMaxAI/MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) in diffusers format with the AdaLN input
projections reduced to their reachable rank. **52 GB smaller** across the two DiT partitions, no visible change in
output, and every published H3 LoRA loads - pruned-trained ones natively, released-trained ones projected.
| | released | this repo |
|---|---|---|
| `transformer/` | 66.28 GB | **40.24 GB** |
| `transformer_ref/` | 66.28 GB | **40.24 GB** |
| DiT parameters, per partition | 33.14 B | 20.11 B |
| `text_encoder/` | 66.71 GB | **52.48 GB** |
The conditioner is hosted here too, truncated: MiniMax-H3 conditions on the *unnormalized* hidden state after the
Qwen3-VL conditioner's 50th decoder layer (`hidden_states[50]`), so decoder layers 51-63 and the language-model head
can never reach the conditioning and are dropped. `text_encoder/` ships decoder layers 0-50 - the 51st is kept so
`hidden_states[50]` stays a pre-norm intermediate rather than the final-norm output - and its embeddings are **bitwise
identical** to the released conditioner's, text-only and vision-bearing presentations alike. It stays bfloat16; a
quantized conditioner is planned as a separate variant, pending its own quality measurement.
Both VAEs, both schedulers, the tokenizer and the processor are *not* duplicated here. `modular_model_index.json`
points those at `MiniMaxAI/MiniMax-H3`, so loading this repo pulls only the pruned partitions, the truncated
conditioner, and the released components you already have.
## What was removed, and why it is safe
Each of the 50 blocks holds `adaln_proj.linear`, a `Linear(2688 -> 96768)`, and `norm_out.linear` adds one more.
Together that is **13.03 B parameters, 39.3% of the checkpoint**. All 51 of them read the same vector,
`silu(time_embedder(t))`, which is a function of the scalar timestep alone - so however wide it is, the set of
values it ever takes is a one-dimensional curve in `R^2688`.
An 8-dimensional affine subspace covers that curve to a relative RMS error of **1.45e-5**. One bfloat16 rounding step
of the weights those projections are stored in is about **3.9e-3** - roughly 250x larger. So the projections are
folded onto that subspace: `W @ x + b` with `x = mean + c @ basis` becomes `(W @ basis.T) @ c + (b + W @ mean)`, the
timestep MLP is replaced by a 1025-entry table of the coordinates `c(t)` (linearly interpolated), and every AdaLN
projection takes an 8-wide input instead of a 2688-wide one.
### Measured fidelity
Every folded projection, evaluated over 200 off-grid timesteps against an exact float64 evaluation of the released
weights. "Released" is the released checkpoint running its own bfloat16 arithmetic; "pruned" is this checkpoint
running its.
| | `transformer` | `transformer_ref` |
|---|---|---|
| rank-8 subspace residual (no storage rounding) | 1.51e-5 | 1.29e-5 |
| **pruned** vs exact float64, worst of 51 modules | **1.715e-3** | **1.715e-3** |
| **released** vs exact float64, worst of 51 modules | 1.751e-3 | 1.758e-3 |
| modules where pruned is closer to exact than released | **51 / 51** | **51 / 51** |
| pruned vs released, worst of 51 modules | 1.35e-3 | 1.41e-3 |
The approximation error is ~100x below the arithmetic the released checkpoint already runs at, which is why the
pruned model lands *closer* to the exact answer than the released one does, at every single projection.
### End to end, with a control
Same-seed `t2va` - 10 steps, 960x544, 124 frames, one cached conditioning, seed 42 - comparing the final latents
handed to each VAE. A 10-step video diffusion trajectory amplifies *any* bfloat16-level perturbation, so the number
that matters is not the raw divergence but how it compares to a perturbation that is definitionally harmless. The
control is the **released** model against **itself**, run once with FlashAttention-3 and once with the native
backend: mathematically the same computation, differing only in the order bfloat16 sums are accumulated.
| video latents | cosine | relative L2 | max abs |
|---|---|---|---|
| released (FA3) vs released (native) - *control, same weights* | 0.99248 | 0.1227 | 7.66 |
| **released (FA3) vs pruned (FA3)** - *the fold* | **0.99128** | **0.1321** | **8.59** |
| released (native) vs pruned (FA3) | 0.99274 | 0.1205 | 7.71 |
| audio latents | cosine | relative L2 | max abs |
|---|---|---|---|
| released (FA3) vs released (native) - *control, same weights* | 0.99964 | 0.0292 | 0.255 |
| **released (FA3) vs pruned (FA3)** - *the fold* | **0.99911** | **0.0437** | **0.358** |
| released (native) vs pruned (FA3) | 0.99972 | 0.0236 | 0.157 |
The fold moves the trajectory about as far as changing the attention kernel does, and *less* far than that when
measured from the native-backend run. Latent statistics are unchanged: video latent standard deviation is 2.146331
released against 2.146338 pruned. Whatever this model's 10-step trajectory is sensitive to, it is not the pruning.
**Not bitwise.** This is a refactor of a numerical function, not a lossless re-encoding: rounding the folded weights
into bfloat16 lands on different bits than the released weights did. It is *effectively* lossless in bfloat16 - the
approximation the fold introduces is about 250x below one rounding step of the weights it replaces, and what reaches
the output is the size of a change in summation order.
## Usage
```python
import torch
from diffusers import ComponentsManager, ModularPipeline
manager = ComponentsManager()
pipe = ModularPipeline.from_pretrained(
"multimodalart/MiniMax-H3-Pruned", workflow="t2va", trust_remote_code=True, components_manager=manager
)
pipe.load_components(dtype=torch.bfloat16, trust_remote_code=True)
manager.enable_auto_cpu_offload(device="cuda")
result = pipe(
prompt="A red fox trotting through a snowy pine forest, snow crunching underfoot",
height=544,
width=960,
num_frames=124,
num_inference_steps=50,
generator=torch.Generator().manual_seed(42),
output=["videos", "audio", "sampling_rate"],
)
```
`trust_remote_code=True` is needed on **both** calls: the first resolves this repo's pipeline wiring, the second
resolves the transformer class in [`transformer/modeling_minimax_h3_pruned.py`](transformer/modeling_minimax_h3_pruned.py).
Read it before you run it - it is one file, and it subclasses `MiniMaxH3Transformer3DModel` without touching
`forward`.
The timestep embedder answers `linear_1.weight.dtype` with the table's float32, as a property rather than a module:
diffusers before [#14398](https://github.com/huggingface/diffusers/pull/14398) reads the released timestep MLP's
dtype off that attribute, and this checkpoint has no MLP to read it from. It is a dtype answer and nothing else -
it reaches no `state_dict`, no `named_modules`, no quantizer scan - and can go once every consumer carries #14398.
`workflow="t2va"` and `workflow="fl2va"` load `transformer/`; `workflow="ref2va"` loads `transformer_ref/`. Only the
partition a workflow needs is downloaded.
26 GB of headroom also buys speed wherever weights are being moved. On one 80 GB H100 with automatic CPU offload,
the parity run above took **94 s** pruned against **205 s** released, at a 52.7 GB peak against 65.2 GB - not because
the arithmetic is cheaper (the AdaLN projections were never the bottleneck) but because far less of the model has to
be paged in and out per step. On a card with room for all of it, expect the two to run at the same speed.
## LoRAs
**Every published H3 LoRA loads here** - the ones trained against a pruned checkpoint natively, the ones trained
against the released one by projection.
### Pruned-trained LoRAs, natively
ai-toolkit trains against a pruned checkpoint by default, so its H3 LoRAs carry `adaln_proj.linear.lora_A` of shape
`[rank, 8]`. That is a size mismatch against the released `Linear(2688, ...)` and an exact fit here.
This works because the coordinates are **not ours**. A rank-8 basis is unique only up to sign and rotation, so a
pruned-trained LoRA is portable only between checkpoints that share the coordinate convention. Rather than derive a
basis, the one the ecosystem already trains against was extracted: `time_embedder.table` is the `adaln_t_table` from
the [Comfy-Org](https://huggingface.co/Comfy-Org/MiniMax-H3) pruned files, **byte for byte**, and the basis was
solved for from that table against the released timestep MLP.
Both published pruned-trained LoRAs were checked against their trainer's own arithmetic - `lora_B @ (lora_A @ c(t))`
with `c(t)` interpolated from the shipped table - over a 64-point timestep sweep:
| coordinates the update is read in | relative error vs the trainer's own result |
|---|---|
| **this repo (shipped table, verbatim)** | **0.0, exactly** |
| a sign gauge differing on 3 of 8 columns (what a re-derived SVD lands on) | 0.054 - 0.078 |
| an arbitrary rotation of the same 8-dimensional subspace | 1.10 - 1.63 (cosine as low as -0.97) |
The last row is why the table is copied and not recomputed: a re-derived basis spans the same subspace and is equally
accurate, and would still turn every existing LoRA into noise. Both files load with all 50 AdaLN projections wrapped
by PEFT at scale exactly 1.0, 362 modules in total.
### Released-trained LoRAs, by projection
LoRAs trained against the **released** checkpoint's 2688-wide AdaLN projections - the 4-step turbo LoRA and its
conversions - are mapped onto these coordinates when they load. Nothing about them is approximated beyond what the
pruning already approximates: the identity that folded the weights folds the adapter.
A LoRA on the released projection contributes `lora_B @ (lora_A @ x)`, and `x = mean + c @ basis`, so
```
lora_B @ (lora_A @ x) = lora_B @ ((lora_A @ basis.T) @ c) + lora_B @ (lora_A @ mean)
```
`lora_A @ basis.T` is an `[rank, 8]` `lora_A` over the pruned coordinates, at the same rank; `lora_B` is unchanged;
and what is left over is a **constant**, which no `Linear(8 -> ...)` can produce. That constant is carried as a
float32 offset on the modulation, alongside the fold's own `folded_bias` and for the same reason - it is ~98% of
what these adapters do to the AdaLN path, so rounding it into bfloat16 would spend a full rounding step of the
modulation on almost the whole update. The factors are computed in float64.
`adaln_basis` and `adaln_mean` ship as `adaln_affine.safetensors` (97 KB) beside each partition's weights. They are
the map the projections were folded with, read back, not re-derived - for the same reason the table is copied and
not recomputed.
**Why not PEFT's `lora_bias`.** That is the obvious place to put a constant, and it is the wrong one here.
`lora_bias` is a `LoraConfig` flag, not a per-module one, so switching it on for the 51 AdaLN modules also switches
it on for the other 312: it warns on every bias-free target, creates zero biases on the attention and feed-forward
modules that then read as missing keys, and makes `fuse_lora` raise `Impossible to merge LoRA with lora_bias=True
because the base layer has no bias` on all 51 - `adaln_proj.linear` is deliberately bias-free, which is what makes
PEFT wrap it exactly as it wraps the released projection. It would also put the constant in bfloat16, on the wrong
side of the argument this repo already made for `folded_bias`. The float32 buffer beside `folded_bias` avoids all
four, and PEFT still owns the state that matters: the offsets are read through `active_adapters`, `scaling` and
`disable_adapters`, so they follow `set_adapters`, `disable_lora` and `delete_adapters` on their own.
Measured per AdaLN module over 200 off-grid timesteps, projected against full-space, both in float64:
| | worst of 51 | mean of 51 |
|---|---|---|
| rank-8 subspace residual on the timestep curve | 1.47e-5 | - |
| [`larryvrh/MiniMax-H3-Turbo-Lora`](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora) v4 step 600 EMA | **2.05e-5** | 1.26e-5 |
| [`InstantX/MiniMax-H3-Turbo-Lora-Diffusers`](https://huggingface.co/InstantX/MiniMax-H3-Turbo-Lora-Diffusers) | **1.53e-5** | 0.88e-5 |
which is the subspace residual and nothing else. Both files attach to **363 modules** - 312 attention/feed-forward
and token-refiner ones untouched, 51 AdaLN ones projected - all at scale exactly 1.0. Loaded on top of the released
weights and on top of these, the LoRA'd AdaLN function was compared against an exact float64 evaluation at three
probe modules: the pruned side is the closer of the two at all three, the same way the fold itself is.
End to end, 960x544, 124 frames, 7 steps, seed 42, `larryvrh` v4, against the identical request on the released
weights: **cosine 0.9958** with the LoRA on and **0.9992** with it off, and the on/off difference - the turbo effect
itself - is 0.9609 here against 0.9641 there. Frame for frame the two runs are the same video.
How much of that is the AdaLN half? On these particular files, not much - their AdaLN update is about 2e-4 of the
modulation, and simply *discarding* those 51 modules lands at 0.9960 against the released run where projecting
lands at 0.9958, which is the same number twice. What is not the same number is projecting them and dropping the
constant term: **0.9947**. The half-applied version is the only one of the three that is measurably wrong, which
is both the argument for where the constant is kept and the reason to do this exactly rather than approximately -
an adapter that leans harder on AdaLN than a turbo LoRA does has no other way to arrive intact.
A file whose AdaLN modules are not uniformly 8-wide or uniformly 2688-wide raises rather than loading some of them:
an adapter that applies to 50 of its 51 AdaLN projections is not the adapter anyone trained.
One caveat: the projection happens at load, and the constant terms are not PEFT parameters, so
`save_lora_weights` on a projected adapter writes the projected factors *without* them. Distribute the original
file, not a re-save of it.
Everything outside `adaln_proj` and `norm_out` - attention, feed-forward, the token refiner - is untouched by the
pruning and was always loading either way.
LoRA loading for MiniMax-H3 landed in diffusers via
[huggingface/diffusers#14408](https://github.com/huggingface/diffusers/pull/14408). This repo declares the stock
`MiniMaxH3ModularPipeline`, which is where that PR attaches the loader - so on a diffusers without it the repo loads
and generates normally, and gains `load_lora_weights` the moment the PR is in your install. Nothing here needs to
change.
## 8-bit compute
The transformer's attention and feed-forward matmuls can run in **int8** - not int8 weights dequantized back to
bfloat16, but int8 tensor cores multiplying int8 activations by int8 weights - at a quality **above** what int8
weight-only gets, and, once the blocks are compiled, slightly faster than bfloat16. Two extra lines:
```python
transformer = pipe.transformer
transformer.enable_convrot() # fold H into the 300 attention/feed-forward weights
from torchao.quantization import Int8DynamicActivationInt8WeightConfig, quantize_
quantize_(transformer, Int8DynamicActivationInt8WeightConfig(), filter_fn=transformer.convrot_filter)
transformer.compile_repeated_blocks(fullgraph=False) # not optional, see the speed table
```
`transformer.quantize_8bit()` does the same thing one linear at a time - the working set is a single weight
rather than the model - which is what you want when the bfloat16 model is on the CPU and the card is small:
```python
transformer.quantize_8bit(device="cuda") # rotate + quantize per layer, then put each layer back
```
Both need `torchao` (0.17 was what this was measured on) and, for the compile step, a diffusers with
`compile_repeated_blocks`. Neither touches the checkpoint on disk.
Nothing is serialized: the rotation is a fixed, parameter-free matrix, so there is no second copy of the weights
to download. `transformer/` stays the 40.24 GB bfloat16 partition and becomes a 19.5 GB int8 model in memory.
### What ConvRot is
Each quantized `Linear` gets its weight replaced by `W @ H` and its input by `x @ H`, where `H` is a
block-diagonal stack of 256x256 *regular* Hadamard blocks (Kronecker powers of the symmetric order-4 seed,
scaled by `1/16`). `H` is symmetric and orthogonal, so it is its own inverse and `(x @ H) @ (W @ H)^T` is
`x @ W^T` exactly. The function does not change. The *distribution* does: every coordinate of `x @ H` is a
+-1 combination of 256 input channels, so one outlier channel stops setting the quantization scale for its
whole row.
The reason this is cheap to adopt is that the rotation is created and destroyed inside a single matmul. There
is no rotated residual stream, nothing that has to commute with the AdaLN modulation, and no calibration data.
### Measured
`t2va`, seed 42, 960x544, 124 frames, 20 steps, one cached conditioning, FlashAttention-3, H100. Every leg is
the same request against the same bfloat16 baseline; only the transformer's linears differ. "8-bit compute"
means the matmul itself runs at 8 bits - weight-only quantization dequantizes and multiplies in bfloat16.
| | 8-bit compute | video cos | video PSNR | audio cos | video-latent cos | s/step | peak |
|---|---|---|---|---|---|---|---|
| bfloat16 | - | 1 | inf | 1 | 1 | 3.51 | 52.7 GB |
| *control: bfloat16, blocks compiled* | *-* | *0.9940* | *23.17 dB* | *0.9968* | *0.9907* | *3.03* | *52.7 GB* |
| int8 weight-only | no | 0.9773 | 17.36 dB | 0.9852 | 0.9657 | 3.74 | 34.8 GB |
| fp8 dynamic, every bfloat16 linear | yes | 0.9585 | 14.83 dB | 0.8386 | 0.9367 | 3.49 | 34.0 GB |
| fp8 dynamic | yes | 0.9583 | 14.81 dB | 0.8775 | 0.9416 | 3.50 | 34.8 GB |
| fp8 dynamic + **ConvRot** | yes | 0.9678 | 15.84 dB | 0.9263 | 0.9469 | 3.57 | 34.8 GB |
| int8 dynamic | yes | 0.9819 | 18.37 dB | 0.9906 | 0.9702 | 11.23 | 34.8 GB |
| **int8 dynamic + ConvRot** | **yes** | **0.9893** | **20.65 dB** | **0.9943** | **0.9819** | 11.28 | 34.8 GB |
The control row is the bfloat16 model against *itself*, run once eagerly and once with the blocks compiled:
identical weights, different kernels and a different summation order, 0.9940 video cosine. That is the floor
this comparison can resolve over 20 diffusion steps, and int8 + ConvRot at 0.9893 sits just above it.
Two more things in that table are worth stating plainly.
**ConvRot buys almost nothing for fp8 and everything for int8.** Per-layer, on activations captured from a real
denoise, the mean relative error of the matmul goes 0.0278 -> 0.0269 for fp8 (a 3% improvement) and
0.0242 -> 0.0101 for int8 (a 2.4x improvement, and 0.0387 -> 0.0104 on the feed-forward down-projection alone).
fp8's error is a mantissa floor - a floating-point format's relative accuracy does not care how large the
outliers are - so spreading the outliers does not help it. int8's error *is* the outliers, which is exactly what
a rotation removes. The same study says the popular alternative is the weaker one: SmoothQuant/AWQ
per-input-channel pre-scales, calibrated on those activations, take int8 from 0.0242 only to 0.0171, and buy a
further 0.0009 on top of ConvRot. This repo ships no pre-scales, and neither do ComfyUI's H3 checkpoints.
ComfyUI's own fp8 build concedes the same point from the other side: in
`minimax_h3_*_pruned_fp8_scaled.safetensors`, all 50 `mlp.fc2` layers carry
`"full_precision_matrix_mult": true` and no `input_scale` - the feed-forward down-projection is the one they
refuse to give 8-bit activations to without a rotation.
**int8 with ConvRot lands above int8 weight-only.** 0.9893 against 0.9773 video cosine, 20.65 dB against
17.36 dB - so on this model there is no accuracy argument left for giving up 8-bit compute. Its per-layer
matmul error, 0.0101, is within a fifth of int8 weight-only's 0.0086, which itself only quantizes half the
operands.
### Speed, and why `compile_repeated_blocks` is not optional
torchao's dynamic-activation paths are eager-hostile: the quantize step is a separate kernel writing a
full-size intermediate. Steady-state, dropping the compile warmup:
| | eager | compiled |
|---|---|---|
| bfloat16 | 3.51 s/step | 3.03 s/step |
| fp8 dynamic + ConvRot | 3.57 s/step | 2.47 s/step |
| **int8 dynamic + ConvRot** | 11.28 s/step | **2.90 s/step** |
Eager int8 dynamic is 3.2x *slower* than bfloat16 and worth nobody's time. Compiled, it is faster than
bfloat16. One `[4096, 14336] x [14336, 5376]` matmul on this H100, all compiled: bfloat16 1.14 ms, int8 dynamic
0.89 ms, int8 dynamic + ConvRot 1.02 ms, fp8 dynamic 0.77 ms. The online rotation costs 0.13 ms, about 11% of a
bfloat16 matmul, and it is the only thing ConvRot adds at inference.
`torch._int_mm` through cuBLAS is poor on Hopper specifically (98 TOPS measured, against 522 TFLOPS bfloat16 on
the same shape); Inductor's Triton int8 kernel gets 0.68 ms where cuBLAS gets 0.83 ms - another reason the
compile step is load-bearing here. On Ampere and Ada, where int8 tensor cores are the well-trodden path and (on
Ampere) the only 8-bit ones that exist, this recipe is the whole point.
**fp8 is the faster leg and the worse one** - 2.47 against 2.90 s/step, 0.9678 against 0.9893 video cosine.
Take it only if you are on Ada or newer and buying 15% of step time with a visible amount of accuracy;
`enable_convrot` followed by `Float8DynamicActivationFloat8WeightConfig(granularity=PerRow())` is the same two
lines.
### What is left alone
The 300 rotated-and-quantized linears are `attn.to_q`, `attn.to_k`, `attn.to_v`, `attn.to_out.0`,
`ff.net.0.proj` and `ff.net.2` of the 50 transformer blocks. Untouched, in their checkpoint precision: the
whole AdaLN path (`time_embedder.table`, `adaln_proj.linear`, `norm_out`), the patch projections and output
heads (float32), `context_embedder`, the two token-refiner blocks, and every norm. That is the same set
ComfyUI's `*_int8_convrot` files quantize, arrived at from their tensor inventory.
### LoRAs still work
`MiniMaxH3ConvRotLinear` is a plain `nn.Linear` subclass, so PEFT wraps it as a `base_layer` and a LoRA's own
branch reads the **unrotated** input in bfloat16. A LoRA trained against the ordinary checkpoint is therefore
correct on top of a rotated, quantized base - and `adaln_proj`, which is where pruned-trained LoRAs put most of
their weight and where a released-trained one is projected, is never rotated or quantized at all.
The order below is unchanged by the AdaLN projection: the projection happens at load, before anything is rotated,
and neither `enable_convrot` nor `quantize_` touches the modules it wrote to. Verified on `larryvrh` v4 - the
AdaLN update at three probe modules is bit-identical before and after quantizing, with all 363 modules and all 51
constant terms still attached.
The order matters, and there is one thing not to do:
```python
pipe.load_lora_weights(...) # first
transformer.enable_convrot() # then rotate - `convrot_layers` matches PEFT `base_layer`s too
quantize_(transformer, ..., filter_fn=transformer.convrot_filter)
```
Do **not** `fuse_lora` after rotating: a fused update is expressed in the unrotated basis and would be added to
a weight that no longer lives there. Fuse first, or do not fuse.
### Credit
The transform is ComfyUI's. `convrot` ships in
[`comfy-kitchen`](https://github.com/comfyanonymous/ComfyUI)'s int8 kernels and in
[`Comfy-Org/MiniMax-H3`](https://huggingface.co/Comfy-Org/MiniMax-H3)'s `*_int8_convrot` checkpoints; this repo
reimplements it on top of stock `torchao`. The reimplementation was checked against their shipped tensors:
rotating a released weight in float32 and quantizing it row-wise reproduces
`blocks.0.attn.out_proj.weight` from `minimax_h3_fl2va_pruned_int8_convrot.safetensors` **exactly** - all
38,535,168 int8 codes and every scale, bit for bit.
## Provenance
The pruned format is not an invention of this repo. It ships in the ComfyUI ecosystem as
[`Comfy-Org/MiniMax-H3`](https://huggingface.co/Comfy-Org/MiniMax-H3)'s `*_pruned_*` files - a top-level
`adaln_t_table` of shape `[1025, 8]`, no `time_embedder.*`, and `[out, 8]` AdaLN projections - and
[Wan2GP](https://github.com/deepbeepmeep/Wan2GP) publishes the derivation as
`models/minimax_h3/prune_checkpoint.py`, which is where the construction here was learned from.
What this repo did *not* do is re-run that derivation. Since an SVD basis is only defined up to sign and rotation,
re-deriving it would have produced a subspace that is equally accurate and incompatible with every LoRA already
trained. Instead:
1. `adaln_t_table` was read out of `minimax_h3_fl2va_pruned_bf16.safetensors` and
`minimax_h3_ref2va_pruned_bf16.safetensors` and is stored here unchanged as `time_embedder.table`.
2. The basis and offset were recovered as the least-squares solution of `[table, 1] @ affine = curve` in float64,
where `curve` is `silu(time_embedder(t))` from the **released** weights on the table's own 1025-point grid. The
recovered offset matches the curve's mean to 3.8e-8, confirming the mean-centred gauge the producers used.
3. Cross-check: folding the released weights with the recovered basis reproduces the shipped pruned weights to
cosine **0.9999999** and relative L2 4.3e-4 (`transformer`) / 5.9e-4 (`transformer_ref`), which is fp16 storage
error - the dtype those files ship in. Folding with the *other* partition's basis gives relative L2 4.5e-2, a
100x separation, so the check has power and the `fl2va` / `ref2va` files map onto `transformer/` and
`transformer_ref/` respectively.
4. The weights themselves come from the released bfloat16 tensors, folded in float64 and rounded to bfloat16 exactly
once - not from the fp16 pruned files.
Wan2GP's own basis differs from Comfy-Org's by a per-column sign flip on 3 of the 8 columns; that is the concrete
reason step 1 extracts rather than recomputes.
One deliberate departure from the ComfyUI layout: the folded bias `b + W @ mean` is kept in **float32**, as a
separate `folded_bias` buffer, rather than rounded into the projection's bfloat16 bias. It carries almost the entire
modulation (peak 3.21 against the output's 3.27), so rounding it to bfloat16 costs a full output-scale step on every
evaluation and would put the pruned model *behind* the released one. In float32 it costs 20 MB per partition and puts
it ahead. `adaln_proj.linear` stays a plain bias-free `nn.Linear`, so PEFT wraps it exactly as it wraps the released
projection.
## Credits
- [MiniMax](https://huggingface.co/MiniMaxAI) for MiniMax-H3 and its released diffusers weights.
- [Wan2GP](https://github.com/deepbeepmeep/Wan2GP) (deepbeepmeep) for `prune_checkpoint.py`, the published
derivation of the rank-8 AdaLN fold.
- [Comfy-Org](https://huggingface.co/Comfy-Org/MiniMax-H3) for the pruned checkpoint format and the specific
`adaln_t_table` this repo adopts, which is what makes existing LoRAs portable.
- [ai-toolkit](https://github.com/ostris/ai-toolkit) (ostris), whose H3 trainer targets the pruned format and is why
most third-party H3 LoRAs are 8-wide.