在持久内存的世界探索

标题:A journey into the World of Persistent Memory

日期:2024/01/23

作者:Gael Thomas

链接:https://www.youtube.com/watch?v=7O_IY6P72XE

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注一:演讲是分两部分的,粗略看了第一部分。

备注二:好像不太理解文中持久化指令的必要性?按他的说法,难道易失性内存就没缓存行写回问题?我认为这就是缓存一致性协议要做的事情。怎么易失性内存换成持久内存就出事了呢?

备注三:(更新)好像确实有问题,但是我只认为是崩溃后误持久化导致的。两个线程 T1 x=1; T2 if(x) y=1; 运行时的内存屏障没问题,但是 pmem 的话线程 T2 可能会持久化 y,然后断电,然后 x 没有持久化。


什么是持久内存?

我很高兴能在这里向大家介绍什么是持久内存。正如Pierre所说,我是法国INRIA的一名高级研究员。你们可以听出我浓重的口音,我是法国人。我不是一个理论家,我是一个系统(system)领域的从业者。这意味着我感兴趣的不是计算机系统背后的理论,而是我们如何能让应用程序更高效。所以,在这次演讲中,我的重点将是性能,以及我们如何高效地使用持久内存。

那么,什么是持久内存?它其实就是易失性内存(volatile memory,也就是传统内存)和持久性存储(persistent storage)之间的一场美妙联姻。这具体意味着什么呢?它意味着你拥有内存的属性。你拥有字节可寻址性(byte addressability),这意味着处理器可以通过简单的加载(load)和存储(store)指令来访问持久内存。与易失性内存的主要区别在于,当你关闭计算机时,你的数据仍然会保留在持久内存中。所以,它的行为就像一个持久性存储设备。它非常有意思的地方在于,你既拥有内存的属性,又拥有硬盘的持久性,并且性能几乎和易失性内存一样好。

为了说明这一点,在性能方面,当你访问易失性内存时,延迟是在纳秒(nanosecond)级别,一次读取大约是80纳秒。而使用持久内存,你的延迟仅仅是这个数字的两倍。这意味着你使用持久内存的速度几乎可以和易失性内存一样快。真正产生巨大变化的是非易失性内存和硬盘之间的差异。因为正如你所看到的,持久内存和传统存储之间有1000倍的性能差距。如果你用NVMe,延迟在250微秒(microsecond)的级别。即使你用你能想象到的最高效的NVMe,延迟也只能达到10微秒的级别,而不是纳秒级别。所以,这意味着通过持久内存,我们可以在拥有持久性的同时获得高性能。

那么,我们该如何使用它呢?在硬件层面,持久内存就是一种内存。你有一个内存条(DIMM),像插其他内存一样把它插上,然后它会被BIOS暴露出来,就像任何其他内存条(memory bank)一样。之后,在系统层面,我们并不把持久内存当作内存条来使用。持久内存有一个小问题,那就是我们希望在计算机启动后能够找回我们的数据。为此,我们需要名字(names)。我们必须将名字和数据关联起来。在系统中,我们通常通过文件系统抽象来实现这一点。所以Linux所做的,就是简单地在持久内存中存储一个文件系统,以便给你的数据命名。你会使用一个路径,比如,想象一下文件hello。这个路径只是一个名字,对应存储在持久内存中的数据,就像你把数据存储在硬盘上一样。对于Linux来说,你将拥有完全相同的抽象。

普通文件系统和用于持久内存的文件系统之间有一个小小的区别,那就是用于持久内存的文件系统处于直接访问模式(direct access mode)。我会向你们展示这意味着什么。从高层次来看,这意味着你将绕过我们通常用于访问文件系统的所有软件栈,以便直接访问持久内存。之后,如果你查看用作文件系统的持久内存,并检查它的字节,你会看到一个完全正常的、标准的文件系统,有目录、inode等等。所以,我们可以像使用任何磁盘一样,使用openreadwriteclose这些原语来使用持久内存,这意味着对于开发者来说,我们只是拥有了一个非常高效的存储设备,可以直接使用。当你对持久内存进行读写时,性能会非常惊人。但我们还可以做得更好。

为了解释问题所在,我将试着回顾一下我们是如何在Linux中使用一个普通文件系统来访问文件的。对于标准的文件系统访问,我们面临的问题是,处理器能够以字节(byte)粒度从内存中加载和存储数据。但对于硬盘,无论是HDD、SSD还是NVMe,你都无法直接以字节粒度访问它。你只能以扇区(sector)粒度访问存储,这可能是512字节。并且CPU无法直接访问存储设备。当你从CPU执行加载和存储指令时,你加载和存储的是内存中的数据。但对于硬盘,你必须使用另一个API。你必须向硬盘发送命令,这些命令会将数据从硬盘移动到物理地址空间。这里的物理地址空间,需要明确一下,它只是硬件层面内存总线所能看到的视图。

那么,我们如何访问一个文件呢?传统上,我们做的是将易失性内存用作硬盘的缓存。基本上,我们在易失性内存中存储一份文件的副本,当我们访问文件时,直接在易失性内存上操作。在Linux中能够做到这一点的原语叫做mmap。这是一个非常强大的函数,它接受一个文件、一个进程中的虚拟地址,并将文件加载到你的进程中,加载到进程的地址空间里。所以,当我们想访问文件时,我们使用mmap,它将文件加载到你的物理地址空间,然后Linux利用页表(page table)将文件内容映射到进程的虚拟地址空间。这非常简单。所以你的进程在虚拟地址空间中加载和存储数据。当有加载或存储操作时,它通过页表被转换为物理地址,然后你访问的是位于易失性内存中的文件数据。这里的易失性内存充当了硬盘的缓存。这意味着,如果你在任何时候关闭系统电源,你在易失性内存中的最后一次写入将不会被传播到硬盘。

还好吗?还好。希望这不会太技术化。

