哪种代码流模式在 C#/.NET 中更有效?
Which code-flow pattern is more efficient in C#/.NET?
考虑这样一种情况,在这种情况下,方法的主要逻辑实际上应该 运行 给定特定条件。据我所知,有两种基本方法可以实现这一点:
如果逆条件成立,只需return:
public void aMethod(){
if(!aBoolean) return;
// rest of method code goes here
}
或
如果原条件成立,继续执行:
public void aMethod(){
if(aBoolean){
// rest of method code goes here
}
}
现在,我猜这些实现中哪个更有效取决于它用 and/or 编写的语言 如何 if
语句和 return
语句,以及可能的方法调用, 由 compiler/interpreter/VM 实现(取决于语言);所以我的问题的第一部分是,这是真的吗?
我的问题的第二部分是,如果第一部分的答案是 "yes",以上代码流模式中哪一个在 C#/.NET 4 中更有效。6.x?
编辑:
关于 Dark Falcon 的评论:这个问题的目的实际上并不是解决性能问题或优化我编写的任何实际代码,我只是好奇编译器如何实现每种模式的每一部分,例如为了论证,如果它是逐字编译的,没有编译器优化,哪个会更有效率?
正如 [~Dark Falcon] 所提到的,您不应该担心代码的微优化,编译器很可能会针对同一事物优化这两种方法。
相反,您应该非常关注您的程序可维护性和易读性
从这个角度看你应该选择B,原因有二:
- 它只有一个出口点(只有一个return)
- if 块被花括号包围
编辑
但是,嘿!正如评论中所述,这只是我的意见和我的考虑良好做法
验证函数参数时出现同样的问题。
像夜总会的保镖一样行事,尽快将不希望的人赶出去要干净得多。
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# 语言将安全(错误预防)置于速度之上。换句话说,几乎所有的东西都被放慢了速度以防止错误,无论是哪种方式。
如果你非常需要速度以至于你开始担心 if 语句,那么也许更快的语言更适合你的目的,可能是 C++
编译器编写者可以并且确实使用统计信息来优化代码,例如 "else clauses are only executed 30% of the time"。
但是,硬件人员在预测执行路径方面可能做得更好。我猜想现在,最有效的优化发生在 CPU 中,使用它们的 L1 和 L2 缓存,编译器编写者不需要做任何事情。
I am just curious about how each piece of each pattern is implemented
by the compiler, e.g. for arguments sake, if it was compiled verbatim
with no compiler optimizations, which would be more efficient?
以这种方式测试效率的最佳方法是 运行 对您关注的代码示例进行基准测试。特别是对于 C#,JIT 在这些场景中的作用并不明显。
附带说明一下,我对其他答案给出了 +1,这些答案指出效率不仅在编译器级别确定 - 代码可维护性涉及 数量级 效率水平高于您从这种特定类型的模式选择中获得的效率。
TL;DR It doesn't make a difference. Current generations of processors (circa Ivy Bridge and later) don't use a static branch-prediction algorithm that you can reason about anymore, so there is no possible performance gain in using one form or the other.
On most older processors, the static branch-prediction strategy is generally that forward conditional jumps are assumed to be taken, while backwards conditional jumps are assumed not-taken. Therefore, there might be a small performance advantage to be gained the first time the code is executed by arranging for the fall-through case to be the most likely—i.e.,
if { expected } else { unexpected }
.
But the fact is, this kind of low-level performance analysis makes very little sense when writing in a managed, JIT-compiled language like C#.
您得到的很多答案表明可读性和可维护性应该是编写代码时的首要考虑因素。令人遗憾的是,这在 "performance" 问题中很常见,虽然它完全正确且无可争辩,但它主要回避问题而不是回答问题。
此外,尚不清楚为什么表单 "A" 本质上比表单 "B" 更具可读性,反之亦然。无论哪种方式,参数都一样多——在函数顶部进行所有参数验证,或者确保只有一个 return 点——最终归结为按照你的风格指南所说的去做,除非在非常恶劣的情况下,您必须以各种可怕的方式扭曲代码,然后您显然应该做最易读的事情。
除了基于 conceptual/theoretical 理由提出一个完全合理的问题之外,了解性能影响似乎也是做出关于哪种 一般 形式的明智决定的好方法在编写风格指南时采用。
现有答案的其余部分由误导性猜测或完全错误的信息组成。当然,这是有道理的。分支预测很复杂,并且随着处理器变得越来越智能,它只会变得更难理解引擎盖下实际发生(或将要发生)的事情。
首先,让我们弄清楚几件事。您在问题中提到分析 未优化的 代码的性能。不,你永远不想那样做。这是浪费时间;你会得到没有反映真实世界使用情况的无意义数据,然后你会尝试从这些数据中得出结论,这最终会是错误的(或者可能是正确的,但出于错误的原因,这同样糟糕).除非您将未优化的代码发送给您的客户(您不应该这样做),否则您不会关心未优化代码的执行情况。在用 C# 编写时,实际上有两个级别的优化。第一个由 C# 编译器在生成中间语言 (IL) 时执行。这是由项目设置中的优化开关控制的。第二级优化由 JIT 编译器在将 IL 翻译成机器代码时执行。这是一个单独的设置,您实际上可以在启用或禁用优化的情况下分析 JITed 机器代码。当您进行概要分析或基准测试,甚至分析生成的机器代码时,您需要启用 两个 级别的优化。
但是对优化代码进行基准测试很困难,因为优化通常会干扰您要测试的内容。如果您尝试对问题中显示的代码进行基准测试,优化编译器可能会注意到它们中的任何一个实际上都没有做任何有用的事情并将它们转换为空操作。一个 no-op 与另一个 no-op 一样快——或者可能不是,这实际上更糟,因为那样的话你要进行的基准测试就是与性能无关的噪音。
到达此处的最佳方法是在概念层面上实际理解代码将如何由编译器转换为机器代码。这不仅可以让您摆脱创建良好基准的困难,而且还具有超出数字的价值。一个体面的程序员知道如何编写产生正确结果的代码; 优秀的程序员知道幕后发生的事情(然后然后就他们是否需要关心做出明智的决定)。
有人猜测编译器是否会将形式 "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
的值并确定它是真还是假,在 [=154] 中隐式设置一些标志=].下一条指令是条件分支:它根据一个或多个标志的值分支到规范 location/label。在这种情况下,如果设置了 "equals" 标志,je
将跳转,而如果 "equals" 标志未设置,则 jne
将跳转设置。很简单,对吧?这正是它在 x86 系列处理器上的工作方式,可能 CPU 您的 JIT 编译器为其发出代码。
现在我们进入您真正想问的问题的核心;也就是说,如果比较设置等于标志,我们是否执行je
指令跳转,或者如果比较设置相等标志,我们是否执行jne
指令跳转是否重要?比较没有设置相等标志?同样,不幸的是,答案很复杂,但很有启发性。
在继续之前,我们需要对分支预测有一些了解。这些条件跳转是代码中某个任意部分的分支。分支可以被采用(这意味着分支实际发生,并且处理器开始执行在完全不同的位置找到的代码),也可以不被采用(这意味着执行落到下一条指令,就像分支指令一样甚至不在那里)。分支预测非常重要,因为 mispredicted branches are very expensive on modern processors with deep pipelines that use speculative execution. If it predicts right, it continues uninterrupted; however, if it predicts wrong, it has to throw away all of the code that it speculatively executed and start over. Therefore, 在分支很可能被错误预测的情况下。足够聪明的优化器会将 if (condition) { return 42; } else { return 0; }
变成根本不使用分支的条件移动,无论您以何种方式编写 if
语句,从而使分支预测无关紧要。但我们假设这并没有发生,而且您实际上拥有带有条件分支的代码——它是如何预测的?
分支预测的工作原理很复杂,并且随着 CPU 供应商不断改进其处理器内部的电路和逻辑而变得越来越复杂。改进分支预测逻辑是硬件供应商为他们试图销售的产品增加价值和速度的重要方式,每个供应商都使用不同的专有分支预测机制。更糟糕的是,每一 代 处理器都使用略有不同的分支预测机制,因此在 "general case" 中对其进行推理非常困难。静态编译器提供的选项允许您优化它们为特定一代微处理器生成的代码,但在将代码发送给大量客户端时,这并不能很好地推广。您别无选择,只能求助于 "general purpose" 优化策略,尽管这通常效果很好。 JIT 编译器的最大希望在于,因为它会在您使用代码之前就在您的机器上编译代码,所以它可以针对您的特定机器进行优化,就像使用完美选项调用的静态编译器一样。这个承诺还没有完全实现,但我不会离开那个兔子洞。
所有现代处理器都具有 动态 分支预测,但它们具体如何实现是可变的。基本上,他们 "remember" 一个特定的(最近的)分支是否被采用,然后预测下一次它会走这条路。您可以在这里想象各种病态情况,相应地,分支预测逻辑中有各种情况或方法可以帮助减轻可能的损害。不幸的是,在编写代码来缓解这个问题时,您实际上无能为力——除了完全摆脱分支,这在用 C# 或其他托管语言编写时甚至都不是可用的选项。优化器会做任何它想做的事;您只需要祈祷并希望它是最理想的。那么在我们正在考虑的代码中,动态分支预测基本上是无关紧要的,我们不再谈论它。
重要的是static分支预测——处理器在第一次执行这段代码时、第一次遇到这个分支时、当它没有有什么真正的依据可以做出决定吗?有一堆似是而非的静态预测算法:
- 预测所有分支都没有被采用(一些早期的处理器确实使用了这个)。
假设采用"backwards"个条件分支,而"forwards"个条件分支不采用。这里的改进是大多数时候都会正确预测循环(在执行流中向后跳转)。这是大多数 Intel x86 处理器使用的静态分支预测策略,直到大约 Sandy Bridge。
由于此策略已使用了很长时间,标准建议是相应地安排您的 if
语句:
if (condition)
{
// most likely case
}
else
{
// least likely case
}
这可能看起来有悖常理,但您必须回过头来看看此 C# 代码将转换成的机器代码是什么样的。编译器通常会将 if
语句转换为比较语句,并将条件分支转换为 else
块。这个静态分支预测算法会将该分支预测为 "not taken",因为它是前向分支。 if
块将在不采用分支的情况下直接通过,这就是为什么要将 "most likely" 案例放在那里的原因。
如果您养成了这样编写代码的习惯,它可能在某些处理器上具有性能优势,但永远不会足以牺牲可读性的优势。特别是因为它只在 first 执行代码(之后,动态分支预测开始),并且第一次执行代码是 always 在 JIT 编译语言中很慢!
始终使用动态预测器的结果,即使是从未见过的分支。
这种策略很奇怪,但它实际上是大多数现代 Intel 处理器(大约 Ivy Bridge 及更高版本)所使用的。基本上,即使动态分支预测器可能从未见过这个分支,因此可能没有关于它的任何信息,处理器仍然会查询它并使用它 return 的预测。您可以将其想象为等同于任意静态预测算法。
在这种情况下,您如何安排 if
语句的条件绝对无关紧要,因为初始预测基本上是随机的。大约 50% 的情况下,您会因预测错误的分支而受到惩罚,而另外 50% 的情况下,您会从正确预测的分支中获益。这只是第一次 - 之后,几率变得更好,因为动态预测器现在有更多关于分支性质的信息。
这个答案已经 way 太长了,所以我将不讨论静态预测提示(仅在 Pentium 4 中实现)和其他此类有趣的话题,带来我们的分支预测的探索到此结束。如果您对更多感兴趣,请查看 CPU 供应商的技术手册(尽管我们所知道的大部分内容必须凭经验确定),阅读 Agner Fog's optimization guides(对于 x86 处理器),在线搜索各种白-论文和博客文章,and/or 询问有关它的其他问题。
要点可能是它无关紧要,除了在使用某种静态分支预测策略的处理器上,即使在这种情况下,当您使用 C# 等 JIT 编译语言编写代码时,它也无关紧要因为第一次编译延迟超过了单个预测错误分支的成本(甚至可能没有预测错误)。
考虑这样一种情况,在这种情况下,方法的主要逻辑实际上应该 运行 给定特定条件。据我所知,有两种基本方法可以实现这一点:
如果逆条件成立,只需return:
public void aMethod(){
if(!aBoolean) return;
// rest of method code goes here
}
或
如果原条件成立,继续执行:
public void aMethod(){
if(aBoolean){
// rest of method code goes here
}
}
现在,我猜这些实现中哪个更有效取决于它用 and/or 编写的语言 如何 if
语句和 return
语句,以及可能的方法调用, 由 compiler/interpreter/VM 实现(取决于语言);所以我的问题的第一部分是,这是真的吗?
我的问题的第二部分是,如果第一部分的答案是 "yes",以上代码流模式中哪一个在 C#/.NET 4 中更有效。6.x?
编辑: 关于 Dark Falcon 的评论:这个问题的目的实际上并不是解决性能问题或优化我编写的任何实际代码,我只是好奇编译器如何实现每种模式的每一部分,例如为了论证,如果它是逐字编译的,没有编译器优化,哪个会更有效率?
正如 [~Dark Falcon] 所提到的,您不应该担心代码的微优化,编译器很可能会针对同一事物优化这两种方法。
相反,您应该非常关注您的程序可维护性和易读性
从这个角度看你应该选择B,原因有二:
- 它只有一个出口点(只有一个return)
- if 块被花括号包围
编辑 但是,嘿!正如评论中所述,这只是我的意见和我的考虑良好做法
验证函数参数时出现同样的问题。
像夜总会的保镖一样行事,尽快将不希望的人赶出去要干净得多。
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# 语言将安全(错误预防)置于速度之上。换句话说,几乎所有的东西都被放慢了速度以防止错误,无论是哪种方式。 如果你非常需要速度以至于你开始担心 if 语句,那么也许更快的语言更适合你的目的,可能是 C++
编译器编写者可以并且确实使用统计信息来优化代码,例如 "else clauses are only executed 30% of the time"。
但是,硬件人员在预测执行路径方面可能做得更好。我猜想现在,最有效的优化发生在 CPU 中,使用它们的 L1 和 L2 缓存,编译器编写者不需要做任何事情。
I am just curious about how each piece of each pattern is implemented by the compiler, e.g. for arguments sake, if it was compiled verbatim with no compiler optimizations, which would be more efficient?
以这种方式测试效率的最佳方法是 运行 对您关注的代码示例进行基准测试。特别是对于 C#,JIT 在这些场景中的作用并不明显。
附带说明一下,我对其他答案给出了 +1,这些答案指出效率不仅在编译器级别确定 - 代码可维护性涉及 数量级 效率水平高于您从这种特定类型的模式选择中获得的效率。
TL;DR It doesn't make a difference. Current generations of processors (circa Ivy Bridge and later) don't use a static branch-prediction algorithm that you can reason about anymore, so there is no possible performance gain in using one form or the other.
On most older processors, the static branch-prediction strategy is generally that forward conditional jumps are assumed to be taken, while backwards conditional jumps are assumed not-taken. Therefore, there might be a small performance advantage to be gained the first time the code is executed by arranging for the fall-through case to be the most likely—i.e.,
if { expected } else { unexpected }
.But the fact is, this kind of low-level performance analysis makes very little sense when writing in a managed, JIT-compiled language like C#.
您得到的很多答案表明可读性和可维护性应该是编写代码时的首要考虑因素。令人遗憾的是,这在 "performance" 问题中很常见,虽然它完全正确且无可争辩,但它主要回避问题而不是回答问题。
此外,尚不清楚为什么表单 "A" 本质上比表单 "B" 更具可读性,反之亦然。无论哪种方式,参数都一样多——在函数顶部进行所有参数验证,或者确保只有一个 return 点——最终归结为按照你的风格指南所说的去做,除非在非常恶劣的情况下,您必须以各种可怕的方式扭曲代码,然后您显然应该做最易读的事情。
除了基于 conceptual/theoretical 理由提出一个完全合理的问题之外,了解性能影响似乎也是做出关于哪种 一般 形式的明智决定的好方法在编写风格指南时采用。
现有答案的其余部分由误导性猜测或完全错误的信息组成。当然,这是有道理的。分支预测很复杂,并且随着处理器变得越来越智能,它只会变得更难理解引擎盖下实际发生(或将要发生)的事情。
首先,让我们弄清楚几件事。您在问题中提到分析 未优化的 代码的性能。不,你永远不想那样做。这是浪费时间;你会得到没有反映真实世界使用情况的无意义数据,然后你会尝试从这些数据中得出结论,这最终会是错误的(或者可能是正确的,但出于错误的原因,这同样糟糕).除非您将未优化的代码发送给您的客户(您不应该这样做),否则您不会关心未优化代码的执行情况。在用 C# 编写时,实际上有两个级别的优化。第一个由 C# 编译器在生成中间语言 (IL) 时执行。这是由项目设置中的优化开关控制的。第二级优化由 JIT 编译器在将 IL 翻译成机器代码时执行。这是一个单独的设置,您实际上可以在启用或禁用优化的情况下分析 JITed 机器代码。当您进行概要分析或基准测试,甚至分析生成的机器代码时,您需要启用 两个 级别的优化。
但是对优化代码进行基准测试很困难,因为优化通常会干扰您要测试的内容。如果您尝试对问题中显示的代码进行基准测试,优化编译器可能会注意到它们中的任何一个实际上都没有做任何有用的事情并将它们转换为空操作。一个 no-op 与另一个 no-op 一样快——或者可能不是,这实际上更糟,因为那样的话你要进行的基准测试就是与性能无关的噪音。
到达此处的最佳方法是在概念层面上实际理解代码将如何由编译器转换为机器代码。这不仅可以让您摆脱创建良好基准的困难,而且还具有超出数字的价值。一个体面的程序员知道如何编写产生正确结果的代码; 优秀的程序员知道幕后发生的事情(然后然后就他们是否需要关心做出明智的决定)。
有人猜测编译器是否会将形式 "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
的值并确定它是真还是假,在 [=154] 中隐式设置一些标志=].下一条指令是条件分支:它根据一个或多个标志的值分支到规范 location/label。在这种情况下,如果设置了 "equals" 标志,je
将跳转,而如果 "equals" 标志未设置,则 jne
将跳转设置。很简单,对吧?这正是它在 x86 系列处理器上的工作方式,可能 CPU 您的 JIT 编译器为其发出代码。
现在我们进入您真正想问的问题的核心;也就是说,如果比较设置等于标志,我们是否执行je
指令跳转,或者如果比较设置相等标志,我们是否执行jne
指令跳转是否重要?比较没有设置相等标志?同样,不幸的是,答案很复杂,但很有启发性。
在继续之前,我们需要对分支预测有一些了解。这些条件跳转是代码中某个任意部分的分支。分支可以被采用(这意味着分支实际发生,并且处理器开始执行在完全不同的位置找到的代码),也可以不被采用(这意味着执行落到下一条指令,就像分支指令一样甚至不在那里)。分支预测非常重要,因为 mispredicted branches are very expensive on modern processors with deep pipelines that use speculative execution. If it predicts right, it continues uninterrupted; however, if it predicts wrong, it has to throw away all of the code that it speculatively executed and start over. Therefore, if (condition) { return 42; } else { return 0; }
变成根本不使用分支的条件移动,无论您以何种方式编写 if
语句,从而使分支预测无关紧要。但我们假设这并没有发生,而且您实际上拥有带有条件分支的代码——它是如何预测的?
分支预测的工作原理很复杂,并且随着 CPU 供应商不断改进其处理器内部的电路和逻辑而变得越来越复杂。改进分支预测逻辑是硬件供应商为他们试图销售的产品增加价值和速度的重要方式,每个供应商都使用不同的专有分支预测机制。更糟糕的是,每一 代 处理器都使用略有不同的分支预测机制,因此在 "general case" 中对其进行推理非常困难。静态编译器提供的选项允许您优化它们为特定一代微处理器生成的代码,但在将代码发送给大量客户端时,这并不能很好地推广。您别无选择,只能求助于 "general purpose" 优化策略,尽管这通常效果很好。 JIT 编译器的最大希望在于,因为它会在您使用代码之前就在您的机器上编译代码,所以它可以针对您的特定机器进行优化,就像使用完美选项调用的静态编译器一样。这个承诺还没有完全实现,但我不会离开那个兔子洞。
所有现代处理器都具有 动态 分支预测,但它们具体如何实现是可变的。基本上,他们 "remember" 一个特定的(最近的)分支是否被采用,然后预测下一次它会走这条路。您可以在这里想象各种病态情况,相应地,分支预测逻辑中有各种情况或方法可以帮助减轻可能的损害。不幸的是,在编写代码来缓解这个问题时,您实际上无能为力——除了完全摆脱分支,这在用 C# 或其他托管语言编写时甚至都不是可用的选项。优化器会做任何它想做的事;您只需要祈祷并希望它是最理想的。那么在我们正在考虑的代码中,动态分支预测基本上是无关紧要的,我们不再谈论它。
重要的是static分支预测——处理器在第一次执行这段代码时、第一次遇到这个分支时、当它没有有什么真正的依据可以做出决定吗?有一堆似是而非的静态预测算法:
- 预测所有分支都没有被采用(一些早期的处理器确实使用了这个)。
假设采用"backwards"个条件分支,而"forwards"个条件分支不采用。这里的改进是大多数时候都会正确预测循环(在执行流中向后跳转)。这是大多数 Intel x86 处理器使用的静态分支预测策略,直到大约 Sandy Bridge。
由于此策略已使用了很长时间,标准建议是相应地安排您的
if
语句:if (condition) { // most likely case } else { // least likely case }
这可能看起来有悖常理,但您必须回过头来看看此 C# 代码将转换成的机器代码是什么样的。编译器通常会将
if
语句转换为比较语句,并将条件分支转换为else
块。这个静态分支预测算法会将该分支预测为 "not taken",因为它是前向分支。if
块将在不采用分支的情况下直接通过,这就是为什么要将 "most likely" 案例放在那里的原因。如果您养成了这样编写代码的习惯,它可能在某些处理器上具有性能优势,但永远不会足以牺牲可读性的优势。特别是因为它只在 first 执行代码(之后,动态分支预测开始),并且第一次执行代码是 always 在 JIT 编译语言中很慢!
始终使用动态预测器的结果,即使是从未见过的分支。
这种策略很奇怪,但它实际上是大多数现代 Intel 处理器(大约 Ivy Bridge 及更高版本)所使用的。基本上,即使动态分支预测器可能从未见过这个分支,因此可能没有关于它的任何信息,处理器仍然会查询它并使用它 return 的预测。您可以将其想象为等同于任意静态预测算法。
在这种情况下,您如何安排
if
语句的条件绝对无关紧要,因为初始预测基本上是随机的。大约 50% 的情况下,您会因预测错误的分支而受到惩罚,而另外 50% 的情况下,您会从正确预测的分支中获益。这只是第一次 - 之后,几率变得更好,因为动态预测器现在有更多关于分支性质的信息。
这个答案已经 way 太长了,所以我将不讨论静态预测提示(仅在 Pentium 4 中实现)和其他此类有趣的话题,带来我们的分支预测的探索到此结束。如果您对更多感兴趣,请查看 CPU 供应商的技术手册(尽管我们所知道的大部分内容必须凭经验确定),阅读 Agner Fog's optimization guides(对于 x86 处理器),在线搜索各种白-论文和博客文章,and/or 询问有关它的其他问题。
要点可能是它无关紧要,除了在使用某种静态分支预测策略的处理器上,即使在这种情况下,当您使用 C# 等 JIT 编译语言编写代码时,它也无关紧要因为第一次编译延迟超过了单个预测错误分支的成本(甚至可能没有预测错误)。