优化 DLL 加载时间性能
请下载本文的代码: UnderTheHood0500.exe (264KB)
近年来,在我的专栏文章和技术讲座中,其中一个主要主题一直是优化可执行文件技术的优点。 通常,优化技术指的是确定基址和绑定,但可能还包括按序号导入函数或更改可执行文件页面对齐。 从直观的角度来说,这些策略应该使您的代码加载得更快。 不过,始终有一个关于这一主题的问题萦绕在我的心里 — 利用这些技术究竟能获得多大的改进? 这个月我一直在研究这个问题,并得出一些具体的数字。
在本期专栏文章中,我的目标是测量以下几个方案的加载时间速度效果:
• |
使用具有冲突的加载地址的 DLL 的程序与在首选地址加载每个 DLL 的程序。 此方案测试 REBASE 程序的效果。 |
• |
以正常方式导入数百个 API 的程序与将所有 API 绑定到其目标 DLL 的程序。 此方案测试 BIND 程序的效果。 |
• |
按序号而不是按名称导入函数。 为此,您必须自己来完成某些工作,通常是创建和维护 .DEF 文件。 |
我将立刻解释为什么第二个方案取决于第一个方案。 原因在于,针对不在其首选地址加载的 DLL 绑定可执行文件是浪费时间。 绑定假定您有一个具有正确基址的系统,其中 DLL 根据其头规范进行加载。
![*](http://img.microsoft.com/library/gallery/templates/MNP2.Common/images/3squares.gif)
本页内容
![]() |
加载时间性能优化的快速回顾 |
![]() |
创建优化测试 |
![]() |
LoadTimeTest 基准测试进程 |
![]() |
LoadTimeTest 结果 |
![]() |
随机观测 |
加载时间性能优化的快速回顾
在讨论性能改进的细节之前,迅速回顾一遍确定基址、绑定和按序号导入。 确定基址是一个很好的起点。 在创建 DLL 时,链接器假定 DLL 将在某个特定地址加载。 某些代码块和数据包含硬编码的地址,仅当 DLL 在首选地址加载时,这些地址才是正确的。 不过,运行时有可能发生这样的事情 — 操作系统可能不得不在另一个不同的内存位置加载 DLL。
为了处理操作系统不得不移动 DLL 的情况,链接器向 DLL 添加基重定位。 基重定位是需要修改的地址,这些地址在修改后就会包含 DLL 在内存中进行加载的正确地址。 DLL 拥有的基重定位越多,操作系统处理它们以加载 DLL 所需的时间也越多。 具有正确基址的 DLL 在其首选地址加载,并且可以跳过对基重定位记录的处理。
如果没有给定明确的指示,Microsoft 链接器会用首选加载地址 0x10000000 来创建每个 DLL。 如果您的程序使用了一个以上您自己设计的 DLL,则您将拥有多个具有相同首选加载地址的 DLL。 这种情况的结果是,除了第一个 DLL 之外的每个 DLL 都将由操作系统在加载时重定位。 有时,这种情况称为加载地址冲突。 不过,如果您进行干预,就能避免此类情况的发生。
随 Microsoft Visual Studio 和 Platform SDK 提供的 REBASE 程序是一个解决加载地址冲突问题的方便工具。 您为 REBASE 提供一个构成程序的所有模块(系统 DLL 不计算在内)的列表,该程序会为 DLL 挑选新的加载地址,然后再进行适当的修改。
绑定可执行文件建立在可将所有 DLL 调整为在其首选地址加载这一前提之上。 当您从 DLL 导入一个函数时,Windows 加载器为查找导入函数所必需的信息存储在您的可执行文件中。 通常,此信息是导入的 DLL 的名称和导入的函数的名称。 当加载器解析导入的函数时,实质上是在执行 GetProcAddress 使用的同一代码。
正常情况下,在启动时,加载器会搜索所有导入的函数,查找它们的地址。 不过,如果导入的 DLL 不随着运行而更改,则加载器找回的地址也不更改。 一个简单的优化方法是,将目标函数的地址写入进行导入的可执行文件,这正是 BIND 程序承担的工作。
普通的 Win32 可执行文件有两个完全相同的、查找导入函数所必需的信息副本。 一个称为导入地址表 (IAT),另一个称为导入名称表。 不过,只有一个副本 (IAT) 是 Win32 加载器所必需的。 BIND 程序利用此信息有两个副本这一事实,用导入的函数的实际地址覆盖 IAT 条目。
在加载时,加载器检查是否一切都符合规定。如果是,则使用 BIND 存储在 IAT 中的地址。 这样一来,就无需按名称查找函数。 如果链接可执行文件时出了差错,该怎么办呢? 例如,导入的 DLL 可能加载到其他位置。 在这种情况下,加载器使用导入名称表信息来进行正常的查找。
BIND.EXE 是绑定可执行文件的最著名的方法。 不过,它是根据您的系统 DLL 来优化可执行文件的。 如果您将自己的程序分发给用户,他们的系统 DLL 可能不同,因此您需要在这些用户的系统上绑定您的可执行文件。 Windows Installer 具有 BindImage 操作,非常易于使用(但我必须承认我从来没有编写过一个安装脚本)。 另一种方法是,您可以使用 API BindImageEx,它是 IMAGEHLP.DLL 的一部分。
本月专栏文章中最后一种性能优化是按序号导入。 一般来说,当您导入函数时,您的二进制文件包含导入的函数的名称。 当 Win32 加载器查找导入的名称时,它必须进行字符串比较,以将正在导入的名称与正在从其中导入的 DLL 所导出的名称进行匹配。
当一个可移植的二进制可执行文件导出函数时,它包含对导出的函数的偏移量数组。 当您按序号导入时,进行导入的二进制文件将数组索引(这里称为序号)包含在此数组中。 这种查找导入函数的地址的方法是一个简单的数组查找,因此非常快。 按名称导入则麻烦得多,因为 Win32 加载器先获得名称,然后执行搜索,以查找相应的序号值。 接着,加载器继续工作,就好像您已经先指定了序号一样。 按名称导入只是在按序号导入之上又添加了额外一个代码层。
那么,为什么 Win32 允许按名称导入和导出呢? 其中有好几个原因,但我只能立即想到两个原因。 第一个原因,如果其 DLL 随着时间变化(就像 Win32 系统 DLL 那样),则保留分配到给定的 API 的同一个导出序号非常困难。在一个 .DEF 文件中跟踪数百个函数会非常麻烦。 此外,KERNEL32.DLL 不只有一个;Windows 2000 有一个,Windows 98 有一个,等等。 第二个原因,按名称导出允许您使用函数的名称 GetProcAddress,而不是其序号值。 如果您从 Platform SDK 或 Visual Studio 转储随机选择的导入库,您会发现大多数 DLL 按名称导出,但有几个按序号导出。
您怎样按序号导入和导出呢? 导入部分实际上是由 Microsoft 链接器自动为您完成的。 不过,作为交换,您必须按序号导出 API。 当链接器生成与 DLL 相对应的导入库时,它会生成指定链接器应该如何导入 API 的导入记录。 按序号导出的最佳方法是,通过 .DEF 文件向链接器显式指定。 例如,在 .DEF 文件中可以包含:
EXPORTS MyExportedAPI @1
如果您不使用 @1 修饰符,Microsoft 链接器会按名称导出 API。
按序号导入和导出除了具有加载速度快这一优点之外,还有另一个好处。 按序号导出 API 时,您可以告诉链接器不要在进行导出的 DLL 中存储导出的 API 名称。 这意味着导出部分更小,二进制文件也可能更小,请求调页的数据更少。要消除 API 名称,请在按序号导出时使用 NONAME 修饰符。 EXPORTS
MyExportedAPI @1 NONAME
如果查看一下 MFC42.DLL,您就会发现,几乎所有 6000 多个 API 都是按序号导出的,并使用了 NONAME 修饰符。 您可以想象,如果 MFC42.DLL 不得不在其导出中存储所有 6000 个打乱的 C++ 名称,它会有多么庞大!
创建优化测试
为了测试正确的确定基址、绑定和按序号导入的效果,我编写了一个名为 MakeLoadTimeTest.EXE 的程序,它会生成作为基准测试的程序。 利用我创建的这个程序,我可以很容易地调整导入的函数的数目、它调用多少个 DLL 以及每个 DLL 拥有的重定位的数目。 MakeLoadTimeTest 的源代码可在本月的下载文件中找到。
MakeLoadTimeTest.CPP 代码并非十分完美。 不过,您不必太仔细阅读该代码,因为您可能需要更改的内容已经在开头单独列出 — 尤其是这三行:
const unsigned nDLLs = 10; const unsigned nExportedFunctions = 100; const unsigned nGlobalVariablesPerFunction = 5;
根据这些常量,生成的程序 (LoadTimeTest) 将导入 10 个 DLL(KERNEL32.DLL 除外)。 其中每个 DLL 将导出 100 个函数,主可执行文件将导入所有 100 个函数。 最后,每个导出的函数引用五个全局变量。 为什么引用全局变量呢? 因为这是强制生成基重定位的一个简便方法。 基重定位越多,DLL 不在其首选地址加载时加载器需要完成的工作就越多。
图 1 显示了为一个导出的函数生成的代码。 代码开头是五个全局变量声明(例如,g_var_n2_0)。 接下来是 #ifdef 程序块,可用于在编译时确定函数按名称导出还是按序号导出。 最后,函数本身(在此示例中,即 LoadTimeDLL_10_func_2)只将一个值存储到前面声明的变量中。 为什么变量和函数名这样奇怪呢? 原因在于,这些名称结尾处的数字使它们成为唯一名称,从而避免了命名冲突。
至于 LoadTimeTest 可执行文件,它只需要一个简单的函数 main,它引用生成的 DLL 导出的所有函数。 整个函数的长度超过 1000 行,但下面只列出了相关的代码片段:
#include "LoadTimeTest.H" #include <windows.h> int main(int argc) { TerminateProcess( GetCurrentProcess(), 0 ); LoadTimeDLL_1_func_1(); LoadTimeDLL_1_func_2(); LoadTimeDLL_1_func_3(); LoadTimeDLL_1_func_4(); // ... }
您可能想知道为什么我使用了 TerminateProcess 调用。 在理想情况下,我能够计算出加载我的目标进程所用的时间有多长(直到调用其入口点)。 不过,我无法提出一个完全按此过程执行的简单方法。
我最终采用的办法是,使 main 函数调用 TerminateProcess。 这种做法可立即停止该进程,而不必向 DLL 发送 DLL_PROCESS_DETACH 通知。 此外,因为我实际上不调用所有生成的 API(例如,LoadTimeDLL_1_func_1),就不会发生对代码请求调页的系统开销。不过,因为我在函数 main 中引用了所有生成的导出的函数,加载器被强制加载 DLL,并且有可能应用基重定位。
除了代码生成器 (MakeLoadTimeTest) 和生成的程序 (LoadTimeTest),这个基准测试程序包中还有一个程序。 LoadTimer 可执行文件运行 LoadTimeTest 程序,并计算出执行所用的时间。 因为 Windows 是一个按优先级执行的多任务处理系统,我花了相当大的工夫才得到比较可靠的计时信息。 下载文件中的源代码包含了所有细节,但这里还是要概括一下。
对于启动程序,LoadTimer 并不依靠 LoadTimeTest 的一次运行。 LoadTimeTest 第一次运行时,磁盘系统开销增加了它的加载时间。 后来的运行通常更快一些,因为操作系统已缓存了 EXE 和 DLL 中的页面。 我计算 30 次调用 LoadTimeTest 的时间,并使用最快的运行 — 由于线程切换和中断处理等因素导致外部系统开销最少。
为了最大限度地减少这些外部事件的系统开销,LoadTimer 将其优先级设置为 REALTIME_PRIORITY_CLASS。 此外,当它启动 LoadTimeTest.EXE 时,代码为该进程指定 REALTIME_PRIORITY_CLASS。 这种做法可确保 LoadTimer 进程中执行的 CreateProcess 代码以及 LoadTimeTest 进程中的代码都以可能设定的最高优先级运行。 因此,外部事件的作用应该被最大限度地降低。
为了进行计时,我使用了 QueryPerformanceCounter API。 我原来使用 x86 结构的 RDTSC 指令来凑合一下,该指令可以与一个 CPU 时钟周期一样准确。 不过,它需要知道 CPU 的速度,以便计算实际时间。 虽然您可以从 Windows NT 中的注册表 (HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\ System\CentralProcessor\0) 读取 CPU 的速度,但该数字并不准确。 例如,在我的 550Mhz 的计算机中,注册表报告的速度是 548Mhz。 (我该向谁投诉那缺少的 2Mhz 呢?) 最后,QueryPerformanceCounter 的粒度似乎足以满足我们研究的持续时间的要求。
为了让我的基准测试代码变得灵活,我使 LoadTimer 可在任何无需用户干预、自动退出的程序中使用。 LoadTimer 接受命令行参数,指定要运行的文件名。 在我的测试中,命令行参数是 LoadTimeTest.EXE。
LoadTimeTest 基准测试进程
下面讨论重新生成所下载的代码文件中的结果需要的步骤。 首先,从项目文件构建 MakeLoadTimeTest 和 LoadTimer。 接着,运行 MakeLoadTimeTest.EXE。结果是 11 个 .CPP 文件和 11 个 .H 文件。 运行 BuildLoadTimeTest.BAT 文件可将这些文件编译到 LoadTimeTest.EXE 和 10 个相关联的 DLL。 如果您指定 "ORDINAL" 作为 BuildLoadTimeTest.BAT 的参数,LoadTimeTest.EXE 将按序号导入 API;否则,API 将按名称导入。
如果查看一下 BuildLoadTimeTest.BAT,您就会发现它将链接器默认值用于这 10 个 DLL。 因此,所有生成的 DLL 都有相同的首选加载地址: 0x10000000。 这种做法是故意的,因为这样可使基准测试程序的开头是将在运行时重定位的九个 DLL。
现在,开始实际测试。 首先,运行命令
LoadTimer.EXE LoadTimeTest.EXE
若干次,并记录最短的时间。 这是一种最坏情况计时。
现在,我们来解决所有那些 DLL 需要重定位的问题。 运行 RebaseLoadTimeTest.BAT 文件,该文件对 EXE 和 10 个生成的 DLL 使用 REBASE.EXE,因此每个 DLL 都有一个唯一的加载地址。 重新运行计时序列,记录最短时间。 这样,您可以大致了解重新确定基址对加载时间有多大的影响。
既然 EXE 和所有 DLL 都在其首选加载地址加载,我们应该看看将它们进行绑定可以获得哪些额外的好处。 运行 BindLoadTimeTest.BAT,然后重新运行计时序列,再次记录最短时间。
此时,您应该有三个加载时间: 没有任何干预的默认时间、对可执行文件重新确定基址后的时间以及确定基址并进行绑定后的时间。 要查看按 ORDINAL 导入的效果,请重新运行前面的测试,但要进行一处更改: 运行 BuildLoadTimeTest.BAT 时,在开头指定 "ORDINAL" 作为参数。
LoadTimeTest 结果
在讨论实际数字之前,我首先要说的是,我对加载程序速度之快感到吃惊。 我故意创建了 LoadTimeTest.EXE,使 Win32 加载器不得不完成许多工作。 它包括许多 DLL、大量导出的函数和重定位。 即使在最慢的情况下,我的计算机在 Windows 2000 下仍然以低于每秒 1/50th 的速度加载该程序。 如果您的程序用了很长的时间才加载,不要责怪加载器。 我几乎可以肯定问题的原因是,某人的初始化代码用了太长时间。
图 2 显示了我获得的结果。 测试所使用的计算机是 Dell XPS T550,它具有以 550Mhz 速度运行的单个 Pentium III CPU。 唯一可见的运行进程(除了 Explorer 外壳程序)是命令提示。 测试从 FAT16 分区运行,这样我可以在 Windows 2000 和 Windows 9x 两种平台上测试。
您可以用各种方式来划分数据,但下面是我认为很有趣的几个数字。 在 Windows 2000 中,正确确定 DLL 的基址将加载时间缩短了大约 12%。 对 EXE 和 DLL 进行确定基址和绑定将加载时间缩短了大约 18%。 相比按名称导入而言,按序号导入使加载时间缩短了 4%。 在 Windows 98 第二版中,正确确定 DLL 的基址使加载时间缩短了大约 8%。 对 EXE 和 DLL 进行确定基址和绑定将加载时间缩短了大约 12%。 相比按名称导入而言,按序号导入使加载时间只缩短了 2%。
当程序正确地确定基址并绑定时,按名称导入(而不是按序号导入)对加载时间并没有太大的影响。 当一切都正确配置后,加载器可以直接从进行导入的 DLL 读取导入的函数的地址。 加载器不需要查找名称或在数组中索引来获得函数地址。
随机观测
在完成代码的同时,我考虑了在非首选地址加载的 DLL 的效果。 在我的测试代码中,其中每个 DLL 导出 100 个 API,可是那些 API 从未被调用。 由于请求调页,我们可以认为包含那些 API 的页面不会调入内存。 同样,可能不会应用使用基重定位的开销。 带着这个看法,我联系了一位从事 Microsoft 研究的性能专家。 他告诉我,在 Windows 9x 中,我关于不需要应用基重定位的假设是正确的。
不过,在 Windows NT 和 Windows 2000 中,页面被临时设置为读/写以进行修改,然后恢复到原来的权限。 因此,用这种方式修改的任何页面不再由各进程共享。 实质上,在 Windows NT 和 Windows 2000 中,不在其首选加载地址加载的任何可执行文件将在可执行文件第一次加载时将其所有代码和数据按请求调入页面。
另一个值得注意之处是,我对 QueryPerformanceFrequency 在 Windows 2000 中的结果感到吃惊。请看图 2,注意在 Windows 2000 中,计时器以 3.57Mhz 运行。 这个速度比 Windows 98 中使用的 1.19 Mhz 频率整整快了三倍。如果您是一个 PC 机的老用户,您可能会想起,传统上,母板使用的是驱动 8254 芯片的 1.19Mhz 示波器。
为了试验一下,我在运行 Windows 2000 的 Pentium Pro 200Mhz 上运行我的测试,并获得了 1.19Mhz 的频率。 然后,我在一些双处理器计算机上运行 LoadTimer,发现该频率与 CPU 速度相匹配。 我从中得出的结论是,Windows 2000 观察母板能够完成的工作,并将最佳可用计时器频率用于 QueryPerformanceXXX API。 我承认自己有些罗嗦,在这个很小的细节上占用了太多时间,不过我喜欢上面的实验,很高兴有机会以硬件级别深入探讨问题。