• 软件测试技术
  • 软件测试博客
  • 软件测试视频
  • 开源软件测试技术
  • 软件测试论坛
  • 软件测试沙龙
  • 软件测试资料下载
  • 软件测试杂志
  • 软件测试人才招聘
    暂时没有公告

字号: | 推荐给好友 上一篇 | 下一篇

编写更快的托管代码:了解开销情况

发布: 2007-6-11 12:29 | 作者: 网络转载 | 来源: 网络 | 查看: 48次 | 进入软件测试论坛讨论

领测软件测试网

适用于:
   Microsoft® .NET Framework

摘要:本文介绍托管代码执行时间的低级操作开销模型,该模型是通过测量操作时间得到的,开发人员可以据此做出更好的编码决策并编写更快的代码。

下载 CLR Profiler。(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; }

编译为大约十三个隐式成员函数调用(但愿是内联的)。

九年前,在我的文章 C++:Under the Hood(英文)中曾探讨过这个主题,我写道:

“了解编程语言的实现方式是非常重要的。这些知识可以让我们消除‘编译器到底在做些什么?’的恐惧和疑虑,让我们有信心使用新功能,并使我们在调试和学习其他的语言功能时更具洞察力。这些知识还能使我们认识到各种编码方案的相对开销,而这正是我们在日常工作中编写出最有效的代码所必需的。”

现在,我们将以同样的方式来了解托管代码。本文将探讨托管执行的“低级”时间和空间开销,以使我们能够在日常的编码工作中权衡利弊,做出明智的判断。

并遵守我们的承诺。

为什么是托管代码?

对大多数本机代码的开发人员来说,托管代码为运行他们的软件提供了更好、更有效率的平台。它可以消除整类错误,如堆损坏和数组索引超出边界的错误,而这些错误常常使深夜的调试工作无功而返。它支持更为现代的要求,如安全移动代码(通过代码访问安全性实现)和 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,请查看 Shared Source CLI [英文],又称 Rotor。Rotor 代码与 .NET Framework 和 CLR 属于同一类别,但并不是完全相同的代码。不过即使是这样,我保证在认真学习 Rotor 之后,您会对 CLR 有更新、更深刻的理解。但一定保证首先要审核 SSCLI 许可证!)

知识

如果您想成为伦敦的出租车司机,首先必须学习 The Knowledge(英文)。学生们通过几个月的学习,要记住伦敦城里上千条的小街道,还要了解到达各个地点的最佳路线。他们每天骑着踏板车四处查看,以巩固在书本上学到的知识。

同样,如果您想成为一名高性能托管代码的开发人员,您必须获得“托管代码知识”。您必须了解每项低级操作的开销,必须了解像委托 (Delegate) 和代码访问安全等这类功能的开销,还必须了解正在使用以及正在编写的类型和方法的开销。能够发现哪些方法的开销太大,对您的应用程序不会有什么损害,反倒因此可以避免使用这些方法。

这些知识不在任何书本中,也就是说,您必须骑上自己的踏板车进行探索:准备好 csc、ildasm、VS.NET 调试器、CLR 分析器、您的分析器、一些性能计时器等,了解代码的时间和空间开销。

关于托管代码的开销模型

让我们开门见山地谈谈托管代码的开销模型。利用这种模型,您可以查看叶方法,能马上判断出开销较大的表达式或语句,而在您编写新代码时,就可以做出更明智的选择。

(有关调用您的方法或 .NET Framework 方法所需的可传递的开销,本文将不做介绍。这些内容以后会在另一篇文章中介绍。)

之前我曾经说过,大多数的 C 开销模型仍然适用于 C++ 方案。同样,许多 C/C++ 开销模型也适用于托管代码。

怎么会这样呢?您一定了解 CLR 执行模型。您使用几种语言中的一种来编写代码,并将其编译成 CIL(公用中间语言)格式,然后打包成程序集。当您运行主应用程序的程序集时,它开始执行 CIL。但是不是像旧的字节码解释器一样,速度会非常慢?

实时编译器

不,它一点也不慢。CLR 使用 JIT(实时)编译器将 CIL 中的各种方法编译成本机 x86 代码,然后运行本机代码。尽管 JIT 在编译首次调用的方法时会稍有延迟,但所调用的各种方法在运行纯本机代码时都不需要解释性的系统开销。

