Полтора месяца ускорял MoE на GTX 1660 SUPER: выиграл штатный флаг llama.cpp

Я тепломеханик, начальник отдела в проектной организации, не программист. С августа ковыряю форк llama.cpp: хочу, чтобы большие MoE‑модели нормально шли на обычном домашнем ящике, где видеокарта на 6 ГБ, 24 ГБ памяти и SATA‑диск. Полигоном была gpt‑oss-20b. Итог пока обидный: выиграл штатный флаг ‑fit, на длинном запросе он в 2,44 раза быстрее привычного ‑ngl 99, а мои ускорения поверх него не дали ничего. Зато по дороге я собрал неплохую коллекцию способов, которыми замер скорости врет, и пару настроек, которые пригодятся любому владельцу небольшой видеокарты.
Зачем мне это
Задача у меня одна на все опыты: сделать ИИ полезным без большой видеокарты и без подписки. По этой линии я ковыряю llama.cpp, по второй гоняю рой из маленьких моделей, но это отдельная история. Код пишут ИИ‑агенты, я ставлю задачи и придираюсь к цифрам.
Расчет такой. У MoE‑модели на каждый токен работает малая часть экспертов. У gpt‑oss-120b весов больше 60 ГБ, а быстрой памяти в моем ящике около 30 ГБ: 6 ГБ на карте и 24 ГБ ОЗУ. Значит, примерно половина модели все время будет читаться с диска, и весь вопрос в том, как читать поменьше и вовремя. Под это я и пилил форк: свои раскладки, подкачку, подталкивание маршрутизатора к экспертам, которые уже лежат в памяти.
120b я еще не мерил. Сначала все гонял на младшей gpt‑oss-20b, а чтобы она оказалась в том же положении, запирал часть ОЗУ отдельной программой. В быстрой памяти оставалось 45–48% модели, остальное шло с SSD.
Да, gpt‑oss уже не модная, тут ее недавно назвали мертвым ослом, которого хватит пинать. Я брал ее в августе как полигон, потому что у нее есть старшая сестра ровно под мою задачу. Механика llama.cpp, о которой дальше речь, от модели не зависит. Конкретные числа зависят, и на этом я сам попался, пример будет про KV‑кэш.
Стенд
Ryzen 5 1600 (шесть ядер Zen 1), 24 ГБ DDR4, GTX 1660 SUPER на 6 ГБ, SATA SSD, Windows 10. Модель gpt‑oss-20b в родном MXFP4, файл 11,3 ГиБ. Скорость я мерил llama‑bench, как все, и почему так мерить не стоит, расскажу ниже. Основной режим — запрос на 16 тысяч токенов и ответ целиком, 256 токенов. Так выглядит реальная работа, когда в модель кладут код или документы.
Главное: не пишите ‑ngl 99
Многие инструкции по llama.cpp советуют ставить ‑ngl 99, то есть все слои на видеокарту. Для модели, которая в видеопамять не влезает, на Windows это худший вариант:
раскладка | т/с | к ‑ngl 99 |
|---|---|---|
‑ngl 99 | 3,55 | 1,00 |
‑fit | 8,67 | 2,44 |
‑fit + мой рычаг | 8,50 | 2,39 |
‑ncmoe 25 + мой рычаг | 6,46 | 1,82 |
Запрос 16 тысяч токенов, ответ 256, в быстрой памяти 46% модели, два круга, порядок перемешан.
Почему так, видно по тому, сколько каждая раскладка просит у карты, где всего 6143 МиБ:
на карте
-ngl 99 10949 МиБ переподписка в 1,8 раза
-ncmoe 25 1242 МиБ карта пустая на 80%
--fit 4747 МиБ карта заполнена на 77%
‑ngl 99 просит у карты почти вдвое больше, чем у нее есть. На Linux такое, насколько я знаю, просто не загрузится, у меня Linux нет, не проверял. Драйвер Windows молча уводит лишнее в общую память, и дальше все ходит туда‑сюда через шину. Скорость при этом гуляет от 3,3 до 5,2 т/с между прогонами, а с диска за прогон читается около 25 ГиБ, вдвое больше, чем у ‑fit.
‑fit делает простую вещь: оставляет на карте внимание и столько экспертов, сколько влезает, а остальных экспертов кладет в ОЗУ. Слои при этом формально все на карте, разница в том, какие тензоры куда. С ростом контекста KV‑кэш занимает видеопамять, и ‑fit сам уводит экспертов в ОЗУ. При пустом контексте эксперты 16 слоев из 24 живут в ОЗУ, при 16 тысячах токенов 18, при 65 тысячах 21.
Когда модель целиком помещается в ОЗУ, картина та же. На коротком замере ‑ngl 99 дал 4,54 т/с, почти как голый процессор (4,67), а раскладка, где эксперты 14 слоев живут в ОЗУ, дала 10,00.
Ловушка в том, что ‑fit в llama‑server и llama‑cli включен по умолчанию, но подбирает только то, что вы не задали сами. Стоит написать ‑ngl, ‑ot или ‑ncmoe, и он сдается, в логе одна строчка. В коде апстрима это выглядит так:
if (mparams->n_gpu_layers != default_mparams.n_gpu_layers) {
throw common_params_fit_exception("n_gpu_layers already set by user to " + std::to_string(mparams->n_gpu_layers) + ", abort");
}
То есть совет «ставь ‑ngl 99» на модели, которая не влезает в видеопамять, просто выключает штатный подбор.
Мой рычаг на картинке — подталкивать маршрутизатор MoE к экспертам, которые уже лежат в быстрой памяти. Стоит он около 1% перплексии на собственном тексте модели. На коротких замерах давал до полутора раз, на длинном запросе 0,98–1,02, то есть ничего: чтение длинного запроса само прогревает кэш почти всеми экспертами, и подталкивать некуда.
Убатч для длинного запроса
Вторая штатная настройка, которая что‑то дает, — размер убатча при чтении запроса, ‑ub. По умолчанию он 512. Чтение запроса на 16 тысяч токенов, 45% модели в быстрой памяти:
‑ub | т/с |
|---|---|
256 | 72 |
512 | 98 |
1024 | 125–129 |
1536 | 143 |
2048 | 146 или 77 |
4096 | 68 |
Крупный убатч быстрее и без нехватки памяти: там 1024 дает 1,28 раза к 512. А обрыв берется от нехватки: крупный убатч выдавливает кэш страниц, и начинается молотьба. На 4096 чтение с диска подскакивает впятеро, а 2048 стоит на краю и от прогона к прогону дает то 146, то 77. На 65 тысячах контекста край сдвигается, там 1536 уже за ним: 229 ГиБ чтения против 14 у 1024.
Итого у меня ‑ub 1536 до 16 тысяч и 1024 на 65 тысячах. На вашей машине числа будут свои, но кривая немонотонная, и мерить ее надо на своей длине запроса.
KV‑кэш: q8_0 почти даром, q4_0 нет
Тут числа уже зависят от модели, и я на этом попался. Долго считал, что KV‑кэш в q4_0 стоит около 1,5% перплексии. Это число я снял на другой модели, OLMoE, и перенес на gpt‑oss не глядя. На самой gpt‑oss, на ее собственном тексте, вышло так:
KV‑кэш | KL к исходной | топ-1 совпадает | перплексия |
|---|---|---|---|
q8_0 | 0,002 | 97,65% | +0,05% |
q4_0 | 0,087 | 86,2% | +10,7% |
q8_0 по качеству почти бесплатен. q4_0 обходится в десятую часть перплексии, и каждый седьмой самый вероятный токен меняется на другой. Насколько q8_0 ускоряет длинный контекст на карте 6 ГБ, я пока не мерил. На 65 тысячах KV занимает 1,5 ГиБ видеопамяти и выталкивает экспертов, так что выигрыш там должен быть, но это догадка.
Что не сработало
Спекулятивное декодирование. Своей MTP‑головы у gpt‑oss нет, поэтому черновик брался из n‑грамм самого контекста: отдельная черновая модель заняла бы ту самую память, которой не хватает. На нарочно повторяющемся запросе черновик сработал 7 раз на 128 токенов, и все 7 приняты. Потолок выигрыша от этого 5,5%, ниже шума машины. На обычном тексте 0 черновиков за 126 вызовов. Один прогон показывал мне 1,35 раза, при повторе та же настройка дала 4,14 и 5,26 т/с, это был шум.
В плане у меня стояло «многотокенный проход даст до 1,76 раза». Замер трафика показал, откуда это бралось. Один проход модели под нехваткой памяти стоит примерно одного чтения модели, сколько бы токенов он ни нес. Когда токенов в проходе стало в 256 раз больше, чтение с диска за прогон выросло только с 6,5 до 11,1 ГиБ. Поделили чтение на число токенов и получили красивую цифру из ничего.
Процессор. Шесть ядер дают всего 2,63 раза от одного, а память при этом загружена на треть. Упирается само ядро MXFP4 на Zen 1, где 256-битные операции AVX2 идут двумя половинами. Отсюда главный вывод про мой ящик: нехватка памяти в 55% стоит всего 4–11% скорости на 16 тысячах контекста. Упор в процессор, диск тут вторичен, и штатный ‑fit уже делает почти все, что можно.
Мои рычаги. Подталкивание маршрутизатора, подкачка, свои раскладки — на 20b поверх ‑fit ни один ничего не добавил.
Как замеры мне врали
В журнале у меня есть правило: режим замера входит в замер. К середине сентября оно сработало девять раз, то есть девять раз вывод, снятый не в том режиме, в нужном переворачивался. Вот самые поучительные случаи.
Короткий прогон
Я мерил короткими прогонами по 32–48 токенов при пустом контексте, так делают многие. Под нехваткой памяти такой прогон меряет состояние кэша. 32 токена при пустом контексте шли 2,8 т/с, потому что первые токены читают холодный кэш. Те же 32 токена после запроса на 4 тысячи шли 10,2 т/с: чтение запроса задевает почти всех экспертов и прогревает кэш. Ответ целиком, 256 токенов, дал 6,6 т/с при пустом контексте и 8,6 после запроса на 16 тысяч.

