Начну с пары чисел, которая меня остановила.

RTX 5090, одна и та же сборка llama.cpp (b10326, коммит 3653e6d6d), один драйвер, квант Q4_0, батч 1. Плотная Qwen3.5-9B выбирает 72% пропускной способности памяти карты. MoE Qwen3.5-35B-A3B на той же карте выбирает 41%.

Сразу оговорюсь, чтобы не унесли неверный вывод: MoE при этом быстрее в абсолюте, 317 токенов в секунду против 240. Она просто оставляет на столе примерно полтора раза от собственного потолка, и оставляет по причине, которую можно назвать и померить.

Маршрутизация экспертов тут почти ни при чём. Я её измерил отдельно.

Коротко. Измерено: полоса памяти карты, кривая полосы от объёма чтения на ядро настоящим ядром ggml, разбивка всех матвеков по трассе, накладные сервера. Предсказано и проверено: скорость другой модели (ошибка 3.7%) и скорость на четырёх глубинах контекста (в пределах 2.4%). Спрогнозировано, но не проверено: выигрыш от укрупнения ядер, от 1.38 до 1.54 раза.

Дальше по тексту «полоса» это пропускная способность памяти (ПСП), «матвек» это матрично-векторное умножение (mul_mat_vec), то самое, которым считается декод при батче 1, а GDN это Gated DeltaNet, рекуррентные слои, которых в обеих моделях примерно три четверти.

Стенд

карта

RTX 5090, 32 ГБ GDDR7, sm_120, 170 мультипроцессоров

драйвер

580.159.03

llama.cpp

b10326, коммит 3653e6d6d, CUDA 12.8

квант

Q4_0 в терминах llama.cpp, фактически смесь: голова q6_K, часть тензоров q8_0 и q5_0, треть матвеков в f32

полоса паспортная

1792 ГБ/с

полоса измеренная

1671.0 ГБ/с (93% паспортной)

пик кривой ядра ggml

1683 ГБ/с

пик кривой torch-редукции

1638 ГБ/с

Полосу я мерил, а не брал из спецификации. Паспортное число недостижимо, и если считать по нему, все дальнейшие выводы поедут. Последовательное чтение буфера в 1.5 ГиБ дало 1671.0 ГБ/с при разбросе 0.18% по шестидесяти выборкам. Три разных ядра редукции (sum по f32, max по f32, sum по f16) сошлись в пределах 0.34%, то есть измерена память, а не конкретное ядро.

Скорость снята так, пять повторов на точку:

llama-bench -m <model>.gguf -p 0 -n 512 -d 0 -r 5 \
            -ngl 99 -sm none -fa 1 -ctk f16 -ctv f16 -t 16

Все заголовочные числа относятся к пустому контексту (-d 0). Это важно: байты на токен зависят от глубины, и таблица ниже верна только для нулевой. Матрицу я снимал и на 4096, 16384 и 32768; при fa=1 и KV в f16 падение к 32K составляет 14.5%, с 317.45 до 271.53 т/с.

Байты на токен считаются из метаданных GGUF по фактическим типам тензоров, а не по номинальной битности кванта:

MoE 35B-A3B

плотная 9B

слоёв

41 (11 внимания, 30 GDN)

33 (9 внимания, 24 GDN)

эксперты

256 всего, на токен 8 маршрутизируемых плюс 1 общий

нет

веса на токен

2.007 ГБ

4.900 ГБ

состояние GDN (чтение и запись)

0.126 ГБ

0.101 ГБ

всего на токен

2.133 ГБ

5.000 ГБ

замер, т/с

317.45 (разброс 0.74%)

239.62 ± 0.86

Эмбеддинг из подсчёта исключён: token_embd.weight весит 286 МБ, но за токен из него читается одна строка на 1.1 КиБ. Голова output.weight наоборот читается целиком каждый токен и включена. Посчитать эмбеддинг целиком значит завысить веса на 14%, а это больше, чем многие эффекты, которые дальше в тексте измеряются.

