编写更快的托管代码:了解成本
Jan Gray
Microsoft CLR 性能团队
2003 年 6 月
适用于:
® Microsoft .NET Framework
摘要: 本文根据测量的操作时间提供了托管代码执行时间的低成本模型,以便开发人员可以做出更明智的编码决策并编写更快的代码。 (30 页打印)
下载 CLR Profiler。 (330KB)
内容
简介(和承诺)
面向托管代码的成本模型
托管代码中的成本
结论
资源
简介(和承诺)
实现计算的方法有很多,有些方法比其他方法要好得多:更简单、更简洁、更易于维护。 有些方法速度极快,有些方式速度惊人。
不要对世界实施缓慢和脂肪的代码。 你不鄙视这样的代码吗? 以适合和启动方式运行的代码? 一次锁定 UI 的代码数秒? 将 CPU 固定或阻止磁盘的代码?
别这样。 相反,站起来和我一起承诺:
“我承诺我不会交付缓慢的代码。 速度是我关心的功能。 每天我会注意代码的性能。 我会定期和有条不紊地 测量其速度和大小。 我将学习、构建或购买我需要执行此操作的工具。 这是我的责任。
(真的。你答应了吗? 适合你。
那么,如何 你每天编写最快、最严格的代码? 这是一个有意识地选择节俭的方式,而不是奢侈,膨胀的方式,一次又一次,以及思考后果的问题。 任何给定的代码页都会捕获数十个此类小决策。
但是,如果你不知道什么成本,你不能在替代项之间进行明智的选择:如果你不知道什么成本,你不能编写高效的代码。
在美好的时代,这更容易。 优秀的 C 程序员知道。 C 中的每个运算符和运算,无论是赋值、整数或浮点数学、取消引用或函数调用,还是将一对一映射到单个基元计算机操作。 如此,有时需要几个机器指令将正确的操作数放在正确的寄存器中,有时单个指令可以捕获多个 C 操作(著名的 *dest++ = *src++;
),但通常可以编写(或读取)一行 C 代码并知道时间的去向。 对于代码和数据,C 编译器是 WYWIWYG —“你编写的就是你得到的内容”。 (异常是,也是函数调用。如果不知道函数的成本,则不知道该函数的成本。
在20世纪90年代,为了享受数据抽象、面向对象的编程和代码重用的许多软件工程和生产力优势,电脑软件行业从 C 过渡到C++。
C++是 C 的超集,并且是“即用即付”(如果不使用这些新功能),因此 C 编程专业知识(包括内部化成本模型)直接适用。 如果采用一些工作 C 代码并将其重新编译为 C++,则执行时间和空间开销不应发生太大变化。
另一方面,C++引入了许多新的语言功能,包括构造函数、析构函数、new、delete、single、multiple 和 virtual 继承、强制转换、成员函数、虚拟函数、重载运算符、指向成员、对象数组、异常处理和相同构成的指针,这会产生不小的隐藏成本。 例如,虚拟函数为每个调用花费两个额外的间接费用,并向每个实例添加一个隐藏的 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! Whoo-hoo!
这一切都是如此简单。 确实如此容易。 因此,只需从中拉出几个元素,即可轻松燃烧 MB 的 RAM 分析 XML 信息集。 在 C 或 C++它是如此痛苦, 你会两次思考, 也许你会在一些类似于 SAX 的 API 上生成状态机。 使用 .NET Framework,只需在一个 gulp 中加载整个信息集。 也许你甚至一遍又一遍地这样做。 然后,也许应用程序看起来不再这么快了。 也许它有一组工作集,包含许多兆字节。 也许你应该两次思考那些简单的方法的成本...
遗憾的是,在我看来,当前的 .NET Framework 文档并未充分详细说明框架类型和方法的性能影响,甚至不会指定哪些方法可能会创建新对象。 性能建模并非易事,需要涵盖或记录;但是,“不知道”使我们更难做出明智的决定。
由于我们在这里都是新手,由于我们不知道有什么成本,而且由于成本没有明确记录,我们该怎么办?
测量它。 秘密是 衡量它,并 保持警惕。 我们都得习惯测量事情的成本。 如果我们去衡量什么成本的问题,那么我们就不会无意中调用一种低调的新方法,这种新方法的成本是 假设 成本的十倍。
(顺便说一句,若要更深入地了解 BCL(基类库)或 CLR 本身的性能基础,请考虑查看 共享源 CLI,即马达。 转子代码与 .NET Framework 和 CLR 共享血线。 这在整个代码中并不一样,但即便如此,我保证,对罗托的深思熟虑的研究将让你对 CLR 的幕后发生的事情有新的见解。 但请务必先查看 SSCLI 许可证!
知识
如果你渴望成为伦敦的出租车司机,你首先必须赢得 知识。 学生学习了几个月来,以记住伦敦的数千条小街道,并学习从地方到地方的最佳路线。 他们每天都在摩托车上出去侦察,加强他们的书学习。
同样,如果要成为高性能托管代码开发人员,则必须获取 托管代码知识。 必须了解每个低级别操作成本。 必须了解委托和代码访问安全成本等功能。 你必须了解所使用的类型和方法的成本,以及你正在编写的类型和方法。 发现哪些方法对于应用程序来说可能太昂贵,这不会造成伤害,因此请避免它们。
知识不在任何书里, 唉唉。 你必须走出 你的 滑板车和探索 - 也就是说,调高 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'ing 程序集当前对执行时间没有重大影响(好或坏),但它可以减少加载到许多 AppDomain 和进程的共享程序集的总工作集。 (OS 可以在所有客户端之间共享 NGEN'd 代码的一个副本;而已抖动的代码通常不是跨 AppDomains 或进程共享的。但另见 LoaderOptimizationAttribute.MultiDomain
。
自动内存管理
托管代码最重要的离别(与本机代码)是自动内存管理。 分配新对象,但 CLR 垃圾回收器(GC)会在无法访问对象时自动为你释放它们。 GC 现在和再次运行,通常无法察觉地停止应用程序,通常只需一两毫秒(偶尔会更长)。
其他几篇文章讨论了垃圾回收器的性能影响,我们不会在这里回顾它们。 如果应用程序遵循这些其他文章中的建议,则垃圾回收的总体成本可能微不足道,执行时间的几百分比,与传统C++对象竞争或优于传统C++对象 new
和 delete
。 创建和以后自动回收对象的摊销成本足够低,每秒可以创建数千万个小对象。
但对象分配仍未 免费。 对象占用空间。 猖獗的对象分配会导致更频繁的垃圾回收周期。
更糟的是,不必要地保留对无用对象图的引用使它们保持活动状态。 我们有时会看到具有可悲的 100+ MB 工作集的适度程序,其作者否认了他们的罪责,而是将性能不佳归因于一些神秘、身份不明(因此难以理解)的托管代码本身问题。 这是悲惨的。 但是,然后,通过 CLR 探查器进行一个小时的研究,并更改了几行代码,将堆使用量削减了 10 倍或更多。 如果遇到大型工作集问题,第一步是查看镜像。
因此,不要不必要的创建对象。 仅仅因为自动内存管理消除对象分配和释放的许多复杂性、麻烦和 bug,因为它如此之快且如此方便,我们自然倾向于创建越来越多的对象,就像它们生长在树上一样。 如果要编写非常快速的托管代码,请以深思熟虑和适当方式创建对象。
这也适用于 API 设计。 可以设计类型及其方法,以便它们 需要 客户端创建具有野生放弃的新对象。 别这样。
托管代码中的成本
现在,让我们考虑各种低级别托管代码操作的时间成本。
表 1 在运行 Windows XP 和 .NET Framework v1.1(“Everett”)的一组简单计时循环收集的静止 1.1 GHz Pentium-III 电脑上,呈现各种低级别托管代码操作的大致成本(以纳秒为单位)。
测试驱动程序调用每个测试方法,指定要执行的迭代次数,并自动缩放为循环访问 218 到 230 迭代,必要时至少执行每个测试 50 毫秒。 一般来说,这足以在测试中观察第 0 代垃圾回收的几个周期,该测试会进行激烈的对象分配。 该表显示平均超过 10 次试验的结果,以及每个测试科目的最佳(最短时间)试验。
每个测试循环将根据需要取消注册 4 到 64 次,以降低测试循环开销。 我检查了为每个测试生成的本机代码,以确保 JIT 编译器未优化测试,例如,在某些情况下,我修改了测试以在测试循环期间和之后保持中间结果的运行状态。 同样,我在多个测试中进行了更改,以排除常见的子表达式消除。
表 1 基元时间(平均值和最小值)
Avg | 最小值 | 原始 | Avg | 最小值 | 原始 | Avg | 最小值 | 原始 |
---|---|---|---|---|---|---|---|---|
0.0 | 0.0 | 控制 | 2.6 | 2.6 | 新 valtype L1 | 0.8 | 0.8 | isinst up 1 |
1.0 | 1.0 | Int add | 4.6 | 4.6 | 新 valtype L2 | 0.8 | 0.8 | isinst down 0 |
1.0 | 1.0 | Int 子 | 6.4 | 6.4 | 新 valtype L3 | 6.3 | 6.3 | isinst down 1 |
2.7 | 2.7 | Int mul | 8.0 | 8.0 | 新 valtype L4 | 10.7 | 10.6 | isinst (up 2) down 1 |
35.9 | 35.7 | Int div | 23.0 | 22.9 | 新 valtype L5 | 6.4 | 6.4 | isinst down 2 |
2.1 | 2.1 | Int shift | 22.0 | 20.3 | 新 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 子 | 30.2 | 27.5 | 新 reftype L3 | 1.2 | 1.2 | get prop |
34.2 | 34.1 | 长 mul | 34.1 | 30.8 | 新 reftype L4 | 1.2 | 1.2 | set 字段 |
50.1 | 50.0 | long div | 39.1 | 34.4 | 新 reftype L5 | 1.2 | 1.2 | 设置道具 |
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 子 | 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 | 新的 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 | 双 mul | 32.7 | 29.9 | new reftype ctor L3 | 1.9 | 1.9 | 加载 int 数组 elem |
27.7 | 27.6 | double div | 37.7 | 34.1 | 新的 reftype ctor L4 | 1.9 | 1.9 | 存储 int 数组 elem |
0.2 | 0.2 | 内联静态调用 | 43.2 | 39.1 | 新的 reftype ctor L5 | 2.5 | 2.5 | 加载 obj 数组 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 | 内联此内联调用 | 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 | 此 itf 虚拟调用 | 21.5 | 21.4 | sum list 1000000 |
免责声明:请不要太字面上接受此数据。 时间测试充满了意外的第二阶影响的危险。 发生偶然性可能会放置被抖动的代码或某些关键数据,以便它跨越缓存行、干扰其他内容或你拥有的内容。 这有点像不确定性原则:1 纳秒左右的时间差是可观测的极限。
另一个免责声明:此数据仅适用于完全适合缓存中的小型代码和数据方案。 如果应用程序的“热”部分不适合芯片上缓存,则很可能有一组不同的性能挑战。 关于接近纸张末尾的缓存,我们有更多的话要说。
还有另一个免责声明:将组件和应用程序作为 CIL 程序集交付的崇高优势之一是,程序可以自动每秒获得更快的速度,并每年获得更快的速度-“更快的每秒”,因为运行时可以(理论上)在程序运行时重新优化 JIT 编译的代码:和“更快一年”,因为随着运行时的每个新版本,更好的、更智能、更快的算法可以采用全新的表来优化代码。 因此,如果其中一些时间在 .NET 1.1 中看起来不太理想,请从心中了解它们应在产品的后续版本中改进。 本文中报告的任何给定代码本机代码序列可能会在 .NET Framework 的未来版本中更改。
除了免责声明之外,数据确实为各种基元的当前性能提供了合理的直觉。 这些数字有意义,它们证实了我的断言,即大多数被抖动的托管代码运行“靠近计算机”,就像编译的本机代码一样。 基元整数和浮点运算速度较快,各种方法调用较少,但(信任我)仍可比本机 C/C++;然而,我们还看到,一些操作在本机代码(强制转换、数组和字段存储、函数指针(委托)中通常很便宜,现在成本更高。 为什么? 我看看。
算术运算
表 2 算术运算时间 (ns)
Avg | 最小值 | 原始 | Avg | 最小值 | 原始 |
---|---|---|---|---|---|
1.0 | 1.0 | int add | 1.3 | 1.3 | float add |
1.0 | 1.0 | int 子 | 1.4 | 1.4 | float 子 |
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 子 | 1.5 | 1.5 | double sub |
34.2 | 34.1 | 长 mul | 2.1 | 2.0 | 双 mul |
50.1 | 50.0 | long div | 27.7 | 27.6 | double div |
5.1 | 5.1 | 长班 |
在旧时代,浮点数学可能比整数数学要慢一个数量级。 如表 2 所示,使用新式管道浮点单位,它似乎几乎没有或没有区别。 认为普通笔记本电脑现在是千兆瓦级计算机(适用于缓存中的问题),这真是令人吃惊。
让我们看看整数和浮点添加测试中的一行抖动代码:
反汇编 1 Int 添加和浮点添加
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
,以消除常见的子表达式消除。
方法调用
在本部分中,我们将研究方法调用的成本和实现。 测试科目是一个类,T
实现接口 I
,具有各种方法。 请参阅列表 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)
Avg | 最小值 | 原始 | 被调用方 | Avg | 最小值 | 原始 | 被调用方 |
---|---|---|---|---|---|---|---|
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 | 内联此内联调用 | inl_i1 |
0.2 | 0.2 | 此 itf 实例调用 | itf1 |
6.2 | 6.2 | 此实例调用 | i1 |
5.4 | 5.4 | inst itf 虚拟调用 | itf5 |
5.4 | 5.4 | 此 itf 虚拟调用 | 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)
属性。
相同的方法用于内联实例调用和常规实例调用计时。 但是,由于 C# 语言规范可确保对 null 对象引用的任何调用都引发 NullReferenceException,因此每个调用站点都必须确保实例不为 null。 这是通过取消引用实例引用来完成的;如果 为 null,它将生成转换为此异常的错误。
在反汇编 2 中,我们使用静态变量 t
作为实例,因为在使用局部变量时
T t = new T();
编译器启动 null 实例签出循环。
反汇编 2 实例方法调用站点,其中 null 实例“check”
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
基元计时的其余部分,itf 实例调用,此 itf 实例调用,itf 虚拟调用,此 itf 虚拟调用 突出显示当派生类型的方法实现接口方法时,它仍可通过实例方法调用站点调用。
例如,对于测试 此 itf 实例调用(通过实例(非接口)引用对接口方法实现的调用,接口方法已成功内联,成本将转到 0 ns。 当你将其作为实例方法调用时,即使是接口方法实现也可能是内联的。
对尚未被 Jitted 的方法的调用
对于静态和实例方法调用(但不是虚拟和接口方法调用),JIT 编译器当前会生成不同的方法调用序列,具体取决于目标方法在调用站点被抖动时是否已被抖动。
如果被调用方(target 方法)尚未被打断,编译器会通过指针间接发出调用,该指针首先使用“prejit 存根”进行初始化。 目标方法的第一次调用到达存根,这会触发该方法的 JIT 编译、生成本机代码并更新指针以处理新的本机代码。
如果被调用方已被抖动,则其本机代码地址是已知的,因此编译器会发出对其的直接调用。
新建对象
新建对象包括两个阶段:对象分配和对象初始化。
对于引用类型,对象在垃圾回收堆上分配。 对于值类型,无论是堆栈驻留类型还是嵌入在另一个引用类型或值类型中,值类型对象都位于封闭结构的某些常量偏移处,无需分配。
对于典型的小型引用类型对象,堆分配非常快。 每次垃圾回收后,除了存在固定对象外,第 0 代堆中的实时对象将被压缩并提升为第 1 代,因此内存分配器具有很好的大型连续可用内存竞技场来处理。 大多数对象分配只产生指针增量和边界检查,这比典型的 C/C++ 免费列表分配器(malloc/operator new)便宜。 垃圾回收器甚至考虑到计算机的缓存大小,以尝试将第 0 代对象保留在缓存/内存层次结构的快速甜点。
由于首选托管代码样式是分配大多数生存期较短的对象,并快速回收它们,因此我们还包括(在时间成本中)这些新对象的垃圾回收的摊销成本。
请注意,垃圾回收器没有时间哀悼死对象。 如果对象死了,GC 看不到它,不走它,不给它一个纳米秒的想法。 GC 只关心生活的福利。
(例外:可终结的死对象是一种特殊情况。GC 跟踪这些对象,并特别将死定对象提升到下一代待完成的终结对象。这是昂贵的,在最坏的情况下可以传递地提升大型死对象图。因此,除非严格必要,否则不要使对象可完成;如果必须考虑使用 释放模式,请尽可能调用 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-add-times 中使用四个 int 字段。 反汇编 6 是从三个不同的计时循环生成的代码,创建 A、C 和 E。 (在每个循环中,我们修改每个新实例,使 JIT 编译器无法优化所有内容。
表 4 值和引用类型对象创建时间 (ns)
Avg | 最小值 | 原始 | Avg | 最小值 | 原始 | Avg | 最小值 | 原始 |
---|---|---|---|---|---|---|---|---|
2.6 | 2.6 | 新 valtype L1 | 22.0 | 20.3 | 新 reftype L1 | 22.9 | 20.7 | new rt ctor L1 |
4.6 | 4.6 | 新 valtype L2 | 26.1 | 23.9 | new reftype L2 | 27.8 | 25.4 | new rt ctor L2 |
6.4 | 6.4 | 新 valtype L3 | 30.2 | 27.5 | 新 reftype L3 | 32.7 | 29.9 | new rt ctor L3 |
8.0 | 8.0 | 新 valtype L4 | 34.1 | 30.8 | 新 reftype L4 | 37.7 | 34.1 | new rt ctor L4 |
23.0 | 22.9 | 新 valtype L5 | 39.1 | 34.4 | 新 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
、sans 用户定义的构造函数的五种引用类型的继承级别:
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
最后三组五个计时在继承的类构造方案中存在差异。
新的 rt 空函数 L1,...,新的 rt 空函数 L5: 每种类型
A
,...,E
具有空的用户定义的构造函数。 这些代码全部内联,生成的代码与上述代码相同。新的 rt ctor L1、...、新的 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
新的 rt no-inl L1,...,新的 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 探查器。 CLR 探查器(以前称为分配探查器)使用 CLR 分析 API 来收集事件数据,尤其是在应用程序运行时调用、返回和对象分配和垃圾回收事件。 (CLR 探查器是一个“侵入性”探查器,这意味着它不幸地减慢了分析的应用程序。收集事件后,可以使用 CLR Profiler 浏览应用程序的内存分配和 GC 行为,包括分层调用图与内存分配模式之间的交互。
CLR Profiler 值得学习,因为对于许多“性能挑战”托管代码应用程序,了解数据分配配置文件提供了减少工作集所需的关键见解,因此提供快速节俭的组件和应用程序。
CLR 探查器还可以揭示哪些方法分配的存储量超过预期,并可以发现无意中保留对无用对象图的引用,否则可以由 GC 回收。 (常见的问题设计模式是不再需要或以后安全重建的项目的软件缓存或查找表。当缓存使对象图保持活动超过其有用生命时,这是悲惨的。相反,请务必将不再需要的对象引用为 null。
图 1 是执行计时测试驱动程序期间堆的时间线视图。 锯齿模式表示 C
(洋红)、D
(紫色)和 E
(蓝色)对象实例的分配。 每隔几毫秒,我们在新对象(第 0 代)堆中咀嚼另一个约 150 KB 的 RAM,垃圾回收器会短暂运行以回收它,并将任何实时对象提升到第 1 代。 值得注意的是,即使在这种侵入性(缓慢)分析环境中,在 100 毫秒(2.8 秒到 2.9 秒)的间隔内,我们也会经历大约 8 代 0 GC 周期。 然后,在 2.977 秒,为另一个 E
实例腾出空间,垃圾回收器执行第 1 代垃圾回收,这会收集和压缩第 1 代堆,因此锯齿从较低的起始地址继续。
图 1 CLR 探查器时间线视图
请注意,对象(E 大于 D 大于 C),第 0 代堆填充的速度越快,GC 周期越频繁。
强制转换和实例类型检查
安全、安全、可验证 托管代码的基础是类型安全性。 如果可以将对象强制转换为它不是的类型,那么损害 CLR 的完整性非常简单,因此请以不受信任的代码为由对其进行处理。
表 5 强制转换和 isinst Times (ns)
Avg | 最小值 | 原始 | Avg | 最小值 | 原始 |
---|---|---|---|---|---|
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
。
相比之下,isinst
CIL 指令用于实现 C# as
关键字:
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 和一个属性集,类似于对象的字段。 get_ 方法提取属性;set_ 方法将属性更新为新值。
除此之外,属性的行为和成本,就像常规实例方法和虚拟方法一样。 如果使用属性只是提取或存储实例字段,则通常与使用任何小方法一样内联。
表 6 显示了提取(并添加)以及存储一组整数实例字段和属性所需的时间。 获取或设置属性的成本确实与直接访问基础字段相同,除非 属性声明为虚拟,在这种情况下,成本大约是虚拟方法调用的成本。 那里并不奇怪。
表 6 字段和属性时间 (ns)
Avg | 最小值 | 原始 |
---|---|---|
1.0 | 1.0 | 获取字段 |
1.2 | 1.2 | get prop |
1.2 | 1.2 | set 字段 |
1.2 | 1.2 | 设置道具 |
6.4 | 6.3 | 获取虚拟道具 |
6.4 | 6.3 | 设置虚拟道具 |
写入屏障
CLR 垃圾回收器充分利用“代际假设”(大多数新对象死亡的年轻),以最大程度地减少收集开销。
堆在逻辑上分区为几代。 最新对象位于第 0 代(第 0 代)。 这些对象尚未在集合中幸存下来。 在第 0 代集合期间,GC 确定可从 GC 根集访问哪些第 0 代对象(包括计算机寄存器中的对象引用、堆栈上的类静态字段对象引用等)。可传递的可访问对象是“实时”的,并被提升(复制)到第 1 代。
由于堆总大小可能为数百 MB,而第 0 代堆大小可能仅为 256 KB,因此将 GC 的对象图跟踪的范围限制为第 0 代堆是实现 CLR 非常短暂的收集暂停时间所必需的优化。
但是,可以将对第 0 代对象的引用存储在第 1 代或第 2 代对象的对象引用字段中。 由于我们不会在第 0 代集合期间扫描第 1 代或第 2 代对象,如果这是对给定第 0 代对象的唯一引用,则 GC 可能会错误地回收该对象。 我们不能让这种情况发生!
相反,堆中所有对象引用字段的所有存储都会产生 写入屏障。 这是记帐代码,可有效地将新一代对象引用存储到旧代对象的字段中。 此类旧对象引用字段将添加到后续 GC 的 GC 根集。
per-object-reference-field-store 写入屏障开销与简单方法调用(表 7)的成本相当。 这是本机 C/C++ 代码中不存在的新费用,但它通常是支付超快速对象分配和 GC 的一个小价格,以及自动内存管理的许多生产力优势。
表 7 写入屏障时间 (ns)
Avg | 最小值 | 原始 |
---|---|---|
6.4 | 6.4 | 写入屏障 |
在紧密的内部循环中,写入屏障的成本可能很高。 但在未来的几年里,我们可以期待先进的编译技术,减少占用的写入障碍数和总摊销成本。
你可能认为仅在存储区上需要写入屏障才能访问引用类型的对象引用字段。 但是,在值类型方法中,存储到其对象引用字段(如果有)也受写入屏障的保护。 这是必要的,因为值类型本身有时可能嵌入在驻留在堆中的引用类型中。
Array 元素访问
若要诊断和排除数组超出边界的错误和堆损坏,并保护 CLR 本身的完整性,将检查数组元素加载和存储,确保索引在间隔 [0,array 内。length-1] 非独占或引发 IndexOutOfRangeException
。
我们的测试测量加载或存储 int[]
数组和 A[]
数组的元素的时间。 (表 8)。
表 8 数组访问时间 (ns)
Avg | 最小值 | 原始 |
---|---|---|
1.9 | 1.9 | 加载 int 数组 elem |
1.9 | 1.9 | 存储 int 数组 elem |
2.5 | 2.5 | 加载 obj 数组 elem |
16.0 | 16.0 | store obj array elem |
边界检查需要将数组索引与隐式数组进行比较。长度字段。 正如反汇编 10 所示,在两个指令中,我们检查索引既不小于 0 也不大于或等于数组。Length - 如果是,我们将分支到引发异常的行外序列。 同样适用于加载对象数组元素,以及用于存储到 ints 数组和其他简单值类型的数组中。 (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 编译器通常会消除冗余边界检查。
回顾前面的部分,我们预计 对象数组元素存储 成本要高得多。 若要将对象引用存储在对象引用数组中,运行时必须:
- 检查数组索引在边界内;
- check 对象是数组元素类型的实例;
- 执行写入屏障(指出从数组到对象的任何代际对象引用)。
此代码序列相当长。 编译器不会在每个对象数组存储站点上发出它,而是发出对共享帮助程序函数的调用,如反汇编 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 Box and Unbox int Times (ns)
Avg | 最小值 | 原始 |
---|---|---|
29.0 | 21.6 | box int |
3.0 | 3.0 | 取消装箱 int |
若要取消装箱 int 对象,需要显式强制转换为 int。这编译为对象的类型(由其方法表地址表示)和装箱 int 方法表地址的比较。 如果它们相等,该值将从对象中复制出来。 否则将引发异常。 请参阅反汇编 12。
反汇编 12 Box 和取消装箱
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
(sans 函数调用运算符)? 我们将其称为 绑定指针,指向成员函数,但 延迟成员函数调用 同样恰当。
返回到托管代码降落,委托对象就是一个潜在的方法调用。 委托对象表示要调用的方法和要对其调用的实例,或委托到静态方法,只表示要调用的静态方法。
(正如我们的文档所述:委托声明定义了一个引用类型,该引用类型可用于封装具有特定签名的方法。委托实例封装静态或实例方法。委托大致类似于 C++ 中的函数指针;但是,委托类型安全。)
C# 中的委托类型是 MulticastDelegate 的派生类型。 此类型提供丰富的语义,包括生成调用列表(对象,方法)对的功能,可在调用委托时调用。
委托还提供异步方法调用的设施。 定义委托类型并实例化一个(使用潜在方法调用初始化)后,可以通过 BeginInvoke
以同步方式(方法调用语法)或异步调用它。 如果调用 BeginInvoke
,运行时会将调用排在队列中,并立即返回给调用方。 稍后在线程池线程上调用目标方法。
所有这些丰富的语义并不便宜。 比较表 10 和表 3,请注意,委托调用的速度大约比方法调用慢 8 倍。 预计随着时间的推移,这一点会有所改善。
表 10 委托调用时间 (ns)
Avg | 最小值 | 原始 |
---|---|---|
41.1 | 40.9 | 委托调用 |
缓存未命中数、页面错误和计算机体系结构
早在1983年的“良好时代”中,处理器速度缓慢(约500万条指令/秒),相对而言,RAM 足够快,但在 256 KB 的 DRAM 上访问次数约为 300 n 秒,磁盘速度较慢且较大(10 MB 磁盘上的访问时间大约为 25 毫秒)。 PC 微控制器是标量 CISC,大多数浮点都在软件中,没有缓存。
经过2003年的摩尔法律,大约2003年,处理器 快速(每个周期最多发出三个操作,3 GHz),RAM 相对较慢(大约100 ns访问时间在512 MB 的 DRAM),磁盘 缓慢,巨大的(大约10毫秒的访问时间在100 GB 磁盘上)。 PC 微控制器现在是无序数据流超线程超线程跟踪缓存 RISC(运行解码的 CISC 指令),并且有多个缓存层-例如,某些面向服务器的微控制器有 32 KB 级别 1 数据缓存(可能为 2 个延迟周期)、512 KB L2 数据缓存和 2 MB L3 数据缓存(可能还有十几个延迟周期), 全部在芯片上。
在良好的时代,你可以(有时)计算所编写代码的字节数,并计算运行代码所需的周期数。 负载或存储的周期数与添加周期数大致相同。 新式处理器使用跨多个函数单元的分支预测、猜测和无序(数据流)执行来查找指令级并行度,因此一次在多个方面取得进展。
现在,我们最快的电脑最多可以发出约 9000 微秒的操作,但在同一微秒内,仅加载或存储到 DRAM 约 10 个缓存行。 在计算机体系结构圈中,这称为 击中内存墙。 缓存会隐藏内存延迟,但只隐藏到一个点。 如果代码或数据不适合缓存,并且/或表现出引用位置不佳,我们的 9000 操作/微秒超音速喷气式飞机将退化为 10 个负载/微秒三轮车。
(不要让这种情况发生) 如果程序的工作集超过可用的物理 RAM,并且程序开始采取硬页面故障,然后在每 10,000 微秒的页面故障服务(磁盘访问)中,我们错过了使用户多达 9000 万 9000 万 操作更接近其答案的机会。 这太可怕了,我相信从这一天起,你将注意衡量工作集(vadump),并使用 CLR 探查器等工具消除不必要的分配和无意的对象图形保留。
但是,这一切与知道托管代码基元的成本有什么关系?所有内容*.*
回顾表 1,托管代码基元时间的全总线列表(以 1.1 GHz P-III 为单位)观察到,每次(即使是分配、初始化和回收具有五个级别的显式构造函数调用的五个字段对象)的摊销成本也比单个 DRAM 访问更快 。 只有一个负载错过了芯片上缓存的所有级别,服务所需的时间可能比几乎任何单个托管代码操作都长。
因此,如果你对代码的速度充满热情,在设计和实现算法和数据结构时,必须考虑 和度量 缓存/内存层次结构。
简单演示的时间:对 ints 数组求和或求和等效链接的 ints 列表是否更快? 那又多了,为什么呢?
想想一分钟。 对于 ints 等小项,每个数组元素的内存占用量是链接列表的四分之一。 (每个链接列表节点都有两个对象开销字和两个字段字(下一个链接和 int 项)。 这将会损害缓存利用率。 为数组方法评分 1。
但是,数组遍历可能会为每个项进行数组边界检查。 你刚刚看到边界检查需要一些时间。 也许这提示比例有利于链接列表?
反汇编 13 总和 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
在谈到反汇编 13 时,我堆叠了甲板,支持链接列表遍历,将它取消四次,甚至删除通常的 null 指针列表结束检查。 数组循环中的每个项都需要六条指令,而链接列表循环中的每个项只需要 11/4 = 2.75 指令。 现在,假设哪个更快?
测试条件:首先,创建一个由 100 万个 ints 的数组,以及一个简单的传统链接列表(100 万个列表节点)。 然后,每个项目需要多长时间,将前 1,000、10,000、100,000 和 1,000,000 个项相加。 多次重复每个循环,以测量每个事例最平淡的缓存行为。
哪个更快? 猜出答案后,请参阅答案:表 1 中的最后八个条目。
有趣! 当引用的数据增长大于连续缓存大小时,时间会明显变慢。 数组版本始终比链接列表版本快两倍,即使它执行了两倍的指令:对于 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 数据。
- 你可能希望将数据分区为经常遍历且必须适合缓存的热部件,以及不经常使用且可以“缓存出去”的冷部件。
do-It-Yourself 时间试验
对于本文中的计时度量,我使用了 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 编译器发出的优化本机代码的唯一方法是 调试器外部启动测试应用程序
空间成本模型?
具有讽刺意味的是,空间考虑排除了对空间的彻底讨论。 然后,几个简短段落。
低级别注意事项(几个是 C# (默认 TypeAttributes.SequentialLayout) 和 x86 特定):
- 值类型的大小通常是其字段的总大小,其中 4 字节或更小的字段与其自然边界对齐。
- 可以使用
[StructLayout(LayoutKind.Explicit)]
和[FieldOffset(n)]
属性来实现联合。 - 引用类型的大小为 8 个字节,加上其字段的总大小,向上舍入到下一个 4 字节边界,以及 4 字节或更小的字段与其自然边界对齐。
- 在 C# 中,枚举声明可以指定任意整型基类型(字符除外),因此可以定义 8 位、16 位、32 位和 64 位枚举。
- 与 C/C++ 一样,通常可以通过适当调整整型字段的大小,将几十%的空间从较大的对象中剃掉。
- 可以使用 CLR 探查器检查分配的引用类型的大小。
- 大型对象(数十 KB 或更多)在单独的大型对象堆中管理,以排除成本高昂的复制。
- 可终结对象需要额外的 GC 生成来回收 - 请谨慎使用它们,并考虑使用释放模式。
大局注意事项:
- 每个 AppDomain 当前会产生大量空间开销。 许多运行时和框架结构不会在 AppDomains 之间共享。
- 在进程中,吉特代码通常不会在 AppDomains 之间共享。 如果专门托管运行时,可以重写此行为。 请参阅
CorBindToRuntimeEx
和STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN
标志的文档。 - 在任何情况下,不跨进程共享被抖动的代码。 如果有一个组件将加载到许多进程中,请考虑使用 NGEN 预编译来共享本机代码。
反射
有人说,“如果你必须问什么反射成本,你负担不起它。 如果你已经读到这一步,你知道询问哪些事情的成本,以及衡量这些成本是多么重要。
反射非常有用且强大,但与被抖动的本机代码相比,它既不是快速的,也不是较小的。 你被警告过 为自己衡量它。
结论
现在,你知道(或多或少)最低级别的托管代码成本。 现在,你已基本了解了实现更智能的权衡,并编写更快的托管代码。
我们已经看到,被抖动的管理代码可以像本机代码一样“踩到金属”。 你的挑战是明智地编码,并在框架中的许多丰富且易于使用的设施中明智地选择
存在性能无关紧要的设置,以及它是产品最重要的功能的设置。 过早优化 一切邪恶的根源。 但对效率的粗心无情也是如此。 你是一个专业,一个艺术家,一个工匠。 因此,请确保你知道事情的成本。 如果你不知道,或者即使你认为你这样做,定期衡量它。
至于 CLR 团队,我们继续努力提供比本机代码 大幅
记住你的承诺。
资源
- David Stutz et al, 共享源 CLI Essentials. 奥赖利和阿索克,2003年。 ISBN 059600351X。
- 简·格雷,C++:在胡德下。
- Gregor Noriskin,编写 High-Performance 托管应用程序:一个入门,MSDN。
- Rico Mariani,垃圾回收器基础知识和性能提示MSDN。
- Emmanuel Schanzer,.NET 应用程序MSDN 中的性能提示和技巧。
- Emmanuel Schanzer,.NET FrameworkMSDN 中 Run-Time 技术的性能注意事项。
- vadump(平台 SDK 工具),MSDN。
- .NET Show, [托管] 代码优化,2002 年 9 月 10 日,MSDN。