Files
nvlabs--longlive/utils/error_buffer.py
T
2026-07-13 12:31:40 +08:00

322 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Adopted from https://github.com/vita-epfl/Stable-Video-Infinity
# SPDX-License-Identifier: Apache-2.0
import random
import torch
class ErrorBuffer:
"""Bucketed ring buffer for storing prediction errors on CPU.
Two layouts are supported:
* **1D (timestep-only)** — when ``num_blocks <= 0``. Buckets are keyed by
the diffusion timestep. This is the original SVI behavior.
* **2D (position × timestep)** — when ``num_blocks > 0``. Each entry is
keyed by both the global block position along the sequence and the
timestep. Inject paths can then choose:
- ``sample(pos, t)``: match BOTH position and timestep
(E_vid / E_noise — noise-level dependent errors)
- ``sample_pos_any_t(pos)``: match position, sample uniformly across
timesteps (E_img — position-dependent context corruption that is
agnostic to the current denoising step)
- ``sample_global()``: legacy fallback, samples uniformly everywhere
The 2D layout encodes the teacher-forcing insight that ``noisy_suffix[i]``
looks at clean_prefix[0..i] during training but at model rollouts during
inference; storing prediction errors per-position therefore lets later
blocks self-feed larger errors without any manual position ramp.
**Sharded timestep buckets** (``shard_size > 1``):
Each rank only allocates the timestep buckets it owns
(``t_bucket % shard_size == shard_rank``), reducing per-rank CPU memory
by ~``shard_size`` times. Typically ``shard_rank/shard_size`` are set to
``sp_rank/sp_size`` so that sharding is per-SP-rank and saving follows
the same per-SP-rank pattern as the 2D position split. On ``add()``,
non-owned buckets are silently skipped; on ``sample()``, non-owned buckets
are remapped to the nearest owned one.
"""
def __init__(
self,
num_buckets=40,
max_size_per_bucket=50,
num_train_timesteps=1000,
modulate_factor=0.3,
replacement_strategy="random",
num_blocks=0,
global_block_offset=0,
shard_rank=0,
shard_size=1,
):
self.num_buckets = num_buckets
self.max_size = max_size_per_bucket
self.num_train_timesteps = num_train_timesteps
self.modulate_factor = modulate_factor
self.replacement_strategy = replacement_strategy
self.bucket_width = num_train_timesteps / num_buckets
self.num_blocks = int(num_blocks) if num_blocks else 0
# ``global_block_offset`` is only used for stats / debug display so
# users can tell which absolute positions of the full sequence this
# buffer covers (the LAST SP rank carries the highest accumulated
# error positions). It does NOT participate in bucket keying.
self.global_block_offset = int(global_block_offset)
self.shard_rank = int(shard_rank)
self.shard_size = max(int(shard_size), 1)
self._owned_t_buckets = sorted(
t for t in range(num_buckets) if t % self.shard_size == self.shard_rank
)
if self.num_blocks > 0:
self.buckets = {
(p, t): []
for p in range(self.num_blocks)
for t in self._owned_t_buckets
}
else:
self.buckets = {t: [] for t in self._owned_t_buckets}
self.total_added = 0
# ------------------------------------------------------------------ keys
def _t_bucket(self, timestep_index):
b = int(timestep_index / self.bucket_width)
return max(0, min(b, self.num_buckets - 1))
def _is_owned_t(self, t_bucket):
return self.shard_size <= 1 or (t_bucket % self.shard_size == self.shard_rank)
def _nearest_owned_t(self, t_bucket):
"""Remap ``t_bucket`` to the closest owned timestep bucket."""
if self.shard_size <= 1 or self._is_owned_t(t_bucket):
return t_bucket
fwd = (self.shard_rank - t_bucket % self.shard_size) % self.shard_size
bwd = self.shard_size - fwd
t_up, t_down = t_bucket + fwd, t_bucket - bwd
up_ok = 0 <= t_up < self.num_buckets
down_ok = 0 <= t_down < self.num_buckets
if up_ok and down_ok:
return t_up if fwd <= bwd else t_down
return t_up if up_ok else t_down
def _make_key(self, t_bucket, block_pos):
if self.num_blocks > 0:
assert block_pos is not None, "block_pos required when num_blocks>0"
p = max(0, min(int(block_pos), self.num_blocks - 1))
return (p, t_bucket)
return t_bucket
# ------------------------------------------------------------------ add
def add(self, error_block, timestep_index, block_pos=None):
"""Store a single block error into the matching bucket.
Args:
error_block: (block_size, C, H, W) tensor
timestep_index: int, raw index in [0, num_train_timesteps)
block_pos: int, global block position; required iff num_blocks>0
"""
t = self._t_bucket(timestep_index)
if not self._is_owned_t(t):
return
key = self._make_key(t, block_pos)
# Store in the source dtype on CPU to match SVI (which keeps bf16),
# cutting buffer memory in half vs. casting to fp32.
entry = error_block.detach().to("cpu", copy=True)
buf = self.buckets[key]
if len(buf) < self.max_size:
buf.append(entry)
else:
if self.replacement_strategy == "fifo":
buf.pop(0)
buf.append(entry)
elif self.replacement_strategy == "l2":
stacked = torch.stack(buf)
dists = (stacked - entry.unsqueeze(0)).flatten(1).norm(dim=1)
most_similar = torch.argmin(dists).item()
buf[most_similar] = entry
else: # "random" (default)
idx = random.randint(0, self.max_size - 1)
buf[idx] = entry
self.total_added += 1
# ------------------------------------------------------------------ sample
def sample(self, timestep_index, device, dtype, block_pos=None):
"""Sample one entry matching (block_pos, timestep_index) when 2D, or
just timestep_index when 1D. Non-owned timestep buckets are
transparently remapped to the nearest owned one. Returns None if the
(remapped) bucket is empty."""
t = self._nearest_owned_t(self._t_bucket(timestep_index))
key = self._make_key(t, block_pos)
buf = self.buckets[key]
if not buf:
return None
err = random.choice(buf)
return self._modulate(err).to(device=device, dtype=dtype)
def sample_pos_any_t(self, block_pos, device, dtype):
"""For 2D buffers: sample at the given position, with random timestep.
This is the natural choice for context (E_img) injection — the clean
prefix is the result of a full ODE rollout so its accumulated error
could have originated at any timestep, but its magnitude scales with
position along the sequence.
Falls back to ``sample_global`` when the buffer is 1D.
Only owned timestep buckets are scanned.
"""
if self.num_blocks <= 0:
return self.sample_global(device, dtype)
p = max(0, min(int(block_pos), self.num_blocks - 1))
all_entries = []
for t in self._owned_t_buckets:
all_entries.extend(self.buckets[(p, t)])
if not all_entries:
return None
err = random.choice(all_entries)
return self._modulate(err).to(device=device, dtype=dtype)
def sample_global(self, device, dtype):
"""Sample one entry uniformly from ALL buckets (legacy SVI E_img)."""
all_entries = []
for buf in self.buckets.values():
all_entries.extend(buf)
if not all_entries:
return None
err = random.choice(all_entries)
return self._modulate(err).to(device=device, dtype=dtype)
# ------------------------------------------------------------------ misc
def _modulate(self, err):
if self.modulate_factor > 0:
lo = 1.0 - self.modulate_factor
hi = 1.0 + self.modulate_factor
err = err * random.uniform(lo, hi)
return err
def is_empty(self):
return self.total_added == 0
def has_pos(self, block_pos):
"""Whether ANY owned timestep bucket at ``block_pos`` has samples (2D only)."""
if self.num_blocks <= 0:
return not self.is_empty()
p = max(0, min(int(block_pos), self.num_blocks - 1))
return any(len(self.buckets[(p, t)]) > 0 for t in self._owned_t_buckets)
def stats(self):
filled = sum(1 for b in self.buckets.values() if len(b) > 0)
total = sum(len(b) for b in self.buckets.values())
num_owned_t = len(self._owned_t_buckets)
denom = self.num_blocks * num_owned_t if self.num_blocks > 0 else num_owned_t
out = {
"total_added": self.total_added,
"filled_buckets": f"{filled}/{denom}",
"total_entries": total,
}
if self.shard_size > 1:
out["shard"] = f"shard_rank={self.shard_rank}/{self.shard_size} ({num_owned_t}/{self.num_buckets} t-buckets)"
if self.num_blocks > 0:
lo = self.global_block_offset
hi = self.global_block_offset + self.num_blocks
out["global_block_range"] = f"[{lo},{hi})"
return out
def state_dict(self):
# Keys are tuples (pos, t) when 2D — torch.save handles them fine
# via pickle. We serialize the bucket layout so loaders can validate.
return {
"buckets": {k: list(v) for k, v in self.buckets.items()},
"total_added": self.total_added,
"num_blocks": self.num_blocks,
"num_buckets": self.num_buckets,
"global_block_offset": self.global_block_offset,
"shard_rank": self.shard_rank,
"shard_size": self.shard_size,
}
def load_state_dict(self, state, strict_offset=True):
"""Restore buckets from a serialized state.
Args:
state: dict produced by ``state_dict``.
strict_offset: when True (default) and the buffer is 2D,
refuse to load if the saved ``global_block_offset`` does
not match the current one. This prevents the silent
position-misalignment bug under SP, where a checkpoint
saved by SP rank 0 (covering global blocks ``[0, B)``)
would otherwise be loaded into SP rank 1 (which expects
``[B, 2B)``) and corrupt position-bucketed sampling.
Pass ``strict_offset=False`` only for backward-compat
with checkpoints saved before this field existed.
"""
if self.num_blocks > 0 and strict_offset:
saved_off = state.get("global_block_offset", None)
if saved_off is None:
raise RuntimeError(
"Refusing to load: this is a 2D position-bucketed buffer "
"but the checkpoint has no `global_block_offset` field. "
"Pass strict_offset=False if you accept the misalignment risk."
)
if int(saved_off) != self.global_block_offset:
raise RuntimeError(
f"Refusing to load: checkpoint covers global blocks "
f"starting at {saved_off}, but this rank covers blocks "
f"starting at {self.global_block_offset}. Make sure each "
f"SP rank loads its own per-rank checkpoint file."
)
# Shard check: warn but don't crash if shard layout changed (e.g.
# resuming a non-sharded checkpoint into a sharded buffer is fine —
# we just load whichever buckets overlap).
saved_shard_size = int(state.get("shard_size", state.get("dp_size", 1)))
saved_shard_rank = int(state.get("shard_rank", state.get("dp_rank", 0)))
if saved_shard_size != self.shard_size or saved_shard_rank != self.shard_rank:
import logging
logging.warning(
f"[ErrorBuffer] Shard layout changed: checkpoint was "
f"shard_rank={saved_shard_rank}/{saved_shard_size}, current is "
f"shard_rank={self.shard_rank}/{self.shard_size}. "
f"Loading overlapping buckets only."
)
saved = state["buckets"]
# Lenient match: ignore keys that don't exist in the current layout.
for k in self.buckets:
if k in saved:
self.buckets[k] = saved[k]
elif isinstance(k, tuple):
# Try string-form key from older serializations
continue
elif str(k) in saved:
self.buckets[k] = saved[str(k)]
self.total_added = int(state.get("total_added", 0))
def build_error_buffer(config, num_blocks=0, global_block_offset=0,
shard_rank=0, shard_size=1):
"""Build an ErrorBuffer from an OmegaConf/dict config node.
When ``num_blocks > 0`` the buffer becomes 2D (position × timestep),
enabling teacher-forcing-aware position-dependent error injection.
Pass ``global_block_offset`` so logs can identify which absolute slice
of the full sequence this rank's buffer covers (e.g. the last SP rank
is responsible for the most error-accumulated tail blocks).
``shard_rank`` / ``shard_size`` shard timestep buckets: each rank only
allocates the buckets it owns, reducing per-rank CPU memory by
~``shard_size`` times. Typically set to ``(sp_rank, sp_size)``.
"""
cfg = config if isinstance(config, dict) else dict(config)
return ErrorBuffer(
num_buckets=cfg.get("num_buckets", 40),
max_size_per_bucket=cfg.get("buffer_size_per_bucket", 50),
num_train_timesteps=cfg.get("num_train_timesteps", 1000),
modulate_factor=cfg.get("modulate_factor", 0.3),
replacement_strategy=cfg.get("replacement_strategy", "random"),
num_blocks=num_blocks,
global_block_offset=global_block_offset,
shard_rank=shard_rank,
shard_size=shard_size,
)