c# - 哪种代码流模式在C#/。NET中更有效?

标签 c# .net performance compilation .net-assembly

考虑一种情况,其中方法的主要逻辑仅应在特定条件下实际运行。据我所知,有两种基本方法可以实现此目的:

如果逆条件为真,则只需返回:

public void aMethod(){
    if(!aBoolean) return;
    // rest of method code goes here
}


要么

如果原始条件为真,则继续执行:

public void aMethod(){
    if(aBoolean){
        // rest of method code goes here
    }
}


现在,我猜想这些实现中哪个更高效取决于其编写的语言和/或编译器/解释器/ VM如何实现if语句和return语句以及可能的方法调用(取决于语言);所以我的问题的第一部分是,这是真的吗?

我的问题的第二部分是,如果第一部分的答案是“是”,那么上述哪种代码流模式在C#/。NET 4.6.x中更有效?

编辑:
关于Dark Falcon的评论:这个问题的目的并不是要解决性能问题或优化我编写的任何实际代码,我只是对编译器如何实现每个模式的每个部分感到好奇,例如出于参数考虑,如果它是在没有编译器优化的情况下逐字编译的,哪一种效率更高?

最佳答案

TL; DR没什么关系。当前一代的处理器(大约在Ivy Bridge和更高版本中)不再使用可以推理的静态分支预测算法,因此使用一种形式或另一种形式不会带来性能提升。
  
  在大多数较旧的处理器上,静态分支预测策略通常是假定采用前向条件跳转,而假定不采用后向条件跳转。因此,在第一次执行代码时,通过安排掉线情况最有可能(即,
  if { expected } else { unexpected }
  
  但是事实是,当使用托管的,JIT编译的语言(如C#)编写时,这种低级性能分析几乎没有意义。


您会得到很多答案,其中说,编写代码时应首先考虑可读性和可维护性。遗憾的是,这在“性能”问题中很常见,尽管它是完全正确且无可争辩的,但它通常会忽略问题而不是回答问题。

此外,尚不清楚为什么表格“ A”本质上比表格“ B”更具可读性,反之亦然。一种方法或另一种方法有很多参数–在函数顶部进行所有参数验证,或确保只有一个返回点–最终归结为您的样式指南所说的,除非确实如此糟糕在这种情况下,您必须以各种可怕的方式来扭曲代码,然后显然应该做一些最易读的事情。

除了要从概念/理论基础上提出一个完全合理的问题外,理解性能影响似乎也是一种明智的决定方式,可以明智地决定编写样式指南时采用哪种一般形式。

现有答案的其余部分包括误导性猜测或彻头彻尾的错误信息。当然,这是有道理的。分支预测很复杂,并且随着处理器变得越来越聪明,它只会越来越难以理解引擎盖下实际发生(或将要发生)的事情。

首先,让我们弄清楚一些事情。您可以在问题中参考分析未优化代码的性能。不,您永远都不想这样做。这是浪费时间。您将获得无法反映实际使用情况的无意义的数据,然后您将尝试从该数据中得出结论,最终将得出错误的结论(或也许是正确的,但由于错误的原因,这同样很糟糕) )。除非您将未优化的代码交付给客户(您不应该这样做),否则您不会在乎未优化的代码的性能。使用C#编写时,实际上有两个优化级别。第一种由C#编译器生成中间语言(IL)时执行。这由项目设置中的优化开关控制。当JIT编译器将IL转换为机器代码时,将执行第二级优化。这是一个单独的设置,您实际上可以分析启用或禁用优化的JIT机器代码。在进行性能分析或基准测试,甚至分析生成的机器代码时,需要同时启用两个优化级别。

但是对优化代码进行基准测试比较困难,因为优化经常会干扰您要测试的内容。如果您尝试像问题中所示的那样对代码进行基准测试,则优化的编译器可能会注意到它们实际上都没有做任何有用的事情并将其转换为无操作。一个无操作操作与另一个无操作操作同样快-也许不是,这实际上更糟,因为那时您要进行基准测试的只是与性能无关的噪音。

最好的方法是从概念上实际理解编译器将如何将代码转换为机器代码。这不仅使您能够逃避创建良好基准的困难,而且还具有超越数字的价值。体面的程序员知道如何编写可产生正确结果的代码。一个好的程序员知道幕后的情况(然后就是否需要照顾做出明智的决定)。

有人猜测编译器是否会将形式“ A”和形式“ B”转换为等效代码。事实证明,答案很复杂。 IL几乎可以肯定会有所不同,因为无论您是否启用优化,IL或多或少都是您实际编写的C#代码的字面翻译。但是事实证明,您实际上并不在乎,因为IL不会直接执行。它仅在JIT编译器完成后才执行,并且JIT编译器将应用自己的优化集。确切的优化取决于您编写的代码类型。如果你有:

int A1(bool condition)
{
    if (condition)    return 42;
    return 0;
}

int A2(bool condition)
{
    if (!condition)   return 0;
    return 42;
}


优化后的机器代码很有可能是相同的。实际上,即使这样:

void B1(bool condition)
{
    if (condition)
    {
        DoComplicatedThingA();
        DoComplicatedThingB();
    }
    else
    {
        throw new InvalidArgumentException();
    }
}

void B2(bool condition)
{
    if (!condition)
    {
        throw new InvalidArgumentException();
    }
    DoComplicatedThingA();
    DoComplicatedThingB();
}


在功能足够强大的优化程序中将被视为等效项。很容易理解为什么:它们是等效的。证明一种形式可以用另一种形式重写而无需更改语义或行为,这很简单,而这正是优化器的工作。

但是,让我们假设它们确实为您提供了不同的机器代码,或者是因为您编写了足够复杂的代码以致优化器无法证明它们是等效的,或者是因为您的优化器刚刚落伍了(有时可能会发生在JIT中)优化程序,因为它优先于代码生成的速度而不是最大效率的生成代码。出于说明目的,我们将假设机器代码类似于以下内容(大大简化了):

C1:
    cmp  condition, 0        // test the value of the bool parameter against 0 (false)
    jne  ConditionWasTrue    // if true (condition != 1), jump elsewhere;
                             //  otherwise, fall through
    call DoComplicatedStuff  // condition was false, so do some stuff
    ret                      // return
ConditionWasTrue:
    call ThrowException      // condition was true, throw an exception and never return




C2:
    cmp  condition, 0        // test the value of the bool parameter against 0 (false)
    je   ConditionWasFalse   // if false (condition == 0), jump elsewhere;
                             //  otherwise, fall through
    call DoComplicatedStuff  // condition was true, so do some stuff
    ret                      // return
ConditionWasFalse:
    call ThrowException      // condition was false, throw an exception and never return


cmp指令等效于您的if测试:它检查condition的值并确定它是true还是false,从而在CPU内部隐式设置一些标志。下一条指令是条件分支:它根据一个或多个标志的值分支到规范位置/标签。在这种情况下,如果设置了“等于”标志,je将跳转,而如果未设置“等于”标志,jne将跳转。很简单吧?这正是在x86处理器系列上的工作方式,这可能是您的JIT编译器为其发出代码的CPU。

现在,我们进入了您真正要问的问题的核心;也就是说,如果比较设置了相等标志,是否执行je指令跳转,或者如果比较没有设置相等标志,是否执行jne指令跳转是否重要?再次,不幸的是,答案很复杂,但却很有启发性。

在继续之前,我们需要对分支预测有一些了解。这些条件跳转是代码中任意部分的分支。可以采用分支(这意味着分支实际上发生了,并且处理器开始执行在完全不同的位置找到的代码),也可以不采用分支(这意味着执行将进入下一条指令,就像分支指令一样)甚至不在那里)。分支预测非常重要,因为mispredicted branches are very expensive在具有使用推测执行的深层管道的现代处理器上。如果它预测正确,它将继续不间断;但是,如果它预测错误,则必须丢弃推测性执行的所有代码,然后重新开始。因此,在分支可能被错误预测的情况下,a common low-level optimization technique is replacing branches with clever branchless code。一个足够聪明的优化器会将if (condition) { return 42; } else { return 0; }变成完全不使用分支的条件移动,无论您以何种方式编写if语句,都使分支预测无关紧要。但是我们在想这没有发生,实际上您的代码带有条件分支-如何预测它?

