超越单核:提升并行环境中VM效率

CairoVM 性能分析与优化,通过lambda works-felt功能,使用来自密码库LambdaWorks的Felt实现替换了原有的BigInt实现。在多线程环境中,由于缓存未命中导致性能下降。通过重新设计MemoryCell结构,优化了内存布局,减少了缓存未命中,显著提高了多核系统上的虚拟机扩展性,但在ARM架构上,这种优化对执行性能没有明显影响。

在 LambdaClass,基准测试和性能分析是我们开发过程中至关重要的环节。我们始终通过 CI 流水线对每个 PR 进行性能分析,以便发现任何性能问题。

Cairo 虚拟机也不例外,因为它是 Starknet 网络的核心组成部分。在这篇文章中,我们将深入探讨我们如何调查一个性能衰退问题,然后优化库中的一个核心数据结构,以提高其多核性能。

Screenshot 2024-03-15 at 17.58.47

初探

一些背景:不久前,我们引入了一个可选功能 lambdaworks-felt,这标志着我们的性能指标有了显著的提升。它使用了我们密码学库 LambdaWorks 中的 Felt(域元素)实现,取代了使用 BigInt 的更简单的实现。

上周,来自 Equilibrium 的 Pathfinder 团队(一如既往地,我们要感谢他们发现并提出这个问题)观察到一种意料之外的 scaling 行为,当他们尝试使用他们的 re_execute 工具重新执行一些 Sepolia 测试网区块时。该工具会启动多个 CairoVM,以并行运行区块的交易。

当启用了 lambda works-felt 功能的 CairoVM 的多个实例在支持超线程的处理器上执行时,执行时间并不会像未启用 lambda works-felt 功能时那样,随着启用线程数量的增加而等比 scaling。

Untitled (2)

该图由 Pathfinder 团队提供,展示了在 Ryzen 5900X 上执行的基准测试结果。正如你所看到的,带有 lambdaworks-felt 功能的 CairoVM 在使用较少线程执行时性能更好。但随着线程数量的增加,使用默认实现的运行(使用 num_bigint crate 实现的 Felt 类型)scaling 效果更好。

深入挖掘

我们的首要任务是重现所报告的问题。一旦我们看到与 Pathfinder 团队相同的结果,我们就可以开始调查可能的原因。在那之后,我们开始调查这种行为,发现使用基于 lambdaworks 的 felt 时,我们有很多缓存未命中。

使用 Bigint felt 的 VM:

$ perf stat -e cache-misses ./binaries/re_execute_main sepolia-testnet_0.11.0_47191.sqlite 47000 47191

 Performance counter stats for './binaries/re_execute_main sepolia-testnet_0.11.0_47191.sqlite 47000 47191':

        2094269051      cache-misses

       5.926431912 seconds time elapsed

     168.877378000 seconds user
       3.675086000 seconds sys

使用 Lambdaworks felt 的 VM:

$ perf stat -e cache-misses ./binaries/re_execute_main_lambdaworks sepolia-testnet_0.11.0_47191.sqlite 47000 47191

 Performance counter stats for './binaries/re_execute_main_lambdaworks sepolia-testnet_0.11.0_47191.sqlite 47000 47191':

        2426557083      cache-misses

       6.931543878 seconds time elapsed

     197.086250000 seconds user
       6.588698000 seconds sys

所以,在这里我们可以看到,lambdaworks felt 的缓存未命中比 BigInt 实现多 16%。

这如何帮助我们寻找原因?我们与最初对 CairoVM 进行基准测试并将其与运行时的内存分配相关联,并将 integrated lambdaworks-felt 集成到 CairoVM 的团队成员进行了交谈。当我们向他展示这些结果时,他提到在 VM 运行时查看 felt 在内存中的布局将是一个好主意。

当 CairoVM 运行程序时,它会将 felt 值存储在其内存表示中,该内存表示编码了 proving 所需的规则和保证。因此,对于一个正在运行的程序,内存是一个 MemoryCell 的集合,而 MemoryCell 又封装了一个布尔值,该布尔值表示该内存单元是否在程序执行期间被访问过,以及一个 MaybeRelocatable 值,这是一个可以是 felt 或 Relocatable 值的枚举:

pub(crate) struct MemoryCell(MaybeRelocatable, bool);
pub enum MaybeRelocatable {
    RelocatableValue(Relocatable),
    Int(Felt252),
}

在查看缓存问题时,通常会查看值在内存中呈现的形状或布局。我们注意到,当使用 lambdaworks-felt 功能时,MemoryCell 结构的大小从 40 字节增加到 48 字节,这是在运行并行 workload 时缓存未命中增加的根本原因。

我们可以猜测,由于多个 VM 尝试用它们的值填充缓存,因此跨行运行的 felts 会导致更多的缓存抖动。

另一个需要考虑的因素是在 AMD 和 Intel CPU 中使用 SMT(Simultaneous multithreading,** 也称为 Hyper-Threading)。这种技术基本上在一个物理核心内运行两个逻辑核心,这通常会提高整体性能。

但情况并非总是如此;有时,它会妨碍工作。例如,一个逻辑核心可以驱逐其他逻辑核心稍后需要的缓存项,从而导致更多的缓存未命中。

