First half of owning the causal lane outright, so the feature needs no
third-party package installed.
h3_causal.py: a ChunkKVCache with sink/window eviction and host offload, the
chunk cut, and CausalAttention / CausalDiTBlock / CausalMiniMaxH3Model as
subclasses of ComfyUI's own H3 classes — same module names, same state dict, so
weights load with no remapping and a PEFT LoRA attaches through ComfyUI's
ordinary adapter (which already reads lora_A/lora_B keys).
The safety property: with cache=None every forward defers to its parent, so the
dense lane is bit-identical to stock H3 and nothing here can degrade an
ordinary generation.
Geometry is BORROWED, not re-derived. A chunk's RoPE positions must be the ones
the dense layout would have given those rows, so the full clip's grid is built
with upstream's own _frame_grid / _video_grid / _audio_grid and sliced per
chunk. An upstream grid change is inherited rather than silently disagreed with.
Two traps handled explicitly:
- pack_audio is channel-major over the WHOLE clip, so a chunk's audio rows are
TWO spans, not one. A contiguous slice would hand the model the wrong half
of the stereo field, quietly.
- blocks are RE-CLASSED in place, adopting submodules by reference, never
wrapped. Wrapping renames the state-dict keys of everything inside, which
has broken unpatching in this project before.
Tests: 1751 passed (+21). prefill_text/forward_chunk still to come.
211 lines
7.3 KiB
Python
211 lines
7.3 KiB
Python
"""FunPack's own chunk-causal H3: the cache, the chunk geometry, and the re-classed DiT.
|
|
|
|
H3's stock DiT does one dense forward over the whole packed sequence. Chunk-causal generation
|
|
needs a forward over a SLICE that attends to a cache of the slices already finished. This is
|
|
that machinery, owned rather than borrowed — no third-party package has to be installed for
|
|
the feature to exist.
|
|
|
|
The safety property everything rests on: with no cache passed, every forward defers to its
|
|
parent, so an ordinary generation cannot be degraded by any of this.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
import h3_causal as hc
|
|
|
|
|
|
# ── the cache: what a chunk is allowed to remember ──────────────────────────
|
|
|
|
def _cache(**kw):
|
|
kw.setdefault("num_layers", 2)
|
|
return hc.ChunkKVCache(**kw)
|
|
|
|
|
|
def _kv(rows, value):
|
|
return torch.full((1, 2, rows, 4), float(value)), torch.full((1, 2, rows, 4), float(value))
|
|
|
|
|
|
def test_an_empty_cache_has_nothing_to_read():
|
|
assert _cache().read(0, 0) == (None, None)
|
|
|
|
|
|
def test_a_committed_chunk_is_readable():
|
|
cache = _cache()
|
|
k, v = _kv(3, 1.0)
|
|
cache.write(0, 0, k, v)
|
|
cache.finish_chunk(0)
|
|
got_k, got_v = cache.read(0, 1)
|
|
assert got_k.shape[-2] == 3 and got_v.shape[-2] == 3
|
|
|
|
|
|
def test_layers_do_not_share_their_cache():
|
|
"""Layer 7 reading layer 3's keys would be silent nonsense, not a crash."""
|
|
cache = _cache(num_layers=4)
|
|
cache.write(0, 0, *_kv(3, 1.0))
|
|
cache.finish_chunk(0)
|
|
assert cache.read(1, 1) == (None, None)
|
|
|
|
|
|
def test_chunks_are_read_back_in_order():
|
|
cache = _cache()
|
|
for index, value in enumerate((1.0, 2.0)):
|
|
cache.write(0, index, *_kv(2, value))
|
|
cache.finish_chunk(index)
|
|
keys, _v = cache.read(0, 2)
|
|
assert keys[0, 0, 0, 0] == 1.0 and keys[0, 0, 2, 0] == 2.0
|
|
|
|
|
|
def test_sink_chunks_are_never_evicted():
|
|
"""The prompt and the opening shot are what hold a character together over a long clip."""
|
|
cache = _cache(sink=1, window=1)
|
|
for index in range(6):
|
|
cache.write(0, index, *_kv(2, index))
|
|
cache.finish_chunk(index)
|
|
assert 0 in cache.retained_indices(6)
|
|
|
|
|
|
def test_the_window_keeps_only_the_most_recent():
|
|
cache = _cache(sink=1, window=2)
|
|
for index in range(6):
|
|
cache.write(0, index, *_kv(2, index))
|
|
cache.finish_chunk(index)
|
|
assert cache.retained_indices(6) == [0, 4, 5]
|
|
|
|
|
|
def test_a_zero_window_leans_entirely_on_the_sink():
|
|
cache = _cache(sink=2, window=0)
|
|
for index in range(5):
|
|
cache.write(0, index, *_kv(2, index))
|
|
cache.finish_chunk(index)
|
|
assert cache.retained_indices(5) == [0, 1]
|
|
|
|
|
|
def test_no_window_remembers_everything():
|
|
cache = _cache(sink=1, window=None)
|
|
for index in range(5):
|
|
cache.write(0, index, *_kv(2, index))
|
|
cache.finish_chunk(index)
|
|
assert cache.retained_indices(5) == [0, 1, 2, 3, 4]
|
|
|
|
|
|
def test_evicted_chunks_are_actually_dropped():
|
|
"""Retention that only hides rows would grow memory with clip length, which is the thing
|
|
chunking exists to avoid."""
|
|
cache = _cache(sink=1, window=1)
|
|
for index in range(8):
|
|
cache.write(0, index, *_kv(64, index))
|
|
cache.finish_chunk(index)
|
|
assert len(cache._store) <= 3
|
|
|
|
|
|
def test_a_negative_sink_is_refused():
|
|
with pytest.raises(hc.CacheError):
|
|
_cache(sink=-1)
|
|
|
|
|
|
def test_zero_layers_is_refused():
|
|
with pytest.raises(hc.CacheError):
|
|
hc.ChunkKVCache(0)
|
|
|
|
|
|
def test_clearing_forgets_everything():
|
|
cache = _cache()
|
|
cache.write(0, 0, *_kv(2, 1.0))
|
|
cache.finish_chunk(0)
|
|
cache.clear()
|
|
assert cache.committed_chunks == 0 and cache.read(0, 1) == (None, None)
|
|
|
|
|
|
def test_offloaded_entries_leave_the_compute_device():
|
|
"""The retained K/V of a long clip is tens of GiB; holding it on the card is what makes a
|
|
long clip impossible rather than merely slow."""
|
|
cache = _cache(offload=True)
|
|
cache.write(0, 0, *_kv(2, 1.0))
|
|
assert cache._store[(0, 0)][0].device.type == "cpu"
|
|
|
|
|
|
# ── the chunk cut ───────────────────────────────────────────────────────────
|
|
|
|
def test_video_cuts_every_five_latents():
|
|
bounds = hc.chunk_bounds(latent_t=12, audio_t=100)
|
|
assert [(a, b) for a, b, _c, _d in bounds] == [(0, 5), (5, 10), (10, 12)]
|
|
|
|
|
|
def test_every_latent_lands_in_exactly_one_chunk():
|
|
bounds = hc.chunk_bounds(latent_t=22, audio_t=180)
|
|
video, audio = [], []
|
|
for vs, ve, as_, ae in bounds:
|
|
video.extend(range(vs, ve))
|
|
audio.extend(range(as_, ae))
|
|
assert video == list(range(22)) and audio == list(range(180))
|
|
|
|
|
|
def test_the_audio_cadence_falls_out_of_the_clock():
|
|
"""40 latents/s against a fixed 24 fps, 17 frames per chunk. The 28/29 alternation is a
|
|
CONSEQUENCE — hard-coding it would drift silently if the grid ever moved."""
|
|
frames = 17 * 11 + 5
|
|
bounds = hc.chunk_bounds(latent_t=5 * 11 + 2, audio_t=int(round(frames / 24 * 40)))
|
|
per_chunk = [ae - as_ for _a, _b, as_, ae in bounds]
|
|
assert per_chunk[:4] == [28, 29, 28, 28]
|
|
|
|
|
|
def test_the_tail_chunk_is_kept_not_dropped():
|
|
"""H3's grid is 5k+2 latents, so there is ALWAYS a two-latent tail. Dropping it would cut
|
|
the last five frames off every clip."""
|
|
bounds = hc.chunk_bounds(latent_t=17, audio_t=140)
|
|
assert bounds[-1][1] - bounds[-1][0] == 2
|
|
|
|
|
|
# ── stereo rows: the trap in packed audio ───────────────────────────────────
|
|
|
|
def test_a_chunks_audio_rows_are_two_spans_not_one():
|
|
"""pack_audio is channel-major over the WHOLE clip — all of the left channel, then all of
|
|
the right. Treating a chunk's rows as one contiguous slice hands the model the wrong half
|
|
of the stereo field, quietly."""
|
|
rows = hc.chunk_rows(audio_t=100, audio_start=28, audio_stop=57)
|
|
assert rows[:29] == list(range(28, 57))
|
|
assert rows[29:] == list(range(128, 157))
|
|
|
|
|
|
def test_the_row_count_is_two_per_latent():
|
|
rows = hc.chunk_rows(audio_t=100, audio_start=0, audio_stop=28)
|
|
assert len(rows) == 56
|
|
|
|
|
|
def test_the_first_chunk_starts_at_both_channel_origins():
|
|
rows = hc.chunk_rows(audio_t=50, audio_start=0, audio_stop=4)
|
|
assert rows == [0, 1, 2, 3, 50, 51, 52, 53]
|
|
|
|
|
|
# ── re-classing, not wrapping ───────────────────────────────────────────────
|
|
|
|
def test_a_block_is_re_classed_in_place_and_keeps_its_submodules():
|
|
"""Wrapping renames the state-dict keys of everything inside, which has broken unpatching
|
|
here before. Re-classing changes only which forward runs."""
|
|
class FakeAttention:
|
|
pass
|
|
|
|
class FakeBlock:
|
|
def __init__(self):
|
|
self.attn = FakeAttention()
|
|
self.norm1 = "the-real-norm"
|
|
|
|
class CausalAttn(FakeAttention):
|
|
pass
|
|
|
|
class CausalBlock(FakeBlock):
|
|
def __init__(self):
|
|
raise AssertionError("re-classing must not re-run __init__")
|
|
|
|
block = FakeBlock()
|
|
attn, norm = block.attn, block.norm1
|
|
out = hc._to_causal_block(block, CausalBlock, CausalAttn)
|
|
assert out is block
|
|
assert out.attn is attn and out.norm1 is norm
|
|
assert isinstance(out, CausalBlock) and isinstance(out.attn, CausalAttn)
|