您正在查看: Solana-优秀转载 分类下的文章

EVM与SVM:帐户 看看在以太坊和索拉纳上构建时帐户有何不同

作为区块链网络,以太坊和索拉纳拥有独特的数据结构,作为全球公共世界计算机,在其网络上存储和共享数据。在本章中,我们旨在探索这些链如何构建其数据集。

以太坊中的帐户

在以太坊中,“帐户”是指拥有以太并可以发送交易的实体。它包括存款和取款所需的地址,分类如下:

  • EOA(外部拥有的帐户):外部拥有的帐户,拥有私钥。把它想象成个人钱包的帐户。
  • CA(合同账户):合同账户,持有智能合同代码。

以太坊中EOA和CA的一个关键区别是,EOA不是智能合约,通常没有自己的存储空间。因此,EOA的代码散列设置为“空”散列,表示帐户没有存储空间。

外部拥有帐户(EOA)是具有私钥的帐户,拥有私钥意味着控制对资金或合同的访问。私钥意味着对资金或合同的访问进行控制。EOA中包含以下数据:

合同帐户包含EOA根本无法持有的智能合同代码。此外,合同帐户没有私钥。相反,它由智能合约代码的逻辑控制。这个智能合同代码在创建合同帐户时记录在以太坊区块链上,是由EVM执行的软件程序。

与EOA一样,合同帐户有一个地址,可以发送和接收以太币。然而,当交易的目的地是合同帐户地址时,交易和交易数据将用作在EVM中执行合同的输入。除了以太外,该事务还可以包括指示要执行的合同特定功能的数据以及要传递给该函数的参数。因此,事务可以调用合同中的函数。如果EOA要求,合同也可以调用其他合同。然而,由于合同帐户没有私钥,它不能签署交易,也不能自行启动交易。这些关系总结如下:

  • EOA → EOA (OK)
  • EOA → CA (OK)
  • EOA → CA → CA (OK)
  • CA → CA (Impossible)

Solana的帐户

Solana帐户的概念比以太坊更广泛。在Solana中,所有数据都根据帐户进行存储和执行。这意味着,在每次需要在事务之间存储状态的情况下,都会使用帐户进行保存。与Linux等操作系统中的文件类似,帐户可以存储超出程序生命周期的任意数据。此外,与文件一样,帐户包含元数据,这些元数据告知运行时谁可以访问数据以及如何访问数据。

在Solana的Sealevel VM中,所有帐户都能够存储数据。那么,智能合约开发人员可以在哪里存储他们的数据呢?他们可以将数据存储在可执行帐户拥有的不可执行帐户(PDA)中。开发人员可以通过分配与其可执行帐户地址相同的所有者来创建新帐户来存储数据。

然而,存储数据的Solana网络上的“帐户”需要支付费用。这些帐户包括关于它们所含数据寿命的元数据,以名为“Lamports”的本机令牌表示。帐户存储在验证器的内存中,并支付“租金”以保留在那里。验证员定期扫描所有帐户并收取租金。Lamports降至零的帐户将自动删除,因为他们无法支付租金。如果一个帐户包含足够数量的Lamports,它将免收租金,并且不单独扣除租金。

Solana的帐户分为以下两种类型,类似于以太坊:

可执行帐户(程序帐户):这些是存储代码的智能合约,通常更简单地称为“程序”。

不可执行帐户(数据帐户):这些可以接收令牌或数据,但不能执行代码,因为可执行变量设置为“false”。

(*与以太坊不同,Solana使用“程序”而不是“合同”一词。)
比较每个链中的帐户结构揭示了以下差异。

那么,EOA和CA如何与Solana的帐户结构相对应?它们可以映射如下。

帐户抽象

以太坊长期以来一直在探索帐户抽象的概念。以太坊有两种类型的帐户:EOA和CA,每种帐户都具有明显不同的功能。值得注意的是,合同账户(CA)无法生成或签署交易,导致重大限制。交易必须通过EOA发起和签署,这意味着使用21,000天然气的基本费用,并增加了帐户管理的复杂性。帐户抽象旨在消除这些限制,允许单个帐户同时执行EOA和合同帐户的功能。
因此,可以对图表进行以下调整:

  • EOA → EOA(OK)
  • EOA → CA(OK)
  • EOA → CA → CA(OK)
  • EOA + CA(AA)→CA(现在,好的!)

例如,multisig钱包或智能合约钱包需要将少量以太坊存储在单独的EOA中以支付交易费用,导致随着时间的推移不得不补充它的不便。帐户抽象允许单个帐户执行合同和签发交易,从而改善了这种不便。通过ERC-4337,Vitalik向社区提出了这个概念,并于2021年被采纳,现在在以太坊网络中实施。

总之,帐户抽象提供了以下好处:

  • 其他人支付我的交易费用,或者我为其他人支付。
  • 使用ERC-20代币支付费用
  • 设置自定义安全规则。
  • 在密钥丢失的情况下恢复帐户。
  • 在受信任的设备或个人之间共享帐户安全。
  • 批量交易(例如,一次性授权和执行掉期)。
  • Dapp和钱包开发人员有更多机会创新用户体验。

帐户抽象是否在索拉纳中实施?

Solana自推出以来一直实施帐户抽象(AA)。如前所述,Solana将所有数据存储在称为“帐户”的单元中,分为可执行文件(程序帐户)和不可执行文件(数据帐户)。从一开始,Solana就支持程序创建和管理特定帐户(即直接发起交易)的能力。此功能扩展了Solana中的帐户抽象功能,被称为程序派生地址(PDA)。Solana程序与数据帐户不同,是包含可执行代码的可执行帐户。使用PDA,开发人员可以为交易签名设置规则和机制,允许代表Solana网络认可和批准的受控帐户(PDA)自主授权各种链上操作。因此,与以太坊不同,Solana允许直接控制另一个基于Solana程序的程序,而无需繁琐的分层。

总结

Solana的帐户概念构建了链上的所有数据,所有数据都基于帐户。
Solana原生支持AA,允许在程序之间进行自调用。

原文:https://learnblockchain.cn/article/8416

Solana eBPF 虚拟机

也许您已经看到了最近关于 Solana 虚拟机 (SVM) 的所有炒作以及在其上构建的所有激动人心的项目。也许您甚至读过我们之前关于 SVM API 的博客文章。

无论哪种情况,您可能都在寻找有关 Solana 虚拟机的更多信息。本指南将带您了解 Agave 验证器对rBPF 虚拟机的使用,解释它是什么、它如何工作以及验证器如何使用它来执行 Solana 程序。

rBPF:用于 eBPF 程序的 Rust 虚拟机和 JIT 编译器

rBPF VM

rBPF虚拟机是Quentin Monnet创建的扩展伯克利包过滤器 (eBPF) 虚拟机的 Rust 实现。在 Solana 早期,rBPF 项目在 Solana Labs 下分叉并进行了轻微修改以支持自定义 Solana 特定功能,这将在后面的部分中介绍。如今,rBPF 分叉由 Anza 工程师维护。

如存储库的 README 文件中所述,rBPF VM 旨在在用户空间而不是内核中运行。这使得 rBPF 成为 Solana 验证器用于执行程序的虚拟机的理想候选者,因为验证器的运行时在节点的用户空间中运行。

流行的术语“SVM”实际上有点用词不当。在整个生态系统中,当 Solana 开发人员提到 Solana 虚拟机 (SVM) 时,他们通常指的是 Solana 运行时内的整个事务处理管道或执行层。然而,负责执行 Solana 程序的实际虚拟机是一个 eBPF VM,受到 Solana 虚拟机指令集架构 (SVM ISA) 的限制。

Solana rBPF 是实现 SVM ISA 的 Rust 虚拟机,由 Agave 验证器使用。例如,Firedancer 有一个完全重新实现的虚拟机版本,它遵循 SVM ISA。

虚拟机本身也可以访问 Solana 协议定义的一组系统调用(“sycalls”),后面的部分会介绍这些系统调用。这些系统调用也是对较低级别的 Solana 虚拟机环境施加的约束的一部分。

伯克利包过滤器

Solana 程序被编译为伯克利数据包过滤器(BPF) 格式。BPF 最初是为伯克利软件发行版(BSD) Unix 系统设计的,用于过滤操作系统内核中的网络数据包。该格式利用类似于鉴别器的限定符,允许高效过滤数据包而无需复制数据。

BPF 程序使用这些限定符来定义数据包被捕获或丢弃的条件。它们定义了一组对寄存器、内存或数据包数据进行操作的指令(操作码)。

BPF 最终演变为扩展伯克利数据包过滤器(eBPF) 格式,Solana 的 LLVM 现已将其编译为该格式。eBPF 允许程序配置专为安全内核执行而设计的受限指令集和约束。这对 Solana 程序非常有用,因为它可以防止验证器崩溃并为所有程序创建一致的环境。