分支预测的工作原理很复杂,并且随着CPU供应商不断改进其处理器内部的电路和逻辑,它一直变得越来越复杂。改进分支预测逻辑是硬件供应商为其尝试销售的产品增加价值和速度的重要途径,并且每个供应商都使用不同的专有分支预测机制。更糟糕的是,每一代处理器都使用略有不同的分支预测机制,因此在“一般情况”下进行推理非常困难。静态编译器提供了一些选项,这些选项使您可以优化它们为特定一代微处理器生成的代码,但这在将代码交付给大量客户端时并不能很好地推广。您别无选择,只能求助于“通用”优化策略,尽管这通常效果很好。 JIT编译器的最大希望是,因为它可以在使用之前在您的计算机上编译代码,所以它可以针对您的特定计算机进行优化,就像使用完美选项调用的静态编译器一样。尚未完全实现这一诺言,但我不会偏离那个兔子洞。

所有现代处理器都有动态分支预测,但是它们执行的准确度是可变的。基本上,他们“记住”某个特定的(最近的)分支是否被采用,然后预测下一次它将以这种方式进行。您可以在这里想象各种病理情况,相应地,分支预测逻辑中或方法中的各种情况也有助于减轻可能的损害。不幸的是,编写代码来缓解此问题时,您实际上无法做任何事情-除了完全摆脱分支,使用C#或其他托管语言编写时甚至没有选择。优化器将尽其所能。您只需要交叉手指,并希望这是最理想的选择。在我们正在考虑的代码中,动态分支预测基本上是不相关的,我们不再赘述。

