Serializing data faster

·

4 min read

Recently I've released first version of the Alkahest - Zero-Overhead Zero-Copy serialization library.

Why would I even make one?

Well, it is fun problem to solve.

I was not happy with performance of alternatives available in Rust ecosystem.

I also would like to avoid any unsafe code. Alkahest uses #![deny(unsafe)] and to go from typed values to bytes and back again it uses awesome bytemuck crate. The only place where unsafe can be found is needed for the temporary workaround for bytemuck inability to prove packed structure is Pod when there are generics involved.

What makes a serialization library

Alkahest is a code generation library with a bit of supporting code. It generates code for serializing and deserializing user-defined types using powerful Rust procedural macros system. Yeah, just like any other serialization library in Rust. But with a twist.

User defined type derives Schema trait and then values of that type are never constructed. The type is used only as a type argument for functions and as a field type for other Schemas. Values of the type implementing Schema has no meaning for Alkahest, even if it is possible to construct one.

Instead user creates and receives values of other types generated for the Schema type.

Code generated

Given the following user-defined structure:

use alkahest::Schema;

#[derive(Schema)]
struct Data {
    val: Val, // `Val` implements `Schema`.
}

Three types will be generated:

  1. Structure for packed representation. Contains all fields' packed representations.

    #[repr(C, packed)]
    struct DataPacked {
       val: <Val as Schema>::Packed,
    }
    
  2. Structure for unpacked representation. Contains all fields' unpacked representations.

    struct DataUnpacked<'a> {
       val: <Val as SchemaUnpack<'a>>::Unpacked,
    }
    
  3. Structure for packing. Generic over every field.

    struct DataPack<T0> {
       val: T0,
    }
    

    For enums packing structure is generated for each variant.

How to use

Now user can construct DataPack and serialize it with the write function specifying schema Data, provided chosen type for val implements Pack<Val>, i.e. can be packed with schema Val.

This allows for more flexibility during serialization phase as multiple types can be packed with the same schema and user can use whatever is available or which is cheaper to construct.

To construct a message with expensive fields (vectors, strings) using popular serde crate it is necessary to construct those fields, which would involve allocations, only for those fields to be read by serializer implementation.

With Alkahest sequences are serialized from iterators directly. Serialization process won't allocate anything. Although, iterators must have known size, i.e. must implement ExactSizeIterator.

Deserialization in Alkahest is lazy. Sequences get unpacked into iterator that reads elements directly from bytes during iteration. Again, skipping expensive Vec allocation.

Tradeoffs

Usually writing some useful code requires tradeoffs. Serialization library is no exception.

My main use case is message writing-parsing where messages have to be constructed only to be serialized once, and deserialized only to be read once. This use case guides design choices for Alkahest.

The library has to generate multiple data representations to make both serialization and deserialization processes as fast as possible.

Alkahest dictates data format with no ability for user to redefine it. Even primitive encoding is hardcoded to Little-Endian.

When user already have a complex structure serializable with serde it would require nonzero amount of code to switch to Alkahest. There's no reason to jump to Alkahest if performance of current solution is not a concern.

Know issues

Currently there is no way to know in advance how much memory would be required for serialized data and serialization function will just panic if provided buffer is not large enough. This is the first problem I'm going to tackle with.

Benchmark

I've added Alkahest to rust_serialization_benchmark.

Table

Alkahest performs well during serialization, slightly behind Abomonation which is heavy on unsafe operations and isn't nearly portable.

rkyv outperforms Alkahest on reads if the raw data is not validated. Probably because rkyv simply casts pointer to reference and returns it, while Alkahest returns owned value, so during nested structures traversal Alkahest reads deeper.

Yet if rkyv validates raw data, making reading safe, then Alkahest outperforms rkyv except for mesh test.

Conclusion

Writing library for a very specific use case allows to squeeze out more performance and utilize available resources better. It was sort of fun yak shaving and I'm going to return to writing that thing where I need this performant serializer - netcode.