本章讲对软件性能有直接影响的现代CPU微架构特征。做硬件的不要看了,太基础。
Instruction Set Architecture
大多数现代体系结构可以归类于基于寄存器的loadstore架构,其中操作数明确指定,内存只能通过load/store来访问。除了基本的load store control 标量算术操作(使用整数和浮点型),当前ISA还在增加新的计算模式。vector处理 Intel整了AVX系列,ARM整了SVE,RISC-V整了V extension,matrix/tensor Intel还整了AMX。通常使用这些高级指令,软件的速度会有数量级的提升。
此外,关于AI,现代处理器除了支持32/64 bit精度,还支持了8bit整数(intel VNNI),16位浮点(fp16,bf16)等低精度运算。
流水线的基本原理
工厂流水线
在我们的生活中也存在流水线的思想, 最常见的就是工厂中的流水线. 例如, 某工厂生产产品需要经过组装, 贴纸, 装入包装袋, 装入包装盒, 外检这5道工序. 如果分别用1~5来标识这些工序, 用A, B, C......标识不同的产品, 那么, 不采用流水线方式的时空图如下:
代码语言:javascript复制 ----> 时间
| 产品
| --- --- --- --- ---
V |A.1|A.2|A.3|A.4|A.5|
--- --- --- --- ---
--- --- --- --- ---
|B.1|B.2|B.3|B.4|B.5|
--- --- --- --- ---
--- --- --- --- ---
|C.1|C.2|C.3|C.4|C.5|
--- --- --- --- ---
================================================================
----> 时间
| 员工
| --- --- ---
V |A.1| |B.1| |C.1|
--- --- ---
--- --- ---
|A.2| |B.2| |C.2|
--- --- ---
--- --- ---
|A.3| |B.3| |C.3|
--- --- ---
--- --- ---
|A.4| |B.4| |C.4|
--- --- ---
--- --- ---
|A.5| |B.5| |C.5|
--- --- --- 代码语言:javascript复制而采用流水线方式的时空图如下:
代码语言:javascript复制 ----> 时间
| 产品
| --- --- --- --- ---
V |A.1|A.2|A.3|A.4|A.5|
--- --- --- --- ---
--- --- --- --- ---
|B.1|B.2|B.3|B.4|B.5|
--- --- --- --- ---
--- --- --- --- ---
|C.1|C.2|C.3|C.4|C.5|
--- --- --- --- ---
--- --- --- --- ---
|D.1|D.2|D.3|D.4|D.5|
--- --- --- --- ---
--- --- --- --- ---
|E.1|E.2|E.3|E.4|E.5|
--- --- --- --- ---
================================================================
----> 时间
| 员工
| --- --- --- --- ---
V |A.1|B.1|C.1|D.1|E.1|
--- --- --- --- ---
--- --- --- --- ---
|A.2|B.2|C.2|D.2|E.2|
--- --- --- --- ---
--- --- --- --- ---
|A.3|B.3|C.3|D.3|E.3|
--- --- --- --- ---
--- --- --- --- ---
|A.4|B.4|C.4|D.4|E.4|
--- --- --- --- ---
--- --- --- --- ---
|A.5|B.5|C.5|D.5|E.5|
--- --- --- --- ---
可以看到, 在流水线方式中, 虽然每个产品的生产时间没有减少, 但由于每位员工都能一直保持工作状态, 它们能连续处理不同产品的同一道工序, 使得从总体上来看, 每一时刻都能有一件产品完成生产, 从而提升产线的吞吐.
指令流水线
类比工厂流水线, 处理器也可以用流水线方式来执行指令. 我们把指令执行的过程分为若干个阶段, 让每个部件处理其中一个阶段, 并让这些部件保持工作状态, 可以连续处理不同指令的同一个阶段, 使得从总体上来看, 每个周期都能有一条指令完成执行, 从而提升处理器的吞吐.
在学习总线的时候, 我们已经要求大家把NPC升级成一个分布式控制的多周期处理器. 多周期处理器已经有阶段的概念了, 其工作过程和上述工厂场景中的非流水线方式非常类似. 因此, 指令流水线的工作方式也不难理解了.
我们可以对几种处理器的性能进行简单的分析评估. 假设将处理器的工作分为取指, 译码, 执行, 访存, 写回这5个阶段, 它们的逻辑延迟都是1ns, 且先不考虑取指和访存的延迟.
- 单周期处理器: 阶段间无寄存器, 因此关键路径为5ns, 频率为200MHz. 其中, 一条指令需要执行1周期, 即5ns; 每1周期执行1条指令, 即
IPC = 1. - 多周期处理器: 阶段间有寄存器, 因此关键路径为1ns, 频率为1000MHz. 其中, 一条指令需要执行5周期, 即5ns; 每5周期执行1条指令, 即
IPC = 0.2. - 流水线处理器: 阶段间有寄存器, 因此关键路径为1ns, 频率为1000MHz. 其中, 一条指令需要执行5周期, 即5ns; 每1周期执行1条指令, 即
IPC = 1.
可以看到, 虽然指令执行的延迟仍然是5ns, 但流水线具有频率高和IPC高的优势, 这些优势本质上是由指令级并行技术带来的: 流水线处理器的每个周期都在处理5条不同的指令.
当然, 上面只是从理想情况下分析得到的数据. 如果考虑SoC中的访存, IPC就远远没有这么高了; 此外流水线处理器也并不是总能在每个周期都执行5条指令, 我们会在下文进一步分析.
处理器 | 频率 | 指令执行延迟 | IPC |
|---|---|---|---|
单周期 | 200MHz | 5ns | 1 |
多周期 | 1000MHz | 5ns | 0.2 |
流水线 | 1000MHz | 5ns | 1 |
上面的例子只将流水线划分成5个阶段. 事实上, 我们可以将流水线划分成更多的阶段, 使得每一个阶段的逻辑更简单, 从而提升处理器整体的频率. 这种流水线称为"超流水"(superpipeline). 例如, 如果能将流水线划分成30级, 按照上面的估算, 理论上频率可以达到6GHz.
但目前的主流高性能处理器一般只会将流水线划分成15级左右, 你认为有可能是哪些因素导致不宜将流水线划分成过多的阶段?
下图是一个理想的标量五级流水线

