Chapter 8. Scheduler in OSStrategy 1: FIFO(FCFS,First Come First Served)Strategy 2 : SJF(Shortest Job First)Strategy 3 : STCF(Shortest Time-to-Completion First)Strategy 4 : Round Robin(时间片轮转)Strategy 5 : Overlap when performing I/O operationStrategy 6 : Multi-Level Feedback Queue Scheduler(MLFQ)Chapter 9. Y86-64: A self-defined ISA9.1 Y86-64 Processor State9.2 Y86-64 Instructions9.2.1 程序终止指令类 halt0x009.2.2 空指令类 nop0x109.2.3 寄存器移动指令类9.2.4 直接量-寄存器移动指令类9.2.5 寄存器-内存移动指令类9.2.6 内存-寄存器移动指令类9.2.7 运算指令类9.2.8 跳转指令类9.2.9 调用指令类9.2.10 返回指令类 ret0x909.2.11 寄存器进栈指令类9.2.12 寄存器出栈指令类9.3 Y86-64 Program Stack9.4 半章小结9.5 CPU Design: CISC vs RISC9.5 CPU Design: Logic Design & HCLHardware Control Language SyntaxArithmetic Logic Unit DesignRegister(Storage) Design9.6 CPU Design: SEQ General9.6.1 General Principle9.6.2 Basic Framework9.6.3 Describe Assembly with Framework9.6.4 Advantages & Disadvantages9.7 Principles of Pipeline9.7.1 Time Analysis: SEQ9.7.2 Time Analysis: N-Way-PipelinedLimitation 1: Nonuniform DelaysLimitation 2: Register OverheadLimitation 3: Data Dependency & Data HazardLimitation 4: Control Dependency9.8 Pipeline Implementation9.8.1 Hardware9.8.2 Resolve Data Hazard: Data Forwarding & Stalling9.8.3 Resolve Control Hazard Part Ⅰ: Predicting the PC9.8.4 Resolve Control Hazard Part Ⅱ: Fix Wrong Predictions9.8.5 Exception Handling in Pipeline9.8.5 Final Implementation9.8.6 Performance Analysis: Metrics

Chapter 8. Scheduler in OS

为操作系统的调度环境作出假设:

  1. Each job runs for the same amount of time

  2. All jobs arrive at the same time

  3. Once started, each job runs to completion

  4. All jobs only use the CPU

    • i.e., they perform no I/O

  5. The run-time of each job is known

 

引入调度优劣衡量指标:周转时间Tturnaround=TcompletionTarrival

Strategy 1: FIFO(FCFS,First Come First Served)

Strategy 2 : SJF(Shortest Job First)

Strategy 3 : STCF(Shortest Time-to-Completion First)

Strategy 4 : Round Robin(时间片轮转)

Strategy 5 : Overlap when performing I/O operation

Strategy 6 : Multi-Level Feedback Queue Scheduler(MLFQ)

 

Chapter 9. Y86-64: A self-defined ISA

Y86-64 是由 CMU 教授设计,SJTU 老师改进的一种教学用指令集架构,实际生产中没有处理器使用此架构。

学习它的目的是 更好地了解 CPU 与 ISA 间的关系和实现思路,剥离开复杂的细节,从抽象角度帮助我们理解 CPU 与 ISA、软件之间的交融

最后,为我们自己设计模拟器(simulator)、汇编器(assembler)、计算机处理器(CPU)打下基础。

9.1 Y86-64 Processor State

我们定义 Y86-64 架构下,用于保存处理器状态(或者说程序执行状态)的由以下部件构成:

9.2 Y86-64 Instructions

Y86-64 下定义了 12 类处理器指令:

Tips 1. Generic Form & Encoded Representation

Tips 2. Instruction Code (first 4 bits) & Function Code (second 4 bits)

 

9.2.1 程序终止指令类 halt0x00

9.2.2 空指令类 nop0x10

9.2.3 寄存器移动指令类

rrmovq rA, rB0x20 0x<rA><rB>(看作无条件的 conditional move)

cmov<XX> rA, rB0x2<X> 0x<rA><rB>

9.2.4 直接量-寄存器移动指令类

