OpenMP с визуализацией Game of Life с использованием SFML - PullRequest
0 голосов
/ 23 апреля 2020

Здравствуйте, я пытаюсь сравнить скорости между последовательной и параллельной версией «Игры жизни». Я использовал библиотеку SFML для визуализации игры жизни, подобной этой. Окно SFML Последовательная логика c проста, как показано ниже.

for (int i = 0; i < height; i++) {
            for (int j = 0; j < width; j++) {
                int neighbor = 0;

                // check 8 cells around.
                // 1 2 3  -1
                // 4   5  0
                // 6 7 8  +1

                // (1)
                if (gamefieldSerial.isAvailableCell(UP(i), LEFT(j))) {
                    if(gamefieldSerial[UP(i)][LEFT(j)] == LIVE) neighbor++;
                }
                // (2)
                if (gamefieldSerial.isAvailableCell(UP(i), j)) {
                    if (gamefieldSerial[UP(i)][j] == LIVE)      neighbor++;
                }
                // (3)
                if (gamefieldSerial.isAvailableCell(UP(i), RIGHT(j))) {
                    if (gamefieldSerial[UP(i)][RIGHT(j)] == LIVE)   neighbor++;
                }
                // (4)
                if (gamefieldSerial.isAvailableCell(i, LEFT(j))) {
                    if (gamefieldSerial[i][LEFT(j)] == LIVE)        neighbor++;
                }
                // (5)
                if (gamefieldSerial.isAvailableCell(i, RIGHT(j))) {
                    if (gamefieldSerial[i][RIGHT(j)] == LIVE)       neighbor++;
                }
                // (6)
                if (gamefieldSerial.isAvailableCell(DOWN(i), LEFT(j))) {
                    if (gamefieldSerial[DOWN(i)][LEFT(j)] == LIVE)  neighbor++;
                }
                // (7)
                if (gamefieldSerial.isAvailableCell(DOWN(i), j)) {
                    if (gamefieldSerial[DOWN(i)][j] == LIVE)        neighbor++;
                }
                // (8)
                if (gamefieldSerial.isAvailableCell(DOWN(i), RIGHT(j))) {
                    if (gamefieldSerial[DOWN(i)][RIGHT(j)] == LIVE) neighbor++;
                }

                // -- Rule of Game of Life
                // Cell borns when exactly 3 neighbor is LIVE
                // Cell remains alive when 2 or 3 neighbor is LIVE
                // Cell with more than 3 neighbor dies with overpopulation
                // Cell with less than 2 neighbor dies with underpopulation
                if (gamefieldSerial[i][j] == DEAD) {
                    if (neighbor == 3) {
                        gamefieldSerial[i][j] = LIVE;
                    }
                }
                else if (gamefieldSerial[i][j] == LIVE) {
                    if (neighbor < 2 || neighbor > 3) {
                        gamefieldSerial[i][j] = DEAD;
                    }
                }
            }

Потребовалось 3940 мс на 768 * 256 ячеек с 100 поколениями. Но в параллельной версии я реализовал, как показано ниже,

#pragma omp parallel for num_threads(4)
        for (int t = 0; t < width * height; t++) {
            int i = t / width;
            int j = t % width;
            int neighbor = 0;

            // check 8 cells around.
            // 1 2 3  -1
            // 4   5  0
            // 6 7 8  +1

            // (1)
            if (gamefieldParallel.isAvailableCell(UP(i), LEFT(j))) {
                if (gamefieldParallel[UP(i)][LEFT(j)] == LIVE) neighbor++;
            }
            // (2)
            if (gamefieldParallel.isAvailableCell(UP(i), j)) {
                if (gamefieldParallel[UP(i)][j] == LIVE)      neighbor++;
            }
            // (3)
            if (gamefieldParallel.isAvailableCell(UP(i), RIGHT(j))) {
                if (gamefieldParallel[UP(i)][RIGHT(j)] == LIVE)   neighbor++;
            }
            // (4)
            if (gamefieldParallel.isAvailableCell(i, LEFT(j))) {
                if (gamefieldParallel[i][LEFT(j)] == LIVE)        neighbor++;
            }
            // (5)
            if (gamefieldParallel.isAvailableCell(i, RIGHT(j))) {
                if (gamefieldParallel[i][RIGHT(j)] == LIVE)       neighbor++;
            }
            // (6)
            if (gamefieldParallel.isAvailableCell(DOWN(i), LEFT(j))) {
                if (gamefieldParallel[DOWN(i)][LEFT(j)] == LIVE)  neighbor++;
            }
            // (7)
            if (gamefieldParallel.isAvailableCell(DOWN(i), j)) {
                if (gamefieldParallel[DOWN(i)][j] == LIVE)        neighbor++;
            }
            // (8)
            if (gamefieldParallel.isAvailableCell(DOWN(i), RIGHT(j))) {
                if (gamefieldParallel[DOWN(i)][RIGHT(j)] == LIVE) neighbor++;
            }

            // -- Rule of Game of Life
            // Cell borns when exactly 3 neighbor is LIVE
            // Cell remains alive when 2 or 3 neighbor is LIVE
            // Cell with more than 3 neighbor dies with overpopulation
            // Cell with less than 2 neighbor dies with underpopulation
            if (gamefieldParallel[i][j] == DEAD) {
                if (neighbor == 3) {
                    gamefieldParallel[i][j] = LIVE;
                }
            }
            else if (gamefieldParallel[i][j] == LIVE) {
                if (neighbor < 2 || neighbor > 3) {
                    gamefieldParallel[i][j] = DEAD;
                }
            }
        }

В той же среде потребовалось 5746 мс. Я думал, что применение директивы forMP for-l oop повышает производительность, но это не так. Должен ли я подходить по-другому?

============= И gamefieldParallel, и gamefieldSerial - это экземпляр класса GameField, который динамически выделяет переменную поля int ** для ячеек. Я использую перегрузку операторов для доступа к нему как к двумерному массиву. (Извините за плохой engli sh!)

Ответы [ 2 ]

1 голос
/ 24 апреля 2020

GoL - идеальный образец для распараллеливания OpenMP, поскольку он смущающе параллелен - вычисление значения ячейки в текущем поколении не зависит от вычислений соседних ячеек. Проблема здесь в том, что вы читаете и пишете в один и тот же массив, что неправильно с точки зрения реализации. В последовательном коде это просто приводит к ошибочно вычисленным состояниям ячеек, но параллельно вы сталкиваетесь с такими проблемами, как ложное совместное использование, которое значительно замедляет программу. Кроме того, вы заменили два вложенных цикла на один и вычислили индексы строк и столбцов, используя модуль arithmeti c, который, вероятно, является самым большим источником замедления. Другой причиной замедления является наличие параллельной области во внутренней l oop - вы платите цену за активацию потоков в регионе для каждого поколения.

Вам нужно использовать два массива - один для предыдущего поколения и один для текущего. Прочитайте из первого массива и запишите в последний. Когда закончите, поменяйте местами массивы и повторите. В псевдо-C ++ с OpenMP решение выглядит следующим образом:

#pragma omp parallel
{
  // Generations loop (1)
  for (int gen = 0; gen < NUM_GENERATIONS; gen++) {

    // Compute the new current generation (2)
    #pragma omp for
    for (int i = 0; i < height; i++) {
      for (int j = 0; j < width; j++) {
        // Count the number of live neighbours of current[i][j] (3)
        int neighbours = count_neighbours(current, i, j);

        // Update the state of the current cell (4)
        if (current[i][j] == DEAD && neighbours == 3)
          next[i][j] = LIVE;
        else if (current[i][j] == LIVE)
          next[i][j] = (neighbours < 2 || neighbours > 3) ? DEAD : LIVE;
      }
    }

    // The following block runs in the master thread (5)
    #pragma omp master
    {
      // Swap the current and next arrays
      std::swap(current, next);

      // Display the board state (if necessary)
      display(current);
    }

    // Synchronise the threads before the next iteration (6)
    #pragma omp barrier
  }
}

На что следует обратить внимание (цифры соответствуют номерам в комментариях к коду):

  1. Внешний (поколения) l oop находится внутри параллельной области. Это устраняет накладные расходы, связанные с активацией и деактивацией региона на каждой итерации.

  2. Конструкция совместного использования for применяется к l oop, который проходит по строкам платы. Этого достаточно для оптимального распараллеливания проблемы. Если вы можете убедиться, что width times sizeof тип элементов в next кратен 64 байтам (размер строки кэша на большинстве процессоров), вероятность ложного совместного использования будет уменьшена.

  3. Подсчет количества соседей включает значения в массиве current.

  4. Новые значения go в массиве next.

  5. Как только следующее поколение будет полностью вычислено, нам нужно поменять местами массивы и сделать next, чтобы стать current для следующей итерации поколений l oop. Это должно быть сделано одним потоком, и в этом случае это бремя ложится на главный поток. Обратите внимание, что этот обмен наиболее эффективен, если и current, и next являются указателями на фактические массивы. Меняется местами элемент массива для элемента. Поменять местами два указателя на эти массивы очень быстро. Использование главного потока также дает возможность совершать GUI вызовов, например, display() (при условии, что это функция, которая выводит доску на экран aws).

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

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

    // The following block runs in the master thread (5)
    #pragma omp master
    {
      // Swap the current and next arrays
      std::swap(current, next);

      // Display the board state (if necessary)
      display(current);
    }

    // Synchronise the threads before the next iteration (6)
    #pragma omp barrier

можно заменить на:

    #pragma omp single
    std::swap(current, next);

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


Я собираюсь дать вам еще один незапрошенный совет по ускорению вычислений. Наличие всех этих условий

if (gamefield.isAvailableCell(UP(i), LEFT(j))) {
   ...
}

замедляет ваш код, поскольку современные процессоры работают лучше, если нет условий. Этот код служит только для отлова ячеек на границах массива моделирования. Таким образом, вместо проверки для каждой ячейки, имеет ли она соседа в заданном направлении, просто сделайте доску на две ячейки шире (одну в начале и одну в конце ряда) и на две ячейки выше (одну строку вверху и один внизу) и оставьте лишние клетки пустыми (DEAD). Тогда isAvailableCell() всегда будет true, и вы сможете избавиться от условных выражений. Только не забудьте запустить циклы от 1 до width / height включительно.

0 голосов
/ 23 апреля 2020

Как в вашей последовательной, так и в параллельной версии вы обновляете игровое поле по мере его прохождения. Это ошибка с обеими версиями. Допустим, вы вычислили новое состояние для gameField[0][0], а затем обновили его. Теперь вы переходите на gameField[0][1] --- как часть вычисления его нового состояния, вы смотрите налево на gameField[0][0], которое уже содержит недавно обновленное состояние этой ячейки, но правила Игры Жизни должны применяться к первой ячейке previous state.

Другими словами, вы должны иметь только для чтения (const) oldGameField и затем заполнять новые состояния в newGameField. После того как вы вычислили все ячейки, вы можете использовать новое поле в качестве старого поля для следующей итерации игры.

Исправление этой ошибки на самом деле является важной частью решения вашей проблемы с производительностью.

Многопоточность

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

Но у вас все равно есть только одна страница для newGameField. В вашем последовательном подходе есть только один человек, который имеет страницу и карандаш исключительно для себя. Теперь у вас есть четыре человека, которые пытаются рисовать на одной странице одновременно. Они тратят больше времени на передачу карандаша и страницы между собой, чем на расчеты! Буквально на выполнение работы уходит четыре человека дольше, чем один мог бы выполнить ее в одиночку.

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

Итак, как нам это исправить?

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

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

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

Убедитесь, что память нового игрового поля каждого потока не установлена. не достаточно близко, чтобы вызвать помехи в памяти, используемой другим потоком, сложно. Как правило, вам нужно знать некоторую информацию о размере строк кэша в ваших ядрах, независимо от того, использует ли ваша платформа что-то, называемое «NUMA», et c et c.

Я не знаю OpenMP - но, возможно, он имеет некоторую встроенную поддержку, чтобы помочь с этим.

В итоге

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