CLR 全面透彻解析: 及早并经常评量性能,第 2 部分

CLR 全面透彻解析
及早并经常评量性能,第 2 部分
Vance Morrison


代码下载位置: CLRInsideOut2008_05.exe (205 KB)
Browse the Code Online

在上期的“CLR 全面透彻解析”中,我强调要可靠地创建高性能的程序,您需要了解设计初期所使用的各个组件的性能(msdn2.microsoft.com/magazine/cc424899)。这就需要用到性能数据。因此,测量是设计过程中不可或缺的一部分。
我还在那一期中介绍了一款名为 MeasureIt 的工具,利用它可以轻松地创建新基准,从而快速地收集制定良好设计决策所需的数据。诸如 MeasureIt 之类的工具所提供的原始数字是极为重要的,另外,使人们能够了解这些数字的基本含义也非常重要。在此理解的基础上,您在实际测量之前即可预测出它的某些性能。这就是我将在这里讨论的内容。

 

MeasureIt 概述
如果您尚未下载 MeasureIt 工具,我强烈建议您立即下载。该工具位于《MSDN ® 杂志》网站中本专栏的下载代码内,由一个 EXE 文件组成。运行它将生成一个网页,显示运行某个基准套件的结果。安装后,可通过运行以下命令访问其他文档:
measureIt /usersGuide
MeasureIt 随同其源代码一起出现,使用 /edit 限制符可以方便地将其解压缩。这使得添加新基准就像编写一两行代码(并提供要定时的代码)一样轻松。有关如何执行此操作的更多详细说明,请参阅用户指南。
MeasureIt 基准与不同的性能区域相关联,当该工具启动时会在命令行上指示出来。 默认情况下(即无命令行参数),MeasureIt 会运行一组基准(约 50 个),其中包括各种基本的 Microsoft ® .NET Framework 运行时操作。 图 1 中显示了简化的示例输出。


名称中值平均值标准偏差最小值最大值示例
NOTHING [count=1,000]0.0000.0370.1100.0000.36610
MethodCalls:EmptyStaticFunction() [count=1000 scale=10.0]1.0001.1030.4960.8572.57710
ObjectOps:new Class() [count=1000 scale=10.0]5.06010.22313.9273.34051.21510
ObjectOps:new FinalizableClass() [count=1000 scale=10.0]78.552155.408168.59564.997629.24310
ObjectOps:(Class) Activator.CreateInstance(classType)]102.510102.9494.07696.876109.81910
Arrays:localIntPtr[i] = 1 [count=1,000 scale=10.0]0.7130.6640.0760.5740.77310
Arrays:string[i] = aString [count=1,000 scale=10.0]3.4023.4050.0123.3973.44210
Delegates:aInstanceDelegate() [count=1,000 scale=10.0]1.2351.2050.1111.0941.47510
MethodReflection:Method.Invoke EmptyStaticFunction()472.283472.7445.409466.291482.09410
P/Invoke:FullTrustCall() [count=1,000]6.1846.2540.7935.4697.59910
P/Invoke:10 FullTrustCall() (10 call average)2.6692.6880.0612.6652.87010
P/Invoke:1 PartialTrustCall [count=1,000]27.80630.4408.73526.34356.58210

MeasureIt 将每个基准运行 10 次,并根据结果计算统计数据。这些报告值随即被标量化,从而使对空方法的单个调用花费一个时间单位。例如, 图 1 显示分配某个小对象的中值时间为 5.06,这表示通常分配小对象仅是调用方法所花费时间的五倍多一点。但并非所有的情况都是如此。请注意,对象分配的最长时间要超过 51 个单位。因此,它所花费时间经常要比平均情况更长。事实上,如果该基准不幸强制收集大量碎片,则其在方法中花费的时间很可能要比此处报告的最大值多出许多。
即便如此,您还是应该能发现 MeasureIt 工具的价值。几乎无需进行任何工作,您即可粗略地获知小型分配的开销如何。由于工具会收集多个示例并计算统计数据,因此您还会了解到某些操作(如对象分配)可具有相当多的变化,这才是重点。通过了解最小值、最大值和标准偏差信息,您可以确定是否足以信赖测量的可靠性。

 

