Files
cicalooo-ComfyUI-H3-PowerLo…/README.md
T
doggeddalle 0e47a77976 Add H3 LoRA scheduling for runtime strength
Introduce runtime scheduling of per-row LoRA strengths. Adds h3lora/schedule.py (Schedule, ScheduleState, parsing, progress_from), new H3LoraSchedule node in nodes.py, and wiring in apply.py to resolve per-row schedules and preserve bias deltas. branch.py extended with FusedBranch, schedule-aware fusion, LoraBank/LoraBranch changes, and ScheduleController wrapper (attach now returns controller). README updated and unit tests added (tests/test_schedule.py, tests/test_branch_schedule.py) to validate curves, domains, thread-local state and controller nesting.
2026-08-09 17:14:56 +10:00

284 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ComfyUI-H3-PowerLoraStack
A stacked multi-LoRA loader for **MiniMax H3**, in the spirit of rgthree's Power
Lora Loader but built around the three things that actually break H3 LoRAs.
<img width="1533" height="487" alt="Screenshot 2026-08-08 211513" src="https://github.com/user-attachments/assets/cf7ba1dc-96d9-42ae-8c77-89905319b816" />
## Nodes
| Node | Purpose |
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| **MiniMax H3 Power LoRA Stack** | Any number of LoRAs on one node, each with a toggle and strength, plus one-click strength calibration |
| **MiniMax H3 adaLN Modality** | Scales stacked LoRAs' adaLN modulation per modality (video / text / audio) |
| **MiniMax H3 LoRA Schedule** | Varies selected stack rows' strength over denoising steps or normalized sigma |
| **MiniMax H3 LoRA Inspector** | Reports a LoRA's format, rank and adaLN basis without loading it |
## Why not just use a normal LoRA loader
### 1. Quantized weights: merging destroys the LoRA
ComfyUI's stock path is dequantize → add delta → `requantize_from_float(scale="recalculate")`.
That round trip is **not idempotent**: re-fitting the codebook and re-rounding to
int4 injects ~1.5% relative weight noise, while a typical H3 LoRA delta is
0.01–0.08% of the weight. The merge therefore replaces the adapter with noise.
Measured on a w4a8 checkpoint, the stock merge recovers under a third of the
LoRA magnitude at cos 0.12–0.14 against the correct result, and takes 128 s
versus 6.5 s.
This node routes quantized layers through an exact runtime low-rank branch
(`y = W_q(x) + B @ A @ x`) instead, keeping the quantized kernel and costing
~1.5% extra FLOPs at rank 64.
`quantized_layers` controls this:
- `auto` (default) — branch quantized layers, merge unquantized ones
- `branch` — never modify a weight, even in bf16 (fast strength A/B testing)
- `merge` — stock behaviour; only useful for comparison
One layer is special-cased: `mlp.fc2` under `TensorWiseINT8Layout` is reached
through `comfy.ops.linear_input_act`, which fuses the activation into the INT8
kernel and never calls `fc2.forward`. A branch there would be silently dropped,
so those layers merge even in `branch` mode.
### 2. adaLN basis mismatch
H3 ships in two forms. A *dense* checkpoint feeds `silu(time_embedder(t))`, a
2688-dim vector, into every `adaln_proj.linear`. A *curve* (pruned) checkpoint
drops the time embedder and stores an `adaln_t_table` of shape `[grid, 8]`.
A LoRA trained against one form has the wrong `lora_A` width for the other, so
ComfyUI logs `shape '[96768, 8]' is invalid for input of size 260112384` and
skips the layer. **Dropping those pairs is not an acceptable fix** — on the
turbo distillation LoRAs the constant term alone is ~100% of the magnitude of
`dW @ S(t)`, so it discards essentially the whole adapter.
This node changes basis instead, which preserves rank:
```
dense -> curve A' = A @ V, bias delta B @ (A @ c)
curve -> dense A' = A @ pinv(V), bias delta -B @ (A' @ c)
```
where `S(t) = c + V @ table(t)`, recovered by least squares of `[1 | table]`
against the silu grid. The bias delta is emitted as `.diff_b`, which comfy
applies as a `("diff",)` patch on the sibling `.bias`; without it the port is
nearly worthless. Measured end to end, the ported adapter reproduces the dense
contribution at **cos 0.999998**.
The fit uses the *target checkpoint's own* table, because bakes differ — of two
local bakes, one ships an uncentered basis (column norms 22.98, 2.67, 1.66, …)
while the other ships comfy's mean-centered one (7.08, 2.09, 0.75, …).
**This also catches a silent failure the stock loader cannot see.** Two curve
bakes have the same adaLN width, so a LoRA trained against one loads without
complaint on the other and is simply wrong — measured at **cos −0.375**, worse
than not applying it at all. When a LoRA ships its own `adaln_t_table`, this
node rebases table-to-table (exactly, cos 1.000000, no grid needed). When it
does not, the width matches and the mismatch is undetectable — so prefer LoRAs
trained against the checkpoint you are running.
#### The silu grid
Dense↔curve porting needs `h3_silu_temb_grid.safetensors`. It is searched for in
`models/h3_adaln/`, `models/loras/`, `models/diffusion_models/` and one level
under `custom_nodes/`. Table-to-table rebasing does not need it. Set
`adaln_port` to `off` to disable porting entirely.
With a grid from a different build the fit bottoms out at ~1.7e-3 relative,
because the 7th and 8th curve directions are near-degenerate (σ₇ ≈ σ₈) and so
differ between bakes. That is still ~6× below the int8 quantization floor.
### 3. Key conventions
Every H3 LoRA naming convention resolves against the model's own key set rather
than by guessing where underscores split, so `qkv_proj` is never mistaken for
two tokens:
| Convention | Example |
| ------------------------ | -------------------------------------------------------------- |
| ai-toolkit / diffusers | `diffusion_model.blocks.0.attn.qkv_proj.lora_A.weight` |
| bare (no prefix) | `blocks.0.attn.qkv_proj.lora_A.weight` |
| kohya / musubi | `lora_unet_blocks_0_attn_qkv_proj.lora_down.weight` + `.alpha` |
| lycoris | `lycoris_blocks_0_...` |
| peft / diffusers trainer | `base_model.model.blocks.0...`, `transformer.blocks.0...` |
Verified against all 37 H3 LoRAs in `models/loras/h3`: **zero unmatched keys.**
## Stacking
Multiple LoRAs on the same layer are fused into a single pair by concatenating
along the rank axis:
```
sum_i s_i * B_i @ A_i @ x == [s_1 B_1 | ... | s_N B_N] @ [A_1; ...; A_N] @ x
```
so a ten-LoRA stack costs one extra matmul pair per layer, not ten. The factors
live in a `_LoraBank` registered via `set_additional_models`, so its VRAM is
accounted for and comfy's weakref bookkeeping stays quiet.
## Denoising schedules
**MiniMax H3 LoRA Schedule** changes selected stack rows' strength during
sampling. Wire its `schedule` output into the stack, select rows with `all`,
`1,3`, or `2-4`, then choose a linear, cosine, smoothstep, power, step, or
explicit curve. `start_percent` and `end_percent` limit the transition to part
of the trajectory. Chain schedule nodes for different row groups; the later
node wins where selectors overlap.
The `steps` domain follows model-call indices. The `sigma` domain follows the
scheduler's actual normalized noise values, which can produce a different
shape with non-linear schedulers. The stack reads `sample_sigmas` supplied by
ComfyUI automatically, so plain KSampler works and **no SIGMAS wire is needed**.
Scheduled rows always use the live branch path, including on unquantized bases.
Any adapter feature or fused layer that cannot run as a branch is merged at the
row's ordinary static strength and called out in the report. Ported adaLN bias
deltas are scheduled with their LoRA rather than being left at a fixed value.
## Auto-balance
**Strength 1.0 is not a unit.** Measured across the 27 non-distillation H3 LoRAs
in `models/loras/h3`, the perturbation produced at strength 1.0 spans **65×** —
0.054% of the base weights at one end, 5.24% at the other. Neither rank nor file
size predicts it: a rank-128 adapter sits at 0.088% while a rank-16 one sits at
0.40%. So a strength that worked on one LoRA carries no information about the
next, and the sweet spot has to be rediscovered per file.
`⚖ Auto-balance strengths` measures what each active LoRA actually does and puts
them all on one scale:
```
rel = sqrt( sum_l ||dW_l||_F^2 / sum_l ||W_l||_F^2 )
```
The factor multiplies the strength you already chose, so your relative intent
between rows survives — what changes is that a LoRA perturbing the model 18×
harder than usual stops arriving at full force. `↺ Restore manual strengths`
puts every row back exactly as it was; the pre-balance value is stashed on the
row, so it survives saving and reloading the workflow. Editing a strength by
hand overrides that row and is not clobbered by a later recompute.
**The factor only ever trims** (clamped to ≤ 1). A LoRA measuring *below* the
reference may be quiet deliberately — distillation adapters sit an order of
magnitude down and are correct at 1.0 — whereas one measuring far above it
essentially never is. That asymmetry means no classifier is needed: every turbo
LoRA in the collection lands on ×1.00 by itself.
Computing this is only affordable because `dW` is never formed. It is up to
28672 × 5376 and there are ~260 per file, but
```
||B A||_F^2 = tr((B^T B)(A A^T))
```
needs only r×r matrices, so a 2.4 GB rank-128 adapter measures in a few seconds,
almost all of it disk. Results cache on (path, mtime, size). LoKr is handled too
— `||W1 ⊗ W2||_F = ||W1||_F · ||W2||_F`.
The reference is the *median* of the collection rather than a hand-picked
target, so the calibration agrees with trainer defaults on ordinary files and
only moves outliers. Base norms come from a per-group constant (measured base
weight RMS is uniform to ~2× within a group and the four linear groups agree to
20%), which avoids reading the 20 GB checkpoint.
adaLN is excluded from the measurement: its basis is checkpoint-dependent — the
same adapter shipped dense and curve8 differs 5.7× there — and it is where a
distillation LoRA keeps the schedule change that must not be normalised away.
Two honest limits: Frobenius energy is not perceptual strength, and it says
nothing about *contention*. Distinct LoRAs are near-orthogonal in weight space
(measured |cos| ≤ 0.03) yet overlap 3–15× above chance in the feature subspaces
they read and write, so two adapters can still fight over the same features at
perfectly balanced magnitudes.
Because the deltas really are near-orthogonal, stack energy adds in quadrature:
holding a stack at the "one LoRA at 1.0" budget wants `1/√N`, not the `1/N` that
gets recommended. Auto-balance does **not** apply that — it calibrates each LoRA
and leaves the total to you, so adding a row never silently weakens the others.
## adaLN modality control
**MiniMax H3 is not built like LTX 2.3.** LTX duplicates the tower per modality
(`audio_attn`, `audio_ff`, `audio_patchify_proj`, `audio_to_video_attn`), so a
LoRA can be steered by picking layers. H3 packs audio and video into one token
sequence and pushes both through the same 50 blocks. The only modality-specific
weights in the entire checkpoint are four tensors — `video_patch_proj`,
`audio_patch_proj`, `final_layer.video_out`, `final_layer.audio_out` — and none
of the 46 H3 LoRAs checked touches any of them. There is no layer-name axis.
One pathway does separate cleanly: **adaLN**. `AdalnProj.forward` computes
`linear(t) -> [M, expand*hidden*modalities]` then `view(M*modalities,
expand*hidden)`, so output feature `j` belongs to modality `j //
(expand*hidden)`. The 96768 rows are three contiguous blocks of 32256, and
`comfy/ldm/minimax/model.py` tags segments `{video: 0, text: 1, audio: 2}`.
Scaling a slice of `lora_B`'s rows scales that modality's modulation exactly,
with no runtime hook.
Wire **MiniMax H3 adaLN Modality** into the stack's `adaln_modality` input. All
three at 1.0 is a no-op; 0.0 removes that modality's share of every stacked
adapter.
This holds for future LoRAs *by construction*: the row order is a property of the
trained weights, not of ComfyUI. Any adapter that loads onto `adaln_proj.linear`
at all must match it, whatever its rank, alpha, trainer convention, or adaLN
basis (dense/curve affects only the `A` side). All seven local H3 bakes — fl2va,
ref2va, int8-convrot, w4a8-mixed, int4-BQ — carry identical geometry.
The geometry is read off the model's own `AdalnProj` rather than hardcoded, and
every layer is shape-checked before it is touched, so a future H3 variant either
adapts or keeps today's behaviour — it cannot slice at the wrong offsets.
`final_layer.adaln_proj` is `AdalnProj(t_dim, hidden, 2, 1)` — one modality,
differentiated only by timestep — so it fails that check and is left alone.
Ordering matters and is handled: the scaling runs *before* adaLN porting, which
derives its bias delta as `B @ const`, so the emitted `.diff_b` inherits it.
Where adaLN *is* present it is not a marginal knob. Measured on the 14 LoRAs
whose adaLN basis matches the checkpoint, it carries 89–99.7% of the
weight-space perturbation for content LoRAs (median ~96%) and 16–23% for the
curve8 turbo adapters. Caveat: relative Frobenius across differently-shaped
matrices is an imperfect proxy for perceptual impact, and adaLN's input is only
8-dimensional, so read that as "where most of the weight change lives", not "96%
of what you see".
## Output
The `report` string output accounts for every LoRA (wire it to a preview/show-text
node to read it; it is also written to the console log):
```
base: ConvRotW4A4 x300, INT8 x50
adaLN: curve (input dim 8)
turbo dense-adaLN @ 1: 26 merged, 275 branched, adaLN ported x51 (basis fit 1.7e-03)
rel dW 0.047%
style_lora curve-adaLN @ 0.8: 0 merged, 258 branched, adaLN modality video/text/audio = 1/1/0.25 x50
rel dW 0.054%
motion_lora @ 1: 0 merged, 104 branched, adaLN modality 1/1/0.25 INACTIVE (LoRA has no adaLN pairs)
rel dW 0.316%
branch bank: 300 layers, 412 MB
```
`rel dW` is the measurement above, reported for every LoRA whether or not
auto-balance was used, so an out-of-scale strength is visible from the API too.
When the applied strength is far from the calibrated one the line says what
auto-balance would have used.
## Limitations
- A curve-trained LoRA on a **dense** checkpoint can only be ported if the LoRA
carries its own `adaln_t_table`; otherwise there is no record of which bake it
was trained against and the adaLN pairs are dropped with a warning.
- Runtime branches apply to `MODEL` from ComfyUI's native H3 loader. The
streaming loader in `minimaxh3chinkloader` uses its own `MINIMAX_H3_MODEL`
handle and its own LoRA path.
- DoRA, LoHa, LoKr and locon adapters always merge — only plain rank
decompositions can be branched.
## License
Licensed under the [Apache License, Version 2.0](LICENSE).