编写更快的托管代码:了解代价

 

Jan Gray
Microsoft CLR 性能团队

2003 年 6 月

适用于:
   Microsoft® .NET Framework

总结: 本文提供了一个基于测量的操作时间的托管代码执行时间的低成本模型,以便开发人员可以做出更明智的编码决策并更快地编写代码。 ) (30 个打印页

下载 CLR Profiler。 (330KB)

目录

(和承诺) 简介
面向托管代码的成本模型
托管代码中的成本
结论
资源

(和承诺) 简介

实现计算的方法有很多种,其中一些方法比其他方法要好得多:更简单、更简洁、更易于维护。 有些方法速度极快,有些则速度惊人。

不要在世界上实施缓慢而胖的代码。 你不鄙视这样的代码吗? 运行的代码适合和启动? 将 UI 锁定几秒钟的代码? 锁定 CPU 或使磁盘抖动的代码?

不要这样做。 相反,站起来,和我一起承诺:

“我保证我不会交付慢代码。 速度是我关心的功能。 我每天都会关注代码的性能。 我会定期有条不紊地 测量 它的速度和大小。 我将学习、构建或购买执行此操作所需的工具。 这是我的责任。

(真的) 你答应过吗? 对你有好处。

那么 如何一天又一天地编写最快、最紧密的代码呢? 这是一个自觉选择节俭的方式,而不是奢侈,膨胀的方式,一次又一次的问题,一个思考后果的问题。 任何给定的代码页都会捕获数十个这样的小决策。

但是,如果你不知道成本是多少,则不能在替代项之间做出明智的选择: 如果你不知道成本,就不能编写高效的代码。

在以前的美好时代,这更容易。 优秀的 C 程序员知道。 C 中的每个运算符和运算(无论是赋值、整数或浮点数学、取消引用还是函数调用)都或多或少地一对一映射到单个基元计算机操作。 的确,有时需要多个计算机指令才能将正确的操作数放入正确的寄存器中,有时单个指令可以捕获多个 C 操作 (著名的 *dest++ = *src++;) ,但你通常可以编写 (或读取) 一行 C 代码,并知道时间去向。 对于代码和数据,C 编译器都是 WYWIWYG ,“你编写的就是你得到的东西”。 (异常曾调用过函数,并且是函数调用。如果不知道函数的成本,则不知道 diddly.)

在 20 世纪 90 年代,为了享受数据抽象、面向对象的编程和代码重用的许多软件工程和生产力优势,电脑软件行业从 C 向 C++ 进行了过渡。

C++ 是 C 的超集,是“即用即付”(如果不使用新功能,则无需任何成本),因此 C 编程专业知识(包括内部化成本模型)直接适用。 如果采用一些工作 C 代码并为 C++ 重新编译它,则执行时间和空间开销应该不会有太大变化。

另一方面,C++ 引入了许多新的语言功能,包括构造函数、析构函数、新建、删除、单一、多继承和虚拟继承、强制转换、成员函数、虚拟函数、重载运算符、指向成员的指针、对象数组、异常处理和相同的组合,这会产生不小的隐藏成本。 例如,虚拟函数每次调用产生两个额外的间接费用,并将隐藏的 vtable 指针字段添加到每个实例。 或者考虑这个看起来无害的代码:

{ complex a, b, c, d; … a = b + c * d; }

编译为大约 13 个隐式成员函数调用 , (希望内联) 。

九年前,我们在我的文章 《C++:幕后》中探讨了这个主题。 我写道:

“了解编程语言的实现方式非常重要。 这种知识消除了对“编译器究竟在这里做什么?”的恐惧和疑惑:赋予使用新功能的信心;和 在调试和学习其他语言功能时提供见解。 它还让人了解每天编写最高效代码所需的不同编码选项的相对成本。”

现在,我们将对托管代码进行类似的介绍。 本文探讨托管执行的 低级别 时间和空间成本,以便 我们可以 在日常编码中做出更明智的权衡。

信守诺言。

为什么使用托管代码?

对于绝大多数本机代码开发人员来说,托管代码是运行其软件的更好、更高效的平台。 它删除了整个类别的 bug,例如堆损坏和数组索引超出边界的错误,这些错误通常会导致令人沮丧的深夜调试会话。 它支持现代要求,例如通过代码访问安全) 和 XML Web 服务 (安全移动代码,与老化的 Win32/COM/ATL/MFC/VB 相比,.NET Framework是一种令人耳目一新的干净石板设计,你可以在其中以更少的精力完成更多工作。

对于用户社区,托管代码可实现更丰富、更可靠的应用程序,通过更好的软件实现更好的生活。

编写更快的托管代码的秘诀是什么?

仅仅因为你可以用更少的精力完成更多工作,就不是明智地放弃代码责任的许可证。 首先,你必须承认自己:“我是新手。你是新手 我也是新手 我们都是托管代码土地的宝贝。 我们都在学习绳子,包括成本。

说到丰富而方便的.NET Framework,就好像我们是罐头店里的孩子一样。 “哇,我不必做那些乏味 strncpy 的事情,我可以'+'字符串在一起! 哇,我可以在几行代码中加载一兆字节的 XML! 哇!

这一切都太简单了。 确实如此简单。 只需将几个元素从其中拉出几个元素,就能轻松消耗数兆字节的 RAM 分析 XML 信息集。 在 C 或 C++ 中,这太痛苦了,你会三思而后行,也许你会在一些类似于 SAX 的 API 上构建状态机。 使用 .NET Framework,只需在一个 gulp 中加载整个信息集。 也许你甚至一遍又一遍地做。 然后,也许应用程序看起来不再那么快了。 也许它有一个许多兆字节的工作集。 也许你应该三思而后行,这些简单的方法会付出什么代价...

遗憾的是,在我看来,当前的.NET Framework文档没有充分详细说明框架类型和方法对性能的影响,它甚至没有指定哪些方法可以创建新对象。 性能建模不是一个容易涵盖或记录的主题;但是,“不知道”使我们更难做出明智的决定。

既然我们都是这里的新手,而且我们不知道要付出什么代价,而且成本也没有明确记录,我们该怎么办?

测量它。 秘诀是 测量它 ,保持 警惕。 我们都要养成衡量事物成本的习惯。 如果我们去测量东西的成本的麻烦,那么我们就不会不经意地调用一个呼啸而过的新方法,这种新方法的成本是 我们假设 成本的十倍。

(顺便说一句,若要更深入地了解 BCL (基类库) 或 CLR 本身的性能基础,请考虑查看 共享源 CLI,即“旋翼”。 转子代码与.NET Framework和 CLR 共享血脉。 它自始至终都不是同一个代码,但即便如此,我向你保证,对Rotor的深思熟虑的研究将给你对 CLR 的幕后所发生的情况有新的认识。 但请务必先查看 SSCLI 许可证!)

知识

如果你渴望成为伦敦的出租车司机,你首先必须获得 知识。 学生们学习了几个月,以记住伦敦的数千条小街道,并学习从一个地方到一个地方的最佳路线。 他们每天骑摩托车出去侦察,加强书本学习。

同样,如果你想成为一名高性能托管代码开发人员,则必须获得 托管代码知识。 必须了解每个低级别的运营成本。 必须了解委托和代码访问安全成本等功能。 你必须了解所使用的类型和方法以及正在编写的类型和方法的成本。 发现哪些方法对应用程序而言可能过于昂贵,因此避免使用它们也没什么坏处。

唉, 知识不在任何书里。 你必须走出 你的 滑板车并探索 - 即,启动 csc,ildasm,VS.NET 调试器,CLR Profiler,你的探查器,一些性能计时器,等等,看看你的代码在时间和空间方面的成本。

面向托管代码的成本模型

撇开初步不谈,让我们考虑托管代码的成本模型。 这样,你将能够查看叶方法,并一目了然地判断哪些表达式和语句的成本更高:编写新代码时,你将能够做出更明智的选择。

(这不会解决调用方法或.NET Framework方法的传递成本。这将不得不等待另一天的另一篇文章。)

我之前曾表示,大多数 C 成本模型仍适用于 C++ 方案。 同样,大部分 C/C++ 成本模型仍适用于托管代码。

怎么会这样呢? 你知道 CLR 执行模型。 使用多种语言之一编写代码。 将其编译为 CIL (通用中间语言) 格式,打包到程序集中。 运行main应用程序程序集,然后它开始执行 CIL。 但是,这难道不是像旧字节码解释器那样慢一个数量级吗?

实时编译器

不,不是。 CLR 使用 JIT (实时) 编译器将 CIL 中的每个方法编译为本机 x86 代码,然后运行本机代码。 尽管每个方法的 JIT 编译在首次调用时都有较小的延迟,但每个名为 的方法都运行纯本机代码,没有解释性开销。

与传统的脱机 C++ 编译过程不同,JIT 编译器花费的时间在每个用户脸上都是“挂钟时间”延迟,因此 JIT 编译器没有详尽的优化过程。 即便如此,JIT 编译器执行的优化列表也令人印象深刻:

  • 常量折叠
  • 常量和复制传播
  • 常见子表达式清除
  • 循环固定对象的代码运动
  • 死存储和死代码消除
  • 注册分配
  • 方法内联
  • 循环展开 (具有小体的小循环)

结果与传统本机代码相当, 至少在同一个球场中是这样

对于数据,你将混合使用值类型或引用类型。 值类型(包括整型类型、浮点类型、枚举和结构)通常位于堆栈上。 它们与局部变量和结构在 C/C++ 中一样小而快速。 与 C/C++ 一样,你可能应避免将大型结构作为方法参数传递或返回值,因为复制开销可能极其昂贵。

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

因此,抖动的托管代码可以很快。 我们在下面讨论的一些例外情况是,如果你对本机 C 代码中某些表达式的成本有直接的感觉,那么在托管代码中将其成本建模为等效项不会出错。

我还应提及 NGEN,它是一种“提前”将 CIL 编译为本机代码程序集的工具。 虽然 NGEN 的程序集目前不会对执行时间 (好坏) 产生重大影响,但它可以减少加载到许多 AppDomain 和进程中的共享程序集的总工作集。 (OS 可以在所有客户端之间共享 NGEN'd 代码的一个副本;而 jitted 代码当前通常不跨 AppDomains 或进程共享。但另 LoaderOptimizationAttribute.MultiDomain请参阅 .)

自动内存管理

托管代码与本机) 最重要的偏离 (是自动内存管理。 分配新对象,但 CLR 垃圾回收器 (GC) 在无法访问时自动释放它们。 GC 一次又一次地运行,通常是不可见的,通常只停止应用程序一两毫秒,偶尔会更长。

其他几篇文章讨论了垃圾回收器的性能影响,我们在此不复述。 如果应用程序遵循这些其他文章中的建议,则垃圾回收的总体成本可能微不足道、执行时间的几%、与传统 C++ 对象 newdelete竞争或优于 。 创建和以后自动回收对象的摊销成本非常低,因此每秒可以创建数千万个小对象。

但对象分配仍然不 是免费的。 对象占用空间。 频繁的对象分配会导致垃圾回收周期更加频繁。

更糟的是,不必要地保留对无用对象图的引用会使它们保持活动状态。 我们有时会看到具有可悲的 100 MB 以上工作集的适度程序,这些程序作者否认其责任,而是将其性能不佳归因于一些神秘的、身份不明的 (,因此难以解决的托管代码本身) 问题。 这是悲惨的。 但是,使用 CLR Profiler 进行一个小时的研究和对几行代码的更改,其堆使用量减少了 10 倍或更多。 如果遇到较大的工作集问题,第一步是查看镜像。

因此,不要不必要地创建对象。 由于自动内存管理消除了对象分配和释放的许多复杂性、麻烦和 bug,因为它如此快速且如此方便,因此我们自然倾向于创建越来越多的对象,就像它们生长在树上一样。 如果想要编写真正快速的托管代码,请仔细且适当地创建对象。

这也适用于 API 设计。 可以设计类型及其方法,以便它们 需要 客户端创建具有野生放弃的新对象。 别这样。

托管代码中的成本

现在,我们来考虑各种低级别托管代码操作的时间成本。

表 1 显示了运行 Windows XP 和 .NET Framework v1.1 .NET Framework v1.1 (“Everett”) 的静态 1.1 GHzPentium-III 电脑上各种低级别托管代码操作的大致成本(以纳秒为单位),这些操作与一组简单的计时循环一起收集。

测试驱动程序调用每个测试方法,指定要执行的迭代次数,并根据需要自动缩放以循环访问 218 到 230 次迭代,以便执行每个测试至少 50 毫秒。 一般来说,这足够长,可以在执行密集对象分配的测试中观察第 0 代垃圾回收的几个周期。 下表显示了超过 10 次试验的平均结果,以及每个测试对象的最佳 (最短试验时间) 。

每个测试循环将根据需要展开 4 到 64 次,以减小测试循环开销。 我检查了为每个测试生成的本机代码,以确保 JIT 编译器不会优化测试,例如,在一些情况下,我修改了测试,使中间结果在测试循环期间和之后保持实时。 同样,我做了一些更改,以排除在多个测试中消除常见的子表达式。

表 1 基元时间 (平均值和最小) (ns)

平均值 Min 基元 平均值 Min 基元 平均值 Min 基元
0.0 0.0 控制 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 子 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 获取字段
2.1 2.1 long sub 30.2 27.5 new reftype L3 1.2 1.2 获取道具
34.2 34.1 long mul 34.1 30.8 new reftype L4 1.2 1.2 set 字段
50.1 50.0 long div 39.1 34.4 new reftype L5 1.2 1.2 set prop
5.1 5.1 长班次 22.3 20.3 new reftype empty ctor L1 0.9 0.9 获取此字段
1.3 1.3 float add 26.5 23.9 new reftype empty ctor L2 0.9 0.9 获取此道具
1.4 1.4 float sub 38.1 34.7 new reftype empty ctor L3 1.2 1.2 设置此字段
2.0 2.0 float mul 34.7 30.7 new reftype empty ctor L4 1.2 1.2 设置此属性
27.7 27.6 float div 38.5 34.3 new reftype empty ctor L5 6.4 6.3 获取虚拟道具
1.5 1.5 double add 22.9 20.7 new reftype ctor L1 6.4 6.3 设置虚拟道具
1.5 1.5 double sub 27.8 25.4 新 reftype ctor L2 6.4 6.4 写入屏障
2.1 2.0 double mul 32.7 29.9 新的 reftype ctor L3 1.9 1.9 load int array elem
27.7 27.6 double div 37.7 34.1 新 reftype ctor L4 1.9 1.9 store int array elem
0.2 0.2 内联静态调用 43.2 39.1 新的 reftype ctor L5 2.5 2.5 load obj array elem
6.1 6.1 静态调用 28.6 26.7 new reftype ctor no-inl L1 16.0 16.0 store obj array elem
1.1 1.0 内联实例调用 38.9 36.5 新的 reftype ctor no-inl L2 29.0 21.6 box int
6.8 6.8 实例调用 50.6 47.7 新的 reftype ctor no-inl L3 3.0 3.0 取消装箱 int
0.2 0.2 内联此 inst 调用 61.8 58.2 新的 reftype ctor no-inl L4 41.1 40.9 委托调用
6.2 6.2 此实例调用 72.6 68.5 新 reftype ctor no-inl L5 2.7 2.7 sum 数组 1000
5.4 5.4 虚拟呼叫 0.4 0.4 投出 1 2.8 2.8 sum 数组 10000
5.4 5.4 此虚拟调用 0.3 0.3 强制转换 0 2.9 2.8 sum 数组 100000
6.6 6.5 接口调用 8.9 8.8 强制转换 1 5.6 5.6 sum 数组 1000000
1.1 1.0 inst itf 实例调用 9.8 9.7 投 (涨 2) 下 1 3.5 3.5 sum list 1000
0.2 0.2 此 itf 实例调用 8.9 8.8 强制转换 2 6.1 6.1 sum list 10000
5.4 5.4 inst itf 虚拟调用 8.7 8.6 强制转换 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)

平均值 Min 基元 平均值 Min 基元
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int 子 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 长班次      

在旧时代,浮点数学可能比整数数学慢一个数量级。 如表 2 所示,使用新式管道浮点单位时,似乎几乎没有差别。 对于适合缓存) 中的问题,一台普通的笔记本电脑现在就是千兆级计算机, (,真是令人惊奇。

让我们看一下整数和浮点添加测试中的一行吉特代码:

反汇编 1 Int add 和 float add

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 案例中,我不得不通过h类静态来生成变量a,以击败常见的子表达式消除。

方法调用

本部分介绍方法调用的成本和实现。 测试主题是一个实现接口 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)