读者需要了解 data hazards , control hazards, structural hazards概念
结构冒险是指流水线中的不同阶段需要同时访问同一个部件, 但该部件无法支持被多个阶段同时访问.
例如, 在下图所示的指令序列中, 在T4时刻, I1正在LSU中读数据, I4正在IFU中取指令, 两者都需要读内存; 在T5时刻, I1正在WBU中写寄存器, I4正在IDU中读寄存器, 两者都需要访问寄存器堆.
代码语言:javascript复制 T1 T2 T3 T4 T5 T6 T7 T8
---- ---- ---- ---- ----
I1: ld | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I2: add | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I3: sub | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I4: xor | IF | ID | EX | LS | WB |
---- ---- ---- ---- ---- 代码语言:javascript复制
部分结构冒险可以从硬件设计上完全避免, 使其不会在CPU执行过程中发生: 我们只需要在硬件设计上让这些部件支持同时被多个阶段访问即可. 具体地:
- 对于寄存器堆, 我们只需要独立实现其读口和写口, 让IDU通过读口访问寄存器堆, 让WBU通过写口访问寄存器堆
- 对于内存, 则有多种解决方案
- 像寄存器堆那样将读口和写口分开, 实现真双口的内存
- 将内存分为指令存储器和数据存储器, 两者独立工作
- 引入cache, 如果在cache中命中, 则无需访问内存
还有一些结构冒险还是无法完全避免, 例如:
- cache缺失时, IFU和LSU还是要同时访问内存
- SDRAM控制器的队列满了, 无法继续接收请求
- 除法器的计算需要花数十个周期, 在一次计算结束之前无法开始另一次
为了应对上述情况, 一种简单的处理方式是等待: 如果IFU和LSU同时访存, 就让一个等待另一个; 等SDRAM控制器的队列有空闲位置; 等除法器完成当前计算. 一个好消息是, 总线天生就具备等待的功能, 因此只要从设备, 下游模块或仲裁器把ready置为无效, 就能把结构冒险的检测和处理归约到总线状态机, 从而无需实现专门的结构冒险检测和处理逻辑.
数据冒险
数据冒险是指不同阶段的指令依赖同一个寄存器数据, 且至少有一条指令写入该寄存器. 例如, 在下图所示的指令序列中, I1要写a0寄存器, 但要在T5时刻结束时才完成写入, 在这之前, I2在T3时刻读到a0的旧值, I3在T4时刻读到a0的旧值, I4在T5时刻读到a0的旧值, I5在T6时刻才能读到a0的新值.
代码语言:javascript复制 T1 T2 T3 T4 T5 T6 T7 T8 T9
---- ---- ---- ---- ----
I1: add a0,t0,s0 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I2: sub a1,a0,t0 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I3: and a2,a0,s0 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I4: xor a3,a0,t1 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I5: sll a4,a0,1 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ---- 上述数据冒险称为写后读(Read After Write, RAW)冒险, RAW冒险的特征是, 一条指令需要写入某寄存器, 而另一条更年轻的指令需要读出该寄存器. 显然, 如果不处理这种数据冒险, 指令I2, I3和I4将会因为读到a0的旧值而计算出错误的结果, 违反指令执行的语义.
解决RAW冒险有多种方式. 从软件上来看, 指令是由编译器生成的, 因此一种方式是让编译器来检测RAW冒险, 并插入空指令, 来等待写入寄存器的指令完成写入, 如下图所示:
代码语言:javascript复制 T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12
---- ---- ---- ---- ----
I1: add a0,t0,s0 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
nop | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
nop | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
nop | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I2: sub a1,a0,t0 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I3: and a2,a0,s0 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I4: xor a3,a0,t1 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I5: sll a4,a0,1 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
插入空指令的本质还是等待, 但其实编译器还可以做得更好: 与其等待, 还不如执行一些有意义的指令. 这可以通过让编译器进行指令调度的工作来实现, 编译器可以尝试寻找一些没有数据依赖关系的指令, 在不影响程序行为的情况下调整其顺序.
不过对于指令调度的工作, 编译器只能尽力而为, 并非总能找到合适的指令. 例如, 除法指令需要执行数十个周期, 编译器通常很难找到这么多合适的指令. 在这些情况下, 如果要让编译器来处理RAW冒险, 还是只能插入空指令.
一个更糟糕的消息是, 有的RAW光靠编译器是无法解决的. 考虑被依赖的指令是一条load指令, 这种RAW冒险称为load-use冒险,感觉就过于复杂了,这里听个大概就好
控制冒险
控制冒险是指跳转指令会改变指令执行顺序, 导致IFU可能会取到不该执行的指令. 例如, 在下图所示的指令序列中, T4的IFU具体应该取出哪条指令, 需要等到I3在T5时刻计算出跳转结果后才能得知.
代码语言:javascript复制 T1 T2 T3 T4 T5 T6 T7
---- ---- ---- ---- ----
I1: 100 add | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I2: 104 ld | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I3: 108 beq 200 | IF | ID | EX | LS | WB |
---- ---- ---- ---- ----
---- ---- ---- ---- ----
I4: ??? ??? | IF | ID | EX | LS | WB |
---- ---- ---- ---- ---- 代码语言:javascript复制除了上述分支指令, jal和jalr也会造成类似问题. 假设上图的I3为跳转指令, 我们期望在T4时刻就取出跳转目标处的指令, 而在T4时刻IDU正好在对I3进行译码, 按道理是可以赶上的, 但现代处理器一般会认为还是赶不上, 从而将其作为控制冒险来处理.
甚至CPU抛出异常也会导致控制冒险. 抛出异常时, 需要马上从mtvec所指的内存位置重新取指, 但通常来说, 处理器无法在取指时刻就得知这条指令的执行是否会发生异常.
上述问题都是因为在取指阶段无法确定接下来真正需要取出哪条指令. 如果选择等待, 就要等待上一条指令几乎执行完成, 才能得知下一条指令的真正地址. 例如, 访存指令要等到访存结束后, 通过总线的resp信号才能确定访存过程没有抛出异常. 显然, 这个方案会使得指令流水线流不起来, 大幅降低处理器执行指令的吞吐. 而如果选择不等待, 就有可能取出了一部分不该执行的指令, 如果不采取进一步的处理措施, 处理器的状态转移就会与ISA状态机不一致, 从而导致执行结果不正确.
为了应对控制冒险, 现代处理器通常采用"推测执行"(speculative execution)的技术. 推测执行本质上是一种预测技术, 其基本思想是, 在等待的同时尝试推测一个选择, 如果猜对了, 就相当于提前做出了正确的选择, 从而节省了等待的开销. 推测执行具体由三部分组成:
- 选择策略 - 得到正确结果之前, 通过一定的策略推测一个选择
- 检查机制 - 得到正确结果时, 检查之前推测的选择是否与正确结果一致
- 错误恢复 - 如果检查后发现不一致, 则需要回滚到选择策略时的状态, 并根据得到的正确结果做出正确的选择
针对控制冒险, 一种最简单的推测执行策略是"总是推测接下来执行下一条静态指令". 从上述三部分考虑这种策略的实现, 具体如下:
- 选择策略 - 非常简单, 只需要让IFU一直取出
PC 4处的指令即可. - 检查机制 - 根据指令的语义, 只有在执行分支和跳转指令, 以及抛出异常时, CPU才有可能改变执行流, 其他情况下都是顺序执行. 因而在其他情况下, 上述推测的选择总是正确的, 无需额外检查. 故只需要在执行分支和跳转指令, 以及抛出异常时, 才需要检查跳转结果与推测的选择是否一致, 也即, 检查跳转结果是否为
PC 4. - 错误恢复 - 如果发现上述跳转结果不为
PC 4, 则说明之前的推测是错误的, 基于这一推测所取出的指令都不应该被执行, 应该将其从流水线上消除, 这一动作称为"冲刷"; 同时还需要让IFU从正确的跳转结果处取指.
推测执行所带来的性能提升与推测的准确率有关, 如果推测的准确率高, 则IFU能以高概率提前取到正确的指令, 从而节省等待的开销; 如果推测的准确率低, 则IFU经常取到不该执行的指令, 这些指令后续又被冲刷, 在这段时间内, 流水线的行为等价于未执行任何有效指令, 从而降低了执行指令的吞吐. 具体地:
- 由于异常属于处理器执行过程中的小概率事件, 绝大部分指令的执行都不会抛出异常, 因此针对异常, 上述策略的准确率接近100%
- 分支指令的执行结果只有"跳转"(taken)和"不跳转"(not taken), 上述策略相当于总是预测"不跳转", 因此从概率上来说, 针对分支指令, 上述策略的准确率接近50%
- 跳转指令的行为是无条件跳转到目标地址, 但目标地址的可能性有很多, 正好跳转到
PC 4的概率非常低, 因此针对跳转指令, 上述策略的准确率接近0%
根据上述分析, 推测执行一方面可以正确处理控制冒险, 另一方面, 相对于消极等待的方式, 推测执行还可以带来一定的性能提升. 但针对分支指令和跳转指令, 上述的推测执行方案还有较大的提升空间, 我们会在下文继续讨论.
关于推测执行的实现, 还有一些需要注意的细节:
- 从需求的角度来看, 冲刷是为了将处理器的状态恢复成发生控制冒险之前的时刻, 因此, 我们可以从状态机的视角推导出应该如何处理相关的实现细节. 状态机视角告诉我们, 处理器的状态由时序逻辑电路决定, 而处理器的状态更新又受到控制信号的控制, 因此, 要实现冲刷的效果, 我们只需要考虑将相关的控制信号置为无效即可. 例如, 通过将
in.valid置为无效, 可以将大部分部件正在执行的指令直接冲刷掉. - 但如果部件中还存在一些影响控制信号的状态, 你还需要进行额外的考量, 例如icache中的状态机, 尤其是发出的AXI请求无法撤回, 因此需要等待请求完成.
- 推测执行意味着当前执行的操作不一定是将来真正需要的, 如果推测错误, 就应该取消相关操作. 但有一些操作很难取消, 包括更新寄存器堆, 更新CSR, 写内存, 访问外设等, 这些模块的状态一旦发生改变, 就很难恢复到旧状态. 因此, 需要在确认推测正确后, 才能更新这些模块的状态.
我们跑题有点远了,回到流水线 乱序执行
乱序执行,简单讲只要是操作数准备好了,就能够执行,不需要等前面的执行完了才能执行,可以理解为插队执行,但是最终指令执行完毕时,还要按照原始的顺序退出。指令的动态执行算法有tomasulo算法,scoreboard等。
下图是乱序执行的演示,因为冲突inst x 1 只能在cycle 6执行,但是x 2 没有冲突,可以在x 1之前的cycle5执行,并没有等待x 1执行完再执行,这就是乱序

