在本文中,我们将详细介绍一个新开发的CPU内核——Sushi Roll。该内核使用多种创新技术来测量Intel微架构上未定义的行为。Sushi Roll测量时产生的噪音非常小,这样就可以测量微小的微结构事件,比如预测执行和缓存一致性行为。通过创造性地使用性能计数器,我们能够准确地在一个循环周期中绘制微架构活动。
本文将分为2部分:
1.现代英特尔微架构的解释;
2.Sushi Roll:低噪声的设计;
在过去的一年里,我们花了相当多的时间来研究CPU漏洞,并为几乎每个CPU漏洞编写了概念验证漏洞。
当前许多CPU研究都是基于编写代码片段并查看它的整体副作用(通过缓存计时、性能计数器等测量)。这些整体副作用还可能包括来自其他处理器活动的噪音、来自OS任务切换进程的噪音等,你可以点此查看现代英特尔Skylake处理器的微结构。
该结构主要有3个组件:前端,它将复杂的x86指令转换为一组微操作。执行引擎,它执行微操作。以及内存子系统,它确保处理器能够获得指令流和数据流。
前端
前端几乎涵盖了与确定哪些微操作(uops)需要被分配到执行引擎以完成任务相关的所有内容。现代Intel处理器上的执行引擎并不直接执行x86指令,而是将这些指令转换为固定大小且特定于处理器微架构的微操作。
指令获取和缓存
在实际执行指令之前会发生很多事情,首先,将包含指令的内存读入L1指令缓存,理想情况下是从L2缓存中引入,以最小化延迟。不过此时,该指令仍然是一个宏操作(一个可变长度的x86指令),这是一个相当麻烦的工作。处理器仍然不知道指令有多大,因此在预解码期间,处理器将执行初始长度解码来确定指令边界。
此时,指令大小已被确定,可以进入指令队列了!
指令队列和宏融合
执行的指令可能非常简单,并且可能被“融合”到一个复杂的操作中。这个阶段没有公开的参考文档,但是我们知道一个非常常见的融合是将比较指令与条件分支结合起来,如所示,就是一个常见的指令。
cmp rax, 5 jne .offset
将其组合成具有相同语义的单个宏操作,这种复杂的融合操作现在只占用CPU管道的许多部分中的一个插槽,而从将更多的资源释放给其他操作。
解码
指令解码是x86宏操作转换为微操作的地方,这些微操作系统因uArch的不同而有很大的差异,并且允许Intel定期地改变处理器的基本原理,而不会影响与x86架构的向后兼容性。在解码器中,可变长度宏操作被转换为固定长度的微操作。这种转换发生的方式有很多,比如指令可以直接转换为uops,这是大多数x86指令的通用路径。然而,一些指令甚至处理器,可能会导致所谓的微代码被执行。
微码
使用x86中的一些指令触发微代码,微代码实际上是uops的一个小集合,它将在特定的条件下执行。你可以把它想象成一个C/ c++宏,在这个宏中,你可以用一行代码来扩展更多的内容。当一个操作执行一些需要微代码的操作时,将访问微代码ROM并将其指定的uops放入管道中。这些通常是复杂的操作,比如切换操作模式、读取/写入内部CPU寄存器等,这个微码ROM还使英特尔有机会完全使用微代码补丁来改变指令行为。
微指令缓存
还有一个uop缓存,它允许先前解码的指令跳过整个预解码和解码过程。与标准内存缓存一样,这提供了巨大的加速,并极大地减少了前端的瓶颈。
分配队列
分配队列负责保存一组需要执行的uops,然后,当执行引擎有可用的资源来执行它们时,会将这些信息提供给执行引擎。
执行引擎
执行引擎做的正是你所期望的,但是在这个阶段,处理器开始移动指令来加快运行速度。
重新命名
现在就需要为某些操作分配资源了,此时处理器中的寄存器比标准的x86寄存器多得多。这些寄存器被分配用于临时操作,并且通常映射到它们对应的x86寄存器。
在这个阶段,CPU可以做很多优化。它可以通过混淆寄存器消除寄存器移动(例如两个x86寄存器“指向”相同的内部寄存器)。它可以从管道中删除已知的调零指令(比如xor with self,或者and with zero),并且直接调零寄存器。最后,当指令成功完成后,重命名操作就会停止。此时,内部微架构状态将恢复为x86架构状态。当内存操作对其他CPU可见时也是如此。
重新排序
uOP重新排序对于现代CPU性能非常重要,不依赖于当前指令的未来指令可以在等待当前指令的结果时执行。
例如:
mov rax, [rax] add rbx, rcx
在这个简短的示例中,我们看到从rax中的地址执行64位加载并将其存储回rax。内存操作可能非常费时,从L1缓存命中的4个周期到非处理器内存访问的250个周期甚至更多。
处理器能够实现add rbx、rcx指令不需要“等待”加载结果,并且可以在等待加载完成的同时发送add uop以供执行。
此时,处理器开始以不同于你告诉它的顺序执行操作。然后处理器保存结果,并确保它们以正确的顺序出现在其他内核中,因为x86是一个强顺序的架构。像ARM这样的其他架构通常是弱排序的,开发人员可以在指令流中插入命令,告诉处理器需要完成的特定顺序操作。
例如:
Core 0执行以下操作:
mov [shared_memory.pointer], rax ; Store the pointer in `rax` to shared memory mov [shared_memory.owned], 0 ; Mark that we no longer own the shared memory
Core 1执行以下操作:
.try_again: cmp [shared_memory.owned], 0 ; Check if someone owns this memory jne .try_again ; Someone owns this memory, wait a bit longer mov rax, [shared_memory.pointer] ; Get the pointer mov rax, [rax] ; Read from the pointer
在x86上,这是安全的,因为所有对齐的加载和存储都是原子性的(程序的原子性指:整个程序中的所有操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节。 ),并且以它们按顺序出现在所有其他处理器中的方式提交。在ARM之类的设备上,可以在指针被写入之前将拥有的值写入,从而允许core 1使用过时或无效的指针。
执行单元
执行单元负责执行计算、装载和存储,对于一些常见的操作,内核具有该硬件逻辑的多个副本,这允许在单独的数据上执行相同的操作。例如,可以在4个不同的执行单元上执行添加。
对于加载之类的操作,有两个加载端口(端口2和端口3),这允许每个周期执行两个独立的加载。
内存子系统
英特尔的内存子系统非常复杂,但我们只讨论基础知识。
缓存
缓存对现代CPU性能至关重要,RAM延迟非常高(150-250个周期),如果没有缓存,CPU基本上是不可用的。例如,如果一个2.2 GHz的现代x86处理器禁用了所有缓存,那么它每秒执行的指令将永远不会超过1500万条,这和1991年的英特尔80486一样慢。
对于x86,通常有3个级别的缓存:1级缓存,速度极快,但很小:4个周期的延迟。接下来是2级缓存,它更大,但仍然非常小:14个周期的延迟。最后是最后一级缓存(LLC,通常是L3缓存),这是非常大的,但具有更高的延迟,一般都多于 60个周期。
L1和L2缓存存在于每个内核中,但是L3缓存可以在多个内核之间共享。
转译后备缓冲器(TLB)
在现代cpu中,应用程序几乎从不直接与物理内存接口。而是通过地址转换将虚拟地址转换为物理地址。这允许连续虚拟内存区域映射到碎片物理内存,执行此转换需要4次内存访问(在64位4级分页上)。因此,CPU可以缓存最近转换的地址,进而在存储器操作期间跳过该转换过程。
进而在存储器操作期间跳过该转换过程。
操作系统会通过无效页面invlpg指令告诉CPU何时刷新这些转译后备缓冲器,如果操作系统在映射更改时未正确调用内存,则可以使用过时的转换信息。
缓存行填充区
当加载处于挂起状态,且还没有出现在L1缓存中时,数据将驻留在一个缓存行填充区中。缓存行填充区位于L2缓存和L1缓存之间。当内存访问错过L1缓存时,分配一个行缓存行填充区条目,一旦加载完成,就将LFB复制到L1缓存中,并删除LFB条目。
存储缓冲区
存储缓冲区类似于行缓存行填充区,在等待资源可用以完成存储时,数据将被放入存储缓冲区。即使内存子系统的所有其他方面目前都很忙,或者存储还没有准备好退出,最多也可以有56个存储(在Skylake上)排队。
此外,访问内存的加载将查询存储缓冲区,从而可能绕过缓存。如果读取发生在最近存储的位置,则可以直接从存储缓冲区填充读取,这称为存储转发。
加载缓冲区
与存储缓冲区类似,加载缓冲区用于挂起的加载uops。它位于执行单元和L1缓存之间,这可以在Skylake上容纳多达72个条目。
Sushi Roll
Sushi Roll最初是为我的矢量化模拟工作而设计的。这是一个非常奇怪的架构。首先,时钟速率约为1.3 GHz,因此仅这一点就比“标准”x86处理器慢2-3倍。而且,它用于重新排序和指令解码的CPU资源更少。在运行单线程应用程序时,与“标准”的3 GHz现代英特尔CPU相比,All-in-all CPU的速度要慢10倍左右。也没有L3缓存,所以内存访问可能会变得更加耗时。
除了这些简单的性能问题之外,还有更复杂的问题。由于Knights Landing被设计为每个内核4个线程(4路超线程),以缓解一些有限的指令解码和缓存的性能损失。这允许线程“阻塞”内存访问,而其他具有挂起计算的线程使用执行单元。这种4路超线程与64核处理器相结合,可以产生256个硬件线程。
在这些线程之间迁移进程和资源可能会非常慢,例如:如果所有256个线程都通过执行原子增量(lock inc指令)来进行相同的内存,那么每个单独的增量将花费超过10000个周期!这足够让Xeon Phi处理器上的一个内核执行64万次单精度浮点运算,这会造成一些严重的缓存问题。
显然,我们可以通过减少共享内存访问的频率来缓解这些问题,但也许我们可以开发一个从根本上不允许这种行为的内核。
Sushi Roll的设计初衷
Sushi Roll从一开始就就被设计成一个基于大量并行消息传递的内核,Sushi Roll最显着的特点是不允许使用可变共享内存(内核IPC机制的一个小例外)。这意味着如果你想要与其他处理器共享信息,你必须通过IPC传递该信息。幸运的是,共享不可变内存是允许的。
这种设计还意味着永远不需要使用lock,甚至使用lock前缀的原子级lock也不需要持有lock。一个特定的内核将拥有一个硬件资源,而不是使用lock。例如,core #0可能拥有网卡,或者网卡上的特定队列。你将向core #0发送一条消息,指示你想要发送一个数据包,而不是通过获取lock来请求对NIC的独占访问。所有这些数据包的处理都是由发送方完成的,因此数据已经以一种可以直接放入NIC环缓冲区的方式格式化了。这使得硬件资源的所有者只是一个中介,从而减少了对该资源的延迟。
虽然这使得内核的内部更加复杂,但是开发人员看到的编程模型仍然是标准的send()/recv()模型。通过强制传递消息,可以确保为这个内核编写的所有软件都可以在多台设备之间进行扩展而无需修改。在一台计算机上有一种快速、低延迟的IPC机制,它利用了一些共享内存的功能(通过将物理内存的所有权转移给接收方)。如果消息的目标位于网络上的另一台计算机上,则消息将以可以通过网络发送的方式进行序列化。这种复杂性对开发人员来说是隐藏的,开发人员可以制作一个程序,无需任何额外的工作就可以扩展。
没有中断、没有定时器、没有软件线程、没有进程
Sushi Roll没有中断,没有定时器,没有软件线程,也没有进程。这些通常是传统操作系统所需要的,以便为多个进程和用户提供用户体验。
通过删除所有这些外部事件,CPU的测量行为更具确定性。相比其他工具Sushi Roll在这方面走得更远,因为没有内核共享内存,并导致意外的缓存清除或一致性通信,它进一步降低了CPU噪音。
软重启
软重启是从电脑的操作系统通过正确指令来重启的,与Linux上的kexec类似,Sushi Roll的内核总是支持软重启。这允许用新内核替换旧内核,甚至是双故障/损坏的内)。这个过程大约需要200-300ms,因为要首先拆除旧内核,通过PXE下载新内核,并运行新内核。这使得在没有进程的情况下拥有这样一个专门的内核是可行的,因为我只需更改内核的代码并在一秒内启动新的内核。快速原型设计对于快速开发至关重要,如果没有此功能,此内核将无法使用。
Sushi Roll最终成为CPU内省的完美内核,这是迄今为止具有最低噪音的内核。
性能计数器
在讨论如何获得逐周期的微架构数据之前,我们必须了解一下Intel cpu上可用的性能监控功能。
Intel cpu有一个性能监控子系统,主要依赖于一组特定于模型的寄存器(MSRS)。可以配置这些MSRS来跟踪特定的架构事件,通常是通过计算它们来实现的。这些计数器的正式名称是“性能监控计数器”,通常简称为“性能计数器”或pmc。
这些pmc因微结构而异,不过Intel已经承诺在多个微架构之间提供一小部分计数器,这些被称为架构性能计数器。在撰写本文时,架构性能监控有4个版本,而最新版本提供了大量对通用优化有用的通用信息。然而,对于特定的微架构,跟踪性能事件的可能性几乎是无限的。
要使用Intel上的性能计数器,需要执行几个步骤。首先,你必须找到要监控的性能事件。
例如,以下有一小部分特定于Skylake的性能事件:
英特尔性能计数器主要依赖于两组MSRS,性能事件选择MSRS,其中使用上表中的umask和事件编号对不同的事件进行编程。而且性能计数器MSRS本身也可以保存计数。
性能事件选择MSRS (IA32_PERFEVTSELx)从地址0x186开始,跨越一个连续的MSRS区域。这些事件选择MSRS的布局因微架构的不同而略有不同。可用计数器的数量因CPU而异,通过读取CPUID.0AH:EAX[15:8]来动态检查。性能计数器MSRS (IA32_PMCx)从地址0xc1开始,也跨越一个连续的MSRS区域。计数器支持特定于微架构的位,可以在CPUID.0AH:EAX[23:16]中找到。读取和写入这些MSRs分别是通过rdMSR和wrMSR指令完成的。
通常,现代英特尔处理器支持4个PMC,因此将具有4个事件选择MSRS (0x186、0x187、0x188和0x189)和4个计数器MSRS (0xc1、0xc2、0xc3和0xc4)。大多数处理器都有48位的性能计数器。所以动态检测此信息非常重要!
以下是针对PMC版本3的IA32_PERFEVTSELx MSR:
在特定的微架构表中找到要跟踪的正确事件,使用正确的事件编号和umask在IA32_PERFEVTSELx寄存器中对其进行编程,根据要跟踪的代码类型设置USR或OS位,并设置E位以启用它!这样在每次事件发生时,相应的IA32_PMCx计数器都会递增!
阅读PMC的速度更快
可以使用rdpmc指令,而不是执行rdmsr指令来读取IA32_PMCx值,如果ecx[31]被设置为1,这条指令就会支持“快速读取模式”。
性能计数器的第2个版本
在性能计数器的第二个版本中,Intel增加了许多新特性。
Intel增加了一些不可编程的固定性能计数器(IA32_FIXED_CTR0到IA32_FIXED_CTR2,从地址0x309开始)。这些由地址0x38d处的IA32_FIXED_CTR_CTRL配置。与普通的pmc不同,这些不能被编程来计算任何事件。相反,这些控件只允许选择它们所增加的CPU环级别以及它们是否在溢出时触发中断。
然后通过以下方式启用和禁用它们:
性能计数器的第二个版本还增加了3个新的MSRs,允许对性能计数器进行“批量管理”。英特尔没有检查状态并单独启用/禁用每个性能计数器,而是添加了3个全局控制MSRS。这些是IA32_PERF_GLOBAL_CTRL(地址0x38f),它允许批量启用和禁用性能计数器。IA32_PERF_GLOBAL_STATUS(地址0x38e),它允许检查一个rdmsr中所有性能计数器的溢出状态,另外是IA32_PERF_GLOBAL_OVF_CTRL(地址0x390),它允许在一个wrmsr中重置所有性能计数器的溢出状态。由于rdmsr和wrmsr是序列化指令,因此这些指令可能非常耗时,所以减少它们的数量非常重要!
全局控制(允许通过一个MSR屏蔽单个计数器):
状态(跟踪各种计数器的溢出,使用全局条件更改跟踪器):
状态控制(在IA32_PERF_GLOBAL_STATUS中,将1写入这些位中任何一位,将清除对应的位):
最后,Intel向现有的IA32_DEBUGCTL MSR (地址0x1d9)添加了2位,这些设计是为了在发生溢出条件时减少中断本身的测量。
性能计数器的第3个版本
该版本非常简单,Intel将ANY位添加到IA32_PERFEVTSELx和IA32_FIXED_CTR_CTRL中,以便跟踪物理内核上的任何线程上的性能事件。此外,性能计数器从固定数量的2个计数器增加到可变数量的计数器。这导致更多位被添加到全局状态,溢出和溢出控制MSRs,以控制相应的计数器。
性能计数器的第4个版本
此版本中,Intel重命名了一些MSRS,例如IA32_PERF_GLOBAL_OVF_CTRL改为IA32_PERF_GLOBAL_STATUS_RESET。另外,Intel还添加了一个新的MSR IA32_PERF_GLOBAL_STATUS_SET (地址0x391),它不清除IA32_PERF_GLOBAL_STATUS中的位,而是允许设置位。
最后,该版本中还被添加了一种机制,允许多个用户共享性能计数器。
最终要实现的目标
我们为自己设定了一个相当高的目标,希望有效地将两个性能计数器连接起来。在本例中,我们希望对感兴趣的事件使用任意的性能计数器,并希望将其链接到跟踪经过的循环数的性能计数器。然而,似乎没有一种直接的方法来执行这种链接。
但是我们可以使用IA32_DEBUGCTL.Freeze_PerfMon_On_PMI功能同时停止多个性能计数器。当计数器溢出时,会发生中断。当发生这种溢出时,IA32_PERF_GLOBAL_STATUS中的相关位会导致所有性能计数器停止运行。这意味着,如果我们可以在每个循环边界上造成溢出,就可能同时捕获时间和我们感兴趣的事件。