重要的是静态分支预测-当处理器没有真正的决策依据时,处理器第一次执行该代码,第一次遇到该分支时将进行什么预测?有很多合理的静态预测算法:


预测不采用所有分支(实际上,某些早期的处理器确实使用了此分支)。
假定采用“向后”条件分支,而不采用“向前”条件分支。此处的改进是循环(在执行流中向后跳)将在大多数时间正确预测。这是大多数Intel x86处理器使用的静态分支预测策略,直至Sandy Bridge为止。

由于此策略使用了很长时间,因此标准建议是相应地安排您的if语句:

if (condition)
{
    // most likely case
}
else
{
    // least likely case
}


这看起来可能违反直觉,但是您必须回到将C#代码转换为机器代码的样子。编译器通常会将if语句转换为比较,并将条件分支转换为else块。该静态分支预测算法会将该分支预测为“未采用”,因为它是前向分支。 if块将不通过分支而直接通过,这就是为什么要将“最可能”的情况放在这里的原因。

如果您习惯以这种方式编写代码,那么它在某些处理器上可能具有性能上的优势,但是牺牲可读性永远是不够的。特别是因为它仅在第一次执行代码时才重要(此后,动态分支预测开始),并且第一次使用JIT编译语言执行代码总是很慢!
即使对于从未见过的分支,也始终使用动态预测器的结果。

这种策略很奇怪,但是实际上是大多数现代英特尔处理器使用的方法(大约在Ivy Bridge和更高版本中)。基本上,即使动态分支预测器可能从未见过该分支,因此可能没有任何有关该分支的信息,但处理器仍会查询该分支并使用它返回的预测。您可以想象这等同于任意静态预测算法。

在这种情况下,如何安排if语句的条件绝对不重要,因为初始预测基本上是随机的。大约有50%的时间,您将支付分支预测错误的罚款,而其余50%的时间,您将从正确预测的分支中受益。这只是第一次,在那之后,可能性更大,因为动态预测器现在具有有关分支性质的更多信息。


这个答案已经太久了,因此,我将避免讨论静态预测提示(仅在Pentium 4中实现)和其他此类有趣的话题,从而使我们对分支预测的探索逐渐结束。如果您对更多信息感兴趣,请查阅CPU供应商的技术手册(尽管大多数我们必须根据经验确定),阅读Agner Fog's optimization guides(对于x86处理器),在线搜索各种白皮书和博客文章,以及/或提出其他问题。

收获可能是没有关系,除了在使用某种静态分支预测策略的处理器上,甚至在那里,当您使用像C#这样的JIT编译语言编写代码时,也没有关系,因为这是第一次编译延迟超过了单个错误预测分支(甚至可能不会被错误预测)的代价。

关于c# - 哪种代码流模式在C#/。NET中更有效?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/41382497/

相关文章:

.net - TFS 分支图上显示的排除分支

c# - 算法比较 C#

c# - 为什么我的递归下降解析器是右关联的

c# - 使用带有 Caliburn micro Message.Attach 的附加事件

c# - 富文本框如何在没有选择的情况下突出显示文本 block

c# - 为什么 Dictionary 没有 AddRange?

javascript - 如何遍历对象的实例属性?

c - 已编译编译器对已编译代码的性能

C#,实例化泛型类型 - 具有可变类型参数?

c# - 从 C# 客户端在 Solr 中索引 pdf 文档