关于现代CPU程序员应当更新的知识.docx

上传人:b****1 文档编号:2375063 上传时间:2022-10-29 格式:DOCX 页数:13 大小:73.30KB
下载 相关 举报
关于现代CPU程序员应当更新的知识.docx_第1页
第1页 / 共13页
关于现代CPU程序员应当更新的知识.docx_第2页
第2页 / 共13页
关于现代CPU程序员应当更新的知识.docx_第3页
第3页 / 共13页
关于现代CPU程序员应当更新的知识.docx_第4页
第4页 / 共13页
关于现代CPU程序员应当更新的知识.docx_第5页
第5页 / 共13页
点击查看更多>>
下载资源
资源描述

关于现代CPU程序员应当更新的知识.docx

《关于现代CPU程序员应当更新的知识.docx》由会员分享,可在线阅读,更多相关《关于现代CPU程序员应当更新的知识.docx(13页珍藏版)》请在冰豆网上搜索。

关于现代CPU程序员应当更新的知识.docx

关于现代CPU程序员应当更新的知识

关于现代CPU,程序员应当更新的知识

有人在Twitter上谈到了自己对CPU的认识:

我记忆中的CPU模型还停留在上世纪80年代:

一个能做算术、逻辑、移位和位操作,可以加载,并把信息存储在记忆体中的盒子。

我隐约意识到了各种新发展,例如矢量指令(SIMD),新CPU还拥有了虚拟化支持(虽然不知道这在实际使用中意味着什么)。

我错过了哪些很酷的发展呢?

有什么是今天的CPU可以做到而去年还做不到的呢?

那两年,五年或者十年之前的CPU又如何呢?

我最感兴趣的事是,哪些程序员需要自己动手才能充分利用的功能(或者不得不重新设计编程环境)。

我想,这不该包括超线程/SMT,但我并不确定。

我也对暂时CPU做不到但是未来可以做得到的事感兴趣。

本文内容除非另有说明,都是指在x86和Linux环境下。

历史总在重演,很多x86上的新事物,对于超级计算机、大型机和工作站来说已经是老生常谈了。

现状

杂记

现代CPU拥有更宽的寄存器,可寻址更多内存。

在上世纪80年代,你可能已经使用过8位CPU,但现在肯定已在使用64位CPU。

除了能提供更多地址空间,64位模式(对于32位和64位操作通过x867浮点避免伪随机地获得80位精度)提供了更多寄存器和更一致的浮点结果。

自80年代初已经被引入x86的其他非常有可能用到的功能还包括:

分页/虚拟内存,pipelining和浮点运算。

本文将避免讨论那些写驱动程序、BIOS代码、做安全审查,才会用到的不寻常的底层功能,如APIC/x2APIC,SMM或NX位等。

内存/缓存(Memory/Caches)

在所有话题中,最可能真正影日常编程工作的是内存访问。

我的第一台电脑是286在,那台机器上,一次内存访问可能只需要几个时钟周期。

几年前,我使用奔腾4,内存访问需要花费超过400时钟周期。

处理器比内存的发展速度快得多,对于内存较慢问题的解决方法是增加缓存,如果访问模式可被预测,常用数据访问速度更快,还有预取——预加载数据到缓存。

几个周期与400多个相比,听起来很糟——慢了100倍。

但一个对64位(8字节)值块读取并操作的循环,CPU聪明到能在我需要之前就预取正确的数据,在3Ghz处理器上,以约22GB/s的速度处理,我们只丢了8%的性能而不是100倍。

通过使用小于CPU缓存的可预测内存访问模式和数据块操作,在现代CPU缓存架构中能发挥最大优势。

如果你想尽可能高效,这份文件是个很好的起点。

消化了这100页PDF文件后,接下来,你会想熟悉系统的微架构和内存子系统,以及学习使用类似likwid这样的工具来分析和测验应用程序。

TLBs

芯片里也有小缓存来处理各种事务,除非需要全力实现微优化,你并不需要知道解码指令缓存和其他有趣的小缓存。

最大的例外是TLB——虚拟内存查找缓存(通过x86上4级页表结构完成)。

