|
64 位 NGEN 优化
出于本文需要,我将 JIT 和 NGEN 统称为 JIT。对于 32 位版本的 CLR,JIT 编译器和 NGEN 执行的优化相同。但 64 位版本的却不是这样,在 64 位版本中,NGEN 比 JIT 所进行的优化明显更多。
64 位的 NGEN 利用了这样的事实:它可以比 JIT 花费更多的时间进行编译,因为 JIT 的吞吐量直接影响应用程序的响应时间。我在本文特别提到了 64 位的 NGEN,因为它针对 C++ 风格的代码进行相对地微调,它进行的一些优化(例如双 Thunk 消除优化)对 C++ 起到很大的帮助,这些优化是其他 JIT 和 NEGN 所不具备的。32 位 JIT 和 64 位 JIT 分别是 Microsoft 中两个不同团队使用两种不同的代码基实现的。32 位 JIT 是由 CLR 团队开发的,而 64 位 JIT 是由 Visual C++ 团队开发的,而且基于 Visual C++ 代码基。因为 64 位 JIT 是由 C++ 团队开发的,所以它更加注重与 C++ 相关的问题。
双 Thunk 消除
64 位 NGEN 执行的最重要的优化之一就是所谓的双 thunk 消除。这个优化在带 /clr 开关编译的 C++ 代码中通过函数指针或虚拟调用解决了一个转换,这个转换发生在通过托管代码调用托管入口点的时候。(在 /clr:pure 或 /clr:safe 编译代码中不会发生这种转换。)发生这个转换是因为在 callsite 上函数指针和虚拟调用都没有足够的信息可以确定它们调用的是托管入口点 (MEP) 还是非托管入口点 (UEP)。
为了向后兼容,始终选择 UEP。但如果托管 callsite 实际调用的是托管方法呢?在这种情况下,除了初始 thunk 从托管 callsite 进入 UEP 外,还会有一个 thunk 从 UEP 进入目标托管方法。这个托管-托管 thunk 过程通常称为双 thunk。
64 位 NGEN 实现了对“从非托管到托管”的调用(即为反过来对托管代码 thunk 的调用),从而实现了优化。可以进行一个检查来确定是否是这种情况;如果是,它就会跳过这两个 thunk,并直接跳到托管代码,如图 2 所示。这样可以节省许多指令,在实际的代码建模基准中,我发现有 5-10% 的提高(在人为测试中,可以看到超过 100% 的性能提高)。

图 2 双 Thunk 消除
不过有一点需要注意,那就是这个优化只有在位于默认应用程序域时才生效。有一个很好的小规则,那就是记住,默认 AppDomain 通常会获得更好的性能。
C++ Interop
C++ interop 是用于本机托管 interop 的一种技术,它允许标准 C++ 代码带 /clr 开关编译,以便直接调用本机函数,而不用编程人员添加其他任何代码。当使用 /clr 开关时生成的代码是 MSIL(除了少数特例),并且数据可以是托管或非托管的(由用户指定数据的存放位置)。我倾向认为 C++ interop 是没有人知道的最重要的 .NET 功能。它是真正具有突破性的改革,但要真正了解 C++ interop 的强大之处还需要一定的时间。
在其他与 .NET 兼容的语言中,要与本机代码进行 interop 需要您将本机代码放在一个 DLL 中,并使用 dllimport 调用带有显式 P/Invoke 的函数(或者其他一些与此类似的做法,取决于您使用的语言)。否则就必须使用笨重的 COM interop 访问本机代码。这明显不方便,而且经常会遇到性能比 C++ 差很多的情况。
一般不认为 C++ interop 是 C++ 语言的性能特征,但正如您将看到的,C++ interop 所提供的灵活性和便利性却可以让您借助 CLR 获得更好的性能。
单映像中的本机代码和托管代码
Visual C++ 可以使编程人员(按逐函数方式)有选择地选择哪些函数是托管的,哪些是本机的。 这是通过 #pragma managed 和 #pragma unmanaged 实现的,图 3 显示了其中一个例子。在许多计算量大的任务中,让核心函数进行本机编译而其他代码进行托管编译可以带来很大好处。在单个映像中,C++ 可以将托管代码和本机代码混合一起,通过本机函数调用托管函数(反之亦然)不需要特殊的语法。在这种粒度下,C++ 可以很轻松地控制从托管代码向本机代码的转换,反之亦然。
当从托管代码向本机代码转换(或反向)时,执行的过程要经过由编译器/链接器生成的 thunk。这个 thunk 需要一定代价,编程人员都竭力避免付出这样的代价。有大量工作是在 CLR 中完成的,并且编译器会使转换的成本降到最低,但开发人员也可以通过降低这种转换的频率来帮助降低成本。
图 4 的 A 部分中是一个 C++ 应用程序,它的部分代码 (Z.cpp) 经编译生成 MSIL (/clr),而其他部分(X.cpp 和 Y.cpp)经编译生成本机代码。在这个程序中,Y.cpp 和 Z.cpp 中有些函数经过来回多次调用。这会导致大量托管/本机转换,从而降低程序的执行速度。

图 4 更改托管界限图 4 中的 B 部分显示了如何优化该程序来使托管/本机转换降至最少。其思想是确定常用接口,将它们都移到托管/本机界限的一侧,从而消除所有跨常用接口的转换。使用 Visual C++ 为 interop 提供的工具可以很轻松地完成这项工作。
例如,要从图 4 中的 A 转到 B,只需要用 /clr 开关重新编译 Y.cpp。现在 Y.cpp 被编译为托管代码,从 Z.cpp 调用就不需要有从托管到本机的转换成本。当然,您也需要考虑从 Y.cpp 生成 MSIL 的相关性能代价,并确保这种折衷对应用程序有利。
高性能封送处理
封送处理是托管/本机 interop 中成本最高的方面之一。在 C# 和 Visual Basic .NET 等语言中,封送处理是在调用 P/Invoke 时 CLR 隐式完成的(使用默认封送拆收器或者在实现 IcustomMarshaler 时用自定义封送处理代码完成)。而在 C++ interop 中,编程人员可以在代码中认为合适的地方显式封送处理数据。这样做的好处是编程人员可以一次性将数据封送到本机数据,然后通过多次调用重用数据的封送处理结果,从而均摊封送处理成本。
[本文共有 3 页,当前是第 2 页] <<上一页 下一页>>
OVER
|