Отсюда утилизация. MoE: 2.133 ГБ за 3.150 мс, это 677 ГБ/с, то есть 41% от 1671. Плотная: 5.000 ГБ за 4.173 мс, это 1198 ГБ/с, то есть 72%.

Три подозрения, которые не подтвердились

Прежде чем искать новое объяснение, я отработал очевидные.

Первое подозрение падало на CUDA-графы: у MoE формы тензоров при маршрутизации меняются от токена к токену, и захват графа мог не срабатывать. В трассе nsys видно 127 запусков графа на 128 токенов декода, а llama.cpp не сообщает ни одной причины отключения. Графы захватываются, и на MoE, и на плотной модели.

Второе подозрение падало на саму маршрутизацию. Ядра topk_moe вместе с выборкой строк занимают 7.0% времени в ядрах, тогда как матвеки занимают 51%. Маршрутизация не бесплатна, но разрыв она не объясняет.

Третье: GPU простаивает в промежутках между запусками. nvidia-smi dmon на чистом прогоне без профилировщика показал занятость SM 97%. Карта работает почти всё время. Она просто делает работу медленнее, чем позволяет память.

Последний пункт и есть ключ. Если GPU занят на 97%, а полоса выбрана на 41%, то ядра выполняются, но читают память неэффективно.

Ядру нужно прочитать достаточно байт

Гипотеза простая: пока ядро читает мало, оно не успевает наполнить контроллер памяти запросами и не выходит на полную полосу.

Проверять это надо на настоящем ядре и на холодных данных. Я написал программу на ggml, которая создаёт N различных матриц, суммарно на 4 ГиБ, и вызывает на них тот же mul_mat_vec_q, что работает внутри модели. Рабочий набор в сорок раз больше кэша L2 (96 МиБ), поэтому к моменту возврата к матрице она вытеснена. Это тот же режим, в котором идёт декод: каждый вес читается один раз за токен и не переиспользуется.

МБ на ядро

1.0

2.1

4.2

8.4

16.8

33.6

67

134

537

ГБ/с

381

617

899

1171

1390

1528

1608

1646

1683

% от пика

23%

37%

53%

70%

83%

91%

96%

98%

100%

Пик кривой 1683 ГБ/с против 1671 ГБ/с, измеренных независимо простым тестом полосы. Расхождение 0.7%. Три числа полосы в шапке отличаются тем, каким ядром они получены, и это как раз иллюстрация того, что «полоса карты» без указания ядра величина неполная.

Сколько из этого просто цена запуска

Честный вопрос к такой кривой: не меряю ли я вместо разгона полосы банальные накладные на запуск ядра? На 1 МБ одно ядро живёт 2.75 мкс, а цена узла CUDA-графа, измеренная отдельно, составляет 0.94 мкс. Это треть точки.

Вычитаю эту константу из каждого измерения:

МБ на ядро

1.0

4.2

16.8

33.6

134

537

как измерено

23%

53%

83%

91%

98%

100%

минус запуск

33%

67%

89%

95%

99%

100%

Кривая не распрямляется. Даже при нулевой цене запуска чтение на 4.2 МБ берёт две трети полосы. Порог, на котором набирается 90%, сдвигается примерно с 34 МБ до 18 МБ. То есть запуск отвечает за треть дефицита на мелких размерах, остальные две трети остаются на разгон.

Дальше я пользуюсь кривой как измерено, потому что в реальном движке цена запуска тоже никуда не девается. Но механизм именно двойной, и сводить его к одному запуску нельзя.

Где на этой кривой сидят обе модели

Число матвеков на токен взято из той же трассы nsys:

матвеков на токен

байт на матвек

доля полосы

MoE 35B-A3B

437.8

4.6 МБ

55%

плотная 9B

220.4

22.2 МБ

86%

Вот и весь разрыв. Но среднее здесь скрывает главное, поэтому вот разбивка всех 437.8 матвеков по размеру сетки запуска, прямо из трассы:

