iihero@ChinaUnix, ehero.[iihero] 数据库技术的痴迷爱好者. 您可以通过iihero AT qq.com联系到我 以下是我的三本图书: Sybase ASE in Action, Oracle Spatial及OCI高级编程, Java2网络协议内幕
分类: C/C++
2013-07-17 13:39:43
Jan Gray
Microsoft CLR Performance Team
2003 年 6 月
适用于:
Microsoft? .NET Framework
摘要:本文介绍托管代码执行时间的低级操作开销模型,该模型是通过测量操作时间得到的,开发人员可以据此做出更好的编码决策并编写更快的代码。
下载 。(330KB)
实现计算的方法有无数种,但这些方法良莠不齐,有些方法远胜于其他方法:更简单,更清晰,更容易维护。有些方法速度很快,有些却慢得出奇。
不要错用那些速度慢、内容臃肿的代码。难道您不讨厌这样的代码吗:不能连续运行的代码、不时将用户界面锁定几秒种的代码、顽固占用 CPU 或严重损害磁盘的代码?
千万不要用这样的代码。相反,请站起来,和我一起宣誓:
“我保证,我不会向用户提供慢速代码。速度是我关注的特性。每天我都会注意代码的性能。我会经常地、系统地‘测量’代码的速度和大小。我将学习、构建或购买为此所需的工具。这是我的责任。”
(我保证。)你是这样保证的吗?非常好。
那么,怎样才能在日常工作中编写出最快、最简洁的代码呢?这就要不断有意识地优先选择节俭的方法,而不要选择浪费、臃肿的方法,并且要深入思考。即使是任意指定的一段代码,都会需要许多这样的小决定。
但是,如果不知道开销的情况,就无法面对众多方案作出明智的选择:如果您不知道开销情况,也就无法编写高效的代码。
在过去的美好日子里,事情要容易一些,好的 C 程序员都知道。C 中的每个运算符和操作,不管是赋值、整数或浮点数学、解除引用,还是函数调用,都在不同程度上一一对应着单一的原始计算机操作。当然,有时会需要数条计算机指令来将正确的操作数放置在正确的寄存器中,而有时一条指令就可以完成几种 C 操作(比较著名的是 *dest++ = *src++;),但您通常可以编写(或阅读取)一行 C 代码,并知道要花费多少时间。对于代码和数据,C 编译器具有所见即所得的特点 -“您编写的就是您得到的”。(例外的情况是函数调用。如果不知道函数的开销,您将无法知道其花费的时间。)
到了 20 世纪 90 年代,为了将数据抽象、面向对象编程和代码复用等技术更好地用于软件工程和生产,PC 软件业将 C 发展为 C++。
C++ 是 C 的超集,并且是“使用才需付出”,即如果不使用,新功能不会有任何开销。因此,C 的专用编程技术,包括其内在的开销模型,都可以直接应用。如果编写一段 C 代码并用 C++ 重新编译这段代码,则执行时间和空间的系统开销不会有太大变化。
另一方面,C++ 引入了许多新的语言功能,包括构造函数、析构函数、New、Delete、单继承、多继承、虚拟继承、数据类型转换、成员函数、虚函数、重载运算符、指向成员的指针、对象数组、异常处理和相同的复合,这些都会造成许多不易察觉但非常重要的开销。例如,每次调用虚函数时都要花费两次额外的定位,而且还会将隐藏的 vtable 指针字段添加到每个实例中。或者,考虑将这段看起来比较安全的代码:
{ complex a, b, c, d; ... a = b + c * d; }
编译为大约十三个隐式成员函数调用(但愿是内联的)。
九年前,在我的文章 (英文)中曾探讨过这个主题,我写道:
“了解编程语言的实现方式是非常重要的。这些知识可以让我们消除‘编译器到底在做些什么?’的恐惧和疑虑,让我们有信心使用新功能,并使我们在调试和学习其他的语言功能时更具洞察力。这些知识还能使我们认识到各种编码方案的相对开销,而这正是我们在日常工作中编写出最有效的代码所必需的。”
现在,我们将以同样的方式来了解托管代码。本文将探讨托管执行的“低级”时间和空间开销,以使我们能够在日常的编码工作中权衡利弊,做出明智的判断。
并遵守我们的承诺。
对大多数本机代码的开发人员来说,托管代码为运行他们的软件提供了更好、更有效率的平台。它可以消除整类错误,如堆损坏和数组索引超出边界的错误,而这些错误常常使深夜的调试工作无功而返。它支持更为现代的要求,如安全移动代码(通过代码访问安全性实现)和 XML Web Service,而且与过去的 Win32/COM/ATL/MFC/VB 相比,.NET Framework 更加清楚明了,利用它可以做到事半功倍。
对软件用户来说,托管代码为他们提供了更丰富、更健壮的应用程序,让他们通过更优质的软件享受更好的生活。
尽管可以做到事半功倍,但还是不能放弃认真编码的责任。首先,您必须承认:“我是个新手。”您是个新手。我也是个新手。在托管代码领域中,我们都是新手。我们仍然在学习这方面的诀窍,包括开销的情况。
面对功能丰富、使用方便的 .NET Framework,我们就像糖果店里的孩子:“哇,不需要枯燥的 strncpy,只要把字符串‘+’在一起就可以了!哇,我可以在几行代码中加载一兆字节的 XML!哈哈!”
一切都是那么容易。真的是很容易。即使是从 XML 信息集中提出几个元素,也会轻易地投入几兆字节的 RAM 来分析 XML 信息集。使用 C 或 C++ 时,这件事是很令人头疼的,必须考虑再三,甚至您会想在某些类似 SAX 的 API 上创建一个状态机。而使用 .NET Framework 时,您可以在一口气加载整个信息集,甚至可以反复加载。这样一来,您的应用程序可能就不再那么快了。也许它的工作集达到了许多兆字节。也许您应该重新考虑一下那些简单方法的开销情况。
遗憾的是,在我看来,当前的 .NET Framework 文档并没有足够详细地介绍 Framework 的类型和方法的性能含义,甚至没有具体指明哪些方法会创建新对象。性能建模不是一个很容易阐述的主题,但是“不知道”会使我们更难做出恰当的决定。
既然在这方面我们都是新手,又不知道任何开销情况,而且也没有什么文档可以清楚说明开销情况,那我们应该做些什么呢?
测量,对开销进行测量。秘诀就是“对开销进行测量”并“保持警惕”。我们都应该养成测量开销的习惯。如果我们不怕麻烦去测量开销,就不会轻易调用比我们“假设”的开销高出十倍的新方法。
(顺便说一下,要更深入地了解 BCL [基类库] 的性能基础或 CLR,请查看 [英文],又称 Rotor。Rotor 代码与 .NET Framework 和 CLR 属于同一类别,但并不是完全相同的代码。不过即使是这样,我保证在认真学习 Rotor 之后,您会对 CLR 有更新、更深刻的理解。但一定保证首先要审核 SSCLI 许可证!)
如果您想成为伦敦的出租车司机,首先必须学习 (英文)。学生们通过几个月的学习,要记住伦敦城里上千条的小街道,还要了解到达各个地点的最佳路线。他们每天骑着踏板车四处查看,以巩固在书本上学到的知识。
同样,如果您想成为一名高性能托管代码的开发人员,您必须获得“托管代码知识”。您必须了解每项低级操作的开销,必须了解像委托 (Delegate) 和代码访问安全等这类功能的开销,还必须了解正在使用以及正在编写的类型和方法的开销。能够发现哪些方法的开销太大,对您的应用程序不会有什么损害,反倒因此可以避免使用这些方法。
这些知识不在任何书本中,也就是说,您必须骑上自己的踏板车进行探索:准备好 csc、ildasm、VS.NET 调试器、CLR 分析器、您的分析器、一些性能计时器等,了解代码的时间和空间开销。
让我们开门见山地谈谈托管代码的开销模型。利用这种模型,您可以查看叶方法,能马上判断出开销较大的表达式或语句,而在您编写新代码时,就可以做出更明智的选择。
(有关调用您的方法或 .NET Framework 方法所需的可传递的开销,本文将不做介绍。这些内容以后会在另一篇文章中介绍。)
之前我曾经说过,大多数的 C 开销模型仍然适用于 C++ 方案。同样,许多 C/C++ 开销模型也适用于托管代码。
怎么会这样呢?您一定了解 CLR 执行模型。您使用几种语言中的一种来编写代码,并将其编译成 CIL(公用中间语言)格式,然后打包成程序集。当您运行主应用程序的程序集时,它开始执行 CIL。但是不是像旧的字节码解释器一样,速度会非常慢?
不,它一点也不慢。CLR 使用 JIT(实时)编译器将 CIL 中的各种方法编译成本机 x86 代码,然后运行本机代码。尽管 JIT 在编译首次调用的方法时会稍有延迟,但所调用的各种方法在运行纯本机代码时都不需要解释性的系统开销。
与传统的脱机 C++ 编译过程不同,JIT 编译器花费的时间对用户来说都是“时钟时间”延迟,因此 JIT 编译器不具备占用大量时间的彻底优化过程。尽管如此,JIT 编译器所执行的一系列优化仍给人以深刻印象:
结果可以与传统的本机代码相媲美,至少是相近。
至于数据,可以混合使用值类型和引用类型。值类型(包括整型、浮点类型、枚举和结构)通常存储在栈中。这些数据类型就像 C/C++ 中的本地和结构一样又小又快。使用 C/C++ 时,应该避免将大的结构作为方法参数或返回值进行传送,因为复制的系统开销可能会大的惊人。
引用类型和装箱后的值类型存储在堆中。它们通过对象引用来寻址,这些对象引用只是计算机的指针,就像 C/C++ 中的对象指针一样。
因此实时编译的托管代码可以很快。下面我们将讨论一些例外,如果您深入了解了本机 C 代码中某些表达式的开销,您就不会像在托管代码中那样错误地为这些开销建模。
我还应该提一下 NGEN,这是一种“超前的”工具,可以将 CIL 编译为本机代码程序集。尽管利用 NGEN 编译程序集在当前并不会对执行时间造成什么实质性的影响(好的或坏的影响),却会使加载到许多应用程序域和进程中的共享程序集的总工作集减少。(操作系统可以跨所有客户端共享一份利用 NGEN 编译的代码,而实时编译的代码目前通常不会跨应用程序域或进程共享。请参阅 LoaderOptimizationAttribute.MultiDomain [英文]。)
托管代码与本机代码的最大不同之处在于自动内存管理。您可以分配新的对象,但 CLR 垃圾回收器 (GC) 会在这些对象无法访问时自动释放它们。GC 不时地运行,通常不为人觉察,但一般会使应用程序停止一两毫秒,偶尔也会更长一些。
有一些文章探讨了垃圾回收器的性能含义,这里就不作介绍了。如果您的应用程序遵循这些文章中的建议,那么总的内存回收开销就不会很大,也就是百分之几的执行时间,与传统的 C++ 对象 new 和 delete 大致相当或者更好一些。创建对象以及后来的自动收回对象的分期开销非常低,这样就可以在每秒钟内创建数千万个小对象。
但仍不能“免费”分配对象。对象会占用空间。无限制的对象分配将会导致更加频繁的内存回收。
更糟糕的是,不必要地持续引用无用的对象图 (Object Graph) 会使对象保持活动。有时,我们会发现有些不大的程序竟然有 100 MB 以上的工作集,可是这些程序的作者却拒绝承认自己的错误,反而认为性能不佳是由于托管代码本身存在一些神秘、无法确认(因此很难处理)的问题。这真令人遗憾。但是,只需使用 CLR 编译器花一个小时做一下研究,更改几行代码,就可以将这些程序用到的堆减少十倍或更多。如果您遇上大的工作集问题,第一步就应该查看真实的情况。
因此,不要创建不必要的对象。由于自动内存管理消除了许多对象分配和释放方面的复杂情况、问题和错误,并且用起来又快又方便,因此我们会很自然地想要创建越来越多的对象,最终形成错综复杂的对象群。如果您想编写真正的快速托管代码,创建对象时就需要深思熟虑,确保对象的数量合适。
这也适用于 API 的设计。由于可以设计类型及其方法,因此它们会要求客户端创建可以随便放弃的新对象。不要那样做。
现在,让我们来研究一下各种低级托管代码操作的时间开销。
表 1 列出了各种低级托管代码操作的大致开销,单位是毫微秒。这些数据是在配备了 1.1 GHz Pentium-III、运行了 Windows XP 和 .NET Framework v1.1 (Everett) 的静止 PC 上通过一套简单的计时循环收集到的。
测试驱动程序调用各种测试方法,指定要执行的多个迭代,自动调整为迭代 218 到 230 次,并根据需要使每次测试的时间不少于 50 毫秒。一般情况下,这么长的时间足可以在一个进行密集对象分配的测试中观察几个 0 代内存回收周期。该表显示了 10 次实验的平均结果,对于每个测试主题,都列出了最好(最少时间)的实验结果。
根据需要,每个测试循环都展开 4 至 60 次,以减少测试循环的系统开销。我检查了每次测试生成的主机代码,以确保 JIT 编译器没有将测试彻底优化,例如,我修改了几个示例中的测试,以使中间结果在测试循环期间和测试循环之后都存在。同样,我还对几个测试进行了更改,以使通用子表达式消除不起作用。
表 1:原语时间(平均和最小)(ns)
平均 | 最小 | 原语 | 平均 | 最小 | 原语 | 平均 | 最小 | 原语 |
---|---|---|---|---|---|---|---|---|
0.0 | 0.0 | Control | 2.6 | 2.6 | new valtype L1 | 0.8 | 0.8 | isinst up 1 |
1.0 | 1.0 | Int add | 4.6 | 4.6 | new valtype L2 | 0.8 | 0.8 | isinst down 0 |
1.0 | 1.0 | Int sub | 6.4 | 6.4 | new valtype L3 | 6.3 | 6.3 | isinst down 1 |
2.7 | 2.7 | Int mul | 8.0 | 8.0 | new valtype L4 | 10.7 | 10.6 | isinst (up 2) down 1 |
35.9 | 35.7 | Int div | 23.0 | 22.9 | new valtype L5 | 6.4 | 6.4 | isinst down 2 |
2.1 | 2.1 | Int shift | 22.0 | 20.3 | new reftype L1 | 6.1 | 6.1 | isinst down 3 |
2.1 | 2.1 | long add | 26.1 | 23.9 | new reftype L2 | 1.0 | 1.0 | get field |
2.1 | 2.1 | long sub | 30.2 | 27.5 | new reftype L3 | 1.2 | 1.2 | get prop |
34.2 | 34.1 | long mul | 34.1 | 30.8 | new reftype L4 | 1.2 | 1.2 | set field |
50.1 | 50.0 | long div | 39.1 | 34.4 | new reftype L5 | 1.2 | 1.2 | set prop |
5.1 | 5.1 | long shift | 22.3 | 20.3 | new reftype empty ctor L1 | 0.9 | 0.9 | get this field |
1.3 | 1.3 | float add | 26.5 | 23.9 | new reftype empty ctor L2 | 0.9 | 0.9 | get this prop |
1.4 | 1.4 | float sub | 38.1 | 34.7 | new reftype empty ctor L3 | 1.2 | 1.2 | set this field |
2.0 | 2.0 | float mul | 34.7 | 30.7 | new reftype empty ctor L4 | 1.2 | 1.2 | set this prop |
27.7 | 27.6 | float div | 38.5 | 34.3 | new reftype empty ctor L5 | 6.4 | 6.3 | get virtual prop |
1.5 | 1.5 | double add | 22.9 | 20.7 | new reftype ctor L1 | 6.4 | 6.3 | set virtual prop |
1.5 | 1.5 | double sub | 27.8 | 25.4 | new reftype ctor L2 | 6.4 | 6.4 | write barrier |
2.1 | 2.0 | double mul | 32.7 | 29.9 | new reftype ctor L3 | 1.9 | 1.9 | load int array elem |
27.7 | 27.6 | double div | 37.7 | 34.1 | new reftype ctor L4 | 1.9 | 1.9 | store int array elem |
0.2 | 0.2 | inlined static call | 43.2 | 39.1 | new reftype ctor L5 | 2.5 | 2.5 | load obj array elem |
6.1 | 6.1 | static call | 28.6 | 26.7 | new reftype ctor no-inl L1 | 16.0 | 16.0 | store obj array elem |
1.1 | 1.0 | inlined instance call | 38.9 | 36.5 | new reftype ctor no-inl L2 | 29.0 | 21.6 | box int |
6.8 | 6.8 | instance call | 50.6 | 47.7 | new reftype ctor no-inl L3 | 3.0 | 3.0 | unbox int |
0.2 | 0.2 | inlined this inst call | 61.8 | 58.2 | new reftype ctor no-inl L4 | 41.1 | 40.9 | delegate invoke |
6.2 | 6.2 | this instance call | 72.6 | 68.5 | new reftype ctor no-inl L5 | 2.7 | 2.7 | sum array 1000 |
5.4 | 5.4 | virtual call | 0.4 | 0.4 | cast up 1 | 2.8 | 2.8 | sum array 10000 |
5.4 | 5.4 | this virtual call | 0.3 | 0.3 | cast down 0 | 2.9 | 2.8 | sum array 100000 |
6.6 | 6.5 | interface call | 8.9 | 8.8 | cast down 1 | 5.6 | 5.6 | sum array 1000000 |
1.1 | 1.0 | inst itf instance call | 9.8 | 9.7 | cast (up 2) down 1 | 3.5 | 3.5 | sum list 1000 |
0.2 | 0.2 | this itf instance call | 8.9 | 8.8 | cast down 2 | 6.1 | 6.1 | sum list 10000 |
5.4 | 5.4 | inst itf virtual call | 8.7 | 8.6 | cast down 3 | 22.0 | 22.0 | sum list 100000 |
5.4 | 5.4 | this itf virtual call | 21.5 | 21.4 | sum list 1000000 |
免责声明:请不要照搬这些数据。时间测试会由于无法预料的二次影响而变得不准确。偶然事件可能会使实时编译的代码或某些关键数据跨过缓存行,影响其他的缓存或已有数据。这有点像不确定性原则:1 毫微秒左右的时间和时间差异是可观察到的范围限度。
另一项免责声明:这些数据只与完全适应缓存的小代码和数据方案有关。如果应用程序中最常用的部分不适应芯片缓存,您可能会遇到其他的性能问题。本文的结尾将详细介绍缓存。
还有一项免责声明:将组件和应用程序作为 CIL 的程序集的最大好处之一是,您的程序可以做到每秒都变快、每年都变快。“每秒都变快”是因为运行时(理论上)可以在程序运行时重新调整 JIT 编译的代码;“每年都变快”是因为新发布的运行时总能提供更好、更先进、更快的算法以将代码迅速优化。因此,如果 .NET 1.1 中的这几个计时不是最佳结果,请相信在以后发布的产品中它们会得到改善。而且在今后发布的 .NET Framework 中,本文中所列代码的本机代码序列可能会更改。
不考虑这些免责声明,这些数据确实让我们对各种原语的当前性能有了充分的认识。这些数字很有意义,并且证实了我的判断,即大多数实时编译的托管代码可以像编译过的本机代码一样,“接近计算机”运行。原始的整型和浮点操作很快,而各种方法调用却不太快,但(请相信我)仍可比得上本机 C/C++。同时我们还会发现,有些通常在本机代码中开销不太大的操作(如数据类型转换、数组和字段存储、函数指针 [委托])现在的开销却变大了。为什么是这样呢?让我们来看一下。
。。。。