好的,所以这种方式非常昂贵且效率低下,如果我们这样做,会扼杀掉持久内存所有可能的好性能。为什么?为什么会这样?因为它需要将硬盘的内容复制到易失性内存中,这很耗时。当我们修改易失性内存中的文件内容时,我们还必须将易失性内存的内容写回到硬盘。这也非常耗时。而且系统,也就是Linux,会定期这样做。这意味着你只是在处理你的文件,修改它,然后某个时候,Linux就会把易失性内存的内容复制到硬盘。所以这很耗时,会极大地拖慢你的应用程序。如果我们对持久内存使用完全相同的系统,我们拥有一个能够在100纳秒级别运行的高效持久内存,然后如果我们把易失性内存当作持久内存的缓存,我们就会扼杀掉性能,因为我们会把内容从持久内存复制到易失性内存,这是非常低效的。

所以,有了持久内存,我们能做什么呢?最终,持久内存是一种内存,我们可以直接访问它,无需任何抽象。我们不需要缓存来访问持久内存。因为处理器能够……CPU发送的加载和存储指令会直接到达持久内存并在原地执行,无需任何转换。所以Linux所做的,通过我们称之为直接访问文件系统(Direct Access File System)的方式,就是当你使用mmap时,Linux会简单地将持久内存的内容直接映射到你的虚拟地址空间。没有任何转换,没有任何复制,你将能够原地(in-place)访问你的数据。

最终,Linux到底在做什么呢?Linux使用文件系统的元数据,也就是文件名,以便能够找回文件的内容。Linux也使用文件系统抽象来分配新文件、新数据,以及扩展它。例如,如果你想增加持久内存中数据的大小,你会使用ftruncate。所以,我们使用文件系统抽象来分配、创建和删除数据。然后,对于访问操作,我们直接访问文件的内容。所以,可以说我们兼具了两全其美。

好的,所以,你现在能够使用持久内存了。所以,当你……我忘了说这个。因为我是一个系统领域的从业者,我非常享受编码。我们接下来要做的是,我会在不到一小时的时间里,给你们一些关于如何使用持久内存的信息。然后,你们会做一个两小时的实验(lab),在实验中你们将实现一个系统来提升你们的IO性能。你们的电脑里没有持久内存,所以我们会用易失性内存来模拟它。但你们会看到,它非常高效。当你们将来拥有持久内存时,你们只需……你们只需拿起你们的代码,用它来在访问硬盘时获得惊人的性能。

/* step 1: open / create fic1 */
int fd = open("/mnt/pmem/ficl", O_RDWR | O_CREAT, 0700);

/* step 2: fix the size of fic1 */
ftruncate(fd, 8*1024);

好的,所以,为了访问我们的持久内存,我们做什么……我们打开一个文件,这里的这个命令,open,你不应该把它看作是一个普通的打开操作。我们使用文件名 fic1,这只是个名字。这里重要的是,我们使用了 O_CREATE 标志。这个标志告诉系统,“好的,如果文件不存在,就创建它”。这是一次分配(allocation)。在这里,你必须把它看作是我们在持久内存中分配数据。它不完全是一个文件创建。所以,我们创建我们的文件。如果文件已经存在,我们就重用它。所以,我们用名字只是为了……它就是文件的名字。

然后,我们做什么……我们有了文件,我们可以给文件一个大小。为此,我们使用函数 ftruncateftruncate 接受一个文件描述符和一个大小,然后会扩展文件的大小。这里,我们正在做的,不是……第一条指令在存储中分配数据并给它一个名字。第二条指令给数据一个大小。所以,我们固定了大小。这就像在持久内存中做了一次 malloc,如果你愿意这么想的话。只是我们为此使用了文件系统抽象。

/* step 3: map fic1 in the virtual address space */
char* addr = mmap(NULL, 8*1024, PROT_READ | PROT_WRITE,
                  MAP_SHARED_VALIDATE | MAP_SYNC, fd, 0);

然后,我们必须使用mmap。伴随着这里你看到的非常奇怪的标志:MAP_SHARED_VALIDATE | MAP_SYNC。这告诉Linux,“好的,我想以直接访问模式使用我的文件。所以,我不想使用Linux的页面缓存(page cache)。我想把内容直接映射到我的进程中”。所以,你这么做。这里是你想要映射文件的地址,NULL,任何地址对我们来说都可以。你想要映射的大小。我们想以读写方式访问持久内存。然后我们有这个标志,就这样。

/* step 4: direct access to the pmem */
addr[0] = 42;

到了这一步,这个地址(addr)就只是一个指向你持久内存的指针。你可以像使用任何其他内存一样使用你的持久内存。所以,你可以像这样在持久内存中写入一些东西。

这就像对任何内存的正常访问一样,而且它能工作。所以,在四五行代码里,有趣的地方在于,我非常欣赏Linux提出的这些抽象。因为我们看到,我们确实有这种命名的概念,我们重用了文件系统抽象,然后我们拥有了内存。我们可以看到,内存和存储、文件系统和内存,彼此之间并没有那么大的差别。

好的。现在,你关掉电脑,持久内存仍然在那里。你什么都不用做。当你重启时,你只需要重新打开你的文件,你会发现你的内容原封不动,然后可以继续工作。

提问(听众): Linux是如何知道它不需要从持久内存加载到易失性内存的?

回答: 就是因为这个标志 (MAP_SHARED_VALIDATE | MAP_SYNC),它表示,我想要绕过Linux的页面缓存,就这么简单。

好的。所以这很简单。最终,你可以看到,用五行代码,你就能使用持久内存,你可以做任何你想做的事,它都会工作。但生活从来没有这么简单,不幸的是。问题在于,持久内存不是一种普通的内存。在某些时候,你可能随时会遇到崩溃。所以,我们必须做些什么来让我们的数据保持一致性(consistent)。我们接下来会看到。

一致性问题与原子性

