File size: 13,519 Bytes
2e26e31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
#!/usr/bin/env python3
"""Всё про MOSS-Image в одном месте: шаблон промпта, учитель (Qwen3.5-9B), ученик (0.8B), пайплайн.

Задача та же, что в docs/qwen-image-2.1-adapter.md: заменить текстовый энкодер 15.6 ГБ
(Qwen3.5-VL-9B, `language_model`, hidden 4096) на замороженную 0.8B + адаптер. Но здесь
проще и одновременно строже:

  * токенизатор у учителя и ученика ОДИН (248 077 токенов, совпадают и строки, и id),
    поэтому общая сегментация не нужна — id одни и те же;
  * цель — `last_hidden_state`, то есть ПОСТ-норм выход слоя 32 (Qwen3_5TextModel сам
    применяет `norm`). Это не то же самое, что в 2.1 (там был пред-норм выход), и именно
    это видит DiT: text_embedder.proj 4096 -> 3072;
  * учитель и ученик — одно семейство (Qwen3.5 hybrid linear attention), срезы ученика
    берутся после его full-attention слоёв (3,7,11,15,19,23) -> hs[4,8,12,16,20,24];
  * пайплайн принимает `prompt_embeds`/`negative_prompt_embeds` официально, monkeypatch
    не нужен (но метод `from_pretrained(text_encoder=None)` НЕ пропускает загрузку TE —
    пайплайн собираем вручную, см. build_pipeline).
"""
import json
import os
import sys

import torch

MOSS_DIR = os.environ.get("MOSS_DIR", "/home/recoilme/moss")
STUDENT = os.environ.get("MOSS_STUDENT", "Qwen/Qwen3.5-0.8B")
# раскладка папки модели: ученик — в text_encoder/, оригинальный 9B — рядом под явным именем
STUDENT_SUBDIR = "text_encoder"
TEACHER_SUBDIRS = ("text_encoder_qwen3.5-9b", "text_encoder")
# после полных attention-слоёв ученика (0-based 3,7,11,15,19,23) -> hidden_states[4,8,...]
LAYERS = (4, 8, 12, 16, 20, 24)


# ---------------------------------------------------------------- токены и шаблон
def load_tokenizer(moss_dir=None):
    """Токенизатор учителя = токенизатор ученика, берём из папки модели."""
    from transformers import AutoTokenizer
    return AutoTokenizer.from_pretrained(os.path.join(moss_dir or MOSS_DIR, "tokenizer"))


def render(tok, text):
    """Ровно то, что делает `MOSSImagePipeline.encode_prompt` (сырая строка, не id)."""
    return tok.apply_chat_template([{"role": "user", "content": text}], tokenize=False,
                                   add_generation_prompt=True, enable_thinking=True)


def encode(tok, texts, max_len=4096):
    """list[str] -> list[list[int]]: шаблон + обрезка хвоста, как в пайплайне (truncation)."""
    return [tok(render(tok, t), truncation=True, max_length=max_len)["input_ids"] for t in texts]


# ---------------------------------------------------------------- модели
def _has_config(path):
    return os.path.isfile(os.path.join(path, "config.json"))


def teacher_dir(moss_dir=None):
    """Папка оригинального текстового энкодера 9B (не удаляем и не переименовываем в text_encoder)."""
    root = moss_dir or MOSS_DIR
    for name in TEACHER_SUBDIRS:
        path = os.path.join(root, name)
        if _has_config(path):
            return path
    return os.path.join(root, TEACHER_SUBDIRS[0])


def student_dir(moss_dir=None):
    """Ученик: локальная копия в папке модели (`text_encoder/`), иначе HF-репозиторий."""
    local = os.path.join(moss_dir or MOSS_DIR, STUDENT_SUBDIR)
    return local if _has_config(local) else STUDENT


