如何加速我的稀疏矩阵求解器?

时间:2010-03-05 16:19:41

标签: c++ performance optimization sparse-matrix

我正在使用Gauss-Seidel方法编写稀疏矩阵求解器。通过剖析,我已经确定我的程序的大约一半时间花在解算器中。性能关键部分如下:

size_t ic = d_ny + 1, iw = d_ny, ie = d_ny + 2, is = 1, in = 2 * d_ny + 1;
for (size_t y = 1; y < d_ny - 1; ++y) {
    for (size_t x = 1; x < d_nx - 1; ++x) {
        d_x[ic] = d_b[ic]
                - d_w[ic] * d_x[iw] - d_e[ic] * d_x[ie]
                - d_s[ic] * d_x[is] - d_n[ic] * d_x[in];
        ++ic; ++iw; ++ie; ++is; ++in;
    }
    ic += 2; iw += 2; ie += 2; is += 2; in += 2;
}

所涉及的所有数组都是float类型。实际上,它们不是数组,而是具有重载[]运算符的对象,我认为它应该被优化掉,但定义如下:

inline float &operator[](size_t i) { return d_cells[i]; }
inline float const &operator[](size_t i) const { return d_cells[i]; }

对于d_nx = d_ny = 128,这可以在Intel i7 920上以每秒3500次运行。这意味着内部循环体每秒运行3500 * 128 * 128 = 5700万次。由于只涉及一些简单的算法,因此对于2.66 GHz处理器来说,这个数字很低。

也许它不受CPU功率的限制,而是受内存带宽的限制?好吧,一个128 * 128 float阵列吃65 kB,所以所有6个阵列都应该很容易适应CPU的L3缓存(8 MB)。假设寄存器中没有任何缓存,我在内部循环体中计算了15次内存访问。在64位系统上,这是每次迭代120个字节,因此5700万* 120字节= 6.8 GB / s。 L3缓存运行在2.66 GHz,因此它具有相同的数量级。我的猜测是记忆确实是瓶颈。

为了加快速度,我尝试了以下方法:

  • g++ -O3汇编。 (好吧,我从一开始就这样做。)

  • 使用OpenMP pragma并行化4个核心。我必须改为Jacobi算法,以避免从同一个数组读取和写入。这需要我做两次迭代,导致大约相同速度的净结果。

  • 摆弄循环体的实现细节,例如使用指针而不是索引。没效果。

加快这个人加速的最佳方法是什么?是否有助于重新组装内部主体(我必须先学习)?我应该在GPU上运行它(我知道该怎么做,但是这样麻烦)?还有其他好主意吗?

(N.B。我取“否”作为答案,如:“它不能明显加快,因为......”)

更新:根据要求,这是一个完整的程序:

#include <iostream>
#include <cstdlib>
#include <cstring>

using namespace std;

size_t d_nx = 128, d_ny = 128;
float *d_x, *d_b, *d_w, *d_e, *d_s, *d_n;

void step() {
    size_t ic = d_ny + 1, iw = d_ny, ie = d_ny + 2, is = 1, in = 2 * d_ny + 1;
    for (size_t y = 1; y < d_ny - 1; ++y) {
        for (size_t x = 1; x < d_nx - 1; ++x) {
            d_x[ic] = d_b[ic]
                - d_w[ic] * d_x[iw] - d_e[ic] * d_x[ie]
                - d_s[ic] * d_x[is] - d_n[ic] * d_x[in];
            ++ic; ++iw; ++ie; ++is; ++in;
        }
        ic += 2; iw += 2; ie += 2; is += 2; in += 2;
    }
}

void solve(size_t iters) {
    for (size_t i = 0; i < iters; ++i) {
        step();
    }
}

void clear(float *a) {
    memset(a, 0, d_nx * d_ny * sizeof(float));
}

int main(int argc, char **argv) {
    size_t n = d_nx * d_ny;
    d_x = new float[n]; clear(d_x);
    d_b = new float[n]; clear(d_b);
    d_w = new float[n]; clear(d_w);
    d_e = new float[n]; clear(d_e);
    d_s = new float[n]; clear(d_s);
    d_n = new float[n]; clear(d_n);
    solve(atoi(argv[1]));
    cout << d_x[0] << endl; // prevent the thing from being optimized away
}

