正文

使用 Visual C++ 2005 的现代语言功能编写更快的代码(3)2005-12-07 10:31:00

【评论】 【打印】 【字体: 】 本文链接:http://blog.pfan.cn/ainterly/7916.html

分享到:

在 Visual Studio .NET 2003 中,C++ Interop 技术被称为 IJW 或“正常运行 (It Just Works)”。在即将推出的版本中,这被改为一个更具描述性的名称“Interop 技术”。那么,它是如何“正常运行”的呢?对于每个由应用程序使用的本机方法而言,编译器同时创建了一个托管的入口点和一个非托管的入口点。它们中的一个是实际的方法实现,而另外一个是转发 thunk,它创建适当的转换并进行任何必要的封送处理。托管入口点几乎总是实际的方法实现,唯一的例外是该方法的代码无法用 MSIL 表示或者开发人员使用“#pragma unmanaged”编译器指令来强制要求将入口点实现为本机代码。

当使用一个 IJW 转发 thunk 时(例如,当本机入口点是转发 thunk 时),编译器提供 thunk 的实现,并通过一个偏移量或导入地址表(Import Address Table,IAT)跳转来调入实际的实现。IJW thunk 的合理时间大约在 50 到 300 个周期之间,不过,精心设计的测试用例可以使这个数字减至 10 那么小。当转发 thunk 是 MSIL 时,托管的 P/Invoke 就会派上用场。P/Invoke 仅包含一个声明而没有实际的方法实现;CLR 提供了对 thunk 的运行时支持的功能。这些转发 thunks 通常都会比同等的本机实现稍微慢一点点。

如上所述,使用 IJW 使每个函数都有两个入口点,一个托管的接口和一个非托管的接口。但某些构造需要这些入口点的调用地点在编译时进行填充(例如函数指针和 vtable)。而如果编译器在编译时无法知道运行时调用地点的托管状态,则它应该选择哪一个入口点呢?在 Visual Studio .NET 2003 中,编译器总是会选择非托管入口点。当然,如果调用方确实是托管的,则上述做法就会造成一些麻烦,这称为 Double P/Invoke 问题,如图 4 所示。在这种情形下,托管调用对非托管 thunk 进行的转换刚好又转换回托管代码,这样的操作会导致几个大的不必要的开销。


图 4 Double P/Invoke 问题

Visual C++ 2005 提出了几个解决方案。第一个方案就是使用 __clrcall 关键字,通过这个关键字,可以指定是否基于每个方法发出非托管入口点。使用这个关键字添加函数声明可以防止生成非托管入口点(这样做的一个缺点就是该函数就不能被本机代码直接调用)。__clrcall 关键字也可以放置在函数指针上,这样在编译器有所选择的情况下,可以使用托管入口点来填充该指针。Visual C++ 2005 提供的第二个解决方案是通过运行库检查来自动消除 Double P /Invoke 问题,而 cookie 将帮助运行库确定是否可以跳过非托管 thunk,从而将调用直接转发至托管入口点。不过,这一功能不可能最终解决问题。

第三个解决方案是纯 MSIL。新的 /clr:pure 编译器标志指示编译器生成一个不包含本机构造的纯托管映像。这样不仅可以产生遵循 CLI 的程序集来支持部分信任的情况,而且通过防止生成非托管 thunk 解决了 Double P/Invoke 问题。结果是,每个函数只有一个入口点(托管入口点),这样,vtable 和函数指针就决不会使用非托管入口点进行填充了。

然而,仅仅因为代码是遵循 CLI 的并不意味着它就是可验证的,而这对于支持低信任级别的情况(例如当从文件共享加载代码时)是一个重要的目标。所以,Microsoft 引入了一个更为严格的编译器标志,称为 /clr:safe。对于 C++ 开发人员来说,这是可验证性的圣杯。推动这个开关会使编译器确保生成的程序集是完全可验证的;任何无法验证的结构都会产生编译时错误。例如,试图将一个整型指针编译成一个变量将会产生这样的错误:“int* = this type is not verifiable”,并指出包含非法结构的行。在一些情况下,走向这个极端是适当的。例如,将要作为 SQL Server 2005 中的存储过程运行的所有托管 C++ 代码都应该使用此标志进行编译。