几点快速观测结论
图 1 使您大致地了解了在 .NET Framework 中小型对象分配的开销。您可以由此迅速推断出更多信息。例如,您可以猜测到与没有终结器的对象相比,有终结器(在 C# 中声明为 ~ClassName())的对象其开销要多出 10 倍不止。更糟的是这些终结器会被继承,这意味着任何子类的实例分配都会处于类似的境遇。因此,通常应尽可能地在将具有多个实例的类中避免使用终结器。
您还可以推断出与正常分配对象相比,使用反射(如 Activator.CreateInstance 中所示)分配对象的开销要多出 10 倍不止。因此使用反射 API 时,其基本原则是:它们比其静态对等项的开销要昂贵得多,如性能要求特别严格,则不应使用它们。
此外,从数据中得出的另一个推断是:与静态调用方法相比,使用反射(如 MethodInfo.Invoke 中所示)调用方法要慢 450 倍乃至更多(比值如此之高的部分原因是常规调用方法的开销很低)。同样地,还可以推断出数组访问的开销也会很低(少于方法调用的开销),但在对象引用(如 string[] 中所示)的数组中设置元素的开销是一般数组集的 4 倍以上。您还可以从 图 1 中看出,调用委托(指向某个方法的指针)在编译时间上仅仅比调用其目标已知的方法慢 20%。
最后,您可以得出结论:当禁止安全检查时,调用非托管代码 (P/Invoke) 的开销并不昂贵(普通方法调用的 6 倍),如果从同一方法中多次调用会使平均开销更为低廉(2.6 倍)。但在使用安全检查时(默认设置),开销将显著增加(普通方法调用的 27 至 30 倍)。
目前,已经能通过内置的 MeasureIt 基准得出一些有用的观测结论。此外,由于下载的 MeasureIt 内含源代码,您可以获得所有必需的详细信息以对数据进行更深入的研究。例如,您可能想要了解如何准确地禁止 P/Invoke 调用的安全检查(或了解如何首先调用本机代码)。要执行此操作,请使用以下命令解压缩源代码:
measureIt /edit 
然后搜索 P/Invoke 基准。您将获得所需的确切代码,然后根据自己的目的进行分析、调试,甚至是修改。

 

验证数据
MeasureIt 可以快速地告知您 .NET 运行时进行某些基本操作的开销,但无法告诉您这些数字是如何得来的。很有可能您当前测量的对象并不是您所要测量的对象。
在上月的专栏中,我着重指出极易创建误导基准(特别是微基准),强调了在信任性能测量前对其进行验证的重要性。请牢记,您还必须验证内置基准中的数据。
这就是 .NET 运行时内部的某些专业技术很有用的原因所在。在具体地了解了运行时内的操作如何转换为机器指令后,我就能估计出各种操作所花费的时间。 图 2 中对此进行了总结。由于硬件自身进行了优化,指令数并不与执行时间精确对应;尽管如此,它们提供了良好的近似预测。如果您发现指令执行时间远超出您根据指令数得出的预期时间,很有可能是数据不正确。最好将 图 2 中所示数据视为操作的定量法,它们极好地佐证了 MeasureIt 的定量法。


操作指令数注释
整数运算1不受限的简单运算可编译为其对应的机器指令,开销通常小于一个机器周期。
浮点运算1编译为 x87 指令(对于 32 位)。目前,运行时无法执行向量化或充分利用较新的硬件指令 (SSE2),因此,通常最佳的非托管编译器能较好地支持内含浮点的应用程序。
实例字段提取或设置1-10大多数操作都采取 1 条指令。但设置作为对象参照(非基元类型)的字段需要使用花费 6-10 条指令的写屏障例程。
静态字段提取1-12通常情况下,在一个 AppDomain 中运行的实时 (JIT) 编译代码仅需花费 1 条指令。但诸如 ASP.NET 此类的宿主可要求运行时生成所有 AppDomain 可共享(节省了大量的内存和 JIT 时间)的代码,开销是花费 12 条指令提取静态字段。JIT 编译器会将此类普通运算提升到循环以外,从而部分减轻这种开销。生成用本机映像生成器 (Ngen) 预编译的代码时,它不考虑所请求的代码共享,这意味着即使在最佳情况下,Ngen 生成的代码也将花费 6 条指令来提取静态字段。
非虚拟或静态方法调用1编译为单个调用指令,调用速度最快。
虚拟方法调用2使用类似 C++ 的调度表。这是速度最快的间接调用。
接口方法调用4-20当特定的调用点几乎始终具有同一目标时,接口方法通过极快(共花费 4 条指令)的存根进行调度。如将单个调用点调度到多个目标,将花费 10-20 条指令查找哈希表以进行调度。
委托调度4-15如果存在单个目标方法且其为实例(非静态)方法,则一般会花费 4 条指令,且其速度可比得上任何其他类型的间接调度。如果目标为静态,则需要混排参数来移除传递来的“这个”无用指针,这会耗费数条指令。如果某个委托具有多个订阅者(事件可做到这一点),则调度必须包含一个循环,开销也随之增大,但这种情况并不常见。
对象分配10-1,000+对于大多数对象,新代码路径将耗费 10-15 条指令;而某些对象类型(如可终结对象)开销会更多。无论怎样,分配都会导致后来增加大量开销。这其中包括清除内存的开销(与其大小成正比)和在对象存在时某些垃圾收集 (GC) 过程中的扫描开销。短时间存在的对象所导致的 GC 额外开销明显要比长期对象少得多,尽管如此,开销仍然过高,对注重性能的代码路径应将分配降至最小。
数组访问1-25数组访问通常需要耗费两条指令:边界检查和提取。对于简单的循环(您需要迭代数组中的所有元素),JIT 编译器可避免边界检查,使其简化为一个指令。事实上,这种优化会导致使用不安全的指针访问替代数组访问,它并不会显著提高性能。如果设置的元素是一个对象参照,则该设置需要写屏障和类型检查,这会将代码路径的大小增加至约 25 条指令。
转换4-100+成功地将对象转换为其确切的类型速度较快(4 条指令);但转换为超类的速度较慢,而转换为接口的速度更慢。转换数组的速度也非常慢。失败的转换(通常是使用 C# 的 "is" 或 "as" 运算符的情况)也相对较慢。
锁定20-1,000+即使在最理想的情况下,进入和退出锁(System.Monitor.Enter 或 c# lock 语句)的开销也是较为昂贵的(一个 call-ret 的 10 到 15 倍)。如果该锁存在争用,则速度显著变慢。
P/Invoke 调用15-1,000+在最理想的情况下(禁止安全检查且无需参数转换),从托管代码中调用本机代码 (P/Invoke) 需要 15-20 条指令。当其中有字符串传递(需要转换)和/或涉及 COM 互操作时要更慢一些。
反射1,000-10,000+根据类型(例如 System.Type.GetType)调用简单反射的开销较低(< 10 条指令);但使用反射 API 执行任何其他操作(如调用方法、设置字段或创建对象)的开销都明显比其他非反射替代方法更为昂贵(10 至 100 倍)。通常不应在注重性能的代码路径中使用反射。
泛型任意泛型类型的性能与其对应非泛型类型的性能相似。如果泛型类型的类型参数为类(而非结构),则该类型的所有实例之间将共享该代码。这种共享可节省空间,但同时意味着依赖类型参数的操作将比预期要慢。如果在注重性能的路径中使用泛型,则应进行测量。

图 2 中的信息对于验证 MeasureIt 所提供的性能数字非常有用,但只能供您对程序和各种设计权衡方法的开销进行粗略的预测。例如,您可以预测两种跨越托管-非托管 (P/Invoke) 边界的频率不同的代码构建设计之间的开销权衡。还可以预测通过避免使用反射方法节约的开销数量,或向程序中加锁以保证其线程安全的成本。

 

注意事项
就个人而言,我认为 MeasureIt 生成的数据对很多性能决策都非常有帮助。但内置基准的侧重点却是许多耗费 CPU 资源的操作。如果您的应用程序的性能不受 CPU 的限制,没有必要将重点放在 CPU 优化上。在下列三种情况中 CPU 不是关键的性能因素:应用程序响应时间取决于 I/O 开销或网络延迟;应用程序响应时间取决于内存缓存开销;以及在多线程应用程序中,应用程序响应时间受线程序列化延迟的影响。
某些应用程序的性能受它们操作磁盘和网络 I/O 的速度的影响,会比实际指令执行时间慢很多倍。当应用程序启动时,如果应用程序请求内存中尚未缓存的文件,则会发生此类情况(冷启动)。当应用程序消耗的内存导致许多页面错误时也会发生此类情况。通常,如果您的应用程序并未完全占用处理器资源,这些其他的延迟就凸显重要了。我在这里提供的大多数信息与这种情况并不相关,本专栏侧重的是找出与 CPU 相关的性能瓶颈。
对于其他的应用程序,响应时间取决于内存缓存的开销。当您使用大型的内存中的数据结构时通常会发生此情况。看上去该应用程序受到 CPU 的限制(它会占用所有处理器资源),但实际上 CPU 大多数时间是在等待内存子系统。您可能需要使用分析器访问内存子系统的统计数据以对此情况进行诊断,但较大的内存消耗 (> 50MB) 以及出现大量的页面错误一般表明内存问题是一个影响因素。在此情况下,您通常应当采用的设计策略是精简(即使这样会增加执行指令的数量)。
在多处理器硬件上运行多线程应用程序时,发生线程延迟的情况并不罕见,因为它们需要等待进入代码的关键节-而代码每次仅允许一个线程进行访问。随着并发执行线程数的增加,这些序列化延迟趋向于更为严重。这种锁竞争可表现为不受 CPU 限制(当等待时间较长,而等待线程进入休眠状态时)或受 CPU 限制(当等待时间很短,但发生频率极为频繁时)。
要将这些非 CPU 问题解释清楚以产生良好的设计直觉,可能还另外需要一个或两个专栏。只要记住,内存消耗很重要(特别是对于大型应用程序),当指令数适中时,您应尝试减小大于 1MB 的数据结构的尺寸。
但如果您的应用程序在其运行时占用所有 CPU 资源(页面错误很少或没有),在受 CPU 限制时(热数据结构均小于 1MB)消耗的内存不大,且并不属于依赖于共享数据的高度并行化应用程序,则您的性能问题很可能与所执行的指令数量相关。因此, 图 2 中的信息以及 MeasureIt 提供的数据在预测性能时将会很有用。

 

不均衡性能的前景
只要有可能,.NET 运行时团队就会尽力设法对操作进行优化。一般而言,这是件好事,但这确实预示着一种不均衡性能,因为很多情况下无法应用优化。我们已经看到了这样的一个示例:可终结的分配。这里提供了更多这样的示例。
首先,在某些情况下,特定的调用点往往会被调度到同一目标,此时,接口调用会进行高度优化。例如,您可以使某个例程采用 List<object> 并对每个元素调用 ICompareable.Compare。如果此操作传递了某个字符串“列表”,则接口调用会始终调用 String.Compare 且速度很快。 但如果该列表内包含多种不同的类型,则同一调用点必须调度给多个不同的目标。这后一种情况的速度会显著变慢。
其次,当需要将基元类型(如 int)传递到期望获得对象的操作中时,必须将值转换为对象(此操作被称为“装箱”)。这时需要对象分配,因此开销会超出预期。通常,编译器会自动插入此装箱,由于整个过程过于粗糙,因此可能会掉进此陷阱中。
再次,使用 C# 变量参量参数功能的方法(如 Console.WriteLine)需要为每次调用分配一个数组,还可能要为某些参数装箱(分配)对象。这使得该调用的开销远远大于普通调用(高出 10 倍)。这样的示例不胜枚举,但列出的这些已足以说明问题。
尽管了解运行时本质的人士会就上述某些事实对您提出警告,但实际情况要远超出所列的示例。这就是 图 2 中的信息和从 MeasureIt 中收集的数据只能用作粗略估计的原因。您永远不会知道自己何时会遇到性能糟糕的代码。因此,应时刻注意:如果热代码路径有可能未经优化,则您不应单独依靠从 图 2 中推断出的结论,而应编写快捷的微基准来精确表示热代码的路径并亲自进行测量。

 

请将您想询问的问题和提出的意见发送至 clrinout@microsoft.com.

 

Vance Morrison 是 .NET 运行时的编译器架构师,从 .NET 问世之初,他就一直在 Microsoft 从事 .NET 的设计工作。Vance 一直在推动 .NET 中间语言 (IL) 的设计发展并担任实时编译器团队的负责人。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值