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, оно кэширует повторно используемые метаданные и производные от них структуры внимания для каждого устройства для текущего упакованного пакета. Эти кэшированные структуры затем повторно используются во всех слоях.
Почему это помогает
Упакованное обучение уже повышает эффективность использования за счет устранения потерь на заполнение. Но если путь метаданных продолжает принудительно вызывать синхронизацию, часть этого выигрыша теряется из-за накладных расходов, которые не имеют ничего общего с реальным обучением модели.
Кэширование помогает, потому что оно удаляет повторяющуюся работу по координации из критического пути. Прямой проход выигрывает больше всего, потому что именно там одни и те же упакованные метаданные потребляются многократно во многих слоях.
Бенчмарки
На 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, функция потерь и накладные расходы фреймворка. Эта оценка намеренно касается только стороны упакованного внимания блока, а не всего слоя трансформера. Она нужна только для того, чтобы проверить, находятся ли измеренные выигрыши в правильном диапазоне для пути упакованного SDPA.
2. Скрытие задержки с помощью перезагрузки контрольных точек с двойной буферизацией
Контрольные точки активации — это стандартный метод обучения больших моделей. Идея заключается в экономии памяти за счет того, что не все промежуточные активации сохраняются во время обратного прохода. Взамен мы платим некоторой дополнительной работой во время обратного прохода.
Этот компромисс обычно оправдан, особенно для более крупных моделей.
Но это поднимает другой системный вопрос: если активация была выгружена, как она возвращается на GPU для обратного прохода?
В интеллектуальном пути контрольных точек Unsloth активации могут размещаться в закрепленной памяти 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 head, функцию потерь, работу оптимизатора или любую другую часть шага, где не используются контрольные точки. Суть лишь в том, что объем коммуникации, который мы можем скрыть, достаточно велик, чтобы правдоподобно объяснить измеренный прирост времени шага.
3. Меньшая, но полезная оптимизация MoE
Третье изменение более специализированное, но оно демонстрирует ту же закономерность в маршрутизации MoE.
В изученном нами пути GPT-OSS MoE на базе PyTorch одной из дорогостоящих частей маршрутизации является определение того, какие токены направляются к какому эксперту. Наивная реализация может выглядеть примерно так:
На первый взгляд это выглядит безобидно. Но 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 за помощь в наших усилиях по развитию открытого исходного кода, чтобы помочь сообществу, и за помощь с этой статьей. Также спасибо, что читаете и используете Unsloth — мы это ценим. 🙏
Как всегда, обязательно присоединяйтесь к нашей странице на Reddit и серверу Discord за помощью или просто чтобы выразить свою поддержку! Вы также можете следить за нами в Twitter и подписаться на нашу рассылку по адресу: Substack.
Спасибо за прочтение!
Дэниел и Майкл Хан 🦥 6 мая 2026 г.









