Оптимизация обратного решения для разреженной линейной системы tri angular - PullRequest
8 голосов
/ 14 февраля 2020

У меня есть сжатый разреженный столбец (cs c), представляющий nxn младшую три angular матрицу A с нулями на главной диагонали, и я хотел бы найти для b в

(A + I)' * x = b

У меня есть процедура для вычисления этого:

void backsolve(const int*__restrict__ Lp,
               const int*__restrict__ Li,
               const double*__restrict__ Lx,
               const int n,
               double*__restrict__ x) {
  for (int i=n-1; i>=0; --i) {
      for (int j=Lp[i]; j<Lp[i+1]; ++j) {
          x[i] -= Lx[j] * x[Li[j]];
      }
  }
}

Таким образом, b передается через аргумент x и перезаписывается решением. Lp, Li, Lx - соответственно строка, индексы и указатели данных в стандартном представлении cs c разреженных матриц. Эта функция является главной горячей точкой в ​​программе, где строка

x[i] -= Lx[j] * x[Li[j]];

представляет собой большую часть затраченного времени. Компиляция с gcc-8.3 -O3 -mfma -mavx -mavx512f дает

backsolve(int const*, int const*, double const*, int, double*):
        lea     eax, [rcx-1]
        movsx   r11, eax
        lea     r9, [r8+r11*8]
        test    eax, eax
        js      .L9
.L5:
        movsx   rax, DWORD PTR [rdi+r11*4]
        mov     r10d, DWORD PTR [rdi+4+r11*4]
        cmp     eax, r10d
        jge     .L6
        vmovsd  xmm0, QWORD PTR [r9]
.L7:
        movsx   rcx, DWORD PTR [rsi+rax*4]
        vmovsd  xmm1, QWORD PTR [rdx+rax*8]
        add     rax, 1
        vfnmadd231sd    xmm0, xmm1, QWORD PTR [r8+rcx*8]
        vmovsd  QWORD PTR [r9], xmm0
        cmp     r10d, eax
        jg      .L7
.L6:
        sub     r11, 1
        sub     r9, 8
        test    r11d, r11d
        jns     .L5
        ret
.L9:
        ret

Согласно vtune,

vmovsd  QWORD PTR [r9], xmm0

является самой медленной частью. У меня почти нет опыта работы со сборкой, и я не знаю, как дальше диагностировать или оптимизировать эту операцию. Я попытался скомпилировать с разными флагами, чтобы включить / отключить SSE, FMA и т. Д. c, но ничего не помогло.

Процессор: Xeon Skylake

Вопрос Что можно Я делаю, чтобы оптимизировать эту функцию?

Ответы [ 2 ]

2 голосов
/ 28 марта 2020

Это должно во многом зависеть от точного разреженности матрицы и используемой платформы. Я протестировал несколько вещей с gcc 8.3.0 и флагами компилятора -O3 -march=native (что составляет -march=skylake на моем ЦП) в нижнем треугольнике этой матрицы измерения 3006 с 19554 ненулевыми записями. Надеюсь, это несколько близко к вашей настройке, но в любом случае я надеюсь, что они могут дать вам представление о том, с чего начать.

Для определения времени я использовал google / benchmark с this исходный файл . Он определяет benchBacksolveBaseline, который измеряет реализацию, представленную в вопросе, и benchBacksolveOptimized, который сравнивает предложенные "оптимизированные" реализации. Существует также benchFillRhs, который отдельно сравнивает функцию, которая используется в обоих для генерации некоторых не совсем тривиальных значений для правой части. Чтобы получить время «чистых» обратных процессов, нужно вычесть время, которое занимает benchFillRhs.

1. Итерация строго назад

Внешний l oop в вашей реализации перебирает столбцы в обратном направлении, в то время как внутренний l oop перебирает текущий столбец вперед. Похоже, было бы более последовательным итерировать каждый столбец в обратном направлении:

for (int i=n-1; i>=0; --i) {
    for (int j=Lp[i+1]-1; j>=Lp[i]; --j) {
        x[i] -= Lx[j] * x[Li[j]];
    }
}

