Files
2026-05-19 22:27:40 +03:00

1529 lines
82 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Streaming triple checkpoint merger using Surveyor & Weaver pattern.
Handles three source checkpoint files (.safetensors) and merges them with low RAM usage.
"""
import json
import gc
import time
import sys
import uuid
import os
import folder_paths
# Debug flag for verbose diagnostic logging during checkpoint weave operations.
# Set WEAVER_DEBUG=1 environment variable to enable detailed per-batch diagnostics.
# In production (default), only essential warnings and summary lines are printed,
# eliminating ~2700 log lines and ~2600 sys.stdout.flush() calls per merge.
_WEAVER_DEBUG = os.environ.get("WEAVER_DEBUG", "0") == "1"
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
import torch
from safetensors import safe_open
from safetensors.torch import save_file
from ..utils import (
categorize_checkpoint_key,
load_checkpoint_with_metadata,
ThreadSafeCleanup,
comfyui_yield,
ProgressTracker,
NullProgressTracker,
cleanup_memory,
get_available_ram,
)
from ..config import DevicePrecisionConfig
from .checkpoint_methods import merge_triple_method
from .metadata_factory import scrub_metadata, sign_metadata
from .identity_normalizer import identity_normalize
from .musubi_checkpoint_studio import MusubiCheckpointStudio, _LazyCheckpointMapping
from .checkpoint_normalizer import (
detect_checkpoint_architecture,
normalize_checkpoint_header,
)
from .forensics import build_forensic_report
from .fp8_quantizer import (
should_preserve_bf16,
quantize_weight_to_fp8_with_scales_optimized as quantize_weight_to_fp8_with_scales,
dequant_fp8_tensor,
)
class CheckpointTripleMerger:
"""
Two‑pass incremental merger that merges three checkpoints on the fly.
Accepts either file paths (source_paths) or in-memory state dicts
(source_dicts) as source inputs. When source_dicts[i] is provided,
the corresponding source_paths[i] is ignored.
"""
class _DictMapping:
"""Dict-like wrapper for in-memory state dicts, compatible with weave()."""
def __init__(self, state_dict):
self._sd = state_dict
def __getitem__(self, key):
return self._sd[key]
def keys(self):
return list(self._sd.keys())
def __contains__(self, key):
return key in self._sd
def __iter__(self):
return iter(self._sd.keys())
def __len__(self):
return len(self._sd)
def close(self):
"""No-op: in-memory dict has no resources to close."""
pass
# Mapping from safetensors dtype strings to torch dtypes and element sizes
DTYPE_MAP = {
"F64": (torch.float64, 8),
"F32": (torch.float32, 4),
"F16": (torch.float16, 2),
"BF16": (torch.bfloat16, 2),
"F8_E4M3": (torch.float8_e4m3fn, 1),
"F8_E5M2": (torch.float8_e5m2, 1),
"I64": (torch.int64, 8),
"I32": (torch.int32, 4),
"I16": (torch.int16, 2),
"I8": (torch.int8, 1),
"U8": (torch.uint8, 1),
"BOOL": (torch.bool, 1),
}
# Reverse mapping from torch.dtype to safetensors dtype string
DTYPE_REVERSE = {
torch.float64: "F64", torch.float32: "F32",
torch.float16: "F16", torch.bfloat16: "BF16",
torch.float8_e4m3fn: "F8_E4M3",
torch.float8_e5m2: "F8_E5M2",
torch.int64: "I64", torch.int32: "I32",
torch.int16: "I16", torch.int8: "I8",
torch.uint8: "U8",
torch.bool: "BOOL",
}
# Extended reverse map with torch aliases and string self-mappings for robust matching.
# Used by _read_header to handle both torch.dtype and string dtype identifiers.
DTYPE_REVERSE_ALIASES = {
**DTYPE_REVERSE,
torch.half: "F16", torch.long: "I64",
torch.int: "I32", torch.short: "I16",
"F64": "F64", "F32": "F32", "F16": "F16", "BF16": "BF16",
"I64": "I64", "I32": "I32", "I16": "I16", "I8": "I8", "U8": "U8",
}
@staticmethod
def _tensor_to_bytes(tensor: torch.Tensor) -> bytes:
"""
Convert a contiguous CPU tensor to raw bytes, supporting all dtypes
(including bfloat16 which numpy does not support).
"""
if tensor.dtype in (torch.bfloat16, torch.float8_e4m3fn, torch.float8_e5m2):
# bfloat16/fp8 not supported by numpy, reinterpret as uint8.
# tensor must be contiguous (caller ensures).
# Handle scalar tensors (dim 0) because view() requires at least 1 dim.
if tensor.dim() == 0:
tensor = tensor.reshape(-1) # convert to 1D with 1 element
return tensor.view(torch.uint8).numpy().tobytes()
else:
# Use numpy for speed and simplicity.
return tensor.numpy().tobytes()
@staticmethod
def _read_header(filepath: Path) -> Tuple[Dict[str, Any], Dict[str, str]]:
"""
Read safetensors header from file, return dict with shape/dtype per key and metadata.
Uses safe_open for robust reading.
"""
tensors = {}
metadata = {}
try:
with safe_open(filepath, framework="pt") as sf:
metadata = sf.metadata() or {}
# Use class-level aliased reverse map (includes torch aliases + string self-mappings)
dtype_reverse = CheckpointTripleMerger.DTYPE_REVERSE_ALIASES
for key in sf.keys():
slice_info = sf.get_slice(key)
dtype = slice_info.get_dtype()
if isinstance(dtype, str):
dtype_str = dtype
else:
dtype_str = dtype_reverse.get(dtype, "F32")
if dtype_str == "F32" and dtype not in dtype_reverse:
print(f"[_read_header] WARNING: dtype {dtype} not in mapping for key {key}, defaulting to F32")
if 'position_ids' in key:
print(f"[_read_header] position_ids key {key}: dtype={dtype}, dtype_str={dtype_str}")
tensors[key] = {
"shape": list(slice_info.get_shape()),
"dtype": dtype_str,
}
except Exception as e:
# If safe_open fails, fall back to manual parsing (original logic)
with open(filepath, 'rb') as f:
magic = f.read(8)
if magic != b'__safet':
print(f"⚠️ Not a safetensors file: {filepath.name}, magic={magic!r}")
raise ValueError('Not a safetensors file')
header_size_bytes = f.read(8)
if len(header_size_bytes) != 8:
raise ValueError('Invalid header size')
header_size = int.from_bytes(header_size_bytes, 'little')
header_bytes = f.read(header_size)
header = json.loads(header_bytes)
# Filter out '__metadata__'
tensors = {k: v for k, v in header.items() if k != '__metadata__'}
metadata = header.get('__metadata__', {})
return tensors, metadata
@staticmethod
def _read_header_from_dict(state_dict: Dict[str, torch.Tensor]) -> Tuple[Dict[str, Any], Dict[str, str]]:
"""
Extract header info (shape, dtype) from an in-memory state dict.
Returns the same format as _read_header() — (tensors_dict, metadata_dict).
This avoids writing chained data to a temporary safetensors file
just to read its header back.
"""
tensors = {}
dtype_reverse = CheckpointTripleMerger.DTYPE_REVERSE_ALIASES
for key, tensor in state_dict.items():
dtype_str = dtype_reverse.get(tensor.dtype, "F32")
tensors[key] = {
"shape": list(tensor.shape),
"dtype": dtype_str,
}
return tensors, {}
@staticmethod
def _ensure_safetensors(filepath: Path) -> Path:
"""
Ensure the file is a safetensors file; if not, convert it.
Returns the path to a safetensors file (original or temporary).
"""
# Try to open with safe_open; if it succeeds, treat as safetensors.
try:
with safe_open(filepath, framework="pt"):
pass
except Exception:
# safe_open failed, check magic bytes as a fallback
with open(filepath, 'rb') as f:
magic = f.read(8)
if magic == b'__safet':
# Magic bytes match but safe_open failed; maybe metadata issue.
# Still treat as safetensors, but log a warning.
print(f"⚠️ safe_open failed but magic bytes match for {filepath.name}; treating as safetensors (may have metadata issues).")
return filepath
# Not a safetensors file, proceed to conversion
else:
# safe_open succeeded → genuine safetensors
return filepath
# Load as checkpoint (supports .ckpt, .pth, etc.)
gb = os.path.getsize(filepath) / (1024**3)
print(f"⚠️ Loading entire checkpoint into RAM ({gb:.1f} GB) for conversion. Ensure sufficient free memory.")
print(f"🔧 Converting non‑safetensors file {filepath.name} to safetensors...")
tensors, metadata = load_checkpoint_with_metadata(filepath, categorize=False)
# Create temporary safetensors file
temp_dir = Path(folder_paths.get_temp_directory()) / "easy_checkpoint_merger"
temp_dir.mkdir(parents=True, exist_ok=True)
unique = uuid.uuid4().hex[:8]
temp_path = temp_dir / f"converted_{unique}_{filepath.stem}.safetensors"
# Save as safetensors
save_file(tensors, str(temp_path), metadata=metadata)
print(f" Temporary safetensors saved to {temp_path}")
# Verify the saved file can be opened with safe_open (more robust than magic check)
try:
with safe_open(temp_path, framework="pt"):
pass
except Exception as e:
raise RuntimeError(
f"Converted file {temp_path.name} is not a valid safetensors file "
f"(safe_open failed: {e})"
)
# Register for cleanup
ThreadSafeCleanup.register_temp_file(temp_path)
return temp_path
def __init__(self,
source_paths: List[Path],
output_path: Path,
merge_config: Dict[str, Any],
metadata_options: Optional[Dict[str, Any]] = None,
use_dict: bool = False,
source_dicts: Optional[List[Optional[Dict[str, torch.Tensor]]]] = None):
"""
Initialize the triple merger.
Args:
source_paths: List of checkpoint file paths (fewer than three allowed).
For sources where source_dicts[i] is provided, the path is a placeholder.
output_path: Where to write the merged checkpoint.
merge_config: Dictionary with merge parameters:
- method: str = "linear"
- weights: List[float] = [1.0, 1.0, 1.0]
- density: float = 1.0
- uniqueness: float = 0.7
- threshold: float = 0.0
- blend: float = 0.5
- blend_mode: str = "auto"
- magnitude_scaling: str = "none"
- max_scaling_factor: float = 10.0
- batch_size: int = 32
- streaming: bool = True
- energy_preservation: bool = True
- balancing_mode: str = "disabled"
- weight_unet: float = 1.0
- weight_clip: float = 1.0
- weight_vae: float = 1.0
- weight_te: float = 1.0
- precision: str = "auto"
- device: str = "auto"
metadata_options: Options for metadata merging (keep, inject, etc.).
source_dicts: Optional parallel list to source_paths. When source_dicts[i]
is not None, that source's data comes from the in-memory dict instead
of reading from source_paths[i]. This avoids writing chained data
to a temporary file on disk.
"""
if len(source_paths) > 3:
raise ValueError("Maximum three source checkpoints supported.")
self.source_dicts = source_dicts or [None] * len(source_paths)
self.source_paths = [Path(p) for p in source_paths]
# Convert non‑safetensors files to safetensors — skip for dict sources
self.source_paths = [
self._ensure_safetensors(p) if self.source_dicts[i] is None else p
for i, p in enumerate(self.source_paths)
]
self.output_path = Path(output_path) if output_path is not None else None
self.merge_config = merge_config
self.metadata_options = metadata_options or {}
self.use_dict = use_dict
self._output_dict: Optional[Dict[str, torch.Tensor]] = None
# Resolve device and precision once via unified config
device_str = merge_config.get('device', 'auto')
precision_str = merge_config.get('precision', 'auto')
self._device_precision = DevicePrecisionConfig(
device_type=device_str, precision=precision_str
)
self.target_device = self._device_precision.device
self.target_dtype = self._device_precision.dtype
# Will be populated by survey()
self.headers = [] # list of header dicts per source
self.metadatas = [] # list of metadata dicts per source
self.normalized_headers = [] # list of normalized header dicts per source
self.key_mappings = [] # list of mapping dicts per source
self.common_keys = [] # keys present in all sources
self.union_keys = [] # keys present in at least one source (sorted)
self.tensor_list = [] # list of dicts describing each output tensor
self.header = None # output header dict
self.header_json = None
self.header_len_padded = None
self.data_start = None
self.total_parameters = 0
self.component_counts = {"unet": 0, "clip": 0, "vae": 0, "te": 0, "other": 0}
self.parameter_counts = {"unet": 0, "clip": 0, "vae": 0, "te": 0, "other": 0}
self.architecture = "Unknown"
# FP8 companion scales dict: norm_key → (weight_scale_tensor, input_scale_tensor)
# Populated during weave() when quantizing weights to FP8, used to write
# companion scale tensor entries alongside FP8-quantized weights.
self._companion_scales: Dict[str, Tuple[torch.Tensor, torch.Tensor]] = {}
# Companion scale tensor slots emitted by survey() for FP8 output.
# Stored separately from tensor_list so they don't inflate batch counting.
self.companion_list: List[Dict[str, Any]] = []
# OOM safety flag: set to True after first CUDA OOM, causing remaining
# batches to process on CPU instead of GPU.
self._oom_cpu_fallback = False
# Map torch dtype to safetensors dtype string
self.target_dtype_str = self.DTYPE_REVERSE.get(self.target_dtype, "F32")
self.elem_size = self.DTYPE_MAP.get(self.target_dtype_str, (None, 4))[1]
def _normalize_header(self, header: Dict[str, Any], metadata: Dict[str, str]) -> Tuple[Dict[str, Any], Dict[str, str]]:
"""
Normalize checkpoint keys using architecture-aware normalization.
For full checkpoints: uses checkpoint_normalizer to detect architecture and
normalize keys to canonical form (e.g., stripping 'model.diffusion_model.' prefix
for FLUX models), enabling proper key matching between sources with different
key naming conventions.
For LoRAs: falls back to identity_normalize for LoRA-specific key conversion.
"""
# Sample keys to show what we're detecting
sample_keys = list(header.keys())[:5]
has_lora = any('lora_' in k for k in header.keys())
if _WEAVER_DEBUG:
print(f"[_normalize_header] Checking {len(header)} keys, has 'lora_' pattern: {has_lora}, sample keys: {sample_keys}")
if not has_lora:
# Full checkpoint: use architecture-aware normalization
architecture = detect_checkpoint_architecture(list(header.keys()))
# Keep one concise line per source even in non-debug mode
if not _WEAVER_DEBUG:
print(f"[_normalize_header] {len(header)} keys, architecture: {architecture}")
else:
print(f"[_normalize_header] Architecture: {architecture}, normalizing {len(header)} keys")
# Normalize keys to canonical form (e.g., strip model.diffusion_model. prefix for FLUX)
normalized_header, mapping = normalize_checkpoint_header(header, architecture)
if _WEAVER_DEBUG:
print(f"[_normalize_header] Normalized {len(header)} keys -> {len(normalized_header)} canonical keys")
return normalized_header, mapping
# LoRA detected - proceed with identity normalization
if _WEAVER_DEBUG:
print(f"[_normalize_header] LoRA: {len(header)} keys, running identity_normalize")
# Build dummy state dict with keys only (identity_normalize uses keys, not values)
dummy_sd = {key: torch.zeros(1, dtype=torch.float32) for key in header}
# Normalize with identity mapping
normalized_sd, mapping = identity_normalize(dummy_sd, metadata=metadata)
# Convert back to header format (preserve shape/dtype from original header)
normalized_header = {}
for norm_key in normalized_sd.keys():
# Find original key via mapping (should exist)
orig_key = mapping.get(norm_key, norm_key)
# Keep original shape/dtype
normalized_header[norm_key] = header[orig_key]
return normalized_header, mapping
def survey(self, pbar: 'ProgressTracker' = NullProgressTracker()) -> Tuple[Dict[str, Any], int, Dict[str, int]]:
"""
First pass: read headers from all source files, compute common keys,
determine output tensor sizes and offsets, build output header.
Returns (header_dict, total_parameters, component_counts).
If called a second time (e.g. RAM guard then weave), cached results
are returned immediately — no redundant header I/O.
Args:
pbar: ProgressTracker to advance after each source processed.
Defaults to NullProgressTracker (silent no-op).
Stateless singleton — safe as default parameter.
"""
# ── Cache guard: skip if already surveyed ──
if self.tensor_list:
return self.header, self.total_parameters, self.component_counts
_survey_t0 = time.time()
print(f" 🕐 [t={_survey_t0 - time.time():.1f}s] survey: reading {len(self.source_paths)} source headers")
# Re-fetch current time for accurate relative offset
_survey_t0 = time.time()
self.headers = []
self.metadatas = []
self.normalized_headers = []
self.key_mappings = []
keys_per_source = []
norm_keys_per_source = []
# Read headers and collect keys
for i, src in enumerate(self.source_paths):
if self.source_dicts[i] is not None:
# In-memory dict source: extract header without writing to disk
tensors, metadata = self._read_header_from_dict(self.source_dicts[i])
print(f" [SURVEY] Source {i}: in-memory dict — {len(tensors)} keys")
else:
tensors, metadata = self._read_header(src)
self.headers.append(tensors)
self.metadatas.append(metadata)
# Normalize keys using architecture-aware normalization
norm_header, mapping = self._normalize_header(tensors, metadata)
self.normalized_headers.append(norm_header)
self.key_mappings.append(mapping)
keys_per_source.append(set(tensors.keys()))
# Normalized keys
norm_keys = set(norm_header.keys())
norm_keys_per_source.append(norm_keys)
pbar.update(1)
print(f" 🕐 [t={time.time()-_survey_t0:.1f}s] survey: headers read ({len(self.union_keys)} union keys before filtering)")
# Log integer tensor counts per source
for i, nh in enumerate(self.normalized_headers):
int_keys = [k for k, v in nh.items() if v['dtype'].startswith('I') or v['dtype'].startswith('U')]
if int_keys:
print(f"[DEBUG] Source {i}: {len(int_keys)} integer tensors")
# Compute intersection (keys present in all sources) and union (sorted) based on NORMALIZED keys
if norm_keys_per_source:
self.common_keys = list(set.intersection(*norm_keys_per_source))
else:
self.common_keys = []
self.union_keys = sorted(set.union(*norm_keys_per_source) if norm_keys_per_source else set())
# ── Filter out FP8 quantization artifact keys ─────────────────────
# These keys (*.input_scale, *.weight_scale, *.comfy_quant) are FP8
# / INT8 quantization metadata, not actual model weights. They appear
# in quantized safetensors files alongside regular weight tensors:
# - .input_scale / .weight_scale — companion scale factors
# - .comfy_quant — ComfyUI quantisation metadata
# Including them in the merge:
# (a) inflates the union with keys present in only one source,
# (b) gets them averaged/corrupted like regular tensors,
# (c) causes ComfyUI to detect "quantization metadata" and enter
# the MixedPrecisionOps code path, but the actual weights are
# BF16 (dequantized during merge) — model loading fails.
# This is safe for ALL architectures (Flux, SDXL, SD1.5, Z-Image,
# Anima, Lumina2) — none use these suffixes as actual parameter names.
# The GGUF writer (gguf_writer.py:203) already uses the same triple.
_FP8_ARTIFACT_SUFFIXES = ('.input_scale', '.weight_scale', '.comfy_quant')
filtered = []
removed = 0
for nk in self.union_keys:
if nk.endswith(_FP8_ARTIFACT_SUFFIXES):
removed += 1
else:
filtered.append(nk)
if removed:
print(f" ⏭️ Excluded {removed} FP8/INT8 quantization artifact keys from merge")
self.union_keys = filtered
print(f"[WEAVE] Union: {len(self.union_keys)} unique keys across sources")
# Merge metadata: keep from first source, add others with source prefix
merged_metadata = {}
if self.metadatas:
merged_metadata.update(self.metadatas[0])
for i, meta in enumerate(self.metadatas[1:], start=2):
for k, v in meta.items():
merged_metadata[f'source{i}_{k}'] = v
# Scrub based on keep_metadata flag, then sign with checkpoint_studio preset
mode = "preserve_a" if self.metadata_options.get('keep_metadata', True) else "none"
scrubbed = scrub_metadata(merged_metadata, mode)
# Strip _quantization_metadata ONLY when output dtype is NOT FP8.
#
# When the merge target is BF16/FP16/FP32, the merger dequantizes
# FP8 sources and produces float output — no quantization metadata
# should propagate (would trigger MixedPrecisionOps incorrectly).
#
# When target IS FP8, the output IS a quantized checkpoint and
# needs _quantization_metadata so ComfyUI's load_state_dict_guess_config
# can enter MixedPrecisionOps for proper dequant during inference.
# The companion scale tensors (.weight_scale, .input_scale) are
# emitted by survey() at lines 575-609 and populated by weave().
if self.target_dtype_str not in ("F8_E4M3", "F8_E5M2"):
scrubbed.pop('_quantization_metadata', None)
self.merged_metadata = scrubbed
# Determine output shape/dtype per key (use first source where key exists)
# For simplicity, assume all sources have same shape for common keys (no validation).
# We'll pick shape from first source that contains the key.
self.tensor_list = []
self.companion_list = []
self.total_parameters = 0
self.component_counts = {k: 0 for k in self.component_counts}
self.parameter_counts = {k: 0 for k in self.parameter_counts}
for norm_key in self.union_keys:
# Find first source that contains this normalized key
for i, norm_header in enumerate(self.normalized_headers):
if norm_key in norm_header:
info = norm_header[norm_key]
shape = tuple(info['shape'])
original_dtype_str = info['dtype']
break
else:
continue # shouldn't happen
numel = 1
for dim in shape:
numel *= dim
# Determine output dtype string and element size
# Preserve integer dtypes (I64, I32, I16, I8, U8) unchanged; convert float tensors to target dtype.
if original_dtype_str.startswith('I') or original_dtype_str.startswith('U'):
output_dtype_str = original_dtype_str
else:
output_dtype_str = self.target_dtype_str
elem_size = self.DTYPE_MAP.get(output_dtype_str, (None, 4))[1]
tensor_size = numel * elem_size
# No padding between tensors (contiguity required by safetensors)
pad_after = 0
# offset will be determined during sequential write
offset = None
# Component counting - need original key for categorization
# Choose original key from first source that contains this normalized key
orig_key = self.key_mappings[i].get(norm_key, norm_key)
comp = categorize_checkpoint_key(orig_key)
self.component_counts[comp] += 1
self.parameter_counts[comp] += numel
self.total_parameters += numel
# Determine which sources have this normalized key
sources = [idx for idx, nh in enumerate(self.normalized_headers) if norm_key in nh]
# For each source, get original key via mapping
orig_keys = []
for src_idx in range(len(self.source_paths)):
if src_idx in sources:
orig_keys.append(self.key_mappings[src_idx].get(norm_key, norm_key))
else:
orig_keys.append(None)
self.tensor_list.append({
"norm_key": norm_key,
"orig_keys": orig_keys,
"key": orig_key, # output key (original from first source)
"shape": shape,
"dtype": output_dtype_str,
"size": tensor_size,
"offset": offset,
"pad_after": pad_after,
"sources": sources,
})
# ── FP8: emit companion scale tensor slots for weight keys ──
if output_dtype_str in ("F8_E4M3", "F8_E5M2") and norm_key.endswith('.weight'):
if not should_preserve_bf16(norm_key):
# .weight_scale companion (scalar F32, 4 bytes)
weight_scale_key = orig_key + "_scale"
self.companion_list.append({
"norm_key": norm_key + "_scale",
"orig_keys": [None] * len(self.source_paths),
"key": weight_scale_key,
"shape": [1],
"dtype": "F32",
"size": 4,
"offset": None,
"pad_after": 0,
"sources": [],
"_companion": True,
"_parent_norm_key": norm_key,
"_scale_type": "weight_scale",
})
# .input_scale companion (scalar F32, 4 bytes)
input_scale_key = orig_key.replace('.weight', '.input_scale')
self.companion_list.append({
"norm_key": norm_key.replace('.weight', '.input_scale'),
"orig_keys": [None] * len(self.source_paths),
"key": input_scale_key,
"shape": [1],
"dtype": "F32",
"size": 4,
"offset": None,
"pad_after": 0,
"sources": [],
"_companion": True,
"_parent_norm_key": norm_key,
"_scale_type": "input_scale",
})
# Architecture detection (using centralized, extensible detection)
# Use normalized keys from all sources for accurate detection
all_normalized_keys = []
for nh in self.normalized_headers:
all_normalized_keys.extend(nh.keys())
self.architecture = detect_checkpoint_architecture(all_normalized_keys)
# Capitalize first letter for display (flux -> Flux, sdxl -> SDXL, sd15 -> SD1.5)
ARCH_DISPLAY_NAMES = {
"anima": "Anima", "flux": "Flux", "lumina2": "Lumina2",
"sdxl": "SDXL", "sd15": "SD1.5", "z_image": "Z-Image",
}
self.architecture = ARCH_DISPLAY_NAMES.get(self.architecture, "Unknown")
print(f"[Architecture] Detected: {self.architecture}")
# Free survey-only metadata — no longer needed by weave()
self.headers = None
self.normalized_headers = None
self.key_mappings = None
# Sign metadata: inject checkpoint_studio preset (SAI header + architecture)
if self.metadata_options.get('inject_sai_header', False):
self.merged_metadata = sign_metadata(
self.merged_metadata,
component="checkpoint_studio",
extra_fields={"modelspec.architecture": self.architecture}
)
else:
# Still add architecture even without full SAI header
self.merged_metadata['modelspec.architecture'] = self.architecture
# Build placeholder header with dummy offsets (0)
# Include both weight tensor_list and companion scale items.
header = {}
for item in self.tensor_list:
header[item['key']] = {
"dtype": item['dtype'],
"shape": list(item['shape']),
"data_offsets": [0, item['size']] # placeholder
}
for item in self.companion_list:
header[item['key']] = {
"dtype": item['dtype'],
"shape": list(item['shape']),
"data_offsets": [0, item['size']] # placeholder
}
# Add merged metadata
if self.merged_metadata:
header['__metadata__'] = self.merged_metadata
# Compute header length with zero offsets, add safety margin, and pad to 8 bytes
header_json = json.dumps(header, separators=(',', ':'))
header_len_raw = len(header_json)
# Safety margin to accommodate final offset digits (max 20 digits per offset) and metadata changes
# Increased to 64KB to ensure placeholder fits final header for large merges.
safety_margin = 65536
header_len_with_margin = header_len_raw + safety_margin
pad_len = (8 - (header_len_with_margin % 8)) % 8
header_len_padded = header_len_with_margin + pad_len
data_start = 8 + header_len_padded
# Placeholder header JSON padded with spaces (will be overwritten later)
# Add safety_margin + pad_len spaces so that the total written length matches header_len_padded
header_json_padded = header_json + ' ' * (header_len_padded - header_len_raw)
# Store placeholder results
self.header = header
self.header_json = header_json_padded
self.header_len_padded = header_len_padded
self.data_start = data_start
total_slots = len(self.tensor_list) + len(self.companion_list)
print(f"[WEAVE] Header boundary: {header_len_padded} bytes, data start = {data_start}, {total_slots} tensors ({len(self.tensor_list)} weights, {len(self.companion_list)} companions)")
print(f" 🕐 [t={time.time()-_survey_t0:.1f}s] survey complete ({total_slots} tensor slots)")
return header, self.total_parameters, self.component_counts
def weave(self, pbar: 'ProgressTracker' = NullProgressTracker()):
"""
Second pass: load tensors from each source, merge using checkpoint_methods,
write merged tensors sequentially using ground‑truth offsets.
Supports both file and BytesIO (true RAM) output targets.
Args:
pbar: ProgressTracker to advance after each batch completes.
Defaults to NullProgressTracker (silent no-op).
The pbar is advanced by merge_triple_method via on_substep callback,
NOT directly at the weave level.
Stateless singleton — safe as default parameter.
"""
# Ensure survey has been called
if not hasattr(self, 'header_json'):
raise RuntimeError('Survey must be called before weave')
# Determine output target: direct dict or file (disk)
if self.use_dict:
out_f = None
print(f"🧠 Direct dict mode – storing merged tensors in dict")
else:
out_f = open(self.output_path, 'wb', buffering=0)
try:
# ── Dict mode: skip placeholder header (no serialized output) ──
if self.use_dict:
data_start = 0
self._output_dict = {}
# ── Phase 3: detect zero-weight components for auto-omit ──
self._omitted_components = []
_comp_map = [("vae", "weight_vae"), ("te", "weight_te"), ("clip", "weight_clip")]
for cat, config_key in _comp_map:
if self.merge_config.get(config_key, 1.0) == 0.0:
self._omitted_components.append(cat)
if self._omitted_components:
print(f" ⏭️ Auto-omitting {self._omitted_components} tensors (weight=0)")
print(f" [DICT] Initialized output dict for {len(self.tensor_list)} tensors")
else:
# Write placeholder header length and JSON (already padded with safety margin)
header_len = self.header_len_padded
out_f.write(header_len.to_bytes(8, 'little'))
out_f.write(self.header_json.encode('utf-8'))
# Verify we are at data_start
current_pos = out_f.tell()
data_start = self.data_start
if current_pos != data_start:
print(f"[WARNING] File position after header ({current_pos}) does not match data_start ({data_start}), adjusting with zero padding")
if current_pos < data_start:
out_f.write(b'\x00' * (data_start - current_pos))
else:
# Should not happen because header length is fixed
out_f.seek(data_start)
# Prepare merge configuration
config = self.merge_config
weights = config.get('weights', [1.0, 1.0, 1.0])
if len(weights) < len(self.source_paths):
weights = weights + [1.0] * (len(self.source_paths) - len(weights))
weights = weights[:len(self.source_paths)]
# Dictionary to store ground‑truth offsets (relative to data_start)
# Use instance variable to prevent any scoping/GC issues between batches
self.actual_offsets = {}
first_tensor = True
batch_size = config.get('batch_size', 64)
streaming = config.get('streaming', True)
total_items = len(self.tensor_list)
# When streaming=False, process all tensors in a single batch
effective_batch_size = batch_size if streaming else total_items
num_batches = (total_items + effective_batch_size - 1) // effective_batch_size
if _WEAVER_DEBUG:
# ── DIAG: show first 10 tensor_list entries ──
print(f" [DIAG] tensor_list first 10 entries:")
for ti in range(min(10, total_items)):
print(f" [DIAG] [{ti}] norm_key='{self.tensor_list[ti]['norm_key']}', "
f"key='{self.tensor_list[ti]['key']}', "
f"sources={self.tensor_list[ti]['sources']}, "
f"shape={self.tensor_list[ti]['shape']}, "
f"dtype={self.tensor_list[ti]['dtype']}")
# ── DIAG: print expected total data size vs actual buffer capacity ──
expected_total_size = sum(item['size'] for item in self.tensor_list)
print(f" [DIAG] Expected total data size from survey: {expected_total_size} bytes "
f"({expected_total_size / (1024**3):.2f} GB)")
# pbar is always a valid object (NullProgressTracker by default).
# No internal fallback tracker needed — merge_triple_method handles
# substep progress via on_substep callback.
_weave_t0 = time.time()
print(f" 🕐 [t={0.0:.1f}s] weave: starting {num_batches} batches (batch_size={effective_batch_size})")
for batch_start in range(0, total_items, effective_batch_size):
# Open source files fresh for THIS batch only, then wrap in try/finally
# so mmap pages are released before the next batch begins.
batch_sources = []
for i, src_path in enumerate(self.source_paths):
if self.source_dicts[i] is not None:
# In-memory dict: wrap with _DictMapping instead of _LazyCheckpointMapping
mapping = CheckpointTripleMerger._DictMapping(self.source_dicts[i])
else:
mapping = _LazyCheckpointMapping(src_path)
batch_sources.append(mapping)
try:
batch_end = min(batch_start + effective_batch_size, total_items)
batch_items = self.tensor_list[batch_start:batch_end]
pos_before = 0 if self.use_dict else out_f.tell()
batch_num = batch_start // effective_batch_size + 1
if _WEAVER_DEBUG:
# ── DIAG: buffer position BEFORE batch ──
buf_id = id(out_f)
self_id = id(self)
print(f" [DIAG:BUF] Batch {batch_num}/{num_batches}: "
f"before write pos={pos_before}, "
f"rel_to_data={pos_before - data_start}, "
f"actual_offsets_count={len(self.actual_offsets)}, "
f"out_f_id={buf_id}, self_id={self_id}")
sys.stdout.flush()
# Build per‑source state dicts for this batch
batch_sds = [{} for _ in range(len(self.source_paths))]
batch_mappings = [{} for _ in range(len(self.source_paths))]
# batch_original_sds NOT needed — only used for LoRA alpha lookup
for item in batch_items:
norm_key = item['norm_key']
orig_keys = item['orig_keys']
source_indices = item['sources']
for idx in source_indices:
orig_key = orig_keys[idx]
tensor = batch_sources[idx][orig_key]
target_torch_dtype = self.DTYPE_MAP[item['dtype']][0]
# ── Dequantize FP8 tensors ────────────────────────────────
# If the loaded tensor is FP8-quantized (float8_e4m3fn or
# float8_e5m2), load the corresponding weight_scale from the
# same source and apply dequantization before converting to
# target dtype. This prevents half-range corrupted values
# when merging FP8 sources with float16/bfloat16 sources,
# which was the root cause of "noise instead of image" in
# the FP8 merge workflow.
if tensor.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
# Dequant FP8 → float32 using companion scale factors
tensor = dequant_fp8_tensor(tensor, orig_key, torch.float32, batch_sources[idx])
# If the storage dtype is FP8, use bfloat16 for compute
# (merge arithmetic cannot run on Float8 CUDA tensors).
# The final conversion to FP8 happens at write stage
# (see lines ~888-892).
if target_torch_dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
compute_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
if tensor.dtype != compute_dtype:
tensor = tensor.to(dtype=compute_dtype)
elif tensor.dtype != target_torch_dtype:
tensor = tensor.to(dtype=target_torch_dtype)
# Keep source tensors on CPU — merge_triple_method's per-key
# .to(device) at checkpoint_methods.py:878 handles GPU transfer
# on-demand for each key individually. Previously, ALL batch
# tensors were pre-loaded to GPU here, causing ~9 GB VRAM tied
# up during merge_triple_method, with unrecoverable 2-3 GB
# fragmentation accumulation per batch (→ CUDA OOM by batch 3).
batch_sds[idx][norm_key] = tensor
batch_mappings[idx][norm_key] = orig_key
# batch_original_sds[idx][orig_key] = tensor # REMOVED — unused in checkpoint path
# Build on_substep callback that advances the weave-level pbar
# via merge_triple_method's fine-grained progress reporting.
def on_merge_step(n: int) -> None:
pbar.update(n)
# Merge using merge_triple_method with mapping support.
# NOTE: Import is from engine.checkpoint_methods, which returns:
# (merged_dict: Dict[str, Tensor], corrupted_keys: List[str])
# This is NOT the same function as engine/triple_methods.py
# which returns 4 values.
merge_device = self.target_device
_batch_t0 = time.time()
try:
merged_dict, batch_corrupted = merge_triple_method(
sds=batch_sds,
weights=weights,
method=config.get('method', 'linear'),
density=config.get('density', 1.0),
uniqueness=config.get('uniqueness', 0.7),
threshold=config.get('threshold', 0.0),
blend=config.get('blend', 0.5),
blend_mode=config.get('blend_mode', 'auto'),
magnitude_scaling=config.get('magnitude_scaling', 'none'),
max_scaling_factor=config.get('max_scaling_factor', 10.0),
batch_size=batch_size,
streaming=config.get('streaming', True),
energy_preservation=config.get('energy_preservation', True),
balancing_mode=config.get('balancing_mode', 'disabled'),
weight_unet=config.get('weight_unet', 1.0),
weight_clip=config.get('weight_clip', 1.0),
weight_vae=config.get('weight_vae', 1.0),
weight_te=config.get('weight_te', 1.0),
mappings=batch_mappings,
original_sds=None,
metas=self.metadatas,
on_substep=on_merge_step,
resolved_device=merge_device,
resolved_dtype=self.target_dtype,
sequential_only=self._oom_cpu_fallback,
)
except torch.cuda.OutOfMemoryError:
cleanup_memory()
print(f" ⚠️ CUDA OOM on batch {batch_num} — falling back to CPU for remaining tensors")
# All subsequent batches will use CPU too
self._oom_cpu_fallback = True
merge_device = torch.device("cpu")
# ── Rebuild batch_sds from source files ────────────────────────
# The first (GPU) call to merge_triple_method partially consumed
# batch_sds via sd.pop() (an intentional GPU-memory optimization).
# We cannot reuse the partially-consumed dicts — instead, re-read
# all tensors from the original source files directly onto CPU.
print(f" 🔄 Rebuilding batch {batch_num} tensors on CPU from source files...")
batch_sds = [{} for _ in range(len(self.source_paths))]
batch_mappings = [{} for _ in range(len(self.source_paths))]
for item in batch_items:
norm_key = item['norm_key']
orig_keys = item['orig_keys']
source_indices = item['sources']
for idx in source_indices:
orig_key = orig_keys[idx]
tensor = batch_sources[idx][orig_key]
target_torch_dtype = self.DTYPE_MAP[item['dtype']][0]
# ── FP8 dequant (same logic as lines 815-837) ──
if tensor.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
# Dequant FP8 → float32 using companion scale factors
tensor = dequant_fp8_tensor(tensor, orig_key, torch.float32, batch_sources[idx])
# ── Dtype conversion (same logic as lines 839-848) ──
if target_torch_dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
compute_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
if tensor.dtype != compute_dtype:
tensor = tensor.to(dtype=compute_dtype)
elif tensor.dtype != target_torch_dtype:
tensor = tensor.to(dtype=target_torch_dtype)
# Always CPU for the retry
tensor = tensor.to(device=torch.device("cpu"))
batch_sds[idx][norm_key] = tensor
batch_mappings[idx][norm_key] = orig_key
merged_dict, batch_corrupted = merge_triple_method(
sds=batch_sds,
weights=weights,
method=config.get('method', 'linear'),
density=config.get('density', 1.0),
uniqueness=config.get('uniqueness', 0.7),
threshold=config.get('threshold', 0.0),
blend=config.get('blend', 0.5),
blend_mode=config.get('blend_mode', 'auto'),
magnitude_scaling=config.get('magnitude_scaling', 'none'),
max_scaling_factor=config.get('max_scaling_factor', 10.0),
batch_size=batch_size,
streaming=config.get('streaming', True),
energy_preservation=config.get('energy_preservation', True),
balancing_mode=config.get('balancing_mode', 'disabled'),
weight_unet=config.get('weight_unet', 1.0),
weight_clip=config.get('weight_clip', 1.0),
weight_vae=config.get('weight_vae', 1.0),
weight_te=config.get('weight_te', 1.0),
mappings=batch_mappings,
original_sds=None,
metas=self.metadatas,
on_substep=on_merge_step,
resolved_device=merge_device,
resolved_dtype=self.target_dtype,
sequential_only=True,
)
print(f" 🕐 [t={time.time()-_weave_t0:.1f}s] weave batch {batch_num}/{num_batches}: merged {len(batch_items)} tensors (merge took {time.time()-_batch_t0:.1f}s)")
if batch_corrupted:
if not hasattr(self, 'corrupted_keys'):
self.corrupted_keys = []
self.corrupted_keys.extend(batch_corrupted)
if _WEAVER_DEBUG:
# ── DIAG: batch items check ──
print(f" [DIAG:INNER] Batch {batch_num}: len(batch_items)={len(batch_items)}, "
f"len(merged_dict)={len(merged_dict)}, "
f"batch_items[0]='{batch_items[0]['norm_key'] if batch_items else 'EMPTY'}', "
f"pos_before={pos_before}, data_start={data_start}")
sys.stdout.flush()
# ── Direct Dict Mode: store tensors directly ──
if self.use_dict:
for idx, item in enumerate(batch_items):
norm_key = item['norm_key']
merged_tensor = merged_dict.get(norm_key)
if merged_tensor is None:
if hasattr(self, 'corrupted_keys') and norm_key in self.corrupted_keys:
shape = tuple(item['shape'])
dtype_str = item['dtype']
target_dtype = self.DTYPE_MAP[dtype_str][0]
merged_tensor = torch.zeros(shape, dtype=target_dtype)
print(f" ⚠️ Zero tensor for corrupted key '{norm_key}' ({shape})")
else:
raise RuntimeError(f"Missing merged tensor for key {norm_key}")
# Ensure tensor is contiguous and move to CPU FIRST
# (before dtype conversion, so FP8 quantization runs on CPU
# and doesn't spike GPU memory with F32 intermediates)
if not merged_tensor.is_contiguous():
merged_tensor = merged_tensor.contiguous()
merged_tensor_cpu = merged_tensor if merged_tensor.device.type == 'cpu' else merged_tensor.cpu()
# ── Convert to target dtype with proper FP8 quantization ──
target_dtype_str = item['dtype']
target_torch_dtype = self.DTYPE_MAP[target_dtype_str][0]
if merged_tensor_cpu.dtype != target_torch_dtype:
if target_torch_dtype in (torch.float8_e4m3fn, torch.float8_e5m2) \
and norm_key.endswith('.weight') and not should_preserve_bf16(norm_key):
# Proper scale-then-cast for FP8 output weights (on CPU)
q, wscale, iscale = quantize_weight_to_fp8_with_scales(
merged_tensor_cpu, target_torch_dtype
)
merged_tensor_cpu = q
# Store companion scales for writing later
self._companion_scales[norm_key] = (wscale, iscale)
elif target_torch_dtype in (torch.float8_e4m3fn, torch.float8_e5m2) \
and should_preserve_bf16(norm_key):
merged_tensor_cpu = merged_tensor_cpu.to(dtype=torch.bfloat16)
else:
merged_tensor_cpu = merged_tensor_cpu.to(dtype=target_torch_dtype)
# ── Phase 2: skip tensors for zero-weight components ──
if self._omitted_components:
component = categorize_checkpoint_key(norm_key)
if component in self._omitted_components:
if _WEAVER_DEBUG:
print(f" ⏭️ Omitting '{norm_key}' (component={component}, weight=0)")
continue
self._output_dict[norm_key] = merged_tensor_cpu
# ── Standard Mode: write to BytesIO or file ──
else:
for idx, item in enumerate(batch_items):
norm_key = item['norm_key']
merged_tensor = merged_dict.get(norm_key)
# ── DIAG: print first item of each batch ──
if idx == 0:
present = "YES" if merged_tensor is not None else "NO"
if _WEAVER_DEBUG:
print(f" [DIAG:ITEM] Batch {batch_num}[0]: norm_key='{norm_key}', "
f"in_merged_dict={present}, "
f"merged_dict keys count={len(merged_dict)}")
sys.stdout.flush()
if merged_tensor is None:
# Check if this key was skipped due to corruption
if hasattr(self, 'corrupted_keys') and norm_key in self.corrupted_keys:
# Write zeros of expected shape as fallback
shape = tuple(item['shape'])
dtype_str = item['dtype']
target_dtype = self.DTYPE_MAP[dtype_str][0]
fallback = torch.zeros(shape, dtype=target_dtype)
print(f" ⚠️ Writing zero tensor for corrupted key '{norm_key}' ({shape})")
merged_tensor = fallback
merged_dict[norm_key] = fallback
else:
# ── DIAG: dump batch context before failing ──
print(f" [DIAG] ⚠️ Key '{norm_key}' NOT in merged_dict ({len(merged_dict)} keys)")
print(f" [DIAG] ⚠️ First 10 merged_dict keys: {list(merged_dict.keys())[:10]}")
print(f" [DIAG] ⚠️ batch_items count={len(batch_items)}, batch_sds[0] keys={len(batch_sds[0])}")
raise RuntimeError(f"Missing merged tensor for key {norm_key}")
# Ensure tensor is contiguous and move to CPU FIRST
# (before dtype conversion, so FP8 quantization runs on CPU
# and doesn't spike GPU memory with F32 intermediates)
if not merged_tensor.is_contiguous():
merged_tensor = merged_tensor.contiguous()
merged_tensor_cpu = merged_tensor if merged_tensor.device.type == 'cpu' else merged_tensor.cpu()
# ── Convert to target dtype with proper FP8 quantization ──
target_dtype_str = item['dtype']
target_torch_dtype = self.DTYPE_MAP[target_dtype_str][0]
if merged_tensor_cpu.dtype != target_torch_dtype:
if target_torch_dtype in (torch.float8_e4m3fn, torch.float8_e5m2) \
and norm_key.endswith('.weight') and not should_preserve_bf16(norm_key):
# Proper scale-then-cast for FP8 output weights (on CPU)
q, wscale, iscale = quantize_weight_to_fp8_with_scales(
merged_tensor_cpu, target_torch_dtype
)
merged_tensor_cpu = q
# Store companion scales for writing later
self._companion_scales[norm_key] = (wscale, iscale)
elif target_torch_dtype in (torch.float8_e4m3fn, torch.float8_e5m2) \
and should_preserve_bf16(norm_key):
merged_tensor_cpu = merged_tensor_cpu.to(dtype=torch.bfloat16)
else:
merged_tensor_cpu = merged_tensor_cpu.to(dtype=target_torch_dtype)
# Verify tensor size matches survey expectation
expected_size = item['size'] # bytes from survey
actual_size = merged_tensor_cpu.numel() * merged_tensor_cpu.element_size()
if expected_size != actual_size:
print(f"[WARNING] Size mismatch for tensor {norm_key}: expected {expected_size}, actual {actual_size}. This may cause offset drift.")
# Verify first tensor alignment (silent check)
if first_tensor:
if out_f.tell() != data_start:
print(f"[ERROR] File position mismatch! Expected {data_start}, got {out_f.tell()}")
out_f.seek(data_start)
first_tensor = False
# No alignment padding (contiguity required by safetensors)
current_pos = out_f.tell()
# Record start offset relative to data_start
start = current_pos - data_start
# Write tensor data
raw_bytes = self._tensor_to_bytes(merged_tensor_cpu)
out_f.write(raw_bytes)
# Record end offset
end = out_f.tell() - data_start
self.actual_offsets[norm_key] = (start, end)
# Verify the tensor actually produced data
if start == end:
print(f" ⚠️ Zero-length tensor written for key '{norm_key}': "
f"shape={list(item['shape'])}, dtype={item['dtype']}, "
f"tensor.numel()={merged_tensor_cpu.numel()}, "
f"elem_size={merged_tensor_cpu.element_size()}")
if _WEAVER_DEBUG and len(self.actual_offsets) <= 5:
# ── DIAG: dump first 5 tensor offsets ──
print(f" [DIAG] Written: norm_key='{norm_key}', "
f"start={start}, end={end}, "
f"raw_bytes_len={len(raw_bytes)}, "
f"numel={merged_tensor_cpu.numel()}, "
f"elem_size={merged_tensor_cpu.element_size()}, "
f"shape={list(merged_tensor_cpu.shape)}, "
f"dtype={merged_tensor_cpu.dtype}")
if _WEAVER_DEBUG:
# ── DIAG: after batch write loop ──
print(f" [DIAG:AFTER] Batch {batch_num}: len(self.actual_offsets)={len(self.actual_offsets)}, "
f"pos after write={out_f.tell()}, "
f"rel_to_data={out_f.tell() - data_start}")
if self.actual_offsets:
first_key = next(iter(self.actual_offsets))
print(f" [DIAG:AFTER] first entry: '{first_key}' = {self.actual_offsets[first_key]}")
sys.stdout.flush()
# Batch-level summary (unconditional - removed `if 'end' in dir()` to ensure it always prints)
# Use actual_offsets count as reliable indicator
if not self.use_dict:
last_offset = list(self.actual_offsets.values())[-1][1] if self.actual_offsets else 0
print(f" [WEAVE] Batch {batch_num}/{num_batches}: "
f"tensors {batch_start}..{batch_end-1}, "
f"data offset={last_offset} bytes, "
f"tensors_in_batch={len(batch_items)}, "
f"total_recorded={len(self.actual_offsets)}")
else:
print(f" [WEAVE] Batch {batch_num}/{num_batches}: "
f"tensors {batch_start}..{batch_end-1}, "
f"tensors_in_batch={len(batch_items)}, "
f"dict_keys={len(self._output_dict)}")
# ── Log available RAM every batch for memory profiling ──
_avail = get_available_ram()
if _avail is not None:
print(f" 📊 RAM: {_avail / (1024**3):.2f} GB available")
# Clean up batch references — GPU tensors no longer accumulate
# at batch level (tensors stay on CPU; per-key GPU transfer is
# handled by merge_triple_method inside checkpoint_methods.py).
del batch_sds
del batch_mappings
del merged_dict
cleanup_memory() # gc.collect() + torch.cuda.empty_cache()
# pbar is advanced by merge_triple_method via on_substep callback.
# No advancement at weave level — that would double-count.
# Heartbeat guard: yield every batch to prevent connection drops
comfyui_yield()
finally:
for bs in batch_sources:
bs.close()
# ── Write companion scale tensors (after all weight batches) ──
# Companion scales are scalar FP32 tensors generated during FP8 quantization
# of parent weight tensors. They were partitioned into self.companion_list
# during survey() and are now written here from self._companion_scales.
if self.companion_list:
if self.use_dict:
for item in self.companion_list:
norm_key = item['norm_key']
parent_norm_key = item.get('_parent_norm_key')
if parent_norm_key and parent_norm_key in self._companion_scales:
scale_idx = 0 if item.get('_scale_type') == 'weight_scale' else 1
tensor = self._companion_scales[parent_norm_key][scale_idx]
self._output_dict[norm_key] = tensor
else:
print(f" [WEAVE] Writing {len(self.companion_list)} companion scale tensors...")
for item in self.companion_list:
norm_key = item['norm_key']
parent_norm_key = item.get('_parent_norm_key')
if parent_norm_key and parent_norm_key in self._companion_scales:
scale_idx = 0 if item.get('_scale_type') == 'weight_scale' else 1
tensor = self._companion_scales[parent_norm_key][scale_idx]
else:
raise RuntimeError(
f"Missing companion scale for '{norm_key}' "
f"(parent='{parent_norm_key}')"
)
# Tensor is already F32 CPU scalar — just write it as raw bytes
expected_size = item['size']
raw_bytes = self._tensor_to_bytes(tensor)
current_pos = out_f.tell()
start = current_pos - data_start
out_f.write(raw_bytes)
end = out_f.tell() - data_start
self.actual_offsets[norm_key] = (start, end)
# No internal tracker to clean up — on_substep handles all advancement.
print(f" 🕐 [t={time.time()-_weave_t0:.1f}s] weave: all {num_batches} batches complete")
# Post-loop final GC sweep
cleanup_memory()
# ── Dict mode: skip safetensors header finalization ──
if not self.use_dict:
# All tensors written – now build final header with ground‑truth offsets
print("[FINALIZE] Building output header...")
final_header = {}
added_keys = set()
zero_offset_count = 0
fallback_count = 0
if _WEAVER_DEBUG:
# ── DIAG: dump first 5 actual_offsets entries ──
print(" [DIAG] self.actual_offsets first 5 entries:")
diag_count = 0
for nk, (s, e) in self.actual_offsets.items():
if diag_count >= 5:
break
print(f" [DIAG] self.actual_offsets['{nk}'] = ({s}, {e})")
diag_count += 1
print(f" [DIAG] total self.actual_offsets entries: {len(self.actual_offsets)}")
# ── FIX: compute cumulative offsets as fallback ──
# The weave writes tensors sequentially in tensor_list order starting at data_start.
# If actual_offsets entries are missing (e.g. due to scoping/GC issues between batches),
# we compute the expected offset from the cumulative size of preceding tensors.
cumulative_offset = 0
for i, item in enumerate(self.tensor_list):
norm_key = item['norm_key']
key = item['key']
# Primary: use actual_offsets if available
entry = self.actual_offsets.get(norm_key)
if entry is not None:
start, end = entry
else:
# Fallback: compute from cumulative position
start = cumulative_offset
end = cumulative_offset + item['size']
fallback_count += 1
# ── WARN: fallback offset used (offset tracking gap) ──
if fallback_count <= 10:
print(f" [WARNING] Offset for '{norm_key}' not in actual_offsets. "
f"Using cumulative: ({start}, {end})")
if _WEAVER_DEBUG and i < 5:
# ── DIAG: first 5 tensor_list entries ──
print(f" [DIAG] tensor_list[{i}]: norm_key='{norm_key}', key='{key}', "
f"offsets=({start}, {end})")
# Detect zero-length tensors (data corruption) early
if start == end:
zero_offset_count += 1
print(f" ⚠️ Zero-length tensor detected in final header: "
f"key='{key}', norm_key='{norm_key}', "
f"shape={list(item['shape'])}, dtype={item['dtype']}, "
f"offsets=[{start}, {end}]")
else:
# Verify consistency if actual_offsets was available
if entry is not None and start != cumulative_offset:
print(f" [WARNING] Offset mismatch for '{norm_key}': "
f"actual_offsets says {start}, expected {cumulative_offset} "
f"(diff={start - cumulative_offset} bytes)")
# Advance cumulative offset for next iteration
cumulative_offset = end
# Add primary key (original key from first source) — canonical form only
if key not in added_keys:
final_header[key] = {
"dtype": item['dtype'],
"shape": list(item['shape']),
"data_offsets": [start, end]
}
added_keys.add(key)
# ── Add companion scale tensors to final header ──
for item in self.companion_list:
norm_key = item['norm_key']
key = item['key']
entry = self.actual_offsets.get(norm_key)
if entry is not None:
start, end = entry
else:
# Fallback: compute from cumulative position
start = cumulative_offset
end = cumulative_offset + item['size']
fallback_count += 1
if fallback_count <= 10:
print(f" [WARNING] Offset for companion '{norm_key}' not in actual_offsets. "
f"Using cumulative: ({start}, {end})")
cumulative_offset = end
if key not in added_keys:
final_header[key] = {
"dtype": item['dtype'],
"shape": list(item['shape']),
"data_offsets": [start, end]
}
added_keys.add(key)
if fallback_count:
print(f" [FALLBACK] {fallback_count} tensor(s) used cumulative fallback offset "
f"(missing from actual_offsets, likely batches 1-{max(1, fallback_count // 64)})")
if zero_offset_count:
print(f" ⚠️ WARNING: {zero_offset_count} tensor(s) have zero-length data offsets. "
f"This will cause 'ValueError: buffer length (0)' when parsing the output.")
# Add merged metadata
if self.merged_metadata:
final_header['__metadata__'] = self.merged_metadata
# Summary
print(f"[FINALIZE] Header built: {len(final_header)} tensors, "
f"{len(self.merged_metadata)} metadata keys")
# Compute final header JSON and pad to placeholder size (self.header_len_padded)
header_json = json.dumps(final_header, separators=(',', ':'))
header_len = len(header_json)
# Ensure final header fits within the placeholder size
if header_len > self.header_len_padded:
raise RuntimeError(
f"Final header length ({header_len}) exceeds placeholder size ({self.header_len_padded}). "
"Increase safety margin in survey()."
)
# Pad with spaces to exactly placeholder length (which is already 8‑byte aligned)
pad_len = self.header_len_padded - header_len
print(f"[FINALIZE] Header size = {header_len} bytes (padded to {self.header_len_padded})")
header_json_padded = header_json + ' ' * pad_len
# Verify length matches
assert len(header_json_padded) == self.header_len_padded, f"Padded header length mismatch: {len(header_json_padded)} vs {self.header_len_padded}"
# Seek to start of file and overwrite header length and JSON
out_f.seek(0)
out_f.write(self.header_len_padded.to_bytes(8, 'little'))
out_f.write(header_json_padded.encode('utf-8'))
# Verify file position after writing header matches data_start
if out_f.tell() != self.data_start:
print(f"[WARNING] File position after final header ({out_f.tell()}) does not match data_start ({self.data_start}), seeking")
out_f.seek(self.data_start)
out_f.flush()
os.fsync(out_f.fileno())
# Validate the safetensors file after writing
self._validate_safetensors_file(self.output_path)
finally:
if self.use_dict:
# Dict mode: no output stream to finalize
omit_msg = ""
if hasattr(self, '_omitted_components') and self._omitted_components:
omit_msg = f" (omitted {self._omitted_components} zero-weight components)"
print(f"🧠 Direct dict mode completed — {len(self._output_dict)} tensors stored in output dict{omit_msg}")
else:
out_f.close()
print(f"✅ Triple checkpoint merging completed: {self.output_path}")
print(f" 🕐 [t={time.time()-_weave_t0:.1f}s] weave: output finalized")
def _validate_safetensors_file(self, filepath):
"""
Validate the safetensors file after writing:
- Read header length and ensure it matches the computed length.
- Parse JSON header and ensure it's valid.
- Verify that each tensor's data_offsets are within file bounds.
- Verify 8‑byte alignment of each tensor offset.
"""
print(f"[DEBUG VALIDATION] Validating {filepath}")
with open(filepath, 'rb') as f:
# Read header length
header_len_bytes = f.read(8)
if len(header_len_bytes) != 8:
raise RuntimeError(f"File too short: {filepath}")
header_len = int.from_bytes(header_len_bytes, 'little')
# Read header JSON
header_json_bytes = f.read(header_len)
if len(header_json_bytes) != header_len:
raise RuntimeError(f"Header length mismatch: expected {header_len}, got {len(header_json_bytes)}")
# Decode and parse JSON
try:
header = json.loads(header_json_bytes.decode('utf-8'))
except json.JSONDecodeError as e:
raise RuntimeError(f"Invalid JSON in header: {e}")
# Ensure header is a dict
if not isinstance(header, dict):
raise RuntimeError("Header is not a JSON object")
# Remove __metadata__ if present (it's not a tensor)
tensor_headers = {k: v for k, v in header.items() if k != '__metadata__'}
# Compute data start offset
data_start = 8 + header_len
# Get file size
f.seek(0, 2) # seek to end
file_size = f.tell()
for key, info in tensor_headers.items():
if 'data_offsets' not in info:
raise RuntimeError(f"Tensor {key} missing data_offsets")
start, end = info['data_offsets']
# Verify offsets are relative to data_start
absolute_start = data_start + start
absolute_end = data_start + end
if absolute_start < data_start:
raise RuntimeError(f"Tensor {key} start offset is negative relative")
if absolute_end > file_size:
raise RuntimeError(f"Tensor {key} ends beyond file size: {absolute_end} > {file_size}")
# Verify 8‑byte alignment (warning only, source checkpoints may be misaligned)
# (alignment check intentionally omitted — source checkpoints may be misaligned)
# Additional validation using safe_open to ensure tensors can be loaded
with safe_open(filepath, framework="pt") as sf:
for key in tensor_headers.keys():
try:
# This will raise if the tensor cannot be accessed
slice_info = sf.get_slice(key)
# Optionally verify dtype and shape match header
# (skip for speed)
except Exception as e:
raise RuntimeError(f"Safe‑open validation failed for key {key}: {e}")
# Success
print(f"[DEBUG VALIDATION] All tensors aligned correctly and pass safe_open validation")
return True
def get_output_dict(self) -> Dict[str, torch.Tensor]:
"""
Return the output dict containing merged tensors.
Only valid when use_dict=True was passed to __init__.
Returns:
Dict[str, torch.Tensor]: Merged tensors keyed by normalized key names.
"""
if not self.use_dict:
raise RuntimeError("get_output_dict() is only valid when use_dict=True was passed to __init__")
if self._output_dict is None:
raise RuntimeError("Output dict not available – weave() may not have been called yet")
return self._output_dict
def generate_forensic_report(self) -> str:
"""
Generate a standardized forensic audit report.
"""
mc = self.merge_config
method = mc.get("method", "unknown")
weights = mc.get("weights", [])
blend_mode = mc.get("blend_mode", "auto")
balancing_mode = mc.get("balancing_mode", "disabled")
density = mc.get("density", 1.0)
w_str = ", ".join(f"{w:.2f}" for w in weights) if weights else "N/A"
# Build component data for component-breakdown lines
comp_lines = []
for comp in ["unet", "clip", "vae", "te", "other"]:
count = self.component_counts[comp]
params = self.parameter_counts[comp]
if count > 0:
comp_lines.append(
f" {comp.upper():<6} {count:>4} tensors, {params:>12,} parameters"
)
# Build source lines — show "(in-memory)" for dict sources
source_lines = []
for i, p in enumerate(self.source_paths):
if self.source_dicts[i] is not None:
source_lines.append(f" {i + 1}. (in-memory dict) — {len(self.source_dicts[i])} tensors")
else:
source_lines.append(f" {i + 1}. {p.name}")
# Build merge-settings lines
settings_keys = ["method", "weights", "density", "uniqueness", "threshold", "blend",
"blend_mode", "magnitude_scaling", "streaming", "balancing_mode",
"weight_unet", "weight_clip", "weight_vae", "weight_te",
"precision", "device"]
settings_lines = [f" {k}: {mc[k]}" for k in settings_keys if k in mc]
# Build ordered sections
sections = []
sections.append((None, [f"🏗️ ARCHITECTURE: {self.architecture}"]))
sections.append((None, [f"📊 TOTAL PARAMETERS: {self.total_parameters:,}"]))
sections.append((None, [""])) # blank line before component breakdown
sections.append(("COMPONENT BREAKDOWN", comp_lines))
sections.append((None, [""])) # blank line after component breakdown
sections.append(("SOURCES", source_lines))
sections.append((None, [""])) # blank line after sources
# ── Corrupted/Skipped tensors ──
corrupted = getattr(self, 'corrupted_keys', None)
if corrupted:
corr_lines = [f" ⚠️ {ck}" for ck in corrupted]
sections.append(("CORRUPTED / INVALID SHAPE TENSORS (skipped, zero-filled)", corr_lines))
sections.append((None, [""]))
sections.append(("MERGE SETTINGS", settings_lines))
sections.append((None, [f"📅 DATE: {time.strftime('%Y-%m-%d %H:%M:%S')}"]))
return build_forensic_report(
report_type="EASY CHECKPOINT MERGER",
title_data={
"📦 METHOD": method,
"📋 SUMMARY": (
f"{method} | {len(self.source_paths)} checkpoints [{w_str}] "
f"| blend={blend_mode} | balance={balancing_mode} | density={density}"
),
},
sections=sections,
footer_width=60,
)