与传统的脱机 C++ 编译过程不同,JIT 编译器花费的时间对用户来说都是“时钟时间”延迟,因此 JIT 编译器不具备占用大量时间的彻底优化过程。尽管如此,JIT 编译器所执行的一系列优化仍给人以深刻印象:

  • 常量重叠
  • 常量和复制的传播
  • 通用子表达式消除
  • 循环不变量的代码活动
  • 死存储 (Dead Store) 和死代码 (Dead Code) 消除
  • 寄存器分配
  • 内联方法
  • 循环展开(带有小循环体的小循环)

结果可以与传统的本机代码相媲美,至少是相近。

至于数据,可以混合使用值类型和引用类型。值类型(包括整型、浮点类型、枚举和结构)通常存储在栈中。这些数据类型就像 C/C++ 中的本地和结构一样又小又快。使用 C/C++ 时,应该避免将大的结构作为方法参数或返回值进行传送,因为复制的系统开销可能会大的惊人。

引用类型和装箱后的值类型存储在堆中。它们通过对象引用来寻址,这些对象引用只是计算机的指针,就像 C/C++ 中的对象指针一样。

因此实时编译的托管代码可以很快。下面我们将讨论一些例外,如果您深入了解了本机 C 代码中某些表达式的开销,您就不会像在托管代码中那样错误地为这些开销建模。

我还应该提一下 NGEN,这是一种“超前的”工具,可以将 CIL 编译为本机代码程序集。尽管利用 NGEN 编译程序集在当前并不会对执行时间造成什么实质性的影响(好的或坏的影响),却会使加载到许多应用程序域和进程中的共享程序集的总工作集减少。(操作系统可以跨所有客户端共享一份利用 NGEN 编译的代码,而实时编译的代码目前通常不会跨应用程序域或进程共享。请参阅 LoaderOptimizationAttribute.MultiDomain [英文]。)

自动内存管理

托管代码与本机代码的最大不同之处在于自动内存管理。您可以分配新的对象,但 CLR 垃圾回收器 (GC) 会在这些对象无法访问时自动释放它们。GC 不时地运行,通常不为人觉察,但一般会使应用程序停止一两毫秒,偶尔也会更长一些。

有一些文章探讨了垃圾回收器的性能含义,这里就不作介绍了。如果您的应用程序遵循这些文章中的建议,那么总的内存回收开销就不会很大,也就是百分之几的执行时间,与传统的 C++ 对象 newdelete 大致相当或者更好一些。创建对象以及后来的自动收回对象的分期开销非常低,这样就可以在每秒钟内创建数千万个小对象。

但仍不能“免费”分配对象。对象会占用空间。无限制的对象分配将会导致更加频繁的内存回收。

更糟糕的是,不必要地持续引用无用的对象图 (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++。同时我们还会发现,有些通常在本机代码中开销不太大的操作(如数据类型转换、数组和字段存储、函数指针 [委托])现在的开销却变大了。为什么是这样呢?让我们来看一下。

算术运算

表 2:算术运算时间 (ns)

平均 最小 原语 平均 最小 原语
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 float sub
2.7 2.7 int mul 2.0 2.0 float mul
35.9 35.7 int div 27.7 27.6 float div
2.1 2.1 int shift      
2.1 2.1 long add 1.5 1.5 double add
2.1 2.1 long sub 1.5 1.5 double sub
34.2 34.1 long mul 2.1 2.0 double mul
50.1 50.0 long div 27.7 27.6 double div
5.1 5.1 long shift      

过去,浮点运算几乎比整数运算慢一个数量级。如表 2 所示,在使用现代的管道化的浮点单位之后,二者之间的差别变得很小或没有差别。而且令人惊奇的是,普通的笔记本 PC 现在已经可以在每秒内进行十亿次浮点运算(对于适应缓存的问题)。

让我们看一行从整数和浮点的加法运算测试中得到的实时编译代码:

反汇编 1:整数加法运算和浮点加法运算

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

这里我们可以看到,实时编译的代码已接近最佳状态。在 int add 示例中,编译器甚至记录了五个局部变量。在 float add 示例中,为了避免通用子表达式消除,我强制使变量 ah 成为静态类。

方法调用

本节将探讨方法调用的开销和实现。测试主题是实现接口 I 的类 T,同时测试各种方法。请参阅列表 1。

列表 1:方法调用的测试方法

interface I { void itf1();  void itf5();  }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, , int p) { }

    static void inl_s1() { } 
    static void s1()     { if (falsePred) dummy(1, 2, 3, , 16); } 
    void inl_i1()        { } 
    void i1()            { if (falsePred) dummy(1, 2, 3, , 16); } 
    public virtual void v1() { } 
    void itf1()          { } 
    virtual void itf5()  { } 
}

