Мнение специалиста от 12 февраля 2013 года | Конструктивная кибернетика

Wolfram Mathematica: параллельные вычисления, эффективный код, маркетинговые ловушки


Продолжаем серию кратких сообщений о некоторых возможностях Wolfram Mathematica и особенностях работы с этой программой.

Ранее, в заметке было проиллюстрировано, почему имеет смысл применять Wolfram Mathematica при разработке (прототипировании) систем Больших Вычислений, лежащих в основе концепции Большой Аналитики основанной на парадигме Big Data. Стоить отметить, что с выходом Wolfram Mathematica 9, количество пунктов «за» – существенно увеличилось.

Далее, последовала заметка об основах компилирования исполняемых функций в среде Wolfram Mathematica (через функцию-обёртку Compile[ ]) и простейших способах реализации моделей параллельных вычислений SIMD и shared memory.

В настоящей статье рассмотрим простейшие примеры реализации другого подхода к параллельным вычислениям в Wolfram Mathematica – через порождение объектов KernelObject[ ] и реализацию модели distributed memory. Заметим, что в Mathematica существует ещё и третий подход к организации параллельных вычислений, через MathLink (и/или Wolfram LibraryLink), но его рассмотрение выходит за рамки данной статьи.

Поводом к написанию данного эссе послужили отклики читателей на наш ранее написанный материал, а также забавный эпизод, связанный с некорректной рекламой возможностей параллелизма в системе Wolfram Mathematica одной группой её «фанатов». Поэтому для начала воспроизведём их результаты, а затем покажем настоящее лицо Mathematica. Справедливости ради отметим, что воспроизводить ситуацию будем на Intel Core i5-2430M (два ядра, два потока на ядро), но качественно, ситуация остаётся похожей и на других, более мощных процессорах.

Дальнейшее изложение, в принципе, рассчитано на читателя уже немного знакомого с системой Wolfram Mathematica 8 (9) и с синтаксисом её языка программирования. Если знаний всё же окажется недостаточно, по ходу чтения можно заглядывать в официальное справочное руководство.

Итак, для начала сконфигурируем среду вычислений:

LaunchKernels[4];
ParallelEvaluate[$HistoryLength = 0;];

Nn = 100 000;

DistributeDefinitions[Nn];

Зададим функцию, синтаксически эквивалентную «фанатской» функции (забегая вперёд, отметим, что ключевой момент «фанатского» подхода – это вызов функции AppendTo[ ]):

SlopeFan[n_] := Module[{a, result},
result = {};
Do[
a = Cos[RandomReal[{0., 1.}]];
AppendTo[result, a];
, {i, n}]; result];

Выполним эту функцию на одном ядре, в однопоточном режиме:

tFanS = AbsoluteTiming[rFanS = SlopeFan[Nn];]
{32.3596553, Null}

А теперь запустим её в параллельном режиме, на всех 4-х ядрах (при самостоятельных экспериментах следите за целочисленным соотношением между Nn и $KernelCount):

DistributeDefinitions[SlopeFan];