图 5 编译模式

5 图解了数据及代码的托管与非托管环境,并且显示了各个编译器标志所针对的环境。不包含任何 /clr 标志将导致生成完全的本机映像。使用 /clr 标志将会产生可以包含托管与非托管代码和数据的混合映像。通过使用 /clr:pure 标志生成的纯 MSIL 不会包含任何非托管代码,尽管这仍不能保证是可验证的,而且可以包含本机类型。安全 MSIL 是可验证性的最终目标(只针对 .NET 框架而言)。简而言之,这两种新的编译模式都将使以前不可能实现或者难以实现的多种情况变为现实。

优化

所有优秀的软件开发人员都想要确保他们的软件能够正常运行。编译器编写人员是一种特殊的开发人员;它们的代码不仅需要正常运行,而且他们的代码生成的代码必须尽可能地具有高的效率。由于这个原因,任何成功的编译器的背后都要有一个好的优化支持。在这方面,Visual C++ 2005 是无可挑剔的。

由于 Visual Studio .NET 2002 和 Visual Studio .NET 2003 在本机代码的性能提高方面做了许多的工作,所以它们加入了对 C++ 编译器的一些惊人的优化。在加入了 SSE 和 SSE2 体系结构的同时,它们还提供了针对 Intel Pentium IV 的支持。最为显著的是加入了全程序优化(Whole Program Optimization,WPO),它允许链接器在将每个经过编译的 .cpp 文件变成 .obj 文件时对整个程序进行优化。这些对象文件和普通的对象文件有所不同,因为它们包含的不是本机代码,而是用来在编译器前端和后端进行通信的中间语言。然后,链接器就能将所有这些文件优化成一个大的单元,从而提供更多的内联机会、更好的堆栈对齐方式和在各种情况下使用自定义调用约定的可能性。Visual C++ 2005 使用称为自顶向下、自底向上分析这样的新功能来改进 (WPO)。但是大的改进是以 Profile Guided Optimization (POGO) 的形式出现的,在编译器中提供的这种全新的功能将会对性能有所改进。

就编译器而言,对源代码的静态分析将会留下许多悬而未决的问题。如果在一个 if 语句中比较两个变量,第一个变量比第二个变量大的几率是多少?在一个 switch 语句中,哪一个 case 被选中的次数最多?哪些函数最常使用,而哪些代码常常被忽略?如果编译器在编译时知道代码在运行时应该如何使用的话,它就可以为大多数情况进行优化。这正是 Visual C++ 2005 编译器所能够做到的。


6 Profile Guided Optimization

POGO 的编译过程如 6 所示。第一步需要编译代码并将其链接成一个规范构建,它具备一组分析探测器。当使用 WPO 时,由编译器生成并导入到链接器的对象文件是由中间语言而不是本机代码构成的。这些探测器分为两种:值探测器和计数探测器。值探测器用来构造变量存放的值的直方图,而计数探测器用来跟踪您通过该应用程序往返某一特定路径的次数。当应用程序运行并正常使用时,数据是从所有这些探测器中集合汇集而成的,并被写入一个配置文件数据库。这些配置文件数据和原始的 .obj 文件一起被导入到链接器。链接器能够分析配置文件数据,确定应该应用的其他优化,并生成一个新的非规范应用程序构建。这只是一个经过编译的版本,而不是一个可以用来发布给客户的规范版本。

Profile Guided Optimization 支持各种各样的优化。以计数探测器为基础,可以在每个函数调用地点做出内联决策。通过使用值探测器,可以重新排列 switch 和 if-else 构造,这样就可以提取出最常用的值,并且避免在找到常见的 case 之前进行额外的不必要检查。还可以重新排列代码段,从而能够将最常用的路径排在一起,而不是强制要求在代码内进行不必要的跳转。这避免了高开销的 Translation Lookaside Buffer (TLB) 颠簸 (thrashing) 和分页 (paging)。

