C#编译器是否足够聪明,能够优化这段代码?

27

请忽略这个问题中的代码可读性。

就性能而言,以下代码应该像这样编写:

int maxResults = criteria.MaxResults;

if (maxResults > 0)
{
    while (accounts.Count > maxResults)
        accounts.RemoveAt(maxResults);
}

或者像这样:

if (criteria.MaxResults > 0)
{
    while (accounts.Count > criteria.MaxResults)
        accounts.RemoveAt(criteria.MaxResults);
}

编辑:criteria 是一个类,MaxResults 是一个简单的整数属性(即public int MaxResults { get { return _maxResults; } })。

C# 编译器会把 MaxResults 当作黑盒计算每次调用吗?还是它足够聪明,在 3 次调用同一属性且没有在调用之间修改该属性的情况下找出来了?如果 MaxResults 是字段呢?

优化法则之一是预计算,所以我本能地按照第一个示例编写了此代码,但我想知道是否有人自动为我执行此类操作(再次忽略代码可读性)。

(注意:我不想听到“微优化”论点,在我发布的特定情况下可能有效。我只想知道发生或未发生的事情背后的理论。)


1
你为什么要担心外部的if呢?如果accounts是一个数组或集合,count永远不会小于0。而且如果它从来不小于0,那么谁在乎MaxResults是否小于0呢?(它仍然永远不会循环) - Matthew Whited
5
@Matthew:请不要纠缠于逻辑,因为(a)它对我的应用程序来说是正确的,而(b)这不是我在这个问题中询问的内容。 - Jon Seigel
2
想知道什么是C#编译器。据我所知,至少有两个编译器:微软原版和Mono项目的编译器。 - Maurits Rijk
@Jon Seigel:这并没有回答他的问题。 - Brian
@Brian:如果你指的是我对Matthew的评论,当MaxResults恰好为零时,我需要排除移除逻辑。 - Jon Seigel
我的观点是,如果你担心微小的优化问题,你应该手动完成它们,而不是依赖编译器。此外,在 .Net 中,JIT 和 Ngen 完成的更重要的优化。IL 不是 CPU 将要执行的内容。 - Matthew Whited
7个回答

62

首先,回答性能问题的唯一方法是尝试两种方式并在实际条件下测试结果。

话虽如此,其他答案说“编译器”不进行此优化是对和错的。 问题的问题(除了根本问题是没有实际尝试和测量结果根本无法回答)在于“编译器”实际上是两个编译器:C #编译器,它编译为MSIL,以及JIT编译器,它将IL编译为机器代码。

C#编译器从不进行此类优化;正如已经注意到的那样,这样做需要编译器查看被调用的代码并验证其计算结果在被调用者的代码生命周期内不会改变。 C#编译器不这样做。

JIT编译器可能会这样做。没有理由不能这样做。它拥有所有的代码。它完全可以内联属性 getter,如果JIT决定内联属性 getter 返回一个可以缓存在寄存器中并重复使用的值,那么它是自由的这样做。 (如果你不想这样做,因为该值可能在另一个线程上被修改,则已经存在竞争条件错误;在担心性能之前修复错误。)

无论JIT是否实际上内联属性提取,然后将值编址到寄存器中,我都不知道。 我几乎不了解JIT。 但是如果它认为合适,它就被允许那样做。 如果您想知道它是否这样做,可以(1)询问撰写JIT的团队成员或(2)在调试器中检查JIT代码。

最后,我要利用这个机会指出,仅计算结果一次、存储结果并重复使用并非总是优化。 这是一个令人惊讶的复杂问题。 有各种各样的优化:

  • 执行时间

  • 可执行代码大小 -- 这对可执行时间有重大影响,因为大型代码需要更长的加载时间,增加了工作集大小,对处理器缓存、RAM和页面文件施加压力。在重要指标(如启动时间和高速缓存局部性)方面,小而慢的代码通常长期来看比大而快的代码更快。

  • 寄存器分配 -- 这也对执行时间有很大影响,特别是在像x86这样寄存器数量有限的架构中。将值存入寄存器以进行快速重用可能意味着其他需要优化的操作可用的寄存器较少;也许优化这些操作会带来净胜利。

  • 等等。这些问题变得非常复杂。

简而言之,你不可能知道编写缓存结果的代码是否实际上比重新计算快(1),或者表现更好(2)。更好的性能并不总是意味着使特定例程的执行更快。 更好的性能是指找出用户关心的资源--执行时间、内存、工作集、启动时间等--并为这些资源进行优化。如果没有(1)与客户交谈以了解他们关心什么,和(2)实际测量看看你的更改是否对所需方向产生可衡量的影响,就无法做到这一点。


