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

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

同学跑程序要用大页来提升性能,然后发现在机房中的某些机器上能跑起来,而另外一些不行,然后就找到我这debug来了。表现出来的症状是,调用shmget生成共享内存id时直接报ENOMEM问题。

回顾一下计算机基础知识,页是定长、连续的内存物理结构,而大页区别于普通页最大的区别就是其大小,于是首先想到的是在内存分配上是否会因为没有足够长的连续空间而出现即使内存数量足够、但是分配不出连续大空间的问题,解决该问题最直接的方法就是重启。

然而,重启过后,问题仍然存在,遂排除连续内存空间不够的可能性(然而如果是数十/百M的大页应该也不算大,内存不至于那么碎)。而后怀疑是程序本身的问题,于是把shmget操作从程序中抄出来,单独用一段代码执行:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <wait.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/shm.h>

int main(int argc, char *argv[]) {

    int status;
    int segment_id;

    const int SEGMENT_SIZE = 0x1234;

    segment_id = shmget (IPC_PRIVATE, SEGMENT_SIZE,
                        IPC_CREAT | IPC_EXCL | 0666 | SHM_HUGETLB);

    printf("segment_id=%d\n", segment_id);
    perror("");

    shmctl(segment_id, IPC_RMID, 0);
    exit(EXIT_SUCCESS);
}

由于我们只需要验证共享内存是否可以创建,而不是是否可以使用,因而后面的shmat/shmdt都省去了。运行后发现,ENOMEM错误仍然存在。

然后思路又回到大页这方面,猜测是不是SHM_HUGETLB参数导致的共享内存分配失败,编译执行发现程序运行正常,然后就想到了用SHM_HUGETLB作为关键字上网冲浪。然后在StackOverflow上发现有人遇到了相似的问题,说是因为总大页数达到了系统设置的上限。然后顺着问题中的思路检查机器,用sysctl -a发现了问题:对比两台分别可否运行程序的机器,发现有个叫vm.nr_hugepages的参数,看起来和大页的数量有关,在不能运行同学程序的机器上,该参数值为0,而可以运行的机器上参数值为16384。

然后就去网上搜索这个参数到底是什么含义。经过艰难摸索,在官方文档里找到了描述:

/proc/sys/vm/nr_hugepages indicates the current number of “persistent” huge pages in the kernel’s huge page pool. “Persistent” huge pages will be returned to the huge page pool when freed by a task. A user with root privileges can dynamically allocate more or free some persistent huge pages by increasing or decreasing the value of nr_hugepages.

大意是说大页是有单独的内存池的,而这个nr_hugepages可以通过控制允许创建的大页数目来控制大页内存池的大小,因而合理推测当该内存池大小为0的时候,等价于实际上禁用了大页功能。于是通过使用sysctl命令,临时设置大大页内存池的大小:

sysctl vm.nr_hugepages=1

测试后发现问题解决。

然后继续往下阅读文档发现,似乎还有个选项,允许程序运行时动态调整大页内存池的大小,而不至于“撞墙”:

/proc/sys/vm/nr_overcommit_hugepages specifies how large the pool of huge pages can grow, if more huge pages than /proc/sys/vm/nr_hugepages are requested by applications. Writing any non-zero value into this file indicates that the hugetlb subsystem is allowed to try to obtain that number of “surplus” huge pages from the kernel’s normal page pool, when the persistent huge page pool is exhausted. As these surplus huge pages become unused, they are freed back to the kernel’s normal page pool.

大意是说往/proc/sys/vm/nr_overcommit_hugepages里面写任意一个非0值就会开启这个功能,允许当大页内存池的固定内存用完时弹性借用普通内存池的内存(未经测试)。

  1. 配置hosts

    vi /etc/hosts
  2. 关闭防火墙、SELinux

    systemctl stop firewalld    # ubuntu换成ufw
    setenforce 0
  3. 装docker

(暂略)

  1. 安装cephadm
    wget https://github.com/ceph/ceph/blob/v16.2.7/src/cephadm/cephadm
    chmod +x cephadm
    ./cephadm add-repo --version 15.2.7    # 指定ceph版本
    ./cephadm install ceph-common  ceph    # 客户端软件包
    ./cephadm install                      # 把cephadm装到系统环境中

其实cephadm也可以用包管理器装。似乎cephadm的版本和要部署的ceph集群版本可以不一致。

  1. 创建本机最小集群

    mkdir -p /etc/ceph
    cephadm bootstrap --mon-ip <ip>

    其中<ip>替换成本机在ceph集群中欲配置的ip地址,这里不能用主机名。

  2. 配置集群免密登录
    ssh-copy-id -f -i /etc/ceph/ceph.pub <user>@<host>

<user><host>替换成要加入集群的机器名称。

  1. 进入ceph容器命令行
    cephadm shell

这一步的目的可以理解为隔离环境,也就是说允许在本机不安装ceph包的情况下,运行ceph相关的管理命令,可以保证本地环境不被污染。注意退出本shell之后所有临时生成的文件会被删除,但是命令记录似乎会被保留。

  1. 将节点添加进集群(在其它节点上部署docker容器)
    # 在cephadm shell中执行
    ceph orch host add <hostname> <host-ip>    # --labels _admin 以作为管理员节点

<hostname><host-ip>替换成要添加的节点的hosts名称和ip。

  1. 部署ceph mon
    # 在cephadm shell中执行
    # ceph config set mon public_network XXX.XXX.XXX.XXX/XX    # 配置网段,不清楚是否为必须
    ceph orch apply mon <host1>,<host2>,...

