Skip to content

Commit 89cba85

Browse files
authored
fix: fix LORA key mismatch between FAL.AI and Nunchaku (#557)
* Fix FLUX.1-Kontext LoRA support and dimension mismatch issues - Added convert_keys_to_diffusers() for ComfyUI/PEFT format conversion - Fixed dimension mismatch in LoRA weight concatenation - Added preprocessing for single_blocks LoRA structure - Added comprehensive test suite for Kontext LoRA - Added example script for FLUX.1-Kontext with LoRA Fixes #354 * lint * FAL.AI and relight-kontext-lora patch
1 parent e4fe254 commit 89cba85

4 files changed

Lines changed: 554 additions & 8 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import torch
2+
from diffusers import FluxKontextPipeline
3+
from diffusers.utils import load_image
4+
5+
from nunchaku import NunchakuFluxTransformer2dModel
6+
from nunchaku.utils import get_precision
7+
8+
transformer = NunchakuFluxTransformer2dModel.from_pretrained(
9+
f"nunchaku-tech/nunchaku-flux.1-kontext-dev/svdq-{get_precision()}_r32-flux.1-kontext-dev.safetensors"
10+
)
11+
12+
pipeline = FluxKontextPipeline.from_pretrained(
13+
"black-forest-labs/FLUX.1-Kontext-dev", transformer=transformer, torch_dtype=torch.bfloat16
14+
).to("cuda")
15+
16+
image = load_image(
17+
"https://huggingface.co/datasets/nunchaku-tech/test-data/resolve/main/ComfyUI-nunchaku/inputs/monalisa.jpg"
18+
).convert("RGB")
19+
20+
### LoRA Related Code ###
21+
transformer.update_lora_params(
22+
"nunchaku-tech/nunchaku-test-models/relight-kontext-lora-single-caption_comfy.safetensors"
23+
# "linoyts/relight-kontext-lora-single-caption/relight-kontext-lora-single-caption.safetensors"
24+
) # Path to your LoRA safetensors, can also be a remote HuggingFace path
25+
transformer.set_lora_strength(1) # Your LoRA strength here
26+
### End of LoRA Related Code ###
27+
28+
prompt = "neon light, city"
29+
image = pipeline(image=image, prompt=prompt, generator=torch.Generator().manual_seed(23), guidance_scale=2.5).images[0]
30+
image.save("flux-kontext-dev.png")

nunchaku/lora/flux/diffusers_converter.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,74 @@ def handle_kohya_lora(state_dict: dict[str, torch.Tensor]) -> dict[str, torch.Te
7474
return new_state_dict
7575

7676

77+
def convert_peft_to_comfyui(state_dict: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
78+
"""
79+
Convert PEFT format (base_model.model.*) to ComfyUI format (lora_unet_*).
80+
81+
Mapping rules:
82+
- base_model.model.double_blocks.X.img_attn.proj → lora_unet_double_blocks_X_img_attn_proj
83+
- base_model.model.single_blocks.X.linear1 → lora_unet_single_blocks_X_linear1
84+
- base_model.model.final_layer.linear → lora_unet_final_layer_linear
85+
- lora_A/lora_B → lora_down/lora_up
86+
87+
Parameters
88+
----------
89+
state_dict : dict[str, torch.Tensor]
90+
LoRA weights in PEFT format
91+
92+
Returns
93+
-------
94+
dict[str, torch.Tensor]
95+
LoRA weights in ComfyUI format
96+
"""
97+
converted_dict = {}
98+
99+
for key, value in state_dict.items():
100+
new_key = key
101+
102+
if key.startswith("base_model.model."):
103+
# Remove base_model.model. prefix
104+
new_key = key.replace("base_model.model.", "")
105+
106+
# Convert to ComfyUI format with underscores
107+
# Handle double_blocks
108+
if "double_blocks" in new_key:
109+
# Replace dots with underscores within the block structure
110+
# e.g., double_blocks.0.img_attn.proj → double_blocks_0_img_attn_proj
111+
new_key = new_key.replace("double_blocks.", "lora_unet_double_blocks_")
112+
# Replace remaining dots with underscores
113+
new_key = new_key.replace(".", "_")
114+
115+
# Handle single_blocks
116+
elif "single_blocks" in new_key:
117+
new_key = new_key.replace("single_blocks.", "lora_unet_single_blocks_")
118+
# Special handling for modulation.lin → modulation_lin
119+
new_key = new_key.replace("modulation.lin", "modulation_lin")
120+
# Replace remaining dots with underscores
121+
new_key = new_key.replace(".", "_")
122+
123+
# Handle final_layer
124+
elif "final_layer" in new_key:
125+
new_key = new_key.replace("final_layer.linear", "lora_unet_final_layer_linear")
126+
# Replace remaining dots with underscores
127+
new_key = new_key.replace(".", "_")
128+
129+
else:
130+
# For any other keys, add lora_unet_ prefix and replace dots
131+
new_key = "lora_unet_" + new_key.replace(".", "_")
132+
133+
# Convert lora_A/lora_B to lora_down/lora_up
134+
new_key = new_key.replace("_lora_A_weight", ".lora_down.weight")
135+
new_key = new_key.replace("_lora_B_weight", ".lora_up.weight")
136+
137+
converted_dict[new_key] = value
138+
139+
if key != new_key:
140+
logger.debug(f"Converted: {key}{new_key}")
141+
142+
return converted_dict
143+
144+
77145
def to_diffusers(input_lora: str | dict[str, torch.Tensor], output_path: str | None = None) -> dict[str, torch.Tensor]:
78146
"""
79147
Convert LoRA weights to Diffusers format, which will later be converted to Nunchaku format.
@@ -102,6 +170,25 @@ def to_diffusers(input_lora: str | dict[str, torch.Tensor], output_path: str | N
102170
if v.dtype not in [torch.float64, torch.float32, torch.bfloat16, torch.float16]:
103171
tensors[k] = v.to(torch.bfloat16)
104172

173+
# Apply Kontext-specific key conversion for both PEFT format and ComfyUI format
174+
# This handles LoRAs with base_model.model.* prefix or lora_unet_* prefix (including final_layer_linear)
175+
if any(k.startswith("base_model.model.") for k in tensors.keys()):
176+
logger.info("Converting PEFT format to ComfyUI format")
177+
return convert_peft_to_comfyui(tensors)
178+
179+
# Handle LoRAs that only have final_layer_linear without adaLN_modulation
180+
# This is a workaround for incomplete final layer LoRAs
181+
final_keys = [k for k in tensors.keys() if "final_layer" in k]
182+
has_linear = any("final_layer_linear" in k for k in final_keys)
183+
has_adaln = any("final_layer_adaLN_modulation" in k for k in final_keys)
184+
185+
if has_linear and not has_adaln:
186+
for key in list(tensors.keys()):
187+
if "final_layer_linear" in key:
188+
adaln_key = key.replace("final_layer_linear", "final_layer_adaLN_modulation_1")
189+
if adaln_key not in tensors:
190+
tensors[adaln_key] = torch.zeros_like(tensors[key])
191+
105192
new_tensors, alphas = FluxLoraLoaderMixin.lora_state_dict(tensors, return_alphas=True)
106193
new_tensors = convert_unet_state_dict_to_peft(new_tensors)
107194

nunchaku/lora/flux/nunchaku_converter.py

Lines changed: 209 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -306,11 +306,63 @@ def convert_to_nunchaku_transformer_block_lowrank_dict( # noqa: C901
306306
logger.debug(" - Using original LoRA")
307307
lora = orig_lora
308308
else:
309-
lora = (
310-
torch.cat([orig_lora[0], extra_lora[0].to(orig_lora[0].dtype)], dim=0), # [r, c]
311-
torch.cat([orig_lora[1], extra_lora[1].to(orig_lora[1].dtype)], dim=1), # [c, r]
312-
)
313-
logger.debug(f" - Merging original and extra LoRA (rank: {lora[0].shape[0]})")
309+
try:
310+
lora = (
311+
torch.cat([orig_lora[0], extra_lora[0].to(orig_lora[0].dtype)], dim=0), # [r, c]
312+
torch.cat([orig_lora[1], extra_lora[1].to(orig_lora[1].dtype)], dim=1), # [c, r]
313+
)
314+
logger.debug(f" - Merging original and extra LoRA (rank: {lora[0].shape[0]})")
315+
except RuntimeError as e:
316+
if "Sizes of tensors must match" in str(e):
317+
# Handle various dimension mismatch cases for LoRA
318+
logger.debug(
319+
f" - Dimension mismatch detected: orig_lora[1]={orig_lora[1].shape}, extra_lora[1]={extra_lora[1].shape}"
320+
)
321+
322+
# Handle dimension mismatch by using only the properly sized portion of extra_lora
323+
# instead of trying to concatenate mismatched dimensions
324+
325+
# Case 1: single_blocks linear1 [21504] -> mlp_fc1 [12288]
326+
if extra_lora[1].shape[1] == 21504 and orig_lora[1].shape[1] == 12288:
327+
# Use only the first 12288 dimensions from the 21504 extra LoRA
328+
extra_lora_up_split = extra_lora[1][:, :12288].clone()
329+
extra_lora_down = extra_lora[0].clone()
330+
# logger.debug(f" - Dimension fix 21504->12288: using split extra LoRA instead of merge")
331+
332+
# Use the split extra LoRA instead of concatenating
333+
lora = (extra_lora_down.to(orig_lora[0].dtype), extra_lora_up_split.to(orig_lora[1].dtype))
334+
335+
# Case 2: transformer_blocks with different MLP dimensions (27648 -> 9216)
336+
elif extra_lora[1].shape[1] == 27648 and orig_lora[1].shape[1] == 9216:
337+
# Use only the first 9216 dimensions from the 27648 extra LoRA
338+
extra_lora_up_split = extra_lora[1][:, :9216].clone()
339+
extra_lora_down = extra_lora[0].clone()
340+
# logger.debug(f" - Dimension fix 27648->9216: using split extra LoRA instead of merge")
341+
342+
# Use the split extra LoRA instead of concatenating
343+
lora = (extra_lora_down.to(orig_lora[0].dtype), extra_lora_up_split.to(orig_lora[1].dtype))
344+
345+
# Case 3: Other dimension ratios - try to find a reasonable split
346+
elif extra_lora[1].shape[1] > orig_lora[1].shape[1]:
347+
# Use only what we need from extra LoRA
348+
target_dim = orig_lora[1].shape[1]
349+
extra_lora_up_split = extra_lora[1][:, :target_dim].clone()
350+
extra_lora_down = extra_lora[0].clone()
351+
# logger.debug(
352+
# f" - Dimension fix {extra_lora[1].shape[1]}->{target_dim}: using truncated extra LoRA"
353+
# )
354+
355+
# Use the truncated extra LoRA instead of concatenating
356+
lora = (extra_lora_down.to(orig_lora[0].dtype), extra_lora_up_split.to(orig_lora[1].dtype))
357+
358+
else:
359+
# For cases where extra LoRA has fewer dimensions, use original LoRA only
360+
# logger.warning(
361+
# f" - Cannot split extra LoRA {extra_lora[1].shape[1]}->{orig_lora[1].shape[1]}, using original only"
362+
# )
363+
lora = orig_lora
364+
else:
365+
raise e
314366
# endregion
315367
if lora is not None:
316368
if convert_map[converted_local_name] == "adanorm_single":
@@ -343,6 +395,109 @@ def convert_to_nunchaku_transformer_block_lowrank_dict( # noqa: C901
343395
return converted
344396

345397

398+
def preprocess_single_blocks_lora(
399+
extra_lora_dict: dict[str, torch.Tensor], candidate_block_name: str
400+
) -> dict[str, torch.Tensor]:
401+
"""
402+
Preprocess LoRA weights from single_blocks format to match single_transformer_blocks structure.
403+
404+
This function handles the architectural mismatch between old and new models:
405+
- Old single_blocks: linear1 (fused 21504-dim layer) and linear2
406+
- New single_transformer_blocks: mlp_fc1 (12288-dim), qkv_proj (9216-dim), and mlp_fc2
407+
408+
The linear1 layer in the old architecture combines two functions:
409+
1. MLP projection (first 12288 dimensions)
410+
2. QKV projection for attention (last 9216 dimensions)
411+
412+
These are split into separate layers in the new architecture.
413+
"""
414+
processed_dict = extra_lora_dict.copy()
415+
416+
# Find all single_transformer_blocks keys that need preprocessing
417+
single_blocks_keys = [k for k in extra_lora_dict.keys() if "single_transformer_blocks" in k and "linear" in k]
418+
419+
logger.debug(f"Preprocessing LoRA for candidate: {candidate_block_name}")
420+
logger.debug(f"All keys in extra_lora_dict: {list(extra_lora_dict.keys())[:10]}...") # Show first 10 keys
421+
logger.debug(f"Found single_transformer_blocks keys: {single_blocks_keys[:5]}...") # Show first 5 keys
422+
423+
if single_blocks_keys:
424+
logger.debug(f"Found single_transformer_blocks LoRA keys, preprocessing for candidate: {candidate_block_name}")
425+
426+
# The candidate_block_name is already "single_transformer_blocks.0"
427+
# Look for linear1 and linear2 keys with this exact name
428+
linear1_lora_A_key = f"{candidate_block_name}.linear1.lora_A.weight"
429+
linear1_lora_B_key = f"{candidate_block_name}.linear1.lora_B.weight"
430+
linear2_lora_A_key = f"{candidate_block_name}.linear2.lora_A.weight"
431+
linear2_lora_B_key = f"{candidate_block_name}.linear2.lora_B.weight"
432+
433+
logger.debug(f"Looking for keys: {linear1_lora_B_key}")
434+
logger.debug(
435+
f"Available keys matching pattern: {[k for k in extra_lora_dict.keys() if candidate_block_name in k][:5]}..."
436+
)
437+
438+
if linear1_lora_B_key in extra_lora_dict:
439+
linear1_lora_A = extra_lora_dict[linear1_lora_A_key]
440+
linear1_lora_B = extra_lora_dict[linear1_lora_B_key]
441+
442+
# Check if this is the problematic 21504 dimension case
443+
if linear1_lora_B.shape[0] == 21504:
444+
logger.debug(
445+
f"Splitting linear1 LoRA weights: [21504, {linear1_lora_B.shape[1]}] -> "
446+
f"mlp_fc1 [12288, {linear1_lora_B.shape[1]}] + qkv_proj [9216, {linear1_lora_B.shape[1]}]"
447+
)
448+
449+
# Split linear1.lora_B [21504, rank] into two parts:
450+
# 1. First 12288 dimensions -> mlp_fc1
451+
# 2. Last 9216 dimensions (12288:21504) -> qkv_proj
452+
mlp_fc1_lora_B = linear1_lora_B[:12288, :].clone()
453+
qkv_proj_lora_B = linear1_lora_B[12288:21504, :].clone()
454+
455+
# The lora_A weight is reused for both new layers
456+
# since it represents the down-projection from the input
457+
mlp_fc1_lora_A = linear1_lora_A.clone()
458+
qkv_proj_lora_A = linear1_lora_A.clone()
459+
460+
# Map to new architecture:
461+
# 1. proj_mlp corresponds to mlp_fc1
462+
processed_dict[f"{candidate_block_name}.proj_mlp.lora_A.weight"] = mlp_fc1_lora_A
463+
processed_dict[f"{candidate_block_name}.proj_mlp.lora_B.weight"] = mlp_fc1_lora_B
464+
465+
# 2. Map the QKV part to the attention layers
466+
# Note: In the new architecture, this maps to attn.to_q, attn.to_k, attn.to_v
467+
# which get fused into qkv_proj during the conversion
468+
processed_dict[f"{candidate_block_name}.attn.to_q.lora_A.weight"] = qkv_proj_lora_A
469+
processed_dict[f"{candidate_block_name}.attn.to_q.lora_B.weight"] = qkv_proj_lora_B[
470+
:3072, :
471+
] # Q projection
472+
processed_dict[f"{candidate_block_name}.attn.to_k.lora_A.weight"] = qkv_proj_lora_A
473+
processed_dict[f"{candidate_block_name}.attn.to_k.lora_B.weight"] = qkv_proj_lora_B[
474+
3072:6144, :
475+
] # K projection
476+
processed_dict[f"{candidate_block_name}.attn.to_v.lora_A.weight"] = qkv_proj_lora_A
477+
processed_dict[f"{candidate_block_name}.attn.to_v.lora_B.weight"] = qkv_proj_lora_B[
478+
6144:9216, :
479+
] # V projection
480+
481+
# Handle linear2 -> mlp_fc2 mapping
482+
if linear2_lora_B_key in extra_lora_dict:
483+
linear2_lora_A = extra_lora_dict[linear2_lora_A_key]
484+
linear2_lora_B = extra_lora_dict[linear2_lora_B_key]
485+
486+
# Map linear2 to proj_out.linears.1 (mlp_fc2)
487+
processed_dict[f"{candidate_block_name}.proj_out.linears.1.lora_A.weight"] = linear2_lora_A
488+
processed_dict[f"{candidate_block_name}.proj_out.linears.1.lora_B.weight"] = linear2_lora_B
489+
490+
# Remove original keys
491+
processed_dict.pop(linear2_lora_A_key, None)
492+
processed_dict.pop(linear2_lora_B_key, None)
493+
494+
# Remove original linear1 keys
495+
processed_dict.pop(linear1_lora_A_key, None)
496+
processed_dict.pop(linear1_lora_B_key, None)
497+
498+
return processed_dict
499+
500+
346501
def convert_to_nunchaku_flux_single_transformer_block_lowrank_dict(
347502
orig_state_dict: dict[str, torch.Tensor],
348503
extra_lora_dict: dict[str, torch.Tensor],
@@ -381,6 +536,10 @@ def convert_to_nunchaku_flux_single_transformer_block_lowrank_dict(
381536
- Handles both fused and unfused attention projections (e.g., qkv).
382537
- Applies special packing for W4A16 linear layers (e.g., ``"adanorm_single"`` and ``"adanorm_zero"``).
383538
"""
539+
540+
# Preprocess single_blocks LoRA structure if needed
541+
# extra_lora_dict = preprocess_single_blocks_lora(extra_lora_dict, candidate_block_name)
542+
384543
if f"{candidate_block_name}.proj_out.lora_A.weight" in extra_lora_dict:
385544
assert f"{converted_block_name}.out_proj.qweight" in orig_state_dict
386545
assert f"{converted_block_name}.mlp_fc2.qweight" in orig_state_dict
@@ -530,16 +689,58 @@ def convert_to_nunchaku_flux_lowrank_dict(
530689
orig_state_dict = base_model
531690

532691
if isinstance(lora, str):
533-
extra_lora_dict = load_state_dict_in_safetensors(lora, filter_prefix="transformer.")
692+
# Load the LoRA - check if it has transformer prefix
693+
temp_dict = load_state_dict_in_safetensors(lora)
694+
if any(k.startswith("transformer.") for k in temp_dict.keys()):
695+
# Standard FLUX LoRA with transformer prefix
696+
extra_lora_dict = filter_state_dict(temp_dict, filter_prefix="transformer.")
697+
# Remove the transformer. prefix after filtering
698+
renamed_dict = {}
699+
for k, v in extra_lora_dict.items():
700+
new_k = k.replace("transformer.", "") if k.startswith("transformer.") else k
701+
renamed_dict[new_k] = v
702+
extra_lora_dict = renamed_dict
703+
else:
704+
# Kontext LoRA without transformer prefix - use as is
705+
extra_lora_dict = temp_dict
534706
else:
535-
extra_lora_dict = filter_state_dict(lora, filter_prefix="transformer.")
707+
# When called from to_nunchaku, lora is already processed by to_diffusers
708+
# Keys should be in format: single_blocks.0.linear1.lora_A.weight
709+
extra_lora_dict = lora
710+
711+
# Add transformer. prefix and rename blocks to match expectations
712+
renamed_dict = {}
713+
for k, v in extra_lora_dict.items():
714+
new_k = k
715+
# Add transformer. prefix and rename blocks
716+
if k.startswith("single_blocks."):
717+
new_k = "transformer.single_transformer_blocks." + k[14:]
718+
elif k.startswith("double_blocks."):
719+
new_k = "transformer.transformer_blocks." + k[14:]
720+
elif k.startswith("proj_out."):
721+
new_k = "transformer." + k
722+
elif not k.startswith("transformer."):
723+
new_k = "transformer." + k
724+
renamed_dict[new_k] = v
725+
extra_lora_dict = renamed_dict
726+
727+
# Now filter for transformer prefix and remove it for processing
728+
extra_lora_dict = filter_state_dict(extra_lora_dict, filter_prefix="transformer.")
729+
730+
# Remove the transformer. prefix for internal processing
731+
renamed_dict = {}
732+
for k, v in extra_lora_dict.items():
733+
new_k = k.replace("transformer.", "") if k.startswith("transformer.") else k
734+
renamed_dict[new_k] = v
735+
extra_lora_dict = renamed_dict
536736

537737
vector_dict, unquantized_lora_dict = {}, {}
538738
for k in list(extra_lora_dict.keys()):
539739
v = extra_lora_dict[k]
540740
if v.ndim == 1:
541741
vector_dict[k.replace(".lora_B.bias", ".bias")] = extra_lora_dict.pop(k)
542-
elif "transformer_blocks" not in k:
742+
elif "transformer_blocks" not in k and "single_transformer_blocks" not in k:
743+
# Only unquantized parts (like final_layer) go here
543744
unquantized_lora_dict[k] = extra_lora_dict.pop(k)
544745

545746
# Concatenate qkv_proj biases if present

0 commit comments

Comments
 (0)