Параллельные сокращения по логарифмическому времени

Учитывая n частичные суммы, можно суммировать все частичные суммы за log2 параллельных шагов. Например, предположим, что имеется восемь потоков с восемью частичными суммами: s0, s1, s2, s3, s4, s5, s6, s7. Это можно уменьшить с помощью log2(8) = 3 последовательных шагов, подобных этому;

thread0     thread1    thread2    thread4
s0 += s1    s2 += s3   s4 += s5   s6 +=s7
s0 += s2    s4 += s6
s0 += s4

Я хотел бы сделать это с помощью OpenMP, но не хочу использовать предложение OpenMP reduction. Я придумал решение, но я думаю, что лучшее решение можно найти, возможно, используя предложение OpenMP task.

Это более общий подход, чем скалярное сложение. Позвольте мне выбрать более полезный случай: сокращение массива (см. здесь , здесь, и здесь для получения дополнительной информации редукции массива).

Допустим, я хочу уменьшить массив a. Вот код, который заполняет частные массивы параллельно для каждого потока.

int bins = 20;
int a[bins];
int **at;  // array of pointers to arrays
for(int i = 0; i<bins; i++) a[i] = 0;
#pragma omp parallel
{
    #pragma omp single   
    at = (int**)malloc(sizeof *at * omp_get_num_threads());        
    at[omp_get_thread_num()] = (int*)malloc(sizeof **at * bins);
    int a_private[bins];
    //arbitrary function to fill the arrays for each thread
    for(int i = 0; i<bins; i++) at[omp_get_thread_num()][i] = i + omp_get_thread_num();
}

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

#pragma omp parallel
{
    int n = omp_get_num_threads();
    for(int m=1; n>1; m*=2) {
        int c = n%2;
        n/=2;
        #pragma omp for
        for(int i = 0; i<n; i++) {
            int *p1 = at[2*i*m], *p2 = at[2*i*m+m];
            for(int j = 0; j<bins; j++) p1[j] += p2[j];
        }
        n+=c;
    }
    #pragma omp single
    memcpy(a, at[0], sizeof *a*bins);
    free(at[omp_get_thread_num()]);
    #pragma omp single
    free(at);
}

Позвольте мне попытаться объяснить, что делает этот код. Предположим, есть восемь потоков. Давайте определим оператор += для суммирования по массиву. например s0 += s1 это

for(int i=0; i<bins; i++) s0[i] += s1[i]

тогда этот код будет делать

n   thread0     thread1    thread2    thread4
4   s0 += s1    s2 += s3   s4 += s5   s6 +=s7
2   s0 += s2    s4 += s6
1   s0 += s4

Но этот код не идеален, как хотелось бы.

Одна из проблем заключается в том, что существует несколько неявных барьеров, которые требуют синхронизации всех потоков. В этих барьерах не должно быть необходимости. Первый барьер - между заполнением массивов и сокращением. Второй барьер находится в #pragma omp for объявлении сокращения. Но я не могу использовать предложение nowait с этим методом для устранения барьера.

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

Мне кажется, что лучший метод можно найти с помощью предложения omp task. К сожалению, у меня мало опыта работы с предложением task, и все мои усилия до сих пор приводили к сокращению лучше, чем то, что у меня было сейчас, потерпело неудачу.

Может ли кто-нибудь предложить лучшее решение для сокращения логарифмического времени, например, Предложение OpenMP task?


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

Вот рабочий пример.

#include <stdio.h>
#include <omp.h>
#include <stdlib.h>
#include <string.h>

void foo6() {
    int nthreads = 13;
    omp_set_num_threads(nthreads);
    int bins= 21;
    int a[bins];
    int **at;
    int m = 0;
    int nsums = 0;
    for(int i = 0; i<bins; i++) a[i] = 0;
    #pragma omp parallel
    {
        int n = omp_get_num_threads();
        int ithread = omp_get_thread_num();
        #pragma omp single
        at = (int**)malloc(sizeof *at * n * 2);
        int* a_private = (int*)malloc(sizeof *a_private * bins);

        //arbitrary fill function
        for(int i = 0; i<bins; i++) a_private[i] = i + omp_get_thread_num();

        #pragma omp critical (stack_section)
        at[nsums++] = a_private;

        while(nsums<2*n-2) {
            int *p1, *p2;
            char pop = 0;
            #pragma omp critical (stack_section)
            if((nsums-m)>1) p1 = at[m], p2 = at[m+1], m +=2, pop = 1;
            if(pop) {
                for(int i = 0; i<bins; i++) p1[i] += p2[i];
                #pragma omp critical (stack_section)
                at[nsums++] = p1;
            }
        }

        #pragma omp barrier
        #pragma omp single
        memcpy(a, at[2*n-2], sizeof **at *bins);
        free(a_private);
        #pragma omp single
        free(at);
    }
    for(int i = 0; i<bins; i++) printf("%d ", a[i]); puts("");
    for(int i = 0; i<bins; i++) printf("%d ", (nthreads-1)*nthreads/2 +nthreads*i); puts("");
}

