【问题标题】:Optimize this function (in C++)优化此功能(在 C++ 中)
【发布时间】:2011-11-18 19:17:39
【问题描述】:

我有一个消耗 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) 可以评估为真:考虑实际最大值为负时的情况
  • 代码在 32 位电脑(开发)和 64 位(生产)上运行
  • N 在编译时未知
  • 我尝试了一些 SIMD,但未能提高性能...(将变量移动到 _m128i,执行和存储回的开销高于 SSE 速度增益。但我不是 SSE 专家,所以也许我的代码很糟糕)

结果:

我添加了一些循环展开,来自 Alex 的帖子的一个不错的 hack。下面我贴一些结果:

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

stage,即 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;

}

【问题讨论】:

  • 这是您的实际代码吗? itsMaximums 是一个全局变量吗? in数组中的数字是正数还是负数?
  • 它的最大值在哪里定义?
  • 与性能无关,但为什么 N 是 size_t 而 i 是 uint32_t?
  • @JakubM.:获得一台 64 位机器进行测试。如果您在完全不同的指令集上进行测试,而不是在生产中使用,那么执行此类低级优化是没有意义的。当针对 64 位机器时,生成的代码会完全不同,这就是您应该尝试优化的地方
  • @JakubM。 6502 刚刚在聊天中提到,使用 CUDA 有一种模式(称为 scan)可以有效地执行类似于您的操作。请参阅developer.nvidia.com/cuda-cc-sdk-code-samples#scan - 如果您仍有进一步优化的目标,我认为您可能会感兴趣 (more background)

标签: c++ performance optimization g++


【解决方案1】:

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

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

这个循环似乎很适合使用 SIMD 指令。 SSE2 支持许多用于整数运算的 SIMD 指令,包括一些适用于两个 64 位值的指令。

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

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

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

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

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

编辑

您的编辑:

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

让我印象深刻的是你添加了一个 巨大的 依赖链。 在计算结果之前,max 必须取反,结果必须移位,that 的结果必须与它的原始值一起and'ed,并且结果那个必须添加到另一个变量中。

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

【讨论】:

  • +1 用于研究生成的代码。在不知道编译器在背后进行哪些优化的情况下进行如此小规模的优化,就像盲目驾驶一样。
  • 展开有一些改进,从 2.0 秒到 1.8 秒(展开 10 次迭代)
  • IME,展开只是第一步。展开代码后,将为您提供更多空间来重新排序代码。所以玩一玩,看看你能把它推多远
  • @jalf:展开后,我这里有很多“机动领域”吗?我能做的就是一开始就加载它,但是我不能对从max += v开始的那三行重新排序太多,因为这里的每次迭代都依赖于前一次
  • 不,你是对的,这限制了你的选择。但即便如此,玩弄,实验。有时,修改代码会产生非常令人惊讶的结果。看起来很浪费的东西(例如,多次重新计算表达式,或者根据估计的数据进行多次计算,然后最后选择正确的)实际上可能会更快,如果它打破依赖链并给 CPU 更多的喘息空间
【解决方案2】:
> #**公告**见[聊天](https://chat.stackoverflow.com/rooms/5056/discussion-between-sehe-and-jakub-m) > > _Hi Jakub,如果我找到一个使用启发式优化的版本,对于均匀分布的随机数据将导致 `int64_t` 的速度提高约 3.2 倍(使用 `float`s 的效率提高 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

它将(在此处查看output fragment):

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

结果

有许多(令人惊讶或不足为奇的)结果:

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

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

    #define LIMITED_RANGE 1
    

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

    return max<0 ? v :  max + v; 
    

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

  3. 令人惊讶一次计算 max 算法的两个定义并不昂贵。你可以看到这是在partial_sum_correct 内部完成的:它在同一个循环中计算 max 的两个“公式”;这实际上只不过是琐事,因为这两种方法都没有明显更快......

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

    #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 的循环展开版本中存在一个偏离 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;
        });

【讨论】:

  • 该算法计算总和和最大值(总和)。条件if (v &gt; max) max = v 很重要,它是更大算法的一部分。
  • 很好的答案!我注意到使用函数 (2) 的单行形式是不正确的。虽然,我注意到使用它提高了约 40% 的性能(我仍然得到了正确的结果)
  • 哇,恭喜你加倍努力并实际实施!关于int64_t vs float,可能是缓存效应:float 仅 32 位宽(double 为 64 位宽)。
  • @MatthieuM.:是的,这就是我的怀疑。我可能只会在今晚更新我的帖子(等一下,让我坚持下去,直到出现奖金 - 眨眼眨眼:))。实际上,我很想先获得一些真实的测试数据。启发式当然可以改进,这只是我的第一次尝试。
  • 由于有符号数字的溢出会导致未定义的行为,因此您的第二点只是间接的。一旦结果溢出,所有赌注都将关闭。
【解决方案3】:

有时,您需要退后一步,重新审视它。第一个问题显然是,你需要这个吗?是否有替代算法会表现更好?

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

