| Published On : | 29.09.2009 00:00 |
Рейтинг |
|
В данной статье рассказывается об использовании Intel® Parallel Studio для распараллеливания алгоритма обхода дерева игры с alpha-beta отсечениями в шахматной программе с целью повышения ее масштабируемости. Кратко описан ход работы и приведены результаты экспериментов по оценке масштабируемости полученного решения. Пока разработанная шахматная программа играет примерно в силу первого разряда, но, возможно, приведенное в данной статье описание будет вам полезно при реализации различных алгоритмов с использованием библиотеки TBB, например, алгоритмов, использующих метод ветвей и границ.
Для определения оценки позиции и выбора лучшего хода в программах, играющих в шахматы и другие игры, обычно используются переборные алгоритмы с отсечениями и различными эвристиками, позволяющими сокращать количество рассматриваемых позиций. В процессе перебора генерируются ходы из текущей позиции, после чего рекурсивно обходятся доступные после каждого хода позиции и выбирается та, которая максимизирует выигрыш противника, взятый с противоположным знаком (в алгоритме negamax).
Последовательную версию алгоритма определения оценки позиции в виде псевдокода можно представить следующим образом:
Value negamax(Position pos, int depth)
{
if( мы находимся в листе дерева перебора )
{
return оценка текущей позиции;
}
moves = сгенерировать ходы из текущей позиции;
result = минимальное значение оценки;
for(int i=0; i<numMoves; i++)
{
pos.MakeMove(moves[i]);
result = max(result, -negamax(pos, depth-1));
pos.UndoMove(moves[i]);
}
return result;
}
Для снижения времени, затрачиваемого на перебор, обычно используются альфа-бета отсечения, которые позволяют отсекать целые поддеревья, при этом не влияя на результат. В алгоритме с альфа-бета отсечениями при рекурсивном вызове передаются alpha и beta - нижняя и верхняя границы значений соответственно. Если возвращаемое значение превышает beta, то происходит отсечение, так как это значение уже не может повлиять на результирующую оценку родительского узла.
Псевдокод последовательного перебора с альфа-бета отсечениями:
Value alphabeta(Position pos, int depth, Value alpha, Value beta)
{
if( мы находимся в листе дерева перебора )
{
return оценка текущей позиции;
}
moves = сгенерировать ходы из текущей позиции;
result = минимальное значение оценки;
for(int i=0; i<numMoves; i++)
{
pos.MakeMove(moves[i]);
Value cur = - alphabeta(pos, depth-1, -beta, -alpha);
pos.UndoMove(moves[i]);
if( cur>result )
{
result = cur;
if( cur>alpha )
{
alpha = cur;
if( alpha>=beta ) // отсечение
break;
}
}
}
return result;
}
Cложность в распараллеливании перебора с отсечениями заключается в том, что при параллельном обходе дерева параллельность достигается за счет выполнения «лишней» (с точки зрения последовательного алгоритма) работы, но при этом ускорение достигается за счет того, что обычно мы заранее не знаем, какая работа лишняя. При обновлении альфа-границы узла дерева, необходимо соответственно обновить бета-границу у тех узлов, родителем которых является текущий, так как при этом в них может произойти отсечение. При хорошем упорядочении ходов обычно выгоднее обойти несколько первых ходов последовательно, а после этого начинать параллельный обход.
В данной статье рассматривается распараллеливание процедуры перебора с использованием библиотеки Intel® Threading Building Blocks и инструментов Intel® Parallel Studio.
При распараллеливании последовательной версии использовались tbb::task, при этом в структуре task-а хранилась текущая позиция, глубина и указатель на результат. При этом (до определенной глубины в дереве игры) в цикле обхода ходов вместо рекурсивного вызова создавались новые task-и и добавлялись в список заданий tbb::task_list. После этого в данном узле производилось ожидание порожденных заданий (spawn_and_wait_for_all) и выбор максимума. Начиная с некоторой глубины (в данном случае - когда остается 4 полухода до листьев дерева) производился обычный рекурсивный перебор без распараллеливания, чтобы избежать лишних накладных расходов на создание task-ов. В принципе, аналогичного результата можно было бы достичь, используя parallel_for по всем сгенерированным в данном узле ходам.
Перебор без отсечений практически идеально распараллеливается (т.е. имеет почти линейную масштабируемость). Более интересен перебор с отсечениями, которые позволяют существенно ускорить определение оценки позиции.
В первоначальной версии разработанного параллельного алгоритма перебора с использованием task-ов каждый узел (task) по завершении определения оценки своего поддерева обновлял оценку родительского task-а. После этого, в случае отсечения вызывался метод отмены перебора. Он выставлял в true переменную отмены текущего узла, которая проверялась перед генерацией ходов из данной позиции. Также эта переменная проверялась в функции последовательного перебора (которая запускалась при достижении некоторой глубины). Кроме того, данная переменная рекурсивно устанавливалась для всех потомков узла, для хранения которых использовался vector указателей на task-и. Для синхронизации при возврате значения родительскому task-у использовался spin_mutex. Данная версия показала недостаточную масштабируемость. Значительное время затрачивалось на ожидание в критической секции (spin_mutex) при обновлении значения родительского узла.
В ходе обсуждения было решено попробовать использовать для обновления значения родительского узла atomic значения, что позволило обойтись без синхронизации, использующей spin_mutex, и положительно сказалось на масштабируемости. Таким образом, структура узла task содержала позицию, оставшуюся глубину, значения результата, границы alpha, beta. Однако для снижения накладных расходов на копирование позиции выгоднее было бы из одного task-узла обходить сразу несколько ходов. Соответственно, после генерации ходов и их сортировки в этом случае можно не создавать по одному task-у на каждый ход, а разделить количество ходов на некоторое количество частей. В данном случае удобна конструкция parallel_for, которая позволяет обойтись без использования task-ов напрямую, а параллельный обход сгенерированных ходов записать в функторе путем определения оператора (). Таким образом, было решено использовать parallel_for, об использовании которого рассказывается ниже.
При обновлении в процессе перебора значения alpha некоторого узла (т.е. когда завершается определение оценки одного из узлов – его потомков) может произойти отсечение в самом узле (alpha превышает его beta), а также в любом из оставшихся узлов – потомков (у которых beta == -alpha родительского узла). Таким образом, для того чтобы производить своевременную остановку излишней работы, необходимо обновлять значения beta узлов-потомков, которые в это время могут уже исполняться.
При обсуждении было решено вместо обновления значений хранить в узле atomic значение alpha и обращаться к нему из узлов-потомков.
Также atomic значения использовались для подсчета во время перебора статистических данных: количества рассмотренных позиций, количества отсечений и узлов, в которых произошла отмена обхода. На 2-х и 4-х- ядерных процессорах такой способ подсчета статистики практически не сказался на производительности, однако на 24-ядерной машине было выявлено существенное снижение производительности:
При помощи Amplifier (в режиме «Hotspots») было выявлено, что снижение производительности вызывалось совместным доступом к atomic-переменной подсчета количества позиций. Таким образом, подсчет статистики эффективнее вести в локальных переменных task-ов. После исправления данного "бутылочного горлышка" затраты времени распределяются следующим образом:
Для распараллеливания перебора было решено использовать parallel_for по ходам из текущей позиции. Такой код имеет несколько преимуществ:
В функции search, которая производит рекурсивный обход дерева игры, выполняется следующая последовательность действий:
В зависимости от глубины, для обхода ходов используется либо последовательный перебор, либо параллельный (в последней версии используется только параллельный, т.к. на форсированный последовательный перебор из листьев затрачивается достаточно времени и вызов parallel_for практически не вносит ощутимого замедления). В parallel_for используется функтор MoveEvaluator, который в операторе () копирует позицию у родительского узла (т.к. в процессе перебора она будет меняться) и последовательно обходит ходы из переданного range, а также tbb::auto_partitioner.
Для оценки масштабируемости разработанной программы проведены эксперименты на многопроцессорном SMP компьютере с процессорами Itel Xeon, имеющем в общей сложности 24 ядра. Запускался обход дерева игры из тестовых позиций на одну и ту же глубину, при этом измерялось затраченное на него время с разным количеством рабочих потоков tbb (устанавливаемых при помощи tbb::task_scheduler_init).
Как показали проведенные эксперименты, достигаемое при параллельном обходе дерева игры ускорение сильно зависит от позиции. Затрачиваемое на обход время уменьшается с увеличением количества процессоров нелинейно, причем зависимым от позиции является время «последовательной» части программы.
В большинстве случаев ускорение меньше линейного. На диаграмме представлен график зависимости затрачиваемого на перебор времени (в секундах) от количества рабочих потоков tbb. Красной ломаной представлены значения, которые наблюдались бы в случае линейной масштабируемости:
В некоторых позициях наблюдается также сверхлинейное ускорение, которое возможно из-за специфики работы отсечений, но наблюдается относительно редко. На диаграмме 2 представлены результаты эксперимента на позиции, в которой достигается сверхлинейное ускорение:
Сверхлинейное ускорение может произойти в том случае, когда в некотором узле к отсечению приводит не первый ход, а один из последующих. В этом случае в параллельной программе, в отличие от последовательной, могут быть отсечены не только поддеревья ходов, следующих за тем ходом, который привел к отсечению, но и предшествующих (если их к этому времени еще не обошли другие процессоры). Таким образом, сверхлинейное ускорение свидетельствует о том, что в некоторых позициях эвристики, упорядовачиющие ходы, недостаточно эффективно сработали.
Разработана параллельная версия шахматной программы (играющей примерно в силу первого разряда), которая работает быстрее на многопроцессорных системах, и поэтому чаще выигрывает у последовательной.
При разработке программы использовалась библиотека Intel® Threading Building Blocks, а именно parallel_for для параллельного обхода ходов из текущей позиции в рекурсивной функции. Поскольку parallel_for реализован через использование task-ов, то приоритет, также как и при использовании task-ов напрямую, имеют задания на большей глубине, что представляется наиболее эффективным способом обхода дерева игры. При обновлении значений оценки и границ родительского узла используется переменные типа tbb::atomic.
Активно использовались Intel® Parallel Inspector для проверки корректности и Intel® Parallel Amplifier для профилировки и повышения производительности. В данной статье описаны некоторые случаи использования Amplifier. Проведены эксперименты для оценки масштабируемости разработанной программы.
Я хотел бы выразить благодарность Прохорову Дмитрию, Рябцеву Дмитрию, Еремину Дмитрию, Федоровой Юлии, которые принимали участие в обсуждениях по проекту, а также организаторам летней школы. Также хочу поблагодарить всех участников Летней школы. Как выяснилось, некоторые из них играют сильнее моей программы!
В основном дальнейшее развитие развитие программы, вероятно, будет связано с усовершенствованием статической оценки в листьях дерева игры, которая в настоящее время относительно простая, а также эвристик, позволяющих выборочно уменьшать глубину перебора. Первый релиз программы планируется по достижении ею определенной силы игры.
Александр Эдуардович Мусман, студент Волгоградского государственного технического университета (кафедра ПОАС), проходил стажировку в Нижнем Новгороде. Для меня участие в Летней Школе - это не только интересные лекции, экскурсии, общение с интересными людьми, но и 4-6 недель работы в команде первоклассных специалистов!
| 17.10.2009 14:39
Alexei Alexandrov (Intel)
| А вообще, очень интересная работа! |
| 19.10.2009 08:11
alexander.musman
| Абзац исправился. Спасибо за комментарии |
| 19.10.2009 09:01
Roman Sharygin (Intel)
| "...Активно использовались Intel® Parallel Inspector для проверки корректности и Intel® Parallel Amplifier для профилировки и повышения производительности...", вот этих деталей хочется побольше. Какие были проблемы, как их удалось обнаружить, понять, оптимизировать и оценить, что проблема ушла. |
| 21.10.2009 04:03
alexander.musman
|
В основном проблемы можно разделить на 2 основные группы: связанные с корректностью результатов перебора и проблемы производительности. Для проверки корректности программы проводился перебор из набора тестовых позиций, и сравнивались полученные значения оценки для разного количества потоков. Здесь также использовался Inspector. В текущей версии, например, при доступе к таблице перестановок (общей для всех потоков) не используется синхронизация, и Inspector сообщает что это нехорошо. Но доступ к таблице требуется достаточно часто, и использование здесь синхронизации при доступе замедлит перебор, да и вероятность ошибки маленькая (кстати, есть интересная статья на эту тему http://www.cis.uab.edu/hyatt/hashing.html). У первоначальной версии была недостаточная маштабируемость, и одной из проблем было то, что затрачивалось время на ожидание в spin_mutex, как показал Amplifier. Что интересно, было еще 2 "почти одинаковых" проблемы, связаные с тем, что разные потоки используют один ресурс. Они были незаметны на 4-ядерном процессоре, но сразу выявились при запуске на 24-ядерном. В первом случае это было использование одной atomic-переменной разными потоками для подсчета статистики, во втором случае - обращение к таймеру. Здесь наиболее простое решение - реже обращаться к ним. Например, обращение к таймеру для завершения перебора, если заканчивается время на обдумывание хода программой, в моем случае достаточно делать каждый 1000-й раз. |
| 23.10.2009 03:49
Dmitry Oganezov (Intel)
|
Отличная работа! И замечательная статья. Но есть несколько хитрых вопроса автору. Александр, поставленная цель - повышение масштабируемости в рамках выбранного алгоритма. А что такое «масштабируемость»? Где, собственно, ее анализ? Я вижу два графика для двух состояний, но не вижу полной картинки. Судя по графикам, масштабируемость не растет при увеличении количества ядер от 12 до 24-х. Почему? Плохой алгоритм? Плохая архитектура? Плохая библиотека TBB? Недостаток времени? А ведь по идее, если на 12 ядрах программа играет как перворазрядник, то на 24-х она должна играть как КМС, или даже как мастер спорта. Наверное, что-то с масштабируемостью не так? :) |
| 24.10.2009 06:15
alexander.musman
|
Дмитрий, спасибо за вопросы! Действительно, для статьи я "на глаз" выбрал график для одной позиции, который, по моему, лучше других характеризует "полную картинку" (а ситуация, как на 2-м графике, встречается очень редко). Вообще, эксперименты проводились на 300 позициях из набора "WAC Test suite". В принципе, можно было посчитать среднее ускорение по экспериментам, но тогда возникнет вопрос, насколько выбранные позиции характеризуют "среднюю" позицию, встречающуюся в игре. К тому же, даже в выбранном множестве позиций наблюдается существенный разброс (дисперсия) значений. В основном в статьях о подобных алгоритмах используется некоторый набор позиций, и сравниваются результаты на этих позициях. То, что масштабируемость не растет при увеличении количества ядер от 12 до 24-х, объясняется тем, что последовательный алгоритм выполняет меньше работы, чем параллельный. В параллельном алгоритме мы в некоторый момент времени начинаем обход нескольких поддеревьев из текущей позиции. Теперь, если результат для первого же поддерева приведет к отсечению, то обход остальных поддеревьев - лишняя работа. А если к отсечению приведет обход не первого поддерева, а какого-нибудь из последующих, это может привести к сверхлинейному ускорению (как на 2-м графике). С увеличением количества ядер объем выполняемой лишней работы возрастает. Есть и накладные расходы, такие как затараты на передачу данных через общую память, которые неизбежно увеличиваются с количеством потоков, но они не так существенны. >А ведь по идее, если на 12 ядрах программа играет как перворазрядник, то на 24-х она должна играть как КМС, или даже как мастер спорта. Не совсем так :) . Даже если бы в алгоритме не было "доли последовальных вычислений", ускорение в 2 раза дало бы углубление поиска в дереве игры на 1 полуход, при условии, что нам достаточно в среднем просмотреть 2 хода из каждой позиции ( в моей программе пока недостаточно:) ), а остальные отсекаются. |
| 26.10.2009 02:13
Dmitry Oganezov (Intel)
|
Значит ли это, что для шахматных программ 12 вычислительных юнитов - предел, дальше которого наращивать вычислительные ресурсы не имеет смысла? А как же тогда быть с Deep Blue (суммарно - 544 юнита, правда частота что-то вроде 130Mgz)? |
| 26.10.2009 04:28
alexander.musman
|
По результатам проведенных экспериментов, если отсечь случаи с маленьким временем перебора, в среднем при переходе от использования 12 потоков к 24 перебор происходит быстрее в 1.2 раза, т.е. смысл добавлять узлы еще есть, хотя с количеством узлов он "уменьшается". Из 544 узлов, которые использовались в Deep Blue: 32 узла SMP машины (возможно, дальше увеличивать их количество смысла не было), а остальные 512 - это по две платы с восьмью специализированными шахматными чипами (на каждом из этих 32 узлов), в которых перебор реализован аппаратно. Насколько я понял, эти специализированные чипы не обмениваются между разными SMP-узлами данными об отсечении. С увеличением количества ядер, в принципе, возможно, такая схема, когда несколько ядер группируются и "подчиняются" ядрам более "высокого уровня" будет эффективнее для таких алгоритмов. Также, возможно, будет выгоднее иметь отдельные хеш-таблицы для этих "групп ядер", выделяемых в алгоритме. 24 ядра еще маловато, чтобы такое организовывать :) |

alexander.musman
|
Alexei Alexandrov (Intel)
323
Статусных баллов:
273
"Для снижения времени, затрачиваемого на перебор, обычно используются альфа-бета отсечения, которые позволяют отсекать целые поддеревья, при этом не влияя на результат. В алгоритме с альфа-бета отсечениями при рекурсивном вызове передаются alpha и beta - нижняя и верхняя границы значений соответственно. Если возвращаемое значение превышает beta, то происходит отсечение, так как это значение уже не может повлиять на результирующую оценку родительского узла."
зачем-то в тексте продублирован...