Линейное внимание обещает то, чего не умеет классический трансформер: стоимость, которая растёт вместе с длиной контекста прямо пропорционально, а не по квадрату. На бумаге Gated Delta Networks, или GDN, выполняют это обещание. На практике префилл на 128 тысячах токенов всё равно остаётся тяжёлым этапом, и каждый процент времени на нём превращается в деньги на GPU. В конце октября вышла работа SparseGDN, авторы которой предлагают не оптимизировать ядро, а пропускать часть работы: сохраняется в среднем около 29 процентов рекуррентных событий, а ядро GDN на префилле работает в 1,50-1,94 раза быстрее нативных ядер FLA, с учётом накладных расходов на онлайн-селекцию. Проверка шла на Qwen3.5-4B и Qwen3.5-9B, на тестах RULER от 4 тысяч до 128 тысяч токенов. Ниже разобрано, из чего складывается это ускорение, что показывают замеры, когда выбрасывание семидесяти процентов обновлений не мешает искать иголку в стоге сена и как проверить и встроить такой селектор в собственный конвейер на базе FLA.

Чем дорог префилл в Gated Delta Networks, если сама архитектура считается линейной

Сначала стоит уточнить формулировку, потому что её часто упрощают. Стоимость GDN по длине последовательности действительно линейна, квадратичной она не бывает. Но префилл идёт по блокам фиксированного размера, чанкам, и внутри каждого чанка работа устроена плотно. Авторы выделяют три источника затрат: плотные вычисления внутри чанка, обновления рекуррентного состояния и хранение промежуточных состояний.

Первый источник - внутричанковое решение Delta-системы. Для каждого чанка нужно учесть, как токены внутри него влияют друг на друга при дельта-правиле, и это операция с квадратичной зависимостью от размера чанка. Второй источник - сам проход по чанкам: состояние обновляется последовательно, от чанка к чанку, отдельно для каждой головы. Третий - промежуточные состояния. Чтобы посчитать выходы токенов внутри чанка, нужно знать состояние на его начало, а значит, эти состояния записываются в память и читаются обратно. На длинном контексте лишний трафик памяти оказывается не мелочью, а заметной частью времени.

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

Двухуровневая разреженность SparseGDN, которая режет локальное решение и пропускает рекуррентные обновления

Авторы исходят из двух наблюдений. Первое касается внутричанковых коэффициентов: по их описанию, значимые связи сосредоточены локально, то есть между близкими токенами, а далёкие значат меньше. На этом строится блочно-смежный решатель, который сокращает вычисление Delta-системы внутри чанка. Он не решает задачу полностью для всей матрицы, а ограничивается связями между соседними блоками. Это структурированное приближение, и оно считается детерминированным, то есть не зависит от входа.

Второе наблюдение касается состояния между чанками: оно неоднородно. Не все чанки одинаково меняют память модели, и не все обновления доживают до момента, когда понадобятся выходам. Поэтому второй уровень разреженности зависит от входа: динамический селектор выбирает, какие рекуррентные обновления оставить. Единицей выбора служит событие на уровне пары голов и чанка. Если событие не выбрано, обновление пропускается, а состояние просто затухает по правилам гейта.

Важная деталь для практиков: обученные параметры не трогаются. Метод применяется к готовой модели без дообучения, а входозависимая разреженность включается только на этапе префилла. Значит, проверить его можно на уже имеющихся чекпойнтах без обучающего конвейера. Вторая деталь - выходы. SparseGDN пропускает часть обновлений состояния, но выходы производит для каждого токена, поэтому интерфейс слоя для остальной модели не меняется.

Селектор бюджета по парам голов и компактные снапшоты состояния, благодаря которым выходы остаются у каждого токена

Селектор устроен как двухступенчатое распределение бюджета. В каждом слое по описанию авторов шестнадцать пар голов. Для каждого чанка в паре вычисляется оценка важности, и она задаёт порядок удержания внутри этой пары. Затем распределяется бюджет между парами. Дополнительные оценки, которые получает пара с большим бюджетом, взвешиваются головными оценками двух свойств: устойчивости рекуррентной памяти и видимости для выходов. Иначе говоря, пара, у которой записанное долго живёт и сильно влияет на ответы, получает больше права на обновления.

Бюджет делится по уровням удержания. В конфигурации по умолчанию упоминаются три значения доли сохраняемых событий: 0,08, 0,30 и 0,95. Сначала назначаются пары с высоким удержанием, затем из оставшихся пары со средним, остальные получают низкое. Для ранжирования чанков используются нормированные оценки, а при распределении бюджета между парами берутся исходные величины, чтобы не стереть различия в силе между головами. Дополнительно в схеме есть якоря, квоты допустимых чанков и правило разрешения ничьих. Усреднённая доля сохранённых событий при такой конфигурации составляет около 29 процентов.

