分类 内存系统 下的文章

最近的工作想基于Intel的AEP非易失内存的物理特性做个读写优化,哪想到这方案还没具体定下来,Intel就宣布要放弃生产所有Optane的产品了,犹如做安倍政治学的成果还没整出来,人就敞开胸怀了。

为了试图让自己做的前期工作不至于白费,必须得想个办法把这个方案从具体硬件上剥离下来,即抽出硬件上的特性作为研究点,而不要将整个方案完全依托在硬件展示出的读写特性上。

因为读写特性大多与底层结构有关,因而有必要了解硬件的底层结构。这里先从数据的底层组织入手,去看了一下之前一直忽略的DRAM的底层结构。

通常来说,DRAM可以分为五个层次:(Row/Column)/Array/Bank/Rank/Channel,分别对应其中物理结构的特性。

RowColumn对应的是DRAM最底层的硬件结构,对应的都是一组一维的线性存储结构。首先要知道的是,现有的DRAM都是呈二维方形排布的,因而可以像经典笛卡尔坐标系一样使用横纵坐标对每个存储单元寻址,而RowColumn恰好对应这两个坐标量,它们的功能完全不同。从组成DRAM的基本结构上来看,Row连接的是一组存储单元的源极,而Column连接的是一组存储单元的栅极,这也就决定了他们各自的作用:Row线用于存取DRAM电容上的0/1数据,而Column线则负责从Row线上选出需要导通用于传输数据的存储单元。

这里简要补充一个题外话,DRAM电容存储的电量有限,那么怎么有效地读取数据呢?

DRAM采用了一种非常骚的方法:反正万物皆可电容,能带电就有电压,那我让Row线充电到工作电压的一半(介于0/1之间),然后将Row线悬空,这样由于接通后存储单元的电压非高即低,那么Row线上的电压在短暂的接通后肯定会发生变化,利用电压差即可判断电容内的数据原本是高电平还是低电平了。那么与哪来的参考电压比较呢?DRAM将Row线一分为二,将电平读取电路放在中间,反正你一次只读取一位数据,总有一头是保持了原滋原味没动的,可以作为参考电压。

接着,RowColumn组成的二维阵列就称为Array,一次可以从一个Array中读取的数据量为1bit。由于一个Column要占用的资源远大于一个Row(电压比较电路、buffer等),因而在大多数现代DRAM芯片设计中,一个Column连接的单元数是远大于一个Row的。(PS. 一个DRAM读写的完整流程可分为6个阶段:解码要访问的Row地址-Column线预充电(precharge,1/2工作电压)-电压比较-数据写入buffer-根据Column地址返回要读取的bit-根据buffer数据写回数据单元)

那么,我要字节寻址(一次读写1Byte)怎么办?直接整8个Array并行放置不就好了。这就是Bank的定义:一次可从一个Bank中读取的数据大小位1Byte。而由于一个Bank通常较小,我们平常见到内存条上的一个芯片内部都是封装了多个(4/8/16)Bank的。

然后通常一个内存条上有8个芯片,可以分别同时读取,这个单位叫做Rank;而内存条级别的并行就是Channel,也就是平常常说的单通道双通道什么的,读写带宽翻倍带来的是实打实的性能提升。

对于内存条而言,通常有DIMM这个说法,即Dual-Inline Memory Module。那么这个“双”体现在哪里呢,双面都贴芯片吗?答案是针脚。仔细观察,可以发现现有内存条两面都有金手指,并且是不互通的,相当于针脚数是我们一次可以看到的数量的两倍。而早期内存条通常是SIMM,也就是说内存条两面的金手指电气功能是一致的,这便是DIMM中这个“Dual”的含义。(PS. 与体系结构相对应,SIMM单次读取宽度位32bit,DIMM则为64bit)

但是,光这么看的话,似乎还是与现代CPU 64Byte的缓存行相差甚远(即便是4-Channel也只是每周期32Byte数据)。原本内存频率就要低于CPU,结果数据速率还跟不上,听起来似乎还是有点问题。

