chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
# 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,
|
||||
)
|
||||
Reference in New Issue
Block a user