Дата подготовки: 2026-11-05. Этот файл — вся контекстная база для продолжения работы в новой сессии. Кода для самих n-gram таблиц ещё не написано: сделана только инфраструктура (экспорт корпуса из Navi) и разведка хоста.
Выяснить, насколько n-gram таблицы, построенные по нашему собственному диалоговому корпусу, ускоряют генерацию на модели Qwen3.6-35B-A3B на хосте .135.
Считаем, что ответ получен, когда есть:
tg t/s и acceptance rate на фиксированном наборе независимых промптов в четырёх конфигурациях: без спекуляции, только MTP, только n-gram-корпус, MTP + n-gram-корпус;Не делаем: не патчим llama.cpp без нужды (см. §3), не трогаем прод Navi, не меняем модель/квант.
Реализовано и проверено end-to-end: периодический снапшот диалогов, age-шифрование в одну сторону, HTTP под машинным bearer-токеном. Документация — docs/export.md, клиент — scripts/navi_export.py.
| Что | Где |
|---|---|
| Манифест | GET http://192.168.1.168:8099/export/v1/manifest |
| Шифртекст | GET http://192.168.1.168:8099/export/v1/messages (ETag/304) |
| Токен (0600) | ~/.config/navi-export/token на .135 |
| Приватный ключ age (0600) | ~/.config/navi-export/key.txt на .135 |
| Клиент + venv | ~/.local/share/navi-export/navi_export.py, ~/.local/share/navi-export/venv |
| Корпус | ~/ngram/messages.jsonl.gz на .135 |
| Состояние выгрузки | ~/ngram/messages.jsonl.gz.state.json |
Текущий снапшот: content_id 7aab3192bcca, 23 914 строк, 1 189 сессий, пользовательских 5 672, ассистентских 18 242; шифртекст 4 223 188 Б; после распаковки — 17,5 МБ JSONL, из них ~14 МБ собственно текста.
Формат строки (gzip-JSONL, одна запись на строку):
{"v":1,"session":"…","seq":42,"role":"assistant","content":"…","ts":"2026-11-04T10:22:31+00:00"}
Порядок: по created_at, затем session_id, sequence_number. Роли по умолчанию — user и assistant; tool, system, summary/compression исключены на уровне SQL.
Повторное выкачивание идемпотентно: клиент сначала читает манифест и выходит с кодом 3, если content_id совпал. Обновление раз в 4 часа на стороне Navi.
Не сделано: cron на .135 (предлагалось */30 * * * *), поэтому сейчас корпус обновляется только вручную.
~/llama/llama-b11960), исходников и тулчейна нет (cmake, ninja, Vulkan dev-заголовков не установлено; система immutable → собирать только в toolbox/distrobox или через rpm-ostree layering).~/models:
Qwen3.6-35B-A3B-MTP-UD-IQ2_M.gguf (11,88 ГБ)Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS.gguf (14,07 ГБ) ← рабочаяQwen3.6-35B-A3B-UD-IQ3_XXS.gguf (13,21 ГБ)LFM3-Omni-2B-* (не по теме)~/bench.sh, ~/mtp_bench.sh, ~/pp16k.sh, ~/ncm_sweep.sh, логи ~/bench_results.txt, ~/bench_iq2m.txt, ~/mtp_combo.log, ~/server_iq2m_combo.log.llama-server, llama-cli, llama-tokenize (умеет -f FILE --ids --no-bos → список id в Python-совместимом виде).Базовый бенч (~/bench.sh: -ngl 99 --n-cpu-moe 999 -fa on -ctk q8_0 -ctv q8_0 -t 4):
| Квант | pp512, t/s | tg128, t/s |
|---|---|---|
| IQ3_XXS | 124,47 | 6,47 |
| IQ2_M | 134,31 | 6,11 |
| IQ3_XXS, чистый CPU | 105,48 | 5,00 |
MTP-спекуляция (IQ3_XXS, -c 4096, temp 0, n_predict 128):
| Конфигурация | tg, t/s | Δ |
|---|---|---|
| без спекуляции | 8,49 | — |
draft-mtp, n_max=1 |
11,00 | +29 % |
draft-mtp, n_max=2 |
10,72 | +26 % |
draft-mtp, n_max=3 |
9,99 | +18 % |
draft-mtp, n_max=5 |
8,78 | +3 % |
Важно: базы 6,47 и 8,49 — из разных прогонов с разным контекстом; сравнивать можно только внутри одного прогона, поэтому в §4 все четыре конфигурации меряются одним скриптом.
Отдельно: в ~/mtp_combo.log и ~/server_iq2m_combo.log лежат цифры 27–33 t/s с acceptance 1.0 и средней длиной драфта 18–21 — это артефакт self-echo (модель повторяла промпт), а не реальный выигрыш. На эти числа опираться нельзя.
В прошлой разведке было сказано «все n-граммы выводятся из контекста, внешней таблицы нет». Это неверно. В b11960 на .135 (проверено по llama-server --help) есть:
--spec-type ... ,ngram-cache одиннадцатый тип спекуляции -lcs, --lookup-cache-static FNAME статическая таблица (не пополняется генерацией) -lcd, --lookup-cache-dynamic FNAME динамическая таблица (пополняется и сохраняется)
Это старый механизм «lookup decoding», доживший до текущего master. Патчить C++ не нужно. Нужно только собрать файл таблицы — утилиту llama-lookup-create, которая когда-то это делала, из репозитория убрали, поэтому таблицу строим сами (§3.3).
common/speculative.cpp, impl ngram-cache. Для каждой последовательности держатся три таблицы common_ngram_cache — отображение n-грамм (до 4 токенов) → распределение следующего токена (token → count):
| Таблица | Источник | Живёт |
|---|---|---|
| context | промпт + сгенерированное в этом запросе | сбрасывается на begin() |
| dynamic | файл -lcd, пополняется context-таблицей |
сохраняется при выходе процесса |
| static | файл -lcs — наш корпус |
read-only |
Драфт идёт цепочкой до 8 токенов (n_max для этого типа захардкожен = 8). На каждом шаге:
draft_min_sample_size_strict = {4,3,2,2} / draft_min_percent_strict = {75,66,66,66};sum ≥ 2 и top ≥ 50 %;Практические следствия, важные для плана:
LLAMA_NGRAM_STATIC = 2). Ключ в файле — 4 слота, у 2-граммы два последних равны LLAMA_TOKEN_NULL (-1). Значит 3- и 4-граммы в файле — мёртвый вес: они загружаются в память, но при поиске по ним не попадают. Собирать только 2-граммы.--spec-type a,b пробует a, и если он дал драфт, b для этой последовательности уже не вызывается. Порядок = приоритет.Файл — это дамп std::unordered_map, без заголовка и без счётчика записей. Нативные little-endian int32:
повторять до EOF:
int32 tokens[4] # 16 байт; неиспользованные слоты = -1
int32 n_pairs # > 0
n_pairs × { int32 token, int32 count } # count > 0
Загрузчик (common_ngram_cache_load) читает до EOF и падает на GGML_ASSERT, если формат не сойдётся: лишний байт в конце — abort. Порядок записей произвольный.
Рекомендуемый путь — свой билдер на Python (≈80 строк), без сборки C++:
~/ngram/messages.jsonl.gz;Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS.gguf) — либо батчами через llama-tokenize -m MODEL -f chunk --ids --no-bos, либо HTTP POST /tokenize у запущенного llama-server (быстрее: модель грузится один раз). Через процесс на каждый из 24 тыс. сообщений — не вариант: загрузка 14 ГБ модели не бесплатна;Альтернатива без своего кода — «прогреть» dynamic-таблицу: запустить llama-server --spec-type ngram-cache -lcd cache.bin и прогнать корпус промптами; context-таблица каждого запроса вливается в dynamic, при выходе файл сохраняется. Минус: каждое сообщение требует генерации хотя бы одного токена, суммарно это часы. Оставляем как резервный вариант для проверки, что наш билдер даёт тот же файл, что и родной код.
llama-server --spec-type ngram-cache -lcs <файл из пары записей> действительно стартует и не падает на загрузке (проверяет формат).unordered_map при загрузке раздувается в разы относительно файла; до старта замерить RSS на маленькой таблице и экстраполировать. При риске OOM — брать подвыборку корпуса (2–4 МБ) и/или top-k обрезку.Все четыре конфигурации — одним и тем же скриптом, одним контекстом, одним набором промптов, с перемешиванием порядка:
| # | --spec-type |
Доп. флаги |
|---|---|---|
| A | none |
— |
| B | draft-mtp |
--spec-draft-n-max 1 |
| C | ngram-cache |
-lcs ~/ngram/ngram-cache.bin |
| D | draft-mtp,ngram-cache (и ngram-cache,draft-mtp) |
оба набора флагов |
Что мерить на конфигурацию:
tg t/s — токенов/с на генерации (главная метрика);-lv 4 / --log-verbosity 4, строка statistics ... #acc rate/pos = (...)), поэтому сервер запускается с подробным логом, а из него берётся grep statistics;pp — чтобы убедиться, что спекуляция не просадила prompt processing;Требования к промптам:
Ожидания, которые надо подтвердить или опровергнуть: static-биграммы по чату дадут мало; больше даст комбинация «контекст + static-фильтр» на повторяющемся boilerplate (системный промпт Navi, шаблоны tool-call). Полезный контрольный прогон — ngram-mod и ngram-map-k4v (чисто контекстные, корпус не нужен): если они уже дают +X %, то вклад именно корпуса = разница с ngram-cache.
# корпус на .135 ssh gstation@192.168.1.135 zcat ~/ngram/messages.jsonl.gz | wc -l # 23914 zcat ~/ngram/messages.jsonl.gz | head -1 # как выкачивался (ключи уже на месте, значения не печатать) ~/.local/share/navi-export/venv/bin/python ~/.local/share/navi-export/navi_export.py pull \ --url http://192.168.1.168:8099 \ --token-file ~/.config/navi-export/token \ --identity ~/.config/navi-export/key.txt \ --out ~/ngram/messages.jsonl.gz # 0 = обновлено, 3 = без изменений, 1 = ошибка # токенизация cd ~/llama/llama-b11960 ./llama-tokenize -m ~/models/Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS.gguf -f prompt.txt --ids --no-bos # проверка, что сборка умеет нужное ./llama-server --help 2>&1 | grep -E "spec-type|lookup-cache"
Правила, которые действуют и в новой сессии: ответы по-русски; подход обсуждать до реализации; коммиты можно делать самому, push/деплой — только по явной команде; .env не читать и не печатать; на прод-БД не писать без явного разрешения; не убивать perf-инстанс на портах 8097/8088.
-lcs стартует);промптов; по решению владельца остановились на 1 повторе, а не на 3;
| Что | Где |
|---|---|
| Билдер таблицы | scripts/ngram_build.py — Python, токенизация через /tokenize, только 2-граммы внутри сообщения, top-k, фильтры уверенности |
| Харнесс замеров | scripts/ngram_bench.py — поднимает llama-server на конфиг, /apply-template (со схемами инструментов) + /completion с cache_prompt=false, считает tg/pp/acceptance из timings |
| Промпты (общие) | scripts/ngram_prompts.json — 20 штук |
| Промпты (агентские) | scripts/ngram_prompts_agentic.json — 12 код/терминал/ssh/правка файлов |
| Схемы инструментов | scripts/ngram_tools_schema.json — реальные filesystem, terminal, ssh_exec, code_exec, todo |
Сервер на .135: ~/llama/llama-b11960/llama-server, модель Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS.gguf, флаги -ngl 99 --n-cpu-moe 999 -fa on -ctk q8_0 -ctv q8_0 -t 4.
--spec-type ngram-cache -lcs <файл> стартует без abort, драфты идут.Общие промпты (5 шт., temp 0, 1 повтор, 256 токенов):
| cfg | конфигурация | tg t/s | accept |
|---|---|---|---|
| A | без спекуляции | 8,36 | — |
| B | MTP n_max=1 | 10,30 | 85 % |
| C | ngram-cache + таблица | 4,54 | 14 % |
| D | MTP + ngram-cache + таблица | 5,46 | 33 % |
| E | ngram-cache без таблицы | 4,76 | 16 % |
| F | ngram-mod | 8,35 | — |
| G | MTP + ngram-cache без таблицы | 6,29 | 40 % |
C ≈ E — вклад корпуса в пределах шума. Строгая таблица (только биграммы с total ≥ 10 и долей топа ≥ 80 %; осталось 1 989 ключей, 0,1 МБ) дала C = 4,67, D = 5,97 — без изменений.
Агентские промпты (3 шт., temp 0, 256 токенов):
| cfg | конфигурация | tg t/s | accept |
|---|---|---|---|
| A | без спекуляции | 8,36 | — |
| B | MTP n_max=1 | 10,22 | 86 % |
| H | MTP n_max=2 | 10,27 | 80 % |
| I | MTP n_max=3 | 8,99 | 71 % |
| J | MTP n_max=5 | 7,92 | 57 % |
| K | MTP + ngram-mod | 10,15 | 85 % |
| L | MTP + ngram-map-k4v | 9,52 | 70 % |
| M | ngram-map-k4v + MTP | 9,54 | 71 % |
Не внедряем. Причины:
LLAMA_NGRAM_STATIC = 2), а биграммы корпуса дают принятие 14–16 %;Оставляем MTP, n_max=1: +23 % на tg на агентских промптах.
Модель: RSS 13,2 ГБ, из них 12,8 ГБ — mmap-страницы GGUF (Private_Clean), free показывает их в buff/cache, а не в used. VRAM — 3,2 ГБ из 8: на карте только attention/dense, эксперты (~10,7 ГБ) считают 4 ядра через DDR3. Отсюда следующий шаг: --n-cpu-moe < 999, перенос части экспертных слоёв в VRAM (полоса ~224 ГБ/с против ~25 ГБ/с). Цель — 128k контекста (KV при q8_0 ≈ 1,33 ГиБ, n_ctx_train = 262144).
Поправка (2026-11-05, §9.2): гипотеза о полосе DDR3 как узком месте измерением опровергнута. Хост ограничен вычислениями, а не памятью; перенос экспертов в VRAM делает работу медленнее в любой раскладке.
Все числа ниже сняты харнессом из §8.1 на агентских промптах (ngram_prompts_agentic.json, temp 0, n_predict 256), кроме явно оговорённых. Сравнивать можно только строки внутри одной таблицы.
scripts/ngram_bench.py + прямой замер (scripts/ngram_busy_probe.py: читает /proc/<pid>/stat и gpu_busy_percent во время генерации):
| показатель | значение |
|---|---|
| governor / частоты | performance, 3392 МГц на всех 4 ядрах |
| CPU во время tg | 351 % из 400 % |
| GPU busy во время tg | 20 % (min 0, max 100) |
| VRAM во время tg | 3114 МБ, не меняется |
| инструкции CPU | avx f16c sse4_2 — без AVX2 и без FMA |
CPU загружен почти в потолок, видеокарта простаивает четыре пятых времени. Сборка уже подгружает libggml-cpu-ivybridge.so, то есть лучшее ядро под этот процессор используется; пересборка llama.cpp не добавит инструкций, которых у Ivy Bridge нет. Это же объясняет, почему внешние референсы на GTX 1080 дают ~24 t/s: там сопоставимая карта, но процессор с AVX2.
Две независимые раскладки, обе проигрывают.
А. Только экспертные тензоры (--n-cpu-moe N, attention остаётся на GPU):
--n-cpu-moe |
слоёв экспертов на GPU | tg t/s | pp t/s | VRAM МБ |
|---|---|---|---|---|
| 999 (все в RAM) | 0 | 10,36 / 10,23 | 89 / 97 | 3388 |
| 36 | 4 | 10,28 | 93,7 | 4509 |
| 32 | 8 | 7,75 / 7,66 | 61,4 / 61,4 | 5440 |
| 28 | 12 | 8,45 | 64,6 | 6538 |
| 24 | 16 | 9,94 | 68,3 | 7636 |
| 20 | 20 | 10,04 / 9,42 | 85,7 / 83,3 | 8147 |
Провал в середине диапазона воспроизведён дважды, pp на -n-cpu-moe 32 совпал до сотых (61,38 в двух прогонах). Раскладка действительно менялась (121 → 61 CPU-буфер), ошибки в логе отсутствуют.
Б. Целые слои (-ngl N --n-cpu-moe 0), чтобы убрать round-trip по PCIe внутри слоя:
-ngl |
что на GPU | tg t/s | pp t/s | VRAM МБ |
|---|---|---|---|---|
| 99 | только attention (эталон) | 10,10 | 88,4 | 3110 |
| 4 | + 4 целых слоя | 5,55 | 48,2 | 2075 |
| 8 | + 8 целых слоёв | 6,19 | 50,3 | 3362 |
| 12 | + 12 целых слоёв | 6,41 | 57,5 | 4538 |
Вывод: Vulkan/RADV на Polaris хорошо считает attention и плохо — MoE. GPU на этом хосте полезен ровно там, где уже используется. Дальнейшее «загрузить видеопамять» ухудшает результат.
При принятии 0,83 теоретический потолок спекуляции 1 + α = 1,83×, фактически 1,21×. Причина в §9.1: на системе, ограниченной вычислениями, проверка батча из двух токенов стоит примерно вдвое дороже одного токена, и выигрыш остаётся только от дешёвого драфта. На системах, ограниченных полосой памяти, проверка почти бесплатна — там же и наблюдаются большие ускорения. Поэтому n_max=1 здесь не «недотюнинг», а потолок.
Проверено и закрыто: -t 3 хуже четырёх потоков на 11 % (9,04 против 10,10); конкуренция за общий L3 не подтвердилась.
5 агентских промптов, одинаковые флаги, меняется только файл модели:
| квант | tg t/s | pp t/s | PPL (наш корпус) |
|---|---|---|---|
| IQ3_XXS (14,07 ГБ) | 9,96 | 96,0 | 3,1846 |
| IQ2_M (11,88 ГБ) | 11,91 | 104,4 | 3,2441 (+1,9 %) |
Быстрее на каждом из пяти промптов без исключения; на ag-code-fix оба прогона выдали ровно 90 токенов при одинаковой статистике драфта (44/46), то есть разница не в тексте.
Perplexity считалась llama-perplexity на 40 КБ ассистентских реплик из корпуса (~/ngram/ppl_corpus.txt), 5 окон по 2048; хуже у IQ2_M в каждом окне. Оговорка: ppl ловит потерю языка и почти не ловит потерю структуры — сломанный tool-call или испорченное рассуждение она не покажет. Для агентных задач 1,9 % — нижняя граница цены, а не полная.
Запись в §2.3 («IQ2_M 6,11 против IQ3_XXS 6,47») относится к другому скрипту и снята без MTP — для текущей конфигурации она неверна.
-ub/-b при -c 131072, IQ2_M, префил 13 283 токенов:
-ub / -b |
pp t/s | время | VRAM МБ |
|---|---|---|---|
| 512 / 2048 (дефолт) | 105,3 | 126 с | 3799 |
| 1024 / 2048 | 151,5 | 88 с | 4294 |
| 2048 / 4096 | 179,6 / 179,4 | 74 с | 5073 |
| 4096 / 4096 | 129,2 / 130,9 | 102 с | 5559 |
| 8192 / 8192 | — | vk::Queue::submit: ErrorDeviceLost |
6837 |
Оптимум — 2048; 4096 воспроизводимо хуже, 8192 роняет Vulkan-устройство (после падения процесс завершается, VRAM возвращается к десктопным ~652 МБ). На генерацию флаг почти не влияет: 11,68 против 11,91 (−2 %, уровень шума), при pp 173,4 против 104,4.
От кванта флаг не зависит: на IQ3_XXS тот же -ub 2048 даёт 103,4 → 165,9 t/s (+60 %), VRAM 5358 МБ.
Кривая префила по длине (дефолтный батч, IQ2_M, -c 131072), снята scripts/ngram_pp_probe.py:
| токенов | pp t/s | время |
|---|---|---|
| 3 365 | 118,7 | 28 с |
| 6 671 | 119,9 | 56 с |
| 13 283 | 114,7 | 116 с |
| 26 507 | 95,7 | 277 с |
Квадратичный член (attention) растёт заметно уже на 26k. При -ub 2048 линейная часть падает с 8,15 до ~4,7 мс на токен, квадратичная остаётся; отсюда оценка полного префила 128k — ~22 минуты против ~30 при дефолте. На практике важен не этот случай, а прирост новых токенов при живом KV-кэше: там работает именно линейная часть.
-m Qwen3.6-35B-A3B-MTP-UD-IQ2_M.gguf -c 131072 -ngl 99 --n-cpu-moe 999 -fa on -ctk q8_0 -ctv q8_0 -t 4 -np 1 -b 4096 -ub 2048 --spec-type draft-mtp --spec-draft-n-max 1 --no-webui
Модель гибридная: из 40 слоёв обычный KV держат ~10, остальные работают на recurrent-state. Отсюда бюджет:
| статья | размер |
|---|---|
| модель (attention/dense) на GPU | ~2,2 ГБ |
| KV на 128k (q8_0), по 85 МиБ на 8k | ~1,36 ГБ |
| RS-буфер, 4 слота | ~0,50 ГБ |
compute-буферы при -ub 2048 |
~1,0 ГБ |
| десктоп | ~0,6 ГБ |
| итого VRAM | ~5,1 ГБ из 8,2 |
Эксперты (11,9 ГБ) остаются в RAM; при 19 ГБ это спокойно, Private_Clean mmap-страницы видны как buff/cache. Свободных ~3 ГБ VRAM не нужны ни под экспертов (§9.2), ни под KV — они остаются запасом.
-ub (3072) и -b;sudo dmidecode, без root не снять) — чтобы окончательно закрыть вопрос полосы, хотя §9.1 уже говорит, что упираемся не в неё.