Files
2025-05-08 10:17:09 +02:00

371 lines
22 KiB
Python

# nodes.py
# This file contains the implementation of your custom nodes.
import torch
from safetensors.torch import save_file
from tqdm import tqdm
import os
# --- Helper Classes (Using original simpler scaling logic as provided by user) ---
class FP8Quantizer:
"""A class to apply FP8 quantization to a state_dict."""
def __init__(self, quant_dtype: str = "float8_e5m2"):
if not hasattr(torch, quant_dtype):
raise ValueError(f"Unsupported quant_dtype: {quant_dtype}. PyTorch does not have this attribute.")
self.quant_dtype = quant_dtype
self.scale_factors = {} # Not used in current quantize_weights but kept for potential future use
def quantize_weights(self, weight: torch.Tensor, layer_name: str) -> torch.Tensor:
"""Quantizes a weight tensor to the specified FP8 format using simple scaling."""
if not weight.is_floating_point():
return weight # Only quantize floating point tensors
original_device = weight.device
# Ensure FP8 conversion happens on CUDA if possible, as FP8 types require it
can_use_cuda = torch.cuda.is_available()
target_device = torch.device("cuda") if can_use_cuda else torch.device("cpu")
# Warn if trying FP8 on CPU without CUDA
if not can_use_cuda and "float8" in self.quant_dtype:
print(f"[FP8Quantizer] Warning: CUDA not available. True {self.quant_dtype} conversion requires CUDA. Attempting on CPU, but results may be unexpected or errors may occur.")
target_device = torch.device("cpu") # Try on CPU despite warning
weight_on_target = weight.to(target_device)
max_val = torch.max(torch.abs(weight_on_target))
if max_val == 0:
# For zero tensor, just cast to target dtype on the target device
target_torch_dtype = getattr(torch, self.quant_dtype)
return torch.zeros_like(weight_on_target, dtype=target_torch_dtype)
else:
# Using the simple scaling from user's provided script
scale = max_val / 127.0
# Clamp scale to avoid division by zero if max_val is extremely small
scale = torch.max(scale, torch.tensor(1e-12, device=target_device, dtype=weight_on_target.dtype))
# Quantize: scale, round, unscale (simulated int8 range mapping)
quantized_weight_simulated = torch.round(weight_on_target / scale * 127.0) / 127.0 * scale
# Final cast to the target FP8 dtype
target_torch_dtype = getattr(torch, self.quant_dtype)
quantized_weight = quantized_weight_simulated.to(dtype=target_torch_dtype)
# Return on the device where conversion happened (target_device, ideally CUDA)
return quantized_weight
def apply_quantization(self, state_dict: dict) -> dict:
"""Applies direct FP8 quantization to all applicable weights."""
quantized_state_dict = {}
eligible_tensors = {name: param for name, param in state_dict.items() if isinstance(param, torch.Tensor) and param.is_floating_point()}
progress_bar = tqdm(eligible_tensors.items(), desc=f"Quantizing to {self.quant_dtype}", unit="tensor", leave=False)
for name, param in progress_bar:
# quantize_weights handles device logic now
quantized_state_dict[name] = self.quantize_weights(param.clone(), name)
for name, param in state_dict.items():
if name not in quantized_state_dict:
quantized_state_dict[name] = param
return quantized_state_dict
class FP8ScaledQuantizer:
"""
Simulated FP8 quantizer using 8-bit scaled float approximation based on user provided script.
Operations are performed on the device of the input tensors.
(Used internally by QuantizeModel node for the value simulation step).
"""
def __init__(self, scaling_strategy: str = "per_tensor"):
self.scaling_strategy = scaling_strategy
self.scale_factors = {} # Stores the calculated scales (Python floats or lists)
def _quantize_fp8_simulated(self, tensor: torch.Tensor, scale: torch.Tensor) -> torch.Tensor:
"""Simulate quantization by scaling, clamping to 8-bit range, and dequantizing."""
# Ensure scale is a tensor on the correct device and dtype
scale = scale.to(device=tensor.device, dtype=tensor.dtype)
# Prevent division by zero
scale = torch.where(scale == 0, torch.tensor(1e-9, device=tensor.device, dtype=tensor.dtype), scale)
# Perform simulation: scale, round, clamp, unscale
quantized_intermediate = tensor / scale * 127.0
quantized = torch.round(quantized_intermediate).clamp_(-127.0, 127.0)
dequantized = quantized / 127.0 * scale
return dequantized
def quantize_weights(self, weight: torch.Tensor, layer_name: str) -> torch.Tensor:
"""Applies the simulated quantization based on the chosen strategy."""
if not isinstance(weight, torch.Tensor) or not weight.is_floating_point():
return weight # Skip non-float tensors
current_device = weight.device
if self.scaling_strategy == "per_tensor":
scale_val = torch.max(torch.abs(weight))
# Ensure scale_val is a tensor for consistent handling in _quantize_fp8_simulated
scale = scale_val if scale_val != 0 else torch.tensor(1.0, device=current_device, dtype=weight.dtype)
self.scale_factors[layer_name] = scale.item() # Store the scale value
quantized_weight = self._quantize_fp8_simulated(weight, scale)
elif self.scaling_strategy == "per_channel":
if weight.ndim < 2: # Fallback to per-tensor for 1D tensors
scale_val = torch.max(torch.abs(weight))
scale = scale_val if scale_val != 0 else torch.tensor(1.0, device=current_device, dtype=weight.dtype)
self.scale_factors[layer_name] = scale.item()
quantized_weight = self._quantize_fp8_simulated(weight, scale)
else:
# Assume channel dimension is 0 for typical Conv layers
# For Linear layers (e.g., [out_features, in_features]), dim 0 is also common.
# If weights are [in, out], dim 1 might be needed. Defaulting to dim 0.
channel_dim = 0
dims_to_reduce = [d for d in range(weight.ndim) if d != channel_dim]
if not dims_to_reduce: # Handle edge case if channel_dim is the only dim somehow
scale_val = torch.max(torch.abs(weight))
scale = scale_val if scale_val != 0 else torch.tensor(1.0, device=current_device, dtype=weight.dtype)
self.scale_factors[layer_name] = scale.item()
else:
scale = torch.amax(torch.abs(weight), dim=dims_to_reduce, keepdim=True)
# Store scales as a list of floats
self.scale_factors[layer_name] = scale.squeeze().tolist()
quantized_weight = self._quantize_fp8_simulated(weight, scale)
else:
raise ValueError(f"Unknown scaling strategy: {self.scaling_strategy}")
# The output tensor retains the original dtype but has modified values
return quantized_weight
def apply_quantization(self, state_dict: dict) -> dict:
"""Applies simulated FP8 quantization to all applicable weights."""
quantized_state_dict = {}
# Process only floating point tensors
eligible_tensors = {name: param for name, param in state_dict.items() if isinstance(param, torch.Tensor) and param.is_floating_point()}
progress_bar = tqdm(eligible_tensors.items(), desc=f"Applying scaled ({self.scaling_strategy}) quantization", unit="tensor", leave=False)
for name, param in progress_bar:
# Pass the clone to avoid modifying the original dict if errors occur mid-way
quantized_state_dict[name] = self.quantize_weights(param.clone(), name)
# Add back non-floating point tensors and non-tensor data
for name, param in state_dict.items():
if name not in quantized_state_dict:
quantized_state_dict[name] = param
return quantized_state_dict
# --- ComfyUI Nodes ---
class ModelToStateDict:
@classmethod
def INPUT_TYPES(s): return {"required": {"model": ("MODEL",)}}
RETURN_TYPES = ("MODEL_STATE_DICT",); RETURN_NAMES = ("model_state_dict",)
FUNCTION = "get_state_dict"; CATEGORY = "Model Quantization/Utils"
def get_state_dict(self, model):
print("[ModelToStateDict] Attempting to extract state_dict...")
if not hasattr(model, 'model') or not hasattr(model.model, 'state_dict'):
print("[ModelToStateDict] Error: Invalid MODEL structure."); return ({},)
try:
original_state_dict = model.model.state_dict()
print(f"[ModelToStateDict] Original keys sample: {list(original_state_dict.keys())[:5]}")
state_dict_to_return = original_state_dict; prefixes_to_try = ["diffusion_model.", "model."]; prefix_found = False
for prefix in prefixes_to_try:
num_keys = len(original_state_dict);
if num_keys == 0: break
matches = sum(1 for k in original_state_dict if k.startswith(prefix))
if matches > 0 and (matches / num_keys > 0.5 or matches == num_keys):
print(f"[ModelToStateDict] Stripping prefix '{prefix}'...")
state_dict_to_return = {k[len(prefix):] if k.startswith(prefix) else k: v for k, v in original_state_dict.items()}
print(f"[ModelToStateDict] New keys sample: {list(state_dict_to_return.keys())[:5]}"); prefix_found = True; break
if not prefix_found: print("[ModelToStateDict] No common prefixes stripped.")
dtypes = {}; total = 0
for k, v in state_dict_to_return.items():
if isinstance(v, torch.Tensor): total += 1; dt = str(v.dtype); dtypes[dt] = dtypes.get(dt, 0) + 1
print(f"[ModelToStateDict] DEBUG: Output Tensors: {total}, Dtypes: {dtypes}")
return (state_dict_to_return,)
except Exception as e: print(f"[ModelToStateDict] Error: {e}"); return ({},)
class QuantizeFP8Format: # Direct FP8 conversion node
@classmethod
def INPUT_TYPES(s): return { "required": { "model_state_dict": ("MODEL_STATE_DICT",), "fp8_format": (["float8_e4m3fn", "float8_e5m2"], {"default": "float8_e5m2"}), } }
RETURN_TYPES = ("MODEL_STATE_DICT",); RETURN_NAMES = ("quantized_model_state_dict",)
FUNCTION = "quantize_model"; CATEGORY = "Model Quantization/FP8 Direct"
def quantize_model(self, model_state_dict: dict, fp8_format: str):
print(f"[QuantizeFP8Format] To {fp8_format}. Keys(sample): {list(model_state_dict.keys())[:3]}")
if not isinstance(model_state_dict, dict) or not model_state_dict: print("[QuantizeFP8Format] Invalid input."); return ({},)
try:
quantizer = FP8Quantizer(quant_dtype=fp8_format) # Uses helper class with simple scaling
quantized_state_dict = quantizer.apply_quantization(model_state_dict)
found = False;
for n, p in quantized_state_dict.items():
if isinstance(p, torch.Tensor) and "float8" in str(p.dtype): print(f"[QuantizeFP8Format] Sample '{n}' dtype: {p.dtype}, dev: {p.device}"); found=True; break
if not found: print(f"[QuantizeFP8Format] No tensor converted to {fp8_format}.")
print("[QuantizeFP8Format] Complete."); return (quantized_state_dict,)
except Exception as e: print(f"[QuantizeFP8Format] Error: {e}"); return (model_state_dict,)
class QuantizeModel: # <<< RENAMED CLASS from QuantizeScaled
"""
Applies simulated FP8 scaling (per-tensor/per-channel) and casts
to a specified output dtype (float16, bfloat16, or Original).
"""
# Removed "FP8" from the list as requested
OUTPUT_DTYPES_LIST = ["Original", "float16", "bfloat16"]
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model_state_dict": ("MODEL_STATE_DICT",),
"scaling_strategy": (["per_tensor", "per_channel"], {"default": "per_tensor"}),
"processing_device": (["Auto", "CPU", "GPU"], {"default": "Auto"}),
# Default to float16 for size reduction, user can choose others
"output_dtype": (s.OUTPUT_DTYPES_LIST, {"default": "float16"}),
}
}
RETURN_TYPES = ("MODEL_STATE_DICT",)
RETURN_NAMES = ("quantized_model_state_dict",)
FUNCTION = "quantize_model_scaled" # Internal function name can stay
CATEGORY = "Model Quantization" # More general category
def quantize_model_scaled(self, model_state_dict: dict, scaling_strategy: str, processing_device: str, output_dtype: str):
# Log using the new class name
print(f"[QuantizeModel] Strategy: {scaling_strategy}, Device: {processing_device}, Output Dtype: {output_dtype}")
if not isinstance(model_state_dict, dict) or not model_state_dict:
print("[QuantizeModel] Error: Input model_state_dict is invalid.");
return (model_state_dict if isinstance(model_state_dict, dict) else {},)
# Determine processing device
current_processing_device_str = "cpu"
if processing_device == "Auto":
first_tensor_device = next((p.device for p in model_state_dict.values() if isinstance(p, torch.Tensor)), torch.device("cpu"))
current_processing_device_str = str(first_tensor_device)
elif processing_device == "CPU": current_processing_device_str = "cpu"
elif processing_device == "GPU":
if torch.cuda.is_available(): current_processing_device_str = "cuda"
else: print("[QuantizeModel] Warning: GPU selected, CUDA unavailable. Defaulting to CPU."); current_processing_device_str = "cpu"
current_processing_device = torch.device(current_processing_device_str)
print(f"[QuantizeModel] Value scaling simulation target device: {current_processing_device}")
# Move input state_dict to the processing device
state_dict_on_processing_device = {}
for name, param in model_state_dict.items():
if isinstance(param, torch.Tensor):
state_dict_on_processing_device[name] = param.to(current_processing_device)
else: state_dict_on_processing_device[name] = param
scaled_state_dict = {}; final_state_dict = {}
try:
# Perform FP8 value simulation using FP8ScaledQuantizer helper (simple scaling version)
quantizer = FP8ScaledQuantizer(scaling_strategy=scaling_strategy)
scaled_state_dict = quantizer.apply_quantization(state_dict_on_processing_device)
print(f"[QuantizeModel] FP8 value scaling simulation performed on {current_processing_device}.")
# Cast to final output_dtype (Original, float16, bfloat16)
if output_dtype == "Original":
print("[QuantizeModel] Output Dtype: Original. No further dtype casting.")
final_state_dict = scaled_state_dict
else:
# output_dtype is guaranteed to be 'float16' or 'bfloat16'
try:
target_torch_dtype = getattr(torch, output_dtype)
print(f"[QuantizeModel] Casting output to {output_dtype} ({target_torch_dtype})...")
for name, param in scaled_state_dict.items():
if isinstance(param, torch.Tensor) and param.is_floating_point():
final_state_dict[name] = param.to(dtype=target_torch_dtype)
else:
final_state_dict[name] = param # Pass non-float tensors or non-tensors
print(f"[QuantizeModel] Casting to {output_dtype} complete.")
except AttributeError: # Should not happen with the restricted list
print(f"[QuantizeModel] Error: Invalid torch dtype '{output_dtype}'. Using scaled tensors without final casting.")
final_state_dict = scaled_state_dict
except Exception as e_cast:
print(f"[QuantizeModel] Error during casting loop to {output_dtype}: {e_cast}. Using scaled tensors without final casting for affected tensors.")
for name_done, param_done in final_state_dict.items(): pass
for name_rem, param_rem in scaled_state_dict.items():
if name_rem not in final_state_dict: final_state_dict[name_rem] = param_rem
# Verification log
for name, param in final_state_dict.items():
if isinstance(param, torch.Tensor) and param.is_floating_point():
print(f"[QuantizeModel] Sample output tensor '{name}' final dtype: {param.dtype}, device: {param.device}")
break
print(f"[QuantizeModel] Processing complete.")
return (final_state_dict,)
except Exception as e:
print(f"[QuantizeModel] Major error during processing: {e}")
return (model_state_dict,)
class SaveAsSafeTensor: # No changes needed
@classmethod
def INPUT_TYPES(s): return { "required": { "quantized_model_state_dict": ("MODEL_STATE_DICT",), "absolute_save_path": ("STRING", {"default": "C:/temp/quantized_model.safetensors", "multiline": False}), } }
RETURN_TYPES = () ; OUTPUT_NODE = True ; FUNCTION = "save_model"; CATEGORY = "Model Quantization/Save"
def save_model(self, quantized_model_state_dict: dict, absolute_save_path: str):
print(f"[SaveAsSafeTensor] Saving to: {absolute_save_path}")
if not isinstance(quantized_model_state_dict, dict) or not quantized_model_state_dict: print("[SaveAsSafeTensor] Error: Input invalid."); return {"ui": {"text": ["Error: Input invalid."]}}
if not absolute_save_path: print("[SaveAsSafeTensor] Error: Path empty."); return {"ui": {"text": ["Error: Path empty."]}}
if not absolute_save_path.lower().endswith(".safetensors"): absolute_save_path += ".safetensors"; print(f"[SaveAsSafeTensor] Appended .safetensors")
try:
output_dir = os.path.dirname(absolute_save_path);
if output_dir and not os.path.exists(output_dir): os.makedirs(output_dir, exist_ok=True); print(f"[SaveAsSafeTensor] Created dir.")
cpu_state_dict = {}; dtype_counts = {}; total_tensors = 0
for k, v in quantized_model_state_dict.items():
if isinstance(v, torch.Tensor):
total_tensors += 1; tensor_to_save = v.cpu() if v.device.type != 'cpu' else v; cpu_state_dict[k] = tensor_to_save
dt_str = str(tensor_to_save.dtype); dtype_counts[dt_str] = dtype_counts.get(dt_str, 0) + 1
else: cpu_state_dict[k] = v
print(f"[SaveAsSafeTensor] DEBUG: Tensors: {total_tensors}, Dtypes: {dtype_counts}")
save_file(cpu_state_dict, absolute_save_path)
print(f"[SaveAsSafeTensor] Saved successfully."); return {"ui": {"text": [f"Saved: {absolute_save_path}"]}}
except Exception as e: print(f"[SaveAsSafeTensor] Error saving: {e}"); return {"ui": {"text": [f"Error: {e}"]}}
# --- Main (for testing outside ComfyUI, not strictly necessary for the plugin) ---
# (Test block remains the same as previous version, it already tests QuantizeModel)
if __name__ == '__main__':
print("--- Testing Quantization Nodes (Renamed QuantizeModel) ---")
class MockCoreModel(torch.nn.Module):
def __init__(self): super().__init__(); self.layer1 = torch.nn.Linear(10,10).float(); self.layer2=torch.nn.Linear(10,10).float()
def forward(self,x): return self.layer2(self.layer1(x))
def state_dict(self, *args, **kwargs): return {k:v.clone() for k,v in super().state_dict(*args,**kwargs).items()}
class MockModelWithPrefix(torch.nn.Module):
def __init__(self): super().__init__(); self.model = MockCoreModel() # Use "model." prefix
def forward(self,x): return self.model(x)
class MockModelPatcher:
def __init__(self): self.model = MockModelWithPrefix(); [p.data.normal_().float() for p in self.model.parameters()]
mock_comfy_model = MockModelPatcher()
node_to_sd = ModelToStateDict()
base_sd_tuple = node_to_sd.get_state_dict(mock_comfy_model)
base_sd = base_sd_tuple[0] if base_sd_tuple else {}
if not base_sd or 'layer1.weight' not in base_sd: print("ModelToStateDict failed."); exit() # Check unprefixed key
print(f"Base SD 'layer1.weight' dtype: {base_sd['layer1.weight'].dtype}, device: {base_sd['layer1.weight'].device}")
# Test the renamed node
node_quantize = QuantizeModel()
print("\n--- Test QuantizeModel ---")
# Test Case 1: Output float16
print("Testing QuantizeModel: Output Dtype = float16, Device = CPU")
result_fp16_tuple = node_quantize.quantize_model_scaled(base_sd, "per_tensor", "CPU", "float16")
result_fp16 = result_fp16_tuple[0] if result_fp16_tuple else {}
if result_fp16 and 'layer1.weight' in result_fp16:
tensor = result_fp16['layer1.weight']
print(f" Output 'layer1.weight' dtype: {tensor.dtype} (Expected float16), device: {tensor.device}")
assert tensor.dtype == torch.float16
assert tensor.device.type == 'cpu'
else: print(" Test Case 1 Failed.")
# Test Case 2: Output Original
print("\nTesting QuantizeModel: Output Dtype = Original, Device = CPU")
result_orig_tuple = node_quantize.quantize_model_scaled(base_sd, "per_tensor", "CPU", "Original")
result_orig = result_orig_tuple[0] if result_orig_tuple else {}
if result_orig and 'layer1.weight' in result_orig:
tensor = result_orig['layer1.weight']
print(f" Output 'layer1.weight' dtype: {tensor.dtype} (Expected {base_sd['layer1.weight'].dtype}), device: {tensor.device}")
assert tensor.dtype == base_sd['layer1.weight'].dtype # Should match original
assert tensor.device.type == 'cpu'
else: print(" Test Case 2 Failed.")
print("\n--- Testing Complete ---")