并行编译技术_理解现代处理器:指令级并行篇

0 前言

我最近在看Hennessy和Patterson写的体系结构的书: Computer Architecture: A Quantitative Approach。这本书写得很棒,让我理解了很多现代处理器的行为。

在看指令级并行 (Instruction-Level Parallelism) 这一部分内容的时候, 我发现,从“数据流动态分析”的角度来理解指令级并行技术会更加直观 (有编译背景的读者可能想到“数据流静态分析”)。 我相信深入学习过体系结构的人应该也会想到过从这个角度理解指令级并行,或者有更加深入的视角。不过由于我目前没看到比较好的文章对此做阐释,所以决定写一篇文章来跟大家分享一下。

我的目标是努力让有计算机背景 (最好知道汇编) 的读者能在阅读本文后能对现代处理器的运行有基本概念。当然,有体系结构基础 (比如知道处理器流水线) 的读者应该更容易读懂本文。而对于那些将要深入学习体系结构的读者,我希望本文的视角能让他们未来学习相关技术的时候事半功倍。

本文的结构如下:

  • 第1节为讲解了处理器的基本概念,为下文做个铺垫。
  • 第2节从“数据流动态分析”的角度出发, 描述了一个理想的“分析-执行”两阶段处理器模型。 这个模型是以易于理解为目的构建的,因此我希望大部分读者能借此获得现代处理器行为的基本概念。
  • 第3节分析了当前一些指令级并行技术可以如何“套用”到第2节描述的处理器模型来理解。 这一节主要面向有体系结构基础的读者。

最后,我要感谢一下为我上一篇文章点赞、感谢、收藏、关注、评论的朋友们。你们的认可是我写文章的动力。

1 基本概念

一个人如果第一次接触处理器的话题,那他的第一个问题可能是:“处理器是做什么的?”。

要回答这个问题,首先要知道,每个处理器都会为用户提供一份它的“服务清单” (专业术语就是ISA), 清单上有两部分内容最为重要:

  • 处理器中含有的寄存器。指令一般会从寄存器上读取数据,然后把计算结果保存在寄存器上。
  • 处理器可以执行的每一条指令以及执行这个指令的效果。其中,最主要的指令有:
    • load指令:把内存中的数据加载到某个寄存器上。
    • store指令:把某个寄存器的数据保存到内存上。
    • 算数指令:对两个寄存器进行加减乘除取模运算,并把结果保存到某个寄存器上。
    • (条件/无条件) 跳转指令:正常来说处理器是按顺序执行每一条指令的,但跳转指令会让处理器转去执行其它位置的指令。

然后,用户既可以自己按照这个清单编写一个指令序列 (即汇编程序) 交给处理器执行,也可以用高级语言编写程序,再让编译器根据这个清单把程序转化为处理器可以执行的指令序列。

最后,处理器必须按照“服务清单”的约定执行指令序列,产生相应的效果。

下面来举个例子。这里有一份简单的C代码,它的功能是为数组中每一个元素都乘上x:

double da[2] = {0.0, 0.0};
double x = 2.0;
for (int i = 0; i < 2; i++)
    da[i] *= x;

那么,编译器可以将它转化为以下由某个“服务清单”描述的指令序列:

# 初始值: x1=&da[0], x2=&da[1], f0=x
loop:
fld    f1, 0(x1)    # f1 = *x1
fmul.d f2, f1, f0   # f2 = f1 * f0
fsd    f2, 0(x1)    # *x1 = f2
addi   x1, x1, 8    # x1 = x1 + 8
bne    x1, x2, loop # if (x1 != x2) goto loop

这里面用到了5个寄存器x1, x2, f0, f1, f2。其中:

  • x1初始值为数组da的首地址,即&da[0]
  • x2初始值为数组da的尾地址,即&da[1]
  • f0初始值为变量x的值。

这里面用到了5个指令,注释也用C的语法标注了每条指令的功能,其中:

  • fldfsd分别是浮点数load与store指令。
  • fmul.d是浮点数乘法指令,addi是整数加法指令。
  • bne是条件跳转指令。

