Перейти к содержимому
Ступень 55.4 Эффективное обучение

Смешанная точность — FP32, BF16, FP8

Как устроены числа с плавающей точкой, почему LLM обучают в BF16 и FP8 и как DeepSeek-V3 заставила FP8 работать на масштабе 671B с помощью поблочного масштабирования.

УРОКstage-5/5-4-efficient-training/01-mixed-precision.mdx2 чУрок 1 из 4
После урока вы сможете
  • Разложить число на знак, порядок и мантиссу и по числу бит предсказать диапазон и точность FP32, FP16, BF16, FP8 и FP4
  • Объяснить рецепт смешанной точности — мастер-веса в FP32, вычисления в BF16, накопление в FP32, loss scaling для FP16
  • Реализовать FP8-квантование с общим и поблочным масштабом и измерить, как выбросы портят точность
  • Пересказать, какие решения сделали FP8-обучение DeepSeek-V3 устойчивым и куда движется индустрия (FP4)

Каждое число в нейросети где-то хранится и с чем-то перемножается. Модель на 7 миллиардов параметров в обычном формате FP32 (4 байта на число) весит 28 ГБ, в BF16 — 14 ГБ, в FP8 — 7 ГБ. Скорость тоже зависит от формата: тензорные ядра GPU Hopper (H100/H800) — специальные блоки для умножения матриц — умножают их в FP8 примерно вдвое быстрее, чем в BF16, а в BF16 — во много раз быстрее, чем в FP32. Меньше бит — меньше памяти, меньше трафика, больше операций в секунду.

Платить приходится точностью. Слишком грубые числа превращают маленькие градиенты в ноль, а большие активации — в бесконечность, и обучение разваливается. смешанная точностьсмешанная точностьEN mixed precision — набор приёмов, который позволяет считать в низкой точности там, где можно, и держать высокую там, где нужно. Это первый рычаг эффективности, о котором вы читали в уроке china/02. DeepSeek-V3 первой из открытых моделей такого масштаба провела почти все матричные умножения в 8-битном формате. Как именно — главный сюжет урока.

число с плавающей точкойчисло с плавающей точкой (float)EN floating-point numberСпособ хранить дробные числа как «мантисса × 2 в степени». Форматы: FP32 (4 байта), FP16 и BF16 (2 байта), FP8 (1 байт). хранится в двоичной «научной записи», как 6.02⋅10236.02 \cdot 10^{23}, только по основанию 2:

x=(−1)s⋅(1+m2M)⋅2 e−biasx = (-1)^{s} \cdot \left(1 + \frac{m}{2^{M}}\right) \cdot 2^{\,e - \text{bias}}

Что здесь написано:

  • ss — знак, один бит;
  • ee — порядок (exponent), EE бит. Он отвечает за масштаб: сколько «нулей» у числа. Больше бит порядка — шире диапазон;
  • mm — мантисса, MM бит. Она отвечает за точность: сколько значащих цифр. Между 2e2^e и 2e+12^{e+1} помещается ровно 2M2^M ступенек;
  • bias — сдвиг, чтобы порядок мог быть отрицательным.

Пример: 0.3 в FP8 E4M3 (4 бита порядка, 3 бита мантиссы). Запишем 0.3=1.2⋅2−20.3 = 1.2 \cdot 2^{-2}. Мантисса из 3 бит даёт 8 ступенек между 1 и 2 с шагом 1/81/8: 1, 1.125, 1.25, … Ближайшая к 1.2 — это 1.25=1+2/81.25 = 1 + 2/8. Получаем 1.25⋅2−2=0.31251.25 \cdot 2^{-2} = 0.3125: ошибка 4%. В BF16 (7 бит мантиссы, шаг 1/1281/128) то же число станет 0.30078, ошибка 0.26%.

Главное правило: относительная точность одинакова на любом масштабе (в пределах так называемых нормальных чисел, о самых маленьких — ниже). Шаг между соседними числами растёт вместе с числом. Величину ε=2−M\varepsilon = 2^{-M} (расстояние от 1 до следующего числа) называют машинным эпсилон.

