Главная/Блог/Гайд/Профилирование PyTorch: Внимание к…
Гайд4 мин чтения · 10 июля 2026 г.

Профилирование PyTorch: Внимание к деталям (Part 3)

Разбираем профилирование механизма внимания в PyTorch: от наивной реализации до оптимизированных бэкендов FlashAttention и SDPA.

Профилирование PyTorch: Внимание к деталям (Part 3)

Добро пожаловать в третью часть нашего углубленного руководства по профилированию в PyTorch. Если вы следили за нашей серией, то уже знаете, как читать трассировки профилировщика и использовать их для оптимизации кода. В первой части мы разобрали базовые математические операции, такие как сложение и умножение, увидев, как таблица профилировщика выявляет «узкие места» (hotspots), а трассировка показывает порядок выполнения алгоритмов во времени. Во второй части мы обернули эти операции в слой `nn.Linear`, собрали многослойный перцептрон (MLP) и профилировали как стандартные, так и вручную настроенные ядра (kernels).

С точки зрения архитектуры Transformer, логичным следующим шагом для профилирования является еще один фундаментальный алгоритм — механизм внимания (attention). Несмотря на свою печально известную квадратичную сложность, существует множество хитрых приемов, которые делают его быстрым. Наша цель здесь — не охватить каждый трюк детально, а показать, как каждый из них выглядит под микроскопом профилировщика. Мы будем использовать GPU для запуска скриптов, но вы можете легко повторить эти эксперименты на инфраструктуре Hugging Face, используя Dev Mode с Spaces.

Общий вид трассировки профилировщика для внимания.
Общий вид трассировки профилировщика для внимания.

01Наивное внимание: разбор по косточкам

Механизм внимания оперирует тремя основными компонентами: Запросами (Queries, Q), Ключами (Keys, K) и Значениями (Values, V). Взаимодействие между ними можно записать как короткую последовательность шагов:
  1. Построение оценок внимания (scores): умножение матриц $Q \cdot K^T$.
  2. Масштабирование оценок: умножение на скаляр $1/\sqrt{d_k}$.
  3. Применение причинной маски (causal mask): замена определенных элементов на $-\infty$, чтобы модель не «видела» будущие токены.
  4. Нормализация с помощью softmax: получение весов внимания (attn).
  5. Перевзвешивание значений: умножение весов внимания на значения $V$.
Таким образом, внимание — это на самом деле коллекция примитивных операций. Некоторые из них мы уже знаем (матричные умножения), остальные легко идентифицировать. Давайте напишем наивный модуль внимания в PyTorch и профилируем его.
terminalpython
class NaiveCausalAttention(nn.Module):
    def __init__(self, head_dim):
        super().__init__()
        self.scale = 1.0 / math.sqrt(head_dim)

    def forward(self, q, k, v, mask):
        scores = torch.matmul(q, k.transpose(-2, -1))
        scores = scores * self.scale
        scores = scores.masked_fill(mask, float("-inf"))
        attn = torch.softmax(scores, dim=-1)
        out = torch.matmul(attn, v)
        return out
Прежде чем открывать трассировку, давайте выполним наше обычное упражнение: попробуем угадать, что мы должны увидеть. Трассируя метод `forward` этого модуля, мы ожидаем увидеть:
  • Ядро matmul (для $Q \cdot K^T$).
  • Ядро mul (для масштабирования).
  • Операцию для маскирования.
  • Ядро softmax.
  • Ядро matmul (для $Attn \cdot V$).
Трассировка CPU для наивной реализации внимания.
Трассировка CPU для наивной реализации внимания.
На рисунке 1 показана дорожка CPU (CPU lane) трассировки профилировщика для наивного внимания (дорожка GPU свернута, чтобы не перегружать изображение). Внутри вызова `attn_fwd` (наш аннотированный метод forward) мы видим именно те операции, которые предсказали. Matmul — это старый знакомый, а новые операции легко различимы:
  • mul: масштабирование.
  • masked_fill: причинная маскировка.
  • softmax: ядро softmax.
Теперь развернем дорожку GPU и посмотрим, какие ядра были фактически запущены.
Трассировка GPU для наивной реализации внимания.
Трассировка GPU для наивной реализации внимания.
На рисунке 2 показана дорожка GPU рядом с дорожкой CPU. Давайте увеличим один блок `attn_fwd` на дорожке GPU, чтобы рассмотреть ядра по одному.
Детализация ядер GPU для наивной реализации.
Детализация ядер GPU для наивной реализации.
На рисунке 3 мы можем прочитать отдельные ядра для одного шага профилировщика:
  1. Matmul (запрос и ключ).
  2. Mul (масштабирование).
  3. Копирование памяти (Memcpy) 🤔.
  4. Причинная маскировка.
  5. Softmax (генерация весов внимания).
  6. Matmul (веса внимания и значения).
Пять из этих операций ожидаемы. Копирование памяти выглядит лишним. Откуда оно берется? Подсказка кроется в том, что в PyTorch есть операции «на месте» (in-place). Когда вы оперируете тензором обычным (внеочередным) способом, PyTorch часто делает копию, применяет запрошенную операцию к ней и возвращает копию. Исходя из последовательности операций, виновником здесь является наш вызов masked_fill, который создает новый тензор.
💡
Совет. Обратите внимание на символ подчеркивания в конце некоторых методов PyTorch (например, masked_fill_). Это стандартная конвенция для операций, изменяющих тензор на месте, без создания новой копии в памяти.

02Наивное внимание с операциями «на месте» (In-place)

Все, что мы меняем, — это заменяем masked_fill на masked_fill_ (обратите внимание на завершающее подчеркивание, конвенцию PyTorch для операций «на месте»), и запускаем тот же скрипт.
terminalpython
def forward(self, q, k, v, mask):
    # q, k, v: [batch, heads, seq, head_dim]
    scores = torch.matmul(q, k.transpose(-2, -1))  # [batch, heads, seq, seq]
    scores = torch.mul(scores, self.scale)
    scores.masked_fill_(mask, float("-inf"))  # In-place!
    attn = torch.softmax(scores, dim=-1)
    out = torch.matmul(attn, v)  # [batch, heads, seq, head_dim]
    return out
Давайте посмотрим на трассировку и увидим, изменилось ли что-то. На рисунке 4 показана трассировка с обычной маскировкой, а на рисунке 5 — с маскировкой «на месте». Версия «на месте» (Рисунок 5) содержит значительно меньше операций CPU внутри шага маскирования по сравнению

Источник: Hugging Face ↗