Syndica 的 Sig 取得重大进展,完成了 Sig 的 AccountsDB,改进了 RPC 服务器/客户端,并改进了 Gossip 协议的实现。
本帖是我们定期发布的、概述 Sig Validator 工程更新/里程碑的多部分博文系列的第二部分。第一部分,关于 Gossip 协议,请 在此处 查看。
这篇博文详细介绍了 Syndica 在 Sig 旅程中的重大进展:我们在为 Sig 完成 AccountsDB 方面取得了重大进展,改进了 RPC 服务器/客户端,并完善了我们对 Gossip 协议的实现。
Sig 的 AccountsDB 现在能够加载和验证超过 3 亿个账户的 mainnet
快照,包括读取/写入账户功能、生成账户索引以及生成账户上的累积哈希以进行数据验证。
我们现在正在针对 Solana Labs 的客户端进行读取和写入性能的基准测试,以了解其性能并确保我们做出改进。这包括识别瓶颈并利用 Zig 来提高性能。
在过去一个月的工作中,我们遇到了一些有趣的例子,展示了 Zig 的强大功能,包括:
从较高层面来看,当我们从快照加载时,计算量最大的部分之一是生成账户索引。虽然帐户数据位于磁盘上的文件中,但我们需要一种方法来回答:“对于给定的公钥,相应的帐户数据位于何处?”。这是主帐户索引的工作,它是从 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。
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 中关注。
去年,我们还完成了 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 缺失的 DuplicateShred
、EpochSlots
和 Vote
消息,Sig 具有更多的 NodeInstance
消息和一个更多的 LegacyContactInfo
。
随着时间的推移,我们观察到 Sig 确实赶上了并包含了 Solana 中找到的每个 CrdsValue
(有些仅在哈希和时钟时间上有所不同,如对照组)。然而,20 分钟后,Sig 还包含了 1468 个额外的 CrdsValues
,这些 CrdsValues
在 Solana 中找不到,来自具有 1050 个 Pubkey
的节点,这些 Pubkey
未反映在 Solana 的 CrdsTable
的任何值中。这些额外的 CrdsValues
是:
DuplicateShred
:36EpochSlots
:2LegacyContactInfo
:2NodeInstance
:1050Version
: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 验证器预期知道的数据一样多的数据,以及一些多余的杂乱数据。
我们的目标是在 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();
Counter
和 Gauge
只是使用原子 u64 或 f64 实现的,可以从任何线程更新,而无需锁定。GaugeFn
是一个简单的适配器,可以调用你选择的任意代码。
Histogram
更加复杂,因为它在内部为每个观察更新多个值,并且这些观察可能来自多个线程。我们的目标是优化写入性能,因为写入可能在应用程序的关键路径中非常频繁地发生,而读取仅偶尔来自 Prometheus。我们从 rust-prometheus 中的 histogram 中获得了灵感,以优化写入性能。
我们的解决方案允许并发写入快速发生,而无需任何锁定机制。然而,复杂之处在于,写入操作需要更新多个字段。如果在写入操作仅部分完成时读取数据,则读取的数据将损坏。通常,同步原语(如互斥锁或读写锁)将用于处理此问题,但这些会降低写入性能。
为了解决这个问题,histogram 数据被分成两个分片,一个热分片和一个冷分片。热分片是当前启用的用于写入的分片。无限数量的线程可以并发地将数据写入热分片。根本不使用任何锁定机制进行写入,这使得写入快速且高度可并行化。
由于我们知道读取不经常发生(每秒少于一次),因此我们将确保确保它们仅访问具有完整性的数据,而不会妨碍写入的负担放在读取上。“读取”有点用词不当,因为读取操作实际上会改变 histogram 的一些内部状态。
读取期间发生的第一件事是它切换哪个分片是热的和冷的。先前冷的分片被指定为热分片,因此所有将来的写入都将定向到该分片。允许先前热的分片冷却下来。一旦冷却分片上所有正在进行的写入都已完成,就会读取该分片中的数据。与写入不同,读取具有锁。一次只能发生一个读取,因为如果另一个线程进入并在一个线程等待分片冷却时翻转分片,则会中断并破坏读取过程。
Prometheus 库已合并到主分支并可以使用。我们很快将开始使用它来跟踪 Gossip
、RpcRequestProcessor
和 AccountsDB
中的指标。
高效地实施 AccountsDB 和完善 RPC 客户端/服务器和 Gossip 协议是将 Sig 变为现实的关键步骤。凭借 Zig 提供的强大功能,我们能够证明在这些领域的当前验证器实现中取得了具体的改进。在接下来的几个月中,我们将完成 AccountsDB 和其余的存储相关组件,并定期更新社区。我们欢迎生态系统中的开源开发人员提供反馈、问题和贡献。
- 原文链接: blog.syndica.io/sig-engi...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!