页表在L1数据缓存,每个查询有4次,或16个周期来进行一次完整的虚拟地址查询。

对于所有需要被用户模式内存访问的操作来说,这是不能接受的,从而有了小而快的虚拟地址查找的缓存。

因为第一级TLB缓存必须要快,被严重地限制了尺寸。

如果使用4K页面,确定了在不发生TLB丢失的情况下能找到的内存数量。

x86还支持2MB和1GB页面;有些应用程序会通过使用较大页面受益匪浅。

如果你有一个长时间运行,且使用大量内存的应用程序,很值得研究这项技术的细节。

乱序执行/序列化(OutofOrderExecution/Serialization)

最近二十年,x86芯片已经能思考执行的次序(以避免因为一个停滞资源而被阻塞)。

这有时会导致很奇怪的表现。

x86非常严格的要求单一CPU,或者外部可见的状态,像寄存器和记忆体,如果每件事都在按照顺序执行都必须及时更新。

这些限制使得事情看起来像按顺序执行,在大多数情况下,你可以忽略OoO(乱序)执行的存在,除非要竭力提高性能。

主要的例外是,你不仅要确保事情在外部看起来像是按顺序执行,实际上在内部也要真的按顺序。

一个你可能关心的例子是,如果试图用rdtsc测量一系列指令的执行时间,rdtsc将读出隐藏的内部计数器并将结果置于edx和eax这些外部可见的寄存器。

假设我们这样做:

foo 

rdtsc 

bar 

mov %eax, [%ebx] 

baz 

其中,foo,bar和baz不去碰eax,edx或[%ebx]。

跟着rdtsc的mov会把eax值写入内存某个位置,因为eax外部可见,CPU将保证rdtsc执行后mov才会执行,让一切看起来按顺序发生。

然而,因为rdtsc,foo或bar之间没有明显的依赖关系,rdtsc可能在foo之前,在foo和bar之间,或在bar之后。

甚至只要baz不以任何方式影响移mov,令也可能存在baz在rdtsc之前执行的情况。

有些情况下这么做没问题,但如果rdtsc被用来衡量foo的执行时间就不妙了。

为了精确地安排rdtsc和其他指令的顺序,我们需要串行化所有执行。

如何准确的做到?

请参考英特尔的这份文档。

内存/并发(Memory/Concurrency)

上面提到的排序限制意味着相同位置的加载和存储彼此间不能被重新排序,除此以外,x86加载和存储有一些其他限制。

特别是,对于单一CPU,不管是否是在相同的位置,存储不会与之前的负载一起被记录。

然而,负载可以与更早的存储一起被记录。

例如:

mov 1, [%esp] 

mov [%ebx], %eax 

执行起来就像:

mov [%ebx], %eax 

mov 1, [%esp] 

但反之则不然——如果你写了后者,它永远不能像你前面写那样被执行。

你可能通过插入串行化指令迫使前一个实例像写起来一样来执行。

但是这需要CPU序列化所有指令这会非常缓慢,因为它迫使CPU要等到所有指令完成串行化后才能执行任何操作。

如果你只关心加载/存储顺序,另外还有一个mfence指令只用于序列化加载和存储。

本文不打算讨论memoryfence,lfence和sfence,但你可以在这里阅读更多关于它们的内容。

单核加载和存储大多是有序的,对于多核,上述限制同样适用;如果core0在观察core1,就可以看到所有的单核规则适用于core1的加载和存储。

然而如果core0和core1相互作用,不能保证它们的相互作用也是有序的。

例如,core0和core1通过设置为0的eax和edx开始,core0执行:

 

mov 1, [_foo] 

mov [_foo], %eax 

mov [_bar], %edx 

而core1执行

 

mov 1, [_bar] 

mov [_bar], %eax 

mov [_foo], %edx 

对于这两个核来说,eax必须是1,因为第一指令和第二指令相互依赖。

然而,eax有可能在两个核里都是0,因为core0的第三行可能在core1没看到任何东西时执行,反之亦然。

memorybarriers序列化一个核心内的存储器访问。

Linus对于使用memorybarriers而不是使用locking有这样一段话:

