Files
Claude 919097c732 LoRA training: our own loop for H3, and a Log that survives a crash
The H3 run trains with a loop of our own (LoRA on the attention and feed-forward projections of the
50 blocks, per-block checkpoint, uniform noise levels, musubi-tuner's approach) while ComfyUI carries
the batches. Every Log record is flushed to disk as it is said and reloaded on start; the run
writes each stage and step to it. ComfyUI's own offloading toggle is ignored by the new loop.
UNTESTED on the real weights.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MpnF7GwNGMbngU11wjym7P
2026-10-17 19:57:06 +00:00

109 lines
5.4 KiB
Python

"""What LoRA training needs to know about H3, handed to the training module when it asks.
H3's latent is a video part and a sound part packed as one nested tensor, on a 17k+5 frame
grid at 24 fps, and its picture-and-sound forward uses a fused op that has no gradient in
comfy-kitchen 0.2.38. Its blocks also modulate the stream in place, which makes every checkpointed
block keep its recompute alive through the whole backward pass (about 4 GiB a block at 37k positions;
measured flat once the two modulation helpers work out of place). ComfyUI's own trainer knows none of that,
so the small repairs it needs live here, applied only for the length of a training run and only when probed to be needed.
Proven on a tiny H3 on CPU (the loss moves, a LoRA comes out). NOT yet run on the real weights.
"""
import contextlib
import torch
from comfy.nested_tensor import NestedTensor
from comfy_extras.nodes_minimax_h3 import FPS, align_frame_count, temporal_shape
AUDIO_CHANNELS = 32
SPATIAL_STEP = 32
def _differentiable() -> bool:
"""Whether the fused rotary op the forward picks while training can carry a gradient."""
from comfy import quant_ops
x = torch.randn(1, 2, 2, 4, requires_grad=True)
try:
out = quant_ops.ck.rms_rope_split_half(x, torch.ones(4), torch.ones(1, 2, 1, 2), torch.zeros(1, 2, 1, 2), 1e-6)
out = out[0] if isinstance(out, tuple) else out
return bool(out.requires_grad)
except Exception: # noqa: BLE001 -- any refusal means "swap it"
return False
def _scale_shift(h, shift, scale, segments):
"""`_mod_scale_shift` without writing into its input: the same numbers, nothing left for autograd to keep alive."""
from comfy.ldm.minimax import model as m
parts = [h[a:b] * (1.0 + m._mod_row(scale, row, h.dtype)) + m._mod_row(shift, row, h.dtype) for a, b, row in segments]
return parts[0] if len(parts) == 1 else torch.cat(parts)
def _gate(x, gate, other, segments):
"""`_mod_gate` without writing into the stream."""
from comfy.ldm.minimax import model as m
parts = [x[a:b] + other[a:b] * m._mod_row(gate, row, x.dtype) for a, b, row in segments]
return parts[0] if len(parts) == 1 else torch.cat(parts)
@contextlib.contextmanager
def shims():
"""The repairs, put back afterwards: the trainer accepts a nested latent, the rotary op is differentiable, the block modulation is out of place."""
import comfy_extras.nodes_train as nt
from comfy import quant_ops
from comfy.ldm.minimax import model as minimax
original_mod = (minimax._mod_scale_shift, minimax._mod_gate)
original_count = nt._prepare_latents_and_count
original_rope = quant_ops.ck.rms_rope_split_half
def count(latents, dtype, bucket_mode):
if isinstance(latents, NestedTensor):
return latents.to(dtype), latents.tensors[0].shape[0], False
return original_count(latents, dtype, bucket_mode)
nt._prepare_latents_and_count = count
minimax._mod_scale_shift, minimax._mod_gate = _scale_shift, _gate
try:
if not _differentiable():
from comfy_kitchen.backends.eager import rope
quant_ops.ck.rms_rope_split_half = rope.rms_rope_split_half
yield
finally:
nt._prepare_latents_and_count = original_count
minimax._mod_scale_shift, minimax._mod_gate = original_mod
quant_ops.ck.rms_rope_split_half = original_rope
def pack(videos, sounds):
"""One latent dict from N video latents [1,24,t,h,w] and N sound latents [1,32,2,Ta], batched as H3 expects."""
return {"samples": NestedTensor((torch.cat(videos, 0), torch.cat(sounds, 0)))}
def silence_latent(audio_vae, seconds: float):
"""The sound latent of `seconds` of silence, for a picture or a clip with no sound track."""
rate = int(getattr(audio_vae, "audio_sample_rate", 44100))
wave = torch.zeros(1, 2, max(1, int(seconds * rate)))
return audio_vae.encode(wave.movedim(1, -1))
LORA_LAYERS = ("attn.qkv_proj", "attn.out_proj", "mlp.fc1", "mlp.fc2")
def lora_targets(diffusion_model):
"""[(name, module)] of the layers a LoRA goes on, named as in the model file (blocks.N.attn.qkv_proj ...)."""
return [(f"blocks.{i}.{layer}", block.get_submodule(layer)) for i, block in enumerate(diffusion_model.blocks) for layer in LORA_LAYERS]
def adapt(model):
"""None for any model but H3. For H3, the facts the training module plans around."""
from . import is_h3
if not is_h3(model):
return None
return {"name": "H3", "step": SPATIAL_STEP, "video": True, "batch_limit": 1, "fps": FPS,
"blocks": lambda diffusion_model: list(diffusion_model.blocks), # the 50 DiT blocks, for block swap
"lora_targets": lora_targets, # musubi-tuner's choice: the attention and feed-forward projections of the 50 DiT blocks
"checkpoint": lambda diffusion_model: list(diffusion_model.blocks) + list(diffusion_model.token_refiner.blocks),
"checkpoint_depth": 2, # one checkpoint per block and one for the text refiner: depth 1 would be the whole model as a single piece
"frames": lambda n: align_frame_count(max(5, int(n))),
"fit": lambda available: 17 * ((int(available) - 5) // 17) + 5 if available >= 5 else 0, # the longest 17k+5 run that fits in a clip
"temporal": temporal_shape, "shims": shims, "pack": pack, "silence": silence_latent}