| """Qwen3.5-4B 学生模型加载 + 隐藏态提取 + ComfyUI 感知的显存 offload。 |
| |
| load_student 移植自训练管线 extract_student_features.py: |
| - 修复 checkpoint 的 `model.` 多余前缀(否则全层随机初始化) |
| - 隐藏态取 hidden_states[-1](tie_last_hidden_states -> post-final-norm,与训练/评估同源) |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import os |
| from collections import defaultdict |
|
|
| import torch |
|
|
| try: |
| import comfy.model_management as comfy_mm |
|
|
| def get_torch_device() -> torch.device: |
| return comfy_mm.get_torch_device() |
|
|
| def soft_empty_cache() -> None: |
| comfy_mm.soft_empty_cache() |
| except ImportError: |
| def get_torch_device() -> torch.device: |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| def soft_empty_cache() -> None: |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
|
|
|
|
| def load_student(model_dir: str) -> torch.nn.Module: |
| """加载 Qwen3.5-4B 权重(CPU),剥离 `model.` 前缀。 |
| |
| model_dir 为 .gguf 文件时走自研加载器(gguf_qwen35): |
| 读取 + 反量化 + 张量名映射均自实现,不依赖 transformers 的 GGUF 支持, |
| 产出与训练 checkpoint 同格式的 state dict。 |
| """ |
| from safetensors import safe_open |
| from transformers import AutoConfig, AutoModel |
|
|
| if model_dir.endswith(".gguf"): |
| try: |
| from . import gguf_qwen35 |
| except ImportError: |
| import gguf_qwen35 |
| |
| model, info = gguf_qwen35.load_gguf_model_quantized(model_dir) |
| print(f" [student] GGUF loaded: {info}", flush=True) |
| return model |
|
|
| cfg = AutoConfig.from_pretrained(model_dir) |
| model = AutoModel.from_config(cfg) |
| index = json.load(open(os.path.join(model_dir, "model.safetensors.index.json"))) |
| by_shard: dict[str, list[str]] = defaultdict(list) |
| for k, sh in index["weight_map"].items(): |
| by_shard[sh].append(k) |
| sd: dict[str, torch.Tensor] = {} |
| for shard, keys in by_shard.items(): |
| with safe_open(os.path.join(model_dir, shard), framework="pt", device="cpu") as sf: |
| for k in keys: |
| newk = k[len("model."):] if k.startswith("model.") else k |
| sd[newk] = sf.get_tensor(k) |
| missing, unexpected = model.load_state_dict(sd, strict=False) |
| del sd |
| assert not missing, f"missing after prefix strip: {missing[:10]}" |
| if unexpected: |
| print(f" [student] ignored {len(unexpected)} unexpected keys (e.g. {unexpected[:3]})", flush=True) |
| return model |
|
|
|
|
| def find_language_model(model: torch.nn.Module) -> torch.nn.Module: |
| for name in ("language_model", "model", "text_model"): |
| if hasattr(model, name): |
| sub = getattr(model, name) |
| if hasattr(sub, "layers") or hasattr(sub, "config"): |
| return sub |
| raise RuntimeError(f"cannot locate language model submodule; attrs={[n for n in dir(model) if not n.startswith('_')]}") |
|
|
|
|
| def _move_plain_tensors(module: torch.nn.Module, device: torch.device) -> None: |
| """迁移普通 tensor 属性(GGMLTensor 量化权重/embed 权重等非 Parameter/buffer)。 |
| |
| nn.Module.to() 只迁移 Parameter 与 registered buffer,量化权重是普通属性, |
| 必须手动搬——否则 encode 后 offload 不彻底,量化权重滞留 GPU。 |
| """ |
| for name, attr in list(module.__dict__.items()): |
| if isinstance(attr, torch.Tensor) and not isinstance(attr, torch.nn.Parameter): |
| setattr(module, name, attr.to(device)) |
| for child in module.children(): |
| _move_plain_tensors(child, device) |
|
|
|
|
| class StudentTextEncoder: |
| """学生模型封装: prompt -> [S_S, 2560] bf16 hidden(post-final-norm)。 |
| |
| gpu_mem=""(默认): 整模型进 GPU;encode 后 lowvram 搬回 CPU。 |
| gpu_mem="5GiB"/"16GiB" 等: accelerate 层间 offload(权重驻留 RAM、按层流式进 GPU), |
| 适合 24GB 及以下卡(33B DiT + 4B 学生错峰)。 |
| lowvram=True(默认): encode 完成后立即释放 GPU 占用,让位给 DiT 采样。 |
| """ |
|
|
| def __init__(self, model_dir: str, dtype: torch.dtype = torch.bfloat16, |
| lowvram: bool = True, gpu_mem: str = ""): |
| self.model_dir = model_dir |
| self.dtype = dtype |
| self.lowvram = lowvram |
| self.gpu_mem = gpu_mem |
| self._dispatched = False |
| self._model: torch.nn.Module | None = None |
| self._lm: torch.nn.Module | None = None |
| self._tok = None |
|
|
| def _ensure_ready(self): |
| if self._model is None: |
| from transformers import AutoTokenizer |
| model = load_student(self.model_dir).to(self.dtype) |
| if self.gpu_mem: |
| from accelerate import dispatch_model, infer_auto_device_map |
| max_memory = {"cpu": "20GiB", 0: self.gpu_mem} |
| no_split = getattr(model, "_no_split_modules", None) or None |
| device_map = infer_auto_device_map(model, max_memory=max_memory, |
| no_split_module_classes=no_split) |
| dispatch_model(model, device_map=device_map) |
| self._dispatched = True |
| self._model = model |
| self._lm = find_language_model(self._model) |
| if self.model_dir.endswith(".gguf"): |
| tok_dir = os.path.dirname(self.model_dir) |
| if not os.path.exists(os.path.join(tok_dir, "tokenizer.json")): |
| raise RuntimeError( |
| f"GGUF 同目录 {tok_dir} 缺少 tokenizer.json —— 请把 Qwen3.5-4B 的 tokenizer 文件" |
| "(tokenizer.json / tokenizer_config.json / vocab.json / merges.txt / chat_template.jinja)拷到该目录") |
| self._tok = AutoTokenizer.from_pretrained(tok_dir) |
| else: |
| self._tok = AutoTokenizer.from_pretrained(self.model_dir) |
| print(f" [student] loaded {self.model_dir} (gpu_mem={self.gpu_mem or 'whole'})", flush=True) |
| dev = get_torch_device() |
| if not self._dispatched and next(self._lm.parameters()).device != dev: |
| self._model.to(dev) |
|
|
| def offload(self) -> None: |
| if self._lm is None: |
| return |
| if self._dispatched: |
| soft_empty_cache() |
| return |
| self._model.to("cpu") |
| _move_plain_tensors(self._model, torch.device("cpu")) |
| soft_empty_cache() |
|
|
| @torch.no_grad() |
| def __call__(self, prompt: str) -> torch.Tensor: |
| self._ensure_ready() |
| dev = get_torch_device() |
| ids = self._tok(prompt, add_special_tokens=False)["input_ids"] |
| out = self._lm(input_ids=torch.tensor([ids], device=dev), output_hidden_states=True) |
| h = out.hidden_states[-1][0].to(self.dtype) |
| if self.lowvram: |
| self.offload() |
| return h |
|
|