不务正业系列6:计算机组成原理笔记


原文地址:https://blog.csdn.net/qyf__123/article/list/1

image.png

1. 基本结构

1.1 传统结构

三大件:CPU(分为CU和PU,用于计算)、内存(用于存储)和主板(包含chipset和bus,用于通信)
其他部件:输入输出、CPU辅助(显卡等)
以上部件+可编程+可存储 = 现代计算机
image.png

1.2 虚拟机

Type-1 类型的虚拟化机,实际的指令不需要再通过宿主机的操作系统,而可以直接通过虚拟机监视器访问硬件,所以性能比 Type-2 要好。而 Type-2 类型的虚拟机,所有的指令需要经历客户机操作系统、虚拟机监视器、宿主机操作系统,所以性能上要慢上不少。不过因为经历了宿主机操作系统的一次 “翻译” 过程,它的硬件兼容性往往会更好一些。
今天,即使是 Type-1 型的虚拟机技术,我们也会觉得有一些性能浪费。我们常常在同一个物理机上,跑上 8 个、10 个的虚拟机。而且这些虚拟机的操作系统,其实都是同一个 Linux Kernel 的版本。于是,轻量级的 Docker 技术就进入了我们的视野。Docker 也被很多人称之为 “操作系统级” 的虚拟机技术。不过 Docker 并没有再单独运行一个客户机的操作系统,而是直接运行在宿主机操作系统的内核之上。所以,Docker 也是现在流行的微服务架构底层的基础设施。

2. CPU概念

2.1 主频

你在买电脑的时候,一定关注过 CPU 的主频。比如我手头的这台电脑就是 Intel Core-i7-7700HQ 2.8GHz,这里的 2.8GHz 就是电脑的主频(Frequency/Clock Rate)。这个 2.8GHz,我们可以先粗浅地认为,CPU 在 1 秒时间内,可以执行的简单指令的数量是 2.8G 条。
目前的主频在这个数量级上不去了,主要原因是功耗。功耗 ~= 1/2 ×负载电容×电压的平方×开关频率×晶体管数量。目前的CPU,功耗在10-100瓦的数量级之间,再高的功耗就撑不住了。
计算机每执行一条指令的过程,可以分解成这样几个步骤:
1.Fetch(取得指令),也就是从 PC 寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把 PC 寄存器自增,好在未来执行下一条指令。
2.Decode(指令译码),也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是 R、I、J 中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。
3.Execute(执行指令),也就是实际运行对应的 R、I、J 这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。
CPU 内部的操作速度很快,但是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来,所以我们一般把从内存里面读取一条指令的最短时间,称为 CPU 周期。
image.png

2.2 计算机指令

举个C的例子:

// test.c
int main()
{
  int a = 1; 
  int b = 2;
  a = a + b;
}

首先需要用编译器(GCC等)进行compile,变成汇编语言(ASM language),然后再Assemble成机器码(Machine code)
使用gcc -g -c test.c进行编译,再使用objdump -d -S test.o查看编码:

test.o:	file format Mach-O 64-bit x86-64

Disassembly of section __TEXT,__text:
_main:
; {
       0:	55 	        pushq	%rbp
       1:	48 89 e5 	movq	%rsp, %rbp
       4:	31 c0 	xorl	%eax, %eax
; int a = 1;
       6:	c7 45 fc 01 00 00 00 	movl	$1, -4(%rbp)
; int b = 1;
       d:	c7 45 f8 01 00 00 00 	movl	$1, -8(%rbp)
; a = a + b;
      14:	8b 4d fc 	movl	-4(%rbp), %ecx
      17:	03 4d f8 	addl	-8(%rbp), %ecx
      1a:	89 4d fc 	movl	%ecx, -4(%rbp)
; }
      1d:	5d 	        popq	%rbp
      1e:	c3 	        retq

左边16进制数字是机器码,右边的pushq、movq是汇编代码。常见的指令如下:
image.png
不同的机器的指令集不一样,下图是MIPS指令集:
image.png

机器码最后要在CPU上执行,还要通过指令译码器变成一堆二进制信号。

2.3 寄存器:CPU的缓存

逻辑上,我们可以认为CPU是由一堆寄存器构成的。一个 CPU 里面会有很多种不同功能的寄存器。这里介绍三种比较特殊的。

  1. PC寄存器(Program Counter Register),我们也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。
  2. 指令寄存器(Instruction Register),用来存放当前正在执行的指令。
  3. 条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。
    一个程序执行的时候,CPU会根据PC寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。而有些特殊指令,比如上一讲我们讲到 J 类指令,也就是跳转指令,会修改 PC 寄存器里面的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。事实上,这些跳转指令的存在,也是我们可以在写程序的时候,使用 if…else 条件语句和 while/for 循环语句的原因。

寄存器可以理解为Flip-Flop(触发器)和Latches(锁存器)构成的简单电路。N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。我们一般使用的Intel i7 CPU有16个64位寄存器。
image.png

再介绍两个特殊的寄存器,用于内存栈:
RSP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶,是实际进行计算的地方。
RBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
内存栈用于函数调用。函数特殊的地方在于,执行完之后还要返回到原地址,因此需要多用一个寄存器记录返回地址,当函数调用层级比较多的时候,寄存器的个数就会不够用了。因此在内存中单独开辟了一个stack用于记录函数调用的入口地址,每次函数执行完了之后将地址出栈即可。此外,寄存器放不下的函数参数也会放在stack中,合称stack frame,如下图:
image.png

每个函数开始前有一个push rbp和mov rbp, rsp代码,就是将待执行的语句(rbp)入栈,把控制权交给函数(rsp)。函数执行完之后,调用pop rbp和ret将控制权又交给了原代码。所谓的stack overflow,就是调用的层级或参数实在太多,内存里面也放不下了。
如果函数只执行一次,那么可以将函数内的代码直接嵌入到原代码中,称为内联。内联带来的优化是,CPU 需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。

3. CU:程序编译、加载和执行过程

3.1 ELF和静态链接库

汇编结束后生成的.o文件并不能直接执行,而是要用链接器将各个分散的目标文件串联起来最终生成一个可执行文件才行。目标文件和可执行文件都是ELF格式,中文名字叫可执行与可链接文件格式,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。
image.png
这种合并代码段的方法,就是静态链接(Static Link)。
我们这里讲的是 Linux 下的 ELF 文件格式,而 Windows 的可执行文件格式是一种叫作PE(Portable Executable Format)的文件格式。Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式。
如果我们有一个可以能够解析 PE 格式的装载器,我们就有可能在 Linux 下运行 Windows 程序了。这样的程序真的存在吗?没错,Linux 下著名的开源项目 Wine,就是通过兼容 PE 格式的装载器,使得我们能直接在 Linux 下运行 Windows 程序的。而现在微软的 Windows 里面也提供了 WSL,也就是 Windows Subsystem for Linux,可以解析和加载 ELF 格式的文件。

3.2 如何加载到内存中

第一个问题,如何同时将多个程序同时加载到内存中?方法就是使用虚拟内存,对CPU来说起始地址都是一样的。
image.png

第二个问题,内存不断加载释放造成的碎片怎么办?方法就是内存交换(Memory Swapping),将程序内存写到硬盘上,然后再从硬盘上读回来到内存里面。
image.png

为了减少内存交换的次数,现代计算机使用了内存分页(paging)技术,把整个物理内存空间切成一段段固定尺寸的大小,程序所需要的虚拟内存空间,也同样切成一段段固定尺寸的大小。从虚拟内存到物理内存的映页的尺寸一般远远小于整个程序的大小。你可以通过getconf PAGE_SIZE命令看看你手头的 Linux 系统设置的页的大小。
分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。

3.3 动态链接库

有一些常用的程序,可以供大家共同使用,叫作动态链接(Dynamic Link)。在 Windows 下,这些共享库文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。
image.png
在动态链接对应的共享库,我们在共享库的 data section 里面,保存了一张全局偏移表(GOT,Global Offset Table)。**虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。**所有需要引用当前共享库外部的地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。

4. PU:数据处理方法

4.1 加法器

首先,数据在CPU中使用二进制方式存储的。
通过一个异或门计算出个位,通过一个与门计算出是否进位,我们就通过电路算出了一个一位数的加法。于是,我们把两个门电路打包,给它取一个名字,就叫作半加器(Half Adder)。我们用两个半加器和一个或门,就能组合成一个全加器。
image.png
image.png
有了全加器,我们要进行对应的两个 8 bit 数的加法就很容易了。我们只要把 8 个全加器串联起来就好了。个位的全加器的进位信号作为二位全加器的输入信号,二位全加器的进位信号再作为四位的全加器的进位信号。这样一层层串接八层,我们就得到了一个支持 8 位数加法的算术单元。如果要扩展到 16 位、32 位,乃至 64 位,都只需要多串联几个输入位和全加器就好了。
image.png
出于性能考虑,实际 CPU 里面使用的加法器,比起我们今天讲解的电路还有些差别,会更复杂一些。真实的加法器,使用的是一种叫作超前进位加法器的东西。

4.2 乘法器

使用加法器、左移和右移电路就能实现乘法器
image.png

这种按照顺序计算的方式符合人脑单线程的方式:
image.png

对于计算机来说,可以火力全开,所有位数同时计算:
image.png

还可以继续减少延时,也就是增加电路,将一些计算提前做好,能减少树的层数,和超前进位加法器类似。

4.3 数据表示方法

定点数:我们用 4 个比特来表示 0~9 的整数,那么 32 个比特就可以表示 8 个这样的整数。然后我们把最右边的 2 个 0~9 的整数,当成小数部分;把左边 6 个 0~9 的整数,当成整数部分。这样,我们就可以用 32 个比特,来表示从 0 到 999999.99 这样 1 亿个实数了。这种用二进制来表示十进制的编码方式,叫作BCD 编码(Binary-Coded Decimal)。其实它的运用非常广泛,最常用的是在超市、银行这样需要用小数记录金额的情况里。在超市里面,我们的小数最多也就到分。这样的表示方式,比较直观清楚,也满足了小数部分的计算。

浮点数:浮点数的科学计数法的表示,有一个IEEE的标准,它定义了两个基本的格式。一个是用 32 比特表示单精度的浮点数,也就是我们常常说的 float 或者 float32 类型。另外一个是用 64 比特表示双精度的浮点数,也就是我们平时说的 double 或者 float64 类型。
首先看float32:
image.png
image.png
第一部分是一个符号位,用来表示是正数还是负数。
接下来是一个 8 个比特组成的指数位。用 1~254 映射到 -126~127 这 254 个有正有负的数上。
最后,是一个 23 个比特组成的有效数位。
在python中,我们执行0.3+0.6,得到0.8999999
为什么我们用 0.3 + 0.6 不能得到 0.9 呢?这是因为,浮点数没有办法精确表示 0.3、0.6 和 0.9。事实上,我们拿出 0.1~0.9 这 9 个数,其中只有 0.5 能够被精确地表示成二进制的浮点数,也就是 s = 0、e = -1、f = 0 这样的情况。
浮点数的加法遵循先对齐,再相加的原则。

5 CPU电路结构

image.png
CPU 所需要的硬件电路
那么,要想搭建出来整个 CPU,我们需要在数字电路层面,实现这样一些功能。

第一,进行计算的ALU ,是一个没有状态的,根据输入计算输出结果的第一个电路。