2
+1 我希望能引起您对这个问题的关注。感谢您提供了出色的答案。 - Jon Seigel
1
指出“编译器”在 .Net(或 Java,或任何其他编译为中间格式并应用 [可能应用] JIT 的环境)中并不是一个有意义的术语,这点非常正确。因此,给你一个大大的加分。 - T.J. Crowder
6
@Eric:你的回答总是很好。即使没有看到你的名字,我也知道这一定是你写的。感谢与我们这些凡人分享 .Net 和 C# 内部工作细节的细节。 - Matthew Whited

9
如果MaxResults是一个属性,则不会对其进行优化,因为getter可能具有复杂的逻辑,例如:
private int _maxResults;
public int MaxReuslts {
  get { return _maxResults++; }
  set { _maxResults = value; }
}

看看如果将你的代码内联,行为会如何改变?

如果没有逻辑......你编写的任何一种方法都可以,这只是非常微小的差异,关键是它对你(或你的团队)来说可读性如何......你是在看它。


1
即使它是一个属性,也可以被内联,但这将由JIT完成。该值也可能会被另一个线程更改,因此优化掉可能不安全。 - Matthew Whited
MaxResults 应该是:MaxResults。 - jaygeek

6
你的两个代码示例只有在单线程环境下才能保证具有相同的结果,而.Net不是单线程的,如果MaxResults是一个字段(而不是属性)。除非你使用同步功能,否则编译器无法假设criteria.MaxResults在循环过程中不会改变。如果它是一个属性,它不能假设使用该属性没有副作用。
Eric Lippert非常正确地指出,这在很大程度上取决于你所说的“编译器”。C# -> IL编译器?还是IL -> 机器码(JIT)编译器?他指出JIT可能能够优化属性getter,因为它拥有所有信息(而C# -> IL编译器不一定拥有)。这不会改变多个线程的情况,但这是一个好点。

5

每次都会被调用和评估。编译器无法确定方法(或getter)是否是确定性的和纯净的(没有副作用)。

请注意,属性的实际评估可能会被JIT编译器内联,使其效果与简单字段一样快。

将属性评估变成廉价操作是一个好习惯。如果您在getter中进行了一些重计算,请考虑手动缓存结果或将其更改为方法。


2

为什么不试试呢?

只需设置两个控制台应用程序,让它们看起来相似,然后比较结果...记得将它们作为已正确安装的应用程序运行,否则您不能保证您不只是在运行MSIL。

真的,你可能会得到大约5个答案说“你不应该担心优化”。他们显然没有编写需要在可读性之前尽可能快的例程(例如游戏)。

如果这段代码是执行数十亿次的循环的一部分,那么这种优化可能是值得的。例如,max results可以是一个重载的方法,因此您可能需要讨论虚方法调用。

真正回答任何这些问题的唯一方法是弄清楚这是否是受益于优化的代码片段。然后,您需要知道增加执行时间的事物的类型。实际上,我们这些凡人无法先验地做到这一点,因此只能尝试2-3个不同版本的代码,然后进行测试。


突然间,我预料到了可读性的评论...他甚至使用了粗体;p - John Nicholas

0
如果criteria是一个类类型,我怀疑它不会被优化,因为另一个线程随时可能更改该值。对于struct,我不确定,但我的直觉是它不会被优化,但在那种情况下我认为这不会对性能产生太大影响。

1
结构体和类一样,都可以在另一个线程中被修改。结构体并不保证是不可变的,并且它们可以通过引用传递。 - Thorarin
非常正确。可变结构体太邪恶了,我从不使用它们。因此,我几乎忘记了那个情况。所以我的直觉最终变成了真正的智慧。;-) - herzmeister

-1

大家都在给出非常学术和理论化的答案。更糟糕的是,有些人声称一些我真的很想看到实际例子的事情。(比如声称有时第二段代码可能运行得更快。)

下面是我在C#中类似经验中得出的真实答案。每次我测试这种情况时,第一段代码都运行得更快,这让我认为C#很少或者从不对这种情况进行优化。即使不使用getter,也就是直接访问成员时也是如此。(我经常测试这种东西,因为我喜欢性能调优)。获取成员越困难,这种做法就变得越重要。比如:

int maxResults = simulations[name].getMatrix(id).criteria.MaxResults;

一般来说,根据我的经验,如果你不确定C#编译器是否会对某些内容进行优化,那么它就不会进行优化。这与C++(非托管)编译器相反,后者可以对一些非常疯狂的东西进行优化。
另外,侧面说明一下,在我看来,第一种选择在其他方面也更好,比如代码大小、寄存器缓存和代码可读性。所以这真的是一个毫无疑问的选择。

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