不用locking的真正代价最终不可避免。

通过使用memorybarriers自以为聪明的做事几乎总是错误的前奏。

在所有可以发生在十多种不同架构并且有着不同的内存排序的情况下,缺失一个小小的barrier真的很难让你理清楚…事实上,任何时候任何人编了一个新的锁定机制,他们总是会把它弄错。

而事实证明,在现代的x86处理器上,使用locking来实现并发通常比使用memorybarriers代价低,所以让我们来看看锁。

如果设置_foo为0,并有两个线程执行incl(_foo)10000次——一个单指令同一位置递增20000次,但理论上结果可能2。

搞清楚这一点是个很好的练习。

我们可以用一段简单的代码试验:

 

#include  

#include  

 

#define NUM_ITERS 10000 

#define NUM_THREADS 2 

 

int counter = 0; 

int *p_counter = &counter; 

 

void asm_inc() { 

  int *p_counter = &counter; 

  for (int i = 0; i < NUM_ITERS; ++i) { 

    __asm__("incl (%0) \n\t" :

 :

 "r" (p_counter)); 

  } 

 

int main () { 

  std:

:

thread t[NUM_THREADS]; 

  for (int i = 0; i < NUM_THREADS; ++i) { 

    t[i] = std:

:

thread(asm_inc); 

  } 

  for (int i = 0; i < NUM_THREADS; ++i) { 

    t[i].join(); 

  } 

  printf("Counter value:

 %i\n", counter); 

  return 0; 

用clang++-std=c++11–pthread在我的两台机器上编译得到的分布结果如下:

不仅得到的结果在运行时变化,结果的分布在不同的机器上也是不同。

我们永远没到理论上最小的2,或就此而言,任何低于10000的结果,但有可能得到10000和20000之间的最终结果。

尽管incl是个单独的指令,但不能保证原子性。

在内部,incl是后面跟一个add后再跟一个存储的负载。

在cpu0里的一个增加有可能偷偷的溜进cpu1里面的负载和存储之间执行,反之亦然。

英特尔对此的解决方案是少量的指令可以加lock前缀,以保证它们的原子性。

如果我们把上面代码的incl改成lockincl,输出始终是20000。

为了使序列有原子性,我们可以使用xchg或cmpxchg,它们始终被锁定为比较和交换的基元。

本文不会详细描它是如何工作的,但如果你好奇可以看这篇DavidDalrymple的文章。

为了使存储器的交流原子性,lock相对于彼此在global是有序的,而且加载和存储对于锁不会被重新排序相。

对于内存排序严格的模型,请参考x86TSO文档。

在C或C++中:

 

local_cpu_lock = 1; 

// .. 做些重要的事 .. 

local_cpu_lock = 0; 

编译器不知道local_cpu_lock=0不能被放在重要的中间部分。

Compilerbarriers与CPUmemorybarriers不同。

由于x86内存模型是比较严格,一些编译器的屏障在硬件层面是选择不作为,并告诉编译器不要重新排序。

如果使用的语言比microcode,汇编,C或C++抽象层级高,编译器很可能没有任何类型的注释。

内存/移植(Memory/Porting)

如果要把代码移植到其他架构,需要注意的是,x86也许有着今天你能遇到的任何架构里最强的内存模式。

如果不仔细思考,它移植到有较弱担保的架构(PPC,ARM,或Alpha),几乎肯定得到报错。

考虑Linus对这个例子的评论:

 

CPU1         CPU2 

----         ---- 

if (x == 1)  z = y; 

  y = 5;     mb(); 

             x = 1; 

…如果我读了Alpha架构内存排序保证正确,那么至少在理论上,你真的可以得到Z=5

mb是memorybarrier(内存屏障)。

本文不会细讲,但如果你想知道为什么有人会建立这样一个允许这种疯狂行为发生的规范,想一想

展开阅读全文
相关资源
猜你喜欢
相关搜索

当前位置:首页 > 总结汇报 > 学习总结

copyright@ 2008-2022 冰豆网网站版权所有

经营许可证编号:鄂ICP备2022015515号-1