如果把这份指令序列交给处理器,处理器就会表现得像是在一行一行指行每条指令一样,最终得到C代码期望的结果。

(这部分要想讲到没有ISA基础的读者也能看懂实在太耗篇幅了,所以姑且就讲那么多。如果读者有需要,我后续再扩充)

接下来,问题开始出现了。处理器如果只是单纯地一条一条执行指令,那么其实并不难实现。比如,经典的处理器执行过程差不多分为四步:取指令,解析指令操作数,执行计算,把计算结果写入内存和寄存器。但是,用户的欲望是不会满足的——他们希望处理器能在单位时间里执行更多的指令。这就给处理器带来难题了:如何在满足“服务清单”(即ISA) 中的要求的同时提高执行速度呢?

一个基本的insight是:一份指令序列 (汇编程序) 中有很多指令是互不依赖的,所以我们可以让这部分指令并行执行。比如,在前面的C代码中,处理器完全可以并行执行 da[0] *= xda[1] *= x

但对应到汇编代码中,可以并行执行的部分看起来并不明显,所以我们需要做一些额外的分析。首先,我们假设我们知道跳转指令的执行结果,故把循环展开,得到:

fld    f1, 0(x1)
fmul.d f2, f1, f0
fsd    f2, 0(x1)
fld    f1, 8(x1)
fmul.d f2, f1, f0
fsd    f2, 8(x1)

可以看到,前三行指令对应的C代码是da[0] *= x,后三行指令对应的C代码是da[1] *= x。 但我们发现,前三行指令和第三行指令不能并行地执行。这是因为,第一行和第四行的两条fld指令都会把内存数据加载到f1寄存器上,这可能会导致这样一种情况:如果第四行fld指令先执行完,然后第一行的fld指令在第五行fmul.d指令还没开始时执行完成,那么寄存器f1上的结果就会被覆盖,接下里会执行的第五行fmul.d指令就永远无法获得操作数f1的正确的值。 所以,如果想要获得正确的执行结果,处理器要么先执行前三条指令,要么先执行后三条指令。

但其实,第一行和第四行的fld完全没必要把结果都加载到同一个寄存器上。所以我们可以对汇编代码稍作修改,得到下面这个等价的指令序列:

fld    f1, 0(x1)
fmul.d f2, f1, f0
fsd    f2, 0(x1)
fld    f3, 8(x1)
fmul.d f4, f3, f0
fsd    f4, 8(x1)

这时我们就可以看到,前三条指令和后三条指令是可以同时执行的。

那如何利用这种潜在的并行机会呢? 此时应该有人会想到用多核处理器来并行执行这部分指令。这确实是一种流行的提高执行速度的并行技术,叫Thread-Level Parallelism。但其实单处理器就可以直接利用这种潜在的并行。这样的技术叫做指令级并行(Instruction-Level Parallelism), 这也是本文讨论的话题。

指令级并行技术虽然只用单个处理器来解析指令,但会设计多个计算单元,比如执行load指令和store指令的内存访问单元、执行整数运算的算术逻辑单元、执行浮点运算的浮点计算单元。处理器也会用流水线技术让用户觉得这些计算单元有重复的好几个可以同时使用。 这样,当处理器在解析指令找到可以并行的部分后,就能把它们发送到并存的计算单元上同时执行了。比如,对于前面的汇编代码,处理器会给你一种它有两个内存访问单元的感觉,然后把“fld f1, 0(x1)”和“fld f3, 8(x1)”分配到这两个内存访问单元上并行执行。

指令级并行技术具体又是怎么找到并行机会的呢?请看下节分解。

2 “分析-执行”两阶段处理器模型

通过跟随我们在上面展开汇编代码分析指令间并行性的思路,读者现在应该对处理器有了点感觉:处理器应该会通过某种方法分析指令的并行性,然后把指令发送到多个可用的计算单元上并发的执行。我们正是从这个感觉出发,构建了一个“分析-执行”两阶段处理器模型,简称两阶段处理器