这个时候就可以将视野重新拉回Array层次了:我们可以发现,每次为了取1bit数据,都要经过完整的从预充电到写回的流程。然而,实际上在整个流程中,读取数据占用的周期数反而很小,频繁对Row线做激活读数据操作浪费了大量的时钟周期

那么怎么办呢?考虑到程序执行正好有空间局部性,实际上连续读取内存区域(尤其是在没有分支或跳转的代码段的情况下)的概率是很高的,那么在空间上做预取是可能具有性能提升的可能的。而恰好我们可以发现,实际上在Row内部,一次读取的数据是物理地址上连续的,恰好满足连续地址预取的要求——那么我在每个内存读写周期内,多传输几个数据,不就能提升数据传输效率了吗?这个想法就是DRAM中的Burst Mode,通过一次预充电一口气读写多个DIMM宽度的方式实现了读写带宽的提升。

与想法完全相同的是,我们可以直接在芯片内添加缓冲区来将我们一次读取的多个数据打包带走。考虑到在Array层面以bit为单位做缓存对设计复杂度影响大,在实际的实现中,是把这个缓冲区放在了Bank的层面,将8个Array读出来的8个bit缓存为一个byte(写则相反,在写回之前多改几个bit)。根据规范,DDR2一次可连续从一个Array中读取4bit,DDR3/DDR4是8bit。可以看到,从DDR3开始,我们就实现了一次内存读取可以填满一个CPU缓存行的功能了。

这里顺便提一下Bank Interleaving这个东西,类似于RAID3/4/5,把地址连续的数据拆分放在了不同的物理介质上(如Chip0存地址0/4,Chip1存1/5,Chip2存2/6,Chip4存3/7),两者都含有充分利用多份物理设备的带宽的含义在内。只不过在Bank这里,由于读写完数据后还要加电刷回存储单元,要占据缓冲区并消耗多个时钟周期,导致在两次内存访问之间是有一定的绝对不应期的(雾)。使用了Bank Interleaving的话,相当于就给这些Bank建立了一个流水线,在读完上一个地址的数据后,下一个地址的预充电操作可能就已经完成,可以直接读取数据了,不需要再像单Bank场景一样,在两次内存访问之间白白等一个内存访问周期的时间。如果Bank数量和Burst长度都是2的幂指数倍的时候,还能再二进制地址上进一步做小动作来优化实现,这个就不展开说了。

最近看内存冷热页面替换方向的论文的时候,看到有篇论文 Transparent Hardware Management of Stacked DRAM as Part of Memory 提出了一个有趣的想法,即以段为单位监测内存访问的热点情况,并为每个段分配固定大小的高速缓存。虽然这个方法具有一定的局限性,即没有考虑到段本身的冷热程度,在效率上并不能达到最优,但它成功唤起了我对于内存管理的思考。

根据计算机组成原理,通常认为内存管理是段页式的,其中段式管理提供逻辑上的内存划分(同一段内存具有相同的语义,即同属一个应用程序,或者同为代码/数据),页式管理提供物理上的内存划分(操作系统级别的最小内存管理单位,如分配/虚拟内存的换入换出等)。

然而,事与愿违,当试图在网上搜索的时候,发现大家都在说,x86和x64的操作系统放弃了使用段寄存器,而intel甚至在x64指令集中要求,将段寄存器置为0才是合理的使用方式。

那么事实为何如此呢?根据从网络上收集到的信息来看,可能主要与指令集设计者想甩掉段式管理这个历史包袱有关。早期处理器存在的问题是,处理器的寄存器宽度只有16位,而寻址范围的宽度可以上升到20位,为了应对处理器无法直接寻址超过寄存器宽度的内存地址的问题,加入了段寄存器,通过段寄存器移位的方式,对于数据段/代码段等段寄存器,扩充了程序可使用的内存地址空间。然而寄存器(指令集)大小是指数增长的,光是从16位升级到32位,就让内存花了数十年的时间才重新扩充满整个地址空间,推动64位指令集的刚需化发展。也就是说,从32位指令集开始,内存地址空间的扩张速度就已经远远追不上寻址空间的发展速度了——从这个角度来看的话,既然段式管理解决的是内存空间大于寻址空间的问题,那么在内存空间远小于寻址空间的情况下,段式内存管理似乎就没有存在的必要了(参考StackOverflow)。而从32位指令集开始,段寄存器中的使用方式已经不是之前的基地址移位了,而是变成了段表项,转换成了通过查表的方式取得段的基地址。

