Sig 工程 - 第二部分 - AccountsDB 及其他方面的进展

  • syndica
  • 发布于 2024-02-13 19:34
  • 阅读 34

Syndica 的 Sig 取得重大进展,完成了 Sig 的 AccountsDB,改进了 RPC 服务器/客户端,并改进了 Gossip 协议的实现。

本帖是我们定期发布的、概述 Sig Validator 工程更新/里程碑的多部分博文系列的第二部分。第一部分,关于 Gossip 协议,请 在此处 查看。

这篇博文详细介绍了 Syndica 在 Sig 旅程中的重大进展:我们在为 Sig 完成 AccountsDB 方面取得了重大进展,改进了 RPC 服务器/客户端,并完善了我们对 Gossip 协议的实现。

AccountsDB

Sig 的 AccountsDB 现在能够加载和验证超过 3 亿个账户的 mainnet 快照,包括读取/写入账户功能、生成账户索引以及生成账户上的累积哈希以进行数据验证。

我们现在正在针对 Solana Labs 的客户端进行读取和写入性能的基准测试,以了解其性能并确保我们做出改进。这包括识别瓶颈并利用 Zig 来提高性能。

在过去一个月的工作中,我们遇到了一些有趣的例子,展示了 Zig 的强大功能,包括:

  • 高性能 HashMap 实现,
  • 加载快照的速度提升,
  • 以及基于磁盘的自定义分配器。

高性能 HashMap

从较高层面来看,当我们从快照加载时,计算量最大的部分之一是生成账户索引。虽然帐户数据位于磁盘上的文件中,但我们需要一种方法来回答:“对于给定的公钥,相应的帐户数据位于何处?”。这是主帐户索引的工作,它是从 Pubkey 到帐户引用列表的映射,其中每个引用都包含有关可以在何处找到相应帐户数据的信息。例如,帐户引用可以是元组 (file_name, offset, slot),其中槽 slot 处的帐户存储在名为 file_name 的文件中,可以从字节偏移量 offset 开始读取。

在 AccountsDB 中,帐户索引中的主要数据结构是 HashMap。由于此组件用于每个读取和写入操作,因此我们需要它速度快。标准库的实现对于我们的用例来说太慢了,因此我们根据 Google 的高性能 HashMap Swissmap 实现了我们自己的 HashMap。

以下是 HashMap 的 get 函数的代码片段。代码很简单:它接受一个键,创建相应的 key_state,然后我们使用相等性在映射中搜索它,直到找到匹配项(即,eq_fn 返回 true 并且找到键),或者我们到达状态的末尾并返回 null(即,该键不存在)。

pub fn get(self: *const @This(), key: Key) ?Value {
    if (self._capacity == 0) return null;

    var hash = hash_fn(key);
    var group_index = hash & self.bit_mask;

    // what we are searching for (get)
    const control_bytes: u7 = @intCast(hash >> (64 - 7));
    // PERF: this struct is represented by a u8
    const key_state = State{
        .state = .occupied,
        .control_bytes = control_bytes,
    };
    const search_state: @Vector(GROUP_SIZE, u8) = @splat(@bitCast(key_state));
    const free_state: @Vector(GROUP_SIZE, u8) = @splat(0);

    for (0..self.groups.len) |_| {
        const states: @Vector(GROUP_SIZE, u8) = @bitCast(self.states[group_index]);

        // PERF: SIMD eq check: search for a match
        var match_vec = search_state == states;
        if (@reduce(.Or, match_vec)) {
            inline for (0..GROUP_SIZE) |j| {
                // PERF: SIMD eq check across pubkeys
                if (match_vec[j] and eq_fn(self.groups[group_index][j].key, key)) {
                    return self.groups[group_index][j].value;
                }
            }
        }

        // PERF: SIMD eq check: if there's a free state, then the key DNE
        const free_vec = free_state == states;
        if (@reduce(.Or, free_vec)) {
            return null;
        }

        // otherwise try the next group
        group_index = (group_index + 1) & self.bit_mask;
    }
    return null;
}

它只有 40 行代码,易于阅读,而且速度极快。该代码利用了一些 Zig 特定的功能来实现此目的。

