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)
{
}
else
{
}
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编译语言中编写代码时,这几乎不重要,因为首次编译延迟超过单个错误预测分支的成本(这甚至可能没有被错误预测)。