你知道的,为了更快

8 月 8 日,东京奥运会落下了帷幕,苏炳添在 100m 比赛中跑出了 9.83s 的成绩,让中国运动员第一次站在了 100m 决赛的跑道上,也创造了新的亚洲记录。和奥运会类似的是,计算机世界中也在不断追求 ”更快“ 的目标。本文从CPU、操作系统、数据结构与算法等多个角度,介绍那些使计算机更快的技术。

本文提纲如下:

1. CPU

CPU 作为计算机的心脏,一切计算最终都要由它来完成,因此先来介绍与之相关的内容。

1.1 CPU 频率及晶体管数目

看到这个小标题,相信大家脑海里一定浮现了著名的摩尔定律:当价格不变时,集成电路上可容纳的晶体管数目,约每隔 18 个月增加一倍,性能也将提升一倍。在芯片产业发展前期,摩尔定律准确预测了集成电路的发展趋势。芯片所容纳的晶体管数目越来越多;芯片的频率也越来越快。这些都直接促成了计算机更快地工作。

我们知道,CPU 是一条指令、一条指令地执行的,执行不同的指令需要的时间不尽相同,一般以时钟周期为单位,而时钟周期就是频率的倒数。因此,CPU 频率的提高意味着单位时间内计算机执行的指令数目变多,对于同一个指令序列,执行所需时间就减少了,因此说,CPU 频率的提高让计算机工作地更快。

晶体管数目的增加意味着 CPU 可以完成更加复杂的功能;可以利用这些晶体管构建电路,使 CPU 工作地更快。如下文将提到的 Cache、流水线技术等,它们都直接依赖于芯片所能容纳的晶体管数目增加。

1.2 多核处理器

自 1971 年 Intel 发布第一款微处理器 Intel 4004 直到 20 世纪末,推动 CPU 性能进步的因素都是处理器频率的增加及晶体管数目的增加。但是,处理器的功耗与频率的三次方成正比,这意味着继续增加处理器的频率,CPU 的散热将成为一个重大问题:温度的升高将影响处理器工作的稳定性。为了使摩尔定律继续成立,人们进行了新的探索:多核处理器。

2000 年,IBM 发布了第一款多核处理器 POWER 4 。此后,AMD、Intel 等厂商也陆续推出了多核处理器,推动了摩尔定理继续发挥作用。

POWER 4 处理器图片:

如今,我们已对多核处理器习以为常,个人笔记本处理器已经达到 4-8 核心;服务器 CPU 核心数则更多。为了适应多核心处理器提供的并行处理能力,多线程编程技术也成为了程序员必备技能之一。

1.3 流水线技术

流水线技术也是现代处理器的典型特征之一。其核心思想是将操作分解为若干个步骤,每个步骤用单独的硬件部件完成对应的功能,将这些部件首尾连接起来,并不断输入任务,从而提高处理器的运行速度。这与工厂生产中的流水线其实是一致的:通过部件功能专一化、部件之间的组合连接,完成原本复杂的功能,同时提高生产效率。