irmovq V, rB0x30 0xF<rB> <V>(2+8 bytes)

9.2.5 寄存器-内存移动指令类

rmmovq rA, D(rB)0x40 0x<rA><rB> <D>(2+8 bytes)

9.2.6 内存-寄存器移动指令类

mrmovq D(rB), rA0x50 0x<rA><rB> <D>(2+8 bytes)

⚠⚠ 在 encoded representation 中,用于存储内存基地址的寄存器(在 9.2.5 和 9.2.6 中都用 rB 表示)ID 必须在目标寄存器 ID 之后! ⚠⚠

9.2.7 运算指令类

<OP>q rA, rB0x6<X> 0x<rA><rB>

9.2.8 跳转指令类

j<XX> Dst0x7<X> <Dst>(1+8 bytes)

不支持直接跳转的 PC-relative 表示法,下同。

9.2.9 调用指令类

call Dst0x80 <Dst> (1+8 bytes)

9.2.10 返回指令类 ret0x90

9.2.11 寄存器进栈指令类

pushq rA0xA0 0x<rA>F

(在 9.3 中详细介绍)

9.2.12 寄存器出栈指令类

popq rA0xB0 0x<rA>F

 

我们根据上面的定义总结出几点有价值的信息:

 

9.3 Y86-64 Program Stack

Y86-64 架构下的程序栈与 x86-64 相近,遵循相似的 ABI:

此外,在 Y86-64 中的两种对栈操作的指令:

⚠⚠ 注意,无论是 pushq 还是 popq(读出还是写入)寄存器,寄存器都在 Encoded Representation 的 Function Code 的前 4-bit 内容 ⚠⚠

总之注意三个地方的指令的编写位置:

  1. 在寄存器 - 内存间转移的 Load-Store 指令的 rArB 位置;

  2. 操作栈指令的 rAF 的位置始终是寄存器位于 Function Code first 4-bit;

  3. 在直接量 - 寄存器转移的指令中,Function Code 却遵循 F<rA> 的规律;

 

9.4 半章小结

 

 

9.5 CPU Design: CISC vs RISC

TYPECISCEarly RISC
指令数极多<1001
指令耗时差距较大都很少2
指令长度可变长3定长
指令表示一种运算有多种指令仅表示运算最小完备集
load & store算术、逻辑运算允许内存-寄存器间算术、逻辑运算仅允许寄存器间
机器级细节hiddenexposed4
条件判断Conditional CodesTest Instruction + Normal Registers
procedure call少量使用 registers,集中在栈上传递5大多数交给 registers

1 : 很少的指令数会导致编译得到的汇编码增多;

2 : RISC 指令数少、指令执行时长普遍很短,所以它们的执行时长接近,很适合 pipeline 的设计;

3 : x86-64 一条指令长度在 1 ~ 15 bytes 间;

4 : 将硬件层面的细节暴露给上面的软件层开发者有利有弊。由于 RISC 的精简指令简化了编译器的工作,编译器得以投入到更深层的优化上来。另一方面暴露硬件细节也会损失一定的兼容性;

5 : 主要是 CISC 考虑了兼容性。因为早期的机器寄存器数量不多,使用内存栈传递参数是个明智的选择;


根据上面的线索,我们能总结出 Early RISC 相对于 CISC 的优劣势:

 

如今,新指令集没法明确地分出它属于哪一种,它们会借鉴两者的特点和原则;

例如这里的 Y86-64:

 

9.5 CPU Design: Logic Design & HCL

复习:数字电子电路基础 - 组合时序逻辑电路。例如:

Hardware Control Language Syntax

Arithmetic Logic Unit Design

Register(Storage) Design

 

9.6 CPU Design: SEQ General

本节我们在前几节电路基础、Y86-64 ISA 理论基础上构建 Y86-64 微架构,实现串行(sequential)处理器。

icode 不在 0 ~ B 间说明非法指令;

ifun 也要检查,不在指令列表中的也需要报错;

icode:ifunc 以后的内容 “编码错误” 不是非法指令,因为 CPU 能够识别,应该属于软件层面的错误。

9.6.1 General Principle

