CLR 全面透彻解析
及早并经常评量性能,第 1 部分
Vance Morrison
作为 Microsoft
® .NET Framework 公共语言运行库团队的性能架构师,帮助大家充分利用运行时编写高性能的应用程序是我的职责所在。这无论是在 .NET 还是在其他语言中都不神秘——您只需要在设计之初即考虑应用程序的性能问题即可。有很多应用程序在编写时根本未考虑性能问题。这通常无关紧要,因为大多数程序的计算量相对较少,而且同和它们交互的人类相比,程序的计算速度要快得多。遗憾的是,当真的需要程序具有较高性能时,我们却缺乏相关的知识、技能和工具来很好地实现这一点。
在这里我将讨论编写高性能应用程序所需掌握的内容。我将重点介绍为 .NET 编写的程序,但各种语言间的概念都是通用的。由于 .NET 对底层机器的抽象程度要比典型的 C++ 编译器高,并且由于 .NET 提供了许多强大但成本高昂的功能(包括反射、自定义属性、正则表达式等),所以很容易在无意中将成本高昂的操作加入到对性能要求苛刻的代码路径中。为帮助您避免付出这种开销,我将介绍如何量化各种 .NET 功能的开销以使您了解何时应该使用这些功能。
制定计划
正如我所提及的,大多数程序在编写时都没有充分考虑性能问题,但实际上真的应该为每个项目都制定一个性能计划。您必须考虑到各种用户方案,并清楚地表达出什么样的性能算是出色、良好或糟糕。然后,根据数据量、算法复杂程度以及以前构建类似应用程序的经验来决定是否能轻松满足所定义的各种性能目标。对于许多 GUI 应用程序而言,性能目标比较中庸,无需进行特别设计即可轻松达到至少属于良好级别的性能。在这种情况下,您的性能计划即告完成。
如果不清楚是否能轻松满足性能目标,则需要开始制定计划,列出可能成为瓶颈的方方面面。典型问题方面包括启动时间、批量数据操作和图形动画等。
配置文件数据处理示例
举个例子可以更具体地说明这一点。我目前正在设计用于处理配置文件数据的 .NET 基础结构。我需要以一种有意义的方式表示出操作系统生成的事件(如页面错误、磁盘 I/O、上下文切换等)列表。涉及的数据文件通常会比较大;较小的配置文件大约 10MB,而文件大小超过 1GB 的也不稀奇。
在制定性能计划时,我发现如果仅计算需要在屏幕上重绘的那部分数据集,数据显示不会出问题;换句话说,就是屏幕“懒散”一些问题不大。遗憾的是,要使 GUI 对象(例如树控件、列表控件和文本框等)“懒散”一些,需要一些额外的工作。这正是大多数文本编辑器在处理非常大的文件(比如 100MB)时,其性能让人难以接受的原因。如果在设计 GUI 时不考虑性能,则结果几乎肯定无法令人满意。
但是,延迟显示对于那些需要使用文件中所有数据的操作(例如,计算摘要时)并不会有所帮助。鉴于数据集大小的因素,数据分派和处理方法是必须要用心设计的“热”代码路径。程序的其他部分可能对性能没有严格要求,因此不需要特别关注。
这种情况非常具有代表性。甚至在需要高性能的方案中,应用程序的 95% 都不需要任何性能计划,但您需要认真考虑需要性能计划的最后 5%。而且,根据我的经验,要确定程序中需要认真考虑其性能的 5% 一般都非常容易。
及早评量并经常评量
在高性能设计中,接下来的步骤是评量——在编写代码之前,您需要了解性能目标是否能够实现,如果可以,那它们对设计有哪些限制条件。在本例中,我需要知道在设计中应该加以考虑的一些基本操作(例如原始文件 I/O 和数据库访问等)的开销。在继续操作之前,我需要准备一些数据。这是项目设计中最关键的时刻。
让人沮丧的是,大部分性能都是在开发过程的最初阶段丢失的。当您为程序的核心选定了数据结构后,应用程序的性能框架就已经定型了。选择算法时进一步限制了其性能。选择各种子组件之间的接口约定时又进一步限制了性能。理解早期设计决策中每个决策的开销并进行明智的选择至关重要。
设计是一个迭代过程。最好能够从最简洁、最明显的选择开始制定设计蓝图(我建议采用热代码的原型)并评估其性能。您还应该考虑如果将性能作为唯一要素,那设计会是什么样的,然后再评估该应用程序性能将达到多高。现在最有趣的工程开始啦!您开始调整设计并考虑在这两种极端情况下的可选方法,找出能够得到最佳结果的设计。
同样,我在配置文件数据处理程序方面的经验非常具有指导意义。与大多数项目一样,对数据表示的选择非常重要。数据是否应该存放在内存中?它是否应该以数据流方式写入文件?是否应该将它放在数据库中?标准解决方案是应该将任何大型数据集存储在数据库中;但是,数据库适用于变化相对缓慢的数据,对于变化频繁的大量数据则不适用。我的应用程序需要定期将许多 GB 的数据转储到数据库中。数据库是否能够处理它?通过对数据库操作稍微进行一下评量和分析,很容易就可以确定数据库不具备我所需要的性能框架。
在较为详细地评量了在不引入额外页面错误之前应用程序可以使用的最大内存大小后,我同样排除了将数据放在内存中的解决方案。现在剩下的只有用于基本数据表示的文件数据流解决方案。
但是,仍有许多其他设计决策需要加以制定。配置文件数据的基本形式是由许多异类事件组成的列表。但这些事件应该是什么格式的?它们是否是字符串(都非常一致)?它们是否是 C# 结构或对象?
如果它们是对象,则最直观的解决方案是按照每个事件进行分配,这将需要很多次分配。这是否可以接受?当我迭代事件时,调度工作具体是如何进行的?它是采用回调模型还是迭代模型?调度工作是通过接口、委托还是反射进行的?大约需要制定数十种设计决策,而且它们都会对程序的最终性能产生影响,所以我需要对其进行评量以便权衡取舍。
为评量提供保障支持
很明显,在设计过程中需要进行许多的评量。具体该如何操作呢?有许多剖析工具可为您提供帮助,但是其中一种最通用而且最简单、最常见的技术是微基准。此技术非常简单:当您想知道特定操作的开销时,只需建立一个它的使用示例,然后直接测量该操作所耗时间即可。
.NET Framework 中有一个名为 System.Diagnostics.Stopwatch 的高分辨率计时器,专为此目的而设计。分辨率的大小随着硬件的不同而异,但通常都低于 1 微秒,这已经完全够用。它是随 .NET Framework 一同提供的,所以您已经拥有了所需的功能。
虽然 Stopwatch 是一个不错的开端,但要得到准确的基准工具还需要做许多工作。较小的操作应该放置在循环中,这可以使间隔时间足够长,以便能够进行精确测量。在进行测量前应该运行一次基准,以确保所有实时 (JIT) 编译和其他一次性初始化工作都已完成(当然,如果目标即是对初始化进行测试则另当别论)。因为测量过程会产生干扰,所以应该多运行几次基准,并收集统计数据以确定测量的稳定性。它还应该能够方便地批量运行许多基准(设计变体),并得到显示所有结果的报告以便进行比较。
我编写了一个名为 MeasureIt.exe 的基准工具,它基于 Stopwatch 类构建,可以实现上述这些目标。您可以从《MSDN
® 杂志》网站获得该工具以及本专栏中讨论的所有代码。解压缩之后,只需键入以下命令即可运行:
MeasureIt
它可以在几秒钟之内运行 50 余种标准基准,并以网页形式显示出结果。一个数据摘录示例如
图 1 所示。在这些结果中,每个测量都执行 10,000 次某个操作(此操作在一个执行 1000 次的循环中被克隆 10 次)。每个测量随后执行 10 次,并计算出标准统计值(最小值、最大值、中值、平均值、标准偏差)。
![](http://i.msdn.microsoft.com/Global/Images/clear.gif)
测量的操作 | 中值 | 平均值 | 标准偏差 | 最小值 | 最大值 | 示例 |
---|---|---|---|---|---|---|
MethodCalls:EmptyStaticFunction() [count=1000 scale=10.0] | 1.000 | 1.005 | 0.084 | 0.922 | 1.136 | 10 |
MethodCalls:aClass.Interface() [count=1000 scale=10.0] | 1.699 | 1.769 | 0.090 | 1.696 | 1.943 | 10 |
ObjectOps:new Class() [count=1000 scale=10.0] | 6.248 | 8.040 | 3.556 | 5.087 | 16.296 | 10 |
Arrays:aIntArray[i] = 1 [count=1000 scale=10.0] | 0.616 | 0.638 | 0.071 | 0.612 | 0.850 | 10 |
Delegates:aInstanceDelegate() [count=1000 scale=10.0] | 1.233 | 1.244 | 0.088 | 1.160 | 1.398 | 10 |
PInvoke:FullTrustCall() [count=1000] | 7.452 | 6.946 | 0.804 | 5.878 | 7.913 | 10 |
Locks:Monitor lock [count=1000] | 11.487 | 12.129 | 0.901 | 11.322 | 13.843 | 10 |
为使时间测量更有意义,这些测量都被规范化,以便使从空的静态函数中调用(及返回)的中值时间为一个单位。基准得到的时间经常会差距很大,而这正是所有统计信息都很重要的原因。请注意 FinalizableClass 基准的最小 (71.299) 和最大 (953.864) 时间之间的巨大差异。在接受该基准数据之前,需要对出现这种差异的原因给出合理的解释。在那种特殊的情况下,它是由于运行时定期执行较慢的代码路径以批量分配记帐数据结构而导致的。正如我曾说过的,得到这些统计数据对于验证数据非常有用。
此表格包含大量有用的性能数据,详细列出了以 .NET 为目标的代码所使用的大多数基元操作的开销。我将在本专栏下一部分中做详细介绍,在这里我只想解释 MeasureIt 的一个重要功能:它与其源代码一同提供。要解压缩 MeasureIt 的源代码并启动 Visual Studio
® 进行浏览(如果 Visual Studio 可供使用),请键入:
MeasureIt /edit
有了源代码就意味着您可以快速准确地了解基准所测量的内容。它还表示您可以轻松地向套件中添加新基准。
同样,我在配置文件数据处理程序方面的经验非常具有指导意义。在设计时,我可以通过 C# 事件、委托、虚拟方法或接口等执行特定的常见操作。要制定决策,我需要了解这些选择的性能并在其中进行权衡取舍。我可以在几分钟之内编写出微基准来测量每种备选方案的性能。
图 2 显示的是相关的行,您可以看到在这些备选方案之间没有本质的差别。了解这一点后,我可以选择最适合的选项,并且确定这样做不会牺牲性能。
![](http://i.msdn.microsoft.com/Global/Images/clear.gif)
测量的操作 | 中值 | 平均值 | 标准偏差 | 最小值 | 最大值 | 示例 |
---|---|---|---|---|---|---|
MethodCalls:aClass.Interface() [count=1000 scale=10.0] | 1.651 | 1.660 | 0.084 | 1.579 | 1.814 | 10 |
MethodCalls:aClass.VirtualMethod() [count=1000 scale=10.0] | 1.228 | 1.175 | 0.077 | 1.083 | 1.277 | 10 |
Delegates:aInstanceDelegate() [count=1000 scale=10.0] | 1.151 | 1.159 | 0.085 | 1.075 | 1.314 | 10 |
Events:Fire Events [count=1000 scale=10.0] | 1.228 | 1.195 | 0.070 | 1.088 | 1.291 | 10 |
验证性能结果
MeasureIt 应用程序可以非常容易地收集各种不同基准的数据。遗憾的是,MeasureIt 未能解决使用基准数据的一个重要问题:验证。极有可能您所测量的并不是您预期要测量的内容。结果得到的是错误的数据,这甚至比得到无用的数据更糟糕。成语“过犹不及”非常适合于描述性能数据。对所有要在重要设计决策中使用的数据进行验证是必不可少的环节。
使用调试器验证微基准数据
验证性能结果是指什么?它是指收集其他同样能够预测性能结果的信息并加以比较,看一看这两种方法得到的结果是否吻合。对于非常小的微基准而言,检查机器指令并根据执行的指令数进行评估是一种非常不错的检查方法。在类似 Visual Studio 的调试器中,这会非常简单,就如同在基准代码中设置断点并切换到反汇编窗口一样(“Debug”(调试)->“Windows”(窗口)->“Disassembly”(反汇编))。遗憾的是,Visual Studio 的默认选项旨在简化调试工作,而不是执行检查,因此您需要更改两个选项才能完成此工作。
首先,转到“Tools”(工具)|“Options...”(选项...)|“Debugging”(调试)|“General”(常规),然后清除“Suppress JIT Optimization”(禁止 JIT 优化)复选框。此复选框默认是被选中的,表示即使在调试应该优化的代码时,调试器也告诉运行时不要优化。调试器这样做是为了使本地变量检查不会受到优化操作的干扰,但它也意味着您所看到的代码不是实际运行的代码。我总是取消选中此选项,因为我坚信调试器应侧重于检查工作,不要更改所调试的程序。请注意,取消设置此选项对编译的待调试代码没有任何影响,因为运行时无论如何都不会优化该代码。
接下来,从“Tools”(工具)|“Options”(选项)|“Debugging”(调试)|“General”(常规)对话框中清除“Enable Just My Code”(仅启用我的代码)复选框。“Just My Code”(仅我的代码)功能指示调试器不要显示那些不是由您编写的代码。通常,此功能将删除应用程序开发人员通常不需要关注的杂乱的调用框架。但是,此功能假定任何优化过的代码都不是您编写的代码(它假定您的代码是使用调试配置编译的,或“suppressed JIT Optimizations”(禁止 JIT 优化)是开启的)。如果允许 JIT 优化但未关闭“Just My Code”(仅我的代码),您将发现您永远无法到达任何断点,因为调试器不认为您编写的代码是您的。
当您取消选中这些选项后,它们将在所有项目中都被取消选中。通常情况下这不会有什么问题,但这意味着您将无法使用“Just My Code”(仅我的代码)功能。在调试和性能评估之间转换时,您可能会发现自己正在切换“Just My Code”(仅我的代码)的开关状态。
作为使用调试器对性能结果进行验证的一个示例,您可以对
图 3 中所示数据的异常进行调查。此数据显示调用 C# 结构的接口方法比调用静态方法要快好几倍。鉴于您一直认为静态方法调用应该是最有效的调用类型,此结果显然非常让人疑惑。要对此进行调查,您可以在此基准中设置一个断点并运行应用程序。切换到反汇编窗口(“Debug”(调试)->“Windows”(窗口)->“Disassembly”(反汇编)),可以看到整个基准仅包含以下代码:
![](http://i.msdn.microsoft.com/Global/Images/clear.gif)
测量的操作 | 中值 | 平均值 | 标准偏差 | 最小值 | 最大值 | 示例 |
---|---|---|---|---|---|---|
MethodCalls:EmptyStaticFunction() [count=1000 scale=10.0] | 1.000 | 0.964 | 0.102 | 0.857 | 1.196 | 10 |
MethodCalls:aStructWithInterface.Interface() [count=1000 scale=10.0] | 0.031 | 0.029 | 0.012 | 0.021 | 0.039 | 10 |
aStructWithInterface.InterfaceMethod(); 00000000 ret
这里显示的内容表明此基准(对接口方法的 10 个调用)已经内联消失了。ret 指令实际上是用来定义整个基准的委托主体的结束点。这样看来,什么都不执行自然要比执行方法调用的速度快,因此这就解释了出现该异常的原因。
唯一的神秘之处在于为什么静态方法不会同样内联呢?这是因为对于静态方法而言,我专门通过 MethodImplOptions.NoInlining 属性禁用了内联。我故意“忘记”将它放到接口调用基准中,以证明 JIT 编译器可以像一些非虚拟调用(在上面的基准中有一处注释提到了这一点)一样高效率地进行特定的接口调用。
结束语
再次强调,很有可能您所测量的并不是您预期要测量的内容,特别是测量用于 JIT 编译器优化的小操作时。有时还很可能意外测量到未优化的代码,或者测量到某方法的 JIT 编译的开销,而不是方法本身的开销。MeasureIt /usersGuide 命令将会显示用户指南,其中讨论了创建基准时可能会遇到的许多陷阱。我强烈建议您在准备编写自己的基准之前仔细阅读这些详细信息。
我想强调的是验证的概念。如果您无法解释您的数据,则不应该使用它来制定设计决策。如果得到了异常的数据,您应收集更多的数据、调试基准,或者与其他更专业的人员协作,直到能够解释您的数据为止。您应对无法解释的数据持高度怀疑的态度,并且不应在制定任何重要决策时使用它。
本文讨论有关编写高性能应用程序的基础知识。与软件的任何其他属性一样,良好的性能需要在产品设计之初就加以考虑。为实现此目的,您需要量化在制定不同设计决策时作为各种取舍依据的测量值。也就是要进行性能实验。MeasureIt 能够方便快捷地生成高质量的微基准,因此成为设计过程中不可或缺的一部分。MeasureIt 还是一款非常实用的现成工具,因为它自带了一组涵盖 .NET Framework 中大部分基元操作的基准。
您还可以轻松地为 .NET Framework 中您最感兴趣的部分添加自己的基准。利用此数据,您可以构建应用程序开销模型,并因此可以在编写应用程序代码之前对设计备选项的性能做出合理的(大致)猜测。
有关 .NET 中应用程序性能方面的问题,还有很多内容需要介绍。在构建微基准时有许多潜在的陷阱,因此在编写任何基准之前请务必先阅读 MeasureIt 用户指南。我还推迟了对作为主要瓶颈的磁盘 I/O、内存或锁竞争等情况的讨论。我甚至还未讨论如何在设计好应用程序后,使用各种剖析工具来验证并监控应用程序的性能运行情况。
需要了解的内容还有很多,而大量的信息往往会使开发人员对此类测试望而却步。但是,由于大部分性能损失都发生在应用程序的设计阶段,所以如果想避免麻烦,最好在最初阶段就要考虑到性能问题。我希望本专栏能够鼓励您在设计下一个 .NET 软件项目时,将性能作为设计中明确考虑的一部分。
请将您想询问的问题和提出的意见发送至 clrinout@microsoft.com.
Vance Morrison 是 CLR 团队的编译器架构师,从 .NET 问世之初,他就一直在 Microsoft 从事 .NET 的设计工作。他一直在推动 .NET 中间语言 (IL) 的设计发展并担任实时 (JIT) 编译器团队的负责人。