ФорматБиты: знак / порядок / мантиссаМаксимумε\varepsilon (точность)Наименьшее нормальноеГде используется
FP321 / 8 / 233.4⋅10383.4 \cdot 10^{38}1.2⋅10−71.2 \cdot 10^{-7}1.2⋅10−381.2 \cdot 10^{-38}мастер-веса, оптимизатор, накопление сумм
FP161 / 5 / 1065 5049.8⋅10−49.8 \cdot 10^{-4}6.1⋅10−56.1 \cdot 10^{-5}обучение до 2020 года, инференс
BF161 / 8 / 73.4⋅10383.4 \cdot 10^{38}7.8⋅10−37.8 \cdot 10^{-3}1.2⋅10−381.2 \cdot 10^{-38}стандарт обучения LLM
FP8 E4M31 / 4 / 34480.1250.0156веса и активации (у DeepSeek-V3 — и градиенты)
FP8 E5M21 / 5 / 257 3440.256.1⋅10−56.1 \cdot 10^{-5}градиенты в классическом FP8-рецепте
FP4 E2M11 / 2 / 160.51веса экспертов, инференс (2025–2026)

Самое интересное в таблице — пара FP16 и BF16. Оба занимают 2 байта, но делят биты по-разному. FP16 точнее, но диапазон у него узкий: всё, что больше 65 504, становится inf. BF16BF16 (bfloat16)EN bfloat16 (brain floating point)16-битный формат: 1 бит знака, 8 бит порядка (как у FP32), 7 бит мантиссы. Диапазон как у FP32, точность — 2–3 значащие цифры. Стандарт обучения LLM. (brain floating point, формат Google Brain) взял у FP32 все 8 бит порядка и пожертвовал мантиссой. Он грубее, зато «видит» те же числа от 10−3810^{-38} до 103810^{38}, что и FP32.

Проверьте всё это сами (PyTorch 2.1+, работает на CPU):

import torch
# 1. Паспорт форматов: сколько бит, максимум, точность около единицы (eps)
for name, dt in [("FP32", torch.float32), ("FP16", torch.float16), ("BF16", torch.bfloat16),
("FP8 E4M3", torch.float8_e4m3fn), ("FP8 E5M2", torch.float8_e5m2)]:
fi = torch.finfo(dt)
print(f"{name:9s} бит={fi.bits:2d} max={fi.max:<12.4g} eps={fi.eps:<10.3g} min_normal={fi.smallest_normal:.3g}")
# 2. Одно и то же число в разных форматах
x = torch.tensor(0.3)
for dt in [torch.float16, torch.bfloat16, torch.float8_e4m3fn]:
print(dt, x.to(dt).float().item())
# 3. Три беды низкой точности
print(torch.tensor(70000.0).to(torch.float16)) # переполнение FP16
print(torch.tensor(1e-8).to(torch.float16)) # исчезновение (underflow) в FP16
print(torch.tensor(1e-8).to(torch.bfloat16)) # BF16 держит
one = torch.tensor(1.0, dtype=torch.bfloat16)
print(one + 0.001, one + 0.003) # поглощение: малое прибавили к большому — и пропало
FP32 бит=32 max=3.403e+38 eps=1.19e-07 min_normal=1.18e-38
FP16 бит=16 max=6.55e+04 eps=0.000977 min_normal=6.1e-05
BF16 бит=16 max=3.39e+38 eps=0.00781 min_normal=1.18e-38
FP8 E4M3 бит= 8 max=448 eps=0.125 min_normal=0.0156
FP8 E5M2 бит= 8 max=5.734e+04 eps=0.25 min_normal=6.1e-05
torch.float16 0.300048828125
torch.bfloat16 0.30078125
torch.float8_e4m3fn 0.3125
tensor(inf, dtype=torch.float16)
tensor(0., dtype=torch.float16)
tensor(1.0012e-08, dtype=torch.bfloat16)
tensor(1., dtype=torch.bfloat16) tensor(1., dtype=torch.bfloat16)

Три беды из последнего блока — переполнение, исчезновение и поглощение — и есть всё, от чего защищает смешанная точность.

Классическая работа Micikevicius и соавторов (NVIDIA и Baidu, 2017) сформулировала рецепт, который с небольшими изменениями используют до сих пор. В нём три правила.

Правило 1. Мастер-веса в FP32. Шаг оптимизатора — это w←w−η⋅Δw \leftarrow w - \eta \cdot \Delta, и η⋅Δ\eta \cdot \Delta обычно в тысячи раз меньше ww. Вы только что видели: в BF16 1+0.001=11 + 0.001 = 1. Если хранить веса в BF16, маленькие обновления просто исчезнут, и модель перестанет учиться. Поэтому настоящие веса — мастер-весамастер-весаEN master weightsКопия весов в FP32, к которой применяется шаг оптимизатора. Для вычислений с неё делают копию в BF16/FP8. Без мастер-весов маленькие обновления «поглощаются» при округлении. — живут в FP32, а для вычислений с них делают BF16-копию.

Правило 2. Накопление сумм в FP32. Матричное умножение — это суммы из тысяч произведений. Входы могут быть грубыми, но сумма должна быть точной. Иначе случится вот что:

import torch
vals = torch.full((10_000,), 0.01).to(torch.bfloat16) # 10 000 слагаемых по 0.01, ответ ≈ 100
acc16 = torch.tensor(0.0, dtype=torch.bfloat16)
acc32 = torch.tensor(0.0)
for v in vals:
acc16 = acc16 + v # аккумулятор в BF16
acc32 = acc32 + v.float() # те же слагаемые, аккумулятор в FP32
print(f"BF16-аккумулятор: {acc16.item():.3f} FP32-аккумулятор: {acc32.item():.3f}")
BF16-аккумулятор: 4.000 FP32-аккумулятор: 100.098

Сумма в BF16 застряла на 4. Около четвёрки шаг BF16 равен 4⋅2−7=0.031254 \cdot 2^{-7} = 0.03125, половина шага больше 0.01, и каждое новое слагаемое округляется обратно к 4. Поэтому тензорные ядра принимают входы в BF16, а складывают в FP32. (Ответ 100.098, а не 100, потому что само 0.01 в BF16 хранится как 0.010009…)

Правило 3. Loss scaling для FP16. Градиенты глубоких сетей бывают порядка 10−810^{-8} и меньше. Наименьшее нормальное число FP16 — 6⋅10−56 \cdot 10^{-5} (см. таблицу). Ниже него формат ещё держит так называемые субнормальные числа, у которых меньше значащих бит, но самое маленькое из них — 2−24≈6⋅10−82^{-24} \approx 6 \cdot 10^{-8}, и всё, что меньше его половины (около 3⋅10−83 \cdot 10^{-8}), округляется в ноль. Решение — масштабирование lossмасштабирование loss (loss scaling)EN loss scalingLoss умножают на большое число S перед обратным проходом, чтобы маленькие градиенты не обнулились в FP16, а перед шагом оптимизатора делят на S в FP32. Для BF16 не нужно.: умножить loss на большое число SS перед обратным проходом. По цепному правилу все градиенты тоже умножатся на SS и поднимутся в «видимую» зону FP16. Перед шагом оптимизатора их делят на SS уже в FP32:

import torch
torch.manual_seed(0)
grads = 10 ** (torch.rand(100_000) * 8 - 10) # «градиенты» от 1e-10 до 1e-2
def through_fp16(g, scale):
"""Умножаем на scale, храним в FP16, делим обратно уже в FP32 (как делает GradScaler)."""
return (g * scale).to(torch.float16).float() / scale
print("масштаб обнулилось ошибка>1% inf")
for k in [0, 8, 16, 24]:
g16 = through_fp16(grads, 2.0**k)
zero = (g16 == 0).float().mean().item()
bad = ((g16 - grads).abs() / grads > 0.01).float().mean().item()
inf = torch.isinf(g16).float().mean().item()
print(f"2^{k:<2d} {zero:8.1%} {bad:8.1%} {inf:5.1%}")
масштаб обнулилось ошибка>1% inf
2^0 30.8% 50.5% 0.0%
2^8 0.9% 20.3% 0.0%
2^16 0.0% 0.0% 0.0%
2^24 0.0% 5.1% 5.1%

Без масштаба треть градиентов исчезает. При S=216S = 2^{16} всё в порядке, а при 2242^{24} большие градиенты уже переполняются. Подбирать SS руками неудобно, поэтому используют динамический loss scaling (torch.amp.GradScaler): начинают с 2162^{16}, при появлении inf пропускают шаг и делят SS пополам, после 2000 удачных шагов подряд — удваивают.

flowchart LR
M["Мастер-веса FP32"] -->|"копия в BF16"| W["Веса BF16"]
W --> F["Прямой проход:<br/>умножения BF16,<br/>суммы FP32"]
F --> L["Loss в FP32<br/>(для FP16: × S)"]
L --> B["Обратный проход<br/>в BF16"]
B --> G["Градиенты → FP32<br/>(для FP16: ÷ S)"]
G --> O["Шаг AdamW<br/>в FP32"]
O --> M
Цикл обучения со смешанной точностью

В PyTorch всё это делает torch.autocast. Внутри блока матричные умножения автоматически идут в BF16, а чувствительные операции (softmax, нормализации, функции потерь) PyTorch сам держит в FP32:

import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(64, 256), nn.GELU(), nn.Linear(256, 10)) # веса в FP32 — это и есть мастер-веса
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
x = torch.randn(512, 64)
y = x[:, :10].argmax(dim=1) # игрушечная задача: номер максимальной из первых 10 координат
for step in range(201):
with torch.autocast(device_type="cpu", dtype=torch.bfloat16): # на GPU: device_type="cuda"
logits = model(x) # матричные умножения идут в BF16
loss = F.cross_entropy(logits.float(), y) # вероятности и loss — в FP32
opt.zero_grad()
loss.backward() # градиенты приходят к FP32-весам
opt.step()
if step % 50 == 0:
print(f"шаг {step:3d}: loss = {loss.item():.3f}")
print("logits:", logits.dtype, "| веса:", model[0].weight.dtype, "| градиент:", model[0].weight.grad.dtype)
шаг 0: loss = 2.324
шаг 50: loss = 0.832
шаг 100: loss = 0.126
шаг 150: loss = 0.037
шаг 200: loss = 0.019
logits: torch.bfloat16 | веса: torch.float32 | градиент: torch.float32

Посчитаем байты на один параметр при обучении с AdamW (оптимизатор из урока 1.4.2):

Что хранитсяФорматБайт на параметр
Рабочая копия весовBF162
ГрадиентыBF162
Мастер-весаFP324
Первый момент Adam mmFP324
Второй момент Adam vvFP324
Итого16

Модель на 7B требует 7⋅109⋅16≈1127 \cdot 10^9 \cdot 16 \approx 112 ГБ ещё до активаций (полный расчёт для ваших 4×RTX 4080, с активациями и FSDP, — задача 1 в уроке 11.3.3: так это спрашивают на собеседованиях). Ни на одну видеокарту это не помещается, и именно отсюда растёт распределённое обучение и ZeRO из урока 5.4.3. Заметьте: веса для вычислений занимают лишь 2 байта из 16. Основную память съедает состояние оптимизатора. DeepSeek-V3 хранит моменты AdamW в BF16 и экономит на этом 4 байта на параметр.

У FP8FP8 (8-битное число с плавающей точкой)EN FP8 (8-bit floating point)Два варианта: E4M3 (точнее, максимум 448) и E5M2 (грубее, максимум 57 344). Требует масштабирования тензоров. DeepSeek-V3 обучалась в FP8 с поблочными масштабами. два варианта. E4M3 точнее (3 бита мантиссы), но его максимум всего 448. E5M2 грубее, зато доходит до 57 344. Классический рецепт NVIDIA (библиотека Transformer Engine): веса и активации в E4M3, градиенты в E5M2, потому что разброс градиентов больше.

Диапазон FP8 настолько узок, что тензор нельзя просто привести к FP8. Сначала его делят на масштаб (scale), чтобы самый большой по модулю элемент стал ровно 448, а после умножения результат умножают обратно:

s=max⁡∣x∣448,x^=s⋅FP8 ⁣(xs).s = \frac{\max \lvert x \rvert}{448}, \qquad \hat{x} = s \cdot \mathrm{FP8}\!\left(\frac{x}{s}\right).

Пример: в тензоре максимум по модулю 896, значит s=2s = 2. Элемент 0.6 превращается в 0.30.3, округляется до 0.31250.3125 и восстанавливается как 0.6250.625.

В активациях LLM встречаются выбросы (outliers): отдельные элементы в тысячи раз больше остальных. Если масштаб один на весь тензор, его задаёт выброс. Обычные числа делятся на огромное ss, проваливаются в самую грубую область FP8 или в ноль.

Числа (из задания ниже): у активаций 64×51264 \times 512 с одним выбросом относительная ошибка умножения на матрицу весов для остальных токенов такая:

ВыбросОдин масштаб на тензорТайлы 1×1281 \times 128 + блоки 128×128128 \times 128
10210^23.7%3.7%
10410^43.8%3.7%
10510^512.8%3.7%
10610^695.2%3.7%

Порядок из 4 бит спасает от умеренных выбросов, но не от огромных. Поблочный масштаб держит ошибку на уровне погрешности самого формата при любом выбросе.

В техотчёте V3 FP8 стоит на четырёх решениях. Все три основных матричных умножения линейного слоя (прямой проход, градиент по входу, градиент по весам) идут в FP8.

1. Мелкозернистое масштабирование (мелкозернистое (поблочное) квантованиемелкозернистое (поблочное) квантованиеEN fine-grained quantizationСвой масштаб у каждой маленькой группы чисел, а не у всего тензора. В DeepSeek-V3: тайлы 1×128 для активаций и блоки 128×128 для весов. Выброс портит только свою группу.). Масштаб считается не на тензор, а на маленькую группу:

  • активации — тайлами 1×1281 \times 128: один токен, 128 каналов подряд. Выброс одного токена портит только свой тайл;
  • веса — блоками 128×128128 \times 128. Квадратный блок остаётся блоком и после транспонирования, а в обратном проходе веса используются транспонированными. Поэтому одни и те же квантованные веса годятся и для прямого, и для обратного прохода.

Масштаб считается «на лету» по текущему тензору, без истории прошлых шагов. Цена — лишние числа в памяти: один FP32-масштаб на 128 байт тайла добавляет 3%, на блок 128×128128 \times 128 — 0.02%.

2. Один формат E4M3 для всего, включая градиенты. Когда масштаб свой у каждой группы из 128 чисел, разброс внутри группы невелик. Хватает диапазона E4M3, а лишний бит мантиссы даёт точность.

3. Точное накопление. Авторы обнаружили, что тензорные ядра H800 накапливают FP8-произведения примерно с 14 битами точности, а не с 32. На сумме длины K=4096K = 4096 максимальная относительная ошибка достигает почти 2%. Решение: каждые NC=128N_C = 128 элементов частичная сумма переносится в FP32-регистры на обычных CUDA-ядрах (универсальных вычислительных блоках GPU) и складывается там. Вот грубая модель этого эффекта (numpy):

import numpy as np
def keep_bits(x, bits=14):
"""Грубая модель «узкого» сумматора: оставляем bits значащих битов, остальное отбрасываем."""
if x == 0:
return 0.0
step = 2.0 ** (np.floor(np.log2(abs(x))) - bits + 1)
return float(np.trunc(x / step) * step)
def dot(a, b, promote_every=None):
total, acc = 0.0, 0.0 # total — FP32 на CUDA-ядрах, acc — аккумулятор тензорного ядра
for i, p in enumerate(a * b):
acc = keep_bits(acc + p)
if promote_every and (i + 1) % promote_every == 0:
total, acc = total + acc, 0.0 # перенос частичной суммы в высокую точность
return total + acc
rng = np.random.default_rng(0)
print(" K без переноса перенос каждые 128")
for K in [256, 1024, 4096, 16384]:
a, b = rng.uniform(0, 1, K), rng.uniform(0, 1, K) # положительные слагаемые — худший случай
exact = float(a @ b)
e1 = abs(dot(a, b) - exact) / exact
e2 = abs(dot(a, b, 128) - exact) / exact
print(f"{K:5d} {e1:12.3%} {e2:16.3%}")
K без переноса перенос каждые 128
256 0.602% 0.300%
1024 2.003% 0.258%
4096 7.811% 0.274%
16384 25.908% 0.271%

Модель грубее настоящего железа, поэтому числа хуже, чем у DeepSeek. Но характер тот же: без переноса ошибка растёт с длиной суммы, с переносом — остаётся постоянной.

4. Высокая точность там, где FP8 опасен. В BF16 или FP32 остались эмбеддинги, выходной слой, роутер MoE, нормализации и само внимание. На них приходится малая доля вычислений, а ошибки там дороже всего: вспомните «Китайский угол» урока 1.3.2 про logits и softmax. Мастер-веса и градиенты весов — в FP32, моменты AdamW — в BF16.

Итог по отчёту: относительное отклонение loss FP8-обучения от BF16-обучения — меньше 0.25%, то есть в пределах случайного шума между запусками.

Классический FP8 (Transformer Engine)DeepSeek-V3
Гранулярность масштабатензортайл 1×1281 \times 128 / блок 128×128128 \times 128
Как выбирают масштабпо истории max за прошлые шагипо текущему тензору
ФорматыE4M3 вперёд, E5M2 для градиентовE4M3 везде
Накоплениев тензорных ядрахперенос в FP32 каждые 128 элементов

Следующая ступень — 4 бита. В формате E2M1 всего 15 различных чисел: ±{0,0.5,1,1.5,2,3,4,6}\pm\{0, 0.5, 1, 1.5, 2, 3, 4, 6\}. С таким набором масштаб на тензор бессмыслен, поэтому FP4 существует только вместе с очень мелкими блоками:

  • MXFP4 (открытый стандарт OCP Microscaling): блок из 32 чисел и общий масштаб в виде степени двойки (формат E8M0);
  • NVFP4 (NVIDIA): блок из 16 чисел, масштаб в FP8 E4M3 плюс общий FP32-масштаб тензора.

FP4 на тензорных ядрах появился в GPU поколения Blackwell. Обучать целиком в 4 битах пока не умеют. Типичный приём — обучение с учётом квантования (обучение с учётом квантованияобучение с учётом квантования (QAT)EN quantization-aware training (QAT)В прямом проходе модель видит уже округлённые (например, 4-битные) веса и учится быть к этому устойчивой. Так получены INT4-веса Kimi K2 Thinking.): в прямом проходе модель видит веса, округлённые до 4 бит, и учится быть к этому устойчивой. Так Moonshot выпустила Kimi K2 Thinking с весами в INT4 (4-битные целые числа, урок 7.2.1; ноябрь 2025). По техотчётам 2026 года, DeepSeek-V4 обучает экспертов в FP4 через QAT: мастер-веса в FP32 квантуются в FP4 и без потерь деквантуются в FP8 для вычислений. Kimi K3 с этапа SFT учится с весами экспертов в MXFP4 и активациями в MXFP8. Подробно о квантовании для инференса — в уроке 7.2.1.

Направление движения видно по блоку масштаба: тензор → 128×128128 \times 128 → 1×1281 \times 128 → 32 → 16. Чем меньше бит на число, тем мельче группа, в которой у чисел общий масштаб.

Реализуйте округление в FP8 E4M3 на numpy (в браузере нет torch.float8), затем три способа масштабирования: на тензор, тайлами 1×1281 \times 128 и блоками 128×128128 \times 128. Кнопка «Запустить» выведет таблицу ошибок с выбросами разной силы.

FP8 с общим и поблочным масштабомnumpy · нужен интернет7 тестов
import numpy as np

E4M3_MAX = 448.0          # самое большое число в FP8 E4M3
E4M3_MIN_NORMAL = 2.0**-6  # самое маленькое «нормальное» число E4M3


def fp8_e4m3(x):
    """Округляет каждый элемент x к ближайшему числу формата FP8 E4M3 и возвращает float-массив.

    Правила формата (1 бит знака, 4 бита порядка, 3 бита мантиссы):
      * модуль больше 448 «насыщается» до 448 (знак сохраняется);
      * для |x| >= 2^-6 число лежит в «октаве» [2^e, 2^(e+1)), e = floor(log2|x|),
        и в каждой октаве ровно 8 ступенек: шаг = 2^(e - 3);
      * для |x| < 2^-6 (субнормальные числа) шаг постоянный: 2^(-6 - 3) = 2^-9;
      * округление — к ближайшей ступеньке (np.round: при равенстве — к чётному).
    Пример: fp8_e4m3([0.3, 500.0]) -> [0.3125, 448.0]
    """
    x = np.asarray(x, dtype=np.float64)
    # TODO: ваш код
    ...


def quantize_per_tensor(x):
    """Один масштаб на весь тензор.

    s = max|x| / 448 (если max|x| == 0, то s = 1); x_hat = fp8_e4m3(x / s) * s.
    Возвращает (x_hat, s), где s — число (np.ndarray формы () или float).
    """
    # TODO: ваш код
    ...


