内容简介:超线程的CPU,其实是把一个物理层面CPU核心,“伪装”成两个逻辑层面的CPU核心。这个CPU,会在硬件层面增加很多电路,使得我们可以在一个CPU核心内部,维护两个不同线程的指令的状态信息。比如,在一个物理CPU核心内部,会有双份的PC寄存器、指令寄存器乃至条件码寄存器。这样,这个CPU核心就可以维护两条并行的指令的状态。
超线程
超线程的CPU,其实是把一个物理层面CPU核心,“伪装”成两个逻辑层面的CPU核心。这个CPU,会在硬件层面增加很多电路,使得我们可以在一个CPU核心内部,维护两个不同线程的指令的状态信息。
比如,在一个物理CPU核心内部,会有双份的PC寄存器、指令寄存器乃至条件码寄存器。这样,这个CPU核心就可以维护两条并行的指令的状态。
超线程并不是真的去同时运行两个指令,超线程的目的,是在一个线程A的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU的译码器和ALU就空出来了,那么另外一个线程B,就可以拿来干自己需要的事情。这个线程B可没有对于线程A里面指令的关联和依赖。
所以超线程只在特定的应用场景下效果比较好。一般是在那些各个线程“等待”时间比较长的应用场景下。比如,我们需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。
SIMD加速矩阵乘法
SIMD,中文叫作 单指令多数据流 (Single Instruction Multiple Data)。
下面是两段示例程序,一段呢,是通过循环的方式,给一个list里面的每一个数加1。另一段呢,是实现相同的功能,但是直接调用NumPy这个库的add方法。
$ python >>> 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 >>>
两个功能相同的代码性能有着巨大的差异,足足差出了30多倍。原因就是,NumPy直接用到了SIMD指令,能够并行进行向量的操作。
使用循环来一步一步计算的算法呢,一般被称为 SISD ,也就是 单指令单数据 (Single Instruction Single Data)的处理方式。如果你手头的是一个多核CPU呢,那么它同时处理多个指令的方式可以叫作 MIMD ,也就是 多指令多数据 (Multiple Instruction Multiple Dataa)。
Intel在引入SSE指令集的时候,在CPU里面添上了8个 128 Bits的寄存器。128 Bits也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。比起循环分别读取4次对应的数据,时间就省下来了。
在数据读取到了之后,在指令的执行层面,SIMD也是可以并行进行的。4个整数各自加1,互相之前完全没有依赖,也就没有冒险问题需要处理。只要CPU里有足够多的功能单元,能够同时进行这些计算,这个加法就是4路同时并行的,自然也省下了时间。
所以,对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用SIMD是一个很划算的办法。
异常和中断
异常
关于异常,它其实是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,也就是说,异常的处理,其实是由软件来完成的。
计算机会为每一种可能会发生的异常,分配一个异常代码(Exception Number)。有些教科书会把异常代码叫作中断向量(Interrupt Vector)。
异常发生的时候,通常是CPU检测到了一个特殊的信号。这些信号呢,在组成原理里面,我们一般叫作发生了一个事件(Event)。CPU在检测到事件的时候,其实也就拿到了对应的异常代码。
这些异常代码里,I/O发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由CPU预先分配好的,也就是由硬件来分配的。
拿到异常代码之后,CPU就会触发异常处理的流程。计算机在内存里,会保留一个异常表(Exception Table)。我们的CPU在拿到了异常码之后,会先把当前的程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序。
异常的分类:中断、陷阱、故障和中止
第一种异常叫中断(Interrupt)。顾名思义,自然就是程序在执行到一半的时候,被打断了。
第二种异常叫陷阱(Trap)。陷阱,其实是我们程序员“故意“主动触发的异常。就好像你在程序里面打了一个断点,这个断点就是设下的一个"陷阱"。
第三种异常叫故障(Fault)。比如,我们在程序执行的过程中,进行加法计算发生了溢出,其实就是故障类型的异常。
最后一种异常叫中止(Abort)。与其说这是一种异常类型,不如说这是故障的一种特殊情况。当CPU遇到了故障,但是恢复不过来的时候,程序就不得不中止了。
异常的处理:上下文切换
在实际的异常处理程序执行之前,CPU需要去做一次“保存现场”的操作。有了这个操作,我们才能在异常处理完成之后,重新回到之前执行的指令序列里面来。
因为异常情况往往发生在程序正常执行的预期之外,比如中断、故障发生的时候。所以,除了本来程序压栈要做的事情之外,我们还需要把CPU内当前运行程序用到的所有寄存器,都放到栈里面。最典型的就是条件码寄存器里面的内容。
像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。对应压栈的时候,对应的数据是压到内核栈里,而不是程序栈里。
虚拟机技术
解释型虚拟机
我们把原先的操作系统叫作宿主机(Host),把能够有能力去模拟指令执行的软件,叫作模拟器(Emulator),而实际运行在模拟器上被“虚拟”出来的系统呢,我们叫客户机(Guest VM)。
例如在windows上跑的Android模拟器,或者能在Windows下运行的游戏机模拟器。
这种解释执行方式的最大的优势就是,模拟的系统可以跨硬件。比如,Android手机用的CPU是ARM的,而我们的开发机用的是Intel X86的,两边的CPU指令集都不一样,但是一样可以正常运行。
缺陷:
第一个是,我们做不到精确的“模拟”。很多的老旧的硬件的程序运行,要依赖特定的电路乃至电路特有的时钟频率,想要通过软件达到100%模拟是很难做到的。
第二个是这种解释执行的方式,性能实在太差了。因为我们并不是直接把指令交给CPU去执行的,而是要经过各种解释和翻译工作。
Type-1和Type-2虚拟机
如果我们需要一个“全虚拟化”的技术,可以在现有的物理服务器的硬件和操作系统上,去跑一个完整的、不需要做任何修改的客户机操作系统(Guest OS),有一个很常用的一个解决方案,就是加入一个中间层。在虚拟机技术里面,这个中间层就叫作虚拟机监视器,英文叫VMM(Virtual Machine Manager)或者Hypervisor。
Type-2虚拟机
在Type-2虚拟机里,我们上面说的虚拟机监视器好像一个运行在操作系统上的软件。你的客户机的操作系统呢,把最终到硬件的所有指令,都发送给虚拟机监视器。而虚拟机监视器,又会把这些指令再交给宿主机的操作系统去执行。
Type-1虚拟机
在数据中心里面用的虚拟机,我们通常叫作Type-1型的虚拟机。客户机的指令交给虚拟机监视器之后呢,不再需要通过宿主机的操作系统,才能调用硬件,而是可以直接由虚拟机监视器去调用硬件。
在Type-1型的虚拟机里,我们的虚拟机监视器其实并不是一个操作系统之上的应用层程序,而是一个嵌入在操作系统内核里面的一部分。
Docker
在我们实际的物理机上,我们可能同时运行了多个的虚拟机,而这每一个虚拟机,都运行了一个属于自己的单独的操作系统。多运行一个操作系统,意味着我们要多消耗一些资源在CPU、内存乃至磁盘空间上。
在服务器领域,我们开发的程序都是跑在 Linux 上的。其实我们并不需要一个独立的操作系统,只要一个能够进行资源和环境隔离的“独立空间”就好了。
通过Docker,我们不再需要在操作系统上再跑一个操作系统,而只需要通过容器编排工具,比如Kubernetes或者Docker Swarm,能够进行各个应用之间的环境和资源隔离就好了。
存储器
SRAM
SRAM(Static Random-Access Memory,静态随机存取存储器),被用在CPU Cache中。
SRAM之所以被称为“静态”存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。在SRAM里面,一个比特的数据,需要6~8个晶体管。所以SRAM的存储密度不高。同样的物理空间下,能够存储的数据有限。不过,因为SRAM的电路简单,所以访问速度非常快。
在CPU里,通常会有L1、L2、L3这样三层高速缓存。每个CPU核心都有一块属于自己的L1高速缓存。
L2的Cache同样是每个CPU核心都有的,不过它往往不在CPU核心的内部。所以,L2 Cache的访问速度会比L1稍微慢一些。
L3 Cache,则通常是多个CPU核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。
DRAM
内存用的芯片是一种叫作DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片,比起SRAM来说,它的密度更高,有更大的容量,而且它也比SRAM芯片便宜不少。
DRAM被称为“动态”存储器,是因为DRAM需要靠不断地“刷新”,才能保持数据被存储起来。DRAM的一个比特,只需要一个晶体管和一个电容就能存储。所以,DRAM在同样的物理空间下,能够存储的数据也就更多,也就是存储的“密度”更大。
CPU Cache
目前看来,一次内存的访问,大约需要120个CPU Cycle,这也意味着,在今天,CPU和内存的访问速度已经有了120倍的差距。
为了弥补两者之间的性能差异,我们能真实地把CPU的性能提升用起来,而不是让它在那儿空转,我们在现代CPU中引入了高速缓存。
CPU从内存中读取数据到CPU Cache的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在CPU Cache里面,我们把它叫作Cache Line(缓存块)。
在我们日常使用的Intel服务器或者PC里,Cache Line的大小通常是64字节。
直接映射Cache(Direct Mapped Cache)
对于读取内存中的数据,我们首先拿到的是数据所在的内存块(Block)的地址。而直接映射Cache采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的CPU Cache地址(Cache Line)。而这个映射关系,通常用mod运算(求余运算)来实现。
比如说,我们的主内存被分成0~31号这样32个块。我们一共有8个缓存块。用户想要访问第21号内存块。如果21号内存块内容在缓存块中的话,它一定在5号缓存块(21 mod 8 = 5)中。
在对应的缓存块中,我们会存储一个组标记(Tag)。这个组标记会记录,当前缓存块内存储的数据对应的内存块,而缓存块本身的地址表示访问地址的低N位。
除了组标记信息之外,缓存块中还有两个数据。一个自然是从主内存中加载来的实际存放的数据,另一个是 有效位 (valid bit)。啥是有效位呢?它其实就是用来标记,对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是0,无论其中的组标记和Cache Line里的数据内容是什么,CPU都不会管这些数据,而要直接访问内存,重新加载数据。
CPU在读取数据的时候,并不是要读取一整个Block,而是读取一个他需要的整数。这样的数据,我们叫作CPU里的一个字(Word)。具体是哪个字,就用这个字在整个Block里面的位置来决定。这个位置,我们叫作偏移量(Offset)。
一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的Data Block中定位对应字的位置偏移量。
如果内存中的数据已经在CPU Cache里了,那一个内存地址的访问,就会经历这样4个步骤:
- 根据内存地址的低位,计算在Cache中的索引;
- 判断有效位,确认Cache中的数据是有效的;
- 对比内存访问地址的高位,和Cache中的组标记,确认Cache中的数据就是我们要访问的内存数据,从Cache Line中读取到对应的数据块(Data Block);
- 根据内存地址的Offset位,从Data Block中,读取希望读取到的字。
CPU高速缓存的写入
每一个CPU核里面,都有独立属于自己的L1、L2的Cache,然后再有多个CPU核共用的L3的Cache、主内存。
写直达(Write-Through)
最简单的一种写入策略,叫作写直达(Write-Through)。在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在Cache里面了。如果数据已经在Cache里面了,我们先把数据写入更新到Cache里面,再写入到主内存里面;如果数据不在Cache里,我们就只更新主内存。
这个策略很慢。无论数据是不是在Cache里面,我们都需要把数据写到主内存里面。
写回(Write-Back)
如果发现我们要写入的数据,就在CPU Cache里面,那么我们就只是更新CPU Cache里面的数据。同时,我们会标记CPU Cache里的这个Block是脏(Dirty)的。所谓脏的,就是指这个时候,我们的CPU Cache里面的这个Block的数据,和主内存是不一致的。
如果我们发现,我们要写入的数据所对应的Cache Block里,放的是别的内存地址的数据,那么我们就要看一看,那个Cache Block里面的数据有没有被标记成脏的。如果是脏的话,我们要先把这个Cache Block里面的数据,写入到主内存里面。
然后,再把当前要写入的数据,写入到Cache里,同时把Cache Block标记成脏的。如果Block里面的数据没有被标记成脏的,那么我们直接把数据写入到Cache里面,然后再把Cache Block标记成脏的就好了。
MESI协议:让多核CPU的高速缓存保持一致
MESI协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个CPU核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个CPU核心写入Cache之后,它会去广播一个“失效”请求告诉所有其他的CPU核心。其他的CPU核心,只是去判断自己是否也有一个“失效”版本的Cache Block,然后把这个也标记成失效的就好了。
MESI协议对Cache Line的四个不同的标记,分别是:
- M:代表已修改(Modified)
- E:代表独占(Exclusive)
- S:代表共享(Shared)
- I:代表已失效(Invalidated)
所谓的“已修改”,就是我们上一讲所说的“脏”的Cache Block。Cache Block里面的内容我们已经更新过了,但是还没有写回到主内存里面。
所谓的“已失效“,自然是这个Cache Block里面的数据已经失效了,我们不可以相信这个Cache Block里面的数据。
在独占状态下,对应的Cache Line只加载到了当前CPU核所拥有的Cache里。其他的CPU核,并没有加载对应的数据到自己的Cache里。这个时候,如果要向独占的Cache Block写入数据,我们可以自由地写入数据,而不需要告知其他CPU核。
在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。这个共享状态是因为,这个时候,另外一个CPU核心,也把对应的Cache Block,从内存里面加载到了自己的Cache里来。
而 在共享状态下 ,因为同样的数据在多个CPU核心的Cache里都有。所以,当我们想要更新Cache里面的数据的时候, 不能直接修改 ,而是要先向所有的其他CPU核心广播一个请求,要求先把其他CPU核心里面的Cache,都变成无效的状态,然后再更新当前Cache里面的数据。这个广播操作,一般叫作RFO(Request For Ownership),也就是获取当前对应Cache Block数据的所有权。
以上所述就是小编给大家介绍的《计算机组成原理笔记(三)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。