ariaotp commited on
Commit
9c16dc5
·
verified ·
1 Parent(s): 9cd7ccf

Upload convert_int4_tensorwise_to_convrot_w4a4.py

Browse files
convert_int4_tensorwise_to_convrot_w4a4.py ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Migrate legacy ComfyUI int4_tensorwise+ConvRot safetensors metadata.
3
+
4
+ This converter does NOT dequantize or requantize model weights. It rewrites:
5
+ * safetensors __metadata__["_quantization_metadata"]
6
+ * persisted <layer>.comfy_quant JSON tensors, when present
7
+ * legacy [N, 1] weight_scale header shapes to the official [N] shape
8
+
9
+ The packed INT4 weight payload and FP32 scale bytes are copied unchanged.
10
+ Only legacy layers explicitly marked ConvRot are converted unless
11
+ --assume-convrot is supplied.
12
+
13
+ No third-party Python packages are required.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import copy
20
+ import json
21
+ import math
22
+ import os
23
+ import shutil
24
+ import struct
25
+ import sys
26
+ import tempfile
27
+ from collections import OrderedDict
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, BinaryIO, Iterable, Mapping, MutableMapping
31
+
32
+ OLD_FORMAT = "int4_tensorwise"
33
+ NEW_FORMAT = "convrot_w4a4"
34
+ DEFAULT_CONVROT_GROUPSIZE = 256
35
+ OFFICIAL_QUANT_GROUP_SIZE = 64
36
+ DEFAULT_LINEAR_DTYPE = "int4"
37
+ HEADER_LEN_STRUCT = struct.Struct("<Q")
38
+ COPY_CHUNK_SIZE = 64 * 1024 * 1024
39
+
40
+
41
+ class ConversionError(RuntimeError):
42
+ """Raised when a checkpoint cannot be converted safely."""
43
+
44
+
45
+ @dataclass
46
+ class Report:
47
+ converted_layers: set[str] = field(default_factory=set)
48
+ converted_metadata_layers: set[str] = field(default_factory=set)
49
+ converted_comfy_quant_tensors: set[str] = field(default_factory=set)
50
+ normalized_scale_tensors: set[str] = field(default_factory=set)
51
+ warnings: list[str] = field(default_factory=list)
52
+
53
+ @property
54
+ def converted_count(self) -> int:
55
+ return len(self.converted_layers)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class TensorEntry:
60
+ name: str
61
+ info: dict[str, Any]
62
+ start: int
63
+ end: int
64
+
65
+ @property
66
+ def size(self) -> int:
67
+ return self.end - self.start
68
+
69
+
70
+ def _json_dumps(value: Any) -> str:
71
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
72
+
73
+
74
+ def _read_exact(handle: BinaryIO, size: int) -> bytes:
75
+ data = handle.read(size)
76
+ if len(data) != size:
77
+ raise ConversionError(f"Unexpected EOF: wanted {size} bytes, got {len(data)}")
78
+ return data
79
+
80
+
81
+ def read_safetensors_header(path: Path) -> tuple[OrderedDict[str, Any], int, int]:
82
+ file_size = path.stat().st_size
83
+ if file_size < HEADER_LEN_STRUCT.size:
84
+ raise ConversionError("File is too small to be a safetensors file")
85
+
86
+ with path.open("rb") as handle:
87
+ header_len = HEADER_LEN_STRUCT.unpack(_read_exact(handle, HEADER_LEN_STRUCT.size))[0]
88
+ data_start = HEADER_LEN_STRUCT.size + header_len
89
+ if header_len <= 1 or data_start > file_size:
90
+ raise ConversionError(
91
+ f"Invalid safetensors header length {header_len} for file size {file_size}"
92
+ )
93
+ raw_header = _read_exact(handle, header_len)
94
+
95
+ try:
96
+ decoded = raw_header.rstrip(b" \t\r\n\x00").decode("utf-8")
97
+ header = json.loads(decoded, object_pairs_hook=OrderedDict)
98
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
99
+ raise ConversionError(f"Invalid safetensors JSON header: {exc}") from exc
100
+
101
+ if not isinstance(header, OrderedDict):
102
+ raise ConversionError("Safetensors header root is not a JSON object")
103
+ return header, data_start, file_size
104
+
105
+
106
+ def collect_tensor_entries(
107
+ header: Mapping[str, Any], data_size: int
108
+ ) -> list[TensorEntry]:
109
+ entries: list[TensorEntry] = []
110
+ for name, info in header.items():
111
+ if name == "__metadata__":
112
+ continue
113
+ if not isinstance(info, dict):
114
+ raise ConversionError(f"Tensor header for {name!r} is not an object")
115
+ offsets = info.get("data_offsets")
116
+ if (
117
+ not isinstance(offsets, list)
118
+ or len(offsets) != 2
119
+ or not all(isinstance(v, int) for v in offsets)
120
+ ):
121
+ raise ConversionError(f"Tensor {name!r} has invalid data_offsets: {offsets!r}")
122
+ start, end = offsets
123
+ if start < 0 or end < start or end > data_size:
124
+ raise ConversionError(
125
+ f"Tensor {name!r} has out-of-range offsets [{start}, {end}] "
126
+ f"for data section size {data_size}"
127
+ )
128
+ entries.append(TensorEntry(name=name, info=dict(info), start=start, end=end))
129
+
130
+ entries.sort(key=lambda item: (item.start, item.end, item.name))
131
+ previous_end = 0
132
+ for entry in entries:
133
+ if entry.start < previous_end:
134
+ raise ConversionError(f"Overlapping tensor data near {entry.name!r}")
135
+ previous_end = entry.end
136
+ return entries
137
+
138
+
139
+ def _is_power_of_four(value: int) -> bool:
140
+ if value < 4 or value & (value - 1):
141
+ return False
142
+ exponent = int(math.log2(value))
143
+ return exponent % 2 == 0
144
+
145
+
146
+ def _first_present(configs: Iterable[Mapping[str, Any]], key: str) -> Any:
147
+ for config in configs:
148
+ if key in config:
149
+ return config[key]
150
+ return None
151
+
152
+
153
+ def convert_layer_config(
154
+ config: Any,
155
+ *,
156
+ layer_name: str,
157
+ assume_convrot: bool,
158
+ linear_dtype: str,
159
+ report: Report,
160
+ ) -> tuple[Any, bool]:
161
+ """Convert one layer's quantization config, preserving unrelated fields."""
162
+ if isinstance(config, str):
163
+ if config != OLD_FORMAT:
164
+ return config, False
165
+ if not assume_convrot:
166
+ raise ConversionError(
167
+ f"Layer {layer_name!r} uses {OLD_FORMAT!r} but has no ConvRot marker. "
168
+ "Refusing to relabel it; use --assume-convrot only when you know it was rotated."
169
+ )
170
+ new_config: dict[str, Any] = {
171
+ "format": NEW_FORMAT,
172
+ "params": {
173
+ "convrot_groupsize": DEFAULT_CONVROT_GROUPSIZE,
174
+ "quant_group_size": OFFICIAL_QUANT_GROUP_SIZE,
175
+ "linear_dtype": linear_dtype,
176
+ },
177
+ }
178
+ report.converted_layers.add(layer_name)
179
+ return new_config, True
180
+
181
+ if not isinstance(config, dict):
182
+ return config, False
183
+ if config.get("format") != OLD_FORMAT:
184
+ return config, False
185
+
186
+ params = config.get("params")
187
+ if params is None:
188
+ params = {}
189
+ elif not isinstance(params, dict):
190
+ raise ConversionError(
191
+ f"Layer {layer_name!r} has a non-object params field: {params!r}"
192
+ )
193
+
194
+ sources = (config, params)
195
+ explicit_convrot = _first_present(sources, "convrot")
196
+ group_size_value = _first_present(sources, "convrot_groupsize")
197
+ inferred_convrot = group_size_value is not None
198
+
199
+ if explicit_convrot is False:
200
+ raise ConversionError(
201
+ f"Layer {layer_name!r} explicitly has convrot=false. It cannot be safely "
202
+ f"converted from {OLD_FORMAT!r} to {NEW_FORMAT!r}."
203
+ )
204
+ if explicit_convrot is not True and not inferred_convrot and not assume_convrot:
205
+ raise ConversionError(
206
+ f"Layer {layer_name!r} uses {OLD_FORMAT!r} but is not marked ConvRot. "
207
+ "Use --assume-convrot only if the checkpoint is known to be W4A4 ConvRot."
208
+ )
209
+
210
+ convrot_groupsize = (
211
+ DEFAULT_CONVROT_GROUPSIZE if group_size_value is None else group_size_value
212
+ )
213
+ if not isinstance(convrot_groupsize, int) or not _is_power_of_four(convrot_groupsize):
214
+ raise ConversionError(
215
+ f"Layer {layer_name!r} has invalid convrot_groupsize={convrot_groupsize!r}; "
216
+ "the official regular Hadamard path requires a power of 4 (4, 16, 64, 256, ...)."
217
+ )
218
+
219
+ old_quant_group_size = _first_present(sources, "quant_group_size")
220
+ if old_quant_group_size not in (None, OFFICIAL_QUANT_GROUP_SIZE):
221
+ raise ConversionError(
222
+ f"Layer {layer_name!r} requests quant_group_size={old_quant_group_size!r}, "
223
+ f"but the official INT4 MMA contract requires {OFFICIAL_QUANT_GROUP_SIZE}."
224
+ )
225
+
226
+ new_config = copy.deepcopy(config)
227
+ new_config["format"] = NEW_FORMAT
228
+
229
+ # Normalize recipe options under `params`, which is the generic modern loader path.
230
+ new_params = copy.deepcopy(params)
231
+ for legacy_key in ("convrot", "per_channel", "is_weight"):
232
+ new_params.pop(legacy_key, None)
233
+ new_params["convrot_groupsize"] = convrot_groupsize
234
+ new_params["quant_group_size"] = OFFICIAL_QUANT_GROUP_SIZE
235
+ new_params["linear_dtype"] = linear_dtype
236
+ new_config["params"] = new_params
237
+
238
+ # Remove the legacy top-level knobs after moving their meaningful value to params.
239
+ for legacy_key in (
240
+ "convrot",
241
+ "convrot_groupsize",
242
+ "quant_group_size",
243
+ "linear_dtype",
244
+ "per_channel",
245
+ "is_weight",
246
+ ):
247
+ new_config.pop(legacy_key, None)
248
+
249
+ report.converted_layers.add(layer_name)
250
+ return new_config, True
251
+
252
+
253
+ def convert_quantization_metadata_object(
254
+ value: Any,
255
+ *,
256
+ assume_convrot: bool,
257
+ linear_dtype: str,
258
+ report: Report,
259
+ ) -> tuple[Any, bool]:
260
+ if not isinstance(value, dict):
261
+ raise ConversionError("_quantization_metadata JSON must be an object")
262
+
263
+ root = copy.deepcopy(value)
264
+ target = root.get("_quantization_metadata", root)
265
+ if not isinstance(target, dict):
266
+ raise ConversionError("Nested _quantization_metadata must be an object")
267
+
268
+ layers = target.get("layers")
269
+ if layers is None:
270
+ return root, False
271
+ if not isinstance(layers, dict):
272
+ raise ConversionError("_quantization_metadata.layers must be an object")
273
+
274
+ changed = False
275
+ for layer_name, layer_config in list(layers.items()):
276
+ new_config, layer_changed = convert_layer_config(
277
+ layer_config,
278
+ layer_name=layer_name,
279
+ assume_convrot=assume_convrot,
280
+ linear_dtype=linear_dtype,
281
+ report=report,
282
+ )
283
+ if layer_changed:
284
+ layers[layer_name] = new_config
285
+ report.converted_metadata_layers.add(layer_name)
286
+ changed = True
287
+ return root, changed
288
+
289
+
290
+ def convert_file_metadata(
291
+ header: MutableMapping[str, Any],
292
+ *,
293
+ assume_convrot: bool,
294
+ linear_dtype: str,
295
+ report: Report,
296
+ ) -> bool:
297
+ metadata = header.get("__metadata__")
298
+ if metadata is None:
299
+ return False
300
+ if not isinstance(metadata, dict):
301
+ raise ConversionError("Safetensors __metadata__ must be an object")
302
+
303
+ raw = metadata.get("_quantization_metadata")
304
+ if raw is None:
305
+ return False
306
+ if not isinstance(raw, str):
307
+ raise ConversionError(
308
+ "Safetensors metadata values must be strings; _quantization_metadata is not a string"
309
+ )
310
+ try:
311
+ parsed = json.loads(raw)
312
+ except json.JSONDecodeError as exc:
313
+ raise ConversionError(f"Invalid _quantization_metadata JSON: {exc}") from exc
314
+
315
+ converted, changed = convert_quantization_metadata_object(
316
+ parsed,
317
+ assume_convrot=assume_convrot,
318
+ linear_dtype=linear_dtype,
319
+ report=report,
320
+ )
321
+ if changed:
322
+ metadata["_quantization_metadata"] = _json_dumps(converted)
323
+ return changed
324
+
325
+
326
+ def read_tensor_bytes(handle: BinaryIO, data_start: int, entry: TensorEntry) -> bytes:
327
+ handle.seek(data_start + entry.start)
328
+ return _read_exact(handle, entry.size)
329
+
330
+
331
+ def convert_comfy_quant_payload(
332
+ raw: bytes,
333
+ *,
334
+ tensor_name: str,
335
+ assume_convrot: bool,
336
+ linear_dtype: str,
337
+ report: Report,
338
+ ) -> tuple[bytes, bool]:
339
+ try:
340
+ text = raw.rstrip(b"\x00").decode("utf-8")
341
+ parsed = json.loads(text)
342
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
343
+ raise ConversionError(f"Invalid JSON in tensor {tensor_name!r}: {exc}") from exc
344
+
345
+ layer_name = tensor_name[: -len(".comfy_quant")]
346
+ converted, changed = convert_layer_config(
347
+ parsed,
348
+ layer_name=layer_name,
349
+ assume_convrot=assume_convrot,
350
+ linear_dtype=linear_dtype,
351
+ report=report,
352
+ )
353
+ if not changed:
354
+ return raw, False
355
+ report.converted_comfy_quant_tensors.add(tensor_name)
356
+ return _json_dumps(converted).encode("utf-8"), True
357
+
358
+
359
+ def _numel(shape: Any) -> int | None:
360
+ if not isinstance(shape, list) or not all(isinstance(v, int) and v >= 0 for v in shape):
361
+ return None
362
+ result = 1
363
+ for dim in shape:
364
+ result *= dim
365
+ return result
366
+
367
+
368
+ def _find_matching_layer(tensor_name: str, layers: set[str]) -> str | None:
369
+ suffix = ".weight_scale"
370
+ if not tensor_name.endswith(suffix):
371
+ return None
372
+ base = tensor_name[: -len(suffix)]
373
+ if base in layers:
374
+ return base
375
+ # Prefix changes can occur during ComfyUI model loading. Use an unambiguous
376
+ # suffix match only; never guess if multiple metadata layer names fit.
377
+ candidates = [layer for layer in layers if base.endswith(layer) or layer.endswith(base)]
378
+ return candidates[0] if len(candidates) == 1 else None
379
+
380
+
381
+ def normalize_scale_shape(
382
+ tensor_name: str,
383
+ info: MutableMapping[str, Any],
384
+ *,
385
+ converted_layers: set[str],
386
+ keep_scale_shape: bool,
387
+ report: Report,
388
+ ) -> bool:
389
+ if keep_scale_shape or _find_matching_layer(tensor_name, converted_layers) is None:
390
+ return False
391
+ shape = info.get("shape")
392
+ if isinstance(shape, list) and len(shape) == 2 and shape[1] == 1:
393
+ info["shape"] = [shape[0]]
394
+ report.normalized_scale_tensors.add(tensor_name)
395
+ return True
396
+ return False
397
+
398
+
399
+ def validate_converted_layer_tensors(
400
+ tensor_infos: Mapping[str, Mapping[str, Any]],
401
+ *,
402
+ converted_layers: set[str],
403
+ report: Report,
404
+ ) -> None:
405
+ for layer in sorted(converted_layers):
406
+ weight_name = f"{layer}.weight"
407
+ scale_name = f"{layer}.weight_scale"
408
+ weight_info = tensor_infos.get(weight_name)
409
+ scale_info = tensor_infos.get(scale_name)
410
+ if weight_info is None or scale_info is None:
411
+ report.warnings.append(
412
+ f"Could not validate tensors for {layer!r} by exact name "
413
+ f"(expected {weight_name!r} and {scale_name!r}); metadata prefix remapping may be in use."
414
+ )
415
+ continue
416
+
417
+ if weight_info.get("dtype") != "I8":
418
+ raise ConversionError(
419
+ f"{weight_name!r} dtype is {weight_info.get('dtype')!r}, expected I8 packed INT4"
420
+ )
421
+ weight_shape = weight_info.get("shape")
422
+ if not (
423
+ isinstance(weight_shape, list)
424
+ and len(weight_shape) == 2
425
+ and all(isinstance(v, int) and v > 0 for v in weight_shape)
426
+ ):
427
+ raise ConversionError(
428
+ f"{weight_name!r} must have packed 2D shape [N, K/2], got {weight_shape!r}"
429
+ )
430
+ original_k = weight_shape[1] * 2
431
+ if original_k % OFFICIAL_QUANT_GROUP_SIZE != 0:
432
+ raise ConversionError(
433
+ f"{weight_name!r} reconstructs K={original_k}, not divisible by "
434
+ f"official quant_group_size={OFFICIAL_QUANT_GROUP_SIZE}"
435
+ )
436
+
437
+ if scale_info.get("dtype") != "F32":
438
+ raise ConversionError(
439
+ f"{scale_name!r} dtype is {scale_info.get('dtype')!r}, expected F32"
440
+ )
441
+ scale_numel = _numel(scale_info.get("shape"))
442
+ if scale_numel != weight_shape[0]:
443
+ raise ConversionError(
444
+ f"{scale_name!r} has {scale_numel} elements, expected one scale per output row "
445
+ f"({weight_shape[0]})"
446
+ )
447
+
448
+
449
+ def padded_header_bytes(header: Mapping[str, Any]) -> bytes:
450
+ raw = _json_dumps(header).encode("utf-8")
451
+ padding = (-len(raw)) % 8
452
+ return raw + (b" " * padding)
453
+
454
+
455
+ def copy_region(
456
+ source: BinaryIO,
457
+ destination: BinaryIO,
458
+ *,
459
+ absolute_start: int,
460
+ size: int,
461
+ ) -> None:
462
+ source.seek(absolute_start)
463
+ remaining = size
464
+ while remaining:
465
+ chunk = source.read(min(COPY_CHUNK_SIZE, remaining))
466
+ if not chunk:
467
+ raise ConversionError("Unexpected EOF while copying tensor payload")
468
+ destination.write(chunk)
469
+ remaining -= len(chunk)
470
+
471
+
472
+ def build_conversion_plan(
473
+ source_path: Path,
474
+ *,
475
+ assume_convrot: bool,
476
+ linear_dtype: str,
477
+ keep_scale_shape: bool,
478
+ ) -> tuple[
479
+ OrderedDict[str, Any],
480
+ int,
481
+ list[TensorEntry],
482
+ dict[str, bytes],
483
+ Report,
484
+ ]:
485
+ header, data_start, file_size = read_safetensors_header(source_path)
486
+ entries = collect_tensor_entries(header, file_size - data_start)
487
+ report = Report()
488
+
489
+ convert_file_metadata(
490
+ header,
491
+ assume_convrot=assume_convrot,
492
+ linear_dtype=linear_dtype,
493
+ report=report,
494
+ )
495
+
496
+ replacements: dict[str, bytes] = {}
497
+ with source_path.open("rb") as source:
498
+ for entry in entries:
499
+ if not entry.name.endswith(".comfy_quant"):
500
+ continue
501
+ if entry.info.get("dtype") not in ("U8", "I8"):
502
+ raise ConversionError(
503
+ f"{entry.name!r} must use U8/I8 storage, got {entry.info.get('dtype')!r}"
504
+ )
505
+ raw = read_tensor_bytes(source, data_start, entry)
506
+ replacement, changed = convert_comfy_quant_payload(
507
+ raw,
508
+ tensor_name=entry.name,
509
+ assume_convrot=assume_convrot,
510
+ linear_dtype=linear_dtype,
511
+ report=report,
512
+ )
513
+ if changed:
514
+ replacements[entry.name] = replacement
515
+
516
+ # Rebuild tensor entries in physical order and recalculate every data offset.
517
+ output_header: OrderedDict[str, Any] = OrderedDict()
518
+ if "__metadata__" in header:
519
+ output_header["__metadata__"] = header["__metadata__"]
520
+
521
+ cursor = 0
522
+ final_entries: list[TensorEntry] = []
523
+ tensor_infos: dict[str, dict[str, Any]] = {}
524
+ for entry in entries:
525
+ info = copy.deepcopy(entry.info)
526
+ replacement = replacements.get(entry.name)
527
+ size = len(replacement) if replacement is not None else entry.size
528
+ if replacement is not None:
529
+ info["shape"] = [size]
530
+ normalize_scale_shape(
531
+ entry.name,
532
+ info,
533
+ converted_layers=report.converted_layers,
534
+ keep_scale_shape=keep_scale_shape,
535
+ report=report,
536
+ )
537
+ info["data_offsets"] = [cursor, cursor + size]
538
+ output_header[entry.name] = info
539
+ tensor_infos[entry.name] = info
540
+ final_entries.append(
541
+ TensorEntry(name=entry.name, info=info, start=entry.start, end=entry.end)
542
+ )
543
+ cursor += size
544
+
545
+ validate_converted_layer_tensors(
546
+ tensor_infos,
547
+ converted_layers=report.converted_layers,
548
+ report=report,
549
+ )
550
+
551
+ return output_header, data_start, final_entries, replacements, report
552
+
553
+
554
+ def write_converted_file(
555
+ source_path: Path,
556
+ destination_path: Path,
557
+ *,
558
+ header: OrderedDict[str, Any],
559
+ source_data_start: int,
560
+ entries: list[TensorEntry],
561
+ replacements: Mapping[str, bytes],
562
+ ) -> None:
563
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
564
+ header_bytes = padded_header_bytes(header)
565
+
566
+ with source_path.open("rb") as source, destination_path.open("wb") as destination:
567
+ destination.write(HEADER_LEN_STRUCT.pack(len(header_bytes)))
568
+ destination.write(header_bytes)
569
+ for entry in entries:
570
+ replacement = replacements.get(entry.name)
571
+ if replacement is not None:
572
+ destination.write(replacement)
573
+ else:
574
+ copy_region(
575
+ source,
576
+ destination,
577
+ absolute_start=source_data_start + entry.start,
578
+ size=entry.size,
579
+ )
580
+ destination.flush()
581
+ os.fsync(destination.fileno())
582
+
583
+
584
+ def default_output_path(source: Path) -> Path:
585
+ if source.suffix.lower() == ".safetensors":
586
+ return source.with_name(f"{source.stem}.convrot_w4a4.safetensors")
587
+ return source.with_name(f"{source.name}.convrot_w4a4.safetensors")
588
+
589
+
590
+ def print_report(report: Report, source: Path, destination: Path | None) -> None:
591
+ print(f"Source: {source}")
592
+ if destination is not None:
593
+ print(f"Output: {destination}")
594
+ print(f"Converted layers: {report.converted_count}")
595
+ print(f" _quantization_metadata entries: {len(report.converted_metadata_layers)}")
596
+ print(f" persisted .comfy_quant tensors: {len(report.converted_comfy_quant_tensors)}")
597
+ print(f" normalized weight_scale shapes: {len(report.normalized_scale_tensors)}")
598
+ for layer in sorted(report.converted_layers):
599
+ print(f" - {layer}")
600
+ for warning in report.warnings:
601
+ print(f"WARNING: {warning}", file=sys.stderr)
602
+
603
+
604
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
605
+ parser = argparse.ArgumentParser(
606
+ description=(
607
+ "Convert legacy int4_tensorwise+ConvRot safetensors metadata to the "
608
+ "official convrot_w4a4 format without requantizing model weights."
609
+ )
610
+ )
611
+ parser.add_argument("input", type=Path, help="Input .safetensors checkpoint")
612
+ parser.add_argument(
613
+ "output",
614
+ nargs="?",
615
+ type=Path,
616
+ help="Output path (default: <input>.convrot_w4a4.safetensors)",
617
+ )
618
+ parser.add_argument(
619
+ "--in-place",
620
+ action="store_true",
621
+ help="Atomically replace the input file after successful conversion",
622
+ )
623
+ parser.add_argument(
624
+ "--dry-run",
625
+ action="store_true",
626
+ help="Inspect and validate the conversion without writing a file",
627
+ )
628
+ parser.add_argument(
629
+ "--assume-convrot",
630
+ action="store_true",
631
+ help=(
632
+ "Convert int4_tensorwise entries that lack an explicit convrot marker. "
633
+ "Dangerous for non-ConvRot checkpoints."
634
+ ),
635
+ )
636
+ parser.add_argument(
637
+ "--linear-dtype",
638
+ choices=("int4", "int8"),
639
+ default=DEFAULT_LINEAR_DTYPE,
640
+ help="Official kernel path recorded in metadata (default: int4)",
641
+ )
642
+ parser.add_argument(
643
+ "--keep-scale-shape",
644
+ action="store_true",
645
+ help="Keep legacy weight_scale shape [N,1] instead of normalizing it to [N]",
646
+ )
647
+ parser.add_argument(
648
+ "--force",
649
+ action="store_true",
650
+ help="Overwrite an existing output file",
651
+ )
652
+ parser.add_argument(
653
+ "--allow-noop",
654
+ action="store_true",
655
+ help="Exit successfully even when no eligible legacy ConvRot layers are found",
656
+ )
657
+ return parser.parse_args(argv)
658
+
659
+
660
+ def main(argv: list[str] | None = None) -> int:
661
+ args = parse_args(argv)
662
+ source = args.input.expanduser().resolve()
663
+ if not source.is_file():
664
+ raise ConversionError(f"Input file does not exist: {source}")
665
+ if args.in_place and args.output is not None:
666
+ raise ConversionError("Do not supply an output path together with --in-place")
667
+
668
+ (
669
+ output_header,
670
+ source_data_start,
671
+ entries,
672
+ replacements,
673
+ report,
674
+ ) = build_conversion_plan(
675
+ source,
676
+ assume_convrot=args.assume_convrot,
677
+ linear_dtype=args.linear_dtype,
678
+ keep_scale_shape=args.keep_scale_shape,
679
+ )
680
+
681
+ if report.converted_count == 0 and not args.allow_noop:
682
+ raise ConversionError(
683
+ "No eligible int4_tensorwise ConvRot layers were found. "
684
+ "The file may already use convrot_w4a4, may be non-ConvRot, or may not carry ComfyUI quantization metadata."
685
+ )
686
+
687
+ if args.dry_run:
688
+ print_report(report, source, None)
689
+ print("Dry run only; no file was written.")
690
+ return 0
691
+
692
+ if args.in_place:
693
+ fd, temp_name = tempfile.mkstemp(
694
+ prefix=f".{source.name}.", suffix=".tmp", dir=source.parent
695
+ )
696
+ os.close(fd)
697
+ temp_path = Path(temp_name)
698
+ try:
699
+ write_converted_file(
700
+ source,
701
+ temp_path,
702
+ header=output_header,
703
+ source_data_start=source_data_start,
704
+ entries=entries,
705
+ replacements=replacements,
706
+ )
707
+ shutil.copystat(source, temp_path)
708
+ os.replace(temp_path, source)
709
+ finally:
710
+ temp_path.unlink(missing_ok=True)
711
+ print_report(report, source, source)
712
+ else:
713
+ destination = (
714
+ args.output.expanduser().resolve()
715
+ if args.output is not None
716
+ else default_output_path(source)
717
+ )
718
+ if destination == source:
719
+ raise ConversionError("Output equals input; use --in-place for atomic replacement")
720
+ if destination.exists() and not args.force:
721
+ raise ConversionError(
722
+ f"Output already exists: {destination} (use --force to overwrite)"
723
+ )
724
+ write_converted_file(
725
+ source,
726
+ destination,
727
+ header=output_header,
728
+ source_data_start=source_data_start,
729
+ entries=entries,
730
+ replacements=replacements,
731
+ )
732
+ print_report(report, source, destination)
733
+
734
+ print("Done. Packed INT4 weights and FP32 scale payloads were copied without requantization.")
735
+ return 0
736
+
737
+
738
+ if __name__ == "__main__":
739
+ try:
740
+ raise SystemExit(main())
741
+ except ConversionError as exc:
742
+ print(f"ERROR: {exc}", file=sys.stderr)
743
+ raise SystemExit(2)