Маски
Маска в attention указывает, на какие позиции токен может смотреть. Это маленькая деталь, от которой зависит корректность всей модели: неверная маска либо показывает модели ответ при обучении, либо молча портит результат при батчевой обработке, либо превращает выход в NaN.
Что вы узнаете
Заголовок раздела «Что вы узнаете»- Как маска входит в формулу внимания и почему это , прибавленная до softmax.
- Как выглядят causal-маска и маска скользящего окна и какой кусок маски берётся при работе с KV-кэшем.
- Что такое
attention_mask, чем правый паддинг отличается от левого и зачем при левом паддинге сдвигать позиции. - Что из этого поддерживает репозиторий и как паддинг исключается из функции потерь (метки
-100). - Какие ловушки ждут во float16.
Предварительные знания
Заголовок раздела «Предварительные знания»- Механизм внимания: scaled dot-product attention, KV-кэш, скользящее окно.
- Языковое моделирование: предсказание следующего токена и cross-entropy.
- Позиционное кодирование: от позиции зависят обучаемые позиционные эмбеддинги и RoPE.
Три маски репозитория
Заголовок раздела «Три маски репозитория»Перед softmax запрещённым парам (строка — запрос , столбец — ключ ) в матрицу оценок scores записывается , и их веса становятся нулевыми. В репозитории три вида масок.
| Маска | Откуда | Где | Что запрещает |
|---|---|---|---|
| Causal | строится внутри attention (_tril_mask) | все модели | смотреть в будущее: |
| Скользящее окно | строится внутри GroupedQueryAttention, если задан window_size | Mistral, Mixtral | то же и слишком далёкое прошлое: |
attention_mask | передаётся снаружи, [batch, seq_len], 1 — токен, 0 — паддинг | параметр forward и generate всех моделей | смотреть на pad-токены |
Первые две маски зависят только от позиций и одинаковы для всех примеров батча и всех голов. Третья зависит от данных: у каждой строки батча свой паддинг.
Маска как слагаемое перед softmax
Заголовок раздела «Маска как слагаемое перед softmax»Напомним формулу внимания (см. Механизм внимания); маскирование «установкой в всех недопустимых входов softmax» описано ещё в исходной статье трансформера (Vaswani et al., 2017, разд. 3.2.3):
где:
- — оценки сходства запросов и ключей;
- — аддитивная маска;
- — веса внимания, softmax берётся по каждой строке.
Для разрешённого вес равен
где — множество разрешённых для строки ключей. Второе равенство верно потому, что : запрещённые слагаемые пропадают и из числителя, и из знаменателя. Получается softmax только по разрешённым ключам: запрещённые имеют вес ровно 0, разрешённые в сумме дают 1, а значения оценок для запрещённых на результат никак не влияют.
В коде булева маска mask (True — можно) применяется через scores.masked_fill(~mask, float("-inf")): это то же самое, что прибавить .
Почему не 0 и не умножение после softmax
Заголовок раздела «Почему не 0 и не умножение после softmax»Записать 0 вместо — ошибка: оценка 0 означает не «нельзя», а «среднее сходство», и её вес вполне заметен. Запрещённая позиция продолжит влиять на выход.
Умножить веса на маску после softmax (, ) — тоже ошибка, и более тонкая. Возьмём строку 1 из численного примера главы о внимании: оценки , ключ 2 — будущий.
softmax без маски: (0.1978, 0.4011, 0.4011)× маска (1, 1, 0): (0.1978, 0.4011, 0 ) сумма 0.5989правильно (−∞ до softmax): (0.3302, 0.6698, 0 ) сумма 1Во-первых, веса больше не дают в сумме 1. Во-вторых — и это хуже — они зависят от будущего: знаменатель softmax содержит , оценку будущего ключа. Если бы было 3 вместо 0,7071, после умножения на маску получилось бы — другой выход для токена 1 при изменении только будущего токена 2. Модель могла бы извлекать информацию о будущем из общего масштаба выхода — это утечка (leakage).
Если после умножения ещё и перенормировать строку, результат совпадёт с -вариантом:
где сокращается. Но это лишние операции, и считать от запрещённых оценок впустую (а они могут быть огромными и переполниться) нет смысла. Аддитивная — самый простой и численно надёжный способ.
Маска во float16 и строка из одних минус бесконечностей
Заголовок раздела «Маска во float16 и строка из одних минус бесконечностей»В арифметике с плавающей точкой (IEEE 754) представима в любом формате, включая float16, и вычисляется точно. Проблемы начинаются в двух случаях.
1. Строка, где запрещено всё. Если для запроса запрещены все ключи, softmax получает . Реализации softmax вычитают максимум строки для устойчивости, а не определено; в итоге получается :
import torchprint(torch.softmax(torch.tensor([float("-inf")] * 3), dim=-1)) # tensor([nan, nan, nan])NaN в одной строке распространяется дальше: через он попадает в выход этой позиции, при обучении — в градиенты и, после шага оптимизатора, во все веса. Такая строка возникает, например, при левом паддинге: pad-токен на позиции 0 по causal-маске может видеть только себя, а маска паддинга запрещает и это.
2. «Большое отрицательное число» вместо . Чтобы избежать NaN, многие реализации (в том числе HuggingFace Transformers при построении маски) пишут в запрещённые позиции не , а минимальное конечное число типа, torch.finfo(dtype).min. Тогда полностью запрещённая строка даёт не NaN, а равномерное распределение — выход такой позиции бессмысленен, но конечен, и его отбрасывают. Здесь свои ловушки:
- Константа должна помещаться в тип. Максимальное по модулю конечное число float16 — 65504; «универсальное» при переводе во float16 становится , и защита от
NaNпропадает. помещается, и уже в float32. - Две маски, сложенные вместе, переполняются: во float16 . Маски комбинируют логическим «и» или через
masked_fill, а не сложением двух аддитивных масок. - Конечная константа должна быть намного меньше реальных оценок, иначе запрещённая позиция получит ненулевой вес.
В репозитории используется float("-inf"), и это безопасно: и causal-маска, и маска окна всегда разрешают диагональ (), поэтому строк из одних не бывает. Маску паддинга модули attention не применяют (см. ниже), так что проблемы пункта 1 не возникает.
Causal-маска и скользящее окно
Заголовок раздела «Causal-маска и скользящее окно»Causal-маска нужна для обучения на предсказании следующего токена: без неё, предсказывая токен , модель видела бы его во входе. Она накладывается всегда, в том числе с KV-кэшем. Условие:
Для (строки — запросы , столбцы — ключи ; 1 — можно, · — нельзя, то есть ):
causal j=0 j=1 j=2 j=3 j=4 i=0 1 · · · · i=1 1 1 · · · i=2 1 1 1 · · i=3 1 1 1 1 · i=4 1 1 1 1 1Это нижнетреугольная матрица, torch.tril(torch.ones(T, T)). Именно так она строится в конструкторе MultiHeadAttention и MultiQueryAttention и хранится в буфере _tril_mask.
Скользящее окно (Longformer; Mistral 7B, разд. 2) у Mistral/Mixtral дополнительно отсекает далёкое прошлое:
где = window_size. Это позиций вместе с самим токеном (почему — в mistral.md). Для , :
окно W=2 j=0 j=1 j=2 j=3 j=4(репозиторий) i=0 1 · · · · i=1 1 1 · · · i=2 1 1 1 · · i=3 · 1 1 1 · i=4 · · 1 1 1Разрешена полоса шириной вдоль диагонали. Её строит GroupedQueryAttention._create_sliding_window_mask: col <= row (causal) и row - col <= window_size (окно). Без window_size вместо окна подставляется max_seq_len, и маска превращается в обычную causal. Для сравнения, в HuggingFace sliding_window = 2 означает — полосу шириной 2:
окно W=2 j=0 j=1 j=2 j=3 j=4(HuggingFace) i=0 1 · · · · i=1 1 1 · · · i=2 · 1 1 · · i=3 · · 1 1 · i=4 · · · 1 1Поэтому при загрузке весов HF задают window_size = sliding_window − 1.
Срез маски при работе с кэшем
Заголовок раздела «Срез маски при работе с кэшем»Маска хранится для абсолютных позиций (буфер _tril_mask формы [max_seq_len, max_seq_len]), а в конкретном вызове берётся её прямоугольный кусок. Пусть = start_pos — абсолютная позиция первого нового токена, — число новых токенов.
Строки — новые токены, позиции . Столбцы — все ключи, которые есть в этом вызове: из кэша и новые.
Для MultiHeadAttention и MultiQueryAttention кэш содержит все прошлые позиции , поэтому
causal_mask = self._tril_mask[start_pos:start_pos + seq_len, :start_pos + seq_len]то есть строки , столбцы ; форма [T, s + T].
Для GroupedQueryAttention со скользящим окном кэш содержит только последние позиций перед (), то есть позиции . Столбцы среза начинаются с позиции самого старого ключа:
cache_len = k.size(2) - seq_len # c — сколько ключей пришло из кэшаwindow_mask = self._tril_mask[ start_pos:start_pos + seq_len, start_pos - cache_len:start_pos + seq_len]то есть строки , столбцы ; форма [T, c + T]. Без окна , и срез совпадает со срезом MHA.
Пример 1: MHA, кэш из 3 токенов, 2 новых (, ): строки 3–4, столбцы 0–4 полной causal-матрицы.
j=0 j=1 j=2 j=3 j=4 i=3 1 1 1 1 · i=4 1 1 1 1 1Токен 3 не видит токен 4, хотя они пришли в одном вызове, — ради этого маска нужна и с кэшем.
Пример 2: окно , промпт из 3 токенов, затем 2 новых. После prefill кэш обрезан до последних позиций — 1 и 2, . Срез: строки 3–4, столбцы с по 4 матрицы окна:
j=1 j=2 j=3 j=4 i=3 1 1 1 · i=4 · 1 1 1Пример 3: генерация по одному токену с окном (). В кэше последние позиций, и строка среза состоит из одних единиц: вся работа окна сделана обрезкой кэша, а маска лишь подтверждает это. Именно поэтому при генерации по одному токену строка одна и видит всё, что есть в кэше; а при нескольких новых токенах (prefill промпта кусками) срез не даёт им видеть друг друга «вперёд» и даёт каждой строке своё окно.
Проверка: заполнение кэша кусками разной длины со скользящим окном даёт тот же выход, что и прогон всей последовательности сразу (см. пример кода в Механизм внимания).
attention_mask и паддинг
Заголовок раздела «attention_mask и паддинг»Последовательности в батче должны быть одной длины, и короткие дополняют pad-токенами. attention_mask отмечает, где настоящие токены, а где паддинг:
input_ids attention_maskПривет мир ! 1 1 1Да <pad> <pad> 1 0 0 ← правый паддинг<pad> <pad> Да 0 0 1 ← левый паддингФорма attention_mask — [B, T]: одно число на токен. Чтобы превратить её в маску для attention, нужна маска ключей (key padding mask): запрос строки батча может смотреть на ключ , только если — настоящий токен. Вместе с causal-маской:
где — элемент attention_mask. В тензорной форме маска ключей имеет форму [B, 1, 1, T_kv] и по правилам трансляции объединяется с causal-маской [T, T_kv] в [B, 1, T, T_kv] — общую для всех голов.
Правый паддинг
Заголовок раздела «Правый паддинг»Правый паддинг стоит после настоящих токенов, и causal-маска и так не даёт им на него смотреть: для настоящего токена все pad-позиции лежат правее, то есть . Выход для настоящих токенов не зависит от паддинга, позиции настоящих токенов те же, что без паддинга. Маска ключей не нужна.
Pad-позиции сами смотрят на настоящие токены и получают какой-то выход, но он никому не нужен: при обучении loss на pad-позициях отключают метками -100 (см. ниже). Так дополняет батчи коллатор hf-proxy: input_ids — pad-токеном справа, attention_mask — нулями, labels — значением -100.
Проверка на модели GPT:
import torchfrom llm.models.gpt import GPT
torch.manual_seed(0)model = GPT({"vocab_size": 50, "embed_dim": 32, "num_heads": 4, "num_layers": 2, "max_position_embeddings": 32, "dropout": 0.0}).eval()ids = torch.tensor([[5, 6, 7, 0, 0], [5, 6, 7, 8, 9]])mask = torch.tensor([[1, 1, 1, 0, 0], [1, 1, 1, 1, 1]])with torch.no_grad(): padded, _ = model(ids, attention_mask=mask) alone, _ = model(ids[:1, :3])print(torch.allclose(padded[0, :3], alone[0], atol=1e-6)) # TrueЛевый паддинг
Заголовок раздела «Левый паддинг»Левый паддинг нужен для генерации батчем: все строки должны кончаться в одной позиции, чтобы новый токен дописывался сразу после текста. При правом паддинге новый токен короткой строки встал бы после pad-токенов.
Здесь одной causal-маски мало, и нужны две вещи.
1. Маска ключей. Настоящие токены видят паддинг слева: для Да на позиции 2 ключи 0 и 1 — это <pad>, и causal-маска их разрешает. Нужна маска ключей из формулы выше.
2. Сдвиг позиций. Без него Да получает позицию 2 вместо 0, а от позиции зависят позиционные эмбеддинги (GPT) и RoPE (остальные модели): модель увидела бы тот же текст «в другом месте» и выдала бы другой результат. В HuggingFace для этого из маски вычисляются position_ids:
где — позиция токена строки , — элементы attention_mask. В коде это position_ids = attention_mask.cumsum(-1) - 1 — номер токена среди настоящих токенов своей строки. Пример:
attention_mask 0 0 1 1 1cumsum 0 0 1 2 3cumsum − 1 −1 −1 0 1 2 ← настоящие токены получили позиции 0, 1, 2Pad-позиции получают , что недопустимо как индекс таблицы позиций; HuggingFace заменяет их произвольным допустимым значением (их выход всё равно не используется). Для правого паддинга формула тоже работает: 1 1 1 0 0 → 0 1 2 2 2, и позиции настоящих токенов совпадают с обычными .
И ловушка NaN: pad-токен на позиции 0 при левом паддинге не может смотреть ни на кого — causal-маска разрешает только , а маска ключей запрещает . Его строка состоит из одних (см. выше).
Что поддерживается в репозитории
Заголовок раздела «Что поддерживается в репозитории»Паддинг допускается в любом месте строки: слева, справа и в середине. Обе нужные вещи — маску ключей и позиции — строит padding_from_attention_mask(attention_mask, x, start_pos) из core/padding.py. Её вызывают forward всех шести моделей; результат — Padding(key_mask, positions) или None:
attention_mask is None—None.- Форма должна быть
[B, T]без кэша и[B, cache_len + T]с кэшем — как в HF. Иначе —ValueError. С кэшем маска всегда полная, даже из одних единиц: по одним новым токенам нельзя узнать, был ли паддинг в закэшированных. Если бы короткая маска из единиц принималась, pad-токены кэша остались бы незамаскированными, а позиции новых токенов считались бы отstart_pos— логиты молча оказались бы неверными. - Все элементы ненулевые —
None: модели идут прежним путём, побитово с тем же результатом, что и до поддержки паддинга. - Иначе
key_mask = attention_mask != 0по всем слотам (кэш и новые токены) иpositions = (cumsum(mask) − 1).clamp(min=0)для новых токенов: pad-позициям вместо достаётся 0.
Модель передаёт Padding через декодеры в attention (параметр padding). Позиции идут в PositionalEmbeddings (GPT, GPT-2) и RoPE вместо start_pos, start_pos + 1, …, а Padding.apply добавляет маску ключей к causal-маске и окну — получается маска формы [B, 1, T, T_kv], общая для всех голов.
Ловушку NaN Padding.apply обходит так: pad-токен как запрос всегда видит себя. Тогда в каждой строке маски есть хотя бы один разрешённый ключ, и softmax определён. Без этого NaN pad-строки попал бы в выход настоящих токенов через нулевой вес: . Выход pad-позиций смысла не имеет и дальше не используется.
Causal-маска и скользящее окно по-прежнему считаются по столбцам (слотам) последовательности, а не по позициям. Поэтому у моделей с окном (Mistral, Mixtral) нули в середине строки меняют состав окна: pad-слоты занимают место в окне, хотя и замаскированы.
| Маска | forward | generate |
|---|---|---|
None или из одних единиц | ✅ | ✅ |
| правый паддинг | ✅ | ❌ ValueError: генерация продолжилась бы с pad-токена |
| левый паддинг | ✅ | ✅ — батч промптов разной длины; каждая строка даёт то же, что её промпт отдельно |
| нули в середине строки | ✅ | ✅, если последний токен каждой строки настоящий |
| маска вместе с кэшем | ✅ только полная, [B, cache_len + T]; короткая [B, T] — ValueError | — (generate продлевает маску сам) |
Во всех случаях выход настоящих токенов совпадает с прогоном строки без паддинга; это проверяют тесты для всех шести моделей (llm/tests/models/test_attention_mask.py) и сверка с HF (llm/tests/models/test_padding_hf_parity.py: GPT-2, LLaMA, Mistral с окном, Gemma с MQA).
generate проверяет маску промпта функцией check_generation_mask из core/generation.py: форма — как у промпта, последний токен каждой строки — настоящий (иначе ValueError). Дальше маска растёт на единицу с каждым новым токеном и передаётся в forward целиком, вместе с частью кэша; когда последовательность становится длиннее max_seq_len, маска обрезается вместе с ней.
import torchfrom llm.models.gpt import GPT
torch.manual_seed(0)model = GPT({"vocab_size": 50, "embed_dim": 32, "num_heads": 4, "num_layers": 2, "max_position_embeddings": 32, "dropout": 0.0}).eval()ids = torch.tensor([[0, 0, 5, 6, 7], [5, 6, 7, 8, 9]]) # левый паддинг в первой строкеmask = torch.tensor([[0, 0, 1, 1, 1], [1, 1, 1, 1, 1]])batch = model.generate(ids, max_new_tokens=4, do_sample=False, attention_mask=mask)alone = model.generate(ids[:1, 2:], max_new_tokens=4, do_sample=False)print(torch.equal(batch[0, 2:], alone[0])) # TrueМетки -100 и ignore_index
Заголовок раздела «Метки -100 и ignore_index»Маска внимания решает, на что смотрят токены. Отдельный вопрос — за что штрафуют модель: loss на pad-позициях считать нельзя, иначе модель учится предсказывать паддинг, а средний loss зависит от того, сколько паддинга в батче.
Для этого метки (labels) на pad-позициях заменяют на , а в cross-entropy передают ignore_index=-100. Такие позиции исключаются и из суммы, и из числа слагаемых при усреднении:
где:
- — целевой токен (метка) для позиции : после сдвига это следующий токен или ;
- — вероятность этого токена по мнению модели: softmax логитов позиции , которые вычислены по токенам ;
- — позиции с настоящими метками, — их число.
Число выбрано по соглашению: это значение ignore_index по умолчанию в torch.nn.CrossEntropyLoss и F.cross_entropy, и его же использует HuggingFace; настоящим индексом токена оно быть не может.
Пример: три позиции, словарь из двух токенов, логиты , , , метки . Средняя позиция игнорируется, и
В репозитории это Trainer.compute_lm_loss в training/trainer.py: логиты и метки сдвигаются на одну позицию (логит позиции предсказывает метку ), затем F.cross_entropy(..., ignore_index=-100). Метки -100 при паддинге проставляют датасеты llm/datasets (lm_example в datasets/lm_example.py) и HFTokenizerAdapter.pad из hf-proxy. Подробнее о функции потерь — в Обучение.
Итого при правом паддинге работают два независимых механизма: causal-маска гарантирует, что настоящие токены не видят паддинг, а -100 — что паддинг не участвует в loss.
Типичные ошибки и тонкости
Заголовок раздела «Типичные ошибки и тонкости»- Маска после softmax без перенормировки — утечка информации о будущем через знаменатель softmax.
- 0 вместо — запрещённая позиция продолжает получать вес.
- Строка из одних —
NaN. Возникает при левом паддинге (pad-токен в начале), при пустом окне, при ошибке в срезе маски. - Константа во float16 — переполнение до ; сложение двух масок из
finfo.min— тоже. - Срез маски с кэшем. Строки — абсолютные позиции новых токенов, а не ; со скользящим окном столбцы начинаются с .
- Левый паддинг без сдвига позиций — модель видит текст «не на своих местах».
- Паддинг в loss — забытые метки
-100делают loss зависящим от длины паддинга. - Правый паддинг при генерации — новый токен дописывается после
<pad>; поэтомуgenerateтакие маски отклоняет.
- Маска — слагаемое к оценкам перед softmax. Это даёт softmax только по разрешённым ключам; умножение после softmax без перенормировки оставляет утечку из будущего.
- Causal-маска: . Скользящее окно в репозитории: ( позиций), в HuggingFace — позиций.
- При работе с кэшем берётся срез маски: строки , столбцы или при окне.
- Правый паддинг безопасен для настоящих токенов без дополнительной маски; левый требует маски ключей и
position_ids = cumsum(mask) − 1. - Репозиторий поддерживает паддинг в любом месте строки (
core/padding.py): маска ключей, позицииcumsum(mask) − 1, pad-запрос видит себя, чтобы не былоNaN.generateпринимает левый паддинг и отклоняет правый. - Паддинг исключается из loss метками
-100иignore_index=-100. - Строка из одних даёт
NaN; конечные «большие отрицательные» константы должны помещаться в тип данных.
Вопросы и упражнения
Заголовок раздела «Вопросы и упражнения»-
Нарисуйте маску скользящего окна репозитория для , . Сколько позиций видит каждый токен?
Ответ
i=0 1 · · · · ·i=1 1 1 · · · ·i=2 · 1 1 · · ·i=3 · · 1 1 · ·i=4 · · · 1 1 ·i=5 · · · · 1 1Каждый токен, кроме первого, видит позиции: себя и предыдущий.
-
Докажите, что при веса не зависят от оценок запрещённых ключей , а при умножении на маску после softmax (без перенормировки) — зависят.
Ответ
С аддитивной маской — в формуле только разрешённые . С умножением — знаменатель содержит все , включая запрещённые, и для запрещённого при разрешённом .
-
GroupedQueryAttentionсwindow_size = 3обработал промпт из 6 токенов, затем получает 2 новых токена. Чему равныstart_pos,cache_lenи какой срез_tril_maskбудет взят? Выпишите его.Ответ
start_pos = 6, в кэше последние 3 позиции (3, 4, 5),cache_len = 3. Срез: строки 6–7, столбцы = 3–7.j=3 j=4 j=5 j=6 j=7i=6 1 1 1 1 ·i=7 · 1 1 1 1 -
Вычислите
position_ids = attention_mask.cumsum(-1) − 1для масок0 0 0 1 1и1 1 1 1 0. Какие значения имеют смысл?Ответ
0 0 0 1 1→ cumsum0 0 0 1 2→−1 −1 −1 0 1: настоящие токены получили позиции 0 и 1, значения на pad-позициях не используются.1 1 1 1 0→ cumsum1 2 3 4 4→0 1 2 3 3: настоящие токены — обычные позиции 0–3, pad-позиция получила 3, но её выход не нужен. -
Логиты трёх позиций (сдвиг на одну позицию уже сделан) для словаря из двух токенов: , , ; метки . Чему равен loss с
ignore_index=-100? А если бы метки паддинга были не , а 0?Ответ
Учитывается одна позиция: . С метками 0: слагаемые , и , среднее — loss смешан с «предсказанием паддинга» и зависит от его количества.
-
Почему
generateотклоняет правый паддинг, хотяforwardего принимает?Ответ
generateдописывает новый токен в конец каждой строки. При правом паддинге в короткой строке он встанет после pad-токенов, будет смотреть на них (causal-маска это разрешает) и получит позицию, сдвинутую на длину паддинга. Корректная генерация батчем требует левого паддинга со сдвигом позиций — егоgenerateи принимает. -
Во float16 в запрещённые позиции записали
torch.finfo(torch.float16).min, а затем к оценкам прибавили вторую маску с тем же значением. Что получится в позициях, запрещённых обеими масками, и в строке, где всё запрещено?Ответ
переполняется до . Если в строке всё запрещено обеими масками, строка состоит из , и softmax даёт
NaN— ровно то, от чего константа должна была защищать. Маски нужно объединять логически (mask1 & mask2) и применять однимmasked_fill.
Литература
Заголовок раздела «Литература»- Vaswani et al. Attention Is All You Need. 2017. arXiv:1706.03762 — маскирование в декодере (разд. 3.2.3)
- Beltagy, Peters, Cohan. Longformer: The Long-Document Transformer. 2020. arXiv:2004.05150 — sliding window attention
- Jiang et al. Mistral 7B. 2023. arXiv:2310.06825 — скользящее окно и кэш фиксированного размера