Files
ussoewwin-ComfyUI-QwenImage…/scripts/build_v244_release_notes.py
T

361 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build RELEASE_NOTES/v2.4.4.md and zhmd/v2.4.4.md with language switchers."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RELEASE_DIR = ROOT / "RELEASE_NOTES"
ZHMD_DIR = ROOT / "zhmd"
EN_BODY_SRC = ROOT / "release" / "_release_v2.4.4_body_en.md"
EN_SWITCHER = """<table align="center">
<tr>
<td align="center" bgcolor="#3478ca" width="88" height="36"><font color="#ffffff"><b>EN</b></font></td>
<td align="center" bgcolor="#e5e7eb" width="88" height="36"><a href="https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/blob/main/zhmd/v2.4.4.md"><font color="#4b5563"><b>中文</b></font></a></td>
</tr>
</table>"""
ZH_SWITCHER = """<table align="center">
<tr>
<td align="center" bgcolor="#e5e7eb" width="88" height="36"><a href="https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader/releases/tag/v2.4.4"><font color="#4b5563"><b>EN</b></font></a></td>
<td align="center" bgcolor="#d4465e" width="88" height="36"><font color="#ffffff"><b>中文</b></font></td>
</tr>
</table>"""
ZH_BODY = r"""## 概述
记录 v2.2.7(2026-01-07)新增的「首个 LoRA 重复文件读取消除」功能,在 v2.3.0(2026-01-22)AWQ 调制层 LoRA 修复提交中被无意删除,以及后续恢复的事实。
---
## 1. v2.2.7 中新增的功能
**提交**: `1ff4db9`
**版本**: v2.2.7 — LoRA 加载速度改进:消除重复文件读取
### 新增代码
```python
# OPTIMIZATION: Cache first LoRA state dict for reuse in processing loop
_cached_first_lora_state_dict = None
if lora_configs:
first_lora_path_or_dict, first_lora_strength = lora_configs[0]
first_lora_state_dict = _load_lora_state_dict(first_lora_path_or_dict)
_cached_first_lora_state_dict = first_lora_state_dict # Cache for reuse
# 1. Aggregate weights from all LoRAs
for idx, (lora_path_or_dict, strength) in enumerate(lora_configs):
lora_name = lora_path_or_dict if isinstance(lora_path_or_dict, str) else "dict"
# OPTIMIZATION: Reuse cached first LoRA state dict to avoid duplicate file I/O
if idx == 0 and _cached_first_lora_state_dict is not None:
lora_state_dict = _cached_first_lora_state_dict
else:
lora_state_dict = _load_lora_state_dict(lora_path_or_dict)
```
### 影响与意义
#### 为何首个 LoRA 会被读取两次
`compose_loras_v2()` 在两个独立阶段加载文件:
1. **调试日志阶段**(第 1199–1212 行)
```python
first_lora_state_dict = _load_lora_state_dict(first_lora_path_or_dict)
for key in first_lora_state_dict.keys():
parsed_res = _classify_and_map_key(key)
logger.info(...)
# ← first_lora_state_dict 作为局部变量被丢弃
```
2. **实际处理循环阶段**(第 1217–1223 行)
```python
for lora_path_or_dict, strength in lora_configs:
lora_state_dict = _load_lora_state_dict(lora_path_or_dict) # ← 同一文件再次读取
```
为调试日志加载的 `state_dict`(数百 MB 至数 GB)超出作用域后可被垃圾回收。实际处理循环会**从磁盘再次读取同一文件**,造成完全浪费。
#### 量化浪费
| 操作 | 修复前 | 修复后 | 减少 |
|------|--------|--------|------|
| **文件 I/O** | 2 次 | 1 次 | **50%** |
| **二进制解析与张量反序列化** | 2 次 | 1 次 | **50%** |
| **键分类(`_classify_and_map_key`)** | 2×N 次 | 1×N 次 | **50%** |
N = LoRA 文件中的键数量(通常为数百至数千)
#### 具体性能影响
- **500MB LoRA 文件**:约节省 1–3 秒(取决于磁盘速度)
- **2GB LoRA 文件**:约节省 5–10 秒
- **用户感知**:从执行提示到开始生成之间的等待时间缩短
#### 为何重要
1. **单 LoRA 是最常见用例**
- 许多工作流只应用一个 LoRA
- 此时「首个 LoRA」即唯一 LoRA,浪费为 100%
2. **文件 I/O 为同步阻塞**
- `comfy.utils.load_torch_file()` 同步执行,完成前下游处理暂停
- UI 线程被阻塞,ComfyUI 控制台看似「卡住」
3. **反序列化 CPU 密集**
- safetensors / torch.load 的二进制解析带来高 CPU 负载
- 大体积 LoRA(FLUX、SDXL、Qwen 类)尤为明显
4. **键分类为连续正则匹配**
- `_classify_and_map_key()` 对每个键运行约 30 个正则模式
- 成本随键数量线性增长
#### 缓存安全性
- `_cached_first_lora_state_dict` 为 `compose_loras_v2` 内局部变量
- 函数退出时自动丢弃,跨调用无状态泄漏
- `state_dict` 视为只读,调试日志不修改
- `idx == 0` 条件确保第二个及后续 LoRA 不受影响
---
## 2. v2.2.8 中保留
**提交**: `c113968`
**版本**: v2.2.8 — 修复 Issue #44:加载不支持的 LoRA 格式时控制台变慢
Issue #44(不支持 LoRA 格式产生大量日志)已修复,但 `_cached_first_lora_state_dict` 缓存机制原样保留。
```
git show c113968:nunchaku_code/lora_qwen.py | Select-String "cached_first_lora"
→ _cached_first_lora_state_dict = None
→ _cached_first_lora_state_dict = first_lora_state_dict
→ if idx == 0 and _cached_first_lora_state_dict is not None:
```
---
## 3. v2.3.0 中丢失
**提交**: `6498929`
**版本**: v2.3.0 — 通过运行时猴子补丁实现 AWQ 调制层 LoRA 修复
**变更范围**: `nunchaku_code/lora_qwen.py | 865 ++++++++++++++--------------- | 266 insertions(+), 599 deletions(-)`
### 丢失的代码(经 git diff 确认)
```diff
- # OPTIMIZATION: Cache first LoRA state dict for reuse in processing loop
- _cached_first_lora_state_dict = None
- if lora_configs:
- first_lora_path_or_dict, first_lora_strength = lora_configs[0]
- first_lora_state_dict = _load_lora_state_dict(first_lora_path_or_dict)
- _cached_first_lora_state_dict = first_lora_state_dict # Cache for reuse
- # 1. Aggregate weights from all LoRAs
- for idx, (lora_path_or_dict, strength) in enumerate(lora_configs):
- lora_name = lora_path_or_dict if isinstance(lora_path_or_dict, str) else "dict"
- # OPTIMIZATION: Reuse cached first LoRA state dict to avoid duplicate file I/O
- if idx == 0 and _cached_first_lora_state_dict is not None:
- lora_state_dict = _cached_first_lora_state_dict
- else:
- lora_state_dict = _load_lora_state_dict(lora_path_or_dict)
+ # --- 4. Main Loading Loop ---
+ for idx, (lora_path_or_dict, strength) in enumerate(lora_configs):
+ if abs(strength) < 1e-5:
+ continue
```
### 丢失原因
`compose_loras_v2()` 分段重构时的迁移遗漏。
**v2.2.8 结构:**
```
缓存初始化 → 调试日志 → 循环(idx == 0 时复用缓存)
```
**v2.3.0 结构:**
```
# --- 1. Format Detection ---
# --- 2. Early Exit ---
# --- 3. Debug Inspection (First LoRA) ---
# --- 4. Main Loading Loop ---
```
第 3 节加载了 `first_lora_state_dict`,但为局部变量,第 4 节无法访问。第 4 节无条件调用 `_load_lora_state_dict_robust(lora_path_or_dict)`,首个 LoRA 被读取两次。
`enumerate(lora_configs)` 仍在,但缓存变量的声明、保存、复用三要素全部消失。
---
## 4. v2.4.3 时的代码
```python
# --- 3. Debug Inspection (First LoRA) ---
if lora_configs:
first_lora_path_or_dict, first_lora_strength = lora_configs[0]
first_lora_state_dict = _load_lora_state_dict_robust(first_lora_path_or_dict)
_first_detection = _detect_lora_format(first_lora_state_dict)
# --- 4. Main Loading Loop ---
for idx, (lora_path_or_dict, strength) in enumerate(lora_configs):
...
lora_state_dict = _load_lora_state_dict_robust(lora_path_or_dict) # idx==0 时第二次读取
```
重复文件 I/O 再次出现。
---
## 5. 与 AWQ 猴子补丁的兼容性
AWQ 调制层 LoRA 修复(`_awq_lora_forward`)在 `_apply_lora_to_module` 内运行,位于 `compose_loras_v2` 流程**末端**:文件加载 → 权重处理 → 模块应用。
文件加载优化(缓存)位于流程**入口**。两者为独立层,可共存:
```python
_cached_first_lora_state_dict = None # 新增
if lora_configs:
first_lora_path_or_dict, first_lora_strength = lora_configs[0]
first_lora_state_dict = _load_lora_state_dict_robust(first_lora_path_or_dict)
_cached_first_lora_state_dict = first_lora_state_dict # 新增
_first_detection = _detect_lora_format(first_lora_state_dict)
for idx, (lora_path_or_dict, strength) in enumerate(lora_configs):
...
if idx == 0 and _cached_first_lora_state_dict is not None: # 新增
lora_state_dict = _cached_first_lora_state_dict
else:
lora_state_dict = _load_lora_state_dict_robust(lora_path_or_dict)
```
对 AWQ 猴子补丁代码(第 2 节的 `should_apply_awq_mod` 检查、`_apply_lora_to_module` 内 AWQ 分支)**零影响**。缓存与 AWQ 补丁为独立层。
---
## 6. 迁移遗漏的事实
### 提交 6498929 的实际变更
```
nunchaku_code/lora_qwen.py | 865 ++++++++++++++--------------- | 266 insertions(+), 599 deletions(-)
```
- **删除 599 行**:`compose_loras_v2` 函数结构大规模重写
- **新增 266 行**:主要为 AWQ 猴子补丁(`_awq_lora_forward`、`DEFAULT_APPLY_AWQ_MOD_ENV` 等)
### 结构变化
**v2.2.8 及更早(顺序处理):**
```python
def compose_loras_v2(...):
_cached_first_lora_state_dict = None
if lora_configs:
first_lora = _load_lora_state_dict(...)
_cached_first_lora_state_dict = first_lora
for idx, (lora_path, strength) in enumerate(lora_configs):
if idx == 0 and _cached_first_lora_state_dict is not None:
lora_state_dict = _cached_first_lora_state_dict
else:
lora_state_dict = _load_lora_state_dict(lora_path)
```
**v2.3.0 及以后(分段):**
```python
def compose_loras_v2(...):
# --- 1. Z-Image / NextDiT Handling ---
# --- 2. Environment Variable / Argument Logic for AWQ Mod ---
# --- 3. Debug Inspection (First LoRA) ---
if lora_configs:
first_lora = _load_lora_state_dict_robust(...)
# --- 4. Main Loading Loop ---
for idx, (lora_path, strength) in enumerate(lora_configs):
lora_state_dict = _load_lora_state_dict_robust(lora_path)
```
### 消失的三要素
| 要素 | v2.2.8 | v2.3.0 |
|------|--------|--------|
| **缓存变量声明** | `_cached_first_lora_state_dict = None` | 已删除 |
| **写入缓存** | `_cached_first_lora_state_dict = first_lora_state_dict` | 已删除 |
| **缓存复用** | `if idx == 0 and _cached_first_lora_state_dict is not None:` | 已删除 |
### 保留部分
```python
for idx, (lora_path_or_dict, strength) in enumerate(lora_configs):
```
`enumerate` 仍在,但因 `idx == 0` 分支逻辑消失,仅作计数、无法用于缓存复用。
### 提交说明中的事实
- 提交说明:`Implement AWQ modulation layer LoRA fix with Runtime Monkey Patch`
- 正文未提及「删除缓存」「移除优化」或「文件 I/O 变更」
- 目的是添加 AWQ 猴子补丁,并非有意丢弃文件 I/O 优化
### 结论
提交 6498929 将 `compose_loras_v2` 重构为分段结构时,保留了 `enumerate(lora_configs)`,但未将 `_cached_first_lora_state_dict` 三要素(声明、保存、复用)迁入新结构。提交目的是 AWQ 猴子补丁;删除文件 I/O 优化并非本意。
---
## 7. 恢复事实
### 修改文件
`nunchaku_code/lora_qwen.py`
### 新增代码
**第 3 节(Debug Inspection):**
```python
# --- 3. Debug Inspection (First LoRA) ---
# OPTIMIZATION: Cache first LoRA state dict for reuse in processing loop
_cached_first_lora_state_dict = None
if lora_configs:
first_lora_path_or_dict, first_lora_strength = lora_configs[0]
first_lora_state_dict = _load_lora_state_dict_robust(first_lora_path_or_dict)
_cached_first_lora_state_dict = first_lora_state_dict # Cache for reuse
```
**第 4 节(Main Loading Loop):**
```python
# OPTIMIZATION: Reuse cached first LoRA state dict to avoid duplicate file I/O
if idx == 0 and _cached_first_lora_state_dict is not None:
lora_state_dict = _cached_first_lora_state_dict
else:
lora_state_dict = _load_lora_state_dict_robust(lora_path_or_dict)
```
### 意义
在 v2.3.0 AWQ 调制层猴子补丁完全兼容的前提下,恢复 v2.2.7「首个 LoRA 重复读取消除」功能。
- **第 3 节** 将 `first_lora_state_dict` 写入 `_cached_first_lora_state_dict`
- **第 4 节** 在 `idx == 0` 复用缓存,避免第二次 `_load_lora_state_dict_robust` 调用
- 文件 I/O、二进制解析、键分类减少 50%
对 AWQ 猴子补丁(`_awq_lora_forward`、`_apply_lora_to_module` 内 AWQ 分支)**零影响**。缓存位于文件加载层,AWQ 补丁位于模块应用层,完全独立。
"""
def main() -> None:
RELEASE_DIR.mkdir(parents=True, exist_ok=True)
ZHMD_DIR.mkdir(parents=True, exist_ok=True)
en_body = EN_BODY_SRC.read_text(encoding="utf-8").strip()
(RELEASE_DIR / "v2.4.4.md").write_text(
EN_SWITCHER + "\n\n" + en_body + "\n", encoding="utf-8"
)
(ZHMD_DIR / "v2.4.4.md").write_text(
ZH_SWITCHER + "\n\n" + ZH_BODY.strip() + "\n", encoding="utf-8"
)
print("Wrote RELEASE_NOTES/v2.4.4.md and zhmd/v2.4.4.md")
if __name__ == "__main__":
main()