def load_text_model(path, dtype=torch.bfloat16, load_8bit=False, device="cuda"):
    """Qwen3.5 в любой раскладке — класс берём из `config.json`.

    Две раскладки одного и того же семейства: `Qwen3_5Model` (ключи `language_model.*` — так лежит
    текстовый энкодер MOSS) и `Qwen3_5ForConditionalGeneration` (ключи `model.*` — родной чекпойнт
    Qwen3.5-0.8B). Угадывать нельзя: не та раскладка грузится без ошибки, но со случайными весами
    и только предупреждением в логе — поэтому смотрим architectures и падаем, если не узнали.
    """
    import transformers
    from transformers import AutoConfig
    arch = (AutoConfig.from_pretrained(path, local_files_only=os.path.isdir(path)).architectures or [""])[0]
    classes = {"Qwen3_5Model": transformers.Qwen3_5Model,
               "Qwen3_5ForConditionalGeneration": transformers.Qwen3_5ForConditionalGeneration}
    if arch not in classes:
        raise SystemExit(f"{path}: не знаю, как грузить architectures={arch!r}")
    local = {"local_files_only": os.path.isdir(path)}
    if load_8bit:
        from transformers import BitsAndBytesConfig
        return classes[arch].from_pretrained(
            path, quantization_config=BitsAndBytesConfig(load_in_8bit=True),
            device_map={"": 0}, **local).eval()
    return classes[arch].from_pretrained(path, dtype=dtype, **local).to(device).eval()


def text_stack(model):
    """Текстовая стопка без vision-tower: у MOSS TE это `language_model`, у 0.8B — `model.language_model`."""
    for name in ("language_model", "model.language_model"):
        obj = model
        for part in name.split("."):
            obj = getattr(obj, part, None)
            if obj is None:
                break
        if obj is not None and hasattr(obj, "norm"):
            return obj
    raise SystemExit("не нашёл текстовую модель с .norm")


class Teacher:
    """Qwen3.5-VL-9B (текстовая часть): цель = post-norm `last_hidden_state`, (B,L,4096)."""

    def __init__(self, path=None, device="cuda", dtype=torch.bfloat16, load_8bit=False):
        path = path or teacher_dir()
        self.base = load_text_model(path, dtype=dtype, load_8bit=load_8bit, device=device)
        self.text = text_stack(self.base)
        self.quant = "8bit" if load_8bit else str(dtype).split(".")[-1]
        cfg = self.text.config
        print(f"учитель  : {sum(p.numel() for p in self.base.parameters()) / 1e9:.2f}B, "
              f"слоёв {cfg.num_hidden_layers}, hidden {cfg.hidden_size}, {self.quant}, "
              f"VRAM {torch.cuda.memory_allocated() / 2**30:.1f} ГиБ", flush=True)

    @torch.no_grad()
    def last_hidden(self, ids, mask=None):
        """(B,L) -> (B,L,4096) float32, пост-норм выход последнего слоя."""
        return self.text(input_ids=ids, attention_mask=mask, use_cache=False).last_hidden_state.float()


def load_teacher(device="cuda", prefer_8bit=False, path=None):
    """Учитель целиком, а если не влезает в карту — 8 бит. Возвращает (teacher, режим).

    После неудачной попытки надо отпустить traceback: он держит кадр с недогруженной моделью,
    и без gc/empty_cache вторая попытка падает на уже занятой памяти (проверено).
    """
    order = [True, False] if prefer_8bit else [False, True]
    errors = []
    for load_8bit in order:
        name = "8-бит" if load_8bit else "bf16"
        try:
            return Teacher(path=path, device=device, load_8bit=load_8bit), \
                ("8bit" if load_8bit else "bf16")
        except (torch.OutOfMemoryError, RuntimeError) as exc:
            if "out of memory" not in str(exc).lower():
                raise
            free_gib = torch.cuda.mem_get_info()[0] / 2**30
            print(f"  {name} не влез ({free_gib:.1f} ГиБ свободно) — пробую дальше", flush=True)
            errors.append(f"{name}: {str(exc).splitlines()[0][:100]}")
            del exc
            empty_cache()
    raise SystemExit("учитель не грузится: " + " | ".join(errors))