На коротких прогонах у меня вышло, что мой рычаг вместе с ‑fit дает 1,7–1,8 раза к ‑ngl 99 и без рычага не обойтись. На ответе целиком после длинного запроса рычаг поверх ‑fit дал 1,02.
llama‑bench кормит модель случайными токенами
Это я нашел, когда полез в код. И для чтения запроса, и для генерации llama‑bench подает std::rand() % n_vocab, то есть случайные номера токенов. В сборке MSVC под Windows RAND_MAX равен 32767, и у gpt‑oss со словарем на 201 088 токенов номера берутся только из первых 32 768. Модели скармливается каша из обрывков слов.
Плотной модели это все равно, считать ей одинаково. У MoE маршрутизатор выбирает экспертов по содержимому, и на каше выбор другой, чем на тексте. Выводы про счет от этого не страдают. Все, что зависит от выбора экспертов, кэша и подкачки, надо перемерять на настоящем тексте через llama‑server. Я пока не перемерил. Разница ‑fit и ‑ngl 99 держится на раскладке памяти, и ее это, думаю, не перевернет. Все про кэш и мой рычаг держите в голове с этой оговоркой.
Заодно llama‑bench по умолчанию кладет на карту все слои и ‑fit не включает: у него для этого отдельный ключ ‑fitt, по умолчанию выключенный. Из коробки он меряет ровно ту раскладку, которую сервер сам бы не выбрал.
Порядок прогонов
Однажды скорость выросла от того, что памяти стало меньше: без запертой памяти 2,24 т/с, с запертыми 6 ГБ 7,65. Память была ни при чем. К первой точке файл модели еще не осел в кэше страниц и шел с SSD, к следующей осел. С тех пор на каждой точке два прогона, считаю по второму, а первый показываю рядом.
Дрейф между запусками
Один и тот же код на одной и той же модели дал 99,65 ± 0,51 т/с, а через час 94,0 ± 3,5. Шесть процентов дрейфа при погрешности каждого замера меньше процента: между прогонами были пересборки, десятки гигабайт записи на диск, нагрев. Теперь сравниваемые настройки гоняю вперемешку в одном вызове, A, B, A, B, и смотрю отношения внутри круга. Разницу меньше 10%, снятую сериями, не заявляю вообще.
Перплексия на чужом тексте
Был момент, когда я объявил, что у gpt‑oss в llama.cpp сломана реализация: перплексия на обычном английском тексте росла с длиной контекста и доходила до 134. Мне указали, что gpt‑oss выпустили только дообученной под ее формат Harmony, базовой версии нет, и обычный текст для нее чужой. Я заставил модель написать текст самой, в родном формате, и на нем при контексте 1024 перплексия вышла 2,07. Находку пришлось отозвать. С тех пор качество меряю на тексте, который пишет сама модель.
Что бы я поставил на карту 6–8 ГБ
Полтора месяца ушло на то, чтобы выяснить: главное — не мешать штатному подбору. С оговоркой, что числа мои, а машина ваша:
llama-server -m gpt-oss-20b-mxfp4.gguf -c 16384 -ub 1536 -ctk q8_0 -ctv q8_0
‑ngl, ‑ot и ‑ncmoe я бы не трогал, пока сами не померили: ‑fit включен по умолчанию, и на длинном запросе он у меня выиграл у всех ручных раскладок, что я пробовал. Запас на карте он по умолчанию оставляет 1 ГиБ, я мерил с 256 МиБ (--fit-target 256). Если рабочий стол видеопамять почти не занимает, запас можно ужать. Для 65 тысяч контекста ставьте ‑c 65 536 и ‑ub 1024. Спекулятивное декодирование n‑граммами на gpt‑oss я бы не включал.
Мерить — llama‑server на своем тексте, ответом целиком, на своей длине запроса, а сравниваемые настройки гонять вперемешку.
Чего я не мерил
Одна машина, одна модель, Windows. Linux у меня нет. 120b еще впереди, ради нее все и затевалось. Свежие MoE вроде Qwen3.6–35B‑A3B я не гонял: механика llama.cpp та же, числа будут другие. И главное: скорости сняты llama‑bench на случайных токенах, перепроверка через llama‑server на тексте у меня в планах.
Где цифры
Журнал замеров лежит на GitHub: https://github.com/Deadatreides/LLM‑MEASUREMENTS/tree/main/hardware. Раскладки, убатч и короткий прогон — в https://github.com/Deadatreides/LLM‑MEASUREMENTS/blob/main/hardware/REPORT‑BUILD‑AND‑FIRST‑RUN.md, разделы 4.106–4.128 (журнал большой, ищите по номерам). Проверка качества KV‑кэша — в https://github.com/Deadatreides/LLM‑MEASUREMENTS/blob/main/hardware/PHASE0-RESULTS.md.
Вопрос к владельцам карт на 6–8 ГБ: какие у вас числа ‑fit против ‑ngl 99 на свежих MoE? Особенно интересно на Linux.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.