请参阅表 3。首先可以判断出,表中的方法可以是内联的(抽象不需要任何开销),也可以不是内联的(抽象的开销是整型操作的 5 倍还多)。静态调用、实例调用、虚拟调用和接口调用的原始开销看起来并没有什么大的差别。

表 3:方法调用的时间 (ns)

平均 最小 原语 被调用者 平均 最小 原语 被调用者
0.2 0.2 inlined static call inl_s1 5.4 5.4 virtual call v1
6.1 6.1 static call s1 5.4 5.4 this virtual call v1
1.1 1.0 inlined instance call inl_i1 6.6 6.5 interface call itf1
6.8 6.8 instance call i1 1.1 1.0 inst itf instance call itf1
0.2 0.2 inlined this inst call inl_i1 0.2 0.2 this itf instance call itf1
6.2 6.2 this instance call i1 5.4 5.4 inst itf virtual call itf5
        5.4 5.4 this itf virtual call itf5

但是,这些结果是不具代表性的“最好情况”,是连续上百万次运行计时循环的结果。在这些测试示例中,虚拟方法和接口方法的调用位置都是单态的(例如,对于每个调用位置,目标方法不因时间而改变),因此,缓存的虚拟方法和接口方法的调度机制(方法表、接口映射指针和输入)再加上非常有预测性的分支预测,使得处理器可以调用这些用其他方法难以预测并与数据相关的分支来完成这项不切实际但却富有成效的工作。实际上,任何调度机制数据的数据缓存不命中或分支预测错误(可能是强制性的容量不命中或多态的调用位置),都可以在多个循环之后使虚拟调用和接口调用的速度减慢。

让我们进一步看一下这些方法调用的时间。

在第一个 inlined static call 示例中,我们调用了 s1_inl() 等一系列空的静态方法。由于编译器完全内联了所有调用,因此结果是对一个空循环计时。

为了测量 static method call 的大致开销,我们将 s1() 等静态方法变得很大,使它们无法内联到调用者中。

我们甚至不得不使用一个显式假谓词变量 falsePred。如果我们写下

static void s1() { if (false) dummy(1, 2, 3, , 16); }

JIT 编译器将像以前那样把死调用 (Dead Call) 消除到 dummy,并内联整个(不是空的)方法。顺便说一下,这里有一些调用时间为 6.1 ns,这要归结于被调用的静态方法 s1 中的(假)谓词测试和跳转。(另外,要禁用内联,一种更好的方法是使用 CompilerServices.MethodImpl(MethodImplOptions.NoInlining) 属性)。

内联的实例调用和常规实例调用的计时使用了相同的方法。但是,由于 C# 语言规范规定,对 Null 对象引用的任何调用都会抛出 NullReferenceException,因此每个调用位置都必须确保实例不为空。这可以通过解除实例引用的引用来实现。如果该实例确实是 Null,则会生成一个故障,并转变为此异常。

在反汇编 2 中,我们使用静态变量 t 作为实例,因为当我们使用局部变量

    T t = new T();

时,编译器会提起签出循环的 Null 实例。

反汇编 2:使用 Null 实例“检查”的实例方法调用位置

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

inlined this instance callthis instance call 相同,只是此实例是 this,而此处的 Null 检查已被取消。

反汇编 3:this 实例方法调用位置

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

“虚拟方法调用”的运行情况与传统的 C++ 实现类似。每个新引入的虚拟方法的地址都存储在类型方法表的新插槽中。每个导出类型的方法表都与其基本类型的方法表一致并有所扩展,并且所有虚拟方法替代都会使用导出类型的虚拟方法地址(在导出的类型方法表的相应插槽中)来替换基本类型的虚拟方法地址。

在调用位置,与实例调用相比,虚拟方法调用要进行两次额外的加载,一次是获取方法表地址(随时可以在 *(this+0) 中找到),另外一次是从方法表中获取适当的虚拟方法地址并进行调用。请参阅反汇编 4。

反汇编 4:虚拟方法调用位置

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; 获取方法表地址
00000016 FF 50 38         call        dword ptr [eax+38h] ; 获取/调用方法地址

最后,讨论一下“接口方法调用”(反汇编 5)。在 C++ 中,没有等效的接口方法调用。任何给定的类型都可以实现任意数量的接口,并且每个接口在逻辑上都需要自己的方法表。要对接口方法进行调度,就要查找方法表、方法的接口映射、该映射中接口的入口,然后通过方法表中接口部分适当的入口进行调用。