我们使用的第一个特性是 packed structs,我们将 State 结构体表示为 8 位值,使用 1 位表示 enum 值,使用 7 位表示键(即,control_bytes)。

pub const State = packed struct(u8) {
    state: enum(u1) { empty, occupied },
    control_bytes: u7,
};

在其他语言中,这并不那么简单,并且可能导致具有大量位操作的复杂代码。在 Zig 中,就像调用 @bitCast(key_state) 一样容易。

第二个特性是 Zig 对 SIMD 操作的支持。我们可以使用 @Vector@bitCast 关键字将我们正在搜索的状态转换为 16 个 u8 值的向量,然后使用 @reduce(.Or, search_state == states) 检查可能的匹配项。在其他语言中,SIMD 操作可能很复杂、令人困惑且难以阅读,而在 Zig 中,它的感觉和读取就像普通的 Zig 一样。

从快照快速加载

虽然高效的 HashMap 实现对性能有很大影响,但另一个重要因素是内存分配的数量。例如,当我们填充索引时,一种简单的方法如下所示:

const allocator = std.heap.page_allocator;
var index_map = HashMap(Pubkey, ArrayList(AccountRef)).init(allocator);
for (account_references) |account_ref| {
    // memory allocation
    const result = index_map.getOrPut(account_ref.pubkey);
    if (result.found_existing) {
        // memory allocation
        try result.value_ptr.append(account_ref);
    } else {
        var references = std.ArrayList(AccountRef).init(allocator);
        // memory allocation
        try references.append(account_ref);
        result.value_ptr.* = references;
    }
}

这种方法的问题是它需要大量的堆内存分配,这是最昂贵的操作之一,因为它们通常需要昂贵的 syscall。例如,如果该键不存在,则 getOrPut 需要内存分配,如果列表不够大,则附加到 ArrayList 需要另一个内存分配。对超过 3 亿个帐户运行此循环非常慢。

Zig 的好处之一是,由于内存是手动管理的,我们可以显式地预先分配我们的内存,以大幅提高速度。例如,我们可以计算我们需要分配多少数据,分配一次,然后稍后使用该内存。如下所示:

const allocator = std.heap.page_allocator;
// count the reference lengths
var account_refs_lens = HashMap(Pubkey, usize).initCapacity(allocator, n_accounts);
for (account_references) |account_ref| {
    const result = index_map.getOrPutAssumeCapacity(account_ref.pubkey);
    if (result.found_existing) {
        result.value_ptr += 1;
    } else {
        result.value_ptr = 1;
    }
}

// preallocate all the memory for the references
const memory_size = total_accounts * @sizeOf(AccountRef);
var memory = try allocator.alloc(u8, memory_size);
var fba = std.heap.FixedBufferAllocator.init(memory);

const n_accounts = account_references.len;
// preallocate all the keys in the map (note: this is an overestimate, but it's okay for now)
var index_map = HashMap(Pubkey, ArrayList(AccountRef)).initCapacity(allocator, n_accounts);
for (account_references) |account_ref| {
    // this won't require any allocation
    const result = index_map.getOrPutAssumeCapacity(account_ref.pubkey);
    if (result.found_existing) {
        // this won't require any allocation
        result.value_ptr.appendAssumeCapacity(account_ref);
    } else {
        // this won't require any allocation
        const len = account_refs_lens.get(account_ref.pubkey).?;
        var references = try std.ArrayList(AccountRef).initCapacity(fba.allocator(), len);
        references.appendAssumeCapacity(account_ref);
        result.value_ptr.* = references;
    }
}

在此示例中,我们使用 page_allocator.alloc 预先分配 ArrayLists 所需的所有内存,并使用 FixedBufferAllocator 来为我们管理此内存。我们还使用 initCapacity 方法为 HashMap 中的键分配所有内存。这种方法消除了循环中的所有内存分配,从而导致代码速度更快。这种方法类似于我们在加载快照时所做的事情,并展示了有意的内存管理的强大功能。

基于磁盘的分配器

