【问题标题】:Does the order of cases in a switch statement affect performance?switch 语句中的 case 顺序会影响性能吗?
【发布时间】:2018-04-15 20:06:26
【问题描述】:

我有一个switch 案例程序:

升序切换案例:

int main()
{
        int a, sc = 1;
        switch (sc)
        {
                case 1:
                        a = 1;
                        break;
                case 2:
                        a = 2;
                        break;
        }
}

代码汇编:

main:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 1
        mov     eax, DWORD PTR [rbp-4]
        cmp     eax, 1
        je      .L3
        cmp     eax, 2
        je      .L4
        jmp     .L2
.L3:
        mov     DWORD PTR [rbp-8], 1
        jmp     .L2
.L4:
        mov     DWORD PTR [rbp-8], 2
        nop
.L2:
        mov     eax, 0
        pop     rbp
        ret

降序切换案例:

int main()
{
        int a, sc = 1;
        switch (sc)
        {
                case 2:
                        a = 1;
                        break;
                case 1:
                        a = 2;
                        break;
        }
}

代码汇编:

main:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 1
        mov     eax, DWORD PTR [rbp-4]
        cmp     eax, 1
        je      .L3
        cmp     eax, 2
        jne     .L2
        mov     DWORD PTR [rbp-8], 1
        jmp     .L2
.L3:
        mov     DWORD PTR [rbp-8], 2
        nop
.L2:
        mov     eax, 0
        pop     rbp
        ret

这里,升序顺序比降序顺序产生更多的组装。

那么,如果我有更多的 switch case,那么 case 的顺序会影响性能吗?