Это едва меняет сборку (https://godbolt.org/z/CBZAT5), но временные параметры тестирования показывают заметное улучшение :

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
benchFillRhs                  2737 ns         2734 ns      5120000
benchBacksolveBaseline       17412 ns        17421 ns       829630
benchBacksolveOptimized      16046 ns        16040 ns       853333

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

2. Меньше нагрузок / накоплений во внутренней l oop

Поскольку A меньше tri angular, у нас есть i < Li[j]. Поэтому мы знаем, что x[Li[j]] не изменится из-за изменений в x[i] во внутреннем l oop. Мы можем применить эти знания в нашей реализации, используя временную переменную:

for (int i=n-1; i>=0; --i) {
    double xi_temp = x[i];
    for (int j=Lp[i+1]-1; j>=Lp[i]; --j) {
        xi_temp -= Lx[j] * x[Li[j]];
    }
    x[i] = xi_temp;
}

Это заставит gcc 8.3.0 переместить хранилище в память из внутренней l oop прямо после его окончания ( https://godbolt.org/z/vM4gPD). Тест для тестовой матрицы в моей системе показывает небольшое улучшение:

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
benchFillRhs                  2737 ns         2740 ns      5120000
benchBacksolveBaseline       17410 ns        17418 ns       814545
benchBacksolveOptimized      15155 ns        15147 ns       887129

3. Разверните l oop

Пока clang уже начинает раскручивать l oop после первого предлагаемого изменения кода, gcc 8.3.0 по-прежнему нет. Итак, давайте попробуем, дополнительно передав -funroll-loops.

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
benchFillRhs                  2733 ns         2734 ns      5120000
benchBacksolveBaseline       15079 ns        15081 ns       953191
benchBacksolveOptimized      14392 ns        14385 ns       963441

Обратите внимание, что базовая линия также улучшается, так как l oop в этой реализации также развернут. Наша оптимизированная версия также выигрывает от развертывания l oop, но, возможно, не так, как нам хотелось бы. Глядя на сгенерированную сборку (https://godbolt.org/z/_LJC5f), кажется, что gcc, возможно, ушло немного далеко с 8 развертываниями. Для моей настройки, я могу сделать немного лучше, просто развернув вручную. Поэтому снова сбросьте флаг -funroll-loops и выполните развертывание примерно так:

for (int i=n-1; i>=0; --i) {
    const int col_begin = Lp[i];
    const int col_end = Lp[i+1];
    const bool is_col_nnz_odd = (col_end - col_begin) & 1;
    double xi_temp = x[i];
    int j = col_end - 1;
    if (is_col_nnz_odd) {
        xi_temp -= Lx[j] * x[Li[j]];
        --j;
    }
    for (; j >= col_begin; j -= 2) {
        xi_temp -= Lx[j - 0] * x[Li[j - 0]] +
                   Lx[j - 1] * x[Li[j - 1]];
    }
    x[i] = xi_temp;
}

С этим я измеряю:

------------------------------------------------------------------
Benchmark                        Time             CPU   Iterations
------------------------------------------------------------------
benchFillRhs                  2728 ns         2729 ns      5090909
benchBacksolveBaseline       17451 ns        17449 ns       822018
benchBacksolveOptimized      13440 ns        13443 ns      1018182

Другие алгоритмы

Все эти версии по-прежнему используют одну и ту же простую реализацию обратного решения для структуры разреженной матрицы. По сути, работа с такими разреженными матричными структурами может иметь значительные проблемы с трафиком памяти c. По крайней мере, для матричных факторизаций существуют более сложные методы, которые работают на плотных подматрицах, собранных из разреженной структуры. Примерами являются супернодальные и мультифронтальные методы. Я немного размышляю над этим, но я думаю, что такие методы также применят эту идею для компоновки и используют плотные матричные операции для более низких три angular обратных решений (например, для факторизаций типа Холецкого). Поэтому, возможно, стоит взглянуть на такие методы, если вы не обязаны придерживаться простого метода, который напрямую работает с разреженной структурой. См. Например этот опрос Дэвиса.

1 голос
/ 14 февраля 2020

Вы можете сократить несколько циклов, используя unsigned вместо int для типов индексов, которые в любом случае должны быть >= 0:

void backsolve(const unsigned * __restrict__ Lp,
               const unsigned * __restrict__ Li,
               const double * __restrict__ Lx,
               const unsigned n,
               double * __restrict__ x) {
    for (unsigned i = n; i-- > 0; ) {
        for (unsigned j = Lp[i]; j < Lp[i + 1]; ++j) {
            x[i] -= Lx[j] * x[Li[j]];
        }
    }
}

Компиляция с помощью Проводник компилятора Годболта показывает немного другой код для innerl oop, потенциально улучшая использование конвейера ЦП. Я не могу проверить, но вы можете попробовать.

Вот сгенерированный код для внутреннего l oop:

.L8:
        mov     rax, rcx
.L5:
        mov     ecx, DWORD PTR [r10+rax*4]
        vmovsd  xmm1, QWORD PTR [r11+rax*8]
        vfnmadd231sd    xmm0, xmm1, QWORD PTR [r8+rcx*8]
        lea     rcx, [rax+1]
        vmovsd  QWORD PTR [r9], xmm0
        cmp     rdi, rax
        jne     .L8

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...