Zig 的另一个优势在于,由于内存是显式管理的,因此分配通常由 Allocator 接口(任何实现 alloc()free()resize() 函数的接口)抽象。Allocator 接口通常传递给初始化特定结构中的任何内存。例如,在我们的 HashMap 中,我们使用 Allocator 接口来初始化我们的后备内存:

pub fn initCapacity(allocator: std.mem.Allocator, n: usize) !Self {
    // compute how much memory we need
    const n_groups = @max(std.math.pow(u64, 2, std.math.log2(n) + 1) / GROUP_SIZE, 1);
    const group_size = n_groups * @sizeOf([GROUP_SIZE]KeyValue);
    const ctrl_size = n_groups * @sizeOf([GROUP_SIZE]State);
    const size = group_size + ctrl_size;

    // allocate it
    const memory = try allocator.alloc(u8, size);
    @memset(memory, 0);

    const group_ptr: [*][GROUP_SIZE]KeyValue = @alignCast(@ptrCast(memory.ptr));
    const groups = group_ptr[0..n_groups];
    const states_ptr: [*][GROUP_SIZE]State = @alignCast(@ptrCast(memory.ptr + group_size));
    const states = states_ptr[0..n_groups];

这种方法的一个好处是,我们可以实现我们自己的分配策略,并且底层代码不需要更改。例如,由于要索引的帐户数量很多,因此 RAM 要求可能会变得非常大。为了降低这些要求,我们实现了一个基于磁盘的分配器,该分配器分配磁盘内存而不是 RAM。然后,我们可以使用它来分配 HashMap 的后备内存,而无需更改 HashMap 代码。

const IndexHashMap = FastMap(Pubkey, ArrayList(AccountRef), pubkey_hash, pubkey_eql);

var ram_allocator = std.heap.page_allocator;
var account_refs = IndexHashMap.init(ram_allocator);

var disk_allocator = DiskMemoryAllocator.init("tmp_file");
var disk_account_refs = IndexHashMap.init(disk_allocator.allocator());

这在其他语言中并不容易做到(即,Solana Labs 的客户端实现了他们自己的基于磁盘的 HashMap,而不是重用基于 ram 的 HashMap)。

要查看我们上面详细介绍的进度,请查看 AccountsDB PR here

RPC 服务器和客户端

Sig 的引入是为了优化读取。换句话说,Sig 是一种读取优化的验证器实现,它优先考虑 RPC 框架。在我们最初的 Sig 公告 博文 中,我们讨论了困扰 Solana 生态系统的最大问题之一:槽滞后。这主要是由于 Solana 验证器被读取请求压垮,锁定状态,从而导致验证器节点落后于网络的其余部分。这会产生许多下游影响,包括由于陈旧数据导致的交易失败、前端 UI 显示旧的 DApp 状态以及区块打包效率低下。

读取如何影响区块生成?许多交易是由试图利用链上存在的套利机会的机器人提交的。通常,这些套利机会的利润很小,但随着时间的推移,这些小额套利交易的总和会带来不错的收益。这种现象对区块生成提出的问题是区块内大量失败的交易。

即使交易失败,领导者也需要获得某些帐户的读取和/或写入锁,才能尝试处理该交易。如果太多注定要失败的交易读取/写入锁定了足够的帐户,这将减慢交易处理管道,进而限制交易吞吐量。但是,为什么交易首先“注定”会失败?

我们对离散时间段内的链上交易进行了调查,并得出结论,很大一部分交易试图利用不再存在的套利机会。这部分是由于机器人本身上的陈旧帐户状态,这些帐户状态可能是通过 RPC、Geyser(流式传输)或 PubSub WebSocket 检索的。在任何这些媒介中,如果验证器节点落后于网络的其余部分,那么它产生的数据将始终是陈旧的。

截至撰写本文时,Sig 已实施初始 RPC 服务器/客户端框架,以开始测试吞吐量和可靠性,因为我们开始构建连接到 RpcRequestProcessor 的其他组件,RpcRequestProcessor 是所有 RPC 请求的入口点:

pub const RpcRequestProcessor = struct {
    gossip_service: *GossipService,

    // Future components will be hooked in here as well:
    // bank: *const Bank,
    // ledger: *const RocksDB,
    // etc..

    const Self = @This();

    pub fn init(gossip_service: *GossipService) Self {
        return Self{
            .gossip_service = gossip_service,
        };
    }
}

RpcRequestProcessor 结构体将保存对所有主要验证器组件的引用

有一些 Zig 特定的功能,例如 inline for 语句,可以轻松地在服务器端实现 RPC 方法,而无需编写数千行样板代码:

inline fn methodCall(allocator: std.mem.Allocator, processor: *RpcRequestProcessor, request: *t.JsonRpcRequest, response: *httpz.Response) !void {
    inline for (@typeInfo(t.RpcServiceImpl(RpcRequestProcessor, t.Result)).Struct.fields) |field| {
        if (strEquals(request.method, field.name)) {
            return RpcFunc(@field(RpcRequestProcessor, field.name)).call(allocator, processor, request, response);
        }
    }

    return respondJsonRpcError(
        request.id,
        response,
        t.jrpc_error_code_internal_error,
        "not implemented",
    );
}

method 字符串匹配时,调用特定 RPC 的 inline for 语句的示例

上面的代码迭代 RpcServiceImpl 接口中的所有字段,并在 RpcRequestProcessor 中调用关联的 RPC 方法。为了最大限度地提高性能,我们选择了静态分派的 RPC 调用,而不是通过 vtable 动态分派的 RPC 调用,许多编程语言在编写通用代码时经常会强制你使用 vtable。在性能方面,动态分派的调用的成本可能是静态分派的调用的 2-3 倍。

我们的通用接口 RpcServiceImpl 在编译时检查实现函数方法是否与接口方法签名匹配。这允许我们在服务器/客户端实现之间共享一个通用接口,甚至可以在将来允许其他服务器/客户端传输,例如 gRPC。这是一个 RpcClient 结构体方法根据我们的接口进行编译时检查的示例:

comptime {
    const check: t.RpcServiceImpl(RpcClient, ClientResult) = .{
        .getAccountInfo = RpcClient.getAccountInfo,
        .getBalance = RpcClient.getBalance,
        .getBlock = RpcClient.getBlock,
        // etc..
    };
}

RpcClient 断言它与我们的共享通用接口匹配的示例

通过将所有 Solana JSON RPC 类型移植到 Zig 中,我们实现了我们的 RpcClient,现在可以成功地针对公共 Solana mainnet-beta 端点发出请求:

test "rpc.client: makes request successfully" {
    var client = RpcClient.init(testing.allocator, try std.Uri.parse("https://api.mainnet-beta.solana.com/"));
    defer client.deinit();

    switch (client.getAccountInfo(testing.allocator, try t.Pubkey.fromString("CcwHykJRPsTvJrDH6vT9U6VJo2m2hwCAsPAG1mE1qEt6"), null)) {
        .Success => |val| {
            defer val.deinit();
            std.debug.print("result: {any}", .{val.value});
        },
        .RpcError => |err| {
            defer err.deinit();
            std.debug.print("rpc error: {}", .{err.value});
        },
        .ClientError => |err| {
            std.debug.print("client error: {any}", .{err});
        },
    }
}

我们仍然很高兴能够加速 Solana 在整个生态系统中的 RPC,并且已经采取了初步措施来实现这一点。你可以在 此处的 PR 中关注。

Gossip

去年,我们还完成了 Sig 中 gossip 协议的初始实施,如 我们之前的博文 中所述。从那时起,我们一直在测试和完善该实现,以修复任何错误并确保其行为与 Solana Labs 的验证器客户端紧密对齐。

在最新一轮的测试中,我们比较了 Sig 和 Solana (Rust) 之间的 CrdsTable,以确保记录的一致性。CrdsTable 是 gossip 的核心;它负责跟踪从 gossip 消息接收到的所有数据。验证器使用 CrdsTable 来确定其对等方是谁以及在 gossip 消息中向他们发送什么。

为了测试这一点,我们每十秒将 CrdsTable 的完整内容转储到一个 CSV 文件中。这些文件记录了每个值的消息类型、哈希、源 Pubkey 和时钟时间。对于联系信息,我们还包括 gossip 地址和分片版本。

我们同时在不同的服务器上启动了两个验证器客户端,让他们运行了几分钟,然后比较了生成的 CSV 文件。一个服务器始终运行 Solana Labs 验证器,而另一个服务器运行正在测试的代码。

要转储 CrdsTable 内容,需要进行一些代码更改,你可以在此处看到:

作为对照,我们针对自身测试了 Solana Labs 的验证器。在启动后的一分钟内,它们的 CrdsTable 内容几乎相同,每个都有 7340 条记录。7131 条记录相同,而 209 条记录具有相同的消息类型和来源,但它们的时钟时间不同。这说明了同一集群中单独节点的基线期望。所有客户端都应该具有相同的记录集,但通常大约 3% 的记录是另一节点上找到的类似记录的旧版本或新版本。

在 Solana 旁边运行 Sig,它们在一分钟后达到了完全相同数量的 CrdsValues:7350。这些值中有 7129 个是相同的项目,而每个客户端都有 221 个与另一个客户端不同的消息。然而,与 Solana v. Solana 不同,这些不仅仅是来自同一来源的同一消息的不同版本。Sig 具有来自 28 个 Pubkey 的 CrdsValues,这些 Pubkey 根本没有在 Solana 的 CrdsTable 中表示。Solana 具有一些 Sig 缺失的 DuplicateShredEpochSlotsVote 消息,Sig 具有更多的 NodeInstance 消息和一个更多的 LegacyContactInfo

随着时间的推移,我们观察到 Sig 确实赶上了并包含了 Solana 中找到的每个 CrdsValue(有些仅在哈希和时钟时间上有所不同,如对照组)。然而,20 分钟后,Sig 还包含了 1468 个额外的 CrdsValues,这些 CrdsValues 在 Solana 中找不到,来自具有 1050 个 Pubkey 的节点,这些 Pubkey 未反映在 Solana 的 CrdsTable 的任何值中。这些额外的 CrdsValues 是:

  • DuplicateShred:36
  • EpochSlots:2
  • LegacyContactInfo:2
  • NodeInstance:1050
  • Version:1

数据表明,Sig 现在能够跟踪与 Solana 相同的所有 CrdsValues。但是,它跟踪 Solana Labs 未跟踪的一些附加值。起初,它们密切同步,但随着时间的推移,Sig 逐渐积累了一些额外的值。特别值得注意的是,Sig 中反映了额外的节点实例,包括 NodeInstance CrdsValues 和相等数量的额外 Pubkey

如果创建记录的节点具有零 stake,则 Solana Labs 验证器会在 15 秒后从 CrdsTable 中删除记录。否则,记录将保留一个 epoch 的持续时间(大约 2 天)。Sig 尚未能够跟踪每个节点的 stake,因此目前我们已将 Sig 配置为将所有记录保留在 CrdsTable 中以供完整的 epoch 使用。

从最新一轮的测试和改进中,我们看到 Sig 的 gossip 实现的行为与 Solana 非常相似。仍然存在一个小的挥之不去的不一致之处,当我们实施 stake 跟踪时,将会清除该不一致之处。在此之前,我们已经有了一个 gossip 实现,该实现连接到集群并且可以跟踪至少与 Solana 验证器预期知道的数据一样多的数据,以及一些多余的杂乱数据。

Prometheus

我们的目标是在 Sig 连接到真正的 Solana 集群时提供关于 Sig 性能的详尽数据。为了使其成为可能,我们决定公开与 Prometheus 兼容的指标。Prometheus 是一种开源且广泛采用的指标收集工具。指标 API 基于明确定义的规范,并且已经开发了许多工具,使其易于使用此数据。

我们在 此 PR 中向 Sig 添加了一个库,该库在 Zig 中实现了 Prometheus 指标。我们选择 fork zig-prometheus,这是一个很好的起点。然而,该项目没有优先考虑符合 Prometheus 标准,因此我们根据需要进行了更改以符合 客户端库的官方 prometheus 规范。我们从头开始实现了 Gauge,因为 zig-prometheus 中的 Gauge 与 Prometheus 规范中的 Gauge 是不同的指标类型。zig-prometheus 中的 Gauge 类似于 官方 Prometheus Go 库中的 GaugeFunc,因此我们将其重命名为“GaugeFn”。我们还从头开始实现了 Histogram,因为 zig-prometheus 中的 Histogram 不符合规范,并且因为它没有针对写密集型使用进行优化。

我们的 Prometheus 库支持以下指标:

  • Counter:跟踪单调递增的数值,可以任意递增。
  • Gauge:跟踪可以随时更改为任何数值的值。
  • GaugeFn:调用你代码中的函数来检索要报告给 Prometheus 的数值。
  • Histogram:对数值观察进行抽样,并在可配置的存储桶中对其进行计数。每个存储桶的计数器表示属于配置范围内的数值的观察次数。

注册表用于跟踪所有指标。以下是如何创建注册表并开始使用计数器:

const registry = try Registry(.{}).init(allocator);
const counter = registry.getOrCreateCounter("my_counter");
counter.inc();

我们还包括了一个 http 适配器和一个全局注册表单例,以便在任何应用程序中轻松引导统一的 Prometheus 端点。这将创建一个将在整个应用程序中共享的单个全局注册表,并通过 http 在标准 Prometheus 端口 12345 上提供它:

const registry = globalRegistry(allocator);
try servePrometheus(allocator, registry, metrics_port);

在另一个范围内,可以递增来自同一注册表的计数器:

const counter = globalRegistry(null).getOrCreateCounter("my_counter");
counter.inc();

CounterGauge 只是使用原子 u64 或 f64 实现的,可以从任何线程更新,而无需锁定。GaugeFn 是一个简单的适配器,可以调用你选择的任意代码。

Histogram 更加复杂,因为它在内部为每个观察更新多个值,并且这些观察可能来自多个线程。我们的目标是优化写入性能,因为写入可能在应用程序的关键路径中非常频繁地发生,而读取仅偶尔来自 Prometheus。我们从 rust-prometheus 中的 histogram 中获得了灵感,以优化写入性能。

我们的解决方案允许并发写入快速发生,而无需任何锁定机制。然而,复杂之处在于,写入操作需要更新多个字段。如果在写入操作仅部分完成时读取数据,则读取的数据将损坏。通常,同步原语(如互斥锁或读写锁)将用于处理此问题,但这些会降低写入性能。

为了解决这个问题,histogram 数据被分成两个分片,一个热分片和一个冷分片。热分片是当前启用的用于写入的分片。无限数量的线程可以并发地将数据写入热分片。根本不使用任何锁定机制进行写入,这使得写入快速且高度可并行化。

由于我们知道读取不经常发生(每秒少于一次),因此我们将确保确保它们仅访问具有完整性的数据,而不会妨碍写入的负担放在读取上。“读取”有点用词不当,因为读取操作实际上会改变 histogram 的一些内部状态。

读取期间发生的第一件事是它切换哪个分片是热的和冷的。先前冷的分片被指定为热分片,因此所有将来的写入都将定向到该分片。允许先前热的分片冷却下来。一旦冷却分片上所有正在进行的写入都已完成,就会读取该分片中的数据。与写入不同,读取具有锁。一次只能发生一个读取,因为如果另一个线程进入并在一个线程等待分片冷却时翻转分片,则会中断并破坏读取过程。

Prometheus 库已合并到主分支并可以使用。我们很快将开始使用它来跟踪 GossipRpcRequestProcessorAccountsDB 中的指标。

结论

高效地实施 AccountsDB 和完善 RPC 客户端/服务器和 Gossip 协议是将 Sig 变为现实的关键步骤。凭借 Zig 提供的强大功能,我们能够证明在这些领域的当前验证器实现中取得了具体的改进。在接下来的几个月中,我们将完成 AccountsDB 和其余的存储相关组件,并定期更新社区。我们欢迎生态系统中的开源开发人员提供反馈、问题和贡献。

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

0 条评论

请先 登录 后评论
syndica
syndica
News & research from Syndica: low latency Solana RPC, data streams, Sig Validator & more