feat(lora): add Lodestone delta mergers

This commit is contained in:
silveroxides
2026-09-04 15:55:42 +02:00
parent 9ef1b49b41
commit ebb29393aa
6 changed files with 511 additions and 4 deletions
+3
View File
@@ -21,6 +21,7 @@ from .nodes.merger import (
CheckpointThreeMerger, EmbeddingThreeMerger
)
from .nodes.consensus_merger import CWB_MERGER_NODES
from .nodes.lodestone_merger import LODESTONE_MERGER_NODES
from .nodes.model_analysis import MODEL_ANALYSIS_NODES
from .nodes.lora_extract_svd import (
LoRAExtractFixed, LoRAExtractRatio, LoRAExtractQuantile,
@@ -81,6 +82,8 @@ class ModelUtilsExtension(ComfyExtension):
CheckpointThreeMerger, EmbeddingThreeMerger,
# Dedicated CWB Mergers
*CWB_MERGER_NODES,
# Dedicated Lodestone LoRA Mergers
*LODESTONE_MERGER_NODES,
# Two-Model Analysis
*MODEL_ANALYSIS_NODES,
# LoRA Extraction
+37
View File
@@ -0,0 +1,37 @@
# Lodestone LoRA Mergers
The dedicated Lodestone nodes merge full logical LoRA deltas rather than
blending down and up factors independently. Stored alpha is normalized before
each factor pair is expanded. Results are saved as canonical full direct-difference
tensors and contain no factor or alpha tensors.
The 2-, 3-, and Multi-Merge nodes treat every selected LoRA as one equal-prior
slot, matching the supplied Lodestone calculation.
## Calculation Modes
- `sum`: sum the source deltas.
- `mean`: divide the summed delta by the number of active contributors.
- `slotnorm`: scale each source delta to the median source Frobenius norm, then
average.
- `normmatch`: retain the summed direction and set its Frobenius norm to the
median source norm.
- `slotnorm-normmatch`: equalize source norms, sum, then set the final norm to
the original median source norm.
A zero target or merged norm remains zero.
## Memory Behavior
UEL streams one logical layer at a time and writes each completed full-delta
tensor incrementally through the atomic writer. Dense tensors used for the
merge and Frobenius norms exist only within the active layer. A CUDA
out-of-memory error releases CUDA state and retries that logical layer on CPU.
## Fixed-Input Controls
LoRA 1 anchors output layers and filtering behavior. `skip` preserves LoRA 1
when another input lacks a required layer, `zeros` supplies an explicit zero
slot, and `error` aborts. Source factors may have different ranks when their
expanded dense shapes agree. Exclude and discard patterns apply to complete
logical layers.
+309
View File
@@ -0,0 +1,309 @@
import fnmatch
import gc
import re
import comfy.utils
import folder_paths
import torch
from comfy_api.latest import io
from tqdm import tqdm
from unifiedefficientloader import MemoryEfficientSafeOpen, transfer_to_gpu_pinned
from .artifact_paths import canonical_model_artifact_path
from .device_utils import cleanup_after_operation, estimate_model_size, prepare_for_large_operation
from .lora_alpha import normalize_lora_pair
from .lora_resize import build_lora_layer_map, canonical_lora_key, parse_lora_layers, select_output_dtype, validate_canonical_blocks
from .quantization_guard import inspect_low_bit_input
from .uel_io import AsyncTensorCursor, atomic_uel_writer
LODESTONE_METHODS = ("sum", "mean", "slotnorm", "normmatch", "slotnorm-normmatch")
_ALLOWED_ROLES = {
"down", "up", "alpha", "diff", "format", "down_suffix", "up_suffix",
"alpha_suffix",
}
def _matches(key, patterns, glob_mode):
entries = [entry.strip() for entry in patterns.splitlines() if entry.strip()]
if glob_mode:
return any(fnmatch.fnmatchcase(key, entry) for entry in entries)
return any(re.search(entry, key) is not None for entry in entries)
def factor_delta(down, up, layer):
if down.ndim == 2 and up.ndim == 2:
if down.shape[0] != up.shape[1]:
raise ValueError(f"Lodestone rank mismatch for '{layer}'.")
return up.float() @ down.float()
if down.ndim == 4 and up.ndim == 4 and tuple(up.shape[2:]) == (1, 1):
rank, channels, height, width = down.shape
if up.shape[1] != rank:
raise ValueError(f"Lodestone rank mismatch for '{layer}'.")
delta = up.reshape(up.shape[0], rank).float() @ down.reshape(rank, -1).float()
return delta.reshape(up.shape[0], channels, height, width)
raise ValueError(
f"Lodestone layer '{layer}' requires linear factors or convolutional "
"factors with a 1x1 up kernel."
)
def merge_lodestone_deltas(deltas, method):
if method not in LODESTONE_METHODS:
raise ValueError(f"Unknown Lodestone method '{method}'.")
if not deltas:
raise ValueError("Lodestone requires at least one delta.")
shape = tuple(deltas[0].shape)
if any(tuple(delta.shape) != shape for delta in deltas[1:]):
raise ValueError("Lodestone source deltas must have matching shapes.")
norms = torch.stack([torch.linalg.vector_norm(delta.float()) for delta in deltas])
target = torch.median(norms)
values = deltas
if method in {"slotnorm", "slotnorm-normmatch"}:
values = [
delta * (target / norm.clamp_min(1e-12))
for delta, norm in zip(deltas, norms)
]
merged = torch.stack(values).sum(dim=0)
if method in {"mean", "slotnorm"}:
merged /= len(values)
elif method in {"normmatch", "slotnorm-normmatch"}:
merged_norm = torch.linalg.vector_norm(merged.float())
if merged_norm > 0:
merged *= target / merged_norm.clamp_min(1e-12)
return merged
def _to_device(tensor, device):
if device.type == "cuda":
return transfer_to_gpu_pinned(tensor, device, torch.float32)
return tensor.to(device=device, dtype=torch.float32)
def _release_cuda_oom():
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _is_cuda_oom(error):
return isinstance(error, torch.cuda.OutOfMemoryError) or (
isinstance(error, RuntimeError) and "out of memory" in str(error).lower()
)
def _build_units(layer_map, infos, handlers, mismatch_mode, exclude_patterns, discard_patterns, glob_patterns):
units = []
stream_keys = [[] for _ in handlers]
for core, mapped in layer_map.items():
if not any(index == 0 for index, _ in mapped):
continue
output_key = canonical_lora_key(core, "diff")
if _matches(output_key, discard_patterns, glob_patterns):
continue
preserve = _matches(output_key, exclude_patterns, glob_patterns)
sources = []
for index, block in sorted(mapped):
roles = infos[index]["pairs"][block]
unsupported = set(roles) - _ALLOWED_ROLES
if unsupported:
raise ValueError(f"Lodestone layer '{block}' has unsupported roles: {', '.join(sorted(unsupported))}.")
if "down" in roles or "up" in roles:
if not all(role in roles for role in ("down", "up")):
raise ValueError(f"Lodestone layer '{block}' has an incomplete factor pair.")
keys = [roles["down"], roles["up"]]
if any(key in infos[index]["low_bit"] for key in keys):
raise ValueError(f"Lodestone cannot expand low-bit factors for '{block}'.")
if "alpha" in roles:
keys.append(roles["alpha"])
sources.append((index, block, "pair", keys))
elif "diff" in roles:
key = roles["diff"]
if key in infos[index]["low_bit"]:
raise ValueError(f"Lodestone cannot merge low-bit delta '{block}'.")
sources.append((index, block, "diff", [key]))
if preserve:
sources = [source for source in sources if source[0] == 0]
present = {source[0] for source in sources}
if len(present) < len(handlers) and not preserve:
if mismatch_mode == "error":
raise ValueError(f"Lodestone layer '{core}' is missing from one or more inputs.")
if mismatch_mode == "skip":
sources = [source for source in sources if source[0] == 0]
if not sources:
continue
dtypes = []
for index, _block, kind, keys in sources:
stream_keys[index].extend(keys)
dtypes.append(handlers[index].get_dtype(keys[0] if kind == "diff" else keys[1]))
units.append({
"core": core,
"output_key": output_key,
"sources": sources,
"missing": 0 if preserve or mismatch_mode != "zeros" else len(handlers) - len(present),
"dtypes": dtypes,
})
return units, stream_keys
def _process_on_device(unit, loaded, method, save_dtype, device):
deltas = []
for index, block, kind, keys in unit["sources"]:
if kind == "diff":
delta = _to_device(loaded[(index, keys[0])], device)
else:
down = loaded[(index, keys[0])]
up = loaded[(index, keys[1])]
alpha = loaded[(index, keys[2])] if len(keys) == 3 else None
down, up = normalize_lora_pair(down, up, alpha, layer=block)
delta = factor_delta(_to_device(down, device), _to_device(up, device), block)
deltas.append(delta)
if unit["missing"]:
deltas.extend(torch.zeros_like(deltas[0]) for _ in range(unit["missing"]))
merged = merge_lodestone_deltas(deltas, method)
dtype = select_output_dtype(unit["dtypes"], save_dtype, is_1d_diff=merged.ndim == 1)
return merged.to(dtype).cpu().contiguous()
def _process(unit, loaded, method, save_dtype, requested_device):
device = torch.device(requested_device)
try:
return _process_on_device(unit, loaded, method, save_dtype, device), False
except Exception as error:
if device.type != "cuda" or not _is_cuda_oom(error):
raise
_release_cuda_oom()
return _process_on_device(unit, loaded, method, save_dtype, torch.device("cpu")), True
def merge_lodestone_loras(lora_paths, method, device, save_dtype, output_filename,
mismatch_mode="skip", exclude_patterns="", discard_patterns="",
glob_patterns=False, verbose=True):
operation = "Lodestone LoRA Merge"
prepare_for_large_operation(sum(estimate_model_size(path) for path in lora_paths) * 2.5, torch.device(device))
handlers = [MemoryEfficientSafeOpen(path, low_memory=True) for path in lora_paths]
cursors = []
try:
infos = []
for index, handler in enumerate(handlers):
low_bit = inspect_low_bit_input(handler, f"LoRA {index + 1}", operation)
pairs, passthrough = parse_lora_layers(handler.keys())
infos.append({"pairs": pairs, "passthrough": passthrough, "low_bit": low_bit})
layer_map = build_lora_layer_map(infos, None)
validate_canonical_blocks(layer_map, operation)
units, stream_keys = _build_units(
layer_map, infos, handlers, mismatch_mode, exclude_patterns, discard_patterns, glob_patterns
)
cursors = [
AsyncTensorCursor(handler, keys, pin_memory=torch.device(device).type == "cuda")
for handler, keys in zip(handlers, stream_keys)
]
output_path, _ = canonical_model_artifact_path("loras", output_filename)
metadata = (handlers[0].metadata() or {}).copy()
metadata.update({"merge_method": f"lodestone:{method}", "alpha_normalized": "true", "output_representation": "full_direct_difference"})
fallback_count = 0
pbar = comfy.utils.ProgressBar(len(units))
with atomic_uel_writer(output_path, metadata) as writer, torch.no_grad():
for unit in tqdm(units, desc=f"Lodestone layers ({method})", unit="layers"):
loaded = {}
try:
for index, _block, _kind, keys in unit["sources"]:
for key in keys:
loaded[(index, key)] = cursors[index].take(key)
output, fallback = _process(unit, loaded, method, save_dtype, device)
writer.write_batch([(unit["output_key"], output)])
fallback_count += int(fallback)
del output
finally:
for index, key in list(loaded):
del loaded[(index, key)]
cursors[index].release(key)
loaded.clear()
pbar.update(1)
for cursor in cursors:
cursor.finish()
if verbose:
print(f"[{operation}] CUDA OOM CPU fallbacks: {fallback_count}")
print(f"[{operation}] Saved {len(units)} full-delta layers to {output_path}")
return output_path
finally:
for cursor in cursors:
cursor.close()
for handler in handlers:
handler.__exit__(None, None, None)
cleanup_after_operation()
def _controls(default_filename):
return [
io.Combo.Input("calc_mode", options=list(LODESTONE_METHODS), default="sum", tooltip="Dense delta-space Lodestone calculation."),
io.Combo.Input("mismatch_mode", options=["skip", "zeros", "error"], default="skip", tooltip="Preserve LoRA 1, insert zero deltas, or abort for missing layers."),
io.String.Input("output_filename", default=default_filename, tooltip="Filename without extension under ComfyUI's LoRA directory."),
io.Combo.Input("save_dtype", options=["fp32", "fp16", "bf16"], default="bf16", tooltip="Saved full-delta tensor dtype."),
io.Combo.Input("process_device", options=["cuda", "cpu"], default="cuda", tooltip="Dense processing device; CUDA OOM retries the layer on CPU."),
io.String.Input("exclude_patterns", default="", multiline=True, tooltip="Preserve matching logical layers from LoRA 1."),
io.String.Input("discard_patterns", default="", multiline=True, tooltip="Omit matching logical layers."),
io.Boolean.Input("glob_patterns", default=False, tooltip="Interpret filters as globs instead of regular expressions."),
]
def _execute(kwargs, count):
paths = [folder_paths.get_full_path_or_raise("loras", kwargs[f"lora_{index}"]) for index in range(1, count + 1)]
dtype = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16}[kwargs["save_dtype"]]
merge_lodestone_loras(paths, kwargs["calc_mode"], kwargs["process_device"], dtype,
kwargs["output_filename"], kwargs["mismatch_mode"],
kwargs["exclude_patterns"], kwargs["discard_patterns"], kwargs["glob_patterns"])
return canonical_model_artifact_path("loras", kwargs["output_filename"])[1]
class LodestoneLoRATwoMerger(io.ComfyNode):
@classmethod
def define_schema(cls):
options = folder_paths.get_filename_list("loras")
return io.Schema(node_id="LodestoneLoRATwoMerger", display_name="Lodestone Merge LoRAs (2)",
category="ModelUtils/LoRA/Merge/Lodestone",
inputs=[io.Combo.Input("lora_1", options=options, tooltip="First equal-prior LoRA and anchor."), io.Combo.Input("lora_2", options=options, tooltip="Second equal-prior LoRA."), *_controls("lodestone_merged_2_lora")],
outputs=[io.AnyType.Output(display_name="output_filename")], is_output_node=True)
@classmethod
def execute(cls, **kwargs):
return io.NodeOutput(_execute(kwargs, 2))
class LodestoneLoRAThreeMerger(io.ComfyNode):
@classmethod
def define_schema(cls):
options = folder_paths.get_filename_list("loras")
return io.Schema(node_id="LodestoneLoRAThreeMerger", display_name="Lodestone Merge LoRAs (3)",
category="ModelUtils/LoRA/Merge/Lodestone",
inputs=[io.Combo.Input("lora_1", options=options, tooltip="First equal-prior LoRA and anchor."), io.Combo.Input("lora_2", options=options, tooltip="Second equal-prior LoRA."), io.Combo.Input("lora_3", options=options, tooltip="Third equal-prior LoRA."), *_controls("lodestone_merged_3_lora")],
outputs=[io.AnyType.Output(display_name="output_filename")], is_output_node=True)
@classmethod
def execute(cls, **kwargs):
return io.NodeOutput(_execute(kwargs, 3))
class LodestoneLoRAMultiMerger(io.ComfyNode):
@classmethod
def define_schema(cls):
options = folder_paths.get_filename_list("loras")
optional = ["None", *options]
inputs = [io.Combo.Input("lora_count", options=[str(index) for index in range(2, 9)], default="2", tooltip="Number of consecutive equal-prior LoRAs."),
io.Combo.Input("lora_1", options=options, tooltip="First equal-prior LoRA and anchor."), io.Combo.Input("lora_2", options=options, tooltip="Second equal-prior LoRA.")]
inputs.extend(io.Combo.Input(f"lora_{index}", options=optional, default="None", tooltip="Optional equal-prior LoRA used when included by LoRA Count.") for index in range(3, 9))
inputs.extend(_controls("lodestone_merged_multi_lora"))
return io.Schema(node_id="LodestoneLoRAMultiMerger", display_name="Lodestone LoRA Multi-Merge",
category="ModelUtils/LoRA/Merge/Lodestone", inputs=inputs,
outputs=[io.AnyType.Output(display_name="output_filename")], is_output_node=True)
@classmethod
def execute(cls, **kwargs):
count = int(kwargs["lora_count"])
missing = [index for index in range(1, count + 1) if not kwargs[f"lora_{index}"] or kwargs[f"lora_{index}"] == "None"]
if missing:
raise ValueError(f"LoRA Count includes unselected input(s): {', '.join(map(str, missing))}.")
return io.NodeOutput(_execute(kwargs, count))
LODESTONE_MERGER_NODES = [LodestoneLoRATwoMerger, LodestoneLoRAThreeMerger, LodestoneLoRAMultiMerger]
+1
View File
@@ -13,6 +13,7 @@ MIGRATED_SAVE_MODULES = (
"consensus_merger.py",
"merger.py",
"lora_merger.py",
"lodestone_merger.py",
"lora_resize.py",
"dtype_conversion.py",
"lora_extract_svd.py",
+4 -4
View File
@@ -9,7 +9,7 @@ python_tests = [
paths = [
"nodes/artifact_paths.py", "nodes/dora_extract_wd.py",
"nodes/dora_learned_wd.py", "nodes/dtype_conversion.py",
"nodes/lora_extract_svd.py", "nodes/lora_merger.py",
"nodes/lodestone_merger.py", "nodes/lora_extract_svd.py", "nodes/lora_merger.py",
"nodes/lora_rename.py", "nodes/lora_resize.py", "nodes/merger.py",
"nodes/prunekeys.py", "nodes/renamekeys.py", "nodes/text_encoder_extract.py",
]
@@ -36,12 +36,12 @@ python_tests = ["tests/test_adaptive_extraction_pipeline.py", "tests/test_knee_p
[groups.lora_merge]
paths = [
"nodes/device_utils.py", "nodes/lora_alpha.py", "nodes/lora_merger.py",
"nodes/quantization_guard.py", "nodes/uel_io.py",
"nodes/device_utils.py", "nodes/lodestone_merger.py", "nodes/lora_alpha.py",
"nodes/lora_merger.py", "nodes/quantization_guard.py", "nodes/uel_io.py",
]
python_tests = [
"tests/test_lora_diff_dtype_contract.py", "tests/test_lora_merger_async.py",
"tests/test_lora_quantization_guard.py",
"tests/test_lodestone_merger.py", "tests/test_lora_quantization_guard.py",
]
[groups.lora_resize]
+157
View File
@@ -0,0 +1,157 @@
import importlib
import sys
import types
from pathlib import Path
import pytest
import torch
from unifiedefficientloader import IncrementalSafetensorsWriter, MemoryEfficientSafeOpen
REPO_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_NAME = "modelutils_lodestone_tests"
def _load_module(name):
if PACKAGE_NAME not in sys.modules:
package = types.ModuleType(PACKAGE_NAME)
package.__path__ = [str(REPO_ROOT)]
sys.modules[PACKAGE_NAME] = package
return importlib.import_module(f"{PACKAGE_NAME}.{name}")
@pytest.fixture(scope="module")
def lodestone():
return _load_module("nodes.lodestone_merger")
def _write(path, tensors):
with IncrementalSafetensorsWriter(str(path), max_workers=1) as writer:
writer.write_batch(list(tensors.items()))
def _read(path):
result = {}
with MemoryEfficientSafeOpen(str(path), low_memory=True) as handler:
stream = handler.async_stream(handler.keys(), batch_size=1, prefetch_batches=1, pin_memory=False)
for batch in stream:
for key, tensor in batch:
result[key] = tensor.clone()
handler.mark_processed(key)
return result
@pytest.mark.parametrize(
("method", "expected"),
[
("sum", torch.diag(torch.tensor([2.0, 4.0]))),
("mean", torch.diag(torch.tensor([1.0, 2.0]))),
("slotnorm", torch.eye(2)),
("normmatch", torch.diag(torch.tensor([2.0, 4.0])) * (2.0 / torch.sqrt(torch.tensor(20.0)))),
("slotnorm-normmatch", torch.eye(2) * torch.sqrt(torch.tensor(2.0))),
],
)
def test_methods_match_supplied_dense_math(lodestone, method, expected):
deltas = [torch.diag(torch.tensor([2.0, 0.0])), torch.diag(torch.tensor([0.0, 4.0]))]
torch.testing.assert_close(lodestone.merge_lodestone_deltas(deltas, method), expected)
def test_convolutional_factor_expansion(lodestone):
down = torch.arange(18, dtype=torch.float32).reshape(2, 1, 3, 3)
up = torch.tensor([[[[2.0]], [[-1.0]]]])
expected = (up.reshape(1, 2) @ down.reshape(2, -1)).reshape(1, 1, 3, 3)
torch.testing.assert_close(lodestone.factor_delta(down, up, "conv"), expected)
def test_zero_cancellation_remains_zero(lodestone):
delta = torch.tensor([[1.0, -2.0]])
result = lodestone.merge_lodestone_deltas([delta, -delta], "normmatch")
assert torch.count_nonzero(result) == 0
@pytest.mark.parametrize("count", [3, 8])
def test_three_and_multi_input_dense_means(lodestone, count):
deltas = [torch.full((2, 2), float(index)) for index in range(1, count + 1)]
expected = torch.full((2, 2), (count + 1) / 2)
torch.testing.assert_close(
lodestone.merge_lodestone_deltas(deltas, "mean"), expected
)
def test_layer_plan_enforces_missing_filters_and_low_bit(lodestone):
class Handler:
def get_dtype(self, _key):
return torch.float32
handlers = [Handler(), Handler()]
roles = {"down": "layer.down", "up": "layer.up"}
infos = [
{"pairs": {"layer": roles}, "low_bit": set()},
{"pairs": {}, "low_bit": set()},
]
layer_map = {"layer": [(0, "layer")]}
with pytest.raises(ValueError, match="missing"):
lodestone._build_units(layer_map, infos, handlers, "error", "", "", False)
units, _ = lodestone._build_units(layer_map, infos, handlers, "zeros", "", "", False)
assert units[0]["missing"] == 1
excluded, _ = lodestone._build_units(layer_map, infos, handlers, "zeros", "*.diff", "", True)
assert excluded[0]["missing"] == 0
discarded, _ = lodestone._build_units(layer_map, infos, handlers, "skip", "", "*.diff", True)
assert discarded == []
infos[0]["low_bit"] = {"layer.down"}
with pytest.raises(ValueError, match="low-bit"):
lodestone._build_units(layer_map, infos, handlers, "skip", "", "", False)
def test_streamed_merge_writes_only_alpha_normalized_full_delta(monkeypatch, tmp_path, lodestone):
first = tmp_path / "first.safetensors"
second = tmp_path / "second.safetensors"
_write(first, {
"diffusion_model.layer.lora_A.weight": torch.tensor([[1.0, 0.0]]),
"diffusion_model.layer.lora_B.weight": torch.tensor([[2.0], [0.0]]),
"diffusion_model.layer.alpha": torch.tensor(0.5),
})
_write(second, {
"diffusion_model.layer.lora_A.weight": torch.tensor([[0.0, 1.0]]),
"diffusion_model.layer.lora_B.weight": torch.tensor([[0.0], [4.0]]),
})
monkeypatch.setattr(lodestone.folder_paths, "models_dir", str(tmp_path))
monkeypatch.setattr(lodestone, "prepare_for_large_operation", lambda *args: None)
monkeypatch.setattr(lodestone, "cleanup_after_operation", lambda: None)
output = lodestone.merge_lodestone_loras(
[str(first), str(second)], "sum", "cpu", torch.float32, "dense", verbose=False
)
tensors = _read(output)
assert list(tensors) == ["diffusion_model.layer.diff"]
torch.testing.assert_close(tensors["diffusion_model.layer.diff"], torch.diag(torch.tensor([1.0, 4.0])))
def test_dedicated_nodes_do_not_modify_factor_merger_schema(lodestone):
standard = _load_module("nodes.lora_merger")
assert [node.define_schema().node_id for node in lodestone.LODESTONE_MERGER_NODES] == [
"LodestoneLoRATwoMerger", "LodestoneLoRAThreeMerger", "LodestoneLoRAMultiMerger"
]
standard_inputs = {item.id: item for item in standard.LoRAMultiMerge.define_schema().inputs}
assert standard_inputs["merge_mode"].options == ["concatenate", "weighted_sum"]
assert "calc_mode" not in standard_inputs
def test_dense_cuda_oom_retries_layer_on_cpu(monkeypatch, lodestone):
unit = {"dtypes": [torch.float32], "missing": 0, "sources": []}
calls = []
def process(_unit, _loaded, _method, _dtype, device):
calls.append(device.type)
if device.type == "cuda":
raise torch.cuda.OutOfMemoryError("CUDA out of memory")
return torch.ones(1)
monkeypatch.setattr(lodestone, "_process_on_device", process)
monkeypatch.setattr(lodestone, "_release_cuda_oom", lambda: None)
output, fallback = lodestone._process(unit, {}, "sum", torch.float32, "cuda")
assert fallback is True
assert calls == ["cuda", "cpu"]
assert torch.equal(output, torch.ones(1))