игрыстатьиматематические программырусский языкразноеоб авторе

Логическая часть решателя версия 5.1

Cодержание

Основные нововведения
Опция очистки хеш-таблицы
Введение специальных функций
Увеличение хеша и изменение функции вычисления индекса
Неудачная попытка отсечения нижних слоев
Сравнение скоростей программ на javascript и c++
Таблица сравнения времен расчета

Основные нововведения

Изменения в версии 5.1 затронули только решатель по преферансу, который практически полностью переписан. Теперь программа сделана таким образом, что можно параллельно решать несколько задач в одном процессе, раньше это было невозможно так как было много статических членов класса и приходилось создавать много процессов, для решения нескольких задач. Раньше каждая карта имела свой бит в хеш-ключе (всего 32 бита), что не учитывало ушедшие карты. Например следующие позиции одинаковы, но имели разные хеш-ключи.

задача 1
играющийЮг
контракт6
бридж студия
ЗападВосток
Т B
-  -
- -
- -
Юг
8
-
-
-
задача 2
играющийЮг
контракт6
бридж студия
ЗападВосток
Д 10
-  -
- -
- -
Юг
7
-
-
-

Теперь хеш-код организован следующим образом. Есть четыре кода, по одному на каждую масть. Программа идет от самой старшей карты в масти к младшей, все ушедшие карты игнорируются. Если находится карта принадлежащая одному из игроков, то она добавляется к хеш-коду. Преферансный игрок имеет двухбитовый код 00 (в нашем случае это юг), следующий за ним код 01 (запад), через одного игрока за ним имеет код 10 (восток). В самом конце, для того чтобы указать конец, указывается код 11. Самая старшая карта в обеих позициях принадлежит западу (код 01), вторая по старшинству востоку (код 10) и последняя югу (код 00). Таким образом в обеих позициях мы получим двоичный код пиковой масти 11001001, для остальных мастей код будет 11. Коды мастей считаются только один раз при загрузке позиции и затем для них выполняются операции удалить и вставить обратно карту, когда идет перебор всех ходов. В старой версии для учета ходов велась работа с двусвязными списками и параллельно обнулялся/восстанавливался соответствующий бит в хеш-коде. Теперь списков нет - есть только коды мастей.

В новой версии хеширование организовано таким образом, что игрок всегда имеет код 00 (неважно кто это юг, север, запад или восток), таким образом можно не очищать хеш если меняется только игрок, а козырь остается прежним.

Все это дает ускорение примерно 1.6-1.9 раза или 37-47% по сравнению со старой версией. При работе с программой нужны следующие опции:

  • поиск и оценка лучшего хода - одна позиция
  • оценка всех возможных ходов - максимум 10 позиций (зависит от числа оставшихся карт на руке)
  • опция решить для всех игроков и козырей - 18 раскладов 4 козыря + бескозырная игра + мизер и три возможных игрока
  • опция решить все расклады противников - нужно перебрать C1020=184 756 позиций

Старая версия уже работала достаточно быстро для всех опций, кроме последней, поэтому имеет смысл ускорять только опцию решить все расклады противников. Начиная с версии 5.1 для опции решить для всех игроков и козырей первый ход может быть любым, получается, что нужно сделать оценку 54=18*3 раскладов, что быстро даже для старой версии. Причем 36 из 54 раскладов нужно посчитать без очистки хеша, что намного быстрее (смотрите ниже).

Опция очистки хеш-таблицы

В новой версии как и у бриджевого решателя появилась новая опция - очищать или нет хеш таблицу. Как говорилось выше хеш обязательно очищать только при смене козыря. Так как для опции решить все расклады противников козырь один для всех раскладов, то очищать хеш нужно только один раз - для первого расклада, а затем использовать хеш-таблицу для всех остальных раскладов. Это дает огромный прирост в скорости. Ниже приведена таблица времен расчетов для старой и новой версий. Старая версия решателя написана таким образом, что опция не очищать хеш была невозможна, поэтому невозможно сравнивать скорости расчетов старой и новой версий без очистки хеша. Даны результаты для трех тестовых задач, бралось время расчета первых 20 000 раскладов противников, расчеты были выполнены на одном ядре, время дано в секундах. Видно, что новая версия намного быстрее старой даже без очистки хеша, а с очисткой хеша скорость еще больше.

старая версияновая версия с очисткой хешановая версия без очистки хеша
1140.2588.1311.62
2171.9281.904.60
313.8016.140.82

Колонка новая версия без очистки хеша выведена в таблице сравнений в колонку новая 1. Были какие-то изменения в коде поэтому времена не совпадают.

Введение специальных функций

