《C++性能优化指南》 linux版代码及原理解读 第二章

《C++性能优化指南》 linux版代码及原理解读 第二章,第1张

目录

概述

C++所相信的计算机谎言

计算机的真相

        

某些内存访问会比其他的更慢

内存容量是有限的,但对于程序来说是无限的

流水线停滞  

程序执行中的多个流

调用 *** 作系统的开销是昂贵的

C++也会说谎

并非所有语句的性能开销都相同

语句并非按顺序执行


概述

本章节主要通过讲解部分计算机硬件的基本知识背景,让读者知道很多时候的计算机的表现并不如结果看起来的那样简单,甚至也不像某些书籍中教导的那样运行。

所有这些被广泛使用的计算机都会执行存储在内存中的指令。

指令所 *** 作的数据也是存储在内存中的。

内存被分为许多小的字(word),这些字由若干位(bit)组成。

其中一小部分宝贵的内存字是寄存器(register),它们的名字被直接定义在机器指令中。

其他绝大多数内存字则都是以数值型的地址(address)命名的。

每台计算机中都有一个特殊的寄存器保存着下一条待执行的指令的地址。

如果将内存看作一本书,那么执行地址(execution address)就相当于指向要阅读的下一个单词的手指。

执行单元(execution unit,也被称为处理器、核心、CPU、运算器等其他名字)从内存中读取指令流,然后执行它们。

指令会告诉执行单元要从内存中读取(加载,取得)什么数据,如何处理数据,以及将什么结果写入(存储、保存)到内存中。

计算机是由遵守物理定律的设备组成的。

内存地址读取数据和向内存地址写入数据是需要花费时间的,指令对数据进行 *** 作也是需要花费时间的。

C++所相信的计算机谎言

• C++程序只需要表现得好像语句是按照顺序执行的。

C++编译器和计算机自身只要能够确保每次计算的含义都不会改变,就可以改变执行顺序使程序运行得更快。