首先要知道,一个汇编指令需要很多步电路操作,所以我们设计时应该遵循如下原则:

  1. 采用 Multi-stages Design,即分几步来完成一个汇编指令,思想类似函数包装

  2. 为了更好地利用硬件,不应该为每个指令设计一个电路,而是考虑如何让所有指令共用一套电路

9.6.2 Basic Framework

本节是上完课后总结的内容,叙述顺序进行了综合。

根据前人的经验,Y86-64 串行处理器设计的 stage 应该分为这几个:

 

9.6.3 Describe Assembly with Framework

有了上面的知识,我们可以把各个指令对应的微架构过程描述出来了。

rrmovq rA, rB

cmovXX rA, rB

irmovq V, rB

rmmovq rA, D(rB)

mrmovq D(rB), rA

opq rA, rB

jXX Dest

call Dest

ret

pushq rA

popq rA


其中有几个要点需要着重掌握:

再总结一下以上各个 Stage 实际用到的值情况:

 

9.6.4 Advantages & Disadvantages

 

 

9.7 Principles of Pipeline

Some Ideas:

我们定义:

9.7.1 Time Analysis: SEQ

从之前的串行处理器开始讨论,一条指令执行的耗时情况如下:

因此这种情况下,每个时钟周期至少 320 ps

9.7.2 Time Analysis: N-Way-Pipelined

我们考虑如下流水线的思想,将组合逻辑电路拆成多个 stage,以便使用流水线:

如果将组合逻辑电路按照流水线的 ideas,变为 3-Way Pipelined

Limitation 1: Nonuniform Delays

但是,我们没有考虑一个重要问题:逻辑电路划分为 stage 后,每个 stage 的 Delay 时长不一致(Nonuniform Delays)。

这个时候,有些耗时短的 stage 就要等待耗时长的 stage,例如:

这样我们考虑几个问题:

根据组合逻辑电路的设计,有些部分是无法继续切分的,这就告诉我们无法无限向下切分 stage。假设有一种设计如下,各个部分不能继续切分:

那么,考虑不同 stage 数量下的 CPU 情况:

于是我们能得到结论:

 

Limitation 2: Register Overhead

另外,当我们将过程切分成更多的 stage 后,loading registers 所占的时长比例愈发的大,这是我们所不希望的。

Limitation 3: Data Dependency & Data Hazard

既然使用流水线,就不可避免要考虑前后 stage 的依赖问题,例如 前一条指令的结果是后一条指令的运算数(数据依赖)、前一条指令决定后一条指令的执行位置(控制依赖)

对于数据依赖而言:

因此,这三条指令在流水线上的时间顺序必须是这样的,才能保证运算结果的正确性:

但是,这样又没法使用流水线,因此可以采用 中间插入其他指令 的方式缓解这种问题,具体会在实现的时候进一步讨论:

Limitation 4: Control Dependency

同样,对于控制依赖:

 

于是,我们分析了实现流水线的优势、可能遇到的问题。接下来我们会在实现的介绍中,依次解决上面的几个顾虑。

 

9.8 Pipeline Implementation

首先同样,根据前人的经验,我们作出如下强调:

9.8.1 Hardware

有几点值得注意;

 

那么这个 implementation 是如何解决 Data / Control Hazard 的?

9.8.2 Resolve Data Hazard: Data Forwarding & Stalling

首先对于 Data Hazard,主要的问题是:Read-after-write(write 在 read 后面则不会出现问题)。

可能引发这个问题的指令有:opqir/rr/mr/rm movqpopq

例如对这样几条汇编而言:

直接考虑 5 个 stage 流水线形式进入会出现问题,如下左图:

因为在第 3 条指令在 Decode 阶段会读取紧邻的前两条指令计算结果,而前两条指令此时正处于 Memory 和 Execute 阶段,都没有到 Write Back 阶段,这时存在 read-after-write dependency,读取数据一定是错误的。

第一种尝试方法是之前提到的 插入 nop(Naïve Design);

一般如果 Fetch 读入一个改变下一个指令所用寄存器的指令,那么等到该指令更改寄存器位于 Write Back Stage,前后相差 3 个 stage,因此在有 Data Hazard 的两指令间插入 3 个 nop就能解决问题,如上右图。