仅仅猜测是虚幻的想法,那是占星家的事,所以我们决定实现一个更改并测量其影响。

为了解决这个问题,我们将该结构重构为更友好的缓存表示。现在,新的优化 MemoryCell 可以容纳在半个 64 字节的缓存行中,而不是几乎整个缓存行。现在,新的结构使用 felt 表示中的备用位以原始形式存储数据和元数据,并且根据需要从中构建 MaybeRelocatable 实例。

/// [`MemoryCell`] 表示 VM 内存的优化存储布局。
/// 它被指定为具有 32 字节的大小和对齐方式,以优化缓存访问。
/// 典型的缓存大小为 64 字节;少数情况下可能为 128 字节,这意味着 32 字节对齐到
/// 32 字节边界将永远不会拆分为两个单独的行,从而避免双重停顿和减少虚假共享和驱逐。
/// 权衡是额外计算以转换为我们的“正在运行”的 `MaybeRelocatable` 和
/// `Felt252` 以及一些额外的副本。根据经验,这似乎被 Lambdaworks 较大结构的改进
/// 局部性所抵消。当使用 `BigUint` 实现时,转换会造成很大的冲击,因为这些转换会强制在堆上分配,但由于这在以后的版本中 anyway 会被删除,因此这不是 priority。对于 Lambdaworks,新的副本*主要是指向堆栈,堆栈通常已经在缓存中。
/// 该布局使用第一个 `u64` 中的 4 个 MSB 作为标志:
/// - BIT63:NONE 标志,当单元格实际为空时为 1。
/// - BIT62:ACCESS 标志,当单元格以 Cairo 可观察的方式被访问时为 1。
/// - BIT61:RELOCATABLE 标志,当包含的值是 `Relocatable` 时为 1,当它是 `Felt252` 时为 0。
/// `Felt252` 值以大端顺序存储以保持标志位 free。
/// `Relocatable` 值以本机端顺序存储,其中第三个字存储段索引,第四个字存储偏移量。
#[repr(align(32))]
pub(crate) struct MemoryCell([u64; 4]);

在此更改之后,当我们重新执行一些旧的 Sepolia 测试网区块时,我们可以看到,当使用超线程时,新的缓存友好的 MemoryCell scaling 效果更好。优于带有 BigUint 支持的 Felt 的旧 MemoryCell 和我们之前使用 Lambdaworks felt 实现的 MemoryCell

benchs_x86 (2)

在 AMD Ryzen 9 5950X 16 核处理器上运行的基准测试,架构:x86,CPU(s):32

该图是使用 hyperfine(一个基于 CLI 的基准测试工具)运行提取的数据生成的,使用的线程数不同,因此我们可以了解每次更改随着线程数的增加而执行的效果。


Running benchmark for 1 threads
Benchmark 1: re_execute_main threads: 1
  Time (abs ≡):        57.351 s               [User: 55.107 s, System: 2.174 s]

Benchmark 2: re_execute_fixed_felt threads: 1
  Time (abs ≡):        44.760 s               [User: 42.510 s, System: 2.197 s]

Benchmark 3: re_execute_main_lambdaworks threads: 1
  Time (abs ≡):        47.458 s               [User: 45.454 s, System: 1.948 s]

Summary
  re_execute_fixed_felt threads: 1 ran
    1.06 times faster than re_execute_main_lambdaworks threads: 1
    1.28 times faster than re_execute_main threads: 1
Running benchmark for 2 threads
Benchmark 1: re_execute_main threads: 2
  Time (abs ≡):        28.247 s               [User: 54.708 s, System: 1.647 s]

Benchmark 2: re_execute_fixed_felt threads: 2
  Time (abs ≡):        21.625 s               [User: 41.931 s, System: 1.231 s]

Benchmark 3: re_execute_main_lambdaworks threads: 2
  Time (abs ≡):        23.607 s               [User: 45.111 s, System: 1.987 s]

Summary
  re_execute_fixed_felt threads: 2 ran
    1.09 times faster than re_execute_main_lambdaworks threads: 2
    1.31 times faster than re_execute_main threads: 2
Running benchmark for 4 threads
Benchmark 1: re_execute_main threads: 4
  Time (abs ≡):        14.718 s               [User: 56.848 s, System: 1.445 s]

Benchmark 2: re_execute_fixed_felt threads: 4
  Time (abs ≡):        11.516 s               [User: 44.374 s, System: 1.264 s]

Benchmark 3: re_execute_main_lambdaworks threads: 4
  Time (abs ≡):        12.472 s               [User: 47.662 s, System: 1.627 s]

Summary
  re_execute_fixed_felt threads: 4 ran
    1.08 times faster than re_execute_main_lambdaworks threads: 4
    1.28 times faster than re_execute_main threads: 4
Running benchmark for 8 threads
Benchmark 1: re_execute_main threads: 8
  Time (abs ≡):         7.904 s               [User: 61.202 s, System: 0.705 s]

Benchmark 2: re_execute_fixed_felt threads: 8
  Time (abs ≡):         6.186 s               [User: 47.780 s, System: 0.771 s]

