Опубликован: 15.10.2009 | Уровень: специалист | Доступ: платный
Лекция 1:

Введение в библиотеку Microsoft Parallel Extensions to the .Net Framework

Лекция 1: 12 || Лекция 2 >

Семинарское занятие №1. Наиболее распространенные причины низкой производительности параллельных программ

Параллельное программирование представляет собой написание программ, которые могут исполняться на нескольких ядрах/процессорах, что, в общем случае, должно вести к сокращению времени решения задач. Однако, на практике, в частности, при использовании библиотеки PFX, возможны ситуации когда с увеличением количества используемых процессоров не удается добиться такого уменьшения - в этом случае говорят о низкой производительности параллельной программы.

Причины таких ситуаций могут быть различны:

  • задача, как таковая, плохо поддается распараллеливанию,
  • неправильное использование библиотеки PFX для распараллеливания приложения,и др.

Существует ряд общих ситуаций, которые приводят к низкой эффективности параллельных программ, в частности, использующих библиотеку PFX. Ниже приведено краткое описание некоторых из таких ситуаций.

Достаточный объем вычислительной работы

Одним из основных требований, которым должна удовлетворять программа для того, чтобы была возможность ее эффективного распараллеливания, является наличие достаточного объема работы, который она должна выполнить. Допустим, что только половина всех вычислений в программе может быть выполнена параллельно, тогда согласно закону Амдала [ [ 1 ] ] множитель производительности будет не более двух. Данное правило вполне очевидно, если представить себе, что программа 90% времени исполнения находится в ожидании ответа на SQL-запрос - параллельное исполнение оставшихся 10% не принесет существенного эффекта. Вместе с тем стоит отметить, что в этом случае мы можем параллельно посылать SQL-запросы разным серверам и использовать асинхронное взаимодействие с серверами.

Одним словом, для того чтобы получить эффект от параллельного выполнения задач, нужно иметь достаточный объем работы для распараллеливания, как минимум перекрывающий издержки самого процесса распараллеливания.

Гранулярность задач (размер отдельных задач и их общее количество)

Параллельная программа, решающая общую задачу, обычно состоит из отдельных фрагментов - подзадач, которые определяет в коде сам программист. Если количество подзадач будет слишком велико, то большинство из них будут простаивать в очереди к ядрам процессора, а объем издержек на запуск подзадач будет расти. Если количество подзадач будет слишком малым, то некоторые ядра процессора будут простаивать, снижая общую производительность системы.

Некоторые компоненты Parallel Extensions API, такие как, например, Parallel.For и PLINQ, способны сами определить подходящий для данной системы объем и количество параллельно исполняющихся задач, тогда как при работе с такими компонентами как TPL и Futures ответственность за принятие правильного решения лежит на самом программисте.

Балансировка нагрузки

Даже в том случае, когда гранулярность задач подобрана верно, возникает проблема распределения этих задач по ядрам. Эта проблема связана с тем, что потенциально разные задачи могут исполняться разное (зачастую неопределенное) время и, следовательно, в процессе исполнения программы без балансировки нагрузки ядер возможны простои некоторых ядер и простои задач в очередях к другим ядрам.

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

Выделение памяти и сборка мусора

Некоторые программы характеризуются интенсивным взаимодействием с памятью, что вызывает значительные временные затраты как на выделение памяти, так и на сборку мусора. К примеру, программа, которая обрабатывает текст, будет интенсивно использовать память, в особенности, если программист не обратил должного внимания на косвенные выделения памяти.

К сожалению, выделение памяти на современных вычислительных архитектурах это операция, которая может требовать синхронизации между вычислительными потоками, в которых выполняется эта операция. Как минимум мы должны быть уверены, что области памяти, выделяемые параллельно, не перекрываются.

Более серьезные временные потери возникают при сборке мусора. Если сборщик мусора находится в постоянной активности при исполнении программы, то это может стать узким местом при ее распараллеливании. Разумеется, сборщик мусора в .NET может выполнять свою работу параллельно с остальными задачами программы, но для этого могут потребоваться дополнительные усилия со стороны программиста (подробности см. в [ [ 2 ] ] и [ [ 3 ] ]).

Кэш-промахи

Данная ситуация связана с принципами кэширования в современных компьютерах. Когда процессор производит выборку значения из основной памяти, копия этого значения попадает в кэш, что ускоряет доступ к этому значению, если, конечно, оно понадобится еще раз в последующих вычислениях. На самом деле, процессор кэширует не только одно выбранное значение, но и некоторую окрестность этого значения в памяти. Значения, перемещаемые из основной памяти в кэш и сохраняемые в кэш построчно, называются строками кэша, и типичный размер составляет 64 или 128 байт.

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

Более тонкая проблема возникает, когда ядра записывают значения в разные области памяти, которые, тем не менее, расположены так близко, что находятся в одной строке кэша. Несмотря на кажущуюся малую вероятность такой ситуации, она достаточно часто встречается на практике, поскольку, например, поля объекта или промежуточные результаты обработки массивов объектов зачастую находятся в памяти относительно близко.

Есть несколько способов избежать подобных проблем. Можно, например, проектировать структуры данных специальным образом, снижая вероятность появления проблем кэширования, или выделять память в разных потоках.

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

Лекция 1: 12 || Лекция 2 >
Максим Полищук
Максим Полищук
"...Изучение и анализ примеров.
В и приведены описания и приложены исходные коды параллельных программ..."
Непонятно что такое - "В и приведены описания" и где именно приведены и приложены исходные коды.
Дмитрий Молокоедов
Дмитрий Молокоедов
Россия, Новосибирск, НГПУ, 2009
Паулус Шеетекела
Паулус Шеетекела
Россия, ТГТУ, 2010