【问题标题】:Fastest way to find out minimum of 3 numbers?找出最少3个数字的最快方法?
【发布时间】:2011-01-03 15:03:51
【问题描述】:

在我编写的一个程序中,20% 的时间都花在了在内循环中找出 3 个数字中的最小值,在这个例程中:

static inline unsigned int
min(unsigned int a, unsigned int b, unsigned int c)
{
    unsigned int m = a;
    if (m > b) m = b;
    if (m > c) m = c;
    return m;
}

有什么方法可以加快速度吗?我也可以使用 x86/x86_64 的汇编代码。

编辑:回复一些 cmets:
* 使用的编译器是 gcc 4.3.3
* 就组装而言,我只是那里的初学者。我在这里要求组装,以学习如何做到这一点。 :)
* 我运行的是四核 Intel 64,因此支持 MMX/SSE 等。
* 在这里发布循环很困难,但我可以告诉你这是对 levenshtein 算法的高度优化的实现。

这是编译器为 min 的非内联版本提供的内容:

.globl min
    .type   min, @function
min:
    pushl   %ebp
    movl    %esp, %ebp
    movl    8(%ebp), %edx
    movl    12(%ebp), %eax
    movl    16(%ebp), %ecx
    cmpl    %edx, %eax
    jbe .L2
    movl    %edx, %eax
.L2:
    cmpl    %ecx, %eax
    jbe .L3
    movl    %ecx, %eax
.L3:
    popl    %ebp
    ret
    .size   min, .-min
    .ident  "GCC: (Ubuntu 4.3.3-5ubuntu4) 4.3.3"
    .section    .note.GNU-stack,"",@progbits

内联版本在 -O2 优化代码中(甚至我的标记 mrk = 0xfefefefe,在调用 min() 之前和之后)都被 gcc 优化掉了,所以我无法掌握它。

更新:我测试了 Nils 建议的更改,ehemient,但是使用 min() 的汇编版本没有明显的性能提升。但是,通过使用 -march=i686 编译程序,我得到了 12.5% 的提升,我猜这是因为整个程序正在受益于 gcc 使用此选项生成的新的更快指令。谢谢你们的帮助。

附: - 我使用 ruby​​ 分析器来测量性能(我的 C 程序是一个由 ruby​​ 程序加载的共享库),所以我只能花时间在 ruby​​ 程序调用的顶级 C 函数上,最终调用 min( ) 在堆栈中。请看这个question

【问题讨论】:

  • 查看为该例程生成的程序集,看看是否能找到优化它的方法。
  • 你能发布你的编译器生成的程序集吗?没有看到这一点,很难知道是否有可能走得更快。
  • 另外,这是如何使用的?一些优化,例如向量运算,只能在某些情况下应用。我们可以期待什么级别的 CPU 支持? (SSE3?4.1?)
  • 你能发布发生这种情况的循环吗?可能可以在循环的上下文中进行优化。
  • 如果这是程序的 20%,程序有多么微不足道?对我来说听起来像是家庭作业问题。

标签: c performance assembly x86


【解决方案1】:

首先确保您使用的是适当的-march 设置。 GCC 默认不使用原始 i386 不支持的任何指令 - 允许它使用更新的指令集有时会产生很大的不同!在-march=core2 -O2 我得到:

min:
    pushl   %ebp
    movl    %esp, %ebp
    movl    8(%ebp), %edx
    movl    12(%ebp), %ecx
    movl    16(%ebp), %eax
    cmpl    %edx, %ecx
    leave
    cmovbe  %ecx, %edx
    cmpl    %eax, %edx
    cmovbe  %edx, %eax
    ret

在这里使用 cmov 可以帮助您避免分支延迟 - 您只需传入 -march 即可在没有任何内联 asm 的情况下获得它。当内联到更大的函数中时,这可能会更加高效,可能只有四个组装操作。如果您需要比这更快的东西,请查看是否可以让 SSE 向量运算在您的整体算法的上下文中工作。

【讨论】:

  • +1 表示 -march 建议。通过使用它,我得到了 12.5% 的提升。 :)
  • 显然您希望它在现实生活中内联,而不是将堆栈上的参数传递给独立函数。但如果没有,你会想使用-fomit-frame-pointer。 (在最近的 GCC 版本中,即使是 32 位代码也是默认开启的。)
  • 在 Skylake 上,注意 cmovbe 不幸的是仍然是 2 微秒,因为它需要 ZF 和 CF。仅读取 CF 或仅读取 SPAZO 组的标志的 CMOVcc 只是一个 uop,因此 cmovb 会更好。 (无论你是否平等移动都没有关系)。见this Q&A