На стороне GPU реализация материализует только выбранные события. Рекуррентное ядро проходит по сохранённым событиям, суммирует затухание по пропущенным чанкам и сохраняет компактные снапшоты состояния. По этим снапшотам затем восстанавливаются выходы токенов, в том числе в чанках, обновления которых были пропущены. Благодаря этому уменьшается не только объём вычислений, но и поток промежуточных состояний через память.

Что показывают замеры на Qwen3.5 и где проходит граница между ускорением и потерей точности

Ускорение измерялось на ядре GDN. Для Qwen3.5-4B оно составляет от 1,50 до 1,94 раза, для Qwen3.5-9B от 1,50 до 1,93 раза на длинах от 4 тысяч до 128 тысяч токенов. В эти цифры входят накладные расходы онлайн-селекции, что для подобных методов важно: многие способы, которые на графике выглядят быстрыми, не включают стоимость выбора того, что пропускать.

Теперь точность. Оценка шла по RULER, на 7800 примерах на модель по шести длинам контекста, по официальным правилам подсчёта. Для Qwen3.5-9B агрегатный балл изменился с 96,617 до 96,502 процента, то есть на минус 0,115 процентного пункта. Авторы формулируют вывод осторожно: сопоставимое качество в агрегате при стабильном ускорении ядра. Это хороший результат, но он требует внимательного чтения.

Если пойти дальше и снизить среднюю долю сохранённых событий примерно до 9,8 процента, ускорение вырастает примерно до 2,1 раза, а агрегатный балл падает на 2,063 пункта для Qwen3.5-4B и на 2,281 для Qwen3.5-9B. Отсюда видна кривая убывающей отдачи: втрое меньше событий даёт лишь небольшую прибавку к скорости, а потеря точности для Qwen3.5-9B вырастает примерно в двадцать раз, с 0,115 до 2,281 пункта. Часть времени уходит на неизменяемые куски: внутричанковую работу, селекцию и расчёт выходов, и их разреженность не убирает.

Есть ограничение, о котором легко забыть. Ускорение относится к ядру GDN, а не ко всему префиллу модели. Qwen3.5 использует слои GDN вместе с другими блоками, поэтому прирост времени до первого токена будет меньше. Оценить его заранее помогает простая формула:

def end_to_end_speedup(core_share, core_speedup):
    """core_share: доля времени префилла, которую занимает ядро GDN."""
    return 1.0 / ((1.0 - core_share) + core_share / core_speedup)

# условный пример: ядро GDN занимает половину префилла,
# а само оно ускорено в 1,7 раза
print(round(end_to_end_speedup(0.5, 1.7), 2))   # 1.26

Цифры в примере условные. Реальную долю ядра нужно измерить на своей модели и своей длине контекста, и только после этого говорить о выигрыше в деньгах.

Когда выбрасывание 70 процентов обновлений не ломает поиск иголки в стоге сена, а когда стоит насторожиться

Главный вопрос инженера звучит так: что случится с задачами, где модель обязана вытащить одну спрятанную деталь из длинного текста. Прямо на него отвечают только таблицы по отдельным задачам, а агрегат лишь намекает. Поэтому разумно сначала понять, почему метод вообще может работать, и только затем решать, где ему верить.

Первая причина - затухание. Гейты в GDN постепенно ослабляют вклад старых записей, и многие обновления в далёких чанках оказываются почти невидимыми для будущих выходов. Это ровно то, что авторы называют неоднородностью состояния между чанками. Вторая причина - устойчивость. Селектор учитывает, какие пары голов хранят записанное долго и сильно влияют на ответы, и именно им оставляет бюджет почти полностью, тогда как остальным хватает малой доли. Третья - якорные чанки и квоты, которые не дают отбору полностью забыть нужные участки. Четвёртая - выходы: токены в пропущенных чанках получают результат по снапшотам, а не остаются без ответа.

Теперь о том, где насторожиться. Это гипотезы, а не выводы из работы, и проверять их нужно на своих данных. Первая: задачи с множеством равнозначных фактов, например, перечисление или подсчёт по всему тексту, где важны почти все чанки. Если все чанки одинаково значимы, отбору нечего отбрасывать, и либо ускорение исчезнет, либо точность упадёт. Вторая: самые длинные контексты, на которых бюджет на голову становится совсем малым. Третья: агрессивные настройки удержания. Минус два пункта в агрегате при 9,8 процента удержания могут скрывать провал в одном типе задач и почти ноль в остальных. Четвёртая: данные, непохожие на тест, например, структурированные логи или код, где оценки важности могут вести себя иначе.

Отдельно стоит напомнить про статистику. Разница в 0,115 пункта на 7800 примерах лежит в пределах шума одного прогона, ориентировочно порядка 0,2 пункта. А если в вашей проверке по сто примеров на клетку, где клетка это пара из задачи и длины, шум составит около трёх пунктов при точности около 90 процентов. Поэтому для needle-задач нужны сотни примеров на клетку и парное сравнение на одних и тех же входах.