Solana 程序通常用 Rust 编写,然后由Solana 平台工具编译为 eBPF 。但是,程序也可以用Zig 、C 或汇编编写。平台工具确保程序被编译为正确的 eBPF 格式,以遵守 Solana VM 施加的 eBPF 限制,下一节将对此进行描述。

Solana rBPF ISA

如上一节所述,eBPF 允许平台(例如虚拟机)为 eBPF 程序施加严格的指令集和约束。Solana rBPF 存储库的指令集架构(ISA) 正是对此进行了定义。所有 Solana 虚拟机都必须遵守此 ISA,才能符合 Solana 协议。

第一部分介绍rBPF VM 支持的注册表。它们有 64 位宽,这意味着它们可以容纳 64 位整数或地址

name feature set kind Solana ABI
r0 all GPR Return value
r1 all GPR Argument 0
r2 all GPR Argument 1
r3 all GPR Argument 2
r4 all GPR Argument 3
r5 all GPR Argument 4
or stack spill ptr
r6 all GPR Call-preserved
r7 all GPR Call-preserved
r8 all GPR Call-preserved
r9 all GPR Call-preserved
r10 all Frame pointer System register
r11 from v2 Stack pointer System register
pc all Program counter Hidden register

寄存器是存储当前正在操作的数据的小型内存位置。ISA 定义了十个通用寄存器 (GPR)。

  • r0保存函数的返回数据。
  • r1通过r5存储函数参数,并且r5实际上可以存储“溢出”数据,这些数据由指向某些堆栈数据的指针表示。
  • r6到 都是r9调用保留寄存器,这意味着它们的值在函数调用之间得以保留。

除了 GPR 之外,还有一个帧指针 ( r10),它引用内存中的当前堆栈帧,一个堆栈指针 ( r11),它跟踪堆栈顶部的位置,以及一个程序计数器 ( pc),它保存正在执行的当前指令的地址。

下一节将介绍指令布局。如文档中所述,字节码以 64 位槽位编码,指令可以占用一个或两个槽位,由第一个槽位的操作码指示。

+-------+--------+---------+---------+--------+-----------+
| class | opcode | dst reg | src reg | offset | immediate |
| 0..3 | 3..8 | 8..12 | 12..16 | 16..32 | 32..64 | Bits
+-------+--------+---------+---------+--------+-----------+
low byte high byte

bit index meaning
0..=2 instruction class
3..=7 operation code
8..=11 destination register
12..=15 source register
16..=31 offset
32..=63 immediate

指令布局精确涵盖了指令在虚拟机中的编码方式以及每个位的含义。

  • 指令类:标识指令的类型(算术、内存访问等)。
  • 操作码:具体操作本身。
  • 目标寄存器:存储运算结果的寄存器。
  • 源寄存器:运算输入数据的来源寄存器。
  • 偏移量:用于内存访问或者跳转偏移量。
  • 立即数:常量值。

下一节将介绍 rBPF VM 支持的所有操作码。文档中提供的表格详细列出了 VM 支持的每个操作码,其中行标签是操作码的高四位,列标签是操作码的低四位。

在操作码之后,ISA 定义了一个“按类别划分的指令”部分,其中定义了有关特定操作及其约束的细节。例如,它涵盖了 32 位和 64 位算术、乘法、除法、余数、内存访问和控制流。对于每个部分,都提供了有关预期恐慌的具体信息。这些是前面提到的 eBPF 约束,它们在 SVM ISA 中明确定义。

请注意,ISA 中存在 32 位和 64 位算术定义并不意味着虚拟机可以在 32 位和 64 位架构上运行。这些部分专门定义了算术运算,这些运算可能使用 32 位进行内存优化,或在必要时使用 64 位。

ISA 中定义的恐慌相当简单。对于除法,它将除以零和负溢出定义为恐慌情况。对于内存访问,它引用越界或访问违规(即写入只读部分)。最后,对于控制流,提到了越界、对未注册函数的引用和堆栈溢出。

最后,验证部分定义了验证 eBPF 程序的规则,该规则涉及程序二进制文件的静态分析。总之,这构成了 Solana 虚拟机的整个 eBPF VM ISA 定义。

Solana VM 内置程序(加载器)

当 eBPF VM 加载二进制文件时,已编译 eBPF 程序中的函数会被读入所谓的函数注册表。不过,rBPF VM 支持所谓的“内置程序”,这些程序也有自己的函数注册表。

您可能熟悉 Solana 运行时中的这个术语,它使用内置(有时称为“本机”)程序。这两个术语的设计相同,因为两者具有一些相同的行为。Solana 本机程序为运行时提供的功能与 rBPF 虚拟机内置程序为执行 BPF 程序提供的功能相同:访问执行环境中内置的函数。

当 Solana 运行时遇到内置程序的指令(例如系统传输)时,它不会加载和执行某个已编译的 BPF 程序,而是简单地调用运行时内置的函数来执行传输。这个内置函数是系统程序,其代码实际上随 Solana 运行时一起提供,是其环境不可或缺的一部分。这些程序不存在于链上,而是在其相应地址处具有链上占位符。

类似地,在 rBPF 虚拟机环境中,执行程序实际上可以定义调用内置函数的指令。这些函数(就像运行时的内置程序一样)内置在 VM 中。

// <https://github.com/solana-labs/rbpf/blob/9d1a9a0c394e65a322be2826144b64f00fbce1a4/src/vm.rs#L365>
impl<'a, C: ContextObject> EbpfVm<'a, C> {
     /* ... */
     pub fn execute_program(
   &mut self,
   executable: &Executable<C>,
   interpreted: bool,
 ) -> (u64, ProgramResult)
}

// <https://github.com/solana-labs/rbpf/blob/7364447cba1319e8b63d54b521776439181853a7/src/elf.rs#L249>
pub struct Executable<C: ContextObject> {
 /* ... */
 function_registry: FunctionRegistry<usize>,
 loader: Arc<BuiltinProgram<C>>,
}

// <https://github.com/solana-labs/rbpf/blob/7364447cba1319e8b63d54b521776439181853a7/src/program.rs#L214>
pub struct BuiltinProgram<C: ContextObject> {
    /* ... */
    functions: FunctionRegistry<BuiltinFunction<C>>,
}

VM 级内置程序(允许可执行程序访问内置函数集)称为加载器。VM 内置函数有很多种,但 VM 加载器提供的主要函数是系统调用(或“syscall”)。

Solana 系统调用允许执行 eBPF 程序来调用其编译字节码之外的函数(内置于虚拟机中),以执行许多操作,例如:

  • 打印日志消息
  • 调用其他 Solana 程序(CPI)
  • 执行加密算术运算

与虚拟机的 ISA 类似,所有 Solana 系统调用都是 Solana 协议的一部分,并且具有明确定义的接口。这些接口的更改以及新系统调用的引入均受 Solana 改进文档 (SIMD) 流程的管理。

Agave 验证器在 BPF Loader 上实现了所有 Solana 系统调用,BPF Loader 是提供给 VM 的加载器机制。BPF Loader 已有多个版本,包括目前正在开发的Loader v4

Solana BPF Loaders 也是运行时内置程序(类似于 System 程序),由运行时调用。实际上,当链上 eBPF 程序被指令调用时,运行时实际上会调用拥有它的 BPF Loader 程序来执行它。稍后将详细介绍这一点。

程序执行

rBPF VM 库可以通过两种方式执行 eBPF 程序:通过解释器或使用 JIT 编译为 x86_64 机器代码。

解释器只是逐条执行每条指令,在运行时解释并执行每条指令。这可能会增加一点运行时开销,因为解释器必须在运行时确定每条指令的作用,然后才能执行它,但好处是加载时间大大减少。

相反,将程序实时(JIT)编译为 x86_64 机器代码可以使程序的执行速度更快,但由于初始编译,加载时间会更长。

