FlashMLA на Huawei Ascend: ядро разреженного внимания изнутри
Полный перевод технической заметки DeepSeek о ядре разреженного внимания для NPU Huawei Ascend 950: расчёт отношения вычислений к памяти, выбор числа голов на ядро, расписание конвейера между CUBE- и VECTOR-ядрами и приёмы оптимизации — gather2, быстрый ND2NZ, skip-scale.
Введение
Заголовок раздела «Введение»30 сентября 2026 года DeepSeek открыла свою инфраструктуру инференса и ключевые компоненты для платформы Huawei Ascend, включая ядра разреженного внимания для prefill и decoding, которые лежат в этом репозитории (deepseek-ai/FlashMLA). При типичных рабочих нагрузках модели DeepSeek V4.1 это ядро достигает 410 TFlops на prefill и 360 TFlops на decoding — соответственно 95% и 83% теоретического пика производительности железа.
В этой статье я последовательно изложу замысел, расписание конвейера и приёмы оптимизации этого ядра.
Напоминание об алгоритме
Заголовок раздела «Напоминание об алгоритме»Это ядро вычисляет прямой проход внимания в архитектуре DeepSeek Sparse Attention (DSA), которая используется в модели DeepSeek V4.1, и покрывает и prefill, и decoding. Оно получает некоторое число q-токенов и kv-токенов и, следуя таблице индексов, позволяет каждому q-токену обращаться только к «самым важным» kv-токенам — так экономится много вычислений при сохранении качества модели.
Математическая постановка
Заголовок раздела «Математическая постановка»В этом ядре разные q-токены всегда обрабатываются отдельно. Для простоты в оставшейся части раздела рассматривается только один Q-токен.
Дано:
- Q:
[h_q, d_qk] - KV:
[s_kv, d_qk] - indices:
[topk] - sm_scale: скаляр
Прямой проход должен вычислить:
gathered_kv = KV.index_select(indices, dim=0),[topk, d_qk]P = Q @ gathered_kv^T,[h_q, topk]S = softmax(P*sm_scale, dim=-1),[h_q, topk]out = S @ gathered_kv[:, :d_v],[h_q, d_v]
Ход вычислений
Заголовок раздела «Ход вычислений»Поскольку у softmax глобальная зависимость, мы вычисляем его тем же алгоритмом онлайн-softmax, что и Flash Attention: динамически обновляем текущий максимум P по строке, lse по строке (log sum exp) и накопитель выхода, посчитанный к этому моменту. Шаги такие:
-
Разбить индексы top-k на блоки размера
B_TOPK(B_TOPKобычно 64 или 96). -
Поддерживать три величины:
running_max,running_max_for_softmaxиrunning_sumexp— все формы[h_q], инициализированные , и 0 соответственно. Кроме того, поддерживать накопитель выходаout_accumформы[h_q, d_v]. Всё это — float32. -
Для каждого блока:
- Прочитать KV из глобальной памяти разреженно, по
indices[B_TOPK * k, B_TOPK * k + B_TOPK], получивgathered_kvэтого блока,[B_TOPK, d_qk]. - Вычислить
P = Q @ gathered_kv^T,[h_q, B_TOPK]. - Вычислить максимум
Pпо строке,cur_max,[h_q]. - Обновить
running_max:running_max = max(running_max, cur_max)(иными словами,running_maxвсегда отражает текущий максимум по строке). - Проверить, существует ли строка
i, для которойrunning_max[i] - running_max_for_softmax[i] > RESCALE_THRES, гдеRESCALE_THRES— гиперпараметр, в нашем ядре обычно равный . - Если такая строка
iнашлась, выполнить обновление онлайн-softmax:out_accum <- out_accum * exp(running_max_for_softmax - running_max).unsqueeze(-1);running_sumexp <- running_sumexp * exp(running_max_for_softmax - running_max);running_max_for_softmax <- running_max;- это равносильно обновлению максимума, который использует онлайн-softmax, так что
exp(running_max - running_max_for_softmax)всегда не превосходит ; - если ни одна строка
iне вызывает срабатывания условия, этот шаг пропускается. Такая оптимизация называется «skip scale».
- Вычислить
S = exp(P - running_max_for_softmax), привести S к bfloat16, затем умножить наgathered_kvи прибавить результат к out_accum (out_accum += S.to(torch.bfloat16) @ gathered_kv). - Обновить
running_sumexp += S.sum(dim=-1).
- Прочитать KV из глобальной памяти разреженно, по
Обсуждение
Заголовок раздела «Обсуждение»Прежде чем формально переходить к проектированию конвейера и приёмам оптимизации, пройдём по нескольким простым рассуждениям, чтобы определиться с общим направлением.
Сколько q-голов должно приходиться на один AI Core?
Заголовок раздела «Сколько q-голов должно приходиться на один AI Core?»Первый вопрос: сколько q-голов на один AI Core будет уместно?
Сначала посчитаем отношение вычислений к обращениям в память, считая, что CUBE Core работает на полной пропускной способности (4096 операций «умножить-накопить» за такт на AI Core), а суммарная пропускная способность L2 равна 5 ТБ/с:
d = 512l2_total_bw = 5e12num_cores = 32freq = 1650e6l2_bw = l2_total_bw / num_cores / freq # На один AI Core за такт
for h_q in [16, 32, 64, 128]: for topk in [512, 1024]: cube_cycles = h_q * topk * (d+d) // 4096 qo_mem_vol = h_q * d * 2 * 2 kv_mem_vol = topk * d * 2 mem_cycles = (qo_mem_vol + kv_mem_vol) / l2_bw print(f"{h_q:3d}, {topk=:4d}, {cube_cycles=:6d}, {mem_cycles=:6.0f}") 16, topk= 512, cube_cycles= 2048, mem_cycles= 5883 16, topk=1024, cube_cycles= 4096, mem_cycles= 11419 32, topk= 512, cube_cycles= 4096, mem_cycles= 6229 32, topk=1024, cube_cycles= 8192, mem_cycles= 11765 64, topk= 512, cube_cycles= 8192, mem_cycles= 6921 64, topk=1024, cube_cycles= 16384, mem_cycles= 12457128, topk= 512, cube_cycles= 16384, mem_cycles= 8305128, topk=1024, cube_cycles= 32768, mem_cycles= 13841Как видно, ядро ограничено вычислениями только при числе голов не меньше 64. При 64 головах вычисления и память примерно уравновешены.
При этом брать 128 голов на AI Core означало бы множество проблем с ёмкостью SRAM. А именно, L0C был бы целиком занят O (, float32, 256 КБ), так что накопитель пришлось бы держать на VECTOR Core; а это, в свою очередь, требует двух AIV и мелкозернистого конвейера (копирование с накоплением в конвейере), что писать очень трудно. Кроме того, по результатам микробенчмарков MMAD с M = 64 уже достаточно, чтобы насытить пропускную способность CUBE Core, поэтому 128 голов не нужны.
Где должен жить накопитель выхода FA?
Заголовок раздела «Где должен жить накопитель выхода FA?»Всякий раз, когда текущий максимум слишком сильно отличается от используемого сейчас максимума масштабирования, Flash Attention вынужден умножать выход на ; это и называется «rescale». Однако данные в L0C можно только выгрузить блоком FixPipe или использовать как накопитель в CUBE Core, а умножить их на скаляр на месте нельзя. Возможны два подхода:
- Держать накопитель на VECTOR Core, и всякий раз, когда O нужно пересчитать, забирать результат накопления из CUBE Core и прибавлять к текущему накопителю.
- Всегда держать накопитель в CUBE Core, а при необходимости пересчёта поднимать его на VECTOR Core,
пересчитывать и возвращать обратно в CUBE Core (умножением на единичную матрицу).
- Это съедает дополнительную пропускную способность CUBE и вредит производительности. Перенос матрицы 64×512 из UB в L0C требует как минимум одного матричного умножения 64×64×512 во float.
- Более того, если не терять точность, режим HF32 нужно выключить; тогда CUBE Core выдаёт лишь 256 операций «умножить-накопить» за такт на AI Core, и умножение 64×64×512 занимает 8192 такта, что совершенно неприемлемо.
Поэтому в итоге мы выбрали первый вариант.
Можно ли обойтись схемой 1C1V?
Заголовок раздела «Можно ли обойтись схемой 1C1V?»Следующий вопрос — сколько VECTOR Core должно приходиться на один CUBE Core при вычислениях. У каждого AI Core в Huawei Ascend 950 один CUBE Core и два VECTOR Core. Если бы на один CUBE Core хватало одного VECTOR Core (схема 1C1V), второй VECTOR Core мог бы выполнять другую лёгкую работу (например, другие модули модели или обмен данными между устройствами), повышая общую полезную загрузку железа.
После разбора ответ отрицательный, по следующим причинам:
- Ограничение по очереди запросов MTE2. На NPU Huawei Ascend 950 один VECTOR Core может иметь не более 16 «выданных, но ещё не завершённых» запросов копирования MTE2 в каждый момент. Даже с приёмом gather2 (см. ниже) одновременно «в полёте» оказывается лишь 32 токена, а этого катастрофически мало.
- Ограничение по деквантованию. На стадии decoding ядро должно читать KV-кэш в формате FP8 или FP4, деквантовать его в bfloat16 и подавать в CUBE Core. По микробенчмаркам при 1C1V деквантование не поспевает за CUBE Core и становится узким местом.
- Ограничение по FixPipe. Когда используются только один CUBE Core и один VECTOR Core, FixPipe выгружает
данные со скоростью всего 128 байт за такт на AI Core. В этом случае число тактов вычислений MMA на блок равно
MMA_M * B_TOPK * (512+512) / 4096 = B_TOPK * 16, а число тактов, нужных FixPipe на выгрузку, —(MMA_M * 512 + MMA_M * B_TOPK) * sizeof(float) / 128 = 1024 + B_TOPK * 2. ЗдесьMMA_M— число q-голов, обрабатываемых каждым AI Core (по разбору выше оно должно быть 64), аB_TOPK— размер тайла при проходе по измерению KV во Flash Attention. Чтобы FixPipe не стал узким местом по пропускной способности, нужноB_TOPK * 16 > 1024 + B_TOPK * 2, откудаB_TOPK > 73.14. Чтобы оставить достаточный запас,B_TOPKдолжен быть 96 или 128, а это, с одной стороны, делает место в UB ещё теснее, а с другой — усугубляет ограничение по очереди запросов MTE2, упомянутое выше.
Поэтому в итоге мы решили дать каждому CUBE Core по два VECTOR Core, чтобы разделить между ними накладные расходы на выдачу запросов MTE2, деквантование, перенос через FixPipe, softmax и так далее.
Когда выполнять преобразование раскладки KV из ND в NZ?
Заголовок раздела «Когда выполнять преобразование раскладки KV из ND в NZ?»Следующий вопрос: когда выполнять преобразование раскладки KV из ND в NZ?
KV хранится в памяти устройства в формате ND, с формой [s_kv, d_qk] и шагом [d_qk, 1]. CUBE Core, однако,
требует входные матрицы в раскладке NZ. Обычно мы выполняли бы преобразование ND2NZ на лету при переносе данных
из памяти устройства в буфер L1, но наши kv-токены переносятся из памяти устройства на кристалл «разреженно»,
механизмом MTE2 по одному токену за раз, поэтому преобразование ND2NZ на лету включить нельзя.
Одна из идей — при выдаче копирования GM → L1 рассматривать один токен (512 значений bfloat16) как 32 блока
по 32 байта: эти 32 блока идут подряд в памяти устройства, а в буфере L1 раскладываются с шагом B_TOPK * 32B.
Однако последующие эксперименты показали, что BIU внутри AI Core (блок, выдающий инструкции доступа к памяти)
объединяет обращения только тогда, когда и источник, и приёмник расположены подряд. В случае вроде этого,
когда подряд расположен только источник, обращения объединить нельзя, поэтому пропускная способность L2
используется очень плохо и общим узким местом становится именно она.
Поэтому единственный вариант — сначала скопировать KV из памяти устройства в UB, преобразовать его из раскладки ND в раскладку NZ векторной функцией SIMD, а затем скопировать в L1.
Как синхронизировать skip-scale?
Заголовок раздела «Как синхронизировать skip-scale?»В разделе «Напоминание об алгоритме» выше упоминался приём «skip-scale», смысл которого в том, что, пока используемый сейчас для онлайн-softmax максимум не слишком мал по сравнению с истинным максимумом, обновление и накопление выхода на этом круге можно пропустить.
На NPU Huawei эта операция ценна ещё больше, потому что scale-O там неудобен: он требует скопировать O из L0C
в UB через FixPipe и затем прибавить его к out_accum в UB векторной функцией, тратя пропускную способность
FixPipe, UB и SIMD VF.
Однако при реализации этой операции на NPU возникает одна трудность: сигнал «надо ли пропускать скейл на этом круге» вырабатывается VECTOR Core, а потребители у него оба — и CUBE Core, и VECTOR Core (потому что CUBE Core нужен этот сигнал, чтобы решить, очищать ли O в L0C). В итоге мы решили передавать сигнал skip-scale через SS buffer и межъядерные флаги.
Расписание конвейера
Заголовок раздела «Расписание конвейера»На этом этапе общее направление устройства ядра определено: 64 головы на AI Core, схема 1C2V, накопитель выхода в UB, а KV проходит через UB и векторную функцию SIMD для преобразования ND2NZ.
Следующий шаг — проектирование конвейера. Поскольку это ядро должно быть ограничено пропускной способностью CUBE Core, наша цель — всё время держать матричные блоки CUBE Core насыщенными и не давать другим операциям (например, softmax) стать узким местом.
Самая базовая идея конвейера выглядит примерно так (числа в скобках ниже показывают, к какому блоку KV относится операция):
- Вычислить
P[i] = Q @ K[i]^T(@обозначает матричное умножение). - Вычислить
O += S[i-1] @ V[i-1]. - Вычислить
P[i+1] = Q @ K[i+1]^T. - Вычислить
O += S[i] @ V[i]. - …
Иными словами, операция O += S @ V предыдущего блока откладывается, чтобы оставить время на операции softmax:
P[i] готов сразу после завершения (1), но не используется до начала (4), так что в промежутке остаётся вдоволь
времени (около 1024 тактов), чтобы FixPipe скопировал P[i] из L0C в UB, чтобы выполнился softmax и чтобы S[i]
был скопирован из UB в L1.
Чтение индексов (копирование их из памяти устройства в UB), предобработка индексов (подготовка параметров копирования MTE для приёма gather2, описанного ниже), выдача копирований MTE2 и синхронизация сигнала через SS buffer в приёме skip-scale делают этот конвейер несколько сложнее. Сторона CUBE Core следует расписанию выше, а сторона VECTOR Core планируется так:
- Выдать чтение индексов для блока KV
i+5. - Выдать преобразование ND2NZ и копирование MTE3 из UB в L1 для блока KV
i+3. - Дождаться завершения
P[i+1] = Q @ k[i+1]^T, выдать вычисление softmax для блока KVi+1и скопировать результат в L1. - Выдать предобработку индексов для блока KV
i+5. - Выдать масштабирование O для блока KV
i, если для этого блока скейл O не пропускается. - Синхронизироваться с векторной функцией SIMD, получить, нужно ли для блока KV
i+1пропускать скейл O, и записать это в SS buffer. - Выдать копирование MTE2 для блока KV
i+5, перенося KV из памяти устройства в UB.
Почему этот конвейер такой сложный?
- Во-первых, ядро полагается на SS buffer при передаче сигнала skip-scale, но читать и писать SS buffer может только скалярное ядро, тогда как сигнал skip-scale вырабатывается векторной функцией SIMD; это приводит к блокирующей синхронизации между SIMD VF и скалярным ядром. Если не обойтись с этим аккуратно, скалярное ядро будет ждать завершения SIMD VF, а остальные блоки — простаивать.
- Во-вторых, аппаратные конвейеры Huawei (PIPE) выдают и выполняют инструкции по порядку. Например, для
PIPE_Vпорядок, в котором выдаются векторные функции SIMD, должен в точности совпадать с порядком их выполнения, так что если более ранняя векторная функция SIMD не может быть выдана из-за невыполненного условия (например, ещё не выполнено условиеasc_sync_wait), блокируется весь аппаратный конвейер.
Поэтому нам нужно тщательно спроектировать порядок выдачи различных операций, чтобы все аппаратные ресурсы использовались полностью.
flowchart LR subgraph V["VECTOR Core (два на один CUBE)"] V1["чтение индексов (i+5)"] --> V2["ND2NZ + UB→L1 (i+3)"] --> V3["softmax (i+1) → L1"] --> V4["предобработка индексов (i+5)"] --> V5["scale O (i), если не пропущен"] --> V6["флаг skip-scale (i+1) в SS buffer"] --> V7["MTE2: KV (i+5) → UB"] end subgraph C["CUBE Core"] C1["P[i] = Q · K[i]ᵀ"] --> C2["O += S[i-1] · V[i-1]"] --> C3["P[i+1] = Q · K[i+1]ᵀ"] --> C4["O += S[i] · V[i]"] end V3 -. "S[i] в L1" .-> C4 C1 -. "P[i] через FixPipe в UB" .-> V3
Приёмы оптимизации
Заголовок раздела «Приёмы оптимизации»gather2
Заголовок раздела «gather2»Этому ядру нужно читать KV из памяти устройства с точностью до токена. Если бы мы выдавали по одному запросу
копирования MTE2 на каждый kv-токен, общее число запросов копирования было бы слишком велико и превысило бы
глубину очереди выдачи MTE2 (16). Чтобы этого избежать, мы используем приём: ставим у запроса MTE2
burst_count=2, а src_stride — равным разности начальных адресов двух токенов, так что за раз копируются два
kv-токена. Это экономит запросы копирования MTE2 и уменьшает накладные расходы на их выдачу. Параметры
копирования MTE2 вычисляются векторной функцией SIMD, поэтому скалярное ядро не блокируется.
Оптимизация раскладки KV-кэша при decoding
Заголовок раздела «Оптимизация раскладки KV-кэша при decoding»В предыдущей версии Flash MLA каждый блок KV-кэша (KV Block) сначала хранит данные всех токенов блока (то есть квантованные значения FP8 или FP4), а затем масштабы всех токенов. Для Huawei Ascend такое устройство недружелюбно: каждому токену нужны две операции копирования MTE2, чтобы забрать его данные и его масштаб. Поэтому в этом ядре мы размещаем данные и масштаб токена рядом, и копировать данные и масштабы по отдельности больше не требуется.
Быстрый ND2NZ
Заголовок раздела «Быстрый ND2NZ»Как отмечалось выше, для преобразования раскладки KV из ND в NZ нам нужна векторная функция SIMD. Эффективность этой функции напрямую влияет на эффективность всего ядра, особенно на длительность «окна простоя» перед запуском первого матричного умножения QK. Поэтому мы оптимизировали эту векторную функцию так:
- Внутри векторной функции SIMD использовать
asc_loadalignдля чтения KV в формате ND иvsstbдля записи каждого 32-байтного блока данных обратно в UB с определённым интервалом — это и выполняет преобразование ND2NZ. - Дополнить выходной буфер NZ одной лишней строкой, чтобы при обратной записи не было конфликта банков UB.
- Использовать
asc_loadalign_postupdateи возможность «автоинкремента указателя» уvsstb, чтобы убрать накладные расходы на обновление указателя и чтобы маленькое скалярное ядро векторной функции SIMD не стало новым узким местом. - Обрабатывать за раз пару соседних 256-байтных элементов, чтобы полностью задействовать пропускную способность всех банков UB.
В итоге эта векторная функция достигает пиковой пропускной способности чтения и записи UB.
Проблема промахов icache
Заголовок раздела «Проблема промахов icache»У NPU Huawei Ascend относительно небольшой кэш инструкций (icache): всего 32 КБ (CUBE Core), 16 КБ (VECTOR Core)
или 8 КБ (SIMD VF). Поэтому нам нужно держать под контролем общий размер кода, чтобы слишком большой код
не вызывал постоянного вытеснения инструкций из кэша. Полезные приёмы: использовать в векторной функции SIMD
аппаратную инструкцию цикла (VLOOP) вместо развёртывания циклов через #pragma unroll, а также помнить,
что подстановка функций может многократно увеличить размер кода (именно поэтому основной цикл систолического
массива и цикл опустошения были в итоге слиты вместе на стороне VECTOR Core).
Балансировка банков L1
Заголовок раздела «Балансировка банков L1»L1 у NPU Ascend 950 разделён на два банка, которые отвечают соответственно за диапазоны адресов 0–256 КБ и 256–512 КБ. Чтобы полностью использовать пропускную способность этих двух банков, я делю каждый тензор (например, Q и квантованный K) пополам и размещаю половины в разных банках, так что пропускная способность обоих банков всё время насыщена.
skip-scale
Заголовок раздела «skip-scale»У приёма skip-scale много достоинств, в том числе:
- уменьшается объём копирований через FixPipe и объём вычислений векторных функций;
- снижается нагрузка на пропускную способность чтения и записи UB;
- сокращаются вычисления и перемещения данных, а значит, и потребляемая ядром мощность, что, в свою очередь, позволяет поднять частоту и улучшает производительность.
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| sparse attention forward kernel | ядро прямого прохода разреженного внимания |
| pipeline schedule | расписание конвейера |
| compute-bound / memory-bound | ограничено вычислениями / памятью |
| compute-to-memory ratio | отношение вычислений к обращениям в память |
| issue queue | очередь выдачи (запросов) |
| dequantize | деквантовать (восстановить из квантованного формата) |
| burst count / stride | число элементов за одну передачу / шаг |
| layout conversion (ND2NZ) | преобразование раскладки |
| access coalescing | объединение обращений к памяти |
| bank conflict | конфликт банков памяти |
| instruction cache (icache) thrashing | пробуксовка кэша инструкций |
| hardware loop | аппаратный цикл |
| rescale (in online softmax) | пересчёт масштаба (в онлайн-softmax) |
| drain loop | цикл опустошения (конвейера) |
| systolic array | систолический массив |