我按如下方式编译并运行它:

$ g++ -o gstest -O3 gstest.cpp
$ time ./gstest 8000
0

real    0m1.052s
user    0m1.050s
sys     0m0.010s

(它每秒执行8000而不是3500次迭代,因为我的“真实”程序也做了很多其他的事情。但它具有代表性。)

更新2 :我被告知单元化值可能不具代表性,因为NaN和Inf值可能会减慢速度。现在清除示例代码中的内存。不过,这对我的执行速度没有任何影响。

7 个答案:

答案 0 :(得分:5)

我想我已经设法优化它,这是一个代码,在VC ++中创建一个新项目,添加这个代码并简单地在“Release”下编译。

#include <iostream>
#include <cstdlib>
#include <cstring>

#define _WIN32_WINNT 0x0400
#define WIN32_LEAN_AND_MEAN
#include <windows.h>

#include <conio.h>

using namespace std;

size_t d_nx = 128, d_ny = 128;
float *d_x, *d_b, *d_w, *d_e, *d_s, *d_n;

void step_original() {
    size_t ic = d_ny + 1, iw = d_ny, ie = d_ny + 2, is = 1, in = 2 * d_ny + 1;
    for (size_t y = 1; y < d_ny - 1; ++y) {
        for (size_t x = 1; x < d_nx - 1; ++x) {
            d_x[ic] = d_b[ic]
                - d_w[ic] * d_x[iw] - d_e[ic] * d_x[ie]
                - d_s[ic] * d_x[is] - d_n[ic] * d_x[in];
            ++ic; ++iw; ++ie; ++is; ++in;
        }
        ic += 2; iw += 2; ie += 2; is += 2; in += 2;
    }
}
void step_new() {
    //size_t ic = d_ny + 1, iw = d_ny, ie = d_ny + 2, is = 1, in = 2 * d_ny + 1;
    float
        *d_b_ic,
        *d_w_ic,
        *d_e_ic,
        *d_x_ic,
        *d_x_iw,
        *d_x_ie,
        *d_x_is,
        *d_x_in,
        *d_n_ic,
        *d_s_ic;

    d_b_ic = d_b;
    d_w_ic = d_w;
    d_e_ic = d_e;
    d_x_ic = d_x;
    d_x_iw = d_x;
    d_x_ie = d_x;
    d_x_is = d_x;
    d_x_in = d_x;
    d_n_ic = d_n;
    d_s_ic = d_s;

    for (size_t y = 1; y < d_ny - 1; ++y)
    {
        for (size_t x = 1; x < d_nx - 1; ++x)
        {
            /*d_x[ic] = d_b[ic]
                - d_w[ic] * d_x[iw] - d_e[ic] * d_x[ie]
                - d_s[ic] * d_x[is] - d_n[ic] * d_x[in];*/
            *d_x_ic = *d_b_ic
                - *d_w_ic * *d_x_iw - *d_e_ic * *d_x_ie
                - *d_s_ic * *d_x_is - *d_n_ic * *d_x_in;
            //++ic; ++iw; ++ie; ++is; ++in;
            d_b_ic++;
            d_w_ic++;
            d_e_ic++;
            d_x_ic++;
            d_x_iw++;
            d_x_ie++;
            d_x_is++;
            d_x_in++;
            d_n_ic++;
            d_s_ic++;
        }
        //ic += 2; iw += 2; ie += 2; is += 2; in += 2;
        d_b_ic += 2;
        d_w_ic += 2;
        d_e_ic += 2;
        d_x_ic += 2;
        d_x_iw += 2;
        d_x_ie += 2;
        d_x_is += 2;
        d_x_in += 2;
        d_n_ic += 2;
        d_s_ic += 2;
    }
}

