02-原理篇:指令和运算(13-16)

467 阅读13分钟

13 | 加法器:如何像搭乐高一样搭电路(上)?

image.png

逻辑运算

  1. 与(AND)
    全一为一,有零为零。即只有两个操作数都为1时,结果才为1,其他情况均为0(也可以说,只要有0,结果就为0)。
  2. 或(OR)
    全零为零,有一为一。即只有两个操作数都为0时,结果才为0,其他情况均为1(也可以说,只要有1,结果就为1)。
  3. 非(NOT)
    一变零,零变一。即操作数为1时结果为0,操作数为0时结果为1
  4. 异或(XOR)
    相异为一,相同为零。即两个操作数不一样时结果为1,两个操作数相同时结果为0
操作数1操作数2结果值
110
101
011
000
  1. 同或(XNOR)
    相同为一,相异为零。与异或运算规则相反。即两个操作数值相同时结果为1,两个操作数不一样时结果为0
操作数1操作数2结果值
111
100
010
001
  1. 与非(NAND)
    先与后非(全一为零,有零为一)。也就是将两个操作数先进行“逻辑与运算”,对与“运算结果值”再进行“逻辑非运算”,产生最终的结果。
操作数1操作数2与运算结果值最终结果值
1110
1001
0101
0001
  1. 或非(XOR)
    先或后非(全零为一,有一为零)。也就是将两个操作数先进行“逻辑或运算”,对“或运算结果值”再进行“逻辑非运算”,产生最终的结果。
操作数1操作数2或运算结果值最终结果值
1110
1010
0110
0001

异或门和半加器

异或门就是一个最简单的整数加法,所需要使用的基本门电路。

通过一个异或门计算出个位,通过一个与门计算出是否进位,我们就通过电路算出了 一个一位数的加法。于是,我们把两个门电路打包,给它取一个名字,就叫作半加器(Half Adder)。

image.png

用三个基本门组成异或门 image.png

全加器

我们用两个半加器和一个或门,就能组合成一个全加器。第 一个半加器,我们用和个位的加法一样的方式,得到是否进位 X 和对应的二个数加和后的 结果 Y,这样两个输出。然后,我们把这个加和后的结果 Y,和个位数相加后输出的进位信息U,再连接到一个半加器上,就会再拿到一个是否进位的信号 V 和对应的加和后的结果 W。

这个 W 就是我们在二位上留下的结果。我们把两个半加器的进位输出,作为一个或门的输入连接起来,只要两次加法中任何一次需要进位,那么在二位上,我们就会向左侧的四位进 一位。因为一共只有三个 bit 相加,即使 3 个 bit 都是 1,也最多会进一位。

这样,通过两个半加器和一个或门,我们就得到了一个,能够接受进位信号、加数和被加 数,这样三个数组成的加法。这就是我们需要的全加器。

image.png

在硬件层面,我们通过门电路、半加器、全加器一层层搭出了加法器这样的功能组件。我们 把这些用来做算术逻辑计算的组件叫作 ALU,也就是算术逻辑单元。

真实的加法器,使用的是一种叫作超前进位加法器的东西。你可以找到北京大学 在 Coursera 上开设的《计算机组成》课程中的 Video-306 “加法器优化”一节,了解一 下超前进位加法器的实现原理,以及我们为什么要使用它。

问题1:用补码表示的有符号数,这个加法器是否可以实现正数加负数这样的运算呢?如果不行,我 们应该怎么搭建对应的电路呢?

补码计算:
加法:直接加
减法:减数取反加一,减法变加法(取反加一可以得到相反数的补码)

14 | 乘法器:如何像搭乐高一样搭电路(下)?

顺序乘法的实现过程

image.png

image.png

在这个乘法器的实现过程里,我们其实就是把乘法展开,变成 了“加法 + 位移”来实现。我们用的是 4 位数,所以要进行 4 组“位移 + 加法”的操 作。而且这 4 组操作还不能同时进行。因为下一组的加法要依赖上一组的加法后的计算结 果,下一组的位移也要依赖上一组的位移的结果。这样,整个算法是“顺序”的,每一组加 法或者位移的运算都需要一定的时间。

并行加速方法