平均值 Min 基元 被调用方 平均值 Min 基元 被调用方
0.2 0.2 内联静态调用 inl_s1 5.4 5.4 虚拟呼叫 v1
6.1 6.1 静态调用 s1 5.4 5.4 此虚拟呼叫 v1
1.1 1.0 内联实例调用 inl_i1 6.6 6.5 接口调用 itf1
6.8 6.8 实例调用 i1 1.1 1.0 inst itf 实例调用 itf1
0.2 0.2 内联此 inst 调用 inl_i1 0.2 0.2 此 itf 实例调用 itf1
6.2 6.2 此实例调用 i1 5.4 5.4 inst itf 虚拟调用 itf5
        5.4 5.4 this itf virtual call itf5

但是,这些结果是无代表性 的最佳情况,运行紧密的计时循环的影响数百万次。 在这些测试用例中,虚拟和接口方法调用站点是单态 (例如,每个调用站点的目标方法不会随时间) 而变化,因此,将虚拟方法和接口方法调度机制 (方法表和接口映射指针和条目的组合) 和惊人的公积分支预测,使处理器能够通过这些不切实际的有效作业调用,否则难以预测, 依赖于数据的分支。 实际上,任何调度机制数据上的数据缓存未命中或分支错误预测 (强制容量缺失或多态调用站点) ,可能会而且会减慢数十个周期的虚拟和接口调用。