我们面临的问题是,你可能在任何时候崩溃。所以,让我们想象一下,我写了一个疯狂的应用程序,里面有一些宝可梦之类的东西。这里你有一个数据结构,有一个名字字段,你用我之前介绍的代码来访问它。

struct Pokemon {
    char name[20];
    // ...
};

所以,我们有一个指向我们结构的指针,里面有标识符,我想写入我的宝可梦的名字,也就是“Pikachu”。好的,这很简单。

strcpy(pokemon_ptr->name, "Pikachu");

你在这里会遇到的问题是,如果你在字符串拷贝(strcpy)的中间发生了崩溃——这是一个普通的内存到内存的拷贝,它能工作。好的。如果你在中间崩溃,最终,你在持久内存中可能会发现一个空字符串(null string)。好的,你可能会想,如果我看到了空字符串,可能是我遇到了崩溃,我可以修正它。或者,你也可能找到完整的“Pikachu”。好的,在这种情况下,你有一个有效的名字。

你将在崩溃后遇到的问题是,如果你发现的名字是“Pika”,仅仅是“Pika”,这“Pika”不是一个有效的名字。它从来没有存在过。有效的名字是“Pikachu”或者空字符串,但“Pika”不是任何时候存在过的名字。如果你想象这里你有一个键值存储(key-value store),里面有用户的账户,这就意味着,如果你在中间崩溃,你就会有一个幻影般的名字“Pika”,没有人会认识它,因为那个用户名叫“Pikachu”。所以我们必须做点什么。我们必须定义一些抽象,使用一些抽象,以便能够定义当你重启机器时什么是正确的状态,以及你如何能确保在崩溃后数据的一致性

对此,我们有很多解决方案,当然,因为这是一个众所周知的领域。基本的思想是,我们可以定义我们称之为故障原子块(failure atomic blocks)的东西。一个故障原子块是一段代码,它要么被完整地执行,要么根本不执行,但你不能得到一个中间状态。当然,我们会模拟这个过程,因为我们可能在一个块的中间崩溃,但最终,这个块的效果必须是要么全部执行,要么根本不执行。

好的,我们有不同的实现技术。有很多高层库。有著名的PMDK(Persistent Memory Development Kit),是由Intel为其Optane DC产品实现的。还有Romulus,还有很多不同的框架提供了这种故障原子块的抽象。所以,如果你想用它们,你可以用。我想要向你们展示的是,我们如何能构建这些框架。所以,我们将尝试手动构建故障原子块,只是为了试着理解我们如何能构建一个更高层的库。

实现原子性的硬件挑战

struct Pokemon {
    char name[20];
    bool committed;
    // ...
};

这很简单。当你遇到这类问题时,你想要一个原子块。所以我们只需要一个标志(flag)来说明这个原子块是否被完整执行了。

if (!pokemon_ptr->comitted) {
    strcpy(pokemon_ptr->name, "Pikachu");
    pokemon_ptr->committed = true;
}

这里,有趣的是,只需要一次写入,我就可以验证我的名字是否有效。这是一次原子操作。你只需要在字(word)级别进行一次写入。最终,你会得到一个能工作或不能工作的结果。所以……如果我们想象一个提供顺序一致性(sequential consistency)的理论机器,这也不行。我会解释为什么。但从高层次来看,我们可以想象做这样的事情。

我们发生了崩溃。我们把文件映射到我们的虚拟地址空间。然后……如果committed标志不是true,这意味着我们遇到了崩溃。所以我们只需要写入“Pikachu”的名字,然后验证它。

我们遇到的第一个问题是,硬件非常不友好。因为硬件不是为持久内存设计的,它是为易失性内存设计的。这不完全是一回事。所以我们遇到的第一个问题是乱序执行(out-of-order execution)的问题。处理器有一个流水线,它会把指令取到流水线里,然后以任意顺序执行它们。最终,你有一个语义,你有一个内存模型,当然。但是……我们不确切知道哪个……这里我们用的是Intel,我们可以假设是TSO(Total Store Order)内存模型。但如果我们尝试在另一台机器上使用同样的代码,我们会有另一个内存模型,我们需要一些东西。

// 执行顺序会变成这样
if (!pokemon_ptr->comitted) {
    pokemon_ptr->committed = true;
    strcpy(pokemon_ptr->name, "Pikachu");
}

我们可能遇到的问题是,处理器可能会颠倒这两个操作的顺序。所以在这种情况下,你会先写入你的事务已提交(committed = true),但名字根本无效,因为它还没有被执行。所以你得到的东西是无法工作的。

// 别这么干
if (!pokemon_ptr->comitted) {
    strcpy(pokemon_ptr->name, "Pikachu");
    memory_fence(); // !
    pokemon_ptr->committed = true;
}

在这种情况下,我们的第一个想法是添加一个内存屏障(memory barrier)。这完全没用。那么,什么是内存屏障,或者说内存栅栏(memory fence)?它只是一条指令,防止处理器进行任何重排序。所以当你遇到这条指令时,这意味着处理器会执行Pikachu的写入,然后是内存栅栏,然后是committed的写入。处理器不能在Pikachu之前执行committed。但是,当我说“执行”时,这是我们需要定义的东西。对于处理器来说,执行一条指令意味着让这条指令对其他核心可见。这并不是执行本身。

所以这里我们能确定的是,如果……我把这段代码在一个核心上执行,我在另一个核心上运行一个线程。指令的顺序告诉我,如果另一个核心看到committed等于true,那么在这种情况下,另一个核心也会看到名字是“Pikachu”。但这是在两个核心之间。我没有说任何关于内存的事情。在CPU背后,缓存行(cache line)可以以任何顺序被刷回(flush)到内存。所以处理器之间的可见性是正确的。但在内存中,也许我的处理器出于某种原因会先写回committed,然后再写回“Pikachu”。就因为我遇到了冲突,我需要缓存空间,我必须找到一些空间,于是我写回了committed。所以在我崩溃后,我会在持久内存中发现一个不正确的状态。所以,在我们的情况下,仅仅使用内存栅栏是不够的。