image.png

电路并行

并行加速的办法,看起来还是有点儿笨。我们回头来做一个抽象的思考。之所 以我们的计算会慢,核心原因其实是“顺序”计算,也就是说,要等前面的计算结果完成之 后,我们才能得到后面的计算结果。

每一个全加器,都要等待上一个全加器,把对应 的进入输入结果算出来,才能算下一位的输出。位数越多,越往高位走,等待前面的步骤就 越多,这个等待的时间有个专门的名词,叫作门延迟(Gate Delay)。

完全展开电路,高位的进位和 计算结果,可以和低位的计算结果同时获得。这个的核心原因是电路是天然并行的,一个输入信号,可以同时传播到所有接通的线路当中。

image.png

通过精巧地设计电路,用较少的门电路和寄存器,就能够计算完成乘法这样 相对复杂的运算。是用更少更简单的电路,但是需要更长的门延迟和时钟周期;还是用更复 杂的电路,但是更短的门延迟和时钟周期来计算一个复杂的指令,这之间的权衡,其实就是 计算机体系结构中 RISC 和 CISC 的经典历史路线之争。

15 | 浮点数和定点数(上):怎么用有限的Bit表示尽可能多的信息?

浮点数的不精确性

用二进制来表示十进制的编码方式,叫作BCD 编码(Binary-Coded Decimal)。
用 4 个比特来表示 0~9 的整数,那么 32 个比特就可以表 示 8 个这样的整数。然后我们把最右边的 2 个 0~9 的整数,当成小数部分;把左边 6 个 0~9 的整数,当成整数部分。这样,我们就可以用 32 个比特,来表示从 0 到 999999.99 这样 1 亿个实数了。

没办法同时表示很大的数字和很小的数字,也很浪费,能够表示的数字太少。

浮点数的表示

单精度的 32 个比特可以分成三部分。
第一部分是一个符号位,用来表示是正数还是负数。我们一般用s来表示。在浮点数里,我们不像正数分符号数还是无符号数,所有的浮点数都是有符号的。
接下来是一个 8 个比特组成的指数位。我们一般用e来表示。8 个比特能够表示的整数空 间,就是 0~255。我们在这里用 1~254 映射到 -126~127 这 254 个有正有负的数上。 因为我们的浮点数,不仅仅想要表示很大的数,还希望能够表示很小的数,所以指数位也会有负数。
你发现没,我们没有用到 0 和 255。没错,这里的 0(也就是 8 个比特全部为 0) 和 255 (也就是 8 个比特全部为 1)另有它用,我们等一下再讲。 最后,是一个 23 个比特组成的有效数位。我们用f来表示。综合科学计数法,我们的浮点 数就可以表示成下面这样:

image.png

image.png

image.png 在这样的浮点数表示下,不考虑符号的话,浮点数能够表示的最小的数和最大的数,差不多是1.17*10^-38和3.40*10^38。

在这样的表示方式下,浮点数能够表示的数据范围一下子大了很多。正是因为这个数对应的小数点的位置是“浮动”的,它才被称为浮点数。随着指数位 e 的值的不同, 小数点的位置也在变动。对应的,前面的 BCD 编码的实数,就是小数点固定在某一位的方 式,我们也就把它称为定点数。

16 | 浮点数和定点数(下):深入理解浮点数到底有什么用?

浮点数的二进制转化

十进制的浮点数怎么表示成二进制

输入了一 个十进制浮点数 9.1。那么按照之前的讲解,在二进制里面,我们应该把它变成一个“符号 位 s+ 指数位 e+ 有效位数 f”的组合。第一步,我们要做的,就是把这个数变成二进制。

把这个数的整数部分,变成一个二进制。这个我们前面讲二进制的时候已经讲过了。这里的 9,换算之后就是 1001。

我们把对应的小数部分也换算成二进制。小数怎么换成二进制呢?我们先来定义一 下,小数的二进制表示是怎么回事。我们拿 0.1001 这样一个二进制小数来举例说明。和上 面的整数相反,我们把小数点后的每一位,都表示对应的 2 的 -N 次方。那么 0.1001,转 化成十进制就是:

image.png