考虑一下,如果只插入 2 个 nop,那么第三条指令只能读到 %rdx 中正确的值,因为第二条指令还在 Write Back 阶段没有结束;

如果只插入一个 nop,那么两个寄存器正确的值都读不到!

虽然这种解决方法非常简单,可以由编译器、硬件处理器,甚至开发者来解决。但是弊端是 这会严重拖慢流水线速度。因此我们得想办法尽可能让底层硬件解决这个问题。


第二种尝试方法是 stalling(拖延住)

这种方法是在 CPU 执行指令时实时进行的,具体以例子说明。假设有个程序在 Y86-64 编译器上编译后长这样:

我们根据上一种方法的分析可知,现在 0x016 位置上的指令仍然无法读到正确的 %rax,还需要再等一个 clock 才行。这个时候,我们在 0x0150x016 之间插入一个 “Bubble”。

这个 Bubble 不会在汇编代码中出现,而是 CPU 进行了以下操作

  1. 0x016 指令(依赖未取得的指令)所在的 Stage(Decode)输入状态寄存器发送 Stalling 信号,暂停一个 clock 不允许被修改

  2. 在向 0x016 指令之前的所有 stage(这里只有 Fetch)的输入状态寄存器发送 Stalling 信号(防止后来的指令 “撞到” stalling 寄存器上消失);

  3. 由于 0x016 指令只是状态寄存器定住了,不正确的信号仍然会向前传递,因此 CPU 还要 将前进到 Execute Stage、但没来得及修改 CC 和 后继状态寄存器的错误指令的 icode 修改为 1,表示 nop,使它彻底失去作用

这种 “将其他指令的 icode 改为 1 使其成为 nop” 的动作所产生的不在汇编码上的 nop 就叫 bubble。

这样从 0x015 后的所有指令都会暂停一个 clock,等待 0x00a 指令写入寄存器。

这种方法只是将插入 nop 的工作移交给了硬件,没有解决速度上的问题。


第三种尝试方法是 data forwarding(利用 feedback paths 将后面的结果给到前面的 decode stage);

就是说,我们让最终结果未出现前,让正确 / 可能正确的值先通过传输线传递给读的 decode 阶段;这个方法能实现,主要是因为 能读到信号要早于写入信号(前者在一个 Clock 开始的一段时间后就可读到,而后者则需要这个 Clock 结束)。

例如:

但是 Feedback Paths 不会出现在 Fetch 阶段,因为太早了,后面的指令没进入 Decode 阶段前很难使用这个结果。

一般用 feedback paths 去解决 data hazard,主要作用在 Decode 阶段,因为这个阶段才可能会用到没有写入的寄存器值。

再考虑简单的情况,假设 0x00a0x016 之间有两条无关的指令,就假设为 nop

这个时候 %rdx 能正确读到,如果我们把 Write Back 阶段的 W_valE0x00a 的 Write Back Stage 状态寄存器值)通过 feedback path 将信息告诉 Decode Stage 呢?比如在这种情况下,我们把 W_valE 接给 valB,就能够读到正确的值了。

那我们乘胜追击,假设中间只空 1 个 nop 呢?这个时候产生 %rax 的值还在 Memory 阶段!没事,因为 irmovq 的结果在 Fetch 阶段就知道了嘛,我们把 M_valE 的值给它;

然后另一个寄存器也无法获取正确的值,就类似上个情况的 “从 Write Back 阶段 W_valEvalA“ 接线就行。如上中图所示。

太好了,更极限一点的话,irmovq 写和 opq 读指令不隔 nop,是否也能用 data forward 解决?

同样是可以的:在上一条指令处于 Execute Stage 时,我们直接将运算结果 e_valEvalB。来的及吗?之前都是给状态寄存器(在 Clock 开始前就等待在外),这次给结果值(Clock 开始后一段时间才能稳定),是来得及的。因为 Deocde 阶段可以等,直到这个 Clock 快结束才得到都是没问题的如上右图。

更进一步考虑,假如上面的指令不是 addq %rdx,%rax,而是 addq %rax, %rbx(前两条指令也对应变),那么寄存器情况是不是一样能这么做?

