RFC-0269:衡量内存暂停

RFC-0269:衡量内存停滞
状态已接受
区域
  • 内核
说明

检测内存停滞并向用户空间报告

问题
Gerrit 更改
作者
审核人
提交日期(年-月-日)2024-10-15
审核日期(年-月-日)2025-04-10

问题陈述

Zircon 目前不会公开有关内存压力导致的瞬时失速的信息,而 Starnix 需要这些信息才能实现 PSI 接口 (/proc/pressure/memory)。

此 RFC 解决了以下问题:合成兼容的测量结果,并在超出给定阈值时通知观测者。

摘要

本文档提出了一种检测由内存压力引起的停滞并通知用户空间的机制。该机制将基于对压力诱发活动所花费的时间进行衡量,这些时间本可以用于完成有用的工作。

设计初衷

基于 Linux 的操作系统(包括 Androidsystemd)依赖于指定的用户空间守护程序来接收通知并处理由内存压力引起的延迟(“停滞”)。此类程序执行的操作通常只是终止整个进程/服务作为响应。

在 Starnix 的背景下,其目标是按原样实现 Linux API (RFC-0082),我们需要提供相同的停滞检测 API,该 API 在 Linux 上称为压力停滞信息 (PSI)

Linux 的 PSI 通过测量释放内存所需的额外操作所造成的延迟来量化压力引起的内存失速,并且可以通过 /proc/pressure/memory 虚拟文件访问这些统计信息。此 RFC 建议采用与 Linux 类似的方法来测量压力引起的延迟。

本文档仅重点介绍 Starnix 以及如何实现 Linux 的 PSI 接口以达到功能对等。其他 Fuchsia 组件(Starnix 之外)是否也能使用它,不在本文档的讨论范围内,留待未来阶段再行讨论。

利益相关方

教员:lindkvist@google.com

审核者

  • adanis@google.com
  • eieio@google.com
  • maniscalco@google.com
  • rashaeqbal@google.com

已咨询

  • davemoore@google.com
  • lindkvist@google.com
  • wez@google.com

共同化

此 RFC 已通过电子邮件、聊天和会议与 Zircon 团队讨论过。

要求

鉴于 Starnix 的重点,要求由将提供给 Linux 程序的接口驱动。

在 Linux 上,读取 /proc/pressure/memory 文件会返回以下信息:

  • 自启动以来的两个单调递增的计时器,分别标记为 somefull(单位为微秒):根据官方文档,当至少一个线程因内存不足而暂停时,some 时间会增加;当所有其他可运行的线程因内存不足而暂停时,full 时间会增加。请注意,即使某些其他线程能够取得进展,也可能会出现 some 条件,而 full 条件则表示没有任何线程取得进展。因此,some 时间始终大于或等于 full 时间。
  • 上述两个计时器在过去 10 秒、60 秒和 300 秒内的增长率(以单调时钟的变化率为比率,因此值范围介于 0.00 和 1.00 之间)的平均值。

除了读取这些统计信息之外,Linux 还允许您通过打开该文件、写入要观察的计时器(somefull)、阈值和观测窗口(均以微秒为单位)来在达到特定停滞级别时收到通知,然后轮询该文件描述符。然后,当所选计时器在配置的观测窗口内增加的量超过给定的阈值时,轮询将返回 POLLPRI 事件。

值得注意的是,在 Linux 上,还可以通过 memory.pressure 文件在 cgroup 级启用相同的测量和通知机制,该文件提供相同的接口。虽然此 RFC 中提出的设计目前仅涉及实现全局 /proc/pressure/memory 文件,但如果需要,此 RFC 中描述的内部衡量机制也可以重复使用,以实现类似的功能(如需了解详情,请参阅缺点、替代方案和未知问题)。

为了尽可能减少所测内存停顿的幅度与 Linux 之间的差异,我们打算先将相同的公式应用于 Zircon,并将进一步的调整/偏差留到以后的调优阶段。下一部分详细介绍了针对 Zircon 提出的这些公式的改编。

设计