void solve_original(size_t iters) {
    for (size_t i = 0; i < iters; ++i) {
        step_original();
    }
}
void solve_new(size_t iters) {
    for (size_t i = 0; i < iters; ++i) {
        step_new();
    }
}

void clear(float *a) {
    memset(a, 0, d_nx * d_ny * sizeof(float));
}

int main(int argc, char **argv) {
    size_t n = d_nx * d_ny;
    d_x = new float[n]; clear(d_x);
    d_b = new float[n]; clear(d_b);
    d_w = new float[n]; clear(d_w);
    d_e = new float[n]; clear(d_e);
    d_s = new float[n]; clear(d_s);
    d_n = new float[n]; clear(d_n);

    if(argc < 3)
        printf("app.exe (x)iters (o/n)algo\n");

    bool bOriginalStep = (argv[2][0] == 'o');
    size_t iters = atoi(argv[1]);

    /*printf("Press any key to start!");
    _getch();
    printf(" Running speed test..\n");*/

    __int64 freq, start, end, diff;
    if(!::QueryPerformanceFrequency((LARGE_INTEGER*)&freq))
        throw "Not supported!";
    freq /= 1000000; // microseconds!
    {
        ::QueryPerformanceCounter((LARGE_INTEGER*)&start);
        if(bOriginalStep)
            solve_original(iters);
        else
            solve_new(iters);
        ::QueryPerformanceCounter((LARGE_INTEGER*)&end);
        diff = (end - start) / freq;
    }
    printf("Speed (%s)\t\t: %u\n", (bOriginalStep ? "original" : "new"), diff);
    //_getch();


    //cout << d_x[0] << endl; // prevent the thing from being optimized away
}

像这样运行:

app.exe 10000 o

app.exe 10000 n

“o”表示旧代码,您的。

“n”是我的,新的。

我的结果: 速度(原创):

1515028

1523171

1495988

速度(新):

966012

984110

1006045

改善约30%。

背后的逻辑: 您一直在使用索引计数器来访问/操作。 我用指针。 在VC ++的调试器中的某个计算代码行上运行断点,然后按F8。你会得到反汇编窗口。 您将看到生成的操作码(汇编代码)。

无论如何,看:

int * x = ...;

x [3] = 123;

这告诉PC将指针x放在寄存器(比如说EAX)。 添加它(3 * sizeof(int))。 只有这样,将值设置为123。

你可以理解,指针方法要好得多,因为我们削减了添加过程,实际上我们自己处理它,因此能够根据需要进行优化。

我希望这会有所帮助。

stackoverflow.com员工的旁注: 伟大的网站,我希望我早就听说过它!

答案 1 :(得分:5)

一些想法:

  1. 使用SIMD。您可以从每个阵列一次加载4个浮点数到SIMD寄存器(例如Intel上的SSE,PowerPC上的VMX)。这样做的缺点是某些d_x值将“陈旧”,因此您的收敛速度将受到影响(但不会像jacobi迭代那样糟糕);很难说加速是否会抵消它。

  2. 使用SOR。它很简单,不会增加太多计算,并且可以很好地提高收敛速度,即使是相对保守的放松值(比如说1.5)。

  3. 使用共轭梯度。如果这是流体模拟的投影步骤(即强制执行非压缩性),您应该能够应用CG并获得更好的收敛速度。一个好的预处理器可以提供更多帮助。

  4. 使用专门的解算器。如果线性系统来自Poisson equation,则可以使用基于FFT的方法比共轭梯度更好。

  5. 如果您可以解释更多关于您要解决的系统的内容,我可以就#3和#4提供更多建议。

答案 2 :(得分:3)

首先,这里似乎存在一个流水线问题。循环读取刚刚写入的d_x中的值,但显然它必须等待该写入完成。只需重新排列计算的顺序,在等待时做一些有用的事情,使它几乎快两倍:

d_x[ic] = d_b[ic]
                        - d_e[ic] * d_x[ie]
    - d_s[ic] * d_x[is] - d_n[ic] * d_x[in]
    - d_w[ic] * d_x[iw] /* d_x[iw] has just been written to, process this last */;

