Разделение итераций цикла между потоками

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

for (int i1 = 0; i1 < N; i1++)
  for (int i2 = 0; i2 < N; i2++)
    for (int i3 = 0; i3 < N; i3++)
      for (int i4 = 0; i4 < N; i4++)
        histogram[bin_index(i1, i2, i3, i4)] += 1; // see bottom of question

он работал нормально, yadda yadda yadda, прекрасные графики; -) но потом я подумал, у меня есть 2 ядра на моем компьютере, почему бы не сделать эту программу многопоточной, чтобы я мог запустить ее в два раза быстрее?

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

  • просто давая поток #n все итерации самого внешнего цикла, где i1 % nthreads == n - существенно предопределяя, какие задачи идут в какие потоки
  • попытка настроить некоторую защищенную мьютексом переменную, которая содержит параметр (ы) (i1 в этом случае) следующей задачи, которая требует выполнения - динамическое назначение задач потокам

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

кстати, я написал эту конкретную программу на C, но я предполагаю, что я буду делать то же самое снова на других языках, поэтому ответы не должны быть c-конкретными. (Если кто-нибудь знает библиотеку C для Linux, которая делает такие вещи, я хотел бы знать о это)

редактировать: в этом случае bin_index является детерминированной функцией, которая ничего не меняет, кроме собственных локальных переменных. Что-то вроде этого:--6-->

int bin_index(int i1, int i2, int i3, int i4) {
    // w, d, h are constant floats
    float x1 = i1 * w / N,  x2 = i2 * w / N, y1 = i3 * d / N, y2 = i4 * d / N;
    float l = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) + h * h);
    float th = acos(h / l);
    // th_max is a constant float (previously computed as a function of w, d, h)
    return (int)(th / th_max);
}

(хотя я ценю все комментарии, даже те, которые не относятся к детерминированному bin_index)

8 ответов


первый подход прост. Этого также достаточно, если вы ожидаете, что нагрузка будет равномерно сбалансирована по потокам. В некоторых случаях, особенно если сложность bin_index очень зависит от значений параметров, один из потоков может оказаться гораздо тяжелее, чем остальным. Помните: задача завершается, когда заканчиваются последние потоки.

второй подход немного сложнее, но и распределяет нагрузку более равномерно, если задачи мелкозернистого достаточно (количество задач намного больше, чем количество потоков).

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

кроме того, "гистограмма[bin_index(i1, i2, i3, i4)] += 1" может быть прервана другим потоком, в результате чего результат будет неправильным (если выборка назначения значение, увеличивает его и сохраняет полученное значение в массиве). Вы можете ввести локальную гистограмму для каждого потока и объединить результаты в одну гистограмму, когда все потоки закончатся. Вы также можете убедиться, что только один поток изменяет гистограмму одновременно, но это может привести к тому, что потоки будут блокировать друг друга большую часть времени.


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

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


Как я понимаю, OpenMP был сделан только для того, что вы пытаетесь сделать, хотя я должен признать, что сам еще не использовал его. В основном это сводится к включению заголовка и добавлению предложения pragma.

вы, вероятно, также можете использовать Intel Нить Строительные Блоки библиотека.


Если вы никогда не кодировали многопоточное приложение, я оголю вас, чтобы начать с OpenMP:

  • библиотека теперь включена в gcc по умолчанию
  • это очень простой в использовании

в вашем примере вам просто нужно добавить эту прагму:

#pragma omp parallel shared(histogram)
{
for (int i1 = 0; i1 < N; i1++)
  for (int i2 = 0; i2 < N; i2++)
    for (int i3 = 0; i3 < N; i3++)
      for (int i4 = 0; i4 < N; i4++)
        histogram[bin_index(i1, i2, i3, i4)] += 1;
}

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

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


Я бы сделал что-то вроде этого:

void HistogramThread(int i1, Action<int[]> HandleResults)
{
    int[] histogram = new int[HistogramSize];

    for (int i2 = 0; i2 < N; i2++)
       for (int i3 = 0; i3 < N; i3++)
          for (int i4 = 0; i4 < N; i4++)
             histogram[bin_index(i1, i2, i3, i4)] += 1;

    HandleResults(histogram);
}

int[] CalculateHistogram()
{
    int[] histogram = new int[HistogramSize];

    ThreadPool pool; // I don't know syntax off the top of my head
    for (int i1=0; i1<N; i1++)
    {
       pool.AddNewThread(HistogramThread, i1, delegate(int[] h)
       {
           lock (histogram)
           {
               for (int i=0; i<HistogramSize; i++)
                   histogram[i] += h[i];
           }
       });
    }
    pool.WaitForAllThreadsToFinish();

    return histogram;
}

таким образом, вам не нужно делиться какой-либо памятью до конца.


Если вы когда-либо делали это в .NET, используйте Параллельные Расширения.


Если вы хотите написать многопоточный код хруста чисел (и вы собираетесь делать много этого в будущем), я бы предложил вам взглянуть на использование функционального языка, такого как OCaml или Haskell.

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


Я согласен с Sharptooth, что ваш первый подход кажется единственно правдоподобной.

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

Это будет опасно подход, так как вы назначение общей памяти без охраны. Но это, кажется, стоит опасности (если ускорение x2 имеет значение). Если вы можете быть уверены, что все значения bin_index(i1, i2, i3, i4) отличаются в вашем разделении цикла, то он должен работать, так как назначение массива будет в разных местах в вашей общей памяти. Тем не менее, всегда нужно внимательно смотреть на такие подходы.

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

Edit:

глядя на ваш bin_index(i1, i2, i3, i4), я подозреваю, что ваш процесс не может быть распараллелен без значительных усилий.

единственный способ разделить работу вычисления в цикле-это снова убедиться, что ваши потоки получат доступ к тем же областям памяти. Однако похоже, что bin_index (i1, i2, i3, i4), скорее всего, будет повторять значения довольно часто. Вы можете разделить итерацию на условия, в которых bin_index выше a отсечка и где она ниже, чем отсечка. Или вы можете разделить его произвольно и посмотреть, реализуется ли инкремент атомарно. Но любой сложный подход к потоковой обработке вряд ли обеспечит улучшение, если вы можете работать только с двумя ядрами.