--- 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.