超标量处理器设计:

第一章 超标量处理器概述

为什么需要超标量

为了提高 IPC。

普通处理器流水线

流水线概述

流水线就是为了降低指令周期的时间,提高频率的手段。

流水线的划分

一般根据场景,进行划分流水段,并且每一个阶段的延迟尽量相近。

指令间的相关性

RAW:无法避免;
WAR:可以避免,写到其它寄存器。
WAW:同 WAR,也可以避免,写到其它寄存器。

控制相关:只能靠预测器预取,一旦预测错误,会带来严重的性能问题。

超标量处理器流水线

每周期取出多条指令并执行,就是超标量了。

顺序执行

执行执行遵守指定的顺序,前者没有发射,后者就会卡住。

乱序执行

只要没有数据相关,就可以发射执行。不过最后要让 ROB 配合完成重排序,目标是对架构寄存器按指令顺序进行提交。提交之前,会检查异常并处理。提交之后就是退休了,无法再撤回更改。

第二章 Cache

Cache 的一般设计

哈佛结构,L1 Cache 会分为 Dcache、Icache。Dcache 更复杂一些,因为超标量中可能多有条访存指令,这意味着 Dcache 要有多个端口。如果 Dcache 很大,这将占用更多的硅片面积。

L2Cache 将会考虑更高的命中率设计,一旦 miss 将会访问物理内存,这将花费很多的时钟周期来搬运数据。

Cache 确实的三个条件,简称为 3C 定理:

  • Compulsory:首次访问必 Miss;
  • Capcity:容量影响成本和性能,因此得 tradeoff;
  • Conflict:冲突,组相联的相联度不高,比如 2way 的话,第三个过来比然会替换掉一个,从而造成 miss。

在超标量设计中,可以采用 prefetching,Victim Cache 两种方法降低 miss 的概率。

Cache 的组成方式

  • 直接映射:冲突率高(index)。
  • 全相联:延迟高,因为要进行比较(tag)。
  • 组相联:组内全相联(tag),组外直接映射(index)。

Cache 的写入

  • 写回:替换时发现 dirty 写回
  • 写通:访问 uncache 类型的数据时,需要写回到 ram 并同时写到 cache_line

Cache 的替换策略

  • 近期最少使用法;
  • 随机替换:使用计数器替换掉某一个 set 的某一个 way。8 way 就是 3 bit 的计数器。

提高 Cache 的性能

写缓存,流水线 cache,多级结构,Victim Cache,预取等。

对于超标量乱序的处理器,非阻塞cache、关键字优先、提前开始等方法也可以。

写缓存

对于 L1Dcache 而言,这很重要。它是 L1Dcache 和 下级存储器之间的一个缓冲,当要将某一个 cache_line 替换的时候,暂时写到写缓存中,在空闲的时候,择机再写回到下级存储器。

流水线

由于 SRAM 带有延迟,如果读并命中,那么下一周期就能出结果。如果是写,首先要确认 tag 命中,然后开始写,这就多了几拍,因此就考虑流水线化了。

多级结构

L1、L2、L3 等多级 cache。

Victim Cache

保存被踢掉的 cache line,比如 2way 的某一个 set 中,有一个 way 被频繁踢掉,这时候就可以将它踢到 Victim Cache 中。访问的时候, 如果 cache 未命中但是 Victim cache 命中,那么就会从 Victim cache 中返回数据,同时交换 victim cache 和 某一个 cache,相当于它们交换了数据。

与此相似的是 filter cache。首次访问,未命中,然后从下一级存储器搬运数据到 filter cache。再次被使用的时候,才会写到 cache。解决了偶然访问的代码导致的 cache 浪费,比如启动代码等。

预取

本质上是一种预测技术,缓解 3C 定理中的 compulsory。

硬件预取

预取的话,可以回预测错误,导致 cache 被污染或者浪费。为了避免,将预取的指令放到单独的缓存中。

比如访问第0个块儿,那么第 1 个块儿也放到 stream buffer 的地方。如果访问数据发现 cache miss,但是 stream buffer 命中,那么就会把 stream buffer 放到 cache 中,接着继续去取第 2 个块儿放到 stream buffer 中。这是双刃剑,错误预取会导致功耗增加,浪费 cache 和 下一级存储器的总线带宽。

数据预取更难找到规律,因此要有一个预测单元专门找规律,这大大增加了设计的复杂度。

软件预取

预取时机很重要,编译器严重依赖于 CPU cache 的具体架构。在实现了虚拟存储的系统中,预取指令有可能引起一些异常。

多端口 Cache

暂时忽略。




寄存器重命名

概述

  • 数据相关性:到了解码阶段,找相关性。
  • 存储器数据相关性。
  • 控制相关;
  • 结构相关:与内部结构有关。

RAW 是真相关,WAW,WAR 是假相关,可以用重命名解决。