此 RFC 的目标是精确定义 Zircon 中的停滞概念、停滞测量结果的生成和汇总方式、如何将此数据公开给用户空间,以及如何在系统停滞超过给定级别时通知用户空间。

简而言之,Zircon 将测量内存停滞,对其进行汇总,并公开两个系统级值(作为新的 zx_object_get_info 主题)和一个通知接口(通过新的系统调用),两者都由新的资源类型控制。这两个新值(称为 somefull 停滞时间)将以纳秒为单位表示,并且会以单调时钟速率的一部分(介于 0 到 1 之间)单调且连续地增长,该速率取决于当前的停滞级别。

举例来说,假设系统在运行 300, 000 纳秒后未遇到任何停滞,然后检测到 100, 000 纳秒的 25% 停滞,接着又检测到 200, 000 纳秒的 50% 停滞。相应报告的值将为 0% * 300000 + 25% * 100000 + 50% * 200000 = 125000 纳秒。

识别内存停滞

原则上,由内存压力引起的任何延迟(如果该延迟导致线程无法执行有用的工作)都应视为该线程的停滞。

实际上,鉴于当前的代码库,只有在等待专用内核线程释放内存时花费的时间(或者换句话说,在调用堆栈上存在 AnonymousPageRequester::WaitOnRequest 时花费的时间)才会被视为内存停滞。当尝试分配新内存时,可用页面数低于延迟阈值(接近内存不足级别,计算方式见此处)时,就会发生这种情况。

上述操作涉及通过许多不同的调度程序状态跟踪阻塞线程。此外,停滞跟踪机制旨在实现足够的通用性,以便将来轻松将其他代码部分标记为内存停滞。为了避免因添加新的临时状态而使当前调度程序变得更加复杂,此 RFC 建议使用保护对象 (ScopedMemoryStall) 来界定要视为停滞的代码段,并根据线程的当前调度程序状态以及它们是否在受保护的区域中执行,将线程分类为 IGNOREDPROGRESSINGSTALLING,以便跟踪停滞情况。

| 调度器状态 | 如果在保护范围内 | 如果在保护范围外 | |-|-|-| | INITIAL | 不适用 | IGNORED | | READY | STALLING | IGNORED | | RUNNING | STALLING | PROGRESSING | | BLOCKEDTHREAD_BLOCKED_READ_LOCK | STALLING | IGNORED | | SLEEPING | STALLING | IGNORED | | SUSPENDED | STALLING | IGNORED | | DEATH | 不适用 | IGNORED |

为了消除内核中因内部内存簿记活动而导致的测量噪声,非用户模式线程将始终被视为 IGNORED

值得注意的是,在上述模型中,如果线程正在保护范围内运行,或者在阻塞或被抢占之前曾在保护范围内运行,则会被视为 STALLING

somefull 计时器的增长率

停滞计时器不会仅仅是每个线程的 STALLING 时间之和。相反,它们将是持续评估 STALLINGPROGRESSING 线程总数(在每个 CPU 上本地执行)的结果,然后定期进行汇总。这与此参考资料中记录的 Linux 模型相符。在内部,将向 Zircon 添加两个级别的衡量指标。

第一级是每个 CPU 的本地级,由每个 CPU 的 StallAccumulator 对象跟踪,将使用单调时钟作为时间基准来衡量在以下三种非互斥条件中的每种条件下花费的时间:

  • full:至少有一个与此 CPU 绑定的线程处于 STALLING 状态,且没有线程处于 PROGRESSING 状态。
  • some:至少有一个与此 CPU 关联的线程处于 STALLING 状态。
  • active:至少有一个 PROGRESSINGSTALLING 线程与此 CPU 相关联。

StallAggregator singleton 实现的第二级衡量标准将通过定期查询所有 StallAccumulator 来更新系统级统计信息。具体而言,它将运行一个后台线程,该线程会定期唤醒,并根据每个 StallAccumulator 观测到的 somefull 停滞时间增量(按各自的 active 时间加权)计算平均值,然后相应地增加全局 somefull 值。

