C#/.NET中哪种代码流模式更高效?

3

考虑这样一种情况,即只有在特定条件下才应该实际运行方法的主逻辑。据我所知,有两种基本方法可以实现这一点:

如果反向条件为真,则简单返回:

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的评论:这个问题的目的实际上不是为了修复性能问题或优化我编写的任何真实代码,我只是好奇每个模式的每个部分是如何由编译器实现的,例如,如果它是逐字编译而没有编译器优化,哪一个会更有效?

不会。它们很可能会优化为完全相同的内容。通过分析来查找性能问题,而不是微调那些无关紧要的东西来解决性能问题。就个人喜好而言,只要在“if”之前没有或几乎没有代码,我会选择第一个。 - Dark Falcon
嗯,是的,我知道。我提出这个问题并不是为了优化我的代码,而更多地是好奇编译器是如何实现每个部分的,例如,举个例子,如果它是逐字编译而没有进行任何编译器优化,哪种方式会更有效率? - Mat Jones
1
我认为第一个选项可以减少嵌套,就像Resharper建议的那样。 - federico scamuzzi
1
@federicoscamuzzi Resharper只是出于可读性的原因建议这样做,而非出于性能考虑。 - Mr47
2
@DarkFalcon 我本以为它们会被优化成完全相同的东西,但至少在CIL级别上,它们并不是。你可以在http://tryroslyn.azurewebsites.net/上自己尝试。它们甚至在发布模式下也不同。(现在,我不指望这会有任何性能上的差异,但我还没有测量过。) - user743382
显示剩余4条评论
4个回答

12
TL;DR 目前的处理器没有使用可以推导的静态分支预测算法,因此使用两种形式中的任何一种都不会有性能提升。在早期的处理器上,静态分支预测策略通常是假定向前条件跳转被执行,而向后条件跳转则被假定未执行。因此,在代码第一次执行时通过安排默认情况为最可能发生的情况,例如,if { expected } else { unexpected },可能会获得小幅度的性能优势。但事实上,在使用像C#这样的托管、JIT编译语言编写代码时,这种低级性能分析基本上毫无意义。在编写代码时,可读性和可维护性应该是首要考虑的问题,这是完全正确和不可争议的。
此外,为什么表格"A"比表格"B"更易读并不清楚,反之亦然。有正反两种观点,例如在函数顶部进行所有参数验证或确保只有一个返回点,最终取决于遵循您的样式指南,除非在某些极端情况下,您必须以各种可怕的方式扭曲代码,那么您应该选择最易读的方式。
除了从概念/理论角度提出完全合理的问题外,了解性能影响似乎也是在编写样式指南时采用哪种一般形式的明智决策的绝佳方法。
现有答案的剩余部分包括误导性的猜测或完全不正确的信息。当然,这是有道理的。分支预测很复杂,随着处理器变得更加智能,理解在幕后实际发生(或将要发生)的事情变得越来越困难。

首先,让我们明确几件事情。在问题中,您提到分析未经优化的代码的性能。不,您永远不想这样做。这是浪费时间的行为;您将得到无意义的数据,这些数据不反映实际使用情况,然后您将尝试从这些数据中得出结论,这将最终是错误的(或者可能是正确的,但理由是错误的,这同样糟糕)。除非您向客户发送未经优化的代码(这是不应该的),否则您不关心未经优化的代码的性能。在使用C#编写时,有效地有两个优化级别。第一个是由C#编译器在生成中间语言(IL)时执行的。这由项目设置中的优化开关控制。第二个优化级别是JIT编译器在将IL转换为机器代码时执行的。这是一个单独的设置,您实际上可以启用或禁用优化来分析JIT的机器代码。当您进行分析或基准测试,甚至分析生成的机器代码时,您需要同时启用两个优化级别。

