LWN:多代LRU方案的未来!

Multi-generational LRU: the next generation

By Jonathan Corbet
May 24, 2021
DeepL assisted translation
https://lwn.net/Articles/856931/

multi-generational LRU patch set 对内核的内存管理子系统来说是一次重大改造,它应该可以给一些场景提供更好的性能。LWN 在 4 月份时曾报道过。在那之后,开发人员 Yu Zhao 已经发布了该工作的两个新版本,其中第三版于 5 月 20 日发布。自从最初的介绍文章发布之后,已经有了一些重大的变化,所以有必要再介绍一下。

先来快速复习一下:目前的内核里维护有两个 LRU 列表来跟踪内存 page,分别名为 "active" 和 "inactive" list。前者包含那些被判定为正在使用的 page,而后者包含的则是被认为未在使用的、可用于其他用途的 page。经常需要做不少工作来决定应该在什么时候在这两个 list 之间移动 page。multi-generational LRU 将这个原本两代的架构扩展为多代,也就是说 page 还可以处于 "likely to be active" 和 "likely to be unused" 这些状态。当一个 page 被访问时,它们会从较老的一代 list 移动到较新的一代,当需要回收内存时,会先从最古老的一代来开始回收 page。每一代的 page 都会随着时间的推移而变得老化,当最老的一代中的 page 都被回收掉了的时候,就会创造出新的一代。

这个总结里简化了很多细节,可以参考之前 LWN 的文章来得到更详细的信息。

Multi-tier, multi-generational LRU

自这项工作的第一次发布以来最大的变动是在 "tier" (层)的定义上,tier 这个词现在被用来细分各代 page,这反过来又可以帮助更好地决定哪些 page 需要回收(reclaim),特别是在那些需要使用大量 buffered I/O 的系统中。具体来说,tier 是一种采用访问频率来对每一代中的 page 进行分类的方式,不过它只关注那些通过文件描述符方式进行的访问。如果某个进程会通过文件描述符来访问该 page,此 page 的使用计数就会增加,于是就会把它移到第 1 层。后续的访问则会将该 page 推到更高的 tier(层);实际的 tier 级数就是对使用次数的的 base-2 对数值。

在研究应该如何利用这些 tier 信息之前,可以先讨论一下为什么要这样管理它们。为什么只计算基于文件描述符的访问操作?在补丁集或讨论中从未提及到一个可能理由(并且似乎是合理的理由):通过文件描述符的访问都是系统调用来的,并且相对容易地计算出来,开销很小。如果要跟踪 CPU 对内存的直接访问的话,成本会比较高,因此无法采用类似的粒度来进行监控。

另一个原因是,这种机制使得通过 I/O 引入的那些 page 的老化方式发生了一些改变。在目前的内核中,被放入内存的 page(例如 read() 调用得到的内容),一开始就会被添加到 inactive list 中。这种做法是有道理的,因为这个 page 大多数情况下不会再被使用了。但是,如果又有了一次对该 page 的访问,那么它就会被放到 active list 中,内核就会尽量避免回收它。这种机制比很久以前的机制要好,但是对于做大量 I/O 的进程来说,仍然有可能会把有用的 page 从 active list 中冲掉,从而损害系统的性能。

要做得更好的话,需要利用内核中已有的 shadow-page 跟踪机制。当 page 被回收用于其他用途时,内核会在接下来的一段时间内记着这些 page 所包含的内容,以及旧的内容是什么时候被抛弃掉的。如果这些 page 中有一个在不久的将来就被再次访问了,那么就需要从二级存储中把它提取回来,内核会注意到这个 "refault" 动作,这就是一个信号表明正在使用的 page 被回收掉了。一般来说,refault 通常都意味着 "thrashing" (频繁来回搬移),这并不是一件好事。内核如果发现了 refault 比较过量,就会做出一些调整,例如,增大 active list。

multi-generational LRU 的方案里面调整了 shadow 功能的相关记录项,于是就可以记录一个 page 在被回收时处于哪个 tier。如果这个 page 后来发生了 refault,那么它可以被恢复到之前的 tier 级别,同时这些 refault 时间也会被统计并记录到这个 tier 级别有关的数据中。这样就可以算出每个 tier 级别的 refault 比率,也就是说,从该 tier 回收的 page 中,有多大比例的 page 随后就因为 refault 而又放回了内存中?似乎很明显,在较高 tier 层级(意味着访问更加频繁)的 page 上发生的 refault 一般来说更加需要避免。

是通过比较更高一个 tier 层级的 refault 比率和 tier 0 层的 refault 率,就可以评估 refault 情况了。这里的 tier 0 层包含的是那些被 CPU 直接访问的 page 以及根本没有被访问过的 page。如果较高 tier 层级的 refault 比率高于 tier 0 层级的 refault 率,那么这些 tier 层级的 page 就会被移到较年轻的一代,从而(暂时)避免被回收。这样做了之后,事实上就把回收的重点放在那些较少发生 refault 的 page 类型上了。

还有一点需要注意,内存管理代码不会再像当前内核那样,每当再次进行基于文件描述符的访问的时候就自动将 page 提升一个级别。取而代之的是,那些由 I/O 产生的 page 会停留在最古老的一代,除非它们由于被访问到从而移到了一个比那些直接访问(directly accessed)的 page 更容易发生 refault 的 tier。正如 Zhao 在他的一段很长的解释信息中所说,这样做的效果就是防止这些 page 把那些使用量更大的 directly accessed page 从内存中挤出去。这应该会在那些有大量 buffered I/O 操作的系统带来更好的性能表现。Jens Axboe 也说确实有帮助。

与第一个版本相比,还有一个变化是增加了一个用户空间的开关,用来强制把一个或多个 generation 的 page 给回收掉。这个功能的目的似乎是为了让 job controller 能够为今后要做的工作预先腾出一些空间来,相关的文档 patch 里包含了更多的信息。

Multiple patch generations

multi-generational LRU 工作看起来还是很有价值,它已经收获了许多人的关注。不过,它进入 mainline 内核的道路看起来仍然漫长而艰难。Johannes Weiner 提出了一个在第一篇文章中也提到的问题:现在实现的 multi-generation LRU 与现有的内存管理代码是一个并列的选项,也就是给了内核两种 page reclaim 回收机制。根据 Weiner 所说的原因,这会很难得到推广:

不可能同时维护这两个方案,既不利于集中开发资源,也不利于集中测试资源,并且两个系统要想都能实现得比较稳定的话,会需要花费很多精力来维护一个复杂的共享代码 base。

因此,新的代码必须要能取代现有的方案,而这就是一个很高的要求了。必须要能证明它对于任何工作场景都要能有更好的表现(或者说,至少不能差),并且要能证明它足够可靠,可以用来替换已经有了 "数十亿小时的生产环境中的测试和调优" 的代码。做到这一点的唯一方法是将这个改动分为一系列更小的、逐步进化的改动来合并到 Linux kernel。因此,multi-generation LRU patch set 必须被分解成一系列的改动,其中任何一个改动都不可以过大,导致内存管理的开发者们认为合入它们不是一个安全的选择。

多年来,内核已经通过这种方式吸收了大量的改动,但这个过程无法快速且简单地完成。Weiner 提出了几个可以重点关注的领域,作为开始将这项工作中的一部分来推向 upstream,并使其余部分更容易被考虑合入的一种方式。如果遵循这个建议的话,可能可以在相对较短的时间内合并 multi-generation LRU。但是,这也意味着这个功能整体来说可能还需要经过更多的版本之后,才能全部进入 mainline 内核。

你可能感兴趣的:(内存子系统,linux,服务器,Linux内核,内存)