诊断与修复Arm64原子操作的页面故障性能问题
在运行预缓存预热合成基准测试时,我们注意到Ampere CPU出现了异常的性能影响。深入调查后,我们发现与x86 CPU相比,Ampere CPU发生了更多的页面故障。
问题描述
我们将问题隔离到某些原子指令的使用,如ldadd指令,该指令在单条指令中加载寄存器、对其增加值并在寄存器中存储数据。在某些条件下,这会触发两次"页面故障",尽管在逻辑上这是一个全有或全无的操作,保证在一步中完成。
使用性能分析工具,我们能够在Ampere和x86 CPU上分析基准测试的预热阶段,并查看额外时间花费在哪里。我们随后使用perf发现,在使用透明大页面和内存预接触时,Ampere上的页面故障数量远高于x86。
在启动阶段后,Ampere上的性能仍然受到影响,在/proc/vmstat目录中,我们能够看到在Ampere系统上,thp_split_pmd计数器(指示内存中碎片化大页面的数量)远高于预期。
Arm64原子指令与页面故障
在ARM 8.1-A及以后的Ampere和其他Arm64平台上,原子指令可以使用大系统扩展(LSE)系列的原子指令。使用LSE,Arm引入了一组单指令原子操作,包括执行加载、添加和存储操作的ldadd指令。
由于这些原子指令在Arm64上的实现方式,这条单指令首先生成一个"读"故障(对应于加载寄存器值),然后生成一个单独的"写"故障(对应于存储新值)。因此,这导致了过多的页面故障。
这带来几个不利影响:
- 在常规操作中造成显著的性能损失,因为页面故障及随之而来的TLB维护会显著影响性能
- 在使用透明大页面时,这会导致大页面被拆分
内存管理概览
操作系统为在用户空间运行的程序创建虚拟内存地址空间,并管理虚拟内存地址到物理内存地址的映射。
当应用程序访问虚拟内存地址时,操作系统首先检查该虚拟内存地址是否已在内存中可用的物理内存页面中。内核通过检查其页表(从虚拟内存地址到物理内存地址的映射集合)来完成此操作。
页面故障有两种类型:读故障和写故障。对于未初始化内存的读取,内核可以通过使用称为零页的特殊页面来完全避免访问物理内存。当内存页面被写入时,这会触发写故障。
解决方案
解决这个问题主要从两个方面入手:
首先,对于内存预热,可以使用替代机制来预接触内存。Linux内核提供了系统调用madvise(),允许应用程序开发者指示与内存相关的意图,并就应用程序将如何使用某些内存段向内核提供建议。
我们更新了JVM的行为,调用madvise(addr, len, MADV_POPULATE_WRITE)来向内核表明我们打算写入此内存区域。这完全避免了原子指令与内存预热之间的交互。
其次,我们正在与Linux内核社区合作,确保在使用THP时,对大零页的写故障会在内存中分配一个大页面。虽然这个问题尚未完全解决,但Linux内核社区正在开发补丁,我们预计问题很快就会得到修复。
在我们的测试中,进行这样的更改使"内存预热"基准测试期间的页面故障数量减少了一半,并在运行于Ampere Altra CPU的虚拟机上将该操作的时间减少了60%。