反汇编 5:接口方法调用位置

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; 实例地址
00000018 8B 01             mov        eax,dword ptr [ecx]         ; 方法表地址
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; 接口映射地址
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; 接口方法表地址
00000020 FF 10             call       dword ptr [eax]             ; 获取/调用方法地址

其余的原语计时,inst itf instance callthis itf instance callinst itf virtual callthis itf virtual call,充分印证了这样一个观点:不论何时,导出类型的方法在实现接口方法时,都可以通过实例方法调用位置来保持可调用性。

例如,在 this itf instance call 测试中,通过实例(不是接口)引用来调用接口方法实现,结果接口方法被成功内联并且开销为 0 ns。甚至当您将接口方法作为实例方法进行调用时,接口方法实现都有可能被内联。

尚未实时编译的方法调用

对于静态方法调用和实例方法调用(不是虚拟方法调用和接口方法调用),JIT 编译器会根据在目标方法的调用位置被实时编译时,目标方法是否已经被实时编译,从而在当前生成不同的方法调用序列。

如果被调用者(目标方法)还未被实时编译,编译器将通过已经用“prejit stub”初始化的指针来发出调用。对目标方法的第一个调用到达 stub 时,将触发方法的 JIT 编译,同时生成本机代码,并对指针进行更新以寻址新的本机代码。

如果被调用者已经过实时编译,其本机代码地址已知,则编译器将直接向其发出调用。

创建新对象

创建新对象包括两个阶段:对象分配和对象初始化。

对于引用类型,对象被分配在可以进行内存回收的堆上。对于值类型,不管是以栈形式驻留在另一个引用类型或值类型中,还是嵌入到另一个引用类型或值类型中,值类型对象都与封闭结构有一些固定的差异,即不需要进行任何分配。

对典型的引用类型的小对象来说,堆分配的速度非常快。每次内存回收之后,除了固定的对象之外,第 0 代堆的活对象都将被压缩并被提升到第 1 代,因此,内存分配程序可以使用一个相当大的连续可用内存空间。大多数的对象分配只会引起指针的递增和边界检查,这要比典型的 C/C++ 释放列表分配程序(malloc/操作符 new)节省很多开销。垃圾回收器甚至会考虑计算机的缓存大小,以设法将第 0 代对象保留在缓存/内存层次结构中快速有效的位置。

由于首选的托管代码风格要求大多数分配的对象生存期很短,并且快速回收这些对象,所以我们还包含了这些新对象的内存回收的分期开销(在时间开销中)。

请注意,垃圾回收器不会为死对象浪费时间。如果一个对象是死的,GC 不会处理它,也不会回收它,甚至是根本就不考虑它。GC 只关注那些存活的对象。

(例外:可终结的死对象属于特殊情况。GC 会跟踪这些对象,并且专门将可终结的死对象提升到下一代,等待终结。这会花费很大的开销,而且在最坏的情况下,还会可传递地提升大的死对象图。因此,若非确实需要,请不要使对象成为可终结的。如果必须这样做,请考虑使用“清理模式”[Dispose Pattern],并在可能时调用 GC.SuppressFinalizer。)除非 Finalize 方法要求,否则不要保留从可终结对象对其他对象的引用。

当然,生存期短的大对象的分期 GC 开销要大于生存期短的小对象的开销。每次对象分配都使我们更接近下一个内存回收周期;而较大的对象比较小的对象达到得更早。但无论早晚,“算帐”的时刻终会到来。GC 周期(尤其第 0 代回收)的速度非常快,但不是不需要开销的,即使绝大多数新对象是死的也是如此:因为要查找(标记)活对象,需要先暂停线程,然后查找栈和其他数据结构,以将根对象引用回收到堆中。

(也许更为重要的是,只有极少的大对象能够适应小对象所利用的缓存数量。缓存不命中的影响很容易超过代码路径长度的影响。)

一旦为对象分配了空间,空间就将保留下来以初始化对象(构造对象)。CLR 可以保证,所有的对象引用都预先初始化为 Null,所有的原始标量类型都初始化为 0、0.0、False 等。(因此没有必要在用户定义的构造函数中进行多余的初始化。当然,不必担心。但请注意,当前不必使用 JIT 编译器优化掉冗余的存储。)

除了消除实例字段外,CLR 还初始化(仅引用类型)对象的内部实现字段:方法表指针和对象标头词。而后者要优先于方法表指针。数组也获得一个 Length 字段,对象数组获得 Length 和元素类型字段。

