Elastic-forcing commited on
Commit
2ea2835
·
verified ·
1 Parent(s): 9989925

Add training and inference source code

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +4 -0
  2. 1.3B/.gitignore +11 -0
  3. 1.3B/README.md +183 -0
  4. 1.3B/code/LICENSE +201 -0
  5. 1.3B/code/configs/default_config.yaml +11 -0
  6. 1.3B/code/demo_utils/memory.py +135 -0
  7. 1.3B/code/inference.py +238 -0
  8. 1.3B/code/losses/__init__.py +1 -0
  9. 1.3B/code/losses/multi_encoder_mmd.py +235 -0
  10. 1.3B/code/losses/multi_representation_video_nystrom_mmd.py +7 -0
  11. 1.3B/code/losses/multilayer_video_nystrom_mmd.py +381 -0
  12. 1.3B/code/losses/video_nystrom_mmd.py +1366 -0
  13. 1.3B/code/losses/wan_kv_mmd.py +507 -0
  14. 1.3B/code/model/__init__.py +4 -0
  15. 1.3B/code/model/base.py +343 -0
  16. 1.3B/code/model/distribution_matching.py +0 -0
  17. 1.3B/code/model/generation.py +142 -0
  18. 1.3B/code/model/wan_kv_mmd.py +6 -0
  19. 1.3B/code/pipeline/__init__.py +4 -0
  20. 1.3B/code/pipeline/causal_inference.py +472 -0
  21. 1.3B/code/pipeline/self_forcing_training.py +406 -0
  22. 1.3B/code/prompts/test_8.txt +8 -0
  23. 1.3B/code/tests/__init__.py +0 -0
  24. 1.3B/code/tests/test_multi_representation_video_nystrom_mmd.py +238 -0
  25. 1.3B/code/tests/test_readable_config.py +98 -0
  26. 1.3B/code/tests/test_triple_feature_metadata_contract.py +82 -0
  27. 1.3B/code/tests/test_triple_partial_geometry.py +13 -0
  28. 1.3B/code/tests/test_triple_pixel_cotangent_accumulation.py +34 -0
  29. 1.3B/code/tests/test_triple_video_feature_bank.py +196 -0
  30. 1.3B/code/tests/test_video_nystrom_partial_mmd.py +372 -0
  31. 1.3B/code/tools/build_triple_nystrom_references.py +260 -0
  32. 1.3B/code/tools/download_videomae_v2_large.py +120 -0
  33. 1.3B/code/tools/extract_triple_matched_teacher_bank.py +472 -0
  34. 1.3B/code/tools/precompute_dinov3_dense_nystrom_reference.py +1160 -0
  35. 1.3B/code/train.py +90 -0
  36. 1.3B/code/trainer/__init__.py +31 -0
  37. 1.3B/code/trainer/distillation.py +6 -0
  38. 1.3B/code/trainer/distribution_matching.py +0 -0
  39. 1.3B/code/trainer/feature_gradient_replay.py +414 -0
  40. 1.3B/code/trainer/fresh_video_gradcache.py +634 -0
  41. 1.3B/code/trainer/multi_representation_fresh_video_gradcache.py +7 -0
  42. 1.3B/code/utils/config.py +83 -0
  43. 1.3B/code/utils/dataset.py +283 -0
  44. 1.3B/code/utils/dinov3_vjepa2_feature_bank.py +301 -0
  45. 1.3B/code/utils/distributed.py +188 -0
  46. 1.3B/code/utils/dual_video_feature_bank.py +286 -0
  47. 1.3B/code/utils/lmdb.py +72 -0
  48. 1.3B/code/utils/loss.py +81 -0
  49. 1.3B/code/utils/matched_video_dataset.py +381 -0
  50. 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
+ ]