于是我们发现,只要多加几个 feedback paths,就能解决以 irmovq / opq 为首的 RAW-Dependency 问题,不需要插入 Bubble 或者 nop。我们将这个成功经验拓展到其他指令上,再总结一下:

首先我们可以从这些位置提前得到寄存器的结果(Forwarding Sources):

其次,我们需要判断是否应该接受 forwarding data,即分情况选择 valAvalB 的来源。哪些情况应该和平常一样直接使用寄存器的值,哪些时候又应该选择 Forwarding Sources

为了分情况选择,我们向电路里加入了两个逻辑电路用于选择不同情况下 valAvalB 的取值,纠正 data hazard 问题:

这种 data forwarding 方法看似解决了问题,并且无需使用 bubble 和 nop,但是它还是没有考虑完全。

我们上面讨论的情况 “以 irmovq 写、opq 读为首的 RAW-Dependency” 有个前提是,直接相邻的两个存在 data dependency 的指令,所使用的有依赖的数据产生阶段不能晚于 Execute Stage,因为直接相邻说明只间隔了一个 stage。

那么可能构成 data dependency 的指令中(opqir/rr/mr/rm movqpopq),有一类是没法在 Execute Stage结束前拿到结果:从 memory 中取值的 mrmovqpopq。它们必定要等到 Memory Stage 结束才能取到值!

我们将 “在取 Memory 后,立即使用此值” 的行为称作 “Load / Use Hazard”,这种情况使用 Data Forward 是无法纠正寄存器的读错误。我们必须要借助前面介绍的 Bubble 让这种连续 Load / Use 分开一个 Cycle


于是,Y86-64 针对 Data Hazard 的解决方法总结如下:

 

9.8.3 Resolve Control Hazard Part Ⅰ: Predicting the PC

其次,针对 Control Hazard 的解决方案也是 Feedback Paths。根据指令的特点,有几种数据能决定 next PC:

所以可以根据这些寄存器中的值进行预先推测,猜测可能下一步可能的指令地址

由于上一条指令在 Fetch 之后就要读下一条指令了,所以猜测的可靠性不能保证。

如果猜测错误,就需要恢复到猜测前的状态(如何实现?后面提及)。

对应这几种数据的特点和成功率,我们相应的有猜测策略

现在,我们根据猜测策略,将各种情况下 Next PC 的猜测值全部汇总到 Select PC 原件,让这个逻辑电路根据情况判断正确的下一条指令是什么,如下图。

9.8.4 Resolve Control Hazard Part Ⅱ: Fix Wrong Predictions

但是有个问题,我们按照上一节的猜测策略暂时得到了可能的下一条指令的执行位置,我们又如何在得到结果后发现、修复错误的猜测结果呢?

ret 指令的结果数据在 Memory 结束、Write Back 状态寄存器才能知道,jmp 类指令的结果数据也要在 Execute 结束、Memory 状态寄存器才能知道。这两种指令紧邻的下一条指令在 Fetch 时(哪怕有 Data Forward)也没法得知结果,怎么办?

根据猜测策略,jmp 直接先猜测按照 valC 跳转,但是检查 M_Cnd 时,jmp 已经向前前进了 2 个 Stage 了(Fetch Finish -> Execute Finish),如果 M_Cnd 指示符合跳转条件,那么继续执行没有问题,但是如果出错,应该怎么办?我们发现检查 M_Cnd 的时候,中间两个指令分别在 Decode 起点、Execute 起点,还有下一条指令刚要进入 Fetch 阶段 —— 即便 jmp 错误,当前也并没有产生影响(如更改 CC、写入下一个 stage 的状态寄存器)。因此很好解决:

jmp 如果发现 M_Cnd 指示不应该跳转,说明预测错误,立即进行以下措施:

  1. 由于 jmp 以后的错误指令只有两条(位于 Decode 和 Execute 开始阶段),并且没产生影响,只需要在时钟上升沿时将它们的 icode 改为 1nop),就能解决错误执行的问题;

  2. 同样是上升沿阶段,将 M_Cnd 给到 Fetch 阶段,这个时候 Fetch 阶段就能读到正确的下一条指令;