除了乱序执行,还有超标量的概念,超标量相比于标量,就是一个cycle可以发送多个指令,下图中inst x 和 inst x 1 是在同一个cycle1发送的,提高了并行度。

接着说推测执行的概念,假设存在分支指令,通常的处理方式是等待分支指令得到true/false才能进行下一步性能,但是该阶段要等到执行才能完成,后面的指令一直在空等,浪费处理器周期,于是可以提前预测运行的方向,从预测的路径开始执行。下图中,版本1 是没有预测的,Call foo直到分支指令EXE处理完才能开始,版本2加入预测,cycle2无缝衔接。

预测也有风险,预测错误需要大量的时间进行恢复,但是如果预测准确率足够高,就可以节省大量的时间。处理器使用reorder buffer (ROB)维护正确的指令序,只有正确推测的才会写入ROB。
分支预测依赖于两个原则:
时间相关性:分支的解析方式可以很好地预测下次执行时的解析方式(局部)
空间相关性:多个相邻分支可能以高度相关的方式(全局)
二者整合,又产生了tournament branch predictor,会根据不同的情况从全局或局部选择。当然现代处理器使用的算法远比这些先进,比如TAGE相关的算法,现代处理器预测的准确率达到95%以上。
SIMD Multiprocessors
SIMD = single instruction multiple data,理解为一个指令在一个周期内使用多个独立的功能单元处理数据。SIMD适用于vector/matrix处理。如果执行单元可以对256 bit的vector进行运算,就可以用一条指令处理4个double数据,理论上速度提升4倍,实际上远没有。下图是一个实例,可以看到宽度越大,理论加速越多。

