shreshthsaini commited on
Commit
914261a
·
verified ·
1 Parent(s): b71fb26

Add CachedSearch implementation and model card

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/fig1_overview.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/fig2_method.png filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shreshth Saini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: diffusers
4
+ pipeline_tag: text-to-video
5
+ tags:
6
+ - text-to-video
7
+ - video-generation
8
+ - test-time-search
9
+ - best-of-n
10
+ - caching
11
+ - training-free
12
+ - wan
13
+ - cogvideox
14
+ - hunyuanvideo
15
+ - ltx-video
16
+ ---
17
+
18
+ # CachedSearch: Training-Free Cached Exploration for Test-Time Search in Video Diffusion
19
+
20
+ [Paper (arXiv 2607.23159)](https://arxiv.org/abs/2607.23159) · [Code](https://github.com/shreshthsaini/CachedSearch) · [Project page](https://shreshthsaini.github.io/CachedSearch/) · [ComfyUI nodes](https://github.com/shreshthsaini/ComfyUI-CachedSearch) · [Diffusers community script](https://github.com/huggingface/diffusers/blob/main/examples/community/README_community_scripts.md#cachedsearch-cheaper-best-of-n-for-video-pipelines)
21
+
22
+ CachedSearch makes best-of-N seed search cheaper for video diffusion models. It generates every candidate with a training-free cache turned on, scores the cached drafts with a verifier, and regenerates only the winning seed at full compute.
23
+
24
+ The method needs no training, so this repository holds no weights. It holds the official implementation: the `cachedsearch` Python package and two example scripts, copied unchanged from the GitHub repository at commit [`cc2a637`](https://github.com/shreshthsaini/CachedSearch/tree/cc2a637c89485af723233aeb8ad76190b572a464).
25
+
26
+ ![CachedSearch overview](assets/fig1_overview.png)
27
+
28
+ ## How it works
29
+
30
+ 1. Generate every candidate with a training-free cache enabled.
31
+ 2. Score the cached drafts with the verifier the search uses.
32
+ 3. Select the seed with the highest draft score.
33
+ 4. Regenerate only that seed at full compute and return it.
34
+
35
+ ```text
36
+ drafts = [cached_generate(prompt, seed) for seed in seeds]
37
+ winner = seeds[argmax(verifier(video, prompt) for video in drafts)]
38
+ return full_generate(prompt, winner)
39
+ ```
40
+
41
+ The sampler is deterministic in the seed, so the delivered video matches what full-compute search returns whenever both pick the same seed. Caching can change which candidate wins. It does not change the quality of the video you get back.
42
+
43
+ ![CachedSearch method](assets/fig2_method.png)
44
+
45
+ Results reported in the official README:
46
+
47
+ - Ranking survives caching: median Spearman rho is 0.905, with 72% top-1 agreement on VBench.
48
+ - Exploring under caching and committing at full compute retains 94.7% of the best-of-8 gain at 63% of the cost.
49
+ - Ranking failures concentrate where candidates are nearly tied, so they cost little.
50
+ - The approach works with any published cache engine and transfers across six models in four families.
51
+
52
+ ## Files
53
+
54
+ | Path | Contents |
55
+ |---|---|
56
+ | `cachedsearch/api.py` | `cached_search`, `cached_search_batch`, `calibrate_tau` |
57
+ | `cachedsearch/caching.py` | `CacheConfig`, `CachedTransformer`, `wrap_pipeline` (the EasyCache-style adaptive cache and a static baseline) |
58
+ | `cachedsearch/verifiers.py` | the default ImageReward verifier |
59
+ | `examples/run_wan.py` | best-of-8 search on Wan2.1 |
60
+ | `examples/calibrate_new_model.py` | threshold calibration for a new model family |
61
+
62
+ ## Install
63
+
64
+ Install from GitHub:
65
+
66
+ ```bash
67
+ pip install "git+https://github.com/shreshthsaini/CachedSearch.git@cc2a637c89485af723233aeb8ad76190b572a464"
68
+ ```
69
+
70
+ Or fetch the package from this repository, with no install step:
71
+
72
+ ```python
73
+ import sys
74
+ from huggingface_hub import snapshot_download
75
+
76
+ repo_dir = snapshot_download("shreshthsaini/CachedSearch", allow_patterns=["cachedsearch/*"])
77
+ sys.path.insert(0, repo_dir)
78
+ ```
79
+
80
+ Requirements: `torch`, `diffusers`, `numpy`, plus whatever your pipeline needs (`transformers` for the text encoder). The default verifier needs `image-reward`. Pass your own `verifier=` to skip that dependency.
81
+
82
+ ## Usage
83
+
84
+ `cached_search` takes any diffusers video pipeline that exposes `.transformer`. The verifier is any callable `(frames, prompt) -> float` where higher is better.
85
+
86
+ The example below runs on CPU in a few seconds. It uses `hf-internal-testing/tiny-wan-pipe`, a tiny Wan pipeline with random weights, and a toy verifier. It checks that the search loop and the cache run end to end. The frames are noise and the scores mean nothing.
87
+
88
+ ```python
89
+ import sys
90
+
91
+ import numpy as np
92
+ import torch
93
+ from diffusers import WanPipeline
94
+ from huggingface_hub import snapshot_download
95
+
96
+ # Skip these two lines if you installed the package with pip.
97
+ repo_dir = snapshot_download("shreshthsaini/CachedSearch", allow_patterns=["cachedsearch/*"])
98
+ sys.path.insert(0, repo_dir)
99
+
100
+ from cachedsearch import CacheConfig, cached_search
101
+ from cachedsearch.caching import wrap_pipeline
102
+
103
+ pipe = WanPipeline.from_pretrained("hf-internal-testing/tiny-wan-pipe", torch_dtype=torch.float32)
104
+ gen_kwargs = dict(height=32, width=32, num_frames=9, guidance_scale=5.0)
105
+
106
+
107
+ def toy_verifier(frames, prompt):
108
+ # Stand-in scorer so the example runs without ImageReward. Not a quality metric.
109
+ return float(np.asarray(frames).mean())
110
+
111
+
112
+ result = cached_search(
113
+ pipe,
114
+ "a red fox running through deep snow",
115
+ verifier=toy_verifier,
116
+ n=4, # search width
117
+ tau=0.10, # caching threshold
118
+ steps=20,
119
+ mode="commit", # regenerate the winner at full compute
120
+ gen_kwargs=gen_kwargs,
121
+ )
122
+
123
+ print(result.seed) # the seed that won
124
+ print(result.draft_scores) # verifier score of every cached draft
125
+ print(np.asarray(result.video).shape) # (9, 32, 32, 3), the full-compute video
126
+
127
+ # Count how many transformer calls one cached rollout skips.
128
+ cache = wrap_pipeline(pipe, CacheConfig(mode="adaptive", tau=0.10, total_steps=20))
129
+ cache.reset()
130
+ pipe(prompt="a red fox running through deep snow", num_inference_steps=20,
131
+ generator=torch.Generator().manual_seed(0), **gen_kwargs)
132
+ print(cache.stats())
133
+ ```
134
+
135
+ Output on CPU with `torch` 2.14.1 and `diffusers` 0.41.0:
136
+
137
+ ```text
138
+ 3
139
+ [0.4587..., 0.4588..., 0.4589..., 0.4590...]
140
+ (9, 32, 32, 3)
141
+ {'transformer0': {'branch0': {'skips': 8, 'computes': 12}, 'branch1': {'skips': 8, 'computes': 12}}}
142
+ ```
143
+
144
+ With these settings the cache skips 8 of 20 transformer calls per classifier-free-guidance branch.
145
+
146
+ ### Real models
147
+
148
+ For a real model, load the checkpoint on a GPU and drop the `verifier` argument to use ImageReward averaged over 8 frames, the scorer behind the paper's numbers. `examples/run_wan.py` does this for `Wan-AI/Wan2.1-T2V-1.3B-Diffusers`:
149
+
150
+ ```bash
151
+ pip install image-reward imageio imageio-ffmpeg
152
+ python examples/run_wan.py --prompt "a red fox running through deep snow"
153
+ ```
154
+
155
+ This script and `examples/calibrate_new_model.py` are the official ones from the GitHub repository. They need a CUDA GPU and were not rerun when this card was written. Only the CPU example above was.
156
+
157
+ ## Thresholds for tested models
158
+
159
+ The caching threshold `tau` is the one model-specific number. The table below comes from the official README: the recommended `tau` is the most aggressive setting that still keeps at least 85% of the search gain in the calibration sweep.
160
+
161
+ | Model | Params | Family | Recommended `tau` | Gain capture | Exploration speedup |
162
+ |---|---:|---|---:|---:|---:|
163
+ | Wan2.1-T2V-1.3B | 1.3B | Wan | 0.20 | 88.3% | 2.41x |
164
+ | Wan2.1-T2V-14B | 14B | Wan | 0.10 | 87.5% | 2.05x |
165
+ | Wan2.2-TI2V-5B | 5B | Wan | 0.10 | 86.0% | 2.05x |
166
+ | CogVideoX-5B | 5B | CogVideoX | 0.05 | 85.9% | 1.78x |
167
+ | HunyuanVideo | 13B | HunyuanVideo | 0.05 | 85.1% | 1.77x |
168
+ | LTX-Video | 2B | LTX | 0.02 | 79.6% | 1.71x |
169
+
170
+ No threshold measured for LTX-Video reached 85% capture. For a model that is not listed, run `examples/calibrate_new_model.py` once (about two GPU-hours) and use the threshold it recommends. Do not copy a threshold across model families.
171
+
172
+ ## Modes
173
+
174
+ - `mode="commit"` (default): regenerate the winner with caching off. The delivered video is a full-compute sample.
175
+ - `mode="keep"`: return the cached draft of the winner. Cheaper, with mild motion dampening at aggressive thresholds.
176
+
177
+ ## Citation
178
+
179
+ ```bibtex
180
+ @article{saini2026cachedsearch,
181
+ title = {CachedSearch: Training-Free Cached Exploration for Test-Time Search in Video Diffusion},
182
+ author = {Saini, Shreshth and Birkbeck, Neil and Wang, Yilin and Adsumilli, Balu and Bovik, Alan C.},
183
+ year = {2026},
184
+ eprint = {2607.23159},
185
+ archivePrefix = {arXiv},
186
+ primaryClass = {cs.AI}
187
+ }
188
+ ```
189
+
190
+ ## License
191
+
192
+ MIT. See [LICENSE](LICENSE).
assets/fig1_overview.png ADDED

Git LFS Details

  • SHA256: e02ca2ba73d18c47a029c31accba4ab85eca9bbdeb4de0be2fa25de237891651
  • Pointer size: 131 Bytes
  • Size of remote file: 146 kB
assets/fig2_method.png ADDED

Git LFS Details

  • SHA256: ccbdd6960a4800bbbc9bd020df259ecad839538c1dc8c3c7df5dc8567c77a415
  • Pointer size: 131 Bytes
  • Size of remote file: 294 kB
cachedsearch/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CachedSearch: explore cheap, commit full.
2
+
3
+ Drop-in test-time search for any diffusers video pipeline:
4
+
5
+ from cachedsearch import cached_search
6
+
7
+ result = cached_search(pipe, "a red fox in snow", n=8)
8
+
9
+ The verifier defaults to ImageReward averaged over 8 frames, the scorer behind
10
+ the paper's numbers. Pass ``verifier=`` to use your own.
11
+
12
+ The exploration rollouts run under training-free caching; only the winning
13
+ seed is regenerated at full compute, so the returned video is a genuine
14
+ full-compute sample.
15
+ """
16
+
17
+ from .api import CacheConfig, cached_search, cached_search_batch, calibrate_tau
18
+ from .verifiers import default_verifier, imagereward_verifier
19
+
20
+ __all__ = [
21
+ "cached_search", "cached_search_batch", "calibrate_tau", "CacheConfig",
22
+ "default_verifier", "imagereward_verifier",
23
+ ]
cachedsearch/api.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The user-facing CachedSearch API.
2
+
3
+ Three functions:
4
+
5
+ cached_search(pipe, prompt, verifier, n=8) run search on one prompt
6
+ cached_search_batch(pipe, prompts, verifier) run search on many prompts
7
+ calibrate_tau(pipe, prompts, verifier) find the threshold for a new model
8
+
9
+ Everything is training-free. The only model-specific quantity is the caching
10
+ threshold tau, which `calibrate_tau` measures in about two GPU-hours.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Callable, Iterable, Sequence
17
+
18
+ import numpy as np
19
+ import torch
20
+
21
+ from .caching import CacheConfig, wrap_pipeline
22
+
23
+ from .verifiers import Verifier, default_verifier, imagereward_verifier
24
+
25
+
26
+ @dataclass
27
+ class SearchResult:
28
+ video: object # the delivered video (full-compute sample in commit mode)
29
+ seed: int # the seed that produced it
30
+ draft_scores: list # verifier score of every cached draft
31
+ explore_seconds: float
32
+ commit_seconds: float
33
+
34
+ @property
35
+ def total_seconds(self) -> float:
36
+ return self.explore_seconds + self.commit_seconds
37
+
38
+
39
+ def _generate(pipe, prompt: str, seed: int, steps: int, gen_kwargs: dict):
40
+ generator = torch.Generator(device=pipe.device).manual_seed(seed)
41
+ out = pipe(prompt=prompt, num_inference_steps=steps, generator=generator, **gen_kwargs)
42
+ return out.frames[0]
43
+
44
+
45
+ def cached_search(
46
+ pipe,
47
+ prompt: str,
48
+ verifier: Verifier | None = None,
49
+ n: int = 8,
50
+ tau: float = 0.10,
51
+ steps: int = 50,
52
+ mode: str = "commit",
53
+ seeds: Sequence[int] | None = None,
54
+ gen_kwargs: dict | None = None,
55
+ ) -> SearchResult:
56
+ """Best-of-n search where exploration runs under caching.
57
+
58
+ Args:
59
+ pipe: any diffusers video pipeline exposing `.transformer` (Wan, CogVideoX,
60
+ HunyuanVideo, LTX-Video all work).
61
+ prompt: the text prompt.
62
+ verifier: callable (frames, prompt) -> float, higher is better. Any
63
+ scorer works. Defaults to ImageReward averaged over 8 uniformly
64
+ spaced frames, the verifier behind the paper's headline numbers.
65
+ n: search width (number of candidates). Recommit pays off for n >= 4.
66
+ tau: caching threshold. 0.10 is calibrated for Wan2.1; run `calibrate_tau`
67
+ for a new architecture (do not copy this constant across families).
68
+ steps: denoising steps.
69
+ mode: "commit" regenerates the winner at full compute (recommended; the
70
+ delivered video is a genuine full-compute sample). "keep" returns the
71
+ cached draft directly: cheaper, but caching artifacts reach the user
72
+ and motion is mildly dampened.
73
+ seeds: explicit seeds; defaults to range(n).
74
+ gen_kwargs: extra pipeline kwargs (height, width, num_frames, guidance...).
75
+
76
+ Returns:
77
+ SearchResult with the delivered video, its seed, draft scores, and timings.
78
+ """
79
+ seeds = list(seeds if seeds is not None else range(n))
80
+ gen_kwargs = dict(gen_kwargs or {})
81
+ if verifier is None:
82
+ verifier = default_verifier()
83
+
84
+ # ---- explore: every candidate under caching -------------------------------
85
+ cache = wrap_pipeline(pipe, CacheConfig(mode="adaptive", tau=tau, total_steps=steps))
86
+ drafts, scores = [], []
87
+ t0 = time.time()
88
+ for seed in seeds:
89
+ cache.reset() # cache state must not leak across candidates
90
+ frames = _generate(pipe, prompt, seed, steps, gen_kwargs)
91
+ drafts.append(frames)
92
+ scores.append(float(verifier(frames, prompt)))
93
+ explore_s = time.time() - t0
94
+
95
+ best = int(np.argmax(scores))
96
+ winner_seed = seeds[best]
97
+
98
+ # ---- commit: regenerate only the winner at full compute -------------------
99
+ if mode == "keep":
100
+ return SearchResult(drafts[best], winner_seed, scores, explore_s, 0.0)
101
+
102
+ wrap_pipeline(pipe, CacheConfig(mode="off", total_steps=steps))
103
+ t0 = time.time()
104
+ video = _generate(pipe, prompt, winner_seed, steps, gen_kwargs)
105
+ commit_s = time.time() - t0
106
+ return SearchResult(video, winner_seed, scores, explore_s, commit_s)
107
+
108
+
109
+ def cached_search_batch(
110
+ pipe, prompts: Iterable[str], verifier: Verifier | None = None, **kwargs
111
+ ) -> list[SearchResult]:
112
+ """cached_search over many prompts; returns one SearchResult per prompt."""
113
+ return [cached_search(pipe, p, verifier, **kwargs) for p in prompts]
114
+
115
+
116
+ def calibrate_tau(
117
+ pipe,
118
+ prompts: Sequence[str],
119
+ verifier: Verifier | None = None,
120
+ taus: Sequence[float] = (0.05, 0.10, 0.20),
121
+ n: int = 8,
122
+ steps: int = 50,
123
+ target_capture: float = 0.90,
124
+ gen_kwargs: dict | None = None,
125
+ ) -> dict:
126
+ """Measure the speed-fidelity frontier of a NEW model and pick tau.
127
+
128
+ Protocol (this is the paper's protocol in miniature): generate every
129
+ (prompt, seed) pair twice, once at full compute and once under caching,
130
+ score both, and report how much of full-compute search's gain the cached
131
+ ranking retains. Roughly two GPU-hours with 25 prompts on a 1.3B model.
132
+
133
+ Returns {tau: {"speedup", "capture", "spearman"}} plus "recommended": the
134
+ largest tau whose capture is at or above target_capture.
135
+ """
136
+ prompts = list(prompts)
137
+ gen_kwargs = dict(gen_kwargs or {})
138
+ seeds = list(range(n))
139
+ if verifier is None:
140
+ verifier = default_verifier()
141
+
142
+ # full-compute references, shared across all tau values
143
+ wrap_pipeline(pipe, CacheConfig(mode="off", total_steps=steps))
144
+ full_scores, full_latency = {}, []
145
+ for p in prompts:
146
+ row = []
147
+ for s in seeds:
148
+ t0 = time.time()
149
+ frames = _generate(pipe, p, s, steps, gen_kwargs)
150
+ full_latency.append(time.time() - t0)
151
+ row.append(float(verifier(frames, p)))
152
+ full_scores[p] = np.asarray(row)
153
+ cf = float(np.mean(full_latency))
154
+
155
+ report = {}
156
+ for tau in taus:
157
+ cache = wrap_pipeline(pipe, CacheConfig(mode="adaptive", tau=tau, total_steps=steps))
158
+ rhos, captures, latency = [], [], []
159
+ for p in prompts:
160
+ row = []
161
+ for s in seeds:
162
+ cache.reset()
163
+ t0 = time.time()
164
+ frames = _generate(pipe, p, s, steps, gen_kwargs)
165
+ latency.append(time.time() - t0)
166
+ row.append(float(verifier(frames, p)))
167
+ cached = np.asarray(row)
168
+ full = full_scores[p]
169
+ ra, rb = np.argsort(np.argsort(full)), np.argsort(np.argsort(cached))
170
+ rhos.append(float(np.corrcoef(ra, rb)[0, 1]))
171
+ pick, spread = int(np.argmax(cached)), full.max() - full.mean()
172
+ captures.append((full[pick] - full.mean()) / spread if spread > 1e-9 else 1.0)
173
+ report[tau] = {
174
+ "speedup": cf / float(np.mean(latency)),
175
+ "capture": float(np.mean(captures)),
176
+ "spearman": float(np.median(rhos)),
177
+ }
178
+
179
+ ok = [t for t in taus if report[t]["capture"] >= target_capture]
180
+ report["recommended"] = max(ok) if ok else min(taus)
181
+ return report
cachedsearch/caching.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training-free caching wrappers for video DiTs (Wan, CogVideoX, HunyuanVideo, LTX via diffusers).
2
+
3
+ Two modes:
4
+ - "adaptive": EasyCache-style online rule. Cache the transformation vector
5
+ delta = v(x_t) - x_t; while an accumulated change indicator stays below
6
+ tau, skip the transformer forward and return x_t + delta.
7
+ - "static": fixed skip pattern over a step range (PAB/uniform-style baseline).
8
+
9
+ CFG handling: diffusers WanPipeline calls the transformer once per branch per
10
+ step. We key cache state on call parity (branch index = call_count % branches)
11
+ so cond/uncond each get independent state.
12
+ """
13
+ from __future__ import annotations
14
+ import torch
15
+ from dataclasses import dataclass, field
16
+
17
+
18
+ @dataclass
19
+ class _BranchState:
20
+ prev_input: torch.Tensor | None = None
21
+ prev_output: torch.Tensor | None = None
22
+ delta: torch.Tensor | None = None
23
+ accum: float = 0.0
24
+ skips: int = 0
25
+ computes: int = 0
26
+ step: int = 0
27
+
28
+
29
+ @dataclass
30
+ class CacheConfig:
31
+ mode: str = "adaptive" # "adaptive" | "static" | "off"
32
+ tau: float = 0.10 # adaptive: accumulated-change tolerance
33
+ warmup_steps: int = 5 # always compute the first K steps
34
+ cooldown_steps: int = 5 # always compute the last K steps (needs total_steps)
35
+ total_steps: int = 50
36
+ static_keep_every: int = 2 # static: compute every k-th step in the middle
37
+ num_branches: int = 2 # CFG cond+uncond
38
+
39
+
40
+ class CachedTransformer(torch.nn.Module):
41
+ """Wraps a diffusers transformer; drop-in via `pipe.transformer = CachedTransformer(pipe.transformer, cfg)`."""
42
+
43
+ def __init__(self, transformer: torch.nn.Module, cfg: CacheConfig):
44
+ super().__init__()
45
+ self.t = transformer
46
+ self.cfg = cfg
47
+ self.dtype = transformer.dtype
48
+ self.config = transformer.config # pipeline reads .config
49
+ self._call = 0
50
+ self._states = [_BranchState() for _ in range(cfg.num_branches)]
51
+
52
+ # pipeline may call other attrs; delegate
53
+ def __getattr__(self, name):
54
+ try:
55
+ return super().__getattr__(name)
56
+ except AttributeError:
57
+ return getattr(self.t, name)
58
+
59
+ def reset(self):
60
+ self._call = 0
61
+ self._states = [_BranchState() for _ in range(self.cfg.num_branches)]
62
+
63
+ def stats(self):
64
+ return {
65
+ f"branch{i}": {"skips": s.skips, "computes": s.computes}
66
+ for i, s in enumerate(self._states)
67
+ }
68
+
69
+ def _should_compute(self, st: _BranchState, x: torch.Tensor) -> bool:
70
+ c = self.cfg
71
+ if c.mode == "off" or st.delta is None:
72
+ return True
73
+ if st.step < c.warmup_steps or st.step >= c.total_steps - c.cooldown_steps:
74
+ return True
75
+ if c.mode == "static":
76
+ return (st.step % c.static_keep_every) == 0
77
+ # adaptive: estimate output change rate from input change (EasyCache-style)
78
+ if st.prev_input is None:
79
+ return True
80
+ dx = (x - st.prev_input).float().norm() / max(st.prev_input.float().norm().item(), 1e-8)
81
+ st.accum += dx.item()
82
+ return st.accum > c.tau
83
+
84
+ def forward(self, hidden_states, *args, **kwargs):
85
+ st = self._states[self._call % self.cfg.num_branches]
86
+ self._call += 1
87
+ x = hidden_states
88
+
89
+ if self._should_compute(st, x):
90
+ out = self.t(hidden_states, *args, **kwargs)
91
+ sample = out[0] if isinstance(out, tuple) else out.sample
92
+ st.delta = (sample - x).detach()
93
+ st.prev_input = x.detach()
94
+ st.prev_output = sample.detach()
95
+ st.accum = 0.0
96
+ st.computes += 1
97
+ st.step += 1
98
+ return out
99
+ # skip: approximate v(x_t) ~= x_t + cached delta
100
+ st.skips += 1
101
+ st.step += 1
102
+ approx = x + st.delta
103
+ return (approx,)
104
+
105
+
106
+ class CacheHandle:
107
+ """Handle over every wrapped transformer of a pipeline (Wan 2.2 has two)."""
108
+
109
+ def __init__(self, wrapped: list[CachedTransformer]):
110
+ self.wrapped = wrapped
111
+
112
+ def reset(self):
113
+ for w in self.wrapped:
114
+ w.reset()
115
+
116
+ def stats(self):
117
+ return {f"transformer{i}": w.stats() for i, w in enumerate(self.wrapped)}
118
+
119
+ @property
120
+ def cfg(self) -> CacheConfig:
121
+ return self.wrapped[0].cfg
122
+
123
+
124
+ def wrap_pipeline(pipe, cfg: CacheConfig) -> CacheHandle:
125
+ """Attach caching to a diffusers video pipeline. Call .reset() between generations.
126
+
127
+ Wraps ``pipe.transformer`` and, when present, ``pipe.transformer_2`` (Wan 2.2).
128
+ Calling it again with a new config reconfigures the existing wrappers in place.
129
+ """
130
+ wrapped = []
131
+ for name in ("transformer", "transformer_2"):
132
+ model = getattr(pipe, name, None)
133
+ if model is None:
134
+ continue
135
+ if isinstance(model, CachedTransformer):
136
+ model.cfg = cfg
137
+ model.reset()
138
+ else:
139
+ model = CachedTransformer(model, cfg)
140
+ setattr(pipe, name, model)
141
+ wrapped.append(model)
142
+ if not wrapped:
143
+ raise AttributeError("The pipeline exposes no `transformer` to wrap.")
144
+ return CacheHandle(wrapped)
cachedsearch/verifiers.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verifiers: the scorer that ranks candidates.
2
+
3
+ CachedSearch is verifier-agnostic. Any callable ``(frames, prompt) -> float``
4
+ works, higher meaning better. If you do not pass one, we use ImageReward
5
+ averaged over uniformly spaced frames, which is the verifier all of the
6
+ paper's headline numbers use.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable
11
+
12
+ import numpy as np
13
+
14
+ Verifier = Callable[[object, str], float]
15
+
16
+ _DEFAULT = None
17
+
18
+
19
+ def imagereward_verifier(device: str = "cuda", num_frames: int = 8) -> Verifier:
20
+ """ImageReward averaged over `num_frames` uniformly spaced frames.
21
+
22
+ Requires the `image-reward` package; the model downloads on first use.
23
+ """
24
+ import ImageReward as RM
25
+ from PIL import Image
26
+
27
+ model = RM.load("ImageReward-v1.0", device=device)
28
+
29
+ def score(frames, prompt: str) -> float:
30
+ idx = np.linspace(0, len(frames) - 1, min(num_frames, len(frames))).astype(int)
31
+ vals = [
32
+ model.score(prompt, Image.fromarray(np.asarray(frames[i]).astype("uint8")))
33
+ for i in idx
34
+ ]
35
+ return float(np.mean(vals))
36
+
37
+ return score
38
+
39
+
40
+ def default_verifier(device: str = "cuda") -> Verifier:
41
+ """The default scorer, loaded once and reused across calls."""
42
+ global _DEFAULT
43
+ if _DEFAULT is None:
44
+ try:
45
+ _DEFAULT = imagereward_verifier(device=device)
46
+ except ImportError as exc: # keep the failure actionable
47
+ raise ImportError(
48
+ "No verifier was passed and the default (ImageReward) is not "
49
+ "installed. Either `pip install image-reward`, or pass your own "
50
+ "scorer: cached_search(pipe, prompt, verifier=my_scorer)."
51
+ ) from exc
52
+ return _DEFAULT
examples/calibrate_new_model.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibrate the caching threshold for a model we did not test.
2
+
3
+ python examples/calibrate_new_model.py --model <hf-id> --prompts data/calib25.txt
4
+
5
+ Costs about two GPU-hours for a small model (25 prompts x 8 seeds, twice).
6
+ Fidelity tracks architecture family, not parameter size, so calibrate once per
7
+ family and reuse the threshold across sizes within it.
8
+ """
9
+ import argparse
10
+
11
+ import torch
12
+ from diffusers import DiffusionPipeline
13
+
14
+ from cachedsearch import calibrate_tau
15
+
16
+
17
+ def main():
18
+ ap = argparse.ArgumentParser()
19
+ ap.add_argument("--model", required=True)
20
+ ap.add_argument("--prompts", required=True, help="text file, one prompt per line")
21
+ ap.add_argument("--num-prompts", type=int, default=25)
22
+ ap.add_argument("--taus", default="0.02,0.05,0.10,0.20")
23
+ ap.add_argument("--target-capture", type=float, default=0.90)
24
+ args = ap.parse_args()
25
+
26
+ prompts = [l.strip() for l in open(args.prompts) if l.strip()][: args.num_prompts]
27
+ pipe = DiffusionPipeline.from_pretrained(args.model, torch_dtype=torch.bfloat16).to("cuda")
28
+
29
+ report = calibrate_tau(
30
+ pipe,
31
+ prompts,
32
+ taus=[float(t) for t in args.taus.split(",")],
33
+ target_capture=args.target_capture,
34
+ )
35
+
36
+ print(f"{'tau':>6} {'speedup':>9} {'capture':>9} {'median rho':>11}")
37
+ for tau, row in report.items():
38
+ if tau == "recommended":
39
+ continue
40
+ print(f"{tau:>6} {row['speedup']:>8.2f}x {100*row['capture']:>8.1f}% {row['spearman']:>11.3f}")
41
+ print(f"\nrecommended tau for this model: {report['recommended']}")
42
+ print("Use it as: cached_search(pipe, prompt, verifier, tau=<value>)")
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
examples/run_wan.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end example: best-of-8 search on Wan2.1 for the price of about 5 rollouts.
2
+
3
+ python examples/run_wan.py --prompt "a red fox running through deep snow"
4
+
5
+ Swap the pipeline for CogVideoX, HunyuanVideo, or LTX-Video and the only thing
6
+ that changes is tau (run examples/calibrate_new_model.py once for a new family).
7
+ """
8
+ import argparse
9
+
10
+ import torch
11
+ from diffusers import AutoencoderKLWan, WanPipeline
12
+
13
+ from cachedsearch import cached_search, imagereward_verifier
14
+
15
+
16
+ def main():
17
+ ap = argparse.ArgumentParser()
18
+ ap.add_argument("--prompt", required=True)
19
+ ap.add_argument("--model", default="Wan-AI/Wan2.1-T2V-1.3B-Diffusers")
20
+ ap.add_argument("--n", type=int, default=8, help="search width")
21
+ ap.add_argument("--tau", type=float, default=0.10, help="caching threshold")
22
+ ap.add_argument("--mode", default="commit", choices=["commit", "keep"])
23
+ ap.add_argument("--out", default="delivered.mp4")
24
+ # Pass --verifier to swap in your own scorer; the default is ImageReward.
25
+ args = ap.parse_args()
26
+
27
+ vae = AutoencoderKLWan.from_pretrained(args.model, subfolder="vae", torch_dtype=torch.float32)
28
+ pipe = WanPipeline.from_pretrained(args.model, vae=vae, torch_dtype=torch.bfloat16).to("cuda")
29
+
30
+ result = cached_search(
31
+ pipe,
32
+ args.prompt,
33
+ n=args.n,
34
+ tau=args.tau,
35
+ mode=args.mode,
36
+ gen_kwargs=dict(height=480, width=832, num_frames=81, guidance_scale=5.0),
37
+ )
38
+
39
+ print(f"winner seed : {result.seed}")
40
+ print(f"draft scores : {[round(s, 3) for s in result.draft_scores]}")
41
+ print(f"explore / commit : {result.explore_seconds:.0f}s / {result.commit_seconds:.0f}s")
42
+ print(f"total : {result.total_seconds:.0f}s "
43
+ f"(full best-of-{args.n} would cost about "
44
+ f"{args.n * result.commit_seconds:.0f}s)")
45
+
46
+ from diffusers.utils import export_to_video
47
+ export_to_video(result.video, args.out, fps=16)
48
+ print(f"wrote {args.out}")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()