Agave 目前使用 JIT 编译有几个原因。首先,系统调用目前是动态注册的,这意味着它们无法在[静态分析](https://seahorn.github.io/seahorn/crab/static analysis/linux extensions/ebpf/2019/07/04/seahorn-ebpf.html)期间由验证步骤处理,而是被标记为“未知”的外部函数调用。在 JIT 编译步骤中,程序二进制文件中的系统调用函数引用会链接到其注册的内置函数。

Agave之旅

了解了 rBPF 虚拟机如何工作的一般背景后,现在是时候了解一下 Agave 验证器,并确切了解当用户发送包含链上程序指令的交易时,rBPF VM 是如何用于执行 Solana 程序的。

项目部署

在开始了解 Agave 的指令处理管道之前,重要的是了解开发人员部署 Solana 程序时发生的情况。

程序部署是通过调用 BPF Loader 程序来完成的,如前所述,这是一个内置程序。作为内置程序,它允许程序访问额外的计算资源,从而实现验证请求部署的程序所必需的几个关键步骤。

solana program deploy例如,当您运行 CLI 命令时,CLI 将发送一组事务,这些事务将首先分配一个缓冲区帐户并将程序的 ELF 文件写入其中。ELF 非常大,因此这需要经过几个事务才能完成,其中 ELF 会被分块。缓冲区包含整个 ELF 后,即可“部署”程序(最后的 CLI 指令)。

当 BPF Loader 程序的“部署”指令被调用时,它将尝试验证存储在提供的缓冲账户中的 ELF,如果成功,则将 ELF 移入程序账户并将其标记为可执行。只有在成功验证之后,程序才能被 Solana 交易指令调用。

程序 ELF 的验证在 BPF Loader 的中已经做了很好的描述。步骤如下:deploy_program!

  1. 使用“严格”运行时环境将程序加载为 eBPF 可执行文件。此步骤中“严格”环境的目的是防止部署带有弃用 ELF 标头或系统调用的程序。这使用rBPF 中的方法,该方法验证 ELF 文件结构并执行指令重定位。load
  2. 根据 ISA 验证已加载的执行程序字节。这使用rBPF 中的方法。verify
  3. 使用当前运行环境重新加载程序。

ELF 验证是程序部署中非常重要的一步,因为它直接关系到虚拟机 ISA 设定的期望。BPF Loader 程序实际上将使用 rBPF 库提供的 eBPF 验证工具来验证程序二进制文件,确保它不违反任何约束。

这意味着只有有效的 Solana eBPF 程序二进制文件才能成为活动的 Solana 程序。从性能角度来看,这允许运行时通过简单检查它是否是可执行程序来快速丢弃无效的 Solana 程序二进制文件,因为它只有在通过部署验证后才能变为可执行程序。

交易管道

如前所述,运行时只有在成功部署并验证后才会遇到可执行的 BPF 程序。考虑到这一假设,可以通过 Agave 中的交易管道跟踪有效链上 BPF 程序的交易指令的生命周期。

交易由调度程序调度处理,最终通过Bank实例进行处理。Bank 使用 SVM API 的交易批处理器(具体方法是 )来处理交易。load_and_execute_sanitized_transactions

给定一批交易,处理器将首先评估是否有支付交易费用所需的账户。然后,它将过滤任何可执行程序账户,以供程序 JIT 缓存加载。

程序JIT 缓存仅仅是已经过 JIT 编译为 x86_64 机器代码(如前所述)并准备执行的程序的缓存。程序缓存的最大职责实际上是跨分支加载程序的正确版本,同时考虑到部署或关闭后可能出现的冲突版本。

不久之后,所有必要的账户都会被加载以处理交易。然后,处理交易,如果它是有效交易,则执行。执行交易涉及许多相邻的小步骤,但在本练习中,我们可以主要关注针对已加载的 BPF 程序执行指令的路径。

首先需要的是实例。这是特定于 Agave 的上下文对象,其中包含 rBPF VM 所需的许多特定于 Solana 协议的上下文配置。事实上,rBPF VM 本身是某些上下文对象的通用对象InvokeContext

/// Main pipeline from runtime to program execution.
pub struct InvokeContext<'a> {
    /// Information about the currently executing transaction.
    pub transaction_context: &'a mut TransactionContext,
    /// The local program cache for the transaction batch.
    pub program_cache_for_tx_batch: &'a mut ProgramCacheForTxBatch,
    /// Runtime configurations used to provision the invocation environment.
    pub environment_config: EnvironmentConfig<'a>,
    /// The compute budget for the current invocation.
    compute_budget: ComputeBudget,
    /// Instruction compute meter, for tracking compute units consumed against
    /// the designated compute budget during program execution.
    compute_meter: RefCell<u64>,
    log_collector: Option<Rc<RefCell<LogCollector>>>,
    /// Latest measurement not yet accumulated in [ExecuteDetailsTimings::execute_us]
    pub execute_time: Option<Measure>,
    pub timings: ExecuteDetailsTimings,
    pub syscall_context: Vec<Option<SyscallContext>>,
    traces: Vec<Vec<[u64; 12]>>,
}

在此调用上下文中,将处理事务的消息,在此期间将逐条执行每条指令。对于每条指令,将使用 eBPF VM 直接或间接调用目标程序。下一节将介绍调用样式之间的关系。

调用 BPF 程序

在运行时调用 BPF 程序的过程相当复杂。然而,本节将分解该过程,以阐明在 Agave 源代码中可以发现的各种细微差别。

首先,再次回顾一下 Solana 内置程序非常重要。正如我们提到的,这些程序内置于运行时中,因此它们不需要 eBPF 虚拟机即可执行。但是,无论如何都会使用一个。

使用 eBPF VM 执行运行时内置程序主要是为了在内置程序和 BPF 程序之间强制使用一致的接口。这个通用接口称为程序入口点。

// Psuedo-code Rust interface
fn rust(
    vm: &mut ContextObject,
    arg_a: u64,
    arg_b: u64,
    arg_c: u64,
    arg_d: u64,
    arg_e: u64,
    memory_mapping: &mut MemoryMapping,
) -> Result

回到 Agave 事务管道之旅,我们在 处停了下来InvokeContext。所有指令都由方法InvokeContext内的处理。在此方法中,仅直接调用内置程序。process_executable_chain

首先,运行时确定哪个加载器拥有目标程序。如果是本机加载器,则目标程序是内置程序。如果它是 BPF 加载器之一(所有 BPF 程序都归其所有),则调用该特定的 BPF 加载器内置程序来实际调用目标 BPF 程序。此步骤只是获取要使用的正确加载器 ID。

let builtin_id = {
    let borrowed_root_account = instruction_context
        .try_borrow_program_account(self.transaction_context, 0)
        .map_err(|_| InstructionError::UnsupportedProgramId)?;
    let owner_id = borrowed_root_account.get_owner();
    if native_loader::check_id(owner_id) {
        *borrowed_root_account.get_key()
    } else {
        *owner_id
    }
};

接下来,从加载器的函数注册表中获取对加载器的入口点函数(前面介绍的接口)的引用。这将用于调用内置加载器。

// The Murmur3 hash value (used by RBPF) of the string "entrypoint"
const ENTRYPOINT_KEY: u32 = 0x71E3CF81;
let entry = self
    .program_cache_for_tx_batch
    .find(&builtin_id)
    .ok_or(InstructionError::UnsupportedProgramId)?;
let function = match &entry.program {
    ProgramCacheEntryType::Builtin(program) => program
        .get_function_registry()
        .lookup_by_key(ENTRYPOINT_KEY)
        .map(|(_name, function)| function),
    _ => None,
}
.ok_or(InstructionError::UnsupportedProgramId)?;

再往下几行就是最终创建 eBPF VM 的地方。但是,这个 VM 只是一个模型。使用模拟 VM 可以强制遵守接口,还允许运行时将内置程序作为 rBPF 内置函数(或系统调用)调用。

let mock_config = Config::default();
let empty_memory_mapping =
    MemoryMapping::new(Vec::new(), &mock_config, &SBPFVersion::V1).unwrap();
let mut vm = EbpfVm::new(
    self.program_cache_for_tx_batch
        .environments
        .program_runtime_v2
        .clone(),
    &SBPFVersion::V1,
    // Removes lifetime tracking
    unsafe { std::mem::transmute::<&mut InvokeContext, &mut InvokeContext>(self) },
    empty_memory_mapping,
    0,
);
vm.invoke_function(function);
在 rBPF 内部,该方法仅调用 Rust 接口,而对其调用的实体一无所知。在本例中,它是一个 Solana 内置程序。invoke_functionEbpfVm
/// Invokes a built-in function
pub fn invoke_function(&mut self, function: BuiltinFunction<C>) {
    function(
        unsafe {
            std::ptr::addr_of_mut!(*self)
                .cast::<u64>()
                .offset(get_runtime_environment_key() as isize)
                .cast::<Self>()
        },
        self.registers[1],
        self.registers[2],
        self.registers[3],
        self.registers[4],
        self.registers[5],
    );
}

您可能想知道:如果运行时仅使用模拟的 eBPF VM 调用内置函数,那么我的指令的实际目标 BPF 程序在哪里被调用?答案从运行时代码中无法立即看出,因为该机制实际上是 BPF Loader 程序处理器的一部分。

如上所述,当 BPF 程序被指令瞄准时,运行时将调用其所有者,即 BPF 加载程序之一。BPF 加载程序的处理器将确定它收到了哪种类型的指令。这可以是程序帐户管理指令(即升级、关闭)或 BPF 程序的调用。

如果 BPF Loader 程序的账户在指令上下文中存在,处理器就会推断该指令是针对 BPF Loader 程序的。相反,如果推断目标程序是 BPF 程序,则会调用 BPF Loader 的函数。execute

