File size: 14,766 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
"""Few-step block-causal streaming: the deployment loop, and the loop the
self-forcing trainer rolls out through.

One class serves both, because that identity *is* the method. Self-forcing only
means anything if the context the student is trained on is the context it will
actually receive -- its own previous output, encoded into the same K/V cache, at
the same noise levels, with the same number of steps.

Per streaming unit (a block of `block_frames` latent frames):

    z = noise
    for t, t_next in schedule:            # e.g. 1.0 -> .75 -> .5 -> .25 -> 0
        x0 = z - t * G(z, t | clean K/V of world + committed events)
        z  = (1 - t_next) * x0 + t_next * eps            (fresh eps; last step
                                                          leaves x0 as-is)
    G(x0, t=0)  -> write this block's clean K/V, commit

The re-run at t=0 is what keeps the cache clean: every committed key/value in
the prefix was produced from a *clean* latent, which is the condition Stage 1
trained under. It costs one extra forward per unit and is worth it.

The world's K/V is pinned; eviction under a bounded window drops the oldest
events only, so memory and per-unit cost are flat in stream length while scene
and subject identity persist (v0.3's "world + event stream").
"""
import time

import torch

from . import blockcausal as bc
from .core import make_rope_table, make_cache, latent_geometry
from .graphrunner import GraphedForward, SteadyStateGraphs


def few_step_schedule(num_steps, t_max=1.0, shift=1.0):
    """Flow fractions, highest first.

    shift == 1 gives the uniform spacing DMD-distilled few-step video models use
    (CausVid / Self-Forcing: 1.0, .75, .5, .25). shift > 1 reproduces Wan's own
    inference schedule, which is the right one for evaluating a many-step,
    not-yet-distilled model.
    """
    ts = [t_max * (num_steps - i) / num_steps for i in range(num_steps)]
    if shift == 1.0:
        return ts
    return [shift * t / (1 + (shift - 1) * t) for t in ts]