让我们仔细看看每个方法调用时间。

在第一种情况下, 内联静态调用, 我们调用一系列空静态方法 s1_inl() 等。由于编译器完全内联所有调用,因此我们最终将计时为空循环。

为了测量 静态方法调用的大致成本,我们将静态方法 s1() 等变得如此之大,以至于它们内联到调用方中是无利可图的。

请注意,我们甚至必须使用显式的 false 谓词变量 falsePred。 如果我们写

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

JIT 编译器将消除对 dummy 的死调用,并内联整个 (现在像以前一样空) 方法主体。 顺便说一下,此处的 6.1 ns 调用时间中的一些必须归因于 (false) 谓词测试,并在调用的静态方法 s1内跳转。 (顺便说一句,禁用内联 CompilerServices.MethodImpl(MethodImplOptions.NoInlining) 的更好方法是 attribute.)

内联实例调用和常规实例调用计时使用相同的方法。 但是,由于 C# 语言规范确保对 null 对象引用的任何调用都会引发 NullReferenceException,因此每个调用站点必须确保实例不为 null。 这是通过取消引用实例引用来完成的;如果 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 

联此实例调用此实例调用的用例相同,只是 实例为 this;此处为 null 检查已被执行。

反汇编 3 此实例方法调用站点

               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] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