【解决方案2】:

假设你的编译器没有出去吃午饭,这应该编译成两个比较和两个条件移动。没有比这更好的了。

如果您发布编译器实际生成的程序集,我们可以查看是否有任何不必要的东西减慢了它的速度。

要检查的第一件事是例程实际上是内联的。编译器没有义务这样做,如果它正在生成一个函数调用,那么对于这样一个简单的操作来说,这将是非常昂贵的。

如果调用确实是内联的,那么循环展开可能是有益的,正如 DigitalRoss 所说,或者向量化可能是可能的。

编辑:如果您想对代码进行矢量化,并且使用最新的 x86 处理器,您将需要使用 SSE4.1 pminud 指令(内在:_mm_min_epu32),它接受两个向量,每个向量包含四个无符号整数,并生成一个包含四个无符号整数的向量。结果的每个元素都是两个输入中对应元素的最小值。

我还注意到您的编译器使用分支而不是条件移动;您可能应该先尝试一个使用条件移动的版本,看看这是否能让您在开始矢量实现的竞赛之前获得任何加速。

【讨论】:

  • +1 我的猜测是任何收益都来自外部环境,而不是这个函数。
  • 外部上下文进行了大量优化。它对包含 288 万个字符串的数据库进行计算。在优化之前,它曾经在 4 秒内给出结果。经过一周的大量优化后,这个时间缩短到了 150 毫秒。最新的配置文件运行显示“分钟”在顶部,其中花费了 20% 的时间。
  • 我唯一的评论是查看一直调用 min 的内容,看看是否可以将调用保存到 min 本身。
  • 循环展开是已经存在的优化之一,还有其他几个。该例程正在内联,我在反汇编代码中找不到“min”符号。我对矢量化位很感兴趣——也许应该去读一读。谢谢。
【解决方案3】:

这款嵌入式替换时钟在我的 AMD Phenom 上的速度提高了约 1.5%:

static inline unsigned int
min(unsigned int a, unsigned int b, unsigned int c)
{
    asm("cmp   %1,%0\n"
        "cmova %1,%0\n"
        "cmp   %2,%0\n"
        "cmova %2,%0\n"
        : "+r" (a) : "r" (b), "r" (c));
    return a;
}

结果可能会有所不同;一些 x86 处理器不能很好地处理 CMOV。

【讨论】:

  • 不错..比我的例子好。您可以为 b 添加一个 % 修饰符,以便在寄存器分配中获得额外的灵活性。
  • GCC 将使用适当的 -march 设置自动执行此操作,这也将有助于代码的其他部分。
  • 技术上"+r" 应该是"+&r",因为它是在读取所有纯输入之前写入的。 GCC 目前可能会选择不让 ab 共享相同的 reg,即使它知道它们是相同的。另外,在后来的 Intel CPU 上,cmovae 效率更高(只读取 CF,不读取 CF 和 ZF,所以它是 only 1 uop on Skylake / uops.info.
【解决方案4】:

我对 x86 汇编器实现的看法,GCC 语法。翻译成另一种内联汇编语法应该很简单:

int inline least (int a, int b, int c)
{
  int result;
  __asm__ ("mov     %1, %0\n\t"
           "cmp     %0, %2\n\t" 
           "cmovle  %2, %0\n\t"
           "cmp     %0, %3\n\t"
           "cmovle  %3, %0\n\t" 
          : "=r"(result) : 
            "r"(a), "r"(b), "r"(c)
          );
  return result;
}

新的和改进的版本:

int inline least (int a, int b, int c)
{
  __asm__ (
           "cmp     %0, %1\n\t" 
           "cmovle  %1, %0\n\t"
           "cmp     %0, %2\n\t"
           "cmovle  %2, %0\n\t" 
          : "+r"(a) : 
            "%r"(b), "r"(c)
          );
  return a;
}

注意:它可能会也可能不会比 C 代码快。

这取决于很多因素。如果分支不可预测(在某些 x86 架构上),通常 cmov 会获胜

顺便说一句,Sudhanshu,听听这段代码如何处理您的测试数据会很有趣。

【讨论】:

  • 这也适用于无符号整数比较吗?抱歉,如果这听起来很幼稚。
  • 哎呀,在写我自己的之前我没有看到这个。是的,您可以在未签名的情况下执行此操作;只需将cmovle 更改为cmovbe
  • 正如我在下面的回复中提到的,一旦你传入一个适当的-march 标志,GCC 就会自动执行此优化 - 只是它不在原始 80386 的指令集中,而 GCC 在(极端)谨慎的一面:)
  • Nils, ehemient, bdonlan - 所有这些建议看起来都不错。让我在明天之前回复你结果。感谢您的帮助。
  • GCC 不再进行此优化。优化仍在 GCC 中,但已禁用。而是使用分支版本。原因:编译器很难猜测一个分支是否可预测,并且为了确保分支预测被使用它不使用 cmovcc。