Benchmark 3: re_execute_main_lambdaworks threads: 8
  Time (abs ≡):         6.800 s               [User: 52.407 s, System: 0.947 s]

Summary
  re_execute_fixed_felt threads: 8 ran
    1.10 times faster than re_execute_main_lambdaworks threads: 8
    1.28 times faster than re_execute_main threads: 8
Running benchmark for 16 threads
Benchmark 1: re_execute_main threads: 16
  Time (abs ≡):         5.248 s               [User: 77.844 s, System: 1.159 s]

Benchmark 2: re_execute_fixed_felt threads: 16
  Time (abs ≡):         4.443 s               [User: 65.118 s, System: 1.575 s]

Benchmark 3: re_execute_main_lambdaworks threads: 16
  Time (abs ≡):         5.456 s               [User: 80.535 s, System: 1.852 s]

Summary
  re_execute_fixed_felt threads: 16 ran
    1.18 times faster than re_execute_main threads: 16
    1.23 times faster than re_execute_main_lambdaworks threads: 16

Running benchmark for 32 threads
Benchmark 1: re_execute_main threads: 32
  Time (abs ≡):         5.967 s               [User: 168.953 s, System: 3.411 s]

Benchmark 2: re_execute_fixed_felt threads: 32
  Time (abs ≡):         5.345 s               [User: 149.728 s, System: 4.033 s]

Benchmark 3: re_execute_main_lambdaworks threads: 32
  Time (abs ≡):         7.010 s               [User: 199.011 s, System: 5.984 s]

Summary
  re_execute_fixed_felt threads: 32 ran
    1.12 times faster than re_execute_main threads: 32
    1.31 times faster than re_execute_main_lambdaworks threads: 32

    1.32 times faster than re_execute_main_lambdaworks threads: 48

我们还运行了 perf stat 来检查使用这个新版本的缓存未命中情况,它确实更具缓存效率,与使用 Lambdaworks 的旧 MemoryCell 实现相比,缓存未命中减少了 21%,比使用 Bigints 的缓存未命中减少了 9%。

$ perf stat -e cache-misses ./binaries/re_execute_fixed_felt sepolia-testnet_0.11.0_47191.sqlite 47000 47191

 Performance counter stats for './binaries/re_execute_fixed_felt sepolia-testnet_0.11.0_47191.sqlite 47000 47191':

        1906296012      cache-misses

       5.278474869 seconds time elapsed

     148.647511000 seconds user
       4.168127000 seconds sys

ARM 架构注意事项

虽然我们已经看到了与 x86_64 架构多线程环境中缓存未命中相关的性能衰退,但重要的是要注意,这个问题在使用 ARM CPU 的系统中并不普遍。我们在配备 18 GB 内存和 11 核的 MacBook M3 Pro 上进行的基准测试展示了不同的性能概况。

benchs_mac_2 (1)

在图中,你可以注意到:

  • 在 SMT 上下文中,基于 ARM 的系统在使用基于 lambdaworks 的 MemoryCell 结构而不是 BigInt 实现时,显示出卓越的 scaling 能力。
  • MemoryCell 的修改不会影响 ARM 系统上的执行性能。

ARM 和更传统的 x86_64 处理器(例如 Intel 或 AMD 的处理器)之间的性能差异可归因于缓存管理方面的架构差异和更大的缓存行大小(Apple Silicon 处理器中的 128 字节)。ARM 处理器的设计采用了独特的缓存利用方法,其中各个内核拥有专用的缓存资源。这种设计选择避免了缓存争用的情况,即两个内核争用相同的缓存行,这种情况可能导致缓存未命中增加。

结论

所以一切都很好,但还剩下两个问题:为什么我们以前没有看到这个问题,以及我们如何确保将来能够看到它?我们如何通过考虑我们所学到的知识来改进我们的工程流程?

我们的基准测试模拟了一个 workload,而没有必要的并发性来暴露这个问题。

为了确保性能衰退测试,我们需要编写一些代码,这些代码将在正确的环境下触发它,一个最小版本的 re_execute,它将允许我们改变参数以覆盖更广泛的问题空间(并行运行的 VM 数量、线程数、使用的处理器数量、处理器架构等)。

我们学到的(或者说,加强的)两个教训是:

  1. 不要假设你的代码只会运行在特定的 workload 下。尝试尽可能地模拟真实世界并进行测量以确保。

  2. 不要假设对代码的更改(显示了“本地”测量的性能改进)会对整个程序的整体性能产生积极影响。

这种经验突出表明,在 Rust 中实现最大性能通常需要考虑更底层的细节,而不仅仅是使用枚举。它强调了在对性能敏感的应用程序中理解和优化 CPU 缓存行为的重要性。

通过重新思考我们的数据存储和访问方法,并在结构上做一些创新,我们减少了缓存未命中,并显着提高了虚拟机在多核系统上的 scaling 能力。

  • 原文链接: blog.lambdaclass.com/bey...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
lambdaclass
lambdaclass
LambdaClass是一家风险投资工作室,致力于解决与分布式系统、机器学习、编译器和密码学相关的难题。