第二,我们需要有一个能够进行状态读写的电路元件,也就是我们的寄存器。我们需要有一个电路,能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用,但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路,就有锁存器(Latch),以及我们后面要讲的 D 触发器(Data/Delay Flip-flop)的电路。

第三,我们需要有一个“自动”的电路,按照固定的周期,不停地实现 PC 寄存器自增,自动地去执行“Fetch - Decode - Execute“的步骤。我们的程序执行,并不是靠人去拨动开关来执行指令的。我们希望有一个“自动”的电路,不停地去一条条执行指令。

我们看似写了各种复杂的高级程序进行各种函数调用、条件跳转。其实只是修改 PC 寄存器里面的地址。PC 寄存器里面的地址一修改,计算机就可以加载一条指令新指令,往下运行。实际上,PC 寄存器还有一个名字,就叫作程序计数器。顾名思义,就是随着时间变化,不断去数数。数的数字变大了,就去执行一条新指令。所以,我们需要的就是一个自动数数的电路。

第四,我们需要有一个“译码”的电路。无论是对于指令进行 decode,还是对于拿到的内存地址去获取对应的数据或者指令,我们都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。

5.1 时钟信号

要实现这四种电路中的中间两种,我们还需要时钟电路的配合,称为时序逻辑电路。下图是实现时钟电路的晶体振荡器:
image.png
这种电路,其实就相当于把电路的输出信号作为输入信号,再回到当前电路。这样的电路构造方式呢,我们叫作反馈电路(Feedback Circuit)。
image.png

5.2 寄存器

一般寄存器是指由基本的RS触发器结构衍生出来的D触发。
首先看RS 触发器电路,这个电路由两个或非门电路组成。这样一个电路,我们称之为触发器(Flip-Flop)。接通开关 R,输出变为 1,即使断开开关,输出还是 1 不变。接通开关 S,输出变为 0,即使断开开关,输出也还是 0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能。
image.png
上面的电路,输入都断开后,结果不停的再电路里面循环,也就是所谓的存储功能。我们看看下面这个电路,这个在我们的上面的 R-S 触发器基础之上,在 R 和 S 开关之后,加入了两个与门,同时给这两个与门加入了一个时钟信号 CLK作为电路输入。
这样,当时钟信号 CLK 在低电平的时候,与门的输入里有一个 0,两个实际的 R 和 S 后的与门的输出必然是 0。也就是说,无论我们怎么按 R 和 S 的开关,根据 R-S 触发器的真值表,对应的 Q 的输出都不会发生变化。
只有当时钟信号 CLK 在高电平的时候,与门的一个输入是 1,输出结果完全取决于 R 和 S 的开关。我们可以在这个时候,通过开关 R 和 S,来决定对应 Q 的输出。
image.png
如果这个时候,我们让 R 和 S 的开关,也用一个反相器连起来,也就是通过同一个开关控制 R 和 S。只要 CLK 信号是 1,R 和 S 就可以设置输出 Q。而当 CLK 信号是 0 的时候,无论 R 和 S 怎么设置,输出信号 Q 是不变的。这样,这个电路就成了我们最常用的 D 型触发器。用来控制 R 和 S 这两个开关的信号呢,我们视作一个输入的数据信号 D,也就是 Data,这就是 D 型触发器的由来。
image.png
把 R 和 S 两个信号通过一个反相器合并,我们可以通过一个数据信号 D 进行 Q 的写入操作
一个 D 型触发器,只能控制 1 个比特的读写,但是如果我们同时拿出多个 D 型触发器并列在一起,并且把用同一个 CLK 信号控制作为所有 D 型触发器的开关,这就变成了一个 N 位的 D 型触发器,也就可以同时控制 N 位的读写。
CPU 里面的寄存器可以直接通过 D 型触发器来构造。我们可以在 D 型触发器的基础上,加上更多的开关,来实现清 0 或者全部置为 1 这样的快捷操作。

5.3 计数器

有了时钟信号,我们可以提供定时的输入;有了 D 型触发器,我们可以在时钟信号控制的时间点写入数据。我们把这两个功能组合起来,就可以实现一个自动的计数器了。加法器的两个输入,一个始终设置成 1,另外一个来自于一个 D 型触发器 A。我们把加法器的输出结果,写到这个 D 型触发器 A 里面。于是,D 型触发器里面的数据就会在固定的时钟信号为 1 的时候更新一次。
image.png

5.4 译码器

我们来看一看简单的地址译码器。把“寻址”这件事情退化到最简单的情况,就是在两个地址中,去选择一个地址:
image.png

我们通过一个反相器、两个与门和一个或门,就可以实现一个选择器。通过控制反相器的输入是 0 还是 1,能够决定对应的输出信号,是和地址 A,还是地址 B 的输入信号一致。64位系统能够选择的地址是 2 6 4 2^64 264个,输出指定位置的信号。

当然,除了寻址译码器,还有其他的译码器,原理类似,结构更复杂。

5.5 合起来

image.png

首先,我们有一个自动计数器。这个自动计数器会随着时钟主频不断地自增,来作为我们的 PC 寄存器。
在这个自动计数器的后面,我们连上一个译码器。译码器还要同时连着我们通过大量的 D 触发器组成的内存。
自动计数器会随着时钟主频不断自增,从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的 CPU 指令。
读取出来的 CPU 指令会通过我们的 CPU 时钟的控制,写入到一个由 D 触发器组成的寄存器,也就是指令寄存器当中。
在指令寄存器后面,我们可以再跟一个译码器。这个译码器不再是用来寻址的了,而是把我们拿到的指令,解析成 opcode 和对应的操作数。
当我们拿到对应的 opcode 和操作数,对应的输出线路就要连接 ALU,开始进行各种算术和逻辑运算。对应的计算结果,则会再写回到 D 触发器组成的寄存器或者内存当中。

6 现代CPU技术

6.1 流水线技术

一言以蔽之:使用线平衡技术,让流水线的周期时间和节拍时间变短。
image.png
流水线之间有缓存时间消耗,因此拆解的工位也不能太多。Pentium 4 之前的 Pentium III CPU,流水线的深度是 11 级,也就是一条指令最多会拆分成 11 个更小的步骤来操作,而 CPU 同时也最多会执行 11 条指令的不同 Stage。随着技术发展到今天,你日常用的手机 ARM 的 CPU 或者 Intel i7 服务器的 CPU,流水线的深度是 14 级。可以看到,差不多 20 年过去了,通过技术进步,现代 CPU 还是增加了一些流水线深度的。那 2000 年发布的 Pentium 4 的流水线深度是多少呢?答案是 20 级,比 Pentium III 差不多多了一倍,而到了代号为 Prescott 的 90 纳米工艺处理器 Pentium 4,Intel 更是把流水线深度增加到了 31 级。事实上,31 个 Stage 的 3GHz 主频的 CPU,其实和 11 个 Stage 的 1GHz 主频的 CPU,性能是差不多的。
image.png

6.2 任务调度

将计算任务拆解到计算机的流水线上是一个复杂的排班调度问题(而且常常是一次性的任务),如果优化的不好,流水线工位的空闲时间可能会非常长,这种风险叫做Hazard。下面介绍一下流水线设计需要解决的三大冒险,分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。

  1. 结构冒险
    把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture),来自哈佛大学设计Mark I 型计算机时候的设计。对应的,我们之前说的冯·诺依曼体系结构,又叫作普林斯顿架构(Princeton Architecture)。从这些名字里,我们可以看到,早年的计算机体系结构的设计,其实产生于美国各个高校之间的竞争中。
    我们今天使用的电脑在内存上还是冯·诺依曼体系结构的。不过借鉴了哈佛结构的思路,CPU内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
    image.png

  2. 数据冒险:三种不同的依赖关系
    数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW)。

解决这些数据冒险的办法最简单的一个办法就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。NOP指令的插入,就好像一个水管(Pipeline)里面,进了一个空的气泡。在水流经过的时候,没有传送水到下一个步骤,而是给了一个什么都没有的空气泡。这也是为什么,我们的流水线停顿,又被叫作流水线冒泡(Pipeline Bubble)的原因。
image.png

一个精巧的解决方案是操作数转发(Operand Forwarding),或者操作数旁路(Operand Bypassing)。转发,其实是这个技术的逻辑含义,也就是在第 1 条指令的执行结果,直接“转发”给了第 2 条指令的 ALU 作为输入。另外一个名字,旁路(Bypassing),则是这个技术的硬件含义。为了能够实现这里的“转发”,我们在 CPU 的硬件里面,需要再单独拉一根信号传输的线路出来,使得 ALU 的计算结果,能够重新回到 ALU 的输入里来。这样的一条线路,就是我们的“旁路”。它越过(Bypass)了写入寄存器,再从寄存器读出的过程,也为我们节省了 2 个时钟周期。
image.png
还有一种方法乱序执行(https://en.wikipedia.org/wiki/Tomasulo_algorithm)指令,也就是引入一个线程池,和多线程的思想类似。
image.png

  1. 控制冒险
    在条件跳转的情况下,为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是控制冒险(Control Harzard)。这也是流水线设计里最后一种冒险。
    第一个办法,叫作缩短分支延迟。在硬件电路层面,把一些计算结果更早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。
    第二种解决方案,叫作分支预测(Branch Prediction)技术,也就是说,让我们的 CPU 来猜一猜,条件跳转后执行的指令,应该是哪一条。如果分支预测失败了,我们就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。
    第三种动态方法。根据之前条件跳转的比较结果来预测,叫做一级分支预测(One Level Branch Prediction),或者叫1 比特饱和计数(1-bit saturating counter)。这个方法,其实就是用一个比特,去记录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况。我们还可以用更多的信息,而不只是一次的分支信息来进行预测。于是,我们可以引入一个状态机(State Machine)来做这个事情。2 个比特来记录对应的状态叫作2 比特饱和计数,或者叫双模态预测器(Bimodal Predictor)。
    根据这个机制,我们在循环嵌套的时候,如果没有依赖关系,可以考虑把最复杂的循环放在最里面:
    image.png
    这个方法虽然简单,但是却非常有效。在 SPEC 89 版本的测试当中,使用这样的饱和计数方法,预测的准确率能够高达 93.5%。Intel 的 CPU,一直到 Pentium 时代,在还没有使用 MMX 指令集的时候,用的就是这种分支预测方式。

6.3 并行流水线:多发射与超标量

在指令乱序执行的过程中,我们的取指令(IF)和指令译码(ID)部分并不是并行进行的。其实只要我们把取指令和指令译码,也一样通过增加硬件的方式,并行进行就好了。我们可以一次性从内存里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。这样,我们在一个时钟周期里,能够完成的指令就不只一条了。这种 CPU 设计,我们叫作多发射(Mulitple Issue)和超标量(Superscalar)。
image.png

除了从CPU电路层面去做依赖关系的优化,能不能从编译器的角度去做优化呢?著名的 IA-64 架构的安腾(Itanium)处理器就是这样做的,超长指令字设计(Very Long Instruction Word,VLIW)不仅想让编译器来优化指令数,还想直接通过编译器,来优化 CPI。编译器在这个过程中,其实也能够知道前后数据的依赖。于是,我们可以让编译器把没有依赖关系的代码位置进行交换。然后,再把多条连续的指令打包成一个指令包。
然而,安腾处理器和Pentium 4一样,在市场上是一个失败的产品。在经历了12年之久的设计研发之后,安腾一代只卖出了几千套。而安腾二代,在从2002年开始反复挣扎了16年之后,最终在2018年被Intel宣告放弃,退出了市场。下面是一个对比图:
image.png
安腾处理器的问题有两个:一方面,安腾处理器的指令集和 x86 是不同的。这就意味着,原来 x86 上的所有程序是没有办法在安腾上运行的,而需要通过编译器重新编译才行;另一方面,安腾处理器的 VLIW 架构决定了,如果安腾需要提升并行度,就需要增加一个指令包里包含的指令数量。一旦这么做了,虽然同样是 VLIW 架构,同样指令集的安腾 CPU,程序也需要重新编译,甚至我们需要重新来写编译器,才能让程序在新的 CPU 上跑起来。总的来说,安腾处理器的前后兼容性都很差。

6.4 超线程

超线程的 CPU,其实是把一个物理层面 CPU 核心,“伪装”成两个逻辑层面的 CPU 核心。这个 CPU,会在硬件层面增加很多电路,使得我们可以在一个 CPU 核心内部,维护两个不同线程的指令的状态信息。

比如,在一个物理 CPU 核心内部,会有双份的 PC 寄存器、指令寄存器乃至条件码寄存器。这样,这个 CPU 核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的 CPU 在同时运行。所以,超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称 SMT)技术。