【解决方案5】:

SSE2 指令扩展包含一个整数 min 指令,一次可以选择 8 个最小值。见_mm_mulhi_epu16http://www.intel.com/software/products/compilers/clin/docs/ug_cpp/comm1046.htm

【讨论】:

  • _mm_mulhi_epu16 是向量 16 位乘高指令的内在函数——对于计算至少 32 位整数没有用处。你真正想要的内在是_mm_min_epu32
  • @StephenCanon 这不是真的,因为_mm_min_epu32 比较两个压缩的__m128i 值。 OP 需要的是水平最小值,这在 SSE 中不存在。
  • @JakubArnold:您需要两次_mm_min_epu32,每个输入都位于单独向量的低元素中。如果您使用上部元素,这可以并行执行 4 个单独的 3-way mins,但如果您需要整数 regs 中的结果,则可能不值得 movd 到/从 XMM regs 将其用于标量。否则值得考虑; movd 加载/存储很好。
  • 或者您需要 SSE4.1 _mm_minpos_epu16 对一个向量进行水平无符号最小值,但这是针对 16 位元素的。不过,_mm_mulhi_epu16 似乎一点用都没有;这是一个高半 16 位乘法。 (pmulhuw)
【解决方案6】:

首先,看反汇编。这会告诉你很多。例如,如所写,有 2 个 if 语句(这意味着有 2 个可能的分支错误预测),但我的猜测是,一个体面的现代 C 编译器将有一些巧妙的优化,可以在没有分支的情况下做到这一点。我很想知道。

其次,如果您的 libc 具有特殊的内置最小/最大功能,请使用它们。例如,GNU libc 具有用于浮点的 fmin/fmax,他们声称“在某些处理器上,这些函数可以使用特殊的机器指令来比等效的 C 代码更快地执行这些操作”。也许 uint 也有类似的东西。

最后,如果您要对一堆数字并行执行此操作,则可能有向量指令可以执行此操作,这可以显着加快速度。但是我什至看到非向量代码在使用向量单位时会更快。诸如“将一个 uint 加载到向量寄存器中,调用向量 min 函数,得到结果”之类的东西看起来很愚蠢,但实际上可能更快。

【讨论】:

  • 感谢您的指点 Ken - 我一定会查看矢量说明,我认为 Mark 和 Stephen 也指的是。
【解决方案7】:

如果您只进行一次比较,您可能需要手动展开循环。

首先,看看你是否可以让编译器为你展开循环,如果你不能,你自己做。这至少会减少循环控制开销...

【讨论】:

    【解决方案8】:

    您可以尝试这样的方法来节省声明和不必要的比较:

    static inline unsigned int
    min(unsigned int a, unsigned int b, unsigned int c)
    { 
        if (a < b)
        {
            if (a < c) 
                 return a; 
            else 
                 return c;
        }
    
        if (b < c)
            return b;
        else return c;
    }
    

    【讨论】:

    • 我怀疑这会好得多 - 初始分配无论如何都会在编译器中变成重命名,现在分支预测器中有三个分支占用空间,而不是两个。
    • 这是两种比较。现在的不同之处在于您正在分支而不是使用条件移动 - 我猜这可能会更慢。甚至忽略您正在为管道铺设管道。
    • 我认为这会计算 3 个输入的最大值,而不是最小值。至少对于 a = 5, b = 2, c = 3
    • 这里要小心。现在有额外的分支并且生成的代码更大,两者都有自己的缺点。 (另外,这是最大值,但很清楚你的意思。)
    • 任务很便宜。严重地。除非你必须记住,否则它们比错过的分支便宜得多。
    【解决方案9】:

    这些都是很好的答案。冒着被指责不回答问题的风险,我也会在其他 80% 的时间里看看。 Stackshots 是我最喜欢的查找值得优化的代码的方法,尤其是当您发现自己并不绝对需要的函数调用时。

    【讨论】:

      【解决方案10】:

      是的,后组装,但我天真的优化是:

      static inline unsigned int
      min(unsigned int a, unsigned int b, unsigned int c)
      {
          unsigned int m = a;
          if (m > b) m = b;
          if (m > c) return c;
          return m;
      }
      

      【讨论】:

      • 这种性质的转换几乎可以由任何编译器完成(并且说哪种形式会更有效并非易事!)
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多