pub fn process_instruction_inner(
    invoke_context: &mut InvokeContext,
) -> Result<u64, Box<dyn std::error::Error>> {
    /* ... */
    let program_account =
        instruction_context.try_borrow_last_program_account(transaction_context)?;

    // Program Management Instruction
    if native_loader::check_id(program_account.get_owner()) {
        /* ... */
        return {
         /* more logic ... */
         process_loader_upgradeable_instruction(invoke_context)
        }
    }

    // If the program account is not the BPF Loader program,
    // execute the BPF program.
}

BPF Loader 的功能包含真实executeeBPF VM的所有设置步骤,它将执行目标 BPF 程序。

执行 BPF 程序

如上一节所示,当使用 eBPF VM 调用 Solana 内置程序时,invoke_function将调用该方法,该方法会盲目调用内置函数。但是,当执行 BPF 程序时,运行时实际上会调用 VM 的方法。此时,正确设置 VM 变得至关重要。execute_program

Agave 运行时的介绍在上一节的 BPF Loader 函数中结束execute。在此功能中,将配置适当的 eBPF VM 并用于执行 BPF 程序。设置 VM 涉及四个重要步骤。

  1. 参数序列化
  2. 堆栈和堆的配置
  3. 内存映射配置
  4. 系统调用上下文配置

参数序列化是将规范程序参数序列化到虚拟机的内存区域(程序 ID、帐户信息、指令数据)的过程。在此步骤中,所有帐户、指令数据、程序 ID 都将被序列化,最终它们将由大多数 Solana 开发人员所熟知的 SDK 宏进行反序列化。entrypoint!

let (parameter_bytes, regions, accounts_metadata) = serialization::serialize_parameters(
    invoke_context.transaction_context,
    instruction_context,
    !direct_mapping,
)?;

接下来,配置程序内存的堆栈和堆。开发人员可以使用 Compute Budget 程序请求更多堆空间。

macro_rules! create_vm {
    /* ... */
    let stack_size = $program.get_config().stack_size();
    let heap_size = invoke_context.get_compute_budget().heap_size;
    let heap_cost_result = invoke_context.consume_checked($crate::calculate_heap_cost(
        heap_size,
        invoke_context.get_compute_budget().heap_cost,
    ));
    /* ... */
}

现在参数已序列化为内存区域,并且已配置堆栈和堆,所有这些区域都可用于构建主机内存到 VM 内存的内存映射。这会将这些新配置的区域与程序 ELF 中的区域结合起来,形成一个完整的映射,供 VM 使用。

最后,设置调用上下文中的系统调用上下文,用于存储账户字段的内存地址,以便提供更好的错误堆栈跟踪。

pub struct SyscallContext {
    pub allocator: BpfAllocator,
    pub accounts_metadata: Vec<SerializedAccountMetadata>,
    pub trace_log: Vec<[u64; 12]>,
}
pub struct SerializedAccountMetadata {
    pub original_data_len: usize,
    pub vm_data_addr: u64,
    pub vm_key_addr: u64,
    pub vm_lamports_addr: u64,
    pub vm_owner_addr: u64,
}
impl<'a> ContextObject for InvokeContext<'a> {
    fn trace(&mut self, state: [u64; 12]) {
        self.syscall_context
            .last_mut()
            .unwrap()
            .as_mut()
            .unwrap()
            .trace_log
            .push(state);
    }

    fn consume(&mut self, amount: u64) {
        // 1 to 1 instruction to compute unit mapping
        // ignore overflow, Ebpf will bail if exceeded
        let mut compute_meter = self.compute_meter.borrow_mut();
        *compute_meter = compute_meter.saturating_sub(amount);
    }

    fn get_remaining(&self) -> u64 {
        *self.compute_meter.borrow()
    }
}

ContextObjectrBPF特征在结构上的实现InvokeContext(前面提到过)显示了系统调用上下文用于提供跟踪的位置。InvokeContext还负责计量整个事务的计算单元 (CU)。该ContextObject方法consume在每个程序执行结束时调用,从而减少事务其余部分的 CU 计量。

VM 在每条指令上都会检查 CU 计量溢出,因此一旦达到最大 CU 预算,下一条指令将以 中止。这可以防止任何长时间运行的进程或无限循环通过恶意程序进程对 Solana 验证器执行拒绝服务攻击。它还会返回错误,导致在超出 CU 时交易失败。Error::ExceededMaxInstructions

完成这四个基本步骤后,就可以正确配置 eBPF VM执行程序了

Ok(EbpfVm::new(
    program.get_loader().clone(),
    program.get_sbpf_version(),
    invoke_context,
    memory_mapping,
    stack_size,
))
let (compute_units_consumed, result) = vm.execute_program(executable, !use_jit);

rBPF 中程序的执行只是执行程序二进制文件中每个操作码的过程,直到程序执行完毕。如前文所述,这可以通过解释或执行 JIT 编译的二进制文件来完成。完成后,VM 将返回一个代码,其中零表示程序成功u64

VM 中的恐慌由rBPF 库中的 处理。在 Agave 运行时端,这些错误由转换为运行时类型。例如,如果系统调用抛出,则该错误通过 传递到 VM ,然后在运行时重新转换为其正确类型。对于大多数其他变体,将抛出臭名昭著的错误。EbpfError``InstructionError``InvokeContext``InstructionError``EbpfError::SyscallError(..)``InstructionError``EbfError``InstructionError::ProgramFailedToComplete

最后,虚拟机的返回代码通过运行时传播,最终产生交易指令的程序结果。这产生了许多 Solana 开发人员共同的交易结果,至此结束了对 Agave 的运行时和虚拟机的这次游览!

随着我们继续发展和优化运行时,Anza 仍然高度关注提高性能、优化计算单元和扩展功能。在Solana 改进文档流程中发表您的意见,参与讨论!

原文:https://www.anza.xyz/blog/the-solana-ebpf-virtual-machine

使用 setLoadedAccountsDataSizeLimit 进行 CU 优化

本文将探讨新的计算预算指令“ setLoadedAccountsDataSizeLimit ”,并帮助开发人员了解如何在生产中使用它。正确使用 setLoadedAccountsDataSizeLimit 有可能大幅减少许多高性能应用程序中使用的 CU 数量。

为什么要引入 setLoadedAccountsDataSizeLimit?

主要目标是使拥有低 CU 应用程序(例如钱包、嵌入式钱包提供商等)的开发人员能够通过减少其请求的加载帐户数据大小来提高其交易优先级。此优先级机制与新的计算预算规则相一致,该规则有利于优化数据使用的交易,这可能会影响 CU(计算单位)成本。

在添加此指令之前,交易默认加载最多 64MB 的账户数据,导致内存消耗巨大,每 32KB 收取 8 个 CU。这种低效的默认设置将额外花费 16K CU,最终降低交易优先级(计算为 (reward_to_leader)/total_CU)并增加成本。LoadedAccountsDataSizeLimit 指令通过允许开发人员(尤其是在 DeFi 等计算敏感型应用程序中)明确指定较小的数据大小限制来解决此问题。通过仅请求必要数量的账户数据而不是完整的 64MB 默认值,交易可以减少其 CU 消耗,提高其处理优先级,并可能在新的计算预算规则下实现更好的成本效率。

谁应该关注

这种优化对于处理代币转移和其他低 CU 操作的钱包提供商和嵌入式钱包解决方案(如 Phantom、Solflare、Backpack 和 Privy)尤其有价值。虽然账户数据使用量最少的一般用户可能不受影响,但实施 setLoadedAccountsDataSizeLimit 可以提高交易优先级而不影响费用。做市商和 MEV 机器人运营商也可以从最小化账户数据大小中受益,以提高每个区块内的交易优先级,使其成为简单操作和高性能应用程序的有用功能。

整合使用 setLoadedAccountsDataSizeLimit 可以显著减少 CU。

在 Node.js 中计算数据指令

以下是一段代码片段,演示了如何使用 Node.js实现新指令(在 GitHub 上查看):

import { getSetLoadedAccountsDataSizeLimitInstruction } from "@solana-program/compute-budget";
import {
  appendTransactionMessageInstruction,
  createTransactionMessage, 
  pipe,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
} from "@solana/web3.js";

const transactionMessage = pipe(
  createTransactionMessage({ version: 0 }),
  m => setTransactionMessageFeePayerSigner(feePayerSigner, m),
  m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
  m => appendTransactionMessageInstruction(
    getSetLoadedAccountsDataSizeLimitInstruction({
      accountDataSizeLimit: 32 * 1024, // 32KB limit
    }),
    m,
  ),
);

性能改进

通过设置setLoadedAccountsDataSizeLimit,开发人员可以请求降低其交易中帐户数据大小的默认限制。例如,默认数据限制为 64MB 是标准的,但根据公式“16K - 实际加载的帐户大小”,降低此限制(例如降低至 32KB)可以减少 CU 使用量。计算预算限制可改善 Solana 的区块处理。

假设代币转移的表现示例