正确的持久化指令

好的,我重用一下Israel Eivich七年前提出的观点,那是一篇非常棒的论文,如果你想读的话,它定义了我们如何能正确地使用持久内存。它定义了我们需要的高级属性,我们需要的语义。我不会介绍那个语义,我只想给你们一个直观的感受,关于我们如何能使用之前的指令。我非常确定,我们必须先玩乐高积木,然后才能建立乐高积木的理论。先做事情很重要。

if (!pokemon_ptr->comitted) {
    strcpy(pokemon_ptr->name, "Pikachu");
    pwb(pokemon_ptr->name);
    pfence();
    pokemon_ptr->committed = true;
    pwb(&pokemon_ptr->committed);
}

基本的想法是,我们需要两条新指令,来在我们把数据传播到持久内存,到内存本身时,给出一个顺序。所以我们有两条新指令。第一条是 PWB 指令(pwb(char* addr)),也就是 持久性写回(Persistent Write Back) 指令。这条指令会将包含地址的缓存行添加到一个队列,一个FIFO队列,这个队列里的数据需要被传播到持久内存。物理上,PWB并没有一个队列。最终,PWB只是简单地把缓存行放到总线上,而内存和处理器之间的总线就是一个FIFO总线。所以仅仅通过在内存总线上发送数据,就足以保证顺序。

好的。但这还不够,因为PWB本身也是一条指令,可以和任何其他指令被重排序。所以我们必须定义指令的顺序。为此,我们需要一条新指令,叫做 PFENCE 指令(pfence()),持久性屏障(Persistent Fence) 指令,它能防止存储(store)操作的重排序。很重要,只针对存储。它防止存储操作和PWB操作的重排序。有了这个,我们就可以在向持久内存传播数据时获得一个顺序。

所以,我那段只想在内存某处写入“Pikachu”的简单代码,如你所见,代码变得越来越复杂。结果是,仅仅为了在内存中写入一个字符串,看看你需要什么。你需要五到七行代码。当你这样做时,它是高效的。

它是如何工作的呢?我们首先写入名字。好的。然后我们会说,好的,我把“Pikachu”的写回操作在我这个核心上入队。然后我想要确保我不能在名字之前提交,也就是不能在名字之前把提交数据发送到我的持久内存。为此,我需要一个屏障。这里,我有了持久性屏障。然后我才能发送,我才能写入committed = true。最后,我可以把committed的写回也入队,为什么需要这个?这只是为了下一个事务。我必须在下一个事务开始前提交这一个。

好的。所以这里我们有了一个全序(total order)。因为我们在名字和名字之间有明确的依赖关系。处理器永远不会以颠倒的顺序执行访问相同数据的两条指令。否则你会得到一个随机值。如果你写x = 1然后x = 2,而最后结果是x = 1,那电脑就完全疯了。这没有道理。所以这里这两条指令之间有明确的依赖关系。之后你有屏障,确保你不能在PWB之前执行committedPWB,你不能让它们在这次PWB之前可见。所以我们确定我们会在PWB之后执行committed = true。然后我们把committed入队。这意味着,如果committed到达了内存,持久内存,我们就能确定“Pikachu”也到达了内存。我们有了一个正确的顺序。

所以我们最终有了这个不变量。

提问(听众): 如果名字比缓存行长怎么办?

回答: 是的。我们必须处理这个问题。是的。这在开发时可能会变得非常复杂。因为我们作为开发者操作的是数据变量,而这里我们必须和缓存行打交道。所以如果我们有一个非常大的缓冲区,我们就需要多次调用PWB。为此,Intel处理器有一条指令,可以在你需要的时候完全刷新缓存。这是一种超级PWB,当你不知道你的缓存行在哪里时,它能解决问题。这种情况很常见。

好的。

问题是,我们常常需要更强的保证。这里我只是在玩弄效果(effects),而不是执行(execution)。我说,好的,我的PWB的效果在这里,抱歉,我的PWB是在另一个之后执行的,等等。但目前,这只是一个推测。我的缓存行还在队列里。我不知道它们是否到达了持久内存。我没有任何保证。

// 线程 1
if (!pokemon_ptr->comitted) {
    strcpy(pokemon_ptr->name, "Pikachu");
    pwb(pokemon_ptr->name);
    pfence();
    pokemon_ptr->committed = true;
    pwb(&pokemon_ptr->committed);
    pfence();
    ok = true; // !
}

// 线程 2
while (!ok) {} // !
mfence();
sendmsg("ack" to user);

当我有了这里的代码。所以,让我们想象你有两个线程。在一个线程里,你创建了Pikachu的账户。然后在易失性内存中,两个线程之间有一个同步。我只需要一个标志。好的。来说明用户已创建。如果……所以这里你会写ok = true。在另一个线程里,你在等待ok。当ok变成true时,我们有一个内存屏障来强制排序。因为我们想确保我们只在ok = true之后发送消息。我们想确保,如果ok = true,我们发送了确认消息。为了正确性。所以我发送我的消息。但最终我没有证明任何事情。也许我的消息被发送给了用户。但目前,包含“Pikachu”的那个缓存行还在我的内存总线上飞行。这意味着我会告诉用户,“好的,你的推文已发送”。用户会很高兴。但最终我遇到了崩溃,数据消失了。这根本不是持久的。

所以,当我们想和最终用户交互时,我们需要更强的保证。如果我们想验证一个数据是否是持久的。这有点像文件系统里的fsync,如果你知道的话,它会把Linux的所有缓存同步地传播到文件系统,到存储设备。

好的,所以我们需要另一条指令,就是**PSYNC**。PSYNC就像PFENCE一样,它防止任何重排序。并且PSYNC确保了在下一条指令执行时,缓存行实际上已经被传播到了非易失性内存。如果愿意的话,可以说是在下一条指令生效时。

