# N-gram таблицы для Qwen3.6-35B-A3B — передача задачи в новую сессию

Дата подготовки: 2026-11-05.
Этот файл — вся контекстная база для продолжения работы в новой сессии. Кода
для самих n-gram таблиц ещё не написано: сделана только инфраструктура
(экспорт корпуса из Navi) и разведка хоста.

---

## 1. Задача

Выяснить, **насколько n-gram таблицы, построенные по нашему собственному
диалоговому корпусу, ускоряют генерацию** на модели `Qwen3.6-35B-A3B` на хосте
`.135`.

Считаем, что ответ получен, когда есть:

1. собранная из корпуса n-gram таблица в формате, который llama.cpp грузит
   штатно (см. §3 — он это умеет, вопреки первому впечатлению);
2. замер `tg t/s` и acceptance rate на **фиксированном наборе независимых
   промптов** в четырёх конфигурациях: без спекуляции, только MTP,
   только n-gram-корпус, MTP + n-gram-корпус;
3. вывод в процентах по каждой конфигурации и решение, стоит ли это
   внедрять в Navi.

Не делаем: не патчим llama.cpp без нужды (см. §3), не трогаем прод Navi,
не меняем модель/квант.

---

## 2. Что уже готово

### 2.1 Экспорт корпуса из Navi (задеплоено на прод)

Реализовано и проверено end-to-end: периодический снапшот диалогов,
age-шифрование в одну сторону, HTTP под машинным bearer-токеном.
Документация — [`docs/export.md`](docs/export.md), клиент —
[`scripts/navi_export.py`](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, одна запись на строку):

```json
{"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 * * * *`), поэтому сейчас
корпус обновляется только вручную.

### 2.2 Хост .135

- Bazzite 7.2.11 (Fedora 44 base, immutable), kernel 7.2.11; Python 3.14.8.
- Intel i5-3470, 4 ядра; 19 ГБ RAM (модель 14 ГБ живёт в RAM).
- AMD RX 580 2048SP через RADV/Vulkan, **без ROCm**; 8 ГБ VRAM.
- llama.cpp: **prebuilt build b11960** (`~/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-совместимом виде).

### 2.3 Уже измеренные числа

Базовый бенч (`~/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 (модель повторяла промпт), а не реальный выигрыш. На эти числа
опираться нельзя.

---

## 3. Главное открытие: llama.cpp УМЕЕТ грузить внешнюю n-gram таблицу

В прошлой разведке было сказано «все 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).

### 3.1 Как это работает внутри

`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).
На каждом шаге:

1. из context/dynamic берутся n-граммы длиной 1..4; кандидат принимается,
   только если он встречается и в **static**-таблице по 2-грамме (она
   работает как фильтр правдоподобия) и проходит пороги
   `draft_min_sample_size_strict = {4,3,2,2}` /
   `draft_min_percent_strict = {75,66,66,66}`;
2. если ничего не нашлось — пробуется чисто static по 2-грамме с порогами
   `sum ≥ 2` и `top ≥ 50 %`;
3. если и там пусто — драфт закончился.

Практические следствия, важные для плана:

- **static участвует только 2-граммами** (`LLAMA_NGRAM_STATIC = 2`). Ключ
  в файле — 4 слота, у 2-граммы два последних равны `LLAMA_TOKEN_NULL (-1)`.
  Значит 3- и 4-граммы в файле — мёртвый вес: они загружаются в память,
  но при поиске по ним не попадают. **Собирать только 2-граммы.**
- Корпус напрямую даёт лишь слабый (биграммный) драфт; основной вклад
  static — валидация драфтов из контекста. Поэтому ждать «+30 % от корпуса»
  наивно; измерять надо честно.
- Цепочка типов: `--spec-type a,b` пробует `a`, и если он дал драфт,
  `b` для этой последовательности уже не вызывается. Порядок = приоритет.

### 3.2 Формат файла таблицы

Файл — это дамп `std::unordered_map`, без заголовка и без счётчика записей.
Нативные little-endian `int32`:

```text
повторять до 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.
Порядок записей произвольный.

### 3.3 Как собрать таблицу из корпуса

Рекомендуемый путь — свой билдер на Python (≈80 строк), **без сборки C++**:

1. распаковать `~/ngram/messages.jsonl.gz`;
2. токенизировать тем же GGUF, с которым потом будем обслуживать
   (`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 ГБ модели не бесплатна;
3. считать 2-граммы **внутри каждого сообщения отдельно** (границы
   сообщений не должны образовывать пары);
4. записать файл в формате §3.2; распределения можно обрезать
   (top-k, например k=8) — на качество драфта это почти не влияет,
   а размер и память режет заметно;
5. опционально: собрать варианты по подмножествам (ассистентские реплики
   отдельно; только длинные сообщения; только «в стиле Navi»).

Альтернатива без своего кода — «прогреть» dynamic-таблицу: запустить
`llama-server --spec-type ngram-cache -lcd cache.bin` и прогнать корпус
промптами; context-таблица каждого запроса вливается в dynamic, при выходе
файл сохраняется. Минус: каждое сообщение требует генерации хотя бы одного
токена, суммарно это часы. Оставляем как резервный вариант для проверки,
что наш билдер даёт тот же файл, что и родной код.

### 3.4 Что обязательно проверить до написания билдера

- Смоук: `llama-server --spec-type ngram-cache -lcs <файл из пары записей>`
  действительно стартует и не падает на загрузке (проверяет формат).
- Память: модель IQ3_XXS занимает 14 ГБ из 19 ГБ RAM. `unordered_map` при
  загрузке раздувается в разы относительно файла; до старта замерить RSS
  на маленькой таблице и экстраполировать. При риске OOM — брать подвыборку
  корпуса (2–4 МБ) и/или top-k обрезку.
- Совместимость словаря: таблица валидна только для той же модели/кванта
  (id токенов). Кванты IQ2_M и IQ3_XXS должны иметь одинаковый словарь, но
  это надо подтвердить, прежде чем переиспользовать таблицу между ними.

---

## 4. Протокол измерения

Все четыре конфигурации — одним и тем же скриптом, одним контекстом,
одним набором промптов, с перемешиванием порядка:

| # | `--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` — токенов/с на генерации (главная метрика);
- acceptance rate и средняя принятая длина драфта — из логов спекуляции;
  статистика печатается на уровне TRC (`-lv 4` / `--log-verbosity 4`,
  строка `statistics ... #acc rate/pos = (...)`), поэтому сервер
  запускается с подробным логом, а из него берётся `grep statistics`;
- `pp` — чтобы убедиться, что спекуляция не просадила prompt processing;
- RSS процесса — цена таблицы.

Требования к промптам:

- **не** брать промпты из корпуса-источника: иначе получаем ровно тот
  self-echo, который уже один раз всех обманул (acceptance 1.0);
- набор: ~20 независимых промптов, похожих на реальные запросы к Navi
  (короткий вопрос, просьба на русском, код-задача, длинный инструктирующий
  промпт), каждый — 3 повтора, temp 0 и temp 0.7;
- одинаковый chat template и системный промпт во всех конфигурациях;
- сравнивать только внутри одного прогона: базы 6,47 и 8,49 t/s из разных
  скриптов между собой несопоставимы.

Ожидания, которые надо подтвердить или опровергнуть: static-биграммы по
чату дадут мало; больше даст комбинация «контекст + static-фильтр» на
повторяющемся boilerplate (системный промпт Navi, шаблоны tool-call).
Полезный контрольный прогон — `ngram-mod` и `ngram-map-k4v` (чисто
контекстные, корпус не нужен): если они уже дают +X %, то вклад именно
корпуса = разница с `ngram-cache`.

---

## 5. Открытые вопросы к владельцу

1. **Объём таблицы.** Весь корпус (17,5 МБ, ~4,5 млн токенов) при 19 ГБ RAM и
   14 ГБ модели — риск OOM. Начинать с подвыборки (2–4 МБ) или сразу
   пробовать весь и мерить RSS?
2. **Состав корпуса.** Только ассистентские реплики (18 242 из 23 914) или
   оба голоса? Второй вариант ближе к тому, что модель видит и генерирует.
3. **Приоритет.** MTP уже даёт +29 % без всякого корпуса. Цель — выжать
   максимум (тогда это стопка типов и подбор порогов) или именно измерить
   вклад собственного корпуса (тогда MTP — контроль, а не конкурент)?
4. **Обновление таблицы.** Пересобирать при каждом новом экспорте
   (раз в 4 часа) или по требованию? Пересборка — минуты, но требует
   свободной RAM.

---

## 6. Полезные команды и пути (без секретов)

```bash
# корпус на .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.

---

## 7. Чек-лист «задача закрыта»

- [x] таблица собрана и грузится без abort (`-lcs` стартует);
- [x] замерены A/B/C/D (плюс контроли E/F/G и свип MTP) на одном наборе
      промптов; по решению владельца остановились на 1 повторе, а не на 3;
- [x] acceptance rate и средняя длина драфта вытащены из логов, а не оценены;
- [x] self-echo исключён (промпты не из корпуса, acceptance < 1,0);
- [x] записаны RSS и размер файла таблицы;
- [x] вывод в процентах + рекомендация: внедряем / не внедряем.

---

## 8. Результаты (2026-11-05): n-gram таблицы закрыты

### 8.1 Инструменты

| Что | Где |
|---|---|
| Билдер таблицы | [`scripts/ngram_build.py`](scripts/ngram_build.py) — Python, токенизация через `/tokenize`, только 2-граммы внутри сообщения, top-k, фильтры уверенности |
| Харнесс замеров | [`scripts/ngram_bench.py`](scripts/ngram_bench.py) — поднимает `llama-server` на конфиг, `/apply-template` (со схемами инструментов) + `/completion` с `cache_prompt=false`, считает tg/pp/acceptance из `timings` |
| Промпты (общие) | [`scripts/ngram_prompts.json`](scripts/ngram_prompts.json) — 20 штук |
| Промпты (агентские) | [`scripts/ngram_prompts_agentic.json`](scripts/ngram_prompts_agentic.json) — 12 код/терминал/ssh/правка файлов |
| Схемы инструментов | [`scripts/ngram_tools_schema.json`](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`.

### 8.2 Формат и цена таблицы — подтверждено

- `--spec-type ngram-cache -lcs <файл>` стартует без abort, драфты идут.
- Таблица из 4,2 МБ assistant-текста (35 % корпуса): **234 659 ключей,
  389 319 пар, 7,8 МБ**.
- Загрузка в память: RSS **12 832 → 13 180 МБ**, то есть **+348 МБ**, ≈ **×45**
  к размеру файла. Отсюда потолок: файл больше ~20 МБ не влезет.

### 8.3 Замеры

Общие промпты (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 % |

### 8.4 Вывод

**Не внедряем.** Причины:

1. static-таблица используется только 2-граммами (`LLAMA_NGRAM_STATIC = 2`),
   а биграммы корпуса дают принятие 14–16 %;
2. сам механизм lookup-декодинга на этой машине стоит −43 % даже без всякой
   таблицы (E = 4,76 против A = 8,36): узкое место — CPU на MoE-экспертах,
   а цепочки до 8 токенов добавляют работу почти без принятия;
3. в стопке с MTP ngram-типы только портят (K/L/M ≤ B).

Оставляем **MTP, `n_max=1`**: +23 % на tg на агентских промптах.

### 8.5 Где запас на самом деле

Модель: 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 делает работу медленнее в любой раскладке.

---

## 9. Тюнинг хоста .135: что работает и что нет (2026-11-05)

Все числа ниже сняты харнессом из §8.1 на агентских промптах
(`ngram_prompts_agentic.json`, temp 0, `n_predict 256`), кроме явно
оговорённых. Сравнивать можно только строки внутри одной таблицы.

### 9.1 Узкое место — арифметика на CPU, а не память и не PCIe

`scripts/ngram_bench.py` + прямой замер
([`scripts/ngram_busy_probe.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.

### 9.2 Перенос экспертов в VRAM — закрыто, стало медленнее

Две независимые раскладки, обе проигрывают.

**А. Только экспертные тензоры** (`--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 на этом хосте полезен ровно там, где уже используется. Дальнейшее
«загрузить видеопамять» ухудшает результат.

### 9.3 Почему MTP не даёт обещанного

При принятии 0,83 теоретический потолок спекуляции `1 + α` = 1,83×,
фактически 1,21×. Причина в §9.1: на системе, ограниченной вычислениями,
проверка батча из двух токенов стоит примерно вдвое дороже одного токена,
и выигрыш остаётся только от дешёвого драфта. На системах, ограниченных
полосой памяти, проверка почти бесплатна — там же и наблюдаются большие
ускорения. Поэтому `n_max=1` здесь не «недотюнинг», а потолок.

Проверено и закрыто: `-t 3` хуже четырёх потоков на 11 % (9,04 против
10,10); конкуренция за общий L3 не подтвердилась.

### 9.4 Квант IQ2_M: +19 % скорости за +1,9 % perplexity

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 — для текущей конфигурации она неверна.

### 9.5 Батч префила: +71 % на приёме промпта

`-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`](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-кэше: там работает именно линейная часть.

### 9.6 Бюджет 128k и финальная конфигурация

```
-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 — они остаются запасом.

### 9.7 Что осталось непроверенным

- UD-Q2_K_XL того же размера, но с лучшим качеством на бит: скачать и
  померить ppl тем же способом;
- A/B IQ2_M против IQ3_XXS на реальных агентных задачах Navi — ppl не
  видит сломанных tool-call;
- промежуточные `-ub` (3072) и `-b`;
- конфигурация каналов и частота RAM (`sudo dmidecode`, без root не снять) —
  чтобы окончательно закрыть вопрос полосы, хотя §9.1 уже говорит, что
  упираемся не в неё.