不过,在 CPU 的其他功能组件上,Intel 可不会提供双份。无论是指令译码器还是 ALU,一个 CPU 核心仍然只有一份。因为超线程并不是真的去同时运行两个指令,那就真的变成物理多核了。超线程的目的,是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程 B,就可以拿来干自己需要的事情。这个线程 B 可没有对于线程 A 里面指令的关联和依赖。
image.png
通常我们只要在 CPU 核心的添加 10% 左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。
我们并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好。一般是在那些各个线程“等待”时间比较长的应用场景下。比如,我们需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。这个时候,让 CPU 里的各个功能单元,去处理另外一个数据库连接的查询请求就是一个很好的应用案例。
image.png
如图,CPU的Cores,被标明了是4,而Threads,则是8。说明这个CPU,只有4个物理的CPU核心,也就是所谓的4核CPU。但是在逻辑层面,它“装作”有8个CPU核心,可以利用超线程技术,来同时运行8条指令。

6.5 SIMD:加速矩阵乘法

我们来看下面这段代码

>>> import numpy as np
>>> import timeit
>>> a = list(range(1000))
>>> b = np.array(range(1000))
>>> timeit.timeit("[i + 1 for i in a]", setup="from __main__ import a", number=1000000)
32.82800309999993
>>> timeit.timeit("np.add(1, b)", setup="from __main__ import np, b", number=1000000)
0.9787889999997788

前面使用循环来一步一步计算的算法呢,一般被称为SISD,也就是单指令单数据(Single Instruction Single Data)的处理方式。如果你手头的是一个多核 CPU 呢,那么它同时处理多个指令的方式可以叫作MIMD,也就是多指令多数据(Multiple Instruction Multiple Data)。NumPy 直接用到了 SIMD 指令,能够并行进行向量的操作。SIMD,中文叫作单指令多数据流(Single Instruction Multiple Data)数据读取和计算可以并行来做。
就以我们上面的程序为例,数组里面的每一项都是一个 integer,也就是需要 4 Bytes 的内存空间。Intel 在引入 SSE 指令集的时候,在 CPU 里面添上了 8 个 128 Bits 的寄存器。128 Bits 也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。比起循环分别读取 4 次对应的数据,时间就省下来了。
image.png
在数据读取到了之后,在指令的执行层面,SIMD 也是可以并行进行的。4 个整数各自加 1,互相之前完全没有依赖,也就没有冒险问题需要处理。只要 CPU 里有足够多的功能单元,能够同时进行这些计算,这个加法就是 4 路同时并行的,自然也省下了时间。

所以,对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用 SIMD 是一个很划算的办法。在这个大量的“数据并行”,其实通常就是实践当中的向量运算或者矩阵运算。在实际的程序开发过程中,过去通常是在进行图片、视频、音频的处理。最近几年则通常是在进行各种机器学习算法的计算。
基于 SIMD 的向量计算指令,也正是在 Intel 发布 Pentium 处理器的时候,被引入的指令集。当时的指令集叫作MMX,也就是 Matrix Math eXtensions 的缩写,中文名字就是矩阵数学扩展。而 Pentium 处理器,也是 CPU 第一次有能力进行多媒体处理。这也正是拜 SIMD 和 MMX 所赐。

从 Pentium 时代开始,我们能在电脑上听 MP3、看 VCD 了,而不用专门去买一块“声霸卡”或者“显霸卡”了。没错,在那之前,在电脑上看 VCD,是需要专门买能够解码 VCD 的硬件插到电脑上去的。而到了今天,通过 GPU 快速发展起来的深度学习技术,也一样受益于 SIMD 这样的指令级并行方案。

7 异常处理

计算机会为每一种可能会发生的异常,分配一个异常代码(Exception Number)。有些教科书会把异常代码叫作中断向量(Interrupt Vector)。异常发生的时候,通常是 CPU 检测到了一个特殊的信号。比如,你按下键盘上的按键,输入设备就会给 CPU 发一个信号。或者,正在执行的指令发生了加法溢出,同样,我们可以有一个进位溢出的信号。这些信号呢,在组成原理里面,我们一般叫作发生了一个事件(Event)。CPU 在检测到事件的时候,其实也就拿到了对应的异常代码。

这些异常代码里,I/O 发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由 CPU 预先分配好的,也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。

拿到异常代码之后,CPU 就会触发异常处理的流程。计算机在内存里,会保留一个异常表(Exception Table)。也有地方,把这个表叫作中断向量表(Interrupt Vector Table),好和上面的中断向量对应起来。这个异常表有点儿像我们在第 10 讲里讲的 GOT 表,存放的是不同的异常代码对应的异常处理程序(Exception Handler)所在的地址。

我们的 CPU 在拿到了异常码之后,会先把当前的程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序。
image.png
故障的分类:
image.png
在这四种异常里,中断异常的信号来自系统外部,而不是在程序自己执行的过程中,所以我们称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,所以我们称之为“同步“类型的异常。

在处理异常的过程当中,无论是异步的中断,还是同步的陷阱和故障,我们都是采用同一套处理流程,也就是上面所说的,“保存现场、异常代码查询、异常处理程序调用“。而中止类型的异常,其实是在故障类型异常的一种特殊情况。当故障发生,但是我们发现没有异常处理程序能够处理这种异常的情况下,程序就不得不进入中止状态,也就是最终会退出当前的程序执行。对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在 CPU 层面的切换,所以这个过程我们称之为上下文切换(Context Switch)。

8 ARM架构CPU

8.1 X86

x86是一个intel通用计算机系列的标准编号缩写,也标识一套通用的计算机指令集合,X与处理器没有任何关系,它是一个对所有*86系统的简单的通配符定义,例如:i386, 586,奔腾(pentium)。由于早期intel的CPU编号都是如8086,80286来编号,由于这整个系列的CPU都是指令兼容的,所以都用X86来标识所使用的指令集合如今的奔腾,P2,P4,赛扬系列都是支持X86指令系统的,所以都属于X86家族 。
X86指令集是美国Intel公司为其第一块16位CPU(i8086)专门开发的,美国IBM公司1981年推出的世界第一台PC机中的CPU–i8088(i8086简化版)使用的也是X86指令,同时电脑中为提高浮点数据处理能力而增加的X87芯片系列数学协处理器则另外使用X87指令,以后就将X86指令集和X87指令集统称为X86指令集。虽然随着CPU技术的不断发展,Intel陆续研制出更新型的i80386、i80486直到今天的Pentium 4(以下简为P4)系列,但为了保证电脑能继续运行以往开发的各类应用程序以保护和继承丰富的软件资源,所以Intel公司所生产的所有CPU仍然继续使用X86指令集,所以它的CPU仍属于X86系列。
另外除Intel公司之外,AMD和Cyrix等厂家也相继生产出能使用X86指令集的CPU,由于这些CPU能运行所有的为Intel CPU所开发的各种软件,所以电脑业内人士就将这些CPU列为Intel的CPU兼容产品。由于Intel X86系列及其兼容CPU都使用X86指令集,所以就形成了今天庞大的X86系列及兼容CPU阵容。当然在台式(便携式)电脑中并不都是使用X86系列CPU,部分服务器和苹果(Macintosh)机中还使用美国DIGITAL(数字)公司的Alpha 61164和PowerPC 604e系列CPU。
Intel从8086开始,286、386、486、586、P1、P2、P3、P4都用的同一种CPU架构,统称X86。

8.2 CISC和RISC

RISC 架构的 CPU 的想法其实非常直观。既然我们 80% 的时间都在用 20% 的简单指令,那我们能不能只要那 20% 的简单指令就好了呢?答案当然是可以的。因为指令数量多,计算机科学家们在软硬件两方面都受到了很多挑战。

在硬件层面,我们要想支持更多的复杂指令,CPU 里面的电路就要更复杂,设计起来也就更困难。更复杂的电路,在散热和功耗层面,也会带来更大的挑战。在软件层面,支持更多的复杂指令,编译器的优化就变得更困难。毕竟,面向 2000 个指令来优化编译器和面向 500 个指令来优化编译器的困难是完全不同的。

于是,在 RISC 架构里面,CPU 选择把指令“精简”到 20% 的简单指令。而原先的复杂指令,则通过用简单指令组合起来来实现,让软件来实现硬件的功能。这样,CPU 的整个硬件设计就会变得更简单了,在硬件层面提升性能也会变得更容易了。

RISC 的 CPU 里完成指令的电路变得简单了,于是也就腾出了更多的空间。这个空间,常常被拿来放通用寄存器。因为 RISC 完成同样的功能,执行的指令数量要比 CISC 多,所以,如果需要反复从内存里面读取指令或者数据到寄存器里来,那么很多时间就会花在访问内存上。于是,RISC 架构的 CPU 往往就有更多的通用寄存器。

除了寄存器这样的存储空间,RISC 的 CPU 也可以把更多的晶体管,用来实现更好的分支预测等相关功能,进一步去提升 CPU 实际的执行效率。程序的 CPU 执行时间 = 指令数 × CPI × Clock Cycle Time

CISC 的架构,其实就是通过优化指令数,来减少 CPU 的执行时间。而 RISC 的架构,其实是在优化 CPI。因为指令比较简单,需要的时钟周期就比较少。

Intel基于32位系统x86指令集的CPU非常成功,导致后面想要修改CPU架构非常困难。AMD趁着Intel研发安腾的时候,推出了兼容32 位x86指令集的64位架构,也就是AMD64。为了和AMD展开竞争,Intel也在2004年推出了自己的64位版x86,也就是EM64T。

8.3 微指令架构

从 Pentium Pro 时代开始,Intel 就开始在处理器里引入了微指令(Micro-Instructions/Micro-Ops)架构。而微指令架构的引入,也让 CISC 和 RISC 的分界变得模糊了。在微指令架构的 CPU 里面,编译器编译出来的机器码和汇编代码并没有发生什么变化。但在指令译码的阶段,指令译码器“翻译”出来的,不再是某一条 CPU 指令。译码器会把一条机器码,“翻译”成好几条“微指令”。这里的一条条微指令,就不再是 CISC 风格的了,而是变成了固定长度的 RISC 风格的了。