class Student:
    """Qwen3.5-0.8B (заморожен): срезы hidden_states -> (B, L, K*hidden)."""

    def __init__(self, path=None, layers=LAYERS, device="cuda", dtype=torch.bfloat16):
        path = path or student_dir()
        self.path = path
        self.base = load_text_model(path, dtype=dtype, device=device)
        self.base.requires_grad_(False)
        self.text = text_stack(self.base)
        if self.text.config.hidden_size >= 4096:
            raise SystemExit(f"{path}: hidden {self.text.config.hidden_size} — это учитель, а не "
                             f"ученик (у Qwen3.5-0.8B hidden 1024)")
        self.layers = list(layers)
        self.device = device
        cfg = self.text.config
        print(f"ученик   : {sum(p.numel() for p in self.base.parameters()) / 1e9:.2f}B, "
              f"слоёв {cfg.num_hidden_layers}, hidden {cfg.hidden_size}, срезы {self.layers}",
              flush=True)

    @property
    def in_dim(self):
        return len(self.layers) * self.text.config.hidden_size

    @torch.no_grad()
    def hidden(self, ids, mask=None):
        out = self.text(input_ids=ids, attention_mask=mask, output_hidden_states=True, use_cache=False)
        hs = out.hidden_states
        assert hs is not None and len(hs) > max(self.layers), f"скрытых состояний {len(hs)}"
        x = torch.stack([hs[i] for i in self.layers], dim=1)                 # (B, K, L, d)
        return x.permute(0, 2, 1, 3).reshape(x.shape[0], x.shape[2], -1).float()


# ---------------------------------------------------------------- инференс
def build_pipeline(moss_dir=None, dit_dtype=torch.bfloat16, vae_dtype=torch.float32):
    """MOSSImagePipeline без текстового энкодера.

    `DiffusionPipeline.from_pretrained(..., text_encoder=None)` здесь НЕ работает: text_encoder
    объявлен в __init__ с дефолтом None, поэтому diffusers не считает его «переданным» и лезет
    грузить 15.6 ГБ. Собираем компоненты руками — их три, и все локальные.
    """
    moss_dir = moss_dir or MOSS_DIR
    if moss_dir not in sys.path:
        sys.path.insert(0, moss_dir)
    from diffusers import AutoencoderKLFlux2
    from pipeline_moss_image import MOSSImagePipeline, MOSSImageScheduler, MOSSImageTransformer

    transformer = MOSSImageTransformer.from_pretrained(
        os.path.join(moss_dir, "transformer"), torch_dtype=dit_dtype, local_files_only=True)
    vae = AutoencoderKLFlux2.from_pretrained(
        os.path.join(moss_dir, "vae"), torch_dtype=vae_dtype, local_files_only=True)
    cfg = json.load(open(os.path.join(moss_dir, "scheduler", "scheduler_config.json")))
    keys = ("num_train_timesteps", "shift", "training_shift")
    scheduler = MOSSImageScheduler(**{k: cfg[k] for k in keys if k in cfg})
    return MOSSImagePipeline(transformer.eval(), scheduler, text_encoder=None, tokenizer=None,
                             vae=vae.eval())


@torch.no_grad()
def condition(adapter, student, tok, texts, max_len=4096):
    """list[str] -> list[(embeds [L,4096] bf16, mask [L] bool)]: по одному промпту без паддинга.

    Пайплайн кодирует каждый промпт отдельно (`unpadded individual forwards`), поэтому и здесь
    так же: длину маскирует сам DiT, а не токенизатор.
    """
    device = next(adapter.parameters()).device
    out = []
    for ids in encode(tok, texts, max_len):
        si = torch.tensor([ids], dtype=torch.long, device=device)
        mask = torch.ones_like(si, dtype=torch.bool)
        y = adapter(student.hidden(si, mask), mask)
        out.append((y[0].to(torch.bfloat16).cpu(), mask[0].cpu()))
    return out


@torch.no_grad()
def generate(pipe, pos, neg, size=1024, steps=30, seed=1234, guidance=3.0, device="cuda"):
    """pos/neg: (embeds [L,4096], mask [L]) -> PIL. CFG включён всегда (uncond_skip_layers)."""
    emb, mask = pos
    nemb, nmask = neg
    gen = torch.Generator(device).manual_seed(seed)
    return pipe(prompt_embeds=emb[None].to(device), prompt_attention_mask=mask[None].to(device),
                negative_prompt_embeds=nemb[None].to(device),
                negative_prompt_attention_mask=nmask[None].to(device),
                height=size, width=size, num_inference_steps=steps, guidance_scale=guidance,
                generator=gen, output_type="pil").images[0]


def empty_cache():
    """Вернуть освобождённую память карты. Сами объекты надо удалить у себя (`del`): `del` внутри
    функции снимает только локальную ссылку, объект остаётся жив, пока на него смотрит вызывающий."""
    import gc
    gc.collect()
    torch.cuda.empty_cache()