class FewStepStreamer:
    def __init__(self, model, width=640, height=368, max_frames=256,
                 device='cuda', dtype=torch.bfloat16, window_frames=None,
                 time_scale=1000.0, cache_frames=None, block_frames=3,
                 num_steps=4, rope=None, shift=1.0, sampler='renoise',
                 cuda_graphs=False, noisy_context=False):
        self.model = model
        self.device = device
        self.dtype = dtype
        self.time_scale = time_scale
        self.block_frames = block_frames
        self.schedule = few_step_schedule(num_steps, shift=shift)
        self.sampler = sampler
        self.h_lat, self.w_lat, self.hp, self.wp = latent_geometry(width, height)
        self.S = self.hp * self.wp
        self.max_frames = max_frames
        self.window_frames = window_frames
        self.rope = rope if rope is not None else make_rope_table(
            model, self.hp, self.wp, max_frames, device)
        self.cache = make_cache(model, self.S, cache_frames or max_frames,
                                device, dtype)
        self.kv = bc.BufferKV(self.cache)
        self.n_world = 0
        self.n_frames = 0
        # Training-only: pushes the event window this far from the pinned world
        # in RoPE index space, simulating a long stream. At deployment the gap
        # arises naturally from evicted events, so this stays 0.
        self.rope_gap = 0
        # per-channel moment matching towards the world (0 = off); see _renorm
        self.latent_norm = 0.0
        self.ref_stats = None
        self.graphs = SteadyStateGraphs() if cuda_graphs else None
        # "Noisy context": commit the K/V the LAST denoising step already wrote
        # instead of re-running the finished block at t=0. That drops the cost
        # of a unit from N+1 forwards to N -- a third of the latency at N=2,
        # which is the single largest inference-cost lever left in this loop.
        # It is NOT a free inference-time swap: every committed key then comes
        # from a latent at the schedule's lowest noise level rather than a clean
        # one, and Stage 1 and DMD both trained against a clean prefix. Turning
        # it on without retraining under the same convention changes the
        # conditioning the student was distilled for. Exposed here so the
        # latency claim can be measured before that training is paid for.
        self.noisy_context = noisy_context

    # ------------------------------------------------------------------ context
    def set_text(self, ctx_emb, ctx_lens=None):
        self.ctx = ctx_emb
        # Resolve "is a cross-attention mask needed?" ONCE, here, and store None
        # if it is not. Deciding it inside the forward costs a `.item()` on a
        # device tensor -- a host sync, which is illegal during CUDA graph
        # capture. Contexts are padded to the full text length (upstream's own
        # WanModel.forward passes context_lens=None for exactly this reason), so
        # None is not an approximation, it is the same computation.
        full = ctx_emb.shape[1]
        if ctx_lens is not None and int(torch.as_tensor(ctx_lens).min()) < full:
            self.ctx_lens = ctx_lens
        else:
            self.ctx_lens = None

    def _eager(self, z, tbl, e, e0):
        return bc.block_forward(self.model, z, 0.0, 0, self.rope, self.ctx,
                                self.ctx_lens, kv=self.kv, dtype=self.dtype,
                                time_scale=self.time_scale, tbl=tbl, emb=(e, e0))

    def _fwd(self, z, t, t_start):
        tbl = self.rope.span(t_start, z.shape[2])
        e, e0 = bc.time_embed(self.model, t, self.device, self.time_scale)
        if self.graphs is not None and not torch.is_grad_enabled():
            # Key on the cache length: that is the only thing that changes the
            # shapes, and a bounded window makes it constant once saturated.
            g = self.graphs.get(
                (self.cache.length, z.shape[2]),
                lambda: GraphedForward(self._eager, tuple(z.shape),
                                       tuple(tbl.shape), tuple(e.shape),
                                       tuple(e0.shape), self.device))
            if g is not None:
                return g(z, tbl, e, e0)
        return self._eager(z, tbl, e, e0)

    def _commit(self, n):
        self.cache.commit(n * self.S)
        self.n_frames += n
        if self.window_frames is not None:
            self.trim_to_window(self.window_frames)

    def trim_to_window(self, window_frames):
        """Evict the oldest events so only `window_frames` of them remain.

        Deployment calls this from `_commit` after every unit, which is what
        makes per-unit cost flat in stream length. The self-forcing trainer
        cannot: it records denoising steps during a rollout and redoes them
        under gradient afterwards against `buffer[:prefix]`, and an eviction in
        between silently changes what those tokens are (see
        StreamingKVCache.evictions). So the trainer leaves `window_frames` None,
        rolls one iteration's blocks with the buffer growing monotonically, and
        calls this itself at the iteration boundary -- the one point where no
        recorded step is still owed its prefix.

        The world stays pinned either way; only events are dropped.
        """
        protect = self.n_world * self.S
        budget = (self.n_world + window_frames) * self.S
        excess = self.cache.num_tokens - budget
        if excess > 0:
            self.cache.evict_front(excess, protect=protect)

    # -------------------------------------------------------------------- world
    @torch.no_grad()
    def set_world(self, world_latents):
        """Prime the cache from clean world latents, attended bidirectionally."""
        self.cache.reset()
        self.n_frames = 0
        z = world_latents.to(self.device).unsqueeze(0) \
            if world_latents.dim() == 4 else world_latents.to(self.device)
        F = z.shape[2]
        self._fwd(z, 0.0, 0)
        self._commit(F)
        self.n_world = F
        # Per-channel moments of the world: the in-distribution reference for
        # `latent_norm`. Computed over (F, H, W) per channel.
        w = z[0].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 block's PER-CHANNEL moments back towards the world's.

        Long autoregressive rollouts drift, and here the drift is measurably a
        per-channel one: the decoded stream develops colour casts and saturated
        blotches long before it loses structure. A distilled student inherits
        this from the CFG-guided real score it was matched to -- CFG moves mass
        towards higher-contrast, more saturated samples, and in a feedback loop
        (output becomes context becomes output) that bias compounds.

        Matching the first two moments per channel against the world is the
        cheapest correction that targets exactly that, and it is applied BEFORE
        the block's K/V is recomputed and committed, so the cached history stays
        in distribution and the correction cannot itself accumulate. It cannot
        fix a wrong *direction*, only a wrong per-channel scale and offset.
        """
        if strength <= 0 or getattr(self, 'ref_stats', None) 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

    # ------------------------------------------------------------------- events
    def generate_block(self, generator=None, record=None, n_frames=None):
        """Emit one streaming unit. Returns clean latents [1, C, b, H, W]."""
        b = n_frames or self.block_frames
        t0 = bc.event_rope_index(self.n_frames, self.n_world, self.rope_gap)
        z = torch.randn(1, 16, b, self.h_lat, self.w_lat, device=self.device,
                        dtype=torch.float32, generator=generator)
        sched = self.schedule
        for i, t in enumerate(sched):
            if record is not None:
                record.append({'block_start': t0, 'step': i, 't': t,
                               'z': z.detach()})
            v = self._fwd(z, t, t0).float()
            x0 = z - t * v
            tn = sched[i + 1] if i + 1 < len(sched) else 0.0
            if tn == 0.0:
                z = x0
            elif self.sampler == 'renoise':
                # DMD-distilled few-step sampler: re-noise the x0 prediction to
                # the next level with FRESH noise. This is the sampler the
                # student is distilled under, so it is also the one it must be
                # rolled out with.
                eps = torch.randn(z.shape, device=z.device, dtype=z.dtype,
                                  generator=generator)
                z = (1 - tn) * x0 + tn * eps
            else:
                z = z + (tn - t) * v          # deterministic Euler, for the
                #                               undistilled many-step model
        z = self._renorm(z, self.latent_norm)
        if not self.noisy_context:
            self._fwd(z, 0.0, t0)      # clean K/V for the committed block
        # else: the scratch region still holds the K/V written by the final
        # denoising forward, so committing promotes that instead and the extra
        # pass is skipped entirely. (`_renorm` is then not reflected in the
        # cache, which is another reason the two options are not interchangeable
        # without retraining.)
        self._commit(b)
        return z

    @torch.no_grad()
    def stream(self, num_blocks, seed=0, log_every=0, n_frames=None):
        g = torch.Generator(device=self.device).manual_seed(seed)
        lats, times = [], []
        for u in range(num_blocks):
            torch.cuda.synchronize()
            t0 = time.perf_counter()
            z = self.generate_block(generator=g, n_frames=n_frames)
            torch.cuda.synchronize()
            dt = time.perf_counter() - t0
            lats.append(z[0])
            times.append(dt)
            if log_every and (u + 1) % log_every == 0:
                b = n_frames or self.block_frames
                print(f'  unit {u+1}/{num_blocks}: {dt*1000:7.1f} ms '
                      f'({dt/b*1000:6.1f} ms/latent frame, '
                      f'cache {self.cache.num_tokens} tok)', flush=True)
        return torch.cat(lats, dim=1), times

    # --------------------------------------------------- self-forcing rollout
    @torch.no_grad()
    def rollout_record(self, num_blocks, generator=None):
        """Roll out `num_blocks` units and keep what the DMD step needs.

        Returns (x0_blocks, records). `records[i]` holds, for every denoising
        step of block i, the exact input latent and timestep, plus the cache
        length at that point -- enough to recompute any single step under
        gradient without re-running the rollout.
        """
        out, recs = [], []
        for _ in range(num_blocks):
            rec = []
            prefix = self.cache.num_tokens
            ev = self.cache.evictions
            z = self.generate_block(generator=generator, record=rec)
            for r in rec:
                r['prefix'] = prefix
                r['evictions'] = ev
            out.append(z)
            recs.append(rec)
        return out, recs

    def prefix_intact(self, rec_step):
        """Is this recorded step's prefix still literally `buffer[:prefix]`?

        False once anything has been evicted since it was recorded -- the tokens
        at those indices are now different tokens. The trainer must not take a
        gradient through a step for which this is False; the forward would
        succeed and quietly train against the wrong context.
        """
        return rec_step.get('evictions', 0) == self.cache.evictions

    def recompute_step(self, rec_step, prefix_kv):
        """Differentiably redo one recorded denoising step against a detached
        prefix. Returns that step's x0 prediction."""
        z, t = rec_step['z'], rec_step['t']
        v = bc.block_forward(self.model, z, t, rec_step['block_start'],
                             self.rope, self.ctx, self.ctx_lens, kv=prefix_kv,
                             dtype=self.dtype, time_scale=self.time_scale,
                             prefix_upto=rec_step['prefix'],
                             grad_checkpoint=True)
        return z - t * v.float()