Лишние возможности OpenMP

Опубликовано: 01.09.2018

В библиотеке OpenMP имеется множество конструкций с эквивалентными возможностями (одну конструкцию можно заменить другой). При этом не все они одинаково безопасны, иногда можно легко допустить коварную ошибку, а т.к. мы пишем параллельную программу, то найти ошибку трудно. Итак, я решил собрать в статье рекомендации по использованию OpenMP, которые базируются на моем личном опыте программирования, анализе ошибок, допускаемых студентами (я преподаю смежную дисциплину уже 5 лет) и статьях: « 32 подводных камня OpenMP при программировании на Си++ «, « OpenMP и статический анализ кода «. Отмечу, что статьи написаны командой разработчиков анализатора PVS-Studio (до этого они писали анализатор VivaMP для программ с OpenMP и у них есть колоссальный опыт в этой части).

Не используйте private (firstprivate, lastprivate), shared, threadprivate

Рассмотрим следующий пример:

#include "omp.h" #include <iostream> int main() { int a = 46, b = 123; #pragma omp parallel firstprivate(a) shared(b) { #pragma omp critical { std::cout << omp_get_thread_num() << " -> " << a++ << " : " << b++ << "\n"; } } }

Результаты работы на двух ядрах:

В общей памяти находятся переменные a и b, но в параллельной области указано, что:

a — firstprivate (поэтому каждый поток создаст новую локальную версию переменной и инициализирует ее значением из общей переменной); b — shared (поэтому потоки будут не будут ничего создавать, все обращения к ней — это обращения к переменной в общей памяти).

Кажется, что все хорошо, но:

shared писать не обязательно, ведь это параметр по умолчанию; private и firstprivate можно легко заменить явным созданием локальной переменной внутри потока. Единственное отличие такого подхода в том, что при использовании private тип переменной внутри потока будет гарантированно соответствовать типу переменной в общей памяти. Проблема private в том, что она порождает неинициализированную переменную, но если инициализировать переменные перед использованием мы привыкли, то с private легко ошибиться; lastprivate — одна из самых странных конструкций в OpenMP, ее описывают во всех книжках, но я не видел ни одного примера ее удачного применения. С помощью нее можно записать в общую переменную значение локальной переменной, которое будет получено в потоке, который завершится последним. Так как потоки выполняются параллельно и какой-либо конкретный порядок их завершения обычно не определяется, то использование lastprivate автоматически приводит нас к проблеме гонок потоков.

Во всех случаях, типичной ошибкой студентов является использование private/shared и явно создание внутри потока переменной с таким же именем. Ошибка ищется очень долго:

int main() { int a = 46, b = 123; #pragma omp parallel firstprivate(a) shared(b) { int b = 5; int a = 15; #pragma omp critical { std::cout << omp_get_thread_num() << " -> " << a++ << " : " << b++ << "\n"; } } }

Результаты работы на двух ядрах:

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

Кстати:

проблеме lastprivate посвящен подводный камень №23 «Неосторожное применение lastprivate». Мой совет — просто не используйте эти ключевые слова, без них очень легко обойтись. VivaMP также считает любое использование threadprivate опасным: «Правило 12: Опасным следует считать использование директивы threadprivate».

Не занимайтесь ручным распараллеливанием (omp_get_thread_num, omp_get_num_threads)

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

В ряде случаев может казаться, что вручную получится достичь лучших результатов, один из таких примеров я разобрал « OpenMP — распараллеливание методов численного интегрирования «. По ссылке вы найдете две параллельных реализации одного и того же алгоритма, при этом с помощью «распараллеливания вручную» мы пытаемся избежать лишних операций создания и разрушения потоков (параллельная область у нас помещена в цикл). В конце статьи приведена таблица с результатами и суть ее в том, что эффект от ручного распараллеливания не очевиден. При этом посмотрите как сильно усложняется исходный код, его очень тяжело дорабатывать и насколько повышается порог вхождения в ваш проект…

Кстати, статический анализатор VivaMP также считает ручное распараллеливание опасным:

Правило 8. Опасным следует считать использование функции omp_get_num_threads в арифметических операциях.

Что касается функций omp_get_thread_num() и omp_get_num_threads() — я советую использовать их только для отладки (иногда это очень удобно).

Не используйте блокировки (omp_lock_t)

Блокировки используются для синхронизации, которая чаще всего нужна при обращении нескольких потоков к общему ресурсу. Статический анализатор VivaMP содержит множество правил, связанных с блокировками, ошибками при этом считается нечетное количество обращений к функциям блокировок внутри параллельной области (правило 7), отсутствие инициализации блокировки (правило 16). Также блокировкам посвящены подводные камни 8, 9 и 10.

Использование этого механизма сродни ручному распараллеливанию — делать это можно, но зачем, если есть атомарные операции (atomic) и критические секции (critical)? При использовании блокировок допускается множество ошибок, которые очень трудно найти, а более совершенный механизм критических секции позволяет их избежать, кроме того, пользоваться им проще. Атомарные операции следует использовать везде где возможно вместо критических секций, согласно правилу 22:

Неэффективным следует считать использование критических секций или функций класса omp_set_lock, там где достаточно директивы atomic.

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

rss