Подтвердить что ты не робот

Почему этот код не масштабируется линейно?

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

Я запускаю его с плохой обработанной матрицей строк 2097152 (которая никогда не сходится) с не более 7 ненулевыми столбцами на строку.

Перевод: внешний цикл do-while будет выполнять 10000 итераций (значение, которое я передаю как max_iters), средний for будет выполнять итерации 2097152, разделенные на куски work_line, разделенные между потоками OpenMP. Самый внутренний цикл for будет содержать 7 итераций, за исключением очень немногих случаев (менее 1%), где он может быть меньше.

Существует зависимость данных между потоками в значениях массива sol. Каждая итерация середины for обновляет один элемент, но считывает до 6 других элементов массива. Поскольку SOR не является точным алгоритмом, при чтении он может иметь любое из предыдущего или текущего значения в этой позиции (если вы знакомы с решателями, это Гаусс-Сидель, который терпит поведение Якоби в некоторых местах ради parallelism).

typedef struct{
    size_t size;

    unsigned int *col_buffer;
    unsigned int *row_jumper;
    real *elements;
} Mat;

int work_line;

// Assumes there are no null elements on main diagonal
unsigned int solve(const Mat* matrix, const real *rhs, real *sol, real sor_omega, unsigned int max_iters, real tolerance)
{
    real *coefs = matrix->elements;
    unsigned int *cols = matrix->col_buffer;
    unsigned int *rows = matrix->row_jumper;
    int size = matrix->size;
    real compl_omega = 1.0 - sor_omega;
    unsigned int count = 0;
    bool done;

    do {
        done = true;
        #pragma omp parallel shared(done)
        {
            bool tdone = true;

            #pragma omp for nowait schedule(dynamic, work_line)
            for(int i = 0; i < size; ++i) {
                real new_val = rhs[i];
                real diagonal;
                real residual;
                unsigned int end = rows[i+1];

                for(int j = rows[i]; j < end; ++j) {
                    unsigned int col = cols[j];
                    if(col != i) {
                        real tmp;
                        #pragma omp atomic read
                        tmp = sol[col];

                        new_val -= coefs[j] * tmp;
                    } else {
                        diagonal = coefs[j];
                    }
                }

                residual = fabs(new_val - diagonal * sol[i]);
                if(residual > tolerance) {
                    tdone = false;
                }

                new_val = sor_omega * new_val / diagonal + compl_omega * sol[i];
                #pragma omp atomic write
                sol[i] = new_val;
            }

            #pragma omp atomic update
            done &= tdone;
        }
    } while(++count < max_iters && !done);

    return count;
}

Как вы можете видеть, внутри параллельной области нет блокировки, поэтому, чему они нас всегда учат, это проблема 100% параллельной. Это не то, что я вижу на практике.

Все мои тесты выполнялись на процессоре Intel (R) Xeon (R) E5-2670 v2 @2.50GHz, 2 процессора, по 10 ядер каждый, с поддержкой гиперпотока, суммируя до 40 логических ядер.

В моих первых запусках, work_line был зафиксирован на 2048, а количество потоков варьировалось от 1 до 40 (всего 40 прогонов). Это график с временем выполнения каждого запуска (в секундах x число потоков):

Time x Threads, work line: 2048

Удивление было логарифмической кривой, поэтому я подумал, что, поскольку рабочая строка была настолько большой, общие кэши были не очень хорошо использованы, поэтому я выкопал этот виртуальный файл /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size, который сказал мне, что этот процессор L1-кеш синхронизирует обновления в группах по 64 байта (8 удваивается в массиве sol). Поэтому я установил work_line в 8:

Time x Threads, work line: 8

Затем я подумал, что 8 было слишком низким, чтобы избежать киосков NUMA и установить work_line в 16:

Time x Threads, work line: 16

Во время выполнения вышеизложенного я подумал: "Кто я такой, чтобы предсказать, что work_line хорошо? Позволяет просто увидеть...", и планировать запуск каждого work_line с 8 до 2048, шаг 8 (т.е. каждый несколько строк кеша, от 1 до 256). Результаты для 20 и 40 потоков (в секундах x размер раскола среднего цикла for, разделенный между потоками):