下面您将看到一个假设场景,该场景通过示例令牌传输展示了正确使用 setLoadedAccountsDataSizeLimit 可能带来的实际性能改进:

  • 1 个签名
  • 3 个写锁(签名者,发件人地址,收件人地址)
  • 5 个账户(签名者、发件人地址、收件人地址、代币程序、计算预算程序)
  • 请求的计算预算限制为 8000 个 CU
  • 支付优先费:每 CU 1.25 lamports

我们进一步假设:

  • 该Token程序指令消耗6000CU
  • 2 条计算预算指令总共消耗 300 CU,实际执行成本 = 6000 + 300 = 6,300 CU
  • 所有帐户占用空间小于10M字节

以下列出可能发生的情况:

Metric Without Instruction With 10M Limit
Loaded Account Data Size Limit 64M 10M
Data Size Cost Calculation 64M * (8/32K) 10M * (8/32K)
Data Size Cost (CUs) 16,000 2,500
Reward to Leader Calculation (1 5000 + 1.25 8000)/2 (1 5000 + 1.25 8000)/2
Reward to Leader (lamports) 7,500 7,500
Transaction Cost Formula 1720 + 3300 + 8000 + 16000 1720 + 3300 + 8000 + 2500
Transaction Cost (CUs) 25,471 11,971
Priority Score 0.30 0.63

如果交易明确请求足够的字节来加载账户,优先级将增加一倍以上。

结束语

新的计算预算指令为开发人员提供了一种通过设置帐户数据大小限制来控制交易优先级的方法。虽然这是可选的,但它对于在拥挤环境中需要高优先级处理的低 CU 应用程序非常有用。

沟通和示例应针对高数据应用程序的开发人员,展示利用计算预算的最佳实践,包括 Solana 2.0 增强处理框架内的潜在成本差异和优先优势。

原文:https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit

深入了解 Solana 的 AccountsDB

什么是 AccountsDB

在 Solana 中,所有数据都存储在所谓的“帐户”中。Solana 上数据的组织方式类似于键值存储,其中数据库中的每个条目都称为“帐户”。AccountsDB 是一个由验证器存储的帐户数据库,用于跟踪帐户数据。因此,它是 Solana 的重要组成部分。

快照

当新的验证者启动时,它必须首先赶上链的当前状态;在 Solana 中,这是通过快照实现的。快照包含特定时隙的区块链的完整状态(包括所有帐户)。它们是从网络中的现有验证者请求/下载的,用于引导新的验证者(而不是从 Genesis 开始)。

快照包含两个主要组件:

  • 元数据文件,包含有关最新区块和数据库状态的信息
  • 帐户文件夹,其中包含保存帐户数据的文件。

下面是插槽处标准 Solana 快照的图表196493007,其中突出显示了两个组件。

注意:快照是以 Zstandard 格式压缩的 tar 存档形式构建和下载的,因此文件扩展名通常为.tar.zst。从快照加载时,第一步是将其解压缩并解压到如上所示的目录布局中。

帐户文件

快照中的主要组件之一是帐户文件,其中包含特定槽位的所有帐户数据。每个文件都以字节为单位组织成帐户列表。

注意:在 Rust 实现中,包含帐户的文件被称为 AppendVecs。然而,这个名字已经过时了,因为代码库中不再使用附加功能,所以我们将account files在本文中引用它们。

读取这些数据涉及:

  1. 将快照解压并解压到单独的账户文件中
  2. 将每个账户文件单独加载到内存中(通过mmap系统调用)
  3. 读取每个文件的整个长度并将其组织成 AccountsDB 代码可以使用的结构

账户文件格式如下:

注意:由于帐户的数据是可变长度数组,因此我们首先需要读取data_len变量,这样我们就知道要读入数据字段的字节数。在图中,帐户标题存储有关帐户的有用元数据(包括data_len),而帐户数据包含实际数据字节。

解析文件中所有帐户的伪代码如下:

账户索引

现在我们了解了账户文件是如何组织起来存储账户数据的,我们还需要通过账户的公钥来组织这些数据,即我们需要建立一个从给定公钥到文件中对应账户位置的映射。此映射也称为账户索引。

更具体地说,我们需要一个从公钥到 的元组的映射,(file_id, offset)其中file_id是要读取的帐户文件的名称, 是文件中帐户字节的索引。通过此映射,我们可以通过打开与 关联的文件并读取从 开始的字节来offset访问公钥的关联帐户。file_idoffset

帐户也可以跨槽更改(例如,快照可能包含跨不同槽的同一帐户的多个版本)。将每个帐户的公钥与多个文件位置的集合(或“Vec”)相关联。在 Agave 中,每个槽始终只有一个帐户文件。这意味着我们可以将帐户索引构造为Map<Pubkey, Vec<(file_id, offset)>>,它将从公钥映射到帐户引用集合。

要读取帐户的最新状态,我们还需要找到具有最高插槽的参考,这样我们也可以跟踪与每个相关联的插槽file_id。

由于 Solana 上有如此多的账户,索引可能会变得非常大,从而导致对 RAM 的需求很大。为了减少这些需求,Solana 使用了基于磁盘的哈希图

账户引用默认存储在磁盘上。当访问并从磁盘读取公钥时,其账户引用将缓存在基于 RAM 的哈希图中,以便以后更快地访问。

当读取给定公钥的账户时,一般流程如下:

  • 首先检查 RAM 索引,
  • 如果公钥不存在,请检查基于磁盘的索引,
  • 如果不存在,则表明该帐户不存在。

阅读帐户

如果我们想从特定帐户读取数据,并且我们知道该帐户的公钥,我们可以通过在帐户索引中查找其公钥来找到其在帐户文件中的位置。我们将从最近的插槽中选择帐户引用,然后从帐户文件中的该位置读取数据。验证器可以直接将此数据解释为帐户结构。

撰写账户

AccountsDB 的另一个关键组件是跟踪新帐户状态。例如,当验证器处理新的交易块时,结果就是在特定时隙中产生一批新帐户状态。然后需要将这些新状态写入数据库。

编写这些帐户的两个主要步骤包括:

  1. 将账户数据写入与槽关联的新账户文件中
  2. 更新索引以指向这些新帐户的位置(即,为文件中的每个帐户附加一个新的(slot,file_id,offset)项)

后台线程

当新的帐户状态写入数据库时​​,我们必须通过执行 4 个关键任务来确保有效利用内存

  • Flushing
  • Cleaning
  • Shrinking
  • Purging

Flushing

为了减少内存需求,数据会定期从 RAM 刷新到磁盘。

例如,索引的基于 RAM 的哈希图充当最近访问的数据的缓存,并且当该数据在一段时间内未被使用时,它会被刷新到磁盘以便为更新的帐户索引腾出空间。

另一个示例是,新帐户状态首先存储在 RAM 中,只有当相关插槽成为根时才会刷新到磁盘。这种方法减少了(慢速)磁盘写入次数,只写入根数据。

Cleaning

为了限制内存增长,我们必须清理旧数据。

例如,如果我们有一个账户的两个版本,一个在槽位10,另一个在槽位15,并且槽位15是根(即不会被回滚),那么我们可以删除槽位10的账户对应的账户和索引数据。

清理的另一个例子是当一个帐户的 Lamport 数量降至零时,在这种情况下,我们可以删除整个帐户。

注意:清理阶段会清除索引条目,但不会回收账户文件中被账户数据占用的存储区域。清理后,该区域将被视为账户文件中的垃圾数据,这会浪费存储空间和网络带宽。

Shrinking

账户清理后,账户文件将包含“活跃”和“死亡”账户,其中“死亡”账户已被“清理”,因此不再需要。当账户文件中活跃账户数量较少时,可以将活跃账户复制到较小的文件中,而不包含死亡账户,从而节省磁盘内存。这称为收缩。

Purging

最后,我们还可以清除从根链分叉出来的槽的整个帐户文件。例如,如果分叉的其中一个分支成为根,那么我们可以删除与非根分支相关的所有帐户文件。

实现细节

介绍了 AccountsDB 工作原理的高级组件后,接下来我们将深入探讨实现细节。

从快照加载

我们将从更详细的概述开始,描述验证器如何从快照加载。

解压快照

从网络中的对等点下载快照通常包括:

  1. 完整快照
  2. 增量快照

完整快照包括网络上某个特定时隙的所有账户。增量快照是较小的快照,仅包含与完整快照相比发生变化的账户。例如,如果网络位于时隙 100,则完整快照可能包含时隙 75 的所有账户,而匹配的增量快照可能包含时隙 75 和时隙 100 之间发生变化的所有账户。

完整快照的创建成本通常很高,因为它们包含网络上的所有帐户,而增量快照的创建成本相对较低,因为它们仅包含网络帐户的子集。鉴于这一现实,验证者的典型方法是每隔一段时间创建一次完整快照,并更频繁地创建/更新增量快照。

完整快照遵循以下命名约定格式:

snapshot-{FULL-SLOT}-{HASH}.tar.zst

