perf(diff-cache): fp32 storage + recompute past RAM budget (no disk spill)

Robust-slice follow-up to the low-rank bypass. Two changes to _DiffCache:

1. Store fp32, not fp16. A cache hit is now numerically identical to a
   recompute, so results are independent of diff_cache_mode and of what fit
   in the cache (they match the 'disabled' path). Removes the documented
   ~1e-5 cache-vs-disabled score shift.

2. 'auto' mode recomputes past the RAM budget instead of spilling dense
   diffs to disk (recompute beats disk I/O — that spill once made Pass-2 98%
   disk wait). Explicit 'disk' mode is unchanged for those who want it.

Together these make caching purely a perf/memory policy with zero effect on
results. Algo version 1.11.1 -> 1.11.2 (also covers the earlier low-rank
bypass numerics); invalidates rankings cached under the old fp16 regime.
Registry 1.8.0. Tooltips updated; +2 regression tests (277 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ethanfel
2026-06-22 22:18:59 +02:00
co-authored by Claude Opus 4.8
parent 95693ff96c
commit f415ca7338
3 changed files with 51 additions and 20 deletions
+27 -19
View File
@@ -226,7 +226,13 @@ AUTOTUNER_MEMORY_VERSION = 1
# preservation is opt-in only). Ranking shifts only for stacks that set the flag.
# 1.11.1: per-LoRA merge flags (preserve/conflict_mode/key_filter) now fold into the
# AutoTuner cache keys; bump invalidates any rankings cached during 1.11.0 development.
AUTOTUNER_ALGO_VERSION = "1.11.1"
# 1.11.2: diff handling is now fp32 end-to-end. Low-rank diffs bypass the cache and
# recompute on GPU (factors smaller than the dense diff); the diff cache stores fp32
# instead of fp16; and "auto" mode recomputes past the RAM budget instead of spilling
# dense diffs to disk. A cache hit is now numerically identical to a recompute, so
# results are independent of diff_cache_mode and match the old "disabled" path. Scores
# shift ~1e-5 vs prior fp16-cached runs; bump invalidates those rankings.
AUTOTUNER_ALGO_VERSION = "1.11.2"
def _warn_stale_tuner_data(tuner_data, context):
@@ -820,12 +826,11 @@ class _DiffCache:
self._ram_limit = 4 * 1024 * 1024 * 1024
def _use_disk(self, tensor_bytes):
if self.mode == "disk":
return True
if self.mode == "ram":
return False
# auto: spill to disk if RAM limit would be exceeded
return (self._ram_bytes + tensor_bytes) > self._ram_limit
# Only the explicit "disk" mode spills to disk now. "auto" caps RAM and
# recomputes the overflow on the next miss (recompute beats dense-diff
# disk I/O — that spill once made Pass-2 98% disk wait), "ram" is
# unbounded RAM. Disk caching stays available as an explicit opt-in.
return self.mode == "disk"
def _disk_has_space(self, tensor_bytes):
"""True if writing tensor_bytes keeps the temp volume above the reserve."""
@@ -889,16 +894,19 @@ class _DiffCache:
def put(self, key, tensor):
if key in self._ram_store or key in self._disk_store:
return
cached = tensor.detach()
if cached.dtype == torch.float32:
# Halve fp32 to bound memory — cast on the source device so a GPU
# tensor transfers half the bytes (fp32→fp16 RN cast is
# bit-identical on GPU and CPU); 16-bit dtypes are kept as-is so
# cached diffs stay bit-identical to fresh ones (bf16 -> fp16
# would silently quantize)
cached = cached.half()
cached = cached.cpu()
# Store exactly what a recompute produces — no fp16 downcast — so a
# cache hit is numerically identical to recomputing the diff. Results
# are then independent of diff_cache_mode and of which diffs happened to
# fit in the cache (they match the "disabled" path). Pairs with auto
# mode's recompute-past-budget below; the bypass in _prepare_group_diffs
# already keeps cheap low-rank diffs out of here entirely.
cached = tensor.detach().cpu()
tensor_bytes = cached.nelement() * cached.element_size()
# auto mode caps RAM: decline past the budget so the diff recomputes on
# the next miss (cheaper and more deterministic than a disk spill).
if self.mode == "auto" and (self._ram_bytes + tensor_bytes) > self._ram_limit:
self._ram_overflow_skips = getattr(self, "_ram_overflow_skips", 0) + 1
return
if self._use_disk(tensor_bytes) and not self._disk_failed:
if not self._disk_has_space(tensor_bytes):
# Volume nearly full: skip caching this diff entirely (it will be
@@ -8511,7 +8519,7 @@ class LoRAAutoTunerSettings:
}),
"diff_cache_mode": (["disabled", "auto", "ram", "disk"], {
"default": "auto",
"tooltip": "Caches intermediate data to speed up the sweep. 'auto' (recommended): uses RAM first, spills to disk if needed. 'disabled': slower but uses no extra memory."
"tooltip": "Caches intermediate data to speed up the sweep. 'auto' (recommended): uses RAM up to diff_cache_ram_pct, then recomputes the rest (no disk). 'disabled': slower but uses no extra memory."
}),
"diff_cache_ram_pct": ("FLOAT", {
"default": 0.5, "min": 0.1, "max": 0.9, "step": 0.05,
@@ -8872,11 +8880,11 @@ class LoRAAutoTuner(LoRAOptimizer):
}),
"diff_cache_mode": (["disabled", "auto", "ram", "disk"], {
"default": "auto",
"tooltip": "Cache LoRA diffs across candidates to skip redundant computation. 'disabled' recomputes each time (slowest, no extra memory). 'auto' uses RAM up to diff_cache_ram_pct of free memory then spills to disk (recommended). 'ram' caches entirely in memory (~1.5GB SDXL, ~6GB Flux — fastest). 'disk' caches entirely to temp files (~1-10ms per diff vs 5-50ms to recompute). WARNING: ram/disk can use significant memory/storage on large models."
"tooltip": "Cache LoRA diffs across candidates to skip redundant computation. 'disabled' recomputes each time (no extra memory). 'auto' (recommended) uses RAM up to diff_cache_ram_pct of free memory then recomputes the overflow — low-rank diffs are recomputed anyway (cheaper than a cache read). 'ram' caches entirely in memory (fastest, unbounded). 'disk' caches to temp files (opt-in; only worth it for diffs expensive to recompute on a fast temp volume). Diffs are cached in fp32 so a cache hit is identical to a recompute. WARNING: ram/disk can use significant memory/storage on large models."
}),
"diff_cache_ram_pct": ("FLOAT", {
"default": 0.5, "min": 0.1, "max": 0.9, "step": 0.05,
"tooltip": "Fraction of free system RAM to use for diff cache in 'auto' mode. 0.5 = use up to 50% of available RAM before spilling to disk."
"tooltip": "Fraction of free system RAM to use for diff cache in 'auto' mode. 0.5 = use up to 50% of available RAM; diffs past that budget are recomputed on demand (not spilled to disk)."
}),
"vram_budget": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05,
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-optimizer"
description = "Auto-optimizer node for combining multiple LoRAs via diff-based merging with TIES conflict resolution"
version = "1.7.7"
version = "1.8.0"
license = { text = "GPL-3.0-only" }
dependencies = [
"scikit-learn>=1.3",
+23
View File
@@ -4464,6 +4464,29 @@ class TestDiffCacheRamLimit(unittest.TestCase):
cache.put(("alias_a", 0), torch.zeros(4, 4))
self.assertIn(("alias_a", 0), cache)
@unittest.skipIf(torch is None, "torch is not installed")
def test_cache_stores_fp32_bit_identical(self):
"""A cache hit must equal the stored diff bit-for-bit (fp32, no fp16
downcast) so it matches a fresh recompute — results then don't depend
on whether a diff was cached or recomputed."""
cache = lora_optimizer._DiffCache(mode="ram")
diff = torch.randn(8, 8, dtype=torch.float32)
cache.put(("alias_a", 0), diff)
got = cache.get(("alias_a", 0))
self.assertEqual(got.dtype, torch.float32)
torch.testing.assert_close(got, diff, rtol=0, atol=0)
@unittest.skipIf(torch is None, "torch is not installed")
def test_auto_mode_declines_past_ram_budget_no_disk(self):
"""auto mode recomputes (declines to cache) past the RAM budget instead
of spilling dense diffs to disk."""
fake_vm = types.SimpleNamespace(available=1024) # ram_limit = 512 bytes
with mock.patch("psutil.virtual_memory", return_value=fake_vm):
cache = lora_optimizer._DiffCache(mode="auto", ram_pct=0.5)
cache.put(("alias_a", 0), torch.zeros(64, 64, dtype=torch.float32)) # 16KB > 512
self.assertNotIn(("alias_a", 0), cache) # declined -> caller recomputes
self.assertEqual(len(cache._disk_store), 0) # NOT spilled to disk
@unittest.skipIf(torch is None, "torch is not installed in this environment")
class TestDiffCacheWarming(unittest.TestCase):