тип весов

grid.x

запусков на токен

на слой

медиана, мкс

доля времени

q4_0

512

76.2

1.86

9.22

24.0%

q4_0

8192

36.6

0.89

9.54

14.8%

q6_K

248320

1.0

голова

256.35

11.2%

q4_0

2048

26.4

0.64

5.02

8.0%

q8_0

512

60.9

1.49

2.72

7.7%

f32

256

40.6

0.99

4.38

7.7%

q4_0

4096

30.5

0.74

5.15

6.7%

f32

32

60.9

1.49

1.98

5.0%

q5_0

512

40.6

0.99

2.50

4.6%

q8_0

2048

14.2

0.35

7.42

4.5%

f32

1

40.6

0.99

1.44

2.6%

q6_K

8192

4.1

0.10

11.87

2.1%

q4_1

512

5.1

0.12

5.09

1.1%

итого

437.8

100%

Доля времени здесь считается по сумме реальных длительностей, а не по медиане на число запусков, поэтому произведение колонок её не воспроизводит.

Из таблицы видно две вещи, которых я сам не ожидал.

Экспертов ядро обрабатывает пачкой, а не по одному. На q4_0 суммарно приходится 4.1 запуска на слой, а не 24, как было бы при цикле по восьми экспертам с тремя проекциями. В сигнатуре mul_mat_vec_q есть указатель на массив индексов, то есть путь с индирекцией. Так что дело не в том, что «на каждого эксперта своё ядро». Дело в том, что восемь экспертов из 256 это и есть мало байт: даже собранные в один запуск, они дают единицы мегабайт, и ядро всё равно оказывается ниже порога.

Голова идёт одним запуском на токен и читает 417 МБ. Она берёт 1601 ГБ/с, то есть 96% полосы. Все остальные 436.8 запусков читают в среднем по 3.64 МБ и берут 767 ГБ/с, то есть 46%. Кривая на 3.64 МБ обещает 841 ГБ/с (50%), и трассовое число ниже, потому что несёт на себе раздув профилировщика. То есть реальные ядра, включая экспертные с индирекцией, ложатся на синтетическую кривую в пределах нескольких процентов, а внутри одной модели уживаются ядро на полной полосе и сотни ядер на половине от неё.

Здесь напрашивается возражение: у экспертных ядер grid.x всего 512 блоков на 170 мультипроцессоров, то есть карта недозаполнена, и дело может быть в этом, а не в разгоне полосы. Возражение снимается развёрткой по длине строки: при K = 1024, 4096 и 16384 число блоков меняется в шестнадцать раз при неизменном объёме чтения 4.2 МБ, а полоса держится в пределах 4%, давая 869, 845 и 836 ГБ/с. Заполнение карты блоками варьировалось в шестнадцать раз и не сдвинуло результат.

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

Проверка: предсказать другую модель

Всё описанное выше выведено на MoE. Чтобы отличить закон от подгонки, я взял кривую, снятую на MoE, и предсказал по ней плотную Qwen3.5-9B. Другая архитектура: 33 слоя вместо 41, без экспертов вовсе.

Предсказание сделано до замера:

матвеков на токен            220.4  (из профиля)
байт на матвек               22.2 МБ
доля полосы по кривой        1445 ГБ/с (86% пика)
время матвеков               3.390 мс
прочие 638 ядер по 0.88 мкс  0.561 мс
состояние GDN                0.072 мс
итого                        4.023 мс, то есть 248.6 т/с

Замер: 4.173 мс, то есть 239.62 ± 0.86 т/с. Ошибка 3.7%, предсказание оптимистичнее факта.

Остаточные три процента, скорее всего, сидят в оценке мелких ядер: она снята на MoE, а у плотной набор ядер другой.

Вторая проверка, на другой оси

