SpXMerlin1D commited on
Commit
09ccad2
·
verified ·
1 Parent(s): 131c2ab

Upload folder using huggingface_hub

Browse files
Files changed (39) hide show
  1. adapter_stage2_best.safetensors +3 -0
  2. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__init__.py +26 -0
  3. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/__init__.cpython-311.pyc +0 -0
  4. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/__init__.cpython-313.pyc +0 -0
  5. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/adapter_clip.cpython-311.pyc +0 -0
  6. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/adapter_clip.cpython-313.pyc +0 -0
  7. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/export_cond.cpython-311.pyc +0 -0
  8. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/export_cond.cpython-313.pyc +0 -0
  9. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_dequant.cpython-311.pyc +0 -0
  10. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_dequant.cpython-313.pyc +0 -0
  11. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_qwen35.cpython-311.pyc +0 -0
  12. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_qwen35.cpython-313.pyc +0 -0
  13. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/nodes.cpython-311.pyc +0 -0
  14. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/nodes.cpython-313.pyc +0 -0
  15. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/student.cpython-311.pyc +0 -0
  16. custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/student.cpython-313.pyc +0 -0
  17. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__init__.py +3 -0
  18. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/__init__.cpython-311.pyc +0 -0
  19. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/__init__.cpython-313.pyc +0 -0
  20. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/model.cpython-311.pyc +0 -0
  21. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/model.cpython-313.pyc +0 -0
  22. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/model.py +230 -0
  23. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/model.py.bak_old +160 -0
  24. custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter_clip.py +132 -0
  25. custom_nodes/ComfyUI-MiniMaxH3-Adapter/check_alignment.py +119 -0
  26. custom_nodes/ComfyUI-MiniMaxH3-Adapter/export_adapter.py +43 -0
  27. custom_nodes/ComfyUI-MiniMaxH3-Adapter/export_cond.py +42 -0
  28. custom_nodes/ComfyUI-MiniMaxH3-Adapter/gguf_dequant.py +309 -0
  29. custom_nodes/ComfyUI-MiniMaxH3-Adapter/gguf_qwen35.py +445 -0
  30. custom_nodes/ComfyUI-MiniMaxH3-Adapter/inspect_gguf.py +66 -0
  31. custom_nodes/ComfyUI-MiniMaxH3-Adapter/nodes.py +129 -0
  32. custom_nodes/ComfyUI-MiniMaxH3-Adapter/qwen35_config.json +104 -0
  33. custom_nodes/ComfyUI-MiniMaxH3-Adapter/student.py +161 -0
  34. custom_nodes/ComfyUI-MiniMaxH3-Adapter/test_fake_clip.py +160 -0
  35. custom_nodes/ComfyUI-MiniMaxH3-Adapter/test_gguf_loader.py +212 -0
  36. custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/merges.txt +0 -0
  37. custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/tokenizer.json +0 -0
  38. custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/tokenizer_config.json +246 -0
  39. custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/vocab.json +0 -0