所以就算 jmp 预测错误,有了上面的措施,也就浪费 2 个 Cycles 而已,不会造成执行语义错误

再来看 ret 的跳转错误如何修复。

根据策略,ret 不会做猜测(一般行为遵从 Predict PC 拿到的 valP),直接接着这条指令向下读。但是 retjmp 不一样,不能按照原来 feedback paths 解决,因为 ret 拿到结果 W_valM 要到 Memory Stage 结束,而这个时候 ret 向前前进了 3 个 Stage,紧跟 ret 的指令 Execute 已经执行结束 —— 也就是说,错误的指令改变了 CC 和后续状态寄存器,这是不允许的!

你可能会问,我们能不能早一点拿到 valM,比如 m_valM?不行,因为 m_valM 产生在 Clock 很后面的阶段(尤其是从内存读),Clock 时钟来不及。

因此,我们只能采取 填充 3 个 Bubbles 的方法,避免出现错误:

这样,当 ret 结束 Memory Stage 时,时钟上升延会推动正确的返回地址数据到 Write Back Stage 的状态寄存器 W_valM 中,此时利用 Select PC,在 Fetch 阶段就能读到正确的下一条指令的地址了。

所以,无论如何,ret 后面总是要被 CPU 追加 3 个 bubble 的空隙。

综上,根据我们的猜测策略 和 修复措施,Select PC 的工作逻辑应该是这样的:


最后,总结一下 Y86-64 是如何解决 Control Hazard 的问题的:

 

9.8.5 Exception Handling in Pipeline

在之前的章节,我们介绍了 x86-64 的 Exception Control Flow,它是 OS 与硬件配合改变执行流的动作。

如果在 SEQ 设计中,我们只需更改 PC 寄存器即可。但是在 Pipeline 设计中,我们需要清理一些寄存器的值、某些 stage 的状态寄存器,才能转到指定的 ECF 地址。主要的步骤如下:

  1. 打断当前程序执行流;

  2. 根据出现的事件,填入 Exception Table Base Register,找到 Exception Table 对应的 Exception Handler 地址,跳入新的执行流;

  3. (可能)回到原来的执行流;

我们这里先只考虑 同步异常(Synchronous Exceptions)

那么可能导致同步异常的硬件层面原因有:

为了处理问题,我们还要讨论错误可能发现的位置:

但是,pipeline 的设计导致一些奇怪的情况:

Situation 1. 异常检测顺序倒置:代码的执行流中两个错误,但是后一条错误发现时间(Fetch Stage)在前一条错误发现时间(Memory Stage)之前。导致后一条指令会先报错;

Situation 2. 不应该触发的异常:异常的代码不在程序执行流内(例如 halt 以后不全是 0,有没擦干净的乱码;或者 jmp 的另一个不会执行的分支),但是 CPU 仍然可能读入并报错;

你可能会想,我们要模仿 SEQ,先检测到错误没事,只要我们在这条指令完全结束后(该指令到 Write Back Stage 结束)再报出错误,就能避免上面两种情况的发生。但这样做可能引发另一类问题:

Situation 3. 异常指令的 Side Effect:错误指令和正常指令一起在流水线中向上传播,可能会导致 CC 被错误地修改(例如在错误指令后面的指令不应该被执行,但是它仍然更改了 CC,这会影响 Dump 的数据);

综合以上的情况,我们总结出了解决方案:

 

 

9.8.5 Final Implementation

我们将前几节的 Pipeline 设计的思想和注意要点结合起来,考虑设计一个 Y86-64 pipeline 处理器的最终版本。

 

9.8.6 Performance Analysis: Metrics

比如我们利用 Metrics 的指引进行优化:Speculation Execution in Fetch Logic;

考虑 PIPE CPU 的关键路径:

我们发现 increment 阶段是关键路径上较大的部分,它要等待 regids、valC 结果,然后执行 64-bits 的加法运算(最多 +10),所以我们可以先将前 60 bits 的数据 +1(同时保留不加、+1 的数据),然后另一部分仅仅是 4 bits 数的相加,这样可以节省串行进位加法所耗费的时间,如下图所示:

这样指令总体的速度都会快上一些。