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,在 Linux 上称为 Pressure Stall Information (PSI)

Linux 的 PSI 通过对释放内存所需的额外操作造成的延迟进行计时,量化压力导致的内存失速,并且可以通过 /proc/pressure/memory 虚拟文件访问这些统计信息。此 RFC 提出了与 Linux 类似的测量压力诱导延迟的方法。

本文档仅关注 Starnix 以及如何实现 Linux 的 PSI 接口以实现功能同等性。除了 Starnix 之外,其他 Fuchsia 组件是否也能使用它,超出了该阶段的范围,将留待未来阶段进行探索。

利益相关方

协调员:lindkvist@google.com

审核者

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

已咨询

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

社交

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

要求

鉴于我们侧重于 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 中将添加两级测量。

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

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

第二级衡量(由 StallAggregator 单例实现)将通过定期查询所有 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 主题(在 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)。
  • 将新的卡顿资源作为组件管理器内置功能 (fuchsia.kernel.StallResource) 提供。
  • 实现新的 ZX_INFO_MEMORY_STALL 主题。
  • 添加了基础架构,以检测何时超出卡顿阈值。
  • 通过 zx_system_watch_memory_stall 系统调用公开它。

完成 Zircon 更改后,Starnix 将被修改为在这些更改之上实现 /proc/pressure/memory,如下所示:

  • 系统会直接从 ZX_INFO_MEMORY_STALL 读取总 somefull 值。
  • 系统将通过每秒大约轮询一次 ZX_INFO_MEMORY_STALL、将最近 300 个样本保留在循环队列中,并据此计算滚动平均值,从而在 Starnix 中计算平均值。
  • PSI 通知将在 zx_system_watch_memory_stall 之上实现,并添加速率限制器(在 Starnix 本身中实现),以匹配 Linux 的 PSI 行为,即每个窗口最多只能传送一条通知。

性能

如前面部分所述,所提议的实现将在每个 CPU 的数据结构中维护停顿测量结果,并定期从专用内核线程中汇总这些结果。性能测试未发现因启动/挂起时间记录而导致的任何明显回归。

由于订阅的 zx_system_watch_memory_stall 观察器过多,当超出停顿阈值时,Zircon 内核无法维护这些观察器并向其发送通知,因此可能会导致潜在的性能风险。如果需要,可以通过专用用户空间组件代理访问来缓解此问题,该组件将通过单个 Zircon 订阅汇总和服务多个客户端。与 Zircon 类似(请参阅上文中的新的 zx_system_watch_memory_stall 系统调用),此类代理可以降低请求值的精度,以最大限度地提高汇总机会。

工效学设计

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

向后兼容性

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

安全注意事项

向用户空间授予对新性能衡量指标的访问权限可能会导致创建不必要的边信道。不过,在本例中,由于访问新测量的功能只会路由到受信任的 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 密切相关,syscall API 的设计也遵循了其要求。

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

除了让 zx_system_watch_memory_stall 系统调用返回事件实例之外,我们还考虑过返回具有专用信号(例如 ZX_STALL_MONITOR_ABOVE_THRESHOLD)的新专用“卡顿监视器”对象类型。虽然在 intent 和与内部内核实现的对应方面更明确,但这种方法需要新的内核对象类型,原因仅在于此。该提案被拒,因为现有事件对象类型的 API 已被视为足够表达力。

最后,许多新 API 名称都使用“停顿”而非“内存停顿”一词,以便日后扩展到其他类型的停顿测量(例如 CPU 和 I/O 停顿)。

卡顿衡量和汇总

在所提议的衡量方案中,我们讨论了将等待其他卡顿线程持有的资源的线程作为自身卡顿的线程(“卡顿继承”)的传递性选项,但由于额外的复杂性以及缺少用于评估其优势的基准数据,至少在初始实现阶段,我们拒绝了该选项。

解压缩 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 认为是停滞状态的定义。

在先技术和参考文档