charles2530 commited on
Commit
e2634b7
·
verified ·
1 Parent(s): 3a376c4

Add files using upload-large-folder tool

Browse files
README.md ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model:
4
+ - Wan-AI/Wan2.2-T2V-A14B
5
+ library_name: diffusers
6
+ tags:
7
+ - video_generation
8
+ - NVFP4
9
+ - Sparse_Attention
10
+ - Wan
11
+ ---
12
+ # 🎬 Wan2.2-NVFP4-Sparse
13
+
14
+ > **An extremely efficient Wan 2.2 14B variant: NVFP4 Quantization-Aware Step Distillation with Sparse Attention for Blackwell Architecture**
15
+
16
+ [![GitHub](https://img.shields.io/badge/GitHub-ModelTC/LightX2V-blue)](https://github.com/ModelTC/LightX2V)
17
+ [![HuggingFace](https://img.shields.io/badge/HuggingFace-lightx2v-yellow)](https://huggingface.co/lightx2v/)
18
+
19
+ ## 📋 Table of Contents
20
+
21
+ - [✨ Features](#-features)
22
+ - [🚀 Quick Start](#-quick-start)
23
+ - [🎬 Generation Results](#-generation-results)
24
+ - [⚡ Performance Comparison](#-performance-comparison)
25
+ - [⚠️ Notes](#️-notes)
26
+ - [🤝 Community](#-community)
27
+
28
+ ## ✨ Features
29
+
30
+ - **⚡ 4-Step Inference**: Two high-noise expert steps followed by two low-noise expert steps, enabling extremely fast Wan2.2 MoE generation on a single Blackwell GPU.
31
+ - **🎯 NVFP4 Quantization**: Quantization-aware step distillation reduces memory traffic and compute cost while targeting Blackwell architecture.
32
+ - **🧩 Sparse Attention**: Accelerates the costly O(n²) self-attention workload with sparse attention, reducing end-to-end latency for high-resolution video generation.
33
+ - **🔧 LightX2V Integration**: Recommended runtime stack for stable deployment and best performance.
34
+ - **🚀 High-Quality Generation**: Preserves the visual quality of Wan2.2-T2V-14B while dramatically improving inference speed.
35
+
36
+ ## 🚀 Quick Start
37
+
38
+ We strongly recommend using the official LightX2V Docker image for the cleanest environment and best reproducibility.
39
+
40
+ ### Option A: Docker Recommended
41
+
42
+ ```bash
43
+ # 1. Pull LightX2V Docker image
44
+ docker pull lightx2v/lightx2v:26052801-cu130-5090
45
+
46
+ # 2. Run inference
47
+ bash scripts/wan22/distill/run_wan22_moe_t2v_extreme.sh
48
+ ```
49
+
50
+ ### Option B: Manual Installation
51
+
52
+ If Docker is not available, install the environment manually:
53
+
54
+ ```bash
55
+ # 1. Install LightX2V
56
+ git clone https://github.com/ModelTC/LightX2V.git
57
+ cd LightX2V
58
+ uv pip install -v .
59
+
60
+ # 2. Install NVFP4 Kernel
61
+ pip install scikit_build_core uv
62
+ git clone https://github.com/NVIDIA/cutlass.git
63
+ cd lightx2v_kernel
64
+
65
+ MAX_JOBS=$(nproc) CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) \
66
+ uv build --wheel \
67
+ -Cbuild-dir=build . \
68
+ -Ccmake.define.CUTLASS_PATH=/path/to/cutlass \
69
+ --verbose --color=always --no-build-isolation
70
+
71
+ pip install dist/*whl --force-reinstall --no-deps
72
+
73
+ # 3. Run inference
74
+ bash scripts/wan22/distill/run_wan22_moe_t2v_extreme.sh
75
+ ```
76
+
77
+ Script: [run_wan22_moe_t2v_extreme.sh](https://github.com/ModelTC/LightX2V/blob/main/scripts/wan22/distill/run_wan22_moe_t2v_extreme.sh)
78
+
79
+ ## 🎬 Generation Results
80
+
81
+ <div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin: 16px 0;">
82
+ <p style="font-style: italic; color: #475569; margin: 0; padding: 12px; background: white; border-radius: 6px; border-left: 4px solid #3b82f6;">
83
+ "Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage"
84
+ </p>
85
+ </div>
86
+
87
+
88
+ | Resolution | Wan2.2-T2V-14B | Wan2.2-NVFP4-Sparse |
89
+ | --- | --- | --- |
90
+ | 480p | <video controls style="width: 260px; height: 180px; border-radius: 6px; object-fit: cover;" src="https://cdn-uploads.huggingface.co/production/uploads/658e760cccbc1e2cc78b4258/WTHhrzx7XR4S1Ys_6Kzx4.mp4"></video> | <video controls style="width: 260px; height: 180px; border-radius: 6px; object-fit: cover;" src="https://cdn-uploads.huggingface.co/production/uploads/658e760cccbc1e2cc78b4258/zorpw7gm9At0J2kCmvkDr.mp4"></video> |
91
+ | 720p | <video controls style="width: 260px; height: 180px; border-radius: 6px; object-fit: cover;" src="https://cdn-uploads.huggingface.co/production/uploads/658e760cccbc1e2cc78b4258/vkiyKj7CJA-r0yTz7TEum.mp4"></video> | <video controls style="width: 260px; height: 180px; border-radius: 6px; object-fit: cover;" src="https://cdn-uploads.huggingface.co/production/uploads/658e760cccbc1e2cc78b4258/TuECbzvW5jI9NHG6GLvIR.mp4"></video> |
92
+
93
+
94
+ ## ⚡ Performance Comparison
95
+
96
+ **Test Environment**: RTX 5090 Single GPU | LightX2V Framework | End-to-End Latency
97
+
98
+ | Resolution | Wan2.2-T2V-14B | Wan2.2-NVFP4-Sparse | Speedup |
99
+ | --- | ---: | ---: | ---: |
100
+ | 480p | 734s | 14.15s | 51.9x |
101
+ | 720p | 2668s | 45s | 59.3x |
102
+
103
+ ## ⚠️ Notes
104
+
105
+ ### System Requirements
106
+
107
+ - **Required Hardware**: NVIDIA RTX 50-series GPUs or other Blackwell architecture GPUs.
108
+ - **Recommended Runtime**: `lightx2v/lightx2v:26052801-cu130-5090`.
109
+
110
+ ### Dependencies
111
+
112
+ - Prepare Wan2.2 T5 / VAE components following the standard LightX2V Wan2.2 model structure.
113
+ - Use Blackwell + NVFP4 kernels for optimal speed and memory efficiency.
114
+
115
+ ### Performance Tips
116
+
117
+ - Use the provided extreme inference script for the 4-step high-noise / low-noise expert schedule.
118
+ - Sparse attention is most beneficial at higher resolutions where self-attention dominates latency.
119
+ - Enable CPU offload only when GPU memory is limited, since offload can reduce throughput.
120
+
121
+ ## 🤝 Community
122
+
123
+ - **🐛 Issues**: [GitHub Issues](https://github.com/ModelTC/LightX2V/issues)
124
+ - **🤗 Models**: [HuggingFace Hub](https://huggingface.co/lightx2v/)
125
+ - **📖 Documentation**: [LightX2V Docs](https://github.com/ModelTC/LightX2V)
126
+
127
+ ---
128
+
129
+ <div align="center">
130
+
131
+ **If you find this project helpful, please give us a ⭐ on [GitHub](https://github.com/ModelTC/LightX2V)**
132
+
133
+ For questions or issues, please open an issue on [LightX2V](https://github.com/ModelTC/LightX2V/issues) or contact lvchengtao0319@gmail.com.
134
+
135
+ </div>
136
+
Wan2.2-I2V-A14B_NVFP4_Sparse_high.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c77b92616d678fa8373462973a3d2e3ceefd2fbdb3ccf33454e74f4c79fcf07a
3
+ size 8379833592
Wan2.2-I2V-A14B_NVFP4_Sparse_high_comfy.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:998934b07f124111b83c3bfcdf9efc2ff67ff531879806e54a1453c14066a5db
3
+ size 8379918824
Wan2.2-I2V-A14B_NVFP4_Sparse_low.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d76b53286dd9b68f5d956941037c0a6fd5a1fe0fd05eb97f8bf8abf9915da562
3
+ size 8379833592
Wan2.2-I2V-A14B_NVFP4_Sparse_low_comfy.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a26076d43590941a6526ed52d2b682a9340c81f650ada0751a90e5bdcde5efb7
3
+ size 8379918824
Wan2.2-T2V-A14B_NVFP4_Sparse_high.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1da94b1604f4bb181d1319f311245e39e33e499b3a0a5bf2fafb117b08750ac7
3
+ size 8379014392
Wan2.2-T2V-A14B_NVFP4_Sparse_low.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9feda8a7f4791c6e4f0dd8ab383fa67d429441dfb006b97a1e3b5400e74bc232
3
+ size 8379014392
Wan2.2-T2V-A14B_NVFP4_Sparse_low_comfy.safetensors CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:765476d100853683f0b122be824f87121a5bb3c8cb1c5838c4dbe9eccd76a43b
3
  size 8379099624
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cdccd3005e0050a700a9dec435ab3ef37923294ba03b3132b53f94460f377f6d
3
  size 8379099624
__pycache__/convert_lightx2v_nvfp4_to_comfy.cpython-312.pyc ADDED
Binary file (8.75 kB). View file
 
__pycache__/verify_comfy_nvfp4.cpython-312.pyc ADDED
Binary file (11 kB). View file
 
__pycache__/verify_comfy_source_compat.cpython-312.pyc ADDED
Binary file (4.33 kB). View file
 
analysis.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Wan2.2 NVFP4 Sparse to ComfyUI Conversion Analysis
2
+
3
+ ## Sources checked
4
+
5
+ - Kijai Hugging Face repo: https://huggingface.co/Kijai/WanVideo_comfy_nvfp4
6
+ - ComfyUI Wan2.2 workflow docs: https://docs.comfy.org/tutorials/video/wan/wan2_2
7
+ - ComfyUI Wan2.2 examples: https://comfyanonymous.github.io/ComfyUI_examples/wan22/
8
+ - ComfyUI mixed precision loader reference:
9
+ https://huggingface.co/mhnakif/comfy/blob/main/comfy/ops.py
10
+ - ComfyUI quant op reference:
11
+ https://huggingface.co/mhnakif/comfy/blob/main/comfy/quant_ops.py
12
+ - Comfy Kitchen hardware/backend reference:
13
+ https://github.com/Comfy-Org/comfy-kitchen
14
+ - Local ComfyUI source checkout used for verification:
15
+ `Comfy-Org/ComfyUI` commit
16
+ `5aa71b9bc28809a16596bb9fa3d0a6300d8e3f0e`
17
+
18
+ ## Script provenance
19
+
20
+ `convert_lightx2v_nvfp4_to_comfy.py` is a local conversion script written for
21
+ this directory. It is not copied from one upstream script. The implementation is
22
+ derived from these upstream pages and ComfyUI source conventions:
23
+
24
+ - Kijai model page:
25
+ https://huggingface.co/Kijai/WanVideo_comfy_nvfp4
26
+ - This page gives the actual LightX2V NVFP4 to Comfy NVFP4 conversion rule:
27
+ nibble-swap packed U8 weights, keep `weight_scale`, set
28
+ `weight_scale_2 = alpha * input_global_scale`, and set
29
+ `input_scale = 1 / input_global_scale`.
30
+ - ComfyUI quantized loader:
31
+ https://github.com/Comfy-Org/ComfyUI/blob/5aa71b9bc28809a16596bb9fa3d0a6300d8e3f0e/comfy/ops.py#L1058-L1091
32
+ - This loader reads `{layer}.comfy_quant`, branches on `format == "nvfp4"`,
33
+ then requires `{layer}.weight_scale_2` and `{layer}.weight_scale`.
34
+ - ComfyUI quant algorithm registry:
35
+ https://github.com/Comfy-Org/ComfyUI/blob/5aa71b9bc28809a16596bb9fa3d0a6300d8e3f0e/comfy/quant_ops.py#L190-L205
36
+ - This defines the `nvfp4` storage dtype as `torch.uint8` and the parameter
37
+ set as `weight_scale`, `weight_scale_2`, and `input_scale`.
38
+ - ComfyUI quantization metadata handling:
39
+ https://github.com/Comfy-Org/ComfyUI/blob/5aa71b9bc28809a16596bb9fa3d0a6300d8e3f0e/comfy/utils.py#L1360-L1421
40
+ - This shows that `_quantization_metadata.layers` is converted into
41
+ `{layer}.comfy_quant` JSON byte tensors and that the presence of
42
+ `.comfy_quant` enables mixed quantized ops.
43
+ - ComfyUI native NVFP4 hardware gate:
44
+ https://github.com/Comfy-Org/ComfyUI/blob/5aa71b9bc28809a16596bb9fa3d0a6300d8e3f0e/comfy/model_management.py#L1877-L1885
45
+ - This returns true only for NVIDIA GPUs with compute capability major
46
+ version >= 10, which is why H100 can validate/load files but is not expected
47
+ to use native Blackwell NVFP4 tensor-core compute.
48
+
49
+ ## Format findings
50
+
51
+ The original files in this directory are LightX2V NVFP4 Sparse safetensors. Each
52
+ file has 400 quantized Linear layers with these LightX2V-side tensors:
53
+
54
+ - `{layer}.weight`: packed NVFP4 values in `torch.uint8`
55
+ - `{layer}.weight_scale`: FP8 E4M3 block scale tensor
56
+ - `{layer}.alpha`: scalar post-matmul rescaler
57
+ - `{layer}.input_global_scale`: scalar input scale convention
58
+
59
+ Kijai's model card says the ComfyUI conversion is still NVFP4 and uses the same
60
+ datatype, but changes conventions:
61
+
62
+ - Swap the high/low nibbles in each packed `uint8` weight byte.
63
+ - Keep `{layer}.weight_scale` as-is.
64
+ - Convert `{layer}.alpha * {layer}.input_global_scale` into
65
+ `{layer}.weight_scale_2`.
66
+ - Convert `1 / {layer}.input_global_scale` into `{layer}.input_scale`.
67
+
68
+ ComfyUI's mixed precision loader expects a `{layer}.comfy_quant` tensor
69
+ containing JSON bytes. For NVFP4 it then loads:
70
+
71
+ - `{layer}.weight`
72
+ - `{layer}.weight_scale_2`
73
+ - `{layer}.weight_scale`
74
+ - optional registered parameters such as `{layer}.input_scale`
75
+
76
+ The converted file metadata also includes `_quantization_metadata` with one
77
+ `nvfp4` layer entry per quantized layer so ComfyUI can select mixed precision
78
+ operations for the model.
79
+
80
+ ## H100 note
81
+
82
+ The conversion itself does not require a Blackwell GPU; it is a safetensors
83
+ layout conversion. However, Comfy Kitchen documents `TensorCoreNVFP4Layout` as
84
+ requiring SM >= 10.0 / Blackwell for native NVFP4 tensor-core acceleration. H100
85
+ is Hopper, so ComfyUI may disable native NVFP4 compute and run a fallback path.
86
+
87
+ ## Script
88
+
89
+ The conversion script is:
90
+
91
+ ```bash
92
+ python convert_lightx2v_nvfp4_to_comfy.py
93
+ ```
94
+
95
+ Useful options:
96
+
97
+ ```bash
98
+ python convert_lightx2v_nvfp4_to_comfy.py --dry-run
99
+ python convert_lightx2v_nvfp4_to_comfy.py --overwrite
100
+ python convert_lightx2v_nvfp4_to_comfy.py input.safetensors --output-dir /path/to/out
101
+ ```
102
+
103
+ The script writes `<original_stem>_comfy.safetensors` and uses a temporary file
104
+ before renaming into place.
105
+
106
+ ## Converted outputs
107
+
108
+ - `Wan2.2-I2V-A14B_NVFP4_Sparse_high_comfy.safetensors`
109
+ - `Wan2.2-I2V-A14B_NVFP4_Sparse_low_comfy.safetensors`
110
+ - `Wan2.2-T2V-A14B_NVFP4_Sparse_high_comfy.safetensors`
111
+ - `Wan2.2-T2V-A14B_NVFP4_Sparse_low_comfy.safetensors`
112
+
113
+ For ComfyUI native workflows, place these diffusion model files under:
114
+
115
+ ```text
116
+ ComfyUI/models/diffusion_models/
117
+ ```
118
+
119
+ The Wan2.2 14B workflows still need the normal text encoder and VAE files in
120
+ their ComfyUI locations.
121
+
122
+ ## Verification performed
123
+
124
+ For each converted file:
125
+
126
+ - Tensor count is 2695.
127
+ - `_quantization_metadata` contains 400 quantized layers.
128
+ - `alpha` count is 0.
129
+ - `input_global_scale` count is 0.
130
+ - `input_scale` count is 400.
131
+ - `weight_scale` count is 400.
132
+ - `weight_scale_2` count is 400.
133
+ - `comfy_quant` count is 400.
134
+ - `{layer}.comfy_quant` decodes to `{"format": "nvfp4"}`.
135
+ - A sampled `blocks.0.cross_attn.k.weight` block equals the expected nibble
136
+ swap from the original.
137
+ - The sampled `weight_scale_2` equals `alpha * input_global_scale`.
138
+ - The sampled `input_scale` equals `1 / input_global_scale`.
convert_lightx2v_nvfp4_to_comfy.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Convert LightX2V Wan2.2 NVFP4 safetensors to ComfyUI NVFP4 format."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import torch
11
+ from safetensors import safe_open
12
+ from safetensors.torch import save_file
13
+
14
+
15
+ COMFY_QUANT_CONF = {"format": "nvfp4"}
16
+ QUANT_LAYER_CONF = {"format": "nvfp4", "full_precision_matrix_mult": False}
17
+
18
+
19
+ def parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(
21
+ description=(
22
+ "Convert LightX2V Wan2.2 NVFP4 Sparse safetensors into ComfyUI's "
23
+ "native NVFP4 safetensors convention."
24
+ )
25
+ )
26
+ parser.add_argument(
27
+ "inputs",
28
+ nargs="*",
29
+ type=Path,
30
+ help="Input .safetensors files. Defaults to non-_comfy safetensors in the current directory.",
31
+ )
32
+ parser.add_argument(
33
+ "--output-dir",
34
+ type=Path,
35
+ default=Path("."),
36
+ help="Directory for converted files. Defaults to the current directory.",
37
+ )
38
+ parser.add_argument(
39
+ "--suffix",
40
+ default="_comfy",
41
+ help="Suffix appended before .safetensors. Defaults to _comfy.",
42
+ )
43
+ parser.add_argument(
44
+ "--overwrite",
45
+ action="store_true",
46
+ help="Overwrite existing converted files.",
47
+ )
48
+ parser.add_argument(
49
+ "--dry-run",
50
+ action="store_true",
51
+ help="Inspect inputs and print planned outputs without writing files.",
52
+ )
53
+ return parser.parse_args()
54
+
55
+
56
+ def default_inputs() -> list[Path]:
57
+ return sorted(
58
+ p
59
+ for p in Path(".").glob("*.safetensors")
60
+ if not p.name.endswith("_comfy.safetensors")
61
+ )
62
+
63
+
64
+ def output_path(input_path: Path, output_dir: Path, suffix: str) -> Path:
65
+ return output_dir / f"{input_path.stem}{suffix}{input_path.suffix}"
66
+
67
+
68
+ def is_quant_layer(prefix: str, keys: set[str]) -> bool:
69
+ return (
70
+ f"{prefix}.weight" in keys
71
+ and f"{prefix}.weight_scale" in keys
72
+ and f"{prefix}.alpha" in keys
73
+ and f"{prefix}.input_global_scale" in keys
74
+ )
75
+
76
+
77
+ def quant_layer_prefixes(keys: list[str]) -> list[str]:
78
+ key_set = set(keys)
79
+ prefixes: list[str] = []
80
+ for key in keys:
81
+ if key.endswith(".weight"):
82
+ prefix = key[: -len(".weight")]
83
+ if is_quant_layer(prefix, key_set):
84
+ prefixes.append(prefix)
85
+ return prefixes
86
+
87
+
88
+ def swap_fp4_nibbles(weight: torch.Tensor) -> torch.Tensor:
89
+ if weight.dtype != torch.uint8:
90
+ raise TypeError(f"Expected uint8 packed NVFP4 weight, got {weight.dtype}")
91
+ low = torch.bitwise_left_shift(torch.bitwise_and(weight, 0x0F), 4)
92
+ high = torch.bitwise_right_shift(weight, 4)
93
+ return torch.bitwise_or(low, high).contiguous()
94
+
95
+
96
+ def comfy_quant_tensor() -> torch.Tensor:
97
+ payload = json.dumps(COMFY_QUANT_CONF).encode("utf-8")
98
+ return torch.tensor(list(payload), dtype=torch.uint8)
99
+
100
+
101
+ def convert_one(input_path: Path, output_path_: Path, overwrite: bool, dry_run: bool) -> None:
102
+ if output_path_.exists() and not overwrite and not dry_run:
103
+ raise FileExistsError(f"{output_path_} exists; pass --overwrite to replace it")
104
+
105
+ with safe_open(input_path, framework="pt", device="cpu") as sf:
106
+ keys = list(sf.keys())
107
+ quant_prefixes = quant_layer_prefixes(keys)
108
+ quant_prefix_set = set(quant_prefixes)
109
+
110
+ if dry_run:
111
+ print(
112
+ f"{input_path} -> {output_path_}: "
113
+ f"{len(keys)} input tensors, {len(quant_prefixes)} quantized layers"
114
+ )
115
+ return
116
+
117
+ tensors: dict[str, torch.Tensor] = {}
118
+ for key in keys:
119
+ if key.endswith(".alpha") or key.endswith(".input_global_scale"):
120
+ prefix = key.rsplit(".", 1)[0]
121
+ if prefix in quant_prefix_set:
122
+ continue
123
+
124
+ if key.endswith(".weight"):
125
+ prefix = key[: -len(".weight")]
126
+ tensor = sf.get_tensor(key)
127
+ if prefix in quant_prefix_set:
128
+ tensors[key] = swap_fp4_nibbles(tensor)
129
+ alpha = sf.get_tensor(f"{prefix}.alpha").to(torch.float32)
130
+ input_global_scale = sf.get_tensor(f"{prefix}.input_global_scale").to(torch.float32)
131
+ tensors[f"{prefix}.weight_scale_2"] = (alpha * input_global_scale).contiguous()
132
+ tensors[f"{prefix}.input_scale"] = (1.0 / input_global_scale).contiguous()
133
+ tensors[f"{prefix}.comfy_quant"] = comfy_quant_tensor()
134
+ else:
135
+ tensors[key] = tensor.contiguous() if not tensor.is_contiguous() else tensor
136
+ continue
137
+
138
+ tensor = sf.get_tensor(key)
139
+ tensors[key] = tensor.contiguous() if not tensor.is_contiguous() else tensor
140
+
141
+ metadata = {
142
+ "format": "pt",
143
+ "_quantization_metadata": json.dumps(
144
+ {"layers": {prefix: QUANT_LAYER_CONF for prefix in quant_prefixes}},
145
+ sort_keys=True,
146
+ ),
147
+ }
148
+ tmp_path = output_path_.with_name(f"{output_path_.name}.tmp")
149
+ if tmp_path.exists():
150
+ tmp_path.unlink()
151
+ save_file(tensors, tmp_path, metadata=metadata)
152
+ tmp_path.replace(output_path_)
153
+ print(f"Wrote {output_path_} ({len(tensors)} tensors, {len(quant_prefixes)} quantized layers)")
154
+
155
+
156
+ def main() -> None:
157
+ args = parse_args()
158
+ inputs = args.inputs or default_inputs()
159
+ if not inputs:
160
+ raise SystemExit("No input .safetensors files found")
161
+
162
+ args.output_dir.mkdir(parents=True, exist_ok=True)
163
+ for input_path in inputs:
164
+ input_path = input_path.resolve()
165
+ if input_path.suffix != ".safetensors":
166
+ raise ValueError(f"Expected a .safetensors input, got {input_path}")
167
+ out = output_path(input_path, args.output_dir, args.suffix).resolve()
168
+ convert_one(input_path, out, overwrite=args.overwrite, dry_run=args.dry_run)
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
verify_comfy_nvfp4.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Validate converted Wan2.2 NVFP4 safetensors against ComfyUI expectations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import torch
11
+ from safetensors import safe_open
12
+
13
+
14
+ REQUIRED_QUANT_KEYS = (
15
+ "weight",
16
+ "weight_scale",
17
+ "weight_scale_2",
18
+ "input_scale",
19
+ "comfy_quant",
20
+ )
21
+
22
+
23
+ def parse_args() -> argparse.Namespace:
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument(
26
+ "files",
27
+ nargs="*",
28
+ type=Path,
29
+ help="Converted *_comfy.safetensors files. Defaults to all *_comfy.safetensors in cwd.",
30
+ )
31
+ parser.add_argument(
32
+ "--source-dir",
33
+ type=Path,
34
+ default=Path("."),
35
+ help="Directory containing the original non-_comfy safetensors for numeric relation checks.",
36
+ )
37
+ return parser.parse_args()
38
+
39
+
40
+ def default_files() -> list[Path]:
41
+ return sorted(Path(".").glob("*_comfy.safetensors"))
42
+
43
+
44
+ def source_for(converted: Path, source_dir: Path) -> Path | None:
45
+ source_name = converted.name.replace("_comfy.safetensors", ".safetensors")
46
+ candidate = source_dir / source_name
47
+ return candidate if candidate.exists() else None
48
+
49
+
50
+ def layer_prefixes_from_metadata(metadata: dict[str, str]) -> list[str]:
51
+ raw = metadata.get("_quantization_metadata")
52
+ if raw is None:
53
+ raise AssertionError("missing _quantization_metadata")
54
+ parsed = json.loads(raw)
55
+ layers = parsed.get("layers")
56
+ if not isinstance(layers, dict):
57
+ raise AssertionError("_quantization_metadata.layers is not an object")
58
+ for prefix, conf in layers.items():
59
+ if conf.get("format") != "nvfp4":
60
+ raise AssertionError(f"{prefix}: expected nvfp4 format, got {conf!r}")
61
+ return sorted(layers)
62
+
63
+
64
+ def check_converted_file(path: Path) -> tuple[int, int]:
65
+ with safe_open(path, framework="pt", device="cpu") as sf:
66
+ keys = set(sf.keys())
67
+ metadata = sf.metadata() or {}
68
+ prefixes = layer_prefixes_from_metadata(metadata)
69
+
70
+ if len(prefixes) != 400:
71
+ raise AssertionError(f"{path.name}: expected 400 quantized layers, got {len(prefixes)}")
72
+
73
+ for prefix in prefixes:
74
+ for suffix in REQUIRED_QUANT_KEYS:
75
+ key = f"{prefix}.{suffix}"
76
+ if key not in keys:
77
+ raise AssertionError(f"{path.name}: missing {key}")
78
+
79
+ if f"{prefix}.alpha" in keys:
80
+ raise AssertionError(f"{path.name}: unexpected LightX2V key {prefix}.alpha")
81
+ if f"{prefix}.input_global_scale" in keys:
82
+ raise AssertionError(f"{path.name}: unexpected LightX2V key {prefix}.input_global_scale")
83
+
84
+ weight = sf.get_tensor(f"{prefix}.weight")
85
+ weight_scale = sf.get_tensor(f"{prefix}.weight_scale")
86
+ weight_scale_2 = sf.get_tensor(f"{prefix}.weight_scale_2")
87
+ input_scale = sf.get_tensor(f"{prefix}.input_scale")
88
+ comfy_quant = sf.get_tensor(f"{prefix}.comfy_quant")
89
+
90
+ if weight.dtype != torch.uint8:
91
+ raise AssertionError(f"{path.name}: {prefix}.weight dtype is {weight.dtype}, expected uint8")
92
+ if weight_scale.dtype != torch.float8_e4m3fn:
93
+ raise AssertionError(
94
+ f"{path.name}: {prefix}.weight_scale dtype is {weight_scale.dtype}, expected float8_e4m3fn"
95
+ )
96
+ if weight_scale_2.dtype != torch.float32 or weight_scale_2.shape != torch.Size([]):
97
+ raise AssertionError(f"{path.name}: {prefix}.weight_scale_2 must be scalar float32")
98
+ if input_scale.dtype != torch.float32 or input_scale.shape != torch.Size([]):
99
+ raise AssertionError(f"{path.name}: {prefix}.input_scale must be scalar float32")
100
+
101
+ marker = json.loads(bytes(comfy_quant.tolist()).decode("utf-8"))
102
+ if marker != {"format": "nvfp4"}:
103
+ raise AssertionError(f"{path.name}: {prefix}.comfy_quant is {marker!r}")
104
+
105
+ if sum(key.endswith(".input_scale") for key in keys) != 400:
106
+ raise AssertionError(f"{path.name}: expected 400 input_scale tensors")
107
+ if sum(key.endswith(".weight_scale_2") for key in keys) != 400:
108
+ raise AssertionError(f"{path.name}: expected 400 weight_scale_2 tensors")
109
+ if sum(key.endswith(".comfy_quant") for key in keys) != 400:
110
+ raise AssertionError(f"{path.name}: expected 400 comfy_quant tensors")
111
+ if sum(key.endswith(".alpha") for key in keys) != 0:
112
+ raise AssertionError(f"{path.name}: alpha keys remain after conversion")
113
+ if sum(key.endswith(".input_global_scale") for key in keys) != 0:
114
+ raise AssertionError(f"{path.name}: input_global_scale keys remain after conversion")
115
+
116
+ return len(keys), len(prefixes)
117
+
118
+
119
+ def check_against_source(converted: Path, source: Path) -> None:
120
+ sample_prefixes = (
121
+ "blocks.0.cross_attn.k",
122
+ "blocks.0.ffn.0",
123
+ "blocks.39.self_attn.v",
124
+ )
125
+ with safe_open(source, framework="pt", device="cpu") as src, safe_open(
126
+ converted, framework="pt", device="cpu"
127
+ ) as dst:
128
+ for prefix in sample_prefixes:
129
+ src_weight = src.get_tensor(f"{prefix}.weight")[:16, :16]
130
+ dst_weight = dst.get_tensor(f"{prefix}.weight")[:16, :16]
131
+ expected = torch.bitwise_or(
132
+ torch.bitwise_left_shift(torch.bitwise_and(src_weight, 0x0F), 4),
133
+ torch.bitwise_right_shift(src_weight, 4),
134
+ )
135
+ if not torch.equal(dst_weight, expected):
136
+ raise AssertionError(f"{converted.name}: {prefix}.weight nibble swap mismatch")
137
+
138
+ alpha = src.get_tensor(f"{prefix}.alpha").to(torch.float32)
139
+ input_global_scale = src.get_tensor(f"{prefix}.input_global_scale").to(torch.float32)
140
+ weight_scale_2 = dst.get_tensor(f"{prefix}.weight_scale_2").to(torch.float32)
141
+ input_scale = dst.get_tensor(f"{prefix}.input_scale").to(torch.float32)
142
+
143
+ if not torch.allclose(weight_scale_2, alpha * input_global_scale):
144
+ raise AssertionError(f"{converted.name}: {prefix}.weight_scale_2 mismatch")
145
+ if not torch.allclose(input_scale, 1.0 / input_global_scale):
146
+ raise AssertionError(f"{converted.name}: {prefix}.input_scale mismatch")
147
+
148
+
149
+ def main() -> None:
150
+ args = parse_args()
151
+ files = args.files or default_files()
152
+ if not files:
153
+ raise SystemExit("No *_comfy.safetensors files found")
154
+
155
+ for path in files:
156
+ tensor_count, layer_count = check_converted_file(path)
157
+ source = source_for(path, args.source_dir)
158
+ if source is not None:
159
+ check_against_source(path, source)
160
+ relation = "source relation checks passed"
161
+ else:
162
+ relation = "source file not found; skipped relation checks"
163
+ print(f"OK {path.name}: {tensor_count} tensors, {layer_count} nvfp4 layers, {relation}")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
verify_comfy_source_compat.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Check converted NVFP4 files against a local ComfyUI source checkout."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import torch
12
+ from safetensors import safe_open
13
+
14
+
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("comfyui", type=Path, help="Path to a ComfyUI source checkout")
18
+ parser.add_argument(
19
+ "files",
20
+ nargs="*",
21
+ type=Path,
22
+ help="Converted *_comfy.safetensors files. Defaults to all *_comfy.safetensors in cwd.",
23
+ )
24
+ return parser.parse_args()
25
+
26
+
27
+ def read_subset(path: Path, limit_layers: int = 3) -> tuple[dict[str, torch.Tensor], dict[str, str]]:
28
+ state_dict: dict[str, torch.Tensor] = {}
29
+ with safe_open(path, framework="pt", device="cpu") as sf:
30
+ metadata = sf.metadata() or {}
31
+ layers = sorted(json.loads(metadata["_quantization_metadata"])["layers"])[:limit_layers]
32
+ for layer in layers:
33
+ for suffix in ("weight", "weight_scale", "weight_scale_2", "input_scale", "comfy_quant"):
34
+ key = f"{layer}.{suffix}"
35
+ state_dict[key] = sf.get_tensor(key)
36
+ return state_dict, metadata
37
+
38
+
39
+ def main() -> None:
40
+ args = parse_args()
41
+ sys.path.insert(0, str(args.comfyui.resolve()))
42
+
43
+ import comfy.quant_ops as quant_ops
44
+ import comfy.utils as comfy_utils
45
+
46
+ nvfp4 = quant_ops.QUANT_ALGOS.get("nvfp4")
47
+ assert nvfp4 is not None, "ComfyUI source has no nvfp4 quant algo"
48
+ assert nvfp4["storage_t"] == torch.uint8, nvfp4
49
+ assert {"weight_scale", "weight_scale_2", "input_scale"} <= nvfp4["parameters"], nvfp4
50
+ assert nvfp4["comfy_tensor_layout"] == "TensorCoreNVFP4Layout", nvfp4
51
+
52
+ files = args.files or sorted(Path(".").glob("*_comfy.safetensors"))
53
+ for path in files:
54
+ state_dict, metadata = read_subset(path)
55
+ converted, _ = comfy_utils.convert_old_quants(dict(state_dict), metadata=metadata)
56
+ comfy_quant_keys = sorted(k for k in converted if k.endswith(".comfy_quant"))
57
+ if not comfy_quant_keys:
58
+ raise AssertionError(f"{path.name}: ComfyUI did not expose comfy_quant keys")
59
+ for key in comfy_quant_keys:
60
+ marker = json.loads(bytes(converted[key].tolist()).decode("utf-8"))
61
+ if marker.get("format") != "nvfp4":
62
+ raise AssertionError(f"{path.name}: {key} marker is {marker!r}")
63
+ print(f"OK {path.name}: ComfyUI source recognizes nvfp4 metadata and comfy_quant keys")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()