From da9f07bd00efb60be04ec776c76e03cc0ee2fd71 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Wed, 19 Aug 2026 14:58:56 -0300 Subject: [PATCH] perf(l1): give the RLP encoder's scratch buffer an initial capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Encoder` stages a struct's fields in `temp_buf` so the list header can be written once the payload length is known. That buffer started empty, so every encode paid a chain of doubling reallocations: an RLP list of a few hundred bytes grows 8 -> 16 -> ... -> 512, which is seven allocations and six copies to encode one node. Start it at 512 instead. That covers a trie node — the hashing path already sizes its own buffers at 512 as the maximum encoded node size — and the common transaction and receipt shapes, which is where the encoder is hottest. Larger payloads still grow from there; the point is to skip the small doublings, not to bound the size. The `PERF` note asking for exactly this is now addressed, so it goes away. Encoding is unchanged: capacity does not affect output, only how many times the buffer is reallocated on the way there. Measured on the LambdaVM zkVM guest, which runs ethrex's block execution as a RISC-V program, so every allocation is executed work rather than something the allocator can hide: block before after delta empty 609,404 586,983 -3.68% 1 transfer 1,097,816 1,059,997 -3.44% 10 transfers 3,702,726 3,604,757 -2.65% A guest profile attributes the win: `Encoder::encode_bytes`, which was 7-10% of all allocator cycles, disappears from the profile entirely, and total allocator cycles fall 23%. --- crates/common/rlp/structs.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/common/rlp/structs.rs b/crates/common/rlp/structs.rs index 54b78e2f189..cb0afb77c7d 100644 --- a/crates/common/rlp/structs.rs +++ b/crates/common/rlp/structs.rs @@ -181,14 +181,25 @@ impl core::fmt::Debug for Encoder<'_> { } } +/// Initial capacity of the payload scratch buffer. +/// +/// Fields are staged in `temp_buf` so the list header can be written once the +/// payload length is known. Starting that buffer empty makes every encode pay a +/// chain of doubling reallocations — an RLP list of a few hundred bytes grows +/// 8 → 16 → … → 512, so seven allocations and six copies for one node. +/// +/// 512 covers a trie node (the hashing path already sizes its own buffers at +/// 512 as the maximum encoded node size) and the common transaction and receipt +/// shapes, which is where the encoder is hottest. Larger payloads still grow +/// from here; the point is to skip the small doublings, not to bound the size. +const ENCODER_SCRATCH_CAPACITY: usize = 512; + impl<'a> Encoder<'a> { /// Creates a new encoder that writes to the given buffer. pub fn new(buf: &'a mut dyn BufMut) -> Self { - // PERF: we could pre-allocate the buffer or switch to `ArrayVec`` if we could - // bound the size of the encoded data. Self { buf, - temp_buf: Default::default(), + temp_buf: Vec::with_capacity(ENCODER_SCRATCH_CAPACITY), } }