和整数的二进制表示采用“除以 2,然后看余数”的方式相比,小数部分转换成二进制是用 一个相似的反方向操作,就是乘以 2,然后看看是否超过 1。如果超过 1,我们就记下 1, 并把结果减去 1,进一步循环操作。在这里,我们就会看到,0.1 其实变成了一个无限循环 的二进制小数,0.000110011。这里的“0011”会无限循环下去。

image.png

把整数部分和小数部分拼接在一起,9.1 这个十进制数就变成了 1001.000110011…这样一个二进制表示。

浮点数其实是用二进制的科学计数法来表示的,所以我们可以把小数点左移三位,这个数就变成了:

1.001000110011...*2^3

那这个二进制的科学计数法表示,我们就可以对应到了浮点数的格式里了。这里的符号位 s = 0,对应的有效位 f=001000110011…。因为 f 最长只有 23 位,那这里“0011”无限 循环,最多到 23 位就截止了。于是,f=00100011001100110011 001。最后的一 个“0011”循环中的最后一个“1”会被截断掉。对应的指数为 e,代表的应该是 3。因为 指数位有正又有负,所以指数位在 127 之前代表负数,之后代表正数,那 3 其实对应的是 加上 127 的偏移量 130,转化成二进制,就是 130,对应的就是指数位的二进制,表示出 来就是 10000010。 然后,我们把“s+e+f”拼在一起,就可以得到浮点数 9.1 的二进制表示了。最终得到的二 进制表示就变成了: 010000010 0010 0011001100110011 001 如果我们再把这个浮点数表示换算成十进制, 实际准确的值是 9.09999942779541015625。相信你现在应该不会感觉奇怪了。 我在这里放一个链接,这里提供了直接交互式地设置符号位、指数位和有效位数的操作。你 可以直观地看到,32 位浮点数每一个 bit 的变化,对应的有效位数、指数会变成什么样子 以及最后的十进制的计算结果是怎样的。 这个也解释了为什么,在上一讲一开始,0.3+0.6=0.899999。因为 0.3 转化成浮点数之 后,和这里的 9.1 一样,并不是精确的 0.3 了,0.6 和 0.9 也是一样的,最后的计算会出现 精度问题。

浮点数的加法和精度损失

先对齐、再计算。

两个浮点数的指数位可能是不一样的,所以我们要把两个的指数位,变成一样的,然后只去 计算有效位的加法就好了。

比如 0.5,表示成浮点数,对应的指数位是 -1,有效位是 00…(后面全是 0,记住 f 前默 认有一个 1)。0.125 表示成浮点数,对应的指数位是 -3,有效位也还是 00…(后面全是 0,记住 f 前默认有一个 1)。

image.png

32 位浮点数的有效位长度一共只有 23 位,如果两个数的指数位差出 23 位,较小的数右移 24 位之后,所有的有效位就都丢失了。这也就意味着,虽然浮点数可以表示上到 ,下到 这样的数值范围。但是在实际计算的时候,只要两个 数,差出 ,也就是差不多 1600 万倍,那这两个数相加之后,结果完全不会变化。

Kahan Summation 算法

Kahan 求和 算法,又名补偿求和或进位求和算法,是一个用来 降低有限精度浮点数序列累加值误差 的算法。它主要通过保持一个单独变量用来累积误差(常用变量名为 c)来完成的。

在浮点加法计算中,交换律(commutativity)成立,但结合律(associativity)不成立。也就是说a+b=b+aa+b = b+aa+b)+ca+(b+c)a+b)+c \neq a+(b+c)。因此在浮点序列加法计算中,我们可以从左到右一个个累加,也可以在原有顺序上,将他们两两分成一对。第二种算法会相对较慢并需要更多内存,也常被一些语言的特定求和函数使用,但相对结果更准确。

float kahanSum(vector<float> nums) {
  float sum = 0.0f;
  float c = 0.0f;
  for (auto num : nums) {
    float y = num - c;
    float t = sum + y;
    c = (t - sum) - y;
    sum = t;
  }
  return sum;
}

总结

这四节介绍了如何用门电路构造加法器和乘法器,然后介绍了如何用二进制表示浮点数和定点数,以及浮点数相加的误差怎么解决。