(其实代码的执行顺序不一定是按照用户编写的顺序执行的,比如代码 a += 1 ; b = 10; 有时候可能b变量的赋值会比a的要靠前执行。

• 自C++11开始,C++不再认为只有一个执行地址。

C++标准库现在支持启动和终止线程以及同步线程间的内存访问。

在C++11之前,程序员对C++编译器隐瞒了他们的线程,有时候这会导致难以调试。

• 某些内存地址可能是设备寄存器,而不是普通内存。

这些地址的值可能会在同一个线程对该地址的两次连续读的间隔发生变化,这表示硬件发生了变化。

在C++中用volatile关键字定义这些地址。

声明一个volatile变量会要求编译器在每次使用该变量时都获取它的一份新的副本,而不用通过将该变量的值保存在一个寄存器中并复用它来优化程序。

另外,也可以声明指向volatile内存的指针。

• C++11提供了一个名为std::atomic<>的特性,可以让内存在一段短暂的时间内表现得仿佛是字节的简单线性存储一样,这样可以远离所有现代处理器的复杂性,包括多线程执行、多层高速缓存等。

有些开发人员误以为这与volatile是一样的,其实他们错了。

计算机的真相

        实际而言,计算机的内存硬件的处理速度和指令的执行速度相差很大,往往能相差几个数量级。

一般来说,计算机会通过缓存来弥补这个不足,比如桌面级处理器可以一次获取64字节的数据,像一些超级计算机可以一次获取512甚至更多,注意每次内存的 *** 作都是64 byte对齐的。

当我们需要考虑内存的获取的时候,我们也要明白如果计算机每次取的内存数据是对齐的,比如每次都是按照64倍数的地址开始获取数据,然后每次获取64字节的数据,进行处理,但是有时候我们编写的程序会导致我们希望取出来的数据横跨在两个物理内存字上面,这就导致CPU需要执行两次 *** 作,这种成为非对齐的内存访问(unaligned memory access)。

这种优化的意义也是显而易见的,我们可以通过C++编译器或者手动选择对齐,让我们的数据能进行64 byte对齐,但是这样也会导致一些问题,比如数据和数据之间会有一些无法使用到的内存,而这部分只是为了能让数据对齐。

         某些内存访问会比其他的更慢

        计算机的CPU一般都具有多级缓存,越靠近CPU的缓存,容量越小,但是速度越快。

每层缓存之间的速度可能相差一个或者几个数量级。

CPU获取数据都是通过缓存来获取的。

在桌面级处理器中,通过一级高速缓存、二级高速缓存、三级高速缓存、主内存和磁盘上的虚拟内存页访问内存的时间开销范围可以跨越五个数量级。

        当需要获取不在缓存中的数据时,缓存中的部分数据需要被替换成需要的数据,但并不是缓存中所有的数据都会被清空。

而哪一部分会被替换出去,一般会按照最近最少使用的数据进行丢弃,这意味着访问那些被频繁地访问过的存储位置的速度会比访问不那么频繁地被访问的存储位置更快。

这就是CPU的空间局部性原则。

        通过这些,我们可以推测出CPU执行指令时有以下几个特点:

  1. 一个包含循环处理的代码块的执行速度可能会更快。

    (这是因为组成循环处理的指令会被频繁地执行,而且互相紧挨着,因此更容易留在高速缓存中。

    )同理一个包含if语句或者函数调用这种导致执行发生跳转的代码执行的会更慢。

  2. 访问连续的数据结构的速度会比访问不连续的数据结构快。

    因为不连续的数据结构需要频繁的触发缓存未命中时间将所需要的数据添加到缓存中。

内存容量是有限的,但对于程序来说是无限的

        计算机中的内存容量并非是无限的。

为了维持内存容量无限的假象, *** 作系统可以如同使用高速缓存一样使用物理内存,将没有放入物理内存中的数据作为文件存储在磁盘上。

这种机制被称为虚拟内存(virtual memory)。

虚拟内存制造出了拥有充足的物理内存的假象。

        从磁盘上获取一个内存块需要花费数十毫秒,对现代计算机来说,这几乎是一个恒定值。

        高速缓存和虚拟内存带来的一个影响是,由于高速缓存的存在,在进行性能测试时,一个函数运行于整个程序的上下文中时的执行速度可能是运行于测试套件中时的万分之一。

当运行于整个程序的上下文中时,函数和它的数据不太可能存储至缓存中,而在测试套件的上下文中,它们则通常会被缓存起来。

这个影响放大了减少内存或磁盘使用量带来的优化收益,而减小代码体积的优化收益则没有任何变化。

        第二个影响则是,如果一个大程序访问许多离散的内存地址,那么可能没有足够的高速缓存来保存程序刚刚使用的数据。

这会导致一种性能衰退,称为页抖动(page thrashing)。

当在微处理器内部的高速缓存中发生页抖动时,性能会降低;当在 *** 作系统的虚拟缓存文件中发生页抖动时,性能会下降为原来的1/1000。

过 去,计算机的物理内存很少,页抖动更加普遍。

不过,如今,这个问题仍然会发生。

流水线停滞  

        如果指令B需要指令A的计算结果,那么在计算出指令A的处理结果前是无法执行指令B的计算的。

这会导致在指令执行过程中发生流水线停滞(pipeline stall)——一个短暂的暂停,因为两条指令无法完全同时执行。

如果指令A需要从内存中获取值,然后进行运算得到线程B所需的值,那么流水线停滞时间会特别长。

流水线停滞会拖累高性能微处理器。

        

        一个会导致流水线停滞的原因是计算机需要作决定。

大多数情况下,在执行完一条指令后,处理器都会获取下一个内存地址中的指令继续执行。

这时,多数情况下,下一条指令已经被保存在高速缓存中了。

一旦流水线的第一道工序变为可用状态,指令就可以连续地进入到流水线中。

如果我们执行了一条条件分支语句之后,程序执行会有两种可能,下一条语句或者分支指令。

而具体的执行哪一个是按照条件的执行结果确定的,在等待计算结果的过程中,以及决定出下一条指令的地址并取出,流水线都会停滞。

        还有一种条件分支是控制转义, 跳转指令或跳转子例程指令会将执行地址变为一个新的值。

在执行跳转指令一段时间后,执行地址才会被更新。

在这之前是无法从内存中读取“下一条”指令并将其放入到流水线中的。

新的执行地址中的内存字不太可能会存储在高速缓存中。

在更新执行地址和加载新的“下一条”指令到流水线中的过程中,会发生流水线停滞。

      

程序执行中的多个流

        当 *** 作系统从一个程序切换至另外一个程序时,这个过程的开销会更加昂贵。

所有脏的高速缓存页面(页面被入了数据,但还没有反映到主内存中)都必须被刷新至物理内存中。

所有的处理器寄存器都需要被保存。

然后,内存管理器中的“物理地址到虚拟地址”的内存页寄存器也需要被保存。

接着,新线程的“物理地址到虚拟地址”的内存页寄存器和处理器寄存器被载入。

最后就可以继续执行了。

但是这时高速缓存是空的,因此在高速缓存被填充满之前,还有一段缓慢且需要激烈地竞争内存的初始化阶段。

         计算机不止有一个指令地址,他可以同时执行多个指令,这也是它执行指令为什么这么快的原因。

调用 *** 作系统的开销是昂贵的

        除了最小的处理器外,其他处理器都有硬件可以确保程序之间是互相隔离的。

这样,程序A不能读写和执行属于程序B的物理内存。

这个硬件还会保护 *** 作系统内核不会被程序覆写。

另一方面, *** 作系统内核需要能够访问所有程序的内存,这样程序就可以通过系统调用访问 *** 作系统。

有些 *** 作系统还允许程序发送访问共享内存的请求。

许多系统调用的发生方式和共享内存的分布方式是多样和神秘的。

对优化而言,这意味着系统调用的开销是昂贵的,是单线程程序中的函数调用开销的数百倍。

C++也会说谎

        C++为了用户的编程简单,所以在内部隐藏了大量的细节,但是这也导致有些时候程序的表现可能不像预期的那样运行。

并非所有语句的性能开销都相同

        在以前来说,赋值 *** 作的性能开销是一样的,就是将一个寄存器中的数值保存到另一个寄存器当中。

但是随着语法的发展,新特性的加入,这些也不是一成不变的。

        比如以下代码

int i , j ;
// ........process......
i = j ;

         像这种内置的数据类型的赋值,就是将数据对应的内容复制。

但是假如是以下的代码:

class T;//declaration 
T t1 = T();

        在这个代码中,同样是赋值语句,但是内部所执行的 *** 作就与上述的有很大不同。

这其中甚至会调用到类的构造函数、析构甚至更多。

        同样,相同的 *** 作

template 
T a , b , c;
a = b * c;

        如果T是int,或者如果T是一个矩阵,那他们的执行复杂度也是相差甚大。

语句并非按顺序执行

        

        C++程序表现得仿佛它们是按顺序执行的,完全遵守了C++流程控制语句的控制。

上句话的含糊其辞的“仿佛”正是许多编译器进行优化的基础,也是现代计算机硬件的许多技巧的基础。

当然,在底层,编译器能够而且有时也确实会对语句进行重新排序以改善性能。

但是编译器知道在测试一个变量或是将其赋值给另外一个变量之前,必须先确定它包含了所有的最新计算结果。

现代处理器也可能会选择乱序执行指令,不过它们包含了可以确保在随后读取同一个内存地址之前,一定会先向该地址写入值的逻辑。

甚至微处理器的内存控制逻辑可能会选择延迟写入内存以优化内存总线的使用。

但是内存控制器知道哪次写值正在从执行单元穿越高速缓存飞往主内存的“航班”中,而且确保如果随后读取同一个地址时会使用这个“航班”中的值。

        并发会让情况变得复杂。

C++程序在编译时不知道是否会有其他线程并发运行。

C++编译器不知道哪个变量——如果有的话——会在线程间共享。

当程序中包含共享数据的并发线程时,编译器对语句的重排序和延迟写入主内存会导致计算结果与按顺序执行语句的计算结果不同。

开发人员必须向多线程程序中显式地加入同步代码来确保可预测的行为的一致性。

当并发线程共享数据时,同步代码降低了并发量。

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/674833.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-19
下一篇 2022-04-19

发表评论

登录后才能评论

评论列表(0条)

保存