Following up on the last post closing the chapter on Apache Thrift, we’re looking at another serialization library, FlatBuffers.

FlatBuffers

A lesson learned from using Thrift was we wanted performant, schema’d serialization and robust messaging, but not tightly coupled together. We ended up using the message framing and other features, but using ZeroMQ as a transport and implementing message identification for pub/sub (as opposed to Thrift’s request/reply model).

Other strikes against Thrift we didn’t discover until later. The C++ client has a boost dependency and requires both exceptions and RTTI. Boost you either love or hate, but the last two are basically verboten in the video game industry. Case in point, getting our SDK working in UE4 was a hassle.

Potentially better performance and reduced memory churn drove us to evaluate options like Cap’n Proto and we’re leaning towards FlatBuffers. Notes:

The last two are significant deciding factors for us. There’s a better chance a developer is already using FlatBuffers and that’s one less dependency our SDK will introduce.

FlatBuffers in Rust

Documentation is a bit light, although they have a “Use in Rust” guide in the FlatBuffers documenation.

Instead of following the directions (I know, I know) and using HEAD, we’re using the latest stable release, 1.10.0.

FlatBuffers schema protos/bench.fbs:

namespace bench;

table Basic {
  id:ulong;
}

table Complex {
  name:string (required);
  basic:bench.Basic (required);
  reference:string (required);
}

Run flatc --rust -o src protos/bench.fbs to generate Rust source src/bench_generated.rs containing two structs: bench::Basic and bench::Complex.

We can then serialize/deserialize bench::Basic struct:

use crate::bench_generated as bench_fbs;

#[test]
fn it_deserializes() {
    const ID: u64 = 12;
    let mut builder = FlatBufferBuilder::new();
    // bench::Basic values that will be serialized
    let basic_args = bench_fbs::bench::BasicArgs { id: ID, .. Default::default() };
    // Serialize bench::Basic struct
    let basic: WIPOffset<_> = bench_fbs::bench::Basic::create(&mut builder, &basic_args);
    // Must "finish" the builder before calling `finished_data()`
    builder.finish_minimal(basic);
    // Deserialize the bench::Basic
    let parsed = flatbuffers::get_root::<bench_fbs::bench::Basic>(builder.finished_data());
    assert_eq!(parsed.id(), ID);
}

Notes:

  • .. Default::default() isn’t needed here, but shows impl Default is also generated
  • create() serializes the struct and returns a WIPOffset<bench::Basic> which is an offset to the root of the data
  • The documents deserialize with get_root_as_XXX() functions which aren’t generated by flatc 1.10 (need HEAD?) but appear to be wrappers around get_root().

For the bench::Complex struct:

use crate::bench_generated as bench_fbs;

#[test]
fn it_deserializes() {
    const ID: u64 = 12;
    const NAME: &str = "name";
    const REFERENCE: &str = "reference";
    let mut builder = flatbuffers::FlatBufferBuilder::new();
    {
        let args = bench_fbs::bench::BasicArgs{id: ID};
        let basic = Some(bench_fbs::bench::Basic::create(&mut builder, &args));
        let name = Some(builder.create_string(NAME));
        let reference = Some(builder.create_string(REFERENCE));
        let args = bench_fbs::bench::ComplexArgs{ basic, name, reference };
        let complex = bench_fbs::bench::Complex::create(&mut builder, &args);
        builder.finish_minimal(complex);
    }
    let parsed = flatbuffers::get_root::<bench_fbs::bench::Complex>(builder.finished_data());
    assert_eq!(parsed.basic().id(), ID);
    assert_eq!(parsed.name(), NAME);
    assert_eq!(parsed.reference(), REFERENCE);
}

Notes:

  • Needing to manually serialize each member of bench::Complex is cumbersome and error-prone. There doesn’t seem to be a way to automatically handle it…

Benchmarks

The above schema is from the proto_benchmarks repository which compares protobuf with capnproto. I stumbled upon it and swooned over the pretty criterion plots. I forked it to add FlatBuffers and migrate to Rust 2018.

The flatbuffers schema is actually auto-magically generated from the protobuf schema:

syntax = "proto2";
option optimize_for = SPEED;

package bench;

message Basic {
    required uint64 id = 1;
}

message Complex {
    required string name = 1;
    required Basic basic = 2;
    required string reference = 3;
}

In build.rs:

// Convert protobuf .proto to FlatBuffers .fbs
std::process::Command::new("flatc")
    .args(&["--proto", "-o", "protos", "protos/bench.proto"])
    .spawn()
    .expect("flatc");
// Generate rust source
std::process::Command::new("flatc")
    .args(&["--rust", "-o", "src", "protos/bench.fbs"])
    .spawn()
    .expect("flatc");

Using std::process::Command to execute flatc. First, to convert the protobuf schema into FlatBuffers, then to output Rust source.

Results

Not entirely sure I believe the results. Not knowing much about any of the three libraries, we’re likely comparing slightly different things.

Basic write:

Complex build:

Also found rust-serialization-benchmarks which hasn’t been updated in 3 years and seems to use Bencher for benchmarking.