优化稀疏下三角线性系统的后向求解

时间:2020-02-14 20:17:41

标签: c++ c performance assembly optimization

我有n x n个下三角矩阵A的压缩稀疏列(csc)表示,在主对角线上有零,并想在

中求解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传入,并被解决方案覆盖。 LpLiLx分别是稀疏矩阵的标准csc​​表示中的行,索引和数据指针。该功能是程序中最热门的地方,一行如下:

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等,但没有任何效果。

处理器:Xeon Skylake

问题,我该怎么做才能优化此功能?

2 个答案:

答案 0 :(得分:3)

这应该很大程度上取决于矩阵和所使用平台的精确稀疏模式。我在尺寸3006的this matrix的下三角具有19554个非零条目的情况下,使用gcc 8.3.0和编译器标志-O3 -march=native(在我的CPU上为-march=skylake)测试了几件事。希望这在某种程度上接近您的设置,但是无论如何我希望它们能使您对从哪里开始有所了解。

为了计时,我将google/benchmarkthis source file一起使用。它定义了benchBacksolveBaseline,它对问题中给出的实现进行了基准测试,而benchBacksolveOptimized了对建议的“优化”实现进行了基准测试。还有benchFillRhs分别对这两个函数使用基准进行基准测试,以为右侧生成一些不完全无关紧要的值。要获得“纯”反解析的时间,应减去benchFillRhs所花费的时间。

1。严格向后迭代

实现中的外循环向后遍历各列,而内循环向前遍历当前各列。似乎向后遍历每列也将更加一致:

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。内部循环中更少的加载/存储

由于A是较低的三角形,所以我们有i < Li[j]。因此我们知道x[Li[j]]不会因为内部循环中x[i]的变化而改变。我们可以通过使用临时变量将这些知识应用于实现:

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将存储从内部循环内部移动到内存中,直到其结束(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。展开循环

虽然clang在第一个建议的代码更改之后已经开始展开循环,但是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

请注意,基线也有所改善,因为该实现中的循环也已展开。我们的优化版本还可以从循环展开中受益,但可能不尽如人意。查看生成的程序集(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

其他算法

所有这些版本仍然在稀疏矩阵结构上使用向后求解的相同简单实现。本质上,在像这样的稀疏矩阵结构上进行操作可能会给内存流量带来重大问题。至少对于矩阵分解而言,存在更为复杂的方法,这些方法可用于由稀疏结构组装而成的密集子矩阵。示例是超结点方法和多面方法。我对此感到有点模糊,但是我认为这样的方法也将这种思想应用于布局,并使用密集矩阵运算进行下三角向后求解(例如,对于Cholesky型分解)。因此,如果您不被迫坚持直接在稀疏结构上运行的简单方法,那么可能值得研究这类方法。例如,参见戴维斯的this survey

答案 1 :(得分:1)

对于索引类型,使用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]];
        }
    }
}

使用Godbolt's compiler explorer进行编译显示的内部循环代码略有不同,可能会更好地利用CPU管道。我无法测试,但您可以尝试。

这是内部循环的生成代码:

.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

相关问题