于是,一方面为了保障向前兼容性,另一方面为甩开包袱做好准备,指令集设计者和操作系统设计者在这个问题上默契地采用了同一种方法来应对这个问题:不取消段寄存器,但是要求所有的段的基地址都从0x00000000开始。反正指令集的寻址空间足够大,那么只要我将整个地址空间都视作一个段,不就相当于架空了段寄存器的作用,进而消除了段式管理在操作系统中的应用么?

知乎有一文对于在现代操作系统中的管理设计做了实际验证,通过各种骚操作印证了这个事实。不过文中有一处写的不够清楚,在Linux的gdt_table那里,GDT是如何从一串不规则的值映射到像0x00x1F这类连续0/1的数字的。这个实际上是因为GDT中的变量不是连续存放的,可能是出于硬件设计对数据做了切分,按照OSDev上的Segment Descriptor做一些简单的运算后,就能验证实际上这些变量都是具有正确的含义的。

看完这些信息后,总感觉我之前对段式内存管理的理解有些不太正确。以前一直以为,段式内存管理像malloc一样,要多少分多少,属于一种与软件相关的逻辑定义;现在才发现,段式管理更多的是一种硬件资源管理方式,段的大小似乎是由体系结构所决定的,也没有软件那种随用随分、即时释放的灵活性。

最后总结一下,由于现在寻址空间远大于内存空间,段页式内存管理中的段式管理逐渐失去其作用,被软硬件所淘汰,主流操作系统中主要依靠页式内存管理和多级页表(应用于虚拟内存)来管理内存空间。

浏览NVM全貌

sudo ipmctl show -memoryresources

以内存插槽为单位展示AEP信息

sudo ipmctl show -dimm

NVM Region(区域)信息

Linux以Region区域为单位来划分NVM区域的硬件特征,或者说是用途。一个区域内的NVM具有相同的特性,比如都做内存(Volatile)或者非易失存储(Appdirect,可供系统/用户作为数据设备读写)。可以理解为把NVM DIMM再抽象、划分为内存或硬盘(因为NVM的确同时具有两种设备的特性)。

sudo ipmctl show -region

把一个CPU下的所有PMEM划成一个区域

官方文档中提到,Region不能跨CPU插槽,估计是因为NUMA影响性能的原因。

sudo ipmctl create -goal -socket 0x0000 PersistentMemoryType=AppDirect

制造单个无交错的PMEM区域(可用于测试单条PMEM性能?)

sudo ipmctl create -socket 0x0000 -dimm 0x0001 -goal PersistentMemoryType=AppDirectNotInterleaved

这条命令会报错,但配置完重启后还是会达成预期效果= =

查看已有区域使用情况

sudo ndctl list -Ruv

查看已有Namespace

Namespace命名空间,可认为是设备上的分区,可用作磁盘或分区,格式化后供系统或用户使用。

sudo ndctl list -Nuv

创建新Namespace

sudo ndctl create-namespace -t pmem -m fsdax -s $((128*1024*1024*1024)) -n default -r region0

注意如果使用的Region是DirectApp Interleaved的话,这里的size必须要是区域设备数的整数倍,不然数据不能均匀分布,会报错。

删除Namespace

sudo ndctl disable-namespace -r region0 namespace1.0
sudo ndctl destroy-namespace -r region0 namespace1.0

重置PMEM(比如异常断电后namespace找不回来)

sudo ndctl destroy-namespace all -f  # 删除namespace
sudo ipmctl delete -f -dimm -pcd     # region配置都给你扬喽

其他参见ipmctl/ndctl官方文档,或者巨硬的快速上手教程