使用服务器的hosts名称指定要部署mon的节点,用逗号(不加空格)分开。

  1. 部署ceph osd

osd要求使用裸盘,任何建立了文件系统的盘都不能用作osd。

将集群中所有节点的裸盘用作osd:

# 在cephadm shell中执行
ceph orch apply osd --all-available-devices

如果要详细指定仅使用满足某些特定条件的盘,可以参考https://docs.ceph.com/en/quincy/cephadm/services/osd/#drivegroups,用yml定制过滤器,并用--dry-run检查过滤器写的是否符合预期。

  1. 创建CephFS
    # 在cephadm shell中执行
    ceph fs volume create default

这样会创建一个名为default的CephFS,默认的副本数应该是3。

改成1的方法:

ceph config set mon mon_allow_pool_size_one true
ceph osd pool set cephfs.default.data size 1 --yes-i-really-mean-it

一些翻车后的小技巧:

删除集群后,释放osd占用的裸盘:

ceph-volume lvm zap /dev/sdX --destroy

CephFS创错了,删除提示配置不允许删除:

# 在cephadm shell中执行
ceph config set mon mon_allow_pool_delete true

从集群中删除节点:

ceph orch host rm <host> --force

若是离线了还要加上--offline参数。

查看集群中所有ceph服务的运行状态:

ceph orch ps

一台机器的所有服务都炸了,或者没删除服务就卸载ceph了:

# 在cephadm shell中执行
ceph orch daemon redeploy <service>.<host>

比如ceph orch daemon redeploy mon.ceph01将会重新配置ceph01节点上的mon服务容器。

清理集群删除失败时留下的残渣(相当于移除机器上与ceph相关的所有信息,包括配置文件、数据

# 删除服务
for i in `systemctl | grep ceph | awk '{print $1}' | grep -P 'service|target'`; do systemctl stop $i; done
rm -rf /etc/systemd/system/ceph*
systemctl daemon-reload

# 删除配置文件
rm -rf /var/lib/ceph
rm -f /etc/ceph/*

# 然后还可能需要zap掉创建的osd,参见上文

虽然没怎么用到,但是插眼:https://www.cnblogs.com/oloroso/p/14647299.html

最近新换了个电脑,宏碁的暗影骑士擎,属于比较少见的2NVME+1SATA配置的大存储型号了。俗言人尽其才、物尽其用,宝贵的SATA接口怎么能干放着不利用起来,于是就把老电脑上的1TB汰渍拆下来,拿过来继续当系统盘用。然而intel总喜欢搞些幺蛾子,当年阻碍6/7代U用Windows 7的XHCI、这次的主角Intel RST,都花了我老大功夫才解决问题。

按道理来说,把硬盘从老电脑上一拔、往新电脑上一插、开机键一按,系统就应该噌噌地跑起来才对。然而,事与愿违,启动SATA盘上的系统的时候,不是黑屏,就是出现INACCESSIBLE_BOOT_DEVICE蓝屏。到巨硬官网可以了解到,这个问题通常是因为系统分区所在硬盘的存储驱动配置异常导致的,最为常见的一种情况就是没装驱动。回到电脑自带的Windows 11系统一看,SATA硬盘的控制器名称不是标准SATA AHCI控制器,而是两个Intel RST VMD开头的控制器,我就知道是intel又整新活了。既然已经明确了问题,那就好办了:缺少驱动,就安装驱动。

然而,事情并没有想象中的那么简单:诸如网卡声卡一类的驱动,不装也可以进系统,大不了不用这些功能;显卡驱动的话,不装也有兼容模式,可以在低效率的情况下仍然保持正常工作,又不是不能用。这两种情况都可以在进入系统之后再补驱动。然而,intel家的VMD就没有这种待遇了:不装驱动,连数据都读不出来。读不出系统数据,就进不了系统,更不用谈补驱动了。

然而,在寻找解决方案的时候,无意间发现了官方论坛的一个讨论贴,里面提到BIOS里竟然有隐藏选项可以禁用VMD控制器,转而使用标准的SATA控制器。该隐藏选项需要在Main选项卡(即第二个)中按组合键Ctrl+S召唤出来(稍微按久一点,最好至少1s左右,否则不会显示出来),禁用后重启就能够正常进入系统了。

折腾了这么久,终于可以让自己的系统在新电脑上启动了= =


你以为这就结束了?

作为富有探索精神的咱,可不会因为一点小小进展就满足,最终目标可是在开启VMD的情况下正常进入系统啊。

找了好久,终于在StackOverflow上看到有人有类似的需求,心想StackOverflow上的老哥向来是靠谱的,于是决定拿来一试,嘿,还真他娘的管用。

其原理是利用DISM强制向系统添加驱动,比在系统内用dpinstpnputil一类工具的效果不知道高到哪里去了。命令如下:

dism /Image:C:\ /Add-Driver /Driver:D:\ /Recurse

其中C盘替换成要装驱动的系统分区,D盘及其路径替换成驱动所在分区,如果不需要在子文件夹中搜索驱动的话可以去掉/Recurse选项。DISM命令可以跨系统使用,Windows 11给Windows 10装、Windows 10给Windows 8装,甚至x64系统给x86系统装都是没有问题的,只要保证驱动与目标系统是兼容的就行。输完命令,敲下回车后,程序会给出驱动的安装情况,不出意外的话VMD驱动会出现在其中。

此时重启,再进入要安装驱动的系统时,不会再蓝屏,而是进入类似SysPrep的驱动配置阶段,小圆点转个几圈过后,熟悉的锁屏界面又出现在了眼前。一个字,爽!