Skip to content

Instantly share code, notes, and snippets.

@hinablue
Last active December 26, 2025 16:41
Show Gist options
  • Select an option

  • Save hinablue/f4600bbb412b772a68873d9a315f8cad to your computer and use it in GitHub Desktop.

Select an option

Save hinablue/f4600bbb412b772a68873d9a315f8cad to your computer and use it in GitHub Desktop.
Modify AI-Toolkit Z Image Turbo Lora
import argparse
import re
import torch
from safetensors.torch import load_file, save_file
import sys
import os
# ============================================================================
# 輔助函數:鍵和層處理
# ============================================================================
def extract_layer_number(key):
"""從 key 中提取 layer 編號,例如 'layers.0.xxx' -> 0,'layers.29.xxx' -> 29"""
match = re.search(r'layers\.(\d+)', key)
if match:
return int(match.group(1))
return None
def is_lora_key(key):
"""檢查 key 是否為 LoRA 鍵(lora_A.weight 或 lora_B.weight)"""
return 'lora_A.weight' in key or 'lora_B.weight' in key
def get_lora_rank_info(key, shape):
"""
獲取 LoRA 鍵的 rank 維度資訊
返回: (rank_dim_index, current_rank) 或 None(如果不是 LoRA 鍵)
"""
if 'lora_A.weight' in key:
# lora_A.weight 的 rank 是第一個維度
return (0, shape[0])
elif 'lora_B.weight' in key:
# lora_B.weight 的 rank 是最後一個維度
return (-1, shape[-1])
return None
# ============================================================================
# 輔助函數:張量對齊和 rank 調整
# ============================================================================
def align_tensor_shape(tensor, target_shape, dtype, device):
"""將張量調整到目標形狀,不足的部分用零填充"""
if tensor.shape == target_shape:
return tensor
# 檢查維度數量是否相同
if len(tensor.shape) != len(target_shape):
raise ValueError(
f"無法對齊張量:維度數量不相符 "
f"({len(tensor.shape)}D vs {len(target_shape)}D). "
f"形狀:{tensor.shape} vs {target_shape}"
)
# 建立目標形狀的零張量
aligned = torch.zeros(target_shape, dtype=dtype, device=device)
# 構建切片,將原始張量的數據複製到對齊後的張量左上角
# 對於每個維度,取較小的值作為複製範圍
slices = tuple(slice(0, min(tensor.shape[i], target_shape[i])) for i in range(len(target_shape)))
# 將原始張量的數據複製到對齊後的張量中
aligned[slices] = tensor[slices]
return aligned
def reduce_rank_average(tensor, rank_dim_index, current_rank, target_rank, dtype, device):
"""
使用平均聚合方法將 rank 從 current_rank 降到 target_rank
將多個維度平均成 target_rank 個維度
"""
if rank_dim_index == 0:
# lora_A: (current_rank, ...) -> (target_rank, ...)
# 計算每個目標維度需要聚合多少個原始維度
group_size = current_rank / target_rank
aligned = torch.zeros((target_rank,) + tensor.shape[1:], dtype=dtype, device=device)
for i in range(target_rank):
start_idx = int(i * group_size)
end_idx = int((i + 1) * group_size) if i < target_rank - 1 else current_rank
# 對這一段維度求平均
aligned[i, ...] = tensor[start_idx:end_idx, ...].mean(dim=0)
else:
# lora_B: (..., current_rank) -> (..., target_rank)
group_size = current_rank / target_rank
aligned = torch.zeros(tensor.shape[:-1] + (target_rank,), dtype=dtype, device=device)
for i in range(target_rank):
start_idx = int(i * group_size)
end_idx = int((i + 1) * group_size) if i < target_rank - 1 else current_rank
# 對這一段維度求平均
aligned[..., i] = tensor[..., start_idx:end_idx].mean(dim=-1)
return aligned
def reduce_rank_svd(tensor, rank_dim_index, current_rank, target_rank, dtype, device):
"""
使用 SVD 進行低秩近似,將 rank 從 current_rank 降到 target_rank
注意:SVD 需要 float32 精度,所以先轉換為 float32,計算完成後再轉回原始類型
"""
# SVD 需要 float32 精度,先轉換
tensor_fp32 = tensor.float()
if rank_dim_index == 0:
# lora_A: (current_rank, in_features)
# 轉置為 (in_features, current_rank) 進行 SVD
tensor_t = tensor_fp32.t() # (in_features, current_rank)
U, S, Vh = torch.linalg.svd(tensor_t, full_matrices=False)
# 取前 target_rank 個奇異值
U_reduced = U[:, :target_rank] # (in_features, target_rank)
S_reduced = S[:target_rank] # (target_rank,)
Vh_reduced = Vh[:target_rank, :] # (target_rank, current_rank)
# 重建: U @ diag(S) @ Vh,然後轉置回 (target_rank, in_features)
aligned = (U_reduced @ torch.diag(S_reduced) @ Vh_reduced).t()
else:
# lora_B: (out_features, current_rank)
# 直接進行 SVD
U, S, Vh = torch.linalg.svd(tensor_fp32, full_matrices=False)
# 取前 target_rank 個奇異值
U_reduced = U[:, :target_rank] # (out_features, target_rank)
S_reduced = S[:target_rank] # (target_rank,)
Vh_reduced = Vh[:target_rank, :] # (target_rank, current_rank)
# 重建: U @ diag(S) @ Vh
aligned = U_reduced @ torch.diag(S_reduced) @ Vh_reduced
# 轉回原始數據類型
return aligned.to(dtype)
def align_lora_rank(tensor, key, target_rank, target_shape, dtype, device, reduce_method='truncate'):
"""
將 LoRA 張量的 rank 維度調整到目標值
- lora_A.weight: rank 是第一個維度
- lora_B.weight: rank 是最後一個維度
reduce_method: 當目標 rank < 當前 rank 時的降維方法
- 'truncate': 截斷,只取前 N 個維度
- 'average': 平均聚合,將多個維度平均成 N 個維度
- 'svd': 使用 SVD 進行低秩近似
"""
rank_info = get_lora_rank_info(key, tensor.shape)
if rank_info is None:
# 不是 LoRA 鍵,使用通用對齊
return align_tensor_shape(tensor, target_shape, dtype, device)
rank_dim_index, current_rank = rank_info
# 從 target_shape 中獲取目標 rank(應該已經被設置為指定的 dim)
if rank_dim_index == 0:
target_rank_from_shape = target_shape[0]
else:
target_rank_from_shape = target_shape[-1]
# 確保 target_rank 與 target_shape 中的 rank 一致
if target_rank != target_rank_from_shape:
# 如果目標形狀中的 rank 與指定值不一致,使用指定值
target_shape_list = list(target_shape)
if rank_dim_index == 0:
target_shape_list[0] = target_rank
else:
target_shape_list[-1] = target_rank
target_shape = tuple(target_shape_list)
if current_rank == target_rank and tensor.shape == target_shape:
# rank 和形狀都已經相符
return tensor
if current_rank == target_rank:
# rank 已經相符,但可能其他維度需要對齊
return align_tensor_shape(tensor, target_shape, dtype, device)
# 構建目標形狀(確保 rank 維度正確)
target_shape_list = list(target_shape)
if rank_dim_index == 0:
target_shape_list[0] = target_rank
else:
target_shape_list[-1] = target_rank
target_shape = tuple(target_shape_list)
if current_rank > target_rank:
# 降維:根據 reduce_method 選擇方法
if reduce_method == 'truncate':
# 截斷:只取前 target_rank 個維度
if rank_dim_index == 0:
aligned = tensor[:target_rank, ...]
else:
aligned = tensor[..., :target_rank]
elif reduce_method == 'average':
# 平均聚合
aligned = reduce_rank_average(tensor, rank_dim_index, current_rank, target_rank, dtype, device)
elif reduce_method == 'svd':
# SVD 低秩近似
aligned = reduce_rank_svd(tensor, rank_dim_index, current_rank, target_rank, dtype, device)
else:
raise ValueError(f"未知的降維方法: {reduce_method}")
# 如果其他維度也需要對齊,使用通用對齊
if aligned.shape != target_shape:
aligned = align_tensor_shape(aligned, target_shape, dtype, device)
else:
# 擴展:用零填充
aligned = torch.zeros(target_shape, dtype=dtype, device=device)
if rank_dim_index == 0:
# lora_A: 將原始數據放在前 current_rank 行
aligned[:current_rank, ...] = tensor
else:
# lora_B: 將原始數據放在前 current_rank 列
aligned[..., :current_rank] = tensor
return aligned
# ============================================================================
# 參數解析和驗證
# ============================================================================
def parse_layer_weights(param_value, param_name, num_loras):
"""
通用函數:解析 layer 權重參數(blocks, adaln, ff, attn 等)
Args:
param_value: 參數值列表(從 argparse)
param_name: 參數名稱(用於錯誤消息)
num_loras: LoRA 文件數量
Returns:
解析後的權重配置列表,每個元素是一個包含 30 個權重的列表
"""
if param_value is None:
return None
if len(param_value) != num_loras:
print(f"錯誤:--{param_name} 參數數量 ({len(param_value)}) 與 --lora 參數數量 ({num_loras}) 不相符")
sys.exit(1)
configs = []
for i, weight_str in enumerate(param_value):
try:
weights = [float(x.strip()) for x in weight_str.split(',')]
except ValueError as e:
print(f"錯誤:無法解析第 {i+1} 個 LoRA 的 {param_name} 參數:{weight_str}")
print(f" 原因:{e}")
sys.exit(1)
# 驗證權重數量必須為 30
if len(weights) != 30:
print(f"錯誤:第 {i+1} 個 LoRA 的 {param_name} 參數必須包含 30 個數字,但實際有 {len(weights)} 個")
sys.exit(1)
configs.append(weights)
print(f" 解析 LoRA {i+1} {param_name} 權重:{weights[:5]}... (共 30 個)")
return configs
def validate_args(args):
"""驗證命令行參數"""
# 驗證參數數量相符
if len(args.lora) != len(args.ratio):
print(f"錯誤:--lora 參數數量 ({len(args.lora)}) 與 --ratio 參數數量 ({len(args.ratio)}) 不相符")
sys.exit(1)
if len(args.lora) < 1:
print("錯誤:至少需要指定一個 LoRA 文件")
sys.exit(1)
# blocks 與 adaln, ff, attn, attn_to_k, attn_to_q, attn_to_v 不可混用
incompatible_params = [args.adaln, args.ff, args.attn, args.attn_to_k, args.attn_to_q, args.attn_to_v]
if args.blocks is not None and any(p is not None for p in incompatible_params):
print("錯誤:--blocks 與 --adaln, --ff, --attn, --attn_to_k, --attn_to_q, --attn_to_v 不可混用")
sys.exit(1)
def parse_all_layer_weights(args):
"""解析所有 layer 權重參數"""
num_loras = len(args.lora)
return {
'blocks': parse_layer_weights(args.blocks, 'blocks', num_loras),
'adaln': parse_layer_weights(args.adaln, 'adaln', num_loras),
'ff': parse_layer_weights(args.ff, 'ff', num_loras),
'attn': parse_layer_weights(args.attn, 'attn', num_loras),
'attn_to_k': parse_layer_weights(args.attn_to_k, 'attn_to_k', num_loras),
'attn_to_q': parse_layer_weights(args.attn_to_q, 'attn_to_q', num_loras),
'attn_to_v': parse_layer_weights(args.attn_to_v, 'attn_to_v', num_loras),
'attn_to_out': parse_layer_weights(args.attn_to_out, 'attn_to_out', num_loras),
}
def prepare_lora_configs(args):
"""準備 LoRA 文件配置"""
lora_configs = []
for lora_path, weight in zip(args.lora, args.ratio):
if not os.path.exists(lora_path):
print(f"錯誤:LoRA 文件不存在:{lora_path}")
sys.exit(1)
lora_configs.append((lora_path, weight))
return lora_configs
# ============================================================================
# 模型載入和驗證
# ============================================================================
def load_models(lora_configs):
"""載入所有 LoRA 模型"""
print(f"載入 {len(lora_configs)} 個 LoRA 模型...")
models = []
for lora_path, weight in lora_configs:
print(f" 載入:{lora_path} (權重: {weight})")
models.append((load_file(lora_path), weight))
return models
def validate_model_keys(models):
"""驗證所有模型的鍵是否一致"""
first_model_keys = set(models[0][0].keys())
for i, (model_sd, _) in enumerate(models[1:], 1):
model_keys = set(model_sd.keys())
if model_keys != first_model_keys:
missing_keys = first_model_keys - model_keys
extra_keys = model_keys - first_model_keys
error_msg = f"錯誤:模型 {i+1} 的鍵與第一個模型不相符"
if missing_keys:
error_msg += f"\n 缺少的鍵:{missing_keys}"
if extra_keys:
error_msg += f"\n 多餘的鍵:{extra_keys}"
print(error_msg)
sys.exit(1)
return first_model_keys
def detect_dtype(models, first_model_keys):
"""檢測第一個模型的數據類型"""
first_key = next(iter(first_model_keys))
target_dtype = models[0][0][first_key].dtype
print(f"檢測到模型數據類型:{target_dtype}")
return target_dtype
# ============================================================================
# 形狀檢查和準備
# ============================================================================
def compute_max_shapes(models, first_model_keys, args, layer_weight_configs):
"""
檢查並確定每個鍵的最大形狀(處理不同 rank 的情況)
Returns:
tuple: (max_shapes, shape_info, layer_shapes_dict)
- max_shapes: 每個鍵的最大形狀
- shape_info: 每個鍵的所有形狀資訊(用於錯誤報告)
- layer_shapes_dict: 按類型分組的形狀字典(adaln_shapes, ff_shapes 等)
"""
print("檢查張量形狀...")
max_shapes = {}
shape_info = {}
# 初始化各類型的形狀字典
layer_shapes_dict = {
'adaln': {},
'ff': {},
'attn': {},
'attn_to_k': {},
'attn_to_q': {},
'attn_to_v': {},
'attn_to_out': {},
}
for key in first_model_keys:
shapes = []
max_shape = None
for i, (model_sd, _) in enumerate(models):
tensor_shape = model_sd[key].shape
shapes.append((i+1, tensor_shape))
if max_shape is None:
max_shape = tensor_shape
else:
# 檢查維度數量是否一致
if len(tensor_shape) != len(max_shape):
print(f"錯誤:鍵 '{key}' 的張量維度數量不一致")
for idx, shape in shapes:
print(f" 模型 {idx}: {shape}")
sys.exit(1)
# 逐個維度取最大值
max_shape = tuple(max(max_shape[j], tensor_shape[j]) for j in range(len(max_shape)))
# 如果指定了 --dim 且是 LoRA 鍵,調整目標形狀的 rank 維度
if args.dim is not None and is_lora_key(key):
rank_info = get_lora_rank_info(key, max_shape)
if rank_info is not None:
rank_dim_index, current_rank = rank_info
if current_rank != args.dim:
# 調整目標形狀的 rank 維度
max_shape_list = list(max_shape)
if rank_dim_index == 0:
max_shape_list[0] = args.dim
else:
max_shape_list[-1] = args.dim
max_shape = tuple(max_shape_list)
print(f" 鍵 '{key}': 將 rank 維度設置為 {args.dim} (原最大: {current_rank})")
max_shapes[key] = max_shape
shape_info[key] = shapes
# 根據配置收集各類型的形狀
if layer_weight_configs['adaln'] is not None and 'adaLN_modulation' in key:
layer_shapes_dict['adaln'][key] = max_shape
if layer_weight_configs['ff'] is not None and 'feed_forward' in key:
layer_shapes_dict['ff'][key] = max_shape
if layer_weight_configs['attn'] is not None and 'attention' in key:
layer_shapes_dict['attn'][key] = max_shape
if layer_weight_configs['attn_to_k'] is not None and 'attention.to_k' in key:
layer_shapes_dict['attn_to_k'][key] = max_shape
if layer_weight_configs['attn_to_q'] is not None and 'attention.to_q' in key:
layer_shapes_dict['attn_to_q'][key] = max_shape
if layer_weight_configs['attn_to_v'] is not None and 'attention.to_v' in key:
layer_shapes_dict['attn_to_v'][key] = max_shape
if layer_weight_configs['attn_to_out'] is not None and 'attention.to_out' in key:
layer_shapes_dict['attn_to_out'][key] = max_shape
# 檢查是否有形狀不一致的情況
all_same = all(s[1] == max_shape for s in shapes)
if not all_same:
print(f" 鍵 '{key}': 檢測到不同形狀,將對齊到最大形狀 {max_shape}")
for idx, shape in shapes:
if shape != max_shape:
print(f" 模型 {idx}: {shape} -> {max_shape}")
return max_shapes, shape_info, layer_shapes_dict
# ============================================================================
# 模型合併
# ============================================================================
def calculate_actual_weight(key, layer_num, weight, i, layer_weight_configs, layer_shapes_dict, max_shapes):
"""
計算實際權重,考慮 layer 權重配置
Returns:
tuple: (actual_weight, target_shape)
"""
actual_weight = weight
target_shape = max_shapes[key]
# 如果有 blocks 配置且 key 屬於某個 layer,使用 blocks 權重
if layer_weight_configs['blocks'] is not None and layer_num is not None:
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['blocks'][i][layer_num]
else:
# 使用細粒度的 layer 權重配置
if (any(cfg is not None for cfg in [
layer_weight_configs['adaln'], layer_weight_configs['ff'],
layer_weight_configs['attn'], layer_weight_configs['attn_to_k'],
layer_weight_configs['attn_to_q'], layer_weight_configs['attn_to_v'],
layer_weight_configs['attn_to_out']
]) and layer_num is not None):
if key in layer_shapes_dict['adaln']:
target_shape = layer_shapes_dict['adaln'][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['adaln'][i][layer_num]
elif key in layer_shapes_dict['ff']:
target_shape = layer_shapes_dict['ff'][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['ff'][i][layer_num]
elif key in layer_shapes_dict['attn']:
target_shape = layer_shapes_dict['attn'][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['attn'][i][layer_num]
elif key in layer_shapes_dict['attn_to_k']:
target_shape = layer_shapes_dict['attn_to_k'][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['attn_to_k'][i][layer_num]
elif key in layer_shapes_dict['attn_to_q']:
target_shape = layer_shapes_dict['attn_to_q'][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['attn_to_q'][i][layer_num]
elif key in layer_shapes_dict['attn_to_v']:
target_shape = layer_shapes_dict['attn_to_v'][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['attn_to_v'][i][layer_num]
elif key in layer_shapes_dict['attn_to_out']:
target_shape = layer_shapes_dict['attn_to_out'][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['attn_to_out'][i][layer_num]
return actual_weight, target_shape
def merge_models(models, first_model_keys, max_shapes, layer_weight_configs,
layer_shapes_dict, target_dtype, args):
"""合併所有模型"""
print("合併模型...")
if args.dim is not None:
print(f" 使用指定的 rank 維度:{args.dim}")
print(f" 降維方法:{args.dim_reduce_method}")
# 印出使用的配置資訊
config_names = {
'blocks': 'block 權重配置',
'adaln': 'adaLN_modulation 權重配置',
'ff': 'feed_forward 權重配置',
'attn': 'attention 權重配置',
'attn_to_k': 'attention.to_k 權重配置',
'attn_to_q': 'attention.to_q 權重配置',
'attn_to_v': 'attention.to_v 權重配置',
'attn_to_out': 'attention.to_out 權重配置',
}
for config_key, config_name in config_names.items():
if layer_weight_configs[config_key] is not None:
print(f" 使用 {config_name}")
new_model_sd = {}
for key in first_model_keys:
# 提取 layer 編號(如果適用)
layer_num = extract_layer_number(key)
target_shape = max_shapes[key]
# 加權求和,保持原始數據類型
merged_tensor = None
for i, (model_sd, weight) in enumerate(models):
tensor = model_sd[key]
# 計算實際權重
actual_weight, target_shape = calculate_actual_weight(
key, layer_num, weight, i, layer_weight_configs,
layer_shapes_dict, max_shapes
)
# 將權重轉換為與張量相同的數據類型,避免類型提升
weight_tensor = torch.tensor(actual_weight, dtype=target_dtype, device=tensor.device)
# 對齊張量形狀
# 如果指定了 --dim 且是 LoRA 鍵,使用專門的 rank 對齊函數
if args.dim is not None and is_lora_key(key):
aligned_tensor = align_lora_rank(
tensor, key, args.dim, target_shape, target_dtype,
tensor.device, args.dim_reduce_method
)
else:
aligned_tensor = align_tensor_shape(tensor, target_shape, target_dtype, tensor.device)
if merged_tensor is None:
merged_tensor = aligned_tensor * weight_tensor
else:
merged_tensor = merged_tensor + aligned_tensor * weight_tensor
# 確保結果保持原始數據類型(防止運算過程中的類型提升)
merged_tensor = merged_tensor.to(target_dtype)
new_model_sd[key] = merged_tensor
return new_model_sd
# ============================================================================
# 數據類型轉換和儲存
# ============================================================================
def convert_dtype(new_model_sd, args, target_dtype):
"""根據參數轉換數據類型"""
if args.dtype != 'auto':
print(f"轉換模型數據類型為:{args.dtype}")
if args.dtype == 'bf16':
output_dtype = torch.bfloat16
elif args.dtype == 'fp16':
output_dtype = torch.float16
else:
output_dtype = target_dtype
for key in new_model_sd:
# 只轉換浮點數類型的張量
if new_model_sd[key].dtype in [torch.float32, torch.float16, torch.bfloat16]:
new_model_sd[key] = new_model_sd[key].to(output_dtype)
print(f"轉換完成,輸出數據類型:{output_dtype}")
def save_model(new_model_sd, output_path):
"""儲存模型"""
print(f"儲存到:{output_path}")
save_file(new_model_sd, output_path)
print("完成!")
# ============================================================================
# 主程式
# ============================================================================
def create_parser():
"""建立命令行參數解析器"""
parser = argparse.ArgumentParser(description="合併多個使用 AI-Toolkit 訓練的 Z Image Turbo LoRA 模型")
parser.add_argument(
'--lora',
nargs='+',
required=True,
help='LoRA 文件路徑,可指定多個文件,例如:--lora file1.safetensors file2.safetensors file3.safetensors'
)
parser.add_argument(
'--ratio',
nargs='+',
required=True,
type=float,
help='LoRA 權重比例,可指定多個權重,例如:--ratio 0.1 0.3 0.5。必須與 --lora 參數數量相同'
)
parser.add_argument(
'--output',
type=str,
required=True,
help='輸出文件路徑'
)
parser.add_argument(
'--dtype',
type=str,
choices=['auto', 'bf16', 'fp16'],
default='auto',
help='輸出模型的數據類型:auto(保持原樣)、bf16(bfloat16)、fp16(float16)。默認為 auto'
)
parser.add_argument(
'--blocks',
nargs='+',
default=None,
help='每個 LoRA 的 block 權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 blocks 使用相同的 ratio 權重'
)
parser.add_argument(
'--adaln',
nargs='+',
default=None,
help='特別指定關於 adaLN_modulation 的權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 adaLN_modulation 使用相同的 ratio 權重'
)
parser.add_argument(
'--ff',
nargs='+',
default=None,
help='特別指定關於 feed_forward 的權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 feed_forward 使用相同的 ratio 權重'
)
parser.add_argument(
'--attn',
nargs='+',
default=None,
help='特別指定關於 attention 的權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 attention 使用相同的 ratio 權重'
)
parser.add_argument(
'--attn_to_k',
nargs='+',
default=None,
help='特別指定關於 attention.to_k 的權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 attention.to_k 使用相同的 ratio 權重'
)
parser.add_argument(
'--attn_to_q',
nargs='+',
default=None,
help='特別指定關於 attention.to_q 的權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 attention.to_q 使用相同的 ratio 權重'
)
parser.add_argument(
'--attn_to_v',
nargs='+',
default=None,
help='特別指定關於 attention.to_v 的權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 attention.to_v 使用相同的 ratio 權重'
)
parser.add_argument(
'--attn_to_out',
nargs='+',
default=None,
help='特別指定關於 attention.to_out 的權重,格式為 "0,1,0,1..." 共 30 個數字(對應 layers.0 到 layers.29)。可指定多個,必須與 --lora 參數數量相同。如果未指定,則所有 attention.to_out 使用相同的 ratio 權重'
)
parser.add_argument(
'--dim',
type=int,
default=None,
help='指定 LoRA 的 rank 維度。如果指定,所有 lora_A.weight 和 lora_B.weight 的 rank 維度將轉換為此值。lora_A.weight 的 rank 是第一個維度,lora_B.weight 的 rank 是最後一個維度'
)
parser.add_argument(
'--dim_reduce_method',
type=str,
choices=['truncate', 'average', 'svd'],
default='truncate',
help='當目標 rank 小於當前 rank 時的降維方法:truncate(截斷,只取前 N 個維度)、average(平均聚合,將多個維度平均成 N 個維度)、svd(使用 SVD 進行低秩近似)。默認為 truncate'
)
return parser
if __name__ == "__main__":
# 解析命令行參數
parser = create_parser()
args = parser.parse_args()
# 驗證參數
validate_args(args)
# 解析所有 layer 權重參數
layer_weight_configs = parse_all_layer_weights(args)
# 準備 LoRA 文件配置
lora_configs = prepare_lora_configs(args)
# 載入模型
models = load_models(lora_configs)
# 驗證模型鍵一致性
first_model_keys = validate_model_keys(models)
# 檢測數據類型
target_dtype = detect_dtype(models, first_model_keys)
# 計算最大形狀
max_shapes, shape_info, layer_shapes_dict = compute_max_shapes(
models, first_model_keys, args, layer_weight_configs
)
# 合併模型
new_model_sd = merge_models(
models, first_model_keys, max_shapes, layer_weight_configs,
layer_shapes_dict, target_dtype, args
)
# 轉換數據類型
convert_dtype(new_model_sd, args, target_dtype)
# 儲存模型
save_model(new_model_sd, args.output)
import argparse
import re
import torch
from safetensors.torch import load_file, save_file
import sys
import os
# ============================================================================
# Helper Functions: Key and Layer Processing
# ============================================================================
def extract_layer_number(key):
"""Extracts the layer number from a key, e.g., 'layers.0.xxx' -> 0, 'layers.29.xxx' -> 29"""
match = re.search(r'layers\.(\d+)', key)
if match:
return int(match.group(1))
return None
def is_lora_key(key):
"""Checks if the key is a LoRA weight (lora_A.weight or lora_B.weight)"""
return 'lora_A.weight' in key or 'lora_B.weight' in key
def get_lora_rank_info(key, shape):
"""
Retrieves the rank dimension information for a LoRA key.
Returns: (rank_dim_index, current_rank) or None if not a LoRA key.
"""
if 'lora_A.weight' in key:
# For lora_A.weight, rank is the first dimension
return (0, shape[0])
elif 'lora_B.weight' in key:
# For lora_B.weight, rank is the last dimension
return (-1, shape[-1])
return None
# ============================================================================
# Helper Functions: Tensor Alignment and Rank Adjustment
# ============================================================================
def align_tensor_shape(tensor, target_shape, dtype, device):
"""Adjusts a tensor to the target shape, padding missing parts with zeros."""
if tensor.shape == target_shape:
return tensor
# Check if number of dimensions match
if len(tensor.shape) != len(target_shape):
raise ValueError(
f"Unable to align tensor: dimension mismatch "
f"({len(tensor.shape)}D vs {len(target_shape)}D). "
f"Shapes: {tensor.shape} vs {target_shape}"
)
# Create a zero tensor with the target shape
aligned = torch.zeros(target_shape, dtype=dtype, device=device)
# Build slices to copy data to the top-left of the aligned tensor
# For each dimension, take the minimum of original and target size
slices = tuple(slice(0, min(tensor.shape[i], target_shape[i])) for i in range(len(target_shape)))
# Copy data from the original tensor
aligned[slices] = tensor[slices]
return aligned
def reduce_rank_average(tensor, rank_dim_index, current_rank, target_rank, dtype, device):
"""
Reduces rank from current_rank to target_rank using an average aggregation method.
Groups dimensions and calculates the mean.
"""
if rank_dim_index == 0:
# lora_A: (current_rank, ...) -> (target_rank, ...)
group_size = current_rank / target_rank
aligned = torch.zeros((target_rank,) + tensor.shape[1:], dtype=dtype, device=device)
for i in range(target_rank):
start_idx = int(i * group_size)
end_idx = int((i + 1) * group_size) if i < target_rank - 1 else current_rank
# Calculate mean for this group
aligned[i, ...] = tensor[start_idx:end_idx, ...].mean(dim=0)
else:
# lora_B: (..., current_rank) -> (..., target_rank)
group_size = current_rank / target_rank
aligned = torch.zeros(tensor.shape[:-1] + (target_rank,), dtype=dtype, device=device)
for i in range(target_rank):
start_idx = int(i * group_size)
end_idx = int((i + 1) * group_size) if i < target_rank - 1 else current_rank
# Calculate mean for this group
aligned[..., i] = tensor[..., start_idx:end_idx].mean(dim=-1)
return aligned
def reduce_rank_svd(tensor, rank_dim_index, current_rank, target_rank, dtype, device):
"""
Reduces rank using SVD (Singular Value Decomposition) for low-rank approximation.
Note: SVD requires float32 precision for stability.
"""
# SVD requires float32, convert first
tensor_fp32 = tensor.float()
if rank_dim_index == 0:
# lora_A: (current_rank, in_features)
# Transpose to (in_features, current_rank) for SVD
tensor_t = tensor_fp32.t() # (in_features, current_rank)
U, S, Vh = torch.linalg.svd(tensor_t, full_matrices=False)
# Keep top target_rank singular values
U_reduced = U[:, :target_rank] # (in_features, target_rank)
S_reduced = S[:target_rank] # (target_rank,)
Vh_reduced = Vh[:target_rank, :] # (target_rank, current_rank)
# Reconstruct and transpose back to (target_rank, in_features)
aligned = (U_reduced @ torch.diag(S_reduced) @ Vh_reduced).t()
else:
# lora_B: (out_features, current_rank)
U, S, Vh = torch.linalg.svd(tensor_fp32, full_matrices=False)
# Keep top target_rank singular values
U_reduced = U[:, :target_rank] # (out_features, target_rank)
S_reduced = S[:target_rank] # (target_rank,)
Vh_reduced = Vh[:target_rank, :] # (target_rank, current_rank)
# Reconstruct
aligned = U_reduced @ torch.diag(S_reduced) @ Vh_reduced
# Convert back to original dtype
return aligned.to(dtype)
def align_lora_rank(tensor, key, target_rank, target_shape, dtype, device, reduce_method='truncate'):
"""
Adjusts the rank dimension of a LoRA tensor to a target value.
- lora_A.weight: rank is the first dimension.
- lora_B.weight: rank is the last dimension.
reduce_method: Strategy when target rank < current rank:
- 'truncate': Simply slice the first N dimensions.
- 'average': Group and average dimensions.
- 'svd': Low-rank approximation using SVD.
"""
rank_info = get_lora_rank_info(key, tensor.shape)
if rank_info is None:
# Not a LoRA key, use general alignment
return align_tensor_shape(tensor, target_shape, dtype, device)
rank_dim_index, current_rank = rank_info
# Determine target rank from shape (should match the specified dimension)
if rank_dim_index == 0:
target_rank_from_shape = target_shape[0]
else:
target_rank_from_shape = target_shape[-1]
# Sync target_rank with target_shape
if target_rank != target_rank_from_shape:
target_shape_list = list(target_shape)
if rank_dim_index == 0:
target_shape_list[0] = target_rank
else:
target_shape_list[-1] = target_rank
target_shape = tuple(target_shape_list)
if current_rank == target_rank and tensor.shape == target_shape:
return tensor
if current_rank == target_rank:
# Rank matches, but other dimensions may need alignment
return align_tensor_shape(tensor, target_shape, dtype, device)
# Re-verify target shape consistency
target_shape_list = list(target_shape)
if rank_dim_index == 0:
target_shape_list[0] = target_rank
else:
target_shape_list[-1] = target_rank
target_shape = tuple(target_shape_list)
if current_rank > target_rank:
# Rank Reduction
if reduce_method == 'truncate':
if rank_dim_index == 0:
aligned = tensor[:target_rank, ...]
else:
aligned = tensor[..., :target_rank]
elif reduce_method == 'average':
aligned = reduce_rank_average(tensor, rank_dim_index, current_rank, target_rank, dtype, device)
elif reduce_method == 'svd':
aligned = reduce_rank_svd(tensor, rank_dim_index, current_rank, target_rank, dtype, device)
else:
raise ValueError(f"Unknown reduction method: {reduce_method}")
# Align other dimensions if necessary
if aligned.shape != target_shape:
aligned = align_tensor_shape(aligned, target_shape, dtype, device)
else:
# Expansion: Pad with zeros
aligned = torch.zeros(target_shape, dtype=dtype, device=device)
if rank_dim_index == 0:
# lora_A: Place original data in the top rows
aligned[:current_rank, ...] = tensor
else:
# lora_B: Place original data in the first columns
aligned[..., :current_rank] = tensor
return aligned
# ============================================================================
# Argument Parsing and Validation
# ============================================================================
def parse_layer_weights(param_value, param_name, num_loras):
"""
Generic parser for layer weight parameters (blocks, adaln, ff, attn, etc.)
"""
if param_value is None:
return None
if len(param_value) != num_loras:
print(f"Error: Number of --{param_name} arguments ({len(param_value)}) does not match --lora count ({num_loras})")
sys.exit(1)
configs = []
for i, weight_str in enumerate(param_value):
try:
weights = [float(x.strip()) for x in weight_str.split(',')]
except ValueError as e:
print(f"Error: Could not parse {param_name} for LoRA {i+1}: {weight_str}")
print(f" Reason: {e}")
sys.exit(1)
# Validate weight count (must be 30 for Z-Image-Turbo/AI-Toolkit standard)
if len(weights) != 30:
print(f"Error: {param_name} for LoRA {i+1} must contain exactly 30 numbers (found {len(weights)})")
sys.exit(1)
configs.append(weights)
print(f" Parsed LoRA {i+1} {param_name} weights: {weights[:5]}... (Total 30)")
return configs
def validate_args(args):
"""Validates command line arguments."""
if len(args.lora) != len(args.ratio):
print(f"Error: Number of --lora files ({len(args.lora)}) does not match --ratio count ({len(args.ratio)})")
sys.exit(1)
if len(args.lora) < 1:
print("Error: At least one LoRA file must be specified")
sys.exit(1)
# Prevent conflict between global 'blocks' and specific layer parameters
incompatible_params = [args.adaln, args.ff, args.attn, args.attn_to_k, args.attn_to_q, args.attn_to_v]
if args.blocks is not None and any(p is not None for p in incompatible_params):
print("Error: --blocks cannot be used simultaneously with specific layer parameters (--adaln, --ff, --attn, etc.)")
sys.exit(1)
def parse_all_layer_weights(args):
"""Parses all detailed layer weight parameters."""
num_loras = len(args.lora)
return {
'blocks': parse_layer_weights(args.blocks, 'blocks', num_loras),
'adaln': parse_layer_weights(args.adaln, 'adaln', num_loras),
'ff': parse_layer_weights(args.ff, 'ff', num_loras),
'attn': parse_layer_weights(args.attn, 'attn', num_loras),
'attn_to_k': parse_layer_weights(args.attn_to_k, 'attn_to_k', num_loras),
'attn_to_q': parse_layer_weights(args.attn_to_q, 'attn_to_q', num_loras),
'attn_to_v': parse_layer_weights(args.attn_to_v, 'attn_to_v', num_loras),
'attn_to_out': parse_layer_weights(args.attn_to_out, 'attn_to_out', num_loras),
}
def prepare_lora_configs(args):
"""Prepares list of LoRA paths and their associated global ratios."""
lora_configs = []
for lora_path, weight in zip(args.lora, args.ratio):
if not os.path.exists(lora_path):
print(f"Error: LoRA file not found: {lora_path}")
sys.exit(1)
lora_configs.append((lora_path, weight))
return lora_configs
# ============================================================================
# Model Loading and Verification
# ============================================================================
def load_models(lora_configs):
"""Loads all LoRA models into memory."""
print(f"Loading {len(lora_configs)} LoRA model(s)...")
models = []
for lora_path, weight in lora_configs:
print(f" Loading: {lora_path} (Global Ratio: {weight})")
models.append((load_file(lora_path), weight))
return models
def validate_model_keys(models):
"""Ensures all models share the same state dict keys."""
first_model_keys = set(models[0][0].keys())
for i, (model_sd, _) in enumerate(models[1:], 1):
model_keys = set(model_sd.keys())
if model_keys != first_model_keys:
missing_keys = first_model_keys - model_keys
extra_keys = model_keys - first_model_keys
error_msg = f"Error: Keys in Model {i+1} do not match the first model"
if missing_keys:
error_msg += f"\n Missing keys: {missing_keys}"
if extra_keys:
error_msg += f"\n Extra keys: {extra_keys}"
print(error_msg)
sys.exit(1)
return first_model_keys
def detect_dtype(models, first_model_keys):
"""Detects the data type of the first model."""
first_key = next(iter(first_model_keys))
target_dtype = models[0][0][first_key].dtype
print(f"Detected model data type: {target_dtype}")
return target_dtype
# ============================================================================
# Shape Checking and Preparation
# ============================================================================
def compute_max_shapes(models, first_model_keys, args, layer_weight_configs):
"""
Analyzes and determines the maximum shape for each key (handling mixed ranks).
"""
print("Inspecting tensor shapes...")
max_shapes = {}
shape_info = {}
# Group shapes by type
layer_shapes_dict = {
'adaln': {},
'ff': {},
'attn': {},
'attn_to_k': {},
'attn_to_q': {},
'attn_to_v': {},
'attn_to_out': {},
}
for key in first_model_keys:
shapes = []
max_shape = None
for i, (model_sd, _) in enumerate(models):
tensor_shape = model_sd[key].shape
shapes.append((i+1, tensor_shape))
if max_shape is None:
max_shape = tensor_shape
else:
if len(tensor_shape) != len(max_shape):
print(f"Error: Dimension mismatch for key '{key}'")
for idx, shape in shapes:
print(f" Model {idx}: {shape}")
sys.exit(1)
# Take the maximum size for each dimension
max_shape = tuple(max(max_shape[j], tensor_shape[j]) for j in range(len(max_shape)))
# Adjust rank dimension if --dim is specified
if args.dim is not None and is_lora_key(key):
rank_info = get_lora_rank_info(key, max_shape)
if rank_info is not None:
rank_dim_index, current_rank = rank_info
if current_rank != args.dim:
max_shape_list = list(max_shape)
if rank_dim_index == 0:
max_shape_list[0] = args.dim
else:
max_shape_list[-1] = args.dim
max_shape = tuple(max_shape_list)
print(f" Key '{key}': Setting rank to {args.dim} (Original max: {current_rank})")
max_shapes[key] = max_shape
shape_info[key] = shapes
# Categorize keys for fine-grained weighting
if layer_weight_configs['adaln'] is not None and 'adaLN_modulation' in key:
layer_shapes_dict['adaln'][key] = max_shape
if layer_weight_configs['ff'] is not None and 'feed_forward' in key:
layer_shapes_dict['ff'][key] = max_shape
if layer_weight_configs['attn'] is not None and 'attention' in key:
layer_shapes_dict['attn'][key] = max_shape
if layer_weight_configs['attn_to_k'] is not None and 'attention.to_k' in key:
layer_shapes_dict['attn_to_k'][key] = max_shape
if layer_weight_configs['attn_to_q'] is not None and 'attention.to_q' in key:
layer_shapes_dict['attn_to_q'][key] = max_shape
if layer_weight_configs['attn_to_v'] is not None and 'attention.to_v' in key:
layer_shapes_dict['attn_to_v'][key] = max_shape
if layer_weight_configs['attn_to_out'] is not None and 'attention.to_out' in key:
layer_shapes_dict['attn_to_out'][key] = max_shape
# Log shape discrepancies
all_same = all(s[1] == max_shape for s in shapes)
if not all_same:
print(f" Key '{key}': Mixed shapes detected. Aligning to maximum: {max_shape}")
for idx, shape in shapes:
if shape != max_shape:
print(f" Model {idx}: {shape} -> {max_shape}")
return max_shapes, shape_info, layer_shapes_dict
# ============================================================================
# Model Merging Logic
# ============================================================================
def calculate_actual_weight(key, layer_num, weight, i, layer_weight_configs, layer_shapes_dict, max_shapes):
"""
Calculates the effective weight based on layer-specific configurations.
"""
actual_weight = weight
target_shape = max_shapes[key]
# Handle global block config
if layer_weight_configs['blocks'] is not None and layer_num is not None:
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs['blocks'][i][layer_num]
else:
# Handle fine-grained layer configs
if (any(cfg is not None for cfg in [
layer_weight_configs['adaln'], layer_weight_configs['ff'],
layer_weight_configs['attn'], layer_weight_configs['attn_to_k'],
layer_weight_configs['attn_to_q'], layer_weight_configs['attn_to_v'],
layer_weight_configs['attn_to_out']
]) and layer_num is not None):
config_mapping = [
('adaln', 'adaln'), ('ff', 'ff'), ('attn', 'attn'),
('attn_to_k', 'attn_to_k'), ('attn_to_q', 'attn_to_q'),
('attn_to_v', 'attn_to_v'), ('attn_to_out', 'attn_to_out')
]
for config_key, dict_key in config_mapping:
if key in layer_shapes_dict[dict_key]:
target_shape = layer_shapes_dict[dict_key][key]
if 0 <= layer_num < 30:
actual_weight = weight * layer_weight_configs[config_key][i][layer_num]
break
return actual_weight, target_shape
def merge_models(models, first_model_keys, max_shapes, layer_weight_configs,
layer_shapes_dict, target_dtype, args):
"""Performs the weighted sum and merging of all models."""
print("Merging models...")
if args.dim is not None:
print(f" Using target rank dimension: {args.dim}")
print(f" Reduction method: {args.dim_reduce_method}")
# Log active configurations
config_names = {
'blocks': 'Block weight config',
'adaln': 'adaLN_modulation weight config',
'ff': 'feed_forward weight config',
'attn': 'attention weight config',
'attn_to_k': 'attention.to_k weight config',
'attn_to_q': 'attention.to_q weight config',
'attn_to_v': 'attention.to_v weight config',
'attn_to_out': 'attention.to_out weight config',
}
for config_key, config_name in config_names.items():
if layer_weight_configs[config_key] is not None:
print(f" Active: {config_name}")
new_model_sd = {}
for key in first_model_keys:
layer_num = extract_layer_number(key)
target_shape = max_shapes[key]
merged_tensor = None
for i, (model_sd, weight) in enumerate(models):
tensor = model_sd[key]
actual_weight, target_shape = calculate_actual_weight(
key, layer_num, weight, i, layer_weight_configs,
layer_shapes_dict, max_shapes
)
# Use tensor for weight to prevent automatic type promotion
weight_tensor = torch.tensor(actual_weight, dtype=target_dtype, device=tensor.device)
# Rank and Shape Alignment
if args.dim is not None and is_lora_key(key):
aligned_tensor = align_lora_rank(
tensor, key, args.dim, target_shape, target_dtype,
tensor.device, args.dim_reduce_method
)
else:
aligned_tensor = align_tensor_shape(tensor, target_shape, target_dtype, tensor.device)
if merged_tensor is None:
merged_tensor = aligned_tensor * weight_tensor
else:
merged_tensor = merged_tensor + aligned_tensor * weight_tensor
# Ensure final tensor maintains target precision
merged_tensor = merged_tensor.to(target_dtype)
new_model_sd[key] = merged_tensor
return new_model_sd
# ============================================================================
# Dtype Conversion and Output
# ============================================================================
def convert_dtype(new_model_sd, args, target_dtype):
"""Converts the final model to the specified data type."""
if args.dtype != 'auto':
print(f"Converting model data type to: {args.dtype}")
if args.dtype == 'bf16':
output_dtype = torch.bfloat16
elif args.dtype == 'fp16':
output_dtype = torch.float16
else:
output_dtype = target_dtype
for key in new_model_sd:
if new_model_sd[key].dtype in [torch.float32, torch.float16, torch.bfloat16]:
new_model_sd[key] = new_model_sd[key].to(output_dtype)
print(f"Conversion complete. Output dtype: {output_dtype}")
def save_model(new_model_sd, output_path):
"""Saves the state dict to a safetensors file."""
print(f"Saving to: {output_path}")
save_file(new_model_sd, output_path)
print("Done!")
# ============================================================================
# CLI Setup
# ============================================================================
def create_parser():
"""Defines the command-line interface."""
parser = argparse.ArgumentParser(description="Merge multiple Z-Image-Turbo LoRA models trained with AI-Toolkit")
parser.add_argument(
'--lora',
nargs='+',
required=True,
help='Paths to LoRA files. Multiple files can be specified, e.g., --lora f1.safetensors f2.safetensors'
)
parser.add_argument(
'--ratio',
nargs='+',
required=True,
type=float,
help='Global ratios for each LoRA. Must match the number of --lora files.'
)
parser.add_argument(
'--output',
type=str,
required=True,
help='Path for the output LoRA file'
)
parser.add_argument(
'--dtype',
type=str,
choices=['auto', 'bf16', 'fp16'],
default='auto',
help='Output data type: auto (keep original), bf16, or fp16. Default: auto'
)
parser.add_argument(
'--blocks',
nargs='+',
default=None,
help='Block weights for each LoRA. Format: "0,1,0.5..." (30 values for layers 0-29).'
)
parser.add_argument(
'--adaln',
nargs='+',
default=None,
help='Specific weights for adaLN_modulation (Global Ambiance). Format: 30 comma-separated values.'
)
parser.add_argument(
'--ff',
nargs='+',
default=None,
help='Specific weights for feed_forward (Knowledge/Details). Format: 30 comma-separated values.'
)
parser.add_argument(
'--attn',
nargs='+',
default=None,
help='Specific weights for general attention layers. Format: 30 comma-separated values.'
)
parser.add_argument(
'--attn_to_k',
nargs='+',
default=None,
help='Specific weights for attention.to_k (Layout/Structure). Format: 30 comma-separated values.'
)
parser.add_argument(
'--attn_to_q',
nargs='+',
default=None,
help='Specific weights for attention.to_q (Query). Format: 30 comma-separated values.'
)
parser.add_argument(
'--attn_to_v',
nargs='+',
default=None,
help='Specific weights for attention.to_v (Features/Content). Format: 30 comma-separated values.'
)
parser.add_argument(
'--attn_to_out',
nargs='+',
default=None,
help='Specific weights for attention.to_out (Integration). Format: 30 comma-separated values.'
)
parser.add_argument(
'--dim',
type=int,
default=None,
help='Target rank dimension. If set, all LoRA weights will be converted to this rank.'
)
parser.add_argument(
'--dim_reduce_method',
type=str,
choices=['truncate', 'average', 'svd'],
default='truncate',
help='Strategy for rank reduction: truncate, average, or svd. Default: truncate'
)
return parser
if __name__ == "__main__":
parser = create_parser()
args = parser.parse_args()
validate_args(args)
layer_weight_configs = parse_all_layer_weights(args)
lora_configs = prepare_lora_configs(args)
models = load_models(lora_configs)
first_model_keys = validate_model_keys(models)
target_dtype = detect_dtype(models, first_model_keys)
max_shapes, shape_info, layer_shapes_dict = compute_max_shapes(
models, first_model_keys, args, layer_weight_configs
)
new_model_sd = merge_models(
models, first_model_keys, max_shapes, layer_weight_configs,
layer_shapes_dict, target_dtype, args
)
convert_dtype(new_model_sd, args, target_dtype)
save_model(new_model_sd, args.output)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment