DeepSeekMoE — мелкие и общие эксперты
Три идеи DeepSeek, которые превратили MoE из капризной архитектуры в рабочую лошадку — мелкозернистые эксперты, общие эксперты и балансировка смещениями без вспомогательного лосса, плюс как всё это раскладывают по сотням GPU.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке у классической MoE обнаружились три болезни. Экспертов мало (8–16), поэтому каждому достаётся каша из разнородных знаний. Общие для всех токенов знания (грамматика, частые слова) дублируются в нескольких экспертах. А вспомогательный лосс, который держит баланс, мешает основному обучению.
DeepSeek за 2024 год предложила лекарство от каждой болезни. В январе вышла статья DeepSeekMoE с мелкими и общими экспертами, в августе — балансировка без вспомогательного лосса, в декабре всё это собрали в DeepSeek-V3. Сегодня по этим рецептам строят MoE-модели и другие лаборатории. В этом уроке разберём все три идеи и посмотрим, как модель с 256 экспертами раскладывают по сотням GPU.
Что не так с классической MoE
Заголовок раздела «Что не так с классической MoE»Авторы DeepSeekMoE назвали две проблемы архитектуры:
- Гибридность знаний (knowledge hybridity). Экспертов мало, и к каждому попадают очень разные токены. Эксперт вынужден хранить в одних параметрах, скажем, и математику, и кулинарию, и использовать эти знания одновременно ему трудно.
- Избыточность знаний (knowledge redundancy). Токенам у разных экспертов нужно и общее знание. В итоге несколько экспертов учат одно и то же, и параметры тратятся впустую.
Цель DeepSeekMoE — «предельная специализация экспертов»: каждый эксперт знает своё, и знания не пересекаются.
Идея 1: мелкозернистые эксперты
Заголовок раздела «Идея 1: мелкозернистые эксперты»Возьмём классическую MoE: экспертов, выбираем . Разрежем каждого эксперта на маленьких, уменьшив промежуточную размерность FFN в раз, и будем выбирать из . Параметров столько же, FLOPs на токен столько же. Это мелкозернистые экспертымелкозернистые экспертыEN fine-grained expertsИдея DeepSeekMoE — разрезать каждого эксперта на m маленьких и активировать в m раз больше. Параметры и FLOPs те же, а комбинаций экспертов на порядки больше..
Что изменилось? Число комбинаций. Запись — число сочетаний (урок 1.0.5): сколькими способами можно выбрать
предметов из , если порядок не важен (в Python math.comb(n, k)). Например, :
первого эксперта выбираем 16 способами, второго 15, и делим на 2, потому что пара «A, B» и пара «B, A» — одно и то же.
Пример из статьи: , top-2 даёт вариантов «команды экспертов» для токена. Разрежем каждого на 4 части: вариантов. Это как перейти от
больших блоков конструктора к маленьким: из мелких деталей можно собрать гораздо более точную форму.
Мелкие эксперты специализируются уже, и знания для конкретного токена собираются из нужных «кусочков». Предел у идеи технический: слишком маленькие матрицы плохо загружают GPU, а накладные расходы на раскладку токенов по экспертам растут. Поэтому дробят до разумного размера и останавливаются. Масштаб видно по конфигу DeepSeek-V3: промежуточная размерность эксперта 2048 при плотном FFN на 18 432 — каждый эксперт примерно в 9 раз меньше обычного FFN, и на токен их берут 8 плюс один общий.
Идея 2: общие эксперты
Заголовок раздела «Идея 2: общие эксперты»Выделим экспертов, которые обрабатывают каждый токен, без участия роутера. Это общие экспертыобщий экспертEN shared expertКлючевая идея DeepSeekMoE.. Чтобы вычисления не выросли, из маршрутизируемых выбираем на меньше. Выход слоя:
Здесь — вход слоя (первое слагаемое — residual-связь), — число маршрутизируемых экспертов, — вес эксперта для токена . У выбранных top-k экспертов вес равен нормированной оценке роутера, у остальных он равен нулю, и их FFN вообще не считается. Общий эксперт берёт на себя знания, которые нужны всем, и маршрутизируемым больше не нужно их дублировать.
flowchart LR U["токен u_t"] --> S["общий эксперт: всегда"] U --> R["роутер: сродство с 256 экспертами"] R -->|"top-8"| E["8 мелких экспертов из 256"] U --> E S --> SUM["сумма + residual"] E --> SUM SUM --> H["h'_t"]
Работает ли это: цифры из статьи
Заголовок раздела «Работает ли это: цифры из статьи»Авторы сравнили MoE-архитектуры с 2B параметров всего (и плотную модель на 0.2B для ориентира), все обучены на 100B токенов. Pile — большой открытый английский корпус (книги, статьи, код, веб); loss на нём — та же cross-entropy, чем меньше, тем лучше. HellaSwag — тест «выбери самое правдоподобное продолжение бытовой ситуации», число — процент верных ответов:
| Модель | Активных параметров | Loss на Pile | HellaSwag |
|---|---|---|---|
| Плотная | 0.2B | 2.060 | 38.8 |
| Hash Layer | 0.2B | 1.932 | 46.2 |
| Switch Transformer | 0.2B | 1.881 | 49.1 |
| GShard | 0.3B | 1.867 | 50.5 |
| DeepSeekMoE | 0.3B | 1.808 | 54.8 |
Ещё несколько результатов из той же статьи:
- Общий эксперт незаменим. У уже обученной DeepSeekMoE 2B (1 общий эксперт и 7 из 63 маршрутизируемых) общего эксперта отключили и вместо него включили ещё одного маршрутизируемого. FLOPs те же, а loss вырос с 1.808 до 2.414. Значит, общий эксперт хранит знания, которых нет у маршрутизируемых, и заменить его нечем.
- Сколько общих. При 64 экспертах пробовали 1, 2 и 4 общих: loss 1.808, 1.806 и 1.811. Разница мала, и при масштабировании авторы держали соотношение «общие : выбранные маршрутизируемые» около 1 : 3.
- Масштаб. DeepSeekMoE 16B (2.8B активных) сравнялась с LLaMA2 7B, тратя около 40% её вычислений.
Как это выглядит в DeepSeek-V3
Заголовок раздела «Как это выглядит в DeepSeek-V3»| DeepSeekMoE 16B (01.2024) | DeepSeek-V2 (05.2024) | DeepSeek-V3 (12.2024) | |
|---|---|---|---|
| Всего / активных | 16.4B / 2.8B | 236B / 21B | 671B / 37B |
| Общих экспертов | 2 | 2 | 1 |
| Маршрутизируемых (выбрано) | 64 (6) | 160 (6) | 256 (8) |
| Функция сродства | softmax | softmax | сигмоида |
| Балансировка | aux loss | 3 вида aux loss и отбрасывание токенов | смещения и крошечный aux loss, без отбрасывания |
| Ограничение маршрута | — | не более 3 устройств | не более 4 узлов |
В V3 роутинг заметно изменился. Сродство токена с экспертом считается сигмоидой, , где — обучаемый «центроидцентроидEN centroid» эксперта (вектор-«представитель», как центр кластера в k-means из модуля 2.3). В отличие от softmax, каждая оценка сигмоиды считается независимо от остальных, и в сумме они не обязаны давать 1. Поэтому веса выбранных экспертов нормируют отдельно. Вот сокращённый фрагмент официального инференс-кода:
# DeepSeek-V3, inference/model.py, класс Gate (лицензия MIT); сокращено, комментарии нашиdef forward(self, x): scores = linear(x, self.weight).sigmoid() # (T, 256): сродство σ(u·e_i) original_scores = scores scores = scores + self.bias # смещение балансировки — только для выбора scores = scores.view(x.size(0), self.n_groups, -1) # 8 групп по 32 эксперта group_scores = scores.topk(2, dim=-1)[0].sum(dim=-1) # сила группы = сумма двух лучших indices = group_scores.topk(self.topk_groups, dim=-1)[1] # оставляем 4 группы из 8 # маска групп: True у всех, затем scatter_ ставит False на места 4 выбранных групп → закрываем остальные 4 mask = scores.new_ones(x.size(0), self.n_groups, dtype=bool).scatter_(1, indices, False) scores = scores.masked_fill_(mask.unsqueeze(-1), float("-inf")).flatten(1) indices = torch.topk(scores, self.topk, dim=-1)[1] # 8 экспертов из оставшихся 128 weights = original_scores.gather(1, indices) # gather: взять из каждой строки значения по indices; веса — по ИСХОДНЫМ оценкам weights /= weights.sum(dim=-1, keepdim=True) # нормировка среди выбранных weights *= self.route_scale # × 2.5 из конфига return weights.type_as(x), indicesОбратите внимание на три детали. Смещение self.bias участвует в выборе, но не в весах. Выбор двухступенчатый:
сначала 4 группы из 8, потом 8 экспертов внутри них. Группы нужны для ограничения маршрута, о котором речь пойдёт
в разделе про параллелизм. Наконец, множитель route_scale = 2.5 берётся из конфига модели и задаёт масштаб вклада маршрутизируемых
экспертов после нормировки.
Идея 3: балансировка без вспомогательного лосса
Заголовок раздела «Идея 3: балансировка без вспомогательного лосса»Вспомним дилемму aux loss: маленький коэффициент не спасает от перекоса, большой портит качество. Статья «Auxiliary-Loss-Free Load Balancing» (DeepSeek, август 2024) предлагает разделить две задачи роутера: решать, кому достанется токен, и решать, с каким весом учитывать ответ эксперта.
балансировка без вспомогательного лоссабалансировка без вспомогательного лоссаEN auxiliary-loss-free load balancingМетод DeepSeek (arXiv:2408.15664, использован в V3) — к оценкам роутера прибавляется смещение b_i, влияющее только на выбор экспертов; после каждого шага b_i сдвигается на ±γ по загрузке. устроена так:
- У каждого эксперта есть смещение , вначале 0. Это не параметр модели, градиенты его не трогают.
- Выбор top-k идёт по , а веса берутся по исходным .
- После каждого шага считаем, сколько токенов досталось каждому эксперту, и сдвигаем смещения:
Здесь — средняя загрузка, — маленький шаг, а — знак числа: для положительных,
для отрицательных, 0 для нуля (np.sign в NumPy).
Недогруженный эксперт () получает и чаще проходит в top-k. Перегруженный получает .
Это как светофор с адаптивным переключением: машины (токены) едут сами, светофор лишь чуть меняет приоритеты.
Пример. Четыре эксперта, в батче 100 назначений, загрузка , среднее 25. Знаки : , и при смещения становятся .
Почему это лучше aux loss:
- Нет мешающих градиентов. Основной лосс оптимизирует только качество.
- Выход модели не искажается. Смещение меняет лишь то, кто обработает токен, а не как сложатся ответы.
- Баланс по батчу, а не внутри каждой последовательности. Это оставляет экспертам свободу специализироваться по доменам, как и в подходе Qwen из прошлого урока.
Качество баланса меряют метрикой MaxVio — насколько самый загруженный эксперт превышает среднюю загрузку:
В статье на моделях до 3B параметров метод дал и лучшую perplexity, и лучший баланс, чем aux loss. Скорость обновления (в статье она обозначена ) равна 0.001: при слишком маленькой баланс устанавливается медленно, при слишком большой смещения «дрожат» — в задании ниже вы увидите оба режима своими глазами. Су Цзяньлинь в части 3 цикла «Путешествие по MoE» замечает, что это обновление — знаковый градиентный спуск (SignSGD) по смещениям.
В DeepSeek-V3: первые 14.3T токенов обучения и последние 500B. Дополнительно стоит sequence-wise aux loss с крошечным коэффициентом , только чтобы не было экстремального перекоса внутри одной последовательности. Благодаря балансировке V3 не отбрасывает токены ни при обучении, ни на инференсе.
Параллелизм экспертов
Заголовок раздела «Параллелизм экспертов»Как разместить 256 экспертов в 58 слоях? Ни на одну GPU они не поместятся. Решение — параллелизм экспертовпараллелизм экспертовEN expert parallelism (EP)Разные эксперты MoE-слоя живут на разных GPU; токены пересылаются к своим экспертам и обратно через all-to-all-обмен.: эксперты слоя раскладываются по разным GPU, а токены пересылаются к своим экспертам и обратно.
Пересылка идёт обменом «все со всеми» (all-to-all): каждая GPU отправляет каждой другой ту часть своих токенов, чьи эксперты там живут, и одновременно принимает чужие. На MoE-слой таких обменов два: dispatch (токены к экспертам) и combine (результаты назад).
flowchart LR A["токены на GPU 0"] -->|"dispatch (all-to-all)"| E0["GPU 0: эксперты 0–31"] A -->|dispatch| E1["GPU 1: эксперты 32–63"] B["токены на GPU 1"] -->|dispatch| E0 B -->|dispatch| E1 E0 -->|"combine (all-to-all)"| A2["выходы токенов GPU 0"] E1 -->|combine| A2 E0 -->|combine| B2["выходы токенов GPU 1"] E1 -->|combine| B2
Отсюда три инженерных следствия.
- Коммуникации дороги. Каждый узел, куда уходит токен, — это пересылка по сети. Поэтому DeepSeek ограничивает маршрут: в V2 — не более 3 устройств, в V3 — не более 4 узлов. Это и есть «группы» в коде выше. В V3 8 групп по 32 эксперта, при обучении группа соответствует узлу, и токен попадает максимум в 4 группы.
- Баланс — это скорость. Шаг длится столько, сколько работает самая загруженная GPU. Поэтому балансировка в MoE — вопрос не только качества, но и денег.
- Горячие эксперты дублируют. На инференсе DeepSeek держит избыточные копии самых популярных экспертов и периодически переставляет экспертов между GPU по статистике загрузки. Этот балансировщик открыт как EPLB, а пересылки dispatch/combine делает библиотека DeepEP.
Масштаб впечатляет. По опубликованному DeepSeek обзору инференс-системы V3/R1 (февраль 2025), prefill работает в конфигурации EP32: блок из 4 узлов, на каждой GPU 9 маршрутизируемых экспертов и 1 общий, плюс 32 избыточных эксперта. Decode — EP144: 18 узлов, на каждой GPU 2 маршрутизируемых и 1 общий эксперт. Чтобы GPU не простаивали во время пересылок, батч делят на два микро-батча: пока один считается, другой пересылается. Как то же самое делают при обучении (DualPipe) и почему урезанная связь H800 сделала это необходимым — в уроке 5.4.3.
Практика
Заголовок раздела «Практика»Задание в браузере
Заголовок раздела «Задание в браузере»Реализуйте роутинг со смещениями, обновление смещений, MaxVio и цикл «обучения» на потоке батчей, где первые эксперты заметно популярнее последних. Кнопка «Запустить» нарисует загрузку экспертов без балансировки и с ней.
import numpy as np
# ── Готовые помощники ────────────────────────────────────────────────────────
def make_batches(n_steps, T=256, E=16, d=16, seed=0):
"""Батчи сродства токен–эксперт (sigmoid), где первые эксперты заметно «популярнее» последних."""
rng = np.random.default_rng(seed)
C = rng.normal(size=(d, E)) / np.sqrt(d)
skew = np.linspace(1.5, -1.5, E) # перекос: эксперт 0 любим, эксперт 15 — нет
return [1 / (1 + np.exp(-(rng.normal(size=(T, d)) @ C + skew))) for _ in range(n_steps)]
def bar_chart(counts, width=40):
"""Текстовый «график» загрузки экспертов."""
top = max(counts.max(), 1)
for i, c in enumerate(counts):
print(f"E{i:<2d} {'█' * int(width * c / top):<{width}} {int(c)}")
# ── Ваша часть ───────────────────────────────────────────────────────────────
def route_with_bias(scores, bias, k):
"""Выбор экспертов по scores + bias, а веса — по исходным scores.
scores: (T, E) — сродство токенов и экспертов (после sigmoid); bias: (E,).
Возвращает (idx, weights): idx — (T, k), top-k по (scores + bias), по убыванию,
при равенстве меньший номер раньше (np.argsort(..., kind='stable'));
weights — (T, k): ИСХОДНЫЕ scores выбранных экспертов, нормированные к сумме 1.
"""
# TODO
...
def update_bias(bias, counts, gamma):
"""Шаг балансировки: b_i <- b_i + gamma * sign(mean(counts) - counts_i).
Недогруженные эксперты получают +gamma, перегруженные -gamma, ровно средние — 0.
Возвращает новый массив смещений.
"""
# TODO
...
def max_violation(counts):
"""MaxVio = (max_i load_i - mean load) / mean load. 0 — идеальный баланс."""
# TODO
...
def simulate(batches, k, gamma):
"""Обучение без aux loss: для каждого батча
1) роутинг с текущими смещениями, 2) счётчики токенов по экспертам,
3) записать MaxVio, 4) обновить смещения.
Возвращает (history, bias): список MaxVio по шагам и итоговые смещения.
"""
E = batches[0].shape[1]
bias = np.zeros(E)
history = []
# TODO
...
# Проверьте себя кнопкой «Запустить»:
batches = make_batches(300)
for gamma in (0.0, 0.01):
res = simulate(batches, k=4, gamma=gamma)
if res is None:
break
hist, bias = res
print(f"gamma={gamma}: MaxVio на шаге 0 = {hist[0]:.2f}, в среднем за последние 50 шагов = {np.mean(hist[-50:]):.2f}")
idx, _ = route_with_bias(batches[-1], bias, 4)
bar_chart(np.bincount(idx.ravel(), minlength=16))
Поэкспериментируйте со скоростью gamma. В той же симуляции на 300 шагах при MaxVio почти не меняется
(≈ 1.8), при и падает примерно до 0.2 (при — уже за 20 шагов, при — только к двухсотому),
а при застревает около 0.8: смещения прыгают слишком сильно. Качественно ту же картину описывают авторы статьи:
слишком маленькая скорость медленно наводит баланс, слишком большая раскачивает смещения.
DeepSeekMoE-слой на PyTorch
Заголовок раздела «DeepSeekMoE-слой на PyTorch»Общий эксперт, 16 мелких маршрутизируемых, сигмоидное сродство и балансировка смещениями прямо внутри обучения. Задача та же, что в прошлом уроке, и никакого aux loss.
import math
import torchimport torch.nn as nnimport torch.nn.functional as F
torch.set_num_threads(1)
class SwiGLU(nn.Module): """Эксперт = маленький FFN со SwiGLU, как в DeepSeek/Qwen/Llama."""
def __init__(self, d, d_ff): super().__init__() self.w1, self.w3 = nn.Linear(d, d_ff, bias=False), nn.Linear(d, d_ff, bias=False) self.w2 = nn.Linear(d_ff, d, bias=False)
def forward(self, x): return self.w2(F.silu(self.w1(x)) * self.w3(x))
class DeepSeekMoE(nn.Module): """Общие эксперты + мелкие маршрутизируемые, sigmoid-сродство, балансировка смещениями."""
def __init__(self, d, d_expert, n_routed=16, n_shared=1, k=4, gamma=0.01): super().__init__() self.k, self.E, self.gamma = k, n_routed, gamma self.centroids = nn.Parameter(torch.randn(n_routed, d) / d ** 0.5) # e_i из статьи # buffer — тензор, который живёт в модуле и сохраняется в state_dict, но не является параметром: градиент его не трогает self.register_buffer("bias", torch.zeros(n_routed)) self.routed = nn.ModuleList(SwiGLU(d, d_expert) for _ in range(n_routed)) self.shared = SwiGLU(d, n_shared * d_expert) # общие эксперты = один FFN пошире
def forward(self, x): # x: (T, d) s = torch.sigmoid(x @ self.centroids.T) # сродство (T, E) idx = (s + self.bias).topk(self.k, dim=-1).indices # смещение — только для выбора w = s.gather(-1, idx) w = w / w.sum(dim=-1, keepdim=True) # веса — из исходных s y = self.shared(x) # общие эксперты видят всех for e, expert in enumerate(self.routed): rows, slot = torch.where(idx == e) if rows.numel(): y = y.index_add(0, rows, w[rows, slot, None] * expert(x[rows])) load = torch.bincount(idx.flatten(), minlength=self.E).float() if self.training: # шаг балансировки после батча self.bias += self.gamma * torch.sign(load.mean() - load) self.maxvio = ((load.max() - load.mean()) / load.mean()).item() return y
def run(gamma, steps=200, seed=0): torch.manual_seed(seed) d, n_clusters = 16, 8 centers, common = torch.randn(n_clusters, d), 2.0 * torch.randn(d) maps = torch.randn(n_clusters, d, d) / d ** 0.5 moe = DeepSeekMoE(d, 8, n_routed=16, n_shared=1, k=4, gamma=gamma) opt = torch.optim.Adam(moe.parameters(), lr=3e-3) hist = [] for _ in range(steps): c = torch.randint(0, n_clusters, (256,)) x = centers[c] + common + 0.3 * torch.randn(256, d) x = x / x.pow(2).mean(-1, keepdim=True).sqrt() target = torch.einsum("td,tde->te", x, maps[c]) loss = F.mse_loss(moe(x), target) # никакого aux loss opt.zero_grad() loss.backward() opt.step() hist.append(moe.maxvio) return loss.item(), hist
print("комбинаций: C(16,2) =", math.comb(16, 2), " C(64,8) =", f"{math.comb(64, 8):,}")for gamma in (0.0, 0.01): mse, hist = run(gamma) print(f"gamma={gamma:<4} MSE={mse:.3f} MaxVio: шаг 1 = {hist[0]:.2f}, " f"среднее за последние 50 шагов = {sum(hist[-50:]) / 50:.2f}")Вывод (на CPU от нескольких секунд до полуминуты; на другой версии PyTorch числа могут немного отличаться):
комбинаций: C(16,2) = 120 C(64,8) = 4,426,165,368gamma=0.0 MSE=0.025 MaxVio: шаг 1 = 2.08, среднее за последние 50 шагов = 1.17gamma=0.01 MSE=0.026 MaxVio: шаг 1 = 2.08, среднее за последние 50 шагов = 0.26Балансировка смещениями снизила перекос в 4.5 раза, не добавив ни одного члена в лосс. На качество в этой игрушечной задаче она почти не повлияла, что и требовалось: смещения не должны мешать обучению. Настоящий выигрыш виден на кластере, где перекос нагрузки превращается в простаивающие GPU.
Проверь себя
Заголовок раздела «Проверь себя»- Мелкозернистые эксперты при тех же параметрах и FLOPs дают на порядки больше комбинаций, и знания раскладываются точнее.
- Общие эксперты забирают общее знание; без них качество заметно падает.
- Балансировка смещениями отделяет выбор эксперта от веса его ответа и обходится без вспомогательного лосса. В V3: , сигмоидное сродство, никакого отбрасывания токенов.
- Параллелизм экспертов раскладывает экспертов по GPU. Маршрут ограничивают узлами, горячих экспертов дублируют.
Дальше: модуль 5.4 об эффективном обучении. Начнём со смешанной точности — FP32, BF16 и FP8, с помощью которых DeepSeek обучила V3.