对于SIMD,处理器有专用的SIMD寄存器来进行load/store,上面的实例中,处理器的工作包括(暂不考虑cache):
- 从内存中a, b对应的数组位置load数据到SIMD register
- 执行SIMD运算
- 将结果存储到SIMD register
- 从SIMD register将结果写入数组c的内存区域
下面看编译器,当前主流的编译器都支持SIMD指令。ARM的SVE具有编译时vector长度未知的特点,使用SVE,极大地降低了移植的难度,不再需要为每一种长度编写对应的代码。另一个例子是RVV,riscv v extension,最大支持2048bit vector,最多支持8个vector组合在一起,形成16384bit vector,RVV并没有和SVE一样实现编译器时vector长度未知的特点,而是执行时ptr =number of lanes,但number of lanes编译时未知,RVV能让程序猿可以查询和设置number of lanes。(和ARM确实挺像的,摸着ARM过河)
CPU也支持机器学习中的matrix multiplications,以及下面的功能,都是由SIMD驱动
• String processing: finding characters, validating UTF-8,40 parsing JSON41 and CSV;42 • Hashing,43 random generation,44 cryptography(AES); • Columnar databases (bit packing, filtering, joins); • Sorting built-in types (VQSort,45 QuickSelect); • Machine Learning and Artificial Inteligence (speeding up PyTorch, Tensorflow).
常用的开源SIMD库如下
UTF-8 validation - https://github.com/rusticstuff/simdutf8 Parsing JSON - https://github.com/simdjson/simdjson. Parsing CSV - https://github.com/geofflangdale/simdcsv SIMD hashing - GitHub - google/highwayhash: Fast strong hash functions: SipHash/HighwayHash
Exploiting Thread Level Parallelism
利用cpu执行的进程/线程并行来加速。有三种技术:
- multicore systems,
2. simultaneous multithreading
3. hybrid architectures
multicore systems
该系统理解为,在单个芯片上复制多个处理器核,例如,其中一个内核可以同时运行网络浏览器,另一个内核可以渲染视频,还有一个内核可以播放音乐。对于服务器机器来说,来自不同客户的请求可以在不同的内核上处理,这可以大大提高系统的吞吐量。第一款面向消费者的双核处理器是 2005 年发布的英特尔酷睿 2 双核处理器,同年晚些时候又发布了 AMD Athlon X2 架构。多核系统导致许多软件组件需要重新设计,并影响了我们编写代码的方式。如今,几乎所有面向消费者设备的处理器都是多核 CPU。
多核互联需要考虑带宽,同步,调度等问题,随着内核数量增加,性能回报也会降低。
Simultaneous Multithreading
SMT又名超线程,或同时多线程。SMT 允许多个软件线程使用共享资源在同一物理内核上同时运行。更准确地说,多个软件线程的指令在同一周期内同时执行。这些线程不一定是同一进程的线程,也可以是碰巧被调度到同一物理内核上的完全不同的程序。
非 SMT 和 SMT2 (两个SMT)处理器上的执行示例如下图所示。在这两种情况下,处理器流水线的宽度都是 4,每个插槽都代表一次发送新指令的机会。机器 100% 利用率是指没有未使用的插槽,而这在实际工作负载中从未发生过。不难看出,在非 SMT 情况下,有很多未使用的插槽,因此可用资源没有得到很好的利用。出现这种情况的原因有很多,其中一个常见原因是缓存未命中。在周期 3,线程 1 无法向前推进,因为它在等待数据到达。SMT 处理器会利用这个机会安排另一个线程进行有用的工作。这样做的目的是让另一个线程占用未使用的插槽,以隐藏内存延迟,提高硬件利用率和多线程性能。(主要看白块,白块越多,利用率越低)