优化代码的基准测试很困难,因为优化通常会干扰你试图测试的东西。如果你尝试对像问题中展示的代码进行基准测试,优化编译器可能会注意到它们都没有实际执行任何有用的操作,并将它们转换为无操作。一个无操作和另一个无操作一样快,或许更糟糕的是,这只会导致测量与性能无关的噪音。

最好的方法是真正理解代码将如何被编译器转换为机器码的概念层面。这不仅允许您避免创建良好基准测试的困难,而且具有超越数字的价值。一位合格的程序员知道如何编写产生正确结果的代码;一位优秀的程序员知道发生了什么(然后根据需要决定是否需要关注)。

有人猜测编译器是否会将“表格A”和“表格B”转换为等效代码。事实证明答案很复杂。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的值,并确定它是真还是假,隐式地在CPU内设置一些标志。下一条指令是条件分支:根据一个或多个标志的值,它会基于指定的位置/标签进行分支。在这种情况下,如果“equals”标志被设置,je将跳转,而如果“equals”标志未被设置,则jne将跳转。很简单,对吧?这正是x86系列处理器上的工作方式,这也是您的JIT编译器可能发出代码的CPU。

现在我们来到您真正想问的问题的核心;即,执行je指令以跳转是否重要,如果比较设置了等于标志,或者我们执行jne指令以跳转,如果比较没有设置等于标志?不幸的是,答案很复杂,但很有启发性。

在继续之前,我们需要对分支预测有一些了解。这些条件跳转是代码中到达任意位置的分支。一个分支可以被执行(也就是说分支会真正发生,处理器开始执行完全不同位置的代码),或者它也可以不被执行(这意味着执行会继续到下一条指令,就好像分支指令不存在一样)。分支预测非常重要,因为错误预测的分支对于使用投机执行的深度流水线的现代处理器来说非常昂贵。如果预测正确,它将继续不受干扰地执行;然而,如果预测错误,它必须丢弃所有它所假设执行的代码并重新开始。因此,在分支可能被错误预测的情况下,一种常见的低级优化技术是使用巧妙的无分支代码替换分支。足够聪明的优化器会将if(condition){return 42;}else{return 0;}转换成一个条件移动,它根本不使用分支,而与您如何编写if语句无关,使分支预测变得无关紧要。但是,我们想象这并没有发生,您实际上有一个带有条件分支的代码——它是如何被预测的呢?