【问题讨论】:

  • 讨论非优化代码生成指令的数量是完全没有意义的。请注意,这些示例甚至不等效,因为您在它们之间分配了不同的数字。还有,这跟 C99 和 C11 有什么关系?
  • 您忘记说明您的平台、编译器和优化设置,以及性能对您的重要性,以及您正在开发的代码类型和大小。如果您愿意花费 20 万欧元来获得几个百分比的性能,那么您也应该这么说。
  • 查看一个关于 if 语句排序的非常相似的问题:stackoverflow.com/questions/46833310/…
  • 要模拟一个输入不是编译时常量的真实案例,请使用int foo(int sc){ ...; return a; }。这是在带有 gcc 和 clang -O3 -mtune=intel: godbolt.org/g/anj5Tu 的 Godbolt 上。有趣的是,clang5.0 使用 cmov 链(因为我添加了一个 a=-1 默认值,而不是在 sc 不是 0 或 1 时返回未初始化的 a。当然,在实际用例中,使用 @987654332 的代码@ 将混入其中,例如 if (a == 1) 稍后可能实际上只是在 sc 上分支。
  • 虽然有点明显,但应该注意的是,通常,由于失败,case 语句的顺序会改变程序行为,除非每个case 都以@ 结尾987654337@(恰好在示例中)。

标签: c performance gcc switch-statement


【解决方案1】:

您正在查看未优化的代码,因此研究它的性能并不是很有意义。如果您查看示例的优化代码,您会发现它根本不进行比较!优化器注意到开关变量sc 始终具有值1,因此它删除了无法访问的case 2

优化器还发现变量a 在分配后没有被使用,因此它也删除了case 1 中的代码,留下main() 一个空函数。并且它删除了操作 rbp 的函数 prolog/epilog,因为该寄存器未使用。

因此,对于您的 main() 函数的任一版本,优化后的代码最终都是相同的:

main:
    xor eax, eax
    ret

简而言之,对于问题中的代码,case 语句的放置顺序无关紧要,因为根本不会生成任何代码。

case 的顺序在实际生成和使用代码的更真实的示例中是否重要?可能不是。请注意,即使在您未优化生成的代码中,两个版本都按数字顺序测试两个case 值,首先检查1,然后检查2,无论源代码中的顺序如何代码。显然,即使在未优化的代码中,编译器也会进行一些排序。

请务必注意 Glenn 和 Lundin 的 cmets:case 部分的 顺序 并不是两个示例之间的唯一变化,实际代码也不同。在其中一个中,case 值与设置为 a 的值匹配,但在另一个中不匹配。

编译器根据使用的实际值对switch/case 语句使用各种策略。他们可能会使用这些示例中的一系列比较,或者可能使用跳转表。研究生成的代码可能会很有趣,但与往常一样,如果性能很重要,请观察您的优化设置并在实际情况下测试

【讨论】:

    【解决方案2】:

    Compiler optimization of switch 语句很棘手。当然,您需要启用优化(例如,尝试使用gcc -O2 -fverbose-asm -SGCC 编译您的代码并查看生成的.s 汇编程序文件)。顺便说一句,在你的两个例子中,我在 Debian/Sid/x86-64 上的 GCC 7 给出了简单的:

            .type   main, @function
    main:
    .LFB0:
            .cfi_startproc
    # rsp.c:13: }
            xorl    %eax, %eax      #
            ret
            .cfi_endproc
    

    (因此在生成的代码中没有switch 的痕迹)

    如果您需要了解编译器如何优化switch,有一些关于该主题的论文,例如this one。

    如果我有更多数量的 switch case,那么 case 的顺序会影响性能吗?

    一般来说,如果您正在使用一些优化编译器并要求它进行优化。另见this

    如果这对您很重要(但它不应该,将微优化留给您的编译器!),您需要进行基准测试、分析甚至研究生成的汇编代码。顺便说一句,cache missesregister allocation 可能比case-s 的顺序更重要,所以我认为你根本不应该打扰。请记住最近计算机的近似 timing estimates。将cases 放在最可读 的顺序(对于下一个 开发相同源代码的开发人员)。另请阅读threaded code。如果您有客观(与性能相关的)理由重新订购case-s(这不太可能发生,并且在您的一生中最多应该发生一次),请写一些好的评论来解释这些原因。

    如果您非常关心性能,请务必使用benchmarkprofile,并选择一个好的编译器并将其与相关的优化选项一起使用。也许试验几个不同的optimization 设置(也许还有几个编译器)。您可能需要添加-march=native(除了-O2-O3)。您可以考虑使用-flto -O2 编译和链接,以启用链接时优化等。您可能还需要profile based 优化。

    顺便说一句,许多编译器都是巨大的free software 项目(尤其是GCCClang)。如果您非常关心性能,您可以修补编译器,通过添加一些额外的优化通道来扩展它(通过forking 源代码,通过添加一些plugin to GCC 或一些GCC MELT 扩展)。这需要数月或数年的工作(特别是要了解该编译器的内部表示和组织)。

    (不要忘记考虑开发成本;在大多数情况下,它们的成本要高得多)

    【讨论】:

    • 如果热代码中有复杂的 switches,我希望 Profile Guided 优化会非常有用。 IDK 如果 gcc 总是只在 asm 中按升序对案例进行排序,但如果一个比其他案例更常见,我希望它会首先检查那个案例。
    • @PeterCordes:另一方面,密集整数开关可以变成间接 jmps,甚至首先击败配置文件引导的情况。
    • @PeterCordes:如果 GCC 在通过重新排序案例本身没有看到任何优化之后,允许像 case __builtin_likely(expr, your_case_statement) 这样的东西作为进一步的提示,那将会很有趣。但是,它们不被视为常量表达式(奇怪),因此不允许它(clang seems to,但没有产生任何区别[毕竟它仍然是一个提示])。
    【解决方案3】:

    性能主要取决于给定数据集的分支未命中数,而不是案例总数。而这又在很大程度上取决于实际数据以及编译器如何选择实现切换(调度表、链式条件、条件树——不确定您是否甚至可以从 C 中控制)。

    【讨论】:

    • 尝试更改降序代码以匹配您设置a 以匹配大小写的升序代码。
    【解决方案4】:

    在大多数 case 标签是连续的情况下,编译器通常会处理 switch 语句以使用跳转表而不是比较。编译器决定使用何种形式的计算跳转(如果有的话)的确切方法将在不同的实现中有所不同。有时在 switch 语句中添加额外的 case 可以通过简化编译器生成的代码来提高性能(例如,如果代码使用 case 4-11,而 case 0-3 以默认方式处理,则在 default: 之前添加显式 case 0:; case 1:; case 2:; case 3:; 可能导致编译器将操作数与 12 进行比较,如果小于,则使用 12 项跳转表。省略这些情况可能会导致编译器在将差异与 8 进行比较之前减去 4,然后使用 8 项表。

    尝试优化 switch 语句的一个困难是编译器通常比程序员更了解在给定特定输入时不同方法的性能会如何变化,但程序员可能比编译器更了解程序将接收的输入分布情况。给定类似的东西:

    if (x==0)
      y++;
    else switch(x)
    {
      ...
    }
    

    “智能”编译器可能会认识到将代码更改为:

    switch(x)
    {
      case 0:
        y++;
        break;
      ...
    }
    

    可以在x 为非零的所有情况下消除比较,但代价是 当x 为零时计算的跳转。如果x 大部分时间不为零, 那将是一笔不错的交易。但是,如果x 在 99.9% 的情况下为零,那么 可能是一笔糟糕的交易。不同的编译器编写者在程度上有所不同 他们将尝试将前者的结构优化为后者。

    【讨论】:

      【解决方案5】:

      switch 语句通常通过jump tables 编译,而不是简单的比较。

      因此,如果您对案例陈述进行置换,性能不会下降。

      但是,有时将更多案例保持连续顺序并且不在某些条目中使用 break/return 很有用,以便执行流程转到下一个案例并避免重复代码。

      当 case number 之间的数字差异很大时,例如在 case 10:case 200000: 中,编译器肯定不会生成跳转表,因为它应该填充大约 200K 条目几乎所有指向default: case 的指针,在这种情况下它将使用比较。

      【讨论】:

      • 当案例数量很少时(如这里),编译器使用分支链。如果启用,gcc 可能会根据配置文件引导的优化对案例进行排序。另一种顺序无关紧要,但使用非跳转策略的情况:如果有很多情况都属于几个代码块,编译器可以将其转换为检查位图。甚至将位图放入带有立即数的寄存器中:stackoverflow.com/questions/97987/…
      • @PeterCordes 是的,当然,在某些情况下使用动态跳转毫无意义。我更新了答案。
      • @PeterCordes 可以生成对数的比较,例如如果 jmp 表对内存的需求太大,则在平衡的二叉搜索树中。
      • 是的,IIRC gcc 会在有很多情况且不使用表的情况下执行二进制搜索而不是线性搜索。
      【解决方案6】:

      你的问题很简单——你的代码不一样,所以它不会产生相同的程序集!优化的代码不仅仅依赖于单个语句,还依赖于它周围的一切。在这种情况下,很容易解释优化。

      在您的第一个示例中,案例 1 的结果为 a=1,案例 2 的结果为 a=2。编译器可以对此进行优化,为这两种情况设置 a=sc,这是一条语句。

      在您的第二个示例中,案例 1 导致 a=2,案例 2 导致 a=1。编译器不能再采用这种捷径,因此它必须为这两种情况显式设置 a=1 或 a=2。当然这需要更多的代码。

      如果您只是简单地拿第一个示例并交换案例的顺序和条件代码,那么您应该得到相同的汇编程序。

      您可以使用代码测试此优化

      int main()
      {
          int a, sc = 1;
      
          switch (sc)
          {
              case 1:
              case 2:
                  a = sc;
                  break;
          }
      }
      

      这也应该给出完全相同的汇编程序。

      顺便说一下,您的测试代码假定 sc 实际已被读取。大多数现代优化编译器能够发现 sc 在赋值和 switch 语句之间没有变化,并将读取 sc 替换为常量值 1。进一步的优化将删除 switch 语句的冗余分支,然后甚至分配可以被优化掉,因为 a 实际上并没有改变。而且从变量 a 的角度来看,编译器也可能会发现 a 没有在其他地方读取,因此从代码中完全删除该变量。

      如果您真的希望读取 sc 并设置 a,则需要将它们都声明为 volatile。幸运的是,编译器似乎已经按照您预期的方式实现了它 - 但是当您打开优化时,您绝对不能期待它。

      【讨论】:

      • 你不需要volatile,你可以将sc设为函数参数,将a设为返回值。 (最好是没有调用main的函数)。
      • @PeterCordes 即使这样也可能失败,因为一个可用的优化步骤是内联函数(编译速度超过大小)。我个人不知道编译器是否足够聪明,可以在完成内联后优化常量参数和常量返回值,但原则上它应该是可能的 - 这开辟了一个不受欢迎的代码前景,当你改变其运行时行为时升级你的编译器。
      • 当翻译单元中唯一的功能是您想查看其代码的功能时,它不会在 Godbolt 上失败!这个想法是模拟当输入值不是编译时常量时在实际代码中可能得到的结果。 (内联后的持续传播是一种有价值的优化。这是内联的主要好处之一!当你的编译器的新版本在优化方面做得更好时,你为什么会不高兴?除非你做了“脆弱”的优化,碰巧得到我猜是特定编译器版本的好汇编。)
      • @PeterCordes 这就是我的观点。 OP 正在对他的编译代码的预期行为做出假设,而一个好的优化器可能会使他的期望有点混乱!在他的情况下,这并不是特别“好的 asm”——只是“按照他的预期做任何事情的 asm”并不能保证。
      • OP 的反汇编样本是未优化的代码,不是优化的代码。任何名副其实的优化器都会将整个函数体作为死代码消除。
      【解决方案7】:

      您可能应该在比较汇编代码之前为您的编译器启用优化,但问题是您的变量在编译时是已知的,因此编译器可以从您的函数中删除所有内容,因为它没有任何副作用。

      This example 表明,即使您在示例中的 switch 语句中更改了 case 的顺序,如果启用了优化,GCC 和大多数其他编译器也会重新排序它们。 我使用了 extern 函数来确保这些值仅在运行时才知道,但我也可以使用 rand 例如。

      此外,当您添加更多案例时,编译器可能会用包含函数地址的表替换条件跳转,并且仍会被 GCC 重新排序,如 here 所示。

      【讨论】:

      猜你喜欢
      • 2011-02-18
      • 2016-01-25
      • 1970-01-01
      • 1970-01-01
      • 2015-04-17
      • 1970-01-01
      • 1970-01-01
      • 2011-05-01
      相关资源
      最近更新 更多