"""Opt-in forward hooks used by the offline Full-DiT teacher builder.""" from __future__ import annotations from typing import Any import torch from .schema import CANDIDATE_BLOCK_IDS def clone_bf16_cpu(tensor: torch.Tensor) -> torch.Tensor: return tensor.detach().to(device="cpu", dtype=torch.bfloat16).contiguous() class PredictorV4TeacherCapture: """Capture head input and clean pre-KV features without modifying Wan. The builder explicitly marks denoising and clean calls. Hooks therefore do not infer call order and cannot accidentally record the final clean pass as a fifth denoising step. """ def __init__( self, model: torch.nn.Module, block_ids: tuple[int, ...] = CANDIDATE_BLOCK_IDS, ) -> None: self.model = model self.block_ids = tuple(int(value) for value in block_ids) self._handles: list[Any] = [] self._mode: str | None = None self._active_step: int | None = None self._final_hidden: torch.Tensor | None = None self._clean_features: dict[int, torch.Tensor] = {} def __enter__(self) -> "PredictorV4TeacherCapture": if self._handles: raise RuntimeError("capture hooks are already installed") self._handles.append( self.model.head.register_forward_pre_hook(self._head_pre_hook, with_kwargs=True) ) for block_id in self.block_ids: try: projection = self.model.blocks[block_id].self_attn.k except IndexError as exc: raise ValueError(f"Wan model does not contain block {block_id}") from exc self._handles.append( projection.register_forward_pre_hook( self._make_clean_k_pre_hook(block_id), with_kwargs=True, ) ) return self def __exit__(self, exc_type, exc, traceback) -> bool: for handle in self._handles: handle.remove() self._handles.clear() self._mode = None self._active_step = None self._final_hidden = None self._clean_features.clear() return False def _head_pre_hook(self, module, args, kwargs) -> None: if self._mode != "denoise": return if self._final_hidden is not None: raise RuntimeError(f"head input captured twice for step {self._active_step}") if not args or not torch.is_tensor(args[0]): raise RuntimeError("Wan head did not receive a tensor hidden state") self._final_hidden = clone_bf16_cpu(args[0]) def _make_clean_k_pre_hook(self, block_id: int): def hook(module, args, kwargs) -> None: if self._mode != "clean": return if block_id in self._clean_features: raise RuntimeError(f"clean block {block_id} was captured twice") if not args or not torch.is_tensor(args[0]): raise RuntimeError(f"block {block_id} self_attn.k lacks a tensor input") self._clean_features[block_id] = clone_bf16_cpu(args[0]) return hook def begin_denoise(self, step_id: int) -> None: if self._mode is not None: raise RuntimeError(f"cannot begin denoise while capture mode is {self._mode}") self._mode = "denoise" self._active_step = int(step_id) self._final_hidden = None def finish_denoise(self) -> torch.Tensor: if self._mode != "denoise": raise RuntimeError("finish_denoise called without begin_denoise") value = self._final_hidden step_id = self._active_step self._mode = None self._active_step = None self._final_hidden = None if value is None: raise RuntimeError(f"missing Wan head input for denoising step {step_id}") return value def begin_clean(self) -> None: if self._mode is not None: raise RuntimeError(f"cannot begin clean capture while mode is {self._mode}") self._mode = "clean" self._clean_features = {} def finish_clean(self) -> dict[int, torch.Tensor]: if self._mode != "clean": raise RuntimeError("finish_clean called without begin_clean") result = self._clean_features self._mode = None self._clean_features = {} missing = set(self.block_ids).difference(result) if missing: raise RuntimeError(f"missing clean prefeatures for blocks {sorted(missing)}") return result def abort_active_call(self) -> None: """Reset hook state after a model exception before re-raising it.""" self._mode = None self._active_step = None self._final_hidden = None self._clean_features = {}