我的循环可以再次优化吗?

时间:2010-04-27 22:41:21

标签: c++ math optimization loops physics

下面是我最里面的循环,运行数千次,输入大小为20 - 1000或更多。这段代码占用了99-99.5%的执行时间。我能做些什么来帮助挤出更多的表现吗?

我不打算将此代码移动到使用树形码(Barnes-Hut)之类的东西,而是用于优化内部发生的实际计算,因为在Barnes-Hut算法中进行相同的计算。

感谢任何帮助!

编辑:我在Core 2 Duo T5850(2.16 GHz)上使用Visual Studio 2008版本在Windows 7 64位上运行

typedef double real;

struct Particle
{
    Vector pos, vel, acc, jerk;
    Vector oldPos, oldVel, oldAcc, oldJerk;
    real mass;
};

class Vector
{
private:
    real vec[3];

public:
    // Operators defined here
};

real Gravity::interact(Particle *p, size_t numParticles)
{
    PROFILE_FUNC();
    real tau_q = 1e300;

    for (size_t i = 0; i < numParticles; i++)
    {
        p[i].jerk = 0;
        p[i].acc = 0;
    }

    for (size_t i = 0; i < numParticles; i++)
    {
        for (size_t j = i+1; j < numParticles; j++)
        {
            Vector r = p[j].pos - p[i].pos;
            Vector v = p[j].vel - p[i].vel;
            real r2 = lengthsq(r);
            real v2 = lengthsq(v);

            // Calculate inverse of |r|^3
            real r3i = Constants::G * pow(r2, -1.5);

            // da = r / |r|^3
            // dj = (v / |r|^3 - 3 * (r . v) * r / |r|^5
            Vector da = r * r3i;
            Vector dj = (v - r * (3 * dot(r, v) / r2)) * r3i;

            // Calculate new acceleration and jerk
            p[i].acc += da * p[j].mass;
            p[i].jerk += dj * p[j].mass;
            p[j].acc -= da * p[i].mass;
            p[j].jerk -= dj * p[i].mass;

            // Collision estimation
            // Metric 1) tau = |r|^2 / |a(j) - a(i)|
            // Metric 2) tau = |r|^4 / |v|^4
            real mij = p[i].mass + p[j].mass;
            real tau_est_q1 = r2 / (lengthsq(da) * mij * mij);
            real tau_est_q2 = (r2*r2) / (v2*v2);

            if (tau_est_q1 < tau_q)
                tau_q = tau_est_q1;
            if (tau_est_q2 < tau_q)
                tau_q = tau_est_q2;
        }
    }

    return sqrt(sqrt(tau_q));
}

14 个答案:

答案 0 :(得分:22)

  1. 内联对lengthsq()的调用。

  2. 将pow(r2,-1.5)更改为1 /(r2 * sqrt(r2))以降低计算成本r ^ 1.5

  3. 在最常用的循环中使用标量(p_i_acc等)而不是p [i] .acc来收集结果。编译器可能不知道p [i]没有p [j]的别名,这可能会在每次循环迭代中不必要地强制寻址p [i]。

  4. 4A。尝试用

    替换if(...)tau_q =
        tau_q=minimum(...,...)
    

    许多编译器认为mininum函数是他们可以用谓词操作而不是真正的分支来完成的,避免了管道刷新。

    4b中。 [编辑分开4a和4b]您可以考虑将tau _ .. q2存储为tau_q,并与r2 / v2而不是r2 * r2 / v2 * v2进行比较。然后你避免在内循环中为每次迭代做两次乘法,换一个平方运算来计算最后的tau..q2。为此,分别收集tau_q1和tau_q2(非平方)的最小值,并在完成循环时在单个标量运算中取最小值]

    1. [编辑:我建议如下,但实际上它对OP的代码无效,因为他在循环中更新的方式。]将两个循环折叠在一起。使用两个循环和足够大的粒子集,您可以颠倒缓存并强制从第二个循环中的那些初始值的非缓存中重新获取。折叠是微不足道的。
    2. 除此之外你需要考虑a)循环展开,b)矢量化(使用SIMD指令;手动编码汇编程序或使用英特尔编译器,这应该是相当不错的[但我没有经验] ,c)进行多核(使用OpenMP)。

答案 1 :(得分:7)

这一行real r3i = Constants::G * pow(r2, -1.5);会受到伤害。使用平方根的任何类型的sqrt查找或平台特定帮助都会有所帮助。

如果你有simd能力,分解你的矢量减去并平方到它自己的循环并立即计算它们将有所帮助。您的质量/混蛋计算也是如此。

想到的是 - 你是否保持足够的精确度?把东西带到第4个电源和第4根电源会通过欠/溢出混合器真正地破坏你的可用位。我完全相信你的答案确实是你的答案。