顾名思义,“分析-执行”两阶段处理器的执行过程肯定分为“分析”和“执行”两个阶段。 我们接下来会结合一个例子来看看这个两阶段处理器的基本执行过程。这个例子依旧采用前面用过的汇编代码:

loop:
fld    f1, 0(x1)
fmul.d f2, f1, f0
fsd    f2, 0(x1)
addi   x1, x1, 8
bne    x1, x2, loop

需要注意的是,两阶段处理器是一个描述处理器执行过程的理想模型,所以其中不乏一些理想的假设,比如“硬件资源无限”。但很快,我们会在2.3小节展示如何简单地修改这个模型来去掉理想的假设,得到一个接近于现代处理器的模型。

2.1 分析阶段

在分析阶段中,两阶段处理器会像真正执行了程序一般, 分析它将会执行的所有指令的数据依赖关系,得到一个数据流依赖图。 下图展示了我们从上面的例子中构造的数据流依赖图:

6e2907dcd9531b6777cf41a936b8f22a.png
图1:数据流依赖图

首先,两阶段处理器为最初的寄存器值或者常量分配一个方形结点。 方形结点代表它们的值在未来不会被改变, 所以寄存器在两阶段处理器中目前只是起到初始化的作用。 例子中用到了寄存器x1x2(未标出)、f0和常量0

然后,两阶段处理器会顺序读取它将执行的每一条指令。这一步有两种情况:

  • 如果这条指令是跳转指令,那么处理器会用某种“神秘的力量”(后面会解释) 获知跳转指令的目标地址,然后重新读取下一条指令 (所以我们图中没有为bne指令分配结点)。
  • 否则,处理器会在数据流依赖图中为这条指令分配一个圆形结点。 这个结点会保存指令所需的必要信息。 比如,依赖图中每个圆形结点旁还标记了它原本的目标寄存器。这个寄存器信息在目前这个理想模型中用处不大,但在实现时会有很大的作用。

在为一条指令分配完结点后,两阶段处理器继续为这条指令分析依赖关系,并使用箭头连接可能会有依赖的结点。箭头代表两种可能的数据流依赖关系:

  • 一个指令的操作数依赖于另一个指令的结果,或者依赖于某个寄存器值以及常量。 比如在图中,fmul.d f2, f1, f0指令就依赖于fld f1, 0(x1)保存在f1中的结果和寄存器f0的值,所以图中有箭头从fld结点和f0结点指向fmul.d结点。
  • 读写同一个地址的load或store指令必须按照它们的出现顺序依赖。 比如,假设寄存器x1的值为0,寄存器x2的值为10, 那么尽管load指令ld x3, 10(x1)和store指令st x4, 0(x2)看起来没有依赖关系, 但实际上它们都在对同一个内存地址读写。如果load指令发生在store指令前面,那么就算store指令并不依赖于load指令的结果,但是我们仍需要标记一个从load结点指向store结点的箭头。否则,两阶段处理器在执行阶段会认为它们可以并发,可能会使store比load先执行,以至于改变了load的结果。在我们的例子中,目前还没有load/store的依赖关系。

有些读者可能已经发现了一个致命问题:“ 普通指令的依赖关系或许都可以通过‘读’汇编代码得到,但处理器如果要知道load/store的依赖关系,就需要知道load/store的访问地址; 如果要知道它自己未来会执行哪些指令,就需要知道跳转指令的目标地址。 这些信息都是动态信息,也就是说一般都是在处理器执行指令的时候才知道的。若处理器真知道这些动态信息,那不就相当于执行了程序了吗? 还用接下来执行阶段做什么?”