例如,snapshot-10-6ExseAZAVJsAZjhimxHTR7N8p6VGXiDNdsajYh1ipjAD.tar.zst 是插槽 10 处的完整快照,其哈希值为6ExseAZAVJsAZjhimxHTR7N8p6VGXiDNdsajYh1ipjAD。

增量快照遵循命名约定格式:

incremental-snapshot-{FULL-SLOT}-{INCREMENTAL-SLOT}-{HASH}.tar.zst.

例如,incremental-snapshot-10-25-GXgKvm3NMAPgGdv2verVaNXmKTHQgfy2TAxLVEfAvdCS.tar.zst是一个增量快照,它基于来自插槽 10 的完整快照构建,并包含直到插槽 25 的帐户更改,并且具有的哈希值GXgKvm3NMAPgGdv2verVaNXmKTHQgfy2TAxLVEfAvdCS。

匹配快照和增量快照将具有相同的{FULL-SLOT}值。当验证器启动时,由于验证器可以一次下载多个快照,因此它将找到最新的快照和匹配的增量快照来加载并启动。

下载快照

要下载快照,验证者需要开始参与八卦以加入网络并识别其他节点。一段时间后,我们会寻找以下节点:

  • 具有匹配的碎片版本(即我们的网络版本/硬分叉与其匹配)
  • 拥有有效的 RPC 套接字(即,我们可以从它们那里下载)
  • 已经通过 gossip 分享了快照数据类型

快照哈希结构是一个八卦数据类型,其中包含:

  • 最大可用完整快照的槽位和哈希值
  • 以及可用增量快照的插槽和哈希列表

注意:快照哈希结构可以在此处查看。

然后,验证器开始下载快照,优先下载来自较高槽位的快照。如果我们有一个验证器列表trusted(启动时通过 CLI 提供),我们只会下载哈希值与受信任验证器哈希值匹配的快照。

然后对于每个节点,我们根据快照哈希结构中的信息构建快照的文件名:

  • 满的:snapshot-{slot}-{hash}.tar.zstd
  • 增量:incremental-snapshot-{base_slot}-{slot}-{hash}.tar.zstd

使用节点的 IP 地址、RPC 端口和文件路径,我们开始下载快照。我们会定期检查下载速度,确保速度足够快,或者尝试从另一个节点下载。

一旦验证器找到可用的快照,它将首先解压缩并解存档它们,从而形成如下所述的目录布局。

解压快照并构建相应银行和 AccountsDB 的主要代码路径通过运行调用的函数开始bank_from_snapshot_archives。

注意:银行是一种代表单个区块/槽的数据结构,它存储的信息包括父银行、其槽、其哈希值、区块高度等。
有关快照的规范,请参阅 Richard Patel 的《Solana 快照非正式指南》。

加载账户文件

解压快照的第一步是解压并加载账户文件,该过程发生在verify_and_unarchive_snapshots函数中。
在此函数中,会生成多个线程来从存档中解压和解包快照。解包快照后,会清理每个帐户文件,以确保它们包含有效帐户、映射到内存中并在数据库中进行跟踪。

注意:当验证器为某个槽位构建快照并打包这些帐户文件时,由于实现的原因,验证器将继续将帐户数据写入较新槽位的帐户文件中。由于此数据与快照的槽位不对应,因此应忽略此数据。这意味着,当从文件读取帐户时,我们需要从快照元数据文件(更具体地说是 AccountsDB 元数据)中读取长度,以了解要读取的有用数据的最后一个索引。
注意:在较旧的快照版本中,每个插槽可能有多个帐户文件,但在新版本中,每个插槽只有一个文件。
注意:在 Rust 实现中,帐户文件也称为storages和。stores

加载快照元数据

加载账户文件后,验证器使用中的 Bincode 格式从快照元数据文件中加载 AccountsDB 和银行元数据rebuild_bank_from_unarchived_snapshots。