除此之外,它是一个需要一些CPU时间的数学重函数。汇编程序对此的优化不会比编译器已经为你做的那么多。

另一种想法。由于这似乎与引力有关,有没有办法根据距离检查来剔除你繁重的数学?基本上,半径/半径平方检查以对抗循环的O(n ^ 2)行为。如果你消除了1/2的粒子,那么它的运行速度会快4倍。

最后一件事。您可以将内部循环线程连接到多个处理器。您必须为每个线程创建一个单独的内部版本,以防止数据争用和锁定开销,但是一旦每个线程完成,您可以计算每个结构的质量/加加速度值。我没有看到任何会阻止这种情况的依赖,但到目前为止我还不是这方面的专家:)

答案 2 :(得分:3)

  • 首先,您需要分析代码。此方法取决于您运行的CPU和操作系统。

  • 您可以考虑是否可以使用浮动而不是双打。

  • 如果您使用的是gcc,请确保您使用的是-O2-O3

  • 您可能还想尝试一个好的编译器,比如英特尔的ICC(假设它在x86上运行?)。

  • 再次假设这是(英特尔)x86,如果你有一个64位CPU然后构建一个64位可执行文件,如果你还没有 - 额外的寄存器可以产生明显的差异(约30%)

答案 3 :(得分:3)

如果这是针对视觉效果,并且您的粒子位置/速度只需要是近似值,那么您可以尝试将sqrt替换为其各自Taylor系列的前几个术语。下一个未使用的术语的大小表示近似值的误差范围。

答案 4 :(得分:3)

简单的事情:将所有“旧”变量移动到不同的数组。您永远不会在主循环中访问它们,因此您触摸的内存是实际需要的两倍(因此获得两倍的缓存未命中)。这是最近关于这个主题的博客文章:http://msinilo.pl/blog/?p=614。当然,你可以提前prefetch一些粒子,例如p [j + k],其中k是一些常数,需要进行一些实验。


如果你也将质量移出,你可以存储这样的东西:

struct ParticleData
{
    Vector pos, vel, acc, jerk;
};

ParticleData* currentParticles = ...
ParticleData* oldParticles = ...
real* masses = ...

然后从新数据更新旧粒子数据成为从当前粒子到旧粒子的单个大记忆。


如果您愿意使代码变得更加丑陋,您可以通过以“转置”格式存储内容来获得更好的SIMD优化,例如

struct ParticleData
{
    // data_x[0] == pos.x, data_x[1] = vel.x, data_x[2] = acc.x, data_x[3] = jerk.x
    Vector4 data_x;

    // data_y[0] == pos.y, data_y[1] = vel.y, etc.
    Vector4 data_y;

    // data_z[0] == pos.z, data_y[1] = vel.z, etc.
    Vector4 data_z;
};

其中Vector4是一个单精度或两个双精度SIMD向量。这种格式在光线跟踪中很常见,可以同时测试多条光线;它可以让你更有效地进行点积等操作(不需要随机播放),这也意味着你的内存加载可以是16字节对齐的。绝对需要花几分钟时间来解决问题:)

希望有所帮助,如果您需要使用转置表示的参考,请告诉我(虽然我不确定它实际上会有多少帮助)。

答案 5 :(得分:2)

我的第一个建议是看分子动力学文献,这个领域的人们已经考虑了粒子系统领域的许多优化。例如,请查看GROMACS

对于许多粒子来说,杀死你的当然是双for循环。我不知道你需要多准确地计算粒子系统的时间演变,但是如果你不需要非常准确的计算,你可以简单地忽略相距太远的粒子之间的相互作用(你必须设置一个切口) - 距离)。一种非常有效的方法是使用带有缓冲区的neighbour lists来仅在需要时更新这些列表。

答案 6 :(得分:2)

以上所有好东西。我一直在和二阶(Leapfrog)集成商做类似的事情。在考虑了上面提到的许多改进之后我接下来做的两件事就是开始使用SSE内在函数来利用向量化并使用一种新算法并行化代码,该算法避免竞争条件并利用缓存局部性。

SSE示例:

http://bitbucket.org/ademiller/nbody/src/tip/NBody.DomainModel.Native/LeapfrogNativeIntegratorImpl.cpp

新的缓存算法,解释和示例代码:

http://software.intel.com/en-us/articles/a-cute-technique-for-avoiding-certain-race-conditions/

http://bitbucket.org/ademiller/nbody/src/tip/NBody.DomainModel.Native.Ppl/LeapfrogNativeParallelRecursiveIntegratorImpl.cpp

您可能还会在西雅图代码营发现以下套牌:

http://www.ademiller.com/blogs/tech/2010/04/seattle-code-camp/

你的第四顺序集成器更复杂,并且在两核系统上更难以并行获得有限的增益,但我肯定建议检查SSE,我在这里得到了一些合理的性能改进。

