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

Помечено: 

В этой теме 0 ответов, 1 участник, последнее обновление  Васильев Владимир Сергеевич 1 месяц назад.

Просмотр 1 сообщения - с 1 по 1 (всего 1)
  • Автор
    Сообщения
  • #4349

    В библиотеке 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 есть механизм барьеров (который также не стоит пытаться реализовать вручную на блокировках).

    Вложения:
    Вы должны войти для просмотра вложений.
Просмотр 1 сообщения - с 1 по 1 (всего 1)

Для ответа в этой теме необходимо авторизоваться.