Eamon Nerbonne是谁发现了这一点。很多人对他赞不绝口!我永远不会猜到。

答案 3 :(得分:2)

Poni的答案看起来对我来说是对的。

我只是想指出,在这类问题中,经常从内存局部性中获益。现在,b,w,e,s,n数组都位于内存中的不同位置。如果你能够解决L3缓存中的问题(主要是在L2中),那么这将是不好的,这种解决方案会有所帮助:

size_t d_nx = 128, d_ny = 128;
float *d_x;

struct D { float b,w,e,s,n; };
D *d;

void step() {
    size_t ic = d_ny + 1, iw = d_ny, ie = d_ny + 2, is = 1, in = 2 * d_ny + 1;
    for (size_t y = 1; y < d_ny - 1; ++y) {
        for (size_t x = 1; x < d_nx - 1; ++x) {
            d_x[ic] = d[ic].b
                - d[ic].w * d_x[iw] - d[ic].e * d_x[ie]
                - d[ic].s * d_x[is] - d[ic].n * d_x[in];
            ++ic; ++iw; ++ie; ++is; ++in;
        }
        ic += 2; iw += 2; ie += 2; is += 2; in += 2;
    }
}
void solve(size_t iters) { for (size_t i = 0; i < iters; ++i) step(); }
void clear(float *a) { memset(a, 0, d_nx * d_ny * sizeof(float)); }

int main(int argc, char **argv) {
    size_t n = d_nx * d_ny;
    d_x = new float[n]; clear(d_x);
    d = new D[n]; memset(d,0,n * sizeof(D));
    solve(atoi(argv[1]));
    cout << d_x[0] << endl; // prevent the thing from being optimized away
}

例如,1280x1280的这个解决方案比Poni的解决方案略低于2x (在我的测试中13s vs 23s - 你的原始实现是22s),而在128x128则是30% 较慢(7s vs. 10s - 你原来是10s)。

(对于基本案例,迭代次数扩大到80000次,对于100x大型案例1280x1280,迭代次数扩大到800次。)

答案 4 :(得分:1)

我认为你对记忆是一个瓶颈是正确的。这是一个非常简单的循环,每次迭代只需要一些简单的算术。 ic,iw,ie,is,并且在索引中似乎位于矩阵的两侧,所以我猜测那里有一堆缓存未命中。

答案 5 :(得分:1)

我不是这方面的专家,但我已经看到了there are several academic papers关于改进Gauss-Seidel方法的缓存使用情况。

另一种可能的优化是使用红黑变体,其中点以棋盘式模式在两次扫描中更新。通过这种方式,扫描中的所有更新都是独立的,可以并行化。

答案 6 :(得分:0)

我建议加入一些预取语句,并研究“面向数据的设计”:

void step_original() {
    size_t ic = d_ny + 1, iw = d_ny, ie = d_ny + 2, is = 1, in = 2 * d_ny + 1;
    float dw_ic, dx_ic, db_ic, de_ic, dn_ic, ds_ic;
    float dx_iw, dx_is, dx_ie, dx_in, de_ic, db_ic;
    for (size_t y = 1; y < d_ny - 1; ++y) {
        for (size_t x = 1; x < d_nx - 1; ++x) {
// Perform the prefetch
// Sorting these statements by array may increase speed;
//    although sorting by index name may increase speed too.
            db_ic = d_b[ic];
            dw_ic = d_w[ic];
            dx_iw = d_x[iw];
            de_ic = d_e[ic];
            dx_ie = d_x[ie];
            ds_ic = d_s[ic];
            dx_is = d_x[is];
            dn_ic = d_n[ic];
            dx_in = d_x[in];
// Calculate
            d_x[ic] = db_ic
                - dw_ic * dx_iw - de_ic * dx_ie
                - ds_ic * dx_is - dn_ic * dx_in;
            ++ic; ++iw; ++ie; ++is; ++in;
        }
        ic += 2; iw += 2; ie += 2; is += 2; in += 2;
    }
}

这与第二种方法不同,因为在执行计算之前将值复制到本地临时变量。