优化这个 C++ 函数

7

我有一段计算密集型的代码,其中一个带有循环的函数被执行了许多次。在这个循环中的每一个优化都会带来明显的性能提升。问题:你会如何优化这个循环(即使不太可能再进行更多的优化呢)?

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) 可以`计算为真
  • 代码在32位PC(开发)和64位(生产)上运行
  • N在编译时未知
  • 我尝试过一些SIMD,但无法提高性能...(将变量移到_m128i,执行和存储回来的开销比SSE速度提高更高。然而,我对SSE不是专家,所以也许我的代码很差)

结果:

我添加了一些循环展开,并使用Alex文章中的巧妙技巧。以下是一些结果:

  1. 原始:14.0秒
  2. 展开的循环(4次迭代):10.44秒
  3. Alex的技巧:10.89秒
  4. 2)和3)同时:11.71秒

奇怪的是,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;

}

2
这是你实际的代码吗?itsMaximums是全局变量吗?in数组中的数字既有正数也有负数吗? - Retired Ninja
它的最大值定义在哪里? - Geoffroy
1
不涉及性能问题,但为什么N是size_t类型而i是uint32_t类型? - Francesco
3
@JakubM.:购买一台64位机器进行测试。如果您在完全不同的指令集上进行测试,那么执行这些低级别优化是没有意义的,因为当针对64位机器进行优化时生成的代码会非常不同。这就是您应该尝试优化的内容。 - jalf
1
@JakubM。6502刚在聊天中提到,使用CUDA有一种称为_scan_的模式可以有效地执行类似于您的操作。请参见http://developer.nvidia.com/cuda-cc-sdk-code-samples#scan - 如果您仍然希望进一步优化此操作,则可能会对您感兴趣 (更多背景 - sehe
显示剩余24条评论
8个回答

15

首先,您需要查看生成的汇编代码。否则,您将无法知道执行此循环时实际发生了什么。

现在问题来了:这段代码是在64位机器上运行吗?如果不是,那些64位的加法可能会有点慢。

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

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

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

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

大小(N)是否在编译时已知?如果是,将其作为模板参数(然后尝试将inout作为引用传递给适当大小的数组,因为这也可能有助于编译器进行别名分析)

以上只是我想到的一些想法。但再次强调,要研究汇编代码。您需要知道编译器为您执行了什么操作,尤其是它没有为您执行的操作。

编辑

带有您的编辑:

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

我注意到你添加了一个巨大的依赖链。在结果可以计算之前,必须对max取反,将结果移位,那个的结果必须与其原始值进行and操作,并将那个的结果加到另一个变量中。

换句话说,所有这些操作必须被串行化。在前一个操作完成之前,您不能开始执行下一个操作。这并不一定是加速的。现代流水线乱序CPU喜欢并行执行许多任务。将其绑定在单个长依赖指令链上是最具破坏性的事情之一。(当然,如果可以与其他迭代交错使用,则可能效果更好。但我的直觉是简单的条件移动指令更可取)


4
支持研究生成的代码。在不知道编译器在背后进行了哪些优化的情况下,在这个小规模上进行优化就像是盲目驾驶一样。 - R. Martinho Fernandes
展开循环优化了程序性能,从2.0秒降至1.8秒(展开10次迭代)。 - Jakub M.
IME,展开代码只是第一步。一旦你展开了代码,就有更多的空间来重新排列代码。所以尝试一下,看看你能推进多远。 - jalf
@jalf:展开后,我在这里有多少“操作空间”?我能做的就是在开始时加载它,但我不能太多地重新排序从max += v开始的这三行代码,因为每次迭代都依赖于前一次。 - Jakub M.
不,你说得对,这确实会限制你的选择。但即便如此,也要尝试玩耍、实验。有时候,摆弄代码可以带来非常惊人的结果。看起来浪费的东西(比如多次重新计算表达式,或者基于估计数据进行多次计算,然后在最后选择正确的那个)可能实际上更快,如果它打破了依赖链并给CPU更多的空间。 - jalf

7
> #**公告** 参见[聊天记录](https://chat.stackoverflow.com/rooms/5056/discussion-between-sehe-and-jakub-m) > >_嗨,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. there is no significant performance difference between any of the algorithms whatsoever (for integer data), provided you are compiling with optimizations enabled. (See Makefile; my arch is 64bit, Intel Core Q9550 with gcc-4.6.1)

  2. The algorithms are not equivalent (you'll see hash sums differ): notably the bit fiddle proposed by Alex doesn't handle integer overflow in quite the same way (this can be hidden defining

    #define LIMITED_RANGE 1
    

    which limits the input data so overflows won't occur; Note that the partial_sum_incorrect version shows equivalent C++ non-bitwise _arithmetic operations that yield the same different results:

    return max<0 ? v :  max + v; 
    

    Perhaps, it is ok for your purpose?)

  3. Surprisingly It is not more expensive to calculate both definitions of the max algorithm at once. You can see this being done inside partial_sum_correct: it calculates both 'formulations' of max in the same loop; This is really not more than a triva here, because none of the two methods is significantly faster...

  4. Even more surprisingly a big performance boost can be had when you are able to use float instead of int64_t. A quick and dirty hack can be applied to the benchmark

    #define USE_FLOATS    0
    

    showing that the STL based algorithm (partial_sum_incorrect) runs aproximately 2.5x faster when using float instead of int64_t (!!!).
    Note:

    • that the naming of partial_sum_incorrect only relates to integer overflow, which doesn't apply to floats; this can be seen from the fact that the hashes match up, so in fact it is partial_sum_float_correct :)
    • that the current implementation of partial_sum_correct is doing double work that causes it to perform badly in floating point mode. See bullet 3.
  5. (And there was that off-by-1 bug in the loop-unrolled version from the OP I mentioned before)

部分和

对于您的兴趣,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;
        });

算法不计算总和和最大值。条件 if (v > max) max = v 是重要的,它是一个更大算法的一部分。 - Jakub M.
很棒的答案!我注意到使用函数的单行形式(2)并不是真正正确的方式。虽然我注意到使用它可以提高约40%的性能(而且我仍然得到了正确的结果)。 - Jakub M.
由于有符号数的溢出会导致未定义的行为,因此你的第二点只是一种情况。一旦结果溢出,所有的赌注都是无效的。 - Voo
@MatthieuM。我认为“float”提升也可能是由于SSE优化引起的。 - Jakub M.
@JakubM.:我不太了解SSE,但我不确定你的用例是否适合SSE,因为计算方式非常不寻常。 - Matthieu M.
显示剩余3条评论

5
有时候,你需要向后退一步并重新审视它。首先要问的问题显然是,你需要这个吗?是否有另一种算法可以更好地执行?假设出于这个问题的考虑你已经选择了这个算法,我们可以试着推理一下我们实际拥有的东西。
免责声明:我描述的方法受到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),那么它就是一个纯累加代码(可以很容易地展开)
  • maxin [i]为正时变得积极

因此,在实际处理输入之前建立输入文件的配置文件可能是有趣的(但也许不是必要的)。请注意,对于大型输入,可以逐块制作配置文件,例如根据缓存行大小调整块大小。

参考资料,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);
  }
}

现在,除非introsort中的工作循环是最小的,因此计算特征可能会太昂贵...但是它非常适合于并行化
请注意,特征是输入的纯函数。因此,假设您按块每个块的方式工作,可以并行执行以下操作:
* 切片生产者: 一个characterizer线程,计算Slice::Type值 * 切片消费者: 一个工作线程,实际执行代码
即使输入基本上是随机的,只要切块足够小(例如,CPU L1高速缓存行),就可能有为其工作的块。两个线程之间的同步可以通过具有Slice的简单线程安全队列来完成(生产者/消费者),并添加bool last属性以停止消耗或通过使用原子,创建具有Unknown类型的向量中的Slice,并使消费者阻塞直到已知为止。
注意:由于特征是纯的,因此它可以进行令人尴尬的并行处理。
更多并行化:推测性工作。
记住这句无辜的话:max一旦in[i]为正,就会变成正数
假设我们可以可靠地猜测Slice[j-1]将产生一个负的max值,那么对Slice[j]的计算与之前的内容无关,我们可以立即开始工作!
当然,这只是一个猜测,所以我们可能会错...但是一旦我们完全描述了所有的Slices,我们就有了空闲的核心,因此我们可以利用它们进行投机性工作!如果我们错了?嗯,消费者线程将简单地轻松擦除我们的错误,并用正确的值替换它。
推测计算Slice的启发式方法应该很简单,并且需要进行调整。它也可能是适应性的...但这可能更困难!
结论
分析您的数据集并尝试找出是否有可能打破依赖关系。即使不使用多线程,您也可以利用它。

很棒的回答。不幸的是,太多的依赖于切片/源配置文件。尽管如此,我真的很喜欢你措辞的方式,这正是我一直在思考的。我不确定我能做得那么好 - 显然没有在同样的时间范围内。 - sehe
@Matthieu:我喜欢这个分析!宝贵的提示,即使是对于进一步的算法也很有帮助。 - Jakub M.
仅使用calc在这个短小的单行形式中,将执行时间缩短了约40%。 - Jakub M.
@JakubM.:出于好奇,您使用的编译器和选项是什么?我的基准测试比较了这些选项,并且它们在性能上完全相等(请参见基准测试)。在优化编译器上使用 calc 不应该有任何区别(尽管它确实可以澄清代码)。 - sehe
1
@JakubM.:我很惊讶它确实有影响!我只是为了清晰度而重写了它。我本以为无分支版本会更快...但是很难预测优化器会做什么 :) - Matthieu M.
@MatthieuM.:我已经实现了一个我自己“思考已久”的启发式算法。它看起来很有前途,尽管作为一种启发式算法,它确实取决于实际输入数据。到目前为止,我在均匀输入下看到了3.2倍(int64_t)到10.5倍(float)的加速。**请访问主问题处的聊天链接以获取更多数据**。清理和编写解释需要花费一些时间(我正在照顾孩子)。 - sehe

4
如果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;
}

理论上编译器也可以做类似的技巧,但没有看到反汇编,很难确定它是否这样做。

有趣!它似乎适用于测试(简单)情况。限制实际上不是61,而是+-2^60吗?它提供了与展开循环相当的性能提升。某人的-1是不应该的。 - Jakub M.
2
为什么是60?这是3个最小/最大值的数量,即63。当数字达到一半的最小/最大值时,溢出变得可能,因此是62。然后我再减去一个来保险起见,所以是61。如果你同时使用展开和这个方法呢? - Alexey Frunze
我在那个63上犯了一个错误:)我尝试过乘法,奇怪的是,您的hack和循环展开它们分别工作得很好,但同时使用它们不会累积增益。我将一些结果添加到主题中。 - Jakub M.
Alex,请问你能解释一下这个与条件分支具有相同效果的原因吗?因为我不太明白 :) - Ville Krumlinde
1
@VilleKrumlinde:原始循环等同于 if (max<0) max=0; max+=in[i]; out[i]=max;。如果有符号整数小于 0,则将其右移宽度-1个位置的结果(在大多数平台上)将具有所有位设置为 1。否则,它将具有所有位设置为 0。这给了我们 if (max<0) mask=0xFF...FF; else mask=0;,在此之后,我们可以执行 max &= ~mask; 以实现与 if (max<0) max = 0 相同的结果。我通过反转条件、在 if 中插入 - 来补偿 ~ - Alexey Frunze
@Alex:谢谢你的解释,我现在明白它是如何工作的了。对原始代码进行了非常巧妙的改写。你绝对不应该被投票踩低。 - Ville Krumlinde

1

这段代码似乎已经相当快了。根据输入数组的性质,您可以尝试特殊处理,例如如果您恰好知道在特定的调用中所有输入数字都是正数,则out[i]将等于累积和,而不需要if分支。


1
快速是一个非常相对的概念。你可能意味着它不能太多改进。我已经发布了一些基准测试,看到算法需要大约5000毫秒才能完成,而使用boost::hash_range来创建同样数据的校验和只需要大约3毫秒,这真的很令人沮丧 :) - sehe
看来你是正确的,没有限定条件的“快”并不是非常有意义。我的意思是,“代码是正确的,并且没有明显的低效之处”。各种好的答案(包括你的答案)表明,要想改进它可能需要进行一次或多次彻底的重写,利用复杂的分析方法,也可能会变得不太可移植。它也很可能变得不太容易理解。我的建议只有一个优点,就是非常简单,但显然不够好,如果OP确实需要优化,他将不得不走完全的路线。 - Francesco

1

确保该方法不是虚拟的内联_属性_((always_inline))-funroll-loops似乎是值得探索的好选择。

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


-1
唯一想到的可能会有点帮助的事情是,在循环中使用指针而不是数组索引,例如:
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++
    }
}

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

@Autopopulated,因此请查看编译器输出的注释。在我看来,期望优化器为您做某些事情并根据这种期望行事通常不会产生预期的结果。 - SmacL
3
Neil Butterworth最近写了一篇关于基准测试的博客,以此作为优化的例子。 - Björn Pollex
谢谢Bjorn,有趣的文章。 - SmacL

-3
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;
}

更改行为/结果可能不被OP接受 :) - sehe
这个逻辑中的结果没有改变。 - paper.plane
现在,针对长度为8且包含来自[-3,+3]值的数组,在100个随机测试中的-1情况下(http://pastebin.com/WnQnFwpm),仅有3个是偶然正确的。 - sehe
性能:它使用了1.5%的“更多时间”来生成深度错误的输出。(完整披露:代码数据 - sehe

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接