adapter_stage2_best.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6559e73b3f1b8dd3d3b8702ba4f6221c2bf68427f39741db3cafef53dfe4af5c
3
+ size 2288170738
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import folder_paths
4
+
5
+ # models/minimax_h3_adapter/ -> adapter_stage2.safetensors
6
+ folder_paths.add_model_folder_path("minimax_h3_adapter",
7
+ os.path.join(folder_paths.models_dir, "minimax_h3_adapter"))
8
+ # models/text_encoders/ -> Qwen3.5-4B 文件夹(与 minimax CLIP 同目录)
9
+ folder_paths.add_model_folder_path("minimax_h3_student",
10
+ os.path.join(folder_paths.models_dir, "text_encoders"))
11
+
12
+ from .nodes import MiniMaxH3AdapterLoader, MiniMaxH3AdapterFromCLIPLoader # noqa: E402
13
+ from .export_cond import MiniMaxH3ExportCond # noqa: E402
14
+
15
+ NODE_CLASS_MAPPINGS = {
16
+ "MiniMaxH3AdapterLoader": MiniMaxH3AdapterLoader,
17
+ "MiniMaxH3AdapterFromCLIPLoader": MiniMaxH3AdapterFromCLIPLoader,
18
+ "MiniMaxH3ExportCond": MiniMaxH3ExportCond,
19
+ }
20
+ NODE_DISPLAY_NAME_MAPPINGS = {
21
+ "MiniMaxH3AdapterLoader": "MiniMax H3 Adapter Loader",
22
+ "MiniMaxH3AdapterFromCLIPLoader": "MiniMax H3 Adapter (From CLIP)",
23
+ "MiniMaxH3ExportCond": "MiniMax H3 Export Cond (Debug)",
24
+ }
25
+
26
+ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.1 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (978 Bytes). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/adapter_clip.cpython-311.pyc ADDED
Binary file (9.26 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/adapter_clip.cpython-313.pyc ADDED
Binary file (8.65 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/export_cond.cpython-311.pyc ADDED
Binary file (3.29 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/export_cond.cpython-313.pyc ADDED
Binary file (2.94 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_dequant.cpython-311.pyc ADDED
Binary file (25.8 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_dequant.cpython-313.pyc ADDED
Binary file (24 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_qwen35.cpython-311.pyc ADDED
Binary file (32.4 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/gguf_qwen35.cpython-313.pyc ADDED
Binary file (28.5 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/nodes.cpython-311.pyc ADDED
Binary file (9.77 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/nodes.cpython-313.pyc ADDED
Binary file (8.51 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/student.cpython-311.pyc ADDED
Binary file (12.1 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/__pycache__/student.cpython-313.pyc ADDED
Binary file (10.9 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .model import H3Adapter, H3_HIDDEN, STUDENT_HIDDEN
2
+
3
+ __all__ = ["H3Adapter", "H3_HIDDEN", "STUDENT_HIDDEN"]
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (334 Bytes). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (302 Bytes). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/model.cpython-311.pyc ADDED
Binary file (20.6 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/__pycache__/model.cpython-313.pyc ADDED
Binary file (13 kB). View file
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/model.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """H3 文本编码器替换适配器(接口蒸馏):source_projection + CrossAttention Resampler + TokenRefiner。
2
+
3
+ 前向:
4
+ h3_ids [B, S_T] --QueryEmbedding--> Q [B, S_T, 5376]
5
+ student_hidden [B, S_S, 2560] --source_projection--> KV [B, S_S, 5376]
6
+ CrossAttentionBlock(Q, KV) -> [B, S_T, 5376]
7
+ TokenRefiner(2 层, 原权重初始化) -> [B, S_T, 5376] # 与教师 target 同坐标系
8
+
9
+ TokenRefiner 结构严格复刻原始 checkpoint(已源码核实):
10
+ - fused qkv (chunk(3)) + per-head qk_norm + 双向注意力 + out_proj,全部 bias=False
11
+ - SwiGLU MLP: fc1 为 fused [gate; value](gate 在前),fc2(silu(gate)*value)
12
+ - 2 个 pre-norm block + final RMSNorm,eps 全部 1e-5
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+
22
+ H3_VOCAB = 151936
23
+ H3_HIDDEN = 5376
24
+ REFINER_HEADS = 56
25
+ REFINER_HEAD_DIM = 128
26
+ REFINER_FFN = 14336
27
+ STUDENT_HIDDEN = 2560
28
+
29
+
30
+ class RMSNorm(nn.Module):
31
+ def __init__(self, dim: int, eps: float = 1e-5):
32
+ super().__init__()
33
+ self.weight = nn.Parameter(torch.ones(dim))
34
+ self.eps = eps
35
+
36
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
37
+ return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) * self.weight
38
+
39
+
40
+ class SwiGLUFFN(nn.Module):
41
+ def __init__(self, hidden: int, ffn: int):
42
+ super().__init__()
43
+ self.fc1 = nn.Linear(hidden, 2 * ffn, bias=False)
44
+ self.fc2 = nn.Linear(ffn, hidden, bias=False)
45
+
46
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
47
+ gate, value = self.fc1(x).chunk(2, dim=-1)
48
+ return self.fc2(F.silu(gate) * value)
49
+
50
+
51
+ class TokenRefinerAttention(nn.Module):
52
+ def __init__(self, hidden: int, heads: int, dim_head: int):
53
+ super().__init__()
54
+ self.heads = heads
55
+ self.head_dim = dim_head
56
+ self.inner_dim = heads * dim_head
57
+ self.qkv_proj = nn.Linear(hidden, 3 * self.inner_dim, bias=False)
58
+ self.q_norm = RMSNorm(dim_head)
59
+ self.k_norm = RMSNorm(dim_head)
60
+ self.out_proj = nn.Linear(self.inner_dim, hidden, bias=False)
61
+ self.use_sdpa = True # SDPA flash: 注意力内存 O(S²)->O(S),数值与手写注意力差 ~1e-3(蒸馏噪声级)
62
+
63
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
64
+ q, k, v = self.qkv_proj(x).chunk(3, dim=-1)
65
+ q = self.q_norm(q.unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2))
66
+ k = self.k_norm(k.unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2))
67
+ v = v.unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
68
+ if self.use_sdpa:
69
+ out = F.scaled_dot_product_attention(q, k, v, scale=self.head_dim ** -0.5)
70
+ else:
71
+ attn = torch.softmax((q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5), dim=-1)
72
+ out = attn @ v
73
+ return self.out_proj(out.transpose(1, 2).flatten(2))
74
+
75
+
76
+ class TokenRefinerBlock(nn.Module):
77
+ def __init__(self, hidden: int, heads: int, dim_head: int, ffn: int):
78
+ super().__init__()
79
+ self.norm1 = RMSNorm(hidden)
80
+ self.attn = TokenRefinerAttention(hidden, heads, dim_head)
81
+ self.norm2 = RMSNorm(hidden)
82
+ self.mlp = SwiGLUFFN(hidden, ffn)
83
+
84
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
85
+ x = x + self.attn(self.norm1(x))
86
+ x = x + self.mlp(self.norm2(x))
87
+ return x
88
+
89
+
90
+ class TokenRefiner(nn.Module):
91
+ def __init__(self, num_layers: int = 2, hidden: int = H3_HIDDEN,
92
+ heads: int = REFINER_HEADS, dim_head: int = REFINER_HEAD_DIM, ffn: int = REFINER_FFN):
93
+ super().__init__()
94
+ self.blocks = nn.ModuleList([TokenRefinerBlock(hidden, heads, dim_head, ffn) for _ in range(num_layers)])
95
+ self.final_norm = RMSNorm(hidden)
96
+
97
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
98
+ for block in self.blocks:
99
+ x = block(x)
100
+ return self.final_norm(x)
101
+
102
+
103
+ class CrossAttentionBlock(nn.Module):
104
+ """跨空间交叉注意(学生 2560 投影空间 <-> 教师 5376 表示空间)。
105
+
106
+ 训练稳定性升级(兼容旧权重加载):
107
+ - QK-norm: to_q/to_k 投影后对每头做 RMSNorm(Llama 3.2 vision 交叉注意惯例,
108
+ 稳定跨空间注意力的 q/k 尺度失配)
109
+ - gated tanh: out_proj 输出经 tanh(gate) 门控(Flamingo 惯例),gate 初始 0 => 恒等
110
+ - attn dropout: 交叉注意 dropout 0.1(Emu3 防后期 collapse)
111
+ """
112
+
113
+ def __init__(self, hidden: int = H3_HIDDEN, heads: int = 32, dim_head: int = 128, ffn: int = REFINER_FFN,
114
+ attn_dropout: float = 0.1, use_qk_norm: bool = True, use_gate: bool = True):
115
+ super().__init__()
116
+ self.heads = heads
117
+ self.head_dim = dim_head
118
+ self.inner_dim = heads * dim_head
119
+ self.norm_q = RMSNorm(hidden)
120
+ self.norm_kv = RMSNorm(hidden)
121
+ self.to_q = nn.Linear(hidden, self.inner_dim, bias=False)
122
+ self.to_k = nn.Linear(hidden, self.inner_dim, bias=False)
123
+ self.to_v = nn.Linear(hidden, self.inner_dim, bias=False)
124
+ self.out_proj = nn.Linear(self.inner_dim, hidden, bias=False)
125
+ self.norm2 = RMSNorm(hidden)
126
+ self.mlp = SwiGLUFFN(hidden, ffn)
127
+ self.use_sdpa = True
128
+ self.attn_dropout = attn_dropout
129
+ self.use_qk_norm = use_qk_norm
130
+ if use_qk_norm:
131
+ self.qk_norm = RMSNorm(dim_head)
132
+ self.use_gate = use_gate
133
+ if use_gate:
134
+ self.gate = nn.Parameter(torch.zeros(1)) # tanh(0)=0 -> 恒等,兼容旧权重
135
+
136
+ def forward(self, q: torch.Tensor, kv: torch.Tensor) -> torch.Tensor:
137
+ qn = self.norm_q(q)
138
+ kvn = self.norm_kv(kv)
139
+ qh = self.to_q(qn).unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
140
+ kh = self.to_k(kvn).unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
141
+ vh = self.to_v(kvn).unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
142
+ if self.use_qk_norm:
143
+ qh = self.qk_norm(qh)
144
+ kh = self.qk_norm(kh)
145
+ if self.use_sdpa:
146
+ out = F.scaled_dot_product_attention(qh, kh, vh, scale=self.head_dim ** -0.5,
147
+ dropout_p=self.attn_dropout if self.training else 0.0)
148
+ else:
149
+ attn = torch.softmax((qh @ kh.transpose(-2, -1)) * (self.head_dim ** -0.5), dim=-1)
150
+ out = attn @ vh
151
+ proj = self.out_proj(out.transpose(1, 2).flatten(2))
152
+ x = q + (torch.tanh(self.gate) * proj if self.use_gate else proj)
153
+ x = x + self.mlp(self.norm2(x))
154
+ return x
155
+
156
+
157
+ class QueryEmbedding(nn.Module):
158
+ def __init__(self, vocab: int = H3_VOCAB, dim: int = 256, out: int = H3_HIDDEN):
159
+ super().__init__()
160
+ self.embed = nn.Embedding(vocab, dim)
161
+ self.proj = nn.Linear(dim, out, bias=True)
162
+
163
+ def forward(self, ids: torch.Tensor) -> torch.Tensor:
164
+ return self.proj(self.embed(ids))
165
+
166
+
167
+ class H3Adapter(nn.Module):
168
+ """完整适配器。param 约 1.14B(source_proj 13.8M + query_embed 40.3M + crossattn 319M + refiner 751M)。"""
169
+
170
+ def __init__(self):
171
+ super().__init__()
172
+ self.source_projection = nn.Linear(STUDENT_HIDDEN, H3_HIDDEN, bias=True)
173
+ self.query_embedding = QueryEmbedding()
174
+ self.cross_attention = CrossAttentionBlock()
175
+ self.token_refiner = TokenRefiner()
176
+
177
+ def forward(self, h3_ids: torch.Tensor, student_hidden: torch.Tensor) -> torch.Tensor:
178
+ kv = self.source_projection(student_hidden)
179
+ q = self.query_embedding(h3_ids)
180
+ x = self.cross_attention(q, kv)
181
+ x = self.token_refiner(x)
182
+ return x
183
+
184
+ def load_token_refiner(self, state_dict: dict[str, torch.Tensor], strict: bool = True) -> None:
185
+ refiner_state = {
186
+ k[len("token_refiner."):]: v
187
+ for k, v in state_dict.items()
188
+ if k.startswith("token_refiner.")
189
+ }
190
+ missing, unexpected = self.token_refiner.load_state_dict(refiner_state, strict=strict)
191
+ assert not missing and not unexpected, f"refiner load: missing={missing} unexpected={unexpected}"
192
+
193
+ def trainable_modules(self, stage: int) -> list[nn.Parameter]:
194
+ if stage == 1:
195
+ return (
196
+ list(self.source_projection.parameters())
197
+ + list(self.query_embedding.parameters())
198
+ + list(self.cross_attention.parameters())
199
+ )
200
+ return list(self.parameters())
201
+
202
+
203
+ class TeacherHead(nn.Module):
204
+ """教师 target 计算: h50 -> condition_proj(5120->5376) -> token_refiner -> [B, S_T, 5376]。"""
205
+
206
+ def __init__(self):
207
+ super().__init__()
208
+ self.condition_proj = nn.Linear(5120, H3_HIDDEN, bias=True)
209
+ self.token_refiner = TokenRefiner()
210
+
211
+ def forward(self, h50: torch.Tensor) -> torch.Tensor:
212
+ return self.token_refiner(self.condition_proj(h50))
213
+
214
+
215
+ def compute_query_embed_init(embed_tokens: torch.Tensor, dim: int = 256, seed: int = 0) -> torch.Tensor:
216
+ """query embedding 初始化: embed.weight = E @ P,P 为 5120->dim 的随机投影。
217
+
218
+ 仅用于一次性初始化,后续可训练。embed_tokens 为 [151936, 5120](BF16 或 FP32)。
219
+ """
220
+ assert embed_tokens.dim() == 2 and embed_tokens.shape[1] == 5120, embed_tokens.shape
221
+ rng = torch.Generator().manual_seed(seed)
222
+ p = torch.randn(5120, dim, generator=rng) * (1.0 / math.sqrt(5120))
223
+ embed_tokens = embed_tokens.to(torch.float32)
224
+ chunks = 8
225
+ out = torch.empty(embed_tokens.shape[0], dim, dtype=torch.float32)
226
+ for i in range(chunks):
227
+ lo = i * embed_tokens.shape[0] // chunks
228
+ hi = (i + 1) * embed_tokens.shape[0] // chunks
229
+ out[lo:hi] = embed_tokens[lo:hi] @ p
230
+ return out.to(torch.bfloat16)
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter/model.py.bak_old ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """H3 文本编码器替换适配器(接口蒸馏)——ComfyUI 节点用自包含版。
2
+
3
+ 前向:
4
+ h3_ids [B, S_T] --QueryEmbedding--> Q [B, S_T, 5376]
5
+ student_hidden [B, S_S, 2560] --source_projection--> KV [B, S_S, 5376]
6
+ CrossAttentionBlock(Q, KV) -> [B, S_T, 5376]
7
+ TokenRefiner(2 层, 原权重初始化) -> [B, S_T, 5376] # 与教师 target 同坐标系
8
+
9
+ TokenRefiner 结构严格复刻原始 checkpoint:
10
+ - fused qkv (chunk(3)) + per-head qk_norm + 双向注意力 + out_proj,全部 bias=False
11
+ - SwiGLU MLP: fc1 为 fused [gate; value](gate 在前),fc2(silu(gate)*value)
12
+ - 2 个 pre-norm block + final RMSNorm,eps 全部 1e-5
13
+
14
+ 注意力 use_sdpa=True(默认): SDPA flash 省显存;手写注意力保留为回退。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+
22
+ H3_VOCAB = 151936
23
+ H3_HIDDEN = 5376
24
+ REFINER_HEADS = 56
25
+ REFINER_HEAD_DIM = 128
26
+ REFINER_FFN = 14336
27
+ STUDENT_HIDDEN = 2560
28
+
29
+
30
+ class RMSNorm(nn.Module):
31
+ def __init__(self, hidden: int, eps: float = 1e-5):
32
+ super().__init__()
33
+ self.weight = nn.Parameter(torch.ones(hidden))
34
+ self.eps = eps
35
+
36
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
37
+ return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) * self.weight
38
+
39
+
40
+ class SwiGLUFFN(nn.Module):
41
+ def __init__(self, hidden: int, ffn: int):
42
+ super().__init__()
43
+ self.fc1 = nn.Linear(hidden, 2 * ffn, bias=False)
44
+ self.fc2 = nn.Linear(ffn, hidden, bias=False)
45
+
46
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
47
+ gate, value = self.fc1(x).chunk(2, dim=-1)
48
+ return self.fc2(F.silu(gate) * value)
49
+
50
+
51
+ class TokenRefinerAttention(nn.Module):
52
+ def __init__(self, hidden: int, heads: int, dim_head: int):
53
+ super().__init__()
54
+ self.heads = heads
55
+ self.head_dim = dim_head
56
+ self.inner_dim = heads * dim_head
57
+ self.qkv_proj = nn.Linear(hidden, 3 * self.inner_dim, bias=False)
58
+ self.q_norm = RMSNorm(dim_head)
59
+ self.k_norm = RMSNorm(dim_head)
60
+ self.out_proj = nn.Linear(self.inner_dim, hidden, bias=False)
61
+ self.use_sdpa = True # SDPA flash: 注意力内存 O(S²)->O(S),数值与手写注意力差 ~1e-3(蒸馏噪声级)
62
+
63
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
64
+ q, k, v = self.qkv_proj(x).chunk(3, dim=-1)
65
+ q = self.q_norm(q.unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2))
66
+ k = self.k_norm(k.unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2))
67
+ v = v.unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
68
+ if self.use_sdpa:
69
+ out = F.scaled_dot_product_attention(q, k, v, scale=self.head_dim ** -0.5)
70
+ else:
71
+ attn = torch.softmax((q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5), dim=-1)
72
+ out = attn @ v
73
+ return self.out_proj(out.transpose(1, 2).flatten(2))
74
+
75
+
76
+ class TokenRefinerBlock(nn.Module):
77
+ def __init__(self, hidden: int, heads: int, dim_head: int, ffn: int):
78
+ super().__init__()
79
+ self.norm1 = RMSNorm(hidden)
80
+ self.attn = TokenRefinerAttention(hidden, heads, dim_head)
81
+ self.norm2 = RMSNorm(hidden)
82
+ self.mlp = SwiGLUFFN(hidden, ffn)
83
+
84
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
85
+ x = x + self.attn(self.norm1(x))
86
+ x = x + self.mlp(self.norm2(x))
87
+ return x
88
+
89
+
90
+ class TokenRefiner(nn.Module):
91
+ def __init__(self, num_layers: int = 2, hidden: int = H3_HIDDEN,
92
+ heads: int = REFINER_HEADS, dim_head: int = REFINER_HEAD_DIM, ffn: int = REFINER_FFN):
93
+ super().__init__()
94
+ self.blocks = nn.ModuleList([TokenRefinerBlock(hidden, heads, dim_head, ffn) for _ in range(num_layers)])
95
+ self.final_norm = RMSNorm(hidden)
96
+
97
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
98
+ for block in self.blocks:
99
+ x = block(x)
100
+ return self.final_norm(x)
101
+
102
+
103
+ class CrossAttentionBlock(nn.Module):
104
+ def __init__(self, hidden: int = H3_HIDDEN, heads: int = 32, dim_head: int = 128, ffn: int = REFINER_FFN):
105
+ super().__init__()
106
+ self.heads = heads
107
+ self.head_dim = dim_head
108
+ self.inner_dim = heads * dim_head
109
+ self.norm_q = RMSNorm(hidden)
110
+ self.norm_kv = RMSNorm(hidden)
111
+ self.to_q = nn.Linear(hidden, self.inner_dim, bias=False)
112
+ self.to_k = nn.Linear(hidden, self.inner_dim, bias=False)
113
+ self.to_v = nn.Linear(hidden, self.inner_dim, bias=False)
114
+ self.out_proj = nn.Linear(self.inner_dim, hidden, bias=False)
115
+ self.norm2 = RMSNorm(hidden)
116
+ self.mlp = SwiGLUFFN(hidden, ffn)
117
+ self.use_sdpa = True
118
+
119
+ def forward(self, q: torch.Tensor, kv: torch.Tensor) -> torch.Tensor:
120
+ qn = self.norm_q(q)
121
+ kvn = self.norm_kv(kv)
122
+ qh = self.to_q(qn).unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
123
+ kh = self.to_k(kvn).unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
124
+ vh = self.to_v(kvn).unflatten(-1, (self.heads, self.head_dim)).transpose(1, 2)
125
+ if self.use_sdpa:
126
+ out = F.scaled_dot_product_attention(qh, kh, vh, scale=self.head_dim ** -0.5)
127
+ else:
128
+ attn = torch.softmax((qh @ kh.transpose(-2, -1)) * (self.head_dim ** -0.5), dim=-1)
129
+ out = attn @ vh
130
+ x = q + self.out_proj(out.transpose(1, 2).flatten(2))
131
+ x = x + self.mlp(self.norm2(x))
132
+ return x
133
+
134
+
135
+ class QueryEmbedding(nn.Module):
136
+ def __init__(self, vocab: int = H3_VOCAB, dim: int = 256, out: int = H3_HIDDEN):
137
+ super().__init__()
138
+ self.embed = nn.Embedding(vocab, dim)
139
+ self.proj = nn.Linear(dim, out, bias=True)
140
+
141
+ def forward(self, ids: torch.Tensor) -> torch.Tensor:
142
+ return self.proj(self.embed(ids))
143
+
144
+
145
+ class H3Adapter(nn.Module):
146
+ """完整适配器。param 约 1.14B(source_proj 13.8M + query_embed 40.3M + crossattn 319M + refiner 751M)。"""
147
+
148
+ def __init__(self):
149
+ super().__init__()
150
+ self.source_projection = nn.Linear(STUDENT_HIDDEN, H3_HIDDEN, bias=True)
151
+ self.query_embedding = QueryEmbedding()
152
+ self.cross_attention = CrossAttentionBlock()
153
+ self.token_refiner = TokenRefiner()
154
+
155
+ def forward(self, h3_ids: torch.Tensor, student_hidden: torch.Tensor) -> torch.Tensor:
156
+ kv = self.source_projection(student_hidden)
157
+ q = self.query_embedding(h3_ids)
158
+ x = self.cross_attention(q, kv)
159
+ x = self.token_refiner(x)
160
+ return x
custom_nodes/ComfyUI-MiniMaxH3-Adapter/adapter_clip.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniMaxH3AdapterCLIP: 鸭子类型 CLIP,纯 torch(无 ComfyUI 依赖,可独立测试)。
2
+
3
+ contract(与官方 minimax CLIP 对齐):
4
+ tokenize(prompt, images=...) -> 捕获官方节点的 prompt
5
+ encode_from_tokens_scheduled(tokens) -> [[embeds_fp16 [1, S_T, 5376],
6
+ {"minimax_token_tags": tags_long [S_T]}]]
7
+ 纯文本 prompt 的 tag 全为 1(DiT packed 布局的 text 段)。
8
+
9
+ 原理: ComfyUI MiniMaxH3 DiT 的 preprocess_text_embeds / _forward 在
10
+ text_states.shape[-1] == 5376 (hidden_size) 时跳过 condition_proj + token_refiner,
11
+ 适配器输出已是 post-refiner 表示,直接注入。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import torch
16
+
17
+ try:
18
+ import node_helpers
19
+ except ImportError: # 独立测试环境
20
+ def _conditioning_set_values(cond, new_values):
21
+ if len(cond) == 1 and len(cond[0][1]) == 0:
22
+ return [[cond[0][0], new_values]]
23
+ return [[c[0], {**c[1], **new_values}] for c in cond]
24
+ node_helpers = type("NH", (), {"conditioning_set_values": staticmethod(_conditioning_set_values)})()
25
+
26
+ H3_HIDDEN = 5376
27
+ TEXT_TAG = 1
28
+
29
+
30
+ class MiniMaxH3AdapterCLIP:
31
+ def __init__(self, adapter: torch.nn.Module, student, h3_tokenizer,
32
+ device: torch.device, output_dtype: torch.dtype = torch.float16,
33
+ lowvram: bool = True):
34
+ self._adapter = adapter
35
+ self._student = student # callable: prompt -> [S_S, 2560] (device)
36
+ self._tok = h3_tokenizer
37
+ self._device = device
38
+ self._dtype = output_dtype
39
+ self._lowvram = lowvram
40
+ self._adapter_offloaded = False
41
+ self._prompt = ""
42
+
43
+ def tokenize(self, text: str, return_word_ids: bool = False, images=None, **kwargs):
44
+ self._prompt = text
45
+ return {}
46
+
47
+ @torch.no_grad()
48
+ def encode_from_tokens_scheduled(self, tokens, unprojected: bool = False,
49
+ add_dict: dict = {}, show_pbar: bool = True):
50
+ prompt = self._prompt
51
+ dev = self._device
52
+ if self._adapter_offloaded:
53
+ self._adapter.to(dev)
54
+ self._adapter_offloaded = False
55
+ ids = self._tok(prompt, add_special_tokens=False)["input_ids"]
56
+ h_s = self._student(prompt).unsqueeze(0).to(dev) # [1, S_S, 2560]
57
+ h3_ids = torch.tensor([ids], dtype=torch.long, device=dev)
58
+ embeds = self._adapter(h3_ids, h_s) # [1, S_T, 5376] bf16
59
+ s_t = embeds.shape[1]
60
+ assert s_t == len(ids), f"h3 ids {len(ids)} != adapter out {s_t}"
61
+ assert embeds.shape[-1] == H3_HIDDEN, embeds.shape
62
+ assert torch.isfinite(embeds).all(), "adapter 输出含 NaN"
63
+
64
+ cond_embeds = embeds.to(self._dtype) # fp16 对齐官方 minimax CLIP
65
+ tags = torch.ones(s_t, dtype=torch.long, device=dev)
66
+ cond = [[cond_embeds, {"minimax_token_tags": tags}]]
67
+ if self._lowvram:
68
+ # 适配器 1.14B(≈2.3GB) 不卸载会挤占 DiT 显存 → lowvram offload,下次 encode 再载入
69
+ self._adapter.to("cpu")
70
+ self._adapter_offloaded = True
71
+ if torch.cuda.is_available():
72
+ torch.cuda.empty_cache()
73
+ if add_dict:
74
+ cond = node_helpers.conditioning_set_values(cond, add_dict)
75
+ return cond
76
+
77
+
78
+ class MiniMaxH3AdapterFromCLIP:
79
+ """CLIP→CLIP 包装: 学生 CLIP(CLIPLoader GGUF 等 ComfyUI 原生加载)的 encode 输出当 student_hidden。
80
+
81
+ 消费内层 CLIP 契约(comfy.sd.CLIP 或等价鸭子类型):
82
+ tokenize(prompt) -> tokens
83
+ encode_from_tokens(tokens) -> [[tensor [1, S_S, 2560], extras]]
84
+ 内层 encode 的 layer="last" 输出 = 学生主干 post-final-norm hidden(与训练 HF 语义一致)。
85
+ """
86
+
87
+ def __init__(self, inner_clip, adapter: torch.nn.Module, h3_tokenizer,
88
+ device: torch.device, output_dtype: torch.dtype = torch.float16,
89
+ lowvram: bool = True):
90
+ self._inner = inner_clip
91
+ self._adapter = adapter
92
+ self._tok = h3_tokenizer
93
+ self._device = device
94
+ self._dtype = output_dtype
95
+ self._lowvram = lowvram
96
+ self._adapter_offloaded = False
97
+ self._prompt = ""
98
+
99
+ def tokenize(self, text: str, return_word_ids: bool = False, images=None, **kwargs):
100
+ self._prompt = text
101
+ return {}
102
+
103
+ @torch.no_grad()
104
+ def encode_from_tokens_scheduled(self, tokens, unprojected: bool = False,
105
+ add_dict: dict = {}, show_pbar: bool = True):
106
+ prompt = self._prompt
107
+ if self._adapter_offloaded:
108
+ self._adapter.to(self._device)
109
+ self._adapter_offloaded = False
110
+ inner_tokens = self._inner.tokenize(prompt)
111
+ inner_cond = self._inner.encode_from_tokens(inner_tokens)
112
+ dev = next(self._adapter.parameters()).device # 内层 CLIP 可能在 CPU/低显存模式,以适配器设备为准
113
+ h_s = inner_cond[0][0][0].to(torch.bfloat16).to(dev) # [S_S, 2560] post-norm
114
+ ids = self._tok(prompt, add_special_tokens=False)["input_ids"]
115
+ h3_ids = torch.tensor([ids], dtype=torch.long, device=dev)
116
+ embeds = self._adapter(h3_ids, h_s.unsqueeze(0)) # [1, S_T, 5376] bf16
117
+ s_t = embeds.shape[1]
118
+ assert s_t == len(ids), f"h3 ids {len(ids)} != adapter out {s_t}"
119
+ assert embeds.shape[-1] == H3_HIDDEN, embeds.shape
120
+ assert torch.isfinite(embeds).all(), "adapter 输出含 NaN"
121
+
122
+ cond_embeds = embeds.to(self._dtype)
123
+ tags = torch.ones(s_t, dtype=torch.long, device=dev)
124
+ cond = [[cond_embeds, {"minimax_token_tags": tags}]]
125
+ if self._lowvram:
126
+ self._adapter.to("cpu")
127
+ self._adapter_offloaded = True
128
+ if torch.cuda.is_available():
129
+ torch.cuda.empty_cache()
130
+ if add_dict:
131
+ cond = node_helpers.conditioning_set_values(cond, add_dict)
132
+ return cond
custom_nodes/ComfyUI-MiniMaxH3-Adapter/check_alignment.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """候选学生(GGUF 量化 / uncensored 微调 / 任意 HF 目录)vs bf16 原版 的特征对齐测试。
3
+
4
+ 这是"换学生"的照妖镜——适配器训练时的输入是 bf16 原版的 hidden_states[-1],
5
+ 候选版隐藏态的任何漂移都会直接传导到生成的 conditioning。
6
+
7
+ 用法:
8
+ python check_alignment.py <bf16 原版目录> <候选: *.gguf 文件 或 HF 目录>
9
+ [--prompts 3] [--device cuda]
10
+
11
+ 判定:
12
+ cos > 0.99 合格(量化级噪声,直接用)
13
+ 0.95-0.99 有可见漂移,评估后可用(建议先跑一次端到端生成对比)
14
+ < 0.95 漂移显著,需用候选学生重提特征 + 重训/微调适配器
15
+
16
+ 说明:
17
+ GGUF 走 transformers 内置加载器(>=4.45,AutoModel.from_pretrained(".gguf")),
18
+ 隐藏态语义与 bf16 原版完全一致(都是 transformers 路径),测的是量化+微调漂移本身。
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import os
24
+ import sys
25
+
26
+ import torch
27
+
28
+ HERE = os.path.dirname(os.path.abspath(__file__))
29
+ sys.path.insert(0, HERE)
30
+
31
+ from student import StudentTextEncoder, load_student, find_language_model # noqa: E402
32
+ from adapter_clip import H3_HIDDEN # noqa: E402
33
+
34
+
35
+ def load_candidate(path: str, device: torch.device, baseline_mem: float = 0.0):
36
+ from transformers import AutoConfig, AutoModel
37
+
38
+ if path.endswith(".gguf"):
39
+ from gguf_qwen35 import load_gguf_model_quantized
40
+ model, info = load_gguf_model_quantized(path)
41
+ print(f" [candidate] 自定义 GGUF 加载器(量化驻留): {info}", flush=True)
42
+ model = model.to(torch.bfloat16).to(device) # GGMLTensor 的 to 忽略 dtype, 只移设备
43
+ if device.type == "cuda":
44
+ used = torch.cuda.memory_allocated() / 2**30 - baseline_mem
45
+ print(f" [candidate] 净显存: {used:.2f} GiB", flush=True)
46
+ return model
47
+ # HF 目录
48
+ cfg = AutoConfig.from_pretrained(path)
49
+ model = AutoModel.from_config(cfg)
50
+ try:
51
+ sd = {k[len("model."):] if k.startswith("model.") else k: v
52
+ for k, v in torch.load(os.path.join(path, "pytorch_model.bin"), map_location="cpu").items()}
53
+ missing, _ = model.load_state_dict(sd, strict=False)
54
+ assert not missing, f"missing: {missing[:5]}"
55
+ except FileNotFoundError:
56
+ model = AutoModel.from_pretrained(path, torch_dtype=torch.bfloat16)
57
+ return model.to(device)
58
+
59
+
60
+ @torch.no_grad()
61
+ def hidden_fn(lm, tok, prompt: str, device: torch.device) -> torch.Tensor:
62
+ ids = tok(prompt, add_special_tokens=False)["input_ids"]
63
+ out = lm(input_ids=torch.tensor([ids], device=device), output_hidden_states=True)
64
+ return out.hidden_states[-1][0].float() # [S, D] 与训练同路径
65
+
66
+
67
+ def main() -> None:
68
+ ap = argparse.ArgumentParser()
69
+ ap.add_argument("reference", help="bf16 原版 Qwen3.5-4B HF 目录")
70
+ ap.add_argument("candidate", help="候选: .gguf 或 HF 目录")
71
+ ap.add_argument("--prompts", type=int, default=3)
72
+ ap.add_argument("--device", default="")
73
+ args = ap.parse_args()
74
+
75
+ from transformers import AutoTokenizer
76
+ dev = torch.device(args.device) if args.device else torch.device("cuda" if torch.cuda.is_available() else "cpu")
77
+ print(f"device: {dev}")
78
+
79
+ print("[1/2] loading reference (bf16)...", flush=True)
80
+ ref_model = load_student(args.reference).to(torch.bfloat16).to(dev)
81
+ ref_lm = find_language_model(ref_model)
82
+ ref_tok = AutoTokenizer.from_pretrained(args.reference)
83
+
84
+ print("[2/2] loading candidate...", flush=True)
85
+ baseline = torch.cuda.memory_allocated() / 2**30 if dev.type == "cuda" else 0.0
86
+ cand_model = load_candidate(args.candidate, dev, baseline)
87
+ cand_lm = find_language_model(cand_model)
88
+ # .gguf 候选无自带 tokenizer: 复用参考模型的(同一 Qwen3.5-4B 词表)
89
+ cand_tok = ref_tok if args.candidate.endswith(".gguf") else AutoTokenizer.from_pretrained(args.candidate)
90
+
91
+ demo = [
92
+ "A cinematic shot of a tiny robot repairing a broken music box in a giant's attic",
93
+ "A surfer riding a massive neon wave at sunset, spray frozen in the air, dramatic backlight",
94
+ "Paper collage birds flocking over a misty city skyline, soft morning light",
95
+ ]
96
+ print(f"\ncosine 对比 {args.prompts} 条 prompt(hidden_states[-1], [S, 2560]):")
97
+ worst = 1.0
98
+ for i, p in enumerate(demo[: args.prompts]):
99
+ h_ref = hidden_fn(ref_lm, ref_tok, p, dev)
100
+ h_can = hidden_fn(cand_lm, cand_tok, p, dev)
101
+ n = min(h_ref.shape[0], h_can.shape[0])
102
+ if h_ref.shape[0] != h_can.shape[0]:
103
+ print(f" [注意] prompt {i}: 长度不同 ref={h_ref.shape[0]} cand={h_can.shape[0]}(截断到 {n} 比较)")
104
+ a, b = h_ref[:n].flatten(), h_can[:n].flatten()
105
+ cos = (a * b).sum() / (a.norm() * b.norm())
106
+ worst = min(worst, cos.item())
107
+ print(f" prompt {i}: cos={cos.item():.4f}")
108
+
109
+ print(f"\n最差 cos = {worst:.4f} -> ", end="")
110
+ if worst > 0.99:
111
+ print("合格(量化级噪声,直接用)✅")
112
+ elif worst >= 0.95:
113
+ print("有可见漂移,评估后可用(建议先端到端生成对比)⚠️")
114
+ else:
115
+ print("漂移显著,需用候选学生重提特征 + 重训/微调适配器 ❌")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
custom_nodes/ComfyUI-MiniMaxH3-Adapter/export_adapter.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """导出 train.py checkpoint -> ComfyUI 适配器 .safetensors(纯 state_dict)。
3
+
4
+ 用法:
5
+ python export_adapter.py <adapter_stage2_step2000.pt> [--out adapter_stage2.safetensors]
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import sys
12
+
13
+ import torch
14
+
15
+ HERE = os.path.dirname(os.path.abspath(__file__))
16
+ sys.path.insert(0, HERE)
17
+
18
+ from adapter.model import H3Adapter # noqa: E402
19
+
20
+
21
+ def main() -> None:
22
+ ap = argparse.ArgumentParser()
23
+ ap.add_argument("ckpt", help="train.py checkpoint (.pt)")
24
+ ap.add_argument("--out", default="adapter_stage2.safetensors")
25
+ args = ap.parse_args()
26
+
27
+ ckpt = torch.load(args.ckpt, map_location="cpu", weights_only=False)
28
+ state = ckpt.get("model", ckpt) # 兼容 {"model":..., "config":...} 与纯 state_dict
29
+ if any(k.startswith("model.") for k in state):
30
+ state = {k[len("model."):]: v for k, v in state.items()}
31
+
32
+ model = H3Adapter()
33
+ missing, unexpected = model.load_state_dict(state, strict=False)
34
+ assert not missing, f"missing: {missing[:10]}"
35
+ print(f"adapter keys: {len(state)} | missing={len(missing)} unexpected={len(unexpected)}")
36
+
37
+ from safetensors.torch import save_file
38
+ save_file({k: v.contiguous().to(torch.bfloat16) for k, v in state.items()}, args.out)
39
+ print(f"saved -> {args.out}")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
custom_nodes/ComfyUI-MiniMaxH3-Adapter/export_cond.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """临时调试节点: 导出 CLIP 的 cond embeds 到 models/minimax_h3_adapter/cond_official_ref.npy。
2
+
3
+ 用法(黑屏诊断,插进官方工作流):
4
+ CLIPLoader(官方 qwen3vl-32B GGUF) -> MiniMaxH3ExportCond -> MiniMaxH3ImageToVideo
5
+ 节点透传 clip 对象(不改变工作流行为),执行时导出 cond npy 并打印路径。
6
+ ComfyUI 惰性执行会剪枝无输出节点,故必须输出透传 clip 才能被触发。
7
+ """
8
+ import os
9
+
10
+ import numpy as np
11
+
12
+ import folder_paths
13
+
14
+
15
+ class MiniMaxH3ExportCond:
16
+ @classmethod
17
+ def INPUT_TYPES(cls):
18
+ return {"required": {
19
+ "clip": ("CLIP",),
20
+ "prompt": ("STRING", {"default": "a red apple rotating on a wooden table, studio lighting, 3d render",
21
+ "multiline": True}),
22
+ }}
23
+
24
+ RETURN_TYPES = ("CLIP",)
25
+ RETURN_NAMES = ("clip",)
26
+ FUNCTION = "run"
27
+ CATEGORY = "model/conditioning/minimax"
28
+
29
+ def run(self, clip, prompt):
30
+ tokens = clip.tokenize(prompt)
31
+ cond = clip.encode_from_tokens_scheduled(tokens)
32
+ embeds = cond[0][0].float().cpu().numpy()
33
+ out_dir = folder_paths.get_folder_paths("minimax_h3_adapter")[0]
34
+ os.makedirs(out_dir, exist_ok=True)
35
+ path = os.path.join(out_dir, "cond_official_ref.npy")
36
+ np.save(path, embeds)
37
+ extra = ""
38
+ if len(cond[0]) > 1 and isinstance(cond[0][1], dict) and "minimax_token_tags" in cond[0][1]:
39
+ tags = cond[0][1]["minimax_token_tags"].cpu().numpy()
40
+ extra = f" tags_unique={sorted(set(tags.tolist()))}"
41
+ print(f"[export_cond] saved {path} shape={embeds.shape} dtype={embeds.dtype}{extra}", flush=True)
42
+ return (clip,)
custom_nodes/ComfyUI-MiniMaxH3-Adapter/gguf_dequant.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # (c) City96 || Apache-2.0 (apache.org/licenses/LICENSE-2.0)
2
+ import gguf
3
+ import torch
4
+
5
+ try:
6
+ from tqdm import tqdm
7
+ except ImportError:
8
+ class _Tqdm:
9
+ @staticmethod
10
+ def write(msg):
11
+ print(msg)
12
+ tqdm = _Tqdm
13
+
14
+
15
+ TORCH_COMPATIBLE_QTYPES = (None, gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16)
16
+
17
+ def is_torch_compatible(tensor):
18
+ return tensor is None or getattr(tensor, "tensor_type", None) in TORCH_COMPATIBLE_QTYPES
19
+
20
+ def is_quantized(tensor):
21
+ return not is_torch_compatible(tensor)
22
+
23
+ def dequantize_tensor(tensor, dtype=None, dequant_dtype=None):
24
+ qtype = getattr(tensor, "tensor_type", None)
25
+ oshape = getattr(tensor, "tensor_shape", tensor.shape)
26
+
27
+ if qtype in TORCH_COMPATIBLE_QTYPES:
28
+ return tensor.to(dtype)
29
+ elif qtype in dequantize_functions:
30
+ dequant_dtype = dtype if dequant_dtype == "target" else dequant_dtype
31
+ return dequantize(tensor.data, qtype, oshape, dtype=dequant_dtype).to(dtype)
32
+ else:
33
+ # this is incredibly slow
34
+ tqdm.write(f"Falling back to numpy dequant for qtype: {getattr(qtype, 'name', repr(qtype))}")
35
+ new = gguf.quants.dequantize(tensor.cpu().numpy(), qtype)
36
+ return torch.from_numpy(new).to(tensor.device, dtype=dtype)
37
+
38
+ def dequantize(data, qtype, oshape, dtype=None):
39
+ """
40
+ Dequantize tensor back to usable shape/dtype
41
+ """
42
+ block_size, type_size = gguf.GGML_QUANT_SIZES[qtype]
43
+ dequantize_blocks = dequantize_functions[qtype]
44
+
45
+ rows = data.reshape(
46
+ (-1, data.shape[-1])
47
+ ).view(torch.uint8)
48
+
49
+ n_blocks = rows.numel() // type_size
50
+ blocks = rows.reshape((n_blocks, type_size))
51
+ blocks = dequantize_blocks(blocks, block_size, type_size, dtype)
52
+ return blocks.reshape(oshape)
53
+
54
+ def to_uint32(x):
55
+ # no uint32 :(
56
+ x = x.view(torch.uint8).to(torch.int32)
57
+ return (x[:, 0] | x[:, 1] << 8 | x[:, 2] << 16 | x[:, 3] << 24).unsqueeze(1)
58
+
59
+ def to_uint16(x):
60
+ x = x.view(torch.uint8).to(torch.int32)
61
+ return (x[:, 0] | x[:, 1] << 8).unsqueeze(1)
62
+
63
+ def split_block_dims(blocks, *args):
64
+ n_max = blocks.shape[1]
65
+ dims = list(args) + [n_max - sum(args)]
66
+ return torch.split(blocks, dims, dim=1)
67
+
68
+ # Full weights #
69
+ def dequantize_blocks_BF16(blocks, block_size, type_size, dtype=None):
70
+ return (blocks.view(torch.int16).to(torch.int32) << 16).view(torch.float32)
71
+
72
+ # Legacy Quants #
73
+ def dequantize_blocks_Q8_0(blocks, block_size, type_size, dtype=None):
74
+ d, x = split_block_dims(blocks, 2)
75
+ d = d.view(torch.float16).to(dtype)
76
+ x = x.view(torch.int8)
77
+ return (d * x)
78
+
79
+ def dequantize_blocks_Q5_1(blocks, block_size, type_size, dtype=None):
80
+ n_blocks = blocks.shape[0]
81
+
82
+ d, m, qh, qs = split_block_dims(blocks, 2, 2, 4)
83
+ d = d.view(torch.float16).to(dtype)
84
+ m = m.view(torch.float16).to(dtype)
85
+ qh = to_uint32(qh)
86
+
87
+ qh = qh.reshape((n_blocks, 1)) >> torch.arange(32, device=d.device, dtype=torch.int32).reshape(1, 32)
88
+ ql = qs.reshape((n_blocks, -1, 1, block_size // 2)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape(1, 1, 2, 1)
89
+ qh = (qh & 1).to(torch.uint8)
90
+ ql = (ql & 0x0F).reshape((n_blocks, -1))
91
+
92
+ qs = (ql | (qh << 4))
93
+ return (d * qs) + m
94
+
95
+ def dequantize_blocks_Q5_0(blocks, block_size, type_size, dtype=None):
96
+ n_blocks = blocks.shape[0]
97
+
98
+ d, qh, qs = split_block_dims(blocks, 2, 4)
99
+ d = d.view(torch.float16).to(dtype)
100
+ qh = to_uint32(qh)
101
+
102
+ qh = qh.reshape(n_blocks, 1) >> torch.arange(32, device=d.device, dtype=torch.int32).reshape(1, 32)
103
+ ql = qs.reshape(n_blocks, -1, 1, block_size // 2) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape(1, 1, 2, 1)
104
+
105
+ qh = (qh & 1).to(torch.uint8)
106
+ ql = (ql & 0x0F).reshape(n_blocks, -1)
107
+
108
+ qs = (ql | (qh << 4)).to(torch.int8) - 16
109
+ return (d * qs)
110
+
111
+ def dequantize_blocks_Q4_1(blocks, block_size, type_size, dtype=None):
112
+ n_blocks = blocks.shape[0]
113
+
114
+ d, m, qs = split_block_dims(blocks, 2, 2)
115
+ d = d.view(torch.float16).to(dtype)
116
+ m = m.view(torch.float16).to(dtype)
117
+
118
+ qs = qs.reshape((n_blocks, -1, 1, block_size // 2)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape(1, 1, 2, 1)
119
+ qs = (qs & 0x0F).reshape(n_blocks, -1)
120
+
121
+ return (d * qs) + m
122
+
123
+ def dequantize_blocks_Q4_0(blocks, block_size, type_size, dtype=None):
124
+ n_blocks = blocks.shape[0]
125
+
126
+ d, qs = split_block_dims(blocks, 2)
127
+ d = d.view(torch.float16).to(dtype)
128
+
129
+ qs = qs.reshape((n_blocks, -1, 1, block_size // 2)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2, 1))
130
+ qs = (qs & 0x0F).reshape((n_blocks, -1)).to(torch.int8) - 8
131
+ return (d * qs)
132
+
133
+ # K Quants #
134
+ QK_K = 256
135
+ K_SCALE_SIZE = 12
136
+
137
+ def get_scale_min(scales):
138
+ n_blocks = scales.shape[0]
139
+ scales = scales.view(torch.uint8)
140
+ scales = scales.reshape((n_blocks, 3, 4))
141
+
142
+ d, m, m_d = torch.split(scales, scales.shape[-2] // 3, dim=-2)
143
+
144
+ sc = torch.cat([d & 0x3F, (m_d & 0x0F) | ((d >> 2) & 0x30)], dim=-1)
145
+ min = torch.cat([m & 0x3F, (m_d >> 4) | ((m >> 2) & 0x30)], dim=-1)
146
+
147
+ return (sc.reshape((n_blocks, 8)), min.reshape((n_blocks, 8)))
148
+
149
+ def dequantize_blocks_Q6_K(blocks, block_size, type_size, dtype=None):
150
+ n_blocks = blocks.shape[0]
151
+
152
+ ql, qh, scales, d, = split_block_dims(blocks, QK_K // 2, QK_K // 4, QK_K // 16)
153
+
154
+ scales = scales.view(torch.int8).to(dtype)
155
+ d = d.view(torch.float16).to(dtype)
156
+ d = (d * scales).reshape((n_blocks, QK_K // 16, 1))
157
+
158
+ ql = ql.reshape((n_blocks, -1, 1, 64)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2, 1))
159
+ ql = (ql & 0x0F).reshape((n_blocks, -1, 32))
160
+ qh = qh.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 2, 4, 6], device=d.device, dtype=torch.uint8).reshape((1, 1, 4, 1))
161
+ qh = (qh & 0x03).reshape((n_blocks, -1, 32))
162
+ q = (ql | (qh << 4)).to(torch.int8) - 32
163
+ q = q.reshape((n_blocks, QK_K // 16, -1))
164
+
165
+ return (d * q).reshape((n_blocks, QK_K))
166
+
167
+ def dequantize_blocks_Q5_K(blocks, block_size, type_size, dtype=None):
168
+ n_blocks = blocks.shape[0]
169
+
170
+ d, dmin, scales, qh, qs = split_block_dims(blocks, 2, 2, K_SCALE_SIZE, QK_K // 8)
171
+
172
+ d = d.view(torch.float16).to(dtype)
173
+ dmin = dmin.view(torch.float16).to(dtype)
174
+
175
+ sc, m = get_scale_min(scales)
176
+
177
+ d = (d * sc).reshape((n_blocks, -1, 1))
178
+ dm = (dmin * m).reshape((n_blocks, -1, 1))
179
+
180
+ ql = qs.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2, 1))
181
+ qh = qh.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([i for i in range(8)], device=d.device, dtype=torch.uint8).reshape((1, 1, 8, 1))
182
+ ql = (ql & 0x0F).reshape((n_blocks, -1, 32))
183
+ qh = (qh & 0x01).reshape((n_blocks, -1, 32))
184
+ q = (ql | (qh << 4))
185
+
186
+ return (d * q - dm).reshape((n_blocks, QK_K))
187
+
188
+ def dequantize_blocks_Q4_K(blocks, block_size, type_size, dtype=None):
189
+ n_blocks = blocks.shape[0]
190
+
191
+ d, dmin, scales, qs = split_block_dims(blocks, 2, 2, K_SCALE_SIZE)
192
+ d = d.view(torch.float16).to(dtype)
193
+ dmin = dmin.view(torch.float16).to(dtype)
194
+
195
+ sc, m = get_scale_min(scales)
196
+
197
+ d = (d * sc).reshape((n_blocks, -1, 1))
198
+ dm = (dmin * m).reshape((n_blocks, -1, 1))
199
+
200
+ qs = qs.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2, 1))
201
+ qs = (qs & 0x0F).reshape((n_blocks, -1, 32))
202
+
203
+ return (d * qs - dm).reshape((n_blocks, QK_K))
204
+
205
+ def dequantize_blocks_Q3_K(blocks, block_size, type_size, dtype=None):
206
+ n_blocks = blocks.shape[0]
207
+
208
+ hmask, qs, scales, d = split_block_dims(blocks, QK_K // 8, QK_K // 4, 12)
209
+ d = d.view(torch.float16).to(dtype)
210
+
211
+ lscales, hscales = scales[:, :8], scales[:, 8:]
212
+ lscales = lscales.reshape((n_blocks, 1, 8)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 2, 1))
213
+ lscales = lscales.reshape((n_blocks, 16))
214
+ hscales = hscales.reshape((n_blocks, 1, 4)) >> torch.tensor([0, 2, 4, 6], device=d.device, dtype=torch.uint8).reshape((1, 4, 1))
215
+ hscales = hscales.reshape((n_blocks, 16))
216
+ scales = (lscales & 0x0F) | ((hscales & 0x03) << 4)
217
+ scales = (scales.to(torch.int8) - 32)
218
+
219
+ dl = (d * scales).reshape((n_blocks, 16, 1))
220
+
221
+ ql = qs.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 2, 4, 6], device=d.device, dtype=torch.uint8).reshape((1, 1, 4, 1))
222
+ qh = hmask.reshape(n_blocks, -1, 1, 32) >> torch.tensor([i for i in range(8)], device=d.device, dtype=torch.uint8).reshape((1, 1, 8, 1))
223
+ ql = ql.reshape((n_blocks, 16, QK_K // 16)) & 3
224
+ qh = (qh.reshape((n_blocks, 16, QK_K // 16)) & 1) ^ 1
225
+ q = (ql.to(torch.int8) - (qh << 2).to(torch.int8))
226
+
227
+ return (dl * q).reshape((n_blocks, QK_K))
228
+
229
+ def dequantize_blocks_Q2_K(blocks, block_size, type_size, dtype=None):
230
+ n_blocks = blocks.shape[0]
231
+
232
+ scales, qs, d, dmin = split_block_dims(blocks, QK_K // 16, QK_K // 4, 2)
233
+ d = d.view(torch.float16).to(dtype)
234
+ dmin = dmin.view(torch.float16).to(dtype)
235
+
236
+ # (n_blocks, 16, 1)
237
+ dl = (d * (scales & 0xF)).reshape((n_blocks, QK_K // 16, 1))
238
+ ml = (dmin * (scales >> 4)).reshape((n_blocks, QK_K // 16, 1))
239
+
240
+ shift = torch.tensor([0, 2, 4, 6], device=d.device, dtype=torch.uint8).reshape((1, 1, 4, 1))
241
+
242
+ qs = (qs.reshape((n_blocks, -1, 1, 32)) >> shift) & 3
243
+ qs = qs.reshape((n_blocks, QK_K // 16, 16))
244
+ qs = dl * qs - ml
245
+
246
+ return qs.reshape((n_blocks, -1))
247
+
248
+ # IQ quants
249
+ KVALUES = torch.tensor([-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113], dtype=torch.int8)
250
+
251
+ def dequantize_blocks_IQ4_NL(blocks, block_size, type_size, dtype=None):
252
+ n_blocks = blocks.shape[0]
253
+
254
+ d, qs = split_block_dims(blocks, 2)
255
+ d = d.view(torch.float16).to(dtype)
256
+
257
+ qs = qs.reshape((n_blocks, -1, 1, block_size//2)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2, 1))
258
+ qs = (qs & 0x0F).reshape((n_blocks, -1, 1)).to(torch.int64)
259
+
260
+ kvalues = KVALUES.to(qs.device).expand(*qs.shape[:-1], 16)
261
+ qs = torch.gather(kvalues, dim=-1, index=qs).reshape((n_blocks, -1))
262
+ del kvalues # should still be view, but just to be safe
263
+
264
+ return (d * qs)
265
+
266
+ def dequantize_blocks_IQ4_XS(blocks, block_size, type_size, dtype=None):
267
+ n_blocks = blocks.shape[0]
268
+ d, scales_h, scales_l, qs = split_block_dims(blocks, 2, 2, QK_K // 64)
269
+ d = d.view(torch.float16).to(dtype)
270
+ scales_h = to_uint16(scales_h)
271
+
272
+ shift_a = torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape((1, 1, 2))
273
+ shift_b = torch.tensor([2 * i for i in range(QK_K // 32)], device=d.device, dtype=torch.uint8).reshape((1, -1, 1))
274
+
275
+ scales_l = scales_l.reshape((n_blocks, -1, 1)) >> shift_a.reshape((1, 1, 2))
276
+ scales_h = scales_h.reshape((n_blocks, -1, 1)) >> shift_b.reshape((1, -1, 1))
277
+
278
+ scales_l = scales_l.reshape((n_blocks, -1)) & 0x0F
279
+ scales_h = scales_h.reshape((n_blocks, -1)).to(torch.uint8) & 0x03
280
+
281
+ scales = (scales_l | (scales_h << 4)).to(torch.int8) - 32
282
+ dl = (d * scales.to(dtype)).reshape((n_blocks, -1, 1))
283
+
284
+ qs = qs.reshape((n_blocks, -1, 1, 16)) >> shift_a.reshape((1, 1, 2, 1))
285
+ qs = qs.reshape((n_blocks, -1, 32, 1)) & 0x0F
286
+
287
+ kvalues = KVALUES.to(qs.device).expand(*qs.shape[:-1], 16)
288
+ qs = torch.gather(kvalues, dim=-1, index=qs.to(torch.int64)).reshape((n_blocks, -1, 32))
289
+ del kvalues # see IQ4_NL
290
+ del shift_a
291
+ del shift_b
292
+
293
+ return (dl * qs).reshape((n_blocks, -1))
294
+
295
+ dequantize_functions = {
296
+ gguf.GGMLQuantizationType.BF16: dequantize_blocks_BF16,
297
+ gguf.GGMLQuantizationType.Q8_0: dequantize_blocks_Q8_0,
298
+ gguf.GGMLQuantizationType.Q5_1: dequantize_blocks_Q5_1,
299
+ gguf.GGMLQuantizationType.Q5_0: dequantize_blocks_Q5_0,
300
+ gguf.GGMLQuantizationType.Q4_1: dequantize_blocks_Q4_1,
301
+ gguf.GGMLQuantizationType.Q4_0: dequantize_blocks_Q4_0,
302
+ gguf.GGMLQuantizationType.Q6_K: dequantize_blocks_Q6_K,
303
+ gguf.GGMLQuantizationType.Q5_K: dequantize_blocks_Q5_K,
304
+ gguf.GGMLQuantizationType.Q4_K: dequantize_blocks_Q4_K,
305
+ gguf.GGMLQuantizationType.Q3_K: dequantize_blocks_Q3_K,
306
+ gguf.GGMLQuantizationType.Q2_K: dequantize_blocks_Q2_K,
307
+ gguf.GGMLQuantizationType.IQ4_NL: dequantize_blocks_IQ4_NL,
308
+ gguf.GGMLQuantizationType.IQ4_XS: dequantize_blocks_IQ4_XS,
309
+ }
custom_nodes/ComfyUI-MiniMaxH3-Adapter/gguf_qwen35.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """自定义 Qwen3.5-4B GGUF 加载器(llama.cpp qwen35 arch -> HF state dict)。
2
+
3
+ 动机: city96/ComfyUI-GGUF 与旧版 transformers 均不支持 qwen35 arch 的 GGUF,
4
+ 本项目自研读取 + 反量化 + 张量名映射,产出与训练 checkpoint 同格式的
5
+ state dict(language_model.*,与 load_student 剥离前缀后一致)。
6
+
7
+ 本加载器的全部布局假设均经"逐张量 cos 对比真实 HauhauCS GGUF vs bf16 参考"实证
8
+ (426/426 张量 cos>=0.99,见 diag 记录):
9
+
10
+ 1. 2D 张量数据即 HF [out, in] 行序(元数据 shape 声称 [in, out],二者相反)。
11
+ 反量化 flat 序已是 [out, in],reshape 成 (shape[1], shape[0]) 即可,禁止转置。
12
+ 2. 范数权重(input/post_attention/q_norm/k_norm/output_norm)GGUF 存真值 w,
13
+ 而 HF checkpoint 存增量 (w-1)(Qwen3_5RMSNorm 前向做 * (1 + weight)),
14
+ 加载时统一减 1.0。ssm_norm(Qwen3_5RMSNormGated)无增量约定,不减。
15
+ 3. linear_attn 的 32 个 v-head 参数(A_log/dt_bias/in_proj_a/b 行、
16
+ in_proj_z 及 qkv/conv1d 的 value 段)GGUF 按 value-major 枚举
17
+ (head = v*16 + k),HF 按 key-major(head = k*2 + v),需逆置换。
18
+ 4. ssm_a 存 -A(负衰减),HF 需 A_log = log(A),即 log(-x) 再置换。
19
+
20
+ head 配置 (qwen35_4b): 全注意力 head_dim=256; 线性层 key=16x128 value=32x128; vocab=248320
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+
30
+ try: # ComfyUI 包内加载(节点目录不在 sys.path,必须相对导入)
31
+ from .gguf_dequant import dequantize_tensor
32
+ except ImportError: # 独立脚本/顶层加载
33
+ from gguf_dequant import dequantize_tensor
34
+
35
+ FULL_ATTN_LAYERS = {i for i in range(32) if (i + 1) % 4 == 0} # 3,7,11,...,31
36
+ V_HEADS, V_PER_KEY = 32, 2 # value head 数 = 32, 每个 key head 配 2 个 value head
37
+
38
+
39
+ class GGMLTensor(torch.Tensor):
40
+ """量化块驻留张量(ComfyUI-GGUF 同款): 底层 uint8 量化字节 + 逻辑形状元数据。
41
+
42
+ 前向时由 dequantize_tensor 逐层反量化,避免整模型展开的显存开销。
43
+ 属性必须在 __new__ 设置(torch 子类实例化常跳过 __init__)。
44
+ """
45
+
46
+ def __new__(cls, data=None, tensor_type=None, tensor_shape=None):
47
+ obj = super().__new__(cls, data) if data is not None else super().__new__(cls)
48
+ if tensor_type is not None:
49
+ obj.tensor_type = tensor_type
50
+ obj.tensor_shape = tensor_shape
51
+ return obj
52
+
53
+ def to(self, *args, **kwargs):
54
+ kwargs.pop("dtype", None) # 底层是量化字节,不可 cast dtype,只迁移设备
55
+ if args and isinstance(args[0], torch.dtype):
56
+ args = args[1:]
57
+ new = super().to(*args, **kwargs)
58
+ ts = getattr(self, "tensor_shape", None)
59
+ new.tensor_type = getattr(self, "tensor_type", None)
60
+ new.tensor_shape = ts if ts is not None else tuple(new.data.size())
61
+ return new
62
+
63
+ def clone(self, *args, **kwargs):
64
+ return self
65
+
66
+ def detach(self, *args, **kwargs):
67
+ return self
68
+
69
+ @property
70
+ def shape(self):
71
+ return self.tensor_shape
72
+
73
+
74
+ class QuantizedLinear(nn.Module):
75
+ """前向时反量化权重的 Linear(ComfyUI-GGUF GGMLOps.Linear 同款,纯 torch)。
76
+
77
+ weight 为普通属性而非 Parameter: 避免 Module.to(dtype)/Parameter 包装破坏
78
+ GGMLTensor 的量化元数据;设备在 forward 懒迁移。
79
+ weight_perm: 反量化后的一次性行/列置换(处理 GGUF 的 value-major 头序)。
80
+ """
81
+
82
+ def __init__(self, in_features, out_features, bias=True,
83
+ weight_perm=None, weight_perm_dim=None):
84
+ super().__init__()
85
+ self.in_features = in_features
86
+ self.out_features = out_features
87
+ self.weight = None
88
+ self.bias = None
89
+ self.weight_perm = weight_perm
90
+ self.weight_perm_dim = weight_perm_dim
91
+
92
+ def forward(self, x):
93
+ w = self.weight
94
+ if w is None:
95
+ w = torch.zeros(self.in_features, self.out_features, device=x.device, dtype=x.dtype)
96
+ if w.device != x.device:
97
+ w = self.weight = w.to(x.device)
98
+ if not isinstance(w, GGMLTensor):
99
+ return F.linear(x, w, self.bias)
100
+ w = _dequant_ggml(w, x.dtype)
101
+ if self.weight_perm is not None:
102
+ if self.weight_perm.device != x.device:
103
+ self.weight_perm = self.weight_perm.to(x.device)
104
+ w = w[self.weight_perm] if self.weight_perm_dim == 0 else w[:, self.weight_perm]
105
+ return F.linear(x, w, self.bias)
106
+
107
+
108
+ class QuantizedEmbedding(nn.Module):
109
+ def __init__(self, num_embeddings, embedding_dim, dtype=torch.bfloat16):
110
+ super().__init__()
111
+ self.num_embeddings = num_embeddings
112
+ self.embedding_dim = embedding_dim
113
+ self.dtype = dtype
114
+ self.weight = None
115
+
116
+ def forward(self, x):
117
+ w = self.weight
118
+ if w is None:
119
+ w = torch.zeros(self.num_embeddings, self.embedding_dim, device=x.device, dtype=self.dtype)
120
+ if w.device != x.device:
121
+ w = self.weight = w.to(x.device)
122
+ if not isinstance(w, GGMLTensor):
123
+ return F.embedding(x, w)
124
+ w = _dequant_ggml(w, self.dtype)
125
+ return F.embedding(x, w)
126
+
127
+
128
+ def _qperm_inv32() -> torch.Tensor:
129
+ return torch.tensor([(i % V_PER_KEY) * (V_HEADS // V_PER_KEY) + (i // V_PER_KEY)
130
+ for i in range(V_HEADS)], dtype=torch.long)
131
+
132
+
133
+ def _qperm_vrows() -> torch.Tensor:
134
+ """4096 行(32 头 x 128 维)value-major -> key-major 行序索引。"""
135
+ inv = _qperm_inv32()
136
+ return inv.repeat_interleave(128) * 128 + torch.arange(128).repeat(V_HEADS)
137
+
138
+
139
+ def _qperm_qkv() -> torch.Tensor:
140
+ """8192 行: 前 2 段 key(各 2048 行)保持,末段 4096 行 value 置换。"""
141
+ v_base = 2 * (V_HEADS // V_PER_KEY) * 128 # 4096: 两段 key 行数
142
+ return torch.cat([torch.arange(v_base), v_base + _qperm_vrows()])
143
+
144
+
145
+ def _dequant_ggml(w: "GGMLTensor", dtype: torch.dtype) -> torch.Tensor:
146
+ """GGMLTensor -> fp16/bf16。先 as_subclass 转普通张量(零拷贝)再走 city96 反量化。
147
+
148
+ 不能直接传 GGMLTensor: 其派生实例(reshape/view)无 tensor_shape 元数据,
149
+ .data 属性同样丢失元数据,会触发 shape property 的 AttributeError。
150
+ """
151
+ from gguf import GGMLQuantizationType
152
+ try:
153
+ from .gguf_dequant import dequantize
154
+ except ImportError:
155
+ from gguf_dequant import dequantize
156
+
157
+ qtype = w.tensor_type
158
+ raw = w.as_subclass(torch.Tensor)
159
+ if qtype in (None, GGMLQuantizationType.F32, GGMLQuantizationType.F16):
160
+ return raw.to(dtype)
161
+ return dequantize(raw, qtype, w.tensor_shape, dtype=dtype).to(dtype)
162
+
163
+
164
+ def _head_perm() -> torch.Tensor:
165
+ """HF key-major head i -> GGUF value-major 位置: (i % 2) * 16 + (i // 2)。"""
166
+ return torch.tensor([(i % V_PER_KEY) * (V_HEADS // V_PER_KEY) + (i // V_PER_KEY)
167
+ for i in range(V_HEADS)], dtype=torch.long)
168
+
169
+
170
+ def _v_reorder(w: torch.Tensor) -> torch.Tensor:
171
+ """GGUF value-major -> HF key-major 行序逆置换(仅 linear_attn 的 v-head 参数)。"""
172
+ if w.numel() == V_HEADS:
173
+ return w[_head_perm()]
174
+ if w.dim() == 2 and w.shape[0] == V_HEADS:
175
+ return w[_head_perm()]
176
+ if w.dim() == 2 and w.shape[0] == V_HEADS * 128: # 4096 = 32 头 x 128 维
177
+ return w.view(V_HEADS, 128, -1)[_head_perm()].reshape(w.shape)
178
+ if w.dim() == 2 and w.shape[0] == 2 * V_HEADS * 128: # 8192: 前 2 段是 key,末段是 value
179
+ w = w.clone()
180
+ w[V_HEADS * 128:] = w[V_HEADS * 128:].view(V_HEADS, 128, -1)[_head_perm()].reshape(-1, w.shape[1])
181
+ return w
182
+ if w.dim() == 2 and w.shape[1] == V_HEADS * 128: # out_proj: value 维在列
183
+ return w.view(w.shape[0], V_HEADS, 128)[:, _head_perm(), :].reshape(w.shape)
184
+ return w
185
+
186
+
187
+ def _dequant(t, dtype: torch.dtype = torch.float16) -> torch.Tensor:
188
+ """gguf ReaderTensor -> torch 反量化。
189
+
190
+ 2D 张量按文件实际 [out, in] 行序 reshape;1D 按 t.shape。
191
+ """
192
+ tt = torch.from_numpy(t.data)
193
+ tt.tensor_type = t.tensor_type
194
+ tt.tensor_shape = tuple(int(s) for s in t.shape)
195
+ w = dequantize_tensor(tt, dtype=dtype, dequant_dtype=torch.float32)
196
+ if len(t.shape) == 2:
197
+ return w.reshape(int(t.shape[1]), int(t.shape[0])).contiguous()
198
+ return w.reshape(tuple(int(s) for s in t.shape)).contiguous()
199
+
200
+
201
+ def _norm(w: torch.Tensor) -> torch.Tensor:
202
+ """RMSNorm 增量约定: GGUF 真值 -> HF checkpoint 格式 (w - 1)。"""
203
+ return w - 1.0
204
+
205
+
206
+ def _map_gguf(path: str, quantized: bool = False):
207
+ """GGUF -> HF state dict。quantized=True 时量化 2D 张量产出 GGMLTensor(驻留量化块)。
208
+
209
+ 返回 (sd, perms, skipped)。perms: {hf_key: (perm_tensor, dim)} 反量化后的行/列置换。
210
+ """
211
+ from gguf import GGUFReader, GGMLQuantizationType
212
+
213
+ reader = GGUFReader(path)
214
+ sd: dict[str, torch.Tensor] = {}
215
+ perms: dict[str, tuple[torch.Tensor, int]] = {}
216
+ skipped: list[str] = []
217
+
218
+ def qmat(t, hf_key: str, perm: torch.Tensor | None = None, dim: int = 0):
219
+ if quantized and t.tensor_type not in (None, GGMLQuantizationType.F32, GGMLQuantizationType.F16):
220
+ sd[hf_key] = GGMLTensor(torch.from_numpy(t.data), tensor_type=t.tensor_type,
221
+ tensor_shape=(int(t.shape[1]), int(t.shape[0])))
222
+ if perm is not None:
223
+ perms[hf_key] = (perm, dim)
224
+ else:
225
+ w = _dequant(t)
226
+ if perm is not None:
227
+ w = w[perm] if dim == 0 else w[:, perm]
228
+ sd[hf_key] = w
229
+
230
+ for t in reader.tensors:
231
+ n = t.name
232
+ if n == "token_embd.weight":
233
+ qmat(t, "language_model.embed_tokens.weight")
234
+ elif n == "output_norm.weight":
235
+ sd["language_model.norm.weight"] = _norm(_dequant(t))
236
+ elif n == "output.weight":
237
+ qmat(t, "language_model.lm_head.weight")
238
+ elif n.startswith("blk."):
239
+ parts = n.split(".")
240
+ i, rest = int(parts[1]), ".".join(parts[2:])
241
+ base = f"language_model.layers.{i}"
242
+ if rest == "attn_norm.weight":
243
+ sd[f"{base}.input_layernorm.weight"] = _norm(_dequant(t))
244
+ elif rest in ("post_attention_norm.weight", "attn_output_norm.weight"):
245
+ sd[f"{base}.post_attention_layernorm.weight"] = _norm(_dequant(t))
246
+ elif rest == "ffn_gate.weight":
247
+ qmat(t, f"{base}.mlp.gate_proj.weight")
248
+ elif rest == "ffn_up.weight":
249
+ qmat(t, f"{base}.mlp.up_proj.weight")
250
+ elif rest == "ffn_down.weight":
251
+ qmat(t, f"{base}.mlp.down_proj.weight")
252
+ elif rest == "attn_q.weight": # Q+Gate 融合,HF q_proj 同布局
253
+ qmat(t, f"{base}.self_attn.q_proj.weight")
254
+ elif rest == "attn_k.weight":
255
+ qmat(t, f"{base}.self_attn.k_proj.weight")
256
+ elif rest == "attn_v.weight":
257
+ qmat(t, f"{base}.self_attn.v_proj.weight")
258
+ elif rest == "attn_output.weight":
259
+ qmat(t, f"{base}.self_attn.o_proj.weight")
260
+ elif rest == "attn_q_norm.weight":
261
+ sd[f"{base}.self_attn.q_norm.weight"] = _norm(_dequant(t))
262
+ elif rest == "attn_k_norm.weight":
263
+ sd[f"{base}.self_attn.k_norm.weight"] = _norm(_dequant(t))
264
+ elif rest == "attn_qkv.weight": # 融合 qkv,v 段头序置换
265
+ qmat(t, f"{base}.linear_attn.in_proj_qkv.weight", perm=_qperm_qkv())
266
+ elif rest == "attn_gate.weight":
267
+ qmat(t, f"{base}.linear_attn.in_proj_z.weight", perm=_qperm_vrows())
268
+ elif rest == "ssm_conv1d.weight":
269
+ w = _v_reorder(_dequant(t)) # (conv_dim, 4) -> 深度卷积 (conv_dim, 1, 4)
270
+ sd[f"{base}.linear_attn.conv1d.weight"] = w.unsqueeze(1)
271
+ elif rest == "ssm_dt.bias":
272
+ sd[f"{base}.linear_attn.dt_bias"] = _v_reorder(_dequant(t))
273
+ elif rest in ("ssm_a", "ssm_a_noscan"):
274
+ sd[f"{base}.linear_attn.A_log"] = _v_reorder(-_dequant(t)).clamp_min(1e-12).log()
275
+ elif rest == "ssm_beta.weight":
276
+ qmat(t, f"{base}.linear_attn.in_proj_b.weight", perm=_qperm_inv32())
277
+ elif rest == "ssm_alpha.weight":
278
+ qmat(t, f"{base}.linear_attn.in_proj_a.weight", perm=_qperm_inv32())
279
+ elif rest == "ssm_norm.weight":
280
+ sd[f"{base}.linear_attn.norm.weight"] = _dequant(t) # Gated 范数,无增量约定
281
+ elif rest == "ssm_out.weight":
282
+ qmat(t, f"{base}.linear_attn.out_proj.weight", perm=_qperm_vrows(), dim=1)
283
+ else:
284
+ skipped.append(n)
285
+ else:
286
+ skipped.append(n)
287
+
288
+ if skipped:
289
+ print(f" [gguf] 忽略 {len(skipped)} 个未映射张量: {skipped[:6]}", flush=True)
290
+ return sd, perms, skipped
291
+
292
+
293
+ def load_gguf_state_dict(path: str) -> dict[str, torch.Tensor]:
294
+ """fp16 全展开模式(对比/调试用)。"""
295
+ sd, _, _ = _map_gguf(path, quantized=False)
296
+ return sd
297
+
298
+
299
+ def _build_model(config_path: str | None):
300
+ from transformers import AutoConfig, AutoModel
301
+ cfg = AutoConfig.from_pretrained(config_path or os.path.join(os.path.dirname(__file__), "qwen35_config.json"))
302
+ return AutoModel.from_config(cfg)
303
+
304
+
305
+ def build_quantized_model(state_dict: dict[str, torch.Tensor],
306
+ perms: dict[str, tuple[torch.Tensor, int]],
307
+ config_path: str | None = None):
308
+ """量化模型: GGMLTensor 权重 + 前向时逐层反量化的 QuantizedLinear/Embedding。
309
+
310
+ 只替换实际带量化权重的层;范数/conv1d 等普通张量走原层。
311
+ """
312
+ model = _build_model(config_path)
313
+ quant_keys = {k for k, v in state_dict.items() if isinstance(v, GGMLTensor)}
314
+
315
+ def walk(module, prefix: str = ""):
316
+ for name, child in list(module.named_children()):
317
+ full = f"{prefix}.{name}" if prefix else name
318
+ if isinstance(child, nn.Linear) and f"{full}.weight" in quant_keys:
319
+ perm, dim = perms.get(f"{full}.weight", (None, None))
320
+ setattr(module, name, QuantizedLinear(child.in_features, child.out_features,
321
+ child.bias is not None, perm, dim))
322
+ elif isinstance(child, nn.Embedding) and f"{full}.weight" in quant_keys:
323
+ setattr(module, name, QuantizedEmbedding(child.num_embeddings, child.embedding_dim))
324
+ else:
325
+ walk(child, full)
326
+
327
+ walk(model)
328
+ for k, v in state_dict.items():
329
+ parts = k.split(".")
330
+ obj = model
331
+ for p in parts[:-1]:
332
+ obj = getattr(obj, p)
333
+ # GGMLTensor 不包 Parameter(推理无梯度,且 Parameter 会破坏量化元数据)
334
+ setattr(obj, parts[-1], v if isinstance(v, GGMLTensor)
335
+ else torch.nn.Parameter(v, requires_grad=False))
336
+ model_keys = set(model.state_dict()) | {k for k, v in state_dict.items() if isinstance(v, GGMLTensor)}
337
+ unexpected = sorted(set(state_dict) - model_keys)
338
+ return model, unexpected
339
+
340
+
341
+ def load_gguf_state_dict(path: str) -> dict[str, torch.Tensor]:
342
+ from gguf import GGUFReader
343
+
344
+ reader = GGUFReader(path)
345
+ sd: dict[str, torch.Tensor] = {}
346
+ skipped: list[str] = []
347
+
348
+ for t in reader.tensors:
349
+ n = t.name
350
+ if n == "token_embd.weight":
351
+ sd["language_model.embed_tokens.weight"] = _dequant(t)
352
+ elif n == "output_norm.weight":
353
+ sd["language_model.norm.weight"] = _norm(_dequant(t))
354
+ elif n == "output.weight":
355
+ sd["language_model.lm_head.weight"] = _dequant(t) # 可能绑定 embed,可缺省
356
+ elif n.startswith("blk."):
357
+ parts = n.split(".")
358
+ i, rest = int(parts[1]), ".".join(parts[2:])
359
+ base = f"language_model.layers.{i}"
360
+ if rest == "attn_norm.weight":
361
+ sd[f"{base}.input_layernorm.weight"] = _norm(_dequant(t))
362
+ elif rest in ("post_attention_norm.weight", "attn_output_norm.weight"):
363
+ sd[f"{base}.post_attention_layernorm.weight"] = _norm(_dequant(t))
364
+ elif rest == "ffn_gate.weight":
365
+ sd[f"{base}.mlp.gate_proj.weight"] = _dequant(t)
366
+ elif rest == "ffn_up.weight":
367
+ sd[f"{base}.mlp.up_proj.weight"] = _dequant(t)
368
+ elif rest == "ffn_down.weight":
369
+ sd[f"{base}.mlp.down_proj.weight"] = _dequant(t)
370
+ elif rest == "attn_q.weight": # Q+Gate 融合,HF q_proj 同布局
371
+ sd[f"{base}.self_attn.q_proj.weight"] = _dequant(t)
372
+ elif rest == "attn_k.weight":
373
+ sd[f"{base}.self_attn.k_proj.weight"] = _dequant(t)
374
+ elif rest == "attn_v.weight":
375
+ sd[f"{base}.self_attn.v_proj.weight"] = _dequant(t)
376
+ elif rest == "attn_output.weight":
377
+ sd[f"{base}.self_attn.o_proj.weight"] = _dequant(t)
378
+ elif rest == "attn_q_norm.weight":
379
+ sd[f"{base}.self_attn.q_norm.weight"] = _norm(_dequant(t))
380
+ elif rest == "attn_k_norm.weight":
381
+ sd[f"{base}.self_attn.k_norm.weight"] = _norm(_dequant(t))
382
+ elif rest == "attn_qkv.weight": # 融合 qkv,HF in_proj_qkv 同布局
383
+ sd[f"{base}.linear_attn.in_proj_qkv.weight"] = _v_reorder(_dequant(t))
384
+ elif rest == "attn_gate.weight":
385
+ sd[f"{base}.linear_attn.in_proj_z.weight"] = _v_reorder(_dequant(t))
386
+ elif rest == "ssm_conv1d.weight":
387
+ w = _v_reorder(_dequant(t)) # (conv_dim, 4) -> 深度卷积 (conv_dim, 1, 4)
388
+ sd[f"{base}.linear_attn.conv1d.weight"] = w.unsqueeze(1)
389
+ elif rest == "ssm_dt.bias":
390
+ sd[f"{base}.linear_attn.dt_bias"] = _v_reorder(_dequant(t))
391
+ elif rest in ("ssm_a", "ssm_a_noscan"):
392
+ sd[f"{base}.linear_attn.A_log"] = _v_reorder(-_dequant(t)).clamp_min(1e-12).log()
393
+ elif rest == "ssm_beta.weight":
394
+ sd[f"{base}.linear_attn.in_proj_b.weight"] = _v_reorder(_dequant(t))
395
+ elif rest == "ssm_alpha.weight":
396
+ sd[f"{base}.linear_attn.in_proj_a.weight"] = _v_reorder(_dequant(t))
397
+ elif rest == "ssm_norm.weight":
398
+ sd[f"{base}.linear_attn.norm.weight"] = _dequant(t) # Gated 范数,无增量约定
399
+ elif rest == "ssm_out.weight":
400
+ sd[f"{base}.linear_attn.out_proj.weight"] = _v_reorder(_dequant(t))
401
+ else:
402
+ skipped.append(n)
403
+ else:
404
+ skipped.append(n)
405
+
406
+ if skipped:
407
+ print(f" [gguf] 忽略 {len(skipped)} 个未映射张量: {skipped[:6]}", flush=True)
408
+ return sd
409
+
410
+
411
+ def build_hf_model(state_dict: dict[str, torch.Tensor], config_path: str | None = None):
412
+ """按 state dict 构建 HF 模型(Qwen3_5Model)。
413
+
414
+ vision 权重缺省(text-only GGUF)属正常;仅用 language_model 子模块前向。
415
+ 返回 (model, missing, unexpected)。
416
+ """
417
+ from transformers import AutoConfig, AutoModel
418
+
419
+ cfg = AutoConfig.from_pretrained(config_path or os.path.join(os.path.dirname(__file__), "qwen35_config.json"))
420
+ model = AutoModel.from_config(cfg)
421
+ missing, unexpected = model.load_state_dict(state_dict, strict=False)
422
+ return model, missing, unexpected
423
+
424
+
425
+ def load_gguf_model(path: str, config_path: str | None = None):
426
+ """fp16 全展开加载(对比/调试用)。返回 (model, info)。"""
427
+ sd, _, _ = _map_gguf(path, quantized=False)
428
+ model, missing, unexpected = build_hf_model(sd, config_path)
429
+ return model, {"keys": len(sd), "missing": len(missing), "unexpected": len(unexpected)}
430
+
431
+
432
+ def load_gguf_model_quantized(path: str, config_path: str | None = None):
433
+ """量化驻留加载(ComfyUI 生产路径): 量化块���驻,前向时逐层反量化。
434
+
435
+ embed/lm_head 在加载期 CPU 一次性反量化成 bf16(GPU 上 Q6_K 反量化的
436
+ float32 中间张量峰值达数 GB,远大于 1.27GB 常驻收益)。
437
+ 返回 (model, info)。
438
+ """
439
+ sd, perms, _ = _map_gguf(path, quantized=True)
440
+ for k in ("language_model.embed_tokens.weight", "language_model.lm_head.weight"):
441
+ v = sd.get(k)
442
+ if isinstance(v, GGMLTensor):
443
+ sd[k] = _dequant_ggml(v, torch.bfloat16)
444
+ model, unexpected = build_quantized_model(sd, perms, config_path)
445
+ return model, {"keys": len(sd), "missing": 0, "unexpected": len(unexpected), "quantized": True}
custom_nodes/ComfyUI-MiniMaxH3-Adapter/inspect_gguf.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """检查 GGUF 文件的张量布局(自定义加载器的映射依据)。
3
+
4
+ 用法:
5
+ python inspect_gguf.py <模型.gguf> [--limit 200]
6
+ 输出:
7
+ - 每个张量的名字 / 形状 / 量化类型(按层分组)
8
+ - 顶层张量(token_embd / output_norm / output)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+
15
+
16
+ def main() -> None:
17
+ ap = argparse.ArgumentParser()
18
+ ap.add_argument("gguf_path")
19
+ ap.add_argument("--limit", type=int, default=0, help="只显示前 N 个张量(默认全量)")
20
+ args = ap.parse_args()
21
+
22
+ try:
23
+ from gguf import GGUFReader
24
+ except ImportError as e:
25
+ print(f"需要 gguf 包: pip install gguf ({e})")
26
+ sys.exit(1)
27
+
28
+ reader = GGUFReader(args.gguf_path)
29
+ tensors = reader.tensors
30
+ print(f"文件: {args.gguf_path}")
31
+ print(f"张量总数: {len(tensors)}")
32
+ try:
33
+ arch = reader.fields.get("general.architecture")
34
+ print(f"architecture: {arch.parts[-1] if arch else '?'}")
35
+ except Exception:
36
+ pass
37
+
38
+ # 按层分组
39
+ from collections import OrderedDict
40
+ layers: dict[str, list] = OrderedDict()
41
+ top = []
42
+ for t in tensors:
43
+ name = t.name
44
+ if name.startswith("blk."):
45
+ layer = name.split(".")[1]
46
+ layers.setdefault(layer, []).append(t)
47
+ else:
48
+ top.append(t)
49
+
50
+ print(f"\n===== 顶层张量 ({len(top)}) =====")
51
+ for t in top:
52
+ print(f" {t.name} shape={list(t.shape)} {t.tensor_type.name}")
53
+
54
+ shown = 0
55
+ for layer, ts in layers.items():
56
+ print(f"\n===== 第 {layer} 层 ({len(ts)} 张量) =====")
57
+ for t in ts:
58
+ print(f" {t.name} shape={list(t.shape)} {t.tensor_type.name}")
59
+ shown += 1
60
+ if args.limit and shown >= args.limit:
61
+ print("...(截断)")
62
+ sys.exit(0)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
custom_nodes/ComfyUI-MiniMaxH3-Adapter/nodes.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ComfyUI 节点: MiniMax H3 Adapter Loader(假 CLIP 注入)。
2
+
3
+ 用法(T2V 工作流):
4
+ UNETLoader(fl2va) -> model
5
+ MiniMaxH3AdapterLoader(student=<Qwen3.5-4B 文件夹>, adapter=<adapter.safetensors>) -> clip
6
+ VAELoader(video_vae) -> vae
7
+ MiniMaxH3ImageToVideo(clip=clip, vae=vae, prompt=..., width, height, length) -> positive + latent
8
+ BasicGuider(model, positive) -> RandomNoise -> SamplerCustomAdvanced -> VAEDecode -> SaveVideo
9
+
10
+ 原理:
11
+ ComfyUI 的 MiniMaxH3 DiT (comfy/ldm/minimax/model.py) 在
12
+ preprocess_text_embeds / _forward 中条件式跳过投影:
13
+ if text_states.shape[-1] != self.hidden_size: # 5376
14
+ text_states = token_refiner(condition_proj(text_states))
15
+ 适配器输出已是 [1, S_T, 5376](post-refiner),DiT 直接使用,零手术。
16
+
17
+ 官方 MiniMaxH3ImageToVideo 只对 clip 调用 tokenize(prompt) +
18
+ encode_from_tokens_scheduled(tokens),故用鸭子类型 CLIP 对象替换即可,
19
+ latent 创建 / keyframe / duration 网格全部保留。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ import torch
26
+
27
+ import folder_paths
28
+ import comfy.utils
29
+ import comfy.model_management as mm
30
+
31
+ from .adapter.model import H3Adapter
32
+ from .adapter_clip import MiniMaxH3AdapterCLIP, MiniMaxH3AdapterFromCLIP
33
+ from .student import StudentTextEncoder, get_torch_device
34
+
35
+ ADAPTER_FOLDER = "minimax_h3_adapter" # models/minimax_h3_adapter/
36
+ STUDENT_FOLDER = "minimax_h3_student" # -> models/text_encoders/
37
+
38
+
39
+ def _h3_tokenizer() -> object:
40
+ from transformers import AutoTokenizer
41
+ tok_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tokenizer")
42
+ return AutoTokenizer.from_pretrained(tok_dir)
43
+
44
+
45
+ def _student_folder_options() -> list[str]:
46
+ """models/text_encoders/ 下的子目录(HF 格式)或 .gguf 文件(transformers GGUF 加载)。"""
47
+ opts = []
48
+ for root in folder_paths.get_folder_paths(STUDENT_FOLDER):
49
+ if os.path.isdir(root):
50
+ opts += [d for d in sorted(os.listdir(root))
51
+ if os.path.isdir(os.path.join(root, d)) and os.path.exists(os.path.join(root, d, "config.json"))]
52
+ opts += [f for f in sorted(os.listdir(root)) if f.endswith(".gguf")]
53
+ return opts or ["<把 Qwen3.5-4B 文件夹或 .gguf 放到 models/text_encoders/>"]
54
+
55
+
56
+ class MiniMaxH3AdapterLoader:
57
+ @classmethod
58
+ def INPUT_TYPES(cls):
59
+ return {"required": {
60
+ "student": (_student_folder_options(),),
61
+ "adapter": (folder_paths.get_filename_list(ADAPTER_FOLDER),),
62
+ }, "optional": {
63
+ # 留空 = 整卡;24GB 及以下填 "16GiB" 等启用层间 offload
64
+ "gpu_mem": ("STRING", {"default": ""}),
65
+ }}
66
+
67
+ RETURN_TYPES = ("CLIP",)
68
+ RETURN_NAMES = ("clip",)
69
+ FUNCTION = "load_adapter"
70
+ CATEGORY = "model/conditioning/minimax"
71
+
72
+ def load_adapter(self, student: str, adapter: str, gpu_mem: str = ""):
73
+ dev = get_torch_device()
74
+ model = _load_adapter_weights(adapter, dev)
75
+
76
+ student_root = folder_paths.get_folder_paths(STUDENT_FOLDER)[0]
77
+ student_dir = os.path.join(student_root, student)
78
+ stu = StudentTextEncoder(student_dir, lowvram=True, gpu_mem=gpu_mem)
79
+
80
+ tok = _h3_tokenizer()
81
+ clip = MiniMaxH3AdapterCLIP(model, stu, tok, dev)
82
+ return (clip,)
83
+
84
+
85
+ def _load_adapter_weights(adapter: str, dev: torch.device) -> H3Adapter:
86
+ """加载适配器 safetensors -> bf16 H3Adapter(容错 model. 前缀)。"""
87
+ adapter_path = folder_paths.get_full_path(ADAPTER_FOLDER, adapter)
88
+ sd = comfy.utils.load_torch_file(adapter_path)
89
+ if any(k.startswith("model.") for k in sd):
90
+ sd = {k[len("model."):]: v for k, v in sd.items()}
91
+ model = H3Adapter().to(torch.bfloat16)
92
+ missing, unexpected = model.load_state_dict(sd, strict=False)
93
+ assert not missing, f"adapter 缺 key: {missing[:10]}"
94
+ if unexpected:
95
+ print(f" [adapter] ignored {len(unexpected)} unexpected keys", flush=True)
96
+ model.to(dev).eval()
97
+ for m in model.modules():
98
+ if hasattr(m, "use_sdpa"):
99
+ m.use_sdpa = True
100
+ print(f" [adapter] loaded {adapter} ({sum(p.numel() for p in model.parameters())/1e9:.3f}B)", flush=True)
101
+ return model
102
+
103
+
104
+ class MiniMaxH3AdapterFromCLIPLoader:
105
+ """CLIP→CLIP 包装: 学生 CLIP(CLIPLoader GGUF 等)的 encode 输出经适配器 -> 可喂给官方 MiniMaxH3ImageToVideo。
106
+
107
+ 工作流:
108
+ CLIPLoader(GGUF, 学生模型) -> clip
109
+ MiniMaxH3AdapterFromCLIPLoader(clip=clip, adapter=adapter_stage2.safetensors) -> clip'
110
+ MiniMaxH3ImageToVideo(clip=clip', ...) # 官方节点原样
111
+ """
112
+
113
+ @classmethod
114
+ def INPUT_TYPES(cls):
115
+ return {"required": {
116
+ "clip": ("CLIP",),
117
+ "adapter": (folder_paths.get_filename_list(ADAPTER_FOLDER),),
118
+ }}
119
+
120
+ RETURN_TYPES = ("CLIP",)
121
+ RETURN_NAMES = ("clip",)
122
+ FUNCTION = "wrap_clip"
123
+ CATEGORY = "model/conditioning/minimax"
124
+
125
+ def wrap_clip(self, clip, adapter):
126
+ dev = get_torch_device()
127
+ model = _load_adapter_weights(adapter, dev)
128
+ wrapped = MiniMaxH3AdapterFromCLIP(clip, model, _h3_tokenizer(), dev)
129
+ return (wrapped,)
custom_nodes/ComfyUI-MiniMaxH3-Adapter/qwen35_config.json ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5ForConditionalGeneration"
4
+ ],
5
+ "image_token_id": 248056,
6
+ "model_type": "qwen3_5",
7
+ "text_config": {
8
+ "attention_bias": false,
9
+ "attention_dropout": 0.0,
10
+ "attn_output_gate": true,
11
+ "dtype": "bfloat16",
12
+ "eos_token_id": 248044,
13
+ "full_attention_interval": 4,
14
+ "head_dim": 256,
15
+ "hidden_act": "silu",
16
+ "hidden_size": 2560,
17
+ "initializer_range": 0.02,
18
+ "intermediate_size": 9216,
19
+ "layer_types": [
20
+ "linear_attention",
21
+ "linear_attention",
22
+ "linear_attention",
23
+ "full_attention",
24
+ "linear_attention",
25
+ "linear_attention",
26
+ "linear_attention",
27
+ "full_attention",
28
+ "linear_attention",
29
+ "linear_attention",
30
+ "linear_attention",
31
+ "full_attention",
32
+ "linear_attention",
33
+ "linear_attention",
34
+ "linear_attention",
35
+ "full_attention",
36
+ "linear_attention",
37
+ "linear_attention",
38
+ "linear_attention",
39
+ "full_attention",
40
+ "linear_attention",
41
+ "linear_attention",
42
+ "linear_attention",
43
+ "full_attention",
44
+ "linear_attention",
45
+ "linear_attention",
46
+ "linear_attention",
47
+ "full_attention",
48
+ "linear_attention",
49
+ "linear_attention",
50
+ "linear_attention",
51
+ "full_attention"
52
+ ],
53
+ "linear_conv_kernel_dim": 4,
54
+ "linear_key_head_dim": 128,
55
+ "linear_num_key_heads": 16,
56
+ "linear_num_value_heads": 32,
57
+ "linear_value_head_dim": 128,
58
+ "max_position_embeddings": 262144,
59
+ "mlp_only_layers": [],
60
+ "model_type": "qwen3_5_text",
61
+ "mtp_num_hidden_layers": 1,
62
+ "mtp_use_dedicated_embeddings": false,
63
+ "num_attention_heads": 16,
64
+ "num_hidden_layers": 32,
65
+ "num_key_value_heads": 4,
66
+ "rms_norm_eps": 1e-06,
67
+ "tie_word_embeddings": true,
68
+ "use_cache": true,
69
+ "vocab_size": 248320,
70
+ "mamba_ssm_dtype": "float32",
71
+ "rope_parameters": {
72
+ "mrope_interleaved": true,
73
+ "mrope_section": [
74
+ 11,
75
+ 11,
76
+ 10
77
+ ],
78
+ "rope_type": "default",
79
+ "rope_theta": 10000000,
80
+ "partial_rotary_factor": 0.25
81
+ }
82
+ },
83
+ "tie_word_embeddings": true,
84
+ "transformers_version": "4.57.0.dev0",
85
+ "video_token_id": 248057,
86
+ "vision_config": {
87
+ "deepstack_visual_indexes": [],
88
+ "depth": 24,
89
+ "hidden_act": "gelu_pytorch_tanh",
90
+ "hidden_size": 1024,
91
+ "in_channels": 3,
92
+ "initializer_range": 0.02,
93
+ "intermediate_size": 4096,
94
+ "model_type": "qwen3_5",
95
+ "num_heads": 16,
96
+ "num_position_embeddings": 2304,
97
+ "out_hidden_size": 2560,
98
+ "patch_size": 16,
99
+ "spatial_merge_size": 2,
100
+ "temporal_patch_size": 2
101
+ },
102
+ "vision_end_token_id": 248054,
103
+ "vision_start_token_id": 248053
104
+ }
custom_nodes/ComfyUI-MiniMaxH3-Adapter/student.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qwen3.5-4B 学生模型加载 + 隐藏态提取 + ComfyUI 感知的显存 offload。
2
+
3
+ load_student 移植自训练管线 extract_student_features.py:
4
+ - 修复 checkpoint 的 `model.` 多余前缀(否则全层随机初始化)
5
+ - 隐藏态取 hidden_states[-1](tie_last_hidden_states -> post-final-norm,与训练/评估同源)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from collections import defaultdict
12
+
13
+ import torch
14
+
15
+ try: # ComfyUI 内运行
16
+ import comfy.model_management as comfy_mm
17
+
18
+ def get_torch_device() -> torch.device:
19
+ return comfy_mm.get_torch_device()
20
+
21
+ def soft_empty_cache() -> None:
22
+ comfy_mm.soft_empty_cache()
23
+ except ImportError: # 独立测试环境
24
+ def get_torch_device() -> torch.device:
25
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
26
+
27
+ def soft_empty_cache() -> None:
28
+ if torch.cuda.is_available():
29
+ torch.cuda.empty_cache()
30
+
31
+
32
+ def load_student(model_dir: str) -> torch.nn.Module:
33
+ """加载 Qwen3.5-4B 权重(CPU),剥离 `model.` 前缀。
34
+
35
+ model_dir 为 .gguf 文件时走自研加载器(gguf_qwen35):
36
+ 读取 + 反量化 + 张量名映射均自实现,不依赖 transformers 的 GGUF 支持,
37
+ 产出与训练 checkpoint 同格式的 state dict。
38
+ """
39
+ from safetensors import safe_open
40
+ from transformers import AutoConfig, AutoModel
41
+
42
+ if model_dir.endswith(".gguf"):
43
+ try:
44
+ from . import gguf_qwen35
45
+ except ImportError:
46
+ import gguf_qwen35
47
+ # 生产路径: 量化块驻留 + 前向逐层反量化(显存 ≈ 量化大小而非 fp16 展开)
48
+ model, info = gguf_qwen35.load_gguf_model_quantized(model_dir)
49
+ print(f" [student] GGUF loaded: {info}", flush=True)
50
+ return model
51
+
52
+ cfg = AutoConfig.from_pretrained(model_dir)
53
+ model = AutoModel.from_config(cfg) # 无权重实例化,避免 from_pretrained 错误初始化
54
+ index = json.load(open(os.path.join(model_dir, "model.safetensors.index.json")))
55
+ by_shard: dict[str, list[str]] = defaultdict(list)
56
+ for k, sh in index["weight_map"].items():
57
+ by_shard[sh].append(k)
58
+ sd: dict[str, torch.Tensor] = {}
59
+ for shard, keys in by_shard.items():
60
+ with safe_open(os.path.join(model_dir, shard), framework="pt", device="cpu") as sf:
61
+ for k in keys:
62
+ newk = k[len("model."):] if k.startswith("model.") else k
63
+ sd[newk] = sf.get_tensor(k)
64
+ missing, unexpected = model.load_state_dict(sd, strict=False)
65
+ del sd
66
+ assert not missing, f"missing after prefix strip: {missing[:10]}"
67
+ if unexpected:
68
+ print(f" [student] ignored {len(unexpected)} unexpected keys (e.g. {unexpected[:3]})", flush=True)
69
+ return model
70
+
71
+
72
+ def find_language_model(model: torch.nn.Module) -> torch.nn.Module:
73
+ for name in ("language_model", "model", "text_model"):
74
+ if hasattr(model, name):
75
+ sub = getattr(model, name)
76
+ if hasattr(sub, "layers") or hasattr(sub, "config"):
77
+ return sub
78
+ raise RuntimeError(f"cannot locate language model submodule; attrs={[n for n in dir(model) if not n.startswith('_')]}")
79
+
80
+
81
+ def _move_plain_tensors(module: torch.nn.Module, device: torch.device) -> None:
82
+ """迁移普通 tensor 属性(GGMLTensor 量化权重/embed 权重等非 Parameter/buffer)。
83
+
84
+ nn.Module.to() 只迁移 Parameter 与 registered buffer,量化权重是普通属性,
85
+ 必须手动搬——否则 encode 后 offload 不彻底,量化权重滞留 GPU。
86
+ """
87
+ for name, attr in list(module.__dict__.items()):
88
+ if isinstance(attr, torch.Tensor) and not isinstance(attr, torch.nn.Parameter):
89
+ setattr(module, name, attr.to(device))
90
+ for child in module.children():
91
+ _move_plain_tensors(child, device)
92
+
93
+
94
+ class StudentTextEncoder:
95
+ """学生模型封装: prompt -> [S_S, 2560] bf16 hidden(post-final-norm)。
96
+
97
+ gpu_mem=""(默认): 整模型进 GPU;encode 后 lowvram 搬回 CPU。
98
+ gpu_mem="5GiB"/"16GiB" 等: accelerate 层间 offload(权重驻留 RAM、按层流式进 GPU),
99
+ 适合 24GB 及以下卡(33B DiT + 4B 学生错峰)。
100
+ lowvram=True(默认): encode 完成后立即释放 GPU 占用,让位给 DiT 采样。
101
+ """
102
+
103
+ def __init__(self, model_dir: str, dtype: torch.dtype = torch.bfloat16,
104
+ lowvram: bool = True, gpu_mem: str = ""):
105
+ self.model_dir = model_dir
106
+ self.dtype = dtype
107
+ self.lowvram = lowvram
108
+ self.gpu_mem = gpu_mem
109
+ self._dispatched = False
110
+ self._model: torch.nn.Module | None = None
111
+ self._lm: torch.nn.Module | None = None
112
+ self._tok = None
113
+
114
+ def _ensure_ready(self):
115
+ if self._model is None:
116
+ from transformers import AutoTokenizer
117
+ model = load_student(self.model_dir).to(self.dtype)
118
+ if self.gpu_mem:
119
+ from accelerate import dispatch_model, infer_auto_device_map
120
+ max_memory = {"cpu": "20GiB", 0: self.gpu_mem}
121
+ no_split = getattr(model, "_no_split_modules", None) or None
122
+ device_map = infer_auto_device_map(model, max_memory=max_memory,
123
+ no_split_module_classes=no_split)
124
+ dispatch_model(model, device_map=device_map)
125
+ self._dispatched = True
126
+ self._model = model
127
+ self._lm = find_language_model(self._model)
128
+ if self.model_dir.endswith(".gguf"):
129
+ tok_dir = os.path.dirname(self.model_dir)
130
+ if not os.path.exists(os.path.join(tok_dir, "tokenizer.json")):
131
+ raise RuntimeError(
132
+ f"GGUF 同目录 {tok_dir} 缺少 tokenizer.json —— 请把 Qwen3.5-4B 的 tokenizer 文件"
133
+ "(tokenizer.json / tokenizer_config.json / vocab.json / merges.txt / chat_template.jinja)拷到该目录")
134
+ self._tok = AutoTokenizer.from_pretrained(tok_dir)
135
+ else:
136
+ self._tok = AutoTokenizer.from_pretrained(self.model_dir)
137
+ print(f" [student] loaded {self.model_dir} (gpu_mem={self.gpu_mem or 'whole'})", flush=True)
138
+ dev = get_torch_device()
139
+ if not self._dispatched and next(self._lm.parameters()).device != dev:
140
+ self._model.to(dev)
141
+
142
+ def offload(self) -> None:
143
+ if self._lm is None:
144
+ return
145
+ if self._dispatched:
146
+ soft_empty_cache()
147
+ return
148
+ self._model.to("cpu")
149
+ _move_plain_tensors(self._model, torch.device("cpu"))
150
+ soft_empty_cache()
151
+
152
+ @torch.no_grad()
153
+ def __call__(self, prompt: str) -> torch.Tensor:
154
+ self._ensure_ready()
155
+ dev = get_torch_device()
156
+ ids = self._tok(prompt, add_special_tokens=False)["input_ids"]
157
+ out = self._lm(input_ids=torch.tensor([ids], device=dev), output_hidden_states=True)
158
+ h = out.hidden_states[-1][0].to(self.dtype) # [S_S, 2560] post-final-norm
159
+ if self.lowvram:
160
+ self.offload()
161
+ return h
custom_nodes/ComfyUI-MiniMaxH3-Adapter/test_fake_clip.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """本地冒烟: 验证 MiniMaxH3AdapterCLIP 契约(无 ComfyUI 依赖也可跑)。
3
+
4
+ 模式:
5
+ python test_fake_clip.py # quick: 假 student_fn,验证契约/形状/tags/透传
6
+ python test_fake_clip.py --real-student <qwen35 目录> # 真实 4B: 全链路 + offload 后显存回落
7
+
8
+ 验证点:
9
+ 1. tokenize(prompt) 捕获官方节点传入的 prompt
10
+ 2. encode_from_tokens_scheduled -> CONDITIONING [[embeds, {"minimax_token_tags": tags}]]
11
+ - embeds [1, S_T, 5376] fp16 finite, tags [S_T] 全 1 long, S_T == len(h3 ids)
12
+ 3. 5376 透传: 复刻 ComfyUI MiniMaxH3Model.preprocess_text_embeds —— shape[-1]==5376 恒等
13
+ 4. add_dict 路径 (conditioning_set_values)
14
+ 5. --real-student: encode 后学生模型已 offload(GPU 显存回落)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import os
20
+ import sys
21
+
22
+ import torch
23
+
24
+ HERE = os.path.dirname(os.path.abspath(__file__))
25
+ sys.path.insert(0, HERE)
26
+
27
+ from adapter.model import H3Adapter # noqa: E402
28
+ from student import StudentTextEncoder, get_torch_device, soft_empty_cache # noqa: E402
29
+
30
+ PROMPTS = [
31
+ "A cinematic shot of a tiny robot repairing a broken music box in a giant's attic, dust motes floating in the light",
32
+ "Close-up of papercraft hands folding an origami crane, warm studio light, shallow depth of field",
33
+ ]
34
+
35
+
36
+ def comfy_preprocess_text_embeds(text_states, hidden_size: int = 5376):
37
+ """复刻 comfy/ldm/minimax/model.py preprocess_text_embeds(仅判定分支)。"""
38
+ if text_states.shape[-1] == hidden_size:
39
+ return text_states
40
+ raise AssertionError("输入不是 5376 维 -> 会被 condition_proj+token_refiner 处理(不应发生)")
41
+
42
+
43
+ def conditioning_set_values(cond, new_values):
44
+ """node_helpers.conditioning_set_values 的 torch 版(无 ComfyUI 依赖)。"""
45
+ if len(cond) == 1 and len(cond[0][1]) == 0:
46
+ return [[cond[0][0], new_values]]
47
+ return [[c[0], {**c[1], **new_values}] for c in cond]
48
+
49
+
50
+ def run_checks(clip, prompts, tag: str) -> int:
51
+ fails = 0
52
+ for p in prompts:
53
+ clip.tokenize(p) # 官方节点会调 tokenize
54
+ cond = clip.encode_from_tokens_scheduled({})
55
+ assert len(cond) == 1, "CONDITIONING 应含 1 个元素"
56
+ embeds, extras = cond[0]
57
+ s_t = embeds.shape[1]
58
+ ids = clip._tok(p, add_special_tokens=False)["input_ids"]
59
+
60
+ checks = {
61
+ "embeds.shape == [1, S_T, 5376]": tuple(embeds.shape) == (1, s_t, 5376),
62
+ "S_T == len(h3 ids)": s_t == len(ids),
63
+ "dtype fp16": embeds.dtype == torch.float16,
64
+ "finite": bool(torch.isfinite(embeds).all()),
65
+ "extras 含 minimax_token_tags": "minimax_token_tags" in extras,
66
+ "tags 长度 == S_T": extras["minimax_token_tags"].shape[0] == s_t,
67
+ "tags 全 1": bool((extras["minimax_token_tags"] == 1).all()),
68
+ "tags dtype long": extras["minimax_token_tags"].dtype == torch.long,
69
+ }
70
+ # 5376 透传: DiT 应直接使用
71
+ passthrough = comfy_preprocess_text_embeds(embeds)
72
+ checks["preprocess_text_embeds 5376 透传恒等"] = torch.equal(passthrough, embeds)
73
+
74
+ for name, ok in checks.items():
75
+ print(f" [{'PASS' if ok else 'FAIL'}] {name}")
76
+ fails += 0 if ok else 1
77
+ print(f"--- {tag}: {'全部通过' if fails == 0 else f'{fails} 项失败'}")
78
+ return fails
79
+
80
+
81
+ def check_wrapper_path(adapter, tok, dev) -> int:
82
+ """验证 MiniMaxH3AdapterFromCLIP: 内层学生 CLIP 的 encode 输出被正确消费。"""
83
+ from adapter_clip import MiniMaxH3AdapterFromCLIP
84
+
85
+ class FakeInnerCLIP:
86
+ """模拟 CLIPLoader(GGUF) 的 CLIP 对象契约(内层在 CPU 低显存模式——复现真实报错场景)。"""
87
+ def tokenize(self, prompt, images=None, **kwargs):
88
+ return {"g": prompt}
89
+
90
+ @torch.no_grad()
91
+ def encode_from_tokens(self, tokens, return_pooled=False, return_dict=False):
92
+ p = tokens["g"]
93
+ s_s = max(1, len(tok(p, add_special_tokens=False)["input_ids"]) // 2)
94
+ emb = torch.randn(1, s_s, 2560, dtype=torch.float16, device="cpu") * 0.1 # CPU!
95
+ return [[emb, {}]]
96
+
97
+ wrapped = MiniMaxH3AdapterFromCLIP(FakeInnerCLIP(), adapter, tok, dev)
98
+ assert next(adapter.parameters()).device != torch.device("cpu"), "测试前提: 适配器应在 GPU"
99
+ print("--- 包装器路径 (CLIPLoader(GGUF, CPU) -> 适配器(GPU) -> 官方节点):")
100
+ return run_checks(wrapped, PROMPTS, "CLIP→CLIP 包装(内层CPU)")
101
+
102
+
103
+ def main() -> None:
104
+ ap = argparse.ArgumentParser()
105
+ ap.add_argument("--real-student", default="", help="Qwen3.5-4B 目录(真实全链路 + offload 验证)")
106
+ ap.add_argument("--device", default="")
107
+ args = ap.parse_args()
108
+
109
+ dev = torch.device(args.device) if args.device else get_torch_device()
110
+ print(f"device: {dev}")
111
+
112
+ # 适配器: 随机初始化(机制验证;真实权重在云端导出的 safetensors)
113
+ adapter = H3Adapter().to(torch.bfloat16).to(dev).eval()
114
+ for m in adapter.modules():
115
+ if hasattr(m, "use_sdpa"):
116
+ m.use_sdpa = True
117
+
118
+ from transformers import AutoTokenizer
119
+ tok = AutoTokenizer.from_pretrained(os.path.join(HERE, "tokenizer"))
120
+ print(f"tokenizer: {type(tok).__name__} | vocab={tok.vocab_size}")
121
+
122
+ if args.real_student:
123
+ stu = StudentTextEncoder(args.real_student, lowvram=True, gpu_mem="5GiB")
124
+ tag = "真实学生模型(dispatch offload)"
125
+ else:
126
+ # 假 student_fn: 返回随机 [S_S, 2560](S_S 与 prompt token 数成比例)
127
+ def fake_student(prompt: str) -> torch.Tensor:
128
+ s_s = max(1, len(tok(prompt, add_special_tokens=False)["input_ids"]) // 2)
129
+ return torch.randn(s_s, 2560, dtype=torch.bfloat16, device=dev) * 0.1
130
+ stu = type("FakeStudent", (), {"__call__": staticmethod(fake_student)})()
131
+ tag = "假学生模型(机制验证)"
132
+
133
+ from adapter_clip import MiniMaxH3AdapterCLIP
134
+ clip = MiniMaxH3AdapterCLIP(adapter, stu, tok, dev)
135
+
136
+ fails = run_checks(clip, PROMPTS, tag)
137
+ fails += check_wrapper_path(adapter, tok, dev)
138
+
139
+ # add_dict 路径(模拟官方节点传入 keyframes 等 extras)
140
+ clip.tokenize(PROMPTS[0])
141
+ cond = clip.encode_from_tokens_scheduled({}, add_dict={"minimax_frame_count": 124})
142
+ assert "minimax_frame_count" in cond[0][1], "add_dict 未并入 extras"
143
+ print(" [PASS] add_dict 并入 conditioning extras")
144
+
145
+ if args.real_student:
146
+ # offload 验证: 整卡模式 -> 已搬回 CPU; dispatch 模式 -> 权重层间驻留(首层在 CPU)
147
+ lm = stu._lm
148
+ on_cpu = next(lm.parameters()).device.type == "cpu" or stu._dispatched
149
+ print(f" [{'PASS' if on_cpu else 'FAIL'}] encode 后学生模型已释放 GPU 常驻 (dispatched={stu._dispatched})")
150
+ fails += 0 if on_cpu else 1
151
+ if torch.cuda.is_available():
152
+ free = torch.cuda.mem_get_info()[0] / 2 ** 30
153
+ print(f" [info] encode 后 GPU 可用显存 {free:.1f} GiB")
154
+
155
+ print("SMOKE", "PASS ✅" if fails == 0 else f"FAIL ({fails})")
156
+ raise SystemExit(0 if fails == 0 else 1)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
custom_nodes/ComfyUI-MiniMaxH3-Adapter/test_gguf_loader.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """合成迷你 GGUF 回环测试: 复刻真实 HauhauCS GGUF 的全部布局约定
3
+ (2D 数据 [out,in] + 元数据 [in,out]、v-head value-major 置换、范数增量 -1、ssm_a 存 -A),
4
+ 验证 gguf_qwen35 加载器的映射/反量化/置换/变换正确,含数值断言(cos)。
5
+
6
+ 量化类型用 Q8_0(gguf 库支持量化;Q4_K 反量化已由真实文件 426/426 逐张量验证)。
7
+
8
+ 用法: python test_gguf_loader.py [--keep](--keep 保留临时文件供 inspect_gguf 查看)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ import tempfile
16
+
17
+ import numpy as np
18
+ import torch
19
+
20
+ HERE = os.path.dirname(os.path.abspath(__file__))
21
+ sys.path.insert(0, HERE)
22
+
23
+ N_EMBD = 2560
24
+ FFN = 9216
25
+ N_HEAD, N_HEAD_KV, HEAD_DIM = 16, 4, 256
26
+ L_KEY_HEADS, L_KEY_HD, L_V_HEADS, L_V_HD = 16, 128, 32, 128
27
+ CONV_K = 4
28
+
29
+ GGUF_TO_HF = np.array([(i % 16) * 2 + (i // 16) for i in range(32)], dtype=np.int64)
30
+
31
+
32
+ def hf_to_gguf(x):
33
+ return x[GGUF_TO_HF]
34
+
35
+
36
+ def w2d(rng, out, inn):
37
+ return rng.standard_normal((out, inn)).astype(np.float32)
38
+
39
+
40
+ def add2d(w, name, data, qtype=None):
41
+ from gguf.quants import quantize
42
+ from gguf import GGML_QUANT_SIZES
43
+ out, inn = int(data.shape[0]), int(data.shape[1])
44
+ if qtype is not None:
45
+ data = quantize(np.ascontiguousarray(data), qtype)
46
+ block, tsize = GGML_QUANT_SIZES[qtype]
47
+ w.add_tensor(name, data, raw_shape=(out, inn * tsize // block), raw_dtype=qtype)
48
+ else:
49
+ w.add_tensor(name, data, raw_shape=(out, inn))
50
+
51
+
52
+ def add1d(w, name, data):
53
+ w.add_tensor(name, data)
54
+
55
+
56
+ def add_layer(w, rng, i: int, is_full: bool, expect: dict):
57
+ base = f"blk.{i}"
58
+ pref = f"language_model.layers.{i}."
59
+ norm_in = w2d(rng, N_EMBD, 1).ravel() + 0.5
60
+ norm_out = w2d(rng, N_EMBD, 1).ravel() + 0.5
61
+ add1d(w, f"{base}.attn_norm.weight", norm_in)
62
+ add1d(w, f"{base}.post_attention_norm.weight", norm_out)
63
+ expect[f"{pref}input_layernorm.weight"] = norm_in - 1.0
64
+ expect[f"{pref}post_attention_layernorm.weight"] = norm_out - 1.0
65
+ gate = w2d(rng, FFN, N_EMBD)
66
+ up = w2d(rng, FFN, N_EMBD)
67
+ down = w2d(rng, N_EMBD, FFN)
68
+ add2d(w, f"{base}.ffn_gate.weight", gate, qtype=Q8)
69
+ add2d(w, f"{base}.ffn_up.weight", up, qtype=Q8)
70
+ add2d(w, f"{base}.ffn_down.weight", down, qtype=Q8)
71
+ expect[f"{pref}mlp.gate_proj.weight"] = gate
72
+ expect[f"{pref}mlp.up_proj.weight"] = up
73
+ expect[f"{pref}mlp.down_proj.weight"] = down
74
+ if is_full:
75
+ q = w2d(rng, 2 * N_HEAD * HEAD_DIM, N_EMBD)
76
+ k = w2d(rng, N_HEAD_KV * HEAD_DIM, N_EMBD)
77
+ v = w2d(rng, N_HEAD_KV * HEAD_DIM, N_EMBD)
78
+ o = w2d(rng, N_EMBD, N_HEAD * HEAD_DIM)
79
+ qn = w2d(rng, HEAD_DIM, 1).ravel() + 0.5
80
+ kn = w2d(rng, HEAD_DIM, 1).ravel() + 0.5
81
+ add2d(w, f"{base}.attn_q.weight", q, qtype=Q8)
82
+ add2d(w, f"{base}.attn_k.weight", k, qtype=Q8)
83
+ add2d(w, f"{base}.attn_v.weight", v, qtype=Q8)
84
+ add2d(w, f"{base}.attn_output.weight", o, qtype=Q8)
85
+ add1d(w, f"{base}.attn_q_norm.weight", qn)
86
+ add1d(w, f"{base}.attn_k_norm.weight", kn)
87
+ expect[f"{pref}self_attn.q_proj.weight"] = q
88
+ expect[f"{pref}self_attn.k_proj.weight"] = k
89
+ expect[f"{pref}self_attn.v_proj.weight"] = v
90
+ expect[f"{pref}self_attn.o_proj.weight"] = o
91
+ expect[f"{pref}self_attn.q_norm.weight"] = qn - 1.0
92
+ expect[f"{pref}self_attn.k_norm.weight"] = kn - 1.0
93
+ else:
94
+ k_dim, v_dim = L_KEY_HEADS * L_KEY_HD, L_V_HEADS * L_V_HD
95
+ conv_dim = k_dim * 2 + v_dim
96
+ qkv = w2d(rng, conv_dim, N_EMBD)
97
+ qkv_hf = qkv.copy()
98
+ qkv[v_dim:] = hf_to_gguf(qkv[v_dim:].reshape(32, 128, -1)).reshape(v_dim, -1)
99
+ z = w2d(rng, v_dim, N_EMBD)
100
+ z_hf = z.copy()
101
+ z = hf_to_gguf(z.reshape(32, 128, -1)).reshape(v_dim, -1)
102
+ conv = w2d(rng, conv_dim, CONV_K)
103
+ conv_hf = conv.copy()
104
+ conv[v_dim:] = hf_to_gguf(conv[v_dim:].reshape(32, 128, -1)).reshape(v_dim, -1)
105
+ dt_hf = rng.standard_normal(L_V_HEADS).astype(np.float32)
106
+ dt = hf_to_gguf(dt_hf)
107
+ a_log = rng.standard_normal(L_V_HEADS).astype(np.float32)
108
+ beta_hf = w2d(rng, L_V_HEADS, N_EMBD)
109
+ beta = hf_to_gguf(beta_hf)
110
+ alpha_hf = w2d(rng, L_V_HEADS, N_EMBD)
111
+ alpha = hf_to_gguf(alpha_hf)
112
+ s_norm = w2d(rng, L_V_HD, 1).ravel() + 0.5
113
+ out_proj = w2d(rng, N_EMBD, v_dim)
114
+ out_proj_gguf = out_proj.reshape(N_EMBD, 32, 128)[:, GGUF_TO_HF, :].reshape(N_EMBD, v_dim)
115
+ add2d(w, f"{base}.attn_qkv.weight", qkv, qtype=Q8)
116
+ add2d(w, f"{base}.attn_gate.weight", z, qtype=Q8)
117
+ w.add_tensor(f"{base}.ssm_conv1d.weight", conv, raw_shape=(conv_dim, CONV_K))
118
+ add1d(w, f"{base}.ssm_dt.bias", dt)
119
+ add1d(w, f"{base}.ssm_a", hf_to_gguf(-np.exp(a_log)).astype(np.float32))
120
+ add2d(w, f"{base}.ssm_beta.weight", beta, qtype=Q8)
121
+ add2d(w, f"{base}.ssm_alpha.weight", alpha, qtype=Q8)
122
+ add1d(w, f"{base}.ssm_norm.weight", s_norm)
123
+ add2d(w, f"{base}.ssm_out.weight", out_proj_gguf, qtype=Q8)
124
+ expect[f"{pref}linear_attn.in_proj_qkv.weight"] = qkv_hf
125
+ expect[f"{pref}linear_attn.in_proj_z.weight"] = z_hf
126
+ expect[f"{pref}linear_attn.conv1d.weight"] = conv_hf.reshape(conv_dim, 1, CONV_K)
127
+ expect[f"{pref}linear_attn.dt_bias"] = dt_hf
128
+ expect[f"{pref}linear_attn.A_log"] = a_log
129
+ expect[f"{pref}linear_attn.in_proj_b.weight"] = beta_hf
130
+ expect[f"{pref}linear_attn.in_proj_a.weight"] = alpha_hf
131
+ expect[f"{pref}linear_attn.norm.weight"] = s_norm
132
+ expect[f"{pref}linear_attn.out_proj.weight"] = out_proj
133
+
134
+
135
+ def main() -> None:
136
+ global Q8
137
+ from gguf import GGUFWriter, GGMLQuantizationType
138
+ Q8 = GGMLQuantizationType.Q8_0
139
+
140
+ ap = argparse.ArgumentParser()
141
+ ap.add_argument("--keep", action="store_true")
142
+ args = ap.parse_args()
143
+
144
+ tmpdir = tempfile.mkdtemp(prefix="gguf_test_")
145
+ path = os.path.join(tmpdir, "toy_qwen35.gguf")
146
+ print(f"写入合成 GGUF -> {path}")
147
+
148
+ rng = np.random.default_rng(42)
149
+ expect: dict[str, np.ndarray] = {}
150
+ w = GGUFWriter(path, "qwen35")
151
+ emb = w2d(rng, 248320, N_EMBD)
152
+ add2d(w, "token_embd.weight", emb)
153
+ expect["language_model.embed_tokens.weight"] = emb
154
+ out_norm = w2d(rng, N_EMBD, 1).ravel() + 0.5
155
+ add1d(w, "output_norm.weight", out_norm)
156
+ expect["language_model.norm.weight"] = out_norm - 1.0
157
+ add_layer(w, rng, 0, is_full=False, expect=expect)
158
+ add_layer(w, rng, 3, is_full=True, expect=expect)
159
+ w.write_header_to_file()
160
+ w.write_kv_data_to_file()
161
+ w.write_tensors_to_file()
162
+ w.close()
163
+ print(f"GGUF 写入完成 ({os.path.getsize(path) / 1e6:.0f} MB)")
164
+
165
+ from gguf_qwen35 import load_gguf_state_dict, build_hf_model
166
+ sd = load_gguf_state_dict(path)
167
+ print(f"映射出 {len(sd)} 个键")
168
+
169
+ fails = 0
170
+ for key, want in sorted(expect.items()):
171
+ if key not in sd:
172
+ print(f" [FAIL] {key} MISSING")
173
+ fails += 1
174
+ continue
175
+ got = sd[key]
176
+ ws = torch.from_numpy(want).float()
177
+ gs = got.float()
178
+ if tuple(gs.shape) != tuple(ws.shape):
179
+ print(f" [FAIL] {key} shape {tuple(gs.shape)} 期望 {tuple(ws.shape)}")
180
+ fails += 1
181
+ continue
182
+ dot = torch.zeros((), dtype=torch.float64)
183
+ na = torch.zeros((), dtype=torch.float64)
184
+ nb = torch.zeros((), dtype=torch.float64)
185
+ CH = 1 << 22
186
+ ga, wf = gs.flatten(), ws.flatten()
187
+ for i in range(0, ga.numel(), CH):
188
+ x, y = ga[i:i + CH], wf[i:i + CH]
189
+ dot += (x * y).sum().double()
190
+ na += (x * x).sum().double()
191
+ nb += (y * y).sum().double()
192
+ c = float(dot / (na.sqrt() * nb.sqrt() + 1e-30))
193
+ ok = c > 0.99
194
+ print(f" [{'PASS' if ok else 'FAIL'}] {key} {tuple(gs.shape)} cos={c:.5f}")
195
+ fails += 0 if ok else 1
196
+
197
+ model, missing, unexpected = build_hf_model(sd, os.path.join(HERE, "qwen35_config.json"))
198
+ unexp_ok = len(unexpected) == 0
199
+ print(f" [{'PASS' if unexp_ok else 'FAIL'}] load_state_dict unexpected={len(unexpected)} (应=0)"
200
+ + (f": {unexpected[:4]}" if unexpected else ""))
201
+ fails += 0 if unexp_ok else 1
202
+ print(f" [info] missing={len(missing)}(其余 30 层 + vision 属正常缺省)")
203
+
204
+ if not args.keep:
205
+ import shutil
206
+ shutil.rmtree(tmpdir)
207
+ print("GGUF 回环", "PASS ✅" if fails == 0 else f"FAIL ({fails})")
208
+ raise SystemExit(0 if fails == 0 else 1)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/merges.txt ADDED
The diff for this file is too large to render. See raw diff
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_bos_token": false,
3
+ "add_prefix_space": false,
4
+ "added_tokens_decoder": {
5
+ "151643": {
6
+ "content": "<|endoftext|>",
7
+ "lstrip": false,
8
+ "normalized": false,
9
+ "rstrip": false,
10
+ "single_word": false,
11
+ "special": true
12
+ },
13
+ "151644": {
14
+ "content": "<|im_start|>",
15
+ "lstrip": false,
16
+ "normalized": false,
17
+ "rstrip": false,
18
+ "single_word": false,
19
+ "special": true
20
+ },
21
+ "151645": {
22
+ "content": "<|im_end|>",
23
+ "lstrip": false,
24
+ "normalized": false,
25
+ "rstrip": false,
26
+ "single_word": false,
27
+ "special": true
28
+ },
29
+ "151646": {
30
+ "content": "<|object_ref_start|>",
31
+ "lstrip": false,
32
+ "normalized": false,
33
+ "rstrip": false,
34
+ "single_word": false,
35
+ "special": true
36
+ },
37
+ "151647": {
38
+ "content": "<|object_ref_end|>",
39
+ "lstrip": false,
40
+ "normalized": false,
41
+ "rstrip": false,
42
+ "single_word": false,
43
+ "special": true
44
+ },
45
+ "151648": {
46
+ "content": "<|box_start|>",
47
+ "lstrip": false,
48
+ "normalized": false,
49
+ "rstrip": false,
50
+ "single_word": false,
51
+ "special": true
52
+ },
53
+ "151649": {
54
+ "content": "<|box_end|>",
55
+ "lstrip": false,
56
+ "normalized": false,
57
+ "rstrip": false,
58
+ "single_word": false,
59
+ "special": true
60
+ },
61
+ "151650": {
62
+ "content": "<|quad_start|>",
63
+ "lstrip": false,
64
+ "normalized": false,
65
+ "rstrip": false,
66
+ "single_word": false,
67
+ "special": true
68
+ },
69
+ "151651": {
70
+ "content": "<|quad_end|>",
71
+ "lstrip": false,
72
+ "normalized": false,
73
+ "rstrip": false,
74
+ "single_word": false,
75
+ "special": true
76
+ },
77
+ "151652": {
78
+ "content": "<|vision_start|>",
79
+ "lstrip": false,
80
+ "normalized": false,
81
+ "rstrip": false,
82
+ "single_word": false,
83
+ "special": true
84
+ },
85
+ "151653": {
86
+ "content": "<|vision_end|>",
87
+ "lstrip": false,
88
+ "normalized": false,
89
+ "rstrip": false,
90
+ "single_word": false,
91
+ "special": true
92
+ },
93
+ "151654": {
94
+ "content": "<|vision_pad|>",
95
+ "lstrip": false,
96
+ "normalized": false,
97
+ "rstrip": false,
98
+ "single_word": false,
99
+ "special": true
100
+ },
101
+ "151655": {
102
+ "content": "<|image_pad|>",
103
+ "lstrip": false,
104
+ "normalized": false,
105
+ "rstrip": false,
106
+ "single_word": false,
107
+ "special": true
108
+ },
109
+ "151656": {
110
+ "content": "<|video_pad|>",
111
+ "lstrip": false,
112
+ "normalized": false,
113
+ "rstrip": false,
114
+ "single_word": false,
115
+ "special": true
116
+ },
117
+ "151657": {
118
+ "content": "<tool_call>",
119
+ "lstrip": false,
120
+ "normalized": false,
121
+ "rstrip": false,
122
+ "single_word": false,
123
+ "special": false
124
+ },
125
+ "151658": {
126
+ "content": "</tool_call>",
127
+ "lstrip": false,
128
+ "normalized": false,
129
+ "rstrip": false,
130
+ "single_word": false,
131
+ "special": false
132
+ },
133
+ "151659": {
134
+ "content": "<|fim_prefix|>",
135
+ "lstrip": false,
136
+ "normalized": false,
137
+ "rstrip": false,
138
+ "single_word": false,
139
+ "special": false
140
+ },
141
+ "151660": {
142
+ "content": "<|fim_middle|>",
143
+ "lstrip": false,
144
+ "normalized": false,
145
+ "rstrip": false,
146
+ "single_word": false,
147
+ "special": false
148
+ },
149
+ "151661": {
150
+ "content": "<|fim_suffix|>",
151
+ "lstrip": false,
152
+ "normalized": false,
153
+ "rstrip": false,
154
+ "single_word": false,
155
+ "special": false
156
+ },
157
+ "151662": {
158
+ "content": "<|fim_pad|>",
159
+ "lstrip": false,
160
+ "normalized": false,
161
+ "rstrip": false,
162
+ "single_word": false,
163
+ "special": false
164
+ },
165
+ "151663": {
166
+ "content": "<|repo_name|>",
167
+ "lstrip": false,
168
+ "normalized": false,
169
+ "rstrip": false,
170
+ "single_word": false,
171
+ "special": false
172
+ },
173
+ "151664": {
174
+ "content": "<|file_sep|>",
175
+ "lstrip": false,
176
+ "normalized": false,
177
+ "rstrip": false,
178
+ "single_word": false,
179
+ "special": false
180
+ },
181
+ "151665": {
182
+ "content": "<tool_response>",
183
+ "lstrip": false,
184
+ "normalized": false,
185
+ "rstrip": false,
186
+ "single_word": false,
187
+ "special": false
188
+ },
189
+ "151666": {
190
+ "content": "</tool_response>",
191
+ "lstrip": false,
192
+ "normalized": false,
193
+ "rstrip": false,
194
+ "single_word": false,
195
+ "special": false
196
+ },
197
+ "151667": {
198
+ "content": "<think>",
199
+ "lstrip": false,
200
+ "normalized": false,
201
+ "rstrip": false,
202
+ "single_word": false,
203
+ "special": false
204
+ },
205
+ "151668": {
206
+ "content": "</think>",
207
+ "lstrip": false,
208
+ "normalized": false,
209
+ "rstrip": false,
210
+ "single_word": false,
211
+ "special": false
212
+ }
213
+ },
214
+ "additional_special_tokens": [
215
+ "<|im_start|>",
216
+ "<|im_end|>",
217
+ "<|object_ref_start|>",
218
+ "<|object_ref_end|>",
219
+ "<|box_start|>",
220
+ "<|box_end|>",
221
+ "<|quad_start|>",
222
+ "<|quad_end|>",
223
+ "<|vision_start|>",
224
+ "<|vision_end|>",
225
+ "<|vision_pad|>",
226
+ "<|image_pad|>",
227
+ "<|video_pad|>",
228
+ "<d>",
229
+ "</d>",
230
+ "<|cutoff|>",
231
+ "<|lyrics_start|>",
232
+ "<|lyrics_end|>",
233
+ "<|caption_start|>",
234
+ "<|caption_end|>"
235
+ ],
236
+ "bos_token": null,
237
+ "chat_template": "{%- if tools %}\n {{- '<|im_start|>system\\n' }}\n {%- if messages[0].role == 'system' %}\n {%- if messages[0].content is string %}\n {{- messages[0].content }}\n {%- else %}\n {%- for content in messages[0].content %}\n {%- if 'text' in content %}\n {{- content.text }}\n {%- endif %}\n {%- endfor %}\n {%- endif %}\n {{- '\\n\\n' }}\n {%- endif %}\n {{- \"# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n {%- if messages[0].role == 'system' %}\n {{- '<|im_start|>system\\n' }}\n {%- if messages[0].content is string %}\n {{- messages[0].content }}\n {%- else %}\n {%- for content in messages[0].content %}\n {%- if 'text' in content %}\n {{- content.text }}\n {%- endif %}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- set image_count = namespace(value=0) %}\n{%- set video_count = namespace(value=0) %}\n{%- for message in messages %}\n {%- if message.role == \"user\" %}\n {{- '<|im_start|>' + message.role + '\\n' }}\n {%- if message.content is string %}\n {{- message.content }}\n {%- else %}\n {%- for content in message.content %}\n {%- if content.type == 'image' or 'image' in content or 'image_url' in content %}\n {%- set image_count.value = image_count.value + 1 %}\n {%- if add_vision_id %}Picture {{ image_count.value }}: {% endif -%}\n <|vision_start|><|image_pad|><|vision_end|>\n {%- elif content.type == 'video' or 'video' in content %}\n {%- set video_count.value = video_count.value + 1 %}\n {%- if add_vision_id %}Video {{ video_count.value }}: {% endif -%}\n <|vision_start|><|video_pad|><|vision_end|>\n {%- elif 'text' in content %}\n {{- content.text }}\n {%- endif %}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"assistant\" %}\n {{- '<|im_start|>' + message.role + '\\n' }}\n {%- if message.content is string %}\n {{- message.content }}\n {%- else %}\n {%- for content_item in message.content %}\n {%- if 'text' in content_item %}\n {{- content_item.text }}\n {%- endif %}\n {%- endfor %}\n {%- endif %}\n {%- if message.tool_calls %}\n {%- for tool_call in message.tool_calls %}\n {%- if (loop.first and message.content) or (not loop.first) %}\n {{- '\\n' }}\n {%- endif %}\n {%- if tool_call.function %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {{- '<tool_call>\\n{\"name\": \"' }}\n {{- tool_call.name }}\n {{- '\", \"arguments\": ' }}\n {%- if tool_call.arguments is string %}\n {{- tool_call.arguments }}\n {%- else %}\n {{- tool_call.arguments | tojson }}\n {%- endif %}\n {{- '}\\n</tool_call>' }}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if loop.first or (messages[loop.index0 - 1].role != \"tool\") %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {%- if message.content is string %}\n {{- message.content }}\n {%- else %}\n {%- for content in message.content %}\n {%- if content.type == 'image' or 'image' in content or 'image_url' in content %}\n {%- set image_count.value = image_count.value + 1 %}\n {%- if add_vision_id %}Picture {{ image_count.value }}: {% endif -%}\n <|vision_start|><|image_pad|><|vision_end|>\n {%- elif content.type == 'video' or 'video' in content %}\n {%- set video_count.value = video_count.value + 1 %}\n {%- if add_vision_id %}Video {{ video_count.value }}: {% endif -%}\n <|vision_start|><|video_pad|><|vision_end|>\n {%- elif 'text' in content %}\n {{- content.text }}\n {%- endif %}\n {%- endfor %}\n {%- endif %}\n {{- '\\n</tool_response>' }}\n {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n{%- endif %}\n",
238
+ "clean_up_tokenization_spaces": false,
239
+ "eos_token": "<|im_end|>",
240
+ "errors": "replace",
241
+ "model_max_length": 262144,
242
+ "pad_token": "<|endoftext|>",
243
+ "split_special_tokens": false,
244
+ "tokenizer_class": "Qwen2Tokenizer",
245
+ "unk_token": null
246
+ }
custom_nodes/ComfyUI-MiniMaxH3-Adapter/tokenizer/vocab.json ADDED
The diff for this file is too large to render. See raw diff