您正在查看: Surou 发布的文章

使用 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

修改 Solana 区块间隔时间

从《solana 的工作原理》,可以从区块构建过程了解到,每个插槽持续 400 毫秒,查看对应核心代码

// solana-sdk/clock/src/lib.rs

/// The expected duration of a slot (400 milliseconds).
pub const DEFAULT_MS_PER_SLOT: u64 = 1_000 * DEFAULT_TICKS_PER_SLOT / DEFAULT_TICKS_PER_SECOND;
pub const DEFAULT_S_PER_SLOT: f64 = DEFAULT_TICKS_PER_SLOT as f64 / DEFAULT_TICKS_PER_SECOND as f64;

如果参考atlas https://docs.atlas.xyz/architecture/differences_with_solana 将区块创建时间从400ms降低为50ms
从结果角度,有两个参数可以

  • DEFAULT_TICKS_PER_SLOT,默认值 64
  • DEFAULT_TICKS_PER_SECOND, 默认值160

解释:以 160 个 ticks/s 计算,每时隙 64 个 ticks 意味着领导者轮换和投票将每 400 毫秒发生一次。快速投票节奏可确保更快的最终性和收敛性

从Rollup集中validator的角度,应修改DEFAULT_TICKS_PER_SLOT,缩小8倍。

备注

v2.0.18 版本修改简单些,solana-sdk 没有拆分为单独的模块,直接修改代码即可
v2.2 版本solana-sdk 做了拆分子模块,需要fork下sdk, 然后push到 https://crates.io/ ,整体修改链代码将[workspace.dependencies] 中的solana-clock 替换为fork后的库名

Photon:Solana 上的 ZK 压缩索引器

Photon 是 Solana 区块链上ZK Compression的核心索引器。它提供快速索引功能、快照支持和灵活的数据库选项,以满足本地和生产部署的需求。

Github
https://github.com/helius-labs/photon
https://github.com/helius-labs/photon-explorer

Demo
https://photon.helius.dev/

运行
https://www.zkcompression.com/node-operators/run-a-node