Перенос между архитектурами это одна ось. Есть вторая, бесплатная: глубина контекста. Число матвеков от неё не зависит, меняется только объём KV-кэша, а он считается из метаданных (22528 байт на токен контекста для одиннадцати слоёв внимания). Значит модель обязана предсказать всю кривую по глубине, ничего дополнительно не измеряя:

глубина

KV на токен

предсказано

замерено

ошибка

0

5.8 МБ

317.1

317.45

−0.1%

4096

98.0 МБ

311.6

309.88

+0.6%

16384

374.9 МБ

296.3

289.52

+2.4%

32768

744.0 МБ

278.1

271.53

+2.4%

Абсолютная скорость предсказана в пределах 2.4% на всём диапазоне глубин. Само падение при этом вышло 12.3% против замеренных 14.5%, то есть модель недооценивает цену глубины примерно на пятую часть эффекта: чего-то в KV-тракте она не видит. Проверка всё равно сильнее первой, потому что там была другая модель того же семейства, а здесь другая физическая величина.

Порог, похоже, растёт вместе с шириной карты

Этот раздел слабее остальных, и я скажу почему, прежде чем показывать таблицу.

На ноутбучной RTX 4060 у меня нет сборки бенчмарка на ggml, поэтому сравнить карты настоящим ядром не вышло. Обе колонки ниже сняты другим, более простым синтетическим ядром (редукция через torch по тем же холодным срезам). Между собой они сравнимы, а с абсолютными числами предыдущего раздела нет:

RTX 4060

RTX 5090

отношение

мультипроцессоров

24

170

7.1×

полоса измеренная

249 ГБ/с

1638 ГБ/с

6.6×

порог 80% полосы

8.4 МБ

67.1 МБ

8.0×

порог 90% полосы

16.8 МБ

134.2 МБ

8.0×

Две причины считать это предварительным. Развёртка идёт с шагом вдвое, поэтому порог определён с точностью до множителя два, и отличить масштабирование по числу мультипроцессоров (7.1×) от масштабирования по полосе (6.6×) на таких данных нельзя. И само число порога зависит от ядра: у torch-редукции на 5090 порог 90% приходится на 134 МБ, а у настоящего матвека ggml на 34 МБ, вчетверо ниже. То есть порог это не свойство подсистемы памяти, а свойство пары «карта плюс ядро».

Что из этого можно взять: направление. Чем шире карта, тем крупнее должны быть ядра, и код, отлаженный на 4060, на 5090 окажется мелкозернистым. Коэффициент требует нормальной развёртки с мелким шагом на обеих картах одним ядром.

Как это не надо мерить

Четыре ловушки. Я посидел в каждой.

L2 съедает наивный свип по размеру

Первое, что приходит в голову: прогнать чтение размером от 64 КБ до 64 МБ и посмотреть, где выходит на плато. У 5090 кэш L2 равен 96 МиБ, у 4060 он 32 МиБ. Весь диапазон помещается в кэш целиком, и плато наступает на первых килобайтах. На 64 МиБ я получил 4285 ГБ/с при физической полосе 1683.

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

test-backend-ops меряет кэш

У llama.cpp есть готовый инструмент test-backend-ops perf, который гоняет настоящие ядра ggml на заданных формах. Казалось бы, идеально. Для q4_0 при m=4096, k=14336, n=1 он выдал 9.04 мкс на вызов. Матрица весит 33 МБ, значит полоса 3654 ГБ/с, вдвое выше физической.

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

Питон вместо CUDA

Чтобы понять цену запуска ядра, я сначала померил её через torch: запустить N тривиальных ядер в поток и разделить время на N. Вышло от 9 до 18 микросекунд на запуск, и число почти не зависело от N.

Число оказалось мусорным. После того как CPU отпустил очередь, GPU дорабатывал 0.02 микросекунды. То есть карта простаивала всё время, а я померил скорость диспетчеризации Python. Один вызов torch из питона стоит от единиц до десятков микросекунд, а это на порядок больше цены запуска самого ядра.

Валиден только замер через захваченный CUDA-граф: в цикле replay() питона нет. Там вышло 0.94 микросекунды на узел.

