个人博客: https://www.luozhiyun.com/java
咱们先来看一个问题,在Chrome浏览器里面经过开发者工具,打开浏览器里的Console,在里面输入“0.3 + 0.6”:浏览器
>>> 0.3 + 0.6 0.8999999999999999
下面咱们来一步步解释,为何会这样。缓存
若是咱们用32个比特表示整数,用4个比特来表示0~9的整数,那么32个比特就能够表示8个这样的整数。架构
而后咱们把最右边的2个0~9的整数,当成小数部分;把左边6个0~9的整数,当成整数部分。这样,咱们就能够用32个比特,来表示从0到999999.99这样1亿个实数了。工具
这种用二进制来表示十进制的编码方式,叫做BCD编码。这种小数点固定在某一位的方式,咱们也就把它称为定点数。oop
缺点:
第一,这样的表示方式有点“浪费”。原本32个比特咱们能够表示40亿个不一样的数,可是在BCD编码下,只能表示1亿个数。
第二,这样的表示方式没办法同时表示很大的数字和很小的数字。ui
咱们在表示一个很大的数的时候,一般能够用科学计数法来表示。编码
在计算机里,我也能够用科学计数法来表示实数。浮点数的科学计数法的表示,有一个IEEE的标准,它定义了两个基本的格式。一个是用32比特表示单精度的浮点数,也就是咱们经常说的float或者float32类型。另一个是用64比特表示双精度的浮点数,也就是咱们平时说的double或者float64类型。设计
单精度的32个比特能够分红三部分。3d
第一部分是一个符号位,用来表示是正数仍是负数。咱们通常用s来表示。在浮点数里,咱们不像正数分符号数仍是无符号数,全部的浮点数都是有符号的。
接下来是一个8个比特组成的指数位。咱们通常用e来表示。8个比特可以表示的整数空间,就是0~255。咱们在这里用1~254映射到-126~127这254个有正有负的数上。
最后,是一个23个比特组成的有效数位。咱们用f来表示。综合科学计数法,咱们的浮点数就能够表示成下面这样:
$(-1)^s×1.f×2^e$
特殊值的表示:
以0.5为例子。0.5的符号为s应该是0,f应该是0,而e应该是-1,也就是
$0.5= (-1)^0×1.0×2^{-1}=0.5$,对应的浮点数表示,就是32个比特。
不考虑符号的话,浮点数可以表示的最小的数和最大的数,差很少是$1.17×10^{-38}$和$3.40×10^{38}$。
回到咱们最开头,为何咱们用0.3 + 0.6不能获得0.9呢?这是由于,浮点数没有办法精确表示0.三、0.6和0.9。
咱们输入一个任意的十进制浮点数,背后都会对应一个二进制表示。
好比:9.1,那么,首先,咱们把这个数的整数部分,变成一个二进制。这里的9,换算以后就是1001。
接着,咱们把对应的小数部分也换算成二进制。和整数的二进制表示采用“除以2,而后看余数”的方式相比,小数部分转换成二进制是用一个类似的反方向操做,就是乘以2,而后看看是否超过1。若是超过1,咱们就记下1,并把结果减去1,进一步循环操做。在这里,咱们就会看到,0.1其实变成了一个无限循环的二进制小数,0.000110011。这里的“0011”会无限循环下去。
结果就是:$1.0010$$0011$$0011… × 2^3$
这里的符号位s = 0,对应的有效位f=001000110011…。由于f最长只有23位,那这里“0011”无限循环,最多到23位就截止了。因而,f=00100011001100110011 001。最后的一个“0011”循环中的最后一个“1”会被截断掉。
对应的指数为e,表明的应该是3。由于指数位有正又有负,因此指数位在127以前表明负数,以后表明正数,那3其实对应的是加上127的偏移量130,转化成二进制,就是130,对应的就是指数位的二进制,表示出来就是10000010。
最终获得的二进制表示就变成了:
010000010 0010 0011001100110011 001
若是咱们再把这个浮点数表示换算成十进制, 实际准确的值是9.09999942779541015625。
浮点数的加法是:先对齐、再计算。
那咱们在计算0.5+0.125的浮点数运算的时候,首先要把两个的指数位对齐,也就是把指数位都统一成两个其中较大的-1。对应的有效位1.00…也要对应右移两位,由于f前面有一个默认的1,因此就会变成0.01。而后咱们计算二者相加的有效位1.f,就变成了有效位1.01,而指数位是-1,这样就获得了咱们想要的加法后的结果。
其中指数位较小的数,须要在有效位进行右移,在右移的过程当中,最右侧的有效位就被丢弃掉了。这会致使对应的指数位较小的数,在加法发生以前,就丢失精度。
计算机每执行一条指令的过程,能够分解成这样几个步骤。
Fetch - Decode - Execute循环称之为指令周期(Instruction Cycle)。
在取指令的阶段,咱们的指令是放在存储器里的,实际上,经过PC寄存器和指令寄存器取出指令的过程,是由控制器(Control Unit)操做的。指令的解码过程,也是由控制器进行的。一旦到了执行指令阶段,不管是进行算术操做、逻辑操做的R型指令,仍是进行数据传输、条件分支的I型指令,都是由算术逻辑单元(ALU)操做的,也就是由运算器处理的。不过,若是是一个简单的无条件地址跳转,那么咱们能够直接在控制器里面完成,不须要用到运算器。
有一些电路,只须要给定输入,就能获得固定的输出。这样的电路,咱们称之为组合逻辑电路(Combinational Logic Circuit)。
时序逻辑电路有如下几个特色:
最多见的就是D触发器,电路的输出信号不仅仅取决于当前的输入信号,还要取决于输出信号以前的状态。
PC寄存器就是程序计数器。
加法器的两个输入,一个始终设置成1,另一个来自于一个D型触发器A。咱们把加法器的输出结果,写到这个D型触发器A里面。因而,D型触发器里面的数据就会在固定的时钟信号为1的时候更新一次。
这样,咱们就有了一个每过一个时钟周期,就能固定自增1的自动计数器了。
指令流水线指的是把一个指令拆分红一个一个小步骤,从而来减小单条指令执行的“延时”。经过同时在执行多条指令的不一样阶段,咱们提高了CPU的“吞吐率”。
若是咱们把一个指令拆分红“取指令-指令译码-执行指令”这样三个部分,那这就是一个三级的流水线。若是咱们进一步把“执行指令”拆分红“ALU计算(指令执行)-内存访问-数据写回”,那么它就会变成一个五级的流水线。
五级的流水线,就表示咱们在同一个时钟周期里面,同时运行五条指令的不一样阶段。
咱们能够看这样一个例子。咱们顺序执行这样三条指令。
若是咱们是在单指令周期的CPU上运行,最复杂的指令是一条浮点数乘法,那就须要600ps。那这三条指令,都须要600ps。三条指令的执行时间,就须要1800ps。
若是咱们采用的是6级流水线CPU,每个Pipeline的Stage都只须要100ps。那么,在这三个指令的执行过程当中,在指令1的第一个100ps的Stage结束以后,第二条指令就开始执行了。在第二条指令的第一个100ps的Stage结束以后,第三条指令就开始执行了。这种状况下,这三条指令顺序执行所须要的总时间,就是800ps。那么在1800ps内,使用流水线的CPU比单指令周期的CPU就能够多执行一倍以上的指令数。
能够看到,在第1条指令执行到访存(MEM)阶段的时候,流水线里的第4条指令,在执行取指令(Fetch)的操做。访存和取指令,都要进行内存数据的读取。可是内存在一个时钟周期是没办法都作的。
解决办法:在高速缓存层面拆分成指令缓存和数据缓存
在CPU内部的高速缓存部分进行了区分,把高速缓存分红了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
int main() { int a = 1; int b = 2; a = a + 2; b = a + 3; }
这里须要保证a和b的值先赋,而后才能进行准确的运算。这个先写后读的依赖关系,咱们通常被称之为数据依赖,也就是Data Dependency。
先读后写
int main() { int a = 1; int b = 2; a = b + a; b = a + b; }
这里咱们先要读出a = b+a,而后才能正确的写入b的值。这个先读后写的依赖,通常被叫做反依赖,也就是Anti-Dependency。
写后再写
int main() { int a = 1; a = 2; }
很明显,两个写入操做不能乱,要否则最终结果就是错误的。这个写后再写的依赖,通常被叫做输出依赖,也就是Output Dependency。
解决办法:流水线停顿(Pipeline Stall)
若是咱们发现了后面执行的指令,会对前面执行的指令有数据层面的依赖关系,那最简单的办法就是“再等等”。咱们在进行指令译码的时候,会拿到对应指令所须要访问的寄存器和内存地址。
在实践过程当中,在执行后面的操做步骤前面,插入一个NOP操做,也就是执行一个其实什么都不干的操做。
在执行的代码中,一旦遇到 if…else 这样的条件分支,或者 for/while 循环的时候会发生相似cmp比较指令、jmp和jle这样的条件跳转指令。
在jmp指令发生的时候,CPU可能会跳转去执行其余指令。jmp后的那一条指令是否应该顺序加载执行,在流水线里面进行取指令的时候,咱们无法知道。要等jmp指令执行完成,去更新了PC寄存器以后,咱们才能知道,是否执行下一条指令,仍是跳转到另一个内存地址,去取别的指令。
解决办法:
缩短分支延迟
条件跳转指令其实进行了两种电路操做。
第一种,是进行条件比较。
第二种,是进行实际的跳转,也就是把要跳转的地址信息写入到PC寄存器。不管是opcode,仍是对应的条件码寄存器,仍是咱们跳转的地址,都是在指令译码(ID)的阶段就能得到的。而对应的条件码比较的电路,只要是简单的逻辑门电路就能够了,并不须要一个完整而复杂的ALU。
因此,咱们能够将条件判断、地址跳转,都提早到指令译码阶段进行,而不须要放在指令执行阶段。对应的,咱们也要在CPU里面设计对应的旁路,在指令译码阶段,就提供对应的判断比较的电路。
分支预测
最简单的分支预测技术,叫做“伪装分支不发生”。顾名思义,天然就是仍然按照顺序,把指令往下执行。
若是分支预测失败了呢?那咱们就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操做,在流水线里面,叫做Zap或者Flush。CPU不只要执行后面的指令,对于这些已经在流水线里面执行到一半的指令,咱们还须要作对应的清除操做。
动态分支预测
就是记录当前分支的比较状况,直接用当前分支的比较状况,来预测下一次分支时候的比较状况。
例子:
public class BranchPrediction { public static void main(String args[]) { long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++) { for (int j = 0; j <1000; j ++) { for (int k = 0; k < 10000; k++) { } } } long end = System.currentTimeMillis(); System.out.println("Time spent is " + (end - start)); start = System.currentTimeMillis(); for (int i = 0; i < 10000; i++) { for (int j = 0; j <1000; j ++) { for (int k = 0; k < 100; k++) { } } } end = System.currentTimeMillis(); System.out.println("Time spent is " + (end - start) + "ms"); } }
输出:
Time spent in first loop is 5ms Time spent in second loop is 15ms
分支预测策略最简单的一个方式,天然是“假定分支不发生”。对应到上面的循环代码,就是循环始终会进行下去。在这样的状况下,上面的第一段循环,也就是内层 k 循环10000次的代码。每隔10000次,才会发生一次预测上的错误。而这样的错误,在第二层 j 的循环发生的次数,是1000次。
最外层的 i 的循环是100次。每一个外层循环一次里面,都会发生1000次最内层 k 的循环的预测错误,因此一共会发生 100 × 1000 = 10万次预测错误。
经过流水线停顿能够解决资源竞争产生的问题,可是,插入过多的NOP操做,意味着咱们的CPU老是在空转,干吃饭不干活。因此咱们提出了操做数前推这样的解决方案。
add $t0, $s2,$s1 add $s2, $s1,$t0
第一条指令,把 s1 和 s2 寄存器里面的数据相加,存入到 t0 这个寄存器里面。
第二条指令,把 s1 和 t0 寄存器里面的数据相加,存入到 s2 这个寄存器里面。
咱们要在第二条指令的译码阶段以后,插入对应的NOP指令,直到前一天指令的数据写回完成以后,才能继续执行。可是这样浪费了两个时钟周期。
这个时候彻底能够在第一条指令的执行阶段完成以后,直接将结果数据传输给到下一条指令的ALU。而后,下一条指令不须要再插入两个NOP阶段,就能够继续正常走到执行阶段。
这样的解决方案,咱们就叫做操做数前推(Operand Forwarding),或者操做数旁路(Operand Bypassing)。
在乱序执行的状况下,只有CPU内部指令的执行层面,多是“乱序”的。
例子:
a = b + c d = a * e x = y * z
里面的 d 依赖于 a 的计算结果,不会在 a 的计算完成以前执行。可是咱们的CPU并不会闲着,由于 x = y * z 的指令一样会被分发到保留站里。由于 x 所依赖的 y 和 z 的数据是准备好的, 这里的乘法运算不会等待计算 d,而会先去计算 x 的值。
若是咱们只有一个FU可以计算乘法,那么这个FU并不会由于 d 要等待 a 的计算结果,而被闲置,而是会先被拿去计算 x。
在 x 计算完成以后,d 也等来了 a 的计算结果。这个时候,咱们的FU就会去计算出 d 的结果。而后在重排序缓冲区里,把对应的计算结果的提交顺序,仍然设置成 a -> d -> x,而计算完成的顺序是 x -> a -> d。
在这整个过程当中,整个计算乘法的FU都没有闲置,这也意味着咱们的CPU的吞吐率最大化了。
乱序执行,极大地提升了CPU的运行效率。核心缘由是,现代CPU的运行速度比访问主内存的速度要快不少。若是彻底采用顺序执行的方式,不少时间都会浪费在前面指令等待获取内存数据的时间里。CPU不得不加入NOP操做进行空转。