最后,我们来接口 方法调用 (反汇编 5) 。 这些在 C++ 中没有完全相同的等效项。 任何给定类型都可以实现任意数量的接口,并且每个接口在逻辑上都需要其自己的方法表。 若要对接口方法进行调度,我们查找方法表、其接口映射、该映射中的接口条目,然后通过方法表接口部分的相应条目间接调用 。

反汇编 5 接口方法调用站点

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

基元计时的其余部分、 inst itf 实例调用此 itf 实例调用inst itf 虚拟调用此 itf 虚拟调用 突出显示了派生类型的 方法实现接口方法时,它仍可通过实例方法调用站点进行调用。

例如,对于 此 itf 实例调用的测试,通过实例对接口方法实现的调用 (而不是接口) 引用,接口方法已成功内联,成本将转为 0 ns。 将接口方法实现作为实例方法调用时,即使是它也可能是内联的。

对尚未进行 Jitted 的方法的调用

对于静态方法调用和实例方法调用 (而不是虚拟方法调用和接口方法调用) ,JIT 编译器当前会生成不同的方法调用序列,具体取决于目标方法在其调用站点被抖动时是否已被抖动。

如果被调用方 (目标方法) 尚未进行抖动,编译器会通过指针间接发出调用,该指针首先使用“prejit 存根”初始化。 对目标方法的第一次调用到达存根,这会触发方法的 JIT 编译,生成本机代码,并更新指针以寻址新的本机代码。

如果被调用方已被抖动,则其本机代码地址是已知的,因此编译器会发出对它的直接调用。

新建对象

新对象创建由两个阶段组成:对象分配和对象初始化。

对于引用类型,对象在垃圾回收堆上分配。 对于值类型,无论是堆栈驻留还是嵌入到另一个引用或值类型中,值类型对象都位于与封闭结构的某些常量偏移处,无需分配。

对于典型的小型引用类型对象,堆分配非常快。 每次垃圾回收后,除了存在固定对象外,第 0 代堆中的活动对象将被压缩并提升为第 1 代,因此内存分配器具有一个很好的大型连续可用内存区域。 大多数对象分配只产生指针增量和边界检查,这比典型的 C/C++ 自由列表分配器 (malloc/operator new) 更便宜。 垃圾回收器甚至会考虑计算机的缓存大小,以尝试将第 0 代对象保留在缓存/内存层次结构的快速位置。