在 SMT2 实现中,每个物理处理器核都有两个逻辑内核,对于操作系统来说,这两个逻辑内核是两个独立的处理器,可以进行工作。假设有 16 个软件线程可以运行,但只有 8 个物理内核。在非 SMT 系统中,只有 8 个线程同时运行,而使用 SMT2,我们可以同时执行所有 16 个线程。在另一种假设情况下,如果两个程序在一个支持 SMT 的内核上运行,并且每个程序只持续使用四个可用插槽中的两个,那么它们的运行速度很有可能与单独在该物理内核上运行时的速度相当。
虽然两个程序在同一个处理器内核上运行,但它们彼此完全分离。在支持 SMT 的处理器中,即使指令是混合的,它们也有不同的上下文,这有助于保持执行的正确性。
CPU为了支持SMT,需要复制架构的状态(PC, regfile)等到另一个线程的上下文。部分CPU资源可以共享,比如cache。跟踪乱序执行或者推测执行的资源可以复制或者分割。
SMT2处理器,前端交替获取质量,后端混合处理,动态调度。存在的问题是资源的竞争导致效率低下(比如共享的cache空间小,会出现频繁的换入换出)。
SMT 给软件开发人员带来了相当大的负担,因为它使得预测和测量在 SMT 内核上运行的应用程序的性能变得更加困难。试想一下,在 SMT 内核上运行性能关键型代码,而操作系统突然将另一项要求苛刻的工作放到了同级逻辑内核上。你的代码几乎耗尽了机器的资源,现在你需要与其他人共享。这个问题在云环境中尤为突出,因为你无法预测你的应用程序是否会有占用资源的邻居。某些同步多线程实现也存在安全隐患。研究人员发现,一些早期的实现存在漏洞,一个应用程序可以通过监控缓存的使用,从运行在同一处理器的同级逻辑内核上的另一个应用程序中窃取关键信息(如加密密钥)。
总结:可以提速,存在安全和性能问题,对于性能测量也有风险,但是得用。
Hybrid Architectures
计算机架构师还开发了混合 CPU 设计,即在同一个处理器中采用两种(或更多)类型的内核。通常情况下,更强大的内核与相对较慢的内核相结合,以实现不同的目标。在这种系统中,大内核用于对延迟敏感的任务,而小内核则可降低功耗。此外,两种内核还可以同时使用,以提高多线程性能。所有内核都可以访问相同的内存,因此工作负载可以从大内核迁移到小内核,然后再返回。这样做的目的是为了创建一种能更好地适应动态计算需求、耗电更少的多核处理器。例如,视频游戏既有单核突发性能的部分,也有可以扩展到多核的部分。(比如Intel的沙壁大小核)
第一个主流混合架构是 ARM 于 2011 年 10 月推出的 big.LITTLE。其他厂商也纷纷效仿。苹果公司于 2020 年推出了 M1 芯片,该芯片拥有四个高性能 "Firestorm "内核和四个高能效 "Icestorm "内核。英特尔于 2021 年推出了 Alderlake 混合架构,其顶级配置为8个 P核 8个 E 核。混合架构结合了两种内核类型的优点,但也带来了一系列挑战。首先,它要求内核完全兼容 ISA,即它们应能执行同一套指令。否则,调度就会受到限制。例如,如果一个大内核具有一些小内核无法使用的花哨指令,那么你只能分配大内核运行使用这些指令的工作负载。这就是为什么供应商在为混合处理器选择 ISA 时通常使用 "最大公分母 "方法。即使采用兼容 ISA 的内核,调度工作也会面临挑战。不同类型的工作负载需要特定的调度方案,如突发执行与稳定执行、低 IPC 与高 IPC、低重要性与高重要性等。这很快就会变得非常棘手。
以下是优化调度的几个注意事项:
- 利用小内核节省电能。不要唤醒大内核进行后台工作。 - 识别候选任务(低重要性、低 IPC),将其卸载到较小的内核上。同样,将重要性高、IPC 高的任务分配给大核心。 - 分配新任务时,首先使用空闲的大核心。如果是 SMT,在两个逻辑线程都空闲的情况下使用大核心。然后,使用空闲的小核心。然后,使用大核心的同级逻辑线程。
对于软件程序猿,无需考虑太多,不需要修改软件代码,这是硬件和操作系统的事情。
Memory Hierarchy
这块又臭又长,但是对于硬件和软件都是瓶颈,不得不看。
Cache
首先是时间局部性,空间局部性。
• 时间局部性:当访问给定的内存位置时,很可能在不久的将来会再次访问同一位置。理想情况下,我们希望下次需要时将这些信息保存在缓存中。
• 空间局部性:当访问给定的内存位置时,很可能在不久的将来也会访问附近的位置。这是指将相关数据彼此靠近放置。当程序从内存中读取单个字节时,通常会获取更大的内存块(高速缓存行),因为程序通常很快就会需要该数据。
cache由下面4个属性定义:
- Placement of Data within the Cache,全相联,组相连,直接映射
- Finding Data in the Cache. 通常的地址组织如下,

