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

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) и накопитель выхода, посчитанный к этому моменту. Шаги такие:

  1. Разбить индексы top-k на блоки размера B_TOPK (B_TOPK обычно 64 или 96).

  2. Поддерживать три величины: running_max, running_max_for_softmax и running_sumexp — все формы [h_q], инициализированные −∞-\infty, −∞-\infty и 0 соответственно. Кроме того, поддерживать накопитель выхода out_accum формы [h_q, d_v]. Всё это — float32.

  3. Для каждого блока:

    1. Прочитать KV из глобальной памяти разреженно, по indices[B_TOPK * k, B_TOPK * k + B_TOPK], получив gathered_kv этого блока, [B_TOPK, d_qk].
    2. Вычислить P = Q @ gathered_kv^T, [h_q, B_TOPK].
    3. Вычислить максимум P по строке, cur_max, [h_q].
    4. Обновить running_max: running_max = max(running_max, cur_max) (иными словами, running_max всегда отражает текущий максимум по строке).
    5. Проверить, существует ли строка i, для которой running_max[i] - running_max_for_softmax[i] > RESCALE_THRES, где RESCALE_THRES — гиперпараметр, в нашем ядре обычно равный 66.
    6. Если такая строка i нашлась, выполнить обновление онлайн-softmax:
      1. out_accum <- out_accum * exp(running_max_for_softmax - running_max).unsqueeze(-1);
      2. running_sumexp <- running_sumexp * exp(running_max_for_softmax - running_max);
      3. running_max_for_softmax <- running_max;
      4. это равносильно обновлению максимума, который использует онлайн-softmax, так что exp(running_max - running_max_for_softmax) всегда не превосходит exp⁡(6)\exp(6);
      5. если ни одна строка i не вызывает срабатывания условия, этот шаг пропускается. Такая оптимизация называется «skip scale».
    7. Вычислить S = exp(P - running_max_for_softmax), привести S к bfloat16, затем умножить на gathered_kv и прибавить результат к out_accum (out_accum += S.to(torch.bfloat16) @ gathered_kv).
    8. Обновить running_sumexp += S.sum(dim=-1).

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

Сколько q-голов должно приходиться на один AI Core?

Заголовок раздела «Сколько q-голов должно приходиться на один AI Core?»

Первый вопрос: сколько q-голов на один AI Core будет уместно?

Сначала посчитаем отношение вычислений к обращениям в память, считая, что CUBE Core работает на полной пропускной способности (4096 операций «умножить-накопить» за такт на AI Core), а суммарная пропускная способность L2 равна 5 ТБ/с:

d = 512
l2_total_bw = 5e12
num_cores = 32
freq = 1650e6
l2_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= 12457
128, topk= 512, cube_cycles= 16384, mem_cycles= 8305
128, topk=1024, cube_cycles= 32768, mem_cycles= 13841

Как видно, ядро ограничено вычислениями только при числе голов не меньше 64. При 64 головах вычисления и память примерно уравновешены.

При этом брать 128 голов на AI Core означало бы множество проблем с ёмкостью SRAM. А именно, L0C был бы целиком занят O (128×512128 \times 512, float32, 256 КБ), так что накопитель пришлось бы держать на VECTOR Core; а это, в свою очередь, требует двух AIV и мелкозернистого конвейера (копирование с накоплением в конвейере), что писать очень трудно. Кроме того, по результатам микробенчмарков MMAD с M = 64 уже достаточно, чтобы насытить пропускную способность CUBE Core, поэтому 128 голов не нужны.

Всякий раз, когда текущий максимум слишком сильно отличается от используемого сейчас максимума масштабирования, Flash Attention вынужден умножать выход на exp⁡(new_max−old_scaling_max)\exp(\text{new\_max} - \text{old\_scaling\_max}); это и называется «rescale». Однако данные в L0C можно только выгрузить блоком FixPipe или использовать как накопитель в CUBE Core, а умножить их на скаляр на месте нельзя. Возможны два подхода:

  1. Держать накопитель на VECTOR Core, и всякий раз, когда O нужно пересчитать, забирать результат накопления из CUBE Core и прибавлять к текущему накопителю.
  2. Всегда держать накопитель в CUBE Core, а при необходимости пересчёта поднимать его на VECTOR Core, пересчитывать и возвращать обратно в CUBE Core (умножением на единичную матрицу).
    1. Это съедает дополнительную пропускную способность CUBE и вредит производительности. Перенос матрицы 64×512 из UB в L0C требует как минимум одного матричного умножения 64×64×512 во float.
    2. Более того, если не терять точность, режим HF32 нужно выключить; тогда CUBE Core выдаёт лишь 256 операций «умножить-накопить» за такт на AI Core, и умножение 64×64×512 занимает 8192 такта, что совершенно неприемлемо.

Поэтому в итоге мы выбрали первый вариант.

Следующий вопрос — сколько VECTOR Core должно приходиться на один CUBE Core при вычислениях. У каждого AI Core в Huawei Ascend 950 один CUBE Core и два VECTOR Core. Если бы на один CUBE Core хватало одного VECTOR Core (схема 1C1V), второй VECTOR Core мог бы выполнять другую лёгкую работу (например, другие модули модели или обмен данными между устройствами), повышая общую полезную загрузку железа.