Первоначально в классе Preferans были одни и те же функции для мизерных и немизерных игр. Также функции поиска имели опцию, по которой нужно или не нужно сохранять лучший ход. Но лучший ход требуется сохранять только в самой верхней функции поиска, а если, например, мы просто оцениваем расклады, то нам вообще не нужен лучший ход. Также лучший ход вообще не нужен если оцениваются все расклады противников. Поэтому были сделаны четыре типа функций:
  • поиск оценки и лучшего хода для мизерной игры
  • поиск оценки и лучшего хода для немизерной игры
  • поиск только оценки для мизерной игры
  • поиск только оценки для немизерной игры
Теперь у класса Preferans нет члена m_mizer, функция solve вызывает одну из четырех нужных нам функций. Это дает небольшое ускорение 3.7%. Колонка в таблице сравнений называется новая 2.

Увеличение хеша и изменение функции вычисления индекса

Несмотря на то, что код масти code[i] может иметь максимум 18 бит (по два бита на каждую карту, всего 8 карт и два замыкающих бита 11), достаточно сравнивать только младшие 16 бит. Докажем это. Нужно доказать, что если равны младшие 16 бит кодов мастей, то коды равны. Рассмотрим три случая.
  • Если оба кода имеют меньше восьми карт в масти, то коды будут иметь ≤ 16 бит и поэтому если равны младшие 16 бит, то и коды равны.
  • Если один из кодов имеет 8 карт в масти, а второй n<8 карт в масти, тогда у второго кода биты 2n и 2n+1 будут равны 1, так как они замыкающие. Но 2n<16 и 2n+1<16, при этом у первого кода оба эти биты не могут быть равны 1, так как они соответствуют n-ой карте в масти. Поэтому в этом случае младшие 16 бит кодов не могут быть равны.
  • Если оба кода содержат 8 карт в масти. Младшие 16 бит будут равны, так как соответствуют одинаковым картам. Последние 2 бита 17 и 18 у обоих кодов равны 1, как замыкающие биты, поэтому достаточно сравнивать только младшие 16 бит.
Таким образом в хеш-таблице достаточно хранить только младшие 16 бит кода масти. Также необходимо хранить кто ходит первым 0≤w≤2. Теперь если сделать хеш-таблицу содержащую минимум 18 бит, то один из кодов масти и первый ход w можно не хранить, если правильно выбрать хеш-функцию, например, (code[0]^code[1]^(code[2]<<1)^(code[3]<<2) ^ w) & andKey, где andKey=hashSize - 1. Видно что у нас есть некая функция от кодов 0, 1, 2. F = (H(0,1,2)^(code[3] << 2) ^ w) & andKey. Очевидно что если для двух наборов кодов code равны code[i], i=0, 1, 2 и равны F, то у них также равны code[3] и w, поэтому их можно не хранить. Первоначально хеш-таблица имела размер hashSize=218 элементов. Каждый элемент имеет размер 8 байт: по 2 байта на 3 масти, 1 байт на тип хеш-флага и 1 байт на оценку. Таким образом, общий размер хеша равен hashSize*8=221=2mb. Было замечено, что при увеличении hashSize быстрые задачи начинают замедляться, например если размер хеша 4mb, то на двух ядрах опция решить для всех игроков и козырей считается 0.02 секунды, а если 64mb, то 0.3 секунды. В связи с этим конструктор класса Preferans имеет параметр - размер хеша. Если нужно перебрать 184 756 раскладов, то используется хеш таблица большого размера (64mb), иначе используется таблица маленького размера (4mb). Конечная формула функции вычисления индекса выглядит так - ((code[0]<<9) ^ (code[1]<<6) ^ code[2] ^ (code[3]<<3) ^ w) & andKey. Здесь code[3] сдвигается на 3, а не на 2 как в начальной формуле, так программа работает быстрее, но в этом случае размер хеша должен быть минимум 4mb. Подсчитаем максимальный hashSize. Так как программа 32х битное приложение, то максимальный размер выделяемой памяти может быть примерно 1.5gb. Пусть компьютер имеет N ядер, тогда выделяемая память равна N*hashSize+272+4 ≤ 1.5*1024. 272 - память выделяемая бриджевым решателем, 4 - память используемая текущей преферансной задачей. Таким образом получаем, что hashSize ≤ 1260/N. Если взять hashSize=64mb, то можно использовать число ядер до N ≤ 1260/64=19.7. Проблемы возникнут только если процессор имеет больше 19 ядер. Можно просто перейти на 64х разрядную версию программы и тогда проблема исчезнет, в этом случае ограничения будут связаны с общим размером оперативной памяти.

Изменение функции вычисления индекса и размера хеш-таблицы должно идти одновременно, так как нужно знать число бит в ключе хеш-таблицы, чтобы задать эффективную формулу вычисления индекса. Увеличение размера хеша до 64mb и изменение расчета индекса хеша дало значительный прирост для всех задач. Колонка в таблице сравнений называется новая 3.

