使Xen的dom0 I/O路径具备NUMA感知能力

Hacker News Top 新闻

摘要

本文详细介绍了使Xen的dom0 I/O路径具备NUMA感知能力的过程,包括内存放置和拓扑合成的修复,这是关于Xen虚拟机监控器NUMA优化系列的一部分。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/24 17:05

# NUMA 第四部分:弥合 Xen Dom0 I/O 差距 来源:https://edera.dev/stories/numa-part-4-closing-the-xen-dom0-i-o-gap 在本系列第三部分(https://edera.dev/stories/numa-part-3-xens-pv-i-o-path-and-its-numa-blind-spot)中,我们剖析了 Xen 半虚拟化 I/O 架构,以及它在非统一内存访问(NUMA)主机上代价高昂的原因:既没有 NUMA 元数据的外存映射页、内核调度器随意放置的内核线程,以及 Dom0 内核与主机实际拓扑之间的信息鸿沟。我们在该部分的结尾承诺,修复方案包含两个部分——结构性修复和每个组件的针对性修复——而第四部分将同时给出这两个部分。 本部分将兑现这一承诺——并且在此过程中,我们还解决了另外两个原本没打算修复的问题。一个是在真正工作开始之前就遇到的:Dom0 自身的内存放置严重失衡,如果在解决这个问题之前就为 Dom0 合成拓扑,那简直就是向一个没有内存来支撑这种说法的内核撒谎。这是一个前提条件,并非绕路——本部分的其他所有内容都建立在此基础之上。另一个是在名义上完成工作之后才遇到的:我们自己工具栈中的一个内存放置错误,该错误自多 vnode vNUMA 出现在我们的代码树中以来就一直悄然存在,直到在一次内存带宽基准测试中才暴露出来。这算是真正的绕路,我们将把它留到最后讨论。 让我们从前提条件开始。 ## 第一个发现:Dom0 的内存放错了位置 标准的 Xen 在启动 Dom0 时,必须将主机的 BIOS 内存映射(E820)裁剪为 Dom0 应拥有的内存子集。裁剪过程按物理地址顺序遍历主机 E820,将区域标记为 Dom0 的 RAM,直到分配足够的空间满足 `dom0_mem` 的要求。在单节点主机上,这没问题;Dom0 的内存来自唯一的节点。但在多插槽主机上,同样的代码就成了真正的问题。主机的 RAM 区域通常按物理地址顺序按 NUMA 节点分组,因此按顺序遍历 E820 意味着 Dom0 会首先从*地址最低的节点*获取其所有内存。 最坏的情况非常严重。在 128 GiB、8 节点的主机上,配置了 `dom0_mem=35%`(这是我们实验室机器的配置),Dom0 占用了地址最低的节点 RAM 的 100%,而其他节点则为 0%。节点 0 到 2 几乎完全被 Dom0 消耗;节点 3 到 7 完全没有 Dom0 拥有的页面。如果 Dom0 甚至无法*尝试*在远程节点上进行分配,那么让 Dom0 具备 NUMA 感知能力就如同合成一个内核永远无法对其采取行动的拓扑——我们生成的 SRAT 会描述一个节点,其中 60% 的 Dom0 内存驻留,而其他六个节点则一无所有。 我们的裁剪方法用按比例分配取代了这种顺序抓取,这也是我们的代码树与上游 Xen 分歧之处之一。它分两遍进行:第一遍统计主机总 RAM;第二遍根据 `dom0_mem` 预算按比例分配给每个区域。算法类似于 Bresenham 算法,通过累积取整余数,使总量最终恰好等于 `dom0_mem`。这样,Dom0 的内存最终会按主机各节点在物理 RAM 中的比例分布到每个主机节点上。 既然 Dom0 在所有主机节点上都拥有实际的内存,剩余的工作就可以开始了。 ## 让 Dom0 具备 NUMA 感知能力 ### 任何 Xen 域的三个 NUMA 拓扑要素 回顾第二部分,一个具备 NUMA 感知能力的 Xen 域可以看到三个拓扑要素: - **ACPI 系统资源关联表(SRAT)**,将 CPU 和内存范围映射到 NUMA 节点。 - **ACPI 系统位置信息表(SLIT)**,提供节点间距离矩阵。 - **CPUID 中的 x2APIC ID**,编码了每个 CPU 的封装/核心/线程标识符。 任何希望获得准确 NUMA 感知能力的 Xen 域都需要这三个要素都存在且一致。当工具栈通过 `XEN_DOMCTL_setvnumainfo` 请求 vNUMA 布局时,上游 Xen 会为 PVH 和 HVM domU 完成大部分工作:合成 SRAT 和 SLIT 以匹配请求的拓扑。然而,上游 Xen 不会根据 vNUMA 布局合成 CPUID 中的 x2APIC ID。这第三个要素自本博文描述的工作开始之前就是 Edera 的扩展内容,我们在过程中进一步扩展了它,以处理多节点 domU 中非 2 的幂次 vCPU 数量。 Dom0 则是另一回事。直到本博文描述的工作之前,上游 Xen 不会为 Dom0 合成这三个要素中的任何一个。Dom0 看到的只是一个单一的扁平 NUMA 节点,无论主机有多少个物理节点。本节所涵盖的工作将同样的三要素合成扩展到 Dom0,并在一组特定条件下进行。 条件是:Dom0 必须是 PVH 模式;必须使用 `dom0_vcpus_pin=1` 启动,以便每个 Dom0 vCPU 硬绑定到特定的主机 pCPU;Dom0 vCPU 数量必须等于主机 pCPU 数量,以便每个 pCPU 都有对应的 Dom0 vCPU;并且主机必须拥有多个 NUMA 节点。这些条件对于实现也有一个有用的副作用。由于每个 Dom0 vCPU N 都绑定到 pCPU N,因此 vCPU N 的 vNUMA 节点分配就是 pCPU N 的主机 NUMA 节点。无需设计布局算法,无需在启动时做出放置决策,也不存在部分覆盖主机拓扑的边缘情况,因为根据定义,Dom0 覆盖了整个主机。实现方式就是直接遍历主机的 `cpu_to_node` 表。 当条件满足时,Dom0 会获得: - 一个合成的 SRAT,描述每个 Dom0 跨越的主机 NUMA 节点对应的一个邻近域,其中包含相应的 CPU 集合和每个域中的适当内存区域。 - 一个直接从主机自身的 SLIT 切片出来的合成 SLIT——Dom0 看到的距离是真实的节点间距离,而不是当 SLIT 缺失时 Linux 替代使用的默认“10 本地,20 远程”存根。 - CPUID 中的 x2APIC ID,编码以匹配合成的拓扑,使用处理 domU 情况时相同的机制。 此后,Dom0 内部的 `numactl -H` 终于显示真实的拓扑——八个节点取代了之前报告的一个节点,每个节点都拥有实际属于它的 CPU 和内存。Dom0 内部的存储守护进程看到了真实的拓扑。Dom0 内部的容器运行时看到了真实的拓扑。 而且内核自身的自动 NUMA 平衡终于有了可以作用的对象:有了准确的映射,它会采样每个任务的运行位置,并随时间推移将该任务的页面迁移到同一节点,自行修复远程访问。这种自我修复在扁平的单一节点视图下是不存在的——没有什么需要平衡的——因此这是拓扑工作解锁的又一个能力。不过,它只是一个备用方案而不是规划方案:一种具有自身成本的采样启发式方法,并且内核的建议是对于已经绑定的工作负载将其关闭——这正是后续部分对 I/O 路径所做的操作。 现在,每个 Edera 部署的 Dom0 shell 首次在准确信息的基础上运行。 还需要指出的是,Dom0 的 NUMA 感知能力在混合节点上确实非常重要。许多 Edera 部署并非全 Edera:同一台机器上,部分 Pod 作为普通容器(https://edera.dev/use-case/multi-tenant-isolation)在默认运行时下运行——直接在 Dom0 内——而另一些则在 Edera 运行时下运行,每个都被封装在自己的区域中。这些普通容器与其他任何 Dom0 工作负载一样,在这项工作之前,它们都是 NUMA 盲。一个非 Edera 的 Pod 在 Edera 主机上的运行速度可能比在同一台机器上作为普通 Linux 运行时更*慢*,完全是因为将机器变成 Xen Dom0 隐藏了其工作负载过去看到的拓扑。让 Dom0 具备 NUMA 感知能力弥合了这一差距:留在 Dom0 中的容器获得了它们本应在裸机上看到的拓扑,因此采用 Edera 并不会悄悄地对那些从未被沙盒化的工作负载征税。 ### 为什么 SRAT、SLIT 和 CPUID 都至关重要 我们很容易认为仅 ACPI 表就足够了。内核在启动时读取它们,NUMA 基础设施获取拓扑,然后就完成了。对于某些消费者来说,这*确实*够了。大多数 NUMA 感知的用户空间工具所依赖的 hwloc 库(https://github.com/open-mpi/hwloc)从内核的 sysfs 视图(内核又通过 SRAT 和 SLIT 填充该视图)中获取其拓扑视图。只要这两个表正确,sysfs 表面就会被正确填充,hwloc 就满意了。 其他消费者则更进一步,跳过 CPUID 会悄然破坏它们。LLVM OpenMP 运行时(https://github.com/llvm/llvm-project/tree/main/openmp/runtime),用于编译型语言中并行循环密集型工作负载,它从 x2APIC CPUID 叶子而不是从 sysfs 中派生其套接字和核心拓扑。在 Xen 客户机中,如果 CPUID 保留了默认的单套接字布局,而 SRAT 和 SLIT 是正确的,OpenMP 会得出结论机器是单套接字,并相应分配其工作线程——这意味着在多套接字主机上,所有工作线程都会被塞入一个节点核心范围内。内核调度器会随时间推移进行重平衡,但 OpenMP 的初始分配是固定的,由此产生的放置结果非常糟糕,以至于基准测试数字明显下降。 (LLVM OpenMP 确实有一个编译时选项,可以使用 hwloc(https://github.com/llvm/llvm-project/blob/de59f9ed12db9d47ad41ad44d54ec604ef8841cb/openmp/runtime/CMakeLists.txt#L161-L165)进行拓扑发现,而不是直接读取 CPUID,但该选项默认未启用,我们也没有发现哪个二进制发行版启用了它。生产构建运行的是 CPUID 路径。) 方便的是,运行时可以显示其推理过程。使用 `KMP_AFFINITY=verbose` 运行 LLVM OpenMP 运行时的工作负载,运行时将打印它发现的拓扑和它选择的线程放置位置;在 Edera 客户机上,由于 x2APIC ID 被合成为匹配实际布局,两者都正确——无论工作负载是在 Dom0 还是 domU 内运行。 这个教训超出了 OpenMP 的范围。让所有消费者都能看到拓扑,是使其实际有用的唯一方法。仅有 SRAT、仅有 SLIT、仅有 CPUID——它们单独都不够,因为不同生态系统中的不同消费者读取不同的来源。只做其中两个,就意味着要选择让哪一类工作负载悄然失望。 ## 构建 Dom0 vNUMA 时发现的错误 在正确进行合成时,我们发现了一些意料之外的错误。 第一个是节点编号错误。Xen 在启动时会将邻近域标识符重新编号为密集的内部节点 ID 空间,按 SRAT 扫描顺序进行。Dom0 vNUMA 构建代码的第一个版本使用了这些 Xen 内部的节点 ID 作为 vNUMA 节点索引,然后将其泄露到我们为 Dom0 生成的 SRAT 以及编码的 APIC ID 中。结果是:绑定到主机邻近域 1 上 pCPU 的 Dom0 vCPU 在 Dom0 内部可能显示为属于“插槽 3”,如果 Xen 的重新编号将邻近域 1 放入了 Xen 内部插槽 3 的话。在 Dom0 内部运行的工具如果依赖于邻近域编号,可能会定位到错误的物理插槽。修复方法是直接按主机邻近域进行索引,而不是使用 Xen 内部的节点 ID,这样 SRAT、APIC ID 和 SLIT 都一致地使用相同的主机固件编号系统。 第二个是关于我们如何在生成的 SRAT 中构建内存范围。第一个版本将每个主机 RAM 区域平均分配给 Dom0 拥有的所有 vNUMA 节点,视为按比例分配。这在两个方面是错误的。每个主机 RAM 区域物理上只位于一个 NUMA 节点上,因此将其分割到 vnode 上会声称内存所在节点实际上无法承载它。而且 Linux 的 NUMA 注册代码会拒绝其内存关联条目未能完全覆盖其 memblock 视图的 SRAT——平均分割的条目并没有覆盖完整的主机 E820,并且 Dom0 的 dmesg 会抱怨“NUMA: no nodes coverage for 170175 MB of 261441 MB RAM”,然后回退到伪造的单节点布局。修复方法:为每个物理 NUMA memblk 发出一个 vmemrange,并过滤到 Dom0 实际跨越的节点。完全覆盖;诚实的所属关系。 第三个是最微妙的,也是最初的内存偏斜在新的隐藏位置:按比例的 E820 裁剪本身。这种裁剪假设每个主机 RAM 区域完全位于一个 NUMA 节点上——在多插槽主机上这是真的,因为固件在节点边界处分割高端 RAM——但在单插槽子 NUMA 布局(Intel 子 NUMA 集群,或 AMD 每套接字节点数(NPS)为 2 或更多)上是假的,其中一个连续区域可能跨越两个节点。当裁剪过程将这样的区域按份额切割后,它保留的内存全部落在第一个节点上,导致第二个节点几乎没有内存——SRAT 承诺 32 GiB,实际只有大约 1 GiB——因此 Dom0 的每节点绑定在该节点上没有可供分配的内存。偏斜又回来了,只是更难发现,因为它只会在子 NUMA 硬件上出现。修复方法:在获取其份额之前,先在 NUMA 节点边界处分割每个区域。 在完成这些修复以及拓扑合成之后,Dom0 就按照本系列其余部分一贯假设的方式具备了 NUMA 感知能力。这算是一半的修复。另一半是 Dom0 后端所需按请求的信息,以便利用该拓扑做有用的事情。 ## 向 Dom0 显示外部帧的位置 即使 Dom0 完全具备 NUMA 感知能力,第三部分中按请求的放置问题仍然存在:当 domU 向 Dom0 授予环页面访问权限时,Dom0 的内核无法了解该页面位于哪个主机节点上。在 Dom0 的 `mem_map` 中表示该环的 `struct page` 是从 `xen_alloc_unpopulated_pages` 中获取的占位符。该分配器的标准 Linux 版本使用 `NUMA_NO_NODE` 注册其底层内存段,因此对任何通过 grant 映射的环页面调用 `page_to_nid` 都会返回 `NUMA_NO_NODE`,无论外部帧实际位于何处。 这个单一缺失的数字就是整个问题所在。Dom0 现在有了主机准确的映射,但决定每个后端放置的一个问题——这个环在哪个节点上?——却返回“不知道”。因此,服务该环的内核线程、唤醒它的中断以及它们背后的 Dom0 I/O 栈都被放置,而没有考虑环的实际位置,路径直接落回了第三部分中每跳都跨节点的情形——而此时 Dom0 已经完全具备 NUMA 感知能力,并且拥有除这一信息之外所需的所有事实。 Dom0 需要的信息,即给定机器帧的主机邻近域,就存在于虚拟机监控器内部。只是尚未暴露给 Dom0 而已:答案存在,仅隔着一层特权边界。结构性修复的第一半就是将其暴露出来。我们添加了一个新的 Xen 超级调用 `XENMEM_get_mfn_pxms`,它接收一批机器帧号,并为每个帧返回邻近域标识符。权限仅限于硬件域:没有哪个不可信客户机有正当理由需要此信息,并且广泛暴露它会泄露物理主机拓扑的详细信息。 这段代码中有三个 NUMA 标识符命名空间,值得简短说明一下。主机邻近域(固件级别的标识符,ACPI SRAT 使用的)、Xen 内部节点 ID(按 SRAT 扫描顺序分配)以及 Linux 节点 ID(Dom0 自身的 NUMA 节点编号)。超级调用返回邻近域,因为这是 Dom0 自身的 SRAT 已经使用的值空间;`pxm_to_node()` 是一个标准内核调用,用于将结果转换为 Linux 节点 ID。返回 Xen 内部的节点 ID 会迫使每个调用者维护自己的转换表。 有了超级调用,结构性修复的第二半就是教 Dom0 的非填充页面分配器维护每个节点的池。分配器现在为每个 Linux 节点维护一个独立的空闲列表,并且每个段大小的后备区域通过

相似文章

NUMA:核心、内存及它们之间的距离

Hacker News Top

本文解释了非统一内存访问(NUMA)的概念、历史背景以及它在多插槽服务器上对性能的影响,同时介绍了Edera在使基于Xen的虚拟化实现端到端NUMA感知方面所做的工作。

NX位不仅仅关乎安全

Lobsters Hottest

一位开发者描述了在postmarketOS的ARM64裸机虚拟机管理程序中调试一个复杂bug的过程,涉及指令缓存一致性问题和与NX位相关的硬件特定行为。

x86 模拟的难题

Hacker News Top

本文讨论在 ARM 的弱序内存模型上模拟 x86-TSO 内存模型的挑战,涵盖原子指令、分裂锁和未缓存内存等问题,并探讨潜在解决方案。

为何我重新实现了LVM(尽管保证更差)

Lobsters Hottest

作者解释了为什么他们重新实现了LVM,尽管保证更差,以优化microVM在hypervisors上的启动性能,专注于使用Linux device-mapper的特定工作负载需求。