Files
ussoewwin cd3ff69a14 fix(cache): read precompiled cache as bytes, not mmap (Windows page error)
load_file() is mmap-based; a cache file with a readable header but damaged
data region crashes the whole process with a Windows fatal 'page error'
(uncatchable). Read the file as plain bytes and parse with safetensors.torch.load
so the same damage surfaces as OSError -> existing except returns {} -> caller
falls back to re-fuse. Verified: broken cache returns empty dict (no crash),
good cache still loads fully.
2026-08-26 11:40:30 +09:00

318 lines
12 KiB
Python

"""
lora_cache.py — Pre-compiled LoRA structural cache utilities.
Provides save/load helpers for caching the result of the expensive
classify → fuse pipeline in compose_loras_v2. The cache stores the
raw fused (A, B, alpha) tensor pairs *before* strength scaling so that
the strength slider still works correctly at inference time.
Cache layout on disk:
ComfyUI/models/SVDQLora/
<lora_stem>_precompiled.safetensors ← fused tensors
<lora_stem>_precompiled.safetensors.meta.json ← mtime sidecar
Tensor key format inside the .safetensors file:
"<module_key>__A" → fused A tensor [rank, in_features]
"<module_key>__B" → fused B tensor [out_features, rank] (NOT strength-scaled)
"<module_key>__alpha" → alpha scalar as float32 1-D tensor (optional, omitted if None)
Sidecar JSON format:
{
"source_mtime": <float>, # os.path.getmtime() of the original .safetensors
"source_path": <str> # absolute path of original LoRA (informational)
}
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
from pathlib import Path
from typing import Dict, Optional, Tuple
import torch
logger = logging.getLogger(__name__)
# Separator used to encode module_key and tensor role inside a flat safetensors dict.
# Double-underscore is safe because module keys use dot-notation (e.g. "transformer_blocks.0.attn.to_qkv").
_SEP = "__"
# Roles stored per module entry
_ROLE_A = "A"
_ROLE_B = "B"
_ROLE_ALPHA = "alpha"
# ---------------------------------------------------------------------------
# Directory helpers
# ---------------------------------------------------------------------------
def get_cache_dir(comfy_base_dir: str) -> Path:
"""
Return (and create if needed) the dedicated SVDQLora cache directory.
Args:
comfy_base_dir: Root of the ComfyUI installation, e.g. ``"C:/ComfyUI"``.
Returns:
``Path`` pointing to ``<comfy_base_dir>/models/SVDQLora/``.
"""
cache_dir = Path(comfy_base_dir) / "models" / "SVDQLora"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def get_cache_path(lora_path: str | Path, cache_dir: Path) -> Path:
"""
Derive the cache .safetensors path for a given LoRA source file.
The cache filename includes a short hash of the LoRA's parent directory so
that two LoRAs with identical filenames in different subdirectories (e.g.
``Qwen-Edit/v1/lora.safetensors`` vs ``Qwen-Edit/v2/lora.safetensors``)
never collide to the same cache entry.
Args:
lora_path: Full path to the original LoRA file.
cache_dir: Directory returned by :func:`get_cache_dir`.
Returns:
``Path`` like ``<cache_dir>/<stem>_<dir_hash8>_precompiled.safetensors``.
"""
lora_path = Path(lora_path)
stem = lora_path.stem
# Hash the parent directory path so same-named LoRAs in different dirs
# produce distinct cache keys.
parent_hash = hashlib.sha256(str(lora_path.parent).encode()).hexdigest()[:8]
return cache_dir / f"{stem}_{parent_hash}_precompiled.safetensors"
def _get_meta_path(cache_path: Path) -> Path:
"""Return the sidecar JSON path for a given cache file."""
return cache_path.with_suffix(cache_path.suffix + ".meta.json")
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def is_cache_valid(lora_path: str | Path, cache_path: Path) -> bool:
"""
Return ``True`` iff a usable, up-to-date cache exists for *lora_path*.
Validity criteria:
1. ``cache_path`` exists on disk.
2. The mtime sidecar JSON exists.
3. The recorded ``source_mtime`` matches the current mtime of *lora_path*.
A missing or unreadable sidecar is treated as invalid (forces re-fuse).
Args:
lora_path: Full path to the original LoRA file.
cache_path: Path as returned by :func:`get_cache_path`.
"""
if not cache_path.is_file():
return False
meta_path = _get_meta_path(cache_path)
if not meta_path.is_file():
logger.debug(f"[CACHE] No sidecar for {cache_path.name}, treating as invalid.")
return False
try:
with open(meta_path, "r", encoding="utf-8") as fh:
meta = json.load(fh)
recorded_mtime: float = meta["source_mtime"]
except (KeyError, json.JSONDecodeError, OSError) as exc:
logger.warning(f"[CACHE] Failed to read sidecar {meta_path.name}: {exc}. Treating as invalid.")
return False
try:
current_mtime = os.path.getmtime(str(lora_path))
except OSError as exc:
logger.warning(f"[CACHE] Cannot stat source LoRA {lora_path}: {exc}. Treating cache as invalid.")
return False
if abs(current_mtime - recorded_mtime) > 1e-3:
logger.info(
f"[CACHE] Stale: source mtime changed for {Path(lora_path).name}. "
f"Recorded={recorded_mtime:.3f}, current={current_mtime:.3f}. "
"Will re-fuse and overwrite cache."
)
return False
return True
# ---------------------------------------------------------------------------
# Save
# ---------------------------------------------------------------------------
def save_precompiled(
processed_groups: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]],
lora_path: str | Path,
cache_path: Path,
) -> None:
"""
Serialise the fused ``processed_groups`` dict to *cache_path*.
``processed_groups`` has the shape produced by the classify+fuse stage of
``compose_loras_v2``: ``{ module_key: (A, B, alpha) }``.
All tensors are moved to CPU before saving (the cache is always CPU-side;
device placement happens later when ``_apply_lora_to_module`` runs).
Args:
processed_groups: Fused tensor pairs from the classify+fuse stage.
lora_path: Full path to the original LoRA file (used for mtime).
cache_path: Destination .safetensors file.
"""
# Lazy import so safetensors is only required when actually saving.
try:
from safetensors.torch import save_file as st_save_file
except ImportError as exc:
logger.error(f"[CACHE] Cannot save precompiled cache: safetensors not available. {exc}")
return
if not processed_groups:
logger.warning("[CACHE] processed_groups is empty — nothing to save.")
return
flat: Dict[str, torch.Tensor] = {}
for module_key, (A, B, alpha) in processed_groups.items():
if A is None or B is None:
logger.debug(f"[CACHE] Skipping {module_key}: A or B is None.")
continue
# Validate tensor shapes before saving
if A.ndim != 2 or B.ndim != 2:
logger.warning(
f"[CACHE] Skipping {module_key}: expected 2-D tensors, "
f"got A.ndim={A.ndim}, B.ndim={B.ndim}."
)
continue
# Move to CPU; safetensors does not accept CUDA tensors.
flat[f"{module_key}{_SEP}{_ROLE_A}"] = A.detach().cpu().contiguous()
flat[f"{module_key}{_SEP}{_ROLE_B}"] = B.detach().cpu().contiguous()
if alpha is not None:
# Normalise to a 1-D float32 scalar tensor for portability.
if isinstance(alpha, torch.Tensor):
flat[f"{module_key}{_SEP}{_ROLE_ALPHA}"] = alpha.detach().cpu().float().reshape(1)
else:
flat[f"{module_key}{_SEP}{_ROLE_ALPHA}"] = torch.tensor([float(alpha)], dtype=torch.float32)
if not flat:
logger.warning("[CACHE] No valid tensors to write after filtering — cache not saved.")
return
try:
# Write to a temp file first; atomically rename to the final path.
# This prevents a partially-written file from being mistaken as valid
# if ComfyUI is interrupted mid-write (which would corrupt the cache).
tmp_path = cache_path.with_suffix(".safetensors.tmp")
st_save_file(flat, str(tmp_path))
os.replace(str(tmp_path), str(cache_path))
logger.info(f"[CACHE SAVE] Written {len(processed_groups)} module entries → {cache_path}")
except Exception as exc:
logger.error(f"[CACHE] Failed to write {cache_path}: {exc}")
# Clean up temp file if it was created
try:
tmp_path = cache_path.with_suffix(".safetensors.tmp")
tmp_path.unlink(missing_ok=True)
except Exception:
pass
return
# Write mtime sidecar
meta_path = _get_meta_path(cache_path)
try:
source_mtime = os.path.getmtime(str(lora_path))
meta = {
"source_mtime": source_mtime,
"source_path": str(Path(lora_path).resolve()),
}
with open(meta_path, "w", encoding="utf-8") as fh:
json.dump(meta, fh, indent=2)
logger.debug(f"[CACHE] Sidecar written: {meta_path.name}")
except OSError as exc:
logger.warning(
f"[CACHE] Could not write mtime sidecar {meta_path.name}: {exc}. "
"Cache will be treated as invalid on next run."
)
# ---------------------------------------------------------------------------
# Load
# ---------------------------------------------------------------------------
def load_precompiled(
cache_path: Path,
) -> Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]]:
"""
Load a precompiled cache file and reconstruct the ``processed_groups`` dict.
Returns:
``{ module_key: (A, B, alpha_or_None) }`` — identical structure to
what ``compose_loras_v2`` produces after the classify+fuse stage.
Returns an empty dict on any error so the caller can fall back to
a full re-fuse gracefully.
"""
try:
from safetensors.torch import load as st_load_bytes
except ImportError as exc:
logger.error(f"[CACHE] Cannot load precompiled cache: safetensors not available. {exc}")
return {}
try:
# Read as plain bytes instead of ``load_file`` (mmap). A damaged cache
# file can still expose a readable header but fail during the mmap'd
# tensor read, which crashes the whole process with a Windows fatal
# "page error" that Python cannot catch. A plain read surfaces the same
# damage as an ordinary OSError, so the existing except below falls
# back to a full re-fuse gracefully.
with open(cache_path, "rb") as fh:
data = fh.read()
flat: Dict[str, torch.Tensor] = st_load_bytes(data)
except Exception as exc:
logger.error(f"[CACHE] Failed to load {cache_path}: {exc}")
return {}
# Reconstruct { module_key: { "A": T, "B": T, "alpha": T|None } }
raw: Dict[str, Dict[str, torch.Tensor]] = {}
for flat_key, tensor in flat.items():
# flat_key format: "<module_key>__<role>"
# module_key itself may contain dots but never "__"
sep_idx = flat_key.rfind(_SEP)
if sep_idx == -1:
logger.warning(f"[CACHE] Unrecognised flat key format: '{flat_key}' — skipping.")
continue
module_key = flat_key[:sep_idx]
role = flat_key[sep_idx + len(_SEP):]
raw.setdefault(module_key, {})[role] = tensor
processed_groups: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
for module_key, parts in raw.items():
A = parts.get(_ROLE_A)
B = parts.get(_ROLE_B)
alpha_tensor = parts.get(_ROLE_ALPHA) # 1-D float32 scalar tensor or None
if A is None or B is None:
logger.warning(f"[CACHE] Incomplete entry for '{module_key}' (missing A or B) — skipping.")
continue
# Convert alpha back to the same form compose_loras_v2 expects:
# a torch.Tensor scalar (not a Python float).
alpha: Optional[torch.Tensor] = None
if alpha_tensor is not None:
alpha = alpha_tensor # keep as tensor; compose_loras_v2 calls .item() on it
processed_groups[module_key] = (A, B, alpha)
logger.info(f"[CACHE LOAD] Loaded {len(processed_groups)} module entries ← {cache_path.name}")
return processed_groups