以经典的 RISC 五级流水线为例,其指令执行分为:取指( IF )、译码( ID )、执行( EX )、访存( MEM )、写回( WB )五个处理阶段,每个阶段都由专门的硬件完成。这样,当不断有指令输入时,指令执行情况如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MUtHmnId-1630673303581)(https://github.com/UnpureRationalist/Pictures/blob/main/pipline.png?raw=true)]

可以看出,从第 5 个时钟周期开始(假设每个步骤处理时间都为 1 个时钟周期),之后每个周期都会执行完一条指令。与串行执行的每 5 个周期执行完一条指令相比,速度有了明显的提高。

不过,在具体实现中,流水线技术没那么简单,而是会遇到数据冲突、资源冲突、控制冲突等问题。相应的解决方法有:定向技术、编译优化、分支预测等。这些内容不是一两句话就能说明白的,在此不对其进行介绍,感兴趣的读者可自行查阅相关资料。

1.4 向量处理器

在平时写程序时,我们会用到大量对数组的操作,例如:

constexpr int n = 640;
int a[n], b[n], c[n];
...		// 对数组 a、b 初始化
for(int i = 0; i < n; ++i)
{
    c[i] = a[i] * b[i];
}

这段程序完成了数组对应项相乘并保存到另一个数组的操作。对于标量处理器而言,翻译成汇编指令之后,每条指令只对一个操作数进行计算:

# 假设 a[i]、b[i]已经取到寄存器 R1、R2 中,乘法计算结果保存到 R3 寄存器中
mult R3, R1, R2

对于标量处理机,需要 640 条乘法指令。而对于向量处理器(设向量寄存器长度为 64)来说,一条汇编指令能够同时完成 64 个元素的计算,这样只需要 10 条乘法指令,一条汇编指令同时完成 64 个元素的乘法运算。显然,这需要处理器内部设置 64 套并行的乘法计算部件(ALU)。因此,向量处理器核心思想就是设置多套处理部件,达到单位时间内并行处理多个元素的效果,从而使计算机运行更快。

向量处理器通常结合流水线技术、向量链接技术等,从而进一步加快运算速度。

现代处理器中通常设置了向量指令,用于加速向量计算。AI 技术的兴起使向量处理器的意义更加明显:AI 模型的训练涉及大量的向量、矩阵运算,利用处理器的向量指令功能,能够加快模型的训练速度。

1.5 FPGA 与 ASIC

严格来说,FPGA(Field Programmable Gate Array,现场可编程门阵列)与 ASIC(Application Specific Integrated Circuit,专用集成电路)不属于 CPU 范畴。但是,它们都是为了满足计算的需求,因此一并介绍。

FPGA 是可编程的硬件电路,其最大特点就是可以利用硬件描述语言对 FPGA 进行编程,使其满足特定的应用场景。ASIC 是指依产品需求不同而全定制的特殊规格集成电路。二者区别联系如下:

  • FPGA 可编程; ASIC 不可编程
  • FPGA 通常用于产品的设计验证阶段;在设计验证完成后,再进行速度、功耗等方面的优化,大量生产的固化的产品即为 ASIC
  • FPGA 成本较低;ASIC 由于要经过设计、测试、量产等阶段,成本高,通常只应用于某些需求量大的场景

通过上面的介绍,可以得出明显的结论:由于 FPGA 与 ASIC 是面向特定功能定制化的,因此其在速度、功耗、可靠性等方面均优于 CPU。当然,它们在通用性方面显然不如 CPU。实际上,FPGA 与 ASIC 距离我们并不遥远:在学习数字逻辑电路、计算机组成原理等课程时,通常会使用硬件描述语言(如 Verilog)在 FPGA 芯片上进行电路设计;而 ASIC 芯片广泛应用于人工智能设备、虚拟货币挖矿设备、军事国防设备等智慧终端。

1.6 未来:量子计算

作为关心技术最新发展趋势的程序员,这里必须提一下量子计算。虽然对它的了解仅限于听说过,但是通过相关新闻能够明显地感受到量子计算所带来的革命:Google 首次实现的量子优越性;去年我国科学家潘建伟及其团队制造了量子计算机九章;不久前 Google 创造的时间晶体……

谷歌量子计算机的处理器:

在这里插入图片描述

虽然通用量子计算机的制造和普及还需时日,但是量子计算带来的计算革命,带来的计算速度的提升已经展示在我们面前。未来如何,让我们拭目以待。


2. Cache

在现代处理器中,Cache 实际上已经作为处理器的一部分集成在 CPU 中,但是,Cache 是如此重要,因此有必要单独开一小节进行介绍。

2.1 存储层次与局部性原理

在介绍 Cache 之前,我们先来了解一下计算机存储层次和程序的局部性原理,这能帮助我们理解为什么需要 Cache 以及为什么 Cache 能显著提高程序运行速度。

2.1.1 存储层次

对于存储,一般而言我们有以下需求:

  • 大容量
  • 高速存取
  • 价格便宜

不幸的是,目前的存储技术不能同时满足上述需求:速度快的存储器往往价格高容量小,最典型的就是寄存器;速度慢的存储器则价格便宜、存取速度慢,如磁盘。近几十年来 CPU 频率迅速加快,而存储相关技术则进步缓慢,导致 CPU 与 存储器之间速度差距越来越大。为了在成本、存储容量、速度等因素之间取得平衡,于是出现了存储层次:寄存器、Cache、主存、辅存:

存储层次看起来的确解决了成本和存储容量的问题,但是它究竟是如何解决速度问题的呢?这就要回到程序的局部性原理了。

2.1.2 局部性原理

程序的局部性原理指:在某一段时间内,程序执行的指令仅限于程序中的某一部分。相应地,程序所访问的存储空间也局限于某个内存区域。具体来说,分为:

  • 时间局部性 :程序在运行时,最近刚刚被引用过的一个内存位置容易再次被引用
  • 空间局部性 :最近引用过的内存位置以及其周边的内存位置容易再次被使用

至此,可以解释为什么存储层次能够加快计算机运行速度了:只要把下一层次中访问过的数据及其附近的数据拷贝到上一存储层次中,接下来再次访问数据时,就能够直接从上一级存储层次取得数据,而不必到速度更慢的下一层次存储器中取数据,从而减少访问存储器的平均时间,加快程序运行速度。

实际上,存储层次就是这么工作的:机器启动时,将操作系统等代码、数据从磁盘拷贝到内存;对于最近访问的代码数据,将它们从主存拷贝到 Cache;寄存器中则保存着当前运行的程序所需的代码和数据。

2.2 深入了解 Cache

经过上面的介绍,已经解决了为什么要引入 Cache 以及 Cache 为什么能够加快程序运行速度的问题,下面介绍一些更深入的内容。

首先需要明确的是:

  1. Cache 容量远远小于主存容量,那么应该把主存中的数据存储到 Cache 哪个位置呢?当 Cache 容量已满时,应该覆盖 Cache 中哪些旧数据呢?这两个问题对应着 Cache 的映像方式和替换策略。
  2. 进行映像和替换操作的基本单位是 “块” 或者 “行”,Cache 和 主存都以块为单位划分,并进行编号。

2.2.1 Cache 映像方式

Cache 映像方式有三种,下面简单介绍它们的核心思想:

  1. 直接映像:每个主存地址映像到 Cache 中的一个固定地址

  1. 全相联映像:任何主存块可映像到任意一个 Cache 块

  1. 组相联映像:将主存块和 Cache 块按固定大小分组,各组之间采用直接映像,组内各块之间采用全相联映像

显然,第三种映像方式是前两种映像方式的中和,其综合性能最好。

Cache 映像方式还涉及到如何根据主存地址计算出对应 Cache 地址、主存地址和 Cache 地址结构是怎样的、硬件层面的地址变换机构是如何工作的等内容,这里就不详细介绍了。

2.2.2 Cache 替换策略

Cache 虽然可以通过拷贝主存块到 Cache 中减少平均访存时间,提高程序运行速度,但是,Cache 容量非常有限,当访问新的主存块时,如果此时 Cache 容量已用完,该怎么办呢?直接的想法是:挑一个 Cache 块,让它来存储新的主存块内容就行了。我们可以按照 FIFO 策略挑选一个块替换;甚至可以随机挑选一个块替换;不过,目前,最常用的 Cache 替换策略是 LRU 算法,其核心思想是:选择近期最少被访问的块作为被替换的块。

由程序的局部性原理可以推断:如果最近刚用过的块很可能就是马上要再用到的块,则最久没用过的块就是最佳的被替换者。因此,LRU 算法很好地反映了局部性原理,其失效率较低,性能最佳。

2.2.3 实例:Cache 对程序运行速度的影响

为了直观感受一下 Cache 对程序运行速度的影响,我们来举一个简单的例子:矩阵乘法。

大家对矩阵乘法都非常熟悉了,这里就直接上代码:

matrix1.cpp

#include <iostream>
#include <random>
using namespace std;

constexpr int n = 1024;
double a[n][n], b[n][n], c[n][n];

void init()
{
    const double MAX = RAND_MAX;
    for (int i = 0; i < n; ++i)
    {
        for (int j = 0; j < n; ++j)
        {
            a[i][j] = rand() / MAX;
            b[i][j] = rand() / MAX;
        }
    }
}

void multiply()
{
    for (int i = 0; i < n; ++i)
    {
        for (int j = 0; j < n; ++j)
        {
            for (int k = 0; k < n; ++k)
            {
                c[i][j] += a[i][k] * b[k][j];
            }
        }
    }
}

int main()
{
    init();
    multiply();
    return 0;
}

matrix2.cpp

// 头文件、变量声明、init 函数、main 函数略,与上面代码完全相同

void multiply()
{
    for (int i = 0; i < n; ++i)
    {
        for (int k = 0; k < n; ++k)
        {
            for (int j = 0; j < n; ++j)
            {
                c[i][j] += a[i][k] * b[k][j];
            }
        }
    }
}

可以看到,上面的代码只有内外层循环的顺序有区别:一个是 i,j,k;另一个是i,k,j。它们的计算结果是完全相同的,仔细考虑一下就能看出,不必质疑代码的正确性。

从算法复杂度角度分析,它们没有任何区别,但是,它们真正的运行时间却是这样的:

可见,第一段代码的运行时间约为第二段代码运行时间 3~4 倍,这是很大的性能差距。

相信经过上面的介绍,大家都知道原因:C++ 语言中数组是按照行优先存储的,当进行矩阵乘法的核心运算时:

c[i][j] += a[i][k] * b[k][j];

需要访问 a、b、c 三个数组的不同元素。当按照 i、j、k 的嵌套循环顺序时,最内层循环 k 每自增 1,就需要跨越 1024*8 = 8192 字节来访问数组 b 的元素,这显然不符合局部性原理,也就不能发挥 Cache 性能。示意图(说明:下图的 4096 elements apart 与上面例子不符,仅为说明原理)如下:

而按照 i、k、j 的嵌套循环顺序时,按照与上面相同的分析方法,示意图如下:

显然,这种嵌套循环顺序对 Cache 更友好,因为其更符合局部性原理,所以其运算速度显著快于第一种。

由上面例子可以看出,写代码时充分地利用 Cache 机制,能够明显提升程序运行速度。

2.3 无处不在的缓存思想

Cache 通常翻译为 ”缓存“,缓存是计算机世界中应用广泛的思想,解决了很多问题。

随便举几个例子,如:浏览器缓存、APP 缓存、Web 代理缓存、边缘缓存(典型的是:CDN)、数据库级缓存(在内存保存最近查询的结果)、应用级缓存(如:Redis)等。这些都是我们平时开发中能够接触到的缓存例子。此外,很多硬件内部也有缓存部件,如:U 盘、光驱、磁盘等。缓存思想的应用很大程度上提升了计算机世界的工作速度。


3. 操作系统

操作系统作为计算机运行时真正的 “老大哥”,掌管着计算机的一切硬件资源,完成进程调度、内存管理、输入输出、文件系统等工作。

作为最重要的系统软件,操作系统一切工作的目的可以简单归纳为:公平和效率。例如:进程调度时如何保证每个进程既能分配到处理器时间,又能减少进程平均等待时间、周转时间;如何调度磁盘请求序列,使得每个请求都能被响应且减少平均寻道时间;如何管理内存,使每个进程都能获得申请的内存空间,且减少操作系统分配、回收内存占用的处理器时间……

本节就从效率角度,介绍操作系统做了哪些工作,使计算机运行地更快。

3.1 CPU 与 IO 并行化

操作系统的核心功能之一就是进程调度。进程概念的引入有很多原因,如支持多用户、多任务等,但还有一个重要的原因就是提高 CPU 使用率(即 CPU 与 IO 并行化)。

在过去的单任务系统中,当正在运行的程序需要进行 I/O 操作时,CPU 必须停下来等待程序完成 I/O 操作,然后继续执行指令序列。这就导致了 CPU 在大量时间是空闲的,CPU 与 I/O 设备串行工作。

随着操作系统中引入进程,以及进程就绪、运行、阻塞等状态的引入,CPU 与 I/O 设备能够并行化工作:当进程需要执行 I/O 操作时,进程进入阻塞状态,执行 I/O 操作;此时操作系统执行进程调度,CPU 转去执行其它进程的指令序列;待原进程的 I/O 操作执行完毕后,进程进入就绪状态,等待接下来占用 CPU 时间片继续执行指令序列。这样,CPU 与 I/O 设备就实现了并行化工作,提高了系统整体的速度和吞吐量。

3.2 磁盘调度算法

对于传统的磁盘来说,其访问时间包括:寻道时间、旋转延迟时间、传输时间。其中,寻道时间是访问磁盘的主要开销。磁盘结构示意图如下:

计算机运行时,来自不同进程的磁盘 I/O 请求构成一个随机分布的请求队列。磁盘调度算法的主要目标就是减少请求队列对应的寻道时间。

常见的磁盘调度算法有以下几种:

  1. FCFS:磁盘 I/O 执行顺序为磁盘 I/O 请求的先后顺序
  2. 最短寻道时间优先 SSTF (Short Seek Time First):考虑磁盘 I/O 请求队列中各请求的磁头定位位置,选择从当前磁头位置出发,移动最少的磁盘 I/O 请求。
  3. 扫描 (SCAN) 算法:也叫电梯调度算法。选择在磁头前进方向上从当前位置移动最少的磁盘 I/O 请求执行,没有前进方向上的请求时才改变方向。
  4. 循环扫描算法 CSCAN (Circular SCAN):在一个方向上使用扫描算法,当到达最外的磁道并访问后,磁头立即返回到最里的欲访问的磁道,即将最小磁道号紧接着最大磁道号构成循环。
  5. ……

上述算法都有自己的优点和适用场景。在 Linux 系统中,提供了不同的磁盘调度算法,用户可根据使用场景选择最合适的磁盘调度算法,从而提高系统 I/O 性能。

3.3 缓冲区

先来点八股文:缓冲区是什么?干什么用的?为什么要引入缓冲区?

缓冲区是用来保存在两个设备之间或在设备和应用程序之间所传输数据的内存区域。

引入缓冲区的原因有:

  1. 缓和 CPU 和 I/O 设备间速度不匹配的矛盾
  2. 协调传输数据大小不一致的设备
  3. 减少对 CPU 的中断频率
  4. 提高 CPU 和 I/O 设备之间的并行性

操作系统管理着大量的缓冲区,例如:读写文件时与磁盘交换的缓冲区、进行网络通信时的缓冲区。上文所说的引入缓存区原因的 1、3、4点其实可以归结为:让计算机更快。

结合代码来看一下缓冲区的引入究竟带来多大的速度提升:

cout1.cpp

#include <iostream>
using namespace std;
int main()
{
    int n = 1000000;
    for(int i = 0; i < n; ++i)
    {
        cout << i << endl;
    }
    return 0;
}

cout2.cpp

#include <iostream>
using namespace std;
int main()
{
    int n = 1000000;
    for(int i = 0; i < n; ++i)
    {
        cout << i << "\n";
    }
    return 0;
}

看起来只有 “\n” 与 “endl” 的区别,但是它们的运行速度却有十几倍的差别:

而背后的原因其实很简单,就是缓冲区的原因: endl 强制刷新输出缓冲区,相比 \n ,使用 endl 达到换行的目的需要进行更多次的写磁盘操作,所以 endl\n 慢很多。


对于大多数程序员来说,以上内容都是我们无法控制的,因为我们大都是面向一定的硬件及操作系统进行开发。底层的硬件及系统软件的东西我们无法控制,但是软件层面,我们还是可以掌控于键盘之上的。


4. 编程语言

编程语言是程序员进行开发的最基本的工具,因此我们先来看一下编程语言层面都有哪些机制让计算机运行地更快。

4.1 编程语言的分类

首先来看大家非常熟悉的一个概念:编程语言的分类(按照执行前是否需要编译的标准)。

  1. 编译型:即执行前需要编译的语言,如 C/C++
  2. 解释型:不需要编译即可执行于解释器的语言,如 Python
  3. 半编译半解释型:这是上面二者的中庸之道,如 Java,先编译为字节码,然后在 JVM 解释执行

就执行速度而言,编译型语言天生优于解释型语言,因为编译生成的机器代码即可直接执行;而解释型语言在解释程序或者虚拟机上解释执行,在性能上当然有所损耗。

不过,对于 Java 这个半编译半解释的语言,它又一次采取了中庸之道:JIT(just-in-time compilation) 机制。

4.2 Java JIT 机制

为了跨平台,Java 选择将源程序编译成统一的字节码,然后通过 JVM 在不同平台解释执行;为了更快的运行速度,JVM 又引入了 JIT 机制:当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为 “Hot Spot Code”(热点代码),为了提高热点代码的执行效率,在运行时,JIT 编译器将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化。接下来再次运行到这个方法或代码块时,就会直接执行编译生成的机器码,从而节省了解释执行的时间。

JIT 机制引入的初衷虽然是为了 Java 程序更快地执行,但是这并不意味着所有场景下 JIT 机制都能够加快程序的运行。甚至由于 JIT 机制本身统计方法、代码块的调用次数等开销,会让程序运行地更慢。

4.3 编译优化

对于编译型语言而言,一般而言编译器为我们提供了不同的编译优化选项,从而生成运行速度更快、占用空间更小的二进制代码。

还以前面提到的矩阵乘法为例,对于 matrix2.cpp 源文件,采用 g++ 编译器开启 O2 编译优化,程序运行结果如下:

嗯,一切与预期相同,运行速度有非常明显的提升。然而,编译优化有时并不总和预期一样。对于 matrix1.cpp 源文件,开启编译优化后运行耗时反而增加了:

所以,在实际使用时,往往要根据性能测试结果,选择最佳的编译优化选项。

常见的编译优化技术有:常量折叠、常量传播、公共子表达式消除、指令选择等。

编译优化面临着很多挑战,包括:程序本身的复杂型、如何保证优化后的代码在逻辑上等价于优化前的代码、如何在众多的机器指令中选择最佳指令等,这些都使得编译优化技术成为计算机科学中非常有挑战性的领域之一。


5. 挖掘硬件性能

5.1 回到硬件

本文第一小节中介绍了能够使计算机运行更快的硬件基础,如多核处理器、向量处理器、流水线技术等。但是,这些硬件对程序运行的直接加速比较有限,因此需要程序员在软件层次对硬件加以利用,尽可能挖掘硬件性能。

本小节就选几个点,介绍如何编写充分挖掘硬件性能的代码。

5.2 如何充分利用硬件

5.2.1 多线程编程

多线程编程技术主要是为了充分利用多核处理器,发挥 CPU 多核心并行计算的能力。

在我们平时编程中,多线程很常用,尤其是服务器端,利用多线程技术提高程序应对高并发请求的能力和响应速度已经是标准技术手段。多线程技术的优点在于充分利用了多核处理器的能力,提高程序运行速度;此外,多线程天然适合做任务分离,一个线程(线程池)处理一类任务,如数据库中线程分类处理网络请求、查询任务、写入任务等。

当然,多线程也带来了资源的同步与互斥问题,在多线程并发访问场景下,如何保证数据的正确性、一致性成为需要解决的问题。多线程技术在高性能计算场景中也常被使用,如 OpenMP。

5.2.2 字节对齐

字节对齐是我们很熟悉的一个概念,它的背后原因是为了让 CPU 更高效地访问存储器。

在学习 C/C++ 语言的结构体时,一个必不可少的知识点就是计算结构体占用的字节数,举个例子:

#include <iostream>
using namespace std;

struct Test1
{
    char c;
    double d;
    int i;
};

struct Test2
{
    char c;
    int i;
    double d;
};

int main()
{
    cout << sizeof(Test1) << endl;
    cout << sizeof(Test2) << endl;
    return 0;
}

在我的本地测试环境中(64 位系统),程序输出结果为:24 和 16。这里就不解释为什么是这个输出结果了,而是解决本文关注的问题:字节对齐究竟如何让计算机运行更快的呢?故事还要从 CPU 访存指令说起。

一般而言,CPU 提供了下列访存指令(注意:以下指令只是为了举例,并非来自某款 CPU 的汇编指令集)并限制了存储器地址需要满足的条件:

lb  R0, address			# 从内存取一个字节,内存地址 address 可为任意地址
lhw R0, address			# 从内存取两个字节,内存地址必须满足: 0b......0,即 address % 2 == 0
lw  R0, address			# 从内存取四个字节,内存地址必须满足: 0b.....00,即 address % 4 == 0

那么,对于下面的结构体:

struct Test
{
  	char c;
    int a;
};

如果不采用字节对齐存储,它的内存布局是这样的:

char cint iint iint iint i
0X000X010X020X030X04

那么当需要访问 int i 时,为了满足 CPU 访存时内存地址限制条件,需要执行 3 条汇编指令:

lb ..., 0X01
lhw ...,0X02
lb ..., 0X04

同时还需要移位操作、位运算等,将真正的 int i 值放到一个寄存器内。

而如果采用字节对齐方式存储,则内存布局是这样的:

char c填充填充填充int iint iint iint i
0X000X010X020X030X040X050X060X07

那么,访问 int i 时,只需要一条汇编指令:

lw R0, 0X04

可想而知,字节对齐将带来访存效率的具体提升,从而让程序执行速度更快。

5.2.3 考虑代码对流水线影响

现代处理器一般都引入了流水线技术,在编写代码时我们也要考虑代码对流水线的影响。

前文提到,流水线会遇到数据冲突、资源冲突、控制冲突等问题,其中,对流水线性能影响最大的就是控制冲突。

控制冲突就是流水线遇到分支指令和其他会改变 PC 值的指令所引起的冲突,举一个简单的例子:

	...略...
	beq R0, R1, label
	add ...
	...略...
label:
	...略...

上述指令意思是:若寄存器 R0 的值等于寄存器 R1 的值,则跳转到 label 处继续执行;否则执行下一条 add 指令。

流水线正常工作的时候,会加载 beq 和它后面的指令,但是,如果比较完成后发现 R0 值与 R1 值相等,那么就应该跳转到 label 处执行,这意味着此时流水线中已经流入的 add 等指令就白白执行了,造成流水线断流,浪费了流水线资源。

别担心,好消息是:现代 CPU 内部含有分支预测机制,有能力预测指令究竟是按顺序执行还是跳转到其它地方执行。分支预测机制的工作原理通常是记录前几次该分支处跳转地址,并预测下一次还跳转到该地址。为了利用分支预测机制,我们在写代码时也要注意。

那么,分支预测机制对程序执行速度究竟有多大影响呢?

这里拿 StackOverflow 上的一个例子说明:

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;
    for (unsigned i = 0; i < 100000; ++i)
    {
        for (unsigned c = 0; c < arraySize; ++c)
        {   // Primary loop
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock()-start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << '\n';
    std::cout << "sum = " << sum << '\n';
}
  • Without std::sort(data, data + arraySize);, the code runs in 11.54 seconds.
  • With the sorted data, the code runs in 1.93 seconds.

可见,程序运行速度有近 6 倍的差距。

因此在写代码时,我们应该充分考虑 if 语句、循环语句等会造成跳转的语句对流水线的影响,写出能充分利用 CPU 分支预测机制的代码,做到:

  • 不要在循环中嵌套较多的条件分支
  • 合并分支条件
  • 概率大的条件分支放前面
  • 尽量使用排过序的数据
  • ……

此外,一些编译器提供了宏命令以让程序员决定哪个分支执行的概率更高,如 GCC 的 likely 和 unlikely,不过绝大多数情况下不需要程序员干预分支预测。

5.3.4 向量指令

向量指令是为了充分利用现代 CPU 提供的向量处理部件。

对 C/C++ 程序而言,能够通过编译器提供的宏、直接嵌套向量汇编指令等手段,达到充分利用 CPU 提供的向量处理部件的目的。本人对这方面了解很浅显,这里就不介绍了,感兴趣的读者可自行查询相关资料。


6. 数据结构与算法

最后,我们来简单介绍程序员必备技能:数据结构与算法。

不管你对其是喜爱还是害怕,数据结构与算法是每一名程序员成长之路上必须跨过的门槛。自从高德纳老爷子的巨著《计算机程序设计艺术》(The Art of Computer Programming, 简称 TAOCP)问世以来,数据结构与算法就逐渐成了每一位计算机学子的必修课。这里必须放上高德纳老爷子的照片和他的著作:

数据结构是为算法服务的,算法就是解决某一问题的一系列明确的步骤。

那些有些淡忘的概念也许已经浮现在你的脑海:“数据结构分为物理结构和逻辑结构,物理结构只有连续存储和链式存储两种,逻辑结构则分为集合结构、线性结构……常见的数据结构有数组、栈、队列、链表、二叉树……“;算法的五个特征为:输入、输出、有穷性、确定性、可行性;常见算法思想有:分治、贪心、动态规划、回溯……“

当看到简单的栈结构能够解决括号匹配、表达式求值等问题,且在背后支撑了函数调用这个最基本的程序运行机制时;当看到排序算法从朴素算法的 O(n^2) 复杂度优化到高级算法的 O(nlogn) 复杂度,再到特殊场景下可以实现线性复杂度时;当看到看似很难解决的一些问题利用动态规划、剪枝等算法技巧能够在可接受时间内解决时……你一定感受到了数据结构与算法的魅力。

数据结构与算法是软件系统的灵魂,从操作系统到编译程序、数据库系统,再到普通的应用程序,大量的数据结构与算法发挥着作用,支撑计算机系统高效稳定地运行。它们是计算机世界背后的功臣,也是程序员智慧的显现。


7. 总结

本文从 CPU 等硬件机制,到操作系统的系统软件层次,再到编程语言、编程技术的应用层次,介绍了计算机系统中 ”为了更快“ 的相关技术。当围绕着一个特定主题审视曾经学过的知识时,或许你会有不一样的体会。感谢你花费宝贵时间阅读本文,希望对你有些帮助。

最后,放上《计算机组成与设计:硬件/软件接口》一书中计算机系统结构中的 8 个伟大思想:

  1. 面向摩尔定律的设计
  2. 使用抽象简化设计
  3. 加速大概率事件
  4. 通过并行提高性能
  5. 通过流水线提高性能
  6. 通过预测提高性能
  7. 存储器层次
  8. 通过冗余提高可靠性

这 8 个思想的第 1 点强调了 CPU 本身发展趋势; 3-6 点都可以归结为:为了更快 ,本文很多内容都是它们的体现;7 是综合考虑成本与性能;8 则是为了系统的可靠性;第 2 点则是一个也非常有趣的话题:抽象 。计算机世界中从硬件到软件也存在着大量的抽象思想,这一主题,我们之后有机会再谈。

本文来源:

最近在学习 Elasticsearch,最开始入门时看着官方文档,其中有一句话是:你知道的, 为了搜索 。在之后的学习过程中,对这句话的理解逐渐由朦胧变得深刻。当然,对 Elasticsearch 的了解还仅仅是皮毛中的皮毛,后续仍需不断深入学习。但是这句话却将过去三年学习的知识和脑海中冒出的一些灵感聚集在一起,于是构思之下,有了本文:你知道的,为了更快。

限于学识和篇幅,本文内容基本上是概念性的介绍,可能有错误之处,希望各位读者一起交流学习,不吝赐教。

参考资料:

  1. 摩尔定律
  2. 晶体管数目与CPU运算速度关系
  3. 多核处理器
  4. 向量处理器
  5. Intel AVX-512
  6. FPGA
  7. ASIC
  8. 程序局部性原理介绍
  9. Introduction & Matrix Multiplication
  10. 缓存原理和设计
  11. 了解 Linux I/O 调度算法
  12. C++中输出流的刷新问题和 endl和 \n的区别
  13. 基本功 | Java即时编译器原理解析及实践
  14. Javac编译与JIT编译
  15. C++编译器中的优化
  16. Why is processing a sorted array faster than processing an unsorted array?
  17. 如何在 C++ 代码中提示编译器某个分支的执行概率高?
  18. 深入理解字节对齐
  19. C/C++指令集介绍以及优化(主要针对SSE优化)
  20. 算法和数据结构的先驱者 Donald E.Knuth
  21. 《计算机组成与设计:硬件/软件接口(原书第5版)》
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值