|
Download docs/DEV_NOTES.md from aireet/viggle-animate-workflow: direct link, hf CLI and curl.
- Browser
- Download file 17.3 kB
-
https://huggingface.co/aireet/viggle-animate-workflow/resolve/main/docs/DEV_NOTES.md
- Command line
-
hf download hf://aireet/viggle-animate-workflow/docs/DEV_NOTES.md
-
curl -L -o DEV_NOTES.md https://huggingface.co/aireet/viggle-animate-workflow/resolve/main/docs/DEV_NOTES.md
17.3 kB
| # slimdit | |
| Analytic checkpoint slimming for ComfyUI diffusion models. First target: **MiniMax-H3 | |
| (Viggle-Animate)** — turns the 66 GB official BF16 transformer into a ~21 GB INT8 checkpoint | |
| that stock ComfyUI loads with `Load Diffusion Model`, no custom nodes required. | |
| Everything is computed from the released weights. No distillation, no fine-tuning, no training | |
| data — the two ideas are: | |
| 1. **Low-rank adaln curves.** Every block's full-width `adaln_proj` linear (96768 x 2688) consumes | |
| the *same* time-embedding curve. That curve turns out to be effectively 4-5 dimensional, so a | |
| rank-8 basis plus a per-block projection reproduces the modulation to **0.019 %** relative | |
| error while removing ~1.3 B parameters per block of activation-path weights. | |
| 2. **INT8 ConvRot.** The remaining block linears (qkv/out/fc1/fc2) are Hadamard-rotated per | |
| 256-group and row-wise INT8 quantized, which is what ComfyUI's `comfy_kitchen` INT8 kernels | |
| expect — the rotation spreads outlier channels so the per-row grid fits them properly. | |
| ## Results on MiniMax-H3 (Viggle-Animate) | |
| | | | | |
| |---|---| | |
| | Curve fit vs exact modulation family | rank-4 → 99.99 %, **rank-8 → 0.019 %** relative error | | |
| | INT8 weight error | ~0.9-1.2 % per row (absmax grid, the layout's ceiling) | | |
| | Checkpoint | 66 GB BF16 → 21 GB INT8 (+ curve tables) | | |
| | ComfyUI compatibility | stock `Load Diffusion Model`; `adaln_curve_grid`/`time_embed_dim` are detected from the tensor shapes | | |
| ## Usage | |
| ```bash | |
| # 1) fetch the official shards (66 GB) | |
| python3 tools/fetch_official.py --out /models/viggle-official --workers 6 | |
| # 2) convert (CPU only, ~20 min) | |
| python -m slimdit.convert \ | |
| --src /models/viggle-official \ | |
| --out /models/viggle-slimdit/minimax_h3_ref2va_slimdit_int8_convrot.safetensors \ | |
| --verify-blocks 4 | |
| # 3) sanity check the layout | |
| python3 tools/compare_manifest.py out.safetensors reference.safetensors | |
| ``` | |
| Drop the result into `ComfyUI/models/diffusion_models/` and load it like any other checkpoint. | |
| ## Layout contract | |
| For every quantized linear ComfyUI reads three tensors — this project writes exactly that: | |
| | tensor | dtype | shape | meaning | | |
| |---|---|---|---| | |
| | `<name>.weight` | int8 | `[N, K]` | `W @ H^T` rotated, row-wise quantized | | |
| | `<name>.weight_scale` | float32 | `[N, 1]` | per-row `absmax / 127` | | |
| | `<name>.comfy_quant` | uint8 | `[72]` | `{"format": "int8_tensorwise", "convrot": true, "convrot_groupsize": 256}` | | |
| The Hadamard matrix is the *regular* (4x4-seeded, Kronecker) construction from | |
| `comfy_kitchen.tensor.int8_utils`, normalized by `sqrt(256)` — it must match bit-for-bit, because | |
| the runtime un-rotates with its own copy. | |
| The NVFP4 alternative (`--quant nvfp4`, half the weight bytes at ~9.4 % weight error instead of | |
| ~0.9 %) stores four tensors per linear: | |
| | tensor | dtype | shape | meaning | | |
| |---|---|---|---| | |
| | `<name>.weight` | uint8 | `[N, K/2]` | E2M1 pairs, high nibble first | | |
| | `<name>.weight_scale` | uint8 (fp8 bits) | `[N, K/16]` | E4M3 block scales, cuBLAS tiled layout | | |
| | `<name>.weight_scale_2` | float32 | scalar | `amax / (448 * 6)` | | |
| | `<name>.comfy_quant` | uint8 | `[19]` | `{"format": "nvfp4"}` | | |
| NVFP4 quantization is delegated to `comfy_kitchen` (bit-exact, and its kernels need a GPU during | |
| conversion) rather than reimplemented — a divergent fp4 rounding rule would silently corrupt | |
| weights, so `slimdit` refuses to guess when the package is missing. | |
| Curve checkpoints carry two extra flavours of tensor: a shared `adaln_t_table` `[grid, rank]` | |
| (one row per `t = i / (grid - 1)`, interpolated at runtime) and, per block, | |
| `adaln_proj.linear.weight` of shape `[96768, rank]`. | |
| ### Layout gotchas | |
| Two reorderings are mandatory and are *not* visible in tensor shapes — a wrong one turns renders | |
| into noise while every header check still passes: | |
| * `attn.qkv_proj.weight` = `concat([to_q, to_k, to_v])` (the runtime does | |
| `split(heads * head_dim, dim=-1)`). | |
| * `mlp.fc1.weight` = the official `ff.net.0.proj.weight` **with its two halves swapped**, because | |
| the runtime evaluates it as `linear_input_act(fc2, fc1(x), "swiglu")` and that activation's half | |
| order is the opposite of how the released checkpoint stores the gated projection. This applies | |
| to the token refiner too. | |
| Always finish a conversion with a content comparison against a known-good checkpoint: | |
| ```bash | |
| python tools/verify_checkpoint.py --checkpoint ours.safetensors --reference reference.safetensors | |
| ``` | |
| It dequantizes INT8 tensors on both sides and reports the worst per-tensor deviations, which is | |
| how the fc1 swap above was found. | |
| ### ComfyUI caches models by name and size | |
| Re-converting under the same filename can silently keep serving the *old* model: the rewritten | |
| file has the same size (same tensors, same shapes), so the cached entry still matches and renders | |
| keep using the previous weights. When validating a re-conversion, write to a new name (or restart | |
| the server). This cost us a full debug cycle — the rendered noise was from the cached file, not | |
| from the new weights. | |
| ## ComfyUI node pack | |
| The pack (`comfyui/ComfyUI-SlimDiT/`) ships two nodes. | |
| **`Viggle Animate (one node)`** is the whole render: driving video + reference still in, frames | |
| out. It runs the vendor chain internally -- clip scaled to 0.4 MP, frozen text conditioning, | |
| vendor conditioning build, SlimDiT weights plus sigma shift, euler sampler on the 3/4/6-step | |
| schedule, VAE decode -- by calling those nodes' own implementations, so nothing about the render | |
| changes. Everything else is a widget with the evaluated value: 3 steps, 124 frames, shift 3/3, | |
| and the SlimDiT checkpoint + DMD LoRA + video VAE. | |
| **`SlimDiT Loader`** is the same weight/step selection as separate MODEL/VAE/SIGMAS/SAMPLER | |
| outputs, for wiring a different graph by hand. | |
| The shipped workflow (`comfyui/workflows/viggle-animate-slimdit.json`) is therefore five nodes -- | |
| load video, load image, generate, save, note -- and only three things are worth touching: the step | |
| count, the frame count and the seed (it randomises by default, so a second Run always renders). | |
| Regenerate with `python3 comfyui/workflows/build_workflows.py`, which takes the loaders and the | |
| save node from the vendor workflow and rewires them (links are looked up by slot *name*, never by | |
| index, and the script self-checks every endpoint). | |
| Equivalence: the same weights and seed through the one node and through the vendor 17-node graph | |
| agree at **39.94 dB PSNR over 124 frames**, above the 30.85 dB same-seed numeric floor this project | |
| measured, i.e. the collapse is representational, not behavioural. | |
| Conversion and inspection nodes (`SlimDiT Convert`, `SlimDiT Inspect`, `SlimDiT Sigmas`, | |
| `SlimDiT Attention Override Stats`) are developer tools and stay out of the node library unless | |
| `SLIMDIT_DEV_NODES=1` is set. | |
| Deployment: copy the pack **and** the `slimdit/` package into it, or the node cannot import the | |
| sigma math -- | |
| ```sh | |
| scp -r comfyui/ComfyUI-SlimDiT slimdit host:/tmp/ && ssh host \ | |
| 'cp -r /tmp/ComfyUI-SlimDiT ~/ComfyUI/custom_nodes/ && cp -r /tmp/slimdit ~/ComfyUI/custom_nodes/ComfyUI-SlimDiT/slimdit' | |
| ``` | |
| ## Verification | |
| Three checks, in increasing strength — run all three before publishing a checkpoint: | |
| 1. **Layout parity** — `python3 tools/compare_manifest.py ours.safetensors reference.safetensors`. | |
| Every tensor name, dtype and shape must match a known-good release (936 tensors / 19.59 GiB | |
| for MiniMax-H3). | |
| 2. **Content parity** — `python tools/verify_checkpoint.py --checkpoint ours.safetensors | |
| --reference reference.safetensors`. Dequantizes INT8 on both sides and prints the worst | |
| per-tensor deviations; the two files should agree to ~1 % (INT8 noise). This is the check that | |
| catches reordering mistakes, which headers cannot see. | |
| 3. **Conversion error** — `python tools/verify_checkpoint.py --checkpoint ours.safetensors | |
| --official /models/viggle-official`. Measures INT8 error per layer (0.9 %) and the adaln curve | |
| fit (0.019 % of the modulation family, 0.064 % at the four timesteps the 4-step sampler uses). | |
| Measured on the released weights: | |
| | check | result | | |
| |---|---| | |
| | file vs reference checkpoint | 936 tensors, same dtypes/shapes, values agree to 0.78 % | | |
| | INT8 weight error | 0.88-0.94 % per layer | | |
| | curve modulation error | 0.019 % (grid) / 0.064 % (sampled timesteps) | | |
| | render, INT8 | 124-frame 480x864 clip, 4-step DMD sampler: **38.4 dB PSNR against the reference checkpoint's render** (visually identical) | | |
| | render, NVFP4 | same structure, visibly softer: 11.73 GiB (−40 %) at 9.4 % weight error, 22.2 dB PSNR against the INT8 build | | |
| ### Render timing | |
| `tools/time_checkpoints.py` runs the same 124-frame workflow (480x864, 4-step DMD, seed varied per | |
| run to defeat ComfyUI's per-node result cache) against each checkpoint on one RTX 5090 capped at | |
| 400 W: | |
| | checkpoint | first run (loads the model) | steady state | weights staged in VRAM | | |
| |---|---|---|---| | |
| | reference `pruned_int8_convrot` | 61.1 s | 40.3 / 40.5 s | 19,995 MB | | |
| | this project, INT8 | 49.5 s | 40.6 / 40.7 s | 19,995 MB | | |
| | this project, NVFP4 | 47.2 s | 37.0 / 37.1 s | 11,944 MB | | |
| Inside a steady-state run: ~26-28 s for the three sampler steps and ~12-14 s for VAE decode plus | |
| muxing. The sampler is compute-bound at this sequence length (~50 k tokens), so INT8 and NVFP4 | |
| land within noise of each other; NVFP4's win is size and 8 GB of VRAM, not speed. | |
| ## Status | |
| - [x] INT8 ConvRot conversion of MiniMax-H3 with analytic adaln curves | |
| - [x] ComfyUI node pack: a single `SlimDiT Loader` node and a 12-node three-stage workflow | |
| - [x] Attention router in the node pack (sol below the copy budget, dense when it does not fit, in-place Triton kernel behind `SLIMDIT_ATTN=triton`), opt-in | |
| - [x] NVFP4 variant (`--quant nvfp4`, 11.7 GiB instead of 19.6 GiB, softer renders) -- kept in the converter, dropped as a direction | |
| - [ ] Mixed INT8/NVFP4: not pursued (the NVFP4 direction was dropped) | |
| ## Notes on kernels | |
| **The quantized linears are not the lever.** `tools/bench_int8.py` measures comfy-kitchen's INT8 | |
| ConvRot linear on the shapes this project emits: | |
| | device | M=1 | M=512 | M=2048 | | |
| |---|---|---|---| | |
| | RTX 5090 (sm_120) | 0.058-0.104 ms, 616-1690 GiB/s | 420-506 TFLOP/s | 520-583 TFLOP/s | | |
| | RTX 4090 (sm_89) | 0.079-0.174 ms, 455-911 GiB/s | 356-477 TFLOP/s | 412-498 TFLOP/s | | |
| Both architectures run at ~75 % of their INT8 peak and at memory-bandwidth limit for a single | |
| token, and a kernel-level profile of the same stack puts INT8 GEMMs at only **21.8 %** of a | |
| six-step sampling loop. NVFP4 was measured in the same place: 460-802 TOPS, i.e. ~1.3x the INT8 | |
| throughput on the GEMM alone, which is why the NVFP4 checkpoint buys size and VRAM (~9 % end to | |
| end in our rig) rather than speed. | |
| **Attention is the lever.** The same profile: **bf16 attention is 67.3 % of sampling** (36.0 s of | |
| 55.8 s, 300 calls at ~120 ms on a 5090 — already ~216 TFLOPS, the card's dense bf16 peak), while | |
| decode and H.264 encode add ~13 s. Doing *less* attention work is the only remaining big win, and | |
| the packed MiniMax-H3 sequence has the structure for it: text and conditioning rows sit in one | |
| contiguous span at the front, so they can be pinned as exact keys (`sink_blocks`) while only the | |
| target rows are attended sparsely. ComfyUI already publishes that layout | |
| (`transformer_options["minimax_h3_layout"]`), so the node pack ships the override | |
| (`comfyui/ComfyUI-SlimDiT/sol_attn.py`, opt-in via `SLIMDIT_SOL_ATTN=1`, falls back to dense when | |
| the layout is missing or the sequence is short). | |
| Measured on the service path with identical weights and 10 seeds: sampling 51.85 s -> 36.26 s | |
| (1.42x), end to end 63.7 s -> 46.1 s, and the output sits 32.13 dB from the dense baseline, i.e. | |
| above the 30.85 dB run-to-run numeric floor. | |
| Measured again on this project's ComfyUI rig (124 frames, 4-step DMD, seed varied per run): | |
| | | override off | override on | | | |
| |---|---|---|---| | |
| | first run (loads the model) | 61.3 s | 56.3 s | | | |
| | **steady state** | **41.2 s** | **33.1 s** | **-20 %** | | |
| | sampler's three steps | 26 s | 22 s | | | |
| | model-init warmup forward | 14.3 s | 7.7 s | | | |
| | output vs dense, same seed | | 39.0 dB / 35.2 dB | above the 30.85 dB numeric floor | | |
| **No single kernel wins at every length**, so the pack ships a router | |
| (`comfyui/ComfyUI-SlimDiT/attention_router.py`) instead of one override. Measured on the service | |
| path at 6 steps (same weights, 32 GiB card): | |
| | clip | sol_attn | in-place Triton kernel | | |
| |---|---|---| | |
| | 124 frames | 33.2 s, 26.6 GiB | 38.3 s, 25.4 GiB | | |
| | 243 frames | 103.0 s, 28.2 GiB | 116.4 s, 25.9 GiB | | |
| | 379 frames | **OOM (>31.36 GiB)** | **313.7 s, 29.39 GiB** | | |
| sol_attn is faster but must keep three contiguous bf16 copies of q/k/v alive; the self-written | |
| kernel quantizes in place and therefore runs where sol cannot. The router chooses per call: | |
| * `sol` when the clip is short (<= 243 frames) **and** measured free memory covers the copies | |
| (with a 1.5 GiB reserve and a 1.15x safety factor), | |
| * `dense` otherwise -- measured at 372 frames on a 32 GiB card: dense runs at **26.1 GiB and comes | |
| out clean**, while the in-place kernel takes **31.8 GiB and shows ghosting** from ~frame 124 on | |
| (the same clip through dense attention is sharp). So when sol cannot fit, dense is both the | |
| safer and the cleaner branch; the kernel is still reachable with `SLIMDIT_ATTN=triton` (it is | |
| ~20 % faster than dense: 224 s vs 280 s at 372 frames) and is the branch that needs the | |
| smoothing stage the earlier investigation already scoped. | |
| Step count is a widget on the loader, not a hard-coded list: it emits the schedule for 3 / 4 / 6 | |
| steps. The upstream four-point list is a uniform grid through Comfy's sigma shift with | |
| `shift=3` (`2/3 -> 0.857142857`, `1/3 -> 0.6`, exact), so the 6-step option is the same shift on a | |
| 7-point grid -- `1.0, 0.9375, 0.857142857, 0.75, 0.6, 0.375, 0.0`. 3 steps (three Euler updates) | |
| is what the finetune ships with and what the DMD LoRA was distilled for. | |
| Enable with `SLIMDIT_ATTN=auto` before starting ComfyUI (`sol` / `triton` / `off` force one branch); | |
| the legacy `VIGGLE_ATTN`, `VIGGLE_SOL_ATTN` and `VIGGLE_TRITON_ATTN` names still work. The router | |
| prints its install line at startup; with `SLIMDIT_DEV_NODES=1` the `SlimDiT Attention Override | |
| Stats` node reports how many calls each branch took plus any sol->kernel OOM fallbacks. It only | |
| ever patches | |
| `comfy.ldm.minimax.model.optimized_attention`, so other models in the same process are unaffected. | |
| **Why the copies matter (and why the router is the answer).** sol_attn hands the kernel three | |
| contiguous `(1, S, H, D)` tensors, materialised from the model's packed buffer. At the production | |
| shape (S = 30026, H = 56, D = 128, bf16), `tools/measure_attn_copies.py` measures: | |
| | | | | |
| |---|---| | |
| | three copies per attention call | 1.71 ms, 1232 MiB moved | | |
| | over 300 calls per 124-frame render | **0.51 s** (≈1.5 % of the render) | | |
| | peak VRAM added | **+1.20 GiB** | | |
| So the copies are a *headroom* cost, not a time cost — and at 124 frames the render leaves only | |
| ~1.3-1.6 GiB free on a 32 GiB card, which is exactly why sol_attn runs out of memory at longer | |
| clips. `tools/probe_sol_attn_strided.py` confirms sol_attn itself rejects the strided views, so | |
| making *it* zero-copy would need a rewrite of that kernel; the router instead switches to the | |
| in-place Triton kernel (no copies at all) once the length or the free memory says sol cannot fit, | |
| which is the same result without touching comfy-kitchen. | |
| ## Comparison with the community checkpoint | |
| `drbaph/Viggle-Animate-ComfyUI`'s `pruned_int8_convrot` is the same idea, and the two files come | |
| out near-equivalent: 936 tensors each, identical dtypes/shapes, 19.59 GiB both. Measured with | |
| `tools/check_curves.py` (adaln) and `tools/verify_checkpoint.py` (weights): | |
| | | slimdit (this project) | community `pruned_int8_convrot` | | |
| |---|---|---| | |
| | adaln curve error, full grid | 0.0256 % | 0.0256 % | | |
| | adaln curve error, at the four sampled timesteps | 0.0643 % | 0.0643 % | | |
| | per-block `A` vs the other file | identical up to a global sign (`|A|` matches to 4 digits) | | | |
| | INT8 error vs official bf16, 5 layers | 0.88-1.20 % | 0.91-1.22 % | | |
| | quantized-domain difference between the two files | 0.70 % | | | |
| | warm render, stock ComfyUI (124 frames) | 40.6 s | 40.3 s | | |
| The curve path is therefore **not** a differentiator: both files independently land on the same | |
| analytic rank-8 construction (a shared input-side basis plus `A = W @ P`, up to a sign that | |
| cancels inside `A @ table^T`). What this project adds is the reproducible pipeline (converter plus | |
| the content checks that caught the fc1 swiglu order), an NVFP4 variant, the ComfyUI node pack, and | |
| the attention override that takes a render from ~41 s to ~33 s. | |
| > An earlier revision of this file claimed the community curve does not reproduce the official | |
| > modulation. That was wrong — it came from a stale local tensor dump, not from the checkpoint. | |
| > `tools/check_curves.py` re-derives the numbers from the actual files; trust it over prose. | |
| Algorithms in `slimdit/hadamard.py` and `slimdit/int8.py` mirror `comfy_kitchen` | |
| (Apache-2.0) so that the emitted checkpoints are exactly what the ComfyUI runtime expects. | |
| Apache-2.0. | |