3. Managing Misses , 未命中选择的替换算法
4. Managing Writes. write through/write back的区别,参考下面的图,本质上就是多个dirty


cache的优化策略,参考公示Average Access Latency = Hit Time Miss Rate × Miss Penalty,主要是降低miss rate,有一系列的方式,比如预取。
预取包括硬件和软件预取,先看硬件预取,即提前将指令或者数据预取到cache中处理,而不是按部就班等待。硬件预取可以自动适应应用程序的动态行为,而不需要额外的编译优化。硬件预取的工作无需额外的地址生成和预取指令的开销。然而,硬件预取仅限于学习和预取一组有限的高速缓存未命中模式。软件内存预取是对硬件预取的补充。开发人员可以通过专用硬件指令提前指定需要哪些内存位置。编译器还可以自动将预取指令添加到代码中,以便在需要数据之前请求数据。预取技术需要在需求和预取请求之间进行平衡,以防止预取流量减慢需求流量。
Main Memory
主存位于cache下一级,价格低,速度慢。DDR是主流CPU支持的主存,每一代DDR带宽都在提高,但是读延迟也在增大

DRAM需要定期刷新存储单元,当刷新时不处理访存请求。DRAM 模块被组织为 DRAM 芯片组。rank是一个术语,描述模块上存在多少组 DRAM 芯片。例如,single rank (1R) 内存模块包含一组 DRAM 芯片。dual-rank (2R) 内存模块具有两组 DRAM 芯片,因此使single-rank模块的容量增加了一倍。同样,还有quad-rank (4R) 和octa-rank (8R) 内存模块可供购买。每个rank由多个DRAM芯片组成。内存宽度定义了每个 DRAM 芯片的总线有多宽。由于每个列的宽度为 64 位(对于 ECC RAM 为 72 位宽),因此它还定义了该列中存在的 DRAM 芯片的数量。内存宽度可以是三个值之一:x4、x8 或 x16,它们定义了通往每个芯片的总线的宽度。下图显示了 2R x16 dual-bank DRAM DDR4 模块的组织结构,总容量为 2GB。每列有四个芯片,总线宽度为 16 位。这四个芯片组合起来可提供 64 位输出。Rank Select选择对应的rank。