免责声明:我所描述的方法受到 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;
}

你注意到小费点了吗?行为会根据 ill-named(*) max 是正数还是负数而改变。

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

  • max &lt; 0in[i] &lt; 0 然后 out[i] = in[i] &lt; 0
  • max &lt; 0in[i] &gt; 0 然后 out[i] = in[i] &gt; 0
  • max &gt; 0in[i] &lt; 0 然后 out[i] = (max + in[i]) ?? 0
  • max &gt; 0in[i] &gt; 0 然后 out[i] = (max + in[i]) &gt; 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);
  }
}

现在,除非 introsort 循环中的工作很少,否则计算特征的成本可能太高...但是它很好地导致了并行化

3.简单的并行化

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

  • Slice Producer:一个特征线程,计算Slice::Type
  • Slice Consumer:工作线程,实际执行代码

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

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

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

记住这句无辜的话:in[i] 是肯定的,max 就会得到肯定

假设我们可以(可靠地)猜测Slice[j-1] 将产生一个负数的max 值,那么Slice[j] 上的计算与之前的计算无关,我们现在就可以开始工作了!

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

推测性地计算 Slice 的启发式方法应该很简单,并且必须进行调整。它也可能具有适应性……但这可能更困难!

结论

分析您的数据集并尝试找出是否可以打破依赖关系。如果是,您可能可以利用它,即使不使用多线程。

【讨论】:

  • 很好的答案。不幸的是,切片/源配置文件有点太多了。不过我真的很喜欢你表达我一直在思考的东西的方式。我不确定我能不能做得这么好——显然不是在同一时间跨度内。
  • @Matthieu:我喜欢这个分析!宝贵的提示,即使是进一步的算法
  • 仅使用calc 这个简短的单行形式,执行时间减少了约40%
  • @JakubM.:出于好奇,您使用的是什么编译器和选项?我的基准比较了这些选项,它们在性能上完全等同(参见基准)。 IMO 使用 calc 应该不会对优化编译器产生任何影响(它确实澄清了代码)
  • @JakubM.:我很惊讶它确实产生了影响!我只是为了清楚起见而重写了它。我会认为无分支版本更快......但是很难预测优化器会做什么:)
【解决方案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;
}

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

【讨论】:

  • 有趣!它似乎适用于经过测试的(简单)案例。限制实际上不是 +- 2^60 ,而不是 61?它提供了与展开循环相当的提升。某人 -1 应得
  • 为什么是 60?那是最小/最大的 3 个订单,即 63。当数字达到最小/最大的一半时,可能会出现溢出,因此是 62。然后为了安全起见,我再取消一个订单,所以 61。如果你有两个怎么办,展开和这个?
  • 我对那个 63 的错误 :) 我试了几次,奇怪的是你的 hack 和循环展开它们单独工作得很好,并且立即使用它们不会积累收益。我在主题中添加了一些结果
  • Alex,你能解释一下这与条件分支有什么相同的效果吗?因为我不明白:)
  • @VilleKrumlinde:原来的循环相当于if (max&lt;0) max=0; max+=in[i]; out[i]=max;。如果有符号整数小于 0,则将其右移宽度为 1 的位置的结果将(在大多数平台上)将所有位设置为 1。否则它将所有位设置为 0。这给了我们@ 987654325@ 之后我们可以执行max &amp;= ~mask; 来达到与if (max&lt;0) max = 0 相同的结果。我通过反转条件来补偿~,在if 中的max 之前插入-
【解决方案5】:

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

【讨论】:

  • 快是一个非常相对的概念。您可能的意思是,它无法改善很多。我已经发布了一些基准,看到算法需要大约 5000 毫秒才能完成,而使用 boost::hash_range 来创建校验和在相同数据上大约需要 3 毫秒,这令人非常沮丧:)跨度>
  • 你说得对,没有资格的快速不是很有意义。我的意思是“代码是正确的并且没有表现出明显的低效率”。各种好的答案(显然包括你的)表明,改进它可能需要或多或少地进行彻底的重写,利用复杂的分析并且可能变得不那么便携。它也可能变得难以理解。我的建议的唯一价值是非常简单,但显然不够好,如果 OP 真的需要优化,他将不得不全力以赴。
【解决方案6】:

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

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

【讨论】:

    【解决方案7】:

    唯一想到可能有一点帮助的是在循环中使用指针而不是数组索引,比如

    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,因此查看编译器输出的注释。 IME,期望优化器会为您做某事,但按照这种期望行事通常不会产生预期的结果。
    • Neil Butterworth 最近写了一篇关于基准测试的blog,仅以优化为例。
    • 感谢比约恩,有趣的文章
    【解决方案8】:
    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 来说可能是不可接受的 :)
    • 在这个逻辑中没有改变结果
    • 所以现在,-1: out of 100 randomized tests 在包含 [-3,+3] 值的长度为 8 的数组上,只有 3 个出来意外正确
    • 性能:它使用 1.5% 更多时间 来产生严重的错误输出。 (全面披露:codedata
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2017-02-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-03-04
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多