教书育人对我来说一直是一项挑战。我倾向于跳过我错误地认为显而易见的事情,或者在解释问题的解决方案时在推理上有很大的飞跃。因此,当我试图解释一个完整的视角时,我倾向于漫无边际地闲聊,希望观众知道如果我讲得太快,什么时候应该打断我。然而,这并不适用于博客帖子,比如我目前正在写的那篇。这就是为什么我有一个要求,读者,请一定要告诉我,如果我在这个问题上走得太快了-谢谢!
那么,我为什么要写这篇看似跑题的序言呢?我一直在试着写一篇关于持久性内存编程的性能以及为什么它很困难的文章。我的第一个想法是,它真的不是这样的,你应该遵循你对正常程序所遵循的相同的老常识规则。毕竟它只是内存,但具有更高的访问延迟。但是,当我回顾libpmemobj(我们的内存分配器和事务系统)的发展历史,以及自第一个版本以来我们所做的性能改进时,我突然不那么确定了。
毕竟,如果为持久化内存编写高性能代码很容易,我们至少在第一次就会得到一些正确的结果。不是我们当时想的那么熟练,就是我们当时没有那么熟练。这对我来说肯定不是超出了可能的范围,但我的团队其他人呢?他们绝对是有素质的人。所以…。为了挽回面子,我们会假设我的第一直觉是错误的,毕竟制作性能持久记忆代码是很难的。
以下是我试图解释为什么会出现这种情况,以及我们是如何在开发libpmemobj的过程中艰难地获得这一点的。
但是,在我们深入了解有关性能的具体细节之前,我们首先需要定义什么是持久性内存。我希望做…很容易。
根据你问的是谁,你可能会得到不同的答案。我们通常使用的宽泛定义是,它是非易失性的内存,并且具有足够低的访问延迟,因此在等待读取或写入完成时停止CPU是合理的。
但是这个定义太宽泛了,如果我们只在它的限制范围内操作,那么关于性能的讨论就会相当肤浅,并且仅限于持久存储器的非易失性方面。回想起来,这是我们在最初设计PMDK的算法和数据结构时所犯的错误之一。稍后再讲。
一个更狭隘的答案是,永久存储器可以定义为一种新的存储器类别,最好的特征是Y公司的产品X。例如,如果您要问我,一个付费英特尔先令,这种永久存储器是什么,我的答案是:我可以让您对英特尔®的新革命性产品英特尔®Optane™DC Persistent Memory感兴趣吗?当然,我是开玩笑的,但我的观点是,专注于一类产品,如此类产品中最突出的例子所表征的那样,使我们能够拥有多种
这就是我们最终得出这篇帖子标题中的数字的原因。英特尔新的永久存储设备的一个更重要的特点是它的平均访问延迟。300纳秒。
但在我们把它放在上下文中之前,这个数字本身并没有多大意义。
那么,300纳秒的延迟快吗?对于存储来说,它绝对是如此,从字面上讲,它比任何其他技术都要快几个数量级。但是为了记忆呢?不完全是。它的速度绝对足够快,可以被认为是内存,但就数据结构设计而言,它也不够快,不能像普通的DRAM那样对待。特别是当我们考虑持久存储器编程模型的更广泛的方面时。
持久记忆就像光一样,“我们有两幅相互矛盾的现实图景”,它不能简单地用记忆或存储来描述,因为这两个术语都不能完全解释这个新的层次。
就像存储一样,它可以通过正常的文件I/O操作(如read()或write())进行访问,并且就像内存一样,可以通过内存映射I/O在字节级别直接访问它,而不需要介入页面缓存层。
而且,就像使用存储一样,应用程序需要以某种方式将其写入PMEM的内容与介质同步,就像您发出fsync()或msync()来确保I/O一直到达存储设备一样。事实上,这两个调用也确实可以实现持久内存-但还有更好的方法。
让我们先倒回一点。我之前告诉过您,永久内存是非易失性的,您可以直接写入。那么,我们究竟为什么需要费心同步I/O呢?嗯,出于同样的原因,我们对常规存储设备也是这样做的。从应用程序到DIMM的整个存储过程中都有各种缓存和缓冲区。最重要的是,这里有CPU缓存。
当一个商店到达平台的持久域时,我们认为它是持久的。驻留在持久域中的组件中的所有存储都能确保到达DIMM,即使在出现故障的情况下也是如此,除非出现一些灾难性的硬件问题。
简而言之,在通常情况下,这意味着应用程序需要将存储从CPU缓存中清除出来,然后才能将其视为持久化。您可以使用mSync()来做这件事,内核会做正确的事情,但是您也可以使用用户空间指令直接刷新CPU缓存,这有两个原因:a)不需要昂贵的syscall;b)可以使用缓存行粒度(而不是页面粒度)刷新数据。
哦,在x86-64上,缓存线是64字节,这意味着小于64字节的存储在写入DIMM时会引起一些写入放大。
总而言之,持久性内存确实是非易失性的,但是需要将存储从CPU缓存中清除出来,理想情况下是在单个缓存线上使用用户空间指令。
但是…。(似乎总是有一个BUT)缓存刷新指令将行从缓存中逐出。这意味着在写入后立即读取某些内容会导致缓存未命中-需要CPU从DIMM获取数据。而且,这还不是全部。即使在刷新后在同一高速缓存线中再次写入某些内容,通常也会导致缓存未命中,从而使存储成本翻倍。
除非您想创建一些真正持久的数据结构,否则所有这些都无关紧要。我所说的持久性指的是寿命比创建它的进程的寿命更长的数据结构。但是,该定义也将包括在进程退出时序列化的数据结构,这一众所周知的方法不在本文的…讨论范围之内。因此,让我们将焦点缩小到比进程寿命更长且即使出现故障也始终保持一致的数据结构。我们通常说这样的数据结构是失效原子的。
这听起来与并发(原子)数据结构惊人地相似,不是吗?只需将“进程”替换为“线程”,将“失败”替换为“抢占”。这一观察结果是学术界和产业界围绕持久记忆的许多想法的基础。为了提出高效的算法,我们,PMDK团队,一直在大量利用为并发编程所做的大量工作。
foo->;bar=10;.FETCH_AND_ADD(&;foo->;bar,5);/*Visible=15,Persistent on DIMM=?(10或15)*/Persistent(&;Foo->;bar);/*Visible=15,Persistent on DIMM=15*/。
直接使用并发数据结构来实现故障原子性的一个问题是可见性和持久性之间的区别,并发数据结构必须保证所有执行线程始终保持一致的状态。但是,持久化进程还必须确保在允许其他进程或线程基于结构的状态做出任何决策之前,数据存在于持久域中。正确地执行此操作对于性能至关重要。
每次将元素插入到链表中时,都需要:
要以并发的方式做到这一点,我们可以简单地用某种类型的锁来包围这些操作,以防止其他线程在插入过程中访问列表。对于喜欢冒险的人来说,编写一个无锁算法也是一种选择,因为它可能会有更好的伸缩性。
然而,让它成为原子故障需要我们回答几个基本问题。分配内存意味着什么呢?堆本身需要持久化。当程序中断时,我们必须确保分配的对象不会泄漏。堆毕竟是持久的。接下来,我们将如何以故障保护原子的方式对多个互不相交的内存位置进行更改?这不是在我们修改数据结构时阻止其他进程查看它那么简单。我们的应用程序的执行环境随时可能被残忍地杀死,迫使附加到同一持久内存区域的下一个进程以某种方式处理中断的操作。最后,我们必须问问自己,这是否真的是我们想要做的?创建持久的双向链表是目标吗?根据我的经验,创建数据结构或算法只是达到目的的一种手段。一旦我们稍微改变了我们对记忆的假设,重新考虑我们最初只使用我们所知道的东西的本能可能会更有意义。
回想起来,对于libpmemobj,我们错误地回答了前两个问题,甚至没有问到第三个问题。
有了这个,我将让您思考,直到假期结束:)。
在这篇文章中,我们了解了什么是持久内存以及如何使用它,我们还讨论了持久数据结构意味着什么,以及这可能如何影响性能。
在下一部分,我们将更深入地研究我对我提出的三个问题的回答,以及我们是如何基于重新考虑了关于持久记忆的最初假设后进行的自我反思来改进我们的库的。