最近试图在gem5里做一个数据导出的工作,即在运行过程中记录每次内存访问的时间、位置和数据。

本来是打算当一个单线程来写的,但是考虑到本来跑一个程序用时就长,把关键路径做得尽可能短对工作效率比较友好,就开始考虑把io相关的工作单独提取到一个新线程完成,结果不知不觉地把这个问题演变成一个多线程问题了。

由于涉及到并发编程,原子操作和锁是绕不开的两个概念,一轻一重两大门神守护着多线程环境下的数据安全性,而两者在C++下分别对应有最为常用的头文件,<atomic><mutex>。由于锁的基本使用较为简单,此文主要记录原子类型变量的基本使用方法。

原子类型所要完成的任务是,确保对该类型的指定操作以原子方式完成。至于为什么一开始不把类型设计成原子操作的,这里涉及硬件设计与软件语义之间的抽象层次差异,换句话说,以硬件成本来看,不值得。数据类型难以有个规范,要是为每种数据类型单独设计一条指令,那光是原子操作指令都能上天。VCISC,超复杂指令集的奠基者

于是乎,现有计算机系统采用了折中方案,即硬件提供一些最基本的指令,再使用一些软件手段,以时间/空间为代价,来拓宽原子操作特性的可用范围。以此为契机,出现了<atomic>这种向用户提供软(也可能是硬)原子操作的语言基础库。

原子操作最常见的用法是使用类模板定义对应类型的变量:

std::atomic<int> atomic_int{0};  // use (0) instead of {0} is okay

上面的代码定义了一个原子操作的int类型变量,并且赋初值为0。注意这里用的是括号初始化,而不是等号赋值。这是因为在定义变量时,operator =并不是将值赋予该变量,而是执行对象复制操作,而翻阅文档,有如下定义:

atomic& operator=( const atomic& ) = delete;

也就是说,语言禁止了atomic对象的复制,只能通过值初始化的方式在变量声明时设置初始值。

介绍完初始化操作之后,就是读写操作了。std::atomic提供了两种对原子变量的读写方式,一是像普通变量一样正常使用,二是load/store接口:

std::atomic<int> val{0};
int tmp = val.load();      // equivalent to `int tmp = val;`
val.store(1);              // equivalent to `val = 1;`

虽然说像普通变量一样写很方便,但是真到读代码的时候原子变量和普通变量混在一起还是挺麻烦的。个人还是觉得用显式的load/store比较好。

然后就是大名鼎鼎的复杂原子操作了。复杂原子操作具有共同的特征:RMW(Read-Modify-Write,读-改-写),而CAS(Compare-And-Swap,比较并交换)则是其中最为基础的RMW操作,所有的复杂RMW操作都可以基于CAS操作完成。对于CAS,<atomic>头文件提供了两种相似的实现方案,compare_exchange_weakcompare_exchange_strong。cplusplus对于这两种方法的区别介绍的非常隐晦,只是说strong方法用起来符合直觉,运行结果是标准的CAS;而weak方法用起来性能更好,但是可能出现假阴性的情况(即使compare结果是允许swap的,但还是判定为不允许,走失败逻辑)。

先是compare_exchange_strong的使用样例。

std::atomic<int> val{0};
int prev = val.load();
val.compare_exchange_strong(prev, newval);

这里取原始值为prev,以该值作为CAS的对比值,如果执行compare_exchange_strong的时候读取到的val仍然是该值的话,则执行CAS操作,将val值设置为newval,并给出返回值true;否则不执行CAS操作,反而将prev值设置为最新读取到的val值,并返回false

其次是compare_exchange_weak。由于这里可能出现假阴性的逻辑错误,需要额外对代码做一点修改,来保证代码仍然能完成正确的逻辑。

std::atomic<int> val{0};
int prev = val.load();
while(!val.compare_exchange_weak(prev, newval));

可以看到weak的用法与strong略有差别,这里多套了一层while循环。如前面所说,使用while的目的是对付compare_exchange_weak中即使满足CAS条件,但仍然执行失败流程的情况。这种方式并不适用于顺序敏感型的应用,比如乘加操作,不同线程的操作顺序会导致产生截然不同的输出结果;但是对只需要将数据加入指定结构而不要求顺序的类链表操作的话,这个方法还是具有更高的效率的(因为有神秘的性能优化)。

对于部分常见基础类型,std::atomic预先提供了常见运算方式的原子操作实现,比如原子加fetch_add、原子减fetch_sub,对于char/int及其各种衍生基础类,还提供有原子位或fetch_or/位与fetch_or/位异或fetch_xor等原子逻辑操作的实现。

需求是在gem5里跑SPEC程序。作为一个指令级模拟器,允许在不单独运行操作系统的情况下运行指定架构的程序本身就已经是一个巨大的工程了,而它所付出的代价则是运行效率低下和不支持动态链接(作者似乎说了在x86模式下支持加载动态库,但我没测试)。

于是对应的解决方案是,静态链接。

然而,在如今这个内外存充足、处理器足够快的时代,静态链接的用途可以说是已经被大幅削减了,如果要依赖外部程序的话,没有什么是附加一个动态库解决不了的。一定要说离不开静态链接的,可能也就只有在仍处于系统底层的内核了。对于只能识别汇编指令的处理器来说,它不知道该去哪里找对应的依赖实现,因而程序必须一字一句指导处理器执行。