int main(void) {
    foo6();
}

Я считаю, что можно найти лучший метод с использованием задач, которые не помещают неиспользуемые потоки в цикл занятости.


person Z boson    schedule 27.02.2016    source источник
comment
Почему вы не хотите использовать сокращение OpenMP?   -  person Jeff Hammond    schedule 28.02.2016
comment
@ Джефф, потому что reduction - черный ящик. Потому что я не знаю, как это работает, и используется ли сокращение log(nthreads). Потому что reduction не работает, когда операции не проходят. Потому что я считаю полезным уметь делать что-то вручную. Потому что я считаю OpenMP хорошей парадигмой для обучения концепциям параллельного программирования.   -  person Z boson    schedule 28.02.2016
comment
Вы читали спецификацию или любую из сред выполнения OSS (в GCC и Clang или Pathscale)? Это только черный ящик, если вы откажетесь открывать крышку.   -  person Jeff Hammond    schedule 28.02.2016
comment
@ Джефф, справедливо. Но это не удовлетворяет другие мои причины. По крайней мере, он не будет обрабатывать случаи, когда операции не коммутируются или для компиляторов, которые не поддерживают настраиваемые сокращения, для которых требуется OpenMP 4.0.   -  person Z boson    schedule 28.02.2016
comment
Какие встроенные операторы сокращения не коммутируют? И, пожалуйста, не говорите - потому что операция сокращения не имеет смысла и в любом случае не делает то, что вы думаете.   -  person Jeff Hammond    schedule 28.02.2016
comment
Хорошо, извините, вам нужны сокращения, которые не поддерживаются OpenMP как встроенные. Кто они такие?   -  person Jeff Hammond    schedule 28.02.2016
comment
@ Джефф, это хороший аргумент. Я подозреваю, что log(nthreads) сокращения бесполезны для встроенных операций. Я уже задавал вопрос по этому поводу. Мне потребовалось время, чтобы понять, когда log(nthreads) будет полезным, и я думаю, что понял это. Это для таких случаев, как уменьшение массива (или даже более сложных) случаев, то есть только для некоторого индивидуального сокращения. Но я не удивлюсь, если реализация OpenMP просто сократится за время O (nthreads).   -  person Z boson    schedule 28.02.2016
comment
@Jeff, кстати, умножение матриц - это пример операции, которая не коммутируется, но может выполняться параллельно. Вы можете узнать больше о сокращении с помощью OpenMP, когда операции не коммутируют, здесь.   -  person Z boson    schedule 28.02.2016
comment
OpenMP должен реализовывать самое быстрое сокращение, известное разработчикам. Я ожидаю, что многие из них логарифмические (N). Увидеть это в измерениях или нет, зависит от того, как вы их сконструируете. Во многих экспериментах будут преобладать затраты на память или накладные расходы времени выполнения, если вы не амортизируете затраты на параллельные области.   -  person Jeff Hammond    schedule 28.02.2016
comment
@ Джефф, у тебя есть источник для этого? Я бы хотел прочесть об этом. Я согласен с тем, что лучше всего посмотреть исходный код. Думаю, я могу найти его где-нибудь в Интернете? Вы знаете конкретный исходный файл, который делает это в GCC? В любом случае я все еще хочу знать, как это сделать сам с помощью OpenMP.   -  person Z boson    schedule 28.02.2016
comment
Google для среды выполнения GOMP или LLVM OpenMP. У последнего есть свой сайт. Git clone и Git grep доставят вас туда.   -  person Jeff Hammond    schedule 28.02.2016
comment
@Jeff, для ясности, я задал этот вопрос независимо от того, как OpenMP реализует директиву reduction. Для меня не имеет значения, работает ли он в log(nthreads) операциях.   -  person Z boson    schedule 01.03.2016
comment
Я смущен. В вашем коде нет времени. Можете ли вы дать более четкое определение в своем вопросе выше? Означает ли это, что нужно меньше времени? Потому что я не согласен, что логарифмическое время == лучше. Из-за таких проблем, как ложное совместное использование, синхронизация и накладные расходы на согласованность, я могу представить, что вы могли бы сделать что-то вроде 20-50 накоплений в однопоточном цикле за то же время, что вам потребовалось бы выполнить два этапа двустороннего сокращения. По этой причине коэффициент обрушения на ступень должен быть намного выше 2 - может быть, 10, 20 или больше. И, ну, на большинстве машин нет такого неприличного количества процессоров,   -  person Iwillnotexist Idonotexist    schedule 02.03.2016
comment
@IwillnotexistIdonotexist, это двухэтапный процесс. Пример. Вы хотите заполнить историграмму N бинами параллельно. Допустим, у вас t ядра. Затем создайте t пустые историграммы (по одной для каждого потока). Предположим, вам нужно заполнить n элемента. Вы заполняете каждую частную гистограмму параллельно, и это занимает A*n/t секунд (где A - некоторая константа). Это первый этап. На втором этапе вам нужно добавить каждую частную гистограмму. Вы можете сделать это в t операциях или log(t) операциях.   -  person Z boson    schedule 02.03.2016
comment
@IwillnotexistIdonotexist, обычно n >> N, поэтому на самом деле не имеет значения, как вы делаете второй этап, потому что время полностью зависит от первого этапа. Но что, если n ≈ N? В этом случае второй этап будет не маловажным. Я признаю, что мне нужно было придумать пример, чтобы показать это (я имею в виду время), но все участники SO для OpenMP говорят использовать предложение reduction, потому что оно может выполнять второй этап в log(t) операциях. И поэтому я думаю, что это может быть пример того, где это есть.   -  person Z boson    schedule 02.03.2016
comment
@IwillnotexistIdonotexist Я в основном пытаюсь придумать лучший метод для второго этапа этот вопрос, но я допускаю, что для простого уменьшения значения масштабатора log(t), вероятно, ничуть не лучше (Я подозреваю, что это еще хуже, но этого недостаточно, чтобы заметить).   -  person Z boson    schedule 02.03.2016


