Четыре видеокарты: DDP и FSDP на практике
Один скрипт обучения GPT на 1, 2 и 4 картах через torchrun — DDP и FSDP2, накопление градиентов, перевычисление активаций, распределённые чекпоинты, — с доказательством, что кривая loss совпадает с одной картой, замером масштабирования по PCIe и разбором типичных зависаний.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Теорию распределённого обучения вы прошли в уроке 5.4.3: параллелизм по данным, all-reduce, ZeRO и FSDP, пузыри конвейера, DualPipe. Там же, в уроке 5.6.1 и уроке 5.6.2 вы уже запускали DDP «по образцу». Этот урок — инженерный: один скрипт, который честно меряет, что дают 1, 2 и 4 карты вашей машины, и при этом доказуемо правильный.
| Было в 5.4.3 и 5.6 | Добавляем здесь |
|---|---|
DDP и FSDP2 в виде отличий от train.py | полный скрипт: DDP и FSDP2, накопление, смешанная точность, перевычисление активаций |
| «свой seed на каждой карте» | глобальный батч, одинаковый при любом числе карт, и тест: кривые loss совпадают |
torch.save на ранге 0 | распределённые чекпоинты FSDP, продолжение обучения без потери шага, экспорт полных весов |
| формула времени all-reduce | модель «счёт против обмена», трасса профайлера с перекрытием, таблица и графики масштабирования |
| — | топология PCIe, типичные зависания и их отладка |
В конце урока у вас будет готовый инструмент, чтобы ускорить претрейн из модуля 5.6 и дообучение на ступени 6.
Как torchrun запускает обучение
Заголовок раздела «Как torchrun запускает обучение»torchruntorchrunEN torchrunУтилита PyTorch для запуска распределённого обучения: стартует нужное число процессов и передаёт им RANK, LOCAL_RANK, WORLD_SIZE, MASTER_ADDR и MASTER_PORT через переменные окружения. Пример: torchrun --standalone --nproc_per_node=4 train.py. — это запускалка. Команда torchrun --standalone --nproc_per_node=4 train_dist.py стартует четыре
независимых процесса Python с одним и тем же скриптом. Различаются они только переменными окружения:
| Переменная | Значение у процессов | Зачем |
|---|---|---|
RANK | 0, 1, 2, 3 | рангранг процессаEN rankНомер процесса в распределённом запуске, от 0 до world_size − 1. LOCAL_RANK — номер на своей машине, по нему выбирают GPU. Ранг 0 обычно печатает логи и сохраняет чекпоинты. — номер среди всех процессов (на всех машинах) |
LOCAL_RANK | 0, 1, 2, 3 | номер на этой машине — по нему выбирают карту cuda:LOCAL_RANK |
WORLD_SIZE | 4 | сколько процессов всего |
MASTER_ADDR, MASTER_PORT | один адрес на всех | где процессы находят друг друга |
--standalone означает «одна машина, свободный порт выбери сам». Без него при двух одновременных запусках второй упадёт
с Address already in use. Чтобы занять две карты из четырёх, добавьте CUDA_VISIBLE_DEVICES=0,1 и --nproc_per_node=2.
Внутри скрипта dist.init_process_group("nccl") делает три вещи: процессы встречаются по MASTER_ADDR:MASTER_PORT,
договариваются о составе группы, и библиотека NCCLNCCLEN NCCL (NVIDIA Collective Communications Library)Библиотека NVIDIA для коллективных операций между GPU (all-reduce, all-gather, reduce-scatter): сама выбирает путь — NVLink, PCIe напрямую (P2P), через память CPU или по сети. Бэкенд torch.distributed для видеокарт. строит маршруты обмена между картами — кольца и деревья
для all-reduceall-reduce (суммирование со всех на все)EN all-reduceКоллективная операция — каждый GPU отдаёт свой тензор и получает сумму (или среднее) тензоров всех GPU. Так DDP усредняет градиенты., all-gatherall-gatherEN all-gatherКоллективная операция: каждый GPU отдаёт свой кусок тензора и получает склейку кусков всех GPU. FSDP собирает так веса слоя перед вычислением. и reduce-scatterreduce-scatterEN reduce-scatterКоллективная операция: тензоры всех GPU суммируются, и каждый GPU получает только свой кусок суммы. Вместе с all-gather даёт all-reduce; на этой паре построены ZeRO и FSDP.. Каждый процесс обязан до этого
вызвать torch.cuda.set_device(LOCAL_RANK). Иначе все четыре процесса создадут свои тензоры на cuda:0: нулевая карта
переполнится, остальные будут простаивать.
flowchart TB TR["torchrun --nproc_per_node=4"] TR --> P0["процесс RANK=0<br/>cuda:0"] TR --> P1["процесс RANK=1<br/>cuda:1"] TR --> P2["процесс RANK=2<br/>cuda:2"] TR --> P3["процесс RANK=3<br/>cuda:3"] P0 -.->|"NCCL"| P1 P1 -.->|"NCCL"| P2 P2 -.->|"NCCL"| P3 P3 -.->|"NCCL"| P0
Ваша топология: PCIe без NVLink
Заголовок раздела «Ваша топология: PCIe без NVLink»Команда nvidia-smi topo -m печатает матрицу «как карта связана с картой ». Расшифровка обозначений из её легенды:
| Обозначение | Путь между картами | Скорость |
|---|---|---|
NV# | # линий NVLink напрямую | сотни ГБ/с — у вас не встретится |
PIX, PXB | через один или несколько мостов PCIe, мимо процессора | быстрее всего для PCIe |
PHB | через корневой мост PCIe процессора | |
NODE | между корневыми мостами внутри одного NUMA-узла процессора | |
SYS | между NUMA-узлами (сокетами) процессора | медленнее всего |
На вашей машине ждите PHB, NODE или SYS. Вторая команда, nvidia-smi topo -p2p r, показывает, разрешён ли прямой обмен
P2P (карта читает память соседней по PCIe напрямую). У GeForce он обычно запрещён драйвером (NS — not supported), и тогда
NCCL гоняет данные через оперативную память процессора: карта → RAM → карта. Теоретический предел PCIe 4.0 x16 — около 32 ГБ/с
в одну сторону («Ваша лаборатория»). Реальную шинную скорость all-reduce вы измеряли в уроке 5.4.3;
дальше она обозначена , а в примерах подставлено условное 10 ГБ/с.
Данные: каждому рангу — своё
Заголовок раздела «Данные: каждому рангу — своё»Это место, где чаще всего ошибаются. В уроке 5.6.2 каждая карта брала случайные окна со своим seed 42 + rank. Это правильно,
но с двумя недостатками: результат нельзя сравнить с одной картой шаг в шаг, а забыть + rank очень легко — и тогда все
четыре карты учатся на одних и тех же окнах. Токенов в секунду вчетверо больше, а пользы — как от одной карты.
Наш вариант: глобальный батч шага генерируется одинаково на всех рангах (генератор зависит только от номера шага), и каждый ранг берёт свой кусок:
g = torch.Generator().manual_seed(SEED * 100_003 + step) # один и тот же на всех рангахix = torch.randint(len(data) - T - 1, (GLOBAL,), generator=g) # GLOBAL = MICRO · ACCUM · WORLD оконpart = (micro * world + rank) * MICRO # кусок ранга rank на микрошаге microix = ix[part : part + MICRO]При 4 картах без накопления ранг 0 берёт окна 0–15 глобального батча, ранг 1 — 16–31 и так далее. На одной карте с ACCUM=4
те же 64 окна набираются за четыре микрошага. Градиенты в обоих случаях — среднее по одним и тем же 64 окнам, поэтому кривые
loss совпадают (урок 4.6.3: накопление эквивалентно большому батчу, урок 5.4.3: all-reduce тоже). А в начале обучения скрипт
собирает индексы со всех рангов через all_gather и падает, если они одинаковые, — защита от ошибки с seed.
Скрипт train_dist.py
Заголовок раздела «Скрипт train_dist.py»Один файл на все случаи: одна карта, DDP или FSDP2 на 2 и 4 картах. Положите его рядом с model.py и input.txt из
урока 4.6.2. Размер модели выбирается переменной SIZE:
baby (10.8M), gpt2 (около 85M, как GPT-2 small) и big (около 1.2B — для FSDP).
"""train_dist.py — GPT из урока 4.6 на 1, 2 или 4 GPU: DDP или FSDP2, скорость, память, чекпоинты.
python train_dist.py # одна карта (или CPU)torchrun --standalone --nproc_per_node=4 train_dist.py # DDP на 4 картахMODE=fsdp torchrun --standalone --nproc_per_node=4 train_dist.py # FSDP2 на 4 картахПеременные окружения: MODE=ddp|fsdp, SIZE=tiny|baby|gpt2|big, STEPS, MICRO (окон на карту за микрошаг),ACCUM (шагов накопления), ACT_CKPT=1, BUCKET_MB, SAVE_EVERY, RESUME=1, TAG. Рядом — model.py и input.txt."""import contextlibimport jsonimport osimport time
import torchimport torch.distributed as dist
from model import GPT, GPTConfig
# ── 1. Процессы и устройства ────────────────────────────────────────────────distributed = "RANK" in os.environ # torchrun выставляет RANK, LOCAL_RANK, WORLD_SIZEuse_cuda = torch.cuda.is_available()if distributed: dist.init_process_group("nccl" if use_cuda else "gloo") rank, world = dist.get_rank(), dist.get_world_size() local_rank = int(os.environ["LOCAL_RANK"])else: rank, world, local_rank = 0, 1, 0device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")if use_cuda: torch.cuda.set_device(device) # без этого все процессы полезут на cuda:0master = rank == 0
def log(*args): if master: # печатает только ранг 0, иначе вывод ×4 print(*args, flush=True)
# ── 2. Настройки ────────────────────────────────────────────────────────────MODE = os.environ.get("MODE", "ddp")SIZE = os.environ.get("SIZE", "baby" if use_cuda else "tiny")STEPS = int(os.environ.get("STEPS", 300))MICRO = int(os.environ.get("MICRO", 16)) # окон на одну карту за один микрошагACCUM = int(os.environ.get("ACCUM", 1)) # микрошагов на один шаг оптимизатораSAVE_EVERY = int(os.environ.get("SAVE_EVERY", 0)) # 0 — не сохранять чекпоинтыSEED = 1337TAG = os.environ.get("TAG", f"{MODE}_{SIZE}_w{world}")sizes = { "tiny": dict(n_layer=2, n_head=2, n_embd=64, block_size=64), "baby": dict(n_layer=6, n_head=6, n_embd=384, block_size=256), # 10.8M, урок 4.6.2 "gpt2": dict(n_layer=12, n_head=12, n_embd=768, block_size=256), # ≈ 85M "big": dict(n_layer=24, n_head=16, n_embd=2048, block_size=256), # ≈ 1.2B — для FSDP}
# ── 3. Данные: символы из input.txt (урок 4.6.2) ────────────────────────────RU = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"EN = "abcdefghijklmnopqrstuvwxyz"ALLOWED = set(RU + RU.upper() + EN + EN.upper() + "0123456789" + " \n.,:;!?-—«»()")text = "".join(ch for ch in open("input.txt", encoding="utf-8").read() if ch in ALLOWED)chars = sorted(set(text))stoi = {ch: i for i, ch in enumerate(chars)}data = torch.tensor([stoi[c] for c in text], dtype=torch.long).to(device) # корпус целиком на картеcfg = GPTConfig(vocab_size=len(chars), dropout=0.0, **sizes[SIZE])T = cfg.block_sizeGLOBAL = MICRO * ACCUM * world # эффективный батч в окнах (урок 4.6.3)
def get_batch(step, micro): """Глобальный батч шага одинаков на всех рангах (общий seed), каждый ранг берёт СВОЙ кусок.""" g = torch.Generator().manual_seed(SEED * 100_003 + step) ix = torch.randint(len(data) - T - 1, (GLOBAL,), generator=g) part = (micro * world + rank) * MICRO # кусок ранга rank на микрошаге micro ix = ix[part : part + MICRO].to(device) chunk = data[ix[:, None] + torch.arange(T + 1, device=device)] # (MICRO, T + 1) одним обращением return chunk[:, :-1].contiguous(), chunk[:, 1:].contiguous(), ix # x и y — окно со сдвигом на 1
# ── 4. Модель: DDP или FSDP2 ────────────────────────────────────────────────torch.manual_seed(SEED) # одинаковые веса на всех рангахmodel = GPT(cfg).to(device)n_params = sum(p.numel() for p in model.parameters())if os.environ.get("ACT_CKPT") == "1": # перевычисление активаций (урок 5.4.3) from torch.utils.checkpoint import checkpoint for blk in model.blocks: blk.forward = (lambda f: lambda x: checkpoint(f, x, use_reentrant=False))(blk.forward)amp = torch.autocast(device_type="cuda", dtype=torch.bfloat16) if use_cuda else contextlib.nullcontext()if distributed and MODE == "ddp": from torch.nn.parallel import DistributedDataParallel as DDP model = DDP(model, device_ids=[local_rank] if use_cuda else None, bucket_cap_mb=int(os.environ.get("BUCKET_MB", 25)), # размер «корзины» градиентов gradient_as_bucket_view=True) # градиенты прямо в буферах корзин: меньше копийelif distributed and MODE == "fsdp": from torch.distributed.fsdp import MixedPrecisionPolicy, fully_shard mp = MixedPrecisionPolicy(param_dtype=torch.bfloat16, reduce_dtype=torch.float32) if use_cuda else None kw = {"mp_policy": mp} if mp else {} for blk in model.blocks: fully_shard(blk, **kw) # каждый блок — своя единица шардирования fully_shard(model, **kw) # остальное: эмбеддинги, ln_f, lm_head amp = contextlib.nullcontext() # точность задаёт mp_policy, autocast не нуженraw = model.module if MODE == "ddp" and distributed else modelopt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.1)log(f"{TAG}: {n_params / 1e6:.1f}M параметров, {world} проц., глобальный батч {GLOBAL} × {T} токенов")
# ── 5. Чекпоинты ────────────────────────────────────────────────────────────ckpt_dir = f"ckpt_{TAG}"
def save(step): if MODE == "fsdp" and distributed: # у каждого ранга только свой шард import torch.distributed.checkpoint as dcp from torch.distributed.checkpoint.state_dict import get_state_dict msd, osd = get_state_dict(model, opt) dcp.save({"model": msd, "opt": osd}, checkpoint_id=f"{ckpt_dir}/step{step}") # пишут все ранги elif master: # DDP: копии одинаковые, пишет один ранг os.makedirs(ckpt_dir, exist_ok=True) torch.save({"model": raw.state_dict(), "opt": opt.state_dict()}, f"{ckpt_dir}/step{step}.pt") if distributed: dist.barrier() # никто не идёт дальше, пока чекпоинт не записан
def load(step): if MODE == "fsdp" and distributed: import torch.distributed.checkpoint as dcp from torch.distributed.checkpoint.state_dict import get_state_dict, set_state_dict msd, osd = get_state_dict(model, opt) state = {"model": msd, "opt": osd} dcp.load(state, checkpoint_id=f"{ckpt_dir}/step{step}") set_state_dict(model, opt, model_state_dict=state["model"], optim_state_dict=state["opt"]) else: sd = torch.load(f"{ckpt_dir}/step{step}.pt", map_location=device) raw.load_state_dict(sd["model"]) opt.load_state_dict(sd["opt"])
start = 0if os.environ.get("RESUME") == "1": start = max(int(f.split("step")[1].split(".")[0]) for f in os.listdir(ckpt_dir)) load(start) log(f"продолжаем с шага {start}")
# ── 6. Обучение ─────────────────────────────────────────────────────────────prof = Noneif os.environ.get("PROFILE") == "1": # трасса шагов 8–10 каждого ранга (урок 5.7.1) from torch.profiler import ProfilerActivity, profile, schedule acts = [ProfilerActivity.CPU] + ([ProfilerActivity.CUDA] if use_cuda else []) prof = profile(activities=acts, schedule=schedule(wait=6, warmup=2, active=3)) prof.start()losses, t_start = [], Nonefor step in range(start, STEPS): if step == start + 5: # первые шаги — прогрев: компиляция, выделение памяти if use_cuda: torch.cuda.synchronize() torch.cuda.reset_peak_memory_stats() t_start = time.perf_counter() loss_sum = torch.zeros((), device=device) for micro in range(ACCUM): last = micro == ACCUM - 1 if MODE == "ddp" and distributed: # all-reduce только на последнем микрошаге sync = contextlib.nullcontext() if last else model.no_sync() else: sync = contextlib.nullcontext() if MODE == "fsdp" and distributed: model.set_requires_gradient_sync(last) xb, yb, ix = get_batch(step, micro) if step == start and micro == 0 and distributed: # проверка: у рангов разные окна? all_ix = [torch.zeros_like(ix) for _ in range(world)] dist.all_gather(all_ix, ix) assert len(set(torch.cat(all_ix).tolist())) > MICRO, "все ранги получили одни и те же данные!" with sync: # и прямой, и обратный проход — внутри with amp: _, loss = model(xb, yb) (loss / ACCUM).backward() loss_sum += loss.detach() / ACCUM torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() opt.zero_grad(set_to_none=True) if distributed: # средний loss по всем рангам — для лога dist.all_reduce(loss_sum) loss_sum /= world losses.append(loss_sum) # .item() на каждом шаге заставил бы CPU ждать GPU if step % 50 == 0 or step == STEPS - 1: log(f"шаг {step:4d} loss {loss_sum.item():.4f}") if SAVE_EVERY and (step + 1) % SAVE_EVERY == 0: save(step + 1) if prof is not None: prof.step()if prof is not None: prof.stop() prof.export_chrome_trace(f"trace_{TAG}_rank{rank}.json")
# ── 7. Итоги: скорость, память, кривая loss ─────────────────────────────────if use_cuda: torch.cuda.synchronize()n_timed = STEPS - start - 5if t_start is not None and n_timed > 0: dt = (time.perf_counter() - t_start) / n_timed mem = torch.cuda.max_memory_allocated() / 2**30 if use_cuda else 0.0 if distributed: # память — максимум по рангам m = torch.tensor([mem], device=device) dist.all_reduce(m, op=dist.ReduceOp.MAX) mem = m.item() tok_s = round(GLOBAL * T / dt) res = dict(tag=TAG, mode=MODE, size=SIZE, world=world, accum=ACCUM, micro=MICRO, step_ms=round(dt * 1000, 2), tok_s=tok_s, mem_gib=round(mem, 3), losses=[round(x.item(), 6) for x in losses]) log(f"{TAG}: шаг {dt * 1000:.1f} мс, {tok_s} токенов/с, пик памяти {mem:.2f} ГиБ") if master: with open("results.jsonl", "a") as f: f.write(json.dumps(res) + "\n")# полные веса одним файлом — для генерации обычной GPT на одной карте (урок 4.6.2)if MODE == "fsdp" and distributed: from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict full = get_model_state_dict(model, options=StateDictOptions(full_state_dict=True, cpu_offload=True))else: full = raw.state_dict()if master: # все ранги участвуют в сборке, пишет один torch.save({"model": full, "cfg": cfg, "chars": chars}, f"gpt_{TAG}.pt")if distributed: dist.destroy_process_group()Разберём то, чего не было в прошлых уроках.
DDP. Модель оборачивается в DistributedDataParallel; при создании он рассылает веса ранга 0 всем остальным. Градиенты
собираются в «корзины» по bucket_cap_mb мегабайт, и all-reduce готовой корзины стартует, пока обратный проход ещё считает
следующие слои (урок 5.4.3). gradient_as_bucket_view=True кладёт градиенты прямо в буферы корзин — минус одна копия и немного
памяти.
FSDP2. fully_shard применяется к каждому блоку, затем к корню: перед вычислением блока его веса собираются со всех
карт (all-gather) и после освобождаются, градиенты расходятся по владельцам (reduce-scatter). MixedPrecisionPolicy задаёт
точность: собранные веса — в BF16 (вдвое меньше байт по PCIe и считаем на тензорных ядрах), усреднение градиентов — во FP32,
сами шарды весов и состояние AdamW — во FP32. Поэтому autocast здесь не нужен.
Накопление. На всех микрошагах, кроме последнего, обмен выключен: у DDP — контекст no_sync() (он должен охватывать
и прямой проход: решение о синхронизации DDP принимает в forward), у FSDP2 — set_requires_gradient_sync(False). Один
all-reduce на шаг оптимизатора вместо ACCUM штук.
Остальное. clip_grad_norm_ работает и с DDP (градиенты уже одинаковые), и с FSDP2 (параметры — распределённые
тензоры, норма считается по всем шардам). Loss для лога усредняется по рангам одним all-reduce, а .item() вызывается только
при печати: на каждом шаге он заставил бы процессор ждать видеокарту. ACT_CKPT=1 включает
перевычисление активацийперевычисление активаций (activation checkpointing)EN activation checkpointing (recomputation)Не хранить часть активаций прямого прохода, а пересчитать их при обратном. Экономит память ценой дополнительных вычислений. для каждого блока. Обратите внимание на .contiguous() в get_batch: срез
chunk[:, 1:] не лежит в памяти подряд, и view внутри model.py упал бы с ошибкой «view size is not compatible».
Чекпоинты. У DDP копии одинаковые, и файл пишет только ранг 0, а dist.barrier() не даёт остальным уйти вперёд. У FSDP
полной модели нет ни у кого, поэтому сохраняет распределённый чекпоинтраспределённый чекпоинт (DCP)EN distributed checkpoint (torch.distributed.checkpoint)Сохранение состояния модели и оптимизатора, при котором каждый ранг пишет свой шард параллельно. Позволяет загрузить чекпоинт и на другом числе GPU. Нужен для FSDP, где ни у одного ранга нет полной модели.: каждый ранг
параллельно пишет свой шард (файлы __0_0.distcp, __1_0.distcp, …) и общий файл метаданных. Такой чекпоинт можно
загрузить и на другом числе карт. RESUME=1 продолжает с последнего сохранения, а в конце скрипт собирает полные веса
одним файлом gpt_<тег>.pt — его загрузит обычная GPT из урока 4.6.2 для генерации.
Проверка: кривые loss совпадают
Заголовок раздела «Проверка: кривые loss совпадают»Прежде чем мерить скорость, убедимся, что распределённое обучение считает то же самое. Запустим на процессоре (бэкенд gloo
вместо nccl, крошечная модель SIZE=tiny) пять вариантов с одинаковым глобальным батчем в 64 окна и сравним кривые
отчётом report.py (код — в блоке «На вашей машине» ниже). На машине с видеокартами добавьте к командам CUDA_VISIBLE_DEVICES="",
чтобы скрипт ушёл на процессор:
STEPS=30 ACCUM=4 TAG=ref_acc4 python train_dist.py # 1 процесс, накопление 4STEPS=30 TAG=ddp_tiny_w4 torchrun --standalone --nproc_per_node=4 train_dist.py # DDP, 4 процессаSTEPS=30 MODE=fsdp TAG=fsdp_tiny_w4 torchrun --standalone --nproc_per_node=4 train_dist.pySTEPS=30 ACCUM=2 MODE=fsdp ACT_CKPT=1 TAG=fsdp_ckpt_w2 torchrun --standalone --nproc_per_node=2 train_dist.pypython report.pyddp_tiny_w4 против ref_acc4 (батч 64): max |Δloss| = 1.0e-06fsdp_ckpt_w2 против ref_acc4 (батч 64): max |Δloss| = 1.0e-06fsdp_tiny_w4 против ref_acc4 (батч 64): max |Δloss| = 1.0e-06Расхождение — это округление в логе: все варианты идут по одной кривой. Так же проверено продолжение после
чекпоинта: 20 шагов с SAVE_EVERY=10, затем RESUME=1 до 30-го шага — и шаги 20–29 совпали с непрерывным прогоном
с той же точностью и у DDP, и у FSDP (данные зависят только от номера шага, а состояние AdamW сохранено целиком).
На видеокартах в BF16 совпадение будет не до шестого знака: другой порядок суммирования, недетерминированные ядра и разная
точность у DDP с autocast и FSDP с MixedPrecisionPolicy. Расхождение порядка –, медленно растущее со
временем, — норма. Расхождение на 0.1 и больше уже на первых шагах — ошибка: одинаковые данные на рангах, забытое деление
на ACCUM, не тот глобальный батч.
Масштабирование и PCIe
Заголовок раздела «Масштабирование и PCIe»Когда обмен перестаёт прятаться? Для DDP с одинаковым батчем на карту за шаг оптимизатора:
- счёт одной карты: — по правилу FLOPs на токен (урок 5.1.1);
- обмен: all-reduce градиентов, секунд при картах и градиентах во FP32 (урок 5.4.3);
- обмен прячется за обратным проходом последнего микрошага (около счёта), на виду остаётся только избыток.
Поделим одно на другое — сокращается:
Читаем: «доля обмена зависит не от размера модели, а от того, сколько токенов карта обрабатывает между двумя all-reduce».
При TFLOPS и ГБ/с отношение ≈ . При батче 16 окон по 256
токенов (4096 токенов) обмен почти равен счёту, и после перекрытия эффективность масштабированияэффективность масштабированияEN scaling efficiencyСкорость на N GPU, делённая на N скоростей одной GPU. 100% — идеальное ускорение в N раз; потери дают коммуникации, не спрятанные за вычислениями, и неравномерная загрузка. около 78%. С накоплением ACCUM=4 (16 384
токена на all-reduce) — около 93%. Эти числа вы получите в практике ниже.
Для FSDP есть приятная деталь. За шаг он делает два all-gather весов (в прямом и обратном проходе) и один reduce-scatter градиентов — в 1.5 раза больше пересылок, чем DDP (урок 5.4.3). Но с весами в BF16 и градиентами во FP32 байт выходит — ровно столько же, сколько у DDP во FP32. Платим только задержками: обмен идёт мелкими порциями на каждом блоке.
Как увидеть перекрытие. Запустите PROFILE=1 — каждый ранг сохранит трассу шагов 8–10. В Perfetto (урок 5.7.1) под
строкой вычислительного потока GPU появится вторая строка с ядрами NCCL (ncclDevKernel_AllReduce... у DDP, AllGather
и ReduceScatter у FSDP). Если они идут параллельно ядрам обратного прохода — обмен спрятан. Если после последнего ядра
backward тянется хвост из NCCL, а ядро шага оптимизатора ждёт его — это и есть открытый обмен, который ест эффективность.
Практика
Заголовок раздела «Практика»Перенесите эти рассуждения в код: разбиение глобального батча, DDP-градиент с накоплением (тест сверит его с градиентом по всему батчу), время all-reduce и шага с перекрытием, эффективность и трафик FSDP. Подставьте потом свою шинную скорость.
import numpy as np
# ── Готовые помощники ────────────────────────────────────────────────────────
def global_batch(step, global_size, n_data, seed=1337):
"""Индексы глобального батча шага step. Генератор зависит только от (seed, step),
поэтому на всех рангах получается один и тот же список — как в train_dist.py."""
rng = np.random.default_rng([seed, step])
return rng.integers(0, n_data, size=global_size)
def mse_grad(w, X, y):
"""Градиент средней квадратичной ошибки линейной модели X @ w по w (урок 2.2.1)."""
return 2 * X.T @ (X @ w - y) / len(y)
# ── Ваша часть ───────────────────────────────────────────────────────────────
def rank_slice(ix, rank, world, micro_idx, micro):
"""Кусок глобального батча для ранга rank на микрошаге micro_idx.
Раскладка как в train_dist.py: ix[(micro_idx · world + rank) · micro : … + micro]."""
# TODO
...
def ddp_grad(w, X, y, ix, world, accum, micro):
"""Градиент, который получит каждый ранг после DDP-шага с накоплением.
Каждый ранг на каждом микрошаге считает mse_grad на своём куске и делит на accum
(как loss / ACCUM), складывает по микрошагам; затем all-reduce: среднее по рангам.
"""
# TODO: два цикла — по рангам и по микрошагам
...
def ring_allreduce_time(nbytes, world, bus_bw):
"""Время кольцевого all-reduce: 2·(world − 1)/world · nbytes / bus_bw (урок 5.4.3). На одной карте — 0."""
# TODO
...
def ddp_step_time(t_fwd, t_bwd, grad_bytes, world, bus_bw, accum=1, overlap=True):
"""Время шага оптимизатора в DDP.
Вычисления: accum · (t_fwd + t_bwd). Обмен — один all-reduce за шаг (no_sync на остальных микрошагах).
С перекрытием all-reduce прячется за обратным проходом ПОСЛЕДНЕГО микрошага:
на виду остаётся max(0, t_comm − t_bwd). Без перекрытия — весь t_comm.
"""
# TODO
...
def scaling_efficiency(t_one, t_many):
"""Эффективность при одинаковом батче на карту: токены/с на N картах против N × (токены/с на одной).
Каждая карта обрабатывает столько же токенов, поэтому это просто t_one / t_many."""
# TODO
...
def comm_bytes(n_params, world, mode, param_bytes=2, grad_bytes=4):
"""Байт, которые уходят с одной карты за шаг.
DDP: all-reduce градиентов — 2·(N−1)/N · Ψ · grad_bytes.
FSDP: all-gather весов в прямом и в обратном проходе + reduce-scatter градиентов —
(N−1)/N · Ψ · (2·param_bytes + grad_bytes)."""
# TODO
...
if __name__ == "__main__":
ix = global_batch(0, 8, 100)
print("глобальный батч:", ix)
print("ранг 1 из 2, микрошаг 0, по 2 окна:", rank_slice(ix, 1, 2, 0, 2))
Типичные ошибки
Заголовок раздела «Типичные ошибки»| Симптом | Причина | Что делать |
|---|---|---|
Все процессы на cuda:0, OOM на нулевой карте | нет torch.cuda.set_device(LOCAL_RANK) | вызывать до создания модели и тензоров |
| 4 карты, токенов/с вчетверо больше, а loss падает как на одной | одинаковые данные на всех рангах | общий генератор + свой кусок или seed с + rank; проверка через all_gather |
| Зависание без ошибки, через 10 минут — таймаут NCCL | коллективную операцию вызвали не все ранги | не вызывать FSDP-модель только на ранге 0; одинаковое число шагов на всех рангах; печать и запись — под if rank == 0, а forward — у всех |
Address already in use при старте | порт занят другим запуском | --standalone или свой --master_port |
| Чекпоинт битый или пустой | файл пишут все ранги разом / ранг 0 вышел раньше записи | писать на ранге 0 (DDP) или через DCP (FSDP), затем dist.barrier() |
| Чекпоинт не грузится в обычную модель | префиксы module. (DDP) и _orig_mod. (torch.compile) | сохранять model.module.state_dict(); для FSDP — get_model_state_dict(..., full_state_dict=True) |
| Loss на 4 картах хуже, чем на одной, при той же длине обучения | эффективный батч вырос в 4 раза, а число шагов и lr прежние | сравнивать при одинаковом глобальном батче или пересчитать lr и число шагов |
Для отладки: NCCL_DEBUG=INFO (как NCCL построил связь, урок 5.4.3), TORCH_DISTRIBUTED_DEBUG=DETAIL (проверка, что ранги
вызывают одинаковые коллективные операции с одинаковыми размерами), dist.init_process_group(..., timeout=timedelta(minutes=2))
(падать быстрее, а не ждать 10 минут) и py-spy dump --pid <PID> — увидеть, на какой строке висит каждый процесс.
Где это пригодится
Заголовок раздела «Где это пригодится»- Претрейн из модуля 5.6. Модель L из урока 5.6.2 (80M параметров) по масштабу как наш
gpt2: с накоплением 4 микрошагов иno_syncчетыре карты дадут ускорение около 3.7 раза (оценка по модели урока): прогон на одну ночь вместо почти четырёх. - Ступень 6. LLaMA-Factory и TRL запускают SFT и DPO через тот же torchrun (
FORCE_TORCHRUN=1), а ZeRO-3 и FSDP включаются строкой конфига. Теперь вы знаете, что происходит внутри, и сможете отличить «медленно, потому что PCIe» от «медленно, потому что ошибка».
Проверь себя
Заголовок раздела «Проверь себя»- torchrun запускает процесс на каждую карту и передаёт
RANK,LOCAL_RANK,WORLD_SIZE; процесс выбирает картуset_device(LOCAL_RANK), NCCL строит маршруты по PCIe (у GeForce — часто через память процессора). - Глобальный батч, общий для всех рангов и разрезанный на куски, делает обучение на 1, 2 и 4 картах одной и той же кривой loss — это проверяемо тестом. Одинаковые данные на рангах — самая тихая и дорогая ошибка.
- DDP прячет all-reduce за обратным проходом, FSDP2 шардирует веса и состояние оптимизатора; накопление с
no_sync/set_requires_gradient_syncоставляет один обмен на шаг. Чекпоинты FSDP — через DCP, с продолжением и экспортом полных весов. - Доля обмена ≈ и от размера модели не зависит: на PCIe нужны 10–20 тысяч токенов на карту за один all-reduce. Это ваш домашний вариант задачи, которую DeepSeek решала на PCIe A100 и на H800.
Дальше: ступень 6 начинается с основ обучения с подкреплением — урок MDP, отдача и функции ценности. Четыре карты пригодятся там уже для SFT и GRPO.