DmitryDB commited on
Commit
efddac3
·
verified ·
1 Parent(s): 08c792b

Remove conversion tools from model repository

Browse files
NOTICE CHANGED
@@ -6,5 +6,5 @@ The FL2VA safetensors checkpoint in this repository is a modified derivative of
6
  MiniMax H3. It was converted from the original Diffusers tensor layout to the
7
  ComfyUI layout, selectively quantized to INT8 ConvRot, and compressed with a
8
  dynamic rank-16 representation of the AdaLN time-conditioning curve. The
9
- included ComfyUI patch and conversion/validation scripts are also modified or
10
- new files provided for this derivative release.
 
6
  MiniMax H3. It was converted from the original Diffusers tensor layout to the
7
  ComfyUI layout, selectively quantized to INT8 ConvRot, and compressed with a
8
  dynamic rank-16 representation of the AdaLN time-conditioning curve. The
9
+ included ComfyUI patch is also a modified file provided for this derivative
10
+ release.
README.md CHANGED
@@ -36,7 +36,6 @@ small ComfyUI core patch included in [`comfy_patch/`](comfy_patch/).
36
  | [`reports/layer_policy.json`](reports/layer_policy.json) | Exact per-layer precision policy |
37
  | [`reports/validation.json`](reports/validation.json) | Structural, numerical, and CPU-load results |
38
  | [`reports/mm_quant_profile_fl2va.json`](reports/mm_quant_profile_fl2va.json) | Row-sampled reconstruction profile for all 200 main matrices |
39
- | [`tools/`](tools/) | Audited converter, profiler, and validator sources |
40
 
41
  This repository intentionally does **not** include the MiniMax-H3 text encoder,
42
  tokenizer, or video/audio VAEs. The text encoder is being prepared as a separate
@@ -168,15 +167,14 @@ Therefore, 20.999 GiB is the on-disk tensor payload, not a promise that every
168
  workflow will remain under 24 GiB. Activations, runtime buffers, resolution,
169
  frame count, batch size, and offloading policy determine peak VRAM.
170
 
171
- ## Reproduction
172
 
173
  The converter reads the original top-level `transformer/` Diffusers shards from
174
  MiniMaxAI directly; it does not create a 61+ GiB merged BF16 intermediate. The
175
  nested task-specific transformer folder was not used because its already-packed
176
  QKV is head-major, while current ComfyUI expects global `cat(Q,K,V)` packing.
177
 
