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:
- FlatBuffers is backed by Google
- Cap’n C# support is dubious at best
- Cap’n likely has an edge in performance ([0], [1])
- Both have first-class support for Rust (FlatBuffers, Cap’n Proto)
- Unity support: FlatBuffers’ C# library targets .NET 3.5 and “just works”. There’s a dormant github project for Cap’n.
- FlatBuffers has better adoption in video game industry (google, cocos2d-x)
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 showsimpl Default
is also generatedcreate()
serializes the struct and returns aWIPOffset<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 aroundget_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.