3 Finding the biggest time consumers

3.1 How much is a clock cycle?
在我的手册中,我使用CPU时钟周期而不是以秒或微秒作为时间单位。这是因为计算机的速度差异很大。如果我写下某个操作需要10微秒,那么在下一代计算机上可能只需要5微秒,那么我的手册很快就会过时。但如果我写下某个操作需要10个时钟周期,即使CPU时钟频率翻倍,它仍然需要10个时钟周期。
时钟周期的长度是时钟频率的倒数。例如,如果时钟频率为4 GHz,则时钟周期的长度为1 / 4 GHz = 0.25纳秒。
假设程序中的一个循环重复1000次,并且在循环内有100个浮点运算(加法,乘法等)。如果每个浮点运算需要5个时钟周期,那么我们可以粗略估计该循环在4 GHz的CPU上需要1000 * 100 * 5 * 0.25 ns = 125微秒。我们是否应该尝试对这个循环进行优化呢?当然不需要!125微秒比刷新屏幕所需的时间少于1%。用户无法察觉到延迟。但是,如果该循环在另一个重复1000次的循环内部,那么估计的执行时间将为125毫秒。这种延迟刚好足够引起注意,但又不足以引起烦恼。我们可以进行一些测量,以验证我们的估计是否准确,或者计算时间是否实际超过125毫秒。如果响应时间太长,以至于用户必须等待结果,那么我们将考虑是否有可以改进的地方。
3.2 Use a profiler to find hot spots
在开始优化之前,您必须确定程序的关键部分。在某些程序中,超过99%的时间花在最内层的循环进行数学计算上。在其他程序中,99%的时间用于读写数据文件,而不到1%的时间用于实际对这些数据进行操作。优化代码的重要部分比仅占总时间的一小部分的代码更为重要。优化不太关键的代码部分不仅浪费时间,还会使代码变得不太清晰,难以调试和维护。
大多数编译器套件都包含一个分析器,可以告诉您每个函数被调用的次数和使用的时间。还有第三方分析工具,如AQtime、Intel VTune和AMD CodeAnalyst。
有几种不同的分析方法:
- 插装(Instrumentation):编译器在每个函数调用处插入额外的代码,以计算函数被调用的次数和所需的时间。
- 调试(Debugging):分析器在每个函数或每个代码行插入临时的调试断点。
- 基于时间的采样(Time-based sampling):分析器告诉操作系统在每毫秒生成一个中断。分析器计算程序的每个部分发生中断的次数。这种方法不需要修改待测程序,但可靠性较低。
- 基于事件的采样(Event-based sampling):分析器告诉CPU在某些事件发生时生成中断,例如每次出现一千个缓存不命中。这可以看到程序中哪个部分存在最多的缓存不命中、分支预测错误、浮点异常等。基于事件的采样需要使用特定于CPU的分析工具。对于英特尔CPU,使用Intel VTune;对于AMD CPU,使用AMD CodeAnalyst。
不幸的是,性能分析工具通常不可靠。它们有时会给出误导性的结果,或者由于技术问题而完全失败。
与性能分析工具相关的一些常见问题包括:
- 时间测量粒度不够细。如果时间以毫秒为单位进行测量,而关键函数执行需要微秒级别的时间,那么测量结果可能不准确,甚至为零。
- 执行时间过短或过长。如果待测程序在很短的时间内完成,那么采样生成的数据将过少,无法进行分析。如果程序执行时间过长,分析工具可能生成超出其处理能力范围的数据。
- 等待用户输入。许多程序大部分时间都在等待用户输入或网络资源。这段时间也会被纳入到分析结果中。为了进行性能分析,可能需要修改程序,使用一组测试数据代替用户输入。
- 受其他进程的干扰。分析工具不仅测量待测程序的执行时间,还包括计算机上所有其他进程使用的时间,包括分析工具自身。
- 在优化程序中,函数地址被隐藏。分析工具通过地址识别程序中的热点,并尝试将这些地址转换为函数名。但是,高度优化的程序通常以一种没有函数名和代码地址之间清晰对应关系的方式进行重组。内联函数的名称对分析工具可能根本不可见。结果将导致误导性的报告,显示哪些函数占用最多的时间。
- 使用调试版本的代码。一些性能分析工具要求被测试的代码包含调试信息,以便识别单个函数或代码行。调试版本的代码没有经过优化。
- 在多核处理器上跳转。在多核CPU上,一个进程或线程不一定保持在同一个处理器核心上,但事件计数器却会如此。这会导致在多个CPU核心之间跳转的线程产生毫无意义的事件计数。您可能需要通过设置线程亲和度掩码来将线程锁定到特定的CPU核心。
- 可重复性差。程序执行中的延迟可能由不可重现的随机事件引起。例如,任务切换和垃圾回收可能在随机时间发生,使得程序中的某些部分看起来比正常情况下执行时间更长。
有多种替代方案可以代替使用性能分析工具。一个简单的替代方法是在调试器中运行程序,并在程序运行时按下暂停按钮。如果存在一个占用CPU时间的热点,那么在这个热点上触发断点的概率为90%。多次触发断点可能足以确定一个热点。使用调试器中的调用栈来确定热点周围的情况。
有时,确定性能瓶颈的最佳方法是将测量仪器直接放入代码中,而不是使用现成的性能分析工具。这并不能解决所有与性能分析相关的问题,但通常可以得到更可靠的结果。如果对性能分析工具的工作方式不满意,您可以将所需的测量仪器放入程序本身。可以添加计数器变量,计算每个程序部分执行的次数。此外,可以在程序的最重要或关键部分之前和之后读取时间,以测量每个部分所需的时间。有关该方法的进一步讨论,请参见第167页。
您的测量代码应该使用#if指令进行包装,以便在代码的最终版本中可以禁用它。在代码本身中插入自己的性能分析仪器是在开发程序过程中跟踪性能的非常有用的方式。
如果时间间隔很短,时间测量可能需要很高的分辨率。在Windows中,您可以使用GetTickCount或QueryPerformanceCounter函数来实现毫秒级分辨率。使用CPU中的时间戳计数器可以获得更高的分辨率,它是根据CPU时钟频率进行计数的(_rdtsc()或__rdtsc())。
如果线程在不同的CPU核心之间跳转,时间戳计数器将变为无效。在时间测量期间,您可能需要将线程固定到特定的CPU核心上,以避免这种情况发生(在Windows中,使用SetThreadAffinityMask,在Linux中使用sched_setaffinity)。
应该使用一组逼真的测试数据对程序进行测试。测试数据应包含一定程度的随机性,以获取逼真的缓存未命中和分支预测错误次数。
找到程序最耗时的部分后,重点优化工作应集中在这些耗时部分上。关键代码段可以进一步通过第167页所描述的方法进行测试和调查。
性能分析工具对于发现与CPU密集型代码相关的问题非常有用。但许多程序耗费的时间更多地用于加载文件、访问数据库、网络和其他资源,而不是进行算术运算。下面的部分将讨论常见的耗时因素。
3.3 Program installation
安装一个程序包所需的时间在传统上不被视为软件优化的问题。但这确实是可以占用用户时间的一个重要因素。如果软件优化的目标是为了节省用户时间,那么安装软件包并使其正常工作所需的时间是不容忽视的。随着现代软件的复杂性增加,安装过程耗时超过一小时并不罕见。用户需要多次重新安装软件包以解决兼容性问题也并非例外。
软件开发者在决定是否基于一个需要安装许多文件的复杂框架的软件包时,应考虑安装时间和兼容性问题。
安装过程应始终使用标准化的安装工具。在安装过程的开始阶段应该能够选择所有安装选项,以便后续安装过程可以无人值守地进行。卸载过程也应该按照标准化的方式进行。
3.4 Automatic updates
许多软件程序会定期通过互联网自动下载更新。有些程序甚至在计算机启动时都会搜索更新,即使从未使用过该程序。如果计算机上安装了许多这样的程序,启动时间可能需要几分钟,这完全浪费了用户的时间。其他程序每次启动时都要花时间搜索更新。如果当前版本已经满足用户的需求,用户可能并不需要这些更新。除非存在强制性的安全原因需要进行更新,否则搜索更新应该是可选且默认关闭的。更新过程应在低优先级线程中运行,并且仅当程序实际在使用时才进行更新。任何程序在不使用时都不应该保留后台进程运行。下载的程序更新可以延迟到关闭并重新启动程序时进行安装。
操作系统的更新可能特别耗时。有时安装操作系统的自动更新可能需要数小时。这十分令人困扰,因为这些耗时的更新可能在不方便的时候突然出现。如果用户必须在离开工作场所之前出于安全原因关闭或注销计算机,并且系统禁止在更新过程中关闭计算机,这可能会是一个非常大的问题。
3.5 Program loading
加载程序所需的时间往往比执行程序本身更长。对于基于大型运行时框架、中间代码、解释器、即时编译器等的程序,加载时间可能非常长,尤其是使用Java、C#、Visual Basic等语言编写的程序常常如此。
但是,即使是使用编译的C++实现的程序,加载时间也可能会很长。这通常发生在程序使用许多运行时动态链接库(DLL)、资源文件、配置文件、帮助文件和数据库的情况下。在程序启动时,操作系统可能不会加载大型程序的所有模块。某些模块可能仅在需要时加载,或者如果内存不足,则可能被交换到硬盘上。
用户期望对简单操作(如按键或鼠标移动)能够立即响应。如果由于需要从磁盘加载模块或资源文件而导致几秒钟的延迟,这对用户来说是不可接受的。占用大量内存的应用程序会导致操作系统将内存交换到硬盘上。内存交换是鼠标移动或按键等简单操作响应时间过长的常见原因。
避免在硬盘上散布过多的DLL、配置文件、资源文件、帮助文件等。适当数量的文件,最好位于可执行文件所在的同一目录中,是可以接受的。
3.6 Dynamic linking and position-independent code
函数库可以实现为静态链接库(*.lib,*.a)或动态链接库,也称为共享对象(*.dll,*.so)。有几个因素可能使动态链接库比静态链接库慢。下面详细解释了这些因素,具体参见第158页。
在类Unix系统中,共享对象使用位置无关代码。Mac系统通常默认在所有地方使用位置无关代码。位置无关代码效率低下,特别是在32位模式下,原因在第158页下面解释了。
3.7 File access
在硬盘上读取或写入文件通常比处理文件中的数据花费更多时间,特别是如果用户有一个扫描所有访问的病毒扫描器。
顺序前向访问文件比随机访问更快。读取或写入大块数据比逐个小位地读取或写入更快。不要一次读取或写入少于几千字节。
您可以将整个文件镜像到内存缓冲区中,并进行一次性读取或写入,而不是以非顺序方式读取或写入小块数据。
访问最近访问过的文件通常比首次访问快得多。这是因为文件已经被复制到了磁盘缓存中。
存储在远程或可移动介质(如USB闪存驱动器)上的文件可能无法被缓存。这可能会带来相当严重的后果。我曾经创建过一个Windows程序,通过调用WritePrivateProfileString来创建文件,每写入一行就打开和关闭一次文件。在硬盘上这个程序运行得足够快,因为有了磁盘缓存,但是将文件写入软盘上却需要数分钟。
如果程序有许多文件输入/输出操作,优化文件访问比优化CPU使用更重要。如果在等待磁盘操作完成时处理器有其他工作可以做,将文件访问放在单独的线程中可能会有优势。
3.8 System database
在Windows系统中,访问系统数据库可能需要几秒钟的时间。将应用程序特定的信息存储在单独的文件中比存储在Windows系统的大型注册数据库中更高效。请注意,如果您使用诸如GetPrivateProfileString和WritePrivateProfileString等函数来读写配置文件(*.ini文件),系统可能仍然会将信息存储在数据库中。
3.9 Other databases
许多软件应用程序使用数据库来存储用户数据。数据库可能会消耗大量的CPU时间、内存和磁盘空间。在简单情况下,可以将数据库替换为一个普通的数据文件。通过使用索引、使用集合而不是循环等方法,通常可以对数据库查询进行优化。优化数据库查询超出了本手册的范围,但您应该意识到通过优化数据库访问通常可以取得很大的收益。
3.10 Graphics
图形用户界面(GUI)可能会消耗大量的计算资源。通常会使用特定的图形框架。操作系统可能在其API中提供这样的框架。在某些情况下,操作系统API和应用软件之间可能有一个第三方图形框架的额外层。这样的额外框架可能会消耗大量额外的资源。
应用软件中的每个图形操作都是通过调用图形库或API函数来实现的,然后该函数再调用设备驱动程序。调用图形函数是耗时的,因为它可能经过多个层次,并且需要在保护模式和非保护模式之间切换。显然,通过单个调用绘制整个多边形或位图要比通过多个函数调用分别绘制每个像素或线条更高效。
计算计算机游戏和动画中的图形对象当然也是耗时的,尤其是如果没有图形处理单元。
不同的图形函数库和驱动程序在性能上存在很大差异。我没有关于哪个最好的具体建议。
3.11 Other system resources
最好一次性以大块方式进行对打印机或其他设备的写入,而不是逐个小片段地写入,因为每次调用驱动程序都涉及到切换到保护模式和返回的开销。
访问系统设备并使用操作系统的高级功能可能会耗费时间,因为它可能涉及加载多个驱动程序、配置文件和系统模块。
3.12 Network access
一些应用程序使用互联网或局域网进行自动更新、远程帮助文件、数据库访问等。问题在于无法控制访问时间。网络访问在简单的测试环境中可能很快,但在网络负载过重或用户与服务器距离较远的使用情况下可能变慢或完全无法访问。
在决定是将帮助文件和其他资源存储在本地还是远程时,应考虑到这些问题。如果需要频繁更新,则最好将远程数据镜像到本地。
访问远程数据库通常需要使用密码进行登录。众所周知,登录过程对许多忙碌的软件用户来说是一个令人厌烦的耗时过程。在某些情况下,如果网络或数据库负载过重,登录过程可能需要超过一分钟的时间。
3.13 Memory access
与对数据进行计算相比,从RAM内存中访问数据可能需要相当长的时间。这就是为什么现代计算机都配备了内存缓存的原因。通常情况下,有一个8至64 K字节的一级数据缓存和一个256 K字节至2 M字节的二级缓存。通常还会有一个几M字节的三级缓存。
如果程序中所有数据的总大小大于二级缓存,并且数据在内存中分散或以非连续的方式访问,那么内存访问很可能是程序中最耗时的部分。如果变量被缓存,读取或写入内存只需2-4个时钟周期,但如果没有被缓存,则可能需要几百个时钟周期。请参考第25页关于数据存储的内容和第91页关于内存缓存的内容。
3.14 Context switches
上下文切换是在多任务环境中切换不同任务、在多线程程序中切换不同线程或在大型程序的不同部分之间进行切换。频繁的上下文切换可能会降低性能,因为数据缓存、代码缓存、分支目标缓冲区、分支模式历史等的内容可能需要被更新。
如果分配给每个任务或线程的时间片较小,则上下文切换更加频繁。时间片的长度由操作系统确定,而不是由应用程序确定。
在具有多个CPU或多个核心的计算机中,上下文切换的次数较少。
3.15 Dependency chains
现代微处理器可以进行乱序执行(out-of-order execution)。这意味着,如果一段软件指定了先计算A再计算B,而计算A较慢,那么微处理器可以在计算A完成之前开始计算B。显然,这只有在计算B时不需要A的值时才可能实现。
为了充分利用乱序执行,必须避免长依赖链。依赖链是一系列计算,其中每个计算都依赖于前一个计算的结果。这样会阻止CPU同时或乱序进行多个计算。请参考第113页上如何打破依赖链的示例。
3.16 Execution unit throughput
执行单元的延迟和吞吐量之间存在重要区别。例如,现代CPU上进行浮点加法可能需要三个时钟周期(延迟)。但是每个时钟周期可以启动一个或两个新的浮点加法(吞吐量)。这意味着,如果每个加法都依赖于前一个加法的结果,那么每三个时钟周期只能完成一个加法。但是如果所有的加法都是独立的,那么每个时钟周期可以完成一个或两个加法。
在计算密集的程序中,可能获得的最高性能是当上文提到的时间消耗要素没有主导作用,且不存在长依赖链时。在这种情况下,性能受执行单元的吞吐量限制,而不是延迟或内存访问。
现代微处理器的执行核心分为多个执行单元。通常有两个或更多的整数单元、一个或两个浮点加法单元,以及一个或两个浮点乘法单元。这意味着可以同时进行整数加法、浮点加法和浮点乘法。
因此,执行浮点运算的代码最好是平衡的加法和乘法混合。减法使用与加法相同的单元。除法需要更长的时间。在浮点操作之间进行整数操作而不会降低性能是可能的,因为整数操作使用不同的执行单元。例如,一个执行浮点计算的循环通常会使用整数操作来递增循环计数器、将循环计数器与其限制进行比较等。在大多数情况下,可以假设这些整数操作不会增加总的计算时间。


 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值