// 线程 1
if (!pokemon_ptr->comitted) {
    strcpy(pokemon_ptr->name, "Pikachu");
    pwb(pokemon_ptr->name);
    pfence();
    pokemon_ptr->committed = true;
    pwb(&pokemon_ptr->committed);
    psync();
    ok = true;
}

// 线程 2
while (!ok) {}
mfence();
sendmsg("ack" to user);

所以我只是把我的PSYNC替换了我的PFENCE。这里,我的代码就正确了。因为如果我发送了消息,就意味着ok等于true。如果ok等于true,就意味着我的PSYNC被执行了。我的缓存行被写入了持久内存。现在我有了数据是持久的保证。

好的,所以我们只需要三条新指令。这就足够了。我们可以用它们来在持久内存中构建系统。好的,在奔腾(Pentium)上,你怎么实现这个?你有的,所有这些指令在奔腾上都存在。PWB是用clwb(cache line write-back)实现的。这是奔腾多年来提供的一条指令。因为有时我们想手动管理我们的缓存行。说“我想传播这一个或那一个”非常有用。好的,对于PFENCEPFENCE是用sfence(store fence)实现的。因为奔腾里的sfence恰好定义了任何有存储效果的指令之间的顺序。而clwb就是一条有存储效果的指令。所以sfence已经防止了clwb和读、加载、存储指令之间的重排序。好的。而PSYNC也是用sfence实现的。我不知道这是否真的有效。我不知道。可能有效。网上可以找到一封很长的邮件讨论,Intel的工程师说,如果我们崩溃了并且调用了PWB,在这种情况下奔腾会有足够的剩余电量来传播这些缓存行。我希望这是真的。我不知道。我不知道。所以我们可以假设硬件是正确的。可能吧。我不知道。但它的工作方式,他们有一个理由。那就是clwb在指令执行时,实际上真的把数据发送到了总线上。所以一旦指令在总线上了,缓存行会很快到达持久内存的内存控制器。内存控制器只是接收数据并立即写入。所以我们可以假设我们会有足够的电力来在缓存行到达内存控制器时实际写入它。

实验环节介绍

好的,就这样。现在你们将要实现你们自己的持久内存系统了。我希望你们会喜欢。

所以,总结一下要点:持久内存就是内存,但它提供了持久性。你拥有几乎和易失性内存一样的性能,最终慢两倍。顺便说一句,持久内存的另一个用途是省电。这是一个有趣的用途。这里我把持久内存用作存储来实际存储数据。我们也可以用持久内存来节省能源,因为持久内存比易失性内存稍微慢一点,但消耗的能量更少。所以这是持久内存的另一个用途。

在Linux中,持久内存是通过文件系统抽象暴露出来的。所以用open, read, write, close,你可以访问它。你还有直接访问映射(direct access mapping),它允许你直接访问持久内存的内容。然后你需要三条新指令来在你使用持久内存时强制排序,它们是PWBPFENCEPSYNC

好的,就这样。我不知道现在几点了?哦,完美。你们将有两个多小时的时间来做实验。关于实验,我喜欢实验。我知道你们大多数时候都在搞理论,但有时候实现我们所做的东西是很酷的。要找到实验,很简单。你需要在网上搜索“CSC 5101”。第一个链接,我希望对这里的每个人来说都会是第一个链接。

实验讲解与解决方案

好的,那么我来……我不知道我有没有声音,有吗?好的,我不会再让你们等更长时间了,我来介绍一下解决方案,代码应该如何工作,以及最后一个问题中的bug是什么,那当然是最有趣的部分。你们在半小时内肯定没时间做到最后一个问题,但至少你们会看到难点在哪里。

为了给你们要实现的代码一个概览,基本的想法是,我们这里有持久内存,这里有硬盘,我们有两个文件。第一个文件是nvmm.data,第二个文件是out.data。我们想做的是,当我们要写东西时,我们把它写到持久内存里,在这个缓冲区里,然后我们有另一个线程,叫做清理线程(cleanup thread),它从持久内存中取出数据,并执行到硬盘上。所以基本的想法是,因为你先把数据写到持久内存里,这很快,但持久内存的大小是有限的。所以如果我们想要有大文件,我们只需要把数据从持久内存复制到磁盘,以获得一个非常大的空间。好的,这给了你们一个概览。

      head tail
       ↓    ↓
+----+----+----+----+----+----+----+
| e5 |    | e0 | e1 | e2 | e3 | e4 |
+----+----+----+----+----+----+----+

现在,为了实现这个,我们需要两个变量。一个……基本上,持久内存本身就是一个生产者-消费者模型。主线程在持久内存中生产数据,清理线程消费数据。所以最终它并不复杂。为了在持久内存中实现一个生产者-消费者,我们只需要两个变量来知道我们在哪里生产,在哪里消费。好的,tail用来给清理线程知道从哪里消费。所以清理线程会检查tail的位置,而生产者在head的位置生产。在代码中,我们做的是保留一个空位来识别日志何时已满。因为如果我们不保留这个空位,当head等于tail时,我们无法知道日志是满了还是完全空的。所以我们用它来……这里,我们知道日志满了,因为head就在tail的前面,所以我们不能再生产了。如果你在这里生产东西,你将无法区分满日志和空日志。

后面的图和代码需读者自行看 part 2 幻灯片。

它是如何工作的呢?清理线程使用tail,并会推进tail,应用这些操作。所以这里,清理线程取走E0和E1,把所有东西传播到磁盘,并推进tail。它应该就是这样工作的。好的?大家对原理都清楚吗?完美。

之后,主线程,也就是生产者,可以在日志中生产新的值。所以这里我们生产了一个新的写操作E6,我们把它加在这里。在这期间,清理线程会一个接一个地将写操作应用到磁盘上。所以这是一个环形缓冲区(circular buffer)。

