内容简介:超线程的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数据的所有权。
以上所述就是小编给大家介绍的《计算机组成原理笔记(三)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
The Intersectional Internet
Safiya Umoja Noble、Brendesha M. Tynes / Peter Lang Publishing / 2016
From race, sex, class, and culture, the multidisciplinary field of Internet studies needs theoretical and methodological approaches that allow us to question the organization of social relations that ......一起来看看 《The Intersectional Internet》 这本书的介绍吧!