答案 7 :(得分:1)

除了简单的加/减/除/乘之外,pow()是我在循环体中看到的唯一重量级函数。这可能很慢。你能预先计算它还是去除它,或用更简单的东西替换它?

什么是real?可以是漂浮物吗?

除此之外,您还必须转向MMX / SSE /程序集优化。

答案 8 :(得分:1)

您是否会受益于着名的“fast inverse square root" algorithm

float InvSqrt(float x)
{
    union {
        float f;
        int i;
    } tmp;
    tmp.f = x;
    tmp.i = 0x5f3759df - (tmp.i >> 1);
    float y = tmp.f;
    return y * (1.5f - 0.5f * x * y * y);
}

它返回1 / r ** 2的合理精确表示(牛顿方法的第一次迭代,具有聪明的初始猜测)。它广泛用于计算机图形和游戏开发。

答案 9 :(得分:1)

考虑将Constants :: G的乘法拉出循环。如果你可以改变存储的向量的语义含义,以便它们有效地存储实际值/ G,你可以根据需要进行引力常数多平面化。

您可以采取的任何修剪粒子结构大小的方法也可以帮助您改善缓存局部性。你似乎没有在这里使用旧的*成员。如果它们可以被删除,那么可能会产生重大影响。

考虑将我们的粒子结构分成一对结构。如果你这样做的话,你第一次循环数据来重置所有的acc和jerk值可能是一个有效的memset。那么你将基本上有两个数组(或向量),其中部分粒子'n'存储在每个数组的索引'n'。

答案 10 :(得分:0)

是。尝试查看装配输出。它可能会产生关于编译器在哪里做错的线索。

现在,始终始终首先应用算法优化,并且只有当没有更快的算法可用时,才能通过汇编进行零散优化。然后,先做内循环。

您可能想要分析一下,看看这是否真的是瓶颈。

答案 11 :(得分:0)

我寻找的是分支,他们往往是性能杀手。

您可以使用循环展开。

另外,请记住多个问题的较小部分: -

  for (size_t i = 0; i < numParticles; i++)
    {
        for (size_t j = i+1; j < numParticles; j++)
        {

与一个循环执行所有操作大致相同,并且您可以通过循环展开和更好地命中缓存来获得加速

您可以对此进行处理以更好地利用多个核心

你有一些昂贵的计算可能会减少,特别是如果计算结果计算相同的东西,可以使用缓存等....

但真的需要知道它最耗费你的地方

答案 12 :(得分:0)

您应该重复使用您经常使用的实数和向量。构建Vector或Real的成本可能微不足道..但是如果numParticles非常大,尤其是你的看似O((n ^ 2)/ 2)循环的话。

Vector r;
Vector v;
real r2;
real v2;
Vector da;
Vector dj;
real r3i;
real mij;
real tau_est_q1;
real tau_est_q2;
for (size_t i = 0; i < numParticles; i++)
    {
        for (size_t j = i+1; j < numParticles; j++)
        {
            r = p[j].pos - p[i].pos;
            v = p[j].vel - p[i].vel;
            r2 = lengthsq(r);
            v2 = lengthsq(v);

            // Calculate inverse of |r|^3
            r3i = Constants::G * pow(r2, -1.5);

            // da = r / |r|^3
            // dj = (v / |r|^3 - 3 * (r . v) * r / |r|^5
            da = r * r3i;
            dj = (v - r * (3 * dot(r, v) / r2)) * r3i;

            // Calculate new acceleration and jerk
            p[i].acc += da * p[j].mass;
            p[i].jerk += dj * p[j].mass;
            p[j].acc -= da * p[i].mass;
            p[j].jerk -= dj * p[i].mass;

            // Collision estimation
            // Metric 1) tau = |r|^2 / |a(j) - a(i)|
            // Metric 2) tau = |r|^4 / |v|^4
            mij = p[i].mass + p[j].mass;
            tau_est_q1 = r2 / (lengthsq(da) * mij * mij);
            tau_est_q2 = (r2*r2) / (v2*v2);

            if (tau_est_q1 < tau_q)
                tau_q = tau_est_q1;
            if (tau_est_q2 < tau_q)
                tau_q = tau_est_q2;
        }
    }

答案 13 :(得分:0)

您可以替换任何出现的:

a = b/c
d = e/f

icf = 1/(c*f)
a = bf*icf
d = ec*icf

如果您知道icf不会导致任何超出范围的事情,并且您的硬件可以比分区更快地执行3次乘法。除非你真的有一个非常慢的分区的旧硬件,否则可能不值得将更多的部门合并在一起。

如果您使用其他集成方案(例如Runge-Kutta),您将获得更少的时间步骤,但我怀疑您已经知道了。