Ответы (1)


На самом деле, это довольно просто реализовать с помощью задач, использующих рекурсивный подход «разделяй и властвуй». Это почти учебник код.

void operation(int* p1, int* p2, size_t bins)
{
    for (int i = 0; i < bins; i++)
        p1[i] += p2[i];
}

void reduce(int** arrs, size_t bins, int begin, int end)
{
    assert(begin < end);
    if (end - begin == 1) {
        return;
    }
    int pivot = (begin + end) / 2;
    /* Moving the termination condition here will avoid very short tasks,
     * but make the code less nice. */
#pragma omp task
    reduce(arrs, bins, begin, pivot);
#pragma omp task
    reduce(arrs, bins, pivot, end);
#pragma omp taskwait
    /* now begin and pivot contain the partial sums. */
    operation(arrs[begin], arrs[pivot], bins);
}

/* call this within a parallel region */
#pragma omp single
reduce(at, bins, 0, n);

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

Но давайте посмотрим, как это работает на практике *. Для этого мы можем использовать Score-p и Вампир:

* bins=10000, поэтому сокращение действительно занимает немного времени. Выполнен на 24-ядерной системе Haswell без турбонаддува. gcc 4.8.4, -O3. Я добавил некоторый буфер вокруг фактического выполнения, чтобы скрыть инициализацию / постобработку

исполнение трех вариантов

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

  1. omp for петля
  2. omp critical вид задач.
  3. omp task

Это хорошо показывает, как на самом деле работают конкретные реализации. Теперь кажется, что цикл for на самом деле самый быстрый, несмотря на ненужную синхронизацию. Но в этом анализе производительности все же есть ряд недостатков. Я, например, нити не приколол. На практике NUMA (неравномерный доступ к памяти) имеет большое значение: есть ли у ядра эти данные в собственном кэше / памяти собственного сокета? Здесь решение задачи становится недетерминированным. Очень значительная разница между повторениями не учитывается при простом сравнении.

Если операция сокращения станет переменной во время выполнения, то решение задачи станет лучше, чем ваш синхронизированный цикл for.

Решение critical имеет один интересный аспект: пассивные потоки не ждут постоянно, поэтому они, скорее всего, будут потреблять ресурсы ЦП. Это может плохо сказаться на производительности, например в случае турбо-режима.

Помните, что решение task имеет больший потенциал оптимизации, так как позволяет избежать немедленного запуска задач. Работа этих решений также сильно зависит от конкретной среды выполнения OpenMP. Среда выполнения Intel, кажется, намного хуже справляется с задачами.