由于首选的托管代码样式是分配生存期较短的大多数对象并快速回收它们,因此我们还在时间成本) 这些新对象的垃圾回收的摊销成本中包括 (。

请注意,垃圾回收器不花任何时间哀悼死对象。 如果对象已死亡,GC 看不到它,不走它,不会给它一个纳秒的想法。 GC 只关心活着的福利。

(异常:可终结的死对象是一种特殊情况。GC 跟踪这些对象,并专门将死定可终结对象提升到下一代等待完成。这很昂贵,在最坏的情况下,可能会以可传递方式提升大型死对象图。因此,除非严格必要,否则不要使对象可最终确定;如果必须,请考虑使用 Dispose 模式,尽可能调用 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 的第一列,观察我们可以在大约 8 个 int-add-times 中创建并初始化包含 4 个 int 字段的结构 D 。 反汇编 6 是从三个不同的计时循环生成的代码,创建 A、C 和 E。 (在每个循环中,我们修改每个新实例,这阻止 JIT 编译器优化所有内容。)

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

平均值 Min 基元 平均值 Min 基元 平均值 Min 基元
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] 

接下来的五个计时 (新的 reftype L1, ...新的 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 (20X int 在测试计算机上添加时间) 。 速度很快,每秒持续分配、初始化和回收约 5000 万个短生存期对象。 对于小到五个字段的对象,分配和集合只占对象创建时间的一半。 请参阅反汇编 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. 新的 rt empty ctor L1、...、new rt empty ctor L5: 每个类型 A、...都有 E 一个空的用户定义构造函数。 这些都是内联的,生成的代码与上述代码相同。

  2. 新的 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. 新的 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 分析 API 来收集事件数据,尤其是在应用程序运行时调用、返回和对象分配和垃圾回收事件。 (CLR Profiler 是一个“侵入性”探查器,这意味着它不幸地会大大减慢分析的应用程序的速度。) 收集事件后,可以使用 CLR Profiler 探索应用程序的内存分配和 GC 行为,包括分层调用图和内存分配模式之间的交互。

CLR Profiler 值得学习,因为对于许多“性能挑战”的托管代码应用程序,了解数据分配配置文件可提供减少工作集所需的关键见解,从而提供快速且节俭的组件和应用程序。

CLR 探查器还可以显示哪些方法分配的存储量超出预期,并可以发现无意中保留对无用对象图的引用的情况,否则这些引用可能会被 GC 回收。 (一个常见问题设计模式是不再需要或以后可以安全重建的软件缓存或项查找表。当缓存使对象图在其使用生命周期内保持活动状态时,这是很悲惨的。相反,请确保为不再需要的对象的引用取空。)

图 1 是执行计时测试驱动程序期间堆的时间线视图。 锯齿模式指示分配数千个对象 C 实例, (品红色) 、 D (紫色) 和 E (蓝色) 。 每隔几毫秒,我们会在新对象 (第 0 代) 堆中再咀嚼大约 150 KB 的 RAM,垃圾回收器会短暂运行以回收它并将任何活动对象提升到第 1 代。 值得注意的是,即使在这种侵入性 (缓慢的) 分析环境中,在 100 毫秒 (2.8 秒到 2.9 秒) 的时间间隔内,我们也会经历大约 8 代 0 GC 周期。 然后在 2.977 秒时,为另一个 E 实例腾出空间,垃圾回收器执行第 1 代垃圾回收,该回收器会收集和压缩第 1 代堆,因此锯齿继续从较低的起始地址开始。

图 1 CLR Profiler Time Line 视图

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

强制转换和实例类型检查

安全、安全、 可验证 的托管代码的基础是类型安全。 如果可以将对象强制转换为它不是的类型,则损害 CLR 的完整性会很简单,因此请让其受不受信任的代码的摆布。

表 5 强制转换和 isinst 时间 (ns)

平均值 Min 基元 平均值 Min 基元
0.4 0.4 投出 1 0.8 0.8 isinst up 1
0.3 0.3 强制转换 0 0.8 0.8 isinst down 0
8.9 8.8 强制转换 1 6.3 6.3 isinst down 1
9.8 9.7 投 (涨 2) 下 1 10.7 10.6 isinst (up 2) down 1
8.9 8.8 强制转换 2 6.4 6.4 isinst down 2
8.7 8.6 强制转换 3 6.1 6.1 isinst down 3

表 5 显示了这些强制类型检查的开销。 从派生类型转换为基类型始终安全且自由;而从基类型到派生类型的强制转换必须经过类型检查。

选中 () 强制转换会将对象引用转换为目标类型,或引发 InvalidCastException

相比之下,isinstCIL 指令用于实现 C# as 关键字 (keyword) :

bac = ac as B;

如果 ac 不是 B 或派生自 B,则结果为 null,而不是异常。

列表 2 演示了其中一个强制转换计时循环,反汇编 9 显示一个强制转换到派生类型的生成的代码。 若要执行强制转换,编译器会发出对帮助程序例程的直接调用。

清单 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 = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)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 

属性

在托管代码中,属性是一对方法、一个属性 getter 和一个属性 setter,其作用类似于对象的字段。 get_ 方法提取 属性;set_ 方法将 属性更新为新值。

除此之外,属性的行为和成本,就像常规实例方法和虚拟方法一样。 如果使用属性只是提取或存储实例字段,则它通常与使用任何小型方法一样内联。

表 6 显示了提取 (和添加) 以及存储一组整数实例字段和属性所需的时间。 获取或设置属性的成本确实与直接访问基础字段的成本相同, 除非 属性声明为虚拟,在这种情况下,成本大约是虚拟方法调用的成本。 这并不奇怪。

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

平均值 Min 基元
1.0 1.0 get 字段
1.2 1.2 get prop
1.2 1.2 set 字段
1.2 1.2 set prop
6.4 6.3 获取虚拟属性
6.4 6.3 set virtual prop

写入屏障

CLR 垃圾回收器充分利用了“代系假设”(大多数新对象会年轻化),以最大程度地减少回收开销。

堆按逻辑划分为几代。 最新对象位于第 0 代 (第 0 代) 中。 这些对象尚未在集合中幸存下来。 在 Gen 0 集合期间,GC 确定哪些第 0 代对象可从 GC 根集访问,其中包括计算机寄存器中的对象引用、堆栈上的对象引用、类静态字段对象引用等。可传递可访问的对象是“实时”的, (复制到第 1 代) 提升。

由于总堆大小可能为数百 MB,而第 0 代堆大小可能只有 256 KB,因此将 GC 的对象图跟踪范围限制为第 0 代堆是实现 CLR 非常短暂的收集暂停时间所必需的优化。

但是,可以将对第 0 代对象的引用存储在第 1 代或第 2 代对象的对象引用字段中。 由于我们在第 0 代集合期间不扫描第 1 代或第 2 代对象,如果这是对给定第 0 代对象的唯一引用,则 GC 可能会错误地回收该对象。 我们不能让这种情况发生!

相反,堆中所有对象引用字段的所有存储都会产生 写入屏障。 这是簿记代码,它有效地将新一代对象引用的存储记录到旧一代对象的字段中。 此类旧对象引用字段将添加到后续 GC () 的 GC 根集。

每个 object-reference-field-store 的写入屏障开销与表 7) (简单方法调用的成本相当。 这是本机 C/C++ 代码中不存在的新费用,但对于超快速的对象分配和 GC,以及自动内存管理的许多生产力优势,这通常是一个很小的价格。

表 7 写入屏障时间 (ns)

平均值 Min 基元
6.4 6.4 写入屏障

在紧密的内部循环中,写入屏障的成本可能很高。 但在未来几年内,我们可以期待先进的编译技术,以减少占用的写入障碍数和总摊销成本。

你可能认为仅在引用类型的对象引用字段的存储上才需要写入屏障。 但是,在值类型方法中,如果任何) 也受到写入屏障的保护,则存储到其对象引用字段 (。 这是必需的,因为值类型本身有时可能嵌入到驻留在堆中的引用类型中。

Array 元素访问

为了诊断和排除数组超出边界错误和堆损坏,并保护 CLR 本身的完整性,将检查数组元素加载和存储,确保索引在间隔 [0,array 内。长度-1](包括 或引发 IndexOutOfRangeException)。

我们的测试测量加载或存储数组和A[]数组元素int[]的时间。 (表 8) 。

表 8 数组访问时间 (ns)

平均值 Min 基元
1.9 1.9 load int array elem
1.9 1.9 store int array elem
2.5 2.5 load obj array elem
16.0 16.0 store obj array elem

边界检查需要将数组索引与隐式数组进行比较。长度字段。 正如 Disassembly 10 所示,在两个指令中,我们检查索引既不小于 0,也不大于或等于数组。长度 - 如果是,则分支到引发异常的行外序列。 对于对象数组元素的负载以及存储到 int 数组和其他简单值类型的数组中,也是如此。 (Load obj 数组 elem 时间 (微不足道) 慢,因为其内部循环略有差异。)

反汇编 10 加载 int 数组元素

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

通过代码质量优化,JIT 编译器通常消除冗余边界检查。

回顾前面的部分,我们可以预期 对象数组元素存储 的成本要高得多。 若要将对象引用存储到对象引用数组中,运行时必须:

  1. 检查数组索引处于边界内;
  2. 检查 对象是数组元素类型的实例;
  3. 执行写入屏障 (记下从数组到对象) 的任何代际对象引用。

此代码序列相当长。 编译器发出对共享帮助程序函数的调用,而不是在每个对象数组存储站点发出它,如反汇编 11 中所示。 此调用加上这三个操作会考虑在这种情况下所需的额外时间。

反汇编 11 Store 对象数组元素

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

装箱和取消装箱

通过 .NET 编译器与 CLR 之间的合作关系,值类型(包括 int (System.Int32) 等基元类型)可以像作为引用类型一样参与其中,作为对象引用进行寻址。 这种提供(这种语法糖)允许将值类型作为对象传递给方法,以对象的形式存储在集合中,等等。

对于“box”,值类型是创建一个保存其值类型副本的引用类型对象。 这在概念上与使用与值类型相同类型的未命名实例字段创建类相同。

若要“取消装箱”,已装箱的值类型是将值从 对象复制到值类型的新实例中。

如表 9 所示,与表 4) 相比 (,将 int 装箱以及以后进行垃圾回收所需的摊销时间与实例化具有一个 int 字段的小类所需的时间相当。

表 9 开箱和取消装箱 int 时间 (ns)

平均值 Min 基元
29.0 21.6 box int
3.0 3.0 取消装箱 int

若要取消装箱的 int 对象,需要显式强制转换为 int。这会编译为对象的类型 (由其方法表地址) 和装箱 int 方法表地址表示的比较。 如果它们相等,则会从 对象中复制该值。 否则会引发异常。 请参阅反汇编 12。

反汇编 12 盒和取消装箱 int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

委托

在 C 中,指向函数的指针是字面上存储函数地址的基元数据类型。

C++ 将指针添加到成员函数。 指向成员函数的指针 (PMF) 表示延迟的成员函数调用。 非虚拟成员函数的地址可以是一个简单的代码地址,但虚拟成员函数的地址必须体现特定的虚拟成员函数调用 - 此类 PMF 的取消引用 虚拟函数调用。

若要取消引用 C++ PMF,必须提供 实例:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

几年前,在 Visual C++ 编译器开发团队中,我们常问自己, (无函数调用运算符) 是什么样的裸体表达式 pa->*pmf ? 我们将其称为 指向成员函数的绑定指针 ,但 潜在成员函数调用 与 apt 相同。

返回托管代码后,委托对象就是一个潜在方法调用。 委托对象表示要调用的方法和调用它的实例,或者表示对静态方法的委托,只是要调用的静态方法。

(如文档所述:委托声明定义可用于封装具有特定签名的方法的引用类型。委托实例封装静态或实例方法。委托大致类似于 C++ 中的函数指针;但是,委托是类型安全的和安全的.)

C# 中的委托类型是 MulticastDelegate 的派生类型。 此类型提供丰富的语义,包括生成调用委托时要调用的 (对象、方法) 对的调用列表的功能。

委托还为异步方法调用提供一个工具。 定义委托类型并实例化使用潜在方法调用初始化的委托类型后,可以通过 以同步方式 (方法调用语法) 或异步 BeginInvoke调用它。 如果 BeginInvoke 调用 ,运行时会将调用排队,并立即返回给调用方。 稍后在线程池线程上调用目标方法。

所有这些丰富的语义都不便宜。 比较表 10 和表 3,请注意委托调用的速度比方法调用慢大约 8 倍。 预计这一点会随着时间的推移而改进。

表 10 委托调用时间 (ns)

平均值 Min 基元
41.1 40.9 委托调用

缓存未命中、页面错误和计算机体系结构

早在 1983 年左右的“旧时代”中,处理器) 速度缓慢 (约 50 万条指令/秒,相对而言,RAM 速度足够快,但在 256 KB DRAM) 上访问时间较小, (大约 300 ns 访问时间,而磁盘 (在 10 MB 磁盘) 上访问时间大约为 25 毫秒。 PC 微处理器是标量 CCC,大多数浮点在软件中,没有缓存。

经过二十多年的摩尔定律, 大约在 2003 年,处理器 (以 3 GHz) 每个周期发出最多三个操作,RAM 相对非常慢, (在 512 MB 的 DRAM) 上,访问时间大约为 100 ns,磁盘速度慢,在 100 GB 磁盘) 上 (大约 10 毫秒的访问时间。 PC 微处理器现在是无序数据流超标超线程跟踪缓存 RISC, (运行解码的 CISC 指令) ,并且有多个缓存层,例如, 某个面向服务器的微处理器具有 32 KB 级别 1 的数据缓存 (可能有 2 个周期的延迟) 、512 KB L2 数据缓存和 2 MB L3 数据缓存, (十几个周期的延迟) , 全部在芯片上。

在良好的时代,你可以(有时)计算你编写的代码的字节数,并计算代码运行所需的周期数。 加载或存储所花费的周期数与添加的周期数大致相同。 新式处理器使用分支预测、推理和无序 (数据流) 跨多个函数单元执行来查找指令级并行度,从而一次性在多个方面取得进展。

现在,最快的电脑每微秒最多可以发出约 9000 次操作,但在相同的微秒内,仅加载或存储到 DRAM 大约 10 个缓存行。 在计算机体系结构圈中,这称为 撞内存。 缓存会隐藏内存延迟,但仅达到某个点。 如果代码或数据不适合缓存,并且/或表现出较差的参考位置,我们的每微秒 9000 次操作超音速喷气机会退化为每微秒 10 个负载的三轮车。

(不要让这种情况发生,) 如果程序的工作集超过可用的物理 RAM,并且程序开始出现硬页错误,那么在每 10,000 微秒的页面故障服务 (磁盘访问) ,我们错失了使用户最多 9000 万次操作更接近其答案的机会。 这太可怕了,我相信你从今天起会小心测量工作集 (vadump) ,并使用 CLR Profiler 等工具来消除不必要的分配和无意的对象图保留。

但是,这一切与了解托管代码基元的成本有什么关系呢?一切*.*

回顾表 1,托管代码基元时间综合列表(以 1.1 GHz P-III 测量)观察到,即使分配、初始化和回收具有五个显式构造函数调用级别的五个字段对象的摊销成本,也比单个 DRAM 访问 更快 。 与几乎任何单个托管代码操作相比,只有一个缺少所有级别的片上缓存的负载可能需要更长的时间才能提供服务。

因此,如果你对代码速度充满热情,则必须在设计和实现算法和数据结构时考虑 并测量 缓存/内存层次结构。

进行简单演示的时间:对 ints 数组求和或求和 ints 的等效链接列表是否更快? 哪一个,有多少,为什么?

想想看一下。 对于小型项(如 ints),每个数组元素的内存占用量是链接列表的四分之一。 (每个链接列表节点都有两个对象开销字和两个字段字 (下一个链接和 int 项) .) 这将损害缓存利用率。 数组方法的分数为 1。

但数组遍历可能会导致每个项检查数组边界。 你刚刚看到,检查边界需要一些时间。 也许这提示比例有利于链接列表?

反汇编 13 总和 int 数组与总和 int 链接列表

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

提到 Disassembly 13,我已经堆叠了幻灯片,以支持链接列表遍历,展开它四次,甚至删除了通常的 null 指针列表末尾检查。 数组循环中的每个项都需要六条指令,而链接列表循环中的每个项只需要 11/4 = 2.75 指令。 现在,你认为哪个更快?

测试条件:首先,创建一个包含 100 万 ints 的数组,并创建一个包含 100 万 ints 的简单传统链接列表, (1 M 列表节点) 。 然后,每个项需要多长时间才能将前 1,000、10,000、100,000 和 1,000,000 项加起来。 多次重复每个循环,以衡量每种情况最讨人喜欢的缓存行为。

哪个更快? 猜完后,请参阅答案:表 1 中的最后 8 个条目。

有趣! 当引用的数据增长大于连续缓存大小时,时间会明显变慢。 数组版本始终比链接列表版本快,即使它执行的指令数是链接列表版本的两倍;对于 100,000 个项目,数组版本快 7 倍!

为什么会这样? 首先,任何给定缓存级别中容纳的链接列表项更少。 所有这些对象标头和链接都会浪费空间。 其次,我们的新式无序数据流处理器可能会提前缩放,并同时在数组中的多个项上取得进展。 相比之下,对于链接列表,在当前列表节点处于缓存中之前,处理器无法开始提取指向该节点的下一个链接。

在 100,000 个项目的情况下,处理器平均花费 () 大约 (22-3.5) /22 = 84% 的时间扭动其拇指等待某些列表节点的缓存行从 DRAM 读取。 这听起来不好,但情况 可能会更糟 。 由于链接列表项较小,因此其中许多项适合缓存行。 由于我们按分配顺序遍历列表,并且垃圾回收器在将死对象从堆中压缩时仍保留分配顺序,因此在提取缓存行上的一个节点后,接下来的几个节点现在可能也位于缓存中。 如果节点较大,或者列表节点以随机地址顺序排列,则访问的每个节点很可能都是完全缓存未命中。 向每个列表节点添加 16 个字节会将每个项的遍历时间加倍至 43 ns:+32 字节,67 ns/item;和添加 64 字节再次翻倍,达到 146 ns/item,可能是测试计算机上的平均 DRAM 延迟。

那么,这里的外卖课程是什么? 避免包含 100,000 个节点的链接列表? “否”。 教训是,缓存效果可以主导托管代码与本机代码低级别效率的任何考虑因素。 如果要 编写性能关键型托管代码(尤其是管理大型数据结构的代码),请牢记缓存效果,考虑数据结构访问模式,并努力实现较小的数据占用量和良好的引用位置。

顺便说一句,内存墙(DRAM 访问时间之比除以 CPU 操作时间)会随着时间的推移而继续变差。

下面是一些“缓存意识设计”经验规则:

  • 试验和测量你的方案,因为它很难预测二阶效果,而且经验法则不值得打印它们。
  • 某些数据结构(例如数组)利用 隐式邻接 来表示数据之间的关系。 其他链接列表则使用 显式指针 (引用) 来表示关系。 隐式邻接通常更可取 - 与指针相比,“隐式”可节省空间;和 相邻提供稳定的引用位置,并可能允许处理器在追逐下一个指针之前开始更多工作。
  • 某些使用模式偏向于混合结构-小数组、数组数组或 B 树的列表。
  • 也许应该回收磁盘访问敏感型计划算法,在磁盘访问仅花费 50,000 个 CPU 指令时设计,现在 DRAM 访问可能需要数千个 CPU 操作。
  • 由于 CLR 标记和紧凑垃圾回收器保留对象的相对顺序, 因此 (和同一线程上) 一起分配的对象往往保留在空间中。 可以使用这种现象在常用缓存行 () 上精心地并置 cliquish 数据。
  • 你可能希望将数据分区为经常遍历且必须适合缓存的热部分,以及不常使用且可以“缓存出”的冷部分。

自行完成时间试验

对于本文中的计时度量,我使用了 Win32 高分辨率性能计数器 QueryPerformanceCounter (和 QueryPerformanceFrequency) 。

它们通过 P/Invoke 轻松调用:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

只需在计时循环之前和之后调用 QueryPerformanceCounter ,减去计数,乘以 1.0e9,除以频率,再除以迭代次数,即每次迭代的大致时间(以 ns 为单位)。

由于空间和时间限制,我们未涵盖锁定、异常处理或代码访问安全系统。 对于读者来说,这是一项练习。

顺便说一句,我使用 2003 VS.NET 中的反汇编窗口生成了本文中的反汇编。 然而,有一个技巧。 如果在 VS.NET 调试器中运行应用程序,即使作为在发布模式下生成的优化可执行文件,它也会在禁用优化(如内联)的“调试模式”下运行。 我发现查看 JIT 编译器发出的优化本机代码的唯一方法是在调试器 外部 启动测试应用程序,然后使用 Debug.Process.Attach 附加到它。

空间成本模型?

具有讽刺意味的是,空间考虑排除了对空间的彻底讨论。 然后,几个简短的段落。

低级别注意事项 (C# (默认 TypeAttributes.SequentialLayout) 和特定于 x86 的) :

  • 值类型的大小通常是其字段的总大小,4 字节或更小的字段与其自然边界对齐。
  • [StructLayout(LayoutKind.Explicit)]可以使用 和 [FieldOffset(n)] 属性来实现联合。
  • 引用类型的大小为 8 个字节加上其字段的总大小,向上舍入到下一个 4 字节边界,并且 4 字节或更小的字段与其自然边界对齐。
  • 在 C# 中,枚举声明可以指定任意整型基类型 (字符) 除外,因此可以定义 8 位、16 位、32 位和 64 位枚举。
  • 与 C/C++ 中一样,通常可以通过适当调整整型字段的大小,在较大对象上减少几十%的空间。
  • 可以使用 CLR 探查器检查分配的引用类型的大小。
  • (数十 KB 或更多) 的大型对象在单独的大型对象堆中管理,以防止成本高昂的复制。
  • 可终结对象需要额外的 GC 生成来回收 - 请谨慎使用它们,并考虑使用 Dispose 模式。

大局注意事项:

  • 每个 AppDomain 当前都会产生大量空间开销。 许多运行时和框架结构不跨 AppDomain 共享。
  • 在进程中,通常不会在 AppDomains 之间共享 jitted 代码。 如果运行时是专门托管的,可以重写此行为。 请参阅 和 STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN 标志的文档CorBindToRuntimeEx
  • 在任何情况下,吉特代码都不会在进程之间共享。 如果某个组件将加载到许多进程中,请考虑使用 NGEN 进行预编译以共享本机代码。

反射

有人说,“如果你必须问什么反射成本,你负担不起”。 如果你已经读到这么远,你知道询问成本,并衡量这些成本是多么重要。

反射非常有用且功能强大,但与支取的本机代码相比,反射既不快也不小。 你被警告过 自行测量。

结论

现在,你知道 (或多或少) 最低级别的托管代码成本。 现在,你已经基本了解了实现更智能的权衡和编写更快的托管代码所必需的。

我们已经看到,抖动的托管代码可以像本机代码一样“踩到金属”。 你的挑战是明智地编码,并在框架中许多丰富且易于使用的设施中明智地选择

有些设置的性能并不重要,而设置是产品最重要的功能。 过早优化 所有邪恶的根源。 但对效率的粗心大意也是如此。 你是一个专业人士,一个艺术家,一个工匠。 因此,请确保你知道事情的成本。 如果你不知道,或者即使你认为你这样做,定期测量它。

至于 CLR 团队,我们将继续努力提供一个平台,该平台 的工作效率比本机代码高 得多,但 比本机代码更快。 期待事情越来越好。 敬请期待!

记住你的承诺。

资源