这些 RISC 风格的微指令,会被放到一个微指令缓冲区里面,然后再从缓冲区里面,分发给到后面的超标量,并且是乱序执行的流水线架构里面。不过这个流水线架构里面接受的,就不是复杂的指令,而是精简的指令了。在这个架构里,我们的指令译码器相当于变成了设计模式里的一个“适配器”(Adaptor)。这个适配器,填平了 CISC 和 RISC 之间的指令差异。
image.png
Cache里面保存的是指令译码器把CISC的指令“翻译”成RISC的微指令的结果。于是,在大部分情况下,CPU 都可以从 Cache 里面拿到译码结果,而不需要让译码器去进行实际的译码操作。这样不仅优化了性能,因为译码器的晶体管开关动作变少了,还减少了功耗。不过x86的CPU始终在功耗上还是远远落后于RISC架构的ARM,所以最终在智能手机崛起替代PC的时代,落在了ARM后面。Intel 除了 x86 和安腾之外,还推出过 Atom 这个面向移动设备的低功耗 CPU。与Centrino迅驰一样,Intel也给Atom处理器取了一个好听的中国名字,叫“凌动”。而与之搭配的Menlow平台则被改称为“迅驰凌动(Centrino Atom)”。 atom也是一波三折,在2016年时因为ARM发展太过迅速,宣布将停止开发低端的ATOM处理器, 后来貌似又起死复活,打算用在物联网等地方。

8.4 ARM

ARM 这个名字现在的含义,是“Advanced RISC Machines”。你从名字就能够看出来,ARM 的芯片是基于 RISC 架构的。不过,ARM 能够在移动端战胜 Intel,并不是因为 RISC 架构。ARM 真正能够战胜 Intel,我觉得主要是因为下面这两点原因。

第一点是功耗优先的设计。一个 4 核的 Intel i7 的 CPU,设计的时候功率就是 130W。而一块 ARM A8 的单个核心的 CPU,设计功率只有 2W。两者之间差出了 100 倍。在移动设备上,功耗是一个远比性能更重要的指标,毕竟我们不能随时在身上带个发电机。ARM 的 CPU,主频更低,晶体管更少,高速缓存更小,乱序执行的能力更弱。所有这些,都是为了功耗所做的妥协。

第二点则是低价。ARM 并没有自己垄断 CPU 的生产和制造,只是进行 CPU 设计,然后把对应的知识产权授权出去,让其他的厂商来生产 ARM 架构的 CPU。它甚至还允许这些厂商可以基于 ARM 的架构和指令集,设计属于自己的 CPU。像苹果、三星、华为,它们都是拿到了基于 ARM 体系架构设计和制造 CPU 的授权。ARM 自己只是收取对应的专利授权费用。多个厂商之间的竞争,使得 ARM 的芯片在市场上价格很便宜。所以,尽管 ARM 的芯片的出货量远大于 Intel,但是收入和利润却比不上 Intel。

不过,ARM 并不是开源的。所以,在 ARM 架构逐渐垄断移动端芯片市场的时候,“开源硬件”也慢慢发展起来了。一方面,MIPS 在 2019 年宣布开源;另一方面,从 UC Berkeley 发起的RISC-V项目也越来越受到大家的关注。而 RISC 概念的发明人,图灵奖的得主大卫·帕特森教授从伯克利退休之后,成了 RISC-V 国际开源实验室的负责人,开始推动 RISC-V 这个“CPU 届的 Linux”的开发。可以想见,未来的开源 CPU,也多半会像 Linux 一样,逐渐成为一个业界的主流选择。如果想要“打造一个属于自己 CPU”,不可不关注这个项目。

9 辅助处理器:GPU、FPGA和ASIC

9.1 GPU处理图形的原理

3D游戏的图形是用多边形建模创建出来的,物体的移动、动作,乃至根据光线发生的变化,都是通过计算机根据图形学的各种计算,实时渲染出来的。
这个对于图像进行实时渲染的过程,可以被分解成下面这样 5 个步骤:
1 顶点处理(Vertex Processing)
把物体顶点在三维空间里面的位置,转化到屏幕这个二维空间里面。这个转换的操作,就被叫作顶点处理。这样的转化都是通过线性代数的计算来进行的。可以想见,我们的建模越精细,需要转换的顶点数量就越多,计算量就越大。而且,这里面每一个顶点位置的转换,互相之间没有依赖,是可以并行独立计算的。
2 图元处理(Primitive Processing)
在顶点处理完成之后呢,我们需要开始进行第二步,也就是图元处理。图元处理,其实就是要把顶点处理完成之后的各个顶点连起来,变成多边形。其实转化后的顶点,仍然是在一个三维空间里,只是第三维的 Z 轴,是正对屏幕的“深度”。所以我们针对这些多边形,需要做一个操作,叫剔除和裁剪(Cull and Clip),也就是把不在屏幕里面,或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量。
3 栅格化(Rasterization)
在图元处理完成之后呢,渲染还远远没有完成。我们的屏幕分辨率是有限的。它一般是通过一个个“像素(Pixel)”来显示出内容的。所以,对于做完图元处理的多边形,我们要开始进行第三步操作。这个操作就是把它们转换成屏幕里面的一个个像素点。这个操作呢,就叫作栅格化。这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化。
4 片段处理(Fragment Processing)
在栅格化变成了像素点之后,我们的图还是“黑白”的。我们还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,就是片段处理。这步操作,同样也可以每个片段并行、独立进行,和上面的顶点处理和栅格化一样。
5 像素操作(Pixel Operations)
最后一步呢,我们就要把不同的多边形的像素点“混合(Blending)”到一起。可能前面的多边形可能是半透明的,那么前后的颜色就要混合在一起变成一个新的颜色;或者前面的多边形遮挡住了后面的多边形,那么我们只要显示前面多边形的颜色就好了。最终,输出到显示设备。

在上世纪 90 年代的时候,屏幕的分辨率还没有现在那么高。一般的 CRT 显示器也就是 640×480 的分辨率。这意味着屏幕上有 30 万个像素需要渲染。为了让我们的眼睛看到画面不晕眩,我们希望画面能有 60 帧。于是,每秒我们就要重新渲染 60 次这个画面。也就是说,每秒我们需要完成 1800 万次单个像素的渲染。从栅格化开始,每个像素有 3 个流水线步骤,即使每次步骤只有 1 个指令,那我们也需要 5400 万条指令,也就是 54M 条指令。

90 年代的 CPU 的性能是多少呢?93 年出货的第一代 Pentium 处理器,主频是 60MHz,后续逐步推出了 66MHz、75MHz、100MHz 的处理器。以这个性能来看,用 CPU 来渲染 3D 图形,基本上就要把 CPU 的性能用完了。因为实际的每一个渲染步骤可能不止一个指令,我们的 CPU 可能根本就跑不动这样的三维图形渲染。
也就是在这个时候,Voodoo FX 这样的图形加速卡登上了历史舞台。既然图形渲染的流程是固定的,那我们直接用硬件来处理这部分过程,不用 CPU 来计算是不是就好了?很显然,这样的硬件会比制造有同样计算性能的 CPU 要便宜得多。因为整个计算流程是完全固定的,不需要流水线停顿、乱序执行等等的各类导致 CPU 计算变得复杂的问题。我们也不需要有什么可编程能力,只要让硬件按照写好的逻辑进行运算就好了。
那个时候,整个顶点处理的过程还是都由 CPU 进行的,不过后续所有到图元和像素级别的处理都是通过 Voodoo FX 或者 TNT 这样的显卡去处理的。也就是从这个时代开始,我们能玩上“真 3D”的游戏了。
image.png

9.2 GPU的诞生

1999 年 NVidia 推出的 GeForce 256 显卡,就把顶点处理的计算能力,也从 CPU 里挪到了显卡里。不过,这对于想要做好 3D 游戏的程序员们还不够,即使到了 GeForce 256。整个图形渲染过程都是在硬件里面固定的管线来完成的。从 2001 年的 Direct3D 8.0 开始,微软第一次引入了可编程管线(Programable Function Pipeline)的概念。一开始的可编程管线呢,仅限于顶点处理(Vertex Processing)和片段处理(Fragment Processing)部分。比起原来只能通过显卡和 Direct3D 这样的图形接口提供的固定配置,程序员们终于也可以开始在图形效果上开始大显身手了。
这些可以编程的接口,我们称之为 Shader,中文名称就是 着色器。之所以叫 “着色器”,是因为一开始这些 “可编程” 的接口,只能修改顶点处理和片段处理部分的程序逻辑。我们用这些接口来做的,也主要是光照、亮度、颜色等等的处理,所以叫着色器。
image.png
不过呢,大家很快发现,虽然我们在顶点处理和片段处理上的具体逻辑不太一样,但是里面用到的指令集可以用同一套。本来 GPU 就不便宜,结果设计的电路有一半时间是闲着的。喜欢精打细算抠出每一分性能的硬件工程师当然受不了了。于是,统一着色器架构(Unified Shader Architecture)就应运而生了。

既然大家用的指令集是一样的,那不如就在 GPU 里面放很多个一样的 Shader 硬件电路,然后通过统一调度,把顶点处理、图元处理、片段处理这些任务,都交给这些 Shader 去处理,让整个 GPU 尽可能地忙起来。这样的设计,就是我们现代 GPU 的设计,就是统一着色器架构。

有意思的是,这样的 GPU 并不是先在 PC 里面出现的,而是来自于一台游戏机,就是微软的 XBox 360。后来,这个架构才被用到 ATI 和 NVidia 的显卡里。这个时候的 “着色器” 的作用,其实已经和它的名字关系不大了,而是变成了一个通用的抽象计算模块的名字。正是因为 Shader 变成一个 “通用” 的模块,才有了把 GPU 拿来做各种通用计算的用法,也就是 GPGPU(General-Purpose Computing on Graphics Processing Units,通用图形处理器)。而正是因为 GPU 可以拿来做各种通用的计算,才有了过去 10 年深度学习的火热。
image.png

9.3 GPU和CPU的差别

现代 CPU 里的晶体管变得越来越多,越来越复杂,其实已经不是用来实现 “计算” 这个核心功能,而是拿来实现处理乱序执行、进行分支预测,以及我们之后要在存储器讲的高速缓存部分。

而在 GPU 里,这些电路就显得有点多余了,GPU 的整个处理过程是一个流式处理(Stream Processing)的过程。因为没有那么多分支条件,或者复杂的依赖关系,我们可以把 GPU 里这些对应的电路都可以去掉,做一次小小的瘦身,只留下取指令、指令译码、ALU 以及执行这些计算需要的寄存器和缓存就好了。一般来说,我们会把这些电路抽象成三个部分,就是下面图里的取指令和指令译码、ALU 和执行上下文。
image.png
CPU 里有一种叫作 SIMD 的处理技术。这个技术是说,在做向量计算的时候,我们要执行的指令是一样的,只是同一个指令的数据有所不同而已。在 GPU 的渲染管线里,这个技术可就大有用处了。

无论是顶点去进行线性变换,还是屏幕上临近像素点的光照和上色,都是在用相同的指令流程进行计算。所以,GPU 就借鉴了 CPU 里面的 SIMD,用了一种叫作 SIMT(Single Instruction,Multiple Threads)的技术。SIMT 呢,比 SIMD 更加灵活。在 SIMD 里面,CPU 一次性取出了固定长度的多个数据,放到寄存器里面,用一个指令去执行。而 SIMT,可以把多条数据,交给不同的线程去处理。

