计算机的组成原理总结
计算机基础
冯诺依曼体系
冯诺依曼体系结构确立了计算机硬件的基础架构,具体包括:控制器,运算器的工作原理,也就是CPU是如何工作的,内存的工作原理,从最基本的电路到CPU乃至应用程序接口是怎样的,CPU与输入设备,输出设备之间的交互
计算机学习图
性能问题
两个参数可以来衡量性能
1.响应时间:执行一个程序,所需要的时间。
2.吞吐率:一定时间内,可以处理的数量
一个CPU的执行时间 = CPU时钟周期数 + 时钟周期时间
CPU时钟周期数 可以拆分为指令数 × 每条指令的平均时钟周期数(CPI)
由于每个程序逻辑都对应着一条CPU指令,那么不同的指令所需要的CPI就更多,比如乘法就要比加法需要更多
时钟周期时间则是CPU主频的大小(2.6GHz),主频越大,则每秒的摆动次数就越多,那么每次摆动所需要的时间也就越短
那么提高性能的主要手段就是将这三个参数尽可能的减少,也就是减少CPU的执行时间
我们的CPU也叫做超大规模集成电路,是由一个个晶体管组合而成,想要提升速度,那么就要在一定面积中放入更多的晶体管,也就是增加密度,为了让晶体管开关频率更高,也就是提高主频,但是这两个都会增加功耗,带来耗电和散热的问题。
功耗 = 1/2 × 负载电容 × 电压的平方 × 开关频率 × 晶体管数量
为了提高性能,晶体管要放得更多,那么晶体管就要造更小,但是放得越多,功耗就增加太多,于是就需要降低电压来降低功耗。
但是随着时间,提升主频来提高性能的速度越来越慢,接近于达到瓶颈,所以就开始推出多核CPU来提高吞吐率,从而达到提高性能的目的,多核的CPU通过并行计算来翻倍的提高性能。
优化后的执行时间 = 受优化影响的执行时间/加速倍数 + 不受影响的执行时间
但是并不是所有的问题都可以并行解决的,好比要把结果汇总这个操作还是得按照顺序一步一步来,那么汇总就是不受影响的执行时间。
除了这两个以外,还拥有其他的性能提升方式:加速大概率事件,通过流水线提高性能,通过预测提高性能
计算机指令
从软件工程师的角度来讲,CPU就是一个执行各种计算机指令的逻辑机器,计算机指令就是让CPU能听懂的语言,也叫做机器语言。
不同的CPU各自支持不同的语言,就是两组不同的计算机指令集。
一个计算机程序由成千上万条指令组成,但是又不能一直放着所有指令,所以计算机程序平时都是存储在存储器中的,这种计算机叫作存储程序型计算机。
还有插线板计算机,用不同的插口和插座,从而来完成各种计算任务。
编译过程
首先需要把高级语言翻译成汇编语言,这个过程叫作编译成汇编代码,之后再用汇编器翻译成机器码,这些机器码是由0和1组成的机器语言表示,这一条条机器码就是计算机指令。
在linux操作系统上,可以使用gcc和objdump这两条命令,把对应的汇编代码和机器码打印出来。
gcc -g -c test.c
objdump -d -M intel -S test.o
为什么要有汇编代码,其实汇编代码就是给程序员看的机器码,以前看16进制是非常难以理解是什么意思,而且很难记忆。
解析指令和机器码
常见的指令分为五大类:
1.算术类指令:加减乘除,CPU层面就是一条条算术类指令
2.数据传输类指令:给变量赋值,在内存里读写数据。
3.逻辑类指令:逻辑上的与或非。
4.条件分支指令:日常我们写的“if/else”。
5.无条件跳转指令:调用函数的时候,就是发起了一个无条件跳转指令
一个CPU里面有很多不同功能的寄存器,有三种比较特殊的
1.PC寄存器:也叫指令地址寄存器,用来存放下一条需要执行的计算机指令的内存地址
2.指令寄存器:存放当前正在执行的指令
3.条件码寄存器:用里面的一个一个标记位(Flag),存放CPU进行算术或者逻辑计算的结果
除了这些以外还有很多存储数据和内存地址的寄存器,通常根据存放的数据内容取名字,比如整型寄存器,浮点数寄存器,还有既可以存放数据,又可以存放内存地址的,叫做通用寄存器。
1.例子一:
if (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
3f: 75 09 jne 4a <main+0x4a>
{
a = 1;
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
48: eb 07 jmp 51 <main+0x51>
}
else
{
a = 2;
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
}
根据上面的例子,if(r == 0)被编译成cmp和jne两条指令。
cmp指令就是比较前后两个操作数的值,DWORD PTR代表操作的数据类型是32位的整数,[rbp-0×4]则是一个寄存器的地址,最后的0×0就是我们==后面的0的16进制的表示,也就是将r拿出来,表示这是一个32位的整数,并且与0进行比较,cmp指令的比较结果,会存入到条件码寄存器中。
如果比较结果室True,那么就会把零标志条件码(ZF)设置为1,除了这个条件码以外,还有进位标志,符号标志,溢出标志等等。
接着cmp指令执行之后,PC寄存器会自动自增,开始执行下一条jne指令,jne是jump if not equal,它会查看对应的零标志位,如果是0,那么就会跳转到操作数4a的位置,也就是else条件里的第一条指令,那么这时候,PC寄存器就会直接设置尘这里的4a这个地址,CPU再把4a地址里的指令加载到指令寄存器中执行。
跳转到4a的指令,是一条mov指令,前面一样,以及对应的2的16进制值0 × 2。mov指令把2设置到对应的寄存器里面去,相当于一个赋值操作,然后PC寄存器里的值继续自增,执行下一条mov指令。
这条mov指令第一个操作数是eax,代表累加寄存器,第二个则是0的16进制值,这条指令其实没有实际的作用,只是一个占位符,面前if条件满足的结果,也有个jum的无条件跳转指令,跳转的地址就是这一行的地址51,我们main函数没有设定返回值,而mov eax 0×0 其实就是给main函数生成了一个默认的为0的返回值到累加寄存器里面。
2.例子二:
这里的循环也是用cmp比较指令,紧接着的jle条件跳转指令来视线,而且这条指令跳转的地址是在之前的地址14,所以就会设置到之前执行过的指令位置,重新执行之前执行过的指令,直到条件不满足为止。
其实不管是if/else还是循环,都有点像程序语言里面的goto命令,直接指定了一个特定条件下的跳转位置,虽然我们在用高级语言开发程序的时候反对使用goto,但是实际机器指令层面,都是用goto相同的跳转到特定指令位置的方式来实现的。
程序栈
可以看到在上面的代码中,jump指令换成了函数调用的call指令,call指令后面跟着的,仍然是跳转后的程序地址。
add方法的之后执行了一条push指令,之后又执行了一条pop和一条ret指令,其实这就是调用方法时候的压栈(Push)和出栈(Pop)操作。
当main函数调用add函数时,call指令会把当前的PC寄存器的下一条指令的地址进行压栈,之后开始执行add函数,rbp又叫栈帧指针,是一个存放了当前栈帧位置的寄存器,push rbp就会把之间main函数的栈帧压到栈顶,rsp代表栈指针,mov rbp,rsp这是把rsp的值复制到rbp里,而rsp始终会指向栈顶,那么rbp这个栈帧指针指向的地址就变成了最新的栈顶,就是add函数的栈帧的栈底地址了。
之后pop rbp,来将当前的栈顶出栈,这部分操作维护好了我们整个栈帧,之后调用ret指令,这时候同时要把call指令调用的时候压入PC寄存器的下一条指令出栈,更新到PC寄存器中,将程序的控制权返回到出栈后的栈顶。
这时候如果进行无限递归,或者递归层数过深,在栈空间里面创建非常占内存的变量,都很有可能带来stack overflow,栈溢出现象。
因为程序栈的加入,导致代码可以进行复用,而不是简单粗暴地复制,粘贴代码和指令。
ELF和静态链接
如果main方法里调用的方法是其它文件里的,而且汇编代码里面都是0开始的,那么call指令调用函数,怎么知道跳转到哪个文件呢。
这时候这两个文件都是目标文件,而不是可执行文件,只有通过链接器把多个目标文件以及调用的各种函数库链接起来,才能得到一个可执行文件。
可以通过gcc 的 -o参数,生成对应的可执行文件,就可以得到这个简单的加法调用函数的结果。
实际上,在编译成汇编代码,再到编译成机器码的过程,计算机上进行的时候是由两部分组成
1.编译,汇编,链接三个阶段组成,三个阶段完成之后,就生成了一个可执行文件。
2.通过装载器把可执行文件装载到内存,CPU从内存中读取指令和数据,来开始真正执行程序。
在Linux下,可执行文件和目标文件所使用的都是一种较ELF的文件格式,中文名字叫可执行与可链接文件格式,不仅存放了编译成的汇编指令,还保留了很多别的数据。
会将main,add还有自定义的变量名称,都存放在ELF文件里,存储在一个叫做符号表的位置,相当于一个地址簿,把名字和地址关联起来。
那么main调用add的吊装地址就不是指令的地址,而是add函数的入口地址。
ELF文件格式把各种信息,分成一个个的Section保存起来,ELF有一个基本的文件头,来表示这个文件的基本属性,是否可执行文件,对应的CPU,操作系统等等。
1.首先是.text Section,叫做代码段或者指令段,用来保存程序的代码和指令
2.接着是.data Section 叫做数据段,用来保存程序里面设置好的初始化数据信息
3.之后就是.rel.text Section,叫做重定位表,保留的是当前文件里面,哪些跳转地址是不知道的,比如调用了add函数和print函数,在链接发生之前,并不知道跳转到哪里,就会存储在重定位表里。
4.最后是.systab Section,叫做符号表,保留了当前文件里面定义的函数名称和对应地址的地址簿。
之后链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表,然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里存储的地址,进行一次修正,最后,把所有的目标文件的对应段进行一次合并,变成了可执行代码,这也是为什么,可执行文件里面的函数调用的地址都是正确的。
链接器把程序变成可执行文件之后,装载器不用考虑地址跳转的问题,只要解析ELF文件,把对应的指令和数据加载到内存里面供CPU执行就可以了。
Window可执行文件格式为PE的文件格式,Linux下的装载器只能解析ELF格式,不能解析PE格式,所以同样的程序,只能在一个操作系统下执行。
我们写的程序,可以拆分成不同的函数库,最后通过一个静态链接的机制,使不同的文件之间既有分工,又能通过静态链接来合作,变成一个可执行的程序,ELF格式的文件,为了实现这样一个静态链接的机制,还包括链接缩需要的重定位表和符号表。
程序装载
实际上,装载器需要满足两个要求:
1.可执行程序加载后占用的内存空间应该是连续的,因为程序计数器是顺序地一条一条指令执行下去,意味着这一条条指令需要连续的存储在一起。
2.同时加载很多程序,并且不能让程序自己规定在内存中加载的位置,加载的时候,并没有办法确保这个程序一定加载在哪一段内存地址上,因为可能这个内存地址已经被其它程序加载占用了。
那我们可以找到一个连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里的指定的内存地址做一个映射。
指令里的内存地址叫做虚拟内存地址,实际在内存硬件里面的空间地址,叫做物理内存地址。
那么其实我们只需要维护映射关系的起始地址和对应的空间大小就可以了,毕竟是连续的内存地址空间。
找出一段连续的物理内存和虚拟内存地址进行映射的方法,叫做分段,这里的段,指系统分配出来的那个连续的内存空间。
分段的方法,解决了程序本身不需要关心具体的物理内存地址的问题,但是也有另一些问题,第一个就是内存碎片问题。
由于物理内存空间按照分段装载了很多程序,那么程序之间就会产生内存空隙,导致原本剩余的内存空间的使用率降低,这就是内存碎片。
这个也有解决的方法:
1.内存交换,可以把程序写到硬盘上,再从硬盘上读回来到内存里面,读回来的时候紧紧跟在已经倍占用的内存后面,那么就不会产生内存碎片了,但是硬盘的访问速度很慢,每次内存交换,都需要把一大段连续的内存数据写到硬盘上,如果交换的是一个很占用内存空间的程序,那么整个机器都会显得卡顿。
2.那么解决方法还有就是减少内存碎片,但需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这个叫做内存分页。
和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小,虚拟内存空间也切分成一段一段进行映射。
那么对于虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个页一个页来的,由于内存空间都是预先划分好的,也就没有了不能使用的碎片,如果释放出来很多的页,内存空间不够,也可以让现有的其它程序通过内存交换释放出一些内存的页出来,那么一次性写入磁盘的也只有少数的几个页,并不会花太多时间。
更进一步地,分页的方式使得我们在加载程序的时候,不需要一次性把程序加载到物理内存总,而只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
当要读取特定的页,却发现数据没有加载到物理内存的时候,就会触发CPU的缺页错误,那么操作系统就会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到内存里,那么这种方式就可以运行远大于实际物理内存的程序,只需要加载需要用到的就可以了。
通过虚拟内存,内存交换和内存分页技术的组合,得到了一个让程序不需要考虑实际的物理内存地址,大小和当前分配空间的解决方案,对于程序的编写,编译和链接过程都是透明的,也就是加入了一个间接层。
动态链接
很多应用程序的同样功能的代码,在不同的程序里面,都占用了一份内存空间,那我们可以将相同功能只占用一份,其它应用程序进行调用,那么内存空间将会节省很多。
所以引入了新的链接方法,动态链接,之前的合并代码段的方法就是静态链接
在动态链接的过程中,我们想要链接的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库,这个加载到内存总的共享库会被很多个程序的指令调用到。
Linux下,共享库文件就是.so文件,Windows,就是.dll文件。
想要在程序运行的时候贡献代码,那么这些机器码必须是地址无关码,也就是这段代码,无论加载到哪个内存地址,都能够正常执行,如果不是这样的代码,就是地址相关代码。
但是在不同的应用程序中,所在的虚拟内存地址是不同的,为了把这个共享库所使用的虚拟内存地址变成一致,动态代码库内部的变量和函数调用只需要使用相对地址就可以了,一个相对于当前指令偏移量的内存地址,那么无论装载到哪一段地址,不同指令之间的相对地址都是不变的。
PLT和GOT
在共享库的文件编译过程中,指定了一个 -fPIC的参数,就是要编译陈一个地址无关代码,这时候调用这个代码的call函数后面有一个@plt关键字,代表了需要从PLT,也就是程序链接表里面找要调用的函数,地址为xxxx。
然后这个地址又进行了jmp指令,可以看到GLOBAL_OFFSET_TABLE + 0×18这样的,这个GLOBAL_OFFSET_TABLE就是全局偏移量。
我们在共享库的data Section里面,保存了一张全局偏移量表(GOT),虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的,所有需要应用当前共享库外部的地址的指令,都会查询GOT,来找到当前运行成功许的虚拟内存里的对应位置,而GOT里的数据,则是加载一个个共享库的时候写进去的。
虽然不同的程序调用的同样的动态库,各自的内存地址室独立的,不需要去修改动态库里面的代码所使用的地址,而是各个程序各自维护好自己的GOT,找到对应的动态库就好了。
Linux服务器里,有上千个可执行文件,如果每一个都把标准库静态链接进来的,那么几十GB的磁盘空间一下子就出去了,这个在内存空间较少的时候更加显著。
二进制编码
字符串的表示
最早开始用的是ASCⅡ码来表示,但是后来随着加入的国家越来越多,128个字符无法表示,所以计算机工程师开始给自己国家的语言创建了对应的字符集和字符编码。
我们常说的Unicode就是一个字符集,里面存储了150种语言的14万个不同的字,而字符编码则是对于字符集里的这些字符,我们可以用UTF-8,UTF-16等来进行编码,存储成二进制,有了Unicode,我们可以自己发明一个编码,比如叫做BC-32这样的,只要人知道这套编码规则,就可以正常传输,显示这段代码。
平时所出现的乱码,有可能是Unicode中不存在的,那么Unicode会统一把这些字符记录为U+FFFD这个编码,如果用UTF-8来保存下来,结果用GB2312运行了代码,那么就会出现乱码的现象。
电报机到门电路
第一个电报机是根据摩尔斯电码组成的电报机,利用点和划来组合成数字和字符进行通信,电报机本质上就是一个蜂鸣器 + 电线 + 按钮开关组成,开关留在发送方手里,利用开关的长短组成点和划来进行鸣响进行通信。
后来由于距离过长信号弱,所以就需要继电器的设备,来接力传输信号,这个继电器是根据螺旋线圈 + 磁性开关的方式,代替蜂鸣器和普通开关,当电路封闭通上电,就会因为电磁效应,使螺旋线圈产生电磁场,那么就可以根据磁力进行开关的控制,不需要人为的进行传输,而且信息也更加准确。
根据这些线圈和开关,就可以穿检出“与”,“或”,“非”这样的逻辑,我们在输入端的电路上,提供串联的两个开关,只有两个开关都打开,电路才接通,这其实就是模拟计算机里面的“与”操作。
在输入端的电路,提供两条独立的线路到输出端,两条线路上各有一个开关,那么任何一个开关打开,到输出端的电路都是接通的,机器就是模拟了计算机中的“或”操作
当我们把输出端的“螺旋线圈 + 磁性开关”的组合,从默认关掉,只有通电之后打开,换成默认是打开的,只有通电之后才关闭,那么就得到了计算机中的“非”操作,输出端开和关正好和输入端相关,这个在数字电路中,叫做反向器。
这些线路的连接方式,就是我们在数字电路中所说的门电路,这些门电路,就是创建CPU和内存的基本逻辑单元,我们对于计算机二进制的操作,就是来自于门电路,叫做逻辑电路。
加法器
基础门电路,输入都是两个单独的bit,输出都是一个单独的bit,如果要对2个8bit的数计算与,或,非这样的简单逻辑运算,其实很容易,只需要连续摆放8个开关,来代表一个8位数,这样两组开关,从左到右,上下单个的位开关之间,都统一用与门,或者 或门连接起来,就是两个8位数的与 和 或了。
两个数在进行加法运算的时候,在二进制,每个位数都是0或1的时候,输出都是0,输入的两个位数是0和1的时候,输出的都是1,那么这个其实就相当于异或的操作,异或门就是一个最简单的整改书加法所需要使用的基本门电路。
那么如果两个都是1的时候,还需要进行进位,所以,我们通过一个异或门计算出个位数,然后通过与门计算出是否进位,于是,把两个门电路打包,就叫做半加器。
但是半加器也只能作为一位来使用,二位,四位,八位之后除了一个两个加数以外,还需要加上来自上一个位的进位信号,一共需要三个数进行相加,解决方案就是,用两个半加器和一个或门来组成一个全加器,第一个半加器和之前一样,算出结果和是否进位,然后之前位的进位信息和结果Y在进行计算得到最终结果W和进位信息,这时把上下两个进位信息都给到或门,因为是3个数相加,最多是进一位,而且有可能是上面,也有可能是下面进位,所以用或门来判断是否进位。
那么有了全加器之后,进行对应的两个8位数的加法就很容易了,只要把全加器串联起来,把每个全加器的结果输出,并发进位信息给到后面的位数就可以了,这就是加法器。
正是因为第一位没有输入的进位信息,所以用半加器就可以,然后最后的一位是没有输出的进位信息,那么这个就可以表示,我们的加法是否溢出了位数
其实计算机中,无论软件还是硬件,一个很重要的设计思想就是分层,从简单到复杂,最后从门电路,半加器,全加器到了加法器这样的功能,我们把这些用来做算术逻辑计算的组件叫做ALU,也就是算术逻辑单元。
乘法器
相较于加法,乘法就是将被乘数对乘数的每个位数进行相乘,然后左移即可,最后得到结果。
我们可以用一个开关来决定,下面的输出是完全复制输入,还是将输出全部设置为0。
至于位移,只要斜着错开位置去接就可以了,左移多少,就错开多少。
那么我们就可以用一组开关即可,每次把乘数左移一位,被乘数右移一位,然后相乘,把结果写入用来存放计算结果的开关里面,
里面的控制测试,就是一个时钟信号,来控制左移,右移以及重新计算乘法和加法的时机。
这样的方法虽然节约电路,但是速度很慢,因为四组操作不能同时进行,下一组的加法要依赖上一组加法后的结果记性计算,下一组位移也要依赖上一组的位移结果,所以,这个乘法的计算速度,与计算的数位数相关,那么时间复杂度相当于O(N),那么我们可以通过改电路的操作降低时间复杂度到O(logN)
我们可以让很多加法同时进行,把32位数位移和乘法的计算结果加到中间结果的方法,但是相应的都就要需要更多的晶体管开关来放得下中间计算结果
越往高位走,等待前面的步骤就越多,这个叫作门延迟,其实两个确定的数相加的时候,他最高位的进位信息是已经确定的,将电路完全展开,高位的进位和低位的计算结果同时获得,这个核心原因是电路是天然并行的,一个输入信号,可以同时传播到所有接通的线路中。
如果一个4位整数最高位是否进位,展开门电路图,只要3T的延迟就可以拿到是否进位的计算结果,但是计算的电路变复杂了,那么意味着晶体管就要变更多了,所以晶体管的数量可以优化计算机的计算性能,拿到更低的门延迟,以及用更少的时钟周期完成一个计算指令。
浮点数
如果32个bit来表示小数,那么最右边的两个8bit当作小数部分,剩下的当作整数部分,这样就可以得到0 ~ 999999.99这样的1亿个实数,这种二进制来表示十进制的编码方式叫做BCD编码,在进行小额记录金额的时候运用非常广泛。
但是缺点也很直观:
1.本来32bit可以表示40亿个不同的数,但是为了表示小数部分,现在只能表示1亿个数,而且还不能再往下拓展小数位。
2.这样没办法同时表示小的数字和科学记数法那样的很大的数字
所以浮点数就可以解决这些问题,也就是float类,我们可以用一个位来表示符号,8个位来表示指数位,然后剩下的23位来表示有效数位。
指数为能够表示的整数范围是0~255,但是我们这里用1 ~ 254来映射-126 ~ 127这254个有正有负的数上,因为不仅要表示很大的数,也要表示很小的数。
之后我们可以用0和255来表示0和无穷大,无穷小,以及不规范的数:
以0.5来举例,那么符号s就是0,e就是1,f也是0。那么0.5 = (-1)^0 × 1.0 × 2^-1 = 0.5,但是这里的e表示-126~127,那么-1其实就是第126位,所以二进制应该如下:
这样不考虑符号的话可以表示的范围就是1.17×10^-38 ~ 3.40×10^38,比BCD编码能够表示的范围大多了。
精度损失
十进制在转换二进制的时候,首先整数部分变成一个二进制,比如9.1,这里的9先变成1001,之后把小数也换算成二进制,将小数乘2,然后看是否超过1,超过就 - 1,并且记下1,直到为0为止,那么其实0.1这样计算之后会无限循环下去,但是f的有效长度是有限的,那么到23位时候就被截断,然后再把这个二进制转换成十进制的时候,就会有精度损失,9.1就会变成9.099999942779541015625这样。
那么浮点数怎么进行加法计算,那就是先对齐,再计算,把两个的指数位对其,也就是把指数位统一成两个其中较大的,那么较小的就要进行右移,右移的过程中,最后侧的有效位就被丢掉,那么着就会导致对应的指数位较小的数,进行加法的时候就精度损失了。
那么如果在两个数相差很大的情况下,因为精度损失,导致小数被完全抛弃,为了解决这个问题,就要用到Kehan Summation算法
就是在每次的计算过程中,都用一次减法,把当前加法计算中的损失的精度记录下来,然后在后面的循环中,把这个精度放在要加的小数上,再做一次运算。
建立数据通路
指令周期
其实计算机每执行一条指令的过程,可以拆解为:
1.Fetch(取得指令):也就是从PC寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后PC寄存器自增。
2.Decode(指令译码):根据指令寄存器里面的指令,解析成要进行什么样的操作,是R,I,J的哪一种指令,具体要操作哪些寄存器,数据或者内存地址
3.Execute(执行指令):实际运行对应的R,I,J这些特定的指令,进行算术逻辑操作,数据传输,或者直接的地址跳转。
这样的步骤,就是一个无限循环的闭环,这个循环称为指令周期
取指令的阶段,指令时放在存储器中,通过PC寄存器和指令寄存器取指令的过程,是由控制器操作的,指令的解码过程也是由控制器进行,一旦执行指令阶段,就是由算术逻辑单元(ALU)操作,也就是运算器处理。
除了指令周期以外,还有CPU周期,或者机器周期,一般把从内存里读取一条指令的最短时间,称为CPU周期,还有就是之前的时钟周期。
建立数据通路
数据通路就是我们的处理器单元,它由:
操作元件,也就是ALU,它们的功能就是在特定的输入下,根据组合电路的逻辑,生成特定的输出
存储元件,计算过程中需要用到的寄存器,都是存储元件
然后通过数据总线的方式,把它们连接起来,就可以完成数据的存储,处理和传输了,这就是建立数据通路
控制器的功能是将产生的控制信号,交给ALU来处理,但是电路很复杂, 因为不同的组合可以产生不同的控制信号,因为有了控制器,我们才可以用编程来实现功能。
CPU所需要的硬件电路
1.ALU
2.能够进行状态读写的电路元件,也就是寄存器,能够存储上一次的计算结果
3.需要一个按照固定周期,不停地实现PC寄存器自增,自动地执行指令周期的电路
4.PC寄存器的自增,需要一个自动增加数字的电路
5.需要一个译码的电路,通过这个电路找到对应的数据,地址或者指令
像加法器一样,只需要给定输入就能得到固定输出的电路,称为组合逻辑电路。
但是光有组合逻辑电路还不行,我们还需要自动运行的电路,时序逻辑电路,它可以帮我们解决,自动运行的问题,也就是不停的进行指令周期的循环,还有就是通过时序电路实现的触发器,能够把计算结果存储在特定的电路里面。
想要实现时序逻辑电路,第一步就要由一个时钟,就是晶体振荡器生成的电路信号,就是时钟信号,可以通过电的磁效应产生的开关信号的方法,来切换开关,我们在信号的输入端,放上两个开关,一个开关A是开着的,另一个B关闭的,之后一旦我们合上开关A,那么磁性线圈就会通电,产生磁性,开关B就会从合上编程断开,那么电路就中断了,那么B又被弹回合上的状态,电路接通,又有了磁性,这样就可以不断地开启,关闭这两个状态。
这个过程,对于下游电路来说,就是不断地产生0和1地信号,这就是时钟信号,这种电路,其实就相当于把电路地输出信号作为输入信号,再回到当前电路,这样的电路构造方式,叫做反馈电路。
然后通过两个或非门组成的电路,在接通开关R,输出变1,即使断开开关,输出还是1,接通开关S,输出变为0,即使断开开关,输出还是0,也就是当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个就是记忆功能,这个电路就是触发器。
那么整个流程就变为:
1.首先,有一个自动计数器,这个自动计数器会随着时钟主频不断地自增,来作为我们PC寄存器
2.这个自动计数器地后面,连上一个译码器,译码器还要同时连着我们通过大量地D触发器组成地内存
3.自动计数器会随着时钟主频不断自增,从译码器当中,找到对应地计数器所表示地内存地址,然后读取出里面地CPU指令
4.读取出来地CPU指令会通过我们地CPU时钟地控制,写入到一个由D触发器组成地寄存器,也就是指令寄存器中
5.在指令寄存器后面,再跟一个译码器,解析成opcode和对应地操作数
6.拿到对应地opcode和操作数,对应的输出线路就要连接ALU,开始各种算术和逻辑运算,对应地计算结果,再写回到D触发器组成地寄存器或者内存中。
流水线的指令设计
单指令周期处理器
一条指令 是由取得指令 - 指令译码 - 执行指令三个步骤组成,这个执行过程,至少要花费一个时钟周期,那么我们希望能确保让一整条指令的执行都在一个周期内完成,看起来比执行一条指令需要多个时钟性能要好,这种设计思路叫单指令周期处理器。
那么单指令周期处理器里面,无论是简单的还是复杂的,都要等满一个时钟周期,那么就要以复杂指令为标准,将时钟周期尽可能的延长,那么对于简单的指令来说无疑是浪费时间的,所以就出现了指令流水线的技术。
指令流水线
我们的指令除了基本三大步骤,还可以更加细分一些,比如执行的过程,包含从寄存器或者内存中读取数据,通过ALU进行运算,把结果写回寄存器或者内存中,那么我们就可以根据这些步骤切分成一个一个的小步骤,每个阶段电路完成对应的任务后,不需要等待整个指令执行完成,而是直接执行下一条指令的对应阶段,这里面每个独立的步骤,称之为流水线阶段。
我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。
超长流水线
每个流水线对应的输出,都要放到流水线寄存器中,然后下一个时钟周期,交给下一个流水线级去处理,虽然流水线寄存器很快,只有20皮秒,但是过多级别的流水线,比如20级就需要400皮秒,如果指令有3纳秒,那么已经占用了超过10%,越多的级,所消耗的开销越多,所以并不是越多级别流水线越好。
过长的流水线,就会导致需要的电路数量变多,也就是晶体管变多,那么就会使CPU的功耗变大,而且可以并行执行的指令之间要没有关联才可以,也就是依赖关系,计算机里面所说的是冒险问题。
冒险和预测
冒险分为结构冒险,数据冒险,控制冒险三大冒险,解决这三大冒险,才会使流水线设计得到充分的发挥,那么CPU就会高效率的使用。
结构冒险
结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题,也就是在一个时钟周期内,两条指令的不同阶段,使用到了同样的硬件电路,比如下面的图:
那么在CPU的结构冒险里面,对于访问内存数据和取指令的冲突,一个直观的解决方案就是把内存分成两部分,让他们有各自的地址译码器,这两部分分别是存放指令的程序内存和存放数据的数据内存,是哈弗大学提出的,我们现代CPU所使用的是在高速缓存部分进行了区分,分成了指令缓存和数据缓存两部分。
数据冒险
数据冒险则是同时在执行的多个指令之间,有数据依赖的关系,分为先写后读(数据依赖),先读后写(反依赖),先写后写(输出依赖)。
那么对于数据冒险的解决方式最简单的方法就是流水线停顿,或叫流水线冒泡,也就是发现后面执行的指令,会对前面指令有数据层面的依赖关系,那就等前面指令写回到寄存器中,再执行指令,可能会停顿一个或者多个周期,那么停止的地方就要插入NOP操作,就是什么也不干的操作。
那其实还有更加好的解决方案,操作数前推:
对于数据有依赖的指令,未必要等待前一条指令写回完成,而是在执行过程后,将结果直接传输给第二条指令的执行阶段作为输入,那么第二条指令就不用从寄存器里面单独读数据出来。
那么就不需要再插入NOP来等待到写回的阶段。
操作数前推还可以和流水线停顿一起来使用,比如下面的操作,LOAD指令在访存阶段才能把数据读取出来,那么下一条指令执行前,就可以加入NOP来等待执行完访存阶段。
但是有的情况,前面的指令特定阶段没有执行完成,后面的指令就会被堵塞,但是有可能再后面的指令是与前面不发生依赖关系的,比如:
a = b + c
d = a * e
x = y * z
那么x没有必要等待前面a的计算过程,那么可以在计算a的过程中,把x的结果也算出来,这个过程叫做乱序执行。
CPU的乱序执行流程为:
-
在取指令和指令译码的时候,乱序执行的 CPU 和其他使用流水线架构的 CPU 是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
-
在指令译码完成之后,就不一样了。CPU 不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站(Reservation Stations)的地方。顾名思义,这个保留站,就像一个火车站一样。发送到车站的指令,就像是一列列的火车。
-
这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
-
一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元(Function Unit,FU),其实就是 ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样,有些从上海北上,可以到北京和哈尔滨;有些是南下的,可以到广州和深圳。
-
指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方。
-
在重排序缓冲区里,我们的 CPU 会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果。
-
实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer 面,最终才会写入到高速缓存和内存里。
那么其实上面的例子在计算完成的顺序为 x -> a -> d,然后再将计算结果按照提交顺序排序,仍然设置成a -> d -> x。
乱序执行,极大地提高了CPU的运行效率,因为CPU运行速度比访问主内存的速度快很多,所以这种乱序执行会充分利用CPU的性能。
控制冒险
在流水线里面取指令的时候,如果有jmp,jle这样的条件跳转指令,没办法知道jmp后的那一条指令是否应该顺序加载执行,只有更新PC寄存器之后,才能知道执行下一条指令,这就是控制冒险
1.缩短分支延迟
无论是操作码,还是对应的条件码寄存器,还是我们跳转的地址,都是在指令译码阶段就能获得,只需要简单的逻辑门电路就可以,并不需要一个完整而复杂度ALU。
那么我们可以将条件判断,地址跳转等都提前到指令译码阶段进行,不需要放在指令执行阶段,这种方式就是在硬件电路层面,把一些计算结果更早地反馈到流水线中,那么后面的指令需要等待的时间就变短了。
2.分支预测
就是判断分支不发生的情况,自然就是按照顺序执行下去,那么会有50%的正确率,如果分支预测失败,那么就要把后面已经取出指令已经执行的部分给丢弃掉,在流水线里面,叫做Zap或者Flush,这些清除操作,也有一定的开销。
上面的叫静态分支预测,也有动态分支预测:
1.一级分支预测或者叫1比特饱和计数
每次预测的时候,根据上一次的结果进行预测,如果上一次执行了,那么这一次也执行,如果上一次不执行,那么这一次也不执行
2.双模态预测器,也叫2比特饱和计数
就是如果上一次执行的结果,连续执行了两次,那么下一次就会执行上一次的结果,否则不会改变原来的判断。
下面来看一个java程序:
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");
}
}
同样的10亿此循环,第一个只花了5ms,第二段则花了15ms,原因是预测的失败的次数,预测的错误越少,那么需要清除所需要的开销就会越小,如下图:
CPU吞吐率
根据之前地公式:
程序的 CPU 执行时间 = 指令数 × CPI × Clock Cycle Time
这个CPI就是IPC的倒数,也就是一个时钟周期里面能够执行的指令数,代表CPU的吞吐率,一般只能是1,也就是只能够执行一个指令。
多发射与超标量
指令的执行阶段, 是由多个功能单元(FU)并行进行的,不过,在指令乱序的过程中,我们的取指令和指令译码的部分并不是并行进行的,如果想要这部分也实现并行,那么也一样,通过增加硬件的方式,并行进行就好了,我们可以一次性从内存里取出多条指令,然后分发给多个并行的指令译码器,然后在译码对应交给不同的功能单元去处理,那么在一个时钟周期内,能够完成的指令不只一条了,那么IPC也就能大于1。
这种CPU设计,就叫做多发射和超标量,多发射就是同一个时间,同时把多条指令发射到不同的译码器里或者后续处理的流水下中。
那么原本在一个时钟周期里面,只能执行一个标量,在多发射的情况下,就能够超越这个限制,同时进行多次计算。
但是CPU并行就要解决冒险问题,要在执行指令之前,去判断指令之间是否有依赖关系,那么这种多发射功能,就被称为动态多发射处理器,这种依赖关系的检测,都会使我们的CPU的电路变得更加复杂。
超长指令字设计
这个设计不仅想让编译器来优化指令数,还想通过编译器,来优化CPI,这个就是超长指令字设计。我们可以让编译器没有把依赖关系的代码位置进行交换,然后把多条连续的指令打包成一个指令包,然后CPU运行的时候,不再取出一条指令,而是取出一个指令包,然后译码解析整个指令包,解析出3条指令直接并行运行,那么就可以一组一组的执行指令。
超线程和单指令多数据流
超线程
解决冒险来提升并发的方案,本质上都是一种指令级并行的技术方案。
一个物理地CPU核心只会运行一个线程地指令,但是超线程地CPU不是,会把一个物理CPU核心内部,有双份地PC寄存器,指令寄存器乃至条件码寄存器,这样CPU核心就可以维护两条并行指令的状态,所有超线程技术一般也被叫做同时多线程技术。
但是CPU其他组件上,还是一份,超线程的目的在于,一个线程A的指令,在流水线里停顿的时候,让另外一个线程去执行指令,这个时候,CPU的译码器和ALU就空出来了,那么另一个线程B,就可以拿来干自己需要的事情。
单指令多数据流(SIMD)
一般用循环一步一步计算的算法,被称为SISD,也就是单指令单数据,如果手头是一个多喝CPU,那么就是MIMD,也就是多指令多数据
SIMD则是在获取数据和执行指令的时候,做到了并行,一方面,在内存里读取数据的时候,SIMD是一次性读取多个数据。
举个例子,如果循环对数组中的每个数+1,和调用C语言的NumPy方法,速度是不一样的。
NumPy方法就是使用了SIMD,首先数组里的每一项都是一个Integer,那么久需要4Bytes的内存空间,Intel在引入SSE指令集的时候,在CPU里面添加了8个128Bits的寄存器,每个寄存器可以一次性加载4个整数,比起循环分别读取四次对应的数据要块很多。
而且在指令执行层面,由于各个位置+1的操作没有任何依赖关系,也就是没有冒险的问题要解决,只要CPU有足够多的功能单元,就能够同时计算这些,也就是4路并行。
所以在计算层面存在大量数据并行的计算中,使用SIMD是很好的办法,而基于SIMD的向量计算指令,被引入的指令集就是MMX,中文名为矩阵数学扩展,而CPU第一次有能力进行多媒体处理也正是SIMD和MMX的功劳。
异常
异常,最有意思的一点就是,它其实是一个硬件和软件组合到一起的处理过程,异常的前半生,也就是异常的发生和捕捉,都是在硬件层面完成,但是后半生,异常的处理是由软件来完成的。
计算机会为每一种可能发生的异常,分配一个异常代码,当异常发生的时候,通常是CPU检测到了特殊的信号,然后发生了一个事件,那么CPU在检测到事件的时候,其实就拿到了对应的异常代码。
这些异常代码里,I/O发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的,而像加法溢出这样的异常代码,则是CPU预先分配号的,也就是由硬件来分配的。
拿到异常代码后,CPU就会出发异常处理的流程,计算机在内存里,保留了一个异常表,存放不同的异常代码对应的异常处理程序所在的地址,然后我们CPU在拿到了异常码之后,会先把当前的程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序。
异常的分类
1.中断
自然就是程序在执行到一般的时候,被打断了,这个打断执行的信号,来自于CPU外部的I/O设备。
2.陷阱
就是主动触发的异常,最常见的陷阱,发生在我们的应用程序调用系统调用的时候,也就是从程序的用户态转换到内核台的时候,比如应用程序通过系统调用去读取文件,创建进程,其实就是通过触发一次陷阱来进行,因为应用程序没有权限来做这些事,需要把对应的流程交给有权限的异常处理程序来进行。
3.故障
和陷阱的区别就是,开发程序的时候并不是刻意触发的异常,这个异常并不是我们计划内的,也一样需要有对应的异常处理程序去处理。
4.中止
与其说是异常,不如说是故障的一种特殊情况,当CPU遇到了故障,程序就不得不中止。
其中,中断异常来自系统外部,而不是程序自己执行的过程中国,所以我们称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,所以我们称之为“同步“类型的异常。
在处理异常的过程当中,无论是异步的中断,还是同步的陷阱和故障,我们都是采用同一套处理流程,也就是上面所说的,“保存现场、异常代码查询、异常处理程序调用“。而中止类型的异常,其实是在故障类型异常的一种特殊情况。当故障发生,但是我们发现没有异常处理程序能够处理这种异常的情况下,程序就不得不进入中止状态,也就是最终会退出当前的程序执行。
异常的处理
切换到异常处理程序的时候,其实就好像是去调用一个异常处理函数。指令的控制权被切换到了另外一个"函数"里面,所以我们自然要把当前正在执行的指令去压栈。这样,我们才能在异常处理程序执行完成之后,重新回到当前的指令继续往下执行。
不过,切换到异常处理程序,比起函数调用,还是要更复杂一些。原因有下面几点。
第一点,因为异常情况往往发生在程序正常执行的预期之外,比如中断、故障发生的时候。所以,除了本来程序压栈要做的事情之外,我们还需要把 CPU 内当前运行程序用到的所有寄存器,都放到栈里面。最典型的就是条件码寄存器里面的内容。
第二点,像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。对应压栈的时候,对应的数据是压到内核栈里,而不是程序栈里。
第三点,像故障这样的异常,在异常处理程序执行完成之后。从栈里返回出来,继续执行的不是顺序的下一条指令,而是故障发生的当前指令。因为当前指令因为故障没有正常执行成功,必须重新去执行一次。
所以,对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在 CPU 层面的切换,所以这个过程我们称之为上下文切换(Context Switch)。
CISC和RISC
CISC就是复杂指令集,而RISC就是精简指令集。
实际上,CPU运行的程序里,80%的时间都是在用20%的简单指令,那么久提出了RISC的理念。既然大部分都在用简单指令,那么可以只用简单指令,对于复杂的指令就用简单指令来组合实现,那么面相的指令数就会大大减少,那么CPU的执行时间就会减少。
于是Intel引入了微指令架构,微指令架构里,编译器编译出来的机器码和汇编代码没有发生什么变化,但是在指令译码的阶段,就不再是某一条CPU指令,而是翻译成好几条的微指令,也就是将CISC的风格变成了固定长度的RISC,然后这些RISC风格的指令,会被放到一个微指令缓冲区里面,再从缓冲区里面,分发给后面的超标量,并且是乱序执行的流水线架构里,步过这个流水线架构里面接受的就是精简的指令了,相当于设计模式的适配器,填平了CISC和RISC指令的差异。
但是意味着指令译码器要更加复杂,那么复杂的电路和更长的译码时间,会降低性能,那么Intel就在CPU里加了一层L0 Cache,这个Cache保存的就是指令译码器把CISC的指令翻译成RISC的微指令的结果,那么大部分CPU都可以从Cache里面拿到译码结果,不需要让译码器去进行译码操作,这样不仅提升了性能,还减少了晶体管开关动作,减少了功耗。
GPU
图形渲染的流程
1.顶点处理
构成多边形建模的每一个多边形,都有多个顶点,这些顶点都有一个在三维空间里的坐标,但是我们的屏幕是二维的,那么就要把顶点转换成二维空间的面,这个操作就是顶点处理
这样的转化都是通过线性代数计算,建模越精细,那么转换的顶点就越多,而且每个顶点的位置,没有依赖关系,都是可以并行独立计算的。
2.图元处理
在顶点处理完成之后,就需要进行第二步,其实就是要把顶点处理完成之后的各个顶点连起来,变成多边形。其实转化后的顶点,仍然是在一个三维空间里,只是第三维的 Z 轴,是正对屏幕的“深度”。所以我们针对这些多边形,需要做一个操作,叫剔除和裁剪(Cull and Clip),也就是把不在屏幕里面,或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量。
3.栅格化
对于做完图元处理的多边形,我们要开始进行第三步操作。这个操作就是把它们转换成屏幕里面的一个个像素点。这个操作呢,就叫作栅格化。这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化。
4.片段处理
我们还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,就是片段处理。这步操作,同样也可以每个片段并行、独立进行,和上面的顶点处理和栅格化一样。
5.像素操作
我们就要把不同的多边形的像素点“混合(Blending)”到一起。可能前面的多边形可能是半透明的,那么前后的颜色就要混合在一起变成一个新的颜色;或者前面的多边形遮挡住了后面的多边形,那么我们只要显示前面多边形的颜色就好了。最终,输出到显示设备。
经过了完整的5个步骤之后,就可以完成从三维空间里的数据渲染,变成屏幕上可以看到的3D动画,这个流程称为图形流水线。
但是由于需要渲染的画面像素很高,每个像素有3个流水线步骤,即使每次步骤只有1个指令,CPU基本上就要把所有性能用完,而且每一个渲染步骤不可能只有一个指令,那么CPU完全跑不动三维图形渲染。
GPU这样的硬件会比制造有同样计算性能的 CPU 要便宜得多。因为整个计算流程是完全固定的,不需要流水线停顿、乱序执行等等的各类导致 CPU 计算变得复杂的问题。我们也不需要有什么可编程能力,只要让硬件按照写好的逻辑进行运算就好了。
GPU的发展
由于程序员希望GPU也有一定的可编程能力,不像CPU那样,而是在整个渲染管线的一些特别步骤,于是微软第一次引入了可编程管线的概念。
一开始的可编程管线仅限于顶点处理和片段处理,这种可以编程的接口就叫Shader,中文名就是着色器,因为一开始只能修改光照,亮度, 颜色等等的处理。
由于Shader变成一个通用的模块,所以才有了把GPU拿来做各种通用计算的用法,就是GPGPU。
在现代GPU之所以能那么快,主要是:
1.芯片瘦身
由于相较于CPU,GPU里乱序执行,分支预测就显得狠多余,GPU是整个处理过程是一个流式处理过程,没那么多分支条件,或者复杂的依赖关系,我们可以把GPU进行瘦身,只留下取指令,指令译码,ALU以及执行这些计算需要的寄存器和缓存就可以了。
2.多核并行和SIMT
由于GPU比CPU电路简单,所以一个GPU里面可以塞很多这样并行的GPU电路来实现,所以GPU的运算是天然并行的。
根据CPU的SIMD的并行处理,GPU借鉴了这个,用了一种叫做SIMT的技术,因为本身GPU就是并行的,所以SIMT可以把多条数据直接交给不同的线程去处理,但是线程里的数据不同,可能会走不同的条件分支,那么GPU就在取指令和指令译码的阶段,取出的指令可以给后面多个不同的ALU并行进行运算,那么一个GPU的核里,就可以放下更多的ALU,同时进行更多的并行运算。
3.GPU里的“超线程”
但是GPU没有CPU这样的分支预测的电路,所以可能会遇到流水线停顿的问题,那么流水线停顿的时候,可以调度一些别的任务给当前的ALU,那么针对调度不同的任务,就需要更多的执行上下文,就要比ALU多。
4.GPU在深度学习上的性能差异
通过上面的优化,GPU一个是可以进行通用计算的框架,可以通过编程,在GPU上实现不同的算法,而且GPU非常适合并行,计算能力强的架构。
存储器层级结构
1.首先是CPU,CPU中的寄存器,与CPU同步,而里面的CPU Cache就好比是大脑中的记忆,CPU Cache用的是一种叫作SRAM,静态随机存取存储的芯片。
SRAM之所以被称为静态存储器,是因为只要处在通电状态,里面的数据就会被保持存在,一旦断电,数据就会丢失,而且SRAM的存储密度不高,同样的物理空间下,能够存储的数据有限,不过SRAM的电路简单,所以访问速度非常快。
在 CPU 里,通常会有 L1、L2、L3 这样三层高速缓存。每个 CPU 核心都有一块属于自己的 L1 高速缓存,通常分成指令缓存和数据缓存,分开存放 CPU 使用的指令和数据。
L1 的 Cache 往往就嵌在 CPU 核心的内部。L2 的 Cache 同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。而 L3 Cache,则通常是多个 CPU 核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。
2.之后就是内存,内存用的芯片是DRAM,比起SRAM来说,密度更高,有更大的容量,而且也比SRAM芯片便宜不少。
DRAM被称为动态存储器,因为 DRAM 需要靠不断地“刷新”,才能保持数据被存储起来。DRAM 的一个比特,只需要一个晶体管和一个电容就能存储。但是,因为数据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问延时也就更长。
3.最后就是硬盘了,分为SSD硬盘和HDD硬盘,硬盘的存储就更大了,但是响应的访问速度也就更慢了。
各个存储器只和相邻的一层存储器打交道,并且随着一层层向下,存储器的容量逐层增大,访问速度逐层变慢,而单位存储成本也逐层下降,也就构成了我们日常所说的存储器层次结构。
局部性原理
由于性能和价格的巨大差异,我们能不能既享受CPU Cache的速度,又享受内存,硬盘巨大的容量和低廉的价格呢。
这时候就要用到存储器中的数据的局部性原理,这个局部性原理还包括时间局部性和空间局部性。
时间局部性,这个策略就是一个数据被访问了,那么可能短时间内还会被再次访问。
空间局部性则是一个数据被访问了,那么和它相邻的数据也很快会被访问。
那么我们就可以将访问次数少的数据放在大一点的存储器里,访问多的数据放在贵,但是快的存储器里。
高速缓存
从 CPU Cache 被加入到现有的 CPU 里开始,内存中的指令、数据,会被加载到 L1-L3 Cache 中,而不是直接由 CPU 访问内存去拿。在 95% 的情况下,CPU 都只需要访问 L1-L3 Cache,从里面读取指令和数据,而无需访问内存。
运行程序的时间主要花在了将对应的数据从内存中读取出来,加载到 CPU Cache 里。CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。
现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的数据写入 Cache 之中。当时间局部性原理起作用后,这个最近刚刚被访问的数据,会很快再次被访问。而 Cache 的访问速度远远快于内存,这样,CPU 花在等待内存访问上的时间就大大变短了。
那么CPU如何知道要访问的内存数据,存储在Cache的哪个位置,那么就要从最基本的直接映射Cache说起。
对于取内存中的数据,首先拿到的是数据所在的内存块的地址,而直接映射Cache采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的CPU Cache地址中的Cache Line,通常会用mod运算。
实际计算中,通常会把缓存快的数量设置成2的N次方,这样在计算取模的时候,可以直接取地址的低N位,也就是二进制里面的后几位。比如8个缓存快,在对21取模的时候,10101取地址低三位,就是101,也就是5,就是对应的缓存快地址。
那么想取21号内存块,读取5号缓存块的时候,怎么知道是不是21号对应的数据呢,这个时候,要用一个组标记,这个组标记会记录当前缓存块内存储的数据对应的内存块,而缓存块本身的地址表示访问地址的低N位,那么只需要记录剩余的高位信息就可以了。
除了缓存块,还有一个是有效位,来标记对应缓存块中的数据是否有效,如果是0,那么就直接访问内存,重新加载。
CPU在读取数据的时候,并不是读取整个Block,而是读取一个他需要的整数,这样的数据,叫作CPU里的一个字,具体是哪个字,就用这个字在整个Block的位置,这个位置就是偏移量。
一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的 Data Block 中定位对应字的位置偏移量。
那么步骤就是:
1.根据内存地址的低位,计算Cache中的索引
2.判断有效为,确认Cache中的数据是否有效
3.对比内存访问地址的高低,和Cache中的组标记,确认Cache中的数据就是我们要访问的内存数据,从Cache Line中读取到对应的数据块
4.根据内存地址的Offset位,从Data Block中读取希望读取到的字
CPU高速缓存的写入
java volatile关键字的作用
volatile关键字的作用是确保我们对于这个变量的读取和写入,都一定会同步到内存里,而不是Cache里面读取。
因为 CPU Cache 的访问速度要比主内存快很多,而在 CPU Cache 里面,L1/L2 的 Cache 也要比 L3 的 Cache 快。所以,CPU 始终都是尽可能地从 CPU Cache 中去获取数据,而不是每一次都要从主内存里面去读取数据。
那么我们写入Cache的性能也比写主内存要快,那么应该写到哪里,那么就会有两种策略:
1.写直达
在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。
这个方式就有点儿像我们上面用 volatile 关键字,始终都要把数据同步到主内存里面。
2.写回
这个策略里,我们不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里。只有当 CPU Cache 里面的数据要被“替换”的时候,我们才把数据写入到主内存里面去。
如果发现我们要写入的数据,就在 CPU Cache 里面,那么我们就只是更新 CPU Cache 里面的数据。同时,我们会标记 CPU Cache 里的这个 Block 是脏(Dirty)的。所谓脏的,就是指这个时候,我们的 CPU Cache 里面的这个 Block 的数据,和主内存是不一致的。
如果我们发现,我们要写入的数据所对应的 Cache Block 里,放的是别的内存地址的数据,那么我们就要看一看,那个 Cache Block 里面的数据有没有被标记成脏的。如果是脏的话,我们要先把这个 Cache Block 里面的数据,写入到主内存里面。然后,再把当前要写入的数据,写入到 Cache 里,同时把 Cache Block 标记成脏的。如果 Block 里面的数据没有被标记成脏的,那么我们直接把数据写入到 Cache 里面,然后再把 Cache Block 标记成脏的就好了。
在用了写回这个策略之后,我们在加载内存数据到 Cache 里面的时候,也要多出一步同步脏 Cache 的动作。如果加载内存里面的数据到 Cache 的时候,发现 Cache Block 里面有脏标记,我们也要先把 Cache Block 里的数据写回到主内存,才能加载数据覆盖掉 Cache。
MESI协议
因为多核CPU Cache之间只有L3是共用的,所以就会出现不同核之间的缓存里的数据不一致的问题,这就是缓存一致性问题。
为了解决缓存不一致的问题,要满足两个条件:
1.写传播,也就是一个CPU核的Cache数据更新,必须要传播到其他的对应的节点的Cache Line里
2.事务的串行化,一个CPU核心里面的读取和写入,在其他核里面顺序也要保持一致
解决这两个问题,首先解决多个CPU核之间的数据传播问题,最常用的一种就是总线嗅探,就是把所有的读写请求都通过总线广播给所有的CPU核心,然后让各个核心去嗅探这些请求,再根据本地的情况进行响应。
MESI协议,是一种写失效的协议,只有一个CPU核心负责写入数据,其他的核心去同步读取到这个写入,这个CPU写入之后,会去广播一个失效请求告诉其他的CPU核,那么其他的CPU核心,只是去判断自己是否也有一个失效版本的Cache Block,然后把这个也标记成失效的就好了。
相对于写失效协议,还有一种叫作写广播协议,一个写入请求广播到所有的CPU核心,同时更新各个核里面的Cache。
写失效的话只需要告诉哪一个内存地址的缓存失效即可,但是写广播就需要把对应的数据传输给其他的CPU核心,会占用更多的总线宽带。
M:代表已修改(Modified)
E:代表独占(Exclusive)
S:代表共享(Shared)
I:代表已失效(Invalidated)
已修改就是所说的脏数据,还没有写回主内存,已失效是这个里面的数据已经失效了,独占和共享里面的数据都是和主内存的数据一致的,独占状态下,对应的Cache Line只加载到了当前CPU核所拥有的Cache里,其他CPU核,并没有加载对应的数据到自己的Cache里,如果向独占的Cache Block写入数据,可以随意写入,不需要告知其他CPU核。
但是独占状态下,如果收到了来自于总线的读取对应缓存的请求,那么它就会变成共享状态,这个时候,另外一个CPU也把对应的Cache Block从内存里面加载到自己的Cache里
共享状态下,因为同样的数据多个CPU核心的Cache里都有,所以想要更新Cache里面的数据时候,不能直接修改,而是向所有的其他CPU核心广播一个请求,也就是失效请求,然后更新当前Cache里面的数据。
内存
简单页表
我们把虚拟内存地址映射到物理内存地址,最直观的方法就是来建一张映射表,这个计算机里叫做页表,这里面映射关系,会把一个内存地址分成页号和偏移量两个部分,高位就是内存地址的页号,低位就是偏移量。
那么转换步骤为:
1.把虚拟内存地址切分成页号和偏移量的组合
2.从页表里面,查询出虚拟页号,对应的物理页号
3.直接拿物理页号,加上见面的偏移量,得到物理内存地址。
多级页表
由于简单页表需要每个进程都有占用,但是内存是有限的,我们只需要去存到页之间映射关系就好了,所以采用了一种多级页表的解决方案,一个进程空间的内存地址是连续的空间,那么可以把简单页表的页号部分拆成4级,先通过4级页表索引找到对应的条目,里面存放的是一个3级页表所在的位置,之后在找对应的2级,最后指向一个1级页表,那么1级也表里面就是对应的物理页号了,之后加上偏移量的方式获取到物理内存地址。
因为进程的虚拟内存地址通常是连续的,那么其实可能只需要一张3级页表就够了。多级页表像一个多叉树的数据结构,所以也叫做页表树。因为虚拟内存地址分布的连续性,树的第一层节点的指针,很多就是空的,也就不需要有对应的子树了。所谓不需要子树,其实就是不需要对应的 2 级、3 级的页表。找到最终的物理页号,就好像通过一个特定的访问路径,走到树最底层的叶子节点。
但是由于原来访问一张表就可以得到,虽然空间上节省了,但是时间上反而更慢了,那么就需要对应的加速方案。
1.加速地址转换
因为执行的指令都是一条条顺序,那么内存地址都是连续的,所以通常会在同一个虚拟页里面,那对应的物理页号也都是同一个,所以可以加缓存的方式,把之间的内存地址缓存下来,就不需要反复去访问内存来进行内存地址转换了。
所以CPU里放了一块缓存芯片,叫做TLB,全称地址变换高速缓存,TLB也和CPU高速缓存类似,也像L1,L2这样多层的分级。
为了性能,我们整个内存转换过程也要由硬件来执行。在 CPU 芯片里面,我们封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。
2.安全性和内存保护
我们的CPU已经做了各种权限的管控,正常情况下,已经通过虚拟内存地址和物理内存地址的区分,隔离了各个进程,但是难免还会有漏洞,所以计算机底层也有一些安全保护机制叫做内存保护。
第一个是可执行空间保护,对于一个进程使用的内存,只把其中的指令部分设置成可执行的,其他部分不给予可执行的权限,因为无论是指令,还是数据,在我们的 CPU 看来,都是二进制的数据。我们直接把数据部分拿给 CPU,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行,也会因为没有权限而被阻挡掉。
第二个是地址空间布局随机化,其他的人、进程、程序,会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,我们需要知道这些指令和数据所在的位置才行。原先我们一个进程的内存布局空间是固定的,所以任何第三方很容易就能知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址,让破坏者猜不出来。猜不出来呢,自然就没法找到想要修改的内容的位置。如果只是随便做点修改,程序只会 crash 掉,而不会去执行计划之外的代码。
总线
总线的设计思路其实就是原来各个设备间的通信都是互相之间单独进行的,那么系统复杂度就是N²,那么为了简化复杂度,将这个变为一个N的复杂度,CPU想要和什么设备通信,通信的指令,对应的数据都发送到这个线路上,设备要向CPU发送什么信息也发送到这个线路上,这就是总线。
总线分为很多种:
1.双独立总线
CPU和内存以及高速缓存通信的总线,通常有两种总线,CPU里,有一个快速地本地总线以及一个速度相对较慢的前端总线。
高速本地总线就是用来和高速缓存通信的,而前端总线,则是用来和主内存以及输入输出设备通信的,我们也会把本地总线叫做后端总线啊。
前端总线就是系统总线,CPU里面的内存接口,直接和系统总线通信,然后系统总线再介入一个I/O桥接器,一边接入我们的内存总线,使得CPU和内存通信,另一边又接入一个I/O总线,用来连接I/O设备。
物理层面可以把总线看做一组电线,分为数据线,用来传输实际的数据信息;地址线,用来确定把数据传输到哪里去,是内存的某个位置,还是某一个 I/O 设备;控制线,用来控制对于总线的访问。
尽管总线减少了设备之间的耦合,也降低了系统设计的复杂度,但同时也带来了一个新问题,那就是总线不能同时给多个设备提供通信功能。
我们的总线是很多个设备公用的,那多个设备都想要用总线,我们就需要有一个机制,去决定这种情况下,到底把总线给哪一个设备用。这个机制,就叫作总线裁决(Bus Arbitraction)。
输入输出设备
输入输出设备分为两个组成部分,第一个是它的接口,第二个才是实际的I/O设备。我们的硬件设备并不是直接接入到总线上和 CPU 通信的,而是通过接口,用接口连接到总线上,再通过总线和 CPU 通信。
接口本身就是一块电路板。CPU 其实不是和实际的硬件设备打交道,而是和这个接口电路板打交道。我们平时说的,设备里面有三类寄存器,其实都在这个设备的接口电路上,而不在实际的设备上。它们分别是状态寄存器(Status Register)、 命令寄存器(Command Register)以及数据寄存器(Data Register)。
除了三类寄存器之外,还有对应的控制电路。正是通过这个控制电路,CPU 才能通过向这个接口电路板传输信号,来控制实际的硬件。
我们平时用的打印机作为例子:
1.首先是数据寄存器(Data Register)。CPU 向 I/O 设备写入需要传输的数据,比如要打印的内容是“GeekTime”,我们就要先发送一个“G”给到对应的 I/O 设备。
2.然后是命令寄存器(Command Register)。CPU 发送一个命令,告诉打印机,要进行打印工作。这个时候,打印机里面的控制电路会做两个动作。第一个,是去设置我们的状态寄存器里面的状态,把状态设置成 not-ready。第二个,就是实际操作打印机进行打印。
3.而状态寄存器(Status Register),就是告诉了我们的 CPU,现在设备已经在工作了,所以这个时候,CPU 你再发送数据或者命令过来,都是没有用的。直到前面的动作已经完成,状态寄存器重新变成了 ready 状态,我们的 CPU 才能发送下一个字符和命令。
当然,在实际情况中,打印机里通常不只有数据寄存器,还会有数据缓冲区。我们的 CPU 也不是真的一个字符一个字符这样交给打印机去打印的,而是一次性把整个文档传输到打印机的内存或者数据缓冲区里面一起打印的。
信号和地址
和访问主内存一样,使用内存地址和 I/O 接口上的设备通信,为了让已经足够复杂的 CPU 尽可能简单,计算机会把 I/O 设备的各个寄存器,以及 I/O 设备内部的内存地址,都映射到主内存地址空间里来。主内存的地址空间里,会给不同的 I/O 设备预留一段一段的内存地址。CPU 想要和这些 I/O 设备通信的时候呢,就往这些地址发送数据。
而我们的 I/O 设备呢,就会监控地址线,并且在 CPU 往自己地址发送数据的时候,把对应的数据线里面传输过来的数据,接入到对应的设备里面的寄存器和内存里面来。CPU 无论是向 I/O 设备发送命令、查询状态还是传输数据,都可以通过这样的方式。这种方式呢,叫作内存映射IO(MMIO)。
Intel CPU 虽然也支持 MMIO,不过它还可以通过特定的指令,来支持端口映射 I/O(Port-Mapped I/O,简称 PMIO)或者也可以叫独立输入输出(Isolated I/O)。PMIO 的通信方式和 MMIO 差不多,核心的区别在于,PMIO 里面访问的设备地址,不再是在内存地址空间里面,而是一个专门的端口(Port)。这个端口并不是指一个硬件上的插口,而是和 CPU 通信的一个抽象概念。无论是 PMIO 还是 MMIO,CPU 都会传送一条二进制的数据,给到 I/O 设备的对应地址。设备自己本身的接口电路,再去解码这个数据。解码之后的数据呢,就会变成设备支持的一条指令,再去通过控制电路去操作实际的硬件设备。对于 CPU 来说,它并不需要关心设备本身能够支持哪些操作。它要做的,只是在总线上传输一条条数据就好了。
IO性能
如果去看硬盘厂商的性能报告,通常你会看到两个指标。一个是响应时间(Response Time),另一个叫作数据传输率(Data Transfer Rate)。HDD硬盘的数据传输率要比SSD硬盘的传输率低很多,在实际的传输上,更多的是随机读写,而不是顺序读写,随机读写的IOPS(每秒读写的次数)才是服务器性能的核心指标。
应用开发的时候,性能瓶颈往往在I/O上,因为CPU指令发出去之后,不得不去等我们的I/O操作完成,才能进行下一步操作,那么我们可以用top,iostat来查看。
SSD硬盘
SSD硬盘比HDD硬盘的随机读写都很快,但是耐用性比HDD低。
SSD硬盘是采用电容力的电压变动来存储数据,这样的存储方式叫使用了SLC的颗粒,也就是一个存储单元只有一位。但是只用SLC,容量就上不去,所以又发迷茫了MLC,TLC,QLC,也就是一个电容里面能存下2,3,4个比特。
P/E擦写问题
实际的I/O设备,和机械硬盘很像,由很多个裸片叠在一起,就像机械硬盘把很多歌盘面叠一起一样。
一张裸片上面可以放多个平面,然后一个平面上面划分成很多块,一般一个块的存储大小,通常几百 KB 到几 MB 大小。一个块里面,还会区分很多个页(Page),就和我们内存里面的页一样,一个页的大小通常是 4KB。
对于 SSD 硬盘来说,数据的写入叫作 Program。写入不能像机械硬盘一样,通过覆写(Overwrite)来进行的,而是要先去擦除(Erase),然后再写入。
且读取和写入的基本单位,不是一个比特(bit)或者一个字节(byte),而是一个页(Page)。SSD 的擦除单位就更夸张了,我们不仅不能按照比特或者字节来擦除,连按照页来擦除都不行,我们必须按照块来擦除。
SSD 的使用寿命,其实是每一个块(Block)的擦除的次数。你可以把 SSD 硬盘的一个平面看成是一张白纸。我们在上面写入数据,就好像用铅笔在白纸上写字。如果想要把已经写过字的地方写入新的数据,我们先要用橡皮把已经写好的字擦掉。但是,如果频繁擦同一个地方,那这个地方就会破掉,之后就没有办法再写字了。
我们上面说的 SLC 的芯片,可以擦除的次数大概在 10 万次,MLC 就在 1 万次左右,而 TLC 和 QLC 就只在几千次了。这也是为什么,你去购买 SSD 硬盘,会看到同样的容量的价格差别很大,因为它们的芯片颗粒和寿命完全不一样。
SSD读写的生命周期
一块 SSD 的硬盘容量,是没办法完全用满的。不过,为了不得罪消费者,生产 SSD 硬盘的厂商,其实是预留了一部分空间,专门用来做这个“磁盘碎片整理”工作的。一块标成 240G 的 SSD 硬盘,往往实际有 256G 的硬盘空间。SSD 硬盘通过我们的控制芯片电路,把多出来的硬盘空间,用来进行各种数据的闪转腾挪,让你能够写满那 240G 的空间。这个多出来的 16G 空间,叫作预留空间(Over Provisioning),一般 SSD 的硬盘的预留空间都在 7%-15% 左右。
磨损均衡
为了减少SSD硬盘单个块的摩擦次数,可以均匀分摊到各个块上,这个策略叫做磨损均衡,添加一个间接层FTL这个闪存转换层。
就像在管理内存的时候,我们通过一个页表映射虚拟内存页和物理页一样,在 FTL 里面,存放了逻辑块地址(Logical Block Address,简称 LBA)到物理块地址(Physical Block Address,简称 PBA)的映射。
TRIM指令
我们在操作系统里面去删除一个文件,其实并没有真的在物理层面去删除这个文件,只是在文件系统里面,把对应的 inode 里面的元信息清理掉,这代表这个 inode 还可以继续使用,可以写入新的数据。这个时候,实际物理层面的对应的存储空间,在操作系统里面被标记成可以写入了。
为了解决这个问题,现在的操作系统和 SSD 的主控芯片,都支持TRIM 命令。这个命令可以在文件被删除的时候,让操作系统去通知 SSD 硬盘,对应的逻辑块已经标记成已删除了。现在的 SSD 硬盘都已经支持了 TRIM 命令。
写入放大
当 SSD 硬盘的存储空间被占用得越来越多,每一次写入新数据,我们都可能没有足够的空白。我们可能不得不去进行垃圾回收,合并一些块里面的页,然后再擦除掉一些页,才能匀出一些空间来。
这个时候,从应用层或者操作系统层面来看,我们可能只是写入了一个 4KB 或者 4MB 的数据。但是,实际通过 FTL 之后,我们可能要去搬运 8MB、16MB 甚至更多的数据。
我们通过“实际的闪存写入的数据量 / 系统通过 FTL 写入的数据量 = 写入放大”,可以得到,写入放大的倍数越多,意味着实际的 SSD 性能也就越差,会远远比不上实际 SSD 硬盘标称的指标。
而解决写入放大,需要我们在后台定时进行垃圾回收,在硬盘比较空闲的时候,就把搬运数据、擦除数据、留出空白的块的工作做完,而不是等实际数据写入的时候,再进行这样的操作。
AeroSpike
AeroSpike 针对 SSD 硬盘特性设计的 Key-Value 数据库,首先,AeroSpike 操作 SSD 硬盘,并没有通过操作系统的文件系统。而是直接操作 SSD 里面的块和页。因为操作系统里面的文件系统,对于 KV 数据库来说,只是让我们多了一层间接层,只会降低性能,对我们没有什么实际的作用。
其次,AeroSpike 在读写数据的时候,做了两个优化。在写入数据的时候,AeroSpike 尽可能去写一个较大的数据块,而不是频繁地去写很多小的数据块。这样,硬盘就不太容易频繁出现磁盘碎片。并且,一次性写入一个大的数据块,也更容易利用好顺序写入的性能优势。AeroSpike 写入的一个数据块,是 128KB,远比一个页的 4KB 要大得多。
另外,在读取数据的时候,AeroSpike 倒是可以读取 512 字节(Bytes)这样的小数据。因为 SSD 的随机读取性能很好,也不像写入数据那样有擦除寿命问题。
因为 AeroSpike 是一个对于响应时间要求很高的实时 KV 数据库,如果出现了严重的写放大效应,会导致写入数据的响应时间大幅度变长。所以 AeroSpike 做了这样几个动作:
第一个是持续地进行磁盘碎片整理。AeroSpike 用了所谓的高水位(High Watermark)算法。其实这个算法很简单,就是一旦一个物理块里面的数据碎片超过 50%,就把这个物理块搬运压缩,然后进行数据擦除,确保磁盘始终有足够的空间可以写入。
第二个是在 AeroSpike 给出的最佳实践中,为了保障数据库的性能,建议你只用到 SSD 硬盘标定容量的一半。也就是说,我们人为地给 SSD 硬盘预留了 50% 的预留空间,以确保 SSD 硬盘的写放大效应尽可能小,不会影响数据库的访问性能。
DMA
CPU 的等待,在很多时候,其实并没有太多的实际意义。我们对于 I/O 设备的大量操作,其实都只是把内存里面的数据,传输到 I/O 设备而已。在这种情况下,其实 CPU 只是在傻等而已。特别是当传输的数据量比较大的时候,比如进行大文件复制,如果所有数据都要经过 CPU,实在是有点儿太浪费时间了。因此,计算机工程师们,就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access)技术,来减少 CPU 等待的时间。
DMAC 最有价值的地方体现在,当我们要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。
那使用 DMAC 进行数据传输的过程:
- 首先,CPU 还是作为一个主设备,向 DMAC 设备发起请求。这个请求,其实就是在 DMAC 里面修改配置寄存器。
2.CPU 修改 DMAC 的配置的时候,会告诉 DMAC 这样几个信息:
首先是源地址的初始值以及传输时候的地址增减方式。
其次是目标地址初始值和传输时候的地址增减方式。目标地址自然就是和源地址对应的设备,也就是我们数据传输的目的地。
第三个自然是要传输的数据长度,也就是我们一共要传输多少数据。
3. 设置完这些信息之后,DMAC 就会变成一个空闲的状态(Idle)。
-
如果我们要从硬盘上往内存里面加载数据,这个时候,硬盘就会向 DMAC 发起一个数据传输请求。这个请求并不是通过总线,而是通过一个额外的连线。
-
然后,我们的 DMAC 需要再通过一个额外的连线响应这个申请。
-
于是,DMAC 这个芯片,就向硬盘的接口发起要总线读的传输请求。数据就从硬盘里面,读到了 DMAC 的控制器里面。
-
然后,DMAC 再向我们的内存发起总线写的数据传输请求,把数据写入到内存里面。
8.DMAC 会反复进行上面第 6、7 步的操作,直到 DMAC 的寄存器里面设置的数据长度传输完成。
- 数据传输完成之后,DMAC 重新回到第 3 步的空闲状态。
所以,整个数据传输的过程中,我们不是通过 CPU 来搬运数据,而是由 DMAC 这个芯片来搬运数据。但是 CPU 在这个过程中也是必不可少的。因为传输什么数据,从哪里传输到哪里,其实还是由 CPU 来设置的。这也是为什么,DMAC 被叫作“协处理器” 。
kafka的实现原理
Kafka 里面会有两种常见的海量数据传输的情况。一种是从网络中接收上游的数据,然后需要落地到本地的磁盘上,确保数据不丢失。另一种情况呢,则是从本地磁盘上读取出来,通过网络发送出去。
我们来看一看后一种情况,从磁盘读数据发送到网络上去。如果我们自己写一个简单的程序,最直观的办法,自然是用一个文件读操作,从磁盘上把数据读到内存里面来,然后再用一个 Socket,把这些数据发送到网络上去。
第一次传输,是从硬盘上,读到操作系统内核的缓冲区里。这个传输是通过 DMA 搬运的。
第二次传输,需要从内核缓冲区里面的数据,复制到我们应用分配的内存里面。这个传输是通过 CPU 搬运的。
第三次传输,要从我们应用的内存里面,再写到操作系统的 Socket 的缓冲区里面去。这个传输,还是由 CPU 搬运的。
最后一次传输,需要再从 Socket 的缓冲区里面,写到网卡的缓冲区里面去。这个传输又是通过 DMA 搬运的。
Kafka 做的事情就是,把这个数据搬运的次数,从上面的四次,变成了两次,并且只有 DMA 来进行数据搬运,而不需要 CPU。
Kafka 的代码调用了 Java NIO 库,具体是 FileChannel 里面的 transferTo 方法。我们的数据并没有读到中间的应用内存里面,而是直接通过 Channel,写入到对应的网络设备里。并且,对于 Socket 的操作,也不是写入到 Socket 的 Buffer 里面,而是直接根据描述符(Descriptor)写入到网卡的缓冲区里面。于是,在这个过程之中,我们只进行了两次数据传输。
我们传输同样数据的时间,可以缩减为原来的 1/3,相当于提升了 3 倍的吞吐率。这也是为什么,Kafka 是目前实时数据传输管道的标准解决方案。