Time x Work Line, threads: 40, 20

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

Как я могу сделать этот масштаб шкалы линейно равным числу ядер? Что мне не хватает? Есть ли что-то в этой проблеме, которое делает это не так хорошо, как кажется сначала?

Обновление

Следуя рекомендациям, я тестировал как с static, так и dynamic планированием, но удалял чтение/запись атома массивом sol. Для справки, синяя и оранжевая линии совпадают с предыдущим графиком (только до work_line = 248;). Желтые и зеленые линии - новые. Для чего я мог видеть: static делает существенную разницу для низкого work_line, но после 96 преимущества dynamic перевешивают его накладные расходы, делая это быстрее. Атомные операции не имеют никакого значения.

Time x Work Line, no atomic vs atomic, dynamic vs static

4b9b3361

Ответ 1

Массовое преобразование с разреженным матричным вектором связано с памятью (см. здесь), и это может быть показано с помощью простой модели линии крыши. Проблемы с памятью связаны с более высокой пропускной способностью памяти в системах с множеством NUMA, но только если инициализация данных выполняется таким образом, что данные распределяются между двумя доменами NUMA. У меня есть некоторые причины полагать, что вы загружаете матрицу в последовательный порт, и поэтому вся ее память выделяется на одном NUMA node. В этом случае вам не удастся воспользоваться двойной пропускной способностью памяти, доступной в системе с двумя гнездами, и не имеет значения, используете ли вы schedule(dynamic) или schedule(static). То, что вы можете сделать, это включить политику NUMA для чередования памяти, чтобы распределить распределение памяти между узлами NUMA. Таким образом, каждый поток будет иметь 50% -ный доступ к локальной памяти и 50% -ный доступ к удаленной памяти вместо того, чтобы все потоки на втором CPU были удалены 100% -ным удаленным доступом к памяти. Самый простой способ включить политику - использовать numactl:

$ OMP_NUM_THREADS=... OMP_PROC_BIND=1 numactl --interleave=all ./program ...

OMP_PROC_BIND=1 позволяет фиксировать нити и немного улучшать производительность.

Я также хотел бы отметить, что это:

done = true;
#pragma omp parallel shared(done)
{
    bool tdone = true;

    // ...

    #pragma omp atomic update
    done &= tdone;
}

является, вероятно, не очень эффективной повторной реализацией:

done = true;
#pragma omp parallel reduction(&:done)
{
    // ...
        if(residual > tolerance) {
            done = false;
        }
    // ...
}

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

Ответ 2

Попробуйте запустить IPCM (Монитор производительности Intel Performance). Вы можете наблюдать за пропускной способностью памяти и посмотреть, не превышает ли она больше ядер. Я чувствую, что у вас ограниченная пропускная способность.

В качестве быстрого вычисления количества конвертов я обнаружил, что пропускная способность без кэширования составляет около 10 ГБ/с на Xeon. Если ваши часы составляют 2,5 ГГц, это одно 32-битное слово за такт. Ваш внутренний цикл - это всего лишь операция с несколькими добавлениями, циклы которых вы можете рассчитывать с одной стороны, плюс несколько циклов для накладных расходов на цикл. Меня не удивляет, что после 10 потоков вы не получаете прироста производительности.

Ответ 3

В вашем внутреннем цикле есть omp atomic read, а ваш средний цикл имеет omp atomic write для местоположения, которое может быть тем же самым, что и прочитанное одним из чтений. OpenMP обязан гарантировать, что атомарная запись и чтение одного и того же местоположения будут сериализованы, поэтому на самом деле, вероятно, необходимо ввести блокировку, даже если нет явной.

Возможно, даже потребуется заблокировать весь массив sol, если он не сможет каким-то образом выяснить, какие чтения могут конфликтовать с тем, что пишет, и действительно, процессоры OpenMP не всегда являются такими умными.

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

Ответ 4

Я подозреваю, что у вас проблемы с кешированием. Когда один поток обновляет значение в массиве sol, он блокирует кеши на других ЦП, которые хранят эту же строку кеша. Это заставляет кэши обновляться, что приводит к остановке CPU.

Ответ 5

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

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