tFanP = AbsoluteTiming[
rFanP = Module[{n, r},
n = Table[Nn / $KernelCount, {$KernelCount}];
r = Flatten[ParallelMap[SlopeFan[#] &, n]];
r];
]
{4.5084079, Null}

Далее подсчитаем ускорение достигнутое за счёт параллелизма. Отметим, что в настоящей заметке результаты не усреднялись по набору многократных запусков, но контрольные запуски показали, что вариативность времени достаточно низка, что вполне позволяет увидеть определённые закономерности. Итак, коэффициент ускорения полученный для функции SlopeFan[ ], при 100 000 вызовах:

(* Sequential vs Parallel for Fan *)
tFanS[[1]] / tFanP[[1]]
7.1776237

Что мы видим? Семикратное ускорение программы на «слабеньком» Intel Core i5-2430M (два ядра, два потока на ядро)! Воображение уже рисует перспективы... «Нью-Васюки» уже совсем близко... В общем отличный маркетинговый ход для неискушённых пользователей Wolfram Mathematica. Но опыт и теория подсказывают, что так не бывает, что «копать» нужно в сторону оптимальности кода. И действительно, перепишем функцию SlopeFan[ ] в ином представлении – откажемся от вызова AppendTo[ ] динамически изменяющей размер списка (она весьма затратна в плане ресурсов и времени выполнения), и перейдём к списку фиксированной длины:

SlopeOurM1[n_] := Module[{result},
result = ConstantArray[0., n];
Do[
result[[i]] = Cos[RandomReal[{0., 1.}]]
, {i, n}]; result];

Выполним эту функцию на одном ядре, в однопоточном режиме:

tOurM1S = AbsoluteTiming[rOurM1S = SlopeOurM1[Nn];]
{0.2340004, Null}

Сравним результаты запусков в последовательном режиме функций SlopeFan[ ] и SlopeOurM1[ ]:

(* Sequential Fan vs Sequential OurM1 *)
tFanS[[1]] / tOurM1S[[1]]
138.2889

Иное представление функции, и код ускорился в 138 раз! И без параллелизма. А если с параллелизмом? Пробуем:

DistributeDefinitions[SlopeOurM1];

tOurM1P = AbsoluteTiming[
rOurM1P = Module[{n, r},
n = Table[Nn / $KernelCount, {$KernelCount}];
r = Flatten[ParallelMap[SlopeOurM1[#] &, n]];
r];
]
{0.1560002, Null}

Ускорение от параллелизма:

(* Sequential vs Parallel for OurM1 *)
tOurM1S[[1]] / tOurM1P[[1]]
1.500001

Параллельный код всего лишь в 1.5 раза быстрее последовательного, при запуске на 4-х ядрах (Kernel) Mathematica на процессоре с двумя ядрами (core), по два потока на ядро. Это уже похоже на правду!

А быстрее можно?

Можно! И Wolfram Mathematica даёт эту возможность. Перепишем ещё раз код функции Slope*[ ](здесь стоит отметить, что новый вариант функции потребляет памяти в два раза больше, нежели вариант SlopeOurM1[ ]):

SlopeOurM2[n_] := Module[{a, result},
a = RandomReal[{0., 1.}, n];
result = Cos[a]; result];

Запускаем эту функцию на одном ядре, в однопоточном режиме:

tOurM2S = AbsoluteTiming[rOurM2S = SlopeOurM2[Nn];]
{0.0156000, Null}

Измеряем прирост скорости относительно других реализаций:

(* Sequential Fan vs Sequential OurM2 *)
tFanS[[1]] / tOurM2S[[1]]
2074.34

(* Sequential OurM1 vs Sequential OurM2 *)
tOurM2S[[1]] / tOurM2S[[1]]
15.0000
(* Parallel OurM1 vs Sequential OurM2 *)
tOurM2S[[1]] / tOurM1P[[1]]
10.0000

Получаем. Относительно исходного варианта SlopeFan[ ] – ускорение в 2000 раз! Относительно нашей первой реализации SlopeOurM1[ ] – ускорение в 15 раз! Относительно нашей первой реализации SlopeOurM1[ ], запущенной в параллельном режиме – ускорение в 10 раз!

Фокус? Магия?

Ни первое, ни второе! Вся хитрость заключается в трёх моментах. Во-первых, это способы обработки в Wolfram Mathematica функций имеющих атрибут (Attributes[ ]) Listable. Во-вторых, это активное применение в Mathematica библиотеки Intel MKL. В-третьих, это устройство и принципы функционирования самой библиотеки Intel MKL: параллелизм и векторизация вычислений. Чтобы увидеть третий момент наглядно, запустим SlopeOurM2[ ] в параллельном режиме:

DistributeDefinitions[SlopeOurM2];

tOurM2P = AbsoluteTiming[
rOurM2P = Module[{n, r},
n = Table[Nn / $KernelCount, {$KernelCount}];
r = Flatten[ParallelMap[SlopeOurM2[#] &, n]];
r];
]
{0.0312001, Null}

Получаем прирост скорости:

(* Sequential vs Parallel for OurM2 *)
tOurM2S[[1]] / tOurM2P[[1]]
0.50000

То есть на самом деле мы получили не прирост, а замедление скорости в 2 раза. Простое объяснение этого факта (и в силу простоты, строго говоря, не совсем корректное) – это конкуренция за аппаратные ресурсы (2 вычислительных ядра процессора) со стороны вычислительных процессов, порождаемых в ядрах Wolfram Mathematica, и выполняемых библиотекой Intel MKL.

В качестве резюме.
Первое. Система Wolfram Mathematica умеет быстро и точно считать, нужно только правильно поставить ей задачу.
Второе. Параллелизм в Wolfram Mathematica, основанный на порождении объектов KernelObject[ ] и реализации модели distributed memory, достаточно эффективно работает, нужно только правильно и к месту его применять (с учётом алгоритмических, программных и аппаратных особенностей и ограничений).
Третье. Никогда не поддавайтесь на маркетинговые трюки.
Четвёртое. Перед решением сложных, ёмких задач всегда изучайте документацию (теорию), анализируйте чужой и собственный опыт, разбивайте задачу на блоки и экспериментируйте на тестовых примерах (моделируйте проблему в малом).
Пятое. Старайтесь применять специализированные средства разработки. Кстати, для Wolfram Mathematica существует такое замечательное средство. Оно называется Wolfram WorkBench. Этот пакет, предоставляет неплохую среду разработки (Integrated Development Environment). Помимо навигации по исходному коду, он обеспечивает разработчика также инструментами для отладки (дебага), профилирования и развёртывания приложения. В качестве бонусов: интеграция с системами контроля версий и средства групповой работы; проектирование и запуск юнит-тестов, модульное и функциональное тестирование; написание и оформление онлайн документации в стиле Wolfram Mathematica Documentation Center. В общем, без Wolfram WorkBench достаточно проблематично разрабатывать сложные и большие программные системы, эффективно и надёжно работающие в среде Mathematica.
Шестое. Желаем успехов в познании и свершениях!

12 февраля 2013 года.

Андрей Макаренко,
группа «Конструктивная Кибернетика».

Обсуждение: contact@rdcn.ru

Ключевые слова: Wolfram Mathematica, параллельные вычисления, KernelObject, distributed memory, Большие вычисления, Wolfram WorkBench.