fix(wan): empty CUDA cache between A14B expert swaps

The dual-expert swapper releases the active expert via its context
manager exit, but PyTorch's caching allocator retains the freed blocks
as reserved-not-yet-claimable space until empty_cache runs. The next
partial_load_to_vram for the incoming expert then sees a fragmented
free pool and offloads layers it could otherwise have kept on device.

Users running A14B observed the low-noise expert ending up far more
CPU-resident than the high-noise one on otherwise identical settings —
that was the leftover reservation from the high-noise expert masking
real free VRAM.

Call TorchDevice.empty_cache() between the release and the next load.
Same pattern as the VAE-encode fix earlier in this branch.

Regression test in test_wan_expert_swapper.py mocks empty_cache and
asserts it fires on every actual swap but not on a same-label re-get.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Lincoln Stein
2026-05-13 23:15:59 -04:00
co-authored by Claude Opus 4.7
parent 5ec7e3327f
commit 53b2f4d4c7
2 changed files with 60 additions and 0 deletions
+9
View File
@@ -172,6 +172,15 @@ class _ExpertSwapper:
# Release current GPU residency before bringing the other expert on device.
self._release()
# Hand the PyTorch allocator a clean slate before the next partial_load_to_vram
# decides how much of the incoming expert can be GPU-resident. The previous
# expert's freed blocks stay pinned in the caching allocator until empty_cache
# is called, so partial_load sees fragmented free space and offloads layers it
# could otherwise have kept on device — A14B users observed the low-noise
# transformer ending up far more CPU-resident than the high-noise one purely
# because of leftover reservations from the previous swap.
TorchDevice.empty_cache()
# Load the requested expert lazily so its ``LoadedModel`` handle is
# always fresh — see class docstring for the cache-eviction reasoning.
model_id = self._high_model if label == self.HIGH else self._low_model
@@ -360,3 +360,54 @@ def test_lazy_load_per_swap_not_upfront():
assert ctx.models.load_calls == ["high", "low", "high"]
swapper.close()
def test_empty_cache_called_on_swap():
"""Regression: each expert swap must trigger ``TorchDevice.empty_cache()`` so
the next ``partial_load_to_vram`` sees an un-fragmented allocator.
A14B users reported the low-noise expert ending up far more CPU-resident than
the high-noise one — the previous expert's freed blocks stayed pinned in the
PyTorch caching allocator across the swap, and partial_load decided there
wasn't room for as much of the incoming expert as there actually was."""
log: list[str] = []
high_nn = nn.Linear(1, 1)
low_nn = nn.Linear(1, 1)
ctx = _FakeContext(
{"high": _FakeInfo("HIGH", high_nn, log), "low": _FakeInfo("LOW", low_nn, log)},
log,
)
stub, _ = _stub_lora_context_manager(log)
with (
patch(
"invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
side_effect=stub,
),
patch("invokeai.app.invocations.wan_denoise.TorchDevice.empty_cache") as empty_cache_mock,
):
swapper = _ExpertSwapper(
context=ctx,
high_model="high",
low_model="low",
inference_dtype=torch.bfloat16,
high_lora_factory=_make_factory(log, "HIGH"),
low_lora_factory=_make_factory(log, "LOW"),
)
swapper.get(_ExpertSwapper.HIGH)
first_call_count = empty_cache_mock.call_count
assert first_call_count >= 1, "empty_cache should run on the initial expert load too"
swapper.get(_ExpertSwapper.LOW)
assert empty_cache_mock.call_count > first_call_count, (
"empty_cache must be called on each HIGH→LOW (or LOW→HIGH) swap"
)
# Same-label re-get is a no-op; empty_cache must NOT be re-invoked.
before_no_op = empty_cache_mock.call_count
swapper.get(_ExpertSwapper.LOW)
assert empty_cache_mock.call_count == before_no_op, (
"Re-getting the active expert must short-circuit before empty_cache."
)
swapper.close()