Movetoy / tiny_video /model.py
Kompaktion's picture
Movetoy: code + model card
de96f78 verified
Raw History Blame Contribute Delete
16 kB
"""A tiny text-to-video diffusion transformer, trained from random init with rectified flow.
Video [B,3,T,H,W] is cut into (1 x p x p) patches. Each block runs spatial attention
within a frame, temporal attention across frames at the same patch position,
cross-attention to the caption, and an MLP, all modulated (adaLN-Zero) by the
diffusion time and pooled caption. The caption encoder is a 2-layer transformer
over the small word vocabulary in data.py.
"""
from __future__ import annotations
import copy
import math
from dataclasses import asdict, dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.checkpoint import checkpoint
from .data import NULL, PAD, VOCAB, MAX_TOKENS
from .world import BYTE_VOCAB, HYBRID_VOCAB, MAX_BYTES, MAX_HYBRID, NULL as BYTE_NULL, PAD as BYTE_PAD
def specials(text: str) -> tuple[int, int]:
"""(pad id, null id) for the caption tokenizer a model was trained with."""
return (BYTE_PAD, BYTE_NULL) if text in ("bytes", "hybrid") else (PAD, NULL)
@dataclass
class ModelConfig:
frames: int = 16
size: int = 48
patch: int = 4
dim: int = 384
depth: int = 8
heads: int = 6
text_depth: int = 2
vocab_size: int = len(VOCAB)
max_tokens: int = MAX_TOKENS
mlp_ratio: float = 4.0
attn: str = "factorized"
rope: bool = False
text: str = "words" # "bytes": raw UTF-8 byte tokens (world.ByteTokenizer), no vocabulary at all # 3D rotary positions (t, y, x) in self-attention: motion becomes a relative pattern # "joint": the first attention in each block spans all frames, not just one
grad_checkpoint: bool = False # recompute block activations in backward: far less VRAM, ~30% slower
# ---------------------------------------------------------------- building blocks
def modulate(x, shift, scale):
return x * (1 + scale) + shift
class Attention(nn.Module):
def __init__(self, dim: int, heads: int, cross: bool = False):
super().__init__()
self.heads = heads
self.q = nn.Linear(dim, dim)
self.kv = nn.Linear(dim, 2 * dim)
self.out = nn.Linear(dim, dim)
hd = dim // heads
self.q_norm = nn.RMSNorm(hd)
self.k_norm = nn.RMSNorm(hd)
def forward(self, x, ctx=None, mask=None, rope=None):
B, N, D = x.shape
ctx = x if ctx is None else ctx
q = self.q(x).view(B, N, self.heads, -1).transpose(1, 2)
k, v = self.kv(ctx).view(B, ctx.shape[1], 2, self.heads, -1).permute(2, 0, 3, 1, 4)
q, k = self.q_norm(q), self.k_norm(k)
if rope is not None:
q, k = apply_rope(q, rope), apply_rope(k, rope)
o = F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
return self.out(o.transpose(1, 2).reshape(B, N, D))
def rope_table(t, y, x, head_dim: int, base: float = 100.0):
"""cos/sin [L, head_dim/2] for integer positions t, y, x (each [L]). Head dims split 1/4 t, 3/8 y, 3/8 x."""
pairs = head_dim // 2
pt = pairs // 4
py = (pairs - pt) // 2
angles = []
for pos, n in ((t, pt), (y, py), (x, pairs - pt - py)):
freqs = base ** (-torch.arange(n, device=pos.device, dtype=torch.float32) / max(n, 1))
angles.append(pos.float()[:, None] * freqs[None])
a = torch.cat(angles, dim=1)
return a.cos(), a.sin()
def apply_rope(x, rope):
"""Rotate consecutive channel pairs of x [B,H,L,hd] by the angles in rope."""
cos, sin = rope
x1, x2 = x.float().unflatten(-1, (-1, 2)).unbind(-1)
out = torch.stack([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1).flatten(-2)
return out.to(x.dtype)
class Mlp(nn.Sequential):
def __init__(self, dim: int, ratio: float):
hidden = int(dim * ratio)
super().__init__(nn.Linear(dim, hidden), nn.GELU(approximate="tanh"), nn.Linear(hidden, dim))
class Block(nn.Module):
def __init__(self, cfg: ModelConfig):
super().__init__()
D = cfg.dim
self.norms = nn.ModuleList([nn.LayerNorm(D, elementwise_affine=False, eps=1e-6) for _ in range(4)])
self.spatial = Attention(D, cfg.heads)
self.temporal = Attention(D, cfg.heads)
self.cross = Attention(D, cfg.heads, cross=True)
self.mlp = Mlp(D, cfg.mlp_ratio)
self.ada = nn.Sequential(nn.SiLU(), nn.Linear(D, 11 * D))
assert cfg.attn in ("factorized", "joint"), cfg.attn
self.joint = cfg.attn == "joint"
def forward(self, x, c, ctx, ctx_mask, ropes=None):
# x [B,T,N,D], c [B,D], ctx [B,L,D], ctx_mask [B,1,1,L] bool (True = attend)
B, T, N, D = x.shape
(s1, sc1, g1, s2, sc2, g2, g3, s4, sc4, g4, sc3) = self.ada(c)[:, None, None].chunk(11, dim=-1)
r_joint, r_space, r_time = ropes or (None, None, None)
if self.joint:
h = modulate(self.norms[0](x), s1, sc1).reshape(B, T * N, D)
x = x + g1 * self.spatial(h, rope=r_joint).view(B, T, N, D)
else:
h = modulate(self.norms[0](x), s1, sc1).reshape(B * T, N, D)
x = x + g1 * self.spatial(h, rope=r_space).view(B, T, N, D)
h = modulate(self.norms[1](x), s2, sc2).transpose(1, 2).reshape(B * N, T, D)
x = x + g2 * self.temporal(h, rope=r_time).view(B, N, T, D).transpose(1, 2)
h = (self.norms[2](x) * (1 + sc3)).reshape(B, T * N, D)
x = x + g3 * self.cross(h, ctx, ctx_mask).view(B, T, N, D)
h = modulate(self.norms[3](x), s4, sc4)
return x + g4 * self.mlp(h)
class TextEncoder(nn.Module):
def __init__(self, cfg: ModelConfig):
super().__init__()
self.pad = specials(cfg.text)[0]
self.embed = nn.Embedding(cfg.vocab_size, cfg.dim)
self.pos = nn.Parameter(torch.randn(cfg.max_tokens, cfg.dim) * 0.02)
layer = nn.TransformerEncoderLayer(cfg.dim, cfg.heads, int(cfg.dim * cfg.mlp_ratio), dropout=0.0,
activation="gelu", batch_first=True, norm_first=True)
self.layers = nn.TransformerEncoder(layer, cfg.text_depth, enable_nested_tensor=False)
self.norm = nn.LayerNorm(cfg.dim)
def forward(self, tokens):
pad = tokens == self.pad
h = self.embed(tokens) + self.pos[: tokens.shape[1]]
h = self.norm(self.layers(h, src_key_padding_mask=pad))
keep = (~pad).unsqueeze(-1).to(h.dtype)
pooled = (h * keep).sum(1) / keep.sum(1).clamp(min=1)
return h, pooled, (~pad)[:, None, None, :]
def timestep_embedding(t, dim: int):
half = dim // 2
freqs = torch.exp(-math.log(10000) * torch.arange(half, device=t.device, dtype=torch.float32) / half)
args = (t.float() * 1000)[:, None] * freqs[None]
return torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
# ---------------------------------------------------------------- the model
class VideoDiT(nn.Module):
def __init__(self, cfg: ModelConfig | None = None):
super().__init__()
self.cfg = cfg = cfg or ModelConfig()
assert cfg.size % cfg.patch == 0 and cfg.dim % cfg.heads == 0 and cfg.attn in ("factorized", "joint")
D, p = cfg.dim, cfg.patch
self.grid = cfg.size // p
self.patch_embed = nn.Conv3d(3, D, kernel_size=(1, p, p), stride=(1, p, p))
self.pos_space = nn.Parameter(torch.randn(self.grid * self.grid, D) * 0.02)
self.pos_time = nn.Parameter(torch.randn(cfg.frames, D) * 0.02)
self.t_embed = nn.Sequential(nn.Linear(256, D), nn.SiLU(), nn.Linear(D, D))
self.text = TextEncoder(cfg)
self.text_proj = nn.Linear(D, D)
self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.depth)])
self.final_norm = nn.LayerNorm(D, elementwise_affine=False, eps=1e-6)
self.final_ada = nn.Sequential(nn.SiLU(), nn.Linear(D, 2 * D))
self.final = nn.Linear(D, p * p * 3)
self._init()
def _init(self):
def basic(m):
if isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight)
if m.bias is not None:
nn.init.zeros_(m.bias)
self.apply(basic)
w = self.patch_embed.weight
nn.init.xavier_uniform_(w.view(w.shape[0], -1))
nn.init.normal_(self.text.embed.weight, std=0.02)
for b in self.blocks: # adaLN-Zero: every residual branch starts switched off
nn.init.zeros_(b.ada[-1].weight)
nn.init.zeros_(b.ada[-1].bias)
for m in (self.final_ada[-1], self.final):
nn.init.zeros_(m.weight)
nn.init.zeros_(m.bias)
def _ropes(self, T: int, device):
g, hd = self.grid, self.cfg.dim // self.cfg.heads
tt, yy, xx = torch.meshgrid(torch.arange(T, device=device), torch.arange(g, device=device),
torch.arange(g, device=device), indexing="ij")
joint = rope_table(tt.flatten(), yy.flatten(), xx.flatten(), hd)
zeros = torch.zeros(g * g, device=device, dtype=torch.long)
space = rope_table(zeros, yy[0].flatten(), xx[0].flatten(), hd)
time = rope_table(torch.arange(T, device=device), zeros[:T], zeros[:T], hd)
return joint, space, time
def forward(self, x, t, tokens):
"""x [B,3,T,H,W], t [B] in [0,1], tokens [B,L] -> predicted velocity [B,3,T,H,W]."""
B, _, T, H, W = x.shape
h = self.patch_embed(x) # [B,D,T,g,g]
h = h.flatten(3).permute(0, 2, 3, 1) # [B,T,N,D]
h = h + self.pos_space[None, None] + self.pos_time[None, :T, None]
ctx, pooled, ctx_mask = self.text(tokens)
c = self.t_embed(timestep_embedding(t, 256)) + self.text_proj(pooled)
ropes = self._ropes(T, x.device) if self.cfg.rope else None
for blk in self.blocks:
if self.cfg.grad_checkpoint and self.training and torch.is_grad_enabled():
h = checkpoint(blk, h, c, ctx, ctx_mask, ropes, use_reentrant=False)
else:
h = blk(h, c, ctx, ctx_mask, ropes)
shift, scale = self.final_ada(c)[:, None, None].chunk(2, dim=-1)
h = self.final(modulate(self.final_norm(h), shift, scale)) # [B,T,N,p*p*3]
p, g = self.cfg.patch, self.grid
h = h.view(B, T, g, g, p, p, 3).permute(0, 6, 1, 2, 4, 3, 5)
return h.reshape(B, 3, T, H, W)
# ---------------------------------------------------------------- training / sampling
def null_tokens(tokens: torch.Tensor, text: str = "words") -> torch.Tensor:
pad, null = specials(text)
out = torch.full_like(tokens, pad)
out[:, 0] = null
return out
def flow_loss(model: VideoDiT, x0: torch.Tensor, tokens: torch.Tensor, p_uncond: float = 0.1) -> torch.Tensor:
"""Rectified flow: x_t = (1-t) x0 + t eps, regress v = eps - x0. Logit-normal t."""
B = x0.shape[0]
t = torch.sigmoid(torch.randn(B, device=x0.device))
eps = torch.randn_like(x0)
tt = t.view(B, 1, 1, 1, 1)
xt = (1 - tt) * x0 + tt * eps
drop = torch.rand(B, device=x0.device) < p_uncond
tokens = torch.where(drop[:, None], null_tokens(tokens, model.cfg.text), tokens)
v = model(xt, t, tokens)
return F.mse_loss(v.float(), (eps - x0).float())
@torch.no_grad()
def sample(model: VideoDiT, tokens: torch.Tensor, steps: int = 40, cfg_scale: float = 4.0,
generator: torch.Generator | None = None, shift: float = 2.0) -> torch.Tensor:
"""Euler integration of the flow from noise (t=1) to data (t=0). Returns [B,3,T,H,W] in [-1,1].
`shift` > 1 spends more steps at high noise, where the layout and motion are decided.
"""
cfg = model.cfg
device = next(model.parameters()).device
tokens = tokens.to(device)
B = tokens.shape[0]
x = torch.randn(B, 3, cfg.frames, cfg.size, cfg.size, device=device, generator=generator)
u = torch.linspace(1, 0, steps + 1, device=device)
ts = shift * u / (1 + (shift - 1) * u)
cond = torch.cat([tokens, null_tokens(tokens, cfg.text)]) if cfg_scale != 1 else tokens
for i in range(steps):
t = ts[i].expand(cond.shape[0])
xin = torch.cat([x, x]) if cfg_scale != 1 else x
with torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
v = model(xin, t, cond).float()
if cfg_scale != 1:
vc, vu = v.chunk(2)
v = vu + cfg_scale * (vc - vu)
x = x + (ts[i + 1] - ts[i]) * v
return x.clamp(-1, 1)
def to_uint8(x: torch.Tensor) -> torch.Tensor:
"""[B,3,T,H,W] in [-1,1] -> uint8 [B,T,H,W,3] on CPU."""
return ((x.float().clamp(-1, 1) + 1) * 127.5).round().to(torch.uint8).permute(0, 2, 3, 4, 1).cpu()
class EMA:
def __init__(self, model: nn.Module, decay: float = 0.999):
self.decay = decay
self.model = copy.deepcopy(model).eval().requires_grad_(False)
@torch.no_grad()
def update(self, model: nn.Module):
for e, p in zip(self.model.parameters(), model.parameters()):
e.lerp_(p.detach(), 1 - self.decay)
def save_checkpoint(path, model: VideoDiT, opt=None, step: int = 0, extra: dict | None = None, ema: EMA | None = None):
torch.save({
"config": asdict(model.cfg),
"model": model.state_dict(),
"ema": ema.model.state_dict() if ema else None,
"opt": opt.state_dict() if opt else None,
"step": step,
"extra": extra or {},
}, path)
def load_checkpoint(path, device="cpu", use_ema: bool = True):
ckpt = torch.load(path, map_location=device, weights_only=False)
model = VideoDiT(ModelConfig(**ckpt["config"]))
state = ckpt["ema"] if use_ema and ckpt.get("ema") else ckpt["model"]
model.load_state_dict(state)
return model.to(device).eval(), ckpt
def tokenizer_for(cfg: ModelConfig):
"""The caption tokenizer matching a model's config (v1 word vocabulary or v2 raw bytes)."""
if cfg.text == "bytes":
from .world import ByteTokenizer
return ByteTokenizer(cfg.max_tokens)
if cfg.text == "hybrid":
from .world import HybridTokenizer
return HybridTokenizer(cfg.max_tokens)
from .data import Tokenizer
return Tokenizer(cfg.max_tokens)
def byte_model_config(**kw) -> ModelConfig:
"""Defaults for world-v2 models: byte captions, joint space-time attention, 3D RoPE."""
base = dict(frames=16, size=32, patch=4, dim=384, depth=8, heads=6, text_depth=4, vocab_size=BYTE_VOCAB,
max_tokens=MAX_BYTES, attn="joint", rope=True, text="bytes")
return ModelConfig(**{**base, **kw})
def hybrid_model_config(**kw) -> ModelConfig:
"""byte_model_config with hashed-word tokens added to the caption (text="hybrid")."""
return byte_model_config(**{"text": "hybrid", "vocab_size": HYBRID_VOCAB, "max_tokens": MAX_HYBRID, **kw})
def load_matching(model: nn.Module, state: dict) -> dict:
"""Copy every tensor whose name and shape match; for embedding tables that grew, copy the overlapping rows.
Returns {"loaded": [...], "partial": [...], "skipped": [...]} for provenance."""
own = model.state_dict()
report = {"loaded": [], "partial": [], "skipped": []}
with torch.no_grad():
for k, v in state.items():
if k not in own:
report["skipped"].append(k)
elif own[k].shape == v.shape:
own[k].copy_(v)
report["loaded"].append(k)
elif own[k].ndim == v.ndim and all(a >= b for a, b in zip(own[k].shape, v.shape)):
own[k][tuple(slice(0, n) for n in v.shape)].copy_(v)
report["partial"].append(k)
else:
report["skipped"].append(k)
return report