注意:Bincode 是一种编码方案(https://github.com/bincode-org/bincode

银行元数据包括诸如纪元时间表、权益信息、区块高度等信息。AccountsDB 元数据包括诸如每个帐户文件中使用的数据长度、用于识别数据损坏的所有帐户的累积哈希值等信息。

注意:快照元数据文件位于/snapshots/{slot}/{slot}未存档快照中{slot}快照插槽的路径下。
注意:有关完整字段的列表,请参阅src/core/snapshot_fields.zigSig 存储库或参阅BankFieldsToDeserializeRustAccountsDbFields实现。

生成账户索引

使用 AccountsDB 元数据,验证器使用函数构建 AccountsDB 结构reconstruct_accountsdb_from_fields。然后使用 将帐户文件加载到数据库中AccountsDB :: initialize,并使用 生成索引AccountsDB :: generate_index。

索引的架构将索引划分为许多存储桶。每个存储桶都是一个迷你索引,仅代表一小部分账户。每个公钥都与基于公钥的前 N ​​位的特定存储桶相关联。每个存储桶都包含 RAM(使用简单的 HashMap -InMemAccountsIndex在代码库中称为)和磁盘存储(在代码库中称为Bucket使用 mmap'd 文件)

注意:RAM 映射使用 a作为其键,在 Rust 代码库中Vec<(Slot, File_id, Offset)>也称为 a 。slot list
注意:基于前 N 位的分组也类似于他们对 crds-shards 的方法(如本系列第 1 部分所述,涵盖 Gossip 协议)。
注意:Rust 代码中使用的 bucket 数量默认为 8192。

完整的架构如下图所示:

注意:为了确保在运行验证器时所有索引都存储在 RAM 中,您可以使用 --disable-accounts-disk-index

这些组件按照以下布局存储在 AccountsDB 结构中:

后台线程:刷新数据

由于基于 RAM 的索引存储了最近访问的索引数据,因此后台线程(在代码库中称为BgThreads)用于将旧索引数据刷新到磁盘,以便为更新的索引数据腾出空间。此刷新逻辑是使用AccountsInMemIndex :: flush_internal函数完成的。基于 RAM 的索引条目一旦“过期”,就会刷新到磁盘/存储桶内存中。年龄参数是根据我们希望将索引条目刷新到磁盘的频率配置的,并且与其插槽的根源时间以及其他一些原因有关。

基于磁盘的索引

RAM 索引是 RAM 内存支持的哈希图,而磁盘索引是磁盘内存支持的哈希图。虽然两者都是哈希图实现,但磁盘索引是在 Rust 存储库中从头实现的,解释起来更复杂。

磁盘索引的主要结构是Bucket包含BucketStorage存储基于文件的 mmap 文件的结构。此 mmap 文件将作为 hashmap 的后备内存。

为了支持 put、get 和 delete 方法,BucketStorage 需要实现一个名为 的特征BucketOccupied来识别哪些内存是空闲的,哪些内存被占用了。

这意味着BucketStorage商店:

  1. 一个 mmap 文件(mmap下图中的变量),以及
  2. 实现 BucketOccupied 的结构来跟踪哪些索引是空闲的/被占用的(contents下图中的变量)

代码库中有两种 BucketOccupied 特性的实现:

  • IndexBucket
  • DataBucket

每个Bucket实例包含一个IndexBucket和一个向量DataBuckets,如下所示:

当账户只有一个slot版本时,索引存储在 中IndexBucket。当账户有多个slot版本时,索引数据存储在 中DataBucket。

IndexBucket

在 mmap 文件中存储IndexBucket一个“IndexEntries”列表来表示索引数据,并使用BitVec存储每个索引的枚举来识别它是空闲的还是被占用的。

每个索引可能的枚举值包括:

  • Free:索引未被占用
  • ZeroSlots:索引已被占用,但尚未写入
  • SingleElement:只有一个插槽用于存储公钥,并且直接存储索引数据
  • MultipleSlots:为公钥存储了多个槽(并且索引信息存储在其中一个 DataBuckets 中)

下图解释了 IndexBucket 的组织结构:

请注意 mmap 文件如何包含 IndexEntries 列表,并且 BitVec 标识如何读取内容:

  • 图中第二条记录,BitVec的值为2,即OneSlotInIndex,因此single_element读取IndexEntry中的字段,直接获取账户参考值。
  • 在图表的最后一项中,BitVec 的值为 3,即MultipleSlots,因此multiple_slots读取 IndexEntry 中的字段,用于从数据存储桶中读取帐户引用(下一节将详细讨论)。

注意:由于有 4 个不同的枚举值可以有效地表示它们,我们只需要 2 位,这意味着我们可以使用 u64 来表示 64 / 2 = 32 个不同的索引。这就是BitVec结构的作用。

注意:完整实现类似于由磁盘内存支持的平面哈希图。对公钥进行哈希处理以获取开始搜索的索引,然后进行线性探测以找到匹配的密钥,但是,由于这是一个相对简单的实现,因此与 RAM 哈希图相比,它仍然很慢。

DataBuckets

当读取时IndexBucket,并且某个条目的 BitVec 值为MultipleSlots,则意味着 pubkeys 索引数据存储在 之一中DataBuckets。该MultipleSlots结构存储:

  • 存储在索引(字段中)中的插槽数num_slots,用于计算DataBucket要读取的 内容
  • 要读取的偏移量DataBucket(在offset字段中)。代码如下所示:

由于BucketStorage结构体包含 N DataBuckets,因此DataBucket将给定索引存储在哪个位置取决于公钥包含的以 2 的幂为增量的槽数。例如:

  • 包含 0-1 个槽的索引数据存储在第一个DataBucket
  • 包含2-3个槽的索引数据存储在第二个DataBucket
  • 包含4-7个槽的索引数据存储在第三个DataBucket
  • ...

查找pubkeys索引并读取对应值的逻辑写在了Bucket :: read_value中bucket_map/src/bucket.rs,读取对应值的代码如下:

生成索引

当验证器启动时,将使用generate_index_for_slot在每个帐户文件上调用的函数(并行)生成索引,以从文件中提取所有帐户并将每个帐户引用放置在其相应的索引箱中。

这也是生成二级索引的地方。

二级索引

考虑一下,如果你想获取数据库中具有特定所有者的所有帐户。为此,你需要遍历索引中的所有公钥,访问帐户文件中的帐户,并对帐户的所有者字段进行相等性检查。这就是getProgramAccounts代码中臭名昭著的 RPC 调用所做的事情。

如果有大量账户,这种线性搜索可能会非常昂贵,而且速度非常慢。

由于这种搜索非常常见,二级索引会存储具有特定所有者的公钥列表。这样,您便可以仅访问您知道具有特定所有者的公钥的完整帐户数据,从而使该过程更加高效。

功能、内置程序和系统调用

在对所有帐户进行索引后,将根据元数据创建银行,存储对完整 AccountsDB 的引用,并使用Bank :: finish_init激活功能和加载内置程序和系统调用的功能完成。

如果快照槽大于功能激活槽,则将激活功能。此逻辑在 中处理Bank :: apply_feature_activations。

一些示例功能包括:

  • blake3_syscall_enabled
  • disable_fees_sysvar
  • ETC

完整的功能列表可以FEATURE_NAMES在代码库中的变量下或此处的 GitHub wiki 页面上找到。

内置程序也添加到库中,Bank :: add_builtin所有内置程序都可以在BUILTINS变量名称下找到。这包括以下程序:

  • The system program
  • The vote program
  • The stake program

Bank :: fill_missing_sysvar_cache_entries最后,在加载时钟、纪元计划、租金等变量的 函数中加载系统变量。

阅读和写作记述

在所有账户都被索引之后,下一个要了解的重要部分是如何读取和写入账户

阅读帐户

在处理新区块时,验证器首先根据交易中定义的公钥加载相关账户。在搜索公钥的账户时,它会执行以下操作:

  • 通过考虑前 N 位来计算公钥应该属于的相应 bin
  • 然后搜索该 bin 的 RAM 内存
  • 如果不在 RAM 中,那么它会尝试在磁盘上查找
  • 如果它在磁盘上找到索引数据,它会将数据存储在 RAM 中,以便下次更快地查找

在Rust代码中,读取账户的代码路径从调用哪个函数开始,找到与公钥对应的索引数据。 AccountsDB :: do_load_with_populate_read_cacheAccountsDB :: read_index_for_accessor_or_load_slow

该函数首先找到相应的索引箱,然后调用InMemAccountsIndex:: get_internal它来搜索 RAM 和磁盘以查找索引数据。

主要流程的代码片段如下:

找到公钥的索引数据后,由于数据是跨不同槽的账户引用向量,因此使用最大的槽来标识账户的最新版本。

索引数据可以指向三个可能的位置

  • 在磁盘上的帐户文件中(可以使用file_id和偏移量查找)
  • 读缓存
  • 写缓存

我们已经讨论了如何从帐户文件中读取数据,但我们还没有讨论的两个位置是读取和写入缓存。读取缓存是另一个缓存层,在从帐户文件中读取帐户后,它会使用完整的帐户数据进行更新(不同于索引缓存)。 写入缓存包含尚未根化的插槽中的完整帐户数据(下一节将对此进行更多讨论)。

在代码中,使用并读取帐户retry_to_get_account_accessor,LoadedAccountAccessor :: check_and_get_loaded_account并为三个可能的位置中的每一个加载帐户。

撰写账户

处理完一组交易后,我们现在有了一组与特定槽相对应的新账户,这些账户我们想要存储和索引。为此,我们首先将账户存储到名为的写入缓存AccountsDB.accounts_cache中,并更新索引以指向缓存位置。

定期地,使用后台线程将此缓存(对应于根槽)中的帐户数据刷新到磁盘(下一节讨论)。

代码流从“store_cached”开始,它收集块中设置为write使用的所有账户collect_accounts_to_store,然后将它们存储在缓存中并使用函数进行索引store_cached_inline_update_index。

AccountsDB 后台服务

由于我们已经讨论了 AccountsDB 中的读/写工作方式,接下来我们将讨论 AccountsDB 如何确保内存得到有效利用。

这包括4个主要的后台任务:

  • Flushing 刷新:将数据从 RAM 写入磁盘
  • Cleaning 清理:查找旧帐户或零 Lamport 帐户并释放内存
  • Shrinking 缩减:缩减有大量死账户的账户文件
  • Purging 清除:删除分叉账户文件

Flushing,Cleaning,Shrinking均在AccountsBackgroundService结构中进行,

  • 获取当前根银行
  • 定期刷新根槽
  • 清理被清除的账户
  • 最后,运行收缩过程

主要代码解释如下:

下图解释了完整的流程:

Flushing

第一个后台任务是刷新,它从写入缓存中读取数据,并通过新帐户文件将与根槽关联的帐户推送到磁盘。每个根槽都会有一个关联的帐户文件,其中包含该槽中发生更改的帐户。

1)收集根槽和 2)将这些槽刷新到磁盘的完整代码路径从 开始bank.flush_accounts_cache()。
bank.flush_rooted_accounts_cache()通过读取来收集未刷新的根槽bank.maybe_unflushed_roots,以相反的顺序遍历根并使用将每个根刷新到磁盘。flush_slot_cache_with_clean

将账户存储到磁盘的主要功能是AccountsDB :: store_accounts_custom将其store_to参数设置为Disk,它首先计算存储所有账户所需的总字节数,分配一个足够大的新账户文件,然后将账户写入其中。

将帐户写入磁盘后,索引将更新以指向新的帐户位置AccountsDB :: update_index。

注意:请注意上面的函数flush_slot_cache_with_clean, 后缀为with_clean。这暗示该函数在冲洗时还执行清洁任务(下面将更全面地介绍),它确实执行了清洁任务。

注意:未刷新的根以相反的顺序迭代,因为我们只刷新第一次出现的公钥。例如,如果unflushed_roots = [12, 19] 两个根中都存在和一个公钥,我们将只刷新槽 19 中的公钥帐户。为了实现这一点,代码在名为 的闭包中使用了一个公钥的 HashSet,should_flush_f该闭包在刷新槽时填充。

注意:此清理仅考虑缓存中的根,因此从技术上讲它并不是完全清理

在将根帐户状态刷新到磁盘时,有时新的根状态可能会导致帐户死亡。在迭代帐户以更新索引时,将使用被视为死亡的旧帐户数据填充“回收”数组。将帐户文件视为“死亡”并将文件排队以进行收缩的逻辑位于 中AccountsDB :: handle_reclaims。

根据清理情况,可能存在没有或只有少量活跃账户的账户文件。如果活跃账户为零,则账户文件被视为已死,所有相应账户数据将被删除。如果活跃账户数量较少(“少量”由活跃账户中的字节数定义),则账户文件将添加到shrink_canidate数组中以排队等待收缩。

下面是解释整个过程的伪代码:

此后,新的插槽被添加到“uncleaned_roots”,稍后用于删除零lampor帐户。

Cleaning

清理会找到没有 Lamport 的账户或具有可以删除的旧历史记录的账户,从索引中删除这些账户,然后将足够小的账户文件排队以进行缩小。

代码路径以 开头,它首先通过读取解析函数中其他根槽的 (pubkey, slot) 元组的变量AccountsDB :: clean_accounts来收集所有需要清理的公钥。 uncleaned_pubkeysAccountsDB :: construct_candidate_clean_keys

有关 的更多信息unclean_pubkeys:一旦新区块完全处理完毕,银行就会使用 进行冻结bank.freeze(),除其他事项外,它还会计算bank_hash唯一标识该区块的哈希值。此哈希值是其他哈希值的组合,包括区块中修改的所有帐户的哈希值,称为account_delta_hash。为了计算此值,calculate_accounts_delta_hash将调用该函数,该函数还会将所有帐户的公钥添加到uncleaned_pubkeys变量中。由于仅在刷新后调用清理流程,因此此清理将处理最近刷新的所有帐户。

对于所有这些公钥,零 Lamport 账户和旧账户是分开处理的。