Профилировщик врёт неравномерно

nsys profile --cuda-graph-trace=node нужен, чтобы увидеть ядра внутри графа поимённо. Без этого ключа весь граф схлопывается в одну строку, и вопрос «сколько ядер на токен» остаётся без ответа.

Но такая трассировка добавляет время, и добавляет непропорционально. Сумма времени по всем ядрам дала 4.558 мс на токен при чистом замере 3.150 мс. Ядра идут последовательно в одном потоке, значит сумма не может превышать длительность токена, и часть длительностей раздута.

Раздув неравномерный. Матвеки, которые живут по 5 микросекунд, раздуты примерно в 1.1 раза. Короткие ядра около микросекунды раздуты вдвое. Поэтому доли времени из такой трассы брать можно, а абсолютные значения нельзя.

На 4060 та же трассировка дала 22.271 мс на токен против 22.297 мс в чистом замере, то есть искажение практически нулевое. Там ядра длиннее в тридцать раз, и постоянная добавка на их фоне не видна.

Что из этого доходит до пользователя

Здесь самая практичная часть, и она не про GPU.

llama-bench меряет движок. Люди работают через llama-server, а он сверху кладёт сэмплирование, детокенизацию, слоты и HTTP. Тот же стенд, та же модель, 512 токенов, три повтора на конфигурацию:

мс на токен

накопленный итог

движок llama-bench

3.150

3.150

цикл сервера с жадным сэмплером

+0.279

3.429

реальный сэмплер (top_k 40, top_p 0.9, min_p, repeat_penalty)

+0.723

4.152

HTTP и префилл на запрос

+0.291

4.443

Пользователь видит 225 т/с там, где бенчмарк показывает 317. Это минус 29%.

Самая дорогая часть здесь сэмплер: 0.723 мс на токен, почти четверть времени всего движка. Это работа CPU по словарю в четверть миллиона токенов, к GPU она отношения не имеет. Стриминг, вопреки моему ожиданию, не стоит ничего: 225.8 т/с без него против 225.1 с ним.

Заодно про драйвер. Переход с 570.195.03 на 580.159.03 даёт на декоде 8.0% по медиане двенадцати сопоставленных точек, на префилле 5.8%. Перплексия при этом совпадает до четвёртого знака (5.7732 ± 0.0434 на обоих драйверах), и число ядер на токен тоже одинаково. Механизм виден по кривой: на ядре в 1 МБ новый драйвер даёт 8.6%, на 33.6 МБ уже 2.0%, на 537 МБ 0.3%. Он удешевил запуск ядра, а не исполнение. То же говорит цена графов: при GGML_CUDA_DISABLE_GRAPHS=1 старый драйвер теряет 26.8%, новый только 14.0%.

Что из этого следует

Дальше идёт прогноз, а не измерение. Помечаю явно: своих слитых ядер я не писал, что уже сделано в экосистеме (в ik_llama.cpp и в мейнлайне) я не проверял.

Если оставить K матвеков вместо нынешних 438, каждый прочитает 2.007 ГБ / K, и долю полосы даёт кривая. Одна тонкость меняет ответ вдвое. Ядро quantize_q8_1 запускается ровно один раз на квантованный матвек: 438 матвеков минус 142 по f32 даёт 296 квантований, и это в точности измеренное число. Восемь экспертов на слое читают один и тот же вектор активаций, поэтому при их слиянии остаётся одно квантование вместо восьми. А вот проекции разных слоёв читают разные активации, там квантование не сокращается.

Поэтому две границы:

матвеков на токен

на слой

МБ на ядро

% полосы

т/с, квантование сокращается

т/с, квантование остаётся

438

10.7

4.6

55%

317

317

219

5.3

9.2

71%

392 (1.24×)

373 (1.18×)

109

2.7

18.3

84%

447 (1.41×)

411 (1.30×)

41

1.0

49.0

93%

489 (1.54×)