然后,CLR 调用对象的构造函数(如果有的话)。每种类型的构造函数,不管是用户定义的还是编译器生成的,都是首先调用其基本类型的构造函数,然后运行用户定义的初始化操作(如果有的话)。

从理论上讲,这样做对于深度继承方案来说可能会花费比较大的开销。如果 E 扩展 D 扩展 C 扩展 B 扩展 A(扩展 System.Object),那么初始化 E 将导致五次方法调用。实际上,情况并没有这么糟糕,因为编译器会内联掉对空的基本类型构造函数的调用(使其不存在)。

参考表 4 的第一列时会发现,我们可以创建和初始化一个结构 D,此结构在大约 8 个整型加法运算时间中包含四个 int 字段。反汇编 6 是来自三个不同计时循环的生成代码,创建了 A、C 和 E 的代码。(在每个循环中,我们修改了所有新实例,这可以防止 JIT 编译器优化掉所有内容。)

表 4:值类型和引用类型对象的创建时间 (ns)

平均 最少 原语 平均 最少 原语 平均 最少 原语
2.6 2.6 new valtype L1 22.0 20.3 new reftype L1 22.9 20.7 new rt ctor L1
4.6 4.6 new valtype L2 26.1 23.9 new reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 new valtype L3 30.2 27.5 new reftype L3 32.7 29.9 new rt ctor L3
8.0 8.0 new valtype L4 34.1 30.8 new reftype L4 37.7 34.1 new rt ctor L4
23.0 22.9 new valtype L5 39.1 34.4 new reftype L5 43.2 39.1 new rt ctor L5
      22.3 20.3 new rt empty ctor L1 28.6 26.7 new rt no-inl L1
      26.5 23.9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

反汇编 6:值类型对象的构造

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

另外的五个计时(new reftype L1、……、new reftype L5)针对引用类型 A、……、E 的五个继承级别,没有用户定义的构造函数:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

将引用类型的时间与值类型的时间进行比较,我们会发现,对于每个实例,其分配和释放的分期开销在测试计算机上大约为 20 ns(是整型加法运算时间的 20 倍)。这个速度非常快,也就是说,一秒钟可以分配、初始化和回收大约 5 千万个生存期很短的对象,而且这种速度可以保持不变。对于像五个字段一样小的对象,分配和回收的时间仅占对象创建时间的一半。请参阅反汇编 7。

反汇编 7:引用类型对象的构造

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

最后三组五个计时说明了这种继承类构造方案的变化情况。

  1. new rt empty ctor L1、……、new rt empty ctor L5:每个类型 A、……、E 都有一个空的用户定义的构造函数。这些类型都被内联掉,而且生成的代码与上面的代码相同。
  2. new rt ctor L1、……new rt ctor L5:每个类型 A、……、E 都有一个用户定义的构造函数,将其实例变量设置为 1:
        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

编译器将每组嵌套的基类构造函数调用内联到 new 位置。(反汇编 8)。

反汇编 8:深度内联的继承构造函数

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. new rt no-inl L1、……new rt no-inl L5:每个类型 A、……、E 都有一个用户定义构造函数,该构造函数被有意编写为开销很大,以至无法内联。此方案模拟了创建具有深度继承层次结构和大型构造函数的复杂对象的开销。
      public class A     { int a; public A() { a = 1; if (falsePred) dummy(); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(); } }
    

表 4 中的最后五个计时显示了调用嵌套的基本构造函数时所需的额外系统开销。

中间程序:CLR 分析器(CLR Profiler)演示

现在来简单演示一下 CLR 分析器。CLR 分析器(旧称“分配分析器”)使用 CLR 分析 API 在应用程序运行时收集事件数据,特别是调用、返回以及对象分配和内存回收事件。(CLR 分析器是一种“侵害性”的分析器,即它会严重地减慢被分析的应用程序的运行速度。)收集事件之后,您可以使用 CLR 分析器来检查应用程序的内存分配和 GC 行为,包括分层调用图和内存分配模式之间的交互。

CLR 分析器之所以值得学习,是因为对许多“面临性能挑战的”托管代码应用程序来说,了解数据分配配置文件可以使您获得很关键的认知,从而减少工作集并由此而开发出快速、价廉的组件和应用程序。