所以我们有的算法……好的。我们有的主要算法是能工作的,但当日志满了的时候我们有一个问题。这和我们在缓存里看到的东西与持久内存里的东西不一致有关。所以这里,在易失性内存里,我指的是你在缓存里看到的数据。这是你应用程序的易失性状态。而这里是持久性状态,也就是实际被传播到持久内存的东西。

让我们从这个初始状态开始。我们的日志是满的。我们在持久内存中的tail指向E0。我们在处理器的易失性缓存中也有tail的副本,也指向E0。我们的head就在它后面。

现在,如果我们天真地实现这个算法,我们会得到什么?我们会让清理线程像这样传播E0和E1。

此时,在易失性内存中,也就是对于其他线程来说,tail指向了E2。但是持久内存中的状态仍然是旧的状态,是过时的状态。我们的tail仍然在这里。仅仅因为包含tail的缓存行还没有被传播到持久内存。好的?所以在这个步骤,生产者看到我们有空位,可以在持久内存中生产了。所以生产者可以在这里创建两个新条目,E6和E7,推进head。而在这个步骤,我们没有任何保证tail实际上在E2。所以,如果我们恰好在这个步骤崩溃了,我们就会得到一个不一致的世界观。因为当我们在崩溃后恢复时,我们会看到tail在E7(此处演讲者口误,应为head在E7,而tail仍是旧值E0),然后我们会尝试在E2, E3, E4之前应用E7。这意味着我们颠倒了写的顺序,这是个相当大的问题。想象一下你在这里写x = 1,在这里写x = 2。你期望最后的结果是x = 2。但如果你在x = 1之前应用了x = 2,你就会得到一个错误的状态。

这个问题阐释了我们在易失性内存中看到的和实际持久化的东西之间的问题。

为了解决这个问题,只用一个变量是没有办法的。因为无论什么方案,如果我用tail来同步我的线程,又用tail来知道最后写入的值,我不能把tail用于这两个目的。因为在某个点,我消费东西时必须推进tail,而一旦我推进了tail,其他线程就认为tail更新了。

为了解决这个问题,我们需要两个变量。一个用来同步线程,另一个用来保证持久性。我们必须把实现分成两部分。这正是我们可以用两个变量做到的。所以这里我们有一个易失性的tailv_tail),用来在易失性内存中同步生产者和消费者的线程。我们还有一个持久性的tailp_tail),它指示了日志中实际被传播到磁盘的是什么。

现在它是如何工作的呢?很简单。我们想要消费,所以我们有清理线程想要传播E0和E1。清理线程会做的是,它会推进这里的p_tail。但在此期间,易失性的tail仍然在旧的E0上。所以对于生产者来说,生产者看到你暂时没有任何空间可以生产。所以你必须等待。我们传播数据。这里我们可以用PSYNC原语来确保p_tail被传播到了持久内存。所以这里,用一个简单的PSYNC,你就能确保p_tail拥有正确的值。只有在这之后,你才能推进易失性的tail,以确保你不会在p_tail在持久内存中还落后时进行生产。所以现在我们推进v_tail,在这个步骤,我们有了可以生产的保证,因为持久内存中的p_tail已经传播了正确的值。

好的?你们看到问题所在了吗?酷。酷。

代码详解

我来展示一下代码,只是为了……我不知道我们能不能看清代码。好的,所以在这里的代码中,好的,所以你可以找到展示这个的代码。

重要的是,你有两个文件。NVMM文件就是你在持久内存中的缓冲区。这里是你将要应用写入的文件。要加载非易失性主内存数据结构,你使用getNVMM,这很直接。你只需要打开文件,如果它还不存在就创建它。然后你必须给它一个大小。大小就是NVMM的大小。然后把它映射到你的虚拟地址空间。不是很复杂。我只是用了这个奇怪的标志来适配代码,在有持久内存和在我们的电脑上用易失性内存时都能工作。

好的。然后我们要做的是,在崩溃后我们必须恢复。这意味着我们可能在NVMM的缓冲区中有待处理的写入。我们必须把它们应用到out文件。我稍后会介绍recover的代码,因为先看如何向NVMM添加元素、如何应用写入,然后再看如何恢复,会更容易些。这个顺序不是很直观。

然后我们必须为清理线程创建一个线程,它会和应用程序并行运行。之后就很简单了,我们只是通过调用myPivWrite而不是PivWrite(普通函数)来执行大量的写操作。好的。所以,是的。最后,我只是用这个stop变量告诉清理线程它该停止了。

好的。好的。所以,要向持久内存添加一个写操作,我们只需要……我们可以有多个生产者,因为我们是多线程应用。所以,这里的同步是在执行myPivWrite的线程之间。对于这些线程,它们只需要在日志中找到一个新条目。为此,它们必须推进head。它是如何工作的呢?我们只是加载head。然后我们将head与易失性tailv_tail)比较。就像我们这里做的。如果head + 1等于v_tail,这意味着我们在日志中没有足够的空间。所以我们不能添加新条目。在这种情况下,这是你在这里的第一个测试。所以,我们加载head,递增它,然后把新的索引和v_tail比较。如果它们相等,意味着我们没有空间,必须重试。我们主动尝试递增来找到一个新的……

好的。所以,现在我们确定我们可以生产数据了。所以我们尝试把下一个索引写入head变量。但我们可能会失败,因为我们可能有两个线程同时拿到了相同的索引。它们同时看到了相同的索引。只有第一个线程能够写入新值。第二个线程将不得不重试以找到新的空间。这只是在两个线程并行执行时的情况。我不知道我是否说清楚了。

这只是一个多线程应用试图在同一个缓冲区、同一个变量中递增数字时的问题。

