Перейти к содержимому
ПереводыDeepSeek

FlashMLA изнутри: ядро декодирования MLA и FP8-ядро для V3.2

Полный перевод двух технических заметок DeepSeek о ядрах FlashMLA для GPU Hopper: почему декодирование MLA упирается в вычисления и как устроено «качельное» расписание, и как FP8-ядро для разреженного внимания V3.2 обходит узкое место деквантования.

Оригинал: docs/20250422-new-kernel-deep-dive.md, 22 апреля 2025 года.

В предыдущей версии ядра Flash MLA мы добились впечатляющей производительности: 3000 ГБ/с в режиме, где всё упирается в память, и 580 TFLOPS в режиме, где всё упирается в вычисления. Теперь мы продвинулись ещё дальше — до 660 TFLOPS.

В этой заметке мы подробно разбираем новое ядро и объясняем оптимизации и приёмы, которые стоят за ростом производительности. Сначала объясним, почему ядро MLA упирается в вычисления, хотя это ядро внимания для стадии декодирования, затем обсудим высокоуровневое расписание ядра и в конце — технические детали.

Ядра GPU делятся на ограниченные вычислениями (compute-bound: их предел — число операций с плавающей точкой в секунду, FLOPS) и ограниченные памятью (memory-bound: их предел — пропускная способность памяти). Чтобы понять, во что упирается ядро, мы считаем отношение числа операций к объёму памяти (FLOPs на байт) и сравниваем его с возможностями GPU.

Пусть число голов q равно hqh_q, число q-токенов в запросе — sqs_q (равно 1, если MTP и спекулятивное декодирование выключены), число kv-токенов в запросе — sks_k (sk≫hqsqs_k \gg h_q s_q), а размерности голов K и V — dkd_k и dvd_v. Число операций примерно равно 2(hqsq⋅dk⋅sk+hqsq⋅sk⋅dv)=2hqsqsk(dk+dv)2 (h_q s_q \cdot d_k \cdot s_k + h_q s_q \cdot s_k \cdot d_v) = 2 h_q s_q s_k (d_k+d_v), а объём обращений к памяти (в байтах) — sizeof⁡(bfloat16)×(hqsqdk+skdk+hqsqdv)≈2skdk\operatorname{sizeof}(\text{bfloat16}) \times (h_q s_q d_k + s_k d_k + h_q s_q d_v) \approx 2 s_k d_k. Значит, отношение вычислений к памяти равно hqsq⋅dk+dvdk≈2hqsqh_q s_q \cdot \frac{d_k+d_v}{d_k} \approx 2 h_q s_q.

У NVIDIA H800 SXM5 пиковая пропускная способность памяти — 3,35 ТБ/с, пиковая производительность — 990 TFLOPS. Но из-за троттлинга (у нас частота снижается примерно до 1600 МГц) практический пик падает примерно до 865 TFLOPS. Поэтому при hqsq≥12⋅8653.35=128h_q s_q \ge \frac{1}{2} \cdot \frac{865}{3.35} = 128 ядро ограничено вычислениями, иначе — памятью.

Согласно обзору онлайн-системы инференса DeepSeek, для экземпляров декодирования мы не используем тензорный параллелизм, то есть hqh_q равно 128, и ядро ограничено вычислениями. Значит, ядро нужно оптимизировать именно для этого режима.

Чтобы полностью загрузить вычислительные ресурсы GPU, нужно накладывать операции на CUDA-ядрах на операции на тензорных ядрах, а обращения к памяти — на вычисления, чтобы тензорные ядра всё время были заняты. Для этого пришлось перепроектировать «расписание» ядра.

Статья о FlashAttention-3 вводит «пинг-понговое» расписание (ping-pong scheduling) и конвейеризацию GEMM и softmax внутри группы варпов, чтобы накладывать поблочные матричные умножения на операции CUDA-ядер. Но здесь эти приёмы напрямую не применить из-за ограничений ресурсов. Выходную матрицу (которая масштабируется и накапливается на каждом проходе основного цикла, как в алгоритме FlashAttention) приходится хранить в регистрах — этого требует инструкция WGMMA. Каждая выходная матрица 64×51264 \times 512 занимает 32 768 32-битных регистров. А в одном SM всего 65 536 32-битных регистров, поэтому в SM помещается только одна выходная матрица. Это исключает вариант с двумя выходными матрицами, которые поочерёдно пользуются CUDA-ядрами и тензорными ядрами. Нужен другой хитрый способ наложить вычисления CUDA-ядер на вычисления тензорных ядер.

(Можете остановиться и подумать — вдруг вы найдёте решение лучше нашего!)