好在仍然有部分C语言程序离不开静态编译,因而现有的操作系统还是提供了gcc的静态包。而fortran就不一样了,天生作为计算工具的语言往往在操作系统中执行,没必要搞静态链接,但凡一个库函数被调用了两次都是一种空间的浪费——或许是基于这种思想,几乎所有的Linux发行版都从安装源里删去了gfortran的静态依赖库,仿佛多的这几百KB的空间对服务器也是种累赘。

然而还是有人关注这个问题:在CentOS论坛中,有人解决方案。作为开源操作系统,保留源码可以说是一个非常好的文明了,而红帽系的系统甚至还提供了rpmbuild工具用来实现快捷的,从源码包到二进制包的编译功能,大致分为两步:

首先找到源码rpm包(这里推荐pkgs.org和rpmfind.net,格式工整,资料齐全)并解压:

rpm -i gcc-8.3.1-5.1.el8.src.rpm

然后用rpmbuild按照配置文件编译即可:

rpmbuild -bb ./rpmbuild/SPECS/gcc.spec

然后编译过程中可能会提示缺包缺文件,通过yum/dnf安装即可,缺失的文件也都是可以通过包管理器补上的

以经典的/lib/libc.so.6/usr/lib/libc.so为例,这两个文件是x86架构下的glibc库文件,在amd64系统上是默认不装的,但是因为rpmbuild需要,可以通过分别glibc.i686glibc-devel.i686补上。

补完缺失的依赖之后就可以快乐泡茶等编译啦~

鸽了五年来填坑了

随着人年龄越来越大,逐渐对U盘的数量不再那么感兴趣,反而觉得一串串的U盘变成了随身携带的累赘,生怕一不小心就弄丢了。

几年前就试图给自己整个活,弄一个支持Windows+Linux+MacOS的三合一安装启动盘,无奈学识尚浅,花了几周都没搞定。

最近正好手头有装服务器的需求,心中的整活之魂又燃起来了,花了一下午时间总算把坑填好。

一 明确需求

由于通常情况下不论哪个系统都要求使用一个完整的U盘(尤其是MBR时期,刻盘是最方便的MBR更新方式),所以常规情况下换个系统就要重刻一张盘。

因而本项目的基本需求就是一个U盘能够在不修改引导的情况下完成三类系统的安装,且最好同时支持兼容(后文简称为MBR)+UEFI模式。

二 设计方案

三种不同的系统安装方法有所不同,虽然W/L/M三家都支持原装启动盘启动,但是Windows的官方启动盘功能实在是少得可怜,远比不上*nix两家的shell好用,所以大部分情况下会使用PE+WinNTSetup的方法,附加工具多,可定制化程度高。因而在整体方案上,使用PE工具建立Windows安装环境,Linux、MacOS则原盘启动安装。

好在MBR/UEFI的启动方式不同,一个是读扇区,一个是读文件系统,因而两者具有共存的余地——实际上现在的绝大部分启动盘都是同时支持两种模式的。

由于MacOS很早就抛弃了MBR启动,所以MBR启动不做MacOS支持(其实也不难,加个Chameleon iso引导就行);其他方式能支持则尽可能支持:

Windows Linux MacOS
MBR ×
UEFI

三 大致步骤

由于MBR模式和UEFI模式在引导方式上有区别,因而要采用不同的方式对待。由于需要支持MBR模式,因而整体上U盘还是需要保持MBR格式的。

对于UEFI启动比较简单,首先到网上找一份efi shell,放到efi分区(也就是U盘物理地址上第一个分区)。然后启动进入efi shell之后,直接用命令行执行要uefi启动的系统引导即可。

由于只有MacOS本身可以正常操作其文件系统,通常要为MacOS的安装盘单独准备一个分区,然后在MacOS里用磁盘工具恢复分区。(对,手头需要先有一个能用的MacOS,不论是白的还是黑的)

MBR模式的稍微麻烦一点,我这里是基于大白菜魔改的。大白菜的多级菜单引导模式是基于grldr做的,也就是大名鼎鼎的grub4dos,因而需要通过修改菜单,让grub4dos来引导Linux系统的启动流程。

需要准备一个工具fbinsttool,用来读写大白菜的隐藏文件系统。大白菜专门预留了一个文件用来给我们制作自定义的启动菜单,在(ud)/IDBC/GRUB/DIY.LIST位置,在里面照抄现有启动项即可实现自定义启动项的添加。

以我配置的Rocky Linux 9为例:

title 【01】 Install Rocky Linux 9 Minimal 
# find只支持定位文件,不支持文件夹!
find --set-root --ignore-cd --ignore-floppies /RPM_GPG_KEY_ROCKY_9
kernel /ISOLINUX/VMLINUZ inst.stage2=hd:LABEL=Rocky_9_0-x86_64-dvd quiet
initrd /ISOLINUX/INITRD.IMG
boot

需要注意的是,这里最好用dd直接把光盘镜像的分区写入到U盘,不然后面的Anaconda安装可能找不到本地安装源。然后偷懒使用光盘内特有的文件来定位该分区(因为grub4dos不支持用分区名定位)。然后照常加载kernel和initrd即可完成启动。需要注意的是inst.stage2参数,该参数用于指导安装器Anaconda的加载,要是填错了或者没填会导致启动失败。(在光盘里这俩文件在不同位置出现了几次,估计是为了支持不同的启动方式吧)

最近的工作想基于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一样,要多少分多少,属于一种与软件相关的逻辑定义;现在才发现,段式管理更多的是一种硬件资源管理方式,段的大小似乎是由体系结构所决定的,也没有软件那种随用随分、即时释放的灵活性。

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