好的。所以,现在我们能够推进head了。我们有了我们必须生产数据的索引。好的。在这行代码的末尾,你可以看到一个小小的_mm_pause。这只是Intel提供的一条指令,用来减慢处理器速度。因为当我们有这种循环时,一个问题是处理器会看到我们总是在失败,我们在循环。处理器的分支预测器会看到,“好的,每次我读next_index,我都得重试”。分支预测器会预测我们正在循环。奔腾内部的推测执行(speculative execution)会开始提前执行循环的多个步骤。当v_tail的值改变时,处理器将不得不中止所有提前进行的推测性读取。这会极大地减慢处理器在v_tail被另一个线程修改时的反应速度。所以,我们用_mm_pause来稍微减慢处理器,以确保我们在这里自旋循环时不会提前进行推测性读取。

好的。所以,现在我们,我们只是创建一个操作。创建操作不是很复杂。所以,这里我们有一个指向操作的指针。我们把想要写入的数据复制到操作中。buff就是你想要传播到磁盘的数据。所以,操作中有两个缓存行。一个缓存行里有你想要写入的数据。另一个缓存行里有与操作相关的元数据。所以,这里我们把第二个缓存行加入……我们把第二个缓存行的缓存行写回操作入队。然后我们指定,好的,我们想要访问文件fd,在偏移量off处,我们想写n个字节。我们写下这个。然后我们把这个缓存行加入到我们必须传播的缓存行FIFO队列中。然后我们有一个PFENCE。这个屏障非常重要,因为它确保了这个缓存行会在这条提交操作的写操作之前被传播。

是的。好的。这两个PWB的顺序在之前并不重要。我们可以传播这个缓存行或者那个。不是很重要,因为在最坏的情况下,如果在PFENCE之前崩溃,条目没有被提交。这意味着操作不存在。重要的是,如果committed等于true,我们希望这两个缓存行,这一个和这一个,在崩溃时都被写入了持久内存。

好的。所以,在屏障之后,我们就可以提交操作了。所以,这里,我们知道如果committed等于true,那么fd, n, offsetbuff都是正确的。因为它们在PFENCE之前。好的。然后,我们传播committed。所以,为什么我们有这个PWB和这个PFENCE,这只是为了下一个线程。我们在这里确保,当我们再次调用myPivWrite时,前一个操作在新的操作开始时已经被提交了。所以,这里,这只是为了循环。

好的。好的。所以,这只是一个在共享内存中的生产者,你有的。在易失性内存中。不是很复杂。不比那个更复杂。除了在正确顺序上传播缓存行的问题。

好的。所以,之后我们有清理线程。这里的代码稍微复杂一点。清理线程会尝试批量处理写操作。这意味着清理线程会检查缓冲区,尝试在一个批次中最多应用max_jobs个操作,然后推进tail。抱歉。

这只是为了避免对每个操作都调用fsync,那非常耗时。因为fsync确保了Linux缓存中的数据被传播到了硬盘。我们必须保证这一点,以确保数据从Linux页面缓存中传播出去。否则,我们不能忘记这个操作。我来快速说明一下。

好的。所以,清理线程只是通过从tail开始检查缓冲区。所以,这里。抱歉。我有易失性……啊。好的。所以,tail是持久性tailp_tail)。抱歉。好的。所以,清理线程在运行。它用p_tail来知道我是否有操作要传播。对于清理线程,我只取在p_tail处的那个操作,我只需要读取committed标志来知道我是否有操作要应用。这正是我们做的。

所以,我们取操作来加载committed。如果committedtrue,意味着操作被生产者验证了。所以这里我模拟了一个崩溃。如果我选一个数字,我就让所有东西崩溃。只是为了说明恢复是如何工作的,如果你想测试它的话。不是很重要。重要的是,在这里,如果我有什么要应用的,我有一个待处理的写操作。我调用Linux函数来实际把数据从持久缓冲区写入硬盘。但这里,pwrite只是把要写入的数据添加到了Linux的缓存里。数据还没有被传播到磁盘。好的。所以,在这个时候,我不能假设数据已经到达了硬盘。所以我还不能移动我的tail。我的tail在哪里?这里。我还不能移动它。

所以,就在之后,这里我调用fsync来确保在Linux页面缓存中的数据被传播到了磁盘。所以当这个函数返回时,它就像硬盘的PSYNC。语义完全相同。我们确定在调用fsync之后,数据被持久化到了磁盘。所以,既然数据在磁盘里了,我就可以移动,我就可以在持久内存中忘记这个操作了。它被应用到了磁盘。这正是我们在这里做的。

所以,在调用fsync之后,我可以把操作的committed写成false,为下一个生产者做准备。所以,然后我可以传播我的缓存行,并在推进持久内存中的tail之前执行一个PFENCE。所以,为什么我必须按这个顺序做?这是因为,如果我推进了tailcommitted仍然等于true,在崩溃的情况下,当系统恢复时,它可能会随机地发现一个未提交的操作。

所以,之后,抱歉,我有点慢。所以,现在,我们管理……这里我们只是传播,第一部分,我们只是把操作从缓冲区传播到磁盘。现在,我们还必须管理与生产者的同步。这正是我们在代码的第二部分做的,我们在这里推进持久内存中的tail。我们确保持久性tail被提交到了持久内存。只有在这之后,我们才递增易失性tail,从而在这个步骤解锁生产者。

所以,当然,如果我们不……我们必须停止,我们最后会停止。

好的。好的。好的。好的。所以,现在,要恢复,很简单。我们只需要检查操作缓冲区,并应用缓冲区中的每一个操作。所以,我们从tail开始。我们从tail开始。如果我们看到在tail处,操作是committed的,也就是这个测试。这意味着我们有一个待处理的操作。我们可以通过调用pwrite来应用它。我们对所有条目都这样做。最后,我们只需要从一个空的日志重新开始。这正是我们在这里做的。我们只是通过从第一个开始,擦除日志中的所有条目。就这样。

好的。我不知道你们有没有问题。

问答与总结

很清楚。