分支预测的工作原理很复杂,随着CPU供应商不断改进其处理器内部的电路和逻辑,这种复杂性越来越大。改进分支预测逻辑是硬件供应商增加价值和速度的重要方式,每个供应商都使用不同且专有的分支预测机制。更糟糕的是,每一代处理器都使用略微不同的分支预测机制,因此在“通用情况”下推理它变得异常困难。静态编译器提供选项,允许您优化它们为特定微处理器的生成代码,但是当向大量客户端发送代码时,这种方法并不具有普适性。您别无选择,只能采用“通用目的”优化策略,尽管这通常效果还不错。JIT编译器的巨大优势在于,因为它会在您使用代码之前在您的计算机上编译代码,所以它可以像使用完美选项的静态编译器一样为您的特定计算机进行优化。虽然这个承诺并没有完全实现,但我不会深入探讨这个问题。
现代处理器都有动态分支预测,但它们如何实现是可变的。基本上,它们“记住”了一个特定(最近的)分支是否被采取,然后预测下一次会朝着这个方向走。在这里,您可以想象各种病理情况,并且相应地有各种分支预测逻辑的情况或方法来帮助减轻可能的损害。不幸的是,在编写代码时,您自己无法减轻此问题 - 除了完全消除分支外,这甚至不是在使用C#或其他托管语言编写时可用的选项。优化器将执行任何操作;您只需交叉手指,希望它是最优的。因此,在我们考虑的代码中,动态分支预测基本上是无关紧要的,我们将不再讨论它。
重要的是静态分支预测 - 处理器第一次执行此代码时,第一次遇到此分支时,它将做出什么预测,当它没有任何真实依据可以做出决策时?有一堆可信的静态预测算法:
  • Predict all branches are not taken (some early processors did, in fact, use this).
  • Assume "backwards" conditional branches are taken, while "forwards" conditional branches are not taken. The improvement here is that loops (which jump backwards in the execution stream) will be correctly predicted most of the time. This is the static branch-prediction strategy used by most Intel x86 processors, up to about Sandy Bridge.

    Because this strategy was used for so long, the standard advice was to arrange your if statements accordingly:

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

    This possibly looks counter-intuitive, but you have to go back to what the machine code looks like that this C# code will be transformed into. Compilers will generally transform the if statement into a comparison and a conditional branch into the else block. This static branch prediction algorithm will predict that branch as "not taken", since it's a forward branch. The if block will just fall through without taking the branch, which is why you want to put the "most likely" case there.

    If you get into the habit of writing code this way, it might have a performance advantage on certain processors, but it's never enough of an advantage to sacrifice readability. Especially since it only matters the first time the code is executed (after that, dynamic branch prediction kicks in), and executing code for the first time is always slow in a JIT-compiled language!

  • Always use the dynamic predictor's result, even for never-seen branches.

    This strategy is pretty strange, but it's actually what most modern Intel processors use (circa Ivy Bridge and later). Basically, even though the dynamic branch-predictor may have never seen this branch and therefore may not have any information about it, the processor still queries it and uses the prediction that it returns. You can imagine this as being equivalent to an arbitrary static-prediction algorithm.

    In this case, it absolutely does not matter how you arrange the conditions of an if statement, because the initial prediction is essentially going to be random. Some 50% of the time, you'll pay the penalty of a mispredicted branch, while the other 50% of the time, you'll benefit from a correctly predicted branch. And that's only the first time—after that, the odds get even better because the dynamic predictor now has more information about the nature of the branch.

这个回答已经太长了,所以我不会讨论静态预测提示(仅在 Pentium 4 中实现)和其他有趣的话题,我们的分支预测探索到此结束。如果你对此感兴趣,请查阅 CPU 供应商的技术手册(尽管我们所知道的大部分都必须通过经验确定),阅读Agner Fog的优化指南(适用于x86处理器),在网上搜索各种白皮书和博客文章,或者提出更多关于它的问题。

总结可能是,这并不重要,除了使用某种静态分支预测策略的处理器外,在像C#这样的JIT编译语言中编写代码时,这几乎不重要,因为首次编译延迟超过单个错误预测分支的成本(这甚至可能没有被错误预测)。


1
非常有趣的回答。谢谢!我对分支预测等方面有一些了解,但是通过你的回答我学到了很多。+1,并标记为接受的答案。 - Mat Jones

4

在验证函数参数时可能会出现相同的问题。

最好像夜总会保安一样,尽快把无望者踢出去,这样更加简洁。

  public void aMethod(SomeParam p)
  {
     if (!aBoolean || p == null) 
         return;

     // Write code in the knowledge that everything is fine
  }

让他们进来只会带来以后的麻烦。
  public void aMethod(SomeParam p)
  {
     if (aBoolean) 
     {
         if (p != null)
         {
             // Write code, but now you're indented 
             // and other if statements will be added later
         }

         // Later on, someone else could add code here by mistake.
     }

     // or here...
  }

C#语言优先考虑安全性(防止bug)而非速度。换句话说,为了避免出现bug,几乎所有东西都被放慢了。

如果你追求速度,以至于开始担心if语句的问题,那么可能更适合使用更快的语言,比如C++。

编译器编写者可以利用统计学来优化代码,例如“else语句只执行30%的时间”。

然而,硬件工程师可能会更好地预测执行路径。我猜现在最有效的优化发生在CPU的L1和L2缓存内部,编译器编写者不需要做任何事情。