此外,当结果增长率超过注册观看者设置的阈值时,StallAggregator 会通知这些观看者(有关详情,请参阅新的 zx_system_watch_memory_stall 系统调用)。

Stall 资源

将引入一种名为 ZX_RSRC_SYSTEM_STALL_BASE 的新型内核资源。

由于此 RFC 仅关注衡量全局停滞级别(相当于 /proc/pressure/memory),因此使用单个资源来控制对全局停滞测量的访问权限就足够了。

此资源的句柄将在启动时创建,并通过 fuchsia.kernel.StallResource 协议(作为内置功能)由组件管理器提供,该协议只会返回重复的句柄。 控制此功能路由到的对象将控制哪些人有权访问停滞测量数据。

新主题 ZX_INFO_MEMORY_STALL

系统将添加一个新的 ZX_INFO_MEMORY_STALL 主题(在摊位资源上),公开以下两个字段:

typedef struct zx_info_memory_stall_t {
    // Total monotonic time spent with at least one memory-stalled thread.
    zx_duration_mono_t stalled_time_some;

    // Total monotonic time spent with all threads memory-stalled.
    zx_duration_mono_t stalled_time_full;
} zx_info_memory_stall_t;

Starnix 将查询此主题以合成 /proc/pressure/memory 文件。此外,Starnix 还会定期对其进行抽样,以在内部计算过去 10 秒、60 秒和 300 秒的滚动平均值。

新的 zx_system_watch_memory_stall 系统调用

与现有的 zx_system_get_event 系统调用类似,新的 zx_system_watch_memory_stall 系统调用也会返回一个事件句柄,内核将根据所选停滞计时器的当前增长率来断言/取消断言 ZX_EVENT_SIGNALED

完整的系统调用原型如下所示:

zx_status_t zx_system_watch_memory_stall(zx_handle_t stall_resource,
                                         zx_system_memory_stall_type_t kind,
                                         zx_duration_mono_t threshold,
                                         zx_duration_mono_t window,
                                         zx_handle_t* event);

参数:

  • stall_resource:阻塞资源的句柄。
  • kindZX_SYSTEM_MEMORY_STALL_SOMEZX_SYSTEM_MEMORY_STALL_FULL,用于选择要观测的计时器。
  • threshold:触发信号的最小停滞时间(以纳秒为单位)。
  • window:观测窗口的持续时间(以纳秒为单位)。
  • event:由内核返回时填充,指向一个事件的句柄,如果在上次观测 window 期间,由 kind 选择的停滞计时器增加了至少 threshold,则该事件将被断言 (ZX_EVENT_SIGNALED)。

只要触发条件适用,返回的事件就会保持断言状态,当触发条件不再适用时,内核会取消断言。

可能出现的错误:

  • ZX_ERR_BAD_HANDLEstall_resource 不是有效的句柄。
  • ZX_ERR_WRONG_TYPEstall_resource 的种类不是 ZX_RSRC_KIND_SYSTEM
  • ZX_ERR_OUT_OF_RANGEstall_resource 不是阻塞资源。
  • ZX_ERR_INVALID_ARGSkindthresholdwindow 无效(请参阅下方的注释)。

调用者的作业政策必须允许 ZX_POL_NEW_EVENT

请注意,即使 API 以纳秒为单位定义了 thresholdwindow,Zircon 也可以不使用所请求值的完整精度。此外,还必须满足 0 < threshold <= window,为了限制所需的簿记内存量,我们还规定 window 不得超过 10 秒(Linux 也施加了相同的限制)。

实现

Zircon 变更将拆分为多个 CL,大致按以下顺序进行:

  • 添加了在每个 CPU 级别检测停滞的基础设施 (StallAccumulator)。
  • AnonymousPageRequest 中花费的阻塞时间计为内存停滞。
  • 在低优先级的内核线程中,定期将所有单 CPU 值汇总到全局指标 (StallAggregator) 中。
  • 将新的 stall 资源作为组件管理器内置功能 (fuchsia.kernel.StallResource) 提供。
  • 实现新的 ZX_INFO_MEMORY_STALL 主题。
  • 添加了用于检测何时超过停滞阈值的基础设施。
  • 通过 zx_system_watch_memory_stall 系统调用公开它。

