文章介绍了 Aptos 为 Move VM 重构的 Loader V2。

TL;DR:Loader V2 是 Move VM 代码加载与缓存基础设施的一次重大升级。
Aptos 区块链通过 Block-STM 执行引擎来执行一个区块中的交易。交易会以乐观并行的方式执行,就像它们之间不存在数据依赖一样。Block-STM 会管理每笔交易的读取和写入,以检测冲突,并在必要时重新执行发生冲突的交易。Block-STM 还使用 rolling commit——一种动态检测交易何时可以被提交的机制,也就是说,此时它将不再被重新执行。
每个 Block-STM 线程都会运行一个 Move virtual machine(VM)实例,用于解释交易指定的 Move bytecode。在执行过程中,Move VM 会通过一个名为 loader 的组件获取存储智能合约代码的 modules。loader 会对 modules 进行反序列化,验证它们及其传递依赖项(例如使用了其他合约时),并将验证后的 modules 缓存在 Move VM 的 cache 中。Modules 也可以被 republished(即智能合约被升级),而 loader 会确保新代码能够被正确使用和链接。

图 1:传统 loader 架构,以及它如何融入 Aptos 区块链使用的 Block-STM 并行执行引擎。每个线程都运行一个带有各自 module cache 的 VM 实例,这些 cache 会随着不同交易的执行而被填充。
传统 loader 在 module loading 和升级方面的语义非常依赖具体实现,细节复杂且脆弱,有时会在 Aptos 区块链用户尝试发布 package 时导致意外错误。除此之外,传统 loader 还是一个性能瓶颈。
Move VM 拥有 module cache。 这意味着 module cache 是按线程划分的,并且不会在 Block-STM 运行的不同 VM 实例之间共享。因此,并行加载(即从 storage 获取、反序列化、验证)同一个带有大量依赖的 module 的计算代价非常高。比如,如果 Block-STM 使用 32 个线程,而每个线程都尝试加载同一个 module 及其依赖,那么在最坏情况下,每个 module 会被加载 32 次。
Module cache 只会被一个区块中的交易使用,然后被丢弃。 这是因为 module cache 的生命周期受限于 Block-STM 使用的 VM 实例的生命周期。因此,如果跨区块的交易使用同一个智能合约,它会在每个区块中都被加载一次。对于频繁访问的热点合约来说,这尤其糟糕,例如 Aptos framework。

图 2:并行推测执行交易时,可能会导致非确定性行为。Block-STM 会检测这些情况并顺序执行交易。
如果智能合约代码被升级,Block-STM 可能会顺序执行整个交易区块。 由于 Aptos 区块链上的 Move 智能合约是可升级的,因此区块中的某些交易可能会发布 module 的新版本,例如添加新函数或修改现有实现。不幸的是,传统 loader 架构根本无法与 Block-STM 使用的推测并行执行兼容。这是因为某个线程(T2)可能会读取另一个线程(T1)推测性发布的代码,将其加载并缓存到 T2 的 cache 中(图 2)。但如果 Block-STM 检测到 T1 的执行结果本应不同,从而导致该 module 不应被发布,并重新执行 T1,那么 T2 的 loader cache 就不再一致:它存储了本不应被发布的代码版本。这种行为会导致用户意料之外的错误,在最坏情况下,还会在验证者之间产生非确定性的执行结果。为缓解这个问题,Block-STM 会在智能合约被升级且同时被访问的区块上回退到顺序执行。因此,在这种情况下,性能会下降一个数量级。
Loader V2 是对传统 loader 的一次彻底重设计。新实现中最重要的一点是,loader 及其 cache 不再属于 Move VM,这使得线程之间可以共享 cache。实际上,这也意味着,为了在 VM 中执行一个 Move 智能合约,必须同时向它提供一个 loader,如图 3 所示。