确实,这些动态信息在单纯的静态分析阶段是无法得到的。实际上,现实中的处理器会一边执行指令一边分析指令,从而有动态信息来构建依赖图。 但本文为了方便理解,把“执行”和“分析”拆分成了两个独立的阶段。 因此,在此时,我们必须引入一个假设,也就是上文所说的“神秘的力量”:两阶段处理器在分析阶段能准确预测所有动态信息,比如每一次跳转指令的跳转目标。第2.3节会讲解如何去除这个假设。

另外,我在前言中提到过,我是从“数据流动态分析”的角度来构建这个两阶段处理器的。此时“动态”的含义应该清晰了不少:一个“数据流依赖图”必须要基于处理器的动态执行才能构建出来。相比而言,编译领域的“静态分析”则主要基于代码文本,所以会缺少一些动态时才能得到的信息。如何在有限信息下得到有用的结果,是静态分析需要应对的挑战。

此外,我们还有两个隐含的假设:

  • 指令序列是有限长的,否则我们的理想处理器无法在有限时间内得到数据流依赖图。
  • 处理器有充足的硬件资源来保存数据流依赖图。

总之,数据流依赖图反应了数据在指令流动执行中的依赖关系。为什么要分析依赖关系?如果数据“不依赖”,那么就是“并行的”了。两阶段处理器会在执行阶段依据数据流依赖图尽可能并发地执行没有依赖关系的指令。

2.2 执行阶段

在得到数据流依赖图后,可以并行的部分一目了然。我们定义:两个指令结点可以并行当且仅当没有一条箭头路径连接它们。对于上一节中的数据流依赖图,我在下图中用红框框出了可以并行的部分:

16927d38203d8558a6cd46c7504cd4ec.png
图2:数据流依赖图中的并发部分

我们很容易地看到了和在第1节中手动处理汇编一样的结果:图中上下两排可以并行的指令代表分别代表了da[0] *= xda[1] *= x

我们的两阶段处理器在这一阶段会进入循环,在每次循环中,它首先在构建好的数据流依赖图中查找这样一条可执行指令:这个指令所依赖的指令都已经执行完成,并且能执行它的运算单元正空闲。在满足要求的指令找到后,两阶段处理器就把它发送到对应的运算单元去执行。由于运算单元的运算可能会需要一定时间,所以处理器会立刻进入下一次循环查找新的可执行指令。

比如,在我们的例子中,两阶段处理器首先会发现图中第一行的fld指令没有依赖的指令, 于是它把这条指令发送给一个内存存取单元执行。紧接着它又发现第二行的addi指令也没有依赖的指令,于是它把addi指令发送给一个整数运算单元执行。由于加载内存需要一定时间,所以此时这两条指令是并行的。

接下来,两阶段处理器发现其它指令都依赖着它刚才发送执行的两个指令,于是它等待了一会。很快,addi指令就执行好了。这让处理器发现第二行的fld指令满足了可执行的要求。虽然在这个时候,第一个内存存取单元还要多花点时间才能完成先前给它执行的fld指令, 但两阶段处理器发现还有一个空余的内存存取单元可以用来执行fld指令,于是它就把这条新的fld指令发送了过去。此时,我们就发现有两条fld指令正并行执行。

通过以上这个例子,两阶段处理器的指令级并行效果可见一斑。

2.3 我们离现代处理器还有多远?

回顾一下,我们之前有三个假设:

  • 两阶段处理器在分析阶段能准确预测所有动态信息
  • 指令序列是有限长的
  • 处理器有充足的硬件资源

只要我们简单修改一下模型,去掉这三条假设,就能得到现代处理器的指令级并行效果。

首先,我们拿“指令序列是有限长”这个假设来开刀。回想一下,我们为什么需要这个假设?是因为,我们的模型为了便于理解,把处理器的执行过程简单分成两个阶段。如果处理器要执行的指令序列是无限长的,比如一个Web服务端程序,那么“分析”阶段就永远无法完成。所以,我们只要让两阶段处理器一边在看到新的指令后就分配一个结点、分析依赖性把它加入到数据流依赖图中,一边依据当前构建的数据流依赖图执行可执行的指令。这样就不用考虑指令序列的长度了。

