Spaces:
Running on Zero
Running on Zero
File size: 15,149 Bytes
36cdb93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | """Streaming generator: persistent world W + autoregressive event stream.
Implements the modelling contract the two papers share, at 1.3B scale:
p(e_1..K | W, x_1..K) = prod_k p(e_k | W, x_<=k, e_<k)
* WORLD W -- the established scene/character. Primed ONCE as a single
bidirectional block, then its K/V is frozen in the cache (v0.2's "KV
construction").
* EVENT STREAM -- emitted one chunk at a time. A chunk attends bidirectionally
within itself and freely over the whole world plus all prior events, and to
nothing else.
BLOCK-CAUSALITY IS STRUCTURAL. The cache only ever holds world + committed
events, so full attention over [cache, chunk] already gives exactly "world
bidirectional, events causal". No attention mask is required -- verified in
tests/test_attention_fallback.py case 5, and a mask would wrongly serialise
tokens *within* a chunk.
CHUNK SIZE. final.pt was fine-tuned with --num-frames 4, so it only ever saw
4-latent-frame clips. Generating one latent frame in isolation is out of
distribution; `chunk_frames=4` matches training. One latent frame = 4 pixel
frames = 160 ms at 25 FPS (Wan VAE temporal stride 4), so a 4-frame chunk covers
640 ms of video and must be produced in under 640 ms to sustain 25 FPS.
Timesteps are fed to the model in the [0,1] convention final.pt was fine-tuned on
(train_streaming.py:152 draws torch.rand()); see PROGRESS.md §5.
"""
import time
import torch
from wan.modules.model import sinusoidal_embedding_1d
from wan.modules.attention import flash_attention
from wan.utils.fm_solvers import (FlowDPMSolverMultistepScheduler,
get_sampling_sigmas, retrieve_timesteps)
from .core import (ModulationCache, make_rope_table, make_cache, latent_geometry,
timestep_to_train_scale)
from .rope import apply_rope
class StreamingGenerator:
def __init__(self, model, ctx_emb, width=640, height=368, max_frames=64,
device='cuda', dtype=torch.bfloat16, window_frames=None,
time_scale=1.0, cache_frames=None):
"""window_frames: if set, evict oldest frames so the cache holds at most
this many (bounded memory + bounded per-chunk cost). None = unbounded.
time_scale converts the internal flow fraction t in [0,1] into the value
the WEIGHTS expect at their time embedding:
final.pt -> 1.0 (fine-tuned on torch.rand(), i.e. [0,1])
original Wan -> 1000.0 (trained on scheduler timesteps [0,1000])
This must match the checkpoint. Getting it backwards is not a small
degradation, it is total: measured normalised velocity error 0.064 vs
0.732 for the original weights under the two conventions (PROGRESS.md §5).
Everything internal to this class speaks the [0,1] fraction.
"""
self.model = model
self.device = device
self.dtype = dtype
self.time_scale = float(time_scale)
self.h_lat, self.w_lat, self.hp, self.wp = latent_geometry(width, height)
self.tokens_per_frame = self.hp * self.wp
self.max_frames = max_frames
self.window_frames = window_frames
# RoPE must span the whole stream (absolute temporal indices keep growing),
# but the K/V cache only ever holds world + window, so sizing it by
# max_frames wastes memory quadratically in stream length: 30 layers x
# 6 KB/token means an unbounded 173 frame buffer at 1560 tok/frame is
# ~48 GiB, versus ~6 GiB for a 21 frame working set.
self.rope = make_rope_table(model, self.hp, self.wp, max_frames, device)
self.cache = make_cache(model, self.tokens_per_frame,
cache_frames or max_frames, device, dtype)
self.ctx = ctx_emb
self.ctx_lens = torch.tensor([ctx_emb.shape[1]], device=device, dtype=torch.long)
self.n_world = 0
self.n_frames = 0 # total committed latent frames
self.ref_stats = None # per-channel (mean, std) of the world latents
def _time_embed(self, t_frac):
"""t_frac is the flow fraction in [0,1]; time_scale maps it to the
checkpoint's own convention."""
tv = (torch.ones(1, device=self.device, dtype=torch.float32)
* float(t_frac) * self.time_scale)
with torch.amp.autocast('cuda', enabled=False):
e = self.model.time_embedding(
sinusoidal_embedding_1d(self.model.freq_dim, tv).float())
e0 = self.model.time_projection(e).unflatten(1, (6, self.model.dim))
return e, e0
@torch.no_grad()
def chunk_forward(self, z, t_frac, t_start, use_cache=True, ctx=None):
"""Run a chunk of latent frames against the cached past.
z: [1, C, N, H_lat, W_lat]. t_frac is the flow fraction in [0,1].
Writes the chunk's K/V into the cache scratch region (uncommitted).
Returns predicted velocity [C, N, H_lat, W_lat].
Attention is unmasked over [cache, chunk] == block-causal.
"""
model = self.model
ctx = self.ctx if ctx is None else ctx
N = z.shape[2]
S = N * self.tokens_per_frame
e, e0 = self._time_embed(t_frac)
mod = ModulationCache(model.blocks, e0)
with torch.amp.autocast('cuda', dtype=self.dtype):
x = model.patch_embedding(z.to(self.dtype))
grid = torch.stack([torch.tensor(x.shape[2:], dtype=torch.long,
device=self.device)])
x = x.flatten(2).transpose(1, 2)
tbl = self.rope.span(t_start, N)
for li, blk in enumerate(model.blocks):
ec = mod[li]
sa_in = blk.norm1(x).float() * (1 + ec[1]) + ec[0]
n = blk.num_heads
d = blk.dim // n
sa = blk.self_attn
q = sa.norm_q(sa.q(sa_in)).view(1, S, n, d)
k = sa.norm_k(sa.k(sa_in)).view(1, S, n, d)
v = sa.v(sa_in).view(1, S, n, d)
q = apply_rope(q, tbl).to(self.dtype)
k = apply_rope(k, tbl).to(self.dtype)
v = v.to(self.dtype)
if use_cache:
self.cache.write(li, k, v)
ck, cv = self.cache.context(li, S)
else:
ck, cv = k, v
y = flash_attention(q=q, k=ck, v=cv, window_size=(-1, -1),
causal=False)
y = sa.o(y.flatten(2))
with torch.amp.autocast('cuda', dtype=torch.float32):
x = x + y * ec[2]
x = x + blk.cross_attn(blk.norm3(x), ctx, self.ctx_lens)
yf = blk.ffn(blk.norm2(x).float() * (1 + ec[4]) + ec[3])
with torch.amp.autocast('cuda', dtype=torch.float32):
x = x + yf * ec[5]
return model.unpatchify(model.head(x, e), grid)[0]
def _commit(self, n_frames):
self.cache.commit(n_frames * self.tokens_per_frame)
self.n_frames += n_frames
if self.window_frames is not None:
# Persistent world + sliding event window: the world block is pinned,
# so eviction drops the OLDEST EVENTS. Bounds both memory and the
# per chunk attention cost, which is what makes throughput flat in
# stream length rather than degrading.
protect = self.n_world * self.tokens_per_frame
budget = (self.n_world + self.window_frames) * self.tokens_per_frame
excess = self.cache.num_tokens - budget
if excess > 0:
self.cache.evict_front(excess, protect=protect)
@torch.no_grad()
def set_world(self, world_latents, t_frac=0.0):
"""Prime the cache from clean world latents, attended bidirectionally."""
self.cache.reset()
self.n_frames = 0
z = world_latents.unsqueeze(0).to(self.device, self.dtype)
F = z.shape[2]
assert F <= self.max_frames
self.chunk_forward(z, t_frac, t_start=0, use_cache=True)
self._commit(F)
self.n_world = F
# Per channel moments of the world act as the in dist reference
# for latent_norm (see _renorm). Computed over (F, H, W) per channel.
w = world_latents.float()
self.ref_stats = (w.mean(dim=(1, 2, 3), keepdim=True).unsqueeze(0),
w.std(dim=(1, 2, 3), keepdim=True).unsqueeze(0))
return F
def _renorm(self, z, strength):
"""Pull a finished chunk's per-channel moments back to the world's.
Autoregressive rollout with a model that was never trained on its own
outputs accumulates error, and here that error is measurably a SCALE
divergence: with the original bidirectional weights, generated latent std
grows ~2x over 20 chunks and reaches 3x the world's, at which point the
VAE's input range is exceeded and frames decode to flat grey
(contrast 0.018 vs the world's 0.194 -- out_sweep/round1).
Matching the first two moments per channel is the cheapest correction that
targets exactly that failure, and it is applied BEFORE the chunk's K/V is
recomputed and committed, so the cached history stays in-distribution and
the correction cannot accumulate. strength blends: 0 = off, 1 = full
moment match. It cannot fix a wrong direction, only a wrong scale, so if
content quality is the problem this will not rescue it.
"""
if strength <= 0 or self.ref_stats is None:
return z
mu, sd = self.ref_stats
zm = z.mean(dim=(2, 3, 4), keepdim=True)
zs = z.std(dim=(2, 3, 4), keepdim=True)
z_n = (z - zm) / (zs + 1e-5) * sd.to(z.dtype) + mu.to(z.dtype)
return (1.0 - strength) * z + strength * z_n
@torch.no_grad()
def generate_chunk(self, chunk_frames=4, num_steps=3, generator=None,
sampler='dpm', shift=5.0, guidance=None, ctx_neg=None,
rope_start=None, t_max=1.0, anchor=None, latent_norm=0.0):
"""Emit one event chunk autoregressively. Returns (latents, seconds).
t_max < 1.0 warm-starts the chunk instead of denoising from pure noise:
z_init = (1 - t_max) * anchor + t_max * noise
where `anchor` is the last clean latent frame, broadcast over the chunk.
This is SDEdit-style continuation. It is worth doing here because
diag/context_len_probe.py measured this model's velocity error at 0.33 for
t=0.8 versus 0.14 for t=0.5 -- denoising from t=1 integrates through the
region where it is least accurate. Trade-off: lower t_max means less
motion diversity, since the chunk starts closer to the previous frame.
"""
t_start = self.n_frames if rope_start is None else rope_start
if t_start + chunk_frames > self.max_frames:
raise RuntimeError(f'temporal index {t_start + chunk_frames} exceeds '
f'max_frames {self.max_frames}')
torch.cuda.synchronize()
t0 = time.perf_counter()
noise = torch.randn(1, 16, chunk_frames, self.h_lat, self.w_lat,
device=self.device, dtype=torch.float32,
generator=generator)
if t_max < 1.0:
if anchor is None:
raise ValueError('t_max < 1.0 requires an anchor latent frame')
a = anchor.to(self.device, torch.float32).unsqueeze(0)
if a.shape[2] != chunk_frames:
a = a[:, :, -1:].expand(-1, -1, chunk_frames, -1, -1)
z = (1 - t_max) * a + t_max * noise
else:
z = noise
def predict(z_in, t_frac):
v = self.chunk_forward(z_in, t_frac, t_start).float().unsqueeze(0)
if guidance and ctx_neg is not None:
vu = self.chunk_forward(z_in, t_frac, t_start,
ctx=ctx_neg).float().unsqueeze(0)
v = vu + guidance * (v - vu)
return v
if sampler == 'euler':
ts = torch.linspace(t_max, 0.0, num_steps + 1)
for i in range(num_steps):
tc, tn = float(ts[i]), float(ts[i + 1])
z = z + (tn - tc) * predict(z, tc)
elif sampler == 'dpm':
sch = FlowDPMSolverMultistepScheduler(
num_train_timesteps=1000, shift=1, use_dynamic_shifting=False)
sig = get_sampling_sigmas(num_steps, shift)
if t_max < 1.0: # rescale sigmas into [0, t_max]
sig = sig * t_max # keep it a numpy array
tsteps, _ = retrieve_timesteps(sch, device=self.device, sigmas=sig)
for tv in tsteps:
v = predict(z, timestep_to_train_scale(tv))
z = sch.step(v, tv, z, return_dict=False)[0]
else:
raise ValueError(sampler)
z = self._renorm(z, latent_norm)
# recompute the finished chunk's K/V at t=0 so history is clean, then commit
self.chunk_forward(z, 0.0, t_start)
self._commit(chunk_frames)
torch.cuda.synchronize()
return z[0], time.perf_counter() - t0
@torch.no_grad()
def stream(self, num_chunks, chunk_frames=4, num_steps=3, seed=0,
log_every=5, anchor=None, world_anchor=None, world_anchor_weight=0.0,
**kw):
"""world_anchor_weight w > 0 blends a fixed WORLD reference frame into the
warm start anchor: anchor = (1-w)·last_generated + w·world_ref.
Rationale: pinning the worlds K/V stabilises attention but the warm-start
anchor still forms a chain (each chunk starts from the previous chunks
output), so errors compound and long streams drift. v0.3 casts W as the
persistent carrier of scene and character identity, so letting W enter the
initialisation too is the faithful reading, not just a hack.
"""
g = torch.Generator(device=self.device).manual_seed(seed)
lats, times = [], []
for u in range(num_chunks):
lat, dt = self.generate_chunk(chunk_frames=chunk_frames,
num_steps=num_steps, generator=g,
anchor=anchor, **kw)
anchor = lat[:, -1:] # newest clean frame anchors the next chunk
if world_anchor_weight > 0 and world_anchor is not None:
w = world_anchor_weight
anchor = (1 - w) * anchor + w * world_anchor.to(anchor.device,
anchor.dtype)
lats.append(lat)
times.append(dt)
if log_every and (u + 1) % log_every == 0:
print(f' chunk {u+1}/{num_chunks}: {dt*1000:7.1f} ms '
f'({dt/chunk_frames*1000:6.1f} ms/latent-frame, '
f'cache {self.cache.num_tokens} tok)')
return torch.cat(lats, dim=1), times
|