一开始看内存安全的时候,了解到从电路上窃取信息这种操作是真实存在的,专业术语称作Bus Snooping总线嗅探。然后当时就理所当然地认为,嗅探属于网络攻击的一种,应该尽可能防范。

然而gem5又给我上了一课(没错还是它)。在阅读代码的时候,看到src/mem/packet.hh的注释里提到了Snoop Flags。这是个啥?难道gem5还支持安全攻击建模的,之前咋没听说过?

查阅资料后,咕果搜索结果中出现了缓存一致性这个关键词。进一步了解后发现,嗅探是当今多核时代下,一种朴素而常见的、确保缓存一致性的通信手段。

根据英文wiki,对于内存来说,有两种常见的内存一致性的级别:

一是Cache Coherent缓存一致。在这种一致性级别下,要求对于单一内存地址的数据,满足程序所有线程的执行顺序(反过来想,我们是否可以理解为,某些内存地址的数据操作是不需要遵循全局顺序的,比如只有单个线程使用的私有内存区块?)。另一种定义是,如果对于同一内存地址的写操作是顺序排列的(即同一时刻只有一个写操作),那么该系统具有缓存一致性。

另一个则是Sequentially Cosistent顺序一致。在这种一致性级别下,要求程序的整个内存区块中的内存操作都必须满足所有线程的总体执行顺序。

然后看到了一篇介绍缓存一致性的文章(原文/译文),了解到了在MESI缓存一致性协议中,是如何通过总线嗅探来实现缓存区块的状态转移的。

MESI协议的名称由协议本身定义状态机的四个状态首字母缩写组成,其中MModified、SShared、IInvalid三个状态分别对应单核系统下缓存的三种状态:脏(dirty)、干净(clean)和无效(invalid)。MESI遵循了缓存一致性的原则,对于同一内存地址,同一时刻只允许一个线程具有写的权利(处于M状态)。

由于要尽可能地提升多核系统的工作效率,经典设计方案是为每个核分配一块独占的缓存空间,而这也是导致传统缓存状态机出现问题的关键所在:若是继续使用单核的强同步策略(写入即同步)的话,同步缓存的操作将大幅降低处理器运行效率;不使用同步策略的话,则会带来经典的写前读/写后读问题。

MESI协议则可以视作一种弱同步策略,仅在特殊事件发生的时候执行同步操作。这个特殊事件直接映射成了MESI中的第四个状态EExclusive,也就是独占状态。MESI规定,只有在进入E状态后,才能进入M状态。在MESI中,需要嗅探的内容就是进入这个E状态的内存操作。

关于MESI协议的具体内容可以参见wiki,这里就懒得写了

翻译自 http://iram.cs.berkeley.edu/kozyraki/project/ee241/report/crossbar.html

The memory crossbar connects the multiple Load/Store units of the processor to the multiple memory sections. The L/S units are capable of issuing either a load or a store on every cycle, and as a result, the memory sections should, ideally, be capable of processing these requests at the same rate. In order words, if there are n L/S units, each issuing one memory accesses per cycle, then the crossbar must have a peak bandwidth of at least n words per cycle.

内存交叉开关是一种用于将多个处理器(内存)读写单元与多个内存区块相连接的结构。由于单个(内存)读写单元可在单个时钟周期内完成一次读或写操作,我们可以认为,在理想状态下,被连接的(单个)内存区块也应该具有相同的请求处理速度(即每时钟周期处理一个读或写请求)。也可以理解为,如果(在使用了内存交叉开关的内存系统中)CPU有n个(内存)读写单元,每个单元每时钟周期可完成一次读或写请求的话,那么在该存储交叉开关处至少有等价于每周期n次读写请求的最大带宽。

largexbar.gif

上图是该文章给出的内存交叉开关一种实现的图例。图中上侧为内存读写单元,下侧为内存模块,中部的横线表示总线,一条线就是一根完整的总线;这里有每种四根则表示有四组相同的总线,实心点表示内存读写单元,叉则表示开关。由于总线只有单一的所有权,一个周期内则只能供一个内存读写单元和一个内存模块使用,也正是上文最大n倍单总线吞吐量的原理。