Files
CthomasdesignandClaude Sonnet 5 2ff0827d08 Initial commit: self-contained Krea-2-Turbo MLX nodes for ComfyUI
Krea2ModelLoader / Krea2LoRA / Krea2Generate / Krea2Unload nodes running
Krea-2-Turbo on Apple MLX in-process, returning ComfyUI IMAGE tensors.
The krea2_engine/ pipeline is vendored directly in this repo (no external
git dependency) — see NOTICE for the full attribution chain back to the
original Krea 2 model and the MLX port it builds on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 20:32:37 -04:00

232 lines
9.3 KiB
Python

"""LoRA support for the Krea-2-Turbo MLX transformer.
Krea-2 LoRAs are trained on **Krea-2-Raw** and (per Krea's guidance) express on **Turbo**, so
they apply directly to the quantized Turbo weights this pipeline already loads. A LoRA
`.safetensors` is framework-neutral — MLX reads it as-is, no conversion needed.
We add each adapter as a *parallel low-rank branch* rather than merging it into the base
weights, so it works on a quantized base untouched:
y = base(x) + Σ_i scale_i · (x · A_iᵀ) · B_iᵀ
`base` may be a plain `nn.Linear` or an `nn.QuantizedLinear` — we only ever *call* it, so the
quantization is irrelevant. Multiple LoRAs stack on the same layer (the sum above). The branch
is exact, toggleable, and reversible (`unload_loras` restores the original layers).
Key mapping (`_to_mlx_path`): the mapper strips a wrapper prefix (`base_model.model.` /
`transformer.` / …) and rewrites the two Krea-2 LoRA naming conventions onto the module tree —
the official Krea / diffusers names (`transformer_blocks.N.attn.to_q`, `img_in`, `final_layer`,
`ff.gate`, as in `krea/Krea-2-LoRA-*`) and the ai-toolkit / PEFT names that already match
(`blocks.N.attn.wq`, `mlp.down`, `first`, `last.linear`, `txtfusion...`). `A` is (rank, in),
`B` is (out, rank). Effective scale folds in alpha/rank from the file metadata
(`lora_alpha`/`lora_rank`), matching diffusers/PEFT conventions. Unmapped keys raise.
"""
from __future__ import annotations
import json
import struct
import mlx.core as mx
from mlx import nn
# LoRA wrapper prefixes (PEFT / ComfyUI / kohya) stripped before mapping to the module tree.
_PREFIXES = ("base_model.model.", "transformer.", "diffusion_model.", "lora_unet_")
# Krea-2 LoRAs ship in two naming conventions: the reference / ai-toolkit names that already match
# this port's module tree (e.g. `blocks.N.attn.wq`), and the official Krea / diffusers names
# (e.g. `transformer_blocks.N.attn.to_q`, as in `krea/Krea-2-LoRA-*` and the Comfy-Org repack).
# These ordered replacements rewrite the diffusers names to the module tree; they only match
# diffusers tokens, so reference-named keys pass through unchanged.
_TRANSLATE = (
("transformer_blocks", "blocks"),
("text_fusion", "txtfusion"),
("final_layer.linear", "last.linear"),
("img_in", "first"),
("time_embed.linear_1", "tmlp.0"),
("time_embed.linear_2", "tmlp.2"),
("time_mod_proj", "tproj.1"),
("txt_in.linear_1", "txtmlp.1"),
("txt_in.linear_2", "txtmlp.3"),
("attn.to_out.0", "attn.wo"),
("attn.to_q", "attn.wq"),
("attn.to_k", "attn.wk"),
("attn.to_v", "attn.wv"),
("attn.to_gate", "attn.gate"),
("ff.gate", "mlp.gate"),
("ff.up", "mlp.up"),
("ff.down", "mlp.down"),
)
def _to_mlx_path(path: str) -> str:
"""Map a LoRA's module path onto this port's module tree: strip a wrapper prefix, then rewrite
official-Krea/diffusers layer names to the reference names (a no-op for reference-named keys)."""
for pre in _PREFIXES:
if path.startswith(pre):
path = path[len(pre):]
break
for old, new in _TRANSLATE:
path = path.replace(old, new)
return path
class LoRALinear(nn.Module):
"""Wraps a (quantized or plain) linear layer and adds one or more low-rank branches.
Holds the original layer in `self.base` (kept frozen, quantization intact) plus parallel
lists of adapter matrices. Stacking = more than one (a, b, scale) triple.
"""
def __init__(self, base: nn.Module, a_list, b_list, scales):
super().__init__()
self.base = base
self.lora_a = list(a_list) # each (rank, in)
self.lora_b = list(b_list) # each (out, rank)
self.scales = list(scales) # python floats (not parameters)
def __call__(self, x: mx.array) -> mx.array:
y = self.base(x)
for a, b, s in zip(self.lora_a, self.lora_b, self.scales):
delta = (x @ a.T) @ b.T
y = y + (s * delta).astype(y.dtype)
return y
# --- module-tree navigation -------------------------------------------------
# Paths use '.' separators; integer parts index plain Python lists (e.g. `blocks`, `tmlp`),
# string parts are attributes. In-place list mutation and setattr are both picked up by MLX,
# which re-reads __dict__ on every forward / parameters() call.
def _resolve_parent(root, path: str):
parts = path.split(".")
obj = root
for p in parts[:-1]:
obj = obj[int(p)] if p.isdigit() else getattr(obj, p)
return obj, parts[-1]
def _get_submodule(root, path: str):
parent, last = _resolve_parent(root, path)
return parent[int(last)] if last.isdigit() else getattr(parent, last)
def _set_submodule(root, path: str, value) -> None:
parent, last = _resolve_parent(root, path)
if last.isdigit():
parent[int(last)] = value
else:
setattr(parent, last, value)
# --- file parsing -----------------------------------------------------------
def _read_metadata(path: str) -> dict:
"""Read the `__metadata__` block from a safetensors header (mx.load drops it)."""
with open(path, "rb") as f:
n = struct.unpack("<Q", f.read(8))[0]
header = json.loads(f.read(n))
return header.get("__metadata__", {}) or {}
def _normalize(weights: dict) -> dict:
"""Pair lora_A/lora_B tensors by target module path.
Returns {target_path: (A, B)}. Accepts the PEFT `lora_A`/`lora_B` and the kohya
`lora_down`/`lora_up` conventions; raises on anything it can't pair.
"""
out: dict[str, tuple] = {}
a_keys = {}
b_keys = {}
for k in weights:
if k.endswith(".alpha"):
continue # per-layer alpha handled via file metadata below
for suf, bucket in ((".lora_A.weight", a_keys), (".lora_down.weight", a_keys),
(".lora_B.weight", b_keys), (".lora_up.weight", b_keys)):
if k.endswith(suf):
bucket[_to_mlx_path(k[: -len(suf)])] = k
break
else:
raise ValueError(f"Unrecognized LoRA key (no lora_A/B/down/up suffix): {k}")
missing = set(a_keys) ^ set(b_keys)
if missing:
raise ValueError(f"LoRA has unpaired A/B tensors for: {sorted(missing)[:5]} …")
for tgt in a_keys:
out[tgt] = (weights[a_keys[tgt]], weights[b_keys[tgt]])
return out
def _alpha_factor(meta: dict) -> float:
"""Scale multiplier alpha/rank (diffusers/PEFT convention); 1.0 if unspecified."""
try:
alpha = float(meta.get("lora_alpha", meta.get("alpha", 0)))
rank = float(meta.get("lora_rank", meta.get("r", 0)))
if alpha and rank:
return alpha / rank
except (TypeError, ValueError):
pass
return 1.0
# --- public API -------------------------------------------------------------
def unload_loras(model, paths) -> int:
"""Restore the given wrapped layer paths to their original base layers.
`paths` is the list of target paths returned by `apply_loras` (MLX's `nn.Module` is a dict
subclass, so we unwrap by known path via getattr/setattr rather than walking the tree).
Returns how many layers were restored.
"""
removed = 0
for tgt in paths or []:
try:
cur = _get_submodule(model, tgt)
except (AttributeError, IndexError, KeyError, ValueError):
continue
while isinstance(cur, LoRALinear): # defensively peel any accidental nesting
cur = cur.base
removed += 1
_set_submodule(model, tgt, cur)
if removed:
mx.eval(model.parameters())
return removed
def apply_loras(model, specs, *, dtype=mx.bfloat16) -> list[str]:
"""Apply LoRAs to `model` in place. `specs` is a list of (path, scale); multiple LoRAs
targeting the same layer stack. Returns the list of wrapped target paths (pass to
`unload_loras` to revert). Raises if a target path can't be resolved, naming it."""
# gather: target_path -> ([A...], [B...], [scale...])
grouped: dict[str, tuple[list, list, list]] = {}
for path, scale in specs:
weights = mx.load(path)
factor = _alpha_factor(_read_metadata(path))
adapters = _normalize(weights)
eff = float(scale) * factor
for tgt, (a, b) in adapters.items():
slot = grouped.setdefault(tgt, ([], [], []))
slot[0].append(a.astype(dtype))
slot[1].append(b.astype(dtype))
slot[2].append(eff)
wrapped = []
for tgt, (a_list, b_list, scales) in grouped.items():
try:
base = _get_submodule(model, tgt)
except (AttributeError, IndexError, KeyError, ValueError) as e:
raise ValueError(f"LoRA targets '{tgt}' which doesn't exist in the model tree") from e
if isinstance(base, LoRALinear): # never nest — wrap the true base
base = base.base
if not isinstance(base, (nn.Linear, nn.QuantizedLinear)):
raise ValueError(f"LoRA target '{tgt}' is {type(base).__name__}, not a linear layer")
# sanity: A is (rank, in_features); fail loudly on an obvious shape mismatch
in_feats = a_list[0].shape[1]
for a, b in zip(a_list, b_list):
if a.shape[1] != in_feats or a.shape[0] != b.shape[1]:
raise ValueError(f"LoRA shape mismatch at '{tgt}': A{a.shape} B{b.shape}")
_set_submodule(model, tgt, LoRALinear(base, a_list, b_list, scales))
wrapped.append(tgt)
mx.eval(model.parameters())
return wrapped