17

我正在使用 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 * 128float阵列占用 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 上运行它吗(我知道该怎么做,但这太麻烦了)?还有什么好主意吗?

(注意,我确实接受“不”作为答案,例如:“它不能更快​​地完成,因为......”)

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

#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 值可能会减慢速度。现在清除示例代码中的内存。不过,执行速度对我来说并没有什么不同。

4

7 回答 7

5

几个想法:

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

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

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

  4. 使用专门的求解器。如果线性系统来自泊松方程,则使用基于 FFT 的方法可以做得比共轭梯度更好。

如果你能解释更多关于你试图解决的系统是什么样子的,我可能会在#3 和#4 上提供更多建议。

于 2010-03-06T00:46:34.957 回答
5

我想我已经设法对其进行了优化,这是一个代码,在 VC++ 中创建一个新项目,添加此代码并在“发布”下简单地编译。

#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
}

像这样运行它:

应用程序.exe 10000 o

应用程序.exe 10000 n

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

“n”是我的,新的。

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

1515028

1523171

1495988

速度(新):

966012

984110

1006045

改善约30%。

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

无论如何,看:

诠释 *x = ...;

x[3] = 123;

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

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

我希望这有帮助。

stackoverflow.com 员工的旁注:很棒的网站,我希望我很久以前就听说过它!

于 2010-03-05T17:58:22.397 回答
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发现了这一点。很多人给他点赞!我永远不会猜到。

于 2010-03-05T20:24:43.643 回答
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 的解决方案快不到 2 倍(在我的测试中是 13 秒对 23 秒——你的原始实现是 22 秒),而在 128x128 时它了 30% (7 秒对 10 秒——你原来的是 10 秒)。

(对于基本情况,迭代被放大到 80000,对于 1280x1280 的 100 倍大的情况,迭代被放大到 800。)

于 2010-03-05T20:41:09.580 回答
1

我认为您对内存成为瓶颈的看法是正确的。这是一个非常简单的循环,每次迭代只需一些简单的算术。ic, iw, ie, is, and in indices 似乎在矩阵的两侧,所以我猜那里有一堆缓存未命中。

于 2010-03-05T16:59:58.047 回答
1

我不是这方面的专家,但我看到有几篇关于提高 Gauss-Seidel 方法的缓存使用率的学术论文。

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

于 2010-03-05T20:50:04.673 回答
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;
    }
}

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

于 2010-03-05T22:54:06.800 回答