优化此功能(C++)

Optimize this function (in C++)

本文关键字:C++ 功能 优化      更新时间:2023-10-16

我有一个消耗CPU的代码,其中某些带有循环的函数被执行了很多次。此循环中的每个优化都会带来明显的性能提升。问题:您将如何优化此循环(尽管没有更多要优化的内容...(?

void theloop(int64_t in[], int64_t out[], size_t N)
{
    for(uint32_t i = 0; i < N; i++) {
        int64_t v = in[i];
        max += v;
        if (v > max) max = v;
        out[i] = max;
    }
}

尝试了一些方法,例如,我用每个循环中递增的指针替换了数组,但(令人惊讶的是(我失去了一些性能而不是获得......

编辑:

  • 更改了一个变量的名称(itsMaximums,错误(
  • 该函数是一个类的方法
  • in和put是int64_t,所以是负和正
  • '(v> max( 的计算结果为 true:考虑实际最大值负的情况
  • 代码在 32 位 PC(开发(和 64 位(生产(上运行
  • N在编译时未知
  • 我尝试了一些 SIMD,但未能提高性能...(将变量移动到_m128i、执行和存储回来的开销高于SSE速度增益。然而我不是SSE专家,所以也许我的代码很差(

结果:

我添加了一些循环展开,以及 Alex 帖子中的一个不错的黑客。 下面我粘贴一些结果:

  1. 原价: 14.0s
  2. 展开循环(4 次迭代(:10.44 秒
  3. 亚历克斯的技巧:10.89秒
  4. 2( 和 3( 一次:11.71s

strage,即 4( 不比 3( 和 4( 快(。下面的代码为 4(:

for(size_t i = 1; i < N; i+=CHUNK) {
    int64_t t_in0 = in[i+0];
    int64_t t_in1 = in[i+1];
    int64_t t_in2 = in[i+2];
    int64_t t_in3 = in[i+3];

    max &= -max >> 63;
    max += t_in0;
    out[i+0] = max;
    max &= -max >> 63;
    max += t_in1;
    out[i+1] = max;
    max &= -max >> 63;
    max += t_in2;
    out[i+2] = max;
    max &= -max >> 63;
    max += t_in3;
    out[i+3] = max;
}

首先,您需要查看生成的程序集。否则,您无法知道执行此循环时实际发生的情况。

现在:此代码是否在 64 位计算机上运行?如果没有,这些 64 位添加可能会有点伤害。

此循环似乎是使用 SIMD 指令的明显候选者。SSE2 支持许多用于整数运算的 SIMD 指令,包括一些处理两个 64 位值的指令。

除此之外,查看编译器是否正确展开循环,如果没有,请自己执行。展开循环的几次迭代,然后重新排序。将所有内存负载放在循环的顶部,以便可以尽早启动它们。

对于if行,请检查编译器是否正在生成条件移动,而不是分支。

最后,查看您的编译器是否支持 restrict/__restrict 关键字之类的内容。它在C++中不是标准的,但它对于向编译器指示inout不指向相同的地址非常有用。

编译时是否知道大小(N(?如果是这样,请使其成为模板参数(然后尝试将inout作为对适当大小的数组的引用传递,因为这也可能帮助编译器进行别名分析(

只是我脑海中的一些想法。但同样,研究拆卸。你需要知道编译器为你做了什么,尤其是它对你没有做什么。

编辑

通过您的编辑:

max &= -max >> 63;
max += t_in0;
out[i+0] = max;

让我印象深刻的是,你添加了一个巨大的依赖链。在计算结果之前,必须否定max,必须移动结果,其结果必须与其原始值一起和,并且必须将其结果添加到另一个变量中。

换句话说,所有这些操作都必须序列化。在前一个完成之前,您无法启动其中一个。这不一定是加速。现代流水线无序 CPU 喜欢并行执行很多事情。用一长串依赖指令将其捆绑在一起是你能做的最严重的事情之一。 (当然,如果它可以与其他迭代交错,它可能会更好。但我的直觉是一个简单的条件移动指令会更好(

> #**公告** 见[聊天](https://chat.stackoverflow.com/rooms/5056/discussion-between-sehe-and-jakub-m(>> _Hi Jakub,如果我找到一个使用启发式优化的版本,对于均匀分布的随机数据,将导致"int64_t"的速度提高 ~3.2 倍(使用"float"有效 10.56 倍(?_>我还没有找到时间更新帖子,但可以通过聊天找到解释和代码。
> 我使用相同的测试平台代码(如下(来验证结果是否正确并且与您的 OP 的原始实现完全匹配
   **编辑**:具有讽刺意味的是...该测试平台有一个致命的缺陷,导致结果无效:启发式版本实际上跳过了部分输入,但由于现有输出未被清除,它似乎具有正确的输出......(仍在编辑...<小时 />

好的,我已经根据您的代码版本发布了一个基准测试,以及我建议的partial_sum使用。

在此处查找所有代码 https://gist.github.com/1368992#file_test.cpp

特征

对于默认配置

#define MAGNITUDE     20
#define ITERATIONS    1024
#define VERIFICATION  1
#define VERBOSE       0
#define LIMITED_RANGE 0    // hide difference in output due to absense of overflows
#define USE_FLOATS    0

它将(请参阅此处的输出片段(:

  • 运行 100 x 1024 次迭代(即 100 个不同的随机种子(
  • 对于数据长度 1048576 (2^20(。
  • 随机输入数据均匀分布在元素数据类型(int64_t(的整个范围内
  • 通过生成输出数组的哈希摘要并将其与 OP 中的参考实现进行比较来验证输出。

结果

有许多(令人惊讶或不令人惊讶的(结果:

  1. 任何算法之间(对于整数数据(之间都没有显著的性能差异,前提是您在启用优化的情况下进行编译。(参见 Makefile;我的架构是 64 位,英特尔酷睿 Q9550 和 gcc-4.6.1(

  2. 算法不是等价的(你会看到哈希和不同(:值得注意的是,Alex提出的位小提琴并没有以完全相同的方式处理整数溢出(这可以隐藏定义

    #define LIMITED_RANGE 1
    

    这会限制输入数据,以免发生溢出;请注意,partial_sum_incorrect 版本显示等效C++非按位_arithmetic运算,这些运算产生相同的不同结果:

    return max<0 ? v :  max + v; 
    

    也许,这对你的目的没问题?

  3. 令人惊讶的是,同时计算最大算法的两个定义并不昂贵。你可以看到这是在partial_sum_correct中完成的:它在同一循环中计算max的两个"公式";这真的不过是一个triva,因为这两种方法都没有明显更快......

  4. 更令人惊讶的是,当您能够使用float而不是int64_t时,可以大大提高性能。一个快速而肮脏的黑客可以应用于基准测试

    #define USE_FLOATS    0
    

    表明基于STL的算法(partial_sum_incorrect(在使用float而不是int64_t(!!(时运行速度快2.5倍
    注意:

    • partial_sum_incorrect的命名只与整数溢出有关,这不适用于浮点数;这可以从哈希匹配的事实中看出,所以实际上它是partial_sum_float_correct:)
    • partial_sum_correct 的当前实现正在执行双重工作,导致它在浮点模式下表现不佳。请参阅项目符号 3。
  5. (在我之前提到的 OP 的循环展开版本中有一个 off-by-1 错误(

部分和

为了您的兴趣,部分和应用程序在 C++11 中如下所示:

std::partial_sum(data.begin(), data.end(), output.begin(), 
        [](int64_t max, int64_t v) -> int64_t
        { 
            max += v;
            if (v > max) max = v;
            return max;
        });

有时,您需要退后一步再看一遍。第一个问题显然是,你需要这个吗?有没有一种替代算法可以表现得更好?

话虽如此,假设为了这个问题,你已经确定了这个算法,我们可以尝试推理我们实际拥有的东西。

免责声明:我所描述的方法受到Tim Peters用来改进传统introsort实现的成功方法的启发,从而导致了TimSort。所以请耐心等待我;)

1. 提取属性

我可以看到的主要问题是迭代之间的依赖关系,这将阻止许多可能的优化并阻止许多并行化的尝试。

int64_t v = in[i];
max += v;
if (v > max) max = v;
out[i] = max;

让我们以函数式方式重新编写此代码:

max = calc(in[i], max);
out[i] = max;

哪里:

int64_t calc(int64_t const in, int64_t const max) {
  int64_t const bumped = max + in;
  return in > bumped ? in : bumped;
}

或者更确切地说,一个简化版本(由于未定义而溢出(:

int64_t calc(int64_t const in, int64_t const max) {
  return 0 > max ? in : max + in;
}

你注意到临界点了吗?行为会根据名称错误的 (*( max是正数还是负数而变化。

这个临界点使得更仔细地观察in中的值变得有趣,特别是根据它们可能对max产生的影响:

  • max < 0in[i] < 0然后out[i] = in[i] < 0
  • max < 0in[i] > 0然后out[i] = in[i] > 0
  • max > 0in[i] < 0然后out[i] = (max + in[i]) ?? 0
  • max > 0in[i] > 0然后out[i] = (max + in[i]) > 0

(*( 名称不当,因为它也是一个累加器,名称隐藏了它。不过我没有更好的建议。

2. 优化运营

这导致我们发现有趣的案例:

  • 如果我们有一个只包含负值的数组切片[i, j)(我们称之为负切片(,那么我们可以做一个std::copy(in + i, in + j, out + i)max = out[j-1]
  • 如果我们有一个只包含正值的数组切片[i, j),那么它是一个纯粹的累积代码(可以很容易地展开(
  • 一旦in[i]呈阳性,max就会变得积极
因此,在

实际使用它之前建立输入的配置文件可能会很有趣(但也许不是,我不作任何承诺(。请注意,对于大型输入,可以逐块创建配置文件,例如根据缓存行大小调整块大小。

作为参考,3 个例程:

void copy(int64_t const in[], int64_t out[],
          size_t const begin, size_t const end)
{
  std::copy(in + begin, in + end, out + begin);
} // copy
void accumulate(int64_t const in[], int64_t out[],
                size_t const begin, size_t const end)
{
  assert(begin != 0);
  int64_t max = out[begin-1];
  for (size_t i = begin; i != end; ++i) {
    max += in[i];
    out[i] = max;
  }
} // accumulate
void regular(int64_t const in[], int64_t out[],
             size_t const begin, size_t const end)
{
  assert(begin != 0);
  int64_t max = out[begin - 1];
  for (size_t i = begin; i != end; ++i)
  {
    max = 0 > max ? in[i] : max + in[i];
    out[i] = max;
  }
}

现在,假设我们可以以某种方式使用一个简单的结构来表征输入:

struct Slice {
  enum class Type { Negative, Neutral, Positive };
  Type type;
  size_t begin;
  size_t end;
};
typedef void (*Func)(int64_t const[], int64_t[], size_t, size_t);
Func select(Type t) {
  switch(t) {
  case Type::Negative: return &copy;
  case Type::Neutral: return &regular;
  case Type::Positive: return &accumulate;
  }
}
void theLoop(std::vector<Slice> const& slices, int64_t const in[], int64_t out[]) {
  for (Slice const& slice: slices) {
    Func const f = select(slice.type);
    (*f)(in, out, slice.begin, slice.end);
  }
}

现在,除非引入循环中的工作是最少的,所以计算特征可能太昂贵了......但是,它很好地导致并行化

3. 简单并行化

请注意,表征是输入的纯函数。因此,假设您以每块一个块的方式工作,则可以并行具有:

  • 切片生产者:一个表征器线程,用于计算Slice::Type
  • Slice Consumer:一个工作线程,实际执行代码

即使输入基本上是随机的,只要块足够小(例如,CPU L1 缓存行(,也可能有一些块可以工作。两个线程之间的同步可以通过简单的线程安全Slice队列(生产者/消费者(和添加 bool last 属性来停止消费来完成,或者通过在具有 Unknown 类型的向量中创建Slice,并让使用者块直到已知为止(使用原子学(。

注意:因为表征是纯粹的,所以它令人尴尬地平行。

4. 更多并行化:推测性工作

记住这句无辜的话: 只要in[i]是积极的,max就会变得积极

假设我们可以(可靠地(猜测Slice[j-1]将产生负值的max值,那么对Slice[j]的计算与它们之前的内容无关,我们可以立即开始工作!

当然,这是一个猜测,所以我们可能是错的...但是一旦我们完全表征了所有切片,我们就有了空闲的核心,所以我们不妨将它们用于推测工作!如果我们错了?好吧,消费者线程将简单地轻轻地擦除我们的错误并将其替换为正确的值。

推测

性计算Slice的启发式方法应该很简单,并且必须进行调整。它也可能是适应性的...但这可能更难!

结论

分析数据集并尝试查找是否可以中断依赖项。如果是这样,即使没有多线程,您也可以利用它。

如果 maxin[] 的值远离 64 位最小值/最大值(例如,它们始终介于 -261 和 +261 之间(,您可以尝试没有条件分支的循环,这可能会导致一些性能下降:

for(uint32_t i = 1; i < N; i++) {
    max &= -max >> 63; // assuming >> would do arithmetic shift with sign extension
    max += in[i];
    out[i] = max;
}

从理论上讲,编译器也可以做类似的技巧,但是如果没有看到反汇编,很难判断它是否这样做了。

代码看起来已经很快了。根据 in 数组的性质,您可以尝试特殊的大小写,例如,如果您碰巧知道在特定调用中所有输入数字都是正数,则 out[i] 将等于累积总和,不需要 if 分支。

确保该方法不是虚拟的,内联的,_attribute_((always_inline((-funroll-loops似乎是探索的好选择。

只有通过您对它们进行基准测试,我们才能确定它们是否值得在您的更大计划中进行优化。

唯一想到的可能会有所帮助的事情是在循环中使用指针而不是数组索引,例如

void theloop(int64_t in[], int64_t out[], size_t N)
{
    int64_t max = in[0];
    out[0] = max;
    int64_t *ip = in + 1,*op = out+1;
    for(uint32_t i = 1; i < N; i++) {
        int64_t v = *ip; 
        ip++;
        max += v;
        if (v > max) max = v;
        *op = max;
        op++
    }
}

这里的想法是,数组中的索引可以编译为获取数组的基址,将元素的大小乘以索引,然后添加结果以获得元素地址。 保持运行指针可以避免这种情况。 我猜一个好的优化编译器已经可以做到这一点了,所以你需要研究当前的汇编器输出。

int64_t max = 0, i;
for(i=N-1; i > 0; --i) /* Comparing with 0 is faster */  
{  
    max = in[i] > 0 ? max+in[i] : in[i];
    out[i] = max;
    --i;  /* Will reduce checking of i>=0 by N/2 times */  
    max = in[i] > 0 ? max+in[i] : in[i]; /* Reduce operations v=in[i], max+=v by N times */  
    out[i] = max;         
}
if(0 == i) /* When N is odd */
{ 
    max = in[i] > 0 ? max+in[i] : in[i]; 
    out[i] = max;
}