可以将不常使用的代码放在该模块的一个特定的部分中,这也有助于避免上述问题。可以执行虚拟调用的途径,因为虚拟调用地点常常导致对某一特定的类型进行调用,从而能够避免常见情况中的 vtable 查找。可以执行局部内联,借此确保只对函数中经常使用的代码段进行内联,而且这种决策是基于每个调用地点做出的。此外,某些代码段会以某种优化为目的进行编译,而其他代码段的编译则有着不同的目标。例如,经常使用的和/或小的函数可以编译为最大化速度 (/O2);而不经常使用和/或大一些的函数会被编译为最小化空间 (/O1)。

如果能够了解实际情况,并且能够将您的应用程序投入经常使用的情况,而与此同时还进行一些规范化的工作,则应用程序的性能将会得到极大的提高。最近,使用 POGO 对 SQL Server 进行了重新编译,并且在许多常见的情况下,获得了 30% 的性能飙升。这样下去,您可以断定,Microsoft 会开始使用这一技术来将它的许多产品进行编译。需要注意的是,在分析您的规范构建时,不要试图涵盖全部的代码,这一点非常重要。POGO 的全部意义在于确定如何优化常见的用例。如果您试图涵盖全部的代码,您将得到严厉的教训。

Visual C++ 2005 还增加了对 OpenMP 的支持,它是一个构建多线程程序的开放规范。它由一组程序组成,用来指示编译器代码的哪些部分可以平行放置。如果代码具有大的循环并且这种循环与前面的迭代没有依赖关系,则这种代码最适合使用 OpenMP。看一看下面这个简单的 copy 函数,它将数组 a 和 b 中的值相加,并将结果存储在 c 中:

void copy(int a[], int b[], int c[], int length)
{
    #pragma omp parallel
    for(int i=0; i<length; i++)
    {
        c[i] = a[i] + b[i];
    }
}

在有多个处理器的机器上,编译器会生成多线程来执行这个循环的迭代,而每个线程将执行复制操作的一部分。需要注意的是,编译器无法验证循环是否具有依赖性,因此它不会阻止您在不适当的情况下使用这些杂注。如果具有依赖性,您就极有可能得到与您想像的不同的错误结果,尽管它们在规范方面是正确的。

虽然使用 OpenMP 的最大好处常常来源于平行放置循环(如刚才的例子所示),但是在直线型代码中使用它也会使性能得到改善。“#pragma omp section”指令可以用来区分一段代码中的非依赖性部分,这样就可以让开发人员指定可以并行运行的区域。然后,编译器就可以产生多线程,从而在不同的处理器上执行这些代码段。

对于使用 .NET 的开发人员来说,一个重要的改变是,Visual C++ 2005 优化器对 MSIL 做出的优化和对本机平台做出的优化大体上是一样的,尽管优化器是通过不同的调优来做到这一点的。而现在的实时 (JIT) 编译器是在运行时分析并优化的,它允许 C++ 编译器在初次编译时就进行优化,这样也可以提供极大的性能优势(C++ 编译器就可以有更多的时间来进行分析而不是保持 JIT)。Visual C++ 2005 编译器首次对托管类型进行了优化:执行循环优化、表达式优化和内联。但是在有些地方编译器是无法对基于 .NET 的代码进行优化的。例如,由于指针算术运算的无法验证性,它就有一些无能为力了,而且某些代码由于 CLR 对类型和成员可访问性的严格要求而无法内联,尽管编译器的确对合法的内联机会进行了大量的分析。此外,优化 MSIL 需要平衡考虑对 JIT 编译器的影响。例如,您不会想去解开一个循环而暴露过多的变量给 JIT 编译器,因此,JIT 编译器必须进行注册表分配(一个 NP 完成问题)。Visual C++ 小组正在对这些问题进行研究,并在系统发布时会得到一个经过良好调优的优化解决方案。

阅读(4174) | 评论(0)


版权声明:编程爱好者网站为此博客服务提供商,如本文牵涉到版权问题,编程爱好者网站不承担相关责任,如有版权问题请直接与本文作者联系解决。谢谢!

评论

暂无评论
您需要登录后才能评论,请 登录 或者 注册