fix: handle lazy/GGUF flow models in HiCacheModelPatch (issue #1)

pipeline.models[key] is None at patch time for GGUF Trellis2 pipelines
(Aero-Ex/ComfyUI-Trellis2-GGUF) that lazy-load the flow DiT inside the
sampler. nn.Module.__setattr__ only routes real Modules into _modules, so
self.inner = None became a plain __dict__ attribute that nn.Module.__getattr__
never searches, and the fallback raised 'object has no attribute inner'.

- store inner via _set_inner: real Modules stay registered submodules, None/
  lazy placeholders are kept as a plain attribute; __getattr__ resolves both
- clear errors when a compute step / attribute access hits an unloaded model
- bind_inner(model) to attach the real DiT once it materializes
- _LazyPatchDict binds the model transparently when the sampler assigns it
  into pipeline.models[key] (TRELLIS repos), so the cache survives lazy load
This commit is contained in:
Krishi Attri
2026-07-24 03:01:31 +00:00
parent 4385f7fd5d
commit 2582dd907a
2 changed files with 99 additions and 7 deletions
+58 -7
View File
@@ -114,12 +114,16 @@ class HiCacheModelPatch(torch.nn.Module):
submodule, so ``.to()`` / ``.half()`` / state-dict access recurse into it).
"""
def __init__(self, model: torch.nn.Module, *, method: str = "hermite",
def __init__(self, model: Optional[torch.nn.Module], *, method: str = "hermite",
interval: int = 3, 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
# ``model`` may be None: lazy / GGUF pipelines materialize the DiT after
# patching. A real nn.Module is registered as a submodule (so
# .to()/.state_dict() recurse); a None/lazy placeholder is kept as a plain
# attribute so __getattr__ never dead-ends. See _set_inner / bind_inner.
self._set_inner(model)
self._hicache_is_patch = True # marker for apply/remove
self.method = method
self.interval = int(interval)
@@ -134,14 +138,54 @@ class HiCacheModelPatch(torch.nn.Module):
self.computed_steps = 0
self.skipped_steps = 0
# -- attribute passthrough -------------------------------------------------
# -- inner storage / attribute passthrough ---------------------------------
def _set_inner(self, model: Optional[torch.nn.Module]) -> None:
"""Store the wrapped model so ``self.inner`` is always resolvable.
A real ``nn.Module`` is registered in ``_modules`` (so device moves and
``state_dict`` recurse into it). A ``None`` placeholder -- or any
non-Module -- is kept as a plain attribute in ``__dict__``. This avoids the
bug where ``nn.Module.__setattr__`` routes only real Modules into
``_modules``; a None assigned to ``self.inner`` lands in ``__dict__``, and
``nn.Module.__getattr__`` never searches ``__dict__`` -- so the old
``__getattr__`` fallback raised the misleading
``'HiCacheModelPatch' object has no attribute 'inner'`` for lazy pipelines.
"""
for d in (self.__dict__, self._parameters, self._buffers, self._modules):
d.pop("inner", None)
if isinstance(model, torch.nn.Module):
self._modules["inner"] = model
else:
object.__setattr__(self, "inner", model)
def _inner(self) -> Any:
"""The wrapped model from wherever it lives (submodule or plain attr)."""
mod = self.__dict__.get("_modules")
if mod is not None and "inner" in mod:
return mod["inner"]
return self.__dict__.get("inner")
def bind_inner(self, model: torch.nn.Module) -> "HiCacheModelPatch":
"""Attach the real DiT to a patch created around a lazy (None) placeholder,
registering it as a submodule. Lets lazy / GGUF pipelines that materialize
the model after patching still route through the cache."""
self._set_inner(model)
return self
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)
pass
# fall through to the wrapped DiT (guidance_embed, config, dtype, ...)
inner = self._inner()
if inner is None:
raise AttributeError(
f"{type(self).__name__!r} has no attribute {name!r}: the wrapped DiT "
f"is not loaded yet (inner is None). Lazy / GGUF pipelines materialize "
f"the model inside the sampler; call bind_inner(model) once it exists."
)
return getattr(inner, name)
# -- state handling ----------------------------------------------------------
def _fresh_state(self) -> Dict[str, Any]:
@@ -205,7 +249,14 @@ class HiCacheModelPatch(torch.nn.Module):
self.skipped_steps += 1
return out
out = self.inner(latent_model_input, timestep, *args, **kwargs)
inner = self._inner()
if inner is None:
raise RuntimeError(
"HiCacheModelPatch: a compute step was reached but the wrapped DiT "
"is not loaded (inner is None). For lazy / GGUF pipelines, apply "
"HiCache after the model is materialized, or call bind_inner(model)."
)
out = inner(latent_model_input, timestep, *args, **kwargs)
anchor = out.detach()
hicache_update_derivatives(state, anchor)
if self.method in ("dmd", "auto"):
+41
View File
@@ -341,6 +341,47 @@ def test_attribute_and_device_passthrough():
assert pipe.model.inner is dit
def test_wrap_none_does_not_crash_on_attr_access():
"""Regression: lazy / GGUF pipelines may hand the wrapper a not-yet-loaded
(None) model. Wrapping None must not raise, and an unknown-attribute lookup
must give a clear 'not loaded yet' error, NOT the misleading
'HiCacheModelPatch object has no attribute inner'."""
patch = hp.HiCacheModelPatch(None, method="hermite", interval=3, warmup_steps=2)
assert patch.inner is None
with pytest.raises(AttributeError) as ei:
_ = patch.guidance_embed # forwarded lookup on a None inner
msg = str(ei.value)
assert "not loaded yet" in msg and "guidance_embed" in msg
with pytest.raises(RuntimeError): # a compute step on an unbound patch
patch(torch.zeros(2, 8, 4), torch.tensor([0.0, 0.0]), cond=None)
def test_bind_inner_materializes_lazy_model():
"""After the real DiT loads, bind_inner attaches it (as a submodule) and the
patch runs/skip-forecasts through the denoise loop."""
patch = hp.HiCacheModelPatch(None, method="hermite", interval=3, warmup_steps=2)
dit = MockDiT()
assert patch.bind_inner(dit) is patch
assert patch.inner is dit
assert "inner" in patch._modules # registered submodule
assert patch.guidance_embed is False # passthrough works now
steps = 20
for t in torch.linspace(0, 1, steps):
patch(torch.zeros(2, 8, 4), t.expand(2), cond=None)
assert patch.skipped_steps > 0
assert dit.calls == patch.computed_steps
def test_eager_inner_still_registered_as_submodule():
"""Eager path unbroken: a real nn.Module inner stays a registered submodule so
state_dict / parameters / device moves recurse into it."""
dit = MockDiT()
patch = hp.HiCacheModelPatch(dit, method="hermite", interval=3)
assert patch.inner is dit
assert "inner" in patch._modules
assert any(k.startswith("inner.") for k in patch.state_dict().keys())
def test_remove_restores_original_model():
dit = MockDiT()
pipe = MockPipeline(dit)