假相关之所以存在就是因为:

  • 有限个架构寄存器,通过更多的 PRF 来解决指令集上的不足;
  • 循环的存在。如果循环展开,对架构寄存器消耗过多,而且代码体积膨胀,icache miss 概率增加。
  • 代码重用:和循环展开遇到的问题一致,如果不断 inline,体积膨胀,icache miss 概率增加。

考虑一个例子循环展开的例子

如果有一个循环,编译出来的代码只有短短几行,这短短几行中对r5寄存器大量 WAW 或者 WAR,造成了严重的数据相关。此时一个人想到了一个方法,对原来的短短几行代码进行循环展开,这样就解决了这个问题,但是编译出来的代码有几十行。而且面临着代码体积严重膨胀、icache miss 的风险:

编译参数:

1
2
3
4
5
6
7
GCC=riscv64-unknown-elf-gcc
roll:
$(GCC) -O2 -S loop_example.c -o loop_O2.s
unroll:
$(GCC) -O2 -funroll-loops -S loop_example.c -o loop_unroll.s
unroll_default:
$(GCC) -O3 -S loop_example.c -o loop_O3.s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void process_array(int *arr, int n) {
for (int i = 0; i < n; i++) {
int temp = arr[i] + 5;
int shifted = arr[i] << 1;
arr[i] = shifted + temp + 1;
}
}

// 固定大小循环 - 更容易被展开
void process_fixed(int arr[10]) {
for (int i = 0; i < 10; i++) {
int temp = arr[i] + 5;
int shifted = arr[i] << 1;
arr[i] = shifted + temp + 1;
}
}

如果开启 O3 优化或者强制 unroll 展开,编译出的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
	.file	"loop_example.c"
.option nopic
.attribute arch, "rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zicsr2p0_zifencei2p0_zmmul1p0_zaamo1p0_zalrsc1p0_zca1p0_zcd1p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.align 1
.globl process_array
.type process_array, @function
process_array:
ble a1,zero,.L1
slli a1,a1,2
add a1,a0,a1
.L3:
lw a5,0(a0)
addi a0,a0,4
slliw a4,a5,1
addw a5,a5,a4
addiw a5,a5,6
sw a5,-4(a0)
bne a1,a0,.L3
.L1:
ret
.size process_array, .-process_array
.align 1
.globl process_fixed
.type process_fixed, @function
process_fixed:
lw t4,0(a0)
lw t3,4(a0)
lw t1,8(a0)
lw a7,12(a0)
lw a6,16(a0)
lw a1,20(a0)
lw a2,24(a0)
lw a3,28(a0)
lw a4,32(a0)
lw a5,36(a0)
slliw t5,t4,1
slliw t0,t3,1
slliw t6,t1,1
addw t4,t4,t5
slliw t5,a7,1
addw t3,t3,t0
addw t1,t1,t6
slliw t0,a6,1
slliw t6,a1,1
addw a7,a7,t5
slliw t5,a2,1
addw a6,a6,t0
addw a1,a1,t6
addw a2,a2,t5
slliw t0,a3,1
slliw t6,a4,1
slliw t5,a5,1
addw a3,a3,t0
addw a4,a4,t6
addw a5,a5,t5
addiw t4,t4,6
addiw t3,t3,6
addiw t1,t1,6
addiw a7,a7,6
addiw a6,a6,6
addiw a1,a1,6
addiw a2,a2,6
addiw a3,a3,6
addiw a4,a4,6
addiw a5,a5,6
sw t4,0(a0)
sw t3,4(a0)
sw t1,8(a0)
sw a7,12(a0)
sw a6,16(a0)
sw a1,20(a0)
sw a2,24(a0)
sw a3,28(a0)
sw a4,32(a0)
sw a5,36(a0)
ret
.size process_fixed, .-process_fixed
.ident "GCC: (g1b306039ac4) 15.1.0"
.section .note.GNU-stack,"",@progbits

O3 优化会默认展开短循环。我把循环放大到 100:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
	.file	"loop_example.c"
.option nopic
.attribute arch, "rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zicsr2p0_zifencei2p0_zmmul1p0_zaamo1p0_zalrsc1p0_zca1p0_zcd1p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.align 1
.globl process_array
.type process_array, @function
process_array:
ble a1,zero,.L1
slli a1,a1,2
add a1,a0,a1
.L3:
lw a5,0(a0)
addi a0,a0,4
slliw a4,a5,1
addw a5,a5,a4
addiw a5,a5,6
sw a5,-4(a0)
bne a1,a0,.L3
.L1:
ret
.size process_array, .-process_array
.align 1
.globl process_fixed
.type process_fixed, @function
process_fixed:
addi a3,a0,400
.L7:
lw a5,0(a0)
addi a0,a0,4
slliw a4,a5,1
addw a5,a5,a4
addiw a5,a5,6
sw a5,-4(a0)
bne a3,a0,.L7
ret
.size process_fixed, .-process_fixed
.ident "GCC: (g1b306039ac4) 15.1.0"
.section .note.GNU-stack,"",@progbits

