Text-to-Image
Diffusers
Safetensors
English
MOSSImagePipeline
moss-image
16gb
structured-pruning
alpha
Instructions to use AiArtLab/mossling with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use AiArtLab/mossling with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("AiArtLab/mossling", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- Draw Things
- DiffusionBee
Download src/train.py from AiArtLab/mossling: direct link, hf CLI and curl.
- Browser
- Download file 53.9 kB
-
https://huggingface.co/AiArtLab/mossling/resolve/main/src/train.py
- Command line
-
hf download hf://AiArtLab/mossling/src/train.py
-
curl -L -o train.py https://huggingface.co/AiArtLab/mossling/resolve/main/src/train.py
53.9 kB
| #!/usr/bin/env python3 | |
| """Файнтюн MOSS-Image DiT на закешированном датасете (`src/make_dataset.py`), цель — 16 ГБ. | |
| Что грузится: только сам DiT и тензоры из датасета. Текстовый энкодер не нужен вообще — | |
| условие и маска уже посчитаны, негатив CFG лежит рядом с датасетом в `empty_condition.npz`. | |
| VAE нужен ровно для одного — посмотреть прогресс-сэмпл; по умолчанию он выключен | |
| (`--sample-every 0`), и тогда процесс не трогает ничего, кроме датасета и DiT. | |
| Почему влезает (замерено на этой машине, batch 1): | |
| * bf16-веса 6.94 ГиБ — база, от неё никуда не деться; | |
| * активации не держим: градиентный чекпойнтинг по блокам (`use_reentrant=False`), а входы | |
| блоков лежат в RAM и поднимаются на карту на пересчёт (~0.15 с/шаг шины); | |
| * градиенты живут на один блок: Adam применяется к блоку сразу после его backward | |
| (`BlockUpdater`), градиенты блока сразу освобождаются. Обычный Adam требует градиенты всех | |
| 3.72 B одновременно (6.94 ГиБ) и на 1024x1024 падает с OOM при пике 14.7 ГиБ; | |
| * состояния Adam (2 байта × 2 на параметр = 7.45 ГиБ) живут в RAM — `PagedAdamW8bit`. | |
| Итог: пик 8.28 ГиБ на 768x1024 (2.2 с/шаг), 8.60 ГиБ на 1024x1024 (3.6 с/шаг). | |
| batch 2 нестабилен даже при 8 ГиБ запаса: managed-память paged-оптимизатора не успевает | |
| вытесняться, и torch получает OOM на 15.0 ГиБ — проверено, поэтому batch 1. | |
| Про «fused forward/backward»: склеить forward и backward в один шаг нельзя — веса всё равно | |
| нужны целиком. Память освобождает только «обновлять параметры сразу, как для них готов | |
| градиент», и это здесь и сделано (BlockUpdater): пик падает с 14.7 до 8.6 ГиБ. Отличие от | |
| обычного Adam одно — клип нормы считается по блоку, а не по всей модели. | |
| Лосс — flow matching, как в планировщике модели: | |
| x_t = (1 - sigma) * x0 + sigma * noise, таргет = noise - x0, timestep = sigma * 1000. | |
| Сетка обучения планировщика при `training_shift=1.0` равномерна по sigma, поэтому sigma — U(0,1). | |
| Запуск (по умолчанию — одна эпоха по датасету): | |
| python3 src/train.py --steps 40 # смоук: 2 минуты | |
| python3 src/train.py --epochs 1 --save-every 2000 # прогон | |
| python3 src/train.py --epochs 2 # все датасеты в папке datasets | |
| LR: `--lr` — базовая скорость; и прогрев, и затухание идут от `--lr/10`: линейный прогрев | |
| lr/10 -> lr за `--warmup` шагов, затем cosine от lr до lr/10 к последнему шагу. Постоянного lr | |
| нет — на сошедшейся базе он просто держит лосс. | |
| Прогресс-сэмплы: раз в `--sample-every` секунд (по умолчанию час) из условий самого датасета | |
| (сид фиксирован) -> `<out>/progress_<номер сэмпла>.png`; VAE на время сэмпла загружается и сразу | |
| освобождается, на диске живут последние `--sample-keep` картинок. | |
| Куда пишется результат: только в `--out` (по умолчанию `src/work/train`). Веса модели | |
| (`transformer/`) скрипт только читает; если `--out` указывает внутрь компонентов модели | |
| (`transformer/`, `vae/`, `text_encoder/`, `adapter/`, `tokenizer/`, `scheduler/`) или на саму папку | |
| модели — падаем до загрузки весов, чтобы случайно не перезаписать модель. Один чекпойнт — ~7.4 ГБ. | |
| """ | |
| import argparse | |
| import gc | |
| import glob | |
| import math | |
| import os | |
| import random as random_mod | |
| import re | |
| import shutil | |
| import sys | |
| import time | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from PIL import Image | |
| from torch.utils.checkpoint import checkpoint | |
| MODEL = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # папка модели | |
| if MODEL not in sys.path: | |
| sys.path.insert(0, MODEL) # diffusers ищет pipeline_moss_image как модуль | |
| from pipeline_moss_image import MOSSImageScheduler, MOSSImageTransformer # noqa: E402 | |
| def parse_args(): | |
| p = argparse.ArgumentParser(description="MOSS-Image: файнтюн DiT под 16 ГБ") | |
| p.add_argument("--dataset", default=os.path.join(MODEL, "src", "work", "ds_testg_filtered"), | |
| help="папка с датасетами и чанками из make_dataset.py; всё найденное внутри " | |
| "склеивается в один набор") | |
| p.add_argument("--out", default=os.path.join(MODEL, "src", "work", "train_base"), | |
| help="каталог логов, чекпойнтов и прогресс-сэмплов") | |
| p.add_argument("--epochs", type=float, default=1.0, | |
| help="эпох по выбранным строкам; шаги = ceil(epochs * строк / (batch * accum))") | |
| p.add_argument("--steps", type=int, default=0, | |
| help="точное число шагов, перебивает --epochs; 0 — считать из --epochs") | |
| p.add_argument("--lr", type=float, default=1e-5) | |
| p.add_argument("--warmup", type=int, default=200, | |
| help="шагов линейного прогрева lr/10 -> lr") | |
| p.add_argument("--batch-size", type=int, default=1, | |
| help="сэмплов на шаг (только одного размера); на 16 ГиБ влезает только 1 — " | |
| "batch 2 доходит до 14.85 ГиБ и падает с OOM") | |
| p.add_argument("--accum", type=int, default=1, help="шагов накопления градиента") | |
| p.add_argument("--clip", type=float, default=1.0, help="клип нормы градиента; 0 — выключить") | |
| p.add_argument("--upd", choices=("block", "whole"), default="block", | |
| help="block — Adam по блоку сразу после его backward (пик ~8 ГиБ); " | |
| "whole — обычный Adam на всём наборе (нужно ~14.7 ГиБ, на 1024x1024 не влезает)") | |
| p.add_argument("--master-dtype", choices=("fp32", "bf16"), default="fp32", | |
| help="в чём лежат мастер-веса для шага оптимизатора: fp32 — bf16-копия идёт в " | |
| "forward/backward, а шаг делается по fp32 (иначе при lr~1e-5 обновление " | |
| "теряется в bf16-решётке); bf16 — как было, шаг прямо по bf16-весам") | |
| p.add_argument("--master-device", choices=("cuda", "cpu"), default="cuda", | |
| help="где живёт fp32-мастер: cuda — на карте (+13.9 ГиБ, шаг без копий через шину); " | |
| "cpu — в RAM (на карте только bf16-веса, но градиенты блока едет по PCIe)") | |
| p.add_argument("--te", action="store_true", default=True, | |
| help="считать условие на лету живым текстовым энкодером. Нужно, когда в датасете " | |
| "нет колонок text_embeds/text_mask (make_dataset.py без --te); с колонками " | |
| "энкодер не грузится") | |
| p.add_argument("--text-len", type=int, default=1024, | |
| help="макс. токенов условия при подсчёте на лету (как в make_dataset.py)") | |
| p.add_argument("--word-dropout", type=float, default=0.05, | |
| help="доля шагов, где из промпта выкидывается одно случайное слово (устойчивость " | |
| "к ошибкам в описании); работает только когда энкодер загружен (--te или нет " | |
| "колонок TE). 0 — выключить") | |
| p.add_argument("--text-dropout", type=float, default=0.05, | |
| help="доля шагов с пустым промптом (CFG-ветка); без empty_condition.npz " | |
| "молча выключается") | |
| p.add_argument("--sigma", choices=("uniform", "logit"), default="logit", | |
| help="откуда берётся sigma: uniform — U(0,1), как учили базу; logit — " | |
| "sigmoid(N(--sigma-shift, --sigma-scale)), смещает шаги к нижим сигмам (детали)") | |
| p.add_argument("--sigma-shift", type=float, default=-0.5, | |
| help="logit-normal: среднее в logit-шкале; <0 — меньше шагов на высоком шуме, " | |
| "больше на середине (детали)") | |
| p.add_argument("--sigma-scale", type=float, default=1.0, help="logit-normal: разброс") | |
| p.add_argument("--limit", type=int, default=0, help="первые N строк датасета (0 — все)") | |
| p.add_argument("--sizes", default="", help="только эти размеры, напр. 768x1024,1024x768") | |
| p.add_argument("--seed", type=int, default=42) | |
| p.add_argument("--log-every", type=int, default=50) | |
| p.add_argument("--ema", type=float, default=0.99, | |
| help="сглаживание лосса в логе: ema = ema*--ema + loss*(1---ema); 0 — без сглаживания") | |
| p.add_argument("--save-every", type=int, default=2000, | |
| help="шагов между чекпойнтами; 0 — только в конце") | |
| p.add_argument("--keep", type=int, default=4, | |
| help="сколько step_* чекпойнтов держать на диске (старые удаляются)") | |
| p.add_argument("--init", default="", help="стартовать с чекпойнта (step_0005000 или final)") | |
| p.add_argument("--start-step", type=int, default=0, | |
| help="с какого шага продолжать расписание lr при --init (иначе прогрев и cosine " | |
| "пойдут заново с нуля — неверно при продолжении)") | |
| p.add_argument("--sample-every", type=float, default=1800.0, | |
| help="секунд между прогресс-сэмплами (1800 — раз в полчаса); 0 — выключить " | |
| "(тогда VAE не грузится)") | |
| p.add_argument("--sample-keep", type=int, default=8, | |
| help="сколько progress_*.png держать на диске (старые удаляются); 0 — не удалять") | |
| p.add_argument("--sample-n", type=int, default=6, help="картинок в прогресс-сэмпле") | |
| p.add_argument("--sample-steps", type=int, default=30, help="шагов денойзинга в сэмпле") | |
| p.add_argument("--sample-size", type=int, default=1024, help="длинная сторона сэмпла") | |
| p.add_argument("--guidance", type=float, default=3.0) | |
| p.add_argument("--sample-rows", default="", | |
| help="индексы строк для прогресс-сэмплов через запятую, напр. 0,30000; " | |
| "по умолчанию — равномерно по датасету") | |
| p.add_argument("--sample-seed", type=int, default=42) | |
| return p.parse_args() | |
| # ---------------------------------------------------------------- данные | |
| def find_dataset_chunks(root): | |
| """Все arrow-датасеты под `root` (папки с dataset_info.json). | |
| make_dataset.py пишет не один датасет, а кучу под-датасетов `chunk_*/sub_*` — их и находим. | |
| """ | |
| chunks = [] | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| dirnames[:] = [d for d in dirnames if not d.startswith(".")] | |
| if "dataset_info.json" in filenames or "state.json" in filenames: | |
| chunks.append(dirpath) | |
| dirnames.clear() # внутрь датасета не лезем | |
| return chunks | |
| def load_any_dataset(path): | |
| """Один датасет или папка с чанками: конкатенация ленивая, arrow-файлы не переписываются.""" | |
| from datasets import concatenate_datasets, load_from_disk | |
| chunks = find_dataset_chunks(path) | |
| if not chunks: | |
| raise FileNotFoundError(f"в {path} нет arrow-датасета (ни dataset_info.json, ни state.json)") | |
| if len(chunks) == 1: | |
| print(f"датасет: {chunks[0]}", flush=True) | |
| return load_from_disk(chunks[0]) | |
| print(f"датасет: {len(chunks)} чанков в {path}", flush=True) | |
| return concatenate_datasets([load_from_disk(c) for c in chunks]) | |
| def open_dataset(path, limit, sizes): | |
| """Датасет не грузим в RAM: латент на строку — мегабайты, а 6k строк — это десятки ГБ. | |
| Возвращаем (ds, rows): `rows` — только метаданные (индекс, размер), сами тензоры | |
| читаются по индексу в `to_batch` (27 мс на строку при 768x1024, незаметно на фоне шага). | |
| """ | |
| ds = load_any_dataset(path) | |
| meta = ds.select_columns(["width", "height"]) # только скаляры, быстро | |
| ds = ds.with_format("numpy") | |
| wanted = {x.strip() for x in sizes.split(",") if x.strip()} | |
| rows = [] | |
| for i in range(len(meta)): | |
| w, h = int(meta[i]["width"]), int(meta[i]["height"]) | |
| if wanted and f"{w}x{h}" not in wanted: | |
| continue | |
| rows.append({"idx": i, "width": w, "height": h}) | |
| if limit > 0 and len(rows) >= limit: # limit считается после фильтра по размерам | |
| break | |
| return ds, rows | |
| def load_empty_condition(path): | |
| """Негатив CFG, посчитанный make_dataset.py, или None. | |
| Ищем и в корне, и на уровень ниже: при `--dataset datasets` файл лежит в каждом под-датасете | |
| (`datasets/civit/empty_condition.npz`), а в корне его нет — без этого сэмплы шли без CFG. | |
| """ | |
| files = [os.path.join(path, "empty_condition.npz")] | |
| files += sorted(glob.glob(os.path.join(path, "*", "empty_condition.npz"))) | |
| for file in files: | |
| if os.path.exists(file): | |
| z = np.load(file) | |
| return (torch.from_numpy(z["embeds"].copy()), torch.from_numpy(z["mask"].copy())) | |
| return None | |
| class Buckets: | |
| """Батчи только из одинаковых размеров: латент — один тензор, разные HxW не склеить.""" | |
| def __init__(self, rows, generator): | |
| self.rows = rows | |
| self.generator = generator | |
| self.by_size = {} | |
| for i, r in enumerate(rows): | |
| self.by_size.setdefault((r["width"], r["height"]), []).append(i) | |
| self.sizes = sorted(self.by_size) | |
| print("размеры: " + ", ".join(f"{w}x{h}: {len(self.by_size[(w, h)])}" | |
| for w, h in self.sizes), flush=True) | |
| self._perms, self.cursor = {}, {} | |
| def _shuffle(self): | |
| for size, idx in self.by_size.items(): | |
| order = list(idx) | |
| for i in range(len(order) - 1, 0, -1): # фишер-йетс на своём генераторе | |
| j = int(torch.randint(0, i + 1, (1,), generator=self.generator)) | |
| order[i], order[j] = order[j], order[i] | |
| self._perms[size] = order | |
| self.cursor[size] = 0 | |
| def batch(self, batch_size): | |
| if not self._perms: | |
| self._shuffle() | |
| # размер выбираем пропорционально числу строк, чтобы мелкие группы не залипали | |
| weights = [len(self.by_size[s]) for s in self.sizes] | |
| pick = int(torch.multinomial(torch.tensor(weights, dtype=torch.float), 1, | |
| generator=self.generator)) | |
| size = self.sizes[pick] | |
| picked = [] | |
| for _ in range(batch_size): | |
| if self.cursor[size] >= len(self._perms[size]): | |
| self._perms[size] = self._perms[size][::-1] # второй проход в обратном порядке | |
| self.cursor[size] = 0 | |
| picked.append(self._perms[size][self.cursor[size]]) | |
| self.cursor[size] += 1 | |
| return [self.rows[i] for i in picked] | |
| def read_row(ds, row): | |
| r = ds[row["idx"]] | |
| return {"vae": r["vae"], "text": r["text"], "text_embeds": r.get("text_embeds"), | |
| "text_mask": r.get("text_mask"), "source": r["source"], "width": row["width"], | |
| "height": row["height"]} | |
| def to_batch(rows, ds, device): | |
| """rows (одинаковый HxW) -> латент [B,128,h,w] bf16, условие [B,L,4096] bf16 + маска [B,L]. | |
| Если в датасете нет text_embeds (make_dataset.py без --te), условие собирается живым | |
| энкодером в text_encoder.py — возвращаем строки, а не тензоры. | |
| """ | |
| recs = [read_row(ds, r) for r in rows] | |
| lat = torch.stack([torch.from_numpy(r["vae"]) for r in recs]).to(device, torch.bfloat16) | |
| if recs[0]["text_embeds"] is None: | |
| return lat, None, None | |
| length = max(len(r["text_embeds"]) for r in recs) | |
| cond = torch.zeros(len(recs), length, 4096, dtype=torch.bfloat16) | |
| mask = torch.zeros(len(recs), length, dtype=torch.bool) | |
| for i, r in enumerate(recs): | |
| n = len(r["text_embeds"]) | |
| cond[i, :n] = torch.from_numpy(r["text_embeds"]) | |
| mask[i, :n] = torch.from_numpy(r["text_mask"]) | |
| return lat, cond.to(device), mask.to(device) | |
| # ---------------------------------------------------------------- условие на лету | |
| class LiveTextEncoder: | |
| """Текст условия живым ТЕ, когда в датасете нет колонок text_embeds/text_mask. | |
| Путь тот же, что в make_dataset.py (`encode_text`/`render`), чтобы условие совпало с тем, | |
| что было бы закэшировано: chat-шаблон с add_generation_prompt=True и enable_thinking=True, | |
| обрезка по --text-len, отдельный forward на промпт без паддинга (паддинг менял бы условие). | |
| Возвращает (embeds float16 [L,4096], mask bool [L]). | |
| """ | |
| def __init__(self, model_dir, device, text_len): | |
| """`device` — где считать; на карте энкодер держится только на время кодирования. | |
| 0.85B стоит ~2.3 ГиБ. Вместе с 20.8 ГиБ fp32-мастера и активациями DiT на 32 ГБ это | |
| упирается в потолок, поэтому после encode_batch энкодер уходит в offload (см. `release`). | |
| """ | |
| from transformers import AutoTokenizer | |
| from pipeline_moss_image import MOSSAdapterTextEncoder | |
| path = os.path.join(model_dir, "text_encoder") | |
| self.tok = AutoTokenizer.from_pretrained(path) | |
| # from_pretrained сам поднимает голову из ../adapter | |
| self.te = MOSSAdapterTextEncoder.from_pretrained(path).to(device).eval() | |
| self.te.requires_grad_(False) | |
| self.device = torch.device(device) | |
| self.offloaded = False | |
| self.text_len = text_len | |
| def release(self): | |
| """Убрать энкодер с карты, если он там: память нужна шагу DiT.""" | |
| if self.device.type == "cuda" and not self.offloaded: | |
| self.te.to("cpu") | |
| self.offloaded = True | |
| torch.cuda.empty_cache() | |
| def _ensure(self): | |
| if self.offloaded: | |
| self.te.to(self.device) | |
| self.offloaded = False | |
| def _render(self, text): | |
| return self.tok.apply_chat_template([{"role": "user", "content": text}], tokenize=False, | |
| add_generation_prompt=True, enable_thinking=True) | |
| def encode(self, text): | |
| self._ensure() | |
| ids = self.tok(self._render(text), truncation=True, max_length=self.text_len)["input_ids"] | |
| if not ids: | |
| raise ValueError("пустой промпт после токенизации") | |
| ids = torch.tensor([ids], dtype=torch.long, device=self.device) | |
| out = self.te(input_ids=ids, attention_mask=torch.ones_like(ids, dtype=torch.bool), | |
| use_cache=False) | |
| emb = out.last_hidden_state[0].to(torch.float16) | |
| return emb.cpu().numpy(), np.ones(emb.shape[0], dtype=bool) | |
| def encode_batch(self, texts): | |
| """Строки промптов -> [B,L,4096] bf16 + маска [B,L] (L = max по батчу, без паддинга в ТЕ).""" | |
| pairs = [self.encode(t) for t in texts] | |
| length = max(len(e) for e, _ in pairs) | |
| cond = torch.zeros(len(pairs), length, 4096, dtype=torch.bfloat16) | |
| mask = torch.zeros(len(pairs), length, dtype=torch.bool) | |
| for i, (emb, m) in enumerate(pairs): | |
| cond[i, :len(emb)] = torch.from_numpy(emb) | |
| mask[i, :len(emb)] = torch.from_numpy(m) | |
| return cond, mask | |
| def drop_word(text, gen, rng): | |
| """Убрать одно случайное слово из промпта (один бросок на строку). | |
| Цель — чтобы модель была устойчива к пропускам и мусору в описании. Если слово одно — | |
| остаётся пустая строка (это же и есть ветка пустого промпта). | |
| """ | |
| words = text.split() | |
| if not words: | |
| return text | |
| del words[int(torch.randint(0, len(words), (1,), generator=gen))] | |
| out = " ".join(words) | |
| return out or rng.choice(["image", "a picture", "photo"]) | |
| def _pair(module, hidden_dtype, master_dtype, device): | |
| """Завести мастер-тензоры для Linear: (master_w, master_b), скрытые веса — в module.weight.""" | |
| if not isinstance(module, torch.nn.Linear): | |
| return | |
| def empty_like(t): | |
| return torch.empty(t.shape, dtype=master_dtype, device=device) | |
| pairs = [empty_like(module.weight)] | |
| if module.bias is not None: | |
| pairs.append(empty_like(module.bias)) | |
| module._master = pairs | |
| class MasterWeights: | |
| """fp32-мастер весов: forward/backward по bf16, шаг оптимизатора — по fp32-копии. | |
| Зачем: при lr ~1e-5 обновление `w -= lr*g` — это изменение ~1e-5 от |w|, а квант шага bf16 | |
| (7 мантийных бит) ~2e-3 от |w|. Шаг не представим: он либо равен нулю, либо округляется | |
| вверх до ступеньки решётки. Мастер в fp32 убирает именно это; веса для forward остаются bf16, | |
| поэтому VRAM и скорость счёта не меняются. | |
| Устройство: `module.weight` всегда остаётся bf16 (`nn.Parameter` не подменяется — иначе | |
| autograd и оптимизатор начинают спорить о dtype градиента). Рядом лежит fp32-тензор | |
| `module._master[i]`, уже объявленный оптимизатору как параметр. Цикл шага: | |
| forward/backward по bf16-весам | |
| grads() bf16-градиенты -> fp32-grad мастеров | |
| opt.step() Adam в fp32 по мастерам (состояния тоже fp32) | |
| sync() мастера -> bf16-веса для следующего forward | |
| Предел: fp32-мастер на всю модель — это +13.9 ГиБ (на карте или в RAM при --master-device cpu). | |
| """ | |
| def __init__(self, model, device, dtype=torch.float32): | |
| self.device = torch.device(device) | |
| self.master_dtype = dtype | |
| self.slots = [] # [(bf16-параметр, fp32-мастер)] | |
| self._map = None | |
| hidden_dtype = next(model.parameters()).dtype | |
| for module in model.modules(): | |
| _pair(module, hidden_dtype, dtype, self.device) | |
| for module in model.modules(): | |
| masters = getattr(module, "_master", None) | |
| if masters is None: | |
| continue | |
| hidden = [module.weight] + ([module.bias] if module.bias is not None else []) | |
| self.slots += list(zip(hidden, masters)) | |
| def init(self, model): | |
| """Один раз, до создания оптимизатора: мастера = стартовые веса в fp32.""" | |
| for hidden, master in self.slots: | |
| master.copy_(hidden.data.to(self.master_dtype)) | |
| def parameters(self): | |
| return [master for _, master in self.slots] | |
| def _master_of(self, hidden): | |
| """fp32-двойник bf16-параметра (словарь строится один раз).""" | |
| if self._map is None: | |
| self._map = {id(h): m for h, m in self.slots} | |
| return self._map.get(id(hidden)) | |
| def grads(self): | |
| """bf16-градиенты -> fp32-градиенты мастеров (None, если градиента нет).""" | |
| self.grads_for([h for h, _ in self.slots]) | |
| def grads_for(self, hidden_params): | |
| """То же для конкретной группы (нужно BlockUpdater, который обновляет по блоку).""" | |
| for h in hidden_params: | |
| m = self._master_of(h) | |
| if m is not None: | |
| m.grad = None if h.grad is None else h.grad.to(m.device, self.master_dtype) | |
| def sync(self): | |
| """fp32-мастер -> bf16-веса для следующего forward.""" | |
| for hidden, master in self.slots: | |
| hidden.data.copy_(master) | |
| def sync_one(self, hidden): | |
| """fp32-мастер одного параметра -> его bf16-вес.""" | |
| m = self._master_of(hidden) | |
| if m is not None: | |
| hidden.data.copy_(m) | |
| # ---------------------------------------------------------------- чекпойнтинг и сэмплы | |
| def _run_block(block, x_cpu, rope, mod): | |
| """Вход блока лежит на CPU, на пересчёт поднимается обратно (см. set_block_checkpointing).""" | |
| return block(x_cpu.to("cuda", non_blocking=True), rope, mod) | |
| def set_block_checkpointing(tr, on): | |
| """Пересчёт блоков на backward вместо хранения активаций — без этого 16 ГБ не хватает. | |
| Вход каждого блока (25 МиБ на 1024 px) держим в RAM, а не на карте: autograd сохраняет | |
| только этот вход, поэтому выигрываем ~0.75-1.0 ГиБ GPU — ровно ту разницу, которой не | |
| хватало на квадратных 1024x1024 и на управляемую память PagedAdamW. За шаг это ~0.15 с | |
| копий шины, дешевле любого другого способа освободить гигабайт. | |
| """ | |
| for layer in tr.layers: | |
| if on and "forward" not in layer.__dict__: | |
| block = layer.forward | |
| layer.forward = (lambda x, rope, mod, _b=block: | |
| checkpoint(_run_block, _b, x.cpu(), rope, mod, use_reentrant=False)) | |
| elif not on and "forward" in layer.__dict__: | |
| del layer.forward | |
| class BlockUpdater: | |
| """Adam по каждому блоку сразу после его backward; градиенты блока освобождаются. | |
| Зачем: обычному Adam нужны градиенты всех 3.72 B параметров (6.94 ГиБ) одновременно — | |
| вместе с весами это 13.88 ГиБ, и 1024x1024 в 16 ГБ уже не влезает (замерено: OOM на 14.7). | |
| `register_post_accumulate_grad_hook` срабатывает, когда у параметра появился градиент; как | |
| только все параметры блока их получили, блок обновляется и его градиенты обнуляются. | |
| Пик: веса + активации одного блока ~ 8 ГиБ. | |
| Градиенты при этом те же, что в обычном проходе: backward идёт от последнего блока к первому, | |
| и блок обновляется только после того, как посчитан и пересчитан на своих старых весах. | |
| Отличие одно — клип локальный по блоку, а не глобальный (это единственное, что нельзя | |
| посчитать без всех градиентов сразу). | |
| """ | |
| def __init__(self, tr, lr, clip, master=None): | |
| import bitsandbytes as bnb | |
| block_ids = {id(p) for layer in tr.layers for p in layer.parameters()} | |
| groups = [[p for p in layer.parameters()] for layer in tr.layers] | |
| groups.append([p for p in tr.parameters() if id(p) not in block_ids]) # эмбеддеры, голова | |
| self.groups = [g for g in groups if g] | |
| self.clip = clip | |
| # с fp32-мастером шаг идёт по мастерам: у каждого bf16-параметра свой fp32-двойник | |
| self.master = master | |
| to_master = {} if master is None else {id(h): m for h, m in master.slots} | |
| self.targets = [[to_master.get(id(p), p) for p in g] for g in self.groups] | |
| self.opts = [bnb.optim.PagedAdamW8bit(t, lr=lr, betas=(0.9, 0.95), eps=1e-8) | |
| for t in self.targets] | |
| self.left = [] | |
| for gi, params in enumerate(self.groups): | |
| self.left.append([len(params)]) | |
| for p in params: | |
| p.register_post_accumulate_grad_hook(lambda _p, gi=gi: self._release(gi)) | |
| def set_lr(self, lr): | |
| for opt in self.opts: | |
| for group in opt.param_groups: | |
| group["lr"] = lr | |
| def _release(self, gi): | |
| self.left[gi][0] -= 1 | |
| if self.left[gi][0] > 0: | |
| return | |
| params = self.targets[gi] | |
| if self.master is not None: | |
| self.master.grads_for(self.groups[gi]) | |
| if self.clip > 0: | |
| torch.nn.utils.clip_grad_norm_(params, self.clip) | |
| self.opts[gi].step() | |
| for p in params: | |
| p.grad = None | |
| for p in self.groups[gi]: # bf16-копии получают обновлённые веса | |
| if self.master is not None: | |
| self.master.sync_one(p) | |
| p.grad = None | |
| self.left[gi][0] = len(params) | |
| def finish(self): | |
| """Страховка: группа, которой не хватило градиентов, обновляется здесь.""" | |
| for gi, left in enumerate(self.left): | |
| params = self.groups[gi] | |
| if left[0] == len(params): | |
| continue | |
| print(f"ВНИМАНИЕ: группе {gi} не хватило {left[0]} градиентов — обновляю в конце", | |
| flush=True) | |
| if any(p.grad is not None for p in params): | |
| if self.clip > 0: | |
| torch.nn.utils.clip_grad_norm_(params, self.clip) | |
| self.opts[gi].step() | |
| for p in params: | |
| p.grad = None | |
| left[0] = len(params) | |
| def prune_samples(out, keep): | |
| """Имена нумерованы по сэмплу и с ведущими нулями, поэтому старые — это просто начало списка.""" | |
| if keep <= 0: | |
| return | |
| names = sorted(n for n in os.listdir(out) | |
| if n.startswith("progress_") and n.endswith(".png")) | |
| for name in names[:-keep]: | |
| os.remove(os.path.join(out, name)) | |
| def sample_grid(tr, rows, ds, args, out_path, empty, live=None): | |
| """Несколько картинок из условий датасета: VAE грузим на время сэмпла и сразу освобождаем.""" | |
| from diffusers import AutoencoderKLFlux2 | |
| from pipeline_moss_image import MOSSImagePipeline | |
| set_block_checkpointing(tr, False) | |
| tr.eval() | |
| # fp16: замерено, что на этом VAE качество то же, что в fp32, а грузится и считает вдвое быстрее | |
| vae = AutoencoderKLFlux2.from_pretrained(os.path.join(MODEL, "vae"), | |
| torch_dtype=torch.float16).to("cuda").eval() | |
| pipe = MOSSImagePipeline(tr, MOSSImageScheduler(), None, None, vae) | |
| pipe.set_progress_bar_config(disable=True) | |
| cfg = args.guidance > 1.0 and (empty is not None or live is not None) | |
| if cfg: | |
| if live is not None: | |
| e, m = live.encode("") | |
| nemb = torch.from_numpy(e)[None].to("cuda", torch.bfloat16) | |
| nmask = torch.from_numpy(m)[None].cuda() | |
| else: | |
| nemb, nmask = empty[0][None].cuda(), empty[1][None].cuda() | |
| tiles = [] | |
| for i, meta in enumerate(rows): | |
| r = read_row(ds, meta) | |
| long_side = args.sample_size # aspect из датасета, длинная сторона = --sample-size | |
| if r["width"] >= r["height"]: | |
| w, h = long_side, round(long_side * r["height"] / r["width"] / 32) * 32 | |
| else: | |
| w, h = round(long_side * r["width"] / r["height"] / 32) * 32, long_side | |
| print(f" сэмпл {i + 1}: {w}x{h} {r['text'][:200]}", flush=True) | |
| if r["text_embeds"] is not None: | |
| cond = torch.from_numpy(r["text_embeds"])[None].to("cuda", torch.bfloat16) | |
| cmask = torch.from_numpy(r["text_mask"])[None].cuda() | |
| else: # условия в датасете нет — считаем тем же путём, что и обучение | |
| cond, cmask = live.encode_batch([r["text"]]) | |
| cond, cmask = cond.to("cuda"), cmask.to("cuda") | |
| tiles.append(pipe( | |
| prompt_embeds=cond, | |
| prompt_attention_mask=cmask, | |
| negative_prompt_embeds=nemb if cfg else None, | |
| negative_prompt_attention_mask=nmask if cfg else None, | |
| height=h, width=w, num_inference_steps=args.sample_steps, | |
| guidance_scale=args.guidance if cfg else 1.0, | |
| generator=torch.Generator("cuda").manual_seed(args.sample_seed + i), | |
| output_type="pil").images[0]) | |
| del pipe, vae | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| cols = min(2, len(tiles)) | |
| rows_n = (len(tiles) + cols - 1) // cols | |
| cell = args.sample_size | |
| grid = Image.new("RGB", (cols * cell, rows_n * cell), "white") | |
| for i, tile in enumerate(tiles): # ячейка фиксированная, картинка по центру — без искажений | |
| grid.paste(tile, ((i % cols) * cell + (cell - tile.width) // 2, | |
| (i // cols) * cell + (cell - tile.height) // 2)) | |
| grid.save(out_path) | |
| print(f"сэмпл -> {out_path}" + ("" if cfg else " (без CFG: нет empty_condition.npz)"), | |
| flush=True) | |
| tr.train() | |
| set_block_checkpointing(tr, True) # без этого forward DiT держит все активации и ловит OOM | |
| # ---------------------------------------------------------------- обучение | |
| PROTECTED = ("transformer", "vae", "text_encoder", "adapter", "tokenizer", "scheduler") | |
| def guard_out(out): | |
| """Не даём писать чекпойнты поверх модели: обучение — только в отдельный каталог.""" | |
| out_abs = os.path.abspath(out) | |
| model_abs = os.path.abspath(MODEL) | |
| for name in [""] + list(PROTECTED): | |
| protected = os.path.join(model_abs, name) if name else model_abs | |
| if out_abs == protected or out_abs.startswith(protected + os.sep) and name: | |
| raise SystemExit(f"--out {out_abs} лежит внутри модели ({protected}); " | |
| f"выберите отдельный каталог, например src/work/train") | |
| def main(): | |
| args = parse_args() | |
| guard_out(args.out) | |
| torch.manual_seed(args.seed) | |
| os.makedirs(args.out, exist_ok=True) | |
| device = "cuda" | |
| # PagedAdamW8bit не имеет своего лимита: вытесненные состояния возвращаются в VRAM, как только | |
| # там освободилось место, карта доходит до нуля запаса и падает OOM на транзиентной аллокации | |
| # (40 МиБ на пересчёте FFN). Потолок аллокатора оставляет ~8 % свободными; не влезшие страницы | |
| # оптимизатор держит в RAM, это его штатный режим. | |
| torch.cuda.set_per_process_memory_fraction(0.92) | |
| print(f"потолок VRAM: {0.92 * torch.cuda.get_device_properties(0).total_memory / 2**30:.2f} ГиБ " | |
| f"из {torch.cuda.get_device_properties(0).total_memory / 2**30:.2f}", flush=True) | |
| ds, rows = open_dataset(args.dataset, args.limit, args.sizes) | |
| if not rows: | |
| raise SystemExit("датасет пуст") | |
| print(f"строк: {len(rows)}", flush=True) | |
| has_te_cols = "text_embeds" in ds.column_names | |
| live = None | |
| if args.te or not has_te_cols: | |
| live = LiveTextEncoder(MODEL, device, args.text_len) | |
| print(f"условие считается на лету ({'--te' if args.te else 'в датасете нет text_embeds'}), " | |
| f"text-len {args.text_len}", flush=True) | |
| empty = load_empty_condition(args.dataset) | |
| if live is None and empty is not None: | |
| print(f"пустой промпт (негатив CFG): {tuple(empty[0].shape)}", flush=True) | |
| if live is None and args.text_dropout > 0 and empty is None: | |
| print("empty_condition.npz нет -> text-dropout выключен", flush=True) | |
| dropout = args.text_dropout if (live is not None or empty is not None) else 0.0 | |
| # дропаут слова требует перекодирования промпта: работает только когда энкодер загружен | |
| word_dropout = args.word_dropout if live is not None else 0.0 | |
| if args.word_dropout > 0 and live is None: | |
| print("word-dropout выключен: условие берётся из датасета, энкодер не загружен", flush=True) | |
| if word_dropout: | |
| print(f"дропауты: пустой промпт {dropout:.0%}, случайное слово {word_dropout:.0%}", | |
| flush=True) | |
| n_sample = min(args.sample_n, len(rows)) # сэмплы — из самих данных, по всему датасету | |
| if args.sample_rows: # при смешанных датасетах равномерный срез может попасть в tag-промпт | |
| sample_rows = [rows[int(x)] for x in args.sample_rows.split(",") if x.strip()] | |
| else: | |
| sample_rows = [rows[int(i)] for i in np.linspace(0, len(rows) - 1, n_sample)] | |
| if args.sample_every: # промпты в лог сразу: иначе непонятно, что именно нарисовано | |
| text_col = ds.select_columns(["text"]) | |
| print("прогресс-сэмплы берутся из строк:", flush=True) | |
| for i, meta in enumerate(sample_rows, 1): | |
| print(f" {i}) строка {meta['idx']} {meta['width']}x{meta['height']} " | |
| f"{text_col[meta['idx']]['text'][:160]}", flush=True) | |
| # Paged-оптимизатор держит состояния в managed-памяти: если карту уже занял другой процесс, | |
| # он не падает с OOM, а часами гоняет страницы. Лучше предупредить сразу. | |
| free_gib = torch.cuda.mem_get_info()[0] / 2**30 | |
| if free_gib < 15.0: | |
| print(f"ВНИМАНИЕ: свободно {free_gib:.1f} ГиБ — обучению нужно ~14.7. " | |
| f"Проверьте, не висит ли другой процесс на GPU (nvidia-smi)", flush=True) | |
| tr = MOSSImageTransformer.from_pretrained( | |
| os.path.join(MODEL, "transformer"), torch_dtype=torch.bfloat16, | |
| local_files_only=True, low_cpu_mem_usage=False).to(device).train() | |
| if args.init: | |
| state = MOSSImageTransformer.from_pretrained( | |
| args.init, torch_dtype=torch.bfloat16, local_files_only=True, | |
| low_cpu_mem_usage=False).state_dict() | |
| tr.load_training_state_dict(state) | |
| print(f"старт с {args.init}", flush=True) | |
| tr.requires_grad_(True) | |
| params = [p for p in tr.parameters() if p.requires_grad] | |
| n_param = sum(p.numel() for p in params) | |
| print(f"параметров {n_param / 1e9:.3f}B, веса bf16 {n_param * 2 / 2**30:.2f} ГиБ, " | |
| f"градиенты — только текущего блока", flush=True) | |
| master = None | |
| if args.master_dtype == "fp32": | |
| # до создания оптимизатора: Adam должен видеть fp32-параметры, а не bf16 | |
| master = MasterWeights(tr, args.master_device) | |
| master.init(tr) | |
| extra = f"+{n_param * 4 / 2**30:.2f} ГиБ на {args.master_device}" | |
| print(f"мастер-веса fp32 ({extra}), forward/backward остаются bf16", flush=True) | |
| else: | |
| print("мастер-веса bf16: шаг оптимизатора идёт прямо по bf16-весам", flush=True) | |
| set_block_checkpointing(tr, True) | |
| if args.upd == "block": | |
| if args.accum != 1: | |
| raise SystemExit("--upd block требует --accum 1: обновление идёт внутри backward " | |
| "и не может ждать следующего микро-батча") | |
| updater, opt = BlockUpdater(tr, args.lr, args.clip, master), None | |
| print(f"оптимизатор: block-wise PagedAdamW8bit ({len(updater.groups)} групп, " | |
| f"градиенты живут только на один блок)", flush=True) | |
| else: | |
| import bitsandbytes as bnb | |
| updater = None | |
| targets = master.parameters() if master is not None else params | |
| opt = bnb.optim.PagedAdamW8bit(targets, lr=args.lr, betas=(0.9, 0.95), eps=1e-8) | |
| print("оптимизатор: общий PagedAdamW8bit " | |
| f"({'fp32-мастера' if master is not None else 'bf16-веса'})", flush=True) | |
| gen = torch.Generator().manual_seed(args.seed) | |
| buckets = Buckets(rows, gen) | |
| if args.sigma == "logit": # печатаем, что именно получилось на бумаге | |
| probe = torch.sigmoid(torch.randn(200000) * args.sigma_scale + args.sigma_shift) | |
| print(f"sigma: logit-normal m={args.sigma_shift:+.2f} s={args.sigma_scale:.2f}, " | |
| f"медиана {probe.median():.2f}, доля шагов sigma<0.2: {(probe < 0.2).float().mean():.0%}, " | |
| f"sigma>0.8: {(probe > 0.8).float().mean():.0%}", flush=True) | |
| else: | |
| print("sigma: uniform U(0,1), как в обучении базы", flush=True) | |
| # «эпоха» здесь — один проход по выбранным строкам: перемешивание идёт внутри размера, | |
| # поэтому порядок не строгий, но среднее число просмотров на строку верное | |
| step_rows = args.batch_size * args.accum | |
| new_steps = args.steps or max(1, math.ceil(args.epochs * len(rows) / step_rows)) | |
| start_step = args.start_step # уже пройденные шаги: lr продолжается, а не стартует заново | |
| steps = new_steps + start_step | |
| min_lr = args.lr / 10 | |
| print(f"шагов: {new_steps}" + (f" ({args.epochs:g} эпох x {len(rows)} строк / {step_rows})" | |
| if not args.steps else " (--steps)") | |
| + (f", продолжаю с {start_step} -> до {steps}" if start_step else "") | |
| + f", lr {args.lr:.1e} -> {min_lr:.1e}, warmup {args.warmup}", flush=True) | |
| log_path = os.path.join(args.out, "log.csv") | |
| with open(log_path, "a" if start_step else "w") as log: | |
| if not start_step: | |
| log.write("step,loss_ema,loss_raw,lr,sec,peak_gib,eta_h\n") | |
| def lr_at(step): | |
| """Прогрев lr/10 -> lr, затем cosine lr -> lr/10. Постоянного lr нет.""" | |
| if args.warmup > 0 and step < args.warmup: | |
| return min_lr + (args.lr - min_lr) * (step + 1) / args.warmup | |
| progress = min(1.0, max(0.0, (step - args.warmup) / max(1, steps - args.warmup))) | |
| return min_lr + (args.lr - min_lr) * 0.5 * (1 + math.cos(math.pi * progress)) | |
| ema = None # сглаженный лосс: по сырым значениям ничего не видно (sigma у каждого шага своя) | |
| sec_sum = 0.0 # сумма времени шагов — для ETA по фактическому среднему | |
| t_start = time.time() | |
| t_last_sample = time.time() | |
| sample_n = 0 # в имя файла идёт номер сэмпла, а не шаг: тогда старые видно и можно удалять | |
| torch.cuda.reset_peak_memory_stats() | |
| if args.sample_every: # «до обучения» — чтобы было с чем сравнивать | |
| sample_grid(tr, sample_rows, ds, args, | |
| os.path.join(args.out, "progress_00000.png"), empty, live) | |
| sample_n += 1 | |
| for step in range(start_step + 1, steps + 1): | |
| lr = lr_at(step - 1) | |
| if updater is not None: | |
| updater.set_lr(lr) | |
| else: | |
| for group in opt.param_groups: | |
| group["lr"] = lr | |
| t0 = time.time() | |
| total = 0.0 | |
| for _ in range(args.accum): | |
| batch = buckets.batch(args.batch_size) | |
| lat, cond, mask = to_batch(batch, ds, device) | |
| if live is not None: | |
| texts = [read_row(ds, r)["text"] for r in batch] | |
| dropped = False | |
| # два независимых броска: 1) пустой промпт (CFG-ветка), 2) одно случайное слово | |
| if dropout and torch.rand(1, generator=gen).item() < dropout: | |
| texts, dropped = [""] * len(batch), True | |
| elif word_dropout and torch.rand(1, generator=gen).item() < word_dropout: | |
| i = int(torch.randint(0, len(batch), (1,), generator=gen)) | |
| texts[i] = drop_word(texts[i], gen, random_mod) | |
| dropped = True | |
| if dropped or cond is None: # без дропаута условие из датасета не трогаем | |
| cond, mask = live.encode_batch(texts) | |
| cond, mask = cond.to(device), mask.to(device) | |
| live.release() # энкодер с карты: память нужна шагу DiT | |
| del texts, dropped | |
| noise = torch.randn(lat.shape, dtype=torch.float32, device=device) | |
| if args.sigma == "logit": # больше шагов там, где модель доводит детали (низкие sigma) | |
| sigma = torch.sigmoid(torch.randn(len(batch), 1, 1, 1, device=device) | |
| * args.sigma_scale + args.sigma_shift) | |
| else: | |
| sigma = torch.rand(len(batch), 1, 1, 1, device=device) | |
| x0 = lat.float() | |
| xt = ((1 - sigma) * x0 + sigma * noise).to(torch.bfloat16) | |
| target = noise - x0 | |
| pred = tr(xt, sigma.flatten() * 1000.0, cond, mask).sample.float() | |
| loss = F.mse_loss(pred, target) / args.accum | |
| loss.backward() | |
| total += loss.item() | |
| del lat, cond, mask, xt, target, pred, loss | |
| if updater is not None: | |
| updater.finish() # в норме это no-op: все группы уже обновлены хуками | |
| else: | |
| if master is not None: | |
| master.grads() # bf16-градиенты -> fp32-градиенты мастеров | |
| params_to_clip = master.parameters() | |
| else: | |
| params_to_clip = params # параметры в --init не попали в master.slots | |
| if args.clip > 0: | |
| torch.nn.utils.clip_grad_norm_(params_to_clip, args.clip) | |
| opt.step() | |
| opt.zero_grad(set_to_none=True) | |
| if master is not None: | |
| master.sync() # fp32-мастер -> bf16-веса для следующего forward | |
| step_sec = time.time() - t0 | |
| sec_sum += step_sec # средняя по шагам, без сохранений чекпойнтов и сэмплов | |
| peak = torch.cuda.max_memory_allocated() / 2**30 | |
| raw = total / args.accum | |
| ema = raw if ema is None or args.ema <= 0 else args.ema * ema + (1 - args.ema) * raw | |
| if step % args.log_every == 0 or step == 1: | |
| done = step - start_step | |
| eta = sec_sum / max(done, 1) * (steps - step) # среднее по шагам ЭТОГО прогона | |
| eta_h = eta / 3600 | |
| print(f"шаг {step}/{steps} loss {ema:.4f} (raw {raw:.3f}) lr {lr:.1e} " | |
| f"{step_sec:.1f} c/шаг пик {peak:.2f} ГиБ " | |
| f"осталось {int(eta_h)}ч{int(eta % 3600 // 60):02d}м", flush=True) | |
| with open(log_path, "a") as log: | |
| log.write(f"{step},{ema:.6f},{raw:.6f},{lr:.2e},{step_sec:.2f},{peak:.2f}," | |
| f"{eta_h:.2f}\n") | |
| if args.save_every and step % args.save_every == 0: | |
| path = os.path.join(args.out, f"step_{step:05d}") | |
| tr.save_pretrained(path) | |
| print(f"чекпойнт -> {path}", flush=True) | |
| old = sorted((d for d in os.listdir(args.out) if d.startswith("step_")), | |
| key=lambda d: os.path.getmtime(os.path.join(args.out, d))) | |
| # по времени, а не по номеру: после перезапуска с --init в папке остаются чекпойнты | |
| # прошлого прогона с большими номерами, и сортировка по номеру удаляла свежие | |
| for name in old[:-args.keep] if args.keep > 0 else []: | |
| shutil.rmtree(os.path.join(args.out, name), ignore_errors=True) | |
| print(f"старый чекпойнт удалён: {name}", flush=True) | |
| if args.sample_every and (time.time() - t_last_sample >= args.sample_every | |
| or step == steps): | |
| sample_grid(tr, sample_rows, ds, args, | |
| os.path.join(args.out, f"progress_{sample_n:05d}.png"), empty, live) | |
| sample_n += 1 | |
| t_last_sample = time.time() | |
| prune_samples(args.out, args.sample_keep) | |
| tr.save_pretrained(os.path.join(args.out, "final")) | |
| peak = torch.cuda.max_memory_allocated() / 2**30 | |
| print(f"готово: {steps} шагов за {time.time() - t_start:.0f} c, пик VRAM {peak:.2f} ГиБ, " | |
| f"чекпойнт {os.path.join(args.out, 'final')}, лог {log_path}", flush=True) | |
| if peak > 15.5: | |
| print("ВНИМАНИЕ: пик близко к 16 ГиБ — на другой карте шаг может не влезть", flush=True) | |
| if __name__ == "__main__": | |
| main() | |