6 мая 2026 г.
6 мая 2026 г.
Авторы: Дэниел, Майкл, Мэтью и Датта при поддержке NVIDIA
Мы объединились с NVIDIA, чтобы сделать обучение LLM примерно на 25% быстрее, и в этом блоге/руководстве мы подробно разберем, как именно мы это сделали. Эти оптимизации не приводят к потере точности и являются дополнительным улучшением к уже существующему ускорению Unsloth в 2–5 раз! Новые алгоритмы автоматически включаются на ноутбуках с RTX, графических процессорах для дата-центров и машинах DGX Spark, поэтому просто обновите Unsloth, чтобы получить последние улучшения. Работая с NVIDIA, мы показываем, как:
- Кэширование метаданных упакованных последовательностей ускоряет обучение на 14,3%.
- Использование асинхронного чекпоинтинга градиентов с двойной буферизацией дает прирост скорости на 8%.
- Обучение gpt-oss ускоряется на 15% за счет использования argsort и bincount во время маршрутизации MoE.
Предположим, у нас есть несколько коротких примеров:
Вместо того чтобы дополнять их все до одинаковой длины и тратить вычислительные ресурсы на токены дополнения (padding), мы объединяем их в одну более длинную упакованную последовательность:
Модель все равно должна знать, где начинается и заканчивается каждая исходная последовательность. Поэтому вместе с упакованными токенами мы передаем метаданные последовательности, такие как:
- длины последовательностей
- кумулятивные смещения последовательностей (cu_seqlens)
- максимальная длина последовательности
- структура внимания, производная от трех вышеперечисленных элементов
Это ключевой момент: для фиксированного упакованного батча эти метаданные одинаковы для каждого слоя.
Если мы запишем информацию о границах для упакованного батча как:
B = { длины, cu_seqlens, max_seqlen, структура маски }
то каждый слой трансформера в этом прямом проходе использует одно и то же B.
Если в модели L слоев, то пересборка или повторная синхронизация B один раз на слой не является новой работой. Это та же самая информация, которая реконструируется снова и снова.
Другими словами, полезная работа заключается в следующем:
построить B один раз, использовать его L раз.
Расточительный вариант выглядит так:
построить B + построить B + ⋯ + построить B (L раз)
Накладные расходы здесь связаны не столько с дополнительными FLOPs. Некоторые из этих путей могут принудительно вызывать синхронизацию устройства с хостом, фактически создавая точку синхронизации GPU-CPU. Как только это происходит внутри пути для каждого слоя, накладные расходы повторяются на каждом слое.
Именно это и уменьшает изменение кэширования упакованных последовательностей. Вместо многократной реконструкции информации об упакованной последовательности, упакованных масок SDPA и блочных масок xFormers, оно кэширует повторно используемые метаданные и производные от них структуры внимания для каждого устройства для текущего упакованного батча. Эти кэшированные структуры затем повторно используются во всех слоях.
Почему это помогает
Упакованное обучение уже улучшает использование ресурсов за счет устранения потерь на дополнение (padding). Но если путь метаданных продолжает принудительно вызывать синхронизацию, часть этого выигрыша теряется из-за накладных расходов, которые не имеют ничего общего с реальным обучением модели.
Кэширование помогает, потому что оно удаляет повторяющуюся работу по координации из критического пути. Прямой проход выигрывает больше всего, потому что именно там одни и те же упакованные метаданные потребляются многократно во многих слоях.
Бенчмарки
На Qwen3-14B QLoRA SFT:
- прямой проход: +43,3%
- обратный проход: +5,8%
- на батч: +14,3%
Прямой проход получает наибольшую выгоду, поскольку повторяющиеся метаданные и подготовка масок проявляются там наиболее непосредственно. Обратный проход также улучшается, но эффект меньше. Сэкономленное время аналогично, но обратный проход, особенно с чекпоинтингом градиентов, занимает больше времени, поэтому относительные выигрыши кажутся меньше.
Теперь, когда мы знаем измеренный прирост, мы можем задать более простой вопрос: имеет ли смысл такой масштаб?
Быстрая проверка
Если предположить, что каждый слой примерно одинаков, мы можем представить путь упакованного внимания как:
T_uncached ≈ L · (A + s)
где:
- L — количество слоев,
- A — полезная работа со стороны внимания на слой,
- s — повторяющиеся накладные расходы на метаданные и подготовку масок на слой.
С кэшированием эти повторяющиеся накладные расходы оплачиваются один раз для батча, а не один раз на слой:
T_cached ≈ L · A + s
Таким образом, сэкономленное время составляет примерно:
T_saved ≈ (L − 1) · s
Для пути упакованного SDPA наш микробенчмарк на графических процессорах NVIDIA Blackwell показал, что низкоуровневые вызовы метаданных, видимые хосту, были реальными, но небольшими, около 0,2 мс каждый. Основной повторяющейся стоимостью был сам путь построения упакованной маски SDPA, который составил около 13,7 мс для синтетического упакованного батча с 2048 токенами.
Для бэкенда SDPA лучшая ментальная модель:
малый барьер потока + пересборка маски ≈ пересборка маски
Это позволяет нам провести более чистую проверку согласованности. Если одна пересборка упакованной маски стоит m миллисекунд, то в модели с равномерными слоями:
T_saved ≈ (L − 1) · m
При m ≈ 13,7 мс это предсказывает:
- 16 слоев: (16 - 1) x 13,7 ≈ 206 мс
- 28 слоев: (28 - 1) x 13,7 ≈ 370 мс
Запуски с меньшими упакованными последовательностями показали ту же закономерность:
- Llama-3.2-1B, 16 слоев: около 199 мс сэкономлено на шаг, что примерно на 11,5% меньше общего времени шага
- Qwen3-0.6B, 28 слоев: около 319 мс сэкономлено на шаг, что примерно на 14,8% меньше общего времени шага
Эти проценты относятся к полному времени шага обучения, поэтому они все еще включают работу вне пути упакованного внимания, такую как эмбеддинги, MLP, LM head, функция потерь и накладные расходы фреймворка. Эта оценка намеренно касается только стороны упакованного внимания блока, а не всего слоя трансформера. Она приведена только для того, чтобы проверить, что измеренные выигрыши находятся в правильном диапазоне для пути упакованного SDPA.
2. Скрытие задержки с помощью перезагрузки чекпоинтов с двойной буферизацией
Чекпоинтинг активаций — это стандартный метод обучения больших моделей. Идея заключается в экономии памяти за счет того, что не все промежуточные активации сохраняются во время обратного прохода. Взамен мы платим за некоторую дополнительную работу во время обратного прохода.
Этот компромисс обычно оправдан, особенно для больших моделей.
Но это поднимает еще один системный вопрос: если активация была выгружена, как она возвращается на GPU для обратного прохода?
В интеллектуальном пути чекпоинтинга Unsloth активации могут размещаться в закрепленной (pinned) памяти CPU и копироваться обратно при необходимости. Это экономит VRAM, но может создать узкое место:
- Копирование активации из CPU в GPU.
- Ожидание завершения копирования.
- Запуск обратных вычислений для этой активации.
- Запуск следующего копирования.
Это шаблон сериализации. Если один буфер используется и для копирования, и для вычислений, поток копирования и поток вычислений постоянно сменяют друг друга.
Пусть T_copy — время перезагрузки активации, а T_compute — время обратных вычислений для текущего слоя.
С одним буфером эта часть шага примерно ограничена:
T_single ≈ T_copy + T_compute
Это сериализованный случай. Мы платим за оба почти полностью, один за другим.
Более чистый способ справиться с этим — использовать два буфера.
Пока обратный проход выполняется на буфере A, поток копирования может предварительно загрузить следующую активацию в буфер B. Затем роли меняются. Это создает перекрытие конвейера, хотя и не идеальное.
Двойная буферизация не уменьшает объем вычислений. Она скрывает задержку копирования за полезными вычислениями.
Почему это помогает
Этот тип оптимизации становится сильнее, как только модель становится достаточно большой, чтобы обратные вычисления были существенными, но не настолько доминирующими, чтобы все накладные расходы на копирование исчезли в шуме. Для больших моделей более высокие скрытые размерности означают большее перемещение данных, поэтому скрытие этого перемещения оказывает большее влияние. Большие модели также, как правило, имеют больше слоев, что создает больше возможностей для скрытия копирования за вычислениями.
Именно поэтому более крупные плотные модели хорошо подходят для этого улучшения. На GPU выполняется достаточно реальной работы, чтобы копирование могло перекрываться с ней, а дополнительный объем VRAM, необходимый для второго буфера, остается небольшим.
Реализация также сохраняет практические защитные механизмы:
- использовать дополнительные буферы только при наличии достаточного объема VRAM
- корректно переключаться на стандартный режим при ограниченном бюджете памяти
- сохранять корректность вычислений неизменной
Бенчмарки
На запусках более крупных плотных моделей, протестированных на GPU NVIDIA B200 Blackwell:
- 8B: 0.3739 -> 0.4053 шагов/с, +8.40%
- 14B: 0.2245 -> 0.2395 шагов/с, +6.70%
- 32B: 0.1979 -> 0.2070 шагов/с, +4.61%
Расход памяти остался небольшим:
- +0.37 ГБ для 8B
- +0.47 ГБ для 14B
- +0.23 ГБ для 32B
В этих запусках итоговые значения функции потерь практически не изменились.
Ускорение стабильно для более крупных плотных моделей, а дополнительные затраты VRAM остаются относительно небольшими.
Как только мы узнаем измеренный прирост, возникает естественный вопрос: оправдан ли такой масштаб?
Краткая проверка на здравый смысл
Если предположить, что есть L слоев с чекпоинтами и каждый слой примерно одинаков:
- каждая перезагрузка занимает время c
- каждый блок обратного прохода занимает время g
Это также масштабируется с размером батча, длиной последовательности и другими факторами, влияющими на перемещение данных и вычисления. Мы опускаем эти члены для краткости.
С одним буфером:
T_single ≈ L · (c + g)
С двумя буферами первый слой все еще должен ждать прибытия своих активаций, а последний слой все еще должен завершить вычисления. Поэтому лучшее приближение:
T_double ≈ c + (L − 1) · max(c, g) + g
Таким образом, сэкономленное время составляет примерно:
T_saved ≈ (L − 1) · min(c, g)
Это полезная интерпретация результата:
- первое копирование все еще остается открытым
- последнее вычисление все еще остается открытым
- но для середины конвейера копирование и вычисления могут перекрываться
Если перекрытие хорошее, стоимость одного слоя в середине становится намного ближе к:
T_middle ≈ max(T_copy, T_compute)
Исходя из измеренных результатов для более крупных моделей, сэкономленное время на один шаг обучения составляет примерно:
- 8B: около 207 мс
- 14B: около 279 мс
- 32B: около 222 мс
Эти хост-буферы являются закрепленными (pinned) выделениями памяти, поэтому релевантной пропускной способностью является измеренная пропускная способность закрепленной памяти хост-устройство, а не пропускная способность страничной памяти. В нашей системе на базе NVIDIA B200 Blackwell эта пропускная способность составляла около 55.7 ГБ/с, при этом 64 ГБ/с является полезным пределом PCIe для сравнения.
Если мы используем размер дополнительного буфера как грубый прокси для одной перезагрузки активаций, то каждая перезагрузка естественным образом занимает всего несколько миллисекунд:
- 8B, 0.37 ГБ: около 6.6 мс при 55.7 ГБ/с, или 5.8 мс при пределе 64 ГБ/с
- 14B, 0.47 ГБ: около 8.4 мс при 55.7 ГБ/с, или 7.3 мс при пределе 64 ГБ/с
- 32B, 0.23 ГБ: около 4.1 мс при 55.7 ГБ/с, или 3.6 мс при пределе 64 ГБ/с
Чтобы объяснить наблюдаемое сэкономленное время на шаг, нам нужно скрыть примерно несколько десятков таких перезагрузок:
- 8B: около 31 перезагрузки при 55.7 ГБ/с, или 36 при 64 ГБ/с
- 14B: около 33 перезагрузок при 55.7 ГБ/с, или 38 при 64 ГБ/с
- 32B: около 54 перезагрузок при 55.7 ГБ/с, или 62 при 64 ГБ/с
Скрытие одной такой перезагрузки на протяжении нескольких десятков слоев с чекпоинтами дает экономию времени шага в диапазоне нескольких сотен миллисекунд, что в точности соответствует наблюдаемому нами масштабу.
Опять же, это сэкономленное время является частью полного сквозного шага обучения. Оно не должно объяснять эмбеддинги, LM-голову, функцию потерь, работу оптимизатора или любую другую часть шага, где нет чекпоинтов. Суть лишь в том, что коммуникация, которую мы можем скрыть, достаточно велика, чтобы правдоподобно объяснить измеренный прирост времени шага.
3. Меньшая, но полезная оптимизация MoE
Третье изменение более специализированное, но оно демонстрирует ту же закономерность в маршрутизации MoE.
В изученном нами пути MoE на базе PyTorch (GPT-OSS) одной из затратных частей маршрутизации является определение того, какие токены к какому эксперту направляются. Наивная реализация может выглядеть примерно так:
На первый взгляд это выглядит безобидно. Но torch.where здесь зависит от данных: количество токенов, направляемых каждому эксперту, меняется от батча к батчу. Это может привести к синхронизации CPU-GPU или связанным накладным расходам среды выполнения, поскольку размеры вывода зависят от шаблона маршрутизации. Если это происходит один раз на эксперта, количество динамических запросов масштабируется с num_experts.
Лучший подход — сгруппировать все один раз:
- Выровнять все назначения экспертов.
- Выполнить стабильную сортировку по ID эксперта.
- Использовать bincount один раз, чтобы получить количество токенов на эксперта.
- Построить смещения на основе этих подсчетов.
- Нарезать сгруппированный список токенов по экспертам.
Математически выигрыш не в том, что мы изменили логику маршрутизации. Мы изменили то, как часто мы просим среду выполнения ответить на вопрос о динамической индексации.
Вместо примерно:
накладные расходы на динамический запрос ∝ num_experts
поскольку мы делаем один динамический запрос на эксперта, мы переходим к:
накладные расходы на динамический запрос ∝ 1
плюс дешевый учет после этого.
Это та же тема в более специализированной обстановке: сгруппировать один раз, затем повторно использовать смещения вместо того, чтобы постоянно запрашивать списки динамических токенов.
Бенчмарки
Обратите внимание, что эти оптимизации применимы к любой MoE, использующей бэкенд native_torch.
Для этого улучшения маршрутизации, специфичного для GPT-OSS:
- валидация команды показала ускорение примерно на 10-15% на конфигурациях GPT-OSS
- в целевом пути маршрутизации мы увидели +23% на прямом проходе и +13% на обратном
Что общего у этих изменений
Несмотря на то, что эти три оптимизации находятся в разных частях стека, они решают одну и ту же проблему.
Ключевые возможности для оптимизации находились в связующем коде вокруг основных ядер:
- пересборка метаданных, которые уже существуют
- синхронизация по информации, которую мы могли бы кэшировать
- допущение сериализации копирования и вычислений вместо их перекрытия
Именно поэтому улучшения концептуально дополняют друг друга. По мере того как основные ядра становятся быстрее, накладные расходы, которые раньше были незаметны, начинают составлять значительную часть общего времени шага.
Здесь есть полезный инженерный урок. Как только математические ядра оптимизированы, «быстрее» часто означает одно из двух:
- делать меньше ненужной работы
- делать неизбежную работу параллельно
Это именно то, что здесь произошло.
TL;DR
💕 Спасибо!
Огромное спасибо NVIDIA за помощь в наших open-source инициативах на благо сообщества и за помощь с этой статьей. Также спасибо, что читаете и используете Unsloth — мы это ценим. 🙏
Как всегда, обязательно присоединяйтесь к нашей странице на Reddit и серверу в Discord за помощью или просто чтобы выразить свою поддержку! Вы также можете следить за нами в Twitter и подписаться на нашу рассылку на: Substack.
Спасибо за прочтение!
Дэниел и Майкл Хан 🦥 6 мая 2026 г.