438 (1.38×)

Столбец с долей полосы взят прямо из измеренной кривой. Скорость в последних двух столбцах держится на экстраполяции: помимо матвеков на токен приходится 1142 других ядра, и они стоят около 1.0 мс. Я оцениваю их в 0.88 микросекунды каждое, просто разделив остаток времени на их число. Оценка подогнана по одной точке. Она сходится с независимо измеренной ценой узла графа (0.94 мкс), но в области малых N не проверялась вовсе.

Из таблицы следует неприятное. Укрупнение одних матвеков упирается в от 1.4 до 1.5 раза даже при одном матвеке на слой, потому что мелкие ядра как стоили миллисекунду, так и стоят. Чтобы пройти дальше, придётся резать и их.

И ещё одно, чего я не ожидал. Обвязка сервера в 1.29 мс на токен от фузии никуда не денется. Поэтому 1.54× на движке доходят до пользователя как 1.33×, а идеальный движок, упёршийся в полосу памяти, дал бы всего 1.73×. Сэмплер стоит 0.72 мс и снимается на CPU, без единой строчки CUDA. По величине это сопоставимо со всей выгодой от фузии, а по трудозатратам несопоставимо меньше. Я бы начинал с него.

Оговорки

Средний размер вместо суммы по ядрам. Я беру байты, делю на число матвеков и снимаю с кривой одну долю полосы, хотя честнее суммировать по каждому ядру: кривая вогнутая, а распределение размеров бимодальное. Разбил на две группы и пересчитал по кривой: голова 417 МБ плюс 436.8 запусков по 3.64 МБ дают 2.139 мс против 2.150 мс по среднему размеру, разница 0.5%. Смещение есть и направлено в ожидаемую сторону, но на этих данных ничего не решает. Отдельно не путать с трассовыми числами выше (1601 и 767 ГБ/с): те несут на себе раздув профилировщика и потому ниже кривой.

Треть матвеков считается по чужой кривой. Из 437.8 матвеков 142.2 работают в f32, а кривая снята на q4_0. Для f16 на 4.2 МБ разрыв достигает 21%, значит и здесь оценка смещена. В долю «55% полосы» это входит без поправки.

Достигнутая полоса не снята счётчиком. Она посчитана делением байт на время, а не через dram__throughput в Nsight Compute. Профилировать ncu на арендованном поде не вышло: контейнер запущен без CAP_SYS_ADMIN, и счётчики закрыты даже для root. Снять ограничение изнутри нельзя, нужен под с соответствующими правами.

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

Только Q4_0. Кривые для q8_0, q6_K и f16 я снимал, они между собой почти неразличимы, а f16 на 4.2 МБ быстрее q4_0 на 21%, и разрыв исчезает к 537 МБ. То есть распаковка это фиксированная добавка на ядро, а не потеря полосы. K-кванты, на которых сидит большинство, не проверены вовсе.

Батч только единица. Задача формулировалась как single stream, поэтому батчи 2, 4 и 8 не мерены. Это, вероятно, самый дешёвый решающий тест из оставшихся: при росте батча работа на эксперта растёт, и утилизация должна поползти вверх по той же кривой.

Другие движки не сравнивались. vLLM, SGLang и TensorRT-LLM на этом стенде не запускались. Тезис «на столе лежит полтора раза» проверяется чужим рантаймом, и без этого он остаётся утверждением про llama.cpp, а не про железо.

Модели двух штук, обе из одного семейства. Qwen3.5 9B и 35B-A3B. Карты две: RTX 5090 и ноутбучная RTX 4060.

Облако, а не лаборатория. Все числа по 5090 сняты на арендованных картах. Три пода с тремя разными процессорами дали согласованные результаты, и это добавляет уверенности, но лабораторным стендом облако не является.

Кривая снимается программой на ggml примерно в сотню строк. Её можно собрать против любой сборки llama.cpp и повторить на своей карте за вечер. Код и сырые данные выложу отдельно.