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
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:
- Low-rank adaln curves. Every block's full-width
adaln_projlinear (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. - 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_kitchenINT8 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
# 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 doessplit(heads * head_dim, dim=-1)).mlp.fc1.weight= the officialff.net.0.proj.weightwith its two halves swapped, because the runtime evaluates it aslinear_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:
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 --
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:
- 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). - 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. - 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
- INT8 ConvRot conversion of MiniMax-H3 with analytic adaln curves
- ComfyUI node pack: a single
SlimDiT Loadernode and a 12-node three-stage workflow - 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 - 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:
solwhen 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),denseotherwise -- 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 withSLIMDIT_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 |
| 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.pyre-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.