编译器不傻,这次没有展开,因为它知道架构寄存器不够用了!我再强制 unroll,发现它还是会展开,不过展开了多次而已。

从这个例子可以看到,如果循环展开,随着循环的次数的变大,就算强制展开,也只能每次展开不到 10 (看具体编译器)个循环,而且代码体积会极度膨胀,导致 icache miss 概率增加。

所以重命名就能解决由于架构寄存器数量不足导致的假相关。

寄存器重命名的方式

  • 扩展 ARF;
  • 使用统一的 PRF;
  • 使用 ROB 来实现寄存器重命名。

要实现寄存器重名,需要考虑:

  • 什么时候分配一个物理寄存器;
  • 什么时候释放一个物理寄存器;
  • 发生分支预测失败,如何恢复状态;
  • 发生异常后,如何恢复状态;

使用 ROB 进行寄存器重命名

使用 ARF 扩展进行寄存器重命名

使用统一的 PRF 进行寄存器重命名

重命名映射表

  • sram RAT:32*6bit
  • cam RAT:64*5bit

随着 PRF 的增大,sRAT 的存储空间更小,更快。

旧新的必要保存:

  • ARF 在进行退休之前,需要将它之前对应的 PRF 变为空闲状态,因此每次覆盖 RAT 的时候,都要将旧的映射信息保存下来,以后退休的时候可以将之前的 PRF 释放掉。
  • 遇到分支预测错误、异常的时候,需要将未提交的指令缓存全部冲刷。对于 RAT 而言,就是要撤销掉流水线缓存的刚保存的映射信息。

以上都需要保存旧的 RAT 项以在/覆盖 RAT、恢复 RAT 的时候使用。

对于 sRAT 而言,每次需要保存 sRAT;对于 cRAT ,每次只需要保存一个 Valid 位就行。

因此当需要很多的 checkpoint 的时候,sRAT 的体积将会很大;而 cRAT 体积对于每一个 checkpoint 都只有 nPRF*1bit。

高性能处理器的流水线深度都非常深,每次遇到分支预测错误都得保存 checkpoint。因此 cRAT 应用得更多。

基于 SRAM 的重命名映射表

高性能处理器流水线深度深,这会产生更多的 checkpoint,如何处理sRAT 的矛盾?

对于正确率高的分支指令没必要使用 checkpoint,一旦错误,就使用较慢的方法对 RAT 进行恢复,这种情况发生率低,对性能不会有太大影响。

基于 CAM 的重命名映射表

利用 ARF 编号,对 cRAT 进行全相联的查找,一旦确认一个 Valid 的项,就是 PRF 编号。

cRAT 一共有 nPRF 个,是否映射,就取决于 Valid。

Valid 是 0:

  • 空闲
  • 刚被覆盖(不是空闲)

对于空闲状态管理并不需要 cRAT 参与,只需要:

  • ROB
  • 空闲列表(free list)

超标量处理器的寄存器重命名

RAT 需要三个端口(第一次读 SRC1,SRC2,之后写的时候还需要一个 dest 作为地址)+ 一个端口(一个来自 free list 值送到 RAT)。

WAW 相关性,重命名的时候直接利用 WAW 而不是 RAT 的信息了。

首要任务就是 RAW、WAW。至于 WAR,这种相关性对于重命名也没有影响。

解决 RAW 相关性

同时对 4 条指令进行重命名,然后进行组内相关性检查。如果 in-order 的某一条指令 src 等于前面某条指令的 dest,那么就是 RAW,这个 src 应该来自当前周期从空闲列表输出的对应值。如果有多个项,就是用最新的那条指令的 dest 对应的 PRF。

解决 WAW 相关性

  • 对 RAT 的写入控制:只写最新的映射关系
  • 对 ROB 的写入控制:将旧的 PRF 保存,为了释放 PRF
什么场景会遇到读地址和写地址一样?

RAW & ROB。


要同时对 RAT 进行读取映射关系(src1–>PRF1,src2–>PRF2),然后修改映射关系(dest–>PRF3)。

因此必须回答一个问题:读地址和写地址一致的时候,如何处理。

比如对 ROB 进行检查,这时候 WAW,需要将 某一条指令的 dest 对应的 PRF 读出来,这是为了读出最新的对应状态。对于读取 src,也是希望读取最新的。

如果读取 RAT 的 src 和 写入 RAT 的 dest 一致,说明此时是 RAW。

读优先是不行的,因为 RAT 返回的 src 的物理寄存器 PRF 是错误的,应该返回来自 free list 的 PRF。

寄存器


超标量处理器设计:
http://blog.luliang.online/2025/12/04/超标量处理器设计1/
作者
Luyoung
发布于
2025年12月4日
许可协议