woodfireind commited on
Commit
623a187
·
verified ·
1 Parent(s): 977f488

Add h3_small_te custom node (H3SmallTELoader + H3SmallTextEncoder)

Browse files
custom_nodes/h3_small_te/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
2
+
3
+ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
custom_nodes/h3_small_te/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (334 Bytes). View file
 
custom_nodes/h3_small_te/__pycache__/nodes.cpython-311.pyc ADDED
Binary file (22.7 kB). View file
 
custom_nodes/h3_small_te/nodes.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniMax H3 small text encoder: Qwen3-VL-4B + trained adapter -> 5120-dim conditioning.
2
+
3
+ Matches the training path in optimization/h3-shrink/scripts/train_te_adapter.py:
4
+ - H3 tokenizer (raw text, no chat template)
5
+ - Student final text-norm replaced with Identity (unnormalized hidden states)
6
+ - Adapter Linear(2560->4096)->GELU->Linear(4096->5120) in fp32
7
+ - minimax_token_tags = all-ones for pure text
8
+
9
+ Two nodes:
10
+ - H3SmallTELoader -> CLIP (plugs into MiniMaxH3ImageToVideo for T2V)
11
+ - H3SmallTextEncoder -> CONDITIONING (direct encode of a prompt string)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib.util
17
+ import os
18
+ import sys
19
+ import threading
20
+ from typing import Any, Optional
21
+
22
+ import torch
23
+ import torch.nn as nn
24
+
25
+ import folder_paths
26
+
27
+ DEFAULT_STUDENT = "/home/bbear/Documents/OlympusServer/models/qwen3vl-4b-instruct"
28
+ DEFAULT_ADAPTER = (
29
+ "/home/bbear/Documents/OlympusServer/optimization/h3-shrink/adapters/te_adapter_v1.safetensors"
30
+ )
31
+ DEFAULT_TOKENIZER = "/home/bbear/Documents/OlympusServer/optimization/h3-shrink/h3_tokenizer"
32
+ PAD_ID = 151643
33
+ EMBED_KEY = "qwen3vl_32b" # keep teacher key so downstream nodes see a familiar dict shape
34
+
35
+
36
+ class Adapter(nn.Module):
37
+ def __init__(self):
38
+ super().__init__()
39
+ self.net = nn.Sequential(
40
+ nn.Linear(2560, 4096),
41
+ nn.GELU(),
42
+ nn.Linear(4096, 5120),
43
+ )
44
+
45
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
46
+ return self.net(x)
47
+
48
+
49
+ _CACHE_LOCK = threading.Lock()
50
+ _CACHE: dict[str, Any] = {}
51
+
52
+
53
+ def _pick_device(prefer: str = "auto") -> str:
54
+ if prefer and prefer != "auto":
55
+ return prefer
56
+ if hasattr(torch, "xpu") and torch.xpu.is_available():
57
+ # Prefer xpu:1 when two cards are present so the teacher TE dump
58
+ # server can keep using xpu:0.
59
+ n = torch.xpu.device_count()
60
+ return f"xpu:{1 if n > 1 else 0}"
61
+ if torch.cuda.is_available():
62
+ return "cuda:0"
63
+ return "cpu"
64
+
65
+
66
+ def _gguf_files() -> list[str]:
67
+ files = folder_paths.get_filename_list("text_encoders")
68
+ # ComfyUI-GGUF registers clip_gguf (text_encoders dirs, .gguf extension only).
69
+ if "clip_gguf" in folder_paths.folder_names_and_paths:
70
+ files = files + folder_paths.get_filename_list("clip_gguf")
71
+ return sorted({f for f in files if f.endswith(".gguf")})
72
+
73
+
74
+ def _gguf_full_path(name: str) -> str:
75
+ p = folder_paths.get_full_path("text_encoders", name)
76
+ if p is None and "clip_gguf" in folder_paths.folder_names_and_paths:
77
+ p = folder_paths.get_full_path("clip_gguf", name)
78
+ if p is None:
79
+ raise FileNotFoundError(f"h3_small_te: gguf not found: {name}")
80
+ return p
81
+
82
+
83
+ def _import_gguf_backend():
84
+ """Import the sibling ComfyUI-GGUF package (its dir name is not importable)."""
85
+ name = "h3_small_te.gguf_backend"
86
+ pkg = sys.modules.get(name)
87
+ if pkg is None:
88
+ pkg_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ComfyUI-GGUF")
89
+ spec = importlib.util.spec_from_file_location(
90
+ name, os.path.join(pkg_dir, "__init__.py"), submodule_search_locations=[pkg_dir]
91
+ )
92
+ pkg = importlib.util.module_from_spec(spec)
93
+ sys.modules[name] = pkg
94
+ spec.loader.exec_module(pkg)
95
+ return pkg
96
+
97
+
98
+ def _load_text_stack_gguf(gguf_path: str):
99
+ """Build the comfy-native Qwen3-VL-4B text stack from a GGUF file."""
100
+ pkg = _import_gguf_backend()
101
+ gguf_loader = sys.modules[pkg.__name__ + ".loader"]
102
+ gguf_ops = sys.modules[pkg.__name__ + ".ops"]
103
+ from comfy.text_encoders.llama import Llama2_, Qwen3VL_4BConfig
104
+
105
+ print(f"[h3_small_te] loading student from {gguf_path} (gguf) ...", flush=True)
106
+ sd = gguf_loader.gguf_clip_loader(gguf_path)
107
+ sd = {k.removeprefix("model."): v for k, v in sd.items()}
108
+
109
+ model = Llama2_(Qwen3VL_4BConfig(), device="cpu", dtype=torch.bfloat16, ops=gguf_ops.GGMLOps)
110
+ missing, unexpected = model.load_state_dict(sd, strict=False)
111
+ if missing or unexpected:
112
+ raise RuntimeError(
113
+ f"h3_small_te: gguf state dict mismatch: missing={missing} unexpected={unexpected}"
114
+ )
115
+
116
+ # gguf_clip_loader dequantizes token_embd to fp16; the safetensors path is bf16.
117
+ emb = model.embed_tokens.weight
118
+ model.embed_tokens.weight = nn.Parameter(emb.data.to(torch.bfloat16), requires_grad=False)
119
+ model.eval()
120
+ return model
121
+
122
+
123
+ def _load_stack(student_dir: str, adapter_path: str, tokenizer_dir: str, device: str,
124
+ gguf_path: Optional[str] = None):
125
+ """Load (and cache) student text stack + adapter + H3 tokenizer."""
126
+ key = f"{gguf_path or student_dir}|{adapter_path}|{tokenizer_dir}|{device}"
127
+ with _CACHE_LOCK:
128
+ if key in _CACHE:
129
+ return _CACHE[key]
130
+
131
+ os.environ.setdefault("PYTORCH_ENABLE_XPU_FALLBACK", "1")
132
+ os.environ.setdefault("ONEAPI_DEVICE_SELECTOR", "level_zero:*")
133
+
134
+ from transformers import AutoTokenizer
135
+ from safetensors.torch import load_file
136
+
137
+ if gguf_path:
138
+ text_model = _load_text_stack_gguf(gguf_path)
139
+ path_used = f"gguf:{os.path.basename(gguf_path)}"
140
+ else:
141
+ from transformers import Qwen3VLForConditionalGeneration
142
+
143
+ # Load on CPU first, peel the text stack, THEN move only that stack to
144
+ # the target device. Moving the full VL (vision tower included) to XPU
145
+ # has been observed to hang under concurrent SYCL loaders (llama-server
146
+ # / Comfy GGUF TE). Training path is the same peel-then-run pattern.
147
+ print(f"[h3_small_te] loading student from {student_dir} (cpu then {device}) ...", flush=True)
148
+ model = Qwen3VLForConditionalGeneration.from_pretrained(
149
+ student_dir, dtype=torch.bfloat16
150
+ )
151
+ model.eval()
152
+
153
+ text_model = None
154
+ path_used = None
155
+ for cand in ("model.language_model", "language_model", "model.model.language_model"):
156
+ obj = model
157
+ ok = True
158
+ for part in cand.split("."):
159
+ if hasattr(obj, part):
160
+ obj = getattr(obj, part)
161
+ else:
162
+ ok = False
163
+ break
164
+ if ok and hasattr(obj, "norm") and hasattr(obj, "layers"):
165
+ text_model = obj
166
+ path_used = cand
167
+ break
168
+ if text_model is None:
169
+ raise RuntimeError("h3_small_te: could not locate student text stack")
170
+
171
+ old_norm = text_model.norm
172
+ text_model.norm = nn.Identity()
173
+ print(
174
+ f"[h3_small_te] text stack {path_used}; "
175
+ f"{type(old_norm).__name__} -> Identity",
176
+ flush=True,
177
+ )
178
+ for p in text_model.parameters():
179
+ p.requires_grad_(False)
180
+
181
+ # Detach text stack from the VL parent before device move so the vision
182
+ # tower is not dragged onto the XPU.
183
+ text_model = text_model.to(device)
184
+ if not gguf_path:
185
+ del model
186
+ import gc
187
+ gc.collect()
188
+ if device.startswith("xpu") and hasattr(torch.xpu, "empty_cache"):
189
+ torch.xpu.empty_cache()
190
+ elif device.startswith("cuda") and hasattr(torch.cuda, "empty_cache"):
191
+ torch.cuda.empty_cache()
192
+
193
+ adapter = Adapter()
194
+ if not os.path.isfile(adapter_path):
195
+ raise FileNotFoundError(f"h3_small_te: adapter not found: {adapter_path}")
196
+ sd = load_file(adapter_path)
197
+ adapter.load_state_dict(sd, strict=True)
198
+ adapter = adapter.to(device=device, dtype=torch.float32)
199
+ adapter.eval()
200
+
201
+ tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir)
202
+
203
+ bundle = {
204
+ "text_model": text_model,
205
+ "adapter": adapter,
206
+ "tokenizer": tokenizer,
207
+ "device": device,
208
+ }
209
+ _CACHE[key] = bundle
210
+ print(f"[h3_small_te] ready on {device}", flush=True)
211
+ return bundle
212
+
213
+
214
+ def _encode_ids(text_model, adapter, ids: list[int], device: str) -> torch.Tensor:
215
+ """Return (1, L, 5120) fp32 conditioning tensor."""
216
+ if not ids:
217
+ ids = [PAD_ID]
218
+ input_ids = torch.tensor([ids], dtype=torch.long, device=device)
219
+ attention_mask = torch.ones_like(input_ids)
220
+ with torch.no_grad():
221
+ # Positional ids: HF takes input_ids first; comfy Llama2_ takes x (ids) first.
222
+ out = text_model(input_ids, attention_mask=attention_mask)
223
+ hidden = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
224
+ cond = adapter(hidden.float()) # (1, L, 5120)
225
+ return cond
226
+
227
+
228
+ def _token_ids_from_text(tokenizer, text: str) -> list[int]:
229
+ return list(tokenizer.encode(text, add_special_tokens=False))
230
+
231
+
232
+ def _token_ids_from_clip_tokens(tokens) -> list[int]:
233
+ """Extract flat token id list from a comfy-style tokenize() result."""
234
+ if isinstance(tokens, dict):
235
+ batches = next(iter(tokens.values()))
236
+ else:
237
+ batches = tokens
238
+ if not batches:
239
+ return [PAD_ID]
240
+ entries = batches[0]
241
+ ids = []
242
+ for entry in entries:
243
+ tid = entry[0] if isinstance(entry, (tuple, list)) else entry
244
+ if isinstance(tid, dict):
245
+ # Vision embed — Phase 2 is T2V-only; refuse silently-wrong paths.
246
+ raise RuntimeError(
247
+ "h3_small_te: vision/image tokens are not supported yet "
248
+ "(adapter is text-only). Use pure T2V prompts."
249
+ )
250
+ ids.append(int(tid))
251
+ return ids if ids else [PAD_ID]
252
+
253
+
254
+ class H3SmallCLIP:
255
+ """Duck-typed CLIP for MiniMaxH3ImageToVideo (T2V pure-text path)."""
256
+
257
+ def __init__(self, student_dir: str, adapter_path: str, tokenizer_dir: str, device: str,
258
+ gguf_path: Optional[str] = None):
259
+ self.student_dir = student_dir
260
+ self.adapter_path = adapter_path
261
+ self.tokenizer_dir = tokenizer_dir
262
+ self.device = device
263
+ self.gguf_path = gguf_path
264
+ self._bundle: Optional[dict] = None
265
+
266
+ def _ensure(self):
267
+ if self._bundle is None:
268
+ self._bundle = _load_stack(
269
+ self.student_dir, self.adapter_path, self.tokenizer_dir, self.device,
270
+ gguf_path=self.gguf_path,
271
+ )
272
+ return self._bundle
273
+
274
+ def tokenize(self, text, return_word_ids=False, images=None, minimax_ref_items=None, **kwargs):
275
+ if images:
276
+ raise RuntimeError(
277
+ "h3_small_te: FL2VA image conditioning not supported yet "
278
+ "(student adapter is text-only). Use T2V (no first/last frame)."
279
+ )
280
+ if minimax_ref_items:
281
+ raise RuntimeError(
282
+ "h3_small_te: ref2va not supported yet (student adapter is text-only)."
283
+ )
284
+ b = self._ensure()
285
+ ids = _token_ids_from_text(b["tokenizer"], text)
286
+ entries = [(tid, 1.0) for tid in ids] or [(PAD_ID, 1.0)]
287
+ if return_word_ids:
288
+ entries = [t + (0,) for t in entries]
289
+ return {EMBED_KEY: [entries]}
290
+
291
+ def encode_from_tokens_scheduled(self, tokens, unprojected=False, add_dict=None, show_pbar=True):
292
+ add_dict = add_dict or {}
293
+ b = self._ensure()
294
+ ids = _token_ids_from_clip_tokens(tokens)
295
+ cond = _encode_ids(b["text_model"], b["adapter"], ids, b["device"])
296
+ # Match comfy TE output placement (usually CPU / model management device).
297
+ cond = cond.cpu()
298
+ tags = torch.ones(cond.shape[1], dtype=torch.long)
299
+ pooled = {
300
+ "pooled_output": cond[:, -1, :].clone(),
301
+ "minimax_token_tags": tags,
302
+ }
303
+ pooled.update(add_dict)
304
+ return [[cond, pooled]]
305
+
306
+ def encode_from_tokens(self, tokens, return_pooled=False, return_dict=False):
307
+ scheduled = self.encode_from_tokens_scheduled(tokens)
308
+ cond, pooled = scheduled[0]
309
+ if return_dict:
310
+ out = {"cond": cond, "pooled_output": pooled.get("pooled_output")}
311
+ for k, v in pooled.items():
312
+ if k != "pooled_output":
313
+ out[k] = v
314
+ return out
315
+ if return_pooled:
316
+ return cond, pooled.get("pooled_output")
317
+ return cond
318
+
319
+ def encode(self, text):
320
+ return self.encode_from_tokens(self.tokenize(text))
321
+
322
+
323
+ class H3SmallTELoader:
324
+ """Load Qwen3-VL-4B + adapter as a CLIP substitute for MiniMax H3 T2V."""
325
+
326
+ @classmethod
327
+ def INPUT_TYPES(s):
328
+ return {
329
+ "required": {},
330
+ "optional": {
331
+ "gguf_name": (["none"] + _gguf_files(),),
332
+ "student_dir": ("STRING", {"default": DEFAULT_STUDENT}),
333
+ "adapter_path": ("STRING", {"default": DEFAULT_ADAPTER}),
334
+ "tokenizer_dir": ("STRING", {"default": DEFAULT_TOKENIZER}),
335
+ "device": ("STRING", {"default": "auto"}),
336
+ },
337
+ }
338
+
339
+ RETURN_TYPES = ("CLIP",)
340
+ FUNCTION = "load"
341
+ CATEGORY = "h3"
342
+ TITLE = "H3 Small TE Loader (4B+adapter)"
343
+
344
+ def load(self, gguf_name="none", student_dir=DEFAULT_STUDENT, adapter_path=DEFAULT_ADAPTER,
345
+ tokenizer_dir=DEFAULT_TOKENIZER, device="auto"):
346
+ dev = _pick_device(device)
347
+ gguf_path = None
348
+ if gguf_name != "none":
349
+ gguf_path = _gguf_full_path(gguf_name)
350
+ clip = H3SmallCLIP(student_dir, adapter_path, tokenizer_dir, dev, gguf_path=gguf_path)
351
+ # Eager-load so the first workflow step surfaces errors immediately.
352
+ clip._ensure()
353
+ return (clip,)
354
+
355
+
356
+ class H3SmallTextEncoder:
357
+ """Encode a prompt with Qwen3-VL-4B + adapter -> CONDITIONING (1, L, 5120)."""
358
+
359
+ @classmethod
360
+ def INPUT_TYPES(s):
361
+ return {
362
+ "required": {
363
+ "text": ("STRING", {"multiline": True, "dynamicPrompts": True}),
364
+ },
365
+ "optional": {
366
+ "gguf_name": (["none"] + _gguf_files(),),
367
+ "student_dir": ("STRING", {"default": DEFAULT_STUDENT}),
368
+ "adapter_path": ("STRING", {"default": DEFAULT_ADAPTER}),
369
+ "tokenizer_dir": ("STRING", {"default": DEFAULT_TOKENIZER}),
370
+ "device": ("STRING", {"default": "auto"}),
371
+ },
372
+ }
373
+
374
+ RETURN_TYPES = ("CONDITIONING",)
375
+ FUNCTION = "encode"
376
+ CATEGORY = "h3"
377
+ TITLE = "H3 Small Text Encoder (4B+adapter)"
378
+ OUTPUT_NODE = True
379
+
380
+ def encode(self, text, gguf_name="none", student_dir=DEFAULT_STUDENT, adapter_path=DEFAULT_ADAPTER,
381
+ tokenizer_dir=DEFAULT_TOKENIZER, device="auto"):
382
+ dev = _pick_device(device)
383
+ gguf_path = None
384
+ if gguf_name != "none":
385
+ gguf_path = _gguf_full_path(gguf_name)
386
+ b = _load_stack(student_dir, adapter_path, tokenizer_dir, dev, gguf_path=gguf_path)
387
+ ids = _token_ids_from_text(b["tokenizer"], text)
388
+ cond = _encode_ids(b["text_model"], b["adapter"], ids, b["device"]).cpu()
389
+ tags = torch.ones(cond.shape[1], dtype=torch.long)
390
+ pooled = {
391
+ "pooled_output": cond[:, -1, :].clone(),
392
+ "minimax_token_tags": tags,
393
+ }
394
+ print(
395
+ f"[h3_small_te] encoded L={cond.shape[1]} dim={cond.shape[2]} "
396
+ f"mean||h||={cond[0].norm(dim=-1).mean().item():.1f}",
397
+ flush=True,
398
+ )
399
+ return ([[cond, pooled]],)
400
+
401
+
402
+ NODE_CLASS_MAPPINGS = {
403
+ "H3SmallTELoader": H3SmallTELoader,
404
+ "H3SmallTextEncoder": H3SmallTextEncoder,
405
+ }
406
+
407
+ NODE_DISPLAY_NAME_MAPPINGS = {
408
+ "H3SmallTELoader": "H3 Small TE Loader (4B+adapter)",
409
+ "H3SmallTextEncoder": "H3 Small Text Encoder (4B+adapter)",
410
+ }