Неудачная попытка отсечения нижних слоев

Идея состоит в том, чтобы не хешировать позиции, когда карт на руках остается меньше определенного числа. Такие позиции считаются быстро и при этом забивают хеш. Таким образом если не хешировать их, то можно освободить элементы хеш-таблицы с бо́льшим числом карт, которые считаются дольше. Все попытки такого рода оптимизаций приводят к замедлению программы, поэтому не используются.

Сравнение скоростей программ на javascript и c++

Первоначально новая версия решателя появилась на языке javascript с целью узнать можно ли написать online преферансный решатель, который будет находить лучший ход и оценивать другие ходы за разумное время. Также попутно была поставлена цель сравнить скорости расчетов одинаковых программ на языках js и c++. Потом код был переписан с js на c++ и, так как он быстрее, внедрен в программу, заменив старую версию, но в процессе внедрения он был сильно изменен и для чистоты эксперимента опять переписан уже с c++ на js. Для этого создана вспомогательная программа на языке php, которая делает замены в исходном коде cpp файла, так, чтобы получить файл js. Язык php использовался, потому что программа на c++ работающая с std::regex очень долго компилируется. Поэтому было принято решение использовать php где вообще нет компиляции. Программа на php не делает работоспособный js код, но позволяет сильно упростить процесс переписывания. Затем js код был доделан вручную.

Примечание. Программа php не работает с перегружаемыми функциями членами класса, которые появились в c++ уже после переписывания с c++ на js, поэтому в данный момент php программа не сможет переделать текущий c++ код на js.

Отличия c++ и js программ.

  • В javascript подсчет узлов ведется всегда в отличие от с++ где это определяется через #define, что немного замедляет javascript программу, по сравнению с c++.
  • В javascript не используются специальные функции для мизерных/немизерных игр и опция поиска лучшего хода, описанные в разделе введение специальных функций, что также немного замедляет javascript программу.
  • В программе на c++ для хеш-таблицы используется массив хеш-элементов, в js используется нативный тип - ассоциативный массив. Как было описано выше на c++ некоторые опции расчета замедляются при использовании бо́льшей хеш-таблицы. Поскольку в js используется ассоциативный массив, то задачи всегда работают быстрее для большой хеш-таблицы 64mb.
При тестировании js программы была открыта одна вкладка браузера. Запустить программу для решения тестовых задач можно по ссылке, там решаются только три первые задачи, так как четвертая очень долго считается. Как и ожидалось js программа работает в несколько раз медленнее чем программа на c++. Также в данный момент на js невозможна многозадачность, что дополнительно ухудшает дело. Было замечено, что время расчета довольно сильно варьируется от раза к разу, непонятно в чем это связано. Тестирование проводилось с помощью трех браузеров: opera, firefox и chrome. Колонки в таблице сравнений называются js.

Таблица сравнения времен расчета

Задачи, на которых проводились испытания, находятся в файле в папке problems, файл называется solveallfoe.pts. Время указано в секундах, перебирались первые 20 000 позиций, задачи решались в однозадачном режиме. Общее время расчета в многозадачном режиме всей задачи будет примерно равно 184 756/20 000*t/N=9.2378*t/N, где t - время расчета 20 000 позиций, N - число ядер. Последняя колонка показывает время расчета задач на мобильном телефоне, брались только первые три задачи, так как четвертая считается слишком долго. Время расчета на мобильном телефоне на разных браузерах chrome, opera и нативного браузера примерно совпадают. Общее замедление на мобильном, по сравнению с браузером опера, составляет (22.26+13.94+9.39)/(9.61+5.98 +5.38)=2.17 раза.
ноутбук lenovo g580xiaomi redmi note 7a
стараяновая 1новая 2новая 3js (opera)js (firefox)js (chrome)js
1130.6211.3110.731.659.6111.0211.7622.26
2162.114.344.241.015.987.047.9713.94
312.890.760.750.655.385.035.989.39
42178.53483.65466.32171.011020.951360.541107.60-
всего2484.15500.06482.05174.331041.911383.631133.31-
  • Колонка старая - старая версия
  • Колонка новая 1 - новая версия с очисткой хеша. Размер хеш таблицы 2mb
  • Колонка новая 2 - новая 1 + добавление функций где ищется только оценка и функций только для мизерных/немизерных игр. Ускорение (500.06-482.05)/500.06=3.6%
  • Колонка новая 3 - новая 2 + увеличение размера хеша до 64mb + изменение функции расчета индекса хеша
  • Колонки js - время расчета для js программы на различных браузерах. Замедление в браузере opera 1041.91/174.33=5.98 раза