Add training and inference source code
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +4 -0
- 1.3B/.gitignore +11 -0
- 1.3B/README.md +183 -0
- 1.3B/code/LICENSE +201 -0
- 1.3B/code/configs/default_config.yaml +11 -0
- 1.3B/code/demo_utils/memory.py +135 -0
- 1.3B/code/inference.py +238 -0
- 1.3B/code/losses/__init__.py +1 -0
- 1.3B/code/losses/multi_encoder_mmd.py +235 -0
- 1.3B/code/losses/multi_representation_video_nystrom_mmd.py +7 -0
- 1.3B/code/losses/multilayer_video_nystrom_mmd.py +381 -0
- 1.3B/code/losses/video_nystrom_mmd.py +1366 -0
- 1.3B/code/losses/wan_kv_mmd.py +507 -0
- 1.3B/code/model/__init__.py +4 -0
- 1.3B/code/model/base.py +343 -0
- 1.3B/code/model/distribution_matching.py +0 -0
- 1.3B/code/model/generation.py +142 -0
- 1.3B/code/model/wan_kv_mmd.py +6 -0
- 1.3B/code/pipeline/__init__.py +4 -0
- 1.3B/code/pipeline/causal_inference.py +472 -0
- 1.3B/code/pipeline/self_forcing_training.py +406 -0
- 1.3B/code/prompts/test_8.txt +8 -0
- 1.3B/code/tests/__init__.py +0 -0
- 1.3B/code/tests/test_multi_representation_video_nystrom_mmd.py +238 -0
- 1.3B/code/tests/test_readable_config.py +98 -0
- 1.3B/code/tests/test_triple_feature_metadata_contract.py +82 -0
- 1.3B/code/tests/test_triple_partial_geometry.py +13 -0
- 1.3B/code/tests/test_triple_pixel_cotangent_accumulation.py +34 -0
- 1.3B/code/tests/test_triple_video_feature_bank.py +196 -0
- 1.3B/code/tests/test_video_nystrom_partial_mmd.py +372 -0
- 1.3B/code/tools/build_triple_nystrom_references.py +260 -0
- 1.3B/code/tools/download_videomae_v2_large.py +120 -0
- 1.3B/code/tools/extract_triple_matched_teacher_bank.py +472 -0
- 1.3B/code/tools/precompute_dinov3_dense_nystrom_reference.py +1160 -0
- 1.3B/code/train.py +90 -0
- 1.3B/code/trainer/__init__.py +31 -0
- 1.3B/code/trainer/distillation.py +6 -0
- 1.3B/code/trainer/distribution_matching.py +0 -0
- 1.3B/code/trainer/feature_gradient_replay.py +414 -0
- 1.3B/code/trainer/fresh_video_gradcache.py +634 -0
- 1.3B/code/trainer/multi_representation_fresh_video_gradcache.py +7 -0
- 1.3B/code/utils/config.py +83 -0
- 1.3B/code/utils/dataset.py +283 -0
- 1.3B/code/utils/dinov3_vjepa2_feature_bank.py +301 -0
- 1.3B/code/utils/distributed.py +188 -0
- 1.3B/code/utils/dual_video_feature_bank.py +286 -0
- 1.3B/code/utils/lmdb.py +72 -0
- 1.3B/code/utils/loss.py +81 -0
- 1.3B/code/utils/matched_video_dataset.py +381 -0
- 1.3B/code/utils/matched_video_feature_cache.py +364 -0
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
*.zip
|
1.3B/.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
configs/local.yaml
|
| 2 |
+
configs/local.env
|
| 3 |
+
configs/path-remapping.json
|
| 4 |
+
runs/
|
| 5 |
+
outputs/
|
| 6 |
+
cache/
|
| 7 |
+
assets/
|
| 8 |
+
.venv*/
|
| 9 |
+
__pycache__/
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
*.pyc
|
1.3B/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Anonymous Video Generation — 1.3B
|
| 2 |
+
|
| 3 |
+
[Project overview](../README.md) · [14B release](../14B/README.md)
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
For a guided implementation walkthrough, see [Reading the code](../docs/paper-to-code.md).
|
| 8 |
+
The YAML uses the paper's distribution batch, differentiation batch, and reference minibatch terminology; `code/utils/config.py` keeps legacy configurations compatible.
|
| 9 |
+
|
| 10 |
+
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, allowing distribution estimation to use more samples than backpropagation. Only the generator is optimized; diffusion score models are not needed during post-training.
|
| 11 |
+
|
| 12 |
+
This repository provides the **Wan2.1-T2V-1.3B three-encoder configuration**, using **DINOv3, V-JEPA 2, and VideoMAEv2**. This configuration achieves **84.25 VBench Total** in the paper.
|
| 13 |
+
|
| 14 |
+
## Supported Configuration
|
| 15 |
+
|
| 16 |
+
| Backbone | Feature encoders | Sampling | Training GPUs | Updates |
|
| 17 |
+
|---|---|---|---|---:|
|
| 18 |
+
| Wan2.1-T2V-1.3B | DINOv3 + V-JEPA 2 + VideoMAEv2 | Causal, 4 steps per chunk | 4 | 150 |
|
| 19 |
+
|
| 20 |
+
Training uses a distribution batch of **256 generated videos**, a differentiation batch of **128 generated videos**, and a reference minibatch of **128 videos**. Inference produces **81-frame, 832 × 480 videos at 16 FPS**.
|
| 21 |
+
|
| 22 |
+
## 1. Environment
|
| 23 |
+
|
| 24 |
+
Use Linux, Python 3.10, and NVIDIA GPUs with BF16 support. Run the commands below from the `1.3B/` directory.
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
conda create -n video-generation python=3.10 -y
|
| 28 |
+
conda activate video-generation
|
| 29 |
+
|
| 30 |
+
python -m pip install torch==2.5.1 torchvision==0.20.1 \
|
| 31 |
+
--index-url https://download.pytorch.org/whl/cu121
|
| 32 |
+
python -m pip install -r requirements.txt
|
| 33 |
+
python -m pip install flash-attn==2.7.4.post1 --no-build-isolation
|
| 34 |
+
|
| 35 |
+
export PYTHON_BIN="$(command -v python)"
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## 2. Models and Data
|
| 39 |
+
|
| 40 |
+
### Inference assets
|
| 41 |
+
|
| 42 |
+
Inference needs the Wan2.1 base model, text encoder, tokenizer, VAE, and a trained Anonymous Video Generation checkpoint. Place the matching Wan2.1 assets in the local model directory:
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
export ASSET_ROOT=/path/to/video-generation-assets
|
| 46 |
+
|
| 47 |
+
# Place the matching model files at the local paths described below.
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
The base model is stored under `$ASSET_ROOT/models/Wan2.1-T2V-1.3B/`. The trained generator checkpoint can be stored separately and selected with `CHECKPOINT` when running inference.
|
| 51 |
+
|
| 52 |
+
### Training assets
|
| 53 |
+
|
| 54 |
+
Training starts from the Self-Forcing ODE initialization, stored locally at `$ASSET_ROOT/checkpoints/ode_init.pt`.
|
| 55 |
+
|
| 56 |
+
It additionally requires:
|
| 57 |
+
|
| 58 |
+
- **Frozen feature encoders:** DINOv3 ViT-L/16, V-JEPA 2 ViT-L, and VideoMAEv2-Large.
|
| 59 |
+
- **Reference features:** precomputed features for the 8,682-video reference collection, with the aligned prompt/video manifest.
|
| 60 |
+
- **Nyström references:** one fixed summary per encoder, built from the same collection.
|
| 61 |
+
|
| 62 |
+
Reference preparation consists of extracting the three representations and building their Nyström summaries. During training, reference features are read directly from disk. See [Asset Preparation](docs/assets.md) for model sources, the directory layout, and preprocessing tools.
|
| 63 |
+
|
| 64 |
+
**The trained Anonymous Video Generation weights and reference collection are not distributed with this source release.** Reproducing the reported training run requires the corresponding reference assets in addition to the public model downloads.
|
| 65 |
+
|
| 66 |
+
### Configure asset paths
|
| 67 |
+
|
| 68 |
+
Generate the local configuration once the asset root is set:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
"$PYTHON_BIN" scripts/prepare_config.py --asset-root "$ASSET_ROOT"
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
This creates `configs/local.yaml` from [the three-encoder configuration](configs/three_encoder.yaml). Local asset paths are excluded from version control. Download dependencies before launching a run; the training and inference scripts use local models in offline mode.
|
| 75 |
+
|
| 76 |
+
## 3. Quick Inference
|
| 77 |
+
|
| 78 |
+
Create a text file with one prompt per line, then select a trained checkpoint:
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
export CHECKPOINT=/path/to/video-generation/model.pt
|
| 82 |
+
|
| 83 |
+
GPUS=0 \
|
| 84 |
+
PROMPTS=/path/to/prompts.txt \
|
| 85 |
+
OUTPUT_DIR=outputs/demo \
|
| 86 |
+
bash scripts/infer.sh
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
The launcher generates one video per prompt with seed 0, using the generator weights without EMA. The four-step inference path has no additional classifier-free guidance branch. Example prompts are provided in [test_8.txt](code/prompts/test_8.txt).
|
| 90 |
+
|
| 91 |
+
## 4. Training
|
| 92 |
+
|
| 93 |
+
Prepare the environment and training assets above, then start the three-encoder run on four GPUs:
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
export LOGDIR=runs/video-generation
|
| 97 |
+
GPUS=0,1,2,3 bash scripts/train.sh
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
The launcher uses FSDP and gradient checkpointing. Each worker generates 64 videos for distribution estimation and replays 32 with gradients. The feature encoders remain frozen, but gradients pass through them to the generated videos. W&B logging is disabled by the launcher.
|
| 101 |
+
|
| 102 |
+
Use a separate `LOGDIR` for each run. Training starts from the ODE initialization and runs for **150 updates**, saving model weights, optimizer state, and per-worker random states every five updates. All checkpoints are retained. The final generator checkpoint is:
|
| 103 |
+
|
| 104 |
+
```text
|
| 105 |
+
runs/video-generation/checkpoint_model_000150/model.pt
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Monitor progress with `tail -f "$LOGDIR/train.log"`. To generate videos from the trained model:
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
export CHECKPOINT="$LOGDIR/checkpoint_model_000150/model.pt"
|
| 112 |
+
GPUS=0 PROMPTS=/path/to/prompts.txt \
|
| 113 |
+
OUTPUT_DIR=outputs/video-generation bash scripts/infer.sh
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
<details>
|
| 117 |
+
<summary><b>Training configuration</b></summary>
|
| 118 |
+
|
| 119 |
+
| Setting | Value |
|
| 120 |
+
|---|---|
|
| 121 |
+
| Initialization | Self-Forcing ODE checkpoint |
|
| 122 |
+
| Distribution batch | 256 generated videos |
|
| 123 |
+
| Differentiation batch | 128 generated videos |
|
| 124 |
+
| Reference minibatch | 128 videos, sampled with replacement |
|
| 125 |
+
| Encoder weights | DINOv3 : V-JEPA 2 : VideoMAEv2 = 10 : 1 : 1 |
|
| 126 |
+
| Loss aggregation | Sum of weighted, normalized branch losses divided by 3 |
|
| 127 |
+
| Reference estimator | Nyström + Monte Carlo, weights 2 : 1 |
|
| 128 |
+
| Optimizer | AdamW, betas (0, 0.999), weight decay 0 |
|
| 129 |
+
| Gradient clipping | Maximum norm 10 |
|
| 130 |
+
| Learning rate | Cosine decay from 2.5e-6 to 5e-7 over 125 updates, then constant |
|
| 131 |
+
| Training length | 150 updates |
|
| 132 |
+
| Checkpoint interval | Every 5 updates |
|
| 133 |
+
|
| 134 |
+
The full configuration is in [configs/three_encoder.yaml](configs/three_encoder.yaml).
|
| 135 |
+
|
| 136 |
+
</details>
|
| 137 |
+
|
| 138 |
+
### Short training run
|
| 139 |
+
|
| 140 |
+
To check the complete training and checkpoint path before a full run:
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
export LOGDIR=runs/video-generation-smoke
|
| 144 |
+
GPUS=0,1,2,3 bash scripts/train.sh --smoke
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
This runs **one optimizer update with the same batch sizes and all three encoders**. The resulting checkpoint is `$LOGDIR/checkpoint_model_000001/model.pt` and can be loaded with the inference command above.
|
| 148 |
+
|
| 149 |
+
<details>
|
| 150 |
+
<summary><b>Running in tmux and CPU tests</b></summary>
|
| 151 |
+
|
| 152 |
+
On a machine with four allocated GPUs, start a persistent session:
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
tmux new -s video-generation
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
Inside the session, activate the environment, set `PYTHON_BIN` and `LOGDIR`, and run the training command. Detach with `Ctrl-b`, then `d`; reconnect with `tmux attach -t video-generation`.
|
| 159 |
+
|
| 160 |
+
Run the CPU numerical tests separately:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
"$PYTHON_BIN" -m pip install pytest
|
| 164 |
+
"$PYTHON_BIN" scripts/test_cpu.py
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
See the [validation record](docs/validation.md) for revision-specific test results and the GPU environment used.
|
| 168 |
+
|
| 169 |
+
</details>
|
| 170 |
+
|
| 171 |
+
## 5. Evaluation
|
| 172 |
+
|
| 173 |
+
The paper reports the following result for the three-encoder generator after 150 updates:
|
| 174 |
+
|
| 175 |
+
| Model | VBench Total ↑ | Quality ↑ | Semantic ↑ |
|
| 176 |
+
|---|---:|---:|---:|
|
| 177 |
+
| Anonymous Video Generation, 1.3B, three encoders | **84.25** | **85.06** | **80.99** |
|
| 178 |
+
|
| 179 |
+
The per-dimension scores are available in the [reference summary](results/reference/three-encoder-summary.json). Use the upstream [VBench](https://github.com/Vchitect/VBench) evaluator to evaluate generated videos; VBench and its evaluation models are maintained separately from this training and inference release. A short training run verifies execution and does not reproduce the reported benchmark score.
|
| 180 |
+
|
| 181 |
+
## Acknowledgments
|
| 182 |
+
|
| 183 |
+
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.
|
1.3B/code/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright [yyyy] [name of copyright owner]
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
1.3B/code/configs/default_config.yaml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
independent_first_frame: false
|
| 2 |
+
warp_denoising_step: false
|
| 3 |
+
weight_decay: 0.01
|
| 4 |
+
same_step_across_blocks: true
|
| 5 |
+
discriminator_lr_multiplier: 1.0
|
| 6 |
+
last_step_only: false
|
| 7 |
+
i2v: false
|
| 8 |
+
num_training_frames: 21
|
| 9 |
+
gc_interval: 100
|
| 10 |
+
context_noise: 0
|
| 11 |
+
causal: true
|
1.3B/code/demo_utils/memory.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copied from https://github.com/lllyasviel/FramePack/tree/main/demo_utils
|
| 2 |
+
# Apache-2.0 License
|
| 3 |
+
# By lllyasviel
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
cpu = torch.device('cpu')
|
| 9 |
+
gpu = torch.device(f'cuda:{torch.cuda.current_device()}')
|
| 10 |
+
gpu_complete_modules = []
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class DynamicSwapInstaller:
|
| 14 |
+
@staticmethod
|
| 15 |
+
def _install_module(module: torch.nn.Module, **kwargs):
|
| 16 |
+
original_class = module.__class__
|
| 17 |
+
module.__dict__['forge_backup_original_class'] = original_class
|
| 18 |
+
|
| 19 |
+
def hacked_get_attr(self, name: str):
|
| 20 |
+
if '_parameters' in self.__dict__:
|
| 21 |
+
_parameters = self.__dict__['_parameters']
|
| 22 |
+
if name in _parameters:
|
| 23 |
+
p = _parameters[name]
|
| 24 |
+
if p is None:
|
| 25 |
+
return None
|
| 26 |
+
if p.__class__ == torch.nn.Parameter:
|
| 27 |
+
return torch.nn.Parameter(p.to(**kwargs), requires_grad=p.requires_grad)
|
| 28 |
+
else:
|
| 29 |
+
return p.to(**kwargs)
|
| 30 |
+
if '_buffers' in self.__dict__:
|
| 31 |
+
_buffers = self.__dict__['_buffers']
|
| 32 |
+
if name in _buffers:
|
| 33 |
+
return _buffers[name].to(**kwargs)
|
| 34 |
+
return super(original_class, self).__getattr__(name)
|
| 35 |
+
|
| 36 |
+
module.__class__ = type('DynamicSwap_' + original_class.__name__, (original_class,), {
|
| 37 |
+
'__getattr__': hacked_get_attr,
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
def _uninstall_module(module: torch.nn.Module):
|
| 44 |
+
if 'forge_backup_original_class' in module.__dict__:
|
| 45 |
+
module.__class__ = module.__dict__.pop('forge_backup_original_class')
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
@staticmethod
|
| 49 |
+
def install_model(model: torch.nn.Module, **kwargs):
|
| 50 |
+
for m in model.modules():
|
| 51 |
+
DynamicSwapInstaller._install_module(m, **kwargs)
|
| 52 |
+
return
|
| 53 |
+
|
| 54 |
+
@staticmethod
|
| 55 |
+
def uninstall_model(model: torch.nn.Module):
|
| 56 |
+
for m in model.modules():
|
| 57 |
+
DynamicSwapInstaller._uninstall_module(m)
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def fake_diffusers_current_device(model: torch.nn.Module, target_device: torch.device):
|
| 62 |
+
if hasattr(model, 'scale_shift_table'):
|
| 63 |
+
model.scale_shift_table.data = model.scale_shift_table.data.to(target_device)
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
for k, p in model.named_modules():
|
| 67 |
+
if hasattr(p, 'weight'):
|
| 68 |
+
p.to(target_device)
|
| 69 |
+
return
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_cuda_free_memory_gb(device=None):
|
| 73 |
+
if device is None:
|
| 74 |
+
device = gpu
|
| 75 |
+
|
| 76 |
+
memory_stats = torch.cuda.memory_stats(device)
|
| 77 |
+
bytes_active = memory_stats['active_bytes.all.current']
|
| 78 |
+
bytes_reserved = memory_stats['reserved_bytes.all.current']
|
| 79 |
+
bytes_free_cuda, _ = torch.cuda.mem_get_info(device)
|
| 80 |
+
bytes_inactive_reserved = bytes_reserved - bytes_active
|
| 81 |
+
bytes_total_available = bytes_free_cuda + bytes_inactive_reserved
|
| 82 |
+
return bytes_total_available / (1024 ** 3)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def move_model_to_device_with_memory_preservation(model, target_device, preserved_memory_gb=0):
|
| 86 |
+
print(f'Moving {model.__class__.__name__} to {target_device} with preserved memory: {preserved_memory_gb} GB')
|
| 87 |
+
|
| 88 |
+
for m in model.modules():
|
| 89 |
+
if get_cuda_free_memory_gb(target_device) <= preserved_memory_gb:
|
| 90 |
+
torch.cuda.empty_cache()
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
if hasattr(m, 'weight'):
|
| 94 |
+
m.to(device=target_device)
|
| 95 |
+
|
| 96 |
+
model.to(device=target_device)
|
| 97 |
+
torch.cuda.empty_cache()
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def offload_model_from_device_for_memory_preservation(model, target_device, preserved_memory_gb=0):
|
| 102 |
+
print(f'Offloading {model.__class__.__name__} from {target_device} to preserve memory: {preserved_memory_gb} GB')
|
| 103 |
+
|
| 104 |
+
for m in model.modules():
|
| 105 |
+
if get_cuda_free_memory_gb(target_device) >= preserved_memory_gb:
|
| 106 |
+
torch.cuda.empty_cache()
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
if hasattr(m, 'weight'):
|
| 110 |
+
m.to(device=cpu)
|
| 111 |
+
|
| 112 |
+
model.to(device=cpu)
|
| 113 |
+
torch.cuda.empty_cache()
|
| 114 |
+
return
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def unload_complete_models(*args):
|
| 118 |
+
for m in gpu_complete_modules + list(args):
|
| 119 |
+
m.to(device=cpu)
|
| 120 |
+
print(f'Unloaded {m.__class__.__name__} as complete.')
|
| 121 |
+
|
| 122 |
+
gpu_complete_modules.clear()
|
| 123 |
+
torch.cuda.empty_cache()
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def load_model_as_complete(model, target_device, unload=True):
|
| 128 |
+
if unload:
|
| 129 |
+
unload_complete_models()
|
| 130 |
+
|
| 131 |
+
model.to(device=target_device)
|
| 132 |
+
print(f'Loaded {model.__class__.__name__} to {target_device} as complete.')
|
| 133 |
+
|
| 134 |
+
gpu_complete_modules.append(model)
|
| 135 |
+
return
|
1.3B/code/inference.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import torch
|
| 3 |
+
import os
|
| 4 |
+
from omegaconf import OmegaConf
|
| 5 |
+
from utils.config import load_config
|
| 6 |
+
from tqdm import tqdm
|
| 7 |
+
from torchvision import transforms
|
| 8 |
+
import imageio.v3 as iio # torchvision.io.write_video was removed in torchvision 0.26
|
| 9 |
+
from einops import rearrange
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def write_video(path: str, frames: torch.Tensor, fps: int = 16) -> None:
|
| 13 |
+
"""Drop-in replacement for ``torchvision.io.write_video``.
|
| 14 |
+
|
| 15 |
+
Accepts a ``[T, H, W, C]`` tensor in ``[0, 255]`` (float or uint8) and
|
| 16 |
+
writes it to ``path`` as an H.264 MP4 via the imageio-ffmpeg plugin.
|
| 17 |
+
"""
|
| 18 |
+
if frames.dtype != torch.uint8:
|
| 19 |
+
frames = frames.clamp(0, 255).to(torch.uint8)
|
| 20 |
+
iio.imwrite(
|
| 21 |
+
path,
|
| 22 |
+
frames.cpu().numpy(),
|
| 23 |
+
fps=fps,
|
| 24 |
+
codec="libx264",
|
| 25 |
+
plugin="FFMPEG",
|
| 26 |
+
)
|
| 27 |
+
import torch.distributed as dist
|
| 28 |
+
from torch.utils.data import DataLoader, SequentialSampler
|
| 29 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 30 |
+
|
| 31 |
+
from pipeline import CausalInferencePipeline
|
| 32 |
+
from utils.dataset import TextDataset, TextImagePairDataset
|
| 33 |
+
from utils.misc import set_seed
|
| 34 |
+
|
| 35 |
+
import demo_utils.memory as _demo_memory
|
| 36 |
+
from demo_utils.memory import get_cuda_free_memory_gb, DynamicSwapInstaller
|
| 37 |
+
from pipeline import causal_inference as _causal_inference_module
|
| 38 |
+
|
| 39 |
+
parser = argparse.ArgumentParser()
|
| 40 |
+
parser.add_argument("--config_path", type=str, help="Path to the config file")
|
| 41 |
+
parser.add_argument("--checkpoint_path", type=str, help="Path to the checkpoint folder")
|
| 42 |
+
parser.add_argument("--data_path", type=str, help="Path to the dataset")
|
| 43 |
+
parser.add_argument("--extended_prompt_path", type=str, help="Path to the extended prompt")
|
| 44 |
+
parser.add_argument("--output_folder", type=str, help="Output folder")
|
| 45 |
+
parser.add_argument("--num_output_frames", type=int, default=21,
|
| 46 |
+
help="Number of overlap frames between sliding windows")
|
| 47 |
+
parser.add_argument("--i2v", action="store_true", help="Whether to perform I2V (or T2V by default)")
|
| 48 |
+
parser.add_argument("--use_ema", action="store_true", help="Whether to use EMA parameters")
|
| 49 |
+
parser.add_argument("--seed", type=int, default=0, help="Random seed")
|
| 50 |
+
parser.add_argument("--num_samples", type=int, default=1, help="Number of samples to generate per prompt")
|
| 51 |
+
parser.add_argument("--save_with_index", action="store_true",
|
| 52 |
+
help="Whether to save the video using the index or prompt as the filename")
|
| 53 |
+
args = parser.parse_args()
|
| 54 |
+
|
| 55 |
+
# Initialize distributed inference
|
| 56 |
+
if "LOCAL_RANK" in os.environ:
|
| 57 |
+
dist.init_process_group(backend='nccl')
|
| 58 |
+
local_rank = int(os.environ["LOCAL_RANK"])
|
| 59 |
+
torch.cuda.set_device(local_rank)
|
| 60 |
+
device = torch.device(f"cuda:{local_rank}")
|
| 61 |
+
world_size = dist.get_world_size()
|
| 62 |
+
set_seed(args.seed + local_rank)
|
| 63 |
+
else:
|
| 64 |
+
device = torch.device("cuda")
|
| 65 |
+
local_rank = 0
|
| 66 |
+
world_size = 1
|
| 67 |
+
set_seed(args.seed)
|
| 68 |
+
|
| 69 |
+
# ``demo_utils.memory.gpu`` is computed at import time as
|
| 70 |
+
# ``cuda:<current_device>`` and is also re-exported into
|
| 71 |
+
# ``pipeline.causal_inference``. In a distributed launch every rank imports
|
| 72 |
+
# before ``set_device`` runs, so they all see ``cuda:0`` and end up loading
|
| 73 |
+
# every model onto rank 0's GPU. Patch both module-level bindings so the
|
| 74 |
+
# downstream ``.to(device=gpu)`` calls land on the per-rank device.
|
| 75 |
+
_demo_memory.gpu = device
|
| 76 |
+
_causal_inference_module.gpu = device
|
| 77 |
+
gpu = device
|
| 78 |
+
|
| 79 |
+
print(f'[rank {local_rank}] Free VRAM on {device}: {get_cuda_free_memory_gb(gpu)} GB')
|
| 80 |
+
low_memory = get_cuda_free_memory_gb(gpu) < 40
|
| 81 |
+
|
| 82 |
+
torch.set_grad_enabled(False)
|
| 83 |
+
|
| 84 |
+
config = load_config(args.config_path, "configs/default_config.yaml")
|
| 85 |
+
|
| 86 |
+
# Initialize pipeline
|
| 87 |
+
if not hasattr(config, 'denoising_step_list'):
|
| 88 |
+
raise ValueError("This release supports few-step causal inference only")
|
| 89 |
+
pipeline = CausalInferencePipeline(config, device=device)
|
| 90 |
+
|
| 91 |
+
def _strip_wrapper_prefixes(state_dict):
|
| 92 |
+
"""Strip FSDP / activation-checkpointing / torch.compile wrapper prefixes
|
| 93 |
+
that may be embedded in saved state_dict keys. ``EMA_FSDP.state_dict()``
|
| 94 |
+
in particular keeps ``_fsdp_wrapped_module.`` prefixes."""
|
| 95 |
+
out = {}
|
| 96 |
+
for k, v in state_dict.items():
|
| 97 |
+
new_k = (k.replace("_fsdp_wrapped_module.", "")
|
| 98 |
+
.replace("_checkpoint_wrapped_module.", "")
|
| 99 |
+
.replace("_orig_mod.", ""))
|
| 100 |
+
out[new_k] = v
|
| 101 |
+
return out
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if args.checkpoint_path:
|
| 105 |
+
raw_state_dict = torch.load(args.checkpoint_path, map_location="cpu", weights_only=False)
|
| 106 |
+
key = "generator_ema" if args.use_ema else "generator"
|
| 107 |
+
if key not in raw_state_dict:
|
| 108 |
+
available = list(raw_state_dict.keys())
|
| 109 |
+
raise KeyError(
|
| 110 |
+
f"Checkpoint {args.checkpoint_path!r} has no '{key}' entry "
|
| 111 |
+
f"(found: {available}). Pass --use_ema only for DMD checkpoints saved "
|
| 112 |
+
f"after ema_start_step."
|
| 113 |
+
)
|
| 114 |
+
cleaned = _strip_wrapper_prefixes(raw_state_dict[key])
|
| 115 |
+
missing, unexpected = pipeline.generator.load_state_dict(cleaned, strict=False)
|
| 116 |
+
if local_rank == 0:
|
| 117 |
+
print(f"Loaded {args.checkpoint_path!r} (key='{key}', {len(cleaned)} tensors)")
|
| 118 |
+
|
| 119 |
+
pipeline = pipeline.to(dtype=torch.bfloat16)
|
| 120 |
+
if low_memory:
|
| 121 |
+
DynamicSwapInstaller.install_model(pipeline.text_encoder, device=gpu)
|
| 122 |
+
else:
|
| 123 |
+
pipeline.text_encoder.to(device=gpu)
|
| 124 |
+
pipeline.generator.to(device=gpu)
|
| 125 |
+
pipeline.vae.to(device=gpu)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# Create dataset
|
| 129 |
+
if args.i2v:
|
| 130 |
+
assert not dist.is_initialized(), "I2V does not support distributed inference yet"
|
| 131 |
+
transform = transforms.Compose([
|
| 132 |
+
transforms.Resize((480, 832)),
|
| 133 |
+
transforms.ToTensor(),
|
| 134 |
+
transforms.Normalize([0.5], [0.5])
|
| 135 |
+
])
|
| 136 |
+
dataset = TextImagePairDataset(args.data_path, transform=transform)
|
| 137 |
+
else:
|
| 138 |
+
dataset = TextDataset(prompt_path=args.data_path, extended_prompt_path=args.extended_prompt_path)
|
| 139 |
+
num_prompts = len(dataset)
|
| 140 |
+
print(f"Number of prompts: {num_prompts}")
|
| 141 |
+
|
| 142 |
+
if dist.is_initialized():
|
| 143 |
+
sampler = DistributedSampler(dataset, shuffle=False, drop_last=True)
|
| 144 |
+
else:
|
| 145 |
+
sampler = SequentialSampler(dataset)
|
| 146 |
+
dataloader = DataLoader(dataset, batch_size=1, sampler=sampler, num_workers=0, drop_last=False)
|
| 147 |
+
|
| 148 |
+
# Create output directory (only on main process to avoid race conditions)
|
| 149 |
+
if local_rank == 0:
|
| 150 |
+
os.makedirs(args.output_folder, exist_ok=True)
|
| 151 |
+
|
| 152 |
+
if dist.is_initialized():
|
| 153 |
+
dist.barrier()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def encode(self, videos: torch.Tensor) -> torch.Tensor:
|
| 157 |
+
device, dtype = videos[0].device, videos[0].dtype
|
| 158 |
+
scale = [self.mean.to(device=device, dtype=dtype),
|
| 159 |
+
1.0 / self.std.to(device=device, dtype=dtype)]
|
| 160 |
+
output = [
|
| 161 |
+
self.model.encode(u.unsqueeze(0), scale).float().squeeze(0)
|
| 162 |
+
for u in videos
|
| 163 |
+
]
|
| 164 |
+
|
| 165 |
+
output = torch.stack(output, dim=0)
|
| 166 |
+
return output
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
for i, batch_data in tqdm(enumerate(dataloader), disable=(local_rank != 0)):
|
| 170 |
+
idx = batch_data['idx'].item()
|
| 171 |
+
|
| 172 |
+
# For DataLoader batch_size=1, the batch_data is already a single item, but in a batch container
|
| 173 |
+
# Unpack the batch data for convenience
|
| 174 |
+
if isinstance(batch_data, dict):
|
| 175 |
+
batch = batch_data
|
| 176 |
+
elif isinstance(batch_data, list):
|
| 177 |
+
batch = batch_data[0] # First (and only) item in the batch
|
| 178 |
+
|
| 179 |
+
all_video = []
|
| 180 |
+
num_generated_frames = 0 # Number of generated (latent) frames
|
| 181 |
+
|
| 182 |
+
if args.i2v:
|
| 183 |
+
# For image-to-video, batch contains image and caption
|
| 184 |
+
prompt = batch['prompts'][0] # Get caption from batch
|
| 185 |
+
prompts = [prompt] * args.num_samples
|
| 186 |
+
|
| 187 |
+
# Process the image
|
| 188 |
+
image = batch['image'].squeeze(0).unsqueeze(0).unsqueeze(2).to(device=device, dtype=torch.bfloat16)
|
| 189 |
+
|
| 190 |
+
# Encode the input image as the first latent
|
| 191 |
+
initial_latent = pipeline.vae.encode_to_latent(image).to(device=device, dtype=torch.bfloat16)
|
| 192 |
+
initial_latent = initial_latent.repeat(args.num_samples, 1, 1, 1, 1)
|
| 193 |
+
|
| 194 |
+
sampled_noise = torch.randn(
|
| 195 |
+
[args.num_samples, args.num_output_frames - 1, 16, 60, 104], device=device, dtype=torch.bfloat16
|
| 196 |
+
)
|
| 197 |
+
else:
|
| 198 |
+
# For text-to-video, batch is just the text prompt
|
| 199 |
+
prompt = batch['prompts'][0]
|
| 200 |
+
extended_prompt = batch['extended_prompts'][0] if 'extended_prompts' in batch else None
|
| 201 |
+
if extended_prompt is not None:
|
| 202 |
+
prompts = [extended_prompt] * args.num_samples
|
| 203 |
+
else:
|
| 204 |
+
prompts = [prompt] * args.num_samples
|
| 205 |
+
initial_latent = None
|
| 206 |
+
|
| 207 |
+
sampled_noise = torch.randn(
|
| 208 |
+
[args.num_samples, args.num_output_frames, 16, 60, 104], device=device, dtype=torch.bfloat16
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Generate 81 frames
|
| 212 |
+
video, latents = pipeline.inference(
|
| 213 |
+
noise=sampled_noise,
|
| 214 |
+
text_prompts=prompts,
|
| 215 |
+
return_latents=True,
|
| 216 |
+
initial_latent=initial_latent,
|
| 217 |
+
low_memory=low_memory,
|
| 218 |
+
)
|
| 219 |
+
current_video = rearrange(video, 'b t c h w -> b t h w c').cpu()
|
| 220 |
+
all_video.append(current_video)
|
| 221 |
+
num_generated_frames += latents.shape[1]
|
| 222 |
+
|
| 223 |
+
# Final output video
|
| 224 |
+
video = 255.0 * torch.cat(all_video, dim=1)
|
| 225 |
+
|
| 226 |
+
# Clear VAE cache
|
| 227 |
+
pipeline.vae.model.clear_cache()
|
| 228 |
+
|
| 229 |
+
# Save the video if the current prompt is not a dummy prompt
|
| 230 |
+
if idx < num_prompts:
|
| 231 |
+
model = "regular" if not args.use_ema else "ema"
|
| 232 |
+
for seed_idx in range(args.num_samples):
|
| 233 |
+
# All processes save their videos
|
| 234 |
+
if args.save_with_index:
|
| 235 |
+
output_path = os.path.join(args.output_folder, f'{idx}-{seed_idx}_{model}.mp4')
|
| 236 |
+
else:
|
| 237 |
+
output_path = os.path.join(args.output_folder, f'{prompt[:100]}-{seed_idx}.mp4')
|
| 238 |
+
write_video(output_path, video[seed_idx], fps=16)
|
1.3B/code/losses/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Distribution-matching losses used by distribution matching."""
|
1.3B/code/losses/multi_encoder_mmd.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Combine distribution-matching losses across frozen video encoders.
|
| 2 |
+
|
| 3 |
+
Each encoder defines its own video feature space and Gaussian kernel. Compute
|
| 4 |
+
one MMD per encoder, apply the release's detached per-encoder loss scaling,
|
| 5 |
+
then apply the configured weights and aggregation divisor. ``VideoNystromMMD``
|
| 6 |
+
implements generated repulsion and persistent/minibatch reference attraction.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
from collections import OrderedDict
|
| 13 |
+
from typing import Any, Mapping, Optional, Sequence
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
from torch import nn
|
| 17 |
+
|
| 18 |
+
from losses.video_nystrom_mmd import VideoNystromMMD
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def normalize_mmd_with_detached_scale(
|
| 22 |
+
raw_mmd: torch.Tensor,
|
| 23 |
+
normalization_eps: float = 1.0e-4,
|
| 24 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 25 |
+
"""Return M / (stop_gradient(M) + eps) and its detached scale.
|
| 26 |
+
|
| 27 |
+
Detaching only the denominator preserves gradients through M while
|
| 28 |
+
balancing the different numerical scales of the encoder objectives.
|
| 29 |
+
This release-specific scaling is additional to the paper's MMD objective;
|
| 30 |
+
the expression deliberately has no absolute value or clamping.
|
| 31 |
+
"""
|
| 32 |
+
denominator = raw_mmd.detach() + float(normalization_eps)
|
| 33 |
+
return raw_mmd / denominator, denominator
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class MultiEncoderMMD(nn.Module):
|
| 37 |
+
"""Weighted distribution matching in multiple frozen feature spaces.
|
| 38 |
+
|
| 39 |
+
A branch is one encoder, its kernel bandwidth, and its persistent Nyström
|
| 40 |
+
reference. For raw branch losses M_e, the combined objective is
|
| 41 |
+
sum_e weight_e * M_e / (stop_gradient(M_e) + eps), divided by
|
| 42 |
+
``aggregation_divisor``. That divisor is explicit; it need not equal the
|
| 43 |
+
number of branches or the sum of weights.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
branches: Mapping[str, VideoNystromMMD],
|
| 49 |
+
*,
|
| 50 |
+
weights: Optional[Mapping[str, float]] = None,
|
| 51 |
+
normalization_eps: float = 1.0e-4,
|
| 52 |
+
aggregation_divisor: float = 1.0,
|
| 53 |
+
) -> None:
|
| 54 |
+
super().__init__()
|
| 55 |
+
ordered = OrderedDict()
|
| 56 |
+
for name, objective in branches.items():
|
| 57 |
+
ordered[name] = objective
|
| 58 |
+
self.branches = nn.ModuleDict(ordered)
|
| 59 |
+
configured_weights = weights or {name: 1.0 for name in ordered}
|
| 60 |
+
self.branch_weights = {}
|
| 61 |
+
for name in ordered:
|
| 62 |
+
weight = float(configured_weights[name])
|
| 63 |
+
self.branch_weights[name] = weight
|
| 64 |
+
self.normalization_eps = float(normalization_eps)
|
| 65 |
+
self.aggregation_divisor = float(aggregation_divisor)
|
| 66 |
+
self.current_teacher_attraction_enabled = True
|
| 67 |
+
self.attraction_ema_enabled = False
|
| 68 |
+
self.repulsion_fifo_enabled = False
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def branch_names(self) -> tuple[str, ...]:
|
| 72 |
+
return tuple(self.branches.keys())
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def sample_shapes(self) -> dict[str, tuple[int, int]]:
|
| 76 |
+
return {
|
| 77 |
+
name: objective.sample_shape
|
| 78 |
+
for name, objective in self.branches.items()
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def landmarks(self) -> torch.Tensor:
|
| 83 |
+
"""Legacy diagnostics use the first branch; branch logs stay exact."""
|
| 84 |
+
return self.branches[self.branch_names[0]].landmarks
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def branch_registry(self) -> dict[str, dict[str, Any]]:
|
| 88 |
+
return {
|
| 89 |
+
name: {
|
| 90 |
+
"sample_shape": objective.sample_shape,
|
| 91 |
+
"reference_hash": objective.reference_hash,
|
| 92 |
+
"weight": self.branch_weights[name],
|
| 93 |
+
}
|
| 94 |
+
for name, objective in self.branches.items()
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def _validate_mapping(
|
| 98 |
+
self,
|
| 99 |
+
value: Optional[Mapping[str, torch.Tensor]],
|
| 100 |
+
label: str,
|
| 101 |
+
*,
|
| 102 |
+
required: bool,
|
| 103 |
+
) -> Optional[Mapping[str, torch.Tensor]]:
|
| 104 |
+
if value is None:
|
| 105 |
+
return None
|
| 106 |
+
return value
|
| 107 |
+
|
| 108 |
+
def forward(
|
| 109 |
+
self,
|
| 110 |
+
generated: Optional[Mapping[str, torch.Tensor]] = None,
|
| 111 |
+
current_teacher: Optional[Mapping[str, torch.Tensor]] = None,
|
| 112 |
+
*,
|
| 113 |
+
gradient_students: Optional[Mapping[str, torch.Tensor]] = None,
|
| 114 |
+
student_reference: Optional[Mapping[str, torch.Tensor]] = None,
|
| 115 |
+
gradient_student_indices: Optional[
|
| 116 |
+
torch.Tensor | Sequence[int]
|
| 117 |
+
] = None,
|
| 118 |
+
stage: bool = True,
|
| 119 |
+
) -> tuple[torch.Tensor, dict[str, Any]]:
|
| 120 |
+
"""Evaluate each encoder loss, then balance and combine their scales.
|
| 121 |
+
|
| 122 |
+
``gradient_students`` selects generated samples that retain gradients;
|
| 123 |
+
``student_reference`` holds the full generated feature population.
|
| 124 |
+
``current_teacher`` is the current frozen reference minibatch.
|
| 125 |
+
These input names are retained for existing callers and checkpoints.
|
| 126 |
+
"""
|
| 127 |
+
# Select full-gradient or cached partial-gradient inputs. Every encoder
|
| 128 |
+
# receives the same sample selection.
|
| 129 |
+
partial = gradient_students is not None or student_reference is not None
|
| 130 |
+
generated = self._validate_mapping(
|
| 131 |
+
generated, "generated", required=not partial)
|
| 132 |
+
gradient_students = self._validate_mapping(
|
| 133 |
+
gradient_students, "gradient_students", required=partial)
|
| 134 |
+
student_reference = self._validate_mapping(
|
| 135 |
+
student_reference, "student_reference", required=partial)
|
| 136 |
+
current_teacher = self._validate_mapping(
|
| 137 |
+
current_teacher, "current_teacher", required=True)
|
| 138 |
+
|
| 139 |
+
# Each feature space has its own kernel objective; encoder features
|
| 140 |
+
# and persistent reference landmarks remain separate.
|
| 141 |
+
normalized_terms = []
|
| 142 |
+
branch_metadata: dict[str, dict[str, Any]] = {}
|
| 143 |
+
for name, objective in self.branches.items():
|
| 144 |
+
if partial:
|
| 145 |
+
raw, metadata = objective(
|
| 146 |
+
gradient_students=gradient_students[name],
|
| 147 |
+
student_reference=student_reference[name],
|
| 148 |
+
current_teacher=current_teacher[name],
|
| 149 |
+
gradient_student_indices=gradient_student_indices,
|
| 150 |
+
stage=stage,
|
| 151 |
+
)
|
| 152 |
+
else:
|
| 153 |
+
raw, metadata = objective(
|
| 154 |
+
generated[name],
|
| 155 |
+
current_teacher=current_teacher[name],
|
| 156 |
+
stage=stage,
|
| 157 |
+
)
|
| 158 |
+
# Balance the branch scale before applying its configured weight.
|
| 159 |
+
normalized, denominator = normalize_mmd_with_detached_scale(
|
| 160 |
+
raw, self.normalization_eps)
|
| 161 |
+
weighted = self.branch_weights[name] * normalized
|
| 162 |
+
normalized_terms.append(weighted)
|
| 163 |
+
branch_metadata[name] = {
|
| 164 |
+
**metadata,
|
| 165 |
+
"raw_mmd": raw.detach(),
|
| 166 |
+
"normalization_denominator": denominator.detach(),
|
| 167 |
+
"normalized_mmd": normalized.detach(),
|
| 168 |
+
"branch_weight": raw.new_tensor(self.branch_weights[name]),
|
| 169 |
+
"weighted_normalized_mmd": weighted.detach(),
|
| 170 |
+
"sample_shape": objective.sample_shape,
|
| 171 |
+
"reference_hash": objective.reference_hash,
|
| 172 |
+
}
|
| 173 |
+
# Preserve the explicit reduction order and configured divisor.
|
| 174 |
+
total = (
|
| 175 |
+
torch.stack(normalized_terms).sum() / self.aggregation_divisor
|
| 176 |
+
)
|
| 177 |
+
# Detached values below are diagnostics; ``total`` retains the graph.
|
| 178 |
+
first = branch_metadata[self.branch_names[0]]
|
| 179 |
+
metadata = {
|
| 180 |
+
"branches": branch_metadata,
|
| 181 |
+
"branch_names": self.branch_names,
|
| 182 |
+
"raw_mmd_sum": torch.stack([
|
| 183 |
+
branch_metadata[name]["raw_mmd"] for name in self.branch_names
|
| 184 |
+
]).sum(),
|
| 185 |
+
"normalized_mmd_sum": total.detach(),
|
| 186 |
+
"normalization_eps": total.new_tensor(self.normalization_eps),
|
| 187 |
+
"normalization": "raw_div_stop_gradient_raw_plus_eps",
|
| 188 |
+
"aggregation": "weighted_sum_then_divide",
|
| 189 |
+
"aggregation_divisor": total.new_tensor(
|
| 190 |
+
self.aggregation_divisor),
|
| 191 |
+
# Shared batch counts are used by feature-gradient replay;
|
| 192 |
+
# per-encoder diagnostics remain available under ``branches``.
|
| 193 |
+
"global_samples": first["global_samples"],
|
| 194 |
+
"gradient_student_samples": first.get(
|
| 195 |
+
"gradient_student_samples", first["global_samples"]),
|
| 196 |
+
"repulsion_denominator": first.get("repulsion_denominator"),
|
| 197 |
+
"repulsion": torch.stack([
|
| 198 |
+
branch_metadata[name]["repulsion"]
|
| 199 |
+
for name in self.branch_names
|
| 200 |
+
]).sum(),
|
| 201 |
+
"attraction": torch.stack([
|
| 202 |
+
branch_metadata[name]["attraction"]
|
| 203 |
+
for name in self.branch_names
|
| 204 |
+
]).sum(),
|
| 205 |
+
"attraction_weight": total.new_ones(()),
|
| 206 |
+
"reference_constant": torch.stack([
|
| 207 |
+
branch_metadata[name]["reference_constant"]
|
| 208 |
+
for name in self.branch_names
|
| 209 |
+
]).sum(),
|
| 210 |
+
}
|
| 211 |
+
return total, metadata
|
| 212 |
+
|
| 213 |
+
def prepare_commit(self) -> None:
|
| 214 |
+
for objective in self.branches.values():
|
| 215 |
+
objective.prepare_commit()
|
| 216 |
+
|
| 217 |
+
def commit(self) -> None:
|
| 218 |
+
for objective in self.branches.values():
|
| 219 |
+
objective.commit()
|
| 220 |
+
|
| 221 |
+
def discard(self) -> None:
|
| 222 |
+
for objective in self.branches.values():
|
| 223 |
+
objective.discard()
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# Historical imports and serialized class references use these aliases.
|
| 227 |
+
MultiRepresentationVideoNystromMMD = MultiEncoderMMD
|
| 228 |
+
fd_normalize_raw_mmd = normalize_mmd_with_detached_scale
|
| 229 |
+
|
| 230 |
+
__all__ = [
|
| 231 |
+
"MultiEncoderMMD",
|
| 232 |
+
"normalize_mmd_with_detached_scale",
|
| 233 |
+
"MultiRepresentationVideoNystromMMD",
|
| 234 |
+
"fd_normalize_raw_mmd",
|
| 235 |
+
]
|
1.3B/code/losses/multi_representation_video_nystrom_mmd.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility module; read ``losses.multi_encoder_mmd`` for the implementation."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from losses import multi_encoder_mmd as _implementation
|
| 5 |
+
|
| 6 |
+
# Reuse the module itself so existing imports and module-level patches agree.
|
| 7 |
+
sys.modules[__name__] = _implementation
|
1.3B/code/losses/multilayer_video_nystrom_mmd.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Independent normalized Nyström MMD objectives over multiple video layers.
|
| 2 |
+
|
| 3 |
+
The V-JEPA2 caller concatenates the sampled dense matrices from every selected
|
| 4 |
+
layer along the token axis. This module splits that transport tensor back into
|
| 5 |
+
its layer-local matrices, evaluates one frozen-reference MMD per layer, applies
|
| 6 |
+
detached scalar self-normalization independently, and sums the results.
|
| 7 |
+
|
| 8 |
+
Keeping the objectives separate is important: concatenating layer features
|
| 9 |
+
inside one kernel would change both the bandwidth and the teacher reference
|
| 10 |
+
distribution instead of giving each layer equal normalized influence.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
from collections.abc import Mapping, Sequence
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
from torch import nn
|
| 21 |
+
|
| 22 |
+
from losses.video_nystrom_mmd import VideoNystromMMD
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class MultiLayerVideoNystromMMD(nn.Module):
|
| 26 |
+
"""Sum independent, optionally self-normalized per-layer video MMDs."""
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
objectives: Mapping[int, VideoNystromMMD],
|
| 31 |
+
*,
|
| 32 |
+
self_normalize: bool = True,
|
| 33 |
+
norm_eps: float = 1.0e-4,
|
| 34 |
+
layer_weights: Mapping[int, float] | None = None,
|
| 35 |
+
) -> None:
|
| 36 |
+
super().__init__()
|
| 37 |
+
if not isinstance(objectives, Mapping) or not objectives:
|
| 38 |
+
raise ValueError("objectives must be a non-empty layer mapping")
|
| 39 |
+
ordered_layers = tuple(int(layer) for layer in objectives)
|
| 40 |
+
if len(set(ordered_layers)) != len(ordered_layers):
|
| 41 |
+
raise ValueError("objective layer indices must be unique")
|
| 42 |
+
if any(layer < 0 for layer in ordered_layers):
|
| 43 |
+
raise ValueError("objective layer indices must be non-negative")
|
| 44 |
+
if tuple(sorted(ordered_layers)) != ordered_layers:
|
| 45 |
+
raise ValueError("objective layer indices must be sorted")
|
| 46 |
+
if (
|
| 47 |
+
not math.isfinite(float(norm_eps))
|
| 48 |
+
or float(norm_eps) <= 0.0
|
| 49 |
+
):
|
| 50 |
+
raise ValueError("norm_eps must be finite and positive")
|
| 51 |
+
|
| 52 |
+
weights = (
|
| 53 |
+
{layer: 1.0 for layer in ordered_layers}
|
| 54 |
+
if layer_weights is None
|
| 55 |
+
else {int(layer): float(value)
|
| 56 |
+
for layer, value in layer_weights.items()}
|
| 57 |
+
)
|
| 58 |
+
if set(weights) != set(ordered_layers):
|
| 59 |
+
raise ValueError(
|
| 60 |
+
"layer_weights must contain exactly the objective layers")
|
| 61 |
+
if any(
|
| 62 |
+
not math.isfinite(value) or value < 0.0
|
| 63 |
+
for value in weights.values()
|
| 64 |
+
):
|
| 65 |
+
raise ValueError(
|
| 66 |
+
"layer weights must be finite and non-negative")
|
| 67 |
+
|
| 68 |
+
modules = {}
|
| 69 |
+
sample_shapes = []
|
| 70 |
+
for layer in ordered_layers:
|
| 71 |
+
objective = objectives[layer]
|
| 72 |
+
if not isinstance(objective, VideoNystromMMD):
|
| 73 |
+
raise TypeError(
|
| 74 |
+
"every layer objective must be VideoNystromMMD")
|
| 75 |
+
if any(
|
| 76 |
+
parameter.requires_grad
|
| 77 |
+
for parameter in objective.parameters()
|
| 78 |
+
):
|
| 79 |
+
raise ValueError(
|
| 80 |
+
"Nyström reference objectives must remain frozen")
|
| 81 |
+
modules[str(layer)] = objective
|
| 82 |
+
sample_shapes.append(objective.sample_shape)
|
| 83 |
+
feature_dims = {shape[1] for shape in sample_shapes}
|
| 84 |
+
if len(feature_dims) != 1:
|
| 85 |
+
raise ValueError(
|
| 86 |
+
"all layer objectives must share one feature dimension")
|
| 87 |
+
|
| 88 |
+
self.objectives = nn.ModuleDict(modules)
|
| 89 |
+
self.layer_indices = ordered_layers
|
| 90 |
+
self.layer_token_counts = tuple(
|
| 91 |
+
int(shape[0]) for shape in sample_shapes)
|
| 92 |
+
self.feature_dim = int(sample_shapes[0][1])
|
| 93 |
+
self.self_normalize = bool(self_normalize)
|
| 94 |
+
self.norm_eps = float(norm_eps)
|
| 95 |
+
self.layer_weights = tuple(
|
| 96 |
+
float(weights[layer]) for layer in ordered_layers)
|
| 97 |
+
ema_settings = {
|
| 98 |
+
(
|
| 99 |
+
objective.attraction_ema_enabled,
|
| 100 |
+
objective.attraction_ema_beta,
|
| 101 |
+
)
|
| 102 |
+
for objective in self.objectives.values()
|
| 103 |
+
}
|
| 104 |
+
if len(ema_settings) != 1:
|
| 105 |
+
raise ValueError(
|
| 106 |
+
"all layer objectives must use the same attraction EMA "
|
| 107 |
+
"enabled/beta setting")
|
| 108 |
+
(
|
| 109 |
+
self.attraction_ema_enabled,
|
| 110 |
+
self.attraction_ema_beta,
|
| 111 |
+
) = next(iter(ema_settings))
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def sample_shape(self) -> tuple[int, int]:
|
| 115 |
+
"""Shape of the concatenated GradCache transport matrix."""
|
| 116 |
+
return sum(self.layer_token_counts), self.feature_dim
|
| 117 |
+
|
| 118 |
+
@property
|
| 119 |
+
def landmarks(self) -> torch.Tensor:
|
| 120 |
+
"""Compatibility device/landmark-count view used by GradCache logs."""
|
| 121 |
+
return self.objectives[str(self.layer_indices[0])].landmarks
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def reference_hashes(self) -> tuple[str, ...]:
|
| 125 |
+
return tuple(
|
| 126 |
+
str(self.objectives[str(layer)].reference_hash)
|
| 127 |
+
for layer in self.layer_indices
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
@property
|
| 131 |
+
def reference_metadata(self) -> tuple[Mapping[str, Any], ...]:
|
| 132 |
+
return tuple(
|
| 133 |
+
self.objectives[str(layer)].reference_metadata
|
| 134 |
+
for layer in self.layer_indices
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
@property
|
| 138 |
+
def attraction_ema_pending(self) -> bool:
|
| 139 |
+
pending = tuple(
|
| 140 |
+
objective.attraction_ema_pending
|
| 141 |
+
for objective in self.objectives.values()
|
| 142 |
+
)
|
| 143 |
+
if pending and any(value != pending[0] for value in pending[1:]):
|
| 144 |
+
raise RuntimeError(
|
| 145 |
+
"per-layer attraction EMA pending state is inconsistent")
|
| 146 |
+
return bool(pending[0]) if pending else False
|
| 147 |
+
|
| 148 |
+
def split_features(
|
| 149 |
+
self,
|
| 150 |
+
generated: torch.Tensor,
|
| 151 |
+
) -> tuple[torch.Tensor, ...]:
|
| 152 |
+
if not isinstance(generated, torch.Tensor):
|
| 153 |
+
raise TypeError("generated must be a torch.Tensor")
|
| 154 |
+
if (
|
| 155 |
+
generated.ndim != 3
|
| 156 |
+
or min(generated.shape) <= 0
|
| 157 |
+
or not generated.is_floating_point()
|
| 158 |
+
):
|
| 159 |
+
raise ValueError(
|
| 160 |
+
"generated must be a non-empty floating [B,N,D] tensor")
|
| 161 |
+
if not bool(torch.isfinite(generated.detach()).all().item()):
|
| 162 |
+
raise FloatingPointError(
|
| 163 |
+
"generated contains non-finite values")
|
| 164 |
+
expected_tokens, expected_dim = self.sample_shape
|
| 165 |
+
actual_shape = (
|
| 166 |
+
int(generated.shape[1]), int(generated.shape[2]))
|
| 167 |
+
if actual_shape != (expected_tokens, expected_dim):
|
| 168 |
+
raise ValueError(
|
| 169 |
+
"concatenated multi-layer video matrix shape does not match "
|
| 170 |
+
f"the frozen references: {actual_shape} != "
|
| 171 |
+
f"{(expected_tokens, expected_dim)}")
|
| 172 |
+
return tuple(torch.split(
|
| 173 |
+
generated, self.layer_token_counts, dim=1))
|
| 174 |
+
|
| 175 |
+
def forward(
|
| 176 |
+
self,
|
| 177 |
+
generated: torch.Tensor,
|
| 178 |
+
*,
|
| 179 |
+
stage: bool = True,
|
| 180 |
+
) -> tuple[torch.Tensor, dict[str, Any]]:
|
| 181 |
+
layer_features = self.split_features(generated)
|
| 182 |
+
total = generated.new_zeros((), dtype=torch.float32)
|
| 183 |
+
raw_sum = total.clone()
|
| 184 |
+
repulsion_sum = total.clone()
|
| 185 |
+
attraction_sum = total.clone()
|
| 186 |
+
current_attraction_sum = total.clone()
|
| 187 |
+
reference_constant_sum = total.clone()
|
| 188 |
+
layer_metadata: dict[str, dict[str, torch.Tensor]] = {}
|
| 189 |
+
global_samples = None
|
| 190 |
+
ema_updates = None
|
| 191 |
+
attraction_current_coefficient = None
|
| 192 |
+
|
| 193 |
+
for layer, weight, features in zip(
|
| 194 |
+
self.layer_indices,
|
| 195 |
+
self.layer_weights,
|
| 196 |
+
layer_features,
|
| 197 |
+
):
|
| 198 |
+
objective = self.objectives[str(layer)]
|
| 199 |
+
raw_mmd, metadata = (
|
| 200 |
+
objective(features, stage=stage)
|
| 201 |
+
if self.attraction_ema_enabled
|
| 202 |
+
else objective(features)
|
| 203 |
+
)
|
| 204 |
+
if self.self_normalize:
|
| 205 |
+
# Nyström attraction is approximate, so its finite estimate
|
| 206 |
+
# can be slightly negative even though exact squared MMD is
|
| 207 |
+
# non-negative. Normalize by detached magnitude to avoid both
|
| 208 |
+
# a singularity near -eps and an accidental gradient reversal.
|
| 209 |
+
denominator = raw_mmd.detach().abs() + self.norm_eps
|
| 210 |
+
if (
|
| 211 |
+
not bool(torch.isfinite(denominator).item())
|
| 212 |
+
or float(denominator) == 0.0
|
| 213 |
+
):
|
| 214 |
+
raise FloatingPointError(
|
| 215 |
+
f"layer {layer} has an invalid MMD normalization "
|
| 216 |
+
"denominator")
|
| 217 |
+
normalized_mmd = raw_mmd / denominator
|
| 218 |
+
else:
|
| 219 |
+
normalized_mmd = raw_mmd
|
| 220 |
+
weighted_mmd = float(weight) * normalized_mmd
|
| 221 |
+
total = total + weighted_mmd
|
| 222 |
+
raw_sum = raw_sum + raw_mmd.detach()
|
| 223 |
+
repulsion_sum = repulsion_sum + metadata["repulsion"]
|
| 224 |
+
attraction_sum = attraction_sum + metadata["attraction"]
|
| 225 |
+
if self.attraction_ema_enabled:
|
| 226 |
+
current_attraction_sum = (
|
| 227 |
+
current_attraction_sum + metadata[
|
| 228 |
+
"current_attraction"])
|
| 229 |
+
reference_constant_sum = (
|
| 230 |
+
reference_constant_sum + metadata["reference_constant"])
|
| 231 |
+
observed_global_samples = int(metadata["global_samples"])
|
| 232 |
+
if global_samples is None:
|
| 233 |
+
global_samples = observed_global_samples
|
| 234 |
+
elif observed_global_samples != global_samples:
|
| 235 |
+
raise RuntimeError(
|
| 236 |
+
"per-layer MMD objectives disagree on global batch size")
|
| 237 |
+
if self.attraction_ema_enabled:
|
| 238 |
+
observed_updates = int(
|
| 239 |
+
metadata["attraction_ema_updates"])
|
| 240 |
+
if ema_updates is None:
|
| 241 |
+
ema_updates = observed_updates
|
| 242 |
+
elif observed_updates != ema_updates:
|
| 243 |
+
raise RuntimeError(
|
| 244 |
+
"per-layer attraction EMA update counts disagree")
|
| 245 |
+
observed_coefficient = float(
|
| 246 |
+
metadata["attraction_current_coefficient"])
|
| 247 |
+
if attraction_current_coefficient is None:
|
| 248 |
+
attraction_current_coefficient = observed_coefficient
|
| 249 |
+
elif observed_coefficient != attraction_current_coefficient:
|
| 250 |
+
raise RuntimeError(
|
| 251 |
+
"per-layer attraction EMA coefficients disagree")
|
| 252 |
+
layer_metadata[str(layer)] = {
|
| 253 |
+
**metadata,
|
| 254 |
+
"raw_mmd": raw_mmd.detach(),
|
| 255 |
+
"normalized_mmd": normalized_mmd.detach(),
|
| 256 |
+
"weighted_mmd": weighted_mmd.detach(),
|
| 257 |
+
"weight": raw_mmd.new_tensor(float(weight)),
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
if not bool(torch.isfinite(total.detach()).item()):
|
| 261 |
+
raise FloatingPointError(
|
| 262 |
+
"combined multi-layer MMD loss is non-finite")
|
| 263 |
+
combined_metadata = {
|
| 264 |
+
"layers": layer_metadata,
|
| 265 |
+
"layer_indices": torch.tensor(
|
| 266 |
+
self.layer_indices,
|
| 267 |
+
device=total.device,
|
| 268 |
+
dtype=torch.long,
|
| 269 |
+
),
|
| 270 |
+
"num_layers": total.new_tensor(
|
| 271 |
+
len(self.layer_indices), dtype=torch.long),
|
| 272 |
+
"self_normalized": total.new_tensor(
|
| 273 |
+
int(self.self_normalize), dtype=torch.long),
|
| 274 |
+
"normalization_epsilon": total.new_tensor(self.norm_eps),
|
| 275 |
+
"raw_mmd_sum": raw_sum.detach(),
|
| 276 |
+
"normalized_mmd_sum": total.detach(),
|
| 277 |
+
# Aggregated raw-kernel terms preserve the existing generic log
|
| 278 |
+
# contract. Per-layer diagnostics above are authoritative.
|
| 279 |
+
"repulsion": repulsion_sum.detach(),
|
| 280 |
+
"attraction": attraction_sum.detach(),
|
| 281 |
+
"reference_constant": reference_constant_sum.detach(),
|
| 282 |
+
"global_samples": total.new_tensor(
|
| 283 |
+
int(global_samples), dtype=torch.long),
|
| 284 |
+
}
|
| 285 |
+
if self.attraction_ema_enabled:
|
| 286 |
+
combined_metadata.update({
|
| 287 |
+
"current_attraction": current_attraction_sum.detach(),
|
| 288 |
+
"attraction_ema_enabled": total.new_ones(
|
| 289 |
+
(), dtype=torch.long),
|
| 290 |
+
"attraction_ema_updates": total.new_tensor(
|
| 291 |
+
int(ema_updates), dtype=torch.long),
|
| 292 |
+
"attraction_current_coefficient": total.new_tensor(
|
| 293 |
+
float(attraction_current_coefficient)),
|
| 294 |
+
})
|
| 295 |
+
return total, combined_metadata
|
| 296 |
+
|
| 297 |
+
def prepare_commit(self) -> None:
|
| 298 |
+
"""Validate every layer before publishing any EMA scalar."""
|
| 299 |
+
if not self.attraction_ema_enabled:
|
| 300 |
+
return
|
| 301 |
+
for objective in self.objectives.values():
|
| 302 |
+
objective.prepare_commit()
|
| 303 |
+
|
| 304 |
+
def commit(self) -> None:
|
| 305 |
+
"""Publish one attraction EMA update for every layer."""
|
| 306 |
+
if not self.attraction_ema_enabled:
|
| 307 |
+
return
|
| 308 |
+
if not self.attraction_ema_pending:
|
| 309 |
+
return
|
| 310 |
+
self.prepare_commit()
|
| 311 |
+
for objective in self.objectives.values():
|
| 312 |
+
objective.commit()
|
| 313 |
+
|
| 314 |
+
def discard(self) -> None:
|
| 315 |
+
for objective in self.objectives.values():
|
| 316 |
+
objective.discard()
|
| 317 |
+
|
| 318 |
+
def attraction_ema_state_dict(self) -> Mapping[str, Any] | None:
|
| 319 |
+
if not self.attraction_ema_enabled:
|
| 320 |
+
return None
|
| 321 |
+
if self.attraction_ema_pending:
|
| 322 |
+
raise RuntimeError(
|
| 323 |
+
"refusing to checkpoint pending per-layer attraction EMA")
|
| 324 |
+
return {
|
| 325 |
+
"version": 1,
|
| 326 |
+
"layers": list(self.layer_indices),
|
| 327 |
+
"states": {
|
| 328 |
+
str(layer): self.objectives[
|
| 329 |
+
str(layer)].attraction_ema_state_dict()
|
| 330 |
+
for layer in self.layer_indices
|
| 331 |
+
},
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
def validate_attraction_ema_state_dict(
|
| 335 |
+
self,
|
| 336 |
+
state: Mapping[str, Any],
|
| 337 |
+
) -> Mapping[str, Any]:
|
| 338 |
+
if not self.attraction_ema_enabled:
|
| 339 |
+
raise ValueError(
|
| 340 |
+
"cannot load attraction EMA state while the path is disabled")
|
| 341 |
+
if (
|
| 342 |
+
not isinstance(state, Mapping)
|
| 343 |
+
or set(state) != {"version", "layers", "states"}
|
| 344 |
+
or state.get("version") != 1
|
| 345 |
+
or tuple(state.get("layers", ())) != self.layer_indices
|
| 346 |
+
or not isinstance(state.get("states"), Mapping)
|
| 347 |
+
or set(state["states"])
|
| 348 |
+
!= {str(layer) for layer in self.layer_indices}
|
| 349 |
+
):
|
| 350 |
+
raise ValueError(
|
| 351 |
+
"multi-layer attraction EMA checkpoint schema mismatch")
|
| 352 |
+
|
| 353 |
+
# Validate every layer before the first live scalar is mutated.
|
| 354 |
+
validated = {}
|
| 355 |
+
for layer in self.layer_indices:
|
| 356 |
+
objective = self.objectives[str(layer)]
|
| 357 |
+
validated[str(layer)] = (
|
| 358 |
+
objective.validate_attraction_ema_state_dict(
|
| 359 |
+
state["states"][str(layer)])
|
| 360 |
+
)
|
| 361 |
+
update_counts = {
|
| 362 |
+
int(layer_state["updates"].item())
|
| 363 |
+
for layer_state in validated.values()
|
| 364 |
+
}
|
| 365 |
+
if len(update_counts) != 1:
|
| 366 |
+
raise ValueError(
|
| 367 |
+
"multi-layer attraction EMA update counts disagree")
|
| 368 |
+
return {
|
| 369 |
+
"version": 1,
|
| 370 |
+
"layers": list(self.layer_indices),
|
| 371 |
+
"states": validated,
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
def load_attraction_ema_state_dict(
|
| 375 |
+
self,
|
| 376 |
+
state: Mapping[str, Any],
|
| 377 |
+
) -> None:
|
| 378 |
+
validated = self.validate_attraction_ema_state_dict(state)
|
| 379 |
+
for layer in self.layer_indices:
|
| 380 |
+
self.objectives[str(layer)].load_attraction_ema_state_dict(
|
| 381 |
+
validated["states"][str(layer)])
|
1.3B/code/losses/video_nystrom_mmd.py
ADDED
|
@@ -0,0 +1,1366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-encoder distribution matching: exact repulsion and reference attraction.
|
| 2 |
+
|
| 3 |
+
Reading map: offline helpers build the persistent Nyström reference; training
|
| 4 |
+
starts at ``VideoNystromMMD.forward``. Paper Sec. 3.2 calls its combination
|
| 5 |
+
with the Monte Carlo reference minibatch the hybrid reference kernel mean.
|
| 6 |
+
The released three-encoder path uses full-population partial gradients with
|
| 7 |
+
FIFO and attraction EMA disabled; those optional modes are retained below.
|
| 8 |
+
|
| 9 |
+
The released estimator includes diagonal pairs (B * B denominator) and the
|
| 10 |
+
reference-reference constant. This differs from the paper's self-excluding
|
| 11 |
+
repulsion and constant-free optimization expression; preserve both here,
|
| 12 |
+
including their effect on the release's subsequent detached loss scaling.
|
| 13 |
+
|
| 14 |
+
Every input sample is a complete ``[N, D]`` dense-video feature matrix. For
|
| 15 |
+
generated samples ``G``, frozen landmarks ``Z`` and a frozen reference pack,
|
| 16 |
+
the legacy objective is
|
| 17 |
+
|
| 18 |
+
mean(K_gg) - 2 * mean(K_gz @ alpha) + k_rr.
|
| 19 |
+
|
| 20 |
+
An opt-in matched-teacher path additionally accepts a fresh teacher batch
|
| 21 |
+
``T``. It computes the complete biased Gaussian-kernel terms ``mean(K_gt)``
|
| 22 |
+
and ``mean(K_tt)`` without sampling or approximation. The fixed-reference and
|
| 23 |
+
fresh-teacher MMDs are combined with normalized non-negative group weights, so
|
| 24 |
+
enabling a second teacher target does not duplicate the student repulsion or
|
| 25 |
+
silently double the total attraction coefficient.
|
| 26 |
+
|
| 27 |
+
The explicit partial-gradient interface uses gradient students ``G`` and a
|
| 28 |
+
detached full student population ``S``. Without explicit student indices it
|
| 29 |
+
preserves the rectangular estimator ``mean K(G,S)`` and evaluates attractions
|
| 30 |
+
only for ``G``. Supplying the positions of ``G`` in ``S`` opts into the
|
| 31 |
+
full-student estimator: selected differentiable values are reinserted into
|
| 32 |
+
detached ``S``, then the loss uses ``mean K(S,S)``, fixed attraction over
|
| 33 |
+
``S``, and matched-teacher attraction over ``S x T``. Thus only ``G`` gets
|
| 34 |
+
gradients, including both sides of the full self-kernel. Both partial modes are defined without FIFO or attraction EMA, since those
|
| 35 |
+
stateful transforms change their stated estimators.
|
| 36 |
+
|
| 37 |
+
By default, ``K_gg`` is the exact, biased Gaussian-RBF Gram matrix (including
|
| 38 |
+
its diagonal). An optional transactional FIFO changes only this repulsion
|
| 39 |
+
term to the current-current off-diagonal sum plus the current-history cross
|
| 40 |
+
sum, normalized by the exact number of ordered pairs. FIFO entries are
|
| 41 |
+
detached FP32 feature matrices and are published only after optimizer success.
|
| 42 |
+
Only the generated samples receive gradients. The reference attraction is
|
| 43 |
+
represented by a kernel-ridge/Nyström fit and ``k_rr`` is the exact biased
|
| 44 |
+
reference-reference constant.
|
| 45 |
+
|
| 46 |
+
An optional transactional path applies an EMA to the complete scalar
|
| 47 |
+
attraction term only. The exact current-batch repulsion is never averaged.
|
| 48 |
+
The current attraction keeps its natural ``1-beta`` derivative after the
|
| 49 |
+
first committed update; historical attraction values are detached.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import hashlib
|
| 55 |
+
import hmac
|
| 56 |
+
import json
|
| 57 |
+
import math
|
| 58 |
+
import os
|
| 59 |
+
import tempfile
|
| 60 |
+
from collections.abc import Mapping, Sequence
|
| 61 |
+
from typing import Any, Dict, Optional, Tuple
|
| 62 |
+
|
| 63 |
+
import torch
|
| 64 |
+
from torch import nn
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
REFERENCE_PACK_VERSION = 1
|
| 68 |
+
_PACK_KEYS = {
|
| 69 |
+
"format_version",
|
| 70 |
+
"landmarks",
|
| 71 |
+
"alpha",
|
| 72 |
+
"k_rr",
|
| 73 |
+
"sigma",
|
| 74 |
+
"metadata",
|
| 75 |
+
"content_hash",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def canonicalize_metadata(metadata: Mapping[str, Any]) -> Dict[str, Any]:
|
| 80 |
+
"""Return a deterministic, JSON-safe metadata mapping."""
|
| 81 |
+
try:
|
| 82 |
+
encoded = json.dumps(
|
| 83 |
+
dict(metadata),
|
| 84 |
+
sort_keys=True,
|
| 85 |
+
separators=(",", ":"),
|
| 86 |
+
ensure_ascii=True,
|
| 87 |
+
allow_nan=False,
|
| 88 |
+
)
|
| 89 |
+
except (TypeError, ValueError) as exc:
|
| 90 |
+
raise ValueError(
|
| 91 |
+
"metadata must contain only finite JSON-serializable values"
|
| 92 |
+
) from exc
|
| 93 |
+
decoded = json.loads(encoded)
|
| 94 |
+
return decoded
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _canonical_json_sha256(value: Mapping[str, Any]) -> str:
|
| 98 |
+
payload = json.dumps(
|
| 99 |
+
dict(value),
|
| 100 |
+
sort_keys=True,
|
| 101 |
+
separators=(",", ":"),
|
| 102 |
+
ensure_ascii=True,
|
| 103 |
+
allow_nan=False,
|
| 104 |
+
).encode("utf-8")
|
| 105 |
+
return hashlib.sha256(payload).hexdigest()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _canonicalize_shared_mount_aliases(value: Any) -> tuple[Any, bool]:
|
| 109 |
+
"""Normalize deployment paths in DINOv3 contract metadata.
|
| 110 |
+
|
| 111 |
+
Only deployment paths are
|
| 112 |
+
normalized; encoder revision, weight digest, and every feature setting stay
|
| 113 |
+
in the comparison. The original reference pack and its content hash are
|
| 114 |
+
never rewritten.
|
| 115 |
+
"""
|
| 116 |
+
if isinstance(value, Mapping):
|
| 117 |
+
original = dict(value)
|
| 118 |
+
normalized = {}
|
| 119 |
+
changed = False
|
| 120 |
+
for key, item in original.items():
|
| 121 |
+
normalized[key], item_changed = _canonicalize_shared_mount_aliases(item)
|
| 122 |
+
changed = changed or item_changed
|
| 123 |
+
if original.get("feature_type") == "dinov3_dense_latent" and (
|
| 124 |
+
"repo_path" in original or "weight_path" in original
|
| 125 |
+
):
|
| 126 |
+
weight = str(original.get("weight_sha256", ""))
|
| 127 |
+
revision = str(original.get("source_commit", ""))
|
| 128 |
+
normalized["repo_path"] = "source:" + revision
|
| 129 |
+
normalized["weight_path"] = "sha256:" + weight
|
| 130 |
+
normalized.pop("feature_contract_sha256", None)
|
| 131 |
+
normalized["feature_contract_sha256"] = _canonical_json_sha256(normalized)
|
| 132 |
+
changed = True
|
| 133 |
+
return normalized, changed
|
| 134 |
+
if isinstance(value, list):
|
| 135 |
+
normalized = []
|
| 136 |
+
changed = False
|
| 137 |
+
for item in value:
|
| 138 |
+
result, item_changed = _canonicalize_shared_mount_aliases(item)
|
| 139 |
+
normalized.append(result)
|
| 140 |
+
changed = changed or item_changed
|
| 141 |
+
return normalized, changed
|
| 142 |
+
return value, False
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _metadata_contains(actual: Any, expected: Any, path: str = "metadata") -> None:
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _validate_video_tensor(tensor: torch.Tensor, name: str) -> Tuple[int, int, int]:
|
| 150 |
+
return int(tensor.shape[0]), int(tensor.shape[1]), int(tensor.shape[2])
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def pairwise_frobenius_sq(
|
| 154 |
+
left: torch.Tensor,
|
| 155 |
+
right: torch.Tensor,
|
| 156 |
+
) -> torch.Tensor:
|
| 157 |
+
"""Squared Frobenius distances between ``[B,N,D]`` video matrices."""
|
| 158 |
+
_, left_n, left_d = _validate_video_tensor(left, "left")
|
| 159 |
+
_, right_n, right_d = _validate_video_tensor(right, "right")
|
| 160 |
+
left_flat = left.float().flatten(1)
|
| 161 |
+
right_flat = right.float().flatten(1)
|
| 162 |
+
# vector_norm performs the reduction without allocating an elementwise
|
| 163 |
+
# square tensor the size of a potentially 80-GiB FP32 reference.
|
| 164 |
+
left_sq = torch.linalg.vector_norm(
|
| 165 |
+
left_flat, dim=1, dtype=torch.float32).square().unsqueeze(1)
|
| 166 |
+
right_sq = torch.linalg.vector_norm(
|
| 167 |
+
right_flat, dim=1, dtype=torch.float32).square().unsqueeze(0)
|
| 168 |
+
# Scale only the small pairwise result; scaling left_flat first would allocate
|
| 169 |
+
# another full reference-sized tensor (about 80 GiB for the 20K bank).
|
| 170 |
+
cross = left_flat @ right_flat.t()
|
| 171 |
+
return (left_sq + right_sq - 2.0 * cross).clamp_min(0)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def rbf_frobenius_kernel(
|
| 175 |
+
left: torch.Tensor,
|
| 176 |
+
right: torch.Tensor,
|
| 177 |
+
sigma: torch.Tensor | float,
|
| 178 |
+
) -> torch.Tensor:
|
| 179 |
+
"""Gaussian RBF kernel using complete video-matrix samples."""
|
| 180 |
+
sigma_tensor = torch.as_tensor(
|
| 181 |
+
sigma, device=left.device, dtype=torch.float32)
|
| 182 |
+
distances_sq = pairwise_frobenius_sq(left, right)
|
| 183 |
+
return torch.exp(-distances_sq / (2.0 * sigma_tensor.square()))
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@torch.no_grad()
|
| 187 |
+
def median_frobenius_sigma(
|
| 188 |
+
reference: torch.Tensor,
|
| 189 |
+
*,
|
| 190 |
+
max_samples: Optional[int] = 2048,
|
| 191 |
+
chunk_size: int = 256,
|
| 192 |
+
min_sigma: float = 1.0e-6,
|
| 193 |
+
) -> torch.Tensor:
|
| 194 |
+
"""Deterministic median-distance bandwidth over video samples.
|
| 195 |
+
|
| 196 |
+
A deterministic linspace subset bounds the number of pair distances.
|
| 197 |
+
Pair blocks are constructed in chunks; only the final one-dimensional
|
| 198 |
+
vector of upper-triangular distances is retained for the exact median of
|
| 199 |
+
that subset.
|
| 200 |
+
"""
|
| 201 |
+
count, _, _ = _validate_video_tensor(reference, "reference")
|
| 202 |
+
samples = reference.detach().float()
|
| 203 |
+
if max_samples is not None:
|
| 204 |
+
if count > int(max_samples):
|
| 205 |
+
indices = torch.linspace(
|
| 206 |
+
0,
|
| 207 |
+
count - 1,
|
| 208 |
+
steps=int(max_samples),
|
| 209 |
+
device=samples.device,
|
| 210 |
+
dtype=torch.float64,
|
| 211 |
+
).round().long()
|
| 212 |
+
samples = samples.index_select(0, indices)
|
| 213 |
+
count = int(samples.shape[0])
|
| 214 |
+
if count < 2:
|
| 215 |
+
return samples.new_tensor(1.0)
|
| 216 |
+
|
| 217 |
+
pieces = []
|
| 218 |
+
for row_start in range(0, count, chunk_size):
|
| 219 |
+
row_end = min(row_start + chunk_size, count)
|
| 220 |
+
rows = samples[row_start:row_end]
|
| 221 |
+
for col_start in range(row_start, count, chunk_size):
|
| 222 |
+
col_end = min(col_start + chunk_size, count)
|
| 223 |
+
block = pairwise_frobenius_sq(
|
| 224 |
+
rows, samples[col_start:col_end])
|
| 225 |
+
if col_start == row_start:
|
| 226 |
+
upper = torch.triu_indices(
|
| 227 |
+
row_end - row_start,
|
| 228 |
+
col_end - col_start,
|
| 229 |
+
offset=1,
|
| 230 |
+
device=block.device,
|
| 231 |
+
)
|
| 232 |
+
values = block[upper[0], upper[1]]
|
| 233 |
+
else:
|
| 234 |
+
values = block.reshape(-1)
|
| 235 |
+
positive = values[values > 0]
|
| 236 |
+
if positive.numel():
|
| 237 |
+
pieces.append(positive)
|
| 238 |
+
if not pieces:
|
| 239 |
+
return samples.new_tensor(1.0)
|
| 240 |
+
median_sq = torch.cat(pieces).median().clamp_min(float(min_sigma) ** 2)
|
| 241 |
+
return median_sq.sqrt()
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _nearest_centroid_labels(
|
| 245 |
+
samples: torch.Tensor,
|
| 246 |
+
centroids: torch.Tensor,
|
| 247 |
+
chunk_size: int,
|
| 248 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 249 |
+
labels = []
|
| 250 |
+
minimum_sq = []
|
| 251 |
+
for start in range(0, samples.shape[0], chunk_size):
|
| 252 |
+
distances = pairwise_frobenius_sq(
|
| 253 |
+
samples[start:start + chunk_size], centroids)
|
| 254 |
+
values, indices = distances.min(dim=1)
|
| 255 |
+
labels.append(indices)
|
| 256 |
+
minimum_sq.append(values)
|
| 257 |
+
return torch.cat(labels), torch.cat(minimum_sq)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _deterministic_cluster_sums(
|
| 261 |
+
flattened: torch.Tensor,
|
| 262 |
+
labels: torch.Tensor,
|
| 263 |
+
num_clusters: int,
|
| 264 |
+
*,
|
| 265 |
+
max_workspace_bytes: int = 1 << 30,
|
| 266 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 267 |
+
"""Segment-sum rows without CUDA atomics or an N-by-D temporary.
|
| 268 |
+
|
| 269 |
+
Rows are sorted once, while feature columns are processed in bounded
|
| 270 |
+
chunks. Each column retains the same fixed-order prefix sum as the legacy
|
| 271 |
+
implementation, but joint video/text references no longer require two
|
| 272 |
+
additional full-reference CUDA tensors.
|
| 273 |
+
"""
|
| 274 |
+
counts = torch.bincount(labels, minlength=int(num_clusters))
|
| 275 |
+
order = torch.argsort(labels, stable=True)
|
| 276 |
+
sums = flattened.new_zeros(
|
| 277 |
+
(int(num_clusters), flattened.shape[1]))
|
| 278 |
+
nonempty = counts > 0
|
| 279 |
+
ends = counts.cumsum(0) - 1
|
| 280 |
+
selected_ends = ends[nonempty]
|
| 281 |
+
starts = selected_ends - counts[nonempty] + 1
|
| 282 |
+
has_predecessor = starts > 0
|
| 283 |
+
# ``index_select`` and ``cumsum`` coexist, so budget for both tensors.
|
| 284 |
+
bytes_per_column = max(1, 2 * flattened.shape[0] * flattened.element_size())
|
| 285 |
+
feature_chunk = max(
|
| 286 |
+
1, min(flattened.shape[1], int(max_workspace_bytes) // bytes_per_column))
|
| 287 |
+
for begin in range(0, flattened.shape[1], feature_chunk):
|
| 288 |
+
end = min(begin + feature_chunk, flattened.shape[1])
|
| 289 |
+
sorted_values = flattened[:, begin:end].index_select(0, order)
|
| 290 |
+
prefix = sorted_values.cumsum(dim=0)
|
| 291 |
+
cluster_sums = prefix.index_select(0, selected_ends)
|
| 292 |
+
if bool(has_predecessor.any()):
|
| 293 |
+
cluster_sums[has_predecessor] -= prefix.index_select(
|
| 294 |
+
0, starts[has_predecessor] - 1)
|
| 295 |
+
sums[nonempty, begin:end] = cluster_sums
|
| 296 |
+
return sums, counts
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@torch.no_grad()
|
| 300 |
+
def deterministic_video_kmeans(
|
| 301 |
+
reference: torch.Tensor,
|
| 302 |
+
num_landmarks: int,
|
| 303 |
+
*,
|
| 304 |
+
iterations: int = 20,
|
| 305 |
+
chunk_size: int = 256,
|
| 306 |
+
) -> torch.Tensor:
|
| 307 |
+
"""Deterministic farthest-first initialized Lloyd k-means."""
|
| 308 |
+
count, num_tokens, feature_dim = _validate_video_tensor(
|
| 309 |
+
reference, "reference")
|
| 310 |
+
num_landmarks = int(num_landmarks)
|
| 311 |
+
|
| 312 |
+
samples = reference.detach().float()
|
| 313 |
+
global_mean = samples.mean(dim=0, keepdim=True)
|
| 314 |
+
first = int(
|
| 315 |
+
pairwise_frobenius_sq(samples, global_mean)
|
| 316 |
+
.squeeze(1)
|
| 317 |
+
.argmax()
|
| 318 |
+
.item()
|
| 319 |
+
)
|
| 320 |
+
selected = [first]
|
| 321 |
+
closest_sq = pairwise_frobenius_sq(
|
| 322 |
+
samples, samples[first:first + 1]).squeeze(1)
|
| 323 |
+
for _ in range(1, num_landmarks):
|
| 324 |
+
next_index = int(closest_sq.argmax().item())
|
| 325 |
+
selected.append(next_index)
|
| 326 |
+
next_sq = pairwise_frobenius_sq(
|
| 327 |
+
samples, samples[next_index:next_index + 1]).squeeze(1)
|
| 328 |
+
closest_sq = torch.minimum(closest_sq, next_sq)
|
| 329 |
+
|
| 330 |
+
centroids = samples.index_select(
|
| 331 |
+
0, torch.tensor(selected, device=samples.device)).clone()
|
| 332 |
+
previous_labels = None
|
| 333 |
+
for _ in range(int(iterations)):
|
| 334 |
+
labels, _ = _nearest_centroid_labels(
|
| 335 |
+
samples, centroids, int(chunk_size))
|
| 336 |
+
if previous_labels is not None and torch.equal(labels, previous_labels):
|
| 337 |
+
break
|
| 338 |
+
previous_labels = labels
|
| 339 |
+
flattened = samples.flatten(1)
|
| 340 |
+
sums, cluster_counts = _deterministic_cluster_sums(
|
| 341 |
+
flattened, labels, num_landmarks)
|
| 342 |
+
nonempty = cluster_counts > 0
|
| 343 |
+
updated = centroids.flatten(1).clone()
|
| 344 |
+
updated[nonempty] = (
|
| 345 |
+
sums[nonempty]
|
| 346 |
+
/ cluster_counts[nonempty, None].to(sums.dtype)
|
| 347 |
+
)
|
| 348 |
+
centroids = updated.reshape(
|
| 349 |
+
num_landmarks, num_tokens, feature_dim)
|
| 350 |
+
return centroids
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@torch.no_grad()
|
| 354 |
+
def compute_nystrom_alpha(
|
| 355 |
+
reference: torch.Tensor,
|
| 356 |
+
landmarks: torch.Tensor,
|
| 357 |
+
sigma: torch.Tensor | float,
|
| 358 |
+
*,
|
| 359 |
+
ridge: float = 1.0e-6,
|
| 360 |
+
chunk_size: int = 256,
|
| 361 |
+
) -> torch.Tensor:
|
| 362 |
+
"""Fit coefficients for the persistent Nyström reference kernel mean.
|
| 363 |
+
|
| 364 |
+
Solve (K_ZZ + ridge * I) alpha = mean_R K_ZR once, offline. Training
|
| 365 |
+
evaluates K(x, Z) @ alpha without revisiting the full reference bank R.
|
| 366 |
+
Code ``alpha`` is the paper's coefficient vector beta (Appendix C.2),
|
| 367 |
+
not the scalar mixture weight alpha_e in the hybrid mean (Eq. 9).
|
| 368 |
+
"""
|
| 369 |
+
ref_count, ref_n, ref_d = _validate_video_tensor(reference, "reference")
|
| 370 |
+
_, landmark_n, landmark_d = _validate_video_tensor(
|
| 371 |
+
landmarks, "landmarks")
|
| 372 |
+
|
| 373 |
+
reference = reference.detach().float()
|
| 374 |
+
landmarks = landmarks.detach().float()
|
| 375 |
+
target = landmarks.new_zeros(landmarks.shape[0], dtype=torch.float64)
|
| 376 |
+
for start in range(0, ref_count, int(chunk_size)):
|
| 377 |
+
block = rbf_frobenius_kernel(
|
| 378 |
+
landmarks, reference[start:start + int(chunk_size)], sigma)
|
| 379 |
+
target.add_(block.double().sum(dim=1))
|
| 380 |
+
target.div_(float(ref_count))
|
| 381 |
+
|
| 382 |
+
kernel_zz = rbf_frobenius_kernel(
|
| 383 |
+
landmarks, landmarks, sigma).double()
|
| 384 |
+
kernel_zz = 0.5 * (kernel_zz + kernel_zz.t())
|
| 385 |
+
kernel_zz.fill_diagonal_(1.0)
|
| 386 |
+
regularized = kernel_zz + float(ridge) * torch.eye(
|
| 387 |
+
landmarks.shape[0],
|
| 388 |
+
device=landmarks.device,
|
| 389 |
+
dtype=torch.float64,
|
| 390 |
+
)
|
| 391 |
+
try:
|
| 392 |
+
alpha = torch.linalg.solve(regularized, target)
|
| 393 |
+
except RuntimeError as exc:
|
| 394 |
+
raise ValueError(
|
| 395 |
+
"Nyström landmark kernel is singular; increase ridge") from exc
|
| 396 |
+
return alpha.float()
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
@torch.no_grad()
|
| 400 |
+
def compute_reference_krr(
|
| 401 |
+
reference: torch.Tensor,
|
| 402 |
+
sigma: torch.Tensor | float,
|
| 403 |
+
*,
|
| 404 |
+
chunk_size: int = 256,
|
| 405 |
+
) -> torch.Tensor:
|
| 406 |
+
"""Exact biased ``mean(K_rr)``, including the reference diagonal."""
|
| 407 |
+
count, _, _ = _validate_video_tensor(reference, "reference")
|
| 408 |
+
reference = reference.detach().float()
|
| 409 |
+
total = reference.new_zeros((), dtype=torch.float64)
|
| 410 |
+
for row_start in range(0, count, int(chunk_size)):
|
| 411 |
+
rows = reference[row_start:row_start + int(chunk_size)]
|
| 412 |
+
for col_start in range(0, count, int(chunk_size)):
|
| 413 |
+
cols = reference[col_start:col_start + int(chunk_size)]
|
| 414 |
+
kernel = rbf_frobenius_kernel(rows, cols, sigma)
|
| 415 |
+
block_sum = kernel.double().sum()
|
| 416 |
+
if row_start == col_start:
|
| 417 |
+
block_sum.add_(
|
| 418 |
+
(1.0 - kernel.diagonal()).double().sum())
|
| 419 |
+
total.add_(block_sum)
|
| 420 |
+
return (total / float(count * count)).float()
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _hash_tensor(hasher: Any, name: str, tensor: torch.Tensor) -> None:
|
| 424 |
+
value = tensor.detach().cpu().contiguous()
|
| 425 |
+
descriptor = {
|
| 426 |
+
"name": name,
|
| 427 |
+
"dtype": str(value.dtype),
|
| 428 |
+
"shape": list(value.shape),
|
| 429 |
+
}
|
| 430 |
+
hasher.update(json.dumps(
|
| 431 |
+
descriptor, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
| 432 |
+
raw = value.reshape(-1).view(torch.uint8).numpy().tobytes()
|
| 433 |
+
hasher.update(raw)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def compute_reference_pack_hash(pack: Mapping[str, Any]) -> str:
|
| 437 |
+
"""Hash pack contents, deliberately excluding ``content_hash`` itself."""
|
| 438 |
+
required_without_hash = _PACK_KEYS - {"content_hash"}
|
| 439 |
+
missing = required_without_hash - set(pack)
|
| 440 |
+
metadata = canonicalize_metadata(pack["metadata"])
|
| 441 |
+
hasher = hashlib.sha256()
|
| 442 |
+
hasher.update(b"video-nystrom-reference-pack\0")
|
| 443 |
+
hasher.update(str(int(pack["format_version"])).encode("ascii"))
|
| 444 |
+
hasher.update(json.dumps(
|
| 445 |
+
metadata,
|
| 446 |
+
sort_keys=True,
|
| 447 |
+
separators=(",", ":"),
|
| 448 |
+
ensure_ascii=True,
|
| 449 |
+
).encode("utf-8"))
|
| 450 |
+
for name in ("landmarks", "alpha", "k_rr", "sigma"):
|
| 451 |
+
value = pack[name]
|
| 452 |
+
_hash_tensor(hasher, name, value)
|
| 453 |
+
return hasher.hexdigest()
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def validate_reference_pack(
|
| 457 |
+
pack: Mapping[str, Any],
|
| 458 |
+
*,
|
| 459 |
+
expected_metadata: Optional[Mapping[str, Any]] = None,
|
| 460 |
+
) -> Dict[str, Any]:
|
| 461 |
+
"""Prepare reference tensors without enforcing metadata or content identity."""
|
| 462 |
+
return {
|
| 463 |
+
"format_version": REFERENCE_PACK_VERSION,
|
| 464 |
+
"landmarks": pack["landmarks"],
|
| 465 |
+
"alpha": pack["alpha"],
|
| 466 |
+
"k_rr": pack["k_rr"].reshape(()),
|
| 467 |
+
"sigma": pack["sigma"].reshape(()),
|
| 468 |
+
"metadata": canonicalize_metadata(pack["metadata"]),
|
| 469 |
+
"content_hash": pack["content_hash"],
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
@torch.no_grad()
|
| 474 |
+
def build_video_nystrom_reference(
|
| 475 |
+
reference: torch.Tensor,
|
| 476 |
+
*,
|
| 477 |
+
num_landmarks: int,
|
| 478 |
+
metadata: Mapping[str, Any],
|
| 479 |
+
bandwidth: Optional[float] = None,
|
| 480 |
+
median_max_samples: Optional[int] = 2048,
|
| 481 |
+
kmeans_iterations: int = 20,
|
| 482 |
+
ridge: float = 1.0e-6,
|
| 483 |
+
chunk_size: int = 256,
|
| 484 |
+
seed: int = 0,
|
| 485 |
+
) -> Dict[str, Any]:
|
| 486 |
+
"""Construct a frozen, portable Nyström reference pack."""
|
| 487 |
+
reference_count, num_tokens, feature_dim = _validate_video_tensor(
|
| 488 |
+
reference, "reference")
|
| 489 |
+
seed = int(seed)
|
| 490 |
+
reference = reference.detach().float()
|
| 491 |
+
if bandwidth is None:
|
| 492 |
+
sigma = median_frobenius_sigma(
|
| 493 |
+
reference,
|
| 494 |
+
max_samples=median_max_samples,
|
| 495 |
+
chunk_size=int(chunk_size),
|
| 496 |
+
)
|
| 497 |
+
bandwidth_source = "median_frobenius_distance"
|
| 498 |
+
else:
|
| 499 |
+
sigma = reference.new_tensor(float(bandwidth))
|
| 500 |
+
bandwidth_source = "provided"
|
| 501 |
+
|
| 502 |
+
landmarks = deterministic_video_kmeans(
|
| 503 |
+
reference,
|
| 504 |
+
int(num_landmarks),
|
| 505 |
+
iterations=int(kmeans_iterations),
|
| 506 |
+
chunk_size=int(chunk_size),
|
| 507 |
+
)
|
| 508 |
+
alpha = compute_nystrom_alpha(
|
| 509 |
+
reference,
|
| 510 |
+
landmarks,
|
| 511 |
+
sigma,
|
| 512 |
+
ridge=float(ridge),
|
| 513 |
+
chunk_size=int(chunk_size),
|
| 514 |
+
)
|
| 515 |
+
k_rr = compute_reference_krr(
|
| 516 |
+
reference, sigma, chunk_size=int(chunk_size))
|
| 517 |
+
|
| 518 |
+
canonical_metadata = canonicalize_metadata(metadata)
|
| 519 |
+
technical_metadata = {
|
| 520 |
+
"sample_axis": "video",
|
| 521 |
+
"sample_shape": [num_tokens, feature_dim],
|
| 522 |
+
"kernel": "gaussian_rbf",
|
| 523 |
+
"kernel_metric": "frobenius",
|
| 524 |
+
"generated_gram": "biased_exact_including_diagonal",
|
| 525 |
+
"reference_count": reference_count,
|
| 526 |
+
"num_landmarks": int(num_landmarks),
|
| 527 |
+
"bandwidth_source": bandwidth_source,
|
| 528 |
+
"median_max_samples": (
|
| 529 |
+
None if median_max_samples is None else int(median_max_samples)
|
| 530 |
+
),
|
| 531 |
+
"kmeans": "deterministic_farthest_first_lloyd",
|
| 532 |
+
"seed": seed,
|
| 533 |
+
"kmeans_iterations": int(kmeans_iterations),
|
| 534 |
+
"ridge": float(ridge),
|
| 535 |
+
}
|
| 536 |
+
canonical_metadata["nystrom"] = technical_metadata
|
| 537 |
+
canonical_metadata = canonicalize_metadata(canonical_metadata)
|
| 538 |
+
|
| 539 |
+
pack: Dict[str, Any] = {
|
| 540 |
+
"format_version": REFERENCE_PACK_VERSION,
|
| 541 |
+
"landmarks": landmarks.detach().cpu().contiguous(),
|
| 542 |
+
"alpha": alpha.detach().cpu().contiguous(),
|
| 543 |
+
"k_rr": k_rr.detach().cpu().reshape(()),
|
| 544 |
+
"sigma": sigma.detach().cpu().reshape(()),
|
| 545 |
+
"metadata": canonical_metadata,
|
| 546 |
+
}
|
| 547 |
+
pack["content_hash"] = compute_reference_pack_hash(pack)
|
| 548 |
+
return validate_reference_pack(pack)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def save_reference_pack(
|
| 552 |
+
pack: Mapping[str, Any],
|
| 553 |
+
path: str | os.PathLike,
|
| 554 |
+
*,
|
| 555 |
+
atomic: bool = True,
|
| 556 |
+
) -> None:
|
| 557 |
+
"""Validate and save a portable pack, atomically by default."""
|
| 558 |
+
validated = validate_reference_pack(pack)
|
| 559 |
+
portable = {
|
| 560 |
+
**validated,
|
| 561 |
+
"landmarks": validated["landmarks"].detach().cpu().contiguous(),
|
| 562 |
+
"alpha": validated["alpha"].detach().cpu().contiguous(),
|
| 563 |
+
"k_rr": validated["k_rr"].detach().cpu().reshape(()),
|
| 564 |
+
"sigma": validated["sigma"].detach().cpu().reshape(()),
|
| 565 |
+
}
|
| 566 |
+
# Moving tensors to CPU is part of the serialized representation, so hash
|
| 567 |
+
# the exact portable payload rather than copying the incoming hash blindly.
|
| 568 |
+
portable["content_hash"] = compute_reference_pack_hash(portable)
|
| 569 |
+
destination = os.path.abspath(os.fspath(path))
|
| 570 |
+
if not atomic:
|
| 571 |
+
torch.save(portable, destination)
|
| 572 |
+
return
|
| 573 |
+
directory = os.path.dirname(destination)
|
| 574 |
+
descriptor, temporary = tempfile.mkstemp(
|
| 575 |
+
prefix=f".{os.path.basename(destination)}.",
|
| 576 |
+
suffix=".tmp",
|
| 577 |
+
dir=directory,
|
| 578 |
+
)
|
| 579 |
+
os.close(descriptor)
|
| 580 |
+
try:
|
| 581 |
+
torch.save(portable, temporary)
|
| 582 |
+
os.replace(temporary, destination)
|
| 583 |
+
finally:
|
| 584 |
+
if os.path.exists(temporary):
|
| 585 |
+
os.unlink(temporary)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def load_reference_pack(
|
| 589 |
+
path: str | os.PathLike,
|
| 590 |
+
*,
|
| 591 |
+
expected_metadata: Optional[Mapping[str, Any]] = None,
|
| 592 |
+
map_location: str | torch.device = "cpu",
|
| 593 |
+
) -> Dict[str, Any]:
|
| 594 |
+
"""Load a reference pack and prepare its tensors."""
|
| 595 |
+
try:
|
| 596 |
+
pack = torch.load(
|
| 597 |
+
os.fspath(path),
|
| 598 |
+
map_location=map_location,
|
| 599 |
+
weights_only=True,
|
| 600 |
+
)
|
| 601 |
+
except TypeError:
|
| 602 |
+
pack = torch.load(os.fspath(path), map_location=map_location)
|
| 603 |
+
return validate_reference_pack(
|
| 604 |
+
pack, expected_metadata=expected_metadata)
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def _kernel_sum_chunked(
|
| 608 |
+
left: torch.Tensor,
|
| 609 |
+
right: torch.Tensor,
|
| 610 |
+
sigma: torch.Tensor,
|
| 611 |
+
chunk_size: int,
|
| 612 |
+
) -> torch.Tensor:
|
| 613 |
+
total = left.new_zeros(())
|
| 614 |
+
self_kernel = left is right
|
| 615 |
+
for row_start in range(0, left.shape[0], chunk_size):
|
| 616 |
+
rows = left[row_start:row_start + chunk_size]
|
| 617 |
+
for col_start in range(0, right.shape[0], chunk_size):
|
| 618 |
+
cols = right[col_start:col_start + chunk_size]
|
| 619 |
+
kernel = rbf_frobenius_kernel(rows, cols, sigma)
|
| 620 |
+
block_sum = kernel.sum()
|
| 621 |
+
if self_kernel and row_start == col_start:
|
| 622 |
+
# The RBF self-kernel is analytically one. Explicitly replace
|
| 623 |
+
# it to avoid norm/Gram cancellation error in FP32.
|
| 624 |
+
diagonal = kernel.diagonal()
|
| 625 |
+
block_sum = block_sum + (1.0 - diagonal).sum()
|
| 626 |
+
total = total + block_sum
|
| 627 |
+
return total
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def _kernel_matvec_chunked(
|
| 631 |
+
samples: torch.Tensor,
|
| 632 |
+
landmarks: torch.Tensor,
|
| 633 |
+
alpha: torch.Tensor,
|
| 634 |
+
sigma: torch.Tensor,
|
| 635 |
+
chunk_size: int,
|
| 636 |
+
) -> torch.Tensor:
|
| 637 |
+
outputs = []
|
| 638 |
+
for row_start in range(0, samples.shape[0], chunk_size):
|
| 639 |
+
rows = samples[row_start:row_start + chunk_size]
|
| 640 |
+
values = rows.new_zeros(rows.shape[0])
|
| 641 |
+
for col_start in range(0, landmarks.shape[0], chunk_size):
|
| 642 |
+
cols = landmarks[col_start:col_start + chunk_size]
|
| 643 |
+
kernel = rbf_frobenius_kernel(rows, cols, sigma)
|
| 644 |
+
values = values + kernel @ alpha[
|
| 645 |
+
col_start:col_start + chunk_size]
|
| 646 |
+
outputs.append(values)
|
| 647 |
+
return torch.cat(outputs)
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
class VideoNystromMMD(nn.Module):
|
| 651 |
+
"""One encoder's MMD: repulsion - 2 * attraction + reference constant.
|
| 652 |
+
|
| 653 |
+
The generated-sample kernel is evaluated exactly. Nyström approximates
|
| 654 |
+
only attraction to the persistent reference bank; attraction to the
|
| 655 |
+
current reference minibatch is also exact when enabled.
|
| 656 |
+
"""
|
| 657 |
+
|
| 658 |
+
_STATE_VERSION = 1
|
| 659 |
+
_ATTRACTION_EMA_STATE_VERSION = 1
|
| 660 |
+
_REPULSION_FIFO_STATE_VERSION = 1
|
| 661 |
+
|
| 662 |
+
def __init__(
|
| 663 |
+
self,
|
| 664 |
+
reference_pack: Mapping[str, Any],
|
| 665 |
+
*,
|
| 666 |
+
expected_metadata: Optional[Mapping[str, Any]] = None,
|
| 667 |
+
kernel_chunk_size: int = 256,
|
| 668 |
+
attraction_ema_enabled: bool = False,
|
| 669 |
+
attraction_ema_beta: float = 0.99,
|
| 670 |
+
attraction_weight: float = 1.0,
|
| 671 |
+
current_teacher_attraction_enabled: bool = False,
|
| 672 |
+
current_teacher_attraction_weight: float = 1.0,
|
| 673 |
+
repulsion_fifo_size: int = 0,
|
| 674 |
+
):
|
| 675 |
+
super().__init__()
|
| 676 |
+
current_teacher_attraction_enabled = bool(
|
| 677 |
+
current_teacher_attraction_enabled)
|
| 678 |
+
repulsion_fifo_size = int(repulsion_fifo_size)
|
| 679 |
+
pack = validate_reference_pack(
|
| 680 |
+
reference_pack, expected_metadata=expected_metadata)
|
| 681 |
+
self.kernel_chunk_size = int(kernel_chunk_size)
|
| 682 |
+
self.attraction_ema_enabled = bool(attraction_ema_enabled)
|
| 683 |
+
self.attraction_ema_beta = float(attraction_ema_beta)
|
| 684 |
+
self.attraction_weight = float(attraction_weight)
|
| 685 |
+
self.current_teacher_attraction_enabled = (
|
| 686 |
+
current_teacher_attraction_enabled)
|
| 687 |
+
self.current_teacher_attraction_weight = float(
|
| 688 |
+
current_teacher_attraction_weight)
|
| 689 |
+
self.repulsion_fifo_size = repulsion_fifo_size
|
| 690 |
+
self.repulsion_fifo_enabled = bool(repulsion_fifo_size > 0)
|
| 691 |
+
self.reference_metadata = pack["metadata"]
|
| 692 |
+
self.reference_hash = pack["content_hash"]
|
| 693 |
+
self.register_buffer(
|
| 694 |
+
"landmarks", pack["landmarks"].detach().float().clone(),
|
| 695 |
+
persistent=True)
|
| 696 |
+
self.register_buffer(
|
| 697 |
+
"alpha", pack["alpha"].detach().float().clone(),
|
| 698 |
+
persistent=True)
|
| 699 |
+
self.register_buffer(
|
| 700 |
+
"k_rr", pack["k_rr"].detach().float().reshape(()).clone(),
|
| 701 |
+
persistent=True)
|
| 702 |
+
self.register_buffer(
|
| 703 |
+
"sigma", pack["sigma"].detach().float().reshape(()).clone(),
|
| 704 |
+
persistent=True)
|
| 705 |
+
# FIFO contents have their own strict trainer-checkpoint payload. A
|
| 706 |
+
# non-persistent buffer still follows ``module.to(device)`` without
|
| 707 |
+
# silently duplicating the large history in the module state_dict.
|
| 708 |
+
self.register_buffer(
|
| 709 |
+
"repulsion_fifo",
|
| 710 |
+
torch.empty(
|
| 711 |
+
(0, int(self.landmarks.shape[1]), int(self.landmarks.shape[2])),
|
| 712 |
+
dtype=torch.float32,
|
| 713 |
+
device=self.landmarks.device,
|
| 714 |
+
),
|
| 715 |
+
persistent=False,
|
| 716 |
+
)
|
| 717 |
+
self.register_buffer(
|
| 718 |
+
"repulsion_fifo_commits",
|
| 719 |
+
torch.zeros((), dtype=torch.long, device=self.landmarks.device),
|
| 720 |
+
persistent=False,
|
| 721 |
+
)
|
| 722 |
+
# Keep the disabled module's state_dict byte-schema compatible with
|
| 723 |
+
# the original exact objective. Enabled modules persist these two
|
| 724 |
+
# scalars and advertise the changed schema through extra state.
|
| 725 |
+
self.register_buffer(
|
| 726 |
+
"attraction_ema",
|
| 727 |
+
torch.zeros((), dtype=torch.float32),
|
| 728 |
+
persistent=self.attraction_ema_enabled,
|
| 729 |
+
)
|
| 730 |
+
self.register_buffer(
|
| 731 |
+
"attraction_ema_updates",
|
| 732 |
+
torch.zeros((), dtype=torch.long),
|
| 733 |
+
persistent=self.attraction_ema_enabled,
|
| 734 |
+
)
|
| 735 |
+
# Pending state is deliberately not a buffer: an uncommitted optimizer
|
| 736 |
+
# update must never enter a checkpoint.
|
| 737 |
+
self._pending_attraction: Optional[torch.Tensor] = None
|
| 738 |
+
self._pending_repulsion_fifo: Optional[torch.Tensor] = None
|
| 739 |
+
# The final FIFO allocation is prepared before optimizer.step().
|
| 740 |
+
# Post-step commit only publishes this already-owned tensor.
|
| 741 |
+
self._prepared_repulsion_fifo: Optional[torch.Tensor] = None
|
| 742 |
+
|
| 743 |
+
@property
|
| 744 |
+
def sample_shape(self) -> Tuple[int, int]:
|
| 745 |
+
return int(self.landmarks.shape[1]), int(self.landmarks.shape[2])
|
| 746 |
+
|
| 747 |
+
@property
|
| 748 |
+
def attraction_ema_pending(self) -> bool:
|
| 749 |
+
return self._pending_attraction is not None
|
| 750 |
+
|
| 751 |
+
@property
|
| 752 |
+
def repulsion_fifo_pending(self) -> bool:
|
| 753 |
+
return self._pending_repulsion_fifo is not None
|
| 754 |
+
|
| 755 |
+
@property
|
| 756 |
+
def repulsion_fifo_fill(self) -> int:
|
| 757 |
+
return int(self.repulsion_fifo.shape[0])
|
| 758 |
+
|
| 759 |
+
def forward(
|
| 760 |
+
self,
|
| 761 |
+
generated: Optional[torch.Tensor] = None,
|
| 762 |
+
current_teacher: Optional[torch.Tensor] = None,
|
| 763 |
+
*,
|
| 764 |
+
gradient_students: Optional[torch.Tensor] = None,
|
| 765 |
+
student_reference: Optional[torch.Tensor] = None,
|
| 766 |
+
gradient_student_indices: Optional[
|
| 767 |
+
torch.Tensor | Sequence[int]
|
| 768 |
+
] = None,
|
| 769 |
+
stage: bool = True,
|
| 770 |
+
) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]:
|
| 771 |
+
"""Evaluate the legacy full-batch or explicit partial-gradient loss.
|
| 772 |
+
|
| 773 |
+
Legacy callers pass ``generated`` and retain the original two-sided
|
| 774 |
+
``K(generated, generated)`` derivative exactly. Partial-gradient
|
| 775 |
+
callers instead pass both ``gradient_students`` (the row-side samples
|
| 776 |
+
whose derivatives are required) and ``student_reference`` (the full
|
| 777 |
+
detached column-side student population). The latter computes
|
| 778 |
+
|
| 779 |
+
``mean K(G,S) - 2 * attraction(G) + reference_constant``
|
| 780 |
+
|
| 781 |
+
with a rectangular ``G x S`` student population. The matched
|
| 782 |
+
teacher population must have exactly ``|G|`` samples. Neither
|
| 783 |
+
``student_reference`` nor ``current_teacher`` can receive gradients.
|
| 784 |
+
|
| 785 |
+
Passing ``gradient_student_indices`` opts into the full-student
|
| 786 |
+
partial-gradient estimator. The indices identify the positions of
|
| 787 |
+
``gradient_students`` in ``student_reference``. Those differentiable
|
| 788 |
+
values are functionally inserted into the otherwise detached student
|
| 789 |
+
population, yielding complete ``mean K(S,S)``, fixed attraction over
|
| 790 |
+
all ``S``, and exact current-teacher attraction over ``S x T``. Only
|
| 791 |
+
indexed students retain gradients; ``|T|`` must still equal ``|G|``.
|
| 792 |
+
"""
|
| 793 |
+
# 1. Choose the generated population and samples that retain a graph.
|
| 794 |
+
# ``student_reference`` is generated data, not the persistent reference
|
| 795 |
+
# bank; these input names remain compatible with existing callers.
|
| 796 |
+
partial_mode = (
|
| 797 |
+
gradient_students is not None or student_reference is not None)
|
| 798 |
+
full_student_partial_mode = gradient_student_indices is not None
|
| 799 |
+
if partial_mode:
|
| 800 |
+
gradient_input = gradient_students
|
| 801 |
+
else:
|
| 802 |
+
gradient_input = generated
|
| 803 |
+
|
| 804 |
+
gradient_batch, num_tokens, feature_dim = _validate_video_tensor(
|
| 805 |
+
gradient_input,
|
| 806 |
+
"gradient_students" if partial_mode else "generated",
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
+
samples = gradient_input.float()
|
| 810 |
+
selected_indices = None
|
| 811 |
+
if partial_mode:
|
| 812 |
+
reference_batch, reference_tokens, reference_dim = (
|
| 813 |
+
_validate_video_tensor(
|
| 814 |
+
student_reference, "student_reference"))
|
| 815 |
+
reference_samples = student_reference.detach().float()
|
| 816 |
+
if full_student_partial_mode:
|
| 817 |
+
try:
|
| 818 |
+
selected_indices = torch.as_tensor(
|
| 819 |
+
gradient_student_indices,
|
| 820 |
+
device=gradient_input.device,
|
| 821 |
+
)
|
| 822 |
+
except (TypeError, ValueError) as exc:
|
| 823 |
+
raise TypeError(
|
| 824 |
+
"gradient_student_indices must be an integer "
|
| 825 |
+
"tensor or sequence") from exc
|
| 826 |
+
integer_dtypes = (
|
| 827 |
+
torch.uint8,
|
| 828 |
+
torch.int8,
|
| 829 |
+
torch.int16,
|
| 830 |
+
torch.int32,
|
| 831 |
+
torch.int64,
|
| 832 |
+
)
|
| 833 |
+
selected_indices = selected_indices.to(dtype=torch.long)
|
| 834 |
+
selected_reference = reference_samples.index_select(
|
| 835 |
+
0, selected_indices)
|
| 836 |
+
# Functionally replace only selected detached values. Reusing
|
| 837 |
+
# this same object on both sides of K(S,S) gives selected
|
| 838 |
+
# students both row- and column-side derivatives.
|
| 839 |
+
objective_samples = reference_samples.index_copy(
|
| 840 |
+
0, selected_indices, samples)
|
| 841 |
+
repulsion_reference_samples = objective_samples
|
| 842 |
+
attraction_samples = objective_samples
|
| 843 |
+
attraction_batch = reference_batch
|
| 844 |
+
else:
|
| 845 |
+
objective_samples = samples
|
| 846 |
+
repulsion_reference_samples = reference_samples
|
| 847 |
+
attraction_samples = samples
|
| 848 |
+
attraction_batch = gradient_batch
|
| 849 |
+
else:
|
| 850 |
+
reference_batch = gradient_batch
|
| 851 |
+
# Preserve object identity here. The legacy self-kernel helper
|
| 852 |
+
# relies on it both for its exact diagonal replacement and for the
|
| 853 |
+
# original two-sided K_ss derivative.
|
| 854 |
+
reference_samples = samples
|
| 855 |
+
objective_samples = samples
|
| 856 |
+
repulsion_reference_samples = reference_samples
|
| 857 |
+
attraction_samples = samples
|
| 858 |
+
attraction_batch = gradient_batch
|
| 859 |
+
|
| 860 |
+
# 2. Read the frozen reference minibatch for the Monte Carlo term.
|
| 861 |
+
# These features are targets; gradients flow through generated samples.
|
| 862 |
+
teacher_samples = None
|
| 863 |
+
teacher_batch = None
|
| 864 |
+
if self.current_teacher_attraction_enabled:
|
| 865 |
+
teacher_batch, teacher_tokens, teacher_dim = (
|
| 866 |
+
_validate_video_tensor(current_teacher, "current_teacher"))
|
| 867 |
+
if (
|
| 868 |
+
teacher_batch != gradient_batch
|
| 869 |
+
or teacher_tokens != num_tokens
|
| 870 |
+
or teacher_dim != feature_dim
|
| 871 |
+
):
|
| 872 |
+
student_name = (
|
| 873 |
+
"gradient_students" if partial_mode else "generated")
|
| 874 |
+
raise ValueError(
|
| 875 |
+
"current teacher features must exactly match "
|
| 876 |
+
f"{student_name} "
|
| 877 |
+
"[B,N,D]: "
|
| 878 |
+
f"{(teacher_batch, teacher_tokens, teacher_dim)} != "
|
| 879 |
+
f"{(gradient_batch, num_tokens, feature_dim)}")
|
| 880 |
+
# The current teacher is a matched frozen target. Detach before
|
| 881 |
+
# every kernel operation so callers can never accidentally create
|
| 882 |
+
# a teacher-side gradient graph.
|
| 883 |
+
teacher_samples = current_teacher.detach().float()
|
| 884 |
+
|
| 885 |
+
# 3. Exact generated-sample repulsion: sum every kernel pair in
|
| 886 |
+
# chunks. Chunking controls memory without approximating the sum.
|
| 887 |
+
exact_kgg_sum = _kernel_sum_chunked(
|
| 888 |
+
objective_samples,
|
| 889 |
+
repulsion_reference_samples,
|
| 890 |
+
self.sigma,
|
| 891 |
+
self.kernel_chunk_size,
|
| 892 |
+
)
|
| 893 |
+
if self.repulsion_fifo_enabled:
|
| 894 |
+
history_size = self.repulsion_fifo_fill
|
| 895 |
+
denominator = gradient_batch * (
|
| 896 |
+
gradient_batch - 1 + history_size)
|
| 897 |
+
# ``_kernel_sum_chunked`` analytically replaces each self-kernel
|
| 898 |
+
# by one. Subtracting B therefore removes the complete diagonal,
|
| 899 |
+
# including its autograd path, without forming a dense mask.
|
| 900 |
+
current_offdiag_sum = exact_kgg_sum - float(gradient_batch)
|
| 901 |
+
if history_size:
|
| 902 |
+
history_cross_sum = _kernel_sum_chunked(
|
| 903 |
+
samples,
|
| 904 |
+
self.repulsion_fifo,
|
| 905 |
+
self.sigma,
|
| 906 |
+
self.kernel_chunk_size,
|
| 907 |
+
)
|
| 908 |
+
else:
|
| 909 |
+
history_cross_sum = samples.new_zeros(())
|
| 910 |
+
repulsion = (
|
| 911 |
+
current_offdiag_sum + history_cross_sum
|
| 912 |
+
) / float(denominator)
|
| 913 |
+
else:
|
| 914 |
+
history_size = 0
|
| 915 |
+
# Full-population replay keeps B * B pairs even when only a
|
| 916 |
+
# subset is differentiated. The rectangular mode uses |G| * B.
|
| 917 |
+
# Gradient-subset compensation is handled by the replay caller.
|
| 918 |
+
if full_student_partial_mode:
|
| 919 |
+
denominator = reference_batch * reference_batch
|
| 920 |
+
else:
|
| 921 |
+
denominator = gradient_batch * reference_batch
|
| 922 |
+
current_offdiag_sum = None
|
| 923 |
+
history_cross_sum = None
|
| 924 |
+
repulsion = exact_kgg_sum / float(denominator)
|
| 925 |
+
# 4. Persistent Nyström attraction (paper Eq. 8): K(x, Z) @ alpha.
|
| 926 |
+
# The saved ``alpha`` vector is paper beta, not the mixture weight.
|
| 927 |
+
fixed_attraction_values = _kernel_matvec_chunked(
|
| 928 |
+
attraction_samples,
|
| 929 |
+
self.landmarks,
|
| 930 |
+
self.alpha,
|
| 931 |
+
self.sigma,
|
| 932 |
+
self.kernel_chunk_size,
|
| 933 |
+
)
|
| 934 |
+
fixed_current_attraction = fixed_attraction_values.mean()
|
| 935 |
+
current_teacher_attraction = None
|
| 936 |
+
current_teacher_self_kernel = None
|
| 937 |
+
current_teacher_exact_mmd = None
|
| 938 |
+
# 5. Hybrid reference mean (paper Eq. 9): mix the persistent term
|
| 939 |
+
# with exact minibatch attraction over all generated x reference pairs.
|
| 940 |
+
# Group weights 2:1 correspond to paper alpha_e = 1/3.
|
| 941 |
+
if self.current_teacher_attraction_enabled:
|
| 942 |
+
fixed_weight = self.attraction_weight
|
| 943 |
+
current_weight = self.current_teacher_attraction_weight
|
| 944 |
+
teacher_weight_sum = fixed_weight + current_weight
|
| 945 |
+
fixed_normalized_weight = fixed_weight / teacher_weight_sum
|
| 946 |
+
current_normalized_weight = current_weight / teacher_weight_sum
|
| 947 |
+
current_teacher_attraction = _kernel_sum_chunked(
|
| 948 |
+
attraction_samples,
|
| 949 |
+
teacher_samples,
|
| 950 |
+
self.sigma,
|
| 951 |
+
self.kernel_chunk_size,
|
| 952 |
+
) / float(attraction_batch * teacher_batch)
|
| 953 |
+
# The reference self-kernel has no generator gradient. Its value
|
| 954 |
+
# still affects the release's later detached-scale division.
|
| 955 |
+
with torch.no_grad():
|
| 956 |
+
current_teacher_self_kernel = _kernel_sum_chunked(
|
| 957 |
+
teacher_samples,
|
| 958 |
+
teacher_samples,
|
| 959 |
+
self.sigma,
|
| 960 |
+
self.kernel_chunk_size,
|
| 961 |
+
) / float(teacher_batch * teacher_batch)
|
| 962 |
+
current_attraction = (
|
| 963 |
+
fixed_normalized_weight * fixed_current_attraction
|
| 964 |
+
+ current_normalized_weight * current_teacher_attraction
|
| 965 |
+
)
|
| 966 |
+
reference_constant = (
|
| 967 |
+
fixed_normalized_weight * self.k_rr
|
| 968 |
+
+ current_normalized_weight * current_teacher_self_kernel
|
| 969 |
+
)
|
| 970 |
+
current_teacher_exact_mmd = (
|
| 971 |
+
repulsion
|
| 972 |
+
- 2.0 * current_teacher_attraction
|
| 973 |
+
+ current_teacher_self_kernel
|
| 974 |
+
)
|
| 975 |
+
else:
|
| 976 |
+
fixed_normalized_weight = None
|
| 977 |
+
current_normalized_weight = None
|
| 978 |
+
teacher_weight_sum = None
|
| 979 |
+
current_attraction = fixed_current_attraction
|
| 980 |
+
reference_constant = self.k_rr
|
| 981 |
+
# 6. Optional historical attraction/FIFO state. These branches are
|
| 982 |
+
# inactive in the released three-encoder configuration.
|
| 983 |
+
history_updates = int(self.attraction_ema_updates.item())
|
| 984 |
+
if self.attraction_ema_enabled and history_updates > 0:
|
| 985 |
+
ema_blend_current_coefficient = 1.0 - self.attraction_ema_beta
|
| 986 |
+
ema_attraction = (
|
| 987 |
+
self.attraction_ema_beta * self.attraction_ema.detach()
|
| 988 |
+
+ ema_blend_current_coefficient * current_attraction
|
| 989 |
+
)
|
| 990 |
+
if self.current_teacher_attraction_enabled:
|
| 991 |
+
# Keep the transactional EMA's smoothed forward value while
|
| 992 |
+
# preserving the full derivative of this update's normalized
|
| 993 |
+
# fixed/current teacher mixture. Without this straight-through
|
| 994 |
+
# form beta=0.99 would silently shrink all teacher gradients
|
| 995 |
+
# to one percent relative to K_ss.
|
| 996 |
+
attraction = current_attraction + (
|
| 997 |
+
ema_attraction - current_attraction).detach()
|
| 998 |
+
current_coefficient = 1.0
|
| 999 |
+
else:
|
| 1000 |
+
attraction = ema_attraction
|
| 1001 |
+
current_coefficient = ema_blend_current_coefficient
|
| 1002 |
+
else:
|
| 1003 |
+
ema_blend_current_coefficient = 1.0
|
| 1004 |
+
current_coefficient = 1.0
|
| 1005 |
+
attraction = current_attraction
|
| 1006 |
+
|
| 1007 |
+
if self.attraction_ema_enabled and stage:
|
| 1008 |
+
if self._pending_attraction is not None:
|
| 1009 |
+
raise RuntimeError(
|
| 1010 |
+
"an attraction EMA update is already staged; "
|
| 1011 |
+
"commit or discard it before another staged forward")
|
| 1012 |
+
self._pending_attraction = current_attraction.detach().clone()
|
| 1013 |
+
|
| 1014 |
+
if self.repulsion_fifo_enabled and stage:
|
| 1015 |
+
if self._pending_repulsion_fifo is not None:
|
| 1016 |
+
raise RuntimeError(
|
| 1017 |
+
"a repulsion FIFO update is already staged; "
|
| 1018 |
+
"commit or discard it before another staged forward")
|
| 1019 |
+
# The fresh GradCache supplies the identical global batch on every
|
| 1020 |
+
# rank. Cloning it here gives each rank the same independently
|
| 1021 |
+
# owned FIFO candidate and prevents all gradients/aliasing.
|
| 1022 |
+
self._pending_repulsion_fifo = (
|
| 1023 |
+
samples.detach().to(dtype=torch.float32).clone())
|
| 1024 |
+
|
| 1025 |
+
# 7. Assemble the raw single-encoder loss. MultiEncoderMMD applies
|
| 1026 |
+
# detached-scale normalization and encoder weighting after this.
|
| 1027 |
+
if self.current_teacher_attraction_enabled:
|
| 1028 |
+
# This is the weighted average of the fixed-reference MMD and the
|
| 1029 |
+
# current exact MMD. K_ss is shared, so it appears once and the
|
| 1030 |
+
# total attraction coefficient remains -2 for every raw scale.
|
| 1031 |
+
loss = repulsion - 2.0 * attraction + reference_constant
|
| 1032 |
+
effective_attraction_weight = 1.0
|
| 1033 |
+
else:
|
| 1034 |
+
# Preserve the legacy value and gradient exactly when the opt-in
|
| 1035 |
+
# current teacher path is disabled.
|
| 1036 |
+
if self.attraction_weight == 1.0:
|
| 1037 |
+
loss = repulsion - 2.0 * attraction + self.k_rr
|
| 1038 |
+
else:
|
| 1039 |
+
loss = (
|
| 1040 |
+
repulsion
|
| 1041 |
+
- 2.0 * self.attraction_weight * attraction
|
| 1042 |
+
+ self.k_rr
|
| 1043 |
+
)
|
| 1044 |
+
effective_attraction_weight = self.attraction_weight
|
| 1045 |
+
metadata = {
|
| 1046 |
+
"repulsion": repulsion.detach(),
|
| 1047 |
+
"attraction": attraction.detach(),
|
| 1048 |
+
"attraction_weight": loss.new_tensor(
|
| 1049 |
+
effective_attraction_weight),
|
| 1050 |
+
"reference_constant": reference_constant.detach(),
|
| 1051 |
+
"sigma": self.sigma.detach(),
|
| 1052 |
+
"global_samples": torch.tensor(
|
| 1053 |
+
reference_batch, device=loss.device, dtype=torch.long),
|
| 1054 |
+
}
|
| 1055 |
+
if partial_mode:
|
| 1056 |
+
metadata.update({
|
| 1057 |
+
"partial_student_reference_enabled": loss.new_ones(
|
| 1058 |
+
(), dtype=torch.long),
|
| 1059 |
+
"gradient_student_samples": loss.new_tensor(
|
| 1060 |
+
gradient_batch, dtype=torch.long),
|
| 1061 |
+
"student_reference_samples": loss.new_tensor(
|
| 1062 |
+
reference_batch, dtype=torch.long),
|
| 1063 |
+
"student_reference_detached": loss.new_ones(
|
| 1064 |
+
(), dtype=torch.long),
|
| 1065 |
+
"repulsion_denominator": loss.new_tensor(
|
| 1066 |
+
denominator, dtype=torch.long),
|
| 1067 |
+
"student_repulsion_denominator": loss.new_tensor(
|
| 1068 |
+
denominator, dtype=torch.long),
|
| 1069 |
+
"fixed_teacher_attraction_denominator": loss.new_tensor(
|
| 1070 |
+
attraction_batch, dtype=torch.long),
|
| 1071 |
+
})
|
| 1072 |
+
if full_student_partial_mode:
|
| 1073 |
+
metadata.update({
|
| 1074 |
+
"full_student_partial_gradient": loss.new_ones(
|
| 1075 |
+
(), dtype=torch.long),
|
| 1076 |
+
"student_repulsion_gradient_sides": loss.new_tensor(
|
| 1077 |
+
2, dtype=torch.long),
|
| 1078 |
+
})
|
| 1079 |
+
if self.current_teacher_attraction_enabled:
|
| 1080 |
+
metadata.update({
|
| 1081 |
+
"current_teacher_attraction_enabled": loss.new_ones(
|
| 1082 |
+
(), dtype=torch.long),
|
| 1083 |
+
"fixed_teacher_attraction": (
|
| 1084 |
+
fixed_current_attraction.detach()),
|
| 1085 |
+
"current_teacher_attraction": (
|
| 1086 |
+
current_teacher_attraction.detach()),
|
| 1087 |
+
"fixed_teacher_attraction_weight": loss.new_tensor(
|
| 1088 |
+
self.attraction_weight),
|
| 1089 |
+
"current_teacher_attraction_weight": loss.new_tensor(
|
| 1090 |
+
self.current_teacher_attraction_weight),
|
| 1091 |
+
"teacher_attraction_weight_sum": loss.new_tensor(
|
| 1092 |
+
teacher_weight_sum),
|
| 1093 |
+
"fixed_teacher_attraction_normalized_weight": (
|
| 1094 |
+
loss.new_tensor(fixed_normalized_weight)),
|
| 1095 |
+
"current_teacher_attraction_normalized_weight": (
|
| 1096 |
+
loss.new_tensor(current_normalized_weight)),
|
| 1097 |
+
"mixed_current_attraction": current_attraction.detach(),
|
| 1098 |
+
"fixed_reference_constant": self.k_rr.detach(),
|
| 1099 |
+
"current_teacher_self_kernel": (
|
| 1100 |
+
current_teacher_self_kernel.detach()),
|
| 1101 |
+
"current_teacher_exact_mmd": (
|
| 1102 |
+
current_teacher_exact_mmd.detach()),
|
| 1103 |
+
"current_teacher_samples": loss.new_tensor(
|
| 1104 |
+
teacher_batch, dtype=torch.long),
|
| 1105 |
+
})
|
| 1106 |
+
if partial_mode:
|
| 1107 |
+
metadata.update({
|
| 1108 |
+
"current_teacher_attraction_denominator": (
|
| 1109 |
+
loss.new_tensor(
|
| 1110 |
+
attraction_batch * teacher_batch,
|
| 1111 |
+
dtype=torch.long,
|
| 1112 |
+
)
|
| 1113 |
+
),
|
| 1114 |
+
"current_teacher_self_kernel_denominator": (
|
| 1115 |
+
loss.new_tensor(
|
| 1116 |
+
teacher_batch * teacher_batch,
|
| 1117 |
+
dtype=torch.long,
|
| 1118 |
+
)
|
| 1119 |
+
),
|
| 1120 |
+
})
|
| 1121 |
+
if self.attraction_ema_enabled:
|
| 1122 |
+
metadata.update({
|
| 1123 |
+
"current_attraction": current_attraction.detach(),
|
| 1124 |
+
"attraction_ema_enabled": loss.new_ones(
|
| 1125 |
+
(), dtype=torch.long),
|
| 1126 |
+
"attraction_ema_updates": (
|
| 1127 |
+
self.attraction_ema_updates.detach().clone()),
|
| 1128 |
+
"attraction_current_coefficient": loss.new_tensor(
|
| 1129 |
+
current_coefficient),
|
| 1130 |
+
})
|
| 1131 |
+
if self.current_teacher_attraction_enabled:
|
| 1132 |
+
metadata.update({
|
| 1133 |
+
"attraction_ema_blend_current_coefficient": (
|
| 1134 |
+
loss.new_tensor(
|
| 1135 |
+
ema_blend_current_coefficient)),
|
| 1136 |
+
"attraction_gradient_current_coefficient": (
|
| 1137 |
+
loss.new_ones(())),
|
| 1138 |
+
"attraction_ema_smoothed_value": attraction.detach(),
|
| 1139 |
+
"effective_ema_loss": loss.detach(),
|
| 1140 |
+
"attraction_gradient_mode": (
|
| 1141 |
+
"straight_through_full_current_mix"),
|
| 1142 |
+
})
|
| 1143 |
+
if self.repulsion_fifo_enabled:
|
| 1144 |
+
fill_after_commit = min(
|
| 1145 |
+
self.repulsion_fifo_size,
|
| 1146 |
+
history_size + gradient_batch,
|
| 1147 |
+
)
|
| 1148 |
+
metadata.update({
|
| 1149 |
+
"repulsion_fifo_enabled": loss.new_ones(
|
| 1150 |
+
(), dtype=torch.long),
|
| 1151 |
+
"repulsion_fifo_capacity": loss.new_tensor(
|
| 1152 |
+
self.repulsion_fifo_size, dtype=torch.long),
|
| 1153 |
+
"repulsion_fifo_fill_before": loss.new_tensor(
|
| 1154 |
+
history_size, dtype=torch.long),
|
| 1155 |
+
# Compatibility spelling used by the trainer logger.
|
| 1156 |
+
"repulsion_fifo_filled_before": loss.new_tensor(
|
| 1157 |
+
history_size, dtype=torch.long),
|
| 1158 |
+
"repulsion_fifo_fill_after_commit": loss.new_tensor(
|
| 1159 |
+
fill_after_commit, dtype=torch.long),
|
| 1160 |
+
"repulsion_fifo_commits": (
|
| 1161 |
+
self.repulsion_fifo_commits.detach().clone()),
|
| 1162 |
+
"repulsion_fifo_committed_updates": (
|
| 1163 |
+
self.repulsion_fifo_commits.detach().clone()),
|
| 1164 |
+
"repulsion_current_offdiag_kernel_sum": (
|
| 1165 |
+
current_offdiag_sum.detach()),
|
| 1166 |
+
"repulsion_fifo_current_offdiag_sum": (
|
| 1167 |
+
current_offdiag_sum.detach()),
|
| 1168 |
+
"repulsion_history_kernel_sum": (
|
| 1169 |
+
history_cross_sum.detach()),
|
| 1170 |
+
"repulsion_fifo_history_cross_sum": (
|
| 1171 |
+
history_cross_sum.detach()),
|
| 1172 |
+
"repulsion_denominator": loss.new_tensor(
|
| 1173 |
+
denominator, dtype=torch.long),
|
| 1174 |
+
"repulsion_fifo_denominator": loss.new_tensor(
|
| 1175 |
+
denominator, dtype=torch.long),
|
| 1176 |
+
})
|
| 1177 |
+
return loss, metadata
|
| 1178 |
+
|
| 1179 |
+
@torch.no_grad()
|
| 1180 |
+
def prepare_commit(self) -> None:
|
| 1181 |
+
"""Validate all staged distribution state before optimizer mutation."""
|
| 1182 |
+
if self.attraction_ema_enabled:
|
| 1183 |
+
if self._pending_attraction is None:
|
| 1184 |
+
raise RuntimeError("no attraction EMA update is staged")
|
| 1185 |
+
if self.repulsion_fifo_enabled:
|
| 1186 |
+
pending = self._pending_repulsion_fifo
|
| 1187 |
+
if pending is None:
|
| 1188 |
+
raise RuntimeError("no repulsion FIFO update is staged")
|
| 1189 |
+
if self._prepared_repulsion_fifo is None:
|
| 1190 |
+
combined = torch.cat((self.repulsion_fifo, pending), dim=0)
|
| 1191 |
+
if combined.shape[0] > self.repulsion_fifo_size:
|
| 1192 |
+
combined = combined[-self.repulsion_fifo_size:]
|
| 1193 |
+
# Allocate the right-sized, independently owned result while
|
| 1194 |
+
# the trainer can still abort before optimizer mutation.
|
| 1195 |
+
self._prepared_repulsion_fifo = (
|
| 1196 |
+
combined.detach().contiguous().clone())
|
| 1197 |
+
|
| 1198 |
+
@torch.no_grad()
|
| 1199 |
+
def commit(self) -> None:
|
| 1200 |
+
"""Publish staged state only after a successful optimizer update."""
|
| 1201 |
+
if (
|
| 1202 |
+
self._pending_attraction is None
|
| 1203 |
+
and self._pending_repulsion_fifo is None
|
| 1204 |
+
):
|
| 1205 |
+
return
|
| 1206 |
+
self.prepare_commit()
|
| 1207 |
+
if self._pending_attraction is not None:
|
| 1208 |
+
pending_attraction = self._pending_attraction
|
| 1209 |
+
if int(self.attraction_ema_updates.item()) == 0:
|
| 1210 |
+
self.attraction_ema.copy_(pending_attraction)
|
| 1211 |
+
else:
|
| 1212 |
+
self.attraction_ema.mul_(self.attraction_ema_beta).add_(
|
| 1213 |
+
pending_attraction,
|
| 1214 |
+
alpha=1.0 - self.attraction_ema_beta,
|
| 1215 |
+
)
|
| 1216 |
+
self.attraction_ema_updates.add_(1)
|
| 1217 |
+
if self._pending_repulsion_fifo is not None:
|
| 1218 |
+
if self._prepared_repulsion_fifo is None:
|
| 1219 |
+
raise RuntimeError("repulsion FIFO commit was not prepared")
|
| 1220 |
+
self.repulsion_fifo = self._prepared_repulsion_fifo
|
| 1221 |
+
self.repulsion_fifo_commits.add_(1)
|
| 1222 |
+
self.discard()
|
| 1223 |
+
|
| 1224 |
+
def discard(self) -> None:
|
| 1225 |
+
"""Drop all staged state after a failed or skipped update."""
|
| 1226 |
+
self._pending_attraction = None
|
| 1227 |
+
self._pending_repulsion_fifo = None
|
| 1228 |
+
self._prepared_repulsion_fifo = None
|
| 1229 |
+
|
| 1230 |
+
def repulsion_fifo_state_dict(self) -> Optional[Dict[str, Any]]:
|
| 1231 |
+
"""Return the committed FIFO in oldest-to-newest order."""
|
| 1232 |
+
if not self.repulsion_fifo_enabled:
|
| 1233 |
+
return None
|
| 1234 |
+
if self.repulsion_fifo_pending:
|
| 1235 |
+
raise RuntimeError(
|
| 1236 |
+
"refusing to checkpoint a pending repulsion FIFO update")
|
| 1237 |
+
return {
|
| 1238 |
+
"version": self._REPULSION_FIFO_STATE_VERSION,
|
| 1239 |
+
"capacity": self.repulsion_fifo_size,
|
| 1240 |
+
"sample_shape": list(self.sample_shape),
|
| 1241 |
+
"dtype": "float32",
|
| 1242 |
+
"count": self.repulsion_fifo_fill,
|
| 1243 |
+
"commits": int(self.repulsion_fifo_commits.item()),
|
| 1244 |
+
"values": self.repulsion_fifo.detach().cpu().clone(),
|
| 1245 |
+
}
|
| 1246 |
+
def validate_repulsion_fifo_state_dict(
|
| 1247 |
+
self,
|
| 1248 |
+
state: Mapping[str, Any],
|
| 1249 |
+
) -> Dict[str, Any]:
|
| 1250 |
+
"""Validate and normalize a portable FIFO checkpoint payload."""
|
| 1251 |
+
if not self.repulsion_fifo_enabled:
|
| 1252 |
+
raise ValueError(
|
| 1253 |
+
"cannot load repulsion FIFO state while it is disabled")
|
| 1254 |
+
expected_keys = {
|
| 1255 |
+
"version", "capacity", "sample_shape", "dtype",
|
| 1256 |
+
"count", "commits", "values",
|
| 1257 |
+
}
|
| 1258 |
+
count = int(state["count"])
|
| 1259 |
+
commits = int(state["commits"])
|
| 1260 |
+
values = state["values"]
|
| 1261 |
+
return {
|
| 1262 |
+
"version": self._REPULSION_FIFO_STATE_VERSION,
|
| 1263 |
+
"capacity": self.repulsion_fifo_size,
|
| 1264 |
+
"sample_shape": list(self.sample_shape),
|
| 1265 |
+
"dtype": "float32",
|
| 1266 |
+
"count": count,
|
| 1267 |
+
"commits": commits,
|
| 1268 |
+
"values": values.detach().cpu().clone(),
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
@torch.no_grad()
|
| 1272 |
+
def load_repulsion_fifo_state_dict(
|
| 1273 |
+
self,
|
| 1274 |
+
state: Mapping[str, Any],
|
| 1275 |
+
) -> None:
|
| 1276 |
+
validated = self.validate_repulsion_fifo_state_dict(state)
|
| 1277 |
+
self.repulsion_fifo = validated["values"].to(
|
| 1278 |
+
device=self.landmarks.device,
|
| 1279 |
+
dtype=torch.float32,
|
| 1280 |
+
).contiguous().clone()
|
| 1281 |
+
self.repulsion_fifo_commits.fill_(validated["commits"])
|
| 1282 |
+
self.discard()
|
| 1283 |
+
|
| 1284 |
+
def attraction_ema_state_dict(self) -> Optional[Dict[str, Any]]:
|
| 1285 |
+
"""Return the small trainer checkpoint payload for the opt-in EMA."""
|
| 1286 |
+
if not self.attraction_ema_enabled:
|
| 1287 |
+
return None
|
| 1288 |
+
if self.attraction_ema_pending:
|
| 1289 |
+
raise RuntimeError(
|
| 1290 |
+
"refusing to checkpoint a pending attraction EMA update")
|
| 1291 |
+
return {
|
| 1292 |
+
"version": self._ATTRACTION_EMA_STATE_VERSION,
|
| 1293 |
+
"beta": self.attraction_ema_beta,
|
| 1294 |
+
"value": self.attraction_ema.detach().cpu().clone(),
|
| 1295 |
+
"updates": self.attraction_ema_updates.detach().cpu().clone(),
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
def validate_attraction_ema_state_dict(
|
| 1299 |
+
self,
|
| 1300 |
+
state: Mapping[str, Any],
|
| 1301 |
+
) -> Dict[str, Any]:
|
| 1302 |
+
"""Validate and normalize a small attraction EMA checkpoint payload."""
|
| 1303 |
+
if not self.attraction_ema_enabled:
|
| 1304 |
+
raise ValueError(
|
| 1305 |
+
"cannot load attraction EMA state while the path is disabled")
|
| 1306 |
+
expected_keys = {"version", "beta", "value", "updates"}
|
| 1307 |
+
value = state["value"]
|
| 1308 |
+
updates = state["updates"]
|
| 1309 |
+
return {
|
| 1310 |
+
"version": self._ATTRACTION_EMA_STATE_VERSION,
|
| 1311 |
+
"beta": self.attraction_ema_beta,
|
| 1312 |
+
"value": value.detach().cpu().clone(),
|
| 1313 |
+
"updates": updates.detach().cpu().clone(),
|
| 1314 |
+
}
|
| 1315 |
+
|
| 1316 |
+
@torch.no_grad()
|
| 1317 |
+
def load_attraction_ema_state_dict(
|
| 1318 |
+
self,
|
| 1319 |
+
state: Mapping[str, Any],
|
| 1320 |
+
) -> None:
|
| 1321 |
+
"""Strictly restore only the transactional attraction EMA scalars."""
|
| 1322 |
+
validated = self.validate_attraction_ema_state_dict(state)
|
| 1323 |
+
self.attraction_ema.copy_(
|
| 1324 |
+
validated["value"].to(
|
| 1325 |
+
device=self.attraction_ema.device,
|
| 1326 |
+
dtype=self.attraction_ema.dtype,
|
| 1327 |
+
)
|
| 1328 |
+
)
|
| 1329 |
+
self.attraction_ema_updates.copy_(
|
| 1330 |
+
validated["updates"].to(
|
| 1331 |
+
device=self.attraction_ema_updates.device)
|
| 1332 |
+
)
|
| 1333 |
+
self.discard()
|
| 1334 |
+
|
| 1335 |
+
def get_extra_state(self) -> Dict[str, Any]:
|
| 1336 |
+
state = {
|
| 1337 |
+
"version": self._STATE_VERSION,
|
| 1338 |
+
"reference_metadata": self.reference_metadata,
|
| 1339 |
+
"reference_hash": self.reference_hash,
|
| 1340 |
+
}
|
| 1341 |
+
if self.attraction_ema_enabled:
|
| 1342 |
+
state = {
|
| 1343 |
+
**state,
|
| 1344 |
+
"version": self._STATE_VERSION + 1,
|
| 1345 |
+
"attraction_ema": {
|
| 1346 |
+
"version": self._ATTRACTION_EMA_STATE_VERSION,
|
| 1347 |
+
"beta": self.attraction_ema_beta,
|
| 1348 |
+
},
|
| 1349 |
+
}
|
| 1350 |
+
if self.current_teacher_attraction_enabled:
|
| 1351 |
+
state = {
|
| 1352 |
+
**state,
|
| 1353 |
+
"version": (4 if self.attraction_ema_enabled else 3),
|
| 1354 |
+
"current_teacher_attraction": {
|
| 1355 |
+
"enabled": True,
|
| 1356 |
+
"fixed_weight": self.attraction_weight,
|
| 1357 |
+
"current_weight": (
|
| 1358 |
+
self.current_teacher_attraction_weight),
|
| 1359 |
+
"normalization": "divide_by_group_weight_sum",
|
| 1360 |
+
"estimator": "exact_biased_gaussian_mmd",
|
| 1361 |
+
},
|
| 1362 |
+
}
|
| 1363 |
+
return state
|
| 1364 |
+
|
| 1365 |
+
def set_extra_state(self, state: Mapping[str, Any]) -> None:
|
| 1366 |
+
expected = self.get_extra_state()
|
1.3B/code/losses/wan_kv_mmd.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RBF-MMD on Wan KV matrices with a mathematically valid EMA distribution.
|
| 2 |
+
|
| 3 |
+
An RBF kernel has an (infinite-dimensional) feature map ``phi`` and
|
| 4 |
+
|
| 5 |
+
MMD²(P, Q) = || E_P[phi(x)] - E_Q[phi(y)] ||².
|
| 6 |
+
|
| 7 |
+
We approximate ``phi`` with fixed random Fourier features (RFF), then keep an
|
| 8 |
+
EMA of the *kernel mean embeddings*. This represents an EMA mixture of whole
|
| 9 |
+
historical distributions; averaging raw KV rows across unrelated videos does
|
| 10 |
+
not. Historical embeddings are detached, so gradients only reach the latest
|
| 11 |
+
student KV matrix. The natural ``1-beta`` factor is intentionally preserved.
|
| 12 |
+
|
| 13 |
+
History updates are transactional: ``forward(stage=True)`` only stages detached
|
| 14 |
+
current embeddings. ``commit()`` must be called after a successful optimizer
|
| 15 |
+
step; ``discard()`` is used when the optimizer step is skipped.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import math
|
| 21 |
+
from typing import Iterable, Optional, Tuple
|
| 22 |
+
|
| 23 |
+
import torch
|
| 24 |
+
import torch.distributed as dist
|
| 25 |
+
import torch.distributed.nn.functional as dist_nn
|
| 26 |
+
from torch import nn
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _sq_dist(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
|
| 30 |
+
x2 = x.square().sum(-1, keepdim=True)
|
| 31 |
+
y2 = y.square().sum(-1).unsqueeze(0)
|
| 32 |
+
return (x2 + y2 - 2.0 * x @ y.t()).clamp_min(0)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def exact_rbf_mmd(
|
| 36 |
+
student: torch.Tensor,
|
| 37 |
+
teacher: torch.Tensor,
|
| 38 |
+
*,
|
| 39 |
+
bandwidth: float,
|
| 40 |
+
bandwidth_multipliers: Iterable[float] = (0.5, 1.0, 2.0),
|
| 41 |
+
) -> torch.Tensor:
|
| 42 |
+
"""Biased multi-bandwidth RBF-MMD² reference implementation."""
|
| 43 |
+
if student.ndim != 2 or teacher.ndim != 2:
|
| 44 |
+
raise ValueError("exact_rbf_mmd expects [samples, features] matrices")
|
| 45 |
+
if student.shape[1] != teacher.shape[1] or min(student.shape[0], teacher.shape[0]) < 1:
|
| 46 |
+
raise ValueError("student/teacher matrices need matching feature dims and samples")
|
| 47 |
+
if not math.isfinite(float(bandwidth)) or float(bandwidth) <= 0:
|
| 48 |
+
raise ValueError("bandwidth must be finite and positive")
|
| 49 |
+
multipliers = tuple(float(v) for v in bandwidth_multipliers)
|
| 50 |
+
if not multipliers or any((not math.isfinite(v) or v <= 0) for v in multipliers):
|
| 51 |
+
raise ValueError("bandwidth multipliers must be finite and positive")
|
| 52 |
+
|
| 53 |
+
x, y = student.float(), teacher.float()
|
| 54 |
+
dxx, dyy, dxy = _sq_dist(x, x), _sq_dist(y, y), _sq_dist(x, y)
|
| 55 |
+
bandwidth_sq = x.new_tensor(float(bandwidth) ** 2)
|
| 56 |
+
terms = []
|
| 57 |
+
for multiplier in multipliers:
|
| 58 |
+
denom = 2.0 * bandwidth_sq * multiplier * multiplier
|
| 59 |
+
terms.append(
|
| 60 |
+
torch.exp(-dxx / denom).mean()
|
| 61 |
+
+ torch.exp(-dyy / denom).mean()
|
| 62 |
+
- 2.0 * torch.exp(-dxy / denom).mean())
|
| 63 |
+
return torch.stack(terms).mean()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class EMARBFMMD(nn.Module):
|
| 67 |
+
"""EMA RBF kernel-mean MMD using fixed random Fourier features.
|
| 68 |
+
|
| 69 |
+
``sample_mode="rows"`` preserves the original behavior, where every
|
| 70 |
+
``[D]`` row in a ``[B, N, D]`` input is one sample. In
|
| 71 |
+
``sample_mode="video_matrix"``, the only sample axis is ``B`` and every
|
| 72 |
+
position-sensitive ``[N, D]`` matrix is one sample. Its RBF distance is
|
| 73 |
+
the Frobenius distance, equivalently the Euclidean distance after
|
| 74 |
+
vectorizing the matrix.
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
_STATE_VERSION = 2
|
| 78 |
+
_SAMPLE_MODES = ("rows", "video_matrix")
|
| 79 |
+
|
| 80 |
+
def __init__(
|
| 81 |
+
self,
|
| 82 |
+
beta: float = 0.99,
|
| 83 |
+
num_rff_features: int = 256,
|
| 84 |
+
bandwidth: Optional[float] = None,
|
| 85 |
+
bandwidth_multipliers: Iterable[float] = (0.5, 1.0, 2.0),
|
| 86 |
+
seed: int = 0,
|
| 87 |
+
row_normalize: bool = False,
|
| 88 |
+
sample_mode: str = "rows",
|
| 89 |
+
):
|
| 90 |
+
super().__init__()
|
| 91 |
+
if not 0.0 <= float(beta) < 1.0:
|
| 92 |
+
raise ValueError("EMA beta must be in [0, 1)")
|
| 93 |
+
if int(num_rff_features) <= 0:
|
| 94 |
+
raise ValueError("num_rff_features must be positive")
|
| 95 |
+
multipliers = tuple(float(v) for v in bandwidth_multipliers)
|
| 96 |
+
if not multipliers or any((not math.isfinite(v) or v <= 0) for v in multipliers):
|
| 97 |
+
raise ValueError("bandwidth multipliers must be finite and positive")
|
| 98 |
+
if bandwidth is not None and (
|
| 99 |
+
not math.isfinite(float(bandwidth)) or float(bandwidth) <= 0
|
| 100 |
+
):
|
| 101 |
+
raise ValueError("bandwidth must be finite and positive")
|
| 102 |
+
if sample_mode not in self._SAMPLE_MODES:
|
| 103 |
+
raise ValueError(
|
| 104 |
+
f"sample_mode must be one of {self._SAMPLE_MODES}, "
|
| 105 |
+
f"got {sample_mode!r}")
|
| 106 |
+
|
| 107 |
+
self.beta = float(beta)
|
| 108 |
+
self.num_rff_features = int(num_rff_features)
|
| 109 |
+
self.bandwidth_multipliers = multipliers
|
| 110 |
+
self.seed = int(seed)
|
| 111 |
+
self.row_normalize = bool(row_normalize)
|
| 112 |
+
self.sample_mode = sample_mode
|
| 113 |
+
|
| 114 |
+
initial_bw_sq = (
|
| 115 |
+
float(bandwidth) ** 2 if bandwidth is not None else float("nan"))
|
| 116 |
+
self.register_buffer(
|
| 117 |
+
"bandwidth_sq", torch.tensor(initial_bw_sq, dtype=torch.float32),
|
| 118 |
+
persistent=True)
|
| 119 |
+
self.register_buffer("rff_weight", torch.empty(0), persistent=True)
|
| 120 |
+
self.register_buffer("rff_phase", torch.empty(0), persistent=True)
|
| 121 |
+
self.register_buffer("student_ema", torch.empty(0), persistent=True)
|
| 122 |
+
self.register_buffer("teacher_ema", torch.empty(0), persistent=True)
|
| 123 |
+
self.register_buffer(
|
| 124 |
+
"num_updates", torch.zeros((), dtype=torch.long), persistent=True)
|
| 125 |
+
|
| 126 |
+
# Deliberately not buffers: an uncommitted optimizer step must never enter
|
| 127 |
+
# a checkpoint.
|
| 128 |
+
self._pending_student: Optional[torch.Tensor] = None
|
| 129 |
+
self._pending_teacher: Optional[torch.Tensor] = None
|
| 130 |
+
|
| 131 |
+
@property
|
| 132 |
+
def embedding_dim(self) -> int:
|
| 133 |
+
return self.num_rff_features * len(self.bandwidth_multipliers)
|
| 134 |
+
|
| 135 |
+
@staticmethod
|
| 136 |
+
def _distributed() -> bool:
|
| 137 |
+
return (
|
| 138 |
+
dist.is_available()
|
| 139 |
+
and dist.is_initialized()
|
| 140 |
+
and dist.get_world_size() > 1
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
@classmethod
|
| 144 |
+
@torch.no_grad()
|
| 145 |
+
def _all_ranks_true(cls, local_value: bool, device: torch.device) -> bool:
|
| 146 |
+
"""Return one consensus bit before entering later collectives.
|
| 147 |
+
|
| 148 |
+
A rank-local exception here would leave the other ranks blocked in the
|
| 149 |
+
RFF broadcast/all-reduce. Every input-validation failure therefore
|
| 150 |
+
becomes an all-rank failure.
|
| 151 |
+
"""
|
| 152 |
+
if not cls._distributed():
|
| 153 |
+
return bool(local_value)
|
| 154 |
+
flag = torch.tensor(
|
| 155 |
+
int(bool(local_value)), device=device, dtype=torch.int32)
|
| 156 |
+
dist.all_reduce(flag, op=dist.ReduceOp.MIN)
|
| 157 |
+
return bool(flag.item())
|
| 158 |
+
|
| 159 |
+
@classmethod
|
| 160 |
+
def _global_mean(
|
| 161 |
+
cls,
|
| 162 |
+
local_sum: torch.Tensor,
|
| 163 |
+
local_count: int,
|
| 164 |
+
*,
|
| 165 |
+
return_count: bool = False,
|
| 166 |
+
):
|
| 167 |
+
"""Autograd-aware, sample-weighted mean across all ranks.
|
| 168 |
+
|
| 169 |
+
The differentiable all-reduce is intentional. Since every rank
|
| 170 |
+
backpropagates the same global loss, its backward supplies the right
|
| 171 |
+
scaling before FSDP averages the replicated parameter gradients.
|
| 172 |
+
"""
|
| 173 |
+
if not cls._distributed():
|
| 174 |
+
count = local_sum.new_tensor(float(local_count))
|
| 175 |
+
mean = local_sum / float(local_count)
|
| 176 |
+
else:
|
| 177 |
+
global_sum = dist_nn.all_reduce(
|
| 178 |
+
local_sum, op=dist.ReduceOp.SUM)
|
| 179 |
+
count = local_sum.new_tensor(float(local_count))
|
| 180 |
+
dist.all_reduce(count, op=dist.ReduceOp.SUM)
|
| 181 |
+
mean = global_sum / count
|
| 182 |
+
if return_count:
|
| 183 |
+
return mean, count
|
| 184 |
+
return mean
|
| 185 |
+
|
| 186 |
+
@torch.no_grad()
|
| 187 |
+
def _estimate_bandwidth_sq(
|
| 188 |
+
self, student: torch.Tensor, teacher: torch.Tensor, max_samples: int = 256
|
| 189 |
+
) -> torch.Tensor:
|
| 190 |
+
if self.sample_mode == "rows":
|
| 191 |
+
merged = torch.cat([
|
| 192 |
+
student.detach().flatten(0, 1),
|
| 193 |
+
teacher.detach().flatten(0, 1),
|
| 194 |
+
], dim=0).float()
|
| 195 |
+
else:
|
| 196 |
+
# Keep each [N, D] video matrix intact: B is the sole sample axis.
|
| 197 |
+
merged = torch.cat([
|
| 198 |
+
student.detach(),
|
| 199 |
+
teacher.detach(),
|
| 200 |
+
], dim=0).float()
|
| 201 |
+
if merged.shape[0] > max_samples:
|
| 202 |
+
indices = torch.linspace(
|
| 203 |
+
0, merged.shape[0] - 1, max_samples, device=merged.device
|
| 204 |
+
).round().long()
|
| 205 |
+
merged = merged.index_select(0, indices)
|
| 206 |
+
if self._distributed():
|
| 207 |
+
# Use all ranks for the one-time median heuristic. Rows are
|
| 208 |
+
# padded only for the collective and unpadded immediately after.
|
| 209 |
+
local_size = torch.tensor(
|
| 210 |
+
[merged.shape[0]], device=merged.device, dtype=torch.long)
|
| 211 |
+
gathered_sizes = [
|
| 212 |
+
torch.zeros_like(local_size) for _ in range(dist.get_world_size())
|
| 213 |
+
]
|
| 214 |
+
dist.all_gather(gathered_sizes, local_size)
|
| 215 |
+
sizes = [int(size.item()) for size in gathered_sizes]
|
| 216 |
+
max_size = max(sizes)
|
| 217 |
+
padded = merged.new_zeros((max_size, *merged.shape[1:]))
|
| 218 |
+
padded[:merged.shape[0]].copy_(merged)
|
| 219 |
+
gathered_samples = [
|
| 220 |
+
torch.empty_like(padded) for _ in range(dist.get_world_size())
|
| 221 |
+
]
|
| 222 |
+
dist.all_gather(gathered_samples, padded)
|
| 223 |
+
merged = torch.cat([
|
| 224 |
+
samples[:size]
|
| 225 |
+
for samples, size in zip(gathered_samples, sizes)
|
| 226 |
+
], dim=0)
|
| 227 |
+
|
| 228 |
+
if merged.shape[0] < 2:
|
| 229 |
+
estimate = merged.new_tensor(1.0)
|
| 230 |
+
else:
|
| 231 |
+
if not self._distributed() or dist.get_rank() == 0:
|
| 232 |
+
if self.sample_mode == "rows":
|
| 233 |
+
distances_sq = torch.pdist(merged, p=2).square()
|
| 234 |
+
else:
|
| 235 |
+
# ||X-Y||_F^2 without collapsing [N, D] into a token axis.
|
| 236 |
+
squared_norm = merged.square().sum(dim=(1, 2))
|
| 237 |
+
gram = torch.einsum(
|
| 238 |
+
"ind,jnd->ij", merged, merged)
|
| 239 |
+
pairwise_sq = (
|
| 240 |
+
squared_norm[:, None]
|
| 241 |
+
+ squared_norm[None, :]
|
| 242 |
+
- 2.0 * gram
|
| 243 |
+
).clamp_min(0)
|
| 244 |
+
upper = torch.triu_indices(
|
| 245 |
+
merged.shape[0],
|
| 246 |
+
merged.shape[0],
|
| 247 |
+
offset=1,
|
| 248 |
+
device=merged.device,
|
| 249 |
+
)
|
| 250 |
+
distances_sq = pairwise_sq[upper[0], upper[1]]
|
| 251 |
+
positive = distances_sq[distances_sq > 0]
|
| 252 |
+
estimate = (
|
| 253 |
+
positive.median()
|
| 254 |
+
if positive.numel()
|
| 255 |
+
else distances_sq.new_tensor(1.0)
|
| 256 |
+
).clamp_min(1e-6)
|
| 257 |
+
else:
|
| 258 |
+
estimate = merged.new_tensor(1.0)
|
| 259 |
+
if self._distributed():
|
| 260 |
+
# All ranks must use the same fixed, checkpointed RFF map.
|
| 261 |
+
dist.broadcast(estimate, src=0)
|
| 262 |
+
return estimate
|
| 263 |
+
|
| 264 |
+
@torch.no_grad()
|
| 265 |
+
def _initialize_rff(self, student: torch.Tensor, teacher: torch.Tensor) -> None:
|
| 266 |
+
if self.sample_mode == "rows":
|
| 267 |
+
sample_shape = (int(student.shape[-1]),)
|
| 268 |
+
else:
|
| 269 |
+
sample_shape = (
|
| 270 |
+
int(student.shape[-2]),
|
| 271 |
+
int(student.shape[-1]),
|
| 272 |
+
)
|
| 273 |
+
weight_shape = (*sample_shape, self.num_rff_features)
|
| 274 |
+
if self.rff_weight.numel() > 0:
|
| 275 |
+
if self.rff_weight.shape != weight_shape:
|
| 276 |
+
raise ValueError(
|
| 277 |
+
"KV sample shape changed: RFF map expects "
|
| 278 |
+
f"{tuple(self.rff_weight.shape[:-1])}, got {sample_shape}")
|
| 279 |
+
return
|
| 280 |
+
if not bool(torch.isfinite(self.bandwidth_sq).item()):
|
| 281 |
+
self.bandwidth_sq.copy_(self._estimate_bandwidth_sq(student, teacher))
|
| 282 |
+
|
| 283 |
+
generator = torch.Generator(device="cpu")
|
| 284 |
+
generator.manual_seed(self.seed)
|
| 285 |
+
weight = torch.randn(
|
| 286 |
+
weight_shape, generator=generator,
|
| 287 |
+
dtype=torch.float32)
|
| 288 |
+
phase = 2.0 * math.pi * torch.rand(
|
| 289 |
+
self.num_rff_features, generator=generator, dtype=torch.float32)
|
| 290 |
+
self.rff_weight.resize_(weight.shape).copy_(
|
| 291 |
+
weight.to(device=student.device))
|
| 292 |
+
self.rff_phase.resize_(phase.shape).copy_(
|
| 293 |
+
phase.to(device=student.device))
|
| 294 |
+
|
| 295 |
+
def _kernel_sum(self, matrix: torch.Tensor) -> Tuple[torch.Tensor, int]:
|
| 296 |
+
samples = matrix.float()
|
| 297 |
+
if self.row_normalize:
|
| 298 |
+
samples = torch.nn.functional.normalize(samples, dim=-1)
|
| 299 |
+
if self.sample_mode == "rows":
|
| 300 |
+
samples = samples.flatten(0, 1)
|
| 301 |
+
base_projection = samples @ self.rff_weight
|
| 302 |
+
else:
|
| 303 |
+
# This is exactly
|
| 304 |
+
# samples.flatten(1) @ rff_weight.flatten(0, 1), while retaining
|
| 305 |
+
# the [N, D] axes and their positional identity.
|
| 306 |
+
base_projection = torch.einsum(
|
| 307 |
+
"bnd,ndr->br", samples, self.rff_weight)
|
| 308 |
+
bandwidth = self.bandwidth_sq.sqrt().clamp_min(1e-6)
|
| 309 |
+
scale = math.sqrt(
|
| 310 |
+
2.0 / (self.num_rff_features * len(self.bandwidth_multipliers)))
|
| 311 |
+
features = [
|
| 312 |
+
torch.cos(
|
| 313 |
+
base_projection / (bandwidth * multiplier) + self.rff_phase
|
| 314 |
+
) * scale
|
| 315 |
+
for multiplier in self.bandwidth_multipliers
|
| 316 |
+
]
|
| 317 |
+
return torch.cat(features, dim=-1).sum(dim=0), int(samples.shape[0])
|
| 318 |
+
|
| 319 |
+
def forward(
|
| 320 |
+
self,
|
| 321 |
+
student: torch.Tensor,
|
| 322 |
+
teacher: torch.Tensor,
|
| 323 |
+
*,
|
| 324 |
+
stage: bool = True,
|
| 325 |
+
) -> Tuple[torch.Tensor, dict]:
|
| 326 |
+
local_shape_ok = (
|
| 327 |
+
student.ndim == 3
|
| 328 |
+
and teacher.ndim == 3
|
| 329 |
+
and student.shape == teacher.shape
|
| 330 |
+
and student.device == teacher.device
|
| 331 |
+
and min(student.shape[0], student.shape[1], student.shape[2]) > 0
|
| 332 |
+
)
|
| 333 |
+
if not self._all_ranks_true(local_shape_ok, student.device):
|
| 334 |
+
raise ValueError(
|
| 335 |
+
"invalid Wan KV shape/device on at least one rank; each local "
|
| 336 |
+
"student/teacher pair must match [B, tokens, features]")
|
| 337 |
+
|
| 338 |
+
# A mismatched feature width would initialize incompatible RFF maps.
|
| 339 |
+
# Matrix samples additionally require a shared N because every token
|
| 340 |
+
# position has its own slice of the position-sensitive random weight.
|
| 341 |
+
if self._distributed():
|
| 342 |
+
rff_sample_shape = (
|
| 343 |
+
student.shape[-1:]
|
| 344 |
+
if self.sample_mode == "rows"
|
| 345 |
+
else student.shape[1:]
|
| 346 |
+
)
|
| 347 |
+
shape_bounds = torch.tensor(
|
| 348 |
+
[*rff_sample_shape, *(-value for value in rff_sample_shape)],
|
| 349 |
+
device=student.device,
|
| 350 |
+
dtype=torch.long,
|
| 351 |
+
)
|
| 352 |
+
dist.all_reduce(shape_bounds, op=dist.ReduceOp.MIN)
|
| 353 |
+
num_dimensions = len(rff_sample_shape)
|
| 354 |
+
if any(
|
| 355 |
+
int(shape_bounds[index].item())
|
| 356 |
+
!= -int(shape_bounds[index + num_dimensions].item())
|
| 357 |
+
for index in range(num_dimensions)
|
| 358 |
+
):
|
| 359 |
+
raise ValueError(
|
| 360 |
+
"Wan KV RFF sample shape differs across distributed ranks")
|
| 361 |
+
|
| 362 |
+
rff_initialized = self.rff_weight.numel() > 0
|
| 363 |
+
expected_rff_shape = (
|
| 364 |
+
(student.shape[-1], self.num_rff_features)
|
| 365 |
+
if self.sample_mode == "rows"
|
| 366 |
+
else (
|
| 367 |
+
student.shape[-2],
|
| 368 |
+
student.shape[-1],
|
| 369 |
+
self.num_rff_features,
|
| 370 |
+
)
|
| 371 |
+
)
|
| 372 |
+
local_rff_shape_ok = (
|
| 373 |
+
not rff_initialized
|
| 374 |
+
or self.rff_weight.shape == expected_rff_shape
|
| 375 |
+
)
|
| 376 |
+
if not self._all_ranks_true(local_rff_shape_ok, student.device):
|
| 377 |
+
raise ValueError(
|
| 378 |
+
"RFF map shape is incompatible on at least one rank")
|
| 379 |
+
if self._distributed():
|
| 380 |
+
bandwidth_initialized = bool(
|
| 381 |
+
torch.isfinite(self.bandwidth_sq).item())
|
| 382 |
+
state_bounds = torch.tensor(
|
| 383 |
+
[
|
| 384 |
+
int(rff_initialized),
|
| 385 |
+
-int(rff_initialized),
|
| 386 |
+
int(bandwidth_initialized),
|
| 387 |
+
-int(bandwidth_initialized),
|
| 388 |
+
],
|
| 389 |
+
device=student.device,
|
| 390 |
+
dtype=torch.int32,
|
| 391 |
+
)
|
| 392 |
+
dist.all_reduce(state_bounds, op=dist.ReduceOp.MIN)
|
| 393 |
+
if (
|
| 394 |
+
int(state_bounds[0].item()) != -int(state_bounds[1].item())
|
| 395 |
+
or int(state_bounds[2].item()) != -int(state_bounds[3].item())
|
| 396 |
+
):
|
| 397 |
+
raise RuntimeError(
|
| 398 |
+
"RFF/bandwidth initialization state differs across ranks")
|
| 399 |
+
|
| 400 |
+
local_finite = bool(
|
| 401 |
+
torch.isfinite(student).all().item()
|
| 402 |
+
and torch.isfinite(teacher).all().item()
|
| 403 |
+
)
|
| 404 |
+
if not self._all_ranks_true(local_finite, student.device):
|
| 405 |
+
raise FloatingPointError(
|
| 406 |
+
"non-finite Wan KV feature on at least one rank")
|
| 407 |
+
|
| 408 |
+
teacher = teacher.detach()
|
| 409 |
+
self._initialize_rff(student, teacher)
|
| 410 |
+
student_sum, student_count = self._kernel_sum(student)
|
| 411 |
+
teacher_sum, teacher_count = self._kernel_sum(teacher)
|
| 412 |
+
current_student, global_count = self._global_mean(
|
| 413 |
+
student_sum, student_count, return_count=True)
|
| 414 |
+
current_teacher = self._global_mean(teacher_sum, teacher_count)
|
| 415 |
+
|
| 416 |
+
if self.student_ema.numel() == 0:
|
| 417 |
+
current_coefficient = 1.0
|
| 418 |
+
effective_student = current_student
|
| 419 |
+
effective_teacher = current_teacher.detach()
|
| 420 |
+
else:
|
| 421 |
+
current_coefficient = 1.0 - self.beta
|
| 422 |
+
# History has no graph. Consequently only current_student receives
|
| 423 |
+
# gradients, with the mathematically correct (1-beta) coefficient.
|
| 424 |
+
effective_student = (
|
| 425 |
+
self.beta * self.student_ema.detach()
|
| 426 |
+
+ (1.0 - self.beta) * current_student)
|
| 427 |
+
effective_teacher = (
|
| 428 |
+
self.beta * self.teacher_ema.detach()
|
| 429 |
+
+ (1.0 - self.beta) * current_teacher.detach())
|
| 430 |
+
|
| 431 |
+
loss = (effective_student - effective_teacher).square().sum()
|
| 432 |
+
if stage:
|
| 433 |
+
self._pending_student = current_student.detach()
|
| 434 |
+
self._pending_teacher = current_teacher.detach()
|
| 435 |
+
return loss, {
|
| 436 |
+
"bandwidth": self.bandwidth_sq.detach().sqrt(),
|
| 437 |
+
"history_updates": self.num_updates.detach().clone(),
|
| 438 |
+
"current_coefficient": loss.new_tensor(current_coefficient),
|
| 439 |
+
"local_samples": torch.tensor(
|
| 440 |
+
student_count, device=loss.device, dtype=torch.long),
|
| 441 |
+
"global_samples": global_count.detach().to(dtype=torch.long),
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
@torch.no_grad()
|
| 445 |
+
def commit(self) -> None:
|
| 446 |
+
"""Commit staged kernel means after a successful optimizer step."""
|
| 447 |
+
if self._pending_student is None or self._pending_teacher is None:
|
| 448 |
+
return
|
| 449 |
+
if not (
|
| 450 |
+
torch.isfinite(self._pending_student).all()
|
| 451 |
+
and torch.isfinite(self._pending_teacher).all()
|
| 452 |
+
):
|
| 453 |
+
self.discard()
|
| 454 |
+
raise FloatingPointError("refusing to commit non-finite KV kernel mean")
|
| 455 |
+
if self.student_ema.numel() == 0:
|
| 456 |
+
self.student_ema.resize_as_(self._pending_student).copy_(
|
| 457 |
+
self._pending_student)
|
| 458 |
+
self.teacher_ema.resize_as_(self._pending_teacher).copy_(
|
| 459 |
+
self._pending_teacher)
|
| 460 |
+
else:
|
| 461 |
+
self.student_ema.mul_(self.beta).add_(
|
| 462 |
+
self._pending_student, alpha=1.0 - self.beta)
|
| 463 |
+
self.teacher_ema.mul_(self.beta).add_(
|
| 464 |
+
self._pending_teacher, alpha=1.0 - self.beta)
|
| 465 |
+
self.num_updates.add_(1)
|
| 466 |
+
self.discard()
|
| 467 |
+
|
| 468 |
+
def discard(self) -> None:
|
| 469 |
+
"""Drop staged state when backward/optimizer is skipped."""
|
| 470 |
+
self._pending_student = None
|
| 471 |
+
self._pending_teacher = None
|
| 472 |
+
|
| 473 |
+
def get_extra_state(self):
|
| 474 |
+
state = {
|
| 475 |
+
"version": self._STATE_VERSION,
|
| 476 |
+
"beta": self.beta,
|
| 477 |
+
"num_rff_features": self.num_rff_features,
|
| 478 |
+
"bandwidth_multipliers": self.bandwidth_multipliers,
|
| 479 |
+
"seed": self.seed,
|
| 480 |
+
"row_normalize": self.row_normalize,
|
| 481 |
+
}
|
| 482 |
+
# Preserve the exact legacy rows-mode payload. Wan v3 checkpoints
|
| 483 |
+
# contain this dict and must remain loadable without migration.
|
| 484 |
+
if self.sample_mode != "rows":
|
| 485 |
+
state["sample_mode"] = self.sample_mode
|
| 486 |
+
return state
|
| 487 |
+
|
| 488 |
+
def set_extra_state(self, state):
|
| 489 |
+
expected = self.get_extra_state()
|
| 490 |
+
if state != expected:
|
| 491 |
+
raise ValueError(
|
| 492 |
+
f"Wan KV MMD checkpoint/config mismatch: saved={state}, "
|
| 493 |
+
f"current={expected}")
|
| 494 |
+
|
| 495 |
+
def _load_from_state_dict(
|
| 496 |
+
self, state_dict, prefix, local_metadata, strict, missing_keys,
|
| 497 |
+
unexpected_keys, error_msgs,
|
| 498 |
+
):
|
| 499 |
+
for name in (
|
| 500 |
+
"rff_weight", "rff_phase", "student_ema", "teacher_ema",
|
| 501 |
+
):
|
| 502 |
+
key = prefix + name
|
| 503 |
+
if key in state_dict:
|
| 504 |
+
getattr(self, name).resize_as_(state_dict[key])
|
| 505 |
+
super()._load_from_state_dict(
|
| 506 |
+
state_dict, prefix, local_metadata, strict, missing_keys,
|
| 507 |
+
unexpected_keys, error_msgs)
|
1.3B/code/model/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""distribution matching distribution-matching model."""
|
| 2 |
+
from .distribution_matching import DistributionMatchingModel, WanKVMMD
|
| 3 |
+
|
| 4 |
+
__all__ = ["DistributionMatchingModel", "WanKVMMD"]
|
1.3B/code/model/base.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Tuple
|
| 2 |
+
from einops import rearrange
|
| 3 |
+
from torch import nn
|
| 4 |
+
import torch.distributed as dist
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from pipeline import SelfForcingTrainingPipeline
|
| 8 |
+
from utils.loss import get_denoising_loss
|
| 9 |
+
from utils.wan_wrapper import WanDiffusionWrapper, WanTextEncoder, WanVAEWrapper
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class BaseModel(nn.Module):
|
| 13 |
+
def __init__(self, args, device):
|
| 14 |
+
super().__init__()
|
| 15 |
+
self._initialize_models(args, device)
|
| 16 |
+
|
| 17 |
+
self.device = device
|
| 18 |
+
self.args = args
|
| 19 |
+
self.dtype = torch.bfloat16 if args.mixed_precision else torch.float32
|
| 20 |
+
if hasattr(args, "denoising_step_list"):
|
| 21 |
+
self.denoising_step_list = torch.tensor(args.denoising_step_list, dtype=torch.long)
|
| 22 |
+
if args.warp_denoising_step:
|
| 23 |
+
timesteps = torch.cat((self.scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32)))
|
| 24 |
+
self.denoising_step_list = timesteps[1000 - self.denoising_step_list]
|
| 25 |
+
|
| 26 |
+
def _initialize_models(self, args, device):
|
| 27 |
+
self.real_model_name = getattr(args, "real_name", "Wan2.1-T2V-1.3B")
|
| 28 |
+
self.fake_model_name = getattr(args, "fake_name", "Wan2.1-T2V-1.3B")
|
| 29 |
+
|
| 30 |
+
gen_kwargs = dict(getattr(args, "model_kwargs", {}))
|
| 31 |
+
if getattr(args, "attention_type", "softmax") != "softmax":
|
| 32 |
+
raise ValueError("Only causal softmax attention is supported")
|
| 33 |
+
gen_kwargs.setdefault("attention_type", "softmax")
|
| 34 |
+
self.generator = WanDiffusionWrapper(
|
| 35 |
+
**gen_kwargs, is_causal=bool(getattr(args, "causal", True)))
|
| 36 |
+
|
| 37 |
+
self.generator.model.requires_grad_(True)
|
| 38 |
+
|
| 39 |
+
self.uses_score_models = bool(
|
| 40 |
+
getattr(args, "uses_score_models", getattr(args, "distribution_loss", None) != "fd")
|
| 41 |
+
)
|
| 42 |
+
self.uses_real_score = bool(getattr(args, "uses_real_score", self.uses_score_models))
|
| 43 |
+
self.uses_fake_score = bool(getattr(args, "uses_fake_score", self.uses_score_models))
|
| 44 |
+
if self.uses_real_score:
|
| 45 |
+
self.real_score = WanDiffusionWrapper(model_name=self.real_model_name, is_causal=False)
|
| 46 |
+
self.real_score.model.requires_grad_(False)
|
| 47 |
+
else:
|
| 48 |
+
self.real_score = None
|
| 49 |
+
if self.uses_fake_score:
|
| 50 |
+
self.fake_score = WanDiffusionWrapper(model_name=self.fake_model_name, is_causal=False)
|
| 51 |
+
self.fake_score.model.requires_grad_(True)
|
| 52 |
+
else:
|
| 53 |
+
self.fake_score = None
|
| 54 |
+
|
| 55 |
+
# Construct the frozen 5.7B T5 encoder directly in the training dtype.
|
| 56 |
+
# Building four temporary fp32 copies can otherwise exceed host memory
|
| 57 |
+
# before FSDP gets a chance to shard them.
|
| 58 |
+
text_dtype = (
|
| 59 |
+
torch.bfloat16 if bool(getattr(args, "mixed_precision", False))
|
| 60 |
+
else torch.float32
|
| 61 |
+
)
|
| 62 |
+
self.text_encoder = WanTextEncoder(dtype=text_dtype)
|
| 63 |
+
self.text_encoder.requires_grad_(False)
|
| 64 |
+
|
| 65 |
+
self.vae = WanVAEWrapper()
|
| 66 |
+
self.vae.requires_grad_(False)
|
| 67 |
+
|
| 68 |
+
self.scheduler = self.generator.get_scheduler()
|
| 69 |
+
self.scheduler.timesteps = self.scheduler.timesteps.to(device)
|
| 70 |
+
|
| 71 |
+
def _get_timestep(
|
| 72 |
+
self,
|
| 73 |
+
min_timestep: int,
|
| 74 |
+
max_timestep: int,
|
| 75 |
+
batch_size: int,
|
| 76 |
+
num_frame: int,
|
| 77 |
+
num_frame_per_block: int,
|
| 78 |
+
uniform_timestep: bool = False
|
| 79 |
+
) -> torch.Tensor:
|
| 80 |
+
"""
|
| 81 |
+
Randomly generate a timestep tensor based on the generator's task type. It uniformly samples a timestep
|
| 82 |
+
from the range [min_timestep, max_timestep], and returns a tensor of shape [batch_size, num_frame].
|
| 83 |
+
- If uniform_timestep, it will use the same timestep for all frames.
|
| 84 |
+
- If not uniform_timestep, it will use a different timestep for each block.
|
| 85 |
+
"""
|
| 86 |
+
if uniform_timestep:
|
| 87 |
+
timestep = torch.randint(
|
| 88 |
+
min_timestep,
|
| 89 |
+
max_timestep,
|
| 90 |
+
[batch_size, 1],
|
| 91 |
+
device=self.device,
|
| 92 |
+
dtype=torch.long
|
| 93 |
+
).repeat(1, num_frame)
|
| 94 |
+
return timestep
|
| 95 |
+
else:
|
| 96 |
+
timestep = torch.randint(
|
| 97 |
+
min_timestep,
|
| 98 |
+
max_timestep,
|
| 99 |
+
[batch_size, num_frame],
|
| 100 |
+
device=self.device,
|
| 101 |
+
dtype=torch.long
|
| 102 |
+
)
|
| 103 |
+
# make the noise level the same within every block
|
| 104 |
+
if self.independent_first_frame:
|
| 105 |
+
# the first frame is always kept the same
|
| 106 |
+
timestep_from_second = timestep[:, 1:]
|
| 107 |
+
timestep_from_second = timestep_from_second.reshape(
|
| 108 |
+
timestep_from_second.shape[0], -1, num_frame_per_block)
|
| 109 |
+
timestep_from_second[:, :, 1:] = timestep_from_second[:, :, 0:1]
|
| 110 |
+
timestep_from_second = timestep_from_second.reshape(
|
| 111 |
+
timestep_from_second.shape[0], -1)
|
| 112 |
+
timestep = torch.cat([timestep[:, 0:1], timestep_from_second], dim=1)
|
| 113 |
+
else:
|
| 114 |
+
timestep = timestep.reshape(
|
| 115 |
+
timestep.shape[0], -1, num_frame_per_block)
|
| 116 |
+
timestep[:, :, 1:] = timestep[:, :, 0:1]
|
| 117 |
+
timestep = timestep.reshape(timestep.shape[0], -1)
|
| 118 |
+
return timestep
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class SelfForcingModel(BaseModel):
|
| 122 |
+
def __init__(self, args, device):
|
| 123 |
+
super().__init__(args, device)
|
| 124 |
+
self.denoising_loss_func = get_denoising_loss(args.denoising_loss_type)()
|
| 125 |
+
|
| 126 |
+
def _sample_generator_input_descriptor(
|
| 127 |
+
self,
|
| 128 |
+
image_or_video_shape,
|
| 129 |
+
initial_latent: torch.Tensor = None,
|
| 130 |
+
) -> dict:
|
| 131 |
+
"""Explicitly sample `_run_generator`'s block count and initial noise.
|
| 132 |
+
|
| 133 |
+
The returned descriptor is reusable: passing it back to
|
| 134 |
+
``_run_generator`` avoids consulting the ambient RNG for those direct
|
| 135 |
+
inputs. A caller replaying the full rollout must additionally capture
|
| 136 |
+
the post-sampling RNG state used by stochastic pipeline internals.
|
| 137 |
+
"""
|
| 138 |
+
if self.args.i2v:
|
| 139 |
+
noise_shape = [
|
| 140 |
+
image_or_video_shape[0],
|
| 141 |
+
image_or_video_shape[1] - 1,
|
| 142 |
+
*image_or_video_shape[2:],
|
| 143 |
+
]
|
| 144 |
+
else:
|
| 145 |
+
noise_shape = list(image_or_video_shape)
|
| 146 |
+
|
| 147 |
+
min_num_frames = 20 if self.args.independent_first_frame else 21
|
| 148 |
+
max_num_frames = (
|
| 149 |
+
self.num_training_frames - 1
|
| 150 |
+
if self.args.independent_first_frame
|
| 151 |
+
else self.num_training_frames
|
| 152 |
+
)
|
| 153 |
+
if max_num_frames % self.num_frame_per_block != 0:
|
| 154 |
+
raise ValueError(
|
| 155 |
+
"num_training_frames is not divisible by "
|
| 156 |
+
"num_frame_per_block")
|
| 157 |
+
if min_num_frames % self.num_frame_per_block != 0:
|
| 158 |
+
raise ValueError(
|
| 159 |
+
"minimum generated frames is not divisible by "
|
| 160 |
+
"num_frame_per_block")
|
| 161 |
+
max_num_blocks = max_num_frames // self.num_frame_per_block
|
| 162 |
+
min_num_blocks = min_num_frames // self.num_frame_per_block
|
| 163 |
+
block_count = torch.randint(
|
| 164 |
+
min_num_blocks,
|
| 165 |
+
max_num_blocks + 1,
|
| 166 |
+
(1,),
|
| 167 |
+
device=self.device,
|
| 168 |
+
)
|
| 169 |
+
if dist.is_available() and dist.is_initialized():
|
| 170 |
+
dist.broadcast(block_count, src=0)
|
| 171 |
+
num_generated_blocks = int(block_count.item())
|
| 172 |
+
num_generated_frames = (
|
| 173 |
+
num_generated_blocks * self.num_frame_per_block)
|
| 174 |
+
if self.args.independent_first_frame and initial_latent is None:
|
| 175 |
+
num_generated_frames += 1
|
| 176 |
+
noise_shape[1] = num_generated_frames
|
| 177 |
+
return {
|
| 178 |
+
"version": 1,
|
| 179 |
+
"num_generated_blocks": num_generated_blocks,
|
| 180 |
+
"noise": torch.randn(
|
| 181 |
+
noise_shape, device=self.device, dtype=self.dtype),
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
def _run_generator(
|
| 185 |
+
self,
|
| 186 |
+
image_or_video_shape,
|
| 187 |
+
conditional_dict: dict,
|
| 188 |
+
initial_latent: torch.tensor = None,
|
| 189 |
+
generation_descriptor: dict = None,
|
| 190 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 191 |
+
"""
|
| 192 |
+
Optionally simulate the generator's input from noise using backward simulation
|
| 193 |
+
and then run the generator for one-step.
|
| 194 |
+
Input:
|
| 195 |
+
- image_or_video_shape: a list containing the shape of the image or video [B, F, C, H, W].
|
| 196 |
+
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
|
| 197 |
+
- unconditional_dict: a dictionary containing the unconditional information (e.g. null/negative text embeddings, null/negative image embeddings).
|
| 198 |
+
- clean_latent: a tensor containing the clean latents [B, F, C, H, W]. Need to be passed when no backward simulation is used.
|
| 199 |
+
- initial_latent: a tensor containing the initial latents [B, F, C, H, W].
|
| 200 |
+
Output:
|
| 201 |
+
- pred_image: a tensor with shape [B, F, C, H, W].
|
| 202 |
+
- denoised_timestep: an integer
|
| 203 |
+
"""
|
| 204 |
+
# Step 1: Sample noise and backward simulate the generator's input
|
| 205 |
+
assert getattr(self.args, "backward_simulation", True), "Backward simulation needs to be enabled"
|
| 206 |
+
if initial_latent is not None:
|
| 207 |
+
conditional_dict["initial_latent"] = initial_latent
|
| 208 |
+
# During training, the number of generated frames should be uniformly sampled from
|
| 209 |
+
# [21, self.num_training_frames], but still being a multiple of self.num_frame_per_block
|
| 210 |
+
min_num_frames = 20 if self.args.independent_first_frame else 21
|
| 211 |
+
max_num_frames = self.num_training_frames - 1 if self.args.independent_first_frame else self.num_training_frames
|
| 212 |
+
assert max_num_frames % self.num_frame_per_block == 0
|
| 213 |
+
assert min_num_frames % self.num_frame_per_block == 0
|
| 214 |
+
max_num_blocks = max_num_frames // self.num_frame_per_block
|
| 215 |
+
min_num_blocks = min_num_frames // self.num_frame_per_block
|
| 216 |
+
if generation_descriptor is None:
|
| 217 |
+
generation_descriptor = (
|
| 218 |
+
self._sample_generator_input_descriptor(
|
| 219 |
+
image_or_video_shape,
|
| 220 |
+
initial_latent=initial_latent,
|
| 221 |
+
)
|
| 222 |
+
)
|
| 223 |
+
if (
|
| 224 |
+
not isinstance(generation_descriptor, dict)
|
| 225 |
+
or set(generation_descriptor)
|
| 226 |
+
!= {"version", "num_generated_blocks", "noise"}
|
| 227 |
+
or int(generation_descriptor["version"]) != 1
|
| 228 |
+
):
|
| 229 |
+
raise ValueError("invalid explicit generator input descriptor")
|
| 230 |
+
num_generated_blocks = int(
|
| 231 |
+
generation_descriptor["num_generated_blocks"])
|
| 232 |
+
if not min_num_blocks <= num_generated_blocks <= max_num_blocks:
|
| 233 |
+
raise ValueError(
|
| 234 |
+
"generator descriptor block count is outside the configured "
|
| 235 |
+
f"range [{min_num_blocks}, {max_num_blocks}]")
|
| 236 |
+
if dist.is_available() and dist.is_initialized():
|
| 237 |
+
block_min = torch.tensor(
|
| 238 |
+
num_generated_blocks, device=self.device, dtype=torch.long)
|
| 239 |
+
block_max = block_min.clone()
|
| 240 |
+
dist.all_reduce(block_min, op=dist.ReduceOp.MIN)
|
| 241 |
+
dist.all_reduce(block_max, op=dist.ReduceOp.MAX)
|
| 242 |
+
if int(block_min.item()) != int(block_max.item()):
|
| 243 |
+
raise ValueError(
|
| 244 |
+
"generator descriptor block count differs across ranks")
|
| 245 |
+
num_generated_frames = num_generated_blocks * self.num_frame_per_block
|
| 246 |
+
if self.args.independent_first_frame and initial_latent is None:
|
| 247 |
+
num_generated_frames += 1
|
| 248 |
+
min_num_frames += 1
|
| 249 |
+
expected_noise_shape = list(image_or_video_shape)
|
| 250 |
+
if self.args.i2v:
|
| 251 |
+
expected_noise_shape[1] -= 1
|
| 252 |
+
expected_noise_shape[1] = num_generated_frames
|
| 253 |
+
generation_noise = generation_descriptor["noise"]
|
| 254 |
+
expected_device = (
|
| 255 |
+
torch.device("cuda", int(self.device))
|
| 256 |
+
if isinstance(self.device, int)
|
| 257 |
+
else torch.device(self.device)
|
| 258 |
+
)
|
| 259 |
+
if (
|
| 260 |
+
not isinstance(generation_noise, torch.Tensor)
|
| 261 |
+
or tuple(generation_noise.shape) != tuple(expected_noise_shape)
|
| 262 |
+
or generation_noise.device != expected_device
|
| 263 |
+
or generation_noise.dtype != self.dtype
|
| 264 |
+
or not bool(torch.isfinite(generation_noise).all().item())
|
| 265 |
+
):
|
| 266 |
+
raise ValueError(
|
| 267 |
+
"generator descriptor noise is incompatible with the "
|
| 268 |
+
f"expected {tuple(expected_noise_shape)} {self.dtype} on "
|
| 269 |
+
f"{expected_device}")
|
| 270 |
+
|
| 271 |
+
pred_image_or_video, denoised_timestep_from, denoised_timestep_to = self._consistency_backward_simulation(
|
| 272 |
+
noise=generation_noise,
|
| 273 |
+
**conditional_dict,
|
| 274 |
+
)
|
| 275 |
+
# Slice last 21 frames
|
| 276 |
+
if pred_image_or_video.shape[1] > 21:
|
| 277 |
+
with torch.no_grad():
|
| 278 |
+
# Reencode to get image latent
|
| 279 |
+
latent_to_decode = pred_image_or_video[:, :-20, ...]
|
| 280 |
+
# Deccode to video
|
| 281 |
+
pixels = self.vae.decode_to_pixel(latent_to_decode)
|
| 282 |
+
frame = pixels[:, -1:, ...].to(self.dtype)
|
| 283 |
+
frame = rearrange(frame, "b t c h w -> b c t h w")
|
| 284 |
+
# Encode frame to get image latent
|
| 285 |
+
image_latent = self.vae.encode_to_latent(frame).to(self.dtype)
|
| 286 |
+
pred_image_or_video_last_21 = torch.cat([image_latent, pred_image_or_video[:, -20:, ...]], dim=1)
|
| 287 |
+
else:
|
| 288 |
+
pred_image_or_video_last_21 = pred_image_or_video
|
| 289 |
+
|
| 290 |
+
if num_generated_frames != min_num_frames:
|
| 291 |
+
# Currently, we do not use gradient for the first chunk, since it contains image latents
|
| 292 |
+
gradient_mask = torch.ones_like(pred_image_or_video_last_21, dtype=torch.bool)
|
| 293 |
+
if self.args.independent_first_frame:
|
| 294 |
+
gradient_mask[:, :1] = False
|
| 295 |
+
else:
|
| 296 |
+
gradient_mask[:, :self.num_frame_per_block] = False
|
| 297 |
+
else:
|
| 298 |
+
gradient_mask = None
|
| 299 |
+
|
| 300 |
+
pred_image_or_video_last_21 = pred_image_or_video_last_21.to(self.dtype)
|
| 301 |
+
return pred_image_or_video_last_21, gradient_mask, denoised_timestep_from, denoised_timestep_to
|
| 302 |
+
|
| 303 |
+
def _consistency_backward_simulation(
|
| 304 |
+
self,
|
| 305 |
+
noise: torch.Tensor,
|
| 306 |
+
**conditional_dict: dict
|
| 307 |
+
) -> torch.Tensor:
|
| 308 |
+
"""
|
| 309 |
+
Simulate the generator's input from noise to avoid training/inference mismatch.
|
| 310 |
+
See Sec 4.5 of the DMD2 paper (https://arxiv.org/abs/2405.14867) for details.
|
| 311 |
+
Here we use the consistency sampler (https://arxiv.org/abs/2303.01469)
|
| 312 |
+
Input:
|
| 313 |
+
- noise: a tensor sampled from N(0, 1) with shape [B, F, C, H, W] where the number of frame is 1 for images.
|
| 314 |
+
- conditional_dict: a dictionary containing the conditional information (e.g. text embeddings, image embeddings).
|
| 315 |
+
Output:
|
| 316 |
+
- output: a tensor with shape [B, T, F, C, H, W].
|
| 317 |
+
T is the total number of timesteps. output[0] is a pure noise and output[i] and i>0
|
| 318 |
+
represents the x0 prediction at each timestep.
|
| 319 |
+
"""
|
| 320 |
+
if self.inference_pipeline is None:
|
| 321 |
+
self._initialize_inference_pipeline()
|
| 322 |
+
|
| 323 |
+
return self.inference_pipeline.inference_with_trajectory(
|
| 324 |
+
noise=noise, **conditional_dict
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
def _initialize_inference_pipeline(self):
|
| 328 |
+
"""
|
| 329 |
+
Lazy initialize the inference pipeline during the first backward simulation run.
|
| 330 |
+
Here we encapsulate the inference code with a model-dependent outside function.
|
| 331 |
+
We pass our FSDP-wrapped modules into the pipeline to save memory.
|
| 332 |
+
"""
|
| 333 |
+
self.inference_pipeline = SelfForcingTrainingPipeline(
|
| 334 |
+
denoising_step_list=self.denoising_step_list,
|
| 335 |
+
scheduler=self.scheduler,
|
| 336 |
+
generator=self.generator,
|
| 337 |
+
num_frame_per_block=self.num_frame_per_block,
|
| 338 |
+
independent_first_frame=self.args.independent_first_frame,
|
| 339 |
+
same_step_across_blocks=self.args.same_step_across_blocks,
|
| 340 |
+
last_step_only=self.args.last_step_only,
|
| 341 |
+
num_max_frames=self.num_training_frames,
|
| 342 |
+
context_noise=self.args.context_noise
|
| 343 |
+
)
|
1.3B/code/model/distribution_matching.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
1.3B/code/model/generation.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared generator setup and few-step rollout utilities.
|
| 2 |
+
|
| 3 |
+
This module contains only generator behavior used by distribution matching. Loss
|
| 4 |
+
construction lives in :mod:`model.distribution_matching`.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.distributed as dist
|
| 13 |
+
|
| 14 |
+
from model.base import SelfForcingModel
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FewStepGenerationModel(SelfForcingModel):
|
| 18 |
+
"""Initialize the causal generator and expose deterministic replay helpers."""
|
| 19 |
+
|
| 20 |
+
def __init__(self, args, device):
|
| 21 |
+
super().__init__(args, device)
|
| 22 |
+
self.causal_generator = bool(getattr(args, "causal", True))
|
| 23 |
+
self.num_frame_per_block = getattr(args, "num_frame_per_block", 1)
|
| 24 |
+
self.same_step_across_blocks = getattr(
|
| 25 |
+
args, "same_step_across_blocks", True)
|
| 26 |
+
self.num_training_frames = getattr(args, "num_training_frames", 21)
|
| 27 |
+
|
| 28 |
+
if self.causal_generator and self.num_frame_per_block > 1:
|
| 29 |
+
self.generator.model.num_frame_per_block = self.num_frame_per_block
|
| 30 |
+
|
| 31 |
+
self.independent_first_frame = getattr(
|
| 32 |
+
args, "independent_first_frame", False)
|
| 33 |
+
if self.causal_generator and self.independent_first_frame:
|
| 34 |
+
self.generator.model.independent_first_frame = True
|
| 35 |
+
if args.gradient_checkpointing:
|
| 36 |
+
self.generator.enable_gradient_checkpointing()
|
| 37 |
+
|
| 38 |
+
self.inference_pipeline = None
|
| 39 |
+
self.guidance_scale = float(getattr(args, "guidance_scale", 1.0))
|
| 40 |
+
|
| 41 |
+
if not hasattr(self, "denoising_step_list"):
|
| 42 |
+
raise ValueError(
|
| 43 |
+
"few-step generation requires denoising_step_list")
|
| 44 |
+
denoising_steps = self.denoising_step_list.to(device=device)
|
| 45 |
+
if denoising_steps.numel() > 0 and denoising_steps[-1].item() == 0:
|
| 46 |
+
denoising_steps = denoising_steps[:-1]
|
| 47 |
+
self.generation_denoising_step_list = denoising_steps
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
def _apply_gradient_mask(
|
| 51 |
+
latents: torch.Tensor,
|
| 52 |
+
gradient_mask: Optional[torch.Tensor],
|
| 53 |
+
) -> torch.Tensor:
|
| 54 |
+
if gradient_mask is None:
|
| 55 |
+
return latents
|
| 56 |
+
frame_mask = gradient_mask.flatten(2).any(dim=2).view(
|
| 57 |
+
latents.shape[0], latents.shape[1], 1, 1, 1)
|
| 58 |
+
return torch.where(frame_mask, latents, latents.detach())
|
| 59 |
+
|
| 60 |
+
def _predict_x0_with_cfg(
|
| 61 |
+
self,
|
| 62 |
+
noisy_latent: torch.Tensor,
|
| 63 |
+
conditional_dict: dict,
|
| 64 |
+
unconditional_dict: dict,
|
| 65 |
+
timestep: torch.Tensor,
|
| 66 |
+
) -> torch.Tensor:
|
| 67 |
+
_, x0_cond = self.generator(
|
| 68 |
+
noisy_image_or_video=noisy_latent,
|
| 69 |
+
conditional_dict=conditional_dict,
|
| 70 |
+
timestep=timestep,
|
| 71 |
+
)
|
| 72 |
+
if self.guidance_scale == 1.0:
|
| 73 |
+
return x0_cond
|
| 74 |
+
|
| 75 |
+
_, x0_uncond = self.generator(
|
| 76 |
+
noisy_image_or_video=noisy_latent,
|
| 77 |
+
conditional_dict=unconditional_dict,
|
| 78 |
+
timestep=timestep,
|
| 79 |
+
)
|
| 80 |
+
return x0_uncond + self.guidance_scale * (x0_cond - x0_uncond)
|
| 81 |
+
|
| 82 |
+
def _run_few_step_generator(
|
| 83 |
+
self,
|
| 84 |
+
image_or_video_shape,
|
| 85 |
+
conditional_dict: dict,
|
| 86 |
+
unconditional_dict: dict,
|
| 87 |
+
exit_index: Optional[int] = None,
|
| 88 |
+
) -> torch.Tensor:
|
| 89 |
+
"""Run detached rollout steps followed by one differentiated exit step."""
|
| 90 |
+
latent = torch.randn(
|
| 91 |
+
image_or_video_shape,
|
| 92 |
+
device=self.device,
|
| 93 |
+
dtype=self.dtype,
|
| 94 |
+
)
|
| 95 |
+
batch_size, num_frames = latent.shape[:2]
|
| 96 |
+
denoising_steps = self.generation_denoising_step_list.to(
|
| 97 |
+
device=self.device)
|
| 98 |
+
num_steps = len(denoising_steps)
|
| 99 |
+
|
| 100 |
+
if exit_index is None:
|
| 101 |
+
if num_steps <= 1:
|
| 102 |
+
exit_index = 0
|
| 103 |
+
else:
|
| 104 |
+
index_tensor = torch.randint(
|
| 105 |
+
0, num_steps, (1,), device=self.device)
|
| 106 |
+
if dist.is_available() and dist.is_initialized():
|
| 107 |
+
dist.broadcast(index_tensor, src=0)
|
| 108 |
+
exit_index = int(index_tensor.item())
|
| 109 |
+
|
| 110 |
+
for index in range(num_steps):
|
| 111 |
+
current_timestep = denoising_steps[index]
|
| 112 |
+
timestep = torch.ones(
|
| 113 |
+
[batch_size, num_frames],
|
| 114 |
+
device=self.device,
|
| 115 |
+
dtype=current_timestep.dtype,
|
| 116 |
+
) * current_timestep
|
| 117 |
+
if index < exit_index:
|
| 118 |
+
with torch.no_grad():
|
| 119 |
+
x0_pred = self._predict_x0_with_cfg(
|
| 120 |
+
noisy_latent=latent,
|
| 121 |
+
conditional_dict=conditional_dict,
|
| 122 |
+
unconditional_dict=unconditional_dict,
|
| 123 |
+
timestep=timestep,
|
| 124 |
+
)
|
| 125 |
+
next_timestep = denoising_steps[index + 1]
|
| 126 |
+
latent = self.scheduler.add_noise(
|
| 127 |
+
x0_pred.flatten(0, 1),
|
| 128 |
+
torch.randn_like(x0_pred.flatten(0, 1)),
|
| 129 |
+
next_timestep * torch.ones(
|
| 130 |
+
[batch_size * num_frames],
|
| 131 |
+
device=self.device,
|
| 132 |
+
dtype=next_timestep.dtype,
|
| 133 |
+
),
|
| 134 |
+
).unflatten(0, x0_pred.shape[:2]).to(self.dtype)
|
| 135 |
+
else:
|
| 136 |
+
return self._predict_x0_with_cfg(
|
| 137 |
+
noisy_latent=latent,
|
| 138 |
+
conditional_dict=conditional_dict,
|
| 139 |
+
unconditional_dict=unconditional_dict,
|
| 140 |
+
timestep=timestep,
|
| 141 |
+
)
|
| 142 |
+
return latent
|
1.3B/code/model/wan_kv_mmd.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility import; read model/distribution_matching.py for the implementation."""
|
| 2 |
+
import sys
|
| 3 |
+
from . import distribution_matching as _implementation
|
| 4 |
+
# 这是一个历史遗留问题的转发窗口
|
| 5 |
+
# Share the module object so existing monkeypatches and private imports still work.
|
| 6 |
+
sys.modules[__name__] = _implementation
|
1.3B/code/pipeline/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .causal_inference import CausalInferencePipeline
|
| 2 |
+
from .self_forcing_training import SelfForcingTrainingPipeline
|
| 3 |
+
|
| 4 |
+
__all__ = ["CausalInferencePipeline", "SelfForcingTrainingPipeline"]
|
1.3B/code/pipeline/causal_inference.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from utils.wan_wrapper import WanDiffusionWrapper, WanTextEncoder, WanVAEWrapper
|
| 5 |
+
|
| 6 |
+
from demo_utils.memory import gpu, get_cuda_free_memory_gb, DynamicSwapInstaller, move_model_to_device_with_memory_preservation
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class CausalInferencePipeline(torch.nn.Module):
|
| 10 |
+
def __init__(
|
| 11 |
+
self,
|
| 12 |
+
args,
|
| 13 |
+
device,
|
| 14 |
+
generator=None,
|
| 15 |
+
text_encoder=None,
|
| 16 |
+
vae=None
|
| 17 |
+
):
|
| 18 |
+
super().__init__()
|
| 19 |
+
# Step 1: Initialize all models
|
| 20 |
+
if generator is None:
|
| 21 |
+
gen_kwargs = dict(getattr(args, "model_kwargs", {}))
|
| 22 |
+
if getattr(args, "attention_type", "softmax") != "softmax":
|
| 23 |
+
raise ValueError("Only causal softmax attention is supported")
|
| 24 |
+
gen_kwargs.setdefault("attention_type", "softmax")
|
| 25 |
+
self.generator = WanDiffusionWrapper(**gen_kwargs, is_causal=True)
|
| 26 |
+
else:
|
| 27 |
+
self.generator = generator
|
| 28 |
+
self.text_encoder = WanTextEncoder() if text_encoder is None else text_encoder
|
| 29 |
+
self.vae = WanVAEWrapper() if vae is None else vae
|
| 30 |
+
|
| 31 |
+
# Step 2: Initialize all causal hyperparmeters
|
| 32 |
+
self.scheduler = self.generator.get_scheduler()
|
| 33 |
+
self.denoising_step_list = torch.tensor(
|
| 34 |
+
args.denoising_step_list, dtype=torch.long)
|
| 35 |
+
if args.warp_denoising_step:
|
| 36 |
+
timesteps = torch.cat((self.scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32)))
|
| 37 |
+
self.denoising_step_list = timesteps[1000 - self.denoising_step_list]
|
| 38 |
+
|
| 39 |
+
self.num_transformer_blocks = 30
|
| 40 |
+
self.frame_seq_length = 1560
|
| 41 |
+
|
| 42 |
+
self.attention_type = getattr(self.generator, "attention_type", "softmax")
|
| 43 |
+
self.kv_cache1 = None
|
| 44 |
+
self.args = args
|
| 45 |
+
self.num_frame_per_block = getattr(args, "num_frame_per_block", 1)
|
| 46 |
+
self.independent_first_frame = args.independent_first_frame
|
| 47 |
+
# Linear-attention student does not have a (sliding) ``local_attn_size``;
|
| 48 |
+
# treat it as unbounded so the recurrent state is allocated for the full
|
| 49 |
+
# video.
|
| 50 |
+
self.local_attn_size = getattr(self.generator.model, "local_attn_size", -1)
|
| 51 |
+
|
| 52 |
+
print(
|
| 53 |
+
f"KV inference with {self.num_frame_per_block} frames per block "
|
| 54 |
+
f"(attention_type={self.attention_type})"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if self.num_frame_per_block > 1:
|
| 58 |
+
self.generator.model.num_frame_per_block = self.num_frame_per_block
|
| 59 |
+
|
| 60 |
+
def _set_advance_state(self, value: bool) -> None:
|
| 61 |
+
"""Toggle ``advance_state`` on every linear/hybrid kv-cache dict.
|
| 62 |
+
|
| 63 |
+
See ``SelfForcingTrainingPipeline._set_advance_state``: the flag
|
| 64 |
+
controls whether the linear / hybrid recurrent state is updated in
|
| 65 |
+
place on a given forward. Pipelines set ``False`` for every
|
| 66 |
+
denoising-loop pass and ``True`` only on the clean re-run so each
|
| 67 |
+
block contributes EXACTLY ONCE to the persistent state, matching
|
| 68 |
+
the chunkwise training semantics and aligning train / inference
|
| 69 |
+
cache distributions. No-op for softmax caches.
|
| 70 |
+
"""
|
| 71 |
+
if self.kv_cache1 is None:
|
| 72 |
+
return
|
| 73 |
+
for cache in self.kv_cache1:
|
| 74 |
+
if "state" in cache:
|
| 75 |
+
cache["advance_state"] = value
|
| 76 |
+
# ARL2 HybridBlockAttention layers: clean-pass GDN update flag.
|
| 77 |
+
if "gdn_state" in cache:
|
| 78 |
+
cache["update_state"] = value
|
| 79 |
+
|
| 80 |
+
def inference(
|
| 81 |
+
self,
|
| 82 |
+
noise: torch.Tensor,
|
| 83 |
+
text_prompts: List[str],
|
| 84 |
+
initial_latent: Optional[torch.Tensor] = None,
|
| 85 |
+
return_latents: bool = False,
|
| 86 |
+
profile: bool = False,
|
| 87 |
+
low_memory: bool = False,
|
| 88 |
+
decode: bool = True,
|
| 89 |
+
) -> torch.Tensor:
|
| 90 |
+
"""
|
| 91 |
+
Perform inference on the given noise and text prompts.
|
| 92 |
+
Inputs:
|
| 93 |
+
noise (torch.Tensor): The input noise tensor of shape
|
| 94 |
+
(batch_size, num_output_frames, num_channels, height, width).
|
| 95 |
+
text_prompts (List[str]): The list of text prompts.
|
| 96 |
+
initial_latent (torch.Tensor): The initial latent tensor of shape
|
| 97 |
+
(batch_size, num_input_frames, num_channels, height, width).
|
| 98 |
+
If num_input_frames is 1, perform image to video.
|
| 99 |
+
If num_input_frames is greater than 1, perform video extension.
|
| 100 |
+
return_latents (bool): Whether to return the latents.
|
| 101 |
+
Outputs:
|
| 102 |
+
video (torch.Tensor): The generated video tensor of shape
|
| 103 |
+
(batch_size, num_output_frames, num_channels, height, width).
|
| 104 |
+
It is normalized to be in the range [0, 1].
|
| 105 |
+
"""
|
| 106 |
+
batch_size, num_frames, num_channels, height, width = noise.shape
|
| 107 |
+
if not self.independent_first_frame or (self.independent_first_frame and initial_latent is not None):
|
| 108 |
+
# If the first frame is independent and the first frame is provided, then the number of frames in the
|
| 109 |
+
# noise should still be a multiple of num_frame_per_block
|
| 110 |
+
assert num_frames % self.num_frame_per_block == 0
|
| 111 |
+
num_blocks = num_frames // self.num_frame_per_block
|
| 112 |
+
else:
|
| 113 |
+
# Using a [1, 4, 4, 4, 4, 4, ...] model to generate a video without image conditioning
|
| 114 |
+
assert (num_frames - 1) % self.num_frame_per_block == 0
|
| 115 |
+
num_blocks = (num_frames - 1) // self.num_frame_per_block
|
| 116 |
+
num_input_frames = initial_latent.shape[1] if initial_latent is not None else 0
|
| 117 |
+
num_output_frames = num_frames + num_input_frames # add the initial latent frames
|
| 118 |
+
conditional_dict = self.text_encoder(
|
| 119 |
+
text_prompts=text_prompts
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
if low_memory:
|
| 123 |
+
gpu_memory_preservation = get_cuda_free_memory_gb(gpu) + 5
|
| 124 |
+
move_model_to_device_with_memory_preservation(self.text_encoder, target_device=gpu, preserved_memory_gb=gpu_memory_preservation)
|
| 125 |
+
|
| 126 |
+
output = torch.zeros(
|
| 127 |
+
[batch_size, num_output_frames, num_channels, height, width],
|
| 128 |
+
device=noise.device,
|
| 129 |
+
dtype=noise.dtype
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Set up profiling if requested
|
| 133 |
+
if profile:
|
| 134 |
+
init_start = torch.cuda.Event(enable_timing=True)
|
| 135 |
+
init_end = torch.cuda.Event(enable_timing=True)
|
| 136 |
+
diffusion_start = torch.cuda.Event(enable_timing=True)
|
| 137 |
+
diffusion_end = torch.cuda.Event(enable_timing=True)
|
| 138 |
+
vae_start = torch.cuda.Event(enable_timing=True)
|
| 139 |
+
vae_end = torch.cuda.Event(enable_timing=True)
|
| 140 |
+
block_times = []
|
| 141 |
+
block_start = torch.cuda.Event(enable_timing=True)
|
| 142 |
+
block_end = torch.cuda.Event(enable_timing=True)
|
| 143 |
+
init_start.record()
|
| 144 |
+
|
| 145 |
+
# Step 1: Initialize KV cache to all zeros
|
| 146 |
+
if self.kv_cache1 is None:
|
| 147 |
+
self._initialize_kv_cache(
|
| 148 |
+
batch_size=batch_size,
|
| 149 |
+
dtype=noise.dtype,
|
| 150 |
+
device=noise.device
|
| 151 |
+
)
|
| 152 |
+
self._initialize_crossattn_cache(
|
| 153 |
+
batch_size=batch_size,
|
| 154 |
+
dtype=noise.dtype,
|
| 155 |
+
device=noise.device
|
| 156 |
+
)
|
| 157 |
+
else:
|
| 158 |
+
# reset cross attn cache
|
| 159 |
+
for block_index in range(self.num_transformer_blocks):
|
| 160 |
+
self.crossattn_cache[block_index]["is_init"] = False
|
| 161 |
+
# reset kv cache
|
| 162 |
+
for block_index in range(len(self.kv_cache1)):
|
| 163 |
+
cache = self.kv_cache1[block_index]
|
| 164 |
+
cache["global_end_index"] = torch.tensor(
|
| 165 |
+
[0], dtype=torch.long, device=noise.device)
|
| 166 |
+
cache["local_end_index"] = torch.tensor(
|
| 167 |
+
[0], dtype=torch.long, device=noise.device)
|
| 168 |
+
# Linear/hybrid layers carry a recurrent state; softmax
|
| 169 |
+
# layers carry a K/V cache. In a MIXED model each layer
|
| 170 |
+
# has only one of the two -- zero whichever is present.
|
| 171 |
+
if "state" in cache:
|
| 172 |
+
cache["state"].zero_()
|
| 173 |
+
if "normalizer" in cache:
|
| 174 |
+
cache["normalizer"].zero_()
|
| 175 |
+
if "k" in cache:
|
| 176 |
+
cache["k"].zero_()
|
| 177 |
+
if "v" in cache:
|
| 178 |
+
cache["v"].zero_()
|
| 179 |
+
# ARL2 GDN layers: drop the leaked recurrent state so the next
|
| 180 |
+
# video starts from a clean (lazily re-allocated) state.
|
| 181 |
+
if "gdn_state" in cache:
|
| 182 |
+
cache["gdn_state"] = None
|
| 183 |
+
cache["update_state"] = True
|
| 184 |
+
|
| 185 |
+
# Step 2: Cache context feature
|
| 186 |
+
current_start_frame = 0
|
| 187 |
+
if initial_latent is not None:
|
| 188 |
+
timestep = torch.ones([batch_size, 1], device=noise.device, dtype=torch.int64) * 0
|
| 189 |
+
# Initial-latent context forwards write clean K/V into the cache
|
| 190 |
+
# exactly once per block -> advance_state=True.
|
| 191 |
+
self._set_advance_state(True)
|
| 192 |
+
if self.independent_first_frame:
|
| 193 |
+
# Assume num_input_frames is 1 + self.num_frame_per_block * num_input_blocks
|
| 194 |
+
assert (num_input_frames - 1) % self.num_frame_per_block == 0
|
| 195 |
+
num_input_blocks = (num_input_frames - 1) // self.num_frame_per_block
|
| 196 |
+
output[:, :1] = initial_latent[:, :1]
|
| 197 |
+
self.generator(
|
| 198 |
+
noisy_image_or_video=initial_latent[:, :1],
|
| 199 |
+
conditional_dict=conditional_dict,
|
| 200 |
+
timestep=timestep * 0,
|
| 201 |
+
kv_cache=self.kv_cache1,
|
| 202 |
+
crossattn_cache=self.crossattn_cache,
|
| 203 |
+
current_start=current_start_frame * self.frame_seq_length,
|
| 204 |
+
)
|
| 205 |
+
current_start_frame += 1
|
| 206 |
+
else:
|
| 207 |
+
# Assume num_input_frames is self.num_frame_per_block * num_input_blocks
|
| 208 |
+
assert num_input_frames % self.num_frame_per_block == 0
|
| 209 |
+
num_input_blocks = num_input_frames // self.num_frame_per_block
|
| 210 |
+
|
| 211 |
+
for _ in range(num_input_blocks):
|
| 212 |
+
current_ref_latents = \
|
| 213 |
+
initial_latent[:, current_start_frame:current_start_frame + self.num_frame_per_block]
|
| 214 |
+
output[:, current_start_frame:current_start_frame + self.num_frame_per_block] = current_ref_latents
|
| 215 |
+
self.generator(
|
| 216 |
+
noisy_image_or_video=current_ref_latents,
|
| 217 |
+
conditional_dict=conditional_dict,
|
| 218 |
+
timestep=timestep * 0,
|
| 219 |
+
kv_cache=self.kv_cache1,
|
| 220 |
+
crossattn_cache=self.crossattn_cache,
|
| 221 |
+
current_start=current_start_frame * self.frame_seq_length,
|
| 222 |
+
)
|
| 223 |
+
current_start_frame += self.num_frame_per_block
|
| 224 |
+
|
| 225 |
+
if profile:
|
| 226 |
+
init_end.record()
|
| 227 |
+
torch.cuda.synchronize()
|
| 228 |
+
diffusion_start.record()
|
| 229 |
+
|
| 230 |
+
# Step 3: Temporal denoising loop
|
| 231 |
+
all_num_frames = [self.num_frame_per_block] * num_blocks
|
| 232 |
+
if self.independent_first_frame and initial_latent is None:
|
| 233 |
+
all_num_frames = [1] + all_num_frames
|
| 234 |
+
for current_num_frames in all_num_frames:
|
| 235 |
+
if profile:
|
| 236 |
+
block_start.record()
|
| 237 |
+
|
| 238 |
+
noisy_input = noise[
|
| 239 |
+
:, current_start_frame - num_input_frames:current_start_frame + current_num_frames - num_input_frames]
|
| 240 |
+
|
| 241 |
+
# All denoising-loop forwards on this block read the same cross-block
|
| 242 |
+
# cache and do NOT advance it; only the clean re-run (Step 3.3)
|
| 243 |
+
# advances the cache. No-op for softmax caches.
|
| 244 |
+
self._set_advance_state(False)
|
| 245 |
+
|
| 246 |
+
# Step 3.1: Spatial denoising loop
|
| 247 |
+
for index, current_timestep in enumerate(self.denoising_step_list):
|
| 248 |
+
print(f"current_timestep: {current_timestep}")
|
| 249 |
+
# set current timestep
|
| 250 |
+
timestep = torch.ones(
|
| 251 |
+
[batch_size, current_num_frames],
|
| 252 |
+
device=noise.device,
|
| 253 |
+
dtype=torch.int64) * current_timestep
|
| 254 |
+
|
| 255 |
+
if index < len(self.denoising_step_list) - 1:
|
| 256 |
+
_, denoised_pred = self.generator(
|
| 257 |
+
noisy_image_or_video=noisy_input,
|
| 258 |
+
conditional_dict=conditional_dict,
|
| 259 |
+
timestep=timestep,
|
| 260 |
+
kv_cache=self.kv_cache1,
|
| 261 |
+
crossattn_cache=self.crossattn_cache,
|
| 262 |
+
current_start=current_start_frame * self.frame_seq_length
|
| 263 |
+
)
|
| 264 |
+
next_timestep = self.denoising_step_list[index + 1]
|
| 265 |
+
noisy_input = self.scheduler.add_noise(
|
| 266 |
+
denoised_pred.flatten(0, 1),
|
| 267 |
+
torch.randn_like(denoised_pred.flatten(0, 1)),
|
| 268 |
+
next_timestep * torch.ones(
|
| 269 |
+
[batch_size * current_num_frames], device=noise.device, dtype=torch.long)
|
| 270 |
+
).unflatten(0, denoised_pred.shape[:2])
|
| 271 |
+
else:
|
| 272 |
+
# for getting real output
|
| 273 |
+
_, denoised_pred = self.generator(
|
| 274 |
+
noisy_image_or_video=noisy_input,
|
| 275 |
+
conditional_dict=conditional_dict,
|
| 276 |
+
timestep=timestep,
|
| 277 |
+
kv_cache=self.kv_cache1,
|
| 278 |
+
crossattn_cache=self.crossattn_cache,
|
| 279 |
+
current_start=current_start_frame * self.frame_seq_length
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
# Step 3.2: record the model's output
|
| 283 |
+
output[:, current_start_frame:current_start_frame + current_num_frames] = denoised_pred
|
| 284 |
+
|
| 285 |
+
# Step 3.3: rerun with timestep zero to update KV cache using clean context
|
| 286 |
+
context_timestep = torch.ones_like(timestep) * self.args.context_noise
|
| 287 |
+
# This is the single write the linear/hybrid cache gets for this
|
| 288 |
+
# block -- restore the default ``advance_state=True`` before the
|
| 289 |
+
# forward so the clean K/V actually lands in the cache.
|
| 290 |
+
self._set_advance_state(True)
|
| 291 |
+
self.generator(
|
| 292 |
+
noisy_image_or_video=denoised_pred,
|
| 293 |
+
conditional_dict=conditional_dict,
|
| 294 |
+
timestep=context_timestep,
|
| 295 |
+
kv_cache=self.kv_cache1,
|
| 296 |
+
crossattn_cache=self.crossattn_cache,
|
| 297 |
+
current_start=current_start_frame * self.frame_seq_length,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
if profile:
|
| 301 |
+
block_end.record()
|
| 302 |
+
torch.cuda.synchronize()
|
| 303 |
+
block_time = block_start.elapsed_time(block_end)
|
| 304 |
+
block_times.append(block_time)
|
| 305 |
+
|
| 306 |
+
# Step 3.4: update the start and end frame indices
|
| 307 |
+
current_start_frame += current_num_frames
|
| 308 |
+
|
| 309 |
+
if profile:
|
| 310 |
+
# End diffusion timing and synchronize CUDA
|
| 311 |
+
diffusion_end.record()
|
| 312 |
+
torch.cuda.synchronize()
|
| 313 |
+
diffusion_time = diffusion_start.elapsed_time(diffusion_end)
|
| 314 |
+
init_time = init_start.elapsed_time(init_end)
|
| 315 |
+
vae_start.record()
|
| 316 |
+
|
| 317 |
+
# Step 4: Decode the output (skippable for latent-only generation, e.g.
|
| 318 |
+
# ARL² Stage-2 teacher-video latent synthesis where pixels are not needed)
|
| 319 |
+
if not decode:
|
| 320 |
+
return (None, output) if return_latents else output
|
| 321 |
+
video = self.vae.decode_to_pixel(output, use_cache=False)
|
| 322 |
+
video = (video * 0.5 + 0.5).clamp(0, 1)
|
| 323 |
+
|
| 324 |
+
if profile:
|
| 325 |
+
# End VAE timing and synchronize CUDA
|
| 326 |
+
vae_end.record()
|
| 327 |
+
torch.cuda.synchronize()
|
| 328 |
+
vae_time = vae_start.elapsed_time(vae_end)
|
| 329 |
+
total_time = init_time + diffusion_time + vae_time
|
| 330 |
+
|
| 331 |
+
print("Profiling results:")
|
| 332 |
+
print(f" - Initialization/caching time: {init_time:.2f} ms ({100 * init_time / total_time:.2f}%)")
|
| 333 |
+
print(f" - Diffusion generation time: {diffusion_time:.2f} ms ({100 * diffusion_time / total_time:.2f}%)")
|
| 334 |
+
for i, block_time in enumerate(block_times):
|
| 335 |
+
print(f" - Block {i} generation time: {block_time:.2f} ms ({100 * block_time / diffusion_time:.2f}% of diffusion)")
|
| 336 |
+
print(f" - VAE decoding time: {vae_time:.2f} ms ({100 * vae_time / total_time:.2f}%)")
|
| 337 |
+
print(f" - Total time: {total_time:.2f} ms")
|
| 338 |
+
|
| 339 |
+
if return_latents:
|
| 340 |
+
return video, output
|
| 341 |
+
else:
|
| 342 |
+
return video
|
| 343 |
+
|
| 344 |
+
def _initialize_kv_cache(self, batch_size, dtype, device):
|
| 345 |
+
"""
|
| 346 |
+
Initialize a Per-GPU KV cache for the Wan model.
|
| 347 |
+
|
| 348 |
+
Per-layer cache shape depends on that layer's attention type:
|
| 349 |
+
|
| 350 |
+
* ``softmax`` layers carry a sliding-window K/V tensor
|
| 351 |
+
``[B, kv_cache_size, H, Dv]`` in the activation dtype (bf16).
|
| 352 |
+
The cache size matches the all-softmax baseline.
|
| 353 |
+
* ``linear`` / ``hybrid`` layers carry a recurrent state
|
| 354 |
+
``[B, H, M, Dv]`` plus denominator ``[B, H, M]`` in fp32 for
|
| 355 |
+
numerical stability over the long causal rollout.
|
| 356 |
+
|
| 357 |
+
For a MIXED model (some softmax, some linear/hybrid) we walk the
|
| 358 |
+
``per_layer_self_attn_types`` list and pick the right shape per
|
| 359 |
+
layer. Pure-linear / pure-hybrid models use the same code path
|
| 360 |
+
with every layer set to ``"linear_or_hybrid"``.
|
| 361 |
+
"""
|
| 362 |
+
kv_cache1 = []
|
| 363 |
+
model = self.generator.model
|
| 364 |
+
num_heads = getattr(model, "num_heads", 12)
|
| 365 |
+
head_dim = model.dim // num_heads
|
| 366 |
+
|
| 367 |
+
# ── ARL2 path: softmax CausalWanModel with some blocks swapped to
|
| 368 |
+
# HybridBlockAttention (GDN). GDN layers use a lazily-allocated
|
| 369 |
+
# gdn_state (+ update_state flag); other layers keep K/V.
|
| 370 |
+
def is_gdn_block(block):
|
| 371 |
+
cls = type(block.self_attn)
|
| 372 |
+
return (
|
| 373 |
+
cls.__name__ == "HybridBlockAttention"
|
| 374 |
+
and cls.__module__ == "wan.modules.hybrid_attention"
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
if any(is_gdn_block(b) for b in model.blocks):
|
| 378 |
+
sm_size = (self.local_attn_size * self.frame_seq_length
|
| 379 |
+
if self.local_attn_size != -1 else 32760)
|
| 380 |
+
for b in model.blocks:
|
| 381 |
+
entry = {
|
| 382 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 383 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 384 |
+
}
|
| 385 |
+
if is_gdn_block(b):
|
| 386 |
+
entry["gdn_state"] = None
|
| 387 |
+
entry["update_state"] = True
|
| 388 |
+
else:
|
| 389 |
+
entry["k"] = torch.zeros([batch_size, sm_size, num_heads, head_dim], dtype=dtype, device=device)
|
| 390 |
+
entry["v"] = torch.zeros([batch_size, sm_size, num_heads, head_dim], dtype=dtype, device=device)
|
| 391 |
+
kv_cache1.append(entry)
|
| 392 |
+
self.kv_cache1 = kv_cache1
|
| 393 |
+
return
|
| 394 |
+
|
| 395 |
+
if self.attention_type in ("linear", "hybrid"):
|
| 396 |
+
# Default: every layer is linear/hybrid. A mixed model exposes
|
| 397 |
+
# an explicit per-layer types list that overrides this.
|
| 398 |
+
per_layer_types = getattr(
|
| 399 |
+
model, "per_layer_self_attn_types",
|
| 400 |
+
["linear_or_hybrid"] * self.num_transformer_blocks,
|
| 401 |
+
)
|
| 402 |
+
# Softmax-layer KV cache size mirrors the all-softmax baseline.
|
| 403 |
+
softmax_cache_size = (
|
| 404 |
+
self.local_attn_size * self.frame_seq_length
|
| 405 |
+
if self.local_attn_size != -1
|
| 406 |
+
else 32760
|
| 407 |
+
)
|
| 408 |
+
for layer_idx in range(self.num_transformer_blocks):
|
| 409 |
+
ltype = per_layer_types[layer_idx] if layer_idx < len(per_layer_types) else "linear_or_hybrid"
|
| 410 |
+
if ltype == "softmax":
|
| 411 |
+
kv_cache1.append({
|
| 412 |
+
"k": torch.zeros(
|
| 413 |
+
[batch_size, softmax_cache_size, num_heads, head_dim],
|
| 414 |
+
dtype=dtype, device=device,
|
| 415 |
+
),
|
| 416 |
+
"v": torch.zeros(
|
| 417 |
+
[batch_size, softmax_cache_size, num_heads, head_dim],
|
| 418 |
+
dtype=dtype, device=device,
|
| 419 |
+
),
|
| 420 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 421 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 422 |
+
})
|
| 423 |
+
else:
|
| 424 |
+
# linear / hybrid recurrent state. Kernel dim defaults
|
| 425 |
+
# to head_dim but may be overridden via num_kernel_features.
|
| 426 |
+
kernel_dim = head_dim
|
| 427 |
+
try:
|
| 428 |
+
first_block = model.blocks[layer_idx]
|
| 429 |
+
kernel_dim = first_block.self_attn.kernel_dim
|
| 430 |
+
except (AttributeError, IndexError):
|
| 431 |
+
pass
|
| 432 |
+
kv_cache1.append({
|
| 433 |
+
"state": torch.zeros(
|
| 434 |
+
[batch_size, num_heads, kernel_dim, head_dim],
|
| 435 |
+
dtype=torch.float32, device=device,
|
| 436 |
+
),
|
| 437 |
+
"normalizer": torch.zeros(
|
| 438 |
+
[batch_size, num_heads, kernel_dim],
|
| 439 |
+
dtype=torch.float32, device=device,
|
| 440 |
+
),
|
| 441 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 442 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 443 |
+
})
|
| 444 |
+
else:
|
| 445 |
+
# All-softmax model (CausalWanModel path; no mixing).
|
| 446 |
+
if self.local_attn_size != -1:
|
| 447 |
+
kv_cache_size = self.local_attn_size * self.frame_seq_length
|
| 448 |
+
else:
|
| 449 |
+
kv_cache_size = 32760
|
| 450 |
+
for _ in range(self.num_transformer_blocks):
|
| 451 |
+
kv_cache1.append({
|
| 452 |
+
"k": torch.zeros([batch_size, kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
|
| 453 |
+
"v": torch.zeros([batch_size, kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
|
| 454 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 455 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device)
|
| 456 |
+
})
|
| 457 |
+
|
| 458 |
+
self.kv_cache1 = kv_cache1 # always store the clean cache
|
| 459 |
+
|
| 460 |
+
def _initialize_crossattn_cache(self, batch_size, dtype, device):
|
| 461 |
+
"""
|
| 462 |
+
Initialize a Per-GPU cross-attention cache for the Wan model.
|
| 463 |
+
"""
|
| 464 |
+
crossattn_cache = []
|
| 465 |
+
|
| 466 |
+
for _ in range(self.num_transformer_blocks):
|
| 467 |
+
crossattn_cache.append({
|
| 468 |
+
"k": torch.zeros([batch_size, 512, 12, 128], dtype=dtype, device=device),
|
| 469 |
+
"v": torch.zeros([batch_size, 512, 12, 128], dtype=dtype, device=device),
|
| 470 |
+
"is_init": False
|
| 471 |
+
})
|
| 472 |
+
self.crossattn_cache = crossattn_cache
|
1.3B/code/pipeline/self_forcing_training.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Autoregressive training rollouts with a sampled denoising exit step.
|
| 2 |
+
|
| 3 |
+
Each block conditions on earlier generated blocks. Training stops at a sampled
|
| 4 |
+
exit and differentiates that denoising call when gradients are enabled. Released
|
| 5 |
+
four-step inference runs the complete schedule in ``causal_inference.py``.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from utils.wan_wrapper import WanDiffusionWrapper
|
| 9 |
+
from utils.scheduler import SchedulerInterface
|
| 10 |
+
from typing import List, Optional
|
| 11 |
+
import torch
|
| 12 |
+
import torch.distributed as dist
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SelfForcingTrainingPipeline:
|
| 16 |
+
"""Generate on-policy training videos and reproduce their rollout trajectory."""
|
| 17 |
+
|
| 18 |
+
def __init__(self,
|
| 19 |
+
denoising_step_list: List[int],
|
| 20 |
+
scheduler: SchedulerInterface,
|
| 21 |
+
generator: WanDiffusionWrapper,
|
| 22 |
+
num_frame_per_block=3,
|
| 23 |
+
independent_first_frame: bool = False,
|
| 24 |
+
same_step_across_blocks: bool = False,
|
| 25 |
+
last_step_only: bool = False,
|
| 26 |
+
num_max_frames: int = 21,
|
| 27 |
+
context_noise: int = 0,
|
| 28 |
+
**kwargs):
|
| 29 |
+
super().__init__()
|
| 30 |
+
self.scheduler = scheduler
|
| 31 |
+
self.generator = generator
|
| 32 |
+
self.denoising_step_list = denoising_step_list
|
| 33 |
+
if self.denoising_step_list[-1] == 0:
|
| 34 |
+
self.denoising_step_list = self.denoising_step_list[:-1] # remove the zero timestep for inference
|
| 35 |
+
|
| 36 |
+
# Wan specific hyperparameters
|
| 37 |
+
self.num_transformer_blocks = 30
|
| 38 |
+
self.frame_seq_length = 1560
|
| 39 |
+
self.num_frame_per_block = num_frame_per_block
|
| 40 |
+
self.context_noise = context_noise
|
| 41 |
+
self.i2v = False
|
| 42 |
+
|
| 43 |
+
self.kv_cache1 = None
|
| 44 |
+
self.kv_cache2 = None
|
| 45 |
+
self.independent_first_frame = independent_first_frame
|
| 46 |
+
self.same_step_across_blocks = same_step_across_blocks
|
| 47 |
+
self.last_step_only = last_step_only
|
| 48 |
+
self.kv_cache_size = num_max_frames * self.frame_seq_length
|
| 49 |
+
|
| 50 |
+
def _set_advance_state(self, value: bool) -> None:
|
| 51 |
+
"""Toggle the ``advance_state`` flag on every layer's kv-cache dict.
|
| 52 |
+
|
| 53 |
+
Only the linear / hybrid attention recurrent caches read this flag
|
| 54 |
+
(their kv-cache dicts have ``"state"`` and ``"normalizer"`` fields);
|
| 55 |
+
the softmax cache uses overwrite-style writes, so this is a no-op
|
| 56 |
+
for those layers. See ``wan/modules/linear_attention.py`` for the
|
| 57 |
+
rationale (avoid per-pass over-accumulation across the denoising
|
| 58 |
+
loop's multiple forwards on the same block).
|
| 59 |
+
"""
|
| 60 |
+
if self.kv_cache1 is None:
|
| 61 |
+
return
|
| 62 |
+
for cache in self.kv_cache1:
|
| 63 |
+
if "state" in cache:
|
| 64 |
+
cache["advance_state"] = value
|
| 65 |
+
# ARL2 HybridBlockAttention layers use a GDN recurrent state and
|
| 66 |
+
# read ``update_state`` instead of ``advance_state`` (clean-pass
|
| 67 |
+
# update: skip writes during denoising, write on the clean rerun).
|
| 68 |
+
if "gdn_state" in cache:
|
| 69 |
+
cache["update_state"] = value
|
| 70 |
+
|
| 71 |
+
def generate_and_sync_list(self, num_blocks, num_denoising_steps, device):
|
| 72 |
+
"""Choose denoising exit indices on rank zero and share them across ranks."""
|
| 73 |
+
rank = dist.get_rank() if dist.is_initialized() else 0
|
| 74 |
+
|
| 75 |
+
if rank == 0:
|
| 76 |
+
# Sampling an exit bounds the training graph to one denoising call
|
| 77 |
+
# per block; last_step_only optionally fixes it to the final call.
|
| 78 |
+
indices = torch.randint(
|
| 79 |
+
low=0,
|
| 80 |
+
high=num_denoising_steps,
|
| 81 |
+
size=(num_blocks,),
|
| 82 |
+
device=device
|
| 83 |
+
)
|
| 84 |
+
if self.last_step_only:
|
| 85 |
+
indices = torch.ones_like(indices) * (num_denoising_steps - 1)
|
| 86 |
+
else:
|
| 87 |
+
indices = torch.empty(num_blocks, dtype=torch.long, device=device)
|
| 88 |
+
|
| 89 |
+
dist.broadcast(indices, src=0) # Broadcast the random indices to all ranks
|
| 90 |
+
return indices.tolist()
|
| 91 |
+
|
| 92 |
+
def inference_with_trajectory(
|
| 93 |
+
self,
|
| 94 |
+
noise: torch.Tensor,
|
| 95 |
+
initial_latent: Optional[torch.Tensor] = None,
|
| 96 |
+
return_sim_step: bool = False,
|
| 97 |
+
**conditional_dict
|
| 98 |
+
) -> torch.Tensor:
|
| 99 |
+
"""Return a training rollout plus its selected denoising interval.
|
| 100 |
+
|
| 101 |
+
During feature collection the caller disables gradients. During replay,
|
| 102 |
+
the same random choices reconstruct the selected calls with gradients.
|
| 103 |
+
"""
|
| 104 |
+
batch_size, num_frames, num_channels, height, width = noise.shape
|
| 105 |
+
if not self.independent_first_frame or (self.independent_first_frame and initial_latent is not None):
|
| 106 |
+
# If the first frame is independent and the first frame is provided, then the number of frames in the
|
| 107 |
+
# noise should still be a multiple of num_frame_per_block
|
| 108 |
+
assert num_frames % self.num_frame_per_block == 0
|
| 109 |
+
num_blocks = num_frames // self.num_frame_per_block
|
| 110 |
+
else:
|
| 111 |
+
# Using a [1, 4, 4, 4, 4, 4, ...] model to generate a video without image conditioning
|
| 112 |
+
assert (num_frames - 1) % self.num_frame_per_block == 0
|
| 113 |
+
num_blocks = (num_frames - 1) // self.num_frame_per_block
|
| 114 |
+
num_input_frames = initial_latent.shape[1] if initial_latent is not None else 0
|
| 115 |
+
num_output_frames = num_frames + num_input_frames # add the initial latent frames
|
| 116 |
+
output = torch.zeros(
|
| 117 |
+
[batch_size, num_output_frames, num_channels, height, width],
|
| 118 |
+
device=noise.device,
|
| 119 |
+
dtype=noise.dtype
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Step 1: Initialize KV cache to all zeros
|
| 123 |
+
self._initialize_kv_cache(
|
| 124 |
+
batch_size=batch_size, dtype=noise.dtype, device=noise.device
|
| 125 |
+
)
|
| 126 |
+
self._initialize_crossattn_cache(
|
| 127 |
+
batch_size=batch_size, dtype=noise.dtype, device=noise.device
|
| 128 |
+
)
|
| 129 |
+
# Step 2: Cache context feature
|
| 130 |
+
current_start_frame = 0
|
| 131 |
+
if initial_latent is not None:
|
| 132 |
+
timestep = torch.ones([batch_size, 1], device=noise.device, dtype=torch.int64) * 0
|
| 133 |
+
# Assume num_input_frames is 1 + self.num_frame_per_block * num_input_blocks
|
| 134 |
+
output[:, :1] = initial_latent
|
| 135 |
+
# Initial-latent context forward is a legit "clean K/V" write -> advance cache.
|
| 136 |
+
self._set_advance_state(True)
|
| 137 |
+
with torch.no_grad():
|
| 138 |
+
self.generator(
|
| 139 |
+
noisy_image_or_video=initial_latent,
|
| 140 |
+
conditional_dict=conditional_dict,
|
| 141 |
+
timestep=timestep * 0,
|
| 142 |
+
kv_cache=self.kv_cache1,
|
| 143 |
+
crossattn_cache=self.crossattn_cache,
|
| 144 |
+
current_start=current_start_frame * self.frame_seq_length
|
| 145 |
+
)
|
| 146 |
+
current_start_frame += 1
|
| 147 |
+
|
| 148 |
+
# Step 3: Temporal denoising loop
|
| 149 |
+
all_num_frames = [self.num_frame_per_block] * num_blocks
|
| 150 |
+
if self.independent_first_frame and initial_latent is None:
|
| 151 |
+
all_num_frames = [1] + all_num_frames
|
| 152 |
+
num_denoising_steps = len(self.denoising_step_list)
|
| 153 |
+
exit_flags = self.generate_and_sync_list(len(all_num_frames), num_denoising_steps, device=noise.device)
|
| 154 |
+
# Retain generator gradients only for the final 21 latent frames.
|
| 155 |
+
# Earlier context still affects the rollout through cached values.
|
| 156 |
+
start_gradient_frame_index = num_output_frames - 21
|
| 157 |
+
|
| 158 |
+
for block_index, current_num_frames in enumerate(all_num_frames):
|
| 159 |
+
noisy_input = noise[
|
| 160 |
+
:, current_start_frame - num_input_frames:current_start_frame + current_num_frames - num_input_frames]
|
| 161 |
+
|
| 162 |
+
# All denoising-loop forwards on this block read the same cross-block
|
| 163 |
+
# cache and do NOT advance it; only the clean re-run (Step 3.3)
|
| 164 |
+
# advances the cache. No-op for the softmax attention path.
|
| 165 |
+
self._set_advance_state(False)
|
| 166 |
+
|
| 167 |
+
# Step 3.1: Spatial denoising loop
|
| 168 |
+
for index, current_timestep in enumerate(self.denoising_step_list):
|
| 169 |
+
if self.same_step_across_blocks:
|
| 170 |
+
exit_flag = (index == exit_flags[0])
|
| 171 |
+
else:
|
| 172 |
+
exit_flag = (index == exit_flags[block_index]) # Only backprop at the randomly selected timestep (consistent across all ranks)
|
| 173 |
+
timestep = torch.ones(
|
| 174 |
+
[batch_size, current_num_frames],
|
| 175 |
+
device=noise.device,
|
| 176 |
+
dtype=torch.int64) * current_timestep
|
| 177 |
+
|
| 178 |
+
if not exit_flag:
|
| 179 |
+
# Earlier denoising calls construct the selected step's
|
| 180 |
+
# input; their graphs are deliberately not retained.
|
| 181 |
+
with torch.no_grad():
|
| 182 |
+
_, denoised_pred = self.generator(
|
| 183 |
+
noisy_image_or_video=noisy_input,
|
| 184 |
+
conditional_dict=conditional_dict,
|
| 185 |
+
timestep=timestep,
|
| 186 |
+
kv_cache=self.kv_cache1,
|
| 187 |
+
crossattn_cache=self.crossattn_cache,
|
| 188 |
+
current_start=current_start_frame * self.frame_seq_length
|
| 189 |
+
)
|
| 190 |
+
next_timestep = self.denoising_step_list[index + 1]
|
| 191 |
+
noisy_input = self.scheduler.add_noise(
|
| 192 |
+
denoised_pred.flatten(0, 1),
|
| 193 |
+
torch.randn_like(denoised_pred.flatten(0, 1)),
|
| 194 |
+
next_timestep * torch.ones(
|
| 195 |
+
[batch_size * current_num_frames], device=noise.device, dtype=torch.long)
|
| 196 |
+
).unflatten(0, denoised_pred.shape[:2])
|
| 197 |
+
else:
|
| 198 |
+
# The selected exit produces the block's clean prediction.
|
| 199 |
+
# Replay differentiates this call within the gradient window.
|
| 200 |
+
if current_start_frame < start_gradient_frame_index:
|
| 201 |
+
with torch.no_grad():
|
| 202 |
+
_, denoised_pred = self.generator(
|
| 203 |
+
noisy_image_or_video=noisy_input,
|
| 204 |
+
conditional_dict=conditional_dict,
|
| 205 |
+
timestep=timestep,
|
| 206 |
+
kv_cache=self.kv_cache1,
|
| 207 |
+
crossattn_cache=self.crossattn_cache,
|
| 208 |
+
current_start=current_start_frame * self.frame_seq_length
|
| 209 |
+
)
|
| 210 |
+
else:
|
| 211 |
+
_, denoised_pred = self.generator(
|
| 212 |
+
noisy_image_or_video=noisy_input,
|
| 213 |
+
conditional_dict=conditional_dict,
|
| 214 |
+
timestep=timestep,
|
| 215 |
+
kv_cache=self.kv_cache1,
|
| 216 |
+
crossattn_cache=self.crossattn_cache,
|
| 217 |
+
current_start=current_start_frame * self.frame_seq_length
|
| 218 |
+
)
|
| 219 |
+
break
|
| 220 |
+
|
| 221 |
+
# Step 3.2: record the model's output
|
| 222 |
+
output[:, current_start_frame:current_start_frame + current_num_frames] = denoised_pred
|
| 223 |
+
|
| 224 |
+
# Step 3.3: publish this generated block as context for the next.
|
| 225 |
+
# This cache write has no graph, preserving the training truncation.
|
| 226 |
+
context_timestep = torch.ones_like(timestep) * self.context_noise
|
| 227 |
+
# add context noise
|
| 228 |
+
denoised_pred = self.scheduler.add_noise(
|
| 229 |
+
denoised_pred.flatten(0, 1),
|
| 230 |
+
torch.randn_like(denoised_pred.flatten(0, 1)),
|
| 231 |
+
context_timestep * torch.ones(
|
| 232 |
+
[batch_size * current_num_frames], device=noise.device, dtype=torch.long)
|
| 233 |
+
).unflatten(0, denoised_pred.shape[:2])
|
| 234 |
+
# This is the single write the linear/hybrid cache gets for this
|
| 235 |
+
# block -- restore the default ``advance_state=True`` before the
|
| 236 |
+
# forward so the clean K/V actually lands in the cache.
|
| 237 |
+
self._set_advance_state(True)
|
| 238 |
+
with torch.no_grad():
|
| 239 |
+
self.generator(
|
| 240 |
+
noisy_image_or_video=denoised_pred,
|
| 241 |
+
conditional_dict=conditional_dict,
|
| 242 |
+
timestep=context_timestep,
|
| 243 |
+
kv_cache=self.kv_cache1,
|
| 244 |
+
crossattn_cache=self.crossattn_cache,
|
| 245 |
+
current_start=current_start_frame * self.frame_seq_length
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# Step 3.4: update the start and end frame indices
|
| 249 |
+
current_start_frame += current_num_frames
|
| 250 |
+
|
| 251 |
+
# Step 3.5: Return the denoised timestep
|
| 252 |
+
if not self.same_step_across_blocks:
|
| 253 |
+
denoised_timestep_from, denoised_timestep_to = None, None
|
| 254 |
+
elif exit_flags[0] == len(self.denoising_step_list) - 1:
|
| 255 |
+
denoised_timestep_to = 0
|
| 256 |
+
denoised_timestep_from = 1000 - torch.argmin(
|
| 257 |
+
(self.scheduler.timesteps.cuda() - self.denoising_step_list[exit_flags[0]].cuda()).abs(), dim=0).item()
|
| 258 |
+
else:
|
| 259 |
+
denoised_timestep_to = 1000 - torch.argmin(
|
| 260 |
+
(self.scheduler.timesteps.cuda() - self.denoising_step_list[exit_flags[0] + 1].cuda()).abs(), dim=0).item()
|
| 261 |
+
denoised_timestep_from = 1000 - torch.argmin(
|
| 262 |
+
(self.scheduler.timesteps.cuda() - self.denoising_step_list[exit_flags[0]].cuda()).abs(), dim=0).item()
|
| 263 |
+
|
| 264 |
+
if return_sim_step:
|
| 265 |
+
return output, denoised_timestep_from, denoised_timestep_to, exit_flags[0] + 1
|
| 266 |
+
|
| 267 |
+
return output, denoised_timestep_from, denoised_timestep_to
|
| 268 |
+
|
| 269 |
+
def _initialize_kv_cache(self, batch_size, dtype, device):
|
| 270 |
+
"""
|
| 271 |
+
Initialize a Per-GPU KV cache for the Wan model.
|
| 272 |
+
|
| 273 |
+
Per-layer cache shape depends on that layer's attention type
|
| 274 |
+
(mirrors :meth:`pipeline.causal_inference.CausalInferencePipeline._initialize_kv_cache`):
|
| 275 |
+
|
| 276 |
+
* ``softmax`` layers -> ``k`` / ``v`` in bf16, shape
|
| 277 |
+
``[B, cache_size, H, Dv]``.
|
| 278 |
+
* ``linear`` / ``hybrid`` -> ``state`` / ``normalizer`` in fp32,
|
| 279 |
+
shape ``[B, H, M, Dv]`` / ``[B, H, M]``.
|
| 280 |
+
|
| 281 |
+
For a MIXED model (some softmax + some linear/hybrid layers) we
|
| 282 |
+
walk the model's ``per_layer_self_attn_types`` list and allocate
|
| 283 |
+
the matching shape per layer. Pure-linear / pure-hybrid /
|
| 284 |
+
pure-softmax models still work because the type list is uniform
|
| 285 |
+
in those cases.
|
| 286 |
+
|
| 287 |
+
Crashing on the wrong key (the previous "always-linear" allocator
|
| 288 |
+
handed a softmax layer a dict without ``"k"``, raising
|
| 289 |
+
``KeyError: 'k'`` deep inside the attention forward) was exactly
|
| 290 |
+
the failure mode this fix addresses.
|
| 291 |
+
"""
|
| 292 |
+
attention_type = getattr(self.generator, "attention_type", "softmax")
|
| 293 |
+
model = self.generator.model
|
| 294 |
+
num_heads = getattr(model, "num_heads", 12)
|
| 295 |
+
head_dim = model.dim // num_heads
|
| 296 |
+
kv_cache1 = []
|
| 297 |
+
|
| 298 |
+
# ── ARL2 path: a softmax CausalWanModel with some blocks' self_attn
|
| 299 |
+
# replaced by HybridBlockAttention. Those blocks use a lazily-
|
| 300 |
+
# allocated GDN state (gdn_state/update_state); the rest keep K/V.
|
| 301 |
+
def is_gdn_block(block):
|
| 302 |
+
cls = type(block.self_attn)
|
| 303 |
+
return (
|
| 304 |
+
cls.__name__ == "HybridBlockAttention"
|
| 305 |
+
and cls.__module__ == "wan.modules.hybrid_attention"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
if any(is_gdn_block(b) for b in model.blocks):
|
| 309 |
+
for b in model.blocks:
|
| 310 |
+
entry = {
|
| 311 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 312 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 313 |
+
}
|
| 314 |
+
if is_gdn_block(b):
|
| 315 |
+
entry["gdn_state"] = None # lazily allocated H x D x D
|
| 316 |
+
entry["update_state"] = True
|
| 317 |
+
else:
|
| 318 |
+
entry["k"] = torch.zeros(
|
| 319 |
+
[batch_size, self.kv_cache_size, num_heads, head_dim], dtype=dtype, device=device)
|
| 320 |
+
entry["v"] = torch.zeros(
|
| 321 |
+
[batch_size, self.kv_cache_size, num_heads, head_dim], dtype=dtype, device=device)
|
| 322 |
+
kv_cache1.append(entry)
|
| 323 |
+
self.kv_cache1 = kv_cache1
|
| 324 |
+
return
|
| 325 |
+
|
| 326 |
+
if attention_type in ("linear", "hybrid"):
|
| 327 |
+
# Default to "every layer is linear/hybrid"; mixed models
|
| 328 |
+
# override via ``per_layer_self_attn_types``.
|
| 329 |
+
per_layer_types = getattr(
|
| 330 |
+
model, "per_layer_self_attn_types",
|
| 331 |
+
["linear_or_hybrid"] * self.num_transformer_blocks,
|
| 332 |
+
)
|
| 333 |
+
# Softmax-layer cache size. Use the model's
|
| 334 |
+
# ``softmax_layer_local_attn_size`` knob so a windowed config
|
| 335 |
+
# only allocates as much storage as it actually uses.
|
| 336 |
+
sm_local = int(getattr(model, "softmax_layer_local_attn_size", -1))
|
| 337 |
+
if sm_local != -1:
|
| 338 |
+
softmax_cache_size = sm_local * self.frame_seq_length
|
| 339 |
+
else:
|
| 340 |
+
softmax_cache_size = self.kv_cache_size
|
| 341 |
+
for layer_idx in range(self.num_transformer_blocks):
|
| 342 |
+
ltype = (
|
| 343 |
+
per_layer_types[layer_idx]
|
| 344 |
+
if layer_idx < len(per_layer_types)
|
| 345 |
+
else "linear_or_hybrid"
|
| 346 |
+
)
|
| 347 |
+
if ltype == "softmax":
|
| 348 |
+
kv_cache1.append({
|
| 349 |
+
"k": torch.zeros(
|
| 350 |
+
[batch_size, softmax_cache_size, num_heads, head_dim],
|
| 351 |
+
dtype=dtype, device=device,
|
| 352 |
+
),
|
| 353 |
+
"v": torch.zeros(
|
| 354 |
+
[batch_size, softmax_cache_size, num_heads, head_dim],
|
| 355 |
+
dtype=dtype, device=device,
|
| 356 |
+
),
|
| 357 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 358 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 359 |
+
})
|
| 360 |
+
else:
|
| 361 |
+
# Linear/hybrid recurrent state. ``kernel_dim`` may
|
| 362 |
+
# be overridden via ``num_kernel_features``; default
|
| 363 |
+
# is ``head_dim`` (= 128 for Wan-1.3B).
|
| 364 |
+
kernel_dim = head_dim
|
| 365 |
+
try:
|
| 366 |
+
first_linhyb_block = model.blocks[layer_idx]
|
| 367 |
+
kernel_dim = first_linhyb_block.self_attn.kernel_dim
|
| 368 |
+
except (AttributeError, IndexError):
|
| 369 |
+
pass
|
| 370 |
+
kv_cache1.append({
|
| 371 |
+
"state": torch.zeros(
|
| 372 |
+
[batch_size, num_heads, kernel_dim, head_dim],
|
| 373 |
+
dtype=torch.float32, device=device,
|
| 374 |
+
),
|
| 375 |
+
"normalizer": torch.zeros(
|
| 376 |
+
[batch_size, num_heads, kernel_dim],
|
| 377 |
+
dtype=torch.float32, device=device,
|
| 378 |
+
),
|
| 379 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 380 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 381 |
+
})
|
| 382 |
+
else:
|
| 383 |
+
# All-softmax (Wan baseline) path.
|
| 384 |
+
for _ in range(self.num_transformer_blocks):
|
| 385 |
+
kv_cache1.append({
|
| 386 |
+
"k": torch.zeros([batch_size, self.kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
|
| 387 |
+
"v": torch.zeros([batch_size, self.kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
|
| 388 |
+
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
|
| 389 |
+
"local_end_index": torch.tensor([0], dtype=torch.long, device=device)
|
| 390 |
+
})
|
| 391 |
+
|
| 392 |
+
self.kv_cache1 = kv_cache1 # always store the clean cache
|
| 393 |
+
|
| 394 |
+
def _initialize_crossattn_cache(self, batch_size, dtype, device):
|
| 395 |
+
"""
|
| 396 |
+
Initialize a Per-GPU cross-attention cache for the Wan model.
|
| 397 |
+
"""
|
| 398 |
+
crossattn_cache = []
|
| 399 |
+
|
| 400 |
+
for _ in range(self.num_transformer_blocks):
|
| 401 |
+
crossattn_cache.append({
|
| 402 |
+
"k": torch.zeros([batch_size, 512, 12, 128], dtype=dtype, device=device),
|
| 403 |
+
"v": torch.zeros([batch_size, 512, 12, 128], dtype=dtype, device=device),
|
| 404 |
+
"is_init": False
|
| 405 |
+
})
|
| 406 |
+
self.crossattn_cache = crossattn_cache
|
1.3B/code/prompts/test_8.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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.
|
| 2 |
+
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.
|
| 3 |
+
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.
|
| 4 |
+
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.
|
| 5 |
+
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.
|
| 6 |
+
A gorgeously rendered papercraft world of a coral reef, rife with colorful fish and sea creatures.
|
| 7 |
+
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.
|
| 8 |
+
Photorealistic closeup video of two pirate ships battling each other as they sail inside a cup of coffee.
|
1.3B/code/tests/__init__.py
ADDED
|
File without changes
|
1.3B/code/tests/test_multi_representation_video_nystrom_mmd.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
from losses.multi_representation_video_nystrom_mmd import (
|
| 4 |
+
MultiRepresentationVideoNystromMMD,
|
| 5 |
+
fd_normalize_raw_mmd,
|
| 6 |
+
)
|
| 7 |
+
from losses.video_nystrom_mmd import (
|
| 8 |
+
VideoNystromMMD,
|
| 9 |
+
build_video_nystrom_reference,
|
| 10 |
+
)
|
| 11 |
+
from trainer.multi_representation_fresh_video_gradcache import (
|
| 12 |
+
MultiRepresentationFreshVideoGradCache,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _objective(name: str, shape: tuple[int, int], seed: int):
|
| 17 |
+
generator = torch.Generator().manual_seed(seed)
|
| 18 |
+
reference = torch.randn(12, *shape, generator=generator)
|
| 19 |
+
pack = build_video_nystrom_reference(
|
| 20 |
+
reference,
|
| 21 |
+
num_landmarks=5,
|
| 22 |
+
metadata={
|
| 23 |
+
"feature": {"name": name, "matrix_shape": list(shape)},
|
| 24 |
+
"reference": {"dataset": "triple-unit-test"},
|
| 25 |
+
},
|
| 26 |
+
bandwidth=1.7,
|
| 27 |
+
kmeans_iterations=3,
|
| 28 |
+
ridge=1.0e-5,
|
| 29 |
+
chunk_size=16,
|
| 30 |
+
)
|
| 31 |
+
return VideoNystromMMD(
|
| 32 |
+
pack,
|
| 33 |
+
kernel_chunk_size=64,
|
| 34 |
+
attraction_weight=2.0,
|
| 35 |
+
current_teacher_attraction_enabled=True,
|
| 36 |
+
current_teacher_attraction_weight=1.0,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _triple():
|
| 41 |
+
return MultiRepresentationVideoNystromMMD(
|
| 42 |
+
{
|
| 43 |
+
"dinov3": _objective("tiny-dinov3", (1, 3), 4199),
|
| 44 |
+
"vjepa2": _objective("tiny-vjepa2", (1, 2), 4200),
|
| 45 |
+
"videomae_v2": _objective("tiny-videomae", (2, 3), 4201),
|
| 46 |
+
},
|
| 47 |
+
weights={"dinov3": 15.0, "vjepa2": 1.0, "videomae_v2": 2.0},
|
| 48 |
+
normalization_eps=1.0e-4,
|
| 49 |
+
aggregation_divisor=10.0,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_three_raw_mmds_are_normalized_weighted_then_divided_by_ten():
|
| 54 |
+
objective = _triple()
|
| 55 |
+
generator = torch.Generator().manual_seed(4202)
|
| 56 |
+
reference = {
|
| 57 |
+
"dinov3": torch.randn(256, 1, 3, generator=generator),
|
| 58 |
+
"vjepa2": torch.randn(256, 1, 2, generator=generator),
|
| 59 |
+
"videomae_v2": torch.randn(256, 2, 3, generator=generator),
|
| 60 |
+
}
|
| 61 |
+
indices = torch.arange(128, 256)
|
| 62 |
+
gradient = {
|
| 63 |
+
name: value.index_select(0, indices).clone().requires_grad_(True)
|
| 64 |
+
for name, value in reference.items()
|
| 65 |
+
}
|
| 66 |
+
teacher = {
|
| 67 |
+
"dinov3": torch.randn(
|
| 68 |
+
128, 1, 3, generator=generator, requires_grad=True),
|
| 69 |
+
"vjepa2": torch.randn(
|
| 70 |
+
128, 1, 2, generator=generator, requires_grad=True),
|
| 71 |
+
"videomae_v2": torch.randn(
|
| 72 |
+
128, 2, 3, generator=generator, requires_grad=True),
|
| 73 |
+
}
|
| 74 |
+
loss, metadata = objective(
|
| 75 |
+
gradient_students=gradient,
|
| 76 |
+
student_reference=reference,
|
| 77 |
+
current_teacher=teacher,
|
| 78 |
+
gradient_student_indices=indices,
|
| 79 |
+
stage=False,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
expected_terms = []
|
| 83 |
+
for name, branch in objective.branches.items():
|
| 84 |
+
raw, branch_metadata = branch(
|
| 85 |
+
gradient_students=gradient[name],
|
| 86 |
+
student_reference=reference[name],
|
| 87 |
+
current_teacher=teacher[name],
|
| 88 |
+
gradient_student_indices=indices,
|
| 89 |
+
stage=False,
|
| 90 |
+
)
|
| 91 |
+
normalized = raw / (raw.detach() + 1.0e-4)
|
| 92 |
+
expected_terms.append(objective.branch_weights[name] * normalized)
|
| 93 |
+
observed = metadata["branches"][name]
|
| 94 |
+
torch.testing.assert_close(observed["raw_mmd"], raw.detach())
|
| 95 |
+
assert int(observed["repulsion_denominator"]) == 65536
|
| 96 |
+
assert int(observed["fixed_teacher_attraction_denominator"]) == 256
|
| 97 |
+
assert int(observed["current_teacher_attraction_denominator"]) == 32768
|
| 98 |
+
assert int(observed["current_teacher_self_kernel_denominator"]) == 16384
|
| 99 |
+
assert int(observed["full_student_partial_gradient"]) == 1
|
| 100 |
+
assert abs(float(observed[
|
| 101 |
+
"fixed_teacher_attraction_normalized_weight"]) - 2.0 / 3.0) < 1e-7
|
| 102 |
+
assert abs(float(observed[
|
| 103 |
+
"current_teacher_attraction_normalized_weight"]) - 1.0 / 3.0) < 1e-7
|
| 104 |
+
assert branch_metadata["repulsion_denominator"].item() == 65536
|
| 105 |
+
expected = torch.stack(expected_terms).sum() / 10.0
|
| 106 |
+
torch.testing.assert_close(loss, expected, rtol=2.0e-5, atol=2.0e-6)
|
| 107 |
+
# Aggregate exactly as (15 * DINOv3 + V-JEPA2 + 2 * VideoMAE-v2) / 10.
|
| 108 |
+
torch.testing.assert_close(
|
| 109 |
+
metadata["normalized_mmd_sum"], expected.detach(),
|
| 110 |
+
rtol=2.0e-5, atol=2.0e-6)
|
| 111 |
+
assert metadata["branches"]["dinov3"]["branch_weight"].item() == 15.0
|
| 112 |
+
assert metadata["branches"]["vjepa2"]["branch_weight"].item() == 1.0
|
| 113 |
+
assert metadata["branches"]["videomae_v2"]["branch_weight"].item() == 2.0
|
| 114 |
+
assert metadata["aggregation_divisor"].item() == 10.0
|
| 115 |
+
|
| 116 |
+
gradients = torch.autograd.grad(
|
| 117 |
+
loss,
|
| 118 |
+
tuple(gradient.values()) + tuple(teacher.values()),
|
| 119 |
+
allow_unused=True,
|
| 120 |
+
)
|
| 121 |
+
assert all(value is not None and value.norm() > 0 for value in gradients[:3])
|
| 122 |
+
assert all(value is None for value in gradients[3:])
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_dual_dinov3_weight_is_applied_after_fd_normalization():
|
| 126 |
+
objective = MultiRepresentationVideoNystromMMD(
|
| 127 |
+
{
|
| 128 |
+
"dinov3": _objective("tiny-dinov3-weighted", (1, 3), 4210),
|
| 129 |
+
"vjepa2": _objective("tiny-vjepa2-weighted", (1, 2), 4211),
|
| 130 |
+
},
|
| 131 |
+
weights={"dinov3": 10.0, "vjepa2": 1.0},
|
| 132 |
+
normalization_eps=1.0e-4,
|
| 133 |
+
)
|
| 134 |
+
generator = torch.Generator().manual_seed(4212)
|
| 135 |
+
generated = {
|
| 136 |
+
"dinov3": torch.randn(
|
| 137 |
+
8, 1, 3, generator=generator, requires_grad=True),
|
| 138 |
+
"vjepa2": torch.randn(
|
| 139 |
+
8, 1, 2, generator=generator, requires_grad=True),
|
| 140 |
+
}
|
| 141 |
+
teacher = {
|
| 142 |
+
"dinov3": torch.randn(8, 1, 3, generator=generator),
|
| 143 |
+
"vjepa2": torch.randn(8, 1, 2, generator=generator),
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
loss, metadata = objective(
|
| 147 |
+
generated,
|
| 148 |
+
current_teacher=teacher,
|
| 149 |
+
stage=False,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
dino = metadata["branches"]["dinov3"]
|
| 153 |
+
vjepa = metadata["branches"]["vjepa2"]
|
| 154 |
+
expected = 10.0 * dino["normalized_mmd"] + vjepa["normalized_mmd"]
|
| 155 |
+
torch.testing.assert_close(loss.detach(), expected)
|
| 156 |
+
torch.testing.assert_close(
|
| 157 |
+
dino["weighted_normalized_mmd"],
|
| 158 |
+
10.0 * dino["normalized_mmd"],
|
| 159 |
+
)
|
| 160 |
+
assert dino["branch_weight"].item() == 10.0
|
| 161 |
+
assert vjepa["branch_weight"].item() == 1.0
|
| 162 |
+
|
| 163 |
+
gradients = torch.autograd.grad(loss, tuple(generated.values()))
|
| 164 |
+
assert all(torch.isfinite(value).all() and value.norm() > 0
|
| 165 |
+
for value in gradients)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_mapping_gradcache_suffix_and_cotangent_compensation():
|
| 169 |
+
objective = _triple()
|
| 170 |
+
cache = MultiRepresentationFreshVideoGradCache(
|
| 171 |
+
objective,
|
| 172 |
+
target_local_samples=4,
|
| 173 |
+
gradient_local_samples=2,
|
| 174 |
+
full_student_partial_gradient=True,
|
| 175 |
+
require_sample_id_alignment=True,
|
| 176 |
+
)
|
| 177 |
+
generator = torch.Generator().manual_seed(4203)
|
| 178 |
+
batches = []
|
| 179 |
+
for index in range(4):
|
| 180 |
+
features = {
|
| 181 |
+
"dinov3": torch.randn(1, 1, 3, generator=generator),
|
| 182 |
+
"vjepa2": torch.randn(1, 1, 2, generator=generator),
|
| 183 |
+
"videomae_v2": torch.randn(1, 2, 3, generator=generator),
|
| 184 |
+
}
|
| 185 |
+
batches.append(features)
|
| 186 |
+
selected = index >= 2
|
| 187 |
+
teacher = ({
|
| 188 |
+
"dinov3": torch.randn(1, 1, 3, generator=generator),
|
| 189 |
+
"vjepa2": torch.randn(1, 1, 2, generator=generator),
|
| 190 |
+
"videomae_v2": torch.randn(1, 2, 3, generator=generator),
|
| 191 |
+
} if selected else None)
|
| 192 |
+
sample_id = (f"sample-{index}",)
|
| 193 |
+
cache.add(
|
| 194 |
+
features,
|
| 195 |
+
teacher,
|
| 196 |
+
gradient_selected=selected,
|
| 197 |
+
sample_ids=sample_id,
|
| 198 |
+
current_teacher_sample_ids=sample_id if selected else None,
|
| 199 |
+
)
|
| 200 |
+
result = cache.finalize()
|
| 201 |
+
assert result.replay_batch_indices == (2, 3)
|
| 202 |
+
assert len(result.fsdp_cotangents) == 2
|
| 203 |
+
assert set(result.fsdp_cotangents[0]) == {
|
| 204 |
+
"dinov3", "vjepa2", "videomae_v2"}
|
| 205 |
+
assert int(result.metadata["gradcache_kss_denominator"]) == 16
|
| 206 |
+
assert int(result.metadata["gradcache_gradient_global_samples"]) == 2
|
| 207 |
+
original_loss = result.loss.clone()
|
| 208 |
+
compensated = tuple({
|
| 209 |
+
name: value * 2.0 for name, value in cotangent.items()
|
| 210 |
+
} for cotangent in result.fsdp_cotangents)
|
| 211 |
+
for before, after in zip(result.fsdp_cotangents, compensated):
|
| 212 |
+
for name in before:
|
| 213 |
+
torch.testing.assert_close(after[name], before[name] * 2.0)
|
| 214 |
+
torch.testing.assert_close(result.loss, original_loss, rtol=0, atol=0)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_fd_normalization_preserves_exact_signed_and_zero_neighborhood_behavior():
|
| 218 |
+
eps = 1.0e-4
|
| 219 |
+
cases = (
|
| 220 |
+
(-2.0e-4, 2.0, -1.0e4),
|
| 221 |
+
(0.0, 0.0, 1.0e4),
|
| 222 |
+
(1.0e-8, 1.0e-8 / (eps + 1.0e-8), 1.0 / (eps + 1.0e-8)),
|
| 223 |
+
)
|
| 224 |
+
for value, expected_forward, expected_gradient in cases:
|
| 225 |
+
raw = torch.tensor(value, dtype=torch.float64, requires_grad=True)
|
| 226 |
+
normalized, denominator = fd_normalize_raw_mmd(raw, eps)
|
| 227 |
+
torch.testing.assert_close(
|
| 228 |
+
normalized.detach(), torch.tensor(expected_forward, dtype=torch.float64))
|
| 229 |
+
(gradient,) = torch.autograd.grad(normalized, raw)
|
| 230 |
+
torch.testing.assert_close(
|
| 231 |
+
gradient, torch.tensor(expected_gradient, dtype=torch.float64))
|
| 232 |
+
torch.testing.assert_close(
|
| 233 |
+
denominator, raw.detach() + eps, rtol=0, atol=0)
|
| 234 |
+
|
| 235 |
+
singular = torch.tensor(-eps, dtype=torch.float64, requires_grad=True)
|
| 236 |
+
normalized, denominator = fd_normalize_raw_mmd(singular, eps)
|
| 237 |
+
assert denominator.item() == 0.0
|
| 238 |
+
assert torch.isinf(normalized) and normalized.item() < 0.0
|
1.3B/code/tests/test_readable_config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exercise readable config inputs through the actual public entry points."""
|
| 2 |
+
import importlib
|
| 3 |
+
import importlib.util
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import shutil
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
from types import SimpleNamespace
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
import yaml
|
| 12 |
+
from utils.config import load_config, resolve_config_aliases
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 15 |
+
RECIPE = "three_encoder.yaml" if ROOT.name == "1.3B" else "train.yaml"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_readable_override_wins_over_legacy_defaults_without_losing_metadata(tmp_path):
|
| 19 |
+
defaults = tmp_path / "defaults.yaml"
|
| 20 |
+
defaults.write_text("total_batch_size: 8\nmax_steps: 99\nmetadata: {revision: frozen}\n")
|
| 21 |
+
path = tmp_path / "run.yaml"
|
| 22 |
+
path.write_text("distribution_batch_size: 256\ntraining_updates: 1\nreference_minibatch_size: 128\n")
|
| 23 |
+
cfg = load_config(path, defaults)
|
| 24 |
+
assert (cfg.total_batch_size, cfg.max_steps, cfg.expected_current_teacher_samples) == (256, 1, 128)
|
| 25 |
+
assert cfg.metadata.revision == "frozen"
|
| 26 |
+
# Existing runtime YAML and arbitrary metadata survive the boundary unchanged.
|
| 27 |
+
legacy = {"total_batch_size": 256, "metadata": {"shape": [1, 2, 3]}}
|
| 28 |
+
resolved = resolve_config_aliases(legacy)
|
| 29 |
+
resolved["metadata"]["shape"].append(4)
|
| 30 |
+
assert legacy["metadata"]["shape"] == [1, 2, 3]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@pytest.mark.parametrize("step_flag", ["--max_steps", "--training-updates"])
|
| 34 |
+
def test_training_entry_resolves_recipe_before_constructing_trainer(monkeypatch, tmp_path, step_flag):
|
| 35 |
+
captured = {}
|
| 36 |
+
class RecordingTrainer:
|
| 37 |
+
def __init__(self, config):
|
| 38 |
+
captured["config"] = config
|
| 39 |
+
def train(self):
|
| 40 |
+
captured["trained"] = True
|
| 41 |
+
|
| 42 |
+
monkeypatch.setitem(sys.modules, "trainer.distribution_matching", SimpleNamespace(DistributionMatchingTrainer=RecordingTrainer))
|
| 43 |
+
spec = importlib.util.spec_from_file_location("readable_training_entry", ROOT / "code/train.py")
|
| 44 |
+
entry = importlib.util.module_from_spec(spec)
|
| 45 |
+
spec.loader.exec_module(entry)
|
| 46 |
+
monkeypatch.setattr(entry.wandb, "finish", lambda: None)
|
| 47 |
+
monkeypatch.setattr(entry.dist, "is_initialized", lambda: False)
|
| 48 |
+
monkeypatch.chdir(ROOT / "code")
|
| 49 |
+
monkeypatch.setattr(sys, "argv", ["train.py", "--config_path", str(ROOT / "configs" / RECIPE),
|
| 50 |
+
"--logdir", str(tmp_path), "--disable-wandb", step_flag, "1"])
|
| 51 |
+
entry.main()
|
| 52 |
+
cfg = captured["config"]
|
| 53 |
+
assert captured["trained"]
|
| 54 |
+
assert cfg.trainer == "score_distillation" and cfg.distribution_loss == "wan_kv_mmd"
|
| 55 |
+
assert cfg.total_batch_size == 256 and cfg.expected_current_teacher_samples == 128
|
| 56 |
+
assert cfg.max_steps == 1 and cfg.lr == 2.5e-6
|
| 57 |
+
assert cfg.triple_aggregation_divisor == 3.0
|
| 58 |
+
# Stopping early must not shorten the scientific learning-rate schedule.
|
| 59 |
+
if ROOT.name == "1.3B":
|
| 60 |
+
assert cfg.generator_lr_cosine_end_step == 125
|
| 61 |
+
else:
|
| 62 |
+
assert cfg.lr_schedule_total_steps == 200
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_prepare_config_preserves_recipe_and_only_relocates_assets(tmp_path):
|
| 66 |
+
sandbox = tmp_path / "release"
|
| 67 |
+
for folder in ("configs", "scripts", "code/utils"):
|
| 68 |
+
(sandbox / folder).mkdir(parents=True, exist_ok=True)
|
| 69 |
+
for source in (Path("configs") / RECIPE, Path("scripts/prepare_config.py"), Path("code/utils/config.py")):
|
| 70 |
+
shutil.copy2(ROOT / source, sandbox / source)
|
| 71 |
+
assets = tmp_path / "assets with spaces"
|
| 72 |
+
command = [sys.executable, str(sandbox / "scripts/prepare_config.py"), "--asset-root", str(assets)]
|
| 73 |
+
if ROOT.name == "14B":
|
| 74 |
+
command.append("--inference-only") # No external feature-bank files are needed to relocate paths.
|
| 75 |
+
subprocess.run(command, check=True, capture_output=True, text=True)
|
| 76 |
+
original = resolve_config_aliases(yaml.safe_load((ROOT / "configs" / RECIPE).read_text()))
|
| 77 |
+
prefix = "/path/to/video-generation-assets"
|
| 78 |
+
expected = {k: str(assets) + v[len(prefix):] if isinstance(v, str) and v.startswith(prefix + "/") else v
|
| 79 |
+
for k, v in original.items()}
|
| 80 |
+
actual = yaml.safe_load((sandbox / "configs/local.yaml").read_text())
|
| 81 |
+
assert actual == expected
|
| 82 |
+
assert list(actual) == list(expected)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_legacy_modules_share_implementation_and_method_aliases():
|
| 86 |
+
for old, new, old_class, new_class in (
|
| 87 |
+
("model.wan_kv_mmd", "model.distribution_matching", "WanKVMMD", "DistributionMatchingModel"),
|
| 88 |
+
("trainer.distillation", "trainer.distribution_matching", "Trainer", "DistributionMatchingTrainer"),
|
| 89 |
+
("losses.multi_representation_video_nystrom_mmd", "losses.multi_encoder_mmd", "MultiRepresentationVideoNystromMMD", "MultiEncoderMMD"),
|
| 90 |
+
("trainer.multi_representation_fresh_video_gradcache", "trainer.feature_gradient_replay", "MultiRepresentationFreshVideoGradCache", "FeatureGradientReplay"),
|
| 91 |
+
):
|
| 92 |
+
legacy, current = importlib.import_module(old), importlib.import_module(new)
|
| 93 |
+
assert legacy is current
|
| 94 |
+
assert getattr(legacy, old_class) is getattr(current, new_class)
|
| 95 |
+
from model.distribution_matching import DistributionMatchingModel
|
| 96 |
+
from trainer.distribution_matching import DistributionMatchingTrainer
|
| 97 |
+
assert DistributionMatchingModel.fresh_video_gradcache_prepare_replay is DistributionMatchingModel.prepare_latent_gradients
|
| 98 |
+
assert DistributionMatchingTrainer._fresh_video_generator_update is DistributionMatchingTrainer._run_distribution_matching_step
|
1.3B/code/tests/test_triple_feature_metadata_contract.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
from model.wan_kv_mmd import WanKVMMD
|
| 4 |
+
from video_fd.encoders.vjepa2_dense_metadata import (
|
| 5 |
+
canonical_vjepa2_dense_feature_metadata,
|
| 6 |
+
uniform_dense_token_indices,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_triple_vjepa_metadata_matches_bank_last_64_contract():
|
| 11 |
+
model = object.__new__(WanKVMMD)
|
| 12 |
+
model.feature_type = "triple_dinov3_vjepa2_videomae_v2_dense_latent"
|
| 13 |
+
model.dense_feature_prefix = "triple"
|
| 14 |
+
model.dense_encoder_name = "triple"
|
| 15 |
+
model.dense_log_prefix = "triple_dense"
|
| 16 |
+
model.vjepa2_reference_model_id = "vjepa2-vitl-fpc64-256"
|
| 17 |
+
model.vjepa2_model_id = "/unused/local/model"
|
| 18 |
+
model.vjepa2_dense_layer = 15
|
| 19 |
+
model.vjepa2_dense_layers = (15,)
|
| 20 |
+
model.vjepa2_dense_num_frames = 64
|
| 21 |
+
model.vjepa2_dense_source_num_frames = 81
|
| 22 |
+
model.vjepa2_dense_frame_sampling = "last_contiguous"
|
| 23 |
+
model.vjepa2_dense_resize_short_side = 292
|
| 24 |
+
model.vjepa2_dense_crop_size = 256
|
| 25 |
+
model.vjepa2_dense_raw_tokens = 2048
|
| 26 |
+
model.vjepa2_dense_num_tokens = 1024
|
| 27 |
+
model.vjepa2_dense_feature_dim = 1024
|
| 28 |
+
indices = uniform_dense_token_indices(2048, 1024)
|
| 29 |
+
actual = model._vjepa2_feature_metadata(indices)
|
| 30 |
+
expected = canonical_vjepa2_dense_feature_metadata(
|
| 31 |
+
model_id="vjepa2-vitl-fpc64-256",
|
| 32 |
+
layer_index=15,
|
| 33 |
+
num_frames=64,
|
| 34 |
+
resize_short_side=292,
|
| 35 |
+
crop_size=256,
|
| 36 |
+
raw_tokens=2048,
|
| 37 |
+
num_tokens=1024,
|
| 38 |
+
feature_dim=1024,
|
| 39 |
+
token_indices=indices,
|
| 40 |
+
frame_sampling="last_contiguous",
|
| 41 |
+
source_num_frames=81,
|
| 42 |
+
)
|
| 43 |
+
assert actual == expected
|
| 44 |
+
assert actual["selected_frame_start"] == 17
|
| 45 |
+
assert actual["selected_frame_end_inclusive"] == 80
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_dual_dinov3_vjepa_metadata_uses_explicit_uniform_indices_by_default():
|
| 49 |
+
model = object.__new__(WanKVMMD)
|
| 50 |
+
model.feature_type = "dual_dinov3_vjepa2_dense_latent"
|
| 51 |
+
model.dense_feature_prefix = "dinov3_vjepa2"
|
| 52 |
+
model.dense_encoder_name = "dinov3_vjepa2"
|
| 53 |
+
model.dense_log_prefix = "dinov3_vjepa2_dense"
|
| 54 |
+
model.vjepa2_reference_model_id = "vjepa2-vitl-fpc64-256"
|
| 55 |
+
model.vjepa2_model_id = "/unused/local/model"
|
| 56 |
+
model.vjepa2_dense_layer = 15
|
| 57 |
+
model.vjepa2_dense_layers = (15,)
|
| 58 |
+
model.vjepa2_dense_num_frames = 64
|
| 59 |
+
model.vjepa2_dense_source_num_frames = 81
|
| 60 |
+
model.vjepa2_dense_frame_sampling = "center_contiguous"
|
| 61 |
+
model.vjepa2_dense_resize_short_side = 292
|
| 62 |
+
model.vjepa2_dense_crop_size = 256
|
| 63 |
+
model.vjepa2_dense_raw_tokens = 2048
|
| 64 |
+
model.vjepa2_dense_num_tokens = 1024
|
| 65 |
+
model.vjepa2_dense_feature_dim = 1024
|
| 66 |
+
actual = model._vjepa2_feature_metadata()
|
| 67 |
+
indices = uniform_dense_token_indices(2048, 1024)
|
| 68 |
+
expected = canonical_vjepa2_dense_feature_metadata(
|
| 69 |
+
model_id="vjepa2-vitl-fpc64-256",
|
| 70 |
+
layer_index=15,
|
| 71 |
+
num_frames=64,
|
| 72 |
+
resize_short_side=292,
|
| 73 |
+
crop_size=256,
|
| 74 |
+
raw_tokens=2048,
|
| 75 |
+
num_tokens=1024,
|
| 76 |
+
feature_dim=1024,
|
| 77 |
+
token_indices=indices,
|
| 78 |
+
frame_sampling="last_contiguous",
|
| 79 |
+
source_num_frames=81,
|
| 80 |
+
)
|
| 81 |
+
assert actual == expected
|
| 82 |
+
assert actual["token_alignment"] == "same_explicit_indices_and_order"
|
1.3B/code/tests/test_triple_partial_geometry.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_released_recipe_uses_10_1_1_loss_divided_by_three():
|
| 5 |
+
import yaml
|
| 6 |
+
from utils.config import resolve_config_aliases
|
| 7 |
+
root = Path(__file__).resolve().parents[2]
|
| 8 |
+
config = resolve_config_aliases(yaml.safe_load((root / "configs/three_encoder.yaml").read_text()))
|
| 9 |
+
assert config["triple_dinov3_weight"] == 10.0
|
| 10 |
+
assert config["triple_vjepa2_weight"] == 1.0
|
| 11 |
+
assert config["triple_videomae_v2_weight"] == 1.0
|
| 12 |
+
assert config["triple_aggregation_divisor"] == 3.0
|
| 13 |
+
assert config["partial_mmd_geometry"] == [256, 128, 128]
|
1.3B/code/tests/test_triple_pixel_cotangent_accumulation.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from model.wan_kv_mmd import _accumulate_fp32_pixel_cotangent
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_pixel_cotangents_accumulate_immediately_in_fp32():
|
| 8 |
+
accumulated = None
|
| 9 |
+
shape = None
|
| 10 |
+
expected = torch.zeros(2, 3, 4, dtype=torch.float32)
|
| 11 |
+
norms = []
|
| 12 |
+
for index, dtype in enumerate(
|
| 13 |
+
(torch.bfloat16, torch.float16, torch.float32), start=1
|
| 14 |
+
):
|
| 15 |
+
value = torch.full((2, 3, 4), float(index), dtype=dtype)
|
| 16 |
+
expected.add_(value.float())
|
| 17 |
+
accumulated, shape, norm = _accumulate_fp32_pixel_cotangent(
|
| 18 |
+
accumulated, value, shape, branch_name=f"branch{index}")
|
| 19 |
+
norms.append(norm)
|
| 20 |
+
assert accumulated.dtype == torch.float32
|
| 21 |
+
torch.testing.assert_close(accumulated, expected)
|
| 22 |
+
assert shape == (2, 3, 4)
|
| 23 |
+
assert all(item.ndim == 0 for item in norms)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_pixel_cotangent_accumulation_rejects_shape_and_nonfinite():
|
| 27 |
+
first, shape, _ = _accumulate_fp32_pixel_cotangent(
|
| 28 |
+
None, torch.ones(1, 2), None, branch_name="first")
|
| 29 |
+
with pytest.raises(ValueError, match="shapes differ"):
|
| 30 |
+
_accumulate_fp32_pixel_cotangent(
|
| 31 |
+
first, torch.ones(1, 3), shape, branch_name="second")
|
| 32 |
+
with pytest.raises(FloatingPointError, match="non-finite"):
|
| 33 |
+
_accumulate_fp32_pixel_cotangent(
|
| 34 |
+
None, torch.tensor([float("nan")]), None, branch_name="bad")
|
1.3B/code/tests/test_triple_video_feature_bank.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from types import SimpleNamespace
|
| 6 |
+
from unittest import mock
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
import torch
|
| 10 |
+
from safetensors.torch import save_file
|
| 11 |
+
|
| 12 |
+
from utils.triple_video_feature_bank import (
|
| 13 |
+
BANK_KIND,
|
| 14 |
+
BANK_SCHEMA_VERSION,
|
| 15 |
+
TripleVideoFeatureBank,
|
| 16 |
+
canonical_sha256,
|
| 17 |
+
file_sha256,
|
| 18 |
+
)
|
| 19 |
+
from trainer.distillation import Trainer
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
VJEPA_SHA = "1" * 64
|
| 23 |
+
VIDEOMAE_REVISION = "9981a9c8f77118c421e5228e1b219468a4b0238d"
|
| 24 |
+
VIDEOMAE_DOWNLOAD_HASH = "2" * 64
|
| 25 |
+
SOURCE_SHA = "3" * 64
|
| 26 |
+
DINOV3_SHA = "4" * 64
|
| 27 |
+
DINOV3_COMMIT = "6876159a11b4df116f30f667f8c9888617df0751"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _write_bank(root: Path):
|
| 31 |
+
samples = [
|
| 32 |
+
{"index": index, "sample_id": f"sample-{index}", "prompt": f"p{index}"}
|
| 33 |
+
for index in range(3)
|
| 34 |
+
]
|
| 35 |
+
branch_metadata = {
|
| 36 |
+
"dinov3": {
|
| 37 |
+
"sample_shape": [1024, 1024],
|
| 38 |
+
"dtype": "float16",
|
| 39 |
+
"feature_contract": {"name": "dinov3-test"},
|
| 40 |
+
"model_identity": {
|
| 41 |
+
"checkpoint_sha256": DINOV3_SHA,
|
| 42 |
+
"source_commit": DINOV3_COMMIT,
|
| 43 |
+
},
|
| 44 |
+
},
|
| 45 |
+
"vjepa2": {
|
| 46 |
+
"sample_shape": [1024, 1024],
|
| 47 |
+
"dtype": "float16",
|
| 48 |
+
"feature_contract": {"name": "vjepa2-test"},
|
| 49 |
+
"model_identity": {"sha256": VJEPA_SHA},
|
| 50 |
+
},
|
| 51 |
+
"videomae_v2": {
|
| 52 |
+
"sample_shape": [1280, 1024],
|
| 53 |
+
"dtype": "float16",
|
| 54 |
+
"feature_contract": {"name": "videomae-test"},
|
| 55 |
+
"model_identity": {
|
| 56 |
+
"revision": VIDEOMAE_REVISION,
|
| 57 |
+
"download_manifest_content_hash": VIDEOMAE_DOWNLOAD_HASH,
|
| 58 |
+
},
|
| 59 |
+
},
|
| 60 |
+
}
|
| 61 |
+
shard_dir = root / "shards"
|
| 62 |
+
shard_dir.mkdir(parents=True)
|
| 63 |
+
shards = []
|
| 64 |
+
for shard_index, (start, end) in enumerate(((0, 2), (2, 3))):
|
| 65 |
+
shard_content_hash = canonical_sha256({
|
| 66 |
+
"index": shard_index,
|
| 67 |
+
"sample_start": start,
|
| 68 |
+
"sample_end": end,
|
| 69 |
+
})
|
| 70 |
+
path = shard_dir / f"{shard_index:05d}.safetensors"
|
| 71 |
+
save_file(
|
| 72 |
+
{
|
| 73 |
+
"dinov3": torch.stack([
|
| 74 |
+
torch.full((1024, 1024), float(row + 20), dtype=torch.float16)
|
| 75 |
+
for row in range(start, end)
|
| 76 |
+
]),
|
| 77 |
+
"vjepa2": torch.stack([
|
| 78 |
+
torch.full((1024, 1024), float(row), dtype=torch.float16)
|
| 79 |
+
for row in range(start, end)
|
| 80 |
+
]),
|
| 81 |
+
"videomae_v2": torch.stack([
|
| 82 |
+
torch.full((1280, 1024), float(row + 10), dtype=torch.float16)
|
| 83 |
+
for row in range(start, end)
|
| 84 |
+
]),
|
| 85 |
+
},
|
| 86 |
+
path,
|
| 87 |
+
metadata={"content_hash": shard_content_hash},
|
| 88 |
+
)
|
| 89 |
+
shards.append({
|
| 90 |
+
"index": shard_index,
|
| 91 |
+
"path": str(path.relative_to(root)),
|
| 92 |
+
"sample_start": start,
|
| 93 |
+
"sample_end": end,
|
| 94 |
+
"sample_ids_sha256": canonical_sha256([
|
| 95 |
+
item["sample_id"] for item in samples[start:end]
|
| 96 |
+
]),
|
| 97 |
+
"sha256": file_sha256(path),
|
| 98 |
+
"content_hash": shard_content_hash,
|
| 99 |
+
})
|
| 100 |
+
payload = {
|
| 101 |
+
"schema_version": BANK_SCHEMA_VERSION,
|
| 102 |
+
"kind": BANK_KIND,
|
| 103 |
+
"sample_count": len(samples),
|
| 104 |
+
"source_manifest": {"sha256": SOURCE_SHA},
|
| 105 |
+
"branches": branch_metadata,
|
| 106 |
+
"samples": samples,
|
| 107 |
+
"shards": shards,
|
| 108 |
+
}
|
| 109 |
+
manifest = {**payload, "content_hash": canonical_sha256(payload)}
|
| 110 |
+
manifest_path = root / "manifest.json"
|
| 111 |
+
manifest_path.write_text(
|
| 112 |
+
json.dumps(manifest, sort_keys=True) + "\n", encoding="utf-8")
|
| 113 |
+
return manifest_path, manifest
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _open(manifest_path: Path, manifest: dict, **overrides):
|
| 117 |
+
kwargs = {
|
| 118 |
+
"expected_manifest_sha256": file_sha256(manifest_path),
|
| 119 |
+
"expected_content_hash": manifest["content_hash"],
|
| 120 |
+
"expected_source_manifest_sha256": SOURCE_SHA,
|
| 121 |
+
"expected_sample_count": 3,
|
| 122 |
+
"expected_videomae_revision": VIDEOMAE_REVISION,
|
| 123 |
+
"expected_videomae_download_manifest_content_hash": (
|
| 124 |
+
VIDEOMAE_DOWNLOAD_HASH),
|
| 125 |
+
"expected_vjepa_model_sha256": VJEPA_SHA,
|
| 126 |
+
"expected_dinov3_checkpoint_sha256": DINOV3_SHA,
|
| 127 |
+
"expected_dinov3_source_commit": DINOV3_COMMIT,
|
| 128 |
+
"expected_shard_size": 2,
|
| 129 |
+
"validate_all_shards": True,
|
| 130 |
+
}
|
| 131 |
+
kwargs.update(overrides)
|
| 132 |
+
return TripleVideoFeatureBank(manifest_path, **kwargs)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_random_access_preserves_order_and_duplicate_sample_ids(tmp_path):
|
| 136 |
+
manifest_path, manifest = _write_bank(tmp_path)
|
| 137 |
+
bank = _open(manifest_path, manifest)
|
| 138 |
+
assert [bank[index]["sample_ids"] for index in (2, 0, 2)] == [
|
| 139 |
+
"sample-2", "sample-0", "sample-2"]
|
| 140 |
+
loaded = bank.load_rows(["sample-2", "sample-0", "sample-2"])
|
| 141 |
+
assert tuple(loaded["dinov3"].shape) == (3, 1024, 1024)
|
| 142 |
+
assert tuple(loaded["vjepa2"].shape) == (3, 1024, 1024)
|
| 143 |
+
assert tuple(loaded["videomae_v2"].shape) == (3, 1280, 1024)
|
| 144 |
+
torch.testing.assert_close(
|
| 145 |
+
loaded["dinov3"][:, 0, 0], torch.tensor([22, 20, 22]).half())
|
| 146 |
+
torch.testing.assert_close(
|
| 147 |
+
loaded["vjepa2"][:, 0, 0], torch.tensor([2, 0, 2]).half())
|
| 148 |
+
torch.testing.assert_close(
|
| 149 |
+
loaded["videomae_v2"][:, 0, 0],
|
| 150 |
+
torch.tensor([12, 10, 12]).half(),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_counter_sampler_is_rank_major_ordered_and_with_replacement():
|
| 159 |
+
class Dataset:
|
| 160 |
+
def __len__(self):
|
| 161 |
+
return 3
|
| 162 |
+
|
| 163 |
+
def __getitem__(self, index):
|
| 164 |
+
return {"sample_ids": f"sample-{index}", "prompts": f"p{index}"}
|
| 165 |
+
|
| 166 |
+
@staticmethod
|
| 167 |
+
def collate_fn(items):
|
| 168 |
+
return {
|
| 169 |
+
"sample_ids": [item["sample_ids"] for item in items],
|
| 170 |
+
"prompts": [item["prompts"] for item in items],
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
trainer = Trainer.__new__(Trainer)
|
| 174 |
+
trainer.matched_counter_sampling_enabled = True
|
| 175 |
+
trainer.dataset = Dataset()
|
| 176 |
+
trainer.config = SimpleNamespace(seed=7, batch_size=1)
|
| 177 |
+
trainer.step = 11
|
| 178 |
+
trainer.data_counter = 0
|
| 179 |
+
trainer.fresh_video_total_samples = 8
|
| 180 |
+
trainer.fresh_video_local_samples = 2
|
| 181 |
+
expected = tuple(
|
| 182 |
+
int.from_bytes(hashlib.sha256(
|
| 183 |
+
f"sf-matched-counter-v1\0{trainer.config.seed}\0{trainer.step}\0{position}"
|
| 184 |
+
.encode("ascii")
|
| 185 |
+
).digest()[:8], "little") % len(trainer.dataset)
|
| 186 |
+
for position in range(trainer.fresh_video_total_samples)
|
| 187 |
+
)
|
| 188 |
+
assert trainer._matched_global_indices_for_step() == expected
|
| 189 |
+
# Eight with-replacement draws from a three-row bank must contain repeats.
|
| 190 |
+
assert len(set(expected)) < len(expected)
|
| 191 |
+
with mock.patch("trainer.distillation.dist.get_rank", return_value=2):
|
| 192 |
+
first = trainer._matched_batch_for_microbatch(0)
|
| 193 |
+
second = trainer._matched_batch_for_microbatch(1)
|
| 194 |
+
assert first["sample_ids"] == [f"sample-{expected[4]}"]
|
| 195 |
+
assert second["sample_ids"] == [f"sample-{expected[5]}"]
|
| 196 |
+
assert trainer.data_counter == 2
|
1.3B/code/tests/test_video_nystrom_partial_mmd.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
from losses.video_nystrom_mmd import (
|
| 6 |
+
VideoNystromMMD,
|
| 7 |
+
build_video_nystrom_reference,
|
| 8 |
+
rbf_frobenius_kernel,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestVideoNystromPartialMMD(unittest.TestCase):
|
| 13 |
+
def _pack(self):
|
| 14 |
+
reference = torch.randn(
|
| 15 |
+
12,
|
| 16 |
+
1,
|
| 17 |
+
2,
|
| 18 |
+
generator=torch.Generator().manual_seed(9100),
|
| 19 |
+
)
|
| 20 |
+
return build_video_nystrom_reference(
|
| 21 |
+
reference,
|
| 22 |
+
num_landmarks=5,
|
| 23 |
+
metadata={
|
| 24 |
+
"feature": {
|
| 25 |
+
"name": "tiny-partial-video",
|
| 26 |
+
"matrix_shape": [1, 2],
|
| 27 |
+
},
|
| 28 |
+
"reference": {"dataset": "partial-unit-test"},
|
| 29 |
+
},
|
| 30 |
+
bandwidth=1.6,
|
| 31 |
+
kmeans_iterations=4,
|
| 32 |
+
ridge=1.0e-5,
|
| 33 |
+
chunk_size=3,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def _matched_objective(self, *, chunk_size: int = 37):
|
| 37 |
+
return VideoNystromMMD(
|
| 38 |
+
self._pack(),
|
| 39 |
+
kernel_chunk_size=chunk_size,
|
| 40 |
+
attraction_weight=2.0,
|
| 41 |
+
current_teacher_attraction_enabled=True,
|
| 42 |
+
current_teacher_attraction_weight=1.0,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def test_128_by_256_formula_denominators_and_suffix_only_gradient(self):
|
| 46 |
+
objective = self._matched_objective()
|
| 47 |
+
generator = torch.Generator().manual_seed(9101)
|
| 48 |
+
all_students = torch.randn(
|
| 49 |
+
256, 1, 2, generator=generator, requires_grad=True)
|
| 50 |
+
gradient_students = all_students[-128:]
|
| 51 |
+
teacher = torch.randn(
|
| 52 |
+
128, 1, 2, generator=generator, requires_grad=True)
|
| 53 |
+
|
| 54 |
+
loss, metadata = objective(
|
| 55 |
+
gradient_students=gradient_students,
|
| 56 |
+
student_reference=all_students,
|
| 57 |
+
current_teacher=teacher,
|
| 58 |
+
stage=False,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
kernel_gs = rbf_frobenius_kernel(
|
| 62 |
+
gradient_students,
|
| 63 |
+
all_students.detach(),
|
| 64 |
+
objective.sigma,
|
| 65 |
+
)
|
| 66 |
+
fixed_attraction = (
|
| 67 |
+
rbf_frobenius_kernel(
|
| 68 |
+
gradient_students,
|
| 69 |
+
objective.landmarks,
|
| 70 |
+
objective.sigma,
|
| 71 |
+
)
|
| 72 |
+
@ objective.alpha
|
| 73 |
+
).mean()
|
| 74 |
+
kernel_gt = rbf_frobenius_kernel(
|
| 75 |
+
gradient_students,
|
| 76 |
+
teacher.detach(),
|
| 77 |
+
objective.sigma,
|
| 78 |
+
)
|
| 79 |
+
kernel_tt = rbf_frobenius_kernel(
|
| 80 |
+
teacher.detach(),
|
| 81 |
+
teacher.detach(),
|
| 82 |
+
objective.sigma,
|
| 83 |
+
)
|
| 84 |
+
expected = (
|
| 85 |
+
kernel_gs.sum() / float(128 * 256)
|
| 86 |
+
- 2.0
|
| 87 |
+
* (
|
| 88 |
+
(2.0 / 3.0) * fixed_attraction
|
| 89 |
+
+ (1.0 / 3.0) * kernel_gt.sum() / float(128 * 128)
|
| 90 |
+
)
|
| 91 |
+
+ (2.0 / 3.0) * objective.k_rr
|
| 92 |
+
+ (1.0 / 3.0) * kernel_tt.sum() / float(128 * 128)
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
torch.testing.assert_close(
|
| 96 |
+
loss, expected, rtol=2.0e-5, atol=2.0e-6)
|
| 97 |
+
torch.testing.assert_close(
|
| 98 |
+
metadata["repulsion"],
|
| 99 |
+
(kernel_gs.sum() / float(128 * 256)).detach(),
|
| 100 |
+
rtol=2.0e-5,
|
| 101 |
+
atol=2.0e-6,
|
| 102 |
+
)
|
| 103 |
+
self.assertEqual(int(metadata["global_samples"]), 256)
|
| 104 |
+
self.assertEqual(int(metadata["gradient_student_samples"]), 128)
|
| 105 |
+
self.assertEqual(int(metadata["student_reference_samples"]), 256)
|
| 106 |
+
self.assertEqual(int(metadata["current_teacher_samples"]), 128)
|
| 107 |
+
self.assertEqual(int(metadata["repulsion_denominator"]), 32768)
|
| 108 |
+
self.assertEqual(
|
| 109 |
+
int(metadata["student_repulsion_denominator"]), 32768)
|
| 110 |
+
self.assertEqual(
|
| 111 |
+
int(metadata["current_teacher_attraction_denominator"]), 16384)
|
| 112 |
+
self.assertEqual(
|
| 113 |
+
int(metadata["current_teacher_self_kernel_denominator"]), 16384)
|
| 114 |
+
self.assertEqual(
|
| 115 |
+
int(metadata["fixed_teacher_attraction_denominator"]), 128)
|
| 116 |
+
self.assertEqual(
|
| 117 |
+
int(metadata["partial_student_reference_enabled"]), 1)
|
| 118 |
+
self.assertEqual(int(metadata["student_reference_detached"]), 1)
|
| 119 |
+
self.assertNotIn("full_student_partial_gradient", metadata)
|
| 120 |
+
self.assertAlmostEqual(
|
| 121 |
+
float(metadata[
|
| 122 |
+
"fixed_teacher_attraction_normalized_weight"]),
|
| 123 |
+
2.0 / 3.0,
|
| 124 |
+
places=7,
|
| 125 |
+
)
|
| 126 |
+
self.assertAlmostEqual(
|
| 127 |
+
float(metadata[
|
| 128 |
+
"current_teacher_attraction_normalized_weight"]),
|
| 129 |
+
1.0 / 3.0,
|
| 130 |
+
places=7,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
actual_student_gradient, actual_teacher_gradient = (
|
| 134 |
+
torch.autograd.grad(
|
| 135 |
+
loss,
|
| 136 |
+
(all_students, teacher),
|
| 137 |
+
allow_unused=True,
|
| 138 |
+
retain_graph=True,
|
| 139 |
+
)
|
| 140 |
+
)
|
| 141 |
+
expected_student_gradient, = torch.autograd.grad(
|
| 142 |
+
expected, all_students)
|
| 143 |
+
torch.testing.assert_close(
|
| 144 |
+
actual_student_gradient,
|
| 145 |
+
expected_student_gradient,
|
| 146 |
+
rtol=3.0e-5,
|
| 147 |
+
atol=3.0e-6,
|
| 148 |
+
)
|
| 149 |
+
self.assertIsNone(actual_teacher_gradient)
|
| 150 |
+
torch.testing.assert_close(
|
| 151 |
+
actual_student_gradient[:128],
|
| 152 |
+
torch.zeros_like(actual_student_gradient[:128]),
|
| 153 |
+
rtol=0.0,
|
| 154 |
+
atol=0.0,
|
| 155 |
+
)
|
| 156 |
+
self.assertGreater(
|
| 157 |
+
float(actual_student_gradient[-128:].norm()), 0.0)
|
| 158 |
+
self.assertTrue(torch.isfinite(actual_student_gradient).all())
|
| 159 |
+
|
| 160 |
+
def test_full_student_partial_gradient_formula_and_denominators(self):
|
| 161 |
+
objective = self._matched_objective()
|
| 162 |
+
generator = torch.Generator().manual_seed(9105)
|
| 163 |
+
student_reference = torch.randn(
|
| 164 |
+
256, 1, 2, generator=generator, requires_grad=True)
|
| 165 |
+
gradient_student_indices = torch.cat([
|
| 166 |
+
torch.arange(rank * 64 + 32, rank * 64 + 64)
|
| 167 |
+
for rank in range(4)
|
| 168 |
+
])
|
| 169 |
+
gradient_students = (
|
| 170 |
+
student_reference.detach()
|
| 171 |
+
.index_select(0, gradient_student_indices)
|
| 172 |
+
.clone()
|
| 173 |
+
.requires_grad_(True)
|
| 174 |
+
)
|
| 175 |
+
teacher = torch.randn(
|
| 176 |
+
128, 1, 2, generator=generator, requires_grad=True)
|
| 177 |
+
|
| 178 |
+
loss, metadata = objective(
|
| 179 |
+
gradient_students=gradient_students,
|
| 180 |
+
student_reference=student_reference,
|
| 181 |
+
current_teacher=teacher,
|
| 182 |
+
gradient_student_indices=gradient_student_indices,
|
| 183 |
+
stage=False,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
full_students = student_reference.detach().index_copy(
|
| 187 |
+
0, gradient_student_indices, gradient_students)
|
| 188 |
+
kernel_ss = rbf_frobenius_kernel(
|
| 189 |
+
full_students, full_students, objective.sigma)
|
| 190 |
+
fixed_attraction = (
|
| 191 |
+
rbf_frobenius_kernel(
|
| 192 |
+
full_students, objective.landmarks, objective.sigma)
|
| 193 |
+
@ objective.alpha
|
| 194 |
+
).mean()
|
| 195 |
+
kernel_st = rbf_frobenius_kernel(
|
| 196 |
+
full_students, teacher.detach(), objective.sigma)
|
| 197 |
+
kernel_tt = rbf_frobenius_kernel(
|
| 198 |
+
teacher.detach(), teacher.detach(), objective.sigma)
|
| 199 |
+
expected = (
|
| 200 |
+
kernel_ss.sum() / float(256 * 256)
|
| 201 |
+
- 2.0
|
| 202 |
+
* (
|
| 203 |
+
(2.0 / 3.0) * fixed_attraction
|
| 204 |
+
+ (1.0 / 3.0) * kernel_st.sum() / float(256 * 128)
|
| 205 |
+
)
|
| 206 |
+
+ (2.0 / 3.0) * objective.k_rr
|
| 207 |
+
+ (1.0 / 3.0) * kernel_tt.sum() / float(128 * 128)
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
torch.testing.assert_close(
|
| 211 |
+
loss, expected, rtol=2.0e-5, atol=2.0e-6)
|
| 212 |
+
self.assertEqual(int(metadata["global_samples"]), 256)
|
| 213 |
+
self.assertEqual(int(metadata["gradient_student_samples"]), 128)
|
| 214 |
+
self.assertEqual(int(metadata["student_reference_samples"]), 256)
|
| 215 |
+
self.assertEqual(int(metadata["current_teacher_samples"]), 128)
|
| 216 |
+
self.assertEqual(int(metadata["repulsion_denominator"]), 65536)
|
| 217 |
+
self.assertEqual(
|
| 218 |
+
int(metadata["student_repulsion_denominator"]), 65536)
|
| 219 |
+
self.assertEqual(
|
| 220 |
+
int(metadata["fixed_teacher_attraction_denominator"]), 256)
|
| 221 |
+
self.assertEqual(
|
| 222 |
+
int(metadata["current_teacher_attraction_denominator"]),
|
| 223 |
+
32768,
|
| 224 |
+
)
|
| 225 |
+
self.assertEqual(
|
| 226 |
+
int(metadata["current_teacher_self_kernel_denominator"]),
|
| 227 |
+
16384,
|
| 228 |
+
)
|
| 229 |
+
self.assertEqual(
|
| 230 |
+
int(metadata["full_student_partial_gradient"]), 1)
|
| 231 |
+
self.assertEqual(
|
| 232 |
+
int(metadata["student_repulsion_gradient_sides"]), 2)
|
| 233 |
+
|
| 234 |
+
actual_gradient, reference_gradient, teacher_gradient = (
|
| 235 |
+
torch.autograd.grad(
|
| 236 |
+
loss,
|
| 237 |
+
(gradient_students, student_reference, teacher),
|
| 238 |
+
allow_unused=True,
|
| 239 |
+
retain_graph=True,
|
| 240 |
+
)
|
| 241 |
+
)
|
| 242 |
+
expected_gradient, = torch.autograd.grad(
|
| 243 |
+
expected, gradient_students, retain_graph=True)
|
| 244 |
+
torch.testing.assert_close(
|
| 245 |
+
actual_gradient,
|
| 246 |
+
expected_gradient,
|
| 247 |
+
rtol=3.0e-5,
|
| 248 |
+
atol=3.0e-6,
|
| 249 |
+
)
|
| 250 |
+
self.assertIsNone(reference_gradient)
|
| 251 |
+
self.assertIsNone(teacher_gradient)
|
| 252 |
+
self.assertGreater(float(actual_gradient.norm()), 0.0)
|
| 253 |
+
self.assertTrue(torch.isfinite(actual_gradient).all())
|
| 254 |
+
|
| 255 |
+
full_repulsion_gradient, = torch.autograd.grad(
|
| 256 |
+
kernel_ss.sum() / float(256 * 256),
|
| 257 |
+
gradient_students,
|
| 258 |
+
retain_graph=True,
|
| 259 |
+
)
|
| 260 |
+
rectangular_repulsion = rbf_frobenius_kernel(
|
| 261 |
+
gradient_students, student_reference.detach(), objective.sigma
|
| 262 |
+
).sum() / float(128 * 256)
|
| 263 |
+
rectangular_repulsion_gradient, = torch.autograd.grad(
|
| 264 |
+
rectangular_repulsion, gradient_students)
|
| 265 |
+
torch.testing.assert_close(
|
| 266 |
+
full_repulsion_gradient,
|
| 267 |
+
rectangular_repulsion_gradient,
|
| 268 |
+
rtol=3.0e-5,
|
| 269 |
+
atol=3.0e-6,
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
def test_inputs_are_detached_and_converted_to_fp32_for_kernels(self):
|
| 273 |
+
objective = self._matched_objective(chunk_size=2)
|
| 274 |
+
generator = torch.Generator().manual_seed(9102)
|
| 275 |
+
gradient_students = torch.randn(
|
| 276 |
+
3, 1, 2, generator=generator, requires_grad=True)
|
| 277 |
+
student_reference = torch.randn(
|
| 278 |
+
5,
|
| 279 |
+
1,
|
| 280 |
+
2,
|
| 281 |
+
generator=generator,
|
| 282 |
+
dtype=torch.float64,
|
| 283 |
+
requires_grad=True,
|
| 284 |
+
)
|
| 285 |
+
teacher = torch.randn(
|
| 286 |
+
3,
|
| 287 |
+
1,
|
| 288 |
+
2,
|
| 289 |
+
generator=generator,
|
| 290 |
+
dtype=torch.float64,
|
| 291 |
+
requires_grad=True,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
loss, _ = objective(
|
| 295 |
+
gradient_students=gradient_students,
|
| 296 |
+
student_reference=student_reference,
|
| 297 |
+
current_teacher=teacher,
|
| 298 |
+
stage=False,
|
| 299 |
+
)
|
| 300 |
+
student_gradient, reference_gradient, teacher_gradient = (
|
| 301 |
+
torch.autograd.grad(
|
| 302 |
+
loss,
|
| 303 |
+
(gradient_students, student_reference, teacher),
|
| 304 |
+
allow_unused=True,
|
| 305 |
+
)
|
| 306 |
+
)
|
| 307 |
+
self.assertEqual(loss.dtype, torch.float32)
|
| 308 |
+
self.assertIsNotNone(student_gradient)
|
| 309 |
+
self.assertIsNone(reference_gradient)
|
| 310 |
+
self.assertIsNone(teacher_gradient)
|
| 311 |
+
|
| 312 |
+
def test_legacy_interface_preserves_full_symmetric_formula_and_schema(self):
|
| 313 |
+
objective = self._matched_objective(chunk_size=2)
|
| 314 |
+
generator = torch.Generator().manual_seed(9103)
|
| 315 |
+
generated = torch.randn(
|
| 316 |
+
4, 1, 2, generator=generator, requires_grad=True)
|
| 317 |
+
teacher = torch.randn(4, 1, 2, generator=generator)
|
| 318 |
+
|
| 319 |
+
loss, metadata = objective(
|
| 320 |
+
generated,
|
| 321 |
+
current_teacher=teacher,
|
| 322 |
+
stage=False,
|
| 323 |
+
)
|
| 324 |
+
kernel_gg = rbf_frobenius_kernel(
|
| 325 |
+
generated, generated, objective.sigma)
|
| 326 |
+
fixed_attraction = (
|
| 327 |
+
rbf_frobenius_kernel(
|
| 328 |
+
generated, objective.landmarks, objective.sigma)
|
| 329 |
+
@ objective.alpha
|
| 330 |
+
).mean()
|
| 331 |
+
kernel_gt = rbf_frobenius_kernel(
|
| 332 |
+
generated, teacher, objective.sigma)
|
| 333 |
+
kernel_tt = rbf_frobenius_kernel(
|
| 334 |
+
teacher, teacher, objective.sigma)
|
| 335 |
+
expected = (
|
| 336 |
+
kernel_gg.mean()
|
| 337 |
+
- 2.0
|
| 338 |
+
* (
|
| 339 |
+
(2.0 / 3.0) * fixed_attraction
|
| 340 |
+
+ (1.0 / 3.0) * kernel_gt.mean()
|
| 341 |
+
)
|
| 342 |
+
+ (2.0 / 3.0) * objective.k_rr
|
| 343 |
+
+ (1.0 / 3.0) * kernel_tt.mean()
|
| 344 |
+
)
|
| 345 |
+
torch.testing.assert_close(loss, expected)
|
| 346 |
+
actual_gradient, = torch.autograd.grad(
|
| 347 |
+
loss, generated, retain_graph=True)
|
| 348 |
+
expected_gradient, = torch.autograd.grad(expected, generated)
|
| 349 |
+
torch.testing.assert_close(
|
| 350 |
+
actual_gradient,
|
| 351 |
+
expected_gradient,
|
| 352 |
+
rtol=2.0e-5,
|
| 353 |
+
atol=2.0e-6,
|
| 354 |
+
)
|
| 355 |
+
self.assertEqual(int(metadata["global_samples"]), 4)
|
| 356 |
+
for partial_key in (
|
| 357 |
+
"partial_student_reference_enabled",
|
| 358 |
+
"gradient_student_samples",
|
| 359 |
+
"student_reference_samples",
|
| 360 |
+
"student_reference_detached",
|
| 361 |
+
"student_repulsion_denominator",
|
| 362 |
+
"fixed_teacher_attraction_denominator",
|
| 363 |
+
"current_teacher_attraction_denominator",
|
| 364 |
+
"current_teacher_self_kernel_denominator",
|
| 365 |
+
"full_student_partial_gradient",
|
| 366 |
+
):
|
| 367 |
+
self.assertNotIn(partial_key, metadata)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
if __name__ == "__main__":
|
| 372 |
+
unittest.main()
|
1.3B/code/tools/build_triple_nystrom_references.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Build three fixed Nyström references sequentially from one triple bank."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import copy
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from safetensors import safe_open
|
| 14 |
+
|
| 15 |
+
from losses.video_nystrom_mmd import (
|
| 16 |
+
build_video_nystrom_reference,
|
| 17 |
+
compute_reference_pack_hash,
|
| 18 |
+
load_reference_pack,
|
| 19 |
+
save_reference_pack,
|
| 20 |
+
)
|
| 21 |
+
from utils.triple_video_feature_bank import (
|
| 22 |
+
TripleVideoFeatureBank,
|
| 23 |
+
file_sha256,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
OUTPUT_NAMES = {
|
| 28 |
+
"dinov3": "dinov3_vitl16_f20mean5_k1024_m256_matched5k.pt",
|
| 29 |
+
"vjepa2": "vjepa2_l15_last64_k1024_m1024_matched5k.pt",
|
| 30 |
+
"videomae_v2": "videomae_v2_l15_fixed5x16_k256_m256_matched5k.pt",
|
| 31 |
+
}
|
| 32 |
+
LANDMARKS = {"dinov3": 256, "vjepa2": 1024, "videomae_v2": 256}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def reference_feature_metadata(bank: TripleVideoFeatureBank, branch: str) -> dict:
|
| 36 |
+
record = bank.manifest["branches"][branch]
|
| 37 |
+
raw = copy.deepcopy(record["feature_contract"])
|
| 38 |
+
if branch != "videomae_v2":
|
| 39 |
+
return raw
|
| 40 |
+
from video_fd.encoders.videomae_v2_dense_metadata import (
|
| 41 |
+
canonical_videomae_v2_dense_feature_metadata,
|
| 42 |
+
)
|
| 43 |
+
expected = canonical_videomae_v2_dense_feature_metadata(
|
| 44 |
+
model_id=record["model_identity"]["model_id"],
|
| 45 |
+
layer_index=int(raw.get("layer_index", 15)),
|
| 46 |
+
num_tokens=int(raw.get("num_tokens_per_segment", 256)),
|
| 47 |
+
token_indices=raw.get("relative_token_indices"),
|
| 48 |
+
vae_id=str(raw.get("vae_id", "Wan2.1-T2V-1.3B/Wan2.1_VAE.pth")),
|
| 49 |
+
num_segments=int(raw.get("num_segments", 5)),
|
| 50 |
+
frames_per_segment=int(raw.get("frames_per_segment", 16)),
|
| 51 |
+
raw_tokens_per_segment=int(raw.get("raw_tokens_per_segment", 1568)),
|
| 52 |
+
feature_dim=int(raw.get("feature_dim", 1024)),
|
| 53 |
+
resize_short_side=int(raw.get("resize_short_side", 224)),
|
| 54 |
+
crop_size=int(raw.get("crop_size", 224)),
|
| 55 |
+
skip_first_frame=bool(raw.get("skip_first_frame", True)),
|
| 56 |
+
)
|
| 57 |
+
ignored = {"model_id", "feature_identity_sha256"}
|
| 58 |
+
if (
|
| 59 |
+
{key: value for key, value in raw.items() if key not in ignored}
|
| 60 |
+
!= {key: value for key, value in expected.items() if key not in ignored}
|
| 61 |
+
):
|
| 62 |
+
raise ValueError("VideoMAE bank and runtime protocols differ beyond model ID")
|
| 63 |
+
return expected
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def atomic_torch_save(value, path: Path) -> None:
|
| 67 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
temporary = path.with_name(f".{path.name}.tmp")
|
| 69 |
+
torch.save(value, temporary)
|
| 70 |
+
os.replace(temporary, path)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_branch(bank: TripleVideoFeatureBank, branch: str) -> torch.Tensor:
|
| 74 |
+
parts = []
|
| 75 |
+
for shard in bank.shards:
|
| 76 |
+
with safe_open(
|
| 77 |
+
shard["resolved_path"], framework="pt", device="cpu"
|
| 78 |
+
) as handle:
|
| 79 |
+
parts.append(handle.get_tensor(branch))
|
| 80 |
+
value = torch.cat(parts, dim=0)
|
| 81 |
+
expected = (len(bank), *bank.manifest["branches"][branch]["sample_shape"])
|
| 82 |
+
if value.dtype != torch.float16 or tuple(value.shape) != expected:
|
| 83 |
+
raise ValueError(f"bank branch {branch} geometry/dtype mismatch")
|
| 84 |
+
return value
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main() -> None:
|
| 88 |
+
parser = argparse.ArgumentParser()
|
| 89 |
+
parser.add_argument("--bank-manifest", required=True)
|
| 90 |
+
parser.add_argument("--bank-manifest-sha256", required=True)
|
| 91 |
+
parser.add_argument("--bank-content-hash", required=True)
|
| 92 |
+
parser.add_argument("--source-manifest-sha256", required=True)
|
| 93 |
+
parser.add_argument("--videomae-revision", default=(
|
| 94 |
+
"9981a9c8f77118c421e5228e1b219468a4b0238d"))
|
| 95 |
+
parser.add_argument(
|
| 96 |
+
"--videomae-download-manifest-content-hash", required=True)
|
| 97 |
+
parser.add_argument("--vjepa-model-sha256", required=True)
|
| 98 |
+
parser.add_argument("--dinov3-checkpoint-sha256", required=True)
|
| 99 |
+
parser.add_argument("--dinov3-source-commit", required=True)
|
| 100 |
+
parser.add_argument("--output-dir", required=True)
|
| 101 |
+
parser.add_argument("--device", default="cuda:0")
|
| 102 |
+
parser.add_argument("--kmeans-iterations", type=int, default=20)
|
| 103 |
+
parser.add_argument("--chunk-size", type=int, default=32)
|
| 104 |
+
parser.add_argument("--ridge", type=float, default=1.0e-6)
|
| 105 |
+
parser.add_argument("--expected-sample-count", type=int, default=5000)
|
| 106 |
+
parser.add_argument("--expected-shard-size", type=int, default=32)
|
| 107 |
+
parser.add_argument("--dataset-name", default="fdwan13b_vidprom5k_matched")
|
| 108 |
+
parser.add_argument("--skip-bank-shard-hashes", action="store_true")
|
| 109 |
+
args = parser.parse_args()
|
| 110 |
+
bank = TripleVideoFeatureBank(
|
| 111 |
+
args.bank_manifest,
|
| 112 |
+
expected_manifest_sha256=args.bank_manifest_sha256,
|
| 113 |
+
expected_content_hash=args.bank_content_hash,
|
| 114 |
+
expected_source_manifest_sha256=args.source_manifest_sha256,
|
| 115 |
+
expected_sample_count=args.expected_sample_count,
|
| 116 |
+
expected_videomae_revision=args.videomae_revision,
|
| 117 |
+
expected_videomae_download_manifest_content_hash=(
|
| 118 |
+
args.videomae_download_manifest_content_hash),
|
| 119 |
+
expected_vjepa_model_sha256=args.vjepa_model_sha256,
|
| 120 |
+
expected_dinov3_checkpoint_sha256=(
|
| 121 |
+
args.dinov3_checkpoint_sha256),
|
| 122 |
+
expected_dinov3_source_commit=args.dinov3_source_commit,
|
| 123 |
+
expected_shard_size=args.expected_shard_size,
|
| 124 |
+
validate_all_shards=not args.skip_bank_shard_hashes,
|
| 125 |
+
)
|
| 126 |
+
output_dir = Path(args.output_dir).expanduser().resolve()
|
| 127 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 128 |
+
build_request = {
|
| 129 |
+
"chunk_size": int(args.chunk_size),
|
| 130 |
+
"kmeans_iterations": int(args.kmeans_iterations),
|
| 131 |
+
"ridge": float(args.ridge),
|
| 132 |
+
"seed": 0,
|
| 133 |
+
}
|
| 134 |
+
summaries = {}
|
| 135 |
+
for branch in ("dinov3", "vjepa2", "videomae_v2"):
|
| 136 |
+
output = output_dir / OUTPUT_NAMES[branch]
|
| 137 |
+
branch_manifest = bank.manifest["branches"][branch]
|
| 138 |
+
feature_metadata = reference_feature_metadata(bank, branch)
|
| 139 |
+
migrated_metadata = False
|
| 140 |
+
if output.is_file():
|
| 141 |
+
existing = load_reference_pack(output, map_location="cpu")
|
| 142 |
+
reference_metadata = existing["metadata"].get("reference", {})
|
| 143 |
+
source = reference_metadata.get("source", {})
|
| 144 |
+
nystrom = existing["metadata"].get("nystrom", {})
|
| 145 |
+
if (
|
| 146 |
+
source.get("triple_teacher_bank_content_hash")
|
| 147 |
+
!= bank.content_hash
|
| 148 |
+
or int(nystrom.get("num_landmarks", -1)) != LANDMARKS[branch]
|
| 149 |
+
or int(nystrom.get("kmeans_iterations", -1))
|
| 150 |
+
!= int(args.kmeans_iterations)
|
| 151 |
+
or float(nystrom.get("ridge", float("nan")))
|
| 152 |
+
!= float(args.ridge)
|
| 153 |
+
or int(nystrom.get("seed", -1)) != 0
|
| 154 |
+
or reference_metadata.get("build_request") != build_request
|
| 155 |
+
):
|
| 156 |
+
raise ValueError(
|
| 157 |
+
f"existing reference has another identity: {output}")
|
| 158 |
+
if existing["metadata"].get("feature") != feature_metadata:
|
| 159 |
+
old_feature = existing["metadata"].get("feature", {})
|
| 160 |
+
ignored = {"model_id", "feature_identity_sha256"}
|
| 161 |
+
if (
|
| 162 |
+
branch != "videomae_v2"
|
| 163 |
+
or {key: value for key, value in old_feature.items()
|
| 164 |
+
if key not in ignored}
|
| 165 |
+
!= {key: value for key, value in feature_metadata.items()
|
| 166 |
+
if key not in ignored}
|
| 167 |
+
):
|
| 168 |
+
raise ValueError(
|
| 169 |
+
f"existing {branch} reference feature protocol differs")
|
| 170 |
+
source_content_hash = existing["content_hash"]
|
| 171 |
+
migrated = copy.deepcopy(existing)
|
| 172 |
+
migrated["metadata"]["feature"] = feature_metadata
|
| 173 |
+
migrated["metadata"]["reference"][
|
| 174 |
+
"metadata_only_migration"] = {
|
| 175 |
+
"source_reference_content_hash": source_content_hash,
|
| 176 |
+
"numerical_tensors_preserved": True,
|
| 177 |
+
}
|
| 178 |
+
migrated["content_hash"] = compute_reference_pack_hash(migrated)
|
| 179 |
+
save_reference_pack(migrated, output, atomic=True)
|
| 180 |
+
existing = load_reference_pack(output, map_location="cpu")
|
| 181 |
+
migrated_metadata = True
|
| 182 |
+
summaries[branch] = {
|
| 183 |
+
"path": str(output),
|
| 184 |
+
"sha256": file_sha256(output),
|
| 185 |
+
"content_hash": existing["content_hash"],
|
| 186 |
+
"resumed": True,
|
| 187 |
+
"metadata_only_migration": migrated_metadata,
|
| 188 |
+
}
|
| 189 |
+
continue
|
| 190 |
+
cpu_reference = load_branch(bank, branch)
|
| 191 |
+
reference = cpu_reference.to(
|
| 192 |
+
device=torch.device(args.device), dtype=torch.float32)
|
| 193 |
+
del cpu_reference
|
| 194 |
+
metadata = {
|
| 195 |
+
"feature": feature_metadata,
|
| 196 |
+
"reference": {
|
| 197 |
+
"dataset": args.dataset_name,
|
| 198 |
+
"build_request": build_request,
|
| 199 |
+
"source": {
|
| 200 |
+
"triple_teacher_bank_content_hash": bank.content_hash,
|
| 201 |
+
"branch_name": branch,
|
| 202 |
+
"triple_teacher_bank_manifest_sha256": (
|
| 203 |
+
bank.manifest_sha256),
|
| 204 |
+
"source_manifest_sha256": (
|
| 205 |
+
args.source_manifest_sha256),
|
| 206 |
+
"selected_samples": len(bank),
|
| 207 |
+
"sample_order": "frozen_manifest_order",
|
| 208 |
+
"feature_bank_fp16_sha256": branch_manifest[
|
| 209 |
+
"feature_bank_fp16_sha256"],
|
| 210 |
+
"model_identity": branch_manifest["model_identity"],
|
| 211 |
+
},
|
| 212 |
+
},
|
| 213 |
+
}
|
| 214 |
+
pack = build_video_nystrom_reference(
|
| 215 |
+
reference,
|
| 216 |
+
num_landmarks=LANDMARKS[branch],
|
| 217 |
+
metadata=metadata,
|
| 218 |
+
kmeans_iterations=args.kmeans_iterations,
|
| 219 |
+
ridge=args.ridge,
|
| 220 |
+
chunk_size=args.chunk_size,
|
| 221 |
+
seed=0,
|
| 222 |
+
)
|
| 223 |
+
del reference
|
| 224 |
+
cpu_pack = {
|
| 225 |
+
key: (value.detach().cpu() if isinstance(value, torch.Tensor) else value)
|
| 226 |
+
for key, value in pack.items()
|
| 227 |
+
}
|
| 228 |
+
if compute_reference_pack_hash(cpu_pack) != cpu_pack["content_hash"]:
|
| 229 |
+
raise RuntimeError("reference hash changed during CPU transfer")
|
| 230 |
+
atomic_torch_save(cpu_pack, output)
|
| 231 |
+
validated = load_reference_pack(output, map_location="cpu")
|
| 232 |
+
summaries[branch] = {
|
| 233 |
+
"path": str(output),
|
| 234 |
+
"sha256": file_sha256(output),
|
| 235 |
+
"content_hash": validated["content_hash"],
|
| 236 |
+
"resumed": False,
|
| 237 |
+
}
|
| 238 |
+
del pack, cpu_pack, validated
|
| 239 |
+
if torch.cuda.is_available():
|
| 240 |
+
torch.cuda.empty_cache()
|
| 241 |
+
manifest = {
|
| 242 |
+
"schema_version": 1,
|
| 243 |
+
"bank_manifest": str(Path(args.bank_manifest).resolve()),
|
| 244 |
+
"bank_manifest_sha256": args.bank_manifest_sha256,
|
| 245 |
+
"bank_content_hash": bank.content_hash,
|
| 246 |
+
"build_request": build_request,
|
| 247 |
+
"references": summaries,
|
| 248 |
+
}
|
| 249 |
+
path = output_dir / "triple_reference_manifest.json"
|
| 250 |
+
temporary = path.with_name(f".{path.name}.tmp")
|
| 251 |
+
temporary.write_text(
|
| 252 |
+
json.dumps(manifest, sort_keys=True, indent=2) + "\n",
|
| 253 |
+
encoding="utf-8",
|
| 254 |
+
)
|
| 255 |
+
os.replace(temporary, path)
|
| 256 |
+
print(json.dumps(manifest, sort_keys=True), flush=True)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
if __name__ == "__main__":
|
| 260 |
+
main()
|
1.3B/code/tools/download_videomae_v2_large.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Stage an existing local VideoMAE-v2 Large snapshot; no network access."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import tempfile
|
| 12 |
+
import shutil
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
MODEL_ID = "VideoMAEv2-Large"
|
| 16 |
+
REVISION = "9981a9c8f77118c421e5228e1b219468a4b0238d"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def file_sha256(path: Path) -> str:
|
| 20 |
+
digest = hashlib.sha256()
|
| 21 |
+
with path.open("rb") as handle:
|
| 22 |
+
while block := handle.read(8 * 1024 * 1024):
|
| 23 |
+
digest.update(block)
|
| 24 |
+
return digest.hexdigest()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def canonical_sha256(value) -> str:
|
| 28 |
+
payload = json.dumps(
|
| 29 |
+
value, sort_keys=True, separators=(",", ":"), allow_nan=False
|
| 30 |
+
).encode("utf-8")
|
| 31 |
+
return hashlib.sha256(payload).hexdigest()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def atomic_json(path: Path, value) -> None:
|
| 35 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
descriptor, name = tempfile.mkstemp(
|
| 37 |
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
| 38 |
+
try:
|
| 39 |
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
| 40 |
+
json.dump(value, handle, sort_keys=True, indent=2)
|
| 41 |
+
handle.write("\n")
|
| 42 |
+
handle.flush()
|
| 43 |
+
os.fsync(handle.fileno())
|
| 44 |
+
os.replace(name, path)
|
| 45 |
+
finally:
|
| 46 |
+
Path(name).unlink(missing_ok=True)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main() -> None:
|
| 50 |
+
parser = argparse.ArgumentParser()
|
| 51 |
+
parser.add_argument(
|
| 52 |
+
"--output-dir",
|
| 53 |
+
default=(
|
| 54 |
+
"assets/models/"
|
| 55 |
+
"VideoMAEv2-Large"),
|
| 56 |
+
)
|
| 57 |
+
parser.add_argument("--source-dir", required=True, type=Path,
|
| 58 |
+
help="Existing local snapshot; not a hosted repository ID")
|
| 59 |
+
args = parser.parse_args()
|
| 60 |
+
output = Path(args.output_dir).expanduser().resolve()
|
| 61 |
+
source = args.source_dir.expanduser().resolve()
|
| 62 |
+
if not source.is_dir():
|
| 63 |
+
parser.error("--source-dir must be an existing local directory")
|
| 64 |
+
if source == output or source in output.parents or output in source.parents:
|
| 65 |
+
parser.error("Source and output directories must be separate and non-nested")
|
| 66 |
+
if output.exists() and (not output.is_dir() or any(output.iterdir())):
|
| 67 |
+
parser.error("--output-dir must be absent or empty")
|
| 68 |
+
if not (source / "config.json").is_file():
|
| 69 |
+
parser.error("The local snapshot must include config.json")
|
| 70 |
+
if not any(source.glob("*.safetensors")) and not any(source.glob("*.bin")):
|
| 71 |
+
parser.error("The local snapshot must include model weights")
|
| 72 |
+
shutil.copytree(source, output, dirs_exist_ok=True,
|
| 73 |
+
ignore=shutil.ignore_patterns(".git", ".cache", "__pycache__", ".DS_Store"))
|
| 74 |
+
# Existing frozen banks depend on the original manifest digest. Copy that
|
| 75 |
+
# manifest unchanged when supplied, rather than rewriting its identity.
|
| 76 |
+
existing_manifest = output / "download_manifest.json"
|
| 77 |
+
if existing_manifest.is_file():
|
| 78 |
+
manifest = json.loads(existing_manifest.read_text())
|
| 79 |
+
payload = dict(manifest)
|
| 80 |
+
saved = payload.pop("content_hash", None)
|
| 81 |
+
if manifest.get("revision") != REVISION or canonical_sha256(payload) != saved:
|
| 82 |
+
raise ValueError("Invalid pinned local snapshot manifest")
|
| 83 |
+
for item in manifest["files"]:
|
| 84 |
+
path = (output / item["path"]).resolve()
|
| 85 |
+
if (output not in path.parents or path.stat().st_size != item["size_bytes"]
|
| 86 |
+
or file_sha256(path) != item["sha256"]):
|
| 87 |
+
raise ValueError(f"Local snapshot file identity mismatch: {path}")
|
| 88 |
+
print(json.dumps({"output_dir": str(output), "file_count": len(manifest["files"]),
|
| 89 |
+
"content_hash": saved, "preserved_manifest": True}, sort_keys=True))
|
| 90 |
+
return
|
| 91 |
+
files = []
|
| 92 |
+
for path in sorted(output.rglob("*")):
|
| 93 |
+
if (
|
| 94 |
+
not path.is_file()
|
| 95 |
+
or path.name == "download_manifest.json"
|
| 96 |
+
or ".cache" in path.parts
|
| 97 |
+
):
|
| 98 |
+
continue
|
| 99 |
+
files.append({
|
| 100 |
+
"path": path.relative_to(output).as_posix(),
|
| 101 |
+
"size_bytes": path.stat().st_size,
|
| 102 |
+
"sha256": file_sha256(path),
|
| 103 |
+
})
|
| 104 |
+
payload = {
|
| 105 |
+
"schema_version": 1,
|
| 106 |
+
"model_id": MODEL_ID,
|
| 107 |
+
"revision": REVISION,
|
| 108 |
+
"files": files,
|
| 109 |
+
}
|
| 110 |
+
payload["content_hash"] = canonical_sha256(payload)
|
| 111 |
+
atomic_json(output / "download_manifest.json", payload)
|
| 112 |
+
print(json.dumps({
|
| 113 |
+
"output_dir": str(output),
|
| 114 |
+
"file_count": len(files),
|
| 115 |
+
"content_hash": payload["content_hash"],
|
| 116 |
+
}, sort_keys=True))
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
main()
|
1.3B/code/tools/extract_triple_matched_teacher_bank.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Decode each frozen 81-frame MP4 once and write a resumable triple bank."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import tempfile
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
import torch.distributed as dist
|
| 17 |
+
from safetensors import safe_open
|
| 18 |
+
from safetensors.torch import save_file
|
| 19 |
+
|
| 20 |
+
from utils.triple_video_feature_bank import (
|
| 21 |
+
BANK_KIND,
|
| 22 |
+
BANK_SCHEMA_VERSION,
|
| 23 |
+
canonical_sha256,
|
| 24 |
+
file_sha256,
|
| 25 |
+
)
|
| 26 |
+
from utils.matched_video_manifest import (
|
| 27 |
+
load_matched_video_manifest,
|
| 28 |
+
sha256_file,
|
| 29 |
+
)
|
| 30 |
+
from video_fd.encoders.dinov3_temporal_pool_adapter import (
|
| 31 |
+
DINOv3TemporalPoolAdapter,
|
| 32 |
+
)
|
| 33 |
+
from video_fd.encoders.segmented_videomae_v2 import (
|
| 34 |
+
SegmentedVideoMAEv2Ensemble,
|
| 35 |
+
)
|
| 36 |
+
from video_fd.encoders.videomae_v2_dense_metadata import (
|
| 37 |
+
canonical_videomae_v2_dense_feature_metadata,
|
| 38 |
+
uniform_segment_token_indices,
|
| 39 |
+
)
|
| 40 |
+
from video_fd.encoders.vjepa2_adapter import VJEPA2Adapter
|
| 41 |
+
from video_fd.encoders.vjepa2_dense_metadata import (
|
| 42 |
+
canonical_vjepa2_dense_feature_metadata,
|
| 43 |
+
uniform_dense_token_indices,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
VIDEOMAE_REVISION = "9981a9c8f77118c421e5228e1b219468a4b0238d"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def atomic_json(path: Path, value) -> None:
|
| 51 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
descriptor, name = tempfile.mkstemp(
|
| 53 |
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
| 54 |
+
try:
|
| 55 |
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
| 56 |
+
json.dump(value, handle, sort_keys=True, indent=2, ensure_ascii=False)
|
| 57 |
+
handle.write("\n")
|
| 58 |
+
handle.flush()
|
| 59 |
+
os.fsync(handle.fileno())
|
| 60 |
+
os.replace(name, path)
|
| 61 |
+
finally:
|
| 62 |
+
Path(name).unlink(missing_ok=True)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def tensor_sha256(tensor: torch.Tensor) -> str:
|
| 66 |
+
value = tensor.detach().cpu().contiguous()
|
| 67 |
+
return hashlib.sha256(value.numpy().tobytes()).hexdigest()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def decode_81(path: Path, *, size: int, mtime_ns: int) -> torch.Tensor:
|
| 71 |
+
stat = path.stat()
|
| 72 |
+
if stat.st_size != size or stat.st_mtime_ns != mtime_ns:
|
| 73 |
+
raise ValueError(f"frozen video stat changed: {path}")
|
| 74 |
+
import av
|
| 75 |
+
|
| 76 |
+
frames = []
|
| 77 |
+
with av.open(str(path), mode="r") as container:
|
| 78 |
+
if len(container.streams.video) != 1:
|
| 79 |
+
raise ValueError(f"expected one video stream: {path}")
|
| 80 |
+
stream = container.streams.video[0]
|
| 81 |
+
for frame in container.decode(stream):
|
| 82 |
+
array = frame.to_ndarray(format="rgb24")
|
| 83 |
+
if array.dtype != np.uint8 or array.ndim != 3 or array.shape[2] != 3:
|
| 84 |
+
raise ValueError(f"invalid RGB frame in {path}")
|
| 85 |
+
frames.append(torch.from_numpy(
|
| 86 |
+
np.ascontiguousarray(array)).permute(2, 0, 1))
|
| 87 |
+
if len(frames) != 81:
|
| 88 |
+
raise ValueError(f"{path.name} has {len(frames)} frames, expected 81")
|
| 89 |
+
shape = tuple(frames[0].shape)
|
| 90 |
+
if any(tuple(frame.shape) != shape for frame in frames):
|
| 91 |
+
raise ValueError(f"resolution changes within {path}")
|
| 92 |
+
return torch.stack(frames).float().div_(255.0).unsqueeze(0)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def load_download_manifest(model_dir: Path) -> dict:
|
| 96 |
+
path = model_dir / "download_manifest.json"
|
| 97 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 98 |
+
manifest = json.load(handle)
|
| 99 |
+
payload = dict(manifest)
|
| 100 |
+
saved = payload.pop("content_hash")
|
| 101 |
+
if (
|
| 102 |
+
manifest.get("revision") != VIDEOMAE_REVISION
|
| 103 |
+
or canonical_sha256(payload) != saved
|
| 104 |
+
):
|
| 105 |
+
raise ValueError("invalid pinned VideoMAE download manifest")
|
| 106 |
+
for item in manifest["files"]:
|
| 107 |
+
path = (model_dir / item["path"]).resolve()
|
| 108 |
+
if (
|
| 109 |
+
model_dir not in path.parents
|
| 110 |
+
or path.stat().st_size != int(item["size_bytes"])
|
| 111 |
+
or file_sha256(path) != item["sha256"]
|
| 112 |
+
):
|
| 113 |
+
raise ValueError(f"VideoMAE file identity mismatch: {path}")
|
| 114 |
+
return manifest
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def build_encoders(args, device):
|
| 118 |
+
dino = DINOv3TemporalPoolAdapter({
|
| 119 |
+
"repo_path": str(args.dinov3_repo),
|
| 120 |
+
"weight_path": str(args.dinov3_weights),
|
| 121 |
+
"source_commit": str(args.dinov3_source_commit),
|
| 122 |
+
"checkpoint_sha256": str(args.dinov3_checkpoint_sha256),
|
| 123 |
+
"source_num_frames": 81,
|
| 124 |
+
"frame_indices": (
|
| 125 |
+
0, 4, 8, 13, 17, 21, 25, 29, 34, 38,
|
| 126 |
+
42, 46, 51, 55, 59, 63, 67, 72, 76, 80),
|
| 127 |
+
"input_size": 256,
|
| 128 |
+
"patch_size": 16,
|
| 129 |
+
"spatial_axis_indices": tuple(range(16)),
|
| 130 |
+
"forward_chunk_frames": 4,
|
| 131 |
+
"geometry": "temporal_mean5_full256",
|
| 132 |
+
"temporal_groups": 4,
|
| 133 |
+
"temporal_pool_frames": 5,
|
| 134 |
+
}).to(device=device, dtype=torch.float32).eval().requires_grad_(False)
|
| 135 |
+
vj = VJEPA2Adapter({
|
| 136 |
+
"name": "vjepa2_dense",
|
| 137 |
+
"model_id": str(args.vjepa_model),
|
| 138 |
+
"input_frames": 64,
|
| 139 |
+
"dense_num_frames": 64,
|
| 140 |
+
"input_size": 256,
|
| 141 |
+
"resize_short_side": 292,
|
| 142 |
+
"crop_mode": "center",
|
| 143 |
+
"dense_only": True,
|
| 144 |
+
"dense_layer_index": 15,
|
| 145 |
+
}).to(device=device, dtype=torch.float32).eval().requires_grad_(False)
|
| 146 |
+
vm = SegmentedVideoMAEv2Ensemble({
|
| 147 |
+
"name": "videomae_v2_five_segment_dense",
|
| 148 |
+
"model_id": str(args.videomae_model),
|
| 149 |
+
"revision": VIDEOMAE_REVISION,
|
| 150 |
+
"local_files_only": True,
|
| 151 |
+
"encoder_count": 5,
|
| 152 |
+
"skip_leading_frames": 1,
|
| 153 |
+
"segment_num_frames": 16,
|
| 154 |
+
"source_num_frames": 81,
|
| 155 |
+
"input_frames": 16,
|
| 156 |
+
"dense_num_frames": 16,
|
| 157 |
+
"input_size": 224,
|
| 158 |
+
"resize_short_side": 224,
|
| 159 |
+
"crop_mode": "center",
|
| 160 |
+
"dense_only": True,
|
| 161 |
+
"dense_layer_index": 15,
|
| 162 |
+
}).to(device=device, dtype=torch.float32).eval().requires_grad_(False)
|
| 163 |
+
if (dino.dense_feature_dim, vj.dense_feature_dim, vm.dense_feature_dim) != (1024, 1024, 1024):
|
| 164 |
+
raise RuntimeError("encoder feature dimensions differ from contract")
|
| 165 |
+
return dino, vj, vm
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def main() -> None:
|
| 169 |
+
parser = argparse.ArgumentParser()
|
| 170 |
+
parser.add_argument("--manifest", required=True)
|
| 171 |
+
parser.add_argument("--manifest-sha256", required=True)
|
| 172 |
+
parser.add_argument("--source-csv-sha256", required=True)
|
| 173 |
+
parser.add_argument("--video-root", required=True)
|
| 174 |
+
parser.add_argument("--dinov3-repo", required=True)
|
| 175 |
+
parser.add_argument("--dinov3-weights", required=True)
|
| 176 |
+
parser.add_argument("--dinov3-source-commit", required=True)
|
| 177 |
+
parser.add_argument("--dinov3-checkpoint-sha256", required=True)
|
| 178 |
+
parser.add_argument("--vjepa-model", required=True)
|
| 179 |
+
parser.add_argument("--vjepa-reference-model-id", required=True)
|
| 180 |
+
parser.add_argument("--vjepa-model-sha256", required=True)
|
| 181 |
+
parser.add_argument("--videomae-model", required=True)
|
| 182 |
+
parser.add_argument("--output-dir", required=True)
|
| 183 |
+
parser.add_argument("--shard-rows", type=int, default=32)
|
| 184 |
+
parser.add_argument("--device", default="cuda:0")
|
| 185 |
+
parser.add_argument("--expected-count", type=int, default=5000)
|
| 186 |
+
args = parser.parse_args()
|
| 187 |
+
if args.shard_rows != 32:
|
| 188 |
+
raise ValueError("triple teacher bank requires shard-rows=32")
|
| 189 |
+
manifest_path = Path(args.manifest).expanduser().resolve()
|
| 190 |
+
if sha256_file(manifest_path) != args.manifest_sha256:
|
| 191 |
+
raise ValueError("source manifest SHA-256 mismatch")
|
| 192 |
+
entries, source_metadata = load_matched_video_manifest(
|
| 193 |
+
manifest_path,
|
| 194 |
+
expected_source_csv_sha256=args.source_csv_sha256,
|
| 195 |
+
expected_count=args.expected_count,
|
| 196 |
+
)
|
| 197 |
+
video_root = Path(args.video_root).expanduser().resolve()
|
| 198 |
+
output = Path(args.output_dir).expanduser().resolve()
|
| 199 |
+
shard_dir = output / "shards"
|
| 200 |
+
shard_dir.mkdir(parents=True, exist_ok=True)
|
| 201 |
+
progress_path = output / "progress.json"
|
| 202 |
+
final_path = output / "manifest.json"
|
| 203 |
+
vm_download = load_download_manifest(
|
| 204 |
+
Path(args.videomae_model).expanduser().resolve())
|
| 205 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 206 |
+
distributed = world_size > 1
|
| 207 |
+
if distributed:
|
| 208 |
+
local_rank = int(os.environ["LOCAL_RANK"])
|
| 209 |
+
torch.cuda.set_device(local_rank)
|
| 210 |
+
dist.init_process_group(backend="nccl")
|
| 211 |
+
rank = int(dist.get_rank())
|
| 212 |
+
world_size = int(dist.get_world_size())
|
| 213 |
+
device = torch.device("cuda", local_rank)
|
| 214 |
+
else:
|
| 215 |
+
rank = 0
|
| 216 |
+
device = torch.device(args.device)
|
| 217 |
+
dino, vj, vm = build_encoders(args, device)
|
| 218 |
+
vj_indices = uniform_dense_token_indices(
|
| 219 |
+
vj.dense_raw_tokens, 1024, device=device)
|
| 220 |
+
vm_indices = uniform_segment_token_indices(
|
| 221 |
+
vm.dense_raw_tokens, 256, device=device)
|
| 222 |
+
vj_contract = canonical_vjepa2_dense_feature_metadata(
|
| 223 |
+
model_id=args.vjepa_reference_model_id,
|
| 224 |
+
layer_index=15,
|
| 225 |
+
num_frames=64,
|
| 226 |
+
resize_short_side=292,
|
| 227 |
+
crop_size=256,
|
| 228 |
+
raw_tokens=vj.dense_raw_tokens,
|
| 229 |
+
num_tokens=1024,
|
| 230 |
+
feature_dim=1024,
|
| 231 |
+
token_indices=vj_indices.cpu(),
|
| 232 |
+
frame_sampling="last_contiguous",
|
| 233 |
+
source_num_frames=81,
|
| 234 |
+
)
|
| 235 |
+
vm_contract = canonical_videomae_v2_dense_feature_metadata(
|
| 236 |
+
model_id="VideoMAEv2-Large",
|
| 237 |
+
layer_index=15,
|
| 238 |
+
num_tokens=256,
|
| 239 |
+
token_indices=vm_indices.cpu(),
|
| 240 |
+
)
|
| 241 |
+
fixed = {
|
| 242 |
+
"schema_version": BANK_SCHEMA_VERSION,
|
| 243 |
+
"kind": BANK_KIND,
|
| 244 |
+
"sample_count": len(entries),
|
| 245 |
+
"source_manifest": {
|
| 246 |
+
"path": str(manifest_path),
|
| 247 |
+
"sha256": args.manifest_sha256,
|
| 248 |
+
"source_csv_sha256": args.source_csv_sha256,
|
| 249 |
+
"sample_identity_sha256": source_metadata.get(
|
| 250 |
+
"sample_identity_sha256"),
|
| 251 |
+
},
|
| 252 |
+
"branches": {
|
| 253 |
+
"dinov3": {
|
| 254 |
+
"sample_shape": [1024, 1024],
|
| 255 |
+
"dtype": "float16",
|
| 256 |
+
"feature_contract": dino.feature_metadata(),
|
| 257 |
+
"model_identity": {
|
| 258 |
+
"model_id": "dinov3_vitl16",
|
| 259 |
+
"repo_path": str(Path(args.dinov3_repo).resolve()),
|
| 260 |
+
"weight_path": str(Path(args.dinov3_weights).resolve()),
|
| 261 |
+
"source_commit": args.dinov3_source_commit,
|
| 262 |
+
"checkpoint_sha256": args.dinov3_checkpoint_sha256,
|
| 263 |
+
},
|
| 264 |
+
},
|
| 265 |
+
"vjepa2": {
|
| 266 |
+
"sample_shape": [1024, 1024],
|
| 267 |
+
"dtype": "float16",
|
| 268 |
+
"feature_contract": vj_contract,
|
| 269 |
+
"model_identity": {
|
| 270 |
+
"model_id": args.vjepa_reference_model_id,
|
| 271 |
+
"load_path": str(Path(args.vjepa_model).resolve()),
|
| 272 |
+
"sha256": args.vjepa_model_sha256,
|
| 273 |
+
},
|
| 274 |
+
},
|
| 275 |
+
"videomae_v2": {
|
| 276 |
+
"sample_shape": [1280, 1024],
|
| 277 |
+
"dtype": "float16",
|
| 278 |
+
"feature_contract": vm_contract,
|
| 279 |
+
"model_identity": {
|
| 280 |
+
"model_id": "VideoMAEv2-Large",
|
| 281 |
+
"revision": VIDEOMAE_REVISION,
|
| 282 |
+
"download_manifest_content_hash": vm_download[
|
| 283 |
+
"content_hash"],
|
| 284 |
+
},
|
| 285 |
+
},
|
| 286 |
+
},
|
| 287 |
+
"samples": [{
|
| 288 |
+
"index": entry.manifest_index,
|
| 289 |
+
"sample_id": entry.sample_id,
|
| 290 |
+
"prompt": entry.caption,
|
| 291 |
+
} for entry in entries],
|
| 292 |
+
}
|
| 293 |
+
progress_identity = canonical_sha256(fixed)
|
| 294 |
+
receipt_dir = output / "receipts"
|
| 295 |
+
receipt_dir.mkdir(parents=True, exist_ok=True)
|
| 296 |
+
shard_count = (len(entries) + args.shard_rows - 1) // args.shard_rows
|
| 297 |
+
started = time.time()
|
| 298 |
+
for shard_index in range(rank, shard_count, world_size):
|
| 299 |
+
start = shard_index * args.shard_rows
|
| 300 |
+
end = min(start + args.shard_rows, len(entries))
|
| 301 |
+
relative = f"shards/shard-{shard_index:05d}.safetensors"
|
| 302 |
+
path = output / relative
|
| 303 |
+
receipt_path = receipt_dir / f"shard-{shard_index:05d}.json"
|
| 304 |
+
if receipt_path.is_file():
|
| 305 |
+
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
|
| 306 |
+
if (
|
| 307 |
+
receipt.get("progress_identity") != progress_identity
|
| 308 |
+
or int(receipt.get("index", -1)) != shard_index
|
| 309 |
+
or int(receipt.get("sample_start", -1)) != start
|
| 310 |
+
or int(receipt.get("sample_end", -1)) != end
|
| 311 |
+
or not path.is_file()
|
| 312 |
+
or file_sha256(path) != receipt.get("sha256")
|
| 313 |
+
):
|
| 314 |
+
raise ValueError(
|
| 315 |
+
f"resumable triple shard identity mismatch: {path}")
|
| 316 |
+
print(json.dumps({
|
| 317 |
+
"rank": rank,
|
| 318 |
+
"resumed_shard": shard_index,
|
| 319 |
+
"completed": end,
|
| 320 |
+
}), flush=True)
|
| 321 |
+
continue
|
| 322 |
+
|
| 323 |
+
branch_rows = {
|
| 324 |
+
"dinov3": [],
|
| 325 |
+
"vjepa2": [],
|
| 326 |
+
"videomae_v2": [],
|
| 327 |
+
}
|
| 328 |
+
for entry in entries[start:end]:
|
| 329 |
+
video = decode_81(
|
| 330 |
+
video_root / entry.video_name,
|
| 331 |
+
size=entry.video_size_bytes,
|
| 332 |
+
mtime_ns=entry.video_mtime_ns,
|
| 333 |
+
).to(device=device, dtype=torch.float32)
|
| 334 |
+
temporal = torch.arange(
|
| 335 |
+
17, 81, device=device, dtype=torch.long).unsqueeze(0)
|
| 336 |
+
with torch.inference_mode():
|
| 337 |
+
dino_feature = dino.forward_dense_no_grad(
|
| 338 |
+
video, gradient_checkpointing=False)
|
| 339 |
+
vj_feature = vj.forward_dense(
|
| 340 |
+
video,
|
| 341 |
+
layer_index=15,
|
| 342 |
+
token_indices=vj_indices,
|
| 343 |
+
gradient_checkpointing=False,
|
| 344 |
+
temporal_window_indices=temporal,
|
| 345 |
+
)
|
| 346 |
+
vm_feature = vm.forward_dense(
|
| 347 |
+
video,
|
| 348 |
+
layer_index=15,
|
| 349 |
+
token_indices=vm_indices,
|
| 350 |
+
gradient_checkpointing=False,
|
| 351 |
+
)
|
| 352 |
+
observed = {
|
| 353 |
+
"dinov3": tuple(dino_feature.shape),
|
| 354 |
+
"vjepa2": tuple(vj_feature.shape),
|
| 355 |
+
"videomae_v2": tuple(vm_feature.shape),
|
| 356 |
+
}
|
| 357 |
+
expected = {
|
| 358 |
+
"dinov3": (1, 1024, 1024),
|
| 359 |
+
"vjepa2": (1, 1024, 1024),
|
| 360 |
+
"videomae_v2": (1, 1280, 1024),
|
| 361 |
+
}
|
| 362 |
+
if observed != expected:
|
| 363 |
+
raise RuntimeError(
|
| 364 |
+
f"triple bank row shape mismatch: {observed}")
|
| 365 |
+
branch_rows["dinov3"].append(
|
| 366 |
+
dino_feature.squeeze(0).half().cpu().contiguous())
|
| 367 |
+
branch_rows["vjepa2"].append(
|
| 368 |
+
vj_feature.squeeze(0).half().cpu().contiguous())
|
| 369 |
+
branch_rows["videomae_v2"].append(
|
| 370 |
+
vm_feature.squeeze(0).half().cpu().contiguous())
|
| 371 |
+
del video, dino_feature, vj_feature, vm_feature
|
| 372 |
+
|
| 373 |
+
tensors = {
|
| 374 |
+
name: torch.stack(rows, dim=0)
|
| 375 |
+
for name, rows in branch_rows.items()
|
| 376 |
+
}
|
| 377 |
+
ids = [entry.sample_id for entry in entries[start:end]]
|
| 378 |
+
shard_payload = {
|
| 379 |
+
"index": shard_index,
|
| 380 |
+
"sample_start": start,
|
| 381 |
+
"sample_end": end,
|
| 382 |
+
"sample_ids_sha256": canonical_sha256(ids),
|
| 383 |
+
"tensor_sha256": {
|
| 384 |
+
name: tensor_sha256(value)
|
| 385 |
+
for name, value in tensors.items()
|
| 386 |
+
},
|
| 387 |
+
"branches": fixed["branches"],
|
| 388 |
+
"source_manifest_sha256": args.manifest_sha256,
|
| 389 |
+
"progress_identity": progress_identity,
|
| 390 |
+
}
|
| 391 |
+
shard_content_hash = canonical_sha256(shard_payload)
|
| 392 |
+
temporary = path.with_name(f".{path.name}.rank{rank}.tmp")
|
| 393 |
+
save_file(tensors, str(temporary), metadata={
|
| 394 |
+
"content_hash": shard_content_hash,
|
| 395 |
+
"sample_start": str(start),
|
| 396 |
+
"sample_end": str(end),
|
| 397 |
+
"source_manifest_sha256": args.manifest_sha256,
|
| 398 |
+
"progress_identity": progress_identity,
|
| 399 |
+
})
|
| 400 |
+
os.replace(temporary, path)
|
| 401 |
+
shard = {
|
| 402 |
+
"index": shard_index,
|
| 403 |
+
"path": relative,
|
| 404 |
+
"sample_start": start,
|
| 405 |
+
"sample_end": end,
|
| 406 |
+
"sample_ids_sha256": canonical_sha256(ids),
|
| 407 |
+
"sha256": file_sha256(path),
|
| 408 |
+
"content_hash": shard_content_hash,
|
| 409 |
+
}
|
| 410 |
+
atomic_json(receipt_path, {
|
| 411 |
+
**shard,
|
| 412 |
+
"progress_identity": progress_identity,
|
| 413 |
+
"rank": rank,
|
| 414 |
+
})
|
| 415 |
+
print(json.dumps({
|
| 416 |
+
"rank": rank,
|
| 417 |
+
"completed": end,
|
| 418 |
+
"total": len(entries),
|
| 419 |
+
"elapsed_seconds": time.time() - started,
|
| 420 |
+
"shard": relative,
|
| 421 |
+
}), flush=True)
|
| 422 |
+
|
| 423 |
+
if distributed:
|
| 424 |
+
dist.barrier()
|
| 425 |
+
if rank == 0:
|
| 426 |
+
shards = []
|
| 427 |
+
for shard_index in range(shard_count):
|
| 428 |
+
receipt_path = receipt_dir / f"shard-{shard_index:05d}.json"
|
| 429 |
+
if not receipt_path.is_file():
|
| 430 |
+
raise FileNotFoundError(
|
| 431 |
+
f"missing completed shard receipt: {receipt_path}")
|
| 432 |
+
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
|
| 433 |
+
if receipt.pop("progress_identity", None) != progress_identity:
|
| 434 |
+
raise ValueError("triple shard progress identity mismatch")
|
| 435 |
+
receipt.pop("rank", None)
|
| 436 |
+
path = output / receipt["path"]
|
| 437 |
+
if file_sha256(path) != receipt["sha256"]:
|
| 438 |
+
raise ValueError(f"triple shard SHA-256 mismatch: {path}")
|
| 439 |
+
shards.append(receipt)
|
| 440 |
+
|
| 441 |
+
branch_hashers = {
|
| 442 |
+
name: hashlib.sha256() for name in fixed["branches"]
|
| 443 |
+
}
|
| 444 |
+
for shard in shards:
|
| 445 |
+
with safe_open(
|
| 446 |
+
str(output / shard["path"]),
|
| 447 |
+
framework="pt",
|
| 448 |
+
device="cpu",
|
| 449 |
+
) as handle:
|
| 450 |
+
for name in branch_hashers:
|
| 451 |
+
value = handle.get_tensor(name).contiguous()
|
| 452 |
+
branch_hashers[name].update(value.numpy().tobytes())
|
| 453 |
+
for name, hasher in branch_hashers.items():
|
| 454 |
+
fixed["branches"][name]["feature_bank_fp16_sha256"] = (
|
| 455 |
+
hasher.hexdigest())
|
| 456 |
+
final_payload = {**fixed, "shards": shards}
|
| 457 |
+
final_payload["content_hash"] = canonical_sha256(final_payload)
|
| 458 |
+
atomic_json(final_path, final_payload)
|
| 459 |
+
print(json.dumps({
|
| 460 |
+
"manifest": str(final_path),
|
| 461 |
+
"manifest_sha256": file_sha256(final_path),
|
| 462 |
+
"content_hash": final_payload["content_hash"],
|
| 463 |
+
"samples": len(entries),
|
| 464 |
+
"world_size": world_size,
|
| 465 |
+
}, sort_keys=True), flush=True)
|
| 466 |
+
if distributed:
|
| 467 |
+
dist.barrier()
|
| 468 |
+
dist.destroy_process_group()
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
if __name__ == "__main__":
|
| 472 |
+
main()
|
1.3B/code/tools/precompute_dinov3_dense_nystrom_reference.py
ADDED
|
@@ -0,0 +1,1160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Build the fixed DINOv3 ViT-L/16 whole-video Nyström reference.
|
| 3 |
+
|
| 4 |
+
The teacher and objective are unchanged from the paired V-JEPA2 experiment:
|
| 5 |
+
the final latent from every row of the 1,537-video CausVid ODE LMDB is decoded
|
| 6 |
+
with the frozen Wan VAE, then represented by a frozen DINOv3 image encoder.
|
| 7 |
+
The DINOv3 feature map is fixed to 16 uniformly distributed source frames and
|
| 8 |
+
an 8x8 subset of the 16x16 patch grid per frame. Frame-major concatenation
|
| 9 |
+
therefore produces one aligned ``[16 * 64, 1024]`` matrix per video.
|
| 10 |
+
|
| 11 |
+
Every torchrun rank atomically writes one indexed FP16 feature shard. Rank 0
|
| 12 |
+
validates and merges all shards in LMDB-index order, constructs the existing
|
| 13 |
+
video-matrix Nyström objective (m=256, seed=0), atomically publishes the pack,
|
| 14 |
+
and reloads the *final path* for validation. Temporary shards are removed only
|
| 15 |
+
after that final validation succeeds; failed runs retain them for diagnosis.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
from datetime import timedelta
|
| 22 |
+
import gc
|
| 23 |
+
import hashlib
|
| 24 |
+
import json
|
| 25 |
+
import math
|
| 26 |
+
import os
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
import shutil
|
| 29 |
+
import subprocess
|
| 30 |
+
import sys
|
| 31 |
+
import tempfile
|
| 32 |
+
import time
|
| 33 |
+
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple
|
| 34 |
+
|
| 35 |
+
import torch
|
| 36 |
+
import torch.distributed as dist
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 40 |
+
if str(REPO_ROOT) not in sys.path:
|
| 41 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
TOOL_FORMAT_VERSION = 1
|
| 45 |
+
MIB = 1024 ** 2
|
| 46 |
+
EXPECTED_TEACHER_COUNT = 1537
|
| 47 |
+
EXPECTED_SOURCE_COMMIT = "6876159a11b4df116f30f667f8c9888617df0751"
|
| 48 |
+
SOURCE_NUM_FRAMES = 81
|
| 49 |
+
FRAME_INDICES = (0, 5, 11, 16, 21, 27, 32, 37, 43, 48, 53, 59, 64, 69, 75, 80)
|
| 50 |
+
INPUT_SIZE = 256
|
| 51 |
+
PATCH_SIZE = 16
|
| 52 |
+
PATCH_GRID_SIZE = 16
|
| 53 |
+
SPATIAL_AXIS_INDICES = (0, 2, 4, 6, 9, 11, 13, 15)
|
| 54 |
+
SPATIAL_TOKEN_INDICES = tuple(
|
| 55 |
+
row * PATCH_GRID_SIZE + column
|
| 56 |
+
for row in SPATIAL_AXIS_INDICES
|
| 57 |
+
for column in SPATIAL_AXIS_INDICES
|
| 58 |
+
)
|
| 59 |
+
PER_FRAME_TOKENS = 64
|
| 60 |
+
TOTAL_TOKENS = len(FRAME_INDICES) * PER_FRAME_TOKENS
|
| 61 |
+
FEATURE_DIM = 1024
|
| 62 |
+
FORWARD_CHUNK_FRAMES = 4
|
| 63 |
+
NUM_LANDMARKS = 256
|
| 64 |
+
NYSTROM_SEED = 0
|
| 65 |
+
MEDIAN_MAX_SAMPLES = EXPECTED_TEACHER_COUNT
|
| 66 |
+
KMEANS_ITERATIONS = 20
|
| 67 |
+
NYSTROM_RIDGE = 1.0e-6
|
| 68 |
+
KERNEL_CHUNK_SIZE = 32
|
| 69 |
+
WAN_VAE_MODEL_NAME = "Wan2.1-T2V-1.3B"
|
| 70 |
+
WAN_VAE_MEAN = (
|
| 71 |
+
-0.7571, -0.7089, -0.9113, 0.1075, -0.1745, 0.9653, -0.1517, 1.5508,
|
| 72 |
+
0.4134, -0.0715, 0.5517, -0.3632, -0.1922, -0.9497, 0.2503, -0.2921,
|
| 73 |
+
)
|
| 74 |
+
WAN_VAE_STD = (
|
| 75 |
+
2.8184, 1.4541, 2.3275, 2.6558, 1.2196, 1.7708, 2.6052, 2.0743,
|
| 76 |
+
3.2687, 2.1526, 2.8652, 1.5579, 1.6382, 1.1253, 2.8251, 1.9160,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 81 |
+
parser = argparse.ArgumentParser(
|
| 82 |
+
description=(
|
| 83 |
+
"Build the fixed full-1537 DINOv3 ViT-L/16 video-matrix "
|
| 84 |
+
"Nyström teacher reference."
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
parser.add_argument("--teacher-lmdb", type=Path, required=True)
|
| 88 |
+
parser.add_argument("--repo-path", type=Path, required=True)
|
| 89 |
+
parser.add_argument("--weight-path", type=Path, required=True)
|
| 90 |
+
parser.add_argument(
|
| 91 |
+
"--source-commit", type=str, default=EXPECTED_SOURCE_COMMIT)
|
| 92 |
+
parser.add_argument("--output", type=Path, required=True)
|
| 93 |
+
parser.add_argument("--batch-size", type=int, default=1)
|
| 94 |
+
parser.add_argument(
|
| 95 |
+
"--expected-teacher-count", type=int, default=EXPECTED_TEACHER_COUNT)
|
| 96 |
+
parser.add_argument(
|
| 97 |
+
"--forward-chunk-frames", type=int, default=FORWARD_CHUNK_FRAMES)
|
| 98 |
+
parser.add_argument("--num-landmarks", type=int, default=NUM_LANDMARKS)
|
| 99 |
+
parser.add_argument("--seed", type=int, default=NYSTROM_SEED)
|
| 100 |
+
parser.add_argument(
|
| 101 |
+
"--median-max-samples", type=int, default=MEDIAN_MAX_SAMPLES)
|
| 102 |
+
parser.add_argument("--bandwidth", type=float, default=None)
|
| 103 |
+
parser.add_argument(
|
| 104 |
+
"--kmeans-iterations", type=int, default=KMEANS_ITERATIONS)
|
| 105 |
+
parser.add_argument("--ridge", type=float, default=NYSTROM_RIDGE)
|
| 106 |
+
parser.add_argument("--chunk-size", type=int, default=KERNEL_CHUNK_SIZE)
|
| 107 |
+
parser.add_argument("--tmp-dir", type=Path, default=None)
|
| 108 |
+
parser.add_argument("--overwrite", action="store_true")
|
| 109 |
+
parser.add_argument("--log-every", type=int, default=25)
|
| 110 |
+
parser.add_argument("--distributed-timeout-minutes", type=int, default=360)
|
| 111 |
+
parser.add_argument("--dry-run", action="store_true")
|
| 112 |
+
return parser
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _validate_args(args: argparse.Namespace) -> None:
|
| 116 |
+
positive = {
|
| 117 |
+
"batch_size": args.batch_size,
|
| 118 |
+
"expected_teacher_count": args.expected_teacher_count,
|
| 119 |
+
"forward_chunk_frames": args.forward_chunk_frames,
|
| 120 |
+
"num_landmarks": args.num_landmarks,
|
| 121 |
+
"median_max_samples": args.median_max_samples,
|
| 122 |
+
"kmeans_iterations": args.kmeans_iterations,
|
| 123 |
+
"chunk_size": args.chunk_size,
|
| 124 |
+
"distributed_timeout_minutes": args.distributed_timeout_minutes,
|
| 125 |
+
}
|
| 126 |
+
invalid = {name: value for name, value in positive.items() if int(value) <= 0}
|
| 127 |
+
if invalid:
|
| 128 |
+
raise ValueError(f"arguments must be positive: {invalid}")
|
| 129 |
+
if int(args.expected_teacher_count) != EXPECTED_TEACHER_COUNT:
|
| 130 |
+
raise ValueError(
|
| 131 |
+
"the canonical reference requires exactly "
|
| 132 |
+
f"{EXPECTED_TEACHER_COUNT} teachers")
|
| 133 |
+
if int(args.forward_chunk_frames) != FORWARD_CHUNK_FRAMES:
|
| 134 |
+
raise ValueError(
|
| 135 |
+
f"--forward-chunk-frames must be {FORWARD_CHUNK_FRAMES}")
|
| 136 |
+
if int(args.num_landmarks) != NUM_LANDMARKS:
|
| 137 |
+
raise ValueError(f"--num-landmarks must be {NUM_LANDMARKS}")
|
| 138 |
+
if int(args.seed) != NYSTROM_SEED:
|
| 139 |
+
raise ValueError(f"--seed must be {NYSTROM_SEED}")
|
| 140 |
+
if int(args.median_max_samples) != MEDIAN_MAX_SAMPLES:
|
| 141 |
+
raise ValueError(
|
| 142 |
+
f"--median-max-samples must be {MEDIAN_MAX_SAMPLES}")
|
| 143 |
+
if args.bandwidth is not None:
|
| 144 |
+
raise ValueError(
|
| 145 |
+
"the canonical reference requires median-estimated bandwidth")
|
| 146 |
+
if int(args.kmeans_iterations) != KMEANS_ITERATIONS:
|
| 147 |
+
raise ValueError(
|
| 148 |
+
f"--kmeans-iterations must be {KMEANS_ITERATIONS}")
|
| 149 |
+
if not math.isclose(
|
| 150 |
+
float(args.ridge), NYSTROM_RIDGE, rel_tol=0.0, abs_tol=0.0
|
| 151 |
+
):
|
| 152 |
+
raise ValueError(f"--ridge must be {NYSTROM_RIDGE}")
|
| 153 |
+
if int(args.chunk_size) != KERNEL_CHUNK_SIZE:
|
| 154 |
+
raise ValueError(f"--chunk-size must be {KERNEL_CHUNK_SIZE}")
|
| 155 |
+
if str(args.source_commit) != EXPECTED_SOURCE_COMMIT:
|
| 156 |
+
raise ValueError(
|
| 157 |
+
"--source-commit must pin the approved DINOv3 source commit "
|
| 158 |
+
f"{EXPECTED_SOURCE_COMMIT}")
|
| 159 |
+
if float(args.ridge) <= 0.0 or not math.isfinite(float(args.ridge)):
|
| 160 |
+
raise ValueError("--ridge must be finite and positive")
|
| 161 |
+
if args.bandwidth is not None and (
|
| 162 |
+
float(args.bandwidth) <= 0.0
|
| 163 |
+
or not math.isfinite(float(args.bandwidth))
|
| 164 |
+
):
|
| 165 |
+
raise ValueError("--bandwidth must be finite and positive")
|
| 166 |
+
if int(args.log_every) < 0:
|
| 167 |
+
raise ValueError("--log-every must be non-negative")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _git_head(repo_path: Path) -> str:
|
| 171 |
+
try:
|
| 172 |
+
result = subprocess.run(
|
| 173 |
+
["git", "-C", str(repo_path), "rev-parse", "HEAD"],
|
| 174 |
+
check=True,
|
| 175 |
+
capture_output=True,
|
| 176 |
+
text=True,
|
| 177 |
+
)
|
| 178 |
+
except (OSError, subprocess.CalledProcessError) as exc:
|
| 179 |
+
raise RuntimeError(
|
| 180 |
+
f"failed to inspect DINOv3 source checkout {repo_path}") from exc
|
| 181 |
+
return result.stdout.strip()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _git_source_status(repo_path: Path) -> str:
|
| 185 |
+
try:
|
| 186 |
+
result = subprocess.run(
|
| 187 |
+
[
|
| 188 |
+
"git",
|
| 189 |
+
"-C",
|
| 190 |
+
str(repo_path),
|
| 191 |
+
"status",
|
| 192 |
+
"--porcelain",
|
| 193 |
+
"--untracked-files=all",
|
| 194 |
+
],
|
| 195 |
+
check=True,
|
| 196 |
+
capture_output=True,
|
| 197 |
+
text=True,
|
| 198 |
+
)
|
| 199 |
+
except (OSError, subprocess.CalledProcessError) as exc:
|
| 200 |
+
raise RuntimeError(
|
| 201 |
+
f"failed to inspect DINOv3 source worktree {repo_path}") from exc
|
| 202 |
+
return result.stdout
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _inspect_assets(args: argparse.Namespace) -> Dict[str, Any]:
|
| 206 |
+
repo_path = args.repo_path.expanduser().resolve()
|
| 207 |
+
weight_path = args.weight_path.expanduser().resolve()
|
| 208 |
+
if not repo_path.is_dir():
|
| 209 |
+
raise FileNotFoundError(f"DINOv3 source directory not found: {repo_path}")
|
| 210 |
+
if not weight_path.is_file():
|
| 211 |
+
raise FileNotFoundError(f"DINOv3 weight file not found: {weight_path}")
|
| 212 |
+
source_commit = _git_head(repo_path)
|
| 213 |
+
if source_commit != str(args.source_commit):
|
| 214 |
+
raise ValueError(
|
| 215 |
+
"DINOv3 source checkout commit mismatch: "
|
| 216 |
+
f"{source_commit} != {args.source_commit}")
|
| 217 |
+
source_status = _git_source_status(repo_path)
|
| 218 |
+
if source_status.strip():
|
| 219 |
+
raise ValueError(
|
| 220 |
+
"DINOv3 source checkout has worktree changes; refusing "
|
| 221 |
+
"to build a reference from unpinned source")
|
| 222 |
+
stat = weight_path.stat()
|
| 223 |
+
return {
|
| 224 |
+
"repo_path": str(repo_path),
|
| 225 |
+
"weight_path": str(weight_path),
|
| 226 |
+
"source_commit": source_commit,
|
| 227 |
+
"weight_size_bytes": int(stat.st_size),
|
| 228 |
+
"weight_mtime_ns": int(stat.st_mtime_ns),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _inspect_lmdb(path: Path) -> Dict[str, Any]:
|
| 233 |
+
try:
|
| 234 |
+
import lmdb
|
| 235 |
+
except ImportError as exc:
|
| 236 |
+
raise ImportError(
|
| 237 |
+
"python-lmdb is required; use the same Python environment as training"
|
| 238 |
+
) from exc
|
| 239 |
+
from utils.lmdb import get_array_shape_from_lmdb
|
| 240 |
+
|
| 241 |
+
resolved = path.expanduser().resolve()
|
| 242 |
+
if not resolved.is_dir():
|
| 243 |
+
raise FileNotFoundError(f"teacher LMDB directory not found: {resolved}")
|
| 244 |
+
env = lmdb.open(
|
| 245 |
+
str(resolved), readonly=True, lock=False, readahead=False, meminit=False)
|
| 246 |
+
try:
|
| 247 |
+
latents_shape = tuple(
|
| 248 |
+
int(value) for value in get_array_shape_from_lmdb(env, "latents"))
|
| 249 |
+
prompts_shape = tuple(
|
| 250 |
+
int(value) for value in get_array_shape_from_lmdb(env, "prompts"))
|
| 251 |
+
finally:
|
| 252 |
+
env.close()
|
| 253 |
+
if len(latents_shape) != 6:
|
| 254 |
+
raise ValueError(
|
| 255 |
+
"teacher LMDB latents must be [N,trajectory,T,C,H,W], got "
|
| 256 |
+
f"{latents_shape}")
|
| 257 |
+
if prompts_shape != (latents_shape[0],):
|
| 258 |
+
raise ValueError(
|
| 259 |
+
f"teacher prompts/latents count mismatch: {prompts_shape} vs "
|
| 260 |
+
f"{latents_shape}")
|
| 261 |
+
data_path = resolved / "data.mdb"
|
| 262 |
+
stat = data_path.stat()
|
| 263 |
+
return {
|
| 264 |
+
"path": str(resolved),
|
| 265 |
+
"latents_shape": latents_shape,
|
| 266 |
+
"prompts_shape": prompts_shape,
|
| 267 |
+
"data_mdb_size_bytes": int(stat.st_size),
|
| 268 |
+
"data_mdb_mtime_ns": int(stat.st_mtime_ns),
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _rank_indices(count: int, rank: int, world_size: int) -> range:
|
| 273 |
+
return range(int(rank), int(count), int(world_size))
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _required_temp_bytes(count: int) -> int:
|
| 277 |
+
feature_bytes = int(count) * TOTAL_TOKENS * FEATURE_DIM * 2
|
| 278 |
+
return int(math.ceil(feature_bytes * 1.15)) + 64 * MIB
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _required_output_bytes() -> int:
|
| 282 |
+
landmark_bytes = NUM_LANDMARKS * TOTAL_TOKENS * FEATURE_DIM * 4
|
| 283 |
+
return int(math.ceil(landmark_bytes * 1.25)) + 32 * MIB
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _choose_temp_parent(
|
| 287 |
+
requested: Optional[Path], required_bytes: int
|
| 288 |
+
) -> Tuple[Path, int]:
|
| 289 |
+
candidates: Sequence[Path] = (
|
| 290 |
+
(requested.expanduser(),)
|
| 291 |
+
if requested is not None
|
| 292 |
+
else (Path("/dev/shm"), Path(tempfile.gettempdir()))
|
| 293 |
+
)
|
| 294 |
+
errors = []
|
| 295 |
+
for candidate in candidates:
|
| 296 |
+
try:
|
| 297 |
+
candidate = candidate.resolve()
|
| 298 |
+
if not candidate.is_dir():
|
| 299 |
+
errors.append(f"{candidate}: not a directory")
|
| 300 |
+
continue
|
| 301 |
+
if not os.access(candidate, os.W_OK | os.X_OK):
|
| 302 |
+
errors.append(f"{candidate}: not writable")
|
| 303 |
+
continue
|
| 304 |
+
free = int(shutil.disk_usage(candidate).free)
|
| 305 |
+
if free < required_bytes:
|
| 306 |
+
errors.append(
|
| 307 |
+
f"{candidate}: {free / MIB:.1f} MiB free, "
|
| 308 |
+
f"{required_bytes / MIB:.1f} MiB required")
|
| 309 |
+
continue
|
| 310 |
+
return candidate, free
|
| 311 |
+
except OSError as exc:
|
| 312 |
+
errors.append(f"{candidate}: {exc}")
|
| 313 |
+
raise RuntimeError(
|
| 314 |
+
"no temporary shard directory has sufficient capacity: "
|
| 315 |
+
+ "; ".join(errors))
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _check_output(
|
| 319 |
+
output: Path, *, required_bytes: int, overwrite: bool
|
| 320 |
+
) -> Dict[str, Any]:
|
| 321 |
+
output = output.expanduser().resolve()
|
| 322 |
+
if output.exists() and not overwrite:
|
| 323 |
+
raise FileExistsError(
|
| 324 |
+
f"output already exists: {output}; pass --overwrite to replace it")
|
| 325 |
+
existing = output.parent
|
| 326 |
+
while not existing.exists() and existing != existing.parent:
|
| 327 |
+
existing = existing.parent
|
| 328 |
+
if not existing.is_dir():
|
| 329 |
+
raise FileNotFoundError(
|
| 330 |
+
f"no existing output parent for capacity check: {output.parent}")
|
| 331 |
+
free = int(shutil.disk_usage(existing).free)
|
| 332 |
+
if free < required_bytes:
|
| 333 |
+
raise RuntimeError(
|
| 334 |
+
f"output filesystem has only {free / MIB:.1f} MiB free; "
|
| 335 |
+
f"{required_bytes / MIB:.1f} MiB required")
|
| 336 |
+
return {
|
| 337 |
+
"path": str(output),
|
| 338 |
+
"capacity_parent": str(existing),
|
| 339 |
+
"free_bytes": free,
|
| 340 |
+
"required_bytes": required_bytes,
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _plan(args: argparse.Namespace, world_size: int) -> Dict[str, Any]:
|
| 345 |
+
lmdb = _inspect_lmdb(args.teacher_lmdb)
|
| 346 |
+
count = int(lmdb["latents_shape"][0])
|
| 347 |
+
if count != EXPECTED_TEACHER_COUNT:
|
| 348 |
+
raise ValueError(
|
| 349 |
+
"canonical DINOv3 reference requires the complete 1,537-row LMDB; "
|
| 350 |
+
f"found {count} rows")
|
| 351 |
+
if int(args.num_landmarks) > count:
|
| 352 |
+
raise ValueError("number of landmarks exceeds teacher population")
|
| 353 |
+
assets = _inspect_assets(args)
|
| 354 |
+
temp_required = _required_temp_bytes(count)
|
| 355 |
+
temp_parent, temp_free = _choose_temp_parent(args.tmp_dir, temp_required)
|
| 356 |
+
output = _check_output(
|
| 357 |
+
args.output,
|
| 358 |
+
required_bytes=_required_output_bytes(),
|
| 359 |
+
overwrite=bool(args.overwrite),
|
| 360 |
+
)
|
| 361 |
+
return {
|
| 362 |
+
"tool_format_version": TOOL_FORMAT_VERSION,
|
| 363 |
+
"teacher_lmdb": lmdb,
|
| 364 |
+
"assets": assets,
|
| 365 |
+
"selected_samples": count,
|
| 366 |
+
"world_size": int(world_size),
|
| 367 |
+
"samples_per_rank": [
|
| 368 |
+
len(_rank_indices(count, rank, world_size))
|
| 369 |
+
for rank in range(world_size)
|
| 370 |
+
],
|
| 371 |
+
"feature_geometry": {
|
| 372 |
+
"source_num_frames": SOURCE_NUM_FRAMES,
|
| 373 |
+
"frame_indices": list(FRAME_INDICES),
|
| 374 |
+
"input_size": INPUT_SIZE,
|
| 375 |
+
"patch_size": PATCH_SIZE,
|
| 376 |
+
"patch_grid_size": PATCH_GRID_SIZE,
|
| 377 |
+
"spatial_axis_indices": list(SPATIAL_AXIS_INDICES),
|
| 378 |
+
"spatial_token_indices": list(SPATIAL_TOKEN_INDICES),
|
| 379 |
+
"raw_tokens_per_frame": PATCH_GRID_SIZE ** 2,
|
| 380 |
+
"per_frame_tokens": PER_FRAME_TOKENS,
|
| 381 |
+
"num_tokens": TOTAL_TOKENS,
|
| 382 |
+
"feature_dim": FEATURE_DIM,
|
| 383 |
+
"sample_shape": [TOTAL_TOKENS, FEATURE_DIM],
|
| 384 |
+
"concat_order": "frame_major_then_spatial_row_major",
|
| 385 |
+
},
|
| 386 |
+
"num_landmarks": NUM_LANDMARKS,
|
| 387 |
+
"temporary": {
|
| 388 |
+
"parent": str(temp_parent),
|
| 389 |
+
"required_bytes": temp_required,
|
| 390 |
+
"free_bytes": temp_free,
|
| 391 |
+
},
|
| 392 |
+
"output": output,
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _setup_distributed(timeout_minutes: int) -> Tuple[torch.device, int, int]:
|
| 397 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 398 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 399 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 400 |
+
if not torch.cuda.is_available():
|
| 401 |
+
raise RuntimeError(
|
| 402 |
+
"reference extraction requires CUDA; use --dry-run on CPU")
|
| 403 |
+
torch.cuda.set_device(local_rank)
|
| 404 |
+
if world_size > 1:
|
| 405 |
+
dist.init_process_group(
|
| 406 |
+
backend="nccl",
|
| 407 |
+
timeout=timedelta(minutes=int(timeout_minutes)),
|
| 408 |
+
)
|
| 409 |
+
rank = dist.get_rank()
|
| 410 |
+
world_size = dist.get_world_size()
|
| 411 |
+
return torch.device("cuda", local_rank), rank, world_size
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _barrier() -> None:
|
| 415 |
+
if dist.is_available() and dist.is_initialized():
|
| 416 |
+
dist.barrier()
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def _broadcast_rank0_object(value: Any, rank: int) -> Any:
|
| 420 |
+
if not (dist.is_available() and dist.is_initialized()):
|
| 421 |
+
return value
|
| 422 |
+
payload = [value if rank == 0 else None]
|
| 423 |
+
dist.broadcast_object_list(payload, src=0)
|
| 424 |
+
return payload[0]
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _atomic_torch_save(value: Any, path: Path) -> None:
|
| 428 |
+
temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
| 429 |
+
try:
|
| 430 |
+
torch.save(value, temporary)
|
| 431 |
+
os.replace(temporary, path)
|
| 432 |
+
finally:
|
| 433 |
+
if temporary.exists():
|
| 434 |
+
temporary.unlink()
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _canonical_sha256(value: Dict[str, Any]) -> str:
|
| 438 |
+
payload = json.dumps(
|
| 439 |
+
value,
|
| 440 |
+
sort_keys=True,
|
| 441 |
+
separators=(",", ":"),
|
| 442 |
+
ensure_ascii=True,
|
| 443 |
+
allow_nan=False,
|
| 444 |
+
).encode("utf-8")
|
| 445 |
+
return hashlib.sha256(payload).hexdigest()
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def _file_identity(path: Path) -> Dict[str, Any]:
|
| 449 |
+
resolved = path.expanduser().resolve(strict=True)
|
| 450 |
+
if not resolved.is_file():
|
| 451 |
+
raise ValueError(f"identity target is not a regular file: {resolved}")
|
| 452 |
+
before = resolved.stat()
|
| 453 |
+
digest = hashlib.sha256()
|
| 454 |
+
with resolved.open("rb") as handle:
|
| 455 |
+
while True:
|
| 456 |
+
block = handle.read(8 << 20)
|
| 457 |
+
if not block:
|
| 458 |
+
break
|
| 459 |
+
digest.update(block)
|
| 460 |
+
after = resolved.stat()
|
| 461 |
+
if (
|
| 462 |
+
int(before.st_size) != int(after.st_size)
|
| 463 |
+
or int(before.st_mtime_ns) != int(after.st_mtime_ns)
|
| 464 |
+
):
|
| 465 |
+
raise RuntimeError(f"identity target changed while hashing: {resolved}")
|
| 466 |
+
return {
|
| 467 |
+
"path": str(resolved),
|
| 468 |
+
"size_bytes": int(after.st_size),
|
| 469 |
+
"mtime_ns": int(after.st_mtime_ns),
|
| 470 |
+
"sha256": digest.hexdigest(),
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _vae_identity() -> Dict[str, Any]:
|
| 475 |
+
"""Resolve the exact Wan decoder used before DINOv3 extraction."""
|
| 476 |
+
from utils.paths import model_path
|
| 477 |
+
|
| 478 |
+
weights = model_path(WAN_VAE_MODEL_NAME) / "Wan2.1_VAE.pth"
|
| 479 |
+
identity = {
|
| 480 |
+
"wrapper": "utils.wan_wrapper.WanVAEWrapper",
|
| 481 |
+
"wrapper_source": _file_identity(REPO_ROOT / "utils" / "wan_wrapper.py"),
|
| 482 |
+
"model_name": WAN_VAE_MODEL_NAME,
|
| 483 |
+
"weights": _file_identity(weights),
|
| 484 |
+
"latent_channels": 16,
|
| 485 |
+
"latent_mean": list(WAN_VAE_MEAN),
|
| 486 |
+
"latent_std": list(WAN_VAE_STD),
|
| 487 |
+
"mean_std_contract": "WanVAEWrapper.inline_constants.v1",
|
| 488 |
+
"compute_dtype": "bfloat16",
|
| 489 |
+
"decode_use_cache": False,
|
| 490 |
+
"decode_output_range": "[-1,1]",
|
| 491 |
+
}
|
| 492 |
+
identity["identity_sha256"] = _canonical_sha256(identity)
|
| 493 |
+
return identity
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def _validate_reference_current_assets(
|
| 497 |
+
pack: Mapping[str, Any],
|
| 498 |
+
*,
|
| 499 |
+
repo_path: Path,
|
| 500 |
+
weight_path: Path,
|
| 501 |
+
teacher_lmdb: Path,
|
| 502 |
+
) -> Dict[str, Any]:
|
| 503 |
+
"""Require a validated reference to match every current production asset.
|
| 504 |
+
|
| 505 |
+
Callers must first run ``load_reference_pack`` (which verifies schema and
|
| 506 |
+
content hash). This second gate prevents a valid but stale pack from being
|
| 507 |
+
accepted after the DINO checkpoint, Wan VAE, or teacher LMDB changes.
|
| 508 |
+
"""
|
| 509 |
+
if not isinstance(pack, Mapping):
|
| 510 |
+
raise TypeError("reference pack must be a mapping")
|
| 511 |
+
metadata = pack.get("metadata")
|
| 512 |
+
if not isinstance(metadata, Mapping):
|
| 513 |
+
raise ValueError("reference pack metadata is missing")
|
| 514 |
+
feature = metadata.get("feature")
|
| 515 |
+
reference = metadata.get("reference")
|
| 516 |
+
if not isinstance(feature, Mapping) or not isinstance(reference, Mapping):
|
| 517 |
+
raise ValueError("reference feature/source metadata is missing")
|
| 518 |
+
source = reference.get("source")
|
| 519 |
+
saved_vae = reference.get("vae")
|
| 520 |
+
if not isinstance(source, Mapping) or not isinstance(saved_vae, Mapping):
|
| 521 |
+
raise ValueError("reference source or Wan VAE identity is missing")
|
| 522 |
+
|
| 523 |
+
from video_fd.encoders.dinov3_dense_metadata import file_sha256
|
| 524 |
+
|
| 525 |
+
resolved_repo = repo_path.expanduser().resolve(strict=True)
|
| 526 |
+
resolved_weight = weight_path.expanduser().resolve(strict=True)
|
| 527 |
+
current_weight_sha256 = file_sha256(resolved_weight)
|
| 528 |
+
expected_feature_identity = {
|
| 529 |
+
"repo_path": str(resolved_repo),
|
| 530 |
+
"weight_path": str(resolved_weight),
|
| 531 |
+
"weight_sha256": current_weight_sha256,
|
| 532 |
+
}
|
| 533 |
+
feature_mismatches = {
|
| 534 |
+
key: {"saved": feature.get(key), "current": value}
|
| 535 |
+
for key, value in expected_feature_identity.items()
|
| 536 |
+
if feature.get(key) != value
|
| 537 |
+
}
|
| 538 |
+
if feature_mismatches:
|
| 539 |
+
raise ValueError(
|
| 540 |
+
"reference DINOv3 asset identity does not match current assets: "
|
| 541 |
+
f"{feature_mismatches}")
|
| 542 |
+
|
| 543 |
+
current_vae = _vae_identity()
|
| 544 |
+
if dict(saved_vae) != current_vae:
|
| 545 |
+
raise ValueError(
|
| 546 |
+
"reference Wan VAE identity does not match current assets")
|
| 547 |
+
|
| 548 |
+
current_lmdb = _inspect_lmdb(teacher_lmdb)
|
| 549 |
+
expected_source_identity = {
|
| 550 |
+
"lmdb_path": current_lmdb["path"],
|
| 551 |
+
"latents_shape": list(current_lmdb["latents_shape"]),
|
| 552 |
+
"prompts_shape": list(current_lmdb["prompts_shape"]),
|
| 553 |
+
"selected_samples": int(current_lmdb["latents_shape"][0]),
|
| 554 |
+
"data_mdb_size_bytes": int(current_lmdb["data_mdb_size_bytes"]),
|
| 555 |
+
"data_mdb_mtime_ns": int(current_lmdb["data_mdb_mtime_ns"]),
|
| 556 |
+
}
|
| 557 |
+
source_mismatches = {
|
| 558 |
+
key: {"saved": source.get(key), "current": value}
|
| 559 |
+
for key, value in expected_source_identity.items()
|
| 560 |
+
if source.get(key) != value
|
| 561 |
+
}
|
| 562 |
+
if source_mismatches:
|
| 563 |
+
raise ValueError(
|
| 564 |
+
"reference teacher LMDB identity does not match current assets: "
|
| 565 |
+
f"{source_mismatches}")
|
| 566 |
+
return {
|
| 567 |
+
"dinov3_weight_sha256": current_weight_sha256,
|
| 568 |
+
"vae_identity_sha256": current_vae["identity_sha256"],
|
| 569 |
+
"teacher_lmdb": current_lmdb,
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def _prompt_entry_digest(index: int, prompt: str) -> bytes:
|
| 574 |
+
digest = hashlib.sha256()
|
| 575 |
+
digest.update(int(index).to_bytes(8, byteorder="little", signed=False))
|
| 576 |
+
digest.update(b"\0")
|
| 577 |
+
digest.update(str(prompt).encode("utf-8"))
|
| 578 |
+
return digest.digest()
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
def _load_dense_encoder(args: argparse.Namespace, device: torch.device):
|
| 582 |
+
"""The sole runtime boundary with the separately-owned DINOv3 adapter."""
|
| 583 |
+
from video_fd.encoders.dinov3_adapter import DINOv3DenseAdapter
|
| 584 |
+
|
| 585 |
+
encoder = DINOv3DenseAdapter({
|
| 586 |
+
"name": "dinov3_dense",
|
| 587 |
+
"repo_path": str(args.repo_path.expanduser().resolve()),
|
| 588 |
+
"weight_path": str(args.weight_path.expanduser().resolve()),
|
| 589 |
+
"source_commit": str(args.source_commit),
|
| 590 |
+
"source_num_frames": SOURCE_NUM_FRAMES,
|
| 591 |
+
"frame_indices": list(FRAME_INDICES),
|
| 592 |
+
"input_size": INPUT_SIZE,
|
| 593 |
+
"patch_size": PATCH_SIZE,
|
| 594 |
+
"patch_grid_size": PATCH_GRID_SIZE,
|
| 595 |
+
"spatial_axis_indices": list(SPATIAL_AXIS_INDICES),
|
| 596 |
+
"forward_chunk_frames": int(args.forward_chunk_frames),
|
| 597 |
+
"compute_dtype": "float32",
|
| 598 |
+
})
|
| 599 |
+
return encoder.to(device=device, dtype=torch.float32).eval().requires_grad_(False)
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
@torch.no_grad()
|
| 603 |
+
def _extract_rank_shard(
|
| 604 |
+
args: argparse.Namespace,
|
| 605 |
+
plan: Dict[str, Any],
|
| 606 |
+
*,
|
| 607 |
+
device: torch.device,
|
| 608 |
+
rank: int,
|
| 609 |
+
world_size: int,
|
| 610 |
+
shard_path: Path,
|
| 611 |
+
vae_identity: Dict[str, Any],
|
| 612 |
+
) -> None:
|
| 613 |
+
from utils.dataset import ODERegressionLMDBDataset
|
| 614 |
+
from utils.wan_wrapper import WanVAEWrapper
|
| 615 |
+
|
| 616 |
+
selected_count = int(plan["selected_samples"])
|
| 617 |
+
assigned = list(_rank_indices(selected_count, rank, world_size))
|
| 618 |
+
dataset = ODERegressionLMDBDataset(
|
| 619 |
+
str(args.teacher_lmdb.expanduser().resolve()), max_pair=selected_count)
|
| 620 |
+
if len(dataset) != selected_count:
|
| 621 |
+
raise RuntimeError(
|
| 622 |
+
f"LMDB length changed after planning: {len(dataset)} != "
|
| 623 |
+
f"{selected_count}")
|
| 624 |
+
vae = (
|
| 625 |
+
WanVAEWrapper()
|
| 626 |
+
.to(device=device, dtype=torch.bfloat16)
|
| 627 |
+
.eval()
|
| 628 |
+
.requires_grad_(False)
|
| 629 |
+
)
|
| 630 |
+
if not torch.equal(vae.mean, torch.tensor(WAN_VAE_MEAN)):
|
| 631 |
+
raise RuntimeError("WanVAEWrapper latent mean changed from the reference contract")
|
| 632 |
+
if not torch.equal(vae.std, torch.tensor(WAN_VAE_STD)):
|
| 633 |
+
raise RuntimeError("WanVAEWrapper latent std changed from the reference contract")
|
| 634 |
+
encoder = _load_dense_encoder(args, device)
|
| 635 |
+
feature_metadata = encoder.feature_metadata()
|
| 636 |
+
if not isinstance(feature_metadata, dict):
|
| 637 |
+
raise TypeError("DINOv3 adapter feature_metadata() must return a dict")
|
| 638 |
+
spatial_indices = torch.tensor(
|
| 639 |
+
SPATIAL_TOKEN_INDICES, device=device, dtype=torch.long)
|
| 640 |
+
|
| 641 |
+
feature_chunks = []
|
| 642 |
+
index_chunks = []
|
| 643 |
+
prompt_digests = []
|
| 644 |
+
expected_tail = tuple(plan["teacher_lmdb"]["latents_shape"][1:])
|
| 645 |
+
started = time.monotonic()
|
| 646 |
+
for offset in range(0, len(assigned), int(args.batch_size)):
|
| 647 |
+
batch_indices = assigned[offset:offset + int(args.batch_size)]
|
| 648 |
+
items = [dataset[index] for index in batch_indices]
|
| 649 |
+
trajectories = [item["ode_latent"] for item in items]
|
| 650 |
+
for index, trajectory in zip(batch_indices, trajectories):
|
| 651 |
+
if tuple(trajectory.shape) != expected_tail:
|
| 652 |
+
raise ValueError(
|
| 653 |
+
f"LMDB row {index} shape {tuple(trajectory.shape)} != "
|
| 654 |
+
f"{expected_tail}")
|
| 655 |
+
if not bool(torch.isfinite(trajectory).all().item()):
|
| 656 |
+
raise FloatingPointError(
|
| 657 |
+
f"LMDB row {index} contains non-finite latents")
|
| 658 |
+
latent = torch.stack(
|
| 659 |
+
[trajectory[-1] for trajectory in trajectories], dim=0
|
| 660 |
+
).to(device=device, dtype=torch.bfloat16)
|
| 661 |
+
pixels = vae.decode_to_pixel(latent.detach())
|
| 662 |
+
video01 = ((pixels + 1.0) * 0.5).clamp(0.0, 1.0)
|
| 663 |
+
if (
|
| 664 |
+
video01.ndim != 5
|
| 665 |
+
or int(video01.shape[1]) != SOURCE_NUM_FRAMES
|
| 666 |
+
or int(video01.shape[2]) != 3
|
| 667 |
+
):
|
| 668 |
+
raise ValueError(
|
| 669 |
+
"Wan VAE must decode [B,81,3,H,W], got "
|
| 670 |
+
f"{tuple(video01.shape)}")
|
| 671 |
+
features = encoder.forward_dense(
|
| 672 |
+
video01,
|
| 673 |
+
token_indices=spatial_indices,
|
| 674 |
+
gradient_checkpointing=False,
|
| 675 |
+
).float()
|
| 676 |
+
expected_shape = (len(batch_indices), TOTAL_TOKENS, FEATURE_DIM)
|
| 677 |
+
if tuple(features.shape) != expected_shape:
|
| 678 |
+
raise RuntimeError(
|
| 679 |
+
f"DINOv3 features {tuple(features.shape)} != {expected_shape}")
|
| 680 |
+
if not bool(torch.isfinite(features).all().item()):
|
| 681 |
+
raise FloatingPointError(
|
| 682 |
+
f"non-finite DINOv3 features for indices {batch_indices}")
|
| 683 |
+
features_fp16 = features.to(dtype=torch.float16, device="cpu")
|
| 684 |
+
if not bool(torch.isfinite(features_fp16).all().item()):
|
| 685 |
+
raise FloatingPointError(
|
| 686 |
+
f"FP16 conversion overflowed for indices {batch_indices}")
|
| 687 |
+
feature_chunks.append(features_fp16)
|
| 688 |
+
index_chunks.append(torch.tensor(batch_indices, dtype=torch.long))
|
| 689 |
+
prompt_digests.extend(
|
| 690 |
+
_prompt_entry_digest(index, item["prompts"])
|
| 691 |
+
for index, item in zip(batch_indices, items)
|
| 692 |
+
)
|
| 693 |
+
del latent, pixels, video01, features, features_fp16, items, trajectories
|
| 694 |
+
completed = min(offset + len(batch_indices), len(assigned))
|
| 695 |
+
if int(args.log_every) > 0 and (
|
| 696 |
+
completed == len(assigned) or completed % int(args.log_every) == 0
|
| 697 |
+
):
|
| 698 |
+
print(
|
| 699 |
+
f"[extract rank {rank}] {completed}/{len(assigned)} "
|
| 700 |
+
f"({time.monotonic() - started:.1f}s)",
|
| 701 |
+
flush=True,
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
if feature_chunks:
|
| 705 |
+
shard_features = torch.cat(feature_chunks, dim=0).contiguous()
|
| 706 |
+
shard_indices = torch.cat(index_chunks, dim=0).contiguous()
|
| 707 |
+
digest_tensor = torch.tensor(
|
| 708 |
+
[list(value) for value in prompt_digests], dtype=torch.uint8)
|
| 709 |
+
else:
|
| 710 |
+
shard_features = torch.empty(
|
| 711 |
+
0, TOTAL_TOKENS, FEATURE_DIM, dtype=torch.float16)
|
| 712 |
+
shard_indices = torch.empty(0, dtype=torch.long)
|
| 713 |
+
digest_tensor = torch.empty(0, 32, dtype=torch.uint8)
|
| 714 |
+
payload = {
|
| 715 |
+
"shard_format_version": TOOL_FORMAT_VERSION,
|
| 716 |
+
"rank": int(rank),
|
| 717 |
+
"world_size": int(world_size),
|
| 718 |
+
"indices": shard_indices,
|
| 719 |
+
"features": shard_features,
|
| 720 |
+
"prompt_entry_sha256": digest_tensor,
|
| 721 |
+
"sample_shape": [TOTAL_TOKENS, FEATURE_DIM],
|
| 722 |
+
"feature_dtype": "float16",
|
| 723 |
+
"feature_metadata": feature_metadata,
|
| 724 |
+
"vae_identity": vae_identity,
|
| 725 |
+
}
|
| 726 |
+
_atomic_torch_save(payload, shard_path)
|
| 727 |
+
print(
|
| 728 |
+
f"[extract rank {rank}] wrote {shard_path} "
|
| 729 |
+
f"{tuple(shard_features.shape)}",
|
| 730 |
+
flush=True,
|
| 731 |
+
)
|
| 732 |
+
del encoder, vae, dataset, payload, feature_chunks, shard_features
|
| 733 |
+
gc.collect()
|
| 734 |
+
torch.cuda.empty_cache()
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def _load_and_merge_shards(
|
| 738 |
+
shard_dir: Path,
|
| 739 |
+
*,
|
| 740 |
+
world_size: int,
|
| 741 |
+
selected_count: int,
|
| 742 |
+
sample_shape: Sequence[int],
|
| 743 |
+
) -> Tuple[torch.Tensor, str, str, Dict[str, Any], Dict[str, Any]]:
|
| 744 |
+
feature_parts = []
|
| 745 |
+
index_parts = []
|
| 746 |
+
digest_parts = []
|
| 747 |
+
feature_metadata = None
|
| 748 |
+
vae_identity = None
|
| 749 |
+
expected_tail = tuple(int(value) for value in sample_shape)
|
| 750 |
+
for rank in range(int(world_size)):
|
| 751 |
+
path = shard_dir / f"rank_{rank:05d}.pt"
|
| 752 |
+
if not path.is_file():
|
| 753 |
+
raise FileNotFoundError(f"missing rank shard: {path}")
|
| 754 |
+
payload = torch.load(path, map_location="cpu", weights_only=False)
|
| 755 |
+
if int(payload.get("shard_format_version", -1)) != TOOL_FORMAT_VERSION:
|
| 756 |
+
raise ValueError(f"incompatible shard format in {path}")
|
| 757 |
+
if int(payload.get("rank", -1)) != rank:
|
| 758 |
+
raise ValueError(f"rank identity mismatch in {path}")
|
| 759 |
+
if int(payload.get("world_size", -1)) != int(world_size):
|
| 760 |
+
raise ValueError(f"world-size mismatch in {path}")
|
| 761 |
+
features = payload.get("features")
|
| 762 |
+
indices = payload.get("indices")
|
| 763 |
+
digests = payload.get("prompt_entry_sha256")
|
| 764 |
+
shard_feature_metadata = payload.get("feature_metadata")
|
| 765 |
+
shard_vae_identity = payload.get("vae_identity")
|
| 766 |
+
if (
|
| 767 |
+
not isinstance(features, torch.Tensor)
|
| 768 |
+
or features.dtype != torch.float16
|
| 769 |
+
or features.ndim != 3
|
| 770 |
+
or tuple(features.shape[1:]) != expected_tail
|
| 771 |
+
or not bool(torch.isfinite(features).all().item())
|
| 772 |
+
):
|
| 773 |
+
raise ValueError(f"invalid FP16 feature shard {path}")
|
| 774 |
+
if (
|
| 775 |
+
not isinstance(indices, torch.Tensor)
|
| 776 |
+
or indices.dtype != torch.long
|
| 777 |
+
or tuple(indices.shape) != (features.shape[0],)
|
| 778 |
+
):
|
| 779 |
+
raise ValueError(f"invalid indices in {path}")
|
| 780 |
+
if (
|
| 781 |
+
not isinstance(digests, torch.Tensor)
|
| 782 |
+
or digests.dtype != torch.uint8
|
| 783 |
+
or tuple(digests.shape) != (features.shape[0], 32)
|
| 784 |
+
):
|
| 785 |
+
raise ValueError(f"invalid prompt hashes in {path}")
|
| 786 |
+
if not isinstance(shard_feature_metadata, dict):
|
| 787 |
+
raise ValueError(f"missing DINOv3 feature metadata in {path}")
|
| 788 |
+
if feature_metadata is None:
|
| 789 |
+
feature_metadata = shard_feature_metadata
|
| 790 |
+
elif shard_feature_metadata != feature_metadata:
|
| 791 |
+
raise ValueError(
|
| 792 |
+
f"DINOv3 feature metadata differs across rank shards: {path}")
|
| 793 |
+
if not isinstance(shard_vae_identity, dict):
|
| 794 |
+
raise ValueError(f"missing Wan VAE identity in {path}")
|
| 795 |
+
if vae_identity is None:
|
| 796 |
+
vae_identity = shard_vae_identity
|
| 797 |
+
elif shard_vae_identity != vae_identity:
|
| 798 |
+
raise ValueError(
|
| 799 |
+
f"Wan VAE identity differs across rank shards: {path}")
|
| 800 |
+
feature_parts.append(features)
|
| 801 |
+
index_parts.append(indices)
|
| 802 |
+
digest_parts.append(digests)
|
| 803 |
+
features = torch.cat(feature_parts, dim=0)
|
| 804 |
+
indices = torch.cat(index_parts, dim=0)
|
| 805 |
+
digests = torch.cat(digest_parts, dim=0)
|
| 806 |
+
if int(features.shape[0]) != int(selected_count):
|
| 807 |
+
raise ValueError(
|
| 808 |
+
f"rank shards contain {features.shape[0]} samples, expected "
|
| 809 |
+
f"{selected_count}")
|
| 810 |
+
order = torch.argsort(indices, stable=True)
|
| 811 |
+
indices = indices.index_select(0, order)
|
| 812 |
+
if not torch.equal(indices, torch.arange(selected_count, dtype=torch.long)):
|
| 813 |
+
raise ValueError(
|
| 814 |
+
"rank shards do not cover every selected LMDB index exactly once")
|
| 815 |
+
features = features.index_select(0, order).contiguous()
|
| 816 |
+
digests = digests.index_select(0, order).contiguous()
|
| 817 |
+
prompt_hash = hashlib.sha256(digests.numpy().tobytes()).hexdigest()
|
| 818 |
+
feature_hash = hashlib.sha256(
|
| 819 |
+
features.view(torch.uint8).numpy().tobytes()).hexdigest()
|
| 820 |
+
if feature_metadata is None:
|
| 821 |
+
raise ValueError("rank shards contained no DINOv3 feature metadata")
|
| 822 |
+
if vae_identity is None:
|
| 823 |
+
raise ValueError("rank shards contained no Wan VAE identity")
|
| 824 |
+
return features, prompt_hash, feature_hash, feature_metadata, vae_identity
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
def _validate_feature_metadata(
|
| 828 |
+
args: argparse.Namespace,
|
| 829 |
+
feature_metadata: Dict[str, Any],
|
| 830 |
+
) -> Dict[str, Any]:
|
| 831 |
+
"""Rebuild the canonical schema and reject even consistent bad shards."""
|
| 832 |
+
from video_fd.encoders.dinov3_dense_metadata import (
|
| 833 |
+
canonical_dinov3_dense_feature_metadata,
|
| 834 |
+
file_sha256,
|
| 835 |
+
)
|
| 836 |
+
|
| 837 |
+
repo_path = args.repo_path.expanduser().resolve()
|
| 838 |
+
if _git_head(repo_path) != str(args.source_commit):
|
| 839 |
+
raise RuntimeError("DINOv3 source commit changed during extraction")
|
| 840 |
+
if _git_source_status(repo_path).strip():
|
| 841 |
+
raise RuntimeError("DINOv3 source worktree changed during extraction")
|
| 842 |
+
current_weight_sha256 = file_sha256(args.weight_path)
|
| 843 |
+
expected = canonical_dinov3_dense_feature_metadata(
|
| 844 |
+
repo_path=str(repo_path),
|
| 845 |
+
weight_path=str(args.weight_path.expanduser().resolve()),
|
| 846 |
+
source_commit=str(args.source_commit),
|
| 847 |
+
weight_sha256=current_weight_sha256,
|
| 848 |
+
source_num_frames=SOURCE_NUM_FRAMES,
|
| 849 |
+
frame_indices=list(FRAME_INDICES),
|
| 850 |
+
input_size=INPUT_SIZE,
|
| 851 |
+
patch_size=PATCH_SIZE,
|
| 852 |
+
patch_grid_size=PATCH_GRID_SIZE,
|
| 853 |
+
spatial_axis_indices=list(SPATIAL_AXIS_INDICES),
|
| 854 |
+
spatial_token_indices=list(SPATIAL_TOKEN_INDICES),
|
| 855 |
+
per_frame_tokens=PER_FRAME_TOKENS,
|
| 856 |
+
num_tokens=TOTAL_TOKENS,
|
| 857 |
+
forward_chunk_frames=FORWARD_CHUNK_FRAMES,
|
| 858 |
+
feature_dim=FEATURE_DIM,
|
| 859 |
+
)
|
| 860 |
+
if feature_metadata != expected:
|
| 861 |
+
raise ValueError(
|
| 862 |
+
"rank-shard DINOv3 metadata does not equal the canonical schema")
|
| 863 |
+
return expected
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
def _reference_metadata(
|
| 867 |
+
args: argparse.Namespace,
|
| 868 |
+
plan: Dict[str, Any],
|
| 869 |
+
*,
|
| 870 |
+
feature_metadata: Dict[str, Any],
|
| 871 |
+
vae_identity: Dict[str, Any],
|
| 872 |
+
prompt_index_hash: str,
|
| 873 |
+
feature_bank_hash: str,
|
| 874 |
+
) -> Dict[str, Any]:
|
| 875 |
+
feature_metadata = _validate_feature_metadata(args, feature_metadata)
|
| 876 |
+
if vae_identity != _vae_identity():
|
| 877 |
+
raise RuntimeError(
|
| 878 |
+
"Wan VAE asset or wrapper changed during teacher extraction")
|
| 879 |
+
data_mdb = Path(plan["teacher_lmdb"]["path"]) / "data.mdb"
|
| 880 |
+
data_stat = data_mdb.stat()
|
| 881 |
+
if (
|
| 882 |
+
int(data_stat.st_size)
|
| 883 |
+
!= int(plan["teacher_lmdb"]["data_mdb_size_bytes"])
|
| 884 |
+
or int(data_stat.st_mtime_ns)
|
| 885 |
+
!= int(plan["teacher_lmdb"]["data_mdb_mtime_ns"])
|
| 886 |
+
):
|
| 887 |
+
raise RuntimeError("teacher LMDB changed during DINOv3 extraction")
|
| 888 |
+
source = {
|
| 889 |
+
"kind": "causvid_ode_teacher_lmdb_final_latent",
|
| 890 |
+
"lmdb_path": plan["teacher_lmdb"]["path"],
|
| 891 |
+
"latents_shape": list(plan["teacher_lmdb"]["latents_shape"]),
|
| 892 |
+
"prompts_shape": list(plan["teacher_lmdb"]["prompts_shape"]),
|
| 893 |
+
"selected_samples": EXPECTED_TEACHER_COUNT,
|
| 894 |
+
"selected_index_start": 0,
|
| 895 |
+
"selected_index_stop_exclusive": EXPECTED_TEACHER_COUNT,
|
| 896 |
+
"trajectory_index": -1,
|
| 897 |
+
"lmdb_storage_dtype": "float16",
|
| 898 |
+
"data_mdb_size_bytes": int(
|
| 899 |
+
plan["teacher_lmdb"]["data_mdb_size_bytes"]),
|
| 900 |
+
"data_mdb_mtime_ns": int(
|
| 901 |
+
plan["teacher_lmdb"]["data_mdb_mtime_ns"]),
|
| 902 |
+
"prompt_index_sha256": prompt_index_hash,
|
| 903 |
+
"feature_bank_fp16_sha256": feature_bank_hash,
|
| 904 |
+
}
|
| 905 |
+
source["identity_sha256"] = _canonical_sha256(source)
|
| 906 |
+
return {
|
| 907 |
+
"feature": feature_metadata,
|
| 908 |
+
"reference": {
|
| 909 |
+
"source": source,
|
| 910 |
+
"vae": vae_identity,
|
| 911 |
+
"extraction": {
|
| 912 |
+
"tool": "tools/precompute_dinov3_dense_nystrom_reference.py",
|
| 913 |
+
"tool_format_version": TOOL_FORMAT_VERSION,
|
| 914 |
+
"world_size": int(plan["world_size"]),
|
| 915 |
+
"vae_compute_dtype": "bfloat16",
|
| 916 |
+
"vae_decode_use_cache": False,
|
| 917 |
+
"decoded_pixel_transform": "((pixels+1)*0.5).clamp(0,1)",
|
| 918 |
+
"dinov3_compute_dtype": "float32",
|
| 919 |
+
"forward_chunk_frames": FORWARD_CHUNK_FRAMES,
|
| 920 |
+
"rank_shard_dtype": "float16",
|
| 921 |
+
},
|
| 922 |
+
"build_request": {
|
| 923 |
+
"num_landmarks": NUM_LANDMARKS,
|
| 924 |
+
"seed": NYSTROM_SEED,
|
| 925 |
+
"bandwidth": (
|
| 926 |
+
None if args.bandwidth is None else float(args.bandwidth)),
|
| 927 |
+
"median_max_samples": int(args.median_max_samples),
|
| 928 |
+
"kmeans_iterations": int(args.kmeans_iterations),
|
| 929 |
+
"ridge": float(args.ridge),
|
| 930 |
+
"chunk_size": int(args.chunk_size),
|
| 931 |
+
},
|
| 932 |
+
},
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def _build_and_save_reference(
|
| 937 |
+
args: argparse.Namespace,
|
| 938 |
+
plan: Dict[str, Any],
|
| 939 |
+
shard_dir: Path,
|
| 940 |
+
*,
|
| 941 |
+
device: torch.device,
|
| 942 |
+
) -> Dict[str, Any]:
|
| 943 |
+
from losses.video_nystrom_mmd import (
|
| 944 |
+
build_video_nystrom_reference,
|
| 945 |
+
load_reference_pack,
|
| 946 |
+
save_reference_pack,
|
| 947 |
+
validate_reference_pack,
|
| 948 |
+
)
|
| 949 |
+
|
| 950 |
+
features, prompt_hash, feature_hash, feature_metadata, vae_identity = (
|
| 951 |
+
_load_and_merge_shards(
|
| 952 |
+
shard_dir,
|
| 953 |
+
world_size=int(plan["world_size"]),
|
| 954 |
+
selected_count=EXPECTED_TEACHER_COUNT,
|
| 955 |
+
sample_shape=(TOTAL_TOKENS, FEATURE_DIM),
|
| 956 |
+
)
|
| 957 |
+
)
|
| 958 |
+
metadata = _reference_metadata(
|
| 959 |
+
args,
|
| 960 |
+
plan,
|
| 961 |
+
feature_metadata=feature_metadata,
|
| 962 |
+
vae_identity=vae_identity,
|
| 963 |
+
prompt_index_hash=prompt_hash,
|
| 964 |
+
feature_bank_hash=feature_hash,
|
| 965 |
+
)
|
| 966 |
+
print(
|
| 967 |
+
f"[merge] building m={NUM_LANDMARKS} reference from "
|
| 968 |
+
f"{tuple(features.shape)} FP16 matrices on {device}",
|
| 969 |
+
flush=True,
|
| 970 |
+
)
|
| 971 |
+
reference = features.to(device=device, non_blocking=False)
|
| 972 |
+
pack = build_video_nystrom_reference(
|
| 973 |
+
reference,
|
| 974 |
+
num_landmarks=NUM_LANDMARKS,
|
| 975 |
+
metadata=metadata,
|
| 976 |
+
bandwidth=args.bandwidth,
|
| 977 |
+
median_max_samples=int(args.median_max_samples),
|
| 978 |
+
kmeans_iterations=int(args.kmeans_iterations),
|
| 979 |
+
ridge=float(args.ridge),
|
| 980 |
+
chunk_size=int(args.chunk_size),
|
| 981 |
+
seed=NYSTROM_SEED,
|
| 982 |
+
)
|
| 983 |
+
validate_reference_pack(pack)
|
| 984 |
+
del reference
|
| 985 |
+
torch.cuda.empty_cache()
|
| 986 |
+
|
| 987 |
+
output = args.output.expanduser().resolve()
|
| 988 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 989 |
+
temporary = output.with_name(f".{output.name}.tmp-{os.getpid()}")
|
| 990 |
+
try:
|
| 991 |
+
save_reference_pack(pack, temporary)
|
| 992 |
+
staged = load_reference_pack(temporary, map_location="cpu")
|
| 993 |
+
validate_reference_pack(staged)
|
| 994 |
+
if output.exists() and not bool(args.overwrite):
|
| 995 |
+
raise FileExistsError(
|
| 996 |
+
f"output appeared during construction: {output}")
|
| 997 |
+
os.replace(temporary, output)
|
| 998 |
+
finally:
|
| 999 |
+
if temporary.exists():
|
| 1000 |
+
temporary.unlink()
|
| 1001 |
+
|
| 1002 |
+
# This final-path reload is the gate for temporary-shard deletion.
|
| 1003 |
+
published = load_reference_pack(output, map_location="cpu")
|
| 1004 |
+
validate_reference_pack(published)
|
| 1005 |
+
_validate_published_pack_identity(pack, published)
|
| 1006 |
+
return {
|
| 1007 |
+
"output": str(output),
|
| 1008 |
+
"samples": int(features.shape[0]),
|
| 1009 |
+
"sample_shape": list(features.shape[1:]),
|
| 1010 |
+
"num_landmarks": int(pack["landmarks"].shape[0]),
|
| 1011 |
+
"sigma": float(torch.as_tensor(pack["sigma"]).item()),
|
| 1012 |
+
"content_hash": str(pack["content_hash"]),
|
| 1013 |
+
"prompt_index_sha256": prompt_hash,
|
| 1014 |
+
"feature_bank_fp16_sha256": feature_hash,
|
| 1015 |
+
"final_path_validated": True,
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
|
| 1019 |
+
def _validate_published_pack_identity(
|
| 1020 |
+
pack: Mapping[str, Any],
|
| 1021 |
+
published: Mapping[str, Any],
|
| 1022 |
+
) -> None:
|
| 1023 |
+
"""Require the final-path reload to preserve the built pack identity."""
|
| 1024 |
+
|
| 1025 |
+
if published.get("content_hash") != pack.get("content_hash"):
|
| 1026 |
+
raise ValueError("published reference content hash changed after rename")
|
| 1027 |
+
# The builder canonicalizes user metadata and adds its reserved ``nystrom``
|
| 1028 |
+
# block. Compare against that exact pack payload, not the pre-build input.
|
| 1029 |
+
if published.get("metadata") != pack.get("metadata"):
|
| 1030 |
+
raise ValueError("published reference metadata changed after rename")
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
def _remove_validated_shards(
|
| 1034 |
+
shard_dir: Path,
|
| 1035 |
+
build_summary: Dict[str, Any],
|
| 1036 |
+
) -> None:
|
| 1037 |
+
"""Delete shards only with the explicit final-path validation receipt."""
|
| 1038 |
+
if build_summary.get("final_path_validated") is not True:
|
| 1039 |
+
raise RuntimeError(
|
| 1040 |
+
"refusing to delete shards before final reference validation")
|
| 1041 |
+
output = Path(str(build_summary.get("output", ""))).expanduser().resolve()
|
| 1042 |
+
if not output.is_file():
|
| 1043 |
+
raise FileNotFoundError(
|
| 1044 |
+
f"refusing to delete shards; published reference is missing: {output}")
|
| 1045 |
+
shutil.rmtree(shard_dir)
|
| 1046 |
+
|
| 1047 |
+
|
| 1048 |
+
def _run(args: argparse.Namespace) -> None:
|
| 1049 |
+
_validate_args(args)
|
| 1050 |
+
env_world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 1051 |
+
env_rank = int(os.environ.get("RANK", "0"))
|
| 1052 |
+
plan = _plan(args, env_world_size)
|
| 1053 |
+
if bool(args.dry_run):
|
| 1054 |
+
if env_rank == 0:
|
| 1055 |
+
print(json.dumps(plan, indent=2, sort_keys=True), flush=True)
|
| 1056 |
+
return
|
| 1057 |
+
|
| 1058 |
+
device, rank, world_size = _setup_distributed(
|
| 1059 |
+
int(args.distributed_timeout_minutes))
|
| 1060 |
+
if world_size != int(plan["world_size"]):
|
| 1061 |
+
raise RuntimeError(
|
| 1062 |
+
f"world size changed after planning: {world_size} != "
|
| 1063 |
+
f"{plan['world_size']}")
|
| 1064 |
+
shard_dir = None
|
| 1065 |
+
success = False
|
| 1066 |
+
validated_summary = None
|
| 1067 |
+
try:
|
| 1068 |
+
vae_result = None
|
| 1069 |
+
if rank == 0:
|
| 1070 |
+
try:
|
| 1071 |
+
vae_result = {"identity": _vae_identity(), "error": None}
|
| 1072 |
+
except Exception as exc:
|
| 1073 |
+
vae_result = {
|
| 1074 |
+
"identity": None,
|
| 1075 |
+
"error": f"{type(exc).__name__}: {exc}",
|
| 1076 |
+
}
|
| 1077 |
+
vae_result = _broadcast_rank0_object(vae_result, rank)
|
| 1078 |
+
if vae_result["error"] is not None:
|
| 1079 |
+
raise RuntimeError(
|
| 1080 |
+
"rank 0 could not resolve the Wan VAE identity: "
|
| 1081 |
+
f"{vae_result['error']}")
|
| 1082 |
+
vae_identity = vae_result["identity"]
|
| 1083 |
+
|
| 1084 |
+
temp_result = None
|
| 1085 |
+
if rank == 0:
|
| 1086 |
+
try:
|
| 1087 |
+
created = Path(tempfile.mkdtemp(
|
| 1088 |
+
prefix="dinov3_dense_nystrom_",
|
| 1089 |
+
dir=Path(plan["temporary"]["parent"]),
|
| 1090 |
+
))
|
| 1091 |
+
temp_result = {"path": str(created), "error": None}
|
| 1092 |
+
except Exception as exc:
|
| 1093 |
+
temp_result = {
|
| 1094 |
+
"path": None,
|
| 1095 |
+
"error": f"{type(exc).__name__}: {exc}",
|
| 1096 |
+
}
|
| 1097 |
+
temp_result = _broadcast_rank0_object(temp_result, rank)
|
| 1098 |
+
if temp_result["error"] is not None:
|
| 1099 |
+
raise RuntimeError(
|
| 1100 |
+
"rank 0 could not create shard directory: "
|
| 1101 |
+
f"{temp_result['error']}")
|
| 1102 |
+
shard_dir = Path(temp_result["path"])
|
| 1103 |
+
|
| 1104 |
+
_extract_rank_shard(
|
| 1105 |
+
args,
|
| 1106 |
+
plan,
|
| 1107 |
+
device=device,
|
| 1108 |
+
rank=rank,
|
| 1109 |
+
world_size=world_size,
|
| 1110 |
+
shard_path=shard_dir / f"rank_{rank:05d}.pt",
|
| 1111 |
+
vae_identity=vae_identity,
|
| 1112 |
+
)
|
| 1113 |
+
_barrier()
|
| 1114 |
+
summary = None
|
| 1115 |
+
build_error = None
|
| 1116 |
+
if rank == 0:
|
| 1117 |
+
try:
|
| 1118 |
+
summary = _build_and_save_reference(
|
| 1119 |
+
args, plan, shard_dir, device=device)
|
| 1120 |
+
except Exception as exc:
|
| 1121 |
+
import traceback
|
| 1122 |
+
traceback.print_exc()
|
| 1123 |
+
build_error = f"{type(exc).__name__}: {exc}"
|
| 1124 |
+
outcome = _broadcast_rank0_object(
|
| 1125 |
+
{"summary": summary, "error": build_error}, rank)
|
| 1126 |
+
if outcome["error"] is not None:
|
| 1127 |
+
raise RuntimeError(
|
| 1128 |
+
f"rank 0 reference construction failed: {outcome['error']}")
|
| 1129 |
+
_barrier()
|
| 1130 |
+
validated_summary = outcome["summary"]
|
| 1131 |
+
success = True
|
| 1132 |
+
if rank == 0:
|
| 1133 |
+
print(json.dumps(
|
| 1134 |
+
{"status": "ok", **outcome["summary"]},
|
| 1135 |
+
indent=2,
|
| 1136 |
+
sort_keys=True,
|
| 1137 |
+
), flush=True)
|
| 1138 |
+
finally:
|
| 1139 |
+
if rank == 0 and shard_dir is not None:
|
| 1140 |
+
if success:
|
| 1141 |
+
_remove_validated_shards(shard_dir, validated_summary)
|
| 1142 |
+
print(
|
| 1143 |
+
f"[cleanup] removed validated temporary shards {shard_dir}",
|
| 1144 |
+
flush=True,
|
| 1145 |
+
)
|
| 1146 |
+
else:
|
| 1147 |
+
print(
|
| 1148 |
+
f"[cleanup] preserving failed-run shards at {shard_dir}",
|
| 1149 |
+
flush=True,
|
| 1150 |
+
)
|
| 1151 |
+
if dist.is_available() and dist.is_initialized():
|
| 1152 |
+
dist.destroy_process_group()
|
| 1153 |
+
|
| 1154 |
+
|
| 1155 |
+
def main(argv: Optional[Iterable[str]] = None) -> None:
|
| 1156 |
+
_run(build_parser().parse_args(argv))
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
if __name__ == "__main__":
|
| 1160 |
+
main()
|
1.3B/code/train.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import torch.distributed as dist
|
| 4 |
+
from omegaconf import OmegaConf
|
| 5 |
+
from utils.config import load_config
|
| 6 |
+
from utils.optional_wandb import wandb
|
| 7 |
+
|
| 8 |
+
def main():
|
| 9 |
+
parser = argparse.ArgumentParser()
|
| 10 |
+
parser.add_argument("--config_path", type=str, required=True)
|
| 11 |
+
parser.add_argument("--no_save", action="store_true")
|
| 12 |
+
parser.add_argument("--no_visualize", action="store_true")
|
| 13 |
+
parser.add_argument(
|
| 14 |
+
"--max_steps", "--training-updates",
|
| 15 |
+
type=int,
|
| 16 |
+
default=None,
|
| 17 |
+
help="Optional runtime override for training_updates (useful for smoke tests)",
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument(
|
| 20 |
+
"--fresh-video-local-samples", "--distribution-samples-per-rank",
|
| 21 |
+
type=int,
|
| 22 |
+
default=None,
|
| 23 |
+
help=(
|
| 24 |
+
"Optional smoke-test override for the number of fresh videos "
|
| 25 |
+
"collected per rank and optimizer update."
|
| 26 |
+
),
|
| 27 |
+
)
|
| 28 |
+
parser.add_argument(
|
| 29 |
+
"--total-batch-size", "--distribution-batch-size",
|
| 30 |
+
type=int,
|
| 31 |
+
default=None,
|
| 32 |
+
help="Optional matching logical global-batch override.",
|
| 33 |
+
)
|
| 34 |
+
parser.add_argument(
|
| 35 |
+
"--eval-interval",
|
| 36 |
+
type=int,
|
| 37 |
+
default=None,
|
| 38 |
+
help="Optional runtime eval interval override; 0 disables eval.",
|
| 39 |
+
)
|
| 40 |
+
parser.add_argument("--logdir", type=str, default="", help="Path to the directory to save logs")
|
| 41 |
+
parser.add_argument("--wandb-save-dir", type=str, default="", help="Path to the directory to save wandb logs")
|
| 42 |
+
parser.add_argument("--disable-wandb", action="store_true")
|
| 43 |
+
|
| 44 |
+
args = parser.parse_args()
|
| 45 |
+
|
| 46 |
+
config = load_config(args.config_path, "configs/default_config.yaml")
|
| 47 |
+
config.no_save = args.no_save
|
| 48 |
+
config.no_visualize = args.no_visualize
|
| 49 |
+
if args.max_steps is not None:
|
| 50 |
+
if args.max_steps <= 0:
|
| 51 |
+
parser.error("--max_steps must be positive")
|
| 52 |
+
config.max_steps = args.max_steps
|
| 53 |
+
if args.fresh_video_local_samples is not None:
|
| 54 |
+
if args.fresh_video_local_samples <= 0:
|
| 55 |
+
parser.error("--fresh-video-local-samples must be positive")
|
| 56 |
+
config.fresh_video_local_samples = args.fresh_video_local_samples
|
| 57 |
+
if args.total_batch_size is not None:
|
| 58 |
+
if args.total_batch_size <= 0:
|
| 59 |
+
parser.error("--total-batch-size must be positive")
|
| 60 |
+
config.total_batch_size = args.total_batch_size
|
| 61 |
+
if args.eval_interval is not None:
|
| 62 |
+
if args.eval_interval < 0:
|
| 63 |
+
parser.error("--eval-interval must be non-negative")
|
| 64 |
+
config.eval_interval = args.eval_interval
|
| 65 |
+
|
| 66 |
+
# get the filename of config_path
|
| 67 |
+
config_name = os.path.basename(args.config_path).split(".")[0]
|
| 68 |
+
config.config_name = config_name
|
| 69 |
+
# Preserve the exact YAML identity for strict offline-reference checks.
|
| 70 |
+
# Runtime-only smoke overrides remain separate and do not change the
|
| 71 |
+
# generator trajectory contract encoded by this file.
|
| 72 |
+
config.config_path = os.path.realpath(args.config_path)
|
| 73 |
+
config.logdir = args.logdir
|
| 74 |
+
config.wandb_save_dir = args.wandb_save_dir
|
| 75 |
+
config.disable_wandb = args.disable_wandb
|
| 76 |
+
|
| 77 |
+
if config.trainer != "score_distillation":
|
| 78 |
+
raise ValueError("This release supports only three-encoder distribution matching")
|
| 79 |
+
from trainer.distribution_matching import DistributionMatchingTrainer
|
| 80 |
+
trainer = DistributionMatchingTrainer(config)
|
| 81 |
+
try:
|
| 82 |
+
trainer.train()
|
| 83 |
+
finally:
|
| 84 |
+
wandb.finish()
|
| 85 |
+
if dist.is_available() and dist.is_initialized():
|
| 86 |
+
dist.destroy_process_group()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
main()
|
1.3B/code/trainer/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lazy public trainer exports.
|
| 2 |
+
|
| 3 |
+
Loading one trainer must not import optional dependencies belonging only to a
|
| 4 |
+
different experiment.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from importlib import import_module
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
_EXPORTS = {
|
| 13 |
+
"DiffusionTrainer": (".diffusion", "Trainer"),
|
| 14 |
+
"GANTrainer": (".gan", "Trainer"),
|
| 15 |
+
"ODETrainer": (".ode", "Trainer"),
|
| 16 |
+
"ScoreDistillationTrainer": (".distillation", "Trainer"),
|
| 17 |
+
"ARLStage1Trainer": (".arl", "ARLStage1Trainer"),
|
| 18 |
+
"ARLStage2Trainer": (".arl", "ARLStage2Trainer"),
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
__all__ = list(_EXPORTS)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def __getattr__(name):
|
| 25 |
+
try:
|
| 26 |
+
module_name, attribute = _EXPORTS[name]
|
| 27 |
+
except KeyError as exc:
|
| 28 |
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
| 29 |
+
value = getattr(import_module(module_name, __name__), attribute)
|
| 30 |
+
globals()[name] = value
|
| 31 |
+
return value
|
1.3B/code/trainer/distillation.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility import; read trainer/distribution_matching.py for the implementation."""
|
| 2 |
+
import sys
|
| 3 |
+
from . import distribution_matching as _implementation
|
| 4 |
+
|
| 5 |
+
# Share the module object so existing monkeypatches and private imports still work.
|
| 6 |
+
sys.modules[__name__] = _implementation
|
1.3B/code/trainer/distribution_matching.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
1.3B/code/trainer/feature_gradient_replay.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Separate distribution estimation from generator backpropagation (Sec. 3.3).
|
| 2 |
+
|
| 3 |
+
The distribution batch B supplies all cross-video interactions. Only the smaller
|
| 4 |
+
configured differentiation batch b needs feature gradients and rollout replay.
|
| 5 |
+
This module caches features and returns their cotangents (dL/d feature); the
|
| 6 |
+
trainer later replays the encoders, VAE, and generator to obtain dL/d parameters.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Any, Mapping, Sequence
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
import torch.distributed as dist
|
| 16 |
+
|
| 17 |
+
from losses.multi_encoder_mmd import (
|
| 18 |
+
MultiEncoderMMD,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _detach(value: Any) -> Any:
|
| 23 |
+
if isinstance(value, torch.Tensor):
|
| 24 |
+
return value.detach()
|
| 25 |
+
if isinstance(value, Mapping):
|
| 26 |
+
return {key: _detach(item) for key, item in value.items()}
|
| 27 |
+
if isinstance(value, tuple):
|
| 28 |
+
return tuple(_detach(item) for item in value)
|
| 29 |
+
if isinstance(value, list):
|
| 30 |
+
return [_detach(item) for item in value]
|
| 31 |
+
return value
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class FeatureGradientReplayResult:
|
| 36 |
+
"""Detached loss, per-microbatch feature gradients, and replay bookkeeping.
|
| 37 |
+
|
| 38 |
+
``local_cotangents`` are the derivatives of the global distribution loss.
|
| 39 |
+
``fsdp_cotangents`` additionally compensate for FSDP's gradient averaging.
|
| 40 |
+
Field names stay compatible with existing training logs and checkpoints.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
loss: torch.Tensor
|
| 44 |
+
optimized_loss: torch.Tensor
|
| 45 |
+
metadata: Mapping[str, Any]
|
| 46 |
+
local_cotangents: tuple[Mapping[str, torch.Tensor], ...]
|
| 47 |
+
fsdp_cotangents: tuple[Mapping[str, torch.Tensor], ...]
|
| 48 |
+
replay_batch_indices: tuple[int, ...]
|
| 49 |
+
replay_sample_ids: tuple[tuple[str, ...], ...]
|
| 50 |
+
local_samples: int
|
| 51 |
+
global_samples: int
|
| 52 |
+
gradient_local_samples: int
|
| 53 |
+
gradient_global_samples: int
|
| 54 |
+
current_teacher_local_samples: int
|
| 55 |
+
current_teacher_global_samples: int
|
| 56 |
+
world_size: int
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class FeatureGradientReplay:
|
| 60 |
+
"""Cache B generated feature sets and differentiate the selected b samples.
|
| 61 |
+
|
| 62 |
+
B and b denote global sample counts; constructor counts are per rank. Each
|
| 63 |
+
encoder keeps its own [tokens, channels] layout while sharing sample order.
|
| 64 |
+
This implementation selects a per-rank suffix. All B samples still define
|
| 65 |
+
the distribution loss, including interactions with unselected samples.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(
|
| 69 |
+
self,
|
| 70 |
+
loss_module: MultiEncoderMMD,
|
| 71 |
+
*,
|
| 72 |
+
target_local_samples: int,
|
| 73 |
+
gradient_local_samples: int,
|
| 74 |
+
loss_scale: float = 1.0,
|
| 75 |
+
require_sample_id_alignment: bool = True,
|
| 76 |
+
full_student_partial_gradient: bool = True,
|
| 77 |
+
) -> None:
|
| 78 |
+
if not isinstance(loss_module, MultiEncoderMMD):
|
| 79 |
+
raise TypeError(
|
| 80 |
+
"loss_module must be MultiRepresentationVideoNystromMMD")
|
| 81 |
+
if not 0 < int(gradient_local_samples) < int(target_local_samples):
|
| 82 |
+
raise ValueError(
|
| 83 |
+
"dual GradCache requires a strict positive gradient suffix")
|
| 84 |
+
if not full_student_partial_gradient:
|
| 85 |
+
raise ValueError(
|
| 86 |
+
"dual GradCache requires full_student_partial_gradient=true")
|
| 87 |
+
self.loss_module = loss_module
|
| 88 |
+
self.target_local_samples = int(target_local_samples)
|
| 89 |
+
self.gradient_local_samples = int(gradient_local_samples)
|
| 90 |
+
self.loss_scale = float(loss_scale)
|
| 91 |
+
self.require_sample_id_alignment = bool(require_sample_id_alignment)
|
| 92 |
+
self.full_student_partial_gradient = True
|
| 93 |
+
self.partial_gradient_enabled = True
|
| 94 |
+
self.current_teacher_attraction_enabled = True
|
| 95 |
+
self._feature_batches: list[dict[str, torch.Tensor]] = []
|
| 96 |
+
self._teacher_batches: list[dict[str, torch.Tensor]] = []
|
| 97 |
+
self._batch_sizes: list[int] = []
|
| 98 |
+
self._gradient_batch_indices: list[int] = []
|
| 99 |
+
self._gradient_batch_sizes: list[int] = []
|
| 100 |
+
self._gradient_sample_ids: list[tuple[str, ...]] = []
|
| 101 |
+
self._selection_started = False
|
| 102 |
+
self._device: torch.device | None = None
|
| 103 |
+
self._result: FeatureGradientReplayResult | None = None
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def _world_size() -> int:
|
| 107 |
+
return dist.get_world_size() if dist.is_initialized() else 1
|
| 108 |
+
|
| 109 |
+
@staticmethod
|
| 110 |
+
def _rank() -> int:
|
| 111 |
+
return dist.get_rank() if dist.is_initialized() else 0
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def local_samples(self) -> int:
|
| 115 |
+
return sum(self._batch_sizes)
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def selected_local_samples(self) -> int:
|
| 119 |
+
return sum(self._gradient_batch_sizes)
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def finalized(self) -> bool:
|
| 123 |
+
return self._result is not None
|
| 124 |
+
|
| 125 |
+
def _validate_mapping(
|
| 126 |
+
self,
|
| 127 |
+
value: Mapping[str, torch.Tensor],
|
| 128 |
+
label: str,
|
| 129 |
+
*,
|
| 130 |
+
expected_batch: int | None = None,
|
| 131 |
+
) -> tuple[int, torch.device]:
|
| 132 |
+
if not isinstance(value, Mapping):
|
| 133 |
+
raise TypeError(f"{label} must be a branch tensor mapping")
|
| 134 |
+
if set(value) != set(self.loss_module.branch_names):
|
| 135 |
+
raise ValueError(
|
| 136 |
+
f"{label} keys must be {self.loss_module.branch_names}")
|
| 137 |
+
batch_size = None
|
| 138 |
+
device = None
|
| 139 |
+
for name in self.loss_module.branch_names:
|
| 140 |
+
tensor = value[name]
|
| 141 |
+
if (
|
| 142 |
+
not isinstance(tensor, torch.Tensor)
|
| 143 |
+
or tensor.ndim != 3
|
| 144 |
+
or not tensor.is_floating_point()
|
| 145 |
+
or min(tensor.shape) <= 0
|
| 146 |
+
):
|
| 147 |
+
raise ValueError(f"{label}[{name!r}] must be floating [B,N,D]")
|
| 148 |
+
if tuple(tensor.shape[1:]) != self.loss_module.sample_shapes[name]:
|
| 149 |
+
raise ValueError(
|
| 150 |
+
f"{label}[{name!r}] geometry {tuple(tensor.shape[1:])} "
|
| 151 |
+
f"!= {self.loss_module.sample_shapes[name]}")
|
| 152 |
+
if not bool(torch.isfinite(tensor).all().item()):
|
| 153 |
+
raise FloatingPointError(f"{label}[{name!r}] is non-finite")
|
| 154 |
+
if batch_size is None:
|
| 155 |
+
batch_size = int(tensor.shape[0])
|
| 156 |
+
device = tensor.device
|
| 157 |
+
elif int(tensor.shape[0]) != batch_size or tensor.device != device:
|
| 158 |
+
raise ValueError(
|
| 159 |
+
f"all {label} branches must share batch size and device")
|
| 160 |
+
if expected_batch is not None and batch_size != expected_batch:
|
| 161 |
+
raise ValueError(
|
| 162 |
+
f"{label} batch size {batch_size} != {expected_batch}")
|
| 163 |
+
return int(batch_size), device
|
| 164 |
+
|
| 165 |
+
def add(
|
| 166 |
+
self,
|
| 167 |
+
generated_features: Mapping[str, torch.Tensor],
|
| 168 |
+
current_teacher_features: Mapping[str, torch.Tensor] | None = None,
|
| 169 |
+
*,
|
| 170 |
+
gradient_selected: bool = True,
|
| 171 |
+
sample_ids: Sequence[str] | None = None,
|
| 172 |
+
current_teacher_sample_ids: Sequence[str] | None = None,
|
| 173 |
+
) -> int:
|
| 174 |
+
"""Store one rollout's features without retaining its generator graph."""
|
| 175 |
+
if self.finalized:
|
| 176 |
+
raise RuntimeError("cannot add after finalize")
|
| 177 |
+
batch_size, device = self._validate_mapping(
|
| 178 |
+
generated_features, "generated_features")
|
| 179 |
+
if self._device is None:
|
| 180 |
+
self._device = device
|
| 181 |
+
elif device != self._device:
|
| 182 |
+
raise ValueError("all microbatches must share a device")
|
| 183 |
+
if self.local_samples + batch_size > self.target_local_samples:
|
| 184 |
+
raise ValueError("dual GradCache exceeds target_local_samples")
|
| 185 |
+
if gradient_selected:
|
| 186 |
+
self._selection_started = True
|
| 187 |
+
if self.selected_local_samples + batch_size > self.gradient_local_samples:
|
| 188 |
+
raise ValueError("dual GradCache exceeds gradient suffix")
|
| 189 |
+
elif self._selection_started:
|
| 190 |
+
raise ValueError("gradient-selected microbatches must form a suffix")
|
| 191 |
+
|
| 192 |
+
normalized_ids = None
|
| 193 |
+
if sample_ids is not None:
|
| 194 |
+
normalized_ids = tuple(str(value) for value in sample_ids)
|
| 195 |
+
if len(normalized_ids) != batch_size or any(not value for value in normalized_ids):
|
| 196 |
+
raise ValueError("sample_ids must contain one non-empty ID per row")
|
| 197 |
+
if gradient_selected and self.require_sample_id_alignment:
|
| 198 |
+
if normalized_ids is None:
|
| 199 |
+
raise ValueError("selected dual samples require sample_ids")
|
| 200 |
+
teacher_ids = (
|
| 201 |
+
tuple(str(value) for value in current_teacher_sample_ids)
|
| 202 |
+
if current_teacher_sample_ids is not None else None
|
| 203 |
+
)
|
| 204 |
+
if teacher_ids != normalized_ids:
|
| 205 |
+
raise ValueError("student and teacher sample IDs/order differ")
|
| 206 |
+
|
| 207 |
+
if gradient_selected:
|
| 208 |
+
if current_teacher_features is None:
|
| 209 |
+
raise ValueError("selected dual samples require teacher features")
|
| 210 |
+
self._validate_mapping(
|
| 211 |
+
current_teacher_features,
|
| 212 |
+
"current_teacher_features",
|
| 213 |
+
expected_batch=batch_size,
|
| 214 |
+
)
|
| 215 |
+
elif current_teacher_features is not None:
|
| 216 |
+
raise ValueError("prefix microbatches must not carry teacher features")
|
| 217 |
+
|
| 218 |
+
# Collection stage (Algorithm 1): keep only feature values and sample
|
| 219 |
+
# order. Replay state is stored by the trainer, outside this cache.
|
| 220 |
+
staged = {
|
| 221 |
+
name: generated_features[name].detach().float().clone()
|
| 222 |
+
for name in self.loss_module.branch_names
|
| 223 |
+
}
|
| 224 |
+
self._feature_batches.append(staged)
|
| 225 |
+
index = len(self._feature_batches) - 1
|
| 226 |
+
if gradient_selected:
|
| 227 |
+
self._gradient_batch_indices.append(index)
|
| 228 |
+
self._gradient_batch_sizes.append(batch_size)
|
| 229 |
+
self._gradient_sample_ids.append(normalized_ids)
|
| 230 |
+
self._teacher_batches.append({
|
| 231 |
+
name: current_teacher_features[name].detach().float().clone()
|
| 232 |
+
for name in self.loss_module.branch_names
|
| 233 |
+
})
|
| 234 |
+
self._batch_sizes.append(batch_size)
|
| 235 |
+
return index
|
| 236 |
+
|
| 237 |
+
def _gather(self, value: torch.Tensor) -> list[torch.Tensor]:
|
| 238 |
+
if self._world_size() == 1:
|
| 239 |
+
return [value.detach()]
|
| 240 |
+
gathered = [torch.empty_like(value) for _ in range(self._world_size())]
|
| 241 |
+
dist.all_gather(gathered, value.detach().contiguous())
|
| 242 |
+
return gathered
|
| 243 |
+
|
| 244 |
+
def _validate_ready(self) -> None:
|
| 245 |
+
return None
|
| 246 |
+
|
| 247 |
+
def finalize(self) -> FeatureGradientReplayResult:
|
| 248 |
+
"""Evaluate the global objective and cache dL/d feature for replay."""
|
| 249 |
+
if self._result is not None:
|
| 250 |
+
return self._result
|
| 251 |
+
self._validate_ready()
|
| 252 |
+
world_size = self._world_size()
|
| 253 |
+
global_distribution_features = {}
|
| 254 |
+
global_differentiation_features = {}
|
| 255 |
+
global_reference_features = {}
|
| 256 |
+
local_leaves = {}
|
| 257 |
+
# Map each rank's selected suffix into the concatenated B-sample batch.
|
| 258 |
+
# These indices preserve full-batch interactions while only b rows have
|
| 259 |
+
# an autograd path. This function does not randomly resample the subset.
|
| 260 |
+
suffix_start = self.target_local_samples - self.gradient_local_samples
|
| 261 |
+
gradient_indices = torch.cat([
|
| 262 |
+
torch.arange(
|
| 263 |
+
rank * self.target_local_samples + suffix_start,
|
| 264 |
+
(rank + 1) * self.target_local_samples,
|
| 265 |
+
device=self._device,
|
| 266 |
+
dtype=torch.long,
|
| 267 |
+
)
|
| 268 |
+
for rank in range(world_size)
|
| 269 |
+
])
|
| 270 |
+
for name in self.loss_module.branch_names:
|
| 271 |
+
local_distribution_features = torch.cat([
|
| 272 |
+
batch[name] for batch in self._feature_batches
|
| 273 |
+
], dim=0)
|
| 274 |
+
local_differentiation_features = torch.cat([
|
| 275 |
+
self._feature_batches[index][name]
|
| 276 |
+
for index in self._gradient_batch_indices
|
| 277 |
+
], dim=0)
|
| 278 |
+
local_reference_features = torch.cat([
|
| 279 |
+
batch[name] for batch in self._teacher_batches
|
| 280 |
+
], dim=0)
|
| 281 |
+
distribution_parts = self._gather(local_distribution_features)
|
| 282 |
+
differentiation_parts = self._gather(local_differentiation_features)
|
| 283 |
+
reference_parts = self._gather(local_reference_features)
|
| 284 |
+
# Only this rank's selected features are differentiable leaves.
|
| 285 |
+
# Remote features contribute values; their owning rank gets their
|
| 286 |
+
# derivatives. No generator or encoder graph is retained here.
|
| 287 |
+
leaf = local_differentiation_features.detach().requires_grad_(True)
|
| 288 |
+
differentiation_parts[self._rank()] = leaf
|
| 289 |
+
local_leaves[name] = leaf
|
| 290 |
+
global_distribution_features[name] = torch.cat(distribution_parts, dim=0).detach()
|
| 291 |
+
global_differentiation_features[name] = torch.cat(differentiation_parts, dim=0)
|
| 292 |
+
global_reference_features[name] = torch.cat(reference_parts, dim=0).detach()
|
| 293 |
+
|
| 294 |
+
# Resolve all cross-video interactions once. The loss combines the
|
| 295 |
+
# persistent Nystrom summary with current reference features (Eq. 9),
|
| 296 |
+
# then aggregates the encoder objectives (Eqs. 11 and 13).
|
| 297 |
+
raw_loss, metadata = self.loss_module(
|
| 298 |
+
gradient_students=global_differentiation_features,
|
| 299 |
+
student_reference=global_distribution_features,
|
| 300 |
+
current_teacher=global_reference_features,
|
| 301 |
+
gradient_student_indices=gradient_indices,
|
| 302 |
+
)
|
| 303 |
+
optimized = raw_loss * self.loss_scale
|
| 304 |
+
gradients = torch.autograd.grad(
|
| 305 |
+
optimized,
|
| 306 |
+
tuple(local_leaves[name] for name in self.loss_module.branch_names),
|
| 307 |
+
retain_graph=False,
|
| 308 |
+
create_graph=False,
|
| 309 |
+
)
|
| 310 |
+
# Restore the collection order so each feature gradient is replayed
|
| 311 |
+
# through the exact sample and encoder that produced its feature value.
|
| 312 |
+
split_by_branch = {
|
| 313 |
+
name: tuple(part.contiguous() for part in gradient.detach().split(
|
| 314 |
+
self._gradient_batch_sizes, dim=0))
|
| 315 |
+
for name, gradient in zip(self.loss_module.branch_names, gradients)
|
| 316 |
+
}
|
| 317 |
+
local_parts = tuple({
|
| 318 |
+
name: split_by_branch[name][index]
|
| 319 |
+
for name in self.loss_module.branch_names
|
| 320 |
+
} for index in range(len(self._gradient_batch_sizes)))
|
| 321 |
+
# FSDP averages parameter gradients across ranks, so cancel that factor
|
| 322 |
+
# here. This world_size multiplier is separate from the B/b selective
|
| 323 |
+
# differentiation correction (Eq. 12), applied later by the trainer.
|
| 324 |
+
fsdp_parts = tuple({
|
| 325 |
+
name: (value * float(world_size)).contiguous()
|
| 326 |
+
for name, value in part.items()
|
| 327 |
+
} for part in local_parts)
|
| 328 |
+
global_samples = self.target_local_samples * world_size
|
| 329 |
+
gradient_global = self.gradient_local_samples * world_size
|
| 330 |
+
kss_denominator = global_samples * global_samples
|
| 331 |
+
# Keep established metadata names for checkpoint/log compatibility.
|
| 332 |
+
detached_metadata = dict(_detach(metadata))
|
| 333 |
+
detached_metadata.update({
|
| 334 |
+
"raw_loss": raw_loss.detach(),
|
| 335 |
+
"optimized_loss": optimized.detach(),
|
| 336 |
+
"loss_scale": raw_loss.new_tensor(self.loss_scale),
|
| 337 |
+
"gradcache_local_samples": raw_loss.new_tensor(
|
| 338 |
+
self.target_local_samples, dtype=torch.long),
|
| 339 |
+
"gradcache_global_samples": raw_loss.new_tensor(
|
| 340 |
+
global_samples, dtype=torch.long),
|
| 341 |
+
"gradcache_forward_local_samples": raw_loss.new_tensor(
|
| 342 |
+
self.target_local_samples, dtype=torch.long),
|
| 343 |
+
"gradcache_forward_global_samples": raw_loss.new_tensor(
|
| 344 |
+
global_samples, dtype=torch.long),
|
| 345 |
+
"gradcache_gradient_local_samples": raw_loss.new_tensor(
|
| 346 |
+
self.gradient_local_samples, dtype=torch.long),
|
| 347 |
+
"gradcache_gradient_global_samples": raw_loss.new_tensor(
|
| 348 |
+
gradient_global, dtype=torch.long),
|
| 349 |
+
"gradcache_current_teacher_local_samples": raw_loss.new_tensor(
|
| 350 |
+
self.gradient_local_samples, dtype=torch.long),
|
| 351 |
+
"gradcache_current_teacher_global_samples": raw_loss.new_tensor(
|
| 352 |
+
gradient_global, dtype=torch.long),
|
| 353 |
+
"gradcache_kss_denominator": raw_loss.new_tensor(
|
| 354 |
+
kss_denominator, dtype=torch.long),
|
| 355 |
+
"gradcache_partial_gradient_enabled": raw_loss.new_ones(
|
| 356 |
+
(), dtype=torch.long),
|
| 357 |
+
"gradcache_full_student_partial_gradient": raw_loss.new_ones(
|
| 358 |
+
(), dtype=torch.long),
|
| 359 |
+
"gradcache_world_size": raw_loss.new_tensor(
|
| 360 |
+
world_size, dtype=torch.long),
|
| 361 |
+
"gradcache_fsdp_cotangent_scale": raw_loss.new_tensor(
|
| 362 |
+
float(world_size)),
|
| 363 |
+
"gradcache_branch_names": self.loss_module.branch_names,
|
| 364 |
+
})
|
| 365 |
+
self._result = FeatureGradientReplayResult(
|
| 366 |
+
loss=raw_loss.detach(),
|
| 367 |
+
optimized_loss=optimized.detach(),
|
| 368 |
+
metadata=detached_metadata,
|
| 369 |
+
local_cotangents=local_parts,
|
| 370 |
+
fsdp_cotangents=fsdp_parts,
|
| 371 |
+
replay_batch_indices=tuple(self._gradient_batch_indices),
|
| 372 |
+
replay_sample_ids=tuple(self._gradient_sample_ids),
|
| 373 |
+
local_samples=self.target_local_samples,
|
| 374 |
+
global_samples=global_samples,
|
| 375 |
+
gradient_local_samples=self.gradient_local_samples,
|
| 376 |
+
gradient_global_samples=gradient_global,
|
| 377 |
+
current_teacher_local_samples=self.gradient_local_samples,
|
| 378 |
+
current_teacher_global_samples=gradient_global,
|
| 379 |
+
world_size=world_size,
|
| 380 |
+
)
|
| 381 |
+
return self._result
|
| 382 |
+
|
| 383 |
+
def _clear(self) -> None:
|
| 384 |
+
self._feature_batches.clear()
|
| 385 |
+
self._teacher_batches.clear()
|
| 386 |
+
self._batch_sizes.clear()
|
| 387 |
+
self._gradient_batch_indices.clear()
|
| 388 |
+
self._gradient_batch_sizes.clear()
|
| 389 |
+
self._gradient_sample_ids.clear()
|
| 390 |
+
self._selection_started = False
|
| 391 |
+
self._device = None
|
| 392 |
+
self._result = None
|
| 393 |
+
|
| 394 |
+
def commit(self) -> None:
|
| 395 |
+
"""Release cached values after the corresponding optimizer update."""
|
| 396 |
+
if self._result is None:
|
| 397 |
+
raise RuntimeError("cannot commit before finalize")
|
| 398 |
+
self._clear()
|
| 399 |
+
|
| 400 |
+
def discard(self) -> None:
|
| 401 |
+
"""Release an abandoned batch without marking an optimizer update."""
|
| 402 |
+
self._clear()
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# Preserve old imports and serialized class references during the naming cleanup.
|
| 406 |
+
MultiRepresentationFreshVideoGradCache = FeatureGradientReplay
|
| 407 |
+
MultiRepresentationFreshVideoGradCacheResult = FeatureGradientReplayResult
|
| 408 |
+
|
| 409 |
+
__all__ = [
|
| 410 |
+
"FeatureGradientReplay",
|
| 411 |
+
"FeatureGradientReplayResult",
|
| 412 |
+
"MultiRepresentationFreshVideoGradCache",
|
| 413 |
+
"MultiRepresentationFreshVideoGradCacheResult",
|
| 414 |
+
]
|
1.3B/code/trainer/fresh_video_gradcache.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fresh-update gradient cache for video-level distribution losses.
|
| 2 |
+
|
| 3 |
+
The cache deliberately has no state across optimizer updates. It collects a
|
| 4 |
+
fixed number of detached ``[B, N, D]`` feature matrices on every rank, builds
|
| 5 |
+
the exact global fresh batch in deterministic rank order, and differentiates
|
| 6 |
+
the frozen reference loss only with respect to the local rank's feature leaf.
|
| 7 |
+
|
| 8 |
+
The two returned cotangents have distinct meanings:
|
| 9 |
+
|
| 10 |
+
``local_cotangents``
|
| 11 |
+
The mathematical derivatives ``d L_global / d feature_local``.
|
| 12 |
+
|
| 13 |
+
``fsdp_cotangents``
|
| 14 |
+
``world_size * local_cotangents``. These are the cotangents that replay
|
| 15 |
+
should use when FSDP/DDP will average parameter gradients across ranks:
|
| 16 |
+
|
| 17 |
+
``mean_r(world_size * J_r.T @ dL/dF_r) = sum_r J_r.T @ dL/dF_r``.
|
| 18 |
+
|
| 19 |
+
Collection is transactional. ``finalize`` stages a result but retains the
|
| 20 |
+
fresh features until the caller reports optimizer success with ``commit`` or
|
| 21 |
+
failure with ``discard``. Both terminal operations clear all update-local
|
| 22 |
+
state, so no generated sample can leak into the next optimizer update.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
import math
|
| 29 |
+
from typing import Any, Mapping, Sequence
|
| 30 |
+
|
| 31 |
+
import torch
|
| 32 |
+
import torch.distributed as dist
|
| 33 |
+
|
| 34 |
+
from losses.video_nystrom_mmd import VideoNystromMMD
|
| 35 |
+
from losses.multilayer_video_nystrom_mmd import (
|
| 36 |
+
MultiLayerVideoNystromMMD,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _detach_metadata(value: Any) -> Any:
|
| 41 |
+
if isinstance(value, torch.Tensor):
|
| 42 |
+
return value.detach()
|
| 43 |
+
if isinstance(value, Mapping):
|
| 44 |
+
return {
|
| 45 |
+
key: _detach_metadata(item)
|
| 46 |
+
for key, item in value.items()
|
| 47 |
+
}
|
| 48 |
+
if isinstance(value, tuple):
|
| 49 |
+
return tuple(_detach_metadata(item) for item in value)
|
| 50 |
+
if isinstance(value, list):
|
| 51 |
+
return [_detach_metadata(item) for item in value]
|
| 52 |
+
return value
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass(frozen=True)
|
| 56 |
+
class FreshVideoGradCacheResult:
|
| 57 |
+
"""Finalized loss and replay cotangents for one fresh optimizer update."""
|
| 58 |
+
|
| 59 |
+
loss: torch.Tensor
|
| 60 |
+
optimized_loss: torch.Tensor
|
| 61 |
+
metadata: Mapping[str, Any]
|
| 62 |
+
local_cotangents: tuple[torch.Tensor, ...]
|
| 63 |
+
fsdp_cotangents: tuple[torch.Tensor, ...]
|
| 64 |
+
replay_batch_indices: tuple[int, ...]
|
| 65 |
+
replay_sample_ids: tuple[tuple[str, ...], ...]
|
| 66 |
+
local_samples: int
|
| 67 |
+
global_samples: int
|
| 68 |
+
gradient_local_samples: int
|
| 69 |
+
gradient_global_samples: int
|
| 70 |
+
current_teacher_local_samples: int
|
| 71 |
+
current_teacher_global_samples: int
|
| 72 |
+
world_size: int
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class FreshVideoGradCache:
|
| 76 |
+
"""Collect and differentiate one globally fresh video feature batch."""
|
| 77 |
+
|
| 78 |
+
def __init__(
|
| 79 |
+
self,
|
| 80 |
+
loss_module: VideoNystromMMD | MultiLayerVideoNystromMMD,
|
| 81 |
+
*,
|
| 82 |
+
target_local_samples: int = 16,
|
| 83 |
+
gradient_local_samples: int | None = None,
|
| 84 |
+
loss_scale: float = 1.0,
|
| 85 |
+
require_sample_id_alignment: bool = False,
|
| 86 |
+
full_student_partial_gradient: bool = False,
|
| 87 |
+
) -> None:
|
| 88 |
+
if not isinstance(
|
| 89 |
+
loss_module,
|
| 90 |
+
(VideoNystromMMD, MultiLayerVideoNystromMMD),
|
| 91 |
+
):
|
| 92 |
+
raise TypeError(
|
| 93 |
+
"loss_module must be a VideoNystromMMD or "
|
| 94 |
+
"MultiLayerVideoNystromMMD instance")
|
| 95 |
+
if int(target_local_samples) <= 0:
|
| 96 |
+
raise ValueError("target_local_samples must be positive")
|
| 97 |
+
if gradient_local_samples is None:
|
| 98 |
+
gradient_local_samples = int(target_local_samples)
|
| 99 |
+
if not 0 < int(gradient_local_samples) <= int(target_local_samples):
|
| 100 |
+
raise ValueError(
|
| 101 |
+
"gradient_local_samples must be in [1, target_local_samples]")
|
| 102 |
+
if (
|
| 103 |
+
not math.isfinite(float(loss_scale))
|
| 104 |
+
or float(loss_scale) < 0.0
|
| 105 |
+
):
|
| 106 |
+
raise ValueError("loss_scale must be finite and non-negative")
|
| 107 |
+
if any(parameter.requires_grad for parameter in loss_module.parameters()):
|
| 108 |
+
raise ValueError(
|
| 109 |
+
"VideoNystromMMD reference parameters must be frozen")
|
| 110 |
+
|
| 111 |
+
self.loss_module = loss_module
|
| 112 |
+
self.current_teacher_attraction_enabled = bool(getattr(
|
| 113 |
+
loss_module, "current_teacher_attraction_enabled", False))
|
| 114 |
+
self.target_local_samples = int(target_local_samples)
|
| 115 |
+
self.gradient_local_samples = int(gradient_local_samples)
|
| 116 |
+
self.partial_gradient_enabled = bool(
|
| 117 |
+
self.gradient_local_samples < self.target_local_samples)
|
| 118 |
+
self.full_student_partial_gradient = bool(
|
| 119 |
+
full_student_partial_gradient)
|
| 120 |
+
if (
|
| 121 |
+
self.full_student_partial_gradient
|
| 122 |
+
and not self.partial_gradient_enabled
|
| 123 |
+
):
|
| 124 |
+
raise ValueError(
|
| 125 |
+
"full_student_partial_gradient requires "
|
| 126 |
+
"gradient_local_samples < target_local_samples")
|
| 127 |
+
if (
|
| 128 |
+
self.partial_gradient_enabled
|
| 129 |
+
and isinstance(loss_module, MultiLayerVideoNystromMMD)
|
| 130 |
+
):
|
| 131 |
+
raise TypeError(
|
| 132 |
+
"partial-gradient GradCache currently requires a single "
|
| 133 |
+
"VideoNystromMMD layer")
|
| 134 |
+
self.loss_scale = float(loss_scale)
|
| 135 |
+
self.require_sample_id_alignment = bool(require_sample_id_alignment)
|
| 136 |
+
self._feature_batches: list[torch.Tensor] = []
|
| 137 |
+
self._teacher_feature_batches: list[torch.Tensor] = []
|
| 138 |
+
self._batch_sizes: list[int] = []
|
| 139 |
+
self._gradient_batch_indices: list[int] = []
|
| 140 |
+
self._gradient_batch_sizes: list[int] = []
|
| 141 |
+
self._gradient_sample_ids: list[tuple[str, ...]] = []
|
| 142 |
+
self._selection_started = False
|
| 143 |
+
self._sample_shape: tuple[int, int] | None = None
|
| 144 |
+
self._device: torch.device | None = None
|
| 145 |
+
self._result: FreshVideoGradCacheResult | None = None
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def _world_size() -> int:
|
| 149 |
+
if dist.is_available() and dist.is_initialized():
|
| 150 |
+
return dist.get_world_size()
|
| 151 |
+
return 1
|
| 152 |
+
|
| 153 |
+
@staticmethod
|
| 154 |
+
def _rank() -> int:
|
| 155 |
+
if dist.is_available() and dist.is_initialized():
|
| 156 |
+
return dist.get_rank()
|
| 157 |
+
return 0
|
| 158 |
+
|
| 159 |
+
@staticmethod
|
| 160 |
+
def _all_ranks_true(local_value: bool, device: torch.device) -> bool:
|
| 161 |
+
flag = torch.tensor(
|
| 162 |
+
int(bool(local_value)), device=device, dtype=torch.int32)
|
| 163 |
+
if (
|
| 164 |
+
dist.is_available()
|
| 165 |
+
and dist.is_initialized()
|
| 166 |
+
and dist.get_world_size() > 1
|
| 167 |
+
):
|
| 168 |
+
dist.all_reduce(flag, op=dist.ReduceOp.MIN)
|
| 169 |
+
return bool(flag.item())
|
| 170 |
+
|
| 171 |
+
@property
|
| 172 |
+
def local_samples(self) -> int:
|
| 173 |
+
return sum(self._batch_sizes)
|
| 174 |
+
|
| 175 |
+
@property
|
| 176 |
+
def num_microbatches(self) -> int:
|
| 177 |
+
return len(self._feature_batches)
|
| 178 |
+
|
| 179 |
+
@property
|
| 180 |
+
def selected_local_samples(self) -> int:
|
| 181 |
+
return sum(self._gradient_batch_sizes)
|
| 182 |
+
|
| 183 |
+
@property
|
| 184 |
+
def ready(self) -> bool:
|
| 185 |
+
return (
|
| 186 |
+
self.local_samples == self.target_local_samples
|
| 187 |
+
and self.selected_local_samples == self.gradient_local_samples
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
@property
|
| 191 |
+
def finalized(self) -> bool:
|
| 192 |
+
return self._result is not None
|
| 193 |
+
|
| 194 |
+
def add(
|
| 195 |
+
self,
|
| 196 |
+
generated_features: torch.Tensor,
|
| 197 |
+
current_teacher_features: torch.Tensor | None = None,
|
| 198 |
+
*,
|
| 199 |
+
gradient_selected: bool | None = None,
|
| 200 |
+
sample_ids: Sequence[str] | None = None,
|
| 201 |
+
current_teacher_sample_ids: Sequence[str] | None = None,
|
| 202 |
+
) -> int:
|
| 203 |
+
"""Add one detached microbatch and return its zero-based cache index."""
|
| 204 |
+
if self.finalized:
|
| 205 |
+
raise RuntimeError(
|
| 206 |
+
"cannot add features after finalize; commit or discard first")
|
| 207 |
+
if not isinstance(generated_features, torch.Tensor):
|
| 208 |
+
raise TypeError("generated_features must be a torch.Tensor")
|
| 209 |
+
if (
|
| 210 |
+
generated_features.ndim != 3
|
| 211 |
+
or min(generated_features.shape) <= 0
|
| 212 |
+
or not generated_features.is_floating_point()
|
| 213 |
+
):
|
| 214 |
+
raise ValueError(
|
| 215 |
+
"generated_features must be a non-empty floating [B, N, D] "
|
| 216 |
+
"tensor")
|
| 217 |
+
if not bool(torch.isfinite(generated_features).all().item()):
|
| 218 |
+
raise FloatingPointError(
|
| 219 |
+
"generated_features contains non-finite values")
|
| 220 |
+
|
| 221 |
+
if gradient_selected is None:
|
| 222 |
+
if self.partial_gradient_enabled:
|
| 223 |
+
raise TypeError(
|
| 224 |
+
"gradient_selected must be explicit when partial-gradient "
|
| 225 |
+
"GradCache is enabled")
|
| 226 |
+
gradient_selected = True
|
| 227 |
+
gradient_selected = bool(gradient_selected)
|
| 228 |
+
if self._selection_started and not gradient_selected:
|
| 229 |
+
raise ValueError(
|
| 230 |
+
"gradient-selected microbatches must form a contiguous suffix")
|
| 231 |
+
if gradient_selected:
|
| 232 |
+
self._selection_started = True
|
| 233 |
+
|
| 234 |
+
batch_size = int(generated_features.shape[0])
|
| 235 |
+
normalized_sample_ids: tuple[str, ...] | None = None
|
| 236 |
+
normalized_teacher_ids: tuple[str, ...] | None = None
|
| 237 |
+
if sample_ids is not None:
|
| 238 |
+
normalized_sample_ids = tuple(str(value) for value in sample_ids)
|
| 239 |
+
if len(normalized_sample_ids) != batch_size or any(
|
| 240 |
+
not value for value in normalized_sample_ids
|
| 241 |
+
):
|
| 242 |
+
raise ValueError(
|
| 243 |
+
"sample_ids must contain one non-empty ID per student")
|
| 244 |
+
if current_teacher_sample_ids is not None:
|
| 245 |
+
normalized_teacher_ids = tuple(
|
| 246 |
+
str(value) for value in current_teacher_sample_ids)
|
| 247 |
+
if len(normalized_teacher_ids) != batch_size or any(
|
| 248 |
+
not value for value in normalized_teacher_ids
|
| 249 |
+
):
|
| 250 |
+
raise ValueError(
|
| 251 |
+
"current_teacher_sample_ids must contain one non-empty ID "
|
| 252 |
+
"per selected student")
|
| 253 |
+
if self.require_sample_id_alignment and normalized_sample_ids is None:
|
| 254 |
+
raise ValueError(
|
| 255 |
+
"sample_ids are required by this GradCache experiment")
|
| 256 |
+
|
| 257 |
+
if self.current_teacher_attraction_enabled and gradient_selected:
|
| 258 |
+
if not isinstance(current_teacher_features, torch.Tensor):
|
| 259 |
+
raise TypeError(
|
| 260 |
+
"current_teacher_features must be a torch.Tensor for each "
|
| 261 |
+
"selected student microbatch")
|
| 262 |
+
if (
|
| 263 |
+
current_teacher_features.ndim != 3
|
| 264 |
+
or min(current_teacher_features.shape) <= 0
|
| 265 |
+
or not current_teacher_features.is_floating_point()
|
| 266 |
+
):
|
| 267 |
+
raise ValueError(
|
| 268 |
+
"current_teacher_features must be a non-empty floating "
|
| 269 |
+
"[B, N, D] tensor")
|
| 270 |
+
if tuple(current_teacher_features.shape) != tuple(
|
| 271 |
+
generated_features.shape
|
| 272 |
+
):
|
| 273 |
+
raise ValueError(
|
| 274 |
+
"current teacher features must exactly match selected "
|
| 275 |
+
"student [B,N,D]: "
|
| 276 |
+
f"{tuple(current_teacher_features.shape)} != "
|
| 277 |
+
f"{tuple(generated_features.shape)}")
|
| 278 |
+
if current_teacher_features.device != generated_features.device:
|
| 279 |
+
raise ValueError(
|
| 280 |
+
"generated and current teacher features must share a device")
|
| 281 |
+
if not bool(torch.isfinite(
|
| 282 |
+
current_teacher_features.detach()
|
| 283 |
+
).all().item()):
|
| 284 |
+
raise FloatingPointError(
|
| 285 |
+
"current_teacher_features contains non-finite values")
|
| 286 |
+
if self.require_sample_id_alignment and normalized_teacher_ids is None:
|
| 287 |
+
raise ValueError(
|
| 288 |
+
"current_teacher_sample_ids are required for selected "
|
| 289 |
+
"students")
|
| 290 |
+
if (
|
| 291 |
+
normalized_teacher_ids is not None
|
| 292 |
+
and normalized_sample_ids != normalized_teacher_ids
|
| 293 |
+
):
|
| 294 |
+
raise ValueError(
|
| 295 |
+
"matched teacher sample IDs must exactly equal selected "
|
| 296 |
+
"student sample IDs in order")
|
| 297 |
+
elif current_teacher_features is not None:
|
| 298 |
+
raise ValueError(
|
| 299 |
+
"current_teacher_features may only be provided for a selected "
|
| 300 |
+
"microbatch while exact current-teacher attraction is enabled")
|
| 301 |
+
elif current_teacher_sample_ids is not None:
|
| 302 |
+
raise ValueError(
|
| 303 |
+
"current_teacher_sample_ids may only be provided for a "
|
| 304 |
+
"selected current-teacher microbatch")
|
| 305 |
+
|
| 306 |
+
if self.local_samples + batch_size > self.target_local_samples:
|
| 307 |
+
raise ValueError(
|
| 308 |
+
"fresh feature cache would exceed target_local_samples: "
|
| 309 |
+
f"{self.local_samples} + {batch_size} > "
|
| 310 |
+
f"{self.target_local_samples}")
|
| 311 |
+
if (
|
| 312 |
+
gradient_selected
|
| 313 |
+
and self.selected_local_samples + batch_size
|
| 314 |
+
> self.gradient_local_samples
|
| 315 |
+
):
|
| 316 |
+
raise ValueError(
|
| 317 |
+
"fresh feature cache would exceed gradient_local_samples: "
|
| 318 |
+
f"{self.selected_local_samples} + {batch_size} > "
|
| 319 |
+
f"{self.gradient_local_samples}")
|
| 320 |
+
|
| 321 |
+
sample_shape = (
|
| 322 |
+
int(generated_features.shape[1]),
|
| 323 |
+
int(generated_features.shape[2]),
|
| 324 |
+
)
|
| 325 |
+
if self._sample_shape is None:
|
| 326 |
+
self._sample_shape = sample_shape
|
| 327 |
+
self._device = generated_features.device
|
| 328 |
+
elif (
|
| 329 |
+
sample_shape != self._sample_shape
|
| 330 |
+
or generated_features.device != self._device
|
| 331 |
+
):
|
| 332 |
+
raise ValueError(
|
| 333 |
+
"all fresh feature batches on a rank must share [N, D] and "
|
| 334 |
+
"device; expected "
|
| 335 |
+
f"{self._sample_shape} on {self._device}, got "
|
| 336 |
+
f"{sample_shape} on {generated_features.device}")
|
| 337 |
+
|
| 338 |
+
staged = generated_features.detach().to(dtype=torch.float32).clone()
|
| 339 |
+
self._feature_batches.append(staged)
|
| 340 |
+
batch_index = len(self._feature_batches) - 1
|
| 341 |
+
if gradient_selected:
|
| 342 |
+
self._gradient_batch_indices.append(batch_index)
|
| 343 |
+
self._gradient_batch_sizes.append(batch_size)
|
| 344 |
+
self._gradient_sample_ids.append(
|
| 345 |
+
normalized_sample_ids or tuple("" for _ in range(batch_size)))
|
| 346 |
+
if self.current_teacher_attraction_enabled and gradient_selected:
|
| 347 |
+
staged_teacher = current_teacher_features.detach().to(
|
| 348 |
+
dtype=torch.float32).clone()
|
| 349 |
+
self._teacher_feature_batches.append(staged_teacher)
|
| 350 |
+
self._batch_sizes.append(batch_size)
|
| 351 |
+
return batch_index
|
| 352 |
+
|
| 353 |
+
def _validate_distributed_geometry(
|
| 354 |
+
self,
|
| 355 |
+
local_features: torch.Tensor,
|
| 356 |
+
*,
|
| 357 |
+
expected_samples: int,
|
| 358 |
+
label: str,
|
| 359 |
+
) -> None:
|
| 360 |
+
return None
|
| 361 |
+
|
| 362 |
+
def _validate_ready_consensus(self) -> None:
|
| 363 |
+
return None
|
| 364 |
+
|
| 365 |
+
def _gather_detached(
|
| 366 |
+
self,
|
| 367 |
+
local_features: torch.Tensor,
|
| 368 |
+
) -> list[torch.Tensor]:
|
| 369 |
+
if self._world_size() == 1:
|
| 370 |
+
return [local_features.detach()]
|
| 371 |
+
gathered = [
|
| 372 |
+
torch.empty_like(local_features)
|
| 373 |
+
for _ in range(self._world_size())
|
| 374 |
+
]
|
| 375 |
+
dist.all_gather(gathered, local_features.detach().contiguous())
|
| 376 |
+
return gathered
|
| 377 |
+
|
| 378 |
+
def finalize(self) -> FreshVideoGradCacheResult:
|
| 379 |
+
"""Compute the global loss and stage suffix-only replay cotangents."""
|
| 380 |
+
if self._result is not None:
|
| 381 |
+
return self._result
|
| 382 |
+
self._validate_ready_consensus()
|
| 383 |
+
|
| 384 |
+
local_reference = torch.cat(self._feature_batches, dim=0)
|
| 385 |
+
self._validate_distributed_geometry(
|
| 386 |
+
local_reference,
|
| 387 |
+
expected_samples=self.target_local_samples,
|
| 388 |
+
label="student reference",
|
| 389 |
+
)
|
| 390 |
+
gathered_reference = self._gather_detached(local_reference)
|
| 391 |
+
|
| 392 |
+
if self.partial_gradient_enabled:
|
| 393 |
+
selected_batches = [
|
| 394 |
+
self._feature_batches[index]
|
| 395 |
+
for index in self._gradient_batch_indices
|
| 396 |
+
]
|
| 397 |
+
local_gradient = torch.cat(selected_batches, dim=0)
|
| 398 |
+
self._validate_distributed_geometry(
|
| 399 |
+
local_gradient,
|
| 400 |
+
expected_samples=self.gradient_local_samples,
|
| 401 |
+
label="gradient students",
|
| 402 |
+
)
|
| 403 |
+
gathered_gradient = self._gather_detached(local_gradient)
|
| 404 |
+
local_leaf = local_gradient.detach().requires_grad_(True)
|
| 405 |
+
gathered_gradient[self._rank()] = local_leaf
|
| 406 |
+
global_gradient = torch.cat(gathered_gradient, dim=0)
|
| 407 |
+
global_reference = torch.cat(
|
| 408 |
+
gathered_reference, dim=0).detach()
|
| 409 |
+
gradient_student_indices = None
|
| 410 |
+
if self.full_student_partial_gradient:
|
| 411 |
+
# ``global_gradient`` is rank-major, with each rank
|
| 412 |
+
# contributing its contiguous selected suffix. Build the
|
| 413 |
+
# matching positions in the rank-major full student batch so
|
| 414 |
+
# the objective can inject every selected value back into its
|
| 415 |
+
# original location without reordering either population.
|
| 416 |
+
suffix_start = (
|
| 417 |
+
self.target_local_samples
|
| 418 |
+
- self.gradient_local_samples
|
| 419 |
+
)
|
| 420 |
+
gradient_student_indices = torch.cat([
|
| 421 |
+
torch.arange(
|
| 422 |
+
rank * self.target_local_samples + suffix_start,
|
| 423 |
+
(rank + 1) * self.target_local_samples,
|
| 424 |
+
device=global_reference.device,
|
| 425 |
+
dtype=torch.long,
|
| 426 |
+
)
|
| 427 |
+
for rank in range(self._world_size())
|
| 428 |
+
])
|
| 429 |
+
else:
|
| 430 |
+
local_leaf = local_reference.detach().requires_grad_(True)
|
| 431 |
+
gathered_reference[self._rank()] = local_leaf
|
| 432 |
+
global_gradient = torch.cat(gathered_reference, dim=0)
|
| 433 |
+
global_reference = None
|
| 434 |
+
|
| 435 |
+
global_teacher = None
|
| 436 |
+
if self.current_teacher_attraction_enabled:
|
| 437 |
+
if len(self._teacher_feature_batches) != len(
|
| 438 |
+
self._gradient_batch_indices
|
| 439 |
+
):
|
| 440 |
+
raise RuntimeError(
|
| 441 |
+
"matched teacher and selected student GradCache batch "
|
| 442 |
+
"counts differ")
|
| 443 |
+
local_teacher = torch.cat(
|
| 444 |
+
self._teacher_feature_batches, dim=0)
|
| 445 |
+
if tuple(local_teacher.shape) != tuple(
|
| 446 |
+
local_gradient.shape
|
| 447 |
+
if self.partial_gradient_enabled else local_reference.shape
|
| 448 |
+
):
|
| 449 |
+
raise RuntimeError(
|
| 450 |
+
"matched teacher and gradient student shapes differ")
|
| 451 |
+
self._validate_distributed_geometry(
|
| 452 |
+
local_teacher,
|
| 453 |
+
expected_samples=self.gradient_local_samples,
|
| 454 |
+
label="current teacher",
|
| 455 |
+
)
|
| 456 |
+
gathered_teacher = self._gather_detached(local_teacher)
|
| 457 |
+
global_teacher = torch.cat(
|
| 458 |
+
gathered_teacher, dim=0).detach().to(dtype=torch.float32)
|
| 459 |
+
|
| 460 |
+
if self.partial_gradient_enabled:
|
| 461 |
+
if isinstance(self.loss_module, MultiLayerVideoNystromMMD):
|
| 462 |
+
raise TypeError(
|
| 463 |
+
"partial-gradient GradCache currently requires a single "
|
| 464 |
+
"VideoNystromMMD layer")
|
| 465 |
+
partial_loss_kwargs = {
|
| 466 |
+
"gradient_students": global_gradient,
|
| 467 |
+
"student_reference": global_reference,
|
| 468 |
+
"current_teacher": global_teacher,
|
| 469 |
+
}
|
| 470 |
+
if self.full_student_partial_gradient:
|
| 471 |
+
partial_loss_kwargs["gradient_student_indices"] = (
|
| 472 |
+
gradient_student_indices)
|
| 473 |
+
raw_loss, metadata = self.loss_module(**partial_loss_kwargs)
|
| 474 |
+
elif self.current_teacher_attraction_enabled:
|
| 475 |
+
raw_loss, metadata = self.loss_module(
|
| 476 |
+
global_gradient, current_teacher=global_teacher)
|
| 477 |
+
else:
|
| 478 |
+
raw_loss, metadata = self.loss_module(global_gradient)
|
| 479 |
+
if not isinstance(raw_loss, torch.Tensor) or raw_loss.ndim != 0:
|
| 480 |
+
raise TypeError(
|
| 481 |
+
"VideoNystromMMD must return a scalar tensor loss")
|
| 482 |
+
if not self._all_ranks_true(
|
| 483 |
+
bool(torch.isfinite(raw_loss.detach()).item()),
|
| 484 |
+
raw_loss.device,
|
| 485 |
+
):
|
| 486 |
+
raise FloatingPointError(
|
| 487 |
+
"VideoNystromMMD returned a non-finite loss on at least one "
|
| 488 |
+
"rank")
|
| 489 |
+
optimized_loss = raw_loss * self.loss_scale
|
| 490 |
+
|
| 491 |
+
(local_cotangent,) = torch.autograd.grad(
|
| 492 |
+
optimized_loss,
|
| 493 |
+
local_leaf,
|
| 494 |
+
retain_graph=False,
|
| 495 |
+
create_graph=False,
|
| 496 |
+
)
|
| 497 |
+
local_cotangent = local_cotangent.detach()
|
| 498 |
+
if not self._all_ranks_true(
|
| 499 |
+
bool(torch.isfinite(local_cotangent).all().item()),
|
| 500 |
+
local_cotangent.device,
|
| 501 |
+
):
|
| 502 |
+
raise FloatingPointError(
|
| 503 |
+
"VideoNystromMMD returned a non-finite feature cotangent on "
|
| 504 |
+
"at least one rank")
|
| 505 |
+
|
| 506 |
+
world_size = self._world_size()
|
| 507 |
+
local_parts = tuple(
|
| 508 |
+
value.contiguous()
|
| 509 |
+
for value in local_cotangent.split(
|
| 510 |
+
self._gradient_batch_sizes, dim=0)
|
| 511 |
+
)
|
| 512 |
+
fsdp_parts = tuple(
|
| 513 |
+
(value * float(world_size)).contiguous()
|
| 514 |
+
for value in local_parts
|
| 515 |
+
)
|
| 516 |
+
global_samples = self.target_local_samples * world_size
|
| 517 |
+
gradient_global_samples = self.gradient_local_samples * world_size
|
| 518 |
+
teacher_local_samples = (
|
| 519 |
+
self.gradient_local_samples
|
| 520 |
+
if self.current_teacher_attraction_enabled else 0
|
| 521 |
+
)
|
| 522 |
+
teacher_global_samples = teacher_local_samples * world_size
|
| 523 |
+
kss_denominator = global_samples * (
|
| 524 |
+
global_samples
|
| 525 |
+
if self.full_student_partial_gradient
|
| 526 |
+
else gradient_global_samples
|
| 527 |
+
)
|
| 528 |
+
detached_metadata = dict(_detach_metadata(metadata))
|
| 529 |
+
detached_metadata.update({
|
| 530 |
+
"raw_loss": raw_loss.detach(),
|
| 531 |
+
"optimized_loss": optimized_loss.detach(),
|
| 532 |
+
"loss_scale": raw_loss.new_tensor(self.loss_scale),
|
| 533 |
+
"gradcache_local_samples": raw_loss.new_tensor(
|
| 534 |
+
self.target_local_samples, dtype=torch.long),
|
| 535 |
+
"gradcache_global_samples": raw_loss.new_tensor(
|
| 536 |
+
global_samples, dtype=torch.long),
|
| 537 |
+
"gradcache_forward_local_samples": raw_loss.new_tensor(
|
| 538 |
+
self.target_local_samples, dtype=torch.long),
|
| 539 |
+
"gradcache_forward_global_samples": raw_loss.new_tensor(
|
| 540 |
+
global_samples, dtype=torch.long),
|
| 541 |
+
"gradcache_gradient_local_samples": raw_loss.new_tensor(
|
| 542 |
+
self.gradient_local_samples, dtype=torch.long),
|
| 543 |
+
"gradcache_gradient_global_samples": raw_loss.new_tensor(
|
| 544 |
+
gradient_global_samples, dtype=torch.long),
|
| 545 |
+
"gradcache_kss_denominator": raw_loss.new_tensor(
|
| 546 |
+
kss_denominator, dtype=torch.long),
|
| 547 |
+
"gradcache_partial_gradient_enabled": raw_loss.new_tensor(
|
| 548 |
+
int(self.partial_gradient_enabled), dtype=torch.long),
|
| 549 |
+
"gradcache_full_student_partial_gradient": raw_loss.new_tensor(
|
| 550 |
+
int(self.full_student_partial_gradient), dtype=torch.long),
|
| 551 |
+
"gradcache_world_size": raw_loss.new_tensor(
|
| 552 |
+
world_size, dtype=torch.long),
|
| 553 |
+
"gradcache_fsdp_cotangent_scale": raw_loss.new_tensor(
|
| 554 |
+
float(world_size)),
|
| 555 |
+
})
|
| 556 |
+
detached_metadata.update({
|
| 557 |
+
"gradcache_current_teacher_local_samples": raw_loss.new_tensor(
|
| 558 |
+
teacher_local_samples, dtype=torch.long),
|
| 559 |
+
"gradcache_current_teacher_global_samples": raw_loss.new_tensor(
|
| 560 |
+
teacher_global_samples, dtype=torch.long),
|
| 561 |
+
})
|
| 562 |
+
core_global_samples = detached_metadata.get("global_samples")
|
| 563 |
+
if (
|
| 564 |
+
core_global_samples is not None
|
| 565 |
+
and int(torch.as_tensor(core_global_samples).item())
|
| 566 |
+
!= global_samples
|
| 567 |
+
):
|
| 568 |
+
raise ValueError(
|
| 569 |
+
"VideoNystromMMD global_samples metadata disagrees with the "
|
| 570 |
+
f"fresh GradCache: {int(torch.as_tensor(core_global_samples))} "
|
| 571 |
+
f"!= {global_samples}")
|
| 572 |
+
core_gradient_samples = detached_metadata.get(
|
| 573 |
+
"gradient_student_samples")
|
| 574 |
+
if (
|
| 575 |
+
self.partial_gradient_enabled
|
| 576 |
+
and core_gradient_samples is not None
|
| 577 |
+
and int(torch.as_tensor(core_gradient_samples).item())
|
| 578 |
+
!= gradient_global_samples
|
| 579 |
+
):
|
| 580 |
+
raise ValueError(
|
| 581 |
+
"VideoNystromMMD gradient_student_samples metadata disagrees "
|
| 582 |
+
"with the fresh GradCache")
|
| 583 |
+
core_repulsion_denominator = detached_metadata.get(
|
| 584 |
+
"repulsion_denominator")
|
| 585 |
+
if (
|
| 586 |
+
self.partial_gradient_enabled
|
| 587 |
+
and core_repulsion_denominator is not None
|
| 588 |
+
and int(torch.as_tensor(core_repulsion_denominator).item())
|
| 589 |
+
!= kss_denominator
|
| 590 |
+
):
|
| 591 |
+
raise ValueError(
|
| 592 |
+
"VideoNystromMMD Kss denominator disagrees with the fresh "
|
| 593 |
+
f"GradCache: expected {kss_denominator}")
|
| 594 |
+
|
| 595 |
+
self._result = FreshVideoGradCacheResult(
|
| 596 |
+
loss=raw_loss.detach(),
|
| 597 |
+
optimized_loss=optimized_loss.detach(),
|
| 598 |
+
metadata=detached_metadata,
|
| 599 |
+
local_cotangents=local_parts,
|
| 600 |
+
fsdp_cotangents=fsdp_parts,
|
| 601 |
+
replay_batch_indices=tuple(self._gradient_batch_indices),
|
| 602 |
+
replay_sample_ids=tuple(self._gradient_sample_ids),
|
| 603 |
+
local_samples=self.target_local_samples,
|
| 604 |
+
global_samples=global_samples,
|
| 605 |
+
gradient_local_samples=self.gradient_local_samples,
|
| 606 |
+
gradient_global_samples=gradient_global_samples,
|
| 607 |
+
current_teacher_local_samples=teacher_local_samples,
|
| 608 |
+
current_teacher_global_samples=teacher_global_samples,
|
| 609 |
+
world_size=world_size,
|
| 610 |
+
)
|
| 611 |
+
return self._result
|
| 612 |
+
|
| 613 |
+
def _clear(self) -> None:
|
| 614 |
+
self._feature_batches.clear()
|
| 615 |
+
self._teacher_feature_batches.clear()
|
| 616 |
+
self._batch_sizes.clear()
|
| 617 |
+
self._gradient_batch_indices.clear()
|
| 618 |
+
self._gradient_batch_sizes.clear()
|
| 619 |
+
self._gradient_sample_ids.clear()
|
| 620 |
+
self._selection_started = False
|
| 621 |
+
self._sample_shape = None
|
| 622 |
+
self._device = None
|
| 623 |
+
self._result = None
|
| 624 |
+
|
| 625 |
+
def commit(self) -> None:
|
| 626 |
+
"""Clear a finalized cache after a successful optimizer update."""
|
| 627 |
+
if self._result is None:
|
| 628 |
+
raise RuntimeError(
|
| 629 |
+
"cannot commit a fresh feature cache before finalize")
|
| 630 |
+
self._clear()
|
| 631 |
+
|
| 632 |
+
def discard(self) -> None:
|
| 633 |
+
"""Clear collected/finalized data after a skipped or failed update."""
|
| 634 |
+
self._clear()
|
1.3B/code/trainer/multi_representation_fresh_video_gradcache.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility module; read ``trainer.feature_gradient_replay`` instead."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from trainer import feature_gradient_replay as _implementation
|
| 5 |
+
|
| 6 |
+
# Preserve private helpers, monkeypatch targets, and legacy import paths.
|
| 7 |
+
sys.modules[__name__] = _implementation
|
1.3B/code/utils/config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Readable paper terminology at the config boundary, stable runtime keys inside.
|
| 2 |
+
|
| 3 |
+
The public YAML uses distribution/differentiation/reference batches. Translate
|
| 4 |
+
once before loading defaults, constructing models, or relocating assets. The
|
| 5 |
+
resolved config retains the released keys and values so checkpoint/reference
|
| 6 |
+
identities and existing scripts remain compatible. Legacy YAML is still valid.
|
| 7 |
+
"""
|
| 8 |
+
from collections.abc import Mapping
|
| 9 |
+
from copy import deepcopy
|
| 10 |
+
|
| 11 |
+
# New public spelling -> existing runtime/serialized spelling.
|
| 12 |
+
CONFIG_ALIASES = {
|
| 13 |
+
'initial_generator_checkpoint': 'generator_ckpt',
|
| 14 |
+
'initial_generator_checkpoint_sha256': 'generator_ckpt_sha256',
|
| 15 |
+
'training_objective': 'distribution_loss',
|
| 16 |
+
'training_loop': 'trainer',
|
| 17 |
+
'feature_space': 'wan_feature_type',
|
| 18 |
+
'mmd_estimator': 'wan_mmd_estimator',
|
| 19 |
+
'objective_weight': 'wan_kv_loss_weight',
|
| 20 |
+
'normalize_encoder_losses': 'wan_kv_self_normalize',
|
| 21 |
+
'encoder_loss_normalization_epsilon': 'wan_kv_norm_eps',
|
| 22 |
+
'reference_feature_source': 'current_teacher_source',
|
| 23 |
+
'reference_feature_manifest': 'triple_teacher_bank_manifest',
|
| 24 |
+
'reference_feature_manifest_sha256': 'triple_teacher_bank_manifest_sha256',
|
| 25 |
+
'reference_feature_content_hash': 'triple_teacher_bank_content_hash',
|
| 26 |
+
'reference_video_manifest': 'matched_manifest_path',
|
| 27 |
+
'reference_video_count': 'matched_manifest_samples',
|
| 28 |
+
'nystrom_reference_weight': 'triple_nystrom_attraction_weight',
|
| 29 |
+
'monte_carlo_reference_weight': 'triple_nystrom_current_teacher_attraction_weight',
|
| 30 |
+
'dino_loss_weight': 'triple_dinov3_weight',
|
| 31 |
+
'vjepa_loss_weight': 'triple_vjepa2_weight',
|
| 32 |
+
'videomae_loss_weight': 'triple_videomae_v2_weight',
|
| 33 |
+
'encoder_loss_divisor': 'triple_aggregation_divisor',
|
| 34 |
+
'active_feature_encoders': 'triple_active_branches',
|
| 35 |
+
'per_device_microbatch_size': 'batch_size',
|
| 36 |
+
'distribution_samples_per_rank': 'fresh_video_local_samples',
|
| 37 |
+
'differentiation_samples_per_rank': 'fresh_video_generator_grad_local_samples',
|
| 38 |
+
'differentiate_full_distribution_loss': 'fresh_video_full_student_partial_gradient',
|
| 39 |
+
'differentiation_gradient_scale': 'fresh_video_generator_grad_sample_scale',
|
| 40 |
+
'distribution_batch_size': 'total_batch_size',
|
| 41 |
+
'reference_minibatch_size': 'expected_current_teacher_samples',
|
| 42 |
+
'latent_video_shape': 'image_or_video_shape',
|
| 43 |
+
'training_updates': 'max_steps',
|
| 44 |
+
'generator_learning_rate': 'lr',
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
VALUE_ALIASES = {
|
| 48 |
+
'training_objective': {'distribution_matching': 'wan_kv_mmd'},
|
| 49 |
+
'training_loop': {'distribution_matching': 'score_distillation'},
|
| 50 |
+
'feature_space': {'frozen_video_encoders': 'triple_dinov3_vjepa2_videomae_v2_dense_latent'},
|
| 51 |
+
'mmd_estimator': {'hybrid_nystrom_monte_carlo': 'nystrom_exact_repulsion'},
|
| 52 |
+
'reference_feature_source': {'matched_encoder_features': 'matched_triple_feature_bank'},
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def resolve_config_aliases(config: Mapping) -> dict:
|
| 57 |
+
"""Return the released runtime schema without mutating the caller's data.
|
| 58 |
+
|
| 59 |
+
An explicitly supplied readable key takes precedence over its legacy alias.
|
| 60 |
+
This also permits overriding a legacy base config with readable settings.
|
| 61 |
+
"""
|
| 62 |
+
result = {}
|
| 63 |
+
overridden = {legacy for readable, legacy in CONFIG_ALIASES.items() if readable in config}
|
| 64 |
+
for key, value in config.items():
|
| 65 |
+
if key in CONFIG_ALIASES:
|
| 66 |
+
legacy = CONFIG_ALIASES[key]
|
| 67 |
+
replacements = VALUE_ALIASES.get(key, {})
|
| 68 |
+
result[legacy] = replacements.get(value, value) if isinstance(value, str) else deepcopy(value)
|
| 69 |
+
elif key not in overridden:
|
| 70 |
+
result[key] = deepcopy(value)
|
| 71 |
+
return result
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_config(path, default_path=None):
|
| 75 |
+
"""Resolve public names before merging defaults; preserve OmegaConf behavior."""
|
| 76 |
+
from omegaconf import OmegaConf
|
| 77 |
+
|
| 78 |
+
def read(filename):
|
| 79 |
+
config = OmegaConf.to_container(OmegaConf.load(filename), resolve=False)
|
| 80 |
+
return OmegaConf.create(resolve_config_aliases(config))
|
| 81 |
+
|
| 82 |
+
config = read(path)
|
| 83 |
+
return OmegaConf.merge(read(default_path), config) if default_path else config
|
1.3B/code/utils/dataset.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.lmdb import get_array_shape_from_lmdb, retrieve_row_from_lmdb
|
| 2 |
+
from torch.utils.data import Dataset
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
import lmdb
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TextDataset(Dataset):
|
| 13 |
+
def __init__(self, prompt_path, extended_prompt_path=None):
|
| 14 |
+
with open(prompt_path, encoding="utf-8") as f:
|
| 15 |
+
self.prompt_list = [line.rstrip() for line in f]
|
| 16 |
+
|
| 17 |
+
if extended_prompt_path is not None:
|
| 18 |
+
with open(extended_prompt_path, encoding="utf-8") as f:
|
| 19 |
+
self.extended_prompt_list = [line.rstrip() for line in f]
|
| 20 |
+
assert len(self.extended_prompt_list) == len(self.prompt_list)
|
| 21 |
+
else:
|
| 22 |
+
self.extended_prompt_list = None
|
| 23 |
+
|
| 24 |
+
def __len__(self):
|
| 25 |
+
return len(self.prompt_list)
|
| 26 |
+
|
| 27 |
+
def __getitem__(self, idx):
|
| 28 |
+
batch = {
|
| 29 |
+
"prompts": self.prompt_list[idx],
|
| 30 |
+
"idx": idx,
|
| 31 |
+
}
|
| 32 |
+
if self.extended_prompt_list is not None:
|
| 33 |
+
batch["extended_prompts"] = self.extended_prompt_list[idx]
|
| 34 |
+
return batch
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ODERegressionLMDBDataset(Dataset):
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
data_path: str,
|
| 41 |
+
max_pair: int = int(1e8),
|
| 42 |
+
*,
|
| 43 |
+
load_latents: bool = True,
|
| 44 |
+
load_terminal_latent_only: bool = False,
|
| 45 |
+
):
|
| 46 |
+
self.env = lmdb.open(data_path, readonly=True,
|
| 47 |
+
lock=False, readahead=False, meminit=False)
|
| 48 |
+
|
| 49 |
+
self.latents_shape = get_array_shape_from_lmdb(self.env, 'latents')
|
| 50 |
+
self.max_pair = max_pair
|
| 51 |
+
self.load_latents = bool(load_latents)
|
| 52 |
+
self.load_terminal_latent_only = bool(
|
| 53 |
+
load_terminal_latent_only)
|
| 54 |
+
if self.load_terminal_latent_only and not self.load_latents:
|
| 55 |
+
raise ValueError(
|
| 56 |
+
"load_terminal_latent_only requires load_latents=True")
|
| 57 |
+
|
| 58 |
+
def __len__(self):
|
| 59 |
+
return min(self.latents_shape[0], self.max_pair)
|
| 60 |
+
|
| 61 |
+
def __getitem__(self, idx):
|
| 62 |
+
"""
|
| 63 |
+
Outputs:
|
| 64 |
+
- prompts: List of Strings
|
| 65 |
+
- latents: Tensor of shape (num_denoising_steps, num_frames, num_channels, height, width). It is ordered from pure noise to clean image.
|
| 66 |
+
"""
|
| 67 |
+
prompts = retrieve_row_from_lmdb(
|
| 68 |
+
self.env,
|
| 69 |
+
"prompts", str, idx
|
| 70 |
+
)
|
| 71 |
+
batch = {
|
| 72 |
+
"prompts": prompts,
|
| 73 |
+
"idx": idx,
|
| 74 |
+
}
|
| 75 |
+
if self.load_latents:
|
| 76 |
+
latents = retrieve_row_from_lmdb(
|
| 77 |
+
self.env,
|
| 78 |
+
"latents", np.float16, idx, shape=self.latents_shape[1:]
|
| 79 |
+
)
|
| 80 |
+
if len(latents.shape) == 4:
|
| 81 |
+
if not self.load_terminal_latent_only:
|
| 82 |
+
latents = latents[None, ...]
|
| 83 |
+
elif self.load_terminal_latent_only:
|
| 84 |
+
# Keep only the clean terminal trajectory row in host memory.
|
| 85 |
+
latents = latents[-1]
|
| 86 |
+
batch["ode_latent"] = torch.tensor(
|
| 87 |
+
latents, dtype=torch.float32)
|
| 88 |
+
return batch
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class ShardingLMDBDataset(Dataset):
|
| 92 |
+
def __init__(self, data_path: str, max_pair: int = int(1e8)):
|
| 93 |
+
self.envs = []
|
| 94 |
+
self.index = []
|
| 95 |
+
|
| 96 |
+
for fname in sorted(os.listdir(data_path)):
|
| 97 |
+
path = os.path.join(data_path, fname)
|
| 98 |
+
env = lmdb.open(path,
|
| 99 |
+
readonly=True,
|
| 100 |
+
lock=False,
|
| 101 |
+
readahead=False,
|
| 102 |
+
meminit=False)
|
| 103 |
+
self.envs.append(env)
|
| 104 |
+
|
| 105 |
+
self.latents_shape = [None] * len(self.envs)
|
| 106 |
+
for shard_id, env in enumerate(self.envs):
|
| 107 |
+
self.latents_shape[shard_id] = get_array_shape_from_lmdb(env, 'latents')
|
| 108 |
+
for local_i in range(self.latents_shape[shard_id][0]):
|
| 109 |
+
self.index.append((shard_id, local_i))
|
| 110 |
+
|
| 111 |
+
# print("shard_id ", shard_id, " local_i ", local_i)
|
| 112 |
+
|
| 113 |
+
self.max_pair = max_pair
|
| 114 |
+
|
| 115 |
+
def __len__(self):
|
| 116 |
+
return len(self.index)
|
| 117 |
+
|
| 118 |
+
def __getitem__(self, idx):
|
| 119 |
+
"""
|
| 120 |
+
Outputs:
|
| 121 |
+
- prompts: List of Strings
|
| 122 |
+
- latents: Tensor of shape (num_denoising_steps, num_frames, num_channels, height, width). It is ordered from pure noise to clean image.
|
| 123 |
+
"""
|
| 124 |
+
shard_id, local_idx = self.index[idx]
|
| 125 |
+
|
| 126 |
+
latents = retrieve_row_from_lmdb(
|
| 127 |
+
self.envs[shard_id],
|
| 128 |
+
"latents", np.float16, local_idx,
|
| 129 |
+
shape=self.latents_shape[shard_id][1:]
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
if len(latents.shape) == 4:
|
| 133 |
+
latents = latents[None, ...]
|
| 134 |
+
|
| 135 |
+
prompts = retrieve_row_from_lmdb(
|
| 136 |
+
self.envs[shard_id],
|
| 137 |
+
"prompts", str, local_idx
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
return {
|
| 141 |
+
"prompts": prompts,
|
| 142 |
+
"ode_latent": torch.tensor(latents, dtype=torch.float32)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class TextImagePairDataset(Dataset):
|
| 147 |
+
def __init__(
|
| 148 |
+
self,
|
| 149 |
+
data_dir,
|
| 150 |
+
transform=None,
|
| 151 |
+
eval_first_n=-1,
|
| 152 |
+
pad_to_multiple_of=None
|
| 153 |
+
):
|
| 154 |
+
"""
|
| 155 |
+
Args:
|
| 156 |
+
data_dir (str): Path to the directory containing:
|
| 157 |
+
- target_crop_info_*.json (metadata file)
|
| 158 |
+
- */ (subdirectory containing images with matching aspect ratio)
|
| 159 |
+
transform (callable, optional): Optional transform to be applied on the image
|
| 160 |
+
"""
|
| 161 |
+
self.transform = transform
|
| 162 |
+
data_dir = Path(data_dir)
|
| 163 |
+
|
| 164 |
+
# Find the metadata JSON file
|
| 165 |
+
metadata_files = list(data_dir.glob('target_crop_info_*.json'))
|
| 166 |
+
if not metadata_files:
|
| 167 |
+
raise FileNotFoundError(f"No metadata file found in {data_dir}")
|
| 168 |
+
if len(metadata_files) > 1:
|
| 169 |
+
raise ValueError(f"Multiple metadata files found in {data_dir}")
|
| 170 |
+
|
| 171 |
+
metadata_path = metadata_files[0]
|
| 172 |
+
# Extract aspect ratio from metadata filename (e.g. target_crop_info_26-15.json -> 26-15)
|
| 173 |
+
aspect_ratio = metadata_path.stem.split('_')[-1]
|
| 174 |
+
|
| 175 |
+
# Use aspect ratio subfolder for images
|
| 176 |
+
self.image_dir = data_dir / aspect_ratio
|
| 177 |
+
if not self.image_dir.exists():
|
| 178 |
+
raise FileNotFoundError(f"Image directory not found: {self.image_dir}")
|
| 179 |
+
|
| 180 |
+
# Load metadata
|
| 181 |
+
with open(metadata_path, 'r') as f:
|
| 182 |
+
self.metadata = json.load(f)
|
| 183 |
+
|
| 184 |
+
eval_first_n = eval_first_n if eval_first_n != -1 else len(self.metadata)
|
| 185 |
+
self.metadata = self.metadata[:eval_first_n]
|
| 186 |
+
|
| 187 |
+
# Verify all images exist
|
| 188 |
+
for item in self.metadata:
|
| 189 |
+
image_path = self.image_dir / item['file_name']
|
| 190 |
+
if not image_path.exists():
|
| 191 |
+
raise FileNotFoundError(f"Image not found: {image_path}")
|
| 192 |
+
|
| 193 |
+
self.dummy_prompt = "DUMMY PROMPT"
|
| 194 |
+
self.pre_pad_len = len(self.metadata)
|
| 195 |
+
if pad_to_multiple_of is not None and len(self.metadata) % pad_to_multiple_of != 0:
|
| 196 |
+
# Duplicate the last entry
|
| 197 |
+
self.metadata += [self.metadata[-1]] * (
|
| 198 |
+
pad_to_multiple_of - len(self.metadata) % pad_to_multiple_of
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
def __len__(self):
|
| 202 |
+
return len(self.metadata)
|
| 203 |
+
|
| 204 |
+
def __getitem__(self, idx):
|
| 205 |
+
"""
|
| 206 |
+
Returns:
|
| 207 |
+
dict: A dictionary containing:
|
| 208 |
+
- image: PIL Image
|
| 209 |
+
- caption: str
|
| 210 |
+
- target_bbox: list of int [x1, y1, x2, y2]
|
| 211 |
+
- target_ratio: str
|
| 212 |
+
- type: str
|
| 213 |
+
- origin_size: tuple of int (width, height)
|
| 214 |
+
"""
|
| 215 |
+
item = self.metadata[idx]
|
| 216 |
+
|
| 217 |
+
# Load image
|
| 218 |
+
image_path = self.image_dir / item['file_name']
|
| 219 |
+
image = Image.open(image_path).convert('RGB')
|
| 220 |
+
|
| 221 |
+
# Apply transform if specified
|
| 222 |
+
if self.transform:
|
| 223 |
+
image = self.transform(image)
|
| 224 |
+
|
| 225 |
+
return {
|
| 226 |
+
'image': image,
|
| 227 |
+
'prompts': item['caption'],
|
| 228 |
+
'target_bbox': item['target_crop']['target_bbox'],
|
| 229 |
+
'target_ratio': item['target_crop']['target_ratio'],
|
| 230 |
+
'type': item['type'],
|
| 231 |
+
'origin_size': (item['origin_width'], item['origin_height']),
|
| 232 |
+
'idx': idx
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class _CyclingDataLoader:
|
| 237 |
+
"""Infinite DataLoader iterator with epoch-aware DistributedSampler state."""
|
| 238 |
+
|
| 239 |
+
def __init__(self, dataloader):
|
| 240 |
+
self.dataloader = dataloader
|
| 241 |
+
self.epoch = 0
|
| 242 |
+
self._iterator = None
|
| 243 |
+
|
| 244 |
+
def __iter__(self):
|
| 245 |
+
return self
|
| 246 |
+
|
| 247 |
+
def _new_iterator(self):
|
| 248 |
+
if hasattr(self.dataloader.sampler, "set_epoch"):
|
| 249 |
+
self.dataloader.sampler.set_epoch(self.epoch)
|
| 250 |
+
self._iterator = iter(self.dataloader)
|
| 251 |
+
|
| 252 |
+
def __next__(self):
|
| 253 |
+
if self._iterator is None:
|
| 254 |
+
self._new_iterator()
|
| 255 |
+
try:
|
| 256 |
+
return next(self._iterator)
|
| 257 |
+
except StopIteration:
|
| 258 |
+
self.epoch += 1
|
| 259 |
+
self._new_iterator()
|
| 260 |
+
return next(self._iterator)
|
| 261 |
+
|
| 262 |
+
def seek(self, consumed_batches):
|
| 263 |
+
"""Position the next item at an absolute batch counter."""
|
| 264 |
+
length = len(self.dataloader)
|
| 265 |
+
if length <= 0:
|
| 266 |
+
raise ValueError("cannot cycle an empty DataLoader")
|
| 267 |
+
self.epoch, offset = divmod(int(consumed_batches), length)
|
| 268 |
+
self._new_iterator()
|
| 269 |
+
for _ in range(offset):
|
| 270 |
+
next(self._iterator)
|
| 271 |
+
return offset
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def cycle(dl):
|
| 275 |
+
return _CyclingDataLoader(dl)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# Compatibility re-exports keep legacy callers on ``utils.dataset`` while the
|
| 279 |
+
# prompt/video implementation remains isolated from the LMDB dataset module.
|
| 280 |
+
from utils.openvid_dataset import ( # noqa: E402
|
| 281 |
+
OpenVidPromptVideoDataset,
|
| 282 |
+
collate_openvid_prompt_video,
|
| 283 |
+
)
|
1.3B/code/utils/dinov3_vjepa2_feature_bank.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Strict mmap-backed teacher bank for DINOv3 + V-JEPA2 features."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Mapping, Sequence
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from safetensors import safe_open
|
| 13 |
+
from torch.utils.data import Dataset
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
BANK_KIND = "dual_dinov3_vjepa2_matched_teacher_bank"
|
| 17 |
+
BANK_SCHEMA_VERSION = 1
|
| 18 |
+
BRANCH_SHAPES = {
|
| 19 |
+
"dinov3": (1024, 1024),
|
| 20 |
+
"vjepa2": (1024, 1024),
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _canonical_bytes(value: Any) -> bytes:
|
| 25 |
+
return json.dumps(
|
| 26 |
+
value,
|
| 27 |
+
sort_keys=True,
|
| 28 |
+
separators=(",", ":"),
|
| 29 |
+
ensure_ascii=False,
|
| 30 |
+
allow_nan=False,
|
| 31 |
+
).encode("utf-8")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def canonical_sha256(value: Any) -> str:
|
| 35 |
+
return hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def file_sha256(path: str | os.PathLike) -> str:
|
| 39 |
+
digest = hashlib.sha256()
|
| 40 |
+
with open(path, "rb") as handle:
|
| 41 |
+
while True:
|
| 42 |
+
block = handle.read(8 * 1024 * 1024)
|
| 43 |
+
if not block:
|
| 44 |
+
break
|
| 45 |
+
digest.update(block)
|
| 46 |
+
return digest.hexdigest()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _strict_sha256(value: object, label: str) -> str:
|
| 50 |
+
normalized = str(value)
|
| 51 |
+
if (
|
| 52 |
+
len(normalized) != 64
|
| 53 |
+
or any(character not in "0123456789abcdef" for character in normalized)
|
| 54 |
+
):
|
| 55 |
+
raise ValueError(f"{label} must be a lowercase SHA-256")
|
| 56 |
+
return normalized
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class DINOv3VJEPA2FeatureBank(Dataset):
|
| 60 |
+
"""Metadata dataset plus random-access frozen FP16 feature rows."""
|
| 61 |
+
|
| 62 |
+
def __init__(
|
| 63 |
+
self,
|
| 64 |
+
manifest_path: str | os.PathLike,
|
| 65 |
+
*,
|
| 66 |
+
expected_manifest_sha256: str,
|
| 67 |
+
expected_content_hash: str,
|
| 68 |
+
expected_source_manifest_sha256: str,
|
| 69 |
+
expected_source_csv_sha256: str,
|
| 70 |
+
expected_sample_identity_sha256: str,
|
| 71 |
+
expected_sample_count: int = 5000,
|
| 72 |
+
expected_vjepa_model_sha256: str | None = None,
|
| 73 |
+
expected_dinov3_checkpoint_sha256: str | None = None,
|
| 74 |
+
expected_dinov3_source_commit: str | None = None,
|
| 75 |
+
expected_shard_size: int = 32,
|
| 76 |
+
validate_all_shards: bool = True,
|
| 77 |
+
) -> None:
|
| 78 |
+
self.manifest_path = Path(manifest_path).expanduser().resolve()
|
| 79 |
+
if not self.manifest_path.is_file():
|
| 80 |
+
raise FileNotFoundError(self.manifest_path)
|
| 81 |
+
actual_manifest_sha = file_sha256(self.manifest_path)
|
| 82 |
+
if actual_manifest_sha != _strict_sha256(
|
| 83 |
+
expected_manifest_sha256, "expected_manifest_sha256"
|
| 84 |
+
):
|
| 85 |
+
raise ValueError("DINOv3+V-JEPA2 teacher bank manifest SHA-256 mismatch")
|
| 86 |
+
with self.manifest_path.open("r", encoding="utf-8") as handle:
|
| 87 |
+
manifest = json.load(handle)
|
| 88 |
+
if not isinstance(manifest, dict):
|
| 89 |
+
raise TypeError("DINOv3+V-JEPA2 teacher bank manifest must be an object")
|
| 90 |
+
required = {
|
| 91 |
+
"schema_version",
|
| 92 |
+
"kind",
|
| 93 |
+
"sample_count",
|
| 94 |
+
"source_manifest",
|
| 95 |
+
"branches",
|
| 96 |
+
"samples",
|
| 97 |
+
"shards",
|
| 98 |
+
"content_hash",
|
| 99 |
+
}
|
| 100 |
+
if set(manifest) != required:
|
| 101 |
+
raise ValueError(
|
| 102 |
+
"DINOv3+V-JEPA2 teacher bank manifest keys mismatch: "
|
| 103 |
+
f"{sorted(manifest)} != {sorted(required)}")
|
| 104 |
+
if (
|
| 105 |
+
manifest["schema_version"] != BANK_SCHEMA_VERSION
|
| 106 |
+
or manifest["kind"] != BANK_KIND
|
| 107 |
+
or int(manifest["sample_count"]) != int(expected_sample_count)
|
| 108 |
+
):
|
| 109 |
+
raise ValueError("DINOv3+V-JEPA2 teacher bank kind/schema/sample count mismatch")
|
| 110 |
+
payload = dict(manifest)
|
| 111 |
+
content_hash = payload.pop("content_hash")
|
| 112 |
+
if canonical_sha256(payload) != content_hash:
|
| 113 |
+
raise ValueError("DINOv3+V-JEPA2 teacher bank content_hash is invalid")
|
| 114 |
+
if content_hash != _strict_sha256(
|
| 115 |
+
expected_content_hash, "expected_content_hash"
|
| 116 |
+
):
|
| 117 |
+
raise ValueError("DINOv3+V-JEPA2 teacher bank content identity mismatch")
|
| 118 |
+
source = manifest["source_manifest"]
|
| 119 |
+
if source.get("sha256") != _strict_sha256(
|
| 120 |
+
expected_source_manifest_sha256,
|
| 121 |
+
"expected_source_manifest_sha256",
|
| 122 |
+
):
|
| 123 |
+
raise ValueError("DINOv3+V-JEPA2 bank source manifest SHA-256 mismatch")
|
| 124 |
+
if source.get("source_csv_sha256") != _strict_sha256(
|
| 125 |
+
expected_source_csv_sha256,
|
| 126 |
+
"expected_source_csv_sha256",
|
| 127 |
+
):
|
| 128 |
+
raise ValueError("DINOv3+V-JEPA2 bank source CSV SHA-256 mismatch")
|
| 129 |
+
if source.get("sample_identity_sha256") != _strict_sha256(
|
| 130 |
+
expected_sample_identity_sha256,
|
| 131 |
+
"expected_sample_identity_sha256",
|
| 132 |
+
):
|
| 133 |
+
raise ValueError("DINOv3+V-JEPA2 bank sample identity mismatch")
|
| 134 |
+
branches = manifest["branches"]
|
| 135 |
+
if set(branches) != set(BRANCH_SHAPES):
|
| 136 |
+
raise ValueError("DINOv3+V-JEPA2 teacher bank branch set mismatch")
|
| 137 |
+
for name, expected_shape in BRANCH_SHAPES.items():
|
| 138 |
+
branch = branches[name]
|
| 139 |
+
if (
|
| 140 |
+
tuple(branch.get("sample_shape", ())) != expected_shape
|
| 141 |
+
or branch.get("dtype") != "float16"
|
| 142 |
+
or not isinstance(branch.get("feature_contract"), Mapping)
|
| 143 |
+
or not isinstance(branch.get("model_identity"), Mapping)
|
| 144 |
+
):
|
| 145 |
+
raise ValueError(f"DINOv3+V-JEPA2 teacher branch {name!r} contract mismatch")
|
| 146 |
+
if expected_vjepa_model_sha256 is not None and (
|
| 147 |
+
branches["vjepa2"]["model_identity"].get("sha256")
|
| 148 |
+
!= _strict_sha256(
|
| 149 |
+
expected_vjepa_model_sha256,
|
| 150 |
+
"expected_vjepa_model_sha256",
|
| 151 |
+
)
|
| 152 |
+
):
|
| 153 |
+
raise ValueError("V-JEPA2 bank model SHA-256 mismatch")
|
| 154 |
+
if expected_dinov3_checkpoint_sha256 is not None and (
|
| 155 |
+
branches["dinov3"]["model_identity"].get("checkpoint_sha256")
|
| 156 |
+
!= _strict_sha256(
|
| 157 |
+
expected_dinov3_checkpoint_sha256,
|
| 158 |
+
"expected_dinov3_checkpoint_sha256",
|
| 159 |
+
)
|
| 160 |
+
):
|
| 161 |
+
raise ValueError("DINOv3 bank checkpoint SHA-256 mismatch")
|
| 162 |
+
if expected_dinov3_source_commit is not None and (
|
| 163 |
+
branches["dinov3"]["model_identity"].get("source_commit")
|
| 164 |
+
!= str(expected_dinov3_source_commit)
|
| 165 |
+
):
|
| 166 |
+
raise ValueError("DINOv3 bank source commit mismatch")
|
| 167 |
+
if type(expected_shard_size) is not int or expected_shard_size <= 0:
|
| 168 |
+
raise ValueError("expected_shard_size must be positive")
|
| 169 |
+
|
| 170 |
+
samples = manifest["samples"]
|
| 171 |
+
if len(samples) != int(expected_sample_count):
|
| 172 |
+
raise ValueError("DINOv3+V-JEPA2 bank sample table length mismatch")
|
| 173 |
+
self.samples = []
|
| 174 |
+
self._sample_index = {}
|
| 175 |
+
for expected_index, item in enumerate(samples):
|
| 176 |
+
if (
|
| 177 |
+
not isinstance(item, dict)
|
| 178 |
+
or set(item) != {"index", "sample_id", "prompt"}
|
| 179 |
+
or int(item["index"]) != expected_index
|
| 180 |
+
or not isinstance(item["sample_id"], str)
|
| 181 |
+
or not item["sample_id"]
|
| 182 |
+
or not isinstance(item["prompt"], str)
|
| 183 |
+
):
|
| 184 |
+
raise ValueError(
|
| 185 |
+
f"invalid DINOv3+V-JEPA2 bank sample record at {expected_index}")
|
| 186 |
+
if item["sample_id"] in self._sample_index:
|
| 187 |
+
raise ValueError("DINOv3+V-JEPA2 bank sample IDs must be unique")
|
| 188 |
+
self._sample_index[item["sample_id"]] = expected_index
|
| 189 |
+
self.samples.append(dict(item))
|
| 190 |
+
|
| 191 |
+
self.root = self.manifest_path.parent
|
| 192 |
+
self.shards = []
|
| 193 |
+
self._row_to_shard: list[tuple[int, int]] = [None] * len(samples)
|
| 194 |
+
expected_start = 0
|
| 195 |
+
for shard_index, item in enumerate(manifest["shards"]):
|
| 196 |
+
required_shard = {
|
| 197 |
+
"index", "path", "sample_start", "sample_end",
|
| 198 |
+
"sample_ids_sha256", "sha256", "content_hash",
|
| 199 |
+
}
|
| 200 |
+
if not isinstance(item, dict) or set(item) != required_shard:
|
| 201 |
+
raise ValueError(f"invalid shard record {shard_index}")
|
| 202 |
+
start = int(item["sample_start"])
|
| 203 |
+
end = int(item["sample_end"])
|
| 204 |
+
if (
|
| 205 |
+
int(item["index"]) != shard_index
|
| 206 |
+
or start != expected_start
|
| 207 |
+
or end <= start
|
| 208 |
+
or end > len(samples)
|
| 209 |
+
or end - start != min(expected_shard_size, len(samples) - start)
|
| 210 |
+
):
|
| 211 |
+
raise ValueError(
|
| 212 |
+
"DINOv3+V-JEPA2 bank shard ranges must be contiguous fixed-size "
|
| 213 |
+
f"chunks of {expected_shard_size}")
|
| 214 |
+
relative = Path(str(item["path"]))
|
| 215 |
+
if relative.is_absolute() or ".." in relative.parts:
|
| 216 |
+
raise ValueError("DINOv3+V-JEPA2 bank shard path must stay under bank root")
|
| 217 |
+
path = (self.root / relative).resolve()
|
| 218 |
+
if self.root not in path.parents or not path.is_file():
|
| 219 |
+
raise FileNotFoundError(path)
|
| 220 |
+
ids = [samples[row]["sample_id"] for row in range(start, end)]
|
| 221 |
+
if canonical_sha256(ids) != item["sample_ids_sha256"]:
|
| 222 |
+
raise ValueError("DINOv3+V-JEPA2 bank shard sample ID hash mismatch")
|
| 223 |
+
if validate_all_shards and file_sha256(path) != item["sha256"]:
|
| 224 |
+
raise ValueError(f"DINOv3+V-JEPA2 bank shard SHA-256 mismatch: {path}")
|
| 225 |
+
with safe_open(str(path), framework="pt", device="cpu") as handle:
|
| 226 |
+
metadata = handle.metadata() or {}
|
| 227 |
+
if metadata.get("content_hash") != item["content_hash"]:
|
| 228 |
+
raise ValueError("DINOv3+V-JEPA2 bank shard embedded content hash mismatch")
|
| 229 |
+
for name, shape in BRANCH_SHAPES.items():
|
| 230 |
+
tensor_shape = tuple(handle.get_slice(name).get_shape())
|
| 231 |
+
if tensor_shape != (end - start, *shape):
|
| 232 |
+
raise ValueError(
|
| 233 |
+
f"DINOv3+V-JEPA2 bank shard tensor {name!r} shape mismatch")
|
| 234 |
+
for row in range(start, end):
|
| 235 |
+
self._row_to_shard[row] = (shard_index, row - start)
|
| 236 |
+
self.shards.append({**item, "resolved_path": str(path)})
|
| 237 |
+
expected_start = end
|
| 238 |
+
if expected_start != len(samples):
|
| 239 |
+
raise ValueError("DINOv3+V-JEPA2 bank shards do not cover all samples")
|
| 240 |
+
self.manifest = manifest
|
| 241 |
+
self.content_hash = content_hash
|
| 242 |
+
self.manifest_sha256 = actual_manifest_sha
|
| 243 |
+
|
| 244 |
+
def __len__(self) -> int:
|
| 245 |
+
return len(self.samples)
|
| 246 |
+
|
| 247 |
+
def __getitem__(self, index: int) -> dict[str, object]:
|
| 248 |
+
item = self.samples[int(index)]
|
| 249 |
+
return {
|
| 250 |
+
"prompts": item["prompt"],
|
| 251 |
+
"sample_ids": item["sample_id"],
|
| 252 |
+
"teacher_bank_rows": int(item["index"]),
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
@staticmethod
|
| 256 |
+
def collate_fn(items: Sequence[Mapping[str, object]]) -> dict[str, object]:
|
| 257 |
+
return {
|
| 258 |
+
"prompts": [str(item["prompts"]) for item in items],
|
| 259 |
+
"sample_ids": [str(item["sample_ids"]) for item in items],
|
| 260 |
+
"teacher_bank_rows": torch.tensor([
|
| 261 |
+
int(item["teacher_bank_rows"]) for item in items
|
| 262 |
+
], dtype=torch.long),
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
def load_rows(
|
| 266 |
+
self,
|
| 267 |
+
sample_ids: Sequence[str],
|
| 268 |
+
) -> dict[str, torch.Tensor]:
|
| 269 |
+
"""Read only requested rows, preserving order and duplicate IDs."""
|
| 270 |
+
ids = tuple(str(value) for value in sample_ids)
|
| 271 |
+
if not ids or any(value not in self._sample_index for value in ids):
|
| 272 |
+
raise KeyError("DINOv3+V-JEPA2 bank request contains an unknown sample ID")
|
| 273 |
+
output = {name: [None] * len(ids) for name in BRANCH_SHAPES}
|
| 274 |
+
by_shard: dict[int, list[tuple[int, int]]] = {}
|
| 275 |
+
for output_index, sample_id in enumerate(ids):
|
| 276 |
+
row = self._sample_index[sample_id]
|
| 277 |
+
shard_index, offset = self._row_to_shard[row]
|
| 278 |
+
by_shard.setdefault(shard_index, []).append((output_index, offset))
|
| 279 |
+
for shard_index, requests in by_shard.items():
|
| 280 |
+
path = self.shards[shard_index]["resolved_path"]
|
| 281 |
+
with safe_open(path, framework="pt", device="cpu") as handle:
|
| 282 |
+
slices = {name: handle.get_slice(name) for name in BRANCH_SHAPES}
|
| 283 |
+
for output_index, offset in requests:
|
| 284 |
+
for name in BRANCH_SHAPES:
|
| 285 |
+
output[name][output_index] = slices[name][
|
| 286 |
+
offset:offset + 1
|
| 287 |
+
].squeeze(0)
|
| 288 |
+
return {
|
| 289 |
+
name: torch.stack(rows, dim=0)
|
| 290 |
+
for name, rows in output.items()
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
__all__ = [
|
| 295 |
+
"BANK_KIND",
|
| 296 |
+
"BANK_SCHEMA_VERSION",
|
| 297 |
+
"BRANCH_SHAPES",
|
| 298 |
+
"DINOv3VJEPA2FeatureBank",
|
| 299 |
+
"canonical_sha256",
|
| 300 |
+
"file_sha256",
|
| 301 |
+
]
|
1.3B/code/utils/distributed.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import timedelta
|
| 2 |
+
from functools import partial
|
| 3 |
+
import inspect
|
| 4 |
+
import os
|
| 5 |
+
import torch
|
| 6 |
+
import torch.distributed as dist
|
| 7 |
+
from torch.distributed.fsdp import FullStateDictConfig, FullOptimStateDictConfig, FullyShardedDataParallel as FSDP, MixedPrecision, ShardingStrategy, StateDictType
|
| 8 |
+
from torch.distributed.fsdp.api import CPUOffload
|
| 9 |
+
from torch.distributed.fsdp.wrap import size_based_auto_wrap_policy, transformer_auto_wrap_policy
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def fsdp_state_dict(model):
|
| 13 |
+
fsdp_fullstate_save_policy = FullStateDictConfig(
|
| 14 |
+
offload_to_cpu=True, rank0_only=True
|
| 15 |
+
)
|
| 16 |
+
with FSDP.state_dict_type(
|
| 17 |
+
model, StateDictType.FULL_STATE_DICT, fsdp_fullstate_save_policy
|
| 18 |
+
):
|
| 19 |
+
checkpoint = model.state_dict()
|
| 20 |
+
|
| 21 |
+
return checkpoint
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def fsdp_optim_state_dict(model, optimizer):
|
| 25 |
+
"""Gather the full (unsharded) optimizer state on rank 0 (CPU).
|
| 26 |
+
|
| 27 |
+
Collective: must be called by every rank. Returns the full optim state
|
| 28 |
+
dict on rank 0 and an empty/partial dict on other ranks (rank0_only=True).
|
| 29 |
+
"""
|
| 30 |
+
save_policy = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
|
| 31 |
+
optim_policy = FullOptimStateDictConfig(offload_to_cpu=True, rank0_only=True)
|
| 32 |
+
with FSDP.state_dict_type(
|
| 33 |
+
model,
|
| 34 |
+
StateDictType.FULL_STATE_DICT,
|
| 35 |
+
state_dict_config=save_policy,
|
| 36 |
+
optim_state_dict_config=optim_policy,
|
| 37 |
+
):
|
| 38 |
+
osd = FSDP.optim_state_dict(model, optimizer)
|
| 39 |
+
return osd
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def fsdp_load_optim_state_dict(model, optimizer, full_osd):
|
| 43 |
+
"""Load a full optimizer state dict (present on rank 0) back into ``optimizer``.
|
| 44 |
+
|
| 45 |
+
Collective: every rank must call this. ``full_osd`` need only be non-None on
|
| 46 |
+
rank 0 (FSDP broadcasts/shards it to the other ranks), which keeps host memory
|
| 47 |
+
bounded to a single copy of the optimizer state.
|
| 48 |
+
"""
|
| 49 |
+
save_policy = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
|
| 50 |
+
optim_policy = FullOptimStateDictConfig(offload_to_cpu=True, rank0_only=True)
|
| 51 |
+
with FSDP.state_dict_type(
|
| 52 |
+
model,
|
| 53 |
+
StateDictType.FULL_STATE_DICT,
|
| 54 |
+
state_dict_config=save_policy,
|
| 55 |
+
optim_state_dict_config=optim_policy,
|
| 56 |
+
):
|
| 57 |
+
sharded = FSDP.optim_state_dict_to_load(
|
| 58 |
+
model=model, optim=optimizer, optim_state_dict=full_osd
|
| 59 |
+
)
|
| 60 |
+
optimizer.load_state_dict(sharded)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def fsdp_wrap(module, sharding_strategy="full", mixed_precision=False, wrap_strategy="size", min_num_params=int(5e7), transformer_module=None, cpu_offload=False):
|
| 64 |
+
if mixed_precision:
|
| 65 |
+
mixed_precision_policy = MixedPrecision(
|
| 66 |
+
param_dtype=torch.bfloat16,
|
| 67 |
+
reduce_dtype=torch.float32,
|
| 68 |
+
buffer_dtype=torch.float32,
|
| 69 |
+
cast_forward_inputs=False
|
| 70 |
+
)
|
| 71 |
+
else:
|
| 72 |
+
mixed_precision_policy = None
|
| 73 |
+
|
| 74 |
+
if wrap_strategy == "transformer":
|
| 75 |
+
if transformer_module is None:
|
| 76 |
+
raise ValueError("transformer wrap strategy requires transformer_module")
|
| 77 |
+
transformer_classes = (
|
| 78 |
+
{transformer_module}
|
| 79 |
+
if isinstance(transformer_module, type)
|
| 80 |
+
else set(transformer_module)
|
| 81 |
+
)
|
| 82 |
+
auto_wrap_policy = partial(
|
| 83 |
+
transformer_auto_wrap_policy,
|
| 84 |
+
transformer_layer_cls=transformer_classes
|
| 85 |
+
)
|
| 86 |
+
elif wrap_strategy == "size":
|
| 87 |
+
auto_wrap_policy = partial(
|
| 88 |
+
size_based_auto_wrap_policy,
|
| 89 |
+
min_num_params=min_num_params
|
| 90 |
+
)
|
| 91 |
+
else:
|
| 92 |
+
raise ValueError(f"Invalid wrap strategy: {wrap_strategy}")
|
| 93 |
+
|
| 94 |
+
os.environ["NCCL_CROSS_NIC"] = "1"
|
| 95 |
+
|
| 96 |
+
sharding_strategy = {
|
| 97 |
+
"full": ShardingStrategy.FULL_SHARD,
|
| 98 |
+
"hybrid_full": ShardingStrategy.HYBRID_SHARD,
|
| 99 |
+
"hybrid_zero2": ShardingStrategy._HYBRID_SHARD_ZERO2,
|
| 100 |
+
"no_shard": ShardingStrategy.NO_SHARD,
|
| 101 |
+
}[sharding_strategy]
|
| 102 |
+
|
| 103 |
+
module = FSDP(
|
| 104 |
+
module,
|
| 105 |
+
auto_wrap_policy=auto_wrap_policy,
|
| 106 |
+
sharding_strategy=sharding_strategy,
|
| 107 |
+
mixed_precision=mixed_precision_policy,
|
| 108 |
+
device_id=torch.cuda.current_device(),
|
| 109 |
+
limit_all_gathers=True,
|
| 110 |
+
use_orig_params=True,
|
| 111 |
+
cpu_offload=CPUOffload(offload_params=cpu_offload),
|
| 112 |
+
# Every rank constructs and loads the same pretrained/checkpoint state
|
| 113 |
+
# before wrapping; no rank-0 broadcast is implied here.
|
| 114 |
+
sync_module_states=False
|
| 115 |
+
)
|
| 116 |
+
return module
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def barrier():
|
| 120 |
+
if dist.is_initialized():
|
| 121 |
+
dist.barrier()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def launch_distributed_job(backend: str = "nccl"):
|
| 125 |
+
rank = int(os.environ["RANK"])
|
| 126 |
+
local_rank = int(os.environ["LOCAL_RANK"])
|
| 127 |
+
world_size = int(os.environ["WORLD_SIZE"])
|
| 128 |
+
host = os.environ["MASTER_ADDR"]
|
| 129 |
+
port = int(os.environ["MASTER_PORT"])
|
| 130 |
+
# Bind CUDA before NCCL creates communicators.
|
| 131 |
+
torch.cuda.set_device(local_rank)
|
| 132 |
+
|
| 133 |
+
if ":" in host: # IPv6
|
| 134 |
+
init_method = f"tcp://[{host}]:{port}"
|
| 135 |
+
else: # IPv4
|
| 136 |
+
init_method = f"tcp://{host}:{port}"
|
| 137 |
+
init_kwargs = {}
|
| 138 |
+
if (
|
| 139 |
+
backend == "nccl"
|
| 140 |
+
and "device_id" in inspect.signature(
|
| 141 |
+
dist.init_process_group).parameters
|
| 142 |
+
):
|
| 143 |
+
init_kwargs["device_id"] = torch.device("cuda", local_rank)
|
| 144 |
+
dist.init_process_group(
|
| 145 |
+
rank=rank,
|
| 146 |
+
world_size=world_size,
|
| 147 |
+
backend=backend,
|
| 148 |
+
init_method=init_method,
|
| 149 |
+
timeout=timedelta(minutes=30),
|
| 150 |
+
**init_kwargs,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class EMA_FSDP:
|
| 155 |
+
def __init__(self, fsdp_module: torch.nn.Module, decay: float = 0.999):
|
| 156 |
+
self.decay = decay
|
| 157 |
+
self.shadow = {}
|
| 158 |
+
self._init_shadow(fsdp_module)
|
| 159 |
+
|
| 160 |
+
@torch.no_grad()
|
| 161 |
+
def _init_shadow(self, fsdp_module):
|
| 162 |
+
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
| 163 |
+
with FSDP.summon_full_params(fsdp_module, writeback=False):
|
| 164 |
+
for n, p in fsdp_module.module.named_parameters():
|
| 165 |
+
self.shadow[n] = p.detach().clone().float().cpu()
|
| 166 |
+
|
| 167 |
+
@torch.no_grad()
|
| 168 |
+
def update(self, fsdp_module):
|
| 169 |
+
d = self.decay
|
| 170 |
+
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
| 171 |
+
with FSDP.summon_full_params(fsdp_module, writeback=False):
|
| 172 |
+
for n, p in fsdp_module.module.named_parameters():
|
| 173 |
+
self.shadow[n].mul_(d).add_(p.detach().float().cpu(), alpha=1. - d)
|
| 174 |
+
|
| 175 |
+
# Optional helpers ---------------------------------------------------
|
| 176 |
+
def state_dict(self):
|
| 177 |
+
return self.shadow # picklable
|
| 178 |
+
|
| 179 |
+
def load_state_dict(self, sd):
|
| 180 |
+
self.shadow = {k: v.clone() for k, v in sd.items()}
|
| 181 |
+
|
| 182 |
+
def copy_to(self, fsdp_module):
|
| 183 |
+
# load EMA weights into an (unwrapped) copy of the generator
|
| 184 |
+
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
|
| 185 |
+
with FSDP.summon_full_params(fsdp_module, writeback=True):
|
| 186 |
+
for n, p in fsdp_module.module.named_parameters():
|
| 187 |
+
if n in self.shadow:
|
| 188 |
+
p.data.copy_(self.shadow[n].to(p.dtype, device=p.device))
|
1.3B/code/utils/dual_video_feature_bank.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Strict mmap-backed teacher bank for V-JEPA2 + VideoMAE-v2 features."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Mapping, Sequence
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from safetensors import safe_open
|
| 13 |
+
from torch.utils.data import Dataset
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
BANK_KIND = "dual_vjepa2_videomae_v2_matched_teacher_bank"
|
| 17 |
+
BANK_SCHEMA_VERSION = 1
|
| 18 |
+
BRANCH_SHAPES = {
|
| 19 |
+
"vjepa2": (1024, 1024),
|
| 20 |
+
"videomae_v2": (1280, 1024),
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _canonical_bytes(value: Any) -> bytes:
|
| 25 |
+
return json.dumps(
|
| 26 |
+
value,
|
| 27 |
+
sort_keys=True,
|
| 28 |
+
separators=(",", ":"),
|
| 29 |
+
ensure_ascii=False,
|
| 30 |
+
allow_nan=False,
|
| 31 |
+
).encode("utf-8")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def canonical_sha256(value: Any) -> str:
|
| 35 |
+
return hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def file_sha256(path: str | os.PathLike) -> str:
|
| 39 |
+
digest = hashlib.sha256()
|
| 40 |
+
with open(path, "rb") as handle:
|
| 41 |
+
while True:
|
| 42 |
+
block = handle.read(8 * 1024 * 1024)
|
| 43 |
+
if not block:
|
| 44 |
+
break
|
| 45 |
+
digest.update(block)
|
| 46 |
+
return digest.hexdigest()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _strict_sha256(value: object, label: str) -> str:
|
| 50 |
+
normalized = str(value)
|
| 51 |
+
if (
|
| 52 |
+
len(normalized) != 64
|
| 53 |
+
or any(character not in "0123456789abcdef" for character in normalized)
|
| 54 |
+
):
|
| 55 |
+
raise ValueError(f"{label} must be a lowercase SHA-256")
|
| 56 |
+
return normalized
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class DualVideoFeatureBank(Dataset):
|
| 60 |
+
"""Metadata dataset plus random-access frozen FP16 feature rows."""
|
| 61 |
+
|
| 62 |
+
def __init__(
|
| 63 |
+
self,
|
| 64 |
+
manifest_path: str | os.PathLike,
|
| 65 |
+
*,
|
| 66 |
+
expected_manifest_sha256: str,
|
| 67 |
+
expected_content_hash: str,
|
| 68 |
+
expected_source_manifest_sha256: str,
|
| 69 |
+
expected_sample_count: int = 5000,
|
| 70 |
+
expected_videomae_revision: str | None = None,
|
| 71 |
+
expected_videomae_download_manifest_content_hash: str | None = None,
|
| 72 |
+
expected_vjepa_model_sha256: str | None = None,
|
| 73 |
+
validate_all_shards: bool = True,
|
| 74 |
+
) -> None:
|
| 75 |
+
self.manifest_path = Path(manifest_path).expanduser().resolve()
|
| 76 |
+
if not self.manifest_path.is_file():
|
| 77 |
+
raise FileNotFoundError(self.manifest_path)
|
| 78 |
+
actual_manifest_sha = file_sha256(self.manifest_path)
|
| 79 |
+
if actual_manifest_sha != _strict_sha256(
|
| 80 |
+
expected_manifest_sha256, "expected_manifest_sha256"
|
| 81 |
+
):
|
| 82 |
+
raise ValueError("dual teacher bank manifest SHA-256 mismatch")
|
| 83 |
+
with self.manifest_path.open("r", encoding="utf-8") as handle:
|
| 84 |
+
manifest = json.load(handle)
|
| 85 |
+
if not isinstance(manifest, dict):
|
| 86 |
+
raise TypeError("dual teacher bank manifest must be an object")
|
| 87 |
+
required = {
|
| 88 |
+
"schema_version",
|
| 89 |
+
"kind",
|
| 90 |
+
"sample_count",
|
| 91 |
+
"source_manifest",
|
| 92 |
+
"branches",
|
| 93 |
+
"samples",
|
| 94 |
+
"shards",
|
| 95 |
+
"content_hash",
|
| 96 |
+
}
|
| 97 |
+
if set(manifest) != required:
|
| 98 |
+
raise ValueError(
|
| 99 |
+
"dual teacher bank manifest keys mismatch: "
|
| 100 |
+
f"{sorted(manifest)} != {sorted(required)}")
|
| 101 |
+
if (
|
| 102 |
+
manifest["schema_version"] != BANK_SCHEMA_VERSION
|
| 103 |
+
or manifest["kind"] != BANK_KIND
|
| 104 |
+
or int(manifest["sample_count"]) != int(expected_sample_count)
|
| 105 |
+
):
|
| 106 |
+
raise ValueError("dual teacher bank kind/schema/sample count mismatch")
|
| 107 |
+
payload = dict(manifest)
|
| 108 |
+
content_hash = payload.pop("content_hash")
|
| 109 |
+
if canonical_sha256(payload) != content_hash:
|
| 110 |
+
raise ValueError("dual teacher bank content_hash is invalid")
|
| 111 |
+
if content_hash != _strict_sha256(
|
| 112 |
+
expected_content_hash, "expected_content_hash"
|
| 113 |
+
):
|
| 114 |
+
raise ValueError("dual teacher bank content identity mismatch")
|
| 115 |
+
source = manifest["source_manifest"]
|
| 116 |
+
if source.get("sha256") != _strict_sha256(
|
| 117 |
+
expected_source_manifest_sha256,
|
| 118 |
+
"expected_source_manifest_sha256",
|
| 119 |
+
):
|
| 120 |
+
raise ValueError("dual bank source manifest SHA-256 mismatch")
|
| 121 |
+
branches = manifest["branches"]
|
| 122 |
+
if set(branches) != set(BRANCH_SHAPES):
|
| 123 |
+
raise ValueError("dual teacher bank branch set mismatch")
|
| 124 |
+
for name, expected_shape in BRANCH_SHAPES.items():
|
| 125 |
+
branch = branches[name]
|
| 126 |
+
if (
|
| 127 |
+
tuple(branch.get("sample_shape", ())) != expected_shape
|
| 128 |
+
or branch.get("dtype") != "float16"
|
| 129 |
+
or not isinstance(branch.get("feature_contract"), Mapping)
|
| 130 |
+
or not isinstance(branch.get("model_identity"), Mapping)
|
| 131 |
+
):
|
| 132 |
+
raise ValueError(f"dual teacher branch {name!r} contract mismatch")
|
| 133 |
+
if expected_videomae_revision is not None and (
|
| 134 |
+
branches["videomae_v2"]["model_identity"].get("revision")
|
| 135 |
+
!= str(expected_videomae_revision)
|
| 136 |
+
):
|
| 137 |
+
raise ValueError("VideoMAE-v2 bank model revision mismatch")
|
| 138 |
+
if expected_videomae_download_manifest_content_hash is not None and (
|
| 139 |
+
branches["videomae_v2"]["model_identity"].get(
|
| 140 |
+
"download_manifest_content_hash"
|
| 141 |
+
)
|
| 142 |
+
!= _strict_sha256(
|
| 143 |
+
expected_videomae_download_manifest_content_hash,
|
| 144 |
+
"expected_videomae_download_manifest_content_hash",
|
| 145 |
+
)
|
| 146 |
+
):
|
| 147 |
+
raise ValueError(
|
| 148 |
+
"VideoMAE-v2 bank download manifest identity mismatch")
|
| 149 |
+
if expected_vjepa_model_sha256 is not None and (
|
| 150 |
+
branches["vjepa2"]["model_identity"].get("sha256")
|
| 151 |
+
!= _strict_sha256(
|
| 152 |
+
expected_vjepa_model_sha256,
|
| 153 |
+
"expected_vjepa_model_sha256",
|
| 154 |
+
)
|
| 155 |
+
):
|
| 156 |
+
raise ValueError("V-JEPA2 bank model SHA-256 mismatch")
|
| 157 |
+
|
| 158 |
+
samples = manifest["samples"]
|
| 159 |
+
if len(samples) != int(expected_sample_count):
|
| 160 |
+
raise ValueError("dual bank sample table length mismatch")
|
| 161 |
+
self.samples = []
|
| 162 |
+
self._sample_index = {}
|
| 163 |
+
for expected_index, item in enumerate(samples):
|
| 164 |
+
if (
|
| 165 |
+
not isinstance(item, dict)
|
| 166 |
+
or set(item) != {"index", "sample_id", "prompt"}
|
| 167 |
+
or int(item["index"]) != expected_index
|
| 168 |
+
or not isinstance(item["sample_id"], str)
|
| 169 |
+
or not item["sample_id"]
|
| 170 |
+
or not isinstance(item["prompt"], str)
|
| 171 |
+
):
|
| 172 |
+
raise ValueError(
|
| 173 |
+
f"invalid dual bank sample record at {expected_index}")
|
| 174 |
+
if item["sample_id"] in self._sample_index:
|
| 175 |
+
raise ValueError("dual bank sample IDs must be unique")
|
| 176 |
+
self._sample_index[item["sample_id"]] = expected_index
|
| 177 |
+
self.samples.append(dict(item))
|
| 178 |
+
|
| 179 |
+
self.root = self.manifest_path.parent
|
| 180 |
+
self.shards = []
|
| 181 |
+
self._row_to_shard: list[tuple[int, int]] = [None] * len(samples)
|
| 182 |
+
expected_start = 0
|
| 183 |
+
for shard_index, item in enumerate(manifest["shards"]):
|
| 184 |
+
required_shard = {
|
| 185 |
+
"index", "path", "sample_start", "sample_end",
|
| 186 |
+
"sample_ids_sha256", "sha256", "content_hash",
|
| 187 |
+
}
|
| 188 |
+
if not isinstance(item, dict) or set(item) != required_shard:
|
| 189 |
+
raise ValueError(f"invalid shard record {shard_index}")
|
| 190 |
+
start = int(item["sample_start"])
|
| 191 |
+
end = int(item["sample_end"])
|
| 192 |
+
if (
|
| 193 |
+
int(item["index"]) != shard_index
|
| 194 |
+
or start != expected_start
|
| 195 |
+
or end <= start
|
| 196 |
+
or end > len(samples)
|
| 197 |
+
):
|
| 198 |
+
raise ValueError("dual bank shard ranges are not contiguous")
|
| 199 |
+
relative = Path(str(item["path"]))
|
| 200 |
+
if relative.is_absolute() or ".." in relative.parts:
|
| 201 |
+
raise ValueError("dual bank shard path must stay under bank root")
|
| 202 |
+
path = (self.root / relative).resolve()
|
| 203 |
+
if self.root not in path.parents or not path.is_file():
|
| 204 |
+
raise FileNotFoundError(path)
|
| 205 |
+
ids = [samples[row]["sample_id"] for row in range(start, end)]
|
| 206 |
+
if canonical_sha256(ids) != item["sample_ids_sha256"]:
|
| 207 |
+
raise ValueError("dual bank shard sample ID hash mismatch")
|
| 208 |
+
if validate_all_shards and file_sha256(path) != item["sha256"]:
|
| 209 |
+
raise ValueError(f"dual bank shard SHA-256 mismatch: {path}")
|
| 210 |
+
with safe_open(str(path), framework="pt", device="cpu") as handle:
|
| 211 |
+
metadata = handle.metadata() or {}
|
| 212 |
+
if metadata.get("content_hash") != item["content_hash"]:
|
| 213 |
+
raise ValueError("dual bank shard embedded content hash mismatch")
|
| 214 |
+
for name, shape in BRANCH_SHAPES.items():
|
| 215 |
+
tensor_shape = tuple(handle.get_slice(name).get_shape())
|
| 216 |
+
if tensor_shape != (end - start, *shape):
|
| 217 |
+
raise ValueError(
|
| 218 |
+
f"dual bank shard tensor {name!r} shape mismatch")
|
| 219 |
+
for row in range(start, end):
|
| 220 |
+
self._row_to_shard[row] = (shard_index, row - start)
|
| 221 |
+
self.shards.append({**item, "resolved_path": str(path)})
|
| 222 |
+
expected_start = end
|
| 223 |
+
if expected_start != len(samples):
|
| 224 |
+
raise ValueError("dual bank shards do not cover all samples")
|
| 225 |
+
self.manifest = manifest
|
| 226 |
+
self.content_hash = content_hash
|
| 227 |
+
self.manifest_sha256 = actual_manifest_sha
|
| 228 |
+
|
| 229 |
+
def __len__(self) -> int:
|
| 230 |
+
return len(self.samples)
|
| 231 |
+
|
| 232 |
+
def __getitem__(self, index: int) -> dict[str, object]:
|
| 233 |
+
item = self.samples[int(index)]
|
| 234 |
+
return {
|
| 235 |
+
"prompts": item["prompt"],
|
| 236 |
+
"sample_ids": item["sample_id"],
|
| 237 |
+
"teacher_bank_rows": int(item["index"]),
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
@staticmethod
|
| 241 |
+
def collate_fn(items: Sequence[Mapping[str, object]]) -> dict[str, object]:
|
| 242 |
+
return {
|
| 243 |
+
"prompts": [str(item["prompts"]) for item in items],
|
| 244 |
+
"sample_ids": [str(item["sample_ids"]) for item in items],
|
| 245 |
+
"teacher_bank_rows": torch.tensor([
|
| 246 |
+
int(item["teacher_bank_rows"]) for item in items
|
| 247 |
+
], dtype=torch.long),
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
def load_rows(
|
| 251 |
+
self,
|
| 252 |
+
sample_ids: Sequence[str],
|
| 253 |
+
) -> dict[str, torch.Tensor]:
|
| 254 |
+
"""Read only requested rows, preserving order and duplicate IDs."""
|
| 255 |
+
ids = tuple(str(value) for value in sample_ids)
|
| 256 |
+
if not ids or any(value not in self._sample_index for value in ids):
|
| 257 |
+
raise KeyError("dual bank request contains an unknown sample ID")
|
| 258 |
+
output = {name: [None] * len(ids) for name in BRANCH_SHAPES}
|
| 259 |
+
by_shard: dict[int, list[tuple[int, int]]] = {}
|
| 260 |
+
for output_index, sample_id in enumerate(ids):
|
| 261 |
+
row = self._sample_index[sample_id]
|
| 262 |
+
shard_index, offset = self._row_to_shard[row]
|
| 263 |
+
by_shard.setdefault(shard_index, []).append((output_index, offset))
|
| 264 |
+
for shard_index, requests in by_shard.items():
|
| 265 |
+
path = self.shards[shard_index]["resolved_path"]
|
| 266 |
+
with safe_open(path, framework="pt", device="cpu") as handle:
|
| 267 |
+
slices = {name: handle.get_slice(name) for name in BRANCH_SHAPES}
|
| 268 |
+
for output_index, offset in requests:
|
| 269 |
+
for name in BRANCH_SHAPES:
|
| 270 |
+
output[name][output_index] = slices[name][
|
| 271 |
+
offset:offset + 1
|
| 272 |
+
].squeeze(0)
|
| 273 |
+
return {
|
| 274 |
+
name: torch.stack(rows, dim=0)
|
| 275 |
+
for name, rows in output.items()
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
__all__ = [
|
| 280 |
+
"BANK_KIND",
|
| 281 |
+
"BANK_SCHEMA_VERSION",
|
| 282 |
+
"BRANCH_SHAPES",
|
| 283 |
+
"DualVideoFeatureBank",
|
| 284 |
+
"canonical_sha256",
|
| 285 |
+
"file_sha256",
|
| 286 |
+
]
|
1.3B/code/utils/lmdb.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def get_array_shape_from_lmdb(env, array_name):
|
| 5 |
+
with env.begin() as txn:
|
| 6 |
+
image_shape = txn.get(f"{array_name}_shape".encode()).decode()
|
| 7 |
+
image_shape = tuple(map(int, image_shape.split()))
|
| 8 |
+
return image_shape
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def store_arrays_to_lmdb(env, arrays_dict, start_index=0):
|
| 12 |
+
"""
|
| 13 |
+
Store rows of multiple numpy arrays in a single LMDB.
|
| 14 |
+
Each row is stored separately with a naming convention.
|
| 15 |
+
"""
|
| 16 |
+
with env.begin(write=True) as txn:
|
| 17 |
+
for array_name, array in arrays_dict.items():
|
| 18 |
+
for i, row in enumerate(array):
|
| 19 |
+
# Convert row to bytes
|
| 20 |
+
if isinstance(row, str):
|
| 21 |
+
row_bytes = row.encode()
|
| 22 |
+
else:
|
| 23 |
+
row_bytes = row.tobytes()
|
| 24 |
+
|
| 25 |
+
data_key = f'{array_name}_{start_index + i}_data'.encode()
|
| 26 |
+
|
| 27 |
+
txn.put(data_key, row_bytes)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def process_data_dict(data_dict, seen_prompts):
|
| 31 |
+
output_dict = {}
|
| 32 |
+
|
| 33 |
+
all_videos = []
|
| 34 |
+
all_prompts = []
|
| 35 |
+
for prompt, video in data_dict.items():
|
| 36 |
+
if prompt in seen_prompts:
|
| 37 |
+
continue
|
| 38 |
+
else:
|
| 39 |
+
seen_prompts.add(prompt)
|
| 40 |
+
|
| 41 |
+
video = video.half().numpy()
|
| 42 |
+
all_videos.append(video)
|
| 43 |
+
all_prompts.append(prompt)
|
| 44 |
+
|
| 45 |
+
if len(all_videos) == 0:
|
| 46 |
+
return {"latents": np.array([]), "prompts": np.array([])}
|
| 47 |
+
|
| 48 |
+
all_videos = np.concatenate(all_videos, axis=0)
|
| 49 |
+
|
| 50 |
+
output_dict['latents'] = all_videos
|
| 51 |
+
output_dict['prompts'] = np.array(all_prompts)
|
| 52 |
+
|
| 53 |
+
return output_dict
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def retrieve_row_from_lmdb(lmdb_env, array_name, dtype, row_index, shape=None):
|
| 57 |
+
"""
|
| 58 |
+
Retrieve a specific row from a specific array in the LMDB.
|
| 59 |
+
"""
|
| 60 |
+
data_key = f'{array_name}_{row_index}_data'.encode()
|
| 61 |
+
|
| 62 |
+
with lmdb_env.begin() as txn:
|
| 63 |
+
row_bytes = txn.get(data_key)
|
| 64 |
+
|
| 65 |
+
if dtype == str:
|
| 66 |
+
array = row_bytes.decode()
|
| 67 |
+
else:
|
| 68 |
+
array = np.frombuffer(row_bytes, dtype=dtype)
|
| 69 |
+
|
| 70 |
+
if shape is not None and len(shape) > 0:
|
| 71 |
+
array = array.reshape(shape)
|
| 72 |
+
return array
|
1.3B/code/utils/loss.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class DenoisingLoss(ABC):
|
| 6 |
+
@abstractmethod
|
| 7 |
+
def __call__(
|
| 8 |
+
self, x: torch.Tensor, x_pred: torch.Tensor,
|
| 9 |
+
noise: torch.Tensor, noise_pred: torch.Tensor,
|
| 10 |
+
alphas_cumprod: torch.Tensor,
|
| 11 |
+
timestep: torch.Tensor,
|
| 12 |
+
**kwargs
|
| 13 |
+
) -> torch.Tensor:
|
| 14 |
+
"""
|
| 15 |
+
Base class for denoising loss.
|
| 16 |
+
Input:
|
| 17 |
+
- x: the clean data with shape [B, F, C, H, W]
|
| 18 |
+
- x_pred: the predicted clean data with shape [B, F, C, H, W]
|
| 19 |
+
- noise: the noise with shape [B, F, C, H, W]
|
| 20 |
+
- noise_pred: the predicted noise with shape [B, F, C, H, W]
|
| 21 |
+
- alphas_cumprod: the cumulative product of alphas (defining the noise schedule) with shape [T]
|
| 22 |
+
- timestep: the current timestep with shape [B, F]
|
| 23 |
+
"""
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class X0PredLoss(DenoisingLoss):
|
| 28 |
+
def __call__(
|
| 29 |
+
self, x: torch.Tensor, x_pred: torch.Tensor,
|
| 30 |
+
noise: torch.Tensor, noise_pred: torch.Tensor,
|
| 31 |
+
alphas_cumprod: torch.Tensor,
|
| 32 |
+
timestep: torch.Tensor,
|
| 33 |
+
**kwargs
|
| 34 |
+
) -> torch.Tensor:
|
| 35 |
+
return torch.mean((x - x_pred) ** 2)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class VPredLoss(DenoisingLoss):
|
| 39 |
+
def __call__(
|
| 40 |
+
self, x: torch.Tensor, x_pred: torch.Tensor,
|
| 41 |
+
noise: torch.Tensor, noise_pred: torch.Tensor,
|
| 42 |
+
alphas_cumprod: torch.Tensor,
|
| 43 |
+
timestep: torch.Tensor,
|
| 44 |
+
**kwargs
|
| 45 |
+
) -> torch.Tensor:
|
| 46 |
+
weights = 1 / (1 - alphas_cumprod[timestep].reshape(*timestep.shape, 1, 1, 1))
|
| 47 |
+
return torch.mean(weights * (x - x_pred) ** 2)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class NoisePredLoss(DenoisingLoss):
|
| 51 |
+
def __call__(
|
| 52 |
+
self, x: torch.Tensor, x_pred: torch.Tensor,
|
| 53 |
+
noise: torch.Tensor, noise_pred: torch.Tensor,
|
| 54 |
+
alphas_cumprod: torch.Tensor,
|
| 55 |
+
timestep: torch.Tensor,
|
| 56 |
+
**kwargs
|
| 57 |
+
) -> torch.Tensor:
|
| 58 |
+
return torch.mean((noise - noise_pred) ** 2)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class FlowPredLoss(DenoisingLoss):
|
| 62 |
+
def __call__(
|
| 63 |
+
self, x: torch.Tensor, x_pred: torch.Tensor,
|
| 64 |
+
noise: torch.Tensor, noise_pred: torch.Tensor,
|
| 65 |
+
alphas_cumprod: torch.Tensor,
|
| 66 |
+
timestep: torch.Tensor,
|
| 67 |
+
**kwargs
|
| 68 |
+
) -> torch.Tensor:
|
| 69 |
+
return torch.mean((kwargs["flow_pred"] - (noise - x)) ** 2)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
NAME_TO_CLASS = {
|
| 73 |
+
"x0": X0PredLoss,
|
| 74 |
+
"v": VPredLoss,
|
| 75 |
+
"noise": NoisePredLoss,
|
| 76 |
+
"flow": FlowPredLoss
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_denoising_loss(loss_type: str) -> DenoisingLoss:
|
| 81 |
+
return NAME_TO_CLASS[loss_type]
|
1.3B/code/utils/matched_video_dataset.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metadata-only matched-video dataset with explicitly lazy teacher I/O."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import time
|
| 9 |
+
from typing import Any, Callable, Mapping, Sequence
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
from torch.utils.data import Dataset
|
| 14 |
+
|
| 15 |
+
from utils.matched_video_feature_cache import (
|
| 16 |
+
MATCHED_PYAV_LAST64_DECODER_CONTRACT,
|
| 17 |
+
MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT,
|
| 18 |
+
MatchedVideoFeatureCache,
|
| 19 |
+
)
|
| 20 |
+
from utils.matched_video_manifest import (
|
| 21 |
+
MatchedVideoManifestEntry,
|
| 22 |
+
load_matched_video_manifest,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True)
|
| 27 |
+
class DecodedMatchedVideo:
|
| 28 |
+
"""The final contiguous RGB frame window in V-JEPA2 input range."""
|
| 29 |
+
|
| 30 |
+
video01: torch.Tensor
|
| 31 |
+
frame_indices: torch.Tensor
|
| 32 |
+
source_frame_count: int
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _frames_to_video01(frames: Sequence[Any]) -> torch.Tensor:
|
| 36 |
+
tensors: list[torch.Tensor] = []
|
| 37 |
+
shape: tuple[int, int, int] | None = None
|
| 38 |
+
for frame in frames:
|
| 39 |
+
array = frame.to_ndarray(format="rgb24")
|
| 40 |
+
if not isinstance(array, np.ndarray):
|
| 41 |
+
raise TypeError("PyAV frame.to_ndarray did not return ndarray")
|
| 42 |
+
if array.ndim != 3 or int(array.shape[2]) != 3:
|
| 43 |
+
raise ValueError(
|
| 44 |
+
f"decoded RGB frame must be [H,W,3], got {array.shape}"
|
| 45 |
+
)
|
| 46 |
+
if array.dtype != np.uint8:
|
| 47 |
+
raise ValueError(f"decoded RGB frame must be uint8, got {array.dtype}")
|
| 48 |
+
current_shape = tuple(int(value) for value in array.shape)
|
| 49 |
+
if shape is None:
|
| 50 |
+
shape = current_shape
|
| 51 |
+
elif current_shape != shape:
|
| 52 |
+
raise ValueError(
|
| 53 |
+
"video changes resolution inside selected frame window: "
|
| 54 |
+
f"{current_shape} != {shape}"
|
| 55 |
+
)
|
| 56 |
+
tensors.append(
|
| 57 |
+
torch.from_numpy(np.ascontiguousarray(array)).permute(2, 0, 1)
|
| 58 |
+
)
|
| 59 |
+
if not tensors:
|
| 60 |
+
raise ValueError("decoded frame window is empty")
|
| 61 |
+
return torch.stack(tensors).to(dtype=torch.float32).div_(255.0)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def decode_matched_video_last_contiguous_pyav(
|
| 65 |
+
video_path: str | os.PathLike,
|
| 66 |
+
*,
|
| 67 |
+
decoder_contract: Mapping[str, Any] | None = None,
|
| 68 |
+
) -> DecodedMatchedVideo:
|
| 69 |
+
"""Decode exactly frames 17..80 from a contractually 81-frame MP4."""
|
| 70 |
+
|
| 71 |
+
contract = dict(
|
| 72 |
+
decoder_contract
|
| 73 |
+
if decoder_contract is not None
|
| 74 |
+
else MATCHED_PYAV_LAST64_DECODER_CONTRACT
|
| 75 |
+
)
|
| 76 |
+
if contract.get("backend") != "pyav":
|
| 77 |
+
raise ValueError("decoder contract backend must be pyav")
|
| 78 |
+
if contract.get("selection") != "last_contiguous":
|
| 79 |
+
raise ValueError("decoder contract selection must be last_contiguous")
|
| 80 |
+
num_frames = int(contract.get("num_frames", 0))
|
| 81 |
+
expected_count = int(contract.get("expected_source_frame_count", 0))
|
| 82 |
+
if num_frames <= 0 or expected_count < num_frames:
|
| 83 |
+
raise ValueError("decoder contract frame geometry is invalid")
|
| 84 |
+
expected_bounds = [expected_count - num_frames, expected_count - 1]
|
| 85 |
+
if list(contract.get("expected_frame_indices", ())) != expected_bounds:
|
| 86 |
+
raise ValueError(
|
| 87 |
+
"decoder contract expected_frame_indices do not describe last64"
|
| 88 |
+
)
|
| 89 |
+
try:
|
| 90 |
+
import av # Imported only for cache misses.
|
| 91 |
+
except ImportError as exc: # pragma: no cover
|
| 92 |
+
raise ImportError(
|
| 93 |
+
"PyAV is required for a matched-video teacher cache miss; "
|
| 94 |
+
"install the pinned av==13.1.0 dependency"
|
| 95 |
+
) from exc
|
| 96 |
+
expected_version = str(contract.get("backend_version", ""))
|
| 97 |
+
actual_version = str(getattr(av, "__version__", ""))
|
| 98 |
+
if actual_version != expected_version:
|
| 99 |
+
raise RuntimeError(
|
| 100 |
+
"PyAV version differs from the cache decoder contract: "
|
| 101 |
+
f"{actual_version!r} != {expected_version!r}"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
path = Path(video_path).expanduser().resolve()
|
| 105 |
+
if not path.is_file():
|
| 106 |
+
raise FileNotFoundError(path)
|
| 107 |
+
selected: list[Any] = []
|
| 108 |
+
decoded_count = 0
|
| 109 |
+
start = expected_count - num_frames
|
| 110 |
+
with av.open(str(path), mode="r") as container:
|
| 111 |
+
streams = container.streams.video
|
| 112 |
+
if len(streams) != 1:
|
| 113 |
+
raise ValueError(
|
| 114 |
+
f"expected exactly one video stream in {path}, got {len(streams)}"
|
| 115 |
+
)
|
| 116 |
+
stream = streams[0]
|
| 117 |
+
for frame_index, frame in enumerate(container.decode(stream)):
|
| 118 |
+
decoded_count = frame_index + 1
|
| 119 |
+
if start <= frame_index < expected_count:
|
| 120 |
+
selected.append(frame)
|
| 121 |
+
if decoded_count != expected_count:
|
| 122 |
+
raise ValueError(
|
| 123 |
+
"matched video source frame count violates the last64 contract: "
|
| 124 |
+
f"sample={path.name}, actual={decoded_count}, "
|
| 125 |
+
f"expected={expected_count}"
|
| 126 |
+
)
|
| 127 |
+
if len(selected) != num_frames:
|
| 128 |
+
raise RuntimeError(
|
| 129 |
+
f"failed to decode frames {start}..{expected_count - 1} from {path}"
|
| 130 |
+
)
|
| 131 |
+
video01 = _frames_to_video01(selected)
|
| 132 |
+
if tuple(video01.shape[:2]) != (num_frames, 3):
|
| 133 |
+
raise RuntimeError(f"decoded video has invalid shape {tuple(video01.shape)}")
|
| 134 |
+
return DecodedMatchedVideo(
|
| 135 |
+
video01=video01,
|
| 136 |
+
frame_indices=torch.arange(start, expected_count, dtype=torch.long),
|
| 137 |
+
source_frame_count=decoded_count,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
DecodeFunction = Callable[[Path, Mapping[str, Any]], DecodedMatchedVideo]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _default_decode(
|
| 145 |
+
path: Path,
|
| 146 |
+
contract: Mapping[str, Any],
|
| 147 |
+
) -> DecodedMatchedVideo:
|
| 148 |
+
return decode_matched_video_last_contiguous_pyav(
|
| 149 |
+
path, decoder_contract=contract
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class MatchedManifestVideoDataset(Dataset):
|
| 154 |
+
"""Return metadata eagerly; teacher cache/decode only on explicit request."""
|
| 155 |
+
|
| 156 |
+
def __init__(
|
| 157 |
+
self,
|
| 158 |
+
manifest_path: str | os.PathLike,
|
| 159 |
+
video_root: str | os.PathLike,
|
| 160 |
+
cache_root: str | os.PathLike,
|
| 161 |
+
expected_model_sha256: str,
|
| 162 |
+
expected_feature_contract_sha256: str,
|
| 163 |
+
*,
|
| 164 |
+
expected_source_csv_sha256: str | None = None,
|
| 165 |
+
expected_count: int | None = None,
|
| 166 |
+
feature_contract: Mapping[str, Any] | None = None,
|
| 167 |
+
decoder_contract: Mapping[str, Any] | None = None,
|
| 168 |
+
decode_fn: DecodeFunction | None = None,
|
| 169 |
+
) -> None:
|
| 170 |
+
self.manifest_path = Path(manifest_path).expanduser().resolve()
|
| 171 |
+
self.video_root = Path(video_root).expanduser().resolve()
|
| 172 |
+
if not self.video_root.is_dir():
|
| 173 |
+
raise NotADirectoryError(self.video_root)
|
| 174 |
+
self.entries, self.manifest_metadata = load_matched_video_manifest(
|
| 175 |
+
self.manifest_path,
|
| 176 |
+
expected_source_csv_sha256=expected_source_csv_sha256,
|
| 177 |
+
expected_count=expected_count,
|
| 178 |
+
)
|
| 179 |
+
if not self.entries:
|
| 180 |
+
raise ValueError("matched-video manifest is empty")
|
| 181 |
+
self.feature_cache = MatchedVideoFeatureCache(
|
| 182 |
+
cache_root,
|
| 183 |
+
expected_model_sha256=expected_model_sha256,
|
| 184 |
+
expected_feature_contract_sha256=expected_feature_contract_sha256,
|
| 185 |
+
feature_contract=(
|
| 186 |
+
feature_contract
|
| 187 |
+
if feature_contract is not None
|
| 188 |
+
else MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT
|
| 189 |
+
),
|
| 190 |
+
decoder_contract=(
|
| 191 |
+
decoder_contract
|
| 192 |
+
if decoder_contract is not None
|
| 193 |
+
else MATCHED_PYAV_LAST64_DECODER_CONTRACT
|
| 194 |
+
),
|
| 195 |
+
)
|
| 196 |
+
self.decoder_contract = self.feature_cache.decoder_contract
|
| 197 |
+
self.decode_fn = decode_fn if decode_fn is not None else _default_decode
|
| 198 |
+
self._cache_key_to_index: dict[str, int] = {}
|
| 199 |
+
self._sample_id_to_index: dict[str, int] = {}
|
| 200 |
+
for index, entry in enumerate(self.entries):
|
| 201 |
+
cache_key = self.feature_cache.key_for(entry)
|
| 202 |
+
if cache_key in self._cache_key_to_index:
|
| 203 |
+
raise ValueError(f"duplicate matched-video cache key: {cache_key}")
|
| 204 |
+
if entry.sample_id in self._sample_id_to_index:
|
| 205 |
+
raise ValueError(f"duplicate sample_id: {entry.sample_id}")
|
| 206 |
+
self._cache_key_to_index[cache_key] = index
|
| 207 |
+
self._sample_id_to_index[entry.sample_id] = index
|
| 208 |
+
|
| 209 |
+
def __len__(self) -> int:
|
| 210 |
+
return len(self.entries)
|
| 211 |
+
|
| 212 |
+
def _entry_for_sample(self, sample_id: str) -> MatchedVideoManifestEntry:
|
| 213 |
+
try:
|
| 214 |
+
return self.entries[self._sample_id_to_index[str(sample_id)]]
|
| 215 |
+
except KeyError as exc:
|
| 216 |
+
raise KeyError(f"unknown matched-video sample_id: {sample_id!r}") from exc
|
| 217 |
+
|
| 218 |
+
def _video_path_and_verify_stat(
|
| 219 |
+
self,
|
| 220 |
+
entry: MatchedVideoManifestEntry,
|
| 221 |
+
) -> Path:
|
| 222 |
+
path = self.video_root / entry.video_name
|
| 223 |
+
if not path.is_file():
|
| 224 |
+
raise FileNotFoundError(path)
|
| 225 |
+
stat = path.stat()
|
| 226 |
+
actual = (int(stat.st_size), int(stat.st_mtime_ns))
|
| 227 |
+
expected = (entry.video_size_bytes, entry.video_mtime_ns)
|
| 228 |
+
if actual != expected:
|
| 229 |
+
raise ValueError(
|
| 230 |
+
"matched video is stale after manifest freeze for "
|
| 231 |
+
f"sample_id={entry.sample_id}: actual={actual}, "
|
| 232 |
+
f"expected={expected}"
|
| 233 |
+
)
|
| 234 |
+
return path
|
| 235 |
+
|
| 236 |
+
def cache_key_for_sample(self, sample_id: str) -> str:
|
| 237 |
+
return self.feature_cache.key_for(self._entry_for_sample(sample_id))
|
| 238 |
+
|
| 239 |
+
def __getitem__(self, index: int) -> dict[str, Any]:
|
| 240 |
+
"""Return only prompt/sample/path metadata; never touch teacher data."""
|
| 241 |
+
|
| 242 |
+
entry = self.entries[int(index)]
|
| 243 |
+
video_path = self._video_path_and_verify_stat(entry)
|
| 244 |
+
return {
|
| 245 |
+
"idx": entry.manifest_index,
|
| 246 |
+
"prompts": entry.caption,
|
| 247 |
+
"sample_id": entry.sample_id,
|
| 248 |
+
"teacher_cache_key": self.feature_cache.key_for(entry),
|
| 249 |
+
"video_name": entry.video_name,
|
| 250 |
+
"video_path": str(video_path),
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
def load_teacher_payload(self, sample_id: str) -> dict[str, Any]:
|
| 254 |
+
"""Load/decode teacher input only after suffix replay selects a sample."""
|
| 255 |
+
|
| 256 |
+
entry = self._entry_for_sample(sample_id)
|
| 257 |
+
video_path = self._video_path_and_verify_stat(entry)
|
| 258 |
+
cache_key = self.feature_cache.key_for(entry)
|
| 259 |
+
cached = self.feature_cache.load(entry, cache_key=cache_key)
|
| 260 |
+
payload: dict[str, Any] = {
|
| 261 |
+
"sample_id": entry.sample_id,
|
| 262 |
+
"teacher_cache_key": cache_key,
|
| 263 |
+
"video_name": entry.video_name,
|
| 264 |
+
"video_path": str(video_path),
|
| 265 |
+
}
|
| 266 |
+
if cached is not None:
|
| 267 |
+
payload.update({
|
| 268 |
+
"current_teacher_cache_hit": True,
|
| 269 |
+
"current_teacher_decode_seconds": 0.0,
|
| 270 |
+
"current_teacher_features": cached,
|
| 271 |
+
})
|
| 272 |
+
return payload
|
| 273 |
+
|
| 274 |
+
decode_started = time.perf_counter()
|
| 275 |
+
decoded = self.decode_fn(video_path, self.decoder_contract)
|
| 276 |
+
decode_seconds = time.perf_counter() - decode_started
|
| 277 |
+
if not isinstance(decoded, DecodedMatchedVideo):
|
| 278 |
+
raise TypeError("decode_fn must return DecodedMatchedVideo")
|
| 279 |
+
expected_count = int(self.decoder_contract["expected_source_frame_count"])
|
| 280 |
+
expected_frames = int(self.decoder_contract["num_frames"])
|
| 281 |
+
expected_indices = torch.arange(
|
| 282 |
+
expected_count - expected_frames,
|
| 283 |
+
expected_count,
|
| 284 |
+
dtype=torch.long,
|
| 285 |
+
)
|
| 286 |
+
if (
|
| 287 |
+
int(decoded.source_frame_count) != expected_count
|
| 288 |
+
or not isinstance(decoded.frame_indices, torch.Tensor)
|
| 289 |
+
or decoded.frame_indices.dtype != torch.long
|
| 290 |
+
or not torch.equal(
|
| 291 |
+
decoded.frame_indices.detach().cpu(), expected_indices
|
| 292 |
+
)
|
| 293 |
+
):
|
| 294 |
+
raise ValueError(
|
| 295 |
+
"decode_fn violated the strict last-contiguous 17..80 contract"
|
| 296 |
+
)
|
| 297 |
+
video = decoded.video01
|
| 298 |
+
if (
|
| 299 |
+
not isinstance(video, torch.Tensor)
|
| 300 |
+
or video.dtype != torch.float32
|
| 301 |
+
or video.ndim != 4
|
| 302 |
+
or tuple(video.shape[:2]) != (expected_frames, 3)
|
| 303 |
+
or not bool(torch.isfinite(video).all().item())
|
| 304 |
+
or float(video.min()) < 0.0
|
| 305 |
+
or float(video.max()) > 1.0
|
| 306 |
+
):
|
| 307 |
+
raise ValueError(
|
| 308 |
+
"decode_fn returned invalid video01; expected finite FP32 "
|
| 309 |
+
f"[{expected_frames},3,H,W] in [0,1]"
|
| 310 |
+
)
|
| 311 |
+
payload.update({
|
| 312 |
+
"current_teacher_cache_hit": False,
|
| 313 |
+
"current_teacher_decode_seconds": decode_seconds,
|
| 314 |
+
"current_teacher_frame_indices": decoded.frame_indices,
|
| 315 |
+
"current_teacher_source_frame_count": expected_count,
|
| 316 |
+
"current_teacher_video01": video.contiguous(),
|
| 317 |
+
})
|
| 318 |
+
return payload
|
| 319 |
+
|
| 320 |
+
def write_feature_cache(
|
| 321 |
+
self,
|
| 322 |
+
cache_key: str,
|
| 323 |
+
feature: torch.Tensor,
|
| 324 |
+
*,
|
| 325 |
+
sample_id: str,
|
| 326 |
+
) -> Path:
|
| 327 |
+
try:
|
| 328 |
+
index = self._cache_key_to_index[str(cache_key)]
|
| 329 |
+
except KeyError as exc:
|
| 330 |
+
raise KeyError(f"unknown teacher cache key: {cache_key!r}") from exc
|
| 331 |
+
entry = self.entries[index]
|
| 332 |
+
if entry.sample_id != str(sample_id):
|
| 333 |
+
raise ValueError(
|
| 334 |
+
"sample_id does not own teacher cache key: "
|
| 335 |
+
f"{sample_id!r} != {entry.sample_id!r}"
|
| 336 |
+
)
|
| 337 |
+
self._video_path_and_verify_stat(entry)
|
| 338 |
+
return self.feature_cache.write(
|
| 339 |
+
entry, feature, cache_key=str(cache_key)
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
@staticmethod
|
| 343 |
+
def collate_fn(batch: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
| 344 |
+
return collate_matched_video_metadata(batch)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def collate_matched_video_metadata(
|
| 348 |
+
batch: Sequence[Mapping[str, Any]],
|
| 349 |
+
) -> dict[str, Any]:
|
| 350 |
+
"""Collate only metadata; teacher payloads intentionally have no collator."""
|
| 351 |
+
|
| 352 |
+
if not batch:
|
| 353 |
+
raise ValueError("cannot collate an empty matched-video batch")
|
| 354 |
+
forbidden = {
|
| 355 |
+
"current_teacher_features",
|
| 356 |
+
"current_teacher_video01",
|
| 357 |
+
"current_teacher_cache_hit",
|
| 358 |
+
}
|
| 359 |
+
for item in batch:
|
| 360 |
+
present = forbidden.intersection(item)
|
| 361 |
+
if present:
|
| 362 |
+
raise ValueError(
|
| 363 |
+
"metadata collator received eager teacher payload fields: "
|
| 364 |
+
f"{sorted(present)}"
|
| 365 |
+
)
|
| 366 |
+
return {
|
| 367 |
+
"idx": torch.tensor([int(item["idx"]) for item in batch]),
|
| 368 |
+
"prompts": [str(item["prompts"]) for item in batch],
|
| 369 |
+
"sample_ids": [str(item["sample_id"]) for item in batch],
|
| 370 |
+
"teacher_cache_keys": [str(item["teacher_cache_key"]) for item in batch],
|
| 371 |
+
"video_names": [str(item["video_name"]) for item in batch],
|
| 372 |
+
"video_paths": [str(item["video_path"]) for item in batch],
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
__all__ = [
|
| 377 |
+
"DecodedMatchedVideo",
|
| 378 |
+
"MatchedManifestVideoDataset",
|
| 379 |
+
"collate_matched_video_metadata",
|
| 380 |
+
"decode_matched_video_last_contiguous_pyav",
|
| 381 |
+
]
|
1.3B/code/utils/matched_video_feature_cache.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Strict safetensors cache for matched-manifest live-teacher features."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import tempfile
|
| 10 |
+
from typing import Any, Mapping
|
| 11 |
+
|
| 12 |
+
from safetensors import safe_open
|
| 13 |
+
from safetensors.torch import save_file
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from utils.matched_video_manifest import MatchedVideoManifestEntry
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
CACHE_FORMAT = "sf_matched_manifest_video_vjepa2_teacher_feature"
|
| 20 |
+
CACHE_FORMAT_VERSION = 1
|
| 21 |
+
CACHE_METADATA_KEY = "sf_matched_video_teacher_feature"
|
| 22 |
+
CACHE_TENSOR_KEY = "features"
|
| 23 |
+
|
| 24 |
+
# Independent from the fixed-reference central-64 protocol. Both new student
|
| 25 |
+
# and live teacher use the last 64 frames, i.e. 17..80 for the 81-frame source.
|
| 26 |
+
MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT: dict[str, Any] = {
|
| 27 |
+
"adapter_contract_version": 1,
|
| 28 |
+
"contract_version": 1,
|
| 29 |
+
"crop_size": 256,
|
| 30 |
+
"expected_source_frame_count": 81,
|
| 31 |
+
"feature_dim": 1024,
|
| 32 |
+
"feature_type": "vjepa2_dense_latent",
|
| 33 |
+
"frame_indices": [17, 80],
|
| 34 |
+
"frame_sampling": "last_contiguous",
|
| 35 |
+
"interpolation": "bilinear_antialias",
|
| 36 |
+
"layer_index": 15,
|
| 37 |
+
"model_family": "vjepa2-vitl-fpc64-256",
|
| 38 |
+
"model_config_sha256": (
|
| 39 |
+
"3dec96fe962e94e569182d3a7b9ef0dd74b6b8c89c337a428e43e10d593e70c9"
|
| 40 |
+
),
|
| 41 |
+
"normalization": "imagenet_default",
|
| 42 |
+
"num_frames": 64,
|
| 43 |
+
"num_tokens": 1024,
|
| 44 |
+
"output_dtype": "float32",
|
| 45 |
+
"output_shape": [1024, 1024],
|
| 46 |
+
"processor_config_sha256": (
|
| 47 |
+
"d2fab4418fc0390b62c4cd72ade56908a7929f80c62288adbe10dd8d23421227"
|
| 48 |
+
),
|
| 49 |
+
"resize_short_side": 292,
|
| 50 |
+
"sample_axis": "video",
|
| 51 |
+
"token_alignment": "same_deterministic_indices",
|
| 52 |
+
"token_indices_sha256": (
|
| 53 |
+
"a77ee1f9d20cba6b2f2a9a971c3d36610ca7f8aa2a55c616192582665be69d10"
|
| 54 |
+
),
|
| 55 |
+
"token_sampling": "uniform_linspace_round",
|
| 56 |
+
"transformers_version": "4.53.3",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
MATCHED_PYAV_LAST64_DECODER_CONTRACT: dict[str, Any] = {
|
| 60 |
+
"backend": "pyav",
|
| 61 |
+
"backend_version": "13.1.0",
|
| 62 |
+
"contract_version": 1,
|
| 63 |
+
"expected_frame_indices": [17, 80],
|
| 64 |
+
"expected_source_frame_count": 81,
|
| 65 |
+
"frame_count_source": "container_decode_order_with_stream_frames_hint",
|
| 66 |
+
"num_frames": 64,
|
| 67 |
+
"output_dtype": "float32",
|
| 68 |
+
"output_layout": "TCHW",
|
| 69 |
+
"output_range": [0.0, 1.0],
|
| 70 |
+
"pixel_format": "rgb24",
|
| 71 |
+
"selection": "last_contiguous",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class MatchedVideoFeatureCacheValidationError(ValueError):
|
| 76 |
+
"""An existing cache file is stale, corrupt, or identity-incompatible."""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def canonical_json(value: Any) -> str:
|
| 80 |
+
return json.dumps(
|
| 81 |
+
value,
|
| 82 |
+
ensure_ascii=True,
|
| 83 |
+
sort_keys=True,
|
| 84 |
+
separators=(",", ":"),
|
| 85 |
+
allow_nan=False,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def canonical_sha256(value: Any) -> str:
|
| 90 |
+
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT_SHA256 = canonical_sha256(
|
| 94 |
+
MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT
|
| 95 |
+
)
|
| 96 |
+
MATCHED_PYAV_LAST64_DECODER_CONTRACT_SHA256 = canonical_sha256(
|
| 97 |
+
MATCHED_PYAV_LAST64_DECODER_CONTRACT
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _is_sha256(value: str) -> bool:
|
| 102 |
+
return len(value) == 64 and all(c in "0123456789abcdef" for c in value)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _entry_context(
|
| 106 |
+
entry: MatchedVideoManifestEntry | Mapping[str, Any],
|
| 107 |
+
) -> dict[str, Any]:
|
| 108 |
+
if isinstance(entry, MatchedVideoManifestEntry):
|
| 109 |
+
result = {
|
| 110 |
+
"sample_id": entry.sample_id,
|
| 111 |
+
"video_mtime_ns": int(entry.video_mtime_ns),
|
| 112 |
+
"video_name": entry.video_name,
|
| 113 |
+
"video_size_bytes": int(entry.video_size_bytes),
|
| 114 |
+
}
|
| 115 |
+
elif isinstance(entry, Mapping):
|
| 116 |
+
required = {
|
| 117 |
+
"sample_id", "video_mtime_ns", "video_name", "video_size_bytes",
|
| 118 |
+
}
|
| 119 |
+
missing = required - set(entry)
|
| 120 |
+
if missing:
|
| 121 |
+
raise ValueError(f"cache sample context is missing {sorted(missing)}")
|
| 122 |
+
result = {
|
| 123 |
+
"sample_id": str(entry["sample_id"]),
|
| 124 |
+
"video_mtime_ns": int(entry["video_mtime_ns"]),
|
| 125 |
+
"video_name": str(entry["video_name"]),
|
| 126 |
+
"video_size_bytes": int(entry["video_size_bytes"]),
|
| 127 |
+
}
|
| 128 |
+
else:
|
| 129 |
+
raise TypeError("entry must be MatchedVideoManifestEntry or mapping")
|
| 130 |
+
if not result["sample_id"] or result["sample_id"] != result["video_name"]:
|
| 131 |
+
raise ValueError("cache sample_id must equal video_name")
|
| 132 |
+
if result["video_size_bytes"] <= 0 or result["video_mtime_ns"] <= 0:
|
| 133 |
+
raise ValueError("cache video stat must be positive")
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _tensor_sha256(value: torch.Tensor) -> str:
|
| 138 |
+
value = value.detach().cpu().contiguous()
|
| 139 |
+
return hashlib.sha256(value.numpy().tobytes(order="C")).hexdigest()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class MatchedVideoFeatureCache:
|
| 143 |
+
"""Lazy one-file-per-video FP32 cache with immutable identity checks."""
|
| 144 |
+
|
| 145 |
+
def __init__(
|
| 146 |
+
self,
|
| 147 |
+
cache_root: str | os.PathLike,
|
| 148 |
+
*,
|
| 149 |
+
expected_model_sha256: str,
|
| 150 |
+
expected_feature_contract_sha256: str,
|
| 151 |
+
feature_contract: Mapping[str, Any] | None = None,
|
| 152 |
+
decoder_contract: Mapping[str, Any] | None = None,
|
| 153 |
+
expected_shape: tuple[int, int] | None = None,
|
| 154 |
+
) -> None:
|
| 155 |
+
self.cache_root = Path(cache_root).expanduser().resolve()
|
| 156 |
+
self.model_sha256 = str(expected_model_sha256).lower()
|
| 157 |
+
if not _is_sha256(self.model_sha256):
|
| 158 |
+
raise ValueError("expected_model_sha256 must be lowercase SHA-256")
|
| 159 |
+
self.feature_contract = json.loads(canonical_json(
|
| 160 |
+
feature_contract or MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT
|
| 161 |
+
))
|
| 162 |
+
self.feature_contract_sha256 = canonical_sha256(self.feature_contract)
|
| 163 |
+
expected_hash = str(expected_feature_contract_sha256).lower()
|
| 164 |
+
if not _is_sha256(expected_hash):
|
| 165 |
+
raise ValueError(
|
| 166 |
+
"expected_feature_contract_sha256 must be lowercase SHA-256"
|
| 167 |
+
)
|
| 168 |
+
if self.feature_contract_sha256 != expected_hash:
|
| 169 |
+
raise ValueError(
|
| 170 |
+
"feature contract SHA-256 mismatch: "
|
| 171 |
+
f"expected={expected_hash}, actual={self.feature_contract_sha256}"
|
| 172 |
+
)
|
| 173 |
+
self.decoder_contract = json.loads(canonical_json(
|
| 174 |
+
decoder_contract or MATCHED_PYAV_LAST64_DECODER_CONTRACT
|
| 175 |
+
))
|
| 176 |
+
self.decoder_contract_sha256 = canonical_sha256(self.decoder_contract)
|
| 177 |
+
contract_shape = tuple(
|
| 178 |
+
int(value) for value in self.feature_contract.get("output_shape", ())
|
| 179 |
+
)
|
| 180 |
+
self.expected_shape = (
|
| 181 |
+
tuple(int(value) for value in expected_shape)
|
| 182 |
+
if expected_shape is not None else contract_shape
|
| 183 |
+
)
|
| 184 |
+
if len(self.expected_shape) != 2 or min(self.expected_shape) <= 0:
|
| 185 |
+
raise ValueError("expected feature shape must be positive [N,D]")
|
| 186 |
+
if contract_shape and self.expected_shape != contract_shape:
|
| 187 |
+
raise ValueError(
|
| 188 |
+
"expected_shape must match feature_contract.output_shape"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
def _key_payload(
|
| 192 |
+
self, entry: MatchedVideoManifestEntry | Mapping[str, Any],
|
| 193 |
+
) -> dict[str, Any]:
|
| 194 |
+
return {
|
| 195 |
+
"decoder_contract": self.decoder_contract,
|
| 196 |
+
"decoder_contract_sha256": self.decoder_contract_sha256,
|
| 197 |
+
"feature_contract": self.feature_contract,
|
| 198 |
+
"feature_contract_sha256": self.feature_contract_sha256,
|
| 199 |
+
"format": CACHE_FORMAT,
|
| 200 |
+
"format_version": CACHE_FORMAT_VERSION,
|
| 201 |
+
"model_sha256": self.model_sha256,
|
| 202 |
+
"sample": _entry_context(entry),
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
def key_for(
|
| 206 |
+
self, entry: MatchedVideoManifestEntry | Mapping[str, Any],
|
| 207 |
+
) -> str:
|
| 208 |
+
digest = hashlib.sha256()
|
| 209 |
+
digest.update(b"sf-matched-video-teacher-feature-cache\0")
|
| 210 |
+
digest.update(canonical_json(self._key_payload(entry)).encode("utf-8"))
|
| 211 |
+
return digest.hexdigest()
|
| 212 |
+
|
| 213 |
+
def path_for_key(self, cache_key: str) -> Path:
|
| 214 |
+
key = str(cache_key).lower()
|
| 215 |
+
if not _is_sha256(key):
|
| 216 |
+
raise ValueError("cache_key must be lowercase SHA-256")
|
| 217 |
+
return self.cache_root / key[:2] / key[2:4] / f"{key}.safetensors"
|
| 218 |
+
|
| 219 |
+
def _expected_metadata(
|
| 220 |
+
self,
|
| 221 |
+
entry: MatchedVideoManifestEntry | Mapping[str, Any],
|
| 222 |
+
cache_key: str,
|
| 223 |
+
tensor_sha256: str,
|
| 224 |
+
) -> dict[str, Any]:
|
| 225 |
+
result = self._key_payload(entry)
|
| 226 |
+
result.update({
|
| 227 |
+
"cache_key": cache_key,
|
| 228 |
+
"tensor": {
|
| 229 |
+
"dtype": "float32", "key": CACHE_TENSOR_KEY,
|
| 230 |
+
"sha256": tensor_sha256, "shape": list(self.expected_shape),
|
| 231 |
+
},
|
| 232 |
+
})
|
| 233 |
+
return result
|
| 234 |
+
|
| 235 |
+
def _validate_tensor(self, feature: torch.Tensor) -> torch.Tensor:
|
| 236 |
+
if not isinstance(feature, torch.Tensor):
|
| 237 |
+
raise TypeError("teacher feature must be a torch.Tensor")
|
| 238 |
+
if feature.ndim == 3 and int(feature.shape[0]) == 1:
|
| 239 |
+
feature = feature[0]
|
| 240 |
+
if feature.ndim != 2 or tuple(feature.shape) != self.expected_shape:
|
| 241 |
+
raise ValueError(
|
| 242 |
+
f"teacher feature shape mismatch: {tuple(feature.shape)} "
|
| 243 |
+
f"!= {self.expected_shape}"
|
| 244 |
+
)
|
| 245 |
+
if not feature.is_floating_point():
|
| 246 |
+
raise TypeError("teacher feature must be floating point")
|
| 247 |
+
result = feature.detach().to(device="cpu", dtype=torch.float32).contiguous()
|
| 248 |
+
if not bool(torch.isfinite(result).all().item()):
|
| 249 |
+
raise FloatingPointError("teacher feature contains non-finite values")
|
| 250 |
+
return result
|
| 251 |
+
|
| 252 |
+
def load(
|
| 253 |
+
self,
|
| 254 |
+
entry: MatchedVideoManifestEntry | Mapping[str, Any],
|
| 255 |
+
*,
|
| 256 |
+
cache_key: str | None = None,
|
| 257 |
+
) -> torch.Tensor | None:
|
| 258 |
+
key = self.key_for(entry)
|
| 259 |
+
if cache_key is not None and str(cache_key) != key:
|
| 260 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 261 |
+
"caller cache_key does not match sample/model/contracts"
|
| 262 |
+
)
|
| 263 |
+
path = self.path_for_key(key)
|
| 264 |
+
if not path.exists():
|
| 265 |
+
return None
|
| 266 |
+
if not path.is_file():
|
| 267 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 268 |
+
f"cache path is not a regular file: {path}"
|
| 269 |
+
)
|
| 270 |
+
try:
|
| 271 |
+
with safe_open(path, framework="pt", device="cpu") as handle:
|
| 272 |
+
if list(handle.keys()) != [CACHE_TENSOR_KEY]:
|
| 273 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 274 |
+
f"cache tensor keys mismatch in {path}"
|
| 275 |
+
)
|
| 276 |
+
serialized = (handle.metadata() or {}).get(CACHE_METADATA_KEY)
|
| 277 |
+
if serialized is None:
|
| 278 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 279 |
+
f"cache metadata is missing in {path}"
|
| 280 |
+
)
|
| 281 |
+
metadata = json.loads(serialized)
|
| 282 |
+
feature = handle.get_tensor(CACHE_TENSOR_KEY).clone()
|
| 283 |
+
except MatchedVideoFeatureCacheValidationError:
|
| 284 |
+
raise
|
| 285 |
+
except Exception as exc:
|
| 286 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 287 |
+
f"failed to read safetensors cache {path}: {exc}"
|
| 288 |
+
) from exc
|
| 289 |
+
feature = self._validate_tensor(feature)
|
| 290 |
+
expected = self._expected_metadata(entry, key, _tensor_sha256(feature))
|
| 291 |
+
if metadata != expected:
|
| 292 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 293 |
+
f"cache metadata/identity mismatch (stale or corrupt) in {path}"
|
| 294 |
+
)
|
| 295 |
+
return feature
|
| 296 |
+
|
| 297 |
+
def write(
|
| 298 |
+
self,
|
| 299 |
+
entry: MatchedVideoManifestEntry | Mapping[str, Any],
|
| 300 |
+
feature: torch.Tensor,
|
| 301 |
+
*,
|
| 302 |
+
cache_key: str | None = None,
|
| 303 |
+
overwrite: bool = False,
|
| 304 |
+
) -> Path:
|
| 305 |
+
key = self.key_for(entry)
|
| 306 |
+
if cache_key is not None and str(cache_key) != key:
|
| 307 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 308 |
+
"caller cache_key does not match sample/model/contracts"
|
| 309 |
+
)
|
| 310 |
+
feature = self._validate_tensor(feature)
|
| 311 |
+
tensor_hash = _tensor_sha256(feature)
|
| 312 |
+
destination = self.path_for_key(key)
|
| 313 |
+
if destination.exists() and not overwrite:
|
| 314 |
+
existing = self.load(entry, cache_key=key)
|
| 315 |
+
if existing is None or _tensor_sha256(existing) != tensor_hash:
|
| 316 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 317 |
+
"refusing to replace a valid cache entry with different features: "
|
| 318 |
+
f"{destination}"
|
| 319 |
+
)
|
| 320 |
+
return destination
|
| 321 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 322 |
+
metadata = self._expected_metadata(entry, key, tensor_hash)
|
| 323 |
+
descriptor, temporary_name = tempfile.mkstemp(
|
| 324 |
+
dir=destination.parent,
|
| 325 |
+
prefix=f".{destination.name}.", suffix=".tmp",
|
| 326 |
+
)
|
| 327 |
+
os.close(descriptor)
|
| 328 |
+
temporary = Path(temporary_name)
|
| 329 |
+
try:
|
| 330 |
+
temporary.unlink()
|
| 331 |
+
save_file(
|
| 332 |
+
{CACHE_TENSOR_KEY: feature}, str(temporary),
|
| 333 |
+
metadata={CACHE_METADATA_KEY: canonical_json(metadata)},
|
| 334 |
+
)
|
| 335 |
+
with temporary.open("rb") as handle:
|
| 336 |
+
os.fsync(handle.fileno())
|
| 337 |
+
if overwrite:
|
| 338 |
+
os.replace(temporary, destination)
|
| 339 |
+
else:
|
| 340 |
+
try:
|
| 341 |
+
os.link(temporary, destination)
|
| 342 |
+
except FileExistsError:
|
| 343 |
+
existing = self.load(entry, cache_key=key)
|
| 344 |
+
if existing is None or _tensor_sha256(existing) != tensor_hash:
|
| 345 |
+
raise MatchedVideoFeatureCacheValidationError(
|
| 346 |
+
"concurrent cache writer produced different features "
|
| 347 |
+
f"for {key}"
|
| 348 |
+
)
|
| 349 |
+
temporary.unlink(missing_ok=True)
|
| 350 |
+
finally:
|
| 351 |
+
temporary.unlink(missing_ok=True)
|
| 352 |
+
self.load(entry, cache_key=key)
|
| 353 |
+
return destination
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
__all__ = [
|
| 357 |
+
"CACHE_FORMAT", "CACHE_FORMAT_VERSION", "CACHE_METADATA_KEY",
|
| 358 |
+
"CACHE_TENSOR_KEY", "MATCHED_PYAV_LAST64_DECODER_CONTRACT",
|
| 359 |
+
"MATCHED_PYAV_LAST64_DECODER_CONTRACT_SHA256",
|
| 360 |
+
"MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT",
|
| 361 |
+
"MATCHED_VJEPA2_L15_LAST64_FEATURE_CONTRACT_SHA256",
|
| 362 |
+
"MatchedVideoFeatureCache", "MatchedVideoFeatureCacheValidationError",
|
| 363 |
+
"canonical_json", "canonical_sha256",
|
| 364 |
+
]
|