FlashMLA изнутри: ядро декодирования MLA и FP8-ядро для V3.2
Полный перевод двух технических заметок DeepSeek о ядрах FlashMLA для GPU Hopper: почему декодирование MLA упирается в вычисления и как устроено «качельное» расписание, и как FP8-ядро для разреженного внимания V3.2 обходит узкое место деквантования.
Часть 1. Новое ядро Flash MLA изнутри
Заголовок раздела «Часть 1. Новое ядро Flash MLA изнутри»Оригинал: docs/20250422-new-kernel-deep-dive.md, 22 апреля 2025 года.
В предыдущей версии ядра Flash MLA мы добились впечатляющей производительности: 3000 ГБ/с в режиме, где всё упирается в память, и 580 TFLOPS в режиме, где всё упирается в вычисления. Теперь мы продвинулись ещё дальше — до 660 TFLOPS.
В этой заметке мы подробно разбираем новое ядро и объясняем оптимизации и приёмы, которые стоят за ростом производительности. Сначала объясним, почему ядро MLA упирается в вычисления, хотя это ядро внимания для стадии декодирования, затем обсудим высокоуровневое расписание ядра и в конце — технические детали.
Теоретический анализ алгоритма MLA
Заголовок раздела «Теоретический анализ алгоритма MLA»Ядра GPU делятся на ограниченные вычислениями (compute-bound: их предел — число операций с плавающей точкой в секунду, FLOPS) и ограниченные памятью (memory-bound: их предел — пропускная способность памяти). Чтобы понять, во что упирается ядро, мы считаем отношение числа операций к объёму памяти (FLOPs на байт) и сравниваем его с возможностями GPU.
Пусть число голов q равно , число q-токенов в запросе — (равно 1, если MTP и спекулятивное декодирование выключены), число kv-токенов в запросе — (), а размерности голов K и V — и . Число операций примерно равно , а объём обращений к памяти (в байтах) — . Значит, отношение вычислений к памяти равно .
У NVIDIA H800 SXM5 пиковая пропускная способность памяти — 3,35 ТБ/с, пиковая производительность — 990 TFLOPS. Но из-за троттлинга (у нас частота снижается примерно до 1600 МГц) практический пик падает примерно до 865 TFLOPS. Поэтому при ядро ограничено вычислениями, иначе — памятью.
Согласно обзору онлайн-системы инференса DeepSeek, для экземпляров декодирования мы не используем тензорный параллелизм, то есть равно 128, и ядро ограничено вычислениями. Значит, ядро нужно оптимизировать именно для этого режима.
Высокоуровневое устройство нового ядра
Заголовок раздела «Высокоуровневое устройство нового ядра»Чтобы полностью загрузить вычислительные ресурсы GPU, нужно накладывать операции на CUDA-ядрах на операции на тензорных ядрах, а обращения к памяти — на вычисления, чтобы тензорные ядра всё время были заняты. Для этого пришлось перепроектировать «расписание» ядра.
Статья о FlashAttention-3 вводит «пинг-понговое» расписание (ping-pong scheduling) и конвейеризацию GEMM и softmax внутри группы варпов, чтобы накладывать поблочные матричные умножения на операции CUDA-ядер. Но здесь эти приёмы напрямую не применить из-за ограничений ресурсов. Выходную матрицу (которая масштабируется и накапливается на каждом проходе основного цикла, как в алгоритме FlashAttention) приходится хранить в регистрах — этого требует инструкция WGMMA. Каждая выходная матрица занимает 32 768 32-битных регистров. А в одном SM всего 65 536 32-битных регистров, поэтому в SM помещается только одна выходная матрица. Это исключает вариант с двумя выходными матрицами, которые поочерёдно пользуются CUDA-ядрами и тензорными ядрами. Нужен другой хитрый способ наложить вычисления CUDA-ядер на вычисления тензорных ядер.
(Можете остановиться и подумать — вдруг вы найдёте решение лучше нашего!)
Наше решение добавляет ещё одно математическое преобразование к online softmax и накоплению из FlashAttention. На каждом шаге мы берём два блока KV (назовём их , , и ). Выходная матрица занимает 32 768 регистров — слишком много для одной группы варпов, поэтому мы делим её по вертикали на и (каждая ). Так же делим и на , , и (каждая ). Затем выходная матрица вычисляется так:
- Храним текущий максимум (начальное значение , общий для двух групп варпов) и выходные матрицы (начальное значение 0).
- [0] Вычисляем .
- [1] Вычисляем .
- [0] Вычисляем , и . Обновляем .
- [0] Применяем softmax к : .
- [0] Обновляем .
- [1] Вычисляем , и . Обновляем .
- [1] Применяем softmax к : .
- [1] Обновляем .
- [0] Обновляем .
- [1] Обновляем .
- [0] Обновляем .
Примечание: для простоты считаем, что голова q одна, поэтому и — векторы. Число в скобках — номер группы варпов, которая выполняет операцию. Считаем, что лежит в регистрах группы варпов 0, а — в регистрах группы варпов 1.
Такое расписание можно считать вариантом «пинг-понга» с одной выходной матрицей — мы называем его «качелями» (seesaw scheduling). Математически оно эквивалентно online softmax из FlashAttention. Оно позволяет накладывать операции CUDA-ядер на операции тензорных ядер, чередуя две группы варпов, а также накладывать обращения к памяти на вычисления: соответствующие инструкции Tensor Memory Accelerator (TMA) можно запускать сразу, как только данные больше не нужны.
Полное расписание показано на рисунке ниже (помните, что в MLA и — одно и то же под разными именами).
Технические детали
Заголовок раздела «Технические детали»Этот раздел посвящён техническим деталям нового ядра.
Во-первых, хотя ядро рассчитано на режим, ограниченный вычислениями (где пропускная способность памяти — не узкое место), задержку памяти игнорировать нельзя. Если данные не готовы к моменту, когда они нужны, приходится ждать. Чтобы решить эту проблему, мы используем такие приёмы:
- Мелкозернистая конвейеризация копирования TMA и GEMM. Для блока K размером мы запускаем 9 копирований TMA (каждое переносит блок ). Операции 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-кэш размером ), что может приводить к ошибкам нехватки памяти (OOM) или к недогрузке GPU из-за маленьких батчей. Чтобы решить эту проблему, мы ввели для DeepSeek-V3.2 KV-кэш в FP8.
Однако написать высокопроизводительное ядро декодирования трудно: нужно деквантовать данные, а доступ к памяти разреженный. В этой заметке мы рассказываем историю нашего нового разреженного FP8-ядра декодирования для GPU Hopper. Сначала объясним формат FP8 KV-кэша, затем проведём теоретический анализ тактов и в конце подробно опишем приёмы, использованные в новом ядре.
Формат FP8 KV-кэша
Заголовок раздела «Формат FP8 KV-кэша»Вспомним, что фаза декодирования в многоголовом латентном внимании (MLA) работает аналогично Multi-Query Attention
(MQA) со 128 головами запросов и 1 головой ключа, где head_dim_k = 576 и head_dim_v = 512. Чтобы уменьшить
KV-кэш и сохранить точность, мы используем мелкозернистое квантование. А именно: к первым 512 элементам KV-кэша
каждого токена применяем квантование по тайлам (размер тайла ). Получается 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-токена нужно всего такта.
Однако H800 не умеет напрямую переводить float8_e4m3 в bfloat16, поэтому деквантование KV-кэша одного токена
требует таких шагов:
- Перевести
float8_e4m3вhalf. - Перевести
halfвfloat32. - Перевести
float32вbfloat16. - Умножить полученное значение
bfloat16на масштабный множительfloat32.
Согласно документации NVIDIA, на деквантование каждого токена нужно не меньше тактов! Это заметно больше 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 выполняет такие шаги:
- Загружает половину квантованных K/V из глобальной памяти. Для производительности используем широкую загрузку
__ldgшириной 128 бит. - Деквантует свою половину на CUDA-ядрах.
- Сохраняет деквантованные K/V в свою общую память.
- Одновременно с помощью
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.
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| 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 | пролог / эпилог ядра (подготовка и запись результата) |