diff --git a/README.md b/README.md
index 585e6cb..736d4bb 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,55 @@
# ComfyUI-ModelUtils
-[WIP]Custom nodes for handling, inspecting, modifying and creating various model files.
+
+A collection of ComfyUI custom nodes for inspecting, modifying, merging, and creating model files. Supports Models, TextEncoders, LoRAs, Checkpoints, and Embeddings.
+
+## Features
+
+- **MetaKeys** – Inspect and display metadata and tensor keys from model files
+- **RenameKeys** – Batch rename tensor keys using pattern matching
+- **PruneKeys** – Remove unwanted layers/keys from models
+- **Mergers** – Combine 2 or 3 models with configurable blend modes and ratios
+- **LoRA Extraction** – Extract LoRA adapters from model pairs using various SVD rank selection methods (Fixed, Ratio, Quantile, Knee-detection, Frobenius-norm)
+
+## Example Workflows
+
+
+
+
+ 📥 GetMetaAndkeys.json
+
+
+---
+
+
+
+
+ 📥 LoRA_Extract_nodes.json
+
+
+---
+
+
+
+
+ 📥 Merging_Examples.json
+
+
+---
+
+
+
+
+ 📥 RenameKeysInModel.json
+
+
+## Acknowledgements
+
+The LoRA extraction functionality was inspired by and references the excellent work from:
+
+- [kohya-ss/sd-scripts](https://github.com/kohya-ss/sd-scripts) – Training scripts for Stable Diffusion
+- [KohakuBlueleaf/LyCORIS](https://github.com/KohakuBlueleaf/LyCORIS) – Advanced LoRA techniques
+- [bmaltais/kohya_ss](https://github.com/bmaltais/kohya_ss) – Windows-friendly GUI for sd-scripts
+
+## License
+
+See [LICENSE](LICENSE) for details.
diff --git a/__init__.py b/__init__.py
index 82c8b43..10e45ce 100644
--- a/__init__.py
+++ b/__init__.py
@@ -19,8 +19,9 @@ from .nodes.merger import (
ModelThreeMerger, TextEncoderThreeMerger, LoRAThreeMerger,
CheckpointThreeMerger, EmbeddingThreeMerger
)
-from .nodes.lora_extract import (
- LoRASaveFromFile, LoRACheckpointSaveFromFile
+from .nodes.lora_extract_svd import (
+ LoRAExtractFixed, LoRAExtractRatio, LoRAExtractQuantile,
+ LoRAExtractKnee, LoRAExtractFrobenius
)
@@ -44,9 +45,11 @@ class ModelUtilsExtension(ComfyExtension):
ModelThreeMerger, TextEncoderThreeMerger, LoRAThreeMerger,
CheckpointThreeMerger, EmbeddingThreeMerger,
# LoRA Extraction
- LoRASaveFromFile, LoRACheckpointSaveFromFile,
+ LoRAExtractFixed, LoRAExtractRatio, LoRAExtractQuantile,
+ LoRAExtractKnee, LoRAExtractFrobenius,
]
async def comfy_entrypoint() -> ModelUtilsExtension:
- return ModelUtilsExtension()
\ No newline at end of file
+ return ModelUtilsExtension()
+
diff --git a/assets/GetMetaAndkeys.png b/assets/GetMetaAndkeys.png
new file mode 100644
index 0000000..9adf418
Binary files /dev/null and b/assets/GetMetaAndkeys.png differ
diff --git a/assets/LoRA_Extract_nodes.png b/assets/LoRA_Extract_nodes.png
new file mode 100644
index 0000000..6a18e68
Binary files /dev/null and b/assets/LoRA_Extract_nodes.png differ
diff --git a/assets/Merging_Examples.png b/assets/Merging_Examples.png
new file mode 100644
index 0000000..565f64e
Binary files /dev/null and b/assets/Merging_Examples.png differ
diff --git a/assets/RenameKeysInModel.png b/assets/RenameKeysInModel.png
new file mode 100644
index 0000000..60f5858
Binary files /dev/null and b/assets/RenameKeysInModel.png differ
diff --git a/example_workflows/GetMetaAndkeys.json b/example_workflows/GetMetaAndkeys.json
new file mode 100644
index 0000000..b7395a3
--- /dev/null
+++ b/example_workflows/GetMetaAndkeys.json
@@ -0,0 +1,600 @@
+{
+ "id": "e07ecc9b-30b9-45e4-9ed6-5fbb7e510257",
+ "revision": 0,
+ "last_node_id": 31,
+ "last_link_id": 19,
+ "nodes": [
+ {
+ "id": 16,
+ "type": "PreviewAny",
+ "pos": [
+ -3608,
+ 200
+ ],
+ "size": [
+ 832,
+ 232
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 18
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 17,
+ "type": "ModelMetaKeys",
+ "pos": [
+ -2800,
+ 504
+ ],
+ "size": [
+ 346.253515625,
+ 78
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "metadata",
+ "type": "STRING",
+ "links": [
+ 13
+ ]
+ },
+ {
+ "name": "keys",
+ "type": "STRING",
+ "links": [
+ 17
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "ModelMetaKeys"
+ },
+ "widgets_values": [
+ "flux2-dev.safetensors"
+ ]
+ },
+ {
+ "id": 18,
+ "type": "TextEncoderMetaKeys",
+ "pos": [
+ -3176,
+ 680
+ ],
+ "size": [
+ 329.4234375,
+ 78
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "metadata",
+ "type": "STRING",
+ "links": [
+ 15
+ ]
+ },
+ {
+ "name": "keys",
+ "type": "STRING",
+ "links": [
+ 16
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "TextEncoderMetaKeys"
+ },
+ "widgets_values": [
+ "clip_l.safetensors"
+ ]
+ },
+ {
+ "id": 19,
+ "type": "CheckpointMetaKeys",
+ "pos": [
+ -3168,
+ 512
+ ],
+ "size": [
+ 317.6724609375,
+ 78
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "metadata",
+ "type": "STRING",
+ "links": [
+ 12
+ ]
+ },
+ {
+ "name": "keys",
+ "type": "STRING",
+ "links": [
+ 18
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "CheckpointMetaKeys"
+ },
+ "widgets_values": [
+ "unsorted\\v1-5-pruned-emaonly.safetensors"
+ ]
+ },
+ {
+ "id": 21,
+ "type": "LoRAMetaKeys",
+ "pos": [
+ -2800,
+ 680
+ ],
+ "size": [
+ 277.0943359375,
+ 78
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "metadata",
+ "type": "STRING",
+ "links": [
+ 14
+ ]
+ },
+ {
+ "name": "keys",
+ "type": "STRING",
+ "links": [
+ 19
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAMetaKeys"
+ },
+ "widgets_values": [
+ "Chroma\\FlatAnimeStyle_Chroma.safetensors"
+ ]
+ },
+ {
+ "id": 24,
+ "type": "PreviewAny",
+ "pos": [
+ -2728,
+ 216
+ ],
+ "size": [
+ 512,
+ 200
+ ],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 17
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 25,
+ "type": "PreviewAny",
+ "pos": [
+ -2816,
+ 832
+ ],
+ "size": [
+ 736,
+ 248
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 19
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 27,
+ "type": "PreviewAny",
+ "pos": [
+ -3496,
+ 848
+ ],
+ "size": [
+ 632,
+ 224
+ ],
+ "flags": {},
+ "order": 11,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 16
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 28,
+ "type": "PreviewAny",
+ "pos": [
+ -3600,
+ 664
+ ],
+ "size": [
+ 352,
+ 136
+ ],
+ "flags": {},
+ "order": 10,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 15
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 29,
+ "type": "PreviewAny",
+ "pos": [
+ -2408,
+ 648
+ ],
+ "size": [
+ 360,
+ 120
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 14
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 30,
+ "type": "PreviewAny",
+ "pos": [
+ -3576,
+ 480
+ ],
+ "size": [
+ 336,
+ 128
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 12
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 31,
+ "type": "PreviewAny",
+ "pos": [
+ -2368,
+ 472
+ ],
+ "size": [
+ 328,
+ 120
+ ],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 13
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ }
+ ],
+ "links": [
+ [
+ 12,
+ 19,
+ 0,
+ 30,
+ 0,
+ "STRING"
+ ],
+ [
+ 13,
+ 17,
+ 0,
+ 31,
+ 0,
+ "STRING"
+ ],
+ [
+ 14,
+ 21,
+ 0,
+ 29,
+ 0,
+ "STRING"
+ ],
+ [
+ 15,
+ 18,
+ 0,
+ 28,
+ 0,
+ "STRING"
+ ],
+ [
+ 16,
+ 18,
+ 1,
+ 27,
+ 0,
+ "STRING"
+ ],
+ [
+ 17,
+ 17,
+ 1,
+ 24,
+ 0,
+ "STRING"
+ ],
+ [
+ 18,
+ 19,
+ 1,
+ 16,
+ 0,
+ "STRING"
+ ],
+ [
+ 19,
+ 21,
+ 1,
+ 25,
+ 0,
+ "STRING"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 0.9070294784580497,
+ "offset": [
+ 3855.323424397858,
+ -56.106573378634465
+ ]
+ },
+ "frontendVersion": "1.37.2",
+ "VHS_latentpreview": false,
+ "VHS_latentpreviewrate": 0,
+ "VHS_MetadataImage": true,
+ "VHS_KeepIntermediate": true
+ },
+ "version": 0.4
+}
\ No newline at end of file
diff --git a/example_workflows/LoRA_Extract_nodes.json b/example_workflows/LoRA_Extract_nodes.json
new file mode 100644
index 0000000..62c4668
--- /dev/null
+++ b/example_workflows/LoRA_Extract_nodes.json
@@ -0,0 +1,522 @@
+{
+ "id": "e07ecc9b-30b9-45e4-9ed6-5fbb7e510257",
+ "revision": 0,
+ "last_node_id": 10,
+ "last_link_id": 5,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "LoRAExtractFixed",
+ "pos": [
+ -1848,
+ 16
+ ],
+ "size": [
+ 296,
+ 352
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_path",
+ "type": "STRING",
+ "links": [
+ 3
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAExtractFixed"
+ },
+ "widgets_values": [
+ "13499-bf16.safetensors",
+ "13499-bf16.safetensors",
+ 64,
+ 32,
+ false,
+ 0.99,
+ 0,
+ "skip",
+ "extracted_lora",
+ "fp16",
+ "cuda",
+ ""
+ ]
+ },
+ {
+ "id": 2,
+ "type": "LoRAExtractFrobenius",
+ "pos": [
+ -2432,
+ 24
+ ],
+ "size": [
+ 296,
+ 352
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_path",
+ "type": "STRING",
+ "links": [
+ 1
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAExtractFrobenius"
+ },
+ "widgets_values": [
+ "13499-bf16.safetensors",
+ "13499-bf16.safetensors",
+ 0.9,
+ 0.9,
+ false,
+ 0.99,
+ 0,
+ "skip",
+ "extracted_lora",
+ "fp16",
+ "cuda",
+ ""
+ ]
+ },
+ {
+ "id": 3,
+ "type": "LoRAExtractKnee",
+ "pos": [
+ -2416,
+ 440
+ ],
+ "size": [
+ 280,
+ 328
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_path",
+ "type": "STRING",
+ "links": [
+ 2
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAExtractKnee"
+ },
+ "widgets_values": [
+ "13499-bf16.safetensors",
+ "13499-bf16.safetensors",
+ "sv_knee",
+ false,
+ 0.99,
+ 0,
+ "skip",
+ "extracted_lora",
+ "fp16",
+ "cuda",
+ ""
+ ]
+ },
+ {
+ "id": 4,
+ "type": "LoRAExtractQuantile",
+ "pos": [
+ -1248,
+ 128
+ ],
+ "size": [
+ 288,
+ 352
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_path",
+ "type": "STRING",
+ "links": [
+ 5
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAExtractQuantile"
+ },
+ "widgets_values": [
+ "13499-bf16.safetensors",
+ "13499-bf16.safetensors",
+ 0.9,
+ 0.9,
+ false,
+ 0.99,
+ 0,
+ "skip",
+ "extracted_lora",
+ "fp16",
+ "cuda",
+ ""
+ ]
+ },
+ {
+ "id": 5,
+ "type": "LoRAExtractRatio",
+ "pos": [
+ -1824,
+ 424
+ ],
+ "size": [
+ 296,
+ 352
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_path",
+ "type": "STRING",
+ "links": [
+ 4
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAExtractRatio"
+ },
+ "widgets_values": [
+ "13499-bf16.safetensors",
+ "13499-bf16.safetensors",
+ 0.5,
+ 0.5,
+ false,
+ 0.99,
+ 0,
+ "skip",
+ "extracted_lora",
+ "fp16",
+ "cuda",
+ ""
+ ]
+ },
+ {
+ "id": 6,
+ "type": "PreviewAny",
+ "pos": [
+ -2096,
+ 136
+ ],
+ "size": [
+ 210,
+ 168
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 1
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 7,
+ "type": "PreviewAny",
+ "pos": [
+ -2096,
+ 448
+ ],
+ "size": [
+ 210,
+ 168
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 2
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 8,
+ "type": "PreviewAny",
+ "pos": [
+ -1504,
+ 96
+ ],
+ "size": [
+ 210,
+ 168
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 3
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 9,
+ "type": "PreviewAny",
+ "pos": [
+ -1472,
+ 432
+ ],
+ "size": [
+ 210,
+ 168
+ ],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 4
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 10,
+ "type": "PreviewAny",
+ "pos": [
+ -904,
+ 184
+ ],
+ "size": [
+ 210,
+ 168
+ ],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 5
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ }
+ ],
+ "links": [
+ [
+ 1,
+ 2,
+ 0,
+ 6,
+ 0,
+ "STRING"
+ ],
+ [
+ 2,
+ 3,
+ 0,
+ 7,
+ 0,
+ "STRING"
+ ],
+ [
+ 3,
+ 1,
+ 0,
+ 8,
+ 0,
+ "STRING"
+ ],
+ [
+ 4,
+ 5,
+ 0,
+ 9,
+ 0,
+ "STRING"
+ ],
+ [
+ 5,
+ 4,
+ 0,
+ 10,
+ 0,
+ "STRING"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 1.05,
+ "offset": [
+ 2532,
+ 84
+ ]
+ },
+ "frontendVersion": "1.37.2",
+ "VHS_latentpreview": false,
+ "VHS_latentpreviewrate": 0,
+ "VHS_MetadataImage": true,
+ "VHS_KeepIntermediate": true
+ },
+ "version": 0.4
+}
\ No newline at end of file
diff --git a/example_workflows/Merging_Examples.json b/example_workflows/Merging_Examples.json
new file mode 100644
index 0000000..5f92f11
--- /dev/null
+++ b/example_workflows/Merging_Examples.json
@@ -0,0 +1,460 @@
+{
+ "id": "e07ecc9b-30b9-45e4-9ed6-5fbb7e510257",
+ "revision": 0,
+ "last_node_id": 16,
+ "last_link_id": 11,
+ "nodes": [
+ {
+ "id": 6,
+ "type": "PreviewAny",
+ "pos": [
+ -3552,
+ 136
+ ],
+ "size": [
+ 336,
+ 160
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 6
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 7,
+ "type": "PreviewAny",
+ "pos": [
+ -2792,
+ 144
+ ],
+ "size": [
+ 456,
+ 464
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 9
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ false
+ ]
+ },
+ {
+ "id": 11,
+ "type": "ModelThreeMerger",
+ "pos": [
+ -3120,
+ 112
+ ],
+ "size": [
+ 296,
+ 522
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_filename",
+ "type": "STRING",
+ "links": null
+ },
+ {
+ "name": "documentation",
+ "type": "STRING",
+ "links": [
+ 9
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "ModelThreeMerger"
+ },
+ "widgets_values": [
+ "DOCUMENTATION ONLY",
+ "Chroma1-HD.safetensors",
+ "Chroma-DC-2K.safetensors",
+ "None",
+ "Add-Difference",
+ "skip",
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 996811747788113,
+ "randomize",
+ "merged_3_model",
+ "fp32",
+ "cuda",
+ "",
+ ""
+ ]
+ },
+ {
+ "id": 12,
+ "type": "ModelTwoMerger",
+ "pos": [
+ -3120,
+ 704
+ ],
+ "size": [
+ 304,
+ 474
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_filename",
+ "type": "STRING",
+ "links": [
+ 11
+ ]
+ },
+ {
+ "name": "documentation",
+ "type": "STRING",
+ "links": null
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "ModelTwoMerger"
+ },
+ "widgets_values": [
+ "MERGE",
+ "None",
+ "None",
+ "Weight-Sum",
+ "skip",
+ 0.5,
+ 0.5,
+ 0.99,
+ 388546580517523,
+ "randomize",
+ "merged_2_model",
+ "fp32",
+ "cuda",
+ "",
+ ""
+ ]
+ },
+ {
+ "id": 13,
+ "type": "LoRAThreeMerger",
+ "pos": [
+ -3872,
+ 128
+ ],
+ "size": [
+ 296,
+ 522
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_filename",
+ "type": "STRING",
+ "links": [
+ 6
+ ]
+ },
+ {
+ "name": "documentation",
+ "type": "STRING",
+ "links": []
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAThreeMerger"
+ },
+ "widgets_values": [
+ "MERGE",
+ "None",
+ "None",
+ "None",
+ "Add-Difference",
+ "skip",
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 1120102695669269,
+ "randomize",
+ "merged_3_lora",
+ "fp32",
+ "cuda",
+ "",
+ ""
+ ]
+ },
+ {
+ "id": 14,
+ "type": "LoRATwoMerger",
+ "pos": [
+ -3872,
+ 720
+ ],
+ "size": [
+ 296,
+ 474
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "output_filename",
+ "type": "STRING",
+ "links": []
+ },
+ {
+ "name": "documentation",
+ "type": "STRING",
+ "links": [
+ 10
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRATwoMerger"
+ },
+ "widgets_values": [
+ "DOCUMENTATION ONLY",
+ "Hyper-FLUX.1-dev-16steps-lora-rescale_z1.safetensors",
+ "Hyper-FLUX.1-dev-16steps-lora-rescale_z2.safetensors",
+ "Weight-Sum",
+ "skip",
+ 0.5,
+ 0.5,
+ 0.99,
+ 1020143314396572,
+ "randomize",
+ "merged_2_lora",
+ "fp32",
+ "cuda",
+ "",
+ ""
+ ]
+ },
+ {
+ "id": 15,
+ "type": "PreviewAny",
+ "pos": [
+ -2768,
+ 720
+ ],
+ "size": [
+ 432,
+ 160
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 11
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 16,
+ "type": "PreviewAny",
+ "pos": [
+ -3544,
+ 712
+ ],
+ "size": [
+ 400,
+ 448
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 10
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ false
+ ]
+ }
+ ],
+ "links": [
+ [
+ 6,
+ 13,
+ 0,
+ 6,
+ 0,
+ "STRING"
+ ],
+ [
+ 9,
+ 11,
+ 1,
+ 7,
+ 0,
+ "STRING"
+ ],
+ [
+ 10,
+ 14,
+ 1,
+ 16,
+ 0,
+ "STRING"
+ ],
+ [
+ 11,
+ 12,
+ 0,
+ 15,
+ 0,
+ "STRING"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 0.9070294784580494,
+ "offset": [
+ 3972,
+ -12
+ ]
+ },
+ "frontendVersion": "1.37.2",
+ "VHS_latentpreview": false,
+ "VHS_latentpreviewrate": 0,
+ "VHS_MetadataImage": true,
+ "VHS_KeepIntermediate": true
+ },
+ "version": 0.4
+}
\ No newline at end of file
diff --git a/example_workflows/RenameKeysInModel.json b/example_workflows/RenameKeysInModel.json
new file mode 100644
index 0000000..4fbabcc
--- /dev/null
+++ b/example_workflows/RenameKeysInModel.json
@@ -0,0 +1,583 @@
+{
+ "id": "e07ecc9b-30b9-45e4-9ed6-5fbb7e510257",
+ "revision": 0,
+ "last_node_id": 46,
+ "last_link_id": 31,
+ "nodes": [
+ {
+ "id": 35,
+ "type": "LoRAMetaKeys",
+ "pos": [
+ -3136,
+ 512
+ ],
+ "size": [
+ 376,
+ 78
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "metadata",
+ "type": "STRING",
+ "links": null
+ },
+ {
+ "name": "keys",
+ "type": "STRING",
+ "links": [
+ 21,
+ 22
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRAMetaKeys"
+ },
+ "widgets_values": [
+ "qwen\\gmqwen_k8_piid_4step.safetensors"
+ ]
+ },
+ {
+ "id": 36,
+ "type": "LoRARenameKeys",
+ "pos": [
+ -2856,
+ 864
+ ],
+ "size": [
+ 320,
+ 190
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "old_keys",
+ "type": "STRING",
+ "widget": {
+ "name": "old_keys"
+ },
+ "link": 27
+ },
+ {
+ "name": "new_keys",
+ "type": "STRING",
+ "widget": {
+ "name": "new_keys"
+ },
+ "link": 28
+ }
+ ],
+ "outputs": [
+ {
+ "name": "output_path",
+ "type": "STRING",
+ "links": [
+ 31
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfyui_modelutils",
+ "ver": "eef737706127fe4a2c28172ab706e92318e0dc60",
+ "Node name for S&R": "LoRARenameKeys"
+ },
+ "widgets_values": [
+ "qwen\\gmqwen_k8_piid_4step.safetensors",
+ "",
+ "",
+ "gmqwen_k8_piid_4step"
+ ]
+ },
+ {
+ "id": 37,
+ "type": "PreviewAny",
+ "pos": [
+ -3792,
+ 920
+ ],
+ "size": [
+ 592,
+ 384
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 21
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 40,
+ "type": "RegexReplace",
+ "pos": [
+ -3400,
+ 568
+ ],
+ "size": [
+ 210,
+ 292
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "string",
+ "type": "STRING",
+ "widget": {
+ "name": "string"
+ },
+ "link": 22
+ }
+ ],
+ "outputs": [
+ {
+ "name": "STRING",
+ "type": "STRING",
+ "links": [
+ 23,
+ 25
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "RegexReplace"
+ },
+ "widgets_values": [
+ "",
+ ",\\s\\[.+$",
+ "",
+ true,
+ true,
+ false,
+ 0
+ ]
+ },
+ {
+ "id": 41,
+ "type": "PreviewAny",
+ "pos": [
+ -3784,
+ 528
+ ],
+ "size": [
+ 360,
+ 328
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 23
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 42,
+ "type": "RegexReplace",
+ "pos": [
+ -3176,
+ 1016
+ ],
+ "size": [
+ 288,
+ 292
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "string",
+ "type": "STRING",
+ "widget": {
+ "name": "string"
+ },
+ "link": 26
+ }
+ ],
+ "outputs": [
+ {
+ "name": "STRING",
+ "type": "STRING",
+ "links": [
+ 28,
+ 30
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "RegexReplace"
+ },
+ "widgets_values": [
+ "",
+ "^(\\w+)",
+ "diffusion_model.\\1",
+ true,
+ true,
+ false,
+ 0
+ ]
+ },
+ {
+ "id": 43,
+ "type": "RegexReplace",
+ "pos": [
+ -3152,
+ 648
+ ],
+ "size": [
+ 288,
+ 292
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "string",
+ "type": "STRING",
+ "widget": {
+ "name": "string"
+ },
+ "link": 25
+ }
+ ],
+ "outputs": [
+ {
+ "name": "STRING",
+ "type": "STRING",
+ "links": [
+ 26,
+ 27,
+ 29
+ ]
+ }
+ ],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "RegexReplace"
+ },
+ "widgets_values": [
+ "",
+ "^(proj_out.+|norm_out.+)$\\n",
+ "",
+ true,
+ true,
+ false,
+ 0
+ ]
+ },
+ {
+ "id": 44,
+ "type": "PreviewAny",
+ "pos": [
+ -2832,
+ 632
+ ],
+ "size": [
+ 640,
+ 192
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 29
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 45,
+ "type": "PreviewAny",
+ "pos": [
+ -2848,
+ 1096
+ ],
+ "size": [
+ 640,
+ 192
+ ],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 30
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ },
+ {
+ "id": 46,
+ "type": "PreviewAny",
+ "pos": [
+ -2520,
+ 872
+ ],
+ "size": [
+ 336,
+ 168
+ ],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [
+ {
+ "name": "source",
+ "type": "*",
+ "link": 31
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "cnr_id": "comfy-core",
+ "ver": "0.7.0",
+ "Node name for S&R": "PreviewAny"
+ },
+ "widgets_values": [
+ null,
+ null,
+ true
+ ]
+ }
+ ],
+ "links": [
+ [
+ 21,
+ 35,
+ 1,
+ 37,
+ 0,
+ "STRING"
+ ],
+ [
+ 22,
+ 35,
+ 1,
+ 40,
+ 0,
+ "STRING"
+ ],
+ [
+ 23,
+ 40,
+ 0,
+ 41,
+ 0,
+ "STRING"
+ ],
+ [
+ 25,
+ 40,
+ 0,
+ 43,
+ 0,
+ "STRING"
+ ],
+ [
+ 26,
+ 43,
+ 0,
+ 42,
+ 0,
+ "STRING"
+ ],
+ [
+ 27,
+ 43,
+ 0,
+ 36,
+ 0,
+ "STRING"
+ ],
+ [
+ 28,
+ 42,
+ 0,
+ 36,
+ 1,
+ "STRING"
+ ],
+ [
+ 29,
+ 43,
+ 0,
+ 44,
+ 0,
+ "STRING"
+ ],
+ [
+ 30,
+ 42,
+ 0,
+ 45,
+ 0,
+ "STRING"
+ ],
+ [
+ 31,
+ 36,
+ 0,
+ 46,
+ 0,
+ "STRING"
+ ]
+ ],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ds": {
+ "scale": 1,
+ "offset": [
+ 4191.258750150775,
+ -356.1917593715161
+ ]
+ },
+ "frontendVersion": "1.37.2",
+ "VHS_latentpreview": false,
+ "VHS_latentpreviewrate": 0,
+ "VHS_MetadataImage": true,
+ "VHS_KeepIntermediate": true
+ },
+ "version": 0.4
+}
\ No newline at end of file
diff --git a/nodes/device_utils.py b/nodes/device_utils.py
new file mode 100644
index 0000000..6ad7588
--- /dev/null
+++ b/nodes/device_utils.py
@@ -0,0 +1,268 @@
+"""
+Device capabilities and memory management utilities.
+
+Integrates with ComfyUI's model_management for safe memory operations.
+"""
+import os
+import gc
+import psutil
+import torch
+from typing import Optional, Tuple
+from dataclasses import dataclass
+
+# Import ComfyUI's model management functions
+try:
+ from comfy.model_management import (
+ get_torch_device,
+ get_free_memory,
+ get_total_memory,
+ free_memory,
+ soft_empty_cache,
+ unload_all_models,
+ is_device_cuda,
+ )
+ COMFY_AVAILABLE = True
+except ImportError:
+ COMFY_AVAILABLE = False
+
+
+@dataclass
+class DeviceCapabilities:
+ """Device capabilities for I/O and compute optimization."""
+ device_type: str # 'cuda', 'cpu', 'mps', 'xpu'
+ total_vram_gb: float
+ free_vram_gb: float
+ total_ram_gb: float
+ free_ram_gb: float
+ cpu_cores: int
+ recommended_workers: int
+ max_tensor_size_gb: float # Largest tensor we can safely load
+ supports_pinned_memory: bool
+ supports_async_streams: bool
+
+
+def get_device_capabilities(device: Optional[torch.device] = None) -> DeviceCapabilities:
+ """
+ Analyze device capabilities for optimal I/O configuration.
+
+ Args:
+ device: Target device (default: ComfyUI's torch device)
+
+ Returns:
+ DeviceCapabilities dataclass with recommended settings
+ """
+ if device is None:
+ if COMFY_AVAILABLE:
+ device = get_torch_device()
+ else:
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
+
+ device_type = device.type if hasattr(device, 'type') else 'cpu'
+
+ # Get RAM info
+ ram_info = psutil.virtual_memory()
+ total_ram_gb = ram_info.total / (1024**3)
+ free_ram_gb = ram_info.available / (1024**3)
+
+ # Get VRAM info
+ if device_type == 'cuda' and torch.cuda.is_available():
+ if COMFY_AVAILABLE:
+ total_vram = get_total_memory(device)
+ free_vram = get_free_memory(device)
+ else:
+ free_vram, total_vram = torch.cuda.mem_get_info(device)
+ total_vram_gb = total_vram / (1024**3)
+ free_vram_gb = free_vram / (1024**3)
+ supports_pinned = True
+ supports_async = True
+ elif device_type == 'mps':
+ total_vram_gb = total_ram_gb * 0.5 # MPS shares RAM
+ free_vram_gb = free_ram_gb * 0.5
+ supports_pinned = False
+ supports_async = False
+ else:
+ total_vram_gb = 0
+ free_vram_gb = 0
+ supports_pinned = False
+ supports_async = False
+
+ # CPU cores
+ cpu_cores = os.cpu_count() or 4
+
+ # Recommended workers based on hardware
+ if device_type == 'cuda':
+ # GPU: fewer workers since I/O is not the bottleneck
+ recommended_workers = min(4, cpu_cores // 2)
+ else:
+ # CPU: more workers for parallel I/O
+ recommended_workers = min(8, cpu_cores)
+
+ # Max tensor size: leave headroom for operations
+ if device_type == 'cuda':
+ max_tensor_gb = min(free_vram_gb * 0.8, free_ram_gb * 0.5)
+ else:
+ max_tensor_gb = free_ram_gb * 0.6
+
+ return DeviceCapabilities(
+ device_type=device_type,
+ total_vram_gb=total_vram_gb,
+ free_vram_gb=free_vram_gb,
+ total_ram_gb=total_ram_gb,
+ free_ram_gb=free_ram_gb,
+ cpu_cores=cpu_cores,
+ recommended_workers=recommended_workers,
+ max_tensor_size_gb=max_tensor_gb,
+ supports_pinned_memory=supports_pinned,
+ supports_async_streams=supports_async,
+ )
+
+
+def estimate_model_size(file_path: str) -> float:
+ """Estimate model size in GB from file size."""
+ try:
+ return os.path.getsize(file_path) / (1024**3)
+ except OSError:
+ return 0.0
+
+
+def can_fit_in_memory(
+ model_paths: list[str],
+ device: Optional[torch.device] = None,
+ safety_factor: float = 0.8
+) -> Tuple[bool, str]:
+ """
+ Check if models can fit in available memory.
+
+ Args:
+ model_paths: List of model file paths
+ device: Target device
+ safety_factor: Fraction of free memory to use (default 0.8)
+
+ Returns:
+ (can_fit, reason_message)
+ """
+ caps = get_device_capabilities(device)
+
+ total_size = sum(estimate_model_size(p) for p in model_paths)
+
+ if caps.device_type == 'cuda':
+ available = caps.free_vram_gb * safety_factor
+ mem_type = "VRAM"
+ else:
+ available = caps.free_ram_gb * safety_factor
+ mem_type = "RAM"
+
+ if total_size <= available:
+ return True, f"OK: {total_size:.2f}GB needed, {available:.2f}GB {mem_type} available"
+ else:
+ return False, f"Insufficient memory: {total_size:.2f}GB needed but only {available:.2f}GB {mem_type} available"
+
+
+def prepare_for_large_operation(
+ estimated_memory_gb: float,
+ device: Optional[torch.device] = None
+) -> bool:
+ """
+ Prepare device for a large memory operation by freeing resources.
+
+ Args:
+ estimated_memory_gb: Estimated memory needed in GB
+ device: Target device
+
+ Returns:
+ True if preparation succeeded
+ """
+ if not COMFY_AVAILABLE:
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ return True
+
+ if device is None:
+ device = get_torch_device()
+
+ memory_bytes = int(estimated_memory_gb * 1024**3)
+
+ # Use ComfyUI's free_memory which unloads models intelligently
+ try:
+ free_memory(memory_bytes, device)
+ return True
+ except Exception as e:
+ print(f"[DeviceUtils] Warning: Could not free memory: {e}")
+ return False
+
+
+def cleanup_after_operation():
+ """Clean up memory after a large operation."""
+ gc.collect()
+
+ if COMFY_AVAILABLE:
+ soft_empty_cache()
+ elif torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.ipc_collect()
+
+
+def get_optimal_workers(
+ model_size_gb: float,
+ caps: Optional[DeviceCapabilities] = None
+) -> int:
+ """
+ Calculate optimal number of I/O workers based on model size and device.
+
+ Args:
+ model_size_gb: Model size in GB
+ caps: Pre-calculated device capabilities (optional)
+
+ Returns:
+ Recommended number of workers
+ """
+ if caps is None:
+ caps = get_device_capabilities()
+
+ # For very large models, fewer workers to avoid memory pressure
+ if model_size_gb > caps.free_ram_gb * 0.5:
+ return max(1, caps.recommended_workers // 2)
+
+ # For small models, use more workers
+ if model_size_gb < 2.0:
+ return min(8, caps.cpu_cores)
+
+ return caps.recommended_workers
+
+
+def get_processing_device_string(prefer_cuda: bool = True) -> str:
+ """
+ Get the best available processing device as a string.
+
+ Args:
+ prefer_cuda: Prefer CUDA over CPU if available
+
+ Returns:
+ Device string ('cuda', 'mps', 'cpu')
+ """
+ if prefer_cuda:
+ if torch.cuda.is_available():
+ return "cuda"
+ if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
+ return "mps"
+ return "cpu"
+
+
+def print_device_info():
+ """Print device capabilities for debugging."""
+ caps = get_device_capabilities()
+ print(f"\n{'='*50}")
+ print(" Device Capabilities")
+ print(f"{'='*50}")
+ print(f" Device Type: {caps.device_type}")
+ print(f" Total VRAM: {caps.total_vram_gb:.2f} GB")
+ print(f" Free VRAM: {caps.free_vram_gb:.2f} GB")
+ print(f" Total RAM: {caps.total_ram_gb:.2f} GB")
+ print(f" Free RAM: {caps.free_ram_gb:.2f} GB")
+ print(f" CPU Cores: {caps.cpu_cores}")
+ print(f" Recommended Workers: {caps.recommended_workers}")
+ print(f" Max Tensor Size: {caps.max_tensor_size_gb:.2f} GB")
+ print(f" Pinned Memory: {caps.supports_pinned_memory}")
+ print(f" Async Streams: {caps.supports_async_streams}")
+ print(f"{'='*50}\n")
diff --git a/nodes/lora_extract.py b/nodes/lora_extract.py
deleted file mode 100644
index fe56431..0000000
--- a/nodes/lora_extract.py
+++ /dev/null
@@ -1,350 +0,0 @@
-"""
-Memory-efficient LoRA extraction node.
-
-This node loads two models from files and extracts a LoRA by computing the difference,
-without requiring the full models to be loaded into GPU memory via ComfyUI's model management.
-"""
-import os
-import torch
-import folder_paths
-import comfy.utils
-from comfy_api.latest import io
-from safetensors.torch import save_file
-from .merger_utils import MemoryEfficientSafeOpen
-
-
-CLAMP_QUANTILE = 0.99
-
-
-def extract_lora(diff, rank):
- """Extract LoRA weights from a weight difference tensor using SVD."""
- conv2d = (len(diff.shape) == 4)
- kernel_size = None if not conv2d else diff.size()[2:4]
- conv2d_3x3 = conv2d and kernel_size != (1, 1)
- out_dim, in_dim = diff.size()[0:2]
- rank = min(rank, in_dim, out_dim)
-
- if conv2d:
- if conv2d_3x3:
- diff = diff.flatten(start_dim=1)
- else:
- diff = diff.squeeze()
-
- U, S, Vh = torch.linalg.svd(diff.float())
- U = U[:, :rank]
- S = S[:rank]
- U = U @ torch.diag(S)
- Vh = Vh[:rank, :]
-
- dist = torch.cat([U.flatten(), Vh.flatten()])
- hi_val = torch.quantile(dist, CLAMP_QUANTILE)
- low_val = -hi_val
-
- U = U.clamp(low_val, hi_val)
- Vh = Vh.clamp(low_val, hi_val)
- if conv2d:
- U = U.reshape(out_dim, rank, 1, 1)
- Vh = Vh.reshape(rank, in_dim, kernel_size[0], kernel_size[1])
- return (U, Vh)
-
-
-def extract_lora_from_files(
- model_a_path: str,
- model_b_path: str,
- rank: int,
- lora_type: str,
- bias_diff: bool,
- prefix_lora: str,
- process_device: str,
- save_dtype: str,
-) -> dict[str, torch.Tensor]:
- """
- Memory-efficient LoRA extraction by streaming tensors from safetensors files.
-
- Args:
- model_a_path: Path to the finetuned/modified model
- model_b_path: Path to the base/original model
- rank: LoRA rank for SVD decomposition
- lora_type: "standard" for LoRA, "full_diff" for full difference
- bias_diff: Whether to include bias differences
- prefix_lora: Prefix for LoRA keys (e.g., "diffusion_model.")
- process_device: Device for processing ("cuda" or "cpu")
- save_dtype: Output dtype ("fp16", "bf16", "fp32")
-
- Returns:
- Dictionary of LoRA tensors
- """
- output_sd = {}
-
- # Get dtype for saving
- save_torch_dtype = {
- "fp32": torch.float32,
- "fp16": torch.float16,
- "bf16": torch.bfloat16
- }.get(save_dtype, torch.float16)
-
- # Open both files with memory-efficient handler
- handler_a = MemoryEfficientSafeOpen(model_a_path)
- handler_b = MemoryEfficientSafeOpen(model_b_path)
-
- try:
- keys_a = set(handler_a.keys())
- keys_b = set(handler_b.keys())
-
- # Only process keys that exist in both models
- common_keys = keys_a & keys_b
- weight_keys = [k for k in common_keys if k.endswith(".weight")]
- bias_keys = [k for k in common_keys if k.endswith(".bias")] if bias_diff else []
-
- # Log key differences
- missing_in_b = keys_a - keys_b
- extra_in_b = keys_b - keys_a
- if missing_in_b:
- print(f"[LoRA Extract] {len(missing_in_b)} keys in model A not in model B (skipped)")
- if extra_in_b:
- print(f"[LoRA Extract] {len(extra_in_b)} keys in model B not in model A (ignored)")
-
- pbar = comfy.utils.ProgressBar(len(weight_keys) + len(bias_keys))
-
- for key in weight_keys:
- # Load tensors one at a time
- tensor_a = handler_a.get_tensor(key).to(device=process_device, dtype=torch.float32)
- tensor_b = handler_b.get_tensor(key).to(device=process_device, dtype=torch.float32)
-
- # Compute difference: finetuned - base
- weight_diff = tensor_a - tensor_b
-
- # Clean up immediately
- del tensor_a, tensor_b
-
- # Extract LoRA key name
- lora_key = key[:-7] # Remove ".weight"
-
- if lora_type == "standard":
- if weight_diff.ndim < 2:
- # 1D weights (e.g., LayerNorm) - store as diff if bias_diff enabled
- if bias_diff:
- output_sd[f"{prefix_lora}{lora_key}.diff"] = weight_diff.to(save_torch_dtype).cpu()
- else:
- try:
- lora_up, lora_down = extract_lora(weight_diff, rank)
- output_sd[f"{prefix_lora}{lora_key}.lora_up.weight"] = lora_up.to(save_torch_dtype).cpu()
- output_sd[f"{prefix_lora}{lora_key}.lora_down.weight"] = lora_down.to(save_torch_dtype).cpu()
- except Exception as e:
- print(f"[LoRA Extract] Could not extract LoRA for {key}: {e}")
-
- elif lora_type == "full_diff":
- output_sd[f"{prefix_lora}{lora_key}.diff"] = weight_diff.to(save_torch_dtype).cpu()
-
- del weight_diff
- pbar.update(1)
-
- # Process bias keys
- for key in bias_keys:
- tensor_a = handler_a.get_tensor(key).to(device=process_device, dtype=torch.float32)
- tensor_b = handler_b.get_tensor(key).to(device=process_device, dtype=torch.float32)
-
- bias_diff_tensor = tensor_a - tensor_b
- del tensor_a, tensor_b
-
- lora_key = key[:-5] # Remove ".bias"
- output_sd[f"{prefix_lora}{lora_key}.diff_b"] = bias_diff_tensor.to(save_torch_dtype).cpu()
-
- del bias_diff_tensor
- pbar.update(1)
-
- finally:
- handler_a.__exit__(None, None, None)
- handler_b.__exit__(None, None, None)
-
- return output_sd
-
-
-class LoRASaveFromFile(io.ComfyNode):
- """
- Memory-efficient LoRA extraction from safetensors files.
-
- Unlike the standard LoraSave node which requires pre-loaded and subtracted models,
- this node loads models directly from files one tensor at a time, making it much
- more memory efficient for extracting LoRAs from large models.
- """
-
- @classmethod
- def define_schema(cls):
- return io.Schema(
- node_id="LoRASaveFromFile",
- display_name="Extract LoRA from Files (Memory Efficient)",
- category="ModelUtils/LoRA",
- description="Extracts a LoRA by computing the difference between two model files. "
- "Much more memory efficient than using ModelSubtract + LoraSave.",
- inputs=[
- io.Combo.Input(
- "model_a",
- options=folder_paths.get_filename_list("diffusion_models"),
- tooltip="The finetuned/modified model (A - B = LoRA)"
- ),
- io.Combo.Input(
- "model_b",
- options=folder_paths.get_filename_list("diffusion_models"),
- tooltip="The base/original model (A - B = LoRA)"
- ),
- io.Int.Input("rank", default=64, min=1, max=4096, step=1,
- tooltip="LoRA rank - higher preserves more detail but increases size"),
- io.Combo.Input("lora_type", options=["standard", "full_diff"], default="standard",
- tooltip="standard: SVD decomposition to lora_up/lora_down, full_diff: raw weight differences"),
- io.Boolean.Input("bias_diff", default=True,
- tooltip="Include bias differences in the LoRA"),
- io.String.Input("output_filename", default="extracted_lora",
- tooltip="Output filename (without extension)"),
- io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16"),
- io.Combo.Input("process_device", options=["cuda", "cpu"], default="cuda",
- tooltip="Device for SVD computation"),
- ],
- outputs=[
- io.String.Output(display_name="output_path"),
- ],
- is_output_node=True,
- )
-
- @classmethod
- def execute(
- cls,
- model_a: str,
- model_b: str,
- rank: int,
- lora_type: str,
- bias_diff: bool,
- output_filename: str,
- save_dtype: str,
- process_device: str,
- ) -> io.NodeOutput:
- # Get full paths
- model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
- model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
-
- # Extract LoRA
- output_sd = extract_lora_from_files(
- model_a_path=model_a_path,
- model_b_path=model_b_path,
- rank=rank,
- lora_type=lora_type,
- bias_diff=bias_diff,
- prefix_lora="diffusion_model.",
- process_device=process_device,
- save_dtype=save_dtype,
- )
-
- if not output_sd:
- raise ValueError("No LoRA weights extracted - models may be identical or incompatible")
-
- # Save to loras folder
- output_dir = folder_paths.get_folder_paths("loras")[0]
- os.makedirs(output_dir, exist_ok=True)
- output_path = os.path.join(output_dir, f"{output_filename.strip()}.safetensors")
-
- save_file(output_sd, output_path)
- print(f"[LoRA Extract] Saved {len(output_sd)} tensors to {output_path}")
-
- return io.NodeOutput(output_path)
-
-
-class LoRACheckpointSaveFromFile(io.ComfyNode):
- """
- Memory-efficient LoRA extraction from checkpoint files (which may contain both model and text encoder).
- """
-
- @classmethod
- def define_schema(cls):
- return io.Schema(
- node_id="LoRACheckpointSaveFromFile",
- display_name="Extract LoRA from Checkpoints (Memory Efficient)",
- category="ModelUtils/LoRA",
- description="Extracts a LoRA by computing the difference between two checkpoint files. "
- "Handles both diffusion model and text encoder weights.",
- inputs=[
- io.Combo.Input(
- "checkpoint_a",
- options=folder_paths.get_filename_list("checkpoints"),
- tooltip="The finetuned/modified checkpoint (A - B = LoRA)"
- ),
- io.Combo.Input(
- "checkpoint_b",
- options=folder_paths.get_filename_list("checkpoints"),
- tooltip="The base/original checkpoint (A - B = LoRA)"
- ),
- io.Int.Input("rank", default=64, min=1, max=4096, step=1,
- tooltip="LoRA rank - higher preserves more detail but increases size"),
- io.Combo.Input("lora_type", options=["standard", "full_diff"], default="standard",
- tooltip="standard: SVD decomposition, full_diff: raw weight differences"),
- io.Boolean.Input("bias_diff", default=True,
- tooltip="Include bias differences in the LoRA"),
- io.String.Input("output_filename", default="extracted_lora",
- tooltip="Output filename (without extension)"),
- io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16"),
- io.Combo.Input("process_device", options=["cuda", "cpu"], default="cuda",
- tooltip="Device for SVD computation"),
- ],
- outputs=[
- io.String.Output(display_name="output_path"),
- ],
- is_output_node=True,
- )
-
- @classmethod
- def execute(
- cls,
- checkpoint_a: str,
- checkpoint_b: str,
- rank: int,
- lora_type: str,
- bias_diff: bool,
- output_filename: str,
- save_dtype: str,
- process_device: str,
- ) -> io.NodeOutput:
- # Get full paths
- checkpoint_a_path = folder_paths.get_full_path_or_raise("checkpoints", checkpoint_a)
- checkpoint_b_path = folder_paths.get_full_path_or_raise("checkpoints", checkpoint_b)
-
- # Open handlers to detect prefixes
- handler_a = MemoryEfficientSafeOpen(checkpoint_a_path)
- keys = handler_a.keys()
- handler_a.__exit__(None, None, None)
-
- # Auto-detect key prefixes
- has_model = any(k.startswith("model.") for k in keys)
- has_diffusion = any(k.startswith("diffusion_model.") for k in keys)
- has_text_encoders = any(k.startswith("text_encoders.") or k.startswith("cond_stage_model.") for k in keys)
-
- output_sd = {}
-
- # Extract diffusion model LoRA
- if has_model or has_diffusion:
- prefix = "model.diffusion_model." if has_model else "diffusion_model."
- model_lora = extract_lora_from_files(
- model_a_path=checkpoint_a_path,
- model_b_path=checkpoint_b_path,
- rank=rank,
- lora_type=lora_type,
- bias_diff=bias_diff,
- prefix_lora="diffusion_model.",
- process_device=process_device,
- save_dtype=save_dtype,
- )
- output_sd.update(model_lora)
-
- # Note: Text encoder extraction would need additional prefix handling
- # For full checkpoint support, this would need to be expanded
-
- if not output_sd:
- raise ValueError("No LoRA weights extracted - checkpoints may be identical or incompatible")
-
- # Save to loras folder
- output_dir = folder_paths.get_folder_paths("loras")[0]
- os.makedirs(output_dir, exist_ok=True)
- output_path = os.path.join(output_dir, f"{output_filename.strip()}.safetensors")
-
- save_file(output_sd, output_path)
- print(f"[LoRA Extract] Saved {len(output_sd)} tensors to {output_path}")
-
- return io.NodeOutput(output_path)
diff --git a/nodes/lora_extract_svd.py b/nodes/lora_extract_svd.py
new file mode 100644
index 0000000..d831a8d
--- /dev/null
+++ b/nodes/lora_extract_svd.py
@@ -0,0 +1,817 @@
+"""
+LoRA Extraction via SVD decomposition.
+
+Native implementation of Low-Rank Adapter extraction with multiple rank selection modes.
+No external dependencies required.
+"""
+import os
+import re
+import torch
+import torch.linalg as linalg
+import folder_paths
+import comfy.utils
+from tqdm import tqdm
+from comfy_api.latest import io
+from safetensors.torch import save_file
+from .merger_utils import MemoryEfficientSafeOpen, transfer_to_gpu_pinned
+from .device_utils import (
+ estimate_model_size, prepare_for_large_operation,
+ cleanup_after_operation, get_device_capabilities
+)
+
+
+# =============================================================================
+# Rank Index Functions - Determine LoRA rank from singular values
+# =============================================================================
+
+def _index_sv_fixed(S: torch.Tensor, dim: int) -> int:
+ """Fixed rank mode - use specified dimension."""
+ return max(1, min(dim, len(S) - 1))
+
+
+def _index_sv_threshold(S: torch.Tensor, threshold: float) -> int:
+ """Threshold mode - keep singular values above absolute threshold."""
+ rank = int(torch.sum(S > threshold).item())
+ return max(1, min(rank, len(S) - 1))
+
+
+def _index_sv_ratio(S: torch.Tensor, ratio: float) -> int:
+ """Ratio mode - keep singular values > max(S) * ratio."""
+ if ratio <= 0:
+ return len(S) - 1
+ min_sv = S[0] * ratio
+ rank = int(torch.sum(S > min_sv).item())
+ return max(1, min(rank, len(S) - 1))
+
+
+def _index_sv_cumulative(S: torch.Tensor, target: float) -> int:
+ """Cumulative mode - keep enough SVs to reach target % of total."""
+ total = torch.sum(S)
+ if total < 1e-8:
+ return 1
+ cumsum = torch.cumsum(S, dim=0) / total
+ rank = int(torch.searchsorted(cumsum, target).item()) + 1
+ return max(1, min(rank, len(S) - 1))
+
+
+def _index_sv_fro(S: torch.Tensor, target: float) -> int:
+ """Frobenius norm mode - preserve target fraction of Frobenius norm."""
+ S_sq = S.pow(2)
+ total_sq = torch.sum(S_sq)
+ if total_sq < 1e-8:
+ return 1
+ cumsum = torch.cumsum(S_sq, dim=0) / total_sq
+ rank = int(torch.searchsorted(cumsum, target ** 2).item()) + 1
+ return max(1, min(rank, len(S) - 1))
+
+
+def _index_sv_knee(S: torch.Tensor, min_sv: float = 1e-8) -> int:
+ """Knee detection - find elbow point on singular value curve."""
+ n = len(S)
+ if n < 3:
+ return 1
+ s_max, s_min = S[0].item(), S[-1].item()
+ if s_max - s_min < min_sv:
+ return 1
+ # Normalize to [0, 1]
+ s_norm = (S - s_min) / (s_max - s_min)
+ x_norm = torch.linspace(0, 1, n, device=S.device, dtype=S.dtype)
+ # Distance from diagonal
+ distances = (x_norm + s_norm - 1).abs()
+ rank = torch.argmax(distances).item() + 1
+ return max(1, min(rank, n - 1))
+
+
+def _index_sv_cumulative_knee(S: torch.Tensor, min_sv: float = 1e-8) -> int:
+ """Knee detection on cumulative singular value curve."""
+ n = len(S)
+ if n < 3:
+ return 1
+ total = torch.sum(S)
+ if total < min_sv:
+ return 1
+ cumsum = torch.cumsum(S, dim=0) / total
+ y_min, y_max = cumsum[0].item(), cumsum[-1].item()
+ if y_max - y_min < min_sv:
+ return 1
+ y_norm = (cumsum - y_min) / (y_max - y_min)
+ x_norm = torch.linspace(0, 1, n, device=S.device, dtype=S.dtype)
+ distances = (y_norm - x_norm).abs()
+ rank = torch.argmax(distances).item() + 1
+ return max(1, min(rank, n - 1))
+
+
+def _index_sv_rel_decrease(S: torch.Tensor, tau: float = 0.1) -> int:
+ """Relative decrease mode - stop when consecutive SV ratio drops below tau."""
+ if len(S) < 2:
+ return 1
+ ratios = S[1:] / (S[:-1] + 1e-8)
+ for k in range(len(ratios)):
+ if ratios[k] < tau:
+ return k + 1
+ return len(S)
+
+
+def _compute_rank(S: torch.Tensor, mode: str, mode_param: float,
+ max_rank: int = None) -> int:
+ """Compute rank based on mode and parameters."""
+ if mode == "fixed":
+ rank = _index_sv_fixed(S, int(mode_param))
+ elif mode == "threshold":
+ rank = _index_sv_threshold(S, mode_param)
+ elif mode == "ratio":
+ rank = _index_sv_ratio(S, mode_param)
+ elif mode == "quantile" or mode == "sv_cumulative":
+ rank = _index_sv_cumulative(S, mode_param)
+ elif mode == "sv_fro":
+ rank = _index_sv_fro(S, mode_param)
+ elif mode == "sv_knee":
+ rank = _index_sv_knee(S)
+ elif mode == "sv_cumulative_knee":
+ rank = _index_sv_cumulative_knee(S)
+ elif mode == "sv_rel_decrease":
+ rank = _index_sv_rel_decrease(S, mode_param)
+ else:
+ rank = _index_sv_fixed(S, int(mode_param) if mode_param else 64)
+
+ if max_rank is not None:
+ rank = min(rank, max_rank)
+ return rank
+
+
+# =============================================================================
+# SVD Extraction Functions
+# =============================================================================
+
+def _svd_extract_linear_lowrank(
+ weight: torch.Tensor,
+ rank: int,
+ device: str,
+ clamp_quantile: float = 0.99,
+) -> tuple:
+ """
+ Low-rank SVD decomposition using svd_lowrank for fixed rank extraction.
+
+ Much faster than full SVD when rank << min(m, n) because it only computes
+ the top-k singular values using randomized algorithms.
+
+ Returns:
+ (lora_down, lora_up, diff), "low rank" or (weight, "full")
+ """
+ weight = weight.to(device=device, dtype=torch.float32)
+ out_dim, in_dim = weight.shape
+
+ # Clamp rank to valid range
+ max_possible_rank = min(out_dim, in_dim)
+ rank = max(1, min(rank, max_possible_rank - 1))
+
+ # Check if decomposition is worthwhile
+ if rank >= max_possible_rank // 2:
+ return weight, "full"
+
+ # Use svd_lowrank for faster low-rank approximation
+ # niter controls accuracy (higher = more accurate but slower)
+ try:
+ U, S, V = torch.svd_lowrank(weight, q=rank, niter=2)
+ # U: [out_dim, rank], S: [rank], V: [in_dim, rank]
+ Vh = V.T # [rank, in_dim]
+ except Exception as e:
+ raise RuntimeError(f"svd_lowrank failed: {e}")
+
+ # Clamp outliers
+ if clamp_quantile < 1.0 and len(S) > 0:
+ try:
+ max_val = torch.quantile(S, clamp_quantile)
+ S = S.clamp(max=max_val)
+ except RuntimeError:
+ pass
+
+ # Construct LoRA matrices
+ lora_up = U @ torch.diag(S) # [out_dim, rank]
+ lora_down = Vh # [rank, in_dim]
+
+ # Compute reconstruction diff
+ diff = weight - (lora_up @ lora_down)
+
+ return (lora_down, lora_up, diff), "low rank"
+
+
+def _svd_extract_linear(
+ weight: torch.Tensor,
+ mode: str,
+ mode_param: float,
+ device: str,
+ max_rank: int = None,
+ clamp_quantile: float = 0.99,
+) -> tuple:
+ """
+ SVD decomposition for linear layers.
+
+ For fixed rank mode, uses svd_lowrank which is much faster.
+ For adaptive modes, uses full SVD to analyze all singular values.
+
+ Returns:
+ (lora_down, lora_up, rank), "low rank" or (weight, "full")
+ """
+ weight = weight.to(device=device, dtype=torch.float32)
+ out_dim, in_dim = weight.shape
+
+ # For fixed rank, use the faster svd_lowrank
+ if mode == "fixed" and mode_param > 0:
+ target_rank = int(mode_param)
+ if max_rank is not None:
+ target_rank = min(target_rank, max_rank)
+ return _svd_extract_linear_lowrank(weight, target_rank, device, clamp_quantile)
+
+ # For adaptive modes, use full SVD to analyze singular values
+ try:
+ U, S, Vh = linalg.svd(weight, full_matrices=False)
+ except Exception as e:
+ raise RuntimeError(f"SVD failed: {e}")
+
+ # Compute rank based on mode
+ rank = _compute_rank(S, mode, mode_param, max_rank)
+
+ # Check if decomposition is worthwhile
+ if rank >= min(out_dim, in_dim) // 2:
+ return weight, "full"
+
+ # Truncate to rank
+ U = U[:, :rank]
+ S = S[:rank]
+ Vh = Vh[:rank, :]
+
+ # Clamp outliers
+ if clamp_quantile < 1.0 and len(S) > 0:
+ try:
+ max_val = torch.quantile(S, clamp_quantile)
+ S = S.clamp(max=max_val)
+ except RuntimeError:
+ pass # Skip clamping if quantile fails
+
+ # Construct LoRA matrices
+ # lora_up = U @ diag(S), lora_down = Vh
+ lora_up = U @ torch.diag(S) # [out_dim, rank]
+ lora_down = Vh # [rank, in_dim]
+
+ # Compute reconstruction diff for optional sparse bias
+ diff = weight - (lora_up @ lora_down)
+
+ return (lora_down, lora_up, diff), "low rank"
+
+
+def _svd_extract_conv(
+ weight: torch.Tensor,
+ mode: str,
+ mode_param: float,
+ device: str,
+ max_rank: int = None,
+ clamp_quantile: float = 0.99,
+) -> tuple:
+ """
+ SVD decomposition for conv2d layers.
+
+ Returns:
+ (lora_down, lora_up, rank), "low rank" or (weight, "full")
+ """
+ out_ch, in_ch, kh, kw = weight.shape
+ is_1x1 = (kh == 1 and kw == 1)
+
+ # Flatten for SVD
+ if is_1x1:
+ mat = weight.squeeze() # [out_ch, in_ch]
+ else:
+ mat = weight.reshape(out_ch, -1) # [out_ch, in_ch*k*k]
+
+ result, mode_str = _svd_extract_linear(mat, mode, mode_param, device, max_rank, clamp_quantile)
+
+ if mode_str == "full":
+ return weight, "full"
+
+ lora_down, lora_up, diff = result
+ rank = lora_down.shape[0]
+
+ # Reshape for conv
+ if is_1x1:
+ lora_down = lora_down.unsqueeze(-1).unsqueeze(-1) # [rank, in_ch, 1, 1]
+ lora_up = lora_up.unsqueeze(-1).unsqueeze(-1) # [out_ch, rank, 1, 1]
+ else:
+ lora_down = lora_down.reshape(rank, in_ch, kh, kw)
+ lora_up = lora_up.reshape(out_ch, rank, 1, 1)
+
+ return (lora_down, lora_up, diff), "low rank"
+
+
+# =============================================================================
+# Chunked Extraction for Large Layers
+# =============================================================================
+
+def _detect_fused_layer(key: str, shape: tuple) -> int:
+ """Detect fused QKV/MLP layers and return number of chunks."""
+ if len(shape) != 2:
+ return 1
+
+ out_dim, in_dim = shape
+ key_lower = key.lower()
+
+ # QKV layers (3 chunks)
+ if "qkv" in key_lower and out_dim % 3 == 0 and out_dim // 3 >= in_dim // 4:
+ return 3
+
+ # KV layers (2 chunks)
+ if "kv" in key_lower and "qkv" not in key_lower and out_dim % 2 == 0:
+ return 2
+
+ # Fused MLP (2 chunks)
+ if ("linear1" in key_lower or "gate_up" in key_lower) and out_dim % 2 == 0 and out_dim >= in_dim * 2:
+ return 2
+
+ return 1
+
+
+def _extract_chunked_layer(
+ weight_diff: torch.Tensor,
+ num_chunks: int,
+ mode: str,
+ mode_param: float,
+ device: str,
+ max_rank: int = None,
+) -> tuple:
+ """Extract LoRA from fused layer by chunking."""
+ out_dim, in_dim = weight_diff.shape
+ chunk_size = out_dim // num_chunks
+
+ all_lora_up = []
+ all_lora_down = []
+
+ for i in range(num_chunks):
+ chunk = weight_diff[i * chunk_size:(i + 1) * chunk_size, :]
+
+ try:
+ result, mode_str = _svd_extract_linear(chunk, mode, mode_param, device, max_rank)
+ if mode_str == "full":
+ return None, None, 0
+ lora_down, lora_up, _ = result
+ all_lora_down.append(lora_down)
+ all_lora_up.append(lora_up)
+ except Exception:
+ return None, None, 0
+
+ # Combine chunks
+ combined_lora_up = torch.cat(all_lora_up, dim=0)
+ combined_lora_down = torch.stack(all_lora_down, dim=0).mean(dim=0)
+ rank = combined_lora_down.shape[0]
+
+ return combined_lora_up, combined_lora_down, rank
+
+
+# =============================================================================
+# Pattern Matching
+# =============================================================================
+
+def _compile_patterns(pattern_string: str) -> list:
+ """Compile regex patterns from whitespace-separated string."""
+ if not pattern_string or not pattern_string.strip():
+ return []
+ patterns = []
+ for p in pattern_string.split():
+ p = p.strip()
+ if p:
+ try:
+ patterns.append(re.compile(p))
+ except re.error:
+ pass
+ return patterns
+
+
+def _matches_any_pattern(key: str, patterns: list) -> bool:
+ """Check if key matches any pattern."""
+ return any(p.search(key) for p in patterns)
+
+
+# =============================================================================
+# Main Extraction Function
+# =============================================================================
+
+def extract_lora_from_files(
+ model_a_path: str,
+ model_b_path: str,
+ mode: str,
+ linear_param: float,
+ conv_param: float,
+ prefix: str,
+ device: str,
+ save_dtype: str,
+ linear_max_rank: int = None,
+ conv_max_rank: int = None,
+ clamp_quantile: float = 0.99,
+ min_diff: float = 0.0,
+ skip_patterns_str: str = "",
+ mismatch_mode: str = "skip",
+ chunk_large_layers: bool = True,
+) -> dict[str, torch.Tensor]:
+ """
+ Extract LoRA from difference between two models.
+
+ Args:
+ model_a_path: Finetuned model path
+ model_b_path: Base model path
+ mode: Rank selection mode
+ linear_param: Mode parameter for linear layers
+ conv_param: Mode parameter for conv layers
+ prefix: LoRA key prefix (e.g. "lora_unet_")
+ device: Computation device
+ save_dtype: Output dtype
+ linear_max_rank: Max rank for linear layers
+ conv_max_rank: Max rank for conv layers
+ clamp_quantile: Quantile for weight clamping
+ min_diff: Minimum difference threshold
+ skip_patterns_str: Patterns for layers to skip
+ mismatch_mode: How to handle mismatches
+ chunk_large_layers: Enable chunked extraction
+ """
+ output_sd = {}
+
+ save_torch_dtype = {
+ "fp32": torch.float32,
+ "fp16": torch.float16,
+ "bf16": torch.bfloat16,
+ }.get(save_dtype, torch.float16)
+
+ skip_patterns = _compile_patterns(skip_patterns_str)
+
+ # Prepare memory before heavy operation
+ total_size_gb = estimate_model_size(model_a_path) + estimate_model_size(model_b_path)
+ print(f"[LoRA Extract] Preparing memory for {total_size_gb:.2f}GB operation...")
+ prepare_for_large_operation(total_size_gb * 1.5, torch.device(device)) # 1.5x for SVD headroom
+
+ handler_a = MemoryEfficientSafeOpen(model_a_path)
+ handler_b = MemoryEfficientSafeOpen(model_b_path)
+
+ try:
+ keys_a = set(handler_a.keys())
+ keys_b = set(handler_b.keys())
+
+ weight_keys = [k for k in keys_a if k.endswith(".weight")]
+
+ pbar = comfy.utils.ProgressBar(len(weight_keys))
+
+ stats = {"extracted": 0, "full": 0, "skipped": 0, "chunked": 0}
+
+ for key in tqdm(weight_keys, desc="Extracting LoRA", unit="layers"):
+ lora_key = key[:-7]
+ lora_name = prefix + lora_key.replace(".", "_")
+
+ if _matches_any_pattern(key, skip_patterns):
+ stats["skipped"] += 1
+ pbar.update(1)
+ continue
+
+ # Load tensors with pinned memory for CUDA
+ use_pinned = device == 'cuda'
+
+ if key not in keys_b:
+ if mismatch_mode == "skip":
+ stats["skipped"] += 1
+ pbar.update(1)
+ continue
+ cpu_a = handler_a.get_tensor(key)
+ if use_pinned:
+ weight_diff = transfer_to_gpu_pinned(cpu_a, device, torch.float32)
+ else:
+ weight_diff = cpu_a.to(device=device, dtype=torch.float32)
+ del cpu_a
+ else:
+ cpu_a = handler_a.get_tensor(key)
+ cpu_b = handler_b.get_tensor(key)
+
+ if use_pinned:
+ tensor_a = transfer_to_gpu_pinned(cpu_a, device, torch.float32)
+ tensor_b = transfer_to_gpu_pinned(cpu_b, device, torch.float32)
+ else:
+ tensor_a = cpu_a.to(device=device, dtype=torch.float32)
+ tensor_b = cpu_b.to(device=device, dtype=torch.float32)
+ del cpu_a, cpu_b
+
+ if tensor_a.shape != tensor_b.shape:
+ if mismatch_mode == "skip":
+ stats["skipped"] += 1
+ pbar.update(1)
+ del tensor_a, tensor_b
+ continue
+ weight_diff = tensor_a
+ del tensor_b
+ else:
+ weight_diff = tensor_a - tensor_b
+ del tensor_a, tensor_b
+
+ # Skip small differences
+ if min_diff > 0 and weight_diff.abs().max() < min_diff:
+ stats["skipped"] += 1
+ del weight_diff
+ pbar.update(1)
+ continue
+
+ # Skip 1D tensors
+ if weight_diff.ndim < 2:
+ stats["skipped"] += 1
+ del weight_diff
+ pbar.update(1)
+ continue
+
+ is_conv = weight_diff.ndim == 4
+
+ try:
+ if is_conv:
+ result, mode_str = _svd_extract_conv(
+ weight_diff, mode, conv_param, device, conv_max_rank, clamp_quantile
+ )
+ else:
+ result, mode_str = _svd_extract_linear(
+ weight_diff, mode, linear_param, device, linear_max_rank, clamp_quantile
+ )
+ except Exception as e:
+ # Try chunked extraction for large tensors
+ if chunk_large_layers and not is_conv:
+ num_chunks = _detect_fused_layer(key, weight_diff.shape)
+ if num_chunks > 1:
+ print(f"[LoRA Extract] Chunked: {key} ({num_chunks} chunks)")
+ lora_up, lora_down, rank = _extract_chunked_layer(
+ weight_diff, num_chunks, mode, linear_param, device, linear_max_rank
+ )
+ if lora_up is not None:
+ output_sd[f"{lora_name}.lora_up.weight"] = lora_up.to(save_torch_dtype).cpu().contiguous()
+ output_sd[f"{lora_name}.lora_down.weight"] = lora_down.to(save_torch_dtype).cpu().contiguous()
+ output_sd[f"{lora_name}.alpha"] = torch.tensor([rank]).to(save_torch_dtype)
+ stats["chunked"] += 1
+ del weight_diff
+ pbar.update(1)
+ continue
+
+ print(f"[LoRA Extract] Failed: {key}: {e}")
+ stats["skipped"] += 1
+ del weight_diff
+ pbar.update(1)
+ continue
+
+ # Store result
+ if mode_str == "full":
+ output_sd[f"{lora_name}.diff"] = weight_diff.to(save_torch_dtype).cpu().contiguous()
+ stats["full"] += 1
+ else:
+ lora_down, lora_up, _ = result
+ output_sd[f"{lora_name}.lora_down.weight"] = lora_down.to(save_torch_dtype).cpu().contiguous()
+ output_sd[f"{lora_name}.lora_up.weight"] = lora_up.to(save_torch_dtype).cpu().contiguous()
+ output_sd[f"{lora_name}.alpha"] = torch.tensor([lora_down.shape[0]]).to(save_torch_dtype)
+ stats["extracted"] += 1
+
+ del weight_diff
+ pbar.update(1)
+
+ print(f"[LoRA Extract] Done: {stats['extracted']} extracted, {stats['chunked']} chunked, "
+ f"{stats['full']} full, {stats['skipped']} skipped")
+
+ finally:
+ handler_a.__exit__(None, None, None)
+ handler_b.__exit__(None, None, None)
+ cleanup_after_operation()
+
+ return output_sd
+
+
+def _save_lora(output_sd: dict, output_filename: str) -> str:
+ """Save LoRA to file."""
+ if not output_sd:
+ raise ValueError("No LoRA weights extracted")
+
+ output_dir = folder_paths.get_folder_paths("loras")[0]
+ os.makedirs(output_dir, exist_ok=True)
+ output_path = os.path.join(output_dir, f"{output_filename.strip()}.safetensors")
+
+ save_file(output_sd, output_path)
+ print(f"[LoRA Extract] Saved to {output_path}")
+
+ return output_path
+
+
+# =============================================================================
+# Node Definitions
+# =============================================================================
+
+def _get_model_inputs():
+ return [
+ io.Combo.Input("model_a", options=folder_paths.get_filename_list("diffusion_models"),
+ tooltip="Finetuned model (A - B = LoRA)"),
+ io.Combo.Input("model_b", options=folder_paths.get_filename_list("diffusion_models"),
+ tooltip="Base model (A - B = LoRA)"),
+ ]
+
+
+def _get_common_inputs():
+ return [
+ io.Boolean.Input("chunk_large_layers", default=False,
+ tooltip="Split large fused layers (QKV, MLP) into chunks"),
+ io.Float.Input("clamp_quantile", default=0.99, min=0.5, max=1.0, step=0.01,
+ tooltip="Clamp outlier singular values"),
+ io.Float.Input("min_diff", default=0.0, min=0.0, max=1.0, step=0.001,
+ tooltip="Skip layers with max difference below this"),
+ io.Combo.Input("mismatch_mode", options=["skip", "zeros", "error"], default="skip"),
+ io.String.Input("output_filename", default="extracted_lora"),
+ io.Combo.Input("save_dtype", options=["fp16", "bf16", "fp32"], default="fp16"),
+ io.Combo.Input("device", options=["cuda", "cpu"], default="cuda"),
+ io.String.Input("skip_patterns", default="", multiline=True,
+ tooltip="Regex patterns for layers to skip"),
+ ]
+
+
+class LoRAExtractFixed(io.ComfyNode):
+ """Extract LoRA with fixed rank."""
+
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="LoRAExtractFixed",
+ display_name="LoRA Extract (Fixed Rank)",
+ category="ModelUtils/LoRA",
+ description="Extract LoRA with specified fixed rank for each layer type.",
+ inputs=[
+ *_get_model_inputs(),
+ io.Int.Input("linear_dim", default=64, min=1, max=4096,
+ tooltip="Rank for linear/attention layers"),
+ io.Int.Input("conv_dim", default=32, min=1, max=4096,
+ tooltip="Rank for conv layers"),
+ *_get_common_inputs(),
+ ],
+ outputs=[io.String.Output(display_name="output_path")],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(cls, model_a, model_b, linear_dim, conv_dim, chunk_large_layers,
+ clamp_quantile, min_diff, mismatch_mode, output_filename,
+ save_dtype, device, skip_patterns) -> io.NodeOutput:
+
+ model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
+ model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
+
+ output_sd = extract_lora_from_files(
+ model_a_path, model_b_path, "fixed", linear_dim, conv_dim,
+ "lora_unet_", device, save_dtype, linear_dim, conv_dim,
+ clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers
+ )
+
+ return io.NodeOutput(_save_lora(output_sd, output_filename))
+
+
+class LoRAExtractRatio(io.ComfyNode):
+ """Extract LoRA with singular value ratio threshold."""
+
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="LoRAExtractRatio",
+ display_name="LoRA Extract (Ratio)",
+ category="ModelUtils/LoRA",
+ description="Keep singular values > max(S) × ratio.",
+ inputs=[
+ *_get_model_inputs(),
+ io.Float.Input("linear_ratio", default=0.5, min=0.0, max=1.0, step=0.01,
+ tooltip="Ratio threshold for linear layers"),
+ io.Float.Input("conv_ratio", default=0.5, min=0.0, max=1.0, step=0.01,
+ tooltip="Ratio threshold for conv layers"),
+ *_get_common_inputs(),
+ ],
+ outputs=[io.String.Output(display_name="output_path")],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(cls, model_a, model_b, linear_ratio, conv_ratio, chunk_large_layers,
+ clamp_quantile, min_diff, mismatch_mode, output_filename,
+ save_dtype, device, skip_patterns) -> io.NodeOutput:
+
+ model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
+ model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
+
+ output_sd = extract_lora_from_files(
+ model_a_path, model_b_path, "ratio", linear_ratio, conv_ratio,
+ "lora_unet_", device, save_dtype, None, None,
+ clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers
+ )
+
+ return io.NodeOutput(_save_lora(output_sd, output_filename))
+
+
+class LoRAExtractQuantile(io.ComfyNode):
+ """Extract LoRA with cumulative singular value quantile."""
+
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="LoRAExtractQuantile",
+ display_name="LoRA Extract (Quantile)",
+ category="ModelUtils/LoRA",
+ description="Keep enough singular values to reach target percentage.",
+ inputs=[
+ *_get_model_inputs(),
+ io.Float.Input("linear_quantile", default=0.9, min=0.0, max=1.0, step=0.01,
+ tooltip="Target cumulative % for linear layers"),
+ io.Float.Input("conv_quantile", default=0.9, min=0.0, max=1.0, step=0.01,
+ tooltip="Target cumulative % for conv layers"),
+ *_get_common_inputs(),
+ ],
+ outputs=[io.String.Output(display_name="output_path")],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(cls, model_a, model_b, linear_quantile, conv_quantile, chunk_large_layers,
+ clamp_quantile, min_diff, mismatch_mode, output_filename,
+ save_dtype, device, skip_patterns) -> io.NodeOutput:
+
+ model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
+ model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
+
+ output_sd = extract_lora_from_files(
+ model_a_path, model_b_path, "quantile", linear_quantile, conv_quantile,
+ "lora_unet_", device, save_dtype, None, None,
+ clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers
+ )
+
+ return io.NodeOutput(_save_lora(output_sd, output_filename))
+
+
+class LoRAExtractKnee(io.ComfyNode):
+ """Extract LoRA with automatic knee detection."""
+
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="LoRAExtractKnee",
+ display_name="LoRA Extract (Knee Detection)",
+ category="ModelUtils/LoRA",
+ description="Automatically find optimal rank using knee detection on singular value curve.",
+ inputs=[
+ *_get_model_inputs(),
+ io.Combo.Input("knee_method", options=["sv_knee", "sv_cumulative_knee"],
+ default="sv_knee", tooltip="Knee detection method"),
+ *_get_common_inputs(),
+ ],
+ outputs=[io.String.Output(display_name="output_path")],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(cls, model_a, model_b, knee_method, chunk_large_layers,
+ clamp_quantile, min_diff, mismatch_mode, output_filename,
+ save_dtype, device, skip_patterns) -> io.NodeOutput:
+
+ model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
+ model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
+
+ output_sd = extract_lora_from_files(
+ model_a_path, model_b_path, knee_method, 0, 0,
+ "lora_unet_", device, save_dtype, None, None,
+ clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers
+ )
+
+ return io.NodeOutput(_save_lora(output_sd, output_filename))
+
+
+class LoRAExtractFrobenius(io.ComfyNode):
+ """Extract LoRA preserving target fraction of Frobenius norm."""
+
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="LoRAExtractFrobenius",
+ display_name="LoRA Extract (Frobenius)",
+ category="ModelUtils/LoRA",
+ description="Preserve target fraction of Frobenius norm.",
+ inputs=[
+ *_get_model_inputs(),
+ io.Float.Input("linear_target", default=0.9, min=0.0, max=1.0, step=0.01,
+ tooltip="Target Frobenius norm fraction for linear"),
+ io.Float.Input("conv_target", default=0.9, min=0.0, max=1.0, step=0.01,
+ tooltip="Target Frobenius norm fraction for conv"),
+ *_get_common_inputs(),
+ ],
+ outputs=[io.String.Output(display_name="output_path")],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(cls, model_a, model_b, linear_target, conv_target, chunk_large_layers,
+ clamp_quantile, min_diff, mismatch_mode, output_filename,
+ save_dtype, device, skip_patterns) -> io.NodeOutput:
+
+ model_a_path = folder_paths.get_full_path_or_raise("diffusion_models", model_a)
+ model_b_path = folder_paths.get_full_path_or_raise("diffusion_models", model_b)
+
+ output_sd = extract_lora_from_files(
+ model_a_path, model_b_path, "sv_fro", linear_target, conv_target,
+ "lora_unet_", device, save_dtype, None, None,
+ clamp_quantile, min_diff, skip_patterns, mismatch_mode, chunk_large_layers
+ )
+
+ return io.NodeOutput(_save_lora(output_sd, output_filename))
diff --git a/nodes/merger.py b/nodes/merger.py
index 3679565..da4d625 100644
--- a/nodes/merger.py
+++ b/nodes/merger.py
@@ -3,10 +3,14 @@ import re
import torch
import folder_paths
import comfy.utils
+from tqdm import tqdm
from comfy_api.latest import io
from safetensors.torch import save_file
from .merger_ops import TWO_MODEL_MODES, THREE_MODEL_MODES, MissingTensorBehavior, MissingTensorError
-from .merger_utils import MemoryEfficientSafeOpen
+from .merger_utils import MemoryEfficientSafeOpen, transfer_to_gpu_pinned
+from .device_utils import (
+ estimate_model_size, prepare_for_large_operation, cleanup_after_operation
+)
def load_documentation_from_file(filename):
@@ -22,13 +26,13 @@ def load_documentation_from_file(filename):
def _compile_patterns(pattern_string):
"""Compiles whitespace-separated regex patterns into a list of compiled patterns.
-
+
Returns empty list if pattern_string is empty or whitespace-only.
Raises ValueError if any pattern is invalid regex.
"""
if not pattern_string or not pattern_string.strip():
return []
-
+
patterns = []
for pattern in pattern_string.split():
try:
@@ -48,7 +52,7 @@ def _matches_any_pattern(key, patterns):
class MergerLogic:
"""Shared merge execution logic for all merger nodes."""
-
+
@staticmethod
def execute_merge(model_names, calc_mode, all_modes, recipe_params, model_type):
calc_mode_class = next((m for m in all_modes if m.name == calc_mode), None)
@@ -57,7 +61,22 @@ class MergerLogic:
primary_model_name = model_names.get('model_a')
if not primary_model_name or primary_model_name == "None":
raise ValueError("Model A is required to run the merge.")
-
+
+ # Prepare memory before heavy operation
+ total_size_gb = 0
+ model_paths = []
+ for name in model_names.values():
+ if name and name != "None":
+ path = folder_paths.get_full_path(model_type, name)
+ if path:
+ total_size_gb += estimate_model_size(path)
+ model_paths.append(path)
+
+ if total_size_gb > 0:
+ process_device = recipe_params.get('device', 'cpu')
+ print(f"[Merger] Preparing memory for {total_size_gb:.2f}GB merge operation...")
+ prepare_for_large_operation(total_size_gb * 1.2, torch.device(process_device))
+
handlers = {}
for name in model_names.values():
if name and name != "None":
@@ -65,20 +84,20 @@ class MergerLogic:
if not path:
raise FileNotFoundError(f"Model '{name}' not found.")
handlers[name] = MemoryEfficientSafeOpen(path)
-
+
primary_handler = handlers[primary_model_name]
all_keys = primary_handler.keys()
metadata = primary_handler.header.get("__metadata__", {})
-
+
# Convert mismatch_mode string to enum
mismatch_mode_str = recipe_params.get('mismatch_mode', 'skip')
mismatch_mode = MissingTensorBehavior(mismatch_mode_str)
recipe_params['mismatch_mode'] = mismatch_mode
-
+
# Compile filter patterns
exclude_patterns = _compile_patterns(recipe_params.get('exclude_patterns', ''))
discard_patterns = _compile_patterns(recipe_params.get('discard_patterns', ''))
-
+
# Pre-compute key differences for logging
primary_keys = set(all_keys)
for name, handler in handlers.items():
@@ -90,7 +109,7 @@ class MergerLogic:
print(f"[Merger] {name} is missing {len(missing)} keys present in Model A")
if extra:
print(f"[Merger] {name} has {len(extra)} extra keys not in Model A (ignored)")
-
+
merged_state_dict = {}
pbar = comfy.utils.ProgressBar(len(all_keys))
save_dtype = recipe_params.pop('save_dtype')
@@ -98,34 +117,39 @@ class MergerLogic:
recipe_params.update({"handlers": handlers})
process_device = recipe_params.get('device', 'cpu')
process_dtype = recipe_params.get('dtype', torch.float32)
-
+
skipped_keys = 0
excluded_keys = 0
discarded_keys = 0
error_keys = []
-
- for key in all_keys:
+
+ for key in tqdm(all_keys, desc="Merging layers", unit="layers"):
# Check discard patterns first - skip entirely
if _matches_any_pattern(key, discard_patterns):
discarded_keys += 1
pbar.update(1)
continue
-
- # Pre-load Model A's tensor
- tensor_a = primary_handler.get_tensor(key).to(device=process_device, dtype=process_dtype)
-
+
+ # Pre-load Model A's tensor with pinned memory for CUDA
+ cpu_tensor = primary_handler.get_tensor(key)
+ if process_device == 'cuda':
+ tensor_a = transfer_to_gpu_pinned(cpu_tensor, process_device, process_dtype)
+ else:
+ tensor_a = cpu_tensor.to(device=process_device, dtype=process_dtype)
+ del cpu_tensor
+
# Check exclude patterns - use Model A only, no merge
if _matches_any_pattern(key, exclude_patterns):
merged_state_dict[key] = tensor_a.to(save_torch_dtype).cpu().clone()
excluded_keys += 1
pbar.update(1)
continue
-
+
# Pass tensor_a metadata to recipes for zeros mode and fallback
recipe_params['_tensor_a'] = tensor_a
recipe_params['_tensor_a_shape'] = tensor_a.shape
recipe_params['_tensor_a_dtype'] = tensor_a.dtype
-
+
try:
recipe = calc_mode_class.create_recipe(key=key, **recipe_params)
result = recipe.merge()
@@ -134,22 +158,22 @@ class MergerLogic:
raise ValueError(f"Layer mismatch error (mismatch_mode='error'): {e}")
result = None
error_keys.append(key)
-
+
# Handle None result (mismatch occurred with skip mode)
if result is None:
result = tensor_a
skipped_keys += 1
-
+
if isinstance(result, dict):
for r_key, r_tensor in result.items():
merged_state_dict[r_key] = r_tensor.to(save_torch_dtype).cpu().clone()
else:
merged_state_dict[key] = result.to(save_torch_dtype).cpu().clone()
-
+
# Clean up tensor_a reference to allow GC
del recipe_params['_tensor_a']
pbar.update(1)
-
+
# Log summary
if excluded_keys > 0:
print(f"[Merger] Excluded {excluded_keys} keys from merge (kept Model A only)")
@@ -159,15 +183,22 @@ class MergerLogic:
print(f"[Merger] Used Model A's values for {skipped_keys} keys due to mismatches")
if error_keys:
print(f"[Merger] Unexpected errors on {len(error_keys)} keys: {error_keys[:5]}{'...' if len(error_keys) > 5 else ''}")
-
+
for handler in handlers.values():
handler.__exit__(None, None, None)
+
output_folder = "loras" if calc_mode == "SVD LoRA Extraction" else model_type
- output_dir = folder_paths.get_folder_paths(output_folder)[0]
+ # Use [-1] for diffusion_models to get the actual diffusion_models folder, not legacy unet
+ output_dir = folder_paths.get_folder_paths(output_folder)[-1]
os.makedirs(output_dir, exist_ok=True)
output_filename = recipe_params.get("output_filename")
output_path = os.path.join(output_dir, f"{output_filename}.safetensors")
save_file(merged_state_dict, output_path, metadata=metadata)
+
+ # Cleanup after heavy operation
+ del merged_state_dict
+ cleanup_after_operation()
+
return f"{output_filename}.safetensors"
diff --git a/nodes/merger_ops.py b/nodes/merger_ops.py
index be93a92..02b5f79 100644
--- a/nodes/merger_ops.py
+++ b/nodes/merger_ops.py
@@ -61,7 +61,7 @@ class LoadTensor(Operation):
def merge(self) -> torch.Tensor:
if self.model_name not in self.handlers:
raise ValueError(f"Model '{self.model_name}' is required for this mode but was not provided.")
-
+
handler = self.handlers[self.model_name]
if self.key not in handler.keys():
if self.on_missing == MissingTensorBehavior.ERROR:
@@ -77,7 +77,7 @@ class LoadTensor(Operation):
)
dtype = self.fallback_dtype if self.fallback_dtype else self.dtype
return torch.zeros(self.fallback_shape, device=self.device, dtype=dtype)
-
+
return handler.get_tensor(self.key).to(device=self.device, dtype=self.dtype)
class Multiply(Operation):
@@ -200,7 +200,7 @@ class InterpolateDifference(Operation):
class CalcMode:
name = 'calcmode'; description = 'description'; models_used = []; param_docs = {}
def create_recipe(self, key, **kwargs) -> Operation: raise NotImplementedError
-
+
@staticmethod
def _get_secondary_loader_args(kwargs, mismatch_mode):
"""Helper to build loader args for secondary models (B, C) with fallback support."""
@@ -219,13 +219,13 @@ class CalcMode:
class PreloadedTensor(Operation):
"""Returns a pre-loaded tensor instead of loading from file.
-
+
Used when Model A's tensor is pre-loaded in the merge loop.
"""
def __init__(self, key, tensor):
super().__init__(key)
self.tensor = tensor
-
+
def merge(self) -> torch.Tensor:
return self.tensor
@@ -236,17 +236,17 @@ class WeightSum(CalcMode):
param_docs = {'alpha': 'Interpolation weight. 0.0 is 100% Model A, 1.0 is 100% Model B.'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
# Use pre-loaded tensor_a if available
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
loader_args_b = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_b)
-
+
if kwargs['alpha'] >= 1: return b
if kwargs['alpha'] <= 0: return a
c = Multiply(key, 1 - kwargs['alpha'], a)
@@ -261,13 +261,13 @@ class AddDifference(CalcMode):
param_docs = {'alpha': 'Multiplier for the (B - C) difference.', 'beta': 'Smoothing (0=Off, 1=On).'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
loader_args_bc = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_bc)
c = LoadTensor(key, kwargs['model_c'], **loader_args_bc)
@@ -284,13 +284,13 @@ class TrainDifference(CalcMode):
param_docs = {'alpha': 'Multiplier for the calculated difference.'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
loader_args_bc = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_bc)
c = LoadTensor(key, kwargs['model_c'], **loader_args_bc)
@@ -304,17 +304,17 @@ class InterpDifference(CalcMode):
param_docs = {'alpha': 'Curve strength.', 'beta': 'Style (0=Similarity, 1=Difference).', 'gamma': 'Mix (0=Random, 1=Linear).'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
# Skip certain keys entirely
if key.startswith('cond_stage_model.transformer.text_model.embeddings'):
return a
-
+
loader_args_b = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_b)
return InterpolateDifference(key, kwargs['alpha'], kwargs['beta'], kwargs['gamma'], kwargs['seed'], a, b)
@@ -327,13 +327,13 @@ class Extract(CalcMode):
param_docs = {'alpha': 'Weighting B vs C.', 'beta': 'Similarity vs dissimilarity.', 'gamma': 'Similarity bias.', 'delta': 'Multiplier for final features.'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
loader_args_bc = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_bc)
c = LoadTensor(key, kwargs['model_c'], **loader_args_bc)
@@ -349,13 +349,13 @@ class AddDisimilarity(CalcMode):
param_docs = {'alpha': 'Weighting B vs C.', 'beta': 'Multiplier for final features.', 'gamma': 'Similarity bias.'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
loader_args_bc = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_bc)
c = LoadTensor(key, kwargs['model_c'], **loader_args_bc)
@@ -371,13 +371,13 @@ class PowerUp(CalcMode):
param_docs = {'alpha': 'Dropout rate (proportion of weights from B to *drop*).', 'beta': 'Multiplier for the final difference.'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
loader_args_b = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_b)
deltahat = PowerUpOp(key, kwargs['alpha'], kwargs['seed'], a, b)
@@ -392,13 +392,13 @@ class SVDMode(CalcMode):
param_docs = {'alpha': 'Rank for standard layers.', 'beta': 'Rank for 3x3 conv layers.', 'gamma': 'Clamp quantile for weights.'}
def create_recipe(self, key, **kwargs):
mismatch_mode = kwargs.get('mismatch_mode', MissingTensorBehavior.SKIP)
-
+
if '_tensor_a' in kwargs:
a = PreloadedTensor(key, kwargs['_tensor_a'])
else:
loader_args_a = {"handlers": kwargs['handlers'], "device": kwargs['device'], "dtype": kwargs['dtype']}
a = LoadTensor(key, kwargs['model_a'], **loader_args_a)
-
+
loader_args_b = self._get_secondary_loader_args(kwargs, mismatch_mode)
b = LoadTensor(key, kwargs['model_b'], **loader_args_b)
return SVD(key, kwargs['alpha'], kwargs['beta'], kwargs['gamma'], a, b)
diff --git a/nodes/merger_utils.py b/nodes/merger_utils.py
index 59a0c4a..aadb5a4 100644
--- a/nodes/merger_utils.py
+++ b/nodes/merger_utils.py
@@ -1,36 +1,266 @@
+"""
+Memory-efficient safetensors utilities with parallel I/O and pinned memory support.
+
+Based on benchmarking, parallel reading with ThreadPoolExecutor provides 2-4x speedup
+for large models compared to sequential reads.
+
+Worker count is automatically optimized based on device capabilities when workers=None.
+Pinned memory enables faster CPU→GPU transfers (2-3x improvement).
+"""
+import os
+import mmap
import json
import struct
import torch
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Dict, List, Optional, Any, Tuple
+
+# Import ComfyUI's memory pinning functions if available
+try:
+ from comfy.model_management import pin_memory, unpin_memory, is_device_cuda
+ PINNING_AVAILABLE = True
+except ImportError:
+ PINNING_AVAILABLE = False
+ def pin_memory(tensor): return False
+ def unpin_memory(tensor): return False
+ def is_device_cuda(device): return str(device).startswith('cuda')
+
+
+class PinnedTensor:
+ """Context manager for automatic pinned memory management.
+
+ Usage:
+ with PinnedTensor(cpu_tensor) as pinned:
+ gpu_tensor = pinned.to('cuda')
+ # Memory automatically unpinned after transfer
+ """
+
+ def __init__(self, tensor: torch.Tensor, auto_pin: bool = True):
+ self.tensor = tensor
+ self.is_pinned = False
+ self.auto_pin = auto_pin and PINNING_AVAILABLE
+
+ def __enter__(self) -> torch.Tensor:
+ if self.auto_pin and self.tensor.device.type == 'cpu':
+ self.is_pinned = pin_memory(self.tensor)
+ return self.tensor
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if self.is_pinned:
+ unpin_memory(self.tensor)
+ self.is_pinned = False
+
+
+def transfer_to_gpu_pinned(
+ tensor: torch.Tensor,
+ device: str = 'cuda',
+ dtype: Optional[torch.dtype] = None
+) -> torch.Tensor:
+ """Transfer tensor to GPU using pinned memory for faster transfer.
+
+ Args:
+ tensor: CPU tensor to transfer
+ device: Target GPU device
+ dtype: Optional dtype conversion
+
+ Returns:
+ Tensor on GPU device
+ """
+ if not PINNING_AVAILABLE or not is_device_cuda(torch.device(device)):
+ # Fall back to regular transfer
+ if dtype:
+ return tensor.to(device=device, dtype=dtype)
+ return tensor.to(device=device)
+
+ # Pin memory for faster transfer
+ was_pinned = pin_memory(tensor)
+ try:
+ if dtype:
+ result = tensor.to(device=device, dtype=dtype, non_blocking=True)
+ else:
+ result = tensor.to(device=device, non_blocking=True)
+ # Sync to ensure transfer is complete before unpinning
+ if was_pinned:
+ torch.cuda.current_stream().synchronize()
+ return result
+ finally:
+ if was_pinned:
+ unpin_memory(tensor)
+
class MemoryEfficientSafeOpen:
- def __init__(self, filename, device='cpu'):
+ """Memory-efficient safetensors file reader with mmap, parallel I/O, and pinned memory support.
+
+ Features:
+ - mmap mode: Zero-copy tensor access via memory-mapped file
+ - Parallel loading: Multi-threaded tensor reads for 2-4x speedup
+ - Sorted batch reads: Keys sorted by file offset for sequential I/O
+ - Auto-optimized workers: Adjusts parallelism based on device capabilities
+ - Pinned memory: Faster CPU→GPU transfers when transferring to CUDA
+
+ Args:
+ filename: Path to safetensors file
+ device: Target device (default 'cpu')
+ mmap_mode: Use memory-mapped file for zero-copy (default True)
+ """
+
+ def __init__(self, filename: str, device: str = 'cpu', mmap_mode: bool = True):
self.filename = filename
self.device = device
+ self.mmap_mode = mmap_mode
self.header, self.header_size = self._read_header()
self.file = open(filename, "rb")
+ self.mmap_obj = None
+
+ if self.mmap_mode:
+ self.mmap_obj = mmap.mmap(self.file.fileno(), 0, access=mmap.ACCESS_READ)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
+ if self.mmap_obj:
+ self.mmap_obj.close()
self.file.close()
- def keys(self):
+ def keys(self) -> List[str]:
+ """Return all tensor keys (excluding metadata)."""
return [k for k in self.header.keys() if k != "__metadata__"]
- def get_tensor(self, key):
+ def metadata(self) -> Dict[str, str]:
+ """Return file metadata."""
+ return self.header.get("__metadata__", {})
+
+ def keys_sorted_by_offset(self) -> List[str]:
+ """Return keys sorted by file offset for optimal sequential I/O."""
+ keys_with_offsets = []
+ for key in self.keys():
+ offset = self.header[key]["data_offsets"][0]
+ keys_with_offsets.append((key, offset))
+ keys_with_offsets.sort(key=lambda x: x[1])
+ return [k for k, _ in keys_with_offsets]
+
+ def get_tensor(self, key: str) -> torch.Tensor:
+ """Load a single tensor by key."""
if key not in self.header:
raise KeyError(f"Tensor '{key}' not found in the file")
metadata = self.header[key]
offset_start, offset_end = metadata["data_offsets"]
- tensor_bytes = None
- if offset_start != offset_end:
+ if self.mmap_mode and self.mmap_obj:
+ if offset_start != offset_end:
+ start = self.header_size + 8 + offset_start
+ end = self.header_size + 8 + offset_end
+ tensor_bytes = memoryview(self.mmap_obj)[start:end]
+ else:
+ tensor_bytes = None
+ else:
+ tensor_bytes = None
+ if offset_start != offset_end:
+ self.file.seek(self.header_size + 8 + offset_start)
+ tensor_bytes = self.file.read(offset_end - offset_start)
+
+ return self._deserialize_tensor(tensor_bytes, metadata)
+
+ def get_tensor_to_gpu(
+ self,
+ key: str,
+ device: str = 'cuda',
+ dtype: Optional[torch.dtype] = None
+ ) -> torch.Tensor:
+ """Load a tensor and transfer to GPU using pinned memory for faster transfer.
+
+ Args:
+ key: Tensor key to load
+ device: Target GPU device (default 'cuda')
+ dtype: Optional dtype conversion
+
+ Returns:
+ Tensor on GPU device
+ """
+ cpu_tensor = self.get_tensor(key)
+ return transfer_to_gpu_pinned(cpu_tensor, device, dtype)
+
+ def get_tensors_parallel(
+ self,
+ keys: Optional[List[str]] = None,
+ workers: Optional[int] = None,
+ sort_by_offset: bool = True
+ ) -> Dict[str, torch.Tensor]:
+ """Load multiple tensors in parallel using ThreadPoolExecutor.
+
+ Args:
+ keys: List of keys to load (default: all keys)
+ workers: Number of worker threads (default: auto-calculated based on device)
+ sort_by_offset: Sort keys by file offset before loading (default: True)
+
+ Returns:
+ Dict mapping key -> tensor
+ """
+ if keys is None:
+ keys = self.keys()
+
+ # Auto-calculate workers based on device capabilities
+ if workers is None:
+ try:
+ from .device_utils import get_optimal_workers, estimate_model_size
+ model_size = estimate_model_size(self.filename)
+ workers = get_optimal_workers(model_size)
+ except ImportError:
+ workers = min(4, os.cpu_count() or 4)
+
+ if sort_by_offset:
+ # Sort by offset for better I/O patterns
+ keys_with_offsets = [(k, self.header[k]["data_offsets"][0]) for k in keys]
+ keys_with_offsets.sort(key=lambda x: x[1])
+ keys = [k for k, _ in keys_with_offsets]
+
+ result = {}
+
+ with ThreadPoolExecutor(max_workers=workers) as executor:
+ futures = {
+ executor.submit(
+ _read_tensor_from_file,
+ self.filename,
+ key,
+ self.header[key],
+ self.header_size
+ ): key
+ for key in keys
+ }
+
+ for future in as_completed(futures):
+ key = futures[future]
+ try:
+ tensor = future.result()
+ if tensor is not None:
+ result[key] = tensor
+ except Exception as e:
+ print(f"[MemoryEfficientSafeOpen] Failed to load {key}: {e}")
+
+ return result
+
+ def get_tensor_as_dict(self, key: str) -> Dict[str, Any]:
+ """Load a tensor and decode as JSON dict (for config tensors)."""
+ if key not in self.header:
+ raise KeyError(f"Tensor '{key}' not found")
+
+ metadata = self.header[key]
+ offset_start, offset_end = metadata["data_offsets"]
+
+ if offset_start == offset_end:
+ return {}
+
+ if self.mmap_mode and self.mmap_obj:
+ start = self.header_size + 8 + offset_start
+ end = self.header_size + 8 + offset_end
+ tensor_bytes = bytes(self.mmap_obj[start:end])
+ else:
self.file.seek(self.header_size + 8 + offset_start)
tensor_bytes = self.file.read(offset_end - offset_start)
- return self._deserialize_tensor(tensor_bytes, metadata)
+ return json.loads(tensor_bytes.decode("utf-8"))
def _read_header(self):
with open(self.filename, "rb") as f:
@@ -80,11 +310,52 @@ class MemoryEfficientSafeOpen:
raise ValueError(f"Unsupported float8 type: {dtype_str}. Your PyTorch version may be too old.")
-def get_union_keys(*handlers):
- """Returns the union of all tensor keys across multiple model handlers.
-
- Useful when you want to iterate over all unique keys present in any model.
+def _read_tensor_from_file(
+ filepath: str,
+ key: str,
+ metadata: Dict,
+ header_size: int
+) -> Optional[torch.Tensor]:
+ """Helper function to read a single tensor from file (for parallel execution).
+
+ Each worker opens its own file handle for thread safety.
"""
+ offset_start, offset_end = metadata["data_offsets"]
+ dtype_str = metadata["dtype"]
+ shape = metadata["shape"]
+
+ if offset_start == offset_end:
+ return None
+
+ dtype_map = {
+ "F64": torch.float64, "F32": torch.float32, "F16": torch.float16, "BF16": torch.bfloat16,
+ "I64": torch.int64, "I32": torch.int32, "I16": torch.int16, "I8": torch.int8,
+ "U8": torch.uint8, "BOOL": torch.bool,
+ }
+ if hasattr(torch, "float8_e5m2"):
+ dtype_map["F8_E5M2"] = torch.float8_e5m2
+ if hasattr(torch, "float8_e4m3fn"):
+ dtype_map["F8_E4M3"] = torch.float8_e4m3fn
+
+ dtype = dtype_map.get(dtype_str, torch.float32)
+
+ with open(filepath, "rb") as f:
+ f.seek(header_size + 8 + offset_start)
+ tensor_bytes = f.read(offset_end - offset_start)
+
+ byte_tensor = torch.frombuffer(bytearray(tensor_bytes), dtype=torch.uint8)
+
+ if dtype_str in ["F8_E5M2", "F8_E4M3"]:
+ if dtype_str == "F8_E5M2" and hasattr(torch, "float8_e5m2"):
+ return byte_tensor.view(torch.float8_e5m2).reshape(shape)
+ elif dtype_str == "F8_E4M3" and hasattr(torch, "float8_e4m3fn"):
+ return byte_tensor.view(torch.float8_e4m3fn).reshape(shape)
+
+ return byte_tensor.view(dtype).reshape(shape)
+
+
+def get_union_keys(*handlers) -> List[str]:
+ """Returns the union of all tensor keys across multiple model handlers."""
all_keys = set()
for handler in handlers:
if handler is not None:
@@ -92,11 +363,8 @@ def get_union_keys(*handlers):
return sorted(all_keys)
-def get_intersection_keys(*handlers):
- """Returns only keys present in ALL model handlers.
-
- Useful when you want to iterate only over keys that exist in every model.
- """
+def get_intersection_keys(*handlers) -> List[str]:
+ """Returns only keys present in ALL model handlers."""
handlers = [h for h in handlers if h is not None]
if not handlers:
return []
diff --git a/nodes/metakeys.py b/nodes/metakeys.py
index 626a383..4a869f2 100644
--- a/nodes/metakeys.py
+++ b/nodes/metakeys.py
@@ -1,15 +1,17 @@
import os
+import json
+import struct
import folder_paths
+import comfy.utils
+from tqdm import tqdm
from comfy_api.latest import io
-from .utils import convert_pt_to_safetensors, load_metadata_from_safetensors
+from safetensors import safe_open
+from .utils import convert_pt_to_safetensors
def _get_metakeys(model_name: str, model_type: str) -> tuple[str, str]:
"""Shared logic for all MetaKeys nodes."""
model_path = folder_paths.get_full_path_or_raise(model_type, model_name)
- model_weights = {}
- metadata = {}
- layer_shapes = ""
if model_path.endswith(('.pt', '.pth', '.bin', '.ckpt')):
temp_safe_path = model_path + ".safetensors"
@@ -19,13 +21,26 @@ def _get_metakeys(model_name: str, model_type: str) -> tuple[str, str]:
return f"Conversion failed: {error_message}", ""
model_path = temp_safe_path
- model_weights, metadata = load_metadata_from_safetensors(model_path)
+ # Read header only - no tensor loading needed
+ with open(model_path, "rb") as f:
+ header_size = struct.unpack(" str:
"""Shared logic for all PruneKeys nodes."""
model_path = folder_paths.get_full_path_or_raise(model_type, model_name)
@@ -21,31 +26,47 @@ def _prune_keys(model_name: str, model_type: str, keys_to_prune_str: str,
else:
model_path_to_load = model_path
- model_weights, metadata = load_metadata_from_safetensors(model_path_to_load)
+ # Prepare memory before operation
+ model_size_gb = estimate_model_size(model_path_to_load)
+ prepare_for_large_operation(model_size_gb * 1.2)
+
+ # Get metadata from safe_open (header only, no tensor loading)
+ with safe_open(model_path_to_load, framework="pt", device="cpu") as f:
+ metadata = f.metadata() or {}
patterns = [p.strip() for p in keys_to_prune_str.strip().split('\n') if p.strip()]
if not patterns:
raise ValueError("No keys/patterns provided to prune.")
+ # Stream tensors and filter on the fly
pruned_tensors = {}
- for key, tensor in model_weights.items():
- is_match = False
- if use_regex:
- if any(re.search(pattern, key) for pattern in patterns):
- is_match = True
- else:
- if any(pattern in key for pattern in patterns):
- is_match = True
+ with MemoryEfficientSafeOpen(model_path_to_load) as handler:
+ all_keys = handler.keys()
+ pbar = comfy.utils.ProgressBar(len(all_keys))
+ for key in tqdm(all_keys, desc="Pruning keys", unit="keys"):
+ is_match = False
+ if use_regex:
+ if any(re.search(pattern, key) for pattern in patterns):
+ is_match = True
+ else:
+ if any(pattern in key for pattern in patterns):
+ is_match = True
- if not is_match:
- pruned_tensors[key] = tensor
+ if not is_match:
+ pruned_tensors[key] = handler.get_tensor(key)
+ pbar.update(1)
- model_dir = folder_paths.get_folder_paths(model_type)[0]
+ # Use [-1] for diffusion_models to get the actual diffusion_models folder, not legacy unet
+ model_dir = folder_paths.get_folder_paths(model_type)[-1]
output_path = os.path.join(model_dir, f"{output_filename.strip()}.safetensors")
save_file(pruned_tensors, output_path, metadata)
+ # Cleanup after operation
+ del pruned_tensors
+ cleanup_after_operation()
+
return output_path
@@ -72,9 +93,9 @@ class ModelPruneKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, diffusionmodel_name: str, keys_to_prune: str,
+ def execute(cls, diffusionmodel_name: str, keys_to_prune: str,
use_regex: bool, output_filename: str) -> io.NodeOutput:
- path = _prune_keys(diffusionmodel_name, "diffusion_models",
+ path = _prune_keys(diffusionmodel_name, "diffusion_models",
keys_to_prune, use_regex, output_filename)
return io.NodeOutput(path)
@@ -102,9 +123,9 @@ class TextEncoderPruneKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, textencoder_name: str, keys_to_prune: str,
+ def execute(cls, textencoder_name: str, keys_to_prune: str,
use_regex: bool, output_filename: str) -> io.NodeOutput:
- path = _prune_keys(textencoder_name, "text_encoders",
+ path = _prune_keys(textencoder_name, "text_encoders",
keys_to_prune, use_regex, output_filename)
return io.NodeOutput(path)
@@ -132,9 +153,9 @@ class LoRAPruneKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, lora_name: str, keys_to_prune: str,
+ def execute(cls, lora_name: str, keys_to_prune: str,
use_regex: bool, output_filename: str) -> io.NodeOutput:
- path = _prune_keys(lora_name, "loras",
+ path = _prune_keys(lora_name, "loras",
keys_to_prune, use_regex, output_filename)
return io.NodeOutput(path)
@@ -162,9 +183,9 @@ class CheckpointPruneKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, ckpt_name: str, keys_to_prune: str,
+ def execute(cls, ckpt_name: str, keys_to_prune: str,
use_regex: bool, output_filename: str) -> io.NodeOutput:
- path = _prune_keys(ckpt_name, "checkpoints",
+ path = _prune_keys(ckpt_name, "checkpoints",
keys_to_prune, use_regex, output_filename)
return io.NodeOutput(path)
@@ -192,8 +213,8 @@ class EmbeddingPruneKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, embedding: str, keys_to_prune: str,
+ def execute(cls, embedding: str, keys_to_prune: str,
use_regex: bool, output_filename: str) -> io.NodeOutput:
- path = _prune_keys(embedding, "embeddings",
+ path = _prune_keys(embedding, "embeddings",
keys_to_prune, use_regex, output_filename)
return io.NodeOutput(path)
\ No newline at end of file
diff --git a/nodes/renamekeys.py b/nodes/renamekeys.py
index d856a70..625f753 100644
--- a/nodes/renamekeys.py
+++ b/nodes/renamekeys.py
@@ -1,11 +1,16 @@
import os
import folder_paths
+import comfy.utils
+from tqdm import tqdm
from comfy_api.latest import io
from safetensors.torch import save_file
-from .utils import convert_pt_to_safetensors, load_metadata_from_safetensors
+from safetensors import safe_open
+from .utils import convert_pt_to_safetensors
+from .merger_utils import MemoryEfficientSafeOpen
+from .device_utils import estimate_model_size, prepare_for_large_operation, cleanup_after_operation
-def _rename_keys(model_name: str, model_type: str, old_keys_str: str,
+def _rename_keys(model_name: str, model_type: str, old_keys_str: str,
new_keys_str: str, output_filename: str) -> str:
"""Shared logic for all RenameKeys nodes."""
model_path = folder_paths.get_full_path_or_raise(model_type, model_name)
@@ -20,7 +25,13 @@ def _rename_keys(model_name: str, model_type: str, old_keys_str: str,
else:
model_path_to_load = model_path
- model_weights, metadata = load_metadata_from_safetensors(model_path_to_load)
+ # Prepare memory before operation
+ model_size_gb = estimate_model_size(model_path_to_load)
+ prepare_for_large_operation(model_size_gb * 1.2)
+
+ # Get metadata from safe_open (header only, no tensor loading)
+ with safe_open(model_path_to_load, framework="pt", device="cpu") as f:
+ metadata = f.metadata() or {}
old_keys = [key.strip() for key in old_keys_str.strip().split('\n') if key.strip()]
new_keys = [key.strip() for key in new_keys_str.strip().split('\n') if key.strip()]
@@ -30,17 +41,26 @@ def _rename_keys(model_name: str, model_type: str, old_keys_str: str,
key_map = dict(zip(old_keys, new_keys))
- original_keys = list(model_weights.keys())
+ # Stream tensors and rename on the fly
renamed_tensors = {}
- for key in original_keys:
- new_key = key_map.get(key, key)
- renamed_tensors[new_key] = model_weights[key]
+ with MemoryEfficientSafeOpen(model_path_to_load) as handler:
+ original_keys = handler.keys()
+ pbar = comfy.utils.ProgressBar(len(original_keys))
+ for key in tqdm(original_keys, desc="Renaming keys", unit="keys"):
+ new_key = key_map.get(key, key)
+ renamed_tensors[new_key] = handler.get_tensor(key)
+ pbar.update(1)
- model_dir = folder_paths.get_folder_paths(model_type)[0]
+ # Use [-1] for diffusion_models to get the actual diffusion_models folder, not legacy unet
+ model_dir = folder_paths.get_folder_paths(model_type)[-1]
output_path = os.path.join(model_dir, f"{output_filename.strip()}.safetensors")
save_file(renamed_tensors, output_path, metadata)
+ # Cleanup after operation
+ del renamed_tensors
+ cleanup_after_operation()
+
return output_path
@@ -67,9 +87,9 @@ class ModelRenameKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, diffusionmodel_name: str, old_keys: str,
+ def execute(cls, diffusionmodel_name: str, old_keys: str,
new_keys: str, output_filename: str) -> io.NodeOutput:
- path = _rename_keys(diffusionmodel_name, "diffusion_models",
+ path = _rename_keys(diffusionmodel_name, "diffusion_models",
old_keys, new_keys, output_filename)
return io.NodeOutput(path)
@@ -97,9 +117,9 @@ class TextEncoderRenameKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, textencoder_name: str, old_keys: str,
+ def execute(cls, textencoder_name: str, old_keys: str,
new_keys: str, output_filename: str) -> io.NodeOutput:
- path = _rename_keys(textencoder_name, "text_encoders",
+ path = _rename_keys(textencoder_name, "text_encoders",
old_keys, new_keys, output_filename)
return io.NodeOutput(path)
@@ -127,9 +147,9 @@ class LoRARenameKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, lora_name: str, old_keys: str,
+ def execute(cls, lora_name: str, old_keys: str,
new_keys: str, output_filename: str) -> io.NodeOutput:
- path = _rename_keys(lora_name, "loras",
+ path = _rename_keys(lora_name, "loras",
old_keys, new_keys, output_filename)
return io.NodeOutput(path)
@@ -157,9 +177,9 @@ class CheckpointRenameKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, ckpt_name: str, old_keys: str,
+ def execute(cls, ckpt_name: str, old_keys: str,
new_keys: str, output_filename: str) -> io.NodeOutput:
- path = _rename_keys(ckpt_name, "checkpoints",
+ path = _rename_keys(ckpt_name, "checkpoints",
old_keys, new_keys, output_filename)
return io.NodeOutput(path)
@@ -187,8 +207,8 @@ class EmbeddingRenameKeys(io.ComfyNode):
)
@classmethod
- def execute(cls, embedding: str, old_keys: str,
+ def execute(cls, embedding: str, old_keys: str,
new_keys: str, output_filename: str) -> io.NodeOutput:
- path = _rename_keys(embedding, "embeddings",
+ path = _rename_keys(embedding, "embeddings",
old_keys, new_keys, output_filename)
return io.NodeOutput(path)
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 4db0a3f..4d3baad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-ModelUtils"
description = "[WIP]Custom nodes for handling, inspecting, modifying and creating various model files."
-version = "0.1.7"
+version = "0.2.0"
license = { file = "LICENSE" }
[project.urls]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d2ce485
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+# No external dependencies required