在 Zircon 更改完成后,将修改 Starnix 以在这些更改的基础上实现 /proc/pressure/memory,如下所示:

  • somefull 值将直接从 ZX_INFO_MEMORY_STALL 中读取。
  • Starnix 将通过每秒(大约)轮询一次 ZX_INFO_MEMORY_STALL 来计算平均值,并将最近 300 个样本保存在循环队列中,然后从中计算滚动平均值。
  • PSI 通知将基于 zx_system_watch_memory_stall 实现,并添加速率限制器(在 Starnix 本身中实现),以匹配 Linux 的 PSI 行为,即每个窗口最多只传递一个通知。

性能

如前几部分所述,提议的实现将在每个 CPU 的数据结构中维护停滞测量结果,并定期从专用内核线程中聚合这些结果。性能测试未发现因停滞时间簿记而导致任何明显的回归。

如果订阅的 zx_system_watch_memory_stall 观察者过多,当超过停滞阈值时,Zircon 内核需要维护并通知这些观察者,这可能会带来性能风险。如有必要,可以通过以下方式缓解此问题:通过专用用户空间组件代理访问,该组件将通过单个 Zircon 订阅聚合并服务多个客户端。与 Zircon 类似(请参阅上文中的新的 zx_system_watch_memory_stall 系统调用),此类代理可以降低所请求值的精度,以最大限度地提高聚合机会。

工效学设计

拟议的 Zircon API 使在 Starnix 中实现 PSI 几乎完全简单明了,但 Linux 对通知的速率限制除外。从理论上讲,我们也可以在 Zircon 内核中实现速率限制,并最终获得类似于 RFC-0237 中描述的频闪信号方案。不过,为了更轻松地描述 zx_system_watch_memory_stall 的行为,并与现有的 zx_system_get_event 信号保持一致,我们选择仅公开一个简单的电平触发信号,并将通知速率限制的复杂性委托给用户空间。

向后兼容性

此 RFC 仅引入了新的接口(一种新的资源类型,即 ZX_INFO_MEMORY_STALL 主题和 zx_system_watch_memory_stall 系统调用),不会带来任何重大 API/ABI 更改。

安全注意事项

向用户空间授予对新效果衡量指标的访问权限,可能会导致创建不必要的边信道。不过,在这种情况下,由于只有受信任的 Fuchsia 组件或 Starnix 内的受信任 Linux 进程(根据 Starnix 容器的安全模型)才能访问新测量数据,因此风险得以缓解。

通过以下方式,消除了无限制的内核内存分配风险:允许内核自由降低所请求的阈值和窗口值的精度(从而实现统计信息的内部量化),并限制允许的值的范围。

隐私注意事项

此项变更应该不会对隐私权产生影响。

测试

我们将添加两种 Zircon 测试:

  • 用户空间测试 (core-tests),用于监视内存停滞事件、生成内存压力、查询信息主题并验证事件是否实际触发。
  • 内核内单元测试,用于模拟 ScopedMemoryStall 保护机制的效果,并验证测得的时序是否符合预期。

文档

文档将添加到相关的系统调用界面(新的 ZX_INFO_MEMORY_STALL 主题和新的 zx_system_watch_memory_stall 系统调用)以及 fuchsia.kernel.StallResource FIDL 协议旁边。

被视为停滞的条件列表将视为实现细节,不会包含在文档中,以便在不破坏 API 的情况下进一步扩展和调整此类条件。

缺点、替代方案和未知因素

系统调用 surface

此 RFC 中的设计与在 Starnix 中实现全局 PSI 密切相关,并且系统调用 API 是根据其要求建模的。

我们曾考虑通过新的“stall gauge”内核对象类型(而非新的资源类型)来公开新功能,以便日后更轻松地实现 cgroups 的 memory.pressure 分层统计信息。不过,鉴于目前不需要此类扩展,我们决定保持 API 变更的简单性,避免过度规划。