各个线程里面执行的指令流程是一样的,但是可能根据数据的不同,走到不同的条件分支。这样,相同的代码和相同的流程,可能执行不同的具体的指令。这个线程走到的是 if 的条件分支,另外一个线程走到的就是 else 的条件分支了。

于是,我们的 GPU 设计就可以进一步进化,也就是在取指令和指令译码的阶段,取出的指令可以给到后面多个不同的 ALU 并行进行运算。这样,我们的一个 GPU 的核里,就可以放下更多的 ALU,同时进行更多的并行运算了。
image.png
GPU 里的指令,可能会遇到和 CPU 类似的 “流水线停顿” 问题。想到流水线停顿,你应该就能记起,我们之前在 CPU 里面讲过超线程技术。在 GPU 上,我们一样可以做类似的事情,也就是遇到停顿的时候,调度一些别的计算任务给当前的 ALU。

和超线程一样,既然要调度一个不同的任务过来,我们就需要针对这个任务,提供更多的执行上下文。所以,一个 Core 里面的执行上下文的数量,需要比 ALU 多。
image.png
我们去看 NVidia 2080 显卡的技术规格,就可以算出,它到底有多大的计算能力。

2080 一共有 46 个 SM(Streaming Multiprocessor,流式处理器),这个 SM 相当于 GPU 里面的 GPU Core,所以你可以认为这是一个 46 核的 GPU,有 46 个取指令指令译码的渲染管线。每个 SM 里面有 64 个 Cuda Core。你可以认为,这里的 Cuda Core 就是我们上面说的 ALU 的数量或者 Pixel Shader 的数量,46x64 呢一共就有 2944 个 Shader。然后,还有 184 个 TMU,TMU 就是 Texture Mapping Unit,也就是用来做纹理映射的计算单元,它也可以认为是另一种类型的 Shader。2080 的主频是 1515MHz,如果自动超频(Boost)的话,可以到 1700MHz。而 NVidia 的显卡,根据硬件架构的设计,每个时钟周期可以执行两条指令。所以,能做的浮点数运算的能力,就是:(2944 + 184)× 1700 MHz × 2 = 10.06 TFLOPS
同价位的Intel i9 9900K 的性能不到 1TFLOPS。所以,在实际进行深度学习的过程中,用 GPU 所花费的时间,往往能减少一到两个数量级。

9.4 FPGA

FPGA,也就是现场可编程门阵列(Field-Programmable Gate Array),FPGA 这样的板子,可以在 “现场” 多次进行编程。它不像 PAL(Programmable Array Logic,可编程阵列逻辑)这样更古老的硬件设备,只能 “编程” 一次,把预先写好的程序一次性烧录到硬件里面,之后就不能再修改了。
**第一,用存储换功能实现组合逻辑。**在实现 CPU 的功能的时候,我们需要完成各种各样的电路逻辑。在 FPGA 里,这些基本的电路逻辑,不是采用布线连接的方式进行的,而是预先根据我们在软件里面设计的逻辑电路,算出对应的真值表,然后直接存到一个叫作 LUT(Look-Up Table,查找表)的电路里面。这个 LUT 呢,其实就是一块存储空间,里面存储了 “特定的输入信号下,对应输出 0 还是 1”。
**第二,对于需要实现的时序逻辑电路,我们可以在 FPGA 里面直接放上 D 触发器,作为寄存器。**这个和 CPU 里的触发器没有什么本质不同。不过,我们会把很多个 LUT 的电路和寄存器组合在一起,变成一个叫作逻辑簇(Logic Cluster)的东西。在 FPGA 里,这样组合了多个 LUT 和寄存器的设备,也被叫做 CLB(Configurable Logic Block,可配置逻辑块)。

我们通过配置 CLB 实现的功能有点儿像我们前面讲过的全加器。它已经在最基础的门电路上做了组合,能够提供更复杂一点的功能。更复杂的芯片功能,我们不用再从门电路搭起,可以通过 CLB 组合搭建出来。
**第三,FPGA 是通过可编程逻辑布线,来连接各个不同的 CLB,最终实现我们想要实现的芯片功能。**这个可编程逻辑布线,你可以把它当成我们的铁路网。整个铁路系统已经铺好了,但是整个铁路网里面,设计了很多个道岔。我们可以通过控制道岔,来确定不同的列车线路。在可编程逻辑布线里面,“编程” 在做的,就是拨动像道岔一样的各个电路开关,最终实现不同 CLB 之间的连接,完成我们想要的芯片功能。

于是,通过 LUT 和寄存器,我们能够组合出很多 CLB,而通过连接不同的 CLB,最终有了我们想要的芯片功能。最关键的是,这个组合过程是可以 “编程” 控制的。而且这个编程出来的软件,还可以后续改写,重新写入到硬件里。让同一个硬件实现不同的芯片功能。

9.5 ASIC

ASIC(Application-Specific Integrated Circuit),也就是专用集成电路,是针对专门用途设计的,所以它的电路更精简,单片的制造成本也比 CPU 更低。因为电路精简,所以通常能耗要比CPU和FPGA更低。而我们上一讲所说的早期的图形加速卡,其实就可以看作是一种 ASIC。

FPGA 的优点在于,它没有硬件研发成本。ASIC 的电路设计,需要仿真、验证,还需要经过流片(Tape out),变成一个印刷的电路版,最终变成芯片。这整个从研发到上市的过程,最低花费也要几万美元,高的话,会在几千万乃至数亿美元。更何况,整个设计还有失败的可能。所以,如果我们设计的专用芯片,只是要制造几千片,那买几千片现成的 FPGA,可能远比花上几百万美元,来设计、制造 ASIC 要经济得多。

TPU就是一种ASIC。第一代 TPU,是为了做各种深度学习的推断而设计出来的,并且希望能够尽早上线。这样,Google 才能节约现有数据中心里面的大量计算资源。TPU 的硬件构造里面,把矩阵乘法、累加器和激活函数都做成了对应的专门的电路。为了满足深度学习推断功能的响应时间短的需求,TPU 设置了很大的使用 SRAM 的 Unified Buffer(UB),就好像一个 CPU 里面的寄存器一样,能够快速响应对于这些数据的反复读取。

为了让 TPU 尽可能快地部署在数据中心里面,TPU 采用了现有的 PCI-E 接口,可以和 GPU 一样直接插在主板上,并且采用了作为一个没有取指令功能的协处理器,就像 387 之于 386 一样,仅仅用来进行需要的各种运算。

在整个电路设计的细节层面,TPU 也尽可能做到了优化。因为机器学习的推断功能,通常做了数值的归一化,所以对于矩阵乘法的计算精度要求有限,整个矩阵乘法的计算模块采用了 8 Bits 来表示浮点数,而不是像 Intel CPU 里那样用上了 32 Bits。

最终,综合了种种硬件设计点之后的 TPU,做到了在深度学习的推断层面更高的能效比。按照 Google 论文里面给出的官方数据,它可以比 CPU、GPU 快上 15~30 倍,能耗比更是可以高出 30~80 倍。而 TPU,也最终替代了 Google 自己的数据中心里,95% 的深度学习推断任务。
image.png

10 存储

image.png

10.1 SRAM

SRAM 之所以被称为 “静态” 存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。在 SRAM 里面,一个比特的数据,需要 6~8 个晶体管。所以 SRAM 的存储密度不高。同样的物理空间下,能够存储的数据有限。不过,因为 SRAM 的电路简单,所以访问速度非常快。
在 CPU 里,通常会有 L1、L2、L3 这样三层高速缓存。每个 CPU 核心都有一块属于自己的 L1 高速缓存,通常分成指令缓存和数据缓存,分开存放 CPU 使用的指令和数据。L2 的 Cache 同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。而 L3 Cache,则通常是多个 CPU 核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。

10.2 DRAM

内存用的芯片和 Cache 有所不同,它用的是一种叫作 DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片,比起 SRAM 来说,它的密度更高,有更大的容量,而且它也比 SRAM 芯片便宜不少。
DRAM 被称为 “动态” 存储器,是因为 DRAM 需要靠不断地 “刷新”,才能保持数据被存储起来。DRAM 的一个比特,只需要一个晶体管和一个电容就能存储。所以,DRAM 在同样的物理空间下,能够存储的数据也就更多,也就是存储的 “密度” 更大。但是,因为数据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问延时也就更长。

10.3 高速缓存

从 CPU Cache 被加入到现有的 CPU 里开始,内存中的指令、数据,会被加载到 L1-L3 Cache 中,而不是直接由 CPU 访问内存去拿。在 95% 的情况下,CPU 都只需要访问 L1-L3 Cache,从里面读取指令和数据,而无需访问内存。
这里是一张 Intel CPU 的放大照片。这里面大片的长方形芯片,就是这个 CPU 使用的 20MB 的 L3 Cache。
image.png
现代 CPU 已经很少使用直接映射Cache了,通常用的是组相连Cache(set associative cache)。不过这里简单起见,以直接映射Cache为例。
CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。
缓存块中一共有3个数据。1)组标记(Tag),这个组标记会记录当前缓存块内存储的数据对应的内存块,而缓存块本身的地址表示访问地址的低N位,因此组标记只需要记录剩余的高位信息即可。2)有效位(valid bit),用来标记对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是 0,CPU要直接访问内存,重新加载数据。3)从主内存中加载来的实际存放的数据。
CPU 在读取数据的时候,并不是要读取一整个 Block,而是读取一个他需要的整数。这样的数据,我们叫作 CPU 里的一个字(Word)。具体是哪个字,就用这个字在整个 Block 里面的位置来决定。这个位置,我们叫作偏移量(Offset)。
CPU读取数据的过程如下图:
image.png
总结一下,一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的 Data Block 中定位对应字的位置偏移量。一个内存地址的访问包括这样 4 个步骤:
1)根据内存地址的低位,计算在 Cache 中的索引;
2)判断有效位,确认 Cache 中的数据是有效的;
3)对比内存访问地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访问的内存数据,从 Cache Line 中读取到对应的数据块(Data Block);
4)根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字。

至于高速缓存的写入策略,第一种是写直达(Write-Through)。在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。写直达的这个策略很直观,但是问题也很明显,那就是这个策略很慢。无论数据是不是在 Cache 里面,我们都需要把数据写到主内存里面。
还有一种策略叫作写回(Write-Back)。这个策略里,我们不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里。同时,我们会标记 CPU Cache 里的这个 Block 是脏(Dirty)的。所谓脏的,就是指这个时候,我们的 CPU Cache 里面的这个 Block 的数据,和主内存是不一致的。只有当 CPU Cache 里面的数据要被 “替换” 的时候,我们才把数据写入到主内存里面去。
在用了写回这个策略之后,我们在加载内存数据到 Cache 里面的时候,也要多出一步同步脏Cache的动作。如果加载内存里面的数据到Cache的时候,发现Cache Block里面有脏标记,我们也要先把Cache Block里的数据写回到主内存,才能加载数据覆盖掉Cache。在写回这个策略里,如果我们大量的操作,都能够命中缓存。那么大部分时间里,我们都不需要读写主内存,自然性能会比写直达的效果好很多。