def quantize_tiles(x, tile=128):
    """Активации (M, K): у каждого куска x[i, j*tile:(j+1)*tile] свой масштаб max|кусок| / 448.

    Возвращает (x_hat той же формы (M, K), scales формы (M, K // tile)).
    Подсказка: x.reshape(M, K // tile, tile).
    """
    # TODO: ваш код
    ...


def quantize_blocks(w, block=128):
    """Веса (K, N): у каждого квадратного блока block×block свой масштаб max|блок| / 448.

    Возвращает (w_hat формы (K, N), scales формы (K // block, N // block)).
    Подсказка: w.reshape(K // block, block, N // block, block) и max по осям (1, 3).
    """
    # TODO: ваш код
    ...


def rel_error(approx, exact):
    """Относительная ошибка по норме Фробениуса: ||approx - exact|| / ||exact||."""
    # TODO: ваш код
    ...


def fp8_matmul(x, w, mode):
    """Симуляция FP8 GEMM: квантуем оба множителя и перемножаем (накопление в float64).

    mode='tensor' — quantize_per_tensor для x и для w;
    mode='fine'   — quantize_tiles(x) и quantize_blocks(w), как в DeepSeek-V3.
    """
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»:
print(fp8_e4m3([0.3, 1.0, 500.0, 0.001]))       # ожидается [0.3125, 1.0, 448.0, 0.001953125]

rng = np.random.default_rng(0)
x = rng.normal(size=(64, 512))                  # активации: 64 токена × 512 каналов
w = rng.normal(size=(512, 256)) * 0.05          # веса слоя
print("выброс   общий масштаб   тайлы 1×128 + блоки 128×128")
for outlier in [1e2, 1e3, 1e4, 1e5, 1e6]:
    xo = x.copy()
    xo[3, 7] = outlier                          # один «массивный» элемент активаций
    ex = xo @ w
    keep = np.ones(64, dtype=bool)
    keep[3] = False                             # смотрим на ошибку в ДРУГИХ токенах
    yt, yf = fp8_matmul(xo, w, "tensor"), fp8_matmul(xo, w, "fine")
    if yt is None or yf is None:
        print("fp8_matmul пока ничего не возвращает")
        break
    print(f"{outlier:7.0e}   {rel_error(yt[keep], ex[keep]):13.4f}   {rel_error(yf[keep], ex[keep]):10.4f}")

После решения поэкспериментируйте: замените tile=128 на 32 и 512. Как меняется ошибка в строке с выбросом 10610^6? Почему тайл не делают из 4 чисел? Подсказка: посчитайте долю памяти, которая уйдёт на масштабы.

Проверь себя6 вопросов
1 Почему при обучении в BF16 не нужен loss scaling, а в FP16 нужен?
2 Округлите 0.3 до ближайшего числа FP8 E4M3 (3 бита мантиссы). Ответ — число.
3 Сколько байт на параметр занимает обучение с AdamW: веса BF16, градиенты BF16, мастер-веса и оба момента Adam в FP32?
4 Что DeepSeek-V3 при FP8-обучении держит в повышенной точности (BF16/FP32)? Выберите все верные.Можно выбрать несколько ответов
5 В тензоре активаций у одного токена появился выброс 10610^6. Почему тайлы 1×1281 \times 128 спасают остальные токены?
6 Зачем в DeepSeek-V3 каждые 128 элементов суммы переносят в FP32 на CUDA-ядра?
  • Число с плавающей точкой = знак × мантисса × 2порядок2^{\text{порядок}}. Порядок задаёт диапазон, мантисса — точность.
  • Смешанная точность: мастер-веса в FP32, умножения в BF16 (или FP8), суммы в FP32. Для FP16 нужен loss scaling, для BF16 — нет.
  • Обучение с AdamW стоит ~16 байт на параметр, и большая часть — состояние оптимизатора.
  • DeepSeek-V3 сделала FP8 рабочим на масштабе 671B: тайлы 1×1281 \times 128 и блоки 128×128128 \times 128, E4M3 везде, перенос накопления в FP32, чувствительные слои в высокой точности. Отклонение loss от BF16 — меньше 0.25%.
  • Дальше — FP4 с блоками по 16–32 числа и QAT.

Дальше: Multi-Token Prediction — как DeepSeek-V3 выжимает из каждого обучающего токена больше сигнала и заодно получает черновик для ускорения генерации.