New Ethereum talks, every Monday. The week's conference uploads by event, in your inbox.

Sszb: A High Performance SSZ Implementation in Rust

Devcon 7 SEAWed, Nov 13, 2024, 10:35 AM · 14:09

This talk goes over my EPF project for the SSZ ecosystem: - a benchmarking suite for the various rust SSZ implementations in the ecosystem to properly evaluate performance and point developers to which library they should use. - a high performance ssz implementation that's faster than existing libraries in the ecosystem

Transcript

Hello, my name is Gilea. I'm here to present my work on SSEB, a high-performance SSE implementation in Rust, as well as my work on SSE Arena, a benchmarking suite for the crates in the Rust ecosystem. My motivation for working on this project was having a chance to work on optimizing an existing project, which I haven't done before. And so I learned a lot in doing this, as well as seeing what kind of gains were possible in something like SSE. It's not a real bottleneck in clients today.

It's still a fairly simple task, but I was curious about what kind of losses were present over long periods of time without an optimized implementation. And so I thought this work was very appropriate for the fellowship because it's, I guess, lower priority for client teams, but still work that needs to be done. So I'm briefly going to talk about what serialization is and SSZ and go over the SSZ ecosystem as well as my work on the benchmarking suite and the implementation. Finally, talking about performance and next steps. So zooming past these first few slides, serialization is the process of transforming a data structure into a common format that the different clients can agree on which is important for consensus.

Normally data structures can't be transmitted as is because of different in-memory representations and such. SSZ is the serialization scheme on Ethereum 2 meant to replace RLP on the execution layer. It has a few improvements like schemas, which are a must-have in a performance system. And Merkleization is the process in which we generate a short digest of the state while allowing for updates without rehashing the entire state. This is useful to generate small proofs of the contents inside a beacon block for light clients that don't want to hold too much state data.

There are a few SSC implementations in the ecosystem, mainly in Go. Fast SSC is the most popular one and used in Geth today, although Peter also has an implementation out. In Rust specifically, there's Sigma Prime's Ethereum SSZ, Grandin's Crate, which is in public and is only used internally, and Alex Stokes' Crate, SSZRS. For the first half of the fellowship, I worked on a benchmarking suite to evaluate how these libraries perform against each other. Most of these crates have tests on consensus spec tests, but there's no real way to see how they perform on real data like beacon blocks and beacon states.

So I worked on a library, well, not a library, a project called SSE Arena, which benchmarks the different crates in the ecosystem. And so it evaluates it on more controlled test cases, like lists of integers and validators, validator structs, and also evaluates it on blockchain data, like beacon blocks and beacon state, that I obtained from a beacon chain checkpoint. So I leverage Criterion for this benchmark suite which gives us handy reports, but I also use another benchmarking library called Divon which handily gives us allocation stats, so you can see how much memory is being allocated during these encoding and decoding runs. And so this gives us a robust way to measure performance. Now on to the second half of my work in the fellowship, working on my own implementation.

So how does one optimize SSE? Optimizing this serialization scheme and benchmarking is kind of tricky, because most the real bottleneck or most of the optimization in encoding and decoding is simply using a more optimal data structure. There's a lot of techniques you can do to optimize this. You can lay out your data and memory so it's aligned to the word boundaries, and the other techniques like zero copy deserialization, where you can simply cast your bytes into your type. That's only really possible when you have control of the underlying data structures, which I did not have for this project.

And there's a lot of reasons why you might not want to just rewrite your types with serialization in mind. Sigma Prime, in particular, has done a lot of good work with their millhouse crate, which allows for faster, sparse updates of beacon state. And so it doesn't really make sense to just change your type that create just to speed up serialization. It's better to just think about how to work with the types we have. And so being constrained by the inability to change the underlying data structure, I opted to minimize intermediate allocations.

And so another second bottleneck in serialization is how much memory are you allocating in between steps to serialize and deserialize. And so here's how I went about my implementation as a ZB. It has two main differences. It uses the buff and buff mute traits. This is an abstraction over buffer types, so it encapsulates both vectors and slices, and has the added benefit of abstracting offset and counting, which greatly simplifies the implementation.

So for context, you can outright define how to encode and decode certain types, but the SSEB package also provides a macro for automatically generating implementations for container types, which are like structs. And so generating this, these implementations is a hassle. We these implementations is a hassle. We provide a way to do this automatically, and the implementation for it is very simple, thanks to the buff mute traits. And second, we avoid a lot of intermediate state during the encoding process, and minimize any needed allocations during the decoding steps.

This reduces the number and size of memory allocations needed to perform serialization, which is another dominant cost, as I mentioned before. Peter's Go implementation does this, and Grandeen was also another big inspiration. Although to note, Grandin only works with slices. The benefit of using buff and buff mute is being able to use vectors and any other buffer implementation you want to provide as long as you implement the trait, which not quite sure how it's gonna be used just yet, but could be handy. And it performs pretty well.

So I tested this on a beacon block, decoding and encoding. It's pretty, pretty fast on the decoding part, if you'll consult the graph, I'm not quite sure how visible it is, but clocking in at around 129 microseconds on the decoding part versus 3 milliseconds in Ethereum SSC. And while the differences aren't as drastic for all types, there's similar levels of performance, around 85% encoding speedup and 95% decoding speedup on the beacon blocks. So that was for the beacon blocks. So that was for the beacon blocks.

There's still some changes to be made, some fixes to be made for beacon state encoding and decoding. I know what the, there's a bug in the implementation. I know where it is. I'm gonna go fix it, but I only found it like two hours ago, so didn't really have time to fix that today. As for next steps, I want to ship a support for Merkleization and Merkle proofs with generalized indices.

This is needed to have a full-fledged SSZ implementation. My focus for this fellowship was on performance of encoding and decoding. And so I left this for after the cohort. Additionally, I'd like to support a new trait I call as a Z check, which provides early input validation for, to check that an input conforms to a certain type. This would be useful if you want to reject malformed inputs earlier, instead of having a full decoding step in the hot path of your application.

And then, after that, stable release. I want to gear up for, right,, stable release. I want to gear up for a stable release, adding usage docs and cleaning up anything that needs to be polished in the library. And then once that's done, I want to work on something I find interesting, but I'm not sure if other projects would want to use this. But I think it'd be cool to have support for partial encoding and decoding.

For example, for large objects like beacon state, fully decoding can be very expensive. And so partial decoding would drastically speed things up, especially if you only need a subfield of your beacon state. And re-encoding and rehashing would work similarly. Again, with the Sigma Prime's millhouse implementation, they're already implementing DIR types with sparse updates in mind, and I think SSC could use Some similar ideas with regards to sparse updates I'm a little over time. I Want to thank my mentor Michael Sproul from Sigma Prime who did a most of the work I believe on Ethereum SSC.

He's not a DEF CON I think. But if you're watching this, thank you. And I also want to thank Josh and Mario for providing the opportunity. I learned a lot through the cohort. And I'm glad I got the chance to do this work.

Any questions? That's all for me. Alright. Any questions about the SSZ library? Not right away.

Probably after a stable release. I forgot to mention also that there's no unsafe code in this. So Michael told me not to use that. Yeah. Coming soon.

Yep, coming soon. All right, one more time for Gilead. Yeah. Thank you. Thank you.

Automatic transcript — names and jargon may be misspelled.