Оптимизация KingCaptureProbability
Путевая заметка о том, как мы ускоряли KingCaptureProbability (KCP) — горячий путь
Monte-Carlo-оценщика. Здесь зафиксировано что отгрузили,
что попробовали и откатили, и — главное — почему. Самая ценная часть — отрицательный
результат: дисциплина «сначала замерь, потом отгружай» один раз уже спасла нас от регрессии,
которая выглядела как очевидное улучшение.
TL;DR
1A + 1B дали честные ~1.7× (точно, bit-for-bit). Вариант A («доска без mailbox») был корректен, но медленнее — откатан. Урок:
mailbox— это не лишняя память, а O(1)-индекс поиска фигуры; убрав его, мы заставили код сканировать битборды и потеряли больше, чем сэкономили.
Почему KCP — это горячий путь
KCP отвечает на один вопрос: «с какой вероятностью соперник в свой следующий ход срубит нашего короля (или ферзя)?». Он перебирает все 216 исходов трёх кубиков (сгруппированных в 56 взвешенных мультисетов) и для каждого делает DFS по микроходам — есть ли путь к взятию цели.
Ключевой факт: MonteCarloEquity.singleRollout зовёт kingCaptureProbability на каждом
полуходе каждого роллаута. Это и есть аналитический член Рао-Блэквеллизации (см.
🎓 Rao-Blackwellized Monte-Carlo):
Каждое — это один вызов KCP. На один роллаут их maxPlies штук, на оценку — сотни
роллаутов. Отсюда вывод: ускорение KCP пропорционально превращается в больше роллаутов за тот
же бюджет времени, то есть в более сильного бота и более узкий доверительный интервал.
Сюрприз бенчмарка
На мидгейме (позиция kiwipete) KCP стоил миллисекунды, а не микросекунды (король ~7 мс до оптимизации). Именно это объясняло, почему Monte-Carlo-бот «висел» на дебюте, пока ему не дали тайм-бюджет.
Как мерили
Всё через JMH (mise run bench:quick), отдельный бенчмарк KingCaptureProbabilityBenchmark
с @Param по позициям (initial / kiwipete / endgame) × защитник (white / black). Аллокации —
через -prof gc. Правило для движка действует жёстко: изменение горячего пути не уходит в main
без A/B-замера (см. тесты для корректности и JMH — для скорости).
Что отгрузили (1A + 1B, точно и замерено)
| Шаг | Идея | Замер (чистый A/B) | Корректность |
|---|---|---|---|
| 1A | Вынести расчёт KCP для нулевого полухода из цикла роллаутов — он одинаков для всех роллаутов одной оценки, считаем один раз. | меньше повторных вызовов на оценку | golden bit-for-bit |
| 1B | В captureDFS кодировать оставшийся пул кубиков прямо в int-битах GameFlags (withDiceSlotsOf), без аллокации List/Option на каждый узел DFS. | король 6945 → 4344 µs, ферзь 5964 → 3468 µs (≈1.6×) | дифф-корпус, tol 1e-12 |
Совокупно ~1.7×, оба точные (числа не «примерно те же», а бит-в-бит совпадают с эталоном, снятым на pristine-коде). Король считается ровно как ; для ферзя сохраняем документированный лёгкий оверэстимейт (правило максимальной длины микроходов) — намеренно.
Что попробовали и ОТКАТИЛИ — и почему это ценно
Вариант A: makeMove без mailbox. Гипотеза была красивой: профиль показывал ~50 МБ
аллокаций на вызов, и казалось, что доминирует клон 64-клеточного массива mailbox, который
makeMove копирует на каждом шаге DFS. Идея — не копировать его вообще, а тип фигуры брать
прямо из битбордов (pieceAt).
Сделали. Корректно — property-тест подтвердил, что новый путь бит-в-бит эквивалентен
настоящему makeMove на всех ветках (тихие ходы, взятия, двойной шаг пешки, взятие на проходе,
рокировка, превращение). И всё равно откатили, потому что замер показал регрессию:
| Версия | kiwipete, µs/op |
|---|---|
| post-1B (с mailbox) | ~4100 |
| вариант A (без mailbox) | ~5400 |
Урок:
mailbox— это не балласт, а O(1)-индекс
mailboxхранит «какая фигура на клетке X» за одно обращение по индексу. Убрав его, мы заставилиpieceAtсканировать битборды (~3 раза на узел DFS), чтобы узнать тип фигуры. Этот скан по CPU дороже, чем сэкономленныйarraycopy64 интов. Корректно ≠ быстрее.
И вторая половина урока — где на самом деле те 50 МБ: в них доминируют не клоны mailbox, а
копии неизменяемого GameState и List[Move], который generateMoves аллоцирует на каждом
узле. То есть гипотеза «виноват mailbox» была опровергнута замером — ровно для этого замер и нужен.
Мораль
- Сначала замерь, потом отгружай. Изменение, которое «очевидно быстрее», оказалось медленнее.
Без JMH-гейта оно бы уехало в
mainи тихо замедлило бота. - Профилируй, а не угадывай, что доминирует. «50 МБ» ≠ «виноват mailbox». Аллокации сидели
в
GameState-копиях иList[Move]. - Корректность и скорость — две независимые проверки. Вариант A прошёл property-тест и всё равно был отброшен. Одно не заменяет другое.
- Отрицательный результат — это знание. Теперь мы знаем, что
mailboxоставлять, а атаковать надоGameState-копии иList[Move].
Что дальше (не сделано, осознанно)
Настоящий выигрыш требует сохранить O(1)-поиск и убрать копирование одновременно. Это уже не быстрый шаг, а рефактор:
- Вариант B — мутабельная scratch-доска
make/undo.mailboxмутируется на месте и откатывается, а не клонируется → O(1)-индекс сохранён И клон убран. Требует property-теста инвариантаundo ∘ make = id. - Либо убрать
List[Move]изgenerateMovesна горячем пути (трогаетMoveGenerator).
Оба варианта оправданы только если выигрыш перевесит сложность и риск. Поскольку 1A + 1B уже дали ~1.7×, на этой точке мы сознательно остановились.
Также отвергнуто (проверкой, не вкусом)
- Прунинг 56 мультисетов по «лучу-надмножеству». Ложно для паттернов
AAB/AAA: гнутые пути слайдера (напр., ферзьb1 → c3черезb3) дают недосчёт. Ни один существующий тест это не ловит — поэтому не трогаем. - Транспозиционный кэш для plies ≥ 1. Hit-rate ≈ 0 на случайных блужданиях роллаута.
- Параллелизм внутри одного вызова. Накладные расходы съедают выигрыш на микросекундных задачах. Параллелить роллауты (уровнем выше) — возможно, но это JVM-only и только после оптимизации аллокаций.
Следы в коде и трекере
Исходник: shared/.../search/KingCaptureProbability.scala, MonteCarloEquity.scala,
domain/GameFlags.scala (репозиторий dicechess-engine-scala). Эпик #385; 1A — #386,
1B — #388; вариант A и разбор — #391. 1A + 1B влиты в main.
Связанные заметки
- 🎓 Rao-Blackwellized Monte-Carlo — что такое роллаут и зачем KCP вызывается на каждом полуходе.
- 🎓 Что такое Property-Based Testing — чем проверяли корректность варианта A.
- Dice Roll Encoding — как 216 исходов кубиков кодируются в движке.