Out of Order off Orthodoxy on Occasion or Omnipotently over Ostentation.
\documentclass[UTF8,11pt]{ctexart} % 使用 CTeX 中文支持
\usepackage[
paperwidth=8.5in,
paperheight=11in,
margin=10pt % 去除所有页边距
]{geometry} % 控制页面布局
\usepackage{multicol} % 分栏支持
\usepackage{lipsum} % 示例文本(可删除)
\usepackage{xcolor}
\usepackage{tabularx}
\usepackage{amsmath}
% 可选:调整栏间距(默认10pt)
\setlength{\columnsep}{1cm}
\begin{document}
\pagestyle{empty} % 移除页眉页脚
% 自动分栏环境(内容填满第一栏后自动进入第二栏)
\begin{multicols}{2}
\section*{Intro}
除了关注上层抽象描述外,底层的实现与\textcolor{red}{算法常数}有很大的关系。还与\textcolor{red}{并行}或\textcolor{blue}{out-of-order superscalar}有关——如何\textcolor{red}{实时(on-the-fly)}地检测可并行的指令?还有系统安全,以及新的专门accelerator等。
高级语言 $\xrightarrow{\text{compiler}}$ 汇编语言 $\xrightarrow{\text{assembler}}$ 机器语言 $\xrightarrow{\text{linker}}$ 可执行程序 $\xrightarrow{\text{loader}}$ 与处理器交互
\textcolor{red}{ISA(指令集)}:软硬件交互界面。它定义了系统的状态,并提供了可调用的指令列表。它仅仅规定了功能而没有限制实现细节,由此允许上下层独立进行优化。ISA 本身比较稳定,而其上下层的实现可能比较短暂。
\texttt{int} 必须 4B 对齐,其它同理,在 \texttt{struct} 内部也是如此。
程序的内存占用:在其对应的虚拟地址段上,有以下部分:
\begin{itemize}
\item 最上层是一段预留给 \textcolor{red}{OS/Kernel} 的内存段。
\item 然后是一段处理递归和过程调用的 \textcolor{red}{stack}。
\item 然后是用来处理动态新建内存的 \textcolor{red}{heap}。
\item 最后是在创建程序时即在末尾存储的静态数据、文本与代码,以及预留的空间。
\end{itemize}
递归时要存储本地的寄存器和本地变量,要将控制权和数据传输给新过程,因此需要栈的结构。
\section*{Evaluation}
如何评估一个系统?有哪些 metric 可以使用?包括成本、表现、能耗、效率、可扩展性、负载均衡等。
首先,如何造芯片?从硅锭(Ingot)到晶圆(Wafer),测试后切割得到裸片(Die),然后包上塑料盒并终测后得到最终的芯片(Chip)。其良品率(Yield)即为良片与单个晶圆上总裸片数目之比。
Wafer边缘的芯片都是废片。因此大Wafer会更好,但是造这么大的Wafer很有难度!大的Chip成本(就算平均)也更高,有 $\text{cost}\propto\text{area}^\alpha$,其中 $\alpha>0$:因为Chip越大出ill的概率就越大。一般仅仅使用面积作为衡量\textcolor{red}{生产成本}的尺度。此外,还有\textcolor{red}{测试成本},与芯片复杂度挂钩;\textcolor{red}{打包成本},与裸片大小、引脚数目等相关;最后还有\textcolor{red}{设计成本},但是它属于\textcolor{blue}{non-recurring engineering, NRE},只要在设计时一次投入大量成本即可,之后的量产不再需要持续投入。
除了芯片以外,总系统成本还包括供能的成本,降温的成本(如果没有\textcolor{blue}{散热片(heat sink)}功率不能超过 2W;没有风扇不超过 10W;要 >100W 只能水冷(liquid cooling)或雾冷(spray cooling))。数据中心的总成本\textcolor{red}{Total Cost of Ownership, TCO}包括固定成本\textcolor{red}{capital expenses(CAPEX)}—设备、安装、计算、存储、网络和软件,和运维成本\textcolor{red}{operational expenses(OPEX)}—功能、租金、维护费、员工成本等。最后,系统稳定性也会影响—宕机即等于收入受损;加入冗余性可以提高稳定性,但成本势必也会增加。
如何衡量 performance?它的尺度是多维的。首先是\textcolor{red}{延时(latency)}—花多久执行单个任务;以及\textcolor{red}{吞吐量(throughput)}—单位时间内能执行的任务数目。一般而言,降低延时会提高吞吐量,但提高吞吐量可能不会降低延时。提高单个处理器的效率同时优化两者,增加更多处理器只提高吞吐量而不影响单个任务的延时。inter-task 并行提高吞吐量延时不变,intra-task 并行两者均优化。
加入队列机制,则吞吐量大时延时上升,吞吐量最小时延时最小。所以,buffering/queuing/batching 提高吞吐量(避免丢包)但可能会影响延时即 \textcolor{red}{Quality of Service (QoS) hurt}。
数字系统(如处理器)使用一个常周期时钟计时。\textcolor{red}{Clock Cycle Time (CCT)}:一个时钟周期的长度。\textcolor{red}{Clock Frequency/Rate}:每秒的周期数。$\text{CF}=1/\text{CCT}$。处理器的 \textcolor{blue}{表现 (Performance)} 是其执行某事项的处理时间的倒数。有 $\text{Execution Time}=\#\text{Cycles}\times\text{CCT}=\dfrac{\#\text{Cycles}}{\text{Freq}}$。因此,为减少处理时间,可以降低周期数(即优化算法),或是提高频率(即优化硬件)。在硬件固定时,可以使用周期数来 \textcolor{blue}{代表(proxy)} 执行时间。
但是周期数是难得到的。容易得到的是一份代码执行时的 \textcolor{red}{指令数 (Instruction Count, IC)}。我们希望有 $\text{ET}=f(\text{IC})$。一个简单的方法是定义 \textcolor{red}{(Average) Cycles per Instruction (CPI)},有时也用其倒数 \textcolor{red}{IPC}。有 $\#\text{Cycles}=\text{IC}\times\text{CPI}$,$\color{orange}{\text{ET}=\text{IC}\times\text{CPI}\times\text{CCT}}$。
优化算法会减少 IC。使用更底层的语言同样也能减少 IC。优化编译器同样。使用不同的指令集呢?复杂指令集和简单指令集,IC 显然不同,且同样会影响 CPI,且可以间接(为了适配新指令集,硬件结构需要改变)影响 CCT。硬件的改变会影响 CPI 和 CCT。
如何求 CPI?在多种指令及其对应的 CPI 间取加权平均即可。即,$\text{CPI}=\sum_i\dfrac{\text{IC}_i}{\text{IC}}\times\text{CPI}_i$。当存在并行时,上式不成立,但仍然可以计算 CPI(且其可能小于 1)。
有一些其它 metric,如 \textcolor{blue}{GHz}(每秒多少 Billion 个周期)、\textcolor{blue}{IPC}(CPI 的倒数)、\textcolor{blue}{MIPS}(每秒多少 Million 个指令)。它们均不完整:MIPS 和 CPI 都是平均数,不同任务上的实际表现可能不同。而且,可以以一者为代价,提升另一者(如提高 GHz 但同时增加 CPI)。只有端到端的执行时间是唯一的金标准,其它的只能供参考。
访问内存的速率同样也影响 CPI。不好的内存设计会需要更多的周期来访问一个内存,因此提高 CPI。同样也可以使用 roofline model 来看一个程序到底受限于计算还是受限于存储—处理器方,有 $\text{ET}=\#\text{Ops}/\text{processor throughput}$;内存方,有 $\text{ET}=\#\text{Bytes}/\text{memory bandwidth}$。实际 runtime 是两者的较大值。可以定义 \textcolor{red}{Operation Intensity (OI)} a.k.a \textcolor{red}{Arithmetic Intensity},描述了访问每个 Byte 需要执行多少个 Ops,即 compute 与 data 的比值。有 $\color{orange}{\text{Perf}=\min(\text{processor throughput},\text{memory bandwidth}\times\text{OI})}$。Roofline 中,左侧 OI 小,受限于存储;右侧 OI 大,受限于计算。\textcolor{blue}{一条 roofline 对应着一个系统,其中的每个点都是一个算法。}
能耗。将所有的晶体管和导线均视作电容,则其\textcolor{red}{动态功耗(dynamic power)}即工作耗能为 $C\times V_{dd}^2\times f_{0\to1}=\alpha CV_{dd}^2f$。其中 $C$ 是电容,$V_{dd}$ 是电源电压,$f$ 是频率(下标 $0\to1$ 是因为,只在充电时耗能,放电不耗能;后一个式子中省略了)。$\alpha$ 是单位周期内,正在充放电的电容比例;进行复杂运算的芯片中,这一数值更高。此外还有\textcolor{red}{静态功耗(static power)}即不工作时的泄露能量,为 $V_{dd}I_\text{leakage}$。在储存器更小、$V_{dd}$ 更小、温度更高时,泄露会更严重。综上,$\text{Power}=\alpha CV_{dd}^2f+V_{dd}I_\text{leakage}$。
\textcolor{red}{功耗密度(power density)}是能耗与芯片面积的比。\textcolor{red}{耗能(energy)}为平均功率与运算事件之积。功率受限于功能等基础设施,功耗密度受限于散热,总耗能受限于电池容量或电费。
在以前,如果 feature size 即芯片边长变成原来的 $1/S$,电压和电流同样能变成 $1/S$。于是有
\begin{tabular}{|c|c|c|}
\hline
特征 & 面积 $A$ & 电容 $C$ \\
$1/S$ & $1/S^2$ & $1/S$ \\
\hline
电压 $V$ & 电流 $I$ & 频率 $f=I/CV$ \\
$1/S$ & $1/S$ & $S$ \\
\hline
能耗 $E=CV^2$ & 功率 $P=CV^2f$ & 功耗密度 $P/A$\\
$1/S^3$ & $1/S^2$ & $1$\\
\hline
\end{tabular}
若芯片过小,电流和电压不再能 scaling,于是就只有
\begin{tabular}{|c|c|c|}
\hline
特征 & 面积 $A$ & 电容 $C$ \\
$1/S$ & $1/S^2$ & $1/S$ \\
\hline
电压 $V$ & 电流 $I$ & 频率 $f=I/CV$ \\
$\color{red}{\sim 1}$ & $\color{red}{\sim 1}$ & $\sim S$ \\
\hline
能耗 $E=CV^2$ & 功率 $P=CV^2f$ & 功耗密度 $P/A$\\
$\color{red}{\sim1/S}$ & $\color{red}{\sim 1}$ & $\color{red}{\sim S^2}$\\
\hline
\end{tabular}
于是供能成为主要限制,必须限制对频率的 scaling 或者减少芯片功效(例如 \textcolor{blue}{dark silicon},即有部分芯片不上工)。
我们现在更在意一些归一化的 metric。例如 Perf/Area,Perf/\$,或是 $\text{Energy Efficiency} = \dfrac{\text{Perf}}{\text{Power}}=\dfrac{\#\text{Ops}}{\text{Energy}}$。表现与耗能有一条 Pareto 最优的曲线,在特定的需求和限制下,二者有 tradeoff。
最后是 scalability,即 speedup 曲线与处理器数量 $n$ 间的斜率。多个处理器时,量变可以引起质变(例如 model parallelism 可以把数据完全 cache),就可以获得多于 $n$ 的 scalability。但是一般只有亚线性的 scalability。
\textcolor{red}{Strong Scaling}:所有处理器总工作量恒定。\textcolor{red}{Weak Scaling}:每个处理器各自工作量恒定。显然前者更困难。
scalability 面临很多挑战:首先是工作量无法被均分,总耗时被分配最多工作量的节点所限制。有 \textcolor{blue}{static load balancing}—但是需要复杂的预测机制;也有 \textcolor{blue}{dynamic load balancing} 例如 \textcolor{blue}{work dispatch}, \textcolor{blue}{work stealing} 等。另外还有 \textcolor{red}{Amdahl 律}:若有 $f$ 的比例可以被加速 $S$ 倍,则总加速为 $\dfrac{1}{1-f+\tfrac fS}$。目标是让能并行的比例 $f\to1$,但是就算过度优化 $S\to\infty$,最优也只能到 $1/(1-f)$—不常见的场合终归会变得常见。启示:不要过度优化一个东西;但也不要忽视任何优化的机会(优化一个出现频率 1/1000 的指令可能是有价值的—它的耗时可能非常高!)。
\textcolor{red}{benchmark}:一些可以衡量表现的任务。\textcolor{red}{benchmark suite}:一组可以衡量表现的任务。在 benchmark suite 中,一般使用 speedup 的几何平均来衡量总表现。为什么:按照经验法则,对于\textcolor{red}{绝对值 (absolute)},要使用算术平均;对于\textcolor{red}{比率 (rate)}(即 work/cost),要使用调和平均 $\text{hmean}=\dfrac{n}{\sum1/x_i}$;对于\textcolor{red}{比值 (ratio)}(即两个同类型值间比值),要使用几何平均 $\sqrt[n]{\prod x_i}$(因为 $\text{gmean}(a/b)=\text{gmean}(a/c)/\text{gmean}(b/c)$。)Queries per Second 应该使用调和平均,IPC 同理,但 CPI 就不行(因为它是 cost/work)。ET 是绝对值。speedup 是比值。
\section*{ISA}
两种 ISA Style:\textcolor{red}{RISC},如 RISC-V 和 MIPS,指令少而简单;\textcolor{red}{CISC},如 x86,指令多而复杂。
RISC-V 也有好多变种。例如,关于整数集的变体就有 RV32I、RV64I、RV128I 等,此处我们关注 RV32I。
PC 是一个 32b 整数。有 32 个\textcolor{red}{通用}寄存器(\texttt{x0} 至 \texttt{x31}),此外还有如服务浮点数的其它寄存器。内存使用 Byte 为单位,地址从 \texttt{0} 到 \texttt{MSIZE-1}。地址对齐到 $4$ 位,因此所有的地址都以 \texttt{00} 结尾。
\texttt{x0} 至 \texttt{x31} 的寄存器有其别名:
\begin{itemize}
\item \texttt{zero(x0)} 永远是 \texttt{0},且向其写入的行为是无效的。
\item \texttt{ra,sp,gp,tp(x1,x2,x3,x4)} 分别是 return address,stack pointer,global pointer,thread pointer。
\item \texttt{t0—t6(x5—x7, x28—x31)} 是临时变量。
\item \texttt{s0—s11(x8,x9,x18—x27)} 存储某些需要存储的值。
\item \texttt{a0—a7(x10—x17)} 存储参数和返回值。
\end{itemize}
所有指令都是固定的 32 位指令(不管是 RV32I 还是 RV64I 均是)。
三寄存器参数指令的格式统一为 \texttt{<op> rd, rs1, rs2},等效于 \texttt{rd} = \texttt{rs1} \textit{op} \texttt{rs2}。此处的 \texttt{<op>} 可以为 \texttt{add, sub, and, or, xor}。出现溢出时,最高位总是会被干掉,不管这是否是 bug(在 \texttt{rs1, rs2} 均为无符号时,这不是 bug;但有符号时,这是 bug)。
有时我们需要\textcolor{red}{立即值(immediate value)},即字面值,此时指令为 \texttt{<op> rd, rs1, imm},其中 \texttt{<op>} 可以为 \texttt{addi, andi, ori, xori} 之一。\texttt{imm} 是\textcolor{blue}{十二位有符号}整数,这就是为什么我们不需要 \texttt{subi}。为了保证指令长度严格是 32b,此处的 \texttt{imm} 只能是 12b。\textcolor{orange}{因为是有符号,所以它在实际执行时,会被硬件自动使用符号位扩展至 32b。}
移位命令是 \texttt{sll, srl, sra},其中逻辑移位(即 \texttt{l} 结尾的)补 0,数学移位补最高位。也可以使用对应的立即值版本 \texttt{slli, srli, srai},此处的立即值也被记作 \texttt{shamt},因为与常规 \texttt{imm} 的十二位有符号不同,移位立即值是一个\textcolor{blue}{五位无符号}—毕竟至多只能移位 32b。
比较有 \textcolor{red}{set less than} \texttt{slt rd, rs1, rs2}:若 \texttt{rs1 < rs2} 则 \texttt{rd=1},否则为 \texttt{0}。同理有无符号和/或立即值版本的 \texttt{sltu, slti, sltiu}。
有一些 \textcolor{red}{伪指令 (pseudo instruction)},可以自动被翻译为实际的 ISA 指令。这些伪指令是在指令集中写好的,不能任意自定义(其意义在于,对硬件实现的需求减少)。还是得列一下:\texttt{sltz rd, rs = slt rd, rs, zero; srtz rd, rs = slt rd, zero, rs; mv rd, rs = addi rd, rs, 0; nop = addi zero, zero, 0; j L = jal zero, L; jr rs1 = jalr zero, rs1, 0}。
通过 load 和 store 与内存交互:\texttt{lw rd, offset(rs1)} 和 \texttt{sw rs2, offset(rs1)}。\texttt{offset} 是一个\textcolor{blue}{十二位有符号}整数。这会将 4B 的数据整个读取到 \texttt{rd} 这个寄存器中。倘若只要读写 2B 的数据,使用 \texttt{lh, lhu, sh},其中 \texttt{u} 控制读取数据时,高位是补 $0$ 还是补符号位;而 \texttt{sh} 将寄存器的低 16 位写入目标地址,不需要操心符号位。1B 数据则是 \texttt{lb, lbu, sb}。此外,\textcolor{orange}{\texttt{lw/sw} 时,地址必须 4-对齐;\texttt{lh/lhu/sh} 时,要 2-对齐。}
因为 RISC-V 是小端序,所以如果在 A 处写入 \texttt{0x12345678} 时,则 A 会是 \texttt{0x78},A+1 会是 \texttt{0x56},以此类推。
将立即值存入寄存器时,因为 \texttt{addi rd, zero, imm} 只能支持 12 位立即值,所以有一个支持 \textcolor{blue}{20 位立即值}的 \texttt{lui rd, imm},可以将其存在 \texttt{rd} 的高 20 位中,然后低 12 位置 0。此外,有一个 \texttt{auipc rd, imm} 可以将 PC 的值加上高 20 位的 \texttt{imm} 存储在 \texttt{rd} 中。最常用的是 \texttt{auipc rd, 0} 获取 PC 值。这种二十位立即值因为直接置于最高位,所以不涉及到符号位的问题。\textcolor{orange}{同时,假如要使用 \texttt{addi} 和 \texttt{lui} 结合赋值,要注意 \texttt{addi} 的符号位补全性质:假如低 12 位的最高位是 $1$,则需要手动给高 $20$ 位加一。\texttt{0xdeadbeef} 要被解释为 \texttt{0xdeae} 和 \texttt{0xbeef}。}
操纵程序的执行流程。\texttt{jal} 会存储 PC+4 并跳到对应标签,\texttt{jalr} 会存储 PC+4 并跳到寄存器指向的位置。\texttt{j} 和 \texttt{jr} 是这两者将 PC+4 存储到 \texttt{zero} 的伪指令。
\begin{verbatim}
while(Test)Body for(Init;Test;Update)Body
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
jump Cond Init
Loop: Body jump Cond
Cond: branch Test, Loop Loop: Body
Update
Cond: branch Test, Loop
\end{verbatim}
\texttt{switch} 可以用一车 \texttt{if-else} 模拟,但不优雅。可以选择令一个 \texttt{jtable} 中存储所有分支的地址,然后查表并跳过去。\textcolor{orange}{查表并跳转是 \texttt{jr} 应用的一个场景。}
ISA 使用了一套默认的方法调用约定:
\begin{itemize}
\item 在调用一个函数后,PC 要回到调用处。因此,caller 使用 jump-and-link 模式调用函数,即 \texttt{jal ra, L}(简写为 \texttt{jal L}):其会将 \texttt{PC+4} 也即下一个指令的位置记录到 \texttt{ra} 中;从函数中返回时,则使用 \texttt{jr ra},也即跳转到 \texttt{ra} 所指向的位置。
\item 调用函数时,将前八个参数放在 \texttt{a0—a7},剩下的存在栈中(即使不足八个,栈上也会预留空间);函数返回时,将前两个参数放在 \texttt{a0,a1},剩下的同样存在栈中。
\item 为了避免寄存器冲突,caller 和 callee 各自需要保存一些信息。\texttt{ra, t0—t6, a0—a7} 由 caller 保存,\texttt{sp, fp, s0—s11} 由 callee 保存。
\item 栈使用 \textcolor{red}{stack pointer \texttt{sp}} 和 \textcolor{red}{frame pointer \texttt{fp}} 描述。\texttt{fp} 始终指向该函数被创建时的栈底,为方便在 \texttt{sp} 不知道跑哪去了时定位。在函数需要的栈空间(即 \textcolor{red}{framesize})确定时,也可以忽略 \texttt{fp},直接全程使用 \texttt{sp}。栈使用降序排序—这意味着入栈时指针应该减少。\textcolor{orange}{\texttt{sp} 应该保证 16B 对齐。}
\item 调用函数的流程:caller 保存归它保存的东西,准备参数,然后调用 callee;callee 移动 \texttt{sp}\textcolor{orange}{(这个发生在所有东西的前面!也即,保存东西时,是在 \texttt{sp} 上加值—因为它们已经被预进栈了)},保存归它保存的东西(如果这个东西在中途没有被修改,那就可以不保存);然后 callee 正常执行逻辑,恢复存储,返回结果;然后 caller 再恢复存储。
\end{itemize}
\section*{Binary Format}
所有的指令都被翻译为 32b 的内存对齐二进制码。其在末尾有一个 7b \texttt{opcode},标记其指令类型,以便于在早期启用控制逻辑。这一点在 CISC 指令上更加有用,因为其指令常常是变长的,而如 MIPS 等其它 RISC 指令则可能出现寄存器编码区不固定的现象,这不利于投机。共有六种不同的 encode 模式。
\textbf{R 格式} 是 register-register 格式。
\noindent\begin{tabular}{|c|c|c|c|c|c|}
\hline
7\texttt{func7}&5\texttt{rs2}&5\texttt{rs1}& 3\texttt{func3}&5\texttt{rd}&7\texttt{opcode}\\
\hline
\end{tabular}
其中 \texttt{func7, func3, opcode} 都是用来标记指令类型的。\textcolor{orange}{这么做是为了与其它指令类型保持一致。}\textcolor{red}{注意读取顺序是从最低位到最高位,与指令中的顺序「相反」。}
\textbf{I 格式} 是 register-immediate 格式。此外 \textcolor{red}{\texttt{load}} 和 \textcolor{red}{\texttt{jalr rd, rs1, imm}—跳到 \texttt{rs1} 中存储值偏移 \texttt{imm} 位,并把当前 PC+4 存储到 \texttt{rd} 中}—也属于这种格式。为什么 \texttt{save} 不属于这种格式?因为它没有 destination \texttt{rd}。
\noindent\begin{tabular}{|c|c|c|c|c|}
\hline
12\texttt{imm}&5\texttt{rs1}& 3\texttt{func3}&5\texttt{rd}&7\texttt{opcode}\\
\hline
\end{tabular}
\textcolor{blue}{特别地,\texttt{shift} 只有立即值的后五位有效,前七位的效果类似 \texttt{func7}。}
\textcolor{orange}{I 格式与 R 格式尽量对齐了。}
\textbf{S 格式}是 store 使用的格式。其有两个 source 即基地址 \texttt{rs1} 和数据 \texttt{rs2}。
\noindent\begin{tabular}{|c|c|c|c|c|c|}
\hline
7\texttt{imm}$_{[11:5]}$&5\texttt{rs2}& 5\texttt{rs1}&3\texttt{func3}&5\texttt{imm}$_{[4:0]}$&7\texttt{opcode}\\
\hline
\end{tabular}
其中的 \texttt{imm} 被拆成两段了。
\textcolor{orange}{对于全部六种格式,只要 \texttt{rd/rs1/rs2} 出现了,它们就会被存在相同位置。这么做可以提供快速提取的可能性—即使我们还没有解码指令类型。假如发现提取出来的东西不对,直接丢掉即可。}这是一种 \textcolor{red}{speculation} 的行为—它预执行了一些可能有用的便宜操作。如 MIPS 等有不同的编码方式,因此需要先解码指令类型再访问寄存器。
\textbf{U 格式}处理 \texttt{lui} 和 \texttt{auipc}。
\noindent\begin{tabular}{|c|c|c|}
\hline
20\texttt{imm}&5\texttt{rd}&7\texttt{opcode}\\
\hline
\end{tabular}
\textbf{B 格式}处理 branches。但是 label \texttt{L} 是一个 32b 的 PC 地址,如何压缩存储呢?注意到 branch 一般只在 if-else 和循环中使用,因此目标往往与指令很接近(长程调用一般用 jump),所以使用与当前 PC 的相对误差 encode。\textcolor{orange}{这样做还有一个好处,就是将代码以 block 形式移动时,这个值不会改变。}但是如果 branch 的目标真的很远,就要 fallback 到使用 \texttt{jump} 来无条件跳转的场合,换句话说就是把一句指令翻译为两个。
\noindent\begin{tabular}{|c|c|c|c|c|c|}
\hline
7\texttt{imm}$_{[12|10:5]}$&5\texttt{rs2}& 5\texttt{rs1}&3\texttt{func3}&5\texttt{imm}$_{[4:1|11]}$&7\texttt{opcode}\\
\hline
\end{tabular}
令 branch target = current PC + imm$_{[12:1]}\times2$,换句话说是忽略最后一位—这是因为指令地址总是 2B 的倍数(存在 16b 指令)。以上述神秘格式存储是因为我们总是希望最高位是符号位,而除了 bit11 外,其它 bit 都存在原始位置上。
\textbf{J 格式}只对 \texttt{jal} 有效(\texttt{jalr} 是 I 格式)。和 B 格式相同,忽略 \texttt{imm[0]} 并编码 \texttt{imm[20:1]}。
\noindent\begin{tabular}{|c|c|c|c|c|c|}
\hline
20\texttt{imm}$_{[20|10:1|11|19:12]}$&5\texttt{rd}&7\texttt{opcode}\\
\hline
\end{tabular}
它只能跳 $\pm2^{20}$ 的距离。至于更远,可以使用寄存器辅助,或者也可以跳多次。不管怎么说,这是一个不常见场合,只要保证它能跑就行。
\section*{SIMD}
使用一条指令修改多个计算。可以增加带宽并均摊 fetch instruction 的开销。
以 x86 的 AVX 扩展为例,其中有 \texttt{xmm, ymm, zmm} 三种寄存器,它们是重叠的:\texttt{ymm} 是 \texttt{zmm} 的低 256b,\texttt{xmm} 是 \texttt{ymm} 的低 128b,因此同时使用时要注意。
这么做显然是有问题的:它每次扩展 vector 长度都要引入新指令,由此原始代码就要修改,旧代码完全无法从新架构中受益。
所以 RISC-V 的 vector 扩展采用了不同路子,在指令环节允许变长的 vector length,这样软硬件的优化自此解耦。
有 \textcolor{red}{\texttt{v0} 至 \texttt{v31}} 的 \textbf{vector register},每个从硬件长度上长度为 \textcolor{red}{\texttt{VLEN}}。有一个 \textcolor{red}{\texttt{vl}} 的 \textbf{vector length register},从软件角度上存储了 vector length 的设定。有一个 \textcolor{red}{\texttt{vtype}} 的 \textbf{vector type register},存储了一些 configuration。
\texttt{vtype} 包括以下信息:\textcolor{red}{\texttt{LMUL}} 是 \textbf{vector register grouping multiplier},即操控可以将几个向量寄存器拼成一个大向量。例如,\texttt{LMUL}=8 时,\texttt{v0:7} 拼成第一个,\texttt{v8:15} 拼成第二个,以此类推。\textcolor{blue}{\texttt{vtype} 的最低 3 位存储 \texttt{vlmul}},即有 $\mathtt{LMUL}=2^{\mathtt{vlmul}}$。\textcolor{red}{\texttt{SEW}} 是 \textbf{selected element length},即操控当前在意的元素的位长。必须保证 \texttt{SEW} 是 \texttt{VLEN} 的因数(以保证 element 能在一个向量寄存器里塞得下—所以最高的数据粒度是 \texttt{VLEN})。\textcolor{blue}{\texttt{vtype} 的次低 3 位存储 \texttt{vsew}},即有 $\mathtt{SEW}=2^{3+\mathtt{vsew}}$—所以最低的数据粒度是 Byte。
\texttt{vtype} 和 \texttt{vl} 作为寄存器,自然可以使用常规寄存器指令进行赋值。但是可以使用一些特殊指令来同时赋值二者:对于 \texttt{vl},其提供一个 \textcolor{red}{application vector length (AVL)},并令 $\mathtt{vl}=\min(\mathtt{AVL},\mathtt{VLMAX}=\mathtt{LMUL}\times\mathtt{VLEN}/\mathtt{SEW})$。其中,\texttt{VLMAX} 是当前设置下最多能操纵的元素数(每个向量寄存器被拆分为 \texttt{VLEN/SEW} 个小段;共 \texttt{LMUL} 个向量寄存器),但\textcolor{orange}{有时我们不想操纵所有元素(例如,循环的收尾),所以就要靠 \texttt{vl} 标记我们到底想操纵多少个元素。}而对于 \texttt{vtype},其提供一些宏定义的值以表示「将某段信息设置为某值」。指令是 \texttt{vsetvl rd, rs1, rs2};\texttt{vsetvli} 把 \texttt{rs2} 换成立即值版的 \texttt{vtypei};\texttt{vsetivli} 额外把 \texttt{rs1} 换成立即值版的 \texttt{uimm}。\texttt{rs1} 存储 AVL,\texttt{rs2} 存储 \texttt{vtype}。更新后的 \texttt{vl} 会被同步到 \texttt{rd} 中。
总结:\texttt{VLEN} 限制最高数据粒度,\texttt{SEW} 进一步切分为更小粒度;\texttt{LMUL} 规定同时操作多少个向量,并计算得到 \texttt{VLMAX} 为最多可同时操纵多少个元素,而 \texttt{vl} 规定到底操纵多少个元素。
然后是加减法等操作。记住如果加减标量,其实是\textcolor{orange}{把这个标量广播后再加减}即可。因此有专门操作来处理比如说\textcolor{orange}{被减数是标量}的场合。格式是 \texttt{vxxx.vv/vx/vi vd,vs2,vs1},其中 \texttt{vv} 是向量对向量,\texttt{vx} 是向量对寄存器标量,\texttt{vi} 是向量对立即值标量。
然后是内存访问。很常见地,我们有三种访问模式:\textcolor{red}{sequential},即访问一段连续段。其读取格式为 \texttt{vle<w>.v vd, (rs1)},其中 \texttt{<w>} 指明了数据在内存中存储的位长(\textcolor{blue}{8/16/32/64}),\texttt{vd} 是要存储至的向量寄存器,而 \texttt{rs1} 为内存地址(\textcolor{blue}{与普通加载不同,此处没有偏移量})。\textcolor{orange}{在从内存读取数据后,其会被补充/截断至与 \texttt{SEW} 对齐后存入寄存器的对应位置。}\textcolor{red}{constant-strided},即以固定步长访问。格式为 \texttt{vlse<w>.v vd, (rs1), rs2},最后一项是以 B 为单位的步长,因此 sequential 就是 \texttt{rs2=<w>/8} 的特殊场景。\textcolor{red}{indexed},适用于 scatter 或 gather 的场合,格式是 \texttt{vluxei<w>.v vd, (rs1), vs2},其中 vs2 储存了以 B 为单位的偏移。把 \texttt{l} 换成 \texttt{s} 就是对应的 store 指令。
上述所有东西全部没有显式地与 VLEN 交互。例如,在访问一个数组时,可以直接使用 \texttt{AVL=n;n-=vl} 的方式来自适应于 \texttt{VLEN} 的升级。
写多了 pytorch 的人就会很想要一个 mask。可以使用一些 compare 操作例如 \texttt{vmslt.vv rd, rs1, rs2} 获取比较结果,比较结果是全零或全一。虽然此处的 \texttt{rd} 可以为一切向量寄存器,但一般只使用 \texttt{v0}—因为编译器只认 \texttt{v0}:可以在前面提到的绝大多数指令后面跟一个 \textcolor{red}{\texttt{v0.t}},其会检查 \texttt{v0} 对应位置的 \textcolor{blue}{最低位},如果是 1 则不会跳过该位置,否则会跳过。\textcolor{orange}{被跳过的位置,无论是内存还是寄存器值,都不会被触动。}
综上,ISA 比 AVX 等架构的好处在于其与硬件寄存器位长无关 (agnostic),在迁移性和灵活性角度都更牛。
\section*{Digital Logic}
跨时钟周期的地址、数据、控制信号等元素构成 \textcolor{red}{状态元素 (state element)},在电路中需要被存储;\textcolor{red}{组合逻辑 (combinational logic)} 则是在每个时钟周期中,由上个周期结束时的状态计算得到下个周期的状态的过程。
一个处理器一般包含两部分:\textcolor{red}{datapath}—同时包含逻辑回路用于处理数据以及状态元素用于存储数据;\textcolor{red}{control}—一般纯粹是逻辑回路,生成控制信号。在硬件上,状态元素是 PC、寄存器表和内存,组合逻辑则有 ALU、MUX(多路复用器—用于转发数据)等。
一个 instruction 的执行被拆为五步:\textcolor{red}{fetch},从 PC 指向处取用指令并更新 PC;\textcolor{red}{decode},将指令从 32b 二进制码解码为对应的逻辑信号,并从其所需寄存器中读取数据;\textcolor{red}{execute},在 ALU 中执行指令;\textcolor{red}{load/store},在需要时从内存中加载或存储数据;\textcolor{red}{store},将运算结果写回目标寄存器。\textcolor{orange}{并非所有操作都需要完整的五步—但是硬件必须实现所有,并依靠控制信号决定实际执行哪些。}
处理器由以下单元组成:fetch 阶段使用的 \textcolor{red}{instruction memory};与寄存器交互的 \textcolor{red}{register file};根据指令编码提取立即值的 \textcolor{red}{ImmGen};ALU;与内存交互的 \textcolor{red}{data memory};一级的 \textcolor{red}{main control} 和二级的 \textcolor{red}{ALU control}。
我们学习的示例处理器有以下信号:\textcolor{red}{RegWrite},要不要把结果写回 \texttt{rd} 对应的寄存器;\textbf{总是有意义;当且仅当指令结构中存在 \texttt{rd} 一项,其为 1}(处理 \texttt{rd=zero} 这种特殊逻辑发生在寄存器内部而非处理器中)。\textcolor{red}{ALUsrc},是从立即值还是从 \texttt{rs2} 中获取值参与计算,\textbf{当且仅当指令涉及 ALU 时,这个值有意义(因此 J 类型的指令其无意义)}。\textcolor{red}{ALUop},告诉 ALU 要执行哪种指令,\textbf{同理在 J 指令中其无意义},\texttt{beq} 时要用 \texttt{SUB},\texttt{lw/sw} 时要用 \texttt{ADD},其它 R/I 类型的看字面意思。\textcolor{red}{Branch},与 ALU 的输出过一个 AND 门决定要不要跳转,\textbf{只在 J 指令中无意义—其它指令中仍然不能随便填,因为 ALU 会对这条路有输出};\textcolor{red}{Jump},与上一个 AND 门的输出过一个 OR 门决定要不要跳转,\textbf{总是有意义;当且仅当 J 指令其为 1}。\textcolor{red}{MemRead/MemWrite},决定要不要从内存中读数据,\textbf{总是有意义—因为这个计算单元总会被访问;只在对应的 \texttt{lw/sw} 时为 1}。\textcolor{red}{MemToReg},决定是将 ALU 输出还是将内存单元输出写回到目标寄存器,\textcolor{red}{只在 RegWrite=1 时有有意义—其它时候反正这玩意写不回去就不管了}。
信号也需要专门的 decode 单元。采取二级解码:第一级只接受 \texttt{opcode},获取除 ALUOp 以外的所有东西;第二级接受第一级的输出,且额外接受 \texttt{func3} 和 \texttt{func7},获取 ALUOp。
现在假设我们必须要在一个时钟周期里跑完整个处理器,那么自然有 CPI=1,而 CCT 为所有 \textcolor{blue}{critical path}(指令中数据的实际流动路径)耗时最大值。这样做好处是设计简单,坏处是时钟周期长、硬件未充分利用且没有并行。
\section*{Pipeline}
所以来点 pipeline。在一段组合逻辑后,一般需要一段 regs 来将数据存入寄存器并增加时钟数;如果把组合逻辑切成多份,每两份间多加一次 regs,就可以在切分后的逻辑间跑并行。一个 $k$-stage pipeline 的频率是 $f=(t_\mathrm{comb}/k+t_\mathrm{reg})^{-1}$,所以流水线长度受限于寄存器开销,一般不会无限制拉长。流水线的 CCT 是所有 stage 的关键路径最大值。
一个问题是升频不等于加速—前几个任务需要时间 \textcolor{blue}{fill/drain},只有在任务数量趋于无穷时才能让加速趋于阶段数。因此流水线只有在大量独立任务时才好用。另一个问题是未均分的任务会在最慢的阶段卡住,且为了跨阶段时储存的信息尽量少,切分的位置前后交互的信息数应尽量少,两者需要兼顾。
于是有一个 5-stage pipeline:\textcolor{red}{IF (Instruction Fetch), ID (Instruction Decode), EX (Execute), MEM (Memory), WB (Writeback)},在阶段间插入 \textcolor{red}{pipeline register}。除了数据,控制信号也要一同在阶段间传输—当然如果一个信号在之后的阶段中不再需要即可不传进下一个阶段。
这么做 CCT 减少了,CPI 在 drain 阶段可能会增加,但理想状态下应该保持 1……吗?有些指令依赖上一个指令的输出,这就要求其必须等待上一个指令运行完才能继续执行。所以此时需要 stall,即冻结前几个阶段的状态,并将关键阶段的信号清空(相当于替换为 \textcolor{red}{nop} 指令),让它等待一个阶段。
Stall 的三种来源:
\textcolor{red}{结构冒险 (Structural Hazard)},多个指令竞争同一个硬件资源—例如若\textbf{寄存器的端口有限},则处于 WB 阶段的指令和处于 ID 阶段的指令可能会竞争,解决方案是区分输入端口和输出端口,或者引入半周期的概念,在前半周期 WB、后半周期 ID。寄存器的有限端口也使得如 R 指令这种与内存无关的指令也\textbf{不能跳过 MEM 阶段}—这是为了简化控制逻辑,且避免引入额外的 WAW 冒险。还有就是当指令与数据在同一个内存中存储时,IF 和 MEM 同样会\textbf{撞内存端口},解决方案是 stall IF(因为这更容易)或使用多个端口、分离 cache 等机制。最后就是有 mult/div 这种\textbf{跨 ALU 周期的指令},解决方案就是要么把它在 ALU 中也切成两个 stage,要么多加一个 ALU(成本!),要么就不管了。总结:\textcolor{blue}{啥都不管直接 stall 靠后的指令};\textcolor{blue}{多排点资源};\textcolor{blue}{使用更好的设计}(例如半周期的寄存器交互以及不允许 R 指令跳步骤)。
\textcolor{red}{数据冒险 (Data Hazard)},指令间存在依赖关系,共有三种:\textcolor{blue}{Read after Write (RAW/True Dependency)},后面指令读取前面指令写入的位置;\textcolor{blue}{Write after Write (WAW/Output Dependency)},后面指令覆盖前面指令的输出;\textcolor{blue}{Write after Read (WAR/Anti Dependency)},后面指令写入前面指令读取的位置。所有的依赖关系都可以针对内存或寄存器。依赖关系客观存在于代码中,但\textbf{可能}会导致\textbf{特定}流水线上的冒险。
首先看寄存器的 RAW。一次 WB 会导致其之后\textbf{两条}指令的 ID 冒险,其之后第三条指令的 ID 即与该 WB 重合,使用之前提到的半周期优化,允许前半周期的输出在后半周期中立刻被输入即可。在不加 forward 时,解决方案是在下一条指令的 IF 后(此时当前指令的 ID 过了,知道它什么了)stall 两次。
至于 WAW、WAR,以及关于内存的依赖关系,在裸的五阶段 RISCV 处理器上,易知其不会产生冒险。但是对于其它比如说有 OoO 能力的处理器,就另当别论了。
除了 stall,另一种方法是 \textcolor{red}{forward}。对于 \texttt{add} 等数学运算,EX 过后答案其实已经算好了,只不过还没有 WB 回去,因此可以直接布线给下一条指令,取代其刚刚在 ID 阶段取来的寄存器信息;而对于 \texttt{load},其要等 MEM 后才有结果,因此还是得 stall 一个周期,不过比起原来的两周期已经好很多了。
要想能 forward,需要加一堆 MUX。其起点是 EX 态(计算指令)或 MEM 态(读取指令)结束时的 register 状态,终点是真正使用这些值的态(同样是 EX 或 MEM)。假设是一个在 EX 阶段用值的计算指令,则其会查看 \texttt{ID/EX.ALUop}、\texttt{ID/EX.RegisterRd/Rs1/Rs2} 这些存储其任务的信号,然后对比 \texttt{EX/MEM} 和 \texttt{MEM/WB} 阶段的信号和寄存器,如果检测到匹配的寄存器名称(\textcolor{orange}{\texttt{x0} 除外,对它的任何交互都不会引起数据冒险}),则出现数据冒险,需要通过 MUX 直接从对应的阶段寄存器处读取数据。特别的场景是\textcolor{red}{双重数据冒险},前两条指令的 \texttt{rd} 都是同一个寄存器,此时应该从较新者也即 \texttt{EX/MEM} 阶段寄存器处获取数据。
从 \texttt{EX/MEM} 到 \texttt{ID/EX} 的 forward 要求 \texttt{EX/MEM.RegWrite};\texttt{EX/MEM.Rd!=x0};\texttt{EX/MEM.Rd==ID/EX.Rs1/Rs2};以及 \texttt{\textcolor{red}{!EX/MEM.MemToReg}},确保上一条不是 \texttt{load}(此时要 stall)。同理,如果要从 MEM/WB 到 ID/EX 则要多一条「未从 EX/MEM forward」。最后,当 \texttt{ID/EX.MemRead}(表示此时是 load)且 \texttt{ID/EX.Rd==IF/ID.Rs1/Rs2} 时,当前处于 IF/ID 的东西在\textcolor{orange}{跑完当前的 ID 后},要 stall 一回合,下一个时刻的指令在跑完当前的 IF 后要连带 stall 一回合。注意:只有 IF/ID 阶段才会检测 stall,\textcolor{orange}{ID 阶段本身是必然会跑完的}。
编译器可以尝试手动把一些后方的不引起依赖关系的语句提前,以避免 stall。当然编译器如果办不到,还是有硬件的 stall 兜底。但是现在大家都不这么搞了,因为有 OoO 了!
最后是 \textcolor{red}{控制冒险 (Control Hazard)}。jump 或 branch 会导致 PC 的值不确定,在 MEM 结束后,PC 才会更新为目标值,所以这就有整整三个 stall。一种做法是把这两者的逻辑直接一股脑迁移到最早能实现之的位置,也就是 ID 阶段。这样做会延长 ID 阶段的关键路径,在 ID 是瓶颈时可能会降低频率,但优势是三个 stall 被降成一个…吗?其实是下一条指令总是必须等上一条的 ID 阶段跑完才能进去,也就是说这一个 stall 是非等不可了。解决方法是来点投机。先把 PC+4 读进去,如果等 ID 搞完后发现不对就把读进去的东西干掉就行。\textcolor{orange}{但是,无论如何,所有指令都会经历完整的五个 stage—哪怕你是提前到 ID 阶段的 branch,后面几个 stage 压根没事干也不能跳过。}
但是问题是提前 branch 到 ID 这个行为本身可能会引起更多的数据冒险,导致 ALU 的计算结果现在需要 stall 一轮、load 则是两轮,这期间 branch 一直赖在 ID 态,于是下一条投机的指令也只能跟着赖在 IF 态。
\textcolor{blue}{由 load 等指令对常规指令的 stall 发生在 ID 之后—因为其对应的结果会直接被 forward 到 EX 态。但是,对 branch 的 stall 发生在 ID 之前—因为对应的结果仍然会 forward 到 ID。换句话说,被 forward 到哪,哪里就不 stall。}
\textcolor{blue}{假如想要通过重排最小化 stall,则 load 下面一回合内不能出现常规指令依赖,两回合内不能出现 branch 依赖;常规指令下面一回合内不能出现 branch 依赖。}
\section*{Exception\&Interrupt}
以上都是程序内部的控制。系统态的改变,比如说除零、地址越界等 exception,或是网络传输、键盘输入等 interrupt,则需要操作系统和硬件介入了。处理器会存储 PC 到 \textcolor{red}{SEPC},存储出现例外或打扰的原因到 \textcolor{red}{SCAUSE},跳到 \textcolor{red}{handler} 也即一段存储于某个特定地址的程序,其使用一堆 if/switch 等语句尝试处理这个问题,如果处理成功则用 SEPC 跳回原本出问题的地方,否则终止程序并使用它们报错。x86 使用了一种 \textcolor{red}{vectored interrupts} 的机制,把所有的 handler 所在位置存在一个表里,出问题了就查表。如果问题解决方案很短那就就地解决,否则跳到对应的 handler 去处理。
我们希望对 exception 的处理是 \textcolor{red}{precise} 的—这意味着所有其之前的指令都被执行完毕,而其之后的指令一条都未执行。单周期处理器容易实现这一点,但流水线上就比较复杂。具体而言,在出现例外时,继续完成例外指令之前的所有指令(它们位于例外位置之后的 stage),然后把例外以及之前的 stage 全都清空,然后再从 handler 处继续取指令—就像是投机预测失败了一样。
\section*{Advance Processor}
以上的投机只能机械地加载 PC+4 的内容,不优雅。可以来点动态投机。例如,编译器或程序员可以手动给出 branch 的预测;不过更常用的是硬件上基于历史的预测。
\textcolor{red}{Branch History Table} 在 branch 前查表,来预测该 branch 会不会跳转。一个 $2^m$ 项的 BHT 会采取 PC 中的 $m$ 位作为关键字查表。\textcolor{orange}{其不记录 tag:一方面是为了省内存,表的每一项只需要 2b,远小于 tag 长度;另一方面就算真的出现了 aliasing 也即两个 PC 不同的 branch 指令映到同一位,预测失败的代价也不过是一次 flush,可以接受。}其维护一个 \textcolor{blue}{saturate counter},branch 每采用一次就增加一(但是到上限不再增加),未被采用则减一(到下限就不再减少)。counter 的位数越大,鲁棒性越好,但适应性则越差,这是一个 tradeoff。一般用 2b 就够了。
\textcolor{red}{Branch Target Buffer} 则在 BHT 预测跳转后,进一步预测跳转地址—这是因为如循环等场合,PC offset 是常数。\textcolor{orange}{函数返回处的 \texttt{jalr} 则不是常数,此时这种 cache 技巧失效,但有一些基于栈的技巧。}BTB 会存储上次跳转的目标地址,且与 BHT 不同,会额外记录 tag。\textcolor{orange}{这是因为目标地址和 tag 差不多长,此时加 tag 的相对代价较低;另一方面如果出现 alias 则会挤占原本的数据,代价更大。}也可以直接将 BHT 和 BTB 放到同一个表里。\textcolor{orange}{一切涉及到非线性执行的指令,如 \texttt{branch, jr, jalr},都可以被 BTB handle。}
流水线可以减少 CCT,但因为会产生 stall,CPI 会增加—流水线越复杂,CCT 的减少效果越不明显,且冒险越常见。另一种想法是在一个周期内跑多个指令,增加 IPC。
对于一个指令序列,定义 $T_1$ 是单个计算单元需要的周期数也即 \#inst,定义 $T_\infty$ 是无穷个计算单元需要的周期数,也即数据在这个序列中的 critical path 长度。定义 \textcolor{red}{Instruct Level Parallelism} $\mathrm{ILP}=T_1/T_\infty$,其是该序列能达到的 IPC 的上界。
一种想法是 \textcolor{red}{Superscalar},一个 $N$-way superscalar pipeline 允许至多 $N$ 条指令处于同一个 stage。其 $\mathrm{CPI}_\mathrm{base}=1/N$,但是 $\mathrm{CPI}_\mathrm{stall}$ 可能更大。硬件上,基础路径开销是 $O(N)$ 的,而控制指令开销则会超过 $O(N)$。
\textcolor{red}{Out-of-Order (OoO)} 则不拘泥于顺序执行整个序列,在 superscalar 的 frontend 输入多条指令后,其会打乱它们以取得最佳的 stall 实践。一个典型的 OoO 流水线分为以下步骤:
\textcolor{red}{Fetch\&Decode}:这必须 \textcolor{blue}{in-order} 执行,原因是总是要读取完前一条指令,才能知道下一条指令的 PC 在哪里。解决方案是一次读一大块,挑出从当前 PC 直到第一个被预测会跳转的 branch,并结合复杂的分支预测结构。decode 同样会并行执行,且 CISC 式的复杂指令也会被解码为 RISC 式的简单指令—OoO 的后端其实总是 RISC 式的处理器。
\textcolor{red}{Dispatch/Rename/Allocation}:\textcolor{orange}{WAW 和 WAR 的依赖并非真的依赖,它们都可以通过增加寄存器数目规避}。于是在硬件上使用比 \textcolor{blue}{architectural register} 数目更多的 \textcolor{blue}{physical register},并动态将对 \texttt{ri} 的写入重定向为 \texttt{pj} 的写入。这个重定向也必须 \textcolor{blue}{in-order} 执行,因为当前的运算对象必须查 AR→PR 的表;如果发现 PR 被用光了,还要 stall。但是一旦重命名完成,之后的执行就可以是纯粹 OoO 的了—其仅仅影响什么时候我们可以释放 PR。
\textcolor{red}{Issue/Schedule}:维护一个 \textcolor{red}{Instruction Window},每 dispatch 完一个就 in-order 地丢进去,而 issuing unit 会 OoO 的从中选择执行。IW 需要足够大,现代处理器中一般是一两百的规模。至于选哪些执行,当遇到有多个 RAW 依赖被解决且对应的功能单元空闲(没有结构冒险),也即准备完毕的的指令时,优先选择那些能够解锁更多后继的指令来执行。这就是 \textcolor{red}{scheduler} 的活:使用一种 \textcolor{blue}{priority heuristic} 来排序,例如入队时间、依赖关系、期望延时等。
\textcolor{red}{Commit/Retire/Write-Back}:将执行好的指令写回去。当然我们还是希望有 excise 的,所以 commit 必须也是顺序的,此时已经执行完但尚未 commit 的指令被认为是投机的。每个周期检查排在最前面的一个或数个指令,如果出现了 exception 或错误投机,则需要丢弃后面的所有计算并 flush 整条流水线。
上述功能通过 \textcolor{red}{Re-Order Buffer (ROB)} 实现:一个指令在 dispatch 时会 in-order 地入队至 buffer 的尾部,在 execute 时会 out-of-order 地更改对应的 ROB 项的结果,在 commit 时 in-order 地出队。其中的每一项包括 PC、目标地址或寄存器编号、计算出的新值(只在 ready 后才有效)、是否 ready 等,整体是一个环状数组。
总结:OoO 的流程包括:fetch 时,以\textbf{块}为单位读取当前 PC 后面的指令,但是还是得\textbf{顺序}一个个检查过去,碰到了 branch/jump 要预测,如果预测的结果是跳转,那就扔掉块后面读取的指令,然后从预测地址开始继续读取。dispatch 时,还是得\textbf{顺序}检查过去并重定向,从 AR→PR 表中获取空闲的物理寄存器,重命名后即进入 instruction window 同时 ROB 入队。issue 会从 IW 中\textbf{乱序}选取东西处理,但要保证准备完毕(也即没有 RAW 依赖和功能单元的结构冒险),issue 后从 IW 中移除该指令、广播完成通知其它依赖它的指令、将结果写入 ROB 并标记 ready;如果 issue 过程中发现 fetch 时预测出锅,就立刻开始 flush,通知 fetch、dispatch 从正确 PC 接受指令,并丢弃所有错误加载的指令。最后 commit 会\textbf{顺序}检查 ROB,丢弃那些在预测出锅被检测前就已经处理好且排在出错位置之后的指令,然后将确认无误的东西真正写入处理器表或内存,并释放对应的物理寄存器至 AR→PR 表然后出队。
OoO 的瓶颈包括 fetch 的流量、schedule 的效率以及与内存的交互速率等。
最后,OoO 可以更好地实现指令并行;数据并行靠向量操作;线程并行靠多核。
\section*{Appendix}
使用 \texttt{jr} 的场景:\texttt{return};jump table。
Make common things fast:允许小常数通过立即值的形式直接写在指令中,但大常数必须先通过 \texttt{lui} 等指令加载到寄存器,再互动;J 格式中只支持 $\pm2^{20}$ 的 offset;常用常数 0 被直接写死在 \texttt{x0} 寄存器;固定所有寄存器出现的位置便于投机解码。
\texttt{jalr} 能跳 $\mathtt{rs1}\pm2^{11}$ 位。\texttt{jal} 能跳 $\mathrm{PC}\pm2^{20}$ 位。\texttt{branch} 能跳 $\mathrm{PC}\pm2^{12}$ 位。\texttt{lw} 和 \texttt{sw} 能访问 $\texttt{rs1}\pm2^{11}$。凡是 PC offset 也即 label 都自动乘二。
RISC 操作简单;数据流动简单(要么是寄存器之间,要么只存取数据)、分支指令数目少、编码简单,因此设计更简单,无论是计算还是解码能耗均更少,优化也更容易。但缺点是同样功能的代码指令数目可能更多,取指令的开销可能更大。
\end{multicols}
\end{document}