ComfyUI-HiCache v0.1.0 (beta): training-free Hunyuan3D acceleration node
One node, HiCacheAccelerate: patches the HY3DMODEL shape pipeline's DiT (kijai/ComfyUI-Hunyuan3DWrapper) to skip DiT forwards and forecast the flow-matching velocity via hicache-pp (HiCache Hermite / HiCache++ DMD / auto holdout selection). - zero comfy-internal imports: the patch targets the pipeline object the wrapper passes between nodes, so it covers hy3dgen (2.0), hy3dshape (2.1) and mini/turbo checkpoints alike - 35 CPU unit tests: standalone node-pack load contract, schedule adherence on a mock DiT with the wrapper's exact loop shape, bit-identity with driving hicache-pp directly, DMD exactness on exponential trajectories, run-boundary reset, unpatch, parameter validation - registry.comfy.org pyproject metadata (publish deferred until GPU-in- ComfyUI validation; repo ships as beta)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Krishi Attri
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,145 @@
|
||||
# ComfyUI-HiCache
|
||||
|
||||
**Training-free Hunyuan3D shape-generation acceleration for ComfyUI** — skip DiT
|
||||
forwards during flow-matching sampling and forecast the velocity instead, using
|
||||
the [`hicache-pp`](https://pypi.org/project/hicache-pp/) library
|
||||
([HiCache](https://arxiv.org/abs/2508.16984) Hermite-polynomial and HiCache++
|
||||
DMD/Prony-exponential forecasters).
|
||||
|
||||
> **Status: beta.** The forecasting math, schedule, and patch logic are
|
||||
> CPU-unit-tested against the library and against the wiring pattern of the
|
||||
> measured adapter repos (see *Validated on* below). End-to-end inside-ComfyUI
|
||||
> GPU validation is still pending — until then, treat the node as experimental
|
||||
> and A/B your own outputs.
|
||||
|
||||
## What it does
|
||||
|
||||
One node, `HiCache Accelerate (Hunyuan3D)`, wired between
|
||||
[kijai/ComfyUI-Hunyuan3DWrapper](https://github.com/kijai/ComfyUI-Hunyuan3DWrapper)'s
|
||||
`Hy3DModelLoader` and its mesh sampler (`Hy3DGenerateMesh` /
|
||||
`Hy3DGenerateMeshMultiView`):
|
||||
|
||||
```
|
||||
Hy3DModelLoader ──pipeline──▶ HiCache Accelerate ──pipeline──▶ Hy3DGenerateMesh
|
||||
```
|
||||
|
||||
The node patches `pipeline.model` (the shape DiT). On *compute* steps the DiT
|
||||
runs normally and its output is cached as a forecast anchor; on *skipped* steps
|
||||
the DiT is **not called at all** — the flow-matching velocity is forecast from
|
||||
the cached anchors. With `interval = N`, roughly `(N-1)/N` of the DiT forwards
|
||||
are skipped. The patch needs nothing from ComfyUI internals and works on any
|
||||
pipeline object whose denoise loop calls
|
||||
`self.model(latent_model_input, timestep, cond, ...)` once per step — which
|
||||
covers the wrapper's vendored `hy3dgen` (Hunyuan3D 2.0) and `hy3dshape`
|
||||
(Hunyuan3D 2.1) pipelines, including the mini/turbo checkpoints
|
||||
([wrapper issue #97](https://github.com/kijai/ComfyUI-Hunyuan3DWrapper/issues/97)
|
||||
— the patch is checkpoint-agnostic; only the loop shape matters).
|
||||
|
||||
### Methods
|
||||
|
||||
| method | basis | when to use |
|
||||
|---|---|---|
|
||||
| `hermite` | HiCache — dual-scaled physicist's Hermite polynomial ([arXiv:2508.16984](https://arxiv.org/abs/2508.16984)) | conservative intervals (i3) |
|
||||
| `dmd` *(default)* | HiCache++ — Dynamic Mode Decomposition / Prony exponential basis | larger intervals (i4–i5); degrades most gracefully |
|
||||
| `auto` | holdout-selected per compute step: serves DMD only when it demonstrably beats the polynomial on the cached window | when unsure |
|
||||
|
||||
### Inputs
|
||||
|
||||
| input | default | meaning |
|
||||
|---|---|---|
|
||||
| `pipeline` | — | `HY3DMODEL` from `Hy3DModelLoader` |
|
||||
| `method` | `dmd` | forecast basis (above) |
|
||||
| `interval` | 5 | compute 1 step, forecast `interval-1`. `1` disables caching |
|
||||
| `warmup_steps` | 2 | always compute the first N steps (floored at 1 — the first step has nothing to forecast from) |
|
||||
| `enable` | true | off = unpatch, restore the original DiT |
|
||||
| `max_order` | 1 | Hermite / finite-difference order |
|
||||
| `sigma` | 0.5 | Hermite contraction factor in (0,1) |
|
||||
| `dmd_history` | 5 | DMD snapshot window length |
|
||||
|
||||
Re-running the node with new parameters re-patches cleanly; `enable=false` (or
|
||||
`interval=1`) restores the original model. State resets automatically at each
|
||||
new sampling run.
|
||||
|
||||
## Measured numbers (from the adapter repos — not yet re-measured inside ComfyUI)
|
||||
|
||||
The same forecasters, wired into the upstream Hunyuan3D pipelines (outside
|
||||
ComfyUI), measured on Toys4K image-to-3D with geometry-preserving A/B
|
||||
(F-score@0.05 vs. the uncached baseline, solo re-timed speedups). Full tables
|
||||
and methodology:
|
||||
[hicache-plus-plus results](https://github.com/Archerkattri/hicache-plus-plus/blob/master/results/RESULTS.md).
|
||||
|
||||
**Hunyuan3D-2.1** (baseline F-score 0.911):
|
||||
|
||||
| interval | Hermite (HiCache) | DMD (HiCache++) | speedup |
|
||||
|---:|---:|---:|---:|
|
||||
| i3 | **0.876** | 0.852 | 1.72× |
|
||||
| i4 | 0.776 | **0.827** | 1.80× |
|
||||
| i5 | 0.735 | **0.860** | 1.79× |
|
||||
|
||||
**Hunyuan3D-2-mini** (baseline F-score 0.794, 1.89 s/gen): DMD i5 is **exactly
|
||||
lossless** (0.794) at 1.69 s; HiCache i3 0.792 at 1.58 s.
|
||||
|
||||
Source adapter repos (measured, runnable):
|
||||
[hunyuan2-plus-plus](https://github.com/Archerkattri/hunyuan2-plus-plus) (2.0 + mini),
|
||||
[hunyuan2.1-plus-plus](https://github.com/Archerkattri/hunyuan2.1-plus-plus) (2.1).
|
||||
|
||||
These numbers transfer to ComfyUI only insofar as the wrapper's vendored
|
||||
pipelines run the same denoise loop (they do, structurally — that is what the
|
||||
unit tests pin down), but **no inside-ComfyUI measurement has been done yet**.
|
||||
|
||||
## Install
|
||||
|
||||
**Via ComfyUI-Manager:** search for `ComfyUI-HiCache` (once indexed), or
|
||||
*Install via Git URL* → `https://github.com/Archerkattri/ComfyUI-HiCache`.
|
||||
|
||||
**Manual:**
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/Archerkattri/ComfyUI-HiCache
|
||||
pip install -r ComfyUI-HiCache/requirements.txt # just: hicache-pp
|
||||
```
|
||||
|
||||
Requires [ComfyUI-Hunyuan3DWrapper](https://github.com/kijai/ComfyUI-Hunyuan3DWrapper)
|
||||
to provide the `HY3DMODEL` pipeline the node accelerates.
|
||||
|
||||
## Validated on
|
||||
|
||||
What has actually been verified, and how:
|
||||
|
||||
* **CPU unit tests (35, in `tests/`)** — run `pytest` in this repo, no ComfyUI
|
||||
needed:
|
||||
* the node pack loads standalone exactly the way ComfyUI loads it
|
||||
(`NODE_CLASS_MAPPINGS`, `INPUT_TYPES` schema, zero `comfy` imports);
|
||||
* against a mock DiT with the wrapper's exact call signature, driven by a
|
||||
synthetic denoise loop replicating the wrapper's flow-matching loop:
|
||||
DiT forwards happen **only** on the scheduled compute steps, skipped steps
|
||||
are forecast-filled, and the patch's outputs are **bit-identical** to
|
||||
driving `hicache-pp`'s state machine directly (the adapter wiring pattern);
|
||||
* DMD forecasts are exact (rel. err < 1e-3) on exponential velocity
|
||||
trajectories — its solution class — once its snapshot window fills, and
|
||||
beat naive last-output reuse by >10× there;
|
||||
* run-boundary reset, re-patch/unpatch, attribute & `.to()` passthrough,
|
||||
and parameter validation.
|
||||
* **Adapter-repo GPU measurements** — the identical forecasters and schedule,
|
||||
measured in the upstream (non-ComfyUI) Hunyuan3D pipelines (tables above).
|
||||
|
||||
**Not yet validated:** running inside ComfyUI on GPU with the real wrapper and
|
||||
real checkpoints (including mini/turbo), and quality A/B of the resulting
|
||||
meshes through the full Comfy graph. Until that lands this repo stays 0.x/beta
|
||||
and is not submitted to the Comfy Registry.
|
||||
|
||||
### One honest design note
|
||||
|
||||
The adapter repos wire the forecast into the pipeline loop and cache the
|
||||
*CFG-combined* velocity; a model-level patch (the only non-invasive option for
|
||||
a ComfyUI node) caches the *pre-CFG stacked* DiT output instead. For the
|
||||
Hermite forecaster the two are mathematically identical (forecast and CFG
|
||||
combine are both linear); for DMD the fit runs on the stacked trajectory
|
||||
rather than the combined one — same dynamics, but not the literally identical
|
||||
computation, which is one more reason the GPU A/B is required before any
|
||||
lossless claim is repeated for this node.
|
||||
|
||||
## License
|
||||
|
||||
MIT. Not affiliated with Tencent (Hunyuan3D) or kijai (ComfyUI-Hunyuan3DWrapper).
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
"""ComfyUI-HiCache — training-free Hunyuan3D acceleration for ComfyUI.
|
||||
|
||||
Skips DiT forwards during flow-matching sampling and forecasts the velocity
|
||||
instead, using the HiCache (Hermite polynomial, arXiv:2508.16984) and
|
||||
HiCache++ (DMD / Prony exponential) forecasters from the ``hicache-pp``
|
||||
library.
|
||||
|
||||
Repository: https://github.com/Archerkattri/ComfyUI-HiCache
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
try:
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
except ImportError: # imported outside ComfyUI (tests / tooling), not as a package
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
WEB_DIRECTORY = None
|
||||
@@ -0,0 +1,235 @@
|
||||
"""HiCache / HiCache++ model-forward patch — pure Python, no ComfyUI imports.
|
||||
|
||||
This module contains all of the actual acceleration logic so it can be unit
|
||||
tested standalone (no ComfyUI install needed). ``nodes.py`` only does the
|
||||
ComfyUI plumbing around :func:`apply_hicache` / :func:`remove_hicache`.
|
||||
|
||||
How it works
|
||||
------------
|
||||
The Hunyuan3D shape pipelines (both the 2.0 ``hy3dgen`` and 2.1 ``hy3dshape``
|
||||
variants vendored by kijai/ComfyUI-Hunyuan3DWrapper, and Tencent's upstream
|
||||
``Hunyuan3DDiTFlowMatchingPipeline``) run a flow-matching denoise loop of the
|
||||
form::
|
||||
|
||||
for t in timesteps: # sigma goes 0 -> 1
|
||||
latent_model_input = cat([latents] * 2) # batched CFG
|
||||
noise_pred = self.model(latent_model_input, timestep, cond, ...)
|
||||
...chunk(2) + CFG combine + scheduler.step...
|
||||
|
||||
``self.model`` is a plain instance attribute (the DiT ``nn.Module``), so
|
||||
replacing ``pipeline.model`` with :class:`HiCacheModelPatch` intercepts every
|
||||
DiT forward. On *compute* steps the wrapped DiT runs normally and its raw
|
||||
output (the stacked cond+uncond velocity) is cached as a forecast anchor; on
|
||||
*skipped* steps the DiT is **not called at all** — the output is forecast from
|
||||
the cached anchors with ``hicache-pp``:
|
||||
|
||||
* ``hermite`` — HiCache (dual-scaled physicist's Hermite polynomial,
|
||||
arXiv:2508.16984).
|
||||
* ``dmd`` — HiCache++ (Dynamic Mode Decomposition / Prony exponential
|
||||
basis; exact on the feature-ODE solution class, so it stays lossless at
|
||||
larger skip intervals than the polynomial).
|
||||
* ``auto`` — holdout-selected per compute step: serve DMD only when it
|
||||
demonstrably beats the polynomial on the cached window.
|
||||
|
||||
Note on what is cached: the adapter repos (e.g. hunyuan2-plus-plus) wire the
|
||||
forecast into the pipeline loop and cache the *CFG-combined* velocity. A
|
||||
model-level patch necessarily caches the *pre-CFG stacked* output instead.
|
||||
For the Hermite forecaster the two are mathematically identical (the forecast
|
||||
is linear in the cached features, and CFG combination is linear). For DMD the
|
||||
fit is done on the stacked trajectory rather than the combined one — in
|
||||
practice the same dynamics; this is the standard trade-off of a model-level
|
||||
patch versus first-class pipeline wiring.
|
||||
|
||||
Run-boundary detection: these pipelines sample with monotonically *increasing*
|
||||
timesteps (``sigmas = linspace(0, 1, N)``; "we start from 0" in the upstream
|
||||
source). A timestep value lower than the previous call's therefore marks a new
|
||||
sampling run, and the forecast state is re-initialised. The total step count is
|
||||
not knowable from inside the model, so the schedule has no end-of-run
|
||||
always-compute window (``end_enhance``) — only the initial warmup window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from hicache_pp import (
|
||||
hicache_init,
|
||||
hicache_decide,
|
||||
hicache_update_derivatives,
|
||||
hicache_forecast,
|
||||
dmd_update_snapshots,
|
||||
dmd_forecast_state,
|
||||
auto_forecast_state,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-HiCache")
|
||||
|
||||
METHODS = ("hermite", "dmd", "auto")
|
||||
|
||||
# Sentinel for "unknown total step count": disables the end-of-run
|
||||
# always-compute window (any real run is far shorter than this).
|
||||
_NO_END_WINDOW = 1_000_000
|
||||
|
||||
|
||||
def validate_config(method: str, interval: int, warmup_steps: int,
|
||||
max_order: int, sigma: float, dmd_history: int) -> None:
|
||||
"""Raise ValueError on bad node parameters (mirrors hicache_pp's checks)."""
|
||||
if method not in METHODS:
|
||||
raise ValueError(f"method must be one of {METHODS}, got {method!r}")
|
||||
if interval < 1:
|
||||
raise ValueError(f"interval must be >= 1, got {interval}")
|
||||
if warmup_steps < 0:
|
||||
raise ValueError(f"warmup_steps must be >= 0, got {warmup_steps}")
|
||||
if max_order < 1:
|
||||
raise ValueError(f"max_order must be >= 1, got {max_order}")
|
||||
if not (0.0 < sigma < 1.0):
|
||||
raise ValueError(f"sigma must be in (0, 1), got {sigma}")
|
||||
if dmd_history < 3:
|
||||
raise ValueError(f"dmd_history must be >= 3, got {dmd_history}")
|
||||
|
||||
|
||||
class HiCacheModelPatch(torch.nn.Module):
|
||||
"""Drop-in replacement for ``pipeline.model`` that skips DiT forwards.
|
||||
|
||||
Wraps the original DiT module; forwards unknown attribute lookups to it so
|
||||
pipeline code like ``hasattr(self.model, 'guidance_embed')`` and
|
||||
``self.model.to(device)`` keep working (the inner model is a registered
|
||||
submodule, so ``.to()`` / ``.half()`` / state-dict access recurse into it).
|
||||
"""
|
||||
|
||||
def __init__(self, model: torch.nn.Module, *, method: str = "dmd",
|
||||
interval: int = 5, warmup_steps: int = 2, max_order: int = 1,
|
||||
sigma: float = 0.5, dmd_history: int = 5) -> None:
|
||||
validate_config(method, interval, warmup_steps, max_order, sigma, dmd_history)
|
||||
super().__init__()
|
||||
self.inner = model
|
||||
self._hicache_is_patch = True # marker for apply/remove
|
||||
self.method = method
|
||||
self.interval = int(interval)
|
||||
self.warmup_steps = int(warmup_steps)
|
||||
self.max_order = int(max_order)
|
||||
self.sigma = float(sigma)
|
||||
self.dmd_history = int(dmd_history)
|
||||
|
||||
self._state: Optional[Dict[str, Any]] = None
|
||||
self._last_t: Optional[float] = None
|
||||
# per-run stats (read by the node / logged at run boundaries)
|
||||
self.computed_steps = 0
|
||||
self.skipped_steps = 0
|
||||
|
||||
# -- attribute passthrough -------------------------------------------------
|
||||
def __getattr__(self, name: str):
|
||||
try:
|
||||
return super().__getattr__(name)
|
||||
except AttributeError:
|
||||
# fall through to the wrapped DiT (guidance_embed, config, dtype, ...)
|
||||
inner = super().__getattr__("inner")
|
||||
return getattr(inner, name)
|
||||
|
||||
# -- state handling ----------------------------------------------------------
|
||||
def _fresh_state(self) -> Dict[str, Any]:
|
||||
# hicache_pp validates interval/order/sigma/backend again here.
|
||||
return hicache_init(
|
||||
num_steps=_NO_END_WINDOW,
|
||||
interval=self.interval,
|
||||
max_order=self.max_order,
|
||||
# the very first step of a run must always compute (there is no
|
||||
# anchor to forecast from), so the effective warmup floor is 1
|
||||
first_enhance=max(1, self.warmup_steps),
|
||||
end_enhance=_NO_END_WINDOW,
|
||||
sigma=self.sigma,
|
||||
backend=self.method,
|
||||
history=self.dmd_history,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop all cached anchors and per-run stats (new sampling run)."""
|
||||
if self._state is not None and (self.computed_steps or self.skipped_steps):
|
||||
logger.info(
|
||||
"[HiCache] run finished: %d computed + %d skipped DiT steps "
|
||||
"(method=%s, interval=%d)",
|
||||
self.computed_steps, self.skipped_steps, self.method, self.interval,
|
||||
)
|
||||
self._state = self._fresh_state()
|
||||
self._last_t = None
|
||||
self.computed_steps = 0
|
||||
self.skipped_steps = 0
|
||||
|
||||
@staticmethod
|
||||
def _timestep_value(timestep: Any) -> float:
|
||||
if torch.is_tensor(timestep):
|
||||
return float(timestep.reshape(-1)[0].item())
|
||||
return float(timestep)
|
||||
|
||||
def _forecast(self, state: Dict[str, Any]) -> torch.Tensor:
|
||||
if self.method == "dmd":
|
||||
return dmd_forecast_state(state)
|
||||
if self.method == "auto":
|
||||
return auto_forecast_state(state)
|
||||
return hicache_forecast(state)
|
||||
|
||||
# -- the patched forward ------------------------------------------------------
|
||||
def forward(self, latent_model_input: torch.Tensor, timestep: Any,
|
||||
*args: Any, **kwargs: Any) -> torch.Tensor:
|
||||
t_val = self._timestep_value(timestep)
|
||||
# New sampling run: timesteps in these pipelines increase monotonically
|
||||
# (sigma 0 -> 1), so a drop in t means the loop restarted.
|
||||
if self._state is None or self._last_t is None or t_val < self._last_t:
|
||||
self.reset()
|
||||
self._last_t = t_val
|
||||
|
||||
state = self._state
|
||||
if hicache_decide(state) == "forecast":
|
||||
out = self._forecast(state)
|
||||
state["step"] += 1
|
||||
self.skipped_steps += 1
|
||||
return out
|
||||
|
||||
out = self.inner(latent_model_input, timestep, *args, **kwargs)
|
||||
anchor = out.detach()
|
||||
hicache_update_derivatives(state, anchor)
|
||||
if self.method in ("dmd", "auto"):
|
||||
dmd_update_snapshots(state, anchor, state["history"])
|
||||
state["step"] += 1
|
||||
self.computed_steps += 1
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply / remove on a pipeline object
|
||||
# ---------------------------------------------------------------------------
|
||||
def apply_hicache(pipeline: Any, *, method: str = "dmd", interval: int = 5,
|
||||
warmup_steps: int = 2, max_order: int = 1, sigma: float = 0.5,
|
||||
dmd_history: int = 5) -> Any:
|
||||
"""Patch ``pipeline.model`` with a :class:`HiCacheModelPatch` (idempotent).
|
||||
|
||||
If the pipeline is already patched the old patch is removed first, so
|
||||
re-running the node with new parameters reconfigures cleanly.
|
||||
"""
|
||||
if not hasattr(pipeline, "model"):
|
||||
raise TypeError(
|
||||
"HiCache: pipeline has no `.model` attribute - expected a Hunyuan3D "
|
||||
f"shape pipeline, got {type(pipeline).__name__}"
|
||||
)
|
||||
remove_hicache(pipeline)
|
||||
patch = HiCacheModelPatch(
|
||||
pipeline.model, method=method, interval=interval, warmup_steps=warmup_steps,
|
||||
max_order=max_order, sigma=sigma, dmd_history=dmd_history,
|
||||
)
|
||||
pipeline.model = patch
|
||||
logger.info(
|
||||
"[HiCache] patched %s.model: method=%s interval=%d warmup=%d",
|
||||
type(pipeline).__name__, method, interval, warmup_steps,
|
||||
)
|
||||
return pipeline
|
||||
|
||||
|
||||
def remove_hicache(pipeline: Any) -> Any:
|
||||
"""Restore the original DiT on ``pipeline.model`` (no-op if not patched)."""
|
||||
model = getattr(pipeline, "model", None)
|
||||
if model is not None and getattr(model, "_hicache_is_patch", False):
|
||||
pipeline.model = model.inner
|
||||
logger.info("[HiCache] removed patch from %s.model", type(pipeline).__name__)
|
||||
return pipeline
|
||||
@@ -0,0 +1,102 @@
|
||||
"""ComfyUI-HiCache node definitions.
|
||||
|
||||
One node, no ComfyUI-internal imports: the patch operates directly on the
|
||||
pipeline object that kijai/ComfyUI-Hunyuan3DWrapper passes between nodes as
|
||||
the custom ``HY3DMODEL`` type, so this file imports cleanly outside ComfyUI
|
||||
too (which is how the unit tests run it).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from .hicache_patch import METHODS, apply_hicache, remove_hicache
|
||||
except ImportError: # imported outside ComfyUI (tests / tooling), not as a package
|
||||
from hicache_patch import METHODS, apply_hicache, remove_hicache
|
||||
|
||||
|
||||
class HiCacheAccelerate:
|
||||
"""Training-free Hunyuan3D shape-DiT acceleration via velocity forecasting.
|
||||
|
||||
Wire between the Hunyuan3DWrapper model loader (``Hy3DModelLoader``) and
|
||||
the mesh sampler (``Hy3DGenerateMesh`` / ``Hy3DGenerateMeshMultiView``).
|
||||
On skipped sampling steps the DiT forward is replaced by a cheap forecast
|
||||
from cached velocities (HiCache Hermite / HiCache++ DMD), cutting the
|
||||
diffusion-sampling wall clock by roughly ``(interval-1)/interval`` of the
|
||||
DiT time.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"pipeline": ("HY3DMODEL", {
|
||||
"tooltip": "Hunyuan3D shape pipeline from Hy3DModelLoader "
|
||||
"(kijai/ComfyUI-Hunyuan3DWrapper)."}),
|
||||
"method": (list(METHODS), {
|
||||
"default": "dmd",
|
||||
"tooltip": "Forecast basis on skipped steps:\n"
|
||||
"hermite = HiCache (polynomial, arXiv:2508.16984)\n"
|
||||
"dmd = HiCache++ (exponential / Prony-DMD; degrades "
|
||||
"most gracefully at large intervals)\n"
|
||||
"auto = holdout-pick the better of the two per "
|
||||
"compute step"}),
|
||||
"interval": ("INT", {
|
||||
"default": 5, "min": 1, "max": 12, "step": 1,
|
||||
"tooltip": "Compute one DiT step, then forecast interval-1 "
|
||||
"steps. 1 = caching disabled (every step computed). "
|
||||
"Measured sweet spots: hermite i3, dmd i4-i5."}),
|
||||
"warmup_steps": ("INT", {
|
||||
"default": 2, "min": 0, "max": 100, "step": 1,
|
||||
"tooltip": "Always compute the first N sampling steps before "
|
||||
"any forecasting starts."}),
|
||||
},
|
||||
"optional": {
|
||||
"enable": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Off = remove the patch and restore the original "
|
||||
"DiT forward."}),
|
||||
"max_order": ("INT", {
|
||||
"default": 1, "min": 1, "max": 4, "step": 1,
|
||||
"tooltip": "Highest Hermite / finite-difference order "
|
||||
"(hermite method and warm-up fallback)."}),
|
||||
"sigma": ("FLOAT", {
|
||||
"default": 0.5, "min": 0.05, "max": 0.95, "step": 0.05,
|
||||
"tooltip": "Hermite contraction factor in (0,1); keeps "
|
||||
"high-order terms bounded."}),
|
||||
"dmd_history": ("INT", {
|
||||
"default": 5, "min": 3, "max": 16, "step": 1,
|
||||
"tooltip": "DMD snapshot window length (dmd/auto methods)."}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("HY3DMODEL",)
|
||||
RETURN_NAMES = ("pipeline",)
|
||||
FUNCTION = "patch"
|
||||
CATEGORY = "HiCache"
|
||||
DESCRIPTION = (
|
||||
"Training-free acceleration of Hunyuan3D shape generation: skip DiT "
|
||||
"forwards and forecast the flow-matching velocity instead (HiCache "
|
||||
"Hermite / HiCache++ DMD, via the hicache-pp library)."
|
||||
)
|
||||
|
||||
def patch(self, pipeline, method="dmd", interval=5, warmup_steps=2,
|
||||
enable=True, max_order=1, sigma=0.5, dmd_history=5):
|
||||
if not enable or interval <= 1:
|
||||
return (remove_hicache(pipeline),)
|
||||
return (apply_hicache(
|
||||
pipeline,
|
||||
method=method,
|
||||
interval=interval,
|
||||
warmup_steps=warmup_steps,
|
||||
max_order=max_order,
|
||||
sigma=sigma,
|
||||
dmd_history=dmd_history,
|
||||
),)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"HiCacheAccelerate": HiCacheAccelerate,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"HiCacheAccelerate": "HiCache Accelerate (Hunyuan3D)",
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Packaging metadata for the registry.comfy.org Comfy Registry.
|
||||
# Field layout follows https://docs.comfy.org/registry/specifications
|
||||
# (and the kijai/ComfyUI-Hunyuan3DWrapper precedent).
|
||||
|
||||
[project]
|
||||
name = "comfyui-hicache"
|
||||
description = "Training-free Hunyuan3D shape-generation acceleration: skip DiT steps and forecast the flow-matching velocity instead (HiCache Hermite / HiCache++ DMD, via hicache-pp). Pairs with kijai/ComfyUI-Hunyuan3DWrapper."
|
||||
version = "0.1.0"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["hicache-pp>=1.1.0"]
|
||||
classifiers = ["Development Status :: 4 - Beta"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/Archerkattri/ComfyUI-HiCache"
|
||||
BugTracker = "https://github.com/Archerkattri/ComfyUI-HiCache/issues"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "archerkattri"
|
||||
DisplayName = "ComfyUI-HiCache"
|
||||
Icon = ""
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--import-mode=importlib"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,2 @@
|
||||
# torch and ComfyUI are assumed pre-installed (this is a ComfyUI node pack).
|
||||
hicache-pp>=1.1.0
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Node-pack registration tests — run standalone, no ComfyUI install.
|
||||
|
||||
ComfyUI loads a custom node pack by importing the package and reading
|
||||
``NODE_CLASS_MAPPINGS`` / ``NODE_DISPLAY_NAME_MAPPINGS``; these tests verify
|
||||
that contract plus the INPUT_TYPES schema shape ComfyUI expects.
|
||||
"""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
PACK_DIR = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_pack():
|
||||
"""Import the node pack the way ComfyUI does (as a package by path)."""
|
||||
name = "comfyui_hicache_under_test"
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, PACK_DIR / "__init__.py",
|
||||
submodule_search_locations=[str(PACK_DIR)],
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_pack_imports_without_comfy():
|
||||
assert "comfy" not in sys.modules, "test must run without ComfyUI"
|
||||
pack = _load_pack()
|
||||
assert hasattr(pack, "NODE_CLASS_MAPPINGS")
|
||||
assert hasattr(pack, "NODE_DISPLAY_NAME_MAPPINGS")
|
||||
assert "comfy" not in sys.modules, "node pack must not import comfy internals"
|
||||
|
||||
|
||||
def test_node_mappings_consistent():
|
||||
pack = _load_pack()
|
||||
assert "HiCacheAccelerate" in pack.NODE_CLASS_MAPPINGS
|
||||
for key in pack.NODE_CLASS_MAPPINGS:
|
||||
assert key in pack.NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
|
||||
def test_input_types_schema():
|
||||
pack = _load_pack()
|
||||
cls = pack.NODE_CLASS_MAPPINGS["HiCacheAccelerate"]
|
||||
schema = cls.INPUT_TYPES()
|
||||
req = schema["required"]
|
||||
|
||||
# model input + the contract from the task spec: method/interval/warmup
|
||||
assert req["pipeline"][0] == "HY3DMODEL"
|
||||
assert set(req["method"][0]) == {"hermite", "dmd", "auto"}
|
||||
assert req["method"][1]["default"] in req["method"][0]
|
||||
assert req["interval"][0] == "INT"
|
||||
assert req["interval"][1]["min"] >= 1
|
||||
assert req["warmup_steps"][0] == "INT"
|
||||
assert req["warmup_steps"][1]["min"] == 0
|
||||
assert "enable" in schema["optional"]
|
||||
|
||||
# ComfyUI node contract
|
||||
assert cls.RETURN_TYPES == ("HY3DMODEL",)
|
||||
assert callable(getattr(cls, cls.FUNCTION))
|
||||
assert isinstance(cls.CATEGORY, str)
|
||||
|
||||
|
||||
def test_node_function_patches_and_unpatches():
|
||||
import torch
|
||||
|
||||
pack = _load_pack()
|
||||
node = pack.NODE_CLASS_MAPPINGS["HiCacheAccelerate"]()
|
||||
|
||||
class FakePipe:
|
||||
model = torch.nn.Linear(2, 2)
|
||||
|
||||
pipe = FakePipe()
|
||||
original = pipe.model
|
||||
|
||||
(out,) = node.patch(pipe, method="hermite", interval=4, warmup_steps=2)
|
||||
assert out is pipe
|
||||
assert getattr(pipe.model, "_hicache_is_patch", False)
|
||||
assert pipe.model.inner is original
|
||||
|
||||
# re-patch with new params replaces (not nests) the patch
|
||||
(out,) = node.patch(pipe, method="dmd", interval=3, warmup_steps=1)
|
||||
assert pipe.model.inner is original
|
||||
assert pipe.model.method == "dmd" and pipe.model.interval == 3
|
||||
|
||||
# enable=False restores the original model
|
||||
(out,) = node.patch(pipe, enable=False)
|
||||
assert pipe.model is original
|
||||
|
||||
# interval=1 means "no caching" -> also unpatched
|
||||
node.patch(pipe, interval=4)
|
||||
(out,) = node.patch(pipe, interval=1)
|
||||
assert pipe.model is original
|
||||
|
||||
|
||||
def test_node_rejects_bad_pipeline():
|
||||
pack = _load_pack()
|
||||
node = pack.NODE_CLASS_MAPPINGS["HiCacheAccelerate"]()
|
||||
with pytest.raises(TypeError, match="no `.model` attribute"):
|
||||
node.patch(object())
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Patch-logic tests on a mock DiT + a synthetic denoise loop (CPU only).
|
||||
|
||||
The mock pipeline reproduces the exact call shape of the Hunyuan3D wrapper's
|
||||
flow-matching loop (kijai/ComfyUI-Hunyuan3DWrapper, hy3dgen/shapegen and
|
||||
hy3dshape pipelines):
|
||||
|
||||
latent_model_input = cat([latents] * 2) # batched CFG
|
||||
timestep = t.expand(2B) / num_train_timesteps # increasing, starts at 0
|
||||
noise_pred = self.model(latent_model_input, timestep, cond, guidance=None)
|
||||
cond_pred, uncond_pred = noise_pred.chunk(2)
|
||||
noise_pred = uncond_pred + scale * (cond_pred - uncond_pred)
|
||||
|
||||
These tests assert that with the HiCacheModelPatch installed:
|
||||
* the inner DiT is called exactly on the scheduled compute steps,
|
||||
* skipped steps are filled by the forecaster (finite, accurate on smooth
|
||||
velocity trajectories),
|
||||
* the per-method forecast path (hermite / dmd / auto) is actually exercised,
|
||||
* state resets across sampling runs,
|
||||
* parameter plumbing and validation behave.
|
||||
"""
|
||||
import importlib.util
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
PACK_DIR = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load(modname):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hicache_patch_under_test_{modname}", PACK_DIR / f"{modname}.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
hp = _load("hicache_patch")
|
||||
|
||||
|
||||
class MockDiT(torch.nn.Module):
|
||||
"""Stands in for the Hunyuan3D DiT: same call signature as the wrapper's
|
||||
``self.model(latent_model_input, timestep, cond, guidance=...)``; returns a
|
||||
smooth, timestep-dependent velocity field and counts real forwards."""
|
||||
|
||||
guidance_embed = False # the pipelines hasattr/check this on .model
|
||||
|
||||
def __init__(self, shape=(2, 8, 4)):
|
||||
super().__init__()
|
||||
self.shape = shape
|
||||
self.calls = 0
|
||||
self.call_timesteps = []
|
||||
base = torch.randn(shape)
|
||||
self.register_buffer("base", base)
|
||||
|
||||
def forward(self, latent_model_input, timestep, cond, guidance=None):
|
||||
self.calls += 1
|
||||
t = float(timestep.reshape(-1)[0])
|
||||
self.call_timesteps.append(t)
|
||||
# smooth damped-oscillatory velocity trajectory (the feature-ODE class)
|
||||
return self.base * math.exp(-1.5 * t) * math.cos(3.0 * t) + 0.3 * t
|
||||
|
||||
|
||||
class MockPipeline:
|
||||
"""Minimal stand-in for Hunyuan3DDiTFlowMatchingPipeline's denoise loop."""
|
||||
|
||||
num_train_timesteps = 1000
|
||||
|
||||
def __init__(self, model):
|
||||
self.model = model
|
||||
|
||||
def __call__(self, num_inference_steps=30, guidance_scale=5.5):
|
||||
sigmas = torch.linspace(0, 1, num_inference_steps)
|
||||
timesteps = sigmas * self.num_train_timesteps # increasing, starts at 0
|
||||
latents = torch.randn(1, 8, 4)
|
||||
outputs = []
|
||||
for t in timesteps:
|
||||
latent_model_input = torch.cat([latents] * 2)
|
||||
timestep = t.expand(latent_model_input.shape[0]) / self.num_train_timesteps
|
||||
noise_pred = self.model(latent_model_input, timestep, cond=None, guidance=None)
|
||||
cond_pred, uncond_pred = noise_pred.chunk(2)
|
||||
noise_pred = uncond_pred + guidance_scale * (cond_pred - uncond_pred)
|
||||
latents = latents - 0.01 * noise_pred # toy scheduler step
|
||||
outputs.append(noise_pred.clone())
|
||||
return latents, outputs
|
||||
|
||||
|
||||
def expected_compute_steps(num_steps, interval, warmup):
|
||||
"""Reference schedule: hicache_pp computes warmup steps, then 1 of every
|
||||
`interval` (counter-based: a compute step resets the skip counter). The
|
||||
patch floors warmup at 1 — the first step of a run has nothing to forecast
|
||||
from."""
|
||||
warmup = max(1, warmup)
|
||||
computes, counter = [], 0
|
||||
for s in range(num_steps):
|
||||
if s < warmup or counter >= interval - 1:
|
||||
computes.append(s)
|
||||
counter = 0
|
||||
else:
|
||||
counter += 1
|
||||
return computes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# schedule / skip behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("method", ["hermite", "dmd", "auto"])
|
||||
@pytest.mark.parametrize("interval,warmup", [(3, 2), (5, 2), (4, 0)])
|
||||
def test_compute_calls_follow_schedule(method, interval, warmup):
|
||||
torch.manual_seed(0)
|
||||
steps = 30
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method=method, interval=interval, warmup_steps=warmup)
|
||||
|
||||
pipe(num_inference_steps=steps)
|
||||
|
||||
expected = expected_compute_steps(steps, interval, warmup)
|
||||
assert dit.calls == len(expected), (
|
||||
f"DiT ran {dit.calls} times, schedule says {len(expected)}")
|
||||
assert pipe.model.computed_steps == len(expected)
|
||||
assert pipe.model.skipped_steps == steps - len(expected)
|
||||
# the real forwards happened exactly at the scheduled timesteps
|
||||
sigmas = torch.linspace(0, 1, steps)
|
||||
assert dit.call_timesteps == pytest.approx([float(sigmas[i]) for i in expected])
|
||||
|
||||
|
||||
class ConstantDiT(MockDiT):
|
||||
"""Constant velocity — every forecast basis is exact on this class
|
||||
(all finite differences vanish), independent of sign/scale conventions."""
|
||||
|
||||
def forward(self, latent_model_input, timestep, cond, guidance=None):
|
||||
self.calls += 1
|
||||
t = float(timestep.reshape(-1)[0])
|
||||
self.call_timesteps.append(t)
|
||||
return self.base.clone()
|
||||
|
||||
|
||||
class ExpDiT(MockDiT):
|
||||
"""Velocity exponential in the timestep: base * exp(-a*t) — the DMD/Prony
|
||||
solution class, on which the DMD forecast is exact once its uniform
|
||||
snapshot window (4) has filled."""
|
||||
|
||||
def forward(self, latent_model_input, timestep, cond, guidance=None):
|
||||
self.calls += 1
|
||||
t = float(timestep.reshape(-1)[0])
|
||||
self.call_timesteps.append(t)
|
||||
return self.base * math.exp(-2.0 * t)
|
||||
|
||||
|
||||
def _run_pair(ref_dit, acc_dit, method, steps=30, interval=4, warmup=2, **kw):
|
||||
acc_dit.load_state_dict(ref_dit.state_dict())
|
||||
torch.manual_seed(1)
|
||||
_, ref_outputs = MockPipeline(ref_dit)(num_inference_steps=steps)
|
||||
pipe = MockPipeline(acc_dit)
|
||||
hp.apply_hicache(pipe, method=method, interval=interval, warmup_steps=warmup, **kw)
|
||||
torch.manual_seed(1)
|
||||
_, acc_outputs = pipe(num_inference_steps=steps)
|
||||
computes = expected_compute_steps(steps, interval, warmup)
|
||||
skipped = [s for s in range(steps) if s not in computes]
|
||||
assert skipped, "test must exercise skipped steps"
|
||||
return ref_outputs, acc_outputs, computes, skipped
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["hermite", "dmd", "auto"])
|
||||
def test_forecast_exact_on_constant_trajectory(method):
|
||||
"""Convention-free exactness check: on a constant velocity every skipped
|
||||
step must reproduce the real DiT output bit-for-bit (all finite
|
||||
differences vanish; the cached anchor is the exact forecast)."""
|
||||
torch.manual_seed(0)
|
||||
ref_outputs, acc_outputs, _, skipped = _run_pair(
|
||||
ConstantDiT(), ConstantDiT(), method)
|
||||
for s in skipped:
|
||||
assert torch.allclose(acc_outputs[s], ref_outputs[s]), \
|
||||
f"step {s}: forecast not exact on constant series"
|
||||
|
||||
|
||||
def test_patch_reproduces_hicache_pp_forecast_bitwise():
|
||||
"""The node's claim is that it faithfully ports the hunyuan2-plus-plus
|
||||
pipeline wiring of hicache-pp. Replay the patched run's compute anchors
|
||||
through hicache_pp's own state machine and check that the patch served
|
||||
the library's forecast bit-for-bit on every skipped step."""
|
||||
from hicache_pp import (hicache_init, hicache_decide,
|
||||
hicache_update_derivatives, hicache_forecast)
|
||||
|
||||
torch.manual_seed(0)
|
||||
steps, interval, warmup = 30, 4, 2
|
||||
dit_a = MockDiT()
|
||||
dit_b = MockDiT()
|
||||
dit_b.load_state_dict(dit_a.state_dict())
|
||||
|
||||
pipe = MockPipeline(dit_a)
|
||||
hp.apply_hicache(pipe, method="hermite", interval=interval, warmup_steps=warmup)
|
||||
torch.manual_seed(1)
|
||||
_, acc_outputs = pipe(num_inference_steps=steps)
|
||||
|
||||
# reference: drive hicache_pp directly, exactly like the adapter's loop
|
||||
state = hicache_init(num_steps=steps, interval=interval, max_order=1,
|
||||
first_enhance=warmup, end_enhance=steps + 1, sigma=0.5)
|
||||
sigmas = torch.linspace(0, 1, steps)
|
||||
latents = torch.randn(1, 8, 4) # same shapes; model output ignores latents
|
||||
for s in range(steps):
|
||||
if hicache_decide(state) == "forecast":
|
||||
ref_raw = hicache_forecast(state)
|
||||
else:
|
||||
lmi = torch.cat([latents] * 2)
|
||||
timestep = sigmas[s].expand(lmi.shape[0])
|
||||
ref_raw = dit_b(lmi, timestep, cond=None)
|
||||
hicache_update_derivatives(state, ref_raw.detach())
|
||||
state["step"] += 1
|
||||
# same CFG combine the pipeline loop applies after the model call
|
||||
cond_pred, uncond_pred = ref_raw.chunk(2)
|
||||
ref = uncond_pred + 5.5 * (cond_pred - uncond_pred)
|
||||
assert torch.equal(acc_outputs[s], ref), \
|
||||
f"step {s}: patch output diverges from direct hicache_pp wiring"
|
||||
|
||||
|
||||
def test_dmd_forecast_exact_on_exponential_trajectory():
|
||||
"""DMD is exact on its solution class (sums of exponentials) once 4
|
||||
uniformly spaced snapshots exist; before that it falls back to Hermite
|
||||
(which only needs to stay finite here)."""
|
||||
torch.manual_seed(0)
|
||||
steps, interval, warmup = 30, 4, 2
|
||||
ref_outputs, acc_outputs, computes, skipped = _run_pair(
|
||||
ExpDiT(), ExpDiT(), "dmd", steps=steps, interval=interval, warmup=warmup)
|
||||
# 4th uniformly spaced compute step (spacing=interval) closes the window
|
||||
uniform = [c for c in computes if c >= warmup]
|
||||
window_full_at = uniform[3]
|
||||
checked = 0
|
||||
for s in skipped:
|
||||
assert torch.isfinite(acc_outputs[s]).all()
|
||||
if s > window_full_at:
|
||||
rel = float((acc_outputs[s] - ref_outputs[s]).norm()
|
||||
/ ref_outputs[s].norm().clamp_min(1e-9))
|
||||
assert rel < 1e-3, f"step {s}: DMD not exact on exponential ({rel})"
|
||||
checked += 1
|
||||
assert checked >= 6, "too few post-window skipped steps exercised"
|
||||
|
||||
|
||||
def test_skipped_outputs_finite_and_better_than_naive_reuse():
|
||||
"""On a smooth exponential trajectory the DMD forecast must be finite on
|
||||
every skipped step and, in aggregate, beat the naive 'reuse the last
|
||||
computed output' cache (what plain output caching would do)."""
|
||||
torch.manual_seed(0)
|
||||
steps, interval, warmup = 30, 4, 2
|
||||
ref_outputs, acc_outputs, computes, skipped = _run_pair(
|
||||
ExpDiT(), ExpDiT(), "dmd", steps=steps, interval=interval, warmup=warmup)
|
||||
|
||||
# every skipped step must be forecast-filled with a finite tensor
|
||||
for s in skipped:
|
||||
assert torch.isfinite(acc_outputs[s]).all()
|
||||
|
||||
# once the DMD snapshot window has filled (4 uniformly spaced computes),
|
||||
# the forecast must beat the naive 'reuse last computed output' cache
|
||||
window_full_at = [c for c in computes if c >= warmup][3]
|
||||
forecast_err, reuse_err = 0.0, 0.0
|
||||
for s in (s for s in skipped if s > window_full_at):
|
||||
last_compute = max(c for c in computes if c < s)
|
||||
forecast_err += float((acc_outputs[s] - ref_outputs[s]).norm())
|
||||
reuse_err += float((ref_outputs[last_compute] - ref_outputs[s]).norm())
|
||||
assert reuse_err > 0
|
||||
assert forecast_err < 0.1 * reuse_err, (
|
||||
f"forecast ({forecast_err:.4f}) must beat naive reuse ({reuse_err:.4f})")
|
||||
|
||||
# compute steps are bit-identical to the reference model output
|
||||
for s in computes:
|
||||
assert torch.allclose(acc_outputs[s], ref_outputs[s])
|
||||
|
||||
|
||||
def test_interval_1_or_warmup_dominates_means_no_skips():
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method="hermite", interval=2, warmup_steps=100)
|
||||
pipe(num_inference_steps=20)
|
||||
assert dit.calls == 20 and pipe.model.skipped_steps == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# method plumbing: the right forecaster actually runs
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_dmd_method_populates_snapshots_and_forecasts():
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2, dmd_history=5)
|
||||
pipe(num_inference_steps=40)
|
||||
st = pipe.model._state
|
||||
assert st["backend"] == "dmd"
|
||||
assert len(st["dmd_snapshots"]) >= 4, "DMD snapshot window never filled"
|
||||
assert pipe.model.skipped_steps > 0
|
||||
|
||||
|
||||
def test_hermite_method_keeps_snapshots_empty():
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method="hermite", interval=4, warmup_steps=2)
|
||||
pipe(num_inference_steps=40)
|
||||
st = pipe.model._state
|
||||
assert st["backend"] == "hermite"
|
||||
assert st["dmd_snapshots"] == []
|
||||
assert st["derivatives"], "Hermite anchors missing"
|
||||
|
||||
|
||||
def test_auto_method_makes_a_holdout_choice():
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method="auto", interval=3, warmup_steps=2, dmd_history=8)
|
||||
pipe(num_inference_steps=40)
|
||||
st = pipe.model._state
|
||||
assert st["backend"] == "auto"
|
||||
# auto caches its per-compute-step holdout selection once enough snapshots exist
|
||||
assert st.get("_auto_choice") in ("dmd", "hermite")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run-boundary reset + passthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_state_resets_between_sampling_runs():
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2)
|
||||
|
||||
pipe(num_inference_steps=20)
|
||||
first_run_calls = dit.calls
|
||||
pipe(num_inference_steps=20) # timestep drops back to 0 -> reset
|
||||
|
||||
expected = len(expected_compute_steps(20, 4, 2))
|
||||
assert first_run_calls == expected
|
||||
assert dit.calls == 2 * expected, "second run must re-warm, not inherit state"
|
||||
assert pipe.model.computed_steps == expected # stats are per-run
|
||||
|
||||
|
||||
def test_attribute_and_device_passthrough():
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method="hermite", interval=4, warmup_steps=2)
|
||||
# pipelines check hasattr(self.model, 'guidance_embed') and call .to()
|
||||
assert pipe.model.guidance_embed is False
|
||||
pipe.model.to(torch.float32)
|
||||
assert pipe.model.inner is dit
|
||||
|
||||
|
||||
def test_remove_restores_original_model():
|
||||
dit = MockDiT()
|
||||
pipe = MockPipeline(dit)
|
||||
hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2)
|
||||
assert pipe.model is not dit
|
||||
hp.remove_hicache(pipe)
|
||||
assert pipe.model is dit
|
||||
hp.remove_hicache(pipe) # idempotent
|
||||
assert pipe.model is dit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parameter validation
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("kwargs,match", [
|
||||
(dict(method="taylor"), "method"),
|
||||
(dict(interval=0), "interval"),
|
||||
(dict(warmup_steps=-1), "warmup_steps"),
|
||||
(dict(max_order=0), "max_order"),
|
||||
(dict(sigma=1.0), "sigma"),
|
||||
(dict(sigma=0.0), "sigma"),
|
||||
(dict(dmd_history=2), "dmd_history"),
|
||||
])
|
||||
def test_bad_params_rejected(kwargs, match):
|
||||
base = dict(method="dmd", interval=4, warmup_steps=2,
|
||||
max_order=1, sigma=0.5, dmd_history=5)
|
||||
base.update(kwargs)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
hp.validate_config(**base)
|
||||
|
||||
|
||||
def test_apply_requires_model_attribute():
|
||||
with pytest.raises(TypeError, match="no `.model` attribute"):
|
||||
hp.apply_hicache(object(), method="dmd")
|
||||
Reference in New Issue
Block a user