接下来介绍一个充分利用CPU cache的Disruptor。Disruptor在RingBufferPad这个类里面定义了p1,p2一直到p7这样7个long类型的变量。我们加载任何数据,都会一次加载连续8个数值,如果这8个数值都是属于同一个不需要进行修改的数组,那这些数据会一直保存在CPU cache中,速度会很快,不需要重新从内存里面去读取数据。但是如果只有一个数值是不变的,其他数据发生变化的时候,这个数据也需要重新写回内存,并在下次读取的时候从内存中重新加载。如果这个数据是频繁读取的数据那影响就大了。
image.png
面临这样一个情况,Disruptor里发明了一个神奇的代码技巧,这个技巧就是缓存行填充。Disruptor在INITIAL_CURSOR_VALUE的前后,分别定义了7个long类型的变量。前面的7个来自继承的RingBufferPad
类,后面的7个则是直接定义在RingBuffer类里面。这14个变量没有任何实际的用途。我们既不会去读他们,也不会去写他们。而INITIAL_CURSOR_VALUE又是一个常量,也不会进行修改。所以,一旦它被加载到CPU Cache之后,只要被频繁地读取访问,就不会再被换出Cache了。这也就意味着,对于这个值的读取速度,
会是一直是CPUCache的访问速度,而不是内存的访问速度。
image.png
另一个技巧是去除了锁机制,在消息队列上直接使用CPU硬件支持的指令CAS(Compare And Swap,比较和交换),对序号进行对比,这样就不用将线程信息存储到内存栈里面了。
image.png

10.4 维护缓存一致性:MESI协议

我们平时用的电脑,应该都是多核的 CPU。我们把多核和CPU Cache两者一结合,就给我们带来了一个新的挑战。因为CPU的每个核各有各的缓存,互相之间的操作又是各自独立的,就会带来缓存一致性(Cache Coherence)的问题。
通过总线嗅探机制进行数据传播:把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去 “嗅探” 这些请求,再根据本地的情况进行响应。总线嗅探机制可以分成很多种不同的缓存一致性协议。其中最常用的,就是今天我们要讲的 MESI 协议。和很多现代的 CPU 技术一样,MESI 协议也是在 Pentium 时代,被引入到 Intel CPU 中的。MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个 “失效” 请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个 “失效” 版本的 Cache Block,然后把这个也标记成失效的就好了。
相对于写失效协议,还有一种叫作写广播(Write Broadcast)的协议。在那个协议里,一个写入请求广播到所有的 CPU 核心,同时更新各个核心里的 Cache。写广播在实现上自然很简单,但是写广播需要占用更多的总线带宽。写失效只需要告诉其他的 CPU 核心,哪一个内存地址的缓存失效了,但是写广播还需要把对应的数据传输给其他 CPU 核心。MESI 协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:
M:代表已修改(Modified)
E:代表独占(Exclusive)
S:代表共享(Shared)
I:代表已失效(Invalidated)
image.png
在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。这个共享状态是因为,这个时候,另外一个 CPU 核心,也把对应的 Cache Block,从内存里面加载到了自己的 Cache 里来。
而在共享状态下,因为同样的数据在多个 CPU 核心的 Cache 里都有。所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权。
这个操作有点儿像我们在多线程里面用到的读写锁。在共享状态下,大家都可以并行去读对应的数据。但是如果要写,我们就需要通过一个锁,获取当前写入位置的所有权。
整个 MESI 的状态,可以用一个有限状态机来表示它的状态流转。需要注意的是,对于不同状态触发的事件操作,可能来自于当前 CPU 核心,也可能来自总线里其他 CPU 核心广播出来的信号。
image.png

11. 内存

我们的内存需要被分成固定大小的页(Page),然后再通过虚拟内存地址(Virtual Address)到物理内存地址(Physical Address)的地址转换(Address Translation),才能到达实际存放数据的物理内存位置。而我们的程序看到的内存地址,都是虚拟内存地址。
image.png

11.1 简单页表

想要把虚拟内存地址,映射到物理内存地址,最直观的办法,就是来建一张映射表。这个映射表,能够实现虚拟内存里面的页,到物理内存里面的页的一一映射。这个映射表,在计算机里面,就叫作页表(Page Table)。

页表这个地址转换的办法,会把一个内存地址分成页号(Directory)和偏移量(Offset)两个部分。这么说太理论了,我以一个 32 位的内存地址为例,帮你理解这个概念。

其实,前面的高位,就是内存地址的页号。后面的低位,就是内存地址里面的偏移量。做地址转换的页表,只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。同一个页里面的内存,在物理层面是连续的。以一个页的大小是 4K 比特(4KiB)为例,我们需要 20 位的高位,12 位的低位。
image.png
image.png

11.2 多级页表

多级页表就像一个多叉树的数据结构,所以我们常常称它为页表树(Page Table Tree)。因为虚拟内存地址分布的连续性,树的第一层节点的指针,很多就是空的,也就不需要有对应的子树了。所谓不需要子树,其实就是不需要对应的 2 级、3 级的页表。找到最终的物理页号,就好像通过一个特定的访问路径,走到树最底层的叶子节点。
image.png
内存访问其实比 Cache 要慢很多。我们本来只是要做一个简单的地址转换,反而是一下子要多访问好多次内存。对于这个时间层面的性能损失,计算机工程师们专门在 CPU 里放了一块缓存芯片。这块缓存芯片我们称之为 TLB,全称是 地址变换高速缓冲(Translation-Lookaside Buffer)。这块缓存存放了之前已经进行过地址转换的查询结果。这样,当同样的虚拟地址需要进行地址转换的时候,我们可以直接在 TLB 里面查询结果,而不需要多次访问内存来完成一次转换。TLB 和我们前面讲的 CPU 的高速缓存类似,可以分成指令的 TLB 和数据的 TLB,也就是 ITLB 和 DTLB。同样的,我们也可以根据大小对它进行分级,变成 L1、L2 这样多层的 TLB。除此之外,还有一点和 CPU 里的高速缓存也是一样的,我们需要用脏标记这样的标记位,来实现 “写回” 这样缓存管理策略。为了性能,我们整个内存转换过程也要由硬件来执行。在 CPU 芯片里面,我们封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。
image.png

11.3 内存保护

第一个常见的安全机制,叫可执行空间保护(Executable Space Protection)。

这个机制是说,我们对于一个进程使用的内存,只把其中的指令部分设置成 “可执行” 的,对于其他部分,比如数据部分,不给予 “可执行” 的权限。因为无论是指令,还是数据,在我们的 CPU 看来,都是二进制的数据。我们直接把数据部分拿给 CPU,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。

这个时候,黑客们想到了一些搞破坏的办法。我们在程序的数据区里,放入一些要执行的指令编码后的数据,然后找到一个办法,让 CPU 去把它们当成指令去加载,那 CPU 就能执行我们想要执行的指令了。对于进程里内存空间的执行权限进行控制,可以使得 CPU 只能执行指令区域的代码。对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行,也会因为没有权限而被阻挡掉。
第二个常见的安全机制,叫地址空间布局随机化(Address Space Layout Randomization)。

内存层面的安全保护核心策略,是在可能有漏洞的情况下进行安全预防。上面的可执行空间保护就是一个很好的例子。但是,内存层面的漏洞还有其他的可能性。

这里的核心问题是,其他的人、进程、程序,会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,我们需要知道这些指令和数据所在的位置才行。

原先我们一个进程的内存布局空间是固定的,所以任何第三方很容易就能知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址,让破坏者猜不出来。猜不出来呢,自然就没法找到想要修改的内容的位置。如果只是随便做点修改,程序只会 crash 掉,而不会去执行计划之外的代码。
image.png

12 总线与IO设备

12.1 总线

总线,其实就是一组线路。我们的 CPU、内存以及输入和输出设备,都是通过这组线路,进行相互间通信的。总线的英文叫作 Bus,就是一辆公交车。这个名字很好地描述了总线的含义。我们的 “公交车” 的各个站点,就是各个接入设备。要想向一个设备传输数据,我们只要把数据放上公交车,在对应的车站下车就可以了。
CPU 和内存以及高速缓存通信的总线是双独立总线(Dual Independent Bus,缩写为 DIB)。CPU 里有一个快速的本地总线(Local Bus),以及一个速度相对较慢的前端总线(Front-side Bus);高速本地总线用来和高速缓存通信的。前端总线则是用来和主内存以及输入输出设备通信的。有时候,我们会把本地总线也叫作后端总线(Back-side Bus),和前面的前端总线对应起来。CPU 里面的北桥芯片把前端总线,一分为二,变成了三个总线。
我们的前端总线,其实就是系统总线。CPU 里面的内存接口,直接和系统总线通信,然后系统总线再接入一个 I/O 桥接器(I/O Bridge)。这个 I/O 桥接器,一边接入了我们的内存总线,使得我们的 CPU 和内存通信;另一边呢,又接入了一个 I/O 总线,用来连接 I/O 设备。

事实上,真实的计算机里,这个总线层面拆分得更细。根据不同的设备,还会分成独立的 PCI 总线、ISA 总线等等。
image.png
PCI(Peripheral Component Interconnect)外部设备互连总线是 英特尔(Intel)公司1991年下半年首先提出的,并得到IBM、Compad、AST、HP、和DEC等100多家计算机公司的响应,于1993年正式推出了PCI局部总线标准。此标准允许在计算机内安装多达10个遵从PCI标准的扩展卡。
USB,是英文Universal Serial Bus(通用串行总线)的缩写,而其中文简称为"通串线",是一个外部总线标准,用于规范电脑与外部设备的连接和通讯。是应用在PC领域的接口技术。USB接口支持设备的即插即用和热插拔功能。USB是在1994年底由英特尔、康柏、IBM、Microsoft等多家公司联合提出的。
ISA插槽是基于ISA总线(Industrial Standard Architecture,工业标准结构总线)的扩展插槽,与PCI插槽功能类似,但是CPU资源占用太高,数据传输带宽太小,是已经被淘汰的插槽接口。目前还能在许多老主板上看到ISA插槽,现在新出品的主板上已经几乎看不到ISA插槽的身影了.
2008 年之后,我们的 Intel CPU 其实已经没有前端总线了。Intel 发明了快速通道互联(Intel Quick Path Interconnect,简称为 QPI)技术,替代了传统的前端总线。

12.2 IO设备

CPU并不是发送一个特定的操作指令来操作不同的I/O设备。因为如果是那样的话,随着新的I/O设备的发明,我们就要去扩展CPU的指令集了。

在计算机系统里面,CPU和I/O设备之间的通信,是这么来解决的。首先,在I/O设备这⼀侧,我们把I/O设备拆分成,能和CPU通信的接⼝电路,以及实际的I/O设备本身。接⼝电路里面有对应的状态寄存器、命令寄存器、数据寄存器、数据缓冲区和设备内存等等。接⼝电路通过总线和CPU通信,接收来⾃CPU的指令和数据。⽽接⼝电路中的控制电路,再解码接收到的指令,实际去操作对应的硬件设备。

而在CPU这⼀侧,对CPU来说,它看到的并不是一个个特定的设备,而是一个个内存地址或者端⼝地址。CPU只是向这些地址传输数据或者读取数据。所需要的指令和操作内存地址的指令其实没有什么本质差别。这种映射方式叫做NMIO。Intel x86使用独立的指令来支持端口映射,即访问设备地址不再存储在内存空间里面,而是有一个专门的端口(port),这种映射方式称为PMIO。以下图为例,在设备管理器里面的资源(Resource)信息。你可以看到,里面既有MemoryRange,这个就是设备对应映射到的内存地址,也就是我们上⾯所说的MMIO的访问方式。同样的,里面还有I/O Range,这个就是我们上⾯所说的PMIO,也就是通过端⼝来访问I/O设备的地址。最后的IRQ是会来自设备的中断信号
image.png

