🎓 Бот не глупый, а медленный — что показали замеры 26–27.07

О чём это

Месяц мы улучшали качество оценки позиции: новые фичи, чистка меток, ретрены, рескоринг. Всё это упиралось в одну и ту же стену ~66% против aggressive, и мы назвали её «потолком дисперсии костей».

Замеры этих двух дней показали, что стена стоит не там, где мы думали. Бот в бою успевает рассмотреть не 24 своих хода-кандидата, как мы считали, а один-два. Он не столько плохо оценивает позиции, сколько не успевает их посмотреть. Это меняет приоритет работ: следующий рычаг — не «умнее», а «быстрее».


1. Как устроен бот (короткое напоминание)

Чтобы дальше было понятно, три термина.

Ход-кандидат. За свой ход бот может сделать 1–3 микрохода по выпавшим костям. Всех вариантов такого полного хода бывают сотни. Перебирать их все вглубь невозможно, поэтому бот сначала быстро ранжирует их простой прикидкой (по материалу) и берёт лучшие K штук. В проде K = 24.

Узел случайности (chance node). Для каждого кандидата бот смотрит: «допустим, я так сходил — что ответит соперник?» Но кости соперника ещё не брошены, а у трёх костей 56 различных комбинаций. Поэтому бот перебирает все 56 бросков, для каждого находит лучший ответ соперника и усредняет с учётом вероятностей. Это и есть «узел случайности» — самая дорогая часть.

Эффективная ширина. Сколько кандидатов бот реально успел досчитать до того, как кончилось время на ход. Мы всегда предполагали, что это и есть K=24. Оказалось — нет.


2. Замер первый: кривая по ширине поиска (без часов)

Досчитали недостающие точки, теперь картина полная. Винрейт против aggressive, чем больше K — тем шире поиск:

Koracle-3rich_1m
8 (n=400)56.5%60.5%
16 (n=100)64.0%66.0%
24 (n=400, прод)69.3%65.5%
32 (n=100)66.0%66.0%
48 (n=100)60.0%66.0%

rich_1m выходит на ровное плато 66% начиная с K=16 — расширять поиск дальше бессмысленно. Вывод, который мы тогда сделали: упёрлись в потолок, дальше только качество модели.

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


3. Замер второй: та же арена, но с часами

Прогнали три модели на трёх контролях времени (50 партий на ячейку, 1 ядро, K=24):

Модель1+03+25+3
oracle-350.0%54.0%56.0%
rich_1m46.0%60.0%56.0%
safe_1m54.0%58.0%60.0%

Разрыв 6–16 процентных пунктов

Те же самые модели без часов дают 64–66%, а под часами — 50–60%. Разница огромная, и она не объясняется дисперсией костей: это цена медленности.

Ещё выразительнее — время на ход. Медианный ход упирается в бюджет на всех контролях: на 1+0 бюджет ~1.9 с, медиана 2.4–2.5 с; на 5+3 бюджет ~12.5 с, медиана 13.5–14 с. То есть поиск почти никогда не заканчивается сам — его обрывает будильник.


4. Главная находка: бот успевает 1–2 кандидата из 24

Складываем два числа.

Сколько стоит один кандидат. В issue #496 замерено на ядре Ampere A1 (это примерно то, что даёт Cloud Run): один кандидат при K=24 — медиана 6.5 секунды.

Сколько времени даётся на ход. При ладдерном контроле Fischer(300,3) наш TimeManager выделяет на первый ход ~12.8 с, минус буфер 300 мс ≈ 12.5 с.

Бот успевает примерно два кандидата из двадцати четырёх. На контроле 1+0 бюджет (1.9 с) меньше стоимости одного кандидата — там медианный ход не успевает вообще ничего и играет то, что предложила грубая материальная прикидка.

Это переворачивает интерпретацию всей главы про «потолок». Мы мерили кривую по K без часов и видели плато на 66%. Но в проде бот работает не на K=24 и даже не на K=16 — он работает на эффективном K≈2. По кривой из раздела 2 видно, что это область, где сила ещё круто растёт с шириной. То есть 6–16 п.п. разрыва — это возвращаемые очки, в отличие от настоящего потолка дисперсии.


5. Находка вторая: 78% работы делается впустую

Отдельно замерили ветвление: взяли 162 позиции из партий greedy-против-greedy и для каждой прогнали все 56 бросков.

  • Ходов-кандидатов на одну пару (позиция, бросок): в среднем 544, медиана 136, максимум 10 516.
  • Строк для оценки в одном узле случайности (то есть на одного кандидата): в среднем 29 592.

А теперь главное. Мы посчитали, сколько среди этих строк различных позиций на доске:

Только 21–22% листьев уникальны

Остальные ~78% — это одна и та же позиция, полученная разным порядком микроходов. Сделать сначала ход пешкой, потом конём — или наоборот — приводит к одной доске, но мы честно считаем её моделью дважды.

Дедупликация перед вызовом модели сокращает работу примерно в 4.6 раза, и это точное преобразование: минимум по набору с дубликатами равен минимуму по уникальным. Никакой потери качества, никакого переобучения — просто перестаём платить за одно и то же.

Для масштаба: сейчас один ход при K=24 — это до 1344 отдельных вызова ONNX-модели (24 кандидата × 56 бросков), каждый со своими накладными расходами на переход в нативный код.


6. Находка третья: прод отстал от движка

Проверили, что реально крутится в Cloud Run:

  • Движок в боте — 1.10.4. А фикс дисциплины времени (#496, дедлайн проверяется внутри узла случайности, а не только между кандидатами) вышел в 1.10.6, телеметрия RootSearchStats — в 1.10.5. То есть вся работа последних дней до прода не доехала: там до сих пор старое поведение, где первый кандидат всегда досчитывается до конца с перерасходом времени.
  • statsSink не подключён — бот не логирует, сколько кандидатов он реально успел. Поэтому «1–2 кандидата» выше — это вывод из расчёта, а не измерение. Подключение — правка в одну строку (после бампа версии).
  • ORACLE_CANDIDATE_LIMIT=24 и --cpu 1 подтверждены напрямую в конфигурации сервисов.

7. План

Порядок важен: сначала измеримость, потом скорость, потом качество модели.

Фаза 0 — гигиена прода — ✅ ВЫПОЛНЕНА 27.07

Движок в dicechess-bot-gcp-onnx поднят с 1.10.4 до 1.10.6, statsSink пишет структурированную JSON-строку на каждый ход (bot-gcp-onnx #2, PR #3). Это разом чинит баг дисциплины времени в проде и превращает наши расчёты в настоящие цифры: Cloud Run разбирает JSON-строку из stdout в jsonPayload, так что candidatesCompleted и fellBackToPreRank можно фильтровать и агрегировать прямо в Cloud Logging.

Осталось: после выкатки снять окно наблюдения и заменить расчётные «1–2 кандидата» на измеренное распределение.

Отдельно стоит попробовать --cpu 2: это самый тупой рычаг скорости, стоит денег, а не инженерных усилий, и заодно калибрует, сколько вообще стоит любое программное ускорение.

Фаза 1 — скорость (основной рычаг) — ✅ ВЫПОЛНЕНА 27.07

  1. Дедупликация листьев внутри узла случайности — ✅ engine #505 смёржен. Замерено на реальной модели: 1.89–2.17× по времени, результат бит-в-бит идентичен прежнему (винрейт, разбивка по цветам, вся телеметрия зевков до последней цифры).

    Попутная засада, стоившая отдельного расследования: первая рабочая версия оказалась в 18.6 раза медленнее. Причина в том, что сгенерированный Scala hashCode для case class хеширует через productElement, который возвращает Any — то есть упаковывает в объекты все одиннадцать примитивных полей на каждом вызове. На пути, который исполняется по разу на лист, это лавина мусора. Ручной hashCode на арифметике Long превратил провал в ускорение.

  2. Батчинг вызовов модели чанками — ❌ engine #506 закрыт без реализации: замеры опровергли идею.

Почему батчинг не понадобился

Рассуждение было такое: 1344 отдельных вызова модели на ход — это много, надо слить их в несколько крупных. Прежде чем писать код, замерили, сколько на самом деле стоит один вызов.

Оказалось, накладные расходы на вызов полностью амортизируются уже к ~128 строкам: при чанке 1 строка стоит 9.61 мкс, при 128 — 2.25 мкс, при 1024 — 2.13 мкс, дальше кривая плоская. А наш поиск уже работает далеко за этим коленом: в среднем 231 строка на вызов.

Ключевая цифра — распределение, взвешенное по строкам: вызовы размером ≤128 строк составляют 63% всех вызовов, но несут лишь 7.7% работы. Мелкие вызовы многочисленны, но почти пусты; вся реальная нагрузка сидит в крупных вызовах по 512–2048 строк, которые и так оптимальны.

Итог: достижимая экономия ~1.8% времени хода. За это не стоит буферизовать листья через броски, тянуть сегменты через границы чанков и рисковать вернуть регрессию #496.

Где была ошибка. Scaladoc onnxEvalBatch говорит «стоимость определяется накладными расходами на вызов» — и это правда, но для маленьких батчей. Мы применили верное утверждение вне области его действия: батчинг по броскам давно уже даёт сотни строк на вызов.

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

Фаза 2 — измерительный инструмент — 📋 engine #508

Парная арена против сильного соперника (oracle-3, а не aggressive), n≥800, с часами. Причина: при n=400 стандартная ошибка ~2.4 п.п., то есть всё, что меньше ~3 п.п., мы просто не видим — именно поэтому серия «probe-гейт стал лучше, а побед ноль» так и осталась неразрешённой. Начать с пилота на 100 парах: общие сиды костей могут дать меньше выигрыша в дисперсии, чем хочется, потому что траектории расходятся после первого же различия в ходах.

Фаза 3 — модель: lambdarank-пре-ранкер — 📋 dicechess-ev #14 (приватный репо)

Единственный ретрен, который сейчас выглядит осмысленным, и вот почему. Под часами top-1 пре-ранкера буквально становится ходом бота (это же и есть fallback, когда время вышло). А ранжируем мы до сих пор чистым материалом — той самой слепотой, с которой началась вся история про зевки ферзя.

LightGBM умеет lambdarank из коробки, ONNX-экспорт ранкера поддержан, шов в движке уже есть (параметр preRank). Недостающий кусок ровно один — генератор кандидат-групп; все нужные примитивы публичны, скелет можно взять из EnrichSafeApp.

Подробный разбор, чем ранжирующая задача отличается от регрессии и почему это важно именно здесь — Ranzhirovanie-protiv-regressii-lambdarank.

Приятная деталь: фазы 1 и 3 усиливают друг друга — дедуп ускоряет и генерацию обучающих данных, и сам поиск.

Куда смотреть дальше по скорости

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


8. Что сознательно не делаем

Прогнали кандидатов через адверсариального критика по собранным данным. Отклонено:

ИдеяПочему нет
Глубина 3 (селективная)Одна точка расширения = ещё один узел случайности ≈ 30 тыс. строк ≈ 6.5 с. Даже самый скромный вариант (top-1 × 5 бросков) добавляет +32 с к ходу при бюджете 1.9–12.5 с. Только как офлайн-анализ.
Оверлей PieceSafety на листьяхТот же механизм, что у safe_1m, — а он дал ноль побед. Плюс размерно произвольный коэффициент: сантипешки подмешиваются в шкалу «вероятность победы × 10000».
Soft-min по ответу соперникаМеханически возвращает слепоту к подставленным фигурам: линия «фигура висит, но вдруг не заметит» получает завышенную оценку.
Углубление книги дебютов56 вариантов броска истончают покрытие за 2–3 полухода. Выигрыш ~1–2 п.п. — ниже порога видимости.
Кэш позиций через canonicalKeyТяжело (1–3 сериализации FEN на обращение) ради остатка, который после дедупа даже не измерен. Дедуп проще и берёт измеренный выигрыш.
Расчехлить oracle-4 (1-ply KCP)Блокер по дедлайну снят в #497, но цена строки у KCP ~18× от rich — плохая сделка ровно там, где связывающее ограничение — часы.

9. Ссылки

  • Предыстория и диагноз зевков: Pochemu-bot-zevaet-ferzya (там же кривая по K, раздел 6.2)
  • Каталог ранее рассмотренных вариантов: Katalog-variantov-usileniya-bota
  • Гранулярность дедлайна: engine #496 → PR #500
  • Телеметрия поиска: engine #494 → PR #495
  • Таймингованная арена в движке: engine #498 → PR #502
  • Документация по управлению временем: страница Time Management
  • Сырые логи замеров: dicechess-ev/logs/kcurve-2026-07-26/ и logs/timed-arena-2026-07-26/