single-rank 还是 dual-rank的性能没有绝对的高低,取决于应用程序的类型。通过Rank Select信号从一个rank切换到另一rank需要额外的时钟周期,这可能会增加访问延迟。另一方面,如果某个rank未被访问,则它可以在其他rank忙时并行执行其刷新周期。一旦前一个Rank完成数据传输,下一个Rank就可以立即开始传输。此外,single-rank模块产生的热量较少,并且不太可能发生故障。在一个系统中安装多个DRAM模块,不仅可以增加内存容量,还可以增加内存带宽。具有多个内存通道的设置用于提高内存控制器和 DRAM 之间的通信速度。具有单个内存通道的系统在 DRAM 和内存控制器之间具有 64 位宽的数据总线。多通道架构增加了内存总线的宽度,允许同时访问 DRAM 模块。例如,双通道架构将内存数据总线的宽度从 64 位扩展到 128 位,使可用带宽加倍,看下图。(总结:多个rank叠加带宽高,速度快,单rank故障率低,耗能少)

使用以下简单公式进行快速计算,以确定给定内存技术的最大内存带宽:
Max. Memory Bandwidth = Data Rate × Bytes per cycle
对于单通道 DDR4 配置,数据速率为 2400 MT/s,每个内存周期可传输 64 位(8 字节),因此最大带宽等于 2400 * 8 = 19.2 GB/s。双通道或双内存控制器设置可将带宽加倍至 38.4 GB/s。
要启动双通道,需要主机的板卡支持。使用CPU-Z 或者 dmidecode测试。
为了在系统中使用多个内存通道,使用interleaving技术,它将页面内的相邻地址分布在多个存储设备上。如下图,不同页面的地址放在同一个memory controller中。

