影响优化的计算机行为
所有这些被广泛使用的计算机都会执行存储在内存中的指令。指令所操作的数据也是存在在内存中的。内存被分为许多小的字(Word),这些字由若干位(bit)组成。其中一小部分宝贵的内存字是寄存器(register),它们的名字被直接定义在机器指令中。其他绝大多数内存字则都是以数值型的地址(address)命名的。每台计算机中都有一个特殊的寄存器保存着下一条待执行的指令的地址,即执行地址(execution address)。执行单元(execution unit)从内存中读取指令流,然后执行它们。指令会告诉执行单元要从内存中读取什么数据,如何处理数据,以及将什么结果写入到内存中。计算机是由遵守物理定律的设备组成的。从内存地址读取数据和向内存地址写入数据是需要花费时间的,指令对数据进行操作也是需要花费时间。
除了以上这条基本原则外,计算机体系结构是易变的,所以很难严格地测量出硬件行为在数值上的规律。现代处理器做了许多不同的、交互的事情来提高指令执行速度,导致指令的执行时间实际上变得难以确定。还有一个问题是,许多开发人员甚至无法准确地知道他们的代码会运行在上面处理器上,多数情况下只能用试探法。
C++所相信的计算机谎言
C++程序至少会假装相信上面将结果的简单的计算机基本模型中的一个版本。其中有
- 可以以固定字符长度的字节为单位寻址,在本质上容量是无限的内存。
- 与其他任何有效的内存地址都不同的特殊的地址,叫做nullptr。
- 整数0会被转换为nullptr,尽管在地址0上不需要nullptr
- 概念上的执行地址指向正在被执行的源代码语句
- 各条语句会按照编写顺序执行,受到C++控制流程语句的控制
C++知道计算机远比这个简单模型要复杂:
- C++程序只需要表现得好像语句是按照顺序执行的。C++编译器和计算机自身之哟啊能够确保每次计算的含义都不会改变,就可以改变执行顺序使程序运行地更快。
- 自C++11开始,C++不再认为只有一个执行地址。C++标准库现在支持启动和终止线程以及同步线程间的内存访问。在C++11之前,程序员对C++编译器隐瞒了他们的线程,有时候这会导致难以调试。
- 某些内存地址可能是设备寄存器,而不是普通内存。这些地址的值可能会在同一个线程对该地址的两次连续读的间隔发生变化,这表示硬件发生了变化。在C++中用volatile关键字定义这些地址。声明一个volatile变量会要求编译器在每次使用该变量时都获取它的一份新的副本,而不用通过将该变量的值保存在一个寄存器中并复用它来优化程序。另外,也可以声明指向volatile内存的指针。
- C++11提供了一个名为std::atomic<>的特性,可以让内存在一段短暂的时间内表现的仿佛是字节的简单线性存储一样,这样可以远离所有现代处理器的复杂性,包括多线程执行、多层高速缓存等。有些开发人员误以为这与volatile是一样 ,其实他们错了。
操作系统也欺骗了程序和用户。实际上,操作系统的目的就是为了给每个程序讲一个让它们信服的谎言。最重要的谎言之一是,操作系统希望每个程序都相信它们是独立运行于计算机上的,而且这些计算机的内存是无限的,还有无限的处理器来运行程序的所有线程。
操作系统会使用计算机硬件来隐藏这些谎言,这样C++不得不相信它们。除了降低程序的运行速度外,这些谎言其实对程序运行并没有什么影响。不过,它们会导致性能测量变得复杂。
计算机的真相
对性能优化而言非常重要的是,真实计算机的实际内存硬件的处理速度与指令的执行速率相比是很慢的。内存并非真的是以字节为单位被访问的,内存并非是一个由相同元素组成的简单的线性数组,而且它的容量也是有限的。真是的计算机可能有不止一个指令地址,而且它们内部的复杂电路可以确保这些同时执行的指令表现得就像一个接一个地执行一样。
内存很慢
计算机的主内存相对于它内部的逻辑门和寄存器来说非常慢。桌面级处理器在从主内存中读取一个数据字的时间内,可以执行数百条指令。优化的根据自安于处理器访问内存的开销远比其他开销大,包括执行指令的开销。
内存访问并非以字节为单位
虽然C++认为每个字节都是可以独立访问的,但计算机会通过获取更大块的数据来补偿缓慢的内存速度。当C++获取一个多字节类型的数据,构成数据的字节可能跨越了两个物理内存字。这种访问被称为非对齐的内存访问(unaligned memory access)。此处优化的意义在于,一次非对齐的内存访问的时间相当于这些字节在同一个字节时的两倍,因为需需要读取两个字。C++编译器会帮助我们对齐结构体,使每个字段的起始字节地址都是该字段大小的倍数。但是这样也会带来相应的问题:结构体的洞中包含了无用的数据。在定义结构体时,对各个数据字段的大小和顺序稍加注意,可以在保持对齐的前提下使结构体更加紧凑。
某些内存访问会比其他的更慢
为了进一步补偿主内存的缓慢速度,许多计算机中都有高速缓存(cache memory),一种非常接近处理器的快速的、临时的存储,来加快对那些使用最频繁的内存字的访问速度。当一个执行单元要获取的字节已经被缓存时,无需访问主内存即可立即获得这些字节。高速缓存层次中每一层的速度大约是它下面一层的10倍。在桌面级处理器中,通过一级高速缓存、二级高速缓存、三级高速缓存、主内存和磁盘上的虚拟内存页访问内存的时间开销范围可以跨越五个数量级。这就是专注于执行的时钟周期和其他奥秘经常会令人恼怒而且没有效果的一个原因,高速缓存的状态会让指令的执行时间变得非常难以确定。
当执行单元需要获取不在高速缓存中的数据时,有些当前处于高速缓存中的数据必须被舍弃已换取足够的空余空间。通常,选择放弃的数据都是最近很少被使用的数据。这一点与性能优化有着紧密的关系,因为这意味着访问那些被频繁访问过的存储位置的速度会比不那么频繁被访问的存储位置更快。
读取一个不再高速缓存中的字节甚至会导致许多临近的字节也都被缓存起来,这些临近的字节也就可以被高速访问了。这意味着平均而言,访问内存中相邻位置的字节要比访问相互远隔的字节的速度更快。
就C++而言,这表示一个包含循环处理的代码执行速度可能会更快,因为循环处理的指令会被频繁地执行,而且相互紧挨着,因此更容易留在高速缓存中。一段包含函数调用或是含有if语句导致执行发生跳转的代码则会执行得较慢,因为代码中各个独立的部分不会那么频繁地执行,也不是那么紧挨着。
类似地,访问包含连续地址的数据结构(如数组),要比访问包含通过指针链接的节点的数据结构(比如链表或树)快,因为连续地址的数据所需的存储空间更少。
内存字分为大端和小端
大端计算机:从首字节地址读取最高有效位的计算机;
小端计算机:从首字节地址读取最低有效位的计算机。
这关系到哪一端首先被存储和发送。
字节序(endian-ness)只是C++不能指定int中位的存储方式或是设置联合体中的一个字段会如何影响其他字段的原因之一。所编写的程序可以工作于一类计算机上,却在另一类计算机上崩溃,原因也自安于字节序。
内存容量是有限的
为了维持内部容量无限的假象,操作系统可以如同使用高速缓存一样使用物理内存,将没有放入物理内存中的数据作为文件存储在磁盘上。这种机制称为虚拟内存(virtual memory)。
不过,从磁盘上获取一个内存块需要花费数十毫秒,对现代计算机来说,这几乎是一个恒定值。
高速缓存和虚拟内存带来的一个影响是,在进行性能测试时,一个函数运行于整个程序的上下文时的执行速度可能是运行于测试套件中时的万分之一。当运行于整个程序的上下文中时,函数和它的数据不太可能存储至缓存中,而在测试套件的上下文中,它们则通常会被缓存起来。这个影响放大了减少内存或磁盘使用量带来的优化收益,而减小代码体积的优化收益则没有任何变化。
第二个影响则是,如果一个大程序访问许多离散的内存地址,那么可能没有足够的高速缓存来保存程序刚刚使用的数据。这会导致一种性能衰退,称为页抖动(page thrashing)。当在微处理器内部的高速缓存中发生页抖动时,性能会降低;当在操作系统的虚拟缓存文件中发生页抖动时,性能会下降为原来的1/1000。
指令执行缓慢
对性能优化而言,内存访问决定了计算开销。
如果没有其他东西妨碍,现代桌面级处理器可以以惊人的速率执行指令。它们每几百皮秒就可以完成一次指令处理。但这并不意味着每条指令只需要皮秒数量级的时间即可执行完毕。处理器中包含一条指令流水线,它支持并发执行指令。它会将指令分解为若干阶段,这样就可以并发地处理更多的指令。
如果指令B需要指令A的计算结果,那么在计算出指令A的处理结果前是无法执行指令B的,这会导致在指令执行过程中发生流水线停滞(pipeline stall)——一个短暂的暂停,因为两条指令无法完全同时执行。
计算机难以作决定
另一个会导致流水线停滞的原因是计算机需要作决定。
大多数情况下,在执行完一条指令后,处理器都会获取下一个内存地址中的指令继续执行。这时,多数情况下,下一条指令已经被保存在高速缓存中了。一旦流水线的第一道工序变为可用状态,指令就可以连续地进入到流水线中。但是控制转义指令略有不同。跳转指令或跳转子例程指令会将执行地址变为一个新的值。在执行跳转指令一段事件后,执行地址才会被更新。新的执行地址中的内存字也不太可能会存储在高速缓存中。
在执行了一个条件分支指令后,执行可能会走两个方向,取决于某些计算的结果。这时,流水线会发生停止,直至这些计算结果的相关指令都指令完毕并且取得下一条指令。
对性能优化而言,计算比做决定更快。
程序执行中的多个流
如果操作系统正在将一个线程切换至同一个程序的另外一个线程,这表示要为即将暂停的线程保存处理器中的寄存器,然后为即将被继续执行的线程加载之前保存过的寄存器。这些数据可能已不在高速缓存中,所以切换线程上下文的成本很高。
当操作系统从一个程序切换至另外一个程序的开销会更加昂贵。
当一个程序必须等某个事件发生时,它甚至可能会在这个事件发生后继续等待,直至操作系统让处理器为继续执行程序做好准备。这会导致当程序运行于其他程序的上下文中,竞争计算机资源时,程序的运行时间变得更长和更加难以确定。
当执行单元写值时,这个值会首先进入高速缓存内存,最终写入主内存中。但是,这些执行单元在访问主内存时存在着竞争,所以可能在执行单元改变了一个值,然后又执行几百个指令后,主内存的值才会被更新。受到不可预测的时间因素的干扰,执行单元看到的共享内存字中的值可能是旧的,也可能是被更新后的值。这是必须使用特殊的同步指令来确保运行于不同执行单元间的线程看到的内存中的值是一直的。对优化而言,这意味着访问线程间的共享数据比访问非共享数据要慢得多。
调用操作系统的开销是昂贵的
许多系统调用的发生方式和共享内存的分布方式是多样和神秘的。对优化而言,这意味着系统调用的开销是昂贵的,是单线程程序中的函数调用开销的数百倍。
C++也会说谎
C++对用户所撒的最大的谎言就是运行它的计算机的结构是简单的、稳定的。为了假装相信这条谎言,C++让开发人员不用了解每种微处理器设备的细节即可编程,如同正在使用真实得近乎残酷的汇编语言编程以一样。
并非所有语句的性能开销都相同
系统内部数据类型的赋值语句的性能开销都一样,但并非所有语句的性能开销都相同。类的赋值语句会调用构造函数,甚至形参传递、函数返回值也会调用构造函数,并且算数操作符和比较操作符可以被重载。对优化而言,这意味着某些语句隐藏了大量的计算,但从这些语句的外表上看不出它的性能开销会有多大。
语句并非按顺序执行
C++程序表现得仿佛它们是按顺序执行的,完全遵守了C++流程控制语句的控制。这正是许多编译器进行优化的基础,也是现代计算机硬件的许多技巧的基础。
当然,在底层,编译器能够而且有时也确实会对语句进行重新排序以改善性能。但是编译器知道在测试一个变量或是将其赋值给另外一个变量之前,必须先确定它包含了所有的最新计算结果。
并发会让情况变得复杂。C++程序在编译时不知道是否会有其他线程并发运行。C++编译器不知道哪个变量会在线程间共享。当程序中包含共享数据的并发线程时,编译器对语句的重排序和延迟写入主内存会导致计算结果与按顺序执行语句的计算结果不同。开发人员必须向多线程程序中显式地加入同步代码来确保可预测的行为的一致性。当并发线程共享数据时,同步代码降低了并发率。
小结
- 在处理器中,访问内存的性能开销远比其他操作的性能开销大。
- 非对齐访问所需的时间是所有字节都在同一个字中时的两倍。
- 访问频繁使用的内存地址的速度比访问非频繁使用的内存地址的速度快。
- 访问相邻地址的内存的速度比访问互相远隔的地址的内存块。
- 由于高速缓存的存在,一个函数运行于整个程序的上下文中时的执行速度可能比运行于测试套件中时更慢。
- 访问线程间共享的数据比访问非共享的数据要慢很多。
- 计算比做决定快。
- 每个程序都会与其他程序竞争计算机资源。
- 如果一个程序必须在启动时执行或是在负载高峰期时执行,那么在测量性能时必须加载负载。
- 每一次赋值、函数参数的初始化和函数返回值都会调用一次构造函数,这个函数可能隐藏了大量的未知代码。
- 有些语句隐藏了大量的计算。从语句的外表上看不出语句的性能开销会有多大。
- 当并发线程共享数据时,同步代码降低了并发量。