v0.2.0: GPU-validated inside ComfyUI; copy-on-patch cache safety; single-step reset fix

End-to-end validation on RTX 5090 (ComfyUI 0.24.0, Hunyuan3DWrapper master,
Hunyuan3D-2mini fp16, 30-step image-to-mesh workflow via the ComfyUI API):

- patch engages exactly on schedule (hermite i3: 11 computed + 19 skipped;
  dmd i5: 7 + 23), sampling 16.1 -> 43.1 it/s (2.68x) for hermite i3
- quality A/B same seed vs unaccelerated mesh: hermite i3 F1@0.05 0.825
  (different-seed floor 0.751); dmd i5 0.719, at the floor on mini, so the
  upstream DMD lossless claim is not repeated for this node
- repeat runs in one session reset per-run state correctly

Two bugs found by the validation, both fixed with regression tests:

1. ComfyUI cache aliasing: the node mutated and returned the same pipeline
   object, so a cached node output could silently run a different config
   after an intervening run re-patched the shared pipeline (observed live:
   a cached hermite-i3 output ran dmd-i5). apply/remove now return a
   shallow copy that owns its patch; the input pipeline is never mutated.
2. Run-boundary reset missed back-to-back single-step runs (t=0 then t=0);
   the boundary check is now non-increasing instead of strictly decreasing.

