CoolFace
Apppublic

ddevin2002/tiny-self-forcing-doom

sourceHugging Facemitupdated 22d agoView on Hugging Face
0likes
rollout.py282 linesDownload Raw Back to sample
1"""Samplers.2 3- sample_clip(): bidirectional Euler sampling of a whole clip (teacher mode).4- CausalSampler: KV-cached chunk-wise autoregressive sampler — interface stubbed now,5  implemented with CausalSFT (see ../CLAUDE.md §4).6 7CLI: uv run python sample/rollout.py --ckpt ckpt_smoke/last.pt --out samples/8"""9 10from __future__ import annotations11 12import argparse13from pathlib import Path14 15import numpy as np16import torch17from PIL import Image18 19import sys20sys.path.insert(0, str(Path(__file__).resolve().parent.parent))21 22from model.dit import DiTConfig, VideoDiT23from model.masks import chunk_causal24 25 26@torch.no_grad()27def sample_clip(model: VideoDiT, actions: torch.Tensor, steps: int = 50,28                seed: int = 0) -> torch.Tensor:29    """actions: [B, T] long -> frames [B, T, 3, H, W] in [-1, 1]. Euler on v-pred flow."""30    cfg = model.cfg31    dev = next(model.parameters()).device32    b, tf = actions.shape33    g = torch.Generator(dev.type).manual_seed(seed)34    x = torch.randn(b, tf, cfg.channels, cfg.image_size, cfg.image_size,35                    generator=g, device=dev)36    ts = torch.linspace(1.0, 0.0, steps + 1, device=dev)37    for i in range(steps):38        t = ts[i].expand(b, tf)39        v = model(x, t, actions)40        x = x - (ts[i] - ts[i + 1]) * v41    return x.clamp(-1, 1)42 43 44class CausalSampler:45    """Chunk-wise autoregressive sampler over a sliding window (../CLAUDE.md §4).46 47    Each chunk is denoised while attending to the last `context_chunks` chunks of48    already-decided frames. Those frames are clean and fixed for the duration of the49    chunk, so their keys/values are cached once and reused across all denoising50    steps -- the saving that makes real-time playback possible, since each step then51    costs one chunk of attention instead of a whole window.52 53    The cache is rebuilt per chunk rather than carried forward: pos_frame is a54    learned absolute embedding, so when the window slides the context occupies55    different positions and its old keys no longer describe it (pinned by56    tests/test_core.py::test_kv_cache_context_is_not_reused_across_positions).57    Rebuilding costs one forward per chunk against `steps_per_chunk` saved.58    """59 60    def __init__(self, model: VideoDiT, chunk: int = 4, steps_per_chunk: int = 50,61                 context_chunks: int = 3, t_max: float = 1.0,62                 noise_device: str = "cpu"):63        cfg = model.cfg64        if cfg.frames % chunk:65            raise ValueError(f"frames {cfg.frames} not divisible by chunk {chunk}")66        if (context_chunks + 1) * chunk > cfg.frames:67            raise ValueError(68                f"{context_chunks} context chunks + 1 current chunk of {chunk} exceeds "69                f"the model's {cfg.frames}-frame window")70        self.model = model71        self.chunk = chunk72        self.steps = steps_per_chunk73        self.context_chunks = context_chunks74        self.t_max = t_max75        # Draw noise on the CPU by default: torch.Generator("mps") and76        # torch.Generator("cpu") give different draws for the same seed, so a figure77        # regenerated on another machine would not reproduce.78        self.noise_device = noise_device79        self._decided: torch.Tensor | None = None80        self._actions: list[int] | None = None81 82    @property83    def ctx_len(self) -> int:84        return self.context_chunks * self.chunk85 86    def _t_grid(self, device) -> torch.Tensor:87        return torch.linspace(self.t_max, 0.0, self.steps + 1, device=device)88 89    def _noise(self, b: int, n: int, seed: int | None, g: torch.Generator | None,90               device) -> torch.Tensor:91        cfg = self.model.cfg92        shape = (b, n, cfg.channels, cfg.image_size, cfg.image_size)93        x = torch.randn(shape, generator=g, device=self.noise_device)94        return x.to(device)95 96    def _context_cache(self, ctx: torch.Tensor, ctx_actions: torch.Tensor):97        """Keys/values for already-decided frames, built one chunk at a time.98 99        Equivalent to one chunk-causal masked forward over the context, but never100        materialises the [T*S, T*S] mask and keeps every call on SDPA's fused path.101        """102        if ctx.shape[1] == 0:103            return None104        model, b = self.model, ctx.shape[0]105        cache = None106        for s in range(0, ctx.shape[1], self.chunk):107            e = min(s + self.chunk, ctx.shape[1])108            nxt: list = []109            model.forward_with_cache(110                ctx[:, s:e],111                torch.zeros(b, e - s, device=ctx.device),      # context is clean: t = 0112                ctx_actions[:, s:e],113                cache, frame_start=s, collect=nxt,114            )115            cache = nxt116        return cache117 118    # ---- streaming primitives -------------------------------------------------119 120    def reset(self, batch: int = 1, init_frames: torch.Tensor | None = None,121              device=None) -> None:122        """Begin a rollout. `init_frames` seeds it with real frames (a whole number123        of chunks); without them the first chunk is generated cold."""124        cfg = self.model.cfg125        dev = device or next(self.model.parameters()).device126        if init_frames is None:127            self._decided = torch.empty(batch, 0, cfg.channels, cfg.image_size,128                                        cfg.image_size, device=dev)129        else:130            if init_frames.shape[1] % self.chunk:131                raise ValueError("init_frames must be a whole number of chunks")132            self._decided = init_frames.to(dev)133        self._actions = []134 135    def push(self, frames: torch.Tensor, actions: torch.Tensor) -> None:136        """Accept frames as decided history, trimming to the context window."""137        if self._decided is None:138            raise RuntimeError("call reset() first")139        self._decided = torch.cat([self._decided, frames.to(self._decided.device)], dim=1)140        keep = self.ctx_len or None141        if keep and self._decided.shape[1] > keep:142            self._decided = self._decided[:, -keep:]143        self._actions.extend(int(a) for a in actions.reshape(-1)[: frames.shape[1]])144 145    @torch.no_grad()146    def next_chunk(self, actions_chunk: torch.Tensor, ctx_actions: torch.Tensor,147                   noise: torch.Tensor | None = None, steps: int | None = None,148                   record_at: list[int] | None = None):149        """Denoise one chunk against the current history. Does NOT push the result.150 151        `record_at` additionally returns the ODE state at those step indices, which is152        what trajectory distillation matches against.153        """154        model = self.model155        b, n = actions_chunk.shape156        dev = self._decided.device157        ctx = self._decided[:, -self.ctx_len:] if self.ctx_len else self._decided[:, :0]158        cache = self._context_cache(ctx, ctx_actions)159        steps = steps or self.steps160        ts = self._t_grid(dev) if steps == self.steps else torch.linspace(self.t_max, 0.0, steps + 1, device=dev)161 162        x = noise.to(dev) if noise is not None else self._noise(b, n, None, None, dev)163        recorded = []164        for i in range(steps):165            if record_at is not None and i in record_at:166                recorded.append(x.clone())167            v = model.forward_with_cache(x, ts[i].expand(b, n), actions_chunk, cache,168                                         frame_start=ctx.shape[1])169            x = x - (ts[i] - ts[i + 1]) * v170        x = x.clamp(-1, 1)171        return (x, recorded) if record_at is not None else x172 173    # ---- the four consumers, each a thin wrapper -------------------------------174 175    @torch.no_grad()176    def _drive(self, actions: torch.Tensor, init_frames: torch.Tensor | None,177               seed: int, gt_frames: torch.Tensor | None = None,178               max_chunks: int | None = None) -> torch.Tensor:179        """Shared loop. `gt_frames` pushes ground truth instead of the model's own180        output (teacher forcing); `max_chunks` stops early."""181        b, total = actions.shape182        if total % self.chunk:183            raise ValueError(f"{total} frames is not a whole number of {self.chunk}-frame chunks")184        dev = next(self.model.parameters()).device185        g = torch.Generator(self.noise_device).manual_seed(seed)186 187        self.reset(batch=b, init_frames=init_frames, device=dev)188        out = [self._decided.clone()] if self._decided.shape[1] else []189        produced = self._decided.shape[1]190        n_chunks = 0191        while produced < total and (max_chunks is None or n_chunks < max_chunks):192            n = min(self.chunk, total - produced)193            have = self._decided.shape[1]194            ctx_a = actions[:, produced - have:produced]195            x = self.next_chunk(actions[:, produced:produced + n], ctx_a,196                                noise=self._noise(b, n, None, g, dev))197            keep = x if gt_frames is None else gt_frames[:, produced:produced + n].to(dev)198            out.append(x)199            self.push(keep, actions[:, produced:produced + n])200            produced += n201            n_chunks += 1202        return torch.cat(out, dim=1)[:, :produced]203 204    def rollout(self, actions: torch.Tensor, init_frames: torch.Tensor | None = None,205                seed: int = 0) -> torch.Tensor:206        """Free rollout. actions: [B, T] long -> frames [B, T, 3, H, W] in [-1, 1].207 208        `init_frames` [B, k, 3, H, W] seeds it with real frames (the replay protocol209        in ../CLAUDE.md §6 starts the model from the frames the engine did); k must be210        a whole number of chunks. Without it the first chunk is generated cold.211        """212        return self._drive(actions, init_frames, seed)213 214    def rollout_teacher_forced(self, gt_frames: torch.Tensor, actions: torch.Tensor,215                               init_frames: torch.Tensor | None = None,216                               seed: int = 0) -> torch.Tensor:217        """Every chunk is predicted from ground-truth history rather than the model's218        own. Distillation arm (a), and the honest short-horizon fidelity measurement:219        errors cannot compound, so this isolates per-step quality from drift."""220        if init_frames is None:221            init_frames = gt_frames[:, : self.ctx_len]222        return self._drive(actions, init_frames, seed, gt_frames=gt_frames)223 224    def rollout_prefix(self, init_frames: torch.Tensor, actions: torch.Tensor,225                       k_chunks: int, seed: int = 0) -> torch.Tensor:226        """Roll k chunks forward on the model's own output and stop -- the227        self-generated context that distillation arm (b) trains against."""228        want = init_frames.shape[1] + k_chunks * self.chunk229        return self._drive(actions[:, :want], init_frames, seed, max_chunks=k_chunks)230 231 232def to_uint8(x: torch.Tensor) -> np.ndarray:233    return ((x.cpu().float() + 1) * 127.5).round().clamp(0, 255).to(torch.uint8) \234        .permute(0, 1, 3, 4, 2).numpy()235 236 237def save_strip_and_gif(frames_u8: np.ndarray, out_dir: Path, name: str, upscale: int = 4):238    """frames_u8: [T, H, W, 3] -> horizontal strip PNG + animated GIF."""239    out_dir.mkdir(parents=True, exist_ok=True)240    t, h, w, _ = frames_u8.shape241    strip = frames_u8.transpose(1, 0, 2, 3).reshape(h, t * w, 3)242    Image.fromarray(strip).resize((t * w * 2, h * 2), Image.NEAREST) \243        .save(out_dir / f"{name}_strip.png")244    imgs = [Image.fromarray(f).resize((w * upscale, h * upscale), Image.NEAREST)245            for f in frames_u8]246    imgs[0].save(out_dir / f"{name}.gif", save_all=True, append_images=imgs[1:],247                 duration=115, loop=0)  # ~8.75 fps, matching ACTION_REPEAT=4 at 35 tics/s248 249 250def main() -> None:251    p = argparse.ArgumentParser()252    p.add_argument("--ckpt", required=True)253    p.add_argument("--out", type=Path, default=Path("samples"))254    p.add_argument("--steps", type=int, default=50)255    p.add_argument("--actions", type=str, default=None,256                   help="comma-separated action ids, e.g. 1,1,1,4,4 (default: forward)")257    p.add_argument("--ema", action=argparse.BooleanOptionalAction, default=True)258    args = p.parse_args()259 260    state = torch.load(args.ckpt, map_location="cpu")261    cfg = DiTConfig(**state["model_cfg"])262    model = VideoDiT(cfg)263    model.load_state_dict(state["ema" if args.ema else "model"])264    dev = ("cuda" if torch.cuda.is_available()265           else "mps" if torch.backends.mps.is_available() else "cpu")266    model = model.to(dev).eval()267 268    if args.actions:269        ids = [int(s) for s in args.actions.split(",")]270        ids = (ids * cfg.frames)[: cfg.frames]271    else:272        ids = [1] * cfg.frames273    actions = torch.tensor([ids], device=dev)274    clip = sample_clip(model, actions, steps=args.steps)275    save_strip_and_gif(to_uint8(clip)[0], args.out, f"clip_s{args.steps}")276    print(f"wrote {args.out}/clip_s{args.steps}_strip.png and .gif "277          f"(step {state['step']}, actions {ids[:8]}…)")278 279 280if __name__ == "__main__":281    main()282