第二,如何消灭“有充足的硬件资源”这个假设? 既然我们已经让两阶段处理器开始动态构建数据流依赖图了,那么我们会想到,依赖图中太过“旧”的结点实际上是没用的。所以我们只需要在结点不必要时把它删除,就能用有限的硬件资源为两阶段处理器维护一个数据流依赖图。

第三,如何消灭“两阶段处理器能准确预测所有动态信息”这个假设? 由于我们已经让两阶段处理器一边执行一边分析了,所以理论上此时所有动态信息都是可以获得的。但是,由于执行指令的单元 (简称执行单元) 实现上会比分析指令的单元 (简称分析单元) 慢一点,所以追求速度的分析单元会超前地读取一些指令来分析。这时,如果分析单元碰上了跳转指令,而跳转指令的目标地址是由执行单元计算的,那么它有两种可能的选择:

  • 等待执行单元追上来,告诉分析单元跳转指令的目标地址。
  • 自己先对跳转指令的目标地址进行预测 (现实中一般准确率能达到90%以上), 并从预测的目标地址开始继续读取指令、扩张数据流依赖图。 如果当执行单元追上来时,分析单元发现自己先前的预测出错, 那么它就会把依赖图中那些在分支指令后新加入的结点都删掉, 假装无事发生,重新从正确的跳转目标地址开始分析指令。

尽管一个现代处理器仍有很多细节需要处理,但现在去掉三条假设的两阶段处理器的理念已经和它很接近了。 实际上,一个采用多发射 (multiple issue)、dynamic schedule和speculation技术的超标量 (superscalar) 处理器大概有这五个并行的单元:

  • 获取指令单元:从内存中获取指令。
  • 发射指令单元:同时为多条新读取的指令分配结点插入“数据流依赖图”中 (multiple issue体现在这个阶段)。
  • 执行单元:把“数据流依赖图”中可执行的指令喂给计算单元开始执行 (一般有多个计算单元在并行执行指令)。
  • 结果传播单元:把一个执行好的指令的结果根据“数据流依赖图”的箭头发送给需要它的指令。
  • 提交单元:把已经执行好的指令的结果按照指令出现的顺序写入寄存器与内存,让用户观察到指令仿佛正按顺序一条一条执行完成。

3 两阶段处理器视角下的指令级并行技术

接下来,我们就看看如何从两阶段处理器模型的出发理解一些现代的指令级并行技术。

本节写得比较随意,适合有体系结构基础的读者。为了行文逻辑,有些技术细节可能会描述得与经典材料上的有些出入,但其实并无本质上的差异。 相信读者能自行判断。

3.1 Dynamic schedule: Tomasulo算法

Dynamic schedule的含义是动态地调度处理器执行指令的顺序,来尽可能地让指令中能并行的部分同时执行。其中经典的算法是Tomasulo算法。

Tomasulo算法发掘并行指令的insight是renaming。

什么是“renaming”?回想一下,我们在第1节的例子中,为了发掘可以并行的指令,我们首先展开汇编代码,然后把后三条指令所使用的寄存器都修改了一下,得以消除了与前三条指令所使用的寄存器的冲突。这个修改寄存器的过程就是renaming。

我们的两阶段处理器模型肯定是采用dynamic schedule策略的。而Tomasulo算法核心的renaming策略实际上也包含在我们的两阶段处理器为指令分配结点与连接箭头的过程中:分配结点就是把一个指令的目标寄存器“rename”成一个结点,连接箭头就是把一个指令的源寄存器“rename”成某个为这个源寄存器提供结果的指令的结点。

Tomasulo算法使用reservation stations来实现renaming。实际上reservation stations就是一个数据流依赖图:reservation stations的每一行都代表着一个数据流依赖图的结点。

那Tomasulo算法是如何管理有限大小的数据流依赖图结点空间的呢?

