前言
这一章主要的内容和C++编程可能是没有什么关系,主要是硬件的一些问题。
内存层次
对于性能的讨论最终都会落在内存以及使用内存上面。算法的复杂性主要取决于使用的内存的大小和类型,我们现在所面临的问题就是计算很快速但是访问很慢。 在一般的计算机内存层次中有五个层级:寄存器、L1(芯片内缓存)、L2(芯片外缓存)、主存(以各种变体出现的随机访问器:DRAM、SDRAM、RAMBUS、SyncLink等)和磁盘存储器。现在大多数的硬盘都是两个内存组成,一个小而快、一个大而慢。 内存访问的趋势是依赖访问时间和时钟周期,和带宽没有直接的联系。访问时间也就是我们所说的得到数据需要多少的延迟时间。延迟不会和贷款以相同的比例升高。主存访问的速度相比于CPU处理的速度就会发展的比较局限,但是带宽却能够同样的随着时代变得更加高效。因此内存的性能其实和这些硬件的发展也类似,访问的时间没有跟上,但是带宽却能够速度翻一倍。 上面的表格是内存访问延迟的表,看上去寄存器和L1缓存花费的时间差不多,但是L1缓存在每个时钟周期都需要一到两次的寄存器访问来获取有效的地址,寄存器的一个时钟周期可以处理三个操作数,因此寄存器的带宽大约是L1缓存的6倍。
寄存器:内存之王
寄存器使用直接寻址的方式,而内存层次较高的内存都是使用虚拟或者物理寻址的方式,因此寄存器在带宽和操作开销方面都是要优于其他的内存的。但是寄存器的使用也有一定的局限性,虽然寄存器提供了很快的访问速度,但是寄存器无法实现嵌入数组和进行动态索引,指针操作也是很困难,因此这些局限性使得寄存器很难作为唯一的内存存储器。 编译器的编写者和其他人一样之道寄存器内存的重要性,因此会在其中加入一些选项以此来优化性能。但是,这也会因为语言的一些特性而比较保守。其实这样的方式如今使得寄存器的使用越来越少,因此我们应该在编译时自己加上这些编译的选项得到最大的性能,同时编译器的编写者如果想让程序获得最大的性能,也需要让编译器发挥最大的能力。
磁盘和内存结构
对于那些具有顺序访问特征并且不会经常修改的数据来说,简单的线性或者索引文件是可行的数据结构。并且这样的结构比较适合管理数据的永久存储,而对于大型的运行时数据,还是需要类似虚拟内存这样的机制来进行管理。 但是我们在实际的编码过程中,有很多的程序员会把虚拟内存看作是一切问题的解决方案。这也是导致程序映像膨胀的一个原因,比如会把一些永久性的数据集映射到虚拟内存上,这样的话就增加了这些数据对于虚拟内存的依赖性,也会影响其他数据的保存和读取。 因此,我们不能不加分辨的依赖虚拟内存。虚拟内存并不深奥,操作系统会控制这个数据在内存中的驻留。磁盘的访问和虚拟页的切换一样耗时。访问磁盘的平均时间大约是12-20ms,大约有300万个时钟周期,典型的磁盘访问包括至少两次的上下文切换以及低级设备接口的执行。因此不断的依赖虚拟内存会导致磁盘访问的加剧,从而影响整体的性能。再分配存储空间时,需要尽可能的考虑数据局域性,如果数据集巨大,那么这种局域性就应该考虑页局域性,保证尽量少的页访问。这也是之前b+树存在的目的。 很多情况下,自然数据可以非常有效的使用系统的虚拟内存能力。数据表现出良好的时间和空间局域性。这就意味着我们可以访问大型的数据,但是在某一个时刻只会访问一小部分的数据。这就称为程序的工作集,它非常稳定,当需要访问新的页面是就可以使用这个工作集进行页的调入或者调出。 一般情况下,程序代码会倾向于拥有很好的局域性,但是代码存储的管理并不是必要的。C++的namespace就是实现局域性的比较好的手段。非常庞大的程序受益于单个编译单元在主要方法上的聚集。因此在编写代码的时候使用namespace将代码做成一个集合中要比文件夹来区分更加高效。 不幸的是,我们在完善存储结构的易用性的同时,也会增加我们错误使用的几率。这样的话,我们需要先理解这些存储结构底层的概念,建议先从简单的结构进行入手,然后慢慢深入比较复杂的结构。比如可以先从数组入手,把vector当作是一种大型的存储结构。如果数据的特性能满足需求,那就可以。如果不满足那么还需要寻找,vector、list等这些存储结构都有相对成熟的方法调用。但是我们要记住一点,能在满足需求的情况下,尽量使用简单的结构。
缓存影响
缓存不仅提供以前访问数据的更快访问,而且还提供包围着以前访问数据的小的预取区域。但是在内存存储中一般都会存在内存对齐的问题,并且两个数据可能在相邻的内存存储,这样的话相邻的内存就会自动缓存。 比如:
class lla
{
public:
...
void insert();
private:
...
int priority;
lla* next;
static lla* first[priority];
static lla* last[priority];
};
class lla
{
public:
...
void insert();
private:
...
int priority;
lla* next;
struct pairs {
lla* first;
lla* last;
};
struct pairs* ptrs[1024];
};
由上面的例子可以看出,struct这个结构使得first和last这两个指针在一个连续的内存中,一个数据缓存出现失败,会影响到另一个数据;而第一种实现的方式就不会有这样的影响。
缓存颠簸
在多处理器系统中,缓存相关协议是内存/缓存控制的一种机制。缓存相关协议防止了数据误读的产生。缓存相关协议的基础是内存属主说明和对同位缓存进入的明确验证。相当于是协议允许多个缓存共享一个数据项拷贝,但是在某一个时刻只有一个缓存对该数据进行修改。如果属主不是正在更新数据,那么其他缓存可以自己进行数据的拷贝,但是每当缓存行进行修改的时候,他会告诉其他缓存失效,这样的话就会使得其他缓存重新拷贝新的数据。 以上的操作是由缓存相关协议决定,但是可能会涉及到大量的缓存间直接的通信,影响到性能。如果内存总线由于缓存事务而繁忙等待,这样的现象叫做缓存颠簸。缓存颠簸是大型SMP系统中获得良好性能所必须的防垢产物。
避免跳转
现代的处理器对于跳转往往不太友好。现在几乎所有的现代操作系统执行都已经管道化,一共五个步骤:获取指令、指令解码、获取操作数、执行操作、存储结果。这样的管道化的操作有助于处理的效率,如果管道在执行的时候都是满的状态的话就会达到最好的性能。但是,往往跳转会延迟管道。下面的例子: CMP r1, r2 BLT x // 比0小就跳转 比较指令可以很快的进入管道,然后是BLT指令,但是这个指令需要先判断条件,这样的话就会延迟几个时钟周期才能完成。 跳转看起来明显较多的地方就是函数参数完整性检查。比如说在代码中有90%的参数检查,只有10%是计算。这样的话就会带来比较大的性能影响。最快的代码就是直线代码,没有条件,没有循环,没有调用,没有返回。我们需要记住的一点是:大量代码跳转的短代码是没有直接的长序列的代码来的执行效率高的。
简单计算胜过小分支
正如上面提到的,频繁的跳转对于程序也会造成很大的影响。我们在写代码的时候也需要避免小分支,使用直接的计算,比如下面:
int X_MAX = 16;
++x;
if (x >= X_MAX) {
x = 0;
}
const int X_MAX = 15;
x = (x + 1) & X_MAX;
其实就是个位掩码操作,但是用上面的语句的话使用了, 一次加载、一次递增、一次条件、一次存储。而下面的方法只是一次加载和一次递增,性能差别显而易见。
线程影响
线程相对于进程更加轻量化,线程就是多重处理的基础,一组线程可以访问一组共享的内存片段。大多数的系统会把任务看作为内存管理结构,而把线程看作是调度结构。尽管多线程是一种非常有价值的功能,在系统中适当的可以产生巨大的收益,但是多线程就意味着上下文切换,就意味着加锁和解锁的多余操作,这样怎样去权衡上下文切换和锁带来的影响,是掌握多线程编程的关键。
上下文切换
上下文切换是什么?一般来说上下文切换就是将进程移出处理器,将另一个进程移入处理器。这里面涉及到了进程状态以及处理器状态的维护。进程状态包括分配的内存、指针、子进程的内容、映射的页表信息等,寄存器和程序计数器的状态等。进程上下文中的大多数内容指挥偶尔被访问,但是处理器部分(比如寄存器和程序计数器,TLB)是一直由处理器使用。进程换出时就会将这些资源转移到内存,移入时再从内存中读出。上下文切换的主要代价就是处理器上下文的转移、缓存和TLB以及调度的开销。处理器的上下文主要指的就是进程状态的切换,一般保存的话需要20-50个字节,需要消耗大约100个时钟周期,读出又需要消耗100个时钟周期。但是对于那些包含更多的寄存器和处理器的机器来说,这样的开销会更大。
内核交叉
当程序使用内核权限来执行服务例程的时候就会发生内核交叉。这会发生胖系统调用或者瘦系统调用,主要取决于系统的调用机制以及请求服务的类型。 胖系统调用就是在最糟糕的情况下,把全部上下文切换到内核。请求服务的进程被换出而内核被换入,调度程序通常在服务例程确定了下一步要运行的进程开始执行。如果服务得到了满足,请求服务的进程会被换回到处理器。因此一次胖系统调用几乎需要两次的上下文切换。 瘦系统调用则不同,只需要一些寄存器并改变优先级和进程的级别就可以完成,这样对性能的影响会相对小很多。这样的好处也使得一些处理器厂商把内核的入口和出口直接构造在硬件内,这样可以最大化的减小调用的开销。
线程选择
有三种选择:单个、小型和大型。单个表示的就是只包含一个控制线程的程序。但是,更复杂的操作可以使得单线程程序实现并发。单线程异步依靠程序内部的结构来监视程序的状态,一般会在一个循环中来执行各个子任务。 大型线程是一种处理请求看作是一种处理实体的机制。可以认为每个重要对象创建的时候都会创建线程,这个线程的目的就是跟着对象从开始到对象的结束。尽管这种机制会很有效,但是会有频繁的上下文切换以及对于对象共享资源的控制。 小型线程是一种把单线程和大型线程混合的一种机制。与完成单个处理请求相比,小型线程更集中与完成子任务,对象在创建到销毁往往会从一个线程转移到另一个线程。这样的话虽然比大型线程处理更加的复杂,但是会减小上下文切换的消耗。 线程选择还有一个决定性的因素是硬件,我们使用的线程处于一个什么样的硬件环境也和我们选择线程的使用息息相关。对于要求响应延迟最小的程序来说,同步多线程要比异步轮询的方案更加适合。因为异步循环缺少通知的机制。但是如果延迟无所谓的话,使用异步就可以提供更好的性能。
总结
使用的内存离处理器越远,访问它的时间就越长。寄存器是离处理器最近的资源。 虚拟内存不是无偿的 上下文切换的开销很昂贵,需要考虑之后再使用 多处理器体系结构会使得单线程的优势越来越少
|