避免托管语言中的分支

本文关键字:分支 语言 | 更新日期: 2023-09-27 18:14:01

C中,当编译到x86机器时,我通常会用逻辑表达式代替分支,当速度是最重要的方面时,即使条件很复杂,例如,代替

char isSomething() {
    if (complexExpression01) {
        if (complexExpression02) {
            if(!complexExpression03) {
                return 1;
            }
        }
    }
    return 0;
}

我会写:

char isSomething() {
    return complexExpression01 &&
           complexExpression02 &&
           !complexExpression03 ;
}

现在很明显,这可能是一个更难维护,可读性较差的代码,但它实际上可能更快。

在使用托管代码(如c#)时,是否有任何理由采取相同的方式?托管代码中的"跳转"是否与非托管代码中的"跳转"一样昂贵(至少在x86上)?

避免托管语言中的分支

总则

在常规编译器中,生成的代码通常是相同的,至少在假设您使用常规

时是如此。
csc.exe /optimize+
cl.exe /O2
g++ -O2

和相关的默认优化模式。

一般的咒语是:概要文件,概要文件,概要文件(并且不要进行微优化,直到您的概要文件告诉您这样做)。您可以随时查看生成的代码2,看看是否有改进的空间。

可以这样想,例如c#代码:

c#/。净

每个complexExpressions都是一个事实上的函数调用调用(call, calli, callvirt opcode3),需要将它的参数压入堆栈。返回值将被左压入堆栈,而不是退出时的参数。

现在,CLR是一个基于堆栈的虚拟机(即无寄存器),这相当于堆栈上的匿名临时变量。唯一的区别是代码中使用的标识符的数量。

现在JIT引擎如何处理它是另一回事:JIT引擎必须将这些调用转换为本机汇编,并可能通过调整寄存器分配,指令排序,分支预测等进行优化1

<子>1(尽管在实践中,对于这个示例,不允许进行更有趣的优化,因为complex function calls可能有副作用,并且c#规范非常清楚求值顺序和所谓的序列)点。然而,注意, JIT引擎允许内联函数调用,以减少调用开销。

不仅当它们是非虚拟的,而且(IIRC)当运行时类型可以在编译时静态地知道某些。net框架内部。我必须为此查找参考资料,但事实上,我认为在。net框架4.0中引入了一些属性来显式地防止框架函数的内联;这样微软就可以在服务包/更新中修补库代码,即使用户程序集已经提前编译(ngen.exe)为本机映像。

C/c++

在C/c++中,内存模型要宽松得多(至少在c++ 11之前),代码通常在编译时直接编译为本机指令。另外,C/c++编译器通常会进行积极的内联,即使在这样的编译器中,代码也通常是相同的,除非您在编译时没有启用优化

<子>2我使用

  • ildasmmonodis查看生成的IL代码
  • mono -aot=full,staticmkbundle生成本机对象模块,objdump -CdS查看注释的本机汇编指令。

注意这纯粹是出于好奇,因为我很少以这种方式发现有趣的瓶颈。但是,请参阅Jon Skeet关于性能优化Noda.NET的博客文章,以获得可能潜伏在泛型类生成的IL代码中的惊喜的好例子。

3Edit对于编译器内部的操作符不准确,尽管它们会将结果留在堆栈上。

这取决于托管语言的CLR和编译器的实现。在c#中,下面的测试用例证明嵌套if语句和组合if语句的指令没有区别:

            // case 1
            if (value1 < value2)
00000089  mov         eax,dword ptr [ebp-0Ch] 
0000008c  cmp         eax,dword ptr [ebp-10h] 
0000008f  jge         000000A6 
            {
                if (value2 < value3)
00000091  mov         eax,dword ptr [ebp-10h] 
00000094  cmp         eax,dword ptr [ebp-14h] 
00000097  jge         000000A6 
                {
                    result1 = true;
00000099  mov         eax,1 
0000009e  and         eax,0FFh 
000000a3  mov         dword ptr [ebp-4],eax 
                }
            }
            // case 2
            if (value1 < value2 && value2 < value3)
000000a6  mov         eax,dword ptr [ebp-0Ch] 
000000a9  cmp         eax,dword ptr [ebp-10h] 
000000ac  jge         000000C3 
000000ae  mov         eax,dword ptr [ebp-10h] 
000000b1  cmp         eax,dword ptr [ebp-14h] 
000000b4  jge         000000C3 
            {
                result2 = true;
000000b6  mov         eax,1 
000000bb  and         eax,0FFh 
000000c0  mov         dword ptr [ebp-8],eax 
            }

这两个表达式将导致相同数量的测试,因为逻辑和运算符(&&)在C和c#中都具有短路语义。因此,你的问题的前提(第二种表达程序的方式导致较少的分支)是不正确的。

唯一的方法就是测量。

True和false被CLR表示为1和0,所以如果使用逻辑表达式有一些优势,我不会感到惊讶。让我们看看:

static void BenchBranch() {
    Stopwatch sw = new Stopwatch();
    const int NMAX = 1000000000;
    bool a = true;
    bool b = false;
    bool c = true;
    sw.Restart();
    int sum = 0;
    for (int i = 0; i < NMAX; i++) {
        if (a)
            if (b)
                if (c)
                    sum++;
        a = !a;
        b = a ^ b;
        c = b;
    }
    sw.Stop();
    Console.WriteLine("1: {0:F3} ms ({1})", sw.Elapsed.TotalMilliseconds, sum);
    sw.Restart();
    sum = 0;
    for (int i = 0; i < NMAX; i++) {
        if (a && b && c) 
            sum++;
        a = !a;
        b = a ^ b;
        c = b;
    }
    sw.Stop();
    Console.WriteLine("2: {0:F3} ms ({1})", sw.Elapsed.TotalMilliseconds, sum);
    sw.Restart();
    sum = 0;
    for (int i = 0; i < NMAX; i++) {
        sum += (a && b && c) ? 1 : 0;
        a = !a;
        b = a ^ b;
        c = b;
    }
    sw.Stop();
    Console.WriteLine("3: {0:F3} ms ({1})", sw.Elapsed.TotalMilliseconds, sum);
}
结果:

1:  2713.396 ms (250000000)
2:  2477.912 ms (250000000)
3:  2324.916 ms (250000000)

因此,从这里看来,使用逻辑运算符而不是嵌套条件语句似乎有一点优势。然而,任何特定的实例可能会给出一些不同的结果。

最后,像这样的微优化是否值得取决于代码的性能关键程度。