Наше решение добавляет ещё одно математическое преобразование к online softmax и накоплению из FlashAttention. На каждом шаге мы берём два блока KV (назовём их K0K_0, K1K_1, V0V_0 и V1V_1). Выходная матрица занимает 32 768 регистров — слишком много для одной группы варпов, поэтому мы делим её по вертикали на OLO_L и ORO_R (каждая 64×25664 \times 256). Так же делим V0V_0 и V1V_1 на V0LV_{0L}, V0RV_{0R}, V1LV_{1L} и V1RV_{1R} (каждая 64×25664 \times 256). Затем выходная матрица вычисляется так:

  1. Храним текущий максимум mm (начальное значение −∞-\infty, общий для двух групп варпов) и выходные матрицы o⃗L,o⃗R\vec o_L, \vec o_R (начальное значение 0).
  2. [0] Вычисляем p⃗0=q⃗K0⊺/qk_scale\vec p_0 = \vec q K_0^\intercal / \mathit{qk\_scale}.
  3. [1] Вычисляем p⃗1=q⃗K1⊺/qk_scale\vec p_1 = \vec q K_1^\intercal / \mathit{qk\_scale}.
  4. [0] Вычисляем mp0=max⁡(p⃗0)mp_0 = \max(\vec p_0), mnew,0=max⁡(m,mp0)m_{\text{new},0} = \max(m, mp_0) и scale0=exp⁡(mnew,0−m)\mathit{scale}_0 = \exp(m_{\text{new},0} - m). Обновляем m←mnew,0m \gets m_{\text{new},0}.
  5. [0] Применяем softmax к p⃗0\vec p_0: p⃗0←exp⁡(p⃗0−mnew,0)\vec p_0 \gets \exp(\vec p_0 - m_{\text{new},0}).
  6. [0] Обновляем o⃗L←o⃗L⋅scale0+p⃗0V0L\vec o_L \gets \vec o_L \cdot \mathit{scale}_0 + \vec p_0 V_{0L}.
  7. [1] Вычисляем mp1=max⁡(p⃗1)mp_1 = \max(\vec p_1), mnew,1=max⁡(m,mp1)m_{\text{new},1} = \max(m, mp_1) и scale1=exp⁡(mnew,1−m)\mathit{scale}_1 = \exp(m_{\text{new},1} - m). Обновляем m←mnew,1m \gets m_{\text{new},1}.
  8. [1] Применяем softmax к p⃗1\vec p_1: p⃗1←exp⁡(p⃗1−mnew,1)\vec p_1 \gets \exp(\vec p_1 - m_{\text{new},1}).
  9. [1] Обновляем o⃗R←o⃗R⋅(scale0⋅scale1)+p⃗1V1R\vec o_R \gets \vec o_R \cdot (\mathit{scale}_0 \cdot \mathit{scale}_1) + \vec p_1 V_{1R}.
  10. [0] Обновляем p⃗0←p⃗0⋅scale1\vec p_0 \gets \vec p_0 \cdot \mathit{scale}_1.
  11. [1] Обновляем o⃗R←o⃗R+p⃗0V0R\vec o_R \gets \vec o_R + \vec p_0 V_{0R}.
  12. [0] Обновляем o⃗L←o⃗L⋅scale1+p⃗1V1L\vec o_L \gets \vec o_L \cdot \mathit{scale}_1 + \vec p_1 V_{1L}.

Примечание: для простоты считаем, что голова q одна, поэтому q⃗\vec q и o⃗\vec o — векторы. Число в скобках — номер группы варпов, которая выполняет операцию. Считаем, что o⃗L\vec o_L лежит в регистрах группы варпов 0, а o⃗R\vec o_R — в регистрах группы варпов 1.

Такое расписание можно считать вариантом «пинг-понга» с одной выходной матрицей — мы называем его «качелями» (seesaw scheduling). Математически оно эквивалентно online softmax из FlashAttention. Оно позволяет накладывать операции CUDA-ядер на операции тензорных ядер, чередуя две группы варпов, а также накладывать обращения к памяти на вычисления: соответствующие инструкции Tensor Memory Accelerator (TMA) можно запускать сразу, как только данные больше не нужны.

Полное расписание показано на рисунке ниже (помните, что в MLA KK и VV — одно и то же под разными именами).

Этот раздел посвящён техническим деталям нового ядра.

Во-первых, хотя ядро рассчитано на режим, ограниченный вычислениями (где пропускная способность памяти — не узкое место), задержку памяти игнорировать нельзя. Если данные не готовы к моменту, когда они нужны, приходится ждать. Чтобы решить эту проблему, мы используем такие приёмы:

  • Мелкозернистая конвейеризация копирования TMA и GEMM. Для блока K размером 64×57664 \times 576 мы запускаем 9 копирований TMA (каждое переносит блок 64×6464 \times 64). Операции GEMM начинаются, как только завершается очередное копирование (когда готово первое копирование TMA, можно начинать первую операцию GEMM, и так далее). Это повышает устойчивость к задержкам памяти.
  • Подсказки кэшу. Подсказка cute::TMA::CacheHintSm90::EVICT_FIRST для копирований TMA, как показали эксперименты, повышает долю попаданий в кэш L2.

