Files
roto/README.md
T

206 lines
6.6 KiB
Markdown
Raw Normal View History

2026-04-30 22:01:41 -07:00
# roto
2026-05-04 13:45:18 -07:00
Zero-allocation Rust protobuf reader and writer.
2026-04-30 22:01:41 -07:00
2026-05-04 13:45:18 -07:00
## Overview
2026-04-30 22:01:41 -07:00
2026-05-04 13:45:18 -07:00
Instead of deserializing binary protobuf data into Rust structs, roto scans a message _once_ on
construction — recording the byte offset of each field — then reads fields on demand directly from
the original bytes. No heap allocation, no data copying, no full deserialization upfront.
2026-04-30 22:01:41 -07:00
2026-05-04 13:45:18 -07:00
Writing works the same way: you provide a fixed buffer and a builder writes fields directly into it,
returning a slice of the bytes written.
2026-05-03 13:31:39 -07:00
2026-05-04 13:45:18 -07:00
## Design
2026-05-03 13:31:39 -07:00
2026-05-04 13:45:18 -07:00
`protoc` generates a `CodeGeneratorRequest` message; `protoc-gen-roto` (in
`src/bin/protoc-gen-roto.rs`) reads this from stdin, generates Rust source files, and writes a
`CodeGeneratorResponse` to stdout. `protoc` then writes those `.rs` files to disk. The generated
files are included directly in the crate that uses the protobuffers.
2026-05-03 13:31:39 -07:00
2026-05-04 14:40:11 -07:00
Sample usage:
```
protoc -Iproto/ proto/hackers.proto --plugin=./target/debug/protoc-gen-roto --roto_out=src/
```
This will generate a file, src/hackers.rs.
2026-05-04 13:45:18 -07:00
## Generated code
2026-04-30 22:01:41 -07:00
2026-05-04 13:45:18 -07:00
For each protobuf message roto generates two types:
- **Reader struct** `MessageName<'a>` — borrows the original byte slice, zero-copy.
- **Builder struct** `MessageNameBuilder<'b>` — writes into a caller-provided `&mut [u8]`.
Nested message types are placed in a `pub mod message_name { ... }` module (snake_case of the
parent message name) within the same generated file.
## Sample usage
Given this proto definition:
```proto
2026-04-30 22:01:41 -07:00
message Hello {
string hello_world = 1;
message InnerWorld {
string thought = 1;
}
InnerWorld inner_world = 2;
}
```
2026-05-04 13:45:18 -07:00
### Reading
2026-04-30 23:13:24 -07:00
```rust
2026-05-04 13:45:18 -07:00
fn parse_proto(data: &[u8]) -> roto::Result<String> {
// Scan the data once, recording field offsets
let hello = Hello::new(data)?;
// String fields return &str borrowed from the original bytes (zero-copy)
let hello_world: &str = hello.hello_world()?;
// Nested message fields return &[u8]; construct the nested reader from those bytes
let inner_bytes: &[u8] = hello.inner_world()?;
let inner_world = hello::InnerWorld::new(inner_bytes)?;
let thought: &str = inner_world.thought()?;
Ok(format!("{} is about {}", hello_world, thought))
}
2026-04-30 23:13:24 -07:00
```
2026-05-04 13:45:18 -07:00
Fields absent from the binary data return `Err(roto::RotoError::FieldNotFound)`.
### Writing
Nested messages must be serialized into a scratch buffer first, then embedded as raw bytes in the
outer builder.
```rust
fn build_proto(buf: &mut [u8]) -> roto::Result<&[u8]> {
// Serialize the inner message first
let mut inner_buf = [0u8; 256];
let inner_bytes = hello::InnerWorldBuilder::builder(&mut inner_buf)
.thought("some thought")?
.finish()?;
// Build the outer message, embedding the serialized inner bytes
HelloBuilder::builder(buf)
.hello_world("some world")?
.inner_world(inner_bytes)?
.finish() // returns Result<&'b mut [u8]> — the written portion of buf
}
```
Builder methods consume `self` and return `Result<Self>`, enabling `?`-based chaining.
`finish()` returns `Result<&'b mut [u8]>` — a slice of the portion of the buffer that was written.
### Repeated fields
Repeated fields return a `RepeatedFieldIterator<'a>`. Each item yields `Result<(&[u8], WireType)>`.
```rust
let hello = Hello::new(data)?;
for item in hello.tags() {
let (value_bytes, _wire_type) = item?;
// decode value_bytes according to the expected wire type
}
```
## Runtime API
The core runtime in `src/lib.rs` provides:
- `ProtoAccessor<'a>` — scans a message's fields and reads values at recorded offsets.
- `ProtoBuilder<'a>` — writes fields into a provided `&mut [u8]` buffer.
- `FieldIterator<'a>` / `RepeatedFieldIterator<'a>` — iterators over fields and repeated fields.
- `Tag`, `WireType` — protobuf encoding primitives.
- `read_varint`, `write_varint`, `skip_value` — low-level wire-format helpers.
- `RotoError`, `Result<T>` — error type and alias.
## High-level design
2026-04-30 22:01:41 -07:00
2026-05-04 13:45:18 -07:00
On construction (`MessageName::new(data)`), the generated reader struct iterates the binary once
using `FieldIterator` and records the byte offset of each field's tag. Subsequent field accesses
call `ProtoAccessor::get_value_at(offset)` — no re-scanning. For repeated fields, the start and
end offsets of the field range are recorded to bound iteration efficiently.
2026-04-30 22:01:41 -07:00
2026-05-04 14:40:11 -07:00
## Benchmarks
Two benchmark suites share the same binary data files and the same four
measurement groups:
| Group | What is timed |
| --------------- | ------------------------------------------------------- |
| `shallow_parse` | Become ready to read any field (one scan / full decode) |
| `deep_parse` | Walk the full tree: Campaign → Operations → Hackers |
| `field_access` | Read individual fields on an already-parsed message |
| `iterate` | Count top-level and nested repeated fields |
### 1 — Generate the shared data files (do this once)
Data files are written to `data/bench/`.
```sh
cargo run --release --bin gen_bench_data -- --preset tiny
cargo run --release --bin gen_bench_data -- --preset small
cargo run --release --bin gen_bench_data -- --preset medium
cargo run --release --bin gen_bench_data -- --preset large
```
For even larger inputs use `--preset huge` (~500 MB) or set the knobs
directly:
```sh
# ~50 MB: 500 operations × 100 KB stolen_data each
cargo run --release --bin gen_bench_data -- --ops 500 --stolen-kb 100 --output data/bench/50mb.pb
```
### 2 — Rust benchmark (criterion)
```sh
cargo bench --bench hackers_bench
```
HTML reports are written to `target/criterion/`. Run a single group:
```sh
cargo bench --bench hackers_bench -- shallow_parse
```
### 3 — C / upb benchmark
Requires protobuf ≥ 21 with `protoc-gen-upb` (ships with modern `protoc`).
```sh
cd upb_test
make # compiles hackers_bench from the pre-generated upb files
./hackers_bench
```
To regenerate the upb C files from `proto/hackers.proto`:
```sh
cd upb_test && make regen
```
### Interpreting the comparison
The two libraries have fundamentally different models:
- **roto `shallow_parse`** does one linear scan recording byte offsets — no
allocation, no field decoding. Subsequent field reads decode on demand at
the stored offset.
- **upb `Campaign_parse`** fully decodes the entire message tree into
arena-allocated structs upfront. Subsequent field reads are direct struct
member lookups (~1 ns).
The result: roto's parse is faster and allocation-free; upb's field access
after parsing is faster. For workloads that read every field the costs
invert; for workloads that read a handful of fields from large messages roto
wins.
2026-05-04 13:45:18 -07:00
## Literature
2026-04-30 22:01:41 -07:00
https://protobuf.dev/programming-guides/encoding/