CLR 分析器还可以揭示哪些方法分配的存储比您预期的多,并可以发现您不小心保留的对无用对象图的引用,而这些引用原本可能会由 GC 回收。(一种常见的问题设计模式是项目的软件缓存或查找表已不再需要,或者对以后的重建是安全的。当缓存使对象图的生存期超出其有用寿命时,情况将非常糟糕。因此,务必解除对不再需要的对象的引用。)

图 1 是在执行计时测试驱动程序时堆的时间线图。锯齿状图案表示对象 C(洋红色)、D(紫色)和 E(蓝色)的上千个实例的分配。每过几毫秒,就会在新对象(第 0 代)堆中消耗大约 150 KB 的 RAM,而垃圾回收器会短暂运行以回收这部分内存,并将所有活对象提升到第 1 代。很明显,即使在这种极具侵害性(缓慢)的分析环境下,在 100 ms(2.8 秒到 2.9 秒)的时间间隔里,仍经历了大约 8 个第 0 代 GC 周期。然后,在 2.977 秒时,垃圾回收器为另一个 E 实例释放了空间,并执行第 1 代内存回收,这会回收和压缩第 1 代堆,因此锯齿状图案从一个较低的位置开始继续延伸。

图 1:CLR 分析器时间线图

注意,对象越大(E 大于 D,D 大于 C),第 0 代堆充满的速度就越快,GC 周期就越频繁。

类型转换和实例类型检查

要使托管代码安全、可靠、“可验证”,必须保证类型安全。如果可以将一个对象的类型转换为其他类型,就很容易危及 CLR 的完整性,并因此而使其被不可信的代码支配。

表 5:类型转换和 isinst 时间 (ns)

平均 最少 原语 平均 最少 原语
0.4 0.4 cast up 1 0.8 0.8 isinst up 1
0.3 0.3 cast down 0 0.8 0.8 isinst down 0
8.9 8.8 cast down 1 6.3 6.3 isinst down 1
9.8 9.7 cast (up 2) down 1 10.7 10.6 isinst (up 2) down 1
8.9 8.8 cast down 2 6.4 6.4 isinst down 2
8.7 8.6 cast down 3 6.1 6.1 isinst down 3

表 5 显示了这些强制性类型检查的系统开销。从导出类型转换到基本类型总是安全的,而且也是不需要开销的,而从基本类型转换到导出类型则必须经过类型检查。

(已检查的)类型转换将对象引用转换为目标类型,或者抛出 InvalidCastException

相反,isinst CIL 指令用于实现 C# as 关键字:

  bac = ac as B;

如果 ac 不是 B 或者从 B 导出,结果就是 Null,而不是一个异常。

列表 2 是一个类型转换的计时循环,反汇编 9 显示了向下转换为导出类型的生成代码。为执行类型转换,编译器直接调用 Helper 例程。

列表 2:测试类型转换计时的循环

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = ©bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = ©bd; dce = (D)ce; edf = (E)df;
    }
}

反汇编 9:向下类型转换

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

属性

在托管代码中,属性是一对方法,即一个属性获取方法和一个属性设置方法,类似于对象的字段。get_ 方法获取属性,set_ 方法将属性更新为新的值。

除此之外,属性的行为和开销与常规的实例方法、虚拟方法的行为和开销非常相像。如果使用一个属性来获取或存储一个实例字段,通常是以内联方式进行,这与小方法相同。

表 6 显示了获取(和添加)并存储一组整数实例字段和属性所需的时间。获取或设置属性的开销实际上与直接访问基本字段相同,除非将属性声明为虚拟的。如果声明为虚拟的,则开销基本上就是虚拟方法调用的开销。这没什么可奇怪的。

表 6:字段和属性时间 (ns)

平均 最少 原语
1.0 1.0 get field
1.2 1.2 get prop
1.2 1.2 set field
1.2 1.2 set prop
6.4 6.3
延伸阅读

文章来源于领测软件测试网 https://www.ltesting.net/

TAG: 编写 代码 更快 托管 开销 情况


关于领测软件测试网 | 领测软件测试网合作伙伴 | 广告服务 | 投稿指南 | 联系我们 | 网站地图 | 友情链接
版权所有(C) 2003-2010 TestAge(领测软件测试网)|领测国际科技(北京)有限公司|软件测试工程师培训网 All Rights Reserved
北京市海淀区中关村南大街9号北京理工科技大厦1402室 京ICP备10010545号-5
技术支持和业务联系:info@testage.com.cn 电话:010-51297073

软件测试 | 领测国际ISTQBISTQB官网TMMiTMMi认证国际软件测试工程师认证领测软件测试网