为了处理旧帐户,clean_accounts_older_than_root会调用清除小于根槽的槽(除了最近的根槽),从索引的槽列表中删除该值。如果在此过程之后槽列表为空,则从索引中删除该条目。

在此过程中,被删除的索引值将被跟踪reclaimed(与刷新过程相同),稍后将被删除的账户计入dead其各自的账户文件中,然后导致账户文件死亡或成为缩减的候选。

处理零 Lamport 账户时,将保存该账户的对应账户文件减alive_account_count一,将该账户定义为dead,并删除索引数据。

然后,与刷新流程的结束类似,对于每个账户文件,如果文件仅包含少量alive账户,则将其添加到shrink_canidates变量中以排队等待缩减。

Shrinking

收缩操作会查找活动数量较少的账户文件,然后删除死账户并重新分配文件,以减少使用的磁盘内存量。

缩减的代码路径从shrink_candidate_slots读取变量中存储的所有槽的函数开始shrink_candidate_slots。对于每个槽,do_shrink_slot_store都会调用该函数,该函数会alive在槽的帐户文件中查找帐户。

要确定帐户是否有效,alive它会在索引中查找 (pubkey, slot)。如果索引中不存在元组,则认为该帐户有效dead,并将其存储在unrefed_pubkeys变量中。否则,如果帐户有效(并且索引中存在元组),则将其添加到alive_accounts变量中。

收集所有活跃账户后,它会将这些账户写入磁盘中一个大小合适的新账户文件,并删除旧账户文件。

Purging

最后,当一个新的槽被根化时,作为分叉一部分的银行将从写缓存中清除,并且通过Drop在银行结构上实现特征来删除索引数据。

原文:https://www.anza.xyz/blog/a-deep-dive-into-solana-s-accountsdb

Anza 的 2025 年使命:迈向 100 万 TPS

本文探讨了 Agave 在过去一年中取得的一些关键进步以及我们 2025 年的路线图。TL ;DR – 我们今年将把区块空间增加一倍,而 Agave 未来将达到 1M TPS。增加带宽和减少延迟 (IBRL) 对于 Solana 保持竞争力至关重要。复杂系统不是通过一个神奇的修复来优化的,而是通过数千个微进步的积累来优化的。这就是为什么我们专注于加速改进为 Solana 网络提供支持的 Agave 客户端。

龙舌兰今日

由于主网采用了 Agave v2.1 版本,运营商和用户注意到了巨大的改进。跳过率(由于领导者节点违约或速度缓慢而跳过某个时隙的时间百分比)已降至 0.5% 以下。

时隙时间变得非常快,最近几个时期的平均时隙时间低于 400 毫秒。第 746 个时期 - 结束于 2025 年 2 月 24 日 5:16 UTC 的时期 - 在 47.5 小时内通过了 432k 个时隙,平均时隙时间低于 396 毫秒!

交易延迟也得到了类似的改善,典型的确认延迟在 1 秒以下!TPS 始终高于 4k 大关。

图片来自 Validators.app Ping Thing

但我们还没完……上图的另一条线显示,4800 万 CU 的人为限制始终是带宽的限制因素。计划是大幅提高这一上限,首先作为SIMD-0207的一部分适度增加到 50M 。

为什么不明天就将 CU 限制增加一倍?目前有两个主要问题正在积极解决:

  1. 确保围绕链的基础设施能够跟上。我们已经观察到 CEX 和最低规格的 RPC 节点难以跟上增加的 TPS。如果写入层的速度大大超过读取层,用户体验就会随之下降。为了解决这个问题,除了链性能本身之外,我们还积极对周围的基础设施进行 IBRL。
  2. 按时将块传送到集群。Agave 客户端面临的下一个最大瓶颈是将块从领导节点传送到集群的其余部分。在当前 CU 限制下,这不是问题,但随着块的字节数显著增加,某些工作负载的中位块分发时间可能会开始超过 400 毫秒。破解的 Anza 开发人员正在缓解这一瓶颈,同时确保块打包优先于最关键的资源并受到约束。

让我们深入探讨一些技术细节,以了解为何 Agave 今年能够如此迅速地扩张。

扩大龙舌兰 TPS:2025 年重点领域

2025 年,我们将推动 Agave 处理更高的交易吞吐量并实现更大的区块容量。让我们深入探讨几个具体的优化领域:

  1. TPU 调度程序优化:调度程序是任何验证器客户端的关键组件,因为它决定了区块生产者的交易处理顺序和优先级。Agave 将在此领域实施多项改进。
    1. 更智能的调度系统可以平衡相互冲突的问题,例如:优先考虑费用最高的支付者、避免账户冲突以及保持工作线程加载和忙于执行。新的贪婪调度程序算法将减少批处理,以确保执行线程获得稳定的工作流。这更好地匹配了实际的主网工作负载,它由更重的交换类型交易组成,而不是合成测试中使用的小而简单的交易。
    2. 减少内存分配,以避免页面错误时管道停顿。动态内存分配可能非常昂贵,尤其是当内存分配器尝试使用 madvise 系统调用将内存返回给内核时。减少总体分配有助于在返回此内存时最大限度地减少管道停顿。
    3. 将某些过滤职责推向管道的上层,以便更小、更精心策划的交易流进入调度程序。目前,按优先级进行过滤是在调度程序中完成的,这在其他昂贵的操作(例如签名验证)之后完成。这意味着可能会在永远不会被调度的低价值数据包上浪费大量精力。通过更早地进行过滤,可以节省大量验证器资源。

这些改进不仅会提高吞吐量,而且还会确保以更高效、及时和公平的方式处理交易。

  1. Turbine 带宽扩展: Turbine 是 Solana 的区块传播协议,其快速向验证者传递数据的能力对于性能至关重要。Agave 将专注于通过增强带宽来改进其涡轮设计的实现,从而实现更快的区块在网络中传播。通过优化涡轮性能,我们可以显著减少区块传播时间并提高整体网络效率,尤其是在交易量较高的时期。

目前的问题之一是计算涡轮树所需的时间,涡轮树使用权益加权混洗来确定给定节点需要发送到哪些对等节点。为每个单独的碎片计算一个唯一的树,并且必须在将碎片发送到下游节点之前进行。通过提高此计算的效率并在空闲期间推测性地计算未来碎片的树,碎片管道可以更加无缝地流动并缩短出块时间。

另一个问题与如何高效地将数据提供给网络接口卡 (NIC) 以便通过网络发送有关。通过减少中间内存副本,可以实现每秒发送的碎片数量增加。

  1. 状态缓存改进:状态缓存保存有关已处理交易的关键信息,并确保交易在给定分叉上仅处理一次。为了提高可扩展性,Agave 将引入更高效的状态缓存管理和锁定策略。这将允许更快的查找和插入,并产生更好的整体验证器性能。

    当前状态缓存使用全局锁。下一个版本将通过使用每个块哈希/槽的锁来使锁定更加细化,这将允许对状态缓存进行更多并行访问。

  2. 块存储插入增强功能:块存储碎片插入是区块链数据存储的一个关键方面。这很大程度上与块元数据的序列化、存储、反序列化和获取方式有关。Agave 将实施优化,以便更快、更高效地将块和元数据插入数据库,从而使系统能够随着交易量的增加而更有效地扩展。

    其中一个例子是块存储如何跟踪块完成。B 树中维护了一个碎片索引,以了解已收到给定块的哪些碎片。序列化和反序列化包含数千个索引的 B 树的成本很高。通过切换到简单的位图,可以显著减少每秒执行数千次的此操作的 SerDes 时间。

  3. 更快的哈希算法:更简单但更有影响力的优化之一是切换到更快的哈希算法。Agave 正在将不需要更高级保护的结构转换为 ahash,而不是默认的 siphash。Ahash 旨在实现更高的吞吐量和更好的性能,这将带来更快的交易处理和响应更快的区块链。这一变化有望显著减少哈希所花费的时间,并有助于实现更高的可扩展性。

查看这个Github 项目,查看计划的性能改进的完整列表并实时跟踪进度!

结论

Anza 的 2025 年路线图遵循了我们对 Agave 的愿景:使其成为 Solana 主网上最好的客户端。与 Firedancer 的合作也帮助两个团队改进了各自的客户端。团队之间的合作(现在每年涉及多次面对面会面)以及各个领域的深度协调,有助于展开和审查 Agave 代码库的每个部分。良性竞争推动两个团队创新并不断突破优化性能的极限。通过不懈地解决交易和碎片处理管道中的瓶颈,Anza 将应对 2025 年及以后的挑战,确保 Agave 和 Solana 保持快速、可靠和极具竞争力。

请继续关注更多更新,我们将继续对 Agave 进行迭代,以充分发挥硬件的所有性能、解锁更多的块容量,并使越来越多的用例能够上链!

原文:https://www.anza.xyz/blog/anzas-2025-mission-on-the-road-to-1m-tps

搜索