Эти оптимизации дают до 80% загрузки тензорных ядер (от теоретического пика с учётом троттлинга) и 3 ТБ/с пропускной способности памяти на H800 SXM5. В режиме, ограниченном памятью, новое ядро немного (примерно на 2%) медленнее старой версии с пинг-понговыми буферами, но это приемлемо.

Другие улучшения производительности:

  • Программный зависимый запуск (Programmatic Dependent Launch). Мы используем programmatic dependent launch, чтобы наложить друг на друга ядра splitkv_mla и combine.
  • Планировщик тайлов (Tile Scheduler). Мы реализовали планировщик тайлов, который распределяет задания (запросы и блоки) по SM. Это обеспечивает равномерную загрузку SM.

Алгоритм и расписание FlashMLA вдохновлены FlashAttention, Flash-Decoding и CUTLASS, а также многими проектами, на которых они основаны. Благодарим авторов за их замечательную работу.

@misc{flashmla2025,
title={FlashMLA: Efficient MLA decoding kernels},
author={Jiashi Li and Shengyu Liu and Yuanhang Sun},
year={2025},
publisher = {GitHub},
howpublished = {\url{https://github.com/deepseek-ai/FlashMLA}},
}

Часть 2. FP8-ядро декодирования Flash MLA для Hopper

Заголовок раздела «Часть 2. FP8-ядро декодирования Flash MLA для Hopper»

Оригинал: docs/20250929-hopper-fp8-sparse-deep-dive.md, 29 сентября 2025 года.

С выпуском DeepSeek-V3.2 мы удвоили длину контекста наших моделей — с 64 тыс. до 128 тыс. токенов. Это сильно нагружает память GPU (одному запросу длиной 128 тыс. токенов нужен KV-кэш размером 576×2×62×128×1024=8,72 ГиБ576 \times 2 \times 62 \times 128 \times 1024 = 8{,}72\ \mathrm{ГиБ}), что может приводить к ошибкам нехватки памяти (OOM) или к недогрузке GPU из-за маленьких батчей. Чтобы решить эту проблему, мы ввели для DeepSeek-V3.2 KV-кэш в FP8.

Однако написать высокопроизводительное ядро декодирования трудно: нужно деквантовать данные, а доступ к памяти разреженный. В этой заметке мы рассказываем историю нашего нового разреженного FP8-ядра декодирования для GPU Hopper. Сначала объясним формат FP8 KV-кэша, затем проведём теоретический анализ тактов и в конце подробно опишем приёмы, использованные в новом ядре.

Вспомним, что фаза декодирования в многоголовом латентном внимании (MLA) работает аналогично Multi-Query Attention (MQA) со 128 головами запросов и 1 головой ключа, где head_dim_k = 576 и head_dim_v = 512. Чтобы уменьшить KV-кэш и сохранить точность, мы используем мелкозернистое квантование. А именно: к первым 512 элементам KV-кэша каждого токена применяем квантование по тайлам (размер тайла 1×1281 \times 128). Получается 512 значений float8_e4m3 и 4 масштабных множителя float32. Оставшиеся 64 элемента (часть с RoPE) не квантуем, потому что они чувствительны к потере точности. Поэтому в памяти GPU KV-кэш каждого токена занимает 656 байт: 512 значений float8_e4m3, 4 значения float32 и 64 значения bfloat16.

Внутри ядра мы сначала деквантуем 512 значений float8_e4m3 в 512 значений bfloat16. Затем склеиваем их с 64 исходными значениями bfloat16 из RoPE-части. Наконец, выполняем вычисление MQA с помощью операций умножения-сложения матриц (MMA) в точности bfloat16 (то есть входы MMA в bfloat16, выходы в float32; это верно и для GEMM QK, и для GEMM «оценки внимания × V»).

Главная трудность в том, что тензорные ядра (они выполняют MMA) очень быстрые, а деквантование, которое выполняется на CUDA-ядрах, за ними не успевает.

Базовая единица GPU NVIDIA — потоковый мультипроцессор (Stream Multiprocessor, SM). Каждый SM можно представлять как независимое ядро GPU. Для простоты рассмотрим один SM. Каждый SM выполняет 4096 операций MMA за такт (на H800 это 989 TFlops / 1830 MHz / 132 SMs). В нашем ядре каждый CTA (блок потоков CUDA) работает на одном SM, и каждому SM сопоставлен только один CTA. Если каждый CTA обрабатывает 64 головы запросов, то на операции MMA для одного K/V-токена нужно всего 64×(576+512)×2/4096≈3464 \times (576+512) \times 2 / 4096 \approx 34 такта.

Однако H800 не умеет напрямую переводить float8_e4m3 в bfloat16, поэтому деквантование KV-кэша одного токена требует таких шагов:

  1. Перевести float8_e4m3 в half.
  2. Перевести half в float32.
  3. Перевести float32 в bfloat16.
  4. Умножить полученное значение bfloat16 на масштабный множитель float32.

Согласно документации NVIDIA, на деквантование каждого токена нужно не меньше (164+164+116+1256)×512≈50(\frac{1}{64} + \frac{1}{64} + \frac{1}{16} + \frac{1}{256}) \times 512 \approx 50 тактов! Это заметно больше 34 тактов на операции MMA, то есть ядро ограничено деквантованием. Если ничего не делать, деквантование станет узким местом, и мощные тензорные ядра будут простаивать.

Прежде чем продолжить, важно отметить ключевой факт: все головы запроса одного и того же q-токена обращаются к одним и тем же головам ключа, потому что это Multi-Query Attention (MQA).

Вспомним, что каждый CTA обрабатывает 64 головы запросов, а всего в DeepSeek-V3.2 128 голов запросов. Если найти способ «поделиться» деквантованными K/V между двумя CTA, которые обрабатывают разные наборы голов запросов, то каждому CTA придётся деквантовать лишь половину KV-кэша — это было бы отлично! Мы называем этот метод «кроссовер» (crossover), потому что идею подсказал кроссинговер хромосом при мейозе.

Следующий вопрос — как реализовать это в CUDA? До архитектуры Hopper обмениваться данными между CTA можно было только через глобальную память или кэш L2, а это медленно. Но новое решение дала мощная распределённая общая память.

На помощь приходит распределённая общая память

Заголовок раздела «На помощь приходит распределённая общая память»

Распределённая общая память (Distributed Shared Memory, DSM) — новая возможность архитектуры Hopper, появившаяся вместе с кластерами CTA (thread block cluster). CTA одного кластера могут напрямую обращаться к общей памяти друг друга. Подробности — в статье NVIDIA Hopper Architecture In-Depth.

Вот как мы её используем. Мы запускаем CTA кластерами по 2. Каждый CTA в кластере отвечает за 64 головы запросов одного и того же q-токена. Каждый CTA выполняет такие шаги:

  1. Загружает половину квантованных K/V из глобальной памяти. Для производительности используем широкую загрузку __ldg шириной 128 бит.
  2. Деквантует свою половину на CUDA-ядрах.
  3. Сохраняет деквантованные K/V в свою общую память.
  4. Одновременно с помощью st.async записывает деквантованные K/V в общую память другого CTA кластера.

Для синхронизации этих операций мы используем транзакционный барьер кластера (cluster transaction barrier) — ещё один мощный примитив программирования, доступный в кластерах CTA. После обмена у каждого CTA в собственной общей памяти лежит полный набор деквантованных K и V, и на нём можно выполнять операции MMA.

С этими приёмами мы получили 410 TFLOPS в конфигурации, ограниченной вычислениями (batch_size=128, num_heads=128, s_q=2, topk=2048), на GPU H800 SXM5. Это заметный прогресс по сравнению с 250 TFLOPS у нашего предыдущего разреженного FP8-ядра декодирования без кроссовера.

Это всё ещё меньше пиковых 640 TFLOPS нашего прежнего плотного ядра декодирования в bfloat16. Одна из причин в том, что это разреженное ядро, и его topk равен всего 2048. При меньшем topk относительные накладные расходы на пролог и эпилог ядра больше, чем у плотного декодирования с длинным контекстом. Если задать topk больше, например 32 768, ядро достигает 460 TFLOPS.

С другой стороны, время работы этого ядра в упомянутой конфигурации сопоставимо со временем плотного ядра декодирования при длине последовательности около 3000. Когда длина последовательности превышает 3000, преимущество нового ядра становится ещё заметнее. Это также показывает эффективность нашего алгоритма DeepSeek Sparse Attention.

ENRU
compute-bound / memory-boundограничено вычислениями / ограничено памятью
throttlingтроттлинг (снижение частоты)
warpgroupгруппа варпов (4 варпа, 128 потоков)
WGMMAасинхронное умножение матриц на уровне группы варпов (Hopper)
TMA, Tensor Memory Acceleratorускоритель копирования тензоров между глобальной и общей памятью
seesaw scheduling«качельное» расписание
tile schedulerпланировщик тайлов
dequantizationдеквантование
CTA, thread block clusterблок потоков, кластер блоков потоков
distributed shared memory (DSM)распределённая общая память
prologue / epilogueпролог / эпилог ядра (подготовка и запись результата)