torch.Tensor: + if isinstance(indices, torch.Tensor): + if ( + indices.dtype == torch.bool + or indices.is_floating_point() + or indices.is_complex() + ): + raise TypeError("token indices must contain only integers") + values = indices.detach().to( + device="cpu", dtype=torch.int64 + ).contiguous() + else: + if any( + isinstance(value, bool) or not isinstance(value, Integral) + for value in indices + ): + raise TypeError("token indices must contain only integers") + values = torch.tensor(indices, dtype=torch.int64, device="cpu") + if values.ndim != 1 or values.numel() <= 1: + raise ValueError("token indices must contain at least two rows") + return values + + +def relative_token_indices_sha256(indices: torch.Tensor | Sequence[int]) -> str: + """Hash a one-segment mask in the VideoMAE-specific identity domain.""" + values = _integer_index_values(indices) + return _indices_sha256(_RELATIVE_TOKEN_HASH_DOMAIN, values) + + +def repeat_segment_token_indices( + indices: torch.Tensor | Sequence[int], + *, + num_segments: int = NUM_SEGMENTS, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, + global_offsets: bool = False, +) -> torch.Tensor: + """Repeat a relative mask into a ``[segments,K]`` alignment table. + + With ``global_offsets=False`` every row is identical. With + ``global_offsets=True`` row ``s`` is offset by + ``s * raw_tokens_per_segment`` and therefore indexes the conceptual full + unselected ``[segments * raw_tokens_per_segment, D]`` matrix. + """ + num_segments = _validated_positive_int(num_segments, "num_segments") + raw_tokens_per_segment = _validated_positive_int( + raw_tokens_per_segment, "raw_tokens_per_segment" + ) + target_device = indices.device if isinstance(indices, torch.Tensor) else None + values = _integer_index_values(indices) + values = validate_segment_token_indices( + values, + raw_tokens_per_segment=raw_tokens_per_segment, + num_tokens=int(values.numel()), + device=target_device, + ) + repeated = values.unsqueeze(0).repeat(num_segments, 1) + if global_offsets: + offsets = torch.arange( + num_segments, dtype=torch.int64, device=repeated.device + ).unsqueeze(1) + repeated = repeated + offsets * raw_tokens_per_segment + return repeated + + +def aggregate_segment_token_indices( + indices: torch.Tensor | Sequence[int], + *, + num_segments: int = NUM_SEGMENTS, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, +) -> torch.Tensor: + """Return the flattened segment-major global token-row order.""" + return repeat_segment_token_indices( + indices, + num_segments=num_segments, + raw_tokens_per_segment=raw_tokens_per_segment, + global_offsets=True, + ).reshape(-1) + + +def aggregate_token_indices_sha256( + indices: torch.Tensor | Sequence[int], + *, + num_segments: int = NUM_SEGMENTS, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, +) -> str: + aggregate = aggregate_segment_token_indices( + indices, + num_segments=num_segments, + raw_tokens_per_segment=raw_tokens_per_segment, + ) + return _indices_sha256(_AGGREGATE_TOKEN_HASH_DOMAIN, aggregate) + + +def _canonical_model_id(model_id: str) -> str: + expanded = os.path.expanduser(str(model_id)) + return os.path.realpath(expanded) if os.path.exists(expanded) else str(model_id) + + +def _segment_frame_ranges( + *, + num_segments: int = NUM_SEGMENTS, + frames_per_segment: int = FRAMES_PER_SEGMENT, + skipped_prefix_frames: int = SKIPPED_PREFIX_FRAMES, +) -> list[list[int]]: + return [ + [ + skipped_prefix_frames + segment * frames_per_segment, + skipped_prefix_frames + (segment + 1) * frames_per_segment, + ] + for segment in range(num_segments) + ] + + +def _feature_identity_sha256(metadata_without_identity: Mapping[str, object]) -> str: + payload = json.dumps( + dict(metadata_without_identity), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(_FEATURE_IDENTITY_HASH_DOMAIN + payload).hexdigest() + + +def canonical_videomae_v2_dense_feature_metadata( + *, + model_id: str, + layer_index: int, + num_tokens: int, + token_indices: torch.Tensor | Sequence[int] | None = None, + vae_id: str = DEFAULT_WAN_VAE_ID, + num_segments: int = NUM_SEGMENTS, + frames_per_segment: int = FRAMES_PER_SEGMENT, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, + feature_dim: int = FEATURE_DIM, + resize_short_side: int = RESIZE_SHORT_SIDE, + crop_size: int = CROP_SIZE, + skip_first_frame: bool = True, +) -> Dict[str, object]: + """Build the strict five-segment feature/reference identity payload.""" + layer_index = _validated_nonnegative_int(layer_index, "layer_index") + num_tokens = _validated_positive_int(num_tokens, "num_tokens") + num_segments = _validated_positive_int(num_segments, "num_segments") + frames_per_segment = _validated_positive_int( + frames_per_segment, "frames_per_segment" + ) + raw_tokens_per_segment = _validated_positive_int( + raw_tokens_per_segment, "raw_tokens_per_segment" + ) + feature_dim = _validated_positive_int(feature_dim, "feature_dim") + resize_short_side = _validated_positive_int( + resize_short_side, "resize_short_side" + ) + crop_size = _validated_positive_int(crop_size, "crop_size") + if num_segments != NUM_SEGMENTS: + raise ValueError(f"num_segments must be {NUM_SEGMENTS}, got {num_segments}") + if frames_per_segment != FRAMES_PER_SEGMENT: + raise ValueError( + f"frames_per_segment must be {FRAMES_PER_SEGMENT}, got {frames_per_segment}" + ) + if raw_tokens_per_segment != RAW_TOKENS_PER_SEGMENT: + raise ValueError( + "raw_tokens_per_segment must be " + f"{RAW_TOKENS_PER_SEGMENT}, got {raw_tokens_per_segment}" + ) + if feature_dim != FEATURE_DIM: + raise ValueError(f"feature_dim must be {FEATURE_DIM}, got {feature_dim}") + if resize_short_side != RESIZE_SHORT_SIDE or crop_size != CROP_SIZE: + raise ValueError( + "VideoMAE V2 preprocessing must use resize_short_side=224 and crop_size=224" + ) + if skip_first_frame is not True: + raise ValueError("skip_first_frame must be True for the five-segment contract") + + if token_indices is None: + relative_indices = uniform_segment_token_indices( + raw_tokens_per_segment, num_tokens + ) + token_sampling = "uniform_linspace_round" + else: + relative_indices = validate_segment_token_indices( + token_indices, + raw_tokens_per_segment=raw_tokens_per_segment, + num_tokens=num_tokens, + ) + token_sampling = "explicit_sorted_unique" + + selected_tokens = num_segments * num_tokens + raw_tokens = num_segments * raw_tokens_per_segment + processed_num_frames = num_segments * frames_per_segment + source_num_frames = processed_num_frames + SKIPPED_PREFIX_FRAMES + metadata: Dict[str, object] = { + "feature_metadata_version": FEATURE_METADATA_VERSION, + "feature_type": FEATURE_TYPE, + "model_family": "OpenGVLab VideoMAE V2", + "model_id": _canonical_model_id(model_id), + "vae_id": str(vae_id), + "layer_index": layer_index, + "prefix_blocks": layer_index + 1, + "layer_index_semantics": "zero_based_complete_post_residual_block", + "feature_stage": "post_residual_pre_pool_fc_norm", + "num_segments": num_segments, + "frames_per_segment": frames_per_segment, + "num_frames": processed_num_frames, + "source_num_frames": source_num_frames, + "skip_first_frame": True, + "skipped_prefix_frames": SKIPPED_PREFIX_FRAMES, + "frame_sampling": FRAME_SAMPLING, + "segment_frame_ranges": _segment_frame_ranges(), + "resize_short_side": resize_short_side, + "crop_size": crop_size, + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "encoder_input_layout": "BCTHW", + "raw_tokens_per_segment": raw_tokens_per_segment, + "raw_tokens": raw_tokens, + "num_tokens_per_segment": num_tokens, + "num_tokens": selected_tokens, + "token_sampling": token_sampling, + "token_mask_reuse": "same_relative_indices_every_segment", + "token_alignment": "same_relative_indices_and_order", + "segment_concat_order": SEGMENT_CONCAT_ORDER, + "relative_token_indices": [ + int(value) for value in relative_indices.tolist() + ], + "relative_token_indices_sha256": relative_token_indices_sha256( + relative_indices + ), + "aggregate_token_indices_sha256": aggregate_token_indices_sha256( + relative_indices, + num_segments=num_segments, + raw_tokens_per_segment=raw_tokens_per_segment, + ), + "feature_dim": feature_dim, + "sample_axis": "video", + "matrix_shape": [selected_tokens, feature_dim], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + metadata["feature_identity_sha256"] = _feature_identity_sha256(metadata) + return metadata + + +def validate_videomae_v2_dense_feature_metadata( + metadata: Mapping[str, object], +) -> Dict[str, object]: + """Validate a serialized reference identity and reject other families. + + A shallow canonical copy is returned so callers can retain their usual + ``validated = validate_...(loaded_metadata)`` pattern. + """ + if not isinstance(metadata, Mapping): + raise TypeError("VideoMAE V2 feature metadata must be a mapping") + result = dict(metadata) + if result.get("feature_type") != FEATURE_TYPE: + raise ValueError( + "feature_type mismatch: expected " + f"{FEATURE_TYPE!r}, got {result.get('feature_type')!r}; " + "V-JEPA2 and VideoMAE V2 reference packs are not interchangeable" + ) + + required_exact = { + "feature_metadata_version": FEATURE_METADATA_VERSION, + "model_family": "OpenGVLab VideoMAE V2", + "feature_stage": "post_residual_pre_pool_fc_norm", + "layer_index_semantics": "zero_based_complete_post_residual_block", + "num_segments": NUM_SEGMENTS, + "frames_per_segment": FRAMES_PER_SEGMENT, + "num_frames": NUM_SEGMENTS * FRAMES_PER_SEGMENT, + "source_num_frames": SOURCE_NUM_FRAMES, + "skip_first_frame": True, + "skipped_prefix_frames": SKIPPED_PREFIX_FRAMES, + "frame_sampling": FRAME_SAMPLING, + "segment_frame_ranges": _segment_frame_ranges(), + "resize_short_side": RESIZE_SHORT_SIDE, + "crop_size": CROP_SIZE, + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "encoder_input_layout": "BCTHW", + "raw_tokens_per_segment": RAW_TOKENS_PER_SEGMENT, + "raw_tokens": NUM_SEGMENTS * RAW_TOKENS_PER_SEGMENT, + "token_mask_reuse": "same_relative_indices_every_segment", + "token_alignment": "same_relative_indices_and_order", + "segment_concat_order": SEGMENT_CONCAT_ORDER, + "feature_dim": FEATURE_DIM, + "sample_axis": "video", + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + for key, expected in required_exact.items(): + if result.get(key) != expected: + raise ValueError( + f"invalid VideoMAE V2 metadata field {key!r}: " + f"expected {expected!r}, got {result.get(key)!r}" + ) + + layer_index = result.get("layer_index") + if isinstance(layer_index, bool) or not isinstance(layer_index, Integral): + raise ValueError("layer_index must be a non-negative integer") + if int(layer_index) < 0 or result.get("prefix_blocks") != int(layer_index) + 1: + raise ValueError("prefix_blocks must equal layer_index + 1") + num_tokens_per_segment = result.get("num_tokens_per_segment") + if ( + isinstance(num_tokens_per_segment, bool) + or not isinstance(num_tokens_per_segment, Integral) + ): + raise ValueError("num_tokens_per_segment must be an integer") + num_tokens_per_segment = int(num_tokens_per_segment) + if not 2 <= num_tokens_per_segment <= RAW_TOKENS_PER_SEGMENT: + raise ValueError( + "num_tokens_per_segment must be in " + f"[2,{RAW_TOKENS_PER_SEGMENT}]" + ) + selected_tokens = NUM_SEGMENTS * num_tokens_per_segment + if result.get("num_tokens") != selected_tokens: + raise ValueError("num_tokens must equal num_segments * num_tokens_per_segment") + if result.get("matrix_shape") != [selected_tokens, FEATURE_DIM]: + raise ValueError( + f"matrix_shape must be [{selected_tokens},{FEATURE_DIM}]" + ) + + relative_indices = validate_segment_token_indices( + result.get("relative_token_indices", []), + raw_tokens_per_segment=RAW_TOKENS_PER_SEGMENT, + num_tokens=num_tokens_per_segment, + ) + expected_relative_hash = relative_token_indices_sha256(relative_indices) + if result.get("relative_token_indices_sha256") != expected_relative_hash: + raise ValueError("relative_token_indices_sha256 does not match the mask") + expected_aggregate_hash = aggregate_token_indices_sha256(relative_indices) + if result.get("aggregate_token_indices_sha256") != expected_aggregate_hash: + raise ValueError("aggregate_token_indices_sha256 does not match segment order") + if result.get("token_sampling") not in ( + "uniform_linspace_round", + "explicit_sorted_unique", + ): + raise ValueError("unsupported token_sampling in VideoMAE V2 metadata") + + identity = result.pop("feature_identity_sha256", None) + expected_identity = _feature_identity_sha256(result) + result["feature_identity_sha256"] = identity + if identity != expected_identity: + raise ValueError("feature_identity_sha256 does not match metadata contents") + return result + + +# Familiar wrappers for call sites that use the V-JEPA helper vocabulary. The +# hashes remain VideoMAE-domain-separated despite the compatibility names. +uniform_dense_token_indices = uniform_segment_token_indices +token_indices_sha256 = relative_token_indices_sha256 + + +def validate_explicit_dense_token_indices( + indices: torch.Tensor | Sequence[int], + *, + raw_tokens: int, + num_tokens: int, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + return validate_segment_token_indices( + indices, + raw_tokens_per_segment=raw_tokens, + num_tokens=num_tokens, + device=device, + ) + + +__all__ = [ + "FEATURE_METADATA_VERSION", + "FEATURE_TYPE", + "DEFAULT_WAN_VAE_ID", + "NUM_SEGMENTS", + "FRAMES_PER_SEGMENT", + "SOURCE_NUM_FRAMES", + "SKIPPED_PREFIX_FRAMES", + "RAW_TOKENS_PER_SEGMENT", + "FEATURE_DIM", + "CROP_SIZE", + "RESIZE_SHORT_SIDE", + "uniform_segment_token_indices", + "validate_segment_token_indices", + "relative_token_indices_sha256", + "repeat_segment_token_indices", + "aggregate_segment_token_indices", + "aggregate_token_indices_sha256", + "canonical_videomae_v2_dense_feature_metadata", + "validate_videomae_v2_dense_feature_metadata", + "uniform_dense_token_indices", + "validate_explicit_dense_token_indices", + "token_indices_sha256", +] diff --git a/1.3B/code/video_fd/encoders/vjepa2_adapter.py b/1.3B/code/video_fd/encoders/vjepa2_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..0f70e3923c26df7712206d9e1250871e291c1e99 --- /dev/null +++ b/1.3B/code/video_fd/encoders/vjepa2_adapter.py @@ -0,0 +1,411 @@ +"""V-JEPA2 sparse and intermediate-dense video encoder adapter. + +The historical sparse API remains available. The dense API additionally +returns the complete post-residual hidden state of an intermediate encoder +block, before V-JEPA2's final LayerNorm and predictor. It is intended for +feature-distribution losses whose gradient must replay through preprocessing +and a frozen encoder prefix. +""" + +from __future__ import annotations + +from pathlib import Path + +from typing import Dict, Optional, Sequence, Tuple + +import torch +import torch.nn as nn +from torch.utils.checkpoint import checkpoint + +from video_fd.encoders.base_sparse_video_encoder import ( + SparseVideoEncoderAdapter, + differentiable_resize_crop, +) + + +class VJEPA2Adapter(SparseVideoEncoderAdapter): + """Hugging Face V-JEPA2 adapter with an optional memory-pruned dense mode. + + Dense-mode configuration keys: + + * ``dense_layer_index``: zero-based complete encoder block (default 15) + * ``dense_num_frames``: contiguous input frames (default 64) + * ``resize_short_side``: differentiable resize short edge (default 292) + * ``dense_only``: discard predictor, final norm, and blocks after the dense + target once the checkpoint is loaded + + ``model`` is an injection seam for tiny unit tests. Production callers + normally omit it and load ``model_id`` with ``from_pretrained``. + """ + + def __init__(self, config, model: Optional[nn.Module] = None): + config = dict(config) + config.setdefault("name", "vjepa2") + config.setdefault("input_size", config.get("crop_size", 256)) + config.setdefault("input_frames", 16) + config.setdefault("norm_mean", (0.485, 0.456, 0.406)) + config.setdefault("norm_std", (0.229, 0.224, 0.225)) + super().__init__(config) + self.model_id = str(config.get("model_id", "assets/models/vjepa2-vitl-fpc64-256")) + self.allow_temporal_pos_interpolation = bool(config.get("allow_temporal_pos_interpolation", True)) + self.tubelet_size = int(config.get("tubelet_size", 2)) + self.patch_size = int(config.get("patch_size", 16)) + self.dense_layer_index = int(config.get("dense_layer_index", 15)) + self.dense_num_frames = int(config.get("dense_num_frames", 64)) + self.resize_short_side = int( + config.get("resize_short_side", config.get("dense_resize_short_side", 292)) + ) + self.dense_only = bool(config.get("dense_only", False)) + self._model = None + self._dense_pruned = False + if model is None: + self._load() + else: + self._adopt_model(model) + + def _load(self): + model_dir = Path(self.model_id).expanduser() + if not model_dir.is_dir(): + raise FileNotFoundError(f"Prepare the local V-JEPA2 model directory: {model_dir}") + self.model_id = str(model_dir.resolve()) + try: + from transformers import VJEPA2Model # type: ignore + except Exception as exc: # pragma: no cover + raise ImportError( + "V-JEPA2 requires `transformers` with VJEPA2Model. Original error: %r" % (exc,) + ) from exc + model = VJEPA2Model.from_pretrained(self.model_id, local_files_only=True) + self._adopt_model(model) + + def _adopt_model(self, model: nn.Module) -> None: + """Install a loaded model, derive its geometry, optionally prune it.""" + cfg = getattr(model, "config", None) + if cfg is not None: + self.patch_size = int(getattr(cfg, "patch_size", self.patch_size)) + self.tubelet_size = int(getattr(cfg, "tubelet_size", self.tubelet_size)) + self.input_size = int(getattr(cfg, "crop_size", getattr(cfg, "image_size", self.input_size))) + cfg_frames = getattr(cfg, "frames_per_clip", None) + if cfg_frames is not None and int(cfg_frames) != self.input_frames and not self.allow_temporal_pos_interpolation: + print(f"[vjepa2] checkpoint frames_per_clip={cfg_frames} != {self.input_frames}; " + "temporal interpolation disabled -> will fall back to global pooling.") + self._model = model.to(torch.float32) + self._validate_dense_layer(self.dense_layer_index) + if self.dense_only: + self.prune_for_dense_prefix(self.dense_layer_index) + self.freeze() + + def _grid_shape(self, n_tokens: int) -> Optional[Tuple[int, int, int]]: + Tp = self.input_frames // self.tubelet_size + Hp = self.input_size // self.patch_size + Wp = self.input_size // self.patch_size + return (Tp, Hp, Wp) if Tp * Hp * Wp == n_tokens else None + + def _encode_tokens(self, pixel_values: torch.Tensor) -> torch.Tensor: + if self._dense_pruned: + raise RuntimeError( + "forward_sparse is unavailable after prune_for_dense_prefix(); " + "construct VJEPA2Adapter with dense_only=False to retain it" + ) + # transformers VJEPA2 expects pixel_values_videos [B, T, 3, H, W] + # Predictor output is unused by the sparse encoder and can be skipped + # without changing encoder.last_hidden_state. + out = self._model(pixel_values_videos=pixel_values, skip_predictor=True) + tokens = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0] + return tokens # [B, N, D] + + # -- intermediate dense feature extraction ----------------------------- + @property + def dense_feature_dim(self) -> int: + cfg = getattr(self._model, "config", None) + if cfg is None or not hasattr(cfg, "hidden_size"): + raise RuntimeError("loaded V-JEPA2 model does not expose config.hidden_size") + return int(cfg.hidden_size) + + @property + def dense_raw_tokens(self) -> int: + return ( + (self.dense_num_frames // self.tubelet_size) + * (self.input_size // self.patch_size) + * (self.input_size // self.patch_size) + ) + + @property + def retained_dense_blocks(self) -> int: + return len(self._model.encoder.layer) + + @staticmethod + def _integer_index_tensor( + values: torch.Tensor, + *, + name: str, + device: torch.device, + ) -> torch.Tensor: + indices = torch.as_tensor(values) + return indices.to(device=device, dtype=torch.long) + + def _temporal_window_indices( + self, + video_clip_01: torch.Tensor, + *, + temporal_window_starts: Optional[torch.Tensor], + temporal_window_indices: Optional[torch.Tensor], + ) -> Optional[torch.Tensor]: + """Validate and canonicalize optional per-video contiguous windows.""" + if ( + temporal_window_starts is None + and temporal_window_indices is None + ): + return None + + batch_size = int(video_clip_01.shape[0]) + total_frames = int(video_clip_01.shape[1]) + max_start = total_frames - self.dense_num_frames + device = video_clip_01.device + + if temporal_window_starts is not None: + starts = self._integer_index_tensor( + temporal_window_starts, + name="temporal_window_starts", + device=device, + ) + offsets = torch.arange( + self.dense_num_frames, + dtype=torch.long, + device=device, + ) + return starts[:, None] + offsets[None, :] + + indices = self._integer_index_tensor( + temporal_window_indices, + name="temporal_window_indices", + device=device, + ) + expected_shape = (batch_size, self.dense_num_frames) + if indices.numel(): + starts = indices[:, 0] + offsets = torch.arange( + self.dense_num_frames, + dtype=torch.long, + device=device, + ) + expected = starts[:, None] + offsets[None, :] + return indices + + def preprocess_dense( + self, + video_clip_01: torch.Tensor, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Official differentiable V-JEPA2 preprocessing. + + The input is ``[B,T,3,H,W]`` in ``[0,1]``. By default a centered + contiguous ``dense_num_frames`` window is selected. Callers may + instead supply explicit integer starts with shape ``[B]`` or explicit + contiguous indices with shape ``[B,dense_num_frames]``. Sampling is + intentionally external: this adapter never consumes ambient RNG + state. The short side is then resized to 292 (configurable), a 256 + center crop is taken, and ImageNet normalization is applied. The + returned layout remains ``[B,T,3,256,256]``. + """ + total_frames = int(video_clip_01.shape[1]) + frame_indices = self._temporal_window_indices( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + if frame_indices is None: + start = (total_frames - self.dense_num_frames) // 2 + clip = video_clip_01.narrow(1, start, self.dense_num_frames) + else: + gather_indices = frame_indices[:, :, None, None, None].expand( + -1, + -1, + int(video_clip_01.shape[2]), + int(video_clip_01.shape[3]), + int(video_clip_01.shape[4]), + ) + clip = torch.gather(video_clip_01, 1, gather_indices) + x = differentiable_resize_crop( + clip, + self.input_size, + crop_mode="center", + resize_short_side=self.resize_short_side, + interpolation="bilinear", + round_center_crop=True, + ) + B, T, C, H, W = x.shape + flat = x.reshape(B * T, C, H, W) + flat = (flat - self.norm_mean.to(flat.dtype)) / self.norm_std.to(flat.dtype) + return flat.reshape(B, T, C, H, W) + + def forward_dense( + self, + video_clip_01: torch.Tensor, + layer_index: Optional[int] = None, + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Return a raw intermediate dense token matrix from an input video. + + No final encoder LayerNorm, predictor, pooling, or flattening is applied. + Gradients are retained with respect to ``video_clip_01`` even though all + V-JEPA2 parameters are frozen. + """ + pixel_values = self.preprocess_dense( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + return self.encode_dense_preprocessed( + pixel_values, + layer_index=layer_index, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + + def forward_dense_layers( + self, + video_clip_01: torch.Tensor, + layer_indices: Sequence[int], + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> Dict[int, torch.Tensor]: + """Return several raw intermediate token matrices in one prefix pass. + + The encoder is executed exactly once through ``max(layer_indices)``. + Each returned value is the complete post-residual output of its + zero-based block, before V-JEPA2's final LayerNorm. The dictionary + follows the caller-provided layer order. + """ + pixel_values = self.preprocess_dense( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + return self.encode_dense_preprocessed_layers( + pixel_values, + layer_indices=layer_indices, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + + def encode_dense_preprocessed( + self, + pixel_values: torch.Tensor, + layer_index: Optional[int] = None, + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + ) -> torch.Tensor: + """Run embeddings and complete blocks ``0..layer_index``. + + ``pixel_values`` must already have V-JEPA2 layout and normalization. + This lower-level entrypoint lets replay code avoid preprocessing twice. + """ + target_layer = self.dense_layer_index if layer_index is None else int(layer_index) + return self.encode_dense_preprocessed_layers( + pixel_values, + layer_indices=(target_layer,), + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + )[target_layer] + + def encode_dense_preprocessed_layers( + self, + pixel_values: torch.Tensor, + layer_indices: Sequence[int], + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + ) -> Dict[int, torch.Tensor]: + """Run one encoder prefix and return all requested block outputs. + + ``pixel_values`` must already have V-JEPA2 layout and normalization. + Blocks are evaluated only once, through the largest requested + zero-based layer. Token selection happens as each requested layer is + captured so that later blocks do not retain unneeded token outputs. + """ + + try: + requested_layers = tuple(int(layer) for layer in layer_indices) + except TypeError as exc: + raise TypeError("layer_indices must be a sequence of integers") from exc + for target_layer in requested_layers: + self._validate_dense_layer(target_layer) + max_target_layer = max(requested_layers) + + encoder = self._model.encoder + hidden_states = encoder.embeddings(pixel_values) + expected_tokens = self.dense_raw_tokens + self._raw_dim = int(hidden_states.shape[-1]) + + indices = None + if token_indices is not None: + indices = torch.as_tensor(token_indices, device=hidden_states.device) + if indices.dtype != torch.long: + indices = indices.to(torch.long) + + requested_set = set(requested_layers) + captured: Dict[int, torch.Tensor] = {} + for block_index in range(max_target_layer + 1): + block = encoder.layer[block_index] + + def run_block(x: torch.Tensor, module=block) -> torch.Tensor: + return module( + x, + position_mask=None, + head_mask=None, + output_attentions=False, + )[0] + + if ( + gradient_checkpointing + and torch.is_grad_enabled() + and hidden_states.requires_grad + ): + hidden_states = checkpoint( + run_block, + hidden_states, + use_reentrant=False, + ) + else: + hidden_states = run_block(hidden_states) + + if block_index in requested_set: + # This is deliberately before encoder.layernorm: + # HF hidden_states[i+1] is the complete post-residual result + # of zero-based block i. + captured[block_index] = ( + hidden_states + if indices is None + else hidden_states.index_select(1, indices) + ) + + return {layer: captured[layer] for layer in requested_layers} + + def prune_for_dense_prefix(self, layer_index: Optional[int] = None): + """Irreversibly retain only the modules required by dense-prefix mode.""" + target_layer = self.dense_layer_index if layer_index is None else int(layer_index) + self._validate_dense_layer(target_layer) + if self._dense_pruned and target_layer >= len(self._model.encoder.layer): + raise ValueError( + f"cannot restore pruned layer {target_layer}; only " + f"{len(self._model.encoder.layer)} blocks remain" + ) + self._model.encoder.layer = nn.ModuleList( + list(self._model.encoder.layer[: target_layer + 1]) + ) + # Dense prefix never executes either module. Replacing them with None + # unregisters their parameters immediately, which matters before FSDP. + self._model.encoder.layernorm = None + self._model.predictor = None + self._dense_pruned = True + self.dense_only = True + self.dense_layer_index = target_layer + return self + + def _validate_dense_layer(self, layer_index: int) -> None: + return None diff --git a/1.3B/code/video_fd/encoders/vjepa2_dense_metadata.py b/1.3B/code/video_fd/encoders/vjepa2_dense_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..224fe2d899fe24aa3f5cb46e15f24e12e12c0a56 --- /dev/null +++ b/1.3B/code/video_fd/encoders/vjepa2_dense_metadata.py @@ -0,0 +1,325 @@ +"""Canonical geometry and identity helpers for V-JEPA2 dense video features.""" + +from __future__ import annotations + +import hashlib +import os +from collections.abc import Sequence +from typing import Dict + +import torch + +from video_fd.encoders.vjepa2_temporal_sampling import WINDOW_SAMPLING_HASH + + +FEATURE_METADATA_VERSION = 1 +EXPLICIT_TOKEN_FEATURE_METADATA_VERSION = 2 +RANDOM_CONTIGUOUS_FEATURE_METADATA_VERSION = 3 +LAST_CONTIGUOUS_FEATURE_METADATA_VERSION = 4 +LAST_CONTIGUOUS_FRAME_SAMPLING = "last_contiguous" +DEFAULT_WAN_VAE_ID = "Wan2.1-T2V-1.3B/Wan2.1_VAE.pth" +RANDOM_CONTIGUOUS_FRAME_SAMPLING = "random_contiguous_per_video" + + +def uniform_dense_token_indices( + raw_tokens: int, + num_tokens: int, + *, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Return the one canonical deterministic token subset.""" + raw_tokens = int(raw_tokens) + num_tokens = int(num_tokens) + if raw_tokens <= 0: + raise ValueError("raw_tokens must be positive") + if num_tokens <= 1 or num_tokens > raw_tokens: + raise ValueError( + f"num_tokens must be in [2,{raw_tokens}], got {num_tokens}") + return torch.linspace( + 0, + raw_tokens - 1, + steps=num_tokens, + device=device, + dtype=torch.float64, + ).round().to(torch.long) + + +def token_indices_sha256(indices: torch.Tensor) -> str: + values = torch.as_tensor( + indices, dtype=torch.int64, device="cpu").contiguous() + if values.ndim != 1 or values.numel() <= 1: + raise ValueError("token indices must be a non-empty 1-D sequence") + return hashlib.sha256(values.numpy().tobytes()).hexdigest() + + +def validate_explicit_dense_token_indices( + indices: torch.Tensor | Sequence[int], + *, + raw_tokens: int, + num_tokens: int, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Canonicalize an explicit aligned-token subset. + + Explicit subsets are deliberately stricter than the legacy uniform + helper: the serialized order is the matrix-row alignment contract shared + by student features and their frozen teacher reference. + """ + raw_tokens = int(raw_tokens) + num_tokens = int(num_tokens) + if raw_tokens <= 0: + raise ValueError("raw_tokens must be positive") + if num_tokens <= 1 or num_tokens > raw_tokens: + raise ValueError( + f"num_tokens must be in [2,{raw_tokens}], got {num_tokens}") + if isinstance(indices, torch.Tensor): + if ( + indices.dtype == torch.bool + or indices.is_floating_point() + or indices.is_complex() + ): + raise TypeError("explicit token indices must be integers") + values = indices.detach().to( + device="cpu", dtype=torch.int64).contiguous() + else: + if any(type(value) is not int for value in indices): + raise TypeError("explicit token indices must be integers") + values = torch.tensor( + indices, dtype=torch.int64, device="cpu").contiguous() + if values.ndim != 1: + raise ValueError("explicit token indices must be one-dimensional") + if values.numel() != num_tokens: + raise ValueError( + "explicit token index count mismatch: " + f"got {values.numel()}, expected {num_tokens}") + if int(values[0]) < 0 or int(values[-1]) >= raw_tokens: + raise ValueError( + f"explicit token indices must be in [0,{raw_tokens})") + if not bool(torch.all(values[1:] > values[:-1]).item()): + raise ValueError( + "explicit token indices must be strictly increasing and unique") + return values.to(device=device) + + +def canonical_vjepa2_dense_feature_metadata( + *, + model_id: str, + layer_index: int, + num_frames: int, + resize_short_side: int, + crop_size: int, + raw_tokens: int, + num_tokens: int, + feature_dim: int, + vae_id: str = DEFAULT_WAN_VAE_ID, + token_indices: torch.Tensor | Sequence[int] | None = None, + frame_sampling: str = "center_contiguous", + source_num_frames: int | None = None, + window_seed: str | None = None, +) -> Dict[str, object]: + """Build metadata shared by offline reference creation and training.""" + expanded_model_id = os.path.expanduser(str(model_id)) + canonical_model_id = ( + os.path.realpath(expanded_model_id) + if os.path.exists(expanded_model_id) + else str(model_id) + ) + if frame_sampling == "center_contiguous" and ( + source_num_frames is not None or window_seed is not None + ): + raise ValueError( + "source_num_frames and window_seed are only valid for " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING}" + ) + + if frame_sampling == "center_contiguous" and token_indices is None: + # Keep this legacy payload byte-for-byte stable for existing reference + # packs and strict checkpoint metadata comparisons. + indices = uniform_dense_token_indices(raw_tokens, num_tokens) + return { + "feature_metadata_version": FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": "center_contiguous", + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + "token_sampling": "uniform_linspace_round", + "token_alignment": "same_deterministic_indices", + "token_indices_sha256": token_indices_sha256(indices), + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + + if frame_sampling == "center_contiguous": + indices = validate_explicit_dense_token_indices( + token_indices, + raw_tokens=raw_tokens, + num_tokens=num_tokens, + ) + return { + "feature_metadata_version": EXPLICIT_TOKEN_FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": "center_contiguous", + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + "token_sampling": "explicit_sorted_unique", + "token_alignment": "same_explicit_indices_and_order", + "token_indices": [int(value) for value in indices.tolist()], + "token_indices_sha256": token_indices_sha256(indices), + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + + if frame_sampling == LAST_CONTIGUOUS_FRAME_SAMPLING: + if type(source_num_frames) is not int: + raise TypeError( + "source_num_frames must be an integer for last_contiguous") + if window_seed is not None: + raise ValueError("last_contiguous does not use window_seed") + if source_num_frames < int(num_frames): + raise ValueError( + "source_num_frames must be >= num_frames for last_contiguous") + indices = ( + uniform_dense_token_indices(raw_tokens, num_tokens) + if token_indices is None + else validate_explicit_dense_token_indices( + token_indices, + raw_tokens=raw_tokens, + num_tokens=num_tokens, + ) + ) + start = int(source_num_frames) - int(num_frames) + metadata = { + "feature_metadata_version": LAST_CONTIGUOUS_FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": LAST_CONTIGUOUS_FRAME_SAMPLING, + "source_num_frames": int(source_num_frames), + "selected_frame_start": start, + "selected_frame_end_inclusive": int(source_num_frames) - 1, + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + "token_sampling": ( + "uniform_linspace_round" + if token_indices is None else "explicit_sorted_unique"), + "token_alignment": ( + "same_deterministic_indices" + if token_indices is None + else "same_explicit_indices_and_order"), + "token_indices_sha256": token_indices_sha256(indices), + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + if token_indices is not None: + metadata["token_indices"] = [ + int(value) for value in indices.tolist() + ] + return metadata + + if frame_sampling != RANDOM_CONTIGUOUS_FRAME_SAMPLING: + raise ValueError( + "frame_sampling must be center_contiguous, last_contiguous, or " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING!r}, got {frame_sampling!r}" + ) + if type(source_num_frames) is not int: + raise TypeError( + "source_num_frames must be an integer for " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING}" + ) + if source_num_frames < int(num_frames): + raise ValueError( + "source_num_frames must be >= num_frames, got " + f"{source_num_frames} < {int(num_frames)}" + ) + if not isinstance(window_seed, str) or not window_seed: + raise ValueError( + "window_seed must be a non-empty string for " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING}" + ) + if "\0" in window_seed: + raise ValueError("window_seed must not contain NUL characters") + + if token_indices is None: + indices = uniform_dense_token_indices(raw_tokens, num_tokens) + token_metadata = { + "token_sampling": "uniform_linspace_round", + "token_alignment": "same_deterministic_indices", + "token_indices_sha256": token_indices_sha256(indices), + } + else: + indices = validate_explicit_dense_token_indices( + token_indices, + raw_tokens=raw_tokens, + num_tokens=num_tokens, + ) + token_metadata = { + "token_sampling": "explicit_sorted_unique", + "token_alignment": "same_explicit_indices_and_order", + "token_indices": [int(value) for value in indices.tolist()], + "token_indices_sha256": token_indices_sha256(indices), + } + + window_start_max = int(source_num_frames) - int(num_frames) + return { + "feature_metadata_version": + RANDOM_CONTIGUOUS_FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": RANDOM_CONTIGUOUS_FRAME_SAMPLING, + "source_num_frames": int(source_num_frames), + "window_seed": window_seed, + "window_start_min": 0, + "window_start_max": window_start_max, + "window_num_choices": window_start_max + 1, + "window_sampling_hash": WINDOW_SAMPLING_HASH, + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + **token_metadata, + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } diff --git a/1.3B/code/video_fd/encoders/vjepa2_temporal_sampling.py b/1.3B/code/video_fd/encoders/vjepa2_temporal_sampling.py new file mode 100644 index 0000000000000000000000000000000000000000..1deb57c81e4adafbc56ef6f2d026d5a627937479 --- /dev/null +++ b/1.3B/code/video_fd/encoders/vjepa2_temporal_sampling.py @@ -0,0 +1,92 @@ +"""Shared stateless temporal-window sampling for V-JEPA2 dense features. + +The helper in this module is deliberately independent of Python, NumPy, and +PyTorch random-number-generator state. Training and offline teacher tooling +can therefore derive the same window from stable sample identities without +changing any ambient RNG stream. +""" + +from __future__ import annotations + +import hashlib + +import torch + + +WINDOW_SAMPLING_HASH = "sha256_first_u64_big_endian_mod_num_windows" + + +def _validate_identity(seed: str, domain: str, sample_key: int) -> None: + return None + + +def _validate_geometry( + source_num_frames: int, + window_num_frames: int, +) -> tuple[int, int]: + return source_num_frames, window_num_frames + + +def temporal_window_sample_sha256( + seed: str, + domain: str, + sample_key: int, +) -> str: + """Return the canonical hash for one stable temporal-window identity. + + The exact payload is ``seed UTF-8 + NUL + domain UTF-8 + NUL + the + unsigned decimal sample key in UTF-8``. + """ + _validate_identity(seed, domain, sample_key) + payload = ( + seed.encode("utf-8") + + b"\0" + + domain.encode("utf-8") + + b"\0" + + str(sample_key).encode("utf-8") + ) + return hashlib.sha256(payload).hexdigest() + + +def stateless_temporal_window_start( + seed: str, + domain: str, + sample_key: int, + *, + source_num_frames: int, + window_num_frames: int, +) -> int: + """Map a stable sample identity to one valid contiguous-window start.""" + source_num_frames, window_num_frames = _validate_geometry( + source_num_frames, + window_num_frames, + ) + digest = bytes.fromhex( + temporal_window_sample_sha256(seed, domain, sample_key)) + num_windows = source_num_frames - window_num_frames + 1 + return int.from_bytes(digest[:8], byteorder="big") % num_windows + + +def stateless_temporal_window_indices( + seed: str, + domain: str, + sample_key: int, + *, + source_num_frames: int, + window_num_frames: int, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Return the selected contiguous frame indices as a 1-D LongTensor.""" + start = stateless_temporal_window_start( + seed, + domain, + sample_key, + source_num_frames=source_num_frames, + window_num_frames=window_num_frames, + ) + return torch.arange( + start, + start + window_num_frames, + dtype=torch.long, + device=device, + ) diff --git a/1.3B/code/video_fd/replay/__init__.py b/1.3B/code/video_fd/replay/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..549154c94d134908851dbfa46ea0cf7188cfa481 --- /dev/null +++ b/1.3B/code/video_fd/replay/__init__.py @@ -0,0 +1,14 @@ +from video_fd.replay.scatter_frame_grads import gather_frames, scatter_add_frames +from video_fd.replay.replay_video_encoder_vjp import ( + replay_encoder_vjp, + replay_video_encoder_vjp, + replay_decoder_vjp, +) + +__all__ = [ + "gather_frames", + "scatter_add_frames", + "replay_encoder_vjp", + "replay_video_encoder_vjp", + "replay_decoder_vjp", +] diff --git a/1.3B/code/video_fd/replay/replay_video_encoder_vjp.py b/1.3B/code/video_fd/replay/replay_video_encoder_vjp.py new file mode 100644 index 0000000000000000000000000000000000000000..d0dfa1d812504c5845ad3df8fae81b6e751c076b --- /dev/null +++ b/1.3B/code/video_fd/replay/replay_video_encoder_vjp.py @@ -0,0 +1,149 @@ +"""Replay VJP: feature gradients -> pixel-frame gradients -> latent gradient. + +Stage B1 (encoder VJP): for each sampled clip, re-run the frozen encoder (+ the +differentiable normalize/project) **with gradients on the clip**, form the linear +surrogate ````, take its gradient w.r.t. the clip, and +scatter-add into the full 81-frame pixel-gradient buffer. + +Stage B2 (decoder VJP): map the full pixel-frame gradient buffer back to the +latent gradient by re-decoding **one latent frame at a time** with a value-carried, +grad-detached temporal cache (bounded memory; values exact; cross-frame cache +gradient coupling dropped). This mirrors the validated per-frame decode VJP. + +No ``torch.no_grad`` is used here -- gradients must flow through the frozen +encoder/decoder ops to the clip / latent. +""" + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Callable, List, Optional, Tuple + +import torch + +from video_fd.replay.scatter_frame_grads import gather_frames, scatter_add_frames, new_frame_grad_buffer + + +def _amp_ctx(use_amp: bool, amp_dtype: torch.dtype, device_type: str): + if use_amp and device_type == "cuda": + return torch.autocast(device_type="cuda", dtype=amp_dtype) + return nullcontext() + + +def replay_encoder_vjp( + encoder, + v_clip_detached: torch.Tensor, # [B, T, 3, H, W] in [0,1], detached + grad_feat_proj: torch.Tensor, # [B, S, D_proj], dL/d(projected feature) + projector: Optional[Callable[[torch.Tensor], torch.Tensor]] = None, + use_amp: bool = False, + amp_dtype: torch.dtype = torch.bfloat16, +) -> torch.Tensor: + """One-clip encoder VJP: returns ``g_clip`` = (dF_proj/d v_clip)^T grad_feat_proj.""" + # Frozen encoder parameters still allow derivatives with respect to pixels. + # Recreate only this clip's graph; grad_feat_proj is the cached cotangent. + v_leaf = v_clip_detached.detach().clone().requires_grad_(True) + with _amp_ctx(use_amp, amp_dtype, v_leaf.device.type): + feat = encoder.forward_sparse(v_leaf) # [B, S, D_raw] + feat_proj = projector(feat) if projector is not None else feat + feat_proj = feat_proj.float() + g = grad_feat_proj.detach().to(feat_proj.dtype) + if g.shape != feat_proj.shape: + raise RuntimeError( + f"encoder VJP shape mismatch: feat_proj {tuple(feat_proj.shape)} vs " + f"grad {tuple(g.shape)}" + ) + # The inner product injects dL/d feature without recomputing the MMD loss: + # its pixel derivative is J_encoder^T (dL/d feature), the required VJP. + replay_loss = (feat_proj * g).sum() + (g_clip,) = torch.autograd.grad(replay_loss, v_leaf, retain_graph=False, create_graph=False) + return g_clip + + +def replay_video_encoder_vjp( + video81_detached: torch.Tensor, # [B, 81, 3, H, W] in [0,1], detached + specs_and_grads: List[Tuple[object, object, torch.Tensor]], + use_amp: bool = False, + amp_dtype: torch.dtype = torch.bfloat16, +) -> torch.Tensor: + """Accumulate per-clip encoder VJPs into a full pixel-frame gradient buffer. + + ``specs_and_grads`` is a list of ``(encoder, spec_or_projector_tuple, + grad_feat_proj)`` where the middle element is ``(spec, projector)``. Each + clip's gradient is scattered into the 81-frame buffer at ``spec.frame_indices`` + (accumulating overlaps). + """ + # Multiple clips/encoders can touch the same frame. Sum their derivatives + # in video coordinates before replaying the VAE decoder. + g_video = new_frame_grad_buffer(video81_detached) + for encoder, (spec, projector), grad_feat_proj in specs_and_grads: + idx = spec.frame_indices + v_clip = gather_frames(video81_detached, idx, time_dim=1) # [B, 16, 3, H, W] + g_clip = replay_encoder_vjp( + encoder, v_clip, grad_feat_proj, projector=projector, + use_amp=use_amp, amp_dtype=amp_dtype, + ) + scatter_add_frames(g_video, idx, g_clip, time_dim=1) + return g_video + + +@torch.enable_grad() +def replay_decoder_vjp( + vae, + z_detached: torch.Tensor, # [B, T_lat, C, H, W] (repo latent layout) + g_video01: torch.Tensor, # [B, T_pix, 3, H, W] dL/d(pixels in [0,1]) + independent_first_frame: bool = True, +) -> torch.Tensor: + """Per-latent-frame decode VJP: ``grad_z = (d pixels01 / d z)^T g_video01``. + + Re-decodes one latent frame at a time with grad, carrying the VAE temporal + cache by value but detaching it between frames so peak memory is ~one frame's + decode graph. Frames with zero gradient still advance the cache (needed for + exact values of later frames) but contribute no latent gradient. + + Temporal cache detachment truncates cross-frame derivatives. This historical + path preserves decode values, but is not the full decoder Jacobian. + """ + vae_model = vae.model + zdim = vae_model.z_dim + device = z_detached.device + dtype = z_detached.dtype + mean = vae.mean.to(device=device, dtype=dtype).view(1, zdim, 1, 1, 1) + inv_std = (1.0 / vae.std.to(device=device, dtype=dtype)).view(1, zdim, 1, 1, 1) + + z_cf = z_detached.detach().permute(0, 2, 1, 3, 4).contiguous() # [B, zdim, T_lat, H, W] + T = z_cf.shape[2] + grad_z_cf = torch.zeros_like(z_cf, dtype=torch.float32) + + # Recompute one decode segment, inject its pixel cotangent, and release the + # segment graph. The returned latent cotangent drives generator replay next. + vae_model.clear_cache() + px_cursor = 0 + for i in range(T): + vae_model._conv_idx = [0] + z_i = z_cf[:, :, i:i + 1].clone().requires_grad_(True) + z_i_unscaled = z_i / inv_std + mean + x_i = vae_model.conv2(z_i_unscaled) + out_i = vae_model.decoder(x_i, feat_cache=vae_model._feat_map, feat_idx=vae_model._conv_idx) + out_i = out_i.float().clamp(-1.0, 1.0) + pix_i = out_i.shape[2] + pixels01_i = ((out_i + 1.0) * 0.5).permute(0, 2, 1, 3, 4) # [B, pix_i, 3, H, W] + end = min(px_cursor + pix_i, g_video01.shape[1]) + # Compute the surrogate in float32 so a large pixel-frame gradient does + # not overflow bf16 (the encoder VJP can be large). + g_i = g_video01[:, px_cursor:end].float() + if g_i.shape[1] == pixels01_i.shape[1] and torch.isfinite(g_i).all() and g_i.abs().sum() > 0: + surrogate_i = (pixels01_i.float() * g_i.detach()).sum() + (grad_zi,) = torch.autograd.grad(surrogate_i, z_i, retain_graph=False) + grad_z_cf[:, :, i:i + 1] += grad_zi.float() + # The gradient has already been accumulated. Keeping its graph + # root alive until the next decoder call would retain the old + # frame leaf and output storage without affecting any later math. + del surrogate_i, grad_zi + px_cursor += pix_i + vae_model._feat_map = [c.detach() if torch.is_tensor(c) else c for c in vae_model._feat_map] + # The detached temporal cache above is the only state needed by the + # next frame. Release the consumed frame graph and views before that + # frame's decoder reaches its allocation peak. + del pixels01_i, out_i, x_i, z_i_unscaled, z_i, g_i + vae_model.clear_cache() + return grad_z_cf.permute(0, 2, 1, 3, 4) # [B, T_lat, C, H, W] diff --git a/1.3B/code/video_fd/replay/scatter_frame_grads.py b/1.3B/code/video_fd/replay/scatter_frame_grads.py new file mode 100644 index 0000000000000000000000000000000000000000..52b3eb253d42c849d970dabc8ef5315ae82f1fa8 --- /dev/null +++ b/1.3B/code/video_fd/replay/scatter_frame_grads.py @@ -0,0 +1,22 @@ +"""Frame gather / scatter-add for replay over arbitrary selected frame indices. + +The stride-4 sampler selects interleaved pixel frames; the replay must (a) gather +those frames into a 16-frame clip and (b) scatter the clip's pixel gradient back +into the full 81-frame buffer, **accumulating** when a frame is selected by +multiple clips / crops / encoders (spec section 11.3). +""" + +from __future__ import annotations + +import torch + +# Re-export the canonical implementations from the sampler module so there is a +# single source of truth for the gather/scatter rule. +from video_fd.samplers.stride4_clip_sampler import gather_frames, scatter_add_frames + +__all__ = ["gather_frames", "scatter_add_frames", "new_frame_grad_buffer"] + + +def new_frame_grad_buffer(video: torch.Tensor) -> torch.Tensor: + """Zero gradient buffer matching a ``[B, T, 3, H, W]`` video (float32).""" + return torch.zeros_like(video, dtype=torch.float32) diff --git a/1.3B/code/video_fd/samplers/__init__.py b/1.3B/code/video_fd/samplers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3cf2151b369867c739c7f7023634b5cc8ee09e1a --- /dev/null +++ b/1.3B/code/video_fd/samplers/__init__.py @@ -0,0 +1,7 @@ +from video_fd.samplers.stride4_clip_sampler import ( + ClipSpec, + Stride4ClipSampler, + stride4_candidate_indices, +) + +__all__ = ["ClipSpec", "Stride4ClipSampler", "stride4_candidate_indices"] diff --git a/1.3B/code/video_fd/samplers/stride4_clip_sampler.py b/1.3B/code/video_fd/samplers/stride4_clip_sampler.py new file mode 100644 index 0000000000000000000000000000000000000000..27460b605954fb64eb8726281bf8cdf9718bc054 --- /dev/null +++ b/1.3B/code/video_fd/samplers/stride4_clip_sampler.py @@ -0,0 +1,200 @@ +"""Stride-4 latent-aligned contiguous-clip sampler. + +From a ``num_pixel_frames``-frame, ``source_fps``-fps video, select a phase +``o ~ Uniform{0,1,2,3}`` and take every ``stride``-th frame +(``I = {o + stride*k}``), giving an effective ``source_fps/stride`` fps stream; +then take a random contiguous ``clip_frames``-frame window of that stream. + +For the canonical setting (81 frames @ 16 fps, stride 4, clip 16) this yields 16 +frames at an effective 4 fps. The *same* sampler is used for target-statistic +precomputation, generated training, and evaluation (random phase + random valid +start), with an optional deterministic mode for tests/debugging. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +import torch + + +def stride4_candidate_indices(phase: int, stride: int, num_pixel_frames: int) -> List[int]: + """All frame indices ``phase + stride*k`` within ``[0, num_pixel_frames-1]``.""" + last = num_pixel_frames - 1 + return [phase + stride * k for k in range(num_pixel_frames) if phase + stride * k <= last] + + +@dataclass +class ClipSpec: + """One sampled clip: a set of frame indices applied across the batch. + + ``crop_id`` / ``encoder_name`` are optional bookkeeping fields set by the + caller (the spatial crop is applied by the encoder adapter, not here). + """ + + phase: int + start: int + frame_indices: List[int] + stride: int = 4 + clip_frames: int = 16 + crop_id: Optional[str] = None + encoder_name: Optional[str] = None + + def to_dict(self) -> dict: + return { + "phase": int(self.phase), + "start": int(self.start), + "frame_indices": list(self.frame_indices), + "stride": int(self.stride), + "clip_frames": int(self.clip_frames), + "crop_id": self.crop_id, + "encoder_name": self.encoder_name, + } + + def index_tensor(self, device=None) -> torch.Tensor: + return torch.tensor(self.frame_indices, dtype=torch.long, device=device) + + +class Stride4ClipSampler: + """Sampler for stride-4 contiguous clips. + + Parameters + ---------- + num_pixel_frames, source_fps, stride, clip_frames: + temporal geometry (defaults: 81, 16, 4, 16 -> 16 frames @ 4 fps). + random_phase / random_start: + if ``False`` and a ``deterministic`` value is given, use it. + deterministic / phase / start: + debug mode -- always return ``frame_indices`` for the fixed phase/start. + """ + + def __init__( + self, + num_pixel_frames: int = 81, + source_fps: int = 16, + stride: int = 4, + clip_frames: int = 16, + random_phase: bool = True, + random_start: bool = True, + deterministic: bool = False, + phase: int = 0, + start: int = 0, + ): + if clip_frames <= 0: + raise ValueError("clip_frames must be positive") + self.num_pixel_frames = int(num_pixel_frames) + self.source_fps = int(source_fps) + self.stride = int(stride) + self.clip_frames = int(clip_frames) + self.random_phase = bool(random_phase) + self.random_start = bool(random_start) + self.deterministic = bool(deterministic) + self.fixed_phase = int(phase) + self.fixed_start = int(start) + + # validate feasibility: the shortest phase stream must still hold a clip + min_candidates = min( + len(stride4_candidate_indices(p, self.stride, self.num_pixel_frames)) + for p in range(self.stride) + ) + if min_candidates < self.clip_frames: + raise ValueError( + f"stride={self.stride}, clip_frames={self.clip_frames} infeasible: " + f"some phase yields only {min_candidates} candidate frames " + f"(< {self.clip_frames}) for {self.num_pixel_frames} pixel frames" + ) + + @property + def effective_fps(self) -> float: + return self.source_fps / self.stride + + def _sample_one(self, generator: Optional[torch.Generator]) -> ClipSpec: + if self.deterministic: + phase = self.fixed_phase + start = self.fixed_start + else: + if self.random_phase: + phase = int(torch.randint(0, self.stride, (), generator=generator).item()) + else: + phase = self.fixed_phase + candidate = stride4_candidate_indices(phase, self.stride, self.num_pixel_frames) + max_start = len(candidate) - self.clip_frames + if self.random_start and max_start > 0: + start = int(torch.randint(0, max_start + 1, (), generator=generator).item()) + else: + start = min(self.fixed_start, max_start) if not self.random_start else 0 + candidate = stride4_candidate_indices(phase, self.stride, self.num_pixel_frames) + max_start = len(candidate) - self.clip_frames + start = max(0, min(start, max_start)) + frame_indices = candidate[start: start + self.clip_frames] + return ClipSpec( + phase=phase, + start=start, + frame_indices=frame_indices, + stride=self.stride, + clip_frames=self.clip_frames, + ) + + def sample( + self, + batch_size: int = 1, + num_clips: int = 1, + generator: Optional[torch.Generator] = None, + ) -> List[ClipSpec]: + """Return ``num_clips`` clip specs (each applied across the whole batch). + + ``batch_size`` is accepted for API symmetry; the same frame indices are + applied to all batch items of a given clip (see ``gather_frames``). + """ + return [self._sample_one(generator) for _ in range(max(1, num_clips))] + + def config_dict(self) -> dict: + """Sampler config to embed in every statistics file (spec section 1.2).""" + return { + "source_fps": self.source_fps, + "num_pixel_frames": self.num_pixel_frames, + "stride": self.stride, + "effective_fps": self.effective_fps, + "clip_frames": self.clip_frames, + "phase_distribution": "uniform_0_1_2_3" if self.stride == 4 else f"uniform_0_{self.stride - 1}", + "start_distribution": "uniform_valid_contiguous_start", + "deterministic": self.deterministic, + } + + @classmethod + def from_config(cls, cfg: dict) -> "Stride4ClipSampler": + return cls( + num_pixel_frames=int(cfg.get("num_pixel_frames", 81)), + source_fps=int(cfg.get("source_fps", 16)), + stride=int(cfg.get("stride", 4)), + clip_frames=int(cfg.get("clip_frames", 16)), + random_phase=bool(cfg.get("random_phase", True)), + random_start=bool(cfg.get("random_start", True)), + deterministic=bool(cfg.get("deterministic", cfg.get("deterministic_debug", False))), + phase=int(cfg.get("phase", 0)), + start=int(cfg.get("start", 0)), + ) + + +def gather_frames(video: torch.Tensor, frame_indices, time_dim: int = 1) -> torch.Tensor: + """Select ``frame_indices`` along ``time_dim`` of ``video`` (differentiable).""" + if not torch.is_tensor(frame_indices): + frame_indices = torch.tensor(list(frame_indices), dtype=torch.long, device=video.device) + else: + frame_indices = frame_indices.to(device=video.device, dtype=torch.long) + return video.index_select(time_dim, frame_indices) + + +def scatter_add_frames(target: torch.Tensor, frame_indices, values: torch.Tensor, time_dim: int = 1) -> torch.Tensor: + """Accumulate ``values`` into ``target`` at ``frame_indices`` along ``time_dim``. + + Uses ``index_add`` so that a pixel frame selected by multiple clips / crops / + encoders correctly accumulates gradients (spec section 11.3). + """ + if not torch.is_tensor(frame_indices): + frame_indices = torch.tensor(list(frame_indices), dtype=torch.long, device=target.device) + else: + frame_indices = frame_indices.to(device=target.device, dtype=torch.long) + target.index_add_(time_dim, frame_indices, values.to(target.dtype)) + return target diff --git a/1.3B/code/wan/__init__.py b/1.3B/code/wan/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..764073680ebcb829e03f1f9a207a6248e0da84a6 --- /dev/null +++ b/1.3B/code/wan/__init__.py @@ -0,0 +1 @@ +"""Wan backbone modules used by distribution matching.""" diff --git a/1.3B/code/wan/modules/__init__.py b/1.3B/code/wan/modules/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f8935bbb45ab4e3f349d203b673102f7cfc07553 --- /dev/null +++ b/1.3B/code/wan/modules/__init__.py @@ -0,0 +1,16 @@ +from .attention import flash_attention +from .model import WanModel +from .t5 import T5Decoder, T5Encoder, T5EncoderModel, T5Model +from .tokenizers import HuggingfaceTokenizer +from .vae import WanVAE + +__all__ = [ + 'WanVAE', + 'WanModel', + 'T5Model', + 'T5Encoder', + 'T5Decoder', + 'T5EncoderModel', + 'HuggingfaceTokenizer', + 'flash_attention', +] diff --git a/1.3B/code/wan/modules/attention.py b/1.3B/code/wan/modules/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..8845659c1418da0b4a82014dcde77a53f7206e6e --- /dev/null +++ b/1.3B/code/wan/modules/attention.py @@ -0,0 +1,185 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import torch + +try: + import flash_attn_interface + + def is_hopper_gpu(): + if not torch.cuda.is_available(): + return False + device_name = torch.cuda.get_device_name(0).lower() + return "h100" in device_name or "hopper" in device_name + FLASH_ATTN_3_AVAILABLE = is_hopper_gpu() +except ModuleNotFoundError: + FLASH_ATTN_3_AVAILABLE = False + +try: + import flash_attn + FLASH_ATTN_2_AVAILABLE = True +except ModuleNotFoundError: + FLASH_ATTN_2_AVAILABLE = False + +# FLASH_ATTN_3_AVAILABLE = False + +import warnings + +__all__ = [ + 'flash_attention', + 'attention', +] + + +def flash_attention( + q, + k, + v, + q_lens=None, + k_lens=None, + dropout_p=0., + softmax_scale=None, + q_scale=None, + causal=False, + window_size=(-1, -1), + deterministic=False, + dtype=torch.bfloat16, + version=None, +): + """ + q: [B, Lq, Nq, C1]. + k: [B, Lk, Nk, C1]. + v: [B, Lk, Nk, C2]. Nq must be divisible by Nk. + q_lens: [B]. + k_lens: [B]. + dropout_p: float. Dropout probability. + softmax_scale: float. The scaling of QK^T before applying softmax. + causal: bool. Whether to apply causal attention mask. + window_size: (left right). If not (-1, -1), apply sliding window local attention. + deterministic: bool. If True, slightly slower and uses more memory. + dtype: torch.dtype. Apply when dtype of q/k/v is not float16/bfloat16. + """ + half_dtypes = (torch.float16, torch.bfloat16) + assert dtype in half_dtypes + assert q.device.type == 'cuda' and q.size(-1) <= 256 + + # params + b, lq, lk, out_dtype = q.size(0), q.size(1), k.size(1), q.dtype + + def half(x): + return x if x.dtype in half_dtypes else x.to(dtype) + + # preprocess query + if q_lens is None: + q = half(q.flatten(0, 1)) + q_lens = torch.tensor( + [lq] * b, dtype=torch.int32).to( + device=q.device, non_blocking=True) + else: + q = half(torch.cat([u[:v] for u, v in zip(q, q_lens)])) + + # preprocess key, value + if k_lens is None: + k = half(k.flatten(0, 1)) + v = half(v.flatten(0, 1)) + k_lens = torch.tensor( + [lk] * b, dtype=torch.int32).to( + device=k.device, non_blocking=True) + else: + k = half(torch.cat([u[:v] for u, v in zip(k, k_lens)])) + v = half(torch.cat([u[:v] for u, v in zip(v, k_lens)])) + + q = q.to(v.dtype) + k = k.to(v.dtype) + + if q_scale is not None: + q = q * q_scale + + if version is not None and version == 3 and not FLASH_ATTN_3_AVAILABLE: + warnings.warn( + 'Flash attention 3 is not available, use flash attention 2 instead.' + ) + + # apply attention + if (version is None or version == 3) and FLASH_ATTN_3_AVAILABLE: + # Note: dropout_p, window_size are not supported in FA3 now. + x = flash_attn_interface.flash_attn_varlen_func( + q=q, + k=k, + v=v, + cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + max_seqlen_q=lq, + max_seqlen_k=lk, + softmax_scale=softmax_scale, + causal=causal, + deterministic=deterministic)[0].unflatten(0, (b, lq)) + else: + assert FLASH_ATTN_2_AVAILABLE + x = flash_attn.flash_attn_varlen_func( + q=q, + k=k, + v=v, + cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + max_seqlen_q=lq, + max_seqlen_k=lk, + dropout_p=dropout_p, + softmax_scale=softmax_scale, + causal=causal, + window_size=window_size, + deterministic=deterministic).unflatten(0, (b, lq)) + + # output + return x.type(out_dtype) + + +def attention( + q, + k, + v, + q_lens=None, + k_lens=None, + dropout_p=0., + softmax_scale=None, + q_scale=None, + causal=False, + window_size=(-1, -1), + deterministic=False, + dtype=torch.bfloat16, + fa_version=None, +): + if FLASH_ATTN_2_AVAILABLE or FLASH_ATTN_3_AVAILABLE: + return flash_attention( + q=q, + k=k, + v=v, + q_lens=q_lens, + k_lens=k_lens, + dropout_p=dropout_p, + softmax_scale=softmax_scale, + q_scale=q_scale, + causal=causal, + window_size=window_size, + deterministic=deterministic, + dtype=dtype, + version=fa_version, + ) + else: + if q_lens is not None or k_lens is not None: + warnings.warn( + 'Padding mask is disabled when using scaled_dot_product_attention. It can have a significant impact on performance.' + ) + attn_mask = None + + q = q.transpose(1, 2).to(dtype) + k = k.transpose(1, 2).to(dtype) + v = v.transpose(1, 2).to(dtype) + + out = torch.nn.functional.scaled_dot_product_attention( + q, k, v, attn_mask=attn_mask, is_causal=causal, dropout_p=dropout_p) + + out = out.transpose(1, 2).contiguous() + return out diff --git a/1.3B/code/wan/modules/causal_model.py b/1.3B/code/wan/modules/causal_model.py new file mode 100644 index 0000000000000000000000000000000000000000..5541916061876564a34c4d2c11ad75c84126516c --- /dev/null +++ b/1.3B/code/wan/modules/causal_model.py @@ -0,0 +1,1095 @@ +from wan.modules.attention import attention +from wan.modules.model import ( + WanRMSNorm, + rope_apply, + WanLayerNorm, + WAN_CROSSATTENTION_CLASSES, + rope_params, + MLPProj, + sinusoidal_embedding_1d +) +from torch.nn.attention.flex_attention import create_block_mask, flex_attention +from diffusers.configuration_utils import ConfigMixin, register_to_config +from torch.nn.attention.flex_attention import BlockMask +from diffusers.models.modeling_utils import ModelMixin +import torch.nn as nn +import torch +import math +import torch.distributed as dist + +# wan 1.3B model has a weird channel / head configurations and require max-autotune to work with flexattention +# see https://github.com/pytorch/pytorch/issues/133254 +# change to default for other models +flex_attention = torch.compile( + flex_attention, dynamic=False, mode="max-autotune-no-cudagraphs") + + +def causal_rope_apply(x, grid_sizes, freqs, start_frame=0): + n, c = x.size(2), x.size(3) // 2 + + # split freqs + freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) + + # loop over samples + output = [] + + for i, (f, h, w) in enumerate(grid_sizes.tolist()): + seq_len = f * h * w + + # Preserve the pretrained causal Wan numerics. The encoder-specific + # memory trade-off must not silently change the student's RoPE. + x_i = torch.view_as_complex(x[i, :seq_len].to(torch.float64).reshape( + seq_len, n, -1, 2)) + freqs_i = torch.cat([ + freqs[0][start_frame:start_frame + f].view(f, 1, 1, -1).expand(f, h, w, -1), + freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1), + freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1) + ], + dim=-1).reshape(seq_len, 1, -1) + + # apply rotary embedding + x_i = torch.view_as_real(x_i * freqs_i).flatten(2) + x_i = torch.cat([x_i, x[i, seq_len:]]) + + # append to collection + output.append(x_i) + return torch.stack(output).type_as(x) + + +class CausalWanSelfAttention(nn.Module): + + def __init__(self, + dim, + num_heads, + local_attn_size=-1, + sink_size=0, + qk_norm=True, + eps=1e-6): + assert dim % num_heads == 0 + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.head_dim = dim // num_heads + self.local_attn_size = local_attn_size + self.sink_size = sink_size + self.qk_norm = qk_norm + self.eps = eps + self.max_attention_size = 32760 if local_attn_size == -1 else local_attn_size * 1560 + + # layers + self.q = nn.Linear(dim, dim) + self.k = nn.Linear(dim, dim) + self.v = nn.Linear(dim, dim) + self.o = nn.Linear(dim, dim) + self.norm_q = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + self.norm_k = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + + def forward( + self, + x, + seq_lens, + grid_sizes, + freqs, + block_mask, + kv_cache=None, + current_start=0, + cache_start=None + ): + r""" + Args: + x(Tensor): Shape [B, L, num_heads, C / num_heads] + seq_lens(Tensor): Shape [B] + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + block_mask (BlockMask) + """ + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + if cache_start is None: + cache_start = current_start + + # query, key, value function + def qkv_fn(x): + q = self.norm_q(self.q(x)).view(b, s, n, d) + k = self.norm_k(self.k(x)).view(b, s, n, d) + v = self.v(x).view(b, s, n, d) + return q, k, v + + q, k, v = qkv_fn(x) + + if kv_cache is None: + # if it is teacher forcing training? + is_tf = (s == seq_lens[0].item() * 2) + if is_tf: + q_chunk = torch.chunk(q, 2, dim=1) + k_chunk = torch.chunk(k, 2, dim=1) + roped_query = [] + roped_key = [] + # rope should be same for clean and noisy parts + for ii in range(2): + rq = rope_apply(q_chunk[ii], grid_sizes, freqs).type_as(v) + rk = rope_apply(k_chunk[ii], grid_sizes, freqs).type_as(v) + roped_query.append(rq) + roped_key.append(rk) + + roped_query = torch.cat(roped_query, dim=1) + roped_key = torch.cat(roped_key, dim=1) + + padded_length = math.ceil(q.shape[1] / 128) * 128 - q.shape[1] + padded_roped_query = torch.cat( + [roped_query, + torch.zeros([q.shape[0], padded_length, q.shape[2], q.shape[3]], + device=q.device, dtype=v.dtype)], + dim=1 + ) + + padded_roped_key = torch.cat( + [roped_key, torch.zeros([k.shape[0], padded_length, k.shape[2], k.shape[3]], + device=k.device, dtype=v.dtype)], + dim=1 + ) + + padded_v = torch.cat( + [v, torch.zeros([v.shape[0], padded_length, v.shape[2], v.shape[3]], + device=v.device, dtype=v.dtype)], + dim=1 + ) + + x = flex_attention( + query=padded_roped_query.transpose(2, 1), + key=padded_roped_key.transpose(2, 1), + value=padded_v.transpose(2, 1), + block_mask=block_mask + )[:, :, :-padded_length].transpose(2, 1) + + else: + roped_query = rope_apply(q, grid_sizes, freqs).type_as(v) + roped_key = rope_apply(k, grid_sizes, freqs).type_as(v) + + padded_length = math.ceil(q.shape[1] / 128) * 128 - q.shape[1] + padded_roped_query = torch.cat( + [roped_query, + torch.zeros([q.shape[0], padded_length, q.shape[2], q.shape[3]], + device=q.device, dtype=v.dtype)], + dim=1 + ) + + padded_roped_key = torch.cat( + [roped_key, torch.zeros([k.shape[0], padded_length, k.shape[2], k.shape[3]], + device=k.device, dtype=v.dtype)], + dim=1 + ) + + padded_v = torch.cat( + [v, torch.zeros([v.shape[0], padded_length, v.shape[2], v.shape[3]], + device=v.device, dtype=v.dtype)], + dim=1 + ) + + x = flex_attention( + query=padded_roped_query.transpose(2, 1), + key=padded_roped_key.transpose(2, 1), + value=padded_v.transpose(2, 1), + block_mask=block_mask + )[:, :, :-padded_length].transpose(2, 1) + else: + frame_seqlen = math.prod(grid_sizes[0][1:]).item() + current_start_frame = current_start // frame_seqlen + roped_query = causal_rope_apply( + q, grid_sizes, freqs, start_frame=current_start_frame).type_as(v) + roped_key = causal_rope_apply( + k, grid_sizes, freqs, start_frame=current_start_frame).type_as(v) + + current_end = current_start + roped_query.shape[1] + sink_tokens = self.sink_size * frame_seqlen + # If we are using local attention and the current KV cache size is larger than the local attention size, we need to truncate the KV cache + kv_cache_size = kv_cache["k"].shape[1] + num_new_tokens = roped_query.shape[1] + if self.local_attn_size != -1 and (current_end > kv_cache["global_end_index"].item()) and ( + num_new_tokens + kv_cache["local_end_index"].item() > kv_cache_size): + # Calculate the number of new tokens added in this step + # Shift existing cache content left to discard oldest tokens + # Clone the source slice to avoid overlapping memory error + num_evicted_tokens = num_new_tokens + kv_cache["local_end_index"].item() - kv_cache_size + num_rolled_tokens = kv_cache["local_end_index"].item() - num_evicted_tokens - sink_tokens + kv_cache["k"][:, sink_tokens:sink_tokens + num_rolled_tokens] = \ + kv_cache["k"][:, sink_tokens + num_evicted_tokens:sink_tokens + num_evicted_tokens + num_rolled_tokens].clone() + kv_cache["v"][:, sink_tokens:sink_tokens + num_rolled_tokens] = \ + kv_cache["v"][:, sink_tokens + num_evicted_tokens:sink_tokens + num_evicted_tokens + num_rolled_tokens].clone() + # Insert the new keys/values at the end + local_end_index = kv_cache["local_end_index"].item() + current_end - \ + kv_cache["global_end_index"].item() - num_evicted_tokens + local_start_index = local_end_index - num_new_tokens + kv_cache["k"][:, local_start_index:local_end_index] = roped_key + kv_cache["v"][:, local_start_index:local_end_index] = v + else: + # Assign new keys/values directly up to current_end + local_end_index = kv_cache["local_end_index"].item() + current_end - kv_cache["global_end_index"].item() + local_start_index = local_end_index - num_new_tokens + kv_cache["k"][:, local_start_index:local_end_index] = roped_key + kv_cache["v"][:, local_start_index:local_end_index] = v + # Clone the K/V slices we hand to FlashAttention. FA's autograd + # Function saves these tensors verbatim (no internal copy when + # the input stride(-1) == 1, which is our case), so without the + # clone the saved K/V are VIEWS into the persistent KV-cache + # storage. Self-Forcing performs multiple forwards per block -- + # 4 denoising passes (one of them gradient-enabled) plus a + # ``no_grad`` clean re-run -- each of which writes back to the + # same cache slot via ``kv_cache["k"][a:b] = roped_key``. Every + # write bumps the storage version counter. When backward + # eventually runs on the gradient-enabled forward's saved K, the + # storage version has advanced past the saved version and + # autograd raises ``RuntimeError: one of the variables needed for + # gradient computation has been modified by an inplace operation: + # [...] is at version N; expected version N-1 instead``. + # + # ``.clone()`` produces an independent storage whose version is + # immune to subsequent cache mutations. Cost: ~24 MB / block / + # call at the Wan-1.3B-ish shape (B=1, slice=7800, H=12, D=128, + # bf16); only the gradient-enabled forward actually pays for it + # since ``.clone()`` is free in ``torch.no_grad()`` (PyTorch + # still allocates but doesn't track for backward). + # + # NOTE: applies to BOTH the all-softmax baseline (which also + # contains this latent bug; just rarely triggered because the + # baseline uses full-visibility caches and tends not to mix with + # alpha-mode gradient flow) and the mixed-attention model. The + # clone keeps the autograd graph intact: ``roped_key → IndexPut → + # cache_slice_view → clone → attention``. Differentiating + # backward through ``clone`` is identity; ``IndexPut.backward`` + # only needs the upstream gradient, not the cache state. + x = attention( + roped_query, + kv_cache["k"][:, max(0, local_end_index - self.max_attention_size):local_end_index].clone(), + kv_cache["v"][:, max(0, local_end_index - self.max_attention_size):local_end_index].clone(), + ) + kv_cache["global_end_index"].fill_(current_end) + kv_cache["local_end_index"].fill_(local_end_index) + + # output + x = x.flatten(2) + x = self.o(x) + return x + + +class CausalWanAttentionBlock(nn.Module): + + def __init__(self, + cross_attn_type, + dim, + ffn_dim, + num_heads, + local_attn_size=-1, + sink_size=0, + qk_norm=True, + cross_attn_norm=False, + eps=1e-6): + super().__init__() + self.dim = dim + self.ffn_dim = ffn_dim + self.num_heads = num_heads + self.local_attn_size = local_attn_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # layers + self.norm1 = WanLayerNorm(dim, eps) + self.self_attn = CausalWanSelfAttention(dim, num_heads, local_attn_size, sink_size, qk_norm, eps) + self.norm3 = WanLayerNorm( + dim, eps, + elementwise_affine=True) if cross_attn_norm else nn.Identity() + self.cross_attn = WAN_CROSSATTENTION_CLASSES[cross_attn_type](dim, + num_heads, + (-1, -1), + qk_norm, + eps) + self.norm2 = WanLayerNorm(dim, eps) + self.ffn = nn.Sequential( + nn.Linear(dim, ffn_dim), nn.GELU(approximate='tanh'), + nn.Linear(ffn_dim, dim)) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5) + + def forward( + self, + x, + e, + seq_lens, + grid_sizes, + freqs, + context, + context_lens, + block_mask, + kv_cache=None, + crossattn_cache=None, + current_start=0, + cache_start=None + ): + r""" + Args: + x(Tensor): Shape [B, L, C] + e(Tensor): Shape [B, F, 6, C] + seq_lens(Tensor): Shape [B], length of each sequence in batch + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + num_frames, frame_seqlen = e.shape[1], x.shape[1] // e.shape[1] + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + e = (self.modulation.unsqueeze(1) + e).chunk(6, dim=2) + # assert e[0].dtype == torch.float32 + + # self-attention + y = self.self_attn( + (self.norm1(x).unflatten(dim=1, sizes=(num_frames, frame_seqlen)) * (1 + e[1]) + e[0]).flatten(1, 2), + seq_lens, grid_sizes, + freqs, block_mask, kv_cache, current_start, cache_start) + + # with amp.autocast(dtype=torch.float32): + x = x + (y.unflatten(dim=1, sizes=(num_frames, frame_seqlen)) * e[2]).flatten(1, 2) + + # cross-attention & ffn function + def cross_attn_ffn(x, context, context_lens, e, crossattn_cache=None): + x = x + self.cross_attn(self.norm3(x), context, + context_lens, crossattn_cache=crossattn_cache) + y = self.ffn( + (self.norm2(x).unflatten(dim=1, sizes=(num_frames, + frame_seqlen)) * (1 + e[4]) + e[3]).flatten(1, 2) + ) + # with amp.autocast(dtype=torch.float32): + x = x + (y.unflatten(dim=1, sizes=(num_frames, + frame_seqlen)) * e[5]).flatten(1, 2) + return x + + x = cross_attn_ffn(x, context, context_lens, e, crossattn_cache) + return x + + +class CausalHead(nn.Module): + + def __init__(self, dim, out_dim, patch_size, eps=1e-6): + super().__init__() + self.dim = dim + self.out_dim = out_dim + self.patch_size = patch_size + self.eps = eps + + # layers + out_dim = math.prod(patch_size) * out_dim + self.norm = WanLayerNorm(dim, eps) + self.head = nn.Linear(dim, out_dim) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5) + + def forward(self, x, e): + r""" + Args: + x(Tensor): Shape [B, L1, C] + e(Tensor): Shape [B, F, 1, C] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + num_frames, frame_seqlen = e.shape[1], x.shape[1] // e.shape[1] + e = (self.modulation.unsqueeze(1) + e).chunk(2, dim=2) + x = (self.head(self.norm(x).unflatten(dim=1, sizes=(num_frames, frame_seqlen)) * (1 + e[1]) + e[0])) + return x + + +class CausalWanModel(ModelMixin, ConfigMixin): + r""" + Wan diffusion backbone supporting both text-to-video and image-to-video. + """ + + ignore_for_config = [ + 'patch_size', 'cross_attn_norm', 'qk_norm', 'text_dim' + ] + _no_split_modules = ['WanAttentionBlock'] + _supports_gradient_checkpointing = True + + @register_to_config + def __init__(self, + model_type='t2v', + patch_size=(1, 2, 2), + text_len=512, + in_dim=16, + dim=2048, + ffn_dim=8192, + freq_dim=256, + text_dim=4096, + out_dim=16, + num_heads=16, + num_layers=32, + local_attn_size=-1, + sink_size=0, + qk_norm=True, + cross_attn_norm=True, + eps=1e-6): + r""" + Initialize the diffusion model backbone. + + Args: + model_type (`str`, *optional*, defaults to 't2v'): + Model variant - 't2v' (text-to-video) or 'i2v' (image-to-video) + patch_size (`tuple`, *optional*, defaults to (1, 2, 2)): + 3D patch dimensions for video embedding (t_patch, h_patch, w_patch) + text_len (`int`, *optional*, defaults to 512): + Fixed length for text embeddings + in_dim (`int`, *optional*, defaults to 16): + Input video channels (C_in) + dim (`int`, *optional*, defaults to 2048): + Hidden dimension of the transformer + ffn_dim (`int`, *optional*, defaults to 8192): + Intermediate dimension in feed-forward network + freq_dim (`int`, *optional*, defaults to 256): + Dimension for sinusoidal time embeddings + text_dim (`int`, *optional*, defaults to 4096): + Input dimension for text embeddings + out_dim (`int`, *optional*, defaults to 16): + Output video channels (C_out) + num_heads (`int`, *optional*, defaults to 16): + Number of attention heads + num_layers (`int`, *optional*, defaults to 32): + Number of transformer blocks + local_attn_size (`int`, *optional*, defaults to -1): + Window size for temporal local attention (-1 indicates global attention) + sink_size (`int`, *optional*, defaults to 0): + Size of the attention sink, we keep the first `sink_size` frames unchanged when rolling the KV cache + qk_norm (`bool`, *optional*, defaults to True): + Enable query/key normalization + cross_attn_norm (`bool`, *optional*, defaults to False): + Enable cross-attention normalization + eps (`float`, *optional*, defaults to 1e-6): + Epsilon value for normalization layers + """ + + super().__init__() + + assert model_type in ['t2v', 'i2v'] + self.model_type = model_type + + self.patch_size = patch_size + self.text_len = text_len + self.in_dim = in_dim + self.dim = dim + self.ffn_dim = ffn_dim + self.freq_dim = freq_dim + self.text_dim = text_dim + self.out_dim = out_dim + self.num_heads = num_heads + self.num_layers = num_layers + self.local_attn_size = local_attn_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # embeddings + self.patch_embedding = nn.Conv3d( + in_dim, dim, kernel_size=patch_size, stride=patch_size) + self.text_embedding = nn.Sequential( + nn.Linear(text_dim, dim), nn.GELU(approximate='tanh'), + nn.Linear(dim, dim)) + + self.time_embedding = nn.Sequential( + nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim)) + self.time_projection = nn.Sequential( + nn.SiLU(), nn.Linear(dim, dim * 6)) + + # blocks + cross_attn_type = 't2v_cross_attn' if model_type == 't2v' else 'i2v_cross_attn' + self.blocks = nn.ModuleList([ + CausalWanAttentionBlock(cross_attn_type, dim, ffn_dim, num_heads, + local_attn_size, sink_size, qk_norm, cross_attn_norm, eps) + for _ in range(num_layers) + ]) + + # head + self.head = CausalHead(dim, out_dim, patch_size, eps) + + # buffers (don't use register_buffer otherwise dtype will be changed in to()) + assert (dim % num_heads) == 0 and (dim // num_heads) % 2 == 0 + d = dim // num_heads + self.freqs = torch.cat([ + rope_params(1024, d - 4 * (d // 6)), + rope_params(1024, 2 * (d // 6)), + rope_params(1024, 2 * (d // 6)) + ], + dim=1) + + if model_type == 'i2v': + self.img_emb = MLPProj(1280, dim) + + # initialize weights + self.init_weights() + + self.gradient_checkpointing = False + + self.block_mask = None + + self.num_frame_per_block = 1 + self.independent_first_frame = False + + def _set_gradient_checkpointing( + self, + module=None, + value=False, + enable=None, + gradient_checkpointing_func=None, + ): + self.gradient_checkpointing = value if enable is None else enable + + @staticmethod + def _prepare_blockwise_causal_attn_mask( + device: torch.device | str, num_frames: int = 21, + frame_seqlen: int = 1560, num_frame_per_block=1, local_attn_size=-1 + ) -> BlockMask: + """ + we will divide the token sequence into the following format + [1 latent frame] [1 latent frame] ... [1 latent frame] + We use flexattention to construct the attention mask + """ + total_length = num_frames * frame_seqlen + + # we do right padding to get to a multiple of 128 + padded_length = math.ceil(total_length / 128) * 128 - total_length + + ends = torch.zeros(total_length + padded_length, + device=device, dtype=torch.long) + + # Block-wise causal mask will attend to all elements that are before the end of the current chunk + frame_indices = torch.arange( + start=0, + end=total_length, + step=frame_seqlen * num_frame_per_block, + device=device + ) + + for tmp in frame_indices: + ends[tmp:tmp + frame_seqlen * num_frame_per_block] = tmp + \ + frame_seqlen * num_frame_per_block + + def attention_mask(b, h, q_idx, kv_idx): + if local_attn_size == -1: + return (kv_idx < ends[q_idx]) | (q_idx == kv_idx) + else: + return ((kv_idx < ends[q_idx]) & (kv_idx >= (ends[q_idx] - local_attn_size * frame_seqlen))) | (q_idx == kv_idx) + # return ((kv_idx < total_length) & (q_idx < total_length)) | (q_idx == kv_idx) # bidirectional mask + + block_mask = create_block_mask(attention_mask, B=None, H=None, Q_LEN=total_length + padded_length, + KV_LEN=total_length + padded_length, _compile=False, device=device) + + import torch.distributed as dist + if not dist.is_initialized() or dist.get_rank() == 0: + print( + f" cache a block wise causal mask with block size of {num_frame_per_block} frames") + print(block_mask) + + # import imageio + # import numpy as np + # from torch.nn.attention.flex_attention import create_mask + + # mask = create_mask(attention_mask, B=None, H=None, Q_LEN=total_length + + # padded_length, KV_LEN=total_length + padded_length, device=device) + # import cv2 + # mask = cv2.resize(mask[0, 0].cpu().float().numpy(), (1024, 1024)) + # imageio.imwrite("mask_%d.jpg" % (0), np.uint8(255. * mask)) + + return block_mask + + @staticmethod + def _prepare_teacher_forcing_mask( + device: torch.device | str, num_frames: int = 21, + frame_seqlen: int = 1560, num_frame_per_block=1 + ) -> BlockMask: + """ + we will divide the token sequence into the following format + [1 latent frame] [1 latent frame] ... [1 latent frame] + We use flexattention to construct the attention mask + """ + # debug + DEBUG = False + if DEBUG: + num_frames = 9 + frame_seqlen = 256 + + total_length = num_frames * frame_seqlen * 2 + + # we do right padding to get to a multiple of 128 + padded_length = math.ceil(total_length / 128) * 128 - total_length + + clean_ends = num_frames * frame_seqlen + # for clean context frames, we can construct their flex attention mask based on a [start, end] interval + context_ends = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + # for noisy frames, we need two intervals to construct the flex attention mask [context_start, context_end] [noisy_start, noisy_end] + noise_context_starts = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + noise_context_ends = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + noise_noise_starts = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + noise_noise_ends = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + + # Block-wise causal mask will attend to all elements that are before the end of the current chunk + attention_block_size = frame_seqlen * num_frame_per_block + frame_indices = torch.arange( + start=0, + end=num_frames * frame_seqlen, + step=attention_block_size, + device=device, dtype=torch.long + ) + + # attention for clean context frames + for start in frame_indices: + context_ends[start:start + attention_block_size] = start + attention_block_size + + noisy_image_start_list = torch.arange( + num_frames * frame_seqlen, total_length, + step=attention_block_size, + device=device, dtype=torch.long + ) + noisy_image_end_list = noisy_image_start_list + attention_block_size + + # attention for noisy frames + for block_index, (start, end) in enumerate(zip(noisy_image_start_list, noisy_image_end_list)): + # attend to noisy tokens within the same block + noise_noise_starts[start:end] = start + noise_noise_ends[start:end] = end + # attend to context tokens in previous blocks + # noise_context_starts[start:end] = 0 + noise_context_ends[start:end] = block_index * attention_block_size + + def attention_mask(b, h, q_idx, kv_idx): + # first design the mask for clean frames + clean_mask = (q_idx < clean_ends) & (kv_idx < context_ends[q_idx]) + # then design the mask for noisy frames + # noisy frames will attend to all clean preceeding clean frames + itself + C1 = (kv_idx < noise_noise_ends[q_idx]) & (kv_idx >= noise_noise_starts[q_idx]) + C2 = (kv_idx < noise_context_ends[q_idx]) & (kv_idx >= noise_context_starts[q_idx]) + noise_mask = (q_idx >= clean_ends) & (C1 | C2) + + eye_mask = q_idx == kv_idx + return eye_mask | clean_mask | noise_mask + + block_mask = create_block_mask(attention_mask, B=None, H=None, Q_LEN=total_length + padded_length, + KV_LEN=total_length + padded_length, _compile=False, device=device) + + if DEBUG: + print(block_mask) + import imageio + import numpy as np + from torch.nn.attention.flex_attention import create_mask + + mask = create_mask(attention_mask, B=None, H=None, Q_LEN=total_length + + padded_length, KV_LEN=total_length + padded_length, device=device) + import cv2 + mask = cv2.resize(mask[0, 0].cpu().float().numpy(), (1024, 1024)) + imageio.imwrite("mask_%d.jpg" % (0), np.uint8(255. * mask)) + + return block_mask + + @staticmethod + def _prepare_blockwise_causal_attn_mask_i2v( + device: torch.device | str, num_frames: int = 21, + frame_seqlen: int = 1560, num_frame_per_block=4, local_attn_size=-1 + ) -> BlockMask: + """ + we will divide the token sequence into the following format + [1 latent frame] [N latent frame] ... [N latent frame] + The first frame is separated out to support I2V generation + We use flexattention to construct the attention mask + """ + total_length = num_frames * frame_seqlen + + # we do right padding to get to a multiple of 128 + padded_length = math.ceil(total_length / 128) * 128 - total_length + + ends = torch.zeros(total_length + padded_length, + device=device, dtype=torch.long) + + # special handling for the first frame + ends[:frame_seqlen] = frame_seqlen + + # Block-wise causal mask will attend to all elements that are before the end of the current chunk + frame_indices = torch.arange( + start=frame_seqlen, + end=total_length, + step=frame_seqlen * num_frame_per_block, + device=device + ) + + for idx, tmp in enumerate(frame_indices): + ends[tmp:tmp + frame_seqlen * num_frame_per_block] = tmp + \ + frame_seqlen * num_frame_per_block + + def attention_mask(b, h, q_idx, kv_idx): + if local_attn_size == -1: + return (kv_idx < ends[q_idx]) | (q_idx == kv_idx) + else: + return ((kv_idx < ends[q_idx]) & (kv_idx >= (ends[q_idx] - local_attn_size * frame_seqlen))) | \ + (q_idx == kv_idx) + + block_mask = create_block_mask(attention_mask, B=None, H=None, Q_LEN=total_length + padded_length, + KV_LEN=total_length + padded_length, _compile=False, device=device) + + if not dist.is_initialized() or dist.get_rank() == 0: + print( + f" cache a block wise causal mask with block size of {num_frame_per_block} frames") + print(block_mask) + + # import imageio + # import numpy as np + # from torch.nn.attention.flex_attention import create_mask + + # mask = create_mask(attention_mask, B=None, H=None, Q_LEN=total_length + + # padded_length, KV_LEN=total_length + padded_length, device=device) + # import cv2 + # mask = cv2.resize(mask[0, 0].cpu().float().numpy(), (1024, 1024)) + # imageio.imwrite("mask_%d.jpg" % (0), np.uint8(255. * mask)) + + return block_mask + + def _forward_inference( + self, + x, + t, + context, + seq_len, + clip_fea=None, + y=None, + kv_cache: dict = None, + crossattn_cache: dict = None, + current_start: int = 0, + cache_start: int = 0 + ): + r""" + Run the diffusion model with kv caching. + See Algorithm 2 of CausVid paper https://arxiv.org/abs/2412.07772 for details. + This function will be run for num_frame times. + Process the latent frames one by one (1560 tokens each) + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of denoised video tensors with original input shapes [C_out, F, H / 8, W / 8] + """ + + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + x = torch.cat(x) + """ + torch.cat([ + torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], + dim=1) for u in x + ]) + """ + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t.flatten()).type_as(x)) + e0 = self.time_projection(e).unflatten( + 1, (6, self.dim)).unflatten(dim=0, sizes=t.shape) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens, + block_mask=self.block_mask + ) + + def create_custom_forward(module): + def custom_forward(*inputs, **kwargs): + return module(*inputs, **kwargs) + return custom_forward + + for block_index, block in enumerate(self.blocks): + if torch.is_grad_enabled() and self.gradient_checkpointing: + kwargs.update( + { + "kv_cache": kv_cache[block_index], + "current_start": current_start, + "cache_start": cache_start + } + ) + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + kwargs.update( + { + "kv_cache": kv_cache[block_index], + "crossattn_cache": crossattn_cache[block_index], + "current_start": current_start, + "cache_start": cache_start + } + ) + x = block(x, **kwargs) + + # head + x = self.head(x, e.unflatten(dim=0, sizes=t.shape).unsqueeze(2)) + # unpatchify + x = self.unpatchify(x, grid_sizes) + return torch.stack(x) + + def _forward_train( + self, + x, + t, + context, + seq_len, + clean_x=None, + aug_t=None, + clip_fea=None, + y=None, + ): + r""" + Forward pass through the diffusion model + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of denoised video tensors with original input shapes [C_out, F, H / 8, W / 8] + """ + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + # Construct blockwise causal attn mask + if self.block_mask is None: + if clean_x is not None: + if self.independent_first_frame: + raise NotImplementedError() + else: + self.block_mask = self._prepare_teacher_forcing_mask( + device, num_frames=x.shape[2], + frame_seqlen=x.shape[-2] * x.shape[-1] // (self.patch_size[1] * self.patch_size[2]), + num_frame_per_block=self.num_frame_per_block + ) + else: + if self.independent_first_frame: + self.block_mask = self._prepare_blockwise_causal_attn_mask_i2v( + device, num_frames=x.shape[2], + frame_seqlen=x.shape[-2] * x.shape[-1] // (self.patch_size[1] * self.patch_size[2]), + num_frame_per_block=self.num_frame_per_block, + local_attn_size=self.local_attn_size + ) + else: + self.block_mask = self._prepare_blockwise_causal_attn_mask( + device, num_frames=x.shape[2], + frame_seqlen=x.shape[-2] * x.shape[-1] // (self.patch_size[1] * self.patch_size[2]), + num_frame_per_block=self.num_frame_per_block, + local_attn_size=self.local_attn_size + ) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_lens[0] - u.size(1), u.size(2))], + dim=1) for u in x + ]) + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t.flatten()).type_as(x)) + e0 = self.time_projection(e).unflatten( + 1, (6, self.dim)).unflatten(dim=0, sizes=t.shape) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + if clean_x is not None: + clean_x = [self.patch_embedding(u.unsqueeze(0)) for u in clean_x] + clean_x = [u.flatten(2).transpose(1, 2) for u in clean_x] + + seq_lens_clean = torch.tensor([u.size(1) for u in clean_x], dtype=torch.long) + assert seq_lens_clean.max() <= seq_len + clean_x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_lens_clean[0] - u.size(1), u.size(2))], dim=1) for u in clean_x + ]) + + x = torch.cat([clean_x, x], dim=1) + if aug_t is None: + aug_t = torch.zeros_like(t) + e_clean = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, aug_t.flatten()).type_as(x)) + e0_clean = self.time_projection(e_clean).unflatten( + 1, (6, self.dim)).unflatten(dim=0, sizes=t.shape) + e0 = torch.cat([e0_clean, e0], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens, + block_mask=self.block_mask) + + def create_custom_forward(module): + def custom_forward(*inputs, **kwargs): + return module(*inputs, **kwargs) + return custom_forward + + for block in self.blocks: + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + + if clean_x is not None: + x = x[:, x.shape[1] // 2:] + + # head + x = self.head(x, e.unflatten(dim=0, sizes=t.shape).unsqueeze(2)) + + # unpatchify + x = self.unpatchify(x, grid_sizes) + return torch.stack(x) + + def forward( + self, + *args, + **kwargs + ): + if kwargs.get('kv_cache', None) is not None: + return self._forward_inference(*args, **kwargs) + else: + return self._forward_train(*args, **kwargs) + + def unpatchify(self, x, grid_sizes): + r""" + Reconstruct video tensors from patch embeddings. + + Args: + x (List[Tensor]): + List of patchified features, each with shape [L, C_out * prod(patch_size)] + grid_sizes (Tensor): + Original spatial-temporal grid dimensions before patching, + shape [B, 3] (3 dimensions correspond to F_patches, H_patches, W_patches) + + Returns: + List[Tensor]: + Reconstructed video tensors with shape [C_out, F, H / 8, W / 8] + """ + + c = self.out_dim + out = [] + for u, v in zip(x, grid_sizes.tolist()): + u = u[:math.prod(v)].view(*v, *self.patch_size, c) + u = torch.einsum('fhwpqrc->cfphqwr', u) + u = u.reshape(c, *[i * j for i, j in zip(v, self.patch_size)]) + out.append(u) + return out + + def init_weights(self): + r""" + Initialize model parameters using Xavier initialization. + """ + + # basic init + for m in self.modules(): + if isinstance(m, nn.Linear): + nn.init.xavier_uniform_(m.weight) + if m.bias is not None: + nn.init.zeros_(m.bias) + + # init embeddings + nn.init.xavier_uniform_(self.patch_embedding.weight.flatten(1)) + for m in self.text_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + for m in self.time_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + + # init output layer + nn.init.zeros_(self.head.head.weight) diff --git a/1.3B/code/wan/modules/model.py b/1.3B/code/wan/modules/model.py new file mode 100644 index 0000000000000000000000000000000000000000..6528b4f6e979a9a4a904a9e94523ebc03e3b5a52 --- /dev/null +++ b/1.3B/code/wan/modules/model.py @@ -0,0 +1,1103 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import math + +import torch +import torch.nn as nn +import torch.utils.checkpoint +from diffusers.configuration_utils import ConfigMixin, register_to_config +from diffusers.models.modeling_utils import ModelMixin +from einops import repeat + +from .attention import flash_attention + +__all__ = ['WanModel'] + + +def sinusoidal_embedding_1d(dim, position): + # preprocess + assert dim % 2 == 0 + half = dim // 2 + position = position.type(torch.float64) + + # calculation + sinusoid = torch.outer( + position, torch.pow(10000, -torch.arange(half).to(position).div(half))) + x = torch.cat([torch.cos(sinusoid), torch.sin(sinusoid)], dim=1) + return x + + +# @amp.autocast(enabled=False) +def rope_params(max_seq_len, dim, theta=10000): + assert dim % 2 == 0 + freqs = torch.outer( + torch.arange(max_seq_len), + 1.0 / torch.pow(theta, + torch.arange(0, dim, 2).to(torch.float64).div(dim))) + freqs = torch.polar(torch.ones_like(freqs), freqs) + return freqs + + +# @amp.autocast(enabled=False) +def rope_apply(x, grid_sizes, freqs): + n, c = x.size(2), x.size(3) // 2 + + # split freqs + freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) + + # loop over samples + output = [] + for i, (f, h, w) in enumerate(grid_sizes.tolist()): + seq_len = f * h * w + + # Match the original Wan implementation exactly. RoPE is evaluated in + # fp64/complex128 and cast back only after the rotation; changing this + # globally also changes the causal student's pretrained forward. + x_i = torch.view_as_complex(x[i, :seq_len].to(torch.float64).reshape( + seq_len, n, -1, 2)) + freqs_i = torch.cat([ + freqs[0][:f].view(f, 1, 1, -1).expand(f, h, w, -1), + freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1), + freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1) + ], + dim=-1).reshape(seq_len, 1, -1) + + # apply rotary embedding + x_i = torch.view_as_real(x_i * freqs_i).flatten(2) + x_i = torch.cat([x_i, x[i, seq_len:]]) + + # append to collection + output.append(x_i) + return torch.stack(output).type_as(x) + + +class WanRMSNorm(nn.Module): + + def __init__(self, dim, eps=1e-5): + super().__init__() + self.dim = dim + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def forward(self, x): + r""" + Args: + x(Tensor): Shape [B, L, C] + """ + return self._norm(x.float()).type_as(x) * self.weight + + def _norm(self, x): + return x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps) + + def reset_parameters(self): + # Required by FSDP's meta-device materialisation path; matches __init__. + nn.init.ones_(self.weight) + + +class WanLayerNorm(nn.LayerNorm): + + def __init__(self, dim, eps=1e-6, elementwise_affine=False): + super().__init__(dim, elementwise_affine=elementwise_affine, eps=eps) + + def forward(self, x): + r""" + Args: + x(Tensor): Shape [B, L, C] + """ + return super().forward(x).type_as(x) + + +class WanSelfAttention(nn.Module): + + def __init__(self, + dim, + num_heads, + window_size=(-1, -1), + qk_norm=True, + eps=1e-6): + assert dim % num_heads == 0 + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.head_dim = dim // num_heads + self.window_size = window_size + self.qk_norm = qk_norm + self.eps = eps + + # layers + self.q = nn.Linear(dim, dim) + self.k = nn.Linear(dim, dim) + self.v = nn.Linear(dim, dim) + self.o = nn.Linear(dim, dim) + self.norm_q = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + self.norm_k = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + + def project_kv(self, x, grid_sizes, freqs, token_indices=None): + """Project the exact K/V used by attention, optionally retaining tokens. + + This is also used by the last encoder block's KV-only fast path, where Q, + attention output, cross-attention and FFN are unnecessary. + """ + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + k = self.norm_k(self.k(x)).view(b, s, n, d) + v = self.v(x).view(b, s, n, d) + k = rope_apply(k, grid_sizes, freqs) + if token_indices is not None: + indices = token_indices.to(k.device) + k = k.index_select(1, indices) + v = v.index_select(1, indices) + return k, v + + def forward( + self, + x, + seq_lens, + grid_sizes, + freqs, + return_kv=False, + kv_token_indices=None, + ): + r""" + Args: + x(Tensor): Shape [B, L, num_heads, C / num_heads] + seq_lens(Tensor): Shape [B] + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + + # query, key, value function + def qkv_fn(x): + q = self.norm_q(self.q(x)).view(b, s, n, d) + k = self.norm_k(self.k(x)).view(b, s, n, d) + v = self.v(x).view(b, s, n, d) + return q, k, v + + q, k, v = qkv_fn(x) + # These are the tensors that are actually consumed by attention. Keeping + # K after RoPE (rather than the pre-RoPE projection) makes the extracted + # feature identical to a real self-attention KV cache. + k_attn = rope_apply(k, grid_sizes, freqs) + + x = flash_attention( + q=rope_apply(q, grid_sizes, freqs), + k=k_attn, + v=v, + k_lens=seq_lens, + window_size=self.window_size) + + # output + x = x.flatten(2) + x = self.o(x) + if return_kv: + # Attention needs full K/V, but downstream MMD does not. Slice here, + # before returning to the block's cross-attention/FFN, so full K/V do + # not stay alive for the remainder of a checkpointed block. + if kv_token_indices is not None: + indices = kv_token_indices.to(k_attn.device) + k_attn = k_attn.index_select(1, indices) + v = v.index_select(1, indices) + return x, k_attn, v + return x + + +class WanT2VCrossAttention(WanSelfAttention): + + def forward(self, x, context, context_lens, crossattn_cache=None): + r""" + Args: + x(Tensor): Shape [B, L1, C] + context(Tensor): Shape [B, L2, C] + context_lens(Tensor): Shape [B] + crossattn_cache (List[dict], *optional*): Contains the cached key and value tensors for context embedding. + """ + b, n, d = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + q = self.norm_q(self.q(x)).view(b, -1, n, d) + + if crossattn_cache is not None: + if not crossattn_cache["is_init"]: + crossattn_cache["is_init"] = True + k = self.norm_k(self.k(context)).view(b, -1, n, d) + v = self.v(context).view(b, -1, n, d) + crossattn_cache["k"] = k + crossattn_cache["v"] = v + else: + k = crossattn_cache["k"] + v = crossattn_cache["v"] + else: + k = self.norm_k(self.k(context)).view(b, -1, n, d) + v = self.v(context).view(b, -1, n, d) + + # compute attention + x = flash_attention(q, k, v, k_lens=context_lens) + + # output + x = x.flatten(2) + x = self.o(x) + return x + + +class WanGanCrossAttention(WanSelfAttention): + + def forward(self, x, context, crossattn_cache=None): + r""" + Args: + x(Tensor): Shape [B, L1, C] + context(Tensor): Shape [B, L2, C] + context_lens(Tensor): Shape [B] + crossattn_cache (List[dict], *optional*): Contains the cached key and value tensors for context embedding. + """ + b, n, d = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + qq = self.norm_q(self.q(context)).view(b, 1, -1, d) + + kk = self.norm_k(self.k(x)).view(b, -1, n, d) + vv = self.v(x).view(b, -1, n, d) + + # compute attention + x = flash_attention(qq, kk, vv) + + # output + x = x.flatten(2) + x = self.o(x) + return x + + +class WanI2VCrossAttention(WanSelfAttention): + + def __init__(self, + dim, + num_heads, + window_size=(-1, -1), + qk_norm=True, + eps=1e-6): + super().__init__(dim, num_heads, window_size, qk_norm, eps) + + self.k_img = nn.Linear(dim, dim) + self.v_img = nn.Linear(dim, dim) + # self.alpha = nn.Parameter(torch.zeros((1, ))) + self.norm_k_img = WanRMSNorm( + dim, eps=eps) if qk_norm else nn.Identity() + + def forward(self, x, context, context_lens): + r""" + Args: + x(Tensor): Shape [B, L1, C] + context(Tensor): Shape [B, L2, C] + context_lens(Tensor): Shape [B] + """ + context_img = context[:, :257] + context = context[:, 257:] + b, n, d = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + q = self.norm_q(self.q(x)).view(b, -1, n, d) + k = self.norm_k(self.k(context)).view(b, -1, n, d) + v = self.v(context).view(b, -1, n, d) + k_img = self.norm_k_img(self.k_img(context_img)).view(b, -1, n, d) + v_img = self.v_img(context_img).view(b, -1, n, d) + img_x = flash_attention(q, k_img, v_img, k_lens=None) + # compute attention + x = flash_attention(q, k, v, k_lens=context_lens) + + # output + x = x.flatten(2) + img_x = img_x.flatten(2) + x = x + img_x + x = self.o(x) + return x + + +WAN_CROSSATTENTION_CLASSES = { + 't2v_cross_attn': WanT2VCrossAttention, + 'i2v_cross_attn': WanI2VCrossAttention, +} + + +class WanAttentionBlock(nn.Module): + + def __init__(self, + cross_attn_type, + dim, + ffn_dim, + num_heads, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=False, + eps=1e-6): + super().__init__() + self.dim = dim + self.ffn_dim = ffn_dim + self.num_heads = num_heads + self.window_size = window_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # layers + self.norm1 = WanLayerNorm(dim, eps) + self.self_attn = WanSelfAttention(dim, num_heads, window_size, qk_norm, + eps) + self.norm3 = WanLayerNorm( + dim, eps, + elementwise_affine=True) if cross_attn_norm else nn.Identity() + self.cross_attn = WAN_CROSSATTENTION_CLASSES[cross_attn_type](dim, + num_heads, + (-1, -1), + qk_norm, + eps) + self.norm2 = WanLayerNorm(dim, eps) + self.ffn = nn.Sequential( + nn.Linear(dim, ffn_dim), nn.GELU(approximate='tanh'), + nn.Linear(ffn_dim, dim)) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5) + + def forward( + self, + x, + e, + seq_lens, + grid_sizes, + freqs, + context, + context_lens, + return_kv=False, + kv_token_indices=None, + kv_only=False, + ): + r""" + Args: + x(Tensor): Shape [B, L, C] + e(Tensor): Shape [B, 6, C] + seq_lens(Tensor): Shape [B], length of each sequence in batch + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + e = (self.modulation + e).chunk(6, dim=1) + # assert e[0].dtype == torch.float32 + + modulated_x = self.norm1(x) * (1 + e[1]) + e[0] + if kv_only: + if not return_kv: + raise ValueError("kv_only requires return_kv=True") + return self.self_attn.project_kv( + modulated_x, grid_sizes, freqs, token_indices=kv_token_indices) + + # self-attention + attn_out = self.self_attn( + modulated_x, seq_lens, grid_sizes, freqs, + return_kv=return_kv, kv_token_indices=kv_token_indices) + if return_kv: + y, k, v = attn_out + else: + y = attn_out + # with amp.autocast(dtype=torch.float32): + x = x + y * e[2] + + # cross-attention & ffn function + def cross_attn_ffn(x, context, context_lens, e): + x = x + self.cross_attn(self.norm3(x), context, context_lens) + y = self.ffn(self.norm2(x) * (1 + e[4]) + e[3]) + # with amp.autocast(dtype=torch.float32): + x = x + y * e[5] + return x + + x = cross_attn_ffn(x, context, context_lens, e) + if return_kv: + return x, k, v + return x + + +class GanAttentionBlock(nn.Module): + + def __init__(self, + dim=1536, + ffn_dim=8192, + num_heads=12, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=True, + eps=1e-6): + super().__init__() + self.dim = dim + self.ffn_dim = ffn_dim + self.num_heads = num_heads + self.window_size = window_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # layers + # self.norm1 = WanLayerNorm(dim, eps) + # self.self_attn = WanSelfAttention(dim, num_heads, window_size, qk_norm, + # eps) + self.norm3 = WanLayerNorm( + dim, eps, + elementwise_affine=True) if cross_attn_norm else nn.Identity() + + self.norm2 = WanLayerNorm(dim, eps) + self.ffn = nn.Sequential( + nn.Linear(dim, ffn_dim), nn.GELU(approximate='tanh'), + nn.Linear(ffn_dim, dim)) + + self.cross_attn = WanGanCrossAttention(dim, num_heads, + (-1, -1), + qk_norm, + eps) + + # modulation + # self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5) + + def forward( + self, + x, + context, + # seq_lens, + # grid_sizes, + # freqs, + # context, + # context_lens, + ): + r""" + Args: + x(Tensor): Shape [B, L, C] + e(Tensor): Shape [B, 6, C] + seq_lens(Tensor): Shape [B], length of each sequence in batch + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + # e = (self.modulation + e).chunk(6, dim=1) + # assert e[0].dtype == torch.float32 + + # # self-attention + # y = self.self_attn( + # self.norm1(x) * (1 + e[1]) + e[0], seq_lens, grid_sizes, + # freqs) + # # with amp.autocast(dtype=torch.float32): + # x = x + y * e[2] + + # cross-attention & ffn function + def cross_attn_ffn(x, context): + token = context + self.cross_attn(self.norm3(x), context) + y = self.ffn(self.norm2(token)) + token # * (1 + e[4]) + e[3]) + # with amp.autocast(dtype=torch.float32): + # x = x + y * e[5] + return y + + x = cross_attn_ffn(x, context) + return x + + +class Head(nn.Module): + + def __init__(self, dim, out_dim, patch_size, eps=1e-6): + super().__init__() + self.dim = dim + self.out_dim = out_dim + self.patch_size = patch_size + self.eps = eps + + # layers + out_dim = math.prod(patch_size) * out_dim + self.norm = WanLayerNorm(dim, eps) + self.head = nn.Linear(dim, out_dim) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5) + + def forward(self, x, e): + r""" + Args: + x(Tensor): Shape [B, L1, C] + e(Tensor): Shape [B, C] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + e = (self.modulation + e.unsqueeze(1)).chunk(2, dim=1) + x = (self.head(self.norm(x) * (1 + e[1]) + e[0])) + return x + + +class MLPProj(torch.nn.Module): + + def __init__(self, in_dim, out_dim): + super().__init__() + + self.proj = torch.nn.Sequential( + torch.nn.LayerNorm(in_dim), torch.nn.Linear(in_dim, in_dim), + torch.nn.GELU(), torch.nn.Linear(in_dim, out_dim), + torch.nn.LayerNorm(out_dim)) + + def forward(self, image_embeds): + clip_extra_context_tokens = self.proj(image_embeds) + return clip_extra_context_tokens + + +class RegisterTokens(nn.Module): + def __init__(self, num_registers: int, dim: int): + super().__init__() + self.register_tokens = nn.Parameter(torch.randn(num_registers, dim) * 0.02) + self.rms_norm = WanRMSNorm(dim, eps=1e-6) + + def forward(self): + return self.rms_norm(self.register_tokens) + + def reset_parameters(self): + nn.init.normal_(self.register_tokens, std=0.02) + + +class WanModel(ModelMixin, ConfigMixin): + r""" + Wan diffusion backbone supporting both text-to-video and image-to-video. + """ + + ignore_for_config = [ + 'patch_size', 'cross_attn_norm', 'qk_norm', 'text_dim', 'window_size' + ] + _no_split_modules = ['WanAttentionBlock'] + _supports_gradient_checkpointing = True + + @register_to_config + def __init__(self, + model_type='t2v', + patch_size=(1, 2, 2), + text_len=512, + in_dim=16, + dim=2048, + ffn_dim=8192, + freq_dim=256, + text_dim=4096, + out_dim=16, + num_heads=16, + num_layers=32, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=True, + eps=1e-6): + r""" + Initialize the diffusion model backbone. + + Args: + model_type (`str`, *optional*, defaults to 't2v'): + Model variant - 't2v' (text-to-video) or 'i2v' (image-to-video) + patch_size (`tuple`, *optional*, defaults to (1, 2, 2)): + 3D patch dimensions for video embedding (t_patch, h_patch, w_patch) + text_len (`int`, *optional*, defaults to 512): + Fixed length for text embeddings + in_dim (`int`, *optional*, defaults to 16): + Input video channels (C_in) + dim (`int`, *optional*, defaults to 2048): + Hidden dimension of the transformer + ffn_dim (`int`, *optional*, defaults to 8192): + Intermediate dimension in feed-forward network + freq_dim (`int`, *optional*, defaults to 256): + Dimension for sinusoidal time embeddings + text_dim (`int`, *optional*, defaults to 4096): + Input dimension for text embeddings + out_dim (`int`, *optional*, defaults to 16): + Output video channels (C_out) + num_heads (`int`, *optional*, defaults to 16): + Number of attention heads + num_layers (`int`, *optional*, defaults to 32): + Number of transformer blocks + window_size (`tuple`, *optional*, defaults to (-1, -1)): + Window size for local attention (-1 indicates global attention) + qk_norm (`bool`, *optional*, defaults to True): + Enable query/key normalization + cross_attn_norm (`bool`, *optional*, defaults to False): + Enable cross-attention normalization + eps (`float`, *optional*, defaults to 1e-6): + Epsilon value for normalization layers + """ + + super().__init__() + + assert model_type in ['t2v', 'i2v'] + self.model_type = model_type + + self.patch_size = patch_size + self.text_len = text_len + self.in_dim = in_dim + self.dim = dim + self.ffn_dim = ffn_dim + self.freq_dim = freq_dim + self.text_dim = text_dim + self.out_dim = out_dim + self.num_heads = num_heads + self.num_layers = num_layers + self.window_size = window_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + self.local_attn_size = 21 + + # embeddings + self.patch_embedding = nn.Conv3d( + in_dim, dim, kernel_size=patch_size, stride=patch_size) + self.text_embedding = nn.Sequential( + nn.Linear(text_dim, dim), nn.GELU(approximate='tanh'), + nn.Linear(dim, dim)) + + self.time_embedding = nn.Sequential( + nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim)) + self.time_projection = nn.Sequential( + nn.SiLU(), nn.Linear(dim, dim * 6)) + + # blocks + cross_attn_type = 't2v_cross_attn' if model_type == 't2v' else 'i2v_cross_attn' + self.blocks = nn.ModuleList([ + WanAttentionBlock(cross_attn_type, dim, ffn_dim, num_heads, + window_size, qk_norm, cross_attn_norm, eps) + for _ in range(num_layers) + ]) + + # head + self.head = Head(dim, out_dim, patch_size, eps) + + # buffers (don't use register_buffer otherwise dtype will be changed in to()) + assert (dim % num_heads) == 0 and (dim // num_heads) % 2 == 0 + d = dim // num_heads + self.freqs = torch.cat([ + rope_params(1024, d - 4 * (d // 6)), + rope_params(1024, 2 * (d // 6)), + rope_params(1024, 2 * (d // 6)) + ], + dim=1) + + if model_type == 'i2v': + self.img_emb = MLPProj(1280, dim) + + # initialize weights + self.init_weights() + + self.gradient_checkpointing = False + + def _set_gradient_checkpointing( + self, + module=None, + value=False, + enable=None, + gradient_checkpointing_func=None, + ): + self.gradient_checkpointing = value if enable is None else enable + + def forward( + self, + *args, + **kwargs + ): + # if kwargs.get('classify_mode', False) is True: + # kwargs.pop('classify_mode') + # return self._forward_classify(*args, **kwargs) + # else: + return self._forward(*args, **kwargs) + + def _forward( + self, + x, + t, + context, + seq_len, + classify_mode=False, + concat_time_embeddings=False, + register_tokens=None, + cls_pred_branch=None, + gan_ca_blocks=None, + clip_fea=None, + y=None, + kv_feature_layers=None, + kv_token_indices=None, + latent_feature_layer=None, + latent_token_indices=None, + ): + r""" + Forward pass through the diffusion model + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of denoised video tensors with original input shapes [C_out, F, H / 8, W / 8] + """ + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + if kv_feature_layers is not None and latent_feature_layer is not None: + raise ValueError( + "KV and post-block latent feature extraction are mutually exclusive") + if kv_token_indices is not None and kv_feature_layers is None: + raise ValueError( + "kv_token_indices requires kv_feature_layers") + if latent_token_indices is not None and latent_feature_layer is None: + raise ValueError( + "latent_token_indices requires latent_feature_layer") + + def validate_token_indices(name, indices): + if indices is None: + return + if not isinstance(indices, torch.Tensor): + raise ValueError(f"{name} must be a torch.Tensor") + if indices.ndim != 1 or indices.dtype != torch.long: + raise ValueError(f"{name} must be a 1-D torch.long tensor") + if indices.numel() == 0: + raise ValueError(f"{name} cannot be empty") + min_index = int(indices.min().item()) + max_index = int(indices.max().item()) + if min_index < 0 or max_index >= int(seq_lens.min().item()): + raise ValueError( + f"{name} range [{min_index}, {max_index}] exceeds " + f"the shortest valid token sequence ({int(seq_lens.min().item())})") + + if kv_feature_layers is not None: + validate_token_indices("kv_token_indices", kv_token_indices) + if latent_feature_layer is not None: + if ( + isinstance(latent_feature_layer, bool) + or not isinstance(latent_feature_layer, int) + ): + raise ValueError("latent_feature_layer must be an integer block index") + if latent_feature_layer < 0 or latent_feature_layer >= len(self.blocks): + raise ValueError( + f"latent_feature_layer={latent_feature_layer} outside " + f"[0, {len(self.blocks) - 1}]") + validate_token_indices("latent_token_indices", latent_token_indices) + x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], + dim=1) for u in x + ]) + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t).type_as(x)) + e0 = self.time_projection(e).unflatten(1, (6, self.dim)) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens) + + def create_custom_forward(module): + def custom_forward(*inputs, **kwargs): + return module(*inputs, **kwargs) + return custom_forward + + # Frozen-Wan post-block latent encoder path. Unlike the final KV-only + # shortcut below, the requested block runs in full (self-attention, + # cross-attention, FFN and all residual updates). The diffusion head + # and all later blocks are intentionally skipped. + if latent_feature_layer is not None: + for ii, block in enumerate(self.blocks): + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + if ii == latent_feature_layer: + if x.ndim != 3 or x.shape[-1] != self.dim: + raise RuntimeError( + "post-block latent must have shape [batch, tokens, dim]") + if latent_token_indices is not None: + indices = latent_token_indices.to(device=x.device) + x = x.index_select(1, indices) + return x + raise RuntimeError( + "latent feature block was not reached despite validated index") + + # Frozen-Wan feature-encoder path. Only the prefix ending at the last + # requested block is evaluated. K/V are concatenated along the feature + # dimension and remain a [B, tokens, features] matrix; no global pooling + # or vector reshape is applied. + if kv_feature_layers is not None: + capture = sorted({int(i) for i in kv_feature_layers}) + if not capture: + raise ValueError("kv_feature_layers must contain at least one block index") + if capture[0] < 0 or capture[-1] >= len(self.blocks): + raise ValueError( + f"kv_feature_layers={capture} outside [0, {len(self.blocks) - 1}]") + captured = [] + capture_set = set(capture) + last_capture = capture[-1] + for ii, block in enumerate(self.blocks): + if ii > last_capture: + break + block_kwargs = dict(kwargs) + block_kwargs["return_kv"] = ii in capture_set + block_kwargs["kv_token_indices"] = ( + kv_token_indices if ii in capture_set else None) + # The last requested feature only needs this block's modulated + # K/V projections. No later block consumes its full output. + block_kwargs["kv_only"] = ii == last_capture + if torch.is_grad_enabled() and self.gradient_checkpointing: + out = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **block_kwargs, + use_reentrant=False, + ) + else: + out = block(x, **block_kwargs) + if ii == last_capture: + k, v = out + kv = torch.cat([k.flatten(2), v.flatten(2)], dim=-1) + captured.append(kv) + break + if ii in capture_set: + x, k, v = out + # [B,L,H,Dh] -> [B,L,2*D]. + kv = torch.cat([k.flatten(2), v.flatten(2)], dim=-1) + captured.append(kv) + else: + x = out + return torch.cat(captured, dim=-1) + + # TODO: Tune the number of blocks for feature extraction + final_x = None + if classify_mode: + assert register_tokens is not None + assert gan_ca_blocks is not None + assert cls_pred_branch is not None + + final_x = [] + registers = repeat(register_tokens(), "n d -> b n d", b=x.shape[0]) + # x = torch.cat([registers, x], dim=1) + + gan_idx = 0 + for ii, block in enumerate(self.blocks): + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + + if classify_mode and ii in [13, 21, 29]: + gan_token = registers[:, gan_idx: gan_idx + 1] + final_x.append(gan_ca_blocks[gan_idx](x, gan_token)) + gan_idx += 1 + + if classify_mode: + final_x = torch.cat(final_x, dim=1) + if concat_time_embeddings: + final_x = cls_pred_branch(torch.cat([final_x, 10 * e[:, None, :]], dim=1).view(final_x.shape[0], -1)) + else: + final_x = cls_pred_branch(final_x.view(final_x.shape[0], -1)) + + # head + x = self.head(x, e) + + # unpatchify + x = self.unpatchify(x, grid_sizes) + + if classify_mode: + return torch.stack(x), final_x + + return torch.stack(x) + + def _forward_classify( + self, + x, + t, + context, + seq_len, + register_tokens, + cls_pred_branch, + clip_fea=None, + y=None, + ): + r""" + Feature extraction through the diffusion model + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of video features with original input shapes [C_block, F, H / 8, W / 8] + """ + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], + dim=1) for u in x + ]) + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t).type_as(x)) + e0 = self.time_projection(e).unflatten(1, (6, self.dim)) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens) + + def create_custom_forward(module): + def custom_forward(*inputs, **kwargs): + return module(*inputs, **kwargs) + return custom_forward + + # TODO: Tune the number of blocks for feature extraction + for block in self.blocks[:16]: + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + + # unpatchify + x = self.unpatchify(x, grid_sizes, c=self.dim // 4) + return torch.stack(x) + + def unpatchify(self, x, grid_sizes, c=None): + r""" + Reconstruct video tensors from patch embeddings. + + Args: + x (List[Tensor]): + List of patchified features, each with shape [L, C_out * prod(patch_size)] + grid_sizes (Tensor): + Original spatial-temporal grid dimensions before patching, + shape [B, 3] (3 dimensions correspond to F_patches, H_patches, W_patches) + + Returns: + List[Tensor]: + Reconstructed video tensors with shape [C_out, F, H / 8, W / 8] + """ + + c = self.out_dim if c is None else c + out = [] + for u, v in zip(x, grid_sizes.tolist()): + u = u[:math.prod(v)].view(*v, *self.patch_size, c) + u = torch.einsum('fhwpqrc->cfphqwr', u) + u = u.reshape(c, *[i * j for i, j in zip(v, self.patch_size)]) + out.append(u) + return out + + def init_weights(self): + r""" + Initialize model parameters using Xavier initialization. + """ + + # basic init + for m in self.modules(): + if isinstance(m, nn.Linear): + nn.init.xavier_uniform_(m.weight) + if m.bias is not None: + nn.init.zeros_(m.bias) + + # init embeddings + nn.init.xavier_uniform_(self.patch_embedding.weight.flatten(1)) + for m in self.text_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + for m in self.time_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + + # init output layer + nn.init.zeros_(self.head.head.weight) diff --git a/1.3B/code/wan/modules/t5.py b/1.3B/code/wan/modules/t5.py new file mode 100644 index 0000000000000000000000000000000000000000..9438874681ea14ab1f4368869b474ce7c8dbcd80 --- /dev/null +++ b/1.3B/code/wan/modules/t5.py @@ -0,0 +1,515 @@ +# Modified from transformers.models.t5.modeling_t5 +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import logging +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .tokenizers import HuggingfaceTokenizer + +__all__ = [ + 'T5Model', + 'T5Encoder', + 'T5Decoder', + 'T5EncoderModel', +] + + +def fp16_clamp(x): + if x.dtype == torch.float16 and torch.isinf(x).any(): + clamp = torch.finfo(x.dtype).max - 1000 + x = torch.clamp(x, min=-clamp, max=clamp) + return x + + +def init_weights(m): + if isinstance(m, T5LayerNorm): + nn.init.ones_(m.weight) + elif isinstance(m, T5Model): + nn.init.normal_(m.token_embedding.weight, std=1.0) + elif isinstance(m, T5FeedForward): + nn.init.normal_(m.gate[0].weight, std=m.dim**-0.5) + nn.init.normal_(m.fc1.weight, std=m.dim**-0.5) + nn.init.normal_(m.fc2.weight, std=m.dim_ffn**-0.5) + elif isinstance(m, T5Attention): + nn.init.normal_(m.q.weight, std=(m.dim * m.dim_attn)**-0.5) + nn.init.normal_(m.k.weight, std=m.dim**-0.5) + nn.init.normal_(m.v.weight, std=m.dim**-0.5) + nn.init.normal_(m.o.weight, std=(m.num_heads * m.dim_attn)**-0.5) + elif isinstance(m, T5RelativeEmbedding): + nn.init.normal_( + m.embedding.weight, std=(2 * m.num_buckets * m.num_heads)**-0.5) + + +class GELU(nn.Module): + + def forward(self, x): + return 0.5 * x * (1.0 + torch.tanh( + math.sqrt(2.0 / math.pi) * (x + 0.044715 * torch.pow(x, 3.0)))) + + +class T5LayerNorm(nn.Module): + + def __init__(self, dim, eps=1e-6): + super(T5LayerNorm, self).__init__() + self.dim = dim + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def forward(self, x): + x = x * torch.rsqrt(x.float().pow(2).mean(dim=-1, keepdim=True) + + self.eps) + if self.weight.dtype in [torch.float16, torch.bfloat16]: + x = x.type_as(self.weight) + return self.weight * x + + +class T5Attention(nn.Module): + + def __init__(self, dim, dim_attn, num_heads, dropout=0.1): + assert dim_attn % num_heads == 0 + super(T5Attention, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.num_heads = num_heads + self.head_dim = dim_attn // num_heads + + # layers + self.q = nn.Linear(dim, dim_attn, bias=False) + self.k = nn.Linear(dim, dim_attn, bias=False) + self.v = nn.Linear(dim, dim_attn, bias=False) + self.o = nn.Linear(dim_attn, dim, bias=False) + self.dropout = nn.Dropout(dropout) + + def forward(self, x, context=None, mask=None, pos_bias=None): + """ + x: [B, L1, C]. + context: [B, L2, C] or None. + mask: [B, L2] or [B, L1, L2] or None. + """ + # check inputs + context = x if context is None else context + b, n, c = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + q = self.q(x).view(b, -1, n, c) + k = self.k(context).view(b, -1, n, c) + v = self.v(context).view(b, -1, n, c) + + # attention bias + attn_bias = x.new_zeros(b, n, q.size(1), k.size(1)) + if pos_bias is not None: + attn_bias += pos_bias + if mask is not None: + assert mask.ndim in [2, 3] + mask = mask.view(b, 1, 1, + -1) if mask.ndim == 2 else mask.unsqueeze(1) + attn_bias.masked_fill_(mask == 0, torch.finfo(x.dtype).min) + + # compute attention (T5 does not use scaling) + attn = torch.einsum('binc,bjnc->bnij', q, k) + attn_bias + attn = F.softmax(attn.float(), dim=-1).type_as(attn) + x = torch.einsum('bnij,bjnc->binc', attn, v) + + # output + x = x.reshape(b, -1, n * c) + x = self.o(x) + x = self.dropout(x) + return x + + +class T5FeedForward(nn.Module): + + def __init__(self, dim, dim_ffn, dropout=0.1): + super(T5FeedForward, self).__init__() + self.dim = dim + self.dim_ffn = dim_ffn + + # layers + self.gate = nn.Sequential(nn.Linear(dim, dim_ffn, bias=False), GELU()) + self.fc1 = nn.Linear(dim, dim_ffn, bias=False) + self.fc2 = nn.Linear(dim_ffn, dim, bias=False) + self.dropout = nn.Dropout(dropout) + + def forward(self, x): + x = self.fc1(x) * self.gate(x) + x = self.dropout(x) + x = self.fc2(x) + x = self.dropout(x) + return x + + +class T5SelfAttention(nn.Module): + + def __init__(self, + dim, + dim_attn, + dim_ffn, + num_heads, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5SelfAttention, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.norm1 = T5LayerNorm(dim) + self.attn = T5Attention(dim, dim_attn, num_heads, dropout) + self.norm2 = T5LayerNorm(dim) + self.ffn = T5FeedForward(dim, dim_ffn, dropout) + self.pos_embedding = None if shared_pos else T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=True) + + def forward(self, x, mask=None, pos_bias=None): + e = pos_bias if self.shared_pos else self.pos_embedding( + x.size(1), x.size(1)) + x = fp16_clamp(x + self.attn(self.norm1(x), mask=mask, pos_bias=e)) + x = fp16_clamp(x + self.ffn(self.norm2(x))) + return x + + +class T5CrossAttention(nn.Module): + + def __init__(self, + dim, + dim_attn, + dim_ffn, + num_heads, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5CrossAttention, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.norm1 = T5LayerNorm(dim) + self.self_attn = T5Attention(dim, dim_attn, num_heads, dropout) + self.norm2 = T5LayerNorm(dim) + self.cross_attn = T5Attention(dim, dim_attn, num_heads, dropout) + self.norm3 = T5LayerNorm(dim) + self.ffn = T5FeedForward(dim, dim_ffn, dropout) + self.pos_embedding = None if shared_pos else T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=False) + + def forward(self, + x, + mask=None, + encoder_states=None, + encoder_mask=None, + pos_bias=None): + e = pos_bias if self.shared_pos else self.pos_embedding( + x.size(1), x.size(1)) + x = fp16_clamp(x + self.self_attn(self.norm1(x), mask=mask, pos_bias=e)) + x = fp16_clamp(x + self.cross_attn( + self.norm2(x), context=encoder_states, mask=encoder_mask)) + x = fp16_clamp(x + self.ffn(self.norm3(x))) + return x + + +class T5RelativeEmbedding(nn.Module): + + def __init__(self, num_buckets, num_heads, bidirectional, max_dist=128): + super(T5RelativeEmbedding, self).__init__() + self.num_buckets = num_buckets + self.num_heads = num_heads + self.bidirectional = bidirectional + self.max_dist = max_dist + + # layers + self.embedding = nn.Embedding(num_buckets, num_heads) + + def forward(self, lq, lk): + device = self.embedding.weight.device + # rel_pos = torch.arange(lk).unsqueeze(0).to(device) - \ + # torch.arange(lq).unsqueeze(1).to(device) + rel_pos = torch.arange(lk, device=device).unsqueeze(0) - \ + torch.arange(lq, device=device).unsqueeze(1) + rel_pos = self._relative_position_bucket(rel_pos) + rel_pos_embeds = self.embedding(rel_pos) + rel_pos_embeds = rel_pos_embeds.permute(2, 0, 1).unsqueeze( + 0) # [1, N, Lq, Lk] + return rel_pos_embeds.contiguous() + + def _relative_position_bucket(self, rel_pos): + # preprocess + if self.bidirectional: + num_buckets = self.num_buckets // 2 + rel_buckets = (rel_pos > 0).long() * num_buckets + rel_pos = torch.abs(rel_pos) + else: + num_buckets = self.num_buckets + rel_buckets = 0 + rel_pos = -torch.min(rel_pos, torch.zeros_like(rel_pos)) + + # embeddings for small and large positions + max_exact = num_buckets // 2 + rel_pos_large = max_exact + (torch.log(rel_pos.float() / max_exact) / + math.log(self.max_dist / max_exact) * + (num_buckets - max_exact)).long() + rel_pos_large = torch.min( + rel_pos_large, torch.full_like(rel_pos_large, num_buckets - 1)) + rel_buckets += torch.where(rel_pos < max_exact, rel_pos, rel_pos_large) + return rel_buckets + + +class T5Encoder(nn.Module): + + def __init__(self, + vocab, + dim, + dim_attn, + dim_ffn, + num_heads, + num_layers, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5Encoder, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_layers = num_layers + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.token_embedding = vocab if isinstance(vocab, nn.Embedding) \ + else nn.Embedding(vocab, dim) + self.pos_embedding = T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=True) if shared_pos else None + self.dropout = nn.Dropout(dropout) + self.blocks = nn.ModuleList([ + T5SelfAttention(dim, dim_attn, dim_ffn, num_heads, num_buckets, + shared_pos, dropout) for _ in range(num_layers) + ]) + self.norm = T5LayerNorm(dim) + + # initialize weights + self.apply(init_weights) + + def forward(self, ids, mask=None): + x = self.token_embedding(ids) + x = self.dropout(x) + e = self.pos_embedding(x.size(1), + x.size(1)) if self.shared_pos else None + for block in self.blocks: + x = block(x, mask, pos_bias=e) + x = self.norm(x) + x = self.dropout(x) + return x + + +class T5Decoder(nn.Module): + + def __init__(self, + vocab, + dim, + dim_attn, + dim_ffn, + num_heads, + num_layers, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5Decoder, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_layers = num_layers + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.token_embedding = vocab if isinstance(vocab, nn.Embedding) \ + else nn.Embedding(vocab, dim) + self.pos_embedding = T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=False) if shared_pos else None + self.dropout = nn.Dropout(dropout) + self.blocks = nn.ModuleList([ + T5CrossAttention(dim, dim_attn, dim_ffn, num_heads, num_buckets, + shared_pos, dropout) for _ in range(num_layers) + ]) + self.norm = T5LayerNorm(dim) + + # initialize weights + self.apply(init_weights) + + def forward(self, ids, mask=None, encoder_states=None, encoder_mask=None): + b, s = ids.size() + + # causal mask + if mask is None: + mask = torch.tril(torch.ones(1, s, s).to(ids.device)) + elif mask.ndim == 2: + mask = torch.tril(mask.unsqueeze(1).expand(-1, s, -1)) + + # layers + x = self.token_embedding(ids) + x = self.dropout(x) + e = self.pos_embedding(x.size(1), + x.size(1)) if self.shared_pos else None + for block in self.blocks: + x = block(x, mask, encoder_states, encoder_mask, pos_bias=e) + x = self.norm(x) + x = self.dropout(x) + return x + + +class T5Model(nn.Module): + + def __init__(self, + vocab_size, + dim, + dim_attn, + dim_ffn, + num_heads, + encoder_layers, + decoder_layers, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5Model, self).__init__() + self.vocab_size = vocab_size + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.encoder_layers = encoder_layers + self.decoder_layers = decoder_layers + self.num_buckets = num_buckets + + # layers + self.token_embedding = nn.Embedding(vocab_size, dim) + self.encoder = T5Encoder(self.token_embedding, dim, dim_attn, dim_ffn, + num_heads, encoder_layers, num_buckets, + shared_pos, dropout) + self.decoder = T5Decoder(self.token_embedding, dim, dim_attn, dim_ffn, + num_heads, decoder_layers, num_buckets, + shared_pos, dropout) + self.head = nn.Linear(dim, vocab_size, bias=False) + + # initialize weights + self.apply(init_weights) + + def forward(self, encoder_ids, encoder_mask, decoder_ids, decoder_mask): + x = self.encoder(encoder_ids, encoder_mask) + x = self.decoder(decoder_ids, decoder_mask, x, encoder_mask) + x = self.head(x) + return x + + +def _t5(name, + encoder_only=False, + decoder_only=False, + return_tokenizer=False, + tokenizer_kwargs={}, + dtype=torch.float32, + device='cpu', + **kwargs): + # sanity check + assert not (encoder_only and decoder_only) + + # params + if encoder_only: + model_cls = T5Encoder + kwargs['vocab'] = kwargs.pop('vocab_size') + kwargs['num_layers'] = kwargs.pop('encoder_layers') + _ = kwargs.pop('decoder_layers') + elif decoder_only: + model_cls = T5Decoder + kwargs['vocab'] = kwargs.pop('vocab_size') + kwargs['num_layers'] = kwargs.pop('decoder_layers') + _ = kwargs.pop('encoder_layers') + else: + model_cls = T5Model + + # init model + with torch.device(device): + model = model_cls(**kwargs) + + # set device + model = model.to(dtype=dtype, device=device) + + # init tokenizer + if return_tokenizer: + from .tokenizers import HuggingfaceTokenizer + tokenizer_options = dict(tokenizer_kwargs) + tokenizer_path = tokenizer_options.pop("name", f"assets/models/tokenizers/{name}") + tokenizer = HuggingfaceTokenizer(tokenizer_path, **tokenizer_options) + return model, tokenizer + else: + return model + + +def umt5_xxl(**kwargs): + cfg = dict( + vocab_size=256384, + dim=4096, + dim_attn=4096, + dim_ffn=10240, + num_heads=64, + encoder_layers=24, + decoder_layers=24, + num_buckets=32, + shared_pos=False, + dropout=0.1) + cfg.update(**kwargs) + return _t5('umt5-xxl', **cfg) + + +class T5EncoderModel: + + def __init__( + self, + text_len, + dtype=torch.bfloat16, + device=torch.cuda.current_device(), + checkpoint_path=None, + tokenizer_path=None, + shard_fn=None, + ): + self.text_len = text_len + self.dtype = dtype + self.device = device + self.checkpoint_path = checkpoint_path + self.tokenizer_path = tokenizer_path + + # init model + model = umt5_xxl( + encoder_only=True, + return_tokenizer=False, + dtype=dtype, + device=device).eval().requires_grad_(False) + logging.info(f'loading {checkpoint_path}') + model.load_state_dict(torch.load(checkpoint_path, map_location='cpu')) + self.model = model + if shard_fn is not None: + self.model = shard_fn(self.model, sync_module_states=False) + else: + self.model.to(self.device) + # init tokenizer + self.tokenizer = HuggingfaceTokenizer( + name=tokenizer_path, seq_len=text_len, clean='whitespace') + + def __call__(self, texts, device): + ids, mask = self.tokenizer( + texts, return_mask=True, add_special_tokens=True) + ids = ids.to(device) + mask = mask.to(device) + seq_lens = mask.gt(0).sum(dim=1).long() + context = self.model(ids, mask) + return [u[:v] for u, v in zip(context, seq_lens)] diff --git a/1.3B/code/wan/modules/tokenizers.py b/1.3B/code/wan/modules/tokenizers.py new file mode 100644 index 0000000000000000000000000000000000000000..8c717e6293b5cd4252dd23b5531faa1627c1248b --- /dev/null +++ b/1.3B/code/wan/modules/tokenizers.py @@ -0,0 +1,88 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import html +import string +from pathlib import Path + +import ftfy +import regex as re +from transformers import AutoTokenizer + +__all__ = ['HuggingfaceTokenizer'] + + +def basic_clean(text): + text = ftfy.fix_text(text) + text = html.unescape(html.unescape(text)) + return text.strip() + + +def whitespace_clean(text): + text = re.sub(r'\s+', ' ', text) + text = text.strip() + return text + + +def canonicalize(text, keep_punctuation_exact_string=None): + text = text.replace('_', ' ') + if keep_punctuation_exact_string: + text = keep_punctuation_exact_string.join( + part.translate(str.maketrans('', '', string.punctuation)) + for part in text.split(keep_punctuation_exact_string)) + else: + text = text.translate(str.maketrans('', '', string.punctuation)) + text = text.lower() + text = re.sub(r'\s+', ' ', text) + return text.strip() + + +class HuggingfaceTokenizer: + + def __init__(self, name, seq_len=None, clean=None, **kwargs): + assert clean in (None, 'whitespace', 'lower', 'canonicalize') + self.name = name + self.seq_len = seq_len + self.clean = clean + + # Load the tokenizer from an existing local model bundle. + model_dir = Path(name).expanduser() + if not model_dir.is_dir(): + raise FileNotFoundError(f"Prepare the local tokenizer directory: {model_dir}") + name = str(model_dir.resolve()) + kwargs["local_files_only"] = True + self.tokenizer = AutoTokenizer.from_pretrained(name, **kwargs) + self.vocab_size = self.tokenizer.vocab_size + + def __call__(self, sequence, **kwargs): + return_mask = kwargs.pop('return_mask', False) + + # arguments + _kwargs = {'return_tensors': 'pt'} + if self.seq_len is not None: + _kwargs.update({ + 'padding': 'max_length', + 'truncation': True, + 'max_length': self.seq_len + }) + _kwargs.update(**kwargs) + + # tokenization + if isinstance(sequence, str): + sequence = [sequence] + if self.clean: + sequence = [self._clean(u) for u in sequence] + ids = self.tokenizer(sequence, **_kwargs) + + # output + if return_mask: + return ids.input_ids, ids.attention_mask + else: + return ids.input_ids + + def _clean(self, text): + if self.clean == 'whitespace': + text = whitespace_clean(basic_clean(text)) + elif self.clean == 'lower': + text = whitespace_clean(basic_clean(text)).lower() + elif self.clean == 'canonicalize': + text = canonicalize(basic_clean(text)) + return text diff --git a/1.3B/code/wan/modules/vae.py b/1.3B/code/wan/modules/vae.py new file mode 100644 index 0000000000000000000000000000000000000000..c50dea913c32eccf971fd528bb15b3173ea5f9b9 --- /dev/null +++ b/1.3B/code/wan/modules/vae.py @@ -0,0 +1,683 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import logging + +import torch +import torch.cuda.amp as amp +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange + +__all__ = [ + 'WanVAE', +] + +CACHE_T = 2 + + +class CausalConv3d(nn.Conv3d): + """ + Causal 3d convolusion. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._padding = (self.padding[2], self.padding[2], self.padding[1], + self.padding[1], 2 * self.padding[0], 0) + self.padding = (0, 0, 0) + + def forward(self, x, cache_x=None): + padding = list(self._padding) + if cache_x is not None and self._padding[4] > 0: + cache_x = cache_x.to(x.device) + x = torch.cat([cache_x, x], dim=2) + padding[4] -= cache_x.shape[2] + x = F.pad(x, padding) + + return super().forward(x) + + +class RMS_norm(nn.Module): + + def __init__(self, dim, channel_first=True, images=True, bias=False): + super().__init__() + broadcastable_dims = (1, 1, 1) if not images else (1, 1) + shape = (dim, *broadcastable_dims) if channel_first else (dim,) + + self.channel_first = channel_first + self.scale = dim**0.5 + self.gamma = nn.Parameter(torch.ones(shape)) + self.bias = nn.Parameter(torch.zeros(shape)) if bias else 0. + + def forward(self, x): + return F.normalize( + x, dim=(1 if self.channel_first else + -1)) * self.scale * self.gamma + self.bias + + +class Upsample(nn.Upsample): + + def forward(self, x): + """ + Fix bfloat16 support for nearest neighbor interpolation. + """ + return super().forward(x.float()).type_as(x) + + +class Resample(nn.Module): + + def __init__(self, dim, mode): + assert mode in ('none', 'upsample2d', 'upsample3d', 'downsample2d', + 'downsample3d') + super().__init__() + self.dim = dim + self.mode = mode + + # layers + if mode == 'upsample2d': + self.resample = nn.Sequential( + Upsample(scale_factor=(2., 2.), mode='nearest'), + nn.Conv2d(dim, dim // 2, 3, padding=1)) + elif mode == 'upsample3d': + self.resample = nn.Sequential( + Upsample(scale_factor=(2., 2.), mode='nearest'), + nn.Conv2d(dim, dim // 2, 3, padding=1)) + self.time_conv = CausalConv3d( + dim, dim * 2, (3, 1, 1), padding=(1, 0, 0)) + + elif mode == 'downsample2d': + self.resample = nn.Sequential( + nn.ZeroPad2d((0, 1, 0, 1)), + nn.Conv2d(dim, dim, 3, stride=(2, 2))) + elif mode == 'downsample3d': + self.resample = nn.Sequential( + nn.ZeroPad2d((0, 1, 0, 1)), + nn.Conv2d(dim, dim, 3, stride=(2, 2))) + self.time_conv = CausalConv3d( + dim, dim, (3, 1, 1), stride=(2, 1, 1), padding=(0, 0, 0)) + + else: + self.resample = nn.Identity() + + def forward(self, x, feat_cache=None, feat_idx=[0]): + b, c, t, h, w = x.size() + if self.mode == 'upsample3d': + if feat_cache is not None: + idx = feat_idx[0] + if feat_cache[idx] is None: + feat_cache[idx] = 'Rep' + feat_idx[0] += 1 + else: + + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[ + idx] is not None and feat_cache[idx] != 'Rep': + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + if cache_x.shape[2] < 2 and feat_cache[ + idx] is not None and feat_cache[idx] == 'Rep': + cache_x = torch.cat([ + torch.zeros_like(cache_x).to(cache_x.device), + cache_x + ], + dim=2) + if feat_cache[idx] == 'Rep': + x = self.time_conv(x) + else: + x = self.time_conv(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + + x = x.reshape(b, 2, c, t, h, w) + x = torch.stack((x[:, 0, :, :, :, :], x[:, 1, :, :, :, :]), + 3) + x = x.reshape(b, c, t * 2, h, w) + t = x.shape[2] + x = rearrange(x, 'b c t h w -> (b t) c h w') + x = self.resample(x) + x = rearrange(x, '(b t) c h w -> b c t h w', t=t) + + if self.mode == 'downsample3d': + if feat_cache is not None: + idx = feat_idx[0] + if feat_cache[idx] is None: + feat_cache[idx] = x.clone() + feat_idx[0] += 1 + else: + + cache_x = x[:, :, -1:, :, :].clone() + # if cache_x.shape[2] < 2 and feat_cache[idx] is not None and feat_cache[idx]!='Rep': + # # cache last frame of last two chunk + # cache_x = torch.cat([feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to(cache_x.device), cache_x], dim=2) + + x = self.time_conv( + torch.cat([feat_cache[idx][:, :, -1:, :, :], x], 2)) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + return x + + def init_weight(self, conv): + conv_weight = conv.weight + nn.init.zeros_(conv_weight) + c1, c2, t, h, w = conv_weight.size() + one_matrix = torch.eye(c1, c2) + init_matrix = one_matrix + nn.init.zeros_(conv_weight) + # conv_weight.data[:,:,-1,1,1] = init_matrix * 0.5 + conv_weight.data[:, :, 1, 0, 0] = init_matrix # * 0.5 + conv.weight.data.copy_(conv_weight) + nn.init.zeros_(conv.bias.data) + + def init_weight2(self, conv): + conv_weight = conv.weight.data + nn.init.zeros_(conv_weight) + c1, c2, t, h, w = conv_weight.size() + init_matrix = torch.eye(c1 // 2, c2) + # init_matrix = repeat(init_matrix, 'o ... -> (o 2) ...').permute(1,0,2).contiguous().reshape(c1,c2) + conv_weight[:c1 // 2, :, -1, 0, 0] = init_matrix + conv_weight[c1 // 2:, :, -1, 0, 0] = init_matrix + conv.weight.data.copy_(conv_weight) + nn.init.zeros_(conv.bias.data) + + +class ResidualBlock(nn.Module): + + def __init__(self, in_dim, out_dim, dropout=0.0): + super().__init__() + self.in_dim = in_dim + self.out_dim = out_dim + + # layers + self.residual = nn.Sequential( + RMS_norm(in_dim, images=False), nn.SiLU(), + CausalConv3d(in_dim, out_dim, 3, padding=1), + RMS_norm(out_dim, images=False), nn.SiLU(), nn.Dropout(dropout), + CausalConv3d(out_dim, out_dim, 3, padding=1)) + self.shortcut = CausalConv3d(in_dim, out_dim, 1) \ + if in_dim != out_dim else nn.Identity() + + def forward(self, x, feat_cache=None, feat_idx=[0]): + h = self.shortcut(x) + for layer in self.residual: + if isinstance(layer, CausalConv3d) and feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = layer(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = layer(x) + return x + h + + +class AttentionBlock(nn.Module): + """ + Causal self-attention with a single head. + """ + + def __init__(self, dim): + super().__init__() + self.dim = dim + + # layers + self.norm = RMS_norm(dim) + self.to_qkv = nn.Conv2d(dim, dim * 3, 1) + self.proj = nn.Conv2d(dim, dim, 1) + + # zero out the last layer params + nn.init.zeros_(self.proj.weight) + + def forward(self, x): + identity = x + b, c, t, h, w = x.size() + x = rearrange(x, 'b c t h w -> (b t) c h w') + x = self.norm(x) + # compute query, key, value + q, k, v = self.to_qkv(x).reshape(b * t, 1, c * 3, + -1).permute(0, 1, 3, + 2).contiguous().chunk( + 3, dim=-1) + + # apply attention + x = F.scaled_dot_product_attention( + q, + k, + v, + ) + x = x.squeeze(1).permute(0, 2, 1).reshape(b * t, c, h, w) + + # output + x = self.proj(x) + x = rearrange(x, '(b t) c h w-> b c t h w', t=t) + return x + identity + + +class Encoder3d(nn.Module): + + def __init__(self, + dim=128, + z_dim=4, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_downsample=[True, True, False], + dropout=0.0): + super().__init__() + self.dim = dim + self.z_dim = z_dim + self.dim_mult = dim_mult + self.num_res_blocks = num_res_blocks + self.attn_scales = attn_scales + self.temperal_downsample = temperal_downsample + + # dimensions + dims = [dim * u for u in [1] + dim_mult] + scale = 1.0 + + # init block + self.conv1 = CausalConv3d(3, dims[0], 3, padding=1) + + # downsample blocks + downsamples = [] + for i, (in_dim, out_dim) in enumerate(zip(dims[:-1], dims[1:])): + # residual (+attention) blocks + for _ in range(num_res_blocks): + downsamples.append(ResidualBlock(in_dim, out_dim, dropout)) + if scale in attn_scales: + downsamples.append(AttentionBlock(out_dim)) + in_dim = out_dim + + # downsample block + if i != len(dim_mult) - 1: + mode = 'downsample3d' if temperal_downsample[ + i] else 'downsample2d' + downsamples.append(Resample(out_dim, mode=mode)) + scale /= 2.0 + self.downsamples = nn.Sequential(*downsamples) + + # middle blocks + self.middle = nn.Sequential( + ResidualBlock(out_dim, out_dim, dropout), AttentionBlock(out_dim), + ResidualBlock(out_dim, out_dim, dropout)) + + # output blocks + self.head = nn.Sequential( + RMS_norm(out_dim, images=False), nn.SiLU(), + CausalConv3d(out_dim, z_dim, 3, padding=1)) + + def forward(self, x, feat_cache=None, feat_idx=[0]): + if feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = self.conv1(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = self.conv1(x) + + # downsamples + for layer in self.downsamples: + if feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # middle + for layer in self.middle: + if isinstance(layer, ResidualBlock) and feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # head + for layer in self.head: + if isinstance(layer, CausalConv3d) and feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = layer(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = layer(x) + return x + + +class Decoder3d(nn.Module): + + def __init__(self, + dim=128, + z_dim=4, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_upsample=[False, True, True], + dropout=0.0): + super().__init__() + self.dim = dim + self.z_dim = z_dim + self.dim_mult = dim_mult + self.num_res_blocks = num_res_blocks + self.attn_scales = attn_scales + self.temperal_upsample = temperal_upsample + + # dimensions + dims = [dim * u for u in [dim_mult[-1]] + dim_mult[::-1]] + scale = 1.0 / 2**(len(dim_mult) - 2) + + # init block + self.conv1 = CausalConv3d(z_dim, dims[0], 3, padding=1) + + # middle blocks + self.middle = nn.Sequential( + ResidualBlock(dims[0], dims[0], dropout), AttentionBlock(dims[0]), + ResidualBlock(dims[0], dims[0], dropout)) + + # upsample blocks + upsamples = [] + for i, (in_dim, out_dim) in enumerate(zip(dims[:-1], dims[1:])): + # residual (+attention) blocks + if i == 1 or i == 2 or i == 3: + in_dim = in_dim // 2 + for _ in range(num_res_blocks + 1): + upsamples.append(ResidualBlock(in_dim, out_dim, dropout)) + if scale in attn_scales: + upsamples.append(AttentionBlock(out_dim)) + in_dim = out_dim + + # upsample block + if i != len(dim_mult) - 1: + mode = 'upsample3d' if temperal_upsample[i] else 'upsample2d' + upsamples.append(Resample(out_dim, mode=mode)) + scale *= 2.0 + self.upsamples = nn.Sequential(*upsamples) + + # output blocks + self.head = nn.Sequential( + RMS_norm(out_dim, images=False), nn.SiLU(), + CausalConv3d(out_dim, 3, 3, padding=1)) + + def forward(self, x, feat_cache=None, feat_idx=[0]): + # conv1 + if feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = self.conv1(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = self.conv1(x) + + # middle + for layer in self.middle: + if isinstance(layer, ResidualBlock) and feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # upsamples + for layer in self.upsamples: + if feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # head + for layer in self.head: + if isinstance(layer, CausalConv3d) and feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = layer(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = layer(x) + return x + + +def count_conv3d(model): + count = 0 + for m in model.modules(): + if isinstance(m, CausalConv3d): + count += 1 + return count + + +class WanVAE_(nn.Module): + + def __init__(self, + dim=128, + z_dim=4, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_downsample=[True, True, False], + dropout=0.0): + super().__init__() + self.dim = dim + self.z_dim = z_dim + self.dim_mult = dim_mult + self.num_res_blocks = num_res_blocks + self.attn_scales = attn_scales + self.temperal_downsample = temperal_downsample + self.temperal_upsample = temperal_downsample[::-1] + + # modules + self.encoder = Encoder3d(dim, z_dim * 2, dim_mult, num_res_blocks, + attn_scales, self.temperal_downsample, dropout) + self.conv1 = CausalConv3d(z_dim * 2, z_dim * 2, 1) + self.conv2 = CausalConv3d(z_dim, z_dim, 1) + self.decoder = Decoder3d(dim, z_dim, dim_mult, num_res_blocks, + attn_scales, self.temperal_upsample, dropout) + self.clear_cache() + + def forward(self, x): + mu, log_var = self.encode(x) + z = self.reparameterize(mu, log_var) + x_recon = self.decode(z) + return x_recon, mu, log_var + + def encode(self, x, scale): + self.clear_cache() + # cache + t = x.shape[2] + iter_ = 1 + (t - 1) // 4 + # 对encode输入的x,按时间拆分为1、4、4、4.... + for i in range(iter_): + self._enc_conv_idx = [0] + if i == 0: + out = self.encoder( + x[:, :, :1, :, :], + feat_cache=self._enc_feat_map, + feat_idx=self._enc_conv_idx) + else: + out_ = self.encoder( + x[:, :, 1 + 4 * (i - 1):1 + 4 * i, :, :], + feat_cache=self._enc_feat_map, + feat_idx=self._enc_conv_idx) + out = torch.cat([out, out_], 2) + mu, log_var = self.conv1(out).chunk(2, dim=1) + if isinstance(scale[0], torch.Tensor): + mu = (mu - scale[0].view(1, self.z_dim, 1, 1, 1)) * scale[1].view( + 1, self.z_dim, 1, 1, 1) + else: + mu = (mu - scale[0]) * scale[1] + self.clear_cache() + return mu + + def decode(self, z, scale): + self.clear_cache() + # z: [b,c,t,h,w] + if isinstance(scale[0], torch.Tensor): + z = z / scale[1].view(1, self.z_dim, 1, 1, 1) + scale[0].view( + 1, self.z_dim, 1, 1, 1) + else: + z = z / scale[1] + scale[0] + iter_ = z.shape[2] + x = self.conv2(z) + for i in range(iter_): + self._conv_idx = [0] + if i == 0: + out = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + else: + out_ = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + out = torch.cat([out, out_], 2) + self.clear_cache() + return out + + def cached_decode(self, z, scale): + # z: [b,c,t,h,w] + if isinstance(scale[0], torch.Tensor): + z = z / scale[1].view(1, self.z_dim, 1, 1, 1) + scale[0].view( + 1, self.z_dim, 1, 1, 1) + else: + z = z / scale[1] + scale[0] + iter_ = z.shape[2] + x = self.conv2(z) + for i in range(iter_): + self._conv_idx = [0] + if i == 0: + out = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + else: + out_ = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + out = torch.cat([out, out_], 2) + return out + + def sample(self, imgs, deterministic=False): + mu, log_var = self.encode(imgs) + if deterministic: + return mu + std = torch.exp(0.5 * log_var.clamp(-30.0, 20.0)) + return mu + std * torch.randn_like(std) + + def clear_cache(self): + self._conv_num = count_conv3d(self.decoder) + self._conv_idx = [0] + self._feat_map = [None] * self._conv_num + # cache encode + self._enc_conv_num = count_conv3d(self.encoder) + self._enc_conv_idx = [0] + self._enc_feat_map = [None] * self._enc_conv_num + + +def _video_vae(pretrained_path=None, z_dim=None, device='cpu', **kwargs): + """ + Autoencoder3d adapted from Stable Diffusion 1.x, 2.x and XL. + """ + # params + cfg = dict( + dim=96, + z_dim=z_dim, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_downsample=[False, True, True], + dropout=0.0) + cfg.update(**kwargs) + + # init model + with torch.device('meta'): + model = WanVAE_(**cfg) + + # load checkpoint + logging.info(f'loading {pretrained_path}') + model.load_state_dict( + torch.load(pretrained_path, map_location=device), assign=True) + + return model + + +class WanVAE: + + def __init__(self, + z_dim=16, + vae_pth='cache/vae_step_411000.pth', + dtype=torch.float, + device="cuda"): + self.dtype = dtype + self.device = device + + mean = [ + -0.7571, -0.7089, -0.9113, 0.1075, -0.1745, 0.9653, -0.1517, 1.5508, + 0.4134, -0.0715, 0.5517, -0.3632, -0.1922, -0.9497, 0.2503, -0.2921 + ] + std = [ + 2.8184, 1.4541, 2.3275, 2.6558, 1.2196, 1.7708, 2.6052, 2.0743, + 3.2687, 2.1526, 2.8652, 1.5579, 1.6382, 1.1253, 2.8251, 1.9160 + ] + self.mean = torch.tensor(mean, dtype=dtype, device=device) + self.std = torch.tensor(std, dtype=dtype, device=device) + self.scale = [self.mean, 1.0 / self.std] + + # init model + self.model = _video_vae( + pretrained_path=vae_pth, + z_dim=z_dim, + ).eval().requires_grad_(False).to(device) + + def encode(self, videos): + """ + videos: A list of videos each with shape [C, T, H, W]. + """ + with amp.autocast(dtype=self.dtype): + return [ + self.model.encode(u.unsqueeze(0), self.scale).float().squeeze(0) + for u in videos + ] + + def decode(self, zs): + with amp.autocast(dtype=self.dtype): + return [ + self.model.decode(u.unsqueeze(0), + self.scale).float().clamp_(-1, 1).squeeze(0) + for u in zs + ] diff --git a/1.3B/configs/three_encoder.yaml b/1.3B/configs/three_encoder.yaml new file mode 100644 index 0000000000000000000000000000000000000000..223a7a382daa4c04cdddd046a3017996d89476af --- /dev/null +++ b/1.3B/configs/three_encoder.yaml @@ -0,0 +1,280 @@ +# Public names follow the paper; code/utils/config.py accepts both these and legacy keys. +# Numerical settings and resource identities are unchanged. See ../../docs/paper-to-code.md. + + +# Initialization and checkpoint compatibility. Only the generator is optimized. +initial_generator_checkpoint: /path/to/video-generation-assets/checkpoints/ode_init.pt +initial_generator_checkpoint_sha256: b5396b8076ab3b920c9e4f4a2b52daa2c98c9983fb5e067ae5160fdf778dce21 +resume_checkpoint: null + +# Distributed execution: wrap units and precision; score-model settings below are legacy/inactive. +generator_fsdp_wrap_strategy: transformer +real_score_fsdp_wrap_strategy: size +fake_score_fsdp_wrap_strategy: size +text_encoder_fsdp_wrap_strategy: size +uses_score_models: false +uses_real_score: false +uses_fake_score: false + +# Autoregressive rollout: four candidate denoising times, with stochastic training exit steps. +causal: true +denoising_step_list: +- 1000 +- 750 +- 500 +- 250 +warp_denoising_step: true +timestep_shift: 5.0 +guidance_scale: 3.0 +mixed_precision: true + +# Training seed 0 requests a randomly chosen seed; use an explicit positive value for repeatable input draws. +seed: 0 + +# Paper Sec. 3.1: match generated videos in frozen representation spaces, not score-model outputs. +training_objective: distribution_matching +training_loop: distribution_matching +feature_space: frozen_video_encoders +mmd_estimator: hybrid_nystrom_monte_carlo +objective_weight: 1.0 + +# Each encoder loss M uses M / (stop_gradient(M) + epsilon); encoder weights follow this scaling. +normalize_encoder_losses: true +encoder_loss_normalization_epsilon: 0.0001 + +# Reference features are read from disk. "Teacher" in legacy keys means reference data, not a live network. +reference_feature_source: matched_encoder_features +reference_feature_manifest: /path/to/video-generation-assets/teacher_banks/triple_video_new8682_824/manifest.json +reference_feature_manifest_sha256: 1001c568c0dd2c53c17b7c7b147d01968b304a52f8b8ebe849246e1c6e34f36d +reference_feature_content_hash: 0e2431c6443f94a32418fddd93328d60c33bd78e2cc2c0751f9bce3960f36912 + +# Reference video/prompt correspondence and immutable asset identities. +reference_video_manifest: /path/to/video-generation-assets/experiments/triple_video_new8682_824/source/new8682_824_matched.jsonl +matched_manifest_sha256: fa8e8e24011e6d292920f63d55b7d4de24fd07ebf46e175a064deee30d2728e1 +reference_video_count: 8682 +matched_source_csv_sha256: d109dd7998a48916158dcc1a1d372c4e3a136f455257d4d272cbcb887a3a5b14 +matched_sample_identity_sha256: c342bee98db8c4096726f06c9e840681516c27c8b39e54d4bbbf210abcc79637 +matched_sampling_algorithm: sha256_seed_step_position_mod_n_v1 +matched_sampling_with_replacement: true + +# Paper Sec. 3.2: combine a persistent Nyström summary with the current reference minibatch. +triple_token_sampling: uniform_fixed +triple_nystrom_attraction_ema_enabled: false + +# Nyström:Monte Carlo = 2:1, corresponding to paper mixture alpha = 1/3. +nystrom_reference_weight: 2.0 +triple_nystrom_current_teacher_attraction_enabled: true +monte_carlo_reference_weight: 1.0 +triple_nystrom_repulsion_fifo_size: 0 + +# The legacy triple_nystrom_* reference fields belong to V-JEPA 2; other encoders have separate packs. +triple_nystrom_reference_path: /path/to/video-generation-assets/experiments/triple_video_new8682_824/references/vjepa2_l15_last64_k1024_m1024_matched5k.pt +triple_nystrom_reference_sha256: 22a23b9bdd8283cf0f5cb41f1a3edfe2aa5badaad4e2bb033bc4a886d5c59b9a +triple_nystrom_landmarks: 1024 +triple_nystrom_kernel_chunk_size: 32 +triple_nystrom_seed: 0 + +# Paper Eq. (3): encoder weights after normalization; divide by the explicit divisor, not their sum. +dino_loss_weight: 10.0 +vjepa_loss_weight: 1.0 +videomae_loss_weight: 1.0 +encoder_loss_divisor: 3.0 + +# DINOv3: 20 selected frames -> four groups of five -> 4 x 256 spatial tokens. +dinov3_repo_path: /path/to/video-generation-assets/models/dinov3-src +dinov3_weight_path: /path/to/video-generation-assets/models/dinov3_vitl16_lvd1689m.pth +dinov3_source_commit: 6876159a11b4df116f30f667f8c9888617df0751 +dinov3_checkpoint_sha256: b361ce4cccaae083fccaa7a732bd9abb143b5a3736175152f28f331b600f8eec +dinov3_dense_geometry: temporal_mean5_full256 +dinov3_dense_source_num_frames: 81 +dinov3_dense_frame_indices: +- 0 +- 4 +- 8 +- 13 +- 17 +- 21 +- 25 +- 29 +- 34 +- 38 +- 42 +- 46 +- 51 +- 55 +- 59 +- 63 +- 67 +- 72 +- 76 +- 80 +dinov3_dense_input_size: 256 +dinov3_dense_patch_size: 16 +dinov3_dense_spatial_axis_indices: +- 0 +- 1 +- 2 +- 3 +- 4 +- 5 +- 6 +- 7 +- 8 +- 9 +- 10 +- 11 +- 12 +- 13 +- 14 +- 15 +dinov3_dense_temporal_groups: 4 +dinov3_dense_temporal_pool_size: 5 +dinov3_dense_forward_chunk_frames: 4 +dinov3_dense_gradient_checkpointing: false +dinov3_nystrom_reference_path: /path/to/video-generation-assets/experiments/triple_video_new8682_824/references/dinov3_vitl16_f20mean5_k1024_m256_matched5k.pt +dinov3_nystrom_reference_sha256: e6e3fe8d2aca39bf68e5a1a2c384ae40cc1033345d88739fd9e97e4f880f5a3c +dinov3_nystrom_landmarks: 256 +dinov3_nystrom_kernel_chunk_size: 32 +dinov3_nystrom_seed: 0 + +# V-JEPA 2: last 64 of 81 frames, block index 15 (the 16th block), 1024 selected tokens. +vjepa2_model_id: /path/to/video-generation-assets/models/vjepa2-vitl-fpc64-256 +vjepa2_reference_model_id: vjepa2-vitl-fpc64-256 +vjepa2_model_sha256: 25466aef85727d16546c6cf8c99f12fcfad9cbca8225d45f23685e2e025b786b +vjepa2_dense_layers: +- 15 +vjepa2_dense_layer: 15 +vjepa2_dense_num_frames: 64 +vjepa2_dense_source_num_frames: 81 +vjepa2_dense_frame_sampling: last_contiguous +vjepa2_student_source_num_frames: 81 +vjepa2_student_frame_sampling: last_contiguous +vjepa2_dense_num_tokens: 1024 +vjepa2_dense_resize_short_side: 292 +vjepa2_dense_crop_size: 256 +vjepa2_dense_gradient_checkpointing: true + +# VideoMAEv2: skip frame 0, split the remaining 80 frames into five 16-frame segments. +videomae_v2_model_id: /path/to/video-generation-assets/models/VideoMAEv2-Large +videomae_v2_model_revision: 9981a9c8f77118c421e5228e1b219468a4b0238d +videomae_v2_download_manifest_content_hash: 5e362cfe6fdb1bec9e3e195e5ef43cdfebbdfaec2625b1f60550efd05ead0d7d +videomae_v2_local_files_only: true +videomae_v2_dense_layers: +- 15 +videomae_v2_dense_layer: 15 +videomae_v2_encoder_count: 5 +videomae_v2_skip_leading_frames: 1 +videomae_v2_segment_num_frames: 16 +videomae_v2_source_num_frames: 81 + +# 256 tokens per segment, hence 1280 tokens for the complete video. +videomae_v2_dense_num_tokens: 256 +videomae_v2_dense_resize_short_side: 224 +videomae_v2_dense_crop_size: 224 +videomae_v2_dense_gradient_checkpointing: true +videomae_v2_nystrom_reference_path: /path/to/video-generation-assets/experiments/triple_video_new8682_824/references/videomae_v2_l15_fixed5x16_k256_m256_matched5k.pt +videomae_v2_nystrom_reference_sha256: 526cad0c388c560c486cb460b3e7df6a4b91826825e3e2b0af10f6ffe8f56a4a +videomae_v2_nystrom_landmarks: 256 +videomae_v2_nystrom_kernel_chunk_size: 32 +videomae_v2_nystrom_seed: 0 +videomae_v2_nystrom_attraction_weight: 2.0 +videomae_v2_nystrom_current_teacher_attraction_weight: 1.0 + +# Paper Sec. 3.3: separate distribution batch B, differentiation batch b, and reference minibatch B_ref. +per_device_microbatch_size: 1 +distribution_samples_per_rank: 64 + +# Replay the configured per-rank suffix; this release does not draw the paper pseudocode's uniform subset. +differentiation_samples_per_rank: 32 +fresh_video_partial_gradient: true +differentiate_full_distribution_loss: true + +# Apply B/b once on replay gradients, separately from FSDP averaging compensation. +differentiation_gradient_scale: 2.0 +distribution_batch_size: 256 +reference_minibatch_size: 128 + +# Recorded estimator geometry: [B, b, B_ref]. Released self-kernel mean includes diagonal pairs and divides by B^2. +partial_mmd_geometry: +- 256 +- 128 +- 128 +partial_mmd_normalization_identity: full_student_population_denominators_v1 +partial_mmd_kss_denominator: 65536 +partial_mmd_fixed_attraction_denominator: 256 +partial_mmd_live_cross_denominator: 32768 +partial_mmd_teacher_self_denominator: 16384 +partial_mmd_gradient_compensation: 2.0 +fresh_video_replay_atol: 1.0e-05 +fresh_video_replay_rtol: 0.0001 + +# Parameter EMA is disabled; inference uses the trained generator weights directly. +ema_weight: 0.0 +sharding_strategy: full + +# Generator optimizer. Learning-rate schedule parameters below remain unchanged. +generator_learning_rate: 2.5e-06 +beta1: 0.0 +beta2: 0.999 +weight_decay: 0.0 +max_grad_norm_generator: 10.0 +negative_prompt: '' +dfake_gen_update_ratio: 1 + +# Latent tensor [batch, time, channels, height, width]; 21 latent frames decode to 81 RGB frames at 480 x 832. +latent_video_shape: +- 1 +- 21 +- 16 +- 60 +- 104 +denoising_loss_type: flow + +# Activation recomputation saves GPU memory; it is unrelated to saving model checkpoints. +gradient_checkpointing: true +num_frame_per_block: 3 +load_raw_video: false +model_kwargs: + model_name: Wan2.1-T2V-1.3B + timestep_shift: 5.0 + +# Checkpoint/logging policy. Full states include optimizer and per-rank RNG state. +log_iters: 5 +save_full_state: true +save_full_state_final_only: false +checkpoint_keep_last: 0 +checkpoint_model_min_free_gib: 12 +checkpoint_full_min_free_gib: 32 +allow_resume_world_size_change: false + +# Optional in-training evaluation is disabled; use scripts/infer.sh for generation. +eval_interval: 0 +eval_seed: 12345 +eval_num_prompts: 0 +eval_prompt_path: prompts/test_8.txt +num_workers: 0 +stdout_log_iters: 1 + +# Optimizer updates, not epochs or diffusion timesteps. Do not shorten the configured cosine horizon. +training_updates: 150 +num_train_timestep: 1000 + +# Clean cached context; blocks share the sampled training exit step. Frozen encoders still pass input gradients. +context_noise: 0 +independent_first_frame: false +same_step_across_blocks: true +last_step_only: false +i2v: false +num_training_frames: 21 +gc_interval: 25 + +# Opaque feature/data identities are retained so existing reference packs and checkpoints still match. +feature_protocol_identity: triple_dinov3_f20mean5_k1024_vjepa2_l15_last64_k1024_videomae_v2_l15_5x16_k256_new8682_824_v1 +matched_video_root: /path/to/new8682_824 +newwan_dataset_alias: new8682_824 + +# 1.3B: cosine decay over updates 0..125, then the floor through update 150. +generator_lr_schedule: cosine_window +generator_lr_cosine_start_step: 0 +generator_lr_cosine_end_step: 125 +generator_lr_min: 5.0e-07 diff --git a/1.3B/docs/assets.md b/1.3B/docs/assets.md new file mode 100644 index 0000000000000000000000000000000000000000..cbd5d4601de7bea625d540d4fbff5ab7dde4b82d --- /dev/null +++ b/1.3B/docs/assets.md @@ -0,0 +1,92 @@ +# Asset Preparation + +The source release includes training and evaluation code, the training configuration, and example prompts. Model weights, training reference data, and generated videos are stored separately. + +## Required assets + +| Use | Assets | +|---|---| +| Inference | Wan2.1-T2V-1.3B base weights, text encoder, tokenizer, VAE, and a trained Anonymous Video Generation checkpoint | +| Training | Inference base assets, the Self-Forcing ODE initialization, three frozen feature encoders, reference features, and Nyström summaries | + +The trained Anonymous Video Generation checkpoint and reference collection are not distributed in this repository. Encoder weights alone are insufficient to reproduce training with the provided configuration. + +## Model families (local files required) + +- Wan2.1-T2V-1.3B +- Self-Forcing initialization +- [DINOv3](https://github.com/facebookresearch/dinov3) +- V-JEPA 2 ViT-L +- VideoMAEv2-Large + +For the supplied configuration, DINOv3 uses ViT-L/16 features from its final layer; V-JEPA 2 and VideoMAEv2 use layer 15. VideoMAEv2 is pinned to revision `9981a9c8f77118c421e5228e1b219468a4b0238d`, and the DINOv3 source is pinned to commit `6876159a11b4df116f30f667f8c9888617df0751`. + +## Directory layout + +Place assets under a common root. The following filenames are retained for compatibility with the supplied configuration: + +```text +ASSET_ROOT/ +├── checkpoints/ode_init.pt +├── models/ +│ ├── Wan2.1-T2V-1.3B/ +│ ├── dinov3-src/ +│ ├── dinov3_vitl16_lvd1689m.pth +│ ├── dinov3_vitl16_lvd1689m.pth.provenance.json +│ ├── vjepa2-vitl-fpc64-256/ +│ └── VideoMAEv2-Large/ +├── teacher_banks/triple_video_new8682_824/ +│ ├── manifest.json +│ └── ... # All feature shards referenced by the manifest +└── experiments/triple_video_new8682_824/ + ├── source/new8682_824_matched.jsonl + └── references/ + ├── dinov3_vitl16_f20mean5_k1024_m256_matched5k.pt + ├── vjepa2_l15_last64_k1024_m1024_matched5k.pt + └── videomae_v2_l15_fixed5x16_k256_m256_matched5k.pt +``` + +The reference collection contains 8,682 videos stored in 272 feature shards. The `matched5k` suffix is a legacy filename; the manifest determines the actual collection size. + +Keep the feature shards, manifests, and summaries together without changing their contents or sample ordering. Preserve the DINOv3 provenance file and the VideoMAEv2 `download_manifest.json` alongside their weights. + +## Configure asset paths + +From the `1.3B/` directory: + +```bash +export ASSET_ROOT=/path/to/video-generation-assets +python scripts/prepare_config.py --asset-root "$ASSET_ROOT" +``` + +The setup script generates a local configuration with relocated asset paths. Generated configurations are excluded from version control. Asset and source-hash preflight checks are not run by the launchers. + +## Reference feature preparation + +Preprocessing is performed once, before generator training. It requires the raw captioned reference videos, the frozen encoders, and a CUDA device. Training subsequently reads the saved features instead of decoding those reference videos again. + +1. **Prepare the prompt/video manifest.** Each entry binds a caption to an MP4 basename in a fixed sample order. The loader in [`matched_video_manifest.py`](../code/utils/matched_video_manifest.py) expects a JSONL manifest and its `.meta.json` sidecar, including the source-data identity. A plain prompt text file is not a training manifest. +2. **Extract all three representations.** [`extract_triple_matched_teacher_bank.py`](../code/tools/extract_triple_matched_teacher_bank.py) reads the manifest and raw videos and writes feature shards plus a `manifest.json`. Supply the source manifest, video root, encoder paths, and model identities. For the released collection, set `--expected-count 8682` and `--shard-rows 32`; the default sample count in this lower-level tool is different. +3. **Build the fixed summaries.** [`build_triple_nystrom_references.py`](../code/tools/build_triple_nystrom_references.py) reads the extracted feature bank and writes one Nyström reference per encoder. Supply the bank and model identities and set `--expected-sample-count 8682`. Keep the reference files together with the feature bank from which they were built. + +Run the preprocessing tools from the `1.3B/` directory with `PYTHONPATH="$PWD/code"`. Their `--help` output lists the required manifests, encoder identities, and output paths. + +The feature protocol used by the training configuration is: + +| Encoder | Video sampling | Representation | Nyström landmarks | +|---|---|---|---:| +| DINOv3 ViT-L/16 | 20 frames across the 81-frame video, pooled in groups of 5 | Final-layer features, 1,024 tokens per video | 256 | +| V-JEPA 2 ViT-L | Last 64 contiguous frames | Layer 15, 1,024 tokens per video | 1,024 | +| VideoMAEv2-Large | Skip the first frame, then use five 16-frame clips | Layer 15, 256 tokens per clip | 256 | + +Encoder revisions, feature sampling, source ordering, and preprocessing metadata must agree between the reference assets and training configuration. Re-extraction can change serialized hashes; it should not be assumed to recreate the supplied assets byte for byte. + +## Using another reference collection + +A new collection requires a new manifest, feature bank, Nyström summaries, and a training configuration with matching paths and identities. + +Update the local configuration for a new experiment. Changing the reference collection should not be expected to reproduce the reported VBench score. + +## Local snapshot preparation + +All model loaders require existing local directories. Hosted model repository URLs, repository IDs and automatic downloads are omitted from this source release. The legacy-named `download_videomae_v2_large.py` stages an existing local snapshot using `--source-dir` and an empty `--output-dir`; it does not download files. If the source includes a valid pinned `download_manifest.json`, that manifest is preserved for compatibility with frozen reference banks. A snapshot without a manifest gets a new local manifest; rebuild matching reference assets and update their configuration identities before training. `prepare_config.py` reads model identities from an available local feature bank into private local configuration files. diff --git a/1.3B/docs/validation.md b/1.3B/docs/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..d1e2208ed47a73983f3fa194344d45c61d587681 --- /dev/null +++ b/1.3B/docs/validation.md @@ -0,0 +1,31 @@ +# Validation + +The GPU results below describe commit `676d5a5ec9eb3cd65152b23826f028f388886dd5`, before validation gates were removed. They were obtained on four NVIDIA H200 GPUs using the original three-encoder training assets. The gate-removal revision has separate CPU regression checks; it has not been rerun on GPUs. + +| Component | Version | +|---|---| +| Python | 3.10.12 | +| PyTorch | 2.5.1+cu121 | +| torchvision | 0.20.1+cu121 | +| FlashAttention | 2.7.4.post1 | +| Transformers | 4.53.3 | + +## Current CPU regression checks + +After removing the launcher preflight gates, core reference checks, and selected encoder checks, all **15 retained CPU numerical tests passed**. They cover MMD gradients, partial-batch normalization, feature sampling, and pixel-gradient accumulation. Tests that only enforced removed gates were retired. The current revision has not been rerun on GPUs. + +## Historical GPU checks + +- **31 CPU tests passed**, covering MMD gradients, partial-batch normalization, feature contracts, pixel-gradient accumulation, and relocated reference identities. +- **One full-size optimizer update completed** with 256 generated videos, 128 differentiated videos, and 128 sampled references on four workers. All 272 reference feature shards were validated during initialization. +- The logged generator loss was **3.995** and the gradient norm before clipping was **44.62**. The update was not skipped. DINOv3, V-JEPA 2, and VideoMAEv2 feature replay errors were all zero in this run. +- Generator weights were finite, and the model, optimizer, and four per-worker random states were saved successfully. All 825 saved Adam parameter states recorded one update, and their gradients were not all zero. +- The new checkpoint was loaded by the shared inference implementation and generated a decodable **81-frame, 832 × 480, 16 FPS** video. + +Machine-readable results are saved in [three-encoder-smoke.json](../results/validation/three-encoder-smoke.json). + +The run used `bash scripts/train.sh --smoke`. This overrides only the training length to one update; the distribution batch, differentiation batch, encoders, and reference assets remain those of the released recipe. + +That revision included reference-content and relocation checks. These historical results should not be interpreted as validation of the current gate-removal changes. + +This is an execution check. It does not repeat the 150-update training run, rerun VBench, establish minimum hardware requirements, or validate installation in a fresh environment. diff --git a/1.3B/requirements.txt b/1.3B/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0dd2c6ae5d0deb7722dcd03fd1ac0f31a380629f --- /dev/null +++ b/1.3B/requirements.txt @@ -0,0 +1,29 @@ +# Training and inference dependencies. Install torch/torchvision first. +numpy==1.26.4 +diffusers==0.31.0 +transformers==4.53.3 +tokenizers==0.21.4 +timm==1.0.28 +accelerate==1.14.0 +tqdm==4.70.0 +imageio==2.37.4 +imageio-ffmpeg==0.6.0 +easydict==1.13 +ftfy==6.3.1 +omegaconf>=2.3.0,<2.4 +einops==0.8.1 +av==13.1.0 +opencv-python +open-clip-torch +lmdb==2.3.0 +matplotlib +sentencepiece==0.2.2 +scikit-image +huggingface-hub==0.36.2 +safetensors==0.8.0 +PyYAML==6.0.3 +pandas +scipy +Pillow==12.2.0 +requests==2.34.2 +packaging==26.3 diff --git a/1.3B/results/reference/three-encoder-summary.json b/1.3B/results/reference/three-encoder-summary.json new file mode 100644 index 0000000000000000000000000000000000000000..b4e37899d6cf6ef7f40f761947be6c36d1a87536 --- /dev/null +++ b/1.3B/results/reference/three-encoder-summary.json @@ -0,0 +1,43 @@ +{ + "protocol": "Classic VBench; three-encoder generator after 150 updates; official extended prompts; 946 rows; 5 samples per row; 4 fixed shards; continuous RNG per shard initialized with seed 0; RAFT filtering for temporal_flickering", + "warning": "The official list contains 946 rows but 944 unique short-prompt strings; under VBench official filename semantics the two duplicated strings share the later row's five files.", + "raw_scores": { + "subject_consistency": 0.9611354023187111, + "background_consistency": 0.9672444401230923, + "temporal_flickering": 0.9936142066227284, + "motion_smoothness": 0.9844131512560166, + "dynamic_degree": 0.6722222222222223, + "aesthetic_quality": 0.6596271849447681, + "imaging_quality": 0.6853940512979873, + "object_class": 0.9572784810126582, + "multiple_objects": 0.8640243902439024, + "human_action": 0.966, + "color": 0.8674415896090772, + "spatial_relationship": 0.8033092988378278, + "scene": 0.5902616279069768, + "appearance_style": 0.20167334747942375, + "temporal_style": 0.2467925687432289, + "overall_consistency": 0.26572600335523644 + }, + "normalized_weighted_scores": { + "subject_consistency": 0.9544804430999193, + "background_consistency": 0.9556458227800845, + "temporal_flickering": 0.9827736892978914, + "motion_smoothness": 0.9551051501064032, + "dynamic_degree": 0.33611111111111114, + "aesthetic_quality": 0.6596271849447681, + "imaging_quality": 0.6853940512979873, + "object_class": 0.9572784810126582, + "multiple_objects": 0.8640243902439024, + "human_action": 0.966, + "color": 0.8674415896090772, + "spatial_relationship": 0.8033092988378278, + "scene": 0.7179051665129856, + "appearance_style": 0.7054580023872936, + "temporal_style": 0.6780015624813981, + "overall_consistency": 0.730016492734166 + }, + "quality_score": 0.8506365311751023, + "semantic_score": 0.8099372204243678, + "total_score": 0.8424966690249555 +} diff --git a/1.3B/results/validation/three-encoder-smoke.json b/1.3B/results/validation/three-encoder-smoke.json new file mode 100644 index 0000000000000000000000000000000000000000..fba1e2f5ee9add4eef92bdf83ca483e6db18897e --- /dev/null +++ b/1.3B/results/validation/three-encoder-smoke.json @@ -0,0 +1,36 @@ +{ + "training_updates": 1, + "distribution_batch": 256, + "differentiation_batch": 128, + "reference_batch": 128, + "encoders": [ + "DINOv3", + "V-JEPA2", + "VideoMAEv2" + ], + "checkpoint_finite": true, + "optimizer_saved": true, + "rng_states": 4, + "video_frames": 81, + "video_shape": [ + 480, + 832, + 3 + ], + "fps": 16.0, + "cpu_tests_passed": 31, + "optimizer_parameter_states": 825, + "adam_steps_all_one": true, + "first_frame_pixel_std": 58.5123941148281, + "hardware": "4 x NVIDIA H200", + "torch": "2.5.1+cu121", + "flash_attention": "2.7.4.post1", + "logged_generator_loss": 3.995, + "logged_generator_grad_norm_before_clipping": 44.62, + "feature_replay_max_abs": { + "dinov3": 0.0, + "vjepa2": 0.0, + "videomae_v2": 0.0 + }, + "scope": "One full-batch training update and checkpoint inference; not a full 150-update or VBench rerun" +} diff --git a/1.3B/scripts/common.sh b/1.3B/scripts/common.sh new file mode 100644 index 0000000000000000000000000000000000000000..7346ff96e5f656b7427e22ed8b898b1954dc079e --- /dev/null +++ b/1.3B/scripts/common.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Source from launchers; all generated files stay in the reproduction directory. +set -euo pipefail +REPRO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$REPRO_ROOT/configs/local.env" +PYTHON_BIN="$(command -v "${PYTHON_BIN:-python}")" +CONFIG="$REPRO_ROOT/configs/local.yaml" +export SELF_FORCING_MODELS_DIR="$VIDEO_GENERATION_ASSET_ROOT/models" +export SELF_FORCING_CKPT_DIR="$VIDEO_GENERATION_ASSET_ROOT/checkpoints" +export PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 +export OMP_NUM_THREADS=4 TOKENIZERS_PARALLELISM=false +export HF_HOME="${HF_HOME:-$REPRO_ROOT/cache/huggingface}" +export TORCH_HOME="${TORCH_HOME:-$REPRO_ROOT/cache/torch}" +export TRITON_CACHE_DIR="${TRITON_CACHE_DIR:-$REPRO_ROOT/cache/triton}" +export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 +export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True +unset WORLD_SIZE RANK LOCAL_RANK MASTER_ADDR MASTER_PORT diff --git a/1.3B/scripts/infer.sh b/1.3B/scripts/infer.sh new file mode 100644 index 0000000000000000000000000000000000000000..4ba8c5afe2d0df7f5614dd9adadfeecaf27aff94 --- /dev/null +++ b/1.3B/scripts/infer.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +CHECKPOINT="$(realpath "$CHECKPOINT")" +export CUDA_VISIBLE_DEVICES="${GPUS:-0}" +export PYTHONPATH="$REPRO_ROOT/code" +PROMPTS="${PROMPTS:-$REPRO_ROOT/code/prompts/test_8.txt}" +OUTPUT_DIR="${OUTPUT_DIR:-$REPRO_ROOT/outputs/demo}" +PROMPTS="$(realpath "$PROMPTS")" +OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")" +cd "$REPRO_ROOT/code" +exec "$PYTHON_BIN" inference.py --config_path "$CONFIG" --checkpoint_path "$CHECKPOINT" \ + --data_path "$PROMPTS" --output_folder "$OUTPUT_DIR" \ + --num_output_frames 21 --num_samples 1 --seed 0 --save_with_index diff --git a/1.3B/scripts/prepare_config.py b/1.3B/scripts/prepare_config.py new file mode 100644 index 0000000000000000000000000000000000000000..c2c59a251e83ece0d280ebb96b3caef31502d16f --- /dev/null +++ b/1.3B/scripts/prepare_config.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Relocate asset paths, preserving every scientific setting and content hash.""" +import argparse +import json +from pathlib import Path +import shlex +import yaml +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "code")) +from utils.config import resolve_config_aliases +ORIGINAL_ROOT = '/path/to/video-generation-assets' + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--asset-root', required=True, type=Path, + help='Directory containing models/, checkpoints/, teacher_banks/, experiments/') + args = parser.parse_args() + original = ROOT / 'configs/three_encoder.yaml' + base = resolve_config_aliases(yaml.safe_load(original.read_text())) + asset_root = str(args.asset_root.expanduser().resolve()) + cfg = {k: asset_root + v[len(ORIGINAL_ROOT):] + if isinstance(v, str) and v.startswith(ORIGINAL_ROOT + '/') else v + for k, v in base.items()} + bank_path = Path(cfg["triple_teacher_bank_manifest"]) + if bank_path.is_file(): + bank = json.loads(bank_path.read_text()) + dino = bank["branches"]["dinov3"].get("feature_contract", {}) + if all(key in dino for key in ("repo_path", "weight_path")): + cfg["dinov3_reference_paths"] = {key: dino[key] for key in ("repo_path", "weight_path")} + for branch in ("vjepa2", "videomae_v2"): + record = bank["branches"][branch] + identity = record.get("feature_contract", record.get("model_identity", {})) + if identity.get("model_id"): + cfg[f"{branch}_reference_model_id"] = identity["model_id"] + # matched_video_root is an identity field; bank-based training does not decode it. + output = ROOT / 'configs/local.yaml' + output.write_text(yaml.safe_dump(cfg, sort_keys=False)) + (ROOT / 'configs/local.env').write_text( + 'export VIDEO_GENERATION_ASSET_ROOT=' + shlex.quote(asset_root) + '\n') + changes = {k: {'original': base.get(k), 'local': v} for k, v in cfg.items() if base.get(k) != v} + (ROOT / 'configs/path-remapping.json').write_text(json.dumps(changes, indent=2) + '\n') + print(f'Wrote {output}; {len(changes)} asset paths remapped, zero setting changes.') + +if __name__ == '__main__': + main() diff --git a/1.3B/scripts/test_cpu.py b/1.3B/scripts/test_cpu.py new file mode 100644 index 0000000000000000000000000000000000000000..6be758ab0f6aae920cadf12320543b27465d64d9 --- /dev/null +++ b/1.3B/scripts/test_cpu.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Run existing CPU tensor tests without initializing the historical T5 CUDA default.""" +import os +from pathlib import Path +import sys +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +os.environ['CUDA_VISIBLE_DEVICES'] = '' +os.environ.setdefault('OMP_NUM_THREADS', '2') +os.environ['PYTHONDONTWRITEBYTECODE'] = '1' +sys.dont_write_bytecode = True +sys.path.insert(0, str(ROOT/'code')) +os.chdir(ROOT/'code') +import torch +import pytest + +# Only bypass import-time device discovery. No model weights or CUDA tensors load. +# Original sources remain untouched; the patch is removed before executing tests. +with patch('torch.cuda.current_device', return_value=0): + from trainer.distillation import Trainer + from model.wan_kv_mmd import WanKVMMD + +raise SystemExit(pytest.main([ + '-q', '-p', 'no:cacheprovider', + 'tests/test_triple_partial_geometry.py', + 'tests/test_readable_config.py', + 'tests/test_triple_video_feature_bank.py', + 'tests/test_triple_feature_metadata_contract.py', + 'tests/test_triple_pixel_cotangent_accumulation.py', + 'tests/test_video_nystrom_partial_mmd.py', + 'tests/test_multi_representation_video_nystrom_mmd.py', +] + sys.argv[1:])) diff --git a/1.3B/scripts/train.sh b/1.3B/scripts/train.sh new file mode 100644 index 0000000000000000000000000000000000000000..4e5ef2b9a4368188279855aee49ea2ebdc4b394b --- /dev/null +++ b/1.3B/scripts/train.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +extra_args=() +case "${1:-}" in + "") ;; + --smoke) extra_args=(--max_steps 1) ;; + *) echo 'Usage: scripts/train.sh [--smoke]' >&2; exit 2 ;; +esac +[[ $# -le 1 ]] || { echo 'Too many arguments' >&2; exit 2; } +export CUDA_VISIBLE_DEVICES="${GPUS:-${CUDA_VISIBLE_DEVICES:-0,1,2,3}}" +LOGDIR="${LOGDIR:-$REPRO_ROOT/runs/three-encoder}" +LOGDIR="$(realpath -m "$LOGDIR")" +mkdir -p "$(dirname "$LOGDIR")" +mkdir -p "$LOGDIR" +cp "$CONFIG" "$LOGDIR/config.yaml" +export SF_FRESH_VIDEO_P0_REPLAY=1 SF_FRESH_VIDEO_PIXEL_CACHE_DEVICE=cpu +unset SF_FRESH_VIDEO_RETAINED_GRAPH SF_FRESH_VIDEO_RETAIN_WAN_GRAPH +export PYTHONPATH="$REPRO_ROOT/code" +cd "$REPRO_ROOT/code" +"$PYTHON_BIN" -m torch.distributed.run --standalone --nnodes=1 --nproc-per-node=4 train.py \ + --config_path "$LOGDIR/config.yaml" --logdir "$LOGDIR" \ + --disable-wandb --no_visualize "${extra_args[@]}" 2>&1 | tee "$LOGDIR/train.log" diff --git a/14B/.gitignore b/14B/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e2ba6c822cdee3f4a7944f66c18f6b1635d87045 --- /dev/null +++ b/14B/.gitignore @@ -0,0 +1,11 @@ +configs/local.yaml +configs/local.env +configs/path-remapping.json +runs/ +outputs/ +cache/ +assets/ +.venv*/ +__pycache__/ +.pytest_cache/ +*.pyc diff --git a/14B/README.md b/14B/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e5815d70d6a7c84dbbe1008851b51b57be7f2b90 --- /dev/null +++ b/14B/README.md @@ -0,0 +1,129 @@ +# Anonymous Video Generation — 14B + +[Project overview](../README.md) · [1.3B release](../1.3B/README.md) + +This directory provides the Wan2.1-T2V-14B implementation for **80 updates** of Anonymous Video Generation with **DINOv3, V-JEPA 2, and VideoMAEv2**. It includes the causal generator, frozen feature encoders, distribution-matching objective, gradient replay, CPU offload, checkpoint saving/resume, and inference code. + +This is the non-EMA checkpoint used for the human study and VLM evaluations. See [evaluated settings](docs/evaluated-settings.md) for the model identity and distinct sampling protocols. + +The step-80 recipe starts from the **causal ODE initialization**. It uses the first 80 updates of a 200-step cosine learning-rate schedule; shortening the learning-rate schedule to 80 steps changes the recipe. + +For an implementation walkthrough, see [Reading the code](../docs/paper-to-code.md). The configuration uses the paper's distribution and differentiation terminology; `code/utils/config.py` preserves legacy configuration compatibility. + +## 1. Environment + +Run the following commands from `14B/`. Use Linux, Python 3.10, and CUDA-capable GPUs with BF16 support. The original training used **8 H200 GPUs** with CPU offload and approximately **1 TB of host RAM** allocated. Inference uses one GPU; this release does not establish a minimum VRAM requirement. + +```bash +conda create -n video-generation-14b python=3.10 -y +conda activate video-generation-14b +python -m pip install torch==2.5.1 torchvision==0.20.1 \ + --index-url https://download.pytorch.org/whl/cu121 +python -m pip install -r requirements.txt +python -m pip install flash-attn==2.7.4.post1 --no-build-isolation +export PYTHON_BIN="$(command -v python)" +``` + +## 2. Models and Data + +See [Asset preparation](docs/assets.md) for the directory layout, frozen encoder revisions, and reference-preparation tools. + +Inference requires the Wan2.1-T2V-14B base model, shared T5/tokenizer/VAE, and a trained Anonymous Video Generation `model.pt`. Training additionally requires the original causal ODE initialization and the **8,000-video three-encoder reference collection**. These experiment-specific weights and reference assets are not bundled in this source repository; downloading the public Wan model alone does not reproduce the trained checkpoint. + +```bash +export ASSET_ROOT=/path/to/video-generation-assets +# Place the matching model files at the local paths described below. + +# After placing all training assets according to docs/assets.md: +python scripts/prepare_config.py --asset-root "$ASSET_ROOT" + +# For inference alone, omit the training-bank requirement: +# python scripts/prepare_config.py --asset-root "$ASSET_ROOT" --inference-only +``` + +The setup script writes `configs/local.yaml` and `configs/local.env`, which are excluded from Git. It preserves the frozen feature-bank identity while separating the VideoMAEv2 load location from the identity stored in its reference metadata. + +## 3. Inference + +```bash +GPUS=0 \ +CHECKPOINT=/path/to/checkpoint_model_000080/model.pt \ +PROMPTS=code/prompts/test_8.txt \ +OUTPUT_DIR=outputs/step80 \ +bash scripts/infer.sh +``` + +The default matches the previous inference run: seed `0`, **five samples per prompt**, regular generator weights, and **no classifier-free guidance**. All five samples' initial and transition noises are drawn together; the generator then executes one video at a time to limit memory. Set `NUM_SAMPLES=1` for a single-video demo; this changes the random-number sequence. The seed advances continuously across prompts. Exact output reproduction also requires the same prompt order and shard split. The supplied pipeline performs four causal denoising steps followed by a terminal `t=0` cleanup, and uses the training-matched context-cache lifetime. The auxiliary noise seed is separately fixed in `configs/inference.yaml`. + +The `21` latent frames requested by the launcher decode to **81 frames at 832 × 480 and 16 FPS**. + +## 4. Training + +After preparing the training assets and local configuration: + +```bash +GPUS=0,1,2,3,4,5,6,7 \ +LOGDIR=runs/three-encoder \ +bash scripts/train.sh +``` + +Training stops after update 80. Full model, optimizer, and per-rank random-state checkpoints are saved every 10 updates. Use `runs/three-encoder/checkpoint_model_000080/model.pt` for inference. Keep all companion files for resuming training. Use a fresh `LOGDIR` for each new experiment; eight full 14B checkpoints require substantial disk space. + +| Setting | Value | +|---|---| +| Generator | Wan2.1-T2V-14B, causal softmax attention | +| Initialization | Causal ODE checkpoint | +| GPUs | 8 | +| Distribution / differentiation / reference batch | 256 / 128 / 128 | +| Per-rank generated / differentiated videos | 32 / 16 | +| Physical generation / logical feature / physical replay batch | 2 / 16 / 1 | +| Reference collection | 8,000 videos | +| Frozen feature encoders | DINOv3 final block; V-JEPA 2 and VideoMAEv2 block index 15 | +| Loss aggregation | `(10 × DINOv3 + V-JEPA 2 + VideoMAEv2) / 3`, after per-branch normalization | +| Fixed / sampled reference attraction weights | 2 / 1 | +| Optimizer | AdamW, betas `(0, 0.999)`, weight decay `0` | +| Learning rate | Cosine `2.5e-6 → 5e-7` over **200** updates; stop at **80** | +| Generator gradient clipping | 10 | +| EMA | Disabled | +| Memory management | FSDP full sharding, activation checkpointing, optimizer/activation/frozen-encoder CPU offload | + +All three encoders are frozen, but their input gradients are retained for generator training. The original configuration uses `seed: 0`, which selects an effective seed at launch; set an explicit positive training seed for a repeatable new run. Matching the recipe alone does not recover the historical RNG state. + +For a one-update smoke test with the full batch geometry: + +```bash +LOGDIR=runs/smoke bash scripts/train.sh --smoke +``` + +Resume a checkpoint produced by this release using the same eight-rank topology and asset identities: + +```bash +python scripts/prepare_config.py --asset-root "$ASSET_ROOT" \ + --resume-checkpoint "$PWD/runs/three-encoder/checkpoint_model_000070/model.pt" +LOGDIR=runs/resumed bash scripts/train.sh +``` + +This continues through update 80. Historical checkpoints may contain deployment-specific resume identities; the inference loader accepts their generator weights, but exact training resume across repackaged identities is not guaranteed. + +To run in tmux after activating the environment: + +```bash +tmux new-session -s video-generation-14b +bash scripts/train.sh +# Detach: Ctrl-b, then d. Reattach: tmux attach -t video-generation-14b +``` + +## 5. Validation and Evaluation + +```bash +python -m pip install pytest +python scripts/test_cpu.py +``` + +The CPU suite covers three-encoder configuration and feature-bank loading, partial-gradient geometry, MMD gradients, replay, offload state restoration, terminal cleanup/context-cache inference, sample-batch noise ordering, and the 200-step cosine curve. See [validation notes](docs/validation.md) for the distinction between the original GPU run and checks of this source package. + +For VBench, supply the benchmark prompt list to `scripts/infer.sh` and evaluate the resulting videos with the official [VBench](https://github.com/Vchitect/VBench) implementation. A sample-video smoke test is not a benchmark reproduction. + +## Acknowledgments + +This implementation builds on [Self-Forcing](https://github.com/guandeh17/Self-Forcing), [Wan2.1](https://github.com/Wan-Video/Wan2.1), [DINOv3](https://github.com/facebookresearch/dinov3), [V-JEPA 2](https://github.com/facebookresearch/vjepa2), and [VideoMAEv2](https://github.com/OpenGVLab/VideoMAEv2). Upstream source notices are preserved in `code/LICENSE`; model weights and datasets retain their respective terms. diff --git a/14B/code/LICENSE b/14B/code/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64 --- /dev/null +++ b/14B/code/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/14B/code/configs/default_config.yaml b/14B/code/configs/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..05eea36aa2201448c6a12fb957b4b50da6ed8300 --- /dev/null +++ b/14B/code/configs/default_config.yaml @@ -0,0 +1,23 @@ +independent_first_frame: false +warp_denoising_step: false +weight_decay: 0.01 +same_step_across_blocks: true +discriminator_lr_multiplier: 1.0 +last_step_only: false +i2v: false +num_training_frames: 21 +gc_interval: 100 +context_noise: 0 +causal: true + +# Use one deterministic V-JEPA2 token subset for every update. +vjepa2_token_sampling: uniform_fixed + +ckpt_step: 0 +prompt_name: MovieGenVideoBench +prompt_path: prompts/MovieGenVideoBench.txt +eval_first_n: 64 +num_samples: 1 +height: 480 +width: 832 +num_frames: 81 diff --git a/14B/code/demo_utils/memory.py b/14B/code/demo_utils/memory.py new file mode 100644 index 0000000000000000000000000000000000000000..c9758df6dffaed70887f2ec3bf14d9fa49181c32 --- /dev/null +++ b/14B/code/demo_utils/memory.py @@ -0,0 +1,135 @@ +# Copied from https://github.com/lllyasviel/FramePack/tree/main/demo_utils +# Apache-2.0 License +# By lllyasviel + +import torch + + +cpu = torch.device('cpu') +gpu = torch.device(f'cuda:{torch.cuda.current_device()}') +gpu_complete_modules = [] + + +class DynamicSwapInstaller: + @staticmethod + def _install_module(module: torch.nn.Module, **kwargs): + original_class = module.__class__ + module.__dict__['forge_backup_original_class'] = original_class + + def hacked_get_attr(self, name: str): + if '_parameters' in self.__dict__: + _parameters = self.__dict__['_parameters'] + if name in _parameters: + p = _parameters[name] + if p is None: + return None + if p.__class__ == torch.nn.Parameter: + return torch.nn.Parameter(p.to(**kwargs), requires_grad=p.requires_grad) + else: + return p.to(**kwargs) + if '_buffers' in self.__dict__: + _buffers = self.__dict__['_buffers'] + if name in _buffers: + return _buffers[name].to(**kwargs) + return super(original_class, self).__getattr__(name) + + module.__class__ = type('DynamicSwap_' + original_class.__name__, (original_class,), { + '__getattr__': hacked_get_attr, + }) + + return + + @staticmethod + def _uninstall_module(module: torch.nn.Module): + if 'forge_backup_original_class' in module.__dict__: + module.__class__ = module.__dict__.pop('forge_backup_original_class') + return + + @staticmethod + def install_model(model: torch.nn.Module, **kwargs): + for m in model.modules(): + DynamicSwapInstaller._install_module(m, **kwargs) + return + + @staticmethod + def uninstall_model(model: torch.nn.Module): + for m in model.modules(): + DynamicSwapInstaller._uninstall_module(m) + return + + +def fake_diffusers_current_device(model: torch.nn.Module, target_device: torch.device): + if hasattr(model, 'scale_shift_table'): + model.scale_shift_table.data = model.scale_shift_table.data.to(target_device) + return + + for k, p in model.named_modules(): + if hasattr(p, 'weight'): + p.to(target_device) + return + + +def get_cuda_free_memory_gb(device=None): + if device is None: + device = gpu + + memory_stats = torch.cuda.memory_stats(device) + bytes_active = memory_stats['active_bytes.all.current'] + bytes_reserved = memory_stats['reserved_bytes.all.current'] + bytes_free_cuda, _ = torch.cuda.mem_get_info(device) + bytes_inactive_reserved = bytes_reserved - bytes_active + bytes_total_available = bytes_free_cuda + bytes_inactive_reserved + return bytes_total_available / (1024 ** 3) + + +def move_model_to_device_with_memory_preservation(model, target_device, preserved_memory_gb=0): + print(f'Moving {model.__class__.__name__} to {target_device} with preserved memory: {preserved_memory_gb} GB') + + for m in model.modules(): + if get_cuda_free_memory_gb(target_device) <= preserved_memory_gb: + torch.cuda.empty_cache() + return + + if hasattr(m, 'weight'): + m.to(device=target_device) + + model.to(device=target_device) + torch.cuda.empty_cache() + return + + +def offload_model_from_device_for_memory_preservation(model, target_device, preserved_memory_gb=0): + print(f'Offloading {model.__class__.__name__} from {target_device} to preserve memory: {preserved_memory_gb} GB') + + for m in model.modules(): + if get_cuda_free_memory_gb(target_device) >= preserved_memory_gb: + torch.cuda.empty_cache() + return + + if hasattr(m, 'weight'): + m.to(device=cpu) + + model.to(device=cpu) + torch.cuda.empty_cache() + return + + +def unload_complete_models(*args): + for m in gpu_complete_modules + list(args): + m.to(device=cpu) + print(f'Unloaded {m.__class__.__name__} as complete.') + + gpu_complete_modules.clear() + torch.cuda.empty_cache() + return + + +def load_model_as_complete(model, target_device, unload=True): + if unload: + unload_complete_models() + + model.to(device=target_device) + print(f'Loaded {model.__class__.__name__} to {target_device} as complete.') + + gpu_complete_modules.append(model) + return diff --git a/14B/code/inference.py b/14B/code/inference.py new file mode 100644 index 0000000000000000000000000000000000000000..de095d3f11d260aa28023715730e403f2b7a59eb --- /dev/null +++ b/14B/code/inference.py @@ -0,0 +1,259 @@ +import argparse +import torch +import os +from omegaconf import OmegaConf +from utils.config import load_config +from tqdm import tqdm +from torchvision import transforms +import imageio.v3 as iio # torchvision.io.write_video was removed in torchvision 0.26 +from einops import rearrange + + +def write_video(path: str, frames: torch.Tensor, fps: int = 16) -> None: + """Drop-in replacement for ``torchvision.io.write_video``. + + Accepts a ``[T, H, W, C]`` tensor in ``[0, 255]`` (float or uint8) and + writes it to ``path`` as an H.264 MP4 via the imageio-ffmpeg plugin. + """ + if frames.dtype != torch.uint8: + frames = frames.clamp(0, 255).to(torch.uint8) + iio.imwrite( + path, + frames.cpu().numpy(), + fps=fps, + codec="libx264", + plugin="FFMPEG", + ) +import torch.distributed as dist +from torch.utils.data import DataLoader, SequentialSampler +from torch.utils.data.distributed import DistributedSampler + +from pipeline import ( + CausalInferencePipeline, +) +from utils.dataset import TextDataset, TextImagePairDataset +from utils.misc import set_seed + +import demo_utils.memory as _demo_memory +from demo_utils.memory import get_cuda_free_memory_gb, DynamicSwapInstaller +from pipeline import causal_inference as _causal_inference_module + +parser = argparse.ArgumentParser() +parser.add_argument("--config_path", type=str, help="Path to the config file") +parser.add_argument("--checkpoint_path", type=str, help="Path to the checkpoint folder") +parser.add_argument("--data_path", type=str, help="Path to the dataset") +parser.add_argument("--extended_prompt_path", type=str, help="Path to the extended prompt") +parser.add_argument("--output_folder", type=str, help="Output folder") +parser.add_argument("--num_output_frames", type=int, default=21, + help="Number of overlap frames between sliding windows") +parser.add_argument("--i2v", action="store_true", help="Whether to perform I2V (or T2V by default)") +parser.add_argument("--use_ema", action="store_true", help="Whether to use EMA parameters") +parser.add_argument("--seed", type=int, default=0, help="Random seed") +parser.add_argument("--num_samples", type=int, default=1, help="Number of samples to generate per prompt") +parser.add_argument("--save_with_index", action="store_true", + help="Whether to save the video using the index or prompt as the filename") +args = parser.parse_args() + +# Initialize distributed inference +if "LOCAL_RANK" in os.environ: + dist.init_process_group(backend='nccl') + local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(local_rank) + device = torch.device(f"cuda:{local_rank}") + world_size = dist.get_world_size() + set_seed(args.seed + local_rank) +else: + device = torch.device("cuda") + local_rank = 0 + world_size = 1 + set_seed(args.seed) + +# ``demo_utils.memory.gpu`` is computed at import time as +# ``cuda: `` and is also re-exported into +# ``pipeline.causal_inference``. In a distributed launch every rank imports +# before ``set_device`` runs, so they all see ``cuda:0`` and end up loading +# every model onto rank 0's GPU. Patch both module-level bindings so the +# downstream ``.to(device=gpu)`` calls land on the per-rank device. +_demo_memory.gpu = device +_causal_inference_module.gpu = device +gpu = device + +print(f'[rank {local_rank}] Free VRAM on {device}: {get_cuda_free_memory_gb(gpu)} GB') +low_memory = get_cuda_free_memory_gb(gpu) < 40 + +torch.set_grad_enabled(False) + +config = load_config(args.config_path, "configs/default_config.yaml") + +# Initialize pipeline +pipeline = CausalInferencePipeline(config, device=device) + +def _strip_wrapper_prefixes(state_dict): + """Strip FSDP / activation-checkpointing / torch.compile wrapper prefixes + that may be embedded in saved state_dict keys. ``EMA_FSDP.state_dict()`` + in particular keeps ``_fsdp_wrapped_module.`` prefixes.""" + out = {} + for k, v in state_dict.items(): + new_k = (k.replace("_fsdp_wrapped_module.", "") + .replace("_checkpoint_wrapped_module.", "") + .replace("_orig_mod.", "")) + out[new_k] = v + return out + + +if args.checkpoint_path: + raw_state_dict = torch.load(args.checkpoint_path, map_location="cpu", weights_only=False) + key = "generator_ema" if args.use_ema else "generator" + if key not in raw_state_dict: + available = list(raw_state_dict.keys()) + raise KeyError( + f"Checkpoint {args.checkpoint_path!r} has no '{key}' entry " + f"(found: {available}). Pass --use_ema only for DMD checkpoints saved " + f"after ema_start_step." + ) + cleaned = _strip_wrapper_prefixes(raw_state_dict[key]) + missing, unexpected = pipeline.generator.load_state_dict(cleaned, strict=False) + # ``kernel_projection`` is registered persistent=False, so it is NEVER in + # the saved state_dict and we MUST tolerate it being missing here. Any + # other missing/unexpected key indicates a real architecture mismatch and + # should fail loudly. + expected_missing = {n for n in missing if n.endswith(".kernel_projection")} + real_missing = [n for n in missing if n not in expected_missing] + if real_missing or unexpected: + raise RuntimeError( + f"Unexpected state-dict mismatch loading {args.checkpoint_path!r} " + f"(key='{key}'): missing={real_missing}, unexpected={unexpected}" + ) + if local_rank == 0: + skipped = len(missing) - len(real_missing) + print( + f"Loaded {args.checkpoint_path!r} (key='{key}', " + f"{len(cleaned)} tensors; skipped {skipped} non-persistent buffers)" + ) + +if args.checkpoint_path: + del raw_state_dict, cleaned +pipeline = pipeline.to(dtype=torch.bfloat16) +if low_memory: + DynamicSwapInstaller.install_model(pipeline.text_encoder, device=gpu) +else: + pipeline.text_encoder.to(device=gpu) +pipeline.generator.to(device=gpu) +pipeline.vae.to(device=gpu) + +# Preserve the evaluated sample-batch noise draws while executing one video at a time. +from utils.inference_microbatch import install_microbatch_adapter +install_microbatch_adapter(pipeline) + + +# Create dataset +if args.i2v: + assert not dist.is_initialized(), "I2V does not support distributed inference yet" + transform = transforms.Compose([ + transforms.Resize((480, 832)), + transforms.ToTensor(), + transforms.Normalize([0.5], [0.5]) + ]) + dataset = TextImagePairDataset(args.data_path, transform=transform) +else: + dataset = TextDataset(prompt_path=args.data_path, extended_prompt_path=args.extended_prompt_path) +num_prompts = len(dataset) +print(f"Number of prompts: {num_prompts}") + +if dist.is_initialized(): + sampler = DistributedSampler(dataset, shuffle=False, drop_last=True) +else: + sampler = SequentialSampler(dataset) +dataloader = DataLoader(dataset, batch_size=1, sampler=sampler, num_workers=0, drop_last=False) + +# Create output directory (only on main process to avoid race conditions) +if local_rank == 0: + os.makedirs(args.output_folder, exist_ok=True) + +if dist.is_initialized(): + dist.barrier() + + +def encode(self, videos: torch.Tensor) -> torch.Tensor: + device, dtype = videos[0].device, videos[0].dtype + scale = [self.mean.to(device=device, dtype=dtype), + 1.0 / self.std.to(device=device, dtype=dtype)] + output = [ + self.model.encode(u.unsqueeze(0), scale).float().squeeze(0) + for u in videos + ] + + output = torch.stack(output, dim=0) + return output + + +for i, batch_data in tqdm(enumerate(dataloader), disable=(local_rank != 0)): + idx = batch_data['idx'].item() + + # For DataLoader batch_size=1, the batch_data is already a single item, but in a batch container + # Unpack the batch data for convenience + if isinstance(batch_data, dict): + batch = batch_data + elif isinstance(batch_data, list): + batch = batch_data[0] # First (and only) item in the batch + + all_video = [] + num_generated_frames = 0 # Number of generated (latent) frames + + if args.i2v: + # For image-to-video, batch contains image and caption + prompt = batch['prompts'][0] # Get caption from batch + prompts = [prompt] * args.num_samples + + # Process the image + image = batch['image'].squeeze(0).unsqueeze(0).unsqueeze(2).to(device=device, dtype=torch.bfloat16) + + # Encode the input image as the first latent + initial_latent = pipeline.vae.encode_to_latent(image).to(device=device, dtype=torch.bfloat16) + initial_latent = initial_latent.repeat(args.num_samples, 1, 1, 1, 1) + + sampled_noise = torch.randn( + [args.num_samples, args.num_output_frames - 1, 16, 60, 104], device=device, dtype=torch.bfloat16 + ) + else: + # For text-to-video, batch is just the text prompt + prompt = batch['prompts'][0] + extended_prompt = batch['extended_prompts'][0] if 'extended_prompts' in batch else None + if extended_prompt is not None: + prompts = [extended_prompt] * args.num_samples + else: + prompts = [prompt] * args.num_samples + initial_latent = None + + sampled_noise = torch.randn( + [args.num_samples, args.num_output_frames, 16, 60, 104], device=device, dtype=torch.bfloat16 + ) + + # Generate 81 frames + video, latents = pipeline.inference( + noise=sampled_noise, + text_prompts=prompts, + return_latents=True, + initial_latent=initial_latent, + low_memory=low_memory, + ) + current_video = rearrange(video, 'b t c h w -> b t h w c').cpu() + all_video.append(current_video) + num_generated_frames += latents.shape[1] + + # Final output video + video = 255.0 * torch.cat(all_video, dim=1) + + # Clear VAE cache + pipeline.vae.model.clear_cache() + + # Save the video if the current prompt is not a dummy prompt + if idx < num_prompts: + model = "regular" if not args.use_ema else "ema" + for seed_idx in range(args.num_samples): + # All processes save their videos + if args.save_with_index: + output_path = os.path.join(args.output_folder, f'{idx}-{seed_idx}_{model}.mp4') + else: + output_path = os.path.join(args.output_folder, f'{prompt[:100]}-{seed_idx}.mp4') + write_video(output_path, video[seed_idx], fps=16) diff --git a/14B/code/losses/__init__.py b/14B/code/losses/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..59bda5465a1050ad53ff5652374368dbad43ce16 --- /dev/null +++ b/14B/code/losses/__init__.py @@ -0,0 +1,3 @@ +"""Distribution objectives used by distribution matching.""" + +__all__ = [] diff --git a/14B/code/losses/multi_encoder_mmd.py b/14B/code/losses/multi_encoder_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..9758a6f869a2b60d5f69f80a5b13058662187690 --- /dev/null +++ b/14B/code/losses/multi_encoder_mmd.py @@ -0,0 +1,292 @@ +"""Combine distribution-matching losses across frozen video encoders. + +Each encoder defines its own video feature space and Gaussian kernel. Compute +one MMD per encoder, apply the release's detached per-encoder loss scaling, +then apply the configured weights and aggregation divisor. ``VideoNystromMMD`` +implements generated repulsion and persistent/minibatch reference attraction. +""" + +from __future__ import annotations + +import math +from collections import OrderedDict +from typing import Any, Mapping, Optional, Sequence + +import torch +from torch import nn + +from losses.video_nystrom_mmd import VideoNystromMMD + + +def normalize_mmd_with_detached_scale( + raw_mmd: torch.Tensor, + normalization_eps: float = 1.0e-4, +) -> tuple[torch.Tensor, torch.Tensor]: + """Return M / (stop_gradient(M) + eps) and its detached scale. + + Detaching only the denominator preserves gradients through M while + balancing the different numerical scales of the encoder objectives. + This release-specific scaling is additional to the paper's MMD objective; + the expression deliberately has no absolute value or clamping. + """ + if not isinstance(raw_mmd, torch.Tensor) or raw_mmd.numel() != 1: + raise TypeError("raw_mmd must be a scalar tensor") + if not math.isfinite(float(normalization_eps)) or normalization_eps <= 0: + raise ValueError("normalization_eps must be finite and positive") + denominator = raw_mmd.detach() + float(normalization_eps) + return raw_mmd / denominator, denominator + + +class MultiEncoderMMD(nn.Module): + """Weighted distribution matching in multiple frozen feature spaces. + + A branch is one encoder, its kernel bandwidth, and its persistent Nyström + reference. For raw branch losses M_e, the combined objective is + sum_e weight_e * M_e / (stop_gradient(M_e) + eps), divided by + ``aggregation_divisor``. That divisor is explicit; it need not equal the + number of branches or the sum of weights. + """ + + def __init__( + self, + branches: Mapping[str, VideoNystromMMD], + *, + weights: Optional[Mapping[str, float]] = None, + normalization_eps: float = 1.0e-4, + aggregation_divisor: float = 1.0, + ) -> None: + super().__init__() + if not isinstance(branches, Mapping) or not branches: + raise ValueError("branches must be a non-empty mapping") + if not math.isfinite(float(normalization_eps)) or normalization_eps <= 0: + raise ValueError("normalization_eps must be finite and positive") + if ( + not math.isfinite(float(aggregation_divisor)) + or aggregation_divisor <= 0 + ): + raise ValueError("aggregation_divisor must be finite and positive") + ordered = OrderedDict() + current_teacher_modes = set() + for name, objective in branches.items(): + if not isinstance(name, str) or not name: + raise ValueError("branch names must be non-empty strings") + if not isinstance(objective, VideoNystromMMD): + raise TypeError( + f"branch {name!r} must be a VideoNystromMMD instance") + current_teacher_modes.add(bool( + objective.current_teacher_attraction_enabled)) + if any(parameter.requires_grad for parameter in objective.parameters()): + raise ValueError(f"branch {name!r} reference must be frozen") + ordered[name] = objective + if len(current_teacher_modes) != 1: + raise ValueError( + "all branches must agree on current-teacher attraction mode") + self.branches = nn.ModuleDict(ordered) + configured_weights = weights or {name: 1.0 for name in ordered} + if set(configured_weights) != set(ordered): + raise ValueError("branch weights must exactly match branch names") + self.branch_weights = {} + for name in ordered: + weight = float(configured_weights[name]) + if not math.isfinite(weight) or weight < 0.0: + raise ValueError( + f"branch weight for {name!r} must be finite and non-negative") + self.branch_weights[name] = weight + if sum(self.branch_weights.values()) <= 0.0: + raise ValueError("at least one branch weight must be positive") + self.normalization_eps = float(normalization_eps) + self.aggregation_divisor = float(aggregation_divisor) + self.current_teacher_attraction_enabled = current_teacher_modes.pop() + self.attraction_ema_enabled = False + self.repulsion_fifo_enabled = False + + @property + def branch_names(self) -> tuple[str, ...]: + return tuple(self.branches.keys()) + + @property + def sample_shapes(self) -> dict[str, tuple[int, int]]: + return { + name: objective.sample_shape + for name, objective in self.branches.items() + } + + @property + def landmarks(self) -> torch.Tensor: + """Legacy diagnostics use the first branch; branch logs stay exact.""" + return self.branches[self.branch_names[0]].landmarks + + @property + def branch_registry(self) -> dict[str, dict[str, Any]]: + return { + name: { + "sample_shape": objective.sample_shape, + "reference_hash": objective.reference_hash, + "weight": self.branch_weights[name], + } + for name, objective in self.branches.items() + } + + def _validate_mapping( + self, + value: Optional[Mapping[str, torch.Tensor]], + label: str, + *, + required: bool, + ) -> Optional[Mapping[str, torch.Tensor]]: + if value is None: + if required: + raise ValueError(f"{label} is required") + return None + if not isinstance(value, Mapping): + raise TypeError(f"{label} must be a branch tensor mapping") + if set(value) != set(self.branches): + raise ValueError( + f"{label} branch keys differ: got {sorted(value)}, " + f"expected {sorted(self.branches)}") + return value + + def forward( + self, + generated: Optional[Mapping[str, torch.Tensor]] = None, + current_teacher: Optional[Mapping[str, torch.Tensor]] = None, + *, + gradient_students: Optional[Mapping[str, torch.Tensor]] = None, + student_reference: Optional[Mapping[str, torch.Tensor]] = None, + gradient_student_indices: Optional[ + torch.Tensor | Sequence[int] + ] = None, + stage: bool = True, + ) -> tuple[torch.Tensor, dict[str, Any]]: + """Evaluate each encoder loss, then balance and combine their scales. + + ``gradient_students`` selects generated samples that retain gradients; + ``student_reference`` holds the full generated feature population. + ``current_teacher`` is the current frozen reference minibatch. + These input names are retained for existing callers and checkpoints. + """ + # Select full-gradient or cached partial-gradient inputs. Every encoder + # receives the same sample selection. + partial = gradient_students is not None or student_reference is not None + generated = self._validate_mapping( + generated, "generated", required=not partial) + gradient_students = self._validate_mapping( + gradient_students, "gradient_students", required=partial) + student_reference = self._validate_mapping( + student_reference, "student_reference", required=partial) + current_teacher = self._validate_mapping( + current_teacher, + "current_teacher", + required=self.current_teacher_attraction_enabled, + ) + if ( + not self.current_teacher_attraction_enabled + and current_teacher is not None + ): + raise ValueError( + "current_teacher was provided while every branch has " + "current-teacher attraction disabled") + + # Each feature space has its own kernel objective; encoder features + # and persistent reference landmarks remain separate. + normalized_terms = [] + branch_metadata: dict[str, dict[str, Any]] = {} + for name, objective in self.branches.items(): + if partial: + raw, metadata = objective( + gradient_students=gradient_students[name], + student_reference=student_reference[name], + current_teacher=( + current_teacher[name] + if current_teacher is not None else None + ), + gradient_student_indices=gradient_student_indices, + stage=stage, + ) + else: + raw, metadata = objective( + generated[name], + current_teacher=( + current_teacher[name] + if current_teacher is not None else None + ), + stage=stage, + ) + # Balance the branch scale before applying its configured weight. + normalized, denominator = normalize_mmd_with_detached_scale( + raw, self.normalization_eps) + weighted = self.branch_weights[name] * normalized + normalized_terms.append(weighted) + branch_metadata[name] = { + **metadata, + "raw_mmd": raw.detach(), + "normalization_denominator": denominator.detach(), + "normalized_mmd": normalized.detach(), + "branch_weight": raw.new_tensor(self.branch_weights[name]), + "weighted_normalized_mmd": weighted.detach(), + "sample_shape": objective.sample_shape, + "reference_hash": objective.reference_hash, + } + # Preserve the explicit reduction order and configured divisor. + total = ( + torch.stack(normalized_terms).sum() / self.aggregation_divisor + ) + # Detached values below are diagnostics; ``total`` retains the graph. + first = branch_metadata[self.branch_names[0]] + metadata = { + "branches": branch_metadata, + "branch_names": self.branch_names, + "raw_mmd_sum": torch.stack([ + branch_metadata[name]["raw_mmd"] for name in self.branch_names + ]).sum(), + "normalized_mmd_sum": total.detach(), + "normalization_eps": total.new_tensor(self.normalization_eps), + "normalization": "raw_div_stop_gradient_raw_plus_eps", + "aggregation": "weighted_sum_then_divide", + "aggregation_divisor": total.new_tensor( + self.aggregation_divisor), + # Shared batch counts are used by feature-gradient replay; + # per-encoder diagnostics remain available under ``branches``. + "global_samples": first["global_samples"], + "gradient_student_samples": first.get( + "gradient_student_samples", first["global_samples"]), + "repulsion_denominator": first.get("repulsion_denominator"), + "repulsion": torch.stack([ + branch_metadata[name]["repulsion"] + for name in self.branch_names + ]).sum(), + "attraction": torch.stack([ + branch_metadata[name]["attraction"] + for name in self.branch_names + ]).sum(), + "attraction_weight": total.new_ones(()), + "reference_constant": torch.stack([ + branch_metadata[name]["reference_constant"] + for name in self.branch_names + ]).sum(), + } + return total, metadata + + def prepare_commit(self) -> None: + for objective in self.branches.values(): + objective.prepare_commit() + + def commit(self) -> None: + for objective in self.branches.values(): + objective.commit() + + def discard(self) -> None: + for objective in self.branches.values(): + objective.discard() + + +# Historical imports and serialized class references use these aliases. +MultiRepresentationVideoNystromMMD = MultiEncoderMMD +fd_normalize_raw_mmd = normalize_mmd_with_detached_scale + +__all__ = [ + "MultiEncoderMMD", + "normalize_mmd_with_detached_scale", + "MultiRepresentationVideoNystromMMD", + "fd_normalize_raw_mmd", +] diff --git a/14B/code/losses/multi_representation_video_nystrom_mmd.py b/14B/code/losses/multi_representation_video_nystrom_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..14ec7d0d98c75f1b32d1ca07bb0ce2e0f652c1d3 --- /dev/null +++ b/14B/code/losses/multi_representation_video_nystrom_mmd.py @@ -0,0 +1,7 @@ +"""Compatibility module; read ``losses.multi_encoder_mmd`` for the implementation.""" + +import sys +from losses import multi_encoder_mmd as _implementation + +# Reuse the module itself so existing imports and module-level patches agree. +sys.modules[__name__] = _implementation diff --git a/14B/code/losses/multilayer_video_nystrom_mmd.py b/14B/code/losses/multilayer_video_nystrom_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..032004521f443790aca7e85dce73fda4b8fcbaeb --- /dev/null +++ b/14B/code/losses/multilayer_video_nystrom_mmd.py @@ -0,0 +1,381 @@ +"""Independent normalized Nyström MMD objectives over multiple video layers. + +The V-JEPA2 caller concatenates the sampled dense matrices from every selected +layer along the token axis. This module splits that transport tensor back into +its layer-local matrices, evaluates one frozen-reference MMD per layer, applies +detached scalar self-normalization independently, and sums the results. + +Keeping the objectives separate is important: concatenating layer features +inside one kernel would change both the bandwidth and the teacher reference +distribution instead of giving each layer equal normalized influence. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from typing import Any + +import torch +from torch import nn + +from losses.video_nystrom_mmd import VideoNystromMMD + + +class MultiLayerVideoNystromMMD(nn.Module): + """Sum independent, optionally self-normalized per-layer video MMDs.""" + + def __init__( + self, + objectives: Mapping[int, VideoNystromMMD], + *, + self_normalize: bool = True, + norm_eps: float = 1.0e-4, + layer_weights: Mapping[int, float] | None = None, + ) -> None: + super().__init__() + if not isinstance(objectives, Mapping) or not objectives: + raise ValueError("objectives must be a non-empty layer mapping") + ordered_layers = tuple(int(layer) for layer in objectives) + if len(set(ordered_layers)) != len(ordered_layers): + raise ValueError("objective layer indices must be unique") + if any(layer < 0 for layer in ordered_layers): + raise ValueError("objective layer indices must be non-negative") + if tuple(sorted(ordered_layers)) != ordered_layers: + raise ValueError("objective layer indices must be sorted") + if ( + not math.isfinite(float(norm_eps)) + or float(norm_eps) <= 0.0 + ): + raise ValueError("norm_eps must be finite and positive") + + weights = ( + {layer: 1.0 for layer in ordered_layers} + if layer_weights is None + else {int(layer): float(value) + for layer, value in layer_weights.items()} + ) + if set(weights) != set(ordered_layers): + raise ValueError( + "layer_weights must contain exactly the objective layers") + if any( + not math.isfinite(value) or value < 0.0 + for value in weights.values() + ): + raise ValueError( + "layer weights must be finite and non-negative") + + modules = {} + sample_shapes = [] + for layer in ordered_layers: + objective = objectives[layer] + if not isinstance(objective, VideoNystromMMD): + raise TypeError( + "every layer objective must be VideoNystromMMD") + if any( + parameter.requires_grad + for parameter in objective.parameters() + ): + raise ValueError( + "Nyström reference objectives must remain frozen") + modules[str(layer)] = objective + sample_shapes.append(objective.sample_shape) + feature_dims = {shape[1] for shape in sample_shapes} + if len(feature_dims) != 1: + raise ValueError( + "all layer objectives must share one feature dimension") + + self.objectives = nn.ModuleDict(modules) + self.layer_indices = ordered_layers + self.layer_token_counts = tuple( + int(shape[0]) for shape in sample_shapes) + self.feature_dim = int(sample_shapes[0][1]) + self.self_normalize = bool(self_normalize) + self.norm_eps = float(norm_eps) + self.layer_weights = tuple( + float(weights[layer]) for layer in ordered_layers) + ema_settings = { + ( + objective.attraction_ema_enabled, + objective.attraction_ema_beta, + ) + for objective in self.objectives.values() + } + if len(ema_settings) != 1: + raise ValueError( + "all layer objectives must use the same attraction EMA " + "enabled/beta setting") + ( + self.attraction_ema_enabled, + self.attraction_ema_beta, + ) = next(iter(ema_settings)) + + @property + def sample_shape(self) -> tuple[int, int]: + """Shape of the concatenated GradCache transport matrix.""" + return sum(self.layer_token_counts), self.feature_dim + + @property + def landmarks(self) -> torch.Tensor: + """Compatibility device/landmark-count view used by GradCache logs.""" + return self.objectives[str(self.layer_indices[0])].landmarks + + @property + def reference_hashes(self) -> tuple[str, ...]: + return tuple( + str(self.objectives[str(layer)].reference_hash) + for layer in self.layer_indices + ) + + @property + def reference_metadata(self) -> tuple[Mapping[str, Any], ...]: + return tuple( + self.objectives[str(layer)].reference_metadata + for layer in self.layer_indices + ) + + @property + def attraction_ema_pending(self) -> bool: + pending = tuple( + objective.attraction_ema_pending + for objective in self.objectives.values() + ) + if pending and any(value != pending[0] for value in pending[1:]): + raise RuntimeError( + "per-layer attraction EMA pending state is inconsistent") + return bool(pending[0]) if pending else False + + def split_features( + self, + generated: torch.Tensor, + ) -> tuple[torch.Tensor, ...]: + if not isinstance(generated, torch.Tensor): + raise TypeError("generated must be a torch.Tensor") + if ( + generated.ndim != 3 + or min(generated.shape) <= 0 + or not generated.is_floating_point() + ): + raise ValueError( + "generated must be a non-empty floating [B,N,D] tensor") + if not bool(torch.isfinite(generated.detach()).all().item()): + raise FloatingPointError( + "generated contains non-finite values") + expected_tokens, expected_dim = self.sample_shape + actual_shape = ( + int(generated.shape[1]), int(generated.shape[2])) + if actual_shape != (expected_tokens, expected_dim): + raise ValueError( + "concatenated multi-layer video matrix shape does not match " + f"the frozen references: {actual_shape} != " + f"{(expected_tokens, expected_dim)}") + return tuple(torch.split( + generated, self.layer_token_counts, dim=1)) + + def forward( + self, + generated: torch.Tensor, + *, + stage: bool = True, + ) -> tuple[torch.Tensor, dict[str, Any]]: + layer_features = self.split_features(generated) + total = generated.new_zeros((), dtype=torch.float32) + raw_sum = total.clone() + repulsion_sum = total.clone() + attraction_sum = total.clone() + current_attraction_sum = total.clone() + reference_constant_sum = total.clone() + layer_metadata: dict[str, dict[str, torch.Tensor]] = {} + global_samples = None + ema_updates = None + attraction_current_coefficient = None + + for layer, weight, features in zip( + self.layer_indices, + self.layer_weights, + layer_features, + ): + objective = self.objectives[str(layer)] + raw_mmd, metadata = ( + objective(features, stage=stage) + if self.attraction_ema_enabled + else objective(features) + ) + if self.self_normalize: + # Nyström attraction is approximate, so its finite estimate + # can be slightly negative even though exact squared MMD is + # non-negative. Normalize by detached magnitude to avoid both + # a singularity near -eps and an accidental gradient reversal. + denominator = raw_mmd.detach().abs() + self.norm_eps + if ( + not bool(torch.isfinite(denominator).item()) + or float(denominator) == 0.0 + ): + raise FloatingPointError( + f"layer {layer} has an invalid MMD normalization " + "denominator") + normalized_mmd = raw_mmd / denominator + else: + normalized_mmd = raw_mmd + weighted_mmd = float(weight) * normalized_mmd + total = total + weighted_mmd + raw_sum = raw_sum + raw_mmd.detach() + repulsion_sum = repulsion_sum + metadata["repulsion"] + attraction_sum = attraction_sum + metadata["attraction"] + if self.attraction_ema_enabled: + current_attraction_sum = ( + current_attraction_sum + metadata[ + "current_attraction"]) + reference_constant_sum = ( + reference_constant_sum + metadata["reference_constant"]) + observed_global_samples = int(metadata["global_samples"]) + if global_samples is None: + global_samples = observed_global_samples + elif observed_global_samples != global_samples: + raise RuntimeError( + "per-layer MMD objectives disagree on global batch size") + if self.attraction_ema_enabled: + observed_updates = int( + metadata["attraction_ema_updates"]) + if ema_updates is None: + ema_updates = observed_updates + elif observed_updates != ema_updates: + raise RuntimeError( + "per-layer attraction EMA update counts disagree") + observed_coefficient = float( + metadata["attraction_current_coefficient"]) + if attraction_current_coefficient is None: + attraction_current_coefficient = observed_coefficient + elif observed_coefficient != attraction_current_coefficient: + raise RuntimeError( + "per-layer attraction EMA coefficients disagree") + layer_metadata[str(layer)] = { + **metadata, + "raw_mmd": raw_mmd.detach(), + "normalized_mmd": normalized_mmd.detach(), + "weighted_mmd": weighted_mmd.detach(), + "weight": raw_mmd.new_tensor(float(weight)), + } + + if not bool(torch.isfinite(total.detach()).item()): + raise FloatingPointError( + "combined multi-layer MMD loss is non-finite") + combined_metadata = { + "layers": layer_metadata, + "layer_indices": torch.tensor( + self.layer_indices, + device=total.device, + dtype=torch.long, + ), + "num_layers": total.new_tensor( + len(self.layer_indices), dtype=torch.long), + "self_normalized": total.new_tensor( + int(self.self_normalize), dtype=torch.long), + "normalization_epsilon": total.new_tensor(self.norm_eps), + "raw_mmd_sum": raw_sum.detach(), + "normalized_mmd_sum": total.detach(), + # Aggregated raw-kernel terms preserve the existing generic log + # contract. Per-layer diagnostics above are authoritative. + "repulsion": repulsion_sum.detach(), + "attraction": attraction_sum.detach(), + "reference_constant": reference_constant_sum.detach(), + "global_samples": total.new_tensor( + int(global_samples), dtype=torch.long), + } + if self.attraction_ema_enabled: + combined_metadata.update({ + "current_attraction": current_attraction_sum.detach(), + "attraction_ema_enabled": total.new_ones( + (), dtype=torch.long), + "attraction_ema_updates": total.new_tensor( + int(ema_updates), dtype=torch.long), + "attraction_current_coefficient": total.new_tensor( + float(attraction_current_coefficient)), + }) + return total, combined_metadata + + def prepare_commit(self) -> None: + """Validate every layer before publishing any EMA scalar.""" + if not self.attraction_ema_enabled: + return + for objective in self.objectives.values(): + objective.prepare_commit() + + def commit(self) -> None: + """Publish one attraction EMA update for every layer.""" + if not self.attraction_ema_enabled: + return + if not self.attraction_ema_pending: + return + self.prepare_commit() + for objective in self.objectives.values(): + objective.commit() + + def discard(self) -> None: + for objective in self.objectives.values(): + objective.discard() + + def attraction_ema_state_dict(self) -> Mapping[str, Any] | None: + if not self.attraction_ema_enabled: + return None + if self.attraction_ema_pending: + raise RuntimeError( + "refusing to checkpoint pending per-layer attraction EMA") + return { + "version": 1, + "layers": list(self.layer_indices), + "states": { + str(layer): self.objectives[ + str(layer)].attraction_ema_state_dict() + for layer in self.layer_indices + }, + } + + def validate_attraction_ema_state_dict( + self, + state: Mapping[str, Any], + ) -> Mapping[str, Any]: + if not self.attraction_ema_enabled: + raise ValueError( + "cannot load attraction EMA state while the path is disabled") + if ( + not isinstance(state, Mapping) + or set(state) != {"version", "layers", "states"} + or state.get("version") != 1 + or tuple(state.get("layers", ())) != self.layer_indices + or not isinstance(state.get("states"), Mapping) + or set(state["states"]) + != {str(layer) for layer in self.layer_indices} + ): + raise ValueError( + "multi-layer attraction EMA checkpoint schema mismatch") + + # Validate every layer before the first live scalar is mutated. + validated = {} + for layer in self.layer_indices: + objective = self.objectives[str(layer)] + validated[str(layer)] = ( + objective.validate_attraction_ema_state_dict( + state["states"][str(layer)]) + ) + update_counts = { + int(layer_state["updates"].item()) + for layer_state in validated.values() + } + if len(update_counts) != 1: + raise ValueError( + "multi-layer attraction EMA update counts disagree") + return { + "version": 1, + "layers": list(self.layer_indices), + "states": validated, + } + + def load_attraction_ema_state_dict( + self, + state: Mapping[str, Any], + ) -> None: + validated = self.validate_attraction_ema_state_dict(state) + for layer in self.layer_indices: + self.objectives[str(layer)].load_attraction_ema_state_dict( + validated["states"][str(layer)]) diff --git a/14B/code/losses/video_nystrom_mmd.py b/14B/code/losses/video_nystrom_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..40af55370065ca5d79a29f0f57fa7e0de2a9fd04 --- /dev/null +++ b/14B/code/losses/video_nystrom_mmd.py @@ -0,0 +1,1780 @@ +"""Single-encoder distribution matching: exact repulsion and reference attraction. + +Reading map: offline helpers build the persistent Nyström reference; training +starts at ``VideoNystromMMD.forward``. Paper Sec. 3.2 calls its combination +with the Monte Carlo reference minibatch the hybrid reference kernel mean. +The released multi-encoder path uses full-population partial gradients with +FIFO and attraction EMA disabled; those optional modes are retained below. + +The released estimator includes diagonal pairs (B * B denominator) and the +reference-reference constant. This differs from the paper's self-excluding +repulsion and constant-free optimization expression; preserve both here, +including their effect on the release's subsequent detached loss scaling. + +Every input sample is a complete ``[N, D]`` dense-video feature matrix. For +generated samples ``G``, frozen landmarks ``Z`` and a frozen reference pack, +the legacy objective is + + mean(K_gg) - 2 * mean(K_gz @ alpha) + k_rr. + +An opt-in matched-teacher path additionally accepts a fresh teacher batch +``T``. It computes the complete biased Gaussian-kernel terms ``mean(K_gt)`` +and ``mean(K_tt)`` without sampling or approximation. The fixed-reference and +fresh-teacher MMDs are combined with normalized non-negative group weights, so +enabling a second teacher target does not duplicate the student repulsion or +silently double the total attraction coefficient. + +The explicit partial-gradient interface uses gradient students ``G`` and a +detached full student population ``S``. Without explicit student indices it +preserves the rectangular estimator ``mean K(G,S)`` and evaluates attractions +only for ``G``. Supplying the positions of ``G`` in ``S`` opts into the +full-student estimator: selected differentiable values are reinserted into +detached ``S``, then the loss uses ``mean K(S,S)``, fixed attraction over +``S``, and matched-teacher attraction over ``S x T``. Thus only ``G`` gets +gradients, including both sides of the full self-kernel. Both partial modes +reject FIFO and attraction EMA because either stateful transform would change +their stated estimators. + +By default, ``K_gg`` is the exact, biased Gaussian-RBF Gram matrix (including +its diagonal). An optional transactional FIFO changes only this repulsion +term to the current-current off-diagonal sum plus the current-history cross +sum, normalized by the exact number of ordered pairs. FIFO entries are +detached FP32 feature matrices and are published only after optimizer success. +Only the generated samples receive gradients. The reference attraction is +represented by a kernel-ridge/Nyström fit and ``k_rr`` is the exact biased +reference-reference constant. + +An optional transactional path applies an EMA to the complete scalar +attraction term only. The exact current-batch repulsion is never averaged. +The current attraction keeps its natural ``1-beta`` derivative after the +first committed update; historical attraction values are detached. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import math +import os +import tempfile +from collections.abc import Mapping, Sequence +from typing import Any, Dict, Optional, Tuple + +import torch +from torch import nn + + +REFERENCE_PACK_VERSION = 1 +_PACK_KEYS = { + "format_version", + "landmarks", + "alpha", + "k_rr", + "sigma", + "metadata", + "content_hash", +} + + +def canonicalize_metadata(metadata: Mapping[str, Any]) -> Dict[str, Any]: + """Return a deterministic, JSON-safe metadata mapping.""" + if not isinstance(metadata, Mapping): + raise TypeError("metadata must be a mapping") + try: + encoded = json.dumps( + dict(metadata), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + except (TypeError, ValueError) as exc: + raise ValueError( + "metadata must contain only finite JSON-serializable values" + ) from exc + decoded = json.loads(encoded) + if not isinstance(decoded, dict): + raise ValueError("metadata must canonicalize to a dictionary") + return decoded + + +def _metadata_contains(actual: Any, expected: Any, path: str = "metadata") -> None: + """Require ``expected`` to be a recursively exact subset of ``actual``.""" + if isinstance(expected, Mapping): + if not isinstance(actual, Mapping): + raise ValueError(f"{path} is not a mapping") + for key, expected_value in expected.items(): + if key not in actual: + raise ValueError(f"{path} is missing required key {key!r}") + _metadata_contains( + actual[key], expected_value, f"{path}.{key}") + return + if actual != expected: + raise ValueError( + f"{path} mismatch: saved={actual!r}, expected={expected!r}") + + +_FINITE_VALIDATION_MAX_ELEMENTS = 64 * 1024 * 1024 + + +def _validate_video_tensor(tensor: torch.Tensor, name: str) -> Tuple[int, int, int]: + if not isinstance(tensor, torch.Tensor): + raise TypeError(f"{name} must be a torch.Tensor") + if tensor.ndim != 3 or min(tensor.shape) <= 0: + raise ValueError(f"{name} must have non-empty shape [B, N, D]") + if not tensor.is_floating_point(): + raise TypeError(f"{name} must be floating point") + + # A full isfinite mask is one byte per element. The production reference + # has 12,212 * 1,024 * 1,024 elements, so materializing that mask needs + # 11.93 GiB and can OOM an otherwise valid H200 consolidation. Validate + # complete rows in bounded slices instead; every value is still checked. + row_elements = int(tensor.shape[1]) * int(tensor.shape[2]) + rows_per_chunk = max( + 1, int(_FINITE_VALIDATION_MAX_ELEMENTS) // row_elements) + detached = tensor.detach() + for start in range(0, int(tensor.shape[0]), rows_per_chunk): + block = detached[start:start + rows_per_chunk] + if not bool(torch.isfinite(block).all().item()): + raise FloatingPointError(f"{name} contains non-finite values") + return int(tensor.shape[0]), int(tensor.shape[1]), int(tensor.shape[2]) + + +def _flat_row_norm_sq(flattened: torch.Tensor) -> torch.Tensor: + """FP32 squared row norms without a full elementwise-square temporary.""" + if flattened.ndim != 2 or not flattened.is_floating_point(): + raise ValueError("flattened video features must be a floating [B, F] tensor") + return torch.linalg.vector_norm(flattened, ord=2, dim=1).square() + + +def _pairwise_flat_sq( + left_flat: torch.Tensor, + right_flat: torch.Tensor, + left_sq: torch.Tensor, + right_sq: torch.Tensor, +) -> torch.Tensor: + return ( + left_sq[:, None] + + right_sq[None, :] + - 2.0 * (left_flat @ right_flat.t()) + ).clamp_min(0) + + +def _inverse_two_sigma_sq( + sigma: torch.Tensor | float, + *, + device: torch.device, +) -> torch.Tensor: + sigma_tensor = torch.as_tensor(sigma, device=device, dtype=torch.float32) + if sigma_tensor.numel() != 1: + raise ValueError("sigma must be scalar") + if not bool(torch.isfinite(sigma_tensor).item()) or float(sigma_tensor) <= 0: + raise ValueError("sigma must be finite and positive") + return 0.5 / sigma_tensor.square() + + +def _rbf_flat_precomputed( + left_flat: torch.Tensor, + right_flat: torch.Tensor, + left_sq: torch.Tensor, + right_sq: torch.Tensor, + inverse_two_sigma_sq: torch.Tensor, +) -> torch.Tensor: + distances_sq = _pairwise_flat_sq( + left_flat, right_flat, left_sq, right_sq) + return torch.exp(-distances_sq * inverse_two_sigma_sq) + + +def pairwise_frobenius_sq( + left: torch.Tensor, + right: torch.Tensor, +) -> torch.Tensor: + """Squared Frobenius distances between ``[B,N,D]`` video matrices.""" + _, left_n, left_d = _validate_video_tensor(left, "left") + _, right_n, right_d = _validate_video_tensor(right, "right") + if (left_n, left_d) != (right_n, right_d): + raise ValueError( + "left/right video matrix shapes differ: " + f"{(left_n, left_d)} != {(right_n, right_d)}") + left_flat = left.float().flatten(1) + right_flat = right.float().flatten(1) + return _pairwise_flat_sq( + left_flat, + right_flat, + _flat_row_norm_sq(left_flat), + _flat_row_norm_sq(right_flat), + ) + + +def rbf_frobenius_kernel( + left: torch.Tensor, + right: torch.Tensor, + sigma: torch.Tensor | float, +) -> torch.Tensor: + """Gaussian RBF kernel using complete video-matrix samples.""" + _, left_n, left_d = _validate_video_tensor(left, "left") + _, right_n, right_d = _validate_video_tensor(right, "right") + if (left_n, left_d) != (right_n, right_d): + raise ValueError( + "left/right video matrix shapes differ: " + f"{(left_n, left_d)} != {(right_n, right_d)}") + left_flat = left.float().flatten(1) + right_flat = right.float().flatten(1) + return _rbf_flat_precomputed( + left_flat, + right_flat, + _flat_row_norm_sq(left_flat), + _flat_row_norm_sq(right_flat), + _inverse_two_sigma_sq(sigma, device=left.device), + ) + + +@torch.no_grad() +def median_frobenius_sigma( + reference: torch.Tensor, + *, + max_samples: Optional[int] = 2048, + chunk_size: int = 256, + min_sigma: float = 1.0e-6, +) -> torch.Tensor: + """Deterministic median-distance bandwidth over video samples. + + A deterministic linspace subset bounds the number of pair distances. + Pair blocks are constructed in chunks; only the final one-dimensional + vector of upper-triangular distances is retained for the exact median of + that subset. + """ + count, _, _ = _validate_video_tensor(reference, "reference") + if chunk_size <= 0: + raise ValueError("chunk_size must be positive") + if not math.isfinite(float(min_sigma)) or float(min_sigma) <= 0: + raise ValueError("min_sigma must be finite and positive") + samples = reference.detach().float() + if max_samples is not None: + if int(max_samples) < 2: + raise ValueError("max_samples must be at least 2 or None") + if count > int(max_samples): + indices = torch.linspace( + 0, + count - 1, + steps=int(max_samples), + device=samples.device, + dtype=torch.float64, + ).round().long() + samples = samples.index_select(0, indices) + count = int(samples.shape[0]) + if count < 2: + return samples.new_tensor(1.0) + + flattened = samples.flatten(1) + norms_sq = _flat_row_norm_sq(flattened) + pieces = [] + for row_start in range(0, count, chunk_size): + row_end = min(row_start + chunk_size, count) + for col_start in range(row_start, count, chunk_size): + col_end = min(col_start + chunk_size, count) + block = _pairwise_flat_sq( + flattened[row_start:row_end], + flattened[col_start:col_end], + norms_sq[row_start:row_end], + norms_sq[col_start:col_end], + ) + if col_start == row_start: + upper = torch.triu_indices( + row_end - row_start, + col_end - col_start, + offset=1, + device=block.device, + ) + values = block[upper[0], upper[1]] + else: + values = block.reshape(-1) + positive = values[values > 0] + if positive.numel(): + pieces.append(positive) + if not pieces: + return samples.new_tensor(1.0) + median_sq = torch.cat(pieces).median().clamp_min(float(min_sigma) ** 2) + return median_sq.sqrt() + + +def _nearest_centroid_labels( + samples: torch.Tensor, + centroids: torch.Tensor, + chunk_size: int, +) -> Tuple[torch.Tensor, torch.Tensor]: + sample_flat = samples.float().flatten(1) + centroid_flat = centroids.float().flatten(1) + sample_sq = _flat_row_norm_sq(sample_flat) + centroid_sq = _flat_row_norm_sq(centroid_flat) + labels = [] + minimum_sq = [] + for start in range(0, samples.shape[0], chunk_size): + distances = _pairwise_flat_sq( + sample_flat[start:start + chunk_size], + centroid_flat, + sample_sq[start:start + chunk_size], + centroid_sq, + ) + values, indices = distances.min(dim=1) + labels.append(indices) + minimum_sq.append(values) + return torch.cat(labels), torch.cat(minimum_sq) + + +def _deterministic_cluster_sums( + flattened: torch.Tensor, + labels: torch.Tensor, + num_clusters: int, +) -> Tuple[torch.Tensor, torch.Tensor]: + """Segment-sum rows without CUDA atomic ``index_add_``. + + Sorting once and differencing a fixed-order prefix sum makes a rebuilt + reference pack bitwise reproducible on the same hardware/software stack. + CUDA ``index_add_`` uses repeated-index atomics whose accumulation order + is intentionally unspecified. + """ + counts = torch.bincount(labels, minlength=int(num_clusters)) + order = torch.argsort(labels, stable=True) + sorted_values = flattened.index_select(0, order) + prefix = sorted_values.cumsum(dim=0) + sums = flattened.new_zeros( + (int(num_clusters), flattened.shape[1])) + nonempty = counts > 0 + ends = counts.cumsum(0) - 1 + selected_ends = ends[nonempty] + cluster_sums = prefix.index_select(0, selected_ends) + starts = selected_ends - counts[nonempty] + 1 + has_predecessor = starts > 0 + if bool(has_predecessor.any()): + cluster_sums[has_predecessor] -= prefix.index_select( + 0, starts[has_predecessor] - 1) + sums[nonempty] = cluster_sums + return sums, counts + + +@torch.no_grad() +def deterministic_video_kmeans( + reference: torch.Tensor, + num_landmarks: int, + *, + iterations: int = 20, + chunk_size: int = 256, +) -> torch.Tensor: + """Deterministic farthest-first initialized Lloyd k-means.""" + count, num_tokens, feature_dim = _validate_video_tensor( + reference, "reference") + num_landmarks = int(num_landmarks) + if num_landmarks <= 0 or num_landmarks > count: + raise ValueError( + f"num_landmarks must be in [1, {count}], got {num_landmarks}") + if int(iterations) < 0: + raise ValueError("iterations must be non-negative") + if int(chunk_size) <= 0: + raise ValueError("chunk_size must be positive") + + samples = reference.detach().float() + flattened = samples.flatten(1) + norms_sq = _flat_row_norm_sq(flattened) + global_mean = samples.mean(dim=0, keepdim=True).flatten(1) + mean_sq = _flat_row_norm_sq(global_mean) + first = int( + _pairwise_flat_sq(flattened, global_mean, norms_sq, mean_sq) + .squeeze(1) + .argmax() + .item() + ) + selected = [first] + closest_sq = _pairwise_flat_sq( + flattened, + flattened[first:first + 1], + norms_sq, + norms_sq[first:first + 1], + ).squeeze(1) + for _ in range(1, num_landmarks): + next_index = int(closest_sq.argmax().item()) + selected.append(next_index) + next_sq = _pairwise_flat_sq( + flattened, + flattened[next_index:next_index + 1], + norms_sq, + norms_sq[next_index:next_index + 1], + ).squeeze(1) + closest_sq = torch.minimum(closest_sq, next_sq) + + centroids = samples.index_select( + 0, + torch.tensor(selected, device=samples.device, dtype=torch.long), + ).clone() + previous_labels = None + for _ in range(int(iterations)): + labels, _ = _nearest_centroid_labels( + samples, centroids, int(chunk_size)) + if previous_labels is not None and torch.equal(labels, previous_labels): + break + previous_labels = labels + flattened = samples.flatten(1) + sums, cluster_counts = _deterministic_cluster_sums( + flattened, labels, num_landmarks) + nonempty = cluster_counts > 0 + updated = centroids.flatten(1).clone() + updated[nonempty] = ( + sums[nonempty] + / cluster_counts[nonempty, None].to(sums.dtype) + ) + centroids = updated.reshape( + num_landmarks, num_tokens, feature_dim) + return centroids + + +@torch.no_grad() +def compute_nystrom_alpha( + reference: torch.Tensor, + landmarks: torch.Tensor, + sigma: torch.Tensor | float, + *, + ridge: float = 1.0e-6, + chunk_size: int = 256, +) -> torch.Tensor: + """Fit coefficients for the persistent Nyström reference kernel mean. + + Solve (K_ZZ + ridge * I) alpha = mean_R K_ZR once, offline. Training + evaluates K(x, Z) @ alpha without revisiting the full reference bank R. + Code ``alpha`` is the paper's coefficient vector beta (Appendix C.2), + not the scalar mixture weight alpha_e in the hybrid mean (Eq. 9). + """ + ref_count, ref_n, ref_d = _validate_video_tensor(reference, "reference") + _, landmark_n, landmark_d = _validate_video_tensor( + landmarks, "landmarks") + if (ref_n, ref_d) != (landmark_n, landmark_d): + raise ValueError("reference and landmarks have different matrix shapes") + if not math.isfinite(float(ridge)) or float(ridge) < 0: + raise ValueError("ridge must be finite and non-negative") + if int(chunk_size) <= 0: + raise ValueError("chunk_size must be positive") + + reference_flat = reference.detach().float().flatten(1) + landmark_flat = landmarks.detach().float().flatten(1) + reference_sq = _flat_row_norm_sq(reference_flat) + landmark_sq = _flat_row_norm_sq(landmark_flat) + inverse = _inverse_two_sigma_sq(sigma, device=landmarks.device) + target = landmarks.new_zeros(landmarks.shape[0], dtype=torch.float64) + for start in range(0, ref_count, int(chunk_size)): + end = min(start + int(chunk_size), ref_count) + block = _rbf_flat_precomputed( + landmark_flat, + reference_flat[start:end], + landmark_sq, + reference_sq[start:end], + inverse, + ) + target.add_(block.double().sum(dim=1)) + target.div_(float(ref_count)) + + kernel_zz = _rbf_flat_precomputed( + landmark_flat, + landmark_flat, + landmark_sq, + landmark_sq, + inverse, + ).double() + kernel_zz = 0.5 * (kernel_zz + kernel_zz.t()) + kernel_zz.fill_diagonal_(1.0) + regularized = kernel_zz + float(ridge) * torch.eye( + landmarks.shape[0], + device=landmarks.device, + dtype=torch.float64, + ) + try: + alpha = torch.linalg.solve(regularized, target) + except RuntimeError as exc: + raise ValueError( + "Nyström landmark kernel is singular; increase ridge") from exc + return alpha.float() + + +@torch.no_grad() +def compute_reference_krr( + reference: torch.Tensor, + sigma: torch.Tensor | float, + *, + chunk_size: int = 256, +) -> torch.Tensor: + """Exact biased ``mean(K_rr)``, including the reference diagonal.""" + count, _, _ = _validate_video_tensor(reference, "reference") + if int(chunk_size) <= 0: + raise ValueError("chunk_size must be positive") + flattened = reference.detach().float().flatten(1) + norms_sq = _flat_row_norm_sq(flattened) + inverse = _inverse_two_sigma_sq(sigma, device=reference.device) + total = reference.new_zeros((), dtype=torch.float64) + chunk_size = int(chunk_size) + # The kernel is symmetric. Evaluate the diagonal and strict upper block + # triangle once, doubling only off-diagonal blocks. This is the same full + # biased Gram mean while avoiding millions of duplicate tiny GEMMs. + for row_start in range(0, count, chunk_size): + row_end = min(row_start + chunk_size, count) + for col_start in range(row_start, count, chunk_size): + col_end = min(col_start + chunk_size, count) + kernel = _rbf_flat_precomputed( + flattened[row_start:row_end], + flattened[col_start:col_end], + norms_sq[row_start:row_end], + norms_sq[col_start:col_end], + inverse, + ) + block_sum = kernel.double().sum() + if row_start == col_start: + block_sum.add_( + (1.0 - kernel.diagonal()).double().sum()) + total.add_(block_sum) + else: + total.add_(2.0 * block_sum) + return (total / float(count * count)).float() + + +def _hash_tensor(hasher: Any, name: str, tensor: torch.Tensor) -> None: + value = tensor.detach().cpu().contiguous() + descriptor = { + "name": name, + "dtype": str(value.dtype), + "shape": list(value.shape), + } + hasher.update(json.dumps( + descriptor, sort_keys=True, separators=(",", ":")).encode("utf-8")) + raw = value.reshape(-1).view(torch.uint8).numpy().tobytes() + hasher.update(raw) + + +def compute_reference_pack_hash(pack: Mapping[str, Any]) -> str: + """Hash pack contents, deliberately excluding ``content_hash`` itself.""" + if not isinstance(pack, Mapping): + raise TypeError("reference pack must be a mapping") + required_without_hash = _PACK_KEYS - {"content_hash"} + missing = required_without_hash - set(pack) + if missing: + raise ValueError(f"reference pack is missing keys: {sorted(missing)}") + metadata = canonicalize_metadata(pack["metadata"]) + hasher = hashlib.sha256() + hasher.update(b"video-nystrom-reference-pack\0") + hasher.update(str(int(pack["format_version"])).encode("ascii")) + hasher.update(json.dumps( + metadata, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8")) + for name in ("landmarks", "alpha", "k_rr", "sigma"): + value = pack[name] + if not isinstance(value, torch.Tensor): + raise TypeError(f"reference pack {name!r} must be a tensor") + _hash_tensor(hasher, name, value) + return hasher.hexdigest() + + +def validate_reference_pack( + pack: Mapping[str, Any], + *, + expected_metadata: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """Validate schema, geometry, recursive metadata subset, and SHA-256.""" + if not isinstance(pack, Mapping): + raise TypeError("reference pack must be a mapping") + keys = set(pack) + if keys != _PACK_KEYS: + raise ValueError( + "reference pack keys mismatch: " + f"missing={sorted(_PACK_KEYS - keys)}, " + f"unexpected={sorted(keys - _PACK_KEYS)}") + if int(pack["format_version"]) != REFERENCE_PACK_VERSION: + raise ValueError( + "unsupported reference pack version " + f"{pack['format_version']!r}; expected {REFERENCE_PACK_VERSION}") + + landmarks = pack["landmarks"] + alpha = pack["alpha"] + k_rr = pack["k_rr"] + sigma = pack["sigma"] + landmark_count, num_tokens, feature_dim = _validate_video_tensor( + landmarks, "landmarks") + if not isinstance(alpha, torch.Tensor) or alpha.ndim != 1: + raise ValueError("alpha must be a one-dimensional tensor") + if alpha.shape[0] != landmark_count or not alpha.is_floating_point(): + raise ValueError("alpha length/dtype is incompatible with landmarks") + if not bool(torch.isfinite(alpha).all().item()): + raise FloatingPointError("alpha contains non-finite values") + for name, value in (("k_rr", k_rr), ("sigma", sigma)): + if ( + not isinstance(value, torch.Tensor) + or value.numel() != 1 + or not value.is_floating_point() + ): + raise ValueError(f"{name} must be a floating scalar tensor") + if not bool(torch.isfinite(value).item()): + raise FloatingPointError(f"{name} is non-finite") + if float(sigma) <= 0: + raise ValueError("sigma must be positive") + if float(k_rr) < 0: + raise ValueError("k_rr must be non-negative") + + metadata = canonicalize_metadata(pack["metadata"]) + if metadata != pack["metadata"]: + raise ValueError("reference pack metadata is not canonical") + nystrom_metadata = metadata.get("nystrom") + required_nystrom = { + "sample_axis": "video", + "sample_shape": [num_tokens, feature_dim], + "kernel": "gaussian_rbf", + "kernel_metric": "frobenius", + "generated_gram": "biased_exact_including_diagonal", + "num_landmarks": landmark_count, + } + if not isinstance(nystrom_metadata, Mapping): + raise ValueError("metadata.nystrom is missing") + _metadata_contains( + nystrom_metadata, required_nystrom, "metadata.nystrom") + if expected_metadata is not None: + expected = canonicalize_metadata(expected_metadata) + _metadata_contains(metadata, expected) + + saved_hash = pack["content_hash"] + if not isinstance(saved_hash, str) or len(saved_hash) != 64: + raise ValueError("content_hash must be a 64-character SHA-256 hex string") + calculated_hash = compute_reference_pack_hash(pack) + if not hmac.compare_digest(saved_hash, calculated_hash): + raise ValueError( + "reference pack content_hash mismatch: " + f"saved={saved_hash}, calculated={calculated_hash}") + + return { + "format_version": REFERENCE_PACK_VERSION, + "landmarks": landmarks, + "alpha": alpha, + "k_rr": k_rr.reshape(()), + "sigma": sigma.reshape(()), + "metadata": metadata, + "content_hash": saved_hash, + } + + +@torch.no_grad() +def build_video_nystrom_reference( + reference: torch.Tensor, + *, + num_landmarks: int, + metadata: Mapping[str, Any], + bandwidth: Optional[float] = None, + median_max_samples: Optional[int] = 2048, + kmeans_iterations: int = 20, + ridge: float = 1.0e-6, + chunk_size: int = 256, + seed: int = 0, +) -> Dict[str, Any]: + """Construct a frozen, portable Nyström reference pack.""" + reference_count, num_tokens, feature_dim = _validate_video_tensor( + reference, "reference") + if int(chunk_size) <= 0: + raise ValueError("chunk_size must be positive") + seed = int(seed) + reference = reference.detach().float() + if bandwidth is None: + sigma = median_frobenius_sigma( + reference, + max_samples=median_max_samples, + chunk_size=int(chunk_size), + ) + bandwidth_source = "median_frobenius_distance" + else: + if not math.isfinite(float(bandwidth)) or float(bandwidth) <= 0: + raise ValueError("bandwidth must be finite and positive") + sigma = reference.new_tensor(float(bandwidth)) + bandwidth_source = "provided" + + landmarks = deterministic_video_kmeans( + reference, + int(num_landmarks), + iterations=int(kmeans_iterations), + chunk_size=int(chunk_size), + ) + alpha = compute_nystrom_alpha( + reference, + landmarks, + sigma, + ridge=float(ridge), + chunk_size=int(chunk_size), + ) + k_rr = compute_reference_krr( + reference, sigma, chunk_size=int(chunk_size)) + + canonical_metadata = canonicalize_metadata(metadata) + technical_metadata = { + "sample_axis": "video", + "sample_shape": [num_tokens, feature_dim], + "kernel": "gaussian_rbf", + "kernel_metric": "frobenius", + "generated_gram": "biased_exact_including_diagonal", + "reference_count": reference_count, + "num_landmarks": int(num_landmarks), + "bandwidth_source": bandwidth_source, + "median_max_samples": ( + None if median_max_samples is None else int(median_max_samples) + ), + "kmeans": "deterministic_farthest_first_lloyd", + "seed": seed, + "kmeans_iterations": int(kmeans_iterations), + "ridge": float(ridge), + } + if ( + "nystrom" in canonical_metadata + and canonical_metadata["nystrom"] != technical_metadata + ): + raise ValueError( + "metadata.nystrom is reserved and does not match builder settings") + canonical_metadata["nystrom"] = technical_metadata + canonical_metadata = canonicalize_metadata(canonical_metadata) + + pack: Dict[str, Any] = { + "format_version": REFERENCE_PACK_VERSION, + "landmarks": landmarks.detach().cpu().contiguous(), + "alpha": alpha.detach().cpu().contiguous(), + "k_rr": k_rr.detach().cpu().reshape(()), + "sigma": sigma.detach().cpu().reshape(()), + "metadata": canonical_metadata, + } + pack["content_hash"] = compute_reference_pack_hash(pack) + return validate_reference_pack(pack) + + +def save_reference_pack( + pack: Mapping[str, Any], + path: str | os.PathLike, + *, + atomic: bool = True, +) -> None: + """Validate and save a portable pack, atomically by default.""" + validated = validate_reference_pack(pack) + portable = { + **validated, + "landmarks": validated["landmarks"].detach().cpu().contiguous(), + "alpha": validated["alpha"].detach().cpu().contiguous(), + "k_rr": validated["k_rr"].detach().cpu().reshape(()), + "sigma": validated["sigma"].detach().cpu().reshape(()), + } + # Moving tensors to CPU is part of the serialized representation, so hash + # the exact portable payload rather than copying the incoming hash blindly. + portable["content_hash"] = compute_reference_pack_hash(portable) + destination = os.path.abspath(os.fspath(path)) + if not atomic: + torch.save(portable, destination) + return + directory = os.path.dirname(destination) + descriptor, temporary = tempfile.mkstemp( + prefix=f".{os.path.basename(destination)}.", + suffix=".tmp", + dir=directory, + ) + os.close(descriptor) + try: + torch.save(portable, temporary) + os.replace(temporary, destination) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def load_reference_pack( + path: str | os.PathLike, + *, + expected_metadata: Optional[Mapping[str, Any]] = None, + map_location: str | torch.device = "cpu", +) -> Dict[str, Any]: + """Load a pack and strictly validate its metadata and content hash.""" + try: + pack = torch.load( + os.fspath(path), + map_location=map_location, + weights_only=True, + ) + except TypeError: + pack = torch.load(os.fspath(path), map_location=map_location) + return validate_reference_pack( + pack, expected_metadata=expected_metadata) + + +def _kernel_sum_chunked( + left: torch.Tensor, + right: torch.Tensor, + sigma: torch.Tensor, + chunk_size: int, +) -> torch.Tensor: + total = left.new_zeros(()) + self_kernel = left is right + for row_start in range(0, left.shape[0], chunk_size): + rows = left[row_start:row_start + chunk_size] + for col_start in range(0, right.shape[0], chunk_size): + cols = right[col_start:col_start + chunk_size] + kernel = rbf_frobenius_kernel(rows, cols, sigma) + block_sum = kernel.sum() + if self_kernel and row_start == col_start: + # The RBF self-kernel is analytically one. Explicitly replace + # it to avoid norm/Gram cancellation error in FP32. + diagonal = kernel.diagonal() + block_sum = block_sum + (1.0 - diagonal).sum() + total = total + block_sum + return total + + +def _kernel_matvec_chunked( + samples: torch.Tensor, + landmarks: torch.Tensor, + alpha: torch.Tensor, + sigma: torch.Tensor, + chunk_size: int, +) -> torch.Tensor: + outputs = [] + for row_start in range(0, samples.shape[0], chunk_size): + rows = samples[row_start:row_start + chunk_size] + values = rows.new_zeros(rows.shape[0]) + for col_start in range(0, landmarks.shape[0], chunk_size): + cols = landmarks[col_start:col_start + chunk_size] + kernel = rbf_frobenius_kernel(rows, cols, sigma) + values = values + kernel @ alpha[ + col_start:col_start + chunk_size] + outputs.append(values) + return torch.cat(outputs) + + +class VideoNystromMMD(nn.Module): + """One encoder's MMD: repulsion - 2 * attraction + reference constant. + + The generated-sample kernel is evaluated exactly. Nyström approximates + only attraction to the persistent reference bank; attraction to the + current reference minibatch is also exact when enabled. + """ + + _STATE_VERSION = 1 + _ATTRACTION_EMA_STATE_VERSION = 1 + _REPULSION_FIFO_STATE_VERSION = 1 + + def __init__( + self, + reference_pack: Mapping[str, Any], + *, + expected_metadata: Optional[Mapping[str, Any]] = None, + kernel_chunk_size: int = 256, + attraction_ema_enabled: bool = False, + attraction_ema_beta: float = 0.99, + attraction_weight: float = 1.0, + current_teacher_attraction_enabled: bool = False, + current_teacher_attraction_weight: float = 1.0, + repulsion_fifo_size: int = 0, + ): + super().__init__() + if int(kernel_chunk_size) <= 0: + raise ValueError("kernel_chunk_size must be positive") + if ( + not math.isfinite(float(attraction_ema_beta)) + or not 0.0 <= float(attraction_ema_beta) < 1.0 + ): + raise ValueError("attraction EMA beta must be in [0, 1)") + if ( + not math.isfinite(float(attraction_weight)) + or float(attraction_weight) < 0.0 + ): + raise ValueError("attraction weight must be finite and non-negative") + if ( + not math.isfinite(float(current_teacher_attraction_weight)) + or float(current_teacher_attraction_weight) < 0.0 + ): + raise ValueError( + "current teacher attraction weight must be finite and " + "non-negative") + current_teacher_attraction_enabled = bool( + current_teacher_attraction_enabled) + if ( + current_teacher_attraction_enabled + and float(attraction_weight) + + float(current_teacher_attraction_weight) <= 0.0 + ): + raise ValueError( + "fixed/current teacher attraction weights must have a " + "positive sum when current teacher attraction is enabled") + repulsion_fifo_size = int(repulsion_fifo_size) + if repulsion_fifo_size < 0: + raise ValueError("repulsion FIFO size must be non-negative") + if repulsion_fifo_size > 0 and attraction_ema_enabled: + raise ValueError( + "repulsion FIFO is incompatible with attraction EMA") + pack = validate_reference_pack( + reference_pack, expected_metadata=expected_metadata) + self.kernel_chunk_size = int(kernel_chunk_size) + self.attraction_ema_enabled = bool(attraction_ema_enabled) + self.attraction_ema_beta = float(attraction_ema_beta) + self.attraction_weight = float(attraction_weight) + self.current_teacher_attraction_enabled = ( + current_teacher_attraction_enabled) + self.current_teacher_attraction_weight = float( + current_teacher_attraction_weight) + self.repulsion_fifo_size = repulsion_fifo_size + self.repulsion_fifo_enabled = bool(repulsion_fifo_size > 0) + self.reference_metadata = pack["metadata"] + self.reference_hash = pack["content_hash"] + self.register_buffer( + "landmarks", pack["landmarks"].detach().float().clone(), + persistent=True) + self.register_buffer( + "alpha", pack["alpha"].detach().float().clone(), + persistent=True) + self.register_buffer( + "k_rr", pack["k_rr"].detach().float().reshape(()).clone(), + persistent=True) + self.register_buffer( + "sigma", pack["sigma"].detach().float().reshape(()).clone(), + persistent=True) + # FIFO contents have their own strict trainer-checkpoint payload. A + # non-persistent buffer still follows ``module.to(device)`` without + # silently duplicating the large history in the module state_dict. + self.register_buffer( + "repulsion_fifo", + torch.empty( + (0, int(self.landmarks.shape[1]), int(self.landmarks.shape[2])), + dtype=torch.float32, + device=self.landmarks.device, + ), + persistent=False, + ) + self.register_buffer( + "repulsion_fifo_commits", + torch.zeros((), dtype=torch.long, device=self.landmarks.device), + persistent=False, + ) + # Keep the disabled module's state_dict byte-schema compatible with + # the original exact objective. Enabled modules persist these two + # scalars and advertise the changed schema through extra state. + self.register_buffer( + "attraction_ema", + torch.zeros((), dtype=torch.float32), + persistent=self.attraction_ema_enabled, + ) + self.register_buffer( + "attraction_ema_updates", + torch.zeros((), dtype=torch.long), + persistent=self.attraction_ema_enabled, + ) + # Pending state is deliberately not a buffer: an uncommitted optimizer + # update must never enter a checkpoint. + self._pending_attraction: Optional[torch.Tensor] = None + self._pending_repulsion_fifo: Optional[torch.Tensor] = None + # The final FIFO allocation is prepared before optimizer.step(). + # Post-step commit only publishes this already-owned tensor. + self._prepared_repulsion_fifo: Optional[torch.Tensor] = None + + @property + def sample_shape(self) -> Tuple[int, int]: + return int(self.landmarks.shape[1]), int(self.landmarks.shape[2]) + + @property + def attraction_ema_pending(self) -> bool: + return self._pending_attraction is not None + + @property + def repulsion_fifo_pending(self) -> bool: + return self._pending_repulsion_fifo is not None + + @property + def repulsion_fifo_fill(self) -> int: + return int(self.repulsion_fifo.shape[0]) + + def forward( + self, + generated: Optional[torch.Tensor] = None, + current_teacher: Optional[torch.Tensor] = None, + *, + gradient_students: Optional[torch.Tensor] = None, + student_reference: Optional[torch.Tensor] = None, + gradient_student_indices: Optional[ + torch.Tensor | Sequence[int] + ] = None, + stage: bool = True, + ) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]: + """Evaluate the legacy full-batch or explicit partial-gradient loss. + + Legacy callers pass ``generated`` and retain the original two-sided + ``K(generated, generated)`` derivative exactly. Partial-gradient + callers instead pass both ``gradient_students`` (the row-side samples + whose derivatives are required) and ``student_reference`` (the full + detached column-side student population). The latter computes + + ``mean K(G,S) - 2 * attraction(G) + reference_constant`` + + with a rectangular ``G x S`` student population. The matched + teacher population must have exactly ``|G|`` samples. Neither + ``student_reference`` nor ``current_teacher`` can receive gradients. + + Passing ``gradient_student_indices`` opts into the full-student + partial-gradient estimator. The indices identify the positions of + ``gradient_students`` in ``student_reference``. Those differentiable + values are functionally inserted into the otherwise detached student + population, yielding complete ``mean K(S,S)``, fixed attraction over + all ``S``, and exact current-teacher attraction over ``S x T``. Only + indexed students retain gradients; ``|T|`` must still equal ``|G|``. + """ + # 1. Choose the generated population and samples that retain a graph. + # ``student_reference`` is generated data, not the persistent reference + # bank; these input names remain compatible with existing callers. + partial_mode = ( + gradient_students is not None or student_reference is not None) + full_student_partial_mode = gradient_student_indices is not None + if full_student_partial_mode and not partial_mode: + raise ValueError( + "gradient_student_indices is only valid with the explicit " + "partial-gradient interface") + if generated is not None and gradient_students is not None: + raise ValueError( + "generated and gradient_students are mutually exclusive") + if partial_mode: + if gradient_students is None or student_reference is None: + raise ValueError( + "partial-gradient MMD requires both gradient_students and " + "student_reference") + if generated is not None: + raise ValueError( + "generated cannot be combined with the explicit " + "partial-gradient interface") + if self.repulsion_fifo_enabled: + raise ValueError( + "partial-gradient student_reference is incompatible with " + "repulsion FIFO") + if self.attraction_ema_enabled: + raise ValueError( + "partial-gradient student_reference is incompatible with " + "attraction EMA") + gradient_input = gradient_students + else: + if generated is None: + raise ValueError( + "generated is required for legacy MMD calls") + gradient_input = generated + + gradient_batch, num_tokens, feature_dim = _validate_video_tensor( + gradient_input, + "gradient_students" if partial_mode else "generated", + ) + if (num_tokens, feature_dim) != self.sample_shape: + raise ValueError( + "student video matrix shape does not match frozen reference: " + f"{(num_tokens, feature_dim)} != {self.sample_shape}") + if gradient_input.device != self.landmarks.device: + raise ValueError( + "gradient students and frozen reference must be on the same " + "device") + + samples = gradient_input.float() + selected_indices = None + if partial_mode: + reference_batch, reference_tokens, reference_dim = ( + _validate_video_tensor( + student_reference, "student_reference")) + if (reference_tokens, reference_dim) != (num_tokens, feature_dim): + raise ValueError( + "student_reference matrix shape must match " + "gradient_students: " + f"{(reference_tokens, reference_dim)} != " + f"{(num_tokens, feature_dim)}") + if student_reference.device != gradient_input.device: + raise ValueError( + "gradient_students and student_reference must share a " + "device") + reference_samples = student_reference.detach().float() + if full_student_partial_mode: + try: + selected_indices = torch.as_tensor( + gradient_student_indices, + device=gradient_input.device, + ) + except (TypeError, ValueError) as exc: + raise TypeError( + "gradient_student_indices must be an integer " + "tensor or sequence") from exc + integer_dtypes = ( + torch.uint8, + torch.int8, + torch.int16, + torch.int32, + torch.int64, + ) + if ( + selected_indices.dtype == torch.bool + or selected_indices.dtype not in integer_dtypes + ): + raise TypeError( + "gradient_student_indices must contain integers") + selected_indices = selected_indices.to(dtype=torch.long) + if selected_indices.ndim != 1: + raise ValueError( + "gradient_student_indices must be one-dimensional") + if int(selected_indices.numel()) != gradient_batch: + raise ValueError( + "gradient_student_indices must contain exactly one " + "index per gradient student: " + f"{int(selected_indices.numel())} != {gradient_batch}") + if bool( + ((selected_indices < 0) + | (selected_indices >= reference_batch)).any().item() + ): + raise ValueError( + "gradient_student_indices contains an out-of-range " + "student_reference position") + if ( + int(torch.unique(selected_indices).numel()) + != gradient_batch + ): + raise ValueError( + "gradient_student_indices must be unique") + selected_reference = reference_samples.index_select( + 0, selected_indices) + if not torch.equal(samples.detach(), selected_reference): + raise ValueError( + "gradient_students must exactly match " + "student_reference at gradient_student_indices") + # Functionally replace only selected detached values. Reusing + # this same object on both sides of K(S,S) gives selected + # students both row- and column-side derivatives. + objective_samples = reference_samples.index_copy( + 0, selected_indices, samples) + repulsion_reference_samples = objective_samples + attraction_samples = objective_samples + attraction_batch = reference_batch + else: + objective_samples = samples + repulsion_reference_samples = reference_samples + attraction_samples = samples + attraction_batch = gradient_batch + else: + reference_batch = gradient_batch + # Preserve object identity here. The legacy self-kernel helper + # relies on it both for its exact diagonal replacement and for the + # original two-sided K_ss derivative. + reference_samples = samples + objective_samples = samples + repulsion_reference_samples = reference_samples + attraction_samples = samples + attraction_batch = gradient_batch + + # 2. Read the frozen reference minibatch for the Monte Carlo term. + # These features are targets; gradients flow through generated samples. + teacher_samples = None + teacher_batch = None + if self.current_teacher_attraction_enabled: + if current_teacher is None: + raise ValueError( + "current_teacher is required when exact current-teacher " + "attraction is enabled") + teacher_batch, teacher_tokens, teacher_dim = ( + _validate_video_tensor(current_teacher, "current_teacher")) + if ( + teacher_batch != gradient_batch + or teacher_tokens != num_tokens + or teacher_dim != feature_dim + ): + student_name = ( + "gradient_students" if partial_mode else "generated") + raise ValueError( + "current teacher features must exactly match " + f"{student_name} " + "[B,N,D]: " + f"{(teacher_batch, teacher_tokens, teacher_dim)} != " + f"{(gradient_batch, num_tokens, feature_dim)}") + if current_teacher.device != gradient_input.device: + raise ValueError( + "gradient students and current teacher must be on the same " + "device") + # The current teacher is a matched frozen target. Detach before + # every kernel operation so callers can never accidentally create + # a teacher-side gradient graph. + teacher_samples = current_teacher.detach().float() + elif current_teacher is not None: + raise ValueError( + "current_teacher was provided while current teacher " + "attraction is disabled") + + # 3. Exact generated-sample repulsion: sum every kernel pair in + # chunks. Chunking controls memory without approximating the sum. + exact_kgg_sum = _kernel_sum_chunked( + objective_samples, + repulsion_reference_samples, + self.sigma, + self.kernel_chunk_size, + ) + if self.repulsion_fifo_enabled: + history_size = self.repulsion_fifo_fill + denominator = gradient_batch * ( + gradient_batch - 1 + history_size) + if denominator <= 0: + raise ValueError( + "repulsion FIFO needs at least one current-current " + "off-diagonal or current-history pair") + # ``_kernel_sum_chunked`` analytically replaces each self-kernel + # by one. Subtracting B therefore removes the complete diagonal, + # including its autograd path, without forming a dense mask. + current_offdiag_sum = exact_kgg_sum - float(gradient_batch) + if history_size: + history_cross_sum = _kernel_sum_chunked( + samples, + self.repulsion_fifo, + self.sigma, + self.kernel_chunk_size, + ) + else: + history_cross_sum = samples.new_zeros(()) + repulsion = ( + current_offdiag_sum + history_cross_sum + ) / float(denominator) + else: + history_size = 0 + # Full-population replay keeps B * B pairs even when only a + # subset is differentiated. The rectangular mode uses |G| * B. + # Gradient-subset compensation is handled by the replay caller. + if full_student_partial_mode: + denominator = reference_batch * reference_batch + else: + denominator = gradient_batch * reference_batch + current_offdiag_sum = None + history_cross_sum = None + repulsion = exact_kgg_sum / float(denominator) + # 4. Persistent Nyström attraction (paper Eq. 8): K(x, Z) @ alpha. + # The saved ``alpha`` vector is paper beta, not the mixture weight. + fixed_attraction_values = _kernel_matvec_chunked( + attraction_samples, + self.landmarks, + self.alpha, + self.sigma, + self.kernel_chunk_size, + ) + fixed_current_attraction = fixed_attraction_values.mean() + current_teacher_attraction = None + current_teacher_self_kernel = None + current_teacher_exact_mmd = None + # 5. Hybrid reference mean (paper Eq. 9): mix the persistent term + # with exact minibatch attraction over all generated x reference pairs. + # Group weights 2:1 correspond to paper alpha_e = 1/3. + if self.current_teacher_attraction_enabled: + fixed_weight = self.attraction_weight + current_weight = self.current_teacher_attraction_weight + teacher_weight_sum = fixed_weight + current_weight + fixed_normalized_weight = fixed_weight / teacher_weight_sum + current_normalized_weight = current_weight / teacher_weight_sum + current_teacher_attraction = _kernel_sum_chunked( + attraction_samples, + teacher_samples, + self.sigma, + self.kernel_chunk_size, + ) / float(attraction_batch * teacher_batch) + # The reference self-kernel has no generator gradient. Its value + # still affects the release's later detached-scale division. + with torch.no_grad(): + current_teacher_self_kernel = _kernel_sum_chunked( + teacher_samples, + teacher_samples, + self.sigma, + self.kernel_chunk_size, + ) / float(teacher_batch * teacher_batch) + current_attraction = ( + fixed_normalized_weight * fixed_current_attraction + + current_normalized_weight * current_teacher_attraction + ) + reference_constant = ( + fixed_normalized_weight * self.k_rr + + current_normalized_weight * current_teacher_self_kernel + ) + current_teacher_exact_mmd = ( + repulsion + - 2.0 * current_teacher_attraction + + current_teacher_self_kernel + ) + else: + fixed_normalized_weight = None + current_normalized_weight = None + teacher_weight_sum = None + current_attraction = fixed_current_attraction + reference_constant = self.k_rr + # 6. Optional historical attraction/FIFO state. These branches are + # inactive in the released multi-encoder configuration. + history_updates = int(self.attraction_ema_updates.item()) + if self.attraction_ema_enabled and history_updates > 0: + ema_blend_current_coefficient = 1.0 - self.attraction_ema_beta + ema_attraction = ( + self.attraction_ema_beta * self.attraction_ema.detach() + + ema_blend_current_coefficient * current_attraction + ) + if self.current_teacher_attraction_enabled: + # Keep the transactional EMA's smoothed forward value while + # preserving the full derivative of this update's normalized + # fixed/current teacher mixture. Without this straight-through + # form beta=0.99 would silently shrink all teacher gradients + # to one percent relative to K_ss. + attraction = current_attraction + ( + ema_attraction - current_attraction).detach() + current_coefficient = 1.0 + else: + attraction = ema_attraction + current_coefficient = ema_blend_current_coefficient + else: + ema_blend_current_coefficient = 1.0 + current_coefficient = 1.0 + attraction = current_attraction + + if self.attraction_ema_enabled and stage: + if self._pending_attraction is not None: + raise RuntimeError( + "an attraction EMA update is already staged; " + "commit or discard it before another staged forward") + self._pending_attraction = current_attraction.detach().clone() + + if self.repulsion_fifo_enabled and stage: + if self._pending_repulsion_fifo is not None: + raise RuntimeError( + "a repulsion FIFO update is already staged; " + "commit or discard it before another staged forward") + # The fresh GradCache supplies the identical global batch on every + # rank. Cloning it here gives each rank the same independently + # owned FIFO candidate and prevents all gradients/aliasing. + self._pending_repulsion_fifo = ( + samples.detach().to(dtype=torch.float32).clone()) + + # 7. Assemble the raw single-encoder loss. MultiEncoderMMD applies + # detached-scale normalization and encoder weighting after this. + if self.current_teacher_attraction_enabled: + # This is the weighted average of the fixed-reference MMD and the + # current exact MMD. K_ss is shared, so it appears once and the + # total attraction coefficient remains -2 for every raw scale. + loss = repulsion - 2.0 * attraction + reference_constant + effective_attraction_weight = 1.0 + else: + # Preserve the legacy value and gradient exactly when the opt-in + # current teacher path is disabled. + if self.attraction_weight == 1.0: + loss = repulsion - 2.0 * attraction + self.k_rr + else: + loss = ( + repulsion + - 2.0 * self.attraction_weight * attraction + + self.k_rr + ) + effective_attraction_weight = self.attraction_weight + metadata = { + "repulsion": repulsion.detach(), + "attraction": attraction.detach(), + "attraction_weight": loss.new_tensor( + effective_attraction_weight), + "reference_constant": reference_constant.detach(), + "sigma": self.sigma.detach(), + "global_samples": torch.tensor( + reference_batch, device=loss.device, dtype=torch.long), + } + if partial_mode: + metadata.update({ + "partial_student_reference_enabled": loss.new_ones( + (), dtype=torch.long), + "gradient_student_samples": loss.new_tensor( + gradient_batch, dtype=torch.long), + "student_reference_samples": loss.new_tensor( + reference_batch, dtype=torch.long), + "student_reference_detached": loss.new_ones( + (), dtype=torch.long), + "repulsion_denominator": loss.new_tensor( + denominator, dtype=torch.long), + "student_repulsion_denominator": loss.new_tensor( + denominator, dtype=torch.long), + "fixed_teacher_attraction_denominator": loss.new_tensor( + attraction_batch, dtype=torch.long), + }) + if full_student_partial_mode: + metadata.update({ + "full_student_partial_gradient": loss.new_ones( + (), dtype=torch.long), + "student_repulsion_gradient_sides": loss.new_tensor( + 2, dtype=torch.long), + }) + if self.current_teacher_attraction_enabled: + metadata.update({ + "current_teacher_attraction_enabled": loss.new_ones( + (), dtype=torch.long), + "fixed_teacher_attraction": ( + fixed_current_attraction.detach()), + "current_teacher_attraction": ( + current_teacher_attraction.detach()), + "fixed_teacher_attraction_weight": loss.new_tensor( + self.attraction_weight), + "current_teacher_attraction_weight": loss.new_tensor( + self.current_teacher_attraction_weight), + "teacher_attraction_weight_sum": loss.new_tensor( + teacher_weight_sum), + "fixed_teacher_attraction_normalized_weight": ( + loss.new_tensor(fixed_normalized_weight)), + "current_teacher_attraction_normalized_weight": ( + loss.new_tensor(current_normalized_weight)), + "mixed_current_attraction": current_attraction.detach(), + "fixed_reference_constant": self.k_rr.detach(), + "current_teacher_self_kernel": ( + current_teacher_self_kernel.detach()), + "current_teacher_exact_mmd": ( + current_teacher_exact_mmd.detach()), + "current_teacher_samples": loss.new_tensor( + teacher_batch, dtype=torch.long), + }) + if partial_mode: + metadata.update({ + "current_teacher_attraction_denominator": ( + loss.new_tensor( + attraction_batch * teacher_batch, + dtype=torch.long, + ) + ), + "current_teacher_self_kernel_denominator": ( + loss.new_tensor( + teacher_batch * teacher_batch, + dtype=torch.long, + ) + ), + }) + if self.attraction_ema_enabled: + metadata.update({ + "current_attraction": current_attraction.detach(), + "attraction_ema_enabled": loss.new_ones( + (), dtype=torch.long), + "attraction_ema_updates": ( + self.attraction_ema_updates.detach().clone()), + "attraction_current_coefficient": loss.new_tensor( + current_coefficient), + }) + if self.current_teacher_attraction_enabled: + metadata.update({ + "attraction_ema_blend_current_coefficient": ( + loss.new_tensor( + ema_blend_current_coefficient)), + "attraction_gradient_current_coefficient": ( + loss.new_ones(())), + "attraction_ema_smoothed_value": attraction.detach(), + "effective_ema_loss": loss.detach(), + "attraction_gradient_mode": ( + "straight_through_full_current_mix"), + }) + if self.repulsion_fifo_enabled: + fill_after_commit = min( + self.repulsion_fifo_size, + history_size + gradient_batch, + ) + metadata.update({ + "repulsion_fifo_enabled": loss.new_ones( + (), dtype=torch.long), + "repulsion_fifo_capacity": loss.new_tensor( + self.repulsion_fifo_size, dtype=torch.long), + "repulsion_fifo_fill_before": loss.new_tensor( + history_size, dtype=torch.long), + # Compatibility spelling used by the trainer logger. + "repulsion_fifo_filled_before": loss.new_tensor( + history_size, dtype=torch.long), + "repulsion_fifo_fill_after_commit": loss.new_tensor( + fill_after_commit, dtype=torch.long), + "repulsion_fifo_commits": ( + self.repulsion_fifo_commits.detach().clone()), + "repulsion_fifo_committed_updates": ( + self.repulsion_fifo_commits.detach().clone()), + "repulsion_current_offdiag_kernel_sum": ( + current_offdiag_sum.detach()), + "repulsion_fifo_current_offdiag_sum": ( + current_offdiag_sum.detach()), + "repulsion_history_kernel_sum": ( + history_cross_sum.detach()), + "repulsion_fifo_history_cross_sum": ( + history_cross_sum.detach()), + "repulsion_denominator": loss.new_tensor( + denominator, dtype=torch.long), + "repulsion_fifo_denominator": loss.new_tensor( + denominator, dtype=torch.long), + }) + return loss, metadata + + @torch.no_grad() + def prepare_commit(self) -> None: + """Validate all staged distribution state before optimizer mutation.""" + if self.attraction_ema_enabled: + if self._pending_attraction is None: + raise RuntimeError("no attraction EMA update is staged") + if ( + self._pending_attraction.ndim != 0 + or not bool(torch.isfinite(self._pending_attraction).item()) + ): + raise FloatingPointError( + "refusing to commit a non-finite attraction EMA update") + if self.repulsion_fifo_enabled: + pending = self._pending_repulsion_fifo + if pending is None: + raise RuntimeError("no repulsion FIFO update is staged") + if ( + pending.ndim != 3 + or tuple(pending.shape[1:]) != self.sample_shape + or pending.shape[0] <= 0 + or pending.dtype != torch.float32 + or pending.device != self.repulsion_fifo.device + or pending.requires_grad + or pending.grad_fn is not None + ): + raise ValueError( + "staged repulsion FIFO values must be detached FP32 " + f"[B,{self.sample_shape[0]},{self.sample_shape[1]}] " + "on the FIFO device") + if not bool(torch.isfinite(pending).all().item()): + raise FloatingPointError( + "refusing to commit non-finite repulsion FIFO values") + if self._prepared_repulsion_fifo is None: + combined = torch.cat((self.repulsion_fifo, pending), dim=0) + if combined.shape[0] > self.repulsion_fifo_size: + combined = combined[-self.repulsion_fifo_size:] + # Allocate the right-sized, independently owned result while + # the trainer can still abort before optimizer mutation. + self._prepared_repulsion_fifo = ( + combined.detach().contiguous().clone()) + + @torch.no_grad() + def commit(self) -> None: + """Publish staged state only after a successful optimizer update.""" + if ( + self._pending_attraction is None + and self._pending_repulsion_fifo is None + ): + return + self.prepare_commit() + if self._pending_attraction is not None: + pending_attraction = self._pending_attraction + if int(self.attraction_ema_updates.item()) == 0: + self.attraction_ema.copy_(pending_attraction) + else: + self.attraction_ema.mul_(self.attraction_ema_beta).add_( + pending_attraction, + alpha=1.0 - self.attraction_ema_beta, + ) + self.attraction_ema_updates.add_(1) + if self._pending_repulsion_fifo is not None: + if self._prepared_repulsion_fifo is None: + raise RuntimeError("repulsion FIFO commit was not prepared") + self.repulsion_fifo = self._prepared_repulsion_fifo + self.repulsion_fifo_commits.add_(1) + self.discard() + + def discard(self) -> None: + """Drop all staged state after a failed or skipped update.""" + self._pending_attraction = None + self._pending_repulsion_fifo = None + self._prepared_repulsion_fifo = None + + def repulsion_fifo_state_dict(self) -> Optional[Dict[str, Any]]: + """Return the committed FIFO in oldest-to-newest order.""" + if not self.repulsion_fifo_enabled: + return None + if self.repulsion_fifo_pending: + raise RuntimeError( + "refusing to checkpoint a pending repulsion FIFO update") + return { + "version": self._REPULSION_FIFO_STATE_VERSION, + "capacity": self.repulsion_fifo_size, + "sample_shape": list(self.sample_shape), + "dtype": "float32", + "count": self.repulsion_fifo_fill, + "commits": int(self.repulsion_fifo_commits.item()), + "values": self.repulsion_fifo.detach().cpu().clone(), + } + def validate_repulsion_fifo_state_dict( + self, + state: Mapping[str, Any], + ) -> Dict[str, Any]: + """Validate and normalize a portable FIFO checkpoint payload.""" + if not self.repulsion_fifo_enabled: + raise ValueError( + "cannot load repulsion FIFO state while it is disabled") + if not isinstance(state, Mapping): + raise TypeError("repulsion FIFO state must be a mapping") + expected_keys = { + "version", "capacity", "sample_shape", "dtype", + "count", "commits", "values", + } + if set(state) != expected_keys: + raise ValueError( + "repulsion FIFO state keys mismatch: " + f"saved={sorted(state)}, expected={sorted(expected_keys)}") + if ( + state["version"] != self._REPULSION_FIFO_STATE_VERSION + or int(state["capacity"]) != self.repulsion_fifo_size + or tuple(state["sample_shape"]) != self.sample_shape + or state["dtype"] != "float32" + ): + raise ValueError("repulsion FIFO checkpoint/config mismatch") + count = int(state["count"]) + commits = int(state["commits"]) + values = state["values"] + if not 0 <= count <= self.repulsion_fifo_size or commits < 0: + raise ValueError("repulsion FIFO counters are invalid") + if ( + not isinstance(values, torch.Tensor) + or values.dtype != torch.float32 + or values.layout != torch.strided + or values.is_meta + or tuple(values.shape) != (count, *self.sample_shape) + or not bool(torch.isfinite(values).all().item()) + ): + raise ValueError( + "repulsion FIFO values must be finite float32 with shape " + f"{(count, *self.sample_shape)}") + return { + "version": self._REPULSION_FIFO_STATE_VERSION, + "capacity": self.repulsion_fifo_size, + "sample_shape": list(self.sample_shape), + "dtype": "float32", + "count": count, + "commits": commits, + "values": values.detach().cpu().clone(), + } + + @torch.no_grad() + def load_repulsion_fifo_state_dict( + self, + state: Mapping[str, Any], + ) -> None: + validated = self.validate_repulsion_fifo_state_dict(state) + self.repulsion_fifo = validated["values"].to( + device=self.landmarks.device, + dtype=torch.float32, + ).contiguous().clone() + self.repulsion_fifo_commits.fill_(validated["commits"]) + self.discard() + + def attraction_ema_state_dict(self) -> Optional[Dict[str, Any]]: + """Return the small trainer checkpoint payload for the opt-in EMA.""" + if not self.attraction_ema_enabled: + return None + if self.attraction_ema_pending: + raise RuntimeError( + "refusing to checkpoint a pending attraction EMA update") + return { + "version": self._ATTRACTION_EMA_STATE_VERSION, + "beta": self.attraction_ema_beta, + "value": self.attraction_ema.detach().cpu().clone(), + "updates": self.attraction_ema_updates.detach().cpu().clone(), + } + + def validate_attraction_ema_state_dict( + self, + state: Mapping[str, Any], + ) -> Dict[str, Any]: + """Validate and normalize a small attraction EMA checkpoint payload.""" + if not self.attraction_ema_enabled: + raise ValueError( + "cannot load attraction EMA state while the path is disabled") + if not isinstance(state, Mapping): + raise TypeError("attraction EMA state must be a mapping") + expected_keys = {"version", "beta", "value", "updates"} + if set(state) != expected_keys: + raise ValueError( + "attraction EMA state keys mismatch: " + f"saved={sorted(state)}, expected={sorted(expected_keys)}") + if ( + state["version"] != self._ATTRACTION_EMA_STATE_VERSION + or float(state["beta"]) != self.attraction_ema_beta + ): + raise ValueError( + "attraction EMA checkpoint/config mismatch: " + f"saved version/beta={state['version']!r}/{state['beta']!r}, " + "expected " + f"{self._ATTRACTION_EMA_STATE_VERSION}/" + f"{self.attraction_ema_beta}") + value = state["value"] + updates = state["updates"] + if ( + not isinstance(value, torch.Tensor) + or value.ndim != 0 + or value.dtype != torch.float32 + or value.layout != torch.strided + or value.is_meta + or not bool(torch.isfinite(value).item()) + ): + raise ValueError( + "attraction EMA checkpoint value must be a finite scalar " + "torch.float32 tensor") + if ( + not isinstance(updates, torch.Tensor) + or updates.ndim != 0 + or updates.dtype != torch.long + or updates.layout != torch.strided + or updates.is_meta + or int(updates.item()) < 0 + ): + raise ValueError( + "attraction EMA checkpoint updates must be a non-negative " + "scalar torch.long tensor") + if int(updates.item()) == 0 and float(value.item()) != 0.0: + raise ValueError( + "an uninitialized attraction EMA must have value zero") + return { + "version": self._ATTRACTION_EMA_STATE_VERSION, + "beta": self.attraction_ema_beta, + "value": value.detach().cpu().clone(), + "updates": updates.detach().cpu().clone(), + } + + @torch.no_grad() + def load_attraction_ema_state_dict( + self, + state: Mapping[str, Any], + ) -> None: + """Strictly restore only the transactional attraction EMA scalars.""" + validated = self.validate_attraction_ema_state_dict(state) + self.attraction_ema.copy_( + validated["value"].to( + device=self.attraction_ema.device, + dtype=self.attraction_ema.dtype, + ) + ) + self.attraction_ema_updates.copy_( + validated["updates"].to( + device=self.attraction_ema_updates.device) + ) + self.discard() + + def get_extra_state(self) -> Dict[str, Any]: + state = { + "version": self._STATE_VERSION, + "reference_metadata": self.reference_metadata, + "reference_hash": self.reference_hash, + } + if self.attraction_ema_enabled: + state = { + **state, + "version": self._STATE_VERSION + 1, + "attraction_ema": { + "version": self._ATTRACTION_EMA_STATE_VERSION, + "beta": self.attraction_ema_beta, + }, + } + if self.current_teacher_attraction_enabled: + state = { + **state, + "version": (4 if self.attraction_ema_enabled else 3), + "current_teacher_attraction": { + "enabled": True, + "fixed_weight": self.attraction_weight, + "current_weight": ( + self.current_teacher_attraction_weight), + "normalization": "divide_by_group_weight_sum", + "estimator": "exact_biased_gaussian_mmd", + }, + } + return state + + def set_extra_state(self, state: Mapping[str, Any]) -> None: + expected = self.get_extra_state() + if state != expected: + raise ValueError( + "Video Nyström MMD checkpoint/reference mismatch: " + f"saved={state}, current={expected}") diff --git a/14B/code/losses/wan_kv_mmd.py b/14B/code/losses/wan_kv_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..0036eaeb743092b4b3d9ae1b9a60bfe92a307b12 --- /dev/null +++ b/14B/code/losses/wan_kv_mmd.py @@ -0,0 +1,507 @@ +"""RBF-MMD on Wan KV matrices with a mathematically valid EMA distribution. + +An RBF kernel has an (infinite-dimensional) feature map ``phi`` and + + MMD²(P, Q) = || E_P[phi(x)] - E_Q[phi(y)] ||². + +We approximate ``phi`` with fixed random Fourier features (RFF), then keep an +EMA of the *kernel mean embeddings*. This represents an EMA mixture of whole +historical distributions; averaging raw KV rows across unrelated videos does +not. Historical embeddings are detached, so gradients only reach the latest +student KV matrix. The natural ``1-beta`` factor is intentionally preserved. + +History updates are transactional: ``forward(stage=True)`` only stages detached +current embeddings. ``commit()`` must be called after a successful optimizer +step; ``discard()`` is used when the optimizer step is skipped. +""" + +from __future__ import annotations + +import math +from typing import Iterable, Optional, Tuple + +import torch +import torch.distributed as dist +import torch.distributed.nn.functional as dist_nn +from torch import nn + + +def _sq_dist(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + x2 = x.square().sum(-1, keepdim=True) + y2 = y.square().sum(-1).unsqueeze(0) + return (x2 + y2 - 2.0 * x @ y.t()).clamp_min(0) + + +def exact_rbf_mmd( + student: torch.Tensor, + teacher: torch.Tensor, + *, + bandwidth: float, + bandwidth_multipliers: Iterable[float] = (0.5, 1.0, 2.0), +) -> torch.Tensor: + """Biased multi-bandwidth RBF-MMD² reference implementation.""" + if student.ndim != 2 or teacher.ndim != 2: + raise ValueError("exact_rbf_mmd expects [samples, features] matrices") + if student.shape[1] != teacher.shape[1] or min(student.shape[0], teacher.shape[0]) < 1: + raise ValueError("student/teacher matrices need matching feature dims and samples") + if not math.isfinite(float(bandwidth)) or float(bandwidth) <= 0: + raise ValueError("bandwidth must be finite and positive") + multipliers = tuple(float(v) for v in bandwidth_multipliers) + if not multipliers or any((not math.isfinite(v) or v <= 0) for v in multipliers): + raise ValueError("bandwidth multipliers must be finite and positive") + + x, y = student.float(), teacher.float() + dxx, dyy, dxy = _sq_dist(x, x), _sq_dist(y, y), _sq_dist(x, y) + bandwidth_sq = x.new_tensor(float(bandwidth) ** 2) + terms = [] + for multiplier in multipliers: + denom = 2.0 * bandwidth_sq * multiplier * multiplier + terms.append( + torch.exp(-dxx / denom).mean() + + torch.exp(-dyy / denom).mean() + - 2.0 * torch.exp(-dxy / denom).mean()) + return torch.stack(terms).mean() + + +class EMARBFMMD(nn.Module): + """EMA RBF kernel-mean MMD using fixed random Fourier features. + + ``sample_mode="rows"`` preserves the original behavior, where every + ``[D]`` row in a ``[B, N, D]`` input is one sample. In + ``sample_mode="video_matrix"``, the only sample axis is ``B`` and every + position-sensitive ``[N, D]`` matrix is one sample. Its RBF distance is + the Frobenius distance, equivalently the Euclidean distance after + vectorizing the matrix. + """ + + _STATE_VERSION = 2 + _SAMPLE_MODES = ("rows", "video_matrix") + + def __init__( + self, + beta: float = 0.99, + num_rff_features: int = 256, + bandwidth: Optional[float] = None, + bandwidth_multipliers: Iterable[float] = (0.5, 1.0, 2.0), + seed: int = 0, + row_normalize: bool = False, + sample_mode: str = "rows", + ): + super().__init__() + if not 0.0 <= float(beta) < 1.0: + raise ValueError("EMA beta must be in [0, 1)") + if int(num_rff_features) <= 0: + raise ValueError("num_rff_features must be positive") + multipliers = tuple(float(v) for v in bandwidth_multipliers) + if not multipliers or any((not math.isfinite(v) or v <= 0) for v in multipliers): + raise ValueError("bandwidth multipliers must be finite and positive") + if bandwidth is not None and ( + not math.isfinite(float(bandwidth)) or float(bandwidth) <= 0 + ): + raise ValueError("bandwidth must be finite and positive") + if sample_mode not in self._SAMPLE_MODES: + raise ValueError( + f"sample_mode must be one of {self._SAMPLE_MODES}, " + f"got {sample_mode!r}") + + self.beta = float(beta) + self.num_rff_features = int(num_rff_features) + self.bandwidth_multipliers = multipliers + self.seed = int(seed) + self.row_normalize = bool(row_normalize) + self.sample_mode = sample_mode + + initial_bw_sq = ( + float(bandwidth) ** 2 if bandwidth is not None else float("nan")) + self.register_buffer( + "bandwidth_sq", torch.tensor(initial_bw_sq, dtype=torch.float32), + persistent=True) + self.register_buffer("rff_weight", torch.empty(0), persistent=True) + self.register_buffer("rff_phase", torch.empty(0), persistent=True) + self.register_buffer("student_ema", torch.empty(0), persistent=True) + self.register_buffer("teacher_ema", torch.empty(0), persistent=True) + self.register_buffer( + "num_updates", torch.zeros((), dtype=torch.long), persistent=True) + + # Deliberately not buffers: an uncommitted optimizer step must never enter + # a checkpoint. + self._pending_student: Optional[torch.Tensor] = None + self._pending_teacher: Optional[torch.Tensor] = None + + @property + def embedding_dim(self) -> int: + return self.num_rff_features * len(self.bandwidth_multipliers) + + @staticmethod + def _distributed() -> bool: + return ( + dist.is_available() + and dist.is_initialized() + and dist.get_world_size() > 1 + ) + + @classmethod + @torch.no_grad() + def _all_ranks_true(cls, local_value: bool, device: torch.device) -> bool: + """Return one consensus bit before entering later collectives. + + A rank-local exception here would leave the other ranks blocked in the + RFF broadcast/all-reduce. Every input-validation failure therefore + becomes an all-rank failure. + """ + if not cls._distributed(): + return bool(local_value) + flag = torch.tensor( + int(bool(local_value)), device=device, dtype=torch.int32) + dist.all_reduce(flag, op=dist.ReduceOp.MIN) + return bool(flag.item()) + + @classmethod + def _global_mean( + cls, + local_sum: torch.Tensor, + local_count: int, + *, + return_count: bool = False, + ): + """Autograd-aware, sample-weighted mean across all ranks. + + The differentiable all-reduce is intentional. Since every rank + backpropagates the same global loss, its backward supplies the right + scaling before FSDP averages the replicated parameter gradients. + """ + if not cls._distributed(): + count = local_sum.new_tensor(float(local_count)) + mean = local_sum / float(local_count) + else: + global_sum = dist_nn.all_reduce( + local_sum, op=dist.ReduceOp.SUM) + count = local_sum.new_tensor(float(local_count)) + dist.all_reduce(count, op=dist.ReduceOp.SUM) + mean = global_sum / count + if return_count: + return mean, count + return mean + + @torch.no_grad() + def _estimate_bandwidth_sq( + self, student: torch.Tensor, teacher: torch.Tensor, max_samples: int = 256 + ) -> torch.Tensor: + if self.sample_mode == "rows": + merged = torch.cat([ + student.detach().flatten(0, 1), + teacher.detach().flatten(0, 1), + ], dim=0).float() + else: + # Keep each [N, D] video matrix intact: B is the sole sample axis. + merged = torch.cat([ + student.detach(), + teacher.detach(), + ], dim=0).float() + if merged.shape[0] > max_samples: + indices = torch.linspace( + 0, merged.shape[0] - 1, max_samples, device=merged.device + ).round().long() + merged = merged.index_select(0, indices) + if self._distributed(): + # Use all ranks for the one-time median heuristic. Rows are + # padded only for the collective and unpadded immediately after. + local_size = torch.tensor( + [merged.shape[0]], device=merged.device, dtype=torch.long) + gathered_sizes = [ + torch.zeros_like(local_size) for _ in range(dist.get_world_size()) + ] + dist.all_gather(gathered_sizes, local_size) + sizes = [int(size.item()) for size in gathered_sizes] + max_size = max(sizes) + padded = merged.new_zeros((max_size, *merged.shape[1:])) + padded[:merged.shape[0]].copy_(merged) + gathered_samples = [ + torch.empty_like(padded) for _ in range(dist.get_world_size()) + ] + dist.all_gather(gathered_samples, padded) + merged = torch.cat([ + samples[:size] + for samples, size in zip(gathered_samples, sizes) + ], dim=0) + + if merged.shape[0] < 2: + estimate = merged.new_tensor(1.0) + else: + if not self._distributed() or dist.get_rank() == 0: + if self.sample_mode == "rows": + distances_sq = torch.pdist(merged, p=2).square() + else: + # ||X-Y||_F^2 without collapsing [N, D] into a token axis. + squared_norm = merged.square().sum(dim=(1, 2)) + gram = torch.einsum( + "ind,jnd->ij", merged, merged) + pairwise_sq = ( + squared_norm[:, None] + + squared_norm[None, :] + - 2.0 * gram + ).clamp_min(0) + upper = torch.triu_indices( + merged.shape[0], + merged.shape[0], + offset=1, + device=merged.device, + ) + distances_sq = pairwise_sq[upper[0], upper[1]] + positive = distances_sq[distances_sq > 0] + estimate = ( + positive.median() + if positive.numel() + else distances_sq.new_tensor(1.0) + ).clamp_min(1e-6) + else: + estimate = merged.new_tensor(1.0) + if self._distributed(): + # All ranks must use the same fixed, checkpointed RFF map. + dist.broadcast(estimate, src=0) + return estimate + + @torch.no_grad() + def _initialize_rff(self, student: torch.Tensor, teacher: torch.Tensor) -> None: + if self.sample_mode == "rows": + sample_shape = (int(student.shape[-1]),) + else: + sample_shape = ( + int(student.shape[-2]), + int(student.shape[-1]), + ) + weight_shape = (*sample_shape, self.num_rff_features) + if self.rff_weight.numel() > 0: + if self.rff_weight.shape != weight_shape: + raise ValueError( + "KV sample shape changed: RFF map expects " + f"{tuple(self.rff_weight.shape[:-1])}, got {sample_shape}") + return + if not bool(torch.isfinite(self.bandwidth_sq).item()): + self.bandwidth_sq.copy_(self._estimate_bandwidth_sq(student, teacher)) + + generator = torch.Generator(device="cpu") + generator.manual_seed(self.seed) + weight = torch.randn( + weight_shape, generator=generator, + dtype=torch.float32) + phase = 2.0 * math.pi * torch.rand( + self.num_rff_features, generator=generator, dtype=torch.float32) + self.rff_weight.resize_(weight.shape).copy_( + weight.to(device=student.device)) + self.rff_phase.resize_(phase.shape).copy_( + phase.to(device=student.device)) + + def _kernel_sum(self, matrix: torch.Tensor) -> Tuple[torch.Tensor, int]: + samples = matrix.float() + if self.row_normalize: + samples = torch.nn.functional.normalize(samples, dim=-1) + if self.sample_mode == "rows": + samples = samples.flatten(0, 1) + base_projection = samples @ self.rff_weight + else: + # This is exactly + # samples.flatten(1) @ rff_weight.flatten(0, 1), while retaining + # the [N, D] axes and their positional identity. + base_projection = torch.einsum( + "bnd,ndr->br", samples, self.rff_weight) + bandwidth = self.bandwidth_sq.sqrt().clamp_min(1e-6) + scale = math.sqrt( + 2.0 / (self.num_rff_features * len(self.bandwidth_multipliers))) + features = [ + torch.cos( + base_projection / (bandwidth * multiplier) + self.rff_phase + ) * scale + for multiplier in self.bandwidth_multipliers + ] + return torch.cat(features, dim=-1).sum(dim=0), int(samples.shape[0]) + + def forward( + self, + student: torch.Tensor, + teacher: torch.Tensor, + *, + stage: bool = True, + ) -> Tuple[torch.Tensor, dict]: + local_shape_ok = ( + student.ndim == 3 + and teacher.ndim == 3 + and student.shape == teacher.shape + and student.device == teacher.device + and min(student.shape[0], student.shape[1], student.shape[2]) > 0 + ) + if not self._all_ranks_true(local_shape_ok, student.device): + raise ValueError( + "invalid Wan KV shape/device on at least one rank; each local " + "student/teacher pair must match [B, tokens, features]") + + # A mismatched feature width would initialize incompatible RFF maps. + # Matrix samples additionally require a shared N because every token + # position has its own slice of the position-sensitive random weight. + if self._distributed(): + rff_sample_shape = ( + student.shape[-1:] + if self.sample_mode == "rows" + else student.shape[1:] + ) + shape_bounds = torch.tensor( + [*rff_sample_shape, *(-value for value in rff_sample_shape)], + device=student.device, + dtype=torch.long, + ) + dist.all_reduce(shape_bounds, op=dist.ReduceOp.MIN) + num_dimensions = len(rff_sample_shape) + if any( + int(shape_bounds[index].item()) + != -int(shape_bounds[index + num_dimensions].item()) + for index in range(num_dimensions) + ): + raise ValueError( + "Wan KV RFF sample shape differs across distributed ranks") + + rff_initialized = self.rff_weight.numel() > 0 + expected_rff_shape = ( + (student.shape[-1], self.num_rff_features) + if self.sample_mode == "rows" + else ( + student.shape[-2], + student.shape[-1], + self.num_rff_features, + ) + ) + local_rff_shape_ok = ( + not rff_initialized + or self.rff_weight.shape == expected_rff_shape + ) + if not self._all_ranks_true(local_rff_shape_ok, student.device): + raise ValueError( + "RFF map shape is incompatible on at least one rank") + if self._distributed(): + bandwidth_initialized = bool( + torch.isfinite(self.bandwidth_sq).item()) + state_bounds = torch.tensor( + [ + int(rff_initialized), + -int(rff_initialized), + int(bandwidth_initialized), + -int(bandwidth_initialized), + ], + device=student.device, + dtype=torch.int32, + ) + dist.all_reduce(state_bounds, op=dist.ReduceOp.MIN) + if ( + int(state_bounds[0].item()) != -int(state_bounds[1].item()) + or int(state_bounds[2].item()) != -int(state_bounds[3].item()) + ): + raise RuntimeError( + "RFF/bandwidth initialization state differs across ranks") + + local_finite = bool( + torch.isfinite(student).all().item() + and torch.isfinite(teacher).all().item() + ) + if not self._all_ranks_true(local_finite, student.device): + raise FloatingPointError( + "non-finite Wan KV feature on at least one rank") + + teacher = teacher.detach() + self._initialize_rff(student, teacher) + student_sum, student_count = self._kernel_sum(student) + teacher_sum, teacher_count = self._kernel_sum(teacher) + current_student, global_count = self._global_mean( + student_sum, student_count, return_count=True) + current_teacher = self._global_mean(teacher_sum, teacher_count) + + if self.student_ema.numel() == 0: + current_coefficient = 1.0 + effective_student = current_student + effective_teacher = current_teacher.detach() + else: + current_coefficient = 1.0 - self.beta + # History has no graph. Consequently only current_student receives + # gradients, with the mathematically correct (1-beta) coefficient. + effective_student = ( + self.beta * self.student_ema.detach() + + (1.0 - self.beta) * current_student) + effective_teacher = ( + self.beta * self.teacher_ema.detach() + + (1.0 - self.beta) * current_teacher.detach()) + + loss = (effective_student - effective_teacher).square().sum() + if stage: + self._pending_student = current_student.detach() + self._pending_teacher = current_teacher.detach() + return loss, { + "bandwidth": self.bandwidth_sq.detach().sqrt(), + "history_updates": self.num_updates.detach().clone(), + "current_coefficient": loss.new_tensor(current_coefficient), + "local_samples": torch.tensor( + student_count, device=loss.device, dtype=torch.long), + "global_samples": global_count.detach().to(dtype=torch.long), + } + + @torch.no_grad() + def commit(self) -> None: + """Commit staged kernel means after a successful optimizer step.""" + if self._pending_student is None or self._pending_teacher is None: + return + if not ( + torch.isfinite(self._pending_student).all() + and torch.isfinite(self._pending_teacher).all() + ): + self.discard() + raise FloatingPointError("refusing to commit non-finite KV kernel mean") + if self.student_ema.numel() == 0: + self.student_ema.resize_as_(self._pending_student).copy_( + self._pending_student) + self.teacher_ema.resize_as_(self._pending_teacher).copy_( + self._pending_teacher) + else: + self.student_ema.mul_(self.beta).add_( + self._pending_student, alpha=1.0 - self.beta) + self.teacher_ema.mul_(self.beta).add_( + self._pending_teacher, alpha=1.0 - self.beta) + self.num_updates.add_(1) + self.discard() + + def discard(self) -> None: + """Drop staged state when backward/optimizer is skipped.""" + self._pending_student = None + self._pending_teacher = None + + def get_extra_state(self): + state = { + "version": self._STATE_VERSION, + "beta": self.beta, + "num_rff_features": self.num_rff_features, + "bandwidth_multipliers": self.bandwidth_multipliers, + "seed": self.seed, + "row_normalize": self.row_normalize, + } + # Preserve the exact legacy rows-mode payload. Wan v3 checkpoints + # contain this dict and must remain loadable without migration. + if self.sample_mode != "rows": + state["sample_mode"] = self.sample_mode + return state + + def set_extra_state(self, state): + expected = self.get_extra_state() + if state != expected: + raise ValueError( + f"Wan KV MMD checkpoint/config mismatch: saved={state}, " + f"current={expected}") + + def _load_from_state_dict( + self, state_dict, prefix, local_metadata, strict, missing_keys, + unexpected_keys, error_msgs, + ): + for name in ( + "rff_weight", "rff_phase", "student_ema", "teacher_ema", + ): + key = prefix + name + if key in state_dict: + getattr(self, name).resize_as_(state_dict[key]) + super()._load_from_state_dict( + state_dict, prefix, local_metadata, strict, missing_keys, + unexpected_keys, error_msgs) diff --git a/14B/code/model/__init__.py b/14B/code/model/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1c6db2e4de3fb031fe92a58874fe32d7ed051239 --- /dev/null +++ b/14B/code/model/__init__.py @@ -0,0 +1,4 @@ +"""distribution matching distribution-matching model.""" +from .distribution_matching import DistributionMatchingModel, WanKVMMD + +__all__ = ["DistributionMatchingModel", "WanKVMMD"] diff --git a/14B/code/model/base.py b/14B/code/model/base.py new file mode 100644 index 0000000000000000000000000000000000000000..92c4c7498840f84cbefe37f955dea650a440874b --- /dev/null +++ b/14B/code/model/base.py @@ -0,0 +1,467 @@ +from typing import Tuple +from einops import rearrange +from torch import nn +import torch.distributed as dist +import torch + +from pipeline import SelfForcingTrainingPipeline +from utils.loss import get_denoising_loss +from utils.wan_wrapper import WanDiffusionWrapper, WanTextEncoder, WanVAEWrapper + + +class BaseModel(nn.Module): + def __init__(self, args, device): + super().__init__() + self._initialize_models(args, device) + + self.device = device + self.args = args + self.dtype = torch.bfloat16 if args.mixed_precision else torch.float32 + if hasattr(args, "denoising_step_list"): + self.denoising_step_list = torch.tensor(args.denoising_step_list, dtype=torch.long) + if args.warp_denoising_step: + timesteps = torch.cat((self.scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32))) + self.denoising_step_list = timesteps[1000 - self.denoising_step_list] + + def _initialize_models(self, args, device): + self.real_model_name = getattr(args, "real_name", "Wan2.1-T2V-1.3B") + self.fake_model_name = getattr(args, "fake_name", "Wan2.1-T2V-1.3B") + + # ``model_kwargs`` already carries timestep_shift; we additionally route + # the (optional) linear-attention configuration from the top-level + # config into the wrapper so a single config file controls everything. + gen_kwargs = dict(getattr(args, "model_kwargs", {})) + attn_type = getattr(args, "attention_type", "softmax") + # ``gdn`` (ARL² GDN hybrid) is selected like any other attention type, + # but it is realized by injecting GDN layers into a softmax backbone + # (see wan/modules/gdn.py), so the underlying CausalWanModel is softmax. + is_gdn = attn_type == "gdn" + backbone_attn_type = "softmax" if is_gdn else attn_type + gen_kwargs.setdefault("attention_type", backbone_attn_type) + if backbone_attn_type in ("linear", "hybrid"): + gen_kwargs.setdefault( + "num_frame_per_block", getattr(args, "num_frame_per_block", 1), + ) + if backbone_attn_type == "hybrid": + gen_kwargs.setdefault( + "hybrid_gate", bool(getattr(args, "hybrid_gate", False)), + ) + gen_kwargs.setdefault( + "hybrid_gate_init_lambda", float(getattr(args, "hybrid_gate_init_lambda", 0.12)), + ) + gen_kwargs.setdefault( + "hybrid_gate_type", str(getattr(args, "hybrid_gate_type", "none")), + ) + gen_kwargs.setdefault( + "alpha_init_bias", float(getattr(args, "alpha_init_bias", -3.0)), + ) + gen_kwargs.setdefault( + "alpha_detach_denom_delta", bool(getattr(args, "alpha_detach_denom_delta", True)), + ) + gen_kwargs.setdefault( + "alpha_detach_state_summary", bool(getattr(args, "alpha_detach_state_summary", True)), + ) + # Fast alpha-hybrid path knobs (all default-off so this is a no-op + # for existing configs). Honour only when the active gate type + # is an alpha_* mode; the wrapper itself silently ignores them + # for "none" / "lambda" / non-hybrid attention. + gen_kwargs.setdefault( + "use_fast_alpha_hybrid", bool(getattr(args, "use_fast_alpha_hybrid", False)), + ) + gen_kwargs.setdefault( + "hybrid_local_impl", str(getattr(args, "hybrid_local_impl", "torch")), + ) + gen_kwargs.setdefault( + "hybrid_cross_impl", str(getattr(args, "hybrid_cross_impl", "torch")), + ) + gen_kwargs.setdefault( + "alpha_combine_impl", str(getattr(args, "alpha_combine_impl", "torch")), + ) + gen_kwargs.setdefault( + "fallback_to_torch_on_error", bool(getattr(args, "fallback_to_torch_on_error", True)), + ) + gen_kwargs.setdefault( + "match_torch_centering", bool(getattr(args, "match_torch_centering", True)), + ) + gen_kwargs.setdefault( + "fast_alpha_maxperf", bool(getattr(args, "fast_alpha_maxperf", False)), + ) + # ---- mixed-layer attention ---- + sm_indices = getattr(args, "softmax_layer_indices", None) + sm_interval = getattr(args, "softmax_layer_interval", None) + if sm_indices is not None: + gen_kwargs.setdefault("softmax_layer_indices", list(sm_indices)) + if sm_interval is not None: + gen_kwargs.setdefault("softmax_layer_interval", int(sm_interval)) + gen_kwargs.setdefault( + "softmax_layer_local_attn_size", + int(getattr(args, "softmax_layer_local_attn_size", -1)), + ) + gen_kwargs.setdefault( + "softmax_layer_sink_size", + int(getattr(args, "softmax_layer_sink_size", 0)), + ) + generator_is_causal = bool(getattr(args, "causal", True)) + self.generator = WanDiffusionWrapper(**gen_kwargs, is_causal=generator_is_causal) + + # ── GDN (ARL²) hybrid attention ────────────────────────────────────── + # When ``attention_type: gdn`` (or a legacy ``arl2_*`` layer spec is + # present) the softmax generator is converted into a GDN hybrid model: + # HybridBlockAttention + GDN/FFN LoRA are injected at the selected + # layers and an optional Stage-2 checkpoint is warm-started. The whole + # model is then trained by DMD (the ARL² DMD-continuation recipe). + gdn_configured = is_gdn or any( + getattr(args, name, None) is not None + for name in ( + "gdn_layer_indices", + "gdn_layer_indices_json", + "arl2_hybrid_layers", + "arl2_hybrid_layers_json", + ) + ) + gdn_layers = None + if gdn_configured: + # GDN depends on the optional ``fla`` package. Keep that + # dependency optional for ordinary softmax/linear Wan training. + from wan.modules.gdn import ( + gdn_layers_from_config, + setup_gdn_generator, + ) + gdn_layers = gdn_layers_from_config(args) + + if generator_is_causal and gdn_configured: + assert backbone_attn_type == "softmax", ( + "GDN injection expects a softmax CausalWanModel backbone; got " + "attention_type=%r" % attn_type) + setup_gdn_generator(self.generator, args, load_stage2=True) + if (not dist.is_initialized()) or dist.get_rank() == 0: + print(f"[base] GDN hybrid generator: injected at layers {gdn_layers}") + elif not generator_is_causal and gdn_configured: + raise ValueError("GDN/hybrid injection is only supported for causal generators") + + self.generator.model.requires_grad_(True) + + self.uses_score_models = bool( + getattr(args, "uses_score_models", getattr(args, "distribution_loss", None) != "fd") + ) + self.uses_real_score = bool(getattr(args, "uses_real_score", self.uses_score_models)) + self.uses_fake_score = bool(getattr(args, "uses_fake_score", self.uses_score_models)) + if self.uses_real_score: + self.real_score = WanDiffusionWrapper(model_name=self.real_model_name, is_causal=False) + self.real_score.model.requires_grad_(False) + else: + self.real_score = None + if self.uses_fake_score: + self.fake_score = WanDiffusionWrapper(model_name=self.fake_model_name, is_causal=False) + self.fake_score.model.requires_grad_(True) + else: + self.fake_score = None + + # Construct the frozen 5.7B T5 encoder directly in the training dtype. + # Building four temporary fp32 copies can otherwise exceed host memory + # before FSDP gets a chance to shard them. + text_dtype = ( + torch.bfloat16 if bool(getattr(args, "mixed_precision", False)) + else torch.float32 + ) + self.text_encoder = WanTextEncoder(dtype=text_dtype) + self.text_encoder.requires_grad_(False) + + self.vae = WanVAEWrapper() + self.vae.requires_grad_(False) + + self.scheduler = self.generator.get_scheduler() + self.scheduler.timesteps = self.scheduler.timesteps.to(device) + + def _get_timestep( + self, + min_timestep: int, + max_timestep: int, + batch_size: int, + num_frame: int, + num_frame_per_block: int, + uniform_timestep: bool = False + ) -> torch.Tensor: + """ + Randomly generate a timestep tensor based on the generator's task type. It uniformly samples a timestep + from the range [min_timestep, max_timestep], and returns a tensor of shape [batch_size, num_frame]. + - If uniform_timestep, it will use the same timestep for all frames. + - If not uniform_timestep, it will use a different timestep for each block. + """ + if uniform_timestep: + timestep = torch.randint( + min_timestep, + max_timestep, + [batch_size, 1], + device=self.device, + dtype=torch.long + ).repeat(1, num_frame) + return timestep + else: + timestep = torch.randint( + min_timestep, + max_timestep, + [batch_size, num_frame], + device=self.device, + dtype=torch.long + ) + # make the noise level the same within every block + if self.independent_first_frame: + # the first frame is always kept the same + timestep_from_second = timestep[:, 1:] + timestep_from_second = timestep_from_second.reshape( + timestep_from_second.shape[0], -1, num_frame_per_block) + timestep_from_second[:, :, 1:] = timestep_from_second[:, :, 0:1] + timestep_from_second = timestep_from_second.reshape( + timestep_from_second.shape[0], -1) + timestep = torch.cat([timestep[:, 0:1], timestep_from_second], dim=1) + else: + timestep = timestep.reshape( + timestep.shape[0], -1, num_frame_per_block) + timestep[:, :, 1:] = timestep[:, :, 0:1] + timestep = timestep.reshape(timestep.shape[0], -1) + return timestep + + +class SelfForcingModel(BaseModel): + def __init__(self, args, device): + super().__init__(args, device) + self.denoising_loss_func = get_denoising_loss(args.denoising_loss_type)() + + def _sample_generator_input_descriptor( + self, + image_or_video_shape, + initial_latent: torch.Tensor = None, + ) -> dict: + """Explicitly sample `_run_generator`'s block count and initial noise. + + The returned descriptor is reusable: passing it back to + ``_run_generator`` avoids consulting the ambient RNG for those direct + inputs. A caller replaying the full rollout must additionally capture + the post-sampling RNG state used by stochastic pipeline internals. + """ + if self.args.i2v: + noise_shape = [ + image_or_video_shape[0], + image_or_video_shape[1] - 1, + *image_or_video_shape[2:], + ] + else: + noise_shape = list(image_or_video_shape) + + min_num_frames = 20 if self.args.independent_first_frame else 21 + max_num_frames = ( + self.num_training_frames - 1 + if self.args.independent_first_frame + else self.num_training_frames + ) + if max_num_frames % self.num_frame_per_block != 0: + raise ValueError( + "num_training_frames is not divisible by " + "num_frame_per_block") + if min_num_frames % self.num_frame_per_block != 0: + raise ValueError( + "minimum generated frames is not divisible by " + "num_frame_per_block") + max_num_blocks = max_num_frames // self.num_frame_per_block + min_num_blocks = min_num_frames // self.num_frame_per_block + block_count = torch.randint( + min_num_blocks, + max_num_blocks + 1, + (1,), + device=self.device, + ) + if dist.is_available() and dist.is_initialized(): + dist.broadcast(block_count, src=0) + num_generated_blocks = int(block_count.item()) + num_generated_frames = ( + num_generated_blocks * self.num_frame_per_block) + if self.args.independent_first_frame and initial_latent is None: + num_generated_frames += 1 + noise_shape[1] = num_generated_frames + noise = torch.randn( + noise_shape, device=self.device, dtype=self.dtype) + return { + "version": 2, + "num_generated_blocks": num_generated_blocks, + "noise": noise, + "trajectory_seeds": torch.randint( + 0, 2**63 - 1, (noise_shape[0],), + device=self.device, dtype=torch.int64), + } + + def _run_generator( + self, + image_or_video_shape, + conditional_dict: dict, + initial_latent: torch.tensor = None, + generation_descriptor: dict = None, + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Optionally simulate the generator's input from noise using backward simulation + and then run the generator for one-step. + Input: + - image_or_video_shape: a list containing the shape of the image or video [B, F, C, H, W]. + - conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings). + - unconditional_dict: a dictionary containing the unconditional information (e.g. null/negative text embeddings, null/negative image embeddings). + - clean_latent: a tensor containing the clean latents [B, F, C, H, W]. Need to be passed when no backward simulation is used. + - initial_latent: a tensor containing the initial latents [B, F, C, H, W]. + Output: + - pred_image: a tensor with shape [B, F, C, H, W]. + - denoised_timestep: an integer + """ + # Step 1: Sample noise and backward simulate the generator's input + assert getattr(self.args, "backward_simulation", True), "Backward simulation needs to be enabled" + if initial_latent is not None: + conditional_dict["initial_latent"] = initial_latent + # During training, the number of generated frames should be uniformly sampled from + # [21, self.num_training_frames], but still being a multiple of self.num_frame_per_block + min_num_frames = 20 if self.args.independent_first_frame else 21 + max_num_frames = self.num_training_frames - 1 if self.args.independent_first_frame else self.num_training_frames + assert max_num_frames % self.num_frame_per_block == 0 + assert min_num_frames % self.num_frame_per_block == 0 + max_num_blocks = max_num_frames // self.num_frame_per_block + min_num_blocks = min_num_frames // self.num_frame_per_block + if generation_descriptor is None: + generation_descriptor = ( + self._sample_generator_input_descriptor( + image_or_video_shape, + initial_latent=initial_latent, + ) + ) + if ( + not isinstance(generation_descriptor, dict) + or set(generation_descriptor) + != {"version", "num_generated_blocks", "noise", "trajectory_seeds"} + or int(generation_descriptor["version"]) != 2 + ): + raise ValueError("invalid explicit generator input descriptor") + num_generated_blocks = int( + generation_descriptor["num_generated_blocks"]) + if not min_num_blocks <= num_generated_blocks <= max_num_blocks: + raise ValueError( + "generator descriptor block count is outside the configured " + f"range [{min_num_blocks}, {max_num_blocks}]") + if dist.is_available() and dist.is_initialized(): + block_min = torch.tensor( + num_generated_blocks, device=self.device, dtype=torch.long) + block_max = block_min.clone() + dist.all_reduce(block_min, op=dist.ReduceOp.MIN) + dist.all_reduce(block_max, op=dist.ReduceOp.MAX) + if int(block_min.item()) != int(block_max.item()): + raise ValueError( + "generator descriptor block count differs across ranks") + num_generated_frames = num_generated_blocks * self.num_frame_per_block + if self.args.independent_first_frame and initial_latent is None: + num_generated_frames += 1 + min_num_frames += 1 + expected_noise_shape = list(image_or_video_shape) + if self.args.i2v: + expected_noise_shape[1] -= 1 + expected_noise_shape[1] = num_generated_frames + generation_noise = generation_descriptor["noise"] + expected_device = ( + torch.device("cuda", int(self.device)) + if isinstance(self.device, int) + else torch.device(self.device) + ) + if ( + not isinstance(generation_noise, torch.Tensor) + or tuple(generation_noise.shape) != tuple(expected_noise_shape) + or generation_noise.device != expected_device + or generation_noise.dtype != self.dtype + or not bool(torch.isfinite(generation_noise).all().item()) + ): + raise ValueError( + "generator descriptor noise is incompatible with the " + f"expected {tuple(expected_noise_shape)} {self.dtype} on " + f"{expected_device}") + trajectory_seeds = generation_descriptor["trajectory_seeds"] + if ( + not isinstance(trajectory_seeds, torch.Tensor) + or tuple(trajectory_seeds.shape) != (expected_noise_shape[0],) + or trajectory_seeds.device != expected_device + or trajectory_seeds.dtype != torch.int64 + or bool((trajectory_seeds < 0).any().item()) + ): + raise ValueError( + "generator descriptor trajectory_seeds must be one " + "non-negative int64 CUDA seed per sample") + + pred_image_or_video, denoised_timestep_from, denoised_timestep_to = self._consistency_backward_simulation( + noise=generation_noise, + trajectory_seeds=trajectory_seeds, + **conditional_dict, + ) + # Slice last 21 frames + if pred_image_or_video.shape[1] > 21: + with torch.no_grad(): + # Reencode to get image latent + latent_to_decode = pred_image_or_video[:, :-20, ...] + # Deccode to video + pixels = self.vae.decode_to_pixel(latent_to_decode) + frame = pixels[:, -1:, ...].to(self.dtype) + frame = rearrange(frame, "b t c h w -> b c t h w") + # Encode frame to get image latent + image_latent = self.vae.encode_to_latent(frame).to(self.dtype) + pred_image_or_video_last_21 = torch.cat([image_latent, pred_image_or_video[:, -20:, ...]], dim=1) + else: + pred_image_or_video_last_21 = pred_image_or_video + + if num_generated_frames != min_num_frames: + # Currently, we do not use gradient for the first chunk, since it contains image latents + gradient_mask = torch.ones_like(pred_image_or_video_last_21, dtype=torch.bool) + if self.args.independent_first_frame: + gradient_mask[:, :1] = False + else: + gradient_mask[:, :self.num_frame_per_block] = False + else: + gradient_mask = None + + pred_image_or_video_last_21 = pred_image_or_video_last_21.to(self.dtype) + return pred_image_or_video_last_21, gradient_mask, denoised_timestep_from, denoised_timestep_to + + def _consistency_backward_simulation( + self, + noise: torch.Tensor, + trajectory_seeds: torch.Tensor, + **conditional_dict: dict + ) -> torch.Tensor: + """ + Simulate the generator's input from noise to avoid training/inference mismatch. + See Sec 4.5 of the DMD2 paper (https://arxiv.org/abs/2405.14867) for details. + Here we use the consistency sampler (https://arxiv.org/abs/2303.01469) + Input: + - noise: a tensor sampled from N(0, 1) with shape [B, F, C, H, W] where the number of frame is 1 for images. + - conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings). + Output: + - output: a tensor with shape [B, T, F, C, H, W]. + T is the total number of timesteps. output[0] is a pure noise and output[i] and i>0 + represents the x0 prediction at each timestep. + """ + if self.inference_pipeline is None: + self._initialize_inference_pipeline() + + return self.inference_pipeline.inference_with_trajectory( + noise=noise, + trajectory_seeds=trajectory_seeds, + **conditional_dict, + ) + + def _initialize_inference_pipeline(self): + """ + Lazy initialize the inference pipeline during the first backward simulation run. + Here we encapsulate the inference code with a model-dependent outside function. + We pass our FSDP-wrapped modules into the pipeline to save memory. + """ + self.inference_pipeline = SelfForcingTrainingPipeline( + denoising_step_list=self.denoising_step_list, + scheduler=self.scheduler, + generator=self.generator, + num_frame_per_block=self.num_frame_per_block, + independent_first_frame=self.args.independent_first_frame, + same_step_across_blocks=self.args.same_step_across_blocks, + last_step_only=self.args.last_step_only, + num_max_frames=self.num_training_frames, + context_noise=self.args.context_noise + ) diff --git a/14B/code/model/distribution_matching.py b/14B/code/model/distribution_matching.py new file mode 100644 index 0000000000000000000000000000000000000000..b5fc59680aae99f8c487386680d1a3bd910040ec --- /dev/null +++ b/14B/code/model/distribution_matching.py @@ -0,0 +1,5905 @@ +"""distribution matching: match generated videos in frozen encoder feature spaces. + +Read the active path in this order: + collect_rollout_features -> hybrid MMD -> prepare_latent_gradients + -> replay_generator_gradients. + +The encoders and VAE are frozen, but their input derivatives connect the +feature-space objective to the generator. Modules/attributes used in saved +state dictionaries keep their original names for checkpoint compatibility. +See docs/paper-to-code.md for the paper mapping and estimator conventions. +""" + +from __future__ import annotations + +import copy +from contextlib import contextmanager, nullcontext +import hashlib +import json +import os +import random +import time +from typing import Mapping, Optional, Tuple + +import numpy as np +import torch +import torch.distributed as dist + +from losses.multilayer_video_nystrom_mmd import ( + MultiLayerVideoNystromMMD, +) +from losses.wan_kv_mmd import EMARBFMMD +from model.generation_base import GeneratorRuntimeBase +from utils.active_mmd_branches import active_mmd_branches +from utils.model_identity import ( + canonical_identity_value, + canonical_sha256, + distributed_file_sha256, + distributed_runtime_file_identity, + file_sha256, + runtime_file_identity, +) +from video_fd.encoders.vjepa2_dense_metadata import ( + canonical_vjepa2_dense_feature_metadata, + uniform_dense_token_indices, +) +from video_fd.encoders.vjepa2_temporal_sampling import ( + stateless_temporal_window_start, +) +from video_fd.replay.replay_video_encoder_vjp import replay_decoder_vjp + + +def _canonicalize_shared_asset_paths(value): + """Normalize the local and shared mount names recursively.""" + local_root = "/path/to/local-assets" + logical_root = "/path/to/video-generation-assets" + if isinstance(value, str): + if value == local_root or value.startswith(local_root + "/"): + return logical_root + value[len(local_root):] + return value + if isinstance(value, Mapping): + return { + key: _canonicalize_shared_asset_paths(item) + for key, item in value.items() + } + if isinstance(value, tuple): + return tuple(_canonicalize_shared_asset_paths(item) for item in value) + if isinstance(value, list): + return [_canonicalize_shared_asset_paths(item) for item in value] + return value + + +def _accumulate_fp32_pixel_cotangent( + accumulated: Optional[torch.Tensor], + value: torch.Tensor, + expected_shape: Optional[tuple[int, ...]], + *, + branch_name: str, +) -> tuple[torch.Tensor, tuple[int, ...], torch.Tensor]: + """Accumulate one branch immediately and retain only its scalar norm.""" + if not isinstance(value, torch.Tensor) or not value.is_floating_point(): + raise TypeError(f"{branch_name} pixel cotangent must be floating") + detached = value.detach().float() + shape = tuple(detached.shape) + if expected_shape is not None and shape != expected_shape: + raise ValueError( + "triple encoder pixel cotangent shapes differ: " + f"{branch_name}={shape} != {expected_shape}") + if not bool(torch.isfinite(detached).all().item()): + raise FloatingPointError( + f"{branch_name} pixel cotangent is non-finite") + branch_norm = detached.norm().detach() + if accumulated is None: + accumulated = detached + else: + accumulated.add_(detached) + return accumulated, shape, branch_norm + + +class DistributionMatchingModel(GeneratorRuntimeBase): + """Match generated and teacher latents in a configured dense feature space. + + The generator remains the only trainable model. In ``post_block_latent`` + mode ``real_score`` is reused as the frozen Wan encoder so the trainer can + shard it normally with FSDP. In ``vjepa2_dense_latent`` mode ``real_score`` + is not constructed; a frozen, replicated FP32 V-JEPA2 prefix is used + instead. + """ + + uses_critic = False + uses_vae_for_loss = False + uses_vae_for_visualization = True + requires_distribution_state_on_resume = True + + _DENSE_VIDEO_FEATURE_TYPES = frozenset({ + "vjepa2_dense_latent", + "dinov3_dense_latent", + "videomae_v2_dense_latent", + "dual_vjepa2_videomae_v2_dense_latent", + "dual_dinov3_vjepa2_dense_latent", + "triple_dinov3_vjepa2_videomae_v2_dense_latent", + }) + + @staticmethod + def _dense_feature_prefix_for(feature_type: str) -> Optional[str]: + return { + "vjepa2_dense_latent": "vjepa2", + "dinov3_dense_latent": "dinov3", + "videomae_v2_dense_latent": "videomae_v2", + "dual_vjepa2_videomae_v2_dense_latent": "dual", + "dual_dinov3_vjepa2_dense_latent": "dinov3_vjepa2", + "triple_dinov3_vjepa2_videomae_v2_dense_latent": "triple", + }.get(str(feature_type)) + + def _dense_config_value(self, args, suffix: str, default): + """Read a backbone-specific public config key. + + The established replay implementation keeps its historical internal + ``vjepa2_*`` attributes for compatibility. Public VideoMAE-v2 configs, + metadata, logs, and checkpoint keys remain independently namespaced. + """ + prefix = self.dense_feature_prefix + return getattr(args, f"{prefix}_{suffix}", default) + + @property + def dense_state_prefix(self) -> str: + """Public artifact namespace for the active dense-video backbone.""" + prefix = getattr(self, "dense_feature_prefix", None) + if prefix is None: + prefix = self._dense_feature_prefix_for( + getattr(self, "feature_type", "")) + return prefix or "vjepa2" + + @property + def dense_nystrom_version_key(self) -> str: + return f"{self.dense_state_prefix}_dense_nystrom_mmd_version" + + @property + def dense_ema_version_key(self) -> str: + return f"{self.dense_state_prefix}_dense_mmd_version" + + @property + def dense_ema_payload_key(self) -> str: + return f"{self.dense_state_prefix}_dense_mmd" + + @property + def dense_feature_display_name(self) -> str: + return ( + "VideoMAE-v2" + if self.dense_state_prefix == "videomae_v2" + else ( + "DINOv3 + V-JEPA2 + VideoMAE-v2" + if self.dense_state_prefix == "triple" + else ( + "DINOv3 + V-JEPA2" + if self.dense_state_prefix == "dinov3_vjepa2" + else ( + "V-JEPA2 + VideoMAE-v2" + if self.dense_state_prefix == "dual" + else ("DINOv3" if self.dense_state_prefix == "dinov3" else "V-JEPA2") + ) + ) + ) + ) + + @property + def dense_feature_metric_prefix(self) -> str: + return f"{self.dense_state_prefix}_dense" + + @property + def dense_sampled_tokens_per_layer(self) -> int: + return int(self.vjepa2_dense_num_tokens) * int( + getattr(self, "dense_feature_segment_count", 1)) + + @property + def dense_raw_tokens_total(self) -> int: + return int(self.vjepa2_dense_raw_tokens) * int( + getattr(self, "dense_feature_segment_count", 1)) + + def _namespace_dense_metrics(self, values: Mapping[str, object]) -> dict: + """Translate legacy internal metric names at the public boundary.""" + if self.dense_state_prefix == "vjepa2": + return dict(values) + if self.dense_state_prefix in { + "dual", "triple", "dinov3", "dinov3_vjepa2" + }: + return dict(values) + return { + ( + key.replace("vjepa2_", "videomae_v2_", 1) + if isinstance(key, str) and key.startswith("vjepa2_") + else key + ): value + for key, value in values.items() + } + + @staticmethod + def _all_ranks_true(local_value: bool, device) -> bool: + flag = torch.tensor( + int(bool(local_value)), device=device, dtype=torch.int32) + if ( + dist.is_available() + and dist.is_initialized() + and dist.get_world_size() > 1 + ): + dist.all_reduce(flag, op=dist.ReduceOp.MIN) + return bool(flag.item()) + + def _synchronize_dinov3_local_failure( + self, + error: Optional[BaseException], + device, + *, + operation: str, + ) -> None: + """Make a rank-local DINOv3 failure an all-rank branch decision. + + DINOv3 startup performs expensive local imports, file inspection and + metadata validation before it hashes shared artifacts. A rank that + raises before a peer enters one of those hash broadcasts would strand + the peer. Every such local-only phase terminates through this helper. + Single-process callers retain the original exception type and message. + """ + if self._all_ranks_true(error is None, device): + return + is_distributed = ( + dist.is_available() + and dist.is_initialized() + and dist.get_world_size() > 1 + ) + if error is not None and not is_distributed: + raise error + detail = ( + f": {type(error).__name__}: {error}" + if error is not None + else " on another rank" + ) + raise RuntimeError(operation + detail) from error + @staticmethod + def _configured_current_teacher_global_samples(args) -> int: + """Resolve the exact live-teacher geometry for this process group. + + Legacy GradCache updates use every forward sample for the MMD VJP, so + their teacher count remains ``total_batch_size``. Partial replay + explicitly configures a per-rank gradient suffix; only that suffix has + a matched teacher and therefore the exact teacher kernel has + ``gradient_local * world_size`` rows. + """ + configured_total = int(getattr(args, "total_batch_size", 0)) + gradient_local = getattr( + args, "fresh_video_generator_grad_local_samples", None) + if gradient_local is None: + return configured_total + if type(gradient_local) is not int or gradient_local <= 0: + raise ValueError( + "fresh_video_generator_grad_local_samples must be a " + "positive integer") + forward_local = getattr(args, "fresh_video_local_samples", None) + if type(forward_local) is not int or forward_local <= 0: + raise ValueError( + "fresh_video_local_samples must be a positive integer when " + "partial GradCache replay is configured") + if gradient_local > forward_local: + raise ValueError( + "fresh_video_generator_grad_local_samples must not exceed " + "fresh_video_local_samples") + + if dist.is_available() and dist.is_initialized(): + world_size = int(dist.get_world_size()) + else: + if configured_total <= 0 or configured_total % forward_local != 0: + raise ValueError( + "total_batch_size must be divisible by " + "fresh_video_local_samples before distributed " + "initialization") + world_size = configured_total // forward_local + expected_forward = forward_local * world_size + if configured_total != expected_forward: + raise ValueError( + "total_batch_size does not match the partial GradCache " + "forward geometry: " + f"{configured_total} != {forward_local} * {world_size}") + return gradient_local * world_size + + def __init__(self, args, device): + p0_value = os.environ.get("SF_FRESH_VIDEO_P0_REPLAY") + if p0_value is None: + self._fresh_video_p0_requested = True + else: + normalized = p0_value.strip().lower() + if normalized not in {"0", "1", "false", "true", "no", "yes"}: + raise ValueError( + "SF_FRESH_VIDEO_P0_REPLAY must be one of " + "0/1/false/true/no/yes") + self._fresh_video_p0_requested = normalized in { + "1", "true", "yes"} + # Final eligibility is known only after the estimator is initialized. + self.fresh_video_prepared_replay_enabled = False + self.fresh_video_pixel_cache_device = os.environ.get( + "SF_FRESH_VIDEO_PIXEL_CACHE_DEVICE", "cuda" + ).strip().lower() + if self.fresh_video_pixel_cache_device not in {"cpu", "cuda"}: + raise ValueError( + "SF_FRESH_VIDEO_PIXEL_CACHE_DEVICE must be cpu or cuda") + requested_feature_type = str( + getattr(args, "wan_feature_type", "post_block_latent")) + supported_feature_types = { + "post_block_latent", + "vjepa2_dense_latent", + "dinov3_dense_latent", + "videomae_v2_dense_latent", + "dual_vjepa2_videomae_v2_dense_latent", + "dual_dinov3_vjepa2_dense_latent", + "triple_dinov3_vjepa2_videomae_v2_dense_latent", + } + if requested_feature_type not in supported_feature_types: + raise ValueError( + "wan_feature_type must be one of " + f"{sorted(supported_feature_types)}, got " + f"{requested_feature_type!r}") + self.dense_feature_prefix = self._dense_feature_prefix_for( + requested_feature_type) + is_dense_video_feature = bool( + requested_feature_type in self._DENSE_VIDEO_FEATURE_TYPES) + self.mmd_estimator = str( + getattr(args, "wan_mmd_estimator", "ema_rff")) + supported_estimators = {"ema_rff", "nystrom_exact_repulsion"} + if self.mmd_estimator not in supported_estimators: + raise ValueError( + "wan_mmd_estimator must be one of " + f"{sorted(supported_estimators)}, got " + f"{self.mmd_estimator!r}") + if ( + self.mmd_estimator == "nystrom_exact_repulsion" + and not is_dense_video_feature + ): + raise ValueError( + "nystrom_exact_repulsion currently requires " + "a dense video feature backend") + self.is_vjepa2_dense = ( + requested_feature_type == "vjepa2_dense_latent") + self.is_dinov3_dense = ( + requested_feature_type == "dinov3_dense_latent") + self.is_videomae_v2_dense = ( + requested_feature_type == "videomae_v2_dense_latent") + self.is_dinov3_vjepa2_dense = ( + requested_feature_type == "dual_dinov3_vjepa2_dense_latent") + self.is_triple_dense = ( + requested_feature_type + == "triple_dinov3_vjepa2_videomae_v2_dense_latent") + self.is_dense_video_feature = is_dense_video_feature + if self.is_dinov3_dense and ( + self.mmd_estimator != "nystrom_exact_repulsion" + ): + raise ValueError( + "wan_feature_type=dinov3_dense_latent currently requires " + "wan_mmd_estimator=nystrom_exact_repulsion") + if self.is_dense_video_feature: + # BaseModel otherwise constructs full real/fake Wan score models. + # Neither participates in this generator-only replay objective. + args.uses_score_models = False + args.uses_real_score = False + args.uses_fake_score = False + self.generator_replay_activation_cpu_offload = bool(getattr( + args, "generator_replay_activation_cpu_offload", False)) + self.generator_replay_activation_cpu_offload_block_stride = int( + getattr( + args, + "generator_replay_activation_cpu_offload_block_stride", + 0, + )) + if self.generator_replay_activation_cpu_offload_block_stride < 0: + raise ValueError( + "generator_replay_activation_cpu_offload_block_stride must " + "be non-negative") + if ( + self.generator_replay_activation_cpu_offload + and self.generator_replay_activation_cpu_offload_block_stride + ): + raise ValueError( + "choose either full generator replay activation CPU " + "offload or a positive block stride, not both") + self.generator_replay_frozen_encoder_cpu_offload = bool(getattr( + args, "generator_replay_frozen_encoder_cpu_offload", False)) + self._generator_replay_frozen_encoder_offload_state = None + if ( + self.generator_replay_frozen_encoder_cpu_offload + and not self.is_triple_dense + ): + raise ValueError( + "generator replay frozen-encoder CPU offload is only " + "implemented for the triple video MMD objective") + super().__init__(args, device) + self.generator.model.gradient_checkpointing_activation_cpu_offload = ( + False) + self.generator.model.gradient_checkpointing_activation_cpu_offload_block_stride = ( + self.generator_replay_activation_cpu_offload_block_stride) + if self.generator_replay_activation_cpu_offload and ( + not dist.is_initialized() or dist.get_rank() == 0): + print("[memory] generator checkpoint activations offload to CPU") + if self.generator_replay_activation_cpu_offload_block_stride and ( + not dist.is_initialized() or dist.get_rank() == 0): + print( + "[memory] generator checkpoint activations selectively " + "offload to CPU every " + f"{self.generator_replay_activation_cpu_offload_block_stride} " + "blocks") + self.feature_type = requested_feature_type + self.dense_encoder_name = self.dense_state_prefix + self.dense_log_prefix = f"{self.dense_encoder_name}_dense" + + if self.feature_type == "post_block_latent": + self._initialize_wan_features(args) + elif self.is_vjepa2_dense: + self._initialize_vjepa2_features(args, device) + elif self.is_dinov3_dense: + self._initialize_dinov3_features(args, device) + elif self.feature_type == "videomae_v2_dense_latent": + self._initialize_videomae_v2_features(args, device) + elif self.is_dinov3_vjepa2_dense: + self._initialize_dual_dinov3_vjepa2_features(args, device) + else: + self._initialize_multi_encoder_features(args, device) + + self.latent_loss_weight = float( + getattr(args, "wan_kv_loss_weight", 1.0)) + self.latent_self_normalize = bool( + getattr(args, "wan_kv_self_normalize", True)) + self.latent_norm_eps = float( + getattr(args, "wan_kv_norm_eps", 1e-4)) + if ( + getattr(self, "vjepa2_multilayer_enabled", False) + and self.mmd_estimator != "nystrom_exact_repulsion" + ): + raise ValueError( + "multi-layer V-JEPA2 MMD requires " + "wan_mmd_estimator=nystrom_exact_repulsion") + if ( + getattr(self, "vjepa2_multilayer_enabled", False) + and not self.latent_self_normalize + ): + raise ValueError( + "multi-layer V-JEPA2 MMD requires " + "wan_kv_self_normalize=true so layer losses are normalized " + "independently before summation") + if self.is_vjepa2_dense: + self.vjepa2_token_sampling = str(getattr( + args, "vjepa2_token_sampling", "uniform_fixed")) + if self.vjepa2_token_sampling != "uniform_fixed": + raise ValueError( + "this release supports vjepa2_token_sampling=" + "uniform_fixed") + else: + self.vjepa2_token_sampling = None + self.uses_fresh_video_gradcache = ( + self.mmd_estimator == "nystrom_exact_repulsion") + self.nystrom_attraction_ema_enabled = bool( + self._dense_config_value( + args, "nystrom_attraction_ema_enabled", False)) + self.nystrom_attraction_ema_beta = float( + self._dense_config_value( + args, "nystrom_attraction_ema_beta", 0.99)) + self.nystrom_attraction_weight = float( + self._dense_config_value( + args, "nystrom_attraction_weight", 1.0)) + self.current_teacher_attraction_enabled = bool( + self._dense_config_value( + args, "nystrom_current_teacher_attraction_enabled", False)) + self.current_teacher_attraction_weight = float( + self._dense_config_value( + args, "nystrom_current_teacher_attraction_weight", 1.0)) + self.expected_current_teacher_samples = int(getattr( + args, "expected_current_teacher_samples", 0)) + self.current_teacher_source = str(getattr( + args, "current_teacher_source", "auto")) + if self.current_teacher_source not in { + "auto", "none", "lmdb_latent", "openvid_video", + "matched_manifest_video", "matched_dual_feature_bank", + "matched_triple_feature_bank", + "matched_dinov3_vjepa2_feature_bank", + }: + raise ValueError( + "unsupported current_teacher_source: " + f"{self.current_teacher_source!r}") + self.nystrom_repulsion_fifo_size = int( + self._dense_config_value( + args, "nystrom_repulsion_fifo_size", 0)) + if ( + not np.isfinite(self.nystrom_attraction_weight) + or self.nystrom_attraction_weight < 0.0 + ): + raise ValueError( + f"{self.dense_state_prefix}_nystrom_attraction_weight must " + "be finite and non-negative") + if ( + not np.isfinite(self.current_teacher_attraction_weight) + or self.current_teacher_attraction_weight < 0.0 + ): + raise ValueError( + f"{self.dense_state_prefix}_nystrom_current_teacher_" + "attraction_weight must be finite and non-negative") + if ( + self.current_teacher_attraction_enabled + and self.nystrom_attraction_weight + + self.current_teacher_attraction_weight <= 0.0 + ): + raise ValueError( + "fixed/current teacher attraction weights must have a " + "positive sum") + if self.nystrom_repulsion_fifo_size < 0: + raise ValueError( + "dense Nyström repulsion FIFO size must be non-negative") + self.nystrom_repulsion_fifo_enabled = bool( + self.nystrom_repulsion_fifo_size > 0) + if ( + not np.isfinite(self.nystrom_attraction_ema_beta) + or not 0.0 <= self.nystrom_attraction_ema_beta < 1.0 + ): + raise ValueError( + "dense Nyström attraction EMA beta must be in [0, 1)") + if ( + self.nystrom_attraction_ema_enabled + and not self.uses_fresh_video_gradcache + ): + raise ValueError( + "vjepa2_nystrom_attraction_ema_enabled requires " + "wan_mmd_estimator=nystrom_exact_repulsion") + if ( + self.current_teacher_attraction_enabled + and not self.uses_fresh_video_gradcache + ): + raise ValueError( + "current teacher attraction requires " + "wan_mmd_estimator=nystrom_exact_repulsion") + if ( + self.current_teacher_attraction_enabled + and getattr(self, "vjepa2_multilayer_enabled", False) + ): + raise ValueError( + "current teacher attraction currently supports one dense " + "feature layer") + if self.current_teacher_attraction_enabled: + configured_teacher_total = ( + self._configured_current_teacher_global_samples(args) + ) + if self.expected_current_teacher_samples <= 0: + raise ValueError( + "expected_current_teacher_samples must be explicitly " + "positive when current teacher attraction is enabled") + if configured_teacher_total != self.expected_current_teacher_samples: + raise ValueError( + "the configured GradCache gradient geometry must match " + "expected_current_teacher_samples for exact current " + "teacher MMD: " + f"{configured_teacher_total} != " + f"{self.expected_current_teacher_samples}") + if ( + self.nystrom_repulsion_fifo_enabled + and not self.uses_fresh_video_gradcache + ): + raise ValueError( + "vjepa2_nystrom_repulsion_fifo_size requires " + "wan_mmd_estimator=nystrom_exact_repulsion") + if ( + self.nystrom_repulsion_fifo_enabled + and getattr(self, "vjepa2_multilayer_enabled", False) + ): + raise ValueError( + "repulsion FIFO currently supports one fixed V-JEPA2 layer") + if ( + self.nystrom_repulsion_fifo_enabled + and self.nystrom_attraction_ema_enabled + ): + raise ValueError( + "repulsion FIFO training requires attraction EMA to be disabled") + self.fresh_video_prepared_replay_enabled = bool( + self._fresh_video_p0_requested + and self.uses_fresh_video_gradcache + ) + if self.uses_fresh_video_gradcache: + if not self.causal_generator: + raise NotImplementedError( + "nystrom_exact_repulsion currently requires the causal " + "generator so every stochastic rollout input can be " + "captured explicitly") + from losses.video_nystrom_mmd import ( + VideoNystromMMD, + load_reference_pack, + ) + kernel_chunk_size = int( + getattr( + args, + "dinov3_nystrom_kernel_chunk_size", + 32, + ) + if self.is_dinov3_dense + else self._dense_config_value( + args, "nystrom_kernel_chunk_size", 32) + ) + if self.is_dinov3_dense: + reference_load_error = None + reference_path = None + reference_pack = None + try: + configured_reference = str(getattr( + args, + "dinov3_nystrom_reference_path", + "", + )).strip() + reference_path = ( + os.path.realpath(os.path.expanduser( + configured_reference)) + if configured_reference else "" + ) + if ( + not reference_path + or not os.path.isfile(reference_path) + ): + raise FileNotFoundError( + "DINOv3 dense Nyström requires a precomputed fixed " + "reference pack under " + "dinov3_nystrom_reference_path; " + f"got {reference_path!r}") + reference_pack = load_reference_pack( + reference_path, map_location="cpu") + except BaseException as exc: + reference_load_error = exc + self._synchronize_dinov3_local_failure( + reference_load_error, + device, + operation="DINOv3 reference-pack loading failed", + ) + self._validate_dinov3_reference_runtime_identity( + args, reference_pack["metadata"]) + objective_error = None + try: + reference_section = reference_pack["metadata"].get( + "reference", {}) + reference_source = ( + reference_section.get("source", {}) + if isinstance(reference_section, dict) + else {} + ) + feature_bank_hash = reference_source.get( + "feature_bank_fp16_sha256") + if ( + not isinstance(feature_bank_hash, str) + or len(feature_bank_hash) != 64 + or any( + character not in "0123456789abcdef" + for character in feature_bank_hash + ) + ): + raise ValueError( + "DINOv3 Nyström reference is missing a valid " + "frozen teacher feature_bank_fp16_sha256") + objective = VideoNystromMMD( + reference_pack, + expected_metadata={ + "feature": self._dinov3_feature_metadata(), + "nystrom": { + "num_landmarks": int(getattr( + args, "dinov3_nystrom_landmarks", 256)), + "seed": int(getattr( + args, "dinov3_nystrom_seed", 0)), + }, + }, + kernel_chunk_size=kernel_chunk_size, + attraction_ema_enabled=False, + repulsion_fifo_size=0, + ) + reference_count = int( + objective.reference_metadata[ + "nystrom"]["reference_count"]) + if int(reference_source.get( + "selected_samples", -1 + )) != reference_count: + raise ValueError( + "DINOv3 Nyström reference source count does not " + "match the frozen kernel reference count") + self.dinov3_nystrom_reference_path = reference_path + self.dinov3_nystrom_reference_hash = str( + objective.reference_hash) + self.dinov3_teacher_feature_bank_hash = feature_bank_hash + self.dinov3_nystrom_reference_count = reference_count + self.latent_mmd = objective.to(device=device) + except BaseException as exc: + objective_error = exc + self._synchronize_dinov3_local_failure( + objective_error, + device, + operation="DINOv3 reference objective setup failed", + ) + else: + def load_fixed_reference(layer_index, configured_path): + reference_path = ( + os.path.realpath(os.path.expanduser( + str(configured_path).strip())) + if str(configured_path).strip() else "" + ) + if ( + not reference_path + or not os.path.isfile(reference_path) + ): + raise FileNotFoundError( + "nystrom_exact_repulsion requires a precomputed " + "reference pack; got " + f"{reference_path!r} for layer {layer_index}") + reference_pack = load_reference_pack( + reference_path, map_location="cpu") + reference_section = reference_pack["metadata"].get( + "reference", {}) + reference_source = ( + reference_section.get("source", {}) + if isinstance(reference_section, dict) + else {} + ) + feature_bank_hash = reference_source.get( + "feature_bank_fp16_sha256") + if ( + not isinstance(feature_bank_hash, str) + or len(feature_bank_hash) != 64 + or any( + character not in "0123456789abcdef" + for character in feature_bank_hash + ) + ): + raise ValueError( + "Nyström reference is missing a valid frozen " + "teacher feature_bank_fp16_sha256 for layer " + f"{layer_index}") + objective = VideoNystromMMD( + reference_pack, + expected_metadata={ + "feature": self._vjepa2_feature_metadata( + layer_index=layer_index), + "nystrom": { + "num_landmarks": int( + self._dense_config_value( + args, "nystrom_landmarks", 256)), + "seed": int( + self._dense_config_value( + args, "nystrom_seed", 0)), + }, + }, + kernel_chunk_size=kernel_chunk_size, + attraction_ema_enabled=( + self.nystrom_attraction_ema_enabled), + attraction_ema_beta=( + self.nystrom_attraction_ema_beta), + attraction_weight=( + self.nystrom_attraction_weight), + current_teacher_attraction_enabled=( + self.current_teacher_attraction_enabled), + current_teacher_attraction_weight=( + self.current_teacher_attraction_weight), + repulsion_fifo_size=( + self.nystrom_repulsion_fifo_size), + ) + reference_count = int( + objective.reference_metadata[ + "nystrom"]["reference_count"]) + if int(reference_source.get( + "selected_samples", -1 + )) != reference_count: + raise ValueError( + "Nyström reference source count does not match " + "the frozen kernel reference count for layer " + f"{layer_index}") + source_identity = { + key: value + for key, value in reference_source.items() + if key != "feature_bank_fp16_sha256" + } + return ( + reference_path, + objective, + feature_bank_hash, + reference_count, + source_identity, + ) + + if getattr(self, "vjepa2_multilayer_enabled", False): + configured_paths = getattr( + args, "vjepa2_nystrom_reference_paths", None) + if ( + configured_paths is None + or isinstance(configured_paths, (str, bytes)) + ): + raise ValueError( + "multi-layer V-JEPA2 MMD requires " + "vjepa2_nystrom_reference_paths in layer order") + configured_paths = tuple(configured_paths) + if len(configured_paths) != len( + self.vjepa2_dense_layers + ): + raise ValueError( + "vjepa2_nystrom_reference_paths must match " + "vjepa2_dense_layers one-for-one") + loaded = [ + load_fixed_reference(layer, path) + for layer, path in zip( + self.vjepa2_dense_layers, + configured_paths, + ) + ] + objectives = { + layer: item[1] + for layer, item in zip( + self.vjepa2_dense_layers, loaded) + } + self.latent_mmd = MultiLayerVideoNystromMMD( + objectives, + self_normalize=self.latent_self_normalize, + norm_eps=self.latent_norm_eps, + ).to(device=device) + self.vjepa2_nystrom_reference_paths = tuple( + item[0] for item in loaded) + self.vjepa2_nystrom_reference_hashes = tuple( + objective.reference_hash + for objective in objectives.values()) + self.vjepa2_teacher_feature_bank_hashes = tuple( + item[2] for item in loaded) + reference_counts = tuple( + item[3] for item in loaded) + if len(set(reference_counts)) != 1: + raise ValueError( + "multi-layer Nyström references must contain " + "the same teacher sample count") + source_identities = tuple( + item[4] for item in loaded) + if any( + identity != source_identities[0] + for identity in source_identities[1:] + ): + raise ValueError( + "multi-layer Nyström references must use the " + "same teacher LMDB rows and prompt index") + self.vjepa2_nystrom_reference_count = ( + reference_counts[0]) + # Singular aliases keep generic diagnostics compatible; + # checkpoint identity uses the complete tuples below. + self.vjepa2_nystrom_reference_path = ( + self.vjepa2_nystrom_reference_paths[-1]) + self.vjepa2_nystrom_reference_hash = ( + self.vjepa2_nystrom_reference_hashes[-1]) + self.vjepa2_teacher_feature_bank_hash = ( + self.vjepa2_teacher_feature_bank_hashes[-1]) + else: + configured_reference_path = self._dense_config_value( + args, "nystrom_reference_path", "") + ( + reference_path, + objective, + feature_bank_hash, + reference_count, + _, + ) = load_fixed_reference( + self.vjepa2_dense_layer, + configured_reference_path, + ) + self.vjepa2_nystrom_reference_path = reference_path + self.latent_mmd = objective.to(device=device) + self.vjepa2_teacher_feature_bank_hash = ( + feature_bank_hash) + self.vjepa2_nystrom_reference_hash = str( + objective.reference_hash) + self.vjepa2_nystrom_reference_count = reference_count + if self.is_dinov3_dense and ( + not dist.is_available() + or not dist.is_initialized() + or dist.get_rank() == 0 + ): + print( + "[dinov3-nystrom] loaded frozen reference " + f"n={self.dinov3_nystrom_reference_count}, " + f"m={self.latent_mmd.landmarks.shape[0]}, " + f"sigma={float(self.latent_mmd.sigma):.6g}, " + f"hash={self.dinov3_nystrom_reference_hash}, " + "teacher_features=" + f"{self.dinov3_teacher_feature_bank_hash}", + flush=True, + ) + elif ( + not dist.is_available() + or not dist.is_initialized() + or dist.get_rank() == 0 + ): + if getattr(self, "vjepa2_multilayer_enabled", False): + reference_summary = ", ".join( + ( + f"layer={layer}:" + f"sigma={float(objective.sigma):.6g}:" + f"hash={objective.reference_hash}" + ) + for layer, objective in zip( + self.vjepa2_dense_layers, + self.latent_mmd.objectives.values(), + ) + ) + print( + "[vjepa2-nystrom] loaded frozen multi-layer " + f"references n={self.vjepa2_nystrom_reference_count}, " + f"m={self.latent_mmd.landmarks.shape[0]}, " + f"normalized={self.latent_self_normalize}, " + "attraction_ema=" + f"{self.nystrom_attraction_ema_enabled}, " + "attraction_weight=" + f"{self.nystrom_attraction_weight:g}, " + "repulsion_fifo=" + f"{self.nystrom_repulsion_fifo_size}, " + f"{reference_summary}", + flush=True, + ) + else: + print( + f"[{self.dense_encoder_name}-nystrom] loaded frozen reference " + f"n={self.vjepa2_nystrom_reference_count}, " + f"m={self.latent_mmd.landmarks.shape[0]}, " + f"sigma={float(self.latent_mmd.sigma):.6g}, " + f"hash={self.vjepa2_nystrom_reference_hash}, " + "attraction_ema=" + f"{self.nystrom_attraction_ema_enabled}, " + "repulsion_fifo=" + f"{self.nystrom_repulsion_fifo_size}, " + "teacher_features=" + f"{self.vjepa2_teacher_feature_bank_hash}", + flush=True, + ) + else: + self.latent_mmd = EMARBFMMD( + beta=float(getattr(args, "wan_kv_ema_beta", 0.99)), + num_rff_features=int( + getattr(args, "wan_kv_rff_features", 256)), + bandwidth=getattr(args, "wan_kv_rbf_bandwidth", None), + bandwidth_multipliers=getattr( + args, "wan_kv_rbf_multipliers", [0.5, 1.0, 2.0]), + seed=int(getattr(args, "wan_kv_rff_seed", 0)), + row_normalize=bool( + getattr(args, "wan_kv_row_normalize", False)), + sample_mode=( + "video_matrix" + if self.is_dense_video_feature + else "rows" + ), + ).to(device) + if self.feature_type == "triple_dinov3_vjepa2_videomae_v2_dense_latent": + self._build_multi_encoder_objective(args, device) + elif self.is_dinov3_vjepa2_dense: + self._replace_with_dual_dinov3_vjepa2_nystrom_objective( + args, device) + # Dense video backends always decode videos. The Wan path only needs VAE + # loss decoding for longer causal rollouts. + self.uses_vae_for_loss = ( + self.is_dense_video_feature + or bool(self.causal_generator and self.num_training_frames > 21) + ) + def offload_frozen_modules_for_generator_replay(self) -> int: + """Offload frozen representation modules after their exact VJPs. + + This mirrors the proven VACE replay schedule: the complete-batch MMD + is evaluated first, selected samples replay the frozen encoders and + VAE to obtain detached latent cotangents, and only then are those + frozen modules moved to CPU for the generator-only replay. + """ + if not self.generator_replay_frozen_encoder_cpu_offload: + return 0 + if self._generator_replay_frozen_encoder_offload_state is not None: + raise RuntimeError("frozen replay modules are already offloaded") + registry = getattr(self, "dual_branch_registry", None) + if not isinstance(registry, Mapping) or not registry: + raise RuntimeError("frozen replay requires an active encoder registry") + module_names = ("vae",) + tuple( + branch["encoder_attribute"] for branch in registry.values()) + if len(set(module_names)) != len(module_names): + raise RuntimeError("frozen replay registry contains duplicate modules") + original_device = torch.device(self.device) + moved = [] + tensor_bytes = 0 + try: + torch.cuda.synchronize(original_device) + for module_name in module_names: + module = getattr(self, module_name, None) + if not isinstance(module, torch.nn.Module): + raise RuntimeError( + f"frozen replay module {module_name} is unavailable") + if module.training or any( + parameter.requires_grad for parameter in module.parameters() + ): + raise RuntimeError( + f"refusing to offload non-frozen module {module_name}") + tensors = tuple(module.parameters()) + tuple(module.buffers()) + if not tensors: + raise RuntimeError( + f"frozen replay module {module_name} is not fully CUDA-resident") + tensor_bytes += sum( + tensor.numel() * tensor.element_size() for tensor in tensors + ) + module.to(device="cpu") + moved.append(module_name) + torch.cuda.synchronize(original_device) + torch.cuda.empty_cache() + except BaseException: + for module_name in moved: + getattr(self, module_name).to(device=original_device) + torch.cuda.synchronize(original_device) + raise + self._generator_replay_frozen_encoder_offload_state = { + "module_names": module_names, + "original_device": str(original_device), + "tensor_bytes": int(tensor_bytes), + } + if not dist.is_initialized() or dist.get_rank() == 0: + print( + f"[memory] replay offloaded frozen modules={module_names} " + f"({tensor_bytes / (1024 ** 3):.2f} GiB tensors)", + flush=True, + ) + return int(tensor_bytes) + + def restore_frozen_modules_before_feature_replay(self) -> int: + """Restore frozen modules before the next complete-batch feature pass.""" + state = self._generator_replay_frozen_encoder_offload_state + if state is None: + return 0 + original_device = torch.device(state["original_device"]) + for module_name in state["module_names"]: + module = getattr(self, module_name, None) + if not isinstance(module, torch.nn.Module): + raise RuntimeError( + f"offloaded replay module {module_name} disappeared") + tensors = tuple(module.parameters()) + tuple(module.buffers()) + if not tensors or any( + tensor.device.type != "cpu" for tensor in tensors + ): + raise RuntimeError( + f"offloaded replay module {module_name} is not fully on CPU") + module.to(device=original_device) + module.eval().requires_grad_(False) + torch.cuda.synchronize(original_device) + tensor_bytes = int(state["tensor_bytes"]) + self._generator_replay_frozen_encoder_offload_state = None + if not dist.is_initialized() or dist.get_rank() == 0: + print( + f"[memory] restored frozen modules={state['module_names']} " + "for the next MMD feature/replay pass", + flush=True, + ) + return tensor_bytes + + _canonical_sha256 = staticmethod(canonical_sha256) + _canonical_identity_value = staticmethod(canonical_identity_value) + _file_sha256 = staticmethod(file_sha256) + _distributed_file_sha256 = staticmethod(distributed_file_sha256) + _runtime_file_identity = staticmethod(runtime_file_identity) + _distributed_runtime_file_identity = staticmethod( + distributed_runtime_file_identity) + + def _validate_dinov3_reference_runtime_identity( + self, + args, + reference_metadata: Mapping, + *, + inspect_lmdb=None, + model_path_resolver=None, + wrapper_source_path=None, + file_hasher=None, + ) -> dict: + """Bind a DINOv3 reference to the active CausVid LMDB and Wan VAE. + + All fallible rank-local inspection completes and reaches consensus + before any rank enters a shared-file hash broadcast. The large VAE + files are then identified entirely on rank zero. Injection seams keep + the artifact checks unit-testable without loading a real LMDB or VAE. + """ + from pathlib import Path + + collective_device = getattr(self, "device", None) + if collective_device is None: + collective_device = torch.device("cpu") + preflight = None + preflight_error = None + try: + if not isinstance(reference_metadata, Mapping): + raise TypeError("DINOv3 reference metadata must be a mapping") + reference = reference_metadata.get("reference") + if not isinstance(reference, Mapping): + raise ValueError( + "DINOv3 reference metadata is missing reference identity") + source = reference.get("source") + saved_vae = reference.get("vae") + if not isinstance(source, Mapping) or not isinstance( + saved_vae, Mapping + ): + raise ValueError( + "DINOv3 reference must contain source and VAE identities") + source = dict(source) + saved_vae = dict(saved_vae) + + teacher_path = os.path.realpath(os.path.expanduser(str(getattr( + args, "teacher_latent_data_path", "")))) + if not teacher_path or not os.path.isdir(teacher_path): + raise FileNotFoundError( + "DINOv3 reference validation requires the configured " + f"teacher LMDB directory; got {teacher_path!r}") + active_inspector = inspect_lmdb + if active_inspector is None: + from tools.precompute_dinov3_dense_nystrom_reference import ( + _inspect_lmdb, + ) + + active_inspector = _inspect_lmdb + live_lmdb = dict(active_inspector(Path(teacher_path))) + live_lmdb_path = os.path.realpath(str( + live_lmdb.get("path", ""))) + live_latents_shape = [ + int(value) + for value in live_lmdb.get("latents_shape", ()) + ] + live_prompts_shape = [ + int(value) + for value in live_lmdb.get("prompts_shape", ()) + ] + if ( + live_lmdb_path != teacher_path + or len(live_latents_shape) != 6 + or live_prompts_shape != [live_latents_shape[0]] + ): + raise ValueError( + "active teacher LMDB has an invalid CausVid array identity") + teacher_count = int(getattr( + args, "teacher_latent_count", live_latents_shape[0])) + data_mdb_path = os.path.join(teacher_path, "data.mdb") + if not os.path.isfile(data_mdb_path): + raise FileNotFoundError( + f"teacher LMDB data file is missing: {data_mdb_path}") + data_stat = os.stat(data_mdb_path) + expected_source_keys = { + "kind", + "lmdb_path", + "latents_shape", + "prompts_shape", + "selected_samples", + "selected_index_start", + "selected_index_stop_exclusive", + "trajectory_index", + "lmdb_storage_dtype", + "data_mdb_size_bytes", + "data_mdb_mtime_ns", + "prompt_index_sha256", + "feature_bank_fp16_sha256", + "identity_sha256", + } + source_without_hash = dict(source) + saved_source_hash = source_without_hash.pop( + "identity_sha256", None) + source_hash_valid = ( + isinstance(saved_source_hash, str) + and saved_source_hash + == self._canonical_sha256(source_without_hash) + ) + source_sha_fields_valid = all( + isinstance(source.get(key), str) + and len(source[key]) == 64 + and all( + character in "0123456789abcdef" + for character in source[key] + ) + for key in ( + "prompt_index_sha256", + "feature_bank_fp16_sha256", + ) + ) + if ( + set(source) != expected_source_keys + or source.get("kind") + != "causvid_ode_teacher_lmdb_final_latent" + or os.path.realpath(str(source.get("lmdb_path", ""))) + != teacher_path + or source.get("latents_shape") != live_latents_shape + or source.get("prompts_shape") != live_prompts_shape + or int(source.get("selected_samples", -1)) + != live_latents_shape[0] + or live_latents_shape[0] != 1537 + or teacher_count != live_latents_shape[0] + or int(source.get("selected_index_start", -1)) != 0 + or int(source.get("selected_index_stop_exclusive", -1)) + != live_latents_shape[0] + or int(source.get("trajectory_index", -2)) != -1 + or source.get("lmdb_storage_dtype") != "float16" + or int(source.get("data_mdb_size_bytes", -1)) + != int(data_stat.st_size) + or int(source.get("data_mdb_mtime_ns", -1)) + != int(data_stat.st_mtime_ns) + or int(live_lmdb.get("data_mdb_size_bytes", -1)) + != int(data_stat.st_size) + or int(live_lmdb.get("data_mdb_mtime_ns", -1)) + != int(data_stat.st_mtime_ns) + or not source_sha_fields_valid + or not source_hash_valid + ): + raise ValueError( + "DINOv3 reference teacher LMDB identity does not match " + "the active CausVid dataset") + + active_model_path_resolver = model_path_resolver + if active_model_path_resolver is None: + from utils.paths import model_path + + active_model_path_resolver = model_path + vae_model_name = "Wan2.1-T2V-1.3B" + weights_path = ( + Path(active_model_path_resolver(vae_model_name)) + / "Wan2.1_VAE.pth" + ) + active_wrapper_source_path = wrapper_source_path + if active_wrapper_source_path is None: + active_wrapper_source_path = ( + Path(__file__).resolve().parents[1] + / "utils" / "wan_wrapper.py" + ) + # Prove every rank can resolve/stat both identities before rank zero + # enters either hash transaction. + for identity_path in (weights_path, active_wrapper_source_path): + resolved = os.path.realpath(os.path.expanduser( + os.fspath(identity_path))) + if not os.path.isfile(resolved): + raise FileNotFoundError( + f"runtime identity target is not a file: {resolved}") + os.stat(resolved) + + vae_mean = getattr(self.vae, "mean", None) + vae_std = getattr(self.vae, "std", None) + if not isinstance(vae_mean, torch.Tensor) or not isinstance( + vae_std, torch.Tensor + ): + raise TypeError( + "active Wan VAE wrapper is missing tensor mean/std " + "constants") + runtime_vae_base = { + "wrapper": ( + f"{type(self.vae).__module__}." + f"{type(self.vae).__qualname__}"), + "model_name": vae_model_name, + "latent_channels": int(vae_mean.numel()), + "latent_mean": [ + round(float(value), 4) for value in + vae_mean.detach().float().cpu().reshape(-1).tolist() + ], + "latent_std": [ + round(float(value), 4) for value in + vae_std.detach().float().cpu().reshape(-1).tolist() + ], + "mean_std_contract": "WanVAEWrapper.inline_constants.v1", + "compute_dtype": str(self.dtype).replace("torch.", ""), + "decode_use_cache": False, + "decode_output_range": "[-1,1]", + } + preflight = { + "saved_source_hash": saved_source_hash, + "saved_vae": saved_vae, + "weights_path": weights_path, + "wrapper_source_path": active_wrapper_source_path, + "runtime_vae_base": runtime_vae_base, + } + except BaseException as exc: + preflight_error = exc + self._synchronize_dinov3_local_failure( + preflight_error, + collective_device, + operation="DINOv3 runtime identity preflight failed", + ) + + identity_error = None + weights_identity = None + wrapper_identity = None + try: + if file_hasher is None: + weights_identity = self._distributed_runtime_file_identity( + preflight["weights_path"]) + wrapper_identity = self._distributed_runtime_file_identity( + preflight["wrapper_source_path"]) + else: + weights_identity = self._runtime_file_identity( + preflight["weights_path"], file_hasher=file_hasher) + wrapper_identity = self._runtime_file_identity( + preflight["wrapper_source_path"], + file_hasher=file_hasher, + ) + except BaseException as exc: + identity_error = exc + self._synchronize_dinov3_local_failure( + identity_error, + collective_device, + operation="DINOv3 shared-file identity failed", + ) + + receipt = None + comparison_error = None + try: + runtime_vae = dict(preflight["runtime_vae_base"]) + runtime_vae["wrapper_source"] = wrapper_identity + runtime_vae["weights"] = weights_identity + runtime_vae["identity_sha256"] = self._canonical_sha256( + runtime_vae) + if preflight["saved_vae"] != runtime_vae: + raise ValueError( + "DINOv3 reference Wan VAE identity does not match the " + "active decoder") + receipt = { + "source_identity_sha256": preflight["saved_source_hash"], + "vae_identity_sha256": runtime_vae["identity_sha256"], + } + except BaseException as exc: + comparison_error = exc + self._synchronize_dinov3_local_failure( + comparison_error, + collective_device, + operation="DINOv3 runtime identity comparison failed", + ) + return receipt + + @classmethod + def _resolve_vjepa2_model_configuration( + cls, + args, + ) -> Tuple[str, str, str]: + """Resolve the load source independently from reference metadata. + + vjepa2_model_id identifies the model that transformers actually opens. + vjepa2_reference_model_id is the canonical identity stored in an + already-created feature/reference pack; it can legitimately be a path + from the machine that produced that pack. Keeping the two separate + lets a byte-identical local mirror satisfy strict reference metadata + without asking transformers to open the producer's path. + + When configured, vjepa2_model_sha256 authenticates the local + model.safetensors before the adapter is constructed. + """ + configured_load_id = str(getattr( + args, + "vjepa2_model_id", + "assets/models/vjepa2-vitl-fpc64-256", + )).strip() + if not configured_load_id: + raise ValueError("vjepa2_model_id must be a non-empty string") + expanded_load_id = os.path.expanduser(configured_load_id) + model_load_path = ( + os.path.realpath(expanded_load_id) + if os.path.exists(expanded_load_id) + else configured_load_id + ) + + configured_reference_id = getattr( + args, "vjepa2_reference_model_id", None) + reference_model_id = ( + "" if configured_reference_id is None + else str(configured_reference_id).strip() + ) + if not reference_model_id: + # Backward compatibility: old configs used one field for both + # loading and canonical reference metadata. + reference_model_id = model_load_path + + expected_sha256 = str(getattr( + args, "vjepa2_model_sha256", "")).strip().lower() + if expected_sha256: + if ( + len(expected_sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in expected_sha256 + ) + ): + raise ValueError( + "vjepa2_model_sha256 must be a 64-character SHA-256") + if not os.path.isdir(model_load_path): + raise ValueError( + "vjepa2_model_sha256 requires vjepa2_model_id to be a " + "local model directory") + weight_path = os.path.join( + model_load_path, "model.safetensors") + if not os.path.isfile(weight_path): + raise FileNotFoundError( + "vjepa2_model_sha256 requires the local weight file " + f"{weight_path}") + actual_sha256 = cls._distributed_file_sha256(weight_path) + if actual_sha256 != expected_sha256: + raise RuntimeError( + "V-JEPA2 model.safetensors SHA-256 mismatch: " + f"expected {expected_sha256}, got {actual_sha256}") + + return model_load_path, reference_model_id, expected_sha256 + + @staticmethod + + @staticmethod + def _config_list(value) -> list: + if isinstance(value, str): + return [value] + return list(value) + + def _dense_config(self, args, suffix: str, default): + """Read an encoder-scoped dense option without changing old YAMLs.""" + return getattr( + args, f"{self._dense_encoder_kind()}_{suffix}", default) + + def _dense_key(self, suffix: str) -> str: + prefix = getattr( + self, + "dense_log_prefix", + f"{self._dense_encoder_kind()}_dense", + ) + return f"{prefix}_{suffix}" + + def _dense_encoder_kind(self) -> str: + return getattr( + self, + "dense_encoder_name", + ( + "motionformer" + if getattr(self, "feature_type", None) + == "motionformer_dense_latent" + else "vjepa2" + ), + ) + + def _dense_encoder_display_name(self) -> str: + return ( + "MotionFormer" + if self._dense_encoder_kind() == "motionformer" + else "V-JEPA2" + ) + + def _dense_attr(self, suffix: str): + generic_name = f"dense_{suffix}" + if hasattr(self, generic_name): + return getattr(self, generic_name) + return getattr(self, f"vjepa2_dense_{suffix}") + + def _dense_ema_version_key(self) -> str: + return self._dense_key("mmd_version") + + def _dense_ema_state_key(self) -> str: + return self._dense_key("mmd") + + def _dense_nystrom_version_key(self) -> str: + return self._dense_key("nystrom_mmd_version") + + def _initialize_wan_features(self, args) -> None: + if self.real_score is None: + raise ValueError( + "post_block_latent requires uses_real_score: true") + self.latent_layer = int(getattr(args, "wan_latent_layer", 15)) + num_encoder_blocks = len(self.real_score.model.blocks) + if self.latent_layer < 0 or self.latent_layer >= num_encoder_blocks: + raise ValueError( + f"wan_latent_layer={self.latent_layer} outside frozen Wan's " + f"0..{num_encoder_blocks - 1} range") + if bool(getattr(args, "wan_latent_prune_unused_blocks", True)): + # The feature path returns after the requested block, so later + # frozen blocks can never be used. Removing them before FSDP avoids + # sharding/loading another ~650M parameters for layers 16..29. + keep = self.latent_layer + 1 + self.real_score.model.blocks = self.real_score.model.blocks[:keep] + self.latent_num_tokens = int( + getattr(args, "wan_latent_num_tokens", 128)) + if self.latent_num_tokens <= 1: + raise ValueError("wan_latent_num_tokens must be > 1") + self.latent_timestep = float( + getattr(args, "wan_latent_timestep", 0.0)) + self.latent_patch_size = tuple( + int(v) for v in self.real_score.model.patch_size) + self.latent_feature_dim = int(self.real_score.model.dim) + # We need input gradients through the frozen encoder, and checkpointing is + # the main activation-memory control for deeper prefixes. + if bool(getattr(args, "wan_latent_gradient_checkpointing", True)): + self.real_score.enable_gradient_checkpointing() + + def _initialize_vjepa2_features(self, args, device) -> None: + # Lazy import keeps the transformers/V-JEPA2 dependency completely out + # of the unchanged Wan feature path. + from video_fd.encoders.vjepa2_adapter import VJEPA2Adapter + + ( + self.vjepa2_model_load_path, + self.vjepa2_reference_model_id, + self.vjepa2_model_sha256, + ) = self._resolve_vjepa2_model_configuration(args) + # Retain the historical runtime attribute for reference-bank identity + # checks and downstream code that expects the actual load source. + self.vjepa2_model_id = self.vjepa2_model_load_path + self.vjepa2_reference_token_contract = str(getattr( + args, + "vjepa2_reference_token_contract", + "explicit_uniform_v2", + )).strip() + if self.vjepa2_reference_token_contract not in { + "explicit_uniform_v2", + "legacy_uniform_implicit_v1", + }: + raise ValueError( + "vjepa2_reference_token_contract must be " + "explicit_uniform_v2 or legacy_uniform_implicit_v1") + configured_dense_layers = getattr( + args, "vjepa2_dense_layers", None) + if configured_dense_layers is None: + dense_layers = ( + int(getattr(args, "vjepa2_dense_layer", 15)), + ) + else: + if isinstance(configured_dense_layers, (str, bytes)): + raise TypeError( + "vjepa2_dense_layers must be a sequence of integers") + dense_layers = tuple( + int(layer) for layer in configured_dense_layers) + if not dense_layers: + raise ValueError( + "vjepa2_dense_layers must not be empty") + if len(set(dense_layers)) != len(dense_layers): + raise ValueError( + "vjepa2_dense_layers must contain unique indices") + if tuple(sorted(dense_layers)) != dense_layers: + raise ValueError( + "vjepa2_dense_layers must be sorted") + self.vjepa2_dense_layers = dense_layers + self.vjepa2_multilayer_enabled = len(dense_layers) > 1 + # The maximum layer controls prefix pruning. Single-layer callers keep + # the historical scalar attribute unchanged. + self.vjepa2_dense_layer = dense_layers[-1] + self.vjepa2_dense_num_frames = int( + getattr(args, "vjepa2_dense_num_frames", 64)) + self.vjepa2_dense_frame_sampling = str(getattr( + args, "vjepa2_dense_frame_sampling", "center_contiguous")) + # Keep the immutable reference contract separate from the student + # temporal window. The historical ``last_contiguous`` alias denotes + # matched5k references that were actually extracted from frames 17..80; + # explicit ``center_contiguous`` + student ``last_contiguous`` is the + # legacy OpenVid-77,615 center64-reference protocol. + self.vjepa2_reference_frame_sampling = ( + self.vjepa2_dense_frame_sampling) + self.vjepa2_dense_source_num_frames = int(getattr( + args, + "vjepa2_dense_source_num_frames", + self.vjepa2_dense_num_frames, + )) + configured_student_frame_sampling = getattr( + args, "vjepa2_student_frame_sampling", None) + if self.vjepa2_dense_frame_sampling == "last_contiguous": + if ( + configured_student_frame_sampling is not None + and str(configured_student_frame_sampling) != "last_contiguous" + ): + raise ValueError( + "vjepa2_dense_frame_sampling=last_contiguous conflicts " + "with vjepa2_student_frame_sampling") + # The dense contract identifies the frozen center64 reference. + # Treat this alias as a student-only last64 request. + self.vjepa2_dense_frame_sampling = "center_contiguous" + self.vjepa2_student_frame_sampling = "last_contiguous" + else: + self.vjepa2_student_frame_sampling = str( + configured_student_frame_sampling + if configured_student_frame_sampling is not None + else self.vjepa2_dense_frame_sampling + ) + default_student_source_frames = ( + 81 + if self.vjepa2_student_frame_sampling == "last_contiguous" + else self.vjepa2_dense_source_num_frames + ) + self.vjepa2_student_source_num_frames = int(getattr( + args, "vjepa2_student_source_num_frames", + default_student_source_frames, + )) + self.vjepa2_dense_window_seed = str(getattr( + args, "vjepa2_dense_window_seed", "sf-vjepa2-window-v1")) + self.vjepa2_dense_num_tokens = int( + getattr(args, "vjepa2_dense_num_tokens", 128)) + self.vjepa2_dense_resize_short_side = int( + getattr(args, "vjepa2_dense_resize_short_side", 292)) + self.vjepa2_dense_crop_size = int( + getattr(args, "vjepa2_dense_crop_size", 256)) + self.vjepa2_dense_gradient_checkpointing = bool( + getattr(args, "vjepa2_dense_gradient_checkpointing", True)) + + if any(layer < 0 for layer in self.vjepa2_dense_layers): + raise ValueError( + "V-JEPA2 dense layer indices must be non-negative") + if self.vjepa2_dense_num_frames <= 0: + raise ValueError("vjepa2_dense_num_frames must be positive") + if self.vjepa2_dense_frame_sampling not in { + "center_contiguous", + "random_contiguous_per_video", + }: + raise ValueError( + "vjepa2_dense_frame_sampling must be center_contiguous or " + "random_contiguous_per_video") + if self.vjepa2_student_frame_sampling not in { + "center_contiguous", + "random_contiguous_per_video", + "last_contiguous", + }: + raise ValueError( + "vjepa2_student_frame_sampling must be center_contiguous, " + "random_contiguous_per_video, or last_contiguous") + if ( + self.vjepa2_student_frame_sampling + != self.vjepa2_dense_frame_sampling + and self.vjepa2_student_frame_sampling != "last_contiguous" + ): + raise ValueError( + "only last_contiguous may intentionally differ from the " + "frozen-reference frame sampling") + if ( + self.vjepa2_student_frame_sampling == "last_contiguous" + and self.vjepa2_dense_frame_sampling != "center_contiguous" + ): + raise ValueError("last_contiguous requires a center64 reference") + if ( + self.vjepa2_student_source_num_frames + < self.vjepa2_dense_num_frames + ): + raise ValueError( + "vjepa2_student_source_num_frames must be at least " + "vjepa2_dense_num_frames") + if self.vjepa2_student_frame_sampling == "last_contiguous" and ( + self.vjepa2_dense_num_frames != 64 + or self.vjepa2_student_source_num_frames != 81 + ): + raise ValueError( + "last_contiguous is the strict 81-frame last64 contract and " + "requires vjepa2_dense_num_frames=64 plus " + "vjepa2_student_source_num_frames=81") + if self.vjepa2_dense_source_num_frames < self.vjepa2_dense_num_frames: + raise ValueError( + "vjepa2_dense_source_num_frames must be at least " + "vjepa2_dense_num_frames") + if not self.vjepa2_dense_window_seed: + raise ValueError( + "vjepa2_dense_window_seed must be a non-empty string") + if ( + self.vjepa2_student_frame_sampling + == "random_contiguous_per_video" + and self.mmd_estimator != "nystrom_exact_repulsion" + ): + raise ValueError( + "random V-JEPA2 temporal windows require " + "wan_mmd_estimator=nystrom_exact_repulsion") + if self.vjepa2_dense_num_tokens <= 1: + raise ValueError("vjepa2_dense_num_tokens must be > 1") + if ( + self.vjepa2_dense_crop_size <= 0 + or self.vjepa2_dense_resize_short_side + < self.vjepa2_dense_crop_size + ): + raise ValueError( + "V-JEPA2 resize_short_side must be >= positive crop_size") + + adapter_config = { + "name": "vjepa2_dense", + "model_id": self.vjepa2_model_load_path, + "input_frames": self.vjepa2_dense_num_frames, + "dense_num_frames": self.vjepa2_dense_num_frames, + "input_size": self.vjepa2_dense_crop_size, + "resize_short_side": self.vjepa2_dense_resize_short_side, + "crop_mode": "center", + "dense_only": True, + "dense_layer_index": self.vjepa2_dense_layer, + } + self.vjepa2_encoder = VJEPA2Adapter(adapter_config) + self.vjepa2_encoder.to(device=device, dtype=torch.float32) + self.vjepa2_encoder.eval().requires_grad_(False) + + actual_geometry = ( + int(self.vjepa2_encoder.dense_layer_index), + int(self.vjepa2_encoder.dense_num_frames), + int(self.vjepa2_encoder.resize_short_side), + int(self.vjepa2_encoder.input_size), + int(self.vjepa2_encoder.retained_dense_blocks), + ) + expected_geometry = ( + self.vjepa2_dense_layer, + self.vjepa2_dense_num_frames, + self.vjepa2_dense_resize_short_side, + self.vjepa2_dense_crop_size, + self.vjepa2_dense_layer + 1, + ) + if actual_geometry != expected_geometry: + raise RuntimeError( + "loaded V-JEPA2 adapter geometry does not match the requested " + "dense-prefix configuration: " + f"actual={actual_geometry}, expected={expected_geometry}") + self.vjepa2_dense_raw_tokens = int( + self.vjepa2_encoder.dense_raw_tokens) + self.vjepa2_dense_feature_dim = int( + self.vjepa2_encoder.dense_feature_dim) + if self.vjepa2_dense_num_tokens > self.vjepa2_dense_raw_tokens: + raise ValueError( + "vjepa2_dense_num_tokens cannot exceed the full dense grid: " + f"{self.vjepa2_dense_num_tokens} > " + f"{self.vjepa2_dense_raw_tokens}") + self._bind_dense_runtime_aliases() + + def _initialize_motionformer_features(self, args, device) -> None: + """Build the optional frozen MotionFormer dense-prefix extractor.""" + from video_fd.encoders.motionformer_adapter import ( + MotionFormerAdapter, + SSV2_MOTIONFORMER_224_16X4_SHA256, + ) + + configured_path = str(getattr( + args, "motionformer_checkpoint_path", "")).strip() + checkpoint_path = os.path.realpath( + os.path.expanduser(configured_path)) if configured_path else "" + if not checkpoint_path or not os.path.isfile(checkpoint_path): + raise FileNotFoundError( + "wan_feature_type=motionformer_dense_latent requires " + "motionformer_checkpoint_path; got " + f"{checkpoint_path!r}") + checkpoint_sha256 = str(getattr( + args, "motionformer_checkpoint_sha256", "")).strip().lower() + if ( + len(checkpoint_sha256) != 64 + or any(c not in "0123456789abcdef" for c in checkpoint_sha256) + ): + raise ValueError( + "motionformer_checkpoint_sha256 must be a lowercase SHA-256") + if checkpoint_sha256 != SSV2_MOTIONFORMER_224_16X4_SHA256: + raise ValueError( + "motionformer_checkpoint_sha256 must identify the official " + "SSv2 Base 16x4 checkpoint") + + self.motionformer_checkpoint_path = checkpoint_path + self.motionformer_checkpoint_sha256 = checkpoint_sha256 + self.motionformer_dense_layer = int(getattr( + args, "motionformer_dense_layer", 11)) + self.motionformer_dense_num_frames = int(getattr( + args, "motionformer_dense_num_frames", 16)) + self.motionformer_dense_num_tokens = int(getattr( + args, "motionformer_dense_num_tokens", 128)) + self.motionformer_dense_resize_short_side = int(getattr( + args, "motionformer_dense_resize_short_side", 224)) + self.motionformer_dense_crop_size = int(getattr( + args, "motionformer_dense_crop_size", 224)) + self.motionformer_dense_gradient_checkpointing = bool(getattr( + args, "motionformer_dense_gradient_checkpointing", True)) + if self.motionformer_dense_layer != 11: + raise ValueError( + "the supported SSv2 MotionFormer feature contract fixes " + "motionformer_dense_layer=11") + if self.motionformer_dense_num_frames != 16: + raise ValueError( + "the supported SSv2 MotionFormer feature contract fixes " + "motionformer_dense_num_frames=16") + if ( + self.motionformer_dense_resize_short_side != 224 + or self.motionformer_dense_crop_size != 224 + ): + raise ValueError( + "the supported SSv2 MotionFormer preprocessing contract fixes " + "resize_short_side=crop_size=224") + if self.motionformer_dense_num_tokens <= 1: + raise ValueError("motionformer_dense_num_tokens must be > 1") + + adapter_config = { + "name": "motionformer_dense", + "checkpoint_path": checkpoint_path, + "checkpoint_sha256": checkpoint_sha256, + "input_frames": self.motionformer_dense_num_frames, + "dense_num_frames": self.motionformer_dense_num_frames, + "input_size": self.motionformer_dense_crop_size, + "resize_short_side": self.motionformer_dense_resize_short_side, + "crop_mode": "center", + "dense_only": True, + "dense_layer_index": self.motionformer_dense_layer, + "use_original_trajectory_attention": True, + } + self.motionformer_encoder = MotionFormerAdapter(adapter_config) + self.motionformer_encoder.to(device=device, dtype=torch.float32) + self.motionformer_encoder.eval().requires_grad_(False) + actual_geometry = ( + int(self.motionformer_encoder.dense_layer_index), + int(self.motionformer_encoder.dense_num_frames), + int(self.motionformer_encoder.input_size), + int(self.motionformer_encoder.retained_dense_blocks), + ) + expected_geometry = ( + self.motionformer_dense_layer, + self.motionformer_dense_num_frames, + self.motionformer_dense_crop_size, + self.motionformer_dense_layer + 1, + ) + if actual_geometry != expected_geometry: + raise RuntimeError( + "loaded MotionFormer adapter geometry does not match the " + "requested dense-prefix configuration: " + f"actual={actual_geometry}, expected={expected_geometry}") + self.motionformer_dense_raw_tokens = int( + self.motionformer_encoder.dense_raw_tokens) + self.motionformer_dense_feature_dim = int( + self.motionformer_encoder.dense_feature_dim) + if self.motionformer_dense_raw_tokens != 1568: + raise RuntimeError( + "SSv2 MotionFormer must expose 1568 non-CLS dense tokens, got " + f"{self.motionformer_dense_raw_tokens}") + if self.motionformer_dense_feature_dim != 768: + raise RuntimeError( + "SSv2 MotionFormer must expose 768-dimensional features, got " + f"{self.motionformer_dense_feature_dim}") + if ( + self.motionformer_dense_num_tokens + > self.motionformer_dense_raw_tokens + ): + raise ValueError( + "motionformer_dense_num_tokens cannot exceed the full dense " + f"grid: {self.motionformer_dense_num_tokens} > " + f"{self.motionformer_dense_raw_tokens}") + + # The replay/MMD implementation below deliberately consumes one common + # dense-encoder contract. These private aliases keep the already + # validated V-JEPA2 implementation and its legacy tests byte-compatible; + # public generic aliases are what the trainer uses. + self.vjepa2_model_id = checkpoint_path + self.vjepa2_dense_layer = self.motionformer_dense_layer + self.vjepa2_dense_layers = (self.motionformer_dense_layer,) + self.vjepa2_multilayer_enabled = False + self.vjepa2_dense_num_frames = self.motionformer_dense_num_frames + # MotionFormer receives the complete decoded video and performs its + # official 16-segment midpoint sampling inside the adapter. Mark this + # as non-random so the V-JEPA2-only contiguous-window machinery stays + # dormant. + self.vjepa2_dense_frame_sampling = "center_contiguous" + self.vjepa2_dense_source_num_frames = ( + self.motionformer_dense_num_frames) + self.vjepa2_dense_window_seed = "" + self.vjepa2_dense_num_tokens = self.motionformer_dense_num_tokens + self.vjepa2_dense_resize_short_side = ( + self.motionformer_dense_resize_short_side) + self.vjepa2_dense_crop_size = self.motionformer_dense_crop_size + self.vjepa2_dense_gradient_checkpointing = ( + self.motionformer_dense_gradient_checkpointing) + self.vjepa2_encoder = self.motionformer_encoder + self.vjepa2_dense_raw_tokens = self.motionformer_dense_raw_tokens + self.vjepa2_dense_feature_dim = self.motionformer_dense_feature_dim + self._bind_dense_runtime_aliases() + + def _bind_dense_runtime_aliases(self) -> None: + """Publish the encoder-neutral runtime interface used by DistributionMatchingTrainer.""" + self.dense_encoder = self.vjepa2_encoder + self.dense_layer = self.vjepa2_dense_layer + self.dense_num_frames = self.vjepa2_dense_num_frames + self.dense_num_tokens = self.vjepa2_dense_num_tokens + self.dense_resize_short_side = self.vjepa2_dense_resize_short_side + self.dense_crop_size = self.vjepa2_dense_crop_size + self.dense_gradient_checkpointing = ( + self.vjepa2_dense_gradient_checkpointing) + self.dense_raw_tokens = self.vjepa2_dense_raw_tokens + self.dense_feature_dim = self.vjepa2_dense_feature_dim + + def _initialize_dual_dinov3_vjepa2_features( + self, + args, + device, + ) -> None: + """Build only the frozen DINOv3 and V-JEPA2 prefixes.""" + self.fresh_video_total_samples = int(getattr( + args, "total_batch_size", 0)) + if self.fresh_video_total_samples <= 0: + raise ValueError( + "DINOv3+V-JEPA2 MMD requires positive total_batch_size") + self._initialize_vjepa2_features(args, device) + expected_vjepa = { + "layers": (15,), + "frames": 64, + "source_frames": 81, + "student_sampling": "last_contiguous", + "tokens": 1024, + "feature_dim": 1024, + } + actual_vjepa = { + "layers": tuple(self.vjepa2_dense_layers), + "frames": int(self.vjepa2_dense_num_frames), + "source_frames": int(self.vjepa2_student_source_num_frames), + "student_sampling": str(self.vjepa2_student_frame_sampling), + "tokens": int(self.vjepa2_dense_num_tokens), + "feature_dim": int(self.vjepa2_dense_feature_dim), + } + if actual_vjepa != expected_vjepa: + raise ValueError( + "dual DINOv3+V-JEPA2 contract fixes V-JEPA2 layer15, " + "frames17..80, K=1024 and D=1024: " + f"actual={actual_vjepa}, expected={expected_vjepa}") + self._initialize_dinov3_features(args, device) + actual_dinov3 = { + "geometry": self.dinov3_dense_geometry, + "source_frames": self.dinov3_dense_source_num_frames, + "sample_shape": ( + self.dinov3_dense_num_tokens, + self.dinov3_dense_feature_dim, + ), + } + expected_dinov3 = { + "geometry": "temporal_mean5_full256", + "source_frames": 81, + "sample_shape": (1024, 1024), + } + if actual_dinov3 != expected_dinov3: + raise ValueError( + "dual DINOv3+V-JEPA2 contract fixes DINOv3 to 20 frames, " + "mean5, K=1024 and D=1024: " + f"actual={actual_dinov3}, expected={expected_dinov3}") + self.dual_branch_registry = { + "dinov3": { + "encoder_attribute": "dinov3_encoder", + "source_frame_indices": list( + self.dinov3_dense_frame_indices), + "sample_shape": (1024, 1024), + "tokens_per_temporal_group": 256, + "temporal_groups": 4, + "model_id": self.dinov3_model_id, + "source_commit": self.dinov3_source_commit, + "checkpoint_sha256": getattr( + self.dinov3_encoder, "checkpoint_sha256", ""), + }, + "vjepa2": { + "encoder_attribute": "vjepa2_encoder", + "layer": 15, + "source_frame_range": [17, 80], + "sample_shape": (1024, 1024), + "tokens_per_segment": 1024, + "segments": 1, + "model_id": self.vjepa2_reference_model_id, + "model_sha256": self.vjepa2_model_sha256, + }, + } + + def _replace_with_dual_dinov3_vjepa2_nystrom_objective( + self, args, device + ) -> None: + """Bind two Wan-video references and sum normalized branch MMDs.""" + from losses.multi_encoder_mmd import ( + MultiEncoderMMD, + ) + from losses.video_nystrom_mmd import ( + VideoNystromMMD, + load_reference_pack, + ) + + self.fresh_video_gradient_global_samples = ( + self._configured_current_teacher_global_samples(args)) + + if not self.uses_fresh_video_gradcache: + raise ValueError( + "DINOv3+V-JEPA2 MMD requires nystrom_exact_repulsion") + if not self.latent_self_normalize: + raise ValueError( + "DINOv3+V-JEPA2 MMD requires self normalization") + if ( + self.nystrom_attraction_ema_enabled + or self.nystrom_repulsion_fifo_enabled + ): + raise ValueError( + "DINOv3+V-JEPA2 MMD requires EMA and FIFO disabled") + if self.current_teacher_attraction_enabled: + if ( + self.nystrom_attraction_weight != 2.0 + or self.current_teacher_attraction_weight != 1.0 + ): + raise ValueError( + "DINOv3+V-JEPA2 fixed/random teacher weights must be 2:1") + if self.expected_current_teacher_samples != 128: + raise ValueError( + "DINOv3+V-JEPA2 current-teacher mode requires 128 " + "matched teachers") + else: + if self.current_teacher_source != "none": + raise ValueError( + "fixed-only DINOv3+V-JEPA2 requires " + "current_teacher_source=none") + if self.nystrom_attraction_weight != 1.0: + raise ValueError( + "fixed-only DINOv3+V-JEPA2 requires fixed attraction " + "weight 1.0") + if self.current_teacher_attraction_weight != 0.0: + raise ValueError( + "fixed-only DINOv3+V-JEPA2 requires current-teacher " + "weight 0.0") + if self.expected_current_teacher_samples != 0: + raise ValueError( + "fixed-only DINOv3+V-JEPA2 requires zero expected " + "current-teacher samples") + if ( + self.vjepa2_reference_frame_sampling != "center_contiguous" + or self.vjepa2_reference_token_contract + != "legacy_uniform_implicit_v1" + ): + raise ValueError( + "fixed-only OpenVid V-JEPA2 requires the approved " + "center64 legacy_uniform_implicit_v1 reference contract") + + def strict_sha256(value, label): + normalized = str(value).strip().lower() + if ( + len(normalized) != 64 + or any(c not in "0123456789abcdef" for c in normalized) + ): + raise ValueError(f"{label} must be a lowercase SHA-256") + return normalized + + def checked_reference_path(path_value, sha_value, label): + path = os.path.realpath(os.path.expanduser(str(path_value))) + if not os.path.isfile(path): + raise FileNotFoundError( + f"DINOv3+V-JEPA2 {label} reference missing: {path!r}") + expected_sha = strict_sha256(sha_value, f"{label} reference SHA-256") + actual_sha = self._distributed_file_sha256(path) + if actual_sha != expected_sha: + raise ValueError( + f"{label} reference SHA-256 mismatch: " + f"{actual_sha} != {expected_sha}") + return path, expected_sha, load_reference_pack( + path, map_location="cpu") + + # Each encoder owns its reference summary; the wrapper combines normalized losses. + vjepa_objective = self.latent_mmd + if not isinstance(vjepa_objective, VideoNystromMMD): + raise TypeError( + "dual bootstrap V-JEPA2 objective must be VideoNystromMMD") + vjepa_path = self.vjepa2_nystrom_reference_path + vjepa_file_sha = strict_sha256( + self._dense_config_value( + args, "nystrom_reference_sha256", ""), + "V-JEPA2 reference SHA-256", + ) + if self._distributed_file_sha256(vjepa_path) != vjepa_file_sha: + raise ValueError( + "dual V-JEPA2 reference file SHA-256 mismatch") + + dinov3_path, dinov3_file_sha, dinov3_pack = checked_reference_path( + getattr(args, "dinov3_nystrom_reference_path", ""), + getattr(args, "dinov3_nystrom_reference_sha256", ""), + "DINOv3", + ) + dinov3_objective = VideoNystromMMD( + dinov3_pack, + expected_metadata={ + "feature": self._dinov3_feature_metadata(), + "nystrom": { + "num_landmarks": int(getattr( + args, "dinov3_nystrom_landmarks", 256)), + "seed": int(getattr(args, "dinov3_nystrom_seed", 0)), + }, + }, + kernel_chunk_size=int(getattr( + args, "dinov3_nystrom_kernel_chunk_size", 32)), + attraction_weight=self.nystrom_attraction_weight, + current_teacher_attraction_enabled=( + self.current_teacher_attraction_enabled), + current_teacher_attraction_weight=( + self.current_teacher_attraction_weight), + attraction_ema_enabled=False, + repulsion_fifo_size=0, + ).to(device=device) + objectives = { + "dinov3": dinov3_objective, + "vjepa2": vjepa_objective, + } + expected_shapes = { + "dinov3": (1024, 1024), + "vjepa2": (1024, 1024), + } + actual_shapes = { + name: tuple(objective.sample_shape) + for name, objective in objectives.items() + } + if actual_shapes != expected_shapes: + raise ValueError( + "dual reference sample-shape mismatch: " + f"actual={actual_shapes}, expected={expected_shapes}") + + if self.current_teacher_attraction_enabled: + configured_reference_identity = strict_sha256( + getattr(args, "dinov3_vjepa2_teacher_bank_content_hash", ""), + "dinov3_vjepa2_teacher_bank_content_hash", + ) + expected_manifest_hash = strict_sha256( + getattr(args, "matched_manifest_sha256", ""), + "matched_manifest_sha256", + ) + for name, objective in objectives.items(): + source = objective.reference_metadata.get( + "reference", {}).get("source", {}) + if ( + source.get("dinov3_vjepa2_teacher_bank_content_hash") + != configured_reference_identity + or source.get("source_manifest_sha256") + != expected_manifest_hash + or int(source.get("selected_samples", -1)) != 5000 + or source.get("branch_name") != name + ): + raise ValueError( + f"{name} reference is not bound to the configured " + "frozen 5000-Wan-video DINOv3+V-JEPA2 bank") + self.dinov3_vjepa2_teacher_bank_content_hash = ( + configured_reference_identity) + else: + bundle_path = os.path.realpath(os.path.expanduser(str(getattr( + args, "dinov3_vjepa2_reference_bundle_manifest", "")))) + if not os.path.isfile(bundle_path): + raise FileNotFoundError( + "fixed-only OpenVid reference bundle is missing: " + f"{bundle_path!r}") + configured_reference_identity = strict_sha256( + getattr( + args, + "dinov3_vjepa2_reference_bundle_manifest_sha256", + "", + ), + "dinov3_vjepa2_reference_bundle_manifest_sha256", + ) + actual_bundle_sha = self._distributed_file_sha256(bundle_path) + if actual_bundle_sha != configured_reference_identity: + raise ValueError( + "OpenVid dual reference bundle SHA-256 mismatch: " + f"{actual_bundle_sha} != {configured_reference_identity}") + with open(bundle_path, "r", encoding="utf-8") as handle: + bundle = json.load(handle) + expected_count = int(getattr( + args, "openvid_reference_count", 77615)) + expected_sequence = strict_sha256( + getattr(args, "openvid_video_name_sequence_sha256", ""), + "openvid_video_name_sequence_sha256", + ) + if ( + bundle.get("kind") + != "openvid_dual_nystrom_reference_bundle" + or int(bundle.get("schema_version", -1)) != 1 + or int(bundle.get("reference_count", -1)) != expected_count + or bundle.get("video_name_sequence_sha256") + != expected_sequence + or set(bundle.get("branches", {})) + != {"dinov3", "vjepa2"} + ): + raise ValueError( + "OpenVid dual reference bundle identity/schema mismatch") + bridge = bundle.get("legacy_vjepa_provenance_bridge", {}) + if ( + bridge.get("accepted") is not True + or bridge.get("limitation") + != "legacy_pack_has_no_embedded_row_name_sequence" + ): + raise ValueError( + "OpenVid bundle must explicitly attest the accepted legacy " + "V-JEPA provenance limitation") + expected_files = { + "dinov3": dinov3_file_sha, + "vjepa2": vjepa_file_sha, + } + for name, objective in objectives.items(): + entry = bundle["branches"].get(name, {}) + nystrom = objective.reference_metadata.get("nystrom", {}) + if ( + entry.get("file_sha256") != expected_files[name] + or entry.get("content_hash") != objective.reference_hash + or entry.get("feature") + != objective.reference_metadata.get("feature") + or int(entry.get("reference_count", -1)) + != expected_count + or int(nystrom.get("reference_count", -1)) + != expected_count + ): + raise ValueError( + f"OpenVid bundle does not bind the loaded {name} " + "reference pack") + self.dinov3_vjepa2_reference_bundle_manifest = bundle_path + self.dinov3_vjepa2_reference_bundle_manifest_sha256 = ( + configured_reference_identity) + self.openvid_video_name_sequence_sha256 = expected_sequence + # Paper Eq. (3) motivates encoder weighting; this release also uses detached-scale normalization. + branch_weights = { + "dinov3": float(getattr( + args, "dinov3_vjepa2_dinov3_weight", 10.0)), + "vjepa2": float(getattr( + args, "dinov3_vjepa2_vjepa2_weight", 1.0)), + } + if branch_weights != {"dinov3": 10.0, "vjepa2": 1.0}: + raise ValueError( + "DINOv3+V-JEPA2 representation weights must be exactly 10:1") + self.latent_mmd = MultiEncoderMMD( + objectives, + weights=branch_weights, + normalization_eps=self.latent_norm_eps, + ).to(device=device) + self.dinov3_nystrom_reference_path = dinov3_path + self.dinov3_nystrom_reference_hash = dinov3_objective.reference_hash + self.dual_teacher_bank_content_hash = configured_reference_identity + reference_files = { + "dinov3": dinov3_file_sha, + "vjepa2": vjepa_file_sha, + } + reference_paths = { + "dinov3": dinov3_path, + "vjepa2": vjepa_path, + } + for name, objective in objectives.items(): + self.dual_branch_registry[name].update({ + "reference_path": reference_paths[name], + "reference_file_sha256": reference_files[name], + "reference_hash": objective.reference_hash, + "nystrom_landmarks": int(objective.landmarks.shape[0]), + }) + + def _initialize_multi_encoder_features( + self, + args, + device, + ) -> None: + """Build the frozen feature encoders and their video/token geometry. + + The released 14B recipe uses all three encoders. The explicit list also + supports ablations; saved attribute names stay compatible.""" + self.dinov3_reference_paths = dict(getattr(args, "dinov3_reference_paths", {})) + self.triple_active_branches = active_mmd_branches( + getattr(args, "triple_active_branches", None)) + self.fresh_video_total_samples = int(getattr( + args, "total_batch_size", 0)) + if self.fresh_video_total_samples <= 0: + raise ValueError( + "triple video MMD requires positive total_batch_size") + self._initialize_vjepa2_features(args, device) + vjepa_attribute_names = ( + "vjepa2_model_load_path", + "vjepa2_reference_model_id", + "vjepa2_model_sha256", + "vjepa2_model_id", + "vjepa2_dense_layers", + "vjepa2_multilayer_enabled", + "vjepa2_dense_layer", + "vjepa2_dense_num_frames", + "vjepa2_dense_frame_sampling", + "vjepa2_reference_frame_sampling", + "vjepa2_reference_token_contract", + "vjepa2_dense_source_num_frames", + "vjepa2_student_frame_sampling", + "vjepa2_student_source_num_frames", + "vjepa2_dense_window_seed", + "vjepa2_dense_num_tokens", + "vjepa2_dense_resize_short_side", + "vjepa2_dense_crop_size", + "vjepa2_dense_gradient_checkpointing", + "vjepa2_dense_raw_tokens", + "vjepa2_dense_feature_dim", + ) + vjepa_state = { + name: getattr(self, name) for name in vjepa_attribute_names + } + vjepa_encoder = self.vjepa2_encoder + expected_vjepa = { + "layers": (15,), + "frames": 64, + "source_frames": 81, + "student_sampling": "last_contiguous", + "tokens": 1024, + "feature_dim": 1024, + } + actual_vjepa = { + "layers": tuple(self.vjepa2_dense_layers), + "frames": int(self.vjepa2_dense_num_frames), + "source_frames": int(self.vjepa2_student_source_num_frames), + "student_sampling": str(self.vjepa2_student_frame_sampling), + "tokens": int(self.vjepa2_dense_num_tokens), + "feature_dim": int(self.vjepa2_dense_feature_dim), + } + if actual_vjepa != expected_vjepa: + raise ValueError( + "triple V-JEPA2 contract is fixed to layer15, frames17..80, " + "K=1024 and D=1024: " + f"actual={actual_vjepa}, expected={expected_vjepa}") + + self._initialize_videomae_v2_features(args, device) + expected_videomae = { + "layers": (15,), + "segments": 5, + "skip": 1, + "frames_per_segment": 16, + "source_frames": 81, + "tokens_per_segment": 256, + "feature_dim": 1024, + } + actual_videomae = { + "layers": tuple(self.videomae_v2_dense_layers), + "segments": int(self.videomae_v2_encoder_count), + "skip": int(self.videomae_v2_skip_leading_frames), + "frames_per_segment": int(self.videomae_v2_segment_num_frames), + "source_frames": int(self.videomae_v2_source_num_frames), + "tokens_per_segment": int(self.videomae_v2_dense_num_tokens), + "feature_dim": int(self.videomae_v2_encoder.dense_feature_dim), + } + if actual_videomae != expected_videomae: + raise ValueError( + "triple VideoMAE-v2 contract is fixed to layer15, frames1..80 " + "as 5x16, K=256 per segment and D=1024: " + f"actual={actual_videomae}, expected={expected_videomae}") + + for name, value in vjepa_state.items(): + setattr(self, name, value) + self.vjepa2_encoder = vjepa_encoder + self.dense_feature_segment_count = 1 + self._bind_dense_runtime_aliases() + self.dual_branch_registry = {} + if "dinov3" in self.triple_active_branches: + self._initialize_dinov3_features(args, device) + actual_dinov3 = { + "geometry": self.dinov3_dense_geometry, + "source_frames": self.dinov3_dense_source_num_frames, + "sample_shape": ( + self.dinov3_dense_num_tokens, + self.dinov3_dense_feature_dim, + ), + } + expected_dinov3 = { + "geometry": "temporal_mean5_full256", + "source_frames": 81, + "sample_shape": (1024, 1024), + } + if actual_dinov3 != expected_dinov3: + raise ValueError( + "triple DINOv3 contract is fixed to 20 frames, mean-pool " + "consecutive groups of 5, K=1024 and D=1024: " + f"actual={actual_dinov3}, expected={expected_dinov3}") + self.dual_branch_registry["dinov3"] = { + "encoder_attribute": "dinov3_encoder", + "source_frame_indices": list( + self.dinov3_dense_frame_indices), + "sample_shape": (1024, 1024), + "tokens_per_temporal_group": 256, + "temporal_groups": 4, + "model_id": self.dinov3_model_id, + "source_commit": self.dinov3_source_commit, + "checkpoint_sha256": getattr( + self.dinov3_encoder, "checkpoint_sha256", ""), + } + self.dual_branch_registry.update({ + "vjepa2": { + "encoder_attribute": "vjepa2_encoder", + "layer": 15, + "source_frame_range": [17, 80], + "sample_shape": (1024, 1024), + "tokens_per_segment": 1024, + "segments": 1, + "model_id": self.vjepa2_reference_model_id, + "model_sha256": self.vjepa2_model_sha256, + }, + "videomae_v2": { + "encoder_attribute": "videomae_v2_encoder", + "layer": 15, + "source_frame_range": [1, 80], + "sample_shape": (1280, 1024), + "tokens_per_segment": 256, + "segments": 5, + "model_id": self.videomae_v2_model_id, + "model_revision": str(getattr( + args, "videomae_v2_model_revision", "")), + "download_manifest_content_hash": ( + self.videomae_v2_download_manifest_content_hash), + }, + }) + + def _build_multi_encoder_objective(self, args, device) -> None: + """Bind each encoder to its fixed reference summary and combine its loss. + + The hybrid reference uses both Nyström statistics and a sampled reference + minibatch. Encoder weights follow the release's detached-scale normalization.""" + active_branches = active_mmd_branches( + getattr(args, "triple_active_branches", None)) + from losses.multi_encoder_mmd import ( + MultiEncoderMMD, + ) + from losses.video_nystrom_mmd import ( + VideoNystromMMD, + load_reference_pack, + ) + from video_fd.encoders.videomae_v2_dense_metadata import ( + canonical_videomae_v2_dense_feature_metadata, + uniform_segment_token_indices, + ) + + if not self.uses_fresh_video_gradcache: + raise ValueError( + "triple video MMD requires nystrom_exact_repulsion") + if not self.latent_self_normalize: + raise ValueError( + "triple video MMD requires wan_kv_self_normalize=true") + if ( + self.nystrom_attraction_ema_enabled + or self.nystrom_repulsion_fifo_enabled + ): + raise ValueError( + "triple video MMD requires EMA and FIFO disabled") + if not self.current_teacher_attraction_enabled: + raise ValueError( + "triple video MMD requires exact matched teacher MMD") + if ( + self.nystrom_attraction_weight != 2.0 + or self.current_teacher_attraction_weight != 1.0 + ): + raise ValueError( + "triple fixed/current teacher weights must be exactly 2:1") + if self.expected_current_teacher_samples != 128: + raise ValueError( + "triple objective requires exactly 128 matched teachers") + + def strict_sha256(value, label): + normalized = str(value).strip().lower() + if ( + len(normalized) != 64 + or any(c not in "0123456789abcdef" for c in normalized) + ): + raise ValueError(f"{label} must be a lowercase SHA-256") + return normalized + + def checked_reference_path(path_value, sha_value, label): + path = os.path.realpath(os.path.expanduser(str(path_value))) + if not os.path.isfile(path): + raise FileNotFoundError( + f"triple {label} reference pack is missing: {path!r}") + expected_sha = strict_sha256(sha_value, f"{label} reference SHA-256") + actual_sha = self._distributed_file_sha256(path) + if actual_sha != expected_sha: + raise ValueError( + f"triple {label} reference file SHA-256 mismatch: " + f"{actual_sha} != {expected_sha}") + return path, expected_sha, load_reference_pack( + path, map_location="cpu") + + # Each encoder owns its reference summary; the wrapper combines normalized losses. + vjepa_objective = self.latent_mmd + if not isinstance(vjepa_objective, VideoNystromMMD): + raise TypeError( + "triple bootstrap V-JEPA2 objective must be VideoNystromMMD") + vjepa_path = self.vjepa2_nystrom_reference_path + configured_vjepa_reference_sha = strict_sha256( + self._dense_config_value( + args, "nystrom_reference_sha256", ""), + "V-JEPA2 reference SHA-256", + ) + if self._distributed_file_sha256( + vjepa_path + ) != configured_vjepa_reference_sha: + raise ValueError( + "triple V-JEPA2 reference file SHA-256 mismatch") + + objectives = {} + reference_files = {} + reference_paths = {} + if "dinov3" in active_branches: + dinov3_path, dinov3_file_sha, dinov3_pack = checked_reference_path( + getattr(args, "dinov3_nystrom_reference_path", ""), + getattr(args, "dinov3_nystrom_reference_sha256", ""), + "DINOv3", + ) + dinov3_objective = VideoNystromMMD( + dinov3_pack, + expected_metadata={ + "feature": self._dinov3_feature_metadata(), + "nystrom": { + "num_landmarks": int(getattr( + args, "dinov3_nystrom_landmarks", 256)), + "seed": int(getattr(args, "dinov3_nystrom_seed", 0)), + }, + }, + kernel_chunk_size=int(getattr( + args, "dinov3_nystrom_kernel_chunk_size", 32)), + attraction_weight=2.0, + current_teacher_attraction_enabled=True, + current_teacher_attraction_weight=1.0, + attraction_ema_enabled=False, + repulsion_fifo_size=0, + ).to(device=device) + objectives["dinov3"] = dinov3_objective + reference_files["dinov3"] = dinov3_file_sha + reference_paths["dinov3"] = dinov3_path + self.dinov3_nystrom_reference_path = dinov3_path + self.dinov3_nystrom_reference_hash = dinov3_objective.reference_hash + + videomae_path, videomae_file_sha, videomae_pack = checked_reference_path( + getattr(args, "videomae_v2_nystrom_reference_path", ""), + getattr(args, "videomae_v2_nystrom_reference_sha256", ""), + "VideoMAE-v2", + ) + video_tokens = uniform_segment_token_indices( + self.videomae_v2_encoder.dense_raw_tokens, + self.videomae_v2_dense_num_tokens, + ) + video_feature_metadata = canonical_videomae_v2_dense_feature_metadata( + model_id=self.videomae_v2_reference_model_id, + model_id_is_reference=True, + layer_index=15, + num_tokens=256, + token_indices=video_tokens, + num_segments=5, + frames_per_segment=16, + raw_tokens_per_segment=( + self.videomae_v2_encoder.dense_raw_tokens), + feature_dim=self.videomae_v2_encoder.dense_feature_dim, + resize_short_side=224, + crop_size=224, + skip_first_frame=True, + ) + videomae_objective = VideoNystromMMD( + videomae_pack, + expected_metadata={ + "feature": video_feature_metadata, + "nystrom": { + "num_landmarks": int(getattr( + args, "videomae_v2_nystrom_landmarks", 256)), + "seed": int(getattr( + args, "videomae_v2_nystrom_seed", 0)), + }, + }, + kernel_chunk_size=int(getattr( + args, "videomae_v2_nystrom_kernel_chunk_size", 32)), + attraction_weight=2.0, + current_teacher_attraction_enabled=True, + current_teacher_attraction_weight=1.0, + attraction_ema_enabled=False, + repulsion_fifo_size=0, + ).to(device=device) + + objectives.update({ + "vjepa2": vjepa_objective, + "videomae_v2": videomae_objective, + }) + expected_shapes = { + "dinov3": (1024, 1024), + "vjepa2": (1024, 1024), + "videomae_v2": (1280, 1024), + } + expected_shapes = {name: expected_shapes[name] for name in active_branches} + actual_shapes = { + name: tuple(objective.sample_shape) + for name, objective in objectives.items() + } + if actual_shapes != expected_shapes: + raise ValueError( + "triple reference sample-shape mismatch: " + f"actual={actual_shapes}, expected={expected_shapes}") + + configured_bank_hash = strict_sha256( + getattr(args, "triple_teacher_bank_content_hash", ""), + "triple_teacher_bank_content_hash", + ) + expected_manifest_hash = strict_sha256( + getattr(args, "matched_manifest_sha256", ""), + "matched_manifest_sha256", + ) + expected_reference_samples = int(getattr( + args, "matched_manifest_samples", 5000)) + if expected_reference_samples <= 0: + raise ValueError("matched_manifest_samples must be positive") + sources = { + name: objective.reference_metadata.get( + "reference", {}).get("source", {}) + for name, objective in objectives.items() + } + for name, source in sources.items(): + if ( + source.get("triple_teacher_bank_content_hash") + != configured_bank_hash + or source.get("source_manifest_sha256") + != expected_manifest_hash + or int(source.get("selected_samples", -1)) + != expected_reference_samples + or source.get("branch_name") != name + ): + raise ValueError( + f"{name} reference is not bound to the configured frozen " + f"{expected_reference_samples}-video triple teacher bank") + + # Paper Eq. (3) motivates encoder weighting; this release also uses detached-scale normalization. + branch_weights = { + "dinov3": float(getattr(args, "triple_dinov3_weight", 10.0)), + "vjepa2": float(getattr(args, "triple_vjepa2_weight", 1.0)), + "videomae_v2": float(getattr( + args, "triple_videomae_v2_weight", 1.0)), + } + branch_weights = {name: branch_weights[name] for name in active_branches} + expected_weights = { + "dinov3": 10.0, + "vjepa2": 1.0, + "videomae_v2": 1.0, + } + if branch_weights != {name: expected_weights[name] for name in active_branches}: + raise ValueError( + "active representation weights must retain their original values") + aggregation_divisor = float(getattr( + args, "triple_aggregation_divisor", 3.0)) + if aggregation_divisor != 3.0: + raise ValueError( + "triple representation aggregation divisor must be exactly 3") + self.latent_mmd = MultiEncoderMMD( + objectives, + weights=branch_weights, + normalization_eps=self.latent_norm_eps, + aggregation_divisor=aggregation_divisor, + ).to(device=device) + + self.videomae_v2_nystrom_reference_path = videomae_path + self.videomae_v2_nystrom_reference_hash = ( + videomae_objective.reference_hash) + self.triple_teacher_bank_content_hash = configured_bank_hash + # Compatibility aliases are internal only; public metadata is triple. + self.dual_teacher_bank_content_hash = configured_bank_hash + reference_files.update({ + "vjepa2": configured_vjepa_reference_sha, + "videomae_v2": videomae_file_sha, + }) + reference_paths.update({ + "vjepa2": vjepa_path, + "videomae_v2": videomae_path, + }) + for name, objective in objectives.items(): + self.dual_branch_registry[name].update({ + "reference_path": reference_paths[name], + "reference_file_sha256": reference_files[name], + "reference_hash": objective.reference_hash, + "nystrom_landmarks": int(objective.landmarks.shape[0]), + }) + + def _initialize_videomae_v2_features(self, args, device) -> None: + """Build the fixed five-replica VideoMAE-v2 Large feature map. + + Public state is VideoMAE-namespaced. A small set of historical + ``vjepa2_*`` aliases is retained only inside this class and its trainer + so the already validated GradCache/VAE replay implementation can be + reused without changing the distribution-loss formula. + """ + from video_fd.encoders.segmented_videomae_v2 import ( + SegmentedVideoMAEv2Ensemble, + ) + + configured_model_id = str(getattr( + args, + "videomae_v2_model_id", + "assets/models/VideoMAEv2-Large", + )) + expanded_model_id = os.path.expanduser(configured_model_id) + self.videomae_v2_model_id = ( + os.path.realpath(expanded_model_id) + if os.path.exists(expanded_model_id) + else configured_model_id + ) + # Keep frozen metadata identity separate from the model's load location. + # The local preparation script reads this identity from the feature bank. + self.videomae_v2_reference_model_id = str(getattr( + args, "videomae_v2_reference_model_id", self.videomae_v2_model_id)) + self.videomae_v2_local_files_only = bool(getattr( + args, "videomae_v2_local_files_only", True)) + if ( + self.videomae_v2_local_files_only + and not os.path.isdir(self.videomae_v2_model_id) + ): + raise FileNotFoundError( + "VideoMAE-v2 training requires the pinned local checkpoint " + "directory; prepare the local VideoMAEv2-Large files first: " + f"{self.videomae_v2_model_id}") + self.videomae_v2_model_revision = str(getattr( + args, "videomae_v2_model_revision", "")) + self.videomae_v2_download_manifest_content_hash = str(getattr( + args, "videomae_v2_download_manifest_content_hash", "")) + if self.dual_video_mmd_enabled: + expected_revision = ( + "9981a9c8f77118c421e5228e1b219468a4b0238d") + if self.videomae_v2_model_revision != expected_revision: + raise ValueError( + "VideoMAE-v2 revision must be pinned to " + f"{expected_revision}") + download_manifest_path = os.path.join( + self.videomae_v2_model_id, "download_manifest.json") + if not os.path.isfile(download_manifest_path): + raise FileNotFoundError( + "VideoMAE-v2 download_manifest.json is required") + with open(download_manifest_path, "r", encoding="utf-8") as handle: + download_manifest = json.load(handle) + saved_content_hash = download_manifest.get("content_hash") + manifest_payload = dict(download_manifest) + manifest_payload.pop("content_hash", None) + if ( + download_manifest.get("revision") != expected_revision + or saved_content_hash + != self._canonical_sha256(manifest_payload) + or self.videomae_v2_download_manifest_content_hash + != saved_content_hash + ): + raise ValueError( + "VideoMAE-v2 download manifest identity mismatch") + for file_identity in download_manifest.get("files", []): + relative = str(file_identity.get("path", "")) + path = os.path.realpath(os.path.join( + self.videomae_v2_model_id, relative)) + if ( + not relative + or not path.startswith(self.videomae_v2_model_id + os.sep) + or not os.path.isfile(path) + or os.path.getsize(path) != int( + file_identity.get("size_bytes", -1)) + or self._distributed_file_sha256(path) + != file_identity.get("sha256") + ): + raise ValueError( + "VideoMAE-v2 downloaded file identity mismatch: " + f"{relative}") + + configured_dense_layers = getattr( + args, "videomae_v2_dense_layers", None) + if configured_dense_layers is None: + dense_layers = ( + int(getattr(args, "videomae_v2_dense_layer", 15)), + ) + else: + if isinstance(configured_dense_layers, (str, bytes)): + raise TypeError( + "videomae_v2_dense_layers must be a sequence of integers") + dense_layers = tuple( + int(layer) for layer in configured_dense_layers) + if not dense_layers: + raise ValueError( + "videomae_v2_dense_layers must not be empty") + if len(set(dense_layers)) != len(dense_layers): + raise ValueError( + "videomae_v2_dense_layers must contain unique indices") + if tuple(sorted(dense_layers)) != dense_layers: + raise ValueError( + "videomae_v2_dense_layers must be sorted") + if any(layer < 0 or layer >= 24 for layer in dense_layers): + raise ValueError( + "VideoMAE-v2 Large dense layers use zero-based indices in " + "[0,24)") + + self.videomae_v2_dense_layers = dense_layers + self.videomae_v2_multilayer_enabled = len(dense_layers) > 1 + self.videomae_v2_dense_layer = dense_layers[-1] + self.videomae_v2_encoder_count = int(getattr( + args, "videomae_v2_encoder_count", 5)) + self.videomae_v2_share_encoder_weights = bool(getattr( + args, "videomae_v2_share_encoder_weights", False)) + self.videomae_v2_skip_leading_frames = int(getattr( + args, "videomae_v2_skip_leading_frames", 1)) + self.videomae_v2_segment_num_frames = int(getattr( + args, "videomae_v2_segment_num_frames", 16)) + derived_source_num_frames = ( + self.videomae_v2_skip_leading_frames + + self.videomae_v2_encoder_count + * self.videomae_v2_segment_num_frames + ) + self.videomae_v2_source_num_frames = int(getattr( + args, + "videomae_v2_source_num_frames", + derived_source_num_frames, + )) + self.videomae_v2_dense_num_tokens = int(getattr( + args, "videomae_v2_dense_num_tokens", 128)) + self.videomae_v2_dense_resize_short_side = int(getattr( + args, "videomae_v2_dense_resize_short_side", 224)) + self.videomae_v2_dense_crop_size = int(getattr( + args, "videomae_v2_dense_crop_size", 224)) + self.videomae_v2_dense_gradient_checkpointing = bool(getattr( + args, "videomae_v2_dense_gradient_checkpointing", True)) + + fixed_geometry = ( + self.videomae_v2_encoder_count, + self.videomae_v2_skip_leading_frames, + self.videomae_v2_segment_num_frames, + self.videomae_v2_source_num_frames, + self.videomae_v2_dense_resize_short_side, + self.videomae_v2_dense_crop_size, + ) + expected_geometry = (5, 1, 16, 81, 224, 224) + if fixed_geometry != expected_geometry: + raise ValueError( + "VideoMAE-v2 whole-video geometry is fixed to five physical " + "16-frame encoders over decoded frames [1:81]: " + f"actual={fixed_geometry}, expected={expected_geometry}") + if self.videomae_v2_dense_num_tokens <= 1: + raise ValueError( + "videomae_v2_dense_num_tokens must be > 1 per segment") + + adapter_config = { + "name": "videomae_v2_five_segment_dense", + "model_id": self.videomae_v2_model_id, + "encoder_count": self.videomae_v2_encoder_count, + "share_encoder_weights": ( + self.videomae_v2_share_encoder_weights), + "skip_leading_frames": self.videomae_v2_skip_leading_frames, + "segment_num_frames": self.videomae_v2_segment_num_frames, + "source_num_frames": self.videomae_v2_source_num_frames, + "input_frames": self.videomae_v2_segment_num_frames, + "dense_num_frames": self.videomae_v2_segment_num_frames, + "input_size": self.videomae_v2_dense_crop_size, + "resize_short_side": ( + self.videomae_v2_dense_resize_short_side), + "crop_mode": "center", + "dense_only": True, + # ``15`` means the 16th block and captures its post-residual + # dense state, before pooling/fc_norm. + "dense_layer_index": self.videomae_v2_dense_layer, + "local_files_only": self.videomae_v2_local_files_only, + } + if self.videomae_v2_model_revision: + adapter_config["revision"] = self.videomae_v2_model_revision + self.videomae_v2_encoder = SegmentedVideoMAEv2Ensemble( + adapter_config) + self.videomae_v2_encoder.to(device=device, dtype=torch.float32) + self.videomae_v2_encoder.eval().requires_grad_(False) + + actual_adapter_geometry = ( + int(self.videomae_v2_encoder.dense_layer_index), + int(self.videomae_v2_encoder.dense_num_frames), + int(self.videomae_v2_encoder.resize_short_side), + int(self.videomae_v2_encoder.input_size), + int(self.videomae_v2_encoder.retained_dense_blocks), + int(self.videomae_v2_encoder.dense_raw_tokens), + int(self.videomae_v2_encoder.dense_feature_dim), + len(self.videomae_v2_encoder.encoders), + ) + expected_adapter_geometry = ( + self.videomae_v2_dense_layer, + 16, + 224, + 224, + self.videomae_v2_dense_layer + 1, + 1568, + 1024, + 1 if self.videomae_v2_share_encoder_weights else 5, + ) + if actual_adapter_geometry != expected_adapter_geometry: + raise RuntimeError( + "loaded VideoMAE-v2 Large ensemble does not match the fixed " + "dense-prefix geometry: " + f"actual={actual_adapter_geometry}, " + f"expected={expected_adapter_geometry}") + configured_depths = { + int(getattr(encoder, "_configured_depth", -1)) + for encoder in self.videomae_v2_encoder.encoders + } + if configured_depths != {24}: + raise RuntimeError( + "all five encoders must be VideoMAE-v2 Large with 24 blocks; " + f"got depths={sorted(configured_depths)}") + + # Historical internal aliases. Public metadata, config, logs, and + # checkpoints are translated back to ``videomae_v2_*``. + self.dense_feature_segment_count = 5 + self.vjepa2_model_id = self.videomae_v2_model_id + self.vjepa2_dense_layers = self.videomae_v2_dense_layers + self.vjepa2_multilayer_enabled = ( + self.videomae_v2_multilayer_enabled) + self.vjepa2_dense_layer = self.videomae_v2_dense_layer + self.vjepa2_dense_num_frames = 80 + self.vjepa2_dense_source_num_frames = 81 + self.vjepa2_dense_frame_sampling = "center_contiguous" + self.vjepa2_dense_window_seed = "fixed-five-segment-videomae-v2" + self.vjepa2_dense_num_tokens = ( + self.videomae_v2_dense_num_tokens) + self.vjepa2_dense_resize_short_side = ( + self.videomae_v2_dense_resize_short_side) + self.vjepa2_dense_crop_size = self.videomae_v2_dense_crop_size + self.vjepa2_dense_gradient_checkpointing = ( + self.videomae_v2_dense_gradient_checkpointing) + self.vjepa2_encoder = self.videomae_v2_encoder + self.vjepa2_dense_raw_tokens = int( + self.videomae_v2_encoder.dense_raw_tokens) + self.vjepa2_dense_feature_dim = int( + self.videomae_v2_encoder.dense_feature_dim) + if self.vjepa2_dense_num_tokens > self.vjepa2_dense_raw_tokens: + raise ValueError( + "videomae_v2_dense_num_tokens cannot exceed the native " + "per-segment token grid: " + f"{self.vjepa2_dense_num_tokens} > " + f"{self.vjepa2_dense_raw_tokens}") + + def _initialize_dinov3_features(self, args, device) -> None: + """Load the independent frozen DINOv3 dense-image backend. + + The compatibility geometry consumes sixteen fixed frames and retains a + row-major 8x8 spatial patch subset from each frame. The optional + ``temporal_mean5_full256`` geometry consumes twenty fixed frames, + averages every five consecutive frame-feature grids, and retains the + complete 16x16 patch grid for each of the four temporal groups. Both + geometries produce one aligned [1024, 1024] matrix per video; their + adapter metadata keeps references and checkpoints mutually exclusive. + """ + compatibility_frames = ( + 0, 5, 11, 16, 21, 27, 32, 37, + 43, 48, 53, 59, 64, 69, 75, 80, + ) + temporal_pool_frames = ( + 0, 4, 8, 13, 17, 21, 25, 29, 34, 38, + 42, 46, 51, 55, 59, 63, 67, 72, 76, 80, + ) + compatibility_spatial_axis = (0, 2, 4, 6, 9, 11, 13, 15) + full_spatial_axis = tuple(range(16)) + initialization_error = None + try: + geometry = str(getattr( + args, "dinov3_dense_geometry", "f16x64")).strip() + if geometry == "f16x64": + from video_fd.encoders.dinov3_adapter import ( + DINOv3DenseAdapter as Adapter, + ) + + canonical_frames = compatibility_frames + canonical_spatial_axis = compatibility_spatial_axis + elif geometry == "temporal_mean5_full256": + from video_fd.encoders.dinov3_temporal_pool_adapter import ( + DINOv3TemporalPoolAdapter as Adapter, + ) + + canonical_frames = temporal_pool_frames + canonical_spatial_axis = full_spatial_axis + else: + raise ValueError( + "dinov3_dense_geometry must be 'f16x64' or " + "'temporal_mean5_full256', got " + f"{geometry!r}") + + configured_frames = tuple(int(value) for value in getattr( + args, "dinov3_dense_frame_indices", canonical_frames)) + configured_spatial_axis = tuple(int(value) for value in getattr( + args, "dinov3_dense_spatial_axis_indices", + canonical_spatial_axis)) + adapter_config = { + "repo_path": str(getattr(args, "dinov3_repo_path", "")), + "weight_path": str(getattr(args, "dinov3_weight_path", "")), + "source_commit": str(getattr( + args, "dinov3_source_commit", "")), + "source_num_frames": int(getattr( + args, "dinov3_dense_source_num_frames", 81)), + "frame_indices": configured_frames, + "input_size": int(getattr( + args, "dinov3_dense_input_size", 256)), + "patch_size": int(getattr( + args, "dinov3_dense_patch_size", 16)), + "spatial_axis_indices": configured_spatial_axis, + "forward_chunk_frames": int(getattr( + args, "dinov3_dense_forward_chunk_frames", 4)), + } + if geometry == "temporal_mean5_full256": + adapter_config.update({ + "geometry": "temporal_mean5_full256", + "temporal_groups": 4, + "temporal_pool_frames": 5, + }) + configured_checkpoint_sha256 = str(getattr( + args, "dinov3_checkpoint_sha256", "")).strip() + if configured_checkpoint_sha256: + adapter_config["checkpoint_sha256"] = ( + configured_checkpoint_sha256) + + self.dinov3_encoder = Adapter(adapter_config) + self.dinov3_encoder.to(device=device, dtype=torch.float32) + self.dinov3_encoder.eval().requires_grad_(False) + + self.dinov3_dense_geometry = geometry + self.dinov3_model_id = str(self.dinov3_encoder.model_id) + self.dinov3_repo_path = str(self.dinov3_encoder.repo_path) + self.dinov3_weight_path = str(self.dinov3_encoder.weight_path) + self.dinov3_source_commit = str( + self.dinov3_encoder.source_commit) + self.dinov3_dense_source_num_frames = int( + self.dinov3_encoder.source_num_frames) + self.dinov3_dense_frame_indices = tuple( + int(value) for value in self.dinov3_encoder.frame_indices) + self.dinov3_dense_num_frames = int( + self.dinov3_encoder.dense_num_frames) + self.dinov3_dense_input_size = int( + self.dinov3_encoder.input_size) + self.dinov3_dense_patch_size = int( + self.dinov3_encoder.patch_size) + self.dinov3_dense_spatial_axis_indices = tuple( + int(value) for value in + self.dinov3_encoder.spatial_axis_indices) + self.dinov3_dense_forward_chunk_frames = int( + self.dinov3_encoder.forward_chunk_frames) + self.dinov3_dense_raw_tokens = int( + self.dinov3_encoder.dense_raw_tokens) + self.dinov3_dense_num_tokens = int( + self.dinov3_encoder.dense_num_tokens) + self.dinov3_dense_feature_dim = int( + self.dinov3_encoder.dense_feature_dim) + self.dinov3_dense_gradient_checkpointing = bool(getattr( + args, "dinov3_dense_gradient_checkpointing", False)) + + if geometry == "temporal_mean5_full256": + self.dinov3_dense_temporal_groups = int( + self.dinov3_encoder.temporal_groups) + self.dinov3_dense_temporal_pool_size = int( + self.dinov3_encoder.temporal_pool_frames) + self.dinov3_dense_tokens_per_group = int( + self.dinov3_encoder.per_frame_tokens) + + actual_contract = { + "model_id": self.dinov3_model_id, + "source_num_frames": self.dinov3_dense_source_num_frames, + "frame_indices": self.dinov3_dense_frame_indices, + "num_frames": self.dinov3_dense_num_frames, + "input_size": self.dinov3_dense_input_size, + "patch_size": self.dinov3_dense_patch_size, + "spatial_axis_indices": ( + self.dinov3_dense_spatial_axis_indices), + "forward_chunk_frames": ( + self.dinov3_dense_forward_chunk_frames), + "raw_tokens": self.dinov3_dense_raw_tokens, + "num_tokens": self.dinov3_dense_num_tokens, + "feature_dim": self.dinov3_dense_feature_dim, + } + if geometry == "temporal_mean5_full256": + actual_contract.update({ + "geometry": str(self.dinov3_encoder.geometry), + "temporal_groups": ( + self.dinov3_dense_temporal_groups), + "temporal_pool_size": ( + self.dinov3_dense_temporal_pool_size), + "tokens_per_group": ( + self.dinov3_dense_tokens_per_group), + }) + expected_contract = { + "model_id": "dinov3_vitl16", + "source_num_frames": 81, + "frame_indices": canonical_frames, + "num_frames": 20, + "input_size": 256, + "patch_size": 16, + "spatial_axis_indices": canonical_spatial_axis, + "forward_chunk_frames": 4, + "raw_tokens": 5120, + "num_tokens": 1024, + "feature_dim": 1024, + "geometry": "temporal_mean5_full256", + "temporal_groups": 4, + "temporal_pool_size": 5, + "tokens_per_group": 256, + } + else: + expected_contract = { + "model_id": "dinov3_vitl16", + "source_num_frames": 81, + "frame_indices": canonical_frames, + "num_frames": 16, + "input_size": 256, + "patch_size": 16, + "spatial_axis_indices": canonical_spatial_axis, + "forward_chunk_frames": 4, + "raw_tokens": 4096, + "num_tokens": 1024, + "feature_dim": 1024, + } + if actual_contract != expected_contract: + raise ValueError( + "loaded DINOv3 adapter does not satisfy the public dense " + f"feature contract: actual={actual_contract}, " + f"expected={expected_contract}") + except BaseException as exc: + initialization_error = exc + self._synchronize_dinov3_local_failure( + initialization_error, + device, + operation="DINOv3 adapter initialization failed", + ) + + def _dinov3_feature_metadata(self) -> dict: + metadata = self.dinov3_encoder.feature_metadata() + if not isinstance(metadata, Mapping): + raise TypeError("DINOv3 feature_metadata() must return a mapping") + metadata = dict(metadata) + required_identity = { + "feature_type": "dinov3_dense_latent", + "model_id": self.dinov3_model_id, + "raw_tokens": self.dinov3_dense_raw_tokens, + "num_tokens": self.dinov3_dense_num_tokens, + "feature_dim": self.dinov3_dense_feature_dim, + "matrix_shape": [ + self.dinov3_dense_num_tokens, + self.dinov3_dense_feature_dim, + ], + } + mismatched = { + key: {"saved": metadata.get(key), "runtime": value} + for key, value in required_identity.items() + if metadata.get(key) != value + } + if mismatched: + raise ValueError( + "DINOv3 adapter metadata violates the runtime feature " + f"contract: {mismatched}") + # The frozen bank may record its original asset locations. Use those + # strings only as metadata identities; encoder I/O uses relocated paths. + reference_paths = getattr(self, "dinov3_reference_paths", {}) + if reference_paths: + for key in ("repo_path", "weight_path"): + metadata[key] = reference_paths[key] + metadata.pop("feature_contract_sha256", None) + payload = json.dumps( + metadata, sort_keys=True, separators=(",", ":"), + ensure_ascii=True, allow_nan=False).encode("utf-8") + metadata["feature_contract_sha256"] = hashlib.sha256(payload).hexdigest() + return metadata + + def _dinov3_descriptor_frame_indices( + self, + replay_descriptor: Mapping, + *, + device: torch.device, + ) -> torch.Tensor: + saved = replay_descriptor.get("dinov3_frame_indices") + if not isinstance(saved, torch.Tensor): + raise TypeError("DINOv3 replay frame indices must be a tensor") + saved = saved.to(device=device, dtype=torch.long) + expected = torch.tensor( + self.dinov3_dense_frame_indices, + device=device, + dtype=torch.long, + ) + if saved.ndim != 1 or not torch.equal(saved, expected): + raise ValueError( + "DINOv3 replay frame indices changed from the fixed public " + "sampling contract") + return saved + + def _extract_dinov3_dense(self, video01: torch.Tensor) -> torch.Tensor: + if ( + video01.ndim != 5 + or int(video01.shape[1]) + != self.dinov3_dense_source_num_frames + or int(video01.shape[2]) != 3 + ): + raise ValueError( + "DINOv3 dense extraction requires full decoded videos with " + f"shape [B,{self.dinov3_dense_source_num_frames},3,H,W], " + f"got {tuple(video01.shape)}") + extract = ( + self.dinov3_encoder.forward_dense_no_grad + if not torch.is_grad_enabled() + else self.dinov3_encoder.forward_dense + ) + features = extract( + video01, + gradient_checkpointing=( + self.dinov3_dense_gradient_checkpointing), + ) + expected_shape = ( + int(video01.shape[0]), + self.dinov3_dense_num_tokens, + self.dinov3_dense_feature_dim, + ) + if features.ndim != 3 or tuple(features.shape) != expected_shape: + raise ValueError( + "frozen DINOv3 returned an invalid dense feature matrix: " + f"got {tuple(features.shape)}, expected {expected_shape}") + return features + + def _replay_dinov3_dense_to_pixels( + self, + video01: torch.Tensor, + *, + expected_features: torch.Tensor, + feature_cotangent: torch.Tensor, + replay_atol: float, + replay_rtol: float, + ) -> Tuple[torch.Tensor, torch.Tensor]: + replay_features, video_cotangent = ( + self.dinov3_encoder.replay_dense_vjp( + video01.detach().float(), + feature_cotangent.detach().float(), + expected_features=expected_features.detach().float(), + gradient_checkpointing=( + self.dinov3_dense_gradient_checkpointing), + replay_atol=float(replay_atol), + replay_rtol=float(replay_rtol), + ) + ) + expected_feature_shape = tuple(expected_features.shape) + if tuple(replay_features.shape) != expected_feature_shape: + raise RuntimeError( + "DINOv3 feature replay changed matrix shape: " + f"{tuple(replay_features.shape)} != " + f"{expected_feature_shape}") + if tuple(video_cotangent.shape) != tuple(video01.shape): + raise RuntimeError( + "DINOv3 replay returned an invalid full-video pixel " + f"cotangent: {tuple(video_cotangent.shape)} != " + f"{tuple(video01.shape)}") + if not bool(torch.isfinite(video_cotangent).all().item()): + raise FloatingPointError( + "DINOv3 replay returned a non-finite pixel cotangent") + replay_max_abs = ( + replay_features.detach().float() + - expected_features.detach().float() + ).abs().max() + return video_cotangent.detach().float(), replay_max_abs.detach() + + def _dense_metric_prefix(self) -> str: + return ( + "dinov3_dense" + if self._uses_dinov3_dense_backend() + else "vjepa2_dense" + ) + + def _uses_dinov3_dense_backend(self) -> bool: + """Support legacy/test instances created without the new init flags.""" + return bool(getattr( + self, + "is_dinov3_dense", + getattr(self, "feature_type", None) == "dinov3_dense_latent", + )) + + def _token_indices(self, latent: torch.Tensor) -> torch.Tensor: + patch = self.latent_patch_size + tokens = ( + (latent.shape[1] // int(patch[0])) + * (latent.shape[3] // int(patch[1])) + * (latent.shape[4] // int(patch[2])) + ) + n = min(tokens, self.latent_num_tokens) + return torch.linspace( + 0, tokens - 1, steps=n, device=latent.device).round().long() + + def _extract(self, latent, conditional_dict, token_indices): + timestep = latent.new_full( + (latent.shape[0], latent.shape[1]), + self.latent_timestep, + dtype=torch.float32, + ) + features = self.real_score( + noisy_image_or_video=latent, + conditional_dict=conditional_dict, + timestep=timestep, + return_latent_features=True, + latent_feature_layer=self.latent_layer, + latent_token_indices=token_indices, + ) + expected_shape = ( + latent.shape[0], token_indices.numel(), self.latent_feature_dim) + if features.ndim != 3 or tuple(features.shape) != expected_shape: + raise ValueError( + "frozen Wan returned an invalid post-block latent shape: " + f"got {tuple(features.shape)}, expected {expected_shape}") + return features + + def _vjepa2_token_indices(self, device: torch.device) -> torch.Tensor: + return uniform_dense_token_indices( + self.vjepa2_dense_raw_tokens, + self.vjepa2_dense_num_tokens, + device=device, + ) + + def _vjepa2_feature_metadata( + self, + token_indices: Optional[torch.Tensor] = None, + *, + layer_index: Optional[int] = None, + ) -> dict: + resolved_layer = ( + ( + self.motionformer_dense_layer + if self._dense_encoder_kind() == "motionformer" + else self.vjepa2_dense_layer + ) + if layer_index is None + else int(layer_index) + ) + if self._dense_encoder_kind() == "motionformer": + if resolved_layer != self.motionformer_dense_layer: + raise ValueError( + "requested feature metadata for an inactive MotionFormer " + f"layer: {resolved_layer}") + from video_fd.encoders.motionformer_dense_metadata import ( + canonical_motionformer_dense_feature_metadata, + ) + + return canonical_motionformer_dense_feature_metadata( + checkpoint_path=self.motionformer_checkpoint_path, + checkpoint_sha256=self.motionformer_checkpoint_sha256, + layer_index=self.motionformer_dense_layer, + num_frames=self.motionformer_dense_num_frames, + raw_tokens=self.motionformer_dense_raw_tokens, + num_tokens=self.motionformer_dense_num_tokens, + feature_dim=self.motionformer_dense_feature_dim, + token_indices=token_indices, + ) + if resolved_layer not in getattr( + self, "vjepa2_dense_layers", (self.vjepa2_dense_layer,) + ): + raise ValueError( + "requested feature metadata for an inactive V-JEPA2 layer: " + f"{resolved_layer}") + if ( + token_indices is None + and self.dense_state_prefix in { + "dual", "triple", "dinov3_vjepa2" + } + and getattr( + self, + "vjepa2_reference_token_contract", + "explicit_uniform_v2", + ) != "legacy_uniform_implicit_v1" + ): + # Multi-encoder Wan banks serialize the exact uniform subset so + # the reference and replay contracts attest row order explicitly. + token_indices = uniform_dense_token_indices( + self.vjepa2_dense_raw_tokens, + self.vjepa2_dense_num_tokens, + ) + metadata_kwargs = { + "model_id": getattr( + self, "vjepa2_reference_model_id", self.vjepa2_model_id), + "layer_index": resolved_layer, + "num_frames": self.vjepa2_dense_num_frames, + "resize_short_side": self.vjepa2_dense_resize_short_side, + "crop_size": self.vjepa2_dense_crop_size, + "raw_tokens": self.vjepa2_dense_raw_tokens, + "num_tokens": self.vjepa2_dense_num_tokens, + "feature_dim": self.vjepa2_dense_feature_dim, + "token_indices": token_indices, + } + default_reference_sampling = getattr( + self, + "vjepa2_dense_frame_sampling", + "center_contiguous", + ) + if ( + not hasattr(self, "vjepa2_reference_frame_sampling") + and self.dense_state_prefix in { + "dual", "triple", "dinov3_vjepa2" + } + ): + # Preserve pre-existing checkpoint/test fixtures that predate the + # explicit reference/student split: multi-encoder references were + # last64 unless an initialized model now says otherwise. + default_reference_sampling = "last_contiguous" + frame_sampling = getattr( + self, + "vjepa2_reference_frame_sampling", + default_reference_sampling, + ) + if frame_sampling == "last_contiguous": + metadata_kwargs.update({ + "frame_sampling": frame_sampling, + "source_num_frames": self.vjepa2_dense_source_num_frames, + }) + elif frame_sampling == "random_contiguous_per_video": + metadata_kwargs.update({ + "frame_sampling": frame_sampling, + "source_num_frames": self.vjepa2_dense_source_num_frames, + "window_seed": self.vjepa2_dense_window_seed, + }) + return canonical_vjepa2_dense_feature_metadata(**metadata_kwargs) + + def _dense_feature_metadata( + self, + token_indices: Optional[torch.Tensor] = None, + *, + layer_index: Optional[int] = None, + ) -> dict: + """Encoder-neutral alias for strict dense feature identity.""" + return self._vjepa2_feature_metadata( + token_indices, layer_index=layer_index) + + def _vjepa2_temporal_window_indices( + self, + sample_keys, + *, + batch_size: int, + device: torch.device, + ) -> Optional[torch.Tensor]: + sampling = getattr( + self, + "vjepa2_student_frame_sampling", + getattr(self, "vjepa2_dense_frame_sampling", "center_contiguous"), + ) + if sampling == "center_contiguous": + if sample_keys is not None: + raise ValueError( + "window sample keys are invalid for center sampling") + return None + if sampling == "last_contiguous": + if sample_keys is not None: + raise ValueError( + "window sample keys are invalid for last sampling") + start = ( + int(self.vjepa2_student_source_num_frames) + - int(self.vjepa2_dense_num_frames) + ) + offsets = torch.arange( + self.vjepa2_dense_num_frames, + device=device, + dtype=torch.long, + ) + return (offsets + start).unsqueeze(0).expand( + int(batch_size), -1).clone() + if sample_keys is None: + raise ValueError( + "random V-JEPA2 windows require one sample key per video") + if isinstance(sample_keys, torch.Tensor): + if sample_keys.ndim != 1: + raise ValueError("window sample keys must have shape [B]") + keys = sample_keys.detach().cpu().tolist() + else: + keys = list(sample_keys) + if len(keys) != int(batch_size): + raise ValueError( + "window sample key count does not match the video batch: " + f"{len(keys)} != {int(batch_size)}") + starts = [ + stateless_temporal_window_start( + self.vjepa2_dense_window_seed, + "student", + int(key), + source_num_frames=getattr( + self, "vjepa2_student_source_num_frames", + self.vjepa2_dense_source_num_frames), + window_num_frames=self.vjepa2_dense_num_frames, + ) + for key in keys + ] + start_tensor = torch.tensor( + starts, device=device, dtype=torch.long) + offsets = torch.arange( + self.vjepa2_dense_num_frames, + device=device, + dtype=torch.long, + ) + return start_tensor[:, None] + offsets[None, :] + + def _vjepa2_descriptor_window_indices( + self, + replay_descriptor: dict, + *, + device: torch.device, + ) -> Optional[torch.Tensor]: + if getattr( + self, + "vjepa2_student_frame_sampling", + getattr(self, "vjepa2_dense_frame_sampling", "center_contiguous"), + ) == "center_contiguous": + return None + return replay_descriptor["vjepa2_frame_indices"].to( + device=device, dtype=torch.long) + + def _extract_vjepa2_dense( + self, + video01: torch.Tensor, + token_indices: torch.Tensor, + *, + gradient_checkpointing: bool, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + if self._dense_encoder_kind() == "motionformer": + if temporal_window_indices is not None: + raise ValueError( + "MotionFormer performs fixed segment-midpoint sampling " + "and does not accept V-JEPA2 temporal windows") + features = self.motionformer_encoder.forward_dense( + video01, + layer_index=self.motionformer_dense_layer, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + expected_shape = ( + video01.shape[0], + token_indices.numel(), + self.motionformer_dense_feature_dim, + ) + if features.ndim != 3 or tuple(features.shape) != expected_shape: + raise ValueError( + "frozen MotionFormer returned an invalid intermediate " + f"dense shape: got {tuple(features.shape)}, expected " + f"{expected_shape}") + return features + + dense_layers = getattr( + self, "vjepa2_dense_layers", (self.vjepa2_dense_layer,)) + if len(dense_layers) == 1: + features_by_layer = { + dense_layers[0]: self.vjepa2_encoder.forward_dense( + video01, + layer_index=dense_layers[0], + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + temporal_window_indices=temporal_window_indices, + ) + } + else: + features_by_layer = self.vjepa2_encoder.forward_dense_layers( + video01, + layer_indices=dense_layers, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + temporal_window_indices=temporal_window_indices, + ) + expected_shape = ( + video01.shape[0], + token_indices.numel(), + self.vjepa2_dense_feature_dim, + ) + ordered_features = [] + for layer in dense_layers: + features = features_by_layer[layer] + if ( + features.ndim != 3 + or tuple(features.shape) != expected_shape + ): + raise ValueError( + "frozen V-JEPA2 returned an invalid intermediate dense " + f"shape at layer {layer}: got {tuple(features.shape)}, " + f"expected {expected_shape}") + ordered_features.append(features) + # The GradCache transports one tensor. The loss module splits this + # layer-major concatenation and evaluates independent kernels. + return ( + ordered_features[0] + if len(ordered_features) == 1 + else torch.cat(ordered_features, dim=1) + ) + + def _replay_dense_encoder_vjp( + self, + video01: torch.Tensor, + token_indices: torch.Tensor, + expected_features: torch.Tensor, + feature_cotangent: torch.Tensor, + *, + gradient_checkpointing: bool, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Replay frozen V-JEPA2 and return its features and pixel VJP.""" + if expected_features.shape != feature_cotangent.shape: + raise ValueError( + "dense encoder replay feature/cotangent shapes differ: " + f"{tuple(expected_features.shape)} != " + f"{tuple(feature_cotangent.shape)}") + student_video_leaf = video01.detach().float().requires_grad_(True) + replay_kwargs = { + "gradient_checkpointing": gradient_checkpointing, + } + if temporal_window_indices is not None: + replay_kwargs["temporal_window_indices"] = temporal_window_indices + replay_features = self._extract_vjepa2_dense( + student_video_leaf, token_indices, **replay_kwargs).float() + encoder_surrogate = (replay_features * feature_cotangent).sum() + (video_cotangent,) = torch.autograd.grad( + encoder_surrogate, student_video_leaf, + retain_graph=False, create_graph=False) + replay_features = replay_features.detach().float() + video_cotangent = video_cotangent.detach().float() + del encoder_surrogate, student_video_leaf + return replay_features, video_cotangent + + def _decode_video01(self, latent: torch.Tensor) -> torch.Tensor: + pixels = self.vae.decode_to_pixel(latent.detach()) + if pixels.ndim != 5 or pixels.shape[2] != 3: + raise ValueError( + "Wan VAE returned an invalid video shape: expected " + f"[B,T,3,H,W], got {tuple(pixels.shape)}") + if ( + self.is_triple_dense + or getattr(self, "is_dinov3_vjepa2_dense", False) + or self._uses_dinov3_dense_backend() + ): + if int(pixels.shape[1]) != 81: + raise ValueError( + "DINOv3/triple dense features require exactly 81 " + f"decoded Wan frames; got {int(pixels.shape[1])}") + elif self.dense_state_prefix == "videomae_v2": + if int(pixels.shape[1]) != 81: + raise ValueError( + "segmented VideoMAE-v2 requires exactly 81 decoded " + f"frames; got {int(pixels.shape[1])}") + else: + if pixels.shape[1] < self.vjepa2_dense_num_frames: + raise ValueError( + "decoded video is too short for the dense-video clip") + student_sampling = getattr( + self, "vjepa2_student_frame_sampling", + getattr(self, "vjepa2_dense_frame_sampling", "center_contiguous")) + if ( + student_sampling in {"random_contiguous_per_video", "last_contiguous"} + and int(pixels.shape[1]) != self.vjepa2_student_source_num_frames + ): + raise ValueError( + f"{student_sampling} V-JEPA2 sampling requires " + f"{self.vjepa2_student_source_num_frames} frames") + return ((pixels + 1.0) * 0.5).clamp(0.0, 1.0) + + def _capture_generation_rng(self) -> dict: + return { + "cpu": torch.get_rng_state().clone(), + "cuda": torch.cuda.get_rng_state(self.device).clone(), + "numpy": np.random.get_state(), + "python": random.getstate(), + } + + @contextmanager + def _replay_generation_rng(self, rng_state: dict): + if set(rng_state) != {"cpu", "cuda", "numpy", "python"}: + raise ValueError( + "invalid fresh-video replay RNG descriptor") + # fork_rng restores the ambient stream afterwards. Consequently the + # second differentiable pass does not consume new training randomness. + ambient_numpy = np.random.get_state() + ambient_python = random.getstate() + try: + with torch.random.fork_rng(devices=[self.device], enabled=True): + torch.set_rng_state(rng_state["cpu"]) + torch.cuda.set_rng_state(rng_state["cuda"], self.device) + np.random.set_state(rng_state["numpy"]) + random.setstate(rng_state["python"]) + yield + finally: + np.random.set_state(ambient_numpy) + random.setstate(ambient_python) + + def _generate_student_latent( + self, + *, + image_or_video_shape, + conditional_dict: dict, + unconditional_dict: dict, + initial_latent: Optional[torch.Tensor] = None, + generation_descriptor: Optional[dict] = None, + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + if self.causal_generator: + pred, gradient_mask, _, _ = self._run_generator( + image_or_video_shape=image_or_video_shape, + conditional_dict=dict(conditional_dict), + initial_latent=initial_latent, + generation_descriptor=generation_descriptor, + ) + return pred, gradient_mask + if initial_latent is not None: + raise NotImplementedError( + "fresh V-JEPA2 GradCache currently supports T2V only") + pred = self._run_few_step_generator( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + ) + return pred, None + + def _validate_current_teacher_feature_tensor( + self, + features: torch.Tensor, + *, + student_latent: torch.Tensor, + token_indices: torch.Tensor, + source: str, + ) -> torch.Tensor: + """Return one frozen FP32 teacher matrix with exact student geometry.""" + if not isinstance(features, torch.Tensor): + raise TypeError(f"{source} must be a torch.Tensor") + if not features.is_floating_point(): + raise TypeError(f"{source} must be floating point") + expected_shape = ( + int(student_latent.shape[0]), + int(token_indices.numel()), + int(self._dense_attr("feature_dim")), + ) + if features.ndim != 3 or tuple(features.shape) != expected_shape: + raise ValueError( + f"{source} must have exact [B,N,D] shape " + f"{expected_shape}, got {tuple(features.shape)}") + if features.device != student_latent.device: + raise ValueError( + f"{source} and student latent must share a device") + frozen = features.detach() + if not bool(torch.isfinite(frozen).all().item()): + raise FloatingPointError(f"{source} contains non-finite values") + return frozen.float() + + def _current_teacher_features_for_fresh_video( + self, + *, + student_latent: torch.Tensor, + token_indices: torch.Tensor, + extract_kwargs: Mapping[str, object], + current_teacher_features: Optional[torch.Tensor], + current_teacher_video01: Optional[torch.Tensor], + current_teacher_latent: Optional[torch.Tensor], + ) -> Optional[torch.Tensor]: + """Resolve the mutually exclusive matched-teacher input contract.""" + candidates = { + "current_teacher_features": current_teacher_features, + "current_teacher_video01": current_teacher_video01, + "current_teacher_latent": current_teacher_latent, + } + supplied = [ + name for name, value in candidates.items() if value is not None + ] + if not getattr(self, "current_teacher_attraction_enabled", False): + if supplied: + raise ValueError( + f"{', '.join(supplied)} provided while exact " + "current-teacher attraction is disabled") + return None + if len(supplied) != 1: + raise ValueError( + "exactly one of current_teacher_features, " + "current_teacher_video01, or current_teacher_latent must be " + f"provided; got {supplied}") + + source = supplied[0] + del candidates + if source == "current_teacher_features": + return self._validate_current_teacher_feature_tensor( + current_teacher_features, + student_latent=student_latent, + token_indices=token_indices, + source=source, + ) + + if source == "current_teacher_video01": + teacher_video01 = current_teacher_video01 + if not isinstance(teacher_video01, torch.Tensor): + raise TypeError( + "current_teacher_video01 must be a torch.Tensor") + if not teacher_video01.is_floating_point(): + raise TypeError( + "current_teacher_video01 must be floating point") + if ( + teacher_video01.ndim != 5 + or int(teacher_video01.shape[0]) + != int(student_latent.shape[0]) + or int(teacher_video01.shape[2]) != 3 + or int(teacher_video01.shape[1]) + < int(self._dense_attr("num_frames")) + or int(teacher_video01.shape[3]) <= 0 + or int(teacher_video01.shape[4]) <= 0 + ): + raise ValueError( + "current_teacher_video01 must have shape " + f"[B,T>={int(self._dense_attr('num_frames'))},3,H,W] " + "with the student batch size; got " + f"{tuple(teacher_video01.shape)}") + student_sampling = getattr( + self, + "vjepa2_student_frame_sampling", + getattr( + self, "vjepa2_dense_frame_sampling", "center_contiguous"), + ) + if ( + student_sampling == "last_contiguous" + and int(teacher_video01.shape[1]) not in { + int(self.vjepa2_dense_num_frames), + int(self.vjepa2_student_source_num_frames), + } + ): + raise ValueError( + "last_contiguous teacher video must be either the exact " + "64-frame contract clip or its complete 81-frame source") + if teacher_video01.device != student_latent.device: + raise ValueError( + "current_teacher_video01 and student latent must share " + "a device") + frozen_video01 = teacher_video01.detach() + if not bool(torch.isfinite(frozen_video01).all().item()): + raise FloatingPointError( + "current_teacher_video01 contains non-finite values") + if ( + float(frozen_video01.min().item()) < 0.0 + or float(frozen_video01.max().item()) > 1.0 + ): + raise ValueError( + "current_teacher_video01 must be normalized to [0,1]") + extracted = self._extract_vjepa2_dense( + frozen_video01, + token_indices, + **extract_kwargs, + ) + del current_teacher_video01, frozen_video01, teacher_video01 + else: + teacher_latent = current_teacher_latent + if not isinstance(teacher_latent, torch.Tensor): + raise TypeError( + "current_teacher_latent must be a torch.Tensor") + if not teacher_latent.is_floating_point(): + raise TypeError( + "current_teacher_latent must be floating point") + if tuple(teacher_latent.shape) != tuple(student_latent.shape): + raise ValueError( + "current teacher/student latent shapes must match: " + f"{tuple(teacher_latent.shape)} != " + f"{tuple(student_latent.shape)}") + if teacher_latent.device != student_latent.device: + raise ValueError( + "current teacher/student latents must share a device") + frozen_latent = teacher_latent.detach() + if not bool(torch.isfinite(frozen_latent).all().item()): + raise FloatingPointError( + "current teacher latent contains non-finite values") + teacher_video01 = self._decode_video01(frozen_latent) + extracted = self._extract_vjepa2_dense( + teacher_video01, + token_indices, + **extract_kwargs, + ) + del current_teacher_latent, teacher_latent, frozen_latent, teacher_video01 + + return self._validate_current_teacher_feature_tensor( + extracted, + student_latent=student_latent, + token_indices=token_indices, + source=f"V-JEPA2 output for {source}", + ) + + @property + def dual_video_mmd_enabled(self) -> bool: + """Compatibility name for heterogeneous multi-encoder MMD.""" + return getattr(self, "feature_type", None) in { + "dual_vjepa2_videomae_v2_dense_latent", + "dual_dinov3_vjepa2_dense_latent", + "triple_dinov3_vjepa2_videomae_v2_dense_latent", + } + + def _dual_token_indices( + self, + device: torch.device, + ) -> dict[str, torch.Tensor]: + indices = { + "vjepa2": uniform_dense_token_indices( + self.vjepa2_dense_raw_tokens, 1024, device=device), + } + if "dinov3" in self.dual_branch_registry: + indices["dinov3"] = torch.tensor( + self.dinov3_dense_frame_indices, + device=device, + dtype=torch.long, + ) + if "videomae_v2" in self.dual_branch_registry: + from video_fd.encoders.videomae_v2_dense_metadata import ( + uniform_segment_token_indices, + ) + + indices["videomae_v2"] = uniform_segment_token_indices( + self.videomae_v2_encoder.dense_raw_tokens, + 256, + device=device, + ) + return indices + + def _validate_dual_feature_mapping( + self, + features: Mapping[str, torch.Tensor], + *, + batch_size: int, + device: torch.device, + label: str, + ) -> dict[str, torch.Tensor]: + expected_keys = tuple(self.dual_branch_registry) + if not isinstance(features, Mapping) or set(features) != set(expected_keys): + raise ValueError( + f"{label} must contain exactly branches {expected_keys}") + result = {} + for name in expected_keys: + tensor = features[name] + expected_shape = ( + batch_size, + *self.dual_branch_registry[name]["sample_shape"], + ) + if ( + not isinstance(tensor, torch.Tensor) + or not tensor.is_floating_point() + or tuple(tensor.shape) != expected_shape + or tensor.device != device + ): + raise ValueError( + f"{label}[{name!r}] must be floating {expected_shape} " + f"on {device}; got {getattr(tensor, 'shape', None)}") + frozen = tensor.detach().float() + if not bool(torch.isfinite(frozen).all().item()): + raise FloatingPointError(f"{label}[{name!r}] is non-finite") + result[name] = frozen + return result + + def _extract_multi_encoder_features( + self, + video01: torch.Tensor, + token_indices: Mapping[str, torch.Tensor], + *, + gradient_checkpointing: bool, + ) -> dict[str, torch.Tensor]: + if int(video01.shape[1]) != 81: + raise ValueError( + "multi-encoder video feature extraction requires 81 frames") + temporal_indices = torch.arange( + 17, 81, device=video01.device, dtype=torch.long + ).unsqueeze(0).expand(int(video01.shape[0]), -1) + features = {} + if "dinov3" in self.dual_branch_registry: + features["dinov3"] = self._extract_dinov3_dense(video01) + features["vjepa2"] = self._extract_vjepa2_dense( + video01, + token_indices["vjepa2"], + gradient_checkpointing=gradient_checkpointing, + temporal_window_indices=temporal_indices, + ) + if "videomae_v2" in self.dual_branch_registry: + features["videomae_v2"] = ( + self.videomae_v2_encoder.forward_dense( + video01, + layer_index=15, + token_indices=token_indices["videomae_v2"], + gradient_checkpointing=gradient_checkpointing, + ) + ) + return self._validate_dual_feature_mapping( + features, + batch_size=int(video01.shape[0]), + device=video01.device, + label="multi-encoder output", + ) + + @torch.no_grad() + def _collect_multi_encoder_rollout_features( + self, + *, + image_or_video_shape, + conditional_dict: dict, + unconditional_dict: dict, + initial_latent: Optional[torch.Tensor], + current_teacher_features: Optional[Mapping[str, torch.Tensor]], + current_teacher_video01: Optional[torch.Tensor], + current_teacher_latent: Optional[torch.Tensor], + require_current_teacher: bool, + ) -> Tuple[Mapping[str, torch.Tensor], dict]: + if current_teacher_video01 is not None or current_teacher_latent is not None: + raise ValueError( + "dual training reads frozen teacher features from the bank; " + "online teacher video/latent decoding is forbidden") + if require_current_teacher != (current_teacher_features is not None): + raise ValueError( + "selected dual suffix rows require exactly one bank feature mapping") + if not self.fresh_video_prepared_replay_enabled: + raise ValueError( + "dual video MMD requires prepared replay so both encoder VJPs " + "share one decoded pixel tensor and one VAE VJP") + + generation_descriptor = self._sample_generator_input_descriptor( + image_or_video_shape, initial_latent=initial_latent) + saved_generation_noise = generation_descriptor["noise"].detach().clone() + rng_state = self._capture_generation_rng() + student_latent, _ = self._generate_student_latent( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + initial_latent=initial_latent, + generation_descriptor=generation_descriptor, + ) + token_indices = self._dual_token_indices(student_latent.device) + teacher = None + if require_current_teacher: + teacher = self._validate_dual_feature_mapping( + current_teacher_features, + batch_size=int(student_latent.shape[0]), + device=student_latent.device, + label="dual teacher bank features", + ) + student_video01 = self._decode_video01(student_latent) + if int(student_video01.shape[1]) != 81: + raise ValueError("dual student decode must produce exactly 81 frames") + features = self._extract_multi_encoder_features( + student_video01, + token_indices, + gradient_checkpointing=False, + ) + descriptor = { + "rng_state": rng_state, + "latent_shape": tuple(int(value) for value in student_latent.shape), + "generation_descriptor": { + "version": int(generation_descriptor["version"]), + "num_generated_blocks": int( + generation_descriptor["num_generated_blocks"]), + "noise": saved_generation_noise, + **({ + "trajectory_seeds": generation_descriptor[ + "trajectory_seeds"].detach().clone(), + } if "trajectory_seeds" in generation_descriptor else {}), + }, + "student_latent": student_latent.detach().clone(), + } + if teacher is not None: + descriptor["current_teacher_features"] = teacher + cached_video01 = student_video01.detach() + if self.fresh_video_pixel_cache_device == "cpu": + cached_video01 = cached_video01.to( + device="cpu", dtype=torch.float32, copy=True) + descriptor["cached_student_video01"] = cached_video01 + return features, descriptor + + def _prepare_multi_encoder_latent_gradients( + self, + *, + replay_descriptor: dict, + expected_features: Mapping[str, torch.Tensor], + feature_cotangent: Mapping[str, torch.Tensor], + replay_atol: float, + replay_rtol: float, + ) -> dict: + required = { + "rng_state", + "latent_shape", + "generation_descriptor", + "student_latent", + "cached_student_video01", + } + if not isinstance(replay_descriptor, dict) or set(replay_descriptor) != required: + raise ValueError("invalid cached triple-video replay descriptor") + student_latent = replay_descriptor["student_latent"] + cached = replay_descriptor.pop("cached_student_video01") + student_video01 = cached.to( + device=student_latent.device, + dtype=torch.float32, + non_blocking=bool(cached.is_pinned()), + ) + batch_size = int(student_latent.shape[0]) + # The finalized global GradCache is intentionally offloaded before + # encoder/VAE replay. Stage only this sample's cached features and + # cotangents back to the active rank; the strict validator below still + # enforces shape, dtype, finiteness, and the destination device. + def _stage_replay_mapping( + mapping: Mapping[str, torch.Tensor], + ) -> Mapping[str, torch.Tensor]: + if not isinstance(mapping, Mapping): + return mapping + return { + name: ( + value.to( + device=student_latent.device, + non_blocking=bool(value.is_pinned()), + ) + if isinstance(value, torch.Tensor) + else value + ) + for name, value in mapping.items() + } + + expected_features = _stage_replay_mapping(expected_features) + feature_cotangent = _stage_replay_mapping(feature_cotangent) + expected = self._validate_dual_feature_mapping( + expected_features, + batch_size=batch_size, + device=student_latent.device, + label="cached triple student features", + ) + cotangent = self._validate_dual_feature_mapping( + feature_cotangent, + batch_size=batch_size, + device=student_latent.device, + label="triple feature cotangent", + ) + tokens = self._dual_token_indices(student_latent.device) + temporal_indices = torch.arange( + 17, 81, device=student_latent.device, dtype=torch.long + ).unsqueeze(0).expand(batch_size, -1) + + combined_pixel = None + pixel_shape = None + pixel_norms = {} + replay_maxima = {} + if "dinov3" in self.dual_branch_registry: + with torch.enable_grad(): + pixel_dino, dino_max_abs = self._replay_dinov3_dense_to_pixels( + student_video01, + expected_features=expected["dinov3"], + feature_cotangent=cotangent["dinov3"], + replay_atol=replay_atol, + replay_rtol=replay_rtol, + ) + combined_pixel, pixel_shape, pixel_norms["dinov3"] = ( + _accumulate_fp32_pixel_cotangent( + combined_pixel, + pixel_dino, + pixel_shape, + branch_name="dinov3", + ) + ) + replay_maxima["dinov3"] = dino_max_abs + del pixel_dino + + with torch.enable_grad(): + replay_vj, pixel_vj = self._replay_dense_encoder_vjp( + student_video01, + tokens["vjepa2"], + expected["vjepa2"], + cotangent["vjepa2"], + gradient_checkpointing=( + self.vjepa2_dense_gradient_checkpointing), + temporal_window_indices=temporal_indices, + ) + if not torch.allclose( + replay_vj, expected["vjepa2"], + atol=float(replay_atol), rtol=float(replay_rtol), + ): + raise RuntimeError("triple V-JEPA2 replay changed cached features") + combined_pixel, pixel_shape, pixel_norms["vjepa2"] = ( + _accumulate_fp32_pixel_cotangent( + combined_pixel, + pixel_vj, + pixel_shape, + branch_name="vjepa2", + ) + ) + replay_maxima["vjepa2"] = ( + replay_vj - expected["vjepa2"]).abs().max().detach() + del replay_vj, pixel_vj + + if "videomae_v2" in self.dual_branch_registry: + with torch.enable_grad(): + replay_vm, pixel_vm = self.videomae_v2_encoder.replay_dense_vjp( + student_video01.detach().float(), + layer_indices=(15,), + token_indices=tokens["videomae_v2"], + expected_features=expected["videomae_v2"], + feature_cotangent=cotangent["videomae_v2"], + gradient_checkpointing=( + self.videomae_v2_dense_gradient_checkpointing), + ) + if not torch.allclose( + replay_vm, expected["videomae_v2"], + atol=float(replay_atol), rtol=float(replay_rtol), + ): + raise RuntimeError( + "VideoMAE-v2 replay changed cached features") + combined_pixel, pixel_shape, pixel_norms["videomae_v2"] = ( + _accumulate_fp32_pixel_cotangent( + combined_pixel, + pixel_vm, + pixel_shape, + branch_name="videomae_v2", + ) + ) + replay_maxima["videomae_v2"] = ( + replay_vm - expected["videomae_v2"]).abs().max().detach() + del replay_vm, pixel_vm + del student_video01 + + if set(pixel_norms) != set(self.dual_branch_registry): + raise RuntimeError( + "triple encoder replay did not visit every branch") + if not bool(torch.isfinite(combined_pixel).all().item()): + raise FloatingPointError( + "combined triple pixel cotangent is non-finite") + latent_cotangent = replay_decoder_vjp( + self.vae, + student_latent.detach(), + combined_pixel, + independent_first_frame=self.independent_first_frame, + ).detach().float() + if latent_cotangent.shape != student_latent.shape: + raise ValueError( + "triple Wan VAE VJP returned an invalid latent shape") + if not bool(torch.isfinite(latent_cotangent).all().item()): + raise FloatingPointError( + "triple Wan VAE VJP returned non-finite values") + + prefix = self.dense_log_prefix + feature_norm = torch.sqrt(torch.stack([ + value.norm().float().square() for value in cotangent.values() + ]).sum()) + replay_max_abs = torch.stack([ + value.float() for value in replay_maxima.values() + ]).max() + replay_log = { + f"{prefix}_feature_grad_norm": feature_norm.detach(), + f"{prefix}_video_grad_norm": combined_pixel.norm().detach(), + f"{prefix}_latent_grad_norm": latent_cotangent.norm().detach(), + f"{prefix}_replay_max_abs": replay_max_abs.detach(), + "combined_latent_grad_norm": latent_cotangent.norm().detach(), + "fresh_video_prepared_vae_vjp": latent_cotangent.new_ones(()), + f"{prefix}_encoder_vjp_count": latent_cotangent.new_tensor( + len(pixel_norms), dtype=torch.long), + f"{prefix}_vae_vjp_count": latent_cotangent.new_tensor( + 1, dtype=torch.long), + } + for name in self.dual_branch_registry: + replay_log[f"{name}_dense_feature_grad_norm"] = ( + cotangent[name].norm().detach()) + replay_log[f"{name}_dense_video_grad_norm"] = ( + pixel_norms[name]) + replay_log[f"{name}_dense_replay_max_abs"] = ( + replay_maxima[name].detach()) + return { + "latent_cotangent": latent_cotangent.detach().to( + device="cpu", + dtype=torch.float32, + non_blocking=False, + ).contiguous(), + "replay_log": replay_log, + } + + @torch.no_grad() + def collect_rollout_features( + self, + *, + image_or_video_shape, + conditional_dict: dict, + unconditional_dict: dict, + initial_latent: Optional[torch.Tensor] = None, + current_teacher_features: Optional[torch.Tensor] = None, + current_teacher_video01: Optional[torch.Tensor] = None, + current_teacher_latent: Optional[torch.Tensor] = None, + require_current_teacher: bool = True, + vjepa2_window_sample_keys=None, + ) -> Tuple[torch.Tensor, dict]: + """First GradCache pass: generate and encode one fresh microbatch.""" + if not self.uses_fresh_video_gradcache: + raise RuntimeError( + "collect_rollout_features requires " + "wan_mmd_estimator=nystrom_exact_repulsion") + if self.dual_video_mmd_enabled: + return self._collect_multi_encoder_rollout_features( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + initial_latent=initial_latent, + current_teacher_features=current_teacher_features, + current_teacher_video01=current_teacher_video01, + current_teacher_latent=current_teacher_latent, + require_current_teacher=require_current_teacher, + ) + if type(require_current_teacher) is not bool: + raise TypeError("require_current_teacher must be a bool") + supplied_teacher_inputs = [ + name for name, value in { + "current_teacher_features": current_teacher_features, + "current_teacher_video01": current_teacher_video01, + "current_teacher_latent": current_teacher_latent, + }.items() if value is not None + ] + if not require_current_teacher and supplied_teacher_inputs: + raise ValueError( + "require_current_teacher=false rejects all teacher inputs; " + f"got {supplied_teacher_inputs}") + first_pass_error = None + try: + generation_descriptor = self._sample_generator_input_descriptor( + image_or_video_shape, + initial_latent=initial_latent, + ) + saved_generation_noise = ( + generation_descriptor["noise"].detach().clone()) + # Initial Gaussian noise and the block count are explicit. The + # saved state begins *after* sampling them and drives any + # stochastic denoise/renoise choices inside the rollout pipeline. + rng_state = self._capture_generation_rng() + student_latent, _ = self._generate_student_latent( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + initial_latent=initial_latent, + generation_descriptor=generation_descriptor, + ) + matched_teacher_features = None + teacher_extract_seconds = None + student_video01 = self._decode_video01(student_latent) + token_indices = None + temporal_window_indices = None + if self._uses_dinov3_dense_backend(): + if vjepa2_window_sample_keys is not None: + raise ValueError( + "DINOv3 fixed-frame sampling rejects V-JEPA2 keys") + features = self._extract_dinov3_dense( + student_video01).detach().float() + if require_current_teacher: + if ( + current_teacher_video01 is not None + or current_teacher_latent is not None + or not isinstance(current_teacher_features, torch.Tensor) + ): + raise ValueError( + "DINOv3 current teacher must be detached features") + matched_teacher_features = ( + current_teacher_features.detach().float()) + if tuple(matched_teacher_features.shape) != tuple(features.shape): + raise ValueError( + "DINOv3 teacher/student feature shapes differ") + else: + token_indices = self._vjepa2_token_indices( + student_latent.device) + temporal_window_indices = ( + self._vjepa2_temporal_window_indices( + vjepa2_window_sample_keys, + batch_size=int(student_latent.shape[0]), + device=student_latent.device, + ) + ) + extract_kwargs = {"gradient_checkpointing": False} + if temporal_window_indices is not None: + extract_kwargs["temporal_window_indices"] = ( + temporal_window_indices) + # A live-video cache miss supplies an already selected 64-frame + # clip. A latent or complete 81-frame video uses the student's + # explicit temporal indices. + teacher_extract_kwargs = dict(extract_kwargs) + if ( + current_teacher_video01 is not None + and temporal_window_indices is not None + and int(current_teacher_video01.shape[1]) + == int(self.vjepa2_dense_num_frames) + ): + teacher_extract_kwargs.pop("temporal_window_indices", None) + teacher_requires_extraction = bool( + require_current_teacher + and ( + current_teacher_video01 is not None + or current_teacher_latent is not None + ) + ) + if teacher_requires_extraction and student_latent.is_cuda: + torch.cuda.synchronize(student_latent.device) + teacher_extract_started = time.perf_counter() + matched_teacher_features = ( + self._current_teacher_features_for_fresh_video( + student_latent=student_latent, + token_indices=token_indices, + extract_kwargs=teacher_extract_kwargs, + current_teacher_features=current_teacher_features, + current_teacher_video01=current_teacher_video01, + current_teacher_latent=current_teacher_latent, + ) + if require_current_teacher + else None + ) + if teacher_requires_extraction and student_latent.is_cuda: + torch.cuda.synchronize(student_latent.device) + teacher_extract_seconds = ( + time.perf_counter() - teacher_extract_started + if teacher_requires_extraction else None + ) + # The trainer transfers ownership with dict.pop(). Drop the three + # input aliases now so an uncached live video can be reclaimed + # before student VAE decode/V-JEPA2 extraction begins. + current_teacher_features = None + current_teacher_video01 = None + current_teacher_latent = None + + student_video01 = self._decode_video01(student_latent) + features = self._extract_vjepa2_dense( + student_video01, + token_indices, + **extract_kwargs, + ).detach().float() + if ( + matched_teacher_features is not None + and tuple(matched_teacher_features.shape) + != tuple(features.shape) + ): + raise ValueError( + "matched teacher/student V-JEPA2 feature shapes must " + "match exactly: " + f"{tuple(matched_teacher_features.shape)} != " + f"{tuple(features.shape)}") + except BaseException as exc: + first_pass_error = exc + if not self._all_ranks_true( + first_pass_error is None, self.device + ): + message = ( + f": {type(first_pass_error).__name__}: {first_pass_error}" + if first_pass_error is not None + else " on another rank" + ) + raise RuntimeError( + "fresh video generation/VAE/dense-encoder first pass failed" + + message + ) from first_pass_error + local_features_finite = bool(torch.isfinite(features).all().item()) + if not self._all_ranks_true( + local_features_finite, features.device + ): + raise FloatingPointError( + "non-finite dense feature in fresh GradCache first pass " + "on at least one rank") + if matched_teacher_features is not None: + local_teacher_features_finite = bool( + torch.isfinite(matched_teacher_features).all().item()) + if not self._all_ranks_true( + local_teacher_features_finite, + matched_teacher_features.device, + ): + raise FloatingPointError( + "non-finite matched teacher feature in fresh GradCache " + "first pass on at least one rank") + descriptor = { + "rng_state": rng_state, + "latent_shape": tuple(int(v) for v in student_latent.shape), + "generation_descriptor": { + "version": int(generation_descriptor["version"]), + "num_generated_blocks": int( + generation_descriptor["num_generated_blocks"]), + "noise": saved_generation_noise, + **({ + "trajectory_seeds": generation_descriptor[ + "trajectory_seeds"].detach().clone(), + } if "trajectory_seeds" in generation_descriptor else {}), + }, + "student_latent": student_latent.detach().clone(), + } + if matched_teacher_features is not None: + # The trainer moves this detached value into the update-local + # GradCache immediately; teacher latents/pixels are never replayed. + descriptor["current_teacher_features"] = ( + matched_teacher_features) + if teacher_extract_seconds is not None: + descriptor["current_teacher_extract_seconds"] = float( + teacher_extract_seconds) + if temporal_window_indices is not None: + descriptor["vjepa2_frame_indices"] = ( + temporal_window_indices.detach().clone()) + if self._uses_dinov3_dense_backend(): + descriptor["dinov3_frame_indices"] = torch.tensor( + self.dinov3_dense_frame_indices, + device=student_latent.device, + dtype=torch.long, + ) + if self.fresh_video_prepared_replay_enabled: + cache_error = None + try: + # Keep the exact first-pass FP32 pixels until update-wide + # feature cotangents are available. They are consumed before + # any differentiable generator replay, so this storage never + # overlaps the generator graph peak. + cached_video01 = student_video01.detach() + if self.fresh_video_pixel_cache_device == "cpu": + cached_video01 = cached_video01.to( + device="cpu", dtype=torch.float32, copy=True) + descriptor["cached_student_video01"] = cached_video01 + except BaseException as exc: + cache_error = exc + if not self._all_ranks_true( + cache_error is None, student_latent.device + ): + message = ( + f": {type(cache_error).__name__}: {cache_error}" + if cache_error is not None + else " on another rank" + ) + raise RuntimeError( + "fresh-video pixel caching failed" + message + ) from cache_error + return features, descriptor + + def prepare_latent_gradients( + self, + *, + replay_descriptor: dict, + expected_features: torch.Tensor, + feature_cotangent: torch.Tensor, + replay_atol: float = 1e-5, + replay_rtol: float = 1e-4, + ) -> dict: + """Replay frozen encoders and prepare a detached latent cotangent.""" + if self.dual_video_mmd_enabled: + return self._prepare_multi_encoder_latent_gradients( + replay_descriptor=replay_descriptor, + expected_features=expected_features, + feature_cotangent=feature_cotangent, + replay_atol=replay_atol, + replay_rtol=replay_rtol, + ) + if ( + not self.fresh_video_prepared_replay_enabled + or not self.uses_fresh_video_gradcache + ): + raise RuntimeError( + "prepared replay is unavailable for this fresh-video path") + prepare_error = None + prepared = None + try: + required_descriptor_keys = { + "rng_state", + "latent_shape", + "generation_descriptor", + "student_latent", + "cached_student_video01", + } + if getattr( + self, "vjepa2_student_frame_sampling", + getattr(self, "vjepa2_dense_frame_sampling", "center_contiguous"), + ) != "center_contiguous": + required_descriptor_keys.add("vjepa2_frame_indices") + if self._uses_dinov3_dense_backend(): + required_descriptor_keys.add("dinov3_frame_indices") + if ( + not isinstance(replay_descriptor, dict) + or set(replay_descriptor) != required_descriptor_keys + ): + raise ValueError( + "invalid cached fresh-video replay descriptor") + + cached_student_video01 = replay_descriptor.pop( + "cached_student_video01") + student_latent = replay_descriptor["student_latent"] + if ( + not isinstance(student_latent, torch.Tensor) + or tuple(student_latent.shape) + != tuple(replay_descriptor["latent_shape"]) + ): + raise ValueError( + "cached first-pass latent has an invalid shape") + if not isinstance(cached_student_video01, torch.Tensor): + raise TypeError("cached first-pass pixels must be a tensor") + student_video01 = cached_student_video01.to( + device=student_latent.device, + dtype=torch.float32, + non_blocking=bool(cached_student_video01.is_pinned()), + ) + del cached_student_video01 + if ( + student_video01.ndim != 5 + or student_video01.shape[0] != student_latent.shape[0] + or student_video01.shape[2] != 3 + ): + raise ValueError( + "cached first-pass pixels have an invalid shape") + if not bool(torch.isfinite(student_video01).all().item()): + raise FloatingPointError( + "cached first-pass pixels are non-finite") + + temporal_window_indices = None + token_indices = None + if self._uses_dinov3_dense_backend(): + self._dinov3_descriptor_frame_indices( + replay_descriptor, device=student_latent.device) + else: + temporal_window_indices = ( + self._vjepa2_descriptor_window_indices( + replay_descriptor, device=student_latent.device) + ) + token_indices = self._vjepa2_token_indices( + student_latent.device) + + expected_features = expected_features.to( + device=student_latent.device, dtype=torch.float32) + feature_cotangent = feature_cotangent.to( + device=student_latent.device, dtype=torch.float32) + if feature_cotangent.shape != expected_features.shape: + raise ValueError( + "feature cotangent shape does not match cached features: " + f"{tuple(feature_cotangent.shape)} != " + f"{tuple(expected_features.shape)}") + if not bool(torch.isfinite(feature_cotangent).all().item()): + raise FloatingPointError( + "non-finite fresh dense feature cotangent") + layer_feature_grad_norms = {} + if getattr(self, "vjepa2_multilayer_enabled", False): + expected_transport_tokens = ( + len(self.vjepa2_dense_layers) + * self.vjepa2_dense_num_tokens + ) + if int(feature_cotangent.shape[1]) != expected_transport_tokens: + raise ValueError( + "multi-layer feature cotangent has an invalid " + "transport token count") + layer_feature_grad_norms = { + f"vjepa2_dense_l{layer}_feature_grad_norm": ( + part.norm().detach()) + for layer, part in zip( + self.vjepa2_dense_layers, + feature_cotangent.split( + self.vjepa2_dense_num_tokens, dim=1), + ) + } + + if self._uses_dinov3_dense_backend(): + video_cotangent, replay_max_abs = ( + self._replay_dinov3_dense_to_pixels( + student_video01, + expected_features=expected_features, + feature_cotangent=feature_cotangent, + replay_atol=float(replay_atol), + replay_rtol=float(replay_rtol), + ) + ) + else: + with torch.enable_grad(): + student_video_leaf = ( + student_video01.detach().requires_grad_(True)) + replay_extract_kwargs = { + "gradient_checkpointing": ( + self.vjepa2_dense_gradient_checkpointing), + } + if temporal_window_indices is not None: + replay_extract_kwargs["temporal_window_indices"] = ( + temporal_window_indices) + replay_features = self._extract_vjepa2_dense( + student_video_leaf, + token_indices, + **replay_extract_kwargs, + ).float() + if replay_features.shape != expected_features.shape: + raise RuntimeError( + "fresh feature replay changed matrix shape: " + f"{tuple(replay_features.shape)} != " + f"{tuple(expected_features.shape)}") + replay_max_abs = ( + replay_features.detach() + - expected_features).abs().max() + if not bool(torch.allclose( + replay_features.detach(), + expected_features, + atol=float(replay_atol), + rtol=float(replay_rtol), + )): + raise RuntimeError( + "cached pixels changed V-JEPA2 features " + f"(max_abs={float(replay_max_abs):.6g})") + encoder_surrogate = ( + replay_features * feature_cotangent).sum() + (video_cotangent,) = torch.autograd.grad( + encoder_surrogate, + student_video_leaf, + retain_graph=False, + create_graph=False, + ) + del encoder_surrogate, student_video_leaf, replay_features + video_cotangent = video_cotangent.detach().float() + if not bool(torch.isfinite(video_cotangent).all().item()): + raise FloatingPointError( + "non-finite fresh dense-encoder pixel cotangent") + video_grad_norm = video_cotangent.norm().detach() + del student_video01 + + latent_cotangent = replay_decoder_vjp( + self.vae, + student_latent.detach(), + video_cotangent, + independent_first_frame=self.independent_first_frame, + ).detach().float() + del video_cotangent + if latent_cotangent.shape != student_latent.shape: + raise ValueError( + "Wan VAE VJP returned an invalid latent shape") + if not bool(torch.isfinite(latent_cotangent).all().item()): + raise FloatingPointError( + "Wan VAE VJP returned a non-finite cotangent") + + replay_log = { + self._dense_key("feature_grad_norm"): ( + feature_cotangent.norm().detach()), + self._dense_key("video_grad_norm"): video_grad_norm, + self._dense_key("latent_grad_norm"): ( + latent_cotangent.norm().detach()), + "combined_latent_grad_norm": ( + latent_cotangent.norm().detach()), + self._dense_key("replay_max_abs"): replay_max_abs.detach(), + "fresh_video_prepared_vae_vjp": ( + latent_cotangent.new_ones(())), + } + replay_log.update(layer_feature_grad_norms) + prepared = { + "latent_cotangent": latent_cotangent.detach().to( + device="cpu", + dtype=torch.float32, + non_blocking=False, + ).contiguous(), + "replay_log": replay_log, + } + except BaseException as exc: + prepare_error = exc + prepare_ok = self._all_ranks_true( + prepare_error is None, self.device) + if not prepare_ok: + all_errors_are_nonfinite = self._all_ranks_true( + prepare_error is None + or isinstance(prepare_error, FloatingPointError), + self.device, + ) + message = ( + f": {type(prepare_error).__name__}: {prepare_error}" + if prepare_error is not None + else " on another rank" + ) + if all_errors_are_nonfinite: + raise FloatingPointError( + "fresh-video prepared replay produced a non-finite value" + + message + ) from prepare_error + raise RuntimeError( + "fresh-video replay preparation failed" + message + ) from prepare_error + return prepared + + + def replay_generator_gradients( + self, + *, + replay_descriptor: dict, + expected_features: torch.Tensor, + feature_cotangent: torch.Tensor, + image_or_video_shape, + conditional_dict: dict, + unconditional_dict: dict, + initial_latent: Optional[torch.Tensor] = None, + prepared_replay: Optional[dict] = None, + replay_atol: float = 1e-5, + replay_rtol: float = 1e-4, + ) -> Tuple[torch.Tensor, dict]: + """Second GradCache pass: replay one video microbatch and encoder/VAE VJP.""" + if not self.uses_fresh_video_gradcache: + raise RuntimeError( + "replay_generator_gradients requires " + "wan_mmd_estimator=nystrom_exact_repulsion") + if self.dual_video_mmd_enabled and prepared_replay is None: + raise RuntimeError("dual video MMD requires prepared replay") + required_descriptor_keys = { + "rng_state", + "latent_shape", + "generation_descriptor", + "student_latent", + } + # Prepared multi-encoder replay has already consumed encoder-specific + # frame fields. Generator replay retains only the common identity keys. + if not self.dual_video_mmd_enabled: + if getattr( + self, "vjepa2_student_frame_sampling", + getattr(self, "vjepa2_dense_frame_sampling", "center_contiguous"), + ) != "center_contiguous": + required_descriptor_keys.add("vjepa2_frame_indices") + if self._uses_dinov3_dense_backend(): + required_descriptor_keys.add("dinov3_frame_indices") + if ( + not isinstance(replay_descriptor, dict) + or set(replay_descriptor) != required_descriptor_keys + ): + raise ValueError("invalid fresh-video replay descriptor") + generator_replay_error = None + try: + with self._replay_generation_rng(replay_descriptor["rng_state"]): + activation_context = ( + torch.autograd.graph.save_on_cpu(pin_memory=True) + if getattr( + self, "generator_replay_activation_cpu_offload", False) + else nullcontext() + ) + with activation_context: + student_latent, gradient_mask = self._generate_student_latent( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + initial_latent=initial_latent, + generation_descriptor=replay_descriptor[ + "generation_descriptor"], + ) + except BaseException as exc: + generator_replay_error = exc + if not self._all_ranks_true( + generator_replay_error is None, self.device + ): + message = ( + f": {type(generator_replay_error).__name__}: " + f"{generator_replay_error}" + if generator_replay_error is not None + else " on another rank" + ) + raise RuntimeError( + "fresh generator replay failed" + message + ) from generator_replay_error + expected_latent_shape = tuple(replay_descriptor["latent_shape"]) + local_latent_shape_ok = ( + tuple(student_latent.shape) == expected_latent_shape) + if not self._all_ranks_true( + local_latent_shape_ok, student_latent.device + ): + raise RuntimeError( + "fresh generator replay changed latent shape on at least one " + f"rank; local={tuple(student_latent.shape)}, " + f"expected={expected_latent_shape}") + expected_student_latent = replay_descriptor["student_latent"].to( + device=student_latent.device, dtype=student_latent.dtype) + latent_replay_max_abs = ( + student_latent.detach() - expected_student_latent + ).abs().max() + local_latent_replay_matches = bool(torch.allclose( + student_latent.detach(), + expected_student_latent, + atol=float(replay_atol), + rtol=float(replay_rtol), + )) + if not self._all_ranks_true( + local_latent_replay_matches, student_latent.device + ): + raise RuntimeError( + "fresh generator replay changed the generated latent on at " + "least one rank " + f"(local max_abs={float(latent_replay_max_abs):.6g})") + student_latent = self._apply_gradient_mask( + student_latent, gradient_mask) + + if prepared_replay is not None: + # The detached decoder VJP was evaluated at the exact first-pass + # latent. Unlike the legacy value-side replay, an allclose-only + # match is insufficient because J_D(z) changes with z. + local_prepared_latent_exact = bool(torch.equal( + student_latent.detach(), expected_student_latent)) + if not self._all_ranks_true( + local_prepared_latent_exact, student_latent.device + ): + raise RuntimeError( + "prepared fresh-video replay requires bitwise-identical generator " + "latents on every rank " + f"(local max_abs={float(latent_replay_max_abs):.6g})") + + prepared_error = None + latent_cotangent = None + replay_log = None + try: + prepared_enabled = self.fresh_video_prepared_replay_enabled + if not prepared_enabled: + raise RuntimeError( + "prepared replay was supplied while P0 is disabled") + if ( + not isinstance(prepared_replay, dict) + or set(prepared_replay) + != {"latent_cotangent", "replay_log"} + or not isinstance(prepared_replay.get("replay_log"), dict) + ): + raise ValueError( + "invalid prepared fresh-video replay payload") + latent_cotangent = prepared_replay["latent_cotangent"] + if ( + not isinstance(latent_cotangent, torch.Tensor) + or latent_cotangent.requires_grad + or latent_cotangent.grad_fn is not None + or latent_cotangent.shape != student_latent.shape + ): + raise ValueError( + "prepared fresh-video latent cotangent has invalid structure") + latent_cotangent = latent_cotangent.to( + device=student_latent.device, dtype=torch.float32) + if not bool(torch.isfinite( + latent_cotangent).all().item() + ): + raise FloatingPointError( + "prepared fresh-video latent cotangent is non-finite") + replay_log = dict(prepared_replay["replay_log"]) + except BaseException as exc: + prepared_error = exc + prepared_ok = self._all_ranks_true( + prepared_error is None, student_latent.device) + if not prepared_ok: + all_errors_are_nonfinite = self._all_ranks_true( + prepared_error is None + or isinstance(prepared_error, FloatingPointError), + student_latent.device, + ) + message = ( + f": {type(prepared_error).__name__}: {prepared_error}" + if prepared_error is not None + else " on another rank" + ) + if all_errors_are_nonfinite: + raise FloatingPointError( + "prepared fresh-video replay produced a non-finite value" + + message + ) from prepared_error + raise RuntimeError( + "prepared fresh-video replay validation failed" + message + ) from prepared_error + replay_surrogate = ( + student_latent.float() * latent_cotangent).sum() + loss = replay_surrogate - replay_surrogate.detach() + dense_prefix = self._dense_metric_prefix() + replay_log.update({ + self._dense_key("latent_replay_max_abs"): ( + latent_replay_max_abs.detach()), + "fresh_video_prepared_replay": ( + latent_cotangent.new_ones(())), + }) + return loss, replay_log + + token_indices = None + temporal_window_indices = None + if not self._uses_dinov3_dense_backend(): + token_indices = self._vjepa2_token_indices( + student_latent.device) + temporal_window_indices = ( + self._vjepa2_descriptor_window_indices( + replay_descriptor, device=student_latent.device) + ) + + # Recreate the value-side VAE/V-JEPA2 path and prove that the replayed + # generator sample is exactly the sample scored in the first pass. + with torch.no_grad(): + student_video01 = self._decode_video01(student_latent) + expected_features = expected_features.to( + device=student_latent.device, dtype=torch.float32) + + feature_cotangent = feature_cotangent.to( + device=student_latent.device, dtype=torch.float32) + local_cotangent_shape_ok = ( + feature_cotangent.shape == expected_features.shape) + if not self._all_ranks_true( + local_cotangent_shape_ok, student_latent.device + ): + raise ValueError( + "feature cotangent shape does not match the cached video " + "matrix on at least one rank: " + f"{tuple(feature_cotangent.shape)} != " + f"{tuple(expected_features.shape)}") + local_cotangent_finite = bool( + torch.isfinite(feature_cotangent).all().item()) + if not self._all_ranks_true( + local_cotangent_finite, student_latent.device + ): + raise FloatingPointError( + "non-finite fresh dense feature cotangent on at least one " + "rank") + + # Frozen encoder VJP to decoded pixels. No rank enters a subsequent + # collective until every locally fallible encoder/autograd operation + # has either completed or been captured, preventing a one-rank replay + # exception from stranding peers in an all-reduce. + encoder_replay_error = None + try: + if self._uses_dinov3_dense_backend(): + self._dinov3_descriptor_frame_indices( + replay_descriptor, device=student_latent.device) + video_cotangent, replay_max_abs = ( + self._replay_dinov3_dense_to_pixels( + student_video01, + expected_features=expected_features, + feature_cotangent=feature_cotangent, + replay_atol=float(replay_atol), + replay_rtol=float(replay_rtol), + ) + ) + else: + with torch.enable_grad(): + student_video_leaf = ( + student_video01.detach().float().requires_grad_(True)) + replay_extract_kwargs = { + "gradient_checkpointing": ( + self.vjepa2_dense_gradient_checkpointing), + } + if temporal_window_indices is not None: + replay_extract_kwargs["temporal_window_indices"] = ( + temporal_window_indices) + replay_features = self._extract_vjepa2_dense( + student_video_leaf, + token_indices, + **replay_extract_kwargs, + ).float() + if replay_features.shape != expected_features.shape: + raise RuntimeError( + "fresh feature replay changed matrix shape: " + f"{tuple(replay_features.shape)} != " + f"{tuple(expected_features.shape)}") + replay_max_abs = ( + replay_features.detach() + - expected_features).abs().max() + if not bool(torch.allclose( + replay_features.detach(), + expected_features, + atol=float(replay_atol), + rtol=float(replay_rtol), + )): + raise RuntimeError( + "fresh generator replay is not deterministic in " + "V-JEPA2 space " + f"(max_abs={float(replay_max_abs):.6g})") + encoder_surrogate = ( + replay_features * feature_cotangent).sum() + (video_cotangent,) = torch.autograd.grad( + encoder_surrogate, + student_video_leaf, + retain_graph=False, + create_graph=False, + ) + del encoder_surrogate, student_video_leaf, replay_features + except BaseException as exc: + encoder_replay_error = exc + if not self._all_ranks_true( + encoder_replay_error is None, student_latent.device + ): + message = ( + f": {type(encoder_replay_error).__name__}: " + f"{encoder_replay_error}" + if encoder_replay_error is not None + else " on another rank" + ) + raise RuntimeError( + "frozen dense encoder replay failed" + message + ) from encoder_replay_error + video_cotangent = video_cotangent.detach().float() + local_video_finite = bool( + torch.isfinite(video_cotangent).all().item()) + if not self._all_ranks_true( + local_video_finite, video_cotangent.device + ): + raise FloatingPointError( + "non-finite fresh dense-encoder pixel cotangent on at least one " + "rank") + mmd_video_grad_norm = video_cotangent.norm().detach() + # ``encoder_surrogate`` is the autograd root and therefore keeps the + # leaf (and its full decoded-video storage) alive even after + # ``autograd.grad`` has consumed the graph. Drop the already-consumed + # root together with the leaf so ``student_video01 = None`` below can + # actually release that storage before the frame VAE VJP. No tensor + # operation or derivative changes: all three values are dead here. + # Frozen causal Wan VAE VJP to the generated latent, with the same + # rank-consensus exception boundary as the encoder replay above. + vae_replay_error = None + try: + mmd_latent_cotangent = replay_decoder_vjp( + self.vae, + student_latent.detach(), + video_cotangent, + independent_first_frame=self.independent_first_frame, + ).detach().float() + if mmd_latent_cotangent.shape != student_latent.shape: + raise ValueError( + "Wan VAE decoder VJP returned an invalid latent shape: " + f"{tuple(mmd_latent_cotangent.shape)} != " + f"{tuple(student_latent.shape)}") + except BaseException as exc: + vae_replay_error = exc + if not self._all_ranks_true( + vae_replay_error is None, student_latent.device + ): + message = ( + f": {type(vae_replay_error).__name__}: {vae_replay_error}" + if vae_replay_error is not None + else " on another rank" + ) + raise RuntimeError( + "frozen Wan VAE replay failed" + message + ) from vae_replay_error + local_latent_finite = bool( + torch.isfinite(mmd_latent_cotangent).all().item()) + if not self._all_ranks_true( + local_latent_finite, mmd_latent_cotangent.device + ): + raise FloatingPointError( + "non-finite fresh dense-feature latent cotangent on at least one " + "rank") + del video_cotangent + + del student_video01 + latent_cotangent = mmd_latent_cotangent + + # The global scalar value is logged from FreshVideoGradCache. This + # zero-valued surrogate carries only its exact replayed derivative. + replay_surrogate = ( + student_latent.float() * latent_cotangent).sum() + loss = replay_surrogate - replay_surrogate.detach() + dense_prefix = self._dense_metric_prefix() + return loss, { + f"{dense_prefix}_feature_grad_norm": ( + feature_cotangent.norm().detach()), + self._dense_key("video_grad_norm"): ( + mmd_video_grad_norm), + self._dense_key("latent_grad_norm"): ( + mmd_latent_cotangent.norm().detach()), + "combined_latent_grad_norm": ( + latent_cotangent.norm().detach()), + self._dense_key("replay_max_abs"): replay_max_abs.detach(), + self._dense_key("latent_replay_max_abs"): ( + latent_replay_max_abs.detach()), + } + + def compute_latent_mmd_loss( + self, + student_latent: torch.Tensor, + teacher_latent: torch.Tensor, + conditional_dict: dict, + gradient_mask: Optional[torch.Tensor] = None, + ) -> Tuple[torch.Tensor, dict]: + if self.uses_fresh_video_gradcache: + raise RuntimeError( + "nystrom_exact_repulsion must be evaluated through the " + "trainer's full fresh-video GradCache batch") + student_latent = self._apply_gradient_mask(student_latent, gradient_mask) + teacher_latent = teacher_latent.to( + device=student_latent.device, dtype=student_latent.dtype) + if teacher_latent.shape != student_latent.shape: + raise ValueError( + f"teacher latent {tuple(teacher_latent.shape)} != " + f"student latent {tuple(student_latent.shape)}") + + if self.feature_type == "post_block_latent": + return self._compute_wan_latent_mmd_loss( + student_latent, teacher_latent, conditional_dict) + return self._compute_vjepa2_dense_mmd_loss( + student_latent, teacher_latent) + + def _compute_wan_latent_mmd_loss( + self, + student_latent: torch.Tensor, + teacher_latent: torch.Tensor, + conditional_dict: dict, + ) -> Tuple[torch.Tensor, dict]: + indices = self._token_indices(student_latent) + # Teacher first: its no-grad temporaries are released before the student + # checkpoint graph is constructed. + with torch.no_grad(): + teacher_features = self._extract( + teacher_latent, conditional_dict, indices) + student_features = self._extract( + student_latent, conditional_dict, indices) + mmd, mmd_meta = self.latent_mmd( + student_features, teacher_features, stage=True) + normalized_mmd = ( + mmd / (mmd.detach() + self.latent_norm_eps) + if self.latent_self_normalize else mmd) + loss = self.latent_loss_weight * normalized_mmd + return loss, { + "dmdtrain_gradient_norm": loss.new_zeros(()), + "wan_latent_mmd": mmd.detach(), + "wan_latent_loss_normalized": normalized_mmd.detach(), + "wan_latent_bandwidth": mmd_meta["bandwidth"], + "wan_latent_history_updates": mmd_meta["history_updates"], + "wan_latent_current_coefficient": mmd_meta["current_coefficient"], + "wan_latent_tokens": loss.new_tensor(student_features.shape[1]), + "wan_latent_feature_dim": loss.new_tensor( + student_features.shape[2]), + "wan_latent_prefix_blocks": loss.new_tensor( + self.latent_layer + 1), + } + + def _compute_vjepa2_dense_mmd_loss( + self, + student_latent: torch.Tensor, + teacher_latent: torch.Tensor, + ) -> Tuple[torch.Tensor, dict]: + token_indices = self._vjepa2_token_indices(student_latent.device) + + # Stage A: decode and extract both sides without retaining either the + # causal VAE or the V-JEPA2 graph. Teacher is processed first and its + # large decoded video is released before the student decode. + with torch.no_grad(): + teacher_video01 = self._decode_video01(teacher_latent) + teacher_features = self._extract_vjepa2_dense( + teacher_video01, + token_indices, + gradient_checkpointing=False, + ).detach().float() + del teacher_video01 + + student_video01 = self._decode_video01(student_latent) + student_features_detached = self._extract_vjepa2_dense( + student_video01, + token_indices, + gradient_checkpointing=False, + ).detach().float() + + # Stage A.2: calculate the MMD and its cotangent only on a small dense + # feature leaf. EMARBFMMD detaches historical kernel means and teacher + # features, so only this latest student leaf receives a gradient. + with torch.enable_grad(): + student_features_leaf = ( + student_features_detached.detach().requires_grad_(True)) + mmd, mmd_meta = self.latent_mmd( + student_features_leaf, teacher_features, stage=True) + normalized_mmd = ( + mmd / (mmd.detach() + self.latent_norm_eps) + if self.latent_self_normalize else mmd) + objective = self.latent_loss_weight * normalized_mmd + (feature_cotangent,) = torch.autograd.grad( + objective, + student_features_leaf, + retain_graph=False, + create_graph=False, + ) + feature_cotangent = feature_cotangent.detach().float() + if not torch.isfinite(feature_cotangent).all(): + raise FloatingPointError( + "non-finite V-JEPA2 dense-feature MMD cotangent") + + # Stage B.1: replay only the frozen FP32 V-JEPA2 prefix. The adapter + # repeats the exact deterministic center-frame/resize/crop/normalize + # pipeline used above; non-reentrant block checkpointing bounds memory. + with torch.enable_grad(): + student_video_leaf = ( + student_video01.detach().float().requires_grad_(True)) + replay_features = self._extract_vjepa2_dense( + student_video_leaf, + token_indices, + gradient_checkpointing=( + self.vjepa2_dense_gradient_checkpointing), + ).float() + encoder_surrogate = ( + replay_features * feature_cotangent).sum() + (video_cotangent,) = torch.autograd.grad( + encoder_surrogate, + student_video_leaf, + retain_graph=False, + create_graph=False, + ) + video_cotangent = video_cotangent.detach().float() + if not torch.isfinite(video_cotangent).all(): + raise FloatingPointError( + "non-finite V-JEPA2 replay pixel cotangent") + del encoder_surrogate, student_video_leaf, replay_features, student_video01 + + # Stage B.2: replay the frozen stateful Wan VAE decoder one latent frame + # at a time. Its temporal cache is carried by value and detached between + # frames, matching the repository's validated bounded-memory replay. + latent_cotangent = replay_decoder_vjp( + self.vae, + student_latent.detach(), + video_cotangent, + independent_first_frame=self.independent_first_frame, + ).detach().float() + if latent_cotangent.shape != student_latent.shape: + raise ValueError( + "Wan VAE decoder VJP returned an invalid latent shape: " + f"got {tuple(latent_cotangent.shape)}, expected " + f"{tuple(student_latent.shape)}") + if not torch.isfinite(latent_cotangent).all(): + raise FloatingPointError( + "non-finite V-JEPA2 replay latent cotangent") + + # Preserve the true objective as the scalar value reported by the + # trainer while replacing its derivative with the replayed latent VJP. + replay_surrogate = ( + student_latent.float() * latent_cotangent).sum() + loss = replay_surrogate + ( + objective.detach() - replay_surrogate.detach()) + return loss, { + "dmdtrain_gradient_norm": latent_cotangent.norm().detach(), + self._dense_key("mmd"): mmd.detach(), + self._dense_key("loss_normalized"): normalized_mmd.detach(), + self._dense_key("bandwidth"): mmd_meta["bandwidth"], + self._dense_key("history_updates"): mmd_meta["history_updates"], + self._dense_key("current_coefficient"): ( + mmd_meta["current_coefficient"]), + "vjepa2_dense_raw_tokens": loss.new_tensor( + self.vjepa2_dense_raw_tokens), + "vjepa2_dense_sampled_tokens": loss.new_tensor( + student_features_detached.shape[1]), + self._dense_key("feature_dim"): loss.new_tensor( + student_features_detached.shape[2]), + self._dense_key("local_video_samples"): mmd_meta["local_samples"], + self._dense_key("global_video_samples"): mmd_meta["global_samples"], + self._dense_key("num_frames"): loss.new_tensor( + self.vjepa2_dense_num_frames), + self._dense_key("layer"): loss.new_tensor( + self.vjepa2_dense_layer), + self._dense_key("prefix_blocks"): loss.new_tensor( + self.vjepa2_dense_layer + 1), + self._dense_key("feature_grad_norm"): ( + feature_cotangent.norm().detach()), + self._dense_key("video_grad_norm"): ( + video_cotangent.norm().detach()), + self._dense_key("latent_grad_norm"): ( + latent_cotangent.norm().detach()), + } + + def distribution_state_pending(self) -> bool: + latent_mmd = getattr(self, "latent_mmd", None) + return bool( + getattr(self, "uses_fresh_video_gradcache", False) + and ( + getattr(latent_mmd, "attraction_ema_pending", False) + or getattr(latent_mmd, "repulsion_fifo_pending", False) + ) + ) + + def prepare_distribution_state(self): + """Validate staged Nyström state before optimizer mutation.""" + latent_mmd = getattr(self, "latent_mmd", None) + if ( + getattr(self, "uses_fresh_video_gradcache", False) + and ( + getattr(latent_mmd, "attraction_ema_enabled", False) + or getattr(latent_mmd, "repulsion_fifo_enabled", False) + ) + ): + latent_mmd.prepare_commit() + + def commit_distribution_state(self) -> None: + if not self.uses_fresh_video_gradcache: + self.latent_mmd.commit() + return + latent_mmd = getattr(self, "latent_mmd", None) + if ( + getattr(latent_mmd, "attraction_ema_enabled", False) + or getattr(latent_mmd, "repulsion_fifo_enabled", False) + ): + latent_mmd.commit() + + def discard_distribution_state(self) -> None: + if not self.uses_fresh_video_gradcache: + self.latent_mmd.discard() + return + latent_mmd = getattr(self, "latent_mmd", None) + if ( + getattr(latent_mmd, "attraction_ema_enabled", False) + or getattr(latent_mmd, "repulsion_fifo_enabled", False) + ): + latent_mmd.discard() + + def generator_loss( + self, + image_or_video_shape, + conditional_dict: dict, + unconditional_dict: dict, + clean_latent: torch.Tensor, + initial_latent: torch.Tensor = None, + ): + if self.uses_fresh_video_gradcache: + raise RuntimeError( + "nystrom_exact_repulsion generator_loss requires " + "FreshVideoGradCache orchestration in DistributionMatchingTrainer") + if clean_latent is None: + raise ValueError( + "wan_kv_mmd needs aligned teacher latents; configure " + "teacher_latent_data_path") + if self.causal_generator: + pred, gradient_mask, _, _ = self._run_generator( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + initial_latent=initial_latent, + ) + else: + pred = self._run_few_step_generator( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + ) + gradient_mask = None + return self.compute_latent_mmd_loss( + pred, clean_latent, conditional_dict, gradient_mask=gradient_mask) + + def _fd_metadata(self) -> dict: + if self.feature_type == "dinov3_dense_latent": + return self._dinov3_feature_metadata() + if self.feature_type in self._DENSE_VIDEO_FEATURE_TYPES: + if self.dual_video_mmd_enabled: + branches = {} + for name, registry in self.dual_branch_registry.items(): + branches[name] = { + "reference_path": registry["reference_path"], + "reference_hash": registry["reference_hash"], + "sample_shape": list(registry["sample_shape"]), + } + if "vjepa2" in branches: + branches["vjepa2"]["feature"] = ( + self._vjepa2_feature_metadata()) + if "dinov3" in branches: + branches["dinov3"]["feature"] = ( + self._dinov3_feature_metadata()) + if "videomae_v2" in branches: + from video_fd.encoders.videomae_v2_dense_metadata import ( + canonical_videomae_v2_dense_feature_metadata, + uniform_segment_token_indices, + ) + video_indices = uniform_segment_token_indices( + self.videomae_v2_encoder.dense_raw_tokens, 256) + branches["videomae_v2"]["feature"] = ( + canonical_videomae_v2_dense_feature_metadata( + model_id=self.videomae_v2_reference_model_id, + model_id_is_reference=True, + layer_index=15, + num_tokens=256, + token_indices=video_indices, + num_segments=5, + frames_per_segment=16, + raw_tokens_per_segment=( + self.videomae_v2_encoder.dense_raw_tokens), + feature_dim=( + self.videomae_v2_encoder.dense_feature_dim), + resize_short_side=224, + crop_size=224, + skip_first_frame=True, + )) + branch_weights = { + name: float(self.latent_mmd.branch_weights[name]) + for name in sorted(branches) + } + teacher_bank_content_hash = ( + self.triple_teacher_bank_content_hash + if self.feature_type + == "triple_dinov3_vjepa2_videomae_v2_dense_latent" + else self.dual_teacher_bank_content_hash + ) + aggregation_divisor = float( + self.latent_mmd.aggregation_divisor) + combination = { + "operation": "sum", + "branch_weights": branch_weights, + "divide_by_branch_count": ( + aggregation_divisor == float(len(branches))), + "normalization": ( + "raw_div_stop_gradient_raw_plus_epsilon"), + "normalization_epsilon": self.latent_norm_eps, + } + if aggregation_divisor != 1.0: + combination["aggregation_divisor"] = aggregation_divisor + current_enabled = bool( + self.current_teacher_attraction_enabled) + forward_samples = int(self.fresh_video_total_samples) + gradient_samples = int( + self.expected_current_teacher_samples + if current_enabled + else getattr( + self, + "fresh_video_gradient_global_samples", + forward_samples, + ) + ) + teacher_samples = ( + gradient_samples if current_enabled else 0) + result = { + "feature_type": self.feature_type, + "branches": branches, + "teacher_bank_content_hash": teacher_bank_content_hash, + "teacher_mixture": { + "fixed_reference_weight": ( + self.nystrom_attraction_weight), + "current_teacher_weight": ( + self.current_teacher_attraction_weight + if current_enabled else 0.0), + "normalization": ( + "divide_by_weight_sum" + if current_enabled else "fixed_reference_only"), + }, + "combination": combination, + "partial_gradient": { + "forward_global_samples": forward_samples, + "gradient_global_samples": gradient_samples, + "expected_teacher_global_samples": teacher_samples, + "full_student_population": True, + "replay_cotangent_compensation": ( + float(forward_samples) / float(gradient_samples)), + "denominators": { + "kss": forward_samples * forward_samples, + "fixed_attraction": forward_samples, + "teacher_cross": ( + forward_samples * teacher_samples), + "teacher_self": teacher_samples * teacher_samples, + }, + }, + } + if hasattr( + self, "dinov3_vjepa2_reference_bundle_manifest_sha256" + ): + result["reference_bundle"] = { + "path": ( + self.dinov3_vjepa2_reference_bundle_manifest), + "sha256": ( + self.dinov3_vjepa2_reference_bundle_manifest_sha256), + "video_name_sequence_sha256": ( + self.openvid_video_name_sequence_sha256), + "legacy_vjepa_provenance_bridge": True, + } + return result + if getattr(self, "vjepa2_multilayer_enabled", False): + return { + "feature_type": "vjepa2_dense_multilayer_latent", + "features": [ + self._vjepa2_feature_metadata( + layer_index=layer) + for layer in self.vjepa2_dense_layers + ], + "combination": { + "operation": "sum", + "per_layer_objective": ( + "nystrom_exact_repulsion"), + "normalization": ( + "mmd_over_abs_detached_mmd_plus_epsilon" + if self.latent_self_normalize + else "none" + ), + "normalization_epsilon": self.latent_norm_eps, + "layer_weights": [ + 1.0 for _ in self.vjepa2_dense_layers + ], + }, + } + metadata = self._vjepa2_feature_metadata() + reference_sampling = getattr( + self, "vjepa2_dense_frame_sampling", "center_contiguous") + student_sampling = getattr( + self, + "vjepa2_student_frame_sampling", + reference_sampling, + ) + if student_sampling != reference_sampling: + student_start = ( + int(self.vjepa2_student_source_num_frames) + - int(self.vjepa2_dense_num_frames) + ) + metadata = dict(metadata) + metadata["student_temporal_sampling"] = { + "frame_sampling": student_sampling, + "source_num_frames": int( + self.vjepa2_student_source_num_frames), + "selected_frame_indices": [ + student_start, + student_start + int(self.vjepa2_dense_num_frames) - 1, + ], + "reference_frame_sampling": ( + reference_sampling), + } + if getattr(self, "nystrom_attraction_weight", 1.0) != 1.0: + metadata = dict(metadata) + metadata["nystrom_attraction_weight"] = ( + self.nystrom_attraction_weight) + if getattr( + self, "current_teacher_attraction_enabled", False + ): + metadata = dict(metadata) + metadata["nystrom_current_teacher_attraction"] = { + "enabled": True, + "estimator": "exact_biased_gaussian_mmd", + "fixed_weight": self.nystrom_attraction_weight, + "current_weight": ( + self.current_teacher_attraction_weight), + "normalization": "divide_by_group_weight_sum", + "expected_global_samples": ( + self.expected_current_teacher_samples), + "ema_gradient_mode": ( + "straight_through_full_current_mix" + if self.nystrom_attraction_ema_enabled + else "direct_full_current_mix"), + } + if self.current_teacher_source == "matched_manifest_video": + metadata[ + "nystrom_current_teacher_attraction" + ]["source"] = "matched_manifest_video" + # Preserve the tuple used by legacy v2 checkpoints. The new + # canonical reference metadata stores JSON-like lists instead. + if getattr(self, "mmd_estimator", "ema_rff") == "ema_rff": + metadata = dict(metadata) + metadata.pop("feature_metadata_version", None) + metadata.pop("vae_id", None) + metadata.pop("token_indices_sha256", None) + metadata.pop("kernel", None) + metadata["matrix_shape"] = tuple(metadata["matrix_shape"]) + return metadata + return { + "feature_type": self.feature_type, + "layer_index": self.latent_layer, + "prefix_blocks": self.latent_layer + 1, + "num_tokens": self.latent_num_tokens, + "feature_dim": self.latent_feature_dim, + "timestep": self.latent_timestep, + "patch_size": self.latent_patch_size, + } + + def _nystrom_attraction_ema_active(self) -> bool: + return bool( + getattr(self, "uses_fresh_video_gradcache", False) + and getattr( + getattr(self, "latent_mmd", None), + "attraction_ema_enabled", + False, + ) + ) + + def _nystrom_repulsion_fifo_active(self) -> bool: + return bool( + getattr(self, "uses_fresh_video_gradcache", False) + and getattr( + getattr(self, "latent_mmd", None), + "repulsion_fifo_enabled", + False, + ) + ) + + def _nystrom_fd_state_version(self) -> int: + if getattr(self, "is_triple_dense", False): + base_version = 10 + elif self.dual_video_mmd_enabled: + base_version = 9 + elif getattr(self, "vjepa2_multilayer_enabled", False): + base_version = 4 + else: + base_version = 1 + if self._nystrom_repulsion_fifo_active(): + if base_version != 1 or self._nystrom_attraction_ema_active(): + raise ValueError( + "repulsion FIFO requires the single-layer fixed-reference " + "Nyström state without attraction EMA") + return 8 + if not self._nystrom_attraction_ema_active(): + return base_version + enabled_versions = {1: 5, 4: 7} + if base_version not in enabled_versions: + raise ValueError( + "attraction EMA is incompatible with this Nyström state " + f"variant v{base_version}") + return enabled_versions[base_version] + + def _with_nystrom_attraction_ema_state(self, state: dict) -> dict: + if self._nystrom_attraction_ema_active(): + state["attraction_ema"] = ( + self.latent_mmd.attraction_ema_state_dict()) + return state + + def _with_nystrom_distribution_state(self, state: dict) -> dict: + state = self._with_nystrom_attraction_ema_state(state) + if self._nystrom_repulsion_fifo_active(): + state["repulsion_fifo"] = ( + self.latent_mmd.repulsion_fifo_state_dict()) + return state + + def fd_state_dict(self) -> dict: + nystrom_version_key = self._dense_nystrom_version_key() + if getattr(self, "uses_fresh_video_gradcache", False): + if self.distribution_state_pending(): + raise RuntimeError( + "refusing to checkpoint pending distribution state") + if self._uses_dinov3_dense_backend(): + return { + "dinov3_dense_nystrom_mmd_version": 1, + "metadata": self._fd_metadata(), + "reference_path": self.dinov3_nystrom_reference_path, + "reference_hash": self.dinov3_nystrom_reference_hash, + "teacher_feature_bank_hash": ( + self.dinov3_teacher_feature_bank_hash), + "estimator": self.mmd_estimator, + } + if getattr(self, "vjepa2_multilayer_enabled", False): + return self._with_nystrom_distribution_state({ + "vjepa2_dense_nystrom_mmd_version": ( + self._nystrom_fd_state_version()), + "metadata": self._fd_metadata(), + "reference_paths": list( + self.vjepa2_nystrom_reference_paths), + "reference_hashes": list( + self.vjepa2_nystrom_reference_hashes), + "teacher_feature_bank_hashes": list( + self.vjepa2_teacher_feature_bank_hashes), + "estimator": self.mmd_estimator, + }) + if self.dual_video_mmd_enabled: + return { + self.dense_nystrom_version_key: ( + self._nystrom_fd_state_version()), + "metadata": self._fd_metadata(), + "references": { + name: { + "path": branch["reference_path"], + "hash": branch["reference_hash"], + "landmarks": branch["nystrom_landmarks"], + } + for name, branch in self.dual_branch_registry.items() + }, + "teacher_bank_content_hash": ( + self.triple_teacher_bank_content_hash + if self.is_triple_dense + else self.dual_teacher_bank_content_hash), + "estimator": self.mmd_estimator, + } + return self._with_nystrom_distribution_state({ + "vjepa2_dense_nystrom_mmd_version": ( + self._nystrom_fd_state_version()), + "metadata": self._fd_metadata(), + "reference_path": self.vjepa2_nystrom_reference_path, + "reference_hash": self.vjepa2_nystrom_reference_hash, + "teacher_feature_bank_hash": ( + self.vjepa2_teacher_feature_bank_hash), + "estimator": self.mmd_estimator, + }) + if self.feature_type == "vjepa2_dense_latent": + return { + "vjepa2_dense_mmd_version": 2, + "metadata": self._fd_metadata(), + "vjepa2_dense_mmd": self.latent_mmd.state_dict(), + } + return { + "wan_kv_mmd_version": 3, + "metadata": self._fd_metadata(), + "wan_latent_mmd": self.latent_mmd.state_dict(), + } + + def load_fd_state_dict(self, state_dict: dict) -> None: + if self._uses_dinov3_dense_backend(): + if not isinstance(state_dict, dict): + raise TypeError("DINOv3 distribution state must be a mapping") + if any(key in state_dict for key in ( + "wan_kv_mmd_version", + "vjepa2_dense_mmd_version", + "vjepa2_dense_nystrom_mmd_version", + )): + raise ValueError( + "Wan/V-JEPA2 distribution state cannot be loaded into " + "the DINOv3 dense-latent feature space") + version = state_dict.get( + "dinov3_dense_nystrom_mmd_version") + if version != 1: + raise ValueError( + "resume checkpoint has no compatible DINOv3 dense " + f"Nyström state v1 (got {version!r})") + expected = self.fd_state_dict() + if set(state_dict) != set(expected): + raise ValueError( + "DINOv3 Nyström checkpoint/reference keys mismatch: " + f"saved={sorted(state_dict)}, " + f"current={sorted(expected)}") + if state_dict != expected: + raise ValueError( + "DINOv3 Nyström checkpoint/reference identity mismatch: " + f"saved={state_dict}, current={expected}") + return + + dense_version_key = self._dense_ema_version_key() + dense_state_key = self._dense_ema_state_key() + nystrom_version_key = self._dense_nystrom_version_key() + foreign_dense_version_keys = { + "vjepa2_dense_mmd_version", + "motionformer_dense_mmd_version", + "vjepa2_dense_nystrom_mmd_version", + "motionformer_dense_nystrom_mmd_version", + "dual_dense_mmd_version", + "dual_dense_nystrom_mmd_version", + "triple_dense_nystrom_mmd_version", + } - {dense_version_key, nystrom_version_key} + if getattr(self, "uses_fresh_video_gradcache", False): + present_foreign = sorted( + foreign_dense_version_keys.intersection(state_dict)) + if present_foreign: + raise ValueError( + "checkpoint belongs to another dense feature namespace: " + f"{present_foreign}") + if "dinov3_dense_nystrom_mmd_version" in state_dict: + raise ValueError( + "DINOv3 distribution state cannot be loaded into the " + "V-JEPA2 dense-latent feature space") + if ( + "wan_kv_mmd_version" in state_dict + or dense_version_key in state_dict + ): + raise ValueError( + "EMA/RFF distribution history cannot be loaded into the " + "fresh Nyström exact-repulsion estimator") + version = state_dict.get(nystrom_version_key) + expected_version = self._nystrom_fd_state_version() + if version != expected_version: + raise ValueError( + "resume checkpoint has no compatible dense feature " + f"Nyström state v{expected_version} for " + f"{nystrom_version_key} (got {version!r})") + validated_attraction_ema = None + if self._nystrom_attraction_ema_active(): + if "attraction_ema" not in state_dict: + raise ValueError( + "resume checkpoint is missing attraction EMA state") + validated_attraction_ema = ( + self.latent_mmd.validate_attraction_ema_state_dict( + state_dict["attraction_ema"]) + ) + validated_repulsion_fifo = None + if self._nystrom_repulsion_fifo_active(): + if "repulsion_fifo" not in state_dict: + raise ValueError( + "resume checkpoint is missing repulsion FIFO state") + validated_repulsion_fifo = ( + self.latent_mmd.validate_repulsion_fifo_state_dict( + state_dict["repulsion_fifo"]) + ) + expected = self.fd_state_dict() + if set(state_dict) != set(expected): + raise ValueError( + "V-JEPA2 Nyström checkpoint/reference keys mismatch: " + f"saved={sorted(state_dict)}, " + f"current={sorted(expected)}") + identity_keys = set(expected) + identity_keys.discard("attraction_ema") + identity_keys.discard("repulsion_fifo") + saved_identity = { + key: state_dict.get(key) for key in identity_keys} + expected_identity = { + key: expected.get(key) for key in identity_keys} + # Runtime live-teacher batch size is not frozen-reference identity. + saved_identity = copy.deepcopy(saved_identity) + expected_identity = copy.deepcopy(expected_identity) + for identity in (saved_identity, expected_identity): + current_teacher = identity.get("metadata", {}).get( + "nystrom_current_teacher_attraction", {}) + current_teacher.pop("expected_global_samples", None) + saved_identity = _canonicalize_shared_asset_paths(saved_identity) + expected_identity = _canonicalize_shared_asset_paths( + expected_identity) + if saved_identity != expected_identity: + raise ValueError( + "V-JEPA2 Nyström checkpoint/reference mismatch: " + f"saved={saved_identity}, current={expected_identity}") + if validated_attraction_ema is not None: + self.latent_mmd.load_attraction_ema_state_dict( + validated_attraction_ema) + if validated_repulsion_fifo is not None: + self.latent_mmd.load_repulsion_fifo_state_dict( + validated_repulsion_fifo) + return + + if self.feature_type == "vjepa2_dense_latent": + if "dinov3_dense_nystrom_mmd_version" in state_dict: + raise ValueError( + "DINOv3 distribution state cannot be loaded into the " + "V-JEPA2 dense-latent feature space") + if "wan_kv_mmd_version" in state_dict: + raise ValueError( + "Wan post-block latent MMD history cannot be loaded into " + "the V-JEPA2 dense-latent feature space") + version = state_dict.get("vjepa2_dense_mmd_version") + if version != 2: + detail = ( + " V-JEPA2 dense MMD v1 treated individual tokens as " + "distribution samples and is intentionally incompatible." + if version == 1 else "" + ) + raise ValueError( + "resume checkpoint has no compatible V-JEPA2 dense " + f"latent MMD v2 state (got version {version!r})." + f"{detail}") + if "vjepa2_dense_mmd" not in state_dict: + raise ValueError( + "resume checkpoint is missing fd.vjepa2_dense_mmd history") + expected = self._fd_metadata() + saved = state_dict.get("metadata") + if saved != expected: + raise ValueError( + "V-JEPA2 dense latent checkpoint/config mismatch: " + f"saved metadata={saved}, current={expected}") + self.latent_mmd.load_state_dict( + state_dict["vjepa2_dense_mmd"], strict=True) + return + + if "dinov3_dense_nystrom_mmd_version" in state_dict: + raise ValueError( + "DINOv3 distribution state cannot be loaded into the Wan " + "post-block latent feature space") + version = state_dict.get("wan_kv_mmd_version") + if version != 3: + detail = ( + " Wan KV MMD v2 history is intentionally incompatible." + if version == 2 else "") + raise ValueError( + "resume checkpoint has no compatible Wan post-block latent " + f"MMD v3 state.{detail}") + if "wan_latent_mmd" not in state_dict: + raise ValueError( + "resume checkpoint is missing fd.wan_latent_mmd history") + expected = self._fd_metadata() + saved = state_dict.get("metadata") + if saved != expected: + raise ValueError( + "Wan post-block latent checkpoint/config mismatch: " + f"saved metadata={saved}, current={expected}. " + "KV EMA checkpoints are intentionally incompatible.") + self.latent_mmd.load_state_dict( + state_dict["wan_latent_mmd"], strict=True) + + def can_load_initialization_fd_state(self, state_dict: dict) -> bool: + """Whether an initialization-only checkpoint carries our estimator.""" + if not isinstance(state_dict, dict): + return False + if self._uses_dinov3_dense_backend(): + return state_dict.get( + "dinov3_dense_nystrom_mmd_version") == 1 + if getattr(self, "uses_fresh_video_gradcache", False): + return ( + state_dict.get(self._dense_nystrom_version_key()) + == self._nystrom_fd_state_version() + ) + if self.feature_type == "vjepa2_dense_latent": + return state_dict.get("vjepa2_dense_mmd_version") == 2 + return state_dict.get("wan_kv_mmd_version") == 3 + + # Historical entry points remain aliases for external callers. + fresh_video_gradcache_prepare_replay = prepare_latent_gradients + fresh_video_gradcache_replay = replay_generator_gradients + fresh_video_gradcache_forward = collect_rollout_features + + _initialize_triple_dinov3_vjepa2_videomae_v2_features = _initialize_multi_encoder_features + _replace_with_triple_nystrom_objective = _build_multi_encoder_objective + _extract_dual_video_features = _extract_multi_encoder_features + _fresh_video_dual_gradcache_forward = _collect_multi_encoder_rollout_features + _dual_gradcache_prepare_replay = _prepare_multi_encoder_latent_gradients + +# Old import/class spelling remains valid for existing callers. +WanKVMMD = DistributionMatchingModel diff --git a/14B/code/model/generation_base.py b/14B/code/model/generation_base.py new file mode 100644 index 0000000000000000000000000000000000000000..997f481fe0f532eba644914ea8a23d3f3ae0d2e1 --- /dev/null +++ b/14B/code/model/generation_base.py @@ -0,0 +1,149 @@ +"""Shared generator runtime used by distribution matching objectives. + +This module contains rollout mechanics only. Distribution objectives belong in +their concrete model classes; frozen-encoder losses and statistics are not part +of the generator runtime. +""" + +from typing import Optional + +import torch +import torch.distributed as dist + +from model.base import SelfForcingModel + + +class GeneratorRuntimeBase(SelfForcingModel): + """Initialize the generator and provide common differentiable rollouts.""" + + uses_critic = False + uses_vae_for_loss = True + + def __init__(self, args, device): + super().__init__(args, device) + self.causal_generator = bool(getattr(args, "causal", True)) + self.num_frame_per_block = getattr(args, "num_frame_per_block", 1) + self.same_step_across_blocks = getattr( + args, "same_step_across_blocks", True) + self.num_training_frames = getattr(args, "num_training_frames", 21) + + if self.causal_generator and self.num_frame_per_block > 1: + self.generator.model.num_frame_per_block = self.num_frame_per_block + + self.independent_first_frame = getattr( + args, "independent_first_frame", False) + if self.causal_generator and self.independent_first_frame: + self.generator.model.independent_first_frame = True + if args.gradient_checkpointing: + self.generator.enable_gradient_checkpointing() + + self.inference_pipeline = None + self.generation_guidance_scale = float( + getattr(args, "guidance_scale", 1.0)) + + if not hasattr(self, "denoising_step_list"): + raise ValueError( + "few-step generation requires denoising_step_list") + denoising_step_list = self.denoising_step_list.to(device=device) + if ( + denoising_step_list.numel() > 0 + and denoising_step_list[-1].item() == 0 + ): + denoising_step_list = denoising_step_list[:-1] + self.generation_denoising_step_list = denoising_step_list + + @staticmethod + def _apply_gradient_mask( + latents: torch.Tensor, + gradient_mask: Optional[torch.Tensor], + ) -> torch.Tensor: + if gradient_mask is None: + return latents + frame_mask = gradient_mask.flatten(2).any(dim=2).view( + latents.shape[0], latents.shape[1], 1, 1, 1) + return torch.where(frame_mask, latents, latents.detach()) + + def _predict_x0_with_cfg( + self, + noisy_latent: torch.Tensor, + conditional_dict: dict, + unconditional_dict: dict, + timestep: torch.Tensor, + ) -> torch.Tensor: + _, x0_cond = self.generator( + noisy_image_or_video=noisy_latent, + conditional_dict=conditional_dict, + timestep=timestep, + ) + if self.generation_guidance_scale == 1.0: + return x0_cond + + _, x0_uncond = self.generator( + noisy_image_or_video=noisy_latent, + conditional_dict=unconditional_dict, + timestep=timestep, + ) + return x0_uncond + self.generation_guidance_scale * ( + x0_cond - x0_uncond) + + def _run_few_step_generator( + self, + image_or_video_shape, + conditional_dict: dict, + unconditional_dict: dict, + exit_index: Optional[int] = None, + ) -> torch.Tensor: + """Roll out detached steps, then differentiate one selected exit step.""" + latent = torch.randn( + image_or_video_shape, + device=self.device, + dtype=self.dtype, + ) + batch_size, num_frames = latent.shape[:2] + denoising_steps = self.generation_denoising_step_list.to( + device=self.device) + num_steps = len(denoising_steps) + + if exit_index is None: + if num_steps <= 1: + exit_index = 0 + else: + index_tensor = torch.randint( + 0, num_steps, (1,), device=self.device) + if dist.is_available() and dist.is_initialized(): + dist.broadcast(index_tensor, src=0) + exit_index = int(index_tensor.item()) + + for index in range(num_steps): + current_timestep = denoising_steps[index] + timestep = torch.ones( + [batch_size, num_frames], + device=self.device, + dtype=current_timestep.dtype, + ) * current_timestep + if index < exit_index: + with torch.no_grad(): + x0_pred = self._predict_x0_with_cfg( + noisy_latent=latent, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + timestep=timestep, + ) + next_timestep = denoising_steps[index + 1] + latent = self.scheduler.add_noise( + x0_pred.flatten(0, 1), + torch.randn_like(x0_pred.flatten(0, 1)), + next_timestep * torch.ones( + [batch_size * num_frames], + device=self.device, + dtype=next_timestep.dtype, + ), + ).unflatten(0, x0_pred.shape[:2]).to(self.dtype) + else: + return self._predict_x0_with_cfg( + noisy_latent=latent, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + timestep=timestep, + ) + return latent diff --git a/14B/code/model/wan_kv_mmd.py b/14B/code/model/wan_kv_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..ec35182d469013349b766de231aa7f6fdf66eef9 --- /dev/null +++ b/14B/code/model/wan_kv_mmd.py @@ -0,0 +1,6 @@ +"""Compatibility import; read model/distribution_matching.py for the implementation.""" +import sys +from . import distribution_matching as _implementation + +# Share the module object so existing monkeypatches and private imports still work. +sys.modules[__name__] = _implementation diff --git a/14B/code/pipeline/__init__.py b/14B/code/pipeline/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d89cdc004df5ffe8c4bec3e5b42679a3df448f4 --- /dev/null +++ b/14B/code/pipeline/__init__.py @@ -0,0 +1,4 @@ +from .causal_inference import CausalInferencePipeline +from .self_forcing_training import SelfForcingTrainingPipeline + +__all__ = ["CausalInferencePipeline", "SelfForcingTrainingPipeline"] diff --git a/14B/code/pipeline/causal_inference.py b/14B/code/pipeline/causal_inference.py new file mode 100644 index 0000000000000000000000000000000000000000..0b44347b96091b6e896c2a20f0b4aab8497e0655 --- /dev/null +++ b/14B/code/pipeline/causal_inference.py @@ -0,0 +1,649 @@ +from typing import List, Optional +import torch + +from utils.wan_wrapper import WanDiffusionWrapper, WanTextEncoder, WanVAEWrapper + +from demo_utils.memory import gpu, get_cuda_free_memory_gb, DynamicSwapInstaller, move_model_to_device_with_memory_preservation + + +def _wan_cache_geometry(generator: WanDiffusionWrapper): + """Return the cache geometry exposed by the loaded Wan backbone.""" + model = generator.model + try: + num_transformer_blocks = len(model.blocks) + num_heads = int(model.num_heads) + dim = int(model.dim) + except AttributeError as exc: + raise AttributeError( + "Wan generator.model must expose blocks, num_heads, and dim" + ) from exc + + if num_transformer_blocks <= 0: + raise ValueError("Wan generator.model.blocks must not be empty") + if num_heads <= 0 or dim <= 0 or dim % num_heads != 0: + raise ValueError( + f"invalid Wan attention geometry: dim={dim}, num_heads={num_heads}" + ) + return num_transformer_blocks, num_heads, dim // num_heads + + +class CausalInferencePipeline(torch.nn.Module): + def __init__( + self, + args, + device, + generator=None, + text_encoder=None, + vae=None + ): + super().__init__() + # Step 1: Initialize all models + if generator is None: + # Mirror ``model/base.py:_initialize_models`` so that ``attention_type`` + # and ``num_frame_per_block`` from the config flow into the wrapper + # (necessary for the block-causal linear-attention student). + gen_kwargs = dict(getattr(args, "model_kwargs", {})) + attn_type = getattr(args, "attention_type", "softmax") + # ``gdn`` is injected into a softmax backbone (see wan/modules/gdn.py). + is_gdn = attn_type == "gdn" + backbone_attn_type = "softmax" if is_gdn else attn_type + gen_kwargs.setdefault("attention_type", backbone_attn_type) + if backbone_attn_type in ("linear", "hybrid"): + gen_kwargs.setdefault( + "num_frame_per_block", getattr(args, "num_frame_per_block", 1), + ) + if backbone_attn_type == "hybrid": + gen_kwargs.setdefault( + "hybrid_gate", bool(getattr(args, "hybrid_gate", False)), + ) + gen_kwargs.setdefault( + "hybrid_gate_init_lambda", float(getattr(args, "hybrid_gate_init_lambda", 0.12)), + ) + gen_kwargs.setdefault( + "hybrid_gate_type", str(getattr(args, "hybrid_gate_type", "none")), + ) + gen_kwargs.setdefault( + "alpha_init_bias", float(getattr(args, "alpha_init_bias", -3.0)), + ) + gen_kwargs.setdefault( + "alpha_detach_denom_delta", bool(getattr(args, "alpha_detach_denom_delta", True)), + ) + gen_kwargs.setdefault( + "alpha_detach_state_summary", bool(getattr(args, "alpha_detach_state_summary", True)), + ) + # Fast alpha-hybrid path knobs (see ``model/base.py``). + gen_kwargs.setdefault( + "use_fast_alpha_hybrid", bool(getattr(args, "use_fast_alpha_hybrid", False)), + ) + gen_kwargs.setdefault( + "hybrid_local_impl", str(getattr(args, "hybrid_local_impl", "torch")), + ) + gen_kwargs.setdefault( + "hybrid_cross_impl", str(getattr(args, "hybrid_cross_impl", "torch")), + ) + gen_kwargs.setdefault( + "alpha_combine_impl", str(getattr(args, "alpha_combine_impl", "torch")), + ) + gen_kwargs.setdefault( + "fallback_to_torch_on_error", bool(getattr(args, "fallback_to_torch_on_error", True)), + ) + gen_kwargs.setdefault( + "match_torch_centering", bool(getattr(args, "match_torch_centering", True)), + ) + gen_kwargs.setdefault( + "fast_alpha_maxperf", bool(getattr(args, "fast_alpha_maxperf", False)), + ) + # ---- mixed-layer attention ---- + sm_indices = getattr(args, "softmax_layer_indices", None) + sm_interval = getattr(args, "softmax_layer_interval", None) + if sm_indices is not None: + gen_kwargs.setdefault("softmax_layer_indices", list(sm_indices)) + if sm_interval is not None: + gen_kwargs.setdefault("softmax_layer_interval", int(sm_interval)) + gen_kwargs.setdefault( + "softmax_layer_local_attn_size", + int(getattr(args, "softmax_layer_local_attn_size", -1)), + ) + gen_kwargs.setdefault( + "softmax_layer_sink_size", + int(getattr(args, "softmax_layer_sink_size", 0)), + ) + self.generator = WanDiffusionWrapper(**gen_kwargs, is_causal=True) + # ── GDN (ARL²) injection — happens BEFORE the checkpoint load in + # inference.py so the injected keys line up. load_stage2=False: + # inference.py loads the full generator (Stage-2 or post-DMD) itself. + # Keep the ordinary softmax inference path independent of the + # optional GDN/FLA stack. Importing ``wan.modules.gdn`` eagerly + # would require ``fla`` even when this config does not request any + # GDN layers. + gdn_configured = is_gdn or any( + getattr(args, name, None) is not None + for name in ( + "gdn_layer_indices", + "gdn_layer_indices_json", + "arl2_hybrid_layers", + "arl2_hybrid_layers_json", + ) + ) + if gdn_configured: + from wan.modules.gdn import ( + gdn_layers_from_config, + setup_gdn_generator, + ) + gdn_layers = gdn_layers_from_config(args) + setup_gdn_generator(self.generator, args, load_stage2=False) + print(f"[causal_inference] GDN hybrid injected at layers {gdn_layers}") + else: + self.generator = generator + self.text_encoder = WanTextEncoder() if text_encoder is None else text_encoder + self.vae = WanVAEWrapper() if vae is None else vae + + # Step 2: Initialize all causal hyperparmeters + self.scheduler = self.generator.get_scheduler() + self.denoising_step_list = torch.tensor( + args.denoising_step_list, dtype=torch.long) + if args.warp_denoising_step: + timesteps = torch.cat((self.scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32))) + self.denoising_step_list = timesteps[1000 - self.denoising_step_list] + + # Evaluated 14B sampler: preserve the four trained non-zero + # stages, then append the terminal t=0 cleanup after warping. + self.inference_terminal_zero_cleanup = bool( + getattr(args, "inference_terminal_zero_cleanup", False) + ) + if ( + self.inference_terminal_zero_cleanup + and float(self.denoising_step_list[-1]) != 0.0 + ): + self.denoising_step_list = torch.cat( + (self.denoising_step_list, self.denoising_step_list.new_zeros(1)) + ) + + # MMD training called scheduler.add_noise(..., t=0) before each cache + # write. Because extra_one_step=True, that is sigma ~= 0.00498 rather + # than exact x0. Match that cache-input distribution without adding + # noise to the decoded output. + self.inference_train_matched_context_cache = bool( + getattr(args, "inference_train_matched_context_cache", False) + ) + self.inference_aux_noise_seed = int( + getattr(args, "inference_aux_noise_seed", 0) + ) + self._inference_call_index = 0 + + ( + self.num_transformer_blocks, + self.num_heads, + self.head_dim, + ) = _wan_cache_geometry(self.generator) + self.frame_seq_length = 1560 + + self.attention_type = getattr(self.generator, "attention_type", "softmax") + self.kv_cache1 = None + self.args = args + self.num_frame_per_block = getattr(args, "num_frame_per_block", 1) + self.independent_first_frame = args.independent_first_frame + # Linear-attention student does not have a (sliding) ``local_attn_size``; + # treat it as unbounded so the recurrent state is allocated for the full + # video. + self.local_attn_size = getattr(self.generator.model, "local_attn_size", -1) + + print( + f"KV inference with {self.num_frame_per_block} frames per block " + f"(attention_type={self.attention_type})" + ) + + if self.num_frame_per_block > 1: + self.generator.model.num_frame_per_block = self.num_frame_per_block + + def _set_advance_state(self, value: bool) -> None: + """Toggle ``advance_state`` on every linear/hybrid kv-cache dict. + + See ``SelfForcingTrainingPipeline._set_advance_state``: the flag + controls whether the linear / hybrid recurrent state is updated in + place on a given forward. Pipelines set ``False`` for every + denoising-loop pass and ``True`` only on the clean re-run so each + block contributes EXACTLY ONCE to the persistent state, matching + the chunkwise training semantics and aligning train / inference + cache distributions. No-op for softmax caches. + """ + if self.kv_cache1 is None: + return + for cache in self.kv_cache1: + if "state" in cache: + cache["advance_state"] = value + # ARL2 HybridBlockAttention layers: clean-pass GDN update flag. + if "gdn_state" in cache: + cache["update_state"] = value + + def inference( + self, + noise: torch.Tensor, + text_prompts: List[str], + initial_latent: Optional[torch.Tensor] = None, + return_latents: bool = False, + profile: bool = False, + low_memory: bool = False, + decode: bool = True, + standard_noise_override=None, + aux_noise_override=None, + ) -> torch.Tensor: + """ + Perform inference on the given noise and text prompts. + Inputs: + noise (torch.Tensor): The input noise tensor of shape + (batch_size, num_output_frames, num_channels, height, width). + text_prompts (List[str]): The list of text prompts. + initial_latent (torch.Tensor): The initial latent tensor of shape + (batch_size, num_input_frames, num_channels, height, width). + If num_input_frames is 1, perform image to video. + If num_input_frames is greater than 1, perform video extension. + return_latents (bool): Whether to return the latents. + Outputs: + video (torch.Tensor): The generated video tensor of shape + (batch_size, num_output_frames, num_channels, height, width). + It is normalized to be in the range [0, 1]. + """ + batch_size, num_frames, num_channels, height, width = noise.shape + aux_generator = torch.Generator(device=noise.device) + aux_generator.manual_seed( + self.inference_aux_noise_seed + self._inference_call_index + ) + self._inference_call_index += 1 + + def aux_randn_like(tensor: torch.Tensor) -> torch.Tensor: + if aux_noise_override is not None: + value = next(aux_noise_override).to(tensor.device) + return value + return torch.randn( + tensor.shape, + dtype=tensor.dtype, + device=tensor.device, + generator=aux_generator, + ) + if not self.independent_first_frame or (self.independent_first_frame and initial_latent is not None): + # If the first frame is independent and the first frame is provided, then the number of frames in the + # noise should still be a multiple of num_frame_per_block + assert num_frames % self.num_frame_per_block == 0 + num_blocks = num_frames // self.num_frame_per_block + else: + # Using a [1, 4, 4, 4, 4, 4, ...] model to generate a video without image conditioning + assert (num_frames - 1) % self.num_frame_per_block == 0 + num_blocks = (num_frames - 1) // self.num_frame_per_block + num_input_frames = initial_latent.shape[1] if initial_latent is not None else 0 + num_output_frames = num_frames + num_input_frames # add the initial latent frames + conditional_dict = self.text_encoder( + text_prompts=text_prompts + ) + + if low_memory: + gpu_memory_preservation = get_cuda_free_memory_gb(gpu) + 5 + move_model_to_device_with_memory_preservation(self.text_encoder, target_device=gpu, preserved_memory_gb=gpu_memory_preservation) + + output = torch.zeros( + [batch_size, num_output_frames, num_channels, height, width], + device=noise.device, + dtype=noise.dtype + ) + + # Set up profiling if requested + if profile: + init_start = torch.cuda.Event(enable_timing=True) + init_end = torch.cuda.Event(enable_timing=True) + diffusion_start = torch.cuda.Event(enable_timing=True) + diffusion_end = torch.cuda.Event(enable_timing=True) + vae_start = torch.cuda.Event(enable_timing=True) + vae_end = torch.cuda.Event(enable_timing=True) + block_times = [] + block_start = torch.cuda.Event(enable_timing=True) + block_end = torch.cuda.Event(enable_timing=True) + init_start.record() + + # Step 1: Initialize KV cache to all zeros + if self.kv_cache1 is None: + self._initialize_kv_cache( + batch_size=batch_size, + dtype=noise.dtype, + device=noise.device + ) + self._initialize_crossattn_cache( + batch_size=batch_size, + dtype=noise.dtype, + device=noise.device + ) + else: + # reset cross attn cache + for block_index in range(self.num_transformer_blocks): + self.crossattn_cache[block_index]["is_init"] = False + # reset kv cache + for block_index in range(len(self.kv_cache1)): + cache = self.kv_cache1[block_index] + cache["global_end_index"] = torch.tensor( + [0], dtype=torch.long, device=noise.device) + cache["local_end_index"] = torch.tensor( + [0], dtype=torch.long, device=noise.device) + # Linear/hybrid layers carry a recurrent state; softmax + # layers carry a K/V cache. In a MIXED model each layer + # has only one of the two -- zero whichever is present. + if "state" in cache: + cache["state"].zero_() + if "normalizer" in cache: + cache["normalizer"].zero_() + if "k" in cache: + cache["k"].zero_() + if "v" in cache: + cache["v"].zero_() + # ARL2 GDN layers: drop the leaked recurrent state so the next + # video starts from a clean (lazily re-allocated) state. + if "gdn_state" in cache: + cache["gdn_state"] = None + cache["update_state"] = True + + # Step 2: Cache context feature + current_start_frame = 0 + if initial_latent is not None: + timestep = torch.ones([batch_size, 1], device=noise.device, dtype=torch.int64) * 0 + # Initial-latent context forwards write clean K/V into the cache + # exactly once per block -> advance_state=True. + self._set_advance_state(True) + if self.independent_first_frame: + # Assume num_input_frames is 1 + self.num_frame_per_block * num_input_blocks + assert (num_input_frames - 1) % self.num_frame_per_block == 0 + num_input_blocks = (num_input_frames - 1) // self.num_frame_per_block + output[:, :1] = initial_latent[:, :1] + self.generator( + noisy_image_or_video=initial_latent[:, :1], + conditional_dict=conditional_dict, + timestep=timestep * 0, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length, + ) + current_start_frame += 1 + else: + # Assume num_input_frames is self.num_frame_per_block * num_input_blocks + assert num_input_frames % self.num_frame_per_block == 0 + num_input_blocks = num_input_frames // self.num_frame_per_block + + for _ in range(num_input_blocks): + current_ref_latents = \ + initial_latent[:, current_start_frame:current_start_frame + self.num_frame_per_block] + output[:, current_start_frame:current_start_frame + self.num_frame_per_block] = current_ref_latents + self.generator( + noisy_image_or_video=current_ref_latents, + conditional_dict=conditional_dict, + timestep=timestep * 0, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length, + ) + current_start_frame += self.num_frame_per_block + + if profile: + init_end.record() + torch.cuda.synchronize() + diffusion_start.record() + + # Step 3: Temporal denoising loop + all_num_frames = [self.num_frame_per_block] * num_blocks + if self.independent_first_frame and initial_latent is None: + all_num_frames = [1] + all_num_frames + for current_num_frames in all_num_frames: + if profile: + block_start.record() + + noisy_input = noise[ + :, current_start_frame - num_input_frames:current_start_frame + current_num_frames - num_input_frames] + + # All denoising-loop forwards on this block read the same cross-block + # cache and do NOT advance it; only the clean re-run (Step 3.3) + # advances the cache. No-op for softmax caches. + self._set_advance_state(False) + + # Step 3.1: Spatial denoising loop + for index, current_timestep in enumerate(self.denoising_step_list): + print(f"current_timestep: {current_timestep}") + # set current timestep + timestep = torch.ones( + [batch_size, current_num_frames], + device=noise.device, + dtype=torch.int64) * current_timestep + + if index < len(self.denoising_step_list) - 1: + _, denoised_pred = self.generator( + noisy_image_or_video=noisy_input, + conditional_dict=conditional_dict, + timestep=timestep, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length + ) + next_timestep = self.denoising_step_list[index + 1] + if ( + self.inference_terminal_zero_cleanup + and float(next_timestep) == 0.0 + ): + transition_noise = aux_randn_like( + denoised_pred.flatten(0, 1) + ) + else: + target = denoised_pred.flatten(0, 1) + if standard_noise_override is None: + transition_noise = torch.randn_like(target) + else: + transition_noise = next(standard_noise_override).to(target.device) + noisy_input = self.scheduler.add_noise( + denoised_pred.flatten(0, 1), + transition_noise, + next_timestep * torch.ones( + [batch_size * current_num_frames], device=noise.device, dtype=torch.long) + ).unflatten(0, denoised_pred.shape[:2]) + else: + # for getting real output + _, denoised_pred = self.generator( + noisy_image_or_video=noisy_input, + conditional_dict=conditional_dict, + timestep=timestep, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length + ) + + # Step 3.2: record the model's output + output[:, current_start_frame:current_start_frame + current_num_frames] = denoised_pred + + # Step 3.3: update KV cache at t=0. Hybrid mode noises only this + # cache input to match training; the stored output remains clean. + context_timestep = torch.ones_like(timestep) * self.args.context_noise + cache_input = denoised_pred + if self.inference_train_matched_context_cache: + flat_cache_input = cache_input.flatten(0, 1) + cache_input = self.scheduler.add_noise( + flat_cache_input, + aux_randn_like(flat_cache_input), + context_timestep.flatten(0, 1), + ).unflatten(0, denoised_pred.shape[:2]) + # This is the single write the linear/hybrid cache gets for this + # block -- restore the default ``advance_state=True`` before the + # forward so the clean K/V actually lands in the cache. + self._set_advance_state(True) + self.generator( + noisy_image_or_video=cache_input, + conditional_dict=conditional_dict, + timestep=context_timestep, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length, + ) + + if profile: + block_end.record() + torch.cuda.synchronize() + block_time = block_start.elapsed_time(block_end) + block_times.append(block_time) + + # Step 3.4: update the start and end frame indices + current_start_frame += current_num_frames + + if profile: + # End diffusion timing and synchronize CUDA + diffusion_end.record() + torch.cuda.synchronize() + diffusion_time = diffusion_start.elapsed_time(diffusion_end) + init_time = init_start.elapsed_time(init_end) + vae_start.record() + + # Step 4: Decode the output (skippable for latent-only generation, e.g. + # ARL² Stage-2 teacher-video latent synthesis where pixels are not needed) + if not decode: + return (None, output) if return_latents else output + video = self.vae.decode_to_pixel(output, use_cache=False) + video = (video * 0.5 + 0.5).clamp(0, 1) + + if profile: + # End VAE timing and synchronize CUDA + vae_end.record() + torch.cuda.synchronize() + vae_time = vae_start.elapsed_time(vae_end) + total_time = init_time + diffusion_time + vae_time + + print("Profiling results:") + print(f" - Initialization/caching time: {init_time:.2f} ms ({100 * init_time / total_time:.2f}%)") + print(f" - Diffusion generation time: {diffusion_time:.2f} ms ({100 * diffusion_time / total_time:.2f}%)") + for i, block_time in enumerate(block_times): + print(f" - Block {i} generation time: {block_time:.2f} ms ({100 * block_time / diffusion_time:.2f}% of diffusion)") + print(f" - VAE decoding time: {vae_time:.2f} ms ({100 * vae_time / total_time:.2f}%)") + print(f" - Total time: {total_time:.2f} ms") + + if return_latents: + return video, output + else: + return video + + def _initialize_kv_cache(self, batch_size, dtype, device): + """ + Initialize a Per-GPU KV cache for the Wan model. + + Per-layer cache shape depends on that layer's attention type: + + * ``softmax`` layers carry a sliding-window K/V tensor + ``[B, kv_cache_size, H, Dv]`` in the activation dtype (bf16). + The cache size matches the all-softmax baseline. + * ``linear`` / ``hybrid`` layers carry a recurrent state + ``[B, H, M, Dv]`` plus denominator ``[B, H, M]`` in fp32 for + numerical stability over the long causal rollout. + + For a MIXED model (some softmax, some linear/hybrid) we walk the + ``per_layer_self_attn_types`` list and pick the right shape per + layer. Pure-linear / pure-hybrid models use the same code path + with every layer set to ``"linear_or_hybrid"``. + """ + kv_cache1 = [] + model = self.generator.model + + # ── ARL2 path: softmax CausalWanModel with some blocks swapped to + # HybridBlockAttention (GDN). GDN layers use a lazily-allocated + # gdn_state (+ update_state flag); other layers keep K/V. + def is_gdn_block(block): + cls = type(block.self_attn) + return ( + cls.__name__ == "HybridBlockAttention" + and cls.__module__ == "wan.modules.hybrid_attention" + ) + + if any(is_gdn_block(b) for b in model.blocks): + sm_size = (self.local_attn_size * self.frame_seq_length + if self.local_attn_size != -1 else 32760) + for b in model.blocks: + entry = { + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device), + } + if is_gdn_block(b): + entry["gdn_state"] = None + entry["update_state"] = True + else: + entry["k"] = torch.zeros([batch_size, sm_size, self.num_heads, self.head_dim], dtype=dtype, device=device) + entry["v"] = torch.zeros([batch_size, sm_size, self.num_heads, self.head_dim], dtype=dtype, device=device) + kv_cache1.append(entry) + self.kv_cache1 = kv_cache1 + return + + if self.attention_type in ("linear", "hybrid"): + # Default: every layer is linear/hybrid. A mixed model exposes + # an explicit per-layer types list that overrides this. + per_layer_types = getattr( + model, "per_layer_self_attn_types", + ["linear_or_hybrid"] * self.num_transformer_blocks, + ) + # Softmax-layer KV cache size mirrors the all-softmax baseline. + softmax_cache_size = ( + self.local_attn_size * self.frame_seq_length + if self.local_attn_size != -1 + else 32760 + ) + for layer_idx in range(self.num_transformer_blocks): + ltype = per_layer_types[layer_idx] if layer_idx < len(per_layer_types) else "linear_or_hybrid" + if ltype == "softmax": + kv_cache1.append({ + "k": torch.zeros( + [batch_size, softmax_cache_size, self.num_heads, self.head_dim], + dtype=dtype, device=device, + ), + "v": torch.zeros( + [batch_size, softmax_cache_size, self.num_heads, self.head_dim], + dtype=dtype, device=device, + ), + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device), + }) + else: + # linear / hybrid recurrent state. Kernel dim defaults + # to head_dim but may be overridden via num_kernel_features. + kernel_dim = self.head_dim + try: + first_block = model.blocks[layer_idx] + kernel_dim = first_block.self_attn.kernel_dim + except (AttributeError, IndexError): + pass + kv_cache1.append({ + "state": torch.zeros( + [batch_size, self.num_heads, kernel_dim, self.head_dim], + dtype=torch.float32, device=device, + ), + "normalizer": torch.zeros( + [batch_size, self.num_heads, kernel_dim], + dtype=torch.float32, device=device, + ), + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device), + }) + else: + # All-softmax model (CausalWanModel path; no mixing). + if self.local_attn_size != -1: + kv_cache_size = self.local_attn_size * self.frame_seq_length + else: + kv_cache_size = 32760 + for _ in range(self.num_transformer_blocks): + kv_cache1.append({ + "k": torch.zeros([batch_size, kv_cache_size, self.num_heads, self.head_dim], dtype=dtype, device=device), + "v": torch.zeros([batch_size, kv_cache_size, self.num_heads, self.head_dim], dtype=dtype, device=device), + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device) + }) + + self.kv_cache1 = kv_cache1 # always store the clean cache + + def _initialize_crossattn_cache(self, batch_size, dtype, device): + """ + Initialize a Per-GPU cross-attention cache for the Wan model. + """ + crossattn_cache = [] + + for _ in range(self.num_transformer_blocks): + crossattn_cache.append({ + "k": torch.zeros([batch_size, 512, self.num_heads, self.head_dim], dtype=dtype, device=device), + "v": torch.zeros([batch_size, 512, self.num_heads, self.head_dim], dtype=dtype, device=device), + "is_init": False + }) + self.crossattn_cache = crossattn_cache diff --git a/14B/code/pipeline/self_forcing_training.py b/14B/code/pipeline/self_forcing_training.py new file mode 100644 index 0000000000000000000000000000000000000000..ab991933f46013905edf508e36fcd273594e4211 --- /dev/null +++ b/14B/code/pipeline/self_forcing_training.py @@ -0,0 +1,495 @@ +"""Autoregressive training rollouts with a sampled denoising exit step. + +Each block conditions on earlier generated blocks. Training stops at a sampled +exit and differentiates that denoising call when gradients are enabled. Released +four-step inference runs the complete schedule in ``causal_inference.py``. +""" + +from utils.wan_wrapper import WanDiffusionWrapper +from utils.scheduler import SchedulerInterface +from typing import List, Optional +import torch +import torch.distributed as dist + + +def _wan_cache_geometry(generator: WanDiffusionWrapper): + """Return the cache geometry exposed by the loaded Wan backbone.""" + model = generator.model + try: + num_transformer_blocks = len(model.blocks) + num_heads = int(model.num_heads) + dim = int(model.dim) + except AttributeError as exc: + raise AttributeError( + "Wan generator.model must expose blocks, num_heads, and dim" + ) from exc + + if num_transformer_blocks <= 0: + raise ValueError("Wan generator.model.blocks must not be empty") + if num_heads <= 0 or dim <= 0 or dim % num_heads != 0: + raise ValueError( + f"invalid Wan attention geometry: dim={dim}, num_heads={num_heads}" + ) + return num_transformer_blocks, num_heads, dim // num_heads + + +class SelfForcingTrainingPipeline: + """Generate on-policy training videos and reproduce their rollout trajectory.""" + + def __init__(self, + denoising_step_list: List[int], + scheduler: SchedulerInterface, + generator: WanDiffusionWrapper, + num_frame_per_block=3, + independent_first_frame: bool = False, + same_step_across_blocks: bool = False, + last_step_only: bool = False, + num_max_frames: int = 21, + context_noise: int = 0, + **kwargs): + super().__init__() + self.scheduler = scheduler + self.generator = generator + self.denoising_step_list = denoising_step_list + if self.denoising_step_list[-1] == 0: + self.denoising_step_list = self.denoising_step_list[:-1] # remove the zero timestep for inference + + # Wan-specific geometry comes from the loaded backbone instead of + # assuming one particular model size. + ( + self.num_transformer_blocks, + self.num_heads, + self.head_dim, + ) = _wan_cache_geometry(self.generator) + self.frame_seq_length = 1560 + self.num_frame_per_block = num_frame_per_block + self.context_noise = context_noise + self.i2v = False + + self.kv_cache1 = None + self.kv_cache2 = None + self.independent_first_frame = independent_first_frame + self.same_step_across_blocks = same_step_across_blocks + self.last_step_only = last_step_only + self.kv_cache_size = num_max_frames * self.frame_seq_length + + def _set_advance_state(self, value: bool) -> None: + """Toggle the ``advance_state`` flag on every layer's kv-cache dict. + + Only the linear / hybrid attention recurrent caches read this flag + (their kv-cache dicts have ``"state"`` and ``"normalizer"`` fields); + the softmax cache uses overwrite-style writes, so this is a no-op + for those layers. See ``wan/modules/linear_attention.py`` for the + rationale (avoid per-pass over-accumulation across the denoising + loop's multiple forwards on the same block). + """ + if self.kv_cache1 is None: + return + for cache in self.kv_cache1: + if "state" in cache: + cache["advance_state"] = value + # ARL2 HybridBlockAttention layers use a GDN recurrent state and + # read ``update_state`` instead of ``advance_state`` (clean-pass + # update: skip writes during denoising, write on the clean rerun). + if "gdn_state" in cache: + cache["update_state"] = value + + def generate_and_sync_list(self, num_blocks, num_denoising_steps, device): + """Choose denoising exit indices on rank zero and share them across ranks.""" + rank = dist.get_rank() if dist.is_initialized() else 0 + + if rank == 0: + # Sampling an exit bounds the training graph to one denoising call + # per block; last_step_only optionally fixes it to the final call. + indices = torch.randint( + low=0, + high=num_denoising_steps, + size=(num_blocks,), + device=device + ) + if self.last_step_only: + indices = torch.ones_like(indices) * (num_denoising_steps - 1) + else: + indices = torch.empty(num_blocks, dtype=torch.long, device=device) + + dist.broadcast(indices, src=0) # Broadcast the random indices to all ranks + return indices.tolist() + + @staticmethod + def _trajectory_generators( + trajectory_seeds: Optional[torch.Tensor], + *, + batch_size: int, + device: torch.device, + ): + if trajectory_seeds is None: + return None + if ( + not isinstance(trajectory_seeds, torch.Tensor) + or trajectory_seeds.dtype != torch.int64 + or tuple(trajectory_seeds.shape) != (batch_size,) + or trajectory_seeds.device != device + or bool((trajectory_seeds < 0).any().item()) + ): + raise ValueError( + "trajectory_seeds must be one non-negative int64 seed " + "per sample on the rollout device") + generators = [] + for seed in trajectory_seeds.detach().cpu().tolist(): + generator = torch.Generator(device=device) + generator.manual_seed(int(seed)) + generators.append(generator) + return tuple(generators) + + @staticmethod + def _samplewise_randn_like( + value: torch.Tensor, + generators, + ) -> torch.Tensor: + if generators is None: + return torch.randn_like(value) + if int(value.shape[0]) != len(generators): + raise ValueError( + "sample-wise trajectory generator count differs from batch") + return torch.cat([ + torch.randn( + value[index:index + 1].shape, + device=value.device, + dtype=value.dtype, + generator=generator, + ) + for index, generator in enumerate(generators) + ], dim=0) + + def inference_with_trajectory( + self, + noise: torch.Tensor, + initial_latent: Optional[torch.Tensor] = None, + return_sim_step: bool = False, + trajectory_seeds: Optional[torch.Tensor] = None, + **conditional_dict + ) -> torch.Tensor: + """Return a training rollout plus its selected denoising interval. + + During feature collection the caller disables gradients. During replay, + the same random choices reconstruct the selected calls with gradients. + """ + batch_size, num_frames, num_channels, height, width = noise.shape + trajectory_generators = self._trajectory_generators( + trajectory_seeds, + batch_size=batch_size, + device=noise.device, + ) + if not self.independent_first_frame or (self.independent_first_frame and initial_latent is not None): + # If the first frame is independent and the first frame is provided, then the number of frames in the + # noise should still be a multiple of num_frame_per_block + assert num_frames % self.num_frame_per_block == 0 + num_blocks = num_frames // self.num_frame_per_block + else: + # Using a [1, 4, 4, 4, 4, 4, ...] model to generate a video without image conditioning + assert (num_frames - 1) % self.num_frame_per_block == 0 + num_blocks = (num_frames - 1) // self.num_frame_per_block + num_input_frames = initial_latent.shape[1] if initial_latent is not None else 0 + num_output_frames = num_frames + num_input_frames # add the initial latent frames + output = torch.zeros( + [batch_size, num_output_frames, num_channels, height, width], + device=noise.device, + dtype=noise.dtype + ) + + # Step 1: Initialize KV cache to all zeros + self._initialize_kv_cache( + batch_size=batch_size, dtype=noise.dtype, device=noise.device + ) + self._initialize_crossattn_cache( + batch_size=batch_size, dtype=noise.dtype, device=noise.device + ) + # Step 2: Cache context feature + current_start_frame = 0 + if initial_latent is not None: + timestep = torch.ones([batch_size, 1], device=noise.device, dtype=torch.int64) * 0 + # Assume num_input_frames is 1 + self.num_frame_per_block * num_input_blocks + output[:, :1] = initial_latent + # Initial-latent context forward is a legit "clean K/V" write -> advance cache. + self._set_advance_state(True) + with torch.no_grad(): + self.generator( + noisy_image_or_video=initial_latent, + conditional_dict=conditional_dict, + timestep=timestep * 0, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length + ) + current_start_frame += 1 + + # Step 3: Temporal denoising loop + all_num_frames = [self.num_frame_per_block] * num_blocks + if self.independent_first_frame and initial_latent is None: + all_num_frames = [1] + all_num_frames + num_denoising_steps = len(self.denoising_step_list) + exit_flags = self.generate_and_sync_list(len(all_num_frames), num_denoising_steps, device=noise.device) + # Retain generator gradients only for the final 21 latent frames. + # Earlier context still affects the rollout through cached values. + start_gradient_frame_index = num_output_frames - 21 + + for block_index, current_num_frames in enumerate(all_num_frames): + noisy_input = noise[ + :, current_start_frame - num_input_frames:current_start_frame + current_num_frames - num_input_frames] + + # All denoising-loop forwards on this block read the same cross-block + # cache and do NOT advance it; only the clean re-run (Step 3.3) + # advances the cache. No-op for the softmax attention path. + self._set_advance_state(False) + + # Step 3.1: Spatial denoising loop + for index, current_timestep in enumerate(self.denoising_step_list): + if self.same_step_across_blocks: + exit_flag = (index == exit_flags[0]) + else: + exit_flag = (index == exit_flags[block_index]) # Only backprop at the randomly selected timestep (consistent across all ranks) + timestep = torch.ones( + [batch_size, current_num_frames], + device=noise.device, + dtype=torch.int64) * current_timestep + + if not exit_flag: + # Earlier denoising calls construct the selected step's + # input; their graphs are deliberately not retained. + with torch.no_grad(): + _, denoised_pred = self.generator( + noisy_image_or_video=noisy_input, + conditional_dict=conditional_dict, + timestep=timestep, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length, + cache_start=current_start_frame * self.frame_seq_length, + update_kv_cache=True, + ) + next_timestep = self.denoising_step_list[index + 1] + noisy_input = self.scheduler.add_noise( + denoised_pred.flatten(0, 1), + self._samplewise_randn_like( + denoised_pred, trajectory_generators + ).flatten(0, 1), + next_timestep * torch.ones( + [batch_size * current_num_frames], device=noise.device, dtype=torch.long) + ).unflatten(0, denoised_pred.shape[:2]) + else: + # The selected exit produces the block's clean prediction. + # Replay differentiates this call within the gradient window. + if current_start_frame < start_gradient_frame_index: + with torch.no_grad(): + _, denoised_pred = self.generator( + noisy_image_or_video=noisy_input, + conditional_dict=conditional_dict, + timestep=timestep, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length, + cache_start=current_start_frame * self.frame_seq_length, + update_kv_cache=True, + ) + else: + _, denoised_pred = self.generator( + noisy_image_or_video=noisy_input, + conditional_dict=conditional_dict, + timestep=timestep, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length, + cache_start=current_start_frame * self.frame_seq_length, + update_kv_cache=False, + ) + break + + # Step 3.2: record the model's output + output[:, current_start_frame:current_start_frame + current_num_frames] = denoised_pred + + # Step 3.3: publish this generated block as context for the next. + # This cache write has no graph, preserving the training truncation. + context_timestep = torch.ones_like(timestep) * self.context_noise + # add context noise + denoised_pred = self.scheduler.add_noise( + denoised_pred.flatten(0, 1), + self._samplewise_randn_like( + denoised_pred, trajectory_generators + ).flatten(0, 1), + context_timestep.flatten(0, 1) + ).unflatten(0, denoised_pred.shape[:2]) + # This is the single write the linear/hybrid cache gets for this + # block -- restore the default ``advance_state=True`` before the + # forward so the clean K/V actually lands in the cache. + self._set_advance_state(True) + with torch.no_grad(): + self.generator( + noisy_image_or_video=denoised_pred, + conditional_dict=conditional_dict, + timestep=context_timestep, + kv_cache=self.kv_cache1, + crossattn_cache=self.crossattn_cache, + current_start=current_start_frame * self.frame_seq_length, + cache_start=current_start_frame * self.frame_seq_length, + update_kv_cache=True, + ) + + # Step 3.4: update the start and end frame indices + current_start_frame += current_num_frames + + # Step 3.5: Return the denoised timestep + if not self.same_step_across_blocks: + denoised_timestep_from, denoised_timestep_to = None, None + elif exit_flags[0] == len(self.denoising_step_list) - 1: + denoised_timestep_to = 0 + denoised_timestep_from = 1000 - torch.argmin( + (self.scheduler.timesteps.cuda() - self.denoising_step_list[exit_flags[0]].cuda()).abs(), dim=0).item() + else: + denoised_timestep_to = 1000 - torch.argmin( + (self.scheduler.timesteps.cuda() - self.denoising_step_list[exit_flags[0] + 1].cuda()).abs(), dim=0).item() + denoised_timestep_from = 1000 - torch.argmin( + (self.scheduler.timesteps.cuda() - self.denoising_step_list[exit_flags[0]].cuda()).abs(), dim=0).item() + + if return_sim_step: + return output, denoised_timestep_from, denoised_timestep_to, exit_flags[0] + 1 + + return output, denoised_timestep_from, denoised_timestep_to + + def _initialize_kv_cache(self, batch_size, dtype, device): + """ + Initialize a Per-GPU KV cache for the Wan model. + + Per-layer cache shape depends on that layer's attention type + (mirrors :meth:`pipeline.causal_inference.CausalInferencePipeline._initialize_kv_cache`): + + * ``softmax`` layers -> ``k`` / ``v`` in bf16, shape + ``[B, cache_size, H, Dv]``. + * ``linear`` / ``hybrid`` -> ``state`` / ``normalizer`` in fp32, + shape ``[B, H, M, Dv]`` / ``[B, H, M]``. + + For a MIXED model (some softmax + some linear/hybrid layers) we + walk the model's ``per_layer_self_attn_types`` list and allocate + the matching shape per layer. Pure-linear / pure-hybrid / + pure-softmax models still work because the type list is uniform + in those cases. + + Crashing on the wrong key (the previous "always-linear" allocator + handed a softmax layer a dict without ``"k"``, raising + ``KeyError: 'k'`` deep inside the attention forward) was exactly + the failure mode this fix addresses. + """ + self.kv_cache1 = None + attention_type = getattr(self.generator, "attention_type", "softmax") + model = self.generator.model + kv_cache1 = [] + + # ── ARL2 path: a softmax CausalWanModel with some blocks' self_attn + # replaced by HybridBlockAttention. Those blocks use a lazily- + # allocated GDN state (gdn_state/update_state); the rest keep K/V. + def is_gdn_block(block): + cls = type(block.self_attn) + return ( + cls.__name__ == "HybridBlockAttention" + and cls.__module__ == "wan.modules.hybrid_attention" + ) + + if any(is_gdn_block(b) for b in model.blocks): + for b in model.blocks: + entry = { + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device), + } + if is_gdn_block(b): + entry["gdn_state"] = None # lazily allocated H x D x D + entry["update_state"] = True + else: + entry["k"] = torch.zeros( + [batch_size, self.kv_cache_size, self.num_heads, self.head_dim], dtype=dtype, device=device) + entry["v"] = torch.zeros( + [batch_size, self.kv_cache_size, self.num_heads, self.head_dim], dtype=dtype, device=device) + kv_cache1.append(entry) + self.kv_cache1 = kv_cache1 + return + + if attention_type in ("linear", "hybrid"): + # Default to "every layer is linear/hybrid"; mixed models + # override via ``per_layer_self_attn_types``. + per_layer_types = getattr( + model, "per_layer_self_attn_types", + ["linear_or_hybrid"] * self.num_transformer_blocks, + ) + # Softmax-layer cache size. Use the model's + # ``softmax_layer_local_attn_size`` knob so a windowed config + # only allocates as much storage as it actually uses. + sm_local = int(getattr(model, "softmax_layer_local_attn_size", -1)) + if sm_local != -1: + softmax_cache_size = sm_local * self.frame_seq_length + else: + softmax_cache_size = self.kv_cache_size + for layer_idx in range(self.num_transformer_blocks): + ltype = ( + per_layer_types[layer_idx] + if layer_idx < len(per_layer_types) + else "linear_or_hybrid" + ) + if ltype == "softmax": + kv_cache1.append({ + "k": torch.zeros( + [batch_size, softmax_cache_size, self.num_heads, self.head_dim], + dtype=dtype, device=device, + ), + "v": torch.zeros( + [batch_size, softmax_cache_size, self.num_heads, self.head_dim], + dtype=dtype, device=device, + ), + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device), + }) + else: + # Linear/hybrid recurrent state. ``kernel_dim`` may + # be overridden via ``num_kernel_features``; default + # is the loaded backbone's per-head width. + kernel_dim = self.head_dim + try: + first_linhyb_block = model.blocks[layer_idx] + kernel_dim = first_linhyb_block.self_attn.kernel_dim + except (AttributeError, IndexError): + pass + kv_cache1.append({ + "state": torch.zeros( + [batch_size, self.num_heads, kernel_dim, self.head_dim], + dtype=torch.float32, device=device, + ), + "normalizer": torch.zeros( + [batch_size, self.num_heads, kernel_dim], + dtype=torch.float32, device=device, + ), + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device), + }) + else: + # All-softmax (Wan baseline) path. + for _ in range(self.num_transformer_blocks): + kv_cache1.append({ + "k": torch.zeros([batch_size, self.kv_cache_size, self.num_heads, self.head_dim], dtype=dtype, device=device), + "v": torch.zeros([batch_size, self.kv_cache_size, self.num_heads, self.head_dim], dtype=dtype, device=device), + "global_end_index": torch.tensor([0], dtype=torch.long, device=device), + "local_end_index": torch.tensor([0], dtype=torch.long, device=device) + }) + + self.kv_cache1 = kv_cache1 # always store the clean cache + + def _initialize_crossattn_cache(self, batch_size, dtype, device): + """ + Initialize a Per-GPU cross-attention cache for the Wan model. + """ + self.crossattn_cache = None + crossattn_cache = [] + + for _ in range(self.num_transformer_blocks): + crossattn_cache.append({ + "k": torch.zeros([batch_size, 512, self.num_heads, self.head_dim], dtype=dtype, device=device), + "v": torch.zeros([batch_size, 512, self.num_heads, self.head_dim], dtype=dtype, device=device), + "is_init": False + }) + self.crossattn_cache = crossattn_cache diff --git a/14B/code/prompts/test_8.txt b/14B/code/prompts/test_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..22d96d5f3d0470549cad53dc28f8bcb64034e522 --- /dev/null +++ b/14B/code/prompts/test_8.txt @@ -0,0 +1,8 @@ +A stylish woman walks down a Tokyo street filled with warm glowing neon and animated city signage. She wears a black leather jacket, a long red dress, and black boots, and carries a black purse. She wears sunglasses and red lipstick. She walks confidently and casually. The street is damp and reflective, creating a mirror effect of the colorful lights. Many pedestrians walk about. +Several giant wooly mammoths approach treading through a snowy meadow, their long wooly fur lightly blows in the wind as they walk, snow covered trees and dramatic snow capped mountains in the distance, mid afternoon light with wispy clouds and a sun high in the distance creates a warm glow, the low camera view is stunning capturing the large furry mammal with beautiful photography, depth of field. +A movie trailer featuring the adventures of the 30 year old space man wearing a red wool knitted motorcycle helmet, blue sky, salt desert, cinematic style, shot on 35mm film, vivid colors. +Drone view of waves crashing against the rugged cliffs along Big Sur’s garay point beach. The crashing blue waters create white-tipped waves, while the golden light of the setting sun illuminates the rocky shore. A small island with a lighthouse sits in the distance, and green shrubbery covers the cliff’s edge. The steep drop from the road down to the beach is a dramatic feat, with the cliff’s edges jutting out over the sea. This is a view that captures the raw beauty of the coast and the rugged landscape of the Pacific Coast Highway. +Animated scene features a close-up of a short fluffy monster kneeling beside a melting red candle. The art style is 3D and realistic, with a focus on lighting and texture. The mood of the painting is one of wonder and curiosity, as the monster gazes at the flame with wide eyes and open mouth. Its pose and expression convey a sense of innocence and playfulness, as if it is exploring the world around it for the first time. The use of warm colors and dramatic lighting further enhances the cozy atmosphere of the image. +A gorgeously rendered papercraft world of a coral reef, rife with colorful fish and sea creatures. +This close-up shot of a Victoria crowned pigeon showcases its striking blue plumage and red chest. Its crest is made of delicate, lacy feathers, while its eye is a striking red color. The bird’s head is tilted slightly to the side, giving the impression of it looking regal and majestic. The background is blurred, drawing attention to the bird’s striking appearance. +Photorealistic closeup video of two pirate ships battling each other as they sail inside a cup of coffee. diff --git a/14B/code/tests/__init__.py b/14B/code/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/14B/code/tests/test_active_encoder_offload.py b/14B/code/tests/test_active_encoder_offload.py new file mode 100644 index 0000000000000000000000000000000000000000..7c6aec06ef7d11c6379e552e21cee2347b37a960 --- /dev/null +++ b/14B/code/tests/test_active_encoder_offload.py @@ -0,0 +1,70 @@ +"""Regression coverage for the missing disabled-DINO offload entry.""" +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +import torch + +from model.wan_kv_mmd import WanKVMMD + + +def _model(names, device="cpu"): + model = object.__new__(WanKVMMD) + torch.nn.Module.__init__(model) + model.device = torch.device(device) + model.generator_replay_frozen_encoder_cpu_offload = True + model._generator_replay_frozen_encoder_offload_state = None + model.dual_branch_registry = { + name: {"encoder_attribute": f"{name}_encoder"} for name in names} + model.vae = torch.nn.Linear(3, 3).to(device).eval().requires_grad_(False) + for name in names: + setattr(model, f"{name}_encoder", torch.nn.Linear(3, 3).to(device).eval().requires_grad_(False)) + return model + + +@pytest.mark.parametrize("names", [ + ("vjepa2", "videomae_v2"), ("dinov3", "vjepa2", "videomae_v2")]) +def test_active_offload_restore_repeats_without_changing_weights(names): + model = _model(names) + modules = ("vae",) + tuple(f"{name}_encoder" for name in names) + before = {name: {k: v.clone() for k, v in getattr(model, name).state_dict().items()} for name in modules} + with patch.object(torch.cuda, "synchronize"), patch.object(torch.cuda, "empty_cache"): + for _ in range(2): + moved = model.offload_frozen_modules_for_generator_replay() + assert moved > 0 + assert model._generator_replay_frozen_encoder_offload_state["module_names"] == modules + with pytest.raises(RuntimeError, match="already offloaded"): + model.offload_frozen_modules_for_generator_replay() + assert model.restore_frozen_modules_before_feature_replay() == moved + assert model._generator_replay_frozen_encoder_offload_state is None + for name in modules: + module = getattr(model, name) + assert not module.training and not any(p.requires_grad for p in module.parameters()) + for key, value in module.state_dict().items(): + assert torch.equal(value, before[name][key]) + if "dinov3" not in names: + assert not hasattr(model, "dinov3_encoder") + + +def test_missing_active_encoder_still_fails_closed_and_rolls_back(): + model = _model(("vjepa2", "videomae_v2")) + del model.vjepa2_encoder + with patch.object(torch.cuda, "synchronize"), patch.object(torch.cuda, "empty_cache"): + with pytest.raises(RuntimeError, match="vjepa2_encoder is unavailable"): + model.offload_frozen_modules_for_generator_replay() + assert model._generator_replay_frozen_encoder_offload_state is None + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires a CUDA GPU") +def test_cuda_two_step_offload_restore_is_bitwise_exact(): + model = _model(("vjepa2", "videomae_v2"), "cuda:0") + modules = (model.vae, model.vjepa2_encoder, model.videomae_v2_encoder) + inputs = torch.randn(4, 3, device="cuda:0") + expected = [module(inputs).detach() for module in modules] + for _ in range(2): + model.offload_frozen_modules_for_generator_replay() + assert all(next(module.parameters()).device.type == "cpu" for module in modules) + model.restore_frozen_modules_before_feature_replay() + assert all(next(module.parameters()).device.type == "cuda" for module in modules) + for module, baseline in zip(modules, expected): + assert torch.equal(module(inputs), baseline) diff --git a/14B/code/tests/test_evaluated_inference.py b/14B/code/tests/test_evaluated_inference.py new file mode 100644 index 0000000000000000000000000000000000000000..cd2b09447fbe1b7d5bc30c3c206061f2a6ef8a6a --- /dev/null +++ b/14B/code/tests/test_evaluated_inference.py @@ -0,0 +1,78 @@ +"""Exercise the actual sampler without loading the 14B weights.""" +from types import SimpleNamespace +import torch +from pipeline.causal_inference import CausalInferencePipeline +from utils.scheduler import FlowMatchScheduler + + +class RecordingGenerator(torch.nn.Module): + def __init__(self): + super().__init__() + self.model = SimpleNamespace(blocks=[SimpleNamespace(self_attn=SimpleNamespace())], num_heads=1, dim=1, local_attn_size=1) + self.attention_type = 'softmax' + self.calls = [] + self.scheduler = FlowMatchScheduler(shift=5, sigma_min=0, extra_one_step=True) + self.scheduler.set_timesteps(1000, training=True) + + def get_scheduler(self): + return self.scheduler + + def forward(self, noisy_image_or_video, timestep, **kwargs): + self.calls.append((timestep.clone(), noisy_image_or_video.clone())) + prediction = noisy_image_or_video * 0.5 + return torch.zeros_like(prediction), prediction + + +def make_pipeline(): + args = SimpleNamespace(denoising_step_list=[1000, 750, 500, 250], warp_denoising_step=True, + independent_first_frame=False, num_frame_per_block=3, context_noise=0, + inference_terminal_zero_cleanup=True, inference_train_matched_context_cache=True, + inference_aux_noise_seed=12345) + generator = RecordingGenerator() + pipe = CausalInferencePipeline(args, 'cpu', generator=generator, + text_encoder=lambda **kw: {}, vae=SimpleNamespace()) + pipe.frame_seq_length = 1 + return pipe, generator + + +def test_terminal_cleanup_and_noised_cache_are_executed(): + pipe, generator = make_pipeline() + assert len(pipe.denoising_step_list) == 5 + assert pipe.denoising_step_list[-1] == 0 + noise = torch.ones(1, 6, 1, 1, 1) + result = pipe.inference(noise=noise, text_prompts=['test'], decode=False) + # Each block has five denoising calls and a separate cache write. + assert len(generator.calls) == 12 + for block in range(2): + calls = generator.calls[block * 6:(block + 1) * 6] + assert [float(t[0, 0]) for t, _ in calls] == pipe.denoising_step_list.tolist() + [0.0] + clean = result[:, block * 3:(block + 1) * 3] + assert torch.equal(clean, calls[4][1] * 0.5) + assert not torch.equal(calls[5][1], clean) # Cache noise never overwrites output. + + + +def test_microbatch_adapter_preserves_five_sample_noise_order(): + from utils.inference_microbatch import install_microbatch_adapter + records = [] + def record(noise, text_prompts, standard_noise_override, aux_noise_override, **kwargs): + records.append((noise.clone(), list(standard_noise_override), list(aux_noise_override))) + return noise.clone(), noise.clone() + pipe = SimpleNamespace(inference=record, num_frame_per_block=3, inference_aux_noise_seed=12345, + vae=SimpleNamespace(model=SimpleNamespace(clear_cache=lambda: None))) + install_microbatch_adapter(pipe) + torch.manual_seed(0) + noise = torch.randn(5, 21, 1, 2, 2) + state = torch.get_rng_state().clone() + videos, latents = pipe.inference(noise, ['test'] * 5) + final_state = torch.get_rng_state().clone() + torch.set_rng_state(state) + main = [torch.randn(15, 1, 2, 2) for _ in range(21)] + rng = torch.Generator().manual_seed(12345) + aux = [torch.randn(15, 1, 2, 2, generator=rng) for _ in range(14)] + assert torch.equal(final_state, torch.get_rng_state()) + assert torch.equal(videos, noise) and torch.equal(latents, noise) + for i, (initial, actual_main, actual_aux) in enumerate(records): + assert torch.equal(initial, noise[i:i+1]) + assert all(torch.equal(a, b[i*3:(i+1)*3]) for a, b in zip(actual_main, main)) + assert all(torch.equal(a, b[i*3:(i+1)*3]) for a, b in zip(actual_aux, aux)) diff --git a/14B/code/tests/test_multi_representation_video_nystrom_mmd.py b/14B/code/tests/test_multi_representation_video_nystrom_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..55abf632184e3dc91be757fd705491c24d55e235 --- /dev/null +++ b/14B/code/tests/test_multi_representation_video_nystrom_mmd.py @@ -0,0 +1,346 @@ +import torch + +from losses.multi_representation_video_nystrom_mmd import ( + MultiRepresentationVideoNystromMMD, + fd_normalize_raw_mmd, +) +from losses.video_nystrom_mmd import ( + VideoNystromMMD, + build_video_nystrom_reference, +) +from trainer.multi_representation_fresh_video_gradcache import ( + MultiRepresentationFreshVideoGradCache, +) + + +def _objective( + name: str, + shape: tuple[int, int], + seed: int, + *, + current_teacher: bool = True, +): + generator = torch.Generator().manual_seed(seed) + reference = torch.randn(12, *shape, generator=generator) + pack = build_video_nystrom_reference( + reference, + num_landmarks=5, + metadata={ + "feature": {"name": name, "matrix_shape": list(shape)}, + "reference": {"dataset": "triple-unit-test"}, + }, + bandwidth=1.7, + kmeans_iterations=3, + ridge=1.0e-5, + chunk_size=16, + ) + return VideoNystromMMD( + pack, + kernel_chunk_size=64, + attraction_weight=2.0 if current_teacher else 1.0, + current_teacher_attraction_enabled=current_teacher, + current_teacher_attraction_weight=1.0 if current_teacher else 0.0, + ) + + +def _triple(): + return MultiRepresentationVideoNystromMMD( + { + "dinov3": _objective("tiny-dinov3", (1, 3), 4199), + "vjepa2": _objective("tiny-vjepa2", (1, 2), 4200), + "videomae_v2": _objective("tiny-videomae", (2, 3), 4201), + }, + weights={"dinov3": 10.0, "vjepa2": 1.0, "videomae_v2": 1.0}, + normalization_eps=1.0e-4, + aggregation_divisor=3.0, + ) + + +def _fixed_dual(): + return MultiRepresentationVideoNystromMMD( + { + "dinov3": _objective( + "tiny-fixed-dinov3", (1, 3), 4300, + current_teacher=False), + "vjepa2": _objective( + "tiny-fixed-vjepa2", (1, 2), 4301, + current_teacher=False), + }, + weights={"dinov3": 10.0, "vjepa2": 1.0}, + normalization_eps=1.0e-4, + ) + + +def test_three_raw_mmds_are_normalized_weighted_then_divided_by_three(): + objective = _triple() + generator = torch.Generator().manual_seed(4202) + reference = { + "dinov3": torch.randn(256, 1, 3, generator=generator), + "vjepa2": torch.randn(256, 1, 2, generator=generator), + "videomae_v2": torch.randn(256, 2, 3, generator=generator), + } + indices = torch.arange(128, 256) + gradient = { + name: value.index_select(0, indices).clone().requires_grad_(True) + for name, value in reference.items() + } + teacher = { + "dinov3": torch.randn( + 128, 1, 3, generator=generator, requires_grad=True), + "vjepa2": torch.randn( + 128, 1, 2, generator=generator, requires_grad=True), + "videomae_v2": torch.randn( + 128, 2, 3, generator=generator, requires_grad=True), + } + loss, metadata = objective( + gradient_students=gradient, + student_reference=reference, + current_teacher=teacher, + gradient_student_indices=indices, + stage=False, + ) + + expected_terms = [] + for name, branch in objective.branches.items(): + raw, branch_metadata = branch( + gradient_students=gradient[name], + student_reference=reference[name], + current_teacher=teacher[name], + gradient_student_indices=indices, + stage=False, + ) + normalized = raw / (raw.detach() + 1.0e-4) + expected_terms.append(objective.branch_weights[name] * normalized) + observed = metadata["branches"][name] + torch.testing.assert_close(observed["raw_mmd"], raw.detach()) + assert int(observed["repulsion_denominator"]) == 65536 + assert int(observed["fixed_teacher_attraction_denominator"]) == 256 + assert int(observed["current_teacher_attraction_denominator"]) == 32768 + assert int(observed["current_teacher_self_kernel_denominator"]) == 16384 + assert int(observed["full_student_partial_gradient"]) == 1 + assert abs(float(observed[ + "fixed_teacher_attraction_normalized_weight"]) - 2.0 / 3.0) < 1e-7 + assert abs(float(observed[ + "current_teacher_attraction_normalized_weight"]) - 1.0 / 3.0) < 1e-7 + assert branch_metadata["repulsion_denominator"].item() == 65536 + expected = torch.stack(expected_terms).sum() / 3.0 + torch.testing.assert_close(loss, expected, rtol=2.0e-5, atol=2.0e-6) + # Aggregate exactly as (10 * DINOv3 + V-JEPA2 + VideoMAE-v2) / 3. + torch.testing.assert_close( + metadata["normalized_mmd_sum"], expected.detach(), + rtol=2.0e-5, atol=2.0e-6) + assert metadata["branches"]["dinov3"]["branch_weight"].item() == 10.0 + assert metadata["branches"]["vjepa2"]["branch_weight"].item() == 1.0 + assert metadata["branches"]["videomae_v2"]["branch_weight"].item() == 1.0 + assert metadata["aggregation_divisor"].item() == 3.0 + + gradients = torch.autograd.grad( + loss, + tuple(gradient.values()) + tuple(teacher.values()), + allow_unused=True, + ) + assert all(value is not None and value.norm() > 0 for value in gradients[:3]) + assert all(value is None for value in gradients[3:]) + + +def test_dual_dinov3_weight_is_applied_after_fd_normalization(): + objective = MultiRepresentationVideoNystromMMD( + { + "dinov3": _objective("tiny-dinov3-weighted", (1, 3), 4210), + "vjepa2": _objective("tiny-vjepa2-weighted", (1, 2), 4211), + }, + weights={"dinov3": 10.0, "vjepa2": 1.0}, + normalization_eps=1.0e-4, + ) + generator = torch.Generator().manual_seed(4212) + generated = { + "dinov3": torch.randn( + 8, 1, 3, generator=generator, requires_grad=True), + "vjepa2": torch.randn( + 8, 1, 2, generator=generator, requires_grad=True), + } + teacher = { + "dinov3": torch.randn(8, 1, 3, generator=generator), + "vjepa2": torch.randn(8, 1, 2, generator=generator), + } + + loss, metadata = objective( + generated, + current_teacher=teacher, + stage=False, + ) + + dino = metadata["branches"]["dinov3"] + vjepa = metadata["branches"]["vjepa2"] + expected = 10.0 * dino["normalized_mmd"] + vjepa["normalized_mmd"] + torch.testing.assert_close(loss.detach(), expected) + torch.testing.assert_close( + dino["weighted_normalized_mmd"], + 10.0 * dino["normalized_mmd"], + ) + assert dino["branch_weight"].item() == 10.0 + assert vjepa["branch_weight"].item() == 1.0 + + gradients = torch.autograd.grad(loss, tuple(generated.values())) + assert all(torch.isfinite(value).all() and value.norm() > 0 + for value in gradients) + + +def test_mapping_gradcache_suffix_and_cotangent_compensation(): + objective = _triple() + cache = MultiRepresentationFreshVideoGradCache( + objective, + target_local_samples=4, + gradient_local_samples=2, + full_student_partial_gradient=True, + require_sample_id_alignment=True, + ) + generator = torch.Generator().manual_seed(4203) + batches = [] + for index in range(4): + features = { + "dinov3": torch.randn(1, 1, 3, generator=generator), + "vjepa2": torch.randn(1, 1, 2, generator=generator), + "videomae_v2": torch.randn(1, 2, 3, generator=generator), + } + batches.append(features) + selected = index >= 2 + teacher = ({ + "dinov3": torch.randn(1, 1, 3, generator=generator), + "vjepa2": torch.randn(1, 1, 2, generator=generator), + "videomae_v2": torch.randn(1, 2, 3, generator=generator), + } if selected else None) + sample_id = (f"sample-{index}",) + cache.add( + features, + teacher, + gradient_selected=selected, + sample_ids=sample_id, + current_teacher_sample_ids=sample_id if selected else None, + ) + result = cache.finalize() + assert result.replay_batch_indices == (2, 3) + assert len(result.fsdp_cotangents) == 2 + assert set(result.fsdp_cotangents[0]) == { + "dinov3", "vjepa2", "videomae_v2"} + assert int(result.metadata["gradcache_kss_denominator"]) == 16 + assert int(result.metadata["gradcache_gradient_global_samples"]) == 2 + original_loss = result.loss.clone() + compensated = tuple({ + name: value * 2.0 for name, value in cotangent.items() + } for cotangent in result.fsdp_cotangents) + for before, after in zip(result.fsdp_cotangents, compensated): + for name in before: + torch.testing.assert_close(after[name], before[name] * 2.0) + torch.testing.assert_close(result.loss, original_loss, rtol=0, atol=0) + + +def test_fixed_only_dual_matches_independent_branches_and_rejects_teacher(): + objective = _fixed_dual() + generator = torch.Generator().manual_seed(4302) + generated = { + "dinov3": torch.randn( + 8, 1, 3, generator=generator, requires_grad=True), + "vjepa2": torch.randn( + 8, 1, 2, generator=generator, requires_grad=True), + } + loss, metadata = objective(generated, stage=False) + terms = [] + for name, branch in objective.branches.items(): + raw, branch_metadata = branch(generated[name], stage=False) + normalized = raw / (raw.detach() + 1.0e-4) + terms.append(objective.branch_weights[name] * normalized) + torch.testing.assert_close( + metadata["branches"][name]["raw_mmd"], raw.detach()) + assert "current_teacher_attraction" not in branch_metadata + torch.testing.assert_close(loss, torch.stack(terms).sum()) + gradients = torch.autograd.grad(loss, tuple(generated.values())) + assert all(value.norm() > 0 for value in gradients) + + unexpected_teacher = { + name: torch.zeros_like(value) + for name, value in generated.items() + } + try: + objective(generated, current_teacher=unexpected_teacher, stage=False) + except ValueError as exc: + assert "disabled" in str(exc) + else: + raise AssertionError("fixed-only dual accepted current teacher payload") + + +def test_fixed_only_mapping_gradcache_has_zero_teacher_geometry(): + objective = _fixed_dual() + cache = MultiRepresentationFreshVideoGradCache( + objective, + target_local_samples=4, + gradient_local_samples=2, + full_student_partial_gradient=True, + require_sample_id_alignment=True, + ) + generator = torch.Generator().manual_seed(4303) + for index in range(4): + selected = index >= 2 + cache.add( + { + "dinov3": torch.randn(1, 1, 3, generator=generator), + "vjepa2": torch.randn(1, 1, 2, generator=generator), + }, + gradient_selected=selected, + sample_ids=(f"openvid-{index}",), + ) + result = cache.finalize() + assert result.replay_batch_indices == (2, 3) + assert result.current_teacher_local_samples == 0 + assert result.current_teacher_global_samples == 0 + assert int(result.metadata[ + "gradcache_current_teacher_global_samples"]) == 0 + assert all( + value.norm() > 0 + for part in result.local_cotangents + for value in part.values() + ) + + cache.discard() + try: + cache.add( + { + "dinov3": torch.zeros(1, 1, 3), + "vjepa2": torch.zeros(1, 1, 2), + }, + { + "dinov3": torch.zeros(1, 1, 3), + "vjepa2": torch.zeros(1, 1, 2), + }, + gradient_selected=True, + sample_ids=("unexpected",), + current_teacher_sample_ids=("unexpected",), + ) + except ValueError as exc: + assert "disabled" in str(exc) + else: + raise AssertionError("fixed-only GradCache accepted teacher payload") + + +def test_fd_normalization_preserves_exact_signed_and_zero_neighborhood_behavior(): + eps = 1.0e-4 + cases = ( + (-2.0e-4, 2.0, -1.0e4), + (0.0, 0.0, 1.0e4), + (1.0e-8, 1.0e-8 / (eps + 1.0e-8), 1.0 / (eps + 1.0e-8)), + ) + for value, expected_forward, expected_gradient in cases: + raw = torch.tensor(value, dtype=torch.float64, requires_grad=True) + normalized, denominator = fd_normalize_raw_mmd(raw, eps) + torch.testing.assert_close( + normalized.detach(), torch.tensor(expected_forward, dtype=torch.float64)) + (gradient,) = torch.autograd.grad(normalized, raw) + torch.testing.assert_close( + gradient, torch.tensor(expected_gradient, dtype=torch.float64)) + torch.testing.assert_close( + denominator, raw.detach() + eps, rtol=0, atol=0) + + singular = torch.tensor(-eps, dtype=torch.float64, requires_grad=True) + normalized, denominator = fd_normalize_raw_mmd(singular, eps) + assert denominator.item() == 0.0 + assert torch.isinf(normalized) and normalized.item() < 0.0 diff --git a/14B/code/tests/test_no_dinov3_ablation.py b/14B/code/tests/test_no_dinov3_ablation.py new file mode 100644 index 0000000000000000000000000000000000000000..409cf6ce15980204815ef6da8a8aa7b2884e296d --- /dev/null +++ b/14B/code/tests/test_no_dinov3_ablation.py @@ -0,0 +1,261 @@ +"""Two-branch ablation: same loss scale, teacher mixture and replay geometry.""" +import ast +import copy +import inspect +import textwrap +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import torch +from omegaconf import OmegaConf + +from model.wan_kv_mmd import WanKVMMD +from utils.active_mmd_branches import active_mmd_branches, NO_DINO_BRANCHES +from losses.multi_representation_video_nystrom_mmd import MultiRepresentationVideoNystromMMD +from tests.test_multi_representation_video_nystrom_mmd import _triple +from tests.test_triple_video_feature_bank import _write_bank, _open +from tests.test_triple_partial_geometry import _trainer, _result +from tests.test_triple_fd_state import _triple_state_only + + +def test_branch_selector_is_explicit_and_fail_closed(): + assert active_mmd_branches() == ("dinov3", *NO_DINO_BRANCHES) + assert active_mmd_branches(list(NO_DINO_BRANCHES)) == NO_DINO_BRANCHES + for invalid in ([], ["vjepa2"], ["vjepa2", "vjepa2"], list(reversed(NO_DINO_BRANCHES)), "vjepa2"): + with pytest.raises(ValueError): + active_mmd_branches(invalid) + + +def test_bank_pair_is_bitwise_same_without_reading_dino_tensors(tmp_path, monkeypatch): + path, manifest = _write_bank(tmp_path) + full = _open(path, manifest) + pair = _open(path, manifest, active_branches=NO_DINO_BRANCHES, + expected_dinov3_checkpoint_sha256=None, expected_dinov3_source_commit=None) + ids = ["sample-2", "sample-0", "sample-2"] + expected = full.load_rows(ids) + import utils.triple_video_feature_bank as bank_module + original_open = bank_module.safe_open + accesses = [] + + class ActiveOnlyOpen: + def __init__(self, *args, **kwargs): + self.handle = original_open(*args, **kwargs) + def __enter__(self): + self.handle.__enter__() + return self + def get_slice(self, name): + accesses.append(name) + assert name != "dinov3", "disabled DINO tensor accessed" + return self.handle.get_slice(name) + def __exit__(self, *args): + return self.handle.__exit__(*args) + + monkeypatch.setattr(bank_module, "safe_open", ActiveOnlyOpen) + actual = pair.load_rows(ids) + assert tuple(actual) == NO_DINO_BRANCHES + assert set(accesses) == set(NO_DINO_BRANCHES) + for name in NO_DINO_BRANCHES: + assert torch.equal(actual[name], expected[name]) + assert pair.content_hash == full.content_hash + + +def test_encoder_initialization_never_calls_dino(): + model = object.__new__(WanKVMMD) + # Populate only cheap frozen-prefix interface fixtures, no real weights. + body = ast.parse(textwrap.dedent(inspect.getsource( + WanKVMMD._initialize_triple_dinov3_vjepa2_videomae_v2_features))) + attributes = next(ast.literal_eval(node.value) for node in ast.walk(body) + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "vjepa_attribute_names" + for t in node.targets)) + for name in attributes: + setattr(model, name, 0) + for name, value in { + "vjepa2_dense_layers": (15,), "vjepa2_dense_num_frames": 64, + "vjepa2_student_source_num_frames": 81, + "vjepa2_student_frame_sampling": "last_contiguous", + "vjepa2_dense_num_tokens": 1024, "vjepa2_dense_feature_dim": 1024, + "videomae_v2_dense_layers": (15,), "videomae_v2_encoder_count": 5, + "videomae_v2_skip_leading_frames": 1, "videomae_v2_segment_num_frames": 16, + "videomae_v2_source_num_frames": 81, "videomae_v2_dense_num_tokens": 256, + "videomae_v2_model_id": "fake-vm", "videomae_v2_reference_model_id": "fake-vm", "videomae_v2_download_manifest_content_hash": "a" * 64, + }.items(): + setattr(model, name, value) + model.vjepa2_encoder = SimpleNamespace() + model.videomae_v2_encoder = SimpleNamespace(dense_feature_dim=1024) + model._initialize_vjepa2_features = Mock() + model._initialize_videomae_v2_features = Mock() + model._bind_dense_runtime_aliases = Mock() + model._initialize_dinov3_features = Mock(side_effect=AssertionError("DINO loaded")) + model._initialize_triple_dinov3_vjepa2_videomae_v2_features( + SimpleNamespace(total_batch_size=256, triple_active_branches=NO_DINO_BRANCHES), "cpu") + model._initialize_dinov3_features.assert_not_called() + assert tuple(model.dual_branch_registry) == NO_DINO_BRANCHES + assert not hasattr(model, "dinov3_encoder") + + +def test_loss_and_gradients_equal_original_minus_dino_keep_divisor_three(): + old = _triple() + pair = MultiRepresentationVideoNystromMMD( + {name: copy.deepcopy(old.branches[name]) for name in NO_DINO_BRANCHES}, + weights=dict.fromkeys(NO_DINO_BRANCHES, 1.0), + normalization_eps=1e-4, aggregation_divisor=3) + rng = torch.Generator().manual_seed(4311) + reference = {name: torch.randn(256, *branch.sample_shape, generator=rng) + for name, branch in old.branches.items()} + indices = torch.arange(128, 256) + leaves = {name: value[128:].clone().requires_grad_(True) for name, value in reference.items()} + teacher = {name: torch.randn(128, *branch.sample_shape, generator=rng, requires_grad=True) + for name, branch in old.branches.items()} + args = dict(gradient_students=leaves, student_reference=reference, + current_teacher=teacher, gradient_student_indices=indices, stage=False) + old_loss, old_metadata = old(**args) + pair_args = {key: ({name: value[name] for name in NO_DINO_BRANCHES} + if isinstance(value, dict) else value) for key, value in args.items()} + new_loss, metadata = pair(**pair_args) + raw_dino, _ = old.branches["dinov3"](**{ + key: (value["dinov3"] if isinstance(value, dict) else value) for key, value in args.items()}) + expected = old_loss - (10 / 3) * raw_dino / (raw_dino.detach() + 1e-4) + torch.testing.assert_close(new_loss, expected, atol=1e-6, rtol=1e-6) + # ×2 remains a separate replay compensation, not an encoder weight. + new_grads = torch.autograd.grad(2 * new_loss, tuple(leaves.values()) + tuple(teacher.values()), + allow_unused=True, retain_graph=True) + old_grads = torch.autograd.grad(2 * old_loss, tuple(leaves.values()), retain_graph=True) + assert new_grads[0] is None + for idx, name in enumerate(NO_DINO_BRANCHES, start=1): + torch.testing.assert_close(new_grads[idx], old_grads[idx], rtol=1e-6, atol=1e-7) + assert torch.isfinite(new_grads[idx]).all() and new_grads[idx].norm() > 0 + branch = metadata["branches"][name] + assert torch.equal(branch["raw_mmd"], old_metadata["branches"][name]["raw_mmd"]) + assert int(branch["repulsion_denominator"]) == 65536 + assert int(branch["current_teacher_attraction_denominator"]) == 32768 + assert float(branch["fixed_teacher_attraction_normalized_weight"]) == pytest.approx(2 / 3) + assert float(branch["current_teacher_attraction_normalized_weight"]) == pytest.approx(1 / 3) + assert all(value is None for value in new_grads[3:]) + assert tuple(metadata["branches"]) == NO_DINO_BRANCHES + assert not any(parameter.requires_grad for parameter in pair.parameters()) + + +def test_partial_geometry_uses_actual_pair_registry(): + trainer = _trainer() + trainer.model.dual_branch_registry = dict.fromkeys(NO_DINO_BRANCHES, {}) + trainer._validate_partial_mmd_geometry(_result(NO_DINO_BRANCHES)) + with pytest.raises(RuntimeError, match="branch set mismatch"): + trainer._validate_partial_mmd_geometry(_result()) + + +def test_exact_resume_rejects_original_three_branch_state(): + old = _triple_state_only() + pair = _triple_state_only() + del pair.dual_branch_registry["dinov3"] + own_state = pair.fd_state_dict() + pair.load_fd_state_dict(copy.deepcopy(own_state)) + with pytest.raises(ValueError): + pair.load_fd_state_dict(old.fd_state_dict()) + + +def test_objective_binding_does_not_require_any_dino_reference(tmp_path, monkeypatch): + import hashlib + import losses.video_nystrom_mmd as single_module + import losses.multi_encoder_mmd as multi_module + paths = {name: tmp_path / f"{name}.pt" for name in NO_DINO_BRANCHES} + for path in paths.values(): + path.write_bytes(b"small test reference stub") + digest = hashlib.sha256(b"small test reference stub").hexdigest() + sources = {name: {"triple_teacher_bank_content_hash": "a" * 64, + "source_manifest_sha256": "b" * 64, + "selected_samples": 8000, "branch_name": name} + for name in NO_DINO_BRANCHES} + + class ReferenceStub: + def __init__(self, name, **kwargs): + self.sample_shape = (1280, 1024) if name == "videomae_v2" else (1024, 1024) + self.reference_metadata = {"reference": {"source": sources[name]}} + self.reference_hash = name + self.landmarks = torch.empty(1) + def to(self, **kwargs): + return self + + monkeypatch.setattr(single_module, "VideoNystromMMD", ReferenceStub) + loaded = [] + def load_pack(path, **kwargs): + name = Path(path).stem + assert name == "videomae_v2", "unexpected reference (DINO) was loaded" + loaded.append(name) + return name + monkeypatch.setattr(single_module, "load_reference_pack", load_pack) + captured = {} + def bind(branches, **kwargs): + captured.update(branches=branches, **kwargs) + return SimpleNamespace(to=lambda **unused: SimpleNamespace()) + monkeypatch.setattr(multi_module, "MultiEncoderMMD", bind) + model = object.__new__(WanKVMMD) + for name, value in { + "uses_fresh_video_gradcache": True, "latent_self_normalize": True, + "nystrom_attraction_ema_enabled": False, "nystrom_repulsion_fifo_enabled": False, + "current_teacher_attraction_enabled": True, + "nystrom_attraction_weight": 2.0, "current_teacher_attraction_weight": 1.0, + "expected_current_teacher_samples": 128, "latent_norm_eps": 1e-4, + "vjepa2_nystrom_reference_path": str(paths["vjepa2"]), + "videomae_v2_encoder": SimpleNamespace(dense_raw_tokens=1568, dense_feature_dim=1024), + "videomae_v2_dense_num_tokens": 256, "videomae_v2_model_id": "fake-vm", "videomae_v2_reference_model_id": "fake-vm", + "latent_mmd": ReferenceStub("vjepa2"), + "dual_branch_registry": {name: {} for name in NO_DINO_BRANCHES}, + }.items(): + setattr(model, name, value) + model._distributed_file_sha256 = lambda path: hashlib.sha256(Path(path).read_bytes()).hexdigest() + model._dense_config_value = lambda *args: digest + model._dinov3_feature_metadata = Mock(side_effect=AssertionError("DINO metadata requested")) + args = SimpleNamespace( + triple_active_branches=NO_DINO_BRANCHES, + videomae_v2_nystrom_reference_path=str(paths["videomae_v2"]), + videomae_v2_nystrom_reference_sha256=digest, + triple_teacher_bank_content_hash="a" * 64, matched_manifest_sha256="b" * 64, + matched_manifest_samples=8000) + model._replace_with_triple_nystrom_objective(args, "cpu") + assert loaded == ["videomae_v2"] + assert tuple(captured["branches"]) == NO_DINO_BRANCHES + assert captured["weights"] == dict.fromkeys(NO_DINO_BRANCHES, 1) + assert captured["aggregation_divisor"] == 3 + assert captured["normalization_eps"] == 1e-4 + assert not hasattr(model, "dinov3_nystrom_reference_path") + + +def test_replay_sums_two_encoder_cotangents_and_calls_vae_once(monkeypatch): + import model.wan_kv_mmd as model_module + model = object.__new__(WanKVMMD) + model.vjepa2_dense_gradient_checkpointing = True + model.videomae_v2_dense_gradient_checkpointing = True + model.independent_first_frame = False + model.dense_log_prefix = "triple_dense" + model.vae = object() + model.dual_branch_registry = {name: {"sample_shape": (1, 2)} for name in NO_DINO_BRANCHES} + model._dual_token_indices = lambda device: {name: torch.arange(1) for name in NO_DINO_BRANCHES} + expected = {name: torch.ones(1, 1, 2) for name in NO_DINO_BRANCHES} + cotangents = {name: torch.ones(1, 1, 2) for name in NO_DINO_BRANCHES} + pixels = torch.zeros(1, 81, 3, 1, 1) + latent = torch.zeros(1, 1, 1, 1, 1) + model._replay_dinov3_dense_to_pixels = Mock(side_effect=AssertionError("DINO replay called")) + model._replay_dense_encoder_vjp = Mock(return_value=(expected["vjepa2"], torch.ones_like(pixels))) + vm_replay = Mock(return_value=(expected["videomae_v2"], 2 * torch.ones_like(pixels))) + model.videomae_v2_encoder = SimpleNamespace(replay_dense_vjp=vm_replay) + def decoder(vae, z, combined, **kwargs): + assert torch.equal(combined, 3 * torch.ones_like(pixels)) + return torch.ones_like(z) * combined.sum() + decoder_mock = Mock(side_effect=decoder) + monkeypatch.setattr(model_module, "replay_decoder_vjp", decoder_mock) + descriptor = dict(rng_state={}, latent_shape=tuple(latent.shape), + generation_descriptor={}, student_latent=latent, + cached_student_video01=pixels) + result = model._dual_gradcache_prepare_replay( + replay_descriptor=descriptor, expected_features=expected, + feature_cotangent=cotangents, + replay_atol=1e-5, replay_rtol=1e-4) + assert decoder_mock.call_count == vm_replay.call_count == model._replay_dense_encoder_vjp.call_count == 1 + model._replay_dinov3_dense_to_pixels.assert_not_called() + assert result["replay_log"]["triple_dense_encoder_vjp_count"].item() == 2 + assert result["replay_log"]["triple_dense_vae_vjp_count"].item() == 1 + assert result["latent_cotangent"].isfinite().all() + assert not any("dinov3" in key for key in result["replay_log"]) diff --git a/14B/code/tests/test_no_dinov3_filtered_bank.py b/14B/code/tests/test_no_dinov3_filtered_bank.py new file mode 100644 index 0000000000000000000000000000000000000000..05fbadb6b54bb452774dd209fc4bbb155fbdeb38 --- /dev/null +++ b/14B/code/tests/test_no_dinov3_filtered_bank.py @@ -0,0 +1,80 @@ +import copy +import json +from pathlib import Path + +import pytest +import torch +from omegaconf import OmegaConf +from utils.config import load_config +from safetensors.torch import load_file, save_file + +from tests.test_triple_video_feature_bank import _write_bank, _open +from utils.active_mmd_branches import NO_DINO_BRANCHES +from utils.triple_video_feature_bank import canonical_sha256, file_sha256, NO_DINO_BANK_KIND + + +def _pair_bank(tmp_path): + path, manifest = _write_bank(tmp_path) + manifest['kind'] = NO_DINO_BANK_KIND + del manifest['branches']['dinov3'] + for shard in manifest['shards']: + tensor_path = tmp_path / shard['path'] + tensors = load_file(tensor_path) + del tensors['dinov3'] + save_file(tensors, tensor_path, metadata={'content_hash': shard['content_hash']}) + shard['sha256'] = file_sha256(tensor_path) + manifest['content_hash'] = canonical_sha256({k: v for k, v in manifest.items() if k != 'content_hash'}) + path.write_text(json.dumps(manifest, sort_keys=True) + '\n') + return path, manifest + + +def test_real_pair_bank_without_placeholder_dino(tmp_path): + path, manifest = _pair_bank(tmp_path) + pair = _open(path, manifest, active_branches=NO_DINO_BRANCHES, + expected_dinov3_checkpoint_sha256=None, expected_dinov3_source_commit=None) + data = pair.load_rows(['sample-2', 'sample-0']) + assert tuple(data) == NO_DINO_BRANCHES + assert torch.equal(data['vjepa2'][:, 0, 0], torch.tensor([2., 0.], dtype=torch.float16)) + assert torch.equal(data['videomae_v2'][:, 0, 0], torch.tensor([12., 10.], dtype=torch.float16)) + with pytest.raises(ValueError, match='explicit no-DINO'): + _open(path, manifest) + with pytest.raises(ValueError, match='DINOv3 identity requested'): + _open(path, manifest, active_branches=NO_DINO_BRANCHES) + + +def test_materializer_changes_only_data_and_preserves_all_hyperparameters(tmp_path, monkeypatch): + from tools.materialize_no_dinov3_filtered_config import main + bank_path, bank = _pair_bank(tmp_path) + bank['source_manifest'].update(path='/data/new.jsonl', source_csv_sha256='c' * 64, + sample_identity_sha256='d' * 64) + bank['content_hash'] = canonical_sha256({k: v for k, v in bank.items() if k != 'content_hash'}) + bank_path.write_text(json.dumps(bank, sort_keys=True) + '\n') + refs = {} + for name in NO_DINO_BRANCHES: + path = tmp_path / f'{name}.pt' + path.write_bytes(b'test pack') + refs[name] = {'path': str(path), 'sha256': file_sha256(path)} + refs_path = tmp_path / 'references.json' + refs_path.write_text(json.dumps({'bank_content_hash': bank['content_hash'], + 'bank_manifest_sha256': file_sha256(bank_path), 'references': refs})) + # This helper is an optional two-encoder ablation, not the released recipe. + recipe = Path(__file__).resolve().parents[2] / 'configs/train.yaml' + ablation = load_config(recipe) + ablation.triple_active_branches = ['vjepa2', 'videomae_v2'] + base = tmp_path / 'ablation.yaml' + OmegaConf.save(ablation, base) + output = tmp_path / 'train.yaml' + monkeypatch.setattr('sys.argv', ['materialize', '--base-config', str(base), + '--bank-manifest', str(bank_path), '--reference-manifest', str(refs_path), + '--dataset-root', str(tmp_path), '--output', str(output), '--expected-count', '3']) + main() + old = OmegaConf.to_container(load_config(base)) + new = OmegaConf.to_container(load_config(output)) + for key in ('lr', 'lr_final', 'lr_schedule_total_steps', 'max_steps', 'log_iters', + 'checkpoint_keep_last', 'generator_ckpt', 'resume_checkpoint', 'triple_active_branches', + 'triple_aggregation_divisor', 'triple_vjepa2_weight', 'triple_videomae_v2_weight', + 'fresh_video_generator_grad_sample_scale', 'partial_mmd_geometry'): + assert new[key] == old[key] + assert new['matched_manifest_samples'] == 3 + assert new['triple_teacher_bank_manifest'] == str(bank_path) + assert new['triple_teacher_bank_content_hash'] != old['triple_teacher_bank_content_hash'] diff --git a/14B/code/tests/test_readable_config.py b/14B/code/tests/test_readable_config.py new file mode 100644 index 0000000000000000000000000000000000000000..3a9b902e759b3ea3f403a742de1a6a73974cbbb5 --- /dev/null +++ b/14B/code/tests/test_readable_config.py @@ -0,0 +1,98 @@ +"""Exercise readable config inputs through the actual public entry points.""" +import importlib +import importlib.util +from pathlib import Path +import shutil +import subprocess +import sys +from types import SimpleNamespace + +import pytest +import yaml +from utils.config import load_config, resolve_config_aliases + +ROOT = Path(__file__).resolve().parents[2] +RECIPE = "three_encoder.yaml" if ROOT.name == "1.3B" else "train.yaml" + + +def test_readable_override_wins_over_legacy_defaults_without_losing_metadata(tmp_path): + defaults = tmp_path / "defaults.yaml" + defaults.write_text("total_batch_size: 8\nmax_steps: 99\nmetadata: {revision: frozen}\n") + path = tmp_path / "run.yaml" + path.write_text("distribution_batch_size: 256\ntraining_updates: 1\nreference_minibatch_size: 128\n") + cfg = load_config(path, defaults) + assert (cfg.total_batch_size, cfg.max_steps, cfg.expected_current_teacher_samples) == (256, 1, 128) + assert cfg.metadata.revision == "frozen" + # Existing runtime YAML and arbitrary metadata survive the boundary unchanged. + legacy = {"total_batch_size": 256, "metadata": {"shape": [1, 2, 3]}} + resolved = resolve_config_aliases(legacy) + resolved["metadata"]["shape"].append(4) + assert legacy["metadata"]["shape"] == [1, 2, 3] + + +@pytest.mark.parametrize("step_flag", ["--max_steps", "--training-updates"]) +def test_training_entry_resolves_recipe_before_constructing_trainer(monkeypatch, tmp_path, step_flag): + captured = {} + class RecordingTrainer: + def __init__(self, config): + captured["config"] = config + def train(self): + captured["trained"] = True + + monkeypatch.setitem(sys.modules, "trainer.distribution_matching", SimpleNamespace(DistributionMatchingTrainer=RecordingTrainer)) + spec = importlib.util.spec_from_file_location("readable_training_entry", ROOT / "code/train.py") + entry = importlib.util.module_from_spec(spec) + spec.loader.exec_module(entry) + monkeypatch.setattr(entry.wandb, "finish", lambda: None) + monkeypatch.setattr(entry.dist, "is_initialized", lambda: False) + monkeypatch.chdir(ROOT / "code") + monkeypatch.setattr(sys, "argv", ["train.py", "--config_path", str(ROOT / "configs" / RECIPE), + "--logdir", str(tmp_path), "--disable-wandb", step_flag, "1"]) + entry.main() + cfg = captured["config"] + assert captured["trained"] + assert cfg.trainer == "score_distillation" and cfg.distribution_loss == "wan_kv_mmd" + assert cfg.total_batch_size == 256 and cfg.expected_current_teacher_samples == 128 + assert cfg.max_steps == 1 and cfg.lr == 2.5e-6 + assert cfg.triple_aggregation_divisor == 3.0 + # Stopping early must not shorten the scientific learning-rate schedule. + if ROOT.name == "1.3B": + assert cfg.generator_lr_cosine_end_step == 125 + else: + assert cfg.lr_schedule_total_steps == 200 + + +def test_prepare_config_preserves_recipe_and_only_relocates_assets(tmp_path): + sandbox = tmp_path / "release" + for folder in ("configs", "scripts", "code/utils"): + (sandbox / folder).mkdir(parents=True, exist_ok=True) + for source in (Path("configs") / RECIPE, Path("scripts/prepare_config.py"), Path("code/utils/config.py")): + shutil.copy2(ROOT / source, sandbox / source) + assets = tmp_path / "assets with spaces" + command = [sys.executable, str(sandbox / "scripts/prepare_config.py"), "--asset-root", str(assets)] + if ROOT.name == "14B": + command.append("--inference-only") # No external feature-bank files are needed to relocate paths. + subprocess.run(command, check=True, capture_output=True, text=True) + original = resolve_config_aliases(yaml.safe_load((ROOT / "configs" / RECIPE).read_text())) + prefix = "/path/to/video-generation-assets" + expected = {k: str(assets) + v[len(prefix):] if isinstance(v, str) and v.startswith(prefix + "/") else v + for k, v in original.items()} + actual = yaml.safe_load((sandbox / "configs/local.yaml").read_text()) + assert actual == expected + assert list(actual) == list(expected) + + +def test_legacy_modules_share_implementation_and_method_aliases(): + for old, new, old_class, new_class in ( + ("model.wan_kv_mmd", "model.distribution_matching", "WanKVMMD", "DistributionMatchingModel"), + ("trainer.distillation", "trainer.distribution_matching", "Trainer", "DistributionMatchingTrainer"), + ("losses.multi_representation_video_nystrom_mmd", "losses.multi_encoder_mmd", "MultiRepresentationVideoNystromMMD", "MultiEncoderMMD"), + ("trainer.multi_representation_fresh_video_gradcache", "trainer.feature_gradient_replay", "MultiRepresentationFreshVideoGradCache", "FeatureGradientReplay"), + ): + legacy, current = importlib.import_module(old), importlib.import_module(new) + assert legacy is current + assert getattr(legacy, old_class) is getattr(current, new_class) + from model.distribution_matching import DistributionMatchingModel + from trainer.distribution_matching import DistributionMatchingTrainer + assert DistributionMatchingModel.fresh_video_gradcache_prepare_replay is DistributionMatchingModel.prepare_latent_gradients + assert DistributionMatchingTrainer._fresh_video_generator_update is DistributionMatchingTrainer._run_distribution_matching_step diff --git a/14B/code/tests/test_release_schedule.py b/14B/code/tests/test_release_schedule.py new file mode 100644 index 0000000000000000000000000000000000000000..cc9420db1b7c879a4cbeba254ce164b4924b868c --- /dev/null +++ b/14B/code/tests/test_release_schedule.py @@ -0,0 +1,20 @@ +from types import SimpleNamespace +import pytest +from trainer.distillation import _cosine_learning_rate, _learning_rate_schedule_identity + + +def test_step80_keeps_the_original_cosine_curve(): + cfg = SimpleNamespace(lr_schedule="cosine", lr=2.5e-6, lr_final=5e-7, + lr_schedule_total_steps=200, max_steps=80) + early = _learning_rate_schedule_identity(cfg) + cfg.max_steps = 200 + original = _learning_rate_schedule_identity(cfg) + assert early == original + for update in range(80): + assert _cosine_learning_rate(update, start=early["start"], end=early["end"], total_updates=early["total_updates"]) == _cosine_learning_rate(update, start=2.5e-6, end=5e-7, total_updates=200) + assert _cosine_learning_rate(79, start=2.5e-6, end=5e-7, total_updates=200) > 5e-7 + cfg.max_steps = 1 + assert _learning_rate_schedule_identity(cfg) == original + cfg.max_steps = 201 + with pytest.raises(ValueError): + _learning_rate_schedule_identity(cfg) diff --git a/14B/code/tests/test_triple_fd_state.py b/14B/code/tests/test_triple_fd_state.py new file mode 100644 index 0000000000000000000000000000000000000000..a539acd6594bbb3e824d62d06e51a0a43810abe2 --- /dev/null +++ b/14B/code/tests/test_triple_fd_state.py @@ -0,0 +1,70 @@ +import copy +from types import SimpleNamespace + +import pytest + +from model.wan_kv_mmd import WanKVMMD + + +def _triple_state_only(): + model = object.__new__(WanKVMMD) + model.feature_type = "triple_dinov3_vjepa2_videomae_v2_dense_latent" + model.is_dinov3_dense = False + model.is_triple_dense = True + model.uses_fresh_video_gradcache = True + model.dense_feature_prefix = "triple" + model.dense_encoder_name = "triple" + model.dense_log_prefix = "triple_dense" + model.vjepa2_multilayer_enabled = False + model.mmd_estimator = "nystrom_exact_repulsion" + model.triple_teacher_bank_content_hash = "a" * 64 + model.dual_teacher_bank_content_hash = "a" * 64 + model.dual_branch_registry = { + "dinov3": { + "reference_path": "/data/dino.pt", + "reference_hash": "b" * 64, + "nystrom_landmarks": 256, + }, + "vjepa2": { + "reference_path": "/data/vj.pt", + "reference_hash": "c" * 64, + "nystrom_landmarks": 1024, + }, + "videomae_v2": { + "reference_path": "/data/vm.pt", + "reference_hash": "d" * 64, + "nystrom_landmarks": 256, + }, + } + model.latent_mmd = SimpleNamespace( + attraction_ema_enabled=False, + repulsion_fifo_enabled=False, + ) + model.distribution_state_pending = lambda: False + model._fd_metadata = lambda: { + "feature_type": model.feature_type, + "teacher_bank_content_hash": model.triple_teacher_bank_content_hash, + } + return model + + +def test_triple_fd_state_round_trip_uses_own_namespace_and_version_10(): + model = _triple_state_only() + state = model.fd_state_dict() + assert state["triple_dense_nystrom_mmd_version"] == 10 + assert "vjepa2_dense_nystrom_mmd_version" not in state + model.load_fd_state_dict(copy.deepcopy(state)) + assert model.can_load_initialization_fd_state(state) + + +def test_triple_fd_state_rejects_foreign_or_wrong_version(): + model = _triple_state_only() + state = model.fd_state_dict() + wrong = copy.deepcopy(state) + wrong["triple_dense_nystrom_mmd_version"] = 9 + with pytest.raises(ValueError, match="triple_dense_nystrom_mmd_version"): + model.load_fd_state_dict(wrong) + foreign = copy.deepcopy(state) + foreign["vjepa2_dense_nystrom_mmd_version"] = 10 + with pytest.raises(ValueError, match="another dense feature namespace"): + model.load_fd_state_dict(foreign) diff --git a/14B/code/tests/test_triple_feature_metadata_contract.py b/14B/code/tests/test_triple_feature_metadata_contract.py new file mode 100644 index 0000000000000000000000000000000000000000..3db936bdca19a2ba2df0cb8a33f3007a09d8a007 --- /dev/null +++ b/14B/code/tests/test_triple_feature_metadata_contract.py @@ -0,0 +1,120 @@ +import torch + +from model.wan_kv_mmd import WanKVMMD +from video_fd.encoders.vjepa2_dense_metadata import ( + canonical_vjepa2_dense_feature_metadata, + uniform_dense_token_indices, +) + + +def test_triple_vjepa_metadata_matches_bank_last_64_contract(): + model = object.__new__(WanKVMMD) + model.feature_type = "triple_dinov3_vjepa2_videomae_v2_dense_latent" + model.dense_feature_prefix = "triple" + model.dense_encoder_name = "triple" + model.dense_log_prefix = "triple_dense" + model.vjepa2_reference_model_id = "vjepa2-vitl-fpc64-256" + model.vjepa2_model_id = "/unused/local/model" + model.vjepa2_dense_layer = 15 + model.vjepa2_dense_layers = (15,) + model.vjepa2_dense_num_frames = 64 + model.vjepa2_dense_source_num_frames = 81 + model.vjepa2_dense_frame_sampling = "last_contiguous" + model.vjepa2_dense_resize_short_side = 292 + model.vjepa2_dense_crop_size = 256 + model.vjepa2_dense_raw_tokens = 2048 + model.vjepa2_dense_num_tokens = 1024 + model.vjepa2_dense_feature_dim = 1024 + indices = uniform_dense_token_indices(2048, 1024) + actual = model._vjepa2_feature_metadata(indices) + expected = canonical_vjepa2_dense_feature_metadata( + model_id="vjepa2-vitl-fpc64-256", + layer_index=15, + num_frames=64, + resize_short_side=292, + crop_size=256, + raw_tokens=2048, + num_tokens=1024, + feature_dim=1024, + token_indices=indices, + frame_sampling="last_contiguous", + source_num_frames=81, + ) + assert actual == expected + assert actual["selected_frame_start"] == 17 + assert actual["selected_frame_end_inclusive"] == 80 + + +def test_dual_dinov3_vjepa_metadata_uses_explicit_uniform_indices_by_default(): + model = object.__new__(WanKVMMD) + model.feature_type = "dual_dinov3_vjepa2_dense_latent" + model.dense_feature_prefix = "dinov3_vjepa2" + model.dense_encoder_name = "dinov3_vjepa2" + model.dense_log_prefix = "dinov3_vjepa2_dense" + model.vjepa2_reference_model_id = "vjepa2-vitl-fpc64-256" + model.vjepa2_model_id = "/unused/local/model" + model.vjepa2_dense_layer = 15 + model.vjepa2_dense_layers = (15,) + model.vjepa2_dense_num_frames = 64 + model.vjepa2_dense_source_num_frames = 81 + model.vjepa2_dense_frame_sampling = "center_contiguous" + model.vjepa2_dense_resize_short_side = 292 + model.vjepa2_dense_crop_size = 256 + model.vjepa2_dense_raw_tokens = 2048 + model.vjepa2_dense_num_tokens = 1024 + model.vjepa2_dense_feature_dim = 1024 + actual = model._vjepa2_feature_metadata() + indices = uniform_dense_token_indices(2048, 1024) + expected = canonical_vjepa2_dense_feature_metadata( + model_id="vjepa2-vitl-fpc64-256", + layer_index=15, + num_frames=64, + resize_short_side=292, + crop_size=256, + raw_tokens=2048, + num_tokens=1024, + feature_dim=1024, + token_indices=indices, + frame_sampling="last_contiguous", + source_num_frames=81, + ) + assert actual == expected + assert actual["token_alignment"] == "same_explicit_indices_and_order" + + +def test_frozen_videomae_identity_never_accesses_old_mount(monkeypatch): + from video_fd.encoders import videomae_v2_dense_metadata as vm + identity = "/unavailable/reference/model" + expected = vm.canonical_videomae_v2_dense_feature_metadata( + model_id=identity, layer_index=15, num_tokens=256, + model_id_is_reference=True) + def forbidden(*args, **kwargs): + raise AssertionError("Frozen reference identity was treated as a local path") + monkeypatch.setattr(vm.os.path, "exists", forbidden) + monkeypatch.setattr(vm.os.path, "realpath", forbidden) + actual = vm.canonical_videomae_v2_dense_feature_metadata( + model_id=identity, layer_index=15, num_tokens=256, + model_id_is_reference=True) + assert actual == expected + assert actual["model_id"] == identity + + +def test_dinov3_relocation_preserves_frozen_metadata_without_changing_load_paths(): + import hashlib + import json + from types import SimpleNamespace + model = object.__new__(WanKVMMD) + torch.nn.Module.__init__(model) + metadata = dict(feature_type='dinov3_dense_latent', model_id='dinov3', + raw_tokens=5120, num_tokens=1024, feature_dim=1024, matrix_shape=[1024, 1024], + repo_path='/relocated/models/dinov3-src', weight_path='/relocated/models/weights.pth') + model.dinov3_encoder = SimpleNamespace(feature_metadata=lambda: dict(metadata)) + model.dinov3_model_id = 'dinov3' + model.dinov3_dense_raw_tokens = 5120 + model.dinov3_dense_num_tokens = model.dinov3_dense_feature_dim = 1024 + model.dinov3_reference_paths = dict(repo_path='/frozen/models/dinov3-src', weight_path='/frozen/models/weights.pth') + expected = {**metadata, **model.dinov3_reference_paths} + expected['feature_contract_sha256'] = hashlib.sha256(json.dumps(expected, + sort_keys=True, separators=(',', ':'), ensure_ascii=True, allow_nan=False).encode()).hexdigest() + assert model._dinov3_feature_metadata() == expected + assert model.dinov3_encoder.feature_metadata()['repo_path'] == '/relocated/models/dinov3-src' diff --git a/14B/code/tests/test_triple_partial_geometry.py b/14B/code/tests/test_triple_partial_geometry.py new file mode 100644 index 0000000000000000000000000000000000000000..9df68dd029b1104045551ffff40045f81614478b --- /dev/null +++ b/14B/code/tests/test_triple_partial_geometry.py @@ -0,0 +1,142 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +from trainer.distillation import Trainer + + +def _branch(): + return { + "repulsion_denominator": torch.tensor(65536), + "fixed_teacher_attraction_denominator": torch.tensor(256), + "current_teacher_attraction_denominator": torch.tensor(32768), + "current_teacher_self_kernel_denominator": torch.tensor(16384), + "full_student_partial_gradient": torch.tensor(1), + } + + +def _trainer(): + trainer = object.__new__(Trainer) + trainer.fresh_video_total_samples = 256 + trainer.fresh_video_generator_grad_global_samples = 128 + trainer.fresh_video_full_student_partial_gradient = True + trainer.fresh_video_generator_grad_sample_scale = 2.0 + trainer.is_main_process = False + trainer.step = 0 + trainer.config = SimpleNamespace( + partial_mmd_normalization_identity=( + "full_student_population_denominators_v1"), + partial_mmd_kss_denominator=65536, + partial_mmd_fixed_attraction_denominator=256, + partial_mmd_live_cross_denominator=32768, + partial_mmd_teacher_self_denominator=16384, + partial_mmd_gradient_compensation=2.0, + ) + trainer.model = SimpleNamespace( + feature_type="triple_dinov3_vjepa2_videomae_v2_dense_latent", + current_teacher_attraction_enabled=True, + nystrom_repulsion_fifo_enabled=False, + nystrom_attraction_ema_enabled=False, + vjepa2_dense_layers=(15,), + dense_layer=15, + ) + return trainer + + +def _result(branch_names=("dinov3", "vjepa2", "videomae_v2")): + branches = {name: _branch() for name in branch_names} + metadata = { + "branches": branches, + "gradcache_kss_denominator": torch.tensor(65536), + "gradcache_full_student_partial_gradient": torch.tensor(1), + } + return SimpleNamespace( + metadata=metadata, + global_samples=256, + gradient_global_samples=128, + current_teacher_global_samples=128, + ) + + +def test_triple_geometry_validator_accepts_exact_three_branch_contract(): + _trainer()._validate_partial_mmd_geometry(_result()) + + +def test_triple_geometry_validator_rejects_legacy_dual_branch_set(): + with pytest.raises(RuntimeError, match="branch set mismatch"): + _trainer()._validate_partial_mmd_geometry( + _result(("vjepa2", "videomae_v2"))) + + +def test_release_config_matches_execution_geometry(): + import yaml + from utils.config import resolve_config_aliases + config = resolve_config_aliases(yaml.safe_load((Path(__file__).resolve().parents[2] / "configs/train.yaml").read_text())) + assert config["max_steps"] == 80 + assert config["lr_schedule_total_steps"] == 200 + assert config["triple_active_branches"] == ["dinov3", "vjepa2", "videomae_v2"] + assert config["triple_dinov3_weight"] == 10.0 + assert config["matched_manifest_samples"] == 8000 + assert config["triple_aggregation_divisor"] == 3.0 + assert config["batch_size"] == 2 + assert config["fresh_video_logical_micro_batch_size"] == 16 + assert config["fresh_video_physical_replay_batch_size"] == 1 + assert config["partial_mmd_geometry"] == [256, 128, 128] + assert config["optimizer_state_cpu_offload_during_backward"] + assert config["generator_replay_activation_cpu_offload"] + + +def test_logical_cotangent_is_sliced_without_rescaling_for_b1_replay(): + trainer = object.__new__(Trainer) + trainer.fresh_video_physical_replay_batch_size = 1 + trainer.fresh_video_logical_micro_batch_size = 2 + descriptor = { + "rng_state": {"opaque": True}, + "latent_shape": (2, 3, 1, 1, 1), + "generation_descriptor": { + "version": 2, + "num_generated_blocks": 1, + "noise": torch.arange(6).reshape(2, 3, 1, 1, 1), + "trajectory_seeds": torch.tensor([11, 22]), + }, + "student_latent": torch.arange(6).reshape(2, 3, 1, 1, 1), + } + physical_entry = { + "replay_descriptor": descriptor, + "image_or_video_shape": [2, 3, 1, 1, 1], + "conditional_dict": { + "prompt_embeds": torch.arange(8).reshape(2, 4), + }, + "unconditional_dict": { + "prompt_embeds": torch.arange(8, 16).reshape(2, 4), + }, + } + logical_entry = { + "features": {"vjepa2": torch.arange(12).reshape(2, 2, 3)}, + "physical_entries": (physical_entry,), + } + cotangent = { + "vjepa2": torch.arange(12, 24).reshape(2, 2, 3), + } + units = trainer._split_fresh_video_replay_units( + logical_entry, cotangent) + assert len(units) == 2 + for index, unit in enumerate(units): + assert unit["replay_descriptor"]["latent_shape"][0] == 1 + assert unit["image_or_video_shape"][0] == 1 + torch.testing.assert_close( + unit["cotangent"]["vjepa2"], + cotangent["vjepa2"][index:index + 1], + rtol=0, + atol=0, + ) + torch.testing.assert_close( + unit["replay_descriptor"]["generation_descriptor"][ + "trajectory_seeds"], + descriptor["generation_descriptor"][ + "trajectory_seeds"][index:index + 1], + rtol=0, + atol=0, + ) diff --git a/14B/code/tests/test_triple_pixel_cotangent_accumulation.py b/14B/code/tests/test_triple_pixel_cotangent_accumulation.py new file mode 100644 index 0000000000000000000000000000000000000000..84e4c95f49f23c3b33b8cacad08927157dcbc471 --- /dev/null +++ b/14B/code/tests/test_triple_pixel_cotangent_accumulation.py @@ -0,0 +1,34 @@ +import pytest +import torch + +from model.wan_kv_mmd import _accumulate_fp32_pixel_cotangent + + +def test_pixel_cotangents_accumulate_immediately_in_fp32(): + accumulated = None + shape = None + expected = torch.zeros(2, 3, 4, dtype=torch.float32) + norms = [] + for index, dtype in enumerate( + (torch.bfloat16, torch.float16, torch.float32), start=1 + ): + value = torch.full((2, 3, 4), float(index), dtype=dtype) + expected.add_(value.float()) + accumulated, shape, norm = _accumulate_fp32_pixel_cotangent( + accumulated, value, shape, branch_name=f"branch{index}") + norms.append(norm) + assert accumulated.dtype == torch.float32 + torch.testing.assert_close(accumulated, expected) + assert shape == (2, 3, 4) + assert all(item.ndim == 0 for item in norms) + + +def test_pixel_cotangent_accumulation_rejects_shape_and_nonfinite(): + first, shape, _ = _accumulate_fp32_pixel_cotangent( + None, torch.ones(1, 2), None, branch_name="first") + with pytest.raises(ValueError, match="shapes differ"): + _accumulate_fp32_pixel_cotangent( + first, torch.ones(1, 3), shape, branch_name="second") + with pytest.raises(FloatingPointError, match="non-finite"): + _accumulate_fp32_pixel_cotangent( + None, torch.tensor([float("nan")]), None, branch_name="bad") diff --git a/14B/code/tests/test_triple_video_feature_bank.py b/14B/code/tests/test_triple_video_feature_bank.py new file mode 100644 index 0000000000000000000000000000000000000000..d4928020196352dd090a38c7595897e00a6e7d39 --- /dev/null +++ b/14B/code/tests/test_triple_video_feature_bank.py @@ -0,0 +1,222 @@ +import copy +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pytest +import torch +from safetensors.torch import save_file + +from utils.triple_video_feature_bank import ( + BANK_KIND, + BANK_SCHEMA_VERSION, + TripleVideoFeatureBank, + canonical_sha256, + file_sha256, +) +from trainer.distillation import Trainer + + +VJEPA_SHA = "1" * 64 +VIDEOMAE_REVISION = "9981a9c8f77118c421e5228e1b219468a4b0238d" +VIDEOMAE_DOWNLOAD_HASH = "2" * 64 +SOURCE_SHA = "3" * 64 +DINOV3_SHA = "4" * 64 +DINOV3_COMMIT = "6876159a11b4df116f30f667f8c9888617df0751" + + +def _write_bank(root: Path): + samples = [ + {"index": index, "sample_id": f"sample-{index}", "prompt": f"p{index}"} + for index in range(3) + ] + branch_metadata = { + "dinov3": { + "sample_shape": [1024, 1024], + "dtype": "float16", + "feature_contract": {"name": "dinov3-test"}, + "model_identity": { + "checkpoint_sha256": DINOV3_SHA, + "source_commit": DINOV3_COMMIT, + }, + }, + "vjepa2": { + "sample_shape": [1024, 1024], + "dtype": "float16", + "feature_contract": {"name": "vjepa2-test"}, + "model_identity": {"sha256": VJEPA_SHA}, + }, + "videomae_v2": { + "sample_shape": [1280, 1024], + "dtype": "float16", + "feature_contract": {"name": "videomae-test"}, + "model_identity": { + "revision": VIDEOMAE_REVISION, + "download_manifest_content_hash": VIDEOMAE_DOWNLOAD_HASH, + }, + }, + } + shard_dir = root / "shards" + shard_dir.mkdir(parents=True) + shards = [] + for shard_index, (start, end) in enumerate(((0, 2), (2, 3))): + shard_content_hash = canonical_sha256({ + "index": shard_index, + "sample_start": start, + "sample_end": end, + }) + path = shard_dir / f"{shard_index:05d}.safetensors" + save_file( + { + "dinov3": torch.stack([ + torch.full((1024, 1024), float(row + 20), dtype=torch.float16) + for row in range(start, end) + ]), + "vjepa2": torch.stack([ + torch.full((1024, 1024), float(row), dtype=torch.float16) + for row in range(start, end) + ]), + "videomae_v2": torch.stack([ + torch.full((1280, 1024), float(row + 10), dtype=torch.float16) + for row in range(start, end) + ]), + }, + path, + metadata={"content_hash": shard_content_hash}, + ) + shards.append({ + "index": shard_index, + "path": str(path.relative_to(root)), + "sample_start": start, + "sample_end": end, + "sample_ids_sha256": canonical_sha256([ + item["sample_id"] for item in samples[start:end] + ]), + "sha256": file_sha256(path), + "content_hash": shard_content_hash, + }) + payload = { + "schema_version": BANK_SCHEMA_VERSION, + "kind": BANK_KIND, + "sample_count": len(samples), + "source_manifest": {"sha256": SOURCE_SHA}, + "branches": branch_metadata, + "samples": samples, + "shards": shards, + } + manifest = {**payload, "content_hash": canonical_sha256(payload)} + manifest_path = root / "manifest.json" + manifest_path.write_text( + json.dumps(manifest, sort_keys=True) + "\n", encoding="utf-8") + return manifest_path, manifest + + +def _open(manifest_path: Path, manifest: dict, **overrides): + kwargs = { + "expected_manifest_sha256": file_sha256(manifest_path), + "expected_content_hash": manifest["content_hash"], + "expected_source_manifest_sha256": SOURCE_SHA, + "expected_sample_count": 3, + "expected_videomae_revision": VIDEOMAE_REVISION, + "expected_videomae_download_manifest_content_hash": ( + VIDEOMAE_DOWNLOAD_HASH), + "expected_vjepa_model_sha256": VJEPA_SHA, + "expected_dinov3_checkpoint_sha256": DINOV3_SHA, + "expected_dinov3_source_commit": DINOV3_COMMIT, + "expected_shard_size": 2, + "validate_all_shards": True, + } + kwargs.update(overrides) + return TripleVideoFeatureBank(manifest_path, **kwargs) + + +def test_random_access_preserves_order_and_duplicate_sample_ids(tmp_path): + manifest_path, manifest = _write_bank(tmp_path) + bank = _open(manifest_path, manifest) + assert [bank[index]["sample_ids"] for index in (2, 0, 2)] == [ + "sample-2", "sample-0", "sample-2"] + loaded = bank.load_rows(["sample-2", "sample-0", "sample-2"]) + assert tuple(loaded["dinov3"].shape) == (3, 1024, 1024) + assert tuple(loaded["vjepa2"].shape) == (3, 1024, 1024) + assert tuple(loaded["videomae_v2"].shape) == (3, 1280, 1024) + torch.testing.assert_close( + loaded["dinov3"][:, 0, 0], torch.tensor([22, 20, 22]).half()) + torch.testing.assert_close( + loaded["vjepa2"][:, 0, 0], torch.tensor([2, 0, 2]).half()) + torch.testing.assert_close( + loaded["videomae_v2"][:, 0, 0], + torch.tensor([12, 10, 12]).half(), + ) + + +@pytest.mark.parametrize( + "override, message", + [ + ({"expected_videomae_revision": "wrong"}, "revision"), + ({ + "expected_videomae_download_manifest_content_hash": "4" * 64, + }, "download manifest"), + ({"expected_vjepa_model_sha256": "5" * 64}, "V-JEPA2"), + ({"expected_dinov3_checkpoint_sha256": "5" * 64}, "DINOv3"), + ({"expected_dinov3_source_commit": "wrong"}, "DINOv3"), + ({"expected_content_hash": "6" * 64}, "content identity"), + ], +) +def test_model_and_content_identity_mismatches_fail_closed( + tmp_path, override, message +): + manifest_path, manifest = _write_bank(tmp_path) + with pytest.raises(ValueError, match=message): + _open(manifest_path, manifest, **override) + + +def test_changed_shard_hash_fails_before_row_loading(tmp_path): + manifest_path, manifest = _write_bank(tmp_path) + shard = tmp_path / manifest["shards"][0]["path"] + with shard.open("ab") as handle: + handle.write(b"changed") + with pytest.raises(ValueError, match="shard SHA-256 mismatch"): + _open(manifest_path, manifest) + + +def test_counter_sampler_is_rank_major_ordered_and_with_replacement(): + class Dataset: + def __len__(self): + return 3 + + def __getitem__(self, index): + return {"sample_ids": f"sample-{index}", "prompts": f"p{index}"} + + @staticmethod + def collate_fn(items): + return { + "sample_ids": [item["sample_ids"] for item in items], + "prompts": [item["prompts"] for item in items], + } + + trainer = Trainer.__new__(Trainer) + trainer.matched_counter_sampling_enabled = True + trainer.dataset = Dataset() + trainer.config = SimpleNamespace(seed=7, batch_size=1) + trainer.step = 11 + trainer.data_counter = 0 + trainer.fresh_video_total_samples = 8 + trainer.fresh_video_local_samples = 2 + expected = tuple( + int.from_bytes(hashlib.sha256( + f"sf-matched-counter-v1\0{trainer.config.seed}\0{trainer.step}\0{position}" + .encode("ascii") + ).digest()[:8], "little") % len(trainer.dataset) + for position in range(trainer.fresh_video_total_samples) + ) + assert trainer._matched_global_indices_for_step() == expected + # Eight with-replacement draws from a three-row bank must contain repeats. + assert len(set(expected)) < len(expected) + with mock.patch("trainer.distillation.dist.get_rank", return_value=2): + first = trainer._matched_batch_for_microbatch(0) + second = trainer._matched_batch_for_microbatch(1) + assert first["sample_ids"] == [f"sample-{expected[4]}"] + assert second["sample_ids"] == [f"sample-{expected[5]}"] + assert trainer.data_counter == 2 diff --git a/14B/code/tests/test_video_nystrom_partial_mmd.py b/14B/code/tests/test_video_nystrom_partial_mmd.py new file mode 100644 index 0000000000000000000000000000000000000000..3b8ca6e375dcdbd3a71067dbf1ab4b0155df1ae9 --- /dev/null +++ b/14B/code/tests/test_video_nystrom_partial_mmd.py @@ -0,0 +1,464 @@ +import unittest + +import torch + +from losses.video_nystrom_mmd import ( + VideoNystromMMD, + build_video_nystrom_reference, + rbf_frobenius_kernel, +) + + +class TestVideoNystromPartialMMD(unittest.TestCase): + def _pack(self): + reference = torch.randn( + 12, + 1, + 2, + generator=torch.Generator().manual_seed(9100), + ) + return build_video_nystrom_reference( + reference, + num_landmarks=5, + metadata={ + "feature": { + "name": "tiny-partial-video", + "matrix_shape": [1, 2], + }, + "reference": {"dataset": "partial-unit-test"}, + }, + bandwidth=1.6, + kmeans_iterations=4, + ridge=1.0e-5, + chunk_size=3, + ) + + def _matched_objective(self, *, chunk_size: int = 37): + return VideoNystromMMD( + self._pack(), + kernel_chunk_size=chunk_size, + attraction_weight=2.0, + current_teacher_attraction_enabled=True, + current_teacher_attraction_weight=1.0, + ) + + def test_128_by_256_formula_denominators_and_suffix_only_gradient(self): + objective = self._matched_objective() + generator = torch.Generator().manual_seed(9101) + all_students = torch.randn( + 256, 1, 2, generator=generator, requires_grad=True) + gradient_students = all_students[-128:] + teacher = torch.randn( + 128, 1, 2, generator=generator, requires_grad=True) + + loss, metadata = objective( + gradient_students=gradient_students, + student_reference=all_students, + current_teacher=teacher, + stage=False, + ) + + kernel_gs = rbf_frobenius_kernel( + gradient_students, + all_students.detach(), + objective.sigma, + ) + fixed_attraction = ( + rbf_frobenius_kernel( + gradient_students, + objective.landmarks, + objective.sigma, + ) + @ objective.alpha + ).mean() + kernel_gt = rbf_frobenius_kernel( + gradient_students, + teacher.detach(), + objective.sigma, + ) + kernel_tt = rbf_frobenius_kernel( + teacher.detach(), + teacher.detach(), + objective.sigma, + ) + expected = ( + kernel_gs.sum() / float(128 * 256) + - 2.0 + * ( + (2.0 / 3.0) * fixed_attraction + + (1.0 / 3.0) * kernel_gt.sum() / float(128 * 128) + ) + + (2.0 / 3.0) * objective.k_rr + + (1.0 / 3.0) * kernel_tt.sum() / float(128 * 128) + ) + + torch.testing.assert_close( + loss, expected, rtol=2.0e-5, atol=2.0e-6) + torch.testing.assert_close( + metadata["repulsion"], + (kernel_gs.sum() / float(128 * 256)).detach(), + rtol=2.0e-5, + atol=2.0e-6, + ) + self.assertEqual(int(metadata["global_samples"]), 256) + self.assertEqual(int(metadata["gradient_student_samples"]), 128) + self.assertEqual(int(metadata["student_reference_samples"]), 256) + self.assertEqual(int(metadata["current_teacher_samples"]), 128) + self.assertEqual(int(metadata["repulsion_denominator"]), 32768) + self.assertEqual( + int(metadata["student_repulsion_denominator"]), 32768) + self.assertEqual( + int(metadata["current_teacher_attraction_denominator"]), 16384) + self.assertEqual( + int(metadata["current_teacher_self_kernel_denominator"]), 16384) + self.assertEqual( + int(metadata["fixed_teacher_attraction_denominator"]), 128) + self.assertEqual( + int(metadata["partial_student_reference_enabled"]), 1) + self.assertEqual(int(metadata["student_reference_detached"]), 1) + self.assertNotIn("full_student_partial_gradient", metadata) + self.assertAlmostEqual( + float(metadata[ + "fixed_teacher_attraction_normalized_weight"]), + 2.0 / 3.0, + places=7, + ) + self.assertAlmostEqual( + float(metadata[ + "current_teacher_attraction_normalized_weight"]), + 1.0 / 3.0, + places=7, + ) + + actual_student_gradient, actual_teacher_gradient = ( + torch.autograd.grad( + loss, + (all_students, teacher), + allow_unused=True, + retain_graph=True, + ) + ) + expected_student_gradient, = torch.autograd.grad( + expected, all_students) + torch.testing.assert_close( + actual_student_gradient, + expected_student_gradient, + rtol=3.0e-5, + atol=3.0e-6, + ) + self.assertIsNone(actual_teacher_gradient) + torch.testing.assert_close( + actual_student_gradient[:128], + torch.zeros_like(actual_student_gradient[:128]), + rtol=0.0, + atol=0.0, + ) + self.assertGreater( + float(actual_student_gradient[-128:].norm()), 0.0) + self.assertTrue(torch.isfinite(actual_student_gradient).all()) + + def test_full_student_partial_gradient_formula_and_denominators(self): + objective = self._matched_objective() + generator = torch.Generator().manual_seed(9105) + student_reference = torch.randn( + 256, 1, 2, generator=generator, requires_grad=True) + gradient_student_indices = torch.cat([ + torch.arange(rank * 64 + 32, rank * 64 + 64) + for rank in range(4) + ]) + gradient_students = ( + student_reference.detach() + .index_select(0, gradient_student_indices) + .clone() + .requires_grad_(True) + ) + teacher = torch.randn( + 128, 1, 2, generator=generator, requires_grad=True) + + loss, metadata = objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + gradient_student_indices=gradient_student_indices, + stage=False, + ) + + full_students = student_reference.detach().index_copy( + 0, gradient_student_indices, gradient_students) + kernel_ss = rbf_frobenius_kernel( + full_students, full_students, objective.sigma) + fixed_attraction = ( + rbf_frobenius_kernel( + full_students, objective.landmarks, objective.sigma) + @ objective.alpha + ).mean() + kernel_st = rbf_frobenius_kernel( + full_students, teacher.detach(), objective.sigma) + kernel_tt = rbf_frobenius_kernel( + teacher.detach(), teacher.detach(), objective.sigma) + expected = ( + kernel_ss.sum() / float(256 * 256) + - 2.0 + * ( + (2.0 / 3.0) * fixed_attraction + + (1.0 / 3.0) * kernel_st.sum() / float(256 * 128) + ) + + (2.0 / 3.0) * objective.k_rr + + (1.0 / 3.0) * kernel_tt.sum() / float(128 * 128) + ) + + torch.testing.assert_close( + loss, expected, rtol=2.0e-5, atol=2.0e-6) + self.assertEqual(int(metadata["global_samples"]), 256) + self.assertEqual(int(metadata["gradient_student_samples"]), 128) + self.assertEqual(int(metadata["student_reference_samples"]), 256) + self.assertEqual(int(metadata["current_teacher_samples"]), 128) + self.assertEqual(int(metadata["repulsion_denominator"]), 65536) + self.assertEqual( + int(metadata["student_repulsion_denominator"]), 65536) + self.assertEqual( + int(metadata["fixed_teacher_attraction_denominator"]), 256) + self.assertEqual( + int(metadata["current_teacher_attraction_denominator"]), + 32768, + ) + self.assertEqual( + int(metadata["current_teacher_self_kernel_denominator"]), + 16384, + ) + self.assertEqual( + int(metadata["full_student_partial_gradient"]), 1) + self.assertEqual( + int(metadata["student_repulsion_gradient_sides"]), 2) + + actual_gradient, reference_gradient, teacher_gradient = ( + torch.autograd.grad( + loss, + (gradient_students, student_reference, teacher), + allow_unused=True, + retain_graph=True, + ) + ) + expected_gradient, = torch.autograd.grad( + expected, gradient_students, retain_graph=True) + torch.testing.assert_close( + actual_gradient, + expected_gradient, + rtol=3.0e-5, + atol=3.0e-6, + ) + self.assertIsNone(reference_gradient) + self.assertIsNone(teacher_gradient) + self.assertGreater(float(actual_gradient.norm()), 0.0) + self.assertTrue(torch.isfinite(actual_gradient).all()) + + full_repulsion_gradient, = torch.autograd.grad( + kernel_ss.sum() / float(256 * 256), + gradient_students, + retain_graph=True, + ) + rectangular_repulsion = rbf_frobenius_kernel( + gradient_students, student_reference.detach(), objective.sigma + ).sum() / float(128 * 256) + rectangular_repulsion_gradient, = torch.autograd.grad( + rectangular_repulsion, gradient_students) + torch.testing.assert_close( + full_repulsion_gradient, + rectangular_repulsion_gradient, + rtol=3.0e-5, + atol=3.0e-6, + ) + + def test_inputs_are_detached_and_converted_to_fp32_for_kernels(self): + objective = self._matched_objective(chunk_size=2) + generator = torch.Generator().manual_seed(9102) + gradient_students = torch.randn( + 3, 1, 2, generator=generator, requires_grad=True) + student_reference = torch.randn( + 5, + 1, + 2, + generator=generator, + dtype=torch.float64, + requires_grad=True, + ) + teacher = torch.randn( + 3, + 1, + 2, + generator=generator, + dtype=torch.float64, + requires_grad=True, + ) + + loss, _ = objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + stage=False, + ) + student_gradient, reference_gradient, teacher_gradient = ( + torch.autograd.grad( + loss, + (gradient_students, student_reference, teacher), + allow_unused=True, + ) + ) + self.assertEqual(loss.dtype, torch.float32) + self.assertIsNotNone(student_gradient) + self.assertIsNone(reference_gradient) + self.assertIsNone(teacher_gradient) + + def test_legacy_interface_preserves_full_symmetric_formula_and_schema(self): + objective = self._matched_objective(chunk_size=2) + generator = torch.Generator().manual_seed(9103) + generated = torch.randn( + 4, 1, 2, generator=generator, requires_grad=True) + teacher = torch.randn(4, 1, 2, generator=generator) + + loss, metadata = objective( + generated, + current_teacher=teacher, + stage=False, + ) + kernel_gg = rbf_frobenius_kernel( + generated, generated, objective.sigma) + fixed_attraction = ( + rbf_frobenius_kernel( + generated, objective.landmarks, objective.sigma) + @ objective.alpha + ).mean() + kernel_gt = rbf_frobenius_kernel( + generated, teacher, objective.sigma) + kernel_tt = rbf_frobenius_kernel( + teacher, teacher, objective.sigma) + expected = ( + kernel_gg.mean() + - 2.0 + * ( + (2.0 / 3.0) * fixed_attraction + + (1.0 / 3.0) * kernel_gt.mean() + ) + + (2.0 / 3.0) * objective.k_rr + + (1.0 / 3.0) * kernel_tt.mean() + ) + torch.testing.assert_close(loss, expected) + actual_gradient, = torch.autograd.grad( + loss, generated, retain_graph=True) + expected_gradient, = torch.autograd.grad(expected, generated) + torch.testing.assert_close( + actual_gradient, + expected_gradient, + rtol=2.0e-5, + atol=2.0e-6, + ) + self.assertEqual(int(metadata["global_samples"]), 4) + for partial_key in ( + "partial_student_reference_enabled", + "gradient_student_samples", + "student_reference_samples", + "student_reference_detached", + "student_repulsion_denominator", + "fixed_teacher_attraction_denominator", + "current_teacher_attraction_denominator", + "current_teacher_self_kernel_denominator", + "full_student_partial_gradient", + ): + self.assertNotIn(partial_key, metadata) + + def test_partial_validation_rejects_ambiguous_or_stateful_modes(self): + generator = torch.Generator().manual_seed(9104) + gradient_students = torch.randn( + 2, 1, 2, generator=generator) + student_reference = torch.randn( + 4, 1, 2, generator=generator) + teacher = torch.randn(2, 1, 2, generator=generator) + objective = self._matched_objective() + + with self.assertRaisesRegex(ValueError, "only valid"): + objective( + generated=gradient_students, + current_teacher=teacher, + gradient_student_indices=[0, 1], + ) + with self.assertRaisesRegex(TypeError, "must contain integers"): + objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + gradient_student_indices=[0.0, 1.0], + ) + with self.assertRaisesRegex(ValueError, "exactly one index"): + objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + gradient_student_indices=[0], + ) + with self.assertRaisesRegex(ValueError, "out-of-range"): + objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + gradient_student_indices=[0, 4], + ) + with self.assertRaisesRegex(ValueError, "must be unique"): + objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + gradient_student_indices=[0, 0], + ) + with self.assertRaisesRegex(ValueError, "must exactly match"): + objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + gradient_student_indices=[0, 1], + ) + + with self.assertRaisesRegex(ValueError, "requires both"): + objective( + gradient_students=gradient_students, + current_teacher=teacher, + ) + with self.assertRaisesRegex(ValueError, "requires both"): + objective( + student_reference=student_reference, + current_teacher=teacher, + ) + with self.assertRaisesRegex(ValueError, "mutually exclusive"): + objective( + generated=gradient_students, + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=teacher, + ) + with self.assertRaisesRegex( + ValueError, "exactly match gradient_students" + ): + objective( + gradient_students=gradient_students, + student_reference=student_reference, + current_teacher=torch.randn(3, 1, 2), + ) + + fifo_objective = VideoNystromMMD( + self._pack(), repulsion_fifo_size=4) + with self.assertRaisesRegex(ValueError, "repulsion FIFO"): + fifo_objective( + gradient_students=gradient_students, + student_reference=student_reference, + ) + + ema_objective = VideoNystromMMD( + self._pack(), attraction_ema_enabled=True) + with self.assertRaisesRegex(ValueError, "attraction EMA"): + ema_objective( + gradient_students=gradient_students, + student_reference=student_reference, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/14B/code/tools/build_no_dinov3_references.py b/14B/code/tools/build_no_dinov3_references.py new file mode 100644 index 0000000000000000000000000000000000000000..db3aefbbb46e91f42e5563c0793de5028dbd9b9b --- /dev/null +++ b/14B/code/tools/build_no_dinov3_references.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Build the two new Nyström references without changing either estimator.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +import torch +from safetensors import safe_open + +from losses.video_nystrom_mmd import ( + build_video_nystrom_reference, + compute_reference_pack_hash, + load_reference_pack, +) +from utils.triple_video_feature_bank import ( + TripleVideoFeatureBank, + file_sha256, +) + + +OUTPUT_NAMES = { + "vjepa2": "vjepa2_l15_last64_k1024_m1024_vbench8852.pt", + "videomae_v2": "videomae_v2_l15_fixed5x16_k256_m256_vbench8852.pt", +} +LANDMARKS = {"dinov3": 256, "vjepa2": 1024, "videomae_v2": 256} + + +def atomic_torch_save(value, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + torch.save(value, temporary) + with temporary.open('rb') as handle: + os.fsync(handle.fileno()) + os.replace(temporary, path) + + +def load_branch(bank: TripleVideoFeatureBank, branch: str) -> torch.Tensor: + parts = [] + for shard in bank.shards: + with safe_open( + shard["resolved_path"], framework="pt", device="cpu" + ) as handle: + parts.append(handle.get_tensor(branch)) + value = torch.cat(parts, dim=0) + expected = (len(bank), *bank.manifest["branches"][branch]["sample_shape"]) + if value.dtype != torch.float16 or tuple(value.shape) != expected: + raise ValueError(f"bank branch {branch} geometry/dtype mismatch") + return value + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--bank-manifest", required=True) + parser.add_argument("--bank-manifest-sha256", required=True) + parser.add_argument("--bank-content-hash", required=True) + parser.add_argument("--source-manifest-sha256", required=True) + parser.add_argument("--videomae-revision", default=( + "9981a9c8f77118c421e5228e1b219468a4b0238d")) + parser.add_argument( + "--videomae-download-manifest-content-hash", required=True) + parser.add_argument("--vjepa-model-sha256", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--kmeans-iterations", type=int, default=20) + parser.add_argument("--chunk-size", type=int, default=32) + parser.add_argument("--ridge", type=float, default=1.0e-6) + parser.add_argument("--expected-sample-count", type=int, default=5000) + parser.add_argument("--expected-shard-size", type=int, default=32) + parser.add_argument("--dataset-name", default="fdwan13b_vidprom5k_matched") + parser.add_argument("--skip-bank-shard-hashes", action="store_true") + args = parser.parse_args() + bank = TripleVideoFeatureBank( + args.bank_manifest, + expected_manifest_sha256=args.bank_manifest_sha256, + expected_content_hash=args.bank_content_hash, + expected_source_manifest_sha256=args.source_manifest_sha256, + expected_sample_count=args.expected_sample_count, + expected_videomae_revision=args.videomae_revision, + expected_videomae_download_manifest_content_hash=( + args.videomae_download_manifest_content_hash), + expected_vjepa_model_sha256=args.vjepa_model_sha256, + active_branches=("vjepa2", "videomae_v2"), + expected_shard_size=args.expected_shard_size, + validate_all_shards=not args.skip_bank_shard_hashes, + ) + output_dir = Path(args.output_dir).expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + build_request = { + "chunk_size": int(args.chunk_size), + "kmeans_iterations": int(args.kmeans_iterations), + "ridge": float(args.ridge), + "seed": 0, + } + summaries = {} + for branch in ("vjepa2", "videomae_v2"): + print(json.dumps({'stage': 'reference_start', 'branch': branch, 'samples': len(bank)}), flush=True) + output = output_dir / OUTPUT_NAMES[branch] + if output.is_file(): + existing = load_reference_pack(output, map_location="cpu") + reference_metadata = existing["metadata"].get("reference", {}) + source = reference_metadata.get("source", {}) + nystrom = existing["metadata"].get("nystrom", {}) + if ( + source.get("triple_teacher_bank_content_hash") + != bank.content_hash + or int(nystrom.get("num_landmarks", -1)) != LANDMARKS[branch] + or int(nystrom.get("kmeans_iterations", -1)) + != int(args.kmeans_iterations) + or float(nystrom.get("ridge", float("nan"))) + != float(args.ridge) + or int(nystrom.get("seed", -1)) != 0 + or reference_metadata.get("build_request") != build_request + ): + raise ValueError( + f"existing reference has another identity: {output}") + summaries[branch] = { + "path": str(output), + "sha256": file_sha256(output), + "content_hash": existing["content_hash"], + "resumed": True, + } + continue + cpu_reference = load_branch(bank, branch) + print(json.dumps({'stage': 'reference_rows_loaded', 'branch': branch}), flush=True) + reference = cpu_reference.to( + device=torch.device(args.device), dtype=torch.float32) + del cpu_reference + branch_manifest = bank.manifest["branches"][branch] + metadata = { + "feature": branch_manifest["feature_contract"], + "reference": { + "dataset": args.dataset_name, + "build_request": build_request, + "source": { + "triple_teacher_bank_content_hash": bank.content_hash, + "branch_name": branch, + "triple_teacher_bank_manifest_sha256": ( + bank.manifest_sha256), + "source_manifest_sha256": ( + args.source_manifest_sha256), + "selected_samples": len(bank), + "sample_order": "frozen_manifest_order", + "feature_bank_fp16_sha256": branch_manifest[ + "feature_bank_fp16_sha256"], + "model_identity": branch_manifest["model_identity"], + }, + }, + } + pack = build_video_nystrom_reference( + reference, + num_landmarks=LANDMARKS[branch], + metadata=metadata, + kmeans_iterations=args.kmeans_iterations, + ridge=args.ridge, + chunk_size=args.chunk_size, + seed=0, + ) + print(json.dumps({'stage': 'reference_computed', 'branch': branch}), flush=True) + del reference + cpu_pack = { + key: (value.detach().cpu() if isinstance(value, torch.Tensor) else value) + for key, value in pack.items() + } + if compute_reference_pack_hash(cpu_pack) != cpu_pack["content_hash"]: + raise RuntimeError("reference hash changed during CPU transfer") + atomic_torch_save(cpu_pack, output) + validated = load_reference_pack(output, map_location="cpu") + summaries[branch] = { + "path": str(output), + "sha256": file_sha256(output), + "content_hash": validated["content_hash"], + "resumed": False, + } + del pack, cpu_pack, validated + if torch.cuda.is_available(): + torch.cuda.empty_cache() + manifest = { + "schema_version": 1, + "bank_manifest": str(Path(args.bank_manifest).resolve()), + "bank_manifest_sha256": args.bank_manifest_sha256, + "bank_content_hash": bank.content_hash, + "build_request": build_request, + "references": summaries, + } + path = output_dir / "no_dinov3_reference_manifest.json" + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text( + json.dumps(manifest, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + os.replace(temporary, path) + print(json.dumps(manifest, sort_keys=True), flush=True) + + +if __name__ == "__main__": + main() diff --git a/14B/code/tools/build_triple_nystrom_references.py b/14B/code/tools/build_triple_nystrom_references.py new file mode 100644 index 0000000000000000000000000000000000000000..820ff0312d08d6b9750688a6dd3b39d4b0b19b5f --- /dev/null +++ b/14B/code/tools/build_triple_nystrom_references.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Build three fixed Nyström references sequentially from one triple bank.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +import torch +from safetensors import safe_open + +from losses.video_nystrom_mmd import ( + build_video_nystrom_reference, + compute_reference_pack_hash, + load_reference_pack, +) +from utils.triple_video_feature_bank import ( + TripleVideoFeatureBank, + file_sha256, +) + + +OUTPUT_NAMES = { + "dinov3": "dinov3_vitl16_f20mean5_k1024_m256_wan21t2v14b480p8000.pt", + "vjepa2": "vjepa2_l15_last64_k1024_m1024_wan21t2v14b480p8000.pt", + "videomae_v2": "videomae_v2_l15_fixed5x16_k256_m256_wan21t2v14b480p8000.pt", +} +LANDMARKS = {"dinov3": 256, "vjepa2": 1024, "videomae_v2": 256} + + +def atomic_torch_save(value, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + torch.save(value, temporary) + os.replace(temporary, path) + + +def load_branch(bank: TripleVideoFeatureBank, branch: str) -> torch.Tensor: + parts = [] + for shard in bank.shards: + with safe_open( + shard["resolved_path"], framework="pt", device="cpu" + ) as handle: + parts.append(handle.get_tensor(branch)) + value = torch.cat(parts, dim=0) + expected = (len(bank), *bank.manifest["branches"][branch]["sample_shape"]) + if value.dtype != torch.float16 or tuple(value.shape) != expected: + raise ValueError(f"bank branch {branch} geometry/dtype mismatch") + return value + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--bank-manifest", required=True) + parser.add_argument("--bank-manifest-sha256", required=True) + parser.add_argument("--bank-content-hash", required=True) + parser.add_argument("--source-manifest-sha256", required=True) + parser.add_argument("--videomae-revision", default=( + "9981a9c8f77118c421e5228e1b219468a4b0238d")) + parser.add_argument( + "--videomae-download-manifest-content-hash", required=True) + parser.add_argument("--vjepa-model-sha256", required=True) + parser.add_argument("--dinov3-checkpoint-sha256", required=True) + parser.add_argument("--dinov3-source-commit", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--kmeans-iterations", type=int, default=20) + parser.add_argument("--chunk-size", type=int, default=32) + parser.add_argument("--ridge", type=float, default=1.0e-6) + parser.add_argument("--expected-sample-count", type=int, default=8000) + parser.add_argument("--expected-shard-size", type=int, default=32) + parser.add_argument("--dataset-name", default="wan21_14b_reference_8000") + parser.add_argument("--skip-bank-shard-hashes", action="store_true") + args = parser.parse_args() + bank = TripleVideoFeatureBank( + args.bank_manifest, + expected_manifest_sha256=args.bank_manifest_sha256, + expected_content_hash=args.bank_content_hash, + expected_source_manifest_sha256=args.source_manifest_sha256, + expected_sample_count=args.expected_sample_count, + expected_videomae_revision=args.videomae_revision, + expected_videomae_download_manifest_content_hash=( + args.videomae_download_manifest_content_hash), + expected_vjepa_model_sha256=args.vjepa_model_sha256, + expected_dinov3_checkpoint_sha256=( + args.dinov3_checkpoint_sha256), + expected_dinov3_source_commit=args.dinov3_source_commit, + expected_shard_size=args.expected_shard_size, + validate_all_shards=not args.skip_bank_shard_hashes, + ) + output_dir = Path(args.output_dir).expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + build_request = { + "chunk_size": int(args.chunk_size), + "kmeans_iterations": int(args.kmeans_iterations), + "ridge": float(args.ridge), + "seed": 0, + } + summaries = {} + for branch in ("dinov3", "vjepa2", "videomae_v2"): + output = output_dir / OUTPUT_NAMES[branch] + if output.is_file(): + existing = load_reference_pack(output, map_location="cpu") + reference_metadata = existing["metadata"].get("reference", {}) + source = reference_metadata.get("source", {}) + nystrom = existing["metadata"].get("nystrom", {}) + if ( + source.get("triple_teacher_bank_content_hash") + != bank.content_hash + or int(nystrom.get("num_landmarks", -1)) != LANDMARKS[branch] + or int(nystrom.get("kmeans_iterations", -1)) + != int(args.kmeans_iterations) + or float(nystrom.get("ridge", float("nan"))) + != float(args.ridge) + or int(nystrom.get("seed", -1)) != 0 + or reference_metadata.get("build_request") != build_request + ): + raise ValueError( + f"existing reference has another identity: {output}") + summaries[branch] = { + "path": str(output), + "sha256": file_sha256(output), + "content_hash": existing["content_hash"], + "resumed": True, + } + continue + cpu_reference = load_branch(bank, branch) + reference = cpu_reference.to( + device=torch.device(args.device), dtype=torch.float32) + del cpu_reference + branch_manifest = bank.manifest["branches"][branch] + metadata = { + "feature": branch_manifest["feature_contract"], + "reference": { + "dataset": args.dataset_name, + "build_request": build_request, + "source": { + "triple_teacher_bank_content_hash": bank.content_hash, + "branch_name": branch, + "triple_teacher_bank_manifest_sha256": ( + bank.manifest_sha256), + "source_manifest_sha256": ( + args.source_manifest_sha256), + "selected_samples": len(bank), + "sample_order": "frozen_manifest_order", + "feature_bank_fp16_sha256": branch_manifest[ + "feature_bank_fp16_sha256"], + "model_identity": branch_manifest["model_identity"], + }, + }, + } + pack = build_video_nystrom_reference( + reference, + num_landmarks=LANDMARKS[branch], + metadata=metadata, + kmeans_iterations=args.kmeans_iterations, + ridge=args.ridge, + chunk_size=args.chunk_size, + seed=0, + ) + del reference + cpu_pack = { + key: (value.detach().cpu() if isinstance(value, torch.Tensor) else value) + for key, value in pack.items() + } + if compute_reference_pack_hash(cpu_pack) != cpu_pack["content_hash"]: + raise RuntimeError("reference hash changed during CPU transfer") + atomic_torch_save(cpu_pack, output) + validated = load_reference_pack(output, map_location="cpu") + summaries[branch] = { + "path": str(output), + "sha256": file_sha256(output), + "content_hash": validated["content_hash"], + "resumed": False, + } + del pack, cpu_pack, validated + if torch.cuda.is_available(): + torch.cuda.empty_cache() + manifest = { + "schema_version": 1, + "bank_manifest": str(Path(args.bank_manifest).resolve()), + "bank_manifest_sha256": args.bank_manifest_sha256, + "bank_content_hash": bank.content_hash, + "build_request": build_request, + "references": summaries, + } + path = output_dir / "triple_reference_manifest.json" + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text( + json.dumps(manifest, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + os.replace(temporary, path) + print(json.dumps(manifest, sort_keys=True), flush=True) + + +if __name__ == "__main__": + main() diff --git a/14B/code/tools/download_videomae_v2_large.py b/14B/code/tools/download_videomae_v2_large.py new file mode 100644 index 0000000000000000000000000000000000000000..1e40cd38b6057568537b03ca7120e699e40709b4 --- /dev/null +++ b/14B/code/tools/download_videomae_v2_large.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Stage an existing local VideoMAE-v2 Large snapshot; no network access.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import tempfile +import shutil + + +MODEL_ID = "VideoMAEv2-Large" +REVISION = "9981a9c8f77118c421e5228e1b219468a4b0238d" + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while block := handle.read(8 * 1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def canonical_sha256(value) -> str: + payload = json.dumps( + value, sort_keys=True, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def atomic_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp( + dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output-dir", + default=( + "assets/models/" + "VideoMAEv2-Large"), + ) + parser.add_argument("--source-dir", required=True, type=Path, + help="Existing local snapshot; not a hosted repository ID") + args = parser.parse_args() + output = Path(args.output_dir).expanduser().resolve() + source = args.source_dir.expanduser().resolve() + if not source.is_dir(): + parser.error("--source-dir must be an existing local directory") + if source == output or source in output.parents or output in source.parents: + parser.error("Source and output directories must be separate and non-nested") + if output.exists() and (not output.is_dir() or any(output.iterdir())): + parser.error("--output-dir must be absent or empty") + if not (source / "config.json").is_file(): + parser.error("The local snapshot must include config.json") + if not any(source.glob("*.safetensors")) and not any(source.glob("*.bin")): + parser.error("The local snapshot must include model weights") + shutil.copytree(source, output, dirs_exist_ok=True, + ignore=shutil.ignore_patterns(".git", ".cache", "__pycache__", ".DS_Store")) + # Existing frozen banks depend on the original manifest digest. Copy that + # manifest unchanged when supplied, rather than rewriting its identity. + existing_manifest = output / "download_manifest.json" + if existing_manifest.is_file(): + manifest = json.loads(existing_manifest.read_text()) + payload = dict(manifest) + saved = payload.pop("content_hash", None) + if manifest.get("revision") != REVISION or canonical_sha256(payload) != saved: + raise ValueError("Invalid pinned local snapshot manifest") + for item in manifest["files"]: + path = (output / item["path"]).resolve() + if (output not in path.parents or path.stat().st_size != item["size_bytes"] + or file_sha256(path) != item["sha256"]): + raise ValueError(f"Local snapshot file identity mismatch: {path}") + print(json.dumps({"output_dir": str(output), "file_count": len(manifest["files"]), + "content_hash": saved, "preserved_manifest": True}, sort_keys=True)) + return + files = [] + for path in sorted(output.rglob("*")): + if ( + not path.is_file() + or path.name == "download_manifest.json" + or ".cache" in path.parts + ): + continue + files.append({ + "path": path.relative_to(output).as_posix(), + "size_bytes": path.stat().st_size, + "sha256": file_sha256(path), + }) + payload = { + "schema_version": 1, + "model_id": MODEL_ID, + "revision": REVISION, + "files": files, + } + payload["content_hash"] = canonical_sha256(payload) + atomic_json(output / "download_manifest.json", payload) + print(json.dumps({ + "output_dir": str(output), + "file_count": len(files), + "content_hash": payload["content_hash"], + }, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/14B/code/tools/extract_no_dinov3_teacher_bank.py b/14B/code/tools/extract_no_dinov3_teacher_bank.py new file mode 100644 index 0000000000000000000000000000000000000000..7435f40421d51008cc9f5919c364bbad7e972d05 --- /dev/null +++ b/14B/code/tools/extract_no_dinov3_teacher_bank.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Decode once; write only V-JEPA2/VideoMAE rows with the original contracts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import tempfile +import time + +import numpy as np +import torch +import torch.distributed as dist +from safetensors import safe_open +from safetensors.torch import save_file + +from utils.triple_video_feature_bank import ( + NO_DINO_BANK_KIND as BANK_KIND, + BANK_SCHEMA_VERSION, + canonical_sha256, + file_sha256, +) +from utils.matched_video_manifest import ( + load_matched_video_manifest, + sha256_file, +) +from video_fd.encoders.segmented_videomae_v2 import ( + SegmentedVideoMAEv2Ensemble, +) +from video_fd.encoders.videomae_v2_dense_metadata import ( + canonical_videomae_v2_dense_feature_metadata, + uniform_segment_token_indices, +) +from video_fd.encoders.vjepa2_adapter import VJEPA2Adapter +from video_fd.encoders.vjepa2_dense_metadata import ( + canonical_vjepa2_dense_feature_metadata, + uniform_dense_token_indices, +) + + +VIDEOMAE_REVISION = "9981a9c8f77118c421e5228e1b219468a4b0238d" + + +def atomic_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp( + dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + + +def tensor_sha256(tensor: torch.Tensor) -> str: + value = tensor.detach().cpu().contiguous() + return hashlib.sha256(value.numpy().tobytes()).hexdigest() + + +def decode_81(path: Path, *, size: int, mtime_ns: int) -> torch.Tensor: + stat = path.stat() + if stat.st_size != size or stat.st_mtime_ns != mtime_ns: + raise ValueError(f"frozen video stat changed: {path}") + import av + + frames = [] + with av.open(str(path), mode="r") as container: + if len(container.streams.video) != 1: + raise ValueError(f"expected one video stream: {path}") + stream = container.streams.video[0] + for frame in container.decode(stream): + array = frame.to_ndarray(format="rgb24") + if array.dtype != np.uint8 or array.ndim != 3 or array.shape[2] != 3: + raise ValueError(f"invalid RGB frame in {path}") + frames.append(torch.from_numpy( + np.ascontiguousarray(array)).permute(2, 0, 1)) + if len(frames) != 81: + raise ValueError(f"{path.name} has {len(frames)} frames, expected 81") + shape = tuple(frames[0].shape) + if shape != (3, 480, 832): + raise ValueError(f"expected native 832x480 video: {path}") + if any(tuple(frame.shape) != shape for frame in frames): + raise ValueError(f"resolution changes within {path}") + return torch.stack(frames).float().div_(255.0).unsqueeze(0) + + +def load_download_manifest(model_dir: Path) -> dict: + path = model_dir / "download_manifest.json" + with path.open("r", encoding="utf-8") as handle: + manifest = json.load(handle) + payload = dict(manifest) + saved = payload.pop("content_hash") + if ( + manifest.get("revision") != VIDEOMAE_REVISION + or canonical_sha256(payload) != saved + ): + raise ValueError("invalid pinned VideoMAE download manifest") + for item in manifest["files"]: + path = (model_dir / item["path"]).resolve() + if ( + model_dir not in path.parents + or path.stat().st_size != int(item["size_bytes"]) + or file_sha256(path) != item["sha256"] + ): + raise ValueError(f"VideoMAE file identity mismatch: {path}") + return manifest + + +def build_encoders(args, device): + if file_sha256(Path(args.vjepa_model) / 'model.safetensors') != args.vjepa_model_sha256: + raise ValueError('V-JEPA2 weights SHA256 mismatch') + vj = VJEPA2Adapter({ + "name": "vjepa2_dense", + "model_id": str(args.vjepa_model), + "input_frames": 64, + "dense_num_frames": 64, + "input_size": 256, + "resize_short_side": 292, + "crop_mode": "center", + "dense_only": True, + "dense_layer_index": 15, + }).to(device=device, dtype=torch.float32).eval().requires_grad_(False) + vm = SegmentedVideoMAEv2Ensemble({ + "name": "videomae_v2_five_segment_dense", + "model_id": str(args.videomae_model), + "revision": VIDEOMAE_REVISION, + "local_files_only": True, + "encoder_count": 5, + "skip_leading_frames": 1, + "segment_num_frames": 16, + "source_num_frames": 81, + "input_frames": 16, + "dense_num_frames": 16, + "input_size": 224, + "resize_short_side": 224, + "crop_mode": "center", + "dense_only": True, + "dense_layer_index": 15, + }).to(device=device, dtype=torch.float32).eval().requires_grad_(False) + if (vj.dense_feature_dim, vm.dense_feature_dim) != (1024, 1024): + raise RuntimeError("encoder feature dimensions differ from contract") + return vj, vm + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", required=True) + parser.add_argument("--manifest-sha256", required=True) + parser.add_argument("--source-csv-sha256", required=True) + parser.add_argument("--video-root", required=True) + parser.add_argument("--vjepa-model", required=True) + parser.add_argument("--vjepa-reference-model-id", required=True) + parser.add_argument("--vjepa-model-sha256", required=True) + parser.add_argument("--videomae-model", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--shard-rows", type=int, default=32) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--expected-count", type=int, default=5000) + args = parser.parse_args() + if args.shard_rows != 32: + raise ValueError("triple teacher bank requires shard-rows=32") + manifest_path = Path(args.manifest).expanduser().resolve() + if sha256_file(manifest_path) != args.manifest_sha256: + raise ValueError("source manifest SHA-256 mismatch") + entries, source_metadata = load_matched_video_manifest( + manifest_path, + expected_source_csv_sha256=args.source_csv_sha256, + expected_count=args.expected_count, + ) + video_root = Path(args.video_root).expanduser().resolve() + output = Path(args.output_dir).expanduser().resolve() + shard_dir = output / "shards" + shard_dir.mkdir(parents=True, exist_ok=True) + progress_path = output / "progress.json" + final_path = output / "manifest.json" + vm_download = load_download_manifest( + Path(args.videomae_model).expanduser().resolve()) + world_size = int(os.environ.get("WORLD_SIZE", "1")) + distributed = world_size > 1 + if distributed: + local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(local_rank) + dist.init_process_group(backend="nccl") + rank = int(dist.get_rank()) + world_size = int(dist.get_world_size()) + device = torch.device("cuda", local_rank) + else: + rank = 0 + device = torch.device(args.device) + print(json.dumps({'rank': rank, 'stage': 'load_two_encoders', 'world_size': world_size}), flush=True) + vj, vm = build_encoders(args, device) + print(json.dumps({'rank': rank, 'stage': 'two_encoders_ready'}), flush=True) + vj_indices = uniform_dense_token_indices( + vj.dense_raw_tokens, 1024, device=device) + vm_indices = uniform_segment_token_indices( + vm.dense_raw_tokens, 256, device=device) + vj_contract = canonical_vjepa2_dense_feature_metadata( + model_id=args.vjepa_reference_model_id, + layer_index=15, + num_frames=64, + resize_short_side=292, + crop_size=256, + raw_tokens=vj.dense_raw_tokens, + num_tokens=1024, + feature_dim=1024, + token_indices=vj_indices.cpu(), + frame_sampling="last_contiguous", + source_num_frames=81, + ) + vm_contract = canonical_videomae_v2_dense_feature_metadata( + model_id=str(Path(args.videomae_model).expanduser().resolve()), + layer_index=15, + num_tokens=256, + token_indices=vm_indices.cpu(), + ) + fixed = { + "schema_version": BANK_SCHEMA_VERSION, + "kind": BANK_KIND, + "sample_count": len(entries), + "source_manifest": { + "path": str(manifest_path), + "sha256": args.manifest_sha256, + "source_csv_sha256": args.source_csv_sha256, + "sample_identity_sha256": source_metadata.get( + "sample_identity_sha256"), + }, + "branches": { + "vjepa2": { + "sample_shape": [1024, 1024], + "dtype": "float16", + "feature_contract": vj_contract, + "model_identity": { + "model_id": args.vjepa_reference_model_id, + "load_path": str(Path(args.vjepa_model).resolve()), + "sha256": args.vjepa_model_sha256, + }, + }, + "videomae_v2": { + "sample_shape": [1280, 1024], + "dtype": "float16", + "feature_contract": vm_contract, + "model_identity": { + "model_id": "VideoMAEv2-Large", + "revision": VIDEOMAE_REVISION, + "download_manifest_content_hash": vm_download[ + "content_hash"], + }, + }, + }, + "samples": [{ + "index": entry.manifest_index, + "sample_id": entry.sample_id, + "prompt": entry.caption, + } for entry in entries], + } + progress_identity = canonical_sha256(fixed) + receipt_dir = output / "receipts" + receipt_dir.mkdir(parents=True, exist_ok=True) + shard_count = (len(entries) + args.shard_rows - 1) // args.shard_rows + started = time.time() + for shard_index in range(rank, shard_count, world_size): + start = shard_index * args.shard_rows + end = min(start + args.shard_rows, len(entries)) + relative = f"shards/shard-{shard_index:05d}.safetensors" + path = output / relative + receipt_path = receipt_dir / f"shard-{shard_index:05d}.json" + if receipt_path.is_file(): + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + if ( + receipt.get("progress_identity") != progress_identity + or int(receipt.get("index", -1)) != shard_index + or int(receipt.get("sample_start", -1)) != start + or int(receipt.get("sample_end", -1)) != end + or not path.is_file() + or file_sha256(path) != receipt.get("sha256") + ): + raise ValueError( + f"resumable triple shard identity mismatch: {path}") + print(json.dumps({ + "rank": rank, + "resumed_shard": shard_index, + "completed": end, + }), flush=True) + continue + + branch_rows = { + "vjepa2": [], + "videomae_v2": [], + } + for entry in entries[start:end]: + video = decode_81( + video_root / entry.video_name, + size=entry.video_size_bytes, + mtime_ns=entry.video_mtime_ns, + ).to(device=device, dtype=torch.float32) + temporal = torch.arange( + 17, 81, device=device, dtype=torch.long).unsqueeze(0) + with torch.inference_mode(): + vj_feature = vj.forward_dense( + video, + layer_index=15, + token_indices=vj_indices, + gradient_checkpointing=False, + temporal_window_indices=temporal, + ) + vm_feature = vm.forward_dense( + video, + layer_index=15, + token_indices=vm_indices, + gradient_checkpointing=False, + ) + observed = { + "vjepa2": tuple(vj_feature.shape), + "videomae_v2": tuple(vm_feature.shape), + } + expected = { + "vjepa2": (1, 1024, 1024), + "videomae_v2": (1, 1280, 1024), + } + if observed != expected: + raise RuntimeError( + f"triple bank row shape mismatch: {observed}") + branch_rows["vjepa2"].append( + vj_feature.squeeze(0).half().cpu().contiguous()) + branch_rows["videomae_v2"].append( + vm_feature.squeeze(0).half().cpu().contiguous()) + if not (torch.isfinite(vj_feature).all() and torch.isfinite(vm_feature).all()): + raise FloatingPointError(f'non-finite encoder feature: {entry.sample_id}') + del video, vj_feature, vm_feature + + tensors = { + name: torch.stack(rows, dim=0) + for name, rows in branch_rows.items() + } + ids = [entry.sample_id for entry in entries[start:end]] + shard_payload = { + "index": shard_index, + "sample_start": start, + "sample_end": end, + "sample_ids_sha256": canonical_sha256(ids), + "tensor_sha256": { + name: tensor_sha256(value) + for name, value in tensors.items() + }, + "branches": fixed["branches"], + "source_manifest_sha256": args.manifest_sha256, + "progress_identity": progress_identity, + } + shard_content_hash = canonical_sha256(shard_payload) + temporary = path.with_name(f".{path.name}.rank{rank}.tmp") + save_file(tensors, str(temporary), metadata={ + "content_hash": shard_content_hash, + "sample_start": str(start), + "sample_end": str(end), + "source_manifest_sha256": args.manifest_sha256, + "progress_identity": progress_identity, + }) + with temporary.open('rb') as handle: + os.fsync(handle.fileno()) + os.replace(temporary, path) + shard = { + "index": shard_index, + "path": relative, + "sample_start": start, + "sample_end": end, + "sample_ids_sha256": canonical_sha256(ids), + "sha256": file_sha256(path), + "content_hash": shard_content_hash, + } + atomic_json(receipt_path, { + **shard, + "progress_identity": progress_identity, + "rank": rank, + }) + print(json.dumps({ + "rank": rank, + "completed": end, + "total": len(entries), + "elapsed_seconds": time.time() - started, + "shard": relative, + }), flush=True) + + if distributed: + dist.barrier() + if rank == 0: + print(json.dumps({'stage': 'validate_all_feature_shards', 'shards': shard_count}), flush=True) + shards = [] + for shard_index in range(shard_count): + receipt_path = receipt_dir / f"shard-{shard_index:05d}.json" + if not receipt_path.is_file(): + raise FileNotFoundError( + f"missing completed shard receipt: {receipt_path}") + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + if receipt.pop("progress_identity", None) != progress_identity: + raise ValueError("triple shard progress identity mismatch") + receipt.pop("rank", None) + path = output / receipt["path"] + if file_sha256(path) != receipt["sha256"]: + raise ValueError(f"triple shard SHA-256 mismatch: {path}") + shards.append(receipt) + + branch_hashers = { + name: hashlib.sha256() for name in fixed["branches"] + } + for shard in shards: + with safe_open( + str(output / shard["path"]), + framework="pt", + device="cpu", + ) as handle: + for name in branch_hashers: + value = handle.get_tensor(name).contiguous() + branch_hashers[name].update(value.numpy().tobytes()) + for name, hasher in branch_hashers.items(): + fixed["branches"][name]["feature_bank_fp16_sha256"] = ( + hasher.hexdigest()) + final_payload = {**fixed, "shards": shards} + final_payload["content_hash"] = canonical_sha256(final_payload) + atomic_json(final_path, final_payload) + print(json.dumps({ + "manifest": str(final_path), + "manifest_sha256": file_sha256(final_path), + "content_hash": final_payload["content_hash"], + "samples": len(entries), + "world_size": world_size, + }, sort_keys=True), flush=True) + if distributed: + dist.barrier() + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/14B/code/tools/extract_triple_matched_teacher_bank.py b/14B/code/tools/extract_triple_matched_teacher_bank.py new file mode 100644 index 0000000000000000000000000000000000000000..7022f4829d895683d9b964074c6e841685123ee8 --- /dev/null +++ b/14B/code/tools/extract_triple_matched_teacher_bank.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Decode each frozen 81-frame MP4 once and write a resumable triple bank.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import tempfile +import time + +import numpy as np +import torch +import torch.distributed as dist +from safetensors import safe_open +from safetensors.torch import save_file + +from utils.triple_video_feature_bank import ( + BANK_KIND, + BANK_SCHEMA_VERSION, + canonical_sha256, + file_sha256, +) +from utils.matched_video_manifest import ( + load_matched_video_manifest, + sha256_file, +) +from video_fd.encoders.dinov3_temporal_pool_adapter import ( + DINOv3TemporalPoolAdapter, +) +from video_fd.encoders.segmented_videomae_v2 import ( + SegmentedVideoMAEv2Ensemble, +) +from video_fd.encoders.videomae_v2_dense_metadata import ( + canonical_videomae_v2_dense_feature_metadata, + uniform_segment_token_indices, +) +from video_fd.encoders.vjepa2_adapter import VJEPA2Adapter +from video_fd.encoders.vjepa2_dense_metadata import ( + canonical_vjepa2_dense_feature_metadata, + uniform_dense_token_indices, +) + + +VIDEOMAE_REVISION = "9981a9c8f77118c421e5228e1b219468a4b0238d" + + +def atomic_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp( + dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + + +def tensor_sha256(tensor: torch.Tensor) -> str: + value = tensor.detach().cpu().contiguous() + return hashlib.sha256(value.numpy().tobytes()).hexdigest() + + +def decode_81(path: Path, *, size: int, mtime_ns: int) -> torch.Tensor: + stat = path.stat() + if stat.st_size != size or stat.st_mtime_ns != mtime_ns: + raise ValueError(f"frozen video stat changed: {path}") + import av + + frames = [] + with av.open(str(path), mode="r") as container: + if len(container.streams.video) != 1: + raise ValueError(f"expected one video stream: {path}") + stream = container.streams.video[0] + for frame in container.decode(stream): + array = frame.to_ndarray(format="rgb24") + if array.dtype != np.uint8 or array.ndim != 3 or array.shape[2] != 3: + raise ValueError(f"invalid RGB frame in {path}") + frames.append(torch.from_numpy( + np.ascontiguousarray(array)).permute(2, 0, 1)) + if len(frames) != 81: + raise ValueError(f"{path.name} has {len(frames)} frames, expected 81") + shape = tuple(frames[0].shape) + if any(tuple(frame.shape) != shape for frame in frames): + raise ValueError(f"resolution changes within {path}") + return torch.stack(frames).float().div_(255.0).unsqueeze(0) + + +def load_download_manifest(model_dir: Path) -> dict: + path = model_dir / "download_manifest.json" + with path.open("r", encoding="utf-8") as handle: + manifest = json.load(handle) + payload = dict(manifest) + saved = payload.pop("content_hash") + if ( + manifest.get("revision") != VIDEOMAE_REVISION + or canonical_sha256(payload) != saved + ): + raise ValueError("invalid pinned VideoMAE download manifest") + for item in manifest["files"]: + path = (model_dir / item["path"]).resolve() + if ( + model_dir not in path.parents + or path.stat().st_size != int(item["size_bytes"]) + or file_sha256(path) != item["sha256"] + ): + raise ValueError(f"VideoMAE file identity mismatch: {path}") + return manifest + + +def build_encoders(args, device): + dino = DINOv3TemporalPoolAdapter({ + "repo_path": str(args.dinov3_repo), + "weight_path": str(args.dinov3_weights), + "source_commit": str(args.dinov3_source_commit), + "checkpoint_sha256": str(args.dinov3_checkpoint_sha256), + "source_num_frames": 81, + "frame_indices": ( + 0, 4, 8, 13, 17, 21, 25, 29, 34, 38, + 42, 46, 51, 55, 59, 63, 67, 72, 76, 80), + "input_size": 256, + "patch_size": 16, + "spatial_axis_indices": tuple(range(16)), + "forward_chunk_frames": 4, + "geometry": "temporal_mean5_full256", + "temporal_groups": 4, + "temporal_pool_frames": 5, + }).to(device=device, dtype=torch.float32).eval().requires_grad_(False) + vj = VJEPA2Adapter({ + "name": "vjepa2_dense", + "model_id": str(args.vjepa_model), + "input_frames": 64, + "dense_num_frames": 64, + "input_size": 256, + "resize_short_side": 292, + "crop_mode": "center", + "dense_only": True, + "dense_layer_index": 15, + }).to(device=device, dtype=torch.float32).eval().requires_grad_(False) + vm = SegmentedVideoMAEv2Ensemble({ + "name": "videomae_v2_five_segment_dense", + "model_id": str(args.videomae_model), + "revision": VIDEOMAE_REVISION, + "local_files_only": True, + "encoder_count": 5, + "skip_leading_frames": 1, + "segment_num_frames": 16, + "source_num_frames": 81, + "input_frames": 16, + "dense_num_frames": 16, + "input_size": 224, + "resize_short_side": 224, + "crop_mode": "center", + "dense_only": True, + "dense_layer_index": 15, + }).to(device=device, dtype=torch.float32).eval().requires_grad_(False) + if (dino.dense_feature_dim, vj.dense_feature_dim, vm.dense_feature_dim) != (1024, 1024, 1024): + raise RuntimeError("encoder feature dimensions differ from contract") + return dino, vj, vm + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", required=True) + parser.add_argument("--manifest-sha256", required=True) + parser.add_argument("--source-csv-sha256", required=True) + parser.add_argument("--video-root", required=True) + parser.add_argument("--dinov3-repo", required=True) + parser.add_argument("--dinov3-weights", required=True) + parser.add_argument("--dinov3-source-commit", required=True) + parser.add_argument("--dinov3-checkpoint-sha256", required=True) + parser.add_argument("--vjepa-model", required=True) + parser.add_argument("--vjepa-reference-model-id", required=True) + parser.add_argument("--vjepa-model-sha256", required=True) + parser.add_argument("--videomae-model", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--shard-rows", type=int, default=32) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--expected-count", type=int, default=8000) + args = parser.parse_args() + if args.shard_rows != 32: + raise ValueError("triple teacher bank requires shard-rows=32") + manifest_path = Path(args.manifest).expanduser().resolve() + if sha256_file(manifest_path) != args.manifest_sha256: + raise ValueError("source manifest SHA-256 mismatch") + entries, source_metadata = load_matched_video_manifest( + manifest_path, + expected_source_csv_sha256=args.source_csv_sha256, + expected_count=args.expected_count, + ) + video_root = Path(args.video_root).expanduser().resolve() + output = Path(args.output_dir).expanduser().resolve() + shard_dir = output / "shards" + shard_dir.mkdir(parents=True, exist_ok=True) + progress_path = output / "progress.json" + final_path = output / "manifest.json" + vm_download = load_download_manifest( + Path(args.videomae_model).expanduser().resolve()) + world_size = int(os.environ.get("WORLD_SIZE", "1")) + distributed = world_size > 1 + if distributed: + local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(local_rank) + dist.init_process_group(backend="nccl") + rank = int(dist.get_rank()) + world_size = int(dist.get_world_size()) + device = torch.device("cuda", local_rank) + else: + rank = 0 + device = torch.device(args.device) + dino, vj, vm = build_encoders(args, device) + vj_indices = uniform_dense_token_indices( + vj.dense_raw_tokens, 1024, device=device) + vm_indices = uniform_segment_token_indices( + vm.dense_raw_tokens, 256, device=device) + vj_contract = canonical_vjepa2_dense_feature_metadata( + model_id=args.vjepa_reference_model_id, + layer_index=15, + num_frames=64, + resize_short_side=292, + crop_size=256, + raw_tokens=vj.dense_raw_tokens, + num_tokens=1024, + feature_dim=1024, + token_indices=vj_indices.cpu(), + frame_sampling="last_contiguous", + source_num_frames=81, + ) + vm_contract = canonical_videomae_v2_dense_feature_metadata( + model_id=str(Path(args.videomae_model).expanduser().resolve()), + layer_index=15, + num_tokens=256, + token_indices=vm_indices.cpu(), + ) + fixed = { + "schema_version": BANK_SCHEMA_VERSION, + "kind": BANK_KIND, + "sample_count": len(entries), + "source_manifest": { + "path": str(manifest_path), + "sha256": args.manifest_sha256, + "source_csv_sha256": args.source_csv_sha256, + "sample_identity_sha256": source_metadata.get( + "sample_identity_sha256"), + }, + "branches": { + "dinov3": { + "sample_shape": [1024, 1024], + "dtype": "float16", + "feature_contract": dino.feature_metadata(), + "model_identity": { + "model_id": "dinov3_vitl16", + "repo_path": str(Path(args.dinov3_repo).resolve()), + "weight_path": str(Path(args.dinov3_weights).resolve()), + "source_commit": args.dinov3_source_commit, + "checkpoint_sha256": args.dinov3_checkpoint_sha256, + }, + }, + "vjepa2": { + "sample_shape": [1024, 1024], + "dtype": "float16", + "feature_contract": vj_contract, + "model_identity": { + "model_id": args.vjepa_reference_model_id, + "load_path": str(Path(args.vjepa_model).resolve()), + "sha256": args.vjepa_model_sha256, + }, + }, + "videomae_v2": { + "sample_shape": [1280, 1024], + "dtype": "float16", + "feature_contract": vm_contract, + "model_identity": { + "model_id": "VideoMAEv2-Large", + "revision": VIDEOMAE_REVISION, + "download_manifest_content_hash": vm_download[ + "content_hash"], + }, + }, + }, + "samples": [{ + "index": entry.manifest_index, + "sample_id": entry.sample_id, + "prompt": entry.caption, + } for entry in entries], + } + progress_identity = canonical_sha256(fixed) + receipt_dir = output / "receipts" + receipt_dir.mkdir(parents=True, exist_ok=True) + shard_count = (len(entries) + args.shard_rows - 1) // args.shard_rows + started = time.time() + for shard_index in range(rank, shard_count, world_size): + start = shard_index * args.shard_rows + end = min(start + args.shard_rows, len(entries)) + relative = f"shards/shard-{shard_index:05d}.safetensors" + path = output / relative + receipt_path = receipt_dir / f"shard-{shard_index:05d}.json" + if receipt_path.is_file(): + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + if ( + receipt.get("progress_identity") != progress_identity + or int(receipt.get("index", -1)) != shard_index + or int(receipt.get("sample_start", -1)) != start + or int(receipt.get("sample_end", -1)) != end + or not path.is_file() + or file_sha256(path) != receipt.get("sha256") + ): + raise ValueError( + f"resumable triple shard identity mismatch: {path}") + print(json.dumps({ + "rank": rank, + "resumed_shard": shard_index, + "completed": end, + }), flush=True) + continue + + branch_rows = { + "dinov3": [], + "vjepa2": [], + "videomae_v2": [], + } + for entry in entries[start:end]: + video = decode_81( + video_root / entry.video_name, + size=entry.video_size_bytes, + mtime_ns=entry.video_mtime_ns, + ).to(device=device, dtype=torch.float32) + temporal = torch.arange( + 17, 81, device=device, dtype=torch.long).unsqueeze(0) + with torch.inference_mode(): + dino_feature = dino.forward_dense_no_grad( + video, gradient_checkpointing=False) + vj_feature = vj.forward_dense( + video, + layer_index=15, + token_indices=vj_indices, + gradient_checkpointing=False, + temporal_window_indices=temporal, + ) + vm_feature = vm.forward_dense( + video, + layer_index=15, + token_indices=vm_indices, + gradient_checkpointing=False, + ) + observed = { + "dinov3": tuple(dino_feature.shape), + "vjepa2": tuple(vj_feature.shape), + "videomae_v2": tuple(vm_feature.shape), + } + expected = { + "dinov3": (1, 1024, 1024), + "vjepa2": (1, 1024, 1024), + "videomae_v2": (1, 1280, 1024), + } + if observed != expected: + raise RuntimeError( + f"triple bank row shape mismatch: {observed}") + branch_rows["dinov3"].append( + dino_feature.squeeze(0).half().cpu().contiguous()) + branch_rows["vjepa2"].append( + vj_feature.squeeze(0).half().cpu().contiguous()) + branch_rows["videomae_v2"].append( + vm_feature.squeeze(0).half().cpu().contiguous()) + del video, dino_feature, vj_feature, vm_feature + + tensors = { + name: torch.stack(rows, dim=0) + for name, rows in branch_rows.items() + } + ids = [entry.sample_id for entry in entries[start:end]] + shard_payload = { + "index": shard_index, + "sample_start": start, + "sample_end": end, + "sample_ids_sha256": canonical_sha256(ids), + "tensor_sha256": { + name: tensor_sha256(value) + for name, value in tensors.items() + }, + "branches": fixed["branches"], + "source_manifest_sha256": args.manifest_sha256, + "progress_identity": progress_identity, + } + shard_content_hash = canonical_sha256(shard_payload) + temporary = path.with_name(f".{path.name}.rank{rank}.tmp") + save_file(tensors, str(temporary), metadata={ + "content_hash": shard_content_hash, + "sample_start": str(start), + "sample_end": str(end), + "source_manifest_sha256": args.manifest_sha256, + "progress_identity": progress_identity, + }) + os.replace(temporary, path) + shard = { + "index": shard_index, + "path": relative, + "sample_start": start, + "sample_end": end, + "sample_ids_sha256": canonical_sha256(ids), + "sha256": file_sha256(path), + "content_hash": shard_content_hash, + } + atomic_json(receipt_path, { + **shard, + "progress_identity": progress_identity, + "rank": rank, + }) + print(json.dumps({ + "rank": rank, + "completed": end, + "total": len(entries), + "elapsed_seconds": time.time() - started, + "shard": relative, + }), flush=True) + + if distributed: + dist.barrier() + if rank == 0: + shards = [] + for shard_index in range(shard_count): + receipt_path = receipt_dir / f"shard-{shard_index:05d}.json" + if not receipt_path.is_file(): + raise FileNotFoundError( + f"missing completed shard receipt: {receipt_path}") + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + if receipt.pop("progress_identity", None) != progress_identity: + raise ValueError("triple shard progress identity mismatch") + receipt.pop("rank", None) + path = output / receipt["path"] + if file_sha256(path) != receipt["sha256"]: + raise ValueError(f"triple shard SHA-256 mismatch: {path}") + shards.append(receipt) + + branch_hashers = { + name: hashlib.sha256() for name in fixed["branches"] + } + for shard in shards: + with safe_open( + str(output / shard["path"]), + framework="pt", + device="cpu", + ) as handle: + for name in branch_hashers: + value = handle.get_tensor(name).contiguous() + branch_hashers[name].update(value.numpy().tobytes()) + for name, hasher in branch_hashers.items(): + fixed["branches"][name]["feature_bank_fp16_sha256"] = ( + hasher.hexdigest()) + final_payload = {**fixed, "shards": shards} + final_payload["content_hash"] = canonical_sha256(final_payload) + atomic_json(final_path, final_payload) + print(json.dumps({ + "manifest": str(final_path), + "manifest_sha256": file_sha256(final_path), + "content_hash": final_payload["content_hash"], + "samples": len(entries), + "world_size": world_size, + }, sort_keys=True), flush=True) + if distributed: + dist.barrier() + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/14B/code/tools/materialize_no_dinov3_filtered_config.py b/14B/code/tools/materialize_no_dinov3_filtered_config.py new file mode 100644 index 0000000000000000000000000000000000000000..336fb18ddd5214cfa8b756c02ffbbf867beb22cd --- /dev/null +++ b/14B/code/tools/materialize_no_dinov3_filtered_config.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Replace only dataset/reference identity in the verified no-DINO config.""" +import argparse +import json +import os +import tempfile +from pathlib import Path + +from omegaconf import OmegaConf +from utils.config import load_config +from utils.triple_video_feature_bank import file_sha256, NO_DINO_BANK_KIND +def atomic_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + dir=path.parent, prefix=f".{path.name}.", suffix=".tmp" + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument('--base-config', required=True) + p.add_argument('--bank-manifest', required=True) + p.add_argument('--reference-manifest', required=True) + p.add_argument('--dataset-root', required=True) + p.add_argument('--output', required=True) + p.add_argument('--expected-count', type=int, required=True) + args = p.parse_args() + config = load_config(args.base_config) + original = OmegaConf.to_container(config) + bank_path = Path(args.bank_manifest).resolve() + refs_path = Path(args.reference_manifest).resolve() + bank = json.loads(bank_path.read_text()) + refs = json.loads(refs_path.read_text()) + names = {'vjepa2', 'videomae_v2'} + if bank['kind'] != NO_DINO_BANK_KIND or set(bank['branches']) != names or bank['sample_count'] != args.expected_count: + raise ValueError('unexpected two-branch bank identity') + bank_sha = file_sha256(bank_path) + if refs['bank_content_hash'] != bank['content_hash'] or refs['bank_manifest_sha256'] != bank_sha: + raise ValueError('references and bank disagree') + if set(refs['references']) != names: + raise ValueError('references must contain exactly two encoders') + for item in refs['references'].values(): + if file_sha256(item['path']) != item['sha256']: + raise ValueError('reference file SHA256 mismatch') + source = bank['source_manifest'] + updates = { + 'triple_teacher_bank_manifest': str(bank_path), + 'triple_teacher_bank_manifest_sha256': bank_sha, + 'triple_teacher_bank_content_hash': bank['content_hash'], + 'matched_manifest_path': source['path'], 'matched_manifest_sha256': source['sha256'], + 'matched_manifest_samples': args.expected_count, + 'videomae_v2_reference_model_id': bank['branches']['videomae_v2']['feature_contract'].get('model_id', config.videomae_v2_model_id), + 'matched_source_csv_sha256': source['source_csv_sha256'], + 'matched_sample_identity_sha256': source['sample_identity_sha256'], + 'matched_video_root': str(Path(args.dataset_root).resolve() / 'videos'), + 'triple_nystrom_reference_path': refs['references']['vjepa2']['path'], + 'triple_nystrom_reference_sha256': refs['references']['vjepa2']['sha256'], + 'videomae_v2_nystrom_reference_path': refs['references']['videomae_v2']['path'], + 'videomae_v2_nystrom_reference_sha256': refs['references']['videomae_v2']['sha256'], + 'feature_protocol_identity': original['feature_protocol_identity'] + '_rebuilt', + 'dataset_identity': { + **original['dataset_identity'], 'name': 'reference_videos', + 'sample_count': args.expected_count, 'source_manifest_sha256': source['sha256'], + 'teacher_bank_content_hash': bank['content_hash'], + 'reference_manifest_sha256': file_sha256(refs_path), + }, + } + for key, value in updates.items(): + config[key] = value + actual = OmegaConf.to_container(config) + if {k: v for k, v in actual.items() if k not in updates} != {k: v for k, v in original.items() if k not in updates}: + raise ValueError('non-data hyperparameter changed') + if actual['triple_active_branches'] != ['vjepa2', 'videomae_v2'] or actual['triple_aggregation_divisor'] != 3: + raise ValueError('loss definition changed') + output = Path(args.output).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(dir=output.parent, suffix='.yaml.tmp') + os.close(descriptor) + try: + OmegaConf.save(config, temporary) + with open(temporary, 'rb') as handle: + os.fsync(handle.fileno()) + if output.exists(): + if file_sha256(output) != file_sha256(temporary): + raise ValueError('refusing to replace a different existing run config') + else: + os.replace(temporary, output) + finally: + Path(temporary).unlink(missing_ok=True) + atomic_json(output.with_suffix('.identity.json'), dict( + config_sha256=file_sha256(output), baseline_config_sha256=file_sha256(args.base_config), + changed_data_keys=sorted(updates), sample_count=args.expected_count, + bank_sha256=bank_sha, reference_manifest_sha256=file_sha256(refs_path), + source_manifest_sha256=source['sha256'], resume_checkpoint=None, + loss='(M_vj/(stopgrad(M_vj)+1e-4) + M_vm/(stopgrad(M_vm)+1e-4))/3')) + print(str(output), flush=True) + + +if __name__ == '__main__': + main() diff --git a/14B/code/tools/precompute_dinov3_dense_nystrom_reference.py b/14B/code/tools/precompute_dinov3_dense_nystrom_reference.py new file mode 100644 index 0000000000000000000000000000000000000000..4a1ecc1032238079d332ab6b9348148bbe187770 --- /dev/null +++ b/14B/code/tools/precompute_dinov3_dense_nystrom_reference.py @@ -0,0 +1,1160 @@ +#!/usr/bin/env python3 +"""Build the fixed DINOv3 ViT-L/16 whole-video Nyström reference. + +The teacher and objective are unchanged from the paired V-JEPA2 experiment: +the final latent from every row of the 1,537-video CausVid ODE LMDB is decoded +with the frozen Wan VAE, then represented by a frozen DINOv3 image encoder. +The DINOv3 feature map is fixed to 16 uniformly distributed source frames and +an 8x8 subset of the 16x16 patch grid per frame. Frame-major concatenation +therefore produces one aligned ``[16 * 64, 1024]`` matrix per video. + +Every torchrun rank atomically writes one indexed FP16 feature shard. Rank 0 +validates and merges all shards in LMDB-index order, constructs the existing +video-matrix Nyström objective (m=256, seed=0), atomically publishes the pack, +and reloads the *final path* for validation. Temporary shards are removed only +after that final validation succeeds; failed runs retain them for diagnosis. +""" + +from __future__ import annotations + +import argparse +from datetime import timedelta +import gc +import hashlib +import json +import math +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time +from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple + +import torch +import torch.distributed as dist + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +TOOL_FORMAT_VERSION = 1 +MIB = 1024 ** 2 +EXPECTED_TEACHER_COUNT = 1537 +EXPECTED_SOURCE_COMMIT = "6876159a11b4df116f30f667f8c9888617df0751" +SOURCE_NUM_FRAMES = 81 +FRAME_INDICES = (0, 5, 11, 16, 21, 27, 32, 37, 43, 48, 53, 59, 64, 69, 75, 80) +INPUT_SIZE = 256 +PATCH_SIZE = 16 +PATCH_GRID_SIZE = 16 +SPATIAL_AXIS_INDICES = (0, 2, 4, 6, 9, 11, 13, 15) +SPATIAL_TOKEN_INDICES = tuple( + row * PATCH_GRID_SIZE + column + for row in SPATIAL_AXIS_INDICES + for column in SPATIAL_AXIS_INDICES +) +PER_FRAME_TOKENS = 64 +TOTAL_TOKENS = len(FRAME_INDICES) * PER_FRAME_TOKENS +FEATURE_DIM = 1024 +FORWARD_CHUNK_FRAMES = 4 +NUM_LANDMARKS = 256 +NYSTROM_SEED = 0 +MEDIAN_MAX_SAMPLES = EXPECTED_TEACHER_COUNT +KMEANS_ITERATIONS = 20 +NYSTROM_RIDGE = 1.0e-6 +KERNEL_CHUNK_SIZE = 32 +WAN_VAE_MODEL_NAME = "Wan2.1-T2V-1.3B" +WAN_VAE_MEAN = ( + -0.7571, -0.7089, -0.9113, 0.1075, -0.1745, 0.9653, -0.1517, 1.5508, + 0.4134, -0.0715, 0.5517, -0.3632, -0.1922, -0.9497, 0.2503, -0.2921, +) +WAN_VAE_STD = ( + 2.8184, 1.4541, 2.3275, 2.6558, 1.2196, 1.7708, 2.6052, 2.0743, + 3.2687, 2.1526, 2.8652, 1.5579, 1.6382, 1.1253, 2.8251, 1.9160, +) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Build the fixed full-1537 DINOv3 ViT-L/16 video-matrix " + "Nyström teacher reference." + ) + ) + parser.add_argument("--teacher-lmdb", type=Path, required=True) + parser.add_argument("--repo-path", type=Path, required=True) + parser.add_argument("--weight-path", type=Path, required=True) + parser.add_argument( + "--source-commit", type=str, default=EXPECTED_SOURCE_COMMIT) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--batch-size", type=int, default=1) + parser.add_argument( + "--expected-teacher-count", type=int, default=EXPECTED_TEACHER_COUNT) + parser.add_argument( + "--forward-chunk-frames", type=int, default=FORWARD_CHUNK_FRAMES) + parser.add_argument("--num-landmarks", type=int, default=NUM_LANDMARKS) + parser.add_argument("--seed", type=int, default=NYSTROM_SEED) + parser.add_argument( + "--median-max-samples", type=int, default=MEDIAN_MAX_SAMPLES) + parser.add_argument("--bandwidth", type=float, default=None) + parser.add_argument( + "--kmeans-iterations", type=int, default=KMEANS_ITERATIONS) + parser.add_argument("--ridge", type=float, default=NYSTROM_RIDGE) + parser.add_argument("--chunk-size", type=int, default=KERNEL_CHUNK_SIZE) + parser.add_argument("--tmp-dir", type=Path, default=None) + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--log-every", type=int, default=25) + parser.add_argument("--distributed-timeout-minutes", type=int, default=360) + parser.add_argument("--dry-run", action="store_true") + return parser + + +def _validate_args(args: argparse.Namespace) -> None: + positive = { + "batch_size": args.batch_size, + "expected_teacher_count": args.expected_teacher_count, + "forward_chunk_frames": args.forward_chunk_frames, + "num_landmarks": args.num_landmarks, + "median_max_samples": args.median_max_samples, + "kmeans_iterations": args.kmeans_iterations, + "chunk_size": args.chunk_size, + "distributed_timeout_minutes": args.distributed_timeout_minutes, + } + invalid = {name: value for name, value in positive.items() if int(value) <= 0} + if invalid: + raise ValueError(f"arguments must be positive: {invalid}") + if int(args.expected_teacher_count) != EXPECTED_TEACHER_COUNT: + raise ValueError( + "the canonical reference requires exactly " + f"{EXPECTED_TEACHER_COUNT} teachers") + if int(args.forward_chunk_frames) != FORWARD_CHUNK_FRAMES: + raise ValueError( + f"--forward-chunk-frames must be {FORWARD_CHUNK_FRAMES}") + if int(args.num_landmarks) != NUM_LANDMARKS: + raise ValueError(f"--num-landmarks must be {NUM_LANDMARKS}") + if int(args.seed) != NYSTROM_SEED: + raise ValueError(f"--seed must be {NYSTROM_SEED}") + if int(args.median_max_samples) != MEDIAN_MAX_SAMPLES: + raise ValueError( + f"--median-max-samples must be {MEDIAN_MAX_SAMPLES}") + if args.bandwidth is not None: + raise ValueError( + "the canonical reference requires median-estimated bandwidth") + if int(args.kmeans_iterations) != KMEANS_ITERATIONS: + raise ValueError( + f"--kmeans-iterations must be {KMEANS_ITERATIONS}") + if not math.isclose( + float(args.ridge), NYSTROM_RIDGE, rel_tol=0.0, abs_tol=0.0 + ): + raise ValueError(f"--ridge must be {NYSTROM_RIDGE}") + if int(args.chunk_size) != KERNEL_CHUNK_SIZE: + raise ValueError(f"--chunk-size must be {KERNEL_CHUNK_SIZE}") + if str(args.source_commit) != EXPECTED_SOURCE_COMMIT: + raise ValueError( + "--source-commit must pin the approved DINOv3 source commit " + f"{EXPECTED_SOURCE_COMMIT}") + if float(args.ridge) <= 0.0 or not math.isfinite(float(args.ridge)): + raise ValueError("--ridge must be finite and positive") + if args.bandwidth is not None and ( + float(args.bandwidth) <= 0.0 + or not math.isfinite(float(args.bandwidth)) + ): + raise ValueError("--bandwidth must be finite and positive") + if int(args.log_every) < 0: + raise ValueError("--log-every must be non-negative") + + +def _git_head(repo_path: Path) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo_path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimeError( + f"failed to inspect DINOv3 source checkout {repo_path}") from exc + return result.stdout.strip() + + +def _git_source_status(repo_path: Path) -> str: + try: + result = subprocess.run( + [ + "git", + "-C", + str(repo_path), + "status", + "--porcelain", + "--untracked-files=all", + ], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimeError( + f"failed to inspect DINOv3 source worktree {repo_path}") from exc + return result.stdout + + +def _inspect_assets(args: argparse.Namespace) -> Dict[str, Any]: + repo_path = args.repo_path.expanduser().resolve() + weight_path = args.weight_path.expanduser().resolve() + if not repo_path.is_dir(): + raise FileNotFoundError(f"DINOv3 source directory not found: {repo_path}") + if not weight_path.is_file(): + raise FileNotFoundError(f"DINOv3 weight file not found: {weight_path}") + source_commit = _git_head(repo_path) + if source_commit != str(args.source_commit): + raise ValueError( + "DINOv3 source checkout commit mismatch: " + f"{source_commit} != {args.source_commit}") + source_status = _git_source_status(repo_path) + if source_status.strip(): + raise ValueError( + "DINOv3 source checkout has worktree changes; refusing " + "to build a reference from unpinned source") + stat = weight_path.stat() + return { + "repo_path": str(repo_path), + "weight_path": str(weight_path), + "source_commit": source_commit, + "weight_size_bytes": int(stat.st_size), + "weight_mtime_ns": int(stat.st_mtime_ns), + } + + +def _inspect_lmdb(path: Path) -> Dict[str, Any]: + try: + import lmdb + except ImportError as exc: + raise ImportError( + "python-lmdb is required; use the same Python environment as training" + ) from exc + from utils.lmdb import get_array_shape_from_lmdb + + resolved = path.expanduser().resolve() + if not resolved.is_dir(): + raise FileNotFoundError(f"teacher LMDB directory not found: {resolved}") + env = lmdb.open( + str(resolved), readonly=True, lock=False, readahead=False, meminit=False) + try: + latents_shape = tuple( + int(value) for value in get_array_shape_from_lmdb(env, "latents")) + prompts_shape = tuple( + int(value) for value in get_array_shape_from_lmdb(env, "prompts")) + finally: + env.close() + if len(latents_shape) != 6: + raise ValueError( + "teacher LMDB latents must be [N,trajectory,T,C,H,W], got " + f"{latents_shape}") + if prompts_shape != (latents_shape[0],): + raise ValueError( + f"teacher prompts/latents count mismatch: {prompts_shape} vs " + f"{latents_shape}") + data_path = resolved / "data.mdb" + stat = data_path.stat() + return { + "path": str(resolved), + "latents_shape": latents_shape, + "prompts_shape": prompts_shape, + "data_mdb_size_bytes": int(stat.st_size), + "data_mdb_mtime_ns": int(stat.st_mtime_ns), + } + + +def _rank_indices(count: int, rank: int, world_size: int) -> range: + return range(int(rank), int(count), int(world_size)) + + +def _required_temp_bytes(count: int) -> int: + feature_bytes = int(count) * TOTAL_TOKENS * FEATURE_DIM * 2 + return int(math.ceil(feature_bytes * 1.15)) + 64 * MIB + + +def _required_output_bytes() -> int: + landmark_bytes = NUM_LANDMARKS * TOTAL_TOKENS * FEATURE_DIM * 4 + return int(math.ceil(landmark_bytes * 1.25)) + 32 * MIB + + +def _choose_temp_parent( + requested: Optional[Path], required_bytes: int +) -> Tuple[Path, int]: + candidates: Sequence[Path] = ( + (requested.expanduser(),) + if requested is not None + else (Path("/dev/shm"), Path(tempfile.gettempdir())) + ) + errors = [] + for candidate in candidates: + try: + candidate = candidate.resolve() + if not candidate.is_dir(): + errors.append(f"{candidate}: not a directory") + continue + if not os.access(candidate, os.W_OK | os.X_OK): + errors.append(f"{candidate}: not writable") + continue + free = int(shutil.disk_usage(candidate).free) + if free < required_bytes: + errors.append( + f"{candidate}: {free / MIB:.1f} MiB free, " + f"{required_bytes / MIB:.1f} MiB required") + continue + return candidate, free + except OSError as exc: + errors.append(f"{candidate}: {exc}") + raise RuntimeError( + "no temporary shard directory has sufficient capacity: " + + "; ".join(errors)) + + +def _check_output( + output: Path, *, required_bytes: int, overwrite: bool +) -> Dict[str, Any]: + output = output.expanduser().resolve() + if output.exists() and not overwrite: + raise FileExistsError( + f"output already exists: {output}; pass --overwrite to replace it") + existing = output.parent + while not existing.exists() and existing != existing.parent: + existing = existing.parent + if not existing.is_dir(): + raise FileNotFoundError( + f"no existing output parent for capacity check: {output.parent}") + free = int(shutil.disk_usage(existing).free) + if free < required_bytes: + raise RuntimeError( + f"output filesystem has only {free / MIB:.1f} MiB free; " + f"{required_bytes / MIB:.1f} MiB required") + return { + "path": str(output), + "capacity_parent": str(existing), + "free_bytes": free, + "required_bytes": required_bytes, + } + + +def _plan(args: argparse.Namespace, world_size: int) -> Dict[str, Any]: + lmdb = _inspect_lmdb(args.teacher_lmdb) + count = int(lmdb["latents_shape"][0]) + if count != EXPECTED_TEACHER_COUNT: + raise ValueError( + "canonical DINOv3 reference requires the complete 1,537-row LMDB; " + f"found {count} rows") + if int(args.num_landmarks) > count: + raise ValueError("number of landmarks exceeds teacher population") + assets = _inspect_assets(args) + temp_required = _required_temp_bytes(count) + temp_parent, temp_free = _choose_temp_parent(args.tmp_dir, temp_required) + output = _check_output( + args.output, + required_bytes=_required_output_bytes(), + overwrite=bool(args.overwrite), + ) + return { + "tool_format_version": TOOL_FORMAT_VERSION, + "teacher_lmdb": lmdb, + "assets": assets, + "selected_samples": count, + "world_size": int(world_size), + "samples_per_rank": [ + len(_rank_indices(count, rank, world_size)) + for rank in range(world_size) + ], + "feature_geometry": { + "source_num_frames": SOURCE_NUM_FRAMES, + "frame_indices": list(FRAME_INDICES), + "input_size": INPUT_SIZE, + "patch_size": PATCH_SIZE, + "patch_grid_size": PATCH_GRID_SIZE, + "spatial_axis_indices": list(SPATIAL_AXIS_INDICES), + "spatial_token_indices": list(SPATIAL_TOKEN_INDICES), + "raw_tokens_per_frame": PATCH_GRID_SIZE ** 2, + "per_frame_tokens": PER_FRAME_TOKENS, + "num_tokens": TOTAL_TOKENS, + "feature_dim": FEATURE_DIM, + "sample_shape": [TOTAL_TOKENS, FEATURE_DIM], + "concat_order": "frame_major_then_spatial_row_major", + }, + "num_landmarks": NUM_LANDMARKS, + "temporary": { + "parent": str(temp_parent), + "required_bytes": temp_required, + "free_bytes": temp_free, + }, + "output": output, + } + + +def _setup_distributed(timeout_minutes: int) -> Tuple[torch.device, int, int]: + world_size = int(os.environ.get("WORLD_SIZE", "1")) + rank = int(os.environ.get("RANK", "0")) + local_rank = int(os.environ.get("LOCAL_RANK", "0")) + if not torch.cuda.is_available(): + raise RuntimeError( + "reference extraction requires CUDA; use --dry-run on CPU") + torch.cuda.set_device(local_rank) + if world_size > 1: + dist.init_process_group( + backend="nccl", + timeout=timedelta(minutes=int(timeout_minutes)), + ) + rank = dist.get_rank() + world_size = dist.get_world_size() + return torch.device("cuda", local_rank), rank, world_size + + +def _barrier() -> None: + if dist.is_available() and dist.is_initialized(): + dist.barrier() + + +def _broadcast_rank0_object(value: Any, rank: int) -> Any: + if not (dist.is_available() and dist.is_initialized()): + return value + payload = [value if rank == 0 else None] + dist.broadcast_object_list(payload, src=0) + return payload[0] + + +def _atomic_torch_save(value: Any, path: Path) -> None: + temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}") + try: + torch.save(value, temporary) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def _canonical_sha256(value: Dict[str, Any]) -> str: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _file_identity(path: Path) -> Dict[str, Any]: + resolved = path.expanduser().resolve(strict=True) + if not resolved.is_file(): + raise ValueError(f"identity target is not a regular file: {resolved}") + before = resolved.stat() + digest = hashlib.sha256() + with resolved.open("rb") as handle: + while True: + block = handle.read(8 << 20) + if not block: + break + digest.update(block) + after = resolved.stat() + if ( + int(before.st_size) != int(after.st_size) + or int(before.st_mtime_ns) != int(after.st_mtime_ns) + ): + raise RuntimeError(f"identity target changed while hashing: {resolved}") + return { + "path": str(resolved), + "size_bytes": int(after.st_size), + "mtime_ns": int(after.st_mtime_ns), + "sha256": digest.hexdigest(), + } + + +def _vae_identity() -> Dict[str, Any]: + """Resolve the exact Wan decoder used before DINOv3 extraction.""" + from utils.paths import model_path + + weights = model_path(WAN_VAE_MODEL_NAME) / "Wan2.1_VAE.pth" + identity = { + "wrapper": "utils.wan_wrapper.WanVAEWrapper", + "wrapper_source": _file_identity(REPO_ROOT / "utils" / "wan_wrapper.py"), + "model_name": WAN_VAE_MODEL_NAME, + "weights": _file_identity(weights), + "latent_channels": 16, + "latent_mean": list(WAN_VAE_MEAN), + "latent_std": list(WAN_VAE_STD), + "mean_std_contract": "WanVAEWrapper.inline_constants.v1", + "compute_dtype": "bfloat16", + "decode_use_cache": False, + "decode_output_range": "[-1,1]", + } + identity["identity_sha256"] = _canonical_sha256(identity) + return identity + + +def _validate_reference_current_assets( + pack: Mapping[str, Any], + *, + repo_path: Path, + weight_path: Path, + teacher_lmdb: Path, +) -> Dict[str, Any]: + """Require a validated reference to match every current production asset. + + Callers must first run ``load_reference_pack`` (which verifies schema and + content hash). This second gate prevents a valid but stale pack from being + accepted after the DINO checkpoint, Wan VAE, or teacher LMDB changes. + """ + if not isinstance(pack, Mapping): + raise TypeError("reference pack must be a mapping") + metadata = pack.get("metadata") + if not isinstance(metadata, Mapping): + raise ValueError("reference pack metadata is missing") + feature = metadata.get("feature") + reference = metadata.get("reference") + if not isinstance(feature, Mapping) or not isinstance(reference, Mapping): + raise ValueError("reference feature/source metadata is missing") + source = reference.get("source") + saved_vae = reference.get("vae") + if not isinstance(source, Mapping) or not isinstance(saved_vae, Mapping): + raise ValueError("reference source or Wan VAE identity is missing") + + from video_fd.encoders.dinov3_dense_metadata import file_sha256 + + resolved_repo = repo_path.expanduser().resolve(strict=True) + resolved_weight = weight_path.expanduser().resolve(strict=True) + current_weight_sha256 = file_sha256(resolved_weight) + expected_feature_identity = { + "repo_path": str(resolved_repo), + "weight_path": str(resolved_weight), + "weight_sha256": current_weight_sha256, + } + feature_mismatches = { + key: {"saved": feature.get(key), "current": value} + for key, value in expected_feature_identity.items() + if feature.get(key) != value + } + if feature_mismatches: + raise ValueError( + "reference DINOv3 asset identity does not match current assets: " + f"{feature_mismatches}") + + current_vae = _vae_identity() + if dict(saved_vae) != current_vae: + raise ValueError( + "reference Wan VAE identity does not match current assets") + + current_lmdb = _inspect_lmdb(teacher_lmdb) + expected_source_identity = { + "lmdb_path": current_lmdb["path"], + "latents_shape": list(current_lmdb["latents_shape"]), + "prompts_shape": list(current_lmdb["prompts_shape"]), + "selected_samples": int(current_lmdb["latents_shape"][0]), + "data_mdb_size_bytes": int(current_lmdb["data_mdb_size_bytes"]), + "data_mdb_mtime_ns": int(current_lmdb["data_mdb_mtime_ns"]), + } + source_mismatches = { + key: {"saved": source.get(key), "current": value} + for key, value in expected_source_identity.items() + if source.get(key) != value + } + if source_mismatches: + raise ValueError( + "reference teacher LMDB identity does not match current assets: " + f"{source_mismatches}") + return { + "dinov3_weight_sha256": current_weight_sha256, + "vae_identity_sha256": current_vae["identity_sha256"], + "teacher_lmdb": current_lmdb, + } + + +def _prompt_entry_digest(index: int, prompt: str) -> bytes: + digest = hashlib.sha256() + digest.update(int(index).to_bytes(8, byteorder="little", signed=False)) + digest.update(b"\0") + digest.update(str(prompt).encode("utf-8")) + return digest.digest() + + +def _load_dense_encoder(args: argparse.Namespace, device: torch.device): + """The sole runtime boundary with the separately-owned DINOv3 adapter.""" + from video_fd.encoders.dinov3_adapter import DINOv3DenseAdapter + + encoder = DINOv3DenseAdapter({ + "name": "dinov3_dense", + "repo_path": str(args.repo_path.expanduser().resolve()), + "weight_path": str(args.weight_path.expanduser().resolve()), + "source_commit": str(args.source_commit), + "source_num_frames": SOURCE_NUM_FRAMES, + "frame_indices": list(FRAME_INDICES), + "input_size": INPUT_SIZE, + "patch_size": PATCH_SIZE, + "patch_grid_size": PATCH_GRID_SIZE, + "spatial_axis_indices": list(SPATIAL_AXIS_INDICES), + "forward_chunk_frames": int(args.forward_chunk_frames), + "compute_dtype": "float32", + }) + return encoder.to(device=device, dtype=torch.float32).eval().requires_grad_(False) + + +@torch.no_grad() +def _extract_rank_shard( + args: argparse.Namespace, + plan: Dict[str, Any], + *, + device: torch.device, + rank: int, + world_size: int, + shard_path: Path, + vae_identity: Dict[str, Any], +) -> None: + from utils.dataset import ODERegressionLMDBDataset + from utils.wan_wrapper import WanVAEWrapper + + selected_count = int(plan["selected_samples"]) + assigned = list(_rank_indices(selected_count, rank, world_size)) + dataset = ODERegressionLMDBDataset( + str(args.teacher_lmdb.expanduser().resolve()), max_pair=selected_count) + if len(dataset) != selected_count: + raise RuntimeError( + f"LMDB length changed after planning: {len(dataset)} != " + f"{selected_count}") + vae = ( + WanVAEWrapper() + .to(device=device, dtype=torch.bfloat16) + .eval() + .requires_grad_(False) + ) + if not torch.equal(vae.mean, torch.tensor(WAN_VAE_MEAN)): + raise RuntimeError("WanVAEWrapper latent mean changed from the reference contract") + if not torch.equal(vae.std, torch.tensor(WAN_VAE_STD)): + raise RuntimeError("WanVAEWrapper latent std changed from the reference contract") + encoder = _load_dense_encoder(args, device) + feature_metadata = encoder.feature_metadata() + if not isinstance(feature_metadata, dict): + raise TypeError("DINOv3 adapter feature_metadata() must return a dict") + spatial_indices = torch.tensor( + SPATIAL_TOKEN_INDICES, device=device, dtype=torch.long) + + feature_chunks = [] + index_chunks = [] + prompt_digests = [] + expected_tail = tuple(plan["teacher_lmdb"]["latents_shape"][1:]) + started = time.monotonic() + for offset in range(0, len(assigned), int(args.batch_size)): + batch_indices = assigned[offset:offset + int(args.batch_size)] + items = [dataset[index] for index in batch_indices] + trajectories = [item["ode_latent"] for item in items] + for index, trajectory in zip(batch_indices, trajectories): + if tuple(trajectory.shape) != expected_tail: + raise ValueError( + f"LMDB row {index} shape {tuple(trajectory.shape)} != " + f"{expected_tail}") + if not bool(torch.isfinite(trajectory).all().item()): + raise FloatingPointError( + f"LMDB row {index} contains non-finite latents") + latent = torch.stack( + [trajectory[-1] for trajectory in trajectories], dim=0 + ).to(device=device, dtype=torch.bfloat16) + pixels = vae.decode_to_pixel(latent.detach()) + video01 = ((pixels + 1.0) * 0.5).clamp(0.0, 1.0) + if ( + video01.ndim != 5 + or int(video01.shape[1]) != SOURCE_NUM_FRAMES + or int(video01.shape[2]) != 3 + ): + raise ValueError( + "Wan VAE must decode [B,81,3,H,W], got " + f"{tuple(video01.shape)}") + features = encoder.forward_dense( + video01, + token_indices=spatial_indices, + gradient_checkpointing=False, + ).float() + expected_shape = (len(batch_indices), TOTAL_TOKENS, FEATURE_DIM) + if tuple(features.shape) != expected_shape: + raise RuntimeError( + f"DINOv3 features {tuple(features.shape)} != {expected_shape}") + if not bool(torch.isfinite(features).all().item()): + raise FloatingPointError( + f"non-finite DINOv3 features for indices {batch_indices}") + features_fp16 = features.to(dtype=torch.float16, device="cpu") + if not bool(torch.isfinite(features_fp16).all().item()): + raise FloatingPointError( + f"FP16 conversion overflowed for indices {batch_indices}") + feature_chunks.append(features_fp16) + index_chunks.append(torch.tensor(batch_indices, dtype=torch.long)) + prompt_digests.extend( + _prompt_entry_digest(index, item["prompts"]) + for index, item in zip(batch_indices, items) + ) + del latent, pixels, video01, features, features_fp16, items, trajectories + completed = min(offset + len(batch_indices), len(assigned)) + if int(args.log_every) > 0 and ( + completed == len(assigned) or completed % int(args.log_every) == 0 + ): + print( + f"[extract rank {rank}] {completed}/{len(assigned)} " + f"({time.monotonic() - started:.1f}s)", + flush=True, + ) + + if feature_chunks: + shard_features = torch.cat(feature_chunks, dim=0).contiguous() + shard_indices = torch.cat(index_chunks, dim=0).contiguous() + digest_tensor = torch.tensor( + [list(value) for value in prompt_digests], dtype=torch.uint8) + else: + shard_features = torch.empty( + 0, TOTAL_TOKENS, FEATURE_DIM, dtype=torch.float16) + shard_indices = torch.empty(0, dtype=torch.long) + digest_tensor = torch.empty(0, 32, dtype=torch.uint8) + payload = { + "shard_format_version": TOOL_FORMAT_VERSION, + "rank": int(rank), + "world_size": int(world_size), + "indices": shard_indices, + "features": shard_features, + "prompt_entry_sha256": digest_tensor, + "sample_shape": [TOTAL_TOKENS, FEATURE_DIM], + "feature_dtype": "float16", + "feature_metadata": feature_metadata, + "vae_identity": vae_identity, + } + _atomic_torch_save(payload, shard_path) + print( + f"[extract rank {rank}] wrote {shard_path} " + f"{tuple(shard_features.shape)}", + flush=True, + ) + del encoder, vae, dataset, payload, feature_chunks, shard_features + gc.collect() + torch.cuda.empty_cache() + + +def _load_and_merge_shards( + shard_dir: Path, + *, + world_size: int, + selected_count: int, + sample_shape: Sequence[int], +) -> Tuple[torch.Tensor, str, str, Dict[str, Any], Dict[str, Any]]: + feature_parts = [] + index_parts = [] + digest_parts = [] + feature_metadata = None + vae_identity = None + expected_tail = tuple(int(value) for value in sample_shape) + for rank in range(int(world_size)): + path = shard_dir / f"rank_{rank:05d}.pt" + if not path.is_file(): + raise FileNotFoundError(f"missing rank shard: {path}") + payload = torch.load(path, map_location="cpu", weights_only=False) + if int(payload.get("shard_format_version", -1)) != TOOL_FORMAT_VERSION: + raise ValueError(f"incompatible shard format in {path}") + if int(payload.get("rank", -1)) != rank: + raise ValueError(f"rank identity mismatch in {path}") + if int(payload.get("world_size", -1)) != int(world_size): + raise ValueError(f"world-size mismatch in {path}") + features = payload.get("features") + indices = payload.get("indices") + digests = payload.get("prompt_entry_sha256") + shard_feature_metadata = payload.get("feature_metadata") + shard_vae_identity = payload.get("vae_identity") + if ( + not isinstance(features, torch.Tensor) + or features.dtype != torch.float16 + or features.ndim != 3 + or tuple(features.shape[1:]) != expected_tail + or not bool(torch.isfinite(features).all().item()) + ): + raise ValueError(f"invalid FP16 feature shard {path}") + if ( + not isinstance(indices, torch.Tensor) + or indices.dtype != torch.long + or tuple(indices.shape) != (features.shape[0],) + ): + raise ValueError(f"invalid indices in {path}") + if ( + not isinstance(digests, torch.Tensor) + or digests.dtype != torch.uint8 + or tuple(digests.shape) != (features.shape[0], 32) + ): + raise ValueError(f"invalid prompt hashes in {path}") + if not isinstance(shard_feature_metadata, dict): + raise ValueError(f"missing DINOv3 feature metadata in {path}") + if feature_metadata is None: + feature_metadata = shard_feature_metadata + elif shard_feature_metadata != feature_metadata: + raise ValueError( + f"DINOv3 feature metadata differs across rank shards: {path}") + if not isinstance(shard_vae_identity, dict): + raise ValueError(f"missing Wan VAE identity in {path}") + if vae_identity is None: + vae_identity = shard_vae_identity + elif shard_vae_identity != vae_identity: + raise ValueError( + f"Wan VAE identity differs across rank shards: {path}") + feature_parts.append(features) + index_parts.append(indices) + digest_parts.append(digests) + features = torch.cat(feature_parts, dim=0) + indices = torch.cat(index_parts, dim=0) + digests = torch.cat(digest_parts, dim=0) + if int(features.shape[0]) != int(selected_count): + raise ValueError( + f"rank shards contain {features.shape[0]} samples, expected " + f"{selected_count}") + order = torch.argsort(indices, stable=True) + indices = indices.index_select(0, order) + if not torch.equal(indices, torch.arange(selected_count, dtype=torch.long)): + raise ValueError( + "rank shards do not cover every selected LMDB index exactly once") + features = features.index_select(0, order).contiguous() + digests = digests.index_select(0, order).contiguous() + prompt_hash = hashlib.sha256(digests.numpy().tobytes()).hexdigest() + feature_hash = hashlib.sha256( + features.view(torch.uint8).numpy().tobytes()).hexdigest() + if feature_metadata is None: + raise ValueError("rank shards contained no DINOv3 feature metadata") + if vae_identity is None: + raise ValueError("rank shards contained no Wan VAE identity") + return features, prompt_hash, feature_hash, feature_metadata, vae_identity + + +def _validate_feature_metadata( + args: argparse.Namespace, + feature_metadata: Dict[str, Any], +) -> Dict[str, Any]: + """Rebuild the canonical schema and reject even consistent bad shards.""" + from video_fd.encoders.dinov3_dense_metadata import ( + canonical_dinov3_dense_feature_metadata, + file_sha256, + ) + + repo_path = args.repo_path.expanduser().resolve() + if _git_head(repo_path) != str(args.source_commit): + raise RuntimeError("DINOv3 source commit changed during extraction") + if _git_source_status(repo_path).strip(): + raise RuntimeError("DINOv3 source worktree changed during extraction") + current_weight_sha256 = file_sha256(args.weight_path) + expected = canonical_dinov3_dense_feature_metadata( + repo_path=str(repo_path), + weight_path=str(args.weight_path.expanduser().resolve()), + source_commit=str(args.source_commit), + weight_sha256=current_weight_sha256, + source_num_frames=SOURCE_NUM_FRAMES, + frame_indices=list(FRAME_INDICES), + input_size=INPUT_SIZE, + patch_size=PATCH_SIZE, + patch_grid_size=PATCH_GRID_SIZE, + spatial_axis_indices=list(SPATIAL_AXIS_INDICES), + spatial_token_indices=list(SPATIAL_TOKEN_INDICES), + per_frame_tokens=PER_FRAME_TOKENS, + num_tokens=TOTAL_TOKENS, + forward_chunk_frames=FORWARD_CHUNK_FRAMES, + feature_dim=FEATURE_DIM, + ) + if feature_metadata != expected: + raise ValueError( + "rank-shard DINOv3 metadata does not equal the canonical schema") + return expected + + +def _reference_metadata( + args: argparse.Namespace, + plan: Dict[str, Any], + *, + feature_metadata: Dict[str, Any], + vae_identity: Dict[str, Any], + prompt_index_hash: str, + feature_bank_hash: str, +) -> Dict[str, Any]: + feature_metadata = _validate_feature_metadata(args, feature_metadata) + if vae_identity != _vae_identity(): + raise RuntimeError( + "Wan VAE asset or wrapper changed during teacher extraction") + data_mdb = Path(plan["teacher_lmdb"]["path"]) / "data.mdb" + data_stat = data_mdb.stat() + if ( + int(data_stat.st_size) + != int(plan["teacher_lmdb"]["data_mdb_size_bytes"]) + or int(data_stat.st_mtime_ns) + != int(plan["teacher_lmdb"]["data_mdb_mtime_ns"]) + ): + raise RuntimeError("teacher LMDB changed during DINOv3 extraction") + source = { + "kind": "causvid_ode_teacher_lmdb_final_latent", + "lmdb_path": plan["teacher_lmdb"]["path"], + "latents_shape": list(plan["teacher_lmdb"]["latents_shape"]), + "prompts_shape": list(plan["teacher_lmdb"]["prompts_shape"]), + "selected_samples": EXPECTED_TEACHER_COUNT, + "selected_index_start": 0, + "selected_index_stop_exclusive": EXPECTED_TEACHER_COUNT, + "trajectory_index": -1, + "lmdb_storage_dtype": "float16", + "data_mdb_size_bytes": int( + plan["teacher_lmdb"]["data_mdb_size_bytes"]), + "data_mdb_mtime_ns": int( + plan["teacher_lmdb"]["data_mdb_mtime_ns"]), + "prompt_index_sha256": prompt_index_hash, + "feature_bank_fp16_sha256": feature_bank_hash, + } + source["identity_sha256"] = _canonical_sha256(source) + return { + "feature": feature_metadata, + "reference": { + "source": source, + "vae": vae_identity, + "extraction": { + "tool": "tools/precompute_dinov3_dense_nystrom_reference.py", + "tool_format_version": TOOL_FORMAT_VERSION, + "world_size": int(plan["world_size"]), + "vae_compute_dtype": "bfloat16", + "vae_decode_use_cache": False, + "decoded_pixel_transform": "((pixels+1)*0.5).clamp(0,1)", + "dinov3_compute_dtype": "float32", + "forward_chunk_frames": FORWARD_CHUNK_FRAMES, + "rank_shard_dtype": "float16", + }, + "build_request": { + "num_landmarks": NUM_LANDMARKS, + "seed": NYSTROM_SEED, + "bandwidth": ( + None if args.bandwidth is None else float(args.bandwidth)), + "median_max_samples": int(args.median_max_samples), + "kmeans_iterations": int(args.kmeans_iterations), + "ridge": float(args.ridge), + "chunk_size": int(args.chunk_size), + }, + }, + } + + +def _build_and_save_reference( + args: argparse.Namespace, + plan: Dict[str, Any], + shard_dir: Path, + *, + device: torch.device, +) -> Dict[str, Any]: + from losses.video_nystrom_mmd import ( + build_video_nystrom_reference, + load_reference_pack, + save_reference_pack, + validate_reference_pack, + ) + + features, prompt_hash, feature_hash, feature_metadata, vae_identity = ( + _load_and_merge_shards( + shard_dir, + world_size=int(plan["world_size"]), + selected_count=EXPECTED_TEACHER_COUNT, + sample_shape=(TOTAL_TOKENS, FEATURE_DIM), + ) + ) + metadata = _reference_metadata( + args, + plan, + feature_metadata=feature_metadata, + vae_identity=vae_identity, + prompt_index_hash=prompt_hash, + feature_bank_hash=feature_hash, + ) + print( + f"[merge] building m={NUM_LANDMARKS} reference from " + f"{tuple(features.shape)} FP16 matrices on {device}", + flush=True, + ) + reference = features.to(device=device, non_blocking=False) + pack = build_video_nystrom_reference( + reference, + num_landmarks=NUM_LANDMARKS, + metadata=metadata, + bandwidth=args.bandwidth, + median_max_samples=int(args.median_max_samples), + kmeans_iterations=int(args.kmeans_iterations), + ridge=float(args.ridge), + chunk_size=int(args.chunk_size), + seed=NYSTROM_SEED, + ) + validate_reference_pack(pack) + del reference + torch.cuda.empty_cache() + + output = args.output.expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(f".{output.name}.tmp-{os.getpid()}") + try: + save_reference_pack(pack, temporary) + staged = load_reference_pack(temporary, map_location="cpu") + validate_reference_pack(staged) + if output.exists() and not bool(args.overwrite): + raise FileExistsError( + f"output appeared during construction: {output}") + os.replace(temporary, output) + finally: + if temporary.exists(): + temporary.unlink() + + # This final-path reload is the gate for temporary-shard deletion. + published = load_reference_pack(output, map_location="cpu") + validate_reference_pack(published) + _validate_published_pack_identity(pack, published) + return { + "output": str(output), + "samples": int(features.shape[0]), + "sample_shape": list(features.shape[1:]), + "num_landmarks": int(pack["landmarks"].shape[0]), + "sigma": float(torch.as_tensor(pack["sigma"]).item()), + "content_hash": str(pack["content_hash"]), + "prompt_index_sha256": prompt_hash, + "feature_bank_fp16_sha256": feature_hash, + "final_path_validated": True, + } + + +def _validate_published_pack_identity( + pack: Mapping[str, Any], + published: Mapping[str, Any], +) -> None: + """Require the final-path reload to preserve the built pack identity.""" + + if published.get("content_hash") != pack.get("content_hash"): + raise ValueError("published reference content hash changed after rename") + # The builder canonicalizes user metadata and adds its reserved ``nystrom`` + # block. Compare against that exact pack payload, not the pre-build input. + if published.get("metadata") != pack.get("metadata"): + raise ValueError("published reference metadata changed after rename") + + +def _remove_validated_shards( + shard_dir: Path, + build_summary: Dict[str, Any], +) -> None: + """Delete shards only with the explicit final-path validation receipt.""" + if build_summary.get("final_path_validated") is not True: + raise RuntimeError( + "refusing to delete shards before final reference validation") + output = Path(str(build_summary.get("output", ""))).expanduser().resolve() + if not output.is_file(): + raise FileNotFoundError( + f"refusing to delete shards; published reference is missing: {output}") + shutil.rmtree(shard_dir) + + +def _run(args: argparse.Namespace) -> None: + _validate_args(args) + env_world_size = int(os.environ.get("WORLD_SIZE", "1")) + env_rank = int(os.environ.get("RANK", "0")) + plan = _plan(args, env_world_size) + if bool(args.dry_run): + if env_rank == 0: + print(json.dumps(plan, indent=2, sort_keys=True), flush=True) + return + + device, rank, world_size = _setup_distributed( + int(args.distributed_timeout_minutes)) + if world_size != int(plan["world_size"]): + raise RuntimeError( + f"world size changed after planning: {world_size} != " + f"{plan['world_size']}") + shard_dir = None + success = False + validated_summary = None + try: + vae_result = None + if rank == 0: + try: + vae_result = {"identity": _vae_identity(), "error": None} + except Exception as exc: + vae_result = { + "identity": None, + "error": f"{type(exc).__name__}: {exc}", + } + vae_result = _broadcast_rank0_object(vae_result, rank) + if vae_result["error"] is not None: + raise RuntimeError( + "rank 0 could not resolve the Wan VAE identity: " + f"{vae_result['error']}") + vae_identity = vae_result["identity"] + + temp_result = None + if rank == 0: + try: + created = Path(tempfile.mkdtemp( + prefix="dinov3_dense_nystrom_", + dir=Path(plan["temporary"]["parent"]), + )) + temp_result = {"path": str(created), "error": None} + except Exception as exc: + temp_result = { + "path": None, + "error": f"{type(exc).__name__}: {exc}", + } + temp_result = _broadcast_rank0_object(temp_result, rank) + if temp_result["error"] is not None: + raise RuntimeError( + "rank 0 could not create shard directory: " + f"{temp_result['error']}") + shard_dir = Path(temp_result["path"]) + + _extract_rank_shard( + args, + plan, + device=device, + rank=rank, + world_size=world_size, + shard_path=shard_dir / f"rank_{rank:05d}.pt", + vae_identity=vae_identity, + ) + _barrier() + summary = None + build_error = None + if rank == 0: + try: + summary = _build_and_save_reference( + args, plan, shard_dir, device=device) + except Exception as exc: + import traceback + traceback.print_exc() + build_error = f"{type(exc).__name__}: {exc}" + outcome = _broadcast_rank0_object( + {"summary": summary, "error": build_error}, rank) + if outcome["error"] is not None: + raise RuntimeError( + f"rank 0 reference construction failed: {outcome['error']}") + _barrier() + validated_summary = outcome["summary"] + success = True + if rank == 0: + print(json.dumps( + {"status": "ok", **outcome["summary"]}, + indent=2, + sort_keys=True, + ), flush=True) + finally: + if rank == 0 and shard_dir is not None: + if success: + _remove_validated_shards(shard_dir, validated_summary) + print( + f"[cleanup] removed validated temporary shards {shard_dir}", + flush=True, + ) + else: + print( + f"[cleanup] preserving failed-run shards at {shard_dir}", + flush=True, + ) + if dist.is_available() and dist.is_initialized(): + dist.destroy_process_group() + + +def main(argv: Optional[Iterable[str]] = None) -> None: + _run(build_parser().parse_args(argv)) + + +if __name__ == "__main__": + main() diff --git a/14B/code/tools/precompute_vjepa2_dense_nystrom_reference.py b/14B/code/tools/precompute_vjepa2_dense_nystrom_reference.py new file mode 100644 index 0000000000000000000000000000000000000000..4bd71a39bc92991186391594bb813deefc46fd6c --- /dev/null +++ b/14B/code/tools/precompute_vjepa2_dense_nystrom_reference.py @@ -0,0 +1,1203 @@ +#!/usr/bin/env python3 +"""Precompute a video-level V-JEPA2 dense Nyström teacher reference. + +The extraction path deliberately matches ``WanKVMMD``: + +* read the final teacher latent from the ODE LMDB; +* decode with the frozen Wan VAE in bfloat16; +* map decoded pixels from ``[-1, 1]`` to ``[0, 1]``; +* select a deterministic contiguous temporal window, then apply the V-JEPA2 + adapter's 292 -> 256 spatial preprocessing; +* execute complete V-JEPA2 blocks 0..the configured dense layer; +* retain the canonical 128 aligned token positions as one ``[128, 1024]`` + video sample. + +Each torchrun rank writes one indexed FP16 feature shard to a temporary +directory. Rank 0 validates and merges the shards, builds a Nyström reference, +publishes it atomically, and removes the temporary directory only after the +published pack has passed validation. +""" + +from __future__ import annotations + +import argparse +from datetime import timedelta +import gc +import hashlib +import json +import math +import os +from pathlib import Path +import shutil +import sys +import tempfile +import time +from typing import Any, Dict, Iterable, Optional, Sequence, Tuple + +import torch +import torch.distributed as dist + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +TOOL_FORMAT_VERSION = 2 +MIB = 1024 ** 2 +FRAME_SAMPLING_CHOICES = ( + "center_contiguous", + "random_contiguous_per_video", +) +TEACHER_TEMPORAL_WINDOW_DOMAIN = "teacher" +WINDOW_STARTS_HASH_ENCODING = "lmdb_index_ordered_uint64_big_endian" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Build a video-level Nyström RBF-MMD teacher reference from an " + "ODE teacher LMDB using the exact Wan-VAE/V-JEPA2 dense pipeline." + ) + ) + parser.add_argument( + "--teacher-lmdb", + type=Path, + required=True, + help="ODE LMDB containing latents and prompts arrays.", + ) + parser.add_argument( + "--model-id", + type=str, + required=True, + help="Readable local Hugging Face V-JEPA2 model directory.", + ) + parser.add_argument( + "--output", + type=Path, + required=True, + help="Final atomic torch reference-pack path.", + ) + parser.add_argument( + "--max-samples", + type=int, + default=0, + help="Use the first N LMDB rows; 0 traverses the complete actual LMDB.", + ) + parser.add_argument( + "--batch-size", + type=int, + default=1, + help="Per-rank extraction batch size; 1 is the memory-safe default.", + ) + + parser.add_argument("--layer", type=int, default=15) + parser.add_argument("--num-frames", type=int, default=64) + parser.add_argument( + "--frame-sampling", + choices=FRAME_SAMPLING_CHOICES, + default="center_contiguous", + help=( + "center_contiguous preserves the legacy adapter-selected center " + "window; random_contiguous_per_video uses one stable LMDB-row-keyed " + "contiguous window." + ), + ) + parser.add_argument( + "--source-num-frames", + type=int, + default=81, + help=( + "Expected decoded source length for random contiguous sampling " + "(default: 81). It is ignored by the legacy center path." + ), + ) + parser.add_argument( + "--window-seed", + type=str, + default="0", + help=( + "Non-empty string seed for stateless per-video temporal-window " + "selection." + ), + ) + parser.add_argument("--num-tokens", type=int, default=128) + parser.add_argument("--resize-short-side", type=int, default=292) + parser.add_argument("--crop-size", type=int, default=256) + + parser.add_argument("--num-landmarks", type=int, default=256) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument( + "--median-max-samples", + type=int, + default=2048, + help="Deterministic subset cap for automatic median bandwidth.", + ) + parser.add_argument( + "--bandwidth", + type=float, + default=None, + help="Fixed Gaussian bandwidth; omitted lets the builder estimate it.", + ) + parser.add_argument("--kmeans-iterations", type=int, default=20) + parser.add_argument("--ridge", type=float, default=1.0e-6) + parser.add_argument( + "--chunk-size", + type=int, + default=256, + help="Sample chunk size used by rank-0 Nyström construction.", + ) + + parser.add_argument( + "--tmp-dir", + type=Path, + default=None, + help=( + "Parent for FP16 rank shards. By default /dev/shm is preferred " + "when writable and large enough, then the platform temp directory." + ), + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Atomically replace an existing output pack.", + ) + parser.add_argument( + "--log-every", + type=int, + default=25, + help="Rank-local extraction progress interval.", + ) + parser.add_argument( + "--distributed-timeout-minutes", + type=int, + default=360, + help="Long timeout while rank 0 builds the reference after extraction.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help=( + "Inspect LMDB/model geometry, distribution and disk budgets without " + "loading either model or writing files." + ), + ) + return parser + + +def _validate_args(args: argparse.Namespace) -> None: + positive = { + "batch_size": args.batch_size, + "num_frames": args.num_frames, + "source_num_frames": args.source_num_frames, + "num_tokens": args.num_tokens, + "resize_short_side": args.resize_short_side, + "crop_size": args.crop_size, + "num_landmarks": args.num_landmarks, + "median_max_samples": args.median_max_samples, + "kmeans_iterations": args.kmeans_iterations, + "chunk_size": args.chunk_size, + "distributed_timeout_minutes": args.distributed_timeout_minutes, + } + invalid = {name: value for name, value in positive.items() if int(value) <= 0} + if invalid: + raise ValueError(f"arguments must be positive: {invalid}") + if args.layer < 0: + raise ValueError("--layer must be non-negative") + if args.max_samples < 0: + raise ValueError("--max-samples must be non-negative") + if not isinstance(args.window_seed, str) or not args.window_seed: + raise ValueError("--window-seed must be a non-empty string") + if "\0" in args.window_seed: + raise ValueError("--window-seed must not contain NUL characters") + if ( + args.frame_sampling == "random_contiguous_per_video" + and args.source_num_frames < args.num_frames + ): + raise ValueError( + "--source-num-frames must be >= --num-frames for random " + "contiguous sampling" + ) + if args.seed != 0: + raise ValueError( + "--seed currently must be 0; landmark selection is deterministic") + if args.resize_short_side < args.crop_size: + raise ValueError("--resize-short-side must be >= --crop-size") + if args.ridge <= 0.0 or not math.isfinite(float(args.ridge)): + raise ValueError("--ridge must be finite and positive") + if args.bandwidth is not None and ( + args.bandwidth <= 0.0 or not math.isfinite(float(args.bandwidth)) + ): + raise ValueError("--bandwidth must be finite and positive") + if args.log_every < 0: + raise ValueError("--log-every must be non-negative") + + +def _read_model_geometry(model_id: str) -> Dict[str, int]: + model_path = Path(os.path.expanduser(model_id)) + if not model_path.is_dir(): + raise FileNotFoundError( + f"--model-id must be a readable local directory, got {model_id!r}" + ) + config_path = model_path / "config.json" + if not config_path.is_file(): + raise FileNotFoundError(f"V-JEPA2 config not found: {config_path}") + with config_path.open(encoding="utf-8") as handle: + config = json.load(handle) + required = ( + "hidden_size", + "num_hidden_layers", + "patch_size", + "tubelet_size", + ) + missing = [key for key in required if key not in config] + if missing: + raise ValueError( + f"V-JEPA2 config {config_path} is missing fields {missing}") + return {key: int(config[key]) for key in required} + + +def _inspect_lmdb(path: Path) -> Dict[str, Any]: + try: + import lmdb + except ImportError as exc: + raise ImportError( + "python-lmdb is required; use the same Python/PYTHONPATH as training" + ) from exc + + from utils.lmdb import get_array_shape_from_lmdb + + resolved = path.expanduser().resolve() + if not resolved.is_dir(): + raise FileNotFoundError(f"teacher LMDB directory not found: {resolved}") + env = lmdb.open( + str(resolved), + readonly=True, + lock=False, + readahead=False, + meminit=False, + ) + try: + latents_shape = tuple( + int(value) for value in get_array_shape_from_lmdb(env, "latents")) + prompts_shape = tuple( + int(value) for value in get_array_shape_from_lmdb(env, "prompts")) + finally: + env.close() + if len(latents_shape) != 6: + raise ValueError( + "teacher LMDB latents must be " + "[samples,trajectory_steps,frames,channels,height,width], got " + f"{latents_shape}" + ) + if prompts_shape != (latents_shape[0],): + raise ValueError( + "teacher LMDB prompts/latents count mismatch: " + f"{prompts_shape} vs {latents_shape}") + data_path = resolved / "data.mdb" + stat = data_path.stat() + return { + "path": str(resolved), + "latents_shape": latents_shape, + "prompts_shape": prompts_shape, + "data_mdb_size_bytes": int(stat.st_size), + "data_mdb_mtime_ns": int(stat.st_mtime_ns), + } + + +def _selected_count(total: int, max_samples: int) -> int: + count = int(total) if int(max_samples) == 0 else min(int(total), int(max_samples)) + if count <= 0: + raise ValueError("the selected teacher reference set is empty") + return count + + +def _rank_indices(count: int, rank: int, world_size: int) -> range: + return range(int(rank), int(count), int(world_size)) + + +def _window_starts_sha256(starts: Sequence[int]) -> str: + """Hash starts in selected-LMDB-index order using fixed-width integers.""" + digest = hashlib.sha256() + for start in starts: + value = int(start) + if value < 0: + raise ValueError("temporal window starts must be non-negative") + digest.update(value.to_bytes(8, byteorder="big", signed=False)) + return digest.hexdigest() + + +def _random_teacher_window_starts( + args: argparse.Namespace, + indices: Sequence[int], +) -> list[int]: + from video_fd.encoders.vjepa2_temporal_sampling import ( + stateless_temporal_window_start, + ) + + return [ + stateless_temporal_window_start( + str(args.window_seed), + TEACHER_TEMPORAL_WINDOW_DOMAIN, + int(index), + source_num_frames=int(args.source_num_frames), + window_num_frames=int(args.num_frames), + ) + for index in indices + ] + + +def _temporal_sampling_plan( + args: argparse.Namespace, + selected_count: int, +) -> Dict[str, Any]: + """Return JSON-safe temporal provenance visible in plan/dry-run output.""" + sampling = str(args.frame_sampling) + common = { + "frame_sampling": sampling, + "window_num_frames": int(args.num_frames), + "window_starts_sha256_encoding": WINDOW_STARTS_HASH_ENCODING, + } + if sampling == "center_contiguous": + # Preserve the legacy path: the adapter derives the center from the + # actual decoded length. source_num_frames/window_seed are random-mode + # inputs and intentionally do not change the old center contract. + return { + **common, + "source_num_frames": None, + "window_seed": None, + "window_domain": None, + "window_start_min": None, + "window_start_max": None, + "teacher_window_starts_sha256": None, + "selection": "legacy_adapter_center", + } + + starts = _random_teacher_window_starts( + args, list(range(int(selected_count)))) + return { + **common, + "source_num_frames": int(args.source_num_frames), + "window_seed": str(args.window_seed), + "window_domain": TEACHER_TEMPORAL_WINDOW_DOMAIN, + "window_start_min": 0, + "window_start_max": ( + int(args.source_num_frames) - int(args.num_frames)), + "teacher_window_starts_sha256": _window_starts_sha256(starts), + "selection": ( + "sha256(seed\\0domain\\0decimal_lmdb_index)" + "_first_u64_big_endian_mod_num_windows" + ), + } + + +def _select_teacher_temporal_windows( + video_clip_01: torch.Tensor, + sample_indices: Sequence[int], + args: argparse.Namespace, +) -> Tuple[torch.Tensor, list[int]]: + """Select teacher windows while retaining the legacy center behavior.""" + if video_clip_01.ndim != 5: + raise ValueError( + "decoded teacher video must be [B,T,3,H,W], got " + f"{tuple(video_clip_01.shape)}") + if video_clip_01.shape[0] != len(sample_indices): + raise ValueError( + "decoded teacher batch/sample index count mismatch: " + f"{video_clip_01.shape[0]} != {len(sample_indices)}") + total_frames = int(video_clip_01.shape[1]) + window_frames = int(args.num_frames) + if total_frames < window_frames: + raise ValueError( + f"decoded teacher video has {total_frames} frames, fewer than " + f"--num-frames={window_frames}") + + if str(args.frame_sampling) == "center_contiguous": + start = (total_frames - window_frames) // 2 + # Do not crop here: forwarding the original decoded video preserves the + # historical adapter-centered preprocessing byte for byte. + return video_clip_01, [start] * len(sample_indices) + + expected_source_frames = int(args.source_num_frames) + if total_frames != expected_source_frames: + raise ValueError( + "random contiguous teacher sampling requires the decoded source " + f"length to equal --source-num-frames: {total_frames} != " + f"{expected_source_frames}") + starts = _random_teacher_window_starts(args, sample_indices) + clips = torch.stack([ + video_clip_01[batch_index].narrow(0, start, window_frames) + for batch_index, start in enumerate(starts) + ], dim=0) + return clips, starts + + +def _required_temp_bytes( + count: int, + num_tokens: int, + feature_dim: int, +) -> int: + feature_bytes = int(count) * int(num_tokens) * int(feature_dim) * 2 + return int(math.ceil(feature_bytes * 1.15)) + 64 * MIB + + +def _required_output_bytes( + num_landmarks: int, + num_tokens: int, + feature_dim: int, +) -> int: + landmark_bytes = ( + int(num_landmarks) * int(num_tokens) * int(feature_dim) * 4) + return int(math.ceil(landmark_bytes * 1.25)) + 32 * MIB + + +def _choose_temp_parent( + requested: Optional[Path], + required_bytes: int, +) -> Tuple[Path, int]: + candidates: Sequence[Path] + if requested is not None: + candidates = (requested.expanduser(),) + else: + candidates = (Path("/dev/shm"), Path(tempfile.gettempdir())) + + errors = [] + for candidate in candidates: + try: + candidate = candidate.resolve() + if not candidate.is_dir(): + errors.append(f"{candidate}: not a directory") + continue + if not os.access(candidate, os.W_OK | os.X_OK): + errors.append(f"{candidate}: not writable") + continue + free = int(shutil.disk_usage(candidate).free) + if free < required_bytes: + errors.append( + f"{candidate}: {free / MIB:.1f} MiB free, " + f"{required_bytes / MIB:.1f} MiB required") + continue + return candidate, free + except OSError as exc: + errors.append(f"{candidate}: {exc}") + raise RuntimeError( + "no temporary shard directory has sufficient capacity: " + + "; ".join(errors) + ) + + +def _check_output( + output: Path, + *, + required_bytes: int, + overwrite: bool, +) -> Dict[str, Any]: + output = output.expanduser().resolve() + if output.exists() and not overwrite: + raise FileExistsError( + f"output already exists: {output}; pass --overwrite to replace it") + parent = output.parent + if not parent.exists(): + existing = parent + while not existing.exists() and existing != existing.parent: + existing = existing.parent + if not existing.is_dir(): + raise FileNotFoundError( + f"no existing output parent for capacity check: {parent}") + capacity_parent = existing + else: + capacity_parent = parent + free = int(shutil.disk_usage(capacity_parent).free) + if free < required_bytes: + raise RuntimeError( + f"output filesystem has only {free / MIB:.1f} MiB free; " + f"at least {required_bytes / MIB:.1f} MiB is required") + return { + "path": str(output), + "capacity_parent": str(capacity_parent), + "free_bytes": free, + } + + +def _plan(args: argparse.Namespace, world_size: int) -> Dict[str, Any]: + lmdb_info = _inspect_lmdb(args.teacher_lmdb) + geometry = _read_model_geometry(args.model_id) + if args.layer >= geometry["num_hidden_layers"]: + raise ValueError( + f"--layer={args.layer} exceeds model's " + f"{geometry['num_hidden_layers']} blocks") + if args.num_frames % geometry["tubelet_size"] != 0: + raise ValueError("--num-frames must be divisible by model tubelet_size") + if args.crop_size % geometry["patch_size"] != 0: + raise ValueError("--crop-size must be divisible by model patch_size") + raw_tokens = ( + args.num_frames // geometry["tubelet_size"] + * (args.crop_size // geometry["patch_size"]) ** 2 + ) + if args.num_tokens > raw_tokens: + raise ValueError( + f"--num-tokens={args.num_tokens} exceeds raw grid {raw_tokens}") + count = _selected_count(lmdb_info["latents_shape"][0], args.max_samples) + if args.num_landmarks > count: + raise ValueError( + f"--num-landmarks={args.num_landmarks} exceeds {count} samples") + + temp_required = _required_temp_bytes( + count, args.num_tokens, geometry["hidden_size"]) + temp_parent, temp_free = _choose_temp_parent(args.tmp_dir, temp_required) + output_required = _required_output_bytes( + args.num_landmarks, args.num_tokens, geometry["hidden_size"]) + output_info = _check_output( + args.output, + required_bytes=output_required, + overwrite=args.overwrite, + ) + per_rank = [ + len(_rank_indices(count, rank, world_size)) + for rank in range(world_size) + ] + return { + "tool_format_version": TOOL_FORMAT_VERSION, + "teacher_lmdb": lmdb_info, + "model_id": str(Path(args.model_id).expanduser().resolve()), + "model_geometry": geometry, + "feature_geometry": { + "raw_tokens": raw_tokens, + "sample_shape": [args.num_tokens, geometry["hidden_size"]], + }, + "selected_samples": count, + "temporal_sampling": _temporal_sampling_plan(args, count), + "world_size": int(world_size), + "samples_per_rank": per_rank, + "num_landmarks": int(args.num_landmarks), + "temporary": { + "parent": str(temp_parent), + "required_bytes": temp_required, + "free_bytes": temp_free, + }, + "output": { + **output_info, + "required_bytes": output_required, + }, + } + + +def _setup_distributed(timeout_minutes: int) -> Tuple[torch.device, int, int]: + world_size = int(os.environ.get("WORLD_SIZE", "1")) + rank = int(os.environ.get("RANK", "0")) + local_rank = int(os.environ.get("LOCAL_RANK", "0")) + if not torch.cuda.is_available(): + raise RuntimeError( + "reference feature extraction requires CUDA; use --dry-run for " + "CPU-only validation") + torch.cuda.set_device(local_rank) + if world_size > 1: + dist.init_process_group( + backend="nccl", + timeout=timedelta(minutes=int(timeout_minutes)), + ) + rank = dist.get_rank() + world_size = dist.get_world_size() + return torch.device("cuda", local_rank), rank, world_size + + +def _barrier() -> None: + if dist.is_available() and dist.is_initialized(): + dist.barrier() + + +def _broadcast_rank0_object(value: Any, rank: int) -> Any: + if not (dist.is_available() and dist.is_initialized()): + return value + payload = [value if rank == 0 else None] + dist.broadcast_object_list(payload, src=0) + return payload[0] + + +def _atomic_torch_save(value: Any, path: Path) -> None: + temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}") + try: + torch.save(value, temporary) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def _prompt_entry_digest(index: int, prompt: str) -> bytes: + digest = hashlib.sha256() + digest.update(int(index).to_bytes(8, byteorder="little", signed=False)) + digest.update(b"\0") + digest.update(str(prompt).encode("utf-8")) + return digest.digest() + + +@torch.no_grad() +def _extract_rank_shard( + args: argparse.Namespace, + plan: Dict[str, Any], + *, + device: torch.device, + rank: int, + world_size: int, + shard_path: Path, +) -> None: + from utils.dataset import ODERegressionLMDBDataset + from utils.wan_wrapper import WanVAEWrapper + from video_fd.encoders.vjepa2_adapter import VJEPA2Adapter + from video_fd.encoders.vjepa2_dense_metadata import ( + uniform_dense_token_indices, + ) + + selected_count = int(plan["selected_samples"]) + assigned = list(_rank_indices(selected_count, rank, world_size)) + feature_dim = int(plan["model_geometry"]["hidden_size"]) + raw_tokens = int(plan["feature_geometry"]["raw_tokens"]) + + dataset = ODERegressionLMDBDataset( + str(args.teacher_lmdb.expanduser().resolve()), + max_pair=selected_count, + ) + if len(dataset) != selected_count: + raise RuntimeError( + f"LMDB length changed after planning: {len(dataset)} != " + f"{selected_count}") + + vae = ( + WanVAEWrapper() + .to(device=device, dtype=torch.bfloat16) + .eval() + .requires_grad_(False) + ) + encoder = ( + VJEPA2Adapter({ + "name": "vjepa2_dense", + "model_id": str(Path(args.model_id).expanduser().resolve()), + "input_frames": int(args.num_frames), + "dense_num_frames": int(args.num_frames), + "input_size": int(args.crop_size), + "resize_short_side": int(args.resize_short_side), + "crop_mode": "center", + "dense_only": True, + "dense_layer_index": int(args.layer), + }) + .to(device=device, dtype=torch.float32) + .eval() + .requires_grad_(False) + ) + actual = { + "raw_tokens": int(encoder.dense_raw_tokens), + "feature_dim": int(encoder.dense_feature_dim), + "retained_blocks": int(encoder.retained_dense_blocks), + } + expected = { + "raw_tokens": raw_tokens, + "feature_dim": feature_dim, + "retained_blocks": int(args.layer) + 1, + } + if actual != expected: + raise RuntimeError( + f"loaded V-JEPA2 geometry mismatch: actual={actual}, " + f"expected={expected}") + + token_indices = uniform_dense_token_indices( + raw_tokens, + args.num_tokens, + device=device, + ) + feature_chunks = [] + index_chunks = [] + prompt_digests = [] + temporal_window_starts = [] + started = time.monotonic() + + for offset in range(0, len(assigned), args.batch_size): + batch_indices = assigned[offset:offset + args.batch_size] + items = [dataset[index] for index in batch_indices] + trajectories = [item["ode_latent"] for item in items] + expected_tail = tuple(plan["teacher_lmdb"]["latents_shape"][1:]) + for index, trajectory in zip(batch_indices, trajectories): + if tuple(trajectory.shape) != expected_tail: + raise ValueError( + f"LMDB row {index} shape {tuple(trajectory.shape)} != " + f"{expected_tail}") + if not torch.isfinite(trajectory).all(): + raise FloatingPointError( + f"LMDB row {index} contains non-finite latents") + + latent = torch.stack( + [trajectory[-1] for trajectory in trajectories], + dim=0, + ).to(device=device, dtype=torch.bfloat16) + pixels = vae.decode_to_pixel(latent.detach()) + video01 = ((pixels + 1.0) * 0.5).clamp(0.0, 1.0) + encoder_video01, batch_window_starts = ( + _select_teacher_temporal_windows( + video01, + batch_indices, + args, + ) + ) + features = encoder.forward_dense( + encoder_video01, + layer_index=int(args.layer), + token_indices=token_indices, + gradient_checkpointing=False, + ).float() + expected_shape = ( + len(batch_indices), + int(args.num_tokens), + feature_dim, + ) + if tuple(features.shape) != expected_shape: + raise RuntimeError( + f"V-JEPA2 features {tuple(features.shape)} != " + f"{expected_shape}") + if not torch.isfinite(features).all(): + raise FloatingPointError( + f"non-finite V-JEPA2 features for indices {batch_indices}") + features_fp16 = features.to(dtype=torch.float16, device="cpu") + if not torch.isfinite(features_fp16).all(): + raise FloatingPointError( + "FP16 shard conversion overflowed for indices " + f"{batch_indices}") + feature_chunks.append(features_fp16) + index_chunks.append(torch.tensor(batch_indices, dtype=torch.long)) + prompt_digests.extend( + _prompt_entry_digest(index, item["prompts"]) + for index, item in zip(batch_indices, items) + ) + temporal_window_starts.extend(batch_window_starts) + + del ( + latent, + pixels, + video01, + encoder_video01, + features, + features_fp16, + items, + trajectories, + ) + completed = min(offset + len(batch_indices), len(assigned)) + if args.log_every > 0 and ( + completed == len(assigned) or completed % args.log_every == 0 + ): + print( + f"[extract rank {rank}] {completed}/{len(assigned)} " + f"({time.monotonic() - started:.1f}s)", + flush=True, + ) + + if feature_chunks: + shard_features = torch.cat(feature_chunks, dim=0).contiguous() + shard_indices = torch.cat(index_chunks, dim=0).contiguous() + digest_tensor = torch.tensor( + [list(value) for value in prompt_digests], + dtype=torch.uint8, + ) + window_starts_tensor = torch.tensor( + temporal_window_starts, dtype=torch.long) + else: + shard_features = torch.empty( + 0, args.num_tokens, feature_dim, dtype=torch.float16) + shard_indices = torch.empty(0, dtype=torch.long) + digest_tensor = torch.empty(0, 32, dtype=torch.uint8) + window_starts_tensor = torch.empty(0, dtype=torch.long) + + payload = { + "shard_format_version": TOOL_FORMAT_VERSION, + "rank": int(rank), + "world_size": int(world_size), + "indices": shard_indices, + "features": shard_features, + "prompt_entry_sha256": digest_tensor, + "temporal_window_starts": window_starts_tensor, + "sample_shape": [int(args.num_tokens), feature_dim], + "feature_dtype": "float16", + } + _atomic_torch_save(payload, shard_path) + print( + f"[extract rank {rank}] wrote {shard_path} " + f"{tuple(shard_features.shape)}", + flush=True, + ) + del encoder, vae, dataset, payload, feature_chunks, shard_features + gc.collect() + torch.cuda.empty_cache() + + +def _load_and_merge_shards( + shard_dir: Path, + *, + world_size: int, + selected_count: int, + sample_shape: Sequence[int], +) -> Tuple[torch.Tensor, str, str, str]: + features_parts = [] + indices_parts = [] + digest_parts = [] + window_start_parts = [] + for rank in range(world_size): + path = shard_dir / f"rank_{rank:05d}.pt" + if not path.is_file(): + raise FileNotFoundError(f"missing rank shard: {path}") + payload = torch.load(path, map_location="cpu", weights_only=False) + if int(payload.get("shard_format_version", -1)) != TOOL_FORMAT_VERSION: + raise ValueError(f"incompatible shard format in {path}") + if int(payload.get("rank", -1)) != rank: + raise ValueError(f"rank identity mismatch in {path}") + if int(payload.get("world_size", -1)) != world_size: + raise ValueError(f"world-size mismatch in {path}") + features = payload.get("features") + indices = payload.get("indices") + digests = payload.get("prompt_entry_sha256") + window_starts = payload.get("temporal_window_starts") + expected_feature_tail = tuple(int(value) for value in sample_shape) + if ( + not isinstance(features, torch.Tensor) + or features.dtype != torch.float16 + or features.ndim != 3 + or tuple(features.shape[1:]) != expected_feature_tail + ): + raise ValueError( + f"invalid FP16 feature shard {path}: " + f"{getattr(features, 'shape', None)}") + if ( + not isinstance(indices, torch.Tensor) + or indices.dtype != torch.long + or tuple(indices.shape) != (features.shape[0],) + ): + raise ValueError(f"invalid indices in {path}") + if ( + not isinstance(digests, torch.Tensor) + or digests.dtype != torch.uint8 + or tuple(digests.shape) != (features.shape[0], 32) + ): + raise ValueError(f"invalid prompt hashes in {path}") + if ( + not isinstance(window_starts, torch.Tensor) + or window_starts.dtype != torch.long + or tuple(window_starts.shape) != (features.shape[0],) + or bool((window_starts < 0).any().item()) + ): + raise ValueError(f"invalid temporal window starts in {path}") + if not torch.isfinite(features).all(): + raise FloatingPointError(f"non-finite FP16 features in {path}") + features_parts.append(features) + indices_parts.append(indices) + digest_parts.append(digests) + window_start_parts.append(window_starts) + + features = torch.cat(features_parts, dim=0) + indices = torch.cat(indices_parts, dim=0) + digests = torch.cat(digest_parts, dim=0) + window_starts = torch.cat(window_start_parts, dim=0) + if features.shape[0] != selected_count: + raise ValueError( + f"rank shards contain {features.shape[0]} samples, expected " + f"{selected_count}") + order = torch.argsort(indices, stable=True) + indices = indices.index_select(0, order) + expected_indices = torch.arange(selected_count, dtype=torch.long) + if not torch.equal(indices, expected_indices): + raise ValueError( + "rank shards do not cover each selected LMDB index exactly once") + features = features.index_select(0, order).contiguous() + digests = digests.index_select(0, order).contiguous() + window_starts = window_starts.index_select(0, order).contiguous() + prompt_index_hash = hashlib.sha256( + digests.numpy().tobytes()).hexdigest() + feature_bank_hash = hashlib.sha256( + features.view(torch.uint8).numpy().tobytes()).hexdigest() + window_starts_hash = _window_starts_sha256(window_starts.tolist()) + return ( + features, + prompt_index_hash, + feature_bank_hash, + window_starts_hash, + ) + + +def _reference_metadata( + args: argparse.Namespace, + plan: Dict[str, Any], + *, + prompt_index_hash: str, + feature_bank_hash: str, + teacher_window_starts_hash: Optional[str] = None, +) -> Dict[str, Any]: + from video_fd.encoders.vjepa2_dense_metadata import ( + canonical_vjepa2_dense_feature_metadata, + ) + + geometry = plan["model_geometry"] + feature_kwargs = { + "model_id": str(Path(args.model_id).expanduser().resolve()), + "layer_index": args.layer, + "num_frames": args.num_frames, + "resize_short_side": args.resize_short_side, + "crop_size": args.crop_size, + "raw_tokens": plan["feature_geometry"]["raw_tokens"], + "num_tokens": args.num_tokens, + "feature_dim": geometry["hidden_size"], + } + if args.frame_sampling == "random_contiguous_per_video": + feature_kwargs.update({ + "frame_sampling": args.frame_sampling, + "source_num_frames": args.source_num_frames, + "window_seed": args.window_seed, + }) + feature_metadata = canonical_vjepa2_dense_feature_metadata( + **feature_kwargs) + source_metadata = { + "kind": "ode_teacher_lmdb_final_latent", + "lmdb_path": plan["teacher_lmdb"]["path"], + "latents_shape": list(plan["teacher_lmdb"]["latents_shape"]), + "prompts_shape": list(plan["teacher_lmdb"]["prompts_shape"]), + "selected_samples": int(plan["selected_samples"]), + "selected_index_start": 0, + "selected_index_stop_exclusive": int( + plan["selected_samples"]), + "trajectory_index": -1, + "lmdb_storage_dtype": "float16", + "data_mdb_size_bytes": int( + plan["teacher_lmdb"]["data_mdb_size_bytes"]), + "data_mdb_mtime_ns": int( + plan["teacher_lmdb"]["data_mdb_mtime_ns"]), + "prompt_index_sha256": prompt_index_hash, + "feature_bank_fp16_sha256": feature_bank_hash, + } + temporal_sampling = plan["temporal_sampling"] + if temporal_sampling["frame_sampling"] == ( + "random_contiguous_per_video" + ): + expected_starts_hash = temporal_sampling[ + "teacher_window_starts_sha256"] + if teacher_window_starts_hash != expected_starts_hash: + raise ValueError( + "merged teacher temporal-window starts do not match the " + "dry-run/plan identity: " + f"{teacher_window_starts_hash!r} != {expected_starts_hash!r}" + ) + source_metadata.update({ + "frame_sampling": temporal_sampling["frame_sampling"], + "source_num_frames": temporal_sampling["source_num_frames"], + "window_num_frames": temporal_sampling["window_num_frames"], + "window_seed": temporal_sampling["window_seed"], + "window_domain": temporal_sampling["window_domain"], + "window_start_min": temporal_sampling["window_start_min"], + "window_start_max": temporal_sampling["window_start_max"], + "teacher_window_starts_sha256": teacher_window_starts_hash, + "window_starts_sha256_encoding": ( + temporal_sampling["window_starts_sha256_encoding"]), + }) + + return { + "feature": feature_metadata, + "reference": { + "source": source_metadata, + "extraction": { + "tool": ( + "tools/precompute_vjepa2_dense_nystrom_reference.py"), + "tool_format_version": TOOL_FORMAT_VERSION, + "world_size": int(plan["world_size"]), + "vae_compute_dtype": "bfloat16", + "vae_decode_use_cache": False, + "decoded_pixel_transform": "((pixels+1)*0.5).clamp(0,1)", + "vjepa2_compute_dtype": "float32", + "rank_shard_dtype": "float16", + }, + "build_request": { + "num_landmarks": int(args.num_landmarks), + "seed": int(args.seed), + "bandwidth": ( + None if args.bandwidth is None else float(args.bandwidth)), + "median_max_samples": int(args.median_max_samples), + "kmeans_iterations": int(args.kmeans_iterations), + "ridge": float(args.ridge), + "chunk_size": int(args.chunk_size), + }, + }, + } + + +def _build_and_save_reference( + args: argparse.Namespace, + plan: Dict[str, Any], + shard_dir: Path, + *, + device: torch.device, +) -> Dict[str, Any]: + from losses.video_nystrom_mmd import ( + build_video_nystrom_reference, + load_reference_pack, + save_reference_pack, + validate_reference_pack, + ) + + ( + features, + prompt_index_hash, + feature_bank_hash, + teacher_window_starts_hash, + ) = _load_and_merge_shards( + shard_dir, + world_size=int(plan["world_size"]), + selected_count=int(plan["selected_samples"]), + sample_shape=plan["feature_geometry"]["sample_shape"], + ) + metadata = _reference_metadata( + args, + plan, + prompt_index_hash=prompt_index_hash, + feature_bank_hash=feature_bank_hash, + teacher_window_starts_hash=teacher_window_starts_hash, + ) + print( + f"[merge] building m={args.num_landmarks} Nyström reference from " + f"{tuple(features.shape)} FP16 video matrices on {device}", + flush=True, + ) + reference = features.to(device=device, non_blocking=False) + pack = build_video_nystrom_reference( + reference, + num_landmarks=int(args.num_landmarks), + metadata=metadata, + bandwidth=args.bandwidth, + median_max_samples=int(args.median_max_samples), + kmeans_iterations=int(args.kmeans_iterations), + ridge=float(args.ridge), + chunk_size=int(args.chunk_size), + seed=int(args.seed), + ) + validate_reference_pack(pack) + del reference + torch.cuda.empty_cache() + + output = args.output.expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(f".{output.name}.tmp-{os.getpid()}") + try: + save_reference_pack(pack, temporary) + published = load_reference_pack(temporary, map_location="cpu") + validate_reference_pack(published) + if output.exists() and not args.overwrite: + raise FileExistsError( + f"output appeared during construction: {output}") + os.replace(temporary, output) + finally: + if temporary.exists(): + temporary.unlink() + return { + "output": str(output), + "samples": int(features.shape[0]), + "sample_shape": list(features.shape[1:]), + "num_landmarks": int(pack["landmarks"].shape[0]), + "sigma": float(torch.as_tensor(pack["sigma"]).item()), + "content_hash": str(pack["content_hash"]), + "prompt_index_sha256": prompt_index_hash, + "feature_bank_fp16_sha256": feature_bank_hash, + "teacher_window_starts_sha256": teacher_window_starts_hash, + } + + +def _run(args: argparse.Namespace) -> None: + _validate_args(args) + env_world_size = int(os.environ.get("WORLD_SIZE", "1")) + env_rank = int(os.environ.get("RANK", "0")) + plan = _plan(args, env_world_size) + if args.dry_run: + if env_rank == 0: + print(json.dumps(plan, indent=2, sort_keys=True), flush=True) + return + + device, rank, world_size = _setup_distributed( + args.distributed_timeout_minutes) + if world_size != int(plan["world_size"]): + raise RuntimeError( + f"distributed world size changed after planning: {world_size} != " + f"{plan['world_size']}") + + temp_result = None + if rank == 0: + try: + parent = Path(plan["temporary"]["parent"]) + shard_dir = Path(tempfile.mkdtemp( + prefix="vjepa2_dense_nystrom_", + dir=parent, + )) + temp_result = {"path": str(shard_dir), "error": None} + except Exception as exc: + temp_result = { + "path": None, + "error": f"{type(exc).__name__}: {exc}", + } + temp_result = _broadcast_rank0_object(temp_result, rank) + if temp_result["error"] is not None: + raise RuntimeError( + f"rank 0 could not create shard directory: " + f"{temp_result['error']}") + shard_dir = Path(temp_result["path"]) + + success = False + try: + _extract_rank_shard( + args, + plan, + device=device, + rank=rank, + world_size=world_size, + shard_path=shard_dir / f"rank_{rank:05d}.pt", + ) + _barrier() + summary = None + build_error = None + if rank == 0: + try: + summary = _build_and_save_reference( + args, + plan, + shard_dir, + device=device, + ) + except Exception as exc: + import traceback + traceback.print_exc() + build_error = f"{type(exc).__name__}: {exc}" + outcome = _broadcast_rank0_object( + {"summary": summary, "error": build_error}, + rank, + ) + if outcome["error"] is not None: + raise RuntimeError( + f"rank 0 reference construction failed: {outcome['error']}") + _barrier() + success = True + if rank == 0: + print(json.dumps( + {"status": "ok", **outcome["summary"]}, + indent=2, + sort_keys=True, + ), flush=True) + finally: + if rank == 0: + if success: + shutil.rmtree(shard_dir) + print(f"[cleanup] removed temporary shards {shard_dir}", flush=True) + else: + print( + f"[cleanup] preserving failed-run shards at {shard_dir}", + flush=True, + ) + if dist.is_available() and dist.is_initialized(): + dist.destroy_process_group() + + +def main(argv: Optional[Iterable[str]] = None) -> None: + args = build_parser().parse_args(argv) + _run(args) + + +if __name__ == "__main__": + main() diff --git a/14B/code/train.py b/14B/code/train.py new file mode 100644 index 0000000000000000000000000000000000000000..865710d43919ccb0c7a4d44dab224b9a4c71965b --- /dev/null +++ b/14B/code/train.py @@ -0,0 +1,101 @@ +import argparse +import os +import torch.distributed as dist +from omegaconf import OmegaConf +from utils.config import load_config +from utils.optional_wandb import wandb + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--config_path", type=str, required=True) + parser.add_argument("--no_save", action="store_true") + parser.add_argument("--no_visualize", action="store_true") + parser.add_argument( + "--max_steps", "--training-updates", + type=int, + default=None, + help="Optional runtime override for training_updates (useful for smoke tests)", + ) + parser.add_argument( + "--fresh-video-local-samples", "--distribution-samples-per-rank", + type=int, + default=None, + help=( + "Optional smoke-test override for the number of fresh videos " + "collected per rank and optimizer update." + ), + ) + parser.add_argument( + "--total-batch-size", "--distribution-batch-size", + type=int, + default=None, + help="Optional matching logical global-batch override.", + ) + parser.add_argument( + "--eval-interval", + type=int, + default=None, + help="Optional runtime eval interval override; 0 disables eval.", + ) + parser.add_argument("--logdir", type=str, default="", help="Path to the directory to save logs") + parser.add_argument("--wandb-save-dir", type=str, default="", help="Path to the directory to save wandb logs") + parser.add_argument("--disable-wandb", action="store_true") + parser.add_argument( + "--preflight-only", + action="store_true", + help="Build and validate the complete distributed trainer, then exit before updates.", + ) + + args = parser.parse_args() + + config = load_config(args.config_path, "configs/default_config.yaml") + config.no_save = args.no_save + config.no_visualize = args.no_visualize + if args.max_steps is not None: + if args.max_steps <= 0: + parser.error("--max_steps must be positive") + config.max_steps = args.max_steps + if args.fresh_video_local_samples is not None: + if args.fresh_video_local_samples <= 0: + parser.error("--fresh-video-local-samples must be positive") + config.fresh_video_local_samples = args.fresh_video_local_samples + if args.total_batch_size is not None: + if args.total_batch_size <= 0: + parser.error("--total-batch-size must be positive") + config.total_batch_size = args.total_batch_size + if args.eval_interval is not None: + if args.eval_interval < 0: + parser.error("--eval-interval must be non-negative") + config.eval_interval = args.eval_interval + + # get the filename of config_path + config_name = os.path.basename(args.config_path).split(".")[0] + config.config_name = config_name + # Preserve the exact YAML identity for strict offline-reference checks. + # Runtime-only smoke overrides remain separate and do not change the + # generator trajectory contract encoded by this file. + config.config_path = os.path.realpath(args.config_path) + config.logdir = args.logdir + config.wandb_save_dir = args.wandb_save_dir + config.disable_wandb = args.disable_wandb + + if config.trainer != "score_distillation": + raise ValueError("This release uses the distribution matching trainer.") + from trainer.distribution_matching import DistributionMatchingTrainer + trainer = DistributionMatchingTrainer(config) + try: + if args.preflight_only: + if dist.is_available() and dist.is_initialized(): + dist.barrier() + if not dist.is_initialized() or dist.get_rank() == 0: + print("Distributed trainer preflight complete; no update or checkpoint written.") + else: + trainer.train() + finally: + wandb.finish() + if dist.is_available() and dist.is_initialized(): + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/14B/code/trainer/__init__.py b/14B/code/trainer/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9966dad727882cb67e5cedc944511f0dcc69f85f --- /dev/null +++ b/14B/code/trainer/__init__.py @@ -0,0 +1,31 @@ +"""Lazy public trainer exports. + +Loading one trainer must not import optional dependencies belonging only to a +different experiment. +""" + +from __future__ import annotations + +from importlib import import_module + + +_EXPORTS = { + "DiffusionTrainer": (".diffusion", "Trainer"), + "GANTrainer": (".gan", "Trainer"), + "ODETrainer": (".ode", "Trainer"), + "ScoreDistillationTrainer": (".distillation", "Trainer"), + "ARLStage1Trainer": (".arl", "ARLStage1Trainer"), + "ARLStage2Trainer": (".arl", "ARLStage2Trainer"), +} + +__all__ = list(_EXPORTS) + + +def __getattr__(name): + try: + module_name, attribute = _EXPORTS[name] + except KeyError as exc: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc + value = getattr(import_module(module_name, __name__), attribute) + globals()[name] = value + return value diff --git a/14B/code/trainer/distillation.py b/14B/code/trainer/distillation.py new file mode 100644 index 0000000000000000000000000000000000000000..b9fd851ed43777556d0fe028ac14641761f91b50 --- /dev/null +++ b/14B/code/trainer/distillation.py @@ -0,0 +1,6 @@ +"""Compatibility import; read trainer/distribution_matching.py for the implementation.""" +import sys +from . import distribution_matching as _implementation + +# Share the module object so existing monkeypatches and private imports still work. +sys.modules[__name__] = _implementation diff --git a/14B/code/trainer/distribution_matching.py b/14B/code/trainer/distribution_matching.py new file mode 100644 index 0000000000000000000000000000000000000000..abe9bd72c962d80860a9b905911bd8885027d496 --- /dev/null +++ b/14B/code/trainer/distribution_matching.py @@ -0,0 +1,3990 @@ +"""Training orchestration for distribution matching (paper Section 3.3). + +Start at _run_distribution_matching_step: collect the distribution batch, +form the feature-space objective, replay the differentiation batch, and update +only the generator. The long initialization/resume code preserves the released +asset and checkpoint contracts; it does not define a separate score model. +""" +import copy +import gc +import hashlib +import logging +import math +from collections.abc import Mapping + +from utils.dataset import ShardingLMDBDataset, cycle +from utils.dataset import ( + OpenVidPromptVideoDataset, + ODERegressionLMDBDataset, + TextDataset, +) +from utils.distributed import ( + EMA_FSDP, + fsdp_wrap, + fsdp_state_dict, + fsdp_optim_state_dict, + fsdp_load_optim_state_dict, + move_adamw_moment_state_, + launch_distributed_job, +) +from utils.misc import ( + init_wandb_from_config, + set_seed, + merge_dict_list, +) +from trainer.fresh_video_gradcache import FreshVideoGradCache +from trainer.feature_gradient_replay import ( + FeatureGradientReplay, +) +import torch.distributed as dist +from omegaconf import OmegaConf +from model import DistributionMatchingModel +import numpy as np +import random +import imageio.v3 as iio +import torch +from safetensors.torch import load_file as load_safetensors_file +from utils.optional_wandb import wandb +import time +import os +import shutil + + +def _canonicalize_experiment_identity_paths(value): + """Make shared asset identities independent of a compute-node mount alias.""" + local_root = "/path/to/local-assets" + logical_root = "/path/to/video-generation-assets" + if isinstance(value, str): + if value == local_root or value.startswith(local_root + "/"): + return logical_root + value[len(local_root):] + return value + if isinstance(value, Mapping): + return { + key: _canonicalize_experiment_identity_paths(item) + for key, item in value.items() + } + if isinstance(value, tuple): + return tuple( + _canonicalize_experiment_identity_paths(item) for item in value) + if isinstance(value, list): + return [ + _canonicalize_experiment_identity_paths(item) for item in value] + return value + + +def _dense_video_metric_contract(feature_type): + """Return the backend-owned prefix and universally required replay keys.""" + prefixes = { + "vjepa2_dense_latent": "vjepa2_dense", + "dinov3_dense_latent": "dinov3_dense", + "dual_dinov3_vjepa2_dense_latent": "dinov3_vjepa2_dense", + "triple_dinov3_vjepa2_videomae_v2_dense_latent": "triple_dense", + } + try: + prefix = prefixes[str(feature_type)] + except KeyError as exc: + raise ValueError( + "fresh-video GradCache requires a supported dense feature type; " + f"got {feature_type!r}" + ) from exc + return prefix, frozenset({ + "combined_latent_grad_norm", + f"{prefix}_feature_grad_norm", + f"{prefix}_video_grad_norm", + f"{prefix}_replay_max_abs", + f"{prefix}_latent_replay_max_abs", + }) + + + + + + +def _validated_resume_data_counter_override(value, *, resume_checkpoint): + """Validate an explicit lightweight-resume prompt-stream position. + + The counter is deliberately never derived from ``step`` or the current + world size. Changing the rank topology changes how many per-rank batches + correspond to one optimizer update, so callers must provide the desired + absolute per-rank batch counter explicitly. + """ + if value is None: + return None + if not resume_checkpoint: + raise ValueError( + "resume_data_counter_override is only valid with " + "resume_checkpoint") + if type(value) is not int: + raise TypeError( + "resume_data_counter_override must be a non-negative integer") + if value < 0: + raise ValueError( + "resume_data_counter_override must be a non-negative integer") + return value + + +def _cosine_learning_rate(update_index, *, start, end, total_updates): + """Endpoint-inclusive cosine LR for the optimizer update at update_index.""" + if type(update_index) is not int or update_index < 0: + raise ValueError( + "learning-rate update_index must be a non-negative integer") + if type(total_updates) is not int or total_updates <= 0: + raise ValueError( + "lr_schedule_total_steps must be a positive integer") + start = float(start) + end = float(end) + if not math.isfinite(start) or not math.isfinite(end): + raise ValueError("learning-rate endpoints must be finite") + if start <= 0.0 or end <= 0.0 or end > start: + raise ValueError( + "learning-rate endpoints must satisfy start >= end > 0") + if total_updates == 1: + return end + position = min(update_index, total_updates - 1) / (total_updates - 1) + return end + 0.5 * (start - end) * ( + 1.0 + math.cos(math.pi * position)) + + +def _learning_rate_schedule_identity(config): + """Validate and freeze the optimizer schedule used by this experiment.""" + schedule = str( + getattr(config, "lr_schedule", "constant")).strip().lower() + if schedule == "constant": + value = float(config.lr) + if not math.isfinite(value) or value <= 0.0: + raise ValueError( + "constant learning rate must be finite and positive") + return {"name": "constant", "value": value} + if schedule != "cosine": + raise ValueError(f"unsupported lr_schedule {schedule!r}") + total_updates = int(getattr(config, "lr_schedule_total_steps", 0)) + max_steps = int(getattr(config, "max_steps", 0)) + if not 0 < max_steps <= total_updates: + raise ValueError( + "endpoint-inclusive cosine schedule requires " + "0 < max_steps <= lr_schedule_total_steps") + start = float(config.lr) + end = float(getattr(config, "lr_final", 0.0)) + first = _cosine_learning_rate( + 0, start=start, end=end, total_updates=total_updates) + last = _cosine_learning_rate( + total_updates - 1, + start=start, + end=end, + total_updates=total_updates, + ) + if first != start or last != end: + raise RuntimeError("cosine learning-rate endpoint invariant failed") + return { + "name": "cosine_endpoint_inclusive_v1", + "start": start, + "end": end, + "total_updates": total_updates, + } + + +def _validated_fresh_video_generator_grad_sample_scale( + value, + *, + partial_gradient_enabled, +): + """Validate the explicit suffix-replay gradient compensation.""" + try: + scale = float(value) + except (TypeError, ValueError) as exc: + raise ValueError( + "fresh_video_generator_grad_sample_scale must be finite and " + "strictly positive" + ) from exc + if not math.isfinite(scale) or scale <= 0.0: + raise ValueError( + "fresh_video_generator_grad_sample_scale must be finite and " + "strictly positive") + if scale != 1.0 and not bool(partial_gradient_enabled): + raise ValueError( + "fresh_video_generator_grad_sample_scale may differ from 1 only " + "when a strict generator-gradient subsample is enabled") + return scale + + +def _scale_replay_video_cotangents(cotangents, scale): + """Scale replay gradients without changing the normalized MMD value. + + GradCache has already applied the configured MMD loss weight and the FSDP + world-size compensation at this point. The explicit partial-gradient + compensation is therefore applied exactly once, immediately before + prepare/replay consumes these cotangents. + """ + values = tuple(cotangents) + if float(scale) == 1.0: + return values + def scaled(cotangent): + if isinstance(cotangent, Mapping): + return { + key: (value * float(scale)).contiguous() + for key, value in cotangent.items() + } + return (cotangent * float(scale)).contiguous() + + return tuple(scaled(cotangent) for cotangent in values) + + +def _atomic_torch_save(value, path): + """Publish a checkpoint file only after ``torch.save`` fully succeeds.""" + temporary_path = f"{path}.tmp" + torch.save(value, temporary_path) + os.replace(temporary_path, path) + + +def _fresh_video_generator_grad_microbatches( + *, + local_samples, + grad_local_samples, + batch_size, +): + """Return how many trailing microbatches build generator graphs. + + ``grad_local_samples=0`` is the compatibility path and replays every + microbatch. A positive value keeps the complete forward feature batch for + the MMD scalar but replays only the trailing whole microbatches. No + inverse-probability rescaling is applied: earlier features are constants, + exactly matching a partial derivative of the full MMD objective. + """ + values = { + "local_samples": local_samples, + "grad_local_samples": grad_local_samples, + "batch_size": batch_size, + } + if any(type(value) is not int for value in values.values()): + raise TypeError( + "fresh-video generator-gradient geometry must use integers") + if local_samples <= 0 or batch_size <= 0: + raise ValueError("local_samples and batch_size must be positive") + if local_samples % batch_size != 0: + raise ValueError("local_samples must be divisible by batch_size") + if grad_local_samples < 0 or grad_local_samples > local_samples: + raise ValueError( + "grad_local_samples must be zero or lie in [1, local_samples]") + selected_samples = ( + local_samples if grad_local_samples == 0 else grad_local_samples) + if selected_samples % batch_size != 0: + raise ValueError( + "grad_local_samples must select complete physical microbatches") + return selected_samples // batch_size + + +def _transformer_block_types(wrapper, label): + model = getattr(wrapper, "model", None) + blocks = getattr(model, "blocks", None) + if blocks is None or len(blocks) == 0: + raise ValueError( + f"{label} transformer FSDP strategy requires model.blocks") + return {type(block) for block in blocks} + + +def _strict_sha256(value, label): + """Return a normalized SHA-256 string or reject ambiguous identity.""" + normalized = str(value or "").strip().lower() + if ( + len(normalized) != 64 + or any(character not in "0123456789abcdef" for character in normalized) + ): + raise ValueError(f"{label} must be a lowercase 64-character SHA-256") + return normalized + + +def _file_sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _fixed_openvid_experiment_identity( + config, + model, + ledger_metadata, + *, + forward_global_samples, + gradient_global_samples, + gradient_compensation, +): + """Build the immutable fixed-only identity saved in every checkpoint.""" + if ( + str(getattr(model, "feature_type", "")) + != "dual_dinov3_vjepa2_dense_latent" + or bool(getattr(model, "current_teacher_attraction_enabled", True)) + or str(getattr(config, "current_teacher_source", "")) != "none" + or int(getattr(config, "expected_current_teacher_samples", -1)) != 0 + ): + raise ValueError( + "fixed OpenVid experiment identity requires dual fixed-only mode") + branch_weights = { + name: float(value) + for name, value in model.latent_mmd.branch_weights.items() + } + if branch_weights != {"dinov3": 10.0, "vjepa2": 1.0}: + raise ValueError("fixed OpenVid branch weights must be exactly 10:1") + registry = getattr(model, "dual_branch_registry", {}) + if set(registry) != {"dinov3", "vjepa2"}: + raise ValueError("fixed OpenVid branch registry is incomplete") + branches = {} + for name in sorted(registry): + branch = registry[name] + branches[name] = { + "nystrom_landmarks": int(branch["nystrom_landmarks"]), + "reference_file_sha256": _strict_sha256( + branch["reference_file_sha256"], + f"{name} reference_file_sha256"), + "reference_hash": _strict_sha256( + branch["reference_hash"], f"{name} reference_hash"), + "reference_path": str(branch["reference_path"]), + "sample_shape": list(branch["sample_shape"]), + } + expected_branch_geometry = { + "dinov3": {"shape": [1024, 1024], "landmarks": 256}, + "vjepa2": {"shape": [1024, 1024], "landmarks": 1024}, + } + for name, expected in expected_branch_geometry.items(): + if ( + branches[name]["sample_shape"] != expected["shape"] + or branches[name]["nystrom_landmarks"] != expected["landmarks"] + ): + raise ValueError(f"fixed OpenVid {name} geometry changed") + + bundle_sha = _strict_sha256( + getattr(config, "dinov3_vjepa2_reference_bundle_manifest_sha256", ""), + "dinov3_vjepa2_reference_bundle_manifest_sha256", + ) + if bundle_sha != str(getattr( + model, "dinov3_vjepa2_reference_bundle_manifest_sha256", "")): + raise ValueError("fixed OpenVid model/config bundle SHA mismatch") + ledger_sha = _strict_sha256( + ledger_metadata.get("ledger_sha256", ""), "OpenVid ledger SHA-256") + sequence_sha = _strict_sha256( + ledger_metadata.get("video_name_sequence_sha256", ""), + "OpenVid video-name sequence SHA-256", + ) + if ( + int(ledger_metadata.get("reference_count", -1)) != 77_615 + or sequence_sha != _strict_sha256( + getattr(config, "openvid_video_name_sequence_sha256", ""), + "configured OpenVid video-name sequence SHA-256", + ) + ): + raise ValueError("fixed OpenVid ledger identity/count mismatch") + generator_sha = _strict_sha256( + getattr(config, "generator_ckpt_sha256", ""), + "generator_ckpt_sha256", + ) + generator_model = str(getattr( + config, "generator_ckpt_model_name", "")) + generator_recipe = str(getattr( + config, "generator_ckpt_ode_recipe", "")) + generator_step = int(getattr(config, "generator_ckpt_step", -1)) + generator_prompt_sha = _strict_sha256( + getattr(config, "generator_ckpt_prompt_manifest_sha256", ""), + "generator_ckpt_prompt_manifest_sha256", + ) + model_kwargs = dict(getattr(config, "model_kwargs", {})) + schedule = { + "denoising_step_list": list(config.denoising_step_list), + "model_timestep_shift": float(model_kwargs.get("timestep_shift", -1)), + "timestep_shift": float(config.timestep_shift), + "warp_denoising_step": bool(config.warp_denoising_step), + } + if ( + generator_model != "Wan2.1-T2V-14B" + or generator_recipe != "causvid_14b_16k" + or generator_step <= 0 + or str(model_kwargs.get("model_name", "")) != generator_model + or schedule != { + "denoising_step_list": [1000, 757, 522, 0], + "model_timestep_shift": 8.0, + "timestep_shift": 8.0, + "warp_denoising_step": False, + } + ): + raise ValueError("fixed OpenVid Wan-14B ODE/schedule identity changed") + forward = int(forward_global_samples) + gradient = int(gradient_global_samples) + if ( + (forward, gradient) != (256, 128) + or float(gradient_compensation) != 2.0 + ): + raise ValueError("fixed OpenVid partial-gradient geometry changed") + return { + "mode": "openvid77615_fixed_only_dual_v1", + "current_teacher_source": "none", + "reference_bundle": { + "path": str(config.dinov3_vjepa2_reference_bundle_manifest), + "sha256": bundle_sha, + }, + "ledger": { + "path": str(config.fixed_student_prompt_ledger), + "sha256": ledger_sha, + "reference_count": 77_615, + "video_name_sequence_sha256": sequence_sha, + }, + "generator_initialization": { + "path": str(config.generator_ckpt), + "sha256": generator_sha, + "model_name": generator_model, + "ode_recipe": generator_recipe, + "ode_step": generator_step, + "prompt_manifest_sha256": generator_prompt_sha, + }, + "schedule": schedule, + "branches": branches, + "branch_weights": branch_weights, + "partial_gradient": { + "forward_global_samples": forward, + "gradient_global_samples": gradient, + "teacher_global_samples": 0, + "full_student_partial_gradient": True, + "kss_denominator": 65_536, + "fixed_attraction_denominator": 256, + "teacher_cross_denominator": 0, + "teacher_self_denominator": 0, + "replay_cotangent_compensation": float( + gradient_compensation), + }, + "feature_protocol_identity": str(config.feature_protocol_identity), + } + + +class DistributionMatchingTrainer: + def __init__(self, config): + self.config = config + self.step = 0 + self.data_counter = 0 + + # Step 1: Initialize the distributed training environment (rank, seed, dtype, logging etc.) + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + + launch_distributed_job() + global_rank = dist.get_rank() + self.world_size = dist.get_world_size() + expected_world_size = int( + getattr(config, "expected_world_size", 0) or 0) + if expected_world_size and self.world_size != expected_world_size: + raise ValueError( + "distributed world-size mismatch: expected " + f"{expected_world_size}, got {self.world_size}") + self.uses_fresh_video_gradcache = ( + str(getattr(config, "distribution_loss", "")) + == "wan_kv_mmd" + and str(getattr(config, "wan_mmd_estimator", "ema_rff")) + == "nystrom_exact_repulsion" + ) + self.fresh_video_local_samples = int( + getattr(config, "fresh_video_local_samples", config.batch_size)) + self.fresh_video_physical_forward_batch_size = int(config.batch_size) + self.fresh_video_logical_micro_batch_size = int(getattr( + config, + "fresh_video_logical_micro_batch_size", + self.fresh_video_physical_forward_batch_size, + )) + self.fresh_video_physical_replay_batch_size = int(getattr( + config, + "fresh_video_physical_replay_batch_size", + self.fresh_video_physical_forward_batch_size, + )) + if self.uses_fresh_video_gradcache: + sizes = { + "fresh_video_local_samples": self.fresh_video_local_samples, + "fresh_video_logical_micro_batch_size": ( + self.fresh_video_logical_micro_batch_size), + "batch_size": self.fresh_video_physical_forward_batch_size, + "fresh_video_physical_replay_batch_size": ( + self.fresh_video_physical_replay_batch_size), + } + if any(value <= 0 for value in sizes.values()): + raise ValueError( + f"fresh-video execution sizes must be positive: {sizes}") + if ( + self.fresh_video_local_samples + % self.fresh_video_logical_micro_batch_size + or self.fresh_video_logical_micro_batch_size + % self.fresh_video_physical_forward_batch_size + or self.fresh_video_logical_micro_batch_size + % self.fresh_video_physical_replay_batch_size + ): + raise ValueError( + "VACE-aligned logical/physical batches must divide " + f"exactly: {sizes}") + self.fresh_video_microbatches = ( + self.fresh_video_local_samples + // self.fresh_video_logical_micro_batch_size) + else: + self.fresh_video_microbatches = 1 + expected_total_batch = self.fresh_video_local_samples * self.world_size + configured_total_batch = int( + getattr(config, "total_batch_size", expected_total_batch)) + if configured_total_batch != expected_total_batch: + raise ValueError( + "total_batch_size must equal fresh_video_local_samples * " + f"world_size; got {configured_total_batch} != " + f"{self.fresh_video_local_samples} * {self.world_size} " + f"({expected_total_batch})") + self.fresh_video_total_samples = configured_total_batch + configured_grad_local_samples = int(getattr( + config, + "fresh_video_generator_grad_local_samples", + self.fresh_video_local_samples, + )) + self.fresh_video_generator_grad_microbatches = ( + _fresh_video_generator_grad_microbatches( + local_samples=self.fresh_video_local_samples, + grad_local_samples=configured_grad_local_samples, + batch_size=self.fresh_video_logical_micro_batch_size, + ) + if self.uses_fresh_video_gradcache + else 1 + ) + self.fresh_video_generator_grad_local_samples = ( + configured_grad_local_samples + if self.uses_fresh_video_gradcache + else int(config.batch_size) + ) + self.fresh_video_generator_grad_subsample_enabled = bool( + self.uses_fresh_video_gradcache + and self.fresh_video_generator_grad_local_samples + < self.fresh_video_local_samples + ) + self.fresh_video_full_student_partial_gradient = bool(getattr( + config, "fresh_video_full_student_partial_gradient", False)) + if ( + self.fresh_video_full_student_partial_gradient + and not self.fresh_video_generator_grad_subsample_enabled + ): + raise ValueError( + "fresh_video_full_student_partial_gradient requires a strict " + "gradient suffix smaller than fresh_video_local_samples") + self.fresh_video_generator_grad_global_samples = ( + self.fresh_video_generator_grad_local_samples * self.world_size + ) + self.fresh_video_generator_grad_sample_scale = ( + _validated_fresh_video_generator_grad_sample_scale( + getattr( + config, + "fresh_video_generator_grad_sample_scale", + 1.0, + ), + partial_gradient_enabled=( + self.fresh_video_generator_grad_subsample_enabled), + ) + ) + configured_teacher_samples = getattr( + config, "expected_current_teacher_samples", None) + if ( + configured_teacher_samples is not None + and int(configured_teacher_samples) != 0 + and int(configured_teacher_samples) + != self.fresh_video_generator_grad_global_samples + ): + raise ValueError( + "expected_current_teacher_samples must equal the global " + "gradient suffix, got " + f"{int(configured_teacher_samples)} != " + f"{self.fresh_video_generator_grad_global_samples}") + + self.dtype = torch.bfloat16 if config.mixed_precision else torch.float32 + self.device = torch.cuda.current_device() + self.is_main_process = global_rank == 0 + self.causal = config.causal + self.disable_wandb = config.disable_wandb + + # use a random seed for the training + if config.seed == 0: + random_seed = torch.randint(0, 10000000, (1,), device=self.device) + dist.broadcast(random_seed, src=0) + config.seed = random_seed.item() + + set_seed(config.seed + global_rank) + + if self.is_main_process and not self.disable_wandb: + init_wandb_from_config(config) + + self.output_path = config.logdir + + # Step 2: Initialize the model and optimizer + if config.distribution_loss != "wan_kv_mmd": + raise ValueError("This release supports distribution matching (wan_kv_mmd).") + self.model = DistributionMatchingModel(config, device=self.device) + self.uses_critic = bool(getattr(self.model, "uses_critic", True)) + if ( + bool(getattr( + self.model, "uses_fresh_video_gradcache", False)) + != self.uses_fresh_video_gradcache + ): + raise RuntimeError( + "trainer/model fresh-video GradCache configuration mismatch") + expected_teacher_samples = ( + self.fresh_video_generator_grad_global_samples + if bool(getattr( + self.model, "current_teacher_attraction_enabled", False)) + else 0 + ) + if ( + configured_teacher_samples is not None + and int(configured_teacher_samples) != expected_teacher_samples + ): + raise ValueError( + "expected_current_teacher_samples disagrees with the active " + "teacher mode: " + f"{int(configured_teacher_samples)} != " + f"{expected_teacher_samples}") + fresh_cache_class = ( + FeatureGradientReplay + if bool(getattr(self.model, "dual_video_mmd_enabled", False)) + else FreshVideoGradCache + ) + self.fresh_video_gradcache = ( + fresh_cache_class( + self.model.latent_mmd, + target_local_samples=self.fresh_video_local_samples, + gradient_local_samples=( + self.fresh_video_generator_grad_local_samples), + require_sample_id_alignment=( + str(getattr(config, "current_teacher_source", "")) + .strip().lower() in { + "matched_manifest_video", + "matched_dual_feature_bank", + "matched_triple_feature_bank", + "matched_dinov3_vjepa2_feature_bank", + }), + full_student_partial_gradient=( + self.fresh_video_full_student_partial_gradient), + loss_scale=float(getattr( + config, "wan_kv_loss_weight", 1.0)), + ) + if self.uses_fresh_video_gradcache + else None + ) + + # Save pretrained model state_dicts to CPU + self.fake_score_state_dict_cpu = ( + self.model.fake_score.state_dict() + if getattr(self.model, "fake_score", None) is not None + else None + ) + + generator_wrap_strategy = config.generator_fsdp_wrap_strategy + self.model.generator = fsdp_wrap( + self.model.generator, + sharding_strategy=config.sharding_strategy, + mixed_precision=config.mixed_precision, + wrap_strategy=generator_wrap_strategy, + transformer_module=( + _transformer_block_types(self.model.generator, "generator") + if generator_wrap_strategy == "transformer" + else None + ), + backward_prefetch=str(getattr( + config, "fsdp_backward_prefetch", "pre")), + ) + + if getattr(self.model, "real_score", None) is not None: + real_wrap_strategy = config.real_score_fsdp_wrap_strategy + self.model.real_score = fsdp_wrap( + self.model.real_score, + sharding_strategy=config.sharding_strategy, + mixed_precision=config.mixed_precision, + wrap_strategy=real_wrap_strategy, + transformer_module=( + _transformer_block_types(self.model.real_score, "real_score") + if real_wrap_strategy == "transformer" + else None + ), + ) + + if getattr(self.model, "fake_score", None) is not None: + self.model.fake_score = fsdp_wrap( + self.model.fake_score, + sharding_strategy=config.sharding_strategy, + mixed_precision=config.mixed_precision, + wrap_strategy=config.fake_score_fsdp_wrap_strategy + ) + + self.model.text_encoder = fsdp_wrap( + self.model.text_encoder, + sharding_strategy=config.sharding_strategy, + mixed_precision=config.mixed_precision, + wrap_strategy=config.text_encoder_fsdp_wrap_strategy, + cpu_offload=getattr(config, "text_encoder_cpu_offload", False) + ) + + if ( + getattr(self.model, "uses_vae_for_loss", False) + or ( + not config.no_visualize + and getattr(self.model, "uses_vae_for_visualization", True) + ) + or bool(getattr(config, "load_raw_video", False)) + or int(getattr(config, "eval_interval", 0)) > 0 + ): + self.model.vae = self.model.vae.to( + device=self.device, dtype=torch.bfloat16 if config.mixed_precision else torch.float32) + + self.generator_optimizer = torch.optim.AdamW( + [param for param in self.model.generator.parameters() + if param.requires_grad], + lr=config.lr, + betas=(config.beta1, config.beta2), + weight_decay=config.weight_decay, + foreach=bool(getattr(config, "optimizer_foreach", False)), + fused=bool(getattr(config, "optimizer_fused", False)), + ) + self.optimizer_state_cpu_offload_during_backward = bool(getattr( + config, "optimizer_state_cpu_offload_during_backward", False)) + self.learning_rate_schedule = _learning_rate_schedule_identity(config) + + if self.uses_critic: + self.critic_optimizer = torch.optim.AdamW( + [param for param in self.model.fake_score.parameters() + if param.requires_grad], + lr=config.lr_critic if hasattr(config, "lr_critic") else config.lr, + betas=(config.beta1_critic, config.beta2_critic), + weight_decay=config.weight_decay + ) + else: + self.critic_optimizer = None + + # Step 3: Initialize the dataloader + current_teacher_enabled = bool(getattr( + self.model, "current_teacher_attraction_enabled", False)) + configured_teacher_source = getattr( + config, "current_teacher_source", None) + requested_teacher_source = str( + configured_teacher_source + if configured_teacher_source is not None + else "auto" + ).strip().lower() + self.current_teacher_source = ( + "lmdb_latent" if current_teacher_enabled else "none" + ) if requested_teacher_source == "auto" else requested_teacher_source + if self.current_teacher_source not in { + "none", "lmdb_latent", "openvid_video", "matched_manifest_video", + "matched_dual_feature_bank", "matched_triple_feature_bank", + "matched_dinov3_vjepa2_feature_bank", + }: + raise ValueError( + "current_teacher_source must be auto, none, lmdb_latent, " + "openvid_video, matched_manifest_video, or " + "matched_dual_feature_bank, matched_triple_feature_bank, or " + "matched_dinov3_vjepa2_feature_bank; got " + f"{self.current_teacher_source!r}") + if current_teacher_enabled == (self.current_teacher_source == "none"): + raise ValueError( + "current_teacher_source must name exactly one source iff " + "exact current-teacher attraction is enabled") + self.current_teacher_dataset = None + self.matched_counter_sampling_enabled = False + self.experiment_identity = None + if self.config.distribution_loss == "wan_kv_mmd": + if self.current_teacher_source in { + "matched_dual_feature_bank", + "matched_triple_feature_bank", + "matched_dinov3_vjepa2_feature_bank", + }: + source = self.current_teacher_source + triple_bank = source == "matched_triple_feature_bank" + dino_vjepa_bank = ( + source == "matched_dinov3_vjepa2_feature_bank") + if triple_bank: + from utils.active_mmd_branches import active_mmd_branches + from utils.triple_video_feature_bank import ( + TripleVideoFeatureBank as FeatureBank, + ) + manifest_field = "triple_teacher_bank_manifest" + content_field = "triple_teacher_bank_content_hash" + expected_branch_names = set(active_mmd_branches( + getattr(config, "triple_active_branches", None))) + expected_feature_type = ( + "triple_dinov3_vjepa2_videomae_v2_dense_latent") + elif dino_vjepa_bank: + from utils.dinov3_vjepa2_feature_bank import ( + DINOv3VJEPA2FeatureBank as FeatureBank, + ) + manifest_field = "dinov3_vjepa2_teacher_bank_manifest" + content_field = ( + "dinov3_vjepa2_teacher_bank_content_hash") + expected_branch_names = {"dinov3", "vjepa2"} + expected_feature_type = ( + "dual_dinov3_vjepa2_dense_latent") + else: + from utils.dual_video_feature_bank import ( + DualVideoFeatureBank as FeatureBank, + ) + manifest_field = "dual_teacher_bank_manifest" + content_field = "dual_teacher_bank_content_hash" + expected_branch_names = {"vjepa2", "videomae_v2"} + expected_feature_type = ( + "dual_vjepa2_videomae_v2_dense_latent") + if not bool(getattr(self.model, "dual_video_mmd_enabled", False)): + raise ValueError( + "matched feature bank requires multi-representation mode") + if str(getattr(self.model, "feature_type", "")) != expected_feature_type: + raise ValueError( + "teacher bank branch set does not match model mode") + if not self.uses_fresh_video_gradcache: + raise ValueError("matched feature bank requires exact GradCache") + manifest_sha_field = f"{manifest_field}_sha256" + kwargs = { + "expected_manifest_sha256": _strict_sha256( + getattr(config, manifest_sha_field), manifest_sha_field), + "expected_content_hash": _strict_sha256( + getattr(config, content_field), content_field), + "expected_source_manifest_sha256": _strict_sha256( + config.matched_manifest_sha256, + "matched_manifest_sha256"), + "expected_sample_count": int(getattr( + config, "matched_manifest_samples", 5000)), + "expected_vjepa_model_sha256": _strict_sha256( + config.vjepa2_model_sha256, + "vjepa2_model_sha256"), + "validate_all_shards": True, + } + if dino_vjepa_bank: + kwargs.update({ + "expected_source_csv_sha256": _strict_sha256( + config.matched_source_csv_sha256, + "matched_source_csv_sha256"), + "expected_sample_identity_sha256": _strict_sha256( + config.matched_sample_identity_sha256, + "matched_sample_identity_sha256"), + }) + if not dino_vjepa_bank: + kwargs.update({ + "expected_videomae_revision": str( + config.videomae_v2_model_revision), + "expected_videomae_download_manifest_content_hash": ( + _strict_sha256( + config.videomae_v2_download_manifest_content_hash, + "videomae_v2_download_manifest_content_hash")), + }) + if triple_bank: + kwargs["active_branches"] = active_mmd_branches( + getattr(config, "triple_active_branches", None)) + kwargs["expected_shard_size"] = 32 + if "dinov3" in expected_branch_names: + kwargs.update({ + "expected_dinov3_checkpoint_sha256": _strict_sha256( + config.dinov3_checkpoint_sha256, + "dinov3_checkpoint_sha256"), + "expected_dinov3_source_commit": str( + config.dinov3_source_commit), + "expected_shard_size": 32, + }) + dataset = FeatureBank( + str(getattr(config, manifest_field)), **kwargs) + model_bank_hash = str(getattr( + self.model, + "triple_teacher_bank_content_hash" + if triple_bank else "dual_teacher_bank_content_hash")) + if dataset.content_hash != model_bank_hash: + raise ValueError( + "training bank identity differs from all references") + self.current_teacher_dataset = dataset + self.matched_counter_sampling_enabled = True + branches = copy.deepcopy(self.model.dual_branch_registry) + if set(branches) != expected_branch_names: + raise ValueError( + "model branch registry does not match teacher bank") + self.experiment_identity = { + "current_teacher_source": self.current_teacher_source, + f"{manifest_field}_sha256": dataset.manifest_sha256, + content_field: dataset.content_hash, + "source_manifest_sha256": _strict_sha256( + config.matched_manifest_sha256, + "matched_manifest_sha256"), + "sample_count": len(dataset), + "branches": branches, + "forward_global_samples": self.fresh_video_total_samples, + "gradient_global_samples": ( + self.fresh_video_generator_grad_global_samples), + "teacher_global_samples": int( + config.expected_current_teacher_samples), + "full_student_partial_gradient": ( + self.fresh_video_full_student_partial_gradient), + "kss_denominator": self.fresh_video_total_samples ** 2, + "fixed_attraction_denominator": ( + self.fresh_video_total_samples), + "teacher_cross_denominator": ( + self.fresh_video_total_samples + * int(config.expected_current_teacher_samples)), + "teacher_self_denominator": ( + int(config.expected_current_teacher_samples) ** 2), + "replay_cotangent_compensation": ( + self.fresh_video_generator_grad_sample_scale), + "branch_normalization": ( + "raw_div_stop_gradient_raw_plus_1e-4"), + "branch_aggregation": ( + ("sum_10_to_1_to_1_then_divide_3" + if "dinov3" in expected_branch_names + else "sum_1_to_1_then_divide_3") + if triple_bank + else "sum_10_to_1_without_divide_2"), + } + elif self.current_teacher_source == "matched_manifest_video": + from utils.matched_video_dataset import ( + MatchedManifestVideoDataset, + ) + from utils.matched_video_feature_cache import ( + MATCHED_PYAV_LAST64_DECODER_CONTRACT_SHA256, + MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT_SHA256, + ) + + if not self.uses_fresh_video_gradcache: + raise ValueError( + "current_teacher_source=matched_manifest_video requires " + "the fresh-video exact Nyström path") + if int(config.batch_size) != 1: + raise ValueError( + "lazy matched-video teacher decoding requires " + "batch_size=1") + manifest_path = os.path.realpath(os.path.expanduser(str( + config.matched_manifest_path))) + expected_manifest_sha256 = _strict_sha256( + config.matched_manifest_sha256, + "matched_manifest_sha256", + ) + actual_manifest_sha256 = _file_sha256(manifest_path) + if actual_manifest_sha256 != expected_manifest_sha256: + raise ValueError( + "frozen matched-video manifest SHA-256 mismatch: " + f"{actual_manifest_sha256} != " + f"{expected_manifest_sha256}") + source_csv_sha256 = _strict_sha256( + config.matched_source_csv_sha256, + "matched_source_csv_sha256", + ) + expected_sample_identity_sha256 = _strict_sha256( + config.matched_sample_identity_sha256, + "matched_sample_identity_sha256", + ) + model_sha256 = _strict_sha256( + config.vjepa2_model_sha256, "vjepa2_model_sha256") + feature_contract_sha256 = _strict_sha256( + config.matched_teacher_feature_contract_sha256, + "matched_teacher_feature_contract_sha256", + ) + if ( + feature_contract_sha256 + != MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT_SHA256 + ): + raise ValueError( + "configured matched-video feature contract does not " + "match runtime") + decoder_contract_sha256 = _strict_sha256( + config.matched_teacher_decoder_contract_sha256, + "matched_teacher_decoder_contract_sha256", + ) + if ( + decoder_contract_sha256 + != MATCHED_PYAV_LAST64_DECODER_CONTRACT_SHA256 + ): + raise ValueError( + "configured matched-video decoder contract does not " + "match runtime") + sampling_algorithm = str( + config.matched_sampling_algorithm) + if sampling_algorithm != "sha256_seed_step_position_mod_n_v1": + raise ValueError( + "unsupported matched counter sampling algorithm: " + f"{sampling_algorithm!r}") + if not bool(config.matched_sampling_with_replacement): + raise ValueError( + "matched sampler must explicitly enable replacement") + expected_samples = int(getattr( + config, "matched_manifest_samples", 5000)) + dataset = MatchedManifestVideoDataset( + manifest_path=manifest_path, + video_root=str(config.matched_video_root), + cache_root=str(config.matched_teacher_cache_root), + expected_model_sha256=model_sha256, + expected_feature_contract_sha256=( + feature_contract_sha256), + expected_source_csv_sha256=source_csv_sha256, + expected_count=expected_samples, + ) + actual_sample_identity_sha256 = str( + dataset.manifest_metadata.get( + "sample_identity_sha256", "")) + if ( + actual_sample_identity_sha256 + != expected_sample_identity_sha256 + ): + raise ValueError( + "matched-video sample identity SHA-256 mismatch: " + f"{actual_sample_identity_sha256} != " + f"{expected_sample_identity_sha256}") + self.current_teacher_dataset = dataset + self.matched_counter_sampling_enabled = True + self.experiment_identity = { + "current_teacher_source": "matched_manifest_video", + "matched_manifest_sha256": expected_manifest_sha256, + "matched_source_csv_sha256": source_csv_sha256, + "matched_sample_identity_sha256": ( + expected_sample_identity_sha256), + "matched_manifest_samples": expected_samples, + "vjepa2_reference_model_id": str( + config.vjepa2_reference_model_id), + "vjepa2_model_sha256": model_sha256, + "matched_teacher_feature_contract_sha256": ( + feature_contract_sha256), + "matched_teacher_decoder_contract_sha256": ( + decoder_contract_sha256), + "student_frame_contract": "last64_frames_17_80", + "live_teacher_frame_contract": "last64_frames_17_80", + "fixed_reference_frame_contract": "central64_existing_pack", + "counter_sampling_algorithm": ( + "sha256_seed_step_position_mod_n_v1"), + "counter_sampling_seed": int(config.seed), + "forward_global_samples": ( + self.fresh_video_total_samples), + "gradient_global_samples": ( + self.fresh_video_generator_grad_global_samples), + "explicit_gradient_compensation": ( + self.fresh_video_generator_grad_sample_scale), + "partial_mmd_gradient_compensation": ( + self.fresh_video_generator_grad_sample_scale), + "teacher_global_samples": int( + config.expected_current_teacher_samples), + "partial_mmd_objective": ( + "full_student_biased_mmd_suffix_partial_backward_v1" + if self.fresh_video_full_student_partial_gradient + else "rectangular_gradient_student_v1" + ), + "full_student_partial_gradient": ( + self.fresh_video_full_student_partial_gradient), + "partial_mmd_kss_denominator": ( + self.fresh_video_total_samples ** 2 + if self.fresh_video_full_student_partial_gradient + else self.fresh_video_total_samples + * self.fresh_video_generator_grad_global_samples + ), + "partial_mmd_fixed_attraction_denominator": ( + self.fresh_video_total_samples + if self.fresh_video_full_student_partial_gradient + else self.fresh_video_generator_grad_global_samples + ), + "partial_mmd_live_cross_denominator": ( + self.fresh_video_total_samples + * int(config.expected_current_teacher_samples) + if self.fresh_video_full_student_partial_gradient + else self.fresh_video_generator_grad_global_samples + * int(config.expected_current_teacher_samples) + ), + "partial_mmd_teacher_self_denominator": ( + int(config.expected_current_teacher_samples) ** 2), + "partial_replay_upstream_commit": ( + "ef43f2d616ff31ba34904ae9743b0de4dda95afd"), + "fixed_reference_file_sha256": _strict_sha256( + config.vjepa2_nystrom_reference_sha256, + "vjepa2_nystrom_reference_sha256"), + "fixed_reference_hash": str(getattr( + self.model, "vjepa2_nystrom_reference_hash", "")), + "live_teacher_baseline_commit": str( + config.live_teacher_baseline_commit), + "partial_gradient_reference_commit": str( + config.partial_gradient_reference_commit), + "feature_protocol_identity": str( + config.feature_protocol_identity), + } + elif self.current_teacher_source == "openvid_video": + from utils.openvid_feature_cache import ( + OPENVID_PYAV_DECODER_CONTRACT_SHA256, + OPENVID_VJEPA2_L15_FEATURE_CONTRACT_SHA256, + ) + + if not self.uses_fresh_video_gradcache: + raise ValueError( + "current_teacher_source=openvid_video requires the " + "fresh-video exact Nyström path") + if int(config.batch_size) != 1: + raise ValueError( + "OpenVid lazy teacher decoding currently requires " + "batch_size=1") + manifest_path = os.path.realpath(os.path.expanduser(str( + config.openvid_manifest_path))) + expected_manifest_sha256 = _strict_sha256( + config.openvid_manifest_sha256, + "openvid_manifest_sha256", + ) + actual_manifest_sha256 = _file_sha256(manifest_path) + if actual_manifest_sha256 != expected_manifest_sha256: + raise ValueError( + "frozen OpenVid manifest SHA-256 mismatch: " + f"{actual_manifest_sha256} != " + f"{expected_manifest_sha256}") + model_sha256 = _strict_sha256( + config.vjepa2_model_sha256, "vjepa2_model_sha256") + feature_contract_sha256 = _strict_sha256( + config.openvid_teacher_feature_contract_sha256, + "openvid_teacher_feature_contract_sha256", + ) + if ( + feature_contract_sha256 + != OPENVID_VJEPA2_L15_FEATURE_CONTRACT_SHA256 + ): + raise ValueError( + "configured OpenVid feature contract does not match " + "runtime") + decoder_contract_sha256 = _strict_sha256( + config.openvid_teacher_decoder_contract_sha256, + "openvid_teacher_decoder_contract_sha256", + ) + if ( + decoder_contract_sha256 + != OPENVID_PYAV_DECODER_CONTRACT_SHA256 + ): + raise ValueError( + "configured OpenVid decoder contract does not match " + "runtime") + dataset = OpenVidPromptVideoDataset( + manifest_path=manifest_path, + video_root=str(config.openvid_video_root), + cache_root=str(config.openvid_teacher_cache_root), + expected_model_sha256=model_sha256, + expected_feature_contract_sha256=( + feature_contract_sha256), + ) + expected_samples = int(getattr( + config, "openvid_manifest_samples", 12800)) + if len(dataset) != expected_samples: + raise ValueError( + "frozen OpenVid dataset size mismatch: " + f"{len(dataset)} != {expected_samples}") + self.current_teacher_dataset = dataset + self.experiment_identity = { + "current_teacher_source": "openvid_video", + "openvid_manifest_sha256": expected_manifest_sha256, + "vjepa2_reference_model_id": str( + config.vjepa2_reference_model_id), + "vjepa2_model_sha256": model_sha256, + "openvid_teacher_feature_contract_sha256": ( + feature_contract_sha256), + "openvid_teacher_decoder_contract_sha256": ( + decoder_contract_sha256), + } + elif self.current_teacher_source == "none": + fixed_prompt_ledger = getattr( + config, "fixed_student_prompt_ledger", None) + if fixed_prompt_ledger: + from utils.openvid_reference_ledger import ( + OpenVidReferencePromptDataset, + ) + dataset = OpenVidReferencePromptDataset( + fixed_prompt_ledger, + expected_count=int(getattr( + config, "openvid_reference_count", 77615)), + ) + self.experiment_identity = ( + _fixed_openvid_experiment_identity( + config, + self.model, + dataset.metadata, + forward_global_samples=( + self.fresh_video_total_samples), + gradient_global_samples=( + self.fresh_video_generator_grad_global_samples), + gradient_compensation=( + self.fresh_video_generator_grad_sample_scale), + ) + ) + else: + dataset = TextDataset(config.data_path) + else: + load_matched_terminal_teacher = bool( + self.uses_fresh_video_gradcache + and self.current_teacher_source == "lmdb_latent") + dataset = ODERegressionLMDBDataset( + config.teacher_latent_data_path, + max_pair=int(getattr( + config, "teacher_latent_count", 1e8)), + load_latents=( + not self.uses_fresh_video_gradcache + or load_matched_terminal_teacher + ), + load_terminal_latent_only=( + load_matched_terminal_teacher), + ) + elif self.config.i2v: + dataset = ShardingLMDBDataset(config.data_path, max_pair=int(1e8)) + else: + dataset = TextDataset(config.data_path) + self.dataset = dataset + if self.matched_counter_sampling_enabled: + self.dataloader_len = 1 + self.dataloader = None + else: + sampler = torch.utils.data.distributed.DistributedSampler( + dataset, shuffle=True, drop_last=True) + dataloader = torch.utils.data.DataLoader( + dataset, + batch_size=config.batch_size, + sampler=sampler, + num_workers=int(getattr(config, "num_workers", 4)), + drop_last=True, + collate_fn=( + dataset.collate_fn + if self.current_teacher_dataset is dataset else None), + ) + self.dataloader_len = max(1, len(dataloader)) + self.dataloader = cycle(dataloader) + + if dist.get_rank() == 0: + print("DATASET SIZE %d" % len(dataset)) + + # Make the optimizer trajectory part of the exact-resume identity. + # This is attached after the data/MMD identity is built and before a + # resume checkpoint is compared against it. + if self.experiment_identity is None: + self.experiment_identity = {} + self.experiment_identity["optimizer_learning_rate_schedule"] = ( + copy.deepcopy(self.learning_rate_schedule)) + self.experiment_identity["vace_aligned_execution"] = { + "fsdp_backward_prefetch": str(getattr( + config, "fsdp_backward_prefetch", "pre")), + "optimizer_foreach": bool(getattr( + config, "optimizer_foreach", False)), + "optimizer_fused": bool(getattr( + config, "optimizer_fused", False)), + "optimizer_state_cpu_offload_during_backward": bool( + self.optimizer_state_cpu_offload_during_backward), + "logical_micro_batch_size": int(getattr( + config, "fresh_video_logical_micro_batch_size", + config.batch_size)), + "physical_forward_batch_size": int(config.batch_size), + "physical_replay_batch_size": int(getattr( + config, "fresh_video_physical_replay_batch_size", + config.batch_size)), + } + self.experiment_identity["generator_initialization"] = { + "path": str(config.generator_ckpt), + "sha256": _strict_sha256( + config.generator_ckpt_sha256, "generator_ckpt_sha256"), + "replay_activation_cpu_offload": bool( + self.model.generator_replay_activation_cpu_offload), + "replay_activation_cpu_offload_block_stride": int( + self.model. + generator_replay_activation_cpu_offload_block_stride), + "replay_frozen_encoder_cpu_offload": bool( + self.model.generator_replay_frozen_encoder_cpu_offload), + } + + ############################################################################################################## + # 6. Record trainable parameters. Generator EMA is initialized only + # after loading the generator checkpoint, below. + rename_param = ( + lambda name: name.replace("_fsdp_wrapped_module.", "") + .replace("_checkpoint_wrapped_module.", "") + .replace("_orig_mod.", "") + ) + self.name_to_trainable_params = {} + for n, p in self.model.generator.named_parameters(): + if not p.requires_grad: + continue + + renamed_n = rename_param(n) + self.name_to_trainable_params[renamed_n] = p + ema_weight = float(getattr(config, "ema_weight", 0.0) or 0.0) + self.generator_ema = None + resume_ema_state = None + + ############################################################################################################## + # 7. Load model/distribution state before constructing an EMA shadow. + resume_checkpoint = getattr(config, "resume_checkpoint", None) + self.resume_data_counter_override = ( + _validated_resume_data_counter_override( + getattr(config, "resume_data_counter_override", None), + resume_checkpoint=resume_checkpoint, + ) + ) + fixed_openvid_dual = bool( + getattr(self.model, "feature_type", "") + == "dual_dinov3_vjepa2_dense_latent" + and not getattr( + self.model, "current_teacher_attraction_enabled", False) + ) + if resume_checkpoint: + # Restore generator weights, optional generator EMA, distribution + # history and step. Optimizer/RNG/dataloader state is restored later + # when save_full_state is enabled and optim.pt is present. + if fixed_openvid_dual: + expected_resume_sha256 = _strict_sha256( + getattr(config, "resume_checkpoint_sha256", ""), + "resume_checkpoint_sha256", + ) + actual_resume_sha256 = self.model._distributed_file_sha256( + str(resume_checkpoint)) + if actual_resume_sha256 != expected_resume_sha256: + raise ValueError( + "fixed-only exact resume checkpoint SHA-256 mismatch") + checkpoint_dir = os.path.dirname(str(resume_checkpoint)) + required_full_state = [ + os.path.join(checkpoint_dir, "optim.pt"), + *[ + os.path.join( + checkpoint_dir, f"rng_rank{rank:02d}.pt") + for rank in range(4) + ], + ] + missing = [ + path for path in required_full_state + if not os.path.isfile(path) + ] + if missing: + raise FileNotFoundError( + "fixed-only exact resume requires optimizer and all " + f"four RNG states: {missing}") + print(f"Resuming training state from {resume_checkpoint}") + ckpt = torch.load(resume_checkpoint, map_location="cpu", weights_only=False) + if self.experiment_identity is not None: + saved_identity = ( + ckpt.get("experiment_identity") + if isinstance(ckpt, dict) + else None + ) + saved_identity = _canonicalize_experiment_identity_paths( + saved_identity) + runtime_identity = _canonicalize_experiment_identity_paths( + self.experiment_identity) + if saved_identity != runtime_identity: + raise ValueError( + "resume checkpoint experiment identity does not match " + "the frozen teacher/V-JEPA2 experiment identity: " + f"{saved_identity!r} != " + f"{runtime_identity!r}") + gen_sd = ckpt["generator"] if (isinstance(ckpt, dict) and "generator" in ckpt) else ckpt + self.model.generator.load_state_dict(gen_sd, strict=True) + if isinstance(ckpt, dict): + resume_ema_state = ckpt.get("generator_ema") + if ( + isinstance(ckpt, dict) + and "fd" in ckpt + and hasattr(self.model, "load_fd_state_dict") + ): + self.model.load_fd_state_dict(ckpt["fd"]) + print(" restored distribution state") + elif getattr( + self.model, "requires_distribution_state_on_resume", False + ): + raise ValueError( + "resume checkpoint is missing the required Wan KV " + "distribution history under key 'fd'") + if isinstance(ckpt, dict) and "step" in ckpt: + self.step = int(ckpt["step"]) + else: + import re + m = re.search(r"checkpoint_model_(\d+)", str(resume_checkpoint)) + self.step = int(m.group(1)) if m else 0 + if isinstance(ckpt, dict) and "data_counter" in ckpt: + self.data_counter = int(ckpt["data_counter"]) + print(f" resumed at step {self.step}") + elif getattr(config, "generator_ckpt", False): + if not fixed_openvid_dual: + expected_generator_sha256 = _strict_sha256( + config.generator_ckpt_sha256, "generator_ckpt_sha256") + actual_generator_sha256 = ( + self.model._distributed_file_sha256( + str(config.generator_ckpt))) + if actual_generator_sha256 != expected_generator_sha256: + raise ValueError( + "generator initialization SHA-256 mismatch") + if fixed_openvid_dual: + expected_generator_sha256 = str(getattr( + config, "generator_ckpt_sha256", "")).strip().lower() + if ( + len(expected_generator_sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in expected_generator_sha256 + ) + ): + raise ValueError( + "fixed-only OpenVid requires generator_ckpt_sha256") + actual_generator_sha256 = ( + self.model._distributed_file_sha256( + str(config.generator_ckpt))) + if actual_generator_sha256 != expected_generator_sha256: + raise ValueError( + "selected 14B ODE generator checkpoint SHA-256 " + "mismatch") + print(f"Loading pretrained generator from {config.generator_ckpt}") + if str(config.generator_ckpt).endswith(".safetensors"): + state_dict = load_safetensors_file( + str(config.generator_ckpt), device="cpu") + else: + state_dict = torch.load(config.generator_ckpt, map_location="cpu") + if fixed_openvid_dual and ( + not isinstance(state_dict, dict) + or state_dict.get("model_name") != "Wan2.1-T2V-14B" + or state_dict.get("ode_recipe") != "causvid_14b_16k" + or int(state_dict.get("seed", -1)) != 0 + or state_dict.get("model_name") + != str(config.generator_ckpt_model_name) + or state_dict.get("ode_recipe") + != str(config.generator_ckpt_ode_recipe) + or int(state_dict.get("step", -1)) + != int(config.generator_ckpt_step) + or state_dict.get("prompt_manifest_sha256") + != _strict_sha256( + config.generator_ckpt_prompt_manifest_sha256, + "generator_ckpt_prompt_manifest_sha256", + ) + ): + raise ValueError( + "fixed-only OpenVid initialization must be a selected " + "Wan-14B CausVid ODE checkpoint") + fd_state_dict = state_dict.get("fd") if isinstance(state_dict, dict) else None + if isinstance(state_dict, dict): + if "generator" in state_dict: + state_dict = state_dict["generator"] + elif "model" in state_dict: + state_dict = state_dict["model"] + self.model.generator.load_state_dict( + state_dict, strict=True + ) + if fd_state_dict is not None and hasattr(self.model, "load_fd_state_dict"): + is_compatible_initialization_state = ( + self.model.can_load_initialization_fd_state(fd_state_dict) + if hasattr( + self.model, "can_load_initialization_fd_state") + else not getattr( + self.model, + "requires_distribution_state_on_resume", + False, + ) + ) + if is_compatible_initialization_state: + # Known dense-MMD states are always passed through the + # model's strict metadata/cross-feature compatibility + # checks. Unknown FD payloads in an initialization-only + # checkpoint remain intentionally ignorable. + self.model.load_fd_state_dict(fd_state_dict) + elif self.is_main_process: + print( + " initialization checkpoint has unrelated or " + "incompatible FD state; using generator weights only") + + ############################################################################################################## + ema_start_step = int(getattr(config, "ema_start_step", 0)) + if ema_weight > 0.0 and self.step >= ema_start_step: + print(f"Setting up EMA with weight {ema_weight}") + # This now snapshots the loaded generator, never the stale + # pre-checkpoint parameters. + self.generator_ema = EMA_FSDP( + self.model.generator, decay=ema_weight) + if resume_ema_state is not None: + self.generator_ema.load_state_dict(resume_ema_state) + print(" restored EMA shadow") + elif resume_checkpoint and self.step > ema_start_step: + raise ValueError( + "resume checkpoint is past ema_start_step but has no " + "generator_ema state") + + self.max_grad_norm_generator = getattr(config, "max_grad_norm_generator", 10.0) + self.max_grad_norm_critic = getattr(config, "max_grad_norm_critic", 10.0) + self.previous_time = None + + # ---- precise-resume + periodic eval bookkeeping ---- + # ``save_full_state`` additionally writes the optimizer state, per-rank RNG + # states and the dataloader counter so a resume continues bit-for-bit (up + # to multi-worker dataloader ordering). Off by default so other trainers + # (DMD etc.) keep their lightweight checkpoints. + self.save_full_state = bool(getattr(config, "save_full_state", False)) + self.save_full_state_final_only = bool( + getattr(config, "save_full_state_final_only", False)) + self.checkpoint_keep_last = int( + getattr(config, "checkpoint_keep_last", 0)) + if self.checkpoint_keep_last < 0: + raise ValueError("checkpoint_keep_last must be non-negative") + self.eval_interval = int(getattr(config, "eval_interval", 0)) + self.eval_seed = int(getattr(config, "eval_seed", 12345)) + self.eval_num_prompts = int(getattr(config, "eval_num_prompts", 4)) + self.eval_prompts = self._load_eval_prompts() if self.eval_interval > 0 else [] + self._last_saved_step = None + # If we resumed and the optimizer/RNG/data state was restored, this is set. + self._restore_full_training_state(resume_checkpoint) + self.optimizer_state_offload_report = None + if self.optimizer_state_cpu_offload_during_backward: + torch.cuda.synchronize(self.device) + self.optimizer_state_offload_report = move_adamw_moment_state_( + self.generator_optimizer, "cpu") + torch.cuda.empty_cache() + if self.is_main_process: + print( + "[memory] VACE optimizer-state CPU offload ready: " + f"{self.optimizer_state_offload_report}", + flush=True, + ) + self.current_learning_rate = self._set_generator_learning_rate( + self.step) + + def _set_generator_learning_rate(self, update_index): + schedule = self.learning_rate_schedule + if schedule["name"] == "constant": + value = float(schedule["value"]) + else: + value = _cosine_learning_rate( + int(update_index), + start=float(schedule["start"]), + end=float(schedule["end"]), + total_updates=int(schedule["total_updates"]), + ) + for parameter_group in self.generator_optimizer.param_groups: + parameter_group["lr"] = value + self.current_learning_rate = value + return value + + def _all_ranks_true(self, local_value): + """Create an all-rank decision before a rank can branch or raise.""" + flag = torch.tensor( + int(bool(local_value)), device=self.device, dtype=torch.int32) + if dist.is_initialized() and dist.get_world_size() > 1: + dist.all_reduce(flag, op=dist.ReduceOp.MIN) + return bool(flag.item()) + + def save(self): + if ( + self.fresh_video_gradcache is not None + and ( + self.fresh_video_gradcache.local_samples + or self.fresh_video_gradcache.finalized + ) + ): + raise RuntimeError( + "refusing to checkpoint a partial fresh-video optimizer " + "update") + if ( + hasattr(self.model, "distribution_state_pending") + and self.model.distribution_state_pending() + ): + raise RuntimeError( + "refusing to checkpoint pending distribution state") + # max_steps can coincide with log_iters; avoid gathering and rewriting + # the same full FSDP checkpoint twice. + if self._last_saved_step == self.step: + return + max_steps = int(getattr(self.config, "max_steps", 0)) + write_full_state = ( + self.save_full_state + and ( + not self.save_full_state_final_only + or (max_steps > 0 and self.step >= max_steps) + ) + ) + required_free_gib = float( + getattr( + self.config, + ( + "checkpoint_full_min_free_gib" + if write_full_state + else "checkpoint_model_min_free_gib" + ), + 0.0, + ) + ) + disk_ok = True + disk_error = None + if self.is_main_process and required_free_gib > 0: + try: + free_gib = ( + shutil.disk_usage(self.output_path).free / (1024 ** 3)) + if free_gib < required_free_gib: + disk_ok = False + disk_error = ( + f"checkpoint at step {self.step} needs at least " + f"{required_free_gib:.1f} GiB free, only " + f"{free_gib:.1f} GiB is available" + ) + except Exception as exc: + disk_ok = False + disk_error = f"checkpoint disk-space check failed: {exc}" + disk_flag = torch.tensor( + int(disk_ok), device=self.device, dtype=torch.int32) + if dist.is_initialized(): + dist.broadcast(disk_flag, src=0) + if not bool(disk_flag.item()): + raise RuntimeError( + disk_error or "rank 0 checkpoint disk-space check failed") + + print("Start gathering distributed model states...") + generator_state_dict = fsdp_state_dict( + self.model.generator) + critic_state_dict = ( + fsdp_state_dict(self.model.fake_score) + if self.uses_critic + else None + ) + + if self.generator_ema is not None: + state_dict = { + "generator": generator_state_dict, + "generator_ema": self.generator_ema.state_dict(), + } + else: + state_dict = { + "generator": generator_state_dict, + } + if self.uses_critic: + state_dict["critic"] = critic_state_dict + if hasattr(self.model, "fd_state_dict"): + state_dict["fd"] = self.model.fd_state_dict() + # record the step so a resume continues numbering / EMA timing. + state_dict["step"] = self.step + state_dict["data_counter"] = self.data_counter + if self.experiment_identity is not None: + state_dict["experiment_identity"] = ( + _canonicalize_experiment_identity_paths( + self.experiment_identity)) + + # ---- full training state for precise resume (optimizer + RNG + data) ---- + gen_optim_full = None + critic_optim_full = None + if write_full_state: + # collective: every rank participates; full state gathered on rank 0. + gen_optim_full = fsdp_optim_state_dict(self.model.generator, self.generator_optimizer) + if self.uses_critic: + critic_optim_full = fsdp_optim_state_dict(self.model.fake_score, self.critic_optimizer) + + ckpt_dir = os.path.join(self.output_path, f"checkpoint_model_{self.step:06d}") + rank0_write_error = None + if self.is_main_process: + try: + os.makedirs(ckpt_dir, exist_ok=True) + model_path = os.path.join(ckpt_dir, "model.pt") + _atomic_torch_save(state_dict, model_path) + print("Model saved to", model_path) + if write_full_state: + optim_path = os.path.join(ckpt_dir, "optim.pt") + _atomic_torch_save( + { + "generator_optimizer": gen_optim_full, + "critic_optimizer": critic_optim_full, + "step": self.step, + "data_counter": self.data_counter, + "world_size": self.world_size, + }, + optim_path, + ) + print( + "Optimizer/training state saved to", + optim_path, + ) + except Exception as exc: + rank0_write_error = exc + + write_ok = torch.tensor( + int(rank0_write_error is None), + device=self.device, + dtype=torch.int32, + ) + if dist.is_initialized(): + dist.broadcast(write_ok, src=0) + if not bool(write_ok.item()): + if rank0_write_error is not None: + raise RuntimeError( + f"checkpoint write failed at step {self.step}" + ) from rank0_write_error + raise RuntimeError( + f"rank 0 checkpoint write failed at step {self.step}") + + # Per-rank RNG state (each rank writes its own) for reproducible streams. + rng_write_ok = True + rng_write_error = None + if write_full_state: + try: + os.makedirs(ckpt_dir, exist_ok=True) + rng_state = { + "torch": torch.get_rng_state(), + "cuda": torch.cuda.get_rng_state(self.device), + "numpy": np.random.get_state(), + "python": random.getstate(), + "data_counter": self.data_counter, + } + _atomic_torch_save( + rng_state, + os.path.join( + ckpt_dir, f"rng_rank{dist.get_rank():02d}.pt"), + ) + except Exception as exc: + rng_write_ok = False + rng_write_error = exc + rng_ok = torch.tensor( + int(rng_write_ok), device=self.device, dtype=torch.int32) + if dist.is_initialized(): + dist.all_reduce(rng_ok, op=dist.ReduceOp.MIN) + if not bool(rng_ok.item()): + if rng_write_error is not None: + raise RuntimeError( + f"rank {dist.get_rank()} RNG checkpoint write failed at " + f"step {self.step}" + ) from rng_write_error + raise RuntimeError( + f"an RNG checkpoint write failed at step {self.step}") + + if dist.is_initialized(): + dist.barrier() + + retention_error = None + if self.is_main_process and self.checkpoint_keep_last > 0: + try: + checkpoints = [] + for name in os.listdir(self.output_path): + prefix = "checkpoint_model_" + if not name.startswith(prefix): + continue + try: + checkpoint_step = int(name[len(prefix):]) + except ValueError: + continue + path = os.path.join(self.output_path, name) + if not os.path.isdir(path): + continue + if not os.path.isfile(os.path.join(path, "model.pt")): + shutil.rmtree(path) + print("Removed incomplete checkpoint", path) + continue + checkpoints.append((checkpoint_step, path)) + checkpoints.sort() + for _, path in checkpoints[:-self.checkpoint_keep_last]: + shutil.rmtree(path) + print("Removed old checkpoint", path) + except Exception as exc: + retention_error = exc + retention_ok = torch.tensor( + int(retention_error is None), + device=self.device, + dtype=torch.int32, + ) + if dist.is_initialized(): + dist.broadcast(retention_ok, src=0) + if not bool(retention_ok.item()): + if retention_error is not None: + raise RuntimeError( + "checkpoint retention failed after publishing a valid " + f"checkpoint at step {self.step}" + ) from retention_error + raise RuntimeError( + "rank 0 checkpoint retention failed after publishing a " + f"valid checkpoint at step {self.step}") + if dist.is_initialized(): + dist.barrier() + self._last_saved_step = self.step + + def _restore_full_training_state(self, resume_checkpoint): + """Restore optimizer + per-rank RNG + dataloader position for a precise resume. + + Called at the end of ``__init__`` (after the optimizer and generator + weights exist). Silently no-ops if the resume checkpoint has no + ``optim.pt`` (e.g. an old lightweight checkpoint), so older runs still + resume their weights/EMA/queue/step as before. + """ + if not resume_checkpoint: + return + if not self.save_full_state: + # Lightweight checkpoints still record data_counter. They do not + # promise bit-exact optimizer/RNG continuation, but they must not + # restart the prompt stream at its first batch. + self._apply_lightweight_resume_data_counter_override() + self._fast_forward_dataloader(self.data_counter) + if self.is_main_process: + print( + " lightweight resume: restored generator/distribution " + "state and data position; optimizer and RNG start fresh", + flush=True, + ) + return + ckpt_dir = os.path.dirname(resume_checkpoint) + optim_path = os.path.join(ckpt_dir, "optim.pt") + + if os.path.exists(optim_path): + if getattr(self, "resume_data_counter_override", None) is not None: + raise ValueError( + "resume_data_counter_override is only valid for a " + "lightweight resume; precise optimizer state exists at " + f"{optim_path}") + # rank0 holds the full optimizer state; FSDP broadcasts/shards it. + full = None + optim_load_error = None + if self.is_main_process: + try: + full = torch.load( + optim_path, + map_location="cpu", + weights_only=False, + ) + except Exception as exc: + optim_load_error = exc + optim_load_ok = torch.tensor( + int(optim_load_error is None), + device=self.device, + dtype=torch.int32, + ) + if dist.is_initialized(): + dist.broadcast(optim_load_ok, src=0) + if not bool(optim_load_ok.item()): + if optim_load_error is not None: + raise RuntimeError( + f"failed to load optimizer checkpoint {optim_path}" + ) from optim_load_error + raise RuntimeError( + f"rank 0 failed to load optimizer checkpoint {optim_path}") + saved_world_size = torch.tensor( + [ + int(full.get("world_size", self.world_size)) + if full is not None + else 0 + ], + device=self.device, + dtype=torch.long, + ) + dist.broadcast(saved_world_size, src=0) + if ( + int(saved_world_size.item()) != self.world_size + and not bool(getattr( + self.config, "allow_resume_world_size_change", False)) + ): + raise ValueError( + "precise resume world-size mismatch: checkpoint used " + f"{int(saved_world_size.item())}, current run uses " + f"{self.world_size}; set allow_resume_world_size_change " + "only if non-bit-exact data/RNG continuation is acceptable") + gen_osd = full["generator_optimizer"] if full is not None else None + fsdp_load_optim_state_dict(self.model.generator, self.generator_optimizer, gen_osd) + if self.uses_critic: + critic_osd = full["critic_optimizer"] if full is not None else None + if critic_osd is not None: + fsdp_load_optim_state_dict(self.model.fake_score, self.critic_optimizer, critic_osd) + # broadcast the data counter from rank0 + dc = torch.tensor( + [int(full["data_counter"]) if full is not None else 0], + device=self.device, dtype=torch.long, + ) + if dist.is_initialized(): + dist.broadcast(dc, src=0) + self.data_counter = int(dc.item()) + if self.is_main_process: + print(f" restored optimizer state; data_counter={self.data_counter}") + else: + self._apply_lightweight_resume_data_counter_override() + if self.is_main_process: + print( + " lightweight resume: restored generator/MMD history " + "and data position; optimizer and RNG start fresh", + flush=True, + ) + + # Read RNG now, but restore it only after DataLoader fast-forwarding: + # creating/advancing workers consumes the main process RNG. + rng_path = os.path.join(ckpt_dir, f"rng_rank{dist.get_rank():02d}.pt") + rng = None + if os.path.exists(rng_path): + rng = torch.load(rng_path, map_location="cpu", weights_only=False) + + # fast-forward the prompt stream to its position + self._fast_forward_dataloader(self.data_counter) + + if rng is not None: + torch.set_rng_state(rng["torch"]) + torch.cuda.set_rng_state(rng["cuda"], self.device) + np.random.set_state(rng["numpy"]) + random.setstate(rng["python"]) + if self.is_main_process: + print(" restored per-rank RNG states") + + def _apply_lightweight_resume_data_counter_override(self): + override = getattr(self, "resume_data_counter_override", None) + if override is None: + return + checkpoint_counter = self.data_counter + self.data_counter = override + if self.is_main_process: + print( + " lightweight resume data cursor: explicitly overriding " + f"checkpoint data_counter {checkpoint_counter} -> {override} " + "from resume_data_counter_override", + flush=True, + ) + + def _matched_global_indices_for_step(self): + """Counter-based with-replacement draw shared identically by all ranks.""" + if not getattr(self, "matched_counter_sampling_enabled", False): + raise RuntimeError("matched counter sampler is not enabled") + population = len(self.dataset) + if population <= 0: + raise RuntimeError("matched counter sampler has an empty population") + seed = int(self.config.seed) + step = int(self.step) + indices = [] + for position in range(self.fresh_video_total_samples): + payload = ( + f"sf-matched-counter-v1\0{seed}\0{step}\0{position}" + .encode("ascii") + ) + value = int.from_bytes( + hashlib.sha256(payload).digest()[:8], "little") + indices.append(value % population) + return tuple(indices) + + def _matched_batch_for_microbatch(self, microbatch_index): + if not getattr(self, "matched_counter_sampling_enabled", False): + raise RuntimeError("matched counter sampler is not enabled") + batch_size = int(self.config.batch_size) + local_offset = ( + dist.get_rank() * self.fresh_video_local_samples + + int(microbatch_index) * batch_size + ) + indices = self._matched_global_indices_for_step() + selected = indices[local_offset:local_offset + batch_size] + if len(selected) != batch_size: + raise RuntimeError("matched counter sampler produced a short slice") + items = [self.dataset[index] for index in selected] + self.data_counter += 1 + return self.dataset.collate_fn(items) + + def _fast_forward_dataloader(self, n_consumed): + if getattr(self, "matched_counter_sampling_enabled", False): + return + if n_consumed <= 0: + return + if hasattr(self.dataloader, "seek"): + skip = self.dataloader.seek(n_consumed) + else: + skip = n_consumed % self.dataloader_len + for _ in range(skip): + next(self.dataloader) + if self.is_main_process and skip: + print( + f" restored dataloader epoch/offset " + f"({n_consumed // self.dataloader_len}, {skip})") + + def _next_batch(self): + if getattr(self, "matched_counter_sampling_enabled", False): + raise RuntimeError( + "matched-manifest training must use the counter sampler") + self.data_counter += 1 + return next(self.dataloader) + + + def _load_eval_prompts(self): + path = getattr(self.config, "eval_prompt_path", "prompts/test_8.txt") + try: + with open(path, encoding="utf-8") as f: + lines = [l.strip() for l in f if l.strip()] + except FileNotFoundError: + if self.is_main_process: + print(f"[eval] prompt file {path} not found; eval disabled") + return [] + return lines[: self.eval_num_prompts] + + @torch.no_grad() + def _fd_eval_inference(self): + """Generate fixed-prompt videos with the live generator and log to wandb. + + Runs the model's own few-step (here single-step, no-CFG) generation under + a fixed seed so successive evals are directly comparable. All ranks + participate (FSDP forward is collective); only rank 0 writes/logs. + """ + if not self.eval_prompts: + return + # Frozen encoders stay in eval mode; generator gradients are enabled only on replay. + self.model.eval() + # fix the RNG for reproducible eval, then restore so training is unperturbed + cpu_rng = torch.get_rng_state() + cuda_rng = torch.cuda.get_rng_state(self.device) + torch.manual_seed(self.eval_seed) + + prompts = self.eval_prompts + # Evaluation is deliberately serialized at one video per rank. The + # training forward is also microbatch 1; evaluating all fixed prompts + # together would multiply the peak generator/VAE memory before the + # first optimizer update. + rank0_pixels = [] + try: + for prompt in prompts: + conditional_dict = self.model.text_encoder( + text_prompts=[prompt]) + unconditional_dict = self.model.text_encoder( + text_prompts=[self.config.negative_prompt]) + shape = [1] + list( + self.config.image_or_video_shape[1:]) + latent = self.model._run_few_step_generator( + image_or_video_shape=shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + ) + pixels = self.model.vae.decode_to_pixel( + latent.to(self.dtype)) + pixels = ((pixels + 1.0) * 0.5).clamp(0.0, 1.0) + if self.is_main_process: + rank0_pixels.append(pixels[0].float().cpu()) + del latent, pixels, conditional_dict, unconditional_dict + finally: + torch.set_rng_state(cpu_rng) + torch.cuda.set_rng_state(cuda_rng, self.device) + + if self.is_main_process: + eval_dir = os.path.join(self.output_path, "eval", f"step_{self.step:06d}") + os.makedirs(eval_dir, exist_ok=True) + log_videos = {} + for i in range(len(prompts)): + vid = ( + rank0_pixels[i].permute(0, 2, 3, 1).numpy() * 255.0 + ).astype("uint8") # [T,H,W,C] + out_path = os.path.join(eval_dir, f"sample_{i}.mp4") + wrote = False + try: + iio.imwrite( + out_path, + vid, + fps=16, + codec="libx264", + plugin="FFMPEG", + ) + wrote = True + except Exception as exc: # pragma: no cover - codec/runtime issues + print(f"[eval] failed to write {out_path}: {exc}") + # Log the encoded mp4 by PATH so wandb does not require moviepy. + if not self.disable_wandb and wrote: + try: + log_videos[f"eval/sample_{i}"] = wandb.Video(out_path, format="mp4") + except Exception as exc: # pragma: no cover + print(f"[eval] wandb.Video failed for {out_path}: {exc}") + if not self.disable_wandb and log_videos: + wandb.log(log_videos, step=self.step) + print(f"[eval] step {self.step}: wrote {len(prompts)} videos to {eval_dir}", flush=True) + if dist.is_initialized(): + dist.barrier() + + @staticmethod + def _batch_string_list(value, *, key, batch_size): + if isinstance(value, str): + values = [value] + elif isinstance(value, (list, tuple)): + values = list(value) + else: + raise TypeError(f"{key} must collate to a list of strings") + if ( + len(values) != batch_size + or any(not isinstance(item, str) or not item for item in values) + ): + raise ValueError( + f"{key} must contain one non-empty string per prompt") + return values + + def _attach_matched_teacher_payload(self, batch): + """Load cache/decode teacher data only for a selected replay suffix.""" + if self.current_teacher_source != "matched_manifest_video": + return + if self.current_teacher_dataset is None: + raise RuntimeError("matched teacher dataset is unavailable") + batch_size = len(batch.get("prompts", ())) + sample_ids = self._batch_string_list( + batch.get("sample_ids"), key="sample_ids", batch_size=batch_size) + cache_keys = self._batch_string_list( + batch.get("teacher_cache_keys"), key="teacher_cache_keys", + batch_size=batch_size) + local_error = None + payloads = None + try: + payloads = [ + self.current_teacher_dataset.load_teacher_payload(sample_id) + for sample_id in sample_ids + ] + for sample_id, cache_key, payload in zip( + sample_ids, cache_keys, payloads + ): + if ( + payload.get("sample_id") != sample_id + or payload.get("teacher_cache_key") != cache_key + ): + raise ValueError( + "lazy teacher payload identity does not match the " + "selected student") + hit_values = [ + bool(payload["current_teacher_cache_hit"]) + for payload in payloads + ] + if not (all(hit_values) or not any(hit_values)): + raise ValueError( + "a metadata microbatch cannot mix cached features and " + "decoded videos") + batch["current_teacher_cache_hit"] = torch.tensor( + hit_values, dtype=torch.bool) + batch["current_teacher_decode_seconds"] = sum( + float(payload["current_teacher_decode_seconds"]) + for payload in payloads + ) + if all(hit_values): + batch["current_teacher_features"] = torch.stack([ + payload["current_teacher_features"] + for payload in payloads + ]) + else: + batch["current_teacher_video01"] = torch.stack([ + payload["current_teacher_video01"] + for payload in payloads + ]) + except BaseException as exc: + local_error = exc + if not self._all_ranks_true(local_error is None): + message = ( + f": {type(local_error).__name__}: {local_error}" + if local_error is not None else " on another rank") + raise RuntimeError( + "matched live-teacher lazy I/O failed" + message + ) from local_error + + def _prepare_current_teacher_inputs(self, batch, batch_size): + """Validate and move exactly one configured teacher payload.""" + if not bool(getattr( + self.model, "current_teacher_attraction_enabled", False + )): + unexpected = [ + key for key in ( + "current_teacher_features", + "current_teacher_video01", + "current_teacher_latent", + "ode_latent", + ) + if batch.get(key) is not None + ] + if unexpected: + raise ValueError( + "fixed-only MMD rejects current-teacher payloads: " + f"{unexpected}") + return {}, None, None + + local_error = None + forward_inputs = None + cache_records = None + cache_log = None + try: + if self.current_teacher_source in { + "matched_dual_feature_bank", + "matched_triple_feature_bank", + "matched_dinov3_vjepa2_feature_bank", + }: + if self.current_teacher_dataset is None: + raise RuntimeError("matched teacher bank is unavailable") + sample_ids = self._batch_string_list( + batch.get("sample_ids"), key="sample_ids", + batch_size=batch_size) + features = self.current_teacher_dataset.load_rows(sample_ids) + expected_shapes = getattr( + self.model.latent_mmd, "sample_shapes", {}) + if set(features) != set(expected_shapes): + raise ValueError("teacher bank/model feature branches differ") + for name, tensor in features.items(): + expected = (batch_size, *expected_shapes[name]) + if tensor.dtype != torch.float16 or tuple(tensor.shape) != expected: + raise ValueError( + f"teacher bank {name} row shape/dtype mismatch: " + f"{tuple(tensor.shape)}/{tensor.dtype} != " + f"{expected}/float16") + features[name] = tensor.to( + device=self.device, dtype=torch.float32) + forward_inputs = {"current_teacher_features": features} + cache_records = () + cache_log = { + "hits": batch_size, + "misses": 0, + "decode_seconds": 0.0, + "bank_rows_read": batch_size, + } + elif self.current_teacher_source == "lmdb_latent": + latent = batch.get("ode_latent") + if ( + not isinstance(latent, torch.Tensor) + or latent.ndim != 5 + or int(latent.shape[0]) != batch_size + or not latent.is_floating_point() + ): + raise ValueError( + "exact LMDB current-teacher MMD requires one terminal " + "latent per prompt with shape " + "[B,frames,channels,height,width]") + latent = latent.to(device=self.device, dtype=self.dtype) + if not bool(torch.isfinite(latent).all().item()): + raise FloatingPointError( + "matched current teacher latent contains non-finite " + "values") + forward_inputs = {"current_teacher_latent": latent} + elif self.current_teacher_source in { + "openvid_video", "matched_manifest_video" + }: + features = batch.get("current_teacher_features") + video01 = batch.get("current_teacher_video01") + if (features is None) == (video01 is None): + raise ValueError( + "video teacher batch must provide exactly one of cached " + "current_teacher_features or current_teacher_video01") + cache_hit = batch.get("current_teacher_cache_hit") + if ( + not isinstance(cache_hit, torch.Tensor) + or cache_hit.dtype != torch.bool + or tuple(cache_hit.shape) != (batch_size,) + ): + raise ValueError( + "current_teacher_cache_hit must be BoolTensor[B]") + sample_ids = self._batch_string_list( + batch.get("sample_ids"), key="sample_ids", + batch_size=batch_size) + cache_keys = self._batch_string_list( + batch.get("teacher_cache_keys"), + key="teacher_cache_keys", batch_size=batch_size) + decode_value = batch.get("current_teacher_decode_seconds", 0.0) + if isinstance(decode_value, torch.Tensor): + decode_seconds = float( + decode_value.detach().float().sum().item()) + else: + decode_seconds = float(decode_value) + if not np.isfinite(decode_seconds) or decode_seconds < 0.0: + raise ValueError( + "current_teacher_decode_seconds must be finite and " + "non-negative") + if features is not None: + expected_shape = ( + batch_size, + int(self.model.dense_num_tokens), + int(self.model.dense_feature_dim), + ) + if ( + not isinstance(features, torch.Tensor) + or not features.is_floating_point() + or tuple(features.shape) != expected_shape + or not bool(cache_hit.all().item()) + ): + raise ValueError( + "cached video teacher features must be floating " + f"{expected_shape} with every cache-hit flag set") + features = features.to( + device=self.device, dtype=torch.float32) + if not bool(torch.isfinite(features).all().item()): + raise FloatingPointError( + "cached video teacher features are non-finite") + forward_inputs = { + "current_teacher_features": features} + cache_hits = batch_size + cache_records = () + else: + expected_prefix = ( + batch_size, + int(self.model.dense_num_frames), + 3, + ) + if ( + not isinstance(video01, torch.Tensor) + or not video01.is_floating_point() + or video01.ndim != 5 + or tuple(video01.shape[:3]) != expected_prefix + or bool(cache_hit.any().item()) + ): + raise ValueError( + "uncached video teacher video must be floating " + "[B,64,3,H,W] with every cache-hit flag clear") + if ( + not bool(torch.isfinite(video01).all().item()) + or float(video01.min().item()) < 0.0 + or float(video01.max().item()) > 1.0 + ): + raise ValueError( + "current_teacher_video01 must contain finite " + "values in [0,1]") + video01 = video01.to( + device=self.device, dtype=torch.float32) + forward_inputs = { + "current_teacher_video01": video01} + cache_hits = 0 + cache_records = tuple(zip(cache_keys, sample_ids)) + cache_log = { + "hits": cache_hits, + "misses": batch_size - cache_hits, + "decode_seconds": decode_seconds, + } + else: + raise RuntimeError( + "enabled exact current teacher has no configured source") + except BaseException as exc: + local_error = exc + if not self._all_ranks_true(local_error is None): + message = ( + f": {type(local_error).__name__}: {local_error}" + if local_error is not None else " on another rank") + raise RuntimeError( + "current teacher batch validation failed" + message + ) from local_error + return forward_inputs, cache_records, cache_log + + def _write_current_teacher_cache(self, records, features): + if not records: + return + if self.current_teacher_dataset is None: + raise RuntimeError("live-teacher feature cache writer is unavailable") + cpu_features = features.detach().float().cpu() + if int(cpu_features.shape[0]) != len(records): + raise ValueError( + "teacher feature count does not match cache-miss records") + for (cache_key, sample_id), feature in zip(records, cpu_features): + self.current_teacher_dataset.write_feature_cache( + cache_key, feature, sample_id=sample_id) + + def _collect_rollout_microbatch( + self, + batch, + *, + gradient_selected=True, + vjepa2_window_sample_keys=None, + ): + """Collect generated features and replay inputs without a generator graph. + + Reference minibatch rows are attached only to selected differentiation + samples; all generated features still enter the distribution loss.""" + text_prompts = batch["prompts"] + batch_size = len(text_prompts) + sample_ids = ( + self._batch_string_list( + batch.get("sample_ids"), key="sample_ids", + batch_size=batch_size) + if batch.get("sample_ids") is not None + else None + ) + if ( + gradient_selected + and self.current_teacher_source == "matched_manifest_video" + ): + self._attach_matched_teacher_payload(batch) + if gradient_selected: + teacher_forward_inputs, cache_records, teacher_cache_log = ( + self._prepare_current_teacher_inputs(batch, batch_size)) + else: + teacher_forward_inputs, cache_records, teacher_cache_log = ( + {}, None, None) + + # Transfer ownership out of the DataLoader batch before model entry. + # Each tensor is popped from teacher_forward_inputs as the call binds + # it, so the model can release its final reference before decoding the + # student and avoid a teacher+student pixel-memory peak. + for key in ( + "current_teacher_features", + "current_teacher_video01", + "ode_latent", + ): + batch.pop(key, None) + image_or_video_shape = list(self.config.image_or_video_shape) + image_or_video_shape[0] = batch_size + first_pass_started = time.perf_counter() + with torch.no_grad(): + conditional_dict = self.model.text_encoder( + text_prompts=text_prompts) + cached_unconditional = getattr( + self, "unconditional_dict", None) + cached_batch = ( + next(iter(cached_unconditional.values())).shape[0] + if cached_unconditional else None + ) + if cached_unconditional is None or cached_batch != batch_size: + unconditional_dict = self.model.text_encoder( + text_prompts=[ + self.config.negative_prompt] * batch_size) + unconditional_dict = { + key: value.detach() + for key, value in unconditional_dict.items() + } + self.unconditional_dict = unconditional_dict + else: + unconditional_dict = cached_unconditional + + features, replay_descriptor = ( + self.model.collect_rollout_features( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + initial_latent=None, + vjepa2_window_sample_keys=( + vjepa2_window_sample_keys), + require_current_teacher=bool( + gradient_selected + and getattr( + self.model, + "current_teacher_attraction_enabled", + False, + ) + ), + current_teacher_features=teacher_forward_inputs.pop( + "current_teacher_features", None), + current_teacher_video01=teacher_forward_inputs.pop( + "current_teacher_video01", None), + current_teacher_latent=teacher_forward_inputs.pop( + "current_teacher_latent", None), + ) + ) + first_pass_seconds = time.perf_counter() - first_pass_started + current_teacher_features = replay_descriptor.pop( + "current_teacher_features", None) + teacher_extract_seconds = replay_descriptor.pop( + "current_teacher_extract_seconds", None) + if ( + gradient_selected + and bool(getattr( + self.model, "current_teacher_attraction_enabled", False)) + and current_teacher_features is None + ): + raise RuntimeError( + "fresh first pass omitted matched current teacher features") + cache_write_error = None + try: + if cache_records: + self._write_current_teacher_cache( + cache_records, current_teacher_features) + except BaseException as exc: + cache_write_error = exc + if not self._all_ranks_true(cache_write_error is None): + message = ( + f": {type(cache_write_error).__name__}: {cache_write_error}" + if cache_write_error is not None else " on another rank") + raise RuntimeError( + "live-teacher feature cache write failed" + message + ) from cache_write_error + # The call-site pops every mutually exclusive input while binding it; + # retaining anything here would defeat the teacher payload handoff. + if teacher_forward_inputs: + raise RuntimeError( + "unconsumed current teacher forward input remained") + if teacher_cache_log is not None: + teacher_cache_log["first_pass_seconds"] = first_pass_seconds + if teacher_extract_seconds is not None: + teacher_cache_log["extract_seconds"] = float( + teacher_extract_seconds) + entry = { + "features": features, + "replay_descriptor": replay_descriptor, + "image_or_video_shape": image_or_video_shape, + "conditional_dict": conditional_dict, + "unconditional_dict": unconditional_dict, + "gradient_selected": bool(gradient_selected), + "sample_ids": sample_ids, + } + if current_teacher_features is not None: + entry["current_teacher_features"] = current_teacher_features + if teacher_cache_log is not None: + entry["current_teacher_cache_log"] = teacher_cache_log + return entry + + @staticmethod + def _slice_fresh_video_batch_mapping(mapping, start, stop, batch_size): + sliced = {} + for key, value in mapping.items(): + if ( + isinstance(value, torch.Tensor) + and value.ndim > 0 + and int(value.shape[0]) == batch_size + ): + sliced[key] = value[start:stop] + else: + sliced[key] = value + return sliced + + def _aggregate_fresh_video_forward_entries( + self, + physical_entries, + *, + gradient_selected, + ): + if not physical_entries: + raise ValueError("logical fresh-video microbatch is empty") + first_features = physical_entries[0]["features"] + if isinstance(first_features, Mapping): + features = { + name: torch.cat([ + entry["features"][name] for entry in physical_entries + ], dim=0) + for name in first_features + } + else: + features = torch.cat([ + entry["features"] for entry in physical_entries + ], dim=0) + sample_id_parts = [ + entry.get("sample_ids") for entry in physical_entries + ] + if all(value is None for value in sample_id_parts): + sample_ids = None + elif any(value is None for value in sample_id_parts): + raise RuntimeError( + "sample identities are incomplete within a logical batch") + else: + sample_ids = tuple( + sample_id + for values in sample_id_parts + for sample_id in values + ) + if len(sample_ids) != self.fresh_video_logical_micro_batch_size: + raise RuntimeError( + "logical fresh-video sample identity count mismatch") + aggregate = { + "features": features, + "gradient_selected": bool(gradient_selected), + "sample_ids": sample_ids, + "physical_entries": tuple(physical_entries), + } + teacher_values = [ + entry.get("current_teacher_features") + for entry in physical_entries + ] + if any(value is not None for value in teacher_values): + if not all(value is not None for value in teacher_values): + raise RuntimeError( + "matched teacher features are incomplete within a " + "logical microbatch") + if isinstance(teacher_values[0], Mapping): + aggregate["current_teacher_features"] = { + name: torch.cat([ + value[name] for value in teacher_values + ], dim=0) + for name in teacher_values[0] + } + else: + aggregate["current_teacher_features"] = torch.cat( + teacher_values, dim=0) + for entry in physical_entries: + entry.pop("features", None) + entry.pop("current_teacher_features", None) + return aggregate + + def _split_fresh_video_replay_units( + self, + logical_entry, + logical_cotangent, + ): + logical_features = logical_entry["features"] + units = [] + logical_offset = 0 + replay_batch = self.fresh_video_physical_replay_batch_size + for physical_entry in logical_entry["physical_entries"]: + descriptor = physical_entry["replay_descriptor"] + physical_batch = int(descriptor["latent_shape"][0]) + for start in range(0, physical_batch, replay_batch): + stop = start + replay_batch + replay_descriptor = dict(descriptor) + replay_descriptor["latent_shape"] = ( + replay_batch, *tuple(descriptor["latent_shape"][1:])) + replay_descriptor["student_latent"] = descriptor[ + "student_latent"][start:stop] + generation_descriptor = dict( + descriptor["generation_descriptor"]) + generation_descriptor["noise"] = generation_descriptor[ + "noise"][start:stop] + generation_descriptor["trajectory_seeds"] = ( + generation_descriptor["trajectory_seeds"][start:stop]) + replay_descriptor["generation_descriptor"] = ( + generation_descriptor) + for key, value in tuple(replay_descriptor.items()): + if key in { + "rng_state", "latent_shape", "generation_descriptor", + "student_latent", + }: + continue + if ( + isinstance(value, torch.Tensor) + and value.ndim > 0 + and int(value.shape[0]) == physical_batch + ): + replay_descriptor[key] = value[start:stop] + unit_slice = slice( + logical_offset + start, logical_offset + stop) + unit_features = ( + { + name: value[unit_slice] + for name, value in logical_features.items() + } + if isinstance(logical_features, Mapping) + else logical_features[unit_slice] + ) + unit_cotangent = ( + { + name: value[unit_slice] + for name, value in logical_cotangent.items() + } + if isinstance(logical_cotangent, Mapping) + else logical_cotangent[unit_slice] + ) + units.append({ + "replay_descriptor": replay_descriptor, + "features": unit_features, + "cotangent": unit_cotangent, + "image_or_video_shape": [ + replay_batch, + *physical_entry["image_or_video_shape"][1:], + ], + "conditional_dict": ( + self._slice_fresh_video_batch_mapping( + physical_entry["conditional_dict"], + start, stop, physical_batch)), + "unconditional_dict": ( + self._slice_fresh_video_batch_mapping( + physical_entry["unconditional_dict"], + start, stop, physical_batch)), + }) + logical_offset += physical_batch + if logical_offset != self.fresh_video_logical_micro_batch_size: + raise RuntimeError( + "logical replay partition does not cover its full batch") + return units + + def _fresh_video_window_log(self, entries, reference): + if getattr( + self.model, + "vjepa2_dense_frame_sampling", + "center_contiguous", + ) != "random_contiguous_per_video": + return {} + num_starts = ( + int(self.model.vjepa2_dense_source_num_frames) + - int(self.model.vjepa2_dense_num_frames) + + 1 + ) + local_error = None + local_starts = None + try: + start_parts = [] + for entry in entries: + for physical_entry in entry["physical_entries"]: + indices = physical_entry["replay_descriptor"][ + "vjepa2_frame_indices"] + if ( + not isinstance(indices, torch.Tensor) + or indices.ndim != 2 + or int(indices.shape[1]) + != int(self.model.vjepa2_dense_num_frames) + ): + raise ValueError( + "invalid V-JEPA2 frame indices in GradCache entry") + start_parts.append(indices[:, 0].to( + device=reference.device, dtype=torch.long)) + local_starts = torch.cat(start_parts) + if int(local_starts.numel()) != self.fresh_video_local_samples: + raise ValueError( + "V-JEPA2 window count does not match local samples") + if ( + int(local_starts.min().item()) < 0 + or int(local_starts.max().item()) >= num_starts + ): + raise ValueError("V-JEPA2 window start is out of range") + except BaseException as exc: + local_error = exc + if not self._all_ranks_true(local_error is None): + message = ( + f": {type(local_error).__name__}: {local_error}" + if local_error is not None + else " on another rank" + ) + raise RuntimeError( + "V-JEPA2 window logging validation failed" + message + ) from local_error + histogram = torch.bincount( + local_starts, minlength=num_starts)[:num_starts] + if dist.is_initialized() and self.world_size > 1: + dist.all_reduce(histogram, op=dist.ReduceOp.SUM) + if int(histogram.sum().item()) != self.fresh_video_total_samples: + raise RuntimeError( + "global V-JEPA2 window histogram has an invalid sample count") + used = histogram.nonzero(as_tuple=False).flatten() + if used.numel() == 0: + raise RuntimeError("global V-JEPA2 window histogram is empty") + return { + "vjepa2_dense_window_start_min": used[0], + "vjepa2_dense_window_start_max": used[-1], + "vjepa2_dense_window_unique_starts": used.new_tensor( + int(used.numel())), + "vjepa2_dense_source_num_frames": used.new_tensor( + int(self.model.vjepa2_dense_source_num_frames)), + } + + def _validate_partial_mmd_geometry(self, result): + expected_forward = self.fresh_video_total_samples + expected_gradient = self.fresh_video_generator_grad_global_samples + expected_teacher = ( + expected_gradient + if bool(getattr( + self.model, + "current_teacher_attraction_enabled", + False, + )) + else 0 + ) + full_student_mode = self.fresh_video_full_student_partial_gradient + expected_kss = expected_forward * ( + expected_forward if full_student_mode else expected_gradient) + expected_fixed = expected_forward if full_student_mode else expected_gradient + expected_live = expected_fixed * expected_teacher + expected_teacher_self = expected_teacher * expected_teacher + metadata = result.metadata + branch_metadata = metadata.get("branches") + if isinstance(branch_metadata, Mapping): + registry = getattr( + self.model, "dual_branch_registry", None) + if isinstance(registry, Mapping) and registry: + expected_branches = set(registry) + else: + feature_type = str(getattr( + self.model, "feature_type", "")) + expected_branches = ( + {"dinov3", "vjepa2", "videomae_v2"} + if feature_type + == "triple_dinov3_vjepa2_videomae_v2_dense_latent" + else ( + {"dinov3", "vjepa2"} + if feature_type + == "dual_dinov3_vjepa2_dense_latent" + else {"vjepa2", "videomae_v2"} + ) + ) + if set(branch_metadata) != expected_branches: + raise RuntimeError( + "multi-representation MMD metadata branch set mismatch: " + f"{set(branch_metadata)} != {expected_branches}") + denominator_keys = [ + "repulsion_denominator", + "fixed_teacher_attraction_denominator", + ] + expected_branch_values = [ + expected_kss, + expected_fixed, + ] + if expected_teacher: + denominator_keys.extend([ + "current_teacher_attraction_denominator", + "current_teacher_self_kernel_denominator", + ]) + expected_branch_values.extend([ + expected_live, + expected_teacher_self, + ]) + for name, branch in branch_metadata.items(): + observed_branch_values = tuple( + int(torch.as_tensor(branch[key]).item()) + for key in denominator_keys + ) + if observed_branch_values != tuple(expected_branch_values): + raise RuntimeError( + f"multi-representation branch {name} denominator mismatch: " + f"{observed_branch_values} != " + f"{tuple(expected_branch_values)}") + if int(torch.as_tensor( + branch.get("full_student_partial_gradient", 0) + ).item()) != 1: + raise RuntimeError( + f"multi-representation branch {name} omitted full-student injection") + metadata = {**metadata, **branch_metadata["vjepa2"]} + observed = ( + int(result.global_samples), + int(result.gradient_global_samples), + int(result.current_teacher_global_samples), + int(torch.as_tensor( + metadata["gradcache_kss_denominator"]).item()), + int(torch.as_tensor( + metadata["fixed_teacher_attraction_denominator"]).item()), + int(torch.as_tensor( + metadata.get("current_teacher_attraction_denominator", 0)).item()), + int(torch.as_tensor( + metadata.get("current_teacher_self_kernel_denominator", 0)).item()), + int(torch.as_tensor( + metadata["gradcache_full_student_partial_gradient"]).item()), + ) + expected = ( + expected_forward, + expected_gradient, + expected_teacher, + expected_kss, + expected_fixed, + expected_live, + expected_teacher_self, + int(full_student_mode), + ) + if observed != expected: + raise RuntimeError( + "partial-MMD geometry mismatch: " + f"observed={observed}, expected={expected}") + if int(torch.as_tensor( + metadata.get("repulsion_denominator", -1) + ).item()) != expected_kss: + raise RuntimeError("partial-MMD core Kss denominator mismatch") + if full_student_mode: + if int(torch.as_tensor( + metadata.get("full_student_partial_gradient", 0) + ).item()) != 1: + raise RuntimeError( + "loss did not activate full-student partial gradients") + configured_denominators = ( + str(getattr( + self.config, "partial_mmd_normalization_identity", "")), + int(getattr( + self.config, "partial_mmd_kss_denominator", -1)), + int(getattr( + self.config, + "partial_mmd_fixed_attraction_denominator", -1)), + int(getattr( + self.config, "partial_mmd_live_cross_denominator", -1)), + int(getattr( + self.config, + "partial_mmd_teacher_self_denominator", -1)), + ) + expected_config = ( + "full_student_population_denominators_v1", + expected_kss, + expected_fixed, + expected_live, + expected_teacher_self, + ) + if configured_denominators != expected_config: + raise RuntimeError( + "full-student MMD config normalization mismatch: " + f"{configured_denominators} != {expected_config}") + configured_compensation = getattr( + self.config, "partial_mmd_gradient_compensation", None) + if ( + configured_compensation is not None + and float(configured_compensation) + != self.fresh_video_generator_grad_sample_scale + ): + raise RuntimeError( + "full-student MMD gradient compensation mismatch: " + f"{configured_compensation} != " + f"{self.fresh_video_generator_grad_sample_scale}") + if bool(getattr( + self.model, "nystrom_repulsion_fifo_enabled", False + )): + raise RuntimeError("partial-MMD experiment requires FIFO=0") + if bool(getattr( + self.model, "nystrom_attraction_ema_enabled", False + )): + raise RuntimeError( + "partial-MMD experiment requires attraction EMA=0") + layers = tuple(getattr( + self.model, + "vjepa2_dense_layers", + (int(self.model.dense_layer),), + )) + if layers != (15,): + raise RuntimeError( + f"partial-MMD experiment requires only layer 15, got {layers}") + if self.is_main_process and int(self.step) == 0: + print( + "[partial-mmd-geometry] " + f"forward={expected_forward} " + f"gradient={expected_gradient} " + f"teacher={expected_teacher} " + f"Kss_denominator={expected_kss} " + f"fixed_attraction_denominator={expected_fixed} " + f"live_cross_denominator={expected_live} " + f"teacher_self_denominator={expected_teacher_self} " + f"full_student_partial_gradient={int(full_student_mode)} " + "gradient_compensation=" + f"{self.fresh_video_generator_grad_sample_scale:g} " + "fifo=0 attraction_ema=0 layer=15", + flush=True, + ) + if bool(getattr(self.model, "dual_video_mmd_enabled", False)): + registry = getattr(self.model, "dual_branch_registry", {}) + shapes = { + name: list(branch["sample_shape"]) + for name, branch in sorted(registry.items()) + } + expected_shapes = { + name: ([1280, 1024] if name == "videomae_v2" + else [1024, 1024]) + for name in registry + } + if shapes != expected_shapes: + raise RuntimeError( + f"multi-encoder branch sample shapes changed: {shapes}") + feature_type = str(getattr( + self.model, "feature_type", "")) + shape_tag = ( + "triple-sample-shapes" + if feature_type + == "triple_dinov3_vjepa2_videomae_v2_dense_latent" + else ( + "dinov3-vjepa2-sample-shapes" + if feature_type == "dual_dinov3_vjepa2_dense_latent" + else "dual-sample-shapes" + ) + ) + print( + f"[{shape_tag}] " + + " ".join( + f"{name}={shape[0]}x{shape[1]}" + for name, shape in sorted(shapes.items()) + ), + flush=True, + ) + + def _run_distribution_matching_step(self): + """Perform one distribution-matching update (paper Sec. 3.3). + + Collect B generated representations, differentiate the feature objective + for b selected rollouts, then replay encoders, VAE and generator. The + released selection and loss conventions are documented in paper-to-code.md.""" + self._fresh_progress("feature_collection_start") + restore_error = None + try: + self.model.restore_frozen_modules_before_feature_replay() + except BaseException as exc: + restore_error = exc + if not self._all_ranks_true(restore_error is None): + message = ( + f": {type(restore_error).__name__}: {restore_error}" + if restore_error is not None + else " on another rank" + ) + raise RuntimeError( + "frozen replay module restore failed" + message + ) from restore_error + # Frozen encoders stay in eval mode; generator gradients are enabled only on replay. + self.model.eval() + dense_log_prefix = getattr( + self.model, "dense_log_prefix", "vjepa2_dense") + + def dense_key(suffix): + return f"{dense_log_prefix}_{suffix}" + + if self.step % 20 == 0: + torch.cuda.empty_cache() + # This cache lasts one optimizer update; it is not a history of older rollouts. + cache = self.fresh_video_gradcache + if cache is None: + raise RuntimeError("fresh video GradCache is not initialized") + if cache.local_samples or cache.finalized: + raise RuntimeError( + "fresh video GradCache leaked state across optimizer updates") + if ( + hasattr(self.model, "distribution_state_pending") + and self.model.distribution_state_pending() + ): + raise RuntimeError( + "dynamic distribution state leaked across optimizer updates") + # This consensus must happen before the first model forward because + # enabled ranks enter a pixel-cache collective inside that forward. + use_prepared_replay = bool( + self.uses_fresh_video_gradcache + and self.model.fresh_video_prepared_replay_enabled + ) + all_use_prepared = self._all_ranks_true(use_prepared_replay) + none_use_prepared = self._all_ranks_true( + not use_prepared_replay) + if not (all_use_prepared or none_use_prepared): + raise RuntimeError( + "fresh-video prepared replay setting differs across ranks") + use_prepared_replay = all_use_prepared + + entries = [] + try: + physical_per_logical = ( + self.fresh_video_logical_micro_batch_size + // self.fresh_video_physical_forward_batch_size) + for microbatch_index in range(self.fresh_video_microbatches): + gradient_selected = bool( + microbatch_index >= ( + self.fresh_video_microbatches + - self.fresh_video_generator_grad_microbatches) + ) + physical_entries = [] + for physical_slot in range(physical_per_logical): + physical_index = ( + microbatch_index * physical_per_logical + + physical_slot) + window_sample_keys = None + if getattr( + self.model, + "vjepa2_dense_frame_sampling", + "center_contiguous", + ) == "random_contiguous_per_video": + local_start = ( + physical_index + * self.fresh_video_physical_forward_batch_size) + global_start = ( + dist.get_rank() * self.fresh_video_local_samples + + local_start) + update_start = ( + int(self.step) * self.fresh_video_total_samples) + window_sample_keys = tuple( + update_start + global_start + offset + for offset in range( + self.fresh_video_physical_forward_batch_size)) + batch = ( + self._matched_batch_for_microbatch(physical_index) + if getattr( + self, "matched_counter_sampling_enabled", False) + else self._next_batch() + ) + physical_entries.append( + self._collect_rollout_microbatch( + batch, + gradient_selected=gradient_selected, + vjepa2_window_sample_keys=window_sample_keys, + ) + ) + entry = self._aggregate_fresh_video_forward_entries( + physical_entries, + gradient_selected=gradient_selected, + ) + add_error = None + try: + student_ids = entry.get("sample_ids") + if hasattr(cache, "partial_gradient_enabled"): + cache.add( + entry["features"], + entry.get("current_teacher_features"), + gradient_selected=gradient_selected, + sample_ids=student_ids, + current_teacher_sample_ids=( + student_ids + if entry.get( + "current_teacher_features") is not None + else None + ), + ) + else: + cache.add(entry["features"]) + entry.pop("current_teacher_features", None) + except BaseException as exc: + add_error = exc + if not self._all_ranks_true(add_error is None): + message = ( + f": {type(add_error).__name__}: {add_error}" + if add_error is not None + else " on another rank" + ) + raise RuntimeError( + "fresh video GradCache add failed" + message + ) from add_error + if not gradient_selected: + for physical_entry in entry["physical_entries"]: + descriptor = physical_entry.get( + "replay_descriptor", {}) + descriptor.pop("cached_student_video01", None) + physical_entry.pop("conditional_dict", None) + physical_entry.pop("unconditional_dict", None) + physical_entry.pop("image_or_video_shape", None) + entries.append(entry) + # Differentiate the global feature-space loss before constructing generator graphs. + result = cache.finalize() + if ( + self.fresh_video_generator_grad_subsample_enabled + and hasattr(cache, "partial_gradient_enabled") + ): + self._validate_partial_mmd_geometry(result) + window_log = self._fresh_video_window_log(entries, result.loss) + if ( + use_prepared_replay + and self.model.generator_replay_frozen_encoder_cpu_offload + ): + if not hasattr(cache, "offload_finalized_to_cpu"): + raise RuntimeError( + "prepared triple GradCache lacks CPU offload support") + result = cache.offload_finalized_to_cpu() + for entry in entries: + features = entry.pop("features") + if entry.get("gradient_selected", False): + if not isinstance(features, Mapping): + raise TypeError( + "triple replay features must be a mapping") + entry["features"] = { + name: value.detach().to( + device="cpu", non_blocking=False) + for name, value in features.items() + } + video_cotangents = result.fsdp_cotangents + expected_replay_start = ( + len(entries) + - self.fresh_video_generator_grad_microbatches + ) + expected_replay_indices = tuple( + range(expected_replay_start, len(entries))) + replay_indices = tuple(getattr( + result, + "replay_batch_indices", + expected_replay_indices, + )) + if replay_indices != expected_replay_indices: + raise RuntimeError( + "GradCache replay indices are not the configured suffix: " + f"{replay_indices} != {expected_replay_indices}") + replay_entries = tuple(entries[index] for index in replay_indices) + if ( + not replay_entries + or len(replay_entries) + != self.fresh_video_generator_grad_microbatches + or ( + hasattr(cache, "partial_gradient_enabled") + and any( + not entry.get("gradient_selected", False) + for entry in replay_entries + ) + ) + ): + raise RuntimeError( + "generator-gradient replay microbatch selection is invalid") + if len(video_cotangents) == len(replay_indices): + replay_video_cotangents = tuple(video_cotangents) + elif len(video_cotangents) == len(entries): + replay_video_cotangents = tuple( + video_cotangents[index] for index in replay_indices) + else: + raise RuntimeError( + "fresh GradCache cotangent/replay count mismatch") + # Apply B/b selection compensation once; FSDP averaging has a separate factor. + replay_video_cotangents = _scale_replay_video_cotangents( + replay_video_cotangents, + self.fresh_video_generator_grad_sample_scale, + ) + replay_units = [] + for entry, cotangent in zip( + replay_entries, replay_video_cotangents + ): + replay_units.extend( + self._split_fresh_video_replay_units(entry, cotangent)) + if ( + len(replay_units) + * self.fresh_video_physical_replay_batch_size + != self.fresh_video_generator_grad_local_samples + ): + raise RuntimeError( + "physical replay units do not cover exactly the configured " + "gradient suffix") + prepared_replays = [None] * len(replay_units) + self._fresh_progress("encoder_vae_replay_start", units=len(replay_units)) + if use_prepared_replay: + prepared_replays = [ + self.model.prepare_latent_gradients( + replay_descriptor=unit["replay_descriptor"], + expected_features=unit["features"], + feature_cotangent=unit["cotangent"], + replay_atol=float(getattr( + self.config, "fresh_video_replay_atol", 1e-5)), + replay_rtol=float(getattr( + self.config, "fresh_video_replay_rtol", 1e-4)), + ) + for unit in replay_units + ] + + replay_offload_bytes = 0 + self._fresh_progress("encoder_vae_replay_complete") + if use_prepared_replay: + offload_error = None + try: + replay_offload_bytes = ( + self.model.offload_frozen_modules_for_generator_replay() + ) + except BaseException as exc: + offload_error = exc + if not self._all_ranks_true(offload_error is None): + message = ( + f": {type(offload_error).__name__}: {offload_error}" + if offload_error is not None + else " on another rank" + ) + raise RuntimeError( + "frozen replay module offload failed" + message + ) from offload_error + + replay_logs = [] + self._fresh_progress( + "generator_backward_start", units=len(replay_units)) + for unit, prepared_replay in zip(replay_units, prepared_replays): + surrogate, replay_log = self.model.replay_generator_gradients( + replay_descriptor=unit["replay_descriptor"], + expected_features=unit["features"], + feature_cotangent=unit["cotangent"], + image_or_video_shape=unit["image_or_video_shape"], + conditional_dict=unit["conditional_dict"], + unconditional_dict=unit["unconditional_dict"], + initial_latent=None, + prepared_replay=prepared_replay, + replay_atol=float(getattr( + self.config, "fresh_video_replay_atol", 1e-5)), + replay_rtol=float(getattr( + self.config, "fresh_video_replay_rtol", 1e-4)), + ) + surrogate.backward() + replay_logs.append(replay_log) + if ( + len(replay_logs) % 4 == 0 + or len(replay_logs) == len(replay_units) + ): + self._fresh_progress( + "generator_backward_progress", + completed=len(replay_logs), + total=len(replay_units), + ) + + generator_grad_norm = self.model.generator.clip_grad_norm_( + self.max_grad_norm_generator) + log_dict = { + key: torch.stack([ + item[key].detach().float() for item in replay_logs + ]).mean() + for key in replay_logs[0] + } + meta = result.metadata + generator_loss = result.optimized_loss + multilayer_metadata = meta.get("layers") + displayed_mmd = ( + meta["raw_mmd_sum"] + if isinstance(multilayer_metadata, dict) + else result.loss + ) + log_dict.update({ + "generator_loss": generator_loss, + "generator_grad_norm": generator_grad_norm, + "dmdtrain_gradient_norm": log_dict[ + "combined_latent_grad_norm"], + dense_key("mmd"): displayed_mmd, + dense_key("optimized_mmd"): result.optimized_loss, + dense_key("exact_repulsion"): meta["repulsion"], + dense_key("nystrom_attraction"): meta["attraction"], + dense_key("nystrom_attraction_weight"): meta.get( + "attraction_weight", result.loss.new_ones(())), + dense_key("reference_constant"): meta[ + "reference_constant"], + dense_key("local_video_samples"): meta[ + "gradcache_local_samples"], + dense_key("global_video_samples"): meta[ + "gradcache_global_samples"], + dense_key("gradient_local_video_samples"): meta.get( + "gradcache_gradient_local_samples", + result.loss.new_tensor( + self.fresh_video_generator_grad_local_samples, + dtype=torch.long, + ), + ), + dense_key("gradient_global_video_samples"): meta.get( + "gradcache_gradient_global_samples", + result.loss.new_tensor( + self.fresh_video_generator_grad_global_samples, + dtype=torch.long, + ), + ), + dense_key("current_teacher_global_samples"): meta.get( + "gradcache_current_teacher_global_samples", + result.loss.new_zeros((), dtype=torch.long), + ), + dense_key("kss_denominator"): meta.get( + "gradcache_kss_denominator", + result.loss.new_tensor( + self.fresh_video_total_samples ** 2, + dtype=torch.long, + ), + ), + dense_key("fresh_microbatches"): result.loss.new_tensor( + len(entries), dtype=torch.long), + dense_key("replay_microbatches"): result.loss.new_tensor( + len(replay_entries), dtype=torch.long), + dense_key("physical_forward_calls"): result.loss.new_tensor( + len(entries) * physical_per_logical, dtype=torch.long), + dense_key("physical_replay_calls"): result.loss.new_tensor( + len(replay_units), dtype=torch.long), + dense_key("raw_tokens"): result.loss.new_tensor( + getattr( + self.model, + "dense_raw_tokens", + self.model.vjepa2_dense_raw_tokens, + ), dtype=torch.long), + dense_key("sampled_tokens"): result.loss.new_tensor( + getattr( + self.model, + "dense_num_tokens", + self.model.vjepa2_dense_num_tokens, + ), dtype=torch.long), + dense_key("feature_dim"): result.loss.new_tensor( + getattr( + self.model, + "dense_feature_dim", + self.model.vjepa2_dense_feature_dim, + ), dtype=torch.long), + dense_key("num_frames"): result.loss.new_tensor( + getattr( + self.model, + "dense_num_frames", + self.model.vjepa2_dense_num_frames, + ), dtype=torch.long), + dense_key("layer"): result.loss.new_tensor( + getattr( + self.model, + "dense_layer", + self.model.vjepa2_dense_layer, + ), dtype=torch.long), + dense_key("prefix_blocks"): result.loss.new_tensor( + getattr( + self.model, + "dense_layer", + self.model.vjepa2_dense_layer, + ) + 1, dtype=torch.long), + dense_key("nystrom_landmarks"): result.loss.new_tensor( + self.model.latent_mmd.landmarks.shape[0], + dtype=torch.long), + dense_key("reference_samples"): result.loss.new_tensor( + self.model.vjepa2_nystrom_reference_count, + dtype=torch.long), + "fresh_video_optimizer_step_skipped": ( + result.loss.new_zeros((), dtype=torch.long)), + "fresh_video_generator_grad_sample_scale": ( + result.loss.new_tensor( + self.fresh_video_generator_grad_sample_scale)), + "fresh_video_generator_effective_replay_scale": ( + result.loss.new_tensor( + self.fresh_video_generator_grad_sample_scale + * float(getattr(self, "world_size", 1)))), + "fresh_video_generator_grad_subsample_enabled": ( + result.loss.new_tensor( + int(self.fresh_video_generator_grad_subsample_enabled), + dtype=torch.long, + ) + ), + "fresh_video_generator_grad_local_samples": ( + result.loss.new_tensor( + self.fresh_video_generator_grad_local_samples, + dtype=torch.long, + ) + ), + "fresh_video_generator_grad_global_samples": ( + result.loss.new_tensor( + self.fresh_video_generator_grad_global_samples, + dtype=torch.long, + ) + ), + "fresh_video_generator_grad_microbatches": ( + result.loss.new_tensor( + self.fresh_video_generator_grad_microbatches, + dtype=torch.long, + ) + ), + "fresh_video_logical_micro_batch_size": ( + result.loss.new_tensor( + self.fresh_video_logical_micro_batch_size, + dtype=torch.long, + ) + ), + "fresh_video_physical_forward_batch_size": ( + result.loss.new_tensor( + self.fresh_video_physical_forward_batch_size, + dtype=torch.long, + ) + ), + "fresh_video_physical_replay_batch_size": ( + result.loss.new_tensor( + self.fresh_video_physical_replay_batch_size, + dtype=torch.long, + ) + ), + "generator_replay_frozen_module_offload_bytes": ( + result.loss.new_tensor( + replay_offload_bytes, dtype=torch.long) + ), + }) + if isinstance(meta.get("branches"), Mapping): + for branch_name, branch in meta["branches"].items(): + prefix = f"{branch_name}_dense" + branch_logs = { + f"{prefix}_raw_mmd": branch["raw_mmd"], + f"{prefix}_normalized_mmd": branch["normalized_mmd"], + f"{prefix}_normalization_denominator": ( + branch["normalization_denominator"]), + f"{prefix}_exact_repulsion": branch["repulsion"], + f"{prefix}_fixed_teacher_attraction": ( + branch.get( + "fixed_teacher_attraction", + branch["attraction"], + )), + f"{prefix}_reference_constant": ( + branch["reference_constant"]), + f"{prefix}_bandwidth": branch["sigma"], + f"{prefix}_kss_denominator": ( + branch["repulsion_denominator"]), + f"{prefix}_fixed_attraction_denominator": ( + branch["fixed_teacher_attraction_denominator"]), + } + if "current_teacher_attraction" in branch: + branch_logs.update({ + f"{prefix}_current_teacher_attraction": ( + branch["current_teacher_attraction"]), + f"{prefix}_teacher_cross_denominator": ( + branch[ + "current_teacher_attraction_denominator"]), + f"{prefix}_teacher_self_denominator": ( + branch[ + "current_teacher_self_kernel_denominator"]), + }) + log_dict.update(branch_logs) + log_dict["dual_dense_normalized_mmd_sum"] = meta[ + "normalized_mmd_sum"] + teacher_cache_logs = [ + physical_entry["current_teacher_cache_log"] + for entry in entries + for physical_entry in entry["physical_entries"] + if "current_teacher_cache_log" in physical_entry + ] + if teacher_cache_logs: + cache_stats = torch.tensor([ + sum(item["hits"] for item in teacher_cache_logs), + sum(item["misses"] for item in teacher_cache_logs), + sum(item["decode_seconds"] for item in teacher_cache_logs), + sum(item["first_pass_seconds"] for item in teacher_cache_logs), + len(teacher_cache_logs), + sum(item.get("extract_seconds", 0.0) + for item in teacher_cache_logs), + sum("extract_seconds" in item + for item in teacher_cache_logs), + ], dtype=torch.float64, device=torch.device( + "cuda", + self.device, + )) + if dist.is_initialized() and self.world_size > 1: + dist.all_reduce(cache_stats, op=dist.ReduceOp.SUM) + total_items = cache_stats[0] + cache_stats[1] + teacher_metric_prefix = ( + "triple_bank" + if self.current_teacher_source == "matched_triple_feature_bank" + else ("dinov3_vjepa2_bank" + if self.current_teacher_source + == "matched_dinov3_vjepa2_feature_bank" + else ("dual_bank" + if self.current_teacher_source == "matched_dual_feature_bank" + else ( + "matched" + if self.current_teacher_source == "matched_manifest_video" + else "openvid" + ))) + ) + log_dict.update({ + f"{teacher_metric_prefix}_teacher_cache_hits": cache_stats[0], + f"{teacher_metric_prefix}_teacher_cache_misses": cache_stats[1], + f"{teacher_metric_prefix}_teacher_cache_hit_rate": ( + cache_stats[0] / total_items.clamp_min(1.0)), + f"{teacher_metric_prefix}_teacher_decode_seconds": cache_stats[2], + f"{teacher_metric_prefix}_teacher_first_pass_seconds_per_microbatch": ( + cache_stats[3] / cache_stats[4].clamp_min(1.0)), + }) + if float(cache_stats[6].item()) > 0: + log_dict[ + f"{teacher_metric_prefix}_teacher_extract_seconds_per_microbatch" + ] = cache_stats[5] / cache_stats[6] + if "current_attraction" in meta: + log_dict.update({ + dense_key("nystrom_current_attraction"): ( + meta["current_attraction"]), + dense_key("nystrom_attraction_ema_updates"): ( + meta["attraction_ema_updates"]), + dense_key("nystrom_attraction_current_coefficient"): ( + meta["attraction_current_coefficient"]), + }) + if "current_teacher_attraction" in meta: + log_dict.update({ + dense_key("fixed_teacher_attraction"): ( + meta["fixed_teacher_attraction"]), + dense_key("current_teacher_attraction"): ( + meta["current_teacher_attraction"]), + dense_key("mixed_current_attraction"): ( + meta["mixed_current_attraction"]), + dense_key("fixed_teacher_normalized_weight"): ( + meta[ + "fixed_teacher_attraction_normalized_weight"]), + dense_key("current_teacher_normalized_weight"): ( + meta[ + "current_teacher_attraction_normalized_weight"]), + dense_key("current_teacher_self_kernel"): ( + meta["current_teacher_self_kernel"]), + dense_key("current_teacher_exact_mmd"): ( + meta["current_teacher_exact_mmd"]), + dense_key("current_teacher_samples"): ( + meta["current_teacher_samples"]), + }) + if "attraction_ema_blend_current_coefficient" in meta: + log_dict.update({ + dense_key("attraction_ema_blend_coefficient"): ( + meta[ + "attraction_ema_blend_current_coefficient"]), + dense_key("attraction_gradient_coefficient"): ( + meta[ + "attraction_gradient_current_coefficient"]), + dense_key("effective_ema_loss"): ( + meta["effective_ema_loss"]), + }) + if "repulsion_fifo_enabled" in meta: + log_dict.update({ + dense_key("repulsion_fifo_capacity"): ( + meta["repulsion_fifo_capacity"]), + dense_key("repulsion_fifo_fill_before"): ( + meta["repulsion_fifo_fill_before"]), + dense_key("repulsion_fifo_fill_after_commit"): ( + meta["repulsion_fifo_fill_after_commit"]), + dense_key("repulsion_fifo_commits_before"): ( + meta["repulsion_fifo_commits"]), + dense_key("repulsion_current_offdiag_kernel_sum"): ( + meta["repulsion_current_offdiag_kernel_sum"]), + dense_key("repulsion_history_kernel_sum"): ( + meta["repulsion_history_kernel_sum"]), + dense_key("repulsion_denominator"): ( + meta["repulsion_denominator"]), + }) + if isinstance(multilayer_metadata, dict): + log_dict.update({ + "vjepa2_dense_multilayer_normalized_sum": result.loss, + "vjepa2_dense_multilayer_weighted_sum": ( + result.optimized_loss), + "vjepa2_dense_num_layers": meta["num_layers"], + "vjepa2_dense_sampled_tokens_per_layer": ( + result.loss.new_tensor( + self.model.vjepa2_dense_num_tokens, + dtype=torch.long, + ) + ), + "vjepa2_dense_gradcache_transport_tokens": ( + result.loss.new_tensor( + self.model.vjepa2_dense_num_tokens + * len(self.model.vjepa2_dense_layers), + dtype=torch.long, + ) + ), + "vjepa2_dense_self_normalized": ( + meta["self_normalized"]), + "vjepa2_dense_normalization_epsilon": ( + meta["normalization_epsilon"]), + "vjepa2_dense_layer_min": result.loss.new_tensor( + min(self.model.vjepa2_dense_layers), + dtype=torch.long, + ), + "vjepa2_dense_layer_max": result.loss.new_tensor( + max(self.model.vjepa2_dense_layers), + dtype=torch.long, + ), + }) + for layer in self.model.vjepa2_dense_layers: + layer_meta = multilayer_metadata[str(layer)] + prefix = f"vjepa2_dense_l{layer}" + log_dict.update({ + f"{prefix}_mmd": layer_meta["raw_mmd"], + f"{prefix}_normalized_mmd": ( + layer_meta["normalized_mmd"]), + f"{prefix}_weighted_mmd": ( + layer_meta["weighted_mmd"]), + f"{prefix}_repulsion": ( + layer_meta["repulsion"]), + f"{prefix}_nystrom_attraction": ( + layer_meta["attraction"]), + f"{prefix}_reference_constant": ( + layer_meta["reference_constant"]), + f"{prefix}_bandwidth": layer_meta["sigma"], + }) + if "current_attraction" in layer_meta: + log_dict.update({ + f"{prefix}_nystrom_current_attraction": ( + layer_meta["current_attraction"]), + f"{prefix}_attraction_ema_updates": ( + layer_meta["attraction_ema_updates"]), + f"{prefix}_attraction_current_coefficient": ( + layer_meta[ + "attraction_current_coefficient"]), + }) + elif not isinstance(meta.get("branches"), Mapping): + log_dict[dense_key("bandwidth")] = meta["sigma"] + log_dict.update(window_log) + return log_dict + except FloatingPointError as exc: + cache.discard() + if hasattr(self.model, "discard_distribution_state"): + self.model.discard_distribution_state() + if self.is_main_process: + print( + "[warn] discarding complete fresh-video update after " + f"non-finite value: {exc}", + flush=True, + ) + nonfinite = torch.tensor( + float("nan"), device=self.device, dtype=torch.float32) + return { + "generator_loss": nonfinite, + "generator_grad_norm": nonfinite, + "dmdtrain_gradient_norm": nonfinite, + dense_key("update_discarded"): nonfinite.new_ones(()), + } + except BaseException: + cache.discard() + if hasattr(self.model, "discard_distribution_state"): + self.model.discard_distribution_state() + raise + finally: + # Prepared-replay pixels are update-local. Never let a decoded + # batch leak into the next update after success or failure. + for entry in entries: + for physical_entry in entry.get("physical_entries", ()): + descriptor = physical_entry.get( + "replay_descriptor", {}) + if isinstance(descriptor, dict): + descriptor.pop("cached_student_video01", None) + + def fwdbwd_one_step(self, batch, train_generator): + # Frozen encoders stay in eval mode; generator gradients are enabled only on replay. + self.model.eval() # prevent any randomness (e.g. dropout) + + if self.step % 20 == 0: + torch.cuda.empty_cache() + + # Step 1: Get the next batch of text prompts + text_prompts = batch["prompts"] + if self.config.distribution_loss == "wan_kv_mmd": + teacher_trajectory = batch["ode_latent"] + local_shape_ok = teacher_trajectory.ndim == 6 + if not self._all_ranks_true(local_shape_ok): + raise ValueError( + "wan_kv_mmd teacher LMDB yielded an invalid shape on at " + "least one rank; expected " + "[B, steps, frames, channels, height, width]") + clean_latent = teacher_trajectory[:, -1].to( + device=self.device, dtype=self.dtype) + local_finite = bool(torch.isfinite(clean_latent).all().item()) + if not self._all_ranks_true(local_finite): + raise FloatingPointError( + "teacher latent contains non-finite values on at least one rank") + image_latent = None + elif self.config.i2v: + clean_latent = None + image_latent = batch["ode_latent"][:, -1][:, 0:1, ].to( + device=self.device, dtype=self.dtype) + else: + clean_latent = None + image_latent = None + + batch_size = len(text_prompts) + image_or_video_shape = list(self.config.image_or_video_shape) + image_or_video_shape[0] = batch_size + + # Step 2: Extract the conditional infos + with torch.no_grad(): + conditional_dict = self.model.text_encoder( + text_prompts=text_prompts) + + if not getattr(self, "unconditional_dict", None): + unconditional_dict = self.model.text_encoder( + text_prompts=[self.config.negative_prompt] * batch_size) + unconditional_dict = {k: v.detach() + for k, v in unconditional_dict.items()} + self.unconditional_dict = unconditional_dict # cache the unconditional_dict + else: + unconditional_dict = self.unconditional_dict + + # Step 3: Store gradients for the generator (if training the generator) + if train_generator: + generator_loss, generator_log_dict = self.model.generator_loss( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + clean_latent=clean_latent, + initial_latent=image_latent if self.config.i2v else None + ) + + generator_loss.backward() + generator_grad_norm = self.model.generator.clip_grad_norm_( + self.max_grad_norm_generator) + + generator_log_dict.update({"generator_loss": generator_loss, + "generator_grad_norm": generator_grad_norm}) + + return generator_log_dict + else: + generator_log_dict = {} + + # Step 4: Store gradients for the critic (if training the critic) + critic_loss, critic_log_dict = self.model.critic_loss( + image_or_video_shape=image_or_video_shape, + conditional_dict=conditional_dict, + unconditional_dict=unconditional_dict, + clean_latent=clean_latent, + initial_latent=image_latent if self.config.i2v else None + ) + + critic_loss.backward() + critic_grad_norm = self.model.fake_score.clip_grad_norm_( + self.max_grad_norm_critic) + + critic_log_dict.update({"critic_loss": critic_loss, + "critic_grad_norm": critic_grad_norm}) + + return critic_log_dict + + def generate_video(self, pipeline, prompts, image=None): + batch_size = len(prompts) + if image is not None: + image = image.squeeze(0).unsqueeze(0).unsqueeze(2).to(device="cuda", dtype=torch.bfloat16) + + # Encode the input image as the first latent + initial_latent = pipeline.vae.encode_to_latent(image).to(device="cuda", dtype=torch.bfloat16) + initial_latent = initial_latent.repeat(batch_size, 1, 1, 1, 1) + sampled_noise = torch.randn( + [batch_size, self.model.num_training_frames - 1, 16, 60, 104], + device="cuda", + dtype=self.dtype + ) + else: + initial_latent = None + sampled_noise = torch.randn( + [batch_size, self.model.num_training_frames, 16, 60, 104], + device="cuda", + dtype=self.dtype + ) + + video, _ = pipeline.inference( + noise=sampled_noise, + text_prompts=prompts, + return_latents=True, + initial_latent=initial_latent + ) + current_video = video.permute(0, 1, 3, 4, 2).cpu().numpy() * 255.0 + return current_video + + def _fresh_progress(self, phase, **fields): + if self.is_main_process and bool(getattr(self.config, "fresh_video_progress_logs", False)): + suffix = " ".join(f"{key}={value}" for key, value in fields.items()) + print(f"[fresh-progress] update={self.step + 1} phase={phase} {suffix}", flush=True) + + def train(self): + start_step = self.step + max_steps = int(getattr(self.config, "max_steps", 0)) + if max_steps > 0 and self.step >= max_steps: + if self.is_main_process: + print( + f"[done] checkpoint step {self.step} already reached " + f"max_steps={max_steps}; nothing to train.", + flush=True, + ) + return + + # baseline eval before any optimization (captures the init / resumed model) + if self.eval_interval > 0: + self._fd_eval_inference() + + while True: + current_learning_rate = self._set_generator_learning_rate(self.step) + TRAIN_GENERATOR = ( + (not self.uses_critic) + or self.step % self.config.dfake_gen_update_ratio == 0 + ) + update_committed = True + + # Train the generator + if TRAIN_GENERATOR: + self.generator_optimizer.zero_grad(set_to_none=True) + if self.uses_fresh_video_gradcache: + generator_log_dict = ( + self._run_distribution_matching_step()) + else: + extras_list = [] + batch = self._next_batch() + extra = self.fwdbwd_one_step(batch, True) + extras_list.append(extra) + generator_log_dict = merge_dict_list(extras_list) + # Skip the step if the generator gradient is non-finite (avoids + # corrupting weights from a stray bf16 overflow); the gradients are + # zeroed at the top of the next iteration. + _ggn = generator_log_dict.get("generator_grad_norm", None) + _local_ggn_ok = ( + (_ggn is None) + or bool(torch.isfinite( + torch.as_tensor(_ggn).float()).all().item()) + ) + _ggn_ok = self._all_ranks_true(_local_ggn_ok) + if _ggn_ok: + prepare_error = None + try: + if hasattr( + self.model, "prepare_distribution_state" + ): + self.model.prepare_distribution_state() + except Exception as exc: + prepare_error = exc + prepare_ok = self._all_ranks_true( + prepare_error is None) + if not prepare_ok: + if self.fresh_video_gradcache is not None: + self.fresh_video_gradcache.discard() + if hasattr(self.model, "discard_distribution_state"): + self.model.discard_distribution_state() + if prepare_error is not None: + raise RuntimeError( + "distribution transaction preparation failed " + "before optimizer.step") from prepare_error + raise RuntimeError( + "distribution transaction preparation failed on " + "another rank before optimizer.step") + try: + if self.optimizer_state_cpu_offload_during_backward: + move_adamw_moment_state_( + self.generator_optimizer, self.device) + torch.cuda.synchronize(self.device) + # Commit after every selected rollout contributed its gradient. + self.generator_optimizer.step() + if self.optimizer_state_cpu_offload_during_backward: + torch.cuda.synchronize(self.device) + self.optimizer_state_offload_report = ( + move_adamw_moment_state_( + self.generator_optimizer, "cpu")) + torch.cuda.empty_cache() + if hasattr(self.model, "commit_distribution_state"): + self.model.commit_distribution_state() + if self.fresh_video_gradcache is not None: + self.fresh_video_gradcache.commit() + except BaseException: + if self.fresh_video_gradcache is not None: + self.fresh_video_gradcache.discard() + if hasattr(self.model, "discard_distribution_state"): + self.model.discard_distribution_state() + raise + update_committed = True + if self.generator_ema is not None: + self.generator_ema.update(self.model.generator) + else: + update_committed = False + if self.fresh_video_gradcache is not None: + self.fresh_video_gradcache.discard() + if hasattr(self.model, "discard_distribution_state"): + self.model.discard_distribution_state() + if self.is_main_process: + print(f"[warn] step {self.step}: non-finite generator_grad_norm={_ggn}; skipping optimizer step", flush=True) + + # Train the critic + critic_log_dict = {} + if self.uses_critic: + self.critic_optimizer.zero_grad(set_to_none=True) + extras_list = [] + batch = self._next_batch() + extra = self.fwdbwd_one_step(batch, False) + extras_list.append(extra) + critic_log_dict = merge_dict_list(extras_list) + self.critic_optimizer.step() + + # Increment the step since we finished gradient update + self.step += 1 + if self.is_main_process and bool(getattr(self.config, "fresh_video_progress_logs", False)): + print(f"[update-committed] step={self.step} committed={int(update_committed)}", flush=True) + + # Create EMA params (if not already created) + ema_start_step = int(getattr(self.config, "ema_start_step", 0)) + ema_weight = float(getattr(self.config, "ema_weight", 0.0) or 0.0) + if (self.step >= ema_start_step) and \ + (self.generator_ema is None) and (ema_weight > 0): + self.generator_ema = EMA_FSDP( + self.model.generator, decay=ema_weight) + + # Save the model + checkpoint_due = self.step % self.config.log_iters == 0 + if (not self.config.no_save) and (self.step - start_step) > 0 and checkpoint_due: + if not update_committed: + raise RuntimeError("refusing to checkpoint an uncommitted optimizer update") + torch.cuda.empty_cache() + self.save() + torch.cuda.empty_cache() + + # Periodic eval inference (all ranks participate; rank 0 logs/saves). + # Remember whether this step was evaluated so max_steps does not + # write the same fixed-seed samples twice. + did_periodic_eval = ( + self.eval_interval > 0 + and (self.step - start_step) > 0 + and self.step % self.eval_interval == 0 + ) + if did_periodic_eval: + torch.cuda.empty_cache() + self._fd_eval_inference() + torch.cuda.empty_cache() + + # Logging + if self.is_main_process: + wandb_loss_dict = {} + if TRAIN_GENERATOR: + wandb_loss_dict.update( + { + "generator_loss": generator_log_dict["generator_loss"].mean().item(), + "generator_grad_norm": generator_log_dict["generator_grad_norm"].mean().item(), + "dmdtrain_gradient_norm": generator_log_dict["dmdtrain_gradient_norm"].mean().item() + } + ) + for key, value in generator_log_dict.items(): + if key in wandb_loss_dict or key in { + "generator_loss", + "generator_grad_norm", + "dmdtrain_gradient_norm", + }: + continue + if isinstance(value, torch.Tensor) and value.numel() == 1: + # Metadata includes integral counters (for example + # Wan-KV history_updates). ``Tensor.mean`` rejects + # integer dtypes; a one-element tensor needs no + # reduction. + wandb_loss_dict[key] = value.detach().item() + wandb_loss_dict["learning_rate"] = current_learning_rate + + if self.uses_critic: + wandb_loss_dict.update( + { + "critic_loss": critic_log_dict["critic_loss"].mean().item(), + "critic_grad_norm": critic_log_dict["critic_grad_norm"].mean().item() + } + ) + + if not self.disable_wandb: + wandb.log(wandb_loss_dict, step=self.step) + + if (self.step % getattr(self.config, "stdout_log_iters", 1)) == 0: + summary = " ".join( + f"{k}={v:.4g}" for k, v in wandb_loss_dict.items() + if isinstance(v, (int, float)) + ) + print(f"[step {self.step}] {summary}", flush=True) + + if self.step % self.config.gc_interval == 0: + if dist.get_rank() == 0: + logging.info("DistGarbageCollector: Running GC.") + gc.collect() + torch.cuda.empty_cache() + + if self.is_main_process: + current_time = time.time() + if self.previous_time is None: + self.previous_time = current_time + else: + if not self.disable_wandb: + wandb.log({"per iteration time": current_time - self.previous_time}, step=self.step) + self.previous_time = current_time + + # Stop only after logging the final step. This is used by smoke tests + # and sequential launchers, so suppressing its metrics would hide + # precisely the EMA transition they need to verify. + max_steps = int(getattr(self.config, "max_steps", 0)) + if max_steps > 0 and self.step >= max_steps: + if not self.config.no_save: + torch.cuda.empty_cache() + self.save() + torch.cuda.empty_cache() + if self.eval_interval > 0 and not did_periodic_eval: + self._fd_eval_inference() + if self.is_main_process: + print( + f"[done] reached max_steps={max_steps}; exiting.", + flush=True, + ) + break + + # Historical entry points remain aliases for external callers. + _prepare_fresh_video_microbatch = _collect_rollout_microbatch + _fresh_video_generator_update = _run_distribution_matching_step + +# Old import/class spelling remains valid for existing callers. +Trainer = DistributionMatchingTrainer diff --git a/14B/code/trainer/feature_gradient_replay.py b/14B/code/trainer/feature_gradient_replay.py new file mode 100644 index 0000000000000000000000000000000000000000..623e84cecafcb3028d27d649da2e6dd4f0a996a9 --- /dev/null +++ b/14B/code/trainer/feature_gradient_replay.py @@ -0,0 +1,498 @@ +"""Separate distribution estimation from generator backpropagation (Sec. 3.3). + +The distribution batch B supplies all cross-video interactions. Only the smaller +configured differentiation batch b needs feature gradients and rollout replay. +This module caches features and returns their cotangents (dL/d feature); the +trainer later replays the encoders, VAE, and generator to obtain dL/d parameters. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +import torch +import torch.distributed as dist + +from losses.multi_encoder_mmd import ( + MultiEncoderMMD, +) + + +def _detach(value: Any) -> Any: + if isinstance(value, torch.Tensor): + return value.detach() + if isinstance(value, Mapping): + return {key: _detach(item) for key, item in value.items()} + if isinstance(value, tuple): + return tuple(_detach(item) for item in value) + if isinstance(value, list): + return [_detach(item) for item in value] + return value + + +def _detach_to_cpu(value: Any) -> Any: + if isinstance(value, torch.Tensor): + return value.detach().to(device="cpu", non_blocking=False) + if isinstance(value, Mapping): + return {key: _detach_to_cpu(item) for key, item in value.items()} + if isinstance(value, tuple): + return tuple(_detach_to_cpu(item) for item in value) + if isinstance(value, list): + return [_detach_to_cpu(item) for item in value] + return value + + +@dataclass(frozen=True) +class FeatureGradientReplayResult: + """Detached loss, per-microbatch feature gradients, and replay bookkeeping. + + ``local_cotangents`` are the derivatives of the global distribution loss. + ``fsdp_cotangents`` additionally compensate for FSDP's gradient averaging. + Field names stay compatible with existing training logs and checkpoints. + """ + + loss: torch.Tensor + optimized_loss: torch.Tensor + metadata: Mapping[str, Any] + local_cotangents: tuple[Mapping[str, torch.Tensor], ...] + fsdp_cotangents: tuple[Mapping[str, torch.Tensor], ...] + replay_batch_indices: tuple[int, ...] + replay_sample_ids: tuple[tuple[str, ...], ...] + local_samples: int + global_samples: int + gradient_local_samples: int + gradient_global_samples: int + current_teacher_local_samples: int + current_teacher_global_samples: int + world_size: int + + +class FeatureGradientReplay: + """Cache B generated feature sets and differentiate the selected b samples. + + B and b denote global sample counts; constructor counts are per rank. Each + encoder keeps its own [tokens, channels] layout while sharing sample order. + This implementation selects a per-rank suffix. All B samples still define + the distribution loss, including interactions with unselected samples. + """ + + def __init__( + self, + loss_module: MultiEncoderMMD, + *, + target_local_samples: int, + gradient_local_samples: int, + loss_scale: float = 1.0, + require_sample_id_alignment: bool = True, + full_student_partial_gradient: bool = True, + ) -> None: + if not isinstance(loss_module, MultiEncoderMMD): + raise TypeError( + "loss_module must be MultiRepresentationVideoNystromMMD") + if not 0 < int(gradient_local_samples) < int(target_local_samples): + raise ValueError( + "dual GradCache requires a strict positive gradient suffix") + if not full_student_partial_gradient: + raise ValueError( + "dual GradCache requires full_student_partial_gradient=true") + self.loss_module = loss_module + self.target_local_samples = int(target_local_samples) + self.gradient_local_samples = int(gradient_local_samples) + self.loss_scale = float(loss_scale) + self.require_sample_id_alignment = bool(require_sample_id_alignment) + self.full_student_partial_gradient = True + self.partial_gradient_enabled = True + self.current_teacher_attraction_enabled = bool( + loss_module.current_teacher_attraction_enabled) + self._feature_batches: list[dict[str, torch.Tensor]] = [] + self._teacher_batches: list[dict[str, torch.Tensor]] = [] + self._batch_sizes: list[int] = [] + self._gradient_batch_indices: list[int] = [] + self._gradient_batch_sizes: list[int] = [] + self._gradient_sample_ids: list[tuple[str, ...]] = [] + self._selection_started = False + self._device: torch.device | None = None + self._result: FeatureGradientReplayResult | None = None + + @staticmethod + def _world_size() -> int: + return dist.get_world_size() if dist.is_initialized() else 1 + + @staticmethod + def _rank() -> int: + return dist.get_rank() if dist.is_initialized() else 0 + + @property + def local_samples(self) -> int: + return sum(self._batch_sizes) + + @property + def selected_local_samples(self) -> int: + return sum(self._gradient_batch_sizes) + + @property + def finalized(self) -> bool: + return self._result is not None + + def _validate_mapping( + self, + value: Mapping[str, torch.Tensor], + label: str, + *, + expected_batch: int | None = None, + ) -> tuple[int, torch.device]: + if not isinstance(value, Mapping): + raise TypeError(f"{label} must be a branch tensor mapping") + if set(value) != set(self.loss_module.branch_names): + raise ValueError( + f"{label} keys must be {self.loss_module.branch_names}") + batch_size = None + device = None + for name in self.loss_module.branch_names: + tensor = value[name] + if ( + not isinstance(tensor, torch.Tensor) + or tensor.ndim != 3 + or not tensor.is_floating_point() + or min(tensor.shape) <= 0 + ): + raise ValueError(f"{label}[{name!r}] must be floating [B,N,D]") + if tuple(tensor.shape[1:]) != self.loss_module.sample_shapes[name]: + raise ValueError( + f"{label}[{name!r}] geometry {tuple(tensor.shape[1:])} " + f"!= {self.loss_module.sample_shapes[name]}") + if not bool(torch.isfinite(tensor).all().item()): + raise FloatingPointError(f"{label}[{name!r}] is non-finite") + if batch_size is None: + batch_size = int(tensor.shape[0]) + device = tensor.device + elif int(tensor.shape[0]) != batch_size or tensor.device != device: + raise ValueError( + f"all {label} branches must share batch size and device") + if expected_batch is not None and batch_size != expected_batch: + raise ValueError( + f"{label} batch size {batch_size} != {expected_batch}") + return int(batch_size), device + + def add( + self, + generated_features: Mapping[str, torch.Tensor], + current_teacher_features: Mapping[str, torch.Tensor] | None = None, + *, + gradient_selected: bool = True, + sample_ids: Sequence[str] | None = None, + current_teacher_sample_ids: Sequence[str] | None = None, + ) -> int: + """Store one rollout's features without retaining its generator graph.""" + if self.finalized: + raise RuntimeError("cannot add after finalize") + batch_size, device = self._validate_mapping( + generated_features, "generated_features") + if self._device is None: + self._device = device + elif device != self._device: + raise ValueError("all microbatches must share a device") + if self.local_samples + batch_size > self.target_local_samples: + raise ValueError("dual GradCache exceeds target_local_samples") + if gradient_selected: + self._selection_started = True + if self.selected_local_samples + batch_size > self.gradient_local_samples: + raise ValueError("dual GradCache exceeds gradient suffix") + elif self._selection_started: + raise ValueError("gradient-selected microbatches must form a suffix") + + normalized_ids = None + if sample_ids is not None: + normalized_ids = tuple(str(value) for value in sample_ids) + if len(normalized_ids) != batch_size or any(not value for value in normalized_ids): + raise ValueError("sample_ids must contain one non-empty ID per row") + if ( + not self.current_teacher_attraction_enabled + and current_teacher_sample_ids is not None + ): + raise ValueError( + "current_teacher_sample_ids were provided while current-teacher " + "attraction is disabled") + if ( + gradient_selected + and self.require_sample_id_alignment + and self.current_teacher_attraction_enabled + ): + if normalized_ids is None: + raise ValueError("selected dual samples require sample_ids") + teacher_ids = ( + tuple(str(value) for value in current_teacher_sample_ids) + if current_teacher_sample_ids is not None else None + ) + if teacher_ids != normalized_ids: + raise ValueError("student and teacher sample IDs/order differ") + + if gradient_selected and self.current_teacher_attraction_enabled: + if current_teacher_features is None: + raise ValueError("selected dual samples require teacher features") + self._validate_mapping( + current_teacher_features, + "current_teacher_features", + expected_batch=batch_size, + ) + elif current_teacher_features is not None: + raise ValueError( + "current teacher features were provided when this microbatch " + "must not carry teacher payloads") + + # Collection stage (Algorithm 1): keep only feature values and sample + # order. Replay state is stored by the trainer, outside this cache. + staged = { + name: generated_features[name].detach().float().clone() + for name in self.loss_module.branch_names + } + self._feature_batches.append(staged) + index = len(self._feature_batches) - 1 + if gradient_selected: + self._gradient_batch_indices.append(index) + self._gradient_batch_sizes.append(batch_size) + self._gradient_sample_ids.append(normalized_ids) + if self.current_teacher_attraction_enabled: + self._teacher_batches.append({ + name: current_teacher_features[name].detach().float().clone() + for name in self.loss_module.branch_names + }) + self._batch_sizes.append(batch_size) + return index + + def _gather(self, value: torch.Tensor) -> list[torch.Tensor]: + if self._world_size() == 1: + return [value.detach()] + gathered = [torch.empty_like(value) for _ in range(self._world_size())] + dist.all_gather(gathered, value.detach().contiguous()) + return gathered + + def _validate_ready(self) -> None: + local = torch.tensor( + [self.local_samples, self.selected_local_samples], + device=self._device, + dtype=torch.long, + ) + expected = (self.target_local_samples, self.gradient_local_samples) + if self._world_size() == 1: + observed = tuple(int(value) for value in local.tolist()) + if observed != expected: + raise RuntimeError( + f"incomplete dual GradCache: {observed} != {expected}") + return + gathered = [torch.empty_like(local) for _ in range(self._world_size())] + dist.all_gather(gathered, local) + observed = [tuple(int(value) for value in item.tolist()) for item in gathered] + if any(item != expected for item in observed): + raise RuntimeError( + f"incomplete distributed dual GradCache: {observed} != {expected}") + + def finalize(self) -> FeatureGradientReplayResult: + """Evaluate the global objective and cache dL/d feature for replay.""" + if self._result is not None: + return self._result + self._validate_ready() + world_size = self._world_size() + global_distribution_features = {} + global_differentiation_features = {} + global_reference_features = ( + {} if self.current_teacher_attraction_enabled else None) + local_leaves = {} + # Map each rank's selected suffix into the concatenated B-sample batch. + # These indices preserve full-batch interactions while only b rows have + # an autograd path. This function does not randomly resample the subset. + suffix_start = self.target_local_samples - self.gradient_local_samples + gradient_indices = torch.cat([ + torch.arange( + rank * self.target_local_samples + suffix_start, + (rank + 1) * self.target_local_samples, + device=self._device, + dtype=torch.long, + ) + for rank in range(world_size) + ]) + for name in self.loss_module.branch_names: + local_distribution_features = torch.cat([ + batch[name] for batch in self._feature_batches + ], dim=0) + local_differentiation_features = torch.cat([ + self._feature_batches[index][name] + for index in self._gradient_batch_indices + ], dim=0) + distribution_parts = self._gather(local_distribution_features) + differentiation_parts = self._gather(local_differentiation_features) + # Only this rank's selected features are differentiable leaves. + # Remote features contribute values; their owning rank gets their + # derivatives. No generator or encoder graph is retained here. + leaf = local_differentiation_features.detach().requires_grad_(True) + differentiation_parts[self._rank()] = leaf + local_leaves[name] = leaf + global_distribution_features[name] = torch.cat(distribution_parts, dim=0).detach() + global_differentiation_features[name] = torch.cat(differentiation_parts, dim=0) + if self.current_teacher_attraction_enabled: + local_reference_features = torch.cat([ + batch[name] for batch in self._teacher_batches + ], dim=0) + reference_parts = self._gather(local_reference_features) + global_reference_features[name] = torch.cat( + reference_parts, dim=0).detach() + + # Resolve all cross-video interactions once. The loss combines the + # persistent Nystrom summary with current reference features (Eq. 9), + # then aggregates the encoder objectives (Eqs. 11 and 13). + raw_loss, metadata = self.loss_module( + gradient_students=global_differentiation_features, + student_reference=global_distribution_features, + current_teacher=global_reference_features, + gradient_student_indices=gradient_indices, + ) + optimized = raw_loss * self.loss_scale + gradients = torch.autograd.grad( + optimized, + tuple(local_leaves[name] for name in self.loss_module.branch_names), + retain_graph=False, + create_graph=False, + ) + # Restore the collection order so each feature gradient is replayed + # through the exact sample and encoder that produced its feature value. + split_by_branch = { + name: tuple(part.contiguous() for part in gradient.detach().split( + self._gradient_batch_sizes, dim=0)) + for name, gradient in zip(self.loss_module.branch_names, gradients) + } + local_parts = tuple({ + name: split_by_branch[name][index] + for name in self.loss_module.branch_names + } for index in range(len(self._gradient_batch_sizes))) + # FSDP averages parameter gradients across ranks, so cancel that factor + # here. This world_size multiplier is separate from the B/b selective + # differentiation correction (Eq. 12), applied later by the trainer. + fsdp_parts = tuple({ + name: (value * float(world_size)).contiguous() + for name, value in part.items() + } for part in local_parts) + global_samples = self.target_local_samples * world_size + gradient_global = self.gradient_local_samples * world_size + kss_denominator = global_samples * global_samples + # Keep established metadata names for checkpoint/log compatibility. + detached_metadata = dict(_detach(metadata)) + teacher_local = ( + self.gradient_local_samples + if self.current_teacher_attraction_enabled else 0) + teacher_global = teacher_local * world_size + detached_metadata.update({ + "raw_loss": raw_loss.detach(), + "optimized_loss": optimized.detach(), + "loss_scale": raw_loss.new_tensor(self.loss_scale), + "gradcache_local_samples": raw_loss.new_tensor( + self.target_local_samples, dtype=torch.long), + "gradcache_global_samples": raw_loss.new_tensor( + global_samples, dtype=torch.long), + "gradcache_forward_local_samples": raw_loss.new_tensor( + self.target_local_samples, dtype=torch.long), + "gradcache_forward_global_samples": raw_loss.new_tensor( + global_samples, dtype=torch.long), + "gradcache_gradient_local_samples": raw_loss.new_tensor( + self.gradient_local_samples, dtype=torch.long), + "gradcache_gradient_global_samples": raw_loss.new_tensor( + gradient_global, dtype=torch.long), + "gradcache_current_teacher_local_samples": raw_loss.new_tensor( + teacher_local, dtype=torch.long), + "gradcache_current_teacher_global_samples": raw_loss.new_tensor( + teacher_global, dtype=torch.long), + "gradcache_kss_denominator": raw_loss.new_tensor( + kss_denominator, dtype=torch.long), + "gradcache_partial_gradient_enabled": raw_loss.new_ones( + (), dtype=torch.long), + "gradcache_full_student_partial_gradient": raw_loss.new_ones( + (), dtype=torch.long), + "gradcache_world_size": raw_loss.new_tensor( + world_size, dtype=torch.long), + "gradcache_fsdp_cotangent_scale": raw_loss.new_tensor( + float(world_size)), + "gradcache_branch_names": self.loss_module.branch_names, + }) + self._result = FeatureGradientReplayResult( + loss=raw_loss.detach(), + optimized_loss=optimized.detach(), + metadata=detached_metadata, + local_cotangents=local_parts, + fsdp_cotangents=fsdp_parts, + replay_batch_indices=tuple(self._gradient_batch_indices), + replay_sample_ids=tuple(self._gradient_sample_ids), + local_samples=self.target_local_samples, + global_samples=global_samples, + gradient_local_samples=self.gradient_local_samples, + gradient_global_samples=gradient_global, + current_teacher_local_samples=teacher_local, + current_teacher_global_samples=teacher_global, + world_size=world_size, + ) + return self._result + + def offload_finalized_to_cpu( + self, + ) -> FeatureGradientReplayResult: + """Move finalized feature/cotangent storage to CPU transactionally.""" + if self._result is None: + raise RuntimeError( + "cannot offload a multi-representation cache before finalize") + self._feature_batches = [ + _detach_to_cpu(value) for value in self._feature_batches + ] + self._teacher_batches = [ + _detach_to_cpu(value) for value in self._teacher_batches + ] + result = self._result + self._result = FeatureGradientReplayResult( + loss=_detach_to_cpu(result.loss), + optimized_loss=_detach_to_cpu(result.optimized_loss), + metadata=_detach_to_cpu(result.metadata), + local_cotangents=_detach_to_cpu(result.local_cotangents), + fsdp_cotangents=_detach_to_cpu(result.fsdp_cotangents), + replay_batch_indices=result.replay_batch_indices, + replay_sample_ids=result.replay_sample_ids, + local_samples=result.local_samples, + global_samples=result.global_samples, + gradient_local_samples=result.gradient_local_samples, + gradient_global_samples=result.gradient_global_samples, + current_teacher_local_samples=result.current_teacher_local_samples, + current_teacher_global_samples=result.current_teacher_global_samples, + world_size=result.world_size, + ) + return self._result + + + def _clear(self) -> None: + self._feature_batches.clear() + self._teacher_batches.clear() + self._batch_sizes.clear() + self._gradient_batch_indices.clear() + self._gradient_batch_sizes.clear() + self._gradient_sample_ids.clear() + self._selection_started = False + self._device = None + self._result = None + + def commit(self) -> None: + """Release cached values after the corresponding optimizer update.""" + if self._result is None: + raise RuntimeError("cannot commit before finalize") + self._clear() + + def discard(self) -> None: + """Release an abandoned batch without marking an optimizer update.""" + self._clear() + + +# Preserve old imports and serialized class references during the naming cleanup. +MultiRepresentationFreshVideoGradCache = FeatureGradientReplay +MultiRepresentationFreshVideoGradCacheResult = FeatureGradientReplayResult + +__all__ = [ + "FeatureGradientReplay", + "FeatureGradientReplayResult", + "MultiRepresentationFreshVideoGradCache", + "MultiRepresentationFreshVideoGradCacheResult", +] diff --git a/14B/code/trainer/fresh_video_gradcache.py b/14B/code/trainer/fresh_video_gradcache.py new file mode 100644 index 0000000000000000000000000000000000000000..70f8b666cee360263ec8a8923e87fae97ccf0a74 --- /dev/null +++ b/14B/code/trainer/fresh_video_gradcache.py @@ -0,0 +1,753 @@ +"""Fresh-update gradient cache for video-level distribution losses. + +The cache deliberately has no state across optimizer updates. It collects a +fixed number of detached ``[B, N, D]`` feature matrices on every rank, builds +the exact global fresh batch in deterministic rank order, and differentiates +the frozen reference loss only with respect to the local rank's feature leaf. + +The two returned cotangents have distinct meanings: + +``local_cotangents`` + The mathematical derivatives ``d L_global / d feature_local``. + +``fsdp_cotangents`` + ``world_size * local_cotangents``. These are the cotangents that replay + should use when FSDP/DDP will average parameter gradients across ranks: + + ``mean_r(world_size * J_r.T @ dL/dF_r) = sum_r J_r.T @ dL/dF_r``. + +Collection is transactional. ``finalize`` stages a result but retains the +fresh features until the caller reports optimizer success with ``commit`` or +failure with ``discard``. Both terminal operations clear all update-local +state, so no generated sample can leak into the next optimizer update. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Any, Mapping, Sequence + +import torch +import torch.distributed as dist + +from losses.video_nystrom_mmd import VideoNystromMMD +from losses.multilayer_video_nystrom_mmd import ( + MultiLayerVideoNystromMMD, +) + + +def _detach_metadata(value: Any) -> Any: + if isinstance(value, torch.Tensor): + return value.detach() + if isinstance(value, Mapping): + return { + key: _detach_metadata(item) + for key, item in value.items() + } + if isinstance(value, tuple): + return tuple(_detach_metadata(item) for item in value) + if isinstance(value, list): + return [_detach_metadata(item) for item in value] + return value + +def _detach_to_cpu(value: Any) -> Any: + """Recursively detach transactional result tensors onto host memory.""" + if isinstance(value, torch.Tensor): + return value.detach().to(device="cpu", non_blocking=False).contiguous() + if isinstance(value, Mapping): + return {key: _detach_to_cpu(item) for key, item in value.items()} + if isinstance(value, tuple): + return tuple(_detach_to_cpu(item) for item in value) + if isinstance(value, list): + return [_detach_to_cpu(item) for item in value] + return value + + +@dataclass(frozen=True) +class FreshVideoGradCacheResult: + """Finalized loss and replay cotangents for one fresh optimizer update.""" + + loss: torch.Tensor + optimized_loss: torch.Tensor + metadata: Mapping[str, Any] + local_cotangents: tuple[torch.Tensor, ...] + fsdp_cotangents: tuple[torch.Tensor, ...] + replay_batch_indices: tuple[int, ...] + replay_sample_ids: tuple[tuple[str, ...], ...] + local_samples: int + global_samples: int + gradient_local_samples: int + gradient_global_samples: int + current_teacher_local_samples: int + current_teacher_global_samples: int + world_size: int + + +class FreshVideoGradCache: + """Collect and differentiate one globally fresh video feature batch.""" + + def __init__( + self, + loss_module: VideoNystromMMD | MultiLayerVideoNystromMMD, + *, + target_local_samples: int = 16, + gradient_local_samples: int | None = None, + loss_scale: float = 1.0, + require_sample_id_alignment: bool = False, + full_student_partial_gradient: bool = False, + ) -> None: + if not isinstance( + loss_module, + (VideoNystromMMD, MultiLayerVideoNystromMMD), + ): + raise TypeError( + "loss_module must be a VideoNystromMMD or " + "MultiLayerVideoNystromMMD instance") + if int(target_local_samples) <= 0: + raise ValueError("target_local_samples must be positive") + if gradient_local_samples is None: + gradient_local_samples = int(target_local_samples) + if not 0 < int(gradient_local_samples) <= int(target_local_samples): + raise ValueError( + "gradient_local_samples must be in [1, target_local_samples]") + if ( + not math.isfinite(float(loss_scale)) + or float(loss_scale) < 0.0 + ): + raise ValueError("loss_scale must be finite and non-negative") + if any(parameter.requires_grad for parameter in loss_module.parameters()): + raise ValueError( + "VideoNystromMMD reference parameters must be frozen") + + self.loss_module = loss_module + self.current_teacher_attraction_enabled = bool(getattr( + loss_module, "current_teacher_attraction_enabled", False)) + self.target_local_samples = int(target_local_samples) + self.gradient_local_samples = int(gradient_local_samples) + self.partial_gradient_enabled = bool( + self.gradient_local_samples < self.target_local_samples) + self.full_student_partial_gradient = bool( + full_student_partial_gradient) + if ( + self.full_student_partial_gradient + and not self.partial_gradient_enabled + ): + raise ValueError( + "full_student_partial_gradient requires " + "gradient_local_samples < target_local_samples") + if ( + self.partial_gradient_enabled + and isinstance(loss_module, MultiLayerVideoNystromMMD) + ): + raise TypeError( + "partial-gradient GradCache currently requires a single " + "VideoNystromMMD layer") + self.loss_scale = float(loss_scale) + self.require_sample_id_alignment = bool(require_sample_id_alignment) + self._feature_batches: list[torch.Tensor] = [] + self._teacher_feature_batches: list[torch.Tensor] = [] + self._batch_sizes: list[int] = [] + self._gradient_batch_indices: list[int] = [] + self._gradient_batch_sizes: list[int] = [] + self._gradient_sample_ids: list[tuple[str, ...]] = [] + self._selection_started = False + self._sample_shape: tuple[int, int] | None = None + self._device: torch.device | None = None + self._result: FreshVideoGradCacheResult | None = None + + @staticmethod + def _world_size() -> int: + if dist.is_available() and dist.is_initialized(): + return dist.get_world_size() + return 1 + + @staticmethod + def _rank() -> int: + if dist.is_available() and dist.is_initialized(): + return dist.get_rank() + return 0 + + @staticmethod + def _all_ranks_true(local_value: bool, device: torch.device) -> bool: + flag = torch.tensor( + int(bool(local_value)), device=device, dtype=torch.int32) + if ( + dist.is_available() + and dist.is_initialized() + and dist.get_world_size() > 1 + ): + dist.all_reduce(flag, op=dist.ReduceOp.MIN) + return bool(flag.item()) + + @property + def local_samples(self) -> int: + return sum(self._batch_sizes) + + @property + def num_microbatches(self) -> int: + return len(self._feature_batches) + + @property + def selected_local_samples(self) -> int: + return sum(self._gradient_batch_sizes) + + @property + def ready(self) -> bool: + return ( + self.local_samples == self.target_local_samples + and self.selected_local_samples == self.gradient_local_samples + ) + + @property + def finalized(self) -> bool: + return self._result is not None + + def add( + self, + generated_features: torch.Tensor, + current_teacher_features: torch.Tensor | None = None, + *, + gradient_selected: bool | None = None, + sample_ids: Sequence[str] | None = None, + current_teacher_sample_ids: Sequence[str] | None = None, + ) -> int: + """Add one detached microbatch and return its zero-based cache index.""" + if self.finalized: + raise RuntimeError( + "cannot add features after finalize; commit or discard first") + if not isinstance(generated_features, torch.Tensor): + raise TypeError("generated_features must be a torch.Tensor") + if ( + generated_features.ndim != 3 + or min(generated_features.shape) <= 0 + or not generated_features.is_floating_point() + ): + raise ValueError( + "generated_features must be a non-empty floating [B, N, D] " + "tensor") + if not bool(torch.isfinite(generated_features).all().item()): + raise FloatingPointError( + "generated_features contains non-finite values") + + if gradient_selected is None: + if self.partial_gradient_enabled: + raise TypeError( + "gradient_selected must be explicit when partial-gradient " + "GradCache is enabled") + gradient_selected = True + gradient_selected = bool(gradient_selected) + if self._selection_started and not gradient_selected: + raise ValueError( + "gradient-selected microbatches must form a contiguous suffix") + if gradient_selected: + self._selection_started = True + + batch_size = int(generated_features.shape[0]) + normalized_sample_ids: tuple[str, ...] | None = None + normalized_teacher_ids: tuple[str, ...] | None = None + if sample_ids is not None: + normalized_sample_ids = tuple(str(value) for value in sample_ids) + if len(normalized_sample_ids) != batch_size or any( + not value for value in normalized_sample_ids + ): + raise ValueError( + "sample_ids must contain one non-empty ID per student") + if current_teacher_sample_ids is not None: + normalized_teacher_ids = tuple( + str(value) for value in current_teacher_sample_ids) + if len(normalized_teacher_ids) != batch_size or any( + not value for value in normalized_teacher_ids + ): + raise ValueError( + "current_teacher_sample_ids must contain one non-empty ID " + "per selected student") + if self.require_sample_id_alignment and normalized_sample_ids is None: + raise ValueError( + "sample_ids are required by this GradCache experiment") + + if self.current_teacher_attraction_enabled and gradient_selected: + if not isinstance(current_teacher_features, torch.Tensor): + raise TypeError( + "current_teacher_features must be a torch.Tensor for each " + "selected student microbatch") + if ( + current_teacher_features.ndim != 3 + or min(current_teacher_features.shape) <= 0 + or not current_teacher_features.is_floating_point() + ): + raise ValueError( + "current_teacher_features must be a non-empty floating " + "[B, N, D] tensor") + if tuple(current_teacher_features.shape) != tuple( + generated_features.shape + ): + raise ValueError( + "current teacher features must exactly match selected " + "student [B,N,D]: " + f"{tuple(current_teacher_features.shape)} != " + f"{tuple(generated_features.shape)}") + if current_teacher_features.device != generated_features.device: + raise ValueError( + "generated and current teacher features must share a device") + if not bool(torch.isfinite( + current_teacher_features.detach() + ).all().item()): + raise FloatingPointError( + "current_teacher_features contains non-finite values") + if self.require_sample_id_alignment and normalized_teacher_ids is None: + raise ValueError( + "current_teacher_sample_ids are required for selected " + "students") + if ( + normalized_teacher_ids is not None + and normalized_sample_ids != normalized_teacher_ids + ): + raise ValueError( + "matched teacher sample IDs must exactly equal selected " + "student sample IDs in order") + elif current_teacher_features is not None: + raise ValueError( + "current_teacher_features may only be provided for a selected " + "microbatch while exact current-teacher attraction is enabled") + elif current_teacher_sample_ids is not None: + raise ValueError( + "current_teacher_sample_ids may only be provided for a " + "selected current-teacher microbatch") + + if self.local_samples + batch_size > self.target_local_samples: + raise ValueError( + "fresh feature cache would exceed target_local_samples: " + f"{self.local_samples} + {batch_size} > " + f"{self.target_local_samples}") + if ( + gradient_selected + and self.selected_local_samples + batch_size + > self.gradient_local_samples + ): + raise ValueError( + "fresh feature cache would exceed gradient_local_samples: " + f"{self.selected_local_samples} + {batch_size} > " + f"{self.gradient_local_samples}") + + sample_shape = ( + int(generated_features.shape[1]), + int(generated_features.shape[2]), + ) + if self._sample_shape is None: + self._sample_shape = sample_shape + self._device = generated_features.device + elif ( + sample_shape != self._sample_shape + or generated_features.device != self._device + ): + raise ValueError( + "all fresh feature batches on a rank must share [N, D] and " + "device; expected " + f"{self._sample_shape} on {self._device}, got " + f"{sample_shape} on {generated_features.device}") + + staged = generated_features.detach().to(dtype=torch.float32).clone() + self._feature_batches.append(staged) + batch_index = len(self._feature_batches) - 1 + if gradient_selected: + self._gradient_batch_indices.append(batch_index) + self._gradient_batch_sizes.append(batch_size) + self._gradient_sample_ids.append( + normalized_sample_ids or tuple("" for _ in range(batch_size))) + if self.current_teacher_attraction_enabled and gradient_selected: + staged_teacher = current_teacher_features.detach().to( + dtype=torch.float32).clone() + self._teacher_feature_batches.append(staged_teacher) + self._batch_sizes.append(batch_size) + return batch_index + + def _validate_distributed_geometry( + self, + local_features: torch.Tensor, + *, + expected_samples: int, + label: str, + ) -> None: + if self._world_size() == 1: + if int(local_features.shape[0]) != int(expected_samples): + raise ValueError( + f"{label} expected {expected_samples} local samples, got " + f"{int(local_features.shape[0])}") + return + signature = torch.tensor( + [ + int(local_features.shape[0]), + int(local_features.shape[1]), + int(local_features.shape[2]), + ], + device=local_features.device, + dtype=torch.long, + ) + signatures = [ + torch.empty_like(signature) for _ in range(self._world_size()) + ] + dist.all_gather(signatures, signature) + expected = ( + int(expected_samples), + int(local_features.shape[1]), + int(local_features.shape[2]), + ) + observed = [ + tuple(int(value) for value in item.tolist()) + for item in signatures + ] + if any(item != expected for item in observed): + raise ValueError( + f"{label} requires {expected_samples} samples and identical " + f"[N,D] on every rank; expected {expected}, got {observed}") + + def _validate_ready_consensus(self) -> None: + """Make incomplete full or gradient suffix counts an all-rank error.""" + expected = ( + self.target_local_samples, + self.gradient_local_samples, + ) + if self._world_size() == 1: + observed = (self.local_samples, self.selected_local_samples) + if observed != expected: + raise RuntimeError( + "cannot finalize an incomplete fresh feature cache; " + f"expected forward/gradient={expected}, got {observed}") + return + collective_device = ( + self._device + if self._device is not None + else next(self.loss_module.buffers()).device + ) + local_counts = torch.tensor( + [self.local_samples, self.selected_local_samples], + device=collective_device, + dtype=torch.long, + ) + counts = [ + torch.empty_like(local_counts) for _ in range(self._world_size()) + ] + dist.all_gather(counts, local_counts) + observed = [ + tuple(int(value) for value in item.tolist()) for item in counts + ] + if any(value != expected for value in observed): + observed_forward = [value[0] for value in observed] + observed_gradient = [value[1] for value in observed] + raise RuntimeError( + "cannot finalize incomplete distributed fresh feature " + f"caches; expected {self.target_local_samples} samples per " + f"rank, got {observed_forward}; expected gradient " + f"{self.gradient_local_samples}, got {observed_gradient}") + + def _gather_detached( + self, + local_features: torch.Tensor, + ) -> list[torch.Tensor]: + if self._world_size() == 1: + return [local_features.detach()] + gathered = [ + torch.empty_like(local_features) + for _ in range(self._world_size()) + ] + dist.all_gather(gathered, local_features.detach().contiguous()) + return gathered + + def finalize(self) -> FreshVideoGradCacheResult: + """Compute the global loss and stage suffix-only replay cotangents.""" + if self._result is not None: + return self._result + self._validate_ready_consensus() + + local_reference = torch.cat(self._feature_batches, dim=0) + self._validate_distributed_geometry( + local_reference, + expected_samples=self.target_local_samples, + label="student reference", + ) + gathered_reference = self._gather_detached(local_reference) + + if self.partial_gradient_enabled: + selected_batches = [ + self._feature_batches[index] + for index in self._gradient_batch_indices + ] + local_gradient = torch.cat(selected_batches, dim=0) + self._validate_distributed_geometry( + local_gradient, + expected_samples=self.gradient_local_samples, + label="gradient students", + ) + gathered_gradient = self._gather_detached(local_gradient) + local_leaf = local_gradient.detach().requires_grad_(True) + gathered_gradient[self._rank()] = local_leaf + global_gradient = torch.cat(gathered_gradient, dim=0) + global_reference = torch.cat( + gathered_reference, dim=0).detach() + gradient_student_indices = None + if self.full_student_partial_gradient: + # ``global_gradient`` is rank-major, with each rank + # contributing its contiguous selected suffix. Build the + # matching positions in the rank-major full student batch so + # the objective can inject every selected value back into its + # original location without reordering either population. + suffix_start = ( + self.target_local_samples + - self.gradient_local_samples + ) + gradient_student_indices = torch.cat([ + torch.arange( + rank * self.target_local_samples + suffix_start, + (rank + 1) * self.target_local_samples, + device=global_reference.device, + dtype=torch.long, + ) + for rank in range(self._world_size()) + ]) + else: + local_leaf = local_reference.detach().requires_grad_(True) + gathered_reference[self._rank()] = local_leaf + global_gradient = torch.cat(gathered_reference, dim=0) + global_reference = None + + global_teacher = None + if self.current_teacher_attraction_enabled: + if len(self._teacher_feature_batches) != len( + self._gradient_batch_indices + ): + raise RuntimeError( + "matched teacher and selected student GradCache batch " + "counts differ") + local_teacher = torch.cat( + self._teacher_feature_batches, dim=0) + if tuple(local_teacher.shape) != tuple( + local_gradient.shape + if self.partial_gradient_enabled else local_reference.shape + ): + raise RuntimeError( + "matched teacher and gradient student shapes differ") + self._validate_distributed_geometry( + local_teacher, + expected_samples=self.gradient_local_samples, + label="current teacher", + ) + gathered_teacher = self._gather_detached(local_teacher) + global_teacher = torch.cat( + gathered_teacher, dim=0).detach().to(dtype=torch.float32) + + if self.partial_gradient_enabled: + if isinstance(self.loss_module, MultiLayerVideoNystromMMD): + raise TypeError( + "partial-gradient GradCache currently requires a single " + "VideoNystromMMD layer") + partial_loss_kwargs = { + "gradient_students": global_gradient, + "student_reference": global_reference, + "current_teacher": global_teacher, + } + if self.full_student_partial_gradient: + partial_loss_kwargs["gradient_student_indices"] = ( + gradient_student_indices) + raw_loss, metadata = self.loss_module(**partial_loss_kwargs) + elif self.current_teacher_attraction_enabled: + raw_loss, metadata = self.loss_module( + global_gradient, current_teacher=global_teacher) + else: + raw_loss, metadata = self.loss_module(global_gradient) + if not isinstance(raw_loss, torch.Tensor) or raw_loss.ndim != 0: + raise TypeError( + "VideoNystromMMD must return a scalar tensor loss") + if not self._all_ranks_true( + bool(torch.isfinite(raw_loss.detach()).item()), + raw_loss.device, + ): + raise FloatingPointError( + "VideoNystromMMD returned a non-finite loss on at least one " + "rank") + optimized_loss = raw_loss * self.loss_scale + + (local_cotangent,) = torch.autograd.grad( + optimized_loss, + local_leaf, + retain_graph=False, + create_graph=False, + ) + local_cotangent = local_cotangent.detach() + if not self._all_ranks_true( + bool(torch.isfinite(local_cotangent).all().item()), + local_cotangent.device, + ): + raise FloatingPointError( + "VideoNystromMMD returned a non-finite feature cotangent on " + "at least one rank") + + world_size = self._world_size() + local_parts = tuple( + value.contiguous() + for value in local_cotangent.split( + self._gradient_batch_sizes, dim=0) + ) + fsdp_parts = tuple( + (value * float(world_size)).contiguous() + for value in local_parts + ) + global_samples = self.target_local_samples * world_size + gradient_global_samples = self.gradient_local_samples * world_size + teacher_local_samples = ( + self.gradient_local_samples + if self.current_teacher_attraction_enabled else 0 + ) + teacher_global_samples = teacher_local_samples * world_size + kss_denominator = global_samples * ( + global_samples + if self.full_student_partial_gradient + else gradient_global_samples + ) + detached_metadata = dict(_detach_metadata(metadata)) + detached_metadata.update({ + "raw_loss": raw_loss.detach(), + "optimized_loss": optimized_loss.detach(), + "loss_scale": raw_loss.new_tensor(self.loss_scale), + "gradcache_local_samples": raw_loss.new_tensor( + self.target_local_samples, dtype=torch.long), + "gradcache_global_samples": raw_loss.new_tensor( + global_samples, dtype=torch.long), + "gradcache_forward_local_samples": raw_loss.new_tensor( + self.target_local_samples, dtype=torch.long), + "gradcache_forward_global_samples": raw_loss.new_tensor( + global_samples, dtype=torch.long), + "gradcache_gradient_local_samples": raw_loss.new_tensor( + self.gradient_local_samples, dtype=torch.long), + "gradcache_gradient_global_samples": raw_loss.new_tensor( + gradient_global_samples, dtype=torch.long), + "gradcache_kss_denominator": raw_loss.new_tensor( + kss_denominator, dtype=torch.long), + "gradcache_partial_gradient_enabled": raw_loss.new_tensor( + int(self.partial_gradient_enabled), dtype=torch.long), + "gradcache_full_student_partial_gradient": raw_loss.new_tensor( + int(self.full_student_partial_gradient), dtype=torch.long), + "gradcache_world_size": raw_loss.new_tensor( + world_size, dtype=torch.long), + "gradcache_fsdp_cotangent_scale": raw_loss.new_tensor( + float(world_size)), + }) + detached_metadata.update({ + "gradcache_current_teacher_local_samples": raw_loss.new_tensor( + teacher_local_samples, dtype=torch.long), + "gradcache_current_teacher_global_samples": raw_loss.new_tensor( + teacher_global_samples, dtype=torch.long), + }) + core_global_samples = detached_metadata.get("global_samples") + if ( + core_global_samples is not None + and int(torch.as_tensor(core_global_samples).item()) + != global_samples + ): + raise ValueError( + "VideoNystromMMD global_samples metadata disagrees with the " + f"fresh GradCache: {int(torch.as_tensor(core_global_samples))} " + f"!= {global_samples}") + core_gradient_samples = detached_metadata.get( + "gradient_student_samples") + if ( + self.partial_gradient_enabled + and core_gradient_samples is not None + and int(torch.as_tensor(core_gradient_samples).item()) + != gradient_global_samples + ): + raise ValueError( + "VideoNystromMMD gradient_student_samples metadata disagrees " + "with the fresh GradCache") + core_repulsion_denominator = detached_metadata.get( + "repulsion_denominator") + if ( + self.partial_gradient_enabled + and core_repulsion_denominator is not None + and int(torch.as_tensor(core_repulsion_denominator).item()) + != kss_denominator + ): + raise ValueError( + "VideoNystromMMD Kss denominator disagrees with the fresh " + f"GradCache: expected {kss_denominator}") + + self._result = FreshVideoGradCacheResult( + loss=raw_loss.detach(), + optimized_loss=optimized_loss.detach(), + metadata=detached_metadata, + local_cotangents=local_parts, + fsdp_cotangents=fsdp_parts, + replay_batch_indices=tuple(self._gradient_batch_indices), + replay_sample_ids=tuple(self._gradient_sample_ids), + local_samples=self.target_local_samples, + global_samples=global_samples, + gradient_local_samples=self.gradient_local_samples, + gradient_global_samples=gradient_global_samples, + current_teacher_local_samples=teacher_local_samples, + current_teacher_global_samples=teacher_global_samples, + world_size=world_size, + ) + return self._result + + def _clear(self) -> None: + self._feature_batches.clear() + self._teacher_feature_batches.clear() + self._batch_sizes.clear() + self._gradient_batch_indices.clear() + self._gradient_batch_sizes.clear() + self._gradient_sample_ids.clear() + self._selection_started = False + self._sample_shape = None + self._device = None + self._result = None + + def offload_finalized_to_cpu(self) -> FreshVideoGradCacheResult: + """Move finalized replay storage to CPU without committing the update. + + ``finalize`` has already differentiated the complete global MMD and + produced detached replay cotangents. Keeping the fresh feature copies + and both cotangent views on CUDA after that point only increases the + generator-replay peak. This operation deliberately preserves all + transactional counters and the finalized result, so ``commit`` and + ``discard`` retain their original success/failure semantics. + """ + + if self._result is None: + raise RuntimeError( + "cannot offload a fresh feature cache before finalize" + ) + self._feature_batches = [ + _detach_to_cpu(value) for value in self._feature_batches + ] + self._teacher_feature_batches = [ + _detach_to_cpu(value) for value in self._teacher_feature_batches + ] + result = self._result + self._result = FreshVideoGradCacheResult( + loss=_detach_to_cpu(result.loss), + optimized_loss=_detach_to_cpu(result.optimized_loss), + metadata=_detach_to_cpu(result.metadata), + local_cotangents=_detach_to_cpu(result.local_cotangents), + fsdp_cotangents=_detach_to_cpu(result.fsdp_cotangents), + replay_batch_indices=result.replay_batch_indices, + replay_sample_ids=result.replay_sample_ids, + local_samples=result.local_samples, + global_samples=result.global_samples, + gradient_local_samples=result.gradient_local_samples, + gradient_global_samples=result.gradient_global_samples, + current_teacher_local_samples=result.current_teacher_local_samples, + current_teacher_global_samples=result.current_teacher_global_samples, + world_size=result.world_size, + ) + return self._result + + def commit(self) -> None: + """Clear a finalized cache after a successful optimizer update.""" + if self._result is None: + raise RuntimeError( + "cannot commit a fresh feature cache before finalize") + self._clear() + + def discard(self) -> None: + """Clear collected/finalized data after a skipped or failed update.""" + self._clear() diff --git a/14B/code/trainer/multi_representation_fresh_video_gradcache.py b/14B/code/trainer/multi_representation_fresh_video_gradcache.py new file mode 100644 index 0000000000000000000000000000000000000000..d0efa6f1fb16b7a2bb23d86e4971f811aefe1bc5 --- /dev/null +++ b/14B/code/trainer/multi_representation_fresh_video_gradcache.py @@ -0,0 +1,7 @@ +"""Compatibility module; read ``trainer.feature_gradient_replay`` instead.""" + +import sys +from trainer import feature_gradient_replay as _implementation + +# Preserve private helpers, monkeypatch targets, and legacy import paths. +sys.modules[__name__] = _implementation diff --git a/14B/code/utils/active_mmd_branches.py b/14B/code/utils/active_mmd_branches.py new file mode 100644 index 0000000000000000000000000000000000000000..0ae82b81924dcfc00923b758efa8727f0d37600c --- /dev/null +++ b/14B/code/utils/active_mmd_branches.py @@ -0,0 +1,14 @@ +"""Opt-in DINOv3 ablation without changing the existing MMD contract.""" + +ALL_BRANCHES = ("dinov3", "vjepa2", "videomae_v2") +NO_DINO_BRANCHES = ("vjepa2", "videomae_v2") + + +def active_mmd_branches(value=None): + """Keep legacy order and reject accidental branch/weight experiments.""" + branches = ALL_BRANCHES if value is None else tuple(value) + if branches not in (ALL_BRANCHES, NO_DINO_BRANCHES): + raise ValueError( + "triple_active_branches must be ordered as " + f"{ALL_BRANCHES!r} or {NO_DINO_BRANCHES!r}; got {branches!r}") + return branches diff --git a/14B/code/utils/config.py b/14B/code/utils/config.py new file mode 100644 index 0000000000000000000000000000000000000000..bf7a0f7d77e92c51b92cc4e8e9e23dfd5aae2054 --- /dev/null +++ b/14B/code/utils/config.py @@ -0,0 +1,83 @@ +"""Readable paper terminology at the config boundary, stable runtime keys inside. + +The public YAML uses distribution/differentiation/reference batches. Translate +once before loading defaults, constructing models, or relocating assets. The +resolved config retains the released keys and values so checkpoint/reference +identities and existing scripts remain compatible. Legacy YAML is still valid. +""" +from collections.abc import Mapping +from copy import deepcopy + +# New public spelling -> existing runtime/serialized spelling. +CONFIG_ALIASES = { + 'initial_generator_checkpoint': 'generator_ckpt', + 'initial_generator_checkpoint_sha256': 'generator_ckpt_sha256', + 'training_objective': 'distribution_loss', + 'training_loop': 'trainer', + 'feature_space': 'wan_feature_type', + 'mmd_estimator': 'wan_mmd_estimator', + 'objective_weight': 'wan_kv_loss_weight', + 'normalize_encoder_losses': 'wan_kv_self_normalize', + 'encoder_loss_normalization_epsilon': 'wan_kv_norm_eps', + 'reference_feature_source': 'current_teacher_source', + 'reference_feature_manifest': 'triple_teacher_bank_manifest', + 'reference_feature_manifest_sha256': 'triple_teacher_bank_manifest_sha256', + 'reference_feature_content_hash': 'triple_teacher_bank_content_hash', + 'reference_video_manifest': 'matched_manifest_path', + 'reference_video_count': 'matched_manifest_samples', + 'nystrom_reference_weight': 'triple_nystrom_attraction_weight', + 'monte_carlo_reference_weight': 'triple_nystrom_current_teacher_attraction_weight', + 'dino_loss_weight': 'triple_dinov3_weight', + 'vjepa_loss_weight': 'triple_vjepa2_weight', + 'videomae_loss_weight': 'triple_videomae_v2_weight', + 'encoder_loss_divisor': 'triple_aggregation_divisor', + 'active_feature_encoders': 'triple_active_branches', + 'per_device_microbatch_size': 'batch_size', + 'distribution_samples_per_rank': 'fresh_video_local_samples', + 'differentiation_samples_per_rank': 'fresh_video_generator_grad_local_samples', + 'differentiate_full_distribution_loss': 'fresh_video_full_student_partial_gradient', + 'differentiation_gradient_scale': 'fresh_video_generator_grad_sample_scale', + 'distribution_batch_size': 'total_batch_size', + 'reference_minibatch_size': 'expected_current_teacher_samples', + 'latent_video_shape': 'image_or_video_shape', + 'training_updates': 'max_steps', + 'generator_learning_rate': 'lr', +} + +VALUE_ALIASES = { + 'training_objective': {'distribution_matching': 'wan_kv_mmd'}, + 'training_loop': {'distribution_matching': 'score_distillation'}, + 'feature_space': {'frozen_video_encoders': 'triple_dinov3_vjepa2_videomae_v2_dense_latent'}, + 'mmd_estimator': {'hybrid_nystrom_monte_carlo': 'nystrom_exact_repulsion'}, + 'reference_feature_source': {'matched_encoder_features': 'matched_triple_feature_bank'}, +} + + +def resolve_config_aliases(config: Mapping) -> dict: + """Return the released runtime schema without mutating the caller's data. + + An explicitly supplied readable key takes precedence over its legacy alias. + This also permits overriding a legacy base config with readable settings. + """ + result = {} + overridden = {legacy for readable, legacy in CONFIG_ALIASES.items() if readable in config} + for key, value in config.items(): + if key in CONFIG_ALIASES: + legacy = CONFIG_ALIASES[key] + replacements = VALUE_ALIASES.get(key, {}) + result[legacy] = replacements.get(value, value) if isinstance(value, str) else deepcopy(value) + elif key not in overridden: + result[key] = deepcopy(value) + return result + + +def load_config(path, default_path=None): + """Resolve public names before merging defaults; preserve OmegaConf behavior.""" + from omegaconf import OmegaConf + + def read(filename): + config = OmegaConf.to_container(OmegaConf.load(filename), resolve=False) + return OmegaConf.create(resolve_config_aliases(config)) + + config = read(path) + return OmegaConf.merge(read(default_path), config) if default_path else config diff --git a/14B/code/utils/dataset.py b/14B/code/utils/dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..918c8b4cfd572bdca0b62deeb34e1c8105ad2144 --- /dev/null +++ b/14B/code/utils/dataset.py @@ -0,0 +1,283 @@ +from utils.lmdb import get_array_shape_from_lmdb, retrieve_row_from_lmdb +from torch.utils.data import Dataset +import numpy as np +import torch +import lmdb +import json +from pathlib import Path +from PIL import Image +import os + + +class TextDataset(Dataset): + def __init__(self, prompt_path, extended_prompt_path=None): + with open(prompt_path, encoding="utf-8") as f: + self.prompt_list = [line.rstrip() for line in f] + + if extended_prompt_path is not None: + with open(extended_prompt_path, encoding="utf-8") as f: + self.extended_prompt_list = [line.rstrip() for line in f] + assert len(self.extended_prompt_list) == len(self.prompt_list) + else: + self.extended_prompt_list = None + + def __len__(self): + return len(self.prompt_list) + + def __getitem__(self, idx): + batch = { + "prompts": self.prompt_list[idx], + "idx": idx, + } + if self.extended_prompt_list is not None: + batch["extended_prompts"] = self.extended_prompt_list[idx] + return batch + + +class ODERegressionLMDBDataset(Dataset): + def __init__( + self, + data_path: str, + max_pair: int = int(1e8), + *, + load_latents: bool = True, + load_terminal_latent_only: bool = False, + ): + self.env = lmdb.open(data_path, readonly=True, + lock=False, readahead=False, meminit=False) + + self.latents_shape = get_array_shape_from_lmdb(self.env, 'latents') + self.max_pair = max_pair + self.load_latents = bool(load_latents) + self.load_terminal_latent_only = bool( + load_terminal_latent_only) + if self.load_terminal_latent_only and not self.load_latents: + raise ValueError( + "load_terminal_latent_only requires load_latents=True") + + def __len__(self): + return min(self.latents_shape[0], self.max_pair) + + def __getitem__(self, idx): + """ + Outputs: + - prompts: List of Strings + - latents: Tensor of shape (num_denoising_steps, num_frames, num_channels, height, width). It is ordered from pure noise to clean image. + """ + prompts = retrieve_row_from_lmdb( + self.env, + "prompts", str, idx + ) + batch = { + "prompts": prompts, + "idx": idx, + } + if self.load_latents: + latents = retrieve_row_from_lmdb( + self.env, + "latents", np.float16, idx, shape=self.latents_shape[1:] + ) + if len(latents.shape) == 4: + if not self.load_terminal_latent_only: + latents = latents[None, ...] + elif self.load_terminal_latent_only: + # Keep only the clean terminal trajectory row in host memory. + latents = latents[-1] + batch["ode_latent"] = torch.tensor( + latents, dtype=torch.float32) + return batch + + +class ShardingLMDBDataset(Dataset): + def __init__(self, data_path: str, max_pair: int = int(1e8)): + self.envs = [] + self.index = [] + + for fname in sorted(os.listdir(data_path)): + path = os.path.join(data_path, fname) + env = lmdb.open(path, + readonly=True, + lock=False, + readahead=False, + meminit=False) + self.envs.append(env) + + self.latents_shape = [None] * len(self.envs) + for shard_id, env in enumerate(self.envs): + self.latents_shape[shard_id] = get_array_shape_from_lmdb(env, 'latents') + for local_i in range(self.latents_shape[shard_id][0]): + self.index.append((shard_id, local_i)) + + # print("shard_id ", shard_id, " local_i ", local_i) + + self.max_pair = max_pair + + def __len__(self): + return len(self.index) + + def __getitem__(self, idx): + """ + Outputs: + - prompts: List of Strings + - latents: Tensor of shape (num_denoising_steps, num_frames, num_channels, height, width). It is ordered from pure noise to clean image. + """ + shard_id, local_idx = self.index[idx] + + latents = retrieve_row_from_lmdb( + self.envs[shard_id], + "latents", np.float16, local_idx, + shape=self.latents_shape[shard_id][1:] + ) + + if len(latents.shape) == 4: + latents = latents[None, ...] + + prompts = retrieve_row_from_lmdb( + self.envs[shard_id], + "prompts", str, local_idx + ) + + return { + "prompts": prompts, + "ode_latent": torch.tensor(latents, dtype=torch.float32) + } + + +class TextImagePairDataset(Dataset): + def __init__( + self, + data_dir, + transform=None, + eval_first_n=-1, + pad_to_multiple_of=None + ): + """ + Args: + data_dir (str): Path to the directory containing: + - target_crop_info_*.json (metadata file) + - */ (subdirectory containing images with matching aspect ratio) + transform (callable, optional): Optional transform to be applied on the image + """ + self.transform = transform + data_dir = Path(data_dir) + + # Find the metadata JSON file + metadata_files = list(data_dir.glob('target_crop_info_*.json')) + if not metadata_files: + raise FileNotFoundError(f"No metadata file found in {data_dir}") + if len(metadata_files) > 1: + raise ValueError(f"Multiple metadata files found in {data_dir}") + + metadata_path = metadata_files[0] + # Extract aspect ratio from metadata filename (e.g. target_crop_info_26-15.json -> 26-15) + aspect_ratio = metadata_path.stem.split('_')[-1] + + # Use aspect ratio subfolder for images + self.image_dir = data_dir / aspect_ratio + if not self.image_dir.exists(): + raise FileNotFoundError(f"Image directory not found: {self.image_dir}") + + # Load metadata + with open(metadata_path, 'r') as f: + self.metadata = json.load(f) + + eval_first_n = eval_first_n if eval_first_n != -1 else len(self.metadata) + self.metadata = self.metadata[:eval_first_n] + + # Verify all images exist + for item in self.metadata: + image_path = self.image_dir / item['file_name'] + if not image_path.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + + self.dummy_prompt = "DUMMY PROMPT" + self.pre_pad_len = len(self.metadata) + if pad_to_multiple_of is not None and len(self.metadata) % pad_to_multiple_of != 0: + # Duplicate the last entry + self.metadata += [self.metadata[-1]] * ( + pad_to_multiple_of - len(self.metadata) % pad_to_multiple_of + ) + + def __len__(self): + return len(self.metadata) + + def __getitem__(self, idx): + """ + Returns: + dict: A dictionary containing: + - image: PIL Image + - caption: str + - target_bbox: list of int [x1, y1, x2, y2] + - target_ratio: str + - type: str + - origin_size: tuple of int (width, height) + """ + item = self.metadata[idx] + + # Load image + image_path = self.image_dir / item['file_name'] + image = Image.open(image_path).convert('RGB') + + # Apply transform if specified + if self.transform: + image = self.transform(image) + + return { + 'image': image, + 'prompts': item['caption'], + 'target_bbox': item['target_crop']['target_bbox'], + 'target_ratio': item['target_crop']['target_ratio'], + 'type': item['type'], + 'origin_size': (item['origin_width'], item['origin_height']), + 'idx': idx + } + + +class _CyclingDataLoader: + """Infinite DataLoader iterator with epoch-aware DistributedSampler state.""" + + def __init__(self, dataloader): + self.dataloader = dataloader + self.epoch = 0 + self._iterator = None + + def __iter__(self): + return self + + def _new_iterator(self): + if hasattr(self.dataloader.sampler, "set_epoch"): + self.dataloader.sampler.set_epoch(self.epoch) + self._iterator = iter(self.dataloader) + + def __next__(self): + if self._iterator is None: + self._new_iterator() + try: + return next(self._iterator) + except StopIteration: + self.epoch += 1 + self._new_iterator() + return next(self._iterator) + + def seek(self, consumed_batches): + """Position the next item at an absolute batch counter.""" + length = len(self.dataloader) + if length <= 0: + raise ValueError("cannot cycle an empty DataLoader") + self.epoch, offset = divmod(int(consumed_batches), length) + self._new_iterator() + for _ in range(offset): + next(self._iterator) + return offset + + +def cycle(dl): + return _CyclingDataLoader(dl) + + +# Compatibility re-exports keep legacy callers on ``utils.dataset`` while the +# prompt/video implementation remains isolated from the LMDB dataset module. +from utils.openvid_dataset import ( # noqa: E402 + OpenVidPromptVideoDataset, + collate_openvid_prompt_video, +) diff --git a/14B/code/utils/dinov3_vjepa2_feature_bank.py b/14B/code/utils/dinov3_vjepa2_feature_bank.py new file mode 100644 index 0000000000000000000000000000000000000000..dffedd8038d661606c9a18c457cd671ef7a06185 --- /dev/null +++ b/14B/code/utils/dinov3_vjepa2_feature_bank.py @@ -0,0 +1,301 @@ +"""Strict mmap-backed teacher bank for DINOv3 + V-JEPA2 features.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Mapping, Sequence + +import torch +from safetensors import safe_open +from torch.utils.data import Dataset + + +BANK_KIND = "dual_dinov3_vjepa2_matched_teacher_bank" +BANK_SCHEMA_VERSION = 1 +BRANCH_SHAPES = { + "dinov3": (1024, 1024), + "vjepa2": (1024, 1024), +} + + +def _canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def canonical_sha256(value: Any) -> str: + return hashlib.sha256(_canonical_bytes(value)).hexdigest() + + +def file_sha256(path: str | os.PathLike) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while True: + block = handle.read(8 * 1024 * 1024) + if not block: + break + digest.update(block) + return digest.hexdigest() + + +def _strict_sha256(value: object, label: str) -> str: + normalized = str(value) + if ( + len(normalized) != 64 + or any(character not in "0123456789abcdef" for character in normalized) + ): + raise ValueError(f"{label} must be a lowercase SHA-256") + return normalized + + +class DINOv3VJEPA2FeatureBank(Dataset): + """Metadata dataset plus random-access frozen FP16 feature rows.""" + + def __init__( + self, + manifest_path: str | os.PathLike, + *, + expected_manifest_sha256: str, + expected_content_hash: str, + expected_source_manifest_sha256: str, + expected_source_csv_sha256: str, + expected_sample_identity_sha256: str, + expected_sample_count: int = 5000, + expected_vjepa_model_sha256: str | None = None, + expected_dinov3_checkpoint_sha256: str | None = None, + expected_dinov3_source_commit: str | None = None, + expected_shard_size: int = 32, + validate_all_shards: bool = True, + ) -> None: + self.manifest_path = Path(manifest_path).expanduser().resolve() + if not self.manifest_path.is_file(): + raise FileNotFoundError(self.manifest_path) + actual_manifest_sha = file_sha256(self.manifest_path) + if actual_manifest_sha != _strict_sha256( + expected_manifest_sha256, "expected_manifest_sha256" + ): + raise ValueError("DINOv3+V-JEPA2 teacher bank manifest SHA-256 mismatch") + with self.manifest_path.open("r", encoding="utf-8") as handle: + manifest = json.load(handle) + if not isinstance(manifest, dict): + raise TypeError("DINOv3+V-JEPA2 teacher bank manifest must be an object") + required = { + "schema_version", + "kind", + "sample_count", + "source_manifest", + "branches", + "samples", + "shards", + "content_hash", + } + if set(manifest) != required: + raise ValueError( + "DINOv3+V-JEPA2 teacher bank manifest keys mismatch: " + f"{sorted(manifest)} != {sorted(required)}") + if ( + manifest["schema_version"] != BANK_SCHEMA_VERSION + or manifest["kind"] != BANK_KIND + or int(manifest["sample_count"]) != int(expected_sample_count) + ): + raise ValueError("DINOv3+V-JEPA2 teacher bank kind/schema/sample count mismatch") + payload = dict(manifest) + content_hash = payload.pop("content_hash") + if canonical_sha256(payload) != content_hash: + raise ValueError("DINOv3+V-JEPA2 teacher bank content_hash is invalid") + if content_hash != _strict_sha256( + expected_content_hash, "expected_content_hash" + ): + raise ValueError("DINOv3+V-JEPA2 teacher bank content identity mismatch") + source = manifest["source_manifest"] + if source.get("sha256") != _strict_sha256( + expected_source_manifest_sha256, + "expected_source_manifest_sha256", + ): + raise ValueError("DINOv3+V-JEPA2 bank source manifest SHA-256 mismatch") + if source.get("source_csv_sha256") != _strict_sha256( + expected_source_csv_sha256, + "expected_source_csv_sha256", + ): + raise ValueError("DINOv3+V-JEPA2 bank source CSV SHA-256 mismatch") + if source.get("sample_identity_sha256") != _strict_sha256( + expected_sample_identity_sha256, + "expected_sample_identity_sha256", + ): + raise ValueError("DINOv3+V-JEPA2 bank sample identity mismatch") + branches = manifest["branches"] + if set(branches) != set(BRANCH_SHAPES): + raise ValueError("DINOv3+V-JEPA2 teacher bank branch set mismatch") + for name, expected_shape in BRANCH_SHAPES.items(): + branch = branches[name] + if ( + tuple(branch.get("sample_shape", ())) != expected_shape + or branch.get("dtype") != "float16" + or not isinstance(branch.get("feature_contract"), Mapping) + or not isinstance(branch.get("model_identity"), Mapping) + ): + raise ValueError(f"DINOv3+V-JEPA2 teacher branch {name!r} contract mismatch") + if expected_vjepa_model_sha256 is not None and ( + branches["vjepa2"]["model_identity"].get("sha256") + != _strict_sha256( + expected_vjepa_model_sha256, + "expected_vjepa_model_sha256", + ) + ): + raise ValueError("V-JEPA2 bank model SHA-256 mismatch") + if expected_dinov3_checkpoint_sha256 is not None and ( + branches["dinov3"]["model_identity"].get("checkpoint_sha256") + != _strict_sha256( + expected_dinov3_checkpoint_sha256, + "expected_dinov3_checkpoint_sha256", + ) + ): + raise ValueError("DINOv3 bank checkpoint SHA-256 mismatch") + if expected_dinov3_source_commit is not None and ( + branches["dinov3"]["model_identity"].get("source_commit") + != str(expected_dinov3_source_commit) + ): + raise ValueError("DINOv3 bank source commit mismatch") + if type(expected_shard_size) is not int or expected_shard_size <= 0: + raise ValueError("expected_shard_size must be positive") + + samples = manifest["samples"] + if len(samples) != int(expected_sample_count): + raise ValueError("DINOv3+V-JEPA2 bank sample table length mismatch") + self.samples = [] + self._sample_index = {} + for expected_index, item in enumerate(samples): + if ( + not isinstance(item, dict) + or set(item) != {"index", "sample_id", "prompt"} + or int(item["index"]) != expected_index + or not isinstance(item["sample_id"], str) + or not item["sample_id"] + or not isinstance(item["prompt"], str) + ): + raise ValueError( + f"invalid DINOv3+V-JEPA2 bank sample record at {expected_index}") + if item["sample_id"] in self._sample_index: + raise ValueError("DINOv3+V-JEPA2 bank sample IDs must be unique") + self._sample_index[item["sample_id"]] = expected_index + self.samples.append(dict(item)) + + self.root = self.manifest_path.parent + self.shards = [] + self._row_to_shard: list[tuple[int, int]] = [None] * len(samples) + expected_start = 0 + for shard_index, item in enumerate(manifest["shards"]): + required_shard = { + "index", "path", "sample_start", "sample_end", + "sample_ids_sha256", "sha256", "content_hash", + } + if not isinstance(item, dict) or set(item) != required_shard: + raise ValueError(f"invalid shard record {shard_index}") + start = int(item["sample_start"]) + end = int(item["sample_end"]) + if ( + int(item["index"]) != shard_index + or start != expected_start + or end <= start + or end > len(samples) + or end - start != min(expected_shard_size, len(samples) - start) + ): + raise ValueError( + "DINOv3+V-JEPA2 bank shard ranges must be contiguous fixed-size " + f"chunks of {expected_shard_size}") + relative = Path(str(item["path"])) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("DINOv3+V-JEPA2 bank shard path must stay under bank root") + path = (self.root / relative).resolve() + if self.root not in path.parents or not path.is_file(): + raise FileNotFoundError(path) + ids = [samples[row]["sample_id"] for row in range(start, end)] + if canonical_sha256(ids) != item["sample_ids_sha256"]: + raise ValueError("DINOv3+V-JEPA2 bank shard sample ID hash mismatch") + if validate_all_shards and file_sha256(path) != item["sha256"]: + raise ValueError(f"DINOv3+V-JEPA2 bank shard SHA-256 mismatch: {path}") + with safe_open(str(path), framework="pt", device="cpu") as handle: + metadata = handle.metadata() or {} + if metadata.get("content_hash") != item["content_hash"]: + raise ValueError("DINOv3+V-JEPA2 bank shard embedded content hash mismatch") + for name, shape in BRANCH_SHAPES.items(): + tensor_shape = tuple(handle.get_slice(name).get_shape()) + if tensor_shape != (end - start, *shape): + raise ValueError( + f"DINOv3+V-JEPA2 bank shard tensor {name!r} shape mismatch") + for row in range(start, end): + self._row_to_shard[row] = (shard_index, row - start) + self.shards.append({**item, "resolved_path": str(path)}) + expected_start = end + if expected_start != len(samples): + raise ValueError("DINOv3+V-JEPA2 bank shards do not cover all samples") + self.manifest = manifest + self.content_hash = content_hash + self.manifest_sha256 = actual_manifest_sha + + def __len__(self) -> int: + return len(self.samples) + + def __getitem__(self, index: int) -> dict[str, object]: + item = self.samples[int(index)] + return { + "prompts": item["prompt"], + "sample_ids": item["sample_id"], + "teacher_bank_rows": int(item["index"]), + } + + @staticmethod + def collate_fn(items: Sequence[Mapping[str, object]]) -> dict[str, object]: + return { + "prompts": [str(item["prompts"]) for item in items], + "sample_ids": [str(item["sample_ids"]) for item in items], + "teacher_bank_rows": torch.tensor([ + int(item["teacher_bank_rows"]) for item in items + ], dtype=torch.long), + } + + def load_rows( + self, + sample_ids: Sequence[str], + ) -> dict[str, torch.Tensor]: + """Read only requested rows, preserving order and duplicate IDs.""" + ids = tuple(str(value) for value in sample_ids) + if not ids or any(value not in self._sample_index for value in ids): + raise KeyError("DINOv3+V-JEPA2 bank request contains an unknown sample ID") + output = {name: [None] * len(ids) for name in BRANCH_SHAPES} + by_shard: dict[int, list[tuple[int, int]]] = {} + for output_index, sample_id in enumerate(ids): + row = self._sample_index[sample_id] + shard_index, offset = self._row_to_shard[row] + by_shard.setdefault(shard_index, []).append((output_index, offset)) + for shard_index, requests in by_shard.items(): + path = self.shards[shard_index]["resolved_path"] + with safe_open(path, framework="pt", device="cpu") as handle: + slices = {name: handle.get_slice(name) for name in BRANCH_SHAPES} + for output_index, offset in requests: + for name in BRANCH_SHAPES: + output[name][output_index] = slices[name][ + offset:offset + 1 + ].squeeze(0) + return { + name: torch.stack(rows, dim=0) + for name, rows in output.items() + } + + +__all__ = [ + "BANK_KIND", + "BANK_SCHEMA_VERSION", + "BRANCH_SHAPES", + "DINOv3VJEPA2FeatureBank", + "canonical_sha256", + "file_sha256", +] diff --git a/14B/code/utils/distributed.py b/14B/code/utils/distributed.py new file mode 100644 index 0000000000000000000000000000000000000000..1471ed245b3ab2ad6af29f68e35179ce65d2b6d3 --- /dev/null +++ b/14B/code/utils/distributed.py @@ -0,0 +1,245 @@ +from datetime import timedelta +from functools import partial +import inspect +import os +import torch +import torch.distributed as dist +from torch.distributed.fsdp import BackwardPrefetch, FullStateDictConfig, FullOptimStateDictConfig, FullyShardedDataParallel as FSDP, MixedPrecision, ShardingStrategy, StateDictType +from torch.distributed.fsdp.api import CPUOffload +from torch.distributed.fsdp.wrap import size_based_auto_wrap_policy, transformer_auto_wrap_policy + + +def fsdp_state_dict(model): + fsdp_fullstate_save_policy = FullStateDictConfig( + offload_to_cpu=True, rank0_only=True + ) + with FSDP.state_dict_type( + model, StateDictType.FULL_STATE_DICT, fsdp_fullstate_save_policy + ): + checkpoint = model.state_dict() + + return checkpoint + + +def fsdp_optim_state_dict(model, optimizer): + """Gather the full (unsharded) optimizer state on rank 0 (CPU). + + Collective: must be called by every rank. Returns the full optim state + dict on rank 0 and an empty/partial dict on other ranks (rank0_only=True). + """ + save_policy = FullStateDictConfig(offload_to_cpu=True, rank0_only=True) + optim_policy = FullOptimStateDictConfig(offload_to_cpu=True, rank0_only=True) + with FSDP.state_dict_type( + model, + StateDictType.FULL_STATE_DICT, + state_dict_config=save_policy, + optim_state_dict_config=optim_policy, + ): + osd = FSDP.optim_state_dict(model, optimizer) + return osd + + +def fsdp_load_optim_state_dict(model, optimizer, full_osd): + """Load a full optimizer state dict (present on rank 0) back into ``optimizer``. + + Collective: every rank must call this. ``full_osd`` need only be non-None on + rank 0 (FSDP broadcasts/shards it to the other ranks), which keeps host memory + bounded to a single copy of the optimizer state. + """ + save_policy = FullStateDictConfig(offload_to_cpu=True, rank0_only=True) + optim_policy = FullOptimStateDictConfig(offload_to_cpu=True, rank0_only=True) + with FSDP.state_dict_type( + model, + StateDictType.FULL_STATE_DICT, + state_dict_config=save_policy, + optim_state_dict_config=optim_policy, + ): + sharded = FSDP.optim_state_dict_to_load( + model=model, optim=optimizer, optim_state_dict=full_osd + ) + optimizer.load_state_dict(sharded) + + +def fsdp_wrap(module, sharding_strategy="full", mixed_precision=False, wrap_strategy="size", min_num_params=int(5e7), transformer_module=None, cpu_offload=False, backward_prefetch="pre"): + if mixed_precision: + mixed_precision_policy = MixedPrecision( + param_dtype=torch.bfloat16, + reduce_dtype=torch.float32, + buffer_dtype=torch.float32, + cast_forward_inputs=False + ) + else: + mixed_precision_policy = None + + if wrap_strategy == "transformer": + if transformer_module is None: + raise ValueError("transformer wrap strategy requires transformer_module") + transformer_classes = ( + {transformer_module} + if isinstance(transformer_module, type) + else set(transformer_module) + ) + auto_wrap_policy = partial( + transformer_auto_wrap_policy, + transformer_layer_cls=transformer_classes + ) + elif wrap_strategy == "size": + auto_wrap_policy = partial( + size_based_auto_wrap_policy, + min_num_params=min_num_params + ) + else: + raise ValueError(f"Invalid wrap strategy: {wrap_strategy}") + + os.environ["NCCL_CROSS_NIC"] = "1" + + sharding_strategy = { + "full": ShardingStrategy.FULL_SHARD, + "hybrid_full": ShardingStrategy.HYBRID_SHARD, + "hybrid_zero2": ShardingStrategy._HYBRID_SHARD_ZERO2, + "no_shard": ShardingStrategy.NO_SHARD, + }[sharding_strategy] + + backward_prefetch_modes = { + "pre": BackwardPrefetch.BACKWARD_PRE, + "post": BackwardPrefetch.BACKWARD_POST, + } + if backward_prefetch not in backward_prefetch_modes: + raise ValueError( + "FSDP backward_prefetch must be 'pre' or 'post', got " + f"{backward_prefetch!r}") + + module = FSDP( + module, + auto_wrap_policy=auto_wrap_policy, + sharding_strategy=sharding_strategy, + mixed_precision=mixed_precision_policy, + device_id=torch.cuda.current_device(), + limit_all_gathers=True, + use_orig_params=True, + cpu_offload=CPUOffload(offload_params=cpu_offload), + backward_prefetch=backward_prefetch_modes[backward_prefetch], + # Every rank constructs and loads the same pretrained/checkpoint state + # before wrapping; no rank-0 broadcast is implied here. + sync_module_states=False + ) + return module + + +def move_adamw_moment_state_(optimizer, target_device): + """Move only AdamW moments without changing optimizer values.""" + if not isinstance(optimizer, torch.optim.AdamW): + raise TypeError("optimizer moment offload requires torch.optim.AdamW") + target = torch.device(target_device) + movable_keys = {"exp_avg", "exp_avg_sq", "max_exp_avg_sq"} + tensor_count = 0 + total_bytes = 0 + initialized_parameters = 0 + for state in optimizer.state.values(): + if not state: + continue + initialized_parameters += 1 + unknown = set(state) - movable_keys - {"step"} + if unknown: + raise RuntimeError( + "AdamW state contains unsupported entries for exact offload: " + f"{sorted(unknown)}") + if "exp_avg" not in state or "exp_avg_sq" not in state: + raise RuntimeError( + "initialized AdamW state is missing moment buffers") + step = state.get("step") + if not torch.is_tensor(step) or step.device.type != "cpu": + raise RuntimeError( + "non-capturable AdamW step state must remain on CPU") + for key in movable_keys: + value = state.get(key) + if value is None: + continue + if not torch.is_tensor(value): + raise RuntimeError(f"AdamW {key} state is not a tensor") + if value.device != target: + value = value.to(device=target, non_blocking=False) + state[key] = value + if value.device != target: + raise RuntimeError( + f"AdamW {key} state did not move to {target}") + tensor_count += 1 + total_bytes += value.numel() * value.element_size() + return { + "target_device": str(target), + "initialized_parameters": initialized_parameters, + "tensor_count": tensor_count, + "bytes": total_bytes, + } + + +def barrier(): + if dist.is_initialized(): + dist.barrier() + + +def launch_distributed_job(backend: str = "nccl"): + rank = int(os.environ["RANK"]) + local_rank = int(os.environ["LOCAL_RANK"]) + world_size = int(os.environ["WORLD_SIZE"]) + host = os.environ["MASTER_ADDR"] + port = int(os.environ["MASTER_PORT"]) + # Bind CUDA before NCCL creates communicators. + torch.cuda.set_device(local_rank) + + if ":" in host: # IPv6 + init_method = f"tcp://[{host}]:{port}" + else: # IPv4 + init_method = f"tcp://{host}:{port}" + init_kwargs = {} + if ( + backend == "nccl" + and "device_id" in inspect.signature( + dist.init_process_group).parameters + ): + init_kwargs["device_id"] = torch.device("cuda", local_rank) + dist.init_process_group( + rank=rank, + world_size=world_size, + backend=backend, + init_method=init_method, + timeout=timedelta(minutes=30), + **init_kwargs, + ) + + +class EMA_FSDP: + def __init__(self, fsdp_module: torch.nn.Module, decay: float = 0.999): + self.decay = decay + self.shadow = {} + self._init_shadow(fsdp_module) + + @torch.no_grad() + def _init_shadow(self, fsdp_module): + from torch.distributed.fsdp import FullyShardedDataParallel as FSDP + with FSDP.summon_full_params(fsdp_module, writeback=False): + for n, p in fsdp_module.module.named_parameters(): + self.shadow[n] = p.detach().clone().float().cpu() + + @torch.no_grad() + def update(self, fsdp_module): + d = self.decay + from torch.distributed.fsdp import FullyShardedDataParallel as FSDP + with FSDP.summon_full_params(fsdp_module, writeback=False): + for n, p in fsdp_module.module.named_parameters(): + self.shadow[n].mul_(d).add_(p.detach().float().cpu(), alpha=1. - d) + + # Optional helpers --------------------------------------------------- + def state_dict(self): + return self.shadow # picklable + + def load_state_dict(self, sd): + self.shadow = {k: v.clone() for k, v in sd.items()} + + def copy_to(self, fsdp_module): + # load EMA weights into an (unwrapped) copy of the generator + from torch.distributed.fsdp import FullyShardedDataParallel as FSDP + with FSDP.summon_full_params(fsdp_module, writeback=True): + for n, p in fsdp_module.module.named_parameters(): + if n in self.shadow: + p.data.copy_(self.shadow[n].to(p.dtype, device=p.device)) diff --git a/14B/code/utils/dual_video_feature_bank.py b/14B/code/utils/dual_video_feature_bank.py new file mode 100644 index 0000000000000000000000000000000000000000..068df83939412e01d01a5ac27da3182c00567092 --- /dev/null +++ b/14B/code/utils/dual_video_feature_bank.py @@ -0,0 +1,286 @@ +"""Strict mmap-backed teacher bank for V-JEPA2 + VideoMAE-v2 features.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Mapping, Sequence + +import torch +from safetensors import safe_open +from torch.utils.data import Dataset + + +BANK_KIND = "dual_vjepa2_videomae_v2_matched_teacher_bank" +BANK_SCHEMA_VERSION = 1 +BRANCH_SHAPES = { + "vjepa2": (1024, 1024), + "videomae_v2": (1280, 1024), +} + + +def _canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def canonical_sha256(value: Any) -> str: + return hashlib.sha256(_canonical_bytes(value)).hexdigest() + + +def file_sha256(path: str | os.PathLike) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while True: + block = handle.read(8 * 1024 * 1024) + if not block: + break + digest.update(block) + return digest.hexdigest() + + +def _strict_sha256(value: object, label: str) -> str: + normalized = str(value) + if ( + len(normalized) != 64 + or any(character not in "0123456789abcdef" for character in normalized) + ): + raise ValueError(f"{label} must be a lowercase SHA-256") + return normalized + + +class DualVideoFeatureBank(Dataset): + """Metadata dataset plus random-access frozen FP16 feature rows.""" + + def __init__( + self, + manifest_path: str | os.PathLike, + *, + expected_manifest_sha256: str, + expected_content_hash: str, + expected_source_manifest_sha256: str, + expected_sample_count: int = 5000, + expected_videomae_revision: str | None = None, + expected_videomae_download_manifest_content_hash: str | None = None, + expected_vjepa_model_sha256: str | None = None, + validate_all_shards: bool = True, + ) -> None: + self.manifest_path = Path(manifest_path).expanduser().resolve() + if not self.manifest_path.is_file(): + raise FileNotFoundError(self.manifest_path) + actual_manifest_sha = file_sha256(self.manifest_path) + if actual_manifest_sha != _strict_sha256( + expected_manifest_sha256, "expected_manifest_sha256" + ): + raise ValueError("dual teacher bank manifest SHA-256 mismatch") + with self.manifest_path.open("r", encoding="utf-8") as handle: + manifest = json.load(handle) + if not isinstance(manifest, dict): + raise TypeError("dual teacher bank manifest must be an object") + required = { + "schema_version", + "kind", + "sample_count", + "source_manifest", + "branches", + "samples", + "shards", + "content_hash", + } + if set(manifest) != required: + raise ValueError( + "dual teacher bank manifest keys mismatch: " + f"{sorted(manifest)} != {sorted(required)}") + if ( + manifest["schema_version"] != BANK_SCHEMA_VERSION + or manifest["kind"] != BANK_KIND + or int(manifest["sample_count"]) != int(expected_sample_count) + ): + raise ValueError("dual teacher bank kind/schema/sample count mismatch") + payload = dict(manifest) + content_hash = payload.pop("content_hash") + if canonical_sha256(payload) != content_hash: + raise ValueError("dual teacher bank content_hash is invalid") + if content_hash != _strict_sha256( + expected_content_hash, "expected_content_hash" + ): + raise ValueError("dual teacher bank content identity mismatch") + source = manifest["source_manifest"] + if source.get("sha256") != _strict_sha256( + expected_source_manifest_sha256, + "expected_source_manifest_sha256", + ): + raise ValueError("dual bank source manifest SHA-256 mismatch") + branches = manifest["branches"] + if set(branches) != set(BRANCH_SHAPES): + raise ValueError("dual teacher bank branch set mismatch") + for name, expected_shape in BRANCH_SHAPES.items(): + branch = branches[name] + if ( + tuple(branch.get("sample_shape", ())) != expected_shape + or branch.get("dtype") != "float16" + or not isinstance(branch.get("feature_contract"), Mapping) + or not isinstance(branch.get("model_identity"), Mapping) + ): + raise ValueError(f"dual teacher branch {name!r} contract mismatch") + if expected_videomae_revision is not None and ( + branches["videomae_v2"]["model_identity"].get("revision") + != str(expected_videomae_revision) + ): + raise ValueError("VideoMAE-v2 bank model revision mismatch") + if expected_videomae_download_manifest_content_hash is not None and ( + branches["videomae_v2"]["model_identity"].get( + "download_manifest_content_hash" + ) + != _strict_sha256( + expected_videomae_download_manifest_content_hash, + "expected_videomae_download_manifest_content_hash", + ) + ): + raise ValueError( + "VideoMAE-v2 bank download manifest identity mismatch") + if expected_vjepa_model_sha256 is not None and ( + branches["vjepa2"]["model_identity"].get("sha256") + != _strict_sha256( + expected_vjepa_model_sha256, + "expected_vjepa_model_sha256", + ) + ): + raise ValueError("V-JEPA2 bank model SHA-256 mismatch") + + samples = manifest["samples"] + if len(samples) != int(expected_sample_count): + raise ValueError("dual bank sample table length mismatch") + self.samples = [] + self._sample_index = {} + for expected_index, item in enumerate(samples): + if ( + not isinstance(item, dict) + or set(item) != {"index", "sample_id", "prompt"} + or int(item["index"]) != expected_index + or not isinstance(item["sample_id"], str) + or not item["sample_id"] + or not isinstance(item["prompt"], str) + ): + raise ValueError( + f"invalid dual bank sample record at {expected_index}") + if item["sample_id"] in self._sample_index: + raise ValueError("dual bank sample IDs must be unique") + self._sample_index[item["sample_id"]] = expected_index + self.samples.append(dict(item)) + + self.root = self.manifest_path.parent + self.shards = [] + self._row_to_shard: list[tuple[int, int]] = [None] * len(samples) + expected_start = 0 + for shard_index, item in enumerate(manifest["shards"]): + required_shard = { + "index", "path", "sample_start", "sample_end", + "sample_ids_sha256", "sha256", "content_hash", + } + if not isinstance(item, dict) or set(item) != required_shard: + raise ValueError(f"invalid shard record {shard_index}") + start = int(item["sample_start"]) + end = int(item["sample_end"]) + if ( + int(item["index"]) != shard_index + or start != expected_start + or end <= start + or end > len(samples) + ): + raise ValueError("dual bank shard ranges are not contiguous") + relative = Path(str(item["path"])) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("dual bank shard path must stay under bank root") + path = (self.root / relative).resolve() + if self.root not in path.parents or not path.is_file(): + raise FileNotFoundError(path) + ids = [samples[row]["sample_id"] for row in range(start, end)] + if canonical_sha256(ids) != item["sample_ids_sha256"]: + raise ValueError("dual bank shard sample ID hash mismatch") + if validate_all_shards and file_sha256(path) != item["sha256"]: + raise ValueError(f"dual bank shard SHA-256 mismatch: {path}") + with safe_open(str(path), framework="pt", device="cpu") as handle: + metadata = handle.metadata() or {} + if metadata.get("content_hash") != item["content_hash"]: + raise ValueError("dual bank shard embedded content hash mismatch") + for name, shape in BRANCH_SHAPES.items(): + tensor_shape = tuple(handle.get_slice(name).get_shape()) + if tensor_shape != (end - start, *shape): + raise ValueError( + f"dual bank shard tensor {name!r} shape mismatch") + for row in range(start, end): + self._row_to_shard[row] = (shard_index, row - start) + self.shards.append({**item, "resolved_path": str(path)}) + expected_start = end + if expected_start != len(samples): + raise ValueError("dual bank shards do not cover all samples") + self.manifest = manifest + self.content_hash = content_hash + self.manifest_sha256 = actual_manifest_sha + + def __len__(self) -> int: + return len(self.samples) + + def __getitem__(self, index: int) -> dict[str, object]: + item = self.samples[int(index)] + return { + "prompts": item["prompt"], + "sample_ids": item["sample_id"], + "teacher_bank_rows": int(item["index"]), + } + + @staticmethod + def collate_fn(items: Sequence[Mapping[str, object]]) -> dict[str, object]: + return { + "prompts": [str(item["prompts"]) for item in items], + "sample_ids": [str(item["sample_ids"]) for item in items], + "teacher_bank_rows": torch.tensor([ + int(item["teacher_bank_rows"]) for item in items + ], dtype=torch.long), + } + + def load_rows( + self, + sample_ids: Sequence[str], + ) -> dict[str, torch.Tensor]: + """Read only requested rows, preserving order and duplicate IDs.""" + ids = tuple(str(value) for value in sample_ids) + if not ids or any(value not in self._sample_index for value in ids): + raise KeyError("dual bank request contains an unknown sample ID") + output = {name: [None] * len(ids) for name in BRANCH_SHAPES} + by_shard: dict[int, list[tuple[int, int]]] = {} + for output_index, sample_id in enumerate(ids): + row = self._sample_index[sample_id] + shard_index, offset = self._row_to_shard[row] + by_shard.setdefault(shard_index, []).append((output_index, offset)) + for shard_index, requests in by_shard.items(): + path = self.shards[shard_index]["resolved_path"] + with safe_open(path, framework="pt", device="cpu") as handle: + slices = {name: handle.get_slice(name) for name in BRANCH_SHAPES} + for output_index, offset in requests: + for name in BRANCH_SHAPES: + output[name][output_index] = slices[name][ + offset:offset + 1 + ].squeeze(0) + return { + name: torch.stack(rows, dim=0) + for name, rows in output.items() + } + + +__all__ = [ + "BANK_KIND", + "BANK_SCHEMA_VERSION", + "BRANCH_SHAPES", + "DualVideoFeatureBank", + "canonical_sha256", + "file_sha256", +] diff --git a/14B/code/utils/inference_microbatch.py b/14B/code/utils/inference_microbatch.py new file mode 100644 index 0000000000000000000000000000000000000000..e9c9f2d19dd354370a008cbc8cb4d7d6de477ff0 --- /dev/null +++ b/14B/code/utils/inference_microbatch.py @@ -0,0 +1,46 @@ +"""Keep the evaluated sample-batch noise order with single-video execution.""" +import torch + + +def install_microbatch_adapter(pipeline): + """Draw all transition noises before inference, as in the step-80 run. + + Five samples is the evaluated setting. Generalizing the batch shape permits + smaller demos while preserving the original draw order for five samples. + """ + original = pipeline.inference + prompt_index = 0 + + def inference(noise, text_prompts, return_latents=True, initial_latent=None, low_memory=False): + nonlocal prompt_index + samples, frames, channels, height, width = noise.shape + block_frames = pipeline.num_frame_per_block + blocks = frames // block_frames + template = torch.empty((samples * block_frames, channels, height, width), + device=noise.device, dtype=noise.dtype) + # Three transitions between the four trained stages per block. + standard = [torch.randn_like(template).cpu() for _ in range(blocks * 3)] + auxiliary_rng = torch.Generator(device=noise.device).manual_seed( + pipeline.inference_aux_noise_seed + prompt_index) + # Terminal cleanup and context-cache writes each consume one auxiliary draw. + auxiliary = [torch.randn(template.shape, dtype=template.dtype, device=template.device, + generator=auxiliary_rng).cpu() for _ in range(blocks * 2)] + videos, latents = [], [] + for sample in range(samples): + start, end = sample * block_frames, (sample + 1) * block_frames + main_it = iter([x[start:end] for x in standard]) + aux_it = iter([x[start:end] for x in auxiliary]) + video, latent = original( + noise=noise[sample:sample + 1], text_prompts=[text_prompts[sample]], + return_latents=True, low_memory=low_memory, + initial_latent=None if initial_latent is None else initial_latent[sample:sample + 1], + standard_noise_override=main_it, aux_noise_override=aux_it) + videos.append(video.cpu()) + latents.append(latent.cpu()) + del video, latent + pipeline.vae.model.clear_cache() + print(f'[inference] prompt={prompt_index} sample={sample} complete', flush=True) + prompt_index += 1 + return torch.cat(videos), torch.cat(latents) + + pipeline.inference = inference diff --git a/14B/code/utils/lmdb.py b/14B/code/utils/lmdb.py new file mode 100644 index 0000000000000000000000000000000000000000..2171d54cd3b1b1963590c5c1a633aac7b9fc287e --- /dev/null +++ b/14B/code/utils/lmdb.py @@ -0,0 +1,72 @@ +import numpy as np + + +def get_array_shape_from_lmdb(env, array_name): + with env.begin() as txn: + image_shape = txn.get(f"{array_name}_shape".encode()).decode() + image_shape = tuple(map(int, image_shape.split())) + return image_shape + + +def store_arrays_to_lmdb(env, arrays_dict, start_index=0): + """ + Store rows of multiple numpy arrays in a single LMDB. + Each row is stored separately with a naming convention. + """ + with env.begin(write=True) as txn: + for array_name, array in arrays_dict.items(): + for i, row in enumerate(array): + # Convert row to bytes + if isinstance(row, str): + row_bytes = row.encode() + else: + row_bytes = row.tobytes() + + data_key = f'{array_name}_{start_index + i}_data'.encode() + + txn.put(data_key, row_bytes) + + +def process_data_dict(data_dict, seen_prompts): + output_dict = {} + + all_videos = [] + all_prompts = [] + for prompt, video in data_dict.items(): + if prompt in seen_prompts: + continue + else: + seen_prompts.add(prompt) + + video = video.half().numpy() + all_videos.append(video) + all_prompts.append(prompt) + + if len(all_videos) == 0: + return {"latents": np.array([]), "prompts": np.array([])} + + all_videos = np.concatenate(all_videos, axis=0) + + output_dict['latents'] = all_videos + output_dict['prompts'] = np.array(all_prompts) + + return output_dict + + +def retrieve_row_from_lmdb(lmdb_env, array_name, dtype, row_index, shape=None): + """ + Retrieve a specific row from a specific array in the LMDB. + """ + data_key = f'{array_name}_{row_index}_data'.encode() + + with lmdb_env.begin() as txn: + row_bytes = txn.get(data_key) + + if dtype == str: + array = row_bytes.decode() + else: + array = np.frombuffer(row_bytes, dtype=dtype) + + if shape is not None and len(shape) > 0: + array = array.reshape(shape) + return array diff --git a/14B/code/utils/loss.py b/14B/code/utils/loss.py new file mode 100644 index 0000000000000000000000000000000000000000..c420466d641d5fe2012eb2c970cba18b66d45826 --- /dev/null +++ b/14B/code/utils/loss.py @@ -0,0 +1,81 @@ +from abc import ABC, abstractmethod +import torch + + +class DenoisingLoss(ABC): + @abstractmethod + def __call__( + self, x: torch.Tensor, x_pred: torch.Tensor, + noise: torch.Tensor, noise_pred: torch.Tensor, + alphas_cumprod: torch.Tensor, + timestep: torch.Tensor, + **kwargs + ) -> torch.Tensor: + """ + Base class for denoising loss. + Input: + - x: the clean data with shape [B, F, C, H, W] + - x_pred: the predicted clean data with shape [B, F, C, H, W] + - noise: the noise with shape [B, F, C, H, W] + - noise_pred: the predicted noise with shape [B, F, C, H, W] + - alphas_cumprod: the cumulative product of alphas (defining the noise schedule) with shape [T] + - timestep: the current timestep with shape [B, F] + """ + pass + + +class X0PredLoss(DenoisingLoss): + def __call__( + self, x: torch.Tensor, x_pred: torch.Tensor, + noise: torch.Tensor, noise_pred: torch.Tensor, + alphas_cumprod: torch.Tensor, + timestep: torch.Tensor, + **kwargs + ) -> torch.Tensor: + return torch.mean((x - x_pred) ** 2) + + +class VPredLoss(DenoisingLoss): + def __call__( + self, x: torch.Tensor, x_pred: torch.Tensor, + noise: torch.Tensor, noise_pred: torch.Tensor, + alphas_cumprod: torch.Tensor, + timestep: torch.Tensor, + **kwargs + ) -> torch.Tensor: + weights = 1 / (1 - alphas_cumprod[timestep].reshape(*timestep.shape, 1, 1, 1)) + return torch.mean(weights * (x - x_pred) ** 2) + + +class NoisePredLoss(DenoisingLoss): + def __call__( + self, x: torch.Tensor, x_pred: torch.Tensor, + noise: torch.Tensor, noise_pred: torch.Tensor, + alphas_cumprod: torch.Tensor, + timestep: torch.Tensor, + **kwargs + ) -> torch.Tensor: + return torch.mean((noise - noise_pred) ** 2) + + +class FlowPredLoss(DenoisingLoss): + def __call__( + self, x: torch.Tensor, x_pred: torch.Tensor, + noise: torch.Tensor, noise_pred: torch.Tensor, + alphas_cumprod: torch.Tensor, + timestep: torch.Tensor, + **kwargs + ) -> torch.Tensor: + return torch.mean((kwargs["flow_pred"] - (noise - x)) ** 2) + + +NAME_TO_CLASS = { + "x0": X0PredLoss, + "v": VPredLoss, + "noise": NoisePredLoss, + "flow": FlowPredLoss +} + + +def get_denoising_loss(loss_type: str) -> DenoisingLoss: + return NAME_TO_CLASS[loss_type] diff --git a/14B/code/utils/matched_video_dataset.py b/14B/code/utils/matched_video_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..c95c936b4fd8af1baa224cb846fc6f90d935615b --- /dev/null +++ b/14B/code/utils/matched_video_dataset.py @@ -0,0 +1,381 @@ +"""Metadata-only matched-video dataset with explicitly lazy teacher I/O.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import time +from typing import Any, Callable, Mapping, Sequence + +import numpy as np +import torch +from torch.utils.data import Dataset + +from utils.matched_video_feature_cache import ( + MATCHED_PYAV_LAST64_DECODER_CONTRACT, + MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT, + MatchedVideoFeatureCache, +) +from utils.matched_video_manifest import ( + MatchedVideoManifestEntry, + load_matched_video_manifest, +) + + +@dataclass(frozen=True) +class DecodedMatchedVideo: + """The final contiguous RGB frame window in V-JEPA2 input range.""" + + video01: torch.Tensor + frame_indices: torch.Tensor + source_frame_count: int + + +def _frames_to_video01(frames: Sequence[Any]) -> torch.Tensor: + tensors: list[torch.Tensor] = [] + shape: tuple[int, int, int] | None = None + for frame in frames: + array = frame.to_ndarray(format="rgb24") + if not isinstance(array, np.ndarray): + raise TypeError("PyAV frame.to_ndarray did not return ndarray") + if array.ndim != 3 or int(array.shape[2]) != 3: + raise ValueError( + f"decoded RGB frame must be [H,W,3], got {array.shape}" + ) + if array.dtype != np.uint8: + raise ValueError(f"decoded RGB frame must be uint8, got {array.dtype}") + current_shape = tuple(int(value) for value in array.shape) + if shape is None: + shape = current_shape + elif current_shape != shape: + raise ValueError( + "video changes resolution inside selected frame window: " + f"{current_shape} != {shape}" + ) + tensors.append( + torch.from_numpy(np.ascontiguousarray(array)).permute(2, 0, 1) + ) + if not tensors: + raise ValueError("decoded frame window is empty") + return torch.stack(tensors).to(dtype=torch.float32).div_(255.0) + + +def decode_matched_video_last_contiguous_pyav( + video_path: str | os.PathLike, + *, + decoder_contract: Mapping[str, Any] | None = None, +) -> DecodedMatchedVideo: + """Decode exactly frames 17..80 from a contractually 81-frame MP4.""" + + contract = dict( + decoder_contract + if decoder_contract is not None + else MATCHED_PYAV_LAST64_DECODER_CONTRACT + ) + if contract.get("backend") != "pyav": + raise ValueError("decoder contract backend must be pyav") + if contract.get("selection") != "last_contiguous": + raise ValueError("decoder contract selection must be last_contiguous") + num_frames = int(contract.get("num_frames", 0)) + expected_count = int(contract.get("expected_source_frame_count", 0)) + if num_frames <= 0 or expected_count < num_frames: + raise ValueError("decoder contract frame geometry is invalid") + expected_bounds = [expected_count - num_frames, expected_count - 1] + if list(contract.get("expected_frame_indices", ())) != expected_bounds: + raise ValueError( + "decoder contract expected_frame_indices do not describe last64" + ) + try: + import av # Imported only for cache misses. + except ImportError as exc: # pragma: no cover + raise ImportError( + "PyAV is required for a matched-video teacher cache miss; " + "install the pinned av==13.1.0 dependency" + ) from exc + expected_version = str(contract.get("backend_version", "")) + actual_version = str(getattr(av, "__version__", "")) + if actual_version != expected_version: + raise RuntimeError( + "PyAV version differs from the cache decoder contract: " + f"{actual_version!r} != {expected_version!r}" + ) + + path = Path(video_path).expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(path) + selected: list[Any] = [] + decoded_count = 0 + start = expected_count - num_frames + with av.open(str(path), mode="r") as container: + streams = container.streams.video + if len(streams) != 1: + raise ValueError( + f"expected exactly one video stream in {path}, got {len(streams)}" + ) + stream = streams[0] + for frame_index, frame in enumerate(container.decode(stream)): + decoded_count = frame_index + 1 + if start <= frame_index < expected_count: + selected.append(frame) + if decoded_count != expected_count: + raise ValueError( + "matched video source frame count violates the last64 contract: " + f"sample={path.name}, actual={decoded_count}, " + f"expected={expected_count}" + ) + if len(selected) != num_frames: + raise RuntimeError( + f"failed to decode frames {start}..{expected_count - 1} from {path}" + ) + video01 = _frames_to_video01(selected) + if tuple(video01.shape[:2]) != (num_frames, 3): + raise RuntimeError(f"decoded video has invalid shape {tuple(video01.shape)}") + return DecodedMatchedVideo( + video01=video01, + frame_indices=torch.arange(start, expected_count, dtype=torch.long), + source_frame_count=decoded_count, + ) + + +DecodeFunction = Callable[[Path, Mapping[str, Any]], DecodedMatchedVideo] + + +def _default_decode( + path: Path, + contract: Mapping[str, Any], +) -> DecodedMatchedVideo: + return decode_matched_video_last_contiguous_pyav( + path, decoder_contract=contract + ) + + +class MatchedManifestVideoDataset(Dataset): + """Return metadata eagerly; teacher cache/decode only on explicit request.""" + + def __init__( + self, + manifest_path: str | os.PathLike, + video_root: str | os.PathLike, + cache_root: str | os.PathLike, + expected_model_sha256: str, + expected_feature_contract_sha256: str, + *, + expected_source_csv_sha256: str | None = None, + expected_count: int | None = None, + feature_contract: Mapping[str, Any] | None = None, + decoder_contract: Mapping[str, Any] | None = None, + decode_fn: DecodeFunction | None = None, + ) -> None: + self.manifest_path = Path(manifest_path).expanduser().resolve() + self.video_root = Path(video_root).expanduser().resolve() + if not self.video_root.is_dir(): + raise NotADirectoryError(self.video_root) + self.entries, self.manifest_metadata = load_matched_video_manifest( + self.manifest_path, + expected_source_csv_sha256=expected_source_csv_sha256, + expected_count=expected_count, + ) + if not self.entries: + raise ValueError("matched-video manifest is empty") + self.feature_cache = MatchedVideoFeatureCache( + cache_root, + expected_model_sha256=expected_model_sha256, + expected_feature_contract_sha256=expected_feature_contract_sha256, + feature_contract=( + feature_contract + if feature_contract is not None + else MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT + ), + decoder_contract=( + decoder_contract + if decoder_contract is not None + else MATCHED_PYAV_LAST64_DECODER_CONTRACT + ), + ) + self.decoder_contract = self.feature_cache.decoder_contract + self.decode_fn = decode_fn if decode_fn is not None else _default_decode + self._cache_key_to_index: dict[str, int] = {} + self._sample_id_to_index: dict[str, int] = {} + for index, entry in enumerate(self.entries): + cache_key = self.feature_cache.key_for(entry) + if cache_key in self._cache_key_to_index: + raise ValueError(f"duplicate matched-video cache key: {cache_key}") + if entry.sample_id in self._sample_id_to_index: + raise ValueError(f"duplicate sample_id: {entry.sample_id}") + self._cache_key_to_index[cache_key] = index + self._sample_id_to_index[entry.sample_id] = index + + def __len__(self) -> int: + return len(self.entries) + + def _entry_for_sample(self, sample_id: str) -> MatchedVideoManifestEntry: + try: + return self.entries[self._sample_id_to_index[str(sample_id)]] + except KeyError as exc: + raise KeyError(f"unknown matched-video sample_id: {sample_id!r}") from exc + + def _video_path_and_verify_stat( + self, + entry: MatchedVideoManifestEntry, + ) -> Path: + path = self.video_root / entry.video_name + if not path.is_file(): + raise FileNotFoundError(path) + stat = path.stat() + actual = (int(stat.st_size), int(stat.st_mtime_ns)) + expected = (entry.video_size_bytes, entry.video_mtime_ns) + if actual != expected: + raise ValueError( + "matched video is stale after manifest freeze for " + f"sample_id={entry.sample_id}: actual={actual}, " + f"expected={expected}" + ) + return path + + def cache_key_for_sample(self, sample_id: str) -> str: + return self.feature_cache.key_for(self._entry_for_sample(sample_id)) + + def __getitem__(self, index: int) -> dict[str, Any]: + """Return only prompt/sample/path metadata; never touch teacher data.""" + + entry = self.entries[int(index)] + video_path = self._video_path_and_verify_stat(entry) + return { + "idx": entry.manifest_index, + "prompts": entry.caption, + "sample_id": entry.sample_id, + "teacher_cache_key": self.feature_cache.key_for(entry), + "video_name": entry.video_name, + "video_path": str(video_path), + } + + def load_teacher_payload(self, sample_id: str) -> dict[str, Any]: + """Load/decode teacher input only after suffix replay selects a sample.""" + + entry = self._entry_for_sample(sample_id) + video_path = self._video_path_and_verify_stat(entry) + cache_key = self.feature_cache.key_for(entry) + cached = self.feature_cache.load(entry, cache_key=cache_key) + payload: dict[str, Any] = { + "sample_id": entry.sample_id, + "teacher_cache_key": cache_key, + "video_name": entry.video_name, + "video_path": str(video_path), + } + if cached is not None: + payload.update({ + "current_teacher_cache_hit": True, + "current_teacher_decode_seconds": 0.0, + "current_teacher_features": cached, + }) + return payload + + decode_started = time.perf_counter() + decoded = self.decode_fn(video_path, self.decoder_contract) + decode_seconds = time.perf_counter() - decode_started + if not isinstance(decoded, DecodedMatchedVideo): + raise TypeError("decode_fn must return DecodedMatchedVideo") + expected_count = int(self.decoder_contract["expected_source_frame_count"]) + expected_frames = int(self.decoder_contract["num_frames"]) + expected_indices = torch.arange( + expected_count - expected_frames, + expected_count, + dtype=torch.long, + ) + if ( + int(decoded.source_frame_count) != expected_count + or not isinstance(decoded.frame_indices, torch.Tensor) + or decoded.frame_indices.dtype != torch.long + or not torch.equal( + decoded.frame_indices.detach().cpu(), expected_indices + ) + ): + raise ValueError( + "decode_fn violated the strict last-contiguous 17..80 contract" + ) + video = decoded.video01 + if ( + not isinstance(video, torch.Tensor) + or video.dtype != torch.float32 + or video.ndim != 4 + or tuple(video.shape[:2]) != (expected_frames, 3) + or not bool(torch.isfinite(video).all().item()) + or float(video.min()) < 0.0 + or float(video.max()) > 1.0 + ): + raise ValueError( + "decode_fn returned invalid video01; expected finite FP32 " + f"[{expected_frames},3,H,W] in [0,1]" + ) + payload.update({ + "current_teacher_cache_hit": False, + "current_teacher_decode_seconds": decode_seconds, + "current_teacher_frame_indices": decoded.frame_indices, + "current_teacher_source_frame_count": expected_count, + "current_teacher_video01": video.contiguous(), + }) + return payload + + def write_feature_cache( + self, + cache_key: str, + feature: torch.Tensor, + *, + sample_id: str, + ) -> Path: + try: + index = self._cache_key_to_index[str(cache_key)] + except KeyError as exc: + raise KeyError(f"unknown teacher cache key: {cache_key!r}") from exc + entry = self.entries[index] + if entry.sample_id != str(sample_id): + raise ValueError( + "sample_id does not own teacher cache key: " + f"{sample_id!r} != {entry.sample_id!r}" + ) + self._video_path_and_verify_stat(entry) + return self.feature_cache.write( + entry, feature, cache_key=str(cache_key) + ) + + @staticmethod + def collate_fn(batch: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + return collate_matched_video_metadata(batch) + + +def collate_matched_video_metadata( + batch: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Collate only metadata; teacher payloads intentionally have no collator.""" + + if not batch: + raise ValueError("cannot collate an empty matched-video batch") + forbidden = { + "current_teacher_features", + "current_teacher_video01", + "current_teacher_cache_hit", + } + for item in batch: + present = forbidden.intersection(item) + if present: + raise ValueError( + "metadata collator received eager teacher payload fields: " + f"{sorted(present)}" + ) + return { + "idx": torch.tensor([int(item["idx"]) for item in batch]), + "prompts": [str(item["prompts"]) for item in batch], + "sample_ids": [str(item["sample_id"]) for item in batch], + "teacher_cache_keys": [str(item["teacher_cache_key"]) for item in batch], + "video_names": [str(item["video_name"]) for item in batch], + "video_paths": [str(item["video_path"]) for item in batch], + } + + +__all__ = [ + "DecodedMatchedVideo", + "MatchedManifestVideoDataset", + "collate_matched_video_metadata", + "decode_matched_video_last_contiguous_pyav", +] diff --git a/14B/code/utils/matched_video_feature_cache.py b/14B/code/utils/matched_video_feature_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..53c2bdd8a56941f70bca76345db35ddbf59b8b5c --- /dev/null +++ b/14B/code/utils/matched_video_feature_cache.py @@ -0,0 +1,364 @@ +"""Strict safetensors cache for matched-manifest live-teacher features.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import tempfile +from typing import Any, Mapping + +from safetensors import safe_open +from safetensors.torch import save_file +import torch + +from utils.matched_video_manifest import MatchedVideoManifestEntry + + +CACHE_FORMAT = "sf_matched_manifest_video_vjepa2_teacher_feature" +CACHE_FORMAT_VERSION = 1 +CACHE_METADATA_KEY = "sf_matched_video_teacher_feature" +CACHE_TENSOR_KEY = "features" + +# Independent from the fixed-reference central-64 protocol. Both new student +# and live teacher use the last 64 frames, i.e. 17..80 for the 81-frame source. +MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT: dict[str, Any] = { + "adapter_contract_version": 1, + "contract_version": 1, + "crop_size": 256, + "expected_source_frame_count": 81, + "feature_dim": 1024, + "feature_type": "vjepa2_dense_latent", + "frame_indices": [17, 80], + "frame_sampling": "last_contiguous", + "interpolation": "bilinear_antialias", + "layer_index": 15, + "model_family": "vjepa2-vitl-fpc64-256", + "model_config_sha256": ( + "3dec96fe962e94e569182d3a7b9ef0dd74b6b8c89c337a428e43e10d593e70c9" + ), + "normalization": "imagenet_default", + "num_frames": 64, + "num_tokens": 1024, + "output_dtype": "float32", + "output_shape": [1024, 1024], + "processor_config_sha256": ( + "d2fab4418fc0390b62c4cd72ade56908a7929f80c62288adbe10dd8d23421227" + ), + "resize_short_side": 292, + "sample_axis": "video", + "token_alignment": "same_deterministic_indices", + "token_indices_sha256": ( + "a77ee1f9d20cba6b2f2a9a971c3d36610ca7f8aa2a55c616192582665be69d10" + ), + "token_sampling": "uniform_linspace_round", + "transformers_version": "4.53.3", +} + +MATCHED_PYAV_LAST64_DECODER_CONTRACT: dict[str, Any] = { + "backend": "pyav", + "backend_version": "13.1.0", + "contract_version": 1, + "expected_frame_indices": [17, 80], + "expected_source_frame_count": 81, + "frame_count_source": "container_decode_order_with_stream_frames_hint", + "num_frames": 64, + "output_dtype": "float32", + "output_layout": "TCHW", + "output_range": [0.0, 1.0], + "pixel_format": "rgb24", + "selection": "last_contiguous", +} + + +class MatchedVideoFeatureCacheValidationError(ValueError): + """An existing cache file is stale, corrupt, or identity-incompatible.""" + + +def canonical_json(value: Any) -> str: + return json.dumps( + value, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def canonical_sha256(value: Any) -> str: + return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT_SHA256 = canonical_sha256( + MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT +) +MATCHED_PYAV_LAST64_DECODER_CONTRACT_SHA256 = canonical_sha256( + MATCHED_PYAV_LAST64_DECODER_CONTRACT +) + + +def _is_sha256(value: str) -> bool: + return len(value) == 64 and all(c in "0123456789abcdef" for c in value) + + +def _entry_context( + entry: MatchedVideoManifestEntry | Mapping[str, Any], +) -> dict[str, Any]: + if isinstance(entry, MatchedVideoManifestEntry): + result = { + "sample_id": entry.sample_id, + "video_mtime_ns": int(entry.video_mtime_ns), + "video_name": entry.video_name, + "video_size_bytes": int(entry.video_size_bytes), + } + elif isinstance(entry, Mapping): + required = { + "sample_id", "video_mtime_ns", "video_name", "video_size_bytes", + } + missing = required - set(entry) + if missing: + raise ValueError(f"cache sample context is missing {sorted(missing)}") + result = { + "sample_id": str(entry["sample_id"]), + "video_mtime_ns": int(entry["video_mtime_ns"]), + "video_name": str(entry["video_name"]), + "video_size_bytes": int(entry["video_size_bytes"]), + } + else: + raise TypeError("entry must be MatchedVideoManifestEntry or mapping") + if not result["sample_id"] or result["sample_id"] != result["video_name"]: + raise ValueError("cache sample_id must equal video_name") + if result["video_size_bytes"] <= 0 or result["video_mtime_ns"] <= 0: + raise ValueError("cache video stat must be positive") + return result + + +def _tensor_sha256(value: torch.Tensor) -> str: + value = value.detach().cpu().contiguous() + return hashlib.sha256(value.numpy().tobytes(order="C")).hexdigest() + + +class MatchedVideoFeatureCache: + """Lazy one-file-per-video FP32 cache with immutable identity checks.""" + + def __init__( + self, + cache_root: str | os.PathLike, + *, + expected_model_sha256: str, + expected_feature_contract_sha256: str, + feature_contract: Mapping[str, Any] | None = None, + decoder_contract: Mapping[str, Any] | None = None, + expected_shape: tuple[int, int] | None = None, + ) -> None: + self.cache_root = Path(cache_root).expanduser().resolve() + self.model_sha256 = str(expected_model_sha256).lower() + if not _is_sha256(self.model_sha256): + raise ValueError("expected_model_sha256 must be lowercase SHA-256") + self.feature_contract = json.loads(canonical_json( + feature_contract or MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT + )) + self.feature_contract_sha256 = canonical_sha256(self.feature_contract) + expected_hash = str(expected_feature_contract_sha256).lower() + if not _is_sha256(expected_hash): + raise ValueError( + "expected_feature_contract_sha256 must be lowercase SHA-256" + ) + if self.feature_contract_sha256 != expected_hash: + raise ValueError( + "feature contract SHA-256 mismatch: " + f"expected={expected_hash}, actual={self.feature_contract_sha256}" + ) + self.decoder_contract = json.loads(canonical_json( + decoder_contract or MATCHED_PYAV_LAST64_DECODER_CONTRACT + )) + self.decoder_contract_sha256 = canonical_sha256(self.decoder_contract) + contract_shape = tuple( + int(value) for value in self.feature_contract.get("output_shape", ()) + ) + self.expected_shape = ( + tuple(int(value) for value in expected_shape) + if expected_shape is not None else contract_shape + ) + if len(self.expected_shape) != 2 or min(self.expected_shape) <= 0: + raise ValueError("expected feature shape must be positive [N,D]") + if contract_shape and self.expected_shape != contract_shape: + raise ValueError( + "expected_shape must match feature_contract.output_shape" + ) + + def _key_payload( + self, entry: MatchedVideoManifestEntry | Mapping[str, Any], + ) -> dict[str, Any]: + return { + "decoder_contract": self.decoder_contract, + "decoder_contract_sha256": self.decoder_contract_sha256, + "feature_contract": self.feature_contract, + "feature_contract_sha256": self.feature_contract_sha256, + "format": CACHE_FORMAT, + "format_version": CACHE_FORMAT_VERSION, + "model_sha256": self.model_sha256, + "sample": _entry_context(entry), + } + + def key_for( + self, entry: MatchedVideoManifestEntry | Mapping[str, Any], + ) -> str: + digest = hashlib.sha256() + digest.update(b"sf-matched-video-teacher-feature-cache\0") + digest.update(canonical_json(self._key_payload(entry)).encode("utf-8")) + return digest.hexdigest() + + def path_for_key(self, cache_key: str) -> Path: + key = str(cache_key).lower() + if not _is_sha256(key): + raise ValueError("cache_key must be lowercase SHA-256") + return self.cache_root / key[:2] / key[2:4] / f"{key}.safetensors" + + def _expected_metadata( + self, + entry: MatchedVideoManifestEntry | Mapping[str, Any], + cache_key: str, + tensor_sha256: str, + ) -> dict[str, Any]: + result = self._key_payload(entry) + result.update({ + "cache_key": cache_key, + "tensor": { + "dtype": "float32", "key": CACHE_TENSOR_KEY, + "sha256": tensor_sha256, "shape": list(self.expected_shape), + }, + }) + return result + + def _validate_tensor(self, feature: torch.Tensor) -> torch.Tensor: + if not isinstance(feature, torch.Tensor): + raise TypeError("teacher feature must be a torch.Tensor") + if feature.ndim == 3 and int(feature.shape[0]) == 1: + feature = feature[0] + if feature.ndim != 2 or tuple(feature.shape) != self.expected_shape: + raise ValueError( + f"teacher feature shape mismatch: {tuple(feature.shape)} " + f"!= {self.expected_shape}" + ) + if not feature.is_floating_point(): + raise TypeError("teacher feature must be floating point") + result = feature.detach().to(device="cpu", dtype=torch.float32).contiguous() + if not bool(torch.isfinite(result).all().item()): + raise FloatingPointError("teacher feature contains non-finite values") + return result + + def load( + self, + entry: MatchedVideoManifestEntry | Mapping[str, Any], + *, + cache_key: str | None = None, + ) -> torch.Tensor | None: + key = self.key_for(entry) + if cache_key is not None and str(cache_key) != key: + raise MatchedVideoFeatureCacheValidationError( + "caller cache_key does not match sample/model/contracts" + ) + path = self.path_for_key(key) + if not path.exists(): + return None + if not path.is_file(): + raise MatchedVideoFeatureCacheValidationError( + f"cache path is not a regular file: {path}" + ) + try: + with safe_open(path, framework="pt", device="cpu") as handle: + if list(handle.keys()) != [CACHE_TENSOR_KEY]: + raise MatchedVideoFeatureCacheValidationError( + f"cache tensor keys mismatch in {path}" + ) + serialized = (handle.metadata() or {}).get(CACHE_METADATA_KEY) + if serialized is None: + raise MatchedVideoFeatureCacheValidationError( + f"cache metadata is missing in {path}" + ) + metadata = json.loads(serialized) + feature = handle.get_tensor(CACHE_TENSOR_KEY).clone() + except MatchedVideoFeatureCacheValidationError: + raise + except Exception as exc: + raise MatchedVideoFeatureCacheValidationError( + f"failed to read safetensors cache {path}: {exc}" + ) from exc + feature = self._validate_tensor(feature) + expected = self._expected_metadata(entry, key, _tensor_sha256(feature)) + if metadata != expected: + raise MatchedVideoFeatureCacheValidationError( + f"cache metadata/identity mismatch (stale or corrupt) in {path}" + ) + return feature + + def write( + self, + entry: MatchedVideoManifestEntry | Mapping[str, Any], + feature: torch.Tensor, + *, + cache_key: str | None = None, + overwrite: bool = False, + ) -> Path: + key = self.key_for(entry) + if cache_key is not None and str(cache_key) != key: + raise MatchedVideoFeatureCacheValidationError( + "caller cache_key does not match sample/model/contracts" + ) + feature = self._validate_tensor(feature) + tensor_hash = _tensor_sha256(feature) + destination = self.path_for_key(key) + if destination.exists() and not overwrite: + existing = self.load(entry, cache_key=key) + if existing is None or _tensor_sha256(existing) != tensor_hash: + raise MatchedVideoFeatureCacheValidationError( + "refusing to replace a valid cache entry with different features: " + f"{destination}" + ) + return destination + destination.parent.mkdir(parents=True, exist_ok=True) + metadata = self._expected_metadata(entry, key, tensor_hash) + descriptor, temporary_name = tempfile.mkstemp( + dir=destination.parent, + prefix=f".{destination.name}.", suffix=".tmp", + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + temporary.unlink() + save_file( + {CACHE_TENSOR_KEY: feature}, str(temporary), + metadata={CACHE_METADATA_KEY: canonical_json(metadata)}, + ) + with temporary.open("rb") as handle: + os.fsync(handle.fileno()) + if overwrite: + os.replace(temporary, destination) + else: + try: + os.link(temporary, destination) + except FileExistsError: + existing = self.load(entry, cache_key=key) + if existing is None or _tensor_sha256(existing) != tensor_hash: + raise MatchedVideoFeatureCacheValidationError( + "concurrent cache writer produced different features " + f"for {key}" + ) + temporary.unlink(missing_ok=True) + finally: + temporary.unlink(missing_ok=True) + self.load(entry, cache_key=key) + return destination + + +__all__ = [ + "CACHE_FORMAT", "CACHE_FORMAT_VERSION", "CACHE_METADATA_KEY", + "CACHE_TENSOR_KEY", "MATCHED_PYAV_LAST64_DECODER_CONTRACT", + "MATCHED_PYAV_LAST64_DECODER_CONTRACT_SHA256", + "MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT", + "MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT_SHA256", + "MatchedVideoFeatureCache", "MatchedVideoFeatureCacheValidationError", + "canonical_json", "canonical_sha256", +] diff --git a/14B/code/utils/matched_video_manifest.py b/14B/code/utils/matched_video_manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..5ec9f52e2455020666fc17f63524edad88cb6cfb --- /dev/null +++ b/14B/code/utils/matched_video_manifest.py @@ -0,0 +1,368 @@ +"""Frozen prompt/video manifests for the generic matched-teacher data source. + +Rows are joined to videos exclusively by the MP4 basename. Prompt text is +never an identity key because prompts need not be unique. The two bookkeeping +columns used by the source dataset (``used_by_sim`` and ``used_by_vjepa2``) are +deliberately ignored. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import csv +import hashlib +import json +import os +from pathlib import Path +import tempfile +from typing import Any, Iterable, Mapping + + +MANIFEST_FORMAT = "sf_matched_manifest_video" +MANIFEST_FORMAT_VERSION = 1 +DEFAULT_EXPECTED_SOURCE_CSV_SHA256 = ( + "fe8ee308715e60c35856d0c7a4df71759f4969801312befacfa3ba3309381397" +) +DEFAULT_EXPECTED_COUNT = 5_000 + + +def _canonical_json(value: Any) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def sha256_file(path: str | os.PathLike, chunk_size: int = 8 << 20) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while chunk := handle.read(int(chunk_size)): + digest.update(chunk) + return digest.hexdigest() + + +def _is_sha256(value: str) -> bool: + return ( + len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _manifest_meta_path(manifest_path: Path) -> Path: + if manifest_path.suffix == ".jsonl": + return manifest_path.with_suffix(".meta.json") + return manifest_path.with_name(manifest_path.name + ".meta.json") + + +def _atomic_write_bytes(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +@dataclass(frozen=True) +class MatchedVideoManifestEntry: + """One immutable prompt/video pair in CSV row order.""" + + manifest_index: int + source_index: int + sample_id: str + video_name: str + caption: str + video_size_bytes: int + video_mtime_ns: int + + @classmethod + def from_mapping( + cls, + value: Mapping[str, Any], + ) -> "MatchedVideoManifestEntry": + required = { + "manifest_index", + "source_index", + "sample_id", + "video_name", + "caption", + "video_size_bytes", + "video_mtime_ns", + } + missing = required - set(value) + unexpected = set(value) - required + if missing or unexpected: + raise ValueError( + "matched-video manifest entry schema mismatch: " + f"missing={sorted(missing)}, unexpected={sorted(unexpected)}" + ) + entry = cls( + manifest_index=int(value["manifest_index"]), + source_index=int(value["source_index"]), + sample_id=str(value["sample_id"]), + video_name=str(value["video_name"]), + caption=str(value["caption"]), + video_size_bytes=int(value["video_size_bytes"]), + video_mtime_ns=int(value["video_mtime_ns"]), + ) + entry.validate() + return entry + + def validate(self) -> None: + if self.manifest_index < 0 or self.source_index < 0: + raise ValueError("manifest/source indices must be non-negative") + if not self.sample_id or self.sample_id != self.video_name: + raise ValueError("sample_id must equal the MP4 basename") + if Path(self.video_name).name != self.video_name: + raise ValueError("video_name must be a basename") + if not self.video_name.lower().endswith(".mp4"): + raise ValueError("video_name must identify an MP4") + if not self.caption.strip(): + raise ValueError("caption must be non-empty") + if self.video_size_bytes <= 0 or self.video_mtime_ns <= 0: + raise ValueError("video stat must contain positive size and mtime") + + def to_dict(self) -> dict[str, Any]: + return { + "caption": self.caption, + "manifest_index": self.manifest_index, + "sample_id": self.sample_id, + "source_index": self.source_index, + "video_mtime_ns": self.video_mtime_ns, + "video_name": self.video_name, + "video_size_bytes": self.video_size_bytes, + } + + +def _serialize_entries(entries: Iterable[MatchedVideoManifestEntry]) -> bytes: + return ( + "".join(_canonical_json(entry.to_dict()) + "\n" for entry in entries) + .encode("utf-8") + ) + + +def build_matched_video_manifest( + *, + source_csv: str | os.PathLike, + video_root: str | os.PathLike, + manifest_path: str | os.PathLike, + expected_source_csv_sha256: str | None = ( + DEFAULT_EXPECTED_SOURCE_CSV_SHA256 + ), + expected_count: int | None = DEFAULT_EXPECTED_COUNT, +) -> dict[str, Any]: + """Freeze every CSV prompt/MP4 pair and publish JSONL plus sidecar.""" + + source_csv = Path(source_csv).expanduser().resolve() + video_root = Path(video_root).expanduser().resolve() + manifest_path = Path(manifest_path).expanduser().resolve() + if not source_csv.is_file(): + raise FileNotFoundError(source_csv) + if not video_root.is_dir(): + raise NotADirectoryError(video_root) + source_hash = sha256_file(source_csv) + if expected_source_csv_sha256 is not None: + expected_hash = str(expected_source_csv_sha256).lower() + if not _is_sha256(expected_hash): + raise ValueError( + "expected_source_csv_sha256 must be lowercase SHA-256" + ) + if source_hash != expected_hash: + raise ValueError( + "source CSV SHA-256 mismatch: " + f"expected={expected_hash}, actual={source_hash}" + ) + if expected_count is not None and int(expected_count) <= 0: + raise ValueError("expected_count must be positive or None") + + entries: list[MatchedVideoManifestEntry] = [] + seen_sample_ids: set[str] = set() + with source_csv.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + required = {"idx", "mp4", "prompt"} + if reader.fieldnames is None or not required.issubset(reader.fieldnames): + raise ValueError( + f"source CSV must contain {sorted(required)}; " + f"got {reader.fieldnames}" + ) + for row_number, row in enumerate(reader, start=2): + try: + source_index = int(row["idx"]) + except (TypeError, ValueError) as exc: + raise ValueError( + f"invalid idx at source CSV row {row_number}" + ) from exc + if source_index != len(entries): + raise ValueError( + "source CSV idx must be contiguous in row order: " + f"row={row_number}, idx={source_index}, " + f"expected={len(entries)}" + ) + video_name = str(row["mp4"]).strip() + if ( + not video_name + or Path(video_name).name != video_name + or not video_name.lower().endswith(".mp4") + ): + raise ValueError(f"invalid MP4 basename at row {row_number}") + if video_name in seen_sample_ids: + raise ValueError(f"duplicate MP4/sample_id: {video_name}") + caption = str(row["prompt"]).strip() + if not caption: + raise ValueError(f"empty prompt for {video_name!r}") + path = video_root / video_name + if not path.is_file(): + raise FileNotFoundError( + f"CSV video is absent from video_root: {path}" + ) + stat = path.stat() + entry = MatchedVideoManifestEntry( + manifest_index=len(entries), + source_index=source_index, + sample_id=video_name, + video_name=video_name, + caption=caption, + video_size_bytes=int(stat.st_size), + video_mtime_ns=int(stat.st_mtime_ns), + ) + entry.validate() + entries.append(entry) + seen_sample_ids.add(video_name) + + if expected_count is not None and len(entries) != int(expected_count): + raise ValueError( + f"source row count mismatch: {len(entries)} != {int(expected_count)}" + ) + if not entries: + raise ValueError("source CSV is empty") + + manifest_payload = _serialize_entries(entries) + manifest_hash = hashlib.sha256(manifest_payload).hexdigest() + identity_hash = hashlib.sha256( + b"".join( + entry.sample_id.encode("utf-8") + + b"\0" + + str(entry.video_size_bytes).encode("ascii") + + b"\0" + + str(entry.video_mtime_ns).encode("ascii") + + b"\0" + for entry in entries + ) + ).hexdigest() + metadata = { + "format": MANIFEST_FORMAT, + "format_version": MANIFEST_FORMAT_VERSION, + "manifest_file": manifest_path.name, + "manifest_sha256": manifest_hash, + "sample_count": len(entries), + "sample_identity_sha256": identity_hash, + "source": { + "csv": str(source_csv), + "csv_sha256": source_hash, + "ignored_columns": ["used_by_sim", "used_by_vjepa2"], + "join_key": "mp4_basename", + "video_root": str(video_root), + }, + } + metadata_payload = ( + json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2) + + "\n" + ).encode("utf-8") + _atomic_write_bytes(manifest_path, manifest_payload) + _atomic_write_bytes(_manifest_meta_path(manifest_path), metadata_payload) + return metadata + + +def load_matched_video_manifest( + manifest_path: str | os.PathLike, + *, + expected_source_csv_sha256: str | None = None, + expected_count: int | None = None, +) -> tuple[list[MatchedVideoManifestEntry], dict[str, Any]]: + """Load a frozen manifest and strictly verify its immutable identity.""" + + manifest_path = Path(manifest_path).expanduser().resolve() + meta_path = _manifest_meta_path(manifest_path) + if not manifest_path.is_file(): + raise FileNotFoundError(manifest_path) + if not meta_path.is_file(): + raise FileNotFoundError(meta_path) + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + if metadata.get("format") != MANIFEST_FORMAT: + raise ValueError("unsupported matched-video manifest format") + if int(metadata.get("format_version", -1)) != MANIFEST_FORMAT_VERSION: + raise ValueError("unsupported matched-video manifest format_version") + if metadata.get("manifest_file") != manifest_path.name: + raise ValueError("manifest sidecar points to a different filename") + expected_manifest_hash = str(metadata.get("manifest_sha256", "")) + actual_manifest_hash = sha256_file(manifest_path) + if ( + not _is_sha256(expected_manifest_hash) + or actual_manifest_hash != expected_manifest_hash + ): + raise ValueError( + "matched-video manifest SHA-256 mismatch: " + f"expected={expected_manifest_hash}, actual={actual_manifest_hash}" + ) + source_hash = str(metadata.get("source", {}).get("csv_sha256", "")) + if not _is_sha256(source_hash): + raise ValueError("manifest sidecar source CSV hash is invalid") + if ( + expected_source_csv_sha256 is not None + and source_hash != str(expected_source_csv_sha256).lower() + ): + raise ValueError( + "manifest source CSV SHA-256 mismatch: " + f"expected={expected_source_csv_sha256}, actual={source_hash}" + ) + + entries: list[MatchedVideoManifestEntry] = [] + seen: set[str] = set() + with manifest_path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + raise ValueError(f"blank manifest line {line_number}") + entry = MatchedVideoManifestEntry.from_mapping(json.loads(line)) + if entry.manifest_index != len(entries): + raise ValueError( + f"non-contiguous manifest_index at line {line_number}" + ) + if entry.sample_id in seen: + raise ValueError(f"duplicate sample_id: {entry.sample_id}") + seen.add(entry.sample_id) + entries.append(entry) + sidecar_count = int(metadata.get("sample_count", -1)) + if len(entries) != sidecar_count: + raise ValueError( + f"manifest row count mismatch: {len(entries)} != {sidecar_count}" + ) + if expected_count is not None and len(entries) != int(expected_count): + raise ValueError( + f"expected manifest row count mismatch: " + f"{len(entries)} != {int(expected_count)}" + ) + return entries, metadata + + +__all__ = [ + "DEFAULT_EXPECTED_COUNT", + "DEFAULT_EXPECTED_SOURCE_CSV_SHA256", + "MANIFEST_FORMAT", + "MANIFEST_FORMAT_VERSION", + "MatchedVideoManifestEntry", + "build_matched_video_manifest", + "load_matched_video_manifest", + "sha256_file", +] diff --git a/14B/code/utils/misc.py b/14B/code/utils/misc.py new file mode 100644 index 0000000000000000000000000000000000000000..53002381dfbf63757bd4eb959b5530f0c2757d56 --- /dev/null +++ b/14B/code/utils/misc.py @@ -0,0 +1,100 @@ +import os +from typing import Optional + +import numpy as np +import random +import torch + + +def _placeholder(value, placeholder: str) -> Optional[str]: + """Return ``value`` if it is set and not equal to ``placeholder``, else ``None``.""" + if value is None: + return None + s = str(value) + if not s or s == placeholder: + return None + return s + + +def init_wandb_from_config(config) -> None: + """Initialise ``wandb`` from ``config`` and environment variables. + + The shipped YAMLs use placeholder strings (``WANDB_HOST``, ``WANDB_KEY`` + etc.) for the wandb fields so the configs can be committed without + leaking secrets. This helper treats those placeholders as unset and + falls back to standard ``WANDB_API_KEY``/``WANDB_ENTITY``/``WANDB_PROJECT`` + environment variables (or any pre-existing ``wandb login``). + """ + import wandb + from omegaconf import OmegaConf + + wb_key = os.environ.get("WANDB_API_KEY") or _placeholder( + getattr(config, "wandb_key", None), "WANDB_KEY" + ) + wb_host = _placeholder(getattr(config, "wandb_host", None), "WANDB_HOST") + wb_entity = os.environ.get("WANDB_ENTITY") or _placeholder( + getattr(config, "wandb_entity", None), "WANDB_ENTITY" + ) + wb_project = os.environ.get("WANDB_PROJECT") or _placeholder( + getattr(config, "wandb_project", None), "WANDB_PROJECT" + ) + wb_name = os.environ.get("WANDB_NAME") or getattr(config, "config_name", None) + wb_run_group = os.environ.get("WANDB_RUN_GROUP") or _placeholder( + getattr(config, "wandb_run_group", None), "WANDB_RUN_GROUP" + ) + + if wb_key: + login_kwargs = {"key": wb_key} + if wb_host: + login_kwargs["host"] = wb_host + wandb.login(**login_kwargs) + + init_kwargs = dict( + config=OmegaConf.to_container(config, resolve=True), + name=wb_name, + mode="online", + dir=getattr(config, "wandb_save_dir", "") or None, + ) + if wb_entity: + init_kwargs["entity"] = wb_entity + if wb_project: + init_kwargs["project"] = wb_project + if wb_run_group: + init_kwargs["group"] = wb_run_group + wandb.init(**init_kwargs) + + +def set_seed(seed: int, deterministic: bool = False): + """ + Helper function for reproducible behavior to set the seed in `random`, `numpy`, `torch`. + + Args: + seed (`int`): + The seed to set. + deterministic (`bool`, *optional*, defaults to `False`): + Whether to use deterministic algorithms where available. Can slow down training. + """ + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + + if deterministic: + torch.use_deterministic_algorithms(True) + + +def merge_dict_list(dict_list): + if len(dict_list) == 1: + return dict_list[0] + + merged_dict = {} + for k, v in dict_list[0].items(): + if isinstance(v, torch.Tensor): + if v.ndim == 0: + merged_dict[k] = torch.stack([d[k] for d in dict_list], dim=0) + else: + merged_dict[k] = torch.cat([d[k] for d in dict_list], dim=0) + else: + # for non-tensor values, we just copy the value from the first item + merged_dict[k] = v + return merged_dict diff --git a/14B/code/utils/model_identity.py b/14B/code/utils/model_identity.py new file mode 100644 index 0000000000000000000000000000000000000000..a1ac2e9a8b25b09a36679b3352bf9e9659842b79 --- /dev/null +++ b/14B/code/utils/model_identity.py @@ -0,0 +1,147 @@ +"""Deterministic identities for frozen files and JSON metadata.""" + +from __future__ import annotations + +import hashlib +import json +import os +from typing import Callable, Optional + +import torch.distributed as dist + + +def canonical_sha256(value) -> str: + """Hash a JSON value with one canonical serialization.""" + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def canonical_identity_value(value): + """Normalize tuples and lists through strict, finite canonical JSON.""" + try: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + except (TypeError, ValueError) as exc: + raise ValueError( + "artifact identity must contain only finite JSON-serializable values" + ) from exc + return json.loads(payload) + + +def file_sha256(path: str) -> str: + """Hash a file without loading it wholly into memory.""" + digest = hashlib.sha256() + with open(path, "rb") as handle: + while chunk := handle.read(8 * 1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def distributed_file_sha256(path: str) -> str: + """Hash a shared file on rank zero and broadcast the result.""" + if ( + not dist.is_available() + or not dist.is_initialized() + or dist.get_world_size() == 1 + ): + return file_sha256(path) + payload = [None] + if dist.get_rank() == 0: + try: + payload[0] = {"ok": True, "sha256": file_sha256(path)} + except BaseException as exc: + payload[0] = { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + } + dist.broadcast_object_list(payload, src=0) + result = payload[0] + if not isinstance(result, dict) or not result.get("ok"): + error = ( + result.get("error", "unknown error") + if isinstance(result, dict) + else "no rank-zero result" + ) + raise RuntimeError(f"rank-zero file hashing failed: {error}") + return str(result["sha256"]) + + +def runtime_file_identity( + path, + *, + file_hasher: Optional[Callable[[str], str]] = None, +) -> dict: + """Return path, stable stat fields, and SHA256 for one file.""" + resolved = os.path.realpath(os.path.expanduser(os.fspath(path))) + if not os.path.isfile(resolved): + raise FileNotFoundError( + f"runtime identity target is not a file: {resolved}") + before = os.stat(resolved) + digest = str((file_hasher or distributed_file_sha256)(resolved)) + after = os.stat(resolved) + if ( + int(before.st_size) != int(after.st_size) + or int(before.st_mtime_ns) != int(after.st_mtime_ns) + ): + raise RuntimeError( + f"runtime identity target changed while hashing: {resolved}") + if ( + len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError( + f"runtime identity returned an invalid SHA256 for {resolved}") + return { + "path": resolved, + "size_bytes": int(after.st_size), + "mtime_ns": int(after.st_mtime_ns), + "sha256": digest, + } + + +def distributed_runtime_file_identity(path) -> dict: + """Build the complete file identity on rank zero and broadcast it.""" + if ( + not dist.is_available() + or not dist.is_initialized() + or dist.get_world_size() == 1 + ): + return runtime_file_identity(path, file_hasher=file_sha256) + payload = [None] + if dist.get_rank() == 0: + try: + payload[0] = { + "ok": True, + "identity": runtime_file_identity( + path, file_hasher=file_sha256), + } + except BaseException as exc: + payload[0] = { + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + } + dist.broadcast_object_list(payload, src=0) + result = payload[0] + if not isinstance(result, dict) or not result.get("ok"): + error = ( + result.get("error", "unknown error") + if isinstance(result, dict) + else "no rank-zero result" + ) + raise RuntimeError( + f"rank-zero runtime file identity failed: {error}") + identity = result.get("identity") + if not isinstance(identity, dict): + raise RuntimeError( + "rank-zero runtime file identity returned invalid data") + return dict(identity) diff --git a/14B/code/utils/openvid_dataset.py b/14B/code/utils/openvid_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..96829e9638126ffa79184480773905255efb756c --- /dev/null +++ b/14B/code/utils/openvid_dataset.py @@ -0,0 +1,420 @@ +"""OpenVid prompt/video dataset with lazy teacher-feature caching.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import time +from typing import Any, Callable, Mapping, Sequence + +import numpy as np +import torch +from torch.utils.data import Dataset + +from utils.openvid_feature_cache import ( + OPENVID_PYAV_DECODER_CONTRACT, + OPENVID_VJEPA2_L15_FEATURE_CONTRACT, + OpenVidFeatureCache, +) +from utils.openvid_manifest import ( + OpenVidManifestEntry, + load_openvid_manifest, +) + + +@dataclass(frozen=True) +class DecodedOpenVidVideo: + """Central contiguous RGB frame window in V-JEPA2 input range.""" + + video01: torch.Tensor + frame_indices: torch.Tensor + source_frame_count: int + + +def _frames_to_video01(frames: Sequence[Any]) -> torch.Tensor: + tensors: list[torch.Tensor] = [] + shape: tuple[int, int, int] | None = None + for frame in frames: + array = frame.to_ndarray(format="rgb24") + if not isinstance(array, np.ndarray): + raise TypeError("PyAV frame.to_ndarray did not return ndarray") + if array.ndim != 3 or int(array.shape[2]) != 3: + raise ValueError( + f"decoded RGB frame must be [H,W,3], got {array.shape}" + ) + if array.dtype != np.uint8: + raise ValueError( + f"decoded RGB frame must be uint8, got {array.dtype}" + ) + current_shape = tuple(int(value) for value in array.shape) + if shape is None: + shape = current_shape + elif current_shape != shape: + raise ValueError( + "video changes resolution inside selected frame window: " + f"{current_shape} != {shape}" + ) + tensor = torch.from_numpy(np.ascontiguousarray(array)).permute(2, 0, 1) + tensors.append(tensor) + if not tensors: + raise ValueError("decoded frame window is empty") + return torch.stack(tensors).to(dtype=torch.float32).div_(255.0) + + +def _decode_window_and_count( + av_module: Any, + video_path: Path, + *, + start: int, + num_frames: int, +) -> tuple[list[Any], int]: + selected: list[Any] = [] + decoded_count = 0 + with av_module.open(str(video_path), mode="r") as container: + streams = container.streams.video + if len(streams) != 1: + raise ValueError( + f"expected exactly one video stream in {video_path}, " + f"got {len(streams)}" + ) + stream = streams[0] + for frame_index, frame in enumerate(container.decode(stream)): + decoded_count = frame_index + 1 + if start <= frame_index < start + num_frames: + selected.append(frame) + return selected, decoded_count + + +def decode_openvid_center_contiguous_pyav( + video_path: str | os.PathLike, + *, + decoder_contract: Mapping[str, Any] | None = None, +) -> DecodedOpenVidVideo: + """Decode the exact central contiguous frame window using PyAV order.""" + + contract = dict( + decoder_contract + if decoder_contract is not None + else OPENVID_PYAV_DECODER_CONTRACT + ) + if contract.get("backend") != "pyav": + raise ValueError("decoder contract backend must be pyav") + if contract.get("selection") != "center_contiguous": + raise ValueError("decoder contract selection must be center_contiguous") + if contract.get("center_tie_break") != "floor": + raise ValueError("decoder contract center_tie_break must be floor") + num_frames = int(contract.get("num_frames", 0)) + if num_frames <= 0: + raise ValueError("decoder contract num_frames must be positive") + try: + import av # Imported lazily so complete cache hits need no decoder. + except ImportError as exc: # pragma: no cover - exercised without PyAV + raise ImportError( + "PyAV is required for an OpenVid teacher cache miss; install " + "the pinned av==13.1.0 dependency" + ) from exc + expected_version = str(contract.get("backend_version", "")) + actual_version = str(getattr(av, "__version__", "")) + if actual_version != expected_version: + raise RuntimeError( + "PyAV version differs from the cache decoder contract: " + f"{actual_version!r} != {expected_version!r}" + ) + + video_path = Path(video_path).expanduser().resolve() + if not video_path.is_file(): + raise FileNotFoundError(video_path) + with av.open(str(video_path), mode="r") as container: + streams = container.streams.video + if len(streams) != 1: + raise ValueError( + f"expected exactly one video stream in {video_path}, " + f"got {len(streams)}" + ) + frame_count_hint = int(getattr(streams[0], "frames", 0) or 0) + provisional_start = max((frame_count_hint - num_frames) // 2, 0) + selected, actual_count = _decode_window_and_count( + av, video_path, start=provisional_start, num_frames=num_frames) + if actual_count < num_frames: + raise ValueError( + f"OpenVid video has only {actual_count} decoded frames; " + f"need {num_frames}: {video_path}" + ) + actual_start = (actual_count - num_frames) // 2 + if actual_start != provisional_start or len(selected) != num_frames: + selected, repeated_count = _decode_window_and_count( + av, video_path, start=actual_start, num_frames=num_frames) + if repeated_count != actual_count: + raise RuntimeError( + "PyAV returned a different frame count on deterministic " + f"re-decode: {repeated_count} != {actual_count}" + ) + if len(selected) != num_frames: + raise RuntimeError( + f"failed to decode central {num_frames}-frame window from {video_path}" + ) + video01 = _frames_to_video01(selected) + if tuple(video01.shape[:2]) != (num_frames, 3): + raise RuntimeError( + f"decoded video has invalid shape {tuple(video01.shape)}" + ) + frame_indices = torch.arange( + actual_start, + actual_start + num_frames, + dtype=torch.long, + ) + return DecodedOpenVidVideo( + video01=video01, + frame_indices=frame_indices, + source_frame_count=actual_count, + ) + + +DecodeFunction = Callable[[Path, Mapping[str, Any]], DecodedOpenVidVideo] + + +def _default_decode( + path: Path, + contract: Mapping[str, Any], +) -> DecodedOpenVidVideo: + return decode_openvid_center_contiguous_pyav( + path, decoder_contract=contract) + + +class OpenVidPromptVideoDataset(Dataset): + """Return a prompt plus either cached teacher features or decoded video.""" + + def __init__( + self, + manifest_path: str | os.PathLike, + video_root: str | os.PathLike, + cache_root: str | os.PathLike, + expected_model_sha256: str, + expected_feature_contract_sha256: str, + *, + feature_contract: Mapping[str, Any] | None = None, + decoder_contract: Mapping[str, Any] | None = None, + decode_fn: DecodeFunction | None = None, + ) -> None: + self.manifest_path = Path(manifest_path).expanduser().resolve() + self.video_root = Path(video_root).expanduser().resolve() + if not self.video_root.is_dir(): + raise NotADirectoryError(self.video_root) + self.entries, self.manifest_metadata = load_openvid_manifest( + self.manifest_path) + if not self.entries: + raise ValueError("OpenVid manifest is empty") + self.feature_cache = OpenVidFeatureCache( + cache_root, + expected_model_sha256=expected_model_sha256, + expected_feature_contract_sha256=( + expected_feature_contract_sha256), + feature_contract=( + feature_contract + if feature_contract is not None + else OPENVID_VJEPA2_L15_FEATURE_CONTRACT + ), + decoder_contract=( + decoder_contract + if decoder_contract is not None + else OPENVID_PYAV_DECODER_CONTRACT + ), + ) + self.decoder_contract = self.feature_cache.decoder_contract + self.decode_fn = decode_fn if decode_fn is not None else _default_decode + self._cache_key_to_index: dict[str, int] = {} + self._sample_id_to_index: dict[str, int] = {} + for index, entry in enumerate(self.entries): + cache_key = self.feature_cache.key_for(entry) + if cache_key in self._cache_key_to_index: + raise ValueError(f"duplicate OpenVid teacher cache key: {cache_key}") + self._cache_key_to_index[cache_key] = index + self._sample_id_to_index[entry.sample_id] = index + + def __len__(self) -> int: + return len(self.entries) + + def _video_path_and_verify_stat( + self, + entry: OpenVidManifestEntry, + ) -> Path: + path = self.video_root / entry.video_name + if not path.is_file(): + raise FileNotFoundError(path) + stat = path.stat() + actual = (int(stat.st_size), int(stat.st_mtime_ns)) + expected = (entry.video_size_bytes, entry.video_mtime_ns) + if actual != expected: + raise ValueError( + "OpenVid video stat changed after manifest freeze for " + f"{entry.video_name}: actual={actual}, expected={expected}" + ) + return path + + def cache_key_for_sample(self, sample_id: str) -> str: + try: + entry = self.entries[self._sample_id_to_index[str(sample_id)]] + except KeyError as exc: + raise KeyError(f"unknown OpenVid sample_id: {sample_id!r}") from exc + return self.feature_cache.key_for(entry) + + def __getitem__(self, index: int) -> dict[str, Any]: + entry = self.entries[int(index)] + video_path = self._video_path_and_verify_stat(entry) + cache_key = self.feature_cache.key_for(entry) + cached = self.feature_cache.load(entry, cache_key=cache_key) + item: dict[str, Any] = { + "idx": entry.manifest_index, + "prompts": entry.caption, + "sample_id": entry.sample_id, + "teacher_cache_key": cache_key, + "video_name": entry.video_name, + "video_path": str(video_path), + } + if cached is not None: + item["current_teacher_cache_hit"] = True + item["current_teacher_decode_seconds"] = 0.0 + item["current_teacher_features"] = cached + return item + + decode_started = time.perf_counter() + decoded = self.decode_fn(video_path, self.decoder_contract) + decode_seconds = time.perf_counter() - decode_started + if not isinstance(decoded, DecodedOpenVidVideo): + raise TypeError("decode_fn must return DecodedOpenVidVideo") + expected_frames = int(self.decoder_contract["num_frames"]) + source_frame_count = int(decoded.source_frame_count) + expected_start = (source_frame_count - expected_frames) // 2 + expected_indices = torch.arange( + expected_start, + expected_start + expected_frames, + dtype=torch.long, + ) + if ( + source_frame_count < expected_frames + or not isinstance(decoded.frame_indices, torch.Tensor) + or decoded.frame_indices.dtype != torch.long + or not torch.equal( + decoded.frame_indices.detach().cpu(), expected_indices) + ): + raise ValueError( + "decode_fn violated the central-contiguous frame-index " + "contract" + ) + video = decoded.video01 + if ( + not isinstance(video, torch.Tensor) + or video.dtype != torch.float32 + or video.ndim != 4 + or tuple(video.shape[:2]) != (expected_frames, 3) + or not bool(torch.isfinite(video).all().item()) + or float(video.min()) < 0.0 + or float(video.max()) > 1.0 + ): + raise ValueError( + "decode_fn returned invalid video01; expected finite FP32 " + f"[{expected_frames},3,H,W] in [0,1]" + ) + item.update({ + "current_teacher_cache_hit": False, + "current_teacher_decode_seconds": decode_seconds, + "current_teacher_frame_indices": decoded.frame_indices, + "current_teacher_source_frame_count": source_frame_count, + "current_teacher_video01": video.contiguous(), + }) + return item + + def write_feature_cache( + self, + cache_key: str, + feature: torch.Tensor, + *, + sample_id: str, + ) -> Path: + """Atomically publish one extracted teacher feature by issued key.""" + + try: + index = self._cache_key_to_index[str(cache_key)] + except KeyError as exc: + raise KeyError(f"unknown teacher cache key: {cache_key!r}") from exc + entry = self.entries[index] + if entry.sample_id != str(sample_id): + raise ValueError( + "sample_id does not own teacher cache key: " + f"{sample_id!r} != {entry.sample_id!r}" + ) + self._video_path_and_verify_stat(entry) + return self.feature_cache.write( + entry, + feature, + cache_key=str(cache_key), + ) + + @staticmethod + def collate_fn(batch: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + return collate_openvid_prompt_video(batch) + + +def collate_openvid_prompt_video( + batch: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Collate the cache-hit/cache-miss union used by batch-size-one training.""" + + if not batch: + raise ValueError("cannot collate an empty OpenVid batch") + hits = torch.tensor( + [bool(item["current_teacher_cache_hit"]) for item in batch], + dtype=torch.bool, + ) + result: dict[str, Any] = { + "idx": torch.tensor([int(item["idx"]) for item in batch]), + "prompts": [str(item["prompts"]) for item in batch], + "sample_ids": [str(item["sample_id"]) for item in batch], + "teacher_cache_keys": [ + str(item["teacher_cache_key"]) for item in batch + ], + "video_names": [str(item["video_name"]) for item in batch], + "video_paths": [str(item["video_path"]) for item in batch], + "current_teacher_cache_hit": hits, + "current_teacher_decode_seconds": sum( + float(item.get("current_teacher_decode_seconds", 0.0)) + for item in batch + ), + } + if bool(hits.all().item()): + result["current_teacher_features"] = torch.stack([ + item["current_teacher_features"] for item in batch + ]) + return result + if bool((~hits).all().item()): + videos = [item["current_teacher_video01"] for item in batch] + shapes = {tuple(video.shape) for video in videos} + if len(shapes) != 1: + raise ValueError( + "OpenVid raw videos have different spatial shapes; this " + "collate path requires batch_size=1" + ) + result.update({ + "current_teacher_frame_indices": torch.stack([ + item["current_teacher_frame_indices"] for item in batch + ]), + "current_teacher_source_frame_count": torch.tensor([ + int(item["current_teacher_source_frame_count"]) + for item in batch + ]), + "current_teacher_video01": torch.stack(videos), + }) + return result + raise ValueError( + "mixed OpenVid teacher cache hits/misses cannot be collated; use the " + "configured batch_size=1 while lazily populating the cache" + ) + + +__all__ = [ + "DecodedOpenVidVideo", + "OpenVidPromptVideoDataset", + "collate_openvid_prompt_video", + "decode_openvid_center_contiguous_pyav", +] diff --git a/14B/code/utils/openvid_feature_cache.py b/14B/code/utils/openvid_feature_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..9f2eaccdf7c6934216f5f6ab21fae4b3c04e5580 --- /dev/null +++ b/14B/code/utils/openvid_feature_cache.py @@ -0,0 +1,410 @@ +"""Strict, per-sample safetensors cache for OpenVid teacher features.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import tempfile +from typing import Any, Mapping + +from safetensors import safe_open +from safetensors.torch import save_file +import torch + +from utils.openvid_manifest import OpenVidManifestEntry + + +CACHE_FORMAT = "sf_openvid_vjepa2_teacher_feature" +CACHE_FORMAT_VERSION = 1 +CACHE_METADATA_KEY = "sf_openvid_teacher_feature" +CACHE_TENSOR_KEY = "features" + +# This is the exact geometry/identity contract carried by the uploaded layer-15 +# Nyström reference. Model *weights* are identified independently by their +# SHA-256 so replacing a checkpoint cannot alias an existing cache entry. +OPENVID_VJEPA2_L15_FEATURE_CONTRACT: dict[str, Any] = { + "adapter_contract_version": 1, + "contract_version": 1, + "crop_size": 256, + "feature_dim": 1024, + "feature_type": "vjepa2_dense_latent", + "frame_sampling": "center_contiguous", + "interpolation": "bilinear_antialias", + "layer_index": 15, + "model_family": "vjepa2-vitl-fpc64-256", + "model_config_sha256": ( + "3dec96fe962e94e569182d3a7b9ef0dd74b6b8c89c337a428e43e10d593e70c9" + ), + "normalization": "imagenet_default", + "num_frames": 64, + "num_tokens": 1024, + "output_dtype": "float32", + "output_shape": [1024, 1024], + "processor_config_sha256": ( + "d2fab4418fc0390b62c4cd72ade56908a7929f80c62288adbe10dd8d23421227" + ), + "resize_short_side": 292, + "sample_axis": "video", + "token_alignment": "same_deterministic_indices", + "token_indices_sha256": ( + "a77ee1f9d20cba6b2f2a9a971c3d36610ca7f8aa2a55c616192582665be69d10" + ), + "token_sampling": "uniform_linspace_round", + "transformers_version": "4.53.3", +} + +OPENVID_PYAV_DECODER_CONTRACT: dict[str, Any] = { + "backend": "pyav", + "backend_version": "13.1.0", + "center_tie_break": "floor", + "contract_version": 1, + "frame_count_source": "container_decode_order_with_stream_frames_hint", + "num_frames": 64, + "output_dtype": "float32", + "output_layout": "TCHW", + "output_range": [0.0, 1.0], + "pixel_format": "rgb24", + "selection": "center_contiguous", +} + + +class OpenVidFeatureCacheValidationError(ValueError): + """A cache file exists but does not satisfy its immutable contract.""" + + +def canonical_json(value: Any) -> str: + return json.dumps( + value, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def canonical_sha256(value: Any) -> str: + return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +OPENVID_VJEPA2_L15_FEATURE_CONTRACT_SHA256 = canonical_sha256( + OPENVID_VJEPA2_L15_FEATURE_CONTRACT +) +OPENVID_PYAV_DECODER_CONTRACT_SHA256 = canonical_sha256( + OPENVID_PYAV_DECODER_CONTRACT +) + + +def _is_sha256(value: str) -> bool: + return ( + len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _entry_context( + entry: OpenVidManifestEntry | Mapping[str, Any], +) -> dict[str, Any]: + if isinstance(entry, OpenVidManifestEntry): + result = { + "sample_id": entry.sample_id, + "video_mtime_ns": int(entry.video_mtime_ns), + "video_name": entry.video_name, + "video_size_bytes": int(entry.video_size_bytes), + } + elif isinstance(entry, Mapping): + required = { + "sample_id", + "video_mtime_ns", + "video_name", + "video_size_bytes", + } + missing = required - set(entry) + if missing: + raise ValueError(f"cache sample context is missing {sorted(missing)}") + result = { + "sample_id": str(entry["sample_id"]), + "video_mtime_ns": int(entry["video_mtime_ns"]), + "video_name": str(entry["video_name"]), + "video_size_bytes": int(entry["video_size_bytes"]), + } + else: + raise TypeError("entry must be OpenVidManifestEntry or mapping") + if not result["sample_id"] or not result["video_name"]: + raise ValueError("cache sample/video identifiers must be non-empty") + if result["video_size_bytes"] <= 0 or result["video_mtime_ns"] <= 0: + raise ValueError("cache video stat must be positive") + return result + + +def _tensor_sha256(value: torch.Tensor) -> str: + value = value.detach().cpu().contiguous() + return hashlib.sha256(value.numpy().tobytes(order="C")).hexdigest() + + +class OpenVidFeatureCache: + """Lazy one-file-per-video FP32 cache with strict identity validation.""" + + def __init__( + self, + cache_root: str | os.PathLike, + *, + expected_model_sha256: str, + expected_feature_contract_sha256: str, + feature_contract: Mapping[str, Any] | None = None, + decoder_contract: Mapping[str, Any] | None = None, + expected_shape: tuple[int, int] | None = None, + ) -> None: + self.cache_root = Path(cache_root).expanduser().resolve() + model_sha256 = str(expected_model_sha256).lower() + if not _is_sha256(model_sha256): + raise ValueError("expected_model_sha256 must be lowercase SHA-256") + self.model_sha256 = model_sha256 + + self.feature_contract = json.loads(canonical_json( + feature_contract + if feature_contract is not None + else OPENVID_VJEPA2_L15_FEATURE_CONTRACT + )) + self.feature_contract_sha256 = canonical_sha256(self.feature_contract) + expected_contract_hash = str( + expected_feature_contract_sha256).lower() + if not _is_sha256(expected_contract_hash): + raise ValueError( + "expected_feature_contract_sha256 must be lowercase SHA-256" + ) + if self.feature_contract_sha256 != expected_contract_hash: + raise ValueError( + "feature contract SHA-256 mismatch: " + f"expected={expected_contract_hash}, " + f"actual={self.feature_contract_sha256}" + ) + + self.decoder_contract = json.loads(canonical_json( + decoder_contract + if decoder_contract is not None + else OPENVID_PYAV_DECODER_CONTRACT + )) + self.decoder_contract_sha256 = canonical_sha256( + self.decoder_contract) + contract_shape = tuple( + int(value) + for value in self.feature_contract.get("output_shape", ()) + ) + self.expected_shape = ( + tuple(int(value) for value in expected_shape) + if expected_shape is not None + else contract_shape + ) + if len(self.expected_shape) != 2 or min(self.expected_shape) <= 0: + raise ValueError("expected feature shape must be positive [N,D]") + if contract_shape and self.expected_shape != contract_shape: + raise ValueError( + "expected_shape must match feature_contract.output_shape" + ) + + def _key_payload( + self, + entry: OpenVidManifestEntry | Mapping[str, Any], + ) -> dict[str, Any]: + return { + "decoder_contract": self.decoder_contract, + "decoder_contract_sha256": self.decoder_contract_sha256, + "feature_contract": self.feature_contract, + "feature_contract_sha256": self.feature_contract_sha256, + "format": CACHE_FORMAT, + "format_version": CACHE_FORMAT_VERSION, + "model_sha256": self.model_sha256, + "sample": _entry_context(entry), + } + + def key_for( + self, + entry: OpenVidManifestEntry | Mapping[str, Any], + ) -> str: + payload = self._key_payload(entry) + digest = hashlib.sha256() + digest.update(b"sf-openvid-teacher-feature-cache\0") + digest.update(canonical_json(payload).encode("utf-8")) + return digest.hexdigest() + + def path_for_key(self, cache_key: str) -> Path: + cache_key = str(cache_key).lower() + if not _is_sha256(cache_key): + raise ValueError("cache_key must be lowercase SHA-256") + return ( + self.cache_root + / cache_key[:2] + / cache_key[2:4] + / f"{cache_key}.safetensors" + ) + + def _expected_metadata( + self, + entry: OpenVidManifestEntry | Mapping[str, Any], + cache_key: str, + tensor_sha256: str, + ) -> dict[str, Any]: + payload = self._key_payload(entry) + payload.update({ + "cache_key": cache_key, + "tensor": { + "dtype": "float32", + "key": CACHE_TENSOR_KEY, + "sha256": tensor_sha256, + "shape": list(self.expected_shape), + }, + }) + return payload + + def _validate_tensor(self, feature: torch.Tensor) -> torch.Tensor: + if not isinstance(feature, torch.Tensor): + raise TypeError("teacher feature must be a torch.Tensor") + if feature.ndim == 3 and int(feature.shape[0]) == 1: + feature = feature[0] + if feature.ndim != 2 or tuple(feature.shape) != self.expected_shape: + raise ValueError( + "teacher feature shape mismatch: " + f"{tuple(feature.shape)} != {self.expected_shape}" + ) + if not feature.is_floating_point(): + raise TypeError("teacher feature must be floating point") + feature = feature.detach().to( + device="cpu", dtype=torch.float32).contiguous() + if not bool(torch.isfinite(feature).all().item()): + raise FloatingPointError("teacher feature contains non-finite values") + return feature + + def load( + self, + entry: OpenVidManifestEntry | Mapping[str, Any], + *, + cache_key: str | None = None, + ) -> torch.Tensor | None: + expected_key = self.key_for(entry) + if cache_key is not None and str(cache_key) != expected_key: + raise OpenVidFeatureCacheValidationError( + "caller cache_key does not match sample/model/contracts" + ) + path = self.path_for_key(expected_key) + if not path.exists(): + return None + if not path.is_file(): + raise OpenVidFeatureCacheValidationError( + f"cache path is not a regular file: {path}" + ) + try: + with safe_open(path, framework="pt", device="cpu") as handle: + if list(handle.keys()) != [CACHE_TENSOR_KEY]: + raise OpenVidFeatureCacheValidationError( + f"cache tensor keys mismatch in {path}" + ) + header = handle.metadata() or {} + serialized = header.get(CACHE_METADATA_KEY) + if serialized is None: + raise OpenVidFeatureCacheValidationError( + f"cache metadata is missing in {path}" + ) + metadata = json.loads(serialized) + # Own the storage after ``safe_open`` closes its mmap. + feature = handle.get_tensor(CACHE_TENSOR_KEY).clone() + except OpenVidFeatureCacheValidationError: + raise + except Exception as exc: + raise OpenVidFeatureCacheValidationError( + f"failed to read safetensors cache {path}: {exc}" + ) from exc + + feature = self._validate_tensor(feature) + tensor_hash = _tensor_sha256(feature) + expected_metadata = self._expected_metadata( + entry, expected_key, tensor_hash) + if metadata != expected_metadata: + raise OpenVidFeatureCacheValidationError( + f"cache metadata/identity mismatch in {path}" + ) + return feature + + def write( + self, + entry: OpenVidManifestEntry | Mapping[str, Any], + feature: torch.Tensor, + *, + cache_key: str | None = None, + overwrite: bool = False, + ) -> Path: + expected_key = self.key_for(entry) + if cache_key is not None and str(cache_key) != expected_key: + raise OpenVidFeatureCacheValidationError( + "caller cache_key does not match sample/model/contracts" + ) + feature = self._validate_tensor(feature) + tensor_hash = _tensor_sha256(feature) + destination = self.path_for_key(expected_key) + if destination.exists() and not overwrite: + existing = self.load(entry, cache_key=expected_key) + if _tensor_sha256(existing) != tensor_hash: + raise OpenVidFeatureCacheValidationError( + "refusing to replace a valid cache entry with different " + f"features: {destination}" + ) + return destination + + destination.parent.mkdir(parents=True, exist_ok=True) + metadata = self._expected_metadata(entry, expected_key, tensor_hash) + descriptor, temporary_name = tempfile.mkstemp( + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + # safetensors refuses to overwrite an already-open descriptor, so + # remove the reserved empty name and let it create the payload. + temporary.unlink() + save_file( + {CACHE_TENSOR_KEY: feature}, + str(temporary), + metadata={CACHE_METADATA_KEY: canonical_json(metadata)}, + ) + with temporary.open("rb") as handle: + os.fsync(handle.fileno()) + if overwrite: + os.replace(temporary, destination) + else: + try: + # A hard link publishes a fully-written inode without ever + # exposing a partial destination and without clobbering a + # concurrent writer. + os.link(temporary, destination) + except FileExistsError: + existing = self.load(entry, cache_key=expected_key) + if _tensor_sha256(existing) != tensor_hash: + raise OpenVidFeatureCacheValidationError( + "concurrent cache writer produced different " + f"features for {expected_key}" + ) + temporary.unlink(missing_ok=True) + finally: + temporary.unlink(missing_ok=True) + # Re-open through the strict reader before reporting success. + self.load(entry, cache_key=expected_key) + return destination + + +__all__ = [ + "CACHE_FORMAT", + "CACHE_FORMAT_VERSION", + "CACHE_METADATA_KEY", + "CACHE_TENSOR_KEY", + "OPENVID_PYAV_DECODER_CONTRACT", + "OPENVID_PYAV_DECODER_CONTRACT_SHA256", + "OPENVID_VJEPA2_L15_FEATURE_CONTRACT", + "OPENVID_VJEPA2_L15_FEATURE_CONTRACT_SHA256", + "OpenVidFeatureCache", + "OpenVidFeatureCacheValidationError", + "canonical_json", + "canonical_sha256", +] diff --git a/14B/code/utils/openvid_manifest.py b/14B/code/utils/openvid_manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..e3f87623f76af94cd75e3fef3eae0973f3e7effe --- /dev/null +++ b/14B/code/utils/openvid_manifest.py @@ -0,0 +1,393 @@ +"""Deterministic OpenVid prompt/video manifest construction and validation. + +The training subset is selected only from the official part mapping. Caption +metadata is joined by the OpenVid video identifier (the MP4 filename), never by +caption text: captions are not unique in OpenVid. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import csv +import hashlib +import json +import os +from pathlib import Path +import tempfile +from typing import Any, Iterable, Mapping, Sequence + + +MANIFEST_FORMAT = "sf_openvid_prompt_video_manifest" +MANIFEST_FORMAT_VERSION = 1 +DEFAULT_SELECTION_SEED = "0" + + +def _canonical_json(value: Any) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def sha256_file(path: str | os.PathLike, chunk_size: int = 8 << 20) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while chunk := handle.read(int(chunk_size)): + digest.update(chunk) + return digest.hexdigest() + + +def _selection_digest(seed: str, video_name: str) -> str: + digest = hashlib.sha256() + digest.update(seed.encode("utf-8")) + digest.update(b"\0") + digest.update(video_name.encode("utf-8")) + return digest.hexdigest() + + +def _manifest_meta_path(manifest_path: Path) -> Path: + if manifest_path.suffix == ".jsonl": + return manifest_path.with_suffix(".meta.json") + return manifest_path.with_name(manifest_path.name + ".meta.json") + + +def _atomic_write_bytes(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +@dataclass(frozen=True) +class OpenVidManifestEntry: + """One immutable prompt/video identity from a frozen manifest.""" + + manifest_index: int + sample_id: str + video_name: str + caption: str + video_size_bytes: int + video_mtime_ns: int + selection_sha256: str + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "OpenVidManifestEntry": + required = { + "manifest_index", + "sample_id", + "video_name", + "caption", + "video_size_bytes", + "video_mtime_ns", + "selection_sha256", + } + missing = required - set(value) + unexpected = set(value) - required + if missing or unexpected: + raise ValueError( + "OpenVid manifest entry schema mismatch: " + f"missing={sorted(missing)}, unexpected={sorted(unexpected)}" + ) + entry = cls( + manifest_index=int(value["manifest_index"]), + sample_id=str(value["sample_id"]), + video_name=str(value["video_name"]), + caption=str(value["caption"]), + video_size_bytes=int(value["video_size_bytes"]), + video_mtime_ns=int(value["video_mtime_ns"]), + selection_sha256=str(value["selection_sha256"]), + ) + entry.validate() + return entry + + def validate(self) -> None: + if self.manifest_index < 0: + raise ValueError("manifest_index must be non-negative") + if not self.sample_id or self.sample_id != self.video_name: + raise ValueError( + "sample_id must be the non-empty OpenVid video identifier" + ) + if Path(self.video_name).name != self.video_name: + raise ValueError("video_name must be a basename") + if not self.video_name.lower().endswith(".mp4"): + raise ValueError("video_name must identify an MP4") + if not self.caption.strip(): + raise ValueError("caption must be non-empty") + if self.video_size_bytes <= 0 or self.video_mtime_ns <= 0: + raise ValueError("video stat must contain positive size and mtime") + if not _is_sha256(self.selection_sha256): + raise ValueError("selection_sha256 must be lowercase SHA-256") + + def to_dict(self) -> dict[str, Any]: + return { + "caption": self.caption, + "manifest_index": self.manifest_index, + "sample_id": self.sample_id, + "selection_sha256": self.selection_sha256, + "video_mtime_ns": self.video_mtime_ns, + "video_name": self.video_name, + "video_size_bytes": self.video_size_bytes, + } + + +def _is_sha256(value: str) -> bool: + return ( + len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _read_part_video_names(mapping_csv: Path, expected_zip: str) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + with mapping_csv.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + required = {"zip_file", "video"} + if reader.fieldnames is None or not required.issubset(reader.fieldnames): + raise ValueError( + f"mapping CSV must contain {sorted(required)}; " + f"got {reader.fieldnames}" + ) + for row_number, row in enumerate(reader, start=2): + if row["zip_file"] != expected_zip: + raise ValueError( + f"unexpected zip_file at mapping row {row_number}: " + f"{row['zip_file']!r} != {expected_zip!r}" + ) + name = row["video"].strip() + if not name or Path(name).name != name: + raise ValueError( + f"invalid video identifier at mapping row {row_number}" + ) + if name in seen: + raise ValueError(f"duplicate mapping video identifier: {name}") + seen.add(name) + names.append(name) + if not names: + raise ValueError("mapping CSV is empty") + return names + + +def _join_captions( + metadata_csv: Path, + video_names: Sequence[str], +) -> dict[str, str]: + wanted = set(video_names) + captions: dict[str, str] = {} + with metadata_csv.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + required = {"video", "caption"} + if reader.fieldnames is None or not required.issubset(reader.fieldnames): + raise ValueError( + f"metadata CSV must contain {sorted(required)}; " + f"got {reader.fieldnames}" + ) + for row_number, row in enumerate(reader, start=2): + name = row["video"].strip() + if name not in wanted: + continue + if name in captions: + raise ValueError( + "metadata CSV contains duplicate rows for selected video " + f"{name!r} (second occurrence at row {row_number})" + ) + caption = row["caption"].strip() + if not caption: + raise ValueError(f"empty caption for selected video {name!r}") + captions[name] = caption + missing = sorted(wanted - set(captions)) + if missing: + preview = ", ".join(repr(value) for value in missing[:5]) + raise ValueError( + f"metadata CSV is missing {len(missing)} mapped videos: {preview}" + ) + return captions + + +def _serialize_entries(entries: Iterable[OpenVidManifestEntry]) -> bytes: + return ( + "".join(_canonical_json(entry.to_dict()) + "\n" for entry in entries) + .encode("utf-8") + ) + + +def build_openvid_part_manifest( + *, + mapping_csv: str | os.PathLike, + metadata_csv: str | os.PathLike, + video_root: str | os.PathLike, + manifest_path: str | os.PathLike, + target_count: int = 12_800, + seed: str = DEFAULT_SELECTION_SEED, + expected_zip: str = "OpenVid_part1.zip", +) -> dict[str, Any]: + """Build and atomically publish a deterministic, stat-frozen manifest.""" + + mapping_csv = Path(mapping_csv).expanduser().resolve() + metadata_csv = Path(metadata_csv).expanduser().resolve() + video_root = Path(video_root).expanduser().resolve() + manifest_path = Path(manifest_path).expanduser().resolve() + target_count = int(target_count) + if target_count <= 0: + raise ValueError("target_count must be positive") + if not seed: + raise ValueError("selection seed must be non-empty") + for source in (mapping_csv, metadata_csv): + if not source.is_file(): + raise FileNotFoundError(source) + if not video_root.is_dir(): + raise NotADirectoryError(video_root) + + video_names = _read_part_video_names(mapping_csv, expected_zip) + if target_count > len(video_names): + raise ValueError( + f"target_count={target_count} exceeds mapped videos={len(video_names)}" + ) + captions = _join_captions(metadata_csv, video_names) + ranked = sorted( + ((_selection_digest(seed, name), name) for name in video_names), + key=lambda item: (item[0], item[1]), + )[:target_count] + + entries: list[OpenVidManifestEntry] = [] + for manifest_index, (selection_hash, video_name) in enumerate(ranked): + path = video_root / video_name + if not path.is_file(): + raise FileNotFoundError( + f"mapped OpenVid video is absent from video_root: {path}" + ) + stat = path.stat() + entry = OpenVidManifestEntry( + manifest_index=manifest_index, + sample_id=video_name, + video_name=video_name, + caption=captions[video_name], + video_size_bytes=int(stat.st_size), + video_mtime_ns=int(stat.st_mtime_ns), + selection_sha256=selection_hash, + ) + entry.validate() + entries.append(entry) + + manifest_payload = _serialize_entries(entries) + manifest_sha256 = hashlib.sha256(manifest_payload).hexdigest() + selected_identity_sha256 = hashlib.sha256( + b"".join( + bytes.fromhex(entry.selection_sha256) + b"\0" + + entry.video_name.encode("utf-8") + b"\0" + for entry in entries + ) + ).hexdigest() + metadata = { + "format": MANIFEST_FORMAT, + "format_version": MANIFEST_FORMAT_VERSION, + "manifest_file": manifest_path.name, + "manifest_sha256": manifest_sha256, + "mapped_video_count": len(video_names), + "selected_identity_sha256": selected_identity_sha256, + "selected_video_count": len(entries), + "selection": { + "algorithm": "sort_sha256(seed_utf8 + NUL + video_name_utf8)", + "seed": seed, + "tie_breaker": "video_name_utf8", + "take": target_count, + }, + "source": { + "expected_zip": expected_zip, + "mapping_csv": str(mapping_csv), + "mapping_csv_sha256": sha256_file(mapping_csv), + "metadata_csv": str(metadata_csv), + "metadata_csv_sha256": sha256_file(metadata_csv), + "video_root": str(video_root), + }, + } + metadata_payload = ( + json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2) + + "\n" + ).encode("utf-8") + _atomic_write_bytes(manifest_path, manifest_payload) + _atomic_write_bytes(_manifest_meta_path(manifest_path), metadata_payload) + return metadata + + +def load_openvid_manifest( + manifest_path: str | os.PathLike, +) -> tuple[list[OpenVidManifestEntry], dict[str, Any]]: + """Load a frozen manifest and strictly verify its sidecar checksum.""" + + manifest_path = Path(manifest_path).expanduser().resolve() + meta_path = _manifest_meta_path(manifest_path) + if not manifest_path.is_file(): + raise FileNotFoundError(manifest_path) + if not meta_path.is_file(): + raise FileNotFoundError(meta_path) + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + if metadata.get("format") != MANIFEST_FORMAT: + raise ValueError("unsupported OpenVid manifest format") + if int(metadata.get("format_version", -1)) != MANIFEST_FORMAT_VERSION: + raise ValueError("unsupported OpenVid manifest format_version") + if metadata.get("manifest_file") != manifest_path.name: + raise ValueError("manifest sidecar points to a different filename") + actual_hash = sha256_file(manifest_path) + expected_hash = metadata.get("manifest_sha256") + if not _is_sha256(str(expected_hash)) or actual_hash != expected_hash: + raise ValueError( + "OpenVid manifest SHA-256 mismatch: " + f"expected={expected_hash}, actual={actual_hash}" + ) + + entries: list[OpenVidManifestEntry] = [] + seen_sample_ids: set[str] = set() + seen_video_names: set[str] = set() + previous_rank: tuple[str, str] | None = None + with manifest_path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + raise ValueError(f"blank manifest line {line_number}") + entry = OpenVidManifestEntry.from_mapping(json.loads(line)) + if entry.manifest_index != len(entries): + raise ValueError( + f"non-contiguous manifest_index at line {line_number}" + ) + rank = (entry.selection_sha256, entry.video_name) + if previous_rank is not None and rank <= previous_rank: + raise ValueError("manifest is not in deterministic selection order") + previous_rank = rank + if entry.sample_id in seen_sample_ids: + raise ValueError(f"duplicate sample_id: {entry.sample_id}") + if entry.video_name in seen_video_names: + raise ValueError(f"duplicate video_name: {entry.video_name}") + seen_sample_ids.add(entry.sample_id) + seen_video_names.add(entry.video_name) + entries.append(entry) + expected_count = int(metadata.get("selected_video_count", -1)) + if len(entries) != expected_count: + raise ValueError( + f"manifest row count mismatch: {len(entries)} != {expected_count}" + ) + return entries, metadata + + +__all__ = [ + "DEFAULT_SELECTION_SEED", + "MANIFEST_FORMAT", + "MANIFEST_FORMAT_VERSION", + "OpenVidManifestEntry", + "build_openvid_part_manifest", + "load_openvid_manifest", + "sha256_file", +] diff --git a/14B/code/utils/openvid_reference_ledger.py b/14B/code/utils/openvid_reference_ledger.py new file mode 100644 index 0000000000000000000000000000000000000000..b7b8b749a8aa03f77c6ac00ede73c30ff78526c3 --- /dev/null +++ b/14B/code/utils/openvid_reference_ledger.py @@ -0,0 +1,314 @@ +"""Immutable OpenVid row ledger shared by frozen video references. + +Unlike prompt datasets, a feature-reference ledger retains videos whose caption +is empty. Feature rows are identified exclusively by the contiguous source +sequence and MP4 basename; captions are descriptive payloads, never keys. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +import tempfile +from typing import Iterable, Mapping + + +LEDGER_KIND = "openvid_video_reference_ledger" +LEDGER_SCHEMA_VERSION = 1 + + +def file_sha256(path: str | os.PathLike, chunk_size: int = 8 << 20) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while block := handle.read(int(chunk_size)): + digest.update(block) + return digest.hexdigest() + + +def canonical_sha256(value) -> str: + payload = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _atomic_bytes(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +@dataclass(frozen=True) +class OpenVidReferenceRow: + sequence: int + role: str + video_name: str + caption: str + video_size_bytes: int + video_mtime_ns: int + + def validate(self, *, anchor_cohort_size: int) -> None: + if self.sequence < 0: + raise ValueError("OpenVid reference sequence must be non-negative") + expected_role = ( + "anchor" if self.sequence < int(anchor_cohort_size) else "reference") + if self.role != expected_role: + raise ValueError( + f"row {self.sequence} role {self.role!r} != {expected_role!r}") + if ( + Path(self.video_name).name != self.video_name + or not self.video_name.lower().endswith(".mp4") + ): + raise ValueError(f"invalid MP4 basename: {self.video_name!r}") + if self.video_size_bytes <= 0 or self.video_mtime_ns <= 0: + raise ValueError("video size/mtime identity must be positive") + + def to_dict(self) -> dict: + return { + "caption": self.caption, + "role": self.role, + "sequence": self.sequence, + "video_mtime_ns": self.video_mtime_ns, + "video_name": self.video_name, + "video_size_bytes": self.video_size_bytes, + } + + @classmethod + def from_mapping( + cls, value: Mapping, *, anchor_cohort_size: int + ) -> "OpenVidReferenceRow": + row = cls( + sequence=int(value["sequence"]), + role=str(value["role"]), + video_name=str(value["video_name"]), + caption=str(value.get("caption", "")), + video_size_bytes=int(value["video_size_bytes"]), + video_mtime_ns=int(value["video_mtime_ns"]), + ) + if set(value) != set(row.to_dict()): + raise ValueError("OpenVid reference row schema mismatch") + row.validate(anchor_cohort_size=anchor_cohort_size) + return row + + +class OpenVidReferencePromptDataset: + """Prompt-only view of a frozen feature ledger. + + The nine captionless videos remain in the 77,615-row feature reference but + are intentionally absent from text-conditioned student sampling. + """ + + def __init__( + self, + ledger_path: str | os.PathLike, + *, + expected_count: int = 77_615, + ) -> None: + rows, metadata = load_openvid_reference_ledger( + ledger_path, expected_count=expected_count) + self.metadata = metadata + self.rows = tuple(row for row in rows if row.caption.strip()) + skipped = len(rows) - len(self.rows) + if skipped != int(metadata["caption_empty_count"]): + raise ValueError("OpenVid empty-caption ledger count mismatch") + if not self.rows: + raise ValueError("OpenVid ledger has no usable prompts") + + def __len__(self) -> int: + return len(self.rows) + + def __getitem__(self, index: int) -> dict: + row = self.rows[int(index)] + return { + "idx": row.sequence, + "prompts": row.caption, + "sample_ids": row.video_name, + } + + +def _video_name_sequence_sha256(rows: Iterable[OpenVidReferenceRow]) -> str: + digest = hashlib.sha256() + for row in rows: + digest.update(row.video_name.encode("utf-8")) + digest.update(b"\n") + return digest.hexdigest() + + +def _local_source_identity_sha256(rows: Iterable[OpenVidReferenceRow]) -> str: + digest = hashlib.sha256() + for row in rows: + digest.update(row.video_name.encode("utf-8")) + digest.update(b"\0") + digest.update(str(row.video_size_bytes).encode("ascii")) + digest.update(b"\0") + digest.update(str(row.video_mtime_ns).encode("ascii")) + digest.update(b"\0") + return digest.hexdigest() + + +def freeze_openvid_reference_ledger( + *, + source_csv: str | os.PathLike, + video_root: str | os.PathLike, + ledger_path: str | os.PathLike, + expected_count: int = 77_615, + anchor_cohort_size: int = 1_024, +) -> dict: + """Validate the CSV/video mirror and atomically publish JSONL + metadata.""" + source_csv = Path(source_csv).expanduser().resolve(strict=True) + video_root = Path(video_root).expanduser().resolve(strict=True) + ledger_path = Path(ledger_path).expanduser().resolve() + expected_count = int(expected_count) + anchor_cohort_size = int(anchor_cohort_size) + if not video_root.is_dir(): + raise NotADirectoryError(video_root) + if not 0 < anchor_cohort_size <= expected_count: + raise ValueError("invalid OpenVid anchor cohort size") + + rows: list[OpenVidReferenceRow] = [] + seen: set[str] = set() + with source_csv.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + required = {"sequence", "role", "video", "caption"} + if reader.fieldnames is None or not required.issubset(reader.fieldnames): + raise ValueError( + f"OpenVid reference CSV requires {sorted(required)}") + for csv_line, value in enumerate(reader, start=2): + try: + sequence = int(value["sequence"]) + except (TypeError, ValueError) as exc: + raise ValueError( + f"invalid sequence at CSV line {csv_line}") from exc + if sequence != len(rows): + raise ValueError( + "OpenVid sequence is not contiguous in CSV order: " + f"{sequence} != {len(rows)}") + video_name = str(value["video"]).strip() + if video_name in seen: + raise ValueError(f"duplicate OpenVid video: {video_name}") + video_path = video_root / video_name + if not video_path.is_file(): + raise FileNotFoundError(video_path) + stat = video_path.stat() + row = OpenVidReferenceRow( + sequence=sequence, + role=str(value["role"]).strip(), + video_name=video_name, + # Empty captions are valid reference rows. + caption=str(value["caption"] or ""), + video_size_bytes=int(stat.st_size), + video_mtime_ns=int(stat.st_mtime_ns), + ) + row.validate(anchor_cohort_size=anchor_cohort_size) + rows.append(row) + seen.add(video_name) + if len(rows) != expected_count: + raise ValueError( + f"OpenVid reference count {len(rows)} != {expected_count}") + + lines = "".join( + json.dumps( + row.to_dict(), ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False, + ) + "\n" + for row in rows + ).encode("utf-8") + manifest_sha256 = hashlib.sha256(lines).hexdigest() + metadata = { + "anchor_cohort_size": anchor_cohort_size, + "anchor_video_name_sequence_sha256": ( + _video_name_sequence_sha256(rows[:anchor_cohort_size])), + "caption_empty_count": sum(not row.caption.strip() for row in rows), + "csv_path": str(source_csv), + "csv_sha256": file_sha256(source_csv), + "kind": LEDGER_KIND, + "ledger_file": ledger_path.name, + "ledger_sha256": manifest_sha256, + "local_source_identity_sha256": ( + _local_source_identity_sha256(rows)), + "reference_count": len(rows), + "schema_version": LEDGER_SCHEMA_VERSION, + "video_name_sequence_sha256": _video_name_sequence_sha256(rows), + "video_root": str(video_root), + } + _atomic_bytes(ledger_path, lines) + _atomic_bytes( + ledger_path.with_suffix(".meta.json"), + (json.dumps( + metadata, ensure_ascii=False, sort_keys=True, indent=2, + allow_nan=False, + ) + "\n").encode("utf-8"), + ) + return metadata + + +def load_openvid_reference_ledger( + ledger_path: str | os.PathLike, + *, + expected_count: int | None = None, +) -> tuple[list[OpenVidReferenceRow], dict]: + path = Path(ledger_path).expanduser().resolve(strict=True) + meta_path = path.with_suffix(".meta.json") + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + if ( + metadata.get("kind") != LEDGER_KIND + or int(metadata.get("schema_version", -1)) != LEDGER_SCHEMA_VERSION + or metadata.get("ledger_file") != path.name + or metadata.get("ledger_sha256") != file_sha256(path) + ): + raise ValueError("OpenVid reference ledger metadata/hash mismatch") + anchor_count = int(metadata["anchor_cohort_size"]) + rows = [] + with path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + raise ValueError(f"blank OpenVid ledger line {line_number}") + row = OpenVidReferenceRow.from_mapping( + json.loads(line), anchor_cohort_size=anchor_count) + if row.sequence != len(rows): + raise ValueError("OpenVid ledger sequence is not contiguous") + rows.append(row) + if len(rows) != int(metadata.get("reference_count", -1)): + raise ValueError("OpenVid ledger row count mismatch") + if expected_count is not None and len(rows) != int(expected_count): + raise ValueError( + f"OpenVid ledger count {len(rows)} != {int(expected_count)}") + if ( + _video_name_sequence_sha256(rows) + != metadata.get("video_name_sequence_sha256") + or _video_name_sequence_sha256(rows[:anchor_count]) + != metadata.get("anchor_video_name_sequence_sha256") + or _local_source_identity_sha256(rows) + != metadata.get("local_source_identity_sha256") + ): + raise ValueError("OpenVid ledger row identity mismatch") + return rows, metadata + + +__all__ = [ + "LEDGER_KIND", + "LEDGER_SCHEMA_VERSION", + "OpenVidReferenceRow", + "OpenVidReferencePromptDataset", + "canonical_sha256", + "file_sha256", + "freeze_openvid_reference_ledger", + "load_openvid_reference_ledger", +] diff --git a/14B/code/utils/optional_wandb.py b/14B/code/utils/optional_wandb.py new file mode 100644 index 0000000000000000000000000000000000000000..2f02c87887b0e4e1239dd4cf007377ea6dd67e43 --- /dev/null +++ b/14B/code/utils/optional_wandb.py @@ -0,0 +1,19 @@ +"""Use real wandb when installed; allow explicitly disabled training without it.""" + +try: + import wandb as wandb +except ModuleNotFoundError as exc: + if exc.name != "wandb": + raise + + class _DisabledWandb: + @staticmethod + def finish(*args, **kwargs): + return None + + def __getattr__(self, name): + raise RuntimeError( + "wandb is not installed; launch with --disable-wandb" + ) + + wandb = _DisabledWandb() diff --git a/14B/code/utils/paths.py b/14B/code/utils/paths.py new file mode 100644 index 0000000000000000000000000000000000000000..2125a8997a88f105a28676c0fd32121d7d9ab44a --- /dev/null +++ b/14B/code/utils/paths.py @@ -0,0 +1,37 @@ +"""Centralized filesystem layout for Self-Forcing assets. + +The pretrained Wan model weights and shared training checkpoints live outside +the repository (on the large data partition). This module is the single +source of truth for those paths so callers do not have to hard-code them. + +Both can be overridden with environment variables for portability: + +* ``SELF_FORCING_MODELS_DIR`` (defaults to the shared SF asset directory) +* ``SELF_FORCING_CKPT_DIR`` (defaults to the shared SF asset directory) +""" + +from __future__ import annotations + +import os +from pathlib import Path + +_ASSET_ROOT = Path( + os.environ.get( + "SELF_FORCING_ASSET_ROOT", + "assets", + ) +) +MODELS_DIR = Path(os.environ.get("SELF_FORCING_MODELS_DIR", _ASSET_ROOT / "models")) +CKPT_DIR = Path( + os.environ.get("SELF_FORCING_CKPT_DIR", _ASSET_ROOT / "checkpoints") +) + + +def model_path(name: str) -> Path: + """Return the on-disk directory for a Wan-style pretrained model bundle.""" + return MODELS_DIR / name + + +def ckpt_path(*parts: str) -> Path: + """Return a path under the shared checkpoint directory.""" + return CKPT_DIR.joinpath(*parts) diff --git a/14B/code/utils/scheduler.py b/14B/code/utils/scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..b1e5163c9348868b154e05a42e51eefee5b7d089 --- /dev/null +++ b/14B/code/utils/scheduler.py @@ -0,0 +1,193 @@ +from abc import abstractmethod, ABC +import torch + + +class SchedulerInterface(ABC): + """ + Base class for diffusion noise schedule. + """ + alphas_cumprod: torch.Tensor # [T], alphas for defining the noise schedule + + @abstractmethod + def add_noise( + self, clean_latent: torch.Tensor, + noise: torch.Tensor, timestep: torch.Tensor + ): + """ + Diffusion forward corruption process. + Input: + - clean_latent: the clean latent with shape [B, C, H, W] + - noise: the noise with shape [B, C, H, W] + - timestep: the timestep with shape [B] + Output: the corrupted latent with shape [B, C, H, W] + """ + pass + + def convert_x0_to_noise( + self, x0: torch.Tensor, xt: torch.Tensor, + timestep: torch.Tensor + ) -> torch.Tensor: + """ + Convert the diffusion network's x0 prediction to noise predidction. + x0: the predicted clean data with shape [B, C, H, W] + xt: the input noisy data with shape [B, C, H, W] + timestep: the timestep with shape [B] + + noise = (xt-sqrt(alpha_t)*x0) / sqrt(beta_t) (eq 11 in https://arxiv.org/abs/2311.18828) + """ + # use higher precision for calculations + original_dtype = x0.dtype + x0, xt, alphas_cumprod = map( + lambda x: x.double().to(x0.device), [x0, xt, + self.alphas_cumprod] + ) + + alpha_prod_t = alphas_cumprod[timestep].reshape(-1, 1, 1, 1) + beta_prod_t = 1 - alpha_prod_t + + noise_pred = (xt - alpha_prod_t ** + (0.5) * x0) / beta_prod_t ** (0.5) + return noise_pred.to(original_dtype) + + def convert_noise_to_x0( + self, noise: torch.Tensor, xt: torch.Tensor, + timestep: torch.Tensor + ) -> torch.Tensor: + """ + Convert the diffusion network's noise prediction to x0 predidction. + noise: the predicted noise with shape [B, C, H, W] + xt: the input noisy data with shape [B, C, H, W] + timestep: the timestep with shape [B] + + x0 = (x_t - sqrt(beta_t) * noise) / sqrt(alpha_t) (eq 11 in https://arxiv.org/abs/2311.18828) + """ + # use higher precision for calculations + original_dtype = noise.dtype + noise, xt, alphas_cumprod = map( + lambda x: x.double().to(noise.device), [noise, xt, + self.alphas_cumprod] + ) + alpha_prod_t = alphas_cumprod[timestep].reshape(-1, 1, 1, 1) + beta_prod_t = 1 - alpha_prod_t + + x0_pred = (xt - beta_prod_t ** + (0.5) * noise) / alpha_prod_t ** (0.5) + return x0_pred.to(original_dtype) + + def convert_velocity_to_x0( + self, velocity: torch.Tensor, xt: torch.Tensor, + timestep: torch.Tensor + ) -> torch.Tensor: + """ + Convert the diffusion network's velocity prediction to x0 predidction. + velocity: the predicted noise with shape [B, C, H, W] + xt: the input noisy data with shape [B, C, H, W] + timestep: the timestep with shape [B] + + v = sqrt(alpha_t) * noise - sqrt(beta_t) x0 + noise = (xt-sqrt(alpha_t)*x0) / sqrt(beta_t) + given v, x_t, we have + x0 = sqrt(alpha_t) * x_t - sqrt(beta_t) * v + """ + # use higher precision for calculations + original_dtype = velocity.dtype + velocity, xt, alphas_cumprod = map( + lambda x: x.double().to(velocity.device), [velocity, xt, + self.alphas_cumprod] + ) + alpha_prod_t = alphas_cumprod[timestep].reshape(-1, 1, 1, 1) + beta_prod_t = 1 - alpha_prod_t + + x0_pred = (alpha_prod_t ** 0.5) * xt - (beta_prod_t ** 0.5) * velocity + return x0_pred.to(original_dtype) + + +class FlowMatchScheduler(): + + def __init__(self, num_inference_steps=100, num_train_timesteps=1000, shift=3.0, sigma_max=1.0, sigma_min=0.003 / 1.002, inverse_timesteps=False, extra_one_step=False, reverse_sigmas=False): + self.num_train_timesteps = num_train_timesteps + self.shift = shift + self.sigma_max = sigma_max + self.sigma_min = sigma_min + self.inverse_timesteps = inverse_timesteps + self.extra_one_step = extra_one_step + self.reverse_sigmas = reverse_sigmas + self.set_timesteps(num_inference_steps) + + def set_timesteps(self, num_inference_steps=100, denoising_strength=1.0, training=False): + sigma_start = self.sigma_min + \ + (self.sigma_max - self.sigma_min) * denoising_strength + if self.extra_one_step: + self.sigmas = torch.linspace( + sigma_start, self.sigma_min, num_inference_steps + 1)[:-1] + else: + self.sigmas = torch.linspace( + sigma_start, self.sigma_min, num_inference_steps) + if self.inverse_timesteps: + self.sigmas = torch.flip(self.sigmas, dims=[0]) + self.sigmas = self.shift * self.sigmas / \ + (1 + (self.shift - 1) * self.sigmas) + if self.reverse_sigmas: + self.sigmas = 1 - self.sigmas + self.timesteps = self.sigmas * self.num_train_timesteps + if training: + x = self.timesteps + y = torch.exp(-2 * ((x - num_inference_steps / 2) / + num_inference_steps) ** 2) + y_shifted = y - y.min() + bsmntw_weighing = y_shifted * \ + (num_inference_steps / y_shifted.sum()) + self.linear_timesteps_weights = bsmntw_weighing + + def step(self, model_output, timestep, sample, to_final=False): + if timestep.ndim == 2: + timestep = timestep.flatten(0, 1) + self.sigmas = self.sigmas.to(model_output.device) + self.timesteps = self.timesteps.to(model_output.device) + timestep_id = torch.argmin( + (self.timesteps.unsqueeze(0) - timestep.unsqueeze(1)).abs(), dim=1) + sigma = self.sigmas[timestep_id].reshape(-1, 1, 1, 1) + if to_final or (timestep_id + 1 >= len(self.timesteps)).any(): + sigma_ = 1 if ( + self.inverse_timesteps or self.reverse_sigmas) else 0 + else: + sigma_ = self.sigmas[timestep_id + 1].reshape(-1, 1, 1, 1) + prev_sample = sample + model_output * (sigma_ - sigma) + return prev_sample + + def add_noise(self, original_samples, noise, timestep): + """ + Diffusion forward corruption process. + Input: + - clean_latent: the clean latent with shape [B*T, C, H, W] + - noise: the noise with shape [B*T, C, H, W] + - timestep: the timestep with shape [B*T] + Output: the corrupted latent with shape [B*T, C, H, W] + """ + if timestep.ndim == 2: + timestep = timestep.flatten(0, 1) + self.sigmas = self.sigmas.to(noise.device) + self.timesteps = self.timesteps.to(noise.device) + timestep_id = torch.argmin( + (self.timesteps.unsqueeze(0) - timestep.unsqueeze(1)).abs(), dim=1) + sigma = self.sigmas[timestep_id].reshape(-1, 1, 1, 1) + sample = (1 - sigma) * original_samples + sigma * noise + return sample.type_as(noise) + + def training_target(self, sample, noise, timestep): + target = noise - sample + return target + + def training_weight(self, timestep): + """ + Input: + - timestep: the timestep with shape [B*T] + Output: the corresponding weighting [B*T] + """ + if timestep.ndim == 2: + timestep = timestep.flatten(0, 1) + self.linear_timesteps_weights = self.linear_timesteps_weights.to(timestep.device) + timestep_id = torch.argmin( + (self.timesteps.unsqueeze(1) - timestep.unsqueeze(0)).abs(), dim=0) + weights = self.linear_timesteps_weights[timestep_id] + return weights diff --git a/14B/code/utils/triple_video_feature_bank.py b/14B/code/utils/triple_video_feature_bank.py new file mode 100644 index 0000000000000000000000000000000000000000..5abfd6802ef97cb4657f144cf7316ee4ca472a56 --- /dev/null +++ b/14B/code/utils/triple_video_feature_bank.py @@ -0,0 +1,328 @@ +"""Strict mmap-backed teacher bank for DINOv3 + V-JEPA2 + VideoMAE-v2 features.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Mapping, Sequence + +import torch +from safetensors import safe_open +from torch.utils.data import Dataset +from utils.active_mmd_branches import active_mmd_branches +from utils.active_mmd_branches import NO_DINO_BRANCHES + + +BANK_KIND = "triple_dinov3_vjepa2_videomae_v2_matched_teacher_bank" +NO_DINO_BANK_KIND = "selected_vjepa2_videomae_v2_matched_teacher_bank" +BANK_SCHEMA_VERSION = 1 +BRANCH_SHAPES = { + "dinov3": (1024, 1024), + "vjepa2": (1024, 1024), + "videomae_v2": (1280, 1024), +} + + +def _canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def canonical_sha256(value: Any) -> str: + return hashlib.sha256(_canonical_bytes(value)).hexdigest() + + +def file_sha256(path: str | os.PathLike) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while True: + block = handle.read(8 * 1024 * 1024) + if not block: + break + digest.update(block) + return digest.hexdigest() + + +def _strict_sha256(value: object, label: str) -> str: + normalized = str(value) + if ( + len(normalized) != 64 + or any(character not in "0123456789abcdef" for character in normalized) + ): + raise ValueError(f"{label} must be a lowercase SHA-256") + return normalized + + +class TripleVideoFeatureBank(Dataset): + """Metadata dataset plus random-access frozen FP16 feature rows.""" + + def __init__( + self, + manifest_path: str | os.PathLike, + *, + expected_manifest_sha256: str, + expected_content_hash: str, + expected_source_manifest_sha256: str, + expected_sample_count: int = 5000, + expected_videomae_revision: str | None = None, + expected_videomae_download_manifest_content_hash: str | None = None, + expected_vjepa_model_sha256: str | None = None, + expected_dinov3_checkpoint_sha256: str | None = None, + expected_dinov3_source_commit: str | None = None, + expected_shard_size: int = 32, + validate_all_shards: bool = True, + active_branches: Sequence[str] | None = None, + ) -> None: + # Validate the original immutable bank, but materialize active rows only. + self.active_branches = active_mmd_branches(active_branches) + self.manifest_path = Path(manifest_path).expanduser().resolve() + if not self.manifest_path.is_file(): + raise FileNotFoundError(self.manifest_path) + actual_manifest_sha = file_sha256(self.manifest_path) + if actual_manifest_sha != _strict_sha256( + expected_manifest_sha256, "expected_manifest_sha256" + ): + raise ValueError("triple teacher bank manifest SHA-256 mismatch") + with self.manifest_path.open("r", encoding="utf-8") as handle: + manifest = json.load(handle) + if not isinstance(manifest, dict): + raise TypeError("triple teacher bank manifest must be an object") + required = { + "schema_version", + "kind", + "sample_count", + "source_manifest", + "branches", + "samples", + "shards", + "content_hash", + } + if set(manifest) != required: + raise ValueError( + "triple teacher bank manifest keys mismatch: " + f"{sorted(manifest)} != {sorted(required)}") + expected_kind = manifest.get("kind") + if expected_kind == NO_DINO_BANK_KIND: + if self.active_branches != NO_DINO_BRANCHES: + raise ValueError("two-branch bank requires explicit no-DINO active branches") + bank_shapes = {name: BRANCH_SHAPES[name] for name in NO_DINO_BRANCHES} + else: + bank_shapes = BRANCH_SHAPES + if ( + manifest["schema_version"] != BANK_SCHEMA_VERSION + or expected_kind not in (BANK_KIND, NO_DINO_BANK_KIND) + or int(manifest["sample_count"]) != int(expected_sample_count) + ): + raise ValueError("triple teacher bank kind/schema/sample count mismatch") + payload = dict(manifest) + content_hash = payload.pop("content_hash") + if canonical_sha256(payload) != content_hash: + raise ValueError("triple teacher bank content_hash is invalid") + if content_hash != _strict_sha256( + expected_content_hash, "expected_content_hash" + ): + raise ValueError("triple teacher bank content identity mismatch") + source = manifest["source_manifest"] + if source.get("sha256") != _strict_sha256( + expected_source_manifest_sha256, + "expected_source_manifest_sha256", + ): + raise ValueError("triple bank source manifest SHA-256 mismatch") + branches = manifest["branches"] + if set(branches) != set(bank_shapes): + raise ValueError("triple teacher bank branch set mismatch") + for name, expected_shape in bank_shapes.items(): + branch = branches[name] + if ( + tuple(branch.get("sample_shape", ())) != expected_shape + or branch.get("dtype") != "float16" + or not isinstance(branch.get("feature_contract"), Mapping) + or not isinstance(branch.get("model_identity"), Mapping) + ): + raise ValueError(f"triple teacher branch {name!r} contract mismatch") + if expected_videomae_revision is not None and ( + branches["videomae_v2"]["model_identity"].get("revision") + != str(expected_videomae_revision) + ): + raise ValueError("VideoMAE-v2 bank model revision mismatch") + if expected_videomae_download_manifest_content_hash is not None and ( + branches["videomae_v2"]["model_identity"].get( + "download_manifest_content_hash" + ) + != _strict_sha256( + expected_videomae_download_manifest_content_hash, + "expected_videomae_download_manifest_content_hash", + ) + ): + raise ValueError( + "VideoMAE-v2 bank download manifest identity mismatch") + if expected_vjepa_model_sha256 is not None and ( + branches["vjepa2"]["model_identity"].get("sha256") + != _strict_sha256( + expected_vjepa_model_sha256, + "expected_vjepa_model_sha256", + ) + ): + raise ValueError("V-JEPA2 bank model SHA-256 mismatch") + if "dinov3" not in branches and ( + expected_dinov3_checkpoint_sha256 is not None + or expected_dinov3_source_commit is not None + ): + raise ValueError("DINOv3 identity requested for a two-branch bank") + if expected_dinov3_checkpoint_sha256 is not None and ( + branches["dinov3"]["model_identity"].get("checkpoint_sha256") + != _strict_sha256( + expected_dinov3_checkpoint_sha256, + "expected_dinov3_checkpoint_sha256", + ) + ): + raise ValueError("DINOv3 bank checkpoint SHA-256 mismatch") + if expected_dinov3_source_commit is not None and ( + branches["dinov3"]["model_identity"].get("source_commit") + != str(expected_dinov3_source_commit) + ): + raise ValueError("DINOv3 bank source commit mismatch") + if type(expected_shard_size) is not int or expected_shard_size <= 0: + raise ValueError("expected_shard_size must be positive") + + samples = manifest["samples"] + if len(samples) != int(expected_sample_count): + raise ValueError("triple bank sample table length mismatch") + self.samples = [] + self._sample_index = {} + for expected_index, item in enumerate(samples): + if ( + not isinstance(item, dict) + or set(item) != {"index", "sample_id", "prompt"} + or int(item["index"]) != expected_index + or not isinstance(item["sample_id"], str) + or not item["sample_id"] + or not isinstance(item["prompt"], str) + ): + raise ValueError( + f"invalid triple bank sample record at {expected_index}") + if item["sample_id"] in self._sample_index: + raise ValueError("triple bank sample IDs must be unique") + self._sample_index[item["sample_id"]] = expected_index + self.samples.append(dict(item)) + + self.root = self.manifest_path.parent + self.shards = [] + self._row_to_shard: list[tuple[int, int]] = [None] * len(samples) + expected_start = 0 + for shard_index, item in enumerate(manifest["shards"]): + required_shard = { + "index", "path", "sample_start", "sample_end", + "sample_ids_sha256", "sha256", "content_hash", + } + if not isinstance(item, dict) or set(item) != required_shard: + raise ValueError(f"invalid shard record {shard_index}") + start = int(item["sample_start"]) + end = int(item["sample_end"]) + if ( + int(item["index"]) != shard_index + or start != expected_start + or end <= start + or end > len(samples) + or end - start != min(expected_shard_size, len(samples) - start) + ): + raise ValueError( + "triple bank shard ranges must be contiguous fixed-size " + f"chunks of {expected_shard_size}") + relative = Path(str(item["path"])) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("triple bank shard path must stay under bank root") + path = (self.root / relative).resolve() + if self.root not in path.parents or not path.is_file(): + raise FileNotFoundError(path) + ids = [samples[row]["sample_id"] for row in range(start, end)] + if canonical_sha256(ids) != item["sample_ids_sha256"]: + raise ValueError("triple bank shard sample ID hash mismatch") + if validate_all_shards and file_sha256(path) != item["sha256"]: + raise ValueError(f"triple bank shard SHA-256 mismatch: {path}") + with safe_open(str(path), framework="pt", device="cpu") as handle: + metadata = handle.metadata() or {} + if metadata.get("content_hash") != item["content_hash"]: + raise ValueError("triple bank shard embedded content hash mismatch") + if set(handle.keys()) != set(bank_shapes): + raise ValueError("teacher shard tensor branch set mismatch") + for name, shape in bank_shapes.items(): + tensor_shape = tuple(handle.get_slice(name).get_shape()) + if tensor_shape != (end - start, *shape): + raise ValueError( + f"triple bank shard tensor {name!r} shape mismatch") + for row in range(start, end): + self._row_to_shard[row] = (shard_index, row - start) + self.shards.append({**item, "resolved_path": str(path)}) + expected_start = end + if expected_start != len(samples): + raise ValueError("triple bank shards do not cover all samples") + self.manifest = manifest + self.content_hash = content_hash + self.manifest_sha256 = actual_manifest_sha + + def __len__(self) -> int: + return len(self.samples) + + def __getitem__(self, index: int) -> dict[str, object]: + item = self.samples[int(index)] + return { + "prompts": item["prompt"], + "sample_ids": item["sample_id"], + "teacher_bank_rows": int(item["index"]), + } + + @staticmethod + def collate_fn(items: Sequence[Mapping[str, object]]) -> dict[str, object]: + return { + "prompts": [str(item["prompts"]) for item in items], + "sample_ids": [str(item["sample_ids"]) for item in items], + "teacher_bank_rows": torch.tensor([ + int(item["teacher_bank_rows"]) for item in items + ], dtype=torch.long), + } + + def load_rows( + self, + sample_ids: Sequence[str], + ) -> dict[str, torch.Tensor]: + """Read only requested rows, preserving order and duplicate IDs.""" + ids = tuple(str(value) for value in sample_ids) + if not ids or any(value not in self._sample_index for value in ids): + raise KeyError("triple bank request contains an unknown sample ID") + output = {name: [None] * len(ids) for name in self.active_branches} + by_shard: dict[int, list[tuple[int, int]]] = {} + for output_index, sample_id in enumerate(ids): + row = self._sample_index[sample_id] + shard_index, offset = self._row_to_shard[row] + by_shard.setdefault(shard_index, []).append((output_index, offset)) + for shard_index, requests in by_shard.items(): + path = self.shards[shard_index]["resolved_path"] + with safe_open(path, framework="pt", device="cpu") as handle: + slices = {name: handle.get_slice(name) for name in self.active_branches} + for output_index, offset in requests: + for name in self.active_branches: + output[name][output_index] = slices[name][ + offset:offset + 1 + ].squeeze(0) + return { + name: torch.stack(rows, dim=0) + for name, rows in output.items() + } + + +__all__ = [ + "BANK_KIND", + "BANK_SCHEMA_VERSION", + "BRANCH_SHAPES", + "TripleVideoFeatureBank", + "canonical_sha256", + "file_sha256", +] diff --git a/14B/code/utils/wan_wrapper.py b/14B/code/utils/wan_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..64958e4ca8ab73b6beec0470a49015b12e77245d --- /dev/null +++ b/14B/code/utils/wan_wrapper.py @@ -0,0 +1,446 @@ +import types +from pathlib import Path +from typing import List, Optional +import torch +from torch import nn + +from utils.paths import model_path +from utils.scheduler import SchedulerInterface, FlowMatchScheduler +from wan.modules.tokenizers import HuggingfaceTokenizer +from wan.modules.model import WanModel, RegisterTokens, GanAttentionBlock +from wan.modules.vae import _video_vae +from wan.modules.t5 import umt5_xxl +from wan.modules.causal_model import CausalWanModel + +_DEFAULT_TEXT_ENCODER_NAME = "Wan2.1-T2V-14B" + + +class WanTextEncoder(torch.nn.Module): + def __init__(self, dtype: torch.dtype = torch.float32) -> None: + super().__init__() + + self.text_encoder = umt5_xxl( + encoder_only=True, + return_tokenizer=False, + dtype=dtype, + device=torch.device('cpu') + ).eval().requires_grad_(False) + encoder_root = model_path(_DEFAULT_TEXT_ENCODER_NAME) + self.text_encoder.load_state_dict( + torch.load(str(encoder_root / "models_t5_umt5-xxl-enc-bf16.pth"), + map_location='cpu', weights_only=False) + ) + + self.tokenizer = HuggingfaceTokenizer( + name=str(encoder_root / "google" / "umt5-xxl") + "/", + seq_len=512, clean='whitespace', + ) + + @property + def device(self): + # Assume we are always on GPU + return torch.cuda.current_device() + + def forward(self, text_prompts: List[str]) -> dict: + ids, mask = self.tokenizer( + text_prompts, return_mask=True, add_special_tokens=True) + ids = ids.to(self.device) + mask = mask.to(self.device) + seq_lens = mask.gt(0).sum(dim=1).long() + context = self.text_encoder(ids, mask) + + for u, v in zip(context, seq_lens): + u[v:] = 0.0 # set padding to 0.0 + + return { + "prompt_embeds": context + } + + +class WanVAEWrapper(torch.nn.Module): + def __init__(self, pretrained_path: str | Path | None = None): + super().__init__() + mean = [ + -0.7571, -0.7089, -0.9113, 0.1075, -0.1745, 0.9653, -0.1517, 1.5508, + 0.4134, -0.0715, 0.5517, -0.3632, -0.1922, -0.9497, 0.2503, -0.2921 + ] + std = [ + 2.8184, 1.4541, 2.3275, 2.6558, 1.2196, 1.7708, 2.6052, 2.0743, + 3.2687, 2.1526, 2.8652, 1.5579, 1.6382, 1.1253, 2.8251, 1.9160 + ] + self.mean = torch.tensor(mean, dtype=torch.float32) + self.std = torch.tensor(std, dtype=torch.float32) + + # init model + vae_path = ( + Path(pretrained_path).expanduser().resolve() + if pretrained_path is not None + else model_path(_DEFAULT_TEXT_ENCODER_NAME) / "Wan2.1_VAE.pth" + ) + if not vae_path.is_file(): + raise FileNotFoundError(f"Wan VAE checkpoint not found: {vae_path}") + self.model = _video_vae( + pretrained_path=str(vae_path), + z_dim=16, + ).eval().requires_grad_(False) + + def encode_to_latent(self, pixel: torch.Tensor) -> torch.Tensor: + # pixel: [batch_size, num_channels, num_frames, height, width] + device, dtype = pixel.device, pixel.dtype + scale = [self.mean.to(device=device, dtype=dtype), + 1.0 / self.std.to(device=device, dtype=dtype)] + + output = [ + self.model.encode(u.unsqueeze(0), scale).float().squeeze(0) + for u in pixel + ] + output = torch.stack(output, dim=0) + # from [batch_size, num_channels, num_frames, height, width] + # to [batch_size, num_frames, num_channels, height, width] + output = output.permute(0, 2, 1, 3, 4) + return output + + def decode_to_pixel(self, latent: torch.Tensor, use_cache: bool = False) -> torch.Tensor: + # from [batch_size, num_frames, num_channels, height, width] + # to [batch_size, num_channels, num_frames, height, width] + zs = latent.permute(0, 2, 1, 3, 4) + if use_cache: + assert latent.shape[0] == 1, "Batch size must be 1 when using cache" + + device, dtype = latent.device, latent.dtype + scale = [self.mean.to(device=device, dtype=dtype), + 1.0 / self.std.to(device=device, dtype=dtype)] + + if use_cache: + decode_function = self.model.cached_decode + else: + decode_function = self.model.decode + + output = [] + for u in zs: + output.append(decode_function(u.unsqueeze(0), scale).float().clamp_(-1, 1).squeeze(0)) + output = torch.stack(output, dim=0) + # from [batch_size, num_channels, num_frames, height, width] + # to [batch_size, num_frames, num_channels, height, width] + output = output.permute(0, 2, 1, 3, 4) + return output + + +class WanDiffusionWrapper(torch.nn.Module): + def __init__( + self, + model_name="Wan2.1-T2V-1.3B", + timestep_shift=8.0, + is_causal=False, + local_attn_size=-1, + sink_size=0, + attention_type="softmax", # one of {"softmax", "linear", "hybrid"}; only used when is_causal=True + num_frame_per_block=1, # only used when attention_type in {"linear", "hybrid"} + hybrid_gate=False, # legacy bool: equivalent to hybrid_gate_type="lambda". Kept for back-compat. + hybrid_gate_init_lambda=0.12, # initial value of λ before any training: σ(bias) where bias = logit(init_lambda). ``lambda`` mode only. + hybrid_gate_type="none", # one of {"none", "lambda", "alpha_a0", "alpha_a1", "alpha_a2", "alpha_a3"}; alpha modes use the revised normalized-branch mixture. + alpha_init_bias=-3.0, # per-head bias init for alpha modes; -3.0 → α≈0.047 at init (local-dominant). + alpha_detach_denom_delta=True, # detach log(Z_c)-log(Z_i) in the alpha logit so the gate's gradient flows only through the explicit base. + alpha_detach_state_summary=True, # detach the prior-state summary used by alpha_a2/a3 so the gate doesn't differentiate through prior blocks' k_phi. + # ----- fast alpha hybrid path (only consulted when hybrid_gate_type startswith "alpha_") ----- + use_fast_alpha_hybrid=False, # master switch; off by default for full backward compat. + hybrid_local_impl="torch", # "torch" | "flash" + hybrid_cross_impl="torch", # "torch" | "torch_cublas" (alias) | "triton" (reserved, raises) + alpha_combine_impl="torch", # "torch" | "triton" (fused Triton combine for the largest single speedup) + fallback_to_torch_on_error=True, + match_torch_centering=True, # only consulted when hybrid_local_impl="flash" + # ``fast_alpha_maxperf=True`` LOCKS the maxperf config (Flash + Triton-combine, + # no fallback, no centering, no alpha return). Overrides the per-knob flags + # above; pre-flights FA + Triton availability and raises on any mismatch. + fast_alpha_maxperf=False, + # ----- mixed-layer attention (some softmax + some linear/hybrid layers) ----- + softmax_layer_indices=None, # explicit list of 0-indexed softmax layers + softmax_layer_interval=None, # shorthand: every Nth layer is softmax + softmax_layer_local_attn_size=-1, # forwarded to CausalWanSelfAttention + softmax_layer_sink_size=0, # forwarded to CausalWanSelfAttention + # ----- Wan2.2 timestep-level two-expert routing ----------------- + wan22_moe=False, + wan22_boundary=875.0, + ode_init_dir=None, + ode_init_audit_dir=None, + pretrained_load_dtype="bfloat16", + ): + super().__init__() + + model_dir = str(model_path(model_name)) + "/" + if isinstance(pretrained_load_dtype, torch.dtype): + load_dtype = pretrained_load_dtype + else: + load_dtype = { + "bfloat16": torch.bfloat16, + "float16": torch.float16, + "float32": torch.float32, + }.get(str(pretrained_load_dtype).lower()) + if load_dtype is None: + raise ValueError( + "pretrained_load_dtype must be bfloat16, float16, or float32" + ) + + if wan22_moe or attention_type != "softmax": + raise ValueError("This release supports Wan2.1 causal softmax attention.") + if is_causal: + self.model = CausalWanModel.from_pretrained( + model_dir, local_attn_size=local_attn_size, sink_size=sink_size) + else: + self.model = WanModel.from_pretrained(model_dir) + self.attention_type = attention_type + self.is_causal = bool(is_causal) + self.wan22_moe = bool(wan22_moe) + self.model.eval() + + # For non-causal diffusion, all frames share the same timestep + self.uniform_timestep = not is_causal + + self.scheduler = FlowMatchScheduler( + shift=timestep_shift, sigma_min=0.0, extra_one_step=True + ) + self.scheduler.set_timesteps(1000, training=True) + + self.seq_len = 32760 # [1, 21, 16, 60, 104] + self.post_init() + + def enable_gradient_checkpointing(self) -> None: + self.model.enable_gradient_checkpointing() + + def adding_cls_branch(self, atten_dim=1536, num_class=4, time_embed_dim=0) -> None: + # NOTE: This is hard coded for WAN2.1-T2V-1.3B for now!!!!!!!!!!!!!!!!!!!! + self._cls_pred_branch = nn.Sequential( + # Input: [B, 384, 21, 60, 104] + nn.LayerNorm(atten_dim * 3 + time_embed_dim), + nn.Linear(atten_dim * 3 + time_embed_dim, 1536), + nn.SiLU(), + nn.Linear(atten_dim, num_class) + ) + self._cls_pred_branch.requires_grad_(True) + num_registers = 3 + self._register_tokens = RegisterTokens(num_registers=num_registers, dim=atten_dim) + self._register_tokens.requires_grad_(True) + + gan_ca_blocks = [] + for _ in range(num_registers): + block = GanAttentionBlock() + gan_ca_blocks.append(block) + self._gan_ca_blocks = nn.ModuleList(gan_ca_blocks) + self._gan_ca_blocks.requires_grad_(True) + # self.has_cls_branch = True + + def _convert_flow_pred_to_x0(self, flow_pred: torch.Tensor, xt: torch.Tensor, timestep: torch.Tensor) -> torch.Tensor: + """ + Convert flow matching's prediction to x0 prediction. + flow_pred: the prediction with shape [B, C, H, W] + xt: the input noisy data with shape [B, C, H, W] + timestep: the timestep with shape [B] + + pred = noise - x0 + x_t = (1-sigma_t) * x0 + sigma_t * noise + we have x0 = x_t - sigma_t * pred + """ + # use higher precision for calculations + original_dtype = flow_pred.dtype + flow_pred, xt, sigmas, timesteps = map( + lambda x: x.double().to(flow_pred.device), [flow_pred, xt, + self.scheduler.sigmas, + self.scheduler.timesteps] + ) + + timestep_id = torch.argmin( + (timesteps.unsqueeze(0) - timestep.unsqueeze(1)).abs(), dim=1) + sigma_t = sigmas[timestep_id].reshape(-1, 1, 1, 1) + x0_pred = xt - sigma_t * flow_pred + return x0_pred.to(original_dtype) + + @staticmethod + def _convert_x0_to_flow_pred(scheduler, x0_pred: torch.Tensor, xt: torch.Tensor, timestep: torch.Tensor) -> torch.Tensor: + """ + Convert x0 prediction to flow matching's prediction. + x0_pred: the x0 prediction with shape [B, C, H, W] + xt: the input noisy data with shape [B, C, H, W] + timestep: the timestep with shape [B] + + pred = (x_t - x_0) / sigma_t + """ + # use higher precision for calculations + original_dtype = x0_pred.dtype + x0_pred, xt, sigmas, timesteps = map( + lambda x: x.double().to(x0_pred.device), [x0_pred, xt, + scheduler.sigmas, + scheduler.timesteps] + ) + timestep_id = torch.argmin( + (timesteps.unsqueeze(0) - timestep.unsqueeze(1)).abs(), dim=1) + sigma_t = sigmas[timestep_id].reshape(-1, 1, 1, 1) + flow_pred = (xt - x0_pred) / sigma_t + return flow_pred.to(original_dtype) + + def forward( + self, + noisy_image_or_video: torch.Tensor, conditional_dict: dict, + timestep: torch.Tensor, kv_cache: Optional[List[dict]] = None, + crossattn_cache: Optional[List[dict]] = None, + current_start: Optional[int] = None, + classify_mode: Optional[bool] = False, + concat_time_embeddings: Optional[bool] = False, + clean_x: Optional[torch.Tensor] = None, + aug_t: Optional[torch.Tensor] = None, + cache_start: Optional[int] = None, + update_kv_cache: bool = True, + return_kv_features: bool = False, + kv_feature_layers: Optional[List[int]] = None, + kv_token_indices: Optional[torch.Tensor] = None, + return_latent_features: bool = False, + latent_feature_layer: Optional[int] = None, + latent_token_indices: Optional[torch.Tensor] = None, + expert_name: Optional[str] = None, + ) -> torch.Tensor: + prompt_embeds = conditional_dict["prompt_embeds"] + if not isinstance(update_kv_cache, bool): + raise TypeError("update_kv_cache must be a bool") + if kv_cache is None and not update_kv_cache: + raise ValueError("read-only KV-cache mode requires kv_cache") + + # [B, F] -> [B] + if self.uniform_timestep: + input_timestep = timestep[:, 0] + else: + input_timestep = timestep + + if expert_name is not None: + if not self.wan22_moe: + raise ValueError( + "expert_name is only valid for a Wan2.2 MoE wrapper" + ) + self.model.expert_for_timestep( + input_timestep, expert_name=expert_name + ) + + logits = None + if return_kv_features or return_latent_features: + if return_kv_features and return_latent_features: + raise ValueError( + "KV and post-block latent feature extraction are mutually exclusive") + if return_kv_features: + if kv_feature_layers is None: + raise ValueError( + "return_kv_features requires kv_feature_layers") + if latent_feature_layer is not None or latent_token_indices is not None: + raise ValueError( + "latent feature arguments cannot be used in KV feature mode") + else: + if latent_feature_layer is None: + raise ValueError( + "return_latent_features requires latent_feature_layer") + if kv_feature_layers is not None or kv_token_indices is not None: + raise ValueError( + "KV feature arguments cannot be used in latent feature mode") + if self.is_causal: + raise ValueError( + "Wan feature extraction requires the frozen non-causal Wan encoder") + if getattr(self.model, "model_type", "t2v") != "t2v": + raise ValueError("Wan feature extraction currently supports T2V only") + if kv_cache is not None or crossattn_cache is not None or clean_x is not None: + raise ValueError( + "Wan feature extraction is mutually exclusive with " + "generation caches/teacher forcing") + if noisy_image_or_video.ndim != 5: + raise ValueError( + "Wan feature input must have shape [batch, frames, channels, height, width]") + patch = tuple(int(v) for v in self.model.patch_size) + _, frames, _, height, width = noisy_image_or_video.shape + feature_seq_len = ( + ((frames - patch[0]) // patch[0] + 1) + * ((height - patch[1]) // patch[1] + 1) + * ((width - patch[2]) // patch[2] + 1) + ) + return self.model( + noisy_image_or_video.permute(0, 2, 1, 3, 4), + t=input_timestep, + context=prompt_embeds, + seq_len=feature_seq_len, + kv_feature_layers=kv_feature_layers, + kv_token_indices=kv_token_indices, + latent_feature_layer=latent_feature_layer, + latent_token_indices=latent_token_indices, + ) + if latent_feature_layer is not None or latent_token_indices is not None: + raise ValueError( + "latent feature arguments require return_latent_features=True") + + # X0 prediction + if kv_cache is not None: + flow_pred = self.model( + noisy_image_or_video.permute(0, 2, 1, 3, 4), + t=input_timestep, context=prompt_embeds, + seq_len=self.seq_len, + kv_cache=kv_cache, + crossattn_cache=crossattn_cache, + current_start=current_start, + cache_start=cache_start, + update_kv_cache=update_kv_cache, + ).permute(0, 2, 1, 3, 4) + else: + if clean_x is not None: + # teacher forcing + flow_pred = self.model( + noisy_image_or_video.permute(0, 2, 1, 3, 4), + t=input_timestep, context=prompt_embeds, + seq_len=self.seq_len, + clean_x=clean_x.permute(0, 2, 1, 3, 4), + aug_t=aug_t, + ).permute(0, 2, 1, 3, 4) + else: + if classify_mode: + flow_pred, logits = self.model( + noisy_image_or_video.permute(0, 2, 1, 3, 4), + t=input_timestep, context=prompt_embeds, + seq_len=self.seq_len, + classify_mode=True, + register_tokens=self._register_tokens, + cls_pred_branch=self._cls_pred_branch, + gan_ca_blocks=self._gan_ca_blocks, + concat_time_embeddings=concat_time_embeddings + ) + flow_pred = flow_pred.permute(0, 2, 1, 3, 4) + else: + flow_pred = self.model( + noisy_image_or_video.permute(0, 2, 1, 3, 4), + t=input_timestep, context=prompt_embeds, + seq_len=self.seq_len + ).permute(0, 2, 1, 3, 4) + + pred_x0 = self._convert_flow_pred_to_x0( + flow_pred=flow_pred.flatten(0, 1), + xt=noisy_image_or_video.flatten(0, 1), + timestep=timestep.flatten(0, 1) + ).unflatten(0, flow_pred.shape[:2]) + + if logits is not None: + return flow_pred, pred_x0, logits + + return flow_pred, pred_x0 + + def get_scheduler(self) -> SchedulerInterface: + """ + Update the current scheduler with the interface's static method + """ + scheduler = self.scheduler + scheduler.convert_x0_to_noise = types.MethodType( + SchedulerInterface.convert_x0_to_noise, scheduler) + scheduler.convert_noise_to_x0 = types.MethodType( + SchedulerInterface.convert_noise_to_x0, scheduler) + scheduler.convert_velocity_to_x0 = types.MethodType( + SchedulerInterface.convert_velocity_to_x0, scheduler) + self.scheduler = scheduler + return scheduler + + def post_init(self): + """ + A few custom initialization steps that should be called after the object is created. + Currently, the only one we have is to bind a few methods to scheduler. + We can gradually add more methods here if needed. + """ + self.get_scheduler() diff --git a/14B/code/video_fd/__init__.py b/14B/code/video_fd/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7ec0fa9bb16b45cbb7a3b5cd4ee45c4ec71c0b33 --- /dev/null +++ b/14B/code/video_fd/__init__.py @@ -0,0 +1,11 @@ +"""Frozen video-encoder adapters and differentiable replay utilities. + +The final distribution matching objective extracts DINOv3, V-JEPA2, and VideoMAE-v2 +representations and replays feature cotangents through the encoders and VAE. +The directory name is retained only to avoid a broad import-path migration. + +Subpackages: +* ``encoders`` -- frozen representation adapters and metadata helpers. +* ``replay`` -- encoder and decoder vector-Jacobian products. +* ``samplers`` -- deterministic frame and clip selection. +""" diff --git a/14B/code/video_fd/encoders/__init__.py b/14B/code/video_fd/encoders/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c4f6261829c604db0a6e0ed6ff45af5f1c82efd5 --- /dev/null +++ b/14B/code/video_fd/encoders/__init__.py @@ -0,0 +1,45 @@ +"""Sparse video-encoder adapters. + +All adapters expose a unified API (``preprocess`` / ``forward_sparse``), are frozen +and in eval mode, downsample internally to the encoder's native size, and emit +*sparse* features ``[B, S, D]`` (S in {1,4,8}) -- never dense patch populations. +The replay forward pass keeps gradients w.r.t. the input clip (no ``no_grad``). +""" + +from video_fd.encoders.base_sparse_video_encoder import ( + SparseVideoEncoderAdapter, + SyntheticSparseVideoEncoder, + differentiable_resize_crop, + temporal_segment_mean, +) + + +def build_encoder(config) -> SparseVideoEncoderAdapter: + """Factory: build a sparse video-encoder adapter from a config dict. + + ``config['name']`` selects the adapter. ``synthetic`` is always available + (used by the replay-gradient tests); the real adapters are imported lazily so + a missing dependency only errors when that encoder is actually requested. + """ + name = str(config.get("name", "synthetic")).lower() + if name == "synthetic": + return SyntheticSparseVideoEncoder(config) + if name in ("videoprism_b", "videoprism"): + from video_fd.encoders.videoprism_b_adapter import VideoPrismBAdapter + return VideoPrismBAdapter(config) + if name in ("vjepa2", "v-jepa2", "vjepa"): + from video_fd.encoders.vjepa2_adapter import VJEPA2Adapter + return VJEPA2Adapter(config) + if name in ("videomae_v2", "videomae", "videomaev2"): + from video_fd.encoders.videomae_v2_adapter import VideoMAEv2Adapter + return VideoMAEv2Adapter(config) + raise ValueError(f"unknown encoder name {name!r}") + + +__all__ = [ + "SparseVideoEncoderAdapter", + "SyntheticSparseVideoEncoder", + "differentiable_resize_crop", + "temporal_segment_mean", + "build_encoder", +] diff --git a/14B/code/video_fd/encoders/base_sparse_video_encoder.py b/14B/code/video_fd/encoders/base_sparse_video_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..dfdc2bf89b9a48d7959ce6be18d5dc0c1b0c8ffe --- /dev/null +++ b/14B/code/video_fd/encoders/base_sparse_video_encoder.py @@ -0,0 +1,330 @@ +"""Base sparse video-encoder adapter + shared differentiable preprocessing. + +The replay pass requires every preprocessing op to be differentiable w.r.t. the +input clip ``video_clip_01 ∈ [0,1]`` of shape ``[B, T, 3, H, W]``: + +* spatial resize -> ``F.interpolate`` (bicubic, antialias) +* spatial crop -> differentiable narrow/index (center or three-crop) +* normalize -> affine +* layout -> view/permute + +Concrete encoders subclass :class:`SparseVideoEncoderAdapter` and implement +``_encode_tokens`` (raw token/grid output) + ``_grid_shape`` (token grid), and the +base class turns tokens into sparse features via spatial mean + temporal segment +pooling. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +import torch +import torch.nn as nn +import torch.nn.functional as F + + +def differentiable_resize_crop( + video_clip_01: torch.Tensor, + size: int, + crop_mode: str = "center", + *, + resize_short_side: Optional[int] = None, + interpolation: str = "bicubic", + round_center_crop: bool = False, +) -> torch.Tensor: + """Resize+crop ``[B, T, 3, H, W]`` in ``[0,1]`` to ``size x size`` (differentiable). + + Returns ``[n_crops*B, T, 3, size, size]``; ``crop_mode='center'`` gives + ``n_crops=1``, ``'three_crops'`` gives ``n_crops=3`` (left/center/right for + wide videos, top/center/bottom for tall ones). Aspect ratio is preserved: + the shorter side is resized to ``resize_short_side`` (or ``size`` when it is + omitted) then square crops are taken. + + ``interpolation`` and ``round_center_crop`` exist so an adapter can reproduce + an upstream processor exactly without introducing a non-differentiable PIL + round-trip. Existing sparse adapters retain the historical bicubic, + floor-offset behavior. + """ + if video_clip_01.ndim != 5: + raise ValueError( + "video_clip_01 must have shape [B,T,3,H,W], " + f"got {tuple(video_clip_01.shape)}" + ) + B, T, C, H, W = video_clip_01.shape + if C != 3: + raise ValueError(f"video_clip_01 must have 3 channels, got {C}") + if size <= 0: + raise ValueError(f"size must be positive, got {size}") + resize_size = size if resize_short_side is None else int(resize_short_side) + if resize_size < size: + raise ValueError( + f"resize_short_side ({resize_size}) must be >= crop size ({size})" + ) + if interpolation not in ("bilinear", "bicubic"): + raise ValueError( + f"unsupported interpolation {interpolation!r}; use 'bilinear' or 'bicubic'" + ) + + x = video_clip_01.reshape(B * T, C, H, W) + # interpolate needs float32+; upcast half/bf16 but preserve float32/float64. + if x.dtype in (torch.float16, torch.bfloat16): + x = x.float() + + # Resize the shorter side while preserving aspect ratio. Hugging Face's + # shortest-edge processors truncate the scaled long edge; keep the legacy + # round-to-nearest behavior when no explicit resize size was requested. + if H <= W: + scaled_w = W * resize_size / H + new_h = resize_size + new_w = int(scaled_w) if resize_short_side is not None else int(round(scaled_w)) + else: + scaled_h = H * resize_size / W + new_h = int(scaled_h) if resize_short_side is not None else int(round(scaled_h)) + new_w = resize_size + new_h, new_w = max(size, new_h), max(size, new_w) + if (new_h, new_w) != (H, W): + x = F.interpolate( + x, + size=(new_h, new_w), + mode=interpolation, + align_corners=False, + antialias=True, + ) + + crops = _square_crops( + x, + size, + crop_mode, + round_center_crop=round_center_crop, + ) # list of [B*T, C, size, size] + out = torch.stack([c.reshape(B, T, C, size, size) for c in crops], dim=0) # [n_crops, B, T, C, s, s] + n_crops = out.shape[0] + return out.reshape(n_crops * B, T, C, size, size) + + +def _square_crops( + x: torch.Tensor, + size: int, + crop_mode: str, + *, + round_center_crop: bool = False, +) -> List[torch.Tensor]: + _, _, h, w = x.shape + if crop_mode == "center": + offs = [_center_offset(h, w, size, round_offset=round_center_crop)] + elif crop_mode == "three_crops": + offs = _three_crop_offsets(h, w, size) + else: + raise ValueError(f"unknown crop_mode {crop_mode!r} (use 'center' or 'three_crops')") + return [x[:, :, top: top + size, left: left + size] for (top, left) in offs] + + +def _center_offset( + h: int, + w: int, + size: int, + *, + round_offset: bool = False, +) -> Tuple[int, int]: + if round_offset: + # torchvision.transforms.functional.center_crop uses round rather than + # floor for odd differences. V-JEPA2's official processor uses it. + return max(0, int(round((h - size) / 2.0))), max( + 0, int(round((w - size) / 2.0)) + ) + return max(0, (h - size) // 2), max(0, (w - size) // 2) + + +def _three_crop_offsets(h: int, w: int, size: int) -> List[Tuple[int, int]]: + if w >= h: # wide: left / center / right + return [(max(0, (h - size) // 2), 0), + (max(0, (h - size) // 2), max(0, (w - size) // 2)), + (max(0, (h - size) // 2), max(0, w - size))] + # tall: top / center / bottom + return [(0, max(0, (w - size) // 2)), + (max(0, (h - size) // 2), max(0, (w - size) // 2)), + (max(0, h - size), max(0, (w - size) // 2))] + + +def temporal_segment_mean(x: torch.Tensor, segments: int) -> torch.Tensor: + """Pool ``[B, T, C]`` into ``[B, segments, C]`` by averaging contiguous segments. + + If ``T`` is not divisible by ``segments``, segment boundaries are spread as + evenly as possible. ``segments==1`` reduces to a global temporal mean. + """ + B, T, C = x.shape + if segments <= 1: + return x.mean(dim=1, keepdim=True) + if segments >= T: + return x + bounds = torch.linspace(0, T, steps=segments + 1).round().long().tolist() + chunks = [x[:, bounds[i]: bounds[i + 1]].mean(dim=1) for i in range(segments)] + return torch.stack(chunks, dim=1) + + +class SparseVideoEncoderAdapter(nn.Module): + """Unified frozen sparse video encoder. + + Subclasses implement :meth:`_encode_tokens` and :meth:`_grid_shape`. The base + class owns preprocessing (resize/crop/normalize) and the tokens -> sparse + ``[B, S, D]`` pooling, with a safe global-pooling fallback when the token grid + cannot be inferred. + """ + + def __init__(self, config): + super().__init__() + self.config = dict(config) + self.name = str(config.get("name", "sparse_video_encoder")) + self.input_size = int(config.get("input_size", 224)) + self.input_frames = int(config.get("input_frames", config.get("clip_frames", 16))) + self.sparse_tokens = int(config.get("sparse_tokens", 4)) + self.use_patch_tokens = bool(config.get("use_patch_tokens", True)) + self.sparse_mode = str(config.get("sparse_mode", "temporal")) # "temporal" | "global" + self.crop_mode = str(config.get("crop_mode", "center")) + # Global encoders (e.g. VideoPrism LvT, which pools to a single [B,D] + # video embedding) produce S>1 segments via segment-forward: the clip is + # split into S contiguous temporal segments, each temporally resized to + # the encoder's native frame count and encoded separately, then stacked. + self.global_encoder = bool(config.get("global_encoder", False)) + # normalization (ImageNet default; subclasses override) + mean = config.get("norm_mean", (0.485, 0.456, 0.406)) + std = config.get("norm_std", (0.229, 0.224, 0.225)) + self.register_buffer("norm_mean", torch.tensor(mean).view(1, 3, 1, 1), persistent=False) + self.register_buffer("norm_std", torch.tensor(std).view(1, 3, 1, 1), persistent=False) + self._warned_grid = False + self._raw_dim: Optional[int] = None + + # -- to be provided by concrete adapters -------------------------------- + def _encode_tokens(self, pixel_values: torch.Tensor) -> torch.Tensor: + """``[B, T, 3, size, size]`` (normalized) -> raw tokens ``[B, N, D]``.""" + raise NotImplementedError + + def _grid_shape(self, n_tokens: int) -> Optional[Tuple[int, int, int]]: + """Return ``(T', H', W')`` if ``T'·H'·W' == n_tokens``, else ``None``.""" + return None + + def _encode_global(self, pixel_values: torch.Tensor) -> torch.Tensor: + """``[nB, T, 3, size, size]`` (normalized) -> global embedding ``[nB, D]``. + + Implemented by global encoders (``global_encoder=True``); ``T`` equals the + encoder's native frame count (segment-forward resizes to it).""" + raise NotImplementedError + + # -- shared preprocessing ---------------------------------------------- + def preprocess(self, video_clip_01: torch.Tensor) -> torch.Tensor: + """``[B, T, 3, H, W]`` in [0,1] -> normalized ``[n_crops*B, T, 3, s, s]``.""" + x = differentiable_resize_crop(video_clip_01, self.input_size, self.crop_mode) + nB, T, C, h, w = x.shape + x = x.reshape(nB * T, C, h, w) + x = (x - self.norm_mean.to(x.dtype)) / self.norm_std.to(x.dtype) + return x.reshape(nB, T, C, h, w) + + # -- sparse feature extraction ----------------------------------------- + def forward_sparse(self, video_clip_01: torch.Tensor) -> torch.Tensor: + """``[B, T, 3, H, W]`` in [0,1] -> sparse features ``[B, S, D]``. + + When ``crop_mode='three_crops'`` the crops are averaged back into the + per-video feature (so the FD sample count is unchanged); this keeps the + crop policy identical between stats and training. + """ + pixel_values = self.preprocess(video_clip_01) # [nB, T, 3, s, s] + nB = pixel_values.shape[0] + B = video_clip_01.shape[0] + if self.global_encoder: + sparse = self._segment_forward(pixel_values) # [nB, S, D] + else: + tokens = self._encode_tokens(pixel_values) # [nB, N, D] + self._raw_dim = int(tokens.shape[-1]) + sparse = self._tokens_to_sparse(tokens) # [nB, S, D] + if nB != B: # average crops + n_crops = nB // B + sparse = sparse.reshape(n_crops, B, *sparse.shape[1:]).mean(dim=0) + return sparse + + def _segment_forward(self, pixel_values: torch.Tensor) -> torch.Tensor: + """Split ``[nB, T, 3, s, s]`` into S temporal segments, each resized to the + native frame count, encode globally, and stack to ``[nB, S, D]``.""" + nB, T, C, s, _ = pixel_values.shape + S = max(1, self.sparse_tokens) if self.sparse_mode != "global" else 1 + bounds = torch.linspace(0, T, steps=S + 1).round().long().tolist() + feats = [] + for i in range(S): + seg = pixel_values[:, bounds[i]: max(bounds[i] + 1, bounds[i + 1])] # [nB, t_i, 3, s, s] + seg = self._resize_time(seg, self.input_frames) # [nB, native, 3, s, s] + feats.append(self._encode_global(seg)) # [nB, D] + out = torch.stack(feats, dim=1) # [nB, S, D] + self._raw_dim = int(out.shape[-1]) + return out + + @staticmethod + def _resize_time(seg: torch.Tensor, target_t: int) -> torch.Tensor: + """Differentiably resample the temporal axis of ``[nB, t, 3, s, s]`` to ``target_t``.""" + nB, t, C, h, w = seg.shape + if t == target_t: + return seg + x = seg.permute(0, 2, 3, 4, 1).reshape(nB * C * h * w, 1, t) # [.., 1, t] + x = F.interpolate(x, size=target_t, mode="linear", align_corners=False) + return x.reshape(nB, C, h, w, target_t).permute(0, 4, 1, 2, 3).contiguous() + + def _tokens_to_sparse(self, tokens: torch.Tensor) -> torch.Tensor: + nB, N, D = tokens.shape + if self.sparse_mode == "global" or not self.use_patch_tokens: + return tokens.mean(dim=1, keepdim=True) # [nB, 1, D] + grid = self._grid_shape(N) + if grid is None: + if not self._warned_grid: + print(f"[{self.name}] grid inference failed for N={N}; using global token pooling.") + self._warned_grid = True + return tokens.mean(dim=1, keepdim=True) + Tp, Hp, Wp = grid + x = tokens.reshape(nB, Tp, Hp, Wp, D).mean(dim=(2, 3)) # spatial mean -> [nB, T', D] + return temporal_segment_mean(x, self.sparse_tokens) # [nB, S, D] + + @property + def raw_dim(self) -> Optional[int]: + return self._raw_dim + + def freeze(self): + self.eval() + for p in self.parameters(): + p.requires_grad_(False) + return self + + +class SyntheticSparseVideoEncoder(SparseVideoEncoderAdapter): + """Tiny deterministic token encoder for tests (no external weights). + + Produces a ``(T', H', W')`` token grid exactly like a tubelet ViT, so the + base-class reshape -> spatial-mean -> temporal-segment path and the replay VJP + are exercised end-to-end. Frozen and differentiable w.r.t. the input clip. + """ + + def __init__(self, config): + config = dict(config) + config.setdefault("input_size", 64) + config.setdefault("input_frames", 16) + super().__init__(config) + self.tubelet = int(config.get("tubelet_size", 2)) + self.patch = int(config.get("patch_size", 16)) + self.embed_dim = int(config.get("raw_dim", 96)) + # a small frozen 3D patch embed -> tokens + self.proj = nn.Conv3d(3, self.embed_dim, kernel_size=(self.tubelet, self.patch, self.patch), + stride=(self.tubelet, self.patch, self.patch)) + self.act = nn.Tanh() + torch.manual_seed(0) + for p in self.parameters(): + nn.init.normal_(p, std=0.05) + self.freeze() + + def _grid_shape(self, n_tokens: int): + Tp = self.input_frames // self.tubelet + Hp = self.input_size // self.patch + Wp = self.input_size // self.patch + return (Tp, Hp, Wp) if Tp * Hp * Wp == n_tokens else None + + def _encode_tokens(self, pixel_values: torch.Tensor) -> torch.Tensor: + # [nB, T, 3, s, s] -> [nB, 3, T, s, s] + x = pixel_values.permute(0, 2, 1, 3, 4) + x = self.act(self.proj(x)) # [nB, D, T', H', W'] + nB, D, Tp, Hp, Wp = x.shape + return x.reshape(nB, D, Tp * Hp * Wp).permute(0, 2, 1) # [nB, N, D] diff --git a/14B/code/video_fd/encoders/dinov3_adapter.py b/14B/code/video_fd/encoders/dinov3_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..ee37c18b847998b08004b358b0480da72f79b64b --- /dev/null +++ b/14B/code/video_fd/encoders/dinov3_adapter.py @@ -0,0 +1,881 @@ +"""Frozen Meta-native DINOv3 ViT-L/16 dense-video adapter. + +This adapter implements the one feature geometry used by the DINOv3 variant +of the whole-video MMD objective: + +* input ``video_clip_01`` is ``[B, 81, 3, H, W]`` in ``[0, 1]``; +* sixteen fixed frames are selected in chronological order; +* each frame is directly resized to ``256 x 256`` and ImageNet-normalized; +* Meta native ``forward_features()["x_norm_patchtokens"]`` supplies the final + normalized ``16 x 16`` patch grid; +* eight fixed rows crossed with eight fixed columns retain 64 patches/frame; +* frame-major concatenation returns one ``[1024, 1024]`` matrix per video. + +The encoder always stays in eval mode with FP32 parameters and floating-point +buffers. The first pass is detached, while :meth:`replay_dense_vjp` replays +four selected frames at a time and returns a full 81-frame pixel cotangent +without ever building a sixteen-frame encoder graph. +""" + +from __future__ import annotations + +import importlib +import subprocess +import sys +from collections.abc import Mapping, Sequence +from contextlib import nullcontext +from pathlib import Path +from typing import Optional + +import torch +from torch import nn +import torch.nn.functional as F + +from video_fd.encoders.dinov3_dense_metadata import ( + DEFAULT_WAN_VAE_ID, + FEATURE_DIM, + FORWARD_CHUNK_FRAMES, + FRAME_INDICES, + INPUT_SIZE, + MODEL_ID, + PATCH_GRID_SIZE, + PATCH_SIZE, + SOURCE_NUM_FRAMES, + SOURCE_COMMIT, + SPATIAL_AXIS_INDICES, + canonical_dinov3_dense_feature_metadata, + dense_token_indices, + file_sha256, + module_state_sha256, + spatial_patch_indices, +) + + +_IMAGENET_MEAN = (0.485, 0.456, 0.406) +_IMAGENET_STD = (0.229, 0.224, 0.225) +_NATIVE_VITL16_PARAMETER_COUNT = 303_154_176 +_NATIVE_BACKBONES_MODULE = "dinov3.hub.backbones" + + +def _dinov3_module_names() -> tuple[str, ...]: + """Return a stable snapshot of every cached module in the DINOv3 tree.""" + + return tuple( + name + for name in tuple(sys.modules) + if name == "dinov3" or name.startswith("dinov3.") + ) + + +def _assert_native_module_origin( + module_name: str, + module: object, + repo: Path, +) -> Path: + """Require a cached native module to be the matching file in ``repo``. + + Merely prepending ``repo`` to ``sys.path`` is insufficient because Python + consults ``sys.modules`` first. Validate both normal modules and package + search paths so an earlier import cannot redirect a relative import to a + different DINOv3 checkout or an installed package. + """ + + if module is None: + raise RuntimeError( + f"cached module {module_name!r} is incomplete; refusing DINOv3 import" + ) + origin = getattr(module, "__file__", None) + if not isinstance(origin, str) or not origin: + raise RuntimeError( + f"cached module {module_name!r} has no filesystem origin; " + "refusing DINOv3 import" + ) + try: + resolved_origin = Path(origin).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise RuntimeError( + f"cannot resolve cached module {module_name!r} origin {origin!r}" + ) from exc + + relative_parts = module_name.split(".") + module_base = repo.joinpath(*relative_parts) + expected_origins = { + module_base.with_suffix(".py").resolve(), + (module_base / "__init__.py").resolve(), + } + if resolved_origin not in expected_origins: + raise RuntimeError( + f"cached module {module_name!r} comes from {resolved_origin}, " + f"not the pinned DINOv3 repo {repo}" + ) + + package_paths = getattr(module, "__path__", None) + if package_paths is not None: + try: + resolved_paths = tuple( + Path(path).expanduser().resolve(strict=True) + for path in package_paths + ) + except (OSError, RuntimeError, TypeError) as exc: + raise RuntimeError( + f"cached package {module_name!r} has invalid search paths" + ) from exc + expected_package_path = module_base.resolve(strict=True) + if resolved_paths != (expected_package_path,): + raise RuntimeError( + f"cached package {module_name!r} search paths {resolved_paths!r} " + f"do not equal the pinned path {(expected_package_path,)!r}" + ) + return resolved_origin + + +def _validate_cached_dinov3_modules(repo: Path) -> None: + for module_name in _dinov3_module_names(): + _assert_native_module_origin( + module_name, + sys.modules.get(module_name), + repo, + ) + + +def _remove_new_dinov3_modules(previous_names: set[str]) -> None: + for module_name in _dinov3_module_names(): + if module_name not in previous_names: + sys.modules.pop(module_name, None) + + +def _import_native_vitl16_factory(repo: Path): + """Import only the official backbone module from one verified checkout. + + The repository's ``hubconf.py`` imports optional segmentation, detection, + depth, and text dependencies even though this adapter needs only a vision + backbone. Directly importing ``dinov3.hub.backbones`` avoids those optional + dependency edges while preserving Meta's native architecture factory. + """ + + repo = repo.expanduser().resolve(strict=True) + if not (repo / "dinov3" / "hub" / "backbones.py").is_file(): + raise FileNotFoundError( + f"pinned DINOv3 backbone module is missing under {repo}" + ) + _validate_cached_dinov3_modules(repo) + + previous_module_names = set(_dinov3_module_names()) + previous_sys_path = list(sys.path) + try: + sys.path.insert(0, str(repo)) + importlib.invalidate_caches() + module = importlib.import_module(_NATIVE_BACKBONES_MODULE) + _assert_native_module_origin(_NATIVE_BACKBONES_MODULE, module, repo) + _validate_cached_dinov3_modules(repo) + factory = getattr(module, MODEL_ID, None) + if not callable(factory): + raise RuntimeError( + f"{_NATIVE_BACKBONES_MODULE} does not expose callable {MODEL_ID}" + ) + return factory + except Exception: + _remove_new_dinov3_modules(previous_module_names) + raise + finally: + sys.path[:] = previous_sys_path + + +def _build_native_vitl16(repo: Path) -> nn.Module: + """Construct the exact official ViT-L/16 architecture without weights.""" + + factory = _import_native_vitl16_factory(repo) + previous_module_names = set(_dinov3_module_names()) + previous_sys_path = list(sys.path) + try: + model = factory(pretrained=False) + _validate_cached_dinov3_modules(repo) + if not isinstance(model, nn.Module): + raise TypeError( + f"official {MODEL_ID} factory did not return an nn.Module" + ) + return model + except Exception: + _remove_new_dinov3_modules(previous_module_names) + raise + finally: + # Some optional acceleration imports used by the native constructor + # append transient paths. Keep the adapter's source-loading boundary + # side-effect free regardless of whether construction succeeds. + sys.path[:] = previous_sys_path + + +def _autocast_disabled(device: torch.device): + if device.type in {"cpu", "cuda"}: + return torch.autocast(device_type=device.type, enabled=False) + return nullcontext() + + +def _strict_config_tuple( + config, + key: str, + expected: tuple[int, ...], +) -> tuple[int, ...]: + raw = config.get(key, expected) + if isinstance(raw, (str, bytes)): + raise TypeError(f"{key} must be a sequence of integers") + values = tuple(raw) + if any(type(value) is not int for value in values): + raise TypeError(f"{key} must contain Python integers") + if values != expected: + raise ValueError(f"{key} must equal {list(expected)}") + return values + + +def _is_tensor_state_dict(value) -> bool: + return ( + isinstance(value, Mapping) + and len(value) > 0 + and all(isinstance(key, str) for key in value) + and all(torch.is_tensor(tensor) for tensor in value.values()) + ) + + +def _checkpoint_state_dict(payload) -> Mapping[str, torch.Tensor]: + """Accept only a direct state dict or an explicit ``model`` wrapper.""" + + if _is_tensor_state_dict(payload): + return payload + if isinstance(payload, Mapping) and "model" in payload: + state = payload["model"] + if _is_tensor_state_dict(state): + return state + raise TypeError("checkpoint['model'] is not a non-empty tensor state dict") + raise TypeError( + "DINOv3 checkpoint must be a direct tensor state dict or contain " + "one under the explicit 'model' key" + ) + + +class DINOv3DenseAdapter(nn.Module): + """Meta-native DINOv3 ViT-L/16 adapter with an exact replay VJP API. + + Required production config keys are ``repo_path``, ``weight_path`` and + ``source_commit``. Geometry keys may be omitted because their canonical + values are defaults, but supplying a different value is rejected. Passing + ``model=...`` is a unit-test seam; production construction creates the + official architecture with ``pretrained=False`` and then strict-loads the + local checkpoint itself. + """ + + def __init__(self, config, model: Optional[nn.Module] = None): + super().__init__() + config = dict(config) + self.config = config + self.model_id = str(config.get("model_id", MODEL_ID)) + if self.model_id != MODEL_ID: + raise ValueError(f"model_id must be {MODEL_ID!r}") + + self.repo_path = str(config.get("repo_path", "")) + self.weight_path = str(config.get("weight_path", "")) + self.source_commit = str(config.get("source_commit", "")) + for name, value in ( + ("repo_path", self.repo_path), + ("weight_path", self.weight_path), + ): + if not value or "\0" in value: + raise ValueError(f"{name} must be a non-empty string without NUL characters") + if self.source_commit != SOURCE_COMMIT: + raise ValueError( + "source_commit must equal the pinned DINOv3 source commit " + f"{SOURCE_COMMIT}" + ) + + self.source_num_frames = int( + config.get("source_num_frames", SOURCE_NUM_FRAMES)) + if self.source_num_frames != SOURCE_NUM_FRAMES: + raise ValueError(f"source_num_frames must be {SOURCE_NUM_FRAMES}") + self.frame_indices = _strict_config_tuple( + config, "frame_indices", FRAME_INDICES) + self.dense_num_frames = len(self.frame_indices) + + self.input_size = int(config.get("input_size", INPUT_SIZE)) + if self.input_size != INPUT_SIZE: + raise ValueError(f"input_size must be {INPUT_SIZE}") + self.patch_size = int(config.get("patch_size", PATCH_SIZE)) + if self.patch_size != PATCH_SIZE: + raise ValueError(f"patch_size must be {PATCH_SIZE}") + configured_grid = int(config.get("patch_grid_size", PATCH_GRID_SIZE)) + if configured_grid != PATCH_GRID_SIZE: + raise ValueError(f"patch_grid_size must be {PATCH_GRID_SIZE}") + self.patch_grid_size = configured_grid + self.spatial_axis_indices = _strict_config_tuple( + config, "spatial_axis_indices", SPATIAL_AXIS_INDICES) + self.spatial_patch_indices = tuple( + int(value) for value in spatial_patch_indices().tolist()) + # ``spatial_token_indices`` is the name used by offline reference + # tooling; both aliases describe the same per-frame 16x16 patch grid. + self.spatial_token_indices = self.spatial_patch_indices + supplied_spatial = config.get("spatial_token_indices") + if supplied_spatial is not None: + supplied = ( + supplied_spatial.detach() + if isinstance(supplied_spatial, torch.Tensor) + else torch.tensor(tuple(supplied_spatial)) + ) + if ( + supplied.dtype == torch.bool + or supplied.is_floating_point() + or supplied.is_complex() + ): + raise TypeError("spatial_token_indices must contain integers") + supplied = supplied.detach().to(device="cpu", dtype=torch.long) + if not torch.equal(supplied, spatial_patch_indices()): + raise ValueError( + "spatial_token_indices must equal the canonical 64 patch indices") + + self.forward_chunk_frames = int( + config.get("forward_chunk_frames", FORWARD_CHUNK_FRAMES)) + if self.forward_chunk_frames != FORWARD_CHUNK_FRAMES: + raise ValueError( + f"forward_chunk_frames must be {FORWARD_CHUNK_FRAMES}") + self.dense_feature_dim = int(config.get("feature_dim", FEATURE_DIM)) + if self.dense_feature_dim != FEATURE_DIM: + raise ValueError(f"feature_dim must be {FEATURE_DIM}") + self.per_frame_tokens = len(self.spatial_patch_indices) + if int(config.get("per_frame_tokens", self.per_frame_tokens)) != self.per_frame_tokens: + raise ValueError(f"per_frame_tokens must be {self.per_frame_tokens}") + self.dense_raw_tokens = ( + self.dense_num_frames * self.patch_grid_size * self.patch_grid_size) + self.dense_num_tokens = self.dense_num_frames * self.per_frame_tokens + if int(config.get("num_tokens", self.dense_num_tokens)) != self.dense_num_tokens: + raise ValueError(f"num_tokens must be {self.dense_num_tokens}") + self.vae_id = str(config.get("vae_id", DEFAULT_WAN_VAE_ID)) + self.raw_tokens = self.dense_raw_tokens + self.num_tokens = self.dense_num_tokens + self.feature_dim = self.dense_feature_dim + + self.register_buffer( + "_frame_indices_tensor", + torch.tensor(self.frame_indices, dtype=torch.long), + persistent=False, + ) + self.register_buffer( + "_spatial_patch_indices_tensor", + spatial_patch_indices(), + persistent=False, + ) + self.register_buffer( + "_norm_mean", + torch.tensor(_IMAGENET_MEAN, dtype=torch.float32).view(1, 3, 1, 1), + persistent=False, + ) + self.register_buffer( + "_norm_std", + torch.tensor(_IMAGENET_STD, dtype=torch.float32).view(1, 3, 1, 1), + persistent=False, + ) + + configured_weight_sha = config.get( + "weight_sha256", config.get("checkpoint_sha256")) + self._injected_model = model is not None + if model is None: + loaded_model = self._load_native_model() + else: + loaded_model = model + self._adopt_model(loaded_model) + + if self._injected_model: + actual_weight_sha = module_state_sha256(self._model) + else: + actual_weight_sha = file_sha256(self.weight_path) + if configured_weight_sha is not None: + configured_weight_sha = str(configured_weight_sha).lower() + if configured_weight_sha != actual_weight_sha: + raise ValueError( + "configured weight_sha256 does not match the loaded model/checkpoint: " + f"{configured_weight_sha} != {actual_weight_sha}" + ) + self.weight_sha256 = actual_weight_sha + + @property + def model(self) -> nn.Module: + return self._model + + @property + def dense_token_indices(self) -> tuple[int, ...]: + return tuple(int(value) for value in dense_token_indices().tolist()) + + def _load_native_model(self) -> nn.Module: + repo = Path(self.repo_path).expanduser().resolve() + weights = Path(self.weight_path).expanduser().resolve() + if not repo.is_dir(): + raise FileNotFoundError(f"DINOv3 repo_path is not a directory: {repo}") + if not weights.is_file(): + raise FileNotFoundError(f"DINOv3 weight_path is not a file: {weights}") + self._verify_repo_source(repo) + + # The official vitl16 helper parses an 8-hex suffix whenever a custom + # weights string is passed. Build without pretrained weights, then load + # the user's pinned local file independently so arbitrary safe filenames + # work without renaming or symlinking. + model = _build_native_vitl16(repo) + payload = torch.load( + str(weights), + map_location="cpu", + weights_only=True, + ) + state_dict = _checkpoint_state_dict(payload) + model.load_state_dict(state_dict, strict=True) + return model + + @staticmethod + def _verify_repo_source(repo: Path) -> None: + """Require the pinned Git commit and no tracked or untracked changes.""" + + def run_git(*arguments: str) -> str: + command = ["git", "-C", str(repo), *arguments] + try: + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise RuntimeError( + "failed to verify the pinned DINOv3 source repository" + ) from exc + return result.stdout + + actual_commit = run_git("rev-parse", "--verify", "HEAD").strip() + if actual_commit != SOURCE_COMMIT: + raise RuntimeError( + "DINOv3 repo HEAD does not match the pinned source commit: " + f"{actual_commit!r} != {SOURCE_COMMIT!r}" + ) + tracked_status = run_git( + "status", + "--porcelain", + "--untracked-files=all", + ) + if tracked_status.strip(): + raise RuntimeError( + "DINOv3 repo has worktree changes; refusing to load " + "an unpinned native implementation" + ) + + def _adopt_model(self, model: nn.Module) -> None: + if not isinstance(model, nn.Module): + raise TypeError("model injection must be an nn.Module") + self._model = model.to(dtype=torch.float32) + self._model.eval().requires_grad_(False) + self._validate_native_geometry() + self._verify_model_fp32() + # Use nn.Module.train directly so our override cannot accidentally + # propagate ``training=True`` into the positional-coordinate path. + nn.Module.train(self, False) + + def _validate_native_geometry(self) -> None: + if not callable(getattr(self._model, "forward_features", None)): + raise TypeError("DINOv3 model must expose callable forward_features") + for attribute, expected in ( + ("patch_size", PATCH_SIZE), + ("embed_dim", FEATURE_DIM), + ("n_storage_tokens", 4), + ): + if not hasattr(self._model, attribute): + raise AttributeError( + f"DINOv3 model is missing required attribute {attribute!r}") + actual = getattr(self._model, attribute) + if isinstance(actual, (tuple, list)): + valid = len(actual) > 0 and all(int(value) == expected for value in actual) + else: + valid = int(actual) == expected + if not valid: + raise ValueError( + f"DINOv3 {attribute} mismatch: expected {expected}, got {actual}") + blocks = getattr(self._model, "blocks", None) + if blocks is None or len(blocks) != 24: + raise ValueError( + "DINOv3 ViT-L/16 must expose exactly 24 transformer blocks") + if not self._injected_model: + parameter_count = sum(parameter.numel() for parameter in self._model.parameters()) + if parameter_count != _NATIVE_VITL16_PARAMETER_COUNT: + raise ValueError( + "DINOv3 ViT-L/16 parameter count mismatch: " + f"{parameter_count} != {_NATIVE_VITL16_PARAMETER_COUNT}" + ) + + def _verify_model_fp32(self) -> None: + wrong = [ + (f"parameter:{name}", value.dtype) + for name, value in self._model.named_parameters() + if value.dtype != torch.float32 + ] + wrong.extend( + (f"buffer:{name}", value.dtype) + for name, value in self._model.named_buffers() + if value.is_floating_point() and value.dtype != torch.float32 + ) + if wrong: + preview = ", ".join(f"{name}={dtype}" for name, dtype in wrong[:8]) + raise TypeError( + "DINOv3 parameters and floating-point buffers must be FP32; " + f"found {preview}" + ) + if any(parameter.requires_grad for parameter in self._model.parameters()): + raise RuntimeError("DINOv3 parameters must be frozen") + if self._model.training: + raise RuntimeError("DINOv3 model must stay in eval mode") + + def train(self, mode: bool = True): + """The frozen feature identity never permits training-mode augmentations.""" + + nn.Module.train(self, False) + self._model.eval() + return self + + def _validate_video(self, video_clip_01: torch.Tensor) -> None: + if not isinstance(video_clip_01, torch.Tensor): + raise TypeError("video_clip_01 must be a torch.Tensor") + if ( + video_clip_01.ndim != 5 + or int(video_clip_01.shape[0]) <= 0 + or int(video_clip_01.shape[1]) != self.source_num_frames + or int(video_clip_01.shape[2]) != 3 + or int(video_clip_01.shape[3]) <= 0 + or int(video_clip_01.shape[4]) <= 0 + ): + raise ValueError( + "video_clip_01 must be a non-empty " + f"[B,{self.source_num_frames},3,H,W] tensor, got " + f"{tuple(video_clip_01.shape)}" + ) + if video_clip_01.dtype != torch.float32: + raise TypeError( + "video_clip_01 must be FP32, got " + f"{video_clip_01.dtype}" + ) + if not bool(torch.isfinite(video_clip_01).all().item()): + raise FloatingPointError("video_clip_01 contains non-finite values") + minimum = float(video_clip_01.min().item()) + maximum = float(video_clip_01.max().item()) + if minimum < 0.0 or maximum > 1.0: + raise ValueError( + "video_clip_01 values must lie in [0, 1], got range " + f"[{minimum:.8g}, {maximum:.8g}]" + ) + + def _resolve_token_indices( + self, + token_indices: Optional[torch.Tensor | Sequence[int]], + ) -> torch.Tensor: + canonical = self._spatial_patch_indices_tensor + if token_indices is None: + return canonical + supplied = ( + token_indices.detach() + if isinstance(token_indices, torch.Tensor) + else torch.tensor(tuple(token_indices)) + ) + if ( + supplied.dtype == torch.bool + or supplied.is_floating_point() + or supplied.is_complex() + ): + raise TypeError("token_indices must contain integers") + supplied = supplied.detach().to( + device=canonical.device, dtype=torch.long).contiguous() + if not torch.equal(supplied, canonical): + raise ValueError( + "token_indices must equal the canonical 64 flattened spatial indices" + ) + return canonical + + def _selected_flat_frames(self, video_clip_01: torch.Tensor) -> torch.Tensor: + selected = video_clip_01.index_select( + 1, self._frame_indices_tensor.to(video_clip_01.device)) + return selected.reshape(-1, *selected.shape[2:]).contiguous() + + def preprocess_frames(self, frames_01: torch.Tensor) -> torch.Tensor: + """Direct square resize and ImageNet-normalize ``[N,3,H,W]`` frames.""" + + if ( + not isinstance(frames_01, torch.Tensor) + or frames_01.ndim != 4 + or int(frames_01.shape[0]) <= 0 + or int(frames_01.shape[1]) != 3 + or not frames_01.is_floating_point() + ): + raise ValueError("frames_01 must be a non-empty floating [N,3,H,W] tensor") + with _autocast_disabled(frames_01.device): + frames = frames_01.float() + if tuple(frames.shape[-2:]) != (self.input_size, self.input_size): + frames = F.interpolate( + frames, + size=(self.input_size, self.input_size), + mode="bilinear", + align_corners=False, + antialias=True, + ) + return ( + frames - self._norm_mean.to(device=frames.device) + ) / self._norm_std.to(device=frames.device) + + def preprocess_dense(self, video_clip_01: torch.Tensor) -> torch.Tensor: + """Return all sixteen selected, normalized frames as ``[B,16,3,256,256]``.""" + + self._validate_video(video_clip_01) + batch_size = int(video_clip_01.shape[0]) + flat = self._selected_flat_frames(video_clip_01) + processed = [] + for start in range(0, int(flat.shape[0]), self.forward_chunk_frames): + processed.append(self.preprocess_frames( + flat[start:start + self.forward_chunk_frames])) + return torch.cat(processed, dim=0).reshape( + batch_size, + self.dense_num_frames, + 3, + self.input_size, + self.input_size, + ) + + def _encode_frame_batch( + self, + frames_01: torch.Tensor, + token_indices: torch.Tensor, + ) -> torch.Tensor: + pixel_values = self.preprocess_frames(frames_01) + with _autocast_disabled(pixel_values.device): + outputs = self._model.forward_features(pixel_values) + if not isinstance(outputs, Mapping) or "x_norm_patchtokens" not in outputs: + raise TypeError( + "DINOv3 forward_features must return a mapping containing " + "'x_norm_patchtokens'" + ) + patches = outputs["x_norm_patchtokens"] + expected_shape = ( + int(frames_01.shape[0]), + self.patch_grid_size * self.patch_grid_size, + self.dense_feature_dim, + ) + if not torch.is_tensor(patches) or tuple(patches.shape) != expected_shape: + actual = None if not torch.is_tensor(patches) else tuple(patches.shape) + raise RuntimeError( + "unexpected DINOv3 normalized patch feature shape: " + f"expected {expected_shape}, got {actual}" + ) + if patches.dtype != torch.float32: + raise TypeError( + f"DINOv3 normalized patch features must be FP32, got {patches.dtype}") + if not bool(torch.isfinite(patches).all().item()): + raise FloatingPointError( + "DINOv3 x_norm_patchtokens contains non-finite values" + ) + return patches.index_select(1, token_indices.to(patches.device)) + + def forward_dense( + self, + video_clip_01: torch.Tensor, + token_indices: Optional[torch.Tensor | Sequence[int]] = None, + gradient_checkpointing: bool = False, + ) -> torch.Tensor: + """Extract one frame-major ``[1024,1024]`` matrix per 81-frame video. + + ``gradient_checkpointing`` is accepted for compatibility with the old + V-JEPA2 call boundary. DINOv3's independent-frame graph is bounded by + the mandatory four-frame chunks, so no additional checkpoint wrapper is + needed and the flag does not change feature identity. + """ + + if type(gradient_checkpointing) is not bool: + raise TypeError("gradient_checkpointing must be a bool") + self._verify_model_fp32() + self._validate_video(video_clip_01) + resolved_tokens = self._resolve_token_indices(token_indices) + batch_size = int(video_clip_01.shape[0]) + flat_frames = self._selected_flat_frames(video_clip_01) + chunks = [] + for start in range(0, int(flat_frames.shape[0]), self.forward_chunk_frames): + chunks.append(self._encode_frame_batch( + flat_frames[start:start + self.forward_chunk_frames], + resolved_tokens, + )) + features = torch.cat(chunks, dim=0) + return features.reshape( + batch_size, + self.dense_num_frames * self.per_frame_tokens, + self.dense_feature_dim, + ) + + @torch.no_grad() + def forward_dense_no_grad( + self, + video_clip_01: torch.Tensor, + token_indices: Optional[torch.Tensor | Sequence[int]] = None, + gradient_checkpointing: bool = False, + ) -> torch.Tensor: + """Detached memory-bounded first-pass extraction.""" + + return self.forward_dense( + video_clip_01, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ).detach().float().clone() + + extract_dense_no_grad = forward_dense_no_grad + + def replay_dense_vjp( + self, + video_clip_01: torch.Tensor, + feature_cotangent: torch.Tensor, + *, + expected_features: Optional[torch.Tensor] = None, + token_indices: Optional[torch.Tensor | Sequence[int]] = None, + gradient_checkpointing: bool = False, + replay_atol: float = 1.0e-6, + replay_rtol: float = 1.0e-5, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Replay the feature VJP in four-frame chunks. + + Returns ``(replay_features, pixel_cotangent)``. The cotangent is FP32 + with the original ``[B,81,3,H,W]`` layout; all 65 unselected frames are + exactly zero. If ``expected_features`` is supplied, the replayed + features must match the detached first pass before any cotangent is + returned. + """ + + if type(gradient_checkpointing) is not bool: + raise TypeError("gradient_checkpointing must be a bool") + self._verify_model_fp32() + self._validate_video(video_clip_01) + batch_size = int(video_clip_01.shape[0]) + expected_shape = ( + batch_size, + self.dense_num_tokens, + self.dense_feature_dim, + ) + if ( + not isinstance(feature_cotangent, torch.Tensor) + or not feature_cotangent.is_floating_point() + or tuple(feature_cotangent.shape) != expected_shape + or not bool(torch.isfinite(feature_cotangent).all().item()) + ): + actual = ( + tuple(feature_cotangent.shape) + if isinstance(feature_cotangent, torch.Tensor) + else type(feature_cotangent).__name__ + ) + raise ValueError( + "feature_cotangent must be a finite floating tensor with " + f"shape {expected_shape}, got {actual}" + ) + if replay_atol < 0 or replay_rtol < 0: + raise ValueError("replay tolerances must be non-negative") + + resolved_tokens = self._resolve_token_indices(token_indices) + flat_frames = self._selected_flat_frames(video_clip_01.detach()) + flat_cotangent = feature_cotangent.detach().reshape( + batch_size * self.dense_num_frames, + self.per_frame_tokens, + self.dense_feature_dim, + ).float() + replay_chunks = [] + gradient_chunks = [] + for start in range(0, int(flat_frames.shape[0]), self.forward_chunk_frames): + end = start + self.forward_chunk_frames + frame_leaf = ( + flat_frames[start:end] + .detach() + .clone() + .requires_grad_(True) + ) + chunk_features = self._encode_frame_batch( + frame_leaf, + resolved_tokens, + ) + chunk_cotangent = flat_cotangent[start:end].to( + device=chunk_features.device, + dtype=torch.float32, + ) + surrogate = (chunk_features * chunk_cotangent).sum() + (chunk_gradient,) = torch.autograd.grad( + surrogate, + frame_leaf, + retain_graph=False, + create_graph=False, + ) + replay_chunks.append(chunk_features.detach().float()) + gradient_chunks.append(chunk_gradient.detach().float()) + + replay_features = torch.cat(replay_chunks, dim=0).reshape(expected_shape) + if expected_features is not None: + if ( + not isinstance(expected_features, torch.Tensor) + or tuple(expected_features.shape) != expected_shape + or not bool(torch.isfinite(expected_features).all().item()) + ): + raise ValueError( + f"expected_features must be finite with shape {expected_shape}") + comparison = expected_features.detach().to( + device=replay_features.device, + dtype=torch.float32, + ) + if not torch.allclose( + replay_features, + comparison, + atol=float(replay_atol), + rtol=float(replay_rtol), + ): + max_abs = float( + (replay_features - comparison).abs().max().item()) + raise RuntimeError( + "DINOv3 replay features changed from the first pass: " + f"max_abs={max_abs:.8g}, atol={replay_atol}, rtol={replay_rtol}" + ) + + selected_gradient = torch.cat(gradient_chunks, dim=0).reshape( + batch_size, + self.dense_num_frames, + *video_clip_01.shape[2:], + ) + full_pixel_cotangent = torch.zeros( + video_clip_01.shape, + device=video_clip_01.device, + dtype=torch.float32, + ) + full_pixel_cotangent.index_copy_( + 1, + self._frame_indices_tensor.to(video_clip_01.device), + selected_gradient, + ) + if not bool(torch.isfinite(full_pixel_cotangent).all().item()): + raise FloatingPointError("DINOv3 replay returned a non-finite pixel cotangent") + return replay_features, full_pixel_cotangent + + def feature_metadata(self) -> dict[str, object]: + """Return the strict checkpoint, preprocessing and matrix identity.""" + + return canonical_dinov3_dense_feature_metadata( + repo_path=self.repo_path, + weight_path=self.weight_path, + source_commit=self.source_commit, + weight_sha256=self.weight_sha256, + source_num_frames=self.source_num_frames, + frame_indices=self.frame_indices, + input_size=self.input_size, + patch_size=self.patch_size, + patch_grid_size=self.patch_grid_size, + spatial_axis_indices=self.spatial_axis_indices, + spatial_token_indices=self.spatial_patch_indices, + per_frame_tokens=self.per_frame_tokens, + num_tokens=self.dense_num_tokens, + forward_chunk_frames=self.forward_chunk_frames, + feature_dim=self.dense_feature_dim, + model_id=self.model_id, + vae_id=self.vae_id, + ) + + +# Short alias for callers that already name the encoder without the geometry +# suffix. Both names refer to the same strict implementation. +DINOv3Adapter = DINOv3DenseAdapter + + +__all__ = ["DINOv3Adapter", "DINOv3DenseAdapter"] diff --git a/14B/code/video_fd/encoders/dinov3_dense_metadata.py b/14B/code/video_fd/encoders/dinov3_dense_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..b2c5eb97aec4f79974dc0ac188a7c444ef9ff4da --- /dev/null +++ b/14B/code/video_fd/encoders/dinov3_dense_metadata.py @@ -0,0 +1,349 @@ +"""Strict identity metadata for the fixed DINOv3 dense-video feature. + +The DINOv3 feature used by this project is intentionally a single immutable +contract rather than a family of loosely compatible preprocessing choices. +One 81-frame video is sampled at sixteen fixed frame indices. Every selected +frame is resized directly to 256 square pixels and contributes the Cartesian +product of eight fixed patch rows and columns. The resulting frame-major +matrix has shape ``[1024, 1024]`` for the Meta DINOv3 ViT-L/16 backbone. + +Offline reference builders and online training both use the helpers in this +module. Keeping this metadata separate from the former V-JEPA2 contract makes +cross-encoder reference reuse fail loudly instead of silently changing the +MMD's feature identity. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Final +from urllib.parse import urlparse + +import torch + + +FEATURE_METADATA_VERSION = 1 +MODEL_ID = "dinov3_vitl16" +FEATURE_TYPE = "dinov3_dense_latent" +DEFAULT_WAN_VAE_ID = "Wan2.1-T2V-1.3B/Wan2.1_VAE.pth" +SOURCE_COMMIT: Final[str] = "6876159a11b4df116f30f667f8c9888617df0751" + +SOURCE_NUM_FRAMES = 81 +FRAME_INDICES = (0, 5, 11, 16, 21, 27, 32, 37, 43, 48, 53, 59, 64, 69, 75, 80) +INPUT_SIZE = 256 +PATCH_SIZE = 16 +PATCH_GRID_SIZE = INPUT_SIZE // PATCH_SIZE +SPATIAL_AXIS_INDICES = (0, 2, 4, 6, 9, 11, 13, 15) +FORWARD_CHUNK_FRAMES = 4 +FEATURE_DIM = 1024 +STORAGE_TOKENS = 4 + + +def spatial_patch_indices( + *, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Return the canonical 64 per-frame flattened patch indices. + + The row is the outer loop and the column is the inner loop, matching the + native ViT patch flattening order and the final frame-major matrix order. + """ + + return torch.tensor( + [row * PATCH_GRID_SIZE + column + for row in SPATIAL_AXIS_INDICES + for column in SPATIAL_AXIS_INDICES], + dtype=torch.long, + device=device, + ) + + +def dense_token_indices( + *, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Return canonical indices into the full ``16 * 256`` patch population.""" + + per_frame = spatial_patch_indices(device=device) + offsets = ( + torch.arange(len(FRAME_INDICES), dtype=torch.long, device=device) + * (PATCH_GRID_SIZE * PATCH_GRID_SIZE) + ) + return (offsets[:, None] + per_frame[None, :]).reshape(-1) + + +def integer_indices_sha256(values: torch.Tensor | Sequence[int]) -> str: + """Hash one ordered integer index sequence using explicit little endian i64.""" + + tensor = ( + values.detach() + if isinstance(values, torch.Tensor) + else torch.tensor(tuple(values)) + ) + if ( + tensor.dtype == torch.bool + or tensor.is_floating_point() + or tensor.is_complex() + ): + raise TypeError("index hashes require integers") + tensor = tensor.detach().to(device="cpu", dtype=torch.int64).contiguous() + if tensor.ndim != 1 or tensor.numel() == 0: + raise ValueError("index hashes require a non-empty one-dimensional sequence") + payload = b"".join( + int(value).to_bytes(8, byteorder="little", signed=True) + for value in tensor.tolist() + ) + return hashlib.sha256(payload).hexdigest() + + +def file_sha256(path: str | os.PathLike[str], *, chunk_bytes: int = 8 << 20) -> str: + """Stream a local checkpoint hash without loading a second copy in memory.""" + + resolved = Path(path).expanduser().resolve(strict=True) + if not resolved.is_file(): + raise ValueError(f"checkpoint is not a regular file: {resolved}") + if int(chunk_bytes) <= 0: + raise ValueError("chunk_bytes must be positive") + digest = hashlib.sha256() + with resolved.open("rb") as handle: + while True: + block = handle.read(int(chunk_bytes)) + if not block: + break + digest.update(block) + return digest.hexdigest() + + +def module_state_sha256(module: torch.nn.Module) -> str: + """Hash an injected test model's state in a deterministic tensor format.""" + + digest = hashlib.sha256() + state = module.state_dict() + for name in sorted(state): + value = state[name] + if not torch.is_tensor(value): + raise TypeError(f"module state {name!r} is not a tensor") + tensor = value.detach().to(device="cpu").contiguous() + digest.update(name.encode("utf-8")) + digest.update(b"\0") + digest.update(str(tensor.dtype).encode("ascii")) + digest.update(b"\0") + digest.update(json.dumps(list(tensor.shape), separators=(",", ":")).encode("ascii")) + digest.update(b"\0") + # Viewing as bytes also supports bfloat16, which NumPy cannot always + # expose directly. + digest.update(tensor.reshape(-1).view(torch.uint8).numpy().tobytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def canonical_json_sha256(payload: Mapping[str, object]) -> str: + """Hash a JSON payload using the one canonical serialization.""" + + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _canonical_location(value: str, *, name: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"{name} must be a non-empty string") + if "\0" in value: + raise ValueError(f"{name} must not contain NUL characters") + parsed = urlparse(value) + if parsed.scheme in {"http", "https", "file"}: + return value + return os.path.realpath(os.path.expanduser(value)) + + +def _sha256_hex(value: str, *, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a hexadecimal string") + normalized = value.lower() + if len(normalized) != 64 or any(c not in "0123456789abcdef" for c in normalized): + raise ValueError(f"{name} must contain exactly 64 hexadecimal characters") + return normalized + + +def _strict_tuple( + actual: Sequence[int], + expected: tuple[int, ...], + *, + name: str, +) -> tuple[int, ...]: + if isinstance(actual, (str, bytes)): + raise TypeError(f"{name} must be an integer sequence") + values = tuple(actual) + if any(type(value) is not int for value in values): + raise TypeError(f"{name} must contain Python integers") + if values != expected: + raise ValueError(f"{name} must equal the canonical contract {list(expected)}") + return values + + +def canonical_dinov3_dense_feature_metadata( + *, + repo_path: str, + weight_path: str, + source_commit: str, + weight_sha256: str, + source_num_frames: int = SOURCE_NUM_FRAMES, + frame_indices: Sequence[int] = FRAME_INDICES, + input_size: int = INPUT_SIZE, + patch_size: int = PATCH_SIZE, + patch_grid_size: int = PATCH_GRID_SIZE, + spatial_axis_indices: Sequence[int] = SPATIAL_AXIS_INDICES, + spatial_token_indices: Sequence[int] | torch.Tensor | None = None, + per_frame_tokens: int = len(SPATIAL_AXIS_INDICES) ** 2, + num_tokens: int = len(FRAME_INDICES) * len(SPATIAL_AXIS_INDICES) ** 2, + forward_chunk_frames: int = FORWARD_CHUNK_FRAMES, + feature_dim: int = FEATURE_DIM, + model_id: str = MODEL_ID, + vae_id: str = DEFAULT_WAN_VAE_ID, +) -> dict[str, object]: + """Build and hash the canonical DINOv3 dense feature identity. + + Geometry arguments are exposed so callers can pass their live adapter + values, but every one is checked against the immutable contract. This is + deliberate: changing any of them requires a new metadata version and new + reference bank, not a permissive payload under the old identity. + """ + + if model_id != MODEL_ID: + raise ValueError(f"model_id must be {MODEL_ID!r}") + if type(source_num_frames) is not int or source_num_frames != SOURCE_NUM_FRAMES: + raise ValueError(f"source_num_frames must be {SOURCE_NUM_FRAMES}") + resolved_frames = _strict_tuple(frame_indices, FRAME_INDICES, name="frame_indices") + if type(input_size) is not int or input_size != INPUT_SIZE: + raise ValueError(f"input_size must be {INPUT_SIZE}") + if type(patch_size) is not int or patch_size != PATCH_SIZE: + raise ValueError(f"patch_size must be {PATCH_SIZE}") + if type(patch_grid_size) is not int or patch_grid_size != PATCH_GRID_SIZE: + raise ValueError(f"patch_grid_size must be {PATCH_GRID_SIZE}") + resolved_axes = _strict_tuple( + spatial_axis_indices, + SPATIAL_AXIS_INDICES, + name="spatial_axis_indices", + ) + if type(forward_chunk_frames) is not int or forward_chunk_frames != FORWARD_CHUNK_FRAMES: + raise ValueError(f"forward_chunk_frames must be {FORWARD_CHUNK_FRAMES}") + if type(feature_dim) is not int or feature_dim != FEATURE_DIM: + raise ValueError(f"feature_dim must be {FEATURE_DIM}") + if source_commit != SOURCE_COMMIT: + raise ValueError( + "source_commit must equal the pinned DINOv3 source commit " + f"{SOURCE_COMMIT}" + ) + if not isinstance(vae_id, str) or not vae_id or "\0" in vae_id: + raise ValueError("vae_id must be a non-empty string without NUL characters") + + per_frame = spatial_patch_indices() + if spatial_token_indices is not None: + supplied_tokens = ( + spatial_token_indices.detach() + if isinstance(spatial_token_indices, torch.Tensor) + else torch.tensor(tuple(spatial_token_indices)) + ) + if ( + supplied_tokens.dtype == torch.bool + or supplied_tokens.is_floating_point() + or supplied_tokens.is_complex() + ): + raise TypeError("spatial_token_indices must contain integers") + supplied_tokens = supplied_tokens.detach().to( + device="cpu", dtype=torch.long).contiguous() + if not torch.equal(supplied_tokens, per_frame): + raise ValueError( + "spatial_token_indices must equal the canonical 64 patch indices" + ) + across_video = dense_token_indices() + matrix_rows = len(resolved_frames) * int(per_frame.numel()) + raw_tokens = len(resolved_frames) * PATCH_GRID_SIZE * PATCH_GRID_SIZE + if type(per_frame_tokens) is not int or per_frame_tokens != int(per_frame.numel()): + raise ValueError(f"per_frame_tokens must be {int(per_frame.numel())}") + if type(num_tokens) is not int or num_tokens != matrix_rows: + raise ValueError(f"num_tokens must be {matrix_rows}") + metadata: dict[str, object] = { + "feature_metadata_version": FEATURE_METADATA_VERSION, + "feature_type": FEATURE_TYPE, + "backend": "meta_native_pinned_repo_direct_import", + "model_id": MODEL_ID, + "repo_path": _canonical_location(repo_path, name="repo_path"), + "weight_path": _canonical_location(weight_path, name="weight_path"), + "source_commit": source_commit, + "weight_sha256": _sha256_hex(weight_sha256, name="weight_sha256"), + "vae_id": vae_id, + "source_num_frames": SOURCE_NUM_FRAMES, + "num_frames": len(resolved_frames), + "frame_sampling": "fixed_uniform_linspace_round_81_to_16", + "frame_indices": list(resolved_frames), + "frame_indices_sha256": integer_indices_sha256(resolved_frames), + "input_size": INPUT_SIZE, + "resize_mode": "direct_square", + "interpolation": "bilinear_antialias_align_corners_false", + "normalization": "imagenet_default", + "normalization_mean": [0.485, 0.456, 0.406], + "normalization_std": [0.229, 0.224, 0.225], + "patch_size": PATCH_SIZE, + "patch_grid": [PATCH_GRID_SIZE, PATCH_GRID_SIZE], + "prefix_tokens": 1 + STORAGE_TOKENS, + "output_field": "x_norm_patchtokens", + "final_norm": True, + "raw_patch_tokens_per_frame": PATCH_GRID_SIZE * PATCH_GRID_SIZE, + "spatial_axis_indices": list(resolved_axes), + "spatial_patch_indices": [int(value) for value in per_frame.tolist()], + "spatial_token_indices": [int(value) for value in per_frame.tolist()], + "spatial_patch_indices_sha256": integer_indices_sha256(per_frame), + "spatial_token_indices_sha256": integer_indices_sha256(per_frame), + "spatial_sampling": "fixed_cartesian_rows_then_columns", + "selected_tokens_per_frame": int(per_frame.numel()), + "raw_tokens": raw_tokens, + "num_tokens": matrix_rows, + "token_sampling": "fixed_spatial_cartesian_per_fixed_frame", + "token_order": "frame_major_then_patch_row_then_patch_column", + "dense_token_indices_sha256": integer_indices_sha256(across_video), + "forward_chunk_frames": FORWARD_CHUNK_FRAMES, + "compute_dtype": "float32", + "feature_dim": FEATURE_DIM, + "sample_axis": "video", + "raw_matrix_shape": [raw_tokens, FEATURE_DIM], + "sampled_matrix_shape": [matrix_rows, FEATURE_DIM], + "matrix_shape": [matrix_rows, FEATURE_DIM], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + metadata["feature_contract_sha256"] = canonical_json_sha256(metadata) + return metadata + + +__all__ = [ + "DEFAULT_WAN_VAE_ID", + "FEATURE_DIM", + "FEATURE_METADATA_VERSION", + "FEATURE_TYPE", + "FORWARD_CHUNK_FRAMES", + "FRAME_INDICES", + "INPUT_SIZE", + "MODEL_ID", + "PATCH_GRID_SIZE", + "PATCH_SIZE", + "SOURCE_NUM_FRAMES", + "SOURCE_COMMIT", + "SPATIAL_AXIS_INDICES", + "canonical_dinov3_dense_feature_metadata", + "canonical_json_sha256", + "dense_token_indices", + "file_sha256", + "integer_indices_sha256", + "module_state_sha256", + "spatial_patch_indices", +] diff --git a/14B/code/video_fd/encoders/dinov3_temporal_pool_adapter.py b/14B/code/video_fd/encoders/dinov3_temporal_pool_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..3f4495f577f7697a4bb4a756cd0f592d52cfaf9d --- /dev/null +++ b/14B/code/video_fd/encoders/dinov3_temporal_pool_adapter.py @@ -0,0 +1,347 @@ +"""Frozen DINOv3 adapter for 20-frame feature-space temporal pooling.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Optional + +import torch +from torch import nn + +from video_fd.encoders.dinov3_adapter import DINOv3DenseAdapter +from video_fd.encoders.dinov3_dense_metadata import ( + FORWARD_CHUNK_FRAMES as BASE_FORWARD_CHUNK_FRAMES, + FRAME_INDICES as BASE_FRAME_INDICES, + SPATIAL_AXIS_INDICES as BASE_SPATIAL_AXIS_INDICES, +) +from video_fd.encoders.dinov3_temporal_pool_metadata import ( + FEATURE_DIM, + FORWARD_CHUNK_FRAMES, + FRAME_INDICES, + GEOMETRY, + NUM_TOKENS, + PER_FRAME_TOKENS, + RAW_TOKENS, + SPATIAL_AXIS_INDICES, + TEMPORAL_GROUPS, + TEMPORAL_POOL_FRAMES, + canonical_dinov3_temporal_pool_feature_metadata, + full_patch_indices, +) + + +def _strict_tuple(config: dict, key: str, expected: tuple[int, ...]) -> tuple[int, ...]: + raw = config.get(key, expected) + if isinstance(raw, (str, bytes)): + raise TypeError(f"{key} must be an integer sequence") + values = tuple(raw) + if any(type(value) is not int for value in values): + raise TypeError(f"{key} must contain Python integers") + if values != expected: + raise ValueError(f"{key} must equal {list(expected)}") + return values + + +class DINOv3TemporalPoolAdapter(DINOv3DenseAdapter): + """Encode 20 frames, mean features by five, and retain all patches.""" + + def __init__(self, config, model: Optional[nn.Module] = None): + requested = dict(config) + geometry = str(requested.get("geometry", GEOMETRY)) + if geometry != GEOMETRY: + raise ValueError(f"geometry must be {GEOMETRY!r}") + frames = _strict_tuple(requested, "frame_indices", FRAME_INDICES) + axes = _strict_tuple( + requested, "spatial_axis_indices", SPATIAL_AXIS_INDICES) + temporal_groups = int(requested.get( + "temporal_groups", TEMPORAL_GROUPS)) + temporal_pool_frames = int(requested.get( + "temporal_pool_frames", + requested.get("temporal_pool_size", TEMPORAL_POOL_FRAMES), + )) + if temporal_groups != TEMPORAL_GROUPS: + raise ValueError(f"temporal_groups must be {TEMPORAL_GROUPS}") + if temporal_pool_frames != TEMPORAL_POOL_FRAMES: + raise ValueError( + f"temporal_pool_frames must be {TEMPORAL_POOL_FRAMES}") + if int(requested.get("per_frame_tokens", PER_FRAME_TOKENS)) != PER_FRAME_TOKENS: + raise ValueError(f"per_frame_tokens must be {PER_FRAME_TOKENS}") + if int(requested.get("num_tokens", NUM_TOKENS)) != NUM_TOKENS: + raise ValueError(f"num_tokens must be {NUM_TOKENS}") + if int(requested.get( + "forward_chunk_frames", FORWARD_CHUNK_FRAMES + )) != FORWARD_CHUNK_FRAMES: + raise ValueError( + f"forward_chunk_frames must be {FORWARD_CHUNK_FRAMES}") + + # The parent owns the pinned native model load, FP32/frozen checks, + # resize and normalization implementation. Initialize that machinery + # with its immutable compatibility geometry, then replace only the + # sampling buffers and public geometry before any feature extraction. + base_config = dict(requested) + base_config.update({ + "frame_indices": BASE_FRAME_INDICES, + "spatial_axis_indices": BASE_SPATIAL_AXIS_INDICES, + "forward_chunk_frames": BASE_FORWARD_CHUNK_FRAMES, + "per_frame_tokens": 64, + "num_tokens": 1024, + }) + for key in ( + "geometry", + "temporal_groups", + "temporal_pool_frames", + "temporal_pool_size", + "spatial_token_indices", + ): + base_config.pop(key, None) + super().__init__(base_config, model=model) + + self.config = requested + self.geometry = GEOMETRY + self.frame_indices = frames + self.dense_num_frames = len(frames) + self.spatial_axis_indices = axes + self.spatial_patch_indices = tuple(range(PER_FRAME_TOKENS)) + self.spatial_token_indices = self.spatial_patch_indices + self.temporal_groups = TEMPORAL_GROUPS + self.temporal_pool_frames = TEMPORAL_POOL_FRAMES + self.temporal_pool_size = TEMPORAL_POOL_FRAMES + self.per_frame_tokens = PER_FRAME_TOKENS + self.dense_raw_tokens = RAW_TOKENS + self.dense_num_tokens = NUM_TOKENS + self.dense_feature_dim = FEATURE_DIM + self.raw_tokens = RAW_TOKENS + self.num_tokens = NUM_TOKENS + self.feature_dim = FEATURE_DIM + self.forward_chunk_frames = FORWARD_CHUNK_FRAMES + self._frame_indices_tensor = torch.tensor(frames, dtype=torch.long) + self._spatial_patch_indices_tensor = full_patch_indices() + + @property + def dense_token_indices(self) -> tuple[int, ...]: + # Pooled rows are means, not a subset of raw frame-patch rows. + return tuple(range(NUM_TOKENS)) + + def _resolve_token_indices( + self, + token_indices: Optional[torch.Tensor | Sequence[int]], + ) -> torch.Tensor: + canonical = self._spatial_patch_indices_tensor + if token_indices is None: + return canonical + supplied = ( + token_indices.detach() + if isinstance(token_indices, torch.Tensor) + else torch.tensor(tuple(token_indices)) + ) + if ( + supplied.dtype == torch.bool + or supplied.is_floating_point() + or supplied.is_complex() + ): + raise TypeError("token_indices must contain integers") + supplied = supplied.detach().to( + device=canonical.device, dtype=torch.long).contiguous() + if not torch.equal(supplied, canonical): + raise ValueError( + "token_indices must contain all canonical 256 patch indices") + return canonical + + def _pool_raw_features( + self, raw_features: torch.Tensor, *, batch_size: int + ) -> torch.Tensor: + expected = ( + batch_size * self.dense_num_frames, + PER_FRAME_TOKENS, + FEATURE_DIM, + ) + if tuple(raw_features.shape) != expected: + raise RuntimeError( + f"raw DINOv3 features must have shape {expected}, got " + f"{tuple(raw_features.shape)}") + return raw_features.reshape( + batch_size, + TEMPORAL_GROUPS, + TEMPORAL_POOL_FRAMES, + PER_FRAME_TOKENS, + FEATURE_DIM, + ).mean(dim=2).reshape(batch_size, NUM_TOKENS, FEATURE_DIM) + + def forward_dense( + self, + video_clip_01: torch.Tensor, + token_indices: Optional[torch.Tensor | Sequence[int]] = None, + gradient_checkpointing: bool = False, + ) -> torch.Tensor: + if type(gradient_checkpointing) is not bool: + raise TypeError("gradient_checkpointing must be a bool") + self._verify_model_fp32() + self._validate_video(video_clip_01) + resolved_tokens = self._resolve_token_indices(token_indices) + batch_size = int(video_clip_01.shape[0]) + flat_frames = self._selected_flat_frames(video_clip_01) + chunks = [] + for start in range(0, int(flat_frames.shape[0]), self.forward_chunk_frames): + chunks.append(self._encode_frame_batch( + flat_frames[start:start + self.forward_chunk_frames], + resolved_tokens, + )) + return self._pool_raw_features( + torch.cat(chunks, dim=0), batch_size=batch_size) + + @torch.no_grad() + def forward_dense_no_grad( + self, + video_clip_01: torch.Tensor, + token_indices: Optional[torch.Tensor | Sequence[int]] = None, + gradient_checkpointing: bool = False, + ) -> torch.Tensor: + return self.forward_dense( + video_clip_01, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ).detach().float().clone() + + extract_dense_no_grad = forward_dense_no_grad + + def replay_dense_vjp( + self, + video_clip_01: torch.Tensor, + feature_cotangent: torch.Tensor, + *, + expected_features: Optional[torch.Tensor] = None, + token_indices: Optional[torch.Tensor | Sequence[int]] = None, + gradient_checkpointing: bool = False, + replay_atol: float = 1.0e-6, + replay_rtol: float = 1.0e-5, + ) -> tuple[torch.Tensor, torch.Tensor]: + if type(gradient_checkpointing) is not bool: + raise TypeError("gradient_checkpointing must be a bool") + self._verify_model_fp32() + self._validate_video(video_clip_01) + batch_size = int(video_clip_01.shape[0]) + expected_shape = (batch_size, NUM_TOKENS, FEATURE_DIM) + if ( + not isinstance(feature_cotangent, torch.Tensor) + or not feature_cotangent.is_floating_point() + or tuple(feature_cotangent.shape) != expected_shape + or not bool(torch.isfinite(feature_cotangent).all().item()) + ): + raise ValueError( + "feature_cotangent must be finite floating with shape " + f"{expected_shape}") + if replay_atol < 0 or replay_rtol < 0: + raise ValueError("replay tolerances must be non-negative") + + resolved_tokens = self._resolve_token_indices(token_indices) + flat_frames = self._selected_flat_frames(video_clip_01.detach()) + pooled_cotangent = feature_cotangent.detach().float().reshape( + batch_size, + TEMPORAL_GROUPS, + PER_FRAME_TOKENS, + FEATURE_DIM, + ) + raw_cotangent = ( + pooled_cotangent[:, :, None] + .expand( + batch_size, + TEMPORAL_GROUPS, + TEMPORAL_POOL_FRAMES, + PER_FRAME_TOKENS, + FEATURE_DIM, + ) + .reshape( + batch_size * self.dense_num_frames, + PER_FRAME_TOKENS, + FEATURE_DIM, + ) + / float(TEMPORAL_POOL_FRAMES) + ) + + replay_raw_chunks = [] + gradient_chunks = [] + for start in range(0, int(flat_frames.shape[0]), self.forward_chunk_frames): + end = min(start + self.forward_chunk_frames, int(flat_frames.shape[0])) + frame_leaf = flat_frames[start:end].detach().clone().requires_grad_(True) + raw_features = self._encode_frame_batch(frame_leaf, resolved_tokens) + surrogate = ( + raw_features + * raw_cotangent[start:end].to(raw_features.device) + ).sum() + (chunk_gradient,) = torch.autograd.grad( + surrogate, + frame_leaf, + retain_graph=False, + create_graph=False, + ) + replay_raw_chunks.append(raw_features.detach().float()) + gradient_chunks.append(chunk_gradient.detach().float()) + + replay_features = self._pool_raw_features( + torch.cat(replay_raw_chunks, dim=0), batch_size=batch_size) + if expected_features is not None: + if ( + not isinstance(expected_features, torch.Tensor) + or tuple(expected_features.shape) != expected_shape + or not bool(torch.isfinite(expected_features).all().item()) + ): + raise ValueError( + f"expected_features must be finite with shape {expected_shape}") + comparison = expected_features.detach().to( + device=replay_features.device, dtype=torch.float32) + if not torch.allclose( + replay_features, + comparison, + atol=float(replay_atol), + rtol=float(replay_rtol), + ): + maximum = float((replay_features - comparison).abs().max().item()) + raise RuntimeError( + "DINOv3 temporal-pool replay differs from first pass: " + f"max_abs={maximum:.8g}") + + selected_gradient = torch.cat(gradient_chunks, dim=0).reshape( + batch_size, + self.dense_num_frames, + *video_clip_01.shape[2:], + ) + full_pixel_cotangent = torch.zeros( + video_clip_01.shape, + device=video_clip_01.device, + dtype=torch.float32, + ) + full_pixel_cotangent.index_copy_( + 1, + self._frame_indices_tensor.to(video_clip_01.device), + selected_gradient, + ) + if not bool(torch.isfinite(full_pixel_cotangent).all().item()): + raise FloatingPointError( + "DINOv3 temporal-pool replay returned non-finite pixels") + return replay_features, full_pixel_cotangent + + def feature_metadata(self) -> dict[str, object]: + return canonical_dinov3_temporal_pool_feature_metadata( + repo_path=self.repo_path, + weight_path=self.weight_path, + source_commit=self.source_commit, + weight_sha256=self.weight_sha256, + source_num_frames=self.source_num_frames, + frame_indices=self.frame_indices, + input_size=self.input_size, + patch_size=self.patch_size, + patch_grid_size=self.patch_grid_size, + spatial_axis_indices=self.spatial_axis_indices, + geometry=self.geometry, + temporal_groups=self.temporal_groups, + temporal_pool_frames=self.temporal_pool_frames, + per_frame_tokens=self.per_frame_tokens, + num_tokens=self.dense_num_tokens, + forward_chunk_frames=self.forward_chunk_frames, + feature_dim=self.dense_feature_dim, + model_id=self.model_id, + vae_id=self.vae_id, + ) + + +__all__ = ["DINOv3TemporalPoolAdapter"] diff --git a/14B/code/video_fd/encoders/dinov3_temporal_pool_metadata.py b/14B/code/video_fd/encoders/dinov3_temporal_pool_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..0955e6a4b1f6f24866f9861ab98971be86ab79bc --- /dev/null +++ b/14B/code/video_fd/encoders/dinov3_temporal_pool_metadata.py @@ -0,0 +1,224 @@ +"""Identity metadata for the 20-frame DINOv3 temporal-pool feature. + +Twenty uniformly sampled frames are encoded independently with the frozen +DINOv3 ViT-L/16. The complete 16x16 final-normalized patch grid is retained +for every frame, then each consecutive group of five frame features is +averaged in FP32. The four pooled grids are flattened group-major to one +``[1024, 1024]`` matrix per video. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +import torch + +from video_fd.encoders.dinov3_dense_metadata import ( + DEFAULT_WAN_VAE_ID, + FEATURE_DIM, + FEATURE_TYPE, + INPUT_SIZE, + MODEL_ID, + PATCH_GRID_SIZE, + PATCH_SIZE, + SOURCE_COMMIT, + SOURCE_NUM_FRAMES, + canonical_dinov3_dense_feature_metadata, + canonical_json_sha256, + file_sha256, + integer_indices_sha256, + module_state_sha256, +) + + +FEATURE_METADATA_VERSION = 2 +GEOMETRY: Final[str] = "temporal_mean5_full256" +FRAME_INDICES = ( + 0, 4, 8, 13, 17, + 21, 25, 29, 34, 38, + 42, 46, 51, 55, 59, + 63, 67, 72, 76, 80, +) +SPATIAL_AXIS_INDICES = tuple(range(PATCH_GRID_SIZE)) +SPATIAL_PATCH_INDICES = tuple(range(PATCH_GRID_SIZE * PATCH_GRID_SIZE)) +TEMPORAL_GROUPS = 4 +TEMPORAL_POOL_FRAMES = 5 +FORWARD_CHUNK_FRAMES = 4 +PER_FRAME_TOKENS = PATCH_GRID_SIZE * PATCH_GRID_SIZE +RAW_TOKENS = len(FRAME_INDICES) * PER_FRAME_TOKENS +NUM_TOKENS = TEMPORAL_GROUPS * PER_FRAME_TOKENS + + +def _strict_integer_tuple( + actual: Sequence[int], + expected: tuple[int, ...], + *, + name: str, +) -> tuple[int, ...]: + if isinstance(actual, (str, bytes)): + raise TypeError(f"{name} must be an integer sequence") + values = tuple(actual) + if any(type(value) is not int for value in values): + raise TypeError(f"{name} must contain Python integers") + if values != expected: + raise ValueError(f"{name} must equal {list(expected)}") + return values + + +def full_patch_indices( + *, device: torch.device | str | int | None = None, +) -> torch.Tensor: + return torch.arange(PER_FRAME_TOKENS, dtype=torch.long, device=device) + + +def temporal_group_frame_indices() -> tuple[tuple[int, ...], ...]: + return tuple( + FRAME_INDICES[start:start + TEMPORAL_POOL_FRAMES] + for start in range(0, len(FRAME_INDICES), TEMPORAL_POOL_FRAMES) + ) + + +def canonical_dinov3_temporal_pool_feature_metadata( + *, + repo_path: str, + weight_path: str, + source_commit: str, + weight_sha256: str, + source_num_frames: int = SOURCE_NUM_FRAMES, + frame_indices: Sequence[int] = FRAME_INDICES, + input_size: int = INPUT_SIZE, + patch_size: int = PATCH_SIZE, + patch_grid_size: int = PATCH_GRID_SIZE, + spatial_axis_indices: Sequence[int] = SPATIAL_AXIS_INDICES, + spatial_token_indices: Sequence[int] | torch.Tensor | None = None, + geometry: str = GEOMETRY, + temporal_groups: int = TEMPORAL_GROUPS, + temporal_pool_frames: int = TEMPORAL_POOL_FRAMES, + per_frame_tokens: int = PER_FRAME_TOKENS, + num_tokens: int = NUM_TOKENS, + forward_chunk_frames: int = FORWARD_CHUNK_FRAMES, + feature_dim: int = FEATURE_DIM, + model_id: str = MODEL_ID, + vae_id: str = DEFAULT_WAN_VAE_ID, +) -> dict[str, object]: + """Return the strict online/offline identity for this feature geometry.""" + + resolved_frames = _strict_integer_tuple( + frame_indices, FRAME_INDICES, name="frame_indices") + resolved_axes = _strict_integer_tuple( + spatial_axis_indices, SPATIAL_AXIS_INDICES, + name="spatial_axis_indices") + if spatial_token_indices is not None: + supplied_tokens = ( + spatial_token_indices.detach() + if isinstance(spatial_token_indices, torch.Tensor) + else torch.tensor(tuple(spatial_token_indices)) + ) + if ( + supplied_tokens.dtype == torch.bool + or supplied_tokens.is_floating_point() + or supplied_tokens.is_complex() + ): + raise TypeError("spatial_token_indices must contain integers") + supplied_tokens = supplied_tokens.detach().to( + device="cpu", dtype=torch.long).contiguous() + if not torch.equal(supplied_tokens, full_patch_indices()): + raise ValueError( + "spatial_token_indices must contain all 256 patch indices") + exact_values = { + "source_num_frames": (source_num_frames, SOURCE_NUM_FRAMES), + "input_size": (input_size, INPUT_SIZE), + "patch_size": (patch_size, PATCH_SIZE), + "patch_grid_size": (patch_grid_size, PATCH_GRID_SIZE), + "temporal_groups": (temporal_groups, TEMPORAL_GROUPS), + "temporal_pool_frames": (temporal_pool_frames, TEMPORAL_POOL_FRAMES), + "per_frame_tokens": (per_frame_tokens, PER_FRAME_TOKENS), + "num_tokens": (num_tokens, NUM_TOKENS), + "forward_chunk_frames": (forward_chunk_frames, FORWARD_CHUNK_FRAMES), + "feature_dim": (feature_dim, FEATURE_DIM), + } + for name, (actual, expected) in exact_values.items(): + if type(actual) is not int or actual != expected: + raise ValueError(f"{name} must be {expected}") + if geometry != GEOMETRY: + raise ValueError(f"geometry must be {GEOMETRY!r}") + if model_id != MODEL_ID: + raise ValueError(f"model_id must be {MODEL_ID!r}") + + # Reuse the original contract builder for checkpoint/source/path and + # preprocessing validation, then replace every geometry-dependent field. + metadata = canonical_dinov3_dense_feature_metadata( + repo_path=repo_path, + weight_path=weight_path, + source_commit=source_commit, + weight_sha256=weight_sha256, + model_id=model_id, + vae_id=vae_id, + ) + patches = full_patch_indices() + groups = temporal_group_frame_indices() + metadata.pop("feature_contract_sha256", None) + metadata.pop("dense_token_indices_sha256", None) + metadata.update({ + "feature_metadata_version": FEATURE_METADATA_VERSION, + "geometry": GEOMETRY, + "source_num_frames": SOURCE_NUM_FRAMES, + "num_frames": len(resolved_frames), + "frame_sampling": "fixed_uniform_linspace_round_81_to_20", + "frame_indices": list(resolved_frames), + "frame_indices_sha256": integer_indices_sha256(resolved_frames), + "spatial_axis_indices": list(resolved_axes), + "spatial_patch_indices": patches.tolist(), + "spatial_token_indices": patches.tolist(), + "spatial_patch_indices_sha256": integer_indices_sha256(patches), + "spatial_token_indices_sha256": integer_indices_sha256(patches), + "spatial_sampling": "all_patch_tokens_row_major", + "selected_tokens_per_frame": PER_FRAME_TOKENS, + "raw_tokens": RAW_TOKENS, + "num_tokens": NUM_TOKENS, + "token_sampling": "all_patches_then_temporal_feature_mean", + "token_order": ( + "temporal_group_major_then_patch_row_then_patch_column"), + "temporal_groups": TEMPORAL_GROUPS, + "temporal_pool_frames": TEMPORAL_POOL_FRAMES, + "temporal_pool_size": TEMPORAL_POOL_FRAMES, + "temporal_pool": "fp32_mean_over_each_consecutive_5_frames", + "temporal_group_frame_indices": [list(group) for group in groups], + "temporal_group_frame_indices_sha256": [ + integer_indices_sha256(group) for group in groups + ], + "tokens_per_group": PER_FRAME_TOKENS, + "forward_chunk_frames": FORWARD_CHUNK_FRAMES, + "raw_matrix_shape": [RAW_TOKENS, FEATURE_DIM], + "sampled_matrix_shape": [NUM_TOKENS, FEATURE_DIM], + "matrix_shape": [NUM_TOKENS, FEATURE_DIM], + }) + metadata["feature_contract_sha256"] = canonical_json_sha256(metadata) + return metadata + + +__all__ = [ + "FEATURE_DIM", + "FEATURE_TYPE", + "FORWARD_CHUNK_FRAMES", + "FRAME_INDICES", + "GEOMETRY", + "INPUT_SIZE", + "MODEL_ID", + "NUM_TOKENS", + "PATCH_GRID_SIZE", + "PATCH_SIZE", + "PER_FRAME_TOKENS", + "RAW_TOKENS", + "SOURCE_COMMIT", + "SOURCE_NUM_FRAMES", + "SPATIAL_AXIS_INDICES", + "TEMPORAL_GROUPS", + "TEMPORAL_POOL_FRAMES", + "canonical_dinov3_temporal_pool_feature_metadata", + "file_sha256", + "full_patch_indices", + "module_state_sha256", + "temporal_group_frame_indices", +] diff --git a/14B/code/video_fd/encoders/segmented_videomae_v2.py b/14B/code/video_fd/encoders/segmented_videomae_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..89ff471aed51ac0e7fd6b3883c05274c67220585 --- /dev/null +++ b/14B/code/video_fd/encoders/segmented_videomae_v2.py @@ -0,0 +1,329 @@ +"""Five-segment VideoMAE-v2 encoder for an 81-frame Wan video. + +Frame zero is intentionally excluded. The remaining 80 frames are split into +five fixed, adjacent 16-frame segments. Production training may reuse one +frozen checkpoint instance for every segment; the legacy five-replica layout +remains available for compatibility. Selected dense tokens are concatenated +in chronological segment order. +""" + +from __future__ import annotations + +from typing import Callable, Dict, Optional, Sequence + +import torch +import torch.nn as nn + +from video_fd.encoders.videomae_v2_adapter import VideoMAEv2Adapter + + +class SegmentedVideoMAEv2Ensemble(nn.Module): + """Five logical same-checkpoint segments with a V-JEPA-compatible API.""" + + def __init__( + self, + config, + *, + adapter_factory: Optional[Callable[[dict], nn.Module]] = None, + ) -> None: + super().__init__() + self.config = dict(config) + self.encoder_count = int(self.config.get("encoder_count", 5)) + self.share_encoder_weights = bool( + self.config.get("share_encoder_weights", False) + ) + self.skip_leading_frames = int( + self.config.get("skip_leading_frames", 1)) + self.segment_num_frames = int( + self.config.get("segment_num_frames", 16)) + self.source_num_frames = int( + self.config.get("source_num_frames", 81)) + if self.encoder_count != 5: + raise ValueError( + "the VideoMAE-v2 whole-video contract requires exactly five " + f"encoders, got {self.encoder_count}") + if self.skip_leading_frames != 1: + raise ValueError( + "the VideoMAE-v2 whole-video contract skips exactly one " + f"leading frame, got {self.skip_leading_frames}") + if self.segment_num_frames != 16: + raise ValueError( + "each VideoMAE-v2 segment must contain 16 frames, got " + f"{self.segment_num_frames}") + expected_source = ( + self.skip_leading_frames + + self.encoder_count * self.segment_num_frames + ) + if self.source_num_frames != expected_source: + raise ValueError( + "source_num_frames must exactly cover the skipped frame and " + f"five segments: {self.source_num_frames} != {expected_source}") + + factory = adapter_factory or VideoMAEv2Adapter + adapter_config = dict(self.config) + adapter_config.update({ + "input_frames": self.segment_num_frames, + "dense_num_frames": self.segment_num_frames, + }) + physical_encoder_count = ( + 1 if self.share_encoder_weights else self.encoder_count + ) + self.encoders = nn.ModuleList([ + factory(dict(adapter_config)) + for _ in range(physical_encoder_count) + ]) + self._validate_replicas() + + def _validate_replicas(self) -> None: + expected_physical = 1 if self.share_encoder_weights else self.encoder_count + if len(self.encoders) != expected_physical: + raise RuntimeError("VideoMAE-v2 replica construction was incomplete") + if len({id(encoder) for encoder in self.encoders}) != expected_physical: + raise RuntimeError( + "VideoMAE-v2 physical encoder instances are not distinct") + first = self.encoders[0] + geometry = ( + int(first.dense_layer_index), + int(first.dense_num_frames), + int(first.resize_short_side), + int(first.input_size), + int(first.retained_dense_blocks), + int(first.dense_raw_tokens), + int(first.dense_feature_dim), + ) + for index, encoder in enumerate(self.encoders[1:], start=1): + candidate = ( + int(encoder.dense_layer_index), + int(encoder.dense_num_frames), + int(encoder.resize_short_side), + int(encoder.input_size), + int(encoder.retained_dense_blocks), + int(encoder.dense_raw_tokens), + int(encoder.dense_feature_dim), + ) + if candidate != geometry: + raise RuntimeError( + "VideoMAE-v2 replica geometry differs at index " + f"{index}: {candidate} != {geometry}") + storage_sets = [] + for encoder in self.encoders: + storage_sets.append({ + parameter.untyped_storage().data_ptr() + for parameter in encoder.parameters() + if parameter.numel() > 0 + }) + for left in range(len(storage_sets)): + for right in range(left + 1, len(storage_sets)): + if storage_sets[left].intersection(storage_sets[right]): + raise RuntimeError( + "VideoMAE-v2 replicas unexpectedly share parameter " + f"storage between segments {left} and {right}") + + def _encoder_for_segment(self, index: int) -> nn.Module: + if index < 0 or index >= self.encoder_count: + raise IndexError(f"segment index outside [0,{self.encoder_count})") + return self.encoders[0 if self.share_encoder_weights else index] + + @property + def dense_layer_index(self) -> int: + return int(self.encoders[0].dense_layer_index) + + @property + def dense_num_frames(self) -> int: + return self.segment_num_frames + + @property + def resize_short_side(self) -> int: + return int(self.encoders[0].resize_short_side) + + @property + def input_size(self) -> int: + return int(self.encoders[0].input_size) + + @property + def retained_dense_blocks(self) -> int: + return int(self.encoders[0].retained_dense_blocks) + + @property + def dense_raw_tokens(self) -> int: + """Raw tokens per 16-frame segment, not the five-segment total.""" + return int(self.encoders[0].dense_raw_tokens) + + @property + def aggregate_dense_raw_tokens(self) -> int: + return self.encoder_count * self.dense_raw_tokens + + @property + def dense_feature_dim(self) -> int: + return int(self.encoders[0].dense_feature_dim) + + def _validate_video(self, video_clip_01: torch.Tensor) -> None: + if video_clip_01.ndim != 5 or int(video_clip_01.shape[2]) != 3: + raise ValueError( + "video_clip_01 must have shape [B,81,3,H,W], got " + f"{tuple(video_clip_01.shape)}") + if int(video_clip_01.shape[1]) != self.source_num_frames: + raise ValueError( + "segmented VideoMAE-v2 requires exactly " + f"{self.source_num_frames} decoded frames, got " + f"{int(video_clip_01.shape[1])}") + + def segment(self, video_clip_01: torch.Tensor, index: int) -> torch.Tensor: + self._validate_video(video_clip_01) + index = int(index) + if index < 0 or index >= self.encoder_count: + raise IndexError(f"segment index outside [0,{self.encoder_count})") + start = self.skip_leading_frames + index * self.segment_num_frames + return video_clip_01.narrow(1, start, self.segment_num_frames) + + def forward_dense( + self, + video_clip_01: torch.Tensor, + layer_index: Optional[int] = None, + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + if temporal_window_starts is not None or temporal_window_indices is not None: + raise ValueError( + "fixed five-segment VideoMAE-v2 does not accept temporal windows") + outputs = [ + self._encoder_for_segment(index).forward_dense( + self.segment(video_clip_01, index), + layer_index=layer_index, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + for index in range(self.encoder_count) + ] + return torch.cat(outputs, dim=1) + + def forward_dense_layers( + self, + video_clip_01: torch.Tensor, + layer_indices: Sequence[int], + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> Dict[int, torch.Tensor]: + if temporal_window_starts is not None or temporal_window_indices is not None: + raise ValueError( + "fixed five-segment VideoMAE-v2 does not accept temporal windows") + layers = tuple(int(layer) for layer in layer_indices) + by_segment = [ + self._encoder_for_segment(index).forward_dense_layers( + self.segment(video_clip_01, index), + layer_indices=layers, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + for index in range(self.encoder_count) + ] + return { + layer: torch.cat( + [segment_features[layer] for segment_features in by_segment], + dim=1, + ) + for layer in layers + } + + def replay_dense_vjp( + self, + video_clip_01: torch.Tensor, + *, + layer_indices: Sequence[int], + token_indices: torch.Tensor, + expected_features: torch.Tensor, + feature_cotangent: torch.Tensor, + gradient_checkpointing: bool, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Replay five encoder VJPs sequentially to cap activation memory. + + Feature transport is layer-major and, within a layer, segment-major. + The returned pixel cotangent has a zero first frame by construction. + """ + self._validate_video(video_clip_01) + layers = tuple(int(layer) for layer in layer_indices) + if not layers: + raise ValueError("layer_indices must not be empty") + tokens_per_segment = int(token_indices.numel()) + tokens_per_layer = self.encoder_count * tokens_per_segment + expected_shape = ( + int(video_clip_01.shape[0]), + len(layers) * tokens_per_layer, + self.dense_feature_dim, + ) + if tuple(expected_features.shape) != expected_shape: + raise ValueError( + "expected_features has the wrong segmented shape: " + f"{tuple(expected_features.shape)} != {expected_shape}") + if tuple(feature_cotangent.shape) != expected_shape: + raise ValueError( + "feature_cotangent has the wrong segmented shape: " + f"{tuple(feature_cotangent.shape)} != {expected_shape}") + + expected_by_layer = expected_features.split(tokens_per_layer, dim=1) + cotangent_by_layer = feature_cotangent.split(tokens_per_layer, dim=1) + replay_by_layer = [[] for _ in layers] + video_cotangent = torch.zeros_like(video_clip_01, dtype=torch.float32) + + for segment_index in range(self.encoder_count): + encoder = self._encoder_for_segment(segment_index) + segment_leaf = self.segment( + video_clip_01, segment_index).detach().requires_grad_(True) + if len(layers) == 1: + outputs = { + layers[0]: encoder.forward_dense( + segment_leaf, + layer_index=layers[0], + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ).float() + } + else: + outputs = encoder.forward_dense_layers( + segment_leaf, + layer_indices=layers, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + outputs = {layer: value.float() for layer, value in outputs.items()} + + surrogate = segment_leaf.new_zeros((), dtype=torch.float32) + for layer_offset, layer in enumerate(layers): + start = segment_index * tokens_per_segment + expected_part = expected_by_layer[layer_offset].narrow( + 1, start, tokens_per_segment) + cotangent_part = cotangent_by_layer[layer_offset].narrow( + 1, start, tokens_per_segment) + output = outputs[layer] + if tuple(output.shape) != tuple(expected_part.shape): + raise RuntimeError( + "VideoMAE-v2 segment replay shape changed for " + f"segment={segment_index}, layer={layer}") + replay_by_layer[layer_offset].append(output.detach()) + surrogate = surrogate + (output * cotangent_part).sum() + (segment_cotangent,) = torch.autograd.grad( + surrogate, + segment_leaf, + retain_graph=False, + create_graph=False, + ) + start_frame = ( + self.skip_leading_frames + + segment_index * self.segment_num_frames + ) + video_cotangent[:, start_frame:start_frame + self.segment_num_frames] = ( + segment_cotangent.detach().float()) + del surrogate, segment_leaf, segment_cotangent, outputs + + replay_features = torch.cat([ + torch.cat(parts, dim=1) + for parts in replay_by_layer + ], dim=1) + return replay_features, video_cotangent + + +__all__ = ["SegmentedVideoMAEv2Ensemble"] diff --git a/14B/code/video_fd/encoders/videomae_v2_adapter.py b/14B/code/video_fd/encoders/videomae_v2_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..51db233135be30959c94e24f4e6fc1628f8c5aca --- /dev/null +++ b/14B/code/video_fd/encoders/videomae_v2_adapter.py @@ -0,0 +1,638 @@ +"""OpenGVLab VideoMAE V2 sparse and intermediate-dense adapter. + +The OpenGVLab Hugging Face checkpoints use remote code and wrap their +``VisionTransformer`` in ``model.model``. That implementation consumes +``[B, C, T, H, W]`` and returns a pooled video embedding from its public +forward method. Feature-distribution training instead needs the complete +post-residual token state of an encoder block, before mean pooling and +``fc_norm``. This adapter therefore executes the verified OpenGVLab backbone +prefix explicitly. + +There is deliberately no VideoMAE-v1 fallback: silently changing model family +would make reference-bank identity and token geometry incorrect. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from numbers import Integral +from pathlib import Path +from typing import Dict, Optional, Tuple + +import torch +import torch.nn as nn +from torch.utils.checkpoint import checkpoint + +from video_fd.encoders.base_sparse_video_encoder import ( + SparseVideoEncoderAdapter, + differentiable_resize_crop, +) + + +class VideoMAEv2Adapter(SparseVideoEncoderAdapter): + """Frozen OpenGVLab VideoMAE V2 adapter with a dense-prefix API. + + Dense mode handles one native checkpoint segment (normally 16 frames) per + call. Five-segment assembly is intentionally owned by the caller so five + separately placed/frozen encoder instances can be used by the training + model. + + ``model`` is an injection seam for tiny tests. Injected models must expose + the same contract as the OpenGVLab remote-code wrapper: nested + ``config.model_config`` and a ``model`` backbone with ``patch_embed``, + ``pos_embed``, ``pos_drop``, and ``blocks``. + """ + + def __init__(self, config, model: Optional[nn.Module] = None): + config = dict(config) + explicit_dense_num_frames = "dense_num_frames" in config + explicit_resize_short_side = ( + "resize_short_side" in config + or "dense_resize_short_side" in config + ) + config.setdefault("name", "videomae_v2") + config.setdefault("input_size", config.get("crop_size", 224)) + config.setdefault("input_frames", 16) + config.setdefault("norm_mean", (0.485, 0.456, 0.406)) + config.setdefault("norm_std", (0.229, 0.224, 0.225)) + super().__init__(config) + + self.model_id = str( + config.get("model_id", "assets/models/VideoMAEv2-Large") + ) + self.patch_size = int(config.get("patch_size", 16)) + self.tubelet_size = int(config.get("tubelet_size", 2)) + self.dense_layer_index = int(config.get("dense_layer_index", 15)) + self.dense_num_frames = int(config.get("dense_num_frames", 16)) + self.resize_short_side = int( + config.get( + "resize_short_side", + config.get("dense_resize_short_side", self.input_size), + ) + ) + self.dense_only = bool(config.get("dense_only", False)) + self._explicit_dense_num_frames = explicit_dense_num_frames + self._explicit_resize_short_side = explicit_resize_short_side + self._model: Optional[nn.Module] = None + self._configured_depth = 0 + self._embed_dim = 0 + self._dense_pruned = False + + if model is None: + self._load() + else: + self._adopt_model(model) + + # -- loading and verified OpenGVLab architecture ---------------------- + def _load(self) -> None: + model_dir = Path(self.model_id).expanduser() + if not model_dir.is_dir(): + raise FileNotFoundError(f"Prepare the local VideoMAEv2 model directory: {model_dir}") + self.model_id = str(model_dir.resolve()) + try: + from transformers import AutoModel # type: ignore + except Exception as exc: # pragma: no cover - optional dependency + raise ImportError( + "OpenGVLab VideoMAE V2 requires transformers.AutoModel with " + "trust_remote_code support" + ) from exc + + load_kwargs: Dict[str, object] = {"trust_remote_code": True} + for key in ("revision", "cache_dir"): + if key in self.config: + load_kwargs[key] = self.config[key] + load_kwargs["local_files_only"] = True + try: + model = AutoModel.from_pretrained(self.model_id, **load_kwargs) + except Exception as exc: # pragma: no cover - needs real checkpoint + raise RuntimeError( + f"failed to load OpenGVLab VideoMAE V2 checkpoint " + f"{self.model_id!r}; no VideoMAE-v1 fallback is permitted" + ) from exc + self._adopt_model(model) + + @staticmethod + def _config_value(model_config: object, name: str) -> object: + if isinstance(model_config, Mapping): + if name not in model_config: + raise RuntimeError( + f"OpenGVLab config.model_config is missing {name!r}" + ) + return model_config[name] + if not hasattr(model_config, name): + raise RuntimeError( + f"OpenGVLab config.model_config is missing {name!r}" + ) + return getattr(model_config, name) + + @staticmethod + def _positive_int(value: object, name: str) -> int: + if isinstance(value, bool): + raise RuntimeError(f"OpenGVLab {name} must be a positive integer") + if isinstance(value, (tuple, list)): + if len(value) != 2 or int(value[0]) != int(value[1]): + raise RuntimeError( + f"OpenGVLab {name} must describe a square geometry, got {value!r}" + ) + value = value[0] + try: + result = int(value) + except (TypeError, ValueError) as exc: + raise RuntimeError( + f"OpenGVLab {name} must be a positive integer, got {value!r}" + ) from exc + if result <= 0: + raise RuntimeError( + f"OpenGVLab {name} must be positive, got {result}" + ) + return result + + def _adopt_model(self, model: nn.Module) -> None: + cfg = getattr(model, "config", None) + model_config = getattr(cfg, "model_config", None) + if model_config is None: + raise RuntimeError( + "model is not an OpenGVLab VideoMAE V2 wrapper: expected " + "config.model_config (VideoMAE-v1 models are unsupported)" + ) + + patch_size = self._positive_int( + self._config_value(model_config, "patch_size"), + "model_config.patch_size", + ) + tubelet_size = self._positive_int( + self._config_value(model_config, "tubelet_size"), + "model_config.tubelet_size", + ) + input_size = self._positive_int( + self._config_value(model_config, "img_size"), + "model_config.img_size", + ) + input_frames = self._positive_int( + self._config_value(model_config, "num_frames"), + "model_config.num_frames", + ) + embed_dim = self._positive_int( + self._config_value(model_config, "embed_dim"), + "model_config.embed_dim", + ) + depth = self._positive_int( + self._config_value(model_config, "depth"), + "model_config.depth", + ) + + backbone = getattr(model, "model", None) + if not isinstance(backbone, nn.Module): + raise RuntimeError( + "OpenGVLab VideoMAE V2 wrapper must expose an nn.Module at model.model" + ) + for name in ("patch_embed", "pos_drop", "blocks"): + if not hasattr(backbone, name): + raise RuntimeError( + "OpenGVLab VideoMAE V2 backbone is missing " + f"model.{name}" + ) + blocks = getattr(backbone, "blocks") + if not isinstance(blocks, (nn.ModuleList, nn.Sequential)): + raise RuntimeError( + "OpenGVLab VideoMAE V2 model.blocks must be a ModuleList or Sequential" + ) + if len(blocks) != depth: + raise RuntimeError( + "OpenGVLab VideoMAE V2 depth mismatch: config.model_config.depth=" + f"{depth}, but model.blocks has {len(blocks)} entries" + ) + if not hasattr(backbone, "pos_embed"): + raise RuntimeError( + "OpenGVLab VideoMAE V2 backbone is missing model.pos_embed" + ) + if input_frames % tubelet_size: + raise RuntimeError( + f"num_frames={input_frames} is not divisible by tubelet_size={tubelet_size}" + ) + if input_size % patch_size: + raise RuntimeError( + f"img_size={input_size} is not divisible by patch_size={patch_size}" + ) + + if ( + self._explicit_dense_num_frames + and self.dense_num_frames != input_frames + ): + raise ValueError( + "dense_num_frames must equal the native OpenGVLab checkpoint " + f"length ({input_frames}), got {self.dense_num_frames}" + ) + self.patch_size = patch_size + self.tubelet_size = tubelet_size + self.input_size = input_size + self.input_frames = input_frames + self.dense_num_frames = input_frames + self._embed_dim = embed_dim + self._configured_depth = depth + if not self._explicit_resize_short_side: + self.resize_short_side = input_size + if self.resize_short_side < self.input_size: + raise ValueError( + "resize_short_side must be >= the checkpoint crop size, got " + f"{self.resize_short_side} < {self.input_size}" + ) + + self._model = model.to(dtype=torch.float32) + self._validate_dense_layer(self.dense_layer_index) + if self.dense_only: + self.prune_for_dense_prefix(self.dense_layer_index) + self.freeze() + + @property + def backbone(self) -> nn.Module: + if self._model is None: + raise RuntimeError("VideoMAE V2 model is not loaded") + backbone = getattr(self._model, "model", None) + if not isinstance(backbone, nn.Module): + raise RuntimeError("VideoMAE V2 backbone is unavailable") + return backbone + + # -- geometry --------------------------------------------------------- + @property + def dense_feature_dim(self) -> int: + return self._embed_dim + + @property + def dense_raw_tokens(self) -> int: + return ( + (self.dense_num_frames // self.tubelet_size) + * (self.input_size // self.patch_size) + * (self.input_size // self.patch_size) + ) + + @property + def retained_dense_blocks(self) -> int: + return len(self.backbone.blocks) + + def _grid_shape(self, n_tokens: int) -> Optional[Tuple[int, int, int]]: + temporal = self.input_frames // self.tubelet_size + spatial = self.input_size // self.patch_size + return ( + (temporal, spatial, spatial) + if temporal * spatial * spatial == n_tokens + else None + ) + + # -- official differentiable preprocessing --------------------------- + def _preprocess_clip( + self, + video_clip_01: torch.Tensor, + *, + crop_mode: str, + ) -> torch.Tensor: + if video_clip_01.ndim != 5: + raise ValueError( + "video_clip_01 must have shape [B,T,3,H,W], " + f"got {tuple(video_clip_01.shape)}" + ) + if int(video_clip_01.shape[2]) != 3: + raise ValueError( + f"video_clip_01 must have 3 channels, got {video_clip_01.shape[2]}" + ) + if int(video_clip_01.shape[1]) != self.input_frames: + raise ValueError( + "VideoMAE V2 requires exactly one native segment of " + f"{self.input_frames} frames, got {video_clip_01.shape[1]}" + ) + x = differentiable_resize_crop( + video_clip_01, + self.input_size, + crop_mode=crop_mode, + resize_short_side=self.resize_short_side, + interpolation="bilinear", + round_center_crop=False, + ) + batch, frames, channels, height, width = x.shape + flat = x.reshape(batch * frames, channels, height, width) + flat = ( + flat - self.norm_mean.to(dtype=flat.dtype, device=flat.device) + ) / self.norm_std.to(dtype=flat.dtype, device=flat.device) + return flat.reshape(batch, frames, channels, height, width) + + def preprocess(self, video_clip_01: torch.Tensor) -> torch.Tensor: + """Official bilinear/ImageNet preprocessing for the sparse API.""" + return self._preprocess_clip(video_clip_01, crop_mode=self.crop_mode) + + def preprocess_dense( + self, + video_clip_01: torch.Tensor, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Preprocess exactly one native segment, preserving ``[B,T,C,H,W]``. + + Temporal window arguments exist for call-site compatibility with the + V-JEPA2 adapter. VideoMAE V2 segment selection is external, so any + non-``None`` value is rejected. + """ + if temporal_window_starts is not None or temporal_window_indices is not None: + raise ValueError( + "VideoMAE V2 accepts one already-selected 16-frame segment; " + "temporal window selection must be performed by the caller" + ) + return self._preprocess_clip(video_clip_01, crop_mode="center") + + # -- OpenGVLab embedding and blocks ----------------------------------- + def _embed_tokens(self, pixel_values: torch.Tensor) -> torch.Tensor: + # OpenGVLab's remote implementation expects BCTHW, unlike the public + # adapter API (and Hugging Face VideoMAE-v1), which uses BTCHW. + bcthw = pixel_values.permute(0, 2, 1, 3, 4).contiguous() + hidden_states = self.backbone.patch_embed(bcthw) + if not isinstance(hidden_states, torch.Tensor) or hidden_states.ndim != 3: + raise RuntimeError( + "OpenGVLab model.patch_embed must return a [B,N,D] tensor" + ) + if int(hidden_states.shape[1]) != self.dense_raw_tokens: + raise RuntimeError( + "unexpected VideoMAE V2 token grid: expected " + f"{self.dense_raw_tokens}, got {hidden_states.shape[1]}" + ) + if int(hidden_states.shape[2]) != self.dense_feature_dim: + raise RuntimeError( + "unexpected VideoMAE V2 feature dimension: expected " + f"{self.dense_feature_dim}, got {hidden_states.shape[2]}" + ) + + pos_embed = getattr(self.backbone, "pos_embed") + if pos_embed is not None: + if not isinstance(pos_embed, torch.Tensor): + raise RuntimeError("OpenGVLab model.pos_embed must be a tensor or None") + if pos_embed.ndim == 2: + pos_embed = pos_embed.unsqueeze(0) + if ( + pos_embed.ndim != 3 + or int(pos_embed.shape[1]) != int(hidden_states.shape[1]) + or int(pos_embed.shape[2]) != int(hidden_states.shape[2]) + or int(pos_embed.shape[0]) not in (1, int(hidden_states.shape[0])) + ): + raise RuntimeError( + "OpenGVLab model.pos_embed is incompatible with patch tokens: " + f"{tuple(pos_embed.shape)} vs {tuple(hidden_states.shape)}" + ) + if int(pos_embed.shape[0]) == 1: + pos_embed = pos_embed.expand(int(hidden_states.shape[0]), -1, -1) + # This mirrors the remote forward exactly. In official checkpoints + # sinusoidal position embeddings may be a plain CPU Tensor rather + # than a registered buffer. + hidden_states = hidden_states + ( + pos_embed.type_as(hidden_states) + .to(hidden_states.device) + .clone() + .detach() + ) + + hidden_states = self.backbone.pos_drop(hidden_states) + if not isinstance(hidden_states, torch.Tensor): + raise RuntimeError("OpenGVLab model.pos_drop must return a tensor") + self._raw_dim = int(hidden_states.shape[-1]) + return hidden_states + + @staticmethod + def _run_block(block: nn.Module, hidden_states: torch.Tensor) -> torch.Tensor: + output = block(hidden_states) + if not isinstance(output, torch.Tensor): + raise RuntimeError( + "OpenGVLab VideoMAE V2 blocks must return the post-residual tensor" + ) + return output + + def _encode_tokens(self, pixel_values: torch.Tensor) -> torch.Tensor: + """Return final post-block tokens for the historical sparse API.""" + if self._dense_pruned: + raise RuntimeError( + "forward_sparse is unavailable after prune_for_dense_prefix(); " + "construct VideoMAEv2Adapter with dense_only=False to retain it" + ) + self._validate_preprocessed(pixel_values) + hidden_states = self._embed_tokens(pixel_values) + for block in self.backbone.blocks: + hidden_states = self._run_block(block, hidden_states) + # Deliberately before mean pooling and fc_norm. + return hidden_states + + # -- intermediate dense feature extraction --------------------------- + def forward_dense( + self, + video_clip_01: torch.Tensor, + layer_index: Optional[int] = None, + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + pixel_values = self.preprocess_dense( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + return self.encode_dense_preprocessed( + pixel_values, + layer_index=layer_index, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + + def forward_dense_layers( + self, + video_clip_01: torch.Tensor, + layer_indices: Sequence[int], + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> Dict[int, torch.Tensor]: + pixel_values = self.preprocess_dense( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + return self.encode_dense_preprocessed_layers( + pixel_values, + layer_indices=layer_indices, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + + def encode_dense_preprocessed( + self, + pixel_values: torch.Tensor, + layer_index: Optional[int] = None, + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + ) -> torch.Tensor: + target_layer = ( + self.dense_layer_index if layer_index is None else self._layer_int(layer_index) + ) + return self.encode_dense_preprocessed_layers( + pixel_values, + layer_indices=(target_layer,), + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + )[target_layer] + + def _validate_preprocessed(self, pixel_values: torch.Tensor) -> None: + if pixel_values.ndim != 5: + raise ValueError( + "pixel_values must have shape [B,T,3,H,W], " + f"got {tuple(pixel_values.shape)}" + ) + if int(pixel_values.shape[2]) != 3: + raise ValueError( + f"pixel_values must have 3 channels, got {pixel_values.shape[2]}" + ) + if int(pixel_values.shape[1]) != self.dense_num_frames: + raise ValueError( + f"pixel_values must contain {self.dense_num_frames} frames, " + f"got {pixel_values.shape[1]}" + ) + if tuple(pixel_values.shape[-2:]) != (self.input_size, self.input_size): + raise ValueError( + f"pixel_values must be {self.input_size}x{self.input_size}, " + f"got {tuple(pixel_values.shape[-2:])}" + ) + + @staticmethod + def _layer_int(value: object) -> int: + if isinstance(value, bool) or not isinstance(value, Integral): + raise TypeError(f"layer index must be an integer, got {value!r}") + return int(value) + + @staticmethod + def _token_index_tensor( + values: torch.Tensor | Sequence[int], + *, + device: torch.device, + num_tokens: int, + ) -> torch.Tensor: + if isinstance(values, torch.Tensor): + if ( + values.dtype == torch.bool + or values.is_floating_point() + or values.is_complex() + ): + raise TypeError("token_indices must contain only integers") + indices = values.to(device=device, dtype=torch.long) + else: + if any(isinstance(value, bool) or not isinstance(value, Integral) for value in values): + raise TypeError("token_indices must contain only integers") + indices = torch.tensor(values, device=device, dtype=torch.long) + if indices.ndim != 1: + raise ValueError( + f"token_indices must be 1-D, got shape {tuple(indices.shape)}" + ) + if indices.numel() == 0: + raise ValueError("token_indices must not be empty") + if int(indices.min()) < 0 or int(indices.max()) >= num_tokens: + raise IndexError(f"token_indices must be in [0,{num_tokens - 1}]") + return indices + + def encode_dense_preprocessed_layers( + self, + pixel_values: torch.Tensor, + layer_indices: Sequence[int], + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + ) -> Dict[int, torch.Tensor]: + """Run one prefix and capture complete post-residual block outputs.""" + self._validate_preprocessed(pixel_values) + try: + requested_layers = tuple(self._layer_int(layer) for layer in layer_indices) + except TypeError: + raise + if not requested_layers: + raise ValueError("layer_indices must not be empty") + if len(set(requested_layers)) != len(requested_layers): + raise ValueError("layer_indices must contain unique indices") + for layer in requested_layers: + self._validate_dense_layer(layer) + max_target_layer = max(requested_layers) + if max_target_layer >= self.retained_dense_blocks: + raise ValueError( + f"layer_index {max_target_layer} was pruned; retained blocks are " + f"0..{self.retained_dense_blocks - 1}" + ) + + hidden_states = self._embed_tokens(pixel_values) + indices = None + if token_indices is not None: + indices = self._token_index_tensor( + token_indices, + device=hidden_states.device, + num_tokens=int(hidden_states.shape[1]), + ) + + requested_set = set(requested_layers) + captured: Dict[int, torch.Tensor] = {} + for block_index in range(max_target_layer + 1): + block = self.backbone.blocks[block_index] + + def run_block(x: torch.Tensor, module=block) -> torch.Tensor: + return self._run_block(module, x) + + if ( + gradient_checkpointing + and torch.is_grad_enabled() + and hidden_states.requires_grad + ): + hidden_states = checkpoint( + run_block, + hidden_states, + use_reentrant=False, + ) + else: + hidden_states = run_block(hidden_states) + if block_index in requested_set: + captured[block_index] = ( + hidden_states + if indices is None + else hidden_states.index_select(1, indices) + ) + + return {layer: captured[layer] for layer in requested_layers} + + def prune_for_dense_prefix( + self, + layer_index: Optional[int] = None, + ) -> "VideoMAEv2Adapter": + """Irreversibly retain embeddings and blocks through ``layer_index``.""" + target_layer = ( + self.dense_layer_index if layer_index is None else self._layer_int(layer_index) + ) + self._validate_dense_layer(target_layer) + if target_layer >= self.retained_dense_blocks: + raise ValueError( + f"cannot restore pruned layer {target_layer}; only " + f"{self.retained_dense_blocks} blocks remain" + ) + self.backbone.blocks = nn.ModuleList( + list(self.backbone.blocks[: target_layer + 1]) + ) + # Dense prefix exits before all of these official final-path modules. + for name in ("norm", "fc_norm", "head_dropout", "head"): + if hasattr(self.backbone, name): + setattr(self.backbone, name, None) + self._dense_pruned = True + self.dense_only = True + self.dense_layer_index = target_layer + self.freeze() + return self + + def _validate_dense_layer(self, layer_index: int) -> None: + layer_index = self._layer_int(layer_index) + if layer_index < 0: + raise ValueError( + f"dense layer index must be non-negative, got {layer_index}" + ) + if self._configured_depth <= 0: + raise RuntimeError("VideoMAE V2 model depth is unavailable") + if layer_index >= self._configured_depth: + raise ValueError( + f"dense layer index {layer_index} out of range for " + f"{self._configured_depth} encoder blocks" + ) diff --git a/14B/code/video_fd/encoders/videomae_v2_dense_metadata.py b/14B/code/video_fd/encoders/videomae_v2_dense_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..e61e2d0ebac49f2a997e72942fb78432b8c5f0ee --- /dev/null +++ b/14B/code/video_fd/encoders/videomae_v2_dense_metadata.py @@ -0,0 +1,553 @@ +"""Canonical geometry and identity for five-segment VideoMAE V2 features. + +An 81-frame Wan clip is partitioned by dropping frame zero and splitting the +remaining 80 frames into five contiguous 16-frame segments. The same relative +VideoMAE token mask is applied independently in every segment and the selected +matrices are concatenated in segment-major order. Hashes are domain-separated +from V-JEPA2 so the two feature families cannot share an identity accidentally. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import struct +from collections.abc import Mapping, Sequence +from numbers import Integral +from typing import Dict + +import torch + + +FEATURE_METADATA_VERSION = 1 +FEATURE_TYPE = "videomae_v2_five_segment_dense_latent" +DEFAULT_WAN_VAE_ID = "Wan2.1-T2V-1.3B/Wan2.1_VAE.pth" +NUM_SEGMENTS = 5 +FRAMES_PER_SEGMENT = 16 +SOURCE_NUM_FRAMES = 81 +SKIPPED_PREFIX_FRAMES = 1 +RAW_TOKENS_PER_SEGMENT = 1568 +FEATURE_DIM = 1024 +CROP_SIZE = 224 +RESIZE_SHORT_SIDE = 224 +FRAME_SAMPLING = "skip_first_then_five_contiguous_segments" +SEGMENT_CONCAT_ORDER = "segment_major_then_relative_token_order" + +_RELATIVE_TOKEN_HASH_DOMAIN = b"part-grad/videomae-v2/relative-token-mask/v1\0" +_AGGREGATE_TOKEN_HASH_DOMAIN = b"part-grad/videomae-v2/aggregate-token-order/v1\0" +_FEATURE_IDENTITY_HASH_DOMAIN = b"part-grad/videomae-v2/feature-metadata/v1\0" + + +def _validated_positive_int(value: object, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, Integral): + raise TypeError(f"{name} must be an integer") + result = int(value) + if result <= 0: + raise ValueError(f"{name} must be positive, got {result}") + return result + + +def _validated_nonnegative_int(value: object, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, Integral): + raise TypeError(f"{name} must be an integer") + result = int(value) + if result < 0: + raise ValueError(f"{name} must be non-negative, got {result}") + return result + + +def uniform_segment_token_indices( + raw_tokens_per_segment: int, + num_tokens: int, + *, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Return the canonical uniformly spaced relative mask for one segment.""" + raw_tokens_per_segment = _validated_positive_int( + raw_tokens_per_segment, "raw_tokens_per_segment" + ) + num_tokens = _validated_positive_int(num_tokens, "num_tokens") + if num_tokens <= 1 or num_tokens > raw_tokens_per_segment: + raise ValueError( + "num_tokens must be in " + f"[2,{raw_tokens_per_segment}], got {num_tokens}" + ) + return torch.linspace( + 0, + raw_tokens_per_segment - 1, + steps=num_tokens, + device=device, + dtype=torch.float64, + ).round().to(torch.long) + + +def validate_segment_token_indices( + indices: torch.Tensor | Sequence[int], + *, + raw_tokens_per_segment: int, + num_tokens: int, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Validate the sorted relative token-row alignment contract.""" + raw_tokens_per_segment = _validated_positive_int( + raw_tokens_per_segment, "raw_tokens_per_segment" + ) + num_tokens = _validated_positive_int(num_tokens, "num_tokens") + if num_tokens <= 1 or num_tokens > raw_tokens_per_segment: + raise ValueError( + "num_tokens must be in " + f"[2,{raw_tokens_per_segment}], got {num_tokens}" + ) + if isinstance(indices, torch.Tensor): + if ( + indices.dtype == torch.bool + or indices.is_floating_point() + or indices.is_complex() + ): + raise TypeError("relative token indices must contain only integers") + values = indices.detach().to(device="cpu", dtype=torch.int64).contiguous() + else: + if any( + isinstance(value, bool) or not isinstance(value, Integral) + for value in indices + ): + raise TypeError("relative token indices must contain only integers") + values = torch.tensor(indices, dtype=torch.int64, device="cpu") + if values.ndim != 1: + raise ValueError("relative token indices must be one-dimensional") + if int(values.numel()) != num_tokens: + raise ValueError( + "relative token index count mismatch: " + f"got {values.numel()}, expected {num_tokens}" + ) + if int(values[0]) < 0 or int(values[-1]) >= raw_tokens_per_segment: + raise ValueError( + "relative token indices must be in " + f"[0,{raw_tokens_per_segment})" + ) + if not bool(torch.all(values[1:] > values[:-1]).item()): + raise ValueError( + "relative token indices must be strictly increasing and unique" + ) + return values.to(device=device) + + +def _indices_sha256(domain: bytes, indices: torch.Tensor) -> str: + values = torch.as_tensor( + indices, dtype=torch.int64, device="cpu" + ).contiguous() + if values.ndim != 1 or values.numel() <= 1: + raise ValueError("token indices must be a non-empty 1-D sequence") + digest = hashlib.sha256(domain) + for value in values.tolist(): + digest.update(struct.pack(" torch.Tensor: + if isinstance(indices, torch.Tensor): + if ( + indices.dtype == torch.bool + or indices.is_floating_point() + or indices.is_complex() + ): + raise TypeError("token indices must contain only integers") + values = indices.detach().to( + device="cpu", dtype=torch.int64 + ).contiguous() + else: + if any( + isinstance(value, bool) or not isinstance(value, Integral) + for value in indices + ): + raise TypeError("token indices must contain only integers") + values = torch.tensor(indices, dtype=torch.int64, device="cpu") + if values.ndim != 1 or values.numel() <= 1: + raise ValueError("token indices must contain at least two rows") + return values + + +def relative_token_indices_sha256(indices: torch.Tensor | Sequence[int]) -> str: + """Hash a one-segment mask in the VideoMAE-specific identity domain.""" + values = _integer_index_values(indices) + return _indices_sha256(_RELATIVE_TOKEN_HASH_DOMAIN, values) + + +def repeat_segment_token_indices( + indices: torch.Tensor | Sequence[int], + *, + num_segments: int = NUM_SEGMENTS, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, + global_offsets: bool = False, +) -> torch.Tensor: + """Repeat a relative mask into a ``[segments,K]`` alignment table. + + With ``global_offsets=False`` every row is identical. With + ``global_offsets=True`` row ``s`` is offset by + ``s * raw_tokens_per_segment`` and therefore indexes the conceptual full + unselected ``[segments * raw_tokens_per_segment, D]`` matrix. + """ + num_segments = _validated_positive_int(num_segments, "num_segments") + raw_tokens_per_segment = _validated_positive_int( + raw_tokens_per_segment, "raw_tokens_per_segment" + ) + target_device = indices.device if isinstance(indices, torch.Tensor) else None + values = _integer_index_values(indices) + values = validate_segment_token_indices( + values, + raw_tokens_per_segment=raw_tokens_per_segment, + num_tokens=int(values.numel()), + device=target_device, + ) + repeated = values.unsqueeze(0).repeat(num_segments, 1) + if global_offsets: + offsets = torch.arange( + num_segments, dtype=torch.int64, device=repeated.device + ).unsqueeze(1) + repeated = repeated + offsets * raw_tokens_per_segment + return repeated + + +def aggregate_segment_token_indices( + indices: torch.Tensor | Sequence[int], + *, + num_segments: int = NUM_SEGMENTS, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, +) -> torch.Tensor: + """Return the flattened segment-major global token-row order.""" + return repeat_segment_token_indices( + indices, + num_segments=num_segments, + raw_tokens_per_segment=raw_tokens_per_segment, + global_offsets=True, + ).reshape(-1) + + +def aggregate_token_indices_sha256( + indices: torch.Tensor | Sequence[int], + *, + num_segments: int = NUM_SEGMENTS, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, +) -> str: + aggregate = aggregate_segment_token_indices( + indices, + num_segments=num_segments, + raw_tokens_per_segment=raw_tokens_per_segment, + ) + return _indices_sha256(_AGGREGATE_TOKEN_HASH_DOMAIN, aggregate) + + +def _canonical_model_id(model_id: str) -> str: + expanded = os.path.expanduser(str(model_id)) + resolved = ( + os.path.realpath(expanded) if os.path.exists(expanded) else expanded) + local_root = "/path/to/local-assets" + logical_root = "/path/to/video-generation-assets" + if ( + resolved == local_root + or resolved.startswith(local_root + "/") + ): + return logical_root + resolved[len(local_root):] + return resolved + + +def _segment_frame_ranges( + *, + num_segments: int = NUM_SEGMENTS, + frames_per_segment: int = FRAMES_PER_SEGMENT, + skipped_prefix_frames: int = SKIPPED_PREFIX_FRAMES, +) -> list[list[int]]: + return [ + [ + skipped_prefix_frames + segment * frames_per_segment, + skipped_prefix_frames + (segment + 1) * frames_per_segment, + ] + for segment in range(num_segments) + ] + + +def _feature_identity_sha256(metadata_without_identity: Mapping[str, object]) -> str: + payload = json.dumps( + dict(metadata_without_identity), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(_FEATURE_IDENTITY_HASH_DOMAIN + payload).hexdigest() + + +def canonical_videomae_v2_dense_feature_metadata( + *, + model_id: str, + layer_index: int, + model_id_is_reference: bool = False, + num_tokens: int, + token_indices: torch.Tensor | Sequence[int] | None = None, + vae_id: str = DEFAULT_WAN_VAE_ID, + num_segments: int = NUM_SEGMENTS, + frames_per_segment: int = FRAMES_PER_SEGMENT, + raw_tokens_per_segment: int = RAW_TOKENS_PER_SEGMENT, + feature_dim: int = FEATURE_DIM, + resize_short_side: int = RESIZE_SHORT_SIDE, + crop_size: int = CROP_SIZE, + skip_first_frame: bool = True, +) -> Dict[str, object]: + """Build the strict five-segment feature/reference identity payload. + + A validated frozen reference identity is opaque: it must not trigger a + filesystem lookup of the machine on which the features were extracted. + """ + layer_index = _validated_nonnegative_int(layer_index, "layer_index") + num_tokens = _validated_positive_int(num_tokens, "num_tokens") + num_segments = _validated_positive_int(num_segments, "num_segments") + frames_per_segment = _validated_positive_int( + frames_per_segment, "frames_per_segment" + ) + raw_tokens_per_segment = _validated_positive_int( + raw_tokens_per_segment, "raw_tokens_per_segment" + ) + feature_dim = _validated_positive_int(feature_dim, "feature_dim") + resize_short_side = _validated_positive_int( + resize_short_side, "resize_short_side" + ) + crop_size = _validated_positive_int(crop_size, "crop_size") + if num_segments != NUM_SEGMENTS: + raise ValueError(f"num_segments must be {NUM_SEGMENTS}, got {num_segments}") + if frames_per_segment != FRAMES_PER_SEGMENT: + raise ValueError( + f"frames_per_segment must be {FRAMES_PER_SEGMENT}, got {frames_per_segment}" + ) + if raw_tokens_per_segment != RAW_TOKENS_PER_SEGMENT: + raise ValueError( + "raw_tokens_per_segment must be " + f"{RAW_TOKENS_PER_SEGMENT}, got {raw_tokens_per_segment}" + ) + if feature_dim != FEATURE_DIM: + raise ValueError(f"feature_dim must be {FEATURE_DIM}, got {feature_dim}") + if resize_short_side != RESIZE_SHORT_SIDE or crop_size != CROP_SIZE: + raise ValueError( + "VideoMAE V2 preprocessing must use resize_short_side=224 and crop_size=224" + ) + if skip_first_frame is not True: + raise ValueError("skip_first_frame must be True for the five-segment contract") + + if token_indices is None: + relative_indices = uniform_segment_token_indices( + raw_tokens_per_segment, num_tokens + ) + token_sampling = "uniform_linspace_round" + else: + relative_indices = validate_segment_token_indices( + token_indices, + raw_tokens_per_segment=raw_tokens_per_segment, + num_tokens=num_tokens, + ) + token_sampling = "explicit_sorted_unique" + + selected_tokens = num_segments * num_tokens + raw_tokens = num_segments * raw_tokens_per_segment + processed_num_frames = num_segments * frames_per_segment + source_num_frames = processed_num_frames + SKIPPED_PREFIX_FRAMES + metadata: Dict[str, object] = { + "feature_metadata_version": FEATURE_METADATA_VERSION, + "feature_type": FEATURE_TYPE, + "model_family": "OpenGVLab VideoMAE V2", + "model_id": str(model_id) if model_id_is_reference else _canonical_model_id(model_id), + "vae_id": str(vae_id), + "layer_index": layer_index, + "prefix_blocks": layer_index + 1, + "layer_index_semantics": "zero_based_complete_post_residual_block", + "feature_stage": "post_residual_pre_pool_fc_norm", + "num_segments": num_segments, + "frames_per_segment": frames_per_segment, + "num_frames": processed_num_frames, + "source_num_frames": source_num_frames, + "skip_first_frame": True, + "skipped_prefix_frames": SKIPPED_PREFIX_FRAMES, + "frame_sampling": FRAME_SAMPLING, + "segment_frame_ranges": _segment_frame_ranges(), + "resize_short_side": resize_short_side, + "crop_size": crop_size, + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "encoder_input_layout": "BCTHW", + "raw_tokens_per_segment": raw_tokens_per_segment, + "raw_tokens": raw_tokens, + "num_tokens_per_segment": num_tokens, + "num_tokens": selected_tokens, + "token_sampling": token_sampling, + "token_mask_reuse": "same_relative_indices_every_segment", + "token_alignment": "same_relative_indices_and_order", + "segment_concat_order": SEGMENT_CONCAT_ORDER, + "relative_token_indices": [ + int(value) for value in relative_indices.tolist() + ], + "relative_token_indices_sha256": relative_token_indices_sha256( + relative_indices + ), + "aggregate_token_indices_sha256": aggregate_token_indices_sha256( + relative_indices, + num_segments=num_segments, + raw_tokens_per_segment=raw_tokens_per_segment, + ), + "feature_dim": feature_dim, + "sample_axis": "video", + "matrix_shape": [selected_tokens, feature_dim], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + metadata["feature_identity_sha256"] = _feature_identity_sha256(metadata) + return metadata + + +def validate_videomae_v2_dense_feature_metadata( + metadata: Mapping[str, object], +) -> Dict[str, object]: + """Validate a serialized reference identity and reject other families. + + A shallow canonical copy is returned so callers can retain their usual + ``validated = validate_...(loaded_metadata)`` pattern. + """ + if not isinstance(metadata, Mapping): + raise TypeError("VideoMAE V2 feature metadata must be a mapping") + result = dict(metadata) + if result.get("feature_type") != FEATURE_TYPE: + raise ValueError( + "feature_type mismatch: expected " + f"{FEATURE_TYPE!r}, got {result.get('feature_type')!r}; " + "V-JEPA2 and VideoMAE V2 reference packs are not interchangeable" + ) + + required_exact = { + "feature_metadata_version": FEATURE_METADATA_VERSION, + "model_family": "OpenGVLab VideoMAE V2", + "feature_stage": "post_residual_pre_pool_fc_norm", + "layer_index_semantics": "zero_based_complete_post_residual_block", + "num_segments": NUM_SEGMENTS, + "frames_per_segment": FRAMES_PER_SEGMENT, + "num_frames": NUM_SEGMENTS * FRAMES_PER_SEGMENT, + "source_num_frames": SOURCE_NUM_FRAMES, + "skip_first_frame": True, + "skipped_prefix_frames": SKIPPED_PREFIX_FRAMES, + "frame_sampling": FRAME_SAMPLING, + "segment_frame_ranges": _segment_frame_ranges(), + "resize_short_side": RESIZE_SHORT_SIDE, + "crop_size": CROP_SIZE, + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "encoder_input_layout": "BCTHW", + "raw_tokens_per_segment": RAW_TOKENS_PER_SEGMENT, + "raw_tokens": NUM_SEGMENTS * RAW_TOKENS_PER_SEGMENT, + "token_mask_reuse": "same_relative_indices_every_segment", + "token_alignment": "same_relative_indices_and_order", + "segment_concat_order": SEGMENT_CONCAT_ORDER, + "feature_dim": FEATURE_DIM, + "sample_axis": "video", + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + for key, expected in required_exact.items(): + if result.get(key) != expected: + raise ValueError( + f"invalid VideoMAE V2 metadata field {key!r}: " + f"expected {expected!r}, got {result.get(key)!r}" + ) + + layer_index = result.get("layer_index") + if isinstance(layer_index, bool) or not isinstance(layer_index, Integral): + raise ValueError("layer_index must be a non-negative integer") + if int(layer_index) < 0 or result.get("prefix_blocks") != int(layer_index) + 1: + raise ValueError("prefix_blocks must equal layer_index + 1") + num_tokens_per_segment = result.get("num_tokens_per_segment") + if ( + isinstance(num_tokens_per_segment, bool) + or not isinstance(num_tokens_per_segment, Integral) + ): + raise ValueError("num_tokens_per_segment must be an integer") + num_tokens_per_segment = int(num_tokens_per_segment) + if not 2 <= num_tokens_per_segment <= RAW_TOKENS_PER_SEGMENT: + raise ValueError( + "num_tokens_per_segment must be in " + f"[2,{RAW_TOKENS_PER_SEGMENT}]" + ) + selected_tokens = NUM_SEGMENTS * num_tokens_per_segment + if result.get("num_tokens") != selected_tokens: + raise ValueError("num_tokens must equal num_segments * num_tokens_per_segment") + if result.get("matrix_shape") != [selected_tokens, FEATURE_DIM]: + raise ValueError( + f"matrix_shape must be [{selected_tokens},{FEATURE_DIM}]" + ) + + relative_indices = validate_segment_token_indices( + result.get("relative_token_indices", []), + raw_tokens_per_segment=RAW_TOKENS_PER_SEGMENT, + num_tokens=num_tokens_per_segment, + ) + expected_relative_hash = relative_token_indices_sha256(relative_indices) + if result.get("relative_token_indices_sha256") != expected_relative_hash: + raise ValueError("relative_token_indices_sha256 does not match the mask") + expected_aggregate_hash = aggregate_token_indices_sha256(relative_indices) + if result.get("aggregate_token_indices_sha256") != expected_aggregate_hash: + raise ValueError("aggregate_token_indices_sha256 does not match segment order") + if result.get("token_sampling") not in ( + "uniform_linspace_round", + "explicit_sorted_unique", + ): + raise ValueError("unsupported token_sampling in VideoMAE V2 metadata") + + identity = result.pop("feature_identity_sha256", None) + expected_identity = _feature_identity_sha256(result) + result["feature_identity_sha256"] = identity + if identity != expected_identity: + raise ValueError("feature_identity_sha256 does not match metadata contents") + return result + + +# Familiar wrappers for call sites that use the V-JEPA helper vocabulary. The +# hashes remain VideoMAE-domain-separated despite the compatibility names. +uniform_dense_token_indices = uniform_segment_token_indices +token_indices_sha256 = relative_token_indices_sha256 + + +def validate_explicit_dense_token_indices( + indices: torch.Tensor | Sequence[int], + *, + raw_tokens: int, + num_tokens: int, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + return validate_segment_token_indices( + indices, + raw_tokens_per_segment=raw_tokens, + num_tokens=num_tokens, + device=device, + ) + + +__all__ = [ + "FEATURE_METADATA_VERSION", + "FEATURE_TYPE", + "DEFAULT_WAN_VAE_ID", + "NUM_SEGMENTS", + "FRAMES_PER_SEGMENT", + "SOURCE_NUM_FRAMES", + "SKIPPED_PREFIX_FRAMES", + "RAW_TOKENS_PER_SEGMENT", + "FEATURE_DIM", + "CROP_SIZE", + "RESIZE_SHORT_SIDE", + "uniform_segment_token_indices", + "validate_segment_token_indices", + "relative_token_indices_sha256", + "repeat_segment_token_indices", + "aggregate_segment_token_indices", + "aggregate_token_indices_sha256", + "canonical_videomae_v2_dense_feature_metadata", + "validate_videomae_v2_dense_feature_metadata", + "uniform_dense_token_indices", + "validate_explicit_dense_token_indices", + "token_indices_sha256", +] diff --git a/14B/code/video_fd/encoders/videoprism_b_adapter.py b/14B/code/video_fd/encoders/videoprism_b_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..0c51425246701cc7956011cead8b05101289c35b --- /dev/null +++ b/14B/code/video_fd/encoders/videoprism_b_adapter.py @@ -0,0 +1,67 @@ +"""VideoPrism-B (LvT base) sparse video-encoder adapter. + +Uses the PyTorch port ``assets/models/videoprism-lvt-base-f16r288-pt`` (Google +VideoPrism LvT base, ~0.138B params), loaded via +``AutoModel(trust_remote_code=True)``. The model forward takes a **channels-last** +``[B, T, H, W, 3]`` tensor in ``[0, 1]`` (native 16 frames @ 288) and returns a +single L2-normalized global ``video_embeds [B, D]``. + +Since the LvT base pools to a global embedding, sparse ``[B, S, D]`` segments are +produced by the base class's segment-forward (S temporal segments, each resized +to 16 frames and encoded). Set ``sparse_mode='global'`` for S=1. +""" + +from __future__ import annotations + +from pathlib import Path + +import torch + +from video_fd.encoders.base_sparse_video_encoder import SparseVideoEncoderAdapter + + +class VideoPrismBAdapter(SparseVideoEncoderAdapter): + def __init__(self, config): + config = dict(config) + config.setdefault("name", "videoprism_b") + config.setdefault("input_size", 288) + config.setdefault("input_frames", 16) + # VideoPrism expects pixels in [0,1] (no mean/std normalization). + config.setdefault("norm_mean", (0.0, 0.0, 0.0)) + config.setdefault("norm_std", (1.0, 1.0, 1.0)) + config["global_encoder"] = True + super().__init__(config) + self.model_id = str(config.get("model_id", "assets/models/videoprism-lvt-base-f16r288-pt")) + self._model = None + self._load() + + def _load(self): + model_dir = Path(self.model_id).expanduser() + if not model_dir.is_dir(): + raise FileNotFoundError(f"Prepare the local VideoPrism model directory: {model_dir}") + self.model_id = str(model_dir.resolve()) + try: + from transformers import AutoModel + except Exception as exc: # pragma: no cover + raise ImportError(f"VideoPrism needs transformers: {exc!r}") from exc + try: + model = AutoModel.from_pretrained(self.model_id, trust_remote_code=True, local_files_only=True) + except Exception as exc: + raise ImportError( + f"Could not load VideoPrism port {self.model_id!r} " + f"(AutoModel trust_remote_code). Original error: {exc!r}" + ) from exc + self._model = model.eval().to(torch.float32) + self.freeze() + + def _encode_global(self, pixel_values: torch.Tensor) -> torch.Tensor: + # pixel_values: [nB, T, 3, s, s] in [0,1]; the port wants channels-last [nB, T, s, s, 3]. + x = pixel_values.permute(0, 1, 3, 4, 2).contiguous() + out = self._model(pixel_values=x) + if hasattr(out, "video_embeds") and out.video_embeds is not None: + return out.video_embeds.float() + if hasattr(out, "last_hidden_state"): # token output fallback -> global mean + return out.last_hidden_state.float().mean(dim=1) + if torch.is_tensor(out): + return out.float() if out.dim() == 2 else out.float().mean(dim=1) + raise RuntimeError("Unexpected VideoPrism output structure") diff --git a/14B/code/video_fd/encoders/vjepa2_adapter.py b/14B/code/video_fd/encoders/vjepa2_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..538e37df9ab8ee3588a4999ba79f731d37ed4bfd --- /dev/null +++ b/14B/code/video_fd/encoders/vjepa2_adapter.py @@ -0,0 +1,530 @@ +"""V-JEPA2 sparse and intermediate-dense video encoder adapter. + +The historical sparse API remains available. The dense API additionally +returns the complete post-residual hidden state of an intermediate encoder +block, before V-JEPA2's final LayerNorm and predictor. It is intended for +feature-distribution losses whose gradient must replay through preprocessing +and a frozen encoder prefix. +""" + +from __future__ import annotations + +from pathlib import Path + +from typing import Dict, Optional, Sequence, Tuple + +import torch +import torch.nn as nn +from torch.utils.checkpoint import checkpoint + +from video_fd.encoders.base_sparse_video_encoder import ( + SparseVideoEncoderAdapter, + differentiable_resize_crop, +) + + +class VJEPA2Adapter(SparseVideoEncoderAdapter): + """Hugging Face V-JEPA2 adapter with an optional memory-pruned dense mode. + + Dense-mode configuration keys: + + * ``dense_layer_index``: zero-based complete encoder block (default 15) + * ``dense_num_frames``: contiguous input frames (default 64) + * ``resize_short_side``: differentiable resize short edge (default 292) + * ``dense_only``: discard predictor, final norm, and blocks after the dense + target once the checkpoint is loaded + + ``model`` is an injection seam for tiny unit tests. Production callers + normally omit it and load ``model_id`` with ``from_pretrained``. + """ + + def __init__(self, config, model: Optional[nn.Module] = None): + config = dict(config) + config.setdefault("name", "vjepa2") + config.setdefault("input_size", config.get("crop_size", 256)) + config.setdefault("input_frames", 16) + config.setdefault("norm_mean", (0.485, 0.456, 0.406)) + config.setdefault("norm_std", (0.229, 0.224, 0.225)) + super().__init__(config) + self.model_id = str(config.get("model_id", "assets/models/vjepa2-vitl-fpc64-256")) + self.allow_temporal_pos_interpolation = bool(config.get("allow_temporal_pos_interpolation", True)) + self.tubelet_size = int(config.get("tubelet_size", 2)) + self.patch_size = int(config.get("patch_size", 16)) + self.dense_layer_index = int(config.get("dense_layer_index", 15)) + self.dense_num_frames = int(config.get("dense_num_frames", 64)) + self.resize_short_side = int( + config.get("resize_short_side", config.get("dense_resize_short_side", 292)) + ) + self.dense_only = bool(config.get("dense_only", False)) + if self.dense_num_frames <= 0: + raise ValueError( + f"dense_num_frames must be positive, got {self.dense_num_frames}" + ) + if self.resize_short_side < self.input_size: + raise ValueError( + "resize_short_side must be >= input_size, got " + f"{self.resize_short_side} < {self.input_size}" + ) + self._model = None + self._dense_pruned = False + if model is None: + self._load() + else: + self._adopt_model(model) + + def _load(self): + model_dir = Path(self.model_id).expanduser() + if not model_dir.is_dir(): + raise FileNotFoundError(f"Prepare the local V-JEPA2 model directory: {model_dir}") + self.model_id = str(model_dir.resolve()) + try: + from transformers import VJEPA2Model # type: ignore + except Exception as exc: # pragma: no cover + raise ImportError( + "V-JEPA2 requires `transformers` with VJEPA2Model. Original error: %r" % (exc,) + ) from exc + model = VJEPA2Model.from_pretrained(self.model_id, local_files_only=True) + self._adopt_model(model) + + def _adopt_model(self, model: nn.Module) -> None: + """Install a loaded model, derive its geometry, optionally prune it.""" + cfg = getattr(model, "config", None) + if cfg is not None: + self.patch_size = int(getattr(cfg, "patch_size", self.patch_size)) + self.tubelet_size = int(getattr(cfg, "tubelet_size", self.tubelet_size)) + self.input_size = int(getattr(cfg, "crop_size", getattr(cfg, "image_size", self.input_size))) + cfg_frames = getattr(cfg, "frames_per_clip", None) + if cfg_frames is not None and int(cfg_frames) != self.input_frames and not self.allow_temporal_pos_interpolation: + print(f"[vjepa2] checkpoint frames_per_clip={cfg_frames} != {self.input_frames}; " + "temporal interpolation disabled -> will fall back to global pooling.") + self._model = model.to(torch.float32) + self._validate_dense_layer(self.dense_layer_index) + if self.dense_only: + self.prune_for_dense_prefix(self.dense_layer_index) + self.freeze() + + def _grid_shape(self, n_tokens: int) -> Optional[Tuple[int, int, int]]: + Tp = self.input_frames // self.tubelet_size + Hp = self.input_size // self.patch_size + Wp = self.input_size // self.patch_size + return (Tp, Hp, Wp) if Tp * Hp * Wp == n_tokens else None + + def _encode_tokens(self, pixel_values: torch.Tensor) -> torch.Tensor: + if self._dense_pruned: + raise RuntimeError( + "forward_sparse is unavailable after prune_for_dense_prefix(); " + "construct VJEPA2Adapter with dense_only=False to retain it" + ) + # transformers VJEPA2 expects pixel_values_videos [B, T, 3, H, W] + # Predictor output is unused by the sparse encoder and can be skipped + # without changing encoder.last_hidden_state. + out = self._model(pixel_values_videos=pixel_values, skip_predictor=True) + tokens = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0] + return tokens # [B, N, D] + + # -- intermediate dense feature extraction ----------------------------- + @property + def dense_feature_dim(self) -> int: + cfg = getattr(self._model, "config", None) + if cfg is None or not hasattr(cfg, "hidden_size"): + raise RuntimeError("loaded V-JEPA2 model does not expose config.hidden_size") + return int(cfg.hidden_size) + + @property + def dense_raw_tokens(self) -> int: + return ( + (self.dense_num_frames // self.tubelet_size) + * (self.input_size // self.patch_size) + * (self.input_size // self.patch_size) + ) + + @property + def retained_dense_blocks(self) -> int: + return len(self._model.encoder.layer) + + @staticmethod + def _integer_index_tensor( + values: torch.Tensor, + *, + name: str, + device: torch.device, + ) -> torch.Tensor: + indices = torch.as_tensor(values) + if ( + indices.dtype == torch.bool + or indices.is_floating_point() + or indices.is_complex() + ): + raise TypeError(f"{name} must contain only integers") + return indices.to(device=device, dtype=torch.long) + + def _temporal_window_indices( + self, + video_clip_01: torch.Tensor, + *, + temporal_window_starts: Optional[torch.Tensor], + temporal_window_indices: Optional[torch.Tensor], + ) -> Optional[torch.Tensor]: + """Validate and canonicalize optional per-video contiguous windows.""" + if ( + temporal_window_starts is not None + and temporal_window_indices is not None + ): + raise ValueError( + "provide either temporal_window_starts or " + "temporal_window_indices, not both" + ) + if ( + temporal_window_starts is None + and temporal_window_indices is None + ): + return None + + batch_size = int(video_clip_01.shape[0]) + total_frames = int(video_clip_01.shape[1]) + max_start = total_frames - self.dense_num_frames + device = video_clip_01.device + + if temporal_window_starts is not None: + starts = self._integer_index_tensor( + temporal_window_starts, + name="temporal_window_starts", + device=device, + ) + if starts.ndim != 1 or int(starts.shape[0]) != batch_size: + raise ValueError( + "temporal_window_starts must have shape [B], got " + f"{tuple(starts.shape)} for B={batch_size}" + ) + if starts.numel() and ( + int(starts.min()) < 0 or int(starts.max()) > max_start + ): + raise ValueError( + "temporal_window_starts must be in " + f"[0,{max_start}]" + ) + offsets = torch.arange( + self.dense_num_frames, + dtype=torch.long, + device=device, + ) + return starts[:, None] + offsets[None, :] + + indices = self._integer_index_tensor( + temporal_window_indices, + name="temporal_window_indices", + device=device, + ) + expected_shape = (batch_size, self.dense_num_frames) + if tuple(indices.shape) != expected_shape: + raise ValueError( + "temporal_window_indices must have shape " + f"[B,{self.dense_num_frames}], got {tuple(indices.shape)} " + f"for B={batch_size}" + ) + if indices.numel(): + starts = indices[:, 0] + if int(starts.min()) < 0 or int(starts.max()) > max_start: + raise ValueError( + "temporal_window_indices must describe windows starting " + f"in [0,{max_start}]" + ) + offsets = torch.arange( + self.dense_num_frames, + dtype=torch.long, + device=device, + ) + expected = starts[:, None] + offsets[None, :] + if not bool(torch.equal(indices, expected)): + raise ValueError( + "each temporal_window_indices row must be a contiguous " + "strictly increasing window" + ) + return indices + + def preprocess_dense( + self, + video_clip_01: torch.Tensor, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Official differentiable V-JEPA2 preprocessing. + + The input is ``[B,T,3,H,W]`` in ``[0,1]``. By default a centered + contiguous ``dense_num_frames`` window is selected. Callers may + instead supply explicit integer starts with shape ``[B]`` or explicit + contiguous indices with shape ``[B,dense_num_frames]``. Sampling is + intentionally external: this adapter never consumes ambient RNG + state. The short side is then resized to 292 (configurable), a 256 + center crop is taken, and ImageNet normalization is applied. The + returned layout remains ``[B,T,3,256,256]``. + """ + if video_clip_01.ndim != 5: + raise ValueError( + "video_clip_01 must have shape [B,T,3,H,W], " + f"got {tuple(video_clip_01.shape)}" + ) + if video_clip_01.shape[2] != 3: + raise ValueError( + f"video_clip_01 must have 3 channels, got {video_clip_01.shape[2]}" + ) + total_frames = int(video_clip_01.shape[1]) + if total_frames < self.dense_num_frames: + raise ValueError( + f"V-JEPA2 dense mode needs at least {self.dense_num_frames} frames, " + f"got {total_frames}" + ) + frame_indices = self._temporal_window_indices( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + if frame_indices is None: + start = (total_frames - self.dense_num_frames) // 2 + clip = video_clip_01.narrow(1, start, self.dense_num_frames) + else: + gather_indices = frame_indices[:, :, None, None, None].expand( + -1, + -1, + int(video_clip_01.shape[2]), + int(video_clip_01.shape[3]), + int(video_clip_01.shape[4]), + ) + clip = torch.gather(video_clip_01, 1, gather_indices) + x = differentiable_resize_crop( + clip, + self.input_size, + crop_mode="center", + resize_short_side=self.resize_short_side, + interpolation="bilinear", + round_center_crop=True, + ) + B, T, C, H, W = x.shape + flat = x.reshape(B * T, C, H, W) + flat = (flat - self.norm_mean.to(flat.dtype)) / self.norm_std.to(flat.dtype) + return flat.reshape(B, T, C, H, W) + + def forward_dense( + self, + video_clip_01: torch.Tensor, + layer_index: Optional[int] = None, + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Return a raw intermediate dense token matrix from an input video. + + No final encoder LayerNorm, predictor, pooling, or flattening is applied. + Gradients are retained with respect to ``video_clip_01`` even though all + V-JEPA2 parameters are frozen. + """ + pixel_values = self.preprocess_dense( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + return self.encode_dense_preprocessed( + pixel_values, + layer_index=layer_index, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + + def forward_dense_layers( + self, + video_clip_01: torch.Tensor, + layer_indices: Sequence[int], + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + temporal_window_starts: Optional[torch.Tensor] = None, + temporal_window_indices: Optional[torch.Tensor] = None, + ) -> Dict[int, torch.Tensor]: + """Return several raw intermediate token matrices in one prefix pass. + + The encoder is executed exactly once through ``max(layer_indices)``. + Each returned value is the complete post-residual output of its + zero-based block, before V-JEPA2's final LayerNorm. The dictionary + follows the caller-provided layer order. + """ + pixel_values = self.preprocess_dense( + video_clip_01, + temporal_window_starts=temporal_window_starts, + temporal_window_indices=temporal_window_indices, + ) + return self.encode_dense_preprocessed_layers( + pixel_values, + layer_indices=layer_indices, + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + ) + + def encode_dense_preprocessed( + self, + pixel_values: torch.Tensor, + layer_index: Optional[int] = None, + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + ) -> torch.Tensor: + """Run embeddings and complete blocks ``0..layer_index``. + + ``pixel_values`` must already have V-JEPA2 layout and normalization. + This lower-level entrypoint lets replay code avoid preprocessing twice. + """ + target_layer = self.dense_layer_index if layer_index is None else int(layer_index) + return self.encode_dense_preprocessed_layers( + pixel_values, + layer_indices=(target_layer,), + token_indices=token_indices, + gradient_checkpointing=gradient_checkpointing, + )[target_layer] + + def encode_dense_preprocessed_layers( + self, + pixel_values: torch.Tensor, + layer_indices: Sequence[int], + token_indices: Optional[torch.Tensor] = None, + gradient_checkpointing: bool = False, + ) -> Dict[int, torch.Tensor]: + """Run one encoder prefix and return all requested block outputs. + + ``pixel_values`` must already have V-JEPA2 layout and normalization. + Blocks are evaluated only once, through the largest requested + zero-based layer. Token selection happens as each requested layer is + captured so that later blocks do not retain unneeded token outputs. + """ + if pixel_values.ndim != 5: + raise ValueError( + "pixel_values must have shape [B,T,3,H,W], " + f"got {tuple(pixel_values.shape)}" + ) + if pixel_values.shape[2] != 3: + raise ValueError(f"pixel_values must have 3 channels, got {pixel_values.shape[2]}") + if pixel_values.shape[1] != self.dense_num_frames: + raise ValueError( + f"pixel_values must contain {self.dense_num_frames} frames, " + f"got {pixel_values.shape[1]}" + ) + if tuple(pixel_values.shape[-2:]) != (self.input_size, self.input_size): + raise ValueError( + f"pixel_values must be {self.input_size}x{self.input_size}, " + f"got {tuple(pixel_values.shape[-2:])}" + ) + + try: + requested_layers = tuple(int(layer) for layer in layer_indices) + except TypeError as exc: + raise TypeError("layer_indices must be a sequence of integers") from exc + if not requested_layers: + raise ValueError("layer_indices must not be empty") + if len(set(requested_layers)) != len(requested_layers): + raise ValueError("layer_indices must contain unique indices") + for target_layer in requested_layers: + self._validate_dense_layer(target_layer) + max_target_layer = max(requested_layers) + if max_target_layer >= self.retained_dense_blocks: + raise ValueError( + f"layer_index {max_target_layer} was pruned; retained blocks are " + f"0..{self.retained_dense_blocks - 1}" + ) + + encoder = self._model.encoder + hidden_states = encoder.embeddings(pixel_values) + expected_tokens = self.dense_raw_tokens + if hidden_states.shape[1] != expected_tokens: + raise RuntimeError( + "unexpected V-JEPA2 dense token grid: expected " + f"{expected_tokens}, got {hidden_states.shape[1]}" + ) + self._raw_dim = int(hidden_states.shape[-1]) + + indices = None + if token_indices is not None: + indices = torch.as_tensor(token_indices, device=hidden_states.device) + if indices.ndim != 1: + raise ValueError( + f"token_indices must be 1-D, got shape {tuple(indices.shape)}" + ) + if indices.numel() == 0: + raise ValueError("token_indices must not be empty") + if indices.dtype != torch.long: + indices = indices.to(torch.long) + if int(indices.min()) < 0 or int(indices.max()) >= hidden_states.shape[1]: + raise IndexError( + f"token_indices must be in [0,{hidden_states.shape[1] - 1}]" + ) + + requested_set = set(requested_layers) + captured: Dict[int, torch.Tensor] = {} + for block_index in range(max_target_layer + 1): + block = encoder.layer[block_index] + + def run_block(x: torch.Tensor, module=block) -> torch.Tensor: + return module( + x, + position_mask=None, + head_mask=None, + output_attentions=False, + )[0] + + if ( + gradient_checkpointing + and torch.is_grad_enabled() + and hidden_states.requires_grad + ): + hidden_states = checkpoint( + run_block, + hidden_states, + use_reentrant=False, + ) + else: + hidden_states = run_block(hidden_states) + + if block_index in requested_set: + # This is deliberately before encoder.layernorm: + # HF hidden_states[i+1] is the complete post-residual result + # of zero-based block i. + captured[block_index] = ( + hidden_states + if indices is None + else hidden_states.index_select(1, indices) + ) + + return {layer: captured[layer] for layer in requested_layers} + + def prune_for_dense_prefix(self, layer_index: Optional[int] = None): + """Irreversibly retain only the modules required by dense-prefix mode.""" + target_layer = self.dense_layer_index if layer_index is None else int(layer_index) + self._validate_dense_layer(target_layer) + if self._dense_pruned and target_layer >= len(self._model.encoder.layer): + raise ValueError( + f"cannot restore pruned layer {target_layer}; only " + f"{len(self._model.encoder.layer)} blocks remain" + ) + self._model.encoder.layer = nn.ModuleList( + list(self._model.encoder.layer[: target_layer + 1]) + ) + # Dense prefix never executes either module. Replacing them with None + # unregisters their parameters immediately, which matters before FSDP. + self._model.encoder.layernorm = None + self._model.predictor = None + self._dense_pruned = True + self.dense_only = True + self.dense_layer_index = target_layer + return self + + def _validate_dense_layer(self, layer_index: int) -> None: + if layer_index < 0: + raise ValueError(f"dense layer index must be non-negative, got {layer_index}") + encoder = getattr(self._model, "encoder", None) + layers = getattr(encoder, "layer", None) + if layers is None: + raise RuntimeError("loaded V-JEPA2 model does not expose encoder.layer") + configured_layers = int( + getattr(getattr(self._model, "config", None), "num_hidden_layers", len(layers)) + ) + if layer_index >= configured_layers: + raise ValueError( + f"dense layer index {layer_index} out of range for " + f"{configured_layers} encoder blocks" + ) diff --git a/14B/code/video_fd/encoders/vjepa2_dense_metadata.py b/14B/code/video_fd/encoders/vjepa2_dense_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..224fe2d899fe24aa3f5cb46e15f24e12e12c0a56 --- /dev/null +++ b/14B/code/video_fd/encoders/vjepa2_dense_metadata.py @@ -0,0 +1,325 @@ +"""Canonical geometry and identity helpers for V-JEPA2 dense video features.""" + +from __future__ import annotations + +import hashlib +import os +from collections.abc import Sequence +from typing import Dict + +import torch + +from video_fd.encoders.vjepa2_temporal_sampling import WINDOW_SAMPLING_HASH + + +FEATURE_METADATA_VERSION = 1 +EXPLICIT_TOKEN_FEATURE_METADATA_VERSION = 2 +RANDOM_CONTIGUOUS_FEATURE_METADATA_VERSION = 3 +LAST_CONTIGUOUS_FEATURE_METADATA_VERSION = 4 +LAST_CONTIGUOUS_FRAME_SAMPLING = "last_contiguous" +DEFAULT_WAN_VAE_ID = "Wan2.1-T2V-1.3B/Wan2.1_VAE.pth" +RANDOM_CONTIGUOUS_FRAME_SAMPLING = "random_contiguous_per_video" + + +def uniform_dense_token_indices( + raw_tokens: int, + num_tokens: int, + *, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Return the one canonical deterministic token subset.""" + raw_tokens = int(raw_tokens) + num_tokens = int(num_tokens) + if raw_tokens <= 0: + raise ValueError("raw_tokens must be positive") + if num_tokens <= 1 or num_tokens > raw_tokens: + raise ValueError( + f"num_tokens must be in [2,{raw_tokens}], got {num_tokens}") + return torch.linspace( + 0, + raw_tokens - 1, + steps=num_tokens, + device=device, + dtype=torch.float64, + ).round().to(torch.long) + + +def token_indices_sha256(indices: torch.Tensor) -> str: + values = torch.as_tensor( + indices, dtype=torch.int64, device="cpu").contiguous() + if values.ndim != 1 or values.numel() <= 1: + raise ValueError("token indices must be a non-empty 1-D sequence") + return hashlib.sha256(values.numpy().tobytes()).hexdigest() + + +def validate_explicit_dense_token_indices( + indices: torch.Tensor | Sequence[int], + *, + raw_tokens: int, + num_tokens: int, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Canonicalize an explicit aligned-token subset. + + Explicit subsets are deliberately stricter than the legacy uniform + helper: the serialized order is the matrix-row alignment contract shared + by student features and their frozen teacher reference. + """ + raw_tokens = int(raw_tokens) + num_tokens = int(num_tokens) + if raw_tokens <= 0: + raise ValueError("raw_tokens must be positive") + if num_tokens <= 1 or num_tokens > raw_tokens: + raise ValueError( + f"num_tokens must be in [2,{raw_tokens}], got {num_tokens}") + if isinstance(indices, torch.Tensor): + if ( + indices.dtype == torch.bool + or indices.is_floating_point() + or indices.is_complex() + ): + raise TypeError("explicit token indices must be integers") + values = indices.detach().to( + device="cpu", dtype=torch.int64).contiguous() + else: + if any(type(value) is not int for value in indices): + raise TypeError("explicit token indices must be integers") + values = torch.tensor( + indices, dtype=torch.int64, device="cpu").contiguous() + if values.ndim != 1: + raise ValueError("explicit token indices must be one-dimensional") + if values.numel() != num_tokens: + raise ValueError( + "explicit token index count mismatch: " + f"got {values.numel()}, expected {num_tokens}") + if int(values[0]) < 0 or int(values[-1]) >= raw_tokens: + raise ValueError( + f"explicit token indices must be in [0,{raw_tokens})") + if not bool(torch.all(values[1:] > values[:-1]).item()): + raise ValueError( + "explicit token indices must be strictly increasing and unique") + return values.to(device=device) + + +def canonical_vjepa2_dense_feature_metadata( + *, + model_id: str, + layer_index: int, + num_frames: int, + resize_short_side: int, + crop_size: int, + raw_tokens: int, + num_tokens: int, + feature_dim: int, + vae_id: str = DEFAULT_WAN_VAE_ID, + token_indices: torch.Tensor | Sequence[int] | None = None, + frame_sampling: str = "center_contiguous", + source_num_frames: int | None = None, + window_seed: str | None = None, +) -> Dict[str, object]: + """Build metadata shared by offline reference creation and training.""" + expanded_model_id = os.path.expanduser(str(model_id)) + canonical_model_id = ( + os.path.realpath(expanded_model_id) + if os.path.exists(expanded_model_id) + else str(model_id) + ) + if frame_sampling == "center_contiguous" and ( + source_num_frames is not None or window_seed is not None + ): + raise ValueError( + "source_num_frames and window_seed are only valid for " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING}" + ) + + if frame_sampling == "center_contiguous" and token_indices is None: + # Keep this legacy payload byte-for-byte stable for existing reference + # packs and strict checkpoint metadata comparisons. + indices = uniform_dense_token_indices(raw_tokens, num_tokens) + return { + "feature_metadata_version": FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": "center_contiguous", + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + "token_sampling": "uniform_linspace_round", + "token_alignment": "same_deterministic_indices", + "token_indices_sha256": token_indices_sha256(indices), + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + + if frame_sampling == "center_contiguous": + indices = validate_explicit_dense_token_indices( + token_indices, + raw_tokens=raw_tokens, + num_tokens=num_tokens, + ) + return { + "feature_metadata_version": EXPLICIT_TOKEN_FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": "center_contiguous", + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + "token_sampling": "explicit_sorted_unique", + "token_alignment": "same_explicit_indices_and_order", + "token_indices": [int(value) for value in indices.tolist()], + "token_indices_sha256": token_indices_sha256(indices), + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + + if frame_sampling == LAST_CONTIGUOUS_FRAME_SAMPLING: + if type(source_num_frames) is not int: + raise TypeError( + "source_num_frames must be an integer for last_contiguous") + if window_seed is not None: + raise ValueError("last_contiguous does not use window_seed") + if source_num_frames < int(num_frames): + raise ValueError( + "source_num_frames must be >= num_frames for last_contiguous") + indices = ( + uniform_dense_token_indices(raw_tokens, num_tokens) + if token_indices is None + else validate_explicit_dense_token_indices( + token_indices, + raw_tokens=raw_tokens, + num_tokens=num_tokens, + ) + ) + start = int(source_num_frames) - int(num_frames) + metadata = { + "feature_metadata_version": LAST_CONTIGUOUS_FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": LAST_CONTIGUOUS_FRAME_SAMPLING, + "source_num_frames": int(source_num_frames), + "selected_frame_start": start, + "selected_frame_end_inclusive": int(source_num_frames) - 1, + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + "token_sampling": ( + "uniform_linspace_round" + if token_indices is None else "explicit_sorted_unique"), + "token_alignment": ( + "same_deterministic_indices" + if token_indices is None + else "same_explicit_indices_and_order"), + "token_indices_sha256": token_indices_sha256(indices), + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } + if token_indices is not None: + metadata["token_indices"] = [ + int(value) for value in indices.tolist() + ] + return metadata + + if frame_sampling != RANDOM_CONTIGUOUS_FRAME_SAMPLING: + raise ValueError( + "frame_sampling must be center_contiguous, last_contiguous, or " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING!r}, got {frame_sampling!r}" + ) + if type(source_num_frames) is not int: + raise TypeError( + "source_num_frames must be an integer for " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING}" + ) + if source_num_frames < int(num_frames): + raise ValueError( + "source_num_frames must be >= num_frames, got " + f"{source_num_frames} < {int(num_frames)}" + ) + if not isinstance(window_seed, str) or not window_seed: + raise ValueError( + "window_seed must be a non-empty string for " + f"{RANDOM_CONTIGUOUS_FRAME_SAMPLING}" + ) + if "\0" in window_seed: + raise ValueError("window_seed must not contain NUL characters") + + if token_indices is None: + indices = uniform_dense_token_indices(raw_tokens, num_tokens) + token_metadata = { + "token_sampling": "uniform_linspace_round", + "token_alignment": "same_deterministic_indices", + "token_indices_sha256": token_indices_sha256(indices), + } + else: + indices = validate_explicit_dense_token_indices( + token_indices, + raw_tokens=raw_tokens, + num_tokens=num_tokens, + ) + token_metadata = { + "token_sampling": "explicit_sorted_unique", + "token_alignment": "same_explicit_indices_and_order", + "token_indices": [int(value) for value in indices.tolist()], + "token_indices_sha256": token_indices_sha256(indices), + } + + window_start_max = int(source_num_frames) - int(num_frames) + return { + "feature_metadata_version": + RANDOM_CONTIGUOUS_FEATURE_METADATA_VERSION, + "feature_type": "vjepa2_dense_latent", + "model_id": canonical_model_id, + "vae_id": str(vae_id), + "layer_index": int(layer_index), + "prefix_blocks": int(layer_index) + 1, + "num_frames": int(num_frames), + "frame_sampling": RANDOM_CONTIGUOUS_FRAME_SAMPLING, + "source_num_frames": int(source_num_frames), + "window_seed": window_seed, + "window_start_min": 0, + "window_start_max": window_start_max, + "window_num_choices": window_start_max + 1, + "window_sampling_hash": WINDOW_SAMPLING_HASH, + "resize_short_side": int(resize_short_side), + "crop_size": int(crop_size), + "interpolation": "bilinear_antialias", + "normalization": "imagenet_default", + "raw_tokens": int(raw_tokens), + "num_tokens": int(num_tokens), + **token_metadata, + "feature_dim": int(feature_dim), + "sample_axis": "video", + "matrix_shape": [int(num_tokens), int(feature_dim)], + "kernel": "single_scale_gaussian_rbf", + "kernel_metric": "frobenius_rbf_aligned_tokens", + } diff --git a/14B/code/video_fd/encoders/vjepa2_temporal_sampling.py b/14B/code/video_fd/encoders/vjepa2_temporal_sampling.py new file mode 100644 index 0000000000000000000000000000000000000000..7c35c5dfce3bd5848ebfe6bdf901ef8ad303f64c --- /dev/null +++ b/14B/code/video_fd/encoders/vjepa2_temporal_sampling.py @@ -0,0 +1,117 @@ +"""Shared stateless temporal-window sampling for V-JEPA2 dense features. + +The helper in this module is deliberately independent of Python, NumPy, and +PyTorch random-number-generator state. Training and offline teacher tooling +can therefore derive the same window from stable sample identities without +changing any ambient RNG stream. +""" + +from __future__ import annotations + +import hashlib + +import torch + + +WINDOW_SAMPLING_HASH = "sha256_first_u64_big_endian_mod_num_windows" + + +def _validate_identity(seed: str, domain: str, sample_key: int) -> None: + if not isinstance(seed, str) or not seed: + raise ValueError("temporal window seed must be a non-empty string") + if "\0" in seed: + raise ValueError("temporal window seed must not contain NUL characters") + if not isinstance(domain, str) or not domain: + raise ValueError("temporal window domain must be a non-empty string") + if "\0" in domain: + raise ValueError( + "temporal window domain must not contain NUL characters") + if type(sample_key) is not int: + raise TypeError("temporal window sample_key must be an integer") + if sample_key < 0: + raise ValueError("temporal window sample_key must be non-negative") + + +def _validate_geometry( + source_num_frames: int, + window_num_frames: int, +) -> tuple[int, int]: + if type(source_num_frames) is not int: + raise TypeError("source_num_frames must be an integer") + if type(window_num_frames) is not int: + raise TypeError("window_num_frames must be an integer") + if source_num_frames <= 0: + raise ValueError("source_num_frames must be positive") + if window_num_frames <= 0: + raise ValueError("window_num_frames must be positive") + if window_num_frames > source_num_frames: + raise ValueError( + "window_num_frames must not exceed source_num_frames, got " + f"{window_num_frames} > {source_num_frames}" + ) + return source_num_frames, window_num_frames + + +def temporal_window_sample_sha256( + seed: str, + domain: str, + sample_key: int, +) -> str: + """Return the canonical hash for one stable temporal-window identity. + + The exact payload is ``seed UTF-8 + NUL + domain UTF-8 + NUL + the + unsigned decimal sample key in UTF-8``. + """ + _validate_identity(seed, domain, sample_key) + payload = ( + seed.encode("utf-8") + + b"\0" + + domain.encode("utf-8") + + b"\0" + + str(sample_key).encode("utf-8") + ) + return hashlib.sha256(payload).hexdigest() + + +def stateless_temporal_window_start( + seed: str, + domain: str, + sample_key: int, + *, + source_num_frames: int, + window_num_frames: int, +) -> int: + """Map a stable sample identity to one valid contiguous-window start.""" + source_num_frames, window_num_frames = _validate_geometry( + source_num_frames, + window_num_frames, + ) + digest = bytes.fromhex( + temporal_window_sample_sha256(seed, domain, sample_key)) + num_windows = source_num_frames - window_num_frames + 1 + return int.from_bytes(digest[:8], byteorder="big") % num_windows + + +def stateless_temporal_window_indices( + seed: str, + domain: str, + sample_key: int, + *, + source_num_frames: int, + window_num_frames: int, + device: torch.device | str | int | None = None, +) -> torch.Tensor: + """Return the selected contiguous frame indices as a 1-D LongTensor.""" + start = stateless_temporal_window_start( + seed, + domain, + sample_key, + source_num_frames=source_num_frames, + window_num_frames=window_num_frames, + ) + return torch.arange( + start, + start + window_num_frames, + dtype=torch.long, + device=device, + ) diff --git a/14B/code/video_fd/replay/__init__.py b/14B/code/video_fd/replay/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..549154c94d134908851dbfa46ea0cf7188cfa481 --- /dev/null +++ b/14B/code/video_fd/replay/__init__.py @@ -0,0 +1,14 @@ +from video_fd.replay.scatter_frame_grads import gather_frames, scatter_add_frames +from video_fd.replay.replay_video_encoder_vjp import ( + replay_encoder_vjp, + replay_video_encoder_vjp, + replay_decoder_vjp, +) + +__all__ = [ + "gather_frames", + "scatter_add_frames", + "replay_encoder_vjp", + "replay_video_encoder_vjp", + "replay_decoder_vjp", +] diff --git a/14B/code/video_fd/replay/replay_video_encoder_vjp.py b/14B/code/video_fd/replay/replay_video_encoder_vjp.py new file mode 100644 index 0000000000000000000000000000000000000000..e2bbcb46edda3da77f03833e9e83163072619599 --- /dev/null +++ b/14B/code/video_fd/replay/replay_video_encoder_vjp.py @@ -0,0 +1,257 @@ +"""Replay VJP: feature gradients -> pixel-frame gradients -> latent gradient. + +Stage B1 (encoder VJP): for each sampled clip, re-run the frozen encoder (+ the +differentiable normalize/project) **with gradients on the clip**, form the linear +surrogate ````, take its gradient w.r.t. the clip, and +scatter-add into the full 81-frame pixel-gradient buffer. + +Stage B2 exposes two decoder helpers. The released feature-matching model calls +``replay_decoder_vjp``, which detaches temporal caches between latent frames. +The optional ``replay_decoder_vjp_exact`` helper preserves temporal derivatives +using recurrent boundary replay; it is not selected by that training path. + +Frozen parameter gradients remain disabled; replay builds only the input and +explicit recurrent-state graphs required for the requested VJP. +""" + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Callable, List, Optional, Tuple + +import torch +from torch.utils.checkpoint import checkpoint + +from video_fd.replay.scatter_frame_grads import gather_frames, scatter_add_frames, new_frame_grad_buffer +from video_fd.replay.wan_recurrent_decoder_vjp import ( + replay_wan_decoder_vjp_exact, + wan_recurrent_components, +) + + +def _amp_ctx(use_amp: bool, amp_dtype: torch.dtype, device_type: str): + if use_amp and device_type == "cuda": + return torch.autocast(device_type="cuda", dtype=amp_dtype) + return nullcontext() + + +def replay_encoder_vjp( + encoder, + v_clip_detached: torch.Tensor, # [B, T, 3, H, W] in [0,1], detached + grad_feat_proj: torch.Tensor, # [B, S, D_proj], dL/d(projected feature) + projector: Optional[Callable[[torch.Tensor], torch.Tensor]] = None, + use_amp: bool = False, + amp_dtype: torch.dtype = torch.bfloat16, +) -> torch.Tensor: + """One-clip encoder VJP: returns ``g_clip`` = (dF_proj/d v_clip)^T grad_feat_proj.""" + # Frozen encoder parameters still allow derivatives with respect to pixels. + # Recreate only this clip's graph; grad_feat_proj is the cached cotangent. + v_leaf = v_clip_detached.detach().clone().requires_grad_(True) + with _amp_ctx(use_amp, amp_dtype, v_leaf.device.type): + feat = encoder.forward_sparse(v_leaf) # [B, S, D_raw] + feat_proj = projector(feat) if projector is not None else feat + feat_proj = feat_proj.float() + g = grad_feat_proj.detach().to(feat_proj.dtype) + if g.shape != feat_proj.shape: + raise RuntimeError( + f"encoder VJP shape mismatch: feat_proj {tuple(feat_proj.shape)} vs " + f"grad {tuple(g.shape)}" + ) + # The inner product injects dL/d feature without recomputing the MMD loss: + # its pixel derivative is J_encoder^T (dL/d feature), the required VJP. + replay_loss = (feat_proj * g).sum() + (g_clip,) = torch.autograd.grad(replay_loss, v_leaf, retain_graph=False, create_graph=False) + return g_clip + + +def replay_video_encoder_vjp( + video81_detached: torch.Tensor, # [B, 81, 3, H, W] in [0,1], detached + specs_and_grads: List[Tuple[object, object, torch.Tensor]], + use_amp: bool = False, + amp_dtype: torch.dtype = torch.bfloat16, +) -> torch.Tensor: + """Accumulate per-clip encoder VJPs into a full pixel-frame gradient buffer. + + ``specs_and_grads`` is a list of ``(encoder, spec_or_projector_tuple, + grad_feat_proj)`` where the middle element is ``(spec, projector)``. Each + clip's gradient is scattered into the 81-frame buffer at ``spec.frame_indices`` + (accumulating overlaps). + """ + # Multiple clips/encoders can touch the same frame. Sum their derivatives + # in video coordinates before replaying the VAE decoder. + g_video = new_frame_grad_buffer(video81_detached) + for encoder, (spec, projector), grad_feat_proj in specs_and_grads: + idx = spec.frame_indices + v_clip = gather_frames(video81_detached, idx, time_dim=1) # [B, 16, 3, H, W] + g_clip = replay_encoder_vjp( + encoder, v_clip, grad_feat_proj, projector=projector, + use_amp=use_amp, amp_dtype=amp_dtype, + ) + scatter_add_frames(g_video, idx, g_clip, time_dim=1) + return g_video + + +@torch.enable_grad() +def replay_decoder_vjp( + vae, + z_detached: torch.Tensor, # [B, T_lat, C, H, W] (repo latent layout) + g_video01: torch.Tensor, # [B, T_pix, 3, H, W] dL/d(pixels in [0,1]) + independent_first_frame: bool = True, +) -> torch.Tensor: + """Per-latent-frame decode VJP: ``grad_z = (d pixels01 / d z)^T g_video01``. + + Re-decodes one latent frame at a time with grad, carrying the VAE temporal + cache by value but detaching it between frames so peak memory is ~one frame's + decode graph. Frames with zero gradient still advance the cache (needed for + exact values of later frames) but contribute no latent gradient. + + Temporal cache detachment truncates cross-frame derivatives. This historical + path preserves decode values, but is not the full decoder Jacobian. + """ + vae_model = vae.model + zdim = vae_model.z_dim + device = z_detached.device + dtype = z_detached.dtype + mean = vae.mean.to(device=device, dtype=dtype).view(1, zdim, 1, 1, 1) + inv_std = (1.0 / vae.std.to(device=device, dtype=dtype)).view(1, zdim, 1, 1, 1) + + z_cf = z_detached.detach().permute(0, 2, 1, 3, 4).contiguous() # [B, zdim, T_lat, H, W] + T = z_cf.shape[2] + grad_z_cf = torch.zeros_like(z_cf, dtype=torch.float32) + + # Recompute one decode segment, inject its pixel cotangent, and release the + # segment graph. The returned latent cotangent drives generator replay next. + vae_model.clear_cache() + px_cursor = 0 + for i in range(T): + vae_model._conv_idx = [0] + z_i = z_cf[:, :, i:i + 1].clone().requires_grad_(True) + z_i_unscaled = z_i / inv_std + mean + x_i = vae_model.conv2(z_i_unscaled) + out_i = vae_model.decoder(x_i, feat_cache=vae_model._feat_map, feat_idx=vae_model._conv_idx) + out_i = out_i.float().clamp(-1.0, 1.0) + pix_i = out_i.shape[2] + pixels01_i = ((out_i + 1.0) * 0.5).permute(0, 2, 1, 3, 4) # [B, pix_i, 3, H, W] + end = min(px_cursor + pix_i, g_video01.shape[1]) + # Compute the surrogate in float32 so a large pixel-frame gradient does + # not overflow bf16 (the encoder VJP can be large). + g_i = g_video01[:, px_cursor:end].float() + if g_i.shape[1] == pixels01_i.shape[1] and torch.isfinite(g_i).all() and g_i.abs().sum() > 0: + surrogate_i = (pixels01_i.float() * g_i.detach()).sum() + (grad_zi,) = torch.autograd.grad(surrogate_i, z_i, retain_graph=False) + grad_z_cf[:, :, i:i + 1] += grad_zi.float() + # The gradient has already been accumulated. Keeping its graph + # root alive until the next decoder call would retain the old + # frame leaf and output storage without affecting any later math. + del surrogate_i, grad_zi + px_cursor += pix_i + vae_model._feat_map = [c.detach() if torch.is_tensor(c) else c for c in vae_model._feat_map] + # The detached temporal cache above is the only state needed by the + # next frame. Release the consumed frame graph and views before that + # frame's decoder reaches its allocation peak. + del pixels01_i, out_i, x_i, z_i_unscaled, z_i, g_i + vae_model.clear_cache() + return grad_z_cf.permute(0, 2, 1, 3, 4) # [B, T_lat, C, H, W] + + +@torch.enable_grad() +def _replay_generic_decoder_vjp_exact( + vae, + z_detached: torch.Tensor, + g_video01: torch.Tensor, + *, + expected_pixels01: torch.Tensor | None = None, +) -> torch.Tensor: + """Exact full-video decoder VJP with activation replay. + + Wan's causal VAE carries temporal feature caches between all 21 latent + frames. Detaching those caches changes the MMD gradient. This path + reentrantly checkpoints the complete frozen decode instead: the first + forward runs without an autograd graph, then backward recomputes the exact + graph while saved activations are moved to pageable CPU memory. Every + cross-frame dependency remains in the autograd graph. + """ + + if z_detached.ndim != 5 or g_video01.ndim != 5: + raise ValueError("decoder VJP expects [B,T,C,H,W] latent and pixel tensors") + if z_detached.device.type != g_video01.device.type: + raise ValueError("decoder latent and pixel cotangent must share a device") + if not torch.isfinite(g_video01).all(): + raise ValueError("decoder pixel cotangent contains non-finite values") + z_leaf = z_detached.detach().clone().requires_grad_(True) + + def decode_pixels01(latent: torch.Tensor) -> torch.Tensor: + # With reentrant checkpointing the first call is under no_grad; only + # the backward recompute saves tensors. Keeping this hook *inside* + # the function is required so those saved activations move to CPU. + with torch.autograd.graph.save_on_cpu( + pin_memory=False, device_type=latent.device.type + ): + with _amp_ctx(True, torch.bfloat16, latent.device.type): + decoded = vae.decode_to_pixel(latent, use_cache=False) + pixels01 = ((decoded + 1.0) * 0.5).clamp(0.0, 1.0) + return pixels01 + + pixels01 = checkpoint( + decode_pixels01, + z_leaf, + use_reentrant=True, + preserve_rng_state=False, + ) + if tuple(pixels01.shape) != tuple(g_video01.shape): + raise ValueError( + f"decoder cotangent shape {tuple(g_video01.shape)} differs from " + f"decoded pixels {tuple(pixels01.shape)}" + ) + surrogate = (pixels01.float() * g_video01.detach().float()).sum() + if expected_pixels01 is not None and not torch.equal( + pixels01.detach(), expected_pixels01 + ): + raise RuntimeError("generic decoder replay differs from expected pixels") + surrogate.backward() + gradient = z_leaf.grad + if gradient is None: + raise RuntimeError("exact decoder VJP did not produce a latent gradient") + if not torch.isfinite(gradient).all(): + raise RuntimeError("exact decoder VJP produced non-finite latent gradients") + return gradient.detach() + + +@torch.enable_grad() +def replay_decoder_vjp_exact( + vae, + z_detached: torch.Tensor, + g_video01: torch.Tensor, + *, + expected_pixels01: torch.Tensor | None = None, +) -> torch.Tensor: + """Exact decoder VJP with bounded-memory Wan recurrent BPTT. + + The official Wan decoder uses an explicit 32-cache recurrent state, K=4 + pageable-CPU boundaries, and future-state cotangent propagation. This + retains the complete temporal Jacobian without materializing or offloading + the whole 21-frame graph. Stateless toy/non-Wan decoders retain the + generic reentrant checkpoint fallback for test and API compatibility. + """ + + if z_detached.ndim != 5 or g_video01.ndim != 5: + raise ValueError("decoder VJP expects [B,T,C,H,W] latent and pixel tensors") + if z_detached.device != g_video01.device: + raise ValueError("decoder latent and pixel cotangent must share a device") + if not torch.isfinite(g_video01).all(): + raise ValueError("decoder pixel cotangent contains non-finite values") + # This optional Wan path carries future-state cotangents across temporal + # boundaries; the generic fallback preserves the full decode graph as well. + if wan_recurrent_components(vae) is not None: + return replay_wan_decoder_vjp_exact( + vae, + z_detached, + g_video01, + expected_pixels01=expected_pixels01, + ) + return _replay_generic_decoder_vjp_exact( + vae, + z_detached, + g_video01, + expected_pixels01=expected_pixels01, + ) diff --git a/14B/code/video_fd/replay/scatter_frame_grads.py b/14B/code/video_fd/replay/scatter_frame_grads.py new file mode 100644 index 0000000000000000000000000000000000000000..52b3eb253d42c849d970dabc8ef5315ae82f1fa8 --- /dev/null +++ b/14B/code/video_fd/replay/scatter_frame_grads.py @@ -0,0 +1,22 @@ +"""Frame gather / scatter-add for replay over arbitrary selected frame indices. + +The stride-4 sampler selects interleaved pixel frames; the replay must (a) gather +those frames into a 16-frame clip and (b) scatter the clip's pixel gradient back +into the full 81-frame buffer, **accumulating** when a frame is selected by +multiple clips / crops / encoders (spec section 11.3). +""" + +from __future__ import annotations + +import torch + +# Re-export the canonical implementations from the sampler module so there is a +# single source of truth for the gather/scatter rule. +from video_fd.samplers.stride4_clip_sampler import gather_frames, scatter_add_frames + +__all__ = ["gather_frames", "scatter_add_frames", "new_frame_grad_buffer"] + + +def new_frame_grad_buffer(video: torch.Tensor) -> torch.Tensor: + """Zero gradient buffer matching a ``[B, T, 3, H, W]`` video (float32).""" + return torch.zeros_like(video, dtype=torch.float32) diff --git a/14B/code/video_fd/replay/wan_recurrent_decoder_vjp.py b/14B/code/video_fd/replay/wan_recurrent_decoder_vjp.py new file mode 100644 index 0000000000000000000000000000000000000000..71b0aef6a5c3627fe878d87e04abd4334675c0af --- /dev/null +++ b/14B/code/video_fd/replay/wan_recurrent_decoder_vjp.py @@ -0,0 +1,579 @@ +"""Bounded-memory exact recurrent VJP for the official Wan VAE decoder. + +Wan's decoder mutates a Python feature-cache list while consuming one latent +frame at a time. This module makes that list an explicit recurrent state, +stores only K=4 boundaries on CPU, and propagates the future-state cotangent +back through every transition. The resulting VJP is the full temporal +Jacobian, not the historical detached-cache approximation. +""" + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Any + +import torch + + +CHUNK_LATENT_FRAMES = 4 +REP_SENTINEL = "Rep" + + +def _amp(device_type: str): + if device_type == "cuda": + return torch.autocast(device_type="cuda", dtype=torch.bfloat16) + return nullcontext() + + +def wan_recurrent_components(vae: Any) -> tuple[Any, Any] | None: + """Duck-type the official Wan wrapper without creating an import cycle.""" + + model = getattr(vae, "model", None) + decoder = getattr(model, "decoder", None) + if ( + model is None + or decoder is None + or not hasattr(model, "conv2") + or not hasattr(model, "z_dim") + or not hasattr(decoder, "num_res_blocks") + or not hasattr(decoder, "dim_mult") + or not hasattr(decoder, "temperal_upsample") + or not hasattr(vae, "mean") + or not hasattr(vae, "std") + ): + return None + return model, decoder + + +def _active_cache_slots(decoder: Any) -> int: + stages = len(tuple(decoder.dim_mult)) + residual_convs = 4 + stages * (int(decoder.num_res_blocks) + 1) * 2 + temporal_resamplers = sum( + bool(value) for value in tuple(decoder.temperal_upsample)[: stages - 1] + ) + # decoder.conv1 + residual convs + temporal resamplers + decoder.head + return 1 + residual_convs + temporal_resamplers + 1 + + +def _is_formal_480p(model: Any, decoder: Any, latent: torch.Tensor) -> bool: + return ( + int(model.z_dim) == 16 + and int(decoder.dim) == 96 + and int(decoder.num_res_blocks) == 2 + and tuple(decoder.dim_mult) == (1, 2, 4, 4) + and tuple(bool(v) for v in decoder.temperal_upsample) + == (True, True, False) + and tuple(latent.shape) == (1, 21, 16, 60, 104) + ) + + +def _model_cache_signature(model: Any) -> tuple[Any, ...]: + """Identity-only proof that the functional path never touched globals.""" + + values = getattr(model, "_feat_map", None) + index = getattr(model, "_conv_idx", None) + value_signature = None + if isinstance(values, list): + value_signature = tuple( + ("tensor", id(value)) if torch.is_tensor(value) else ("value", value) + for value in values + ) + index_signature = tuple(index) if isinstance(index, list) else index + return id(values), value_signature, id(index), index_signature + + +def _formal_shapes( + *, batch: int, height: int, width: int, frames_consumed: int +) -> dict[int, tuple[int, ...] | str]: + temporal = 1 if frames_consumed == 1 else 2 + result: dict[int, tuple[int, ...] | str] = { + 0: (batch, 16, temporal, height, width), + 12: (batch, 192, temporal, height * 2, width * 2), + } + result.update( + { + index: (batch, 384, temporal, height, width) + for index in range(1, 11) + } + ) + result[11] = ( + REP_SENTINEL + if frames_consumed == 1 + else (batch, 384, 2, height, width) + ) + result.update( + { + index: (batch, 384, temporal, height * 2, width * 2) + for index in range(13, 18) + } + ) + result[18] = ( + REP_SENTINEL + if frames_consumed == 1 + else (batch, 384, 2, height * 2, width * 2) + ) + result.update( + { + index: (batch, 192, temporal, height * 4, width * 4) + for index in range(19, 25) + } + ) + result.update( + { + index: (batch, 96, temporal, height * 8, width * 8) + for index in range(25, 32) + } + ) + return result + + +def _formal_cache_dtype(index: int) -> torch.dtype: + if index not in range(32): + raise ValueError(f"formal Wan cache index out of range: {index}") + return torch.bfloat16 if index in (0, 11, 18) else torch.float32 + + +def _validate_state( + state: tuple[Any, ...], + *, + slots: int, + frames_consumed: int, + batch: int, + height: int, + width: int, + formal: bool, + device_type: str | None = None, +) -> None: + if len(state) != slots: + raise RuntimeError(f"Wan state has {len(state)} slots, expected {slots}") + for index, value in enumerate(state): + if torch.is_tensor(value): + if value.ndim != 5 or value.shape[0] != batch: + raise RuntimeError( + f"Wan cache slot {index} has invalid shape {tuple(value.shape)}" + ) + if not value.is_floating_point(): + raise TypeError(f"Wan cache slot {index} is not floating point") + if device_type is not None and value.device.type != device_type: + raise RuntimeError( + f"Wan cache slot {index} is on {value.device.type}, " + f"expected {device_type}" + ) + elif value is None: + if frames_consumed != 0: + raise RuntimeError(f"Wan cache slot {index} unexpectedly remained empty") + elif value == REP_SENTINEL: + if frames_consumed != 1 or (formal and index not in (11, 18)): + raise RuntimeError( + f"unexpected Rep sentinel in slot {index} after " + f"{frames_consumed} frames" + ) + else: + raise TypeError( + f"unsupported Wan cache value {type(value).__name__} in slot {index}" + ) + + if not formal: + return + if slots != 32: + raise RuntimeError(f"formal Wan decoder needs 32 cache slots, got {slots}") + if frames_consumed == 0: + if any(value is not None for value in state): + raise RuntimeError("initial formal Wan state is not empty") + return + expected = _formal_shapes( + batch=batch, + height=height, + width=width, + frames_consumed=frames_consumed, + ) + if set(expected) != set(range(32)): + raise AssertionError("formal cache layout does not cover all 32 slots") + for index, specification in expected.items(): + value = state[index] + if specification == REP_SENTINEL: + if value != REP_SENTINEL: + raise RuntimeError(f"formal Wan slot {index} must be Rep") + continue + if not torch.is_tensor(value) or tuple(value.shape) != specification: + actual = tuple(value.shape) if torch.is_tensor(value) else value + raise RuntimeError( + f"formal Wan slot {index} has {actual}, expected {specification}" + ) + expected_dtype = _formal_cache_dtype(index) + if value.dtype != expected_dtype: + raise TypeError( + f"formal Wan slot {index} must be {expected_dtype}, " + f"got {value.dtype}" + ) + + +def _state_to_cpu(state: tuple[Any, ...], *, formal: bool) -> tuple[Any, ...]: + result: list[Any] = [] + for index, value in enumerate(state): + if torch.is_tensor(value): + if formal and value.dtype != _formal_cache_dtype(index): + raise TypeError( + f"formal boundary slot {index} must be " + f"{_formal_cache_dtype(index)}, got {value.dtype}" + ) + result.append( + value.detach().to(device="cpu", non_blocking=False).contiguous() + ) + else: + result.append(value) + return tuple(result) + + +def _state_to_device( + state: tuple[Any, ...], *, device: torch.device +) -> tuple[Any, ...]: + return tuple( + value.to(device=device, non_blocking=False).detach() + if torch.is_tensor(value) + else value + for value in state + ) + + +def _state_as_leaves(state: tuple[Any, ...]) -> tuple[Any, ...]: + return tuple( + value.detach().requires_grad_(True) if torch.is_tensor(value) else value + for value in state + ) + + +def _transition( + *, + model: Any, + preconv_frame: torch.Tensor, + state: tuple[Any, ...], + slots: int, +) -> tuple[torch.Tensor, tuple[Any, ...]]: + if preconv_frame.ndim != 5 or preconv_frame.shape[2] != 1: + raise ValueError("Wan transition requires one pre-convolved frame") + local_cache = list(state) + local_index = [0] + with _amp(preconv_frame.device.type): + decoded_cf = model.decoder( + preconv_frame, + feat_cache=local_cache, + feat_idx=local_index, + ) + # Preserve WanVAEWrapper exactly: FP32 [-1,1] clamp, repository + # time-first layout, then the [0,1] affine and clamp. + decoded = decoded_cf.float().clamp(-1.0, 1.0).permute(0, 2, 1, 3, 4) + pixels01 = ((decoded + 1.0) * 0.5).clamp(0.0, 1.0) + if local_index[0] != slots: + raise RuntimeError( + f"Wan transition consumed {local_index[0]} cache slots, expected {slots}" + ) + if len(local_cache) != slots: + raise RuntimeError("Wan transition changed local cache-list length") + return pixels01, tuple(local_cache) + + +def _full_preconv(vae: Any, latent: torch.Tensor) -> torch.Tensor: + model = vae.model + channels = int(model.z_dim) + if latent.shape[2] != channels: + raise ValueError( + f"Wan latent has {latent.shape[2]} channels, expected {channels}" + ) + mean = vae.mean.to(device=latent.device, dtype=latent.dtype).view( + 1, channels, 1, 1, 1 + ) + inverse_std = ( + 1.0 / vae.std.to(device=latent.device, dtype=latent.dtype) + ).view(1, channels, 1, 1, 1) + unscaled = latent.permute(0, 2, 1, 3, 4) / inverse_std + mean + return model.conv2(unscaled) + + +def _formal_pixel_span(frame_index: int) -> tuple[int, int]: + if frame_index == 0: + return 0, 1 + return 1 + 4 * (frame_index - 1), 1 + 4 * frame_index + + +def replay_wan_decoder_vjp_exact( + vae: Any, + z_detached: torch.Tensor, + g_video01: torch.Tensor, + *, + expected_pixels01: torch.Tensor | None = None, +) -> torch.Tensor: + """Run exact K=4 recurrent BPTT through an official Wan VAE decoder.""" + + components = wan_recurrent_components(vae) + if components is None: + raise TypeError("recurrent Wan VJP received a non-Wan decoder") + model, decoder = components + if z_detached.shape[0] != 1: + raise ValueError("Wan recurrent decoder VJP requires batch size 1") + if model.training or decoder.training: + raise RuntimeError("Wan recurrent decoder VJP requires eval mode") + if any(parameter.requires_grad for parameter in model.parameters()): + raise RuntimeError("Wan recurrent decoder VJP requires frozen parameters") + slots = _active_cache_slots(decoder) + formal = _is_formal_480p(model, decoder, z_detached) + if int(model.z_dim) == 16 and int(decoder.dim) == 96 and slots != 32: + raise RuntimeError(f"production Wan exposed {slots} slots, expected 32") + if formal and ( + z_detached.dtype != torch.bfloat16 or g_video01.dtype != torch.float32 + ): + raise TypeError("formal 480P VJP needs BF16 latent and FP32 cotangent") + if expected_pixels01 is not None: + if expected_pixels01.device != z_detached.device: + raise ValueError("expected pixels must remain on the latent device") + if tuple(expected_pixels01.shape) != tuple(g_video01.shape): + raise ValueError("expected pixels differ from cotangent shape") + if not torch.isfinite(expected_pixels01).all(): + raise ValueError("expected pixels contain non-finite values") + + global_signature = _model_cache_signature(model) + batch, latent_frames, _, height, width = z_detached.shape + initial_state = tuple(None for _ in range(slots)) + _validate_state( + initial_state, + slots=slots, + frames_consumed=0, + batch=batch, + height=height, + width=width, + formal=formal, + ) + + # Official decode scales and calls conv2 once at full temporal shape. + # Preserve that operation, collect dL/dx_i recurrently, then run one exact + # full-shape conv2+scale VJP at the end. + with torch.no_grad(), _amp(z_detached.device.type): + preconv = _full_preconv(vae, z_detached).detach() + if tuple(preconv.shape[:3]) != ( + batch, + int(model.z_dim), + latent_frames, + ): + raise RuntimeError(f"unexpected conv2 output shape {tuple(preconv.shape)}") + + boundaries: dict[int, tuple[Any, ...]] = {0: initial_state} + state = initial_state + pixel_lengths: list[int] = [] + cursor = 0 + final_state: tuple[Any, ...] | None = None + with torch.no_grad(): + for frame_index in range(latent_frames): + if frame_index and frame_index % CHUNK_LATENT_FRAMES == 0: + boundary = _state_to_cpu(state, formal=formal) + _validate_state( + boundary, + slots=slots, + frames_consumed=frame_index, + batch=batch, + height=height, + width=width, + formal=formal, + device_type="cpu", + ) + boundaries[frame_index] = boundary + pixels, state = _transition( + model=model, + preconv_frame=preconv[:, :, frame_index : frame_index + 1], + state=state, + slots=slots, + ) + _validate_state( + state, + slots=slots, + frames_consumed=frame_index + 1, + batch=batch, + height=height, + width=width, + formal=formal, + device_type=z_detached.device.type, + ) + length = int(pixels.shape[1]) + if tuple(pixels.shape[2:]) != tuple(g_video01.shape[2:]): + raise RuntimeError("Wan transition pixel shape differs from cotangent") + if formal: + start, end = _formal_pixel_span(frame_index) + if cursor != start or length != end - start: + raise RuntimeError( + f"frame {frame_index} emitted {length} at {cursor}, " + f"expected [{start},{end})" + ) + if expected_pixels01 is not None: + expected = expected_pixels01[:, cursor : cursor + length] + if not torch.equal(pixels, expected): + difference = (pixels.float() - expected.float()).abs() + raise RuntimeError( + "functional Wan decode differs from official values; " + f"frame={frame_index}, unequal=" + f"{int(torch.count_nonzero(pixels != expected).item())}, " + f"max_abs={float(difference.max().item())}" + ) + pixel_lengths.append(length) + cursor += length + final_state = state + del pixels + + if cursor != g_video01.shape[1]: + raise ValueError( + f"Wan emitted {cursor} pixel frames, cotangent has {g_video01.shape[1]}" + ) + if formal: + if pixel_lengths != [1] + [4] * 20 or cursor != 81: + raise RuntimeError( + f"formal output schedule changed: {pixel_lengths}, total={cursor}" + ) + if sorted(boundaries) != [0, 4, 8, 12, 16, 20]: + raise RuntimeError(f"formal CPU boundaries changed: {sorted(boundaries)}") + if final_state is None: + raise RuntimeError("Wan decoder emitted no transitions") + if formal and any(not torch.is_tensor(value) for value in final_state): + raise RuntimeError("all 32 final formal cache states must be tensors") + + # There is no terminal-state loss. Each reverse transition creates the + # exact future-state cotangent consumed by the preceding transition. + future_cotangent: tuple[Any, ...] = (None,) * slots + del final_state, state + grad_preconv = torch.empty_like(preconv) + offsets = [0] + for length in pixel_lengths: + offsets.append(offsets[-1] + length) + chunk_starts = list(range(0, latent_frames, CHUNK_LATENT_FRAMES)) + if formal and list(reversed(chunk_starts)) != [20, 16, 12, 8, 4, 0]: + raise RuntimeError(f"formal reverse chunks changed: {chunk_starts}") + + for chunk_start in reversed(chunk_starts): + chunk_end = min(chunk_start + CHUNK_LATENT_FRAMES, latent_frames) + if chunk_start not in boundaries: + raise RuntimeError(f"missing CPU boundary at frame {chunk_start}") + replay_state = _state_to_device( + boundaries[chunk_start], device=z_detached.device + ) + states_before: list[tuple[Any, ...]] = [] + with torch.no_grad(): + for frame_index in range(chunk_start, chunk_end): + states_before.append(replay_state) + replay_pixels, replay_state = _transition( + model=model, + preconv_frame=preconv[:, :, frame_index : frame_index + 1], + state=replay_state, + slots=slots, + ) + if replay_pixels.shape[1] != pixel_lengths[frame_index]: + raise RuntimeError("chunk replay changed its pixel length") + del replay_pixels + del replay_state + + for frame_index in reversed(range(chunk_start, chunk_end)): + input_state = _state_as_leaves(states_before.pop()) + preconv_leaf = ( + preconv[:, :, frame_index : frame_index + 1] + .detach() + .requires_grad_(True) + ) + pixels, output_state = _transition( + model=model, + preconv_frame=preconv_leaf, + state=input_state, + slots=slots, + ) + start, end = offsets[frame_index], offsets[frame_index + 1] + pixel_cotangent = g_video01[:, start:end].detach().float() + if tuple(pixel_cotangent.shape) != tuple(pixels.shape): + raise RuntimeError("reverse pixel cotangent shape mismatch") + if expected_pixels01 is not None: + expected = expected_pixels01[:, start:end] + if not torch.equal(pixels.detach(), expected): + difference = (pixels.detach().float() - expected.float()).abs() + raise RuntimeError( + "gradient Wan replay differs from forward values; " + f"frame={frame_index}, unequal=" + f"{int(torch.count_nonzero(pixels.detach() != expected).item())}, " + f"max_abs={float(difference.max().item())}" + ) + outputs = [pixels] + output_cotangents = [pixel_cotangent] + for index, (value, cotangent) in enumerate( + zip(output_state, future_cotangent) + ): + if cotangent is None: + continue + if not torch.is_tensor(value): + raise RuntimeError( + f"output state {index} has a cotangent but is not a tensor" + ) + if tuple(value.shape) != tuple(cotangent.shape): + raise RuntimeError(f"state cotangent mismatch in slot {index}") + if value.dtype != cotangent.dtype: + raise RuntimeError(f"state cotangent dtype mismatch in slot {index}") + outputs.append(value) + output_cotangents.append(cotangent.detach()) + + positions = [ + index for index, value in enumerate(input_state) if torch.is_tensor(value) + ] + state_leaves = [input_state[index] for index in positions] + gradients = torch.autograd.grad( + outputs, + [preconv_leaf, *state_leaves], + grad_outputs=output_cotangents, + retain_graph=False, + create_graph=False, + allow_unused=True, + ) + grad_x = gradients[0] + if grad_x is None or not torch.isfinite(grad_x).all(): + raise RuntimeError(f"transition {frame_index} has invalid conv2 gradient") + grad_preconv[:, :, frame_index : frame_index + 1].copy_(grad_x.detach()) + incoming: list[Any] = [None] * slots + for index, gradient in zip(positions, gradients[1:]): + if gradient is None: + raise RuntimeError( + f"cache slot {index} lost its recurrent gradient" + ) + if not torch.isfinite(gradient).all(): + raise RuntimeError(f"cache slot {index} has non-finite cotangent") + incoming[index] = gradient.detach() + future_cotangent = tuple(incoming) + value = cotangent = gradient = None + del ( + gradients, + grad_x, + outputs, + output_cotangents, + pixels, + output_state, + preconv_leaf, + input_state, + positions, + state_leaves, + pixel_cotangent, + incoming, + ) + if states_before: + raise AssertionError("reverse chunk left unreplayed states") + + # Finish the exact chain rule through one full-shape conv2+scale replay. + latent_leaf = z_detached.detach().clone().requires_grad_(True) + with _amp(z_detached.device.type): + replay_preconv = _full_preconv(vae, latent_leaf) + if not torch.equal(replay_preconv.detach(), preconv): + difference = (replay_preconv.detach().float() - preconv.float()).abs() + raise RuntimeError( + f"full-shape conv2 replay changed values; max_abs={float(difference.max())}" + ) + (gradient,) = torch.autograd.grad( + replay_preconv, + latent_leaf, + grad_outputs=grad_preconv.detach(), + retain_graph=False, + create_graph=False, + ) + if not torch.isfinite(gradient).all(): + raise RuntimeError("exact recurrent Wan VJP produced non-finite gradients") + if _model_cache_signature(model) != global_signature: + raise RuntimeError("functional Wan VJP mutated model._feat_map/_conv_idx") + return gradient.detach() diff --git a/14B/code/video_fd/samplers/__init__.py b/14B/code/video_fd/samplers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3cf2151b369867c739c7f7023634b5cc8ee09e1a --- /dev/null +++ b/14B/code/video_fd/samplers/__init__.py @@ -0,0 +1,7 @@ +from video_fd.samplers.stride4_clip_sampler import ( + ClipSpec, + Stride4ClipSampler, + stride4_candidate_indices, +) + +__all__ = ["ClipSpec", "Stride4ClipSampler", "stride4_candidate_indices"] diff --git a/14B/code/video_fd/samplers/stride4_clip_sampler.py b/14B/code/video_fd/samplers/stride4_clip_sampler.py new file mode 100644 index 0000000000000000000000000000000000000000..27460b605954fb64eb8726281bf8cdf9718bc054 --- /dev/null +++ b/14B/code/video_fd/samplers/stride4_clip_sampler.py @@ -0,0 +1,200 @@ +"""Stride-4 latent-aligned contiguous-clip sampler. + +From a ``num_pixel_frames``-frame, ``source_fps``-fps video, select a phase +``o ~ Uniform{0,1,2,3}`` and take every ``stride``-th frame +(``I = {o + stride*k}``), giving an effective ``source_fps/stride`` fps stream; +then take a random contiguous ``clip_frames``-frame window of that stream. + +For the canonical setting (81 frames @ 16 fps, stride 4, clip 16) this yields 16 +frames at an effective 4 fps. The *same* sampler is used for target-statistic +precomputation, generated training, and evaluation (random phase + random valid +start), with an optional deterministic mode for tests/debugging. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +import torch + + +def stride4_candidate_indices(phase: int, stride: int, num_pixel_frames: int) -> List[int]: + """All frame indices ``phase + stride*k`` within ``[0, num_pixel_frames-1]``.""" + last = num_pixel_frames - 1 + return [phase + stride * k for k in range(num_pixel_frames) if phase + stride * k <= last] + + +@dataclass +class ClipSpec: + """One sampled clip: a set of frame indices applied across the batch. + + ``crop_id`` / ``encoder_name`` are optional bookkeeping fields set by the + caller (the spatial crop is applied by the encoder adapter, not here). + """ + + phase: int + start: int + frame_indices: List[int] + stride: int = 4 + clip_frames: int = 16 + crop_id: Optional[str] = None + encoder_name: Optional[str] = None + + def to_dict(self) -> dict: + return { + "phase": int(self.phase), + "start": int(self.start), + "frame_indices": list(self.frame_indices), + "stride": int(self.stride), + "clip_frames": int(self.clip_frames), + "crop_id": self.crop_id, + "encoder_name": self.encoder_name, + } + + def index_tensor(self, device=None) -> torch.Tensor: + return torch.tensor(self.frame_indices, dtype=torch.long, device=device) + + +class Stride4ClipSampler: + """Sampler for stride-4 contiguous clips. + + Parameters + ---------- + num_pixel_frames, source_fps, stride, clip_frames: + temporal geometry (defaults: 81, 16, 4, 16 -> 16 frames @ 4 fps). + random_phase / random_start: + if ``False`` and a ``deterministic`` value is given, use it. + deterministic / phase / start: + debug mode -- always return ``frame_indices`` for the fixed phase/start. + """ + + def __init__( + self, + num_pixel_frames: int = 81, + source_fps: int = 16, + stride: int = 4, + clip_frames: int = 16, + random_phase: bool = True, + random_start: bool = True, + deterministic: bool = False, + phase: int = 0, + start: int = 0, + ): + if clip_frames <= 0: + raise ValueError("clip_frames must be positive") + self.num_pixel_frames = int(num_pixel_frames) + self.source_fps = int(source_fps) + self.stride = int(stride) + self.clip_frames = int(clip_frames) + self.random_phase = bool(random_phase) + self.random_start = bool(random_start) + self.deterministic = bool(deterministic) + self.fixed_phase = int(phase) + self.fixed_start = int(start) + + # validate feasibility: the shortest phase stream must still hold a clip + min_candidates = min( + len(stride4_candidate_indices(p, self.stride, self.num_pixel_frames)) + for p in range(self.stride) + ) + if min_candidates < self.clip_frames: + raise ValueError( + f"stride={self.stride}, clip_frames={self.clip_frames} infeasible: " + f"some phase yields only {min_candidates} candidate frames " + f"(< {self.clip_frames}) for {self.num_pixel_frames} pixel frames" + ) + + @property + def effective_fps(self) -> float: + return self.source_fps / self.stride + + def _sample_one(self, generator: Optional[torch.Generator]) -> ClipSpec: + if self.deterministic: + phase = self.fixed_phase + start = self.fixed_start + else: + if self.random_phase: + phase = int(torch.randint(0, self.stride, (), generator=generator).item()) + else: + phase = self.fixed_phase + candidate = stride4_candidate_indices(phase, self.stride, self.num_pixel_frames) + max_start = len(candidate) - self.clip_frames + if self.random_start and max_start > 0: + start = int(torch.randint(0, max_start + 1, (), generator=generator).item()) + else: + start = min(self.fixed_start, max_start) if not self.random_start else 0 + candidate = stride4_candidate_indices(phase, self.stride, self.num_pixel_frames) + max_start = len(candidate) - self.clip_frames + start = max(0, min(start, max_start)) + frame_indices = candidate[start: start + self.clip_frames] + return ClipSpec( + phase=phase, + start=start, + frame_indices=frame_indices, + stride=self.stride, + clip_frames=self.clip_frames, + ) + + def sample( + self, + batch_size: int = 1, + num_clips: int = 1, + generator: Optional[torch.Generator] = None, + ) -> List[ClipSpec]: + """Return ``num_clips`` clip specs (each applied across the whole batch). + + ``batch_size`` is accepted for API symmetry; the same frame indices are + applied to all batch items of a given clip (see ``gather_frames``). + """ + return [self._sample_one(generator) for _ in range(max(1, num_clips))] + + def config_dict(self) -> dict: + """Sampler config to embed in every statistics file (spec section 1.2).""" + return { + "source_fps": self.source_fps, + "num_pixel_frames": self.num_pixel_frames, + "stride": self.stride, + "effective_fps": self.effective_fps, + "clip_frames": self.clip_frames, + "phase_distribution": "uniform_0_1_2_3" if self.stride == 4 else f"uniform_0_{self.stride - 1}", + "start_distribution": "uniform_valid_contiguous_start", + "deterministic": self.deterministic, + } + + @classmethod + def from_config(cls, cfg: dict) -> "Stride4ClipSampler": + return cls( + num_pixel_frames=int(cfg.get("num_pixel_frames", 81)), + source_fps=int(cfg.get("source_fps", 16)), + stride=int(cfg.get("stride", 4)), + clip_frames=int(cfg.get("clip_frames", 16)), + random_phase=bool(cfg.get("random_phase", True)), + random_start=bool(cfg.get("random_start", True)), + deterministic=bool(cfg.get("deterministic", cfg.get("deterministic_debug", False))), + phase=int(cfg.get("phase", 0)), + start=int(cfg.get("start", 0)), + ) + + +def gather_frames(video: torch.Tensor, frame_indices, time_dim: int = 1) -> torch.Tensor: + """Select ``frame_indices`` along ``time_dim`` of ``video`` (differentiable).""" + if not torch.is_tensor(frame_indices): + frame_indices = torch.tensor(list(frame_indices), dtype=torch.long, device=video.device) + else: + frame_indices = frame_indices.to(device=video.device, dtype=torch.long) + return video.index_select(time_dim, frame_indices) + + +def scatter_add_frames(target: torch.Tensor, frame_indices, values: torch.Tensor, time_dim: int = 1) -> torch.Tensor: + """Accumulate ``values`` into ``target`` at ``frame_indices`` along ``time_dim``. + + Uses ``index_add`` so that a pixel frame selected by multiple clips / crops / + encoders correctly accumulates gradients (spec section 11.3). + """ + if not torch.is_tensor(frame_indices): + frame_indices = torch.tensor(list(frame_indices), dtype=torch.long, device=target.device) + else: + frame_indices = frame_indices.to(device=target.device, dtype=torch.long) + target.index_add_(time_dim, frame_indices, values.to(target.dtype)) + return target diff --git a/14B/code/wan/__init__.py b/14B/code/wan/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..764073680ebcb829e03f1f9a207a6248e0da84a6 --- /dev/null +++ b/14B/code/wan/__init__.py @@ -0,0 +1 @@ +"""Wan backbone modules used by distribution matching.""" diff --git a/14B/code/wan/modules/__init__.py b/14B/code/wan/modules/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f8935bbb45ab4e3f349d203b673102f7cfc07553 --- /dev/null +++ b/14B/code/wan/modules/__init__.py @@ -0,0 +1,16 @@ +from .attention import flash_attention +from .model import WanModel +from .t5 import T5Decoder, T5Encoder, T5EncoderModel, T5Model +from .tokenizers import HuggingfaceTokenizer +from .vae import WanVAE + +__all__ = [ + 'WanVAE', + 'WanModel', + 'T5Model', + 'T5Encoder', + 'T5Decoder', + 'T5EncoderModel', + 'HuggingfaceTokenizer', + 'flash_attention', +] diff --git a/14B/code/wan/modules/attention.py b/14B/code/wan/modules/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..4a64f256328cb980dcb749302d685d5c4b657ab1 --- /dev/null +++ b/14B/code/wan/modules/attention.py @@ -0,0 +1,218 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import torch + +try: + import flash_attn_interface + + def is_hopper_gpu(): + if not torch.cuda.is_available(): + return False + device_name = torch.cuda.get_device_name(0).lower() + return "h100" in device_name or "hopper" in device_name + FLASH_ATTN_3_AVAILABLE = is_hopper_gpu() +except ModuleNotFoundError: + FLASH_ATTN_3_AVAILABLE = False + +try: + import flash_attn + FLASH_ATTN_2_AVAILABLE = True +except ModuleNotFoundError: + FLASH_ATTN_2_AVAILABLE = False + +# FLASH_ATTN_3_AVAILABLE = False + +import warnings + +__all__ = [ + 'flash_attention', + 'attention', +] + + +def flash_attention( + q, + k, + v, + q_lens=None, + k_lens=None, + dropout_p=0., + softmax_scale=None, + q_scale=None, + causal=False, + window_size=(-1, -1), + deterministic=False, + dtype=torch.bfloat16, + version=None, +): + """ + q: [B, Lq, Nq, C1]. + k: [B, Lk, Nk, C1]. + v: [B, Lk, Nk, C2]. Nq must be divisible by Nk. + q_lens: [B]. + k_lens: [B]. + dropout_p: float. Dropout probability. + softmax_scale: float. The scaling of QK^T before applying softmax. + causal: bool. Whether to apply causal attention mask. + window_size: (left right). If not (-1, -1), apply sliding window local attention. + deterministic: bool. If True, slightly slower and uses more memory. + dtype: torch.dtype. Apply when dtype of q/k/v is not float16/bfloat16. + """ + half_dtypes = (torch.float16, torch.bfloat16) + assert dtype in half_dtypes + assert q.device.type == 'cuda' and q.size(-1) <= 256 + + # params + b, lq, lk, out_dtype = q.size(0), q.size(1), k.size(1), q.dtype + + def half(x): + return x if x.dtype in half_dtypes else x.to(dtype) + + # preprocess query + if q_lens is None: + q = half(q.flatten(0, 1)) + q_lens = torch.tensor( + [lq] * b, dtype=torch.int32).to( + device=q.device, non_blocking=True) + else: + q = half(torch.cat([u[:v] for u, v in zip(q, q_lens)])) + + # preprocess key, value + if k_lens is None: + k = half(k.flatten(0, 1)) + v = half(v.flatten(0, 1)) + k_lens = torch.tensor( + [lk] * b, dtype=torch.int32).to( + device=k.device, non_blocking=True) + else: + k = half(torch.cat([u[:v] for u, v in zip(k, k_lens)])) + v = half(torch.cat([u[:v] for u, v in zip(v, k_lens)])) + + q = q.to(v.dtype) + k = k.to(v.dtype) + + if q_scale is not None: + q = q * q_scale + + if version is not None and version == 3 and not FLASH_ATTN_3_AVAILABLE: + warnings.warn( + 'Flash attention 3 is not available, use flash attention 2 instead.' + ) + + # apply attention + if (version is None or version == 3) and FLASH_ATTN_3_AVAILABLE: + # Note: dropout_p, window_size are not supported in FA3 now. + x = flash_attn_interface.flash_attn_varlen_func( + q=q, + k=k, + v=v, + cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + max_seqlen_q=lq, + max_seqlen_k=lk, + softmax_scale=softmax_scale, + causal=causal, + deterministic=deterministic)[0].unflatten(0, (b, lq)) + else: + assert FLASH_ATTN_2_AVAILABLE + x = flash_attn.flash_attn_varlen_func( + q=q, + k=k, + v=v, + cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum( + 0, dtype=torch.int32).to(q.device, non_blocking=True), + max_seqlen_q=lq, + max_seqlen_k=lk, + dropout_p=dropout_p, + softmax_scale=softmax_scale, + causal=causal, + window_size=window_size, + deterministic=deterministic).unflatten(0, (b, lq)) + + # output + return x.type(out_dtype) + + +def attention( + q, + k, + v, + q_lens=None, + k_lens=None, + dropout_p=0., + softmax_scale=None, + q_scale=None, + causal=False, + window_size=(-1, -1), + deterministic=False, + dtype=torch.bfloat16, + fa_version=None, +): + if FLASH_ATTN_2_AVAILABLE or FLASH_ATTN_3_AVAILABLE: + return flash_attention( + q=q, + k=k, + v=v, + q_lens=q_lens, + k_lens=k_lens, + dropout_p=dropout_p, + softmax_scale=softmax_scale, + q_scale=q_scale, + causal=causal, + window_size=window_size, + deterministic=deterministic, + dtype=dtype, + version=fa_version, + ) + else: + if window_size != (-1, -1): + raise RuntimeError( + "PyTorch SDPA fallback does not support Wan sliding windows" + ) + out_dtype = q.dtype + batch_size, max_q_len = q.shape[:2] + outputs = [] + for batch_index in range(batch_size): + query_len = ( + max_q_len + if q_lens is None + else int(q_lens[batch_index].item()) + ) + key_len = ( + k.shape[1] + if k_lens is None + else int(k_lens[batch_index].item()) + ) + query = q[batch_index : batch_index + 1, :query_len] + key = k[batch_index : batch_index + 1, :key_len] + value = v[batch_index : batch_index + 1, :key_len] + query = query.transpose(1, 2).to(dtype) + key = key.transpose(1, 2).to(dtype) + value = value.transpose(1, 2).to(dtype) + if q_scale is not None: + query = query * q_scale + if query.shape[1] != key.shape[1]: + if query.shape[1] % key.shape[1]: + raise ValueError( + "query heads must be divisible by key/value heads" + ) + repeats = query.shape[1] // key.shape[1] + key = key.repeat_interleave(repeats, dim=1) + value = value.repeat_interleave(repeats, dim=1) + output = torch.nn.functional.scaled_dot_product_attention( + query, + key, + value, + dropout_p=dropout_p, + is_causal=causal, + scale=softmax_scale, + ).transpose(1, 2) + if query_len < max_q_len: + output = torch.nn.functional.pad( + output, (0, 0, 0, 0, 0, max_q_len - query_len) + ) + outputs.append(output) + return torch.cat(outputs, dim=0).contiguous().to(out_dtype) diff --git a/14B/code/wan/modules/causal_model.py b/14B/code/wan/modules/causal_model.py new file mode 100644 index 0000000000000000000000000000000000000000..13bf00b96712386502c9a518d79dfe5e9f45d5e1 --- /dev/null +++ b/14B/code/wan/modules/causal_model.py @@ -0,0 +1,1166 @@ +from wan.modules.attention import attention +from wan.modules.model import ( + WanRMSNorm, + rope_apply, + WanLayerNorm, + WAN_CROSSATTENTION_CLASSES, + rope_params, + MLPProj, + sinusoidal_embedding_1d +) +from torch.nn.attention.flex_attention import create_block_mask, flex_attention +from diffusers.configuration_utils import ConfigMixin, register_to_config +from torch.nn.attention.flex_attention import BlockMask +from diffusers.models.modeling_utils import ModelMixin +import torch.nn as nn +import torch +import math +import torch.distributed as dist + +# wan 1.3B model has a weird channel / head configurations and require max-autotune to work with flexattention +# see https://github.com/pytorch/pytorch/issues/133254 +# change to default for other models +flex_attention = torch.compile( + flex_attention, dynamic=False, mode="max-autotune-no-cudagraphs") + + +def causal_rope_apply(x, grid_sizes, freqs, start_frame=0): + n, c = x.size(2), x.size(3) // 2 + + # split freqs + freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) + + # loop over samples + output = [] + + for i, (f, h, w) in enumerate(grid_sizes.tolist()): + seq_len = f * h * w + + # Preserve the pretrained causal Wan numerics. The encoder-specific + # memory trade-off must not silently change the student's RoPE. + x_i = torch.view_as_complex(x[i, :seq_len].to(torch.float64).reshape( + seq_len, n, -1, 2)) + freqs_i = torch.cat([ + freqs[0][start_frame:start_frame + f].view(f, 1, 1, -1).expand(f, h, w, -1), + freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1), + freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1) + ], + dim=-1).reshape(seq_len, 1, -1) + + # apply rotary embedding + x_i = torch.view_as_real(x_i * freqs_i).flatten(2) + x_i = torch.cat([x_i, x[i, seq_len:]]) + + # append to collection + output.append(x_i) + return torch.stack(output).type_as(x) + + +class CausalWanSelfAttention(nn.Module): + + def __init__(self, + dim, + num_heads, + local_attn_size=-1, + sink_size=0, + qk_norm=True, + eps=1e-6): + assert dim % num_heads == 0 + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.head_dim = dim // num_heads + self.local_attn_size = local_attn_size + self.sink_size = sink_size + self.qk_norm = qk_norm + self.eps = eps + self.max_attention_size = 32760 if local_attn_size == -1 else local_attn_size * 1560 + + # layers + self.q = nn.Linear(dim, dim) + self.k = nn.Linear(dim, dim) + self.v = nn.Linear(dim, dim) + self.o = nn.Linear(dim, dim) + self.norm_q = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + self.norm_k = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + + def forward( + self, + x, + seq_lens, + grid_sizes, + freqs, + block_mask, + kv_cache=None, + current_start=0, + cache_start=None, + update_kv_cache=True, + ): + r""" + Args: + x(Tensor): Shape [B, L, num_heads, C / num_heads] + seq_lens(Tensor): Shape [B] + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + block_mask (BlockMask) + """ + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + if cache_start is None: + cache_start = current_start + + # query, key, value function + def qkv_fn(x): + q = self.norm_q(self.q(x)).view(b, s, n, d) + k = self.norm_k(self.k(x)).view(b, s, n, d) + v = self.v(x).view(b, s, n, d) + return q, k, v + + q, k, v = qkv_fn(x) + + if kv_cache is None: + # if it is teacher forcing training? + is_tf = (s == seq_lens[0].item() * 2) + if is_tf: + q_chunk = torch.chunk(q, 2, dim=1) + k_chunk = torch.chunk(k, 2, dim=1) + roped_query = [] + roped_key = [] + # rope should be same for clean and noisy parts + for ii in range(2): + rq = rope_apply(q_chunk[ii], grid_sizes, freqs).type_as(v) + rk = rope_apply(k_chunk[ii], grid_sizes, freqs).type_as(v) + roped_query.append(rq) + roped_key.append(rk) + + roped_query = torch.cat(roped_query, dim=1) + roped_key = torch.cat(roped_key, dim=1) + + padded_length = math.ceil(q.shape[1] / 128) * 128 - q.shape[1] + padded_roped_query = torch.cat( + [roped_query, + torch.zeros([q.shape[0], padded_length, q.shape[2], q.shape[3]], + device=q.device, dtype=v.dtype)], + dim=1 + ) + + padded_roped_key = torch.cat( + [roped_key, torch.zeros([k.shape[0], padded_length, k.shape[2], k.shape[3]], + device=k.device, dtype=v.dtype)], + dim=1 + ) + + padded_v = torch.cat( + [v, torch.zeros([v.shape[0], padded_length, v.shape[2], v.shape[3]], + device=v.device, dtype=v.dtype)], + dim=1 + ) + + x = flex_attention( + query=padded_roped_query.transpose(2, 1), + key=padded_roped_key.transpose(2, 1), + value=padded_v.transpose(2, 1), + block_mask=block_mask + )[:, :, :-padded_length].transpose(2, 1) + + else: + roped_query = rope_apply(q, grid_sizes, freqs).type_as(v) + roped_key = rope_apply(k, grid_sizes, freqs).type_as(v) + + padded_length = math.ceil(q.shape[1] / 128) * 128 - q.shape[1] + padded_roped_query = torch.cat( + [roped_query, + torch.zeros([q.shape[0], padded_length, q.shape[2], q.shape[3]], + device=q.device, dtype=v.dtype)], + dim=1 + ) + + padded_roped_key = torch.cat( + [roped_key, torch.zeros([k.shape[0], padded_length, k.shape[2], k.shape[3]], + device=k.device, dtype=v.dtype)], + dim=1 + ) + + padded_v = torch.cat( + [v, torch.zeros([v.shape[0], padded_length, v.shape[2], v.shape[3]], + device=v.device, dtype=v.dtype)], + dim=1 + ) + + x = flex_attention( + query=padded_roped_query.transpose(2, 1), + key=padded_roped_key.transpose(2, 1), + value=padded_v.transpose(2, 1), + block_mask=block_mask + )[:, :, :-padded_length].transpose(2, 1) + else: + frame_seqlen = math.prod(grid_sizes[0][1:]).item() + current_start_frame = current_start // frame_seqlen + roped_query = causal_rope_apply( + q, grid_sizes, freqs, start_frame=current_start_frame).type_as(v) + roped_key = causal_rope_apply( + k, grid_sizes, freqs, start_frame=current_start_frame).type_as(v) + + current_end = current_start + roped_query.shape[1] + sink_tokens = self.sink_size * frame_seqlen + if not isinstance(update_kv_cache, bool): + raise TypeError("update_kv_cache must be a bool") + if not update_kv_cache: + # Gradient-enabled Self-Forcing exits must be pure with respect + # to their persistent cache. Activation checkpointing + # recomputes this forward during backward, after the no-grad + # clean pass has committed the same slot. Reading only the + # detached clean prefix and concatenating the differentiable + # current K/V makes both executions identical and prevents a + # checkpoint recomputation from advancing or overwriting cache + # state. + if self.local_attn_size != -1: + raise ValueError( + "read-only KV-cache mode requires local_attn_size=-1" + ) + if int(cache_start) != int(current_start): + raise ValueError( + "read-only KV-cache mode requires cache_start == current_start" + ) + if int(current_start) < 0 or int(current_end) > int(kv_cache["k"].shape[1]): + raise ValueError("read-only KV-cache range exceeds cache capacity") + global_end = int(kv_cache["global_end_index"].item()) + local_end = int(kv_cache["local_end_index"].item()) + if global_end < int(current_start) or local_end < int(current_start): + raise ValueError( + "read-only KV-cache prefix has not been committed through current_start" + ) + prefix_k = kv_cache["k"][:, :current_start].detach() + prefix_v = kv_cache["v"][:, :current_start].detach() + attention_k = torch.cat((prefix_k, roped_key), dim=1) + attention_v = torch.cat((prefix_v, v), dim=1) + x = attention(roped_query, attention_k, attention_v) + x = x.flatten(2) + return self.o(x) + # If we are using local attention and the current KV cache size is larger than the local attention size, we need to truncate the KV cache + kv_cache_size = kv_cache["k"].shape[1] + num_new_tokens = roped_query.shape[1] + if self.local_attn_size != -1 and (current_end > kv_cache["global_end_index"].item()) and ( + num_new_tokens + kv_cache["local_end_index"].item() > kv_cache_size): + # Calculate the number of new tokens added in this step + # Shift existing cache content left to discard oldest tokens + # Clone the source slice to avoid overlapping memory error + num_evicted_tokens = num_new_tokens + kv_cache["local_end_index"].item() - kv_cache_size + num_rolled_tokens = kv_cache["local_end_index"].item() - num_evicted_tokens - sink_tokens + kv_cache["k"][:, sink_tokens:sink_tokens + num_rolled_tokens] = \ + kv_cache["k"][:, sink_tokens + num_evicted_tokens:sink_tokens + num_evicted_tokens + num_rolled_tokens].clone() + kv_cache["v"][:, sink_tokens:sink_tokens + num_rolled_tokens] = \ + kv_cache["v"][:, sink_tokens + num_evicted_tokens:sink_tokens + num_evicted_tokens + num_rolled_tokens].clone() + # Insert the new keys/values at the end + local_end_index = kv_cache["local_end_index"].item() + current_end - \ + kv_cache["global_end_index"].item() - num_evicted_tokens + local_start_index = local_end_index - num_new_tokens + kv_cache["k"][:, local_start_index:local_end_index] = roped_key + kv_cache["v"][:, local_start_index:local_end_index] = v + else: + # Assign new keys/values directly up to current_end + local_end_index = kv_cache["local_end_index"].item() + current_end - kv_cache["global_end_index"].item() + local_start_index = local_end_index - num_new_tokens + kv_cache["k"][:, local_start_index:local_end_index] = roped_key + kv_cache["v"][:, local_start_index:local_end_index] = v + # Clone the K/V slices we hand to FlashAttention. FA's autograd + # Function saves these tensors verbatim (no internal copy when + # the input stride(-1) == 1, which is our case), so without the + # clone the saved K/V are VIEWS into the persistent KV-cache + # storage. Self-Forcing performs multiple forwards per block -- + # 4 denoising passes (one of them gradient-enabled) plus a + # ``no_grad`` clean re-run -- each of which writes back to the + # same cache slot via ``kv_cache["k"][a:b] = roped_key``. Every + # write bumps the storage version counter. When backward + # eventually runs on the gradient-enabled forward's saved K, the + # storage version has advanced past the saved version and + # autograd raises ``RuntimeError: one of the variables needed for + # gradient computation has been modified by an inplace operation: + # [...] is at version N; expected version N-1 instead``. + # + # ``.clone()`` produces an independent storage whose version is + # immune to subsequent cache mutations. Cost: ~24 MB / block / + # call at the Wan-1.3B-ish shape (B=1, slice=7800, H=12, D=128, + # bf16); only the gradient-enabled forward actually pays for it + # since ``.clone()`` is free in ``torch.no_grad()`` (PyTorch + # still allocates but doesn't track for backward). + # + # NOTE: applies to BOTH the all-softmax baseline (which also + # contains this latent bug; just rarely triggered because the + # baseline uses full-visibility caches and tends not to mix with + # alpha-mode gradient flow) and the mixed-attention model. The + # clone keeps the autograd graph intact: ``roped_key → IndexPut → + # cache_slice_view → clone → attention``. Differentiating + # backward through ``clone`` is identity; ``IndexPut.backward`` + # only needs the upstream gradient, not the cache state. + x = attention( + roped_query, + kv_cache["k"][:, max(0, local_end_index - self.max_attention_size):local_end_index].clone(), + kv_cache["v"][:, max(0, local_end_index - self.max_attention_size):local_end_index].clone(), + ) + kv_cache["global_end_index"].fill_(current_end) + kv_cache["local_end_index"].fill_(local_end_index) + + # output + x = x.flatten(2) + x = self.o(x) + return x + + +class CausalWanAttentionBlock(nn.Module): + + def __init__(self, + cross_attn_type, + dim, + ffn_dim, + num_heads, + local_attn_size=-1, + sink_size=0, + qk_norm=True, + cross_attn_norm=False, + eps=1e-6): + super().__init__() + self.dim = dim + self.ffn_dim = ffn_dim + self.num_heads = num_heads + self.local_attn_size = local_attn_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # layers + self.norm1 = WanLayerNorm(dim, eps) + self.self_attn = CausalWanSelfAttention(dim, num_heads, local_attn_size, sink_size, qk_norm, eps) + self.norm3 = WanLayerNorm( + dim, eps, + elementwise_affine=True) if cross_attn_norm else nn.Identity() + self.cross_attn = WAN_CROSSATTENTION_CLASSES[cross_attn_type](dim, + num_heads, + (-1, -1), + qk_norm, + eps) + self.norm2 = WanLayerNorm(dim, eps) + self.ffn = nn.Sequential( + nn.Linear(dim, ffn_dim), nn.GELU(approximate='tanh'), + nn.Linear(ffn_dim, dim)) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5) + + def forward( + self, + x, + e, + seq_lens, + grid_sizes, + freqs, + context, + context_lens, + block_mask, + kv_cache=None, + crossattn_cache=None, + current_start=0, + cache_start=None, + update_kv_cache=True, + ): + r""" + Args: + x(Tensor): Shape [B, L, C] + e(Tensor): Shape [B, F, 6, C] + seq_lens(Tensor): Shape [B], length of each sequence in batch + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + num_frames, frame_seqlen = e.shape[1], x.shape[1] // e.shape[1] + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + e = (self.modulation.unsqueeze(1) + e).chunk(6, dim=2) + # assert e[0].dtype == torch.float32 + + # self-attention + y = self.self_attn( + (self.norm1(x).unflatten(dim=1, sizes=(num_frames, frame_seqlen)) * (1 + e[1]) + e[0]).flatten(1, 2), + seq_lens, + grid_sizes, + freqs, + block_mask, + kv_cache=kv_cache, + current_start=current_start, + cache_start=cache_start, + update_kv_cache=update_kv_cache, + ) + + # with amp.autocast(dtype=torch.float32): + x = x + (y.unflatten(dim=1, sizes=(num_frames, frame_seqlen)) * e[2]).flatten(1, 2) + + # cross-attention & ffn function + def cross_attn_ffn(x, context, context_lens, e, crossattn_cache=None): + x = x + self.cross_attn(self.norm3(x), context, + context_lens, crossattn_cache=crossattn_cache) + y = self.ffn( + (self.norm2(x).unflatten(dim=1, sizes=(num_frames, + frame_seqlen)) * (1 + e[4]) + e[3]).flatten(1, 2) + ) + # with amp.autocast(dtype=torch.float32): + x = x + (y.unflatten(dim=1, sizes=(num_frames, + frame_seqlen)) * e[5]).flatten(1, 2) + return x + + x = cross_attn_ffn(x, context, context_lens, e, crossattn_cache) + return x + + +class CausalHead(nn.Module): + + def __init__(self, dim, out_dim, patch_size, eps=1e-6): + super().__init__() + self.dim = dim + self.out_dim = out_dim + self.patch_size = patch_size + self.eps = eps + + # layers + out_dim = math.prod(patch_size) * out_dim + self.norm = WanLayerNorm(dim, eps) + self.head = nn.Linear(dim, out_dim) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5) + + def forward(self, x, e): + r""" + Args: + x(Tensor): Shape [B, L1, C] + e(Tensor): Shape [B, F, 1, C] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + num_frames, frame_seqlen = e.shape[1], x.shape[1] // e.shape[1] + e = (self.modulation.unsqueeze(1) + e).chunk(2, dim=2) + x = (self.head(self.norm(x).unflatten(dim=1, sizes=(num_frames, frame_seqlen)) * (1 + e[1]) + e[0])) + return x + + +class CausalWanModel(ModelMixin, ConfigMixin): + r""" + Wan diffusion backbone supporting both text-to-video and image-to-video. + """ + + ignore_for_config = [ + 'patch_size', 'cross_attn_norm', 'qk_norm', 'text_dim' + ] + _no_split_modules = ['WanAttentionBlock'] + _supports_gradient_checkpointing = True + + @register_to_config + def __init__(self, + model_type='t2v', + patch_size=(1, 2, 2), + text_len=512, + in_dim=16, + dim=2048, + ffn_dim=8192, + freq_dim=256, + text_dim=4096, + out_dim=16, + num_heads=16, + num_layers=32, + local_attn_size=-1, + sink_size=0, + qk_norm=True, + cross_attn_norm=True, + eps=1e-6): + r""" + Initialize the diffusion model backbone. + + Args: + model_type (`str`, *optional*, defaults to 't2v'): + Model variant - 't2v' (text-to-video) or 'i2v' (image-to-video) + patch_size (`tuple`, *optional*, defaults to (1, 2, 2)): + 3D patch dimensions for video embedding (t_patch, h_patch, w_patch) + text_len (`int`, *optional*, defaults to 512): + Fixed length for text embeddings + in_dim (`int`, *optional*, defaults to 16): + Input video channels (C_in) + dim (`int`, *optional*, defaults to 2048): + Hidden dimension of the transformer + ffn_dim (`int`, *optional*, defaults to 8192): + Intermediate dimension in feed-forward network + freq_dim (`int`, *optional*, defaults to 256): + Dimension for sinusoidal time embeddings + text_dim (`int`, *optional*, defaults to 4096): + Input dimension for text embeddings + out_dim (`int`, *optional*, defaults to 16): + Output video channels (C_out) + num_heads (`int`, *optional*, defaults to 16): + Number of attention heads + num_layers (`int`, *optional*, defaults to 32): + Number of transformer blocks + local_attn_size (`int`, *optional*, defaults to -1): + Window size for temporal local attention (-1 indicates global attention) + sink_size (`int`, *optional*, defaults to 0): + Size of the attention sink, we keep the first `sink_size` frames unchanged when rolling the KV cache + qk_norm (`bool`, *optional*, defaults to True): + Enable query/key normalization + cross_attn_norm (`bool`, *optional*, defaults to False): + Enable cross-attention normalization + eps (`float`, *optional*, defaults to 1e-6): + Epsilon value for normalization layers + """ + + super().__init__() + + assert model_type in ['t2v', 'i2v'] + self.model_type = model_type + + self.patch_size = patch_size + self.text_len = text_len + self.in_dim = in_dim + self.dim = dim + self.ffn_dim = ffn_dim + self.freq_dim = freq_dim + self.text_dim = text_dim + self.out_dim = out_dim + self.num_heads = num_heads + self.num_layers = num_layers + self.local_attn_size = local_attn_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # embeddings + self.patch_embedding = nn.Conv3d( + in_dim, dim, kernel_size=patch_size, stride=patch_size) + self.text_embedding = nn.Sequential( + nn.Linear(text_dim, dim), nn.GELU(approximate='tanh'), + nn.Linear(dim, dim)) + + self.time_embedding = nn.Sequential( + nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim)) + self.time_projection = nn.Sequential( + nn.SiLU(), nn.Linear(dim, dim * 6)) + + # blocks + cross_attn_type = 't2v_cross_attn' if model_type == 't2v' else 'i2v_cross_attn' + self.blocks = nn.ModuleList([ + CausalWanAttentionBlock(cross_attn_type, dim, ffn_dim, num_heads, + local_attn_size, sink_size, qk_norm, cross_attn_norm, eps) + for _ in range(num_layers) + ]) + + # head + self.head = CausalHead(dim, out_dim, patch_size, eps) + + # buffers (don't use register_buffer otherwise dtype will be changed in to()) + assert (dim % num_heads) == 0 and (dim // num_heads) % 2 == 0 + d = dim // num_heads + self.freqs = torch.cat([ + rope_params(1024, d - 4 * (d // 6)), + rope_params(1024, 2 * (d // 6)), + rope_params(1024, 2 * (d // 6)) + ], + dim=1) + + if model_type == 'i2v': + self.img_emb = MLPProj(1280, dim) + + # initialize weights + self.init_weights() + + self.gradient_checkpointing = False + + self.block_mask = None + + self.num_frame_per_block = 1 + self.independent_first_frame = False + + def _set_gradient_checkpointing( + self, + module=None, + value=False, + enable=None, + gradient_checkpointing_func=None, + ): + self.gradient_checkpointing = value if enable is None else enable + + @staticmethod + def _prepare_blockwise_causal_attn_mask( + device: torch.device | str, num_frames: int = 21, + frame_seqlen: int = 1560, num_frame_per_block=1, local_attn_size=-1 + ) -> BlockMask: + """ + we will divide the token sequence into the following format + [1 latent frame] [1 latent frame] ... [1 latent frame] + We use flexattention to construct the attention mask + """ + total_length = num_frames * frame_seqlen + + # we do right padding to get to a multiple of 128 + padded_length = math.ceil(total_length / 128) * 128 - total_length + + ends = torch.zeros(total_length + padded_length, + device=device, dtype=torch.long) + + # Block-wise causal mask will attend to all elements that are before the end of the current chunk + frame_indices = torch.arange( + start=0, + end=total_length, + step=frame_seqlen * num_frame_per_block, + device=device + ) + + for tmp in frame_indices: + ends[tmp:tmp + frame_seqlen * num_frame_per_block] = tmp + \ + frame_seqlen * num_frame_per_block + + def attention_mask(b, h, q_idx, kv_idx): + if local_attn_size == -1: + return (kv_idx < ends[q_idx]) | (q_idx == kv_idx) + else: + return ((kv_idx < ends[q_idx]) & (kv_idx >= (ends[q_idx] - local_attn_size * frame_seqlen))) | (q_idx == kv_idx) + # return ((kv_idx < total_length) & (q_idx < total_length)) | (q_idx == kv_idx) # bidirectional mask + + block_mask = create_block_mask(attention_mask, B=None, H=None, Q_LEN=total_length + padded_length, + KV_LEN=total_length + padded_length, _compile=False, device=device) + + import torch.distributed as dist + if not dist.is_initialized() or dist.get_rank() == 0: + print( + f" cache a block wise causal mask with block size of {num_frame_per_block} frames") + print(block_mask) + + # import imageio + # import numpy as np + # from torch.nn.attention.flex_attention import create_mask + + # mask = create_mask(attention_mask, B=None, H=None, Q_LEN=total_length + + # padded_length, KV_LEN=total_length + padded_length, device=device) + # import cv2 + # mask = cv2.resize(mask[0, 0].cpu().float().numpy(), (1024, 1024)) + # imageio.imwrite("mask_%d.jpg" % (0), np.uint8(255. * mask)) + + return block_mask + + @staticmethod + def _prepare_teacher_forcing_mask( + device: torch.device | str, num_frames: int = 21, + frame_seqlen: int = 1560, num_frame_per_block=1 + ) -> BlockMask: + """ + we will divide the token sequence into the following format + [1 latent frame] [1 latent frame] ... [1 latent frame] + We use flexattention to construct the attention mask + """ + # debug + DEBUG = False + if DEBUG: + num_frames = 9 + frame_seqlen = 256 + + total_length = num_frames * frame_seqlen * 2 + + # we do right padding to get to a multiple of 128 + padded_length = math.ceil(total_length / 128) * 128 - total_length + + clean_ends = num_frames * frame_seqlen + # for clean context frames, we can construct their flex attention mask based on a [start, end] interval + context_ends = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + # for noisy frames, we need two intervals to construct the flex attention mask [context_start, context_end] [noisy_start, noisy_end] + noise_context_starts = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + noise_context_ends = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + noise_noise_starts = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + noise_noise_ends = torch.zeros(total_length + padded_length, device=device, dtype=torch.long) + + # Block-wise causal mask will attend to all elements that are before the end of the current chunk + attention_block_size = frame_seqlen * num_frame_per_block + frame_indices = torch.arange( + start=0, + end=num_frames * frame_seqlen, + step=attention_block_size, + device=device, dtype=torch.long + ) + + # attention for clean context frames + for start in frame_indices: + context_ends[start:start + attention_block_size] = start + attention_block_size + + noisy_image_start_list = torch.arange( + num_frames * frame_seqlen, total_length, + step=attention_block_size, + device=device, dtype=torch.long + ) + noisy_image_end_list = noisy_image_start_list + attention_block_size + + # attention for noisy frames + for block_index, (start, end) in enumerate(zip(noisy_image_start_list, noisy_image_end_list)): + # attend to noisy tokens within the same block + noise_noise_starts[start:end] = start + noise_noise_ends[start:end] = end + # attend to context tokens in previous blocks + # noise_context_starts[start:end] = 0 + noise_context_ends[start:end] = block_index * attention_block_size + + def attention_mask(b, h, q_idx, kv_idx): + # first design the mask for clean frames + clean_mask = (q_idx < clean_ends) & (kv_idx < context_ends[q_idx]) + # then design the mask for noisy frames + # noisy frames will attend to all clean preceeding clean frames + itself + C1 = (kv_idx < noise_noise_ends[q_idx]) & (kv_idx >= noise_noise_starts[q_idx]) + C2 = (kv_idx < noise_context_ends[q_idx]) & (kv_idx >= noise_context_starts[q_idx]) + noise_mask = (q_idx >= clean_ends) & (C1 | C2) + + eye_mask = q_idx == kv_idx + return eye_mask | clean_mask | noise_mask + + block_mask = create_block_mask(attention_mask, B=None, H=None, Q_LEN=total_length + padded_length, + KV_LEN=total_length + padded_length, _compile=False, device=device) + + if DEBUG: + print(block_mask) + import imageio + import numpy as np + from torch.nn.attention.flex_attention import create_mask + + mask = create_mask(attention_mask, B=None, H=None, Q_LEN=total_length + + padded_length, KV_LEN=total_length + padded_length, device=device) + import cv2 + mask = cv2.resize(mask[0, 0].cpu().float().numpy(), (1024, 1024)) + imageio.imwrite("mask_%d.jpg" % (0), np.uint8(255. * mask)) + + return block_mask + + @staticmethod + def _prepare_blockwise_causal_attn_mask_i2v( + device: torch.device | str, num_frames: int = 21, + frame_seqlen: int = 1560, num_frame_per_block=4, local_attn_size=-1 + ) -> BlockMask: + """ + we will divide the token sequence into the following format + [1 latent frame] [N latent frame] ... [N latent frame] + The first frame is separated out to support I2V generation + We use flexattention to construct the attention mask + """ + total_length = num_frames * frame_seqlen + + # we do right padding to get to a multiple of 128 + padded_length = math.ceil(total_length / 128) * 128 - total_length + + ends = torch.zeros(total_length + padded_length, + device=device, dtype=torch.long) + + # special handling for the first frame + ends[:frame_seqlen] = frame_seqlen + + # Block-wise causal mask will attend to all elements that are before the end of the current chunk + frame_indices = torch.arange( + start=frame_seqlen, + end=total_length, + step=frame_seqlen * num_frame_per_block, + device=device + ) + + for idx, tmp in enumerate(frame_indices): + ends[tmp:tmp + frame_seqlen * num_frame_per_block] = tmp + \ + frame_seqlen * num_frame_per_block + + def attention_mask(b, h, q_idx, kv_idx): + if local_attn_size == -1: + return (kv_idx < ends[q_idx]) | (q_idx == kv_idx) + else: + return ((kv_idx < ends[q_idx]) & (kv_idx >= (ends[q_idx] - local_attn_size * frame_seqlen))) | \ + (q_idx == kv_idx) + + block_mask = create_block_mask(attention_mask, B=None, H=None, Q_LEN=total_length + padded_length, + KV_LEN=total_length + padded_length, _compile=False, device=device) + + if not dist.is_initialized() or dist.get_rank() == 0: + print( + f" cache a block wise causal mask with block size of {num_frame_per_block} frames") + print(block_mask) + + # import imageio + # import numpy as np + # from torch.nn.attention.flex_attention import create_mask + + # mask = create_mask(attention_mask, B=None, H=None, Q_LEN=total_length + + # padded_length, KV_LEN=total_length + padded_length, device=device) + # import cv2 + # mask = cv2.resize(mask[0, 0].cpu().float().numpy(), (1024, 1024)) + # imageio.imwrite("mask_%d.jpg" % (0), np.uint8(255. * mask)) + + return block_mask + + def _forward_inference( + self, + x, + t, + context, + seq_len, + clip_fea=None, + y=None, + kv_cache: dict = None, + crossattn_cache: dict = None, + current_start: int = 0, + cache_start: int = 0, + update_kv_cache: bool = True, + ): + r""" + Run the diffusion model with kv caching. + See Algorithm 2 of CausVid paper https://arxiv.org/abs/2412.07772 for details. + This function will be run for num_frame times. + Process the latent frames one by one (1560 tokens each) + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of denoised video tensors with original input shapes [C_out, F, H / 8, W / 8] + """ + + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + x = torch.cat(x) + """ + torch.cat([ + torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], + dim=1) for u in x + ]) + """ + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t.flatten()).type_as(x)) + e0 = self.time_projection(e).unflatten( + 1, (6, self.dim)).unflatten(dim=0, sizes=t.shape) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens, + block_mask=self.block_mask, + update_kv_cache=update_kv_cache, + ) + + def create_custom_forward(module, block_index): + def custom_forward(*inputs, **kwargs): + full_cpu_offload = bool(getattr( + self, + "gradient_checkpointing_activation_cpu_offload", + False, + )) + cpu_offload_stride = int(getattr( + self, + "gradient_checkpointing_activation_cpu_offload_block_stride", + 0, + )) + selective_cpu_offload = ( + cpu_offload_stride > 0 + and block_index % cpu_offload_stride == 0 + ) + if full_cpu_offload or selective_cpu_offload: + with torch.autograd.graph.save_on_cpu( + pin_memory=False, device_type=inputs[0].device.type): + return module(*inputs, **kwargs) + return module(*inputs, **kwargs) + return custom_forward + + for block_index, block in enumerate(self.blocks): + if torch.is_grad_enabled() and self.gradient_checkpointing: + kwargs.update( + { + "kv_cache": kv_cache[block_index], + "current_start": current_start, + "cache_start": cache_start + } + ) + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block, block_index), + x, **kwargs, + use_reentrant=False, + ) + else: + kwargs.update( + { + "kv_cache": kv_cache[block_index], + "crossattn_cache": crossattn_cache[block_index], + "current_start": current_start, + "cache_start": cache_start + } + ) + x = block(x, **kwargs) + + # head + x = self.head(x, e.unflatten(dim=0, sizes=t.shape).unsqueeze(2)) + # unpatchify + x = self.unpatchify(x, grid_sizes) + return torch.stack(x) + + def _forward_train( + self, + x, + t, + context, + seq_len, + clean_x=None, + aug_t=None, + clip_fea=None, + y=None, + ): + r""" + Forward pass through the diffusion model + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of denoised video tensors with original input shapes [C_out, F, H / 8, W / 8] + """ + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + # Construct blockwise causal attn mask + if self.block_mask is None: + if clean_x is not None: + if self.independent_first_frame: + raise NotImplementedError() + else: + self.block_mask = self._prepare_teacher_forcing_mask( + device, num_frames=x.shape[2], + frame_seqlen=x.shape[-2] * x.shape[-1] // (self.patch_size[1] * self.patch_size[2]), + num_frame_per_block=self.num_frame_per_block + ) + else: + if self.independent_first_frame: + self.block_mask = self._prepare_blockwise_causal_attn_mask_i2v( + device, num_frames=x.shape[2], + frame_seqlen=x.shape[-2] * x.shape[-1] // (self.patch_size[1] * self.patch_size[2]), + num_frame_per_block=self.num_frame_per_block, + local_attn_size=self.local_attn_size + ) + else: + self.block_mask = self._prepare_blockwise_causal_attn_mask( + device, num_frames=x.shape[2], + frame_seqlen=x.shape[-2] * x.shape[-1] // (self.patch_size[1] * self.patch_size[2]), + num_frame_per_block=self.num_frame_per_block, + local_attn_size=self.local_attn_size + ) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_lens[0] - u.size(1), u.size(2))], + dim=1) for u in x + ]) + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t.flatten()).type_as(x)) + e0 = self.time_projection(e).unflatten( + 1, (6, self.dim)).unflatten(dim=0, sizes=t.shape) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + if clean_x is not None: + clean_x = [self.patch_embedding(u.unsqueeze(0)) for u in clean_x] + clean_x = [u.flatten(2).transpose(1, 2) for u in clean_x] + + seq_lens_clean = torch.tensor([u.size(1) for u in clean_x], dtype=torch.long) + assert seq_lens_clean.max() <= seq_len + clean_x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_lens_clean[0] - u.size(1), u.size(2))], dim=1) for u in clean_x + ]) + + x = torch.cat([clean_x, x], dim=1) + if aug_t is None: + aug_t = torch.zeros_like(t) + e_clean = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, aug_t.flatten()).type_as(x)) + e0_clean = self.time_projection(e_clean).unflatten( + 1, (6, self.dim)).unflatten(dim=0, sizes=t.shape) + e0 = torch.cat([e0_clean, e0], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens, + block_mask=self.block_mask) + + def create_custom_forward(module): + def custom_forward(*inputs, **kwargs): + if bool(getattr( + self, + "gradient_checkpointing_activation_cpu_offload", + False, + )): + with torch.autograd.graph.save_on_cpu( + pin_memory=False, device_type=inputs[0].device.type): + return module(*inputs, **kwargs) + return module(*inputs, **kwargs) + return custom_forward + + for block in self.blocks: + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + + if clean_x is not None: + x = x[:, x.shape[1] // 2:] + + # head + x = self.head(x, e.unflatten(dim=0, sizes=t.shape).unsqueeze(2)) + + # unpatchify + x = self.unpatchify(x, grid_sizes) + return torch.stack(x) + + def forward( + self, + *args, + **kwargs + ): + if kwargs.get('kv_cache', None) is not None: + return self._forward_inference(*args, **kwargs) + else: + return self._forward_train(*args, **kwargs) + + def unpatchify(self, x, grid_sizes): + r""" + Reconstruct video tensors from patch embeddings. + + Args: + x (List[Tensor]): + List of patchified features, each with shape [L, C_out * prod(patch_size)] + grid_sizes (Tensor): + Original spatial-temporal grid dimensions before patching, + shape [B, 3] (3 dimensions correspond to F_patches, H_patches, W_patches) + + Returns: + List[Tensor]: + Reconstructed video tensors with shape [C_out, F, H / 8, W / 8] + """ + + c = self.out_dim + out = [] + for u, v in zip(x, grid_sizes.tolist()): + u = u[:math.prod(v)].view(*v, *self.patch_size, c) + u = torch.einsum('fhwpqrc->cfphqwr', u) + u = u.reshape(c, *[i * j for i, j in zip(v, self.patch_size)]) + out.append(u) + return out + + def init_weights(self): + r""" + Initialize model parameters using Xavier initialization. + """ + + # basic init + for m in self.modules(): + if isinstance(m, nn.Linear): + nn.init.xavier_uniform_(m.weight) + if m.bias is not None: + nn.init.zeros_(m.bias) + + # init embeddings + nn.init.xavier_uniform_(self.patch_embedding.weight.flatten(1)) + for m in self.text_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + for m in self.time_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + + # init output layer + nn.init.zeros_(self.head.head.weight) diff --git a/14B/code/wan/modules/model.py b/14B/code/wan/modules/model.py new file mode 100644 index 0000000000000000000000000000000000000000..4329f79988cf1a5ac69f5a3eb7e32e246afa0d22 --- /dev/null +++ b/14B/code/wan/modules/model.py @@ -0,0 +1,1103 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import math + +import torch +import torch.nn as nn +import torch.utils.checkpoint +from diffusers.configuration_utils import ConfigMixin, register_to_config +from diffusers.models.modeling_utils import ModelMixin +from einops import repeat + +from .attention import attention, flash_attention + +__all__ = ['WanModel'] + + +def sinusoidal_embedding_1d(dim, position): + # preprocess + assert dim % 2 == 0 + half = dim // 2 + position = position.type(torch.float64) + + # calculation + sinusoid = torch.outer( + position, torch.pow(10000, -torch.arange(half).to(position).div(half))) + x = torch.cat([torch.cos(sinusoid), torch.sin(sinusoid)], dim=1) + return x + + +# @amp.autocast(enabled=False) +def rope_params(max_seq_len, dim, theta=10000): + assert dim % 2 == 0 + freqs = torch.outer( + torch.arange(max_seq_len), + 1.0 / torch.pow(theta, + torch.arange(0, dim, 2).to(torch.float64).div(dim))) + freqs = torch.polar(torch.ones_like(freqs), freqs) + return freqs + + +# @amp.autocast(enabled=False) +def rope_apply(x, grid_sizes, freqs): + n, c = x.size(2), x.size(3) // 2 + + # split freqs + freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) + + # loop over samples + output = [] + for i, (f, h, w) in enumerate(grid_sizes.tolist()): + seq_len = f * h * w + + # Match the original Wan implementation exactly. RoPE is evaluated in + # fp64/complex128 and cast back only after the rotation; changing this + # globally also changes the causal student's pretrained forward. + x_i = torch.view_as_complex(x[i, :seq_len].to(torch.float64).reshape( + seq_len, n, -1, 2)) + freqs_i = torch.cat([ + freqs[0][:f].view(f, 1, 1, -1).expand(f, h, w, -1), + freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1), + freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1) + ], + dim=-1).reshape(seq_len, 1, -1) + + # apply rotary embedding + x_i = torch.view_as_real(x_i * freqs_i).flatten(2) + x_i = torch.cat([x_i, x[i, seq_len:]]) + + # append to collection + output.append(x_i) + return torch.stack(output).type_as(x) + + +class WanRMSNorm(nn.Module): + + def __init__(self, dim, eps=1e-5): + super().__init__() + self.dim = dim + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def forward(self, x): + r""" + Args: + x(Tensor): Shape [B, L, C] + """ + return self._norm(x.float()).type_as(x) * self.weight + + def _norm(self, x): + return x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps) + + def reset_parameters(self): + # Required by FSDP's meta-device materialisation path; matches __init__. + nn.init.ones_(self.weight) + + +class WanLayerNorm(nn.LayerNorm): + + def __init__(self, dim, eps=1e-6, elementwise_affine=False): + super().__init__(dim, elementwise_affine=elementwise_affine, eps=eps) + + def forward(self, x): + r""" + Args: + x(Tensor): Shape [B, L, C] + """ + return super().forward(x).type_as(x) + + +class WanSelfAttention(nn.Module): + + def __init__(self, + dim, + num_heads, + window_size=(-1, -1), + qk_norm=True, + eps=1e-6): + assert dim % num_heads == 0 + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.head_dim = dim // num_heads + self.window_size = window_size + self.qk_norm = qk_norm + self.eps = eps + + # layers + self.q = nn.Linear(dim, dim) + self.k = nn.Linear(dim, dim) + self.v = nn.Linear(dim, dim) + self.o = nn.Linear(dim, dim) + self.norm_q = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + self.norm_k = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity() + + def project_kv(self, x, grid_sizes, freqs, token_indices=None): + """Project the exact K/V used by attention, optionally retaining tokens. + + This is also used by the last encoder block's KV-only fast path, where Q, + attention output, cross-attention and FFN are unnecessary. + """ + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + k = self.norm_k(self.k(x)).view(b, s, n, d) + v = self.v(x).view(b, s, n, d) + k = rope_apply(k, grid_sizes, freqs) + if token_indices is not None: + indices = token_indices.to(k.device) + k = k.index_select(1, indices) + v = v.index_select(1, indices) + return k, v + + def forward( + self, + x, + seq_lens, + grid_sizes, + freqs, + return_kv=False, + kv_token_indices=None, + ): + r""" + Args: + x(Tensor): Shape [B, L, num_heads, C / num_heads] + seq_lens(Tensor): Shape [B] + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim + + # query, key, value function + def qkv_fn(x): + q = self.norm_q(self.q(x)).view(b, s, n, d) + k = self.norm_k(self.k(x)).view(b, s, n, d) + v = self.v(x).view(b, s, n, d) + return q, k, v + + q, k, v = qkv_fn(x) + # These are the tensors that are actually consumed by attention. Keeping + # K after RoPE (rather than the pre-RoPE projection) makes the extracted + # feature identical to a real self-attention KV cache. + k_attn = rope_apply(k, grid_sizes, freqs) + + x = flash_attention( + q=rope_apply(q, grid_sizes, freqs), + k=k_attn, + v=v, + k_lens=seq_lens, + window_size=self.window_size) + + # output + x = x.flatten(2) + x = self.o(x) + if return_kv: + # Attention needs full K/V, but downstream MMD does not. Slice here, + # before returning to the block's cross-attention/FFN, so full K/V do + # not stay alive for the remainder of a checkpointed block. + if kv_token_indices is not None: + indices = kv_token_indices.to(k_attn.device) + k_attn = k_attn.index_select(1, indices) + v = v.index_select(1, indices) + return x, k_attn, v + return x + + +class WanT2VCrossAttention(WanSelfAttention): + + def forward(self, x, context, context_lens, crossattn_cache=None): + r""" + Args: + x(Tensor): Shape [B, L1, C] + context(Tensor): Shape [B, L2, C] + context_lens(Tensor): Shape [B] + crossattn_cache (List[dict], *optional*): Contains the cached key and value tensors for context embedding. + """ + b, n, d = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + q = self.norm_q(self.q(x)).view(b, -1, n, d) + + if crossattn_cache is not None: + if not crossattn_cache["is_init"]: + crossattn_cache["is_init"] = True + k = self.norm_k(self.k(context)).view(b, -1, n, d) + v = self.v(context).view(b, -1, n, d) + crossattn_cache["k"] = k + crossattn_cache["v"] = v + else: + k = crossattn_cache["k"] + v = crossattn_cache["v"] + else: + k = self.norm_k(self.k(context)).view(b, -1, n, d) + v = self.v(context).view(b, -1, n, d) + + # compute attention + x = attention(q, k, v, k_lens=context_lens) + + # output + x = x.flatten(2) + x = self.o(x) + return x + + +class WanGanCrossAttention(WanSelfAttention): + + def forward(self, x, context, crossattn_cache=None): + r""" + Args: + x(Tensor): Shape [B, L1, C] + context(Tensor): Shape [B, L2, C] + context_lens(Tensor): Shape [B] + crossattn_cache (List[dict], *optional*): Contains the cached key and value tensors for context embedding. + """ + b, n, d = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + qq = self.norm_q(self.q(context)).view(b, 1, -1, d) + + kk = self.norm_k(self.k(x)).view(b, -1, n, d) + vv = self.v(x).view(b, -1, n, d) + + # compute attention + x = flash_attention(qq, kk, vv) + + # output + x = x.flatten(2) + x = self.o(x) + return x + + +class WanI2VCrossAttention(WanSelfAttention): + + def __init__(self, + dim, + num_heads, + window_size=(-1, -1), + qk_norm=True, + eps=1e-6): + super().__init__(dim, num_heads, window_size, qk_norm, eps) + + self.k_img = nn.Linear(dim, dim) + self.v_img = nn.Linear(dim, dim) + # self.alpha = nn.Parameter(torch.zeros((1, ))) + self.norm_k_img = WanRMSNorm( + dim, eps=eps) if qk_norm else nn.Identity() + + def forward(self, x, context, context_lens): + r""" + Args: + x(Tensor): Shape [B, L1, C] + context(Tensor): Shape [B, L2, C] + context_lens(Tensor): Shape [B] + """ + context_img = context[:, :257] + context = context[:, 257:] + b, n, d = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + q = self.norm_q(self.q(x)).view(b, -1, n, d) + k = self.norm_k(self.k(context)).view(b, -1, n, d) + v = self.v(context).view(b, -1, n, d) + k_img = self.norm_k_img(self.k_img(context_img)).view(b, -1, n, d) + v_img = self.v_img(context_img).view(b, -1, n, d) + img_x = flash_attention(q, k_img, v_img, k_lens=None) + # compute attention + x = flash_attention(q, k, v, k_lens=context_lens) + + # output + x = x.flatten(2) + img_x = img_x.flatten(2) + x = x + img_x + x = self.o(x) + return x + + +WAN_CROSSATTENTION_CLASSES = { + 't2v_cross_attn': WanT2VCrossAttention, + 'i2v_cross_attn': WanI2VCrossAttention, +} + + +class WanAttentionBlock(nn.Module): + + def __init__(self, + cross_attn_type, + dim, + ffn_dim, + num_heads, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=False, + eps=1e-6): + super().__init__() + self.dim = dim + self.ffn_dim = ffn_dim + self.num_heads = num_heads + self.window_size = window_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # layers + self.norm1 = WanLayerNorm(dim, eps) + self.self_attn = WanSelfAttention(dim, num_heads, window_size, qk_norm, + eps) + self.norm3 = WanLayerNorm( + dim, eps, + elementwise_affine=True) if cross_attn_norm else nn.Identity() + self.cross_attn = WAN_CROSSATTENTION_CLASSES[cross_attn_type](dim, + num_heads, + (-1, -1), + qk_norm, + eps) + self.norm2 = WanLayerNorm(dim, eps) + self.ffn = nn.Sequential( + nn.Linear(dim, ffn_dim), nn.GELU(approximate='tanh'), + nn.Linear(ffn_dim, dim)) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5) + + def forward( + self, + x, + e, + seq_lens, + grid_sizes, + freqs, + context, + context_lens, + return_kv=False, + kv_token_indices=None, + kv_only=False, + ): + r""" + Args: + x(Tensor): Shape [B, L, C] + e(Tensor): Shape [B, 6, C] + seq_lens(Tensor): Shape [B], length of each sequence in batch + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + e = (self.modulation + e).chunk(6, dim=1) + # assert e[0].dtype == torch.float32 + + modulated_x = self.norm1(x) * (1 + e[1]) + e[0] + if kv_only: + if not return_kv: + raise ValueError("kv_only requires return_kv=True") + return self.self_attn.project_kv( + modulated_x, grid_sizes, freqs, token_indices=kv_token_indices) + + # self-attention + attn_out = self.self_attn( + modulated_x, seq_lens, grid_sizes, freqs, + return_kv=return_kv, kv_token_indices=kv_token_indices) + if return_kv: + y, k, v = attn_out + else: + y = attn_out + # with amp.autocast(dtype=torch.float32): + x = x + y * e[2] + + # cross-attention & ffn function + def cross_attn_ffn(x, context, context_lens, e): + x = x + self.cross_attn(self.norm3(x), context, context_lens) + y = self.ffn(self.norm2(x) * (1 + e[4]) + e[3]) + # with amp.autocast(dtype=torch.float32): + x = x + y * e[5] + return x + + x = cross_attn_ffn(x, context, context_lens, e) + if return_kv: + return x, k, v + return x + + +class GanAttentionBlock(nn.Module): + + def __init__(self, + dim=1536, + ffn_dim=8192, + num_heads=12, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=True, + eps=1e-6): + super().__init__() + self.dim = dim + self.ffn_dim = ffn_dim + self.num_heads = num_heads + self.window_size = window_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + + # layers + # self.norm1 = WanLayerNorm(dim, eps) + # self.self_attn = WanSelfAttention(dim, num_heads, window_size, qk_norm, + # eps) + self.norm3 = WanLayerNorm( + dim, eps, + elementwise_affine=True) if cross_attn_norm else nn.Identity() + + self.norm2 = WanLayerNorm(dim, eps) + self.ffn = nn.Sequential( + nn.Linear(dim, ffn_dim), nn.GELU(approximate='tanh'), + nn.Linear(ffn_dim, dim)) + + self.cross_attn = WanGanCrossAttention(dim, num_heads, + (-1, -1), + qk_norm, + eps) + + # modulation + # self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5) + + def forward( + self, + x, + context, + # seq_lens, + # grid_sizes, + # freqs, + # context, + # context_lens, + ): + r""" + Args: + x(Tensor): Shape [B, L, C] + e(Tensor): Shape [B, 6, C] + seq_lens(Tensor): Shape [B], length of each sequence in batch + grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W) + freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + # e = (self.modulation + e).chunk(6, dim=1) + # assert e[0].dtype == torch.float32 + + # # self-attention + # y = self.self_attn( + # self.norm1(x) * (1 + e[1]) + e[0], seq_lens, grid_sizes, + # freqs) + # # with amp.autocast(dtype=torch.float32): + # x = x + y * e[2] + + # cross-attention & ffn function + def cross_attn_ffn(x, context): + token = context + self.cross_attn(self.norm3(x), context) + y = self.ffn(self.norm2(token)) + token # * (1 + e[4]) + e[3]) + # with amp.autocast(dtype=torch.float32): + # x = x + y * e[5] + return y + + x = cross_attn_ffn(x, context) + return x + + +class Head(nn.Module): + + def __init__(self, dim, out_dim, patch_size, eps=1e-6): + super().__init__() + self.dim = dim + self.out_dim = out_dim + self.patch_size = patch_size + self.eps = eps + + # layers + out_dim = math.prod(patch_size) * out_dim + self.norm = WanLayerNorm(dim, eps) + self.head = nn.Linear(dim, out_dim) + + # modulation + self.modulation = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5) + + def forward(self, x, e): + r""" + Args: + x(Tensor): Shape [B, L1, C] + e(Tensor): Shape [B, C] + """ + # assert e.dtype == torch.float32 + # with amp.autocast(dtype=torch.float32): + e = (self.modulation + e.unsqueeze(1)).chunk(2, dim=1) + x = (self.head(self.norm(x) * (1 + e[1]) + e[0])) + return x + + +class MLPProj(torch.nn.Module): + + def __init__(self, in_dim, out_dim): + super().__init__() + + self.proj = torch.nn.Sequential( + torch.nn.LayerNorm(in_dim), torch.nn.Linear(in_dim, in_dim), + torch.nn.GELU(), torch.nn.Linear(in_dim, out_dim), + torch.nn.LayerNorm(out_dim)) + + def forward(self, image_embeds): + clip_extra_context_tokens = self.proj(image_embeds) + return clip_extra_context_tokens + + +class RegisterTokens(nn.Module): + def __init__(self, num_registers: int, dim: int): + super().__init__() + self.register_tokens = nn.Parameter(torch.randn(num_registers, dim) * 0.02) + self.rms_norm = WanRMSNorm(dim, eps=1e-6) + + def forward(self): + return self.rms_norm(self.register_tokens) + + def reset_parameters(self): + nn.init.normal_(self.register_tokens, std=0.02) + + +class WanModel(ModelMixin, ConfigMixin): + r""" + Wan diffusion backbone supporting both text-to-video and image-to-video. + """ + + ignore_for_config = [ + 'patch_size', 'cross_attn_norm', 'qk_norm', 'text_dim', 'window_size' + ] + _no_split_modules = ['WanAttentionBlock'] + _supports_gradient_checkpointing = True + + @register_to_config + def __init__(self, + model_type='t2v', + patch_size=(1, 2, 2), + text_len=512, + in_dim=16, + dim=2048, + ffn_dim=8192, + freq_dim=256, + text_dim=4096, + out_dim=16, + num_heads=16, + num_layers=32, + window_size=(-1, -1), + qk_norm=True, + cross_attn_norm=True, + eps=1e-6): + r""" + Initialize the diffusion model backbone. + + Args: + model_type (`str`, *optional*, defaults to 't2v'): + Model variant - 't2v' (text-to-video) or 'i2v' (image-to-video) + patch_size (`tuple`, *optional*, defaults to (1, 2, 2)): + 3D patch dimensions for video embedding (t_patch, h_patch, w_patch) + text_len (`int`, *optional*, defaults to 512): + Fixed length for text embeddings + in_dim (`int`, *optional*, defaults to 16): + Input video channels (C_in) + dim (`int`, *optional*, defaults to 2048): + Hidden dimension of the transformer + ffn_dim (`int`, *optional*, defaults to 8192): + Intermediate dimension in feed-forward network + freq_dim (`int`, *optional*, defaults to 256): + Dimension for sinusoidal time embeddings + text_dim (`int`, *optional*, defaults to 4096): + Input dimension for text embeddings + out_dim (`int`, *optional*, defaults to 16): + Output video channels (C_out) + num_heads (`int`, *optional*, defaults to 16): + Number of attention heads + num_layers (`int`, *optional*, defaults to 32): + Number of transformer blocks + window_size (`tuple`, *optional*, defaults to (-1, -1)): + Window size for local attention (-1 indicates global attention) + qk_norm (`bool`, *optional*, defaults to True): + Enable query/key normalization + cross_attn_norm (`bool`, *optional*, defaults to False): + Enable cross-attention normalization + eps (`float`, *optional*, defaults to 1e-6): + Epsilon value for normalization layers + """ + + super().__init__() + + assert model_type in ['t2v', 'i2v'] + self.model_type = model_type + + self.patch_size = patch_size + self.text_len = text_len + self.in_dim = in_dim + self.dim = dim + self.ffn_dim = ffn_dim + self.freq_dim = freq_dim + self.text_dim = text_dim + self.out_dim = out_dim + self.num_heads = num_heads + self.num_layers = num_layers + self.window_size = window_size + self.qk_norm = qk_norm + self.cross_attn_norm = cross_attn_norm + self.eps = eps + self.local_attn_size = 21 + + # embeddings + self.patch_embedding = nn.Conv3d( + in_dim, dim, kernel_size=patch_size, stride=patch_size) + self.text_embedding = nn.Sequential( + nn.Linear(text_dim, dim), nn.GELU(approximate='tanh'), + nn.Linear(dim, dim)) + + self.time_embedding = nn.Sequential( + nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim)) + self.time_projection = nn.Sequential( + nn.SiLU(), nn.Linear(dim, dim * 6)) + + # blocks + cross_attn_type = 't2v_cross_attn' if model_type == 't2v' else 'i2v_cross_attn' + self.blocks = nn.ModuleList([ + WanAttentionBlock(cross_attn_type, dim, ffn_dim, num_heads, + window_size, qk_norm, cross_attn_norm, eps) + for _ in range(num_layers) + ]) + + # head + self.head = Head(dim, out_dim, patch_size, eps) + + # buffers (don't use register_buffer otherwise dtype will be changed in to()) + assert (dim % num_heads) == 0 and (dim // num_heads) % 2 == 0 + d = dim // num_heads + self.freqs = torch.cat([ + rope_params(1024, d - 4 * (d // 6)), + rope_params(1024, 2 * (d // 6)), + rope_params(1024, 2 * (d // 6)) + ], + dim=1) + + if model_type == 'i2v': + self.img_emb = MLPProj(1280, dim) + + # initialize weights + self.init_weights() + + self.gradient_checkpointing = False + + def _set_gradient_checkpointing( + self, + module=None, + value=False, + enable=None, + gradient_checkpointing_func=None, + ): + self.gradient_checkpointing = value if enable is None else enable + + def forward( + self, + *args, + **kwargs + ): + # if kwargs.get('classify_mode', False) is True: + # kwargs.pop('classify_mode') + # return self._forward_classify(*args, **kwargs) + # else: + return self._forward(*args, **kwargs) + + def _forward( + self, + x, + t, + context, + seq_len, + classify_mode=False, + concat_time_embeddings=False, + register_tokens=None, + cls_pred_branch=None, + gan_ca_blocks=None, + clip_fea=None, + y=None, + kv_feature_layers=None, + kv_token_indices=None, + latent_feature_layer=None, + latent_token_indices=None, + ): + r""" + Forward pass through the diffusion model + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of denoised video tensors with original input shapes [C_out, F, H / 8, W / 8] + """ + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + if kv_feature_layers is not None and latent_feature_layer is not None: + raise ValueError( + "KV and post-block latent feature extraction are mutually exclusive") + if kv_token_indices is not None and kv_feature_layers is None: + raise ValueError( + "kv_token_indices requires kv_feature_layers") + if latent_token_indices is not None and latent_feature_layer is None: + raise ValueError( + "latent_token_indices requires latent_feature_layer") + + def validate_token_indices(name, indices): + if indices is None: + return + if not isinstance(indices, torch.Tensor): + raise ValueError(f"{name} must be a torch.Tensor") + if indices.ndim != 1 or indices.dtype != torch.long: + raise ValueError(f"{name} must be a 1-D torch.long tensor") + if indices.numel() == 0: + raise ValueError(f"{name} cannot be empty") + min_index = int(indices.min().item()) + max_index = int(indices.max().item()) + if min_index < 0 or max_index >= int(seq_lens.min().item()): + raise ValueError( + f"{name} range [{min_index}, {max_index}] exceeds " + f"the shortest valid token sequence ({int(seq_lens.min().item())})") + + if kv_feature_layers is not None: + validate_token_indices("kv_token_indices", kv_token_indices) + if latent_feature_layer is not None: + if ( + isinstance(latent_feature_layer, bool) + or not isinstance(latent_feature_layer, int) + ): + raise ValueError("latent_feature_layer must be an integer block index") + if latent_feature_layer < 0 or latent_feature_layer >= len(self.blocks): + raise ValueError( + f"latent_feature_layer={latent_feature_layer} outside " + f"[0, {len(self.blocks) - 1}]") + validate_token_indices("latent_token_indices", latent_token_indices) + x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], + dim=1) for u in x + ]) + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t).type_as(x)) + e0 = self.time_projection(e).unflatten(1, (6, self.dim)) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens) + + def create_custom_forward(module): + def custom_forward(*inputs, **kwargs): + return module(*inputs, **kwargs) + return custom_forward + + # Frozen-Wan post-block latent encoder path. Unlike the final KV-only + # shortcut below, the requested block runs in full (self-attention, + # cross-attention, FFN and all residual updates). The diffusion head + # and all later blocks are intentionally skipped. + if latent_feature_layer is not None: + for ii, block in enumerate(self.blocks): + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + if ii == latent_feature_layer: + if x.ndim != 3 or x.shape[-1] != self.dim: + raise RuntimeError( + "post-block latent must have shape [batch, tokens, dim]") + if latent_token_indices is not None: + indices = latent_token_indices.to(device=x.device) + x = x.index_select(1, indices) + return x + raise RuntimeError( + "latent feature block was not reached despite validated index") + + # Frozen-Wan feature-encoder path. Only the prefix ending at the last + # requested block is evaluated. K/V are concatenated along the feature + # dimension and remain a [B, tokens, features] matrix; no global pooling + # or vector reshape is applied. + if kv_feature_layers is not None: + capture = sorted({int(i) for i in kv_feature_layers}) + if not capture: + raise ValueError("kv_feature_layers must contain at least one block index") + if capture[0] < 0 or capture[-1] >= len(self.blocks): + raise ValueError( + f"kv_feature_layers={capture} outside [0, {len(self.blocks) - 1}]") + captured = [] + capture_set = set(capture) + last_capture = capture[-1] + for ii, block in enumerate(self.blocks): + if ii > last_capture: + break + block_kwargs = dict(kwargs) + block_kwargs["return_kv"] = ii in capture_set + block_kwargs["kv_token_indices"] = ( + kv_token_indices if ii in capture_set else None) + # The last requested feature only needs this block's modulated + # K/V projections. No later block consumes its full output. + block_kwargs["kv_only"] = ii == last_capture + if torch.is_grad_enabled() and self.gradient_checkpointing: + out = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **block_kwargs, + use_reentrant=False, + ) + else: + out = block(x, **block_kwargs) + if ii == last_capture: + k, v = out + kv = torch.cat([k.flatten(2), v.flatten(2)], dim=-1) + captured.append(kv) + break + if ii in capture_set: + x, k, v = out + # [B,L,H,Dh] -> [B,L,2*D]. + kv = torch.cat([k.flatten(2), v.flatten(2)], dim=-1) + captured.append(kv) + else: + x = out + return torch.cat(captured, dim=-1) + + # TODO: Tune the number of blocks for feature extraction + final_x = None + if classify_mode: + assert register_tokens is not None + assert gan_ca_blocks is not None + assert cls_pred_branch is not None + + final_x = [] + registers = repeat(register_tokens(), "n d -> b n d", b=x.shape[0]) + # x = torch.cat([registers, x], dim=1) + + gan_idx = 0 + for ii, block in enumerate(self.blocks): + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + + if classify_mode and ii in [13, 21, 29]: + gan_token = registers[:, gan_idx: gan_idx + 1] + final_x.append(gan_ca_blocks[gan_idx](x, gan_token)) + gan_idx += 1 + + if classify_mode: + final_x = torch.cat(final_x, dim=1) + if concat_time_embeddings: + final_x = cls_pred_branch(torch.cat([final_x, 10 * e[:, None, :]], dim=1).view(final_x.shape[0], -1)) + else: + final_x = cls_pred_branch(final_x.view(final_x.shape[0], -1)) + + # head + x = self.head(x, e) + + # unpatchify + x = self.unpatchify(x, grid_sizes) + + if classify_mode: + return torch.stack(x), final_x + + return torch.stack(x) + + def _forward_classify( + self, + x, + t, + context, + seq_len, + register_tokens, + cls_pred_branch, + clip_fea=None, + y=None, + ): + r""" + Feature extraction through the diffusion model + + Args: + x (List[Tensor]): + List of input video tensors, each with shape [C_in, F, H, W] + t (Tensor): + Diffusion timesteps tensor of shape [B] + context (List[Tensor]): + List of text embeddings each with shape [L, C] + seq_len (`int`): + Maximum sequence length for positional encoding + clip_fea (Tensor, *optional*): + CLIP image features for image-to-video mode + y (List[Tensor], *optional*): + Conditional video inputs for image-to-video mode, same shape as x + + Returns: + List[Tensor]: + List of video features with original input shapes [C_block, F, H / 8, W / 8] + """ + if self.model_type == 'i2v': + assert clip_fea is not None and y is not None + # params + device = self.patch_embedding.weight.device + if self.freqs.device != device: + self.freqs = self.freqs.to(device) + + if y is not None: + x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)] + + # embeddings + x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + grid_sizes = torch.stack( + [torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) + x = [u.flatten(2).transpose(1, 2) for u in x] + seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) + assert seq_lens.max() <= seq_len + x = torch.cat([ + torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))], + dim=1) for u in x + ]) + + # time embeddings + # with amp.autocast(dtype=torch.float32): + e = self.time_embedding( + sinusoidal_embedding_1d(self.freq_dim, t).type_as(x)) + e0 = self.time_projection(e).unflatten(1, (6, self.dim)) + # assert e.dtype == torch.float32 and e0.dtype == torch.float32 + + # context + context_lens = None + context = self.text_embedding( + torch.stack([ + torch.cat( + [u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) + for u in context + ])) + + if clip_fea is not None: + context_clip = self.img_emb(clip_fea) # bs x 257 x dim + context = torch.concat([context_clip, context], dim=1) + + # arguments + kwargs = dict( + e=e0, + seq_lens=seq_lens, + grid_sizes=grid_sizes, + freqs=self.freqs, + context=context, + context_lens=context_lens) + + def create_custom_forward(module): + def custom_forward(*inputs, **kwargs): + return module(*inputs, **kwargs) + return custom_forward + + # TODO: Tune the number of blocks for feature extraction + for block in self.blocks[:16]: + if torch.is_grad_enabled() and self.gradient_checkpointing: + x = torch.utils.checkpoint.checkpoint( + create_custom_forward(block), + x, **kwargs, + use_reentrant=False, + ) + else: + x = block(x, **kwargs) + + # unpatchify + x = self.unpatchify(x, grid_sizes, c=self.dim // 4) + return torch.stack(x) + + def unpatchify(self, x, grid_sizes, c=None): + r""" + Reconstruct video tensors from patch embeddings. + + Args: + x (List[Tensor]): + List of patchified features, each with shape [L, C_out * prod(patch_size)] + grid_sizes (Tensor): + Original spatial-temporal grid dimensions before patching, + shape [B, 3] (3 dimensions correspond to F_patches, H_patches, W_patches) + + Returns: + List[Tensor]: + Reconstructed video tensors with shape [C_out, F, H / 8, W / 8] + """ + + c = self.out_dim if c is None else c + out = [] + for u, v in zip(x, grid_sizes.tolist()): + u = u[:math.prod(v)].view(*v, *self.patch_size, c) + u = torch.einsum('fhwpqrc->cfphqwr', u) + u = u.reshape(c, *[i * j for i, j in zip(v, self.patch_size)]) + out.append(u) + return out + + def init_weights(self): + r""" + Initialize model parameters using Xavier initialization. + """ + + # basic init + for m in self.modules(): + if isinstance(m, nn.Linear): + nn.init.xavier_uniform_(m.weight) + if m.bias is not None: + nn.init.zeros_(m.bias) + + # init embeddings + nn.init.xavier_uniform_(self.patch_embedding.weight.flatten(1)) + for m in self.text_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + for m in self.time_embedding.modules(): + if isinstance(m, nn.Linear): + nn.init.normal_(m.weight, std=.02) + + # init output layer + nn.init.zeros_(self.head.head.weight) diff --git a/14B/code/wan/modules/t5.py b/14B/code/wan/modules/t5.py new file mode 100644 index 0000000000000000000000000000000000000000..9438874681ea14ab1f4368869b474ce7c8dbcd80 --- /dev/null +++ b/14B/code/wan/modules/t5.py @@ -0,0 +1,515 @@ +# Modified from transformers.models.t5.modeling_t5 +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import logging +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .tokenizers import HuggingfaceTokenizer + +__all__ = [ + 'T5Model', + 'T5Encoder', + 'T5Decoder', + 'T5EncoderModel', +] + + +def fp16_clamp(x): + if x.dtype == torch.float16 and torch.isinf(x).any(): + clamp = torch.finfo(x.dtype).max - 1000 + x = torch.clamp(x, min=-clamp, max=clamp) + return x + + +def init_weights(m): + if isinstance(m, T5LayerNorm): + nn.init.ones_(m.weight) + elif isinstance(m, T5Model): + nn.init.normal_(m.token_embedding.weight, std=1.0) + elif isinstance(m, T5FeedForward): + nn.init.normal_(m.gate[0].weight, std=m.dim**-0.5) + nn.init.normal_(m.fc1.weight, std=m.dim**-0.5) + nn.init.normal_(m.fc2.weight, std=m.dim_ffn**-0.5) + elif isinstance(m, T5Attention): + nn.init.normal_(m.q.weight, std=(m.dim * m.dim_attn)**-0.5) + nn.init.normal_(m.k.weight, std=m.dim**-0.5) + nn.init.normal_(m.v.weight, std=m.dim**-0.5) + nn.init.normal_(m.o.weight, std=(m.num_heads * m.dim_attn)**-0.5) + elif isinstance(m, T5RelativeEmbedding): + nn.init.normal_( + m.embedding.weight, std=(2 * m.num_buckets * m.num_heads)**-0.5) + + +class GELU(nn.Module): + + def forward(self, x): + return 0.5 * x * (1.0 + torch.tanh( + math.sqrt(2.0 / math.pi) * (x + 0.044715 * torch.pow(x, 3.0)))) + + +class T5LayerNorm(nn.Module): + + def __init__(self, dim, eps=1e-6): + super(T5LayerNorm, self).__init__() + self.dim = dim + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def forward(self, x): + x = x * torch.rsqrt(x.float().pow(2).mean(dim=-1, keepdim=True) + + self.eps) + if self.weight.dtype in [torch.float16, torch.bfloat16]: + x = x.type_as(self.weight) + return self.weight * x + + +class T5Attention(nn.Module): + + def __init__(self, dim, dim_attn, num_heads, dropout=0.1): + assert dim_attn % num_heads == 0 + super(T5Attention, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.num_heads = num_heads + self.head_dim = dim_attn // num_heads + + # layers + self.q = nn.Linear(dim, dim_attn, bias=False) + self.k = nn.Linear(dim, dim_attn, bias=False) + self.v = nn.Linear(dim, dim_attn, bias=False) + self.o = nn.Linear(dim_attn, dim, bias=False) + self.dropout = nn.Dropout(dropout) + + def forward(self, x, context=None, mask=None, pos_bias=None): + """ + x: [B, L1, C]. + context: [B, L2, C] or None. + mask: [B, L2] or [B, L1, L2] or None. + """ + # check inputs + context = x if context is None else context + b, n, c = x.size(0), self.num_heads, self.head_dim + + # compute query, key, value + q = self.q(x).view(b, -1, n, c) + k = self.k(context).view(b, -1, n, c) + v = self.v(context).view(b, -1, n, c) + + # attention bias + attn_bias = x.new_zeros(b, n, q.size(1), k.size(1)) + if pos_bias is not None: + attn_bias += pos_bias + if mask is not None: + assert mask.ndim in [2, 3] + mask = mask.view(b, 1, 1, + -1) if mask.ndim == 2 else mask.unsqueeze(1) + attn_bias.masked_fill_(mask == 0, torch.finfo(x.dtype).min) + + # compute attention (T5 does not use scaling) + attn = torch.einsum('binc,bjnc->bnij', q, k) + attn_bias + attn = F.softmax(attn.float(), dim=-1).type_as(attn) + x = torch.einsum('bnij,bjnc->binc', attn, v) + + # output + x = x.reshape(b, -1, n * c) + x = self.o(x) + x = self.dropout(x) + return x + + +class T5FeedForward(nn.Module): + + def __init__(self, dim, dim_ffn, dropout=0.1): + super(T5FeedForward, self).__init__() + self.dim = dim + self.dim_ffn = dim_ffn + + # layers + self.gate = nn.Sequential(nn.Linear(dim, dim_ffn, bias=False), GELU()) + self.fc1 = nn.Linear(dim, dim_ffn, bias=False) + self.fc2 = nn.Linear(dim_ffn, dim, bias=False) + self.dropout = nn.Dropout(dropout) + + def forward(self, x): + x = self.fc1(x) * self.gate(x) + x = self.dropout(x) + x = self.fc2(x) + x = self.dropout(x) + return x + + +class T5SelfAttention(nn.Module): + + def __init__(self, + dim, + dim_attn, + dim_ffn, + num_heads, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5SelfAttention, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.norm1 = T5LayerNorm(dim) + self.attn = T5Attention(dim, dim_attn, num_heads, dropout) + self.norm2 = T5LayerNorm(dim) + self.ffn = T5FeedForward(dim, dim_ffn, dropout) + self.pos_embedding = None if shared_pos else T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=True) + + def forward(self, x, mask=None, pos_bias=None): + e = pos_bias if self.shared_pos else self.pos_embedding( + x.size(1), x.size(1)) + x = fp16_clamp(x + self.attn(self.norm1(x), mask=mask, pos_bias=e)) + x = fp16_clamp(x + self.ffn(self.norm2(x))) + return x + + +class T5CrossAttention(nn.Module): + + def __init__(self, + dim, + dim_attn, + dim_ffn, + num_heads, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5CrossAttention, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.norm1 = T5LayerNorm(dim) + self.self_attn = T5Attention(dim, dim_attn, num_heads, dropout) + self.norm2 = T5LayerNorm(dim) + self.cross_attn = T5Attention(dim, dim_attn, num_heads, dropout) + self.norm3 = T5LayerNorm(dim) + self.ffn = T5FeedForward(dim, dim_ffn, dropout) + self.pos_embedding = None if shared_pos else T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=False) + + def forward(self, + x, + mask=None, + encoder_states=None, + encoder_mask=None, + pos_bias=None): + e = pos_bias if self.shared_pos else self.pos_embedding( + x.size(1), x.size(1)) + x = fp16_clamp(x + self.self_attn(self.norm1(x), mask=mask, pos_bias=e)) + x = fp16_clamp(x + self.cross_attn( + self.norm2(x), context=encoder_states, mask=encoder_mask)) + x = fp16_clamp(x + self.ffn(self.norm3(x))) + return x + + +class T5RelativeEmbedding(nn.Module): + + def __init__(self, num_buckets, num_heads, bidirectional, max_dist=128): + super(T5RelativeEmbedding, self).__init__() + self.num_buckets = num_buckets + self.num_heads = num_heads + self.bidirectional = bidirectional + self.max_dist = max_dist + + # layers + self.embedding = nn.Embedding(num_buckets, num_heads) + + def forward(self, lq, lk): + device = self.embedding.weight.device + # rel_pos = torch.arange(lk).unsqueeze(0).to(device) - \ + # torch.arange(lq).unsqueeze(1).to(device) + rel_pos = torch.arange(lk, device=device).unsqueeze(0) - \ + torch.arange(lq, device=device).unsqueeze(1) + rel_pos = self._relative_position_bucket(rel_pos) + rel_pos_embeds = self.embedding(rel_pos) + rel_pos_embeds = rel_pos_embeds.permute(2, 0, 1).unsqueeze( + 0) # [1, N, Lq, Lk] + return rel_pos_embeds.contiguous() + + def _relative_position_bucket(self, rel_pos): + # preprocess + if self.bidirectional: + num_buckets = self.num_buckets // 2 + rel_buckets = (rel_pos > 0).long() * num_buckets + rel_pos = torch.abs(rel_pos) + else: + num_buckets = self.num_buckets + rel_buckets = 0 + rel_pos = -torch.min(rel_pos, torch.zeros_like(rel_pos)) + + # embeddings for small and large positions + max_exact = num_buckets // 2 + rel_pos_large = max_exact + (torch.log(rel_pos.float() / max_exact) / + math.log(self.max_dist / max_exact) * + (num_buckets - max_exact)).long() + rel_pos_large = torch.min( + rel_pos_large, torch.full_like(rel_pos_large, num_buckets - 1)) + rel_buckets += torch.where(rel_pos < max_exact, rel_pos, rel_pos_large) + return rel_buckets + + +class T5Encoder(nn.Module): + + def __init__(self, + vocab, + dim, + dim_attn, + dim_ffn, + num_heads, + num_layers, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5Encoder, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_layers = num_layers + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.token_embedding = vocab if isinstance(vocab, nn.Embedding) \ + else nn.Embedding(vocab, dim) + self.pos_embedding = T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=True) if shared_pos else None + self.dropout = nn.Dropout(dropout) + self.blocks = nn.ModuleList([ + T5SelfAttention(dim, dim_attn, dim_ffn, num_heads, num_buckets, + shared_pos, dropout) for _ in range(num_layers) + ]) + self.norm = T5LayerNorm(dim) + + # initialize weights + self.apply(init_weights) + + def forward(self, ids, mask=None): + x = self.token_embedding(ids) + x = self.dropout(x) + e = self.pos_embedding(x.size(1), + x.size(1)) if self.shared_pos else None + for block in self.blocks: + x = block(x, mask, pos_bias=e) + x = self.norm(x) + x = self.dropout(x) + return x + + +class T5Decoder(nn.Module): + + def __init__(self, + vocab, + dim, + dim_attn, + dim_ffn, + num_heads, + num_layers, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5Decoder, self).__init__() + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.num_layers = num_layers + self.num_buckets = num_buckets + self.shared_pos = shared_pos + + # layers + self.token_embedding = vocab if isinstance(vocab, nn.Embedding) \ + else nn.Embedding(vocab, dim) + self.pos_embedding = T5RelativeEmbedding( + num_buckets, num_heads, bidirectional=False) if shared_pos else None + self.dropout = nn.Dropout(dropout) + self.blocks = nn.ModuleList([ + T5CrossAttention(dim, dim_attn, dim_ffn, num_heads, num_buckets, + shared_pos, dropout) for _ in range(num_layers) + ]) + self.norm = T5LayerNorm(dim) + + # initialize weights + self.apply(init_weights) + + def forward(self, ids, mask=None, encoder_states=None, encoder_mask=None): + b, s = ids.size() + + # causal mask + if mask is None: + mask = torch.tril(torch.ones(1, s, s).to(ids.device)) + elif mask.ndim == 2: + mask = torch.tril(mask.unsqueeze(1).expand(-1, s, -1)) + + # layers + x = self.token_embedding(ids) + x = self.dropout(x) + e = self.pos_embedding(x.size(1), + x.size(1)) if self.shared_pos else None + for block in self.blocks: + x = block(x, mask, encoder_states, encoder_mask, pos_bias=e) + x = self.norm(x) + x = self.dropout(x) + return x + + +class T5Model(nn.Module): + + def __init__(self, + vocab_size, + dim, + dim_attn, + dim_ffn, + num_heads, + encoder_layers, + decoder_layers, + num_buckets, + shared_pos=True, + dropout=0.1): + super(T5Model, self).__init__() + self.vocab_size = vocab_size + self.dim = dim + self.dim_attn = dim_attn + self.dim_ffn = dim_ffn + self.num_heads = num_heads + self.encoder_layers = encoder_layers + self.decoder_layers = decoder_layers + self.num_buckets = num_buckets + + # layers + self.token_embedding = nn.Embedding(vocab_size, dim) + self.encoder = T5Encoder(self.token_embedding, dim, dim_attn, dim_ffn, + num_heads, encoder_layers, num_buckets, + shared_pos, dropout) + self.decoder = T5Decoder(self.token_embedding, dim, dim_attn, dim_ffn, + num_heads, decoder_layers, num_buckets, + shared_pos, dropout) + self.head = nn.Linear(dim, vocab_size, bias=False) + + # initialize weights + self.apply(init_weights) + + def forward(self, encoder_ids, encoder_mask, decoder_ids, decoder_mask): + x = self.encoder(encoder_ids, encoder_mask) + x = self.decoder(decoder_ids, decoder_mask, x, encoder_mask) + x = self.head(x) + return x + + +def _t5(name, + encoder_only=False, + decoder_only=False, + return_tokenizer=False, + tokenizer_kwargs={}, + dtype=torch.float32, + device='cpu', + **kwargs): + # sanity check + assert not (encoder_only and decoder_only) + + # params + if encoder_only: + model_cls = T5Encoder + kwargs['vocab'] = kwargs.pop('vocab_size') + kwargs['num_layers'] = kwargs.pop('encoder_layers') + _ = kwargs.pop('decoder_layers') + elif decoder_only: + model_cls = T5Decoder + kwargs['vocab'] = kwargs.pop('vocab_size') + kwargs['num_layers'] = kwargs.pop('decoder_layers') + _ = kwargs.pop('encoder_layers') + else: + model_cls = T5Model + + # init model + with torch.device(device): + model = model_cls(**kwargs) + + # set device + model = model.to(dtype=dtype, device=device) + + # init tokenizer + if return_tokenizer: + from .tokenizers import HuggingfaceTokenizer + tokenizer_options = dict(tokenizer_kwargs) + tokenizer_path = tokenizer_options.pop("name", f"assets/models/tokenizers/{name}") + tokenizer = HuggingfaceTokenizer(tokenizer_path, **tokenizer_options) + return model, tokenizer + else: + return model + + +def umt5_xxl(**kwargs): + cfg = dict( + vocab_size=256384, + dim=4096, + dim_attn=4096, + dim_ffn=10240, + num_heads=64, + encoder_layers=24, + decoder_layers=24, + num_buckets=32, + shared_pos=False, + dropout=0.1) + cfg.update(**kwargs) + return _t5('umt5-xxl', **cfg) + + +class T5EncoderModel: + + def __init__( + self, + text_len, + dtype=torch.bfloat16, + device=torch.cuda.current_device(), + checkpoint_path=None, + tokenizer_path=None, + shard_fn=None, + ): + self.text_len = text_len + self.dtype = dtype + self.device = device + self.checkpoint_path = checkpoint_path + self.tokenizer_path = tokenizer_path + + # init model + model = umt5_xxl( + encoder_only=True, + return_tokenizer=False, + dtype=dtype, + device=device).eval().requires_grad_(False) + logging.info(f'loading {checkpoint_path}') + model.load_state_dict(torch.load(checkpoint_path, map_location='cpu')) + self.model = model + if shard_fn is not None: + self.model = shard_fn(self.model, sync_module_states=False) + else: + self.model.to(self.device) + # init tokenizer + self.tokenizer = HuggingfaceTokenizer( + name=tokenizer_path, seq_len=text_len, clean='whitespace') + + def __call__(self, texts, device): + ids, mask = self.tokenizer( + texts, return_mask=True, add_special_tokens=True) + ids = ids.to(device) + mask = mask.to(device) + seq_lens = mask.gt(0).sum(dim=1).long() + context = self.model(ids, mask) + return [u[:v] for u, v in zip(context, seq_lens)] diff --git a/14B/code/wan/modules/tokenizers.py b/14B/code/wan/modules/tokenizers.py new file mode 100644 index 0000000000000000000000000000000000000000..8c717e6293b5cd4252dd23b5531faa1627c1248b --- /dev/null +++ b/14B/code/wan/modules/tokenizers.py @@ -0,0 +1,88 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import html +import string +from pathlib import Path + +import ftfy +import regex as re +from transformers import AutoTokenizer + +__all__ = ['HuggingfaceTokenizer'] + + +def basic_clean(text): + text = ftfy.fix_text(text) + text = html.unescape(html.unescape(text)) + return text.strip() + + +def whitespace_clean(text): + text = re.sub(r'\s+', ' ', text) + text = text.strip() + return text + + +def canonicalize(text, keep_punctuation_exact_string=None): + text = text.replace('_', ' ') + if keep_punctuation_exact_string: + text = keep_punctuation_exact_string.join( + part.translate(str.maketrans('', '', string.punctuation)) + for part in text.split(keep_punctuation_exact_string)) + else: + text = text.translate(str.maketrans('', '', string.punctuation)) + text = text.lower() + text = re.sub(r'\s+', ' ', text) + return text.strip() + + +class HuggingfaceTokenizer: + + def __init__(self, name, seq_len=None, clean=None, **kwargs): + assert clean in (None, 'whitespace', 'lower', 'canonicalize') + self.name = name + self.seq_len = seq_len + self.clean = clean + + # Load the tokenizer from an existing local model bundle. + model_dir = Path(name).expanduser() + if not model_dir.is_dir(): + raise FileNotFoundError(f"Prepare the local tokenizer directory: {model_dir}") + name = str(model_dir.resolve()) + kwargs["local_files_only"] = True + self.tokenizer = AutoTokenizer.from_pretrained(name, **kwargs) + self.vocab_size = self.tokenizer.vocab_size + + def __call__(self, sequence, **kwargs): + return_mask = kwargs.pop('return_mask', False) + + # arguments + _kwargs = {'return_tensors': 'pt'} + if self.seq_len is not None: + _kwargs.update({ + 'padding': 'max_length', + 'truncation': True, + 'max_length': self.seq_len + }) + _kwargs.update(**kwargs) + + # tokenization + if isinstance(sequence, str): + sequence = [sequence] + if self.clean: + sequence = [self._clean(u) for u in sequence] + ids = self.tokenizer(sequence, **_kwargs) + + # output + if return_mask: + return ids.input_ids, ids.attention_mask + else: + return ids.input_ids + + def _clean(self, text): + if self.clean == 'whitespace': + text = whitespace_clean(basic_clean(text)) + elif self.clean == 'lower': + text = whitespace_clean(basic_clean(text)).lower() + elif self.clean == 'canonicalize': + text = canonicalize(basic_clean(text)) + return text diff --git a/14B/code/wan/modules/vae.py b/14B/code/wan/modules/vae.py new file mode 100644 index 0000000000000000000000000000000000000000..c50dea913c32eccf971fd528bb15b3173ea5f9b9 --- /dev/null +++ b/14B/code/wan/modules/vae.py @@ -0,0 +1,683 @@ +# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. +import logging + +import torch +import torch.cuda.amp as amp +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange + +__all__ = [ + 'WanVAE', +] + +CACHE_T = 2 + + +class CausalConv3d(nn.Conv3d): + """ + Causal 3d convolusion. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._padding = (self.padding[2], self.padding[2], self.padding[1], + self.padding[1], 2 * self.padding[0], 0) + self.padding = (0, 0, 0) + + def forward(self, x, cache_x=None): + padding = list(self._padding) + if cache_x is not None and self._padding[4] > 0: + cache_x = cache_x.to(x.device) + x = torch.cat([cache_x, x], dim=2) + padding[4] -= cache_x.shape[2] + x = F.pad(x, padding) + + return super().forward(x) + + +class RMS_norm(nn.Module): + + def __init__(self, dim, channel_first=True, images=True, bias=False): + super().__init__() + broadcastable_dims = (1, 1, 1) if not images else (1, 1) + shape = (dim, *broadcastable_dims) if channel_first else (dim,) + + self.channel_first = channel_first + self.scale = dim**0.5 + self.gamma = nn.Parameter(torch.ones(shape)) + self.bias = nn.Parameter(torch.zeros(shape)) if bias else 0. + + def forward(self, x): + return F.normalize( + x, dim=(1 if self.channel_first else + -1)) * self.scale * self.gamma + self.bias + + +class Upsample(nn.Upsample): + + def forward(self, x): + """ + Fix bfloat16 support for nearest neighbor interpolation. + """ + return super().forward(x.float()).type_as(x) + + +class Resample(nn.Module): + + def __init__(self, dim, mode): + assert mode in ('none', 'upsample2d', 'upsample3d', 'downsample2d', + 'downsample3d') + super().__init__() + self.dim = dim + self.mode = mode + + # layers + if mode == 'upsample2d': + self.resample = nn.Sequential( + Upsample(scale_factor=(2., 2.), mode='nearest'), + nn.Conv2d(dim, dim // 2, 3, padding=1)) + elif mode == 'upsample3d': + self.resample = nn.Sequential( + Upsample(scale_factor=(2., 2.), mode='nearest'), + nn.Conv2d(dim, dim // 2, 3, padding=1)) + self.time_conv = CausalConv3d( + dim, dim * 2, (3, 1, 1), padding=(1, 0, 0)) + + elif mode == 'downsample2d': + self.resample = nn.Sequential( + nn.ZeroPad2d((0, 1, 0, 1)), + nn.Conv2d(dim, dim, 3, stride=(2, 2))) + elif mode == 'downsample3d': + self.resample = nn.Sequential( + nn.ZeroPad2d((0, 1, 0, 1)), + nn.Conv2d(dim, dim, 3, stride=(2, 2))) + self.time_conv = CausalConv3d( + dim, dim, (3, 1, 1), stride=(2, 1, 1), padding=(0, 0, 0)) + + else: + self.resample = nn.Identity() + + def forward(self, x, feat_cache=None, feat_idx=[0]): + b, c, t, h, w = x.size() + if self.mode == 'upsample3d': + if feat_cache is not None: + idx = feat_idx[0] + if feat_cache[idx] is None: + feat_cache[idx] = 'Rep' + feat_idx[0] += 1 + else: + + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[ + idx] is not None and feat_cache[idx] != 'Rep': + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + if cache_x.shape[2] < 2 and feat_cache[ + idx] is not None and feat_cache[idx] == 'Rep': + cache_x = torch.cat([ + torch.zeros_like(cache_x).to(cache_x.device), + cache_x + ], + dim=2) + if feat_cache[idx] == 'Rep': + x = self.time_conv(x) + else: + x = self.time_conv(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + + x = x.reshape(b, 2, c, t, h, w) + x = torch.stack((x[:, 0, :, :, :, :], x[:, 1, :, :, :, :]), + 3) + x = x.reshape(b, c, t * 2, h, w) + t = x.shape[2] + x = rearrange(x, 'b c t h w -> (b t) c h w') + x = self.resample(x) + x = rearrange(x, '(b t) c h w -> b c t h w', t=t) + + if self.mode == 'downsample3d': + if feat_cache is not None: + idx = feat_idx[0] + if feat_cache[idx] is None: + feat_cache[idx] = x.clone() + feat_idx[0] += 1 + else: + + cache_x = x[:, :, -1:, :, :].clone() + # if cache_x.shape[2] < 2 and feat_cache[idx] is not None and feat_cache[idx]!='Rep': + # # cache last frame of last two chunk + # cache_x = torch.cat([feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to(cache_x.device), cache_x], dim=2) + + x = self.time_conv( + torch.cat([feat_cache[idx][:, :, -1:, :, :], x], 2)) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + return x + + def init_weight(self, conv): + conv_weight = conv.weight + nn.init.zeros_(conv_weight) + c1, c2, t, h, w = conv_weight.size() + one_matrix = torch.eye(c1, c2) + init_matrix = one_matrix + nn.init.zeros_(conv_weight) + # conv_weight.data[:,:,-1,1,1] = init_matrix * 0.5 + conv_weight.data[:, :, 1, 0, 0] = init_matrix # * 0.5 + conv.weight.data.copy_(conv_weight) + nn.init.zeros_(conv.bias.data) + + def init_weight2(self, conv): + conv_weight = conv.weight.data + nn.init.zeros_(conv_weight) + c1, c2, t, h, w = conv_weight.size() + init_matrix = torch.eye(c1 // 2, c2) + # init_matrix = repeat(init_matrix, 'o ... -> (o 2) ...').permute(1,0,2).contiguous().reshape(c1,c2) + conv_weight[:c1 // 2, :, -1, 0, 0] = init_matrix + conv_weight[c1 // 2:, :, -1, 0, 0] = init_matrix + conv.weight.data.copy_(conv_weight) + nn.init.zeros_(conv.bias.data) + + +class ResidualBlock(nn.Module): + + def __init__(self, in_dim, out_dim, dropout=0.0): + super().__init__() + self.in_dim = in_dim + self.out_dim = out_dim + + # layers + self.residual = nn.Sequential( + RMS_norm(in_dim, images=False), nn.SiLU(), + CausalConv3d(in_dim, out_dim, 3, padding=1), + RMS_norm(out_dim, images=False), nn.SiLU(), nn.Dropout(dropout), + CausalConv3d(out_dim, out_dim, 3, padding=1)) + self.shortcut = CausalConv3d(in_dim, out_dim, 1) \ + if in_dim != out_dim else nn.Identity() + + def forward(self, x, feat_cache=None, feat_idx=[0]): + h = self.shortcut(x) + for layer in self.residual: + if isinstance(layer, CausalConv3d) and feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = layer(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = layer(x) + return x + h + + +class AttentionBlock(nn.Module): + """ + Causal self-attention with a single head. + """ + + def __init__(self, dim): + super().__init__() + self.dim = dim + + # layers + self.norm = RMS_norm(dim) + self.to_qkv = nn.Conv2d(dim, dim * 3, 1) + self.proj = nn.Conv2d(dim, dim, 1) + + # zero out the last layer params + nn.init.zeros_(self.proj.weight) + + def forward(self, x): + identity = x + b, c, t, h, w = x.size() + x = rearrange(x, 'b c t h w -> (b t) c h w') + x = self.norm(x) + # compute query, key, value + q, k, v = self.to_qkv(x).reshape(b * t, 1, c * 3, + -1).permute(0, 1, 3, + 2).contiguous().chunk( + 3, dim=-1) + + # apply attention + x = F.scaled_dot_product_attention( + q, + k, + v, + ) + x = x.squeeze(1).permute(0, 2, 1).reshape(b * t, c, h, w) + + # output + x = self.proj(x) + x = rearrange(x, '(b t) c h w-> b c t h w', t=t) + return x + identity + + +class Encoder3d(nn.Module): + + def __init__(self, + dim=128, + z_dim=4, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_downsample=[True, True, False], + dropout=0.0): + super().__init__() + self.dim = dim + self.z_dim = z_dim + self.dim_mult = dim_mult + self.num_res_blocks = num_res_blocks + self.attn_scales = attn_scales + self.temperal_downsample = temperal_downsample + + # dimensions + dims = [dim * u for u in [1] + dim_mult] + scale = 1.0 + + # init block + self.conv1 = CausalConv3d(3, dims[0], 3, padding=1) + + # downsample blocks + downsamples = [] + for i, (in_dim, out_dim) in enumerate(zip(dims[:-1], dims[1:])): + # residual (+attention) blocks + for _ in range(num_res_blocks): + downsamples.append(ResidualBlock(in_dim, out_dim, dropout)) + if scale in attn_scales: + downsamples.append(AttentionBlock(out_dim)) + in_dim = out_dim + + # downsample block + if i != len(dim_mult) - 1: + mode = 'downsample3d' if temperal_downsample[ + i] else 'downsample2d' + downsamples.append(Resample(out_dim, mode=mode)) + scale /= 2.0 + self.downsamples = nn.Sequential(*downsamples) + + # middle blocks + self.middle = nn.Sequential( + ResidualBlock(out_dim, out_dim, dropout), AttentionBlock(out_dim), + ResidualBlock(out_dim, out_dim, dropout)) + + # output blocks + self.head = nn.Sequential( + RMS_norm(out_dim, images=False), nn.SiLU(), + CausalConv3d(out_dim, z_dim, 3, padding=1)) + + def forward(self, x, feat_cache=None, feat_idx=[0]): + if feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = self.conv1(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = self.conv1(x) + + # downsamples + for layer in self.downsamples: + if feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # middle + for layer in self.middle: + if isinstance(layer, ResidualBlock) and feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # head + for layer in self.head: + if isinstance(layer, CausalConv3d) and feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = layer(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = layer(x) + return x + + +class Decoder3d(nn.Module): + + def __init__(self, + dim=128, + z_dim=4, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_upsample=[False, True, True], + dropout=0.0): + super().__init__() + self.dim = dim + self.z_dim = z_dim + self.dim_mult = dim_mult + self.num_res_blocks = num_res_blocks + self.attn_scales = attn_scales + self.temperal_upsample = temperal_upsample + + # dimensions + dims = [dim * u for u in [dim_mult[-1]] + dim_mult[::-1]] + scale = 1.0 / 2**(len(dim_mult) - 2) + + # init block + self.conv1 = CausalConv3d(z_dim, dims[0], 3, padding=1) + + # middle blocks + self.middle = nn.Sequential( + ResidualBlock(dims[0], dims[0], dropout), AttentionBlock(dims[0]), + ResidualBlock(dims[0], dims[0], dropout)) + + # upsample blocks + upsamples = [] + for i, (in_dim, out_dim) in enumerate(zip(dims[:-1], dims[1:])): + # residual (+attention) blocks + if i == 1 or i == 2 or i == 3: + in_dim = in_dim // 2 + for _ in range(num_res_blocks + 1): + upsamples.append(ResidualBlock(in_dim, out_dim, dropout)) + if scale in attn_scales: + upsamples.append(AttentionBlock(out_dim)) + in_dim = out_dim + + # upsample block + if i != len(dim_mult) - 1: + mode = 'upsample3d' if temperal_upsample[i] else 'upsample2d' + upsamples.append(Resample(out_dim, mode=mode)) + scale *= 2.0 + self.upsamples = nn.Sequential(*upsamples) + + # output blocks + self.head = nn.Sequential( + RMS_norm(out_dim, images=False), nn.SiLU(), + CausalConv3d(out_dim, 3, 3, padding=1)) + + def forward(self, x, feat_cache=None, feat_idx=[0]): + # conv1 + if feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = self.conv1(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = self.conv1(x) + + # middle + for layer in self.middle: + if isinstance(layer, ResidualBlock) and feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # upsamples + for layer in self.upsamples: + if feat_cache is not None: + x = layer(x, feat_cache, feat_idx) + else: + x = layer(x) + + # head + for layer in self.head: + if isinstance(layer, CausalConv3d) and feat_cache is not None: + idx = feat_idx[0] + cache_x = x[:, :, -CACHE_T:, :, :].clone() + if cache_x.shape[2] < 2 and feat_cache[idx] is not None: + # cache last frame of last two chunk + cache_x = torch.cat([ + feat_cache[idx][:, :, -1, :, :].unsqueeze(2).to( + cache_x.device), cache_x + ], + dim=2) + x = layer(x, feat_cache[idx]) + feat_cache[idx] = cache_x + feat_idx[0] += 1 + else: + x = layer(x) + return x + + +def count_conv3d(model): + count = 0 + for m in model.modules(): + if isinstance(m, CausalConv3d): + count += 1 + return count + + +class WanVAE_(nn.Module): + + def __init__(self, + dim=128, + z_dim=4, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_downsample=[True, True, False], + dropout=0.0): + super().__init__() + self.dim = dim + self.z_dim = z_dim + self.dim_mult = dim_mult + self.num_res_blocks = num_res_blocks + self.attn_scales = attn_scales + self.temperal_downsample = temperal_downsample + self.temperal_upsample = temperal_downsample[::-1] + + # modules + self.encoder = Encoder3d(dim, z_dim * 2, dim_mult, num_res_blocks, + attn_scales, self.temperal_downsample, dropout) + self.conv1 = CausalConv3d(z_dim * 2, z_dim * 2, 1) + self.conv2 = CausalConv3d(z_dim, z_dim, 1) + self.decoder = Decoder3d(dim, z_dim, dim_mult, num_res_blocks, + attn_scales, self.temperal_upsample, dropout) + self.clear_cache() + + def forward(self, x): + mu, log_var = self.encode(x) + z = self.reparameterize(mu, log_var) + x_recon = self.decode(z) + return x_recon, mu, log_var + + def encode(self, x, scale): + self.clear_cache() + # cache + t = x.shape[2] + iter_ = 1 + (t - 1) // 4 + # 对encode输入的x,按时间拆分为1、4、4、4.... + for i in range(iter_): + self._enc_conv_idx = [0] + if i == 0: + out = self.encoder( + x[:, :, :1, :, :], + feat_cache=self._enc_feat_map, + feat_idx=self._enc_conv_idx) + else: + out_ = self.encoder( + x[:, :, 1 + 4 * (i - 1):1 + 4 * i, :, :], + feat_cache=self._enc_feat_map, + feat_idx=self._enc_conv_idx) + out = torch.cat([out, out_], 2) + mu, log_var = self.conv1(out).chunk(2, dim=1) + if isinstance(scale[0], torch.Tensor): + mu = (mu - scale[0].view(1, self.z_dim, 1, 1, 1)) * scale[1].view( + 1, self.z_dim, 1, 1, 1) + else: + mu = (mu - scale[0]) * scale[1] + self.clear_cache() + return mu + + def decode(self, z, scale): + self.clear_cache() + # z: [b,c,t,h,w] + if isinstance(scale[0], torch.Tensor): + z = z / scale[1].view(1, self.z_dim, 1, 1, 1) + scale[0].view( + 1, self.z_dim, 1, 1, 1) + else: + z = z / scale[1] + scale[0] + iter_ = z.shape[2] + x = self.conv2(z) + for i in range(iter_): + self._conv_idx = [0] + if i == 0: + out = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + else: + out_ = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + out = torch.cat([out, out_], 2) + self.clear_cache() + return out + + def cached_decode(self, z, scale): + # z: [b,c,t,h,w] + if isinstance(scale[0], torch.Tensor): + z = z / scale[1].view(1, self.z_dim, 1, 1, 1) + scale[0].view( + 1, self.z_dim, 1, 1, 1) + else: + z = z / scale[1] + scale[0] + iter_ = z.shape[2] + x = self.conv2(z) + for i in range(iter_): + self._conv_idx = [0] + if i == 0: + out = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + else: + out_ = self.decoder( + x[:, :, i:i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx) + out = torch.cat([out, out_], 2) + return out + + def sample(self, imgs, deterministic=False): + mu, log_var = self.encode(imgs) + if deterministic: + return mu + std = torch.exp(0.5 * log_var.clamp(-30.0, 20.0)) + return mu + std * torch.randn_like(std) + + def clear_cache(self): + self._conv_num = count_conv3d(self.decoder) + self._conv_idx = [0] + self._feat_map = [None] * self._conv_num + # cache encode + self._enc_conv_num = count_conv3d(self.encoder) + self._enc_conv_idx = [0] + self._enc_feat_map = [None] * self._enc_conv_num + + +def _video_vae(pretrained_path=None, z_dim=None, device='cpu', **kwargs): + """ + Autoencoder3d adapted from Stable Diffusion 1.x, 2.x and XL. + """ + # params + cfg = dict( + dim=96, + z_dim=z_dim, + dim_mult=[1, 2, 4, 4], + num_res_blocks=2, + attn_scales=[], + temperal_downsample=[False, True, True], + dropout=0.0) + cfg.update(**kwargs) + + # init model + with torch.device('meta'): + model = WanVAE_(**cfg) + + # load checkpoint + logging.info(f'loading {pretrained_path}') + model.load_state_dict( + torch.load(pretrained_path, map_location=device), assign=True) + + return model + + +class WanVAE: + + def __init__(self, + z_dim=16, + vae_pth='cache/vae_step_411000.pth', + dtype=torch.float, + device="cuda"): + self.dtype = dtype + self.device = device + + mean = [ + -0.7571, -0.7089, -0.9113, 0.1075, -0.1745, 0.9653, -0.1517, 1.5508, + 0.4134, -0.0715, 0.5517, -0.3632, -0.1922, -0.9497, 0.2503, -0.2921 + ] + std = [ + 2.8184, 1.4541, 2.3275, 2.6558, 1.2196, 1.7708, 2.6052, 2.0743, + 3.2687, 2.1526, 2.8652, 1.5579, 1.6382, 1.1253, 2.8251, 1.9160 + ] + self.mean = torch.tensor(mean, dtype=dtype, device=device) + self.std = torch.tensor(std, dtype=dtype, device=device) + self.scale = [self.mean, 1.0 / self.std] + + # init model + self.model = _video_vae( + pretrained_path=vae_pth, + z_dim=z_dim, + ).eval().requires_grad_(False).to(device) + + def encode(self, videos): + """ + videos: A list of videos each with shape [C, T, H, W]. + """ + with amp.autocast(dtype=self.dtype): + return [ + self.model.encode(u.unsqueeze(0), self.scale).float().squeeze(0) + for u in videos + ] + + def decode(self, zs): + with amp.autocast(dtype=self.dtype): + return [ + self.model.decode(u.unsqueeze(0), + self.scale).float().clamp_(-1, 1).squeeze(0) + for u in zs + ] diff --git a/14B/configs/inference.yaml b/14B/configs/inference.yaml new file mode 100644 index 0000000000000000000000000000000000000000..323fc2acb6887bd25c820f151403221b0a5a655e --- /dev/null +++ b/14B/configs/inference.yaml @@ -0,0 +1,32 @@ +# Human/VLM-evaluated step-80 generator: four trained stages + terminal t=0. +# The auxiliary seed controls context-cache noise, independently of the video seed. +attention_type: softmax +causal: true +denoising_step_list: +- 1000 +- 750 +- 500 +- 250 +warp_denoising_step: true +num_train_timestep: 1000 +timestep_shift: 5.0 +denoising_loss_type: flow +mixed_precision: true +independent_first_frame: false +same_step_across_blocks: true +context_noise: 0 +num_frame_per_block: 3 +latent_video_shape: +- 1 +- 21 +- 16 +- 60 +- 104 +guidance_scale: 1.0 +negative_prompt: '' +model_kwargs: + model_name: Wan2.1-T2V-14B + timestep_shift: 5.0 +inference_terminal_zero_cleanup: true +inference_train_matched_context_cache: true +inference_aux_noise_seed: 12345 diff --git a/14B/configs/train.yaml b/14B/configs/train.yaml new file mode 100644 index 0000000000000000000000000000000000000000..511c829500768931046ba3bc60b62ee2e0219f49 --- /dev/null +++ b/14B/configs/train.yaml @@ -0,0 +1,312 @@ +# Public names follow the paper; code/utils/config.py accepts both these and legacy keys. +# Three-encoder recipe used by the human and VLM evaluations. See ../docs/evaluated-settings.md. + +# Step 80 uses the first 80 updates of the original 200-step cosine schedule. + +# Initialization and checkpoint compatibility. Only the generator is optimized. +initial_generator_checkpoint: /path/to/video-generation-assets/checkpoints/causal-ode-init.safetensors +initial_generator_checkpoint_sha256: 01a21da77adf0e1e803d74ae73ac9c2bc397b1a05a74cbf89709a653282f7ab6 +resume_checkpoint: null + +# Distributed execution: wrap units and precision; score-model settings below are legacy/inactive. +generator_fsdp_wrap_strategy: transformer +real_score_fsdp_wrap_strategy: size +fake_score_fsdp_wrap_strategy: size +text_encoder_fsdp_wrap_strategy: size +uses_score_models: false +uses_real_score: false +uses_fake_score: false + +# Autoregressive rollout: four candidate denoising times, with stochastic training exit steps. +causal: true +denoising_step_list: +- 1000 +- 750 +- 500 +- 250 +warp_denoising_step: true +timestep_shift: 5.0 +guidance_scale: 3.0 +mixed_precision: true + +# Training seed 0 requests a randomly chosen seed; use an explicit positive value for repeatable input draws. +seed: 0 + +# Paper Sec. 3.1: match generated videos in frozen representation spaces, not score-model outputs. +training_objective: distribution_matching +training_loop: distribution_matching +feature_space: frozen_video_encoders +mmd_estimator: hybrid_nystrom_monte_carlo +objective_weight: 1.0 + +# Each encoder loss M uses M / (stop_gradient(M) + epsilon); encoder weights follow this scaling. +normalize_encoder_losses: true +encoder_loss_normalization_epsilon: 0.0001 + +# Reference features are read from disk. "Teacher" in legacy keys means reference data, not a live network. +reference_feature_source: matched_encoder_features +reference_feature_manifest: /path/to/video-generation-assets/teacher_bank/manifest.json +reference_feature_manifest_sha256: c755ccc39ddddca7c9551ce4657c3e95f202453a0943711f0f9cd90b47d106e4 +reference_feature_content_hash: 6b8f249733c39d865a8a9d4b1963779d5578c6a6d68aa5d96002a4b706c766e9 + +# Reference video/prompt correspondence and immutable asset identities. +reference_video_manifest: /path/to/video-generation-assets/source/matched.jsonl +matched_manifest_sha256: a047d08b22757f979f40787af816fa76bf31c68742242250ffbb6177cb815a02 +reference_video_count: 8000 +matched_source_csv_sha256: 5dd5e93f25d7fec7adfee10cfdf4ba85851f40624c3274cceef068ec1f0c36e9 +matched_sample_identity_sha256: 43c752141b38dadb8791ca3e16507b2bc2c0deae3345c5ca609e03ae82a3a021 +matched_sampling_algorithm: sha256_seed_step_position_mod_n_v1 +matched_sampling_with_replacement: true + +# Paper Sec. 3.2: combine a persistent Nyström summary with the current reference minibatch. +triple_token_sampling: uniform_fixed +triple_nystrom_attraction_ema_enabled: false + +# Nyström:Monte Carlo = 2:1, corresponding to paper mixture alpha = 1/3. +nystrom_reference_weight: 2.0 +triple_nystrom_current_teacher_attraction_enabled: true +monte_carlo_reference_weight: 1.0 +triple_nystrom_repulsion_fifo_size: 0 + +# The legacy triple_nystrom_* reference fields belong to V-JEPA 2; other encoders have separate packs. +triple_nystrom_reference_path: /path/to/video-generation-assets/references/vjepa2.pt +triple_nystrom_reference_sha256: caa965b372e8dd75ba3757adf9caefa7c9b0bbc758d39c9d566a0056b2c19e20 +triple_nystrom_landmarks: 1024 +triple_nystrom_kernel_chunk_size: 32 +triple_nystrom_seed: 0 + +# All three frozen encoders contribute; normalize each branch, then combine with weights 10:1:1. +active_feature_encoders: +- dinov3 +- vjepa2 +- videomae_v2 +vjepa_loss_weight: 1.0 +videomae_loss_weight: 1.0 +encoder_loss_divisor: 3.0 + +# DINOv3: 20 sampled frames, five-frame temporal means, 1024 patch tokens. +dino_loss_weight: 10.0 +dinov3_repo_path: /path/to/video-generation-assets/models/dinov3-src +dinov3_weight_path: /path/to/video-generation-assets/models/dinov3_vitl16_lvd1689m.pth +dinov3_source_commit: 6876159a11b4df116f30f667f8c9888617df0751 +dinov3_checkpoint_sha256: b361ce4cccaae083fccaa7a732bd9abb143b5a3736175152f28f331b600f8eec +dinov3_dense_geometry: temporal_mean5_full256 +dinov3_dense_source_num_frames: 81 +dinov3_dense_frame_indices: +- 0 +- 4 +- 8 +- 13 +- 17 +- 21 +- 25 +- 29 +- 34 +- 38 +- 42 +- 46 +- 51 +- 55 +- 59 +- 63 +- 67 +- 72 +- 76 +- 80 +dinov3_dense_input_size: 256 +dinov3_dense_patch_size: 16 +dinov3_dense_spatial_axis_indices: +- 0 +- 1 +- 2 +- 3 +- 4 +- 5 +- 6 +- 7 +- 8 +- 9 +- 10 +- 11 +- 12 +- 13 +- 14 +- 15 +dinov3_dense_temporal_groups: 4 +dinov3_dense_temporal_pool_size: 5 +dinov3_dense_forward_chunk_frames: 4 +dinov3_dense_gradient_checkpointing: false +dinov3_nystrom_reference_path: /path/to/video-generation-assets/references/dinov3.pt +dinov3_nystrom_reference_sha256: bc488a15ea499cc55eaaf201401eecfe37f9a92ccbe43d6e8d3e0fc37415f011 +dinov3_nystrom_landmarks: 256 +dinov3_nystrom_kernel_chunk_size: 32 +dinov3_nystrom_seed: 0 +dinov3_feature_metadata_preserve_runtime_paths: true + +# V-JEPA 2: last 64 of 81 frames, block index 15 (the 16th block), 1024 selected tokens. +vjepa2_model_id: /path/to/video-generation-assets/models/vjepa2-vitl-fpc64-256 +vjepa2_reference_model_id: vjepa2-vitl-fpc64-256 +vjepa2_model_sha256: 25466aef85727d16546c6cf8c99f12fcfad9cbca8225d45f23685e2e025b786b +vjepa2_dense_layers: +- 15 +vjepa2_dense_layer: 15 +vjepa2_dense_num_frames: 64 +vjepa2_dense_source_num_frames: 81 +vjepa2_dense_frame_sampling: last_contiguous +vjepa2_student_source_num_frames: 81 +vjepa2_student_frame_sampling: last_contiguous +vjepa2_dense_num_tokens: 1024 +vjepa2_dense_resize_short_side: 292 +vjepa2_dense_crop_size: 256 +vjepa2_dense_gradient_checkpointing: true + +# VideoMAEv2: skip frame 0, split the remaining 80 frames into five 16-frame segments. +videomae_v2_model_id: /path/to/video-generation-assets/models/VideoMAEv2-Large +videomae_v2_model_revision: 9981a9c8f77118c421e5228e1b219468a4b0238d +videomae_v2_download_manifest_content_hash: 5e362cfe6fdb1bec9e3e195e5ef43cdfebbdfaec2625b1f60550efd05ead0d7d +videomae_v2_local_files_only: true +videomae_v2_dense_layers: +- 15 +videomae_v2_dense_layer: 15 +videomae_v2_encoder_count: 5 +videomae_v2_share_encoder_weights: true +videomae_v2_skip_leading_frames: 1 +videomae_v2_segment_num_frames: 16 +videomae_v2_source_num_frames: 81 + +# 256 tokens per segment, hence 1280 tokens for the complete video. +videomae_v2_dense_num_tokens: 256 +videomae_v2_dense_resize_short_side: 224 +videomae_v2_dense_crop_size: 224 +videomae_v2_dense_gradient_checkpointing: true +videomae_v2_nystrom_reference_path: /path/to/video-generation-assets/references/videomae_v2.pt +videomae_v2_nystrom_reference_sha256: 17284c8eba334b091ab9668621ed9acccb045911e2acac51fd2cf3e4f4d56a36 +videomae_v2_nystrom_landmarks: 256 +videomae_v2_nystrom_kernel_chunk_size: 32 +videomae_v2_nystrom_seed: 0 +videomae_v2_nystrom_attraction_weight: 2.0 +videomae_v2_nystrom_current_teacher_attraction_weight: 1.0 + +# Paper Sec. 3.3: separate distribution batch B, differentiation batch b, and reference minibatch B_ref. +per_device_microbatch_size: 2 +fresh_video_logical_micro_batch_size: 16 +fresh_video_physical_replay_batch_size: 1 +distribution_samples_per_rank: 32 + +# Replay the configured per-rank suffix; this release does not draw the paper pseudocode's uniform subset. +differentiation_samples_per_rank: 16 +fresh_video_partial_gradient: true +differentiate_full_distribution_loss: true + +# Apply B/b once on replay gradients, separately from FSDP averaging compensation. +differentiation_gradient_scale: 2.0 +distribution_batch_size: 256 +expected_world_size: 8 +reference_minibatch_size: 128 + +# Recorded estimator geometry: [B, b, B_ref]. Released self-kernel mean includes diagonal pairs and divides by B^2. +partial_mmd_geometry: +- 256 +- 128 +- 128 +partial_mmd_normalization_identity: full_student_population_denominators_v1 +partial_mmd_kss_denominator: 65536 +partial_mmd_fixed_attraction_denominator: 256 +partial_mmd_live_cross_denominator: 32768 +partial_mmd_teacher_self_denominator: 16384 +partial_mmd_gradient_compensation: 2.0 +fresh_video_replay_atol: 1.0e-05 +fresh_video_replay_rtol: 0.0001 + +# Parameter EMA is disabled; inference uses the trained generator weights directly. +ema_weight: 0.0 +sharding_strategy: full +fsdp_backward_prefetch: post +optimizer_foreach: false +optimizer_fused: false +optimizer_state_cpu_offload_during_backward: true + +# Generator optimizer. Learning-rate schedule parameters below remain unchanged. +generator_learning_rate: 2.5e-06 + +# 14B: retain the original 200-update cosine curve while stopping at update 80. +lr_schedule: cosine +lr_final: 5.0e-07 +lr_schedule_total_steps: 200 +beta1: 0.0 +beta2: 0.999 +weight_decay: 0.0 +max_grad_norm_generator: 10.0 +negative_prompt: '' +dfake_gen_update_ratio: 1 + +# Latent tensor [batch, time, channels, height, width]; 21 latent frames decode to 81 RGB frames at 480 x 832. +latent_video_shape: +- 1 +- 21 +- 16 +- 60 +- 104 +denoising_loss_type: flow + +# Activation recomputation saves GPU memory; it is unrelated to saving model checkpoints. +gradient_checkpointing: true +generator_replay_activation_cpu_offload: true +generator_replay_activation_cpu_offload_block_stride: 0 +generator_replay_frozen_encoder_cpu_offload: true +num_frame_per_block: 3 +load_raw_video: false +model_kwargs: + model_name: Wan2.1-T2V-14B + timestep_shift: 5.0 + +# Checkpoint/logging policy. Full states include optimizer and per-rank RNG state. +log_iters: 10 +save_full_state: true +save_full_state_final_only: false +checkpoint_keep_last: 0 +checkpoint_model_min_free_gib: 12 +checkpoint_full_min_free_gib: 32 +allow_resume_world_size_change: false + +# Optional in-training evaluation is disabled; use scripts/infer.sh for generation. +eval_interval: 0 +eval_seed: 12345 +eval_num_prompts: 0 +eval_prompt_path: prompts/test_8.txt +num_workers: 0 +stdout_log_iters: 1 + +# Optimizer updates, not epochs or diffusion timesteps. Do not shorten the configured cosine horizon. +training_updates: 80 +num_train_timestep: 1000 + +# Clean cached context; blocks share the sampled training exit step. Frozen encoders still pass input gradients. +context_noise: 0 +independent_first_frame: false +same_step_across_blocks: true +last_step_only: false +i2v: false +num_training_frames: 21 +gc_interval: 25 + +# Opaque feature/data identities are retained so existing reference packs and checkpoints still match. +feature_protocol_identity: distribution_matching_14b_three_encoder_step80 +matched_video_root: /path/to/video-generation-assets/videos +dataset_identity: + name: wan21_14b_reference_8000 + sample_count: 8000 + pixel_geometry: + - 81 + - 480 + - 832 + latent_geometry: + - 21 + - 60 + - 104 + caption_type: source_wan_dataset_prompt + source_manifest_sha256: a047d08b22757f979f40787af816fa76bf31c68742242250ffbb6177cb815a02 + teacher_bank_content_hash: 6b8f249733c39d865a8a9d4b1963779d5578c6a6d68aa5d96002a4b706c766e9 + reference_manifest_sha256: bc6041e517b6aa37d149c33cc9e5da69693e9efca515387e6bdb16a015307c4d + checkpoint_policy: every_10_keep_all +fresh_video_progress_logs: true diff --git a/14B/docs/assets.md b/14B/docs/assets.md new file mode 100644 index 0000000000000000000000000000000000000000..1045dae6a7e06510938856c63d7104416a011fee --- /dev/null +++ b/14B/docs/assets.md @@ -0,0 +1,73 @@ +# 14B Asset Preparation + +The evaluated model is the **three-encoder step-80 non-EMA generator**, trained on **8,000 reference videos**. Its asset identities are recorded in [`configs/train.yaml`](../configs/train.yaml). The former two-encoder / 8,852-video bank is a different experiment and cannot reproduce this model. + +Prepare matching model and reference assets locally; this anonymous release does not embed hosted resource locations. Use the matching three-encoder checkpoint and reference assets; filenames or the step number alone do not identify the experiment. This source repository does not include model weights or reference tensors. A final distilled model is not interchangeable with the **causal ODE initialization** needed for training. + +## Directory layout + +```text +ASSET_ROOT/ +├── models/ +│ ├── Wan2.1-T2V-14B/ # Base generator, T5, tokenizer, and VAE +│ ├── dinov3-src/ # Official DINOv3 source at the pinned commit +│ ├── dinov3_vitl16_lvd1689m.pth +│ ├── vjepa2-vitl-fpc64-256/ +│ └── VideoMAEv2-Large/ # Includes download_manifest.json +├── checkpoints/ +│ └── causal-ode-init.safetensors +├── teacher_bank/ +│ ├── manifest.json +│ └── ... # Preserve all shard-relative paths +├── source/ +│ ├── matched.jsonl +│ └── matched.meta.json +├── references/ +│ ├── dinov3.pt +│ ├── vjepa2.pt +│ └── videomae_v2.pt +└── videos/ # Feature extraction only; not decoded during bank-based training +``` + +Preserve original serialized manifests, reference packs, and shards. The three reference files may be renamed as above without changing their bytes. `prepare_config.py` reads DINOv3 and VideoMAEv2 metadata identities from the bank and keeps them separate from local encoder load paths. These local settings are excluded from Git. + +## Frozen encoders + +Wan2.1-T2V-14B includes the shared T5 weights, VAE weights, and tokenizer. Place its files in the directory described in the model README. Before offline training, prepare the other encoders: + +```bash +git clone https://github.com/facebookresearch/dinov3.git "$ASSET_ROOT/models/dinov3-src" +git -C "$ASSET_ROOT/models/dinov3-src" checkout 6876159a11b4df116f30f667f8c9888617df0751 +# Obtain dinov3_vitl16_lvd1689m.pth through the official DINOv3 model access process. +# Place the matching model files at the local paths described below. +PYTHONPATH="$PWD/code" python code/tools/download_videomae_v2_large.py \ + --source-dir /path/to/local/VideoMAEv2-Large \ + --output-dir "$ASSET_ROOT/models/VideoMAEv2-Large" +``` + +The exact encoder hashes are in the training config; a newer checkpoint is not interchangeable with a frozen reference bank. VideoMAEv2 uses revision `9981a9c8f77118c421e5228e1b219468a4b0238d`. + +| Encoder | Input and representation | Nyström landmarks | RBF bandwidth | +|---|---|---:|---:| +| DINOv3 ViT-L/16 | 20 sampled frames resized to 256²; final normalized patch features; mean each group of five frames; 1,024 × 1,024 matrix | 256 | 341.2794189453125 | +| V-JEPA 2 ViT-L | Frames 17–80; short side 292, center crop 256²; block index 15; 1,024 × 1,024 matrix | 1,024 | 3025.305908203125 | +| VideoMAEv2-Large | Frames 1–80 split into five 16-frame clips; short side/crop 224; block index 15; 256 tokens per clip, 1,280 × 1,024 per video | 256 | 3204.123291015625 | + +VideoMAEv2 shares one frozen encoder across the five clips. All branches use ImageNet pixel normalization. The video-level kernel is `exp(-||X-Y||_F² / (2 sigma²))`, without dividing the distance by the number of tokens or channels. The fixed Nyström references use ridge `1e-6` and 20 k-means iterations. The loss normalization epsilon is separately `1e-4`. Full metadata is in [`reference-settings.json`](../provenance/reference-settings.json). + +## Rebuilding a reference bank + +Rebuilding requires the original captioned videos and matched manifest. Re-extraction may change serialized hashes; another video collection defines a new experiment. The included three-encoder tools are: + +```bash +PYTHONPATH="$PWD/code" python code/tools/extract_triple_matched_teacher_bank.py --help +PYTHONPATH="$PWD/code" python code/tools/build_triple_nystrom_references.py --help +``` + +Use `--expected-count 8000 --shard-rows 32` for extraction. Pass all three pinned encoder locations and identities, the source manifest and CSV hashes, and the video root. For reference construction use `--expected-sample-count 8000 --expected-shard-size 32 --kmeans-iterations 20 --ridge 1e-6`, together with the resulting bank identities. The builder fixes the landmark counts to DINOv3 256, V-JEPA 2 1,024, and VideoMAEv2 256. + +For a rebuilt bank, update a local copy of the training config with its new manifest/content hashes and all three reference filenames/hashes before preparing local paths. The optional `*_no_dinov3_*` tools are two-encoder ablation utilities and are not part of this recipe. + +## Local snapshot preparation + +All model loaders require existing local directories. Hosted model repository URLs, repository IDs and automatic downloads are omitted from this source release. The legacy-named `download_videomae_v2_large.py` stages an existing local snapshot using `--source-dir` and an empty `--output-dir`; it does not download files. If the source includes a valid pinned `download_manifest.json`, that manifest is preserved for compatibility with frozen reference banks. A snapshot without a manifest gets a new local manifest; rebuild matching reference assets and update their configuration identities before training. `prepare_config.py` reads model identities from an available local feature bank into private local configuration files. diff --git a/14B/docs/evaluated-settings.md b/14B/docs/evaluated-settings.md new file mode 100644 index 0000000000000000000000000000000000000000..a2985580b4371c4691f89d7a63c2abe007056eb4 --- /dev/null +++ b/14B/docs/evaluated-settings.md @@ -0,0 +1,41 @@ +# Evaluated 14B step-80 settings + +The released configuration corresponds to the **three-encoder step-80 non-EMA model used in the human and VLM evaluations**. The earlier two-encoder / 8,852-reference configuration described a different experiment. + +| Setting | Value | +|---|---| +| Generator | Wan2.1-T2V-14B, causal softmax attention | +| Initialization | Causal ODE; SHA-256 `01a21da77adf0e1e803d74ae73ac9c2bc397b1a05a74cbf89709a653282f7ab6` | +| Frozen encoders | DINOv3 + V-JEPA 2 + VideoMAEv2 | +| Loss weights / divisor | **10 : 1 : 1 / 3**, after detached per-encoder normalization | +| Reference videos | **8,000** | +| Distribution / differentiation / reference minibatch | **256 / 128 / 128** | +| GPUs / per-rank generated / per-rank differentiated | **8 / 32 / 16** | +| Physical generation / logical feature / physical replay batch | **2 / 16 / 1** | +| AdamW | LR `2.5e-6`, betas `(0, 0.999)`, epsilon `1e-8`, weight decay `0`, gradient clipping `10` | +| Schedule | Cosine to `5e-7` over **200** updates; take checkpoint **80** | +| Nyström / Monte Carlo mixture | **2 : 1** | +| Training video / causal chunk | 81 RGB frames at 480 × 832; 21 latent frames; 3 latent frames per chunk | +| Inference weights | `checkpoint_model_000080/model.pt`, regular `generator`, no EMA or CFG | + +The original training config SHA-256 is `fba9c9abd26f9b6b08ed5450c97d01a250da2747c7325db469a2e2f204f694ab`. Public asset paths and descriptive identities are relocated; the three reference-pack hashes and numerical training settings are retained. The public recipe stops at update 80 without shortening the original 200-update learning-rate curve. `seed: 0` chooses an effective seed at startup; exact historical training replay additionally requires its effective seed and RNG state. + +## Inference + +The pipeline and sample-batch noise order follow the previous step-80 inference run: + +- Four trained stages `[1000, 750, 500, 250]`, warped to approximately `[1000, 937.5, 833.3333, 625]`, then a terminal `t=0` cleanup. +- Training-matched context-cache writes, with auxiliary noise seed `12345`. Scheduler `t=0` has a small nonzero noise level; cache input is noised separately from the saved clean output. +- **Seed 0, five samples per prompt**, continuous random stream. Draw all five samples' initial/transition noises together, then execute one video at a time. +- 81 output frames, 832 × 480, 16 FPS. + +```bash +CHECKPOINT=/path/to/checkpoint_model_000080/model.pt \ +PROMPTS=/path/to/prompts.txt \ +SEED=0 NUM_SAMPLES=5 OUTPUT_DIR=outputs/step80 \ +bash scripts/infer.sh +``` + +Set `NUM_SAMPLES=1` for a smaller demo. This changes the random-number sequence. Exact historical video reproduction requires the same prompts, ordering and shard assignment in addition to the same checkpoint. The original VBench source used four contiguous shards with 237 / 237 / 236 / 236 prompt rows, restarting seed 0 for each shard. This package provides generation, not human/VLM scoring infrastructure. + +See [asset preparation](assets.md) for encoder geometry and reference bandwidths, and [validation](validation.md) for checks and limits. diff --git a/14B/docs/validation.md b/14B/docs/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..3b3527fb3892b155952ea26ce435b899b939ccce --- /dev/null +++ b/14B/docs/validation.md @@ -0,0 +1,27 @@ +# Validation + +## Evaluated checkpoint + +The restored recipe is the **three-encoder, 8,000-reference-video run**. The original checkpoint header records step **80**, **1,095 generator parameter tensors**, and all three branches: DINOv3, V-JEPA 2, and VideoMAEv2. Its bank identity matches the released training config. The historical human-evaluation generation records and the VLM source-video run point to this same checkpoint. + +The original step-80 log reports loss **3.854**, generator gradient norm **10.98**, three encoder VJPs, batch geometry **256 / 128 / 128**, zero reported feature replay error for all three encoders, and a completed optimizer update. These are historical measurements, not a new run of this package. See [`original-step80.json`](../results/original-step80.json). + +## Checks of the corrected release + +- **51 CPU tests passed; 1 CUDA-only test skipped.** Coverage includes three-encoder configuration, bank loading, MMD gradients, replay/offload, the 200-update cosine curve, DINOv3 metadata relocation, terminal cleanup and context-cache inference, and five-sample noise ordering. +- All **184 fields** from the original training configuration were accounted for. Asset paths and descriptive identities are public equivalents; the public stopping point is 80 rather than 200, while the cosine horizon remains 200. The three active encoders are specified explicitly. +- The public sampler was compared with the actual historical evaluation sampler using a small recording generator and the real scheduler. Outputs and every generator input/timestep matched exactly for two consecutive prompts, both with ordinary random draws and with supplied noise tensors. +- The five-sample adapter draws the same 21 main transition tensors and 14 auxiliary tensors per prompt as the original 81-frame inference adapter, before single-video execution. CPU tests verify noise slices and final RNG state. +- DINOv3 metadata relocation reproduces the complete actual frozen-bank metadata and digest without loading models from the original paths. +- The single-encoder MMD kernel, training rollout, and encoder VJP implementation match the original three-encoder snapshot's executable AST after removing documentation and restoring readability aliases. +- Python parsing, shell syntax, and diff whitespace checks passed. + +Path-free evidence is in [`release-validation.json`](../results/release-validation.json). Original source-fingerprint manifests are omitted from this anonymous release. + +**Full 14B GPU training, GPU video generation, and VBench were not rerun for this correction.** CPU sampler comparisons use a small test generator, not the 14B network, and do not establish identical generated videos or a new benchmark score. + +Run the CPU suite from `14B/`: + +```bash +python scripts/test_cpu.py +``` diff --git a/14B/provenance/reference-settings.json b/14B/provenance/reference-settings.json new file mode 100644 index 0000000000000000000000000000000000000000..132695668ad31e5ae96667fcc4eea8164ceaae94 --- /dev/null +++ b/14B/provenance/reference-settings.json @@ -0,0 +1,98 @@ +{ + "variant": "14B three encoders, checkpoint 80", + "reference_count": 8000, + "weights": [ + 10.0, + 1.0, + 1.0 + ], + "divisor": 3.0, + "batch_geometry": [ + 256, + 128, + 128 + ], + "branches": { + "DINOv3": { + "sigma": 341.2794189453125, + "landmark_shape": [ + 256, + 1024, + 1024 + ], + "nystrom": { + "bandwidth_source": "median_frobenius_distance", + "generated_gram": "biased_exact_including_diagonal", + "kernel": "gaussian_rbf", + "kernel_metric": "frobenius", + "kmeans": "deterministic_farthest_first_lloyd", + "kmeans_iterations": 20, + "median_max_samples": 2048, + "num_landmarks": 256, + "reference_count": 8000, + "ridge": 1e-06, + "sample_axis": "video", + "sample_shape": [ + 1024, + 1024 + ], + "seed": 0 + }, + "content_hash": "67851f7828ee0b9e12643242ff93f2b2b9b7c56059e51b9b2f267972da15eba9" + }, + "V-JEPA 2": { + "sigma": 3025.305908203125, + "landmark_shape": [ + 1024, + 1024, + 1024 + ], + "nystrom": { + "bandwidth_source": "median_frobenius_distance", + "generated_gram": "biased_exact_including_diagonal", + "kernel": "gaussian_rbf", + "kernel_metric": "frobenius", + "kmeans": "deterministic_farthest_first_lloyd", + "kmeans_iterations": 20, + "median_max_samples": 2048, + "num_landmarks": 1024, + "reference_count": 8000, + "ridge": 1e-06, + "sample_axis": "video", + "sample_shape": [ + 1024, + 1024 + ], + "seed": 0 + }, + "content_hash": "d588e77dd6ea5d5611f5aeaac2c7f9a8c4e4fc36940209df32118a91e22d168b" + }, + "VideoMAEv2": { + "sigma": 3204.123291015625, + "landmark_shape": [ + 256, + 1280, + 1024 + ], + "nystrom": { + "bandwidth_source": "median_frobenius_distance", + "generated_gram": "biased_exact_including_diagonal", + "kernel": "gaussian_rbf", + "kernel_metric": "frobenius", + "kmeans": "deterministic_farthest_first_lloyd", + "kmeans_iterations": 20, + "median_max_samples": 2048, + "num_landmarks": 256, + "reference_count": 8000, + "ridge": 1e-06, + "sample_axis": "video", + "sample_shape": [ + 1280, + 1024 + ], + "seed": 0 + }, + "content_hash": "88d55200ebb71d20ea75c70358eb8e5e68511c030546c5f832b53e591dd5a9b2" + } + } +} diff --git a/14B/requirements.txt b/14B/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0dd2c6ae5d0deb7722dcd03fd1ac0f31a380629f --- /dev/null +++ b/14B/requirements.txt @@ -0,0 +1,29 @@ +# Training and inference dependencies. Install torch/torchvision first. +numpy==1.26.4 +diffusers==0.31.0 +transformers==4.53.3 +tokenizers==0.21.4 +timm==1.0.28 +accelerate==1.14.0 +tqdm==4.70.0 +imageio==2.37.4 +imageio-ffmpeg==0.6.0 +easydict==1.13 +ftfy==6.3.1 +omegaconf>=2.3.0,<2.4 +einops==0.8.1 +av==13.1.0 +opencv-python +open-clip-torch +lmdb==2.3.0 +matplotlib +sentencepiece==0.2.2 +scikit-image +huggingface-hub==0.36.2 +safetensors==0.8.0 +PyYAML==6.0.3 +pandas +scipy +Pillow==12.2.0 +requests==2.34.2 +packaging==26.3 diff --git a/14B/results/original-step80.json b/14B/results/original-step80.json new file mode 100644 index 0000000000000000000000000000000000000000..3dd1126dca25183f920d541110379a9f0a074642 --- /dev/null +++ b/14B/results/original-step80.json @@ -0,0 +1,24 @@ +{ + "scope": "Historical three-encoder training log; not a rerun of this package. Values retain the precision printed by the original logger.", + "checkpoint_step": 80, + "active_encoders": [ + "dinov3", + "vjepa2", + "videomae_v2" + ], + "reference_count": 8000, + "use_ema": false, + "metrics": { + "generator_loss": 3.854, + "generator_grad_norm": 10.98, + "learning_rate": 1.818e-06, + "fresh_video_optimizer_step_skipped": 0.0, + "triple_dense_global_video_samples": 256.0, + "triple_dense_gradient_global_video_samples": 128.0, + "triple_dense_current_teacher_global_samples": 128.0, + "triple_dense_encoder_vjp_count": 3.0, + "dinov3_dense_replay_max_abs": 0.0, + "vjepa2_dense_replay_max_abs": 0.0, + "videomae_v2_dense_replay_max_abs": 0.0 + } +} diff --git a/14B/results/release-validation.json b/14B/results/release-validation.json new file mode 100644 index 0000000000000000000000000000000000000000..cefb4748042872717bdf29575c45523fe1bcdf3a --- /dev/null +++ b/14B/results/release-validation.json @@ -0,0 +1,24 @@ +{ + "checkpoint_step": 80, + "generator_parameter_tensors": 1095, + "active_encoders": [ + "dinov3", + "vjepa2", + "videomae_v2" + ], + "reference_count": 8000, + "teacher_bank_content_hash": "6b8f249733c39d865a8a9d4b1963779d5578c6a6d68aa5d96002a4b706c766e9", + "human_motion_and_camera_protocols_same_checkpoint": true, + "vlm_source_vbench_same_checkpoint": true, + "cpu_sampler_matches_evaluated_source": { + "continuous_two_prompts": true, + "paired_two_prompts": true, + "scope": "Small recording generator and real scheduler; no 14B model weights were executed." + }, + "actual_dinov3_metadata_relocation_exact": true, + "cpu_tests": { + "passed": 51, + "skipped_cuda": 1 + }, + "five_sample_noise_order_cpu_test": true +} diff --git a/14B/scripts/common.sh b/14B/scripts/common.sh new file mode 100644 index 0000000000000000000000000000000000000000..6daf65f0cf949a83e28777ec9309042fcd494060 --- /dev/null +++ b/14B/scripts/common.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +REPRO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$REPRO_ROOT/configs/local.env" +PYTHON_BIN="$(command -v "${PYTHON_BIN:-python}")" +CONFIG="$REPRO_ROOT/configs/local.yaml" +export SELF_FORCING_ASSET_ROOT="$VIDEO_GENERATION_ASSET_ROOT" +export SELF_FORCING_MODELS_DIR="$VIDEO_GENERATION_ASSET_ROOT/models" +export SELF_FORCING_CKPT_DIR="$VIDEO_GENERATION_ASSET_ROOT/checkpoints" +export PYTHONPATH="$REPRO_ROOT/code${PYTHONPATH:+:$PYTHONPATH}" +export PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 +export OMP_NUM_THREADS="${OMP_NUM_THREADS:-12}" TOKENIZERS_PARALLELISM=false +export HF_HOME="${HF_HOME:-$REPRO_ROOT/cache/huggingface}" +export TORCH_HOME="${TORCH_HOME:-$REPRO_ROOT/cache/torch}" +export TRITON_CACHE_DIR="${TRITON_CACHE_DIR:-$REPRO_ROOT/cache/triton}" +export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 +export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True +export TORCH_NCCL_ASYNC_ERROR_HANDLING=1 NCCL_DEBUG=WARN +unset WORLD_SIZE RANK LOCAL_RANK MASTER_ADDR MASTER_PORT diff --git a/14B/scripts/infer.sh b/14B/scripts/infer.sh new file mode 100644 index 0000000000000000000000000000000000000000..c93f62ba6866d3aa8183c651848658e2d23880e4 --- /dev/null +++ b/14B/scripts/infer.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +: "${CHECKPOINT:?Set CHECKPOINT to the step-80 model.pt file}" +CHECKPOINT="$(realpath "$CHECKPOINT")" +PROMPTS="$(realpath "${PROMPTS:-$REPRO_ROOT/code/prompts/test_8.txt}")" +OUTPUT_DIR="$(realpath -m "${OUTPUT_DIR:-$REPRO_ROOT/outputs/demo}")" +export CUDA_VISIBLE_DEVICES="${GPUS:-0}" +cd "$REPRO_ROOT/code" +"$PYTHON_BIN" inference.py --config_path "$REPRO_ROOT/configs/inference.yaml" \ + --checkpoint_path "$CHECKPOINT" --data_path "$PROMPTS" \ + --output_folder "$OUTPUT_DIR" --num_output_frames 21 \ + --num_samples "${NUM_SAMPLES:-5}" --seed "${SEED:-0}" --save_with_index diff --git a/14B/scripts/prepare_config.py b/14B/scripts/prepare_config.py new file mode 100644 index 0000000000000000000000000000000000000000..b9a8dfc951096593c3563b26830e4f456bb488f7 --- /dev/null +++ b/14B/scripts/prepare_config.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Create local asset paths without changing the published training settings.""" +import argparse +import hashlib +import json +from pathlib import Path +import shlex +import yaml +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "code")) +from utils.config import resolve_config_aliases +PREFIX = "/path/to/video-generation-assets" + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--asset-root", required=True, type=Path) + parser.add_argument("--inference-only", action="store_true") + parser.add_argument("--resume-checkpoint", type=Path) + args = parser.parse_args() + asset_root = str(args.asset_root.expanduser().resolve()) + cfg = resolve_config_aliases(yaml.safe_load((ROOT / "configs/train.yaml").read_text())) + cfg = {k: asset_root + v[len(PREFIX):] + if isinstance(v, str) and v.startswith(PREFIX + "/") else v + for k, v in cfg.items()} + if not args.inference_only: + bank_path = Path(cfg["triple_teacher_bank_manifest"]) + data = bank_path.read_bytes() + if hashlib.sha256(data).hexdigest() != cfg["triple_teacher_bank_manifest_sha256"]: + raise ValueError("Feature bank manifest does not match the published configuration") + bank = json.loads(data) + # Frozen metadata identities are separate from local model load paths. + dino = bank["branches"]["dinov3"]["feature_contract"] + cfg["dinov3_reference_paths"] = {k: dino[k] for k in ("repo_path", "weight_path")} + for branch in ("vjepa2", "videomae_v2"): + record = bank["branches"][branch] + identity = record.get("feature_contract", record.get("model_identity", {})) + if identity.get("model_id"): + cfg[f"{branch}_reference_model_id"] = identity["model_id"] + if args.resume_checkpoint: + cfg["resume_checkpoint"] = str(args.resume_checkpoint.expanduser().resolve()) + (ROOT / "configs/local.yaml").write_text(yaml.safe_dump(cfg, sort_keys=False)) + (ROOT / "configs/local.env").write_text( + "export VIDEO_GENERATION_ASSET_ROOT=" + shlex.quote(asset_root) + "\n") + print("Local configuration written. Asset paths are excluded from Git.") + +if __name__ == "__main__": + main() diff --git a/14B/scripts/test_cpu.py b/14B/scripts/test_cpu.py new file mode 100644 index 0000000000000000000000000000000000000000..48890b9fee5b859f0e6d77d9cc9fb711d4a2eec1 --- /dev/null +++ b/14B/scripts/test_cpu.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Run existing CPU tensor tests without initializing the historical T5 CUDA default.""" +import os +from pathlib import Path +import sys +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +os.environ['CUDA_VISIBLE_DEVICES'] = '' +os.environ.setdefault('OMP_NUM_THREADS', '2') +os.environ['PYTHONDONTWRITEBYTECODE'] = '1' +sys.dont_write_bytecode = True +sys.path.insert(0, str(ROOT/'code')) +os.chdir(ROOT/'code') +import torch +import pytest + +# Only bypass import-time device discovery. No model weights or CUDA tensors load. +# Original sources remain untouched; the patch is removed before executing tests. +with patch('torch.cuda.current_device', return_value=0): + from trainer.distillation import Trainer + from model.wan_kv_mmd import WanKVMMD + +raise SystemExit(pytest.main([ + '-q', '-p', 'no:cacheprovider', + 'tests/test_triple_partial_geometry.py', + 'tests/test_readable_config.py', + 'tests/test_evaluated_inference.py', + 'tests/test_active_encoder_offload.py', + 'tests/test_no_dinov3_filtered_bank.py', + 'tests/test_no_dinov3_ablation.py', + 'tests/test_release_schedule.py', + 'tests/test_triple_video_feature_bank.py', + 'tests/test_triple_feature_metadata_contract.py', + 'tests/test_triple_pixel_cotangent_accumulation.py', + 'tests/test_video_nystrom_partial_mmd.py', + 'tests/test_multi_representation_video_nystrom_mmd.py', +] + sys.argv[1:])) diff --git a/14B/scripts/train.sh b/14B/scripts/train.sh new file mode 100644 index 0000000000000000000000000000000000000000..15bff30a7078b634c569ed3ab988691cee01ccdd --- /dev/null +++ b/14B/scripts/train.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +extra_args=() +case "${1:-}" in + "") ;; + --smoke) extra_args=(--max_steps 1) ;; + *) echo 'Usage: scripts/train.sh [--smoke]' >&2; exit 2 ;; +esac +[[ $# -le 1 ]] || { echo 'Too many arguments' >&2; exit 2; } +export CUDA_VISIBLE_DEVICES="${GPUS:-${CUDA_VISIBLE_DEVICES:-0,1,2,3,4,5,6,7}}" +LOGDIR="$(realpath -m "${LOGDIR:-$REPRO_ROOT/runs/three-encoder}")" +mkdir -p "$LOGDIR" +cp "$CONFIG" "$LOGDIR/config.yaml" +cd "$REPRO_ROOT/code" +"$PYTHON_BIN" -m torch.distributed.run --standalone --nnodes=1 --nproc-per-node=8 train.py \ + --config_path "$LOGDIR/config.yaml" --logdir "$LOGDIR" \ + --disable-wandb --no_visualize "${extra_args[@]}" 2>&1 | tee -a "$LOGDIR/train.log" diff --git a/ANONYMIZATION.txt b/ANONYMIZATION.txt new file mode 100644 index 0000000000000000000000000000000000000000..39b5b77dcc69abcf5ac2a1624b31293014a61681 --- /dev/null +++ b/ANONYMIZATION.txt @@ -0,0 +1,35 @@ +Anonymous source release + +Removed author names, affiliations, contact details, personal repository and +project links, tracking badges, the identifying paper PDF, shared-chat links, +cluster-specific paths, private collaboration acknowledgments, original source +fingerprint manifests, and Git metadata/history. Project-specific module names, +class names and environment variables now use neutral names. + +Hosted model repository URLs and hard-coded repository IDs were removed. +Model and tokenizer loaders require existing local directories. The legacy-named +VideoMAEv2 preparation utility stages an existing local snapshot with --source-dir; +it does not download models. Existing valid local manifests can be preserved for +frozen-bank compatibility. New manifests require matching reference assets and +configuration identities. Local preparation reads bank identities into private +generated configuration files; do not include those files or model assets when +sharing this source package. + +Third-party source attribution and license notices are retained. Scientific +training settings and reported results are retained. This package contains source +code, configurations, example prompts and documentation, without model weights +or training data. + +Verification passed: +- Python syntax: 180 files; shell syntax: 6 scripts. +- Both training recipes passed the existing source/configuration checks. +- Both local snapshot tools passed staging, occupied-output rejection, missing + source rejection, nested-directory rejection and manifest-preservation checks. +- Model adapters passed local-only loading and missing-path checks with mocks. +- Both preparation scripts passed path relocation and bank-identity checks. +- Final content and archive checks found no targeted author/project identifiers, + personal links, hosted model repository addresses, Git metadata or paper PDF. + +No GPU training or model inference was run for this source anonymization task. +Previously public source code and retained scientific results can still be matched +to public material; removing identifying metadata cannot guarantee anonymity. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..457eb0a7a891047cac3d7665d1c1909a905f8de3 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +--- +license: apache-2.0 +tags: +- source-code +- video-generation +- pytorch +--- + + +# Anonymous Video Generation + +Anonymous source-code repository. + +[Model checkpoints](https://huggingface.co/Elastic-forcing/anonymous-video-generation) + +[1.3B Training & Inference](1.3B/README.md) · [14B Training & Inference](14B/README.md) + ++ +--- + + +Anonymous Video Generation post-trains few-step autoregressive video generators by matching generated and reference videos in frozen representation spaces. It combines a hybrid Nyström–Monte Carlo distribution estimator with gradient replay. Only the generator is optimized; diffusion score models are not needed during post-training. +Both released recipes use the same three-encoder MMD objective; no auxiliary frame-level distribution objective is enabled or included. + +Read the implementation through the [paper-to-code guide](docs/paper-to-code.md), which maps the paper's notation to configuration fields, the training loop, the hybrid MMD objective, and gradient replay. + +## Models + +Each model directory contains its own implementation, configuration, dependency list, asset guide, and training/inference scripts. Run its commands from that directory. + +| Release | Frozen feature encoders | Training | Inference | Guide | +|---|---|---|---|---| +| **1.3B** | DINOv3 + V-JEPA 2 + VideoMAEv2 | 4 GPUs, 150 updates | 4 causal steps per chunk | [Training and inference](1.3B/README.md) | +| **14B** | DINOv3 + V-JEPA 2 + VideoMAEv2 | 8 GPUs, 80 updates | 4 trained steps + terminal zero-step cleanup | [Training and inference](14B/README.md) | + +Both configurations use a distribution batch of 256 generated videos, a differentiation batch of 128 videos, and a reference minibatch of 128 videos. They generate 81-frame, 832 × 480 videos at 16 FPS. + +The 1.3B three-encoder configuration achieves **84.25 VBench Total** in the paper. The 14B release targets the **step-80 checkpoint**, retaining the first 80 updates of the original **200-step cosine learning-rate schedule**. This is the **three-encoder, 8,000-reference-video model used in the human and VLM evaluations**; see [evaluated settings](14B/docs/evaluated-settings.md). + +```text +anonymous-video-generation-code/ +├── 1.3B/ # Three-encoder training and inference +│ ├── code/ +│ ├── configs/ +│ ├── scripts/ +│ └── README.md +├── 14B/ # Three-encoder training and step-80 inference +│ ├── code/ +│ ├── configs/ +│ ├── scripts/ +│ └── README.md +└── docs/ # Implementation and validation guides +``` + +## Getting Started + +```bash +git clone https://huggingface.co/Elastic-forcing/anonymous-video-generation-code +cd anonymous-video-generation-code + +# Choose one model, then follow its README. +cd 14B +# Or: cd 1.3B +``` + +See the [1.3B asset guide](1.3B/docs/assets.md) or [14B asset guide](14B/docs/assets.md) for model and reference-data requirements. This release contains source code; trained generator weights, the 14B ODE initialization, and frozen reference assets are stored separately and are not bundled here. + +Validation is documented separately for [1.3B](1.3B/docs/validation.md) and [14B](14B/docs/validation.md). +The cleaned source-layout and CPU objective checks are described in [source validation](docs/readability-changes.md). + +## Acknowledgments + +This implementation builds on [Self-Forcing](https://github.com/guandeh17/Self-Forcing) and [Wan2.1](https://github.com/Wan-Video/Wan2.1). We also thank the authors of [DINOv3](https://github.com/facebookresearch/dinov3), [V-JEPA 2](https://github.com/facebookresearch/vjepa2), [VideoMAEv2](https://github.com/OpenGVLab/VideoMAEv2), and [VBench](https://github.com/Vchitect/VBench) for making their work available. Bundled source retains its upstream license notices; model weights and datasets follow their respective upstream terms. diff --git a/docs/first-step-comparison.md b/docs/first-step-comparison.md new file mode 100644 index 0000000000000000000000000000000000000000..bcc275f2da0bf3ae07a366fecb6cd063e6b6da27 --- /dev/null +++ b/docs/first-step-comparison.md @@ -0,0 +1,76 @@ +> Historical validation record: this comparison predates removal of the disabled auxiliary loss branches. Its source identifiers and field counts describe that earlier snapshot. Use scripts/check_readability.py and scripts/check_loss_parity.py for the cleaned release. + +# Readability refactor: first optimizer-step comparison + +The refactored 1.3B three-encoder code completed one full optimizer update on four H200 GPUs. Initial state, sampled inputs, forward features, loss and feature gradients match the earlier public-source run bitwise. The first recorded numerical difference is in `encoder_and_vae_backward`. Compare against the same-source repeated-run control below. + +## Source and run identity + +- Refactor base: `7985874ff49de326b8350a66516c50645a604f45`; the GPU comparison used a source snapshot captured before committing the refactor. +- Tested source snapshot manifest SHA-256: `a806daa28aa2aa75c5adbe1e6bb930dbae822aee07cbfaf830980f6f8111b001`. +- Previous public trace commit: `985ebec542b1c2bdb7af95a4a7404b3069cc15e7`. Its code tree is byte-identical to the pre-refactor 1.3B code tree: `60b92b4230405641d8d65a048d7b7cf842a653cf`. +- Original-source control commit: `e8ec87e10c4825dfaaec993643724c5e8a2e28e9`. +- Base seed **1862643**, with rank seeds **1862643–1862646**, matching the recovered historical effective seed. A legacy setting of `seed: 0` requests seed randomization and is not the effective seed. +- Distribution / differentiation / reference batches: **256 / 128 / 128**. Four ranks, 64 forward samples and 32 backward samples per rank, microbatch size 1. +- Encoder weights **10:1:1**, loss divisor **3**, fixed-reference / Monte Carlo reference weights **2:1**. +- Same physical model/reference assets and four physical GPUs as the earlier runs. Independently queried software versions match. +- All **173** readable input configuration fields resolve to the previous values in the same order. The new config loader and `--training-updates 1` alias were exercised. The configured cosine schedule remains steps 0–125, initial learning-rate setting 2.5e-6, floor 5e-7. +- Observation hooks use the renamed methods and otherwise preserve the earlier observer. No production computation was changed for this run. + +## Scalar observations + +| Quantity | Previous public source | Readable source | Original A | Original B | +|---|---:|---:|---:|---:| +| Loss | 3.994718074798584 | 3.994718074798584 | 3.994718074798584 | 3.994718074798584 | +| Gradient norm before clipping | 48.80884552001953 | 48.82303619384766 | 48.82280349731445 | 48.81880187988281 | + +## Full-vector differences + +| Quantity | Public → readable | Original A → readable | Original A → original B | +|---|---:|---:|---:| +| Latent gradient — relative L2 | 0.507891% | 0.508414% | 0.508167% | +| Latent gradient — cosine similarity | 0.999987102368 | 0.99998707571 | 0.999987088324 | +| Latent gradient — maximum absolute difference | 4.95910644531e-05 | 6.103515625e-05 | 6.103515625e-05 | +| Generator gradient before clipping — relative L2 | 0.299963% | 0.301090% | 0.312999% | +| Generator gradient before clipping — cosine similarity | 0.999995544683 | 0.999995467272 | 0.999995104525 | +| Generator gradient before clipping — maximum absolute difference | 0.0146789550781 | 0.00958251953125 | 0.0114288330078 | +| Generator gradient after clipping — relative L2 | 0.298507% | 0.301089% | 0.312905% | +| Generator gradient after clipping — cosine similarity | 0.999995544684 | 0.999995467273 | 0.999995104526 | +| Generator gradient after clipping — maximum absolute difference | 0.00274252891541 | 0.0019583106041 | 0.00238353013992 | +| Actual Adam parameter update — relative L2 | 7.580502% | 7.667867% | 7.707839% | +| Actual Adam parameter update — cosine similarity | 0.997126798637 | 0.997060191614 | 0.99702946052 | +| Actual Adam parameter update — maximum absolute difference | 5.00679016113e-06 | 5.00679016113e-06 | 5.00679016113e-06 | + +Relative L2 means `||right - left||₂ / ||left||₂`, accumulated across all four ranks. Adam update differences compare `weights_after - initial_weights`; they are not normalized by the much larger model weight norm. Initial parameters are byte-identical across all variants. + +## Exact comparison with the previous public source + +| Recorded stage | Bitwise equality | +|---|---| +| effective_configuration | Exact | +| initial_weights | Exact | +| initial_rng | Exact | +| forward_inputs_and_features | Exact | +| mmd_result | Exact | +| encoder_and_vae_backward | Different (645/2304 tensors) | +| gradients_before_clip | Different (918/918 tensors) | +| gradients_after_clip | Different (918/918 tensors) | +| optimizer_state | Different (1836/2754 tensors) | +| updated_weights | Different (916/3300 tensors) | +| post_step_rng | Exact | + +All **424 tensor archives** (106 per rank) were compared, including all 256 forward samples and 128 selected backward samples. Equality checks compare raw bytes, including signed zeros. Configuration comparison excludes only output-directory and input-file-location fields. Timings and the final process-completion event are excluded from numerical comparison. All non-timing tensor values and structural fields remain compared. + +## Interpretation and scope + +The first recorded numerical difference is in `encoder_and_vae_backward`. The earlier same-source repeat has nonidentical encoder/VAE backward, generator gradients and Adam updates. The control demonstrates backward execution variability in this GPU configuration; it does not identify a specific nondeterministic kernel or establish a statistical tolerance from one control pair. + +The public-to-readable gradient relative L2 difference is 0.9583 times the measured same-source control difference; for the Adam update it is 0.9835 times. These ratios contextualize the observed discrepancies; they do not define a pass/fail tolerance or guarantee matching long-run trajectories. + +The readable-source run completed backward, clipping, Adam, native checkpoint saving, and all four ranks successfully (exit code 0). The previous public run also completed successfully. Original A completed the first update and every compared trace, then failed at checkpoint saving because its audit output directory was absent; original B completed normally. + +This comparison concerns reruns under the historical settings, not tensors captured during the original historical first step. It covers 1.3B only and does not evaluate 14B training, all training updates, generated-video quality, or VBench. + +## Software environment + +`{"cuda": "12.1", "cudnn": 90100, "flash_attn": "2.7.4.post1", "numpy": "1.26.4", "python": "3.10.12", "torch": "2.5.1+cu121", "torchvision": "0.20.1+cu121", "transformers": "4.53.3"}` diff --git a/docs/paper-to-code.md b/docs/paper-to-code.md new file mode 100644 index 0000000000000000000000000000000000000000..323c3be95be819ae021220db1b6dec0ffe2f43bd --- /dev/null +++ b/docs/paper-to-code.md @@ -0,0 +1,158 @@ +# Reading the Anonymous Video Generation implementation + +Start with the training update, then follow the loss and replay calls. The paper's +Sections 3.1–3.3 describe the objective and the separation between estimating a +distribution and differentiating selected rollouts. Appendix C.4 and Algorithm 1 +describe the replay procedure. The notes below distinguish that mathematical +presentation from the numerical choices retained in this release. + +Both releases contain one active distribution objective: the three-encoder MMD described below. The historical code/video_fd directory name is retained because its encoder adapters and replay VJPs are part of that active pipeline; it does not select an additional FD loss. + +## Reading order + +The two model directories have the same main entry points and both use DINOv3, +V-JEPA 2 and VideoMAEv2. Start with 1.3B when reading the shared flow; the 14B +release adds its evaluated step-80 schedule and memory-management details. + +| Read | Purpose | 1.3B | 14B | +|---|---|---|---| +| Configuration | Batch sizes, encoders, loss weights, initialization and optimizer | [three_encoder.yaml](../1.3B/configs/three_encoder.yaml) | [train.yaml](../14B/configs/train.yaml) | +| Training entry | Load configuration and start distributed training | [train.py](../1.3B/code/train.py) | [train.py](../14B/code/train.py) | +| `DistributionMatchingTrainer` | Assemble one complete update: collect rollouts, compute feature gradients, replay and update the generator | [trainer/distribution_matching.py](../1.3B/code/trainer/distribution_matching.py) | [trainer/distribution_matching.py](../14B/code/trainer/distribution_matching.py) | +| `FeatureGradientReplay` | Gather the distribution batch and compute gradients with respect to selected features | [trainer/feature_gradient_replay.py](../1.3B/code/trainer/feature_gradient_replay.py) | [trainer/feature_gradient_replay.py](../14B/code/trainer/feature_gradient_replay.py) | +| `MultiEncoderMMD` | Combine the independently scaled encoder losses | [losses/multi_encoder_mmd.py](../1.3B/code/losses/multi_encoder_mmd.py) | [losses/multi_encoder_mmd.py](../14B/code/losses/multi_encoder_mmd.py) | +| `VideoNystromMMD` | Evaluate generated–generated repulsion and hybrid reference attraction | [losses/video_nystrom_mmd.py](../1.3B/code/losses/video_nystrom_mmd.py) | [losses/video_nystrom_mmd.py](../14B/code/losses/video_nystrom_mmd.py) | +| `DistributionMatchingModel` | Connect rollout generation, frozen encoders, VAE decoding and generator replay | [model/distribution_matching.py](../1.3B/code/model/distribution_matching.py) | [model/distribution_matching.py](../14B/code/model/distribution_matching.py) | + +The previous module paths remain compatibility shims. Read the implementations +linked above rather than the shims. Low-level network definitions in `wan/` are +useful after the training flow is clear. + +In `DistributionMatchingTrainer`, start at `_run_distribution_matching_step` and +follow `_collect_rollout_microbatch`. In `DistributionMatchingModel`, the corresponding +stages are `collect_rollout_features`, `prepare_latent_gradients` and +`replay_generator_gradients`. + +## Three different batch sizes + +| Paper symbol | Meaning | Released settings | +|---|---|---| +| $B$ | **Distribution batch:** generated videos that participate in the loss | 256 globally; 64 per GPU on 1.3B, 32 per GPU on 14B | +| $b$ | **Differentiation batch:** generated videos replayed for generator backpropagation | 128 globally; 32 per GPU on 1.3B, 16 per GPU on 14B | +| $B_{\mathrm{ref}}$ | **Reference minibatch:** stored reference-video features used by the Monte Carlo term | 128 globally | + +The readable configuration calls these `distribution_batch_size`, +`distribution_samples_per_rank`, `differentiation_samples_per_rank` and +`reference_minibatch_size`. `differentiation_gradient_scale` is $B/b$; +`encoder_loss_divisor` is the final loss divisor. `utils/config.py` translates +these names into the compatible runtime fields without changing their values. + +A microbatch is the smaller group processed at once; it is not $B$ or $b$. +All 256 generated videos affect the loss, including the 128 whose generator +computation is not replayed. Computing independent MMD losses on microbatches +would omit their cross-microbatch interactions. + +Compatibility names can be misleading: `student_reference` means the full +**generated** population, while `current_teacher` means the **reference +minibatch**. Reference features are read from a frozen feature bank; this path +does not run an online diffusion teacher. + +## One optimizer update + +```text +Prompts + noise + → generate B videos without retained generator graphs + → extract frozen-encoder features and retain replay state + → gather the complete distribution batch across GPUs + → combine persistent Nyström statistics with a reference minibatch + → compute feature gradients for the b selected videos + → replay encoders: feature gradients → pixel gradients + → replay VAE: pixel gradients → latent gradients + → replay generator: latent gradients → parameter gradients + → accumulate contributions, clip gradients, update once +``` + +A **cotangent** is the incoming gradient used in a vector–Jacobian product. For +example, a feature cotangent is $g_i^e=\partial L/\partial z_i^e$; replay passes it +back through the corresponding encoder and generator. Frozen encoder and VAE +parameters are not optimized, but derivatives with respect to their inputs are +still required. Replay restores the original noise, random choices and segment +inputs so that the gradient is paired with the intended computation. + +Two scaling factors have different purposes. The configured $B/b=2$ compensates +for replaying only part of the distribution batch. Multiplication by the number +of GPUs compensates for FSDP averaging parameter gradients across ranks. + +## Connecting the loss to the paper + +Section 3.1 compares generated and reference distributions in frozen encoder +spaces. Section 3.2 combines two estimates of the reference kernel mean: + +- **Persistent Nyström mean:** landmark kernels multiplied by precomputed + coefficients, as in Equation (8) and Appendix C.2, Equations (18)–(21). +- **Monte Carlo mean:** average kernel similarity to a sampled reference + minibatch, as in Equation (6). + +The released Nyström:Monte Carlo weights are 2:1, so the mixture in Equation (9) +has $\alpha_e=1/3$. The tensor saved under the legacy name `alpha` is instead +the **Nyström coefficient vector**, denoted $\boldsymbol\beta_e$ in Equation +(19). These are different quantities. + +For each encoder, the released implementation evaluates + +$$ +M_e=\frac{1}{B^2}\sum_{i,j}k_e(z_i,z_j) +-\frac{2}{B}\sum_i\left[\frac{2}{3}m^e_{\mathrm{Nys}}(z_i) ++\frac{1}{3}\widehat m^e_{\mathrm{MC}}(z_i)\right]+C_e, +$$ + +where $C_e$ is the retained reference–reference contribution. It then applies +detached per-encoder loss scaling: + +$$ +L=\frac{1}{d}\sum_e w_e +\frac{M_e}{\operatorname{stopgrad}(M_e)+\epsilon}. +$$ + +Here $d$ is the explicit aggregation divisor; it is not inferred from the sum +of the weights. Both 1.3B and the human/VLM-evaluated 14B use +DINOv3:V-JEPA 2:VideoMAEv2 = 10:1:1. Both configurations use $d=3$ and +$\epsilon=10^{-4}$. + +## Details to preserve when reviewing or refactoring + +- **Self-pairs and constants.** Paper Equations (11)/(13) use the off-diagonal + denominator $B(B-1)$ and omit reference–reference constants. This release + retains self-pairs with denominator $B^2$ and retains the constants. Although + a reference constant has no direct generator derivative, it affects the + detached normalization denominator above. Changing either convention changes + the released numerical objective. +- **Replay selection.** Algorithm 1 uniformly samples a subset. The released + implementation selects a fixed suffix of each rank's generated batch and + creates feature-gradient leaves for those selected samples. Their gradients + still include interactions with the full distribution batch. The conditional + unbiasedness statement in Equation (27) assumes uniform subset sampling; + it should not be claimed for this fixed-suffix implementation. +- **VAE temporal derivatives.** The active model paths call `replay_decoder_vjp`, + which carries temporal-cache values but detaches their gradients between latent + frames. This preserves decoded values while truncating cross-frame cache + derivatives. The 14B tree also provides `replay_decoder_vjp_exact` and + `wan_recurrent_decoder_vjp.py`, which propagate future-state cotangents; their + presence does not mean the released training path selects them. Appendix C.4 + distinguishes prescribed gradient truncation from exact replay. +- **Compatibility.** Resource-pack keys, checkpoint parameter names and legacy + configuration fields remain supported. Their historical wording is not an + additional training objective or a requirement to instantiate score models. + +## Instrumentation and compatibility names + +Old imports and direct calls remain available as aliases. New training code +calls the readable method names, so observers that intercept methods should +patch `_collect_rollout_microbatch`, `_run_distribution_matching_step`, +`collect_rollout_features`, `prepare_latent_gradients`, and +`replay_generator_gradients`. Patching an old method alias does not intercept +calls dispatched through the new name. + +Edit the public YAML when changing experiment settings. `prepare_config.py` +resolves those readable fields and writes the legacy runtime schema to +`configs/local.yaml`; this preserves resource and checkpoint identities. diff --git a/docs/readability-changes.md b/docs/readability-changes.md new file mode 100644 index 0000000000000000000000000000000000000000..dd52ac9bae41d996261ccad68c71729223718612 --- /dev/null +++ b/docs/readability-changes.md @@ -0,0 +1,44 @@ +# Source cleanup and validation + +The public 1.3B and 14B releases now expose the training path used by the +reported three-encoder experiments: DINOv3, V-JEPA 2 and VideoMAEv2 features are +combined by the hybrid Nyström–Monte Carlo MMD objective, and selected feature +gradients are replayed to the generator. + +Earlier source snapshots also contained optional frame-level and plain +Fréchet-distance objectives that were disabled in both released recipes. Their +models, controllers, preprocessing tools, configuration fields and replay +helpers have been removed so that the public tree follows the evaluated path. + +## Names that intentionally remain + +The code/video_fd directory remains part of both releases. Despite its +historical directory name, it contains frozen encoder adapters, temporal +sampling and decoder/encoder VJP utilities used by the active three-encoder MMD +pipeline. + +Checkpoint methods and payload keys containing fd also remain where changing +them would break loading existing three-encoder checkpoints. They describe the +saved distribution-matching state; they do not enable a second loss branch. + +## Validation + +Run the root checks from the repository root in an environment with the release dependencies installed. They are self-contained and do not require an old Git commit: + + python3 scripts/check_readability.py + python3 scripts/check_loss_parity.py --release 1.3B + python3 scripts/check_loss_parity.py --release 14B + python3 1.3B/scripts/test_cpu.py + python3 14B/scripts/test_cpu.py + +check_readability.py verifies the cleaned file layout, rejects stale auxiliary +loss references and configuration keys, parses every Python source, checks +shell syntax, and confirms the main scientific settings of both public recipes. +check_loss_parity.py independently composes the three encoder branches and +checks the active MMD values, metadata, detached normalization and feature +gradients on CPU. + +The first optimizer-step report in first-step-comparison.md records a historical +comparison made before this cleanup. It remains useful evidence for the active +training calculation, but it is not a source-identity claim for the cleaned +tree. diff --git a/scripts/check_loss_parity.py b/scripts/check_loss_parity.py new file mode 100644 index 0000000000000000000000000000000000000000..e92076cce37b698e0df2af5cdf00e966721ed065 --- /dev/null +++ b/scripts/check_loss_parity.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Validate the active three-encoder MMD composition and gradients on CPU.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import torch + +sys.dont_write_bytecode = True + + +def assert_same(left: torch.Tensor, right: torch.Tensor, label: str) -> None: + torch.testing.assert_close(left, right, rtol=0, atol=0, msg=label) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release", choices=("1.3B", "14B"), required=True) + args = parser.parse_args() + + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(root / args.release / "code")) + + from losses.multi_encoder_mmd import ( + MultiEncoderMMD, + normalize_mmd_with_detached_scale, + ) + from losses.video_nystrom_mmd import ( + VideoNystromMMD, + build_video_nystrom_reference, + ) + + shapes = { + "dinov3": (1, 2), + "vjepa2": (2, 3), + "videomae_v2": (1, 4), + } + + def objective() -> MultiEncoderMMD: + branches = {} + for index, (name, shape) in enumerate(shapes.items()): + generator = torch.Generator().manual_seed(9000 + index) + reference = torch.randn(12, *shape, generator=generator) + pack = build_video_nystrom_reference( + reference, + num_landmarks=5, + metadata={ + "feature": {"name": name, "matrix_shape": list(shape)}, + "reference": {"dataset": "active-mmd-check"}, + }, + bandwidth=1.7, + kmeans_iterations=3, + ridge=1.0e-5, + chunk_size=16, + ) + branches[name] = VideoNystromMMD( + pack, + kernel_chunk_size=3, + attraction_weight=2.0, + current_teacher_attraction_enabled=True, + current_teacher_attraction_weight=1.0, + ) + return MultiEncoderMMD( + branches, + weights={"dinov3": 10.0, "vjepa2": 1.0, "videomae_v2": 1.0}, + normalization_eps=1.0e-4, + aggregation_divisor=3.0, + ) + + generator = torch.Generator().manual_seed(1862643) + for mode in ("full", "partial"): + combined = objective() + generated = { + name: torch.randn(4, *shape, generator=generator) + for name, shape in shapes.items() + } + teacher = { + name: torch.randn( + 4 if mode == "full" else 2, *shape, generator=generator) + for name, shape in shapes.items() + } + + if mode == "full": + leaves = { + name: value.clone().requires_grad_() + for name, value in generated.items() + } + loss, metadata = combined( + leaves, current_teacher=teacher, stage=False) + else: + indices = torch.tensor([0, 2]) + leaves = { + name: value.index_select(0, indices).clone().requires_grad_() + for name, value in generated.items() + } + loss, metadata = combined( + gradient_students=leaves, + student_reference=generated, + current_teacher=teacher, + gradient_student_indices=indices, + stage=False, + ) + + expected_terms = [] + for name, branch in combined.branches.items(): + if mode == "full": + raw, _ = branch( + leaves[name], current_teacher=teacher[name], stage=False) + else: + raw, _ = branch( + gradient_students=leaves[name], + student_reference=generated[name], + current_teacher=teacher[name], + gradient_student_indices=indices, + stage=False, + ) + normalized, denominator = normalize_mmd_with_detached_scale( + raw, combined.normalization_eps) + expected_terms.append(combined.branch_weights[name] * normalized) + assert_same( + metadata["branches"][name]["raw_mmd"], + raw.detach(), + f"{mode} {name} raw MMD", + ) + assert_same( + metadata["branches"][name]["normalization_denominator"], + denominator.detach(), + f"{mode} {name} normalization denominator", + ) + + expected = torch.stack(expected_terms).sum() / 3.0 + assert_same(loss, expected, f"{mode} combined MMD") + observed_gradients = torch.autograd.grad( + loss, tuple(leaves.values()), retain_graph=True) + expected_gradients = torch.autograd.grad( + expected, tuple(leaves.values())) + for name, observed, reference in zip( + leaves, observed_gradients, expected_gradients): + assert_same(observed, reference, f"{mode} {name} feature gradient") + assert torch.isfinite(observed).all() and observed.norm() > 0 + print( + f"PASS {args.release} {mode}: weighted three-encoder MMD, " + "metadata and feature gradients") + + eps = 1.0e-4 + for value in (-2.0e-4, 0.0, 1.0e-8, 0.1, 7.0): + observed_input = torch.tensor( + value, dtype=torch.float64, requires_grad=True) + expected_input = observed_input.detach().clone().requires_grad_(True) + observed, observed_denominator = normalize_mmd_with_detached_scale( + observed_input, eps) + expected_denominator = expected_input.detach() + eps + expected = expected_input / expected_denominator + assert_same(observed.detach(), expected.detach(), "normalization value") + assert_same( + observed_denominator, expected_denominator, + "normalization denominator") + observed_gradient, = torch.autograd.grad(observed, observed_input) + expected_gradient, = torch.autograd.grad(expected, expected_input) + assert_same( + observed_gradient, expected_gradient, "normalization gradient") + print(f"PASS {args.release}: detached MMD normalization behavior") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_readability.py b/scripts/check_readability.py new file mode 100644 index 0000000000000000000000000000000000000000..140a34565febd30fba4d5a2f6cf3aa260ccd7306 --- /dev/null +++ b/scripts/check_readability.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Check the cleaned release layout, public recipes, and source syntax. + +This check is self-contained. It does not require the historical Git commit +used by the earlier readability-only comparison. +""" + +from __future__ import annotations + +import ast +import importlib.util +from pathlib import Path +import subprocess +import sys + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +sys.dont_write_bytecode = True + +RELEASES = { + "1.3B": { + "recipe": "three_encoder.yaml", + "required": ( + "code/model/generation.py", + "code/model/distribution_matching.py", + "code/trainer/distribution_matching.py", + "code/trainer/feature_gradient_replay.py", + "code/losses/multi_encoder_mmd.py", + "code/losses/video_nystrom_mmd.py", + "code/video_fd/replay/replay_video_encoder_vjp.py", + ), + "removed": ( + "code/losses/replay_video_fd.py", + "code/losses/video_fd.py", + "code/losses/video_nystrom_reference_bank.py", + "code/model/fd.py", + "code/model/frame_fd_head.py", + "code/trainer/fresh_frame_fd_gradcache.py", + "code/trainer/two_level_fidel.py", + "code/utils/chunked_vae_decode.py", + "code/utils/fd_feature_grad.py", + "code/utils/video_chunking.py", + "code/utils/video_feature_extract.py", + ), + "expected": { + "total_batch_size": 256, + "expected_current_teacher_samples": 128, + "fresh_video_local_samples": 64, + "fresh_video_generator_grad_local_samples": 32, + "triple_dinov3_weight": 10.0, + "triple_vjepa2_weight": 1.0, + "triple_videomae_v2_weight": 1.0, + "triple_aggregation_divisor": 3.0, + "max_steps": 150, + "generator_lr_cosine_end_step": 125, + "distribution_loss": "wan_kv_mmd", + }, + }, + "14B": { + "recipe": "train.yaml", + "required": ( + "code/model/generation_base.py", + "code/model/distribution_matching.py", + "code/trainer/distribution_matching.py", + "code/trainer/feature_gradient_replay.py", + "code/losses/multi_encoder_mmd.py", + "code/losses/video_nystrom_mmd.py", + "code/video_fd/replay/replay_video_encoder_vjp.py", + ), + "removed": ( + "code/losses/replay_video_fd.py", + "code/losses/video_fd.py", + "code/losses/video_nystrom_reference_bank.py", + "code/model/fd.py", + "code/model/frame_fd_head.py", + "code/tools/precompute_frame_fd_stats.py", + "code/tools/precompute_vjepa2_fidel_reference_bank.py", + "code/trainer/fresh_frame_fd_gradcache.py", + "code/trainer/two_level_fidel.py", + "code/utils/chunked_vae_decode.py", + "code/utils/fd_feature_grad.py", + "code/utils/video_chunking.py", + "code/utils/video_feature_extract.py", + ), + "expected": { + "total_batch_size": 256, + "expected_current_teacher_samples": 128, + "fresh_video_local_samples": 32, + "fresh_video_generator_grad_local_samples": 16, + "triple_active_branches": [ + "dinov3", "vjepa2", "videomae_v2"], + "triple_dinov3_weight": 10.0, + "triple_vjepa2_weight": 1.0, + "triple_videomae_v2_weight": 1.0, + "triple_aggregation_divisor": 3.0, + "max_steps": 80, + "lr_schedule_total_steps": 200, + "distribution_loss": "wan_kv_mmd", + }, + }, +} + +# These names belonged only to the removed auxiliary objectives. Historical +# checkpoint names such as fd_state_dict and the active video_fd encoder package +# deliberately remain allowed. +REMOVED_TOKENS = ( + "frame_fd_", + "fidel_", + "fd_repr_", + "fd_guidance_scale", + "fresh_frame_fd_gradcache", + "losses.replay_video_fd", + "losses.video_fd", + "from model.fd import", + "import model.fd", + "model.frame_fd_head", + "precompute_frame_fd_stats", + "precompute_vjepa2_fidel_reference_bank", + "trainer.two_level_fidel", + "utils.chunked_vae_decode", + "utils.fd_feature_grad", + "utils.video_chunking", + "utils.video_feature_extract", +) + +REMOVED_CONFIG_KEYS = { + "fd_decode_frames", + "fd_ema_beta", + "fd_exclude_first_frame", + "fd_feature_aggregation", + "fd_fid_norm_eps", + "fd_guidance_scale", + "fd_num_frames", + "fd_queue_size", +} +REMOVED_CONFIG_PREFIXES = ("frame_fd_", "fidel_", "fd_repr_") + + +def load_config_helper(release: str): + path = ROOT / release / "code/utils/config.py" + spec = importlib.util.spec_from_file_location( + f"release_config_{release.replace('.', '_')}", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def nested_keys(value): + if isinstance(value, dict): + for key, child in value.items(): + yield str(key) + yield from nested_keys(child) + elif isinstance(value, list): + for child in value: + yield from nested_keys(child) + + +def check_release(release: str, settings: dict) -> tuple[int, int]: + release_root = ROOT / release + + for relative in settings["required"]: + assert (release_root / relative).is_file(), ( + f"missing active release file: {release}/{relative}") + for relative in settings["removed"]: + assert not (release_root / relative).exists(), ( + f"removed auxiliary-loss file is still present: " + f"{release}/{relative}") + + source_paths = sorted((release_root / "code").rglob("*.py")) + for path in source_paths: + text = path.read_text() + ast.parse(text, filename=str(path.relative_to(ROOT))) + for token in REMOVED_TOKENS: + assert token not in text, ( + f"stale auxiliary-loss reference {token!r} in " + f"{path.relative_to(ROOT)}") + + shell_paths = sorted((release_root / "scripts").rglob("*.sh")) + for path in shell_paths: + subprocess.run(["bash", "-n", str(path)], check=True) + + yaml_paths = sorted((release_root / "configs").rglob("*.yaml")) + yaml_paths += sorted((release_root / "code/configs").rglob("*.yaml")) + for path in yaml_paths: + payload = yaml.safe_load(path.read_text()) + stale = sorted({ + key for key in nested_keys(payload) + if key in REMOVED_CONFIG_KEYS + or key.startswith(REMOVED_CONFIG_PREFIXES) + }) + assert not stale, ( + f"stale auxiliary-loss config keys in {path.relative_to(ROOT)}: " + f"{', '.join(stale)}") + + recipe_path = release_root / "configs" / settings["recipe"] + public = yaml.safe_load(recipe_path.read_text()) + resolved = load_config_helper(release).resolve_config_aliases(public) + for key, expected in settings["expected"].items(): + actual = resolved.get(key) + assert actual == expected, ( + f"{recipe_path.relative_to(ROOT)}: {key}={actual!r}, " + f"expected {expected!r}") + + print( + f"PASS {release}: active three-encoder recipe, cleaned layout, " + f"{len(source_paths)} Python files and {len(shell_paths)} shell files") + return len(source_paths), len(shell_paths) + + +def main() -> None: + python_count = 0 + shell_count = 0 + for release, settings in RELEASES.items(): + parsed, checked_shell = check_release(release, settings) + python_count += parsed + shell_count += checked_shell + + root_scripts = sorted((ROOT / "scripts").glob("*.py")) + for path in root_scripts: + ast.parse(path.read_text(), filename=str(path.relative_to(ROOT))) + print( + f"PASS root scripts: {len(root_scripts)} Python files parse; " + f"total release syntax checks {python_count} Python / " + f"{shell_count} shell files") + + +if __name__ == "__main__": + main()