13 硬盘

顺序读写的话,HDD硬盘用的是SATA3.0接口,每秒可以传输768M数据,日常在200M/s左右。SSD硬盘的话用SATA3.0接口速度可以到500M/s左右,用PCIE接口可以到读取2G/s,写入1.2G/s。至于相应时间,SSD大概几十微秒,HDD毫秒级别。
对于随机读写,IOPS (Input/Output Per Second)即每秒的输入输出量(或读写次数),是衡量磁盘性能的主要指标之一。IOPS是指单位时间内系统能处理的I/O请求数量,I/O请求通常为读或写数据操作请求。另一个重要指标是数据吞吐量(Throughput),指单位时间内可以成功传输的数据数量。即使是使PCI Express接口的SSD硬盘,IOPS也就只是到了2万左右。这个性能,和我们CPU的每秒20亿次操作的能⼒⽐起来,可就差得远了。所以很多时候,我们的程序对外响应慢,其实都是CPU在等待I/O操作完成。

13.1 机械硬盘

image.png
机械硬盘的硬件,主要由盘面、磁头和悬臂三部分组成。我们的数据在盘面上的位置,可以通过磁道、扇区和柱面来定位。实际的一次对于硬盘的访问,
需要把盘面旋转到某一个“几何扇区”,对准悬臂的位置。然后,悬臂通过寻道,把磁头放到我们实际要读取的扇区上。
受制于机械硬盘的结构,我们对于随机数据的访问速度,就要包含旋转盘⾯的平均延时和移动悬臂的寻道时间。通过这两个时间,我们能计算出机械硬盘的IOPS。
7200转机械硬盘的IOPS,只能做到100左右。在互联网时代的早期,我们也没有SSD硬盘可以用,所以工程师们就想出了Partial Stroking这个浪费存储空间,
但是可以缩短寻道时间来提升硬盘的IOPS的解决方案。这个解决方案,也是一个典型的、在深入理解了硬件原理之后的软件优化⽅案。

13.2 SSD

SSD 硬盘在 2010 年前后,进入了主流的商业应用。一块普通的 SSD 硬盘,可以轻松支撑 10000 乃至 20000 的 IOPS。那个时候,不少互联网公司想要完成性能优化的 KPI,最后的解决方案都变成了换 SSD 的硬盘。如果这还不够,那就换上使用 PCI Express 接口的 SSD。
image.png
SSD硬盘的构造可以先简单地认为是由一个电容加上一个电压计组合在一起,记录了一个或者多个比特,如下图:
image.png
SLC全称是Single-Level Cell,也就是一个存储单元中只有一位数据。给电容里面充上电有电压的时候就是1,给电容放电里面没有电就是0。采用这样方式存储数据的SSD硬盘,我们一般称之为使用了SLC的颗粒,
image.png
只有一个电容,我们怎么能够表示更多的比特呢?别忘了,这里我们还有一个电压计。4个比特一共可以从0000-1111表⽰16个不同的数。那么,如果我们能往电容⾥⾯充电的时候,充上15个不同的电压,并且我们电压计能够区分出这15个不同的电压。加上电容被放空代表的0,就能够代表从0000-1111这样4个比特了。
不过,要想表示15个不同的电压,充电和读取的时候,对于精度的要求就会更高。这会导致充电和读取的时候都更慢,所以QLC的SSD的读写速度,要⽐SLC的慢上好几倍。
如果我们去看一看SSD硬盘的硬件构造,可以看到,它大概是自顶向下是这么构成的:

  1. 闪存转换层
    首先,虽然和其他的I/O设备一样,它有对应的接⼝和控制电路。现在的SSD硬盘⽤的是SATA或者PCIExpress接⼝。在控制电路,有一个很重要的模块,叫作FTL(Flash-Translation Layer),也就是闪存转换层。这个可以说是SSD硬盘的一个核心模块,SSD硬盘性能的好坏,很大程度上也取决于FTL的算法好不好。
  2. 实际IO层
    接下来是实际I/O设备,它其实和机械硬盘很像。现在新的大容量SSD硬盘都是3D封装的了,也就是说,是由很多个裸片(Die)叠在一起的,就好像我们的机械硬盘把很多个盘面(Platter)叠放在一起一样,这样可以在同样的空间下放下更多的容量。一张裸片上可以放多个平面(Plane),一般一个平面上的存储容量大概在GB级别。一个平面上面,会划分成很多个块(Block),一般一个块(Block)的存储大小,通常几百KB到几MB大小。一个块里面,还会区分很多个页(Page),就和我们内存里面的页一样,一个页的大小通常是4KB。
    image.png
    image.png
  3. 读写过程
    对于SSD硬盘来说,数据的写入叫作Program。写入不能像机械硬盘一样,通过覆写(Overwrite)来进行的,而是要先去擦除(Erase),然后再写入。SSD的读取和写入的基本单位,不是一个比特(bit)或者一个字节(byte),而是一个页(Page)。SSD的擦除单位就更夸张了,我们不仅不能按照照特或者字节来擦除,连按照 页来擦除都不行,我们必须按照块来擦除。SLC的芯⽚,可以擦除的次数⼤概在10万次,MLC就在1万次左右,而TLC和QLC就只在几千次了。这也是为什么,你去购买SSD硬盘,会看到同样的容量的价格差别很大,因为它们的芯片颗粒和寿命完全不⼀样。

13.3 读写优化

一块SSD的硬盘容量,是没办法完全用满的。不过,为了不得罪消费者,生产SSD硬盘的厂商,其实是预留了一部分空间,专们用来做这个“磁盘碎片整理”工作的。一块标成256G的SSD硬盘,往往实际有240G的硬盘空间。SSD硬盘通过我们的控制芯片电路,把多出来的硬盘空间,用来进行各种数据的闪转腾挪,
让你能够写满那240G的空间。这个多出来的16G空间,叫作 预留空间(Over Provisioning),一般SSD的硬盘的预留空间都在7%-15%左右。
SSD硬盘,特别适合读多写少的应用。在日常应用里面,我们的系统盘适合用SSD。但是,如果我们用SSD做专门的下载盘,一直下载各种影音数据,然后刻盘备份就不太好了,特别是现在QLC颗粒的SSD,它只有几千次可擦写的寿命啊。

在数据中心里面,SSD的应用场景也是适合读多写少的场景。我们拿SSD硬盘用来做数据库,存放电商网站的商品信息很合适。但是,用来作为Hadoop这样的Map-Reduce应用的数据盘就不行了。因为Map-Reduce任务会大量在任务中间向硬盘写入中间数据再删除掉,这样用不了多久,SSD硬盘的寿命就会到了。
那么,我们有没有什么办法,不让这些坏块那么早就出现呢?我们能不能,匀出一些存放操作系统的块的擦写次数,给到这些存放数据的地方呢?

相信你一定想到了,其实我们要的就是想一个办法,让SSD硬盘各个块的擦除次数,均匀分摊到各个块上。这个策略呢,就叫作 磨损均衡(Wear-Leveling)。实现这个技术的核心办法,和我们前面讲过的虚拟内存一样,就是添加一个间接层。这个间接层,就是我们上面讲给你卖的那个关子,就是FTL这个闪存转换层。就像在管理内存的时候,我们通过一个页表映射虚拟内存页和物理页一样,在FTL里面,存放了 逻辑块地址(Logical Block Address,简称LBA)到 物理块地址(Physical Block Address,简称PBA)的映射。
image.png

在使用SSD的硬盘情况下,你会发现,操作系统对于文件的删除,SSD硬盘其实并不知道。这就导致,我们为了磨损均衡,很多时候在都在搬运很多已经删除了的数据。这就会产⽣很多不必要的数据读写和擦除,既消耗了SSD的性能,也缩短了SSD的使⽤寿命。为了解决这个问题,现在的操作系统和SSD的主控芯⽚,都⽀持 TRIM命令。这个命令可以在⽂件被删除的时候,让操作系统去通知SSD硬盘,对应的逻辑块已经标记成已删除了。现在的SSD硬盘都已经⽀持了TRIM命令。⽆论是Linux、Windows还是MacOS,这些操作系统也都已经⽀持了TRIM命令了。TRIM命令的发明,也反应了一个使用SSD硬盘的问题,那就是,SSD硬盘容易越用越慢。当SSD硬盘的存储空间被占用得越来越多,每一次写入新数据,我们都可能没有足够的空间。我们可能不得不去进行垃圾回收,合并一些块里面的页,才能匀出一些空间来。这个时候,从应用层或者操作系统层面来看,我们可能只是写入了一个4KB或者4MB的数据。但是,实际通过FTL之后,我们可能要去搬运8MB、16MB甚至更多的数据。
我们通过“ 实际的闪存写⼊的数据量/系统通过FTL写⼊的数据量=写入放大”,可以得到,写入放大的倍数越多,意味着实际的SSD性能也就越差,会远远⽐不上实际SSD硬盘标称的指标。而解决写入放大,需要我们在后台定时进行垃圾回收,在硬盘比较空闲的时候,就把搬运数据、擦除数据、留出空白的块的工作做完,而不是等实际数据写入的时候,再进行这样的操作。

13.4 硬盘接口

硬盘接口通俗理解就是硬盘和计算机其他部分相互连接的方式,各种接口拥有自己独特的用途和适用的范围。
IDE出现的比较早。IDE数据传输速度慢、线缆长度过短、连接设备少、不支持热插拔、接口速度的可升级性差。IDE接口如下图,左边电源接口,中间是跳线,用来设置主从盘的,右边是数据线
image.png

SATA采用串行连接方式,串行ATA总线使用嵌入式时钟信号,具备了更强的纠错能力,与以往相比其最大的区别在于能对传输指令(不仅仅是数据)进行检查,如果发现错误会自动矫正,这在很大程度上提高了数据传输的可靠性。目前 SATA接口,有1.0、2.0、3.0三个版本,版本号越大,出现的时间就越晚;性能就越好,主要是数据传输速率更快。SATA接口的版本是向下兼容的,高版本的SATA接口兼容低版本的SATA接口。有些SATA硬盘提供了跳线,跳线设置不同,同一块硬盘的SATA接口版本号就不同(主要是数据传输的速率不同)。SATA接口硬盘在外观上和IDE硬盘有很大不同,左边是电源,右边数据线。右边比较宽的是跳线接口,有的硬盘会有,有的硬盘没有,SATA硬盘主从盘设置是在bois,这里的跳线主要设置SATA的传输速率的。现在有些SATA硬盘,也出现了新的固件接口。
image.png

mSATA接口是主要是用在笔记本上: 比如商务本,超极本,主流笔记本等。M.2接口是为超极本量身定做的新一代接口标准,以取代原来的mSATA接口。无论是更小巧的规格尺寸,还是更高的传输性能,M.2都远胜于mSATA。

M.2接口一般分为两种,Socket 2(B key,ngff)和Socket 3(M key,nvme),其中Socket2支持SATA、PCI-E X2接口,而如果采用PCI-E ×2接口标准,最大的读取速度可以达到700MB/s,写入也能达到550MB/s。而其中的Socket 3可支持PCI-E ×4接口,理论带宽可达4GB/s。