首先,每当读取到一个新的指令时,Tomasulo算法会查看reservation stations中是否能分配空闲的结点。如果没有的话,它就等待。如果存在的话,它就分析这条指令的依赖信息,然后分配结点插入到reservation stations中。

比较特殊的是,Tomasulo算法在遇到跳转指令的时候,行为会比较保守 ——它会采取我们在2.3节描述的第一种方法:等待执行单元追上来,告诉分析单元跳转指令的目标地址,再继续分析。下一节的Speculation技术为了提高性能,则会采取第二种做法。

之后,Tomasulo算法在执行单元计算完一个指令P之后,会立刻释放它对应的结点。释放结点的过程分为两步: 第一步,把指令P的结果传递给有需要的指令。第二步,把结果写入指令P原本该存入的寄存器和内存中 (回想一下数据流依赖图中每个结点旁边的标注)。这样,未来某个依赖于指令P结果的指令Q就有两种情况来获取输入:

  • 如果指令Q已经被分析故而也被分配了结点,那么指令P在计算完成后会把结果传递给指令Q。
  • 如果指令Q还未被分析,就可以直接从寄存器中获取输入。

Tomasulo算法这样管理数据流依赖图结点的方式会遇到imprecise exception的问题:因为执行单元执行指令的速度是不同的,也就是说指令执行完毕的时刻是不一定的,所以reservation station中结点的释放顺序也是不一定的。由于释放结点会把结果刷回寄存器和内存中,所以从用户的视角来看,处理器寄存器和内存的状态变化是“乱序”的。当出现异常或中断时,用户就很难得到精确的异常现场。

3.2 Speculation

Speculation使用reorder buffer (ROB) 解决了跳转指令以及precise exception的问题。

不同于Tomasulo算法一执行完指令就把结果刷回寄存器与内存,Speculation会在指令计算完后先把它的结果保存到ROB中。当然,reservation station和ROB在两阶段处理器眼里没有区别——都是依赖图中的一个结点。

然后,Speculation了安排一个“提交单元”根据指令出现的顺序依次释放依赖图中的结点—— 换句话说,把ROB中的结果刷回寄存器与内存。这样就给用户一种处理器的寄存器和内存的状态正在“顺序”变化的感觉。所以异常现场会是精确的。

此外,Speculation的分析单元在遇到跳转指令的时候,会先对跳转指令的目标地址进行预测,并从预测的目标地址开始继续读取指令、分配结点、执行、把结果加入ROB中。 但是ROB暂时不会提交这部分“投机”执行的指令结点—— 它会在执行单元确认分支指令的跳转目标没有预测出错后才会提交这部分结点。如果预测出错,它直接把这部分结点删除,重新从正确的跳转地址开始分析,假装没有计算过这部分内容。这对应于把依赖图中那些在分支指令后新加入的结点都删掉。

3.3 Multiple issue

先前Tomasulo算法和Speculation都是在每个时钟周期一条一条读取指令、一个一个分配结点加入数据流依赖图中。但实践中,分析指令阶段相比于执行阶段仍会快很多,还没达到瓶颈。 所以为了充分利用一个时钟周期,我们完全可以让分析单元一口气读k条指令,一口气往依赖图中加入k个结点。 对应的,我们也可以让ROB一口气提交k个结果。这就是multi issue的概念。

但multi issue的复杂度会随k上升得很快。比如,假设ISA包含n条指令,那么一口气读入k条指令最坏可能会有n的k次方种情况要处理。所以稍微大点的k就容易成为瓶颈。

4 小结

本文从“数据流动态分析”的角度构建了“分析-执行”两阶段处理器模型,来帮读者理解现代处理器中的指令级并行技术。如果读者觉得有所帮助,可以点赞关注。未来有机会我会分享我在学习过程中踩过的坑、有意思的insight、解决的有趣问题等等。

对于那些对体系结构感兴趣的读者,我也非常推荐去看看Computer Architecture: A Quantitative Approach。这本书是由发明RISC体系、拿过图灵奖的两位大佬写的,是体系结构领域必读的经典书籍。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值