Моя рекомендация:

  • Реализовать наиболее удобное в обслуживании решение с оптимальной алгоритмической сложностью
  • Измерьте, какие части кода действительно важны для времени выполнения
  • Анализируйте на основе реальных измерений, что является узким местом. По моему опыту, это больше о NUMA и планировании, чем о каком-то ненужном барьере.
  • Выполните микрооптимизацию на основе ваших фактических измерений

Линейное решение

Вот график для линейного proccess_data_v1 из этого вопроса.

параллельная шкала времени

Сокращение OpenMP 4

Я подумал о сокращении OpenMP. Сложнее всего получить данные из массива at внутри цикла без копии. Я инициализирую рабочий массив с помощью NULL и просто перемещаю указатель в первый раз:

void meta_op(int** pp1, int* p2, size_t bins)
{
    if (*pp1 == NULL) {
        *pp1 = p2;
        return;
    }
    operation(*pp1, p2, bins);
}

// ...

// declare before parallel region as global
int* awork = NULL;

#pragma omp declare reduction(merge : int* : meta_op(&omp_out, omp_in, 100000)) initializer (omp_priv=NULL)

#pragma omp for reduction(merge : awork)
        for (int t = 0; t < n; t++) {
            meta_op(&awork, at[t], bins);
        }

Удивительно, но это выглядит не очень хорошо:

график сокращения omp4

верхний icc 16.0.2, нижний gcc 5.3.0, оба с -O3.

Оба, похоже, реализуют сериализованное сокращение. Я попытался заглянуть в gcc / libgomp, но мне не сразу понятно, что происходит. Исходя из промежуточного кода / дизассемблера, они, кажется, оборачивают окончательное слияние в _18 _ / _ 19_ - и это похоже на глобальный мьютекс. Аналогичным образом icc оборачивает вызов operation в kmpc_critical. Я полагаю, что не было особой оптимизации дорогостоящих операций индивидуального сокращения. Традиционное сокращение может быть выполнено с помощью аппаратной атомарной операции.

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

person Zulan    schedule 04.03.2016
comment
Я проверил ваш код. Оно работает! Это именно тот ответ, который я искал. Спасибо! Тот факт, что это учебник, делает его еще лучше. Я рад видеть, что вы смогли сформулировать суть моего вопроса, несмотря на некоторую двусмысленность. Изображение офигенное! Это действительно наглядно показывает то, что я пытался сказать словами. - person Z boson; 05.03.2016
comment
Обратите внимание, что для вашего метода, использующего задачи, по-прежнему требуется барьер между первым и вторым этапом, тогда как в моем методе с критическими секциями (мой второй метод) его нет. - person Z boson; 05.03.2016
comment
Если вам интересно, я изначально использовал более оптимизированную версию своего первого метода. Вместо #pragma omp for я использовал if(omp_get_thread_num() == 2*i*m) { suma[2*i*m] += suma[2*i*m+m]; }. Это гарантирует, что массив на итерации 2*i*m используется потоком 2*j*m, поэтому он должен быть более удобным для кеширования. - person Z boson; 05.03.2016
comment
Было бы интересно увидеть этот график, используя метод O(n) с критическими участками. Я имею в виду метод, определенный в proccess_data_v1 в этом вопросе. Он должен показать, что сокращение выполняет только один поток за раз, и я ожидал, что это будет самый медленный из методов. - person Z boson; 05.03.2016
comment
@Zboson, в текущей реализации барьер обязателен. Однако вы можете запустить функцию заполнения как задачу при условии завершения рекурсии. Тогда сокращение можно начинать самостоятельно. - person Zulan; 05.03.2016
comment
Было бы также интересно увидеть график, использующий предложение сокращения OpenMP, использующее omp declare reduction для уменьшения массива. Может быть, когда-нибудь я сделаю свой собственный сюжет. - person Z boson; 05.03.2016
comment
@Zboson, я добавил след от process_data_v1, подтверждающий предположение. - person Zulan; 05.03.2016
comment
@Zboson Попробовал OpenMP4 omp declare reduction, отредактировал ответ. Результат меня очень удивил. - person Zulan; 07.03.2016
comment
Вау! Здорово, что вы проверили reduction. Большое спасибо! Все (я спрашивал) говорят, что реализации OpenMP могут использовать log(threads), но я никогда не видел никаких доказательств этого, и теперь мы видим, что это линейно (по крайней мере, в этом случае на ЦП с ICC и GCC), как я и предсказывал. Для ясности, я не задавал этот вопрос, чтобы оспорить предложение reduction. Я задал этот вопрос в основном для того, чтобы узнать об алгоритмах сокращения и узнать больше о task. - person Z boson; 07.03.2016