Total Zero-Copy Serialization with rkyv
Why traditional serialization kills latency and how to implement true zero-copy data loading using rkyv in Rust.
JSON is fine for web APIs. In high-frequency trading and systems programming, serialization formats like JSON, Avro, or even Protobuf are a performance problem.
The Serialization Tax
Consider a standard market data update:
{ "s": "BTC-USD", "p": 45000.50, "q": 1.5, "t": 1638291000 }
```python
When your program receives this:
1. **Allocation**: Allocate memory for the string buffer.
2. **Parsing**: Scan the bytes, state-machine based JSON parsing.
3. **Conversion**: ASCII "45000.50" must be parsed into a float (multiple division operations).
4. **Layout**: Fields are copied into a struct at the right memory offsets.
This process burns thousands of CPU cycles. In a system processing millions of messages per second, this parsing overhead consumes a significant fraction of your CPU time.
## The Zero-Copy Promise
True **Zero-Copy Serialization** means the binary format on disk (or wire) is **identical** to the memory layout of the struct.
- **Deserialization** becomes a pointer cast (0 ns).
- **Access** is instant.
- **Validation** is optional (trusted sources).
### Enter `rkyv`
`rkyv` (pronounced "archive") achieves zero-copy in Rust. Unlike libraries like `bincode` (which packs bytes but still copies them into a struct), `rkyv` guarantees the in-memory representation matches the serialized format.
## Relative Pointers: The Magic Trick
You might ask: "How can you store a `Vec<u8>` or `String` in a zero-copy format? Vectors are pointers to heap memory. If I send you my pointer `0x7ffee...`, it points to garbage on *your* machine."
`rkyv` solves this with **Relative Pointers**.
Instead of storing an absolute address (`0x8000`), it stores an offset (`+32 bytes from here`).
When you load the archive into memory:
1. The root object is at some address.
2. The `String` field says "my data is at +64 bytes from my position".
3. You follow the offset — no matter where in RAM the buffer landed.
This makes the data **relocatable**.
## Hands-On: Zero-Copy Market Data
Let's build a zero-copy order book event.
### 1. Dependencies
```toml
[dependencies]
rkyv = { version = "0.7", features = ["validation"] }
```text
### 2. Defining the Struct
We derive `Archive`, `Serialize`, and `Deserialize`. The `check_bytes` macro generates validation logic (critical for untrusted input).
```rust
use rkyv::{Archive, Deserialize, Serialize, Archived};
use bytecheck::CheckBytes;
#[derive(Archive, Deserialize, Serialize, Debug, PartialEq)]
#[archive(check_bytes)] // Enables security validation
#[repr(C)] // Ensure C-compatible layout stability
pub struct MarketEvent {
pub symbol: [u8; 8], // Fixed size array avoids pointer indirections
pub timestamp: u64,
pub price: u64, // Fixed-point (e.g., satoshis or 1e6 units)
pub quantity: u64,
pub side: u8, // 0 = Bid, 1 = Ask
// Avoided String and Vec on the hot path
}
```text
### 3. Serialization (The "Slow" Path)
This happens at the ingress (feed handler).
```rust
let event = MarketEvent {
symbol: *b"ETH-USDC",
timestamp: 1620000000,
price: 3500_000000,
quantity: 10_000000,
side: 1,
};
// Serialize to a fixed-size buffer on the stack (no heap alloc!)
let mut writer = rkyv::ser::serializers::AllocSerializer::<256>::default();
writer.serialize_value(&event).unwrap();
let bytes = writer.into_serializer().into_inner();
```text
### 4. Deserialization (The "Fast" Path)
This is what the matching engine does.
```rust
// UNSAFE: Trusted Zero-Copy (Fastest)
// Use when source is trusted (e.g., our own shared memory ring buffer)
let archived = unsafe { rkyv::archived_root::<MarketEvent>(&bytes) };
println!("Symbol: {:?}", std::str::from_utf8(&archived.symbol));
println!("Price: {}", archived.price);
```sql
**Cost:** Effectively 0 CPU cycles. It is a pointer calculation.
For untrusted input (e.g., network data from an external party), use the validated path:
```rust
// SAFE: Validates the buffer before accessing
use rkyv::validation::validators::DefaultValidator;
let archived = rkyv::check_archived_root::<MarketEvent>(&bytes)
.expect("invalid archive");
Advanced: Shared Memory Ring Buffers
In the ZeroCopy Sentinel, we combine rkyv with a shared memory file (/dev/shm).
- Writer serializes events directly into the memory-mapped file.
- Reader maps the same file.
- Reader receives a signal (or polls a cursor).
- Reader accesses
archived_rootat the specific offset.
This eliminates memcpy between processes. The data written by the feed handler is visible to the strategy engine without any copy.
Benchmarks
These numbers are from published benchmarks and vary by data structure and hardware. The key takeaway is the category difference:
| Format | Deser Time | Allocation | Copying |
|---|---|---|---|
| JSON | ~4,000 ns | Yes | Yes |
| Bincode | ~100-200 ns | Yes | Yes |
| Cap’n Proto | ~5 ns | No | No |
| rkyv | < 1 ns | No | No |
Your actual numbers will depend on your data structure. Run criterion benchmarks on your specific structs.
Summary
- Avoid Parsing: Parsing is overhead that scales with message rate.
- Relocatable Data: Relative pointers make archives position-independent.
- Validate at the edge: Use
check_archived_rootfor untrusted data,archived_rootfor trusted internal paths.
Next, we need a way to pass these zero-copy events between threads without locking. Enter the Disruptor.
Want to go deeper?
Weekly infrastructure insights for engineers who build trading systems.
Free forever. Unsubscribe anytime.
You're in. Check your inbox.
Questions about this lesson? Working on related infrastructure?
Let's discuss