178
- See [`tools/README.md`](tools/README.md) for exact dry-run, CPU-build, and
179
- validation commands. The official
180
  [`minimax_h3_fl2va_pruned_int8_convrot.safetensors`](https://huggingface.co/Comfy-Org/MiniMax-H3/blob/main/diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors)
181
  was used only as a layout reference and as the source of the FP32
182
  `rope.inv_freq` tensor absent from the raw Diffusers state dict.
 
36
  | [`reports/layer_policy.json`](reports/layer_policy.json) | Exact per-layer precision policy |
37
  | [`reports/validation.json`](reports/validation.json) | Structural, numerical, and CPU-load results |
38
  | [`reports/mm_quant_profile_fl2va.json`](reports/mm_quant_profile_fl2va.json) | Row-sampled reconstruction profile for all 200 main matrices |
 
39
 
40
  This repository intentionally does **not** include the MiniMax-H3 text encoder,
41
  tokenizer, or video/audio VAEs. The text encoder is being prepared as a separate
 
167
  workflow will remain under 24 GiB. Activations, runtime buffers, resolution,
168
  frame count, batch size, and offloading policy determine peak VRAM.
169
 
170
+ ## Conversion provenance
171
 
172
  The converter reads the original top-level `transformer/` Diffusers shards from
173
  MiniMaxAI directly; it does not create a 61+ GiB merged BF16 intermediate. The
174
  nested task-specific transformer folder was not used because its already-packed
175
  QKV is head-major, while current ComfyUI expects global `cat(Q,K,V)` packing.
176
 
177
+ The official
 
178
  [`minimax_h3_fl2va_pruned_int8_convrot.safetensors`](https://huggingface.co/Comfy-Org/MiniMax-H3/blob/main/diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors)
179
  was used only as a layout reference and as the source of the FP32
180
  `rope.inv_freq` tensor absent from the raw Diffusers state dict.
requirements.txt DELETED
@@ -1,3 +0,0 @@
1
- torch
2
- safetensors
3
- comfy-kitchen
 
 
 
 
tools/README.md DELETED
@@ -1,49 +0,0 @@
1
- # Reproduction tools
2
-
3
- These are the exact converter, profiler, and validator used for this release,
4
- with local paths replaced by CLI arguments. They are research utilities, not a
5
- general MiniMax-H3 conversion library.
6
-
7
- Requirements:
8
-
9
- - Python 3.10+
10
- - PyTorch
11
- - `safetensors`
12
- - `comfy-kitchen` from a current ComfyUI environment
13
-
14
- The converter reads the original top-level Diffusers shards directly; it does
15
- not need a merged BF16 file. Use `MiniMaxAI/MiniMax-H3`'s `transformer/` folder,
16
- not the nested task folder whose already-packed QKV uses a different row order.
17
- It also uses the official pruned ComfyOrg file as a structural reference and to
18
- copy `rope.inv_freq`, which is absent from the raw Diffusers state dict.
19
-
20
- Dry run:
21
-
22
- ```bash
23
- python tools/mm_quantize_lean.py fl2va \
24
- --src /path/to/MiniMax-H3/transformer \
25
- --reference /path/to/minimax_h3_fl2va_pruned_int8_convrot.safetensors \
26
- --dst /path/to/output.safetensors \
27
- --profile quality21 --time-mode dynamic --rank 16 --device cpu --dry-run
28
- ```
29
-
30
- Build on CPU:
31
-
32
- ```bash
33
- python tools/mm_quantize_lean.py fl2va \
34
- --src /path/to/MiniMax-H3/transformer \
35
- --reference /path/to/minimax_h3_fl2va_pruned_int8_convrot.safetensors \
36
- --dst /path/to/output.safetensors \
37
- --profile quality21 --time-mode dynamic --rank 16 --device cpu --overwrite
38
- ```
39
-
40
- Validate:
41
-
42
- ```bash
43
- python tools/mm_validate_dynamic_built.py fl2va /path/to/output.safetensors \
44
- --src /path/to/MiniMax-H3/transformer --profile quality21
45
- ```
46
-
47
- The public scripts preserve the exact mapping and quantization behavior used by
48
- the release. Some diagnostic messages remain in Russian because these files are
49
- the audited build sources rather than a rewritten approximation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/convert_int8_convrot.py DELETED
@@ -1,556 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- r"""Конвертер моделей ComfyUI в INT8 ConvRot.
3
-
4
- Берёт safetensors (bf16/fp16/fp32) и квантует линейные слои в int8 с поворотом
5
- Адамара, как это делают официальные сборки Comfy-Org (int8_tensorwise + convrot).
6
-
7
- Не трогает: нормализации, эмбеддеры, bias, 1D-тензоры и матрицы, у которых
8
- входная размерность не кратна размеру группы — они остаются в исходной точности.
9
-
10
- Запуск:
11
- python_embeded\python.exe -s tools\convert_int8_convrot.py <вход.safetensors> <выход.safetensors>
12
- [--groupsize 256] [--device cuda:0] [--dry-run]
13
- """
14
- import argparse
15
- import json
16
- import os
17
- import re
18
- import sys
19
- import time
20
-
21
- import torch
22
- from safetensors import safe_open
23
- from safetensors.torch import save_file
24
-
25
-
26
- class HFShardReader:
27
- """Читает HF-репозиторий (папку с шардами) так же, как safe_open один файл.
28
-
29
- Нужно, чтобы квантовать прямо из оригинала, не складывая на диск
30
- промежуточный файл на десятки гигабайт. Значения при этом те же самые:
31
- склейка ничего не меняла, но лишняя запись и чтение никому не нужны.
32
-
33
- Имена приводятся к раскладке ComfyUI теми же правилами, что в
34
- merge_hf_to_comfy.py, визуальная часть остаётся на месте.
35
- """
36
-
37
- RENAME = [
38
- ("model.language_model.", "model."),
39
- ("language_model.model.", "model."),
40
- ("language_model.", ""),
41
- ("vision_tower.", ""),
42
- ]
43
-
44
- def __init__(self, path: str):
45
- import glob
46
- self._files = {}
47
- self._map = {}
48
- shards = sorted(glob.glob(os.path.join(path, "*.safetensors")))
49
- if not shards:
50
- raise SystemExit(f"в папке нет файлов safetensors: {path}")
51
- for shard in shards:
52
- f = safe_open(shard, framework="pt")
53
- self._files[shard] = f
54
- for k in f.keys():
55
- self._map[self._rename(k)] = (shard, k)
56
- print(f" прочитано шардов: {len(shards)}, тензоров: {len(self._map)}")
57
-
58
- @classmethod
59
- def _rename(cls, key: str) -> str:
60
- for old, new in cls.RENAME:
61
- if key.startswith(old):
62
- rest = key[len(old):]
63
- return rest if rest.startswith("model.") or not new else new + rest
64
- return key
65
-
66
- def keys(self):
67
- return list(self._map)
68
-
69
- def get_tensor(self, key: str):
70
- shard, orig = self._map[key]
71
- return self._files[shard].get_tensor(orig)
72
-
73
- def metadata(self):
74
- return {"format": "pt"}
75
-
76
- def __enter__(self):
77
- return self
78
-
79
- def __exit__(self, *exc):
80
- self._files.clear()
81
-
82
- sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "ComfyUI"))
83
-
84
- # Слои, которые официальные сборки Comfy-Org оставляют в исходной точности:
85
- # нормализации, эмбеддеры, входной/выходной слой. Их квантование заметно бьёт по качеству.
86
- SKIP_TOKENS = (
87
- # диффузионные трансформеры
88
- # "_emb." с точкой, а не "_emb": иначе под запрет попадают модули вида
89
- # video_embeddings_connector - а это обычные блоки внимания и полносвязные
90
- # слои на 4 ГБ, которые квантовать можно и нужно
91
- "norm", "embedder", "pad_token", "emb.", "_emb.",
92
- "final_layer", "x_embedder", "proj_out", "time_in", "vector_in",
93
- # Слои модуляции: выдают масштаб и сдвиг для каждого блока, поэтому ошибка
94
- # в них умножается на всю сеть. Мы их не квантуем НИКОГДА - это наше решение
95
- # по замеру, а не подражание чужим сборкам.
96
- #
97
- # Единодушия у авторов тут нет, пересчитано по файлам на диске 31.07.2026:
98
- # LTX 2.3 0 из 24 квантовано
99
- # Anima 0 из 170
100
- # Z-Image 32 из 33 <- квантует, вопреки прежней записи в этом коде
101
- # Поэтому по Z-Image и Ideogram сверка audit_reference_int8.py всегда будет
102
- # показывать ~84 %, и это ОСОЗНАННОЕ расхождение, а не промах правила.
103
- "adaln", "modulation",
104
- # входные проекции (diffusers-нейминг): в эталонных сборках Comfy-Org,
105
- # obsxrver и supermind они всегда остаются в исходной точности - проверено
106
- # по файлам LTX 2.3, Z-Image, Krea 2 и Qwen-Image
107
- "img_in", "txt_in", "patch_embed", "context_embedder",
108
- # языковые модели (текст-энкодеры)
109
- "lm_head", "embed_tokens", "token_embd", "shared.", "wte", "wpe",
110
- # части полных чекпоинтов, которые квантовать нельзя: VAE свёрточный и к
111
- # int8 чувствителен, текстовая проекция маленькая и стоит на входе, а
112
- # per_channel_statistics - это вообще не веса, а нормировочные константы
113
- "vae.", "text_embedding_projection", "per_channel_statistics", "vocoder.",
114
- )
115
-
116
- # Коэффициенты подрезания выбросов при подборе масштаба.
117
- # У диффузионных моделей оптимум почти всегда в 0.95-1.00 (медиана 0.982), поэтому
118
- # основная часть сетки плотная: шаг 0.001 в диапазоне 1.000-0.940. Но у текст-энкодеров
119
- # хвосты тяжелее: у qwen2.5-VL 3.2% строк упирались в край 0.940, то есть их оптимум
120
- # лежал ещё ниже и просто не находился. Поэтому дальше идёт разреженный хвост до 0.80 -
121
- # он почти не стоит времени, зато снимает обрезание перебора. См. tools/check_range.py.
122
- def build_ratios(lo: float = 0.80) -> tuple:
123
- """Сетка от 1.0 вниз до lo: плотно у единицы, дальше всё разреженнее."""
124
- out = [round(1.0 - i * 0.001, 4) for i in range(61)] # 1.000 .. 0.940
125
- out += [round(0.938 - i * 0.002, 4) for i in range(20)] # 0.938 .. 0.900
126
- out += [round(0.895 - i * 0.005, 4) for i in range(20)] # 0.895 .. 0.800
127
- return tuple(r for r in out if r >= lo - 1e-9)
128
-
129
-
130
- SEARCH_RATIOS = build_ratios()
131
-
132
-
133
- # Поворот Адамара строится удвоением матрицы 4x4, поэтому размер группы обязан
134
- # быть степенью четвёрки (см. _build_hadamard: "Regular Hadamard size must be a
135
- # power of 4"). Отсюда и лесенка: 256 -> 64 -> 16.
136
- GROUPSIZE_LADDER = (256, 64, 16)
137
-
138
- _DIGITS = re.compile(r"\d+")
139
- # Порог повторяемости. Два - это ещё не стопка: linear_fc1/linear_fc2 одного
140
- # модуля дают ровно два совпадения по шаблону.
141
- REPEAT_MIN = 3
142
-
143
-
144
- def block_patterns(layer: str) -> tuple[str, ...]:
145
- """Варианты имени, где обезличена ровно одна числовая позиция.
146
-
147
- Одновременная замена всех цифр смешивала независимые роли. Например,
148
- ``blocks.0.mlp.fc1`` и ``blocks.1.mlp.fc2`` превращались в один шаблон.
149
- Из-за этого два блока token_refiner с fc1/fc2 давали ложную стопку из
150
- четырёх слоёв и проходили порог повторяемости 3.
151
- """
152
- return tuple(layer[:m.start()] + "#" + layer[m.end():] for m in _DIGITS.finditer(layer))
153
-
154
-
155
- def repeated_layers(layer_names, repeat_min: int = REPEAT_MIN) -> set:
156
- """Слои, которые входят в повторяющуюся стопку блоков.
157
-
158
- Зачем это вместо списка подстрок. Эталонные сборки квантуют тело сети и
159
- оставляют в исходной точности края: входные проекции, выходные головы,
160
- таблицы вложений, мост зрение->язык. Объединяет их не название, а то, что
161
- они существуют в единственном экземпляре, тогда как тело - это одна и та же
162
- ноd, повторённая по числу блоков. Номер блока в имени и есть тот признак,
163
- который отличает тело от края, и он не зависит от того, как автор модели
164
- назвал свои модули.
165
-
166
- Проверено сверкой с эталонами (tools/audit_reference_int8.py): по этому
167
- признаку сами собой отсеиваются lm_head, embed_tokens, patchify_proj,
168
- visual.merger, visual.pos_embed, adaln_single.emb.* - всё то, что раньше
169
- приходилось перечислять руками.
170
- """
171
- counts: dict[str, int] = {}
172
- for name in layer_names:
173
- for pattern in set(block_patterns(name)):
174
- counts[pattern] = counts.get(pattern, 0) + 1
175
- return {
176
- name for name in layer_names
177
- if any(counts[pattern] >= repeat_min for pattern in block_patterns(name))
178
- }
179
-
180
-
181
- def pick_groupsize(out_f: int, in_f: int, groupsize) -> int | None:
182
- """Какую группу взять этому слою. Число - взять его же или отказать.
183
-
184
- "auto" спускается по лесенке до первой группы, на которую делится входная
185
- размерность. Это нужно зрительным башням: у Qwen3-VL ширина 1152 и 4304, ни
186
- одна из них не кратна 256, поэтому при фиксированной группе всё зрение
187
- оставалось в bf16. Эталонные сборки supermind делают ровно так же - проверено
188
- по comfy_quant в qwen3vl_8b_int8_convrot: 256 у языковой части, 64 у
189
- visual.attn, 16 у visual.mlp.linear_fc2.
190
- """
191
- if groupsize != "auto":
192
- g = int(groupsize)
193
- return g if in_f % g == 0 and min(out_f, in_f) >= g else None
194
- # Мелкая группа - способ достать слой с неудобной шириной, а не разрешение
195
- # квантовать что угодно узкое. Порог по узкой стороне остаётся базовым (256),
196
- # иначе в отбор лезут краевые слои: gate_logits (32 x 4096) и входные
197
- # проекции с пикселей patchify_proj (4096 x 128). Эталонные сборки LTX их не
198
- # берут - сверено поимённо в tools/audit_reference_int8.py.
199
- if min(out_f, in_f) < GROUPSIZE_LADDER[0]:
200
- return None
201
- for g in GROUPSIZE_LADDER:
202
- if in_f % g == 0:
203
- return g
204
- return None
205
-
206
-
207
- def should_quantize(name: str, t: torch.Tensor, groupsize,
208
- skip_tokens=None, repeated=None) -> tuple[bool, str, int]:
209
- if not name.endswith(".weight"):
210
- return False, "не weight", 0
211
- if t.ndim != 2:
212
- return False, f"ndim={t.ndim}", 0
213
- if repeated is not None and name[: -len(".weight")] not in repeated:
214
- return False, "одиночный слой (не повторяется по блокам)", 0
215
- low = name.lower()
216
- for tok in (SKIP_TOKENS if skip_tokens is None else skip_tokens):
217
- if tok in low:
218
- return False, f"имя содержит '{tok}'", 0
219
- out_f, in_f = t.shape
220
- g = pick_groupsize(out_f, in_f, groupsize)
221
- if g is None:
222
- if in_f % (int(groupsize) if groupsize != "auto" else GROUPSIZE_LADDER[-1]):
223
- return False, f"in_features {in_f} не кратно {groupsize}", 0
224
- return False, f"слишком маленький слой {tuple(t.shape)}", 0
225
- return True, "", g
226
-
227
-
228
- def _cost(residual: torch.Tensor, objective: str, step: torch.Tensor,
229
- chan_w: torch.Tensor | None = None) -> torch.Tensor:
230
- """Во что нам обходится остаток. Сетка значений всегда равномерная - ядро
231
- разжимает строго как q*scale, кодбука в формате нет. Свободен только критерий,
232
- по которому выбирается точка подрезания, и от него зависит, насколько сильно
233
- мы готовы жертвовать выбросами ради основной массы весов.
234
-
235
- mse - квадрат: выбросы дороги, подрезаем осторожно
236
- l1 - модуль: выбросы дешевле, подрезаем смелее
237
- huber - квадрат вблизи нуля, модуль дальше: компромисс
238
- """
239
- # Взвешивание по каналам (AWQ-стиль): ошибка в канале, по которому приходят
240
- # большие активации, стоит дороже. chan_w - средний квадрат входа по каналам,
241
- # снятый capture_activations.py уже в повёрнутом пространстве. Это в точности
242
- # диагональ гессиана слоя, то есть переход от "ошибки весов" к "ошибке выхода".
243
- if objective == "mse":
244
- sq = residual ** 2
245
- if chan_w is not None:
246
- sq = sq * chan_w
247
- return sq.sum(dim=1, keepdim=True)
248
- if objective == "l1":
249
- a = residual.abs()
250
- if chan_w is not None:
251
- a = a * chan_w
252
- return a.sum(dim=1, keepdim=True)
253
- if objective == "huber":
254
- d = step # порог - один шаг кванта
255
- a = residual.abs()
256
- quad = torch.minimum(a, d)
257
- h = 0.5 * quad ** 2 + d * (a - quad)
258
- if chan_w is not None:
259
- h = h * chan_w
260
- return h.sum(dim=1, keepdim=True)
261
- raise SystemExit(f"неизвестный критерий {objective}")
262
-
263
-
264
- def quantize_search(w: torch.Tensor, groupsize: int, ratios,
265
- objective: str = "mse",
266
- qmin: int = -127,
267
- sign_aware: bool = False,
268
- ls_refit: bool = False,
269
- chan_w: torch.Tensor | None = None,
270
- clip_margin: float = 0.0) -> tuple[torch.Tensor, torch.Tensor]:
271
- """INT8 ConvRot с подбором масштаба по минимуму ошибки (вместо простого absmax).
272
-
273
- Официальные сборки берут scale = absmax/127. Небольшое подрезание выбросов
274
- почти всегда уменьшает суммарную ошибку: редкие большие веса округляются
275
- чуть грубее, зато основная масса значений ложится на сетку точнее.
276
- """
277
- from comfy_kitchen.backends.eager.quantization import _build_hadamard, _rotate_weight
278
-
279
- h = _build_hadamard(groupsize, device=w.device, dtype=w.dtype)
280
- w_rot = _rotate_weight(w, h, groupsize)
281
-
282
- absmax = w_rot.abs().amax(dim=1, keepdim=True).clamp(min=1e-12)
283
- best_scale = absmax / 127.0
284
- best_err = torch.full_like(absmax, float("inf"))
285
-
286
- if sign_aware:
287
- # Единственный честный способ дотянуться до -128: разрешать его только
288
- # тем строкам, у которых отрицательный хвост и так длиннее положительного.
289
- # Тогда несимметричная сетка не создаёт перекос, а повторяет уже имеющийся.
290
- neg_heavy = w_rot.amin(dim=1, keepdim=True).abs() > w_rot.amax(dim=1, keepdim=True)
291
- lo = torch.where(neg_heavy, -128.0, -127.0).to(w_rot.dtype)
292
- else:
293
- lo = torch.full_like(absmax, float(qmin))
294
- hi = torch.full_like(absmax, 127.0)
295
-
296
- # qmin=-127 (симметрично) по умолчанию. Значение -128 формально доступно и
297
- # уменьшает ошибку весов, но вносит односторонний сдвиг: сетка перестаёт быть
298
- # симметричной, у ошибки появляется ненулевое среднее, и на генерации это
299
- # выходит дороже выигрыша. Проверено на стенде - см. bench_quant.py.
300
- for r in ratios:
301
- scale = absmax * r / 127.0
302
- q = torch.minimum(torch.maximum(torch.round(w_rot / scale), lo), hi)
303
- err = _cost(q * scale - w_rot, objective, scale, chan_w)
304
- better = err < best_err
305
- best_err = torch.where(better, err, best_err)
306
- best_scale = torch.where(better, scale, best_scale)
307
-
308
- if clip_margin > 0.0:
309
- # Порог на подрезание. Эталонные сборки оставляют без подрезания заметно
310
- # больше строк, чем даёт чистый минимум MSE (у qwen3-4b 9.4% против наших
311
- # 2-3%), и на выходе энкодера это оказывается лучше. Похоже, у выбросов
312
- # есть функциональная роль, которую квадрат ошибки не видит. Поэтому режем
313
- # только там, где выигрыш действительно заметный.
314
- s0 = absmax / 127.0
315
- q0 = torch.minimum(torch.maximum(torch.round(w_rot / s0), lo), hi)
316
- e0 = _cost(q0 * s0 - w_rot, objective, s0, chan_w)
317
- keep = best_err >= e0 * (1.0 - clip_margin)
318
- best_scale = torch.where(keep, s0, best_scale)
319
- del q0
320
-
321
- q = torch.minimum(torch.maximum(torch.round(w_rot / best_scale), lo), hi)
322
-
323
- if ls_refit:
324
- # При фиксированных целых q оптимальный по МНК масштаб - это (q·w)/(q·q),
325
- # а не absmax/127. Пересчёт убирает систематический сдвиг реконструкции,
326
- # который и появляется, когда сетка перестаёт быть симметричной.
327
- num = (q * w_rot).sum(dim=1, keepdim=True)
328
- den = (q * q).sum(dim=1, keepdim=True).clamp(min=1.0)
329
- best_scale = torch.where(num > 0, num / den, best_scale)
330
-
331
- return q.to(torch.int8), best_scale.to(torch.float32)
332
-
333
-
334
- def quantize_int4(w: torch.Tensor, groupsize: int):
335
- """Тот же поворот Адамара, но 4 бита вместо 8.
336
-
337
- Ядро читает такой слой по маркеру format=convrot_w4a4 и жёстко ожидает
338
- quant_group_size=64 (см. comfy/ops.py, ветка convrot_w4a4), поэтому размер
339
- группы квантования не настраиваем.
340
- """
341
- from comfy_kitchen.tensor.convrot_w4a4 import TensorCoreConvRotW4A4Layout
342
- qdata, params = TensorCoreConvRotW4A4Layout.quantize(
343
- w, convrot_groupsize=groupsize, quant_group_size=64, linear_dtype="int4")
344
- return qdata, params.scale
345
-
346
-
347
- def int4_error(w: torch.Tensor, groupsize: int) -> float:
348
- """Во сколько раз 4 бита дороже 8 на этом слое. Нужно, чтобы выбрать,
349
- какие слои не жалко ужать сильнее."""
350
- from comfy_kitchen.tensor.convrot_w4a4 import TensorCoreConvRotW4A4Layout
351
- q8, s8 = quantize_search(w, groupsize, SEARCH_RATIOS)
352
- from comfy_kitchen.tensor.int8 import TensorWiseINT8Layout
353
- p8 = TensorWiseINT8Layout.Params(scale=s8, orig_dtype=torch.float32,
354
- orig_shape=tuple(q8.shape), is_weight=True,
355
- convrot=True, convrot_groupsize=groupsize)
356
- e8 = (TensorWiseINT8Layout.dequantize(q8.to(w.device), p8) - w).norm().item()
357
-
358
- q4, params4 = TensorCoreConvRotW4A4Layout.quantize(
359
- w, convrot_groupsize=groupsize, quant_group_size=64, linear_dtype="int4")
360
- e4 = (TensorCoreConvRotW4A4Layout.dequantize(q4, params4).float() - w).norm().item()
361
- return e4 / max(e8, 1e-12)
362
-
363
-
364
- def load_calib(path: str, alpha: float, device: str):
365
- """Статистика активаций из capture_activations.py -> веса каналов."""
366
- if not path:
367
- return None
368
- blob = torch.load(path, map_location="cpu")
369
- stats = blob["stats"]
370
- out = {}
371
- for name, v in stats.items():
372
- w = v.to(device=device, dtype=torch.float32).clamp(min=1e-12)
373
- w = w / w.mean() # нормируем, чтобы масштаб ошибки не поехал
374
- out[name] = w.pow(alpha).reshape(1, -1)
375
- print(f"калибровка: {path}\n слоёв {len(out)}, промптов {blob.get('prompts')}, "
376
- f"шагов {blob.get('steps')}, показатель {alpha}")
377
- return out
378
-
379
-
380
- def calib_for(calib, tensor_name: str):
381
- """Ключи калибровки - пути модулей, ключи файла - они же плюс '.weight'."""
382
- if not calib:
383
- return None
384
- layer = tensor_name[: -len(".weight")]
385
- if layer in calib:
386
- return calib[layer]
387
- for prefix in ("model.diffusion_model.", "diffusion_model.", "model."):
388
- if layer.startswith(prefix) and layer[len(prefix):] in calib:
389
- return calib[layer[len(prefix):]]
390
- return None
391
-
392
-
393
- def convert(src: str, dst: str, groupsize: int, device: str, dry_run: bool,
394
- search: bool = False, objective: str = "mse", qmin: int = -127,
395
- sign_aware: bool = False, ls_refit: bool = False,
396
- calib_path: str = "", calib_alpha: float = 1.0,
397
- search_min: float = 0.80, clip_margin: float = 0.0,
398
- quant_adaln: bool = False, skip_connectors: bool = False,
399
- select: str = "names", drop_prefixes: tuple = (),
400
- passthrough_dtype: str = "") -> None:
401
- t0 = time.time()
402
- ratios = build_ratios(search_min)
403
- skip = SKIP_TOKENS
404
- if skip_connectors:
405
- # Вернуть старое поведение: не трогать *_embeddings_connector. Это 96 слоёв
406
- # обычного внимания и полносвязных на 4 ГБ, которые эталонные сборки
407
- # пропускают. Нужно, чтобы отделить вклад коннекторов от вклада подрезания.
408
- skip = tuple(t for t in skip if t != "_emb.") + ("_emb",)
409
- print("режим: коннекторы НЕ квантуются (как в эталонных сборках)")
410
- if quant_adaln:
411
- # AdaLN задаёт масштаб и сдвиг каждому блоку, поэтому его везде защищают.
412
- # Но это восьмибитный шаг, а не четырёхбитный - проверяем замером, а не верой.
413
- skip = tuple(t for t in SKIP_TOKENS if t not in ("adaln", "modulation"))
414
- print("режим: AdaLN тоже квантуется")
415
- if groupsize == "auto":
416
- print(f"режим: группа подбирается послойно по лесенке {GROUPSIZE_LADDER}")
417
- out: dict[str, torch.Tensor] = {}
418
- quantized = skipped = 0
419
- skip_reasons: dict[str, int] = {}
420
- gs_used: dict[int, int] = {}
421
- calib = load_calib(calib_path, calib_alpha, device)
422
- calib_hits = 0
423
-
424
- # папка = HF-репозиторий, читаем шарды напрямую; файл = как раньше
425
- opener = HFShardReader(src) if os.path.isdir(src) else safe_open(src, framework="pt")
426
- with opener as f:
427
- keys = list(f.keys())
428
- # ComfyUI читает конфигурацию модели из метаданных файла (model_detection.py:
429
- # dit_config.update(json.loads(metadata["config"])...). Без них он строит
430
- # ��одель по умолчанию, и чекпоинт не грузится: "size mismatch for
431
- # scale_shift_table: [9, 4096] vs [6, 4096]". Переносим как есть.
432
- meta = f.metadata()
433
- print(f"вход: {src}\n тензоров: {len(keys)}"
434
- f"{', метаданных: ' + str(len(meta)) if meta else ', метаданных нет'}")
435
-
436
- repeated = None
437
- if select in ("structure", "both"):
438
- names = [k[: -len(".weight")] for k in keys if k.endswith(".weight")]
439
- repeated = repeated_layers(names)
440
- print(f" повторяющихся по блокам слоёв: {len(repeated)} из {len(names)}")
441
- if select == "structure":
442
- skip = () # имена больше не участвуют, отбор чисто структурный
443
-
444
- for i, k in enumerate(keys, 1):
445
- if drop_prefixes and k.startswith(drop_prefixes):
446
- skip_reasons["выброшен(--drop)"] = skip_reasons.get("выброшен(--drop)", 0) + 1
447
- continue
448
- t = f.get_tensor(k)
449
- ok, why, gs = should_quantize(k, t, groupsize, skip, repeated)
450
- if not ok:
451
- skipped += 1
452
- skip_reasons[why.split()[0]] = skip_reasons.get(why.split()[0], 0) + 1
453
- if passthrough_dtype == "bf16" and t.dtype == torch.float32:
454
- t = t.to(torch.bfloat16)
455
- out[k] = t
456
- continue
457
- gs_used[gs] = gs_used.get(gs, 0) + 1
458
-
459
- if dry_run:
460
- quantized += 1
461
- continue
462
-
463
- # поворот и подбор масштаба считаем в float32: в bf16 сама ротация
464
- # вносит заметную ошибку (проверено на эталонных сборках)
465
- w = t.to(device=device, dtype=torch.float32)
466
- # оба режима идут через один и тот же код: absmax - это просто подбор
467
- # по единственному коэффициенту 1.0
468
- cw = calib_for(calib, k)
469
- calib_hits += cw is not None
470
- qdata, scale = quantize_search(
471
- w, gs, ratios if search else (1.0,), objective, qmin,
472
- sign_aware, ls_refit, cw, clip_margin)
473
- layer = k[: -len(".weight")]
474
- out[k] = qdata.cpu()
475
- out[f"{layer}.weight_scale"] = scale.cpu()
476
- conf = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": gs}
477
- out[f"{layer}.comfy_quant"] = torch.tensor(
478
- list(json.dumps(conf).encode("utf-8")), dtype=torch.uint8)
479
- quantized += 1
480
- del w, qdata, scale
481
-
482
- if i % 50 == 0 or i == len(keys):
483
- done = quantized + skipped
484
- print(f" [{done}/{len(keys)}] квантовано {quantized}, пропущено {skipped}",
485
- flush=True)
486
-
487
- print(f"\nитог: квантовано {quantized}, оставлено {skipped}")
488
- print(" причины пропуска:", skip_reasons)
489
- print(" размеры групп:", dict(sorted(gs_used.items(), reverse=True)))
490
- if calib:
491
- print(f" калибровка нашлась для {calib_hits} из {quantized} слоёв")
492
- if calib_hits < quantized:
493
- print(" ВНИМАНИЕ: часть слоёв квантована без калибровки - проверь имена")
494
- if dry_run:
495
- print("(dry-run: файл не записан)")
496
- return
497
-
498
- os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
499
- save_file(out, dst, metadata=meta if meta else None)
500
- src_gb = os.path.getsize(src) / 1e9
501
- dst_gb = os.path.getsize(dst) / 1e9
502
- print(f"\nзаписано: {dst}\n {src_gb:.2f} GB -> {dst_gb:.2f} GB за {time.time()-t0:.0f} c")
503
-
504
-
505
- if __name__ == "__main__":
506
- ap = argparse.ArgumentParser()
507
- ap.add_argument("src")
508
- ap.add_argument("dst")
509
- ap.add_argument("--groupsize", default=256,
510
- type=lambda v: v if v == "auto" else int(v),
511
- help="число или auto — тогда группа подбирается послойно "
512
- f"по лесенке {GROUPSIZE_LADDER}, как в эталонных сборках")
513
- ap.add_argument("--device", default="cuda:0")
514
- ap.add_argument("--dry-run", action="store_true")
515
- ap.add_argument("--no-search-scale", action="store_true",
516
- help="взять простой absmax вместо подбора масштаба (быстрее, но хуже эталона)")
517
- ap.add_argument("--objective", default="mse", choices=("mse", "l1", "huber"),
518
- help="критерий выбора точки подрезания выбросов")
519
- ap.add_argument("--asymmetric", action="store_true",
520
- help="р��зрешить -128 всем строкам (ошибка весов меньше, на генерации хуже)")
521
- ap.add_argument("--sign-aware", action="store_true",
522
- help="разрешать -128 только строкам с длинным отрицательным хвостом")
523
- ap.add_argument("--ls-refit", action="store_true",
524
- help="пересчитать масштаб по МНК после выбора целых значений")
525
- ap.add_argument("--calib", default="",
526
- help="файл статистики активаций от capture_activations.py")
527
- ap.add_argument("--calib-alpha", type=float, default=1.0,
528
- help="показатель степени для веса канала: 1.0 - диагональ гессиана, "
529
- "0.5 - мягче, 0.0 - как без калибровки")
530
- ap.add_argument("--search-min", type=float, default=0.80,
531
- help="нижняя граница перебора коэффициентов подрезания")
532
- ap.add_argument("--skip-connectors", action="store_true",
533
- help="не квантовать *_embeddings_connector, как в эталонных сборках")
534
- ap.add_argument("--quant-adaln", action="store_true",
535
- help="квантовать и слои модуляции AdaLN (ещё ~0.34 ГБ экономии)")
536
- ap.add_argument("--clip-margin", type=float, default=0.0,
537
- help="подрезать строку, только если ошибка падает больше чем на эту долю "
538
- "(0.03 = на 3%%); иначе оставить absmax")
539
- ap.add_argument("--select", default="names", choices=("names", "structure", "both"),
540
- help="как отбирать слои: names — по списку подстрок (как раньше); "
541
- "structure — по повторяемости в стопке блоков, без имён; "
542
- "both — оба условия сразу")
543
- ap.add_argument("--drop-prefixes", default="",
544
- help="префиксы через запятую: такие тензоры НЕ переносить в выход "
545
- "вовсе (для LTX: vae.,audio_vae.,vocoder.,text_embedding_projection. "
546
- "— компоненты лежат отдельными файлами, дубли не нужны)")
547
- ap.add_argument("--passthrough-dtype", default="", choices=("", "bf16"),
548
- help="во что кастовать НЕквантуемые тензоры (для fp32-исходников "
549
- "вроде HiDream-O1: без этого эмбеддинги уедут в выход в fp32)")
550
- a = ap.parse_args()
551
- convert(a.src, a.dst, a.groupsize, a.device, a.dry_run,
552
- not a.no_search_scale, a.objective, -128 if a.asymmetric else -127,
553
- a.sign_aware, a.ls_refit, a.calib, a.calib_alpha, a.search_min,
554
- a.clip_margin, a.quant_adaln, a.skip_connectors, a.select,
555
- tuple(p for p in a.drop_prefixes.split(",") if p),
556
- a.passthrough_dtype)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/mm_profile_main_quant.py DELETED
@@ -1,143 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """Profile sampled INT8 convrot reconstruction error for all MiniMax-H3 DiT weights.
3
-
4
- The profiler keeps every input column (so Hadamard rotation is exact) but samples
5
- output rows deterministically. It is intended for choosing a small BF16 island;
6
- the final converter still quantizes every retained INT8 tensor in full.
7
- """
8
- import argparse
9
- import json
10
- import os
11
- import sys
12
- import time
13
-
14
- import torch
15
-
16
- SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
17
- ROOT = os.environ.get("MINIMAX_H3_ROOT", os.getcwd())
18
- TOOLS = SCRIPT_DIR
19
- sys.path.insert(0, TOOLS)
20
-
21
- from convert_int8_convrot import build_ratios, quantize_search # noqa: E402
22
- from mm_quantize_lean import ShardReader # noqa: E402
23
- from comfy_kitchen.backends.eager.quantization import _build_hadamard, _rotate_weight # noqa: E402
24
-
25
- sys.stdout.reconfigure(encoding="utf-8")
26
- torch.set_grad_enabled(False)
27
-
28
-
29
- SHAPES = {
30
- "attn.qkv_proj": (21504, 5376),
31
- "attn.out_proj": (5376, 7168),
32
- "mlp.fc1": (28672, 5376),
33
- "mlp.fc2": (5376, 14336),
34
- }
35
-
36
-
37
- def source_weight(reader, block, kind):
38
- prefix = f"transformer_blocks.{block}"
39
- if kind == "attn.qkv_proj":
40
- return torch.cat([reader.get_tensor(f"{prefix}.attn.to_{part}.weight") for part in "qkv"], dim=0)
41
- if kind == "attn.out_proj":
42
- return reader.get_tensor(f"{prefix}.attn.to_out.0.weight")
43
- if kind == "mlp.fc1":
44
- weight = reader.get_tensor(f"{prefix}.ff.net.0.proj.weight")
45
- half = weight.shape[0] // 2
46
- return torch.cat((weight[half:], weight[:half]), dim=0)
47
- if kind == "mlp.fc2":
48
- return reader.get_tensor(f"{prefix}.ff.net.2.weight")
49
- raise ValueError(kind)
50
-
51
-
52
- def sampled_rows(weight, count):
53
- if weight.shape[0] <= count:
54
- return weight
55
- index = torch.linspace(0, weight.shape[0] - 1, count, dtype=torch.float64).round().long()
56
- return weight.index_select(0, index)
57
-
58
-
59
- def profile(weight, groupsize, rows, ratios, device, clip_margin):
60
- sample = sampled_rows(weight, rows).to(device=device, dtype=torch.float32)
61
- hadamard = _build_hadamard(groupsize, device=sample.device, dtype=sample.dtype)
62
- rotated = _rotate_weight(sample, hadamard, groupsize)
63
- absmax = rotated.abs().amax(dim=1, keepdim=True).clamp(min=1e-12)
64
- scale0 = absmax / 127.0
65
- q0 = torch.round(rotated / scale0).clamp(-127, 127)
66
- err0 = ((q0 * scale0 - rotated).norm() / rotated.norm()).item()
67
- q, scale = quantize_search(sample, groupsize, ratios, clip_margin=clip_margin)
68
- err = ((q.float() * scale - rotated).norm() / rotated.norm()).item()
69
- clipped = (scale < scale0 * 0.9999).float().mean().item()
70
- del sample, hadamard, rotated, absmax, scale0, q0, q, scale
71
- return err0, err, clipped
72
-
73
-
74
- def main():
75
- ap = argparse.ArgumentParser()
76
- ap.add_argument("variant", choices=("fl2va", "ref2va"))
77
- ap.add_argument("--src", default="", help="raw diffusers transformer directory")
78
- ap.add_argument("--device", default="cuda:1")
79
- ap.add_argument("--rows", type=int, default=192)
80
- ap.add_argument("--blocks", type=int, default=50,
81
- help="число первых блоков; только для короткой проверки профайлера")
82
- ap.add_argument("--groupsize", type=int, choices=(16, 64, 256), default=256)
83
- ap.add_argument("--search-min", type=float, default=0.80)
84
- ap.add_argument("--clip-margin", type=float, default=0.05)
85
- ap.add_argument("--out", default="")
86
- args = ap.parse_args()
87
-
88
- source = args.src or os.path.join(ROOT, "HF",
89
- "transformer" if args.variant == "fl2va" else "transformer_ref")
90
- out = args.out or os.path.join(ROOT, "comfy_headless", f"mm_quant_profile_{args.variant}.json")
91
- ratios = build_ratios(args.search_min)
92
- results = []
93
- started = time.time()
94
- print(f"source={source}\ndevice={args.device} rows={args.rows} g={args.groupsize}\nout={out}", flush=True)
95
-
96
- with ShardReader(source) as reader:
97
- for block in range(args.blocks):
98
- block_started = time.time()
99
- for kind, shape in SHAPES.items():
100
- weight = source_weight(reader, block, kind)
101
- if tuple(weight.shape) != shape:
102
- raise RuntimeError(f"blocks.{block}.{kind}: {tuple(weight.shape)} != {shape}")
103
- err0, err, clipped = profile(weight, args.groupsize, args.rows, ratios,
104
- args.device, args.clip_margin)
105
- count = weight.numel()
106
- out_features = weight.shape[0]
107
- int8_bytes = count + out_features * 4 + len(json.dumps({
108
- "format": "int8_tensorwise", "convrot": True,
109
- "convrot_groupsize": args.groupsize}).encode("utf-8"))
110
- bf16_delta = count * 2 - int8_bytes
111
- results.append({
112
- "layer": f"blocks.{block}.{kind}",
113
- "block": block,
114
- "kind": kind,
115
- "shape": list(weight.shape),
116
- "sample_rows": min(args.rows, weight.shape[0]),
117
- "absmax_error": err0,
118
- "searched_error": err,
119
- "improvement": err0 - err,
120
- "clipped_row_fraction": clipped,
121
- "bf16_delta_bytes": bf16_delta,
122
- })
123
- del weight
124
- print(f"block {block:02d}/49: {time.time() - block_started:.1f}s, total {time.time() - started:.0f}s",
125
- flush=True)
126
-
127
- payload = {
128
- "variant": args.variant,
129
- "source": source,
130
- "groupsize": args.groupsize,
131
- "rows": args.rows,
132
- "search_min": args.search_min,
133
- "clip_margin": args.clip_margin,
134
- "elapsed_seconds": time.time() - started,
135
- "layers": results,
136
- }
137
- with open(out, "w", encoding="utf-8") as stream:
138
- json.dump(payload, stream, ensure_ascii=False, indent=2)
139
- print(f"готово: {out}; слоёв={len(results)}, {time.time() - started:.0f}s", flush=True)
140
-
141
-
142
- if __name__ == "__main__":
143
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/mm_quantize_lean.py DELETED
@@ -1,562 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """Build MiniMax-H3 INT8 lean-convrot + high-precision adaLN curve.
3
-
4
- The input is the original diffusers transformer/transformer_ref directory.
5
- Q/K/V packing and fc1 ordering are converted while streaming, so the script
6
- does not need a 61.7 GiB merged BF16 file or a 20-44 GiB tensor dictionary in
7
- RAM.
8
- """
9
- import argparse
10
- import json
11
- import math
12
- import os
13
- import re
14
- import struct
15
- import sys
16
- import time
17
-
18
- import torch
19
- import torch.nn.functional as F
20
- from safetensors import safe_open
21
-
22
-
23
- SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
24
- ROOT = os.environ.get("MINIMAX_H3_ROOT", os.getcwd())
25
- HF = os.path.join(ROOT, "HF")
26
- COMFYORG = os.path.join(ROOT, "comfyorg")
27
- TOOLS = SCRIPT_DIR
28
- MODELS = os.path.join(ROOT, "output")
29
-
30
- sys.path.insert(0, TOOLS)
31
- from convert_int8_convrot import build_ratios, quantize_search # noqa: E402
32
-
33
- sys.stdout.reconfigure(encoding="utf-8")
34
- torch.set_grad_enabled(False)
35
-
36
-
37
- DTYPE_BYTES = {"F64": 8, "F32": 4, "F16": 2, "BF16": 2, "I64": 8,
38
- "I32": 4, "I16": 2, "I8": 1, "U8": 1, "BOOL": 1}
39
- TORCH_TO_ST = {torch.float64: "F64", torch.float32: "F32", torch.float16: "F16",
40
- torch.bfloat16: "BF16", torch.int64: "I64", torch.int32: "I32",
41
- torch.int16: "I16", torch.int8: "I8", torch.uint8: "U8",
42
- torch.bool: "BOOL"}
43
- def quant_blob(groupsize):
44
- return json.dumps({"format": "int8_tensorwise", "convrot": True,
45
- "convrot_groupsize": groupsize}).encode("utf-8")
46
-
47
-
48
- def block_layers(blocks, suffix):
49
- return tuple(f"blocks.{block}.{suffix}" for block in blocks)
50
-
51
-
52
- # Deterministic 64-row/full-column FL2VA audit. Attention output projections
53
- # are both the most error-prone family and the cheapest useful BF16 upgrade.
54
- OUT_PROJ_ERR_GE_105 = (2, 1, 0, 3, 5, 7, 15, 38, 49, 6, 9, 45, 4, 19)
55
- OUT_PROJ_ERR_GE_100 = OUT_PROJ_ERR_GE_105 + (14, 43, 8, 17, 10, 11, 47, 27, 44, 12, 13, 46, 20)
56
- TOP_FC2 = (49, 45, 39, 29, 44)
57
-
58
- QUALITY_PROFILES = {
59
- "compact": (),
60
- # ~20.39 GiB with dynamic rank16.
61
- "quality20": block_layers(OUT_PROJ_ERR_GE_105, "attn.out_proj")
62
- + block_layers(TOP_FC2[:1], "mlp.fc2"),
63
- # ~21.00 GiB: recommended first 24 GiB load/generation candidate.
64
- "quality21": block_layers(OUT_PROJ_ERR_GE_100, "attn.out_proj")
65
- + block_layers(TOP_FC2[:3], "mlp.fc2"),
66
- # ~21.97 GiB: only after quality21 demonstrates enough activation headroom.
67
- "quality22": block_layers(range(50), "attn.out_proj")
68
- + block_layers(TOP_FC2, "mlp.fc2"),
69
- }
70
- def tensor_bytes(dtype, shape):
71
- total = DTYPE_BYTES[dtype]
72
- for dim in shape:
73
- total *= dim
74
- return total
75
-
76
-
77
- class ShardReader:
78
- def __init__(self, path):
79
- self.path = os.path.abspath(path)
80
- indexes = [name for name in os.listdir(self.path) if name.endswith(".safetensors.index.json")]
81
- if len(indexes) != 1:
82
- raise SystemExit(f"ожидался один *.safetensors.index.json в {self.path}, найдено {len(indexes)}")
83
- index = json.load(open(os.path.join(self.path, indexes[0]), encoding="utf-8"))
84
- self.weight_map = index["weight_map"]
85
- self._files = {}
86
- for name in sorted(set(self.weight_map.values())):
87
- full = os.path.join(self.path, name)
88
- if not os.path.isfile(full):
89
- raise SystemExit(f"нет шарда из index: {full}")
90
- self._files[name] = safe_open(full, framework="pt")
91
-
92
- def keys(self):
93
- return list(self.weight_map)
94
-
95
- def get_slice(self, key):
96
- return self._files[self.weight_map[key]].get_slice(key)
97
-
98
- def get_tensor(self, key):
99
- return self._files[self.weight_map[key]].get_tensor(key)
100
-
101
- def close(self):
102
- self._files.clear()
103
-
104
- def __enter__(self):
105
- return self
106
-
107
- def __exit__(self, exc_type, exc, traceback):
108
- self.close()
109
-
110
-
111
- def validate_source(reader):
112
- keys = set(reader.keys())
113
- if len(keys) != 638:
114
- raise SystemExit(f"неожиданное число исходных тензоров: {len(keys)}, ожидалось 638")
115
- shapes = {
116
- "transformer_blocks.0.attn.to_q.weight": [7168, 5376],
117
- "transformer_blocks.0.attn.to_k.weight": [7168, 5376],
118
- "transformer_blocks.0.attn.to_v.weight": [7168, 5376],
119
- "transformer_blocks.0.attn.to_out.0.weight": [5376, 7168],
120
- "transformer_blocks.0.ff.net.0.proj.weight": [28672, 5376],
121
- "transformer_blocks.0.ff.net.2.weight": [5376, 14336],
122
- "transformer_blocks.0.adaln_proj.linear.weight": [96768, 2688],
123
- "norm_out.linear.weight": [10752, 2688],
124
- "time_embedder.linear_1.weight": [5376, 256],
125
- "time_embedder.linear_2.weight": [2688, 5376],
126
- }
127
- for key, expected_shape in shapes.items():
128
- if key not in keys:
129
- raise SystemExit(f"в источнике нет {key}")
130
- actual = reader.get_slice(key).get_shape()
131
- if actual != expected_shape:
132
- raise SystemExit(f"форма {key}: {actual}, ожидалось {expected_shape}")
133
- print("источник diffusers: 638 тензоров, 50 DiT-блоков, формы верны")
134
-
135
-
136
- def task(kind, entries, source=None, prefix=None, **extra):
137
- out = {"kind": kind, "entries": entries, "source": source, "prefix": prefix}
138
- out.update(extra)
139
- return out
140
-
141
-
142
- def build_tasks(reader, rank, grid, time_mode="dynamic", bf16_layers=(), groupsize=256):
143
- tasks = []
144
- used = set()
145
- quant_count = curve_count = 0
146
- bf16_layers = set(bf16_layers)
147
- seen_main_layers = set()
148
-
149
- def add_copy(out_key, src_key, kind="copy"):
150
- sl = reader.get_slice(src_key)
151
- tasks.append(task(kind, [(out_key, sl.get_dtype(), sl.get_shape())], source=src_key))
152
- used.add(src_key)
153
-
154
- def add_qkv(out_key, src_prefix, quantized, groupsize=256):
155
- sources = tuple(src_prefix + f".attn.to_{name}.weight" for name in ("q", "k", "v"))
156
- shape = reader.get_slice(sources[0]).get_shape()
157
- out_shape = [shape[0] * 3, shape[1]]
158
- if quantized:
159
- layer = out_key[:-len(".weight")]
160
- blob = quant_blob(groupsize)
161
- entries = [(out_key, "I8", out_shape),
162
- (layer + ".weight_scale", "F32", [out_shape[0], 1]),
163
- (layer + ".comfy_quant", "U8", [len(blob)])]
164
- tasks.append(task("quant_qkv", entries, source=sources, groupsize=groupsize, blob=blob))
165
- else:
166
- tasks.append(task("copy_qkv", [(out_key, reader.get_slice(sources[0]).get_dtype(), out_shape)], source=sources))
167
- used.update(sources)
168
-
169
- def add_quant(out_key, src_key, kind="quant", groupsize=256):
170
- nonlocal quant_count
171
- shape = reader.get_slice(src_key).get_shape()
172
- layer = out_key[:-len(".weight")]
173
- blob = quant_blob(groupsize)
174
- entries = [(out_key, "I8", shape),
175
- (layer + ".weight_scale", "F32", [shape[0], 1]),
176
- (layer + ".comfy_quant", "U8", [len(blob)])]
177
- tasks.append(task(kind, entries, source=src_key, groupsize=groupsize, blob=blob))
178
- used.add(src_key)
179
- quant_count += 1
180
-
181
- def add_main_qkv(out_key, src_prefix):
182
- nonlocal quant_count
183
- layer = out_key[:-len(".weight")]
184
- seen_main_layers.add(layer)
185
- quantized = layer not in bf16_layers
186
- add_qkv(out_key, src_prefix, quantized=quantized, groupsize=groupsize)
187
- if quantized:
188
- quant_count += 1
189
-
190
- def add_main(out_key, src_key, quant_kind="quant", copy_kind="copy"):
191
- layer = out_key[:-len(".weight")]
192
- seen_main_layers.add(layer)
193
- if layer in bf16_layers:
194
- add_copy(out_key, src_key, kind=copy_kind)
195
- else:
196
- add_quant(out_key, src_key, kind=quant_kind, groupsize=groupsize)
197
-
198
- def add_curve(out_prefix, src_prefix):
199
- nonlocal curve_count
200
- weight_key, bias_key = src_prefix + ".weight", src_prefix + ".bias"
201
- out_features = reader.get_slice(weight_key).get_shape()[0]
202
- tasks.append(task("curve", [(out_prefix + ".weight", "F32", [out_features, rank]),
203
- (out_prefix + ".bias", "F32", [out_features])],
204
- source=src_prefix, prefix=out_prefix))
205
- used.update((weight_key, bias_key))
206
- curve_count += 1
207
-
208
- for block in range(50):
209
- src = f"transformer_blocks.{block}"
210
- out = f"blocks.{block}"
211
- add_main_qkv(out + ".attn.qkv_proj.weight", src)
212
- add_main(out + ".attn.out_proj.weight", src + ".attn.to_out.0.weight")
213
- add_copy(out + ".attn.q_norm.weight", src + ".attn.norm_q.weight")
214
- add_copy(out + ".attn.k_norm.weight", src + ".attn.norm_k.weight")
215
- add_main(out + ".mlp.fc1.weight", src + ".ff.net.0.proj.weight",
216
- quant_kind="quant_fc1", copy_kind="copy_fc1")
217
- add_main(out + ".mlp.fc2.weight", src + ".ff.net.2.weight")
218
- add_copy(out + ".norm1.weight", src + ".norm1.weight")
219
- add_copy(out + ".norm2.weight", src + ".norm2.weight")
220
- add_curve(out + ".adaln_proj.linear", src + ".adaln_proj.linear")
221
-
222
- for block in range(2):
223
- src = f"token_refiner.refiner_blocks.{block}"
224
- out = f"token_refiner.blocks.{block}"
225
- add_qkv(out + ".attn.qkv_proj.weight", src, quantized=False)
226
- add_copy(out + ".attn.out_proj.weight", src + ".attn.to_out.0.weight")
227
- add_copy(out + ".attn.q_norm.weight", src + ".attn.norm_q.weight")
228
- add_copy(out + ".attn.k_norm.weight", src + ".attn.norm_k.weight")
229
- add_copy(out + ".mlp.fc1.weight", src + ".ff.net.0.proj.weight", kind="copy_fc1")
230
- add_copy(out + ".mlp.fc2.weight", src + ".ff.net.2.weight")
231
- add_copy(out + ".norm1.weight", src + ".norm1.weight")
232
- add_copy(out + ".norm2.weight", src + ".norm2.weight")
233
-
234
- add_copy("token_refiner.final_norm.weight", "token_refiner.final_norm.weight")
235
- for out_key, src_key in (
236
- ("condition_proj.weight", "context_embedder.weight"),
237
- ("condition_proj.bias", "context_embedder.bias"),
238
- ("video_patch_proj.weight", "proj_in.weight"),
239
- ("video_patch_proj.bias", "proj_in.bias"),
240
- ("audio_patch_proj.weight", "audio_proj_in.weight"),
241
- ("audio_patch_proj.bias", "audio_proj_in.bias"),
242
- ("final_layer.video_out.weight", "proj_out.weight"),
243
- ("final_layer.video_out.bias", "proj_out.bias"),
244
- ("final_layer.audio_out.weight", "audio_proj_out.weight"),
245
- ("final_layer.audio_out.bias", "audio_proj_out.bias"),
246
- ("final_layer.norm.weight", "norm_out.norm.weight"),
247
- ):
248
- add_copy(out_key, src_key)
249
- add_curve("final_layer.adaln_proj.linear", "norm_out.linear")
250
-
251
- time_pairs = (
252
- ("time_embedder.proj_in.weight", "time_embedder.linear_1.weight"),
253
- ("time_embedder.proj_in.bias", "time_embedder.linear_1.bias"),
254
- ("time_embedder.proj_out.weight", "time_embedder.linear_2.weight"),
255
- ("time_embedder.proj_out.bias", "time_embedder.linear_2.bias"),
256
- )
257
- if time_mode == "dynamic":
258
- for out_key, src_key in time_pairs:
259
- add_copy(out_key, src_key)
260
- tasks.append(task("basis", [("adaln_curve_basis", "F32", [2688, rank]),
261
- ("adaln_curve_mean", "F32", [2688])]))
262
- elif time_mode == "table":
263
- used.update(src for _, src in time_pairs)
264
- tasks.append(task("table", [("adaln_t_table", "F32", [grid, rank])]))
265
- else:
266
- raise ValueError(time_mode)
267
- missing, extra = set(reader.keys()) - used, used - set(reader.keys())
268
- if missing or extra:
269
- raise SystemExit(f"маппинг diffusers неполон: не использовано {len(missing)}, неизвестных {len(extra)}; {sorted(missing)[:3]}")
270
- tasks.append(task("rope", [("rope.inv_freq", "F32", [16])]))
271
- unknown_bf16 = bf16_layers - seen_main_layers
272
- if unknown_bf16:
273
- raise SystemExit(f"неизвестные BF16-слои: {sorted(unknown_bf16)}")
274
- expected_quant = 200 - len(bf16_layers)
275
- if quant_count != expected_quant or curve_count != 51:
276
- raise SystemExit(f"план неполон: INT8={quant_count}/{expected_quant}, adaLN={curve_count}/51")
277
- entries = [entry for item in tasks for entry in item["entries"]]
278
- expected_entries = (937 if time_mode == "dynamic" else 932) - 2 * len(bf16_layers)
279
- if len(entries) != expected_entries or len({entry[0] for entry in entries}) != len(entries):
280
- raise SystemExit(f"неверный выходной keyset: {len(entries)} вместо {expected_entries}")
281
- return tasks
282
-
283
-
284
- def inspect_reference(tasks, path, time_mode="dynamic", bf16_layers=()):
285
- planned = {key: (dtype, shape) for item in tasks for key, dtype, shape in item["entries"]}
286
- bf16_layers = set(bf16_layers)
287
- with safe_open(path, framework="pt") as ref:
288
- ref_keys = set(ref.keys())
289
- expected_missing = set()
290
- for layer in bf16_layers:
291
- expected_missing.update((layer + ".weight_scale", layer + ".comfy_quant"))
292
- expected_extra = set()
293
- if time_mode == "dynamic":
294
- expected_missing.add("adaln_t_table")
295
- expected_extra.update({"adaln_curve_basis", "adaln_curve_mean",
296
- "time_embedder.proj_in.weight", "time_embedder.proj_in.bias",
297
- "time_embedder.proj_out.weight", "time_embedder.proj_out.bias"})
298
- actual_missing = ref_keys - set(planned)
299
- actual_extra = set(planned) - ref_keys
300
- if actual_missing != expected_missing or actual_extra != expected_extra:
301
- raise SystemExit("keyset расходится с ожидаемым расширением pruned-формата: "
302
- f"missing={sorted(actual_missing ^ expected_missing)[:5]} "
303
- f"extra={sorted(actual_extra ^ expected_extra)[:5]}")
304
- mismatches = []
305
- for key, (dtype, shape) in planned.items():
306
- if key not in ref_keys or "adaln_proj.linear" in key or key == "adaln_t_table":
307
- continue
308
- sl = ref.get_slice(key)
309
- layer = key[:-len(".weight")] if key.endswith(".weight") else ""
310
- dtype_mismatch = sl.get_dtype() != dtype and layer not in bf16_layers
311
- if dtype_mismatch or sl.get_shape() != shape:
312
- mismatches.append((key, dtype, shape, sl.get_dtype(), sl.get_shape()))
313
- if mismatches:
314
- raise SystemExit(f"формат расходится с pruned-эталоном вне curve-части: {mismatches[:3]}")
315
- print(f"эталон comfyorg-pruned: общие формы совпадают; dynamic={time_mode == 'dynamic'}, "
316
- f"BF16 main={len(bf16_layers)}")
317
-
318
-
319
- def make_curve(reader, rank, grid, device):
320
- w1 = reader.get_tensor("time_embedder.linear_1.weight").to(device=device, dtype=torch.float32)
321
- b1 = reader.get_tensor("time_embedder.linear_1.bias").to(device=device, dtype=torch.float32)
322
- w2 = reader.get_tensor("time_embedder.linear_2.weight").to(device=device, dtype=torch.float32)
323
- b2 = reader.get_tensor("time_embedder.linear_2.bias").to(device=device, dtype=torch.float32)
324
- half = 128
325
- freqs = torch.exp(-math.log(10000.0) * torch.arange(half, dtype=torch.float32, device=device) / half)
326
-
327
- def evaluate(t):
328
- args = t[:, None] * freqs[None]
329
- emb = torch.cat((torch.cos(args), torch.sin(args)), dim=1)
330
- return F.silu(F.linear(F.silu(F.linear(emb, w1, b1)), w2, b2))
331
-
332
- # 2049 points are sufficient to determine the shared subspace. The output
333
- # table may be denser: Comfy derives the grid length from table.shape[0].
334
- fit_t = torch.linspace(0.0, 1.0, 2049, dtype=torch.float32, device=device)
335
- curve = evaluate(fit_t)
336
- # Evaluate the real runtime formula in F32, then solve the tiny singular
337
- # directions in F64 on CPU. F32 SVD bottoms out at ~1.09e-6 relative error
338
- # for rank 16; the F64 solve followed by F32 storage reaches ~2.72e-7.
339
- curve_cpu = curve.cpu()
340
- curve64 = curve_cpu.double()
341
- mean64 = curve64.mean(0)
342
- centered64 = curve64 - mean64
343
- _, singular, vh = torch.linalg.svd(centered64, full_matrices=False)
344
- basis64 = vh[:rank].T.contiguous()
345
- basis = basis64.to(device=device, dtype=torch.float32)
346
- mean = mean64.to(device=device, dtype=torch.float32)
347
- table_t = torch.linspace(0.0, 1.0, grid, dtype=torch.float32, device=device)
348
- table_curve = evaluate(table_t)
349
- table = ((table_curve.cpu().double() - mean64) @ basis64).to(torch.float32)
350
- energy = (singular[:rank].square().sum() / singular.square().sum()).item()
351
- reconstruction = table @ basis.cpu().T + mean.cpu()
352
- grid_error = ((reconstruction - table_curve.cpu()).norm() / table_curve.cpu().norm()).item()
353
- # Real runtime values usually lie between table rows. Midpoints provide a
354
- # sensitive deterministic check of the linear interpolation error.
355
- mid_t = (torch.arange(grid - 1, dtype=torch.float32, device=device) + 0.5) / (grid - 1)
356
- mid_curve = evaluate(mid_t).cpu()
357
- mid_coords = (table[:-1] + table[1:]) * 0.5
358
- mid_reconstruction = mid_coords @ basis.cpu().T + mean.cpu()
359
- midpoint_error = ((mid_reconstruction - mid_curve).norm() / mid_curve.norm()).item()
360
- midpoint_max = ((mid_reconstruction - mid_curve).norm(dim=1) /
361
- mid_curve.norm(dim=1).clamp(min=1e-12)).max().item()
362
- projection = torch.cat((basis, mean[:, None]), dim=1)
363
- del w1, b1, w2, b2, curve, curve_cpu, curve64, centered64, vh, basis64
364
- del table_curve, reconstruction, mid_curve, mid_reconstruction, mid_coords
365
- print(f"adaLN curve: rank={rank}, grid={grid}, энергия={energy:.10f}, "
366
- f"grid err={grid_error:.8%}, midpoint err={midpoint_error:.8%}, "
367
- f"max={midpoint_max:.8%}")
368
- return projection, table
369
-
370
-
371
- def make_header(tasks):
372
- header = {}
373
- offset = 0
374
- for item in tasks:
375
- for key, dtype, shape in item["entries"]:
376
- size = tensor_bytes(dtype, shape)
377
- header[key] = {"dtype": dtype, "shape": shape, "data_offsets": [offset, offset + size]}
378
- offset += size
379
- raw = json.dumps(header, separators=(",", ":")).encode("utf-8")
380
- raw += b" " * ((-len(raw)) % 8)
381
- return raw, offset
382
-
383
-
384
- def raw_bytes(tensor):
385
- tensor = tensor.detach().to(device="cpu").contiguous()
386
- if tensor.dtype not in TORCH_TO_ST:
387
- raise TypeError(f"неподдерживаемый dtype {tensor.dtype}")
388
- return tensor.view(torch.uint8).numpy().tobytes()
389
-
390
-
391
- def write_tensor(stream, tensor, expected):
392
- key, dtype, shape = expected
393
- actual_dtype = TORCH_TO_ST.get(tensor.dtype)
394
- if actual_dtype != dtype or list(tensor.shape) != shape:
395
- raise RuntimeError(f"{key}: получили {actual_dtype} {list(tensor.shape)}, ожидалось {dtype} {shape}")
396
- blob = raw_bytes(tensor)
397
- expected_bytes = tensor_bytes(dtype, shape)
398
- if len(blob) != expected_bytes:
399
- raise RuntimeError(f"{key}: байт {len(blob)}, ожидалось {expected_bytes}")
400
- stream.write(blob)
401
-
402
-
403
- def build(reader, tasks, reference, dst, rank, grid, device, search_min, clip_margin, overwrite):
404
- partial = dst + ".partial"
405
- if (os.path.exists(dst) or os.path.exists(partial)) and not overwrite:
406
- raise SystemExit(f"выход или partial уже существует; используйте --overwrite: {dst}")
407
- os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
408
- header, data_size = make_header(tasks)
409
- projection, table = make_curve(reader, rank, grid, device)
410
- ratios = build_ratios(search_min)
411
- t0 = time.time()
412
- quant_done = curve_done = 0
413
- expected_quant = sum(item["kind"] in ("quant", "quant_fc1", "quant_qkv") for item in tasks)
414
-
415
- def report_block(key):
416
- match = re.fullmatch(r"blocks\.(\d+)\.mlp\.fc2\.weight", key)
417
- if match:
418
- print(f" DiT block {int(match.group(1)):02d}/49: INT8 {quant_done}/{expected_quant}, "
419
- f"{time.time() - t0:.0f} c", flush=True)
420
-
421
- with safe_open(reference, framework="pt") as ref, open(partial, "wb") as stream:
422
- stream.write(struct.pack("<Q", len(header)))
423
- stream.write(header)
424
- for item in tasks:
425
- if item["kind"] == "copy":
426
- tensor = reader.get_tensor(item["source"])
427
- write_tensor(stream, tensor, item["entries"][0])
428
- del tensor
429
- report_block(item["entries"][0][0])
430
- elif item["kind"] in ("copy_qkv", "quant_qkv"):
431
- weight = torch.cat([reader.get_tensor(key) for key in item["source"]], dim=0)
432
- if item["kind"] == "copy_qkv":
433
- write_tensor(stream, weight, item["entries"][0])
434
- del weight
435
- continue
436
- weight = weight.to(device=device, dtype=torch.float32)
437
- qdata, scale = quantize_search(weight, item["groupsize"], ratios, clip_margin=clip_margin)
438
- write_tensor(stream, qdata.cpu(), item["entries"][0])
439
- write_tensor(stream, scale.cpu(), item["entries"][1])
440
- blob = torch.tensor(list(item["blob"]), dtype=torch.uint8)
441
- write_tensor(stream, blob, item["entries"][2])
442
- quant_done += 1
443
- del weight, qdata, scale, blob
444
- elif item["kind"] in ("copy_fc1", "quant_fc1"):
445
- weight = reader.get_tensor(item["source"])
446
- half = weight.shape[0] // 2
447
- weight = torch.cat((weight[half:], weight[:half]), dim=0)
448
- if item["kind"] == "copy_fc1":
449
- write_tensor(stream, weight, item["entries"][0])
450
- del weight
451
- continue
452
- weight = weight.to(device=device, dtype=torch.float32)
453
- qdata, scale = quantize_search(weight, item["groupsize"], ratios, clip_margin=clip_margin)
454
- write_tensor(stream, qdata.cpu(), item["entries"][0])
455
- write_tensor(stream, scale.cpu(), item["entries"][1])
456
- blob = torch.tensor(list(item["blob"]), dtype=torch.uint8)
457
- write_tensor(stream, blob, item["entries"][2])
458
- quant_done += 1
459
- del weight, qdata, scale, blob
460
- elif item["kind"] == "quant":
461
- weight = reader.get_tensor(item["source"]).to(device=device, dtype=torch.float32)
462
- qdata, scale = quantize_search(weight, item["groupsize"], ratios, clip_margin=clip_margin)
463
- write_tensor(stream, qdata.cpu(), item["entries"][0])
464
- write_tensor(stream, scale.cpu(), item["entries"][1])
465
- blob = torch.tensor(list(item["blob"]), dtype=torch.uint8)
466
- write_tensor(stream, blob, item["entries"][2])
467
- quant_done += 1
468
- del weight, qdata, scale, blob
469
- report_block(item["entries"][0][0])
470
- elif item["kind"] == "curve":
471
- source = item["source"]
472
- weight = reader.get_tensor(source + ".weight").to(device=device, dtype=torch.float32)
473
- bias = reader.get_tensor(source + ".bias").to(device=device, dtype=torch.float32)
474
- packed = weight @ projection
475
- curve_weight = packed[:, :rank]
476
- curve_bias = bias + packed[:, rank]
477
- write_tensor(stream, curve_weight.cpu(), item["entries"][0])
478
- write_tensor(stream, curve_bias.cpu(), item["entries"][1])
479
- curve_done += 1
480
- del weight, bias, packed, curve_weight, curve_bias
481
- elif item["kind"] == "table":
482
- write_tensor(stream, table.cpu(), item["entries"][0])
483
- elif item["kind"] == "basis":
484
- write_tensor(stream, projection[:, :rank].cpu(), item["entries"][0])
485
- write_tensor(stream, projection[:, rank].cpu(), item["entries"][1])
486
- elif item["kind"] == "rope":
487
- write_tensor(stream, ref.get_tensor("rope.inv_freq"), item["entries"][0])
488
- else:
489
- raise RuntimeError(item["kind"])
490
- expected_size = 8 + len(header) + data_size
491
- actual_size = os.path.getsize(partial)
492
- if actual_size != expected_size:
493
- raise RuntimeError(f"partial имеет {actual_size} байт, ожидалось {expected_size}")
494
- with safe_open(partial, framework="pt") as result:
495
- expected_keys = sum(len(item["entries"]) for item in tasks)
496
- if len(result.keys()) != expected_keys:
497
- raise RuntimeError(f"safetensors открылся, но ключей {len(result.keys())}, ожидалось {expected_keys}")
498
- os.replace(partial, dst)
499
- print(f"готово: {dst}\n {actual_size / 2**30:.3f} GiB, INT8={quant_done}, curve={curve_done}, {time.time() - t0:.0f} c")
500
-
501
-
502
- def defaults(variant, rank, grid, time_mode, profile):
503
- source = os.path.join(HF, "transformer" if variant == "fl2va" else "transformer_ref")
504
- reference = os.path.join(COMFYORG, f"minimax-h3-{variant}-pruned-int8-convrot-comfyorg.safetensors")
505
- curve_tag = f"table-k{rank}-g{grid}" if time_mode == "table" else f"dynamic-k{rank}"
506
- dst = os.path.join(MODELS, f"minimax-h3-{variant}-int8-lean-convrot-{curve_tag}-{profile}.safetensors")
507
- return source, reference, dst
508
-
509
-
510
- def main():
511
- ap = argparse.ArgumentParser()
512
- ap.add_argument("variant", choices=("fl2va", "ref2va"))
513
- ap.add_argument("--rank", type=int, default=16, choices=(8, 16, 32, 64))
514
- ap.add_argument("--time-mode", choices=("dynamic", "table"), default="dynamic",
515
- help="dynamic сохраняет исходный F32 time_embedder; table совместим с pruned")
516
- ap.add_argument("--grid", type=int, default=4097,
517
- help="число строк table или плотность численной проверки dynamic basis")
518
- ap.add_argument("--profile", choices=tuple(QUALITY_PROFILES), default="quality21")
519
- ap.add_argument("--bf16-layer", action="append", default=[],
520
- help="дополнительный base-name основного слоя, например blocks.48.mlp.fc2")
521
- ap.add_argument("--groupsize", type=int, choices=(16, 64, 256), default=256)
522
- ap.add_argument("--src", default="")
523
- ap.add_argument("--reference", default="")
524
- ap.add_argument("--dst", default="")
525
- ap.add_argument("--device", default="cuda:0")
526
- ap.add_argument("--search-min", type=float, default=0.80)
527
- ap.add_argument("--clip-margin", type=float, default=0.05)
528
- ap.add_argument("--dry-run", action="store_true")
529
- ap.add_argument("--overwrite", action="store_true")
530
- args = ap.parse_args()
531
- if args.grid < 2:
532
- ap.error("--grid должен быть не меньше 2")
533
-
534
- bf16_layers = set(QUALITY_PROFILES[args.profile]) | set(args.bf16_layer)
535
- source, reference, dst = defaults(args.variant, args.rank, args.grid, args.time_mode, args.profile)
536
- source = args.src or source
537
- reference = args.reference or reference
538
- dst = args.dst or dst
539
- print(f"variant={args.variant} rank={args.rank} time={args.time_mode} profile={args.profile}\n"
540
- f"BF16 main={sorted(bf16_layers)}\nsrc={source}\nreference={reference}\ndst={dst}")
541
- reader = ShardReader(source)
542
- try:
543
- validate_source(reader)
544
- tasks = build_tasks(reader, args.rank, args.grid, args.time_mode, bf16_layers, args.groupsize)
545
- inspect_reference(tasks, reference, args.time_mode, bf16_layers)
546
- _, data_size = make_header(tasks)
547
- entries = sum(len(item["entries"]) for item in tasks)
548
- quant_count = sum(item["kind"] in ("quant", "quant_fc1", "quant_qkv") for item in tasks)
549
- print(f"план: {entries} тензоров, {quant_count} INT8 g{args.groupsize}, "
550
- f"BF16 main={len(bf16_layers)}, 51 adaLN curve F32 k={args.rank} time={args.time_mode}; "
551
- f"данные {data_size / 2**30:.3f} GiB")
552
- if args.dry_run:
553
- print("dry-run: веса не читались целиком, GPU не использовалась, файл не создан")
554
- return
555
- build(reader, tasks, reference, dst, args.rank, args.grid, args.device, args.search_min,
556
- args.clip_margin, args.overwrite)
557
- finally:
558
- reader.close()
559
-
560
-
561
- if __name__ == "__main__":
562
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/mm_validate_dynamic_built.py DELETED
@@ -1,137 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """Validate a built MiniMax-H3 dynamic-basis checkpoint against raw HF weights."""
3
- import argparse
4
- import json
5
- import math
6
- import os
7
- import sys
8
-
9
- import torch
10
- import torch.nn.functional as F
11
- from safetensors import safe_open
12
-
13
- SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
14
- ROOT = os.environ.get("MINIMAX_H3_ROOT", os.getcwd())
15
- sys.path.insert(0, SCRIPT_DIR)
16
- from mm_quantize_lean import QUALITY_PROFILES, ShardReader # noqa: E402
17
-
18
- sys.stdout.reconfigure(encoding="utf-8")
19
- torch.set_grad_enabled(False)
20
-
21
-
22
- def rel(actual, expected):
23
- return ((actual - expected).norm() / expected.norm().clamp(min=1e-12)).item()
24
-
25
-
26
- def main():
27
- ap = argparse.ArgumentParser()
28
- ap.add_argument("variant", choices=("fl2va", "ref2va"))
29
- ap.add_argument("checkpoint")
30
- ap.add_argument("--src", default="", help="raw diffusers transformer directory")
31
- ap.add_argument("--profile", choices=tuple(QUALITY_PROFILES), default="quality21")
32
- ap.add_argument("--samples", type=int, default=9)
33
- args = ap.parse_args()
34
-
35
- source = args.src or os.path.join(ROOT, "HF",
36
- "transformer" if args.variant == "fl2va" else "transformer_ref")
37
- expected_bf16 = set(QUALITY_PROFILES[args.profile])
38
- results = {}
39
-
40
- with ShardReader(source) as raw, safe_open(args.checkpoint, framework="pt") as built:
41
- keys = set(built.keys())
42
- quant = [key for key in keys if key.endswith(".comfy_quant")]
43
- groups = {}
44
- for key in quant:
45
- config = json.loads(bytes(built.get_tensor(key).numpy()).decode())
46
- groups[config["convrot_groupsize"]] = groups.get(config["convrot_groupsize"], 0) + 1
47
-
48
- actual_bf16 = set()
49
- for block in range(50):
50
- for suffix in ("attn.qkv_proj", "attn.out_proj", "mlp.fc1", "mlp.fc2"):
51
- base = f"blocks.{block}.{suffix}"
52
- if built.get_slice(base + ".weight").get_dtype() == "BF16":
53
- actual_bf16.add(base)
54
- if actual_bf16 != expected_bf16:
55
- raise RuntimeError(f"BF16 profile mismatch: -{sorted(expected_bf16 - actual_bf16)} "
56
- f"+{sorted(actual_bf16 - expected_bf16)}")
57
-
58
- time_pairs = (
59
- ("time_embedder.linear_1.weight", "time_embedder.proj_in.weight"),
60
- ("time_embedder.linear_1.bias", "time_embedder.proj_in.bias"),
61
- ("time_embedder.linear_2.weight", "time_embedder.proj_out.weight"),
62
- ("time_embedder.linear_2.bias", "time_embedder.proj_out.bias"),
63
- )
64
- time_bitwise = all(torch.equal(raw.get_tensor(src), built.get_tensor(dst))
65
- for src, dst in time_pairs)
66
- if not time_bitwise:
67
- raise RuntimeError("time_embedder differs from raw HF")
68
-
69
- # All quality21 BF16 islands are out_proj or fc2 and should be exact copies.
70
- bf16_bad = []
71
- for base in sorted(actual_bf16):
72
- parts = base.split(".")
73
- block = int(parts[1])
74
- suffix = ".".join(parts[2:])
75
- if suffix == "attn.out_proj":
76
- src = f"transformer_blocks.{block}.attn.to_out.0.weight"
77
- elif suffix == "mlp.fc2":
78
- src = f"transformer_blocks.{block}.ff.net.2.weight"
79
- else:
80
- raise RuntimeError(f"validator needs mapping for {base}")
81
- if not torch.equal(raw.get_tensor(src), built.get_tensor(base + ".weight")):
82
- bf16_bad.append(base)
83
- if bf16_bad:
84
- raise RuntimeError(f"BF16 tensors differ: {bf16_bad}")
85
-
86
- w1 = built.get_tensor("time_embedder.proj_in.weight").float()
87
- b1 = built.get_tensor("time_embedder.proj_in.bias").float()
88
- w2 = built.get_tensor("time_embedder.proj_out.weight").float()
89
- b2 = built.get_tensor("time_embedder.proj_out.bias").float()
90
- basis = built.get_tensor("adaln_curve_basis").float()
91
- mean = built.get_tensor("adaln_curve_mean").float()
92
- orth_error = (basis.T @ basis - torch.eye(basis.shape[1])).abs().max().item()
93
-
94
- t = torch.linspace(0.0, 1.0, args.samples, dtype=torch.float32)
95
- half = 128
96
- freqs = torch.exp(-math.log(10000.0) * torch.arange(half, dtype=torch.float32) / half)
97
- phase = t[:, None] * freqs[None]
98
- sinusoid = torch.cat((torch.cos(phase), torch.sin(phase)), dim=1)
99
- u = F.silu(F.linear(F.silu(F.linear(sinusoid, w1, b1)), w2, b2))
100
- coords = (u - mean) @ basis
101
-
102
- curve_errors = {}
103
- for block in (0, 24, 49):
104
- src = f"transformer_blocks.{block}.adaln_proj.linear"
105
- dst = f"blocks.{block}.adaln_proj.linear"
106
- exact = F.linear(u, raw.get_tensor(src + ".weight").float(),
107
- raw.get_tensor(src + ".bias").float())
108
- approx = F.linear(coords, built.get_tensor(dst + ".weight").float(),
109
- built.get_tensor(dst + ".bias").float())
110
- curve_errors[f"block_{block}"] = rel(approx, exact)
111
- del exact, approx
112
- exact = F.linear(u, raw.get_tensor("norm_out.linear.weight").float(),
113
- raw.get_tensor("norm_out.linear.bias").float())
114
- approx = F.linear(coords, built.get_tensor("final_layer.adaln_proj.linear.weight").float(),
115
- built.get_tensor("final_layer.adaln_proj.linear.bias").float())
116
- curve_errors["final"] = rel(approx, exact)
117
-
118
- results = {
119
- "checkpoint": args.checkpoint,
120
- "bytes": os.path.getsize(args.checkpoint),
121
- "keys": len(keys),
122
- "quant": len(quant),
123
- "quant_groups": groups,
124
- "bf16_main": len(actual_bf16),
125
- "time_embedder_bitwise": time_bitwise,
126
- "bf16_main_bitwise": len(actual_bf16) - len(bf16_bad),
127
- "basis_shape": list(basis.shape),
128
- "basis_orthogonality_max_abs": orth_error,
129
- "curve_samples": args.samples,
130
- "adaln_relative_errors": curve_errors,
131
- }
132
-
133
- print(json.dumps(results, ensure_ascii=False, indent=2))
134
-
135
-
136
- if __name__ == "__main__":
137
- main()