После разбора ответ отрицательный, по следующим причинам:

  1. Ограничение по очереди запросов MTE2. На NPU Huawei Ascend 950 один VECTOR Core может иметь не более 16 «выданных, но ещё не завершённых» запросов копирования MTE2 в каждый момент. Даже с приёмом gather2 (см. ниже) одновременно «в полёте» оказывается лишь 32 токена, а этого катастрофически мало.
  2. Ограничение по деквантованию. На стадии decoding ядро должно читать KV-кэш в формате FP8 или FP4, деквантовать его в bfloat16 и подавать в CUBE Core. По микробенчмаркам при 1C1V деквантование не поспевает за CUBE Core и становится узким местом.
  3. Ограничение по 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», смысл которого в том, что, пока используемый сейчас для онлайн-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 относится операция):

  1. Вычислить P[i] = Q @ K[i]^T (@ обозначает матричное умножение).
  2. Вычислить O += S[i-1] @ V[i-1].
  3. Вычислить P[i+1] = Q @ K[i+1]^T.
  4. Вычислить O += S[i] @ V[i].
  5. …

Иными словами, операция 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 планируется так:

  1. Выдать чтение индексов для блока KV i+5.
  2. Выдать преобразование ND2NZ и копирование MTE3 из UB в L1 для блока KV i+3.
  3. Дождаться завершения P[i+1] = Q @ k[i+1]^T, выдать вычисление softmax для блока KV i+1 и скопировать результат в L1.
  4. Выдать предобработку индексов для блока KV i+5.
  5. Выдать масштабирование O для блока KV i, если для этого блока скейл O не пропускается.
  6. Синхронизироваться с векторной функцией SIMD, получить, нужно ли для блока KV i+1 пропускать скейл O, и записать это в SS buffer.
  7. Выдать копирование 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
Расписание конвейера: блок KV i+5 уже читается, пока CUBE Core считает матрицы блока i (схема переводчика по тексту раздела)

Этому ядру нужно читать KV из памяти устройства с точностью до токена. Если бы мы выдавали по одному запросу копирования MTE2 на каждый kv-токен, общее число запросов копирования было бы слишком велико и превысило бы глубину очереди выдачи MTE2 (16). Чтобы этого избежать, мы используем приём: ставим у запроса MTE2 burst_count=2, а src_stride — равным разности начальных адресов двух токенов, так что за раз копируются два kv-токена. Это экономит запросы копирования MTE2 и уменьшает накладные расходы на их выдачу. Параметры копирования MTE2 вычисляются векторной функцией SIMD, поэтому скалярное ядро не блокируется.

В предыдущей версии Flash MLA каждый блок KV-кэша (KV Block) сначала хранит данные всех токенов блока (то есть квантованные значения FP8 или FP4), а затем масштабы всех токенов. Для Huawei Ascend такое устройство недружелюбно: каждому токену нужны две операции копирования MTE2, чтобы забрать его данные и его масштаб. Поэтому в этом ядре мы размещаем данные и масштаб токена рядом, и копировать данные и масштабы по отдельности больше не требуется.

Как отмечалось выше, для преобразования раскладки KV из ND в NZ нам нужна векторная функция SIMD. Эффективность этой функции напрямую влияет на эффективность всего ядра, особенно на длительность «окна простоя» перед запуском первого матричного умножения QK. Поэтому мы оптимизировали эту векторную функцию так:

  • Внутри векторной функции SIMD использовать asc_loadalign для чтения KV в формате ND и vsstb для записи каждого 32-байтного блока данных обратно в UB с определённым интервалом — это и выполняет преобразование ND2NZ.
  • Дополнить выходной буфер NZ одной лишней строкой, чтобы при обратной записи не было конфликта банков UB.
  • Использовать asc_loadalign_postupdate и возможность «автоинкремента указателя» у vsstb, чтобы убрать накладные расходы на обновление указателя и чтобы маленькое скалярное ядро векторной функции SIMD не стало новым узким местом.
  • Обрабатывать за раз пару соседних 256-байтных элементов, чтобы полностью задействовать пропускную способность всех банков UB.

В итоге эта векторная функция достигает пиковой пропускной способности чтения и записи UB.

У NPU Huawei Ascend относительно небольшой кэш инструкций (icache): всего 32 КБ (CUBE Core), 16 КБ (VECTOR Core) или 8 КБ (SIMD VF). Поэтому нам нужно держать под контролем общий размер кода, чтобы слишком большой код не вызывал постоянного вытеснения инструкций из кэша. Полезные приёмы: использовать в векторной функции SIMD аппаратную инструкцию цикла (VLOOP) вместо развёртывания циклов через #pragma unroll, а также помнить, что подстановка функций может многократно увеличить размер кода (именно поэтому основной цикл систолического массива и цикл опустошения были в итоге слиты вместе на стороне VECTOR Core).

L1 у NPU Ascend 950 разделён на два банка, которые отвечают соответственно за диапазоны адресов 0–256 КБ и 256–512 КБ. Чтобы полностью использовать пропускную способность этих двух банков, я делю каждый тензор (например, Q и квантованный K) пополам и размещаю половины в разных банках, так что пропускная способность обоих банков всё время насыщена.

У приёма skip-scale много достоинств, в том числе:

  • уменьшается объём копирований через FixPipe и объём вычислений векторных функций;
  • снижается нагрузка на пропускную способность чтения и записи UB;
  • сокращаются вычисления и перемещения данных, а значит, и потребляемая ядром мощность, что, в свою очередь, позволяет поднять частоту и улучшает производительность.
ENRU
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систолический массив