图 3:Loader V2 架构,以及它如何融入 Move VM 和 Block-STM 执行。最初,L3 包含 module 0x1::x 。在区块 1 中:(1) 第一个交易从 L3 cache 读取 0x1::x ,并将其记录到本地 L1 cache;(2) 第一个交易再次读取 0x1::x ,这次来自 L1 cache;(3) 第二个交易读取 0x1::y ,它在 L3 cache 和 L2 cache 中都未命中;(4) 该 module 从 storage 中取出并缓存到 L2 cache;(5) 第三个交易从 L2 cache 读取 module 0x1::y (此时 L3 cache 中仍然未命中)。当区块 1 执行完成后,L2 中的缓存条目会被移动到 L3: (6) module 0x1::y 被移动到 L3 cache。然后执行第二个区块。在区块 2 中:(7) 第一个交易从 L3 cache 读取 0x1::y ,并将读取到的 module 复制到本地 L1 cache;(8) 第二个交易写入 0x1::y 的新版本,使 L3 cache 中的条目失效,并将新条目加入 L2 cache。之后对 0x1::y 的访问将会从 L2 cache 中解析到 0x1::y 。
Loader V2 使用的 module cache 可以跨多个区块使用,并且分为 3 个层级。
接下来,让我们看看这些分层 cache 如何与 module publishing 协同工作。
记住我以便更快登录
当交易写入数据时,Block-STM 会在一个多版本数据结构中记录其写入。写入可能会使其他交易的推测执行失效,在这种情况下,这些交易会被安排重新执行,从而产生新的写入版本。
在新设计中,对于发布代码的交易,module 写入在执行结束时不会对其他交易可见。相反,Loader V2 依赖 Block-STM 的 rolling commit 机制,使 module 写入只有在交易被提交时才可见(即可以保证它不再被重新执行)。这种方法的好处是,module 信息不需要版本化:module cache 只需要存储 module 的最新版本。
当 module 写入变为可见时,会发生几件事:
请注意,步骤 (1) — (2) 是为了优化常见场景:读密集型 workload。直接将新 module 添加到 L3 cache 需要锁和同步原语,从而在每次访问时引入额外开销。
Loader V2 已在 AIP-107 中描述,并且该变更已包含在 Aptos node binary 的 1.26 版本中。Loader V2 最近已在 Aptos mainnet 上启用。
向后兼容性
Loader V2 与传统 loader 实现几乎完全向后兼容。然而,在 Aptos mainnet 和 testnet 网络的交易历史中,确实存在少数执行行为出现分歧的情况。我们分析了所有分歧案例,并得出结论:这些历史行为并非预期结果,而是传统 loader 实现不佳导致的。例如,我们发现有些历史交易失败,是因为传统 loader 错误地使用了旧版本代码。使用 Loader V2 时,这些交易会使用正确版本的代码,从而产生不同的执行输出。显然,在这种情况下维持与传统实现的兼容性是不现实的(严格来说,甚至根本不可能)。
性能
最初,Loader V2 是在一组人工基准测试上评估的,这些测试衡量的是单节点在执行不同 workload 时的吞吐量,例如转账、module publish 或 NFT mint。我们观察到,对于会发布 module 的 workload,吞吐量大约可提升 ~10 倍。
然而,现有基准测试套件并没有像真实网络那样包含多样化的不同 modules,因此对于常见场景——读密集型交易来说,代表性还不够。于是,我们在 Aptos mainnet 上进行了实验。
由于 loader V2 几乎完全向后兼容,因此可以将其启用在 Aptos Labs 运行的某个 validator 节点上。启用后,我们比较了使用 Loader V2 的节点与其他节点之间的平均区块执行时间,如图 4 所示。

图 4:8 个不同 validator 节点的平均区块执行时间(单位:秒)。mainnet-validator-usce1–0-aptos-node-validator-0 节点启用了 Loader V2(底部曲线)。其他节点使用传统 loader。
使用 Loader V2 后,平均区块执行时间显著降低,有时最高可减少 60%。这种加速之所以能够实现,是因为新 loader 实现使用了长生命周期的(L3)cache。
Loader V2 是对 Move VM 传统 loader 的一次完整重设计,负责代码加载和缓存。新实现已从 Move VM 中移出,使其变得无状态且线程安全。Loader V2 已集成到 Block-STM 中,使其能够并行执行包含 module publish 的交易区块。同时,还引入了用于缓存已加载并验证过的 Move 智能合约的长生命周期 cache,从而在多样化的真实 workload 上大幅缩短了区块执行时间。
- 原文链接: medium.com/aptoslabs/loa...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!