24 KiB
Comprehensive Technical Report: Z-Image (Nunchaku) LoRA Integration
Version: 2.0.8 Integration Analysis Date: 2025-12-25 Scope: Enabling full LoRA support for CompVis/Nunchaku-based Z-Image models in ComfyUI.
1. Context & Objective
The objective was to integrate LoRA support for the Z-Image model running on the Nunchaku inference backend within ComfyUI-QwenImageLoraLoader. This was challenging because Nunchaku optimizations fundamentally change how the model is structured and executed compared to standard diffusers pipelines.
Unlike Qwen/Flux models which were already supported, Z-Image introduced two critical hurdles:
- Tensor Shape Mismatch: The Nunchaku engine enforces strict 5D input requirements
(B, F, C, H, W)that conflicted with ComfyUI's standard latent formats. - MLP Layer Incompatibility: The model uses a highly optimized
SwiGLUactivation in its Feed-Forward (MLP) blocks. Standard LoRA injection logic failed to recognize these blocks, resulting in "silent failures" where LoRA weigths were loaded but not applied to the majority of model parameters.
2. Technical Architecture Analysis
2.1 The Nunchaku Z-Image Block
The core processing unit ZImageTransformerBlock deviates from standard implementations.
- Path:
nunchaku.models.transformers.transformer_zimage.ZImageTransformerBlock - Structure:
- Attention: Standard Self-Attention (Q, K, V).
- Feed-Forward (MLP):
- Uses
diffusers.models.activations.SwiGLU. - Historically, Diffusers models separate
w1(Gate) andw3(Up) linear layers. - Nunchaku's implementation fuses
w1andw3into a single projection layer.
- Uses
Block Structure Details:
-
Attention Block:
- Standard multi-head self-attention mechanism.
- Q, K, V projections are fused into a single
to_qkvlinear layer for efficiency. - This fusion is standard in many optimized transformer implementations and doesn't require special LoRA handling.
-
Feed-Forward Block Structure:
- Path in model:
layers[i].feed_forward.netwherenetis ann.Sequential:net[0]:SwiGLUcontainer module containingproj: Linear(dim_in, dim_out * 2)net[1]: Activation function (usually identity or SiLU)net[2]:Linear(dim_out, dim_out * expansion_factor)(thew2layer)
- The
SwiGLUatnet[0]replaces the original separatew1andw3layers. - Original structure would be:
w1: Linear(dim_in, dim_out),w3: Linear(dim_in, dim_out),w2: Linear(dim_out, dim_out * expansion). - Fused structure:
proj: Linear(dim_in, dim_out * 2)(inside SwiGLU),w2: Linear(dim_out, dim_out * expansion).
- Path in model:
2.2 SwiGLU Mechanics
The SwiGLU class acts as a customized Container, not a raw nn.Linear layer.
class SwiGLU(nn.Module):
def __init__(self, dim_in, dim_out):
self.proj = nn.Linear(dim_in, dim_out * 2) # Fused w1+w3
self.activation = nn.SiLU()
def forward(self, x):
x, gate = self.proj(x).chunk(2, dim=-1)
return x * self.activation(gate)
Implication: You cannot apply LoRA to SwiGLU directly. You must apply it to SwiGLU.proj. Furthermore, because SwiGLU.proj outputs dim_out * 2, the LoRA weights for w1 and w3 must be similarly fused before application.
Detailed Technical Analysis:
-
Why Direct LoRA Application Fails:
- Standard LoRA injection mechanisms search for
nn.Linearornn.Conv2dmodules in the model's module tree. SwiGLUis a container module (nn.Module) that wraps ann.Linearlayer, but the injection logic doesn't recursively search inside container modules.- When the injection logic encounters
SwiGLU, it sees an unsupported module type and skips it, logging[MISS] Module found but unsupported type: <class 'diffusers.models.activations.SwiGLU'>.
- Standard LoRA injection mechanisms search for
-
The Fused Projection Layer:
SwiGLU.projis ann.Linear(dim_in, dim_out * 2)layer that outputs twice the dimension of the intended output.- In the forward pass, this output is split using
chunk(2, dim=-1), creating two tensors of shape(..., dim_out). - The first half becomes the "value" (
x), and the second half becomes the "gate" (gate). - The gate is passed through
SiLUactivation, then multiplied element-wise with the value:x * SiLU(gate).
-
LoRA Weight Structure:
- Standard Z-Image LoRA files contain separate weights for
w1(gate projection) andw3(value/up projection). - Each has its own LoRA matrices:
w1_A,w1_B,w3_A,w3_B(and optionallyw1_alpha,w3_alpha). - The base model has these fused into a single
projlayer, so LoRA weights must be fused to match this structure.
- Standard Z-Image LoRA files contain separate weights for
-
Mathematical Relationship:
- If the original model had separate
w1: Linear(dim_in, dim_out)andw3: Linear(dim_in, dim_out), the fused version isproj: Linear(dim_in, dim_out * 2). - The fused weight matrix is:
W_fused = [W1; W3](vertical concatenation along the output dimension), whereW1andW3are the weight matrices ofw1andw3respectively. - This means
W_fused.shape == (dim_out * 2, dim_in), matching theprojlayer's weight shape. - During forward pass:
proj_output = W_fused @ x, which produces shape(dim_out * 2, batch_size). Thechunk(2, dim=-1)splits this into two(dim_out, batch_size)tensors, the first being the value and the second being the gate. - For LoRA application, the same fusion principle applies: if separate LoRAs modify
w1andw3, their weight deltas must be fused before application to the fusedprojlayer.
- If the original model had separate
3. Deep Dive: Debugging & Resolution Steps
Phase 1: The Tensor Shape Crisis (wrappers/zimageturbo.py)
Symptom: RuntimeError regarding batch size mismatches during generation.
Cause:
ComfyUI often processes latents as a 4D tensor (Batch * Frames, C, H, W). Nunchaku's forward pass explicitly expects 5D input to handle temporal data:
# Nunchaku Requirement
x.shape == (batch_size, num_frames, channels, height, width)
Fix Strategy:
- Batch Synchronization: We explicitly pass
batch_size=Bto the inference call, ensuringxandcap_featsalign. - Dimensional Gymnastics: The wrapper now intelligently reshapes usage.
- Input:
[B, C, H, W](Single frame inference contexts) - Transform:
unsqueeze(1)->[B, 1, C, H, W] - Inference: Nunchaku processes the 5D tensor.
- Output:
squeeze(1)->[B, C, H, W]to return to ComfyUI format.
- Input:
Implementation Details (wrappers/zimageturbo.py, lines 251-280):
The _execute_model method handles tensor format conversion and device management:
-
List Format Handling (lines 253-266):
- If
xis already a list (from model base conversion), filters outNoneelements. - Each tensor in the list is moved to the model's device if needed:
img.to(model_device). - Validates that the filtered list is not empty, raising
ValueErrorif empty.
- If
-
Tensor Format Handling (lines 267-275):
- If
xis still a tensor, moves it to model device first. - Checks for 5D input:
input_is_5d = x.ndim == 5. - If 5D, squeezes dimension 2:
x = x.squeeze(2)to convert from(B, F, C, H, W)format.
- If
-
Context Device Management (lines 277-280):
- Moves
contexttensor to model device if it's a tensor (not a list). - Ensures all tensors are on the same device before model execution.
- Moves
-
List Format Conversion for Direct Forward (lines 308-337):
- Z-Image-Turbo's forward method expects
xas a list of tensors with shape(C, F, H, W)where F is the frame dimension. - Conversion process:
- If
xis already a list, processes each element individually (lines 309-322). - Filters out
Noneelements and validatesimg.numel() > 0to avoid empty tensors (line 313). - Handles batch dimension: if input is
(1, C, H, W)(batch size 1), squeezes dimension 0 to get(C, H, W)(lines 315-316). - Ensures frame dimension: if tensor is
(C, H, W)(3D), unsqueezes dimension 1 to get(C, 1, H, W)(lines 318-320). - Appends each processed tensor to
x_listfor batch processing.
- If
- If
xis a tensor (not a list), splits it by batch dimension (lines 323-333). - Empty list validation (lines 335-337): Raises
ValueErrorifx_listis empty, as Z-Image-Turbo cannot process empty batches. - Length mismatch detection (lines 381-386): Checks if
len(x_list) != len(cap_feats)and warns, aszip()will truncate to the shorter length, potentially causing batch size mismatches.
- Z-Image-Turbo's forward method expects
Phase 2: The "Weak LoRA" Mystery (MLP Injection Failure)
Symptom: LoRA was loaded, but generated images changed very slightly. Users reported "it feels like strength 0.1 even at 1.0".
Investigation:
Using detailed [DEBUG] logging inspection of the apply_lora function in lora_qwen.py.
Findings
- Success: Attention layers (
to_q,to_k,to_v) were recognized and mapped correctly toto_qkv. - Failure: MLP layers (
w1,w2,w3) showed erratic behavior.- Attempt 1: Regex targeted
ff.net. result: 0 matches. - Correction 1: Variable name is
feed_forward, notff. - Attempt 2: Regex targeted
feed_forward.net. result: Match found, Injection Failed.
- Attempt 1: Regex targeted
Investigation Process Details:
The debugging process involved inspecting the _classify_and_map_key function (nunchaku_code/lora_qwen.py, lines 170-222) and module resolution:
-
Key Mapping Process:
- LoRA keys from the safetensors file are parsed using regex patterns in
KEY_MAPPING(line 27). - The regex
r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"matches keys likelayers.0.feed_forward.w1orlayers_0_feed_forward_w3. - The template
r"\1.\2.feed_forward.net.0.proj"maps tolayers.0.feed_forward.net.0.proj. - The group type
"glu"triggers GLU fusion logic incompose_loras_v2. - Before pattern matching,
_classify_and_map_key(line 170) removes common prefixes like"transformer.","diffusion_model.", or"lora_unet_"(lines 176-182). - The function uses
_RE_LORA_SUFFIXregex (line 120:r"\.(?P<tag>lora(?:[._](?:A|B|down|up)))(?:\.[^.]+)*\.weight$") to extract the base key and identify A/B/alpha components (lines 188-195). - If the suffix matches "lora_A" or ends with ".A" or contains "down",
abis set to "A". If it matches "lora_B" or ends with ".B" or contains "up",abis set to "B" (lines 192-195).
- LoRA keys from the safetensors file are parsed using regex patterns in
-
Module Resolution:
_resolve_module_name(line 254) attempts to find the module using_get_module_by_name(line 230).- The path
layers.0.feed_forward.net.0.projis traversed step by step:layers->0(index) ->feed_forward->net->0(index) ->proj. _get_module_by_nameuses a two-step resolution strategy (lines 237-251):- First: Try
hasattr(module, part)thengetattr(module, part). This works for regular attributes and also for numeric keys innn.Sequential/nn.ModuleDict(which Python exposes as attributes for keys like "0", "1", "2"). - Second: If
hasattrfails andpart.isdigit()is true, check if the module is indexable (_is_indexable_modulechecks fornn.ModuleList,nn.Sequential,list,tuple). If so, use integer indexing:module[int(part)].
- First: Try
- The
hasattr-first approach is necessary becausenn.Sequentialstores modules as attributes with numeric names, sohasattr(sequential, "0")returnsTrueeven though it's not a traditional attribute.nn.ModuleListdoes not expose numeric keys viahasattr, requiring the indexing fallback. - Fallback mappings (lines 269-277): If direct resolution fails,
_resolve_module_nametries alternative paths:.ff.net.0.proj->.mlp_fc1(for models usingmlp_fc1instead offeed_forward.net.0.proj).ff.net.2->.mlp_fc2- This allows compatibility with different model architectures that use different naming conventions.
-
Failure Point:
- When the regex matched
feed_forward.net.0(without.proj), the resolved module wasSwiGLUcontainer. _apply_lora_to_module(line 873) checksisinstance(module, nn.Linear), which fails forSwiGLU.- The function also checks for
proj_down/proj_upattributes (Nunchaku LoRA-ready modules), whichSwiGLUalso lacks. - Result:
ValueError: Unsupported module type <class 'diffusers.models.activations.SwiGLU'>or similar error, logged as[MISS].
- When the regex matched
The SwiGLU Barrier
The log showed:
[MISS] Module found but unsupported type: <class 'diffusers.models.activations.SwiGLU'>
The standard injection logic looks for nn.Linear, nn.Conv2d, or specific lora.Linear layers. It saw SwiGLU and skipped it, having no knowledge of how to inject weights into a container.
Phase 3: The GLU Fusion Solution (lora_qwen.py)
To solve this, we architected a specific path for "GLU Fusion".
1. Regex Retargeting
We modified KEY_MAPPING to "pierce" the container.
- Old:
...feed_forward.net.0(Targets the container) - New:
...feed_forward.net.0.proj(Targets the inner Linear layer)
Regex Pattern Details (nunchaku_code/lora_qwen.py, line 33):
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"),
r"\1.\2.feed_forward.net.0.proj", "glu", lambda m: m.group(3))
- Pattern:
r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"^(layers): Matches "layers" at start[._]: Matches either dot or underscore separator(\d+): Captures layer number (e.g., "0", "1")[._]feed_forward[._]: Matches "feed_forward" with any separator(w1|w3): Captures either "w1" or "w3"
- Template:
r"\1.\2.feed_forward.net.0.proj"\1: Layer group name ("layers")\2: Layer number- Adds
.feed_forward.net.0.projto target the inner Linear layer
- Group Type:
"glu"indicates GLU fusion is needed - Component Function:
lambda m: m.group(3)extracts "w1" or "w3" for fusion logic
2. Weight Fusion Logic (_fuse_glu_lora)
The Z-Image LoRA files (standard format) provide w1 (Gate) and w3 (Up) as separate weight matrices. The Model has them fused. We must replicate this fusion in the LoRA weights before injection.
# Concept
w_fused_A = cat([w1_A, w3_A], dim=0) # Stack ranks? No, usually features.
w_fused_B = cat([w1_B, w3_B], dim=1) # Output dimension doubles
Note: The actual implementation in _fuse_glu_lora ensures correct dimensional concatenation to match dim_out * 2.
Implementation Details (nunchaku_code/lora_qwen.py, lines 640-695):
The _fuse_glu_lora function (line 640) performs the actual fusion:
-
Input Validation (lines 653-667):
- Requires both
w1_Aandw3_Ainglu_weights, returns(None, None, None)if missing. - Validates
A_w1.shape[0] == A_w3.shape[0](input features must match), logs warning on mismatch.
- Requires both
-
A Matrix Fusion (line 674):
- Concatenates along dimension 0:
A_fused = torch.cat([A_w1, A_w3], dim=0). - Result shape:
(r1 + r3, in_features)wherer1andr3are the ranks ofw1andw3respectively.
- Concatenates along dimension 0:
-
B Matrix Fusion (lines 682-684):
- Creates block-diagonal matrix:
B_fused = torch.zeros(out1 + out3, r1 + r3, ...). - Assigns
B_fused[:out1, :r1] = B_w1andB_fused[out1:, r1:] = B_w3. - Result shape:
(out1 + out3, r1 + r3)matching the fusedprojlayer's output dimension.
- Creates block-diagonal matrix:
-
Integration in compose_loras_v2 (line 1078-1079):
- GLU fusion is detected by checking
"w1_A" in lw or "w3_A" in lwin the grouped LoRA weights. - The fused weights are then applied via
_apply_lora_to_module(line 1152) to thefeed_forward.net.0.projmodule (a standardnn.Linearlayer).
- GLU fusion is detected by checking
-
Weight Aggregation and Scaling (lines 1107-1152):
- Multiple LoRAs can target the same module. Weights are aggregated in
aggregated_weights[module_key].append({...})during the first loop (line 1108). - In the second loop (lines 1114-1152), for each module, all LoRA parts from different LoRAs are combined:
- Scaling calculation (line 1129):
scale = strength * (scale_alpha / max(1.0, float(r_lora)))wherescale_alpha = alpha.item() if alpha is not None else float(r_lora). - If
alphaisNone, it defaults to the rank (r_lora), making the scale simplystrength. Ifalphais provided, it acts as a normalization factor: higher alpha increases the effect per rank unit. - Special handling for AdaNorm layers (lines 1131-1134): If the module name contains
.norm1.linearor.norm1_context.linear, the B matrix is reordered usingreorder_adanorm_lora_up(B, splits=6)to match AdaNorm's split structure. Similar handling for single transformer blocks with.norm.linear(splits=3). - Device and dtype synchronization (lines 1136-1141): Determines target dtype and device from the module's weight (or
proj_downfor Nunchaku modules), ensuring all tensors match before concatenation. all_A.append(A.to(dtype=target_dtype, device=target_device))(line 1143) - A matrices are moved to target device/dtype without scaling.all_B_scaled.append((B * scale).to(...))(line 1144) - B matrices are scaled before concatenation. Scaling is applied here rather than after concatenation to allow per-LoRA strength control.
- Scaling calculation (line 1129):
- Final concatenation (lines 1149-1150):
final_A = torch.cat(all_A, dim=0)concatenates A matrices along the rank dimension, increasing total rank.final_B = torch.cat(all_B_scaled, dim=1)concatenates scaled B matrices along the rank dimension, matching the concatenated A shape. - The concatenated weights are applied to the module via
_apply_lora_to_module(line 1152).
- Multiple LoRAs can target the same module. Weights are aggregated in
-
Module Application (
_apply_lora_to_module, lines 873-934):- For standard
nn.Linearmodules (which includesfeed_forward.net.0.proj):- Validates tensor dimensions:
A.ndim == 2andB.ndim == 2,A.shape[1] == module.in_features,B.shape[0] == module.out_features(lines 876-882). - Backs up original weight to CPU in
_lora_slotsdictionary:module.weight.detach().cpu().clone()(line 920). This backup enables LoRA reset functionality. - Calculates weight delta:
delta = B @ AwhereB.shape == (out_features, rank)andA.shape == (rank, in_features)(line 925). The matrix multiplication producesdelta.shape == (out_features, in_features), matching the module weight shape. - Applies delta in-place:
module.weight.data.add_(delta.to(dtype=module.weight.dtype, device=module.weight.device))(line 930). Theadd_operation modifies weights directly, avoiding temporary tensor allocation.
- Validates tensor dimensions:
- For Nunchaku LoRA-ready modules (with
proj_down/proj_upattributes):- Unpacks existing low-rank weights using
unpack_lowrank_weight(lines 887-888). - Determines base rank and concatenation axis based on weight layout (lines 890-897).
- Concatenates new LoRA A matrices along the rank dimension and B matrices along the output dimension (lines 893, 899).
- Repacks using
pack_lowrank_weightand updatesmodule.rank(lines 901-903). - Tracks appended rank in
_lora_slotsfor reset support (line 908).
- Unpacks existing low-rank weights using
- For standard
3. Output Projection (w2)
The w2 layer (Down projection) is a standard linear layer appearing at feed_forward.net.2. It requires no special fusion, just correct mapping.
Mapping Details (nunchaku_code/lora_qwen.py, line 35):
The w2 layer uses standard regex mapping without fusion:
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w2$"),
r"\1.\2.feed_forward.net.2", "regular", None)
- Maps
layers.X.feed_forward.w2directly tolayers.X.feed_forward.net.2 - Group type
"regular"means standard LoRA application (no fusion) - Component function is
None(no component extraction needed) - The resolved module is a standard
nn.Linear, handled by_apply_lora_to_module(lines 911-930)
4. Final Configuration
Supported Layer Mappings
| LoRA Key (Source) | Model Module (Target) | Logic |
|---|---|---|
layers.*.attention.to_q/k/v |
attention.to_qkv |
QKV Fusion: Standard concatenation. |
layers.*.feed_forward.w1/w3 |
feed_forward.net.0.proj |
GLU Fusion (NEW): Fuses Gate+Up, injects into SwiGLU inner linear. |
layers.*.feed_forward.w2 |
feed_forward.net.2 |
Direct: Standard linear injection. |
5. Verification Results
Test Condition: Z-Image Model + MistyPokemon_ZIT.safetensors (Strength 1.0).
Logs (v2.0.8):
[APPLY] LoRA applied to: layers.0.attention.to_qkv (Success)
[APPLY] LoRA applied to: layers.0.feed_forward.net.0.proj (Success - GLU Fused)
[APPLY] LoRA applied to: layers.0.feed_forward.net.2 (Success - Linear)
...
Applied LoRA compositions to 150 modules.
Outcome: Changes in generated images are now distinct and accurate to the requested LoRA style, confirming that the MLP layers (which contain ~60% of model parameters) are finally participating in the inference.
Verification Process:
-
Log Analysis:
- The
[APPLY]log messages confirm successful application tofeed_forward.net.0.proj. - The total count "Applied LoRA compositions to 150 modules" includes all transformer layers (attention + MLP).
- Each layer has attention (
to_qkv) and MLP (net.0.proj,net.2) modules, so 150 modules ≈ 50 layers × 3 modules per layer.
- The
-
Before Fix Behavior:
- Only attention layers were modified, representing ~40% of parameters.
- MLP layers (60% of parameters) were unchanged, causing weak LoRA effects.
- Users needed to set strength > 1.0 to see noticeable changes, and even then results were inaccurate.
-
After Fix Behavior:
- Both attention and MLP layers are modified.
- Strength 1.0 produces accurate LoRA style transfer.
- Image generation shows distinct style changes matching the LoRA training data.
-
Performance Impact:
- LoRA fusion adds minimal computational overhead during composition (one-time operation).
- Block-diagonal B matrix structure maintains efficient matrix multiplication:
B @ Acomputation remains O(rank × features). - Memory overhead: fused LoRA weights have shape
(r1 + r3, in_features)for A and(out1 + out3, r1 + r3)for B, compared to separate(r1, in),(r3, in),(out1, r1),(out3, r3). - The memory increase is negligible compared to model weights (LoRA rank is typically 4-128, while model dimensions are 1024-4096).
6. How to Maintain
- New Architectures: If Nunchaku adds new model types, check if they use
SwiGLU. If so, ensure the regex targets.proj. - Updates to Diffusers: If
diffuserschanges theSwiGLUinternal name (unlikely, but possible) from.projto.linear,KEY_MAPPINGmust be updated.
Maintenance Guidelines:
-
Adding Support for New Model Types:
- Inspect the model architecture using
model.named_modules()to find the path to SwiGLU modules. - Verify the path format (e.g.,
layers.X.feed_forward.net.0contains aSwiGLUwith.projattribute). - Add regex pattern to
KEY_MAPPINGif the path format differs from existing patterns. - Test with a known LoRA file to verify successful application (check logs for
[APPLY]messages).
- Inspect the model architecture using
-
Debugging Failed LoRA Applications:
- Enable debug logging:
logger.setLevel(logging.DEBUG). - Check
[MISS]log messages to see which modules were not found or unsupported. - Use
_resolve_module_namemanually to verify module path resolution. - Inspect
_classify_and_map_keyoutput to verify regex pattern matching.
- Enable debug logging:
-
Key Mapping Updates:
- If model architecture changes, update regex patterns in
KEY_MAPPING(line 27). - Ensure template strings correctly map LoRA keys to model module paths.
- Component functions (lambda) should extract necessary information for fusion logic.
- Group types (
"glu","qkv","regular") determine which fusion function to call.
- If model architecture changes, update regex patterns in
-
Fusion Logic Maintenance:
_fuse_glu_loraassumesw1(gate) andw3(up) weights are present. If model uses different naming, update the detection logic (line 1078).- Block-diagonal B matrix structure assumes
out1 == out3. If this changes, fusion logic needs adjustment. - Alpha handling assumes same alpha for w1/w3. If models require different alphas, fusion logic must be updated.