是的,我知道。我问的并不是关于可维护性/编写“干净”代码的问题,而是询问底层汇编指令的效率。 - Mat Jones
任何一个优秀的优化编译器都会以相同的方式压缩您的代码,无论您如何编写if语句。不用担心。 - user1023602
请看我对问题的编辑和/或原始帖子上的第一条评论。 - Mat Jones

0

正如[~Dark Falcon]所提到的,你不应该关注代码中微小部分的微观优化,编译器很可能会将两种方法优化为相同的结果。

相反,你应该非常关注你的程序的可维护性和易读性。

从这个角度来看,你应该选择B,有两个原因:

  1. 它只有一个出口点(只有一个返回)
  2. if块被花括号包围

编辑 但是嘿!正如评论中所说,这只是我的意见和我认为的良好实践。


1
编译器很可能会将两种方法优化为相同的结果。我在你回答之前不到一分钟就已经评论了这个问题,这是不正确的,并且可以在线验证。至于你回答中的其他部分,那是你的个人意见,你有权拥有它,但你的观点并不普遍,其他人可能有好的理由不同意。无论是你的观点还是他们的观点都不能作为一个好的答案,因为没有办法判断它的对错。 - user743382
@r1verside 说实话,我认为你的第二点有些迂腐/非常主观,因为我也可以在第一个中将if块更改为if(!aBoolean){ return; },从而使你关于花括号的观点无效... - Mat Jones
即使只有一条语句,使用花括号也是良好的编程习惯。这只是你的观点;我工作的编码标准是内联仅包含一条语句的块,例如 if(!aBoolean) return; - Mat Jones
1
“而且这不仅仅是我的观点”-- 这在书面形式中表达得非常糟糕。如果你强调“我的”,如果你的意思是其他人也有同样的看法,那么可以。如果你强调“观点”,如果你的意思是这是事实,那么绝对不行。根据你句子的其余部分,我无法确定你的意思是哪一个。 - user743382
像这样的答案让我感到困扰。它们开始时做出错误的声明/猜测,然后最终说出完全正确且无可争议的事情。当然,编写代码时应以可维护性为主要因素,但这并没有真正回答所提出的问题。此外,在一般情况下,没有理由证明重新排列 if/else 语句的方向会对可读性造成很大影响。 - Cody Gray
显示剩余5条评论

0
我只是好奇编译器如何实现每个模式的每个部分,例如,假设它没有进行编译器优化,哪种更有效率?
以这种方式测试效率的最佳方法是在您关心的代码示例上运行基准测试。特别是对于C#,不会明显地知道JIT正在处理这些情况。
顺便说一句,我赞同其他答案中指出的一点,效率不仅取决于编译器级别 - 代码可维护性涉及比您从此类模式选择中获得的效率更多的效率级别。

请参考上面@hvd的评论。令人惊讶。 - Aaron Thomas
1
即使代码编译方式不同,您也不会在基准测试结果中注意到任何模式 - 在这种特定情况下。 - Fabio
对未经优化的代码进行基准测试将是完全浪费时间,并且不可避免地会给您带来无意义的数据。而JIT正在做什么显然很明显,您只需查看JIT编译后的代码即可!实际上,这确实是唯一一个好的推理方式,因为要创建一个既不会被轻易优化掉又不会过于嘈杂的良好测试用例是多么困难。 - Cody Gray
你从问题中引用的语录说“如果没有编译器优化地按原样编译”。那就是我的意思。分析未经优化的代码毫无意义。在C#中,实际上有两个级别的优化。第一个是由C#编译器执行的,在生成IL时执行。第二个是由JIT编译器执行的,当它将IL转换为机器代码时执行。如果你在任一阶段禁用了优化并对其进行基准测试以得出性能结论,那么你得出的结论是基于不正确和无意义的事实的。 - Cody Gray
@CodyGray 好的,有道理。谢谢你帮我理解。 - Aaron Thomas
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接