在购买M.2 SSD的时候是需要注意内部协议的。一种是走传统的SATA AHCI协议,与普通SATA固态硬盘性能没有差别;另一种则是使用全新的NVMe协议,可以提供SSD高达3000MB/s以上的性能,可谓天差地别。AHCI,全称为串行ATA高级主控接口/高级主机控制器接口,它允许存储驱动程序启用高级串行ATA功能。我们在使用SATA SSD的时候,一定要在主板设置中开启AHCI模式。这是因为,开启AHCI模式后,能够大幅缩短SSD无用的寻道次数和缩短数据查找时间,这样能让多任务下的SSD能够发挥全部的性能和效应。根据相关性能测试,在AHCI模式开启后,大约增加30%的SSD读写性能。
但是随着SSD的性能逐步增强,这些标准也成为了限制固态硬盘的一大瓶颈,专为机械硬盘而设计的AHCI标准并不太适合低延时的固态硬盘。所谓NVMe协议,在于充分利用PCI-E通道的低延时以及并行性,在可控制的存储成本下,极大的提升SSD的读写性能,降低由于AHCI接口带来的高延时,彻底解放SATA时代SSD的极致性能。
image.png
总结来说,M.2、SATA、IDE其实可以说是插槽的形状,而PCI-E跟SATA3就是数据从硬盘到CPU或者内存走的通道,而NVME跟AHCI就是针对PCI-E跟SATA通道的“交通规则”

13.5 U盘

U盘和固态硬盘的结构原理是一样的,都是主控芯片+FLASH存储芯片。但是呢,由于体积和成本的原因,U盘的主控芯片是精简缩水的,它取消了sata的控制器,但是保留了例如有zif(ce),esata,usb等计算机移动设备通用的控制器。另外对于存储寻址能力也简化了,目前最多就只能做到8片FLASH芯片。而固态硬盘的主控芯片,一般至少支持16片FLASH芯片,有些甚至达到32片FLASH芯片,甚至更多。下面简单介绍下固态硬盘与U盘的原理与性能区别:

1 、主控的算法不一样

固态硬盘采用的是一种特殊的算法,这个算法每个厂家都不一样,而且是封装在主控芯片里的。通过这个特殊的算法,可以使存储的数据均匀分配到固态硬盘芯片里的每片flash上,在提高存储速度的同时,也可以极大延长FLASH芯片的寿命。有些厂家更高效的算法甚至可以对存储数据进行带压缩的读写,读取速度可以达到很高,而且能极大延长FLASH芯片的寿命,一般固态硬盘正常使用的话四五年问答不大。
而目前U盘就分成两大类主控,一种就是普通的USB主控,直通闪存颗粒,通过USB主控芯片控制,但是位宽比较低,只有8bit~16bit,性能自然也就不用想了,通常这种U盘的性能都是100MB/s以下的读写。
image.png

第二种就是比较高端的U盘了,它们采用SSD主控,接口其实是SATA,然后通过SATA to USB转接而生。要注意,SSD主控芯片其实是32bit ARM架构级别以上的运算单元,性能自然是高得多。但是SATA to USB转接这个转换过程,会很大程度上损失一部分读写性能。
image.png

2、寿命可以极大的延长

U盘经常容易坏是因为一般的U盘,大部分也就是只有一片FLASH存储芯片,部分高端的型号顶多有两片芯片,在你使用时反复对同一片芯片上的存储单元读写,会降低芯片的寿命,加速芯片的老化。而我们用的固态硬盘上芯片就不止一片两片了,而是有数十片芯片,通过主控的协调,将不同的存储数据,分配到不同芯片进行协同操作,基本上平均分配每一片芯片的使用量,这样就无形中叠加了所有芯片的读写次数,也极大的延长了稳定工作的时间。

第三,可以同时读和写速度不同

大家可以试试,分别复制同一个文件到U盘和固态硬盘,速度明显是固态硬盘快很多。除去USB接口的原因,还有因为内部FLASH芯片原理结构的原因。FLASH芯片相同的存储单元结构里,同一时间内只能进行读或者写 这单一操作。主要是因为FLASH芯片要读写数据,是靠电压的变化来完成的,读操作要一个电压,写操作要一个电压,而不可能同时产生2种电压的,但如果有很多芯片,数据是分散开来的,那么对于一个芯片组合来说同时读写就是可以实现的。

13.6 数据校验

为了判断数据是不是有问题,我们可以单独留出一位来做校验:
image.png
以奇偶校验为例:我们把内存里面的N位比特当成是一组。常见的,比如8位就是一个字节。然后,用额外的一位去记录,这8个比特里面有奇数个1还是偶数个1。如果是奇数个1,那额外的一位就记录为1;如果是偶数个1,那额外的一位就记录成0。那额外的一位,我们就称之为校验码位。
有时,我们不仅能捕捉到错误,还要能够纠正发生的错误。这个策略,我们通常叫作 纠错码(Error CorrectingCode)。它还有意个升级版本,叫作 纠删码(Erasure?Code),不仅能够纠正错误,还能够在错误不能纠正的时候,直接把数据删除。无论是我们的ECC内存,还是网络传输,乃至硬盘的RAID,其实都利⽤了纠错码和纠删码的相关技术。
海明码可以进行一位纠错。下面是数据和纠错码位数对照表:
image.png
计算原理以4-3纠错码为例,d为数据为,p为校验位,用d1、d2、d3来计算校验位p1;用d1、d3、d4计算校验位p2;用d2、d3、d4计算校验位p3,根据这3个结果的校验情况,可以判断出出错位,然后进行翻转即可。
image.png

image.png

补充:数据库选型

下面是一个经典的架构图:
image.png
在KV数场据库的景下,需要支持高并发。那么MongoDB需要把更多的数据放在内存里面,但是这样我们的存储成本就会特别高了。
在数据管道的场景下,我们需要的是大量的顺序读写,而MongoDB则是一个文档数据库系统,并没有为顺序写入和吞吐量做过优化,看起来也不太适合。
而在数据仓库的场景下,主要的数据读取时顺序读取,并且需要海量的存储。MongoDB这样的文档式数据库也没有为海量的顺序读做过优化,仍然不是一个最佳的解决方案。而且文档数据库是总是会有很多冗余的字段的元数据,还会浪费更多的存储空间。
image.png
数据库有个关键的东西叫做索引,可以通过映射关系通过索引找到对应的数据。索引多半是使用B+树来构建的,而且一般索引是存储在内存中的。这个方法带来了大量的随机读写请求。

1. KV数据库

在KV数据库里面,主要是根据主键进行随机查询,用SSD硬盘存储,并且采用专门的KV数据库是最合适的,比如AeroSpike。首先,AeroSpike操作SSD硬盘,并没有通过操作系统的文件系统。而是直接操作SSD硬盘的块和页。因为操作系统里面的文件系统,对于KV数据库来说,只是让我们多了一层间接层,只会降低性能,对我们没有什么实际的作用。其次,AeroSpike在读写数据的时候,做了两个优化。在写入数据的时候,AeroSpike尽可能去写一个较大的数据块,而不是频繁地去写很多小的数据块。这样,硬盘就不太容易频繁出现磁盘碎片。并且,一次性写入一个大的数据块,也更容易利用好顺序写入的性能优势。AeroSpike写入的一个数据块,是128KB,远比一个页的4KB要大得多。
另外,在读取数据的时候,AeroSpike倒是可以读取512字节(Bytes)这样的小数据。因为SSD的随机读取性能很好,也不像写入数据那样有擦除寿命问题。
最后,AeroSpike用了所谓的高水位(High?Watermark)算法。其实这个算法很简单,就是一旦一个物理块里面的数据碎片超过50%,就把这个物理块搬运压缩,然后进行数据擦除,确保磁盘始终有足够的空间可以写入。

2. 数据管道

对于数据管道,因为主要是顺序读和顺序写,所以我们不一定要选用SSD硬盘,而可以用HDD硬盘。不过,对于最大化吞吐量的需求,使用zero-copy和DMA是必不可少的,所以现在的数据管道的标准解决方案就是Kafka了。
DMA技术,也就是直接内存访问(Direct Memory Access)技术,用来减少CPU等待的时间。DMA在主板上放⼀块独立的芯片。在进行内存和I/O设备的数据传输的时候,我们不再通过CPU来控制数据传输,而直接通过 DMA控制器(DMA Controller,简称DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)。我们的千兆网卡或者硬盘传输大量数据的时候,如果都用CPU来搬运的话,肯定忙不过来,所以可以选择DMAC。而当数据传输很慢的时候,DMAC可以等数据到齐了,再发送信号,给到CPU去处理,而不是让CPU在那里忙等待。DMAC其实也是一个特殊的I/O设备,它和CPU以及其他I/O设备⼀样,通过连接到总线来进行实际的数据传输。
总结来说,CPU只需要告诉DMAC,我们要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会有DMAC来完成。随着现代计算机各种外设硬件越来越多,光一个通用的DMAC芯片不够了,我们在各个外设上都加上了DMAC芯片,使得CPU很少再需要关注数据传输的工作了。在我们实际的系统开发过程中,利用好DMA的数据传输机制,也可以大幅提升I/O的吞吐率。

image.png
Kafka是一个用来处理实时数据的管道,我们常常用它来做一个消息队列,或者用来收集和落地海量的日志。作为一个处理实时数据和日志的管道,瓶颈自然也在I/O层面。Kafka里面会有两种常用的海量数据传输的情况。一种是从网络络中接收上游的数据,然后需要落地到本地的磁盘上,确保数据不丢失。另一种情况呢,则是从本地磁盘上读取出来,通过网络发送出去。我们来看一看后一种情况,从磁盘读数据发送到网络上去。如果我们自己写一个简单的程序,最直观的办法,自然是用个一件读操作,从磁盘上把数据读到内存里面来,然后再用个Socket,把这些数据发送到网络上去,这个过程中数据拷贝了4次:
image.png
Kafka的代码调用了Java NIO库,具体是FileChannel里面的transferTo方法。我们的数据并没有读到中间的应用内存里面,而是直接通过Channel,写入到对应的网络设备里。
并且,对于Socket的操作,也不是写入到Socket的Buffer里面,而是直接根据描述符(Descriptor)写到到网卡的缓冲区里面。于是,在这个过程之中,我们只进行了两次数据传输。并且只有DMA来进行数据搬运,而不需要CPU。在这个方法里面,我们没有在内存层面去“复制(Copy)”数据,所以这个方法,也被称之为零拷贝(Zero-Copy)。无论传输数据量的大小,传输同样的数据,使用了零拷贝能够缩短65%的时间,大幅度提升了机器传输数据的吞吐量。
image.png

3. 数据仓库

对于数据仓库,我们通常是一次写入、多次读取。数据分析的数据仓库,通常也不是根据字段进行数据筛选,而是全量扫描数据进行分析汇总。由于存储的数据量很大,我们还要考虑成本问题。于是,一方面,我们会用HDD硬盘而不是SSD硬盘;另一方面,我们往往会预先给数据规定好Schema,使得单条数据的序列化,不需要像存JSON或者MongoDB的BSON那样,存储冗余的字段名称这样的元数据。所以,最常用的解决方案是,用Hadoop这样的集群,采用Hive这样的数据仓库系统,或者采用Avro、FThrift、FProtoBuffer这样的二进制序列化方案。在大型的DMP系统设计当中,我们需要根据各个应用场景面临的实际情况,选择不同的硬件和软件的组合,来作为整个系统中的不同组件。
image.png

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值