现在,你们可以看到的是,很快,我们只有一个生产者-消费者模型。这并不是什么真正复杂的东西,一个生产者-消费者。但最终,你们可以看到,代码有点像意大利面。最终非常复杂。所以,这意味着当我们想为持久内存写真确的代码时,这真的很棘手。因为我们必须理解CPU如何与缓存同步。然后缓存行如何传播到持久内存。系统如何执行对磁盘本身的写入。我们有不同的步骤,很快就一团糟了。我不知道你们是否被说服了,它最终是复杂的。

为了让这段代码正确,我们花了将近一个月的时间来确保它是正确的。当我们设计这个“助推器”(booster)时。仅仅为了几行代码。最终,我们有两个索引。推进它们,直觉上不那么复杂。而你们在这里没有……我们必须使用易失性tail和持久性tail的原因,以及识别这个bug,就花了我们好几天的时间才想到这个bug。所以,我不知道理论研究者是否能帮助我们设计工具来自动证明算法的正确性。这对我来说会非常有用。我必须说。

就这样。没问题?是的。我可以给你们看一些数字。没问题。

这里是我们在完整系统上进行的评估。所以,这里我们……在我展示的代码中,我们只有写操作。我们没有读操作。因为读操作更复杂,我们必须先检查日志,看是否有待处理的写入。然后我们才能读。所以,这里我们用……我不记得是哪个数据库了。写和什么?好的。RoxDB和SQLite。这里你看到的是粉色的SSD的性能,大约是300微秒,在图上大约是这里。而我们用这个“助推器”能达到的性能,在14微秒的级别。所以我们大约把延迟减少了4到5倍。

提问(听众): 在这个评估中,你们有多少线程?

回答: 在这个……在这个评估中,如果我没记错的话,我们只有一个线程。

提问(听众): 多少文件?

回答: 我不记得了。我知道……可能只有一个。嗯,是的……我想也是。但是你……你知道吗?这正是我们在这张图里看到的,我们有随时间变化的吞吐量。我们观察到的是,在某个点,硬盘的速度对于生产者来说太慢了。所以日志总是饱和的。

提问(听众): 你被卡在……的速度上了。

回答: 是的。最终,当我们有大量写密集型工作负载时,我们饱和了队列,清理线程被SSD的速度阻塞了。所以最终我们没有任何收益。对于一个非常密集的写工作负载。

提问(听众): …

回答: 是的。除了延迟不一样。吞吐量是好的。但问题是,当你传播操作时,你必须调用fsync来确保数据被传播了。这里延迟很重要。这不仅仅是吞吐量的问题。是的。是的。所以我明白你的意思……我明白你的意思……

提问(听众): (听不清)

回答: 我真的不知道在有许多文件的情况下,系统会如何表现。关于能力的问题。你可以为每个文件创建一个独立的……一个独立的缓冲区,你会得到同样的东西。事实上,据我理解,这不仅仅是硬件问题,也是Linux内部吞吐量和延迟的软件问题。C++是什么?这是将数据推送到硬件的好方法,没有任何我们现在在Linux内核内部有的限制。它直接通向系统存储内存。这就是我们能做的。你将避免CPU和易失性内存内部大量的处理时间。完全正确。所以你直接进行写操作。但现在你别无选择。你只能用Linux内核API,你只是在等待。你什么也做不了。你只是在等待。你只是在等待一个软件解决方案。这只是一个软件解决方案,如何直接写入磁盘而不错失数据,并且不使用PC的默认内核。完全正确。这是一种内核旁路(kernel bypass)。是的。

我想提一下……我不确定我是否……当然,当然。在练习中,我们直接存储了文件描述符,但这在崩溃后没有意义。所以在实际的代码中,我们在持久内存的开头有一个表,里面有我们在崩溃前使用的名字和文件描述符,以便找回文件的路径,当然。不,不,对于练习来说,这没有意义。我完全同意。

没问题?是时候去吃饭了。没问题?是时候去吃饭了。

提问(听众): 非常有趣地看到,因为直接访问,没有页错误(page fault)的概念。我理解得对吗,这意味着延迟……?

回答: 不完全是。我没有检查,但我认为当你mmap一个持久化文件时,Linux仍然使用按需分页(on-demand paging)机制。这意味着当你从mmap返回时,你只是在你的虚拟地址空间里有了预留。但可能Linux会在你第一次访问页面时才填充页表。所以,你还是会有一个页错误,然后你必须找到文件的页面在哪里并映射它。所以,我不知道它是否那么稳定。我得说,我没检查过。在最坏的情况下,你可以预填充虚拟地址空间。

提问(听众): 是的,它会产生一个瓶颈。

回答: 是的,当然。这正是我们在这条曲线中观察到的。一开始,我们表现很好,因为我们以持久内存的速度运行。在某个点,我们饱和了SSD,然后我们以SSD的速度运行。所以,当你只有少量写入,当写入数量不巨大时,它非常有用。在这种情况下,你赢了,因为你有时间去传播。但是,当然,如果你有一个写密集型应用,最终,瓶颈还是会是SSD。

提问(听众): 再问一下,你们在数据库上测试,而不是像拷贝一个10GB的电影文件那样测试,是因为……

回答: 是的,你需要知道。据我再次理解,这是一种针对某种实时数据的解决方案。完全正确。是的。你需要把它推回到某个持久性存储中。这将以非常有趣的方式适配数据库。因为现在,所有的数据库都基于某些操作系统的限制。我们在这里看到的,是一种可以帮助我们超越我们从默认……比如Linux内核中得到的限制的“助推器”。如果我们看Oracle,这在某些情况下可以帮助我们。因为我们可以用不同的方式来构建它,如何使用它。

是的,当然。我们用FTP做了一些测试。当然,如果我们拷贝非常大的文件,我们立刻就让SSD成为瓶颈,这完全没用。它在数据库这种没有那么多写入的场景下才有意义,那很有趣。

好的。我们可以结束这次会议了,正如Pierre所说。正如Pierre所说。谢谢。谢谢大家。谢谢。