深入浅出计算机组成原理¶
浮点数和定点数¶
BCD 编码(Binary-Coded Decimal)¶
用二进制来表示十进制。用 4 个比特来表示 0~9 的整数,那么 32 个比特就可以表示 8 个这样的整数。然后把最右边的 2 个 0~9 的整数,当成小数部分;把左边 6 个 0~9 的整数,当成整数部分。这样,就可以用 32 个比特,来表示从 0 到 999999.99 这样 1 亿个实数了。
缺点:
- 这样的表示方式有点“浪费”
- 这样的表示方式没办法同时表示很大的数字和很小的数字
浮点数的表示¶
浮点数(Floating Point),也就是 float 类型。
浮点数的科学计数法的表示,有一个 IEEE 的标准,它定义了两个基本的格式。一个是用 32 比特表示单精度的浮点数,也就是我们常常说的 float 或者 float32 类型。另外一个是用 64 比特表示双精度的浮点数,也就是我们平时说的 double 或者 float64 类型。
单精度类型¶

单精度的 32 个比特可以分成三部分:
- 符号位:用来表示是正数还是负数。我们一般用 s 来表示。在浮点数里,我们不像正数分符号数还是无符号数,所有的浮点数都是有符号的。
- 指数位:一般用 e 来表示。8 个比特能够表示的整数空间,就是 0~255。在这里用 1~254 映射到 -126~127 这 254 个有正有负的数上。因为浮点数,不仅仅想要表示很大的数,还希望能够表示很小的数,所以指数位也会有负数。
- 有效数位:23 个比特组成,用 f 来表示。
综合科学计数法,我们的浮点数就可以表示成下面这样:
要表示 0 和一些特殊的数,就要用上在 e 里面留下的 0 和 255 这两个表示,这两个表示其实是两个标记位。在 e 为 0 且 f 为 0 的时候,就把这个浮点数认为是 0。至于其它的 e 是 0 或者 255 的特殊情况,可以看下面这个表格,分别可以表示出无穷大、无穷小、NAN 以及一个特殊的不规范数。

在这样的浮点数表示下,不考虑符号的话,浮点数能够表示的最小的数和最大的数,差不多是 \(1.17×10^{−38}\) 和 \(3.40×10^{38}\)。
正是因为这个数对应的小数点的位置是“浮动”的,它才被称为浮点数。随着指数位 e 的值的不同,小数点的位置也在变动。对应的,前面的 BCD 编码的实数,就是小数点固定在某一位的方式,我们也就把它称为定点数。
浮点数的二进制转化¶
拿 0.1001 这样一个二进制小数来举例说明。和上面的整数相反,我们把小数点后的每一位,都表示对应的 2 的 -N 次方。那么 0.1001,转化成十进制就是:
和整数的二进制表示采用“除以 2,然后看余数”的方式相比,小数部分转换成二进制是用一个相似的反方向操作,就是乘以 2,然后看看是否超过 1。如果超过 1,我们就记下 1,并把结果减去 1,进一步循环操作。在这里,我们就会看到,0.1 其实变成了一个无限循环的二进制小数,0.000110011。这里的“0011”会无限循环下去。

浮点数的加法和精度损失¶
浮点数的加法,记住六个字就行了,那就是先对齐、再计算。
两个浮点数的指数位可能是不一样的,所以我们要把两个的指数位,变成一样的,然后只去计算有效位的加法就好了。
浮点数的加法过程中,其中指数位较小的数,需要在有效位进行右移,在右移的过程中,最右侧的有效位就被丢弃掉了。这会导致对应的指数位较小的数,在加法发生之前,就丢失精度。两个相加数的指数位差的越大,位移的位数越大,可能丢失的精度也就越大。
32 位浮点数的有效位长度一共只有 23 位,如果两个数的指数位差出 23 位,较小的数右移 24 位之后,所有的有效位就都丢失了。这也就意味着,在实际计算的时候,只要两个数,差出 \(2^{24}\),也就是差不多 1600 万倍,那这两个数相加之后,结果完全不会变化。
Kahan Summation 算法¶
public class KahanSummation {
public static void main(String[] args) {
float sum = 0.0f;
float c = 0.0f;
for (int i = 0; i < 20000000; i++) {
float x = 1.0f;
float y = x - c;
float t = sum + y;
c = (t-sum)-y;
sum = t;
}
System.out.println("sum is " + sum);
}
}
这个算法的原理其实并不复杂,就是在每次的计算过程中,都用一次减法,把当前加法计算中损失的精度记录下来,然后在后面的循环中,把这个精度损失放在要加的小数上,再做一次运算。
面向流水线的指令设计¶
在工业界,更好的衡量方式通常是,用 SPEC 这样的跑分程序,从多个不同的实际应用场景,来衡量计算机的性能。
2000 年发布的 Pentium 4 的流水线深度是 20 级。而代号为 Prescott 的 90 纳米工艺处理器 Pentium 4 的流水线深度达到了 31 级。
流水线技术并不能缩短单条指令的响应时间这个性能指标,但是可以增加在运行很多条指令时候的吞吐率。因为不同的指令,实际执行需要的时间是不同的。

