• C++ 性能优化指南 KurtGuntheroth 第2章 影响优化的计算机行为


    所有这些被广泛使用的计算机都会执行存储在内存中的指令。指令所操作的数据也是存储在内存中的。内存被分为许多小字word,这些字是由若干位bit组成。其中一小部分宝贵的内存字是寄存器register,它们的名字直接定义在机器指令中。其他绝大多数内存字都是以数值型的地址address命名的。每台计算机中都有一个特殊的寄存器保存着下一条待执行的指令的地址。如果将内存看作一本书,那么执行地址execution address就相当于下一个单词的手指。执行单元execution unit,也被称为处理器、核心、CPU、运算器等名字从内存中读取指令流,然后执行它们。指令会告诉执行单元要从内存中读取声明数据,如何处理数据,以及将声明结果写入到内存中。从内存地址读取数据和向内存地址写入数据是需要花费时间的,指令对数据进行操作也是需要时间的。

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

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

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

    某些内存地址可能是设备寄存器,而不是普通内存。这些地址的值可能会在同一个线程对该地址两次连续读的间隔发生变化,这表示硬件也发生了变化。volatile关键字要求编译器在每次使用该变量时都获取它的一份新的副本。

    C++11提供了一个名为std::atomic<>的特性,可以让内存在一段短暂的时间内表现得仿佛是字节的简单线性存储一样。

    操作系统的目的就是为了给每个程序讲一个让它们信服的谎言。

    2.2 计算机的真相

    真实计算机的实际内存硬件的处理器速度与指令的执行速率相比是很慢的。内存并非真的是以字节为单位被访问的。真实的计算机非常快,但并非因为它们执行指令非常快,而是因为它们同时执行许多指令,而且它们内部的复杂电路可以确保这些同时执行的指令表现的就像一个接一个地执行一样。

    2.2.1 内存很慢

    计算机的主内存相对于它内部的逻辑门和寄存器来说非常慢,所花费的时间为电子穿越微处理器内各个微距晶体管所需时间的数千倍。主内存太慢,所以桌面级处理器在从主存中读取一个数据字的时间内,可以执行数百条指令。

    优化的根据在于处理器访问内存的开销远比其他开销大,包括执行指令的开销。

    2.2.2 内存访问并非以字节为单位

    虽然C++认为每个字节都是可以独立访问的,但计算机会通过获取更大块的数据来补偿缓慢的内存速度。

    当C++获取一个多字节类型的数据,比如一个int、double或者指针时,构成数据的字节可能跨越了两个物理内存字,这种访问被称为非对齐的内存访问unaligned memory access。此处优化的意义在于,一次非对齐的内存访问的时间相当于这些字节在同一个字中时的两倍,因为需要读取读两个字。C++编译器会帮助我们对齐结构体,使每个字段的起始字节地址都是该字段的大小的倍数。但是这样也会带来相应的问题:结构体的“洞”中包含了无用的数据。在定义结构体时,对各个数据字段的大小和顺序稍加注意,可以在保持对齐的前提下使结构体更紧凑。

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

    为了进一步补偿主内存的缓慢速度,许多计算机中都有高速缓存cache memory,一种非常接近处理器的快速的,临时的存储,来加快对那些使用最频繁的内存字的访问速度。一种大致的经验的估计,高速缓存层次中每一层的速度大约是它下面一层的10倍。现代多级缓存结构导致专注于指令的时钟周期和其他“奥秘”经常会令人恼怒而且没有效果的一个原因,高速缓存的状态会让指令的执行时间变得非常难以确定。

    通常,选择放弃的数据都是最近最少被使用的数据。

    就C++而言,一个包含循环处理的代码块的执行速度可能会更快。这是因为组成循环处理的指令会被频繁地执行,而且互相紧挨着,因此更容易留在高速缓存中。一段包含函数调用或是含有if语句导致执行发生跳转的代码则会执行得较慢,因为代码中各个独立的部分不会那么频繁地被执行,也不是那么紧邻着。

    类似地,访问包含连续地址的数据结构(如数组和矢量),要比访问包含包含通过指针链接的节点数据结构体快,因为连续地址的数据所需要地存储空间更少。

    2.2.4 内存字分为大端和小端

    从首字节地址读取最高有效位的计算机被称为大端计算机,小端计算机则会首先读取最低有效位。

    字节序只是C++不能指定int中位的存储方式或是设置联合体汇总的一个字段会如何影响其他字段的原因之一。

    2.2.5 内存容量是有限的

    为了维持内存容量无限的假象,操作系统可以如同使用高速缓存一样的使用物理内存,将没有放入物理内存中的数据作为文件存储在磁盘上。这种机制被称为虚拟内存virtual memory。

    高速缓存和虚拟内存带来的一个影响是,由于高速缓存的存在,在进行性能测试时,一个函数运行于整个程序的上下文中时的执行速度可能是运行于测试套件中的万分之一。这个影响放大了减少内存或磁盘使用量带来的优化收益,而减少代码的优化收益则没有任何变化。第二个影响则是,如果一个大程序访问许多离散的内存地址,那么困难没有足够的高速缓存来保存刚刚使用的数据。这会导致一种性能衰退,称为页抖动page thrashing。当在操作系统的虚拟缓存文件中发生页抖动时,性能会下降为原来的1/1000.

    2.2.6 指令执行缓慢

    嵌入式微处理器被设计为执行指令的速度与从内存中获取指令一样快。桌面级微处理器则优额外的资源并发地执行处理,因此它们执行指令的速度可以比从主内存获取指令快很多倍,多数时候都需要高速缓存去“喂饱”它们的执行单元。对优化而言,这意味着内存访问决定了计算开销。

    处理器中包含一条指令的流水线,它支持并发执行指令。指令在流水线中解码,获取参数,执行计算,最后保存处理结果。

    如果指令B需要指令A的计算结果,那么在计算出指令A的处理结果前是无法执行指令B的计算。这会导致指令执行过程中发生流水线停滞pipeline stall。

    2.2.7 计算机难以做决定

    另一个导致流水线停滞的原因是计算机需要作决定。控制转义指令略有不同,跳转指令或跳转子程序会将执行地址变为一个新的值。在执行跳转一段时间后,执行地址才更新。在这之前是无法从内存中读取“下一条”指令并将其放入到流水线中的。

    在执行了一个条件分支指令后,执行可能会走向两个方向:下一条指令或者分支目标地址中的指令。最终会走向那个方向取决于之前的某些计算结果。

    2.2.8 程序执行中的多个流

    操作系统会执行一个线程一段很短的时间,然后将上下文切换至其他线程或进程。对程序而言,就仿佛执行一条语句花费了一纳秒,但执行下一条语句花费了60毫秒。

    切换上下文究竟是什么意思?即将暂停的线程保存处理器中寄存器,然后为即将被继续执行的线程加载之前保存过的寄存器。现代处理器中的寄存器包含数百字节的数据。当新线程继续执行时,它的数据可能并不在高速缓存中,所以当加载新的上下文到高速缓存中,会有一个缓慢的初始化阶段。因此,切换线程上下文的成本很高。

    当操作系统从一个程序切换到另外一个程序时,这个过程的开销会更加昂贵。所有脏的高速缓存页面(页面被写入了数据,但还没有反映到主内存中)都必须被刷新至物理内存中。所有的处理器寄存器都需要被保存。然后,内存管理器中的‘物理地址到虚拟地址”的内存页寄存器也需要被保存接着,新线程的‘物理地址到虚拟地址”的内存页寄存器和处理器寄存器被载入。最后就可以继续执行了。但是这时高速缓存是空的,因此在告诉缓存被填充满之前,还有一段缓慢且需要激烈地竞争内存的初始化阶段。

    为了能够达到更好的性能,一个多核处理器的执行单元及相关的高速缓存,与其他执行单元及相关的高速缓存都是或多或少互相独立的。不过,所有的执行单元都共享同样的主内存。执行单元必须竞争使用那些可以将它们链接至主内存的硬件,使得拥有多个执行单元的计算机中,冯诺依曼瓶颈的限制变得更加明显。

    当执行单元在访问主内存存在竞争时,很可能一个执行单元改变了值,然后又执行了几百个指令,主内存中的值才会被更新。

    如果一台计算机有多个执行单元,那么一个执行单元可能需要在很长一段时间后才能看见另一个执行单元所写的数据被反映到主内存中,而且主内存发生改变的顺序可能与指令的执行顺序不一样。必须使用特殊的同步指令来确保运行于不同执行单元间的线程看到的内存中的值是一致的。对优化而言,这意味着访问线程间的共享数据比访问非共享数据要慢很多。

    2.2.9 调用操作系统的开销是昂贵的

    对优化而言,系统调用意味是安规的,是单线程程序中函数调用的数百倍。

    2.3 C++也会说谎

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

    对优化而言,某些语句隐藏了大量的计算,但从这些语句的外表看不出它的性能开销会多大。

    2.3.2 语句并非按顺序执行

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

    并发会让情况变得复杂。C++程序在编译时不知道是否会有其他线程并发运行。C++编译器不知道哪个变量--如果有的话--会在线程间共享。当程序中包含共享数据的并发线程时,编译器对语句的重排序和延迟写入主内存会导致计算结果与按顺序执行语句的计算结果不同。

    2.4 小结

    在处理器中,访问内存的性能开销远比其他操作的性能开销大。

    非对齐访问所需的时间是所有字节都在同一个字中时的两倍。

    访问频繁使用的内存地址的速度比访问非频繁使用的内存地址的速度快;

    访问相邻地址的内存的速度比访问互相远隔的地址的内存快。

    由于高速缓存的存在,一个函数运行于整个程序的上下文中时的执行速度可能比运行于测试套件中时更慢。

    访问线程间共享的数据比访问非共享的数据要慢很多。

    计算比做决定要快。

    每个程序都会和其他程序竞争计算机资源。

    如果一个程序必须在启动时执行或是在负载高峰期执行,那么测量性能时必须加载负载。

    每一次赋值,函数参数的初始化都函数返回都会调用一次构造函数,这个函数可能隐藏了大量的未知代码。

    有些语句隐藏了大量的计算。从语句的外表上看不出语句的性能开销会有多大。

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

  • 相关阅读:
    HTML页面在iPhone中电话号码自动检测带来的布局问题
    pycharm运行后不出结果
    Windows + VS2022超详细点云库(PCL)配置
    分布式计算模型Mapreduce实践与原理剖析(一)
    l8-d15 IO多路复用select函数
    java计算机毕业设计疫情防控管理系统MyBatis+系统+LW文档+源码+调试部署
    elementui-plus el-tree组件数据不显示问题解决
    中小制造企业为什么要做MES智能化升级?看了本文你就知道了
    NX二次开发-使用MFC的CImage裁剪图片
    Kotlin协程Channel浅析
  • 原文地址:https://blog.csdn.net/weixin_47955824/article/details/125894837