下面讲GDDR(图形DDR)和HBM(高带宽内存),它们不仅应用于高端图形、高性能计算(例如气候建模、分子动力学、物理模拟),还应用于自动驾驶,当然还有人工智能/机器学习。它们非常适合那里,因为此类应用程序需要非常快速地移动大量数据。
DRAM DDR 专为降低延迟而设计,而 GDDR 则专为更高带宽而设计,因为它与处理器芯片本身位于同一封装中。与 DDR 类似,GDDR 接口每个时钟周期传输两个 32 位字(总共 64 位)。最新的GDDR6X标准可以实现高达168 GB/s的带宽,运行在相对较低的656 MHz频率下。
HBM是一种新型CPU/GPU内存,垂直堆叠内存芯片,也称为3D堆叠。与 GDDR 类似,HBM 大大缩短了数据到达处理器所需的距离。与 DDR 和 GDDR 的主要区别在于 HBM 内存总线非常宽:每个 HBM 堆栈有 1024 位。这使得HBM能够实现超高带宽。最新的 HBM3 标准支持每个包高达 665 GB/s 的带宽。它还以 500 Mhz 的低频运行,每个封装的存储密度高达 48 GB。如果希望获得尽可能多的内存带宽,那么带有 HBM 的系统将是一个不错的选择。这项技术相当昂贵。由于 GDDR 主要用于显卡,HBM 可能是加速 CPU 上运行的某些工作负载的不错选择。事实上,首款集成 HBM 的 x86 通用服务器芯片现已上市。
Virtual Memory
虚拟内存提供了一种保护机制,可以防止其他进程访问分配给给定进程的内存。虚拟内存还提供重定位功能,即在不更改程序地址的情况下将程序加载到物理内存中的任何位置的能力。在支持虚拟内存的 CPU 中,程序使用虚拟地址进行访问。但是,虽然用户代码在虚拟地址上运行,但从内存中检索数据需要物理地址。此外,为了有效管理稀缺的物理内存,需要分页。
应用程序在操作系统提供的一组页面上运行。访问数据和代码(指令)都需要地址转换。下图是虚地址到物理地址的转换,访问主存需要使用物理地址才行。

下图是二级页表的示意图,划分方案看virtual address的地址分割。

嵌套页表的实现是基数树,嵌套方法不需要将整个页表存储为连续数组,并且不分配没有描述符的块。这节省了内存空间,但增加了遍历页表时的开销。无法提供物理地址映射称为页面错误。如果请求的页面无效或当前不在主内存中,则会发生这种情况。两个最常见的原因是:
1) 操作系统已承诺分配页面,但尚未使用物理页面支持它;
2) 访问的页面已换出到磁盘,并且当前未存储在 RAM 中。
TLB
分层页表中进行搜索可能会很昂贵,需要遍历分层结构,可能会进行多次间接访问。这种遍历通常称为页面遍历。为了减少地址转换时间,CPU 支持称为转换后备缓冲区 (TLB) 的硬件结构来缓存最近使用的转换。
为了降低内存访问延迟,TLB 和缓存查找并行进行,因为数据缓存在虚拟地址上运行,不需要事先进行地址转换。TLB 层次结构会为相对较大的内存空间保留转换。尽管如此,TLB 的失误可能会造成高昂的代价。
为了加快 TLB 未命中的处理速度,CPU 有一种称为 HW page walker 的机制。这样的单元可以通过发出遍历页表所需的指令来直接在硬件中执行页遍历,所有这些都不会中断内核。这就是为什么页表的格式由 CPU 决定,操作系统必须遵守的原因。高端处理器具有多个HW page walker,可以同时处理多个 TLB 未命中。然而,即使现代 CPU 提供了所有加速功能,TLB 未命中仍然会导致许多应用程序出现性能瓶颈。
较小的页面大小可以更有效地管理可用内存并减少碎片。但缺点是它需要更多的页表条目来覆盖相同的内存区域。考虑两种页面大小:4KB(x86 上的默认值)和 2MB 大页面大小。对于运行 10MB 数据的应用程序,在第一种情况下我们需要 2560 个条目,如果我们将地址空间映射到大页上,则只需 5 个条目。
下面是2MB大页的address分布示意图,通常程序猿并不需要考虑大页的机制,操作系统考虑。

Modern CPU Design
主要分析的是英特尔第 12 代核心 Goldencove ,架构图如下,图片是单核,前端是顺序架构,执行是乱序后端,最后顺序提交,该设计支持SMT2。

这里书上讲的硬件的不想看,软件的看不懂,略。
Performance Monitoring Unit
每个现代 CPU 都提供监控性能的工具,这些工具被组合到性能监控单元 (PMU) 中。该单元包含的功能可帮助开发人员分析其应用程序的性能。

PMU随着CPU的版本更新,使用cpuid确定CPU中PMU版本,从PMU的角度看处理器可以划分这几个大的模块,AMD Zen4 和 ARM Neoverse V1 内核支持每个处理器内核 6 个可编程性能监控计数器,无固定计数器。PMU 提供一百多个可用于监控的事件并不罕见。图 22 仅显示了可用于在现代 Intel CPU 上进行监控的性能事件的一小部分。不难发现,可用 PMC 的数量远小于性能事件的数量。不可能同时对所有事件进行计数,但是分析工具通过在程序执行期间在性能事件组之间进行复用来解决这个问题。