Pentium 4 失败的原因¶
-
功耗问题。
提升流水线深度,必须要和提升 CPU 主频同时进行。因为在单个 Pipeline Stage 能够执行的功能变简单了,也就意味着单个时钟周期内能够完成的事情变少了。所以,只有提升时钟周期,CPU 在指令的响应时间这个指标上才能保持和原来相同的性能。
同时,由于流水线深度的增加,我们需要的电路数量变多了,也就是我们所使用的晶体管也就变多了。
主频的提升和晶体管数量的增加都使得我们 CPU 的功耗变大了。这个问题导致了 Pentium 4 在整个生命周期里,都成为了耗电和散热的大户。
-
流水线技术带来的性能提升,是一个理想情况。在实践的应用过程中,还需要解决指令之间的依赖问题。
依赖问题,就是我们在计算机组成里面所说的冒险(Hazard)问题。这里我们只列举了在数据层面的依赖,也就是数据冒险。在实际应用中,还会有结构冒险、控制冒险等其他的依赖问题。要想解决好冒险的依赖关系问题,需要引入乱序执行、分支预测等技术。
流水线越长,这个冒险的问题就越难一解决。这是因为,同一时间同时在运行的指令太多了。如果只有 3 级流水线,就可以把后面没有依赖关系的指令放到前面来执行。这个就是乱序执行的技术。
如果有 20 级流水线,意味着要确保这 20 条指令之间没有依赖关系。这也是为什么,超长流水线的执行效率发而降低了的一个重要原因。
一个合理的流水线深度,会提升 CPU 执行计算机指令的吞吐率。一般用 IPC(Instruction Per Cycle)来衡量 CPU 执行指令的效率。IPC,其实就是 CPI(Cycle Per Instruction)的倒数。
冒险和预测¶
流水线设计需要解决的三大冒险,分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。
结构冒险¶
CPU 在同一个时钟周期,同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段,可能会用到同样的硬件电路。
可以看到,在第 1 条指令执行到访存(MEM)阶段的时候,流水线里的第 4 条指令,在执行取指令(Fetch)的操作。访存和取指令,都要进行内存数据的读取。我们的内存,只有一个地址译码器的作为地址输入,那就只能在一个时钟周期里面读取一条数据,没办法同时执行第 1 条指令的读取内存数据和第 4 条指令的读取指令代码。

对于访问内存数据和取指令的冲突,一个直观的解决方案就是把我们的内存分成两部分,让它们各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存。
这样把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture),来自哈佛大学设计Mark I 型计算机时候的设计。对应的,我们之前说的冯·诺依曼体系结构,又叫作普林斯顿架构(Princeton Architecture)。
不过,我们今天使用的 CPU,仍然是冯·诺依曼体系结构的,并没有把内存拆成程序内存和数据内存这两部分。因为如果那样拆的话,对程序指令和数据需要的内存空间,我们就没有办法根据实际的应用去动态分配了。虽然解决了资源冲突的问题,但是也失去了灵活性。

不过,借鉴了哈佛结构的思路,现代的 CPU 虽然没有在内存层面进行对应的拆分,却在 CPU 内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
内存的访问速度远比 CPU 的速度要慢,所以现代的 CPU 并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中,这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分,使得 CPU 在进行数据访问和取指令的时候,不会再发生资源冲突的问题了。
数据冒险¶
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW)。
先写后读(Read After Write)¶
先写后读的依赖关系,我们一般被称之为数据依赖,也就是 Data Dependency。
先读后写(Write After Read)¶
先读后写的依赖,一般被叫作反依赖,也就是 Anti-Dependency。
写后再写(Write After Write)¶
写后再写的依赖,一般被叫作输出依赖,也就是 Output Dependency。
通过流水线停顿解决数据冒险¶
需要有解决这些数据冒险的办法。其中最简单的一个办法,不过也是最笨的一个办法,就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。
流水线停顿的办法很容易理解。如果发现了后面执行的指令,会对前面执行的指令有数据层面的依赖关系,那最简单的办法就是“再等等”。在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和内存地址。所以,在这个时候,能够判断出来,这个指令是否会触发数据冒险。如果会触发数据冒险,就可以决定,让整个流水线停顿一个或者多个周期。

时钟信号会不停地在 0 和 1 之前自动切换。流水线的每一个操作步骤必须要干点儿事情。所以,在实践过程中,并不是让流水线停下来,而是在执行后面的操作步骤前面,插入一个 NOP 操作,也就是执行一个其实什么都不干的操作。
这个插入的指令,就好像一个水管(Pipeline)里面,进了一个空的气泡。在水流经过的时候,没有传送水到下一个步骤,而是给了一个什么都没有的空气泡。这也是为什么,我们的流水线停顿,又被叫作流水线冒泡(Pipeline Bubble)的原因。