Схема встраивания селектора в собственный пайплайн на FLA с проверкой на своих данных

На момент написания готовую публичную реализацию SparseGDN найти не удалось, поэтому ниже описана схема работ, а не инструкция к конкретной библиотеке. В FLA чанковый GDN разделён на этапы: локальная часть внутри чанка, рекуррентный проход по чанкам (за него отвечает ядро chunk_delta_h) и расчёт выходов. Ядро chunk_delta_h и слой chunk для gated delta rule часто берут за основу при доработках: например, NVIDIA Model Optimizer держит адаптированные копии этих ядер, чтобы квантовать рекуррентное состояние. Тот же приём подходит и здесь: копия ядра с изменённым проходом по чанкам, а не правка оригинала. Порядок работ такой:

  1. Зафиксировать базовую линию: прогнать нативные ядра FLA на своих длинах контекста, отдельно записать время ядра GDN и эталонные выходы;
  2. Измерить, какую долю префилла занимает ядро GDN в вашей модели, чтобы по формуле выше оценить, сколько из ускорения ядра дойдёт до времени первого токена;
  3. Реализовать оценку важности чанков по парам голов отдельным ядром, чтобы стоимость селекции измерялась независимо от остального;
  4. Вынести уровни удержания (у авторов 0,08, 0,30 и 0,95) в конфигурацию и добавить якорные чанки и квоты, не вшивая значения в код;
  5. Адаптировать рекуррентное ядро: проходить только по выбранным событиям, накапливать затухание по пропущенным чанкам и сохранять снапшоты состояния для восстановления выходов;
  6. Сверить результат с нативным ядром при удержании 100 процентов: выходы должны совпадать с эталоном в пределах численной погрешности;
  7. Прогнать развёртку по бюджету и по типам задач, определить допустимую потерю для каждого типа отдельно и только после этого включать метод в рабочий контур.

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

def scan_with_skips(S0, decay, A, B, keep):
    """Рекуррентный проход по чанкам с пропуском обновлений.
    A, B описывают полное обновление чанка: S -> A @ S + B.
    keep[c] == False означает, что в чанке состояние только затухает."""
    S, snapshots = S0, []
    for c in range(len(keep)):
        snapshots.append(S)          # состояние на начало чанка нужно выходам
        S = A[c] @ S + B[c] if keep[c] else decay[c] * S
    return S, snapshots

В настоящем ядре подряд идущие пропуски не обходят по одному: затухание за них складывается в одно значение, а снапшоты сохраняются только там, где они нужны. Для проверки на своих данных достаточно простой развёртки:

def sweep(run_eval, tasks, lengths, keep_rates):
    base = {(t, n): run_eval(t, n, keep=None) for t in tasks for n in lengths}
    rows = []
    for k in keep_rates:
        for t in tasks:
            for n in lengths:
                delta = run_eval(t, n, keep=k) - base[(t, n)]
                rows.append((k, t, n, delta))
    return rows   # смотреть разницу по каждой задаче и длине, а не только среднюю

Правило приёмки лучше записать заранее: допустимая потеря отдельно для каждого типа задач, а для поиска иголки отдельный, более жёсткий порог.

Ограничения работы и вопросы, которые стоит задать до внедрения

У результатов есть границы, и они касаются как самой работы, так и переноса на другие системы. Проверка выполнена на двух моделях семейства Qwen3.5 и на одном наборе RULER, так что для других архитектур и других задач цифры придётся подтверждать отдельно. Ускорение измерено на ядре GDN, а выигрыш для всего префилла зависит от доли этого ядра. Реализация описана как аппаратно ориентированная, поэтому часть выигрыша может зависеть от конкретного GPU, и детали стоит читать в тексте работы.

Остаются и вопросы, которых агрегатные цифры не закрывают. Метод применяется на префилле, а состояние после него используется при генерации. Приближённое конечное состояние может по-разному сказываться на длинных ответах, поэтому проверять нужно не только поиск иголки, но и качество генерации после префилла. Ещё один вопрос касается кэширования префиксов: состояния, полученные с входозависимой разреженностью, отличаются от плотных, и совместимость с повторным использованием префиксов стоит проверить отдельно. Наконец, важен вопрос стабильности: как ведёт себя селектор на данных, непохожих на тест.

Если на эти вопросы получены ответы, картина складывается трезвая. SparseGDN показывает, что в префилле линейного внимания остаётся ещё много неиспользованной разреженности и что её можно забрать без дообучения, с ускорением ядра в полтора-два раза. Выигрывает тот, кто сначала замерил долю ядра в своём префилле, затем проверил точность отдельно по каждому типу задач и только после этого включил метод в рабочий контур.