Defaults switch to the best-measured config (hermite, interval 3).
Tests 35 -> 38. README validation section rewritten from measurements;
REGISTRY-SUBMIT.md documents the registry.comfy.org publish flow.
This commit is contained in:
Krishi Attri
2026-06-11 01:42:19 +00:00
parent 26615c4bcf
commit ebe139e5f5
9 changed files with 291 additions and 87 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
tests/
REGISTRY-SUBMIT.md
.comfyignore
+86 -26
View File
@@ -6,11 +6,13 @@ the [`hicache-pp`](https://pypi.org/project/hicache-pp/) library
([HiCache](https://arxiv.org/abs/2508.16984) Hermite-polynomial and HiCache++ ([HiCache](https://arxiv.org/abs/2508.16984) Hermite-polynomial and HiCache++
DMD/Prony-exponential forecasters). DMD/Prony-exponential forecasters).
> **Status: beta.** The forecasting math, schedule, and patch logic are > **Status: GPU-validated end-to-end inside ComfyUI** (2026-06-11, RTX 5090,
> CPU-unit-tested against the library and against the wiring pattern of the > real ComfyUI + kijai/ComfyUI-Hunyuan3DWrapper + Hunyuan3D-2mini fp16):
> measured adapter repos (see *Validated on* below). End-to-end inside-ComfyUI > the patch engages on schedule, sampling runs 2.7x faster at the recommended
> GPU validation is still pending — until then, treat the node as experimental > `hermite interval=3`, the accelerated mesh stays well above the
> and A/B your own outputs. > different-seed noise floor in F-score against the unaccelerated mesh, and
> repeated runs in one session reset state correctly. Measured details in
> *Validated on* below.
## What it does ## What it does
@@ -39,8 +41,8 @@ covers the wrapper's vendored `hy3dgen` (Hunyuan3D 2.0) and `hy3dshape`
| method | basis | when to use | | 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) | | `hermite` *(default)* | HiCache, dual-scaled physicist's Hermite polynomial ([arXiv:2508.16984](https://arxiv.org/abs/2508.16984)) | interval 3; the best-measured setting inside ComfyUI and on both upstream checkpoints |
| `dmd` *(default)* | HiCache++ — Dynamic Mode Decomposition / Prony exponential basis | larger intervals (i4–i5); degrades most gracefully | | `dmd` | HiCache++, Dynamic Mode Decomposition / Prony exponential basis | larger intervals (4 to 5) on the big 2.1 DiT; on mini it measured below the seed-noise floor inside ComfyUI (see *Validated on*) |
| `auto` | holdout-selected per compute step: serves DMD only when it demonstrably beats the polynomial on the cached window | when unsure | | `auto` | holdout-selected per compute step: serves DMD only when it demonstrably beats the polynomial on the cached window | when unsure |
### Inputs ### Inputs
@@ -48,8 +50,8 @@ covers the wrapper's vendored `hy3dgen` (Hunyuan3D 2.0) and `hy3dshape`
| input | default | meaning | | input | default | meaning |
|---|---|---| |---|---|---|
| `pipeline` | — | `HY3DMODEL` from `Hy3DModelLoader` | | `pipeline` | — | `HY3DMODEL` from `Hy3DModelLoader` |
| `method` | `dmd` | forecast basis (above) | | `method` | `hermite` | forecast basis (above) |
| `interval` | 5 | compute 1 step, forecast `interval-1`. `1` disables caching | | `interval` | 3 | 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) | | `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 | | `enable` | true | off = unpatch, restore the original DiT |
| `max_order` | 1 | Hermite / finite-difference order | | `max_order` | 1 | Hermite / finite-difference order |
@@ -60,7 +62,42 @@ Re-running the node with new parameters re-patches cleanly; `enable=false` (or
`interval=1`) restores the original model. State resets automatically at each `interval=1`) restores the original model. State resets automatically at each
new sampling run. new sampling run.
## Measured numbers (from the adapter repos — not yet re-measured inside ComfyUI) The node never mutates the pipeline it receives: it returns a shallow copy
whose `model` attribute is the patch (weights stay shared, so this costs no
VRAM). This matters because ComfyUI caches node outputs keyed on node inputs;
an in-place patch lets a cached output alias a pipeline that a later run
re-patched with different settings. That failure was actually observed during
GPU validation (a cached `hermite interval=3` output silently ran
`dmd interval=5`) and is covered by a regression test.
## Measured inside ComfyUI (this node, end-to-end)
Setup: ComfyUI 0.24.0, kijai/ComfyUI-Hunyuan3DWrapper master, Hunyuan3D-2mini
fp16 single-file checkpoint, RTX 5090, torch 2.12 cu128. Workflow: LoadImage
(518x518 RGBA crop) -> InvertMask -> Hy3DModelLoader -> HiCacheAccelerate ->
Hy3DGenerateMesh (30 steps, cfg 5.5) -> Hy3DVAEDecode (octree 384) ->
Hy3DExportMesh. Quality metric: F-score@0.05 of the accelerated mesh against
the unaccelerated mesh from the same seed (50k surface samples each, shared
canonical frame, no alignment needed); as a floor, two unaccelerated runs
that differ only in seed score 0.751 on the same metric.
| config | DiT steps run | sampling | sampling speedup | F1@0.05 vs baseline |
|---|---|---:|---:|---:|
| baseline (`enable=false`) | 30/30 | 1.87 s (16.1 it/s) | 1.00x | 1.000 |
| `hermite interval=3` | 11/30 | 0.70 s (43.1 it/s) | **2.68x** | **0.825** |
| `dmd interval=5` | 7/30 | 1.10 s (27.2 it/s) | 1.69x | 0.719 |
Reading: `hermite i3` is comfortably above the 0.751 different-seed floor
(the accelerated mesh is closer to its baseline than a fresh seed is), at a
2.7x sampling speedup. `dmd i5` lands at the floor on mini inside ComfyUI;
its forecast also costs more per skipped step (an SVD), which eats into the
speedup on a DiT this small. Hence the node defaults are `hermite` / `3`.
On mini the end-to-end win is modest in absolute terms (about 1.2 s of a
roughly 9 s run) because VAE decode and marching cubes dominate; the larger
the shape DiT and the higher the step count, the larger the end-to-end share
the node saves.
## Measured numbers (from the adapter repos, outside ComfyUI)
The same forecasters, wired into the upstream Hunyuan3D pipelines (outside The same forecasters, wired into the upstream Hunyuan3D pipelines (outside
ComfyUI), measured on Toys4K image-to-3D with geometry-preserving A/B ComfyUI), measured on Toys4K image-to-3D with geometry-preserving A/B
@@ -83,9 +120,12 @@ Source adapter repos (measured, runnable):
[hunyuan2-plus-plus](https://github.com/Archerkattri/hunyuan2-plus-plus) (2.0 + mini), [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). [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 These numbers come from first-class pipeline wiring (the forecast caches the
pipelines run the same denoise loop (they do, structurally — that is what the CFG-combined velocity); the ComfyUI node is a model-level patch that caches
unit tests pin down), but **no inside-ComfyUI measurement has been done yet**. the pre-CFG stacked output instead. For Hermite the two are mathematically
identical. For DMD they are not, and the inside-ComfyUI measurement above
confirms the difference is real on mini: prefer `hermite` in this node unless
you re-A/B `dmd` on your own checkpoint.
## Install ## Install
@@ -107,7 +147,25 @@ to provide the `HY3DMODEL` pipeline the node accelerates.
What has actually been verified, and how: What has actually been verified, and how:
* **CPU unit tests (35, in `tests/`)** — run `pytest` in this repo, no ComfyUI * **End-to-end GPU validation inside ComfyUI** (2026-06-11, RTX 5090, ComfyUI
0.24.0, wrapper master, Hunyuan3D-2mini fp16):
* headless ComfyUI boots with the pack installed, zero import errors, and
`HiCacheAccelerate` appears in `/object_info`;
* the full image-to-mesh workflow executes through the ComfyUI API with the
node wired in, and the patch demonstrably engages: with 30 sampling steps
the run-boundary log reports `11 computed + 19 skipped` for
`hermite interval=3` and `7 computed + 23 skipped` for `dmd interval=5`,
exactly the library schedule, with the sampling rates and meshes in the
table above;
* repeated runs in one server session (the same patched pipeline served
from ComfyUI's node-output cache across prompts) each reset the forecast
state at the run boundary and produce fresh, seed-dependent meshes;
* two bugs were found by this validation and are fixed with regression
tests: (1) ComfyUI cache aliasing, fixed by copy-on-patch (see *Inputs*);
(2) back-to-back single-step runs both start at t=0, which the old
strictly-decreasing run-boundary check missed, so the second run could be
served a stale anchor; the boundary check is now non-increasing.
* **CPU unit tests (38, in `tests/`)**, run `pytest` in this repo, no ComfyUI
needed: needed:
* the node pack loads standalone exactly the way ComfyUI loads it * the node pack loads standalone exactly the way ComfyUI loads it
(`NODE_CLASS_MAPPINGS`, `INPUT_TYPES` schema, zero `comfy` imports); (`NODE_CLASS_MAPPINGS`, `INPUT_TYPES` schema, zero `comfy` imports);
@@ -117,17 +175,18 @@ What has actually been verified, and how:
are forecast-filled, and the patch's outputs are **bit-identical** to are forecast-filled, and the patch's outputs are **bit-identical** to
driving `hicache-pp`'s state machine directly (the adapter wiring pattern); driving `hicache-pp`'s state machine directly (the adapter wiring pattern);
* DMD forecasts are exact (rel. err < 1e-3) on exponential velocity * DMD forecasts are exact (rel. err < 1e-3) on exponential velocity
trajectories — its solution class — once its snapshot window fills, and trajectories (its solution class) once its snapshot window fills, and
beat naive last-output reuse by >10× there; beat naive last-output reuse by >10x there;
* run-boundary reset, re-patch/unpatch, attribute & `.to()` passthrough, * run-boundary reset (including the single-step edge), copy-on-patch
and parameter validation. non-aliasing, re-patch/unpatch, attribute & `.to()` passthrough, and
* **Adapter-repo GPU measurements** — the identical forecasters and schedule, parameter validation.
* **Adapter-repo GPU measurements**: the identical forecasters and schedule,
measured in the upstream (non-ComfyUI) Hunyuan3D pipelines (tables above). measured in the upstream (non-ComfyUI) Hunyuan3D pipelines (tables above).
**Not yet validated:** running inside ComfyUI on GPU with the real wrapper and **Not yet measured:** the full-size Hunyuan3D 2.0/2.1 checkpoints inside
real checkpoints (including mini/turbo), and quality A/B of the resulting ComfyUI (the mechanism is identical and checkpoint-agnostic, and the adapter
meshes through the full Comfy graph. Until that lands this repo stays 0.x/beta repos measured those checkpoints upstream, but the in-Comfy numbers above are
and is not submitted to the Comfy Registry. mini-only), and the multiview sampler path (`Hy3DGenerateMeshMultiView`).
### One honest design note ### One honest design note
@@ -136,9 +195,10 @@ The adapter repos wire the forecast into the pipeline loop and cache the
a ComfyUI node) caches the *pre-CFG stacked* DiT output instead. For the a ComfyUI node) caches the *pre-CFG stacked* DiT output instead. For the
Hermite forecaster the two are mathematically identical (forecast and CFG Hermite forecaster the two are mathematically identical (forecast and CFG
combine are both linear); for DMD the fit runs on the stacked trajectory 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 rather than the combined one. The inside-ComfyUI A/B above shows this matters
computation, which is one more reason the GPU A/B is required before any in practice: DMD's upstream "lossless at interval 5 on mini" result does NOT
lossless claim is repeated for this node. transfer to the model-level patch, so this README does not claim it. Hermite
numbers transfer exactly, and that is what the defaults use.
## License ## License
+50
View File
@@ -0,0 +1,50 @@
# Submitting ComfyUI-HiCache to the Comfy Registry
Status: the node is GPU-validated end-to-end (see README, *Validated on*) and
version 0.2.0 is ready to publish. Everything below the "human-only" line is
prepared; the two human-only steps need Krishi's browser login.
## What is already in place
* `pyproject.toml` has the required `[project]` fields (`name`,
`description`, `version = "0.2.0"`, `license`, `dependencies`) and the
`[tool.comfy]` block:
* `PublisherId = "archerkattri"`
* `DisplayName = "ComfyUI-HiCache"`
* `.comfyignore` excludes tests and tooling files from the published package.
* The repo is public at `https://github.com/Archerkattri/ComfyUI-HiCache`.
## Human-only steps (browser, one time)
1. Create the publisher account: go to https://registry.comfy.org, log in,
and create a publisher. The publisher ID is the part after the `@` on the
profile page; it is globally unique and immutable. It MUST be
`archerkattri` to match `pyproject.toml`. If that ID is taken, pick
another and update `PublisherId` in `pyproject.toml` before publishing.
2. Create an API key: registry nodes section -> select the publisher ->
create a new API key. Name it (for example `comfyui-hicache-publish`) and
store it safely; lost keys cannot be recovered, only replaced.
## Publish (CLI, after the human-only steps)
```bash
pip install comfy-cli # if not already installed
cd ComfyUI-HiCache # repo root, where pyproject.toml lives
comfy node publish # prompts for the API key
```
Expected confirmation: `Version 0.2.0 Published.` The node then appears at
`https://registry.comfy.org/publishers/archerkattri/nodes/comfyui-hicache`
and becomes installable through ComfyUI-Manager once the registry index
picks it up.
Do NOT use the registry's GitHub Actions auto-publish flow
(`publish_action.yml` + `REGISTRY_ACCESS_TOKEN` secret): this repo
deliberately carries no CI. Publish from the CLI for each release.
## Releasing future versions
1. Bump `version` in `pyproject.toml` AND `__version__` in `__init__.py`
(keep them identical, semver).
2. Run the tests: `pytest` (38 must pass).
3. Commit, push, then `comfy node publish` again with the same API key.
+1 -1
View File
@@ -18,7 +18,7 @@ except ImportError: # imported outside ComfyUI (tests / tooling), not as a pack
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS from nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__version__ = "0.1.0" __version__ = "0.2.0"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
WEB_DIRECTORY = None WEB_DIRECTORY = None
+55 -24
View File
@@ -40,15 +40,30 @@ 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 practice the same dynamics; this is the standard trade-off of a model-level
patch versus first-class pipeline wiring. patch versus first-class pipeline wiring.
Run-boundary detection: these pipelines sample with monotonically *increasing* Run-boundary detection: these pipelines sample with *strictly increasing*
timesteps (``sigmas = linspace(0, 1, N)``; "we start from 0" in the upstream timesteps within a run (``sigmas = linspace(0, 1, N)``; "we start from 0" in
source). A timestep value lower than the previous call's therefore marks a new the upstream source) and call the model exactly once per step. A timestep
sampling run, and the forecast state is re-initialised. The total step count is value lower than **or equal to** the previous call's therefore marks a new
not knowable from inside the model, so the schedule has no end-of-run sampling run, and the forecast state is re-initialised (the equality case
always-compute window (``end_enhance``) — only the initial warmup window. catches back-to-back single-step runs, which both start at t=0 and would
otherwise be served a stale anchor from the previous run). 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.
Cache safety inside ComfyUI: :func:`apply_hicache` / :func:`remove_hicache`
never mutate the pipeline they are given — they return a shallow copy whose
``model`` attribute is replaced (weights are shared, so this costs nothing).
ComfyUI caches node *outputs* keyed on node *inputs*; an in-place patch lets
a cached output alias a pipeline that a later run re-patched with different
settings (GPU-validated failure mode: a cached "hermite interval=3" output
silently ran "dmd interval=5" after an intervening run re-patched the shared
object). With copy-on-patch every cached output permanently owns its own
configuration.
""" """
from __future__ import annotations from __future__ import annotations
import copy
import logging import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -99,8 +114,8 @@ class HiCacheModelPatch(torch.nn.Module):
submodule, so ``.to()`` / ``.half()`` / state-dict access recurse into it). submodule, so ``.to()`` / ``.half()`` / state-dict access recurse into it).
""" """
def __init__(self, model: torch.nn.Module, *, method: str = "dmd", def __init__(self, model: torch.nn.Module, *, method: str = "hermite",
interval: int = 5, warmup_steps: int = 2, max_order: int = 1, interval: int = 3, warmup_steps: int = 2, max_order: int = 1,
sigma: float = 0.5, dmd_history: int = 5) -> None: sigma: float = 0.5, dmd_history: int = 5) -> None:
validate_config(method, interval, warmup_steps, max_order, sigma, dmd_history) validate_config(method, interval, warmup_steps, max_order, sigma, dmd_history)
super().__init__() super().__init__()
@@ -174,9 +189,12 @@ class HiCacheModelPatch(torch.nn.Module):
def forward(self, latent_model_input: torch.Tensor, timestep: Any, def forward(self, latent_model_input: torch.Tensor, timestep: Any,
*args: Any, **kwargs: Any) -> torch.Tensor: *args: Any, **kwargs: Any) -> torch.Tensor:
t_val = self._timestep_value(timestep) t_val = self._timestep_value(timestep)
# New sampling run: timesteps in these pipelines increase monotonically # New sampling run: within a run these pipelines call the model once
# (sigma 0 -> 1), so a drop in t means the loop restarted. # per step with strictly increasing timesteps (sigma 0 -> 1), so a
if self._state is None or self._last_t is None or t_val < self._last_t: # non-increasing t means the loop restarted. `<=` (not `<`) so that
# back-to-back single-step runs (t=0 then t=0 again) also reset
# instead of serving the previous run's anchor.
if self._state is None or self._last_t is None or t_val <= self._last_t:
self.reset() self.reset()
self._last_t = t_val self._last_t = t_val
@@ -200,36 +218,49 @@ class HiCacheModelPatch(torch.nn.Module):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# apply / remove on a pipeline object # apply / remove on a pipeline object
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def apply_hicache(pipeline: Any, *, method: str = "dmd", interval: int = 5, def apply_hicache(pipeline: Any, *, method: str = "hermite", interval: int = 3,
warmup_steps: int = 2, max_order: int = 1, sigma: float = 0.5, warmup_steps: int = 2, max_order: int = 1, sigma: float = 0.5,
dmd_history: int = 5) -> Any: dmd_history: int = 5) -> Any:
"""Patch ``pipeline.model`` with a :class:`HiCacheModelPatch` (idempotent). """Return a shallow copy of ``pipeline`` whose ``model`` is patched.
If the pipeline is already patched the old patch is removed first, so The input pipeline is NOT mutated (ComfyUI caches node outputs keyed on
re-running the node with new parameters reconfigures cleanly. node inputs, so a cached output must own its configuration forever — see
the module docstring). Weights are shared between the copy and the
original; only the wrapper object and the ``model`` attribute differ.
If the given pipeline is already patched, the patch is replaced (never
nested), so re-running the node with new parameters reconfigures cleanly.
""" """
if not hasattr(pipeline, "model"): if not hasattr(pipeline, "model"):
raise TypeError( raise TypeError(
"HiCache: pipeline has no `.model` attribute - expected a Hunyuan3D " "HiCache: pipeline has no `.model` attribute - expected a Hunyuan3D "
f"shape pipeline, got {type(pipeline).__name__}" f"shape pipeline, got {type(pipeline).__name__}"
) )
remove_hicache(pipeline) inner = pipeline.model
patch = HiCacheModelPatch( if getattr(inner, "_hicache_is_patch", False):
pipeline.model, method=method, interval=interval, warmup_steps=warmup_steps, inner = inner.inner # replace, never nest
patched = copy.copy(pipeline)
patched.model = HiCacheModelPatch(
inner, method=method, interval=interval, warmup_steps=warmup_steps,
max_order=max_order, sigma=sigma, dmd_history=dmd_history, max_order=max_order, sigma=sigma, dmd_history=dmd_history,
) )
pipeline.model = patch
logger.info( logger.info(
"[HiCache] patched %s.model: method=%s interval=%d warmup=%d", "[HiCache] patched copy of %s: method=%s interval=%d warmup=%d",
type(pipeline).__name__, method, interval, warmup_steps, type(pipeline).__name__, method, interval, warmup_steps,
) )
return pipeline return patched
def remove_hicache(pipeline: Any) -> Any: def remove_hicache(pipeline: Any) -> Any:
"""Restore the original DiT on ``pipeline.model`` (no-op if not patched).""" """Return ``pipeline`` with the original DiT on ``.model``.
If the pipeline is patched, a shallow copy with the patch unwrapped is
returned (the input is not mutated); if it is not patched, the pipeline
is returned unchanged.
"""
model = getattr(pipeline, "model", None) model = getattr(pipeline, "model", None)
if model is not None and getattr(model, "_hicache_is_patch", False): if model is not None and getattr(model, "_hicache_is_patch", False):
pipeline.model = model.inner clean = copy.copy(pipeline)
logger.info("[HiCache] removed patch from %s.model", type(pipeline).__name__) clean.model = model.inner
logger.info("[HiCache] removed patch from %s", type(pipeline).__name__)
return clean
return pipeline return pipeline
+9 -7
View File
@@ -32,18 +32,20 @@ class HiCacheAccelerate:
"tooltip": "Hunyuan3D shape pipeline from Hy3DModelLoader " "tooltip": "Hunyuan3D shape pipeline from Hy3DModelLoader "
"(kijai/ComfyUI-Hunyuan3DWrapper)."}), "(kijai/ComfyUI-Hunyuan3DWrapper)."}),
"method": (list(METHODS), { "method": (list(METHODS), {
"default": "dmd", "default": "hermite",
"tooltip": "Forecast basis on skipped steps:\n" "tooltip": "Forecast basis on skipped steps:\n"
"hermite = HiCache (polynomial, arXiv:2508.16984)\n" "hermite = HiCache (polynomial, arXiv:2508.16984; "
"dmd = HiCache++ (exponential / Prony-DMD; degrades " "best measured quality inside ComfyUI)\n"
"most gracefully at large intervals)\n" "dmd = HiCache++ (exponential / Prony-DMD; worth "
"trying at large intervals on the big 2.1 DiT)\n"
"auto = holdout-pick the better of the two per " "auto = holdout-pick the better of the two per "
"compute step"}), "compute step"}),
"interval": ("INT", { "interval": ("INT", {
"default": 5, "min": 1, "max": 12, "step": 1, "default": 3, "min": 1, "max": 12, "step": 1,
"tooltip": "Compute one DiT step, then forecast interval-1 " "tooltip": "Compute one DiT step, then forecast interval-1 "
"steps. 1 = caching disabled (every step computed). " "steps. 1 = caching disabled (every step computed). "
"Measured sweet spots: hermite i3, dmd i4-i5."}), "Measured sweet spot inside ComfyUI: hermite "
"interval 3."}),
"warmup_steps": ("INT", { "warmup_steps": ("INT", {
"default": 2, "min": 0, "max": 100, "step": 1, "default": 2, "min": 0, "max": 100, "step": 1,
"tooltip": "Always compute the first N sampling steps before " "tooltip": "Always compute the first N sampling steps before "
@@ -78,7 +80,7 @@ class HiCacheAccelerate:
"Hermite / HiCache++ DMD, via the hicache-pp library)." "Hermite / HiCache++ DMD, via the hicache-pp library)."
) )
def patch(self, pipeline, method="dmd", interval=5, warmup_steps=2, def patch(self, pipeline, method="hermite", interval=3, warmup_steps=2,
enable=True, max_order=1, sigma=0.5, dmd_history=5): enable=True, max_order=1, sigma=0.5, dmd_history=5):
if not enable or interval <= 1: if not enable or interval <= 1:
return (remove_hicache(pipeline),) return (remove_hicache(pipeline),)
+2 -2
View File
@@ -5,11 +5,11 @@
[project] [project]
name = "comfyui-hicache" 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." 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" version = "0.2.0"
license = { file = "LICENSE" } license = { file = "LICENSE" }
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = ["hicache-pp>=1.1.0"] dependencies = ["hicache-pp>=1.1.0"]
classifiers = ["Development Status :: 4 - Beta"] classifiers = ["Development Status :: 5 - Production/Stable"]
[project.urls] [project.urls]
Repository = "https://github.com/Archerkattri/ComfyUI-HiCache" Repository = "https://github.com/Archerkattri/ComfyUI-HiCache"
+14 -12
View File
@@ -78,23 +78,25 @@ def test_node_function_patches_and_unpatches():
original = pipe.model original = pipe.model
(out,) = node.patch(pipe, method="hermite", interval=4, warmup_steps=2) (out,) = node.patch(pipe, method="hermite", interval=4, warmup_steps=2)
assert out is pipe assert out is not pipe, "node must return a copy, not mutate (ComfyUI caching)"
assert getattr(pipe.model, "_hicache_is_patch", False) assert pipe.model is original, "input pipeline must stay unpatched"
assert pipe.model.inner is original assert getattr(out.model, "_hicache_is_patch", False)
assert out.model.inner is original
# re-patch with new params replaces (not nests) the patch # re-patch with new params replaces (not nests) the patch
(out,) = node.patch(pipe, method="dmd", interval=3, warmup_steps=1) (out2,) = node.patch(out, method="dmd", interval=3, warmup_steps=1)
assert pipe.model.inner is original assert out2.model.inner is original
assert pipe.model.method == "dmd" and pipe.model.interval == 3 assert out2.model.method == "dmd" and out2.model.interval == 3
assert out.model.method == "hermite", "earlier output keeps its config"
# enable=False restores the original model # enable=False restores the original model
(out,) = node.patch(pipe, enable=False) (out3,) = node.patch(out2, enable=False)
assert pipe.model is original assert out3.model is original
# interval=1 means "no caching" -> also unpatched # interval=1 means "no caching" -> also unpatched (and an unpatched
node.patch(pipe, interval=4) # input passes through unchanged)
(out,) = node.patch(pipe, interval=1) (out4,) = node.patch(pipe, interval=1)
assert pipe.model is original assert out4 is pipe and out4.model is original
def test_node_rejects_bad_pipeline(): def test_node_rejects_bad_pipeline():
+70 -15
View File
@@ -113,7 +113,7 @@ def test_compute_calls_follow_schedule(method, interval, warmup):
steps = 30 steps = 30
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method=method, interval=interval, warmup_steps=warmup) pipe = hp.apply_hicache(pipe, method=method, interval=interval, warmup_steps=warmup)
pipe(num_inference_steps=steps) pipe(num_inference_steps=steps)
@@ -155,7 +155,7 @@ def _run_pair(ref_dit, acc_dit, method, steps=30, interval=4, warmup=2, **kw):
torch.manual_seed(1) torch.manual_seed(1)
_, ref_outputs = MockPipeline(ref_dit)(num_inference_steps=steps) _, ref_outputs = MockPipeline(ref_dit)(num_inference_steps=steps)
pipe = MockPipeline(acc_dit) pipe = MockPipeline(acc_dit)
hp.apply_hicache(pipe, method=method, interval=interval, warmup_steps=warmup, **kw) pipe = hp.apply_hicache(pipe, method=method, interval=interval, warmup_steps=warmup, **kw)
torch.manual_seed(1) torch.manual_seed(1)
_, acc_outputs = pipe(num_inference_steps=steps) _, acc_outputs = pipe(num_inference_steps=steps)
computes = expected_compute_steps(steps, interval, warmup) computes = expected_compute_steps(steps, interval, warmup)
@@ -192,7 +192,7 @@ def test_patch_reproduces_hicache_pp_forecast_bitwise():
dit_b.load_state_dict(dit_a.state_dict()) dit_b.load_state_dict(dit_a.state_dict())
pipe = MockPipeline(dit_a) pipe = MockPipeline(dit_a)
hp.apply_hicache(pipe, method="hermite", interval=interval, warmup_steps=warmup) pipe = hp.apply_hicache(pipe, method="hermite", interval=interval, warmup_steps=warmup)
torch.manual_seed(1) torch.manual_seed(1)
_, acc_outputs = pipe(num_inference_steps=steps) _, acc_outputs = pipe(num_inference_steps=steps)
@@ -272,7 +272,7 @@ def test_skipped_outputs_finite_and_better_than_naive_reuse():
def test_interval_1_or_warmup_dominates_means_no_skips(): def test_interval_1_or_warmup_dominates_means_no_skips():
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method="hermite", interval=2, warmup_steps=100) pipe = hp.apply_hicache(pipe, method="hermite", interval=2, warmup_steps=100)
pipe(num_inference_steps=20) pipe(num_inference_steps=20)
assert dit.calls == 20 and pipe.model.skipped_steps == 0 assert dit.calls == 20 and pipe.model.skipped_steps == 0
@@ -283,7 +283,7 @@ def test_interval_1_or_warmup_dominates_means_no_skips():
def test_dmd_method_populates_snapshots_and_forecasts(): def test_dmd_method_populates_snapshots_and_forecasts():
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2, dmd_history=5) pipe = hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2, dmd_history=5)
pipe(num_inference_steps=40) pipe(num_inference_steps=40)
st = pipe.model._state st = pipe.model._state
assert st["backend"] == "dmd" assert st["backend"] == "dmd"
@@ -294,7 +294,7 @@ def test_dmd_method_populates_snapshots_and_forecasts():
def test_hermite_method_keeps_snapshots_empty(): def test_hermite_method_keeps_snapshots_empty():
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method="hermite", interval=4, warmup_steps=2) pipe = hp.apply_hicache(pipe, method="hermite", interval=4, warmup_steps=2)
pipe(num_inference_steps=40) pipe(num_inference_steps=40)
st = pipe.model._state st = pipe.model._state
assert st["backend"] == "hermite" assert st["backend"] == "hermite"
@@ -305,7 +305,7 @@ def test_hermite_method_keeps_snapshots_empty():
def test_auto_method_makes_a_holdout_choice(): def test_auto_method_makes_a_holdout_choice():
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method="auto", interval=3, warmup_steps=2, dmd_history=8) pipe = hp.apply_hicache(pipe, method="auto", interval=3, warmup_steps=2, dmd_history=8)
pipe(num_inference_steps=40) pipe(num_inference_steps=40)
st = pipe.model._state st = pipe.model._state
assert st["backend"] == "auto" assert st["backend"] == "auto"
@@ -319,7 +319,7 @@ def test_auto_method_makes_a_holdout_choice():
def test_state_resets_between_sampling_runs(): def test_state_resets_between_sampling_runs():
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2) pipe = hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2)
pipe(num_inference_steps=20) pipe(num_inference_steps=20)
first_run_calls = dit.calls first_run_calls = dit.calls
@@ -334,7 +334,7 @@ def test_state_resets_between_sampling_runs():
def test_attribute_and_device_passthrough(): def test_attribute_and_device_passthrough():
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method="hermite", interval=4, warmup_steps=2) pipe = hp.apply_hicache(pipe, method="hermite", interval=4, warmup_steps=2)
# pipelines check hasattr(self.model, 'guidance_embed') and call .to() # pipelines check hasattr(self.model, 'guidance_embed') and call .to()
assert pipe.model.guidance_embed is False assert pipe.model.guidance_embed is False
pipe.model.to(torch.float32) pipe.model.to(torch.float32)
@@ -344,12 +344,67 @@ def test_attribute_and_device_passthrough():
def test_remove_restores_original_model(): def test_remove_restores_original_model():
dit = MockDiT() dit = MockDiT()
pipe = MockPipeline(dit) pipe = MockPipeline(dit)
hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2) patched = hp.apply_hicache(pipe, method="dmd", interval=4, warmup_steps=2)
assert pipe.model is not dit assert patched is not pipe and patched.model is not dit
hp.remove_hicache(pipe) assert pipe.model is dit, "apply must not mutate its input pipeline"
assert pipe.model is dit restored = hp.remove_hicache(patched)
hp.remove_hicache(pipe) # idempotent assert restored.model is dit
assert pipe.model is dit assert patched.model is not dit, "remove must not mutate its input either"
assert hp.remove_hicache(pipe) is pipe # unpatched input passes through
def test_apply_does_not_alias_between_configs():
"""ComfyUI cache-aliasing regression (found in GPU validation): node
outputs are cached keyed on node inputs, so a pipeline returned for
config A must keep running config A even after the same loader pipeline
is later patched with config B. In-place patching broke this: a cached
'hermite interval=3' output silently ran 'dmd interval=5'."""
torch.manual_seed(0)
dit = MockDiT()
base = MockPipeline(dit)
pipe_a = hp.apply_hicache(base, method="hermite", interval=3, warmup_steps=2)
pipe_b = hp.apply_hicache(base, method="dmd", interval=5, warmup_steps=2)
# the loader's pipeline is untouched; each copy owns its config
assert base.model is dit
assert (pipe_a.model.method, pipe_a.model.interval) == ("hermite", 3)
assert (pipe_b.model.method, pipe_b.model.interval) == ("dmd", 5)
assert pipe_a.model.inner is dit and pipe_b.model.inner is dit
# serving the A copy after B was created still runs A's schedule
steps = 30
pipe_a(num_inference_steps=steps)
assert dit.calls == len(expected_compute_steps(steps, 3, 2))
def test_apply_on_patched_pipeline_replaces_not_nests():
dit = MockDiT()
base = MockPipeline(dit)
once = hp.apply_hicache(base, method="hermite", interval=3, warmup_steps=2)
twice = hp.apply_hicache(once, method="dmd", interval=5, warmup_steps=2)
assert twice.model.inner is dit, "patch must replace, never nest"
assert (once.model.method, once.model.interval) == ("hermite", 3)
def test_single_step_runs_reset_at_equal_timestep():
"""Run-boundary regression: two back-to-back single-step runs both call
the model at t=0. The boundary check must treat the *equal* timestep as
a new run and recompute — not serve the previous run's cached anchor."""
dit = MockDiT()
patch = hp.HiCacheModelPatch(dit, method="hermite", interval=3,
warmup_steps=1)
lmi = torch.randn(2, 8, 4)
t0 = torch.zeros(2)
out1 = patch(lmi, t0, cond=None) # run 1 (single step)
with torch.no_grad():
dit.base.add_(1.0) # "new image": DiT output changes
out2 = patch(lmi, t0, cond=None) # run 2 (single step, same t)
assert dit.calls == 2, "second single-step run must recompute, not forecast"
assert not torch.allclose(out1, out2), "stale anchor served across runs"
assert patch.computed_steps == 1 and patch.skipped_steps == 0 # per-run stats
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------