除了让 zx_system_watch_memory_stall 系统调用返回事件实例之外,还考虑过返回具有专用信号(例如 ZX_STALL_MONITOR_ABOVE_THRESHOLD)的新型专用“停滞监控器”对象类型。虽然这种方法在意图和与内部内核实现的对应关系方面更加明确,但除了此原因之外,它还需要一种新的内核对象类型。之所以被拒绝,是因为现有事件对象类型的 API 已被认为足够富有表现力。

最后,许多新的 API 名称都使用“stall”而非“memory stall”,以便将来能够扩展到其他类型的 stall 测量(例如 CPU 和 I/O stall)。

停滞衡量和汇总

在提议的衡量方案中,讨论了是否将等待其他阻塞线程所持资源的线程也视为阻塞线程(“阻塞继承”),但至少在初始实现中,由于额外的复杂性以及缺乏评估其益处的基准数据,该选项被拒绝。

最初计划将解压缩 ZRAM 时花费的时间也视为内存停滞,作为抖动(由于尝试重新访问过去已压缩的匿名页面)的标志。不过,由于以下因素,这种事件最多只能发出延迟的指示,最坏情况下则会发出虚假通知:1) Zircon 会在后台主动压缩非活跃页面,即使有充足的可用内存也是如此;2) 只有在再次需要页面时才会进行解压缩。此外,实验发现,源自 ZRAM 解压缩的停滞时间贡献目前比源自 AnonymousPageRequester::WaitOnRequest 的停滞时间贡献低几个数量级。出于上述原因,我们决定暂时不将 ZRAM 解压缩视为内存停滞,并在初始实现中将其排除在外。

我们还考虑了另一种聚合方案,其中内核通过无锁数据结构中的共享内存为每个线程公开原始的阻塞时间计数器。然后,用户空间会定期使用特定于产品的逻辑执行聚合,从而将聚合政策与内核 API 分离。这种方法的缺点是,由于其抽样性质,精度会降低;设计更复杂,依赖于共享内存,很容易在细微之处出错;需要更频繁地激活用户空间。

我们还考虑了一种更简单的衡量阻塞的方法:仅衡量驱逐器运行所花费的时间。不过,这种方法既无法区分 somefull 停滞时间,也无法确定受停滞影响的进程。

与现有内存压力事件的关系

Zircon 已经通过五个互斥的事件(正常、警告、严重、即将内存不足和内存不足)公开了有关当前内存压力级别的信息,这些事件由内核断言,并且可以通过 zx_system_get_event 系统调用获取其句柄。虽然 Zircon 的 API 契约中并未明确说明触发这些事件的确切条件,但在实践中,Zircon 中实现的触发条件是将当前可用内存量与五个相应的范围之一进行匹配。

除了此 RFC 提出的对停滞时长进行计时的建议之外,我们还评估了从现有压力事件合成虚假延迟的选项。不过,该提案已被拒绝,理由是 Linux 的 PSI 信号的预期动态非常不同:Zircon 现有压力事件之间的转换速度较慢(在某些情况下,为了避免过于频繁的变化,还会受到人为延迟的影响),而 Linux 的 PSI 计时器预计会近乎实时地更新。PSI 计时器的增长率需要足够快,以便在超过相应阈值时立即生成脉冲来触发注册的监听器,然后在回落到阈值以下时立即停止触发。

此外,借助 Linux 的 PSI 计时器,空闲系统的增长率应为零。但对于当前的 Zircon 事件,无法保证用户空间被要求采取的响应措施最终会释放足够的内存,从而使系统恢复到“正常”状态。

总结

对 Android 映像进行的初步测试表明,按照此 RFC 中的描述测量停滞所生成的通知事件与 Linux 上相应工作负载所生成的通知事件相当。不过,由于 Zircon 和 Linux 是两个具有截然不同的 VMM 子系统的不同内核,因此并非所有极端情况都能保证这一点,未来可能需要进行改进,例如通过扩展/修改 Zircon 认为的停滞情况的定义。

在先技术和参考资料