Конечно,
Во-первых, у него имеется очень много всяких разных проверок различной степени строгости. Если включить все, то можно получить тонну нерелевантных предупреждений. Поэтому его надо тщательно конфигурировать
Во-вторых, скорость работы. Работает
В-третьих, он иногда плохо работает с используемыми библиотеками. Пару диагностик пришлось отключить, поскольку иначе возникали проблемы с тестами на Google Test и бенчмарками на Google Benchmark
В-четвертых,
Из-за описанных выше проблем конфиг для clang-tidy получился довольно объемным: ссылка. Там же есть комментарии, которые объясняют, почему были отключены некоторые проверки
clang-tidy хорош, но у него есть и недостатки:Во-первых, у него имеется очень много всяких разных проверок различной степени строгости. Если включить все, то можно получить тонну нерелевантных предупреждений. Поэтому его надо тщательно конфигурировать
Во-вторых, скорость работы. Работает
clang-tidy довольно медленно, точно в несколько раз медленнее, чем компилятор. В SoFCheck'е это не доставляет больших проблем (разве что пару лишних минут на сборку в CI), а в больших проектах может быть критичноВ-третьих, он иногда плохо работает с используемыми библиотеками. Пару диагностик пришлось отключить, поскольку иначе возникали проблемы с тестами на Google Test и бенчмарками на Google Benchmark
В-четвертых,
clang-tidy местами забагован. И это становится причиной многих отключенных диагностик и NOLINT. Бывают и довольно странные баги, например, такой. Или такая проблема. Или ложноположительные срабатывания с if constexpr
Тем не менее, для меня плюсы перевешивают минусы, поэтому clang-tidy успешно используется в SoFCheck'еИз-за описанных выше проблем конфиг для clang-tidy получился довольно объемным: ссылка. Там же есть комментарии, которые объясняют, почему были отключены некоторые проверки
Решил попробовать и ради интереса переписать BattleField на Python (напоминаю, что это утилита для тестирования движков, подробнее здесь). Коротко расскажу о том, зачем это нужно и что получилось в итоге
Одно из направлений, в котором я хочу улучшить BattleField — это сделать его распределенным: чтобы часть партий проходили локально на ноутбуке, часть — в облаке на большом количестве ядер. Тестирование от этого станет значительно быстрее. А Pascal не очень хорошо дружит с сетевым программированием. Конечно, HTTP-клиент на нем написать можно, но гораздо приятнее это делать на других языках. Почему BattleField изначально написан на паскале, я уже объяснял здесь
Было решено переписать код на Python, потому что тогда 1) есть большие возможности по подключению в код всего, что угодно (например, всяких сетевых библиотек), 2) под него есть замечательная библиотека chess, которая реализует правила и взаимодействие с движками
BattleField — небольшой проект (~1200 строк, не считая часть из Chess256). После переписывания на Python бо́льшей части кода получилось примерно 700 строк. По этой причине работа много времени не заняла: всего-то 4-5 часов времени. Некоторые возможности, например, сохранение партий в PGN, пока не реализованы в версии на Python
Библиотека chess, хотя и написана на Python, реализована на битбоардах, а в BattleField используется наивная реализация правил. Я рассчитывал, что даже несмотря на медлительность Python'а, реализация в chess окажется быстрее моей
Одно из направлений, в котором я хочу улучшить BattleField — это сделать его распределенным: чтобы часть партий проходили локально на ноутбуке, часть — в облаке на большом количестве ядер. Тестирование от этого станет значительно быстрее. А Pascal не очень хорошо дружит с сетевым программированием. Конечно, HTTP-клиент на нем написать можно, но гораздо приятнее это делать на других языках. Почему BattleField изначально написан на паскале, я уже объяснял здесь
Было решено переписать код на Python, потому что тогда 1) есть большие возможности по подключению в код всего, что угодно (например, всяких сетевых библиотек), 2) под него есть замечательная библиотека chess, которая реализует правила и взаимодействие с движками
BattleField — небольшой проект (~1200 строк, не считая часть из Chess256). После переписывания на Python бо́льшей части кода получилось примерно 700 строк. По этой причине работа много времени не заняла: всего-то 4-5 часов времени. Некоторые возможности, например, сохранение партий в PGN, пока не реализованы в версии на Python
Библиотека chess, хотя и написана на Python, реализована на битбоардах, а в BattleField используется наивная реализация правил. Я рассчитывал, что даже несмотря на медлительность Python'а, реализация в chess окажется быстрее моей
В итоге получилось так. В однопоточном режиме обе реализации работают примерно одинаково. А в многопоточном режиме из-за GIL производительность проседает очень сильно. Скорее всего, эта проблема поправима: потоки практически не пишут в общую память, поэтому их получится разнести по разным процессам. Но стоит учитывать, что у оригинального Battlefield гораздо бо́льший потенциал ускорения, поскольку там правила написаны неоптимально. А шансов исправить существующую, довольно неплохо написанную библиотеку у меня меньше, чем переписать свой явно неоптимальный код
Вторая проблема с реализацией на Python заключается в следующем. Сейчас BattleField — это один бинарник без всяких зависимостей, который запустится везде. А вариант на Python требует установки зависимостей. Я сейчас не уверен, что следует усложнять процесс установки BattleField'а
В общем, мне следует тщательно обдумать, какую из версий развивать в дальнейшем (или же рассмотреть какие-то еще другие языки?) А пока что можно посмотреть, как выглядит BattleField на Python: ссылка
Вторая проблема с реализацией на Python заключается в следующем. Сейчас BattleField — это один бинарник без всяких зависимостей, который запустится везде. А вариант на Python требует установки зависимостей. Я сейчас не уверен, что следует усложнять процесс установки BattleField'а
В общем, мне следует тщательно обдумать, какую из версий развивать в дальнейшем (или же рассмотреть какие-то еще другие языки?) А пока что можно посмотреть, как выглядит BattleField на Python: ссылка
Давно здесь не было новостей, поэтому расскажу о том, что я сделал сегодня. А сегодня я выкатил новый релиз Battlefield'а: ссылка. Теперь можно выставить не только фиксированное количество времени на ход, но и полноценный контроль времени, например: "одна минута на игру" или "15 минут на 40 ходов плюс 5 секунд каждый ход"
Еще попробовал поиграть с ifrit'ом. У этого движка ~2400 рейтинга. Раньше с ним не удавалось сыграть по простой причине: ifrit нормально не поддерживал фиксированное время на ход. Если его попросить думать определенное количество времени, то он воспринимает этот лимит как нижнюю границу, и может начать думать больше. Battlefield'у это, естественно, не нравится; в таком случае он просто убивает процесс и засчитывает поражение. А с контролем времени ifrit играет как раз хорошо. Вот результаты на 100 играх, при 60 секундах на игру:
Еще попробовал поиграть с ifrit'ом. У этого движка ~2400 рейтинга. Раньше с ним не удавалось сыграть по простой причине: ifrit нормально не поддерживал фиксированное время на ход. Если его попросить думать определенное количество времени, то он воспринимает этот лимит как нижнюю границу, и может начать думать больше. Battlefield'у это, естественно, не нравится; в таком случае он просто убивает процесс и засчитывает поражение. А с контролем времени ifrit играет как раз хорошо. Вот результаты на 100 играх, при 60 секундах на игру:
Wins: 31, Loses: 59, Draws: 10Т.е. SoFCheck сливает ~100 Эло, что соотносится с моим представлением о его рейтинге примерно в 2300 Эло
Score: 36.0:64.0
Confidence interval:
p = 0.90: Second wins
p = 0.95: Second wins
p = 0.97: Second wins
p = 0.99: Second wins
Other stats:
LOS = 0.00
Elo difference = -99.95
Небольшое замечание. Я иногда сам поглядываю в код ifrit'а, чтобы посмотреть, как реализована та или иная эвристика. Этот движок, как и мой, написан на C++. Код там во многом довольно понятный и хорошо прокомментирован. Но я бы не назвал код ifrit'а хорошим, поскольку в нем много дублирующегося кода. Анализ для белых и для черных написан в двух отдельных функциях, которые очень похожи друг на друга. Интересно, конечно, почему автор не воспользовался NegaMax'ом
Еще во время сегодняшнего тестирования на Battlefield'е я обнаружил, что на маленьком контроле времени (менее 60 секунд на игру) SoFCheck может проиграть из-за просрочки по времени. Надо разбираться, почему так
Что-то я очень долго не писал ничего в код SoFCheck'а и в этот канал. Сегодня у меня наконец-то появилось немного свободного времени, чтобы всем этим заняться
Я решил по-нормальному парсить командную строку во всяких разных утилитах, написанных на C++. Сейчас там написана какая-то кастомная логика, которая смотрит на
По этой причине мне понадобилась библиотека для парсинга командной строки. Требования примерно такие:
- легко встроить в проект, не должно быть внешних зависимостей
- нормальная работа при сборке с флагом
- красивое отображение справки: если описание параметра или программы слишком длинное, его надо разбивать по строкам
Я посмотрел на имеющиеся библиотеки на awesome-cpp и выбрал для себя три:
1) https://github.com/bfgroup/Lyra: довольно неплохо работает, вообще не использует исключений, но не умеет переносить строки (см. issue)
2) https://github.com/taywee/args: хоть там и есть опция
3) https://github.com/jarro2783/cxxopts: работает, умеет собираться без исключений (в этом случае при ошибке в параметрах командной строки программа просто завершается), но не умеет переносить описание программы, если оно слишком длинное (завел issue про это)
В итоге я выбрал 3), а проблему с переносом строк решил уже своими костылями и обертками. После этого осталось только убрать кастомную логику и немного пропатчить код
Наконец, покажу реализацию функции
Я решил по-нормальному парсить командную строку во всяких разных утилитах, написанных на C++. Сейчас там написана какая-то кастомная логика, которая смотрит на
argc и argv напрямую. Я скоро собираюсь улучшать утилиту для сборки датасетов и добавить в нее больше флагов командной строки, но тогда кастомная логика станет довольно сложнойПо этой причине мне понадобилась библиотека для парсинга командной строки. Требования примерно такие:
- легко встроить в проект, не должно быть внешних зависимостей
- нормальная работа при сборке с флагом
-fno-exceptions (предполагается, что SoFCheck должен с ним нормально собираться)- красивое отображение справки: если описание параметра или программы слишком длинное, его надо разбивать по строкам
Я посмотрел на имеющиеся библиотеки на awesome-cpp и выбрал для себя три:
1) https://github.com/bfgroup/Lyra: довольно неплохо работает, вообще не использует исключений, но не умеет переносить строки (см. issue)
2) https://github.com/taywee/args: хоть там и есть опция
ARGS_NOEXCEPT, но библиотека работает с багами при ее использовании. Завел им issue по этому поводу3) https://github.com/jarro2783/cxxopts: работает, умеет собираться без исключений (в этом случае при ошибке в параметрах командной строки программа просто завершается), но не умеет переносить описание программы, если оно слишком длинное (завел issue про это)
В итоге я выбрал 3), а проблему с переносом строк решил уже своими костылями и обертками. После этого осталось только убрать кастомную логику и немного пропатчить код
Наконец, покажу реализацию функции
wordWrap() для разбиения длинных строк: ссылка. Код, конечно, не самый тривиальный, зато оптимальный и работает всегда за O(n)У меня развалился GitHub Actions со странной ошибкой на
git clone, которая проявляется время от времени:$ git clone --branch v1.5.5 https://github.com/google/benchmark/
Cloning into 'benchmark'...
error: RPC failed; curl 56 OpenSSL SSL_read: Connection was reset, errno 10054
error: 5898 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
Вот еще одно падение, на этот раз при клонировании Google Test:$ git clone --branch release-1.10.0 https://github.com/google/googletest/Надеюсь, что GitHub скоро поправит эту проблему, а то у меня весь CI сегодня красный :(
Cloning into 'googletest'...
error: RPC failed; curl 56 OpenSSL SSL_read: Connection was reset, errno 10054
error: 1885 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
Сегодня расскажу немного деталей про то, как я генерировал данные для обучения
Начну издалека. На высоком уровне шахматный движок устроен просто: перебор с огромным количеством отсечений. Но перебирать до конца игры слишком долго, поэтому надо остановиться на какой-то глубине и сказать, насколько хороша позиция. Для этих целей нужна оценочная функция
При написании оценочной функции есть большой простор для творчества. Как это происходит в SoFCheck, я уже писал ранее: раз, два. Еще упомяну, что здесь можно использовать нейросети, как это сделано, например, в Stockfish, но я пока не готов к такому в своем движке
В оценочной функции надо подбирать коэффициенты. Для этого нужно обучение на большом датасете. Как я уже писал ранее, для этого я гонял много партий SoFCheck с самим собой на Android-планшете в Termux. Почему именно на планшете?
1) почему бы и нет? :)
2) параллельно хочется на ноутбуке экспериментировать с движком, а не ждать, пока сгенерируется набор данных
3) современные мобильные процессоры довольно быстрые и ненамного уступают десктопным (на ноутбуке SoFCheck успевает анализировать 8 млн позиций в секунду, на планшете — 4.5 млн), при этом они еще и многоядерные
Теперь расскажу про технические проблемы, которые возникли на этом пути. Основная — надо скомпилировать свой код под ARM и Android. К счастью, в Termux можно поставить
С Battlefield сложнее. Дело в том, что он по историческим причинам написан на Free Pascal, а среди пакетов Termux паскаля нет. К счастью, у Free Pascal есть довольно неплохая кросс-компиляция. Через fpcupdeluxe можно поставить все необходимое: Free Pascal и Lazarus (IDE под Free Pascal + система сборки) любой версии, а также необходимые кросс-компиляторы
Ради интереса я решил глянуть с исходники самого fpcupdeluxe на GitHub. Код там местами написан ужасно. Вот вам, например, функция на 1025 (!) строк: ссылка. Зато работает :)
После кросс-компиляции Battlefield, к сожалению, не запустился и выдавал ошибку при запуске:
Наконец, надо как-то забрать с планшета полученный датасет. Для этого можно, например, поднять в Termux сервер ssh и подключиться к нему по SFTP. Это в целом полезная штука, которая позволяет гонять данные между устройствами
Начну издалека. На высоком уровне шахматный движок устроен просто: перебор с огромным количеством отсечений. Но перебирать до конца игры слишком долго, поэтому надо остановиться на какой-то глубине и сказать, насколько хороша позиция. Для этих целей нужна оценочная функция
При написании оценочной функции есть большой простор для творчества. Как это происходит в SoFCheck, я уже писал ранее: раз, два. Еще упомяну, что здесь можно использовать нейросети, как это сделано, например, в Stockfish, но я пока не готов к такому в своем движке
В оценочной функции надо подбирать коэффициенты. Для этого нужно обучение на большом датасете. Как я уже писал ранее, для этого я гонял много партий SoFCheck с самим собой на Android-планшете в Termux. Почему именно на планшете?
1) почему бы и нет? :)
2) параллельно хочется на ноутбуке экспериментировать с движком, а не ждать, пока сгенерируется набор данных
3) современные мобильные процессоры довольно быстрые и ненамного уступают десктопным (на ноутбуке SoFCheck успевает анализировать 8 млн позиций в секунду, на планшете — 4.5 млн), при этом они еще и многоядерные
Теперь расскажу про технические проблемы, которые возникли на этом пути. Основная — надо скомпилировать свой код под ARM и Android. К счастью, в Termux можно поставить
clang, cmake и скомпилить движок прямо на планшетеС Battlefield сложнее. Дело в том, что он по историческим причинам написан на Free Pascal, а среди пакетов Termux паскаля нет. К счастью, у Free Pascal есть довольно неплохая кросс-компиляция. Через fpcupdeluxe можно поставить все необходимое: Free Pascal и Lazarus (IDE под Free Pascal + система сборки) любой версии, а также необходимые кросс-компиляторы
Ради интереса я решил глянуть с исходники самого fpcupdeluxe на GitHub. Код там местами написан ужасно. Вот вам, например, функция на 1025 (!) строк: ссылка. Зато работает :)
После кросс-компиляции Battlefield, к сожалению, не запустился и выдавал ошибку при запуске:
bash: ./battlefield: No such file or directoryПроблема оказалась в том, что в бинарнике по какой-то причине неправильно выставлялся путь до динамического линкера. Решается проблема просто:
$ patchelf --set-interpreter /system/bin/linker64 battlefield
После этого Battlefield прекрасно работает :)Наконец, надо как-то забрать с планшета полученный датасет. Для этого можно, например, поднять в Termux сервер ssh и подключиться к нему по SFTP. Это в целом полезная штука, которая позволяет гонять данные между устройствами
Наконец, расскажу, что я хочу сделать с обучением дальше. Я почитал обучалку от движка zurichess, которая использует похожие идеи
Понял следующее: мне нужно сгенерировать бо́льший датасет (100'000 игр, например), но при этом не использовать его полностью (иначе все данные просто не поместятся в оперативную память), а рандомно выбрать из датасета сколько-то позиций (10^6, например). Тогда обучение теоретически увидит больше разных ситуаций и построит более сбалансированную оценку. Но это еще предстоит узнать :)
Во-вторых, мне нужно более оптимально хранить датасеты. Сейчас они хранятся, по сути, в виде списка всех позиций в формате FEN. Например:
Эксперименты показывают, что экономия в таком случае получается существенной: примерно в 10 раз. Что интересно, изменение формата сильно уменьшает размер, даже если хранить датасеты в сжатом виде. Пусть
Стоит отметить, что сжатие само по себе существенно уменьшает размер файла:
Понял следующее: мне нужно сгенерировать бо́льший датасет (100'000 игр, например), но при этом не использовать его полностью (иначе все данные просто не поместятся в оперативную память), а рандомно выбрать из датасета сколько-то позиций (10^6, например). Тогда обучение теоретически увидит больше разных ситуаций и построит более сбалансированную оценку. Но это еще предстоит узнать :)
Во-вторых, мне нужно более оптимально хранить датасеты. Сейчас они хранятся, по сути, в виде списка всех позиций в формате FEN. Например:
game B 1Хочется хранить вместо этого список ходов:
board rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
board rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
board rnbqkbnr/pppppp1p/6p1/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2
board rnbqkbnr/pppppp1p/6p1/8/3PP3/8/PPP2PPP/RNBQKBNR b KQkq d3 0 2
game B 1
moves e2e4 e7e5 d2d4
Я мог бы хранить партии просто как PGN, но там не очень приятный для парсинга формат ходов (и SoFCheck не умеет их парсить). Куда проще подавать ходы в формате UCI, который понимает любой шахматный движокЭксперименты показывают, что экономия в таком случае получается существенной: примерно в 10 раз. Что интересно, изменение формата сильно уменьшает размер, даже если хранить датасеты в сжатом виде. Пусть
old.txt — файл в старом формате, а new.txt — в новом. При сжатии с помощью bzip2 получается, что old.txt.bz2 больше в 10 (!) раз, чем new.txt.bz2, а при использовании lzma на максимальной степени сжатия — в 3.5 раза. Хотя, казалось бы, соседние позиции довольно похожи, а запись хода буквально кодирует, откуда и куда переместилась фигураСтоит отметить, что сжатие само по себе существенно уменьшает размер файла:
old.txt больше, чем old.tar.bz2 примерно в 9 раз. Может быть, такая высокая степень сжатия возникает из-за того, что в датасете много повторяющихся партий (или почти повторяющихся, когда долго совпадают первые ходы, а потом уже партии различаются). Это еще одна гипотеза, которую я собираюсь проверитьПро тесты в SoFCheck
Чтобы тестировать движок, я использую разные стратегии тестирования. Во-первых, юнит-тесты с помощью фреймворка Google Test: пример. Таким образом тестируется не весь код, а только часть, в надежности которой я не уверен, и не могу нормально протестировать другими методами. Мне лень писать юнит-тесты на весь код :)
В реализации правил, например, юнит-тестов нет вообще. Но корректность правил тоже проверяется, просто куда более мощным методом под названием selftest. Эта штука запускает генерацию ходов и проверки на шахи на куче разных позиций. Затем selftest сравнивает результаты генерации с тем, что выдает на тех же позициях мой более старый движок, Dodecahedron. В нем абсолютно другая реализация правил, и шансы дважды допустить одну и ту же ошибку стремятся к нулю :) Попутно на всех этих позициях SoFCheck проверяет разные инварианты: что отмена хода работает корректно, что если загрузить и сохранить доску из FEN, то получится тот же результат, и т.д. Такой набор проверок позволяет быть уверенным в том, что правила с большой вероятностью написаны без багов. Еще selftest сделан так, что на нем можно тестировать не только SoFCheck, а любую реализацию правил на C++. Есть даже гайд про это
(Насчет Dodecahedron: он в свое время проверялся похожим методом на еще более старой реализации правил, поэтому ошибка будет незамеченной, только если она допущена трижды в одном и том же месте)
Как генерируются позиции для selftest'а? Генератор выглядит вот так. Если кратко, то там 100 случайных игр, несколько добавленных вручную партий и несколько добавленных вручную позиций (всякие крайние случаи)
Есть тесты в
Есть интеграционные тесты на UCI, которые вводят команды в фиктивный движок, а потом проверяют, что вывод этого движка совпал с тем, что ожидалось
Наконец, есть smoke-тест. Движок запускается на наборе позиций. Тест пройден, если движок думает 4 секунды над позицией и при этом не падает. Для этого теста код компилируется с дополнительными проверками, которые убеждаются, что никакие инварианты не нарушены (а если нарушены — программа падает). Поскольку эти проверки замедляют код, то они используются только в тестах, а в обычной сборке отключены
Конечно же, все эти запускаются в CI на каждый коммит. Самые долгие — selftest (около 4-5 минут) и smoke test (около 40 секунд), остальное работает быстрее
Чтобы тестировать движок, я использую разные стратегии тестирования. Во-первых, юнит-тесты с помощью фреймворка Google Test: пример. Таким образом тестируется не весь код, а только часть, в надежности которой я не уверен, и не могу нормально протестировать другими методами. Мне лень писать юнит-тесты на весь код :)
В реализации правил, например, юнит-тестов нет вообще. Но корректность правил тоже проверяется, просто куда более мощным методом под названием selftest. Эта штука запускает генерацию ходов и проверки на шахи на куче разных позиций. Затем selftest сравнивает результаты генерации с тем, что выдает на тех же позициях мой более старый движок, Dodecahedron. В нем абсолютно другая реализация правил, и шансы дважды допустить одну и ту же ошибку стремятся к нулю :) Попутно на всех этих позициях SoFCheck проверяет разные инварианты: что отмена хода работает корректно, что если загрузить и сохранить доску из FEN, то получится тот же результат, и т.д. Такой набор проверок позволяет быть уверенным в том, что правила с большой вероятностью написаны без багов. Еще selftest сделан так, что на нем можно тестировать не только SoFCheck, а любую реализацию правил на C++. Есть даже гайд про это
(Насчет Dodecahedron: он в свое время проверялся похожим методом на еще более старой реализации правил, поэтому ошибка будет незамеченной, только если она допущена трижды в одном и том же месте)
Как генерируются позиции для selftest'а? Генератор выглядит вот так. Если кратко, то там 100 случайных игр, несколько добавленных вручную партий и несколько добавленных вручную позиций (всякие крайние случаи)
Есть тесты в
static_assert, которые гоняются прямо во время компиляции: ссылкаЕсть интеграционные тесты на UCI, которые вводят команды в фиктивный движок, а потом проверяют, что вывод этого движка совпал с тем, что ожидалось
Наконец, есть smoke-тест. Движок запускается на наборе позиций. Тест пройден, если движок думает 4 секунды над позицией и при этом не падает. Для этого теста код компилируется с дополнительными проверками, которые убеждаются, что никакие инварианты не нарушены (а если нарушены — программа падает). Поскольку эти проверки замедляют код, то они используются только в тестах, а в обычной сборке отключены
Конечно же, все эти запускаются в CI на каждый коммит. Самые долгие — selftest (около 4-5 минут) и smoke test (около 40 секунд), остальное работает быстрее
Как-то раз Антон (@Wind-Eagle) заметил, что у меня медленная реализация генератора ходов для пешек. Я решил сегодня взять и переписать
Запустил код на микробенчмарках. В позициях, где много пешек, генерация ходов ускорилась на 10-15%. Тем не менее, результаты оказались довольно шумными, и некоторые бенчмарки замедлились процентов на 5. Бенчмарки, которые имитируют рекурсивный анализ, практически не поменялись
А на реальном анализе получилось ускорение: на 9% в позиции с одними пешками, на 7% в околодебютных позициях, на 3-5% в глубоком миттельшпиле. Чем дальше от дебюта, тем меньше ускорение, поскольку там становится все меньше и меньше пешек; в эндшпиле старая и новая версии работают примерно одинаково быстро. Тем не менее, результат довольно неплохой :)
Коммит можно посмотреть здесь: ссылка
Еще я понял, что мои микробенчмарки не особо хороши и не отражают реальную скорость. Например бенчмарки, которые имитируют рекурсивный анализ, пытаются сделать все ходы в позициях, т.е. на них больше влияет скорость
Запустил код на микробенчмарках. В позициях, где много пешек, генерация ходов ускорилась на 10-15%. Тем не менее, результаты оказались довольно шумными, и некоторые бенчмарки замедлились процентов на 5. Бенчмарки, которые имитируют рекурсивный анализ, практически не поменялись
А на реальном анализе получилось ускорение: на 9% в позиции с одними пешками, на 7% в околодебютных позициях, на 3-5% в глубоком миттельшпиле. Чем дальше от дебюта, тем меньше ускорение, поскольку там становится все меньше и меньше пешек; в эндшпиле старая и новая версии работают примерно одинаково быстро. Тем не менее, результат довольно неплохой :)
Коммит можно посмотреть здесь: ссылка
Еще я понял, что мои микробенчмарки не особо хороши и не отражают реальную скорость. Например бенчмарки, которые имитируют рекурсивный анализ, пытаются сделать все ходы в позициях, т.е. на них больше влияет скорость
moveMake(), чем генерации ходов. В реальности из позиции делается в среднем 2-3 хода до отсечения, поэтому производительность moveMake() куда менее заметна, чем в бенчмаркахВчера я улучшил оценку оставшегося времени в BattleField. Сейчас расскажу, как :)
Обычно BattleField выводит прогресс примерно в таком виде:
Несмотря на то, что эта формула очень простая, она не лишена недостатков. Она может выдавать сильно завышенные оценки на оставшееся время, когда прошло еще мало игр. Постепенно оценка сходится к реальному значению, но это происходит довольно медленно
Как можно сделать лучше? Построим такую модель. Пусть у нас время одной игры — случайная величина с математическим ожиданием
Осталось научиться оценивать
Затем я немного поколдовал с симулятором и нашел довольно неплохую оценку для
Обычно BattleField выводит прогресс примерно в таком виде:
321/5000 games completed (7.99 min/2.07 hours), score = 153.0:168.0
Как оценить, сколько времени осталось (2.07 hours в строчке выше)? Раньше эта оценка производилась по очень простой формуле: total_time = time / count * total, где time — прошедшее время, count — количество сыгранных игр и total — общее количество игрНесмотря на то, что эта формула очень простая, она не лишена недостатков. Она может выдавать сильно завышенные оценки на оставшееся время, когда прошло еще мало игр. Постепенно оценка сходится к реальному значению, но это происходит довольно медленно
Как можно сделать лучше? Построим такую модель. Пусть у нас время одной игры — случайная величина с математическим ожиданием
1. У нас есть jobs потоков. Каждый поток берет задачу, если задачи еще остались, и выполняет ее. Найдем математическое ожидание времени работы count игр — f(jobs, count). Тогда оценим время работы как total_time = time / f(jobs, count) * f(jobs, total). Здесь необязательно брать реальное значение f(jobs, count) — достаточно лишь довольно точной оценки (чем точнее, тем лучше)Осталось научиться оценивать
f(jobs, count). Здесь имеется две проблемы. Во-первых, я с ходу не придумал, как аналитически оценить это математическое ожидание. Во-вторых, мы не знаем распределение времени одной игры, а от этого зависит ответ. По этой причине будем подбирать оценку на f(jobs, count) эмпирически, считая, что время работы распределено нормально с некоторой дисперсией (я сейчас предполагаю, что дисперсия равна 0.25, хотя надо бы измерить, какова дисперсия времени работы на самом деле). Далее я написал симулятор, который находит требуемое матожидание с помощью метода Монте-КарлоЗатем я немного поколдовал с симулятором и нашел довольно неплохую оценку для
f(jobs, count). Ее можно увидеть в коммите, который улучшает оценку времени. Чтобы еще улучшить результаты, можно подбирать формулу не руками, а с помощью интерполяции или ML. Но это я сделаю как-нибудь потомЯ писал в прошлом посте, как я улучшил оценку оставшегося времени в BattleField. Сегодня оказалось, что я набагал в оценке и словил в BattleField'е деление на ноль, если
Не буду описывать здесь, как оно работает. Лучше смотрите в ноутбук, там все довольно хорошо описано. Есть еще коммит, в котором я как раз поправил функцию оценки
Теперь я полностью доволен тем, как работает оценивание времени работы :) Оно не завышает сильно оценку, если было сыграно малое количестве партий, как это происходило ранее
jobs = 1. Попытался исправить функцию оценки и понял, что не так уж она и хороша. А это значит, что можно воспользоваться машинным обучением, чтобы ее улучшить :)Не буду описывать здесь, как оно работает. Лучше смотрите в ноутбук, там все довольно хорошо описано. Есть еще коммит, в котором я как раз поправил функцию оценки
Теперь я полностью доволен тем, как работает оценивание времени работы :) Оно не завышает сильно оценку, если было сыграно малое количестве партий, как это происходило ранее
Пора рассказать, что у меня получилось за последнее время. А изменений было достаточно много :)
Во-первых, я реализовал все то, что хотел здесь. Для хранения датасетов теперь используется формат, спецификацию которого я описал по ссылке и научил Battlefield выводить файлы в этом формате. Действительно, после этого датасеты стали занимать гораздо меньше места на диске. К тому же, я сконвертировал старые датасеты в новый формат с помощью несложного скрипта
Потом надо было научить SoFCheck понимать новый формат. Для этого у меня есть небольшая библиотека для парсинга этого формата. Когда я ее писал, мне понадобился
После этого я реализовал фильтрацию в
Во-первых, я реализовал все то, что хотел здесь. Для хранения датасетов теперь используется формат, спецификацию которого я описал по ссылке и научил Battlefield выводить файлы в этом формате. Действительно, после этого датасеты стали занимать гораздо меньше места на диске. К тому же, я сконвертировал старые датасеты в новый формат с помощью несложного скрипта
Потом надо было научить SoFCheck понимать новый формат. Для этого у меня есть небольшая библиотека для парсинга этого формата. Когда я ее писал, мне понадобился
unique_ptr<T> с копированием: при копировании он должен копировать данные, на которые он указывает. Зачем это нужно? В коде повсеместно используются variant'ы, а один из вариантов содержит внутри себя доску и занимает из-за этого ~200 байт. Но тогда весь variant будет занимать неоправданно много места, что неприемлемо. Для решения такой проблемы как раз требуется спрятать доску за указатель. При этом хочется, чтобы копирование объектов по-прежнему работало. В итоге я написал что-то такоеПосле этого я реализовал фильтрацию в
make_dataset, сгенерировал 100'000 партий движка с самим собой и обучил на 3 млн позиций из этих партий (из примерно 10 млн позиций). Выигрыша по сравнению с нефильтрованной выборкой, к сожалению, я не получил :(Еще я написал классный скрипт owl.py, который упрощает мне жизнь. Например, раньше для утилиты
Еще owl может собрать несколько версий движка в одну команду. Это бывает очень полезно, чтобы сравнить разные версии по силе. Сборка нескольких версий реализована предельно просто: сохраняем незакоммиченные изменения, переходим на нужные коммиты, собираем с помощью CMake, возвращаем все обратно
А еще owl умеет рисовать маленькую сову ASCII-артом, которую я кидал в канал выше :)
Как owl узнает конфигурацию проекта и системы, чтобы все это делать? Все просто: нужные пути подставляются в скрипт CMake'ом: ссылка
apply_weights, которая записывает новые коэффициенты в features.json, надо было указывать путь до features.json. А owl сам знает путь до этого файла, и для обновления коэффициентов достаточно просто написать./owl.py w a
В качестве приятного бонуса он сразу пересоберет движок с новыми коэффициентами :)Еще owl может собрать несколько версий движка в одну команду. Это бывает очень полезно, чтобы сравнить разные версии по силе. Сборка нескольких версий реализована предельно просто: сохраняем незакоммиченные изменения, переходим на нужные коммиты, собираем с помощью CMake, возвращаем все обратно
А еще owl умеет рисовать маленькую сову ASCII-артом, которую я кидал в канал выше :)
Как owl узнает конфигурацию проекта и системы, чтобы все это делать? Все просто: нужные пути подставляются в скрипт CMake'ом: ссылка
Еще полезное улучшение: Battlefield теперь может показывать доверительный интервал для разницы в рейтинге Эло. Например, так:
- для 100 игр: ±60 Эло
- для 1000 игр: ±25 Эло
- для 10000 игр: ±7 Эло
То есть, чтобы протестировать мелкое изменение, нужно от 1000 до 10000 игр, что довольно много
Elo difference = -77.05/-55.00/-33.38 (low/avg/high, at p = 0.95)Как вычисляется доверительный интервал? Как известно, рейтинг Эло можно представить как функцию
elo(q), где q — вероятность победы первого игрока. Для q мы уже умеем считать среднеквадратичное отклонение σ и строить доверительный интервал [q - 1.96 * σ; q + 1.96 * σ]. Тогда доверительный интервал для рейтинга Эло будет равен [elo(q - 1.96 * σ); elo(q + 1.96 * σ)]
Я еще раз убедился в том, что для достижения высокой точности надо много игр. Если разница в силе игры невелика, то точность Battlefield примерно такая:- для 100 игр: ±60 Эло
- для 1000 игр: ±25 Эло
- для 10000 игр: ±7 Эло
То есть, чтобы протестировать мелкое изменение, нужно от 1000 до 10000 игр, что довольно много
Нашел очередной странный баг в
clang-tidy версии 11, который в CI проявился почему-то только на macOS. Он утверждает, что в коде есть деление на ноль:/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:140:5: note: 'size' initialized to 0
const uint64_t size = *sampleSize_;
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:141:19: note: 'size' is < field 'count_'
if (count_ <= size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:141:5: note: Taking false branch
if (count_ <= size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:145:9: note: Assuming the condition is true
if (random_() % count_ < size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:145:5: note: Taking true branch
if (random_() % count_ < size) {
^
/Users/runner/work/sofcheck/sofcheck/src/eval/feat/bin/make_dataset.cpp:146:45: note: Division by zero
sample_[static_cast<size_t>(random_() % size)] = board;
^
Сам код выглядит так. Т.е. clang-tidy почему-то утверждает, что size = 0, соглашается с тем, что count_ > 0, и при этом (random_() % count_ < size) по его мнению может быть true, хотя все типы здесь uint64_t
Обновление clang-tidy мне, к сожалению, не помогает: баг присутствует и в clang-tidy-13 :( Пришлось обходить проблему костылямиЕще одно серьезное улучшение — добавление в оценку позиции признаков для пешек: бонусы и штрафы за изолированные, двойные, проходные пешки
Скажу сразу: я разочаровался в пешках. Я ожидал, что после пешечных улучшений я получу минимум 60 Эло, а итоговые результаты (на 10000 играх) оказались такими:
Попытался еще добавлять пешечные бонусы в зависимости от фазы игры: отдельные признаки для дебюта и эндшпиля. Парадоксально, но после обучения этот подход сделал только хуже :( Еще интересно, что в этом случае проходная пешка в миттельшпиле оценивается отрицательно (в -0.4 пешки или около того), и только в эндшпиле дает положительную прибавку к оценке позиции
Считать все пешечные признаки долго, поэтому их надо кэшировать: позиции пешек меняются относительно редко. Поэтому я написал такой код для кэширования. Подсчеты показали, что при анализе из начальной позиции получается примерно 7% промахов мимо этого кэша, но по ходу игры количество промахов постепенно снижается, и в миттельшпиле получается на более 2% промахов
Для кэширования необходимо как-то как-то считать хэш от всех пешек на доске. Здесь возможны два подхода. Первый — использовать zobrist hashing, как и для хэша доски в целом. Тогда нам надо вместе с хэшом для всей доски хранить еще хэш для пешек и постепенно пересчитывать его каждый ход. Второй подход — взять
Я решил сравнить, какой из этих двух подходов быстрее работает. Выяснилось, что все-таки пересчитывать zobrist hashing быстрее, хотя и статистически незначимо. Поэтому я решил реализовать первый вариант
Скажу сразу: я разочаровался в пешках. Я ожидал, что после пешечных улучшений я получу минимум 60 Эло, а итоговые результаты (на 10000 играх) оказались такими:
Elo difference = 26.10/32.93/39.78 (low/avg/high, at p = 0.95)Я подумал, что где-то допустил баг, из-за которого не получаю большого выигрыша. Тогда я взял одну партию и запустил на ней
make_dataset, который как раз умеет выдавать коэффициенты для каждого признака. Посмотрел глазами на коэффициенты и сравнил с тем, что ожидалось. В итоге все сошлось, а, значит, оценку позиции я написал правильноПопытался еще добавлять пешечные бонусы в зависимости от фазы игры: отдельные признаки для дебюта и эндшпиля. Парадоксально, но после обучения этот подход сделал только хуже :( Еще интересно, что в этом случае проходная пешка в миттельшпиле оценивается отрицательно (в -0.4 пешки или около того), и только в эндшпиле дает положительную прибавку к оценке позиции
Считать все пешечные признаки долго, поэтому их надо кэшировать: позиции пешек меняются относительно редко. Поэтому я написал такой код для кэширования. Подсчеты показали, что при анализе из начальной позиции получается примерно 7% промахов мимо этого кэша, но по ходу игры количество промахов постепенно снижается, и в миттельшпиле получается на более 2% промахов
Для кэширования необходимо как-то как-то считать хэш от всех пешек на доске. Здесь возможны два подхода. Первый — использовать zobrist hashing, как и для хэша доски в целом. Тогда нам надо вместе с хэшом для всей доски хранить еще хэш для пешек и постепенно пересчитывать его каждый ход. Второй подход — взять
h(b1, b2), где b1 и b2 — это битбоарды белых и черных пешек соответственно, а h — некоторая хорошая хэш-функция общего назначения. Я попробовал в качестве такой хэш-функции FarmHash от Google и сделал коммит, который использует FarmHash для хэширования позиций пешекЯ решил сравнить, какой из этих двух подходов быстрее работает. Выяснилось, что все-таки пересчитывать zobrist hashing быстрее, хотя и статистически незначимо. Поэтому я решил реализовать первый вариант