Introduction
What to use
One line per module: what it is for, and the call you make.
| module | what it does | how you use it |
|---|---|---|
ergo-sbe | schema XML → Rust SBE codecs | ergo_sbe::generate_to_out_dir(...) in build.rs; then plain include!, or ergo_sbe::sbe_mod!(name) in your crate, which also needs ergo-sbe in [dependencies] (chapter) |
ergon-tune | reads the host and names what will wreck your tail latency, and the fix | call at start-up; it changes nothing (chapter) |
ergon-io | WebSocket and HTTP/TLS on one busy-spinning thread | poll() from your loop; epoll or io_uring is configuration (chapter) |
ergo-aeron-cluster | Aeron Cluster client | only if you need cluster; experimental, not a production substitute (chapter) |
ergon-md-dpdk | DPDK transport for the Aeron media driver | Linux x86_64 and aarch64; proof of concept — use Aeron Premium for kernel bypass (chapter) |
Kubernetes, if you deploy there. Tuning a node and pod for HFT: Kubernetes HFT tuning — start there.
| module | what it does | how you use it |
|---|---|---|
k8s/nri-shared-cpus | gives a pod its exclusive CPUs and the node’s shared housekeeping CPUs, and starts every thread on the shared ones so the busy-spin loop keeps its core | helm install ergon-shared-cpus oci://ghcr.io/mimran1980/charts/ergon-shared-cpus --version 0.1.0. Image ghcr.io/mimran1980/ergon-shared-cpus:0.1.0. Prepare the node first. Sample: k8s/nri-shared-cpus/examples/try (chapter) |
k8s/nri-latency-pods | node tuning and the hot-pod admission policy (no exec probes, no always-restart sidecars). The grants live in the shared-cpus image. This directory is the YAML and the node units | apply admission/priority-class.yaml and admission/validating-admission-policy.yaml after the chart. Pod authors set annotations and the ergon.io/latency: hot label (chapter) |
Start with ergo-sbe. Add another crate only for a need you have:
ergon-io for exchange I/O, ergon-tune to check a latency host’s settings.
Which clock to read for timestamps and durations: Clocks.
ergo-sbe generates Rust codecs with no heap allocation on the encode and
decode hot paths (allocation_count_test) from
Simple Binary Encoding schemas.
Wire layout matches official SBE for the
published profile.
Tail order is a type error, not a runtime surprise.
Quick start
[build-dependencies]
ergo-sbe = "0.1"
[dependencies]
ergo-sbe = "0.1" # only for sbe_mod! / include_sbe!
#![allow(unused)]
fn main() {
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
// Buffer is exact size from const compute_length_with_header — no bounds check needed.
let len = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.fixed(&HeartbeatFixedFields { sequence: 7, timestamp: 0 })
.encoded_length_with_header();
let dec = HeartbeatDecoder::try_from(&buf[..len])?;
assert_eq!(dec.sequence(), 7);
}
(From book/examples/heartbeat-encode.rs — compiled against the feature-tour codec.)
Set up → · Feature tour → · Coming from sbe-tool →
| Wire order | Named stages — asks before bids does not compile |
| Exact sizing | compute_length_with_header / *EncodedLength before you write; works with Aeron try_claim |
| Groups | Closures nest like the schema; no .parent() |
| Trust boundary | try_* → Result; bare wrap panics if short; bare decode panics if short and Err on wrong template/schema; unsafe *_unchecked skips the proof |
| Composites | #[repr(transparent)] Engine([u8; N]) — the value is the on-wire bytes |
| Domain types | Map wire Decimal to rust_decimal::Decimal with one config line |
| Bulk groups | bulk_add(&[Entry]) / bulk_decode() for fixed-stride leaf groups |
| Runtime deps | Generated codecs embed sbe_rt — no ergo-sbe on the hot path |
0.x experimental. Pin versions. Exit criteria: Road to 1.0.
It is not a line-for-line port of sbe-tool. The generated API is meant to be
easier under Rust’s borrow checker, safer at untrusted boundaries, and still
official-SBE on the wire. Nested groups use scoped closures so you do not
hand encoder ownership back with .parent():
#![allow(unused)]
fn main() {
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
(Compiled and run in CI.)
Wire parity is tested by the dual-encode suite (sbe_tool_wire_parity_test,
sbe_tool_multi_schema_wire_parity_test). The benchmarks also assert byte
parity before timing: Benchmarks.
Deployment
How to set up hosts and Kubernetes for these crates on AWS and Azure, x86 and Graviton: Deployment.
Rust 1.99+. Clone setup and checks: repository README.
Getting Started
Add ergo-sbe as a build dependency, generate a module, include it, encode
and decode.
- Depend on the Generator
- Generate in build.rs
- Include Generated Code
- Encode and Decode
- Method Chaining
- Multi-Schema Patterns
- Coming from sbe-tool
- Error Diagnostics
Depend on the Generator
Minimal product path — codegen only; generated codecs embed their own
sbe_rt and do not link ergo-sbe into the application. Schema parse
errors render a source snippet (line + span) by default:
[build-dependencies]
ergo-sbe = "0.1"
# no [dependencies] ergo-sbe
Convenience path — also pull ergo-sbe as a normal dependency when you use
sbe_mod! / include_sbe! (macros expand in the app crate):
[build-dependencies]
ergo-sbe = "0.1"
[dependencies]
ergo-sbe = "0.1" # only needed for sbe_mod! / include_sbe!
See Samples for monorepo crates that use each pattern.
Generate in build.rs
Two APIs — pick whichever fits your project:
generate_to_dir
writes generated codecs to a directory you control (prefer src/generated/ for
IDE go-to-definition). The feature-tour sample uses this pattern:
#![allow(unused)]
fn main() {
let generated_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/generated");
let config = ergo_sbe::GenerationConfig::new("feature_tour")
.with_domain_objects(ergo_sbe::DomainVarData::Strings)
.with_domain_type(
ergo_sbe::ConversionSelector::named_type("BooleanType"),
"bool",
)
.with_domain_type(
ergo_sbe::ConversionSelector::semantic_type("UTCTimestamp"),
"chrono::DateTime<chrono::Utc>",
)
.with_conversion(ergo_sbe::ConversionSelector::named_type("Decimal"))
// Same shape as Decimal, but the app supplies the impl itself — see
// demo_domain_type_manual_impl in src/lib.rs.
.with_manual_domain_type(
ergo_sbe::ConversionSelector::named_type("ManualDecimal"),
"rust_decimal::Decimal",
);
ergo_sbe::generate_to_dir("schemas/feature-tour.xml", config, &generated_dir)?;
}
generate_to_out_dir
writes to Cargo’s $OUT_DIR and emits cargo::rerun-if-changed automatically.
Use with include!(concat!(env!("OUT_DIR"), …)) — simpler, but
rust-analyzer usually cannot jump into generated code.
Schema from a string / include_str!:
generate_str_to_out_dir.
Need multi-schema or custom output paths? Use the lower-level
parse_file +
Generator
API (same steps the helper runs). For shared types across schemas, see
Multi-Schema Patterns below.
Include Generated Code
Prefer build-dep only for product crates (no runtime ergo-sbe link).
Generated codecs embed sbe_rt; plain include! is enough:
#[path = "generated/feature_tour.rs"]
pub mod feature_tour;
pub use feature_tour::*;
(From sbe-feature-tour/src/lib.rs — the #[path] pattern used by every sample crate. The feature-tour’s cargo build verifies this compiles.)
or via include!:
mod messages {
include!(concat!(env!("OUT_DIR"), "/messages.rs"));
}
use messages::*;
Optional convenience — sbe_mod! needs ergo-sbe as a normal dependency
(macro expansion only; not required for encode/decode):
// Cargo.toml: [dependencies] ergo-sbe = "0.1"
ergo_sbe::sbe_mod!(messages);
use messages::*;
// Or only the include: ergo_sbe::include_sbe!("messages");
See Samples for which crates use which pattern.
Encode and Decode
Two styles — pick whichever fits:
fixed() struct (fill every field at once — compile error if a field is missing):
#![allow(unused)]
fn main() {
pub fn demo_fixed_heartbeat() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Const length → stack array (no heap).
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
let nanos: i64 = 1_720_000_000_000_000_000;
// Buffer pre-sized via const compute_length_with_header; try_* still validates extent.
let written = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)
.unwrap()
.fixed(&HeartbeatFixedFields {
sequence: 7,
timestamp: nanos as u64,
})
.encoded_length_with_header();
let dec = HeartbeatDecoder::try_decode(&buf[..written], 0)?;
assert_eq!(dec.sequence(), 7);
let decoded_ts: DateTime<Utc> = dec.timestamp()?;
assert_eq!(decoded_ts.timestamp_nanos_opt(), Some(nanos));
Ok(buf[..written].to_vec())
}
}
(This code comes from the sbe-feature-tour sample crate.)
{Msg}FixedFields has no Default. Every required scalar must appear in
the struct literal (optional fields use Option only when schema
presence="optional"). That is intentional: zero-filling required IDs hides
bugs. For large messages, build the literal next to the encode call in wire
order.
On a fixed-only message (no groups or var-data), as_bytes_with_header,
as_body_bytes, encoded_length*, and into_remaining_mut exist only after
fixed(&FixedFields). Calling them on the value returned by wrap* is a type
error — that is what stops a reused buffer from publishing leftover body bytes
or packing the next message over a stale body.
Individual setters stay on the unfixed encoder after wrap* and also on
raw_fixed() (body-relative offsets). Prefer fixed(&FixedFields) when you
have every field; use raw_fixed() when you want a dedicated writer:
#![allow(unused)]
fn main() {
// Dedicated raw writer (setters also exist on the unfixed encoder).
// `as_bytes_with_header` is only on FieldsFixed after `fixed(&FixedFields)`.
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
let mut w = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.raw_fixed();
w.sequence(7);
w.timestamp_wire(0);
let dec = HeartbeatDecoder::try_from(&buf[..HeartbeatEncoder::ENCODED_LENGTH])?;
assert_eq!(dec.sequence(), 7);
}
(From book/examples/heartbeat-encode.rs — compiled against the feature-tour codec.)
raw_fixed() writes into the buffer you already sized. It does not mark
the message complete: omitted required setters leave stale bytes, and
as_bytes_with_header / encoded_length* / into_remaining_mut stay locked.
Set every required field, then call fixed(&FixedFields) for the
complete-message views. Slicing &buf[..ENCODED_LENGTH] yourself is possible
but is not the completeness-checked path.
Optional fields and apply_nulls
try_wrap_and_apply_header / wrap_and_apply_header write the message header
only — they do not fill optional fixed fields with schema null sentinels
(sbe-tool parity). Unwritten optional bytes retain whatever was already in the
buffer (often zero, sometimes stale).
fixed() closes that gap for you. Optional fields are Option<T> in the
generated FixedFields struct, and fixed() writes the schema null wire image
for every None — including fixed arrays and nested optional composite
members. Since fixed() is the only route to a message’s tails and to
fixed-only complete byte views, the ordinary path never leaves a stale
optional behind:
// `price` is optional; None writes the schema null image, not stale bytes.
// (Illustrative — use your schema's message name and optional field.)
let len = OrderEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&OrderFixedFields { symbol: *b"IBM ", price: None })
.encoded_length_with_header();
apply_nulls() remains on the unfixed encoder after wrap*, for the case
where you set individual optional fields yourself and no FixedFields value
describes which optionals are unset. It is generated only for messages that
declare at least one optional field, and it is the counterpart to sbe-tool’s
nullify_optional_fields().
Group entries need no call at all. Every add() writes the null image for
the entry’s optional fields before your setters run, so a reused buffer cannot
leak a previous entry’s value. The cover is narrower than the message-level
path, though: it applies to optional fields with a declared nullValue and a
width of 1-8 bytes. An optional array or composite inside a group entry is
not nulled — set it explicitly if your schema has one.
See Why NullVal Instead of Option and, when porting, Coming from sbe-tool.
Character arrays: fixed-width char fields become [u8; N]. Pass a shorter
&str via the _str setter — auto-padded with NULs. On decode, copy_*
copies the raw bytes into your buffer, or read the slice with vehicle_code():
#![allow(unused)]
fn main() {
let complete_len = CarEncoder::compute_length()
.fuel_figures_ragged(0, |_| Ok(()))?
.performance_figures_ragged(0, |_| Ok(()))?
.manufacturer(5)?
.model(5)?
.activation_code(3)?
.encoded_length_with_header();
const PAD: usize = 256;
assert!(complete_len <= PAD, "car length {complete_len} exceeds pad {PAD}");
let mut storage = [0u8; PAD];
let buf = &mut storage[..complete_len];
let fields = CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: *b"ABCDEF",
extras: OptionalExtras::default(),
engine: Engine::new(2000, 4, *b"123", 0i8, false.into(), Booster::new(BoostType::TURBO, 210)),
};
let n = CarEncoder::try_wrap_and_apply_header(buf, 0)?
.fixed(&fields)
.fuel_figures(0, |_| Ok(()))?
.performance_figures(0, |_| Ok(()))?
.manufacturer_as_str("Honda")?
.model_as_str("Civic")?
.activation_code_as_str("abc")?
.encoded_length_with_header();
assert_eq!(n, complete_len);
let car = CarDecoder::try_from(&buf[..n])?;
let mut dst = [0u8; 6];
assert_eq!(car.copy_vehicle_code(&mut dst), 6);
assert_eq!(&dst, b"ABCDEF");
assert_eq!(car.vehicle_code(), *b"ABCDEF");
}
(From book/examples/fixed-char-arrays.rs — compiled against the feature-tour codec.)
The Car encoder’s fixed fields include vehicle_code: [u8; 6] (schema char array)
and some_numbers: [u32; 4]. See the feature tour for the
complete Car example with groups and var-data.
Start here for a full runnable map of features:
sbe-feature-tour
(cargo run --manifest-path samples/sbe-feature-tour/Cargo.toml).
More recipes: Recipes.
Method Chaining
ergo-sbe encoders are designed so the entire encode reads as one expression,
from wrap_and_apply_header through .fixed(...) and every dynamic tail,
ending in .encoded_length_with_header(). Bind only the resulting length; do not retain
intermediate encoder variables.
Prefer (one chain, one let):
#![allow(unused)]
fn main() {
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
// Buffer is exact size from const compute_length_with_header — no bounds check needed.
let len = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.fixed(&HeartbeatFixedFields { sequence: 7, timestamp: 0 })
.encoded_length_with_header();
let dec = HeartbeatDecoder::try_from(&buf[..len])?;
assert_eq!(dec.sequence(), 7);
}
(From book/examples/heartbeat-encode.rs — compiled against the feature-tour codec.)
Staged chaining vs fixed-only
For a fixed-only message like Heartbeat, wrap* returns
HeartbeatEncoder<'_, H, FieldsUnfixed>. .fixed(&HeartbeatFixedFields { … })
consumes that value and returns FieldsFixed, which is the only phase that
exposes as_bytes_with_header / as_body_bytes / encoded_length* /
into_remaining_mut. Individual field setters stay
on the unfixed phase and on raw_fixed(); they are not on
that complete view.
Avoid (interrupted chain, rebinding):
// Each `let` breaks the chain and splays the pipeline across the screen.
// The `.unwrap()` calls are a code smell — the fallible chain should use `?`.
let enc = CarEncoder::wrap_and_apply_header(&mut buf, 0).fixed(&fields);
let enc = enc.fuel_figures(2, |g| { ... }).unwrap();
let enc = enc.manufacturer_as_str("Honda").unwrap();
let len = enc.encoded_length_with_header();
Every encoder stage is chainable — fixed() and each tail method return
the next stage (or Result<NextStage, _>) and compose with ? in the same
expression. Intermediate encoder rebinding and manual .unwrap() defeat this
design.
For the full Car example with groups and var-data, see the feature tour page.
Decoding
Sequential decode consumes each tail in wire order. For callbacks throughout
the chain, start with decoder.ordered():
#![allow(unused)]
fn main() {
pub fn demo_car_ordered_lane(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let mut speeds = Vec::new();
let mut octanes = Vec::new();
let mut text = Vec::new();
let len = CarDecoder::try_decode(wire, 0)?
.ordered()
.fixed(|car| {
assert_eq!(car.serial_number(), 1234);
Ok(())
})?
.fuel_figures(|entry, info| {
// `info` carries the position the wire declares.
assert_eq!(info.count, 2);
assert_eq!(info.is_last(), info.index == 1);
speeds.push(entry.speed());
Ok(entry.ordered().usage_description(|_| Ok(()))?)
})?
.performance_figures(|entry, _info| {
octanes.push(entry.octane_rating());
Ok(entry.ordered().acceleration(|a, _| {
let _ = (a.mph(), a.seconds());
Ok(())
})?)
})?
.manufacturer_as_str(|s| {
text.push(s.to_owned());
Ok(())
})?
.model_as_str(|s| {
text.push(s.to_owned());
Ok(())
})?
.activation_code_as_str(|s| {
text.push(s.to_owned());
Ok(())
})?
.encoded_length_with_header();
assert_eq!(speeds, vec![30, 60]);
assert_eq!(octanes, vec![95]);
assert_eq!(text, vec!["Honda", "Civic VTi", "abcdef"]);
assert_eq!(len, wire.len());
Ok(())
}
}
The fixed callback comes before tails and receives a fixed-fields-only view;
each group callback receives the entry and its EntryInfo, and var-data
callbacks receive bytes or strictly validated text. Every callback returns
Result<_, DecodeError>, so the closures infer. These
callbacks borrow the original wire buffer, so bytes and text may
be retained as the chain advances. The message chain ends on
encoded_length_with_header() / as_bytes_with_header(), like encode —
.done() is only to unwrap the staged complete. A nested ordered() walk
returns that completion to the parent callback directly. The
L3 sample
shows this recursive form.
The underlying staged API also supports explicit iteration and skipping. Its group and var-data spellings can require iterator and tuple bindings:
| Group entries | into_<group> gives you | Why |
|---|---|---|
| Carry their own groups or var-data | a visit closure, returning the entry’s completion | no stride — the completion is where the next entry starts |
| Fixed-stride (no tails of their own) | an iterator, for e in &mut iter | the next entry is offset + block length, so nothing has to be measured |
Var-data into_<name>() returns (payload, next). Text fields also offer
into_<name>_as_str(), with strict validation and the same tuple shape.
These borrowed payloads can be retained while advancing later stages.
Prefer:
#![allow(unused)]
fn main() {
pub fn demo_car_decode_stages(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let car = CarDecoder::try_decode(wire, 0)?;
assert_eq!(car.serial_number(), 1234);
assert_eq!(car.model_year(), 2013);
// Domain conversion: BooleanType → bool when configured.
let available: bool = car.available()?;
assert!(available);
assert_eq!(car.code(), Model::A);
assert_eq!(car.discounted_model(), Model::C); // constant field
assert_eq!(car.engine().capacity(), 2000);
// Schema order: groups then var-data, one chain. Both of Car's groups have
// entries that carry their own tails, so both take a visit closure: the
// completion the closure returns is where the next entry starts, so the
// group is walked exactly once.
let mut speeds = Vec::new();
let mut octanes = Vec::new();
let (mfr, decoder) = car
.into_fuel_figures(|entry| -> Result<_, sbe_rt::DecodeError> {
speeds.push(entry.speed());
// usageDescription is this entry's only tail, so reading it
// completes the entry.
entry.into_usage_description().map(|(_usage, done)| done)
})?
.into_performance_figures(|entry| -> Result<_, sbe_rt::DecodeError> {
octanes.push(entry.octane_rating());
// acceleration has fixed-stride entries, so it is a real iterator.
let mut accel = entry.into_acceleration()?;
assert_eq!((&mut accel).len(), 2);
accel.finish()
})?
.into_manufacturer_as_str()?;
assert_eq!(speeds, vec![30, 60]);
assert_eq!(octanes, vec![95]);
let (model, decoder) = decoder.into_model_as_str()?;
let (code, _decoder) = decoder.into_activation_code_as_str()?;
// All three &str coexist — each borrows 'a from the original wire buffer.
assert_eq!((mfr, model, code), ("Honda", "Civic VTi", "abcdef"));
Ok(())
}
}
(Real code from the sbe-feature-tour sample. fuelFigures and
performanceFigures entries carry tails, so they take closures;
acceleration is fixed-stride, so it is an iterator.)
Consecutive var-data fields chain through the next stage each tuple
returns; the borrowed slices stay valid while later stages advance:
#![allow(unused)]
fn main() {
fn count_car_text_bytes(wire: &[u8]) -> Result<usize, sbe_rt::DecodeError> {
let (manufacturer, next) = CarDecoder::try_from(wire)?
.skip_fuel_figures()?
.skip_performance_figures()?
.into_manufacturer()?;
let (model, next) = next.into_model()?;
let (activation_code, _complete) = next.into_activation_code()?;
Ok(manufacturer.len() + model.len() + activation_code.len())
}
}
For callbacks instead of tuples, use the ordered lane’s <name>(|bytes| ..)
and <name>_as_str(|text| ..), which return DecodeError.
The generated staged walk advances once through dynamic tails. Random-access getters used before that walk, including counts or lengths for later tails, can add scans. See Asking before walking.
Multi-Schema Patterns
SBE schemas often share types (messageHeader, groupSizeEncoding,
composites, enums, sets). ergo-sbe supports two approaches:
| Approach | When | Method |
|---|---|---|
xi:include (standard) | Schema files live together; official SBE portability matters | <include href="common-types.xml"/> — parse_file resolves includes relative to the base dir |
Shared Ir (programmatic) | Schemas are parsed from strings, generated, or live in separate repos; no filesystem dependency | parse_with_shared / parse_file_with_shared — seed one parse from another’s resolved types |
The <include> path is what the SBE spec expects. The shared-Ir path is a
convenience for tooling, build scripts, and any workflow where you already have
the shared schema parsed in memory.
Shared Ir — parse, then share
// 1. Parse the shared schema once (composites, enums, sets).
let common = ergo_sbe::parse_file("schemas/common-types.xml")?;
// 2. Parse a consumer schema — no <include> needed.
let orders = ergo_sbe::parse_file_with_shared("schemas/orders.xml", &common)?;
// 3. Each schema gets its own module.
let generator = ergo_sbe::Generator::new(
ergo_sbe::GenerationConfig::new("common_types").with_shared_module("common_types"),
);
let modules = generator.generate_multi(&[
(&ergo_sbe::Schema::from_ir(common), "common_types"),
(&ergo_sbe::Schema::from_ir(orders), "orders"),
])?;
With with_shared_module("common_types"), the first entry owns the shared
enums/sets/composites; later entries pub use super::common_types::* and skip
duplicate type generation.
Module names must be unique, non-empty Rust identifiers (no path separators or
keywords). Before any file is written, shared types with the same name are
compared by a canonical wire fingerprint (token order, primitive encodings,
offsets, presence, null/min/max, discriminants/choices, sinceVersion, and
schema byte order). A name collision with a different fingerprint fails
generation with GenerateError::IncompatibleSharedType rather than silently
reusing the first schema’s layout.
parse_with_shared from in-memory strings
let common = ergo_sbe::parse(
r#"<?xml version="1.0"?>
<messageSchema package="common" id="0" version="1" byteOrder="littleEndian">
<types>
<composite name="messageHeader">
<type name="blockLength" primitiveType="uint16"/>
<type name="templateId" primitiveType="uint16"/>
<type name="schemaId" primitiveType="uint16"/>
<type name="version" primitiveType="uint16"/>
</composite>
<composite name="Price">
<type name="mantissa" primitiveType="int64"/>
<type name="exponent" primitiveType="int8"/>
</composite>
</types>
</messageSchema>"#,
)?;
// No <types> / <include> — Price resolves from `common`.
let orders = ergo_sbe::parse_with_shared(
r#"<?xml version="1.0"?>
<messageSchema package="orders" id="1" version="1" byteOrder="littleEndian"
headerType="messageHeader">
<message name="NewOrder" id="1">
<field name="price" id="1" type="Price"/>
</message>
</messageSchema>"#,
&common,
)?;
The shared Ir path does not recover bare top-level <type> typedefs
(those are inlined during parsing and dropped from the token stream). Reference
them through a <composite> / <enum> / <set> in the shared schema instead.
Full build.rs — one helper call
Module names are supplied, not derived from file stems, so a hyphenated
common-types.xml can emit common_types.rs. The helper parses the shared
schema first, resolves consumers against it, validates the whole set, then
writes. A late consumer failure leaves no partial files. Cargo watches every
root and every resolved include.
// build.rs
use std::path::Path;
use ergo_sbe::{GenerationConfig, SchemaFile, generate_multi_to_out_dir};
fn main() -> ergo_sbe::miette::Result<()> {
generate_multi_to_out_dir(
SchemaFile::new(Path::new("schemas/common-types.xml"), "common_types"),
&[
SchemaFile::new(Path::new("schemas/orders.xml"), "orders"),
SchemaFile::new(Path::new("schemas/fills.xml"), "fills"),
],
GenerationConfig::new("common_types"),
)?;
Ok(())
}
Consumer modules import the shared module for cross-schema type resolution:
mod common_types { include!(concat!(env!("OUT_DIR"), "/common_types.rs")); }
mod orders {
use super::common_types::*; // shared types + header composite
include!(concat!(env!("OUT_DIR"), "/orders.rs"));
}
mod fills {
use super::common_types::*;
include!(concat!(env!("OUT_DIR"), "/fills.rs"));
}
AnyMessage is generated once per schema module. Template ids are not unique
across schemas. Generate an opt-in AnySchemaMessage over every configured
schema with Generator::generate_schema_dispatch:
#![allow(unused)]
fn main() {
use ergo_sbe::{GenerateError, GeneratedModule, GenerationConfig, Generator, Schema};
fn dispatcher(market: &Schema, trading: &Schema) -> Result<GeneratedModule, GenerateError> {
Generator::new(GenerationConfig::new("market")).generate_schema_dispatch(
&[(market, "market"), (trading, "trading")],
"any_schema",
)
}
}
Write the returned source to its path beside the codec modules, then include
any_schema.rs in a sibling module. This also works when the codecs were
generated separately with different conversion settings. Each codec must have
AnyMessage dispatch enabled. The combined enum wraps it in a variant named
from the module, such as Market and Trading, and reads schemaId before
templateId. Decode errors retain the originating schema’s error type.
Schema ids and module names must be unique. Headers must declare the same wire
layout and byte order, with a required, encoded schemaId. Custom layouts and
big-endian headers work; mixed layouts or byte orders are rejected as ambiguous.
Single-schema generation and the ordinary codec hot paths are unchanged.
Pass a slice bounded to one transport frame to AnySchemaMessage::decode.
An unknown schema id returns Other { schema_id, template_id, frame }. An
unknown template in a known schema remains that module’s AnyMessage::Unknown.
Both retain the supplied header and body. as_bytes() borrows the complete
frame without allocating, including future-version fields and unknown frames,
for persistence or forwarding.
See also: sbe-codegen-examples (reusable generator setup), multi_schema_versioning_test (versioned schemas with shared types), exchange-example (multi-schema exchange feed with IPC).
Coming from sbe-tool
Side-by-side mapping for teams migrating from the official Simple Binary
Encoding Rust generator (sbe-tool). Within the [published SBE profile]
(../design-notes/feature-matrix.md) and
docs/SBE_COMPATIBILITY.md,
ergo-sbe aims for official-SBE wire fidelity with sbe-tool; the API shape
is intentionally different. Do not read this as unqualified “binary
compatible with every SBE feature.”
The #1 trap: wrap offset
| sbe-tool Rust | ergo-sbe | |
|---|---|---|
wrap argument | Body offset (often 8 for a frame at 0) | Message start (often 0) |
| Where fields live | body_offset + field_offset | message_offset + HEADER_LENGTH + field_offset |
// Frame at buf[0..]:
// sbe-tool: enc.wrap(buf, 8) // body starts at 8
// ergo-sbe: Enc::wrap(buf, 0) // message starts at 0
// Enc::wrap_and_apply_header(buf, 0)
Passing sbe-tool’s 8 into ergo-sbe for a frame at zero mis-aligns every
field. Generated rustdoc on wrap / wrap_and_apply_header / decode
repeats this callout.
Header modes (fair comparison table)
Use the same logical work on both sides when comparing or porting:
| Mode | ergo-sbe | sbe-tool |
|---|---|---|
| Body only | wrap(buf, 0) + setters — no apply-header | wrap(buf, 8) + setters — no .header(0) |
| Header + body | wrap_and_apply_header(buf, 0) + setters | wrap(buf, 8) then header(0).parent() then setters |
| Header only | wrap_and_apply_header alone | wrap(buf, 8).header(0) alone |
- ergon
wrap= message start; sbe-toolwrap= body offset. - sbe-tool
encoded_length()is body only. ergonencoded_length_with_header()includes the header. Never invent8 + encoded_length()to “prove” a header was written if.header(0)was not called on the sbe-tool arm.
Full fairness rules for benchmarks: Benchmarks methodology.
Groups: .parent() hopscotch vs closures
| sbe-tool | ergo-sbe |
|---|---|
Open group flyweight, fill entries, .parent() back | enc.bids(n, |bids| { bids.add(|e| { … })?; Ok(()) })? |
| Nested groups fight the borrow checker | Nested closures end; chain continues in wire order |
#![allow(unused)]
fn main() {
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
Compile-time order: you cannot call asks before bids — the stage type
has no asks method. See
Wire order via named stages.
Length APIs
| Concept | sbe-tool | ergo-sbe |
|---|---|---|
| Body length after encode | encoded_length() | body region only via stage encoded_length where exposed |
| Header + body | compute yourself (8 + …) | encoded_length_with_header() / as_bytes_with_header() |
| Pre-size buffer | often oversize scratch | Exact: const Encoder::compute_length_with_header() (stack [0u8; N]), or Encoder::compute_length() / staged *EncodedLength when the length depends on groups or var-data |
Do not default to vec![0u8; 4096] or Vec::with_capacity(MAX) then
truncate. See Buffer sizing and
Exact sizing.
Optional fields: nullify_optional_fields() → apply_nulls()
Neither generator nullifies on wrap, so in sbe-tool an optional field you
never set keeps whatever was already in the buffer unless you remember to
call nullify_optional_fields(). The ergon equivalent is apply_nulls(),
but the port is usually to delete the call rather than rename it:
| sbe-tool | ergo-sbe |
|---|---|
enc.nullify_optional_fields() on the message, then set fields | .fixed(&MsgFixedFields { price: None, … }) — None writes the schema null image |
nullify_optional_fields() on a group entry encoder | nothing to call — every add() nulls the entry’s optional scalars |
| Forgetting the call leaks stale bytes | fixed() is the only route to tails and to complete byte views, so the ordinary path cannot leak |
Optional fields are Option<T> in the generated *FixedFields struct, and
fixed() writes the schema null wire image for every None — including
fixed arrays and nested optional composite members.
apply_nulls() is still generated (message encoders with at least one
optional field, on the unfixed stage after wrap*) for the case where you
write individual optionals through raw_fixed() and have no FixedFields
value describing which ones are unset.
One asymmetry to carry across: group-entry auto-nulling covers optional
fields that have a declared nullValue and a width of 1-8 bytes. An optional
array or composite inside a group entry is not nulled, where the
message-level path does handle those shapes. If your schema has one, set it
explicitly.
See Optional fields and apply_nulls
and Why NullVal instead of Option.
Decode entry
ergo-sbe decoder wrap and try_wrap take the message start, including
the header, while sbe-tool’s wrap takes the body offset. Use try_decode
when the header metadata is not already available:
| Need | ergo-sbe |
|---|---|
| Untrusted / network | try_decode / try_wrap / try_from → Result (all failures) |
| Known-good buffer | bare wrap → panic if short; bare decode → hybrid (panic if short, Err on wrong template/schema) |
| Proven-tight hot path | unsafe wrap_unchecked; decode_unchecked = unchecked extent + checked identity |
See Trust Boundary.
Decoding: lanes, not one cursor
sbe-tool gives you a single &mut decoder carrying a limit cursor. Every
group and var-data accessor reads at limit and advances it, so the order you
call methods in is the wire walk. ergon splits that:
- Sequential:
into_*/skip_*(ordecoder.ordered()for one callback per tail) - Random-access:
try_decode— any order,Sync
A group whose entries carry their own tails takes a visit closure; a fixed-stride group hands back an iterator.
Start here when porting:
| Your sbe-tool code | Port to |
|---|---|
| Straight-line walk: group, group, var-data, done | staged into_* / skip_* — one traversal, compile-time tail order |
| Re-wrapping the message a second time to read a field you passed | the base decoder, or decoder.memoized() if you re-read deep tails |
| Reading two fixed fields and dropping the rest | the base decoder from try_decode — no cursor, Sync, nothing to consume |
// sbe-tool: one cursor, order is a convention
let car = CarDecoder::default()
.wrap(ReadBuf::new(buf), 8, block_len, version); // body offset, not 0
let mut ff = car.fuel_figures_decoder(); // consumes `car`
while ff.advance()?.is_some() { /* … */ }
let mut car = ff.parent()?; // hand it back
let coords = car.manufacturer_decoder(); // (offset, len)
let manufacturer = car.manufacturer_slice(coords);
// ergo-sbe: consuming stages carry the next tail offset.
// fuelFigures entries carry var-data, so the step is a visit closure and the
// completion it returns is where the next entry starts — one pass, no rescan.
let (manufacturer, car) = CarDecoder::try_decode(buf, 0)?
.into_fuel_figures(|e| e.into_usage_description().map(|(_usage, done)| done))?
.skip_performance_figures()?
.into_manufacturer()?;
Note what disappears in the port: no .parent() hop, no (offset, length)
coordinate pair threaded to a second _slice() call, and no &mut on a
decoder you only wanted to read.
Fixed-block messages — no groups, no var-data — get no memoized()
or ordered(), and AnyMessage offers only into_<name>() for them. Every
field is already random-access off the block.
Full per-lane pros, cons, and cost table: Decoder lanes.
Version handling
Decoders are version-aware: tail offsets use the wire acting block
length, not only the compiled block length. Optional / sinceVersion fields
follow schema presence rules. Prefer explicit try_* entry points when
reading mixed-version streams.
What has no direct ergon equivalent (and why)
| sbe-tool habit | ergo-sbe |
|---|---|
.parent() ownership hop | Encode: nested closures. Decode: consuming stages — a visit closure for dynamic groups, an iterator for fixed-stride ones |
One &mut decoder with a limit cursor | Lanes: random-access &Decoder, staged into_*, optional ordered() / .memoized() (decoder lanes) |
_decoder() returning (offset, len) for a second _slice() call | Var-data accessors return &'a [u8] / &'a str directly |
Generic Encoder<State> spelling | Named stage structs + H: HeaderState only for header mode (type-state note) |
encoded_length() as full-frame size | Use *_with_header when you need the frame |
nullify_optional_fields() on group and composite encoders | Group entries null their optional scalars on add(); only the message encoder gets apply_nulls() (above) |
| Always-on meta / Display noise | Opt-out size knobs: with_display_debug(false), with_meta_attributes(false), with_dispatch(false) |
Trust boundary
try_* returns Result on short buffers (and identity mismatches). Bare
wrap panics if short. Bare decode is a hybrid: panics if short, but
still returns Err on wrong template/schema. decode_unchecked is unchecked
extent + checked identity. After a safe constructor succeeds, fixed-field
accessors are branch-free. Full detail:
Trust Boundary.
Further reading
Error Diagnostics
Schema errors use miette for pinpointed diagnostics.
The generator shows what went wrong, where in the XML, and a unique
error code you can match on programmatically.
Invalid type reference
Referencing a type that doesn’t exist:
<field name="badField" id="10" type="NonExistentType"/>
ergo_sbe::schema_parse::invalid
× invalid type for field 'badField': NonExistentType
╭─[schema.xml:15:9]
14 │ <!-- NonExistentType not defined -->
15 │ <field name="badField" id="10" type="NonExistentType"/>
· ───────────────────────────┬───────────────────────────
· ╰── invalid here
16 │ </message>
╰────
The error code ergo_sbe::schema_parse::invalid identifies the variant.
The span points to the exact attribute. The source line and surrounding context
are rendered automatically.
Missing required attribute
Omitting name on a <field>:
ergo_sbe::schema_parse::missing
× missing field @name
╭─[schema.xml:15:9]
14 │ <message name="TestMessage" id="1">
15 │ <field id="10" type="uint8"/>
· ──────────────┬──────────────
· ╰── missing here
16 │ </message>
╰────
Duplicate template ID
Two messages sharing the same id:
ergo_sbe::schema_parse::resolve
× resolution error: duplicate template id 1 for message
│ AnotherMessageWithId1
╰─▶ duplicate template id 1 for message AnotherMessageWithId1
Invalid enum encoding type
ergo_sbe::schema_parse::invalid
× invalid enum encodingType: NonExistentEncodingType
╭─[schema.xml:13:9]
12 │ <!-- encodingType references non-existent type -->
13 │ ╭─▶ <enum name="BadEnum" encodingType="NonExistentEncodingType">
14 │ │ <validValue name="Value1">1</validValue>
15 │ ├─▶ </enum>
· ╰──── invalid here
16 │ </types>
╰────
Multi-line spans show the full element, with the label pointing to the offending attribute.
Use in build scripts
ParseError implements miette::Diagnostic. Wrap it in miette::Report
to render the full diagnostic with source context:
use ergo_sbe::parse_file;
match parse_file("my-schema.xml") {
Ok(_) => { /* regenerate codec */ }
Err(e) => {
let report = miette::Report::new(e);
eprintln!("{report:?}");
std::process::exit(1);
}
}
For programmatic handling, match on the variant directly — ParseError is
a plain enum, no downcast needed. Keep a wildcard so new variants are not
a compile break:
#![allow(unused)]
fn main() {
use ergo_sbe::{parse_file, ParseError};
match parse_file("my-schema.xml") {
Ok(_) => {}
Err(ParseError::MalformedXml { message, .. }) => {
eprintln!("malformed XML: {message}");
}
Err(ParseError::Missing { what, .. }) => {
eprintln!("missing {what}");
}
Err(ParseError::Invalid { what, value, .. }) => {
eprintln!("invalid {what}: {value}");
}
Err(ParseError::Resolve { error, .. }) => {
eprintln!("resolve: {error}");
}
Err(ParseError::Io { path, source, .. }) => {
eprintln!("cannot read {}: {source}", path.display());
}
Err(ParseError::Include { href, cause, .. }) => {
eprintln!("include {href}: {cause}");
}
// Forward-compatible: ParseError is #[non_exhaustive].
Err(other) => {
eprintln!("{other}");
}
}
}
Out-of-range null / min / max
A present nullValue, minValue, or maxValue is parsed fail-closed against
the declared primitive width. nullValue="256" on uint8 is rejected (it is
not a valid one-byte sentinel). Signed types accept the type’s full range
(int8 -1 is fine; int8 128 is not).
ergo_sbe::schema_parse::invalid
× invalid nullValue: '256' is out of range for UInt8
Without that check the generator used to emit 256_u64 as u8, so Some(0)
and None collided on the wire.
Error variants
ParseError is #[non_exhaustive]. Match with a wildcard. Current variants:
| Variant | Error code | When |
|---|---|---|
MalformedXml | ergo_sbe::schema_parse::malformed_xml | XML is not well-formed |
Missing | ergo_sbe::schema_parse::missing | Required attribute or element absent |
Invalid | ergo_sbe::schema_parse::invalid | Value is syntactically or semantically wrong |
Resolve | ergo_sbe::schema_parse::resolve | Cross-reference or schema-level validation failure |
Io | ergo_sbe::schema_parse::io | Root schema file could not be read (Error::source is the std::io::Error) |
Include | ergo_sbe::schema_parse::include | Include failed. cause is IncludeCause: Cycle { chain } (visit order, ending at the repeated file; a diamond/shared include is not a cycle), Io { path, source }, or NotFound. attempted lists tried paths. |
Every diagnostic variant except Io carries source_code and an optional
span. Include highlights the <include> element when the include was
parsed from a document.
Migration from 0.1.x: IncludeError { message } is now Include { href, attempted, cause, .. }. Root read_to_string failures are Io, not
MalformedXml.
Feature Tour
Runnable examples pull live source from sbe-feature-tour (compiled by
docs_validation_test). Schematics that cannot run in the harness stay
rust,ignore.
- Exact Sizing —
compute_length_with_headerbefore you encode - Bulk Arrays —
bulk_add(&[Entry])for fixed-stride leaf groups - Decoder Lanes — random access, staged
into_*, ordered callbacks,.memoized() - What Generated Code Looks Like — stages, metadata, reserved names
- Trust Boundaries —
try_*→Result;wrappanics if short;decodeis hybrid - Domain Objects (DTOs) — owned snapshots (never on the hot path)
- Multi-Template Dispatch —
AnyMessageroutes by template ID
Exact Sizing
Dynamic messages expose schema-aware size APIs. Flat shapes get a direct
checked helper; nested or ragged shapes get a staged *EncodedLength builder.
Allocate or claim exactly that many bytes, then write groups and var-data in
wire order:
#![allow(unused)]
fn main() {
pub fn demo_car_size_and_encode() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Fuel: 2 entries with usage ASCII lengths 5 and 7.
// Performance: 1 entry with 2 nested acceleration rows (fixed-only entries).
// Message var-data: manufacturer / model / activationCode lengths.
let complete_len = CarEncoder::compute_length()
.fuel_figures_ragged(2, |ff| {
ff.add()?.usage_description(5)?; // "Urban"
ff.add()?.usage_description(7)?; // "Highway"
Ok(())
})?
.performance_figures_ragged(1, |pf| {
pf.add()?.acceleration(|acc| {
acc.uniform(2)?;
Ok(())
})?;
Ok(())
})?
.manufacturer(5)? // "Honda"
.model(9)? // "Civic VTi"
.activation_code(6)? // "abcdef"
.encoded_length_with_header();
// Exact size from compute_length → stack pad (this demo fits well under 512).
const CAR_PAD: usize = 512;
assert!(
complete_len <= CAR_PAD,
"sample car length {complete_len} exceeds stack pad {CAR_PAD}"
);
let mut storage = [0u8; CAR_PAD];
let written = encode_sample_car(&mut storage[..complete_len])?;
assert_eq!(
written, complete_len,
"CarEncodedLength must equal encoder-produced length"
);
Ok(storage[..written].to_vec())
}
/// Encode the canonical sample car into `buf` (must be pre-sized).
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
(This code comes from the sbe-feature-tour sample crate.)
Bulk Arrays
For repeating groups with fixed-size entries (no var-data, no nested
groups), generated encoders offer a bulk_add(&[Entry]) path that validates
the destination region once and writes every entry.
Car fuelFigures has var-data (usageDescription), so it is not eligible.
The nested acceleration group is — each row is mph + seconds only:
#![allow(unused)]
fn main() {
pub fn demo_bulk_add() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true);
let rows = [
CarPerformanceFiguresAccelerationEntry {
mph: 30,
seconds: 4.0,
},
CarPerformanceFiguresAccelerationEntry {
mph: 60,
seconds: 7.5,
},
];
let complete_len = CarEncoder::compute_length()
.fuel_figures_ragged(0, |_| Ok(()))?
.performance_figures_ragged(1, |pf| {
pf.add()?.acceleration(|acc| {
acc.uniform(2)?;
Ok(())
})?;
Ok(())
})?
.manufacturer(5)?
.model(5)?
.activation_code(3)?
.encoded_length_with_header();
const PAD: usize = 256;
assert!(
complete_len <= PAD,
"bulk-add car length {complete_len} exceeds pad {PAD}"
);
let mut storage = [0u8; PAD];
let buf = &mut storage[..complete_len];
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)?
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: *b"ABCDEF",
extras,
engine: Engine::new(
2000,
4,
*b"123",
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(0, |_| Ok(()))?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.bulk_add(&rows)?;
Ok(())
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic")?
.activation_code_as_str("abc")?
.encoded_length_with_header();
assert_eq!(len, complete_len);
Ok(buf[..len].to_vec())
}
}
(From samples/sbe-feature-tour — compiled and run in that crate’s tests.)
bids / asks on the l3-book schema have a nested orders group, so those
outer groups are not eligible either. Only a leaf group whose entries are
a pure fixed block gets bulk_add.
Constants and MetaAttribute expose schema metadata on every generated type
(HeartbeatDecoder::sequence_meta_attribute(MetaAttribute::Presence) and
friends). See the generated module after cargo build of the feature-tour
sample.
Decoder Lanes
A decoder with groups or var-data has two jobs: read some fields in any
order, or walk the whole message once in schema order. A group decoder from a
random-access getter implements Iterator for partial traversal; it is not a
third message-decoding lane.
Encoding is one state machine: each stage is the cursor, so the next legal field is the only method on that type. See Encode and Decode and Method Chaining. Decoding is the choice, because a decoder can be asked to read in an order the encoder never had.
| Lane | Entry | Order | Dynamic-tail cost | Sync |
|---|---|---|---|---|
| Random access | try_decode / wrap getters | Any | Recalculates preceding offsets | yes |
| Staged | into_* / skip_* | Compile time | One forward traversal | yes |
| Ordered | decoder.ordered() | Compile time | Delegates to staged | yes |
| Memoized | decoder.memoized() | Any | Cache of discovered boundaries | no (Send) |
Fixed fields stay random-access in all of them. Ordered also offers a fixed
callback (fixed-fields-only view, not the full decoder). A message with no
groups and no var-data has one lane: the base decoder. memoized() and
ordered() are not generated; AnyMessage offers only into_<name>().
| If you want… | Use |
|---|---|
Some fields, any order, share &Decoder | Random access (default) |
| Whole message in wire order | Staged into_* / skip_* |
| Whole message, one callback per tail | Ordered (decoder.ordered()) |
| Same decoder, many helpers, mixed tail order | Memoized — benchmark it; not the default |
Worked nested example:
L3 order book.
AnyMessage can land in a lane directly (into_car(), into_car_memoized()).
sbe-tool has one &mut flyweight with a limit cursor. Wrong order reads
the next group’s bytes as a length prefix; re-read means re-wrap; no useful
&-sharing. Ergon splits those jobs. Full mapping:
Coming from sbe-tool.
In every ergon lane, try_* construction makes a short buffer a DecodeError
(not a panic), and a group’s numInGroup is checked against the bytes that
are actually there before an entry reaches you.
Random access
Any-order getters. The decoder holds the buffer, offset, and acting header values — nothing else. Each dynamic-tail getter re-walks from the fixed block.
#![allow(unused)]
fn main() {
pub fn demo_car_random_access(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let car = CarDecoder::try_decode(wire, 0)?;
// Manufacturer is the first var-data field, after both groups — still legal
// here because random access rescan preceding tails.
assert_eq!(car.manufacturer_as_str()?, "Honda");
assert_eq!(car.serial_number(), 1234);
let mut speeds = Vec::new();
for entry in car.fuel_figures()? {
speeds.push(entry?.speed());
}
assert_eq!(speeds, vec![30, 60]);
assert_eq!(car.model_as_str()?, "Civic VTi");
Ok(())
}
}
Memoized (decoder.memoized())
Same getter names. First access to a tail walks forward from the cache
frontier; later accesses reuse it. Construction is O(1) and allocates nothing.
Decoded values match the base lane, including schema max-length rejection:
warming the cache with encoded_length() does not accept a payload the base
getter would refuse.
let decoder = CarDecoder::try_from(bytes)?; // small, Sync, recalculates tails
let decoder = decoder.memoized(); // lazy cache, no allocation
read_header(&decoder)?;
read_groups(&decoder)?;
read_final_tail(&decoder)?; // reuses discovered boundaries
Build it once and pass &CarMemoizedDecoder. Calling .memoized() inside
every function creates a separate empty cache. Cell interior mutability:
Send but not Sync. into_inner() returns the base decoder.
The base lane’s tail_offset_k re-walks every preceding tail on every
getter — quadratic even in schema order. Memoized makes that sweep linear.
Staged already carries the cursor, so it needs no cache.
Use it when several helpers read the same decoder, or when you jump to a late
tail and come back. Skip it for one tail and stop, a full wire-order walk
(use staged), or if you need Sync.
The cache covers message-level tails only. Nested groups inside an entry
use an ordinary entry decoder. just bench-diagnostics → versioned_l3_bench.
Staged (into_* / skip_*)
Sequential decode, the encoder dual. Each into_* consumes the stage. Wrong
order is a missing method.
| Group entries | into_<group> | Why |
|---|---|---|
| Own groups or var-data | visit closure returning the entry’s completion | No stride — the completion is the next offset |
| Fixed-stride (no tails) | iterator | Next entry is offset + acting block length |
Iterator is implemented for &mut Iter only — for entry in iter does not
compile, so a loop cannot drop the rest of the message. Also
ExactSizeIterator and FusedIterator.
#![allow(unused)]
fn main() {
pub fn demo_car_decode_stages(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let car = CarDecoder::try_decode(wire, 0)?;
assert_eq!(car.serial_number(), 1234);
assert_eq!(car.model_year(), 2013);
// Domain conversion: BooleanType → bool when configured.
let available: bool = car.available()?;
assert!(available);
assert_eq!(car.code(), Model::A);
assert_eq!(car.discounted_model(), Model::C); // constant field
assert_eq!(car.engine().capacity(), 2000);
// Schema order: groups then var-data, one chain. Both of Car's groups have
// entries that carry their own tails, so both take a visit closure: the
// completion the closure returns is where the next entry starts, so the
// group is walked exactly once.
let mut speeds = Vec::new();
let mut octanes = Vec::new();
let (mfr, decoder) = car
.into_fuel_figures(|entry| -> Result<_, sbe_rt::DecodeError> {
speeds.push(entry.speed());
// usageDescription is this entry's only tail, so reading it
// completes the entry.
entry.into_usage_description().map(|(_usage, done)| done)
})?
.into_performance_figures(|entry| -> Result<_, sbe_rt::DecodeError> {
octanes.push(entry.octane_rating());
// acceleration has fixed-stride entries, so it is a real iterator.
let mut accel = entry.into_acceleration()?;
assert_eq!((&mut accel).len(), 2);
accel.finish()
})?
.into_manufacturer_as_str()?;
assert_eq!(speeds, vec![30, 60]);
assert_eq!(octanes, vec![95]);
let (model, decoder) = decoder.into_model_as_str()?;
let (code, _decoder) = decoder.into_activation_code_as_str()?;
// All three &str coexist — each borrows 'a from the original wire buffer.
assert_eq!((mfr, model, code), ("Honda", "Civic VTi", "abcdef"));
Ok(())
}
}
Each into_*_as_str() returns (&'a str, NextStage<'a>) — the &str borrows
the original buffer. See Method chaining.
Start with try_decode / try_from. Do not verify first then walk — that
is two structural passes. Inside a dynamic-entry callback, consume nested
tails with into_* / skip_* / ordered() and return that
completion. Random-access getters or a root manufacturer_len() before
visiting preceding groups rescan.
Consuming stages and group iterators are #[must_use]. Dropping a stage
without into_* / skip_* / finish() abandons remaining tails.
A fixed-stride iterator need not be drained: finish(), or the next tail’s
into_* / skip_* on the iterator, skips unread entries as
count × block_length. Dynamic-stride skip_* still walks each unread entry.
Asking before walking
| Accessor | Answers | Where |
|---|---|---|
<group>_count() | Result<usize, DecodeError> | base, .memoized(), stage before the group, nested entries |
<field>_len() | var-data byte length, same Result | same four places |
iter.remaining_entries() | unread entries (usize) | the group iterator |
Absent versioned tails are 0. If a primary getter already uses that name,
the convenience accessor is omitted on that owner. Non-consuming is not
constant-time on random-access owners — lookahead may scan preceding tails.
Group byte length is not available from the count when entries have tails.
#![allow(unused)]
fn main() {
pub fn demo_car_visit_entries(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let car = CarDecoder::try_decode(wire, 0)?;
// Non-advancing: `car` is untouched and still owns every tail.
assert_eq!(car.fuel_figures_count()?, 2);
assert_eq!(car.manufacturer_len()?, 5);
// A fixed-stride group is a real iterator, and it does not have to be
// drained. Read one acceleration entry, then let the walk move on — the
// unread entries are skipped by the next step, not lost.
let (mfr, _rest) = car
.skip_fuel_figures()?
.into_performance_figures(|entry| -> Result<_, sbe_rt::DecodeError> {
let mut accel = entry.into_acceleration()?;
assert_eq!((&mut accel).len(), 2);
let first = (&mut accel).next().expect("acceleration is not empty");
assert_eq!(first.mph(), 30);
assert_eq!(accel.remaining_entries(), 1);
// `finish()` skips the entry we did not read and completes.
accel.finish()
})?
.into_manufacturer_as_str()?;
assert_eq!(mfr, "Honda");
Ok(())
}
}
Prefer staged when you skip tails, hold stages across functions, or want
for entry in &mut iter. A closure error consumes the stage; there is no
continuation to retry.
Ordered (decoder.ordered())
One callback per tail, same spelling at message and nested-entry level, plus
EntryInfo per entry. Generated on message decoders with tails and on group
entries that carry tails. An entry whose existing accessor is named ordered
keeps that accessor and does not get this lane.
ordered() is sbe_rt::Ordered<S> over the staged stage — no second cursor.
A nested ordered() walk is the completion the parent callback owes.
.done() only unwraps the staged complete. The message chain ends on
encoded_length_with_header() / as_bytes_with_header(), like encode.
#![allow(unused)]
fn main() {
pub fn demo_car_ordered_lane(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let mut speeds = Vec::new();
let mut octanes = Vec::new();
let mut text = Vec::new();
let len = CarDecoder::try_decode(wire, 0)?
.ordered()
.fixed(|car| {
assert_eq!(car.serial_number(), 1234);
Ok(())
})?
.fuel_figures(|entry, info| {
// `info` carries the position the wire declares.
assert_eq!(info.count, 2);
assert_eq!(info.is_last(), info.index == 1);
speeds.push(entry.speed());
Ok(entry.ordered().usage_description(|_| Ok(()))?)
})?
.performance_figures(|entry, _info| {
octanes.push(entry.octane_rating());
Ok(entry.ordered().acceleration(|a, _| {
let _ = (a.mph(), a.seconds());
Ok(())
})?)
})?
.manufacturer_as_str(|s| {
text.push(s.to_owned());
Ok(())
})?
.model_as_str(|s| {
text.push(s.to_owned());
Ok(())
})?
.activation_code_as_str(|s| {
text.push(s.to_owned());
Ok(())
})?
.encoded_length_with_header();
assert_eq!(speeds, vec![30, 60]);
assert_eq!(octanes, vec![95]);
assert_eq!(text, vec!["Honda", "Civic VTi", "abcdef"]);
assert_eq!(len, wire.len());
Ok(())
}
}
| Tail | Callback |
|---|---|
| Fixed block | fixed(|&{Name}DecoderFixedView|) |
| Group, entries with tails | group(|entry, EntryInfo|) → entry completion, or Ordered<completion> from nested ordered() |
| Group, fixed-stride | group(|entry, EntryInfo|) → () |
| Var-data | field(|&[u8]|), and field_as_str(|&str|) when the schema declares text encoding |
EntryInfo is index, wire-declared count, acting block_length, plus
is_first() / is_last() / remaining(). No entry scan. Group byte
length is absent: entries with tails only settle it by traversing.
Message-level fixed is optional. Skip it, or call it and lose the view.
The view has no group/var-data accessors. A first tail named fixed owns the
fixed() name, so the fixed-block callback yields: read fixed fields before
ordered(). Every callback returns Result<_, DecodeError>; a caller error
type converts after the walk.
Entry-level wrappers have no fixed: the parent already holds the entry.
Empty and version-absent groups invoke the callback zero times.
<group>_count() on the ordered stage reports the wire-declared count for an
empty group and 0 for a group the wire version predates, which has no count.
Bytes and text callbacks borrow 'a from the original buffer. A callback
error consumes the stage. Prefer ordered when you always decode whole
messages and want one shape. Prefer staged to skip, hold stages, or iterate.
Full-frame bytes mid-walk
| Need | API |
|---|---|
| Full frame after the walk | complete stage as_bytes_with_header() |
| Full frame without consuming stages | inherent dec.as_bytes_with_header()? (rescans tails) |
| Fixed block only | dec.get_metadata().as_fixed_region_with_header()? |
See Generated code.
What Generated Code Looks Like
For a schema with one message (Car), ergo-sbe emits a single Rust module with:
Decoder (flyweight over &[u8])
// Each message gets a zero-allocation decoder.
pub struct CarDecoder<'a> {
pub(crate) buf: &'a [u8],
pub(crate) offset: usize,
pub(crate) acting_version: u16,
pub(crate) acting_block_length: usize,
}
impl<'a> CarDecoder<'a> {
// Illustrative only — real values from your schema.
pub const SCHEMA_ID: u16 = 1;
pub const TEMPLATE_ID: u16 = 1;
pub const BLOCK_LENGTH: usize = 45;
pub const HEADER_LENGTH: usize = 8;
// Checked framed entry (message start). Validates header + fixed extent.
pub fn try_decode(buf: &'a [u8], pos: usize)
-> Result<Self, sbe_rt::DecodeError> { ... }
// Proves the header+fixed-body extent and panics if short.
// Caller does not need `unsafe` — the proof is in the constructor.
pub fn wrap(buf: &'a [u8], message_offset: usize,
acting_block_length: usize, acting_version: u16)
-> Self { ... }
// Full dynamic-tail structural check (associated, not `car.verify()`).
pub fn verify(buf: &[u8]) -> Result<(), sbe_rt::VerifyError> { ... }
// Fixed fields are random-access — zero-copy reads after a checked wrap.
#[inline]
pub fn serial_number(&self) -> u64 {
let offset = self.offset + 0;
u64::from_le_bytes(/* private read after extent proof */)
}
}
Encoder (type-state stages)
// Wire order is enforced by named stage types. The root encoder also carries a
// fields-state parameter: tails are reachable only after `fixed()`.
pub struct CarEncoder<
'a,
H: sbe_rt::HeaderState = sbe_rt::HeaderPresent,
F: sbe_rt::FieldsState = sbe_rt::FieldsUnfixed,
> { ... }
pub struct CarAfterFuelFigures<'a, H: sbe_rt::HeaderState = sbe_rt::HeaderPresent> { ... }
pub struct CarAfterPerformanceFigures<'a, H: sbe_rt::HeaderState = sbe_rt::HeaderPresent> { ... }
pub struct CarComplete<'a, H: sbe_rt::HeaderState = sbe_rt::HeaderPresent> { ... }
// `fixed()` moves FieldsUnfixed -> FieldsFixed; the tail methods exist only on
// the fixed phase, so `wrap(...).fuel_figures(...)` is a compile error.
pub type CarUnfixedEncoder<'a, H = sbe_rt::HeaderPresent> =
CarEncoder<'a, H, sbe_rt::FieldsUnfixed>;
impl<'a, H: sbe_rt::HeaderState> CarEncoder<'a, H, sbe_rt::FieldsUnfixed> {
pub fn fixed(self, fields: &CarFixedFields) -> CarEncoder<'a, H, sbe_rt::FieldsFixed> { ... }
}
// Calling stages out of order is a type error — `CarEncoder` has no `asks()`.
impl<'a, H: sbe_rt::HeaderState> CarEncoder<'a, H, sbe_rt::FieldsFixed> {
pub fn fuel_figures(self, count: u16, f: impl FnOnce(...) -> ...) -> Result<CarAfterFuelFigures> { ... }
}
impl<'a> CarAfterFuelFigures<'a> {
pub fn performance_figures(self, ...) -> Result<CarAfterPerformanceFigures> { ... }
}
impl<'a> CarComplete<'a> {
pub fn encoded_length_with_header(&self) -> usize { ... }
pub fn as_bytes_with_header(&self) -> &[u8] { ... }
}
Metadata: no field-name collisions
Utility methods like remaining, buffer, as_bytes_with_header, and
as_body_bytes live on a zero-copy metadata struct from get_metadata().
A schema field named remaining or buffer therefore generates
dec.remaining() / dec.buffer() as field accessors — no _field suffix.
Reserved decoder names (ordered, memoized, acting_version,
acting_block_length, …) still rename a colliding message-level field
(ordered_field()). Group entries do not rename against that list: if an
entry field is actingVersion, it keeps acting_version() and the metadata
getter is omitted.
Convenience accessors (<group>_count, <field>_len, ordered fixed) yield
to a same-named tail or field — the primary getter wins. A domain-typed field
counts: its getter takes the plain field name (renamed against the same
reserved list), so orders_count beside a group orders stays the field.
reserved_name_clash_test, ordered_lane_test.
What does remaining() mean?
| Receiver | remaining() means |
|---|---|
Group decoder (e.g. FuelFiguresDecoder) | Entry count left (usize) — not bytes |
dec.get_metadata() / enc.get_metadata() | Byte slice after the acting fixed block (&[u8]) |
Schema field named remaining | Ordinary field accessor (natural name, no rename) |
Session framing (header then app payload) must use
get_metadata().remaining() for the payload bytes.
let dec = CarDecoder::try_decode(&buf, 0)?;
dec.serial_number(); // field accessor — never collides
dec.get_metadata().remaining(); // metadata — never collides
dec.get_metadata().buffer(); // metadata — never collides
// Car has groups/var-data: metadata is fixed-block only (not a full frame).
dec.get_metadata().as_fixed_region_with_header()?;
// Complete frame after walking tails, or rescan without consuming:
// complete.as_bytes_with_header() / dec.as_bytes_with_header()?
dec.encoded_length_with_header()?; // hot path stays on base struct
The metadata struct holds a reference to the parent (zero-copy):
pub struct CarDecoderMetadata<'m, 'a> {
decoder: &'m CarDecoder<'a>,
}
Encoders have the same pattern. Fixed-only messages expose
as_body_bytes / as_bytes_with_header only after fixed(&FixedFields)
(on the encoder and on FieldsFixed metadata). Messages with tails use
as_fixed_body_bytes / as_fixed_region_with_header until the complete stage.
let meta = enc.get_metadata();
meta.as_fixed_body_bytes(); // fixed block only when message has tails
meta.as_fixed_region_with_header(); // header + fixed block — not full frame
meta.message_offset(); // message start in buffer
Metadata limits (tailed messages)
| API | Span | Full Car frame? |
|---|---|---|
meta.limit() | body start + acting block length | No — stops at fixed block end |
meta.as_fixed_region_with_header()? | header + acting fixed block | No |
meta.remaining() | bytes after that fixed end | May still include unread tails of this message |
complete stage as_bytes_with_header() | header through last var-data | Yes (after walking tails) |
dec.as_bytes_with_header()? (inherent rescan) | same full frame without consuming stages | Yes (rescans tails) |
Do not use meta.limit() as the next-message offset in a multi-message
buffer for Car-shaped schemas — that truncates at the fixed block.
acting_version / acting_block_length (dual surface)
These remain inherent on the message decoder for the hot path
(dec.acting_version(), dec.acting_block_length()) and are also exposed on
metadata (dec.get_metadata().acting_version()) for a uniform placement facet.
Both paths return the same values. They are reserved method names: a schema
field named actingVersion becomes acting_version_field on the decoder.
Exact buffer sizing
// Fixed-only messages: const length.
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
// Variable-length: staged builder (zero allocation).
let len = CarEncoder::compute_length()
.fuel_figures_ragged(2, |ff| {
ff.add()?.usage_description(5)?;
Ok(())
})?
.performance_figures_ragged(0, |_| Ok(()))?
.manufacturer(5)?
.model(9)?
.activation_code(6)?
.encoded_length_with_header();
Configuration controls output size
Every aspect of generated output is configurable:
GenerationConfig::new("msgs")
.with_display_debug(false) // omit Debug/Display impls
.with_meta_attributes(false) // omit *_ENCODING_OFFSET etc.
.with_dispatch(false) // omit AnyMessage/FrameCursor
.with_domain_objects(DomainVarData::Bytes) // owned DTOs
A default single-message schema with these knobs off produces minimal output.
GenerationConfig::new enables display/debug, metadata, and dispatch, and
disables domain objects, deprecated attributes, blanket enum options, and
automatic bool mapping. GenerationProfile::Lean turns off the three
output-heavy conveniences (display/debug, metadata, dispatch). See the
canonical option table.
Real example
The sbe-feature-tour sample
builds and exercises a real Car schema. Run cargo test inside that directory
to see the generated API in action, or open src/generated/feature_tour.rs
after cargo build.
Trust Boundaries
Checked entry points validate the message header and fixed block. verify
walks the complete dynamic tail before bulk access:
#![allow(unused)]
fn main() {
pub fn demo_try_vs_trusted(valid_car: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
// `decode` validates template_id, schema_id, version, and the
// version-aware fixed body extent at message start (offset 0).
let _dec = CarDecoder::try_decode(valid_car, 0)?;
// `verify` walks the complete dynamic tail (groups + var-data), not a
// header-only peek — use it when you need full structural acceptance
// without materialising a long-lived decoder stage chain.
CarDecoder::verify(valid_car)?;
// Truncated buffers fail checked entry points with Result errors.
assert!(
CarDecoder::try_decode(&valid_car[..8.min(valid_car.len())], 0).is_err(),
"truncated buffer should fail try_decode"
);
if valid_car.len() > 16 {
assert!(
CarDecoder::verify(&valid_car[..16]).is_err(),
"truncated buffer should fail verify (incomplete tail)"
);
}
// `wrap` still returns Result and validates the body extent given acting
// block_length + version (message start, not sbe-tool body offset).
let mut hdr_bytes = [0u8; 8];
hdr_bytes.copy_from_slice(&valid_car[..8]);
let hdr = MessageHeader(hdr_bytes);
let dec = CarDecoder::try_wrap(valid_car, 0, hdr.block_length() as usize, hdr.version())?;
assert_eq!(dec.serial_number(), 1234);
Ok(())
}
}
(This code comes from the sbe-feature-tour sample crate.)
Three-tier constructors
| Tier | Entry | Bad buffer |
|---|---|---|
| Checked | try_wrap / try_wrap_and_apply_header / try_decode | Result::Err (all failures) |
| Trusted | bare wrap / wrap_and_apply_header | panic after the same extent proof |
| Trusted hybrid | bare decode | panic if short; Err on wrong template/schema only |
| Unchecked | unsafe wrap_unchecked / wrap_and_apply_header_unchecked | UB — prove extent first |
| Unchecked hybrid | unsafe decode_unchecked | UB on OOB extent; Err on wrong template/schema |
Bare decode looks like try_decode (Result) but short buffers still panic
— prefer try_decode when every failure must be a Result. Full table and
the ladder from untrusted bytes to typed fields:
Trust Boundary (core concepts). sbe-tool
offsets: Coming from sbe-tool.
Trust boundary
The constructor is the single trust checkpoint. After a safe constructor
proves header + version-aware fixed extent, field accessors and setters are
branch-free (unchecked loads/stores justified by that proof). Dynamic
group/var-data tails still check on consume. Use *_unchecked only when you
have proven the fixed extent independently (benchmarks, pre-validated buffers).
Domain Objects (DTOs)
Latency-sensitive paths: never use DTOs. Domain objects allocate (
Vec,String) and copy every field out of the wire buffer. If latency matters, use the zero-copy flyweight decoder instead. DTOs are for tooling, logging, and offline processing — not the hot path.
DTO construction still owns and allocates its Vec/String fields. Re-encode
does not add another allocation: wire-compatible flat groups automatically use
the generated bulk_add_domain(&[EntryDomain]) path, which validates one
complete output region and writes directly from the DTO slice. Groups with
nested tails, var-data, optional/versioned fields, domain conversions, or bool
domain remapping retain the general per-entry path.
Enable domain objects during generation when an owned application value is
more convenient than a zero-copy flyweight. This fixture uses DomainVarData::Strings:
#![allow(unused)]
fn main() {
pub fn demo_car_domain_dto(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let dec = CarDecoder::try_decode(wire, 0)?;
// try_from_decoder (not TryFrom/From): two fallible sources — decoder vs
// try_from_slice_with_header for framed bytes; materialisation can fail.
let dto = CarDomain::try_from_decoder(dec)?;
assert_eq!(dto.serial_number, 1234);
assert!(dto.available); // bool domain field
assert_eq!(dto.fuel_figures.len(), 2);
assert_eq!(dto.fuel_figures[0].usage_description, "Urban");
assert_eq!(dto.manufacturer, "Honda");
const RE_PAD: usize = 512;
assert!(wire.len() <= RE_PAD);
let mut storage = [0u8; RE_PAD];
let n = dto.encode(&mut storage[..wire.len()])?;
assert_eq!(&storage[..n], wire, "DTO re-encode must be byte-identical");
Ok(())
}
}
(This code comes from the sbe-feature-tour sample crate.)
Multi-Template Dispatch
AnyMessage reads the generated header layout and dispatches on the template
ID. Prefer AnyMessage::try_decode at untrusted boundaries (returns
Result). Bare AnyMessage::decode is the same implementation today; keep
using try_* naming for consistency with the three-tier trust boundary.
#![allow(unused)]
fn main() {
pub fn demo_any_message() -> Result<(), Box<dyn std::error::Error>> {
let mut hb = [0u8; HeartbeatEncoder::compute_length_with_header()];
let hb_len = HeartbeatEncoder::compute_length_with_header();
let nanos: u64 = 1_700_000_000_000_000_000;
let _hb_len = HeartbeatEncoder::try_wrap_and_apply_header(&mut hb, 0)
.unwrap()
.fixed(&HeartbeatFixedFields {
sequence: 1,
timestamp: nanos,
})
.encoded_length_with_header();
let note_body = "hello AnyMessage";
let note_len = NoteEncoder::compute_length_with_header(note_body.len());
const NOTE_PAD: usize = 64;
assert!(note_len <= NOTE_PAD);
let mut note_storage = [0u8; NOTE_PAD];
let note = &mut note_storage[..note_len];
let note_written = NoteEncoder::try_wrap_and_apply_header(note, 0)?
.fixed(&NoteFixedFields { note_id: 99 })
.body_as_str(note_body)?
.encoded_length_with_header();
assert_eq!(note_written, note_len);
// Concatenate framed messages (each includes its own SBE header).
let mut stream = Vec::new();
stream.extend_from_slice(&hb[..hb_len]);
stream.extend_from_slice(¬e[..note_written]);
let mut offset = 0usize;
let mut saw_heartbeat = false;
let mut saw_note = false;
while offset < stream.len() {
match AnyMessage::try_decode(&stream, offset)? {
AnyMessage::Heartbeat(d) => {
assert_eq!(d.sequence(), 1);
offset += d.encoded_length_with_header()?;
saw_heartbeat = true;
}
AnyMessage::Note(d) => {
assert_eq!(d.note_id(), 99);
let (body, complete) = d.into_body_as_str()?;
assert_eq!(body, note_body);
offset += complete.encoded_length() + NoteDecoder::HEADER_LENGTH;
saw_note = true;
}
AnyMessage::Car(_) => return Err("unexpected Car in this demo stream".into()),
AnyMessage::Quote(_) => return Err("unexpected Quote in this demo stream".into()),
AnyMessage::Unknown { .. } => {
return Err("unexpected Unknown template".into());
}
}
}
assert!(saw_heartbeat && saw_note);
Ok(())
}
}
(This code comes from the sbe-feature-tour sample crate.)
Core Concepts
- Trust Boundary —
try_*→Result;wrappanics if short;decodeis hybrid - Wire Order via Named Stages —
asksbeforebidsis a type error - Buffer Sizing — exact byte count before writing
- Flyweight vs Whole-Struct — in-place decode or owned DTO
- Composite Layout & Endianness — wire images, not
repr(C)overlays
Trust Boundary
Every SBE buffer crossing a process boundary must be validated. ergo-sbe provides a three-tier constructor API with different failure contracts:
| Tier | Prefix | Behaviour on bad buffer | Use case |
|---|---|---|---|
| Checked | try_{wrap,decode,…} | Returns Result::Err | Untrusted input, process boundaries |
| Trusted | bare name (wrap, decode, …) | Panics after the same extent proof | Known-good buffers |
| Unchecked | unsafe fn *_unchecked | UB (raw pointer ops) | Proven-tight hot loops |
The ladder, from untrusted bytes to typed fields:
flowchart LR
B["untrusted bytes"] --> W["wrap / decode"]
W --> Z["private zero-check core"]
Z --> G["typed getters / setters"]
U["*_unchecked: skip the proof"] -.->|"UB on OOB, not the default"| W
try_* returns Result::Err; bare names panic after the same extent
proof; decode is the hybrid above. *_unchecked is not shipped as a
default path — only after an independent extent proof.
Wire layout follows official SBE offsets and order for the shapes under test (compatibility profile); official SBE wire compatibility outranks API convenience.
The trusted tier is safe Rust. Bare constructors run the same header +
fixed-body extent proof as try_*, then panic on failure. After that proof,
field accessors/setters use unchecked loads/stores (justified by the
constructor). Dynamic tails still check on consume.
try_decode does not first traverse all dynamic tails. A staged walk validates
them as it advances, so checked construction is compatible with a single-pass
decoder. A separate verify() before that walk adds another structural pass.
Checks for text encoding happen when text accessors are used.
The unsafe fn *_unchecked variants skip the extent proof entirely — only for
the case where panic machinery is measurable and the caller has proven the
layout independently.
Encoder entry points
| Entry | Return | Behaviour |
|---|---|---|
Encoder::try_wrap(buf, offset) | Result<Encoder, EncodeError> | Validates capacity, returns Err |
Encoder::wrap(buf, offset) | Encoder (body-only) | Panics if header+fixed body do not fit |
Encoder::try_wrap_and_apply_header(buf, pos) | Result<Encoder, EncodeError> | Validates capacity + writes header, returns Err |
Encoder::wrap_and_apply_header(buf, pos) | Encoder (header written) | Panics if header+fixed body do not fit |
unsafe fn Encoder::wrap_unchecked(buf, offset) | Encoder (body-only) | UB on OOB — raw pointer setters |
unsafe fn Encoder::wrap_and_apply_header_unchecked(buf, pos) | Encoder (header written) | UB on OOB — copy_nonoverlapping header |
Decoder entry points
| Entry | Return | Behaviour |
|---|---|---|
Decoder::try_wrap(buf, offset, bl, ver) | Result<Decoder, DecodeError> | Validates body extent, returns Err |
Decoder::try_decode(buf, pos) | Result<Decoder, DecodeError> | Header + template/schema + version-aware fixed extent (all failures are Err) |
Decoder::wrap(buf, offset, bl, ver) | Decoder | Panics if version-aware fixed body does not fit |
Decoder::decode(buf, pos) | Result<Decoder, DecodeError> | Hybrid: panics if short; Err on wrong template/schema only |
unsafe fn Decoder::wrap_unchecked(buf, offset, bl, ver) | Decoder | UB on OOB — raw pointer accessors |
unsafe fn Decoder::decode_unchecked(buf, pos) | Result<Decoder, DecodeError> | Unchecked extent (UB on OOB) + checked identity (Err on wrong template/schema) |
Why decode returns Result but still panics
Bare decode keeps a freeze-friendly hybrid so feed handlers can ? on
WrongTemplate / WrongSchema after demux, while short buffers remain a
trusted-tier panic (same extent proof as wrap). Prefer try_decode when
every failure — including short buffers — must be a Result.
See Trust boundaries (feature tour) for worked examples.
Wire Order via Named Stages
SBE is positional: groups and var-data appear in a fixed schema order with no per-field tags. Two nearly identical groups back-to-back — bids then asks — are common. Swap them and the bytes still look valid; prices land on the wrong side of the book. That mistake is a type error here, not a production incident.
sbe-tool uses .parent() to hand encoder ownership back up the tree. In Rust
that fights the borrow checker. ergo-sbe uses scoped closures so nested
schemas stay readable:
#![allow(unused)]
fn main() {
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
(From sbe-feature-tour — compiles and runs in CI.)
Order is the type-state pattern, implemented as named concrete stage
structs (CarAfterFuelFigures), not Encoder<State>. The H: HeaderState
generic on every stage is a zero-sized marker for header-present vs body-only
— it does not track wire-order. Duplicating the stage graph for
HeaderPresent / HeaderAbsent would provide no latency advantage.
pub struct BookEncoder<'a> { /* buf, pos, … */ }
pub struct BookAfterBids<'a> { /* same layout */ }
pub struct BookAfterAsks<'a> { /* same layout */ }
impl BookEncoder<'a> {
pub fn bids(self, …) -> Result<BookAfterBids<'a>, …> { … }
// no asks() — bids first on the wire
}
impl BookAfterBids<'a> {
pub fn asks(self, …) -> Result<BookAfterAsks<'a>, …> { … }
}
After fixed fields you may only call the next group or var-data.
BookEncoder has no asks method. Decoders use the same idea
(BookDecoder → BookDecoderAfterBids → …).
Group bodies use |g| { g.add(|e| { … }) }: the closure ends, then chaining
continues. See Type-state and
Benchmarks for the cost claim and the 1.00× gate.
Buffer Sizing
Aeron try_claim (and similar slots) needs the encoded size before you
write. Guessing with vec![0u8; 4096] and copying later defeats that model.
Describe the shape (group counts, nested groups, var-data lengths) and get an exact size from the generated API:
| Message shape | Generated sizing | Prefer |
|---|---|---|
| Fixed only | {Msg}Encoder::compute_length_with_header() (const) | stack / claim of that length |
| Groups / nested / ragged | {Msg}EncodedLength staged builder | len then encode into a claim/slot of len |
#![allow(unused)]
fn main() {
pub fn demo_car_size_and_encode() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Fuel: 2 entries with usage ASCII lengths 5 and 7.
// Performance: 1 entry with 2 nested acceleration rows (fixed-only entries).
// Message var-data: manufacturer / model / activationCode lengths.
let complete_len = CarEncoder::compute_length()
.fuel_figures_ragged(2, |ff| {
ff.add()?.usage_description(5)?; // "Urban"
ff.add()?.usage_description(7)?; // "Highway"
Ok(())
})?
.performance_figures_ragged(1, |pf| {
pf.add()?.acceleration(|acc| {
acc.uniform(2)?;
Ok(())
})?;
Ok(())
})?
.manufacturer(5)? // "Honda"
.model(9)? // "Civic VTi"
.activation_code(6)? // "abcdef"
.encoded_length_with_header();
// Exact size from compute_length → stack pad (this demo fits well under 512).
const CAR_PAD: usize = 512;
assert!(
complete_len <= CAR_PAD,
"sample car length {complete_len} exceeds stack pad {CAR_PAD}"
);
let mut storage = [0u8; CAR_PAD];
let written = encode_sample_car(&mut storage[..complete_len])?;
assert_eq!(
written, complete_len,
"CarEncodedLength must equal encoder-produced length"
);
Ok(storage[..written].to_vec())
}
/// Encode the canonical sample car into `buf` (must be pre-sized).
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
(From sbe-feature-tour — EncodedLength + exact buffer encode, tested in CI.)
Nested books:
book_encoded_length.
API matrix:
encoded_length_api_test.
Flyweight vs Whole-Struct
Use the style that matches how much of the message you touch.
| Style | Best when | Cost | Schema evolution |
|---|---|---|---|
| Flyweight (per-field) | You only read one or a few fields; hot path | Zero-copy; no heap | New fields are optional at call sites (you simply don’t read them) |
*FixedFields + .fixed(...) | You always write the entire fixed block | One struct write, still flyweight buffer | Adding a required fixed field to the schema → compile error until you set it in the struct |
*Domain DTO (.with_domain_objects(DomainVarData::…)) | Whole message as owned data; enum picks String vs Vec<u8> var-data | Allocates — never use on the hot path. Easier app code for tooling, logging, offline processing | Same idea: regenerating after a schema change forces you to fill new struct fields |
*Source trait (.with_source_traits(true)) | Encode your own type, groups and var-data included, without a DTO | No allocation: groups are iterators, var-data is borrowed | A new field is a new trait method: every implementation stops compiling until it provides it |
Encode — whole fixed block as a struct
When you always populate every fixed field, a struct is clearer and schema additions break at compile time:
#![allow(unused)]
fn main() {
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
// Buffer is exact size from const compute_length_with_header — no bounds check needed.
let len = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.fixed(&HeartbeatFixedFields { sequence: 7, timestamp: 0 })
.encoded_length_with_header();
let dec = HeartbeatDecoder::try_from(&buf[..len])?;
assert_eq!(dec.sequence(), 7);
}
(From book/examples/heartbeat-encode.rs — compiled against the feature-tour codec.)
If the schema later adds a required field to the fixed block, this stops compiling until you add it to the struct literal — you cannot silently omit it.
Decode — individual fields (flyweight)
#![allow(unused)]
fn main() {
// Encode a sample car first (normally bytes come from the wire)
let complete_len = CarEncoder::compute_length()
.fuel_figures_ragged(0, |_| Ok(()))?
.performance_figures_ragged(0, |_| Ok(()))?
.manufacturer(5)?
.model(5)?
.activation_code(3)?
.encoded_length_with_header();
const PAD: usize = 256;
assert!(complete_len <= PAD, "car length {complete_len} exceeds pad {PAD}");
let mut storage = [0u8; PAD];
let buf = &mut storage[..complete_len];
let fields = CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: *b"ABCDEF",
extras: OptionalExtras::default(),
engine: Engine::new(2000, 4, *b"123", 0i8, false.into(), Booster::new(BoostType::TURBO, 210)),
};
let n = CarEncoder::try_wrap_and_apply_header(buf, 0)?
.fixed(&fields)
.fuel_figures(0, |_| Ok(()))?
.performance_figures(0, |_| Ok(()))?
.manufacturer_as_str("Honda")?
.model_as_str("Civic")?
.activation_code_as_str("abc")?
.encoded_length_with_header();
assert_eq!(n, complete_len);
// Now decode — read only the fields you need, no DTO allocation:
let car = CarDecoder::try_from(&buf[..n])?;
assert_eq!(car.serial_number(), 1234);
assert_eq!(car.model_year(), 2013);
assert_eq!(car.code(), Model::A);
assert_eq!(car.engine().capacity(), 2000);
}
(From book/examples/flyweight-access.rs — compiled against the feature-tour codec.)
Decode — whole message as a DTO
Do not use on the latency-sensitive path. DTO decode allocates
Vec/Stringand copies every field. For the hot path, use the flyweight decoder instead.
When you always need (almost) everything, or want to pass a value across threads / into non-SBE code:
#![allow(unused)]
fn main() {
pub fn demo_car_domain_dto(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let dec = CarDecoder::try_decode(wire, 0)?;
// try_from_decoder (not TryFrom/From): two fallible sources — decoder vs
// try_from_slice_with_header for framed bytes; materialisation can fail.
let dto = CarDomain::try_from_decoder(dec)?;
assert_eq!(dto.serial_number, 1234);
assert!(dto.available); // bool domain field
assert_eq!(dto.fuel_figures.len(), 2);
assert_eq!(dto.fuel_figures[0].usage_description, "Urban");
assert_eq!(dto.manufacturer, "Honda");
const RE_PAD: usize = 512;
assert!(wire.len() <= RE_PAD);
let mut storage = [0u8; RE_PAD];
let n = dto.encode(&mut storage[..wire.len()])?;
assert_eq!(&storage[..n], wire, "DTO re-encode must be byte-identical");
Ok(())
}
}
Encode — your own type through a Source trait
*FixedFields covers the fixed block only. With .with_source_traits(true)
each message gets a {Message}Source trait — one getter per field in wire
order, impl ExactSizeIterator<Item = impl …EntrySource> per repeating group,
&[u8] per var-data — and the encoder gains encode_from(&src) and
encoded_length_with_header_from(&src). Implement the trait on the type your
application already has; encode_from writes it through the same setters and
type-state stages a hand-written encode uses, and returns the terminal stage:
#![allow(unused)]
fn main() {
/// The application's own type: no generated struct is involved.
struct Reminder {
id: u32,
text: String,
}
impl NoteSource for Reminder {
fn note_id(&self) -> u32 {
self.id
}
fn body(&self) -> &[u8] {
self.text.as_bytes()
}
}
let reminder = Reminder { id: 9, text: "settle T+1".into() };
let len = NoteEncoder::encoded_length_with_header_from(&reminder)?;
let mut scratch = vec![0u8; len];
let claim = sbe_rt::OfferClaim::new(&mut scratch, |_: &[u8]| -> Result<(), &'static str> { Ok(()) });
NoteEncoder::send(claim, |enc| enc.encode_from(&reminder))?;
}
(From book/examples/claim-send.rs — compiled against the feature-tour codec.)
A fixed getter returns what the field’s *FixedFields entry holds
(Option<T> for an optional field, whose None writes the null value and
whose Some of the null value, NaN for a float, is refused as
SomeNullValue), or its domain type when one is configured. A group iterator whose len() does
not match what it yields is GroupCountMismatch or GroupFull, as with a
hand-written group.
Rule of thumb: one field on the hot path → flyweight. Always fill or
always consume the whole message → FixedFields / Domain for clarity and
compile-time breakage on schema growth. More on DTOs in
Recipes — Domain DTOs.
Composite Layout & Endianness
On a little-endian host, can a composite be a #[repr(C)] / #[repr(C, packed)]
overlay so field access is a free load?
Almost — but not via repr(C) transmute. ergo-sbe does something safer that
is still a plain load on LE:
| Approach | What ergo-sbe does | Why not the other thing |
|---|---|---|
| Wire image | #[repr(transparent)] pub struct Engine(pub [u8; 10]) — the value is the on-wire bytes | #[repr(C)] native fields insert alignment padding; SBE is packed and may be unaligned |
| Accessors | from_le_bytes / to_le_bytes at schema offsets | Native loads break big-endian schemas and unaligned safety |
| Flyweight | EngineDecoder { buf, pos } reads in place | Default decode path |
| Eager value | engine_value() copies the N-byte image once | Still not field-by-field re-pack; .0 is the wire block |
| Encode | Writer copies engine.0 into the frame | Same image the decoder reads back |
On LE hosts, from_le_bytes lowers to a load. The generator also emits
const _: () = assert!(core::mem::size_of::<Engine>() == 10);
so the Rust type size is locked to the wire size.
*FixedFields is an application struct used to fill the fixed block in one
call. It is not a zero-copy overlay; .fixed(&…) writes each field with
endian conversion.
Single-field access is already one load. layout_access_bench compares
flyweight, preheld [u8; N], and #[repr(C, packed)] on a mid-block field:
they measure equivalently. Materialising the whole composite first is the
expensive path. zerocopy FromBytes/IntoBytes was evaluated and was not
faster than the flyweight mov.
| You need… | Use |
|---|---|
| One or a few fields on the hot path | Flyweight |
| Whole composite as an owned wire blob | Value Engine([u8; N]) / *_value() — pay N once |
Hand-rolled repr(C, packed) for speed | Skip it — same cost, extra UB/layout risk |
We do not generate packed field structs: packing, unaligned references, big-endian schemas, enums/sets/nested composites. The transparent wire image is the portable form that already optimizes to the packed load on LE.
On encode, encode_style_bench shows .fixed(&CarFixedFields{…}) and
individual setters measure equivalently after inlining. Prefer .fixed for
completeness, not speed. Building a BE image on an LE host costs a bswap per
multi-byte field; once the image exists, write cost matches LE.
composite_layout_test ·
layout_access_bench ·
encode_style_bench.
Read live Criterion output; do not quote numbers from this page.
Configuration
- Feature Integrations
- with_conversion vs with_domain_type
- GenerationConfig Options
- Code-Generation Hooks
Wire type vs app type
| Name | Role |
|---|---|
Decimal (schema composite) | Wire — generated type / price_value() — what is in the buffer |
Cents, rust_decimal::Decimal, … | App — what your code wants to use |
app ──price_from / price(v)──► wire Decimal on the buffer
buf ──price_as / price()──► app value
Feature Integrations
Optional Cargo features. Enable them on both [build-dependencies] and
[dependencies] — the generator decides what to emit from its own feature set.
| Feature | Best for |
|---|---|
compact_str | Tickers / symbols / venue codes in DTOs (≤24 B inline) |
smol_str | Long-lived DTOs that clone often |
bytes | Relay / forwarding, shared buffers |
chrono | Typed timestamps on encode/decode |
[build-dependencies]
ergo-sbe = { version = "0.1", features = ["compact_str", "chrono"] }
[dependencies]
ergo-sbe = { version = "0.1", features = ["compact_str", "chrono"] }
Measure with var_data_types_bench / chrono_converter_bench rather than
quoting numbers from this page. DTO materialisation is never the hot path.
CompactString
Primarily for domain DTOs. Replaces String with
compact_str::CompactString (≤24 bytes on the stack). The codec also gains
into_<field>_as_compact_str() on text var-data stages.
use ergo_sbe::{DomainVarData, GenerationConfig};
let config = GenerationConfig::new("msgs")
.with_domain_objects(DomainVarData::CompactStrings);
pub struct QuoteDomain {
pub symbol: ergo_sbe::compact_str::CompactString,
pub venue: ergo_sbe::compact_str::CompactString,
pub price: Decimal,
}
let (symbol, _next_stage) = L3BookDecoder::try_decode(wire, 0)?
.skip_bids()?
.skip_asks()?
.into_symbol_as_compact_str()?;
SmolStr
O(1) clone regardless of length. DomainVarData::SmolStrings plus
into_<field>_as_smol_str().
let config = GenerationConfig::new("msgs")
.with_domain_objects(DomainVarData::SmolStrings);
Bytes
Reference-counted buffer. DomainVarData::BytesCrate plus
into_<field>_as_bytes().
let config = GenerationConfig::new("relay")
.with_domain_objects(DomainVarData::BytesCrate);
let original = bytes::Bytes::copy_from_slice(b"payload");
let clone = original.clone();
let sub = original.slice(4..);
let (payload, next) = stage.into_payload_as_bytes()?;
Chrono
Schema fields are typically uint64 with a timestamp semanticType. The
converters take i64. Map them with with_domain_type:
use ergo_sbe::{GenerationConfig, ConversionSelector};
let config = GenerationConfig::new("msgs")
.with_domain_type(
ConversionSelector::semantic_type("UTCTimestamp"),
"chrono::DateTime<chrono::Utc>",
)
.with_domain_type(
ConversionSelector::semantic_type("UTCTimestampMicros"),
"chrono::NaiveDateTime",
);
let created: chrono::DateTime<chrono::Utc> = dec.created_at()?;
let updated: chrono::NaiveDateTime = dec.updated_at()?;
enc.created_at(chrono::Utc::now())?;
enc.updated_at(chrono::DateTime::from_timestamp(1_720_000_000, 0).unwrap().naive_utc())?;
One semantic_type("UTCTimestamp") covers every field with that attribute.
Worked example: Timestamps.
Direct converters (chrono feature):
use ergo_sbe::chrono_converters::*;
let dt = i64_nanos_to_datetime(1_720_000_000_000_000_000);
let ns = datetime_to_i64_nanos(dt);
let naive = i64_micros_to_naive(1_720_000_000_000_000);
assert_eq!(naive_to_i64_micros(naive), 1_720_000_000_000_000);
Combining
[build-dependencies]
ergo-sbe = { version = "0.1", features = ["compact_str", "bytes", "chrono"] }
[dependencies]
ergo-sbe = { version = "0.1", features = ["compact_str", "bytes", "chrono"] }
let config = GenerationConfig::new("msgs")
.with_domain_objects(DomainVarData::CompactStrings)
.with_domain_type(
ConversionSelector::semantic_type("UTCTimestamp"),
"chrono::DateTime<chrono::Utc>",
);
with_conversion vs with_domain_type
Do not call both for the same selector — domain type already enables conversion.
A with_conversion | B with_domain_type | |
|---|---|---|
| Idea | Generic convert API; you plug any app type | Always use this Rust path |
| build.rs | .with_conversion(named_type("Decimal")) | .with_domain_type(…, "rust_decimal::Decimal") |
| You write | TryFromSbe<Decimal> / TryToSbe<Decimal> for your type | Usually nothing for bool / rust_decimal / chrono |
| Decode | let p: Cents = dec.price_as()? | let p: rust_decimal::Decimal = dec.price()? |
| Encode | enc.price_from(¢s)? | enc.price(rust_decimal::Decimal::new(12345, 2))? |
| Raw wire | price_value() / price_wire(...) | same when conversion is active |
| Sample | exchange-example · demo_conversion_only | l3-book |
Which fields a selector reaches, and what shape you get
A selector matches by precedence — exact FieldPath, then SemanticType, then
NamedType — so a blanket mapping plus one per-field override behaves the way
it reads, whatever order you register them in. Paths are "Message.field",
extended with group names inside a group ("Order.legs.price").
What the accessor gives you depends on the field’s shape, because it mirrors the raw accessor it delegates to:
| Field | with_conversion | with_domain_type |
|---|---|---|
Required, sinceVersion=0 | price_as::<T>() -> Result<T, _> | price() -> Result<T, _> |
sinceVersion > 0 | Result<Option<T>, _> | Result<Option<T>, _> |
presence="optional" scalar | Result<Option<T>, _> | Result<Option<T>, _> |
The Option layer means “no value on the wire” — absent at this version, or
the schema null value. It is never used to report a bad value; an invalid wire
value is the Err.
Composites, enums, and sets all take domain types. Fixed arrays and optional
scalars keep their wire type in a domain DTO even when a selector matches
them — the flyweight still gets the converted accessor, but the owned struct
stores [u8; N] / Option<i64>.
Option A — you choose the app type (Cents)
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// A — generic converter: one wire type, many app types
let _cfg =
GenerationConfig::new("msgs").with_conversion(ConversionSelector::named_type("Decimal"));
}
(From book/examples/conversion-config.rs — a self-contained program that compiles against ergo-sbe.)
#![allow(unused)]
fn main() {
use rust_decimal::Decimal as Rd;
// App adapter: wire Decimal ↔ rust_decimal::Decimal
struct FixedPrice { mantissa: i64, exponent: i8 }
impl TryFromSbe<Decimal> for FixedPrice {
type Error = &'static str;
fn try_from_sbe(wire: Decimal) -> Result<Self, Self::Error> {
Ok(FixedPrice {
mantissa: wire.mantissa(),
exponent: wire.exponent(),
})
}
}
impl TryToSbe<Decimal> for FixedPrice {
type Error = &'static str;
fn try_to_sbe(&self) -> Result<Decimal, Self::Error> {
Ok(Decimal::new(self.mantissa, self.exponent))
}
}
}
(From book/examples/conversion-app-code.rs — app adapter pattern, compiles against tour_codec.)
// Encode using the generic conversion API:
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let price = Rd::new(12345, 2); // 123.45
let len = QuoteEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.price_from(&price)?
.size_from(&Rd::new(10, 0))?
.encoded_length_with_header();
// Decode — generic `_as::<T>()` picks your adapter:
let dec = QuoteDecoder::try_from(&buf[..len])?;
let p: Rd = dec.price_as()?;
assert_eq!(p, Rd::new(12345, 2));
// Same buffer, different app type — only possible with with_conversion:
let fixed: FixedPrice = dec.price_as()?;
assert_eq!(fixed.mantissa, 12345);
assert_eq!(fixed.exponent, -2);
(Same file — generic _from/_as encode/decode with with_conversion.)
Option B — one fixed app type
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// B — concrete mapping: one Rust type per wire type (already enables conversion)
let _cfg = GenerationConfig::new("msgs").with_domain_type(
ConversionSelector::named_type("Decimal"),
"rust_decimal::Decimal",
);
}
(Same source file — book/examples/conversion-config.rs.)
// Encode using the generic conversion API:
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let price = Rd::new(12345, 2); // 123.45
let len = QuoteEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.price_from(&price)?
.size_from(&Rd::new(10, 0))?
.encoded_length_with_header();
// Decode — generic `_as::<T>()` picks your adapter:
let dec = QuoteDecoder::try_from(&buf[..len])?;
let p: Rd = dec.price_as()?;
assert_eq!(p, Rd::new(12345, 2));
// Same buffer, different app type — only possible with with_conversion:
let fixed: FixedPrice = dec.price_as()?;
assert_eq!(fixed.mantissa, 12345);
assert_eq!(fixed.exponent, -2);
Both styles on different fields:
pub fn demo_conversion_only() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let price = Rd::new(12345, 2); // 123.45
let size = Rd::new(10, 0);
let mut enc = QuoteEncoder::try_wrap_and_apply_header(&mut buf, 0)?;
enc.price_from(&price)?;
enc.size_from(&size)?;
let len = QuoteEncoder::compute_length_with_header();
let dec = QuoteDecoder::try_from(&buf[..len])?;
let wire = dec.price_value();
assert_eq!(wire.mantissa(), 12345);
assert_eq!(wire.exponent(), -2);
let price2: Rd = dec.price_as()?;
let size2: Rd = dec.size_as()?;
assert_eq!(price2, price);
assert_eq!(size2, size);
// Same buffer, different app type — only possible with with_conversion.
let fixed: FixedPrice = dec.price_as()?;
assert_eq!(
fixed,
FixedPrice {
mantissa: 12345,
exponent: -2
}
);
let dto = QuoteDomain::try_from_decoder(dec)?;
assert_eq!(dto.price.mantissa(), 12345);
let mut re = [0u8; QuoteEncoder::compute_length_with_header()];
let n = dto.encode(&mut re)?;
assert_eq!(&re[..n], &buf[..len]);
Ok(buf[..len].to_vec())
}
(This code comes from the sbe-feature-tour sample crate.)
Option B, manual impl — concrete signatures, your own conversion logic
with_domain_type(selector, path) is the common case: ergo-sbe also generates
the TryFromSbe/TryToSbe impl for bool / rust_decimal::Decimal /
chrono::DateTime<Utc>. If you need different conversion behaviour for one of
those exact three types — a custom rounding rule, stricter validation,
different null handling — call additive with_manual_domain_type
instead: same generated price(...)? / price()? signatures, but you
write the impl:
let (_schema, src) = generate_domain_with(&path, "manual_impl_dt", |c| {
c.with_manual_domain_type(
ergo_sbe::ConversionSelector::named_type("Decimal"),
"rust_decimal::Decimal",
)
});
use rust_decimal::Decimal;
// Caller-supplied impl: deliberately scales mantissa by 10 on the way
// in/out, so a value only round-trips correctly through THIS impl —
// proof ergo-sbe didn't quietly generate its own.
impl TryFromSbe<self::Decimal> for rust_decimal::Decimal {
type Error = &'static str;
fn try_from_sbe(wire: self::Decimal) -> Result<Self, Self::Error> {
Ok(rust_decimal::Decimal::new(wire.mantissa() / 10, (-wire.exponent()) as u32))
}
}
impl TryToSbe<self::Decimal> for rust_decimal::Decimal {
type Error = &'static str;
fn try_to_sbe(&self) -> Result<self::Decimal, Self::Error> {
Ok(self::Decimal::new(self.mantissa() as i64 * 10, -(self.scale() as i8)))
}
}
let mut buf = [0u8; 256];
let mut enc = OrderEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&OrderFixedFields { price: self::Decimal::new(0, 0), size: self::Decimal::new(0, 0) });
enc.price(Decimal::new(12345, 2))?;
enc.size(Decimal::new(100, 0))?;
let encoded = enc.as_bytes_with_header().to_vec();
let dec = OrderDecoder::try_decode(&encoded, 0)?;
assert_eq!(dec.price()?, Decimal::new(12345, 2));
assert_eq!(dec.size()?, Decimal::new(100, 0));
(From domain_type_manual_impl_uses_callers_own_impl in
sbe/tests/baseline_test.rs — a real generated-and-compiled test. Any
rust_type string that isn’t one of the three built-ins never gets an
auto-generated impl regardless of DomainImpl — it only matters for opting
those three in or out.)
Forgot the impl? Two things soften it. First, the compile error names the missing impl directly instead of the default trait-bound message:
error[E0277]: `rust_decimal::Decimal` has no `TryFromSbe<Decimal>` impl
|
| missing `impl TryFromSbe<Decimal> for rust_decimal::Decimal`
|
= note: if this field uses DomainImpl::Manual, the generated domain
accessor's doc comment has a ready-to-paste starting point
Second, for the three built-ins, that pointer is real: the generated
price method’s own doc comment (visible on hover, or in cargo doc)
carries the exact impl DomainImpl::Generated would have written — copy it
out and adjust. sbe/tests/baseline_test.rs’s
domain_type_manual_impl_doc_comment_has_generated_snippet asserts this
snippet is present in the generated source.
What your domain type must implement
Only Debug. The generated Display/Debug bodies format domain values with
{:?}, so a plain #[derive(Debug)] struct works — Display is never
required. Note that TryFromSbe::Error / TryToSbe::Error is a separate
associated type and does need Debug + Display; &'static str satisfies it.
Option C — generated mappings for enums and composites
When the application type mirrors the SBE type — an enum with the same
variants, a struct with the same members — with_enum_mapping and
with_composite_mapping generate the TryFromSbe / TryToSbe impls
(exhaustive, with renames) and register the domain type in one call. The
accessors look exactly like Option B’s. See
App Types on Composites.
GenerationConfig Options
Every option except new("module_name") is a chained builder method. Boolean
flags default to the value shown.
| Option | Default | Purpose |
|---|---|---|
new("module") / lean("module") | Full / Lean | lean() is new().profile(Lean) — no Display/Debug, meta attrs, or dispatch |
with_module_name(name) | from new | Override the output module when cloning a base config |
with_conversion(selector) | — | Generic *_as::<T>() / *_from(&t) per selected field |
with_domain_type(selector, path) | — | One canonical app type per field (implies conversion); Generated impl |
with_manual_domain_type(selector, path) | — | Same signatures, caller supplies TryFromSbe/TryToSbe |
with_enum_mapping(EnumMapping) | — | Generated TryFromSbe/TryToSbe between an SBE enum and your enum, an exhaustive match with renames; NullVal (and an unknown value) is an error (App Types) |
with_composite_mapping(CompositeMapping) | — | Generated struct-literal mapping between a composite and your struct, member by member with renames; constants skipped, optional members Option |
with_source_traits(enable: bool) | false | {Message}Source trait per message; encode_from(&src) encodes your own type, groups and var-data included, without allocating |
with_domain_objects(var_data) | off | Owned *Domain + encode; Strings → String (bad UTF-8 → InvalidUtf8), Bytes → Vec<u8> |
with_display_debug(enable: bool) | true | Emit Debug/Display impls on generated types |
with_meta_attributes(enable: bool) | true | Emit *_ENCODING_OFFSET, *_ID, *_META_ATTRIBUTE etc. |
with_dispatch(enable: bool) | true | Emit AnyMessage/FrameCursor/MessageVisitor dispatch |
with_bool_domain_type(enable: bool) | false | Auto-register bool converters for every boolean enum (name, semanticType, or {0,1} value pair) |
with_null_as_option(selector) | — | NullVal → None for matching enum fields; getter returns Option<Enum> |
with_all_enums_as_option() | false | All enums → Option<Enum>; blanket form of with_null_as_option |
profile(GenerationProfile) | Full | Preset: Full (default conveniences) or Lean (off: Display/Debug, meta attrs, dispatch; domains stay off unless re-enabled). Individual with_* overrides still apply after profile. |
with_deprecated_attrs(enable: bool) | false | #[deprecated] on schema-deprecated items |
with_shared_module(name) | — | Multi-schema shared types module |
with_external_sbe_rt(path) | — | Share one sbe_rt runtime module instead of inlining |
with_error_from_impls(path) | — | Deprecated: From<EncodeError> for YourError via String; prefer a typed From (see below) |
with_keyword_append_token(token) | "_" | Schema type → Rust type_ |
with_encode_version(version) | — | Encoder writes version and omits members above it; the decoder still reads every version in the schema |
with_hook(fn) | — | Register a code-generation hook (serde, custom traits, …) |
Turn off with_display_debug, with_meta_attributes, and with_dispatch to
shrink generated code. Text fields stay bytes unless the schema declares a
character encoding (then strict UTF-8/ASCII helpers apply).
Decoder lanes (no configuration needed)
Memoization is a runtime lane, not a generation knob.
with_memoized_tail_offsets / with_compact_tail_offsets are gone.
let decoder = CarDecoder::try_from(bytes)?; // small, Sync, recalculates tails
let decoder = decoder.memoized(); // lazy cache, no allocation
Build it once and share & references. The base decoder is Sync; the
memoized one is Send but not Sync. Compact u32 tail-offset storage was
tried and removed; the deciding measurement was not archived. Lanes:
Decoder Lanes.
Typed error conversions
GenerationConfig::with_error_from_impls is deprecated and scheduled for
removal in 1.0. It formats through String, so needed/available are
lost. Prefer a typed From on the generated sbe_rt types:
#![allow(unused)]
fn main() {
enum AppError {
Encode(sbe_rt::EncodeError),
Decode(sbe_rt::DecodeError),
}
impl From<sbe_rt::EncodeError> for AppError {
fn from(error: sbe_rt::EncodeError) -> Self {
match error {
sbe_rt::EncodeError::BufferTooShort {
field,
needed,
available,
} => {
let _ = (field, needed, available);
Self::Encode(error)
}
other => Self::Encode(other),
}
}
}
impl From<sbe_rt::DecodeError> for AppError {
fn from(error: sbe_rt::DecodeError) -> Self {
Self::Decode(error)
}
}
}
Code-Generation Hooks
Niche feature. Hooks are aimed at users who need to attach extra
implblocks to generated code — serde, custom validation, company-internal traits. Most workflows don’t need them; skip this section unless you recognise your use case.Hooks append tokens after each generated item; they cannot add a
#[derive(...)]to the item itself (that attribute would have to precede thestruct/enum). Emit the traitimpldirectly instead — that is what a derive would expand to anyway.
Hooks let you append arbitrary Rust tokens after each generated item (enum, set,
composite, message decoder/encoder, domain struct). The closure receives an
ItemContext
with structured field/variant/choice metadata, plus a schema reference for
full IR access.
Example — add serde Serialize + Deserialize to every enum and set:
// build.rs
use ergo_sbe::{GenerationConfig, ItemContext};
use quote::quote;
fn serde_hook(ctx: &ItemContext) -> Vec<proc_macro2::TokenStream> {
match ctx {
ItemContext::Enum { name, variants, .. } => {
let ident = quote::format_ident!("{name}");
let labels: Vec<_> = variants.iter().map(|v| v.label.clone()).collect();
let names: Vec<_> = variants.iter().map(|v| quote::format_ident!("{}", v.name)).collect();
let from_labels: Vec<_> = variants.iter().map(|v| quote::format_ident!("{}", v.name)).collect();
vec![quote::quote! {
impl serde::Serialize for #ident {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(match self {
#(Self::#names => #labels,)*
_ => "NullVal",
})
}
}
impl<'de> serde::Deserialize<'de> for #ident {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = <&str>::deserialize(d)?;
match s {
#(#labels => Ok(Self::#from_labels),)*
"NullVal" => Ok(Self::NullVal),
// Reject unknown values — fallback is app policy,
// not codec behaviour.
other => Err(serde::de::Error::unknown_variant(
other, &[#(#labels,)* "NullVal"])),
}
}
}
}]
}
ItemContext::Set { name, choices, .. } => {
let ident = quote::format_ident!("{name}");
let is: Vec<_> = choices.iter().map(|c| quote::format_ident!("is_{}", c.snake_name)).collect();
let labels: Vec<_> = choices.iter().map(|c| c.label.clone()).collect();
let froms: Vec<_> = choices.iter().map(|c| quote::format_ident!("{}", c.snake_name)).collect();
vec![quote::quote! {
impl serde::Serialize for #ident {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut names = Vec::new();
#(if self.#is() { names.push(#labels); })*
names.serialize(s)
}
}
impl<'de> serde::Deserialize<'de> for #ident {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let names: Vec<&str> = <Vec<&str>>::deserialize(d)?;
let mut val = Self::default();
for n in &names {
match *n {
#(#labels => { val.#froms(true); })*
// Reject unknown labels — don't silently drop.
other => return Err(serde::de::Error::unknown_variant(
other, &[#(#labels),*])),
}
}
Ok(val)
}
}
}]
}
_ => vec![],
}
}
let config = GenerationConfig::new("msgs").with_hook(serde_hook);
Each ItemContext variant carries the fields, variants, or choices defined in
the schema — use them to build custom impl blocks or trait implementations.
Hooks fire in registration order; the returned tokens are appended after the
generated item (so they extend it with impls, not with derives).
Full runnable example with serde + serde_json round-trip:
hook_serde_test.
Recipes
Runnable code: sbe-feature-tour src/lib.rs.
- Aeron try_claim
- Display / Debug
- Schema Descriptions → Rustdoc
- Domain DTOs
- App Types on Composites
- Timestamp Conversions
Quick reference
Known vs unknown group count:
#![allow(unused)]
fn main() {
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
(From sbe-feature-tour — known-count groups with chaining, tested in CI.)
Unknown size: count back-patched after the closure (streaming producers).
let unknown_len = CarEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&fields)
.fuel_figures_unknown_size(|g| {
for row in rows {
g.add(|mut e| {
e.speed(row.speed).mpg(row.mpg);
e.usage_description_as_str(&row.usage)
})?;
}
Ok(())
})?
.performance_figures(0, |_| Ok(()))?
.manufacturer_as_str("Honda")?
.model_as_str("Civic")?
.activation_code_as_str("active")?
.encoded_length_with_header();
println!("known={known_len} unknown={unknown_len}");
Aeron try_claim Integration
ergo-sbe’s exact sizing is built for encoding straight into a claimed
region: size the frame, claim exactly that many bytes, encode into the claim,
commit. No scratch buffer, no copy. The generated send and send_fixed do
the last three steps and check the result, so a frame that does not fill its
claim is a typed error and the claim is aborted, never a corrupt publication.
The Claim trait
Every generated module’s sbe_rt declares Claim, a region a transport
reserved for one frame. ergo-sbe has no Aeron dependency, so you implement it
once for your transport. In the crate that generates the codec the trait is
local, so rusteron’s claim type can implement it directly (from another crate,
where both are foreign, wrap the claim in a newtype):
use rusteron_client::{AeronCError, AeronClaim};
impl messages::sbe_rt::Claim for AeronClaim {
type Error = AeronCError;
fn frame_mut(&mut self) -> &mut [u8] {
self.data()
}
fn commit(self) -> Result<(), AeronCError> {
AeronClaim::commit(self).map(drop)
}
fn abort(self) {
let _ = AeronClaim::abort(self);
}
}
send calls abort whenever it publishes nothing — an encoder error, a
length or header mismatch, and while unwinding from a panic in your closure —
so the term-buffer slot is released at once rather than after Aeron’s unblock
timeout.
No encoder writes a block’s padding (the bytes past its last field, up to the
schema’s blockLength), so they go out as the claimed region held them. An
Aeron try_claim region starts zeroed by the transport; the cost of that
zeroing is not measured here. A Claim you implement over a region the
transport reuses must hand it out zeroed, as OfferClaim (below) does.
Fixed-only messages: send_fixed
let claim = publication.try_claim_owned(HeartbeatEncoder::ENCODED_LENGTH)?;
HeartbeatEncoder::send_fixed(claim, &HeartbeatFixedFields { sequence: 7, timestamp: 0 })?;
A claim of any other size is SendError::LengthMismatch, and nothing is
written into it.
Messages with groups and var-data: send
Size the frame with the encoder’s compute_length* helpers (or the staged
EncodedLength builder for nested groups), claim that length, and hand
send a closure that writes the message. The closure receives the encoder
already wrapped on the claim, header written, and must return the message’s
terminal stage — so a frame with tails still to write does not compile:
let len = NoteEncoder::compute_length_with_header(body.len());
NoteEncoder::send(publication.try_claim_owned(len)?, |enc| {
enc.fixed(&NoteFixedFields { note_id: 1 }).body(body)
})?;
send checks that the frame is exactly the claim’s length (a claim sized
for a different frame would otherwise publish stale bytes or a truncated
message) and that it starts with the message’s own header. Each failure has
its own SendError variant: Encode for the encoder’s error,
LengthMismatch, HeaderMismatch, and Commit for the transport refusing
the frame — the only case where the claim was not aborted, because the
transport already had it.
Offer-style transports: OfferClaim
A transport that copies a finished frame, like Aeron’s offer, uses
sbe_rt::OfferClaim over a scratch buffer: the frame is encoded into the
buffer, and committing hands it to the closure. The same send and
send_fixed work unchanged, with the same checks. OfferClaim::new zeroes
the buffer, so a scratch buffer reused for every frame never puts the
previous frame’s bytes in the next one’s padding:
#![allow(unused)]
fn main() {
// Offer style: encode into `scratch`; commit hands the frame to the transport.
let mut scratch = [0u8; HeartbeatEncoder::ENCODED_LENGTH];
let mut published: Vec<Vec<u8>> = Vec::new();
let claim = sbe_rt::OfferClaim::new(&mut scratch, |frame: &[u8]| -> Result<(), &'static str> {
published.push(frame.to_vec()); // publication.offer(frame).map(drop)
Ok(())
});
HeartbeatEncoder::send_fixed(claim, &HeartbeatFixedFields { sequence: 7, timestamp: 0 })?;
assert_eq!(published.len(), 1);
}
#![allow(unused)]
fn main() {
let body = b"rebalance at close";
let len = NoteEncoder::compute_length_with_header(body.len());
let mut scratch = vec![0u8; len];
let claim = sbe_rt::OfferClaim::new(&mut scratch, |frame: &[u8]| -> Result<(), &'static str> {
assert_eq!(frame.len(), len);
Ok(())
});
// The closure gets the encoder wrapped on the claim and must return the
// finished frame: the type-state makes a half-written one a compile error.
NoteEncoder::send(claim, |enc| enc.fixed(&NoteFixedFields { note_id: 1 }).body(body))?;
}
#![allow(unused)]
fn main() {
// A claim one byte too long: nothing is offered, and the error says why.
let body = b"rebalance at close";
let len = NoteEncoder::compute_length_with_header(body.len());
let mut too_long = vec![0u8; len + 1];
let claim = sbe_rt::OfferClaim::new(&mut too_long, |_: &[u8]| -> Result<(), &'static str> {
unreachable!("an aborted claim is never offered")
});
let sent = NoteEncoder::send(claim, |enc| enc.fixed(&NoteFixedFields { note_id: 1 }).body(body));
assert_eq!(
sent,
Err(sbe_rt::SendError::LengthMismatch { claimed: len + 1, frame: len })
);
}
(From book/examples/claim-send.rs — compiled against the feature-tour codec.)
Encoding an application type
With with_source_traits, the
encoder can write any type that implements the message’s generated …Source
trait, sizing it first with encoded_length_with_header_from. encode_from
returns the terminal stage, so it is the whole send closure:
#![allow(unused)]
fn main() {
/// The application's own type: no generated struct is involved.
struct Reminder {
id: u32,
text: String,
}
impl NoteSource for Reminder {
fn note_id(&self) -> u32 {
self.id
}
fn body(&self) -> &[u8] {
self.text.as_bytes()
}
}
let reminder = Reminder { id: 9, text: "settle T+1".into() };
let len = NoteEncoder::encoded_length_with_header_from(&reminder)?;
let mut scratch = vec![0u8; len];
let claim = sbe_rt::OfferClaim::new(&mut scratch, |_: &[u8]| -> Result<(), &'static str> { Ok(()) });
NoteEncoder::send(claim, |enc| enc.encode_from(&reminder))?;
}
encode_from refuses what would not read back: Some of an optional
field’s null value (NaN for a float) is EncodeError::SomeNullValue, and a
value a domain conversion refuses is EncodeError::Conversion, each with the
field’s path and entry indices (fills[1].allocs[0].px). In a hand-written
closure, the setter of a field under with_enum_mapping or
with_composite_mapping returns sbe_rt::MappingError, which converts into
EncodeError, so enc.side(side)? works there too.
A frame written some other way: send_frame
sbe_rt::send_frame(claim, header, encode) is what send is built on: it
runs a closure that writes raw bytes into the claim and returns the frame’s
length, then applies the same length and header checks before committing.
Pass the message’s HEADER_TEMPLATE as header. Use it when the frame is not
written through one generated encoder, for example a bus that encodes
whichever message a caller hands it, and for a message whose own field, or
first group or var-data, is named send (or send_fixed): that accessor is
a method on the encoder too, so it keeps its name and the message has no
helper of that name.
Cluster clients
Cluster clients do not claim from the publication directly: use
AeronCluster::try_claim, which claims
SessionMessageHeader + payload and hands back the payload region via
ClusterClaim::payload_mut().
The cluster-ha-orderbook sample demonstrates the claim pattern in the context of a real Aeron Cluster publication loop.
Display / Debug
Diagnostic only — not a stable wire or log schema. Do not treat either format as a protocol or long-term log contract.
Display currently equals Debug for generated decoders ({car} and
{car:?} print the same text). Prefer Debug in logs if you want that intent
to stay obvious when/if the two diverge later.
#![allow(unused)]
fn main() {
pub fn demo_display_debug(valid_car: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let car = CarDecoder::try_from(valid_car)?;
let display = format!("{car}");
let debug = format!("{car:#?}");
// Display is a compact one-liner; field names use the schema's camelCase.
assert!(display.contains("serialNumber: 1234"));
assert!(display.contains("modelYear: 2013"));
assert!(display.contains("available: true"));
assert!(display.contains(r#"code: A"#));
assert!(display.contains("manufacturer: \"Honda\""));
assert!(display.contains("model: \"Civic VTi\""));
assert!(display.contains("fuelFigures: ["));
assert!(display.contains("performanceFigures: ["));
assert!(display.contains(r#"activationCode: "abcdef""#));
// Pretty Debug ({:#?}) shows each field on its own line with indentation.
for expected in &[
"serialNumber: 1234",
"modelYear: 2013",
"available: true",
r#"manufacturer: "Honda""#,
r#"model: "Civic VTi""#,
r#"activationCode: "abcdef""#,
r#"usageDescription: Urban"#,
"speed: 30",
"mpg: 35.9",
"octaneRating: 95",
] {
assert!(
debug.contains(expected),
"Debug missing: {expected}\n--- full debug ---\n{debug}"
);
}
// ── CarDomain DTO ───────────────────────────────────────────────────
// Owned, heap-allocated, serialisable snapshot of the full message tree.
// Generated by `with_domain_objects(DomainVarData::Strings)`.
let dto = CarDomain::try_from_decoder(car)?;
let dto_dbg = format!("{dto:#?}");
// DTO field names use Rust snake_case (different from wire decoder camelCase).
assert!(dto_dbg.contains("serial_number: 1234"));
assert!(dto_dbg.contains("model_year: 2013"));
assert!(dto_dbg.contains("available: true"));
assert!(dto_dbg.contains("fuel_figures: ["));
assert!(dto_dbg.contains(r#"usage_description: "Urban""#));
assert!(dto_dbg.contains(r#"manufacturer: "Honda""#));
assert!(dto_dbg.contains(r#"activation_code: "abcdef""#));
Ok(())
}
}
(This code comes from the sbe-feature-tour sample crate.)
Real output from the feature-tour Car (demo_car_size_and_encode →
CarDecoder):
CarDecoder { serialNumber: 1234, modelYear: 2013, available: true, code: A, fuelFigures: ["{ speed: 30, mpg: 35.9, usageDescription: Urban }", "{ speed: 60, mpg: 25.0, usageDescription: Highway }"], performanceFigures: ["{ octaneRating: 95, acceleration: [{ mph: 30, seconds: 4.0 }, { mph: 60, seconds: 7.5 }] }"], manufacturer: "Honda", model: "Civic VTi", activationCode: "abcdef" }
Truncated / incomplete buffers omit missing tails rather than panicking.
Schema Descriptions → Rustdoc
<field name="serialNumber" id="1" type="uint64" description="VIN-style serial"/>
// Generated (approx):
/// VIN-style serial
pub fn serial_number(&self) -> u64 { … }
Provenance of all four XML doc sources: schema_docs_provenance_test.
Domain DTOs
Use when you want owned values (Vec groups, owned tails) and simple
structs — not the zero-copy hot path. DTOs allocate on decode; the
flyweight path does not. A DTO-vs-flyweight latency A/B was not measured.
For re-encode, eligible flat groups are bulk-written directly from
&[EntryDomain]: no temporary Vec<Entry> and no encode-time allocation.
Eligibility requires fixed-size entries whose domain fields have the same wire
representation; nested groups, var-data, optional/versioned fields, configured
domain conversions, and bool remapping use the general add path. Integer
min/max checks are preserved in both paths. with_all_enums_as_option() /
with_null_as_option maps a required enum to Option<Enum> on the DTO:
None writes the schema NullVal image (including on a reused buffer), and
bulk_add_domain / to_wire_entry use that same mapping.
DTO re-encode of eligible flat groups is a DTO-to-DTO diagnostic
(group_encode_bench), not an ergon/sbe-tool ratio. Read a fresh run; do not
quote numbers from this page.
#![allow(unused)]
fn main() {
// build.rs — DomainVarData picks the DTO field type for var-data:
// .with_domain_objects(DomainVarData::Strings) // String (invalid UTF-8 → InvalidUtf8 error)
// .with_domain_objects(DomainVarData::Bytes) // Vec<u8> (byte-exact)
}
Generated shape (illustrative — your names follow your schema):
pub struct QuoteDomain {
pub seq: u32,
pub some_numbers: [u32; 4],
pub vehicle_code: [u8; 6],
pub qty: u32,
pub legs: Vec<QuoteLegsEntryDomain>,
pub note: Vec<u8>, // Bytes|String per DomainVarData
}
impl QuoteDomain {
// Named methods, not TryFrom/From: two fallible sources (decoder vs framed
// slice+offset), and materialisation is never infallible.
pub fn try_from_decoder(dec: QuoteDecoder<'_>) -> Result<Self, DecodeError>;
pub fn try_from_slice_with_header(buf: &[u8], offset: usize) -> Result<Self, DecodeError>;
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, EncodeError>;
pub fn encoded_length_with_header(&self) -> Result<usize, EncodeError>;
}
Wire → DTO → wire round-trip (the docs fixture uses DomainVarData::Bytes):
#![allow(unused)]
fn main() {
// Encode a message first (the usual flyweight path)
let mut buf = [0u8; QuoteEncoder::compute_length_with_header(1, 2)];
let len = QuoteEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&QuoteFixedFields {
seq: 1,
some_numbers: [1, 2, 3, 4],
vehicle_code: *b"ABCDEF",
qty: 10,
})
.legs(1, |legs| {
legs.add(|leg| { leg.value(99); Ok(()) })?;
Ok(())
})?
.note_as_str("hi")?
.encoded_length_with_header();
// Decode → owned DTO (allocates — not for the hot path)
let dec = QuoteDecoder::try_from(&buf[..len])?;
let mut dto = QuoteDomain::try_from_decoder(dec)?;
assert_eq!(dto.seq, 1);
assert_eq!(&dto.note, b"hi");
dto.qty = 500;
// Re-encode (integer min/max checked; eligible groups use bulk write)
let n = dto.encode(&mut buf)?;
assert_eq!(n, len);
}
with_domain_objects(DomainVarData)
SBE <data> is length-prefixed bytes. The enum picks the DTO field type:
| Call | Field type | Invalid UTF-8 | When to use |
|---|---|---|---|
.with_domain_objects(DomainVarData::Strings) | String | InvalidUtf8 error (strict; 0.1.10) | Text schemas when validity is known |
.with_domain_objects(DomainVarData::Bytes) | Vec<u8> | n/a (raw copy) | Binary tails or byte-exact re-encode |
Strings rejects invalid UTF-8. Materialise returns InvalidUtf8
for bad bytes; there is no silent empty-string fallback. Use Bytes (or stay
on flyweights) when you need audit / replay fidelity of non-UTF-8 tails.
Runnable demo (text path):
sbe-feature-tour
uses DomainVarData::Strings. Flyweight path is unchanged: with schema
characterEncoding="UTF-8" you still get into_manufacturer_as_str() without
a DTO.
demo_car_domain_dto · domain_objects_test.
App Types on Composites
SBE composite types like Decimal { mantissa, exponent } have a fixed wire
layout. Mapping them to Rust domain types is done at configuration time — no
hand-rolled per-field converters needed.
Option A — generic converter (with_conversion): the generated codec
emits price_from<T: TryToSbe<Decimal>>() and price_as<T: TryFromSbe<Decimal>>().
You implement the trait for any app type. One wire type, many app types.
Option B — concrete mapping (with_domain_type): the generated codec
emits price(rust_decimal::Decimal)? and price()? -> rust_decimal::Decimal.
Exactly one Rust type per wire type. The trait impls are generated for you.
Option C — generated mapping (with_enum_mapping, with_composite_mapping):
for an enum or composite whose application counterpart has the same shape,
ergo-sbe writes the TryFromSbe / TryToSbe impls too. See below.
Full comparison with code examples: with_conversion vs with_domain_type.
Generated mappings
An application enum that mirrors an SBE enum, and a struct that mirrors a composite, need no hand-written conversion:
GenerationConfig::new(module)
.with_enum_mapping(
EnumMapping::new("Side", "app::Side")
.rename("Buy", "Bid")
.rename("Sell", "Ask"),
)
.with_composite_mapping(CompositeMapping::new("Price", "app::Px"))
.with_composite_mapping(CompositeMapping::new("Leg", "app::Leg").rename("qty", "quantity"))
pub mod app {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side { Bid, Ask, SellShort }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Px { pub mantissa: i64, pub exponent: i8 }
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Leg {
pub price: Px,
pub side: Side,
pub quantity: u32,
pub min_qty: Option<u32>,
pub ratio: Option<f64>,
pub flags: super::Flags,
pub venue: [u8; 4],
}
}
(From sbe/tests/type_mapping_test.rs, which generates, compiles and runs
these against the plain codec.)
Each mapping also acts as a with_domain_type for its type, so every field of
that type takes the application type: dec.side()? -> app::Side,
enc.leg(leg)?, and the application type in a domain DTO (a DTO field of an
enum named BooleanType, or with semanticType="Boolean", stays bool, as
under with_domain_type). A presence="constant" field of a mapped enum has
the same domain getter beside its *_wire getter, and no setter. The wire
bytes are the ones the plain codec writes.
A mapped setter’s error is sbe_rt::MappingError, which converts into
sbe_rt::EncodeError, so enc.leg(leg)? works inside an encode or send
closure. A domain DTO’s encode and encode_from report the same refusal as
EncodeError::Conversion, with the field’s path and the index of its entry
in each group on the way (fills[1].leg) and the MappingError itself.
Enum (EnumMapping). A schema variant maps to the application variant of
the same generated name (sellShort → SellShort) unless renamed. Both
impls are exhaustive matches, so an application enum that lacks a schema
variant, or has one the schema lacks, does not compile.
Composite (CompositeMapping). TryFromSbe is a struct literal built
from the composite’s getters, TryToSbe calls the composite’s new; a
member fills the field of the same snake-case name unless renamed. Constant
members have no field (they are not on the wire). An optional scalar member is
an Option field: None writes the member’s null value, and Some of the
null value — which would read back as None — is refused. A member whose enum
or composite has its own generated mapping uses that mapping’s application
type (price: app::Px, side: app::Side above); every other member keeps its
generated wire type. A struct missing a member’s field, or with a field no
member fills, does not compile.
NullVal and unknown values
NullVal has no application value, so decoding it is an explicit
sbe_rt::MappingError::NullEnum, never a default variant. The generated
decoder folds every discriminant the schema does not declare into NullVal
before the mapping sees it, so an unknown value is the same error: the mapping
cannot tell it from the schema null, and only the decoder’s raw_<field>()
still reads the wire byte. With with_null_as_option on the enum, both read as
Ok(None) instead:
let side_at = dm::OrderEncoder::HEADER_LENGTH + 8;
assert_eq!(buf[side_at], 5, "sellShort's discriminant");
for raw in [0u8, 9] {
// 0 is NullVal; 9 is undeclared and folds into NullVal on decode.
buf[side_at] = raw;
let dec = dm::OrderDecoder::decode(&buf, 0)?;
assert_eq!(dec.side_wire(), dm::Side::NullVal);
assert_eq!(dec.side(), Err(dm::sbe_rt::MappingError::NullEnum { enum_name: "Side" }));
assert!(dm::OrderDomain::try_from_slice_with_header(&buf[..len], 0).is_err());
// with_null_as_option: absent instead of an error — for the
// undeclared 9 too, the folding limitation.
let dec = dmo::OrderDecoder::decode(&buf, 0)?;
assert_eq!(dec.side(), Ok(None));
}
buf[side_at] = 2;
assert_eq!(dmo::OrderDecoder::decode(&buf, 0)?.side(), Ok(Some(dmo::app::Side::Ask)));
On a presence="optional" field, NullVal is not an error but the field
being absent, so a mapped enum that such a field uses needs
with_null_as_option: without it, generation fails with
GenerateError::InvalidConfiguration naming the field, instead of every
valid frame that leaves the field out failing to decode.
Optional composites with a null image
A composite member itself can carry presence="optional" with a schema
nullValue — e.g. a PriceNull9-style Decimal whose mantissa is optional
while exponent stays constant. That member decodes as Option<i64>
(checked against the wire null sentinel), not a bare i64. This is distinct
from a composite field gated by sinceVersion (which the whole composite
accessor wraps in Option<Decoder>), and from a composite with no nullValue
anywhere (which has no null image to check, so it decodes as a plain value —
see with_conversion vs with_domain_type).
Because the wire null sentinel is not a valid rust_decimal::Decimal, the
with_domain_type accessor fails closed with a typed error rather than
silently decoding the sentinel as a huge/wrong number:
use rust_decimal::Decimal;
// `price: None` writes the schema null image (mantissa = nullValue).
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let len = QuoteEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&QuoteFixedFields { price: None, qty: 7 })
.encoded_length_with_header();
let dec = QuoteDecoder::try_decode(&buf[..len], 0)?;
assert_eq!(dec.price_value().mantissa(), None);
assert!(dec.price().is_err(), "null mantissa is not a valid Decimal");
// A real price round-trips through the null-aware member accessor.
let len2 = QuoteEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&QuoteFixedFields { price: None, qty: 7 })
.price(Decimal::new(12345, 9))?
.encoded_length_with_header();
let dec2 = QuoteDecoder::try_decode(&buf[..len2], 0)?;
assert_eq!(dec2.price_value().mantissa(), Some(12345));
assert_eq!(dec2.price()?, Decimal::new(12345, 9));
(From optional_composite_member_null_image_roundtrip in
sbe/tests/baseline_test.rs — a real generated-and-compiled test, not a
standalone snippet.)
Timestamp Conversions
SBE timestamps are typically uint64 fields with a semanticType
(UTCTimestamp = nanos, UTCTimestampMicros = micros,
UTCTimestampMillis = millis). The chrono feature maps them to
DateTime<Utc> / NaiveDateTime. Enable it on both Cargo entries:
[build-dependencies]
ergo-sbe = { version = "0.1", features = ["chrono"] }
[dependencies]
ergo-sbe = { version = "0.1", features = ["chrono"] }
chrono = "0.4"
Then register the converters by semanticType in build.rs:
use ergo_sbe::{ConversionSelector, GenerationConfig};
let config = GenerationConfig::new("msgs")
.with_domain_type(
ConversionSelector::semantic_type("UTCTimestamp"),
"chrono::DateTime<chrono::Utc>",
)
.with_domain_type(
ConversionSelector::semantic_type("UTCTimestampMicros"),
"chrono::NaiveDateTime",
);
Schema:
<field name="created_at" id="1" type="uint64" semanticType="UTCTimestamp"/>
<field name="updated_at" id="2" type="uint64" semanticType="UTCTimestampMicros"/>
Generated API — created_at() returns DateTime<Utc>, updated_at()
returns NaiveDateTime:
// Decode
let created: chrono::DateTime<chrono::Utc> = dec.created_at()?;
let updated: chrono::NaiveDateTime = dec.updated_at()?;
// Encode
enc.created_at(chrono::Utc::now())?;
enc.updated_at(
chrono::DateTime::from_timestamp_micros(1_720_000_000_000_000)
.ok_or("micros out of range")?
.naive_utc(),
)?;
timeUnit on a UTCTimestamp field
A uint64 UTCTimestamp field may say what it counts with timeUnit
(second, millisecond, microsecond or nanosecond; nanoseconds when
absent). The built-in DateTime<Utc> conversion honours it: the domain
accessors read and write the field in its own unit, so one
semantic_type("UTCTimestamp") mapping serves fields of every precision.
<field name="sentAt" id="1" type="uint64" semanticType="UTCTimestamp" timeUnit="millisecond"/>
Writing a DateTime more precise than the field — sub-millisecond digits into
a millisecond field — is an error rather than a truncation; truncate it first
(chrono::DurationRound::duration_trunc) when that is what you mean. Under
DomainImpl::Manual your impl receives the field’s own unit.
One selector, many fields
ConversionSelector::semantic_type(..) matches every field in the
schema carrying that semanticType — not just one. You register the
conversion once, not once per field. A schema with three separate
UTCTimestamp timestamps needs no more config than one with a single field:
<field name="createdAt" id="1" type="uint64" semanticType="UTCTimestamp"/>
<field name="updatedAt" id="2" type="uint64" semanticType="UTCTimestamp"/>
<field name="expiresAt" id="3" type="uint64" semanticType="UTCTimestamp"/>
// Same one-time call as the two-field example above — no per-field repeats.
let config = GenerationConfig::new("msgs")
.with_domain_type(
ConversionSelector::semantic_type("UTCTimestamp"),
"chrono::DateTime<chrono::Utc>",
);
All three fields get their own concrete accessor, generated from that one call:
enc.created_at(chrono::Utc::now())?;
enc.updated_at(chrono::Utc::now())?;
enc.expires_at(chrono::Utc::now())?;
let created: chrono::DateTime<chrono::Utc> = dec.created_at()?;
let updated: chrono::DateTime<chrono::Utc> = dec.updated_at()?;
let expires: chrono::DateTime<chrono::Utc> = dec.expires_at()?;
This is why UTCTimestamp and UTCTimestampMicros need separate
with_domain_type calls in the two-field example above — they’re different
semanticType strings, so they’re different selectors — but adding a fourth
UTCTimestamp field to the same schema needs no config change at all.
ConversionSelector::field_path
is the escape hatch when one specific field needs to differ from its
semantic-type siblings. It works because selectors resolve by precedence,
not registration order: an exact FieldPath beats a SemanticType, which
beats a NamedType. So a blanket semantic_type("UTCTimestamp") mapping plus
one field_path("Order.settledAt") override does what it reads like,
regardless of which you register first.
Paths are "Message.field", extended with the group names for fields inside a
group: "Order.legs.settledAt".
Mixed precisions, one app type — DomainImpl::Manual
The built-in converters give nanos → DateTime<Utc> and micros →
NaiveDateTime — two different app types, because that’s what the
built-in impls happen to produce. Real schemas often need the opposite: three
fields at three different wire precisions, all normalized to the same
DateTime<Utc> so downstream app code never branches on precision. None of
the three is the bare uint64 + semanticType="UTCTimestamp" shape the
built-in converter matches, so none gets an auto-generated impl — that’s
exactly the case DomainImpl::Manual
is for: concrete domain accessor signatures from ergo-sbe, conversion body from you.
Distinguish the three precisions with single-element composites (a distinct
Rust type per precision — TimestampMillis(u64) is not TimestampNanos(u64)
even though the wire shape is identical):
<composite name="TimestampMillis"><type name="ts" primitiveType="uint64"/></composite>
<composite name="TimestampMicros"><type name="ts" primitiveType="uint64"/></composite>
<composite name="TimestampNanos"><type name="ts" primitiveType="uint64"/></composite>
<field name="createdAt" id="1" type="TimestampNanos"/>
<field name="updatedAt" id="2" type="TimestampMicros"/>
<field name="deletedAt" id="3" type="TimestampMillis"/>
One with_manual_domain_type(selector, path) call per composite — same
target type, rust_decimal-style — in build.rs:
use ergo_sbe::{ConversionSelector, GenerationConfig};
let config = GenerationConfig::new("msgs")
.with_manual_domain_type(
ConversionSelector::named_type("TimestampNanos"),
"chrono::DateTime<chrono::Utc>",
)
.with_manual_domain_type(
ConversionSelector::named_type("TimestampMicros"),
"chrono::DateTime<chrono::Utc>",
)
.with_manual_domain_type(
ConversionSelector::named_type("TimestampMillis"),
"chrono::DateTime<chrono::Utc>",
);
Each composite is a genuinely new named type, so there is no built-in
template to offer — write the three impls yourself, one per precision
(the traits live in the generated module as sbe_rt::TryFromSbe /
sbe_rt::TryToSbe; import from there, not from ergo_sbe::codegen, which
is crate-private):
// my_msgs is the module name passed to GenerationConfig::new("my_msgs")
use my_msgs::sbe_rt::{TryFromSbe, TryToSbe};
use my_msgs::{TimestampMillis, TimestampMicros, TimestampNanos};
impl TryFromSbe<TimestampNanos> for chrono::DateTime<chrono::Utc> {
type Error = &'static str;
fn try_from_sbe(wire: TimestampNanos) -> Result<Self, Self::Error> {
// A single-member composite wraps its wire bytes: TimestampNanos([u8; 8]).
let ns = u64::from_le_bytes(wire.0);
chrono::DateTime::from_timestamp((ns / 1_000_000_000) as i64, (ns % 1_000_000_000) as u32)
.ok_or("timestamp out of range")
}
}
impl TryToSbe<TimestampNanos> for chrono::DateTime<chrono::Utc> {
type Error = &'static str;
fn try_to_sbe(&self) -> Result<TimestampNanos, Self::Error> {
// The wire type is uint64: pre-epoch has no representation, so it is
// an error. `as u64` on a negative i64 would wrap silently.
let ns = self.timestamp_nanos_opt().ok_or("overflow")?;
let ns = u64::try_from(ns).map_err(|_| "timestamp before 1970")?;
Ok(TimestampNanos(ns.to_le_bytes()))
}
}
impl TryFromSbe<TimestampMicros> for chrono::DateTime<chrono::Utc> {
type Error = &'static str;
fn try_from_sbe(wire: TimestampMicros) -> Result<Self, Self::Error> {
let us = u64::from_le_bytes(wire.0);
chrono::DateTime::from_timestamp((us / 1_000_000) as i64, ((us % 1_000_000) * 1_000) as u32)
.ok_or("timestamp out of range")
}
}
impl TryToSbe<TimestampMicros> for chrono::DateTime<chrono::Utc> {
type Error = &'static str;
fn try_to_sbe(&self) -> Result<TimestampMicros, Self::Error> {
let us = u64::try_from(self.timestamp_micros()).map_err(|_| "timestamp before 1970")?;
Ok(TimestampMicros(us.to_le_bytes()))
}
}
impl TryFromSbe<TimestampMillis> for chrono::DateTime<chrono::Utc> {
type Error = &'static str;
fn try_from_sbe(wire: TimestampMillis) -> Result<Self, Self::Error> {
let ms = u64::from_le_bytes(wire.0);
chrono::DateTime::from_timestamp((ms / 1000) as i64, ((ms % 1000) * 1_000_000) as u32)
.ok_or("timestamp out of range")
}
}
impl TryToSbe<TimestampMillis> for chrono::DateTime<chrono::Utc> {
type Error = &'static str;
fn try_to_sbe(&self) -> Result<TimestampMillis, Self::Error> {
let ms = u64::try_from(self.timestamp_millis()).map_err(|_| "timestamp before 1970")?;
Ok(TimestampMillis(ms.to_le_bytes()))
}
}
All three fields now return the exact same app type despite three different wire precisions — the caller never has to know or care which precision a given field was wire-encoded at:
let created: chrono::DateTime<chrono::Utc> = dec.created_at()?;
let updated: chrono::DateTime<chrono::Utc> = dec.updated_at()?;
let deleted: chrono::DateTime<chrono::Utc> = dec.deleted_at()?;
enc.created_at(chrono::Utc::now())?;
enc.updated_at(chrono::Utc::now())?;
enc.deleted_at(chrono::Utc::now())?;
If you forget one of the three impls, the compile error names it directly —
`chrono::DateTime<Utc>` has no `TryFromSbe<TimestampMillis>` impl —
instead of the default trait-bound message.
Design Notes
Type-state and runtime cost
Stage identity adds no runtime tag. The named structs carry buffer and cursor
state; markers such as HeaderPresent are zero-sized. A transition moves that
state into the next concrete type:
(buf, msg_offset, pos) + PhantomData / zero-sized stage identity
No heap, no vtable, no enum discriminant, no “which stage am I in?” branch — the stage is the type, so the methods that exist are the ones legal at that point. Inlining can remove the intermediate moves. Decoder stages also carry acting version and block length; dynamic group callbacks check that the returned completion belongs to the supplied entry.
The sbe-tool gate compares two different codecs, so it cannot isolate the cost of type-state, and no benchmark here measures that cost on its own. A measurable type-state tax would be a codegen defect. Gate and method: Benchmarks.
Named stages (CarEncoder → CarAfterFuelFigures → CarComplete) are
the type-state pattern. The other spelling is Encoder<'a, Stage> with
phantom markers. This crate uses named stages; the two were not compared.
| Concern | Choice | Why |
|---|---|---|
| Linear tail (groups / var-data) | Named structs per stage | Compile errors name the group you skipped (expected CarAfterFuelFigures); rustdoc stays scannable |
| Header vs body-only | One H: HeaderState on every stage | Avoids doubling the graph. Default H = HeaderPresent |
| Completeness of the fixed block | F: FieldsState on the root encoder | as_bytes_with_header exists only after fixed(&FixedFields) |
Tail stages drop F — they are already past the fixed block.
pub struct BookEncoder<'a, H: HeaderState = HeaderPresent, F: FieldsState = FieldsUnfixed> { /* … */ }
pub struct BookAfterBids<'a, H: HeaderState = HeaderPresent> { /* same layout */ }
impl BookEncoder<'a, H, FieldsFixed> {
pub fn bids(self, …) -> Result<BookAfterBids<'a>, …> { … }
// no asks() — bids first on the wire
}
Stage names: After{GroupPascal} (fuelFigures → CarAfterFuelFigures).
Reserved method names: reserved_name_clash_test. Product rationale:
Wire order. Migration:
Coming from sbe-tool.
API freeze decisions (pre-1.0)
Deliberate decisions on the generated public surface. Changing any of these
after 1.0 is a major version. Golden file
sbe/tests/golden/car_example.rs
is the artifact for API shape review.
1. wrap takes message start (not body offset)
| ergo-sbe | sbe-tool Rust | |
|---|---|---|
wrap offset | Message start (first byte of header) | Body offset (usually message_start + 8) |
| Field bytes | at message_offset + HEADER_LENGTH + field_offset | at body_offset + field_offset |
Decision: keep ergon semantics. One offset works for encode wrap,
wrap_and_apply_header, and claim buffers. sbe-tool refugees who pass 8
for a frame at zero will mis-align every field — that is the #1 migration
trap. Loud rustdoc on every generated wrap / wrap_and_apply_header /
decode documents this; the book chapter
Coming from sbe-tool is the full mapping.
2. *FixedFields is intentionally exhaustive
Generated CarFixedFields (and peers) are not #[non_exhaustive].
Decision: exhaustive is a feature. When the schema adds a fixed field,
every fixed(&…) call site must update. Silent Default / ignored new
fields would hide schema drift. Do not “fix” this with #[non_exhaustive]
without a major-version design review.
3. Stage struct naming
Pattern: {Message}After{GroupPascal} for intermediate stages,
{Message}Complete for the terminal encoder stage; decoder stages use
{Message}DecoderAfter{…} similarly. Multi-word group names go through the
same PascalCase path as other types (fuelFigures → FuelFigures →
CarAfterFuelFigures). Reserved-name clash coverage:
sbe/tests/reserved_name_clash_test.rs.
Decision: keep named monomorphic stages (not Encoder<State = AfterBids>).
Rationale: Type-state design note.
4. _unchecked companions are a supported opt-in
Decision: supported production opt-in after a proven trust boundary — not “benchmarking only” framing.
- Default generation exposes the three-tier constructor boundary:
try_*(Result), barewrap(panic after extent proof), baredecode(panic if short,Erron wrong template/schema), andunsafe fn *_unchecked. - Safety contract: validate with
try_decode/try_from/try_wrap/verifyat trust edges; only*_uncheckedmay skip the extent proof (UB if wrong). - Hot loops after validation are an intended use case for the unchecked lane. Checked constructors remain the default for untrusted input.
See Trust boundaries.
5. Header marker default
H: HeaderState = HeaderPresent on encoder stages so the common full-frame
path needs no turbofish. Body-only encoding uses wrap / HeaderAbsent
explicitly.
6. No renames bundled with this note
This audit records decisions; it does not rename public generated types.
7. #[non_exhaustive] policy for generated structs
| Struct | #[non_exhaustive] | Rationale |
|---|---|---|
{Msg}FixedFields | No (exhaustive) | Schema field additions must surface as compile errors (§2 above) |
{Msg}Encoder | No (all fields pub(crate)) | Constructed by the generated wrap / wrap_and_apply_header |
{Msg}Decoder | No (all fields pub(crate)) | Constructed by the generated try_decode / decode / wrap |
{Msg}After{Element} | No (all fields pub(crate)) | Only reachable through the consuming tail-stage chain |
{Msg}Complete | No (all fields pub(crate)) | Reachable after writing all tails |
{Group}Encoder | No (all fields pub(crate)) | Constructed by the generated group closure |
{Group}Decoder | No (fields are pub(crate)) | Constructed by generated iterator / wrap |
{Group}EntryComplete | No (fields pub(crate)) | Only produced by add_checked / complete() |
{Msg}EncodedLength | No (fields pub(crate)) | Constructed by compute_length() |
{Msg}EncodedLengthAfter* / Complete | No (fields pub(crate)) | Consuming stages, same as encoder |
{Msg}Schema | No (unit struct) | Carries only consts |
ConnectStep | Yes | New async-connect steps must not break exhaustiveness downstream |
GenerateError | Yes | Future validation variants are additive |
ParseError | Yes (1.0) | Typed Io / Include causes; exhaustive matches need a wildcard |
Decision: keep generated consumer-facing structs non-exhaustive via pub(crate) fields rather than #[non_exhaustive]. A downstream crate cannot construct one directly, so adding a field is not a breaking change. #[non_exhaustive] is reserved for public enums that will gain variants over time (GenerateError, ConnectStep).
Any future rename lands in one release with CHANGELOG entries.
Why NullVal Instead of Option
An SBE enum may declare a nullValue in the schema — an explicit wire sentinel
that means “not present” / “not set”. When the schema doesn’t specify one, SBE
defaults to the encoding type’s null sentinel: the maximum value for unsigned
types (e.g. 255 for uint8) and the minimum value for signed types (e.g.
-128 for int8).
nullValue / minValue / maxValue on a type or field must fit the declared
primitive width. nullValue="256" on uint8 is a parse error — it would
otherwise collapse to 0 on the wire and make Some(0) indistinguishable from
None. See Error Diagnostics.
An early design tried wrapping every enum field in Option<EventCode> at the
field site:
// Option approach — REJECTED
pub fn event_code(&self) -> Option<EventCode> { … }
pub fn set_event_code(&mut self, val: Option<EventCode>) { … }
This was rejected for three reasons:
-
API complexity. Using
Option<EventCode>at every access point forces every consumer to.unwrap()or match, even when the field is known to be populated. TheNullValapproach gives you a plainEventCodetype — if you care about null, checkcode == EventCode::NullVal; if you don’t, just use it. (The wire encoding itself would be compatible either way:Nonemaps to the null sentinel,Some(v)maps tov. The issue is ergonomics, not wire format.) -
Generated code complexity. Every field site that uses
Option<EventCode>needs value↔Option mapping in both accessor directions, inflating the generated code for no wire-format gain. -
Schema intent. The schema declares a null sentinel as part of the enum’s own value domain, not as a separate presence flag. A
NullValvariant reflects that intent directly in the Rust type.
The chosen design adds a NullVal variant to every generated enum. It is the
same size as any other variant and wire-compatible with sbe-tool. Its runtime
cost is not measured here:
// ergo-sbe generated (conceptual)
pub enum EventCode {
NullVal = 255, // or schema-declared nullValue
Ok = 200,
Error = 400,
Timeout = 408,
}
For an Optional field (schema presence="optional"), the generated accessor
returns Option<EventCode> — but the null check compares against the NullVal
discriminant on the wire, never allocates, and is transparent to the caller.
On encode, fixed(&FixedFields) always writes every fixed field: Some(v)
writes the value and None writes the exact schema null wire image (including
nested optional composite members). Dirty buffer reuse is therefore safe when
you go through fixed. Prefer that path for whole-message encodes.
apply_nulls() remains on the unfixed encoder after
wrap_and_apply_header when you set individual optional fields piecemeal
instead of using FixedFields. See
Encode and Decode.
Opting into Option<T> with with_null_as_option
The NullVal is the right default, but some codebases prefer Option
throughout. Use with_null_as_option to make generated enum accessors
return Option<Enum> — NullVal maps to None, all other values to
Some(v). Wire bytes are identical either way.
use ergo_sbe::{ConversionSelector, GenerationConfig};
// Individual enum → Option<Enum>
let config = GenerationConfig::new("msgs")
.with_null_as_option(ConversionSelector::named_type("EventCode"));
// Every enum in the schema → Option<Enum>
let config = GenerationConfig::new("msgs")
.with_all_enums_as_option();
Generated diff (individual setter):
// Default (NullVal) // with_null_as_option
pub fn code(&self) -> EventCode { … } → pub fn code(&self) -> Option<EventCode> { … }
The as_option() method is also generated on every enum for manual use:
event_code.as_option() → Option<EventCode>.
Null-aware accessors on BooleanType
For BooleanType fields, ergon emits a {field}_bool() accessor alongside the
standard enum getter. One name at every shape and location, the same name as
the {field}_bool(v) encoder setter; it returns Result because the decode
is fallible:
// Standard getter — returns the enum variant (raw wire discriminant).
pub fn available(&self) -> BooleanType { … }
// Null-aware — rejects NullVal (returns Err); Ok(true/false) otherwise.
pub fn available_bool(&self) -> Result<bool, DecodeError> { … }
// A `sinceVersion > 0` field can also be absent, so its getter adds the
// `Option` layer — the only shape difference, at message and group level:
pub fn added_later_bool(&self) -> Result<Option<bool>, DecodeError> { … }
For enums and other types, the NullVal variant remains the default.
with_null_as_option (above) is the opt-in Option<T> mapping; the
wire encoding is identical either way.
Generated enum mappings
with_enum_mapping maps an SBE enum onto an application enum that has no
NullVal (see App Types).
There, NullVal is never turned into a variant: it decodes as
sbe_rt::MappingError::NullEnum, or as None when the enum is also under
with_null_as_option. Because the decoder folds an undeclared discriminant
into NullVal, an unknown value reaches the mapping as that same NullVal.
Feature Matrix
Scannable map of capabilities. Use the More links for samples and tests.
| Feature | What it does | How to use / more |
|---|---|---|
build.rs codegen | Compile-time schema → Rust module in OUT_DIR | generate_to_out_dir("schemas/….xml", config)? · plain include! or sbe_mod!(name) · Quick start · codegen examples |
| Wire compatibility | Same on-wire layout as official SBE | Dual encode ergo vs sbe-tool · sbe_tool_wire_parity_test · golden fixtures · Benchmarks · baseline_test |
| Flyweight decode | Zero-copy over &[u8] | CarDecoder::try_from(buf)?; car.serial_number() · feature-tour |
| Composite wire image | #[repr(transparent)] Engine([u8; N]) + LE accessors; flyweight default | Not a repr(C) overlay · Core ideas · composite_layout_test |
| Per-field vs whole struct | Flyweight or *FixedFields / *Domain | Single field: flyweight · always fill fixed block: .fixed(&CarFixedFields { … }) · whole message owned: CarDomain · Core ideas · feature-tour |
| Stage-struct encode + closures | Wire order as named monomorphic stages; groups via nested closures | bids(n, |g| g.add(|e| …))? · wrong order = missing method · Core ideas · Recipes · Benchmarks |
| Consuming decode stages | Distinct after-stage decoder types | into_bids(|lvl| …)? → into_asks(…)? → var-data · l3_consuming_stages_test |
| Decoder lanes | Random-access getters, staged into_*/skip_*, ordered callbacks, .memoized() | Any-order &Decoder, wire-order walk, or one callback per tail · decode stages |
| Staged group shape | Entries with their own tails take a visit closure returning the entry’s completion; fixed-stride entries yield an iterator | One pass either way — the completion is the next cursor, and fixed stride needs no measuring · Iterator only for &mut Iter, so for e in iter cannot drop the message tail · decode stages · l3_consuming_stages_test |
Ordered lane (decoder.ordered()) | Callbacks in wire order at message and nested-entry level as sbe_rt::Ordered<S>; EntryInfo { index, count, block_length } per entry | Façade over the staged stages — same single traversal, same compile-time tail order · message fixed takes a fixed-fields-only view · callbacks return DecodeError · nested ordered() returns the parent completion directly · message chain ends on encoded_length_with_header() · decode stages · ordered_lane_test |
| Non-advancing counts and lengths | <group>_count() / <field>_len() return Result<usize, DecodeError> without consuming a stage | Base decoder, .memoized(), the immediately preceding stage, and group entries · 0 for absent versioned tails · iter.remaining_entries() returns usize · an existing primary getter suppresses the colliding convenience accessor (same yield for entry acting_* and ordered fixed) · decode stages |
| Three-tier constructors | try_* → Result; bare wrap panics if short; bare decode panics if short and Err on wrong template/schema; unsafe *_unchecked | CarDecoder::try_decode(buf, 0)? · CarDecoder::verify(buf)? · demo_try_vs_trusted · Trust boundary |
| Placement metadata | Buffer utils on get_metadata() so field names remaining / buffer / limit / message_offset stay natural (no _field rename) | dec.get_metadata().remaining() · schema field remaining → dec.remaining() · Generated code · reserved_name_clash_test |
| Exact buffer sizing | Schema-aware length for nested/ragged msgs — no hand-calculated sizes | compute_length_with_header() (fixed) · compute_length_with_header(…) (flat) · *EncodedLength (nested) · Core ideas · l3-book · encoded_length_api_test |
| Schema docs → rustdoc | XML descriptions become item docs | description="…" / <description> / <comment> / <!-- --> · schema_docs_provenance_test |
Display / Debug | Diagnostic print (not wire format) | println!("{car}"); · Display / Debug · demo_display_debug |
| NULL / MIN / MAX | Schema sentinels as consts | MODEL_YEAR_NULL · baseline_test |
| Version-aware fields | sinceVersion / acting version | Option or skip on older wire · baseline_test · multi_schema_versioning_test |
| Groups / nested groups | Repeating dimensions | bids(n, |g| g.add(…))? · l3-book · l3_orderbook_test |
| Bulk group encode / decode | bulk_add(&[Entry]) / bulk_add_domain(&[EntryDomain]) / bulk_decode() -> Vec<Entry> for eligible flat groups | DTO re-encode selects the domain bulk path automatically when wire and domain fields match; measure against per-entry add() for your schema · group_encode_bench |
| Var-data / text | Length-prefix; optional UTF-8/ASCII | manufacturer_as_str("Honda")? when a characterEncoding is declared, else manufacturer(b"Honda")? · feature-tour |
| Fixed arrays + bulk helpers | Arrays, put, pad string, copy-out | put_some_numbers(…) · vehicle_code_str · copy_vehicle_code · java_parity_features_test |
| Enums / sets / bool | Wire enums, bitsets, _bool | available() / available_bool(true) · comprehensive_test |
with_conversion | Wire type → any app type you impl | price_from(&Cents)? / price_as::<Cents>()? · Configuration · exchange-example |
with_domain_type | Wire type → one fixed Rust path | enc.price(d)?; let d = dec.price()? · l3-book · Configuration |
| Generated enum / composite mappings | SBE enum → your enum, composite → your struct, with renames; exhaustive both ways; NullVal and unknown values are MappingError::NullEnum | .with_enum_mapping(EnumMapping::new("Side", "app::Side")) · .with_composite_mapping(CompositeMapping::new("Leg", "app::Leg")) · App Types · type_mapping_test |
| Source traits | Encode your own type, groups and var-data included, with no DTO and no allocation | .with_source_traits(true) · enc.encode_from(&order)? · Flyweight vs whole struct · source_trait_test |
| Send helpers | Claim → encode → check length and header → commit, aborting on any failure; try-claim or offer transports | OrderEncoder::send(claim, |enc| …)? · send_fixed · sbe_rt::OfferClaim · Aeron try_claim · claim_send_test |
| Domain DTOs | Owned structs + re-encode; allocation-free automatic bulk write for eligible flat groups; var-data via [DomainVarData] | .with_domain_objects(DomainVarData::Strings) · Domain DTOs · domain_objects_test |
AnyMessage + frames | Multi-template + framed streams | AnyMessage::try_decode (bare decode is the same path today) · FrameCursor · demo_any_message |
verify | Full tail bounds check (associated) | CarDecoder::verify(buf)? · demo_try_vs_trusted |
| Schema identity | Id / version / hashes | SCHEMA_ID, SCHEMA_HASH, SCHEMA_SHA256_HEX · generated module header |
| Multi-schema shared types | Dedup across packages | .with_shared_module + generate_multi · exchange-example · multi_schema_versioning_test |
| Keyword-safe names | type → type_ | .with_keyword_append_token("_") · java_parity_features_test |
| XSD-shaped validation | Opt-in stricter check for schema authors | validate_against_sbe_xsd / parse_with_xsd_validation · xsd.rs |
| Zero-alloc hot path | Flyweights + caller buffers; bulk_decode() -> Vec and DomainVarData::Strings do allocate | allocation_count_test |
| Property round-trip | Random messages encode→decode | cargo test -p ergo-sbe --test proptest_roundtrip · proptest_roundtrip |
NullVal → Option<T> | Enum NullVal mapped to Option; wire-identical | .with_null_as_option(…) / .with_all_enums_as_option() — dec.code() -> Option<EventCode> · DTO None writes NullVal; bulk encode uses the same mapping · NullVal design note · Domain DTOs |
| Domain var-data types | CompactString (≤24B inline), SmolStr (O(1) clone), bytes::Bytes (shared) | .with_domain_objects(DomainVarData::CompactStrings) — feature-gated: compact_str, smol_str, bytes · Feature integrations |
| Codec-level type accessors | into_<field>_as_compact_str() / _as_smol_str() / _as_bytes() on consuming stages | Feature-gated behind compact_str, smol_str, bytes · Feature integrations |
| Chrono timestamps | DateTime<Utc> / NaiveDateTime from uint64 + semanticType | .with_domain_type(ConversionSelector::semantic_type("UTCTimestamp"), "chrono::DateTime<chrono::Utc>") — feature-gated: chrono · Timestamps |
| Lean constructor | GenerationConfig::lean("minimal") — no Display/Debug/dispatch, explicit settings preserved | lean() shorthand for new().profile(Lean) · GenerationConfig |
| Must-use lifecycle | #[must_use] on SessionBuilder, AsyncClusterConnect, ClusterClaim | Compile-time guard against discarded builder/connect/claim · SessionBuilder |
| PayloadTooLarge | Typed error when header+payload exceeds Aeron max | ClusterError::PayloadTooLarge { operation, requested, maximum } · Cluster client |
| Zero-alloc offer_parts | Fragmented path uses offer_parts gather — no heap, no payload copy | Stack header + borrowed payload · Cluster client |
| checked_deadline | Fallible deadline: rejects zero/overflow | ClusterError::InvalidTimeout · Cluster client |
Benchmarks
The maintained benchmarks show ergo-sbe is not slower than official sbe-tool
on equal work. They are not a speed claim. Method, fairness rules, ceilings
and the gate:
sbe/BENCHMARKS.md.
| Question | Answer |
|---|---|
| Run it | just bench (SBE), just bench-cluster (cluster) |
| What is graded | ergon / sbe-tool, Criterion’s mean, highest ratio over 5 repeats, both LTO profiles |
| Pass | Highest ratio ≤ ceiling (1.00 for most pairs) |
| Inconclusive (also passes) | Above the ceiling, but its confidence range overlaps the A/A control’s noise band |
| Fail | Above the ceiling and outside the A/A band, or no A/A band measured |
| Tolerance | none (zero tolerance): no slack is added to a ceiling |
| Where results go | target/bench-runs/<run-id>/ (just bench); target/criterion and target/bench-no-lto/criterion (just bench-cluster, overwritten) |
This book records no benchmark result. Quote a ratio with its run id, commit, host, rustc and profile, or do not quote it.
Build your own code with lto = true and codegen-units = 1. Generated
hot-path methods carry #[inline], so a non-LTO build can inline them too.
Also compare against your own previous release: see Methodology.
Benchmark Methodology
The fairness rules and the gate are in
sbe/BENCHMARKS.md;
how a pair is built, its observation barriers and the A/A control in
sbe/benchmarks/methodology.md.
Two rules to keep in mind:
- Treat a surprising ratio as a benchmark defect until wire parity, equal work, optimizer opacity, amplified timing, both LTO profiles and instruction counts agree.
- Compare against your own previous release as well as against sbe-tool. Two releases can both pass the sbe-tool ceiling while the second is slower.
Gate summary: Benchmarks.
Teaching Path
Standalone crates that exercise repository APIs. They are excluded from the
workspace, set publish = false, and are not production reference
implementations — they move with experimental APIs on purpose.
Start here (product teaching path)
| Step | Sample | Why |
|---|---|---|
| 1 | SBE Feature Tour | Golden path. Full feature map: stages, EncodedLength, checked constructors + verify, Display, DTO with DomainVarData::Strings, all three conversion styles |
| 2a | L3 Order Book | Nested/ragged books; with_domain_type only; build-dep only (#[path] into src/generated/) |
| 2b | Exchange Example | Multi-schema; with_conversion only; IPC + app TryFromSbe |
| 3 | Codegen as Library | Generator as a library (no build.rs) |
| Later | Cluster Tutorial | Connect, offer, poll, keep-alive, close |
| Later | Cluster HA Orderbook | Claim-based Cluster publishing + HA-shaped book |
| Later | Cluster RFQ | RFQ / auction codecs over Cluster |
# 1 — always start here
cargo run --manifest-path samples/sbe-feature-tour/Cargo.toml
# 2 — pick the conversion style you want in product code
cargo run --manifest-path samples/l3-book/Cargo.toml
cargo test --manifest-path samples/exchange-example/Cargo.toml
Rule of thumb: one conversion style per schema type
(with_domain_type or with_conversion, not both for the same selector).
Conversion: which sample uses what
| Sample | Config | Decode / encode surface |
|---|---|---|
| L3 Order Book | with_domain_type only | dec.price()? → Decimal; enc.price(d)? |
| Exchange Example | with_conversion only | dec.price_as::<T>()?; enc.price_from(&t)? (+ app TryFromSbe) |
| SBE Feature Tour | All three (different selectors) | bool/timestamp concrete (Generated); Decimal generic (demo_conversion_only); ManualDecimal concrete + app impl (demo_domain_type_manual_impl) |
Rule: one style per selector. with_domain_type already enables conversion;
do not stack with_conversion on the same selector.
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// A — generic converter: one wire type, many app types
let _cfg =
GenerationConfig::new("msgs").with_conversion(ConversionSelector::named_type("Decimal"));
}
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// B — concrete mapping: one Rust type per wire type (already enables conversion)
let _cfg = GenerationConfig::new("msgs").with_domain_type(
ConversionSelector::named_type("Decimal"),
"rust_decimal::Decimal",
);
}
Rules
- Keep every sample outside the workspace and unpublished.
- Do not expose sample-only abstractions as product APIs.
- Size SBE buffers from generated encoded-length APIs (prefer stack when const).
- Propagate fallible operations with
Resultand?. - Delete a sample when it no longer exercises a distinct repository behavior.
SBE Feature Tour
Standalone laboratory sample for ergo-sbe (publish = false). This is the
crates.io / docs.rs teaching entry.
Conversion: three styles in one crate
build.rs uses different APIs for different selectors:
#![allow(unused)]
fn main() {
let generated_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/generated");
let config = ergo_sbe::GenerationConfig::new("feature_tour")
.with_domain_objects(ergo_sbe::DomainVarData::Strings)
.with_domain_type(
ergo_sbe::ConversionSelector::named_type("BooleanType"),
"bool",
)
.with_domain_type(
ergo_sbe::ConversionSelector::semantic_type("UTCTimestamp"),
"chrono::DateTime<chrono::Utc>",
)
.with_conversion(ergo_sbe::ConversionSelector::named_type("Decimal"))
// Same shape as Decimal, but the app supplies the impl itself — see
// demo_domain_type_manual_impl in src/lib.rs.
.with_manual_domain_type(
ergo_sbe::ConversionSelector::named_type("ManualDecimal"),
"rust_decimal::Decimal",
);
ergo_sbe::generate_to_dir("schemas/feature-tour.xml", config, &generated_dir)?;
}
(The real build.rs — this code is compiled and tested in CI.)
The generated code is included via #[path = "generated/feature_tour.rs"] —
no sbe_mod! needed. See Build Patterns.
| Selector | Config | Decode API | Encode API | Who writes the impl? |
|---|---|---|---|---|
BooleanType | with_domain_type(.., "bool") | dec.available()? | enc.available(true)? | ergo-sbe |
UTCTimestamp | with_domain_type(.., chrono) | dec.timestamp()? | enc.timestamp(t)? | ergo-sbe |
Decimal (Quote) | with_conversion only | dec.price_as::<T>()? | enc.price_from(&t)? | app (generic, any T) |
ManualDecimal (Quote) | with_manual_domain_type(.., rust_decimal) | dec.manual_price()? | enc.manual_price(v)? | app (one concrete type) |
Runnable proof for the Decimal row: demo_conversion_only in
src/lib.rs (uses both rust_decimal and a tiny FixedPrice
adapter on the same buffer). Runnable proof for the ManualDecimal row:
demo_domain_type_manual_impl — same concrete manual_price(...)?
signature DomainImpl::Generated would give you, but the impl TryFromSbe<ManualDecimal> / TryToSbe<ManualDecimal> above it are a literal
copy-paste of the doc comment ergo-sbe put on the generated method (see
with_conversion vs with_domain_type).
Quick rule
- One fixed app type, ergo-sbe writes the impl →
with_domain_type(selector, path) - One fixed app type, you write the impl (custom rounding/validation, or
overriding the three built-ins) →
with_manual_domain_type(selector, path) - Pluggable / no forced dep →
with_conversion - Never call more than one of these for the same selector
Other samples:
| Sample | Style |
|---|---|
| L3 Order Book | with_domain_type only |
| Exchange Example | with_conversion only |
Feature map → demo
| Feature | Demo |
|---|---|
Fixed message + compute_length_with_header() | demo_fixed_heartbeat |
Staged CarEncodedLength | demo_car_size_and_encode |
| Consuming decoder stages | demo_car_decode_stages |
| Owned DTO | demo_car_domain_dto |
AnyMessage | demo_any_message |
bulk_add (fixed-stride leaf group) | demo_bulk_add |
| Checked decode / wrap / verify | demo_try_vs_trusted |
| Display / Debug | demo_display_debug |
with_conversion only | demo_conversion_only |
with_manual_domain_type | demo_domain_type_manual_impl |
| All of the above | run_all |
Run
cargo run --manifest-path samples/sbe-feature-tour/Cargo.toml
cargo test --manifest-path samples/sbe-feature-tour/Cargo.toml
After build, generated source is under src/generated/feature_tour.rs.
L3 Order Book
Deep nested / ragged L3 order-book sample for ergo-sbe. publish = false.
Conversion style: with_domain_type only
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// B — concrete mapping: one Rust type per wire type (already enables conversion)
let _cfg = GenerationConfig::new("msgs").with_domain_type(
ConversionSelector::named_type("Decimal"),
"rust_decimal::Decimal",
);
}
(From book/examples/conversion-config.rs. Full L3 build.rs: l3-book.)
Generated API (concrete):
enc.price(rust_decimal::Decimal::new(100, 0))?;
let p: rust_decimal::Decimal = dec.price()?;
let ts: DateTime<Utc> = dec.exchange_timestamp()?;
with_domain_type already enables conversion for that selector. Do not also
call with_conversion(Decimal) here — it would not change the surface.
| Need | Use |
|---|---|
Concrete price() -> Decimal | This sample (with_domain_type) |
Generic price_as::<T>() / app adapters | Exchange Example (with_conversion) |
| Side-by-side both styles | SBE Feature Tour |
Encoding: size exactly, then write
Nothing here guesses a buffer size. The staged L3BookEncodedLength builder
walks the same shape the encoder will write — bids_ragged with a nested
orders count per level, then asks_ragged, then the var-data symbol —
and returns the exact header-inclusive length:
pub fn book_encoded_length(
bids: &Side<'_>,
asks: &Side<'_>,
symbol: &str,
) -> Result<usize, sbe_rt::EncodeError> {
let after_bids = L3BookEncoder::compute_length().bids_ragged(bids.len() as u16, |g| {
for (_, _, orders) in bids {
g.add()?.orders(|og| {
og.uniform(orders.len())?;
Ok(())
})?;
}
Ok(())
})?;
let after_asks = after_bids.asks_ragged(asks.len() as u16, |g| {
for (_, _, orders) in asks {
g.add()?.orders(|og| {
og.uniform(orders.len())?;
Ok(())
})?;
}
Ok(())
})?;
let complete = after_asks.symbol(symbol.len())?;
Ok(complete.encoded_length_with_header())
}
_ragged is the point for an order book: every level has a different number
of orders, so the count cannot be multiplied out. g.add()?.orders(|og| …)
declares one level and then that level’s own order count.
The encoder then writes into a buffer of precisely that length, in wire order, one chained expression:
pub fn encode_book(
buf: &mut [u8],
bids: &Side<'_>,
asks: &Side<'_>,
symbol: &str,
) -> Result<usize, sbe_rt::EncodeError> {
let complete = L3BookEncoder::try_wrap_and_apply_header(buf, 0)?
.fixed(&L3BookFixedFields {
exchange_timestamp: 1_720_000_000_000_000_000u64,
sequence: 42,
is_active: true.into(),
})
.bids(bids.len() as u16, |g| {
for (price, size, orders) in bids {
g.add(|mut e| {
e.price(*price)
.map_err(|_| sbe_rt::EncodeError::DomainConversionFailed {
field: "price",
reason: "conversion",
})?;
e.size(*size)
.map_err(|_| sbe_rt::EncodeError::DomainConversionFailed {
field: "size",
reason: "conversion",
})?;
e.orders(orders.len() as u16, |og| {
for (oid, qty) in *orders {
let raw_qty =
SbeDecimal::new(qty.mantissa() as i64, -(qty.scale() as i8));
og.add_struct(&L3BookBidsOrdersEntry {
order_id: *oid,
quantity: raw_qty,
})?;
}
Ok(())
})
})?;
}
Ok(())
})?
.asks(asks.len() as u16, |g| {
for (price, size, orders) in asks {
g.add(|mut e| {
e.price(*price)
.map_err(|_| sbe_rt::EncodeError::DomainConversionFailed {
field: "price",
reason: "conversion",
})?;
e.size(*size)
.map_err(|_| sbe_rt::EncodeError::DomainConversionFailed {
field: "size",
reason: "conversion",
})?;
e.orders(orders.len() as u16, |og| {
for (oid, qty) in *orders {
let raw_qty =
SbeDecimal::new(qty.mantissa() as i64, -(qty.scale() as i8));
og.add_struct(&L3BookAsksOrdersEntry {
order_id: *oid,
quantity: raw_qty,
})?;
}
Ok(())
})
})?;
}
Ok(())
})?
.symbol_as_str(symbol)?;
Ok(complete.encoded_length_with_header())
}
Both functions end with encoded_length_with_header(), and the tests assert
the two agree — a length that does not match the bytes written is a bug in the
sizing API, not something to paper over with a larger buffer.
symbol declares characterEncoding="ASCII" in the schema, so the generated
encoder carries a symbol_as_str(&str) setter alongside the raw
symbol(&[u8]) one. Every caller here always holds text, so encode_book
takes symbol: &str and writes through symbol_as_str — the ASCII check
runs before any byte is written rather than leaving it to the caller. Both
setters write identical bytes for the same text; reach for the raw
symbol(&[u8]) setter only when the caller genuinely starts from bytes.
let len = book_encoded_length(bids, asks, symbol)?; // exact, header-inclusive
let mut storage = vec![0u8; len]; // no oversize, no truncate
let actual = encode_book(&mut storage, bids, asks, symbol)?;
assert_eq!(len, actual);
See Exact sizing and Buffer sizing.
Decoding: random access or one staged chain
L3Book is the shape that makes the decoder lanes
worth distinguishing: two sibling groups, each entry carrying a nested
orders group, then a trailing var-data symbol. Reaching symbol means
walking past every order of every level.
tests/l3_tests.rs encodes one ragged fixture and decodes it through the
generated lanes, asserting they produce an identical snapshot. Note what the snapshot holds:
the schema’s domain types (rust_decimal::Decimal, DateTime<Utc>,
bool) because that is what with_domain_type generates, and a borrowed
&'a str for symbol — copying it would defeat the flyweight.
/// Everything the fixture contains, in wire order — in the schema's *domain*
/// types, not its wire types.
///
/// This sample generates with `with_domain_type`, so `price` is
/// `rust_decimal::Decimal`, `exchangeTimestamp` is `DateTime<Utc>` and
/// `isActive` is `bool`. Comparing at that level is what proves the
/// conversions round-trip; comparing raw mantissas would pass even if the
/// domain layer were broken.
///
/// `symbol` is borrowed, not owned: every lane hands back `&'a str` pointing
/// into the wire buffer. Copying it would defeat the flyweight. The `Vec`s
/// are the *test's* comparison scaffolding — a decode has to materialise
/// something to compare — not part of the decode path, which allocates
/// nothing.
#[derive(Debug, PartialEq, Eq)]
struct Snapshot<'a> {
timestamp: chrono::DateTime<chrono::Utc>,
sequence: u64,
is_active: bool,
/// `(price, size, [(order_id, quantity)])` per level.
bids: Vec<OwnedLevel>,
asks: Vec<OwnedLevel>,
symbol: &'a str,
}
/// A decoded level, owned so lanes can be compared. The decode itself borrows
/// — see `best_bid_and_depth` for the shape a hot path actually uses.
type OwnedLevel = (Rd, Rd, Vec<(u64, Rd)>);
/// Ragged on purpose: 3 bid levels with 2/1/3 orders and 2 ask levels with
/// 1/2. A uniform fixture would let a lane compute entry positions by
/// multiplication and still look correct.
fn fixture() -> (Vec<u8>, Vec<OwnedLevel>, Vec<OwnedLevel>) {
let o_b1 = [(101u64, d(5)), (102, d(7))];
let o_b2 = [(103u64, d(25))];
let o_b3 = [(104u64, d(1)), (105, d(2)), (106, d(3))];
let bids: &[l3_book::Level<'_>] = &[
(d(50800), d(15), &o_b1),
(d(50750), d(40), &o_b2),
(d(50700), d(60), &o_b3),
];
let o_a1 = [(201u64, d(10))];
let o_a2 = [(202u64, d(20)), (203, d(30))];
let asks: &[l3_book::Level<'_>] = &[(d(50850), d(20), &o_a1), (d(50900), d(35), &o_a2)];
let symbol = "BTCUSDT";
// Exact sizing through the staged builder, then encode into a buffer of
// precisely that length: no oversize, no truncate.
let len = l3_book::book_encoded_length(bids, asks, symbol).expect("length");
let mut storage = vec![0u8; len];
let actual = l3_book::encode_book(&mut storage, bids, asks, symbol).expect("encode");
assert_eq!(len, actual, "computed length must match encoded length");
let own = |lv: &(Rd, Rd, &[(u64, Rd)])| (lv.0, lv.1, lv.2.to_vec());
(
storage,
bids.iter().map(own).collect(),
asks.iter().map(own).collect(),
)
}
Random access — any order, Sync, re-walks
/// Lane 1 — random access (the base decoder from `try_decode`).
///
/// Getters may be called in any order and the decoder is `Sync`. Each
/// dynamic-tail getter re-walks from the fixed block, so reading `bids`,
/// `asks` and `symbol` walks the bid orders three times over. Correct
/// everywhere, cheapest when you want one or two fields.
fn decode_random_access(wire: &[u8]) -> Result<Snapshot<'_>, Box<dyn std::error::Error>> {
let dec = L3BookDecoder::try_decode(wire, 0)?;
// Deliberately out of wire order: symbol (the last tail) first, then back
// to the groups. Only a lane that resolves tails independently can do
// this. The `&str` borrows the wire buffer — nothing is copied.
let symbol = dec.symbol_as_str()?;
let mut bids = Vec::new();
for level in dec.bids()? {
let level = level?;
let mut orders = Vec::new();
// `orders` is fixed-stride, so its whole region was proven in bounds
// when the group was entered and the iterator yields entries directly.
// The outer `bids`/`asks` iterators yield `Result` because a dynamic
// entry's extent can only be known once it is reached.
for order in level.orders()? {
orders.push((order.order_id(), order.quantity()?));
}
bids.push((level.price()?, level.size()?, orders));
}
let mut asks = Vec::new();
for level in dec.asks()? {
let level = level?;
let mut orders = Vec::new();
for order in level.orders()? {
orders.push((order.order_id(), order.quantity()?));
}
asks.push((level.price()?, level.size()?, orders));
}
Ok(Snapshot {
timestamp: dec.exchange_timestamp()?,
sequence: dec.sequence(),
is_active: dec.is_active()?,
bids,
asks,
symbol,
})
}
Note it reads symbol — the last tail — first, then goes back to the
groups. Only a lane that resolves each tail independently can do that. The
cost is that every dynamic-tail getter re-walks from the fixed block.
Staged — wrong order is a compile error
/// Sequential decode — `into_*` / `skip_*`, compile-time order. The group
/// iterator is the stage: `into_asks` skips unread bids.
fn decode_staged(wire: &[u8]) -> Result<Snapshot<'_>, Box<dyn std::error::Error>> {
let dec = L3BookDecoder::try_decode(wire, 0)?;
let timestamp = dec.exchange_timestamp()?;
let sequence = dec.sequence();
let is_active = dec.is_active()?;
// Levels carry a nested `orders` group, so they have no stride and take a
// visit closure — the completion it returns is where the next level starts.
// `orders` entries are fixed-stride, so they are a plain iterator.
let mut bids = Vec::new();
let mut asks = Vec::new();
let (symbol, _complete) = dec
.into_bids(|level| -> Result<_, Box<dyn std::error::Error>> {
let price = level.price()?;
let size = level.size()?;
let mut orders = Vec::new();
let mut order_iter = level.into_orders()?;
for order in &mut order_iter {
orders.push((order.order_id(), order.quantity()?));
}
bids.push((price, size, orders));
Ok(order_iter.finish()?)
})?
.into_asks(|level| -> Result<_, Box<dyn std::error::Error>> {
let price = level.price()?;
let size = level.size()?;
let mut orders = Vec::new();
let mut order_iter = level.into_orders()?;
for order in &mut order_iter {
orders.push((order.order_id(), order.quantity()?));
}
asks.push((price, size, orders));
Ok(order_iter.finish()?)
})?
.into_symbol_as_str()?;
Ok(Snapshot {
timestamp,
sequence,
is_active,
bids,
asks,
symbol,
})
}
into_symbol exists only on the stage reached after asks completes, so the
out-of-order read above is not expressible here. One wire-order pass.
Memoized — any order, each boundary walked once
/// Lane 3 — memoized (`decoder.memoized()`).
///
/// Same getter names as the base lane, plus a progressive cache of discovered
/// tail boundaries. Read here in the worst order for the base lane — symbol
/// first, then back to the groups, then symbol again — which is exactly the
/// shape the cache exists for.
fn decode_memoized(wire: &[u8]) -> Result<Snapshot<'_>, Box<dyn std::error::Error>> {
let dec = L3BookDecoder::try_decode(wire, 0)?.memoized();
let symbol = dec.symbol_as_str()?;
let mut bids = Vec::new();
for level in dec.bids()? {
let level = level?;
let mut orders = Vec::new();
for order in level.orders()? {
orders.push((order.order_id(), order.quantity()?));
}
bids.push((level.price()?, level.size()?, orders));
}
let mut asks = Vec::new();
for level in dec.asks()? {
let level = level?;
let mut orders = Vec::new();
for order in level.orders()? {
orders.push((order.order_id(), order.quantity()?));
}
asks.push((level.price()?, level.size()?, orders));
}
// Re-reading a tail is free once its boundary is known: the base lane
// would re-walk every bid and ask order to get back here.
assert_eq!(dec.symbol_as_str()?, symbol, "re-read must be identical");
Ok(Snapshot {
timestamp: dec.exchange_timestamp()?,
sequence: dec.sequence(),
is_active: dec.is_active()?,
bids,
asks,
symbol,
})
}
Same getter names as random access. Read in the order shown — symbol, then
back to the groups, then symbol again — the base lane re-walks every bid and
ask order to return to symbol; this lane reuses the boundary it already
found. Build it once and pass &L3BookMemoizedDecoder around: calling
.memoized() in each function creates a separate empty cache.
Ordered — one spelling, all the way down
/// Lane 4 — ordered (`decoder.ordered()`). One callback per tail, in wire
/// order, with the *same spelling* at every tail, plus an `EntryInfo` giving
/// each entry's position.
///
/// Compare with `decode_staged`: there, `bids` takes a visit closure and
/// `orders` is an iterator, because their shapes genuinely differ. Here every
/// message-level tail — fixed block, both groups, and the var-data — reads the
/// same way. That uniformity is why this lane exists: code that walks whole
/// messages writes one form instead of branching on each tail's shape.
///
/// It goes all the way down. A `bids` level carries its own tail (the nested
/// `orders` group), so it has an `ordered()` of its own, and the spelling does
/// not change at the entry boundary. Returning that nested walk is the
/// completion the parent owes — no `.done()` on the way out. The message
/// chain ends on `encoded_length_with_header()`, like encode.
///
/// The staged `level.into_orders()` iterator is still generated beside it and
/// is still the better tool when you want to `break` early — `orders` is
/// fixed-stride, so its iterator is an `ExactSizeIterator` and stopping short
/// still reaches the next tail by arithmetic. The lane adds a spelling; it
/// removes nothing.
///
/// `EntryInfo::count` is the wire-declared `numInGroup`, read from the group's
/// dimension header before the walk starts, so the `Vec` is sized once rather
/// than grown. It costs no scan. `bids` and `asks` still need separate bodies
/// because their entry decoders are distinct types — the lane unifies how a
/// tail is *reached*, not what its entries are.
fn decode_ordered(wire: &[u8]) -> Result<Snapshot<'_>, Box<dyn std::error::Error>> {
// Ordered callbacks return `DecodeError`; a domain getter fails with its
// conversion's own error, mapped the way the generated DTO maps it.
fn domain<T, E>(field: &'static str, r: Result<T, E>) -> Result<T, sbe_rt::DecodeError> {
r.map_err(|_| sbe_rt::DecodeError::DomainConversionFailed {
field,
reason: "domain conversion rejected wire value",
})
}
let mut timestamp = None;
let mut sequence = 0u64;
let mut is_active = false;
let mut bids: Vec<OwnedLevel> = Vec::new();
let mut asks: Vec<OwnedLevel> = Vec::new();
let mut symbol: Option<&str> = None;
let len = L3BookDecoder::try_decode(wire, 0)?
.ordered()
.fixed(|d| {
timestamp = Some(domain("exchange_timestamp", d.exchange_timestamp())?);
sequence = d.sequence();
is_active = domain("is_active", d.is_active())?;
Ok(())
})?
.bids(|level, info| {
if info.is_first() {
bids.reserve_exact(info.count);
}
let price = domain("price", level.price())?;
let size = domain("size", level.size())?;
// Nested `ordered()` returns the completion the parent owes —
// no `.done()` on the way out.
let mut orders = Vec::new();
let complete = level.ordered().orders(|order, _oinfo| {
orders.push((order.order_id(), domain("quantity", order.quantity())?));
Ok(())
})?;
bids.push((price, size, orders));
Ok(complete)
})?
.asks(|level, info| {
if info.is_first() {
asks.reserve_exact(info.count);
}
let price = domain("price", level.price())?;
let size = domain("size", level.size())?;
let mut orders = Vec::new();
let complete = level.ordered().orders(|order, _oinfo| {
orders.push((order.order_id(), domain("quantity", order.quantity())?));
Ok(())
})?;
asks.push((price, size, orders));
Ok(complete)
})?
.symbol_as_str(|s| {
symbol = Some(s);
Ok(())
})?
.encoded_length_with_header();
debug_assert_eq!(len, wire.len());
Ok(Snapshot {
timestamp: timestamp.ok_or("fixed callback must run")?,
sequence,
is_active,
bids,
asks,
symbol: symbol.ok_or("symbol callback must run")?,
})
}
Compare it with the staged lane above: there bids takes a visit closure and
orders is an iterator, because those two shapes genuinely differ. Here every
message-level tail — fixed block, both groups, then the var-data — reads the
same way, and each entry callback also receives an EntryInfo carrying
index, the wire-declared count, and the acting block_length. Count and
block length come from the group decoder the staged walk already opened; the
index advances with the walk. Using info.count to reserve space needs no
entry scan. Empty groups never deliver EntryInfo; <group>_count() on the
ordered stage still reports the declared count.
It recurses. A bids level carries its own tail — the nested orders
group — so the level decoder has an ordered() too, and the callback above
uses it. The spelling does not change at the entry boundary, and returning
that nested walk is the completion the parent closure owes — no .done() on
the way out. An entry
with no tails of its own (a fixed-stride entry) has nothing to order and gets
no lane. An entry whose existing getter is named ordered also keeps its
getter and uses the staged spelling for its tails.
The staged spelling is still generated beside it: level.into_orders() hands
back a real ExactSizeIterator you can break out of and still reach the next
tail by arithmetic. Pick the iterator when you want to stop early and continue
at the next tail, or the ordered lane when you want callbacks throughout.
It is a façade over the staged stages, so it keeps their single traversal and
compile-time tail order. The message chain ends on encoded_length_with_header(),
like encode. Dynamic
groups fill EntryInfo from that walk; fixed-stride groups reuse the
iterator’s count and stride. Neither shape pre-scans entries or re-opens the
dimension header. The optimizer and measured workload determine the callback
wrapper’s final cost. Message-level fixed receives a fixed-fields-only view.
Callbacks return DecodeError; convert to an application error after the walk.
On a hot path, collect nothing
The four functions above build owned Vecs because a test has to materialise
something to compare. Real consumption does not: the decoders are flyweights
over the wire buffer, &str and &[u8] borrow from it, and a full nested walk
needs no allocation at all.
/// The shape you actually want on a hot path: no `Vec`, no `String`, no copy.
///
/// The lane functions above build owned collections because a test has to
/// materialise something to compare. Real consumption does not. Here every
/// level and every nested order is visited, `symbol` is used as a borrowed
/// `&str`, and the only state is a handful of scalars in registers — the
/// generated decoders are flyweights over the wire buffer and allocate
/// nothing, so this whole walk is allocation-free.
///
/// A price is compared as a price, not as a raw mantissa: the wire `Decimal`
/// carries its own exponent, and `1.00` (mantissa 100, exponent −2) is less
/// than `99` (mantissa 99, exponent 0). The fallible domain conversion is the
/// one place that knows how to align the two.
fn best_bid_and_depth(wire: &[u8]) -> Result<(Rd, u64, usize, &str), sbe_rt::DecodeError> {
let mut best_bid: Option<Rd> = None;
let mut total_orders = 0u64;
let mut levels = 0usize;
let (symbol, _done) = L3BookDecoder::try_decode(wire, 0)?
.into_bids(|level| -> Result<_, sbe_rt::DecodeError> {
levels += 1;
let px = level
.price()
.map_err(|reason| sbe_rt::DecodeError::DomainConversionFailed {
field: "price",
reason,
})?;
if best_bid.is_none_or(|b| px > b) {
best_bid = Some(px);
}
let mut orders = level.into_orders()?;
// Fixed stride, so the count is exact without walking.
let entries = &mut orders;
total_orders += entries.len() as u64;
orders.finish()
})?
.skip_asks()?
.into_symbol_as_str()?;
Ok((
best_bid.ok_or(sbe_rt::DecodeError::DomainConversionFailed {
field: "bids",
reason: "no bid levels",
})?,
total_orders,
levels,
symbol,
))
}
hot_path_walk_borrows_everything asserts the returned &str points inside
the wire buffer rather than at a copy. Repository-wide, allocation_count_test
pins the same property for generated decode under a counting allocator.
Which one
| You are doing | Lane |
|---|---|
| Reading a couple of fields, or one tail | random access — smallest, Sync |
| Decoding the whole book in wire order | staged into_* — one traversal, compile-time tail order |
| Several helpers reading multiple tails, same thread | .memoized() |
| Walking whole messages with uniform code, or needing entry position | .ordered() — one callback per tail, EntryInfo per entry |
Full comparison, including how each differs from sbe-tool’s single limit
cursor: Decoder lanes.
Layout
| Path | Role |
|---|---|
schemas/l3-book.xml | Nested bids/asks, orders, var-data tails |
build.rs | generate_to_dir into src/generated/ + domain objects / with_domain_type (build-dep only) |
src/lib.rs | #[path = "generated/l3_codec.rs"] + EncodedLength helpers |
src/main.rs | Runnable demos |
tests/l3_tests.rs | Round-trips, exact-length proofs, and the four-lane decode comparison |
Run
cargo run --manifest-path samples/l3-book/Cargo.toml
cargo test --manifest-path samples/l3-book/Cargo.toml
Exchange Example
Multi-schema exchange-shaped sample. with_conversion only for Decimal —
no with_domain_type. publish = false.
Conversion style
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// A — generic converter: one wire type, many app types
let _cfg =
GenerationConfig::new("msgs").with_conversion(ConversionSelector::named_type("Decimal"));
}
(From book/examples/conversion-config.rs. Full multi-schema build.rs: exchange-example.)
That emits generic methods. The app supplies TryFromSbe / TryToSbe:
// encode: app Decimal → wire
e.price_from(&d)?;
// decode: wire → app Decimal
let d: rust_decimal::Decimal = level.price_as()?;
You still have wire accessors (price_value / price_wire). You do not
get a concrete price() -> rust_decimal::Decimal — that requires
with_domain_type(..., "rust_decimal::Decimal") (see L3 Order Book).
| Sample | Config |
|---|---|
| This crate | with_conversion only |
| L3 Order Book | with_domain_type only |
| SBE Feature Tour | both (demo_conversion_only is conversion-only) |
Run
cargo test --manifest-path samples/exchange-example/Cargo.toml
Codegen as Library
Generator-API examples — how to call ergo-sbe as a library to generate and
inspect codecs programmatically (without a build.rs).
The other samples (l3-book, sbe-feature-tour, exchange-example) all use
build.rs to generate codecs at compile time and then exercise them
end-to-end. These examples complement them by showing the generator API
itself — useful for tooling, golden-file workflows, and understanding what the
generator produces.
Examples
# From the repository root — this crate is not a workspace member.
cargo run --manifest-path samples/sbe-codegen-examples/Cargo.toml --example flyweight
cargo run --manifest-path samples/sbe-codegen-examples/Cargo.toml --example domain_objects
cargo run --manifest-path samples/sbe-codegen-examples/Cargo.toml --example l3_nested
cargo run --manifest-path samples/sbe-codegen-examples/Cargo.toml --example dump_gen
What each shows
| Example | Demonstrates |
|---|---|
flyweight | Generator::generate() with default config — zero-copy flyweights |
domain_objects | with_domain_objects(DomainVarData::Strings) — owned DTOs (String var-data) + try_from_decoder |
l3_nested | The full type graph for 3-level nested groups (L1→L2→L3 entry types) |
dump_gen | The complete generated Rust source for inspection |
All examples parse the canonical car schema.
Cluster Tutorial
End-to-end walkthrough: launch a test cluster, connect a session, offer messages, poll events, send keep-alives, and close. The best starting point for the ergo-aeron-cluster client lifecycle.
Source: samples/cluster-tutorial/src/main.rs
See also: Cluster Client → Overview
just build-aeron-jars
cargo run --manifest-path samples/cluster-tutorial/Cargo.toml
Requires a JDK 25 (17 or newer) and built Aeron artifacts; see Java for Archive and Cluster.
Cluster HA Orderbook
Claim-based Cluster publishing with an HA-shaped limit order book. Proves
try_claim patterns and never-stale book snapshots under leader transitions.
- Offline pipeline (
ha_offline_pipeline) — claim + encode + verify without a live cluster. - Kill-leader test (
ha_kill_leader) — validates book integrity across leader changes (requires Java harness).
Source: samples/cluster-ha-orderbook/src/
# Service-free
cargo test --manifest-path samples/cluster-ha-orderbook/Cargo.toml \
--lib --test ha_offline_pipeline
# Full harness (needs Java)
just build-aeron-jars
cargo test --manifest-path samples/cluster-ha-orderbook/Cargo.toml \
--features test-harness
Cluster RFQ
RFQ / auction protocol codecs with cluster-backed examples. Demonstrates e-sniping timer, auction state machine, and multi-participant message flows over Aeron Cluster.
rfq_client— sends RFQ, receives quotes, places order.auction_client— auction lifecycle with e-sniping.rfq_roundtrip— encode/decode parity for all RFQ message types.
Source: samples/cluster-rfq/
cargo build --manifest-path samples/cluster-rfq/Cargo.toml --examples
cargo run --manifest-path samples/cluster-rfq/Cargo.toml --example rfq_roundtrip
Build Patterns
Generated codecs ship their own embedded sbe_rt module. Linking the app
does not require ergo-sbe unless you use its macros or call the generator
library at runtime.
| Pattern | build-dependencies | dependencies | Typical use |
|---|---|---|---|
| Build only (samples default) | ergo-sbe | — | generate_to_dir → src/generated/ (gitignored, unpublished) + #[path = "generated/….rs"] |
| OUT_DIR only | ergo-sbe | — | generate_to_out_dir + include!(concat!(env!("OUT_DIR"), …)) — the product shape without macros |
| Build + runtime (product default) | ergo-sbe | ergo-sbe | generate_to_out_dir + sbe_mod! (expands to that include!) — what cluster/build.rs and the introduction do |
| Runtime only | — | ergo-sbe | Call parse / Generator as a library (no build.rs) |
Seeing generated code (without committing it)
include!(concat!(env!("OUT_DIR"), …)) and sbe_mod! put files under a
hashed path like target/debug/build/<crate>-<hash>/out/….rs, which is
awkward to open by hand.
Samples instead write to a stable, local path:
samples/<name>/src/generated/*.rs # created on cargo build, gitignored
cargo build --manifest-path samples/sbe-feature-tour/Cargo.toml- Open
samples/sbe-feature-tour/src/generated/feature_tour.rs - From app code, Go to definition on
CarEncoder/ etc. should land there
Root .gitignore has **/src/generated/. Do not commit those trees
(Binance alone is multi‑MB). Rebuild after a clean clone.
// build.rs
let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/generated");
ergo_sbe::generate_to_dir("schemas/messages.xml", config, &out)?;
// src/lib.rs — real path → IDE go-to-definition works
#[allow(dead_code, unused_imports, non_camel_case_types, non_snake_case, clippy::all, warnings)]
#[path = "generated/messages.rs"]
mod messages;
Buffer Sizing Guide
Size buffers with the generated length APIs (compute_length_with_header(),
the staged *EncodedLength builders). A MAX_ENCODED_LENGTH stack array is
fine when that constant is a true fixed upper bound — check first: it counts
one entry per group and only var-data that declares maxLength, so multi-entry
groups need the staged builder. Avoid guess constants such as
vec![0u8; 4096]: they hide size bugs.
Const-sized messages — stack array, no heap:
#![allow(unused)]
fn main() {
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
}
Dynamic / ragged messages — compute exact size first with *EncodedLength,
then encode into a claim or slot of that exact length:
#![allow(unused)]
fn main() {
pub fn demo_car_size_and_encode() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Fuel: 2 entries with usage ASCII lengths 5 and 7.
// Performance: 1 entry with 2 nested acceleration rows (fixed-only entries).
// Message var-data: manufacturer / model / activationCode lengths.
let complete_len = CarEncoder::compute_length()
.fuel_figures_ragged(2, |ff| {
ff.add()?.usage_description(5)?; // "Urban"
ff.add()?.usage_description(7)?; // "Highway"
Ok(())
})?
.performance_figures_ragged(1, |pf| {
pf.add()?.acceleration(|acc| {
acc.uniform(2)?;
Ok(())
})?;
Ok(())
})?
.manufacturer(5)? // "Honda"
.model(9)? // "Civic VTi"
.activation_code(6)? // "abcdef"
.encoded_length_with_header();
// Exact size from compute_length → stack pad (this demo fits well under 512).
const CAR_PAD: usize = 512;
assert!(
complete_len <= CAR_PAD,
"sample car length {complete_len} exceeds stack pad {CAR_PAD}"
);
let mut storage = [0u8; CAR_PAD];
let written = encode_sample_car(&mut storage[..complete_len])?;
assert_eq!(
written, complete_len,
"CarEncodedLength must equal encoder-produced length"
);
Ok(storage[..written].to_vec())
}
/// Encode the canonical sample car into `buf` (must be pre-sized).
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
(This code comes from the sbe-feature-tour sample crate.)
Key rules:
compute_length_with_header()isconstwhen the message has no var-data fields or groups — use it directly for stack array sizes.- For messages with groups or var-data, use the staged
*EncodedLengthbuilder. - Assert computed length equals actual encoded length after writing.
- Oversize
vec![0u8; 4096]“guess” buffers hide size bugs — avoid them.
Cluster Client
ergo-aeron-cluster is an experimental Rust client for Aeron Cluster. It uses
rusteron for transport and ergo-sbe-generated codecs for Aeron’s Cluster
protocol.
Hobby project. Do not use it as a production substitute for official Aeron Cluster client support.
Scope
The crate implements client-side operations:
- synchronous and poll-driven connection;
- ingress
offerand explicittry_claim; - regular and controlled egress polling;
- authentication challenges and credentials;
- keep-alives and close requests;
- session events, administrative responses, and leader changes.
The Java Aeron process still provides the media driver, archive, consensus module, clustered services, election, recovery, and operational tooling. This crate implements none of those server-side components.
Features and harness
The published crate has no extra features. Java Cluster launch support lives in
the unpublished workspace crate ergo-aeron-cluster-test-harness and requires
a JDK (17 or newer; CI runs Temurin 25) plus locally built Aeron artifacts. See
Java for Archive and Cluster
for which Java to run the cluster on and the recommended JVM and Aeron settings:
just build-aeron-jars
just test-aeron-cluster-harness
The harness, examples, integration tests, reference codecs, and benchmarks are excluded from the published crate package.
Verify the crate
cargo test -p ergo-aeron-cluster
cargo clippy -p ergo-aeron-cluster --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc -p ergo-aeron-cluster --no-deps
cargo bench -p ergo-aeron-cluster --no-run
cargo package -p ergo-aeron-cluster --list --allow-dirty
Run just bench-cluster for the maintained codec comparisons. See
sbe/BENCHMARKS.md
for the common benchmark rules.
Limitations
Limitations, Aeron and rusteron versions, schema identities, failure modes and what CI runs against a live cluster: Compatibility.
Apache-2.0.
SessionBuilder
SessionBuilder is the supported configuration entry point:
use std::sync::Arc;
use std::time::Duration;
use ergo_aeron_cluster::{SessionBuilder, StaticCredentials};
fn main() -> Result<(), ergo_aeron_cluster::ClusterError> {
let session = SessionBuilder::default()
.ingress_channel("aeron:udp?endpoint=localhost:9010")?
.egress_channel("aeron:udp?endpoint=localhost:9020")?
.credentials(Arc::new(StaticCredentials::from_utf8("user:pass")))
.message_timeout(Duration::from_secs(5))?;
session.validate()?;
let mut client = session.connect("/path/to/aeron-dir")?;
client.offer(b"application payload")?;
client.close()
}
Connection remains poll-driven internally; the crate does not require Tokio or
another async runtime. AsyncClusterConnect is a poll-driven Aeron state
machine, not a Rust Future. When the application owns the poll loop, the
shortest supported path is SessionBuilder → connect_async → default_idle
→ poll_connect_until_done → finish:
#![allow(unused)]
fn main() {
use ergo_aeron_cluster::{default_idle, poll_connect_until_done, SessionBuilder};
fn connect(aeron_dir: &str) -> Result<ergo_aeron_cluster::AeronCluster, ergo_aeron_cluster::ClusterError> {
let builder = SessionBuilder::default()
.ingress_channel("aeron:udp?endpoint=localhost:9010")?
.egress_channel("aeron:udp?endpoint=localhost:9020")?;
let mut connecting = builder.connect_async(aeron_dir);
let mut idle = default_idle();
poll_connect_until_done(&mut connecting, &mut idle)?;
connecting.finish()
}
}
Drive poll / step yourself when you need to inspect connect steps or plug
in a custom idle strategy.
No poll waits on the media driver. AsyncClusterConnect::poll adds the egress
subscription and ingress publication, and the publication a REDIRECT asks
for, with Aeron’s async add and picks them up on later polls. poll_egress
handles a NewLeaderEvent the same way: the session stays AwaitingNewLeader
until the new leader’s publication has connected, for up to the message
timeout. Only the first poll, which creates the Aeron client, can wait, and
only while the media driver is not running yet.
Check message sizes once, at start-up: offer takes payloads up to
ingress_max_message_length() and try_claim up to
ingress_max_payload_length() (when smaller), each less
AeronCluster::SESSION_HEADER_LENGTH. A REDIRECT or a new leader re-adds the
configured ingress channel, changing only a unicast endpoint (IPC and
multicast channels stay as they are), so the limits hold across failover.
Multi-member ingress: ingress_endpoints
For first-connect against a cluster whose current leader isn’t known ahead of
time, use ingress_endpoints instead of (or alongside) ingress_channel:
let session = SessionBuilder::default()
.ingress_endpoints("0=host-a:9010,1=host-b:9010,2=host-c:9010")?
.egress_channel("aeron:udp?endpoint=localhost:9020")?;
The grammar is member_id=host:port pairs separated by commas — no aeron:
prefix, member IDs need not be sorted or contiguous. The setter parses and
validates eagerly: a missing =, non-numeric member ID, empty endpoint,
empty map, or duplicate member ID fails immediately at the call site rather
than at validate() or the first poll().
Egress Listeners
Implement EgressListener and pass it through EgressAdapter to
AeronCluster::poll_egress. Use ControlledEgressListener and
ControlledEgressAdapter when callbacks must return Aeron controlled-poll
actions.
Protocol errors, listener panics, keep-alive failures, publication failures, and
reconnect failures are returned as ClusterError. Application payloads,
credentials, challenges, and binary response data remain byte slices. Text
fields declared by the protocol are validated before being exposed as &str.
The high-level client, configuration, listener, state, error, offer, and claim
types are the consumer-facing surface. The generated protocol codecs are also
reachable, for advanced direct encode/decode, through the cluster_codec_types
module — but that module is #[doc(hidden)] and not a stable API: it exists
for repository tests and low-level experimentation, and its shape may change
without a semver bump. Normal applications should use AeronCluster.
Multi-Message & Framing
Ergon supports two framing approaches for adjacent messages, and an AnyMessage
dispatch enum for multi-message streams where the next type isn’t known until
runtime.
Normal Cluster applications use AeronCluster and its egress listeners. The
protocol-codec examples below illustrate framing through the repository’s
cluster_codec_types test/benchmark seam, which is not a stable consumer API.
Two framing approaches
1. Back-to-back with encoded length
Pre-compute each message’s exact size, lay them out at known offsets, and validate after encoding. Safest when you know all messages ahead of time.
// Size every message from its own payload length.
let len_a = MsgAEncoder::compute_length_with_header(data_a.len());
let len_b = MsgBEncoder::compute_length_with_header(data_b.len());
let mut buf = vec![0u8; len_a + len_b];
// Encode MsgA at offset 0.
let a_len = MsgAEncoder::wrap_and_apply_header(&mut buf[..len_a], 0)
.fixed(&fields_a)
.data(data_a)?
.encoded_length_with_header();
assert_eq!(a_len, len_a);
// Encode MsgB at offset len_a.
let b_len = MsgBEncoder::wrap_and_apply_header(&mut buf[len_a..], 0)
.fixed(&fields_b)
.data(data_b)?
.encoded_length_with_header();
assert_eq!(b_len, len_b);
// Both schemas are known, so their tails determine their message boundaries.
let wire = &buf[..len_a + len_b];
2. Stream / remaining() slot
Write sequentially; use remaining() to find where the next message starts.
Idiomatic for Aeron cluster sessions where a SessionMessageHeader is
immediately followed by application payload.
use ergo_aeron_cluster::cluster_codec_types::*;
let mut buf = [0u8; SessionMessageHeaderEncoder::compute_length_with_header()
+ SessionKeepAliveEncoder::compute_length_with_header()];
// Encode the outer message. `fixed()` writes the required body so a reused
// buffer cannot publish leftover bytes.
let tail = SessionMessageHeaderEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&SessionMessageHeaderFixedFields {
leadership_term_id: 7,
cluster_session_id: 99,
timestamp: 42,
})
.into_remaining_mut();
// into_remaining_mut() returns the unwritten tail.
let keep_alive_len = SessionKeepAliveEncoder::wrap_and_apply_header(tail, 0)
.fixed(&SessionKeepAliveFixedFields {
leadership_term_id: 7,
cluster_session_id: 99,
})
.encoded_length_with_header();
assert_eq!(keep_alive_len, SessionKeepAliveEncoder::compute_length_with_header());
// Decode: remaining() gives bytes after the first message.
let smh = SessionMessageHeaderDecoder::decode(&buf, 0)?;
let tail = smh.get_metadata().remaining();
assert_eq!(tail.len(), SessionKeepAliveEncoder::ENCODED_LENGTH);
AnyMessage dispatch
Cluster sessions multiplex many message types on a single stream.
AnyMessage::try_decode reads the 8-byte session SBE header, validates its
schema identity, and selects the template. For known templates it also checks
the acting fixed-body extent. Dynamic tails are checked when consumed:
use ergo_aeron_cluster::cluster_codec_types::*;
fn dispatch(data: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
match AnyMessage::try_decode(data, 0)? {
AnyMessage::SessionMessageHeader(decoder) => {
// This wraps application payload. The application owns its
// schema; do not recursively dispatch it as Cluster protocol.
let payload = decoder.get_metadata().remaining();
println!("application payload: {} bytes", payload.len());
}
AnyMessage::SessionEvent(decoder) => {
let code = decoder.code();
let (detail, _) = decoder.into_detail_as_str()?;
println!("event {code}: {detail}");
}
AnyMessage::NewLeaderEvent(decoder) => {
let (endpoints, _) = decoder.into_ingress_endpoints_as_str()?;
println!("new leader at {endpoints}");
}
AnyMessage::Challenge(decoder) => {
let (chal, _) = decoder.into_encoded_challenge()?;
println!("challenge: {} bytes", chal.len());
// respond to challenge...
}
AnyMessage::AdminResponse(decoder) => {
let (msg, after) = decoder.into_message()?;
let (payload, _) = after.into_payload()?;
println!("admin response: {msg:?}, {} payload bytes", payload.len());
}
AnyMessage::SessionKeepAlive(_) => {
// heartbeat — nothing to do
}
AnyMessage::Unknown { .. } => {
// Produced by decode_frame when an external length is supplied.
}
_ => {
// Other known session templates are not handled by this example.
}
}
Ok(())
}
SBE headers do not carry a complete message length. try_decode returns
UnknownTemplateLength for an unknown template because its tail cannot be
located without the schema. Use AnyMessage::decode_frame / FrameCursor
with an external frame length when unknown templates must be preserved or
skipped. The high-level Cluster client can ignore an unknown protocol message
because Aeron already supplies the fragment boundary.
Metadata
Every decoder exposes get_metadata() which returns a Metadata struct:
| Method | Returns |
|---|---|
buffer() | The entire original &[u8] buffer |
remaining() | Bytes after the acting fixed block (&buffer[limit()..]) |
message_offset() | Absolute offset of this message’s frame start within buffer() |
limit() | End of the acting fixed block (not the full frame when tails follow) |
Metadata remaining() starts immediately after the fixed block. That is the
application payload for a fixed-only SessionMessageHeader; for a message
with groups or var-data, it starts at that message’s first tail. To locate the
next message after a tailed message, complete its staged walk and use the
complete stage’s remaining(). Calling the base decoder’s full-length helper
instead requires a separate tail scan.
Cluster Compatibility
Supported stacks
| Component | Version | Notes |
|---|---|---|
| Aeron Java/C media driver | 1.53.3 | aeron submodule tag; archive + consensus module |
| Aeron Cluster Java | 1.53.3 | clustered service container |
| rusteron (Rust bindings) | 0.2.13 (workspace Cargo.toml) | rusteron-client, rusteron-media-driver; bundles Aeron C 1.53.3 (the tutorial sample also uses rusteron-archive) |
| ergo-sbe (codec generator) | 0.1.x | schema 111 session + mark codecs |
| Rust | 1.99+ | the workspace rust-version (the cluster Cargo.toml declares none); edition 2024. CI builds the cluster crate with 1.99.0; the MSRV job checks ergo-sbe only |
| OS | Linux x86-64 and arm64 | CI runs the cluster unit tests on ubuntu-24.04 and ubuntu-24.04-arm, and the live-cluster harness on ubuntu-24.04 only. macOS is not run in CI |
| Java | 25 (17+) | Archive and Cluster; the test harness runs Temurin 25 in CI (21 for the Aeron 1.46.8 compatibility job). Java for Archive and Cluster |
Schema identities
| Schema | Id | SBE version | semanticVersion |
|---|---|---|---|
| Aeron Cluster Session | 111 | 17 | 5.4 |
| Aeron Cluster Mark | 110 | 2 | 5.4 |
These are pinned in cluster/schemas/ and generated by cluster/build.rs.
The generated codec API is unstable — use the high-level AeronCluster
client, not the cluster_codec_types module directly.
Failure modes
The client handles these documented transitions:
| Trigger | Expected behaviour |
|---|---|
| Leader loss (the session’s egress image closes / publication CLOSED) | Session → AwaitingNewLeader, poll for NewLeaderEvent |
NewLeaderEvent (the last one in a poll) | Bind to the image that delivered it; add the leader’s ingress publication without waiting; Connected once it connects, Disconnected if it has not within the message timeout |
SessionEvent(CLOSED) | Session → PendingClose → Closed, even while a new leader’s publication is pending |
| Redirect during connect | Follow leader_endpoints to new leader |
| New ingress publication (redirect or new leader) | Keep every unicast ingress channel setting but the endpoint, and re-add IPC and multicast channels unchanged, so the message limits do not change |
| Auth challenge | Pass to CredentialsSupplier, send ChallengeResponse |
| Auth rejection | ClusterError::AuthRejected |
| Timeout (connect / poll / new-leader) | ClusterError::Timeout / ClusterError::Disconnected |
| Listener panic | ClusterError::ListenerPanicked, session continues |
| Malformed egress frame | ClusterError::ProtocolError |
| Payload too large | ClusterError::PayloadTooLarge |
| Publication backpressure | Retryable ClusterError::Publication |
What CI runs against a live cluster
The harness (cluster-test-harness/tests/) runs in ci.yml
(aeron-cluster-integration, Aeron 1.53.3, Java 25) and in
cluster-compatibility.yml (Aeron 1.46.8, Java 21, when cluster/ or
sbe/src/ changes).
| Covered | Test file |
|---|---|
| 3-node connect, leader failover, redirect | failover.rs, failover_own_driver.rs, client_parity.rs, nonblocking.rs |
| New leader that never connects times out | nonblocking.rs (a_new_leader_that_never_connects_times_out) |
SessionEvent(CLOSED) while a new leader is pending | nonblocking.rs |
| Ingress channel settings kept across failover and redirect | ingress_channel_settings.rs |
| Fragmented egress, regular and controlled poll | egress_fragmentation.rs |
| Restart, reconnect, quorum loss, log recovery | restart.rs |
| Null and simple credentials (the default authenticator accepts all) | auth.rs |
Not covered by the harness: auth challenge and rejection, controlled-poll
abort, and connect or poll timeout expiry. Unit tests in cluster/src/ (run
by ci.yml’s test job) cover challenge dispatch (egress.rs), the
challenge response (credentials.rs, client.rs) and the new-leader timeout
(client.rs, test_new_leader_timeout_disconnects). No test drives
ClusterError::AuthRejected, a controlled-poll abort, or a connect or poll
timeout.
Limitations
- No dynamic membership (static cluster only).
- No snapshot/recovery integration.
- No TLS/mTLS at the Aeron transport layer.
- The
cluster_codec_typesmodule is#[doc(hidden)]and not stable API.
ergon-io
Network I/O for an exchange client on one busy-spinning thread: WebSocket and HTTP/1.1 over TLS 1.3.
- The driver is Linux-only; the protocol code builds everywhere.
- Written with heavy AI assistance (disclosure). Not production-ready.
cargo add ergon-io --features webpki-roots
- Features:
uring(on by default) adds theio_uringbackends;webpki-rootsadds Mozilla’s root store (tls::webpki_roots);curladds libcurl as anHttpClientimplementation. - API reference: docs.rs/ergon-io.
- Every measured figure, with its max and its data file:
io/benches/BENCHMARK.md(its first table answers the common questions).
Glossary
Terms this chapter uses. Each links to the section that explains it.
| term | what it is | pros / cons, when it matters |
|---|---|---|
| busy-spin thread | A thread that loops on its own core and never sleeps. | Pro: no wake-up delay. Con: uses a whole core all the time. |
| poll | One non-blocking pass of Driver::poll: receive, decrypt, decode, deliver, send. | A message can wait up to one whole poll for its turn. |
| isolated core | A CPU the kernel keeps other work off. The rest are housekeeping CPUs. | Pin the loop to one. Helper threads (connect_background) run on housekeeping CPUs — in a pod, on its other CPUs (Kubernetes). |
| hyperthread (SMT) sibling | The other logical CPU on the same physical core. | Can host the SQPOLL thread, at a cost in p50 and p99 against a core of its own (data). |
| cpuset | The CPUs a process may run on: its cgroup’s cpuset.cpus.effective. A pod’s is what the kubelet gave it. | Helper threads and SQPOLL must land inside it; the kernel refuses a mask outside (EINVAL). |
| static CPU manager | The kubelet policy that gives a Guaranteed pod whole CPUs of its own. | What makes a second CPU the loop does not share possible; x86 with SMT: also full-pcpus-only. |
| Graviton | AWS’s Arm CPUs. | Each vCPU is a whole core, so there is no hyperthread sibling. The fair backend comparison ran on it. |
SCHED_FIFO | Real-time scheduling: the thread runs ahead of every normal one. | One A/B for the loop: no consistent change (data). |
| syscall | A call from the program into the kernel. | Each costs CPU. uring-spin makes far fewer than epoll-spin at the same message rate. |
| interrupt | The NIC’s signal to the CPU that packets arrived. | epoll-spin, uring-spin and mio wait for it. Compared fairly with NIC polling on Graviton only. |
| NIC polling (busy polling) | The reading thread checks the NIC queue itself instead of waiting for an interrupt. | Pro: skips the interrupt path. Con: CPU; plain recv-busy pays it per socket. |
| NAPI | The kernel code that takes packets off a NIC queue; busy polling calls it directly. | uring-sqpoll and uring-napi register their queues with it: Linux 6.9+. |
| NIC queue | One of the NIC’s receive queues (c7i.2xlarge: 8); each connection lands on one. | per_queue and the SQPOLL thread poll queues, not sockets, so their cost stops growing with sockets. |
| adaptive RX | ENA (the AWS NIC) re-tunes how long it delays interrupts to batch packets (adaptive receive moderation). | On by default. Set on the node: ethtool -C <nic> adaptive-rx off. |
| backend | How the Driver waits for and reads data; set in config: Config::parse("recv-busy"). | Pick by socket count and spare CPUs: choosing a backend. |
epoll-spin | Asks epoll (the kernel’s list of ready sockets) every poll, without waiting; reads only those. | Pro: a cheap poll even at 256 sockets. Con: waits for the interrupt. |
| mio, tokio | Rust’s usual async I/O. On Linux both use epoll, so they wait for the interrupt. | reqwest on tokio parks its thread between requests. |
recv-busy | Reads every socket every poll; SO_BUSY_POLL makes each read poll the NIC too. | The pick at 1–4 sockets. Each socket adds to every poll. |
per_queue | A recv-busy option: one read per NIC queue, then only the ready sockets. | Poll cost stops at the queue count. The one-CPU pick past 4 sockets. |
io_uring | A Linux interface: requests and results pass through rings shared with the kernel. | Needs feature uring (on by default) and Linux 6.1+ (6.9+ for NAPI). |
uring-spin | io_uring with DEFER_TASKRUN: enters the kernel only when it flags work. | Pro: few syscalls, low CPU. Con: waits for the interrupt, like epoll-spin. |
uring-sqpoll | A kernel thread (SQPOLL) spins for the ring: submits, completes, polls the NIC. | No per-socket scan, so its cost does not grow with sockets. Needs a second CPU or the loop’s sibling. |
uring-napi, epoll-busy | Wait in the kernel up to wait_us (100 µs): poll the NIC up to busy_poll_us (50 µs), then sleep. | No run shows them saving CPU. |
| crossover | The socket count where one backend stops beating another. | Depends on the NIC, its queue count, the interrupt path and the traffic: measure it on your host. |
TCP_NODELAY | Turns off Nagle’s algorithm, which holds a small send until earlier data is ACKed. | On for every socket: each send goes out at once. |
| quick ACK | The kernel ACKs at once instead of delaying (tcp(7), TCP_QUICKACK). | A peer using Nagle’s algorithm waits for our ACK before its next small send. Effect not measured here. |
rto_min | The shortest time TCP waits before resending lost data; a route attribute (ip-route(8)). | The lab routes set 5 ms; effect not measured here. |
| TCP keepalive | The kernel probes an idle connection to find a dead peer. | Default: 10 s idle, then every 5 s, 3 probes. One kernel timer per socket; WebSocket Pings already do this. |
TCP_USER_TIMEOUT | How long sent data may stay unacknowledged before the kernel drops the connection. | 25 s here; the kernel default is about 15 minutes. |
| TLS 1.3 | The encryption on every connection. | TLS 1.2 is refused with a clear error. |
| TLS handshake | The key exchange when a connection opens. | Makes one poll of the connecting thread long (data). Keep it off the loop: connect_background. |
| TLS record | TLS’s unit of encrypted data. | A native HTTP request is one record; a large WebSocket message goes out over several. |
| in place | Decrypted and parsed in the buffer the kernel wrote into: one copy per byte. | Pro: no extra copy. Con: the slice lives only during the call; copy what you keep. |
| WebSocket | A two-way message stream over one TCP connection, opened by an HTTP upgrade (status 101). | No compression here: it would break in-place parsing. |
| frame | WebSocket’s unit on the wire; a message is one frame or several (fragmented). | Every frame must fit buf_size (1 MiB). Fragmented messages are received, never sent. |
| Ping / Pong | Control frames that check the other side is alive. | Ping after 10 s with nothing received; nothing back in 5 s: timeout. Any byte received counts as the answer. |
| close handshake | Each side sends a Close frame, then TCP closes. | Waits up to 1 s (close_timeout_ms). Driver::close skips it. |
| HTTP/1.1 keep-alive | One connection reused for many requests. | No handshake per request. One request in flight per connection; a timed-out request closes its connection. |
| connection pool | The warm keep-alive connections an HttpClient keeps. | All busy: Error::Busy. Each one is one more socket the loop polls. |
| libcurl | The common C HTTP library: HttpImpl::Curl (feature curl). | Pro: redirects, proxies, HTTP/2, compression. Con: slower than native at the median on the same interrupt path. |
| rate limit | A venue’s cap on requests, orders or connects per window: per IP, account, instrument or connection. | Break it: refused (429). Keep going: your IP is banned. |
| budget, bucket | limit::Budget: one bucket per venue limit, checked before each request. | Never sleeps or queues: a refusal says when to try again. Sees only this process. |
| rolling window | A count of what went out in the last window, kept in sub-windows. | Safe whatever window edges the venue uses; costs at most one sub-window of budget. |
| draining counter (GCRA) | A level that drains at a fixed rate (Kraken, Deribit). | Wrong for window limits: it lets twice the limit through in one window. |
| reserve | Units only cancels and reduce-only orders may use. | Keeps cancels possible when a bucket is nearly full; not during a hold. |
| hold, halt | Hold: a bucket refuses everything until the venue’s wait ends (a 429). Halt: until a person clears it (a ban). | A halt never ends by itself: alert on Budget::halted(). |
| verdict | What became of a request: done, rejected, not run, throttled, banned, clock skew, unknown. | Only the “did not run” kinds may be sent again, inside their deadline. |
Sent::No, Sent::Maybe | For a request with no answer: it never left, or it may have run. | Only the transport knows. Maybe: look the order up. |
| client order id | Your unique id on each order. | The only way to find out what became of an order with an unknown outcome. |
| connect gate | A bucket every connect attempt must pass. | Keeps 50 links reconnecting at once inside a venue’s connect limit. |
| round trip | Request sent to answer received, on one clock. | x86: 1 connection. Graviton: 1–64 sockets on the loop and an HTTP pool of 20. |
| p50 / p99 / max | pN: N % of samples are at or below it (p50: the median). max: the worst. | In µs. Every figure in BENCHMARK.md keeps its max. |
| kernel arrival timestamp | When the kernel stamped a packet’s arrival; in RxMeta, with the NIC’s stamp where it has one. | Starts arrival → decoded. Key rx_timestamps, on by default. |
| arrival → decoded | Kernel arrival timestamp to decoded message, on the receiver’s clock only. | Leaves out the interrupt wait and the wait for the next queue poll: compares polling backends only. |
| whole path | One-way time, sender → decoded, on two hosts’ clocks. | Includes everything, but the clocks drift: compare backends within one round only. |
When to use it
| Use it when | Use something else when |
|---|---|
| one thread owns 1 to a few hundred exchange connections and spins on its own core | the thread may sleep: tokio |
| you want one copy per byte: TLS records decrypted and messages parsed in place | you need HTTP/2 or 3, proxies, redirects, cookies or compression: libcurl or reqwest |
| Linux on x86-64 or Graviton (both measured) | macOS or Windows in production |
How it works
Driver<E>owns the connections.Driver::pollmakes one non-blocking pass (receive, decrypt, decode, deliver, send) and returns.- Your handler gets
Handler::on_open,on_closed, and the protocol’s callback, such asWsHandler::on_message. A payload is a slice of the receive buffer, valid during the call. - One failure closes one connection. It goes to
on_closed; the others keep running.pollfails only when the backend fails. - Resources are owned. A socket closes when its connection drops.
Dropping a
Drivercancels in-flightio_uringwork before freeing its buffers.
Pages: WebSocket (a complete client), HTTP client, Backends and configuration, Rate limits and errors, Kubernetes (where the helper threads run inside a pod).
Choosing a backend
The backend is configuration: Config::parse("recv-busy"). Count every
socket the loop thread polls: WebSocket feeds, order connections and every
HttpClient pool connection (each Driver and HttpClient on the thread
adds its own). Then pick by what CPUs you have:
| sockets on the loop thread | one CPU | a spare CPU for the SQPOLL thread (or the loop’s sibling) | the NIC’s interrupts on a core of their own (node-level) |
|---|---|---|---|
| 1–4 | recv-busy | recv-busy or uring-sqpoll | recv-busy |
| 5–64 | recv-busy,per_queue=true | uring-sqpoll | uring-sqpoll, epoll-spin or uring-spin |
| 65–256 | recv-busy,per_queue=true | uring-sqpoll | uring-sqpoll (interrupt backends not measured past 64) |
Evidence: fair run, Graviton (all six backends, 1–64 sockets, HTTP pool of 1 and 20) and polling backends, x86 (1–256 connections).
- Plain
recv-busyreads every socket on every poll. Its poll grows with sockets, so a message waits longer for its socket’s turn. It leads at 1 socket and falls behind from about 16. per_queuereads one socket per NIC queue, then the ready ones. Its poll stops growing at the queue count. It trailsuring-sqpollat 16–64 sockets. Not the default: set it when the count can pass 4.- The interrupt backends (
epoll-spin,uring-spin, mio) matcheduring-sqpollfrom 4 sockets in the fair run, which had moderation off and their interrupts on a core of their own. Both are node settings; a pod cannot set them. With the interrupts on the loop’s CPU their p99 rose from 4 sockets. - One physical core with two hyperthreads:
uring-sqpollwith the SQPOLL thread on the loop’s sibling (the default in acpu: 2x86 pod: Kubernetes). - The loop’s CPU time in the kernel matters:
uring-spin. It equalsepoll-spin’s latency with thousands of times fewer syscalls (data). - Many streams on one exchange: pack them onto a few combined-stream connections; fewer sockets decode sooner.
uring-napi,epoll-busypoll the NIC inside a timed wait, then sleep. In the one run they traileduring-sqpolland spent most of the loop’s time in the kernel (data).- How each one waits, every key, and how to find the crossover on your own host: Backends and configuration.
Connecting
| call | where the handshake runs | use it for |
|---|---|---|
Driver::add(WsConn::connect(…)?) | the caller’s thread, which waits | tools, tests, start-up |
Driver::connect(dial) | the loop, a step per poll; the handshake makes one poll long | a loop that can take a long poll per connect |
Driver::connect_background(dial) | a helper thread on the helper CPUs (connect_cpu; in a pod: Kubernetes), not the polling CPU when it is known | reconnects on a live loop |
The loop’s worst poll per connect, both ways: connection set-up.
- TLS 1.3 only. TLS 1.2 is refused with a clear error.
- Trust is the rustls configuration you pass
(
tls::client_config(roots)); featurewebpki-rootsadds Mozilla’s roots. - Which TLS stack: the in-place
tls::RecordLayer, on every native connection. rustls does the handshake (UnbufferedClientConnection, thendangerous_into_kernel_connection); the custom layer then does the record cryptography in place in the receive buffer, no allocation per record. Native connections (TlsStream,WsConn) take it unconditionally — there is no stock-rustls record path to choose. The layer owns the record cryptography that rustls would own after the handshake: post-handshake messages, key updates and the cipher’s confidentiality limit. Its effect on a whole request is not measured (only a decrypt-only microbench exists).
Defaults
| setting | default | change with |
|---|---|---|
TCP_NODELAY | on, every socket | always on |
| TCP keepalive | on: 10 s idle, then a probe every 5 s, 3 probes | keepalive_idle_s (0: off), keepalive_interval_s, keepalive_count |
TCP_USER_TIMEOUT | 25 s | tcp_user_timeout_ms (0: kernel default, about 15 minutes) |
SO_RCVBUF | 4 MiB, fixed; needs net.core.rmem_max ≥ 4 MiB (warned at start-up) | rcvbuf (0: kernel autotuning) |
| TCP quick ACK | assumed: quickack 1 on each route | checked once per route (below) |
| WebSocket timers | Ping after 10 s quiet, Pong timeout 5 s, idle timeout off, close wait 1 s | WsOptions (WebSocket) |
| Reconnect | off | Reconnect (WebSocket) |
- TCP settings apply on
Driver::connect,connect_backgroundandadd.HttpClient’s libcurl connections getTCP_NODELAY, keepalive andTCP_USER_TIMEOUTtoo. - Keepalive adds one kernel timer per socket. For a WebSocket-only loop,
Pings already find a dead server:
keepalive_idle_s=0is an option.
What it checks for you
Driver::newchecks the loop’s CPU (ergon-tune): isolation (T1), transparent hugepages (T3), scheduling policy (T7), SMT sibling (T8), and a CPU quota on the process’s cgroup (T9).- Each egress device, from a warning thread: NIC interrupts on the loop’s CPU (T2), ENA adaptive moderation (N1), deferred interrupts with no busy poller (N2), an Azure VF that is down (N3).
- Each route, once, for quick ACK. Default: one warning with the exact
ip route changethat fixes it.quickack_check=requirerefuses the connection before its TLS handshake. The latency effect of quick ACK is not measured here. - Each finding prints once, with its fix, never from the polling thread.
Best practice
Node-level means the setting is on the host, not in the pod spec (deployment: Kubernetes).
| practice | where | evidence |
|---|---|---|
| Pin the loop to an isolated CPU; give it whole CPUs (static CPU manager) | node and pod | Kubernetes |
Reconnect off the loop: connect_background (the Reconnect default) | pod | connection set-up |
AWS: ENA adaptive moderation off (ethtool -C <nic> adaptive-rx off), again after each NIC reset | node | host and NIC settings |
Interrupt backends: also rx-usecs 0, and the NIC’s interrupts on a core of their own | node | interrupts on a core of their own: fair run (irqlocal against irqcore). rx-usecs 0: the fair run’s setting, not A/B tested for these backends; moderation delays the interrupt they wait for (kernel Documentation/networking/device_drivers/ethernet/amazon/ena.rst). That run also had idle CPUs polling (nohlt) |
| Keep the NIC’s interrupts off the loop’s CPU | node | the irqlocal rows in host and NIC settings and in the fair run |
Past 4 sockets: recv-busy,per_queue=true, or uring-sqpoll with a spare CPU | pod | choosing a backend |
| Pack many streams onto a few connections | app | polling backends |
Measured with no consistent effect: SCHED_FIFO for the loop, one NIC
queue, capped idle states. Not measured here: route quickack and
rto_min, prefer_busy_poll, busy_poll_budget.
Verify
cargo test -p ergon-io -- --test-threads=1
cargo clippy -p ergon-io --all-targets -- -D warnings
Measured results
Every figure, with its max and its data file:
io/benches/BENCHMARK.md.
WebSocket and HTTP round trips: WebSocket,
HTTP client.
Backends and Configuration
A backend is the kernel interface the Driver waits and reads with. It is
configuration, so one binary can run with each. Which to pick:
Choosing a backend.
| backend | how it waits | polls the NIC | sleeps in the kernel | extra core |
|---|---|---|---|---|
epoll-spin | epoll_wait(0) every poll, then recvmsg on ready sockets | no (interrupts) | never | no |
recv-busy | non-blocking recvmsg on every socket every poll, with SO_BUSY_POLL (with per_queue: one read per NIC queue, then the sockets epoll reports ready) | yes, one pass per socket (or queue) per poll | never | no |
uring-spin | IORING_OP_RECVMSG with DEFER_TASKRUN; enters the kernel only when it flags work | no (interrupts) | never | no |
uring-sqpoll | an SQPOLL kernel thread submits, completes and polls the NIC | yes, on the kernel thread | never (the application’s thread) | yes |
epoll-busy | epoll_pwait2(wait_us) with epoll busy polling for busy_poll_us | yes, inside the wait | up to wait_us | no |
uring-napi | io_uring_enter(GETEVENTS, 1, wait_us) with NAPI busy polling | yes, inside the wait | up to wait_us, and on every idle poll | no |
epoll_wait(0)never blocks, but never polls the NIC either. mio, and so tokio, is this path too.- Plain
recv-busyreads every socket on every poll, so each socket adds a little to every poll.per_queuereads one socket per NIC queue instead: see cost per poll. uring-sqpollcosts a spinning kernel thread per ring. Rings in one process can share one; other processes cannot. Its kernel thread polls each NIC queue once per pass, so it has no per-socket scan.
Configuration strings
backend[,key=value...], parsed and checked by Config::parse:
#[test]
fn keys_override_defaults() -> R {
let c =
Config::parse("uring-sqpoll, sqpoll_cpu=3, busy_poll_us=2, rcvbuf=0, rx_timestamps=false")?;
assert_eq!(c.backend, Backend::UringSqpoll);
assert_eq!(c.sqpoll_cpu, Some(3));
assert_eq!(c.busy_poll_us, 2);
assert_eq!(c.rcvbuf, None);
assert!(!c.rx_timestamps);
Ok(())
}
(From io/tests/backend_config.rs; cargo test -p ergon-io runs it on
every platform.)
| key | default | meaning |
|---|---|---|
loop_cpu | unset | the loop’s CPU, for the host checks; unset: the CPU the calling thread is pinned to (ergon-io never pins it) |
sqpoll_cpu | unset | the SQPOLL kernel thread’s CPU; unset: the first helper CPU (placement). A core of its own or the loop’s SMT sibling: what the sibling costs |
sqpoll_idle_ms | 600,000 | idle time before the SQPOLL thread sleeps; NIC polling does not count as work, so keep it long |
ring_entries | 256 | io_uring submission queue size |
busy_poll_us | 1 (recv-busy, uring-sqpoll), 50 (epoll-busy, uring-napi) | NIC busy polling in µs: how long epoll-busy and uring-napi poll before sleeping; recv-busy and uring-sqpoll need it non-zero |
wait_us | 0, or 100 (epoll-busy, uring-napi) | the longest epoll-busy and uring-napi wait in the kernel |
per_queue | false | recv-busy only: one busy-polled read per NIC queue, then the sockets epoll reports ready |
prefer_busy_poll | false | the kernel leaves the NIC queue to the polling loop (with recv-busy, SO_PREFER_BUSY_POLL: needs CAP_NET_ADMIN); NIC-polling backends only; effect not measured here |
busy_poll_budget | 0 (kernel: 8) | packets per busy-poll pass; recv-busy and epoll-busy only; raising it needs CAP_NET_ADMIN; effect not measured here |
connect_cpu | unset | the connect_background helper thread’s CPU; unset: the housekeeping CPUs |
connect_timeout_ms | 10,000 | the longest connection set-up |
rcvbuf | 4 MiB | SO_RCVBUF for every socket; needs net.core.rmem_max at least as large (warned at start-up); 0: the kernel’s autotuning |
rx_timestamps | true | kernel software, and where supported NIC hardware, receive timestamps in RxMeta |
quickack_check | warn | off, warn or require |
keepalive_idle_s | 10 | TCP keepalive: idle seconds before the first probe; 0: off |
keepalive_interval_s, keepalive_count | 5, 3 | seconds between probes, probes before the drop |
tcp_user_timeout_ms | 25,000 | TCP_USER_TIMEOUT; 0: the kernel default |
An unknown key, a bad value or a setting the backend cannot use is an error
(Config::validate), never ignored.
The four one-core backends
recv-busy, recv-busy,per_queue, epoll-spin and uring-spin run on
the loop’s CPU alone: no SQPOLL kernel thread. uring-sqpoll is the only
backend that needs another CPU; it is not in this section. The interrupt
backends (epoll-spin, uring-spin) were measured with the NIC’s
interrupts on a core of their own; on the loop’s CPU their p99 rose from 4
sockets (fair run).
/// The four backends that cost one core each. `uring-sqpoll` is not here:
/// its SQPOLL kernel thread spins on a CPU of its own.
#[test]
fn the_one_core_backends() -> R {
// recv-busy: one recvmsg per socket per poll. Best at 1-4 sockets.
let plain = Config::parse("recv-busy")?;
assert_eq!(plain.backend, Backend::RecvBusy);
assert!(!plain.per_queue);
// per_queue: one busy-polled read per NIC queue, then only the sockets
// epoll_wait(0) reports ready. The same latency as plain at 1-16 sockets,
// and the only one-core choice still flat at 256.
let queued = Config::parse("recv-busy,per_queue=true")?;
assert_eq!(queued.backend, Backend::RecvBusy);
assert!(queued.per_queue);
assert_eq!(queued.busy_poll_us, 1);
// epoll-spin: epoll_wait(0), then recvmsg on the ready sockets. Cheapest
// per socket, but it waits for the NIC interrupt.
let epoll = Config::parse("epoll-spin")?;
assert_eq!(epoll.backend, Backend::EpollSpin);
assert!(!epoll.per_queue);
// uring-spin: IORING_OP_RECVMSG with DEFER_TASKRUN. The fewest syscalls of
// any spin loop; also interrupt-driven.
let uring = Config::parse("uring-spin")?;
assert_eq!(uring.backend, Backend::UringSpin);
assert!(!uring.per_queue);
Ok(())
}
(From io/tests/backend_config.rs; cargo test -p ergon-io runs it on
every platform.)
What one poll does:
| backend | one poll does | syscalls |
|---|---|---|
recv-busy | recvmsg on every socket | one per socket per poll |
recv-busy,per_queue | one read per NIC queue, then epoll_wait(0) on the ready sockets | one per queue plus the ready ones |
epoll-spin | epoll_wait(0), then recvmsg on the ready sockets | only the ready sockets |
uring-spin | IORING_OP_RECVMSG with DEFER_TASKRUN | few |
Which one to take: choosing a backend.
per_queue: one read per NIC queue
Plain recv-busy calls recvmsg on every socket every poll. Each call runs
one NAPI busy-poll pass even when the socket is idle, so almost all of those
reads return nothing and much of the core sits in the kernel. A message then
waits for its socket’s turn, half a poll on average and up to a whole one.
per_queue changes the scan. It reads one socket per NIC queue (that
read polls the queue), then calls epoll_wait(0) and reads only the sockets
that are ready. The cost stops growing at the queue count.
As struct fields, for when more than one setting changes:
/// The same choice as struct fields, for when more than one setting changes.
#[test]
fn per_queue_as_struct_fields() {
let mut cfg = Config::new(Backend::RecvBusy);
cfg.per_queue = true; // one read per NIC queue, then epoll_wait(0)
cfg.busy_poll_us = 1; // one NAPI pass per empty read
cfg.rcvbuf = Some(4 << 20);
assert!(cfg.per_queue);
assert_eq!(cfg.busy_poll_us, 1);
assert_eq!(cfg.rcvbuf, Some(4 << 20));
}
It is recv-busy and nothing else. On any other backend it is a parse
error, never ignored:
for bad in [
"epoll-spin,per_queue=true", // error
"uring-spin,per_queue=true", // error
"uring-sqpoll,per_queue=true", // error
"recv-busy,per_queue=1", // error: not true/false
] {
assert!(Config::parse(bad).is_err(), "{bad:?} accepted");
}
On the lab command line:
# plain: one recvmsg per socket per poll
io_bench lab --variants recv-busy --cpus 2 --busy-poll 1 --wait-us 0 \
--server SENDER:9447 --conns 100 --duration 60 --out res/c100-recv-busy
# per_queue: one read per NIC queue, then the ready sockets
io_bench lab --variants recv-busy --per-queue --cpus 2 --busy-poll 1 --wait-us 0 \
--server SENDER:9447 --conns 100 --duration 60 --out res/c100-per-queue
$B/analyse --warmup-s 2 res/c100-*
Measured against plain recv-busy and uring-sqpoll at 1–256
connections: polling backends.
Kernel
| backend | needs |
|---|---|
epoll-spin, recv-busy | any supported kernel |
uring-spin | Linux 6.1+ and the uring feature |
uring-sqpoll, uring-napi | Linux 6.9+ (NAPI registration) and the uring feature |
epoll-busy | Linux 6.9+ (EPIOCSPARAMS) |
An older kernel gives Error::Unsupported from Driver::new.
Host settings
The loop is only as quiet as its core. What mattered on AWS and Azure, for x86 and Graviton: Deployment: AWS and Azure hosts. ergon-io’s own checks (ergon-tune) report what is missing at start-up.
Cost per poll
One poll is 1 ÷ the loop’s polls per second. Measured poll times: polling backends.
One poll per backend. Where the NIC’s NAPI pass runs is what separates them:
flowchart TD
P["one Driver::poll"]
P --> RB["recv-busy"]
P --> ES["epoll-spin"]
P --> US["uring-spin"]
P --> SQ["uring-sqpoll"]
RB --> RB1["recvmsg on every socket"]
RB1 --> RB2["NAPI pass per socket, even idle"]
RB2 --> RB3["cost grows with sockets"]
ES --> ES1["epoll_wait(0)"]
ES1 --> ES2["recvmsg on ready only"]
ES2 --> ES3["interrupt path, no NIC poll"]
US --> US1["submit RECVMSG"]
US1 --> US2["DEFER_TASKRUN complete"]
US2 --> US3["interrupt path, no NIC poll"]
SQ --> SQ1["SQPOLL kernel thread"]
SQ1 --> SQ2["polls each NIC queue"]
SQ2 --> SQ3["NAPI pass per queue"]
recv-busy,per_queue sits between the first two: one busy-polled read
per NIC queue (so one NAPI pass per queue), then epoll_wait(0) and
only the ready sockets. Its cost stops at the queue count.
- Plain
recv-busygrows with sockets: each non-blockingrecvmsgruns one NIC busy-poll pass even on an idle socket, so most reads return nothing and the core sits in the kernel. - What it costs in latency: a message waits for its socket’s turn, half a poll on average and up to a whole poll.
per_queuereads one socket per NIC queue (which polls that queue), then only the ready sockets. Its poll stops growing at the queue count.epoll-spinanduring-spindo not scan idle sockets; they wait for the NIC interrupt instead. Against the polling backends: fair run.- Where each polling backend stops winning: choosing a backend.
Find the crossover on your own host
The crossover depends on the NIC, its queue count, the interrupt path and
the traffic. Measure it with the lab tools in io/benches, on two hosts:
the many-connection market-data test the tables above come from, once per
backend, at your socket count (here 20).
cargo build --release --manifest-path io/benches/Cargo.toml --bin replay_server --bin io_bench --bin analyse
B=io/benches/target/release
# sender: one stream per connection; restart it before each run, copy cert-many.pem to the receiver
sudo $B/replay_server --listen 0.0.0.0:9447 --cert-out cert-many.pem --clients 20 --mode mixed --cpu 1 --duration 120 \
--trickle-min-us 1000 --trickle-max-us 10000 --burst-every-us 2000 --burst-slots 10 --burst 10 --seed 42
# receiver, loop on isolated CPU 2
sudo $B/io_bench lab --variants recv-busy --cpus 2 --busy-poll 1 --wait-us 0 --server SENDER:9447 \
--ca cert-many.pem --sni lab.local --conns 20 --capacity 8000000 --duration 60 --out res/c20-recv-busy
# then the same with --variants recv-busy --per-queue (out res/c20-per-queue),
# --variants epoll-spin (res/c20-epoll-spin), --variants uring-sqpoll --sqpoll-cpu 3 (res/c20-sqpoll)
$B/analyse --warmup-s 2 res/c20-*
- Compare the backends’ sender → decoded p50, p99 and max within the same session, and repeat in reversed order: the two clocks drift.
- On AWS, set the NIC on the node before each run (node-level):
ethtool -C <nic> adaptive-rx off rx-usecs 0, with the NIC’s interrupts on a core of their own, as in the fair run. Otherwise the interrupt backends wait for moderation. - HTTP with a pool: HTTP with many connections.
- Everything about the method:
io/benches/BENCHMARK.md.
WebSocket
WsConn is a TLS 1.3 WebSocket client that a Driver owns.
- Frames are parsed in place:
WsHandler::on_messagegets a slice of the buffer the kernel wrote into (one copy per byte). RxMetacarries the read’s kernel receive timestamp and, where the NIC has one, its hardware timestamp.- Timers (Pings, timeouts, the close wait) run on the driver’s deadline check (every 250 µs), never on the read or send path.
A complete client
Connect, subscribe, print each message, reconnect and subscribe again after a drop, then close cleanly:
use ergon_io::reconnect::{Closed, LinkId, LinkState, Retry};
use ergon_io::{ConnId, Error, Handler, Reconnect, RxMeta, WsDial, WsHandler};
/// Sent on every connect and reconnect.
pub const SUBSCRIBE: &str = r#"{"method":"SUBSCRIBE","params":["btcusdt@bookTicker"],"id":1}"#;
/// The application. `Driver::poll` calls it.
pub struct App {
/// Keeps the connection open: reopens it with backoff after a drop.
pub links: Reconnect<WsDial>,
/// The one link (a connection that `links` keeps open).
pub link: LinkId,
/// Connections opened during the last poll: subscribe on them.
opened: Vec<ConnId>,
/// Close after this many messages.
pub limit: u64,
/// Messages received.
pub messages: u64,
/// Connections opened; more than 1 means it reconnected.
pub opens: u32,
}
impl App {
/// `dial` builds a fresh connection set-up for each attempt.
///
/// # Errors
/// `retry` is out of range.
pub fn new(
retry: Retry,
dial: impl FnMut() -> ergon_io::Result<WsDial> + 'static,
limit: u64,
) -> ergon_io::Result<Self> {
let mut links = Reconnect::new(retry)?;
let link = links.add(dial);
Ok(Self {
links,
link,
opened: Vec::new(),
limit,
messages: 0,
opens: 0,
})
}
/// Closed cleanly, or the link gave up.
#[must_use]
pub fn finished(&self) -> bool {
matches!(
self.links.state(self.link),
Some(LinkState::Stopped | LinkState::GaveUp)
)
}
}
impl Handler for App {
fn on_open(&mut self, id: ConnId) {
if let Some(opened) = self.links.on_open(id) {
self.opens += 1;
if opened.reconnect {
println!("reconnected: subscribing again");
}
// Sends wait until `poll` returns: remember the connection.
self.opened.push(id);
}
}
fn on_closed(&mut self, id: ConnId, error: Error) {
println!("closed: {error}");
if let Some(Closed::Retry { delay_ms, .. }) = self.links.on_closed(id) {
println!("reconnecting in {delay_ms} ms");
}
}
}
impl WsHandler for App {
fn on_message(&mut self, _: ConnId, _: u8, payload: &[u8], _: &RxMeta, _: u16) {
// `payload` points into the receive buffer: use it during this call only.
println!("{}", std::str::from_utf8(payload).unwrap_or("<binary>"));
self.messages += 1;
}
}
/// The loop: runs until the clean close, or until the link gives up.
///
/// # Errors
/// The backend failed. A connection that fails goes to `on_closed` instead.
#[cfg(target_os = "linux")]
pub fn run(config: ergon_io::Config, app: &mut App) -> ergon_io::Result<()> {
use ergon_io::{Driver, WsConn};
let mut driver: Driver<WsConn> = Driver::new(config)?;
let mut closing = false;
while !app.finished() {
driver.poll(app)?; // receive, decrypt, deliver, send
app.links.poll(&mut driver); // start a reconnect when one is due
for id in app.opened.drain(..) {
// A connection the server is already closing takes no sends.
if let Some(mut conn) = driver.get_mut(id)
&& !conn.is_closing()
{
conn.send_text(SUBSCRIBE)?;
}
}
if app.messages >= app.limit && !closing {
closing = true;
app.links.stop(app.link); // no reconnect after this close
if let Some(LinkState::Open(id)) = app.links.state(app.link)
&& let Some(mut conn) = driver.get_mut(id)
&& !conn.is_closing()
{
// `on_closed` follows once the server answers.
conn.close(1000, "done")?;
}
}
}
Ok(())
}
#[cfg(all(target_os = "linux", feature = "webpki-roots"))]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use ergon_io::{Config, Target, WsOptions, tls};
let mut args = std::env::args().skip(1);
let limit: u64 = args.next().map_or(Ok(20), |a| a.parse())?;
let config = Config::parse(&args.next().unwrap_or_else(|| "recv-busy".into()))?;
// In production, pin this thread to an isolated core first.
let tls = tls::client_config(tls::webpki_roots())?;
let target = Target::new("stream.binance.com", 9443, "/ws");
// Limits, pings and timeouts: WsOptions.
let dial = move || WsDial::with_options(target.clone(), tls.clone(), WsOptions::default());
let mut app = App::new(Retry::default(), dial, limit)?;
run(config, &mut app)?;
println!("{} messages, {} connection(s)", app.messages, app.opens);
Ok(())
}
(From io/examples/websocket.rs. cargo test -p ergon-io builds it, and
io/tests/example_websocket.rs runs it against a local server on every
backend: a server close, the reconnect, the second subscribe and the clean
close.)
Run it (Linux):
cargo run -p ergon-io --features webpki-roots --example websocket -- 20 recv-busy
- Arguments: messages to print before closing, then the backend configuration.
- The feed is Binance’s public spot stream; any public feed will do.
Options
WsOptions, on WsDial::with_options or WsConn::connect_with:
| option | default | what it does |
|---|---|---|
buf_size | 1 MiB | receive buffer; every frame must fit |
max_message | 0 (what the buffer holds) | a larger message closes the connection; a fragmented message may exceed the buffer |
ping_interval_ms | 10,000 | Ping after this long with nothing received; a busy connection sends none; 0: off |
pong_timeout_ms | 5,000 | nothing received this long after a Ping: Error::Timeout("websocket pong"); 0: off |
idle_timeout_ms | 0 (off) | nothing received this long: Error::Timeout("websocket idle") |
close_timeout_ms | 1,000 | longest wait for the other side of a close handshake; 0: no wait |
validate_utf8 | off | invalid text closes the connection; costs one more pass over each text message |
protocols | none | subprotocols offered; WsConn::protocol is the server’s pick |
- Any byte received after a Ping counts as the answer, not only a Pong.
- Server Pings are answered at once.
Close
| who starts | what happens | on_closed gets |
|---|---|---|
| the server | its Close is answered with the same code; then the client waits up to close_timeout_ms for the server to close TCP | Error::WsClose, by_server: true |
the client: close(code, reason) | sends a Close; nothing more can be sent; waits up to close_timeout_ms for the server’s Close | Error::WsClose, by_server: false; or Error::Timeout("websocket close") |
the client: Driver::close(id) | drops the socket at once, no Close | nothing |
- Data that arrives after a server Close is dropped.
- A protocol error drops the connection (RFC 6455 allows it): no Close 1002.
Reconnect
Reconnect is optional. You own it (in your handler, as in the example)
and feed it every open and close.
Retry field | default | meaning |
|---|---|---|
initial_ms | 250 | first delay after a close |
multiplier | 2 | each failed attempt multiplies the delay |
max_ms | 30,000 | longest delay |
jitter | 0.2 | each delay cut by up to 20 %, at random |
seed | 0 (random) | a fixed seed repeats the delays (tests) |
max_attempts | 0 (never give up) | failed attempts in a row before GaveUp |
stable_ms | 60,000 | open this long resets the backoff |
background | true | connect with connect_background (handshake off the loop) |
on_open(id)returnsOpened { link, reconnect }: subscribe either way; withreconnect, resync any state kept from the old connection.on_closed(id)returns the next step:Retry,GaveUporStopped.stop(link)vetoes reconnects;resume(link)starts again.Driver::closereports nothing: callon_closed(id)yourself after it.pollstarts due attempts. It costs one branch while nothing waits.
Connect limits and the server’s wait
Venues limit connect attempts per IP (Binance: 300 per 5 minutes; OKX: 3
per second). One Budget bucket is the gate of every link on that IP:
// Connect attempts per IP: 3 per second (OKX). One bucket, the gate of
// every WebSocket link on that IP.
let mut budget = Budget::new(vec![BucketSpec::rolling("ws-connects", 3, 1_000, 10)])?;
let connects = budget.id("ws-connects").ok_or("no bucket")?;
let mut links = Reconnect::new(Retry::default())?;
// Once per loop pass; `now` is the budget's clock, read once.
let started = links.poll_at_gated(&mut f, now, || {
budget
.try_charge(&[Charge::new(connects, 1)], Class::Normal, now)
.map(drop) // passed: connect now
.map_err(|l| l.wait_ns(now)) // refused: ask again after this
});
(From io/tests/reconnect.rs, which cargo test -p ergon-io runs on
every platform, with a stand-in connector and a fake clock.)
poll_gated(driver, gate)starts an attempt only when the gate passes it, the first attempt afteraddincluded: 50 links added at once start at the gate’s pace, not in one burst.- A refusal is not a failed attempt: the backoff does not grow. The link
asks again after the gate’s wait, at most
max_mslater. - Each pass starts after the last link the gate passed, so links that keep failing cannot starve the others.
defer(link, wait_ns): no attempt before then. It stays with the link across later closes andresume.
A refused handshake, in on_closed:
// Binance's documented minimum for a 418: 2 minutes.
let ban_min = NonZeroU64::new(120_000).ok_or("zero")?;
// In `Handler::on_closed(id, error)`: a refused handshake holds the
// connect bucket (429) or halts it (418), and the link waits it out.
let on_closed = |rc: &mut Reconnect<u32>, budget: &mut Budget, id, error: &Error, now| -> R {
let link = rc.link_of(id).ok_or("not a link")?;
if let Some(Verdict::Throttled { until_ns, .. } | Verdict::Banned { until_ns, .. }) =
outcome::refused_upgrade(
error,
connects,
budget,
ban_min,
Now {
ns: now,
real_ns: now,
},
)?
{
rc.defer_at(link, u64::try_from(until_ns - now)?, now);
}
rc.on_closed_at(id, now);
Ok(())
};
- 429: the bucket is held for
Retry-After(or its backoff without one). - 418: the bucket is halted. No link on that IP connects again until a
person calls
Budget::clear_halt, even after the server’s wait. - Give the HTTP pool a bucket of its own (HTTP client): venues count WebSocket connects.
Sending
send_text,send_binary,ping, orsend_frame(opcode, payload).- A message larger than one TLS record goes out over several.
- On a driver-owned connection:
driver.get_mut(id), then the same methods. - Sends go out on the next
poll, or at once withDriver::flush. - Handlers cannot send (they have no driver): remember the id, send after
pollreturns (as the example does). stats().auto_framescounts the frames the connection sends by itself: Pongs, Pings on a quiet connection, the echo of a server Close. A venue that limits messages per connection may count them (Binance spot does): charge the change each pass withBudget::force.
Handshake
- Checks status 101,
Upgrade,ConnectionandSec-WebSocket-Accept. - Any other status is
Error::Upgrade { status, retry_after_ms, .. }: a 429 or 418 can be told apart, andretry_after_msis the server’sRetry-After(seconds or a date). Wait at least that long before connecting again:outcome::refused_upgradeandReconnect::defer(see Reconnect). - Refuses a subprotocol it did not offer.
- Offers no extension and refuses a server that picks one.
- Extra headers (auth, say) go on the
Target:Target::new(host, port, path).with_header(name, value).
Not supported
- Compression (
permessage-deflate) and other extensions: decompressing breaks in-place parsing and costs CPU per message. - Proxies.
- Sending fragmented messages (receiving them works).
Measured results
Order round trip: a 64-byte echo, one message in flight. Every figure, with its max:
- Fair run, Graviton: all six backends with 1, 4, 16 and 64 sockets on the loop.
- Round trips on x86: 1 connection.
Pick the backend by the total socket count on the loop, feeds included: choosing a backend.
HTTP client
HttpClient keeps a pool of warm HTTP/1.1 connections behind one API. The
implementation is configuration:
HttpImpl | What it is | Use it when |
|---|---|---|
Native (default) | ergon-io’s own client on a Driver: each request is one TLS record, each response is parsed in place | latency matters: on the same interrupt-driven path (epoll-spin), faster at the median than the source-built libcurl (measured results) |
Curl (feature curl) | libcurl, linked in-process, driven without blocking | the endpoint needs redirects, proxies, HTTP/2, other auth schemes or compression |
- Same code for both: switching is a configuration change.
- Linux only (like the
Driver). - One level down,
HttpConnis a single keep-alive connection on your ownDriver(the API reference has it).
Example
The handler gets every result, keyed by request id:
#[derive(Default)]
struct Seen {
ok: Vec<(u64, u16, String)>,
errors: Vec<(u64, Sent, String)>,
}
impl ClientHandler for Seen {
fn on_response(&mut self, request: u64, r: &Response<'_>) {
self.ok.push((
request,
r.status,
String::from_utf8_lossy(r.body).into_owned(),
));
}
// `Sent::No`: the server cannot have seen it; `Sent::Maybe`: it may
// have run (reconcile an order by its client order id).
fn on_error(&mut self, request: u64, sent: Sent, error: Error) {
self.errors.push((request, sent, error.to_string()));
}
}
Native:
/// The native implementation: `recv-busy`, connections opened in the
/// background.
fn native(lab: &Lab) -> R<HttpClient> {
let mut cfg = HttpClientConfig::new(HttpImpl::Native);
cfg.pool = 2; // warm connections, one request in flight each
// A pod with one CPU has no core for the connect helper: opt in to
// opening on the loop there (without this, `new` returns `Error::Cpu`).
cfg.driver.inline_connect_fallback = true;
let client = HttpClient::new(&cfg, lab.target("")?, lab.client_cfg.clone())?;
Ok(client)
}
libcurl:
/// libcurl (feature `curl`): the same API. Without the feature, `new`
/// returns `Error::Unsupported`.
fn curl(lab: &Lab) -> R<HttpClient> {
let mut cfg = HttpClientConfig::new(HttpImpl::Curl);
cfg.pool = 2; // the most connections libcurl may use at once
cfg.curl_ca_file = Some(lab.ca_file()?.display().to_string()); // libcurl's trust store
let client = HttpClient::new(&cfg, lab.target("")?, lab.client_cfg.clone())?;
Ok(client)
}
One request, the same code for both:
/// One GET, the same code for both implementations: wait for a free
/// connection, send, then poll until the answer.
fn get(client: &mut HttpClient, path: &str) -> R<(u16, String)> {
let mut seen = Seen::default();
let end = Instant::now() + Duration::from_secs(10);
while client.idle() == 0 {
client.poll(&mut seen)?; // the pool connects in the background
if Instant::now() > end {
return Err("no connection".into());
}
}
let id = client.request("GET", path, &[], &[])?; // returns at once
while seen.ok.is_empty() && seen.errors.is_empty() {
client.poll(&mut seen)?; // one non-blocking pass
if Instant::now() > end {
return Err("no answer".into());
}
}
let (got, status, body) = seen.ok.pop().ok_or_else(|| format!("{:?}", seen.errors))?;
assert_eq!(got, id);
Ok((status, body))
}
(From io/tests/client.rs. cargo test -p ergon-io runs get through
the native client against a local TLS server on Linux; with
--features curl, through libcurl too. lab is that server: in your code
use Target::new("api.binance.com", 443, "") and
tls::client_config(tls::webpki_roots())?.)
Rules
- One request in flight per connection. All busy:
Error::Busyat once, no hidden queue. Each pooled connection is one more socket the loop polls: read many connections before sizingpool. - A timed-out request closes its connection (
request_timeout_ms, 5 s): HTTP/1.1 cannot drop one request on a live connection. The pool opens a replacement. Both implementations report it asError::Timeout("http request"). - A failed request says whether it was sent:
on_error(request, sent, error).Sent::No: the request never reached the kernel whole, so the server cannot have seen it. Safe to send again.Sent::Maybe: it may have run. Look an order up by its client order id; never send it again blindly.
- libcurl takes no method but GET, HEAD, OPTIONS and DELETE
(
Error::CurlResend) unlesscurl_allow_non_idempotent_methodsis set. Why: libcurl sends a request again, up to 5 times, when the reused connection it went out on dies before any reply byte, so an order could be placed twice (io/tests/client.rsshows it happening). The native client never re-sends: send orders through it. - Connections open in the background (
background_connect, on by default): the handshake runs off the loop, unless the driver’sinline_connect_fallbackis set. A failed connect goes toClientHandler::on_connect_errorand is retried with backoff. - Reconnects spread out: a dropped connection reopens after a random
delay of up to 100 ms (the limit doubles per failed connect, up to 5 s),
so connections that drop together do not reconnect together.
reconnect_seedfixes the delays (tests). - A venue’s connect limit (native only): with
connect_gate,newopens nothing and every connect waits for the gate passed topoll_gated;pollthen refuses. A refusal does not grow the backoff. Give the pool aBudgetbucket of its own: venues’ connect limits count WebSocket connects. buf_size(1 MiB) is each native connection’s receive buffer: it must hold the largest response.- Responses:
Content-Lengthor chunked bodies. The body inon_responseborrows the client’s buffer: copy what you keep. - libcurl:
curl_drivepickscurl_multi_performon every poll (default) orcurl_multi_socket_action; both never block. Compared once on Azure at 1,000/s:curl_multi_performwas 1–6 µs ahead at the median in both rounds and had the higher max (data). Trust comes fromcurl_ca_fileor the system store, not from the rustls configuration.
Signing
Signer signs request parameters as Binance’s signed endpoints expect,
without allocating. HttpClient::signed_request appends the timestamp and
the signature itself; signed_request_at takes the timestamp from you
(your clock, or replay time).
| signer | use it for |
|---|---|
Signer::hmac_sha256 | signed REST requests |
Signer::ed25519_pkcs8 | log-on, once per session; the private key never leaves the machine. About 80 times HMAC’s cost: measure before putting it on a hot path |
Cost per signature: request signing.
Checked against Binance’s documented example:
/// Binance "SIGNED endpoint" example (HMAC-SHA256, parameters in the query).
const SECRET: &str = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
const PARAMS: &str =
"symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000";
const TS: u64 = 1_499_827_319_559;
const SIGNATURE: &str = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71";
#[test]
fn hmac_matches_binance_documented_example() -> R {
let signer = Signer::hmac_sha256(SECRET.as_bytes());
assert_eq!(signer.kind(), SignatureKind::HmacSha256);
let mut out = Vec::new();
auth::append_signed_params(&mut out, PARAMS, TS, &signer);
let expected = format!("{PARAMS}×tamp={TS}&signature={SIGNATURE}");
assert_eq!(String::from_utf8(out)?, expected);
Ok(())
}
(From io/tests/auth.rs; cargo test -p ergon-io runs it on every
platform.)
- Set the API key once as a header:
Target::with_header("X-MBX-APIKEY", key).
Measured results
Every figure, with its max: HTTP clients (1 connection, x86) and the fair run (pool of 1 and 20, Graviton).
- Native against libcurl, same interrupt path: native is ahead at the median on Azure and AWS. Its p99 and max are lower on Azure, not on AWS.
HttpClientwith a pool of 1 was within 1 µs of a rawHttpConnat the median and p99; one of its rounds had a higher max.- reqwest parks its thread between requests and trailed both on Azure.
Many connections
Measured on Graviton with a pool of 20 on one loop, one request in flight
(fair run):
uring-sqpoll and the interrupt backends lead, per_queue is behind them
and plain recv-busy further behind.
- Count the pool with every other socket the thread polls: feeds,
order connections, other pools. Each
HttpClienthas its ownDriver, so its poll adds to theirs. Then pick: choosing a backend. - Set it with
HttpClientConfig::driver, for exampleConfig::parse("recv-busy,per_queue=true")?.
Measure it on your own two hosts with io/benches: one run per backend,
and --pool 1 as the reference.
cargo build --release --manifest-path io/benches/Cargo.toml --bin replay_server --bin http_bench --bin analyse
B=io/benches/target/release
# sender: a keep-alive HTTP server; copy cert-http.pem to the receiver.
# It spins one thread per connection: run it unpinned, with more free CPUs than connections.
sudo $B/replay_server --listen 0.0.0.0:9445 --cert-out cert-http.pem --mode http --busy-poll 50
# receiver, loop on isolated CPU 2: 20 pooled connections, one request in flight, 10,000/s
sudo $B/http_bench --client client --backend recv-busy --pool 20 --server SENDER:9445 --ca cert-http.pem \
--sni lab.local --cpu 2 --rate 10000 --duration 60 --out res/http20-recv-busy
# then the same with: --backend recv-busy --per-queue (out res/http20-per-queue),
# --backend epoll-spin (res/http20-epoll-spin), --backend uring-sqpoll --sqpoll-cpu 3 (res/http20-sqpoll)
$B/analyse --warmup-s 2 res/http20-*
- On AWS, set the NIC on the node first (node-level):
ethtool -C <nic> adaptive-rx off rx-usecs 0, with the NIC’s interrupts on a core of their own, as in the fair run. - Compare p50, p99 and max per backend; repeat in reversed order.
Rate Limits and Errors
Venues limit how fast you send. Break a limit and they refuse requests; keep going and they ban your IP. ergon-io checks each request against the limits before it goes out, and tells you what became of it after.
- It never sleeps, queues or retries. You decide: wait, drop or alert.
- No venue tables in the published crate: venues change them without
notice. Binance, Bybit and OKX adapters live in the repository’s
io/examples/venues.rs.
What venues do
Checked 2026-10-17 against each venue’s docs (linked). Values change: load
them at start where the venue publishes them (Binance exchangeInfo).
Where a number is not on the linked page, the source is named in the cell.
| venue | limits | usage reported | on breach | ban |
|---|---|---|---|---|
| Binance (spot) | per IP and per account, from exchangeInfo (the docs’ own example: weight 6,000/min, raw requests 61,000/5 min, orders 50/10 s and 160,000/day — a live exchangeInfo gives yours) | X-MBX-USED-WEIGHT-1M, X-MBX-ORDER-COUNT-10S/-1D headers; WS API rateLimits | 429 + Retry-After (s); body -1003 (weight), -1015 (orders); 403: the WAF limit | sending after a 429: 418, an IP ban of 2 min to 3 days (errors) |
| Bybit (v5) | per IP: 600/5 s; per endpoint and account: a rolling 1 s limit (create order 10–20/s) | X-Bapi-Limit, X-Bapi-Limit-Status (left), X-Bapi-Limit-Reset-Timestamp | HTTP 200 with retCode 10006 (endpoint) or 10018 (IP); 429: system protection (error) | 403 “access too frequent”: IP banned at least 10 min |
| OKX (v5) | per instrument: place, cancel, amend each 60/2 s; per sub-account: 1,000 orders/2 s; batch-orders 300/2 s; options share a family | none | code 50011 (often with HTTP 200 as well as 429); 50061: sub-account | none documented |
| Coinbase (Advanced Trade) | private 30/s per user; public 10/s per IP (rate limits) | none | 429 | none documented |
| Kraken (spot) | draining counters: REST (max 15–20) and trading per pair (60–180; a quick cancel costs up to 8) | none: model it | EAPI:Rate limit exceeded, EOrder:Rate limit exceeded, EService: Throttled: <time> | a lockout of seconds, longer if you keep calling; about 150 WS connects per 10 min per IP, else 10 min |
| Deribit | credits per sub-account, refilled continuously: 20 requests/s, burst 100 (matching engine: by volume tier) | none | too_many_requests (10028), and the session ends: connect and log in again | none documented |
| Hyperliquid | per IP: weight 1,200/min; per address: 1 request per 1 USDC traded (10,000 to start) | userRateLimit info request | the address drops to 1 request per 10 s | none documented |
- One order counts against several limits at once: a Binance order against IP weight, raw requests, orders per 10 s and orders per day.
- Errors often come as HTTP 200 with a code in the body (Bybit, OKX). Classify on status and code.
- WebSocket limits count control frames (Binance spot: 5 messages/s, Pongs included) and connect attempts per IP (Binance 300/5 min, OKX 3/s).
- HTTP status is not the code. The same body code can arrive with 200, 429 or another status: read the code.
What ergon-io gives you
| piece | what it does |
|---|---|
limit::Budget | Your buckets, one per limit. try_charge takes up to 8 at once, all or none: a ticket, or “not before T”. refund gives a ticket back when the request never left. |
Model::Rolling | A window limit (Binance, Bybit, OKX, connect attempts). Never more than the limit in any window of window + guard, whatever window edges the venue uses. guard_ms defaults to one sub-window; set it to at least your send-to-venue jitter. |
Model::Decay | A draining counter (Kraken, Deribit, Coinbase Exchange). Not for window limits: it lets twice the limit through in one window. |
reserve, Class::Reduce | Units only cancels and reduce-only orders may use, so you can always cancel. |
observe_used, set_limit | What the venue’s headers say: raise the local count, never lower it. |
throttled, banned | A 429 holds the bucket for the venue’s wait plus the guard; with no wait it also marks the bucket full for one window. A ban halts it until clear_halt. |
try_resend, Budget::resend | Re-send a request that did not run, only inside its own deadline. Budget::resend takes the verdict: Unknown answers Reconcile, never re-send. |
outcome::Sent | For a failed request: No (never left; safe to send again) or Maybe (may have run). In ClientHandler::on_error. |
outcome::Verdict, generic | What became of a response: done, rejected, not run, throttled, banned, clock skew, unknown. generic uses the status alone; your adapter reads the body code first. |
retry_after_ms, header_u64, ms_until_epoch | Header parsing with no allocation. A malformed header is an error, never 0. |
| connect gate | ClientHandler::connect_gate (HTTP pool) and Reconnect::poll_gated (WebSocket): every connect passes a bucket first (WebSocket). The gate lives on the handler, so it shares the handler’s budget. |
WsStats::auto_frames | Frames the connection sent by itself (Pongs): charge them with Budget::force. |
One order, start to end
A Binance adapter: limits at start, the charge before sending, the verdict after:
// At start: Binance's limits from `GET /api/v3/exchangeInfo`.
let limits = binance::limits(example::BINANCE_EXCHANGE_INFO.as_bytes())?;
let (b, mut budget) = binance::Binance::new(limits)?;
// Before sending: every bucket the order counts against, or none.
let ticket = budget.try_charge(b.new_order()?.as_slice(), Class::Normal, T0)?;
// On its response (`on` replays a recorded one): usage, then the verdict.
let v = on(example::BINANCE, "order-ack", |r| {
b.on_response(r, &mut budget, NOW)
})??;
What it reads in a response:
match r.status {
// Bans (403: the WAF limit, 418): the IP scope stops until a
// person clears it. Before any header: a ban always halts.
// The gate every request and every connect charges, so one
// ban stops them all.
403 | 418 => return super::ban(r, self.ip, BAN_MIN_MS, budget, now),
_ => {}
}
let code = match super::json_body(r)? {
Some(body) if r.status >= 400 => {
body.get("code")?.map(json::Value::as_i64).transpose()?
}
_ => None,
};
// 409 cancel-replace: -2022 ran nothing (both halves failed);
// -2021 may have run one half.
if r.status == 409 {
return Ok(match code {
Some(-2022) => Verdict::NotRun,
_ => Verdict::Unknown,
});
}
let verdict = match code {
Some(-1003) => {
// "Too many requests queued." is a queue depth, not the
// weight window: filling the weight bucket would stop
// everything on the IP for a whole window. The queued
// variant holds the raw-requests scope, else a short hold
// on weight that does not fill it.
if super::contains_ignore_case(r.body, b"queued") {
if let Some(raw) = self.raw {
super::throttle(r, raw, budget, now)
} else {
let wait = outcome::retry_after_ms(r, super::utc_ms(now))?.or(Some(0));
let until_ns = budget.throttled(self.weight, wait, now.ns)?;
Ok(Verdict::Throttled {
bucket: self.weight,
until_ns,
})
}
} else {
super::throttle(r, self.weight, budget, now)
}
}
Some(-1015) => self.too_many_orders(r, budget, now),
Some(-1021) => Ok(Verdict::ClockSkew),
// -1006, -1007 and -1008: -1006/-1007 lost track of the
// request; spot -1008 is only "server is currently
// overloaded" and does not say the order failed. All three
// mean it may have run.
Some(-1008..=-1006) => Ok(Verdict::Unknown),
// Status alone: 2xx done, 429 throttled, 4xx rejected, 5xx unknown.
_ => outcome::generic(r, self.weight, budget, now),
}?;
// Server truth, after the hold or halt stands: other senders on
// this IP or account show up here. A malformed usage header is
// reported but does not undo the hold.
for (name, bucket) in &self.usage {
if let Some(used) = super::usage(r, name)? {
budget.observe_used(*bucket, used, now.ns)?;
}
}
Ok(verdict)
What to do next with the order:
/// The re-send rules: only what did not run, through the budget, inside
/// its own deadline; never an order that may have run.
///
/// [`Budget::resend`] makes that last rule structural: it answers
/// `Reconcile` for an unknown outcome instead of a re-send.
#[must_use]
pub fn next(order: &Order, verdict: Verdict, budget: &mut Budget, now_ns: i64) -> Next {
match verdict {
Verdict::Done => Next::Applied,
Verdict::Rejected => Next::Dropped,
Verdict::Banned { bucket, .. } => Next::Halted(bucket),
_ => match budget.resend(
verdict,
order.parts.as_slice(),
order.class,
order.deadline_ns,
now_ns,
) {
Resend::Send(ticket) => Next::SendNow(ticket),
Resend::Wait(limited) => Next::RetryAt(limited.ready_at_ns),
Resend::Stale(_) => Next::Dropped,
Resend::Reconcile => Next::Reconcile(order.client_order_id),
},
}
}
(From io/examples/venues.rs and io/tests/example_venues.rs, which
cargo test -p ergon-io runs on every platform against responses written
from each venue’s docs, in io/tests/fixtures/venues/.
cargo run -p ergon-io --example venues prints each decision.)
Over the wire, one order with the pooled client:
try_charge→ keep theTicketby request id.HttpClient::requestreturns at once; the answer comes toClientHandler::on_responseoron_error.on_response→ the adapter’son_responseabove, thenBudget::resendfor the verdict.on_error(request, sent, error)→Sent::No:refundthe ticket andBudget::resend;Sent::Maybe:Unknown, reconcile by client order id.
Error policy
| what came back | verdict | ran? | what to do |
|---|---|---|---|
| 2xx, no error code | Done | yes | apply it |
| 400, 401, 404, other 4xx; 3xx | Rejected | no | fix it; never send it again unchanged |
| 408 | Unknown | maybe | look it up by client order id |
| 429, Binance -1003/-1015, Bybit 10006/10018, OKX 50011/50061 | Throttled | no | the bucket the error names is held; send again only after the hold, inside its deadline |
| Binance 418 or 403, Bybit 403 (any) | Banned | no | the IP stops until a person clears it; alert |
| Binance -1021, Bybit 10002, OKX 50102 | ClockSkew | no | send again with a new timestamp, inside its deadline |
| Binance -1008 (overloaded), -1006/-1007 | Unknown | maybe | look it up by client order id |
| 409 with Binance -2022 (both halves failed) | NotRun | no | send again, inside its deadline |
| 5xx, Binance 409 with -2021, Bybit 10000/10016, OKX 50004 | Unknown | maybe | look it up by client order id |
no response, Sent::No | NotRun | no | refund the ticket; send again, inside its deadline |
no response, Sent::Maybe (timeout, reset) | Unknown | maybe | look it up by client order id |
- A timeout is not a free re-send. A 408, and a Binance -1007
(“timeout waiting for response from backend server”), are
Unknown: the backend may have taken the order. Re-send only what the venue says did not run (a Binance -2022), never what might have. - The code decides before the status. On a 409 the body code picks
NotRun(-2022) orUnknown(-2021). - Hold the narrowest bucket the error names. A -1015 for 10 s holds the 10 s order bucket; cancels (not orders) still go.
- A hold blocks cancels too. On Binance a request sent into a 429 risks a ban. Arm the venue’s cancel-on-disconnect before trading: it is the only cancel path during a hold.
- Refund only
Sent::NoandBusy. Venues count rejected and throttled requests too. - A server wait is the server’s answer. A 429 with
Retry-Afterholds for that wait plus the guard; the bucket keeps its count. With no wait, the bucket is marked full for one window (the local count was wrong). - A ClockSkew that repeats is the host clock. Re-signing with a new timestamp fixes one; if they keep coming, the clock is off: fix the host clock (chrony; see Clocks), not the re-send.
Rules
- A ban stops sending until a person clears it.
Budget::bannedhalts the scope. The end of the venue’s wait does not resume it:Budget::clear_halt, called by a person’s action, does.Budget::halted()andBucketStats::haltedshow it, so the loop can alert. Halt an IP-scope gate that every request and every connect charges, so one ban stops them all. The minimum waits differ: Binance 418 is 2 minutes (to 3 days for repeats); Bybit 403 is 10 minutes. A second ban costs more than a pause. - One sender per account and IP. The budget sees only this process. Budgets are not shared between processes.
- Send again only what did not run, and only inside its deadline
(
Throttled,ClockSkew,NotRun). The application sets each order’s deadline (a few ms, say).Budget::try_resendcharges it again, or says when to ask, or calls it stale. - Never send an order with an unknown outcome again. Every order carries a unique client order id; look it up by that id (or watch the user data stream). Only Coinbase Advanced Trade returns the existing order for a repeated id.
- libcurl takes no method but GET, HEAD, OPTIONS and DELETE unless you opt in (below).
A ban, step by step:
let v = on(example::BINANCE, "ban-418", |r| {
b.on_response(r, &mut budget, NOW)
})??;
// Retry-After 300 s. The IP gate has no window to align to, so no
// guard: this is the end of the server's wait. The halt does not end
// there; a person clears it.
assert_eq!(
v,
Verdict::Banned {
bucket: b.ip(),
until_ns: T0 + 300 * S
}
);
// Ten minutes later the server's wait is long over: still stopped.
let later = T0 + 600 * S;
assert_eq!(why(&mut budget, &b.request(1)?, later), Some(Why::Banned));
// Every connect is stopped too: it charges the same gate.
assert_eq!(
why(&mut budget, &b.connect(), later),
Some(Why::Banned),
"a banned IP must not reconnect"
);
// The loop's alert check names it.
let mut alerted = Vec::new();
example::alerts(&budget, later, |name, stats| {
alerted.push((name.to_owned(), stats.bans));
})?;
assert_eq!(alerted, [("ip".to_owned(), 1)]);
// A person decides; then sending resumes.
budget.clear_halt(b.ip())?;
assert_eq!(why(&mut budget, &b.request(1)?, later), None);
assert_eq!(why(&mut budget, &b.connect(), later), None);
What it cannot do
- See other senders. The venue counts every process, host behind the same NAT and script on the same key or IP. Usage headers show them late (one round trip) and only in part; Kraken, Coinbase, Deribit and OKX send none. So run one sending process per account and IP. Put bulk REST on another IP, or in the same process and budget.
- Know a WebSocket request’s fate when its connection drops: ergon-io does not know WebSocket request ids. Treat pending ones as unknown.
- Retry or queue. Only the application knows whether a request is still wanted, and it must sign it again with a new timestamp.
libcurl sends requests twice
- What happens: when a reused connection dies before any reply byte,
libcurl sends the request again, POST included, up to 5 times
(
Curl_retry_requestinlib/transfer.c, curl 8.22.0). No option turns this off. An order that ran just before the connection died runs twice. - What ergon-io does: the libcurl
HttpClientrefuses every method but GET, HEAD, OPTIONS and DELETE, withError::CurlResend, unlesscurl_allow_non_idempotent_methodsis set.io/tests/client.rsshows libcurl re-sending, and the refusal. - Send orders through the native client: it never re-sends.
Cost
try_charge, the check before each request, is on the send path. Its
cost is not measured here: no committed run holds it. Measure it with
io/benches limit_bench:
cargo build --release --manifest-path io/benches/Cargo.toml --bin limit_bench
io/benches/target/release/limit_bench --samples 200000 --batch 64 # Linux: --cpu <isolated cpu>
- The bench reports ns per call (the mean of a
--batch-call batch, not a per-call percentile), the slowest batch, stalls andallocs(allocations in the timed loop; expect 0). - Catch-up walk. A ring stepped just under one full turn walks every
slot instead of taking the
fill(0)path a longer idle would.Budget::tickduring idle keeps this off the send path: the send path only ever pays for the slots since the last charge. - macOS cannot pin or isolate the thread; a quiet max needs an isolated Linux CPU.
- Inside ergon-io the send path adds one store per request and one add per
send(forSent), and one add per automatic WebSocket frame.
Kubernetes
Where ergon-io’s helper threads run inside a pod. On a host they run on the housekeeping CPUs. In a pod there are none. This page says what to do instead, for every pod size.
Terms: io glossary, deployment glossary.
Why a pod has no housekeeping CPUs
- On a host, the housekeeping CPUs are the ones the kernel leaves out
of
isolcpus: everything except the isolated cores. - Those files describe the node, not the pod.
/sys/devices/system/cpu/{online,isolated}are host-wide. A pod runs only on the CPUs the kubelet gave it: its cpuset (cpuset.cpus.effective). A Guaranteed pod with the static CPU manager gets exactly its whole-numbercpurequest. The node’s housekeeping CPUs are outside that set. - A thread outside the cpuset never starts. The kernel refuses the
mask (
EINVAL), andio_uring_setuprefuses anSQPOLLCPU outside it the same way. - So the helper threads run on the pod’s own CPUs.
Driver::newreads the pod’s cpuset once and chooses from it (placement):helper_cpus/ERGON_HELPER_CPUSfirst, then the loop CPU’s SMT sibling, then the pod’s other CPUs, preferring ones the host does not isolate. Never the polling CPU when it is known: setloop_cpu, or pin the loop thread beforeDriver::new. When it is not known the choice cannot keep off it, and the start-up line says the helpers may include the CPU the loop ends up on. The line reports the choice.
Pod layouts
Every layout: Guaranteed QoS (requests equal limits), a whole-number CPU
request, and the kubelet’s cpuManagerPolicy: static (on x86 with SMT
on, cpuManagerPolicyOptions: full-pcpus-only) — the node settings are
under deployment: Kubernetes.
| pod | the loop | the helpers (dial, warnings, SQPOLL) | what to configure |
|---|---|---|---|
cpu: 1 | the only CPU | none: they would share the loop’s CPU | Driver::connect, or connect_background with inline_connect_fallback=true; recv-busy (per_queue=true past 4 sockets) |
x86 with SMT, cpu: 2 | one hyperthread | the other hyperthread (the loop’s SMT sibling) | nothing |
Graviton, cpu: 2 | one core | the other core | nothing |
| shared CPUs | one exclusive CPU | dial and warnings on the shared CPUs the node added; SQPOLL on a CPU of the pod’s own outside them (never a spinner on shared CPUs) | helper_cpus or ERGON_HELPER_CPUS; sqpoll_cpu only to override that default |
Minimal pod, cpu: 2 (change to "1" for one CPU). Requests equal
limits, and the CPU request is a whole number:
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
resources:
requests: { cpu: "2", memory: 1Gi }
limits: { cpu: "2", memory: 1Gi }
cpu: 1
connect_background refuses at once with Error::Cpu (NoSpareCpu)
rather than start a dial helper on the polling CPU, and uring-sqpoll
refuses at Driver::new. The warning thread shares the core, parked
until there is work. One warning line at start says all this.
/// A pod with one CPU (`cpu: 1`). `connect_background` refuses: its dial
/// helper would time-slice the polling thread. With
/// `inline_connect_fallback=true` it connects as `Driver::connect` does
/// (the TLS handshake on the loop, about 0.7 ms of CPU once per connect
/// or reconnect). Never `uring-sqpoll` here: it is refused at
/// `Driver::new`.
fn one_cpu_pod() -> ergon_io::Result<Config> {
Config::parse("recv-busy,inline_connect_fallback=true")
}
- With
inline_connect_fallback=true,connect_backgroundconnects asDriver::connectdoes: the TLS handshake runs on the loop, a long poll per connect or reconnect (connection set-up). A helper thread sharing the loop’s CPU was not measured. - For production, give the pod 2 CPUs (the warning line ends with
that).
ERGON_HOST_CHECKS=offsilences the line; the typed refusals stay.
x86 with SMT, cpu: 2: one physical core
cpuManagerPolicyOptions: full-pcpus-only: the pod holds one core’s two
hyperthreads. The loop takes one; SQPOLL, the dial helper and the
warning thread take the other. Nothing to configure.
/// x86 with SMT, `cpu: 2` and the kubelet's `full-pcpus-only`: one
/// physical core, its two hyperthreads. The loop takes one; `SQPOLL`, the
/// dial helper and the warning thread take the other (the loop's SMT
/// sibling). Nothing to configure.
fn x86_two_cpu_pod() -> ergon_io::Result<Config> {
Config::parse("uring-sqpoll")
}
What the SQPOLL thread on the sibling costs against a core of its own: SMT sibling cost.
- It needs both hyperthreads and nothing else on the sibling.
- For a core of its own instead: a
cpu: 4pod (two physical cores) withsqpoll_cpuon the second core, or SMT off on the node.
Graviton, cpu: 2: two cores
Graviton has no SMT: two vCPUs are two cores. The loop takes one; the helpers take the other. Nothing to configure.
/// Graviton (no SMT), `cpu: 2`: two cores. The loop takes one; the
/// helpers take the other. Nothing to configure.
fn graviton_two_cpu_pod() -> ergon_io::Result<Config> {
Config::parse("recv-busy")
}
Shared CPUs from the node
A node-level mechanism can add shared CPUs to the pod’s cpuset for the
helper threads (dial, warnings), and keep the loop on its exclusive
CPU: deployment: shared CPUs. Name those
CPUs in Config::helper_cpus, or let the mechanism set
ERGON_HELPER_CPUS (same value: ERGON_SHARED_CPUS).
/// Shared CPUs a node-level mechanism added to the pod's cpuset: name
/// them for the helper threads. A CPU outside the process's CPUs is
/// refused (`Error::Cpu`), never used.
fn shared_cpu_pod() -> ergon_io::Result<Config> {
Config::parse("recv-busy,helper_cpus=3-4")
}
/// The same from the pod's environment: `ERGON_HELPER_CPUS` is a kernel
/// CPU list (`3`, `3,7`, `2-3`). `Config::helper_cpus` wins when both are
/// set. `Driver::new` reads the variable; the application does not.
const fn helper_cpus_env_name() -> &'static str {
HELPER_CPUS_ENV
}
env:
- name: ERGON_HELPER_CPUS
value: "3-4" # a kernel CPU list: 3, 3,7, 2-3
- A CPU outside the pod’s cpuset is refused (
Error::Cpu,Outside), never used.Config::helper_cpuswins over the variable. - Keep
uring-sqpolloff shared CPUs: itsSQPOLLthread spins (io_uring_setup(2),IORING_SETUP_SQPOLL). The default never puts it there: with nosqpoll_cpuit takes the lowest CPU of the pod’s own outside that list, and refuses (Error::Cpu,NoExclusiveCpu) when the list holds every CPU but the loop’s. Then setsqpoll_cputo an exclusive CPU of the pod, or pick a single-CPU backend (recv-busy,per_queue). The dial helper and the warning thread are the ones that belong on the shared CPUs.
Pin the loop thread inside the pod
The kubelet gives the pod its CPUs; it never pins threads to them. Each
busy thread pins itself to one CPU of the pod’s cpuset before
Driver::new, so the host checks and the helper choice know the polling
CPU. Node settings: deployment: Kubernetes.
/// Pin the loop thread to one of the pod's CPUs, before `Driver::new`.
/// The kubelet gives the pod its CPUs (its cpuset); it never pins threads
/// to them. `Driver::new` then knows the polling CPU, for its host checks
/// and for the helper-thread choice.
fn pin_loop() -> R {
let (cpus, source) = placement::process_cpus(&Host::system());
let cpu = cpus
.iter()
.next()
.ok_or_else(|| format!("no CPU to pin to: {source}"))?;
ergon_io::clock::pin_current_thread(usize::try_from(cpu)?)?;
Ok(())
}
- The pod’s CPUs:
cpuset.cpus.effective(whatplacement::process_cpusreads), orCpus_allowed_listin/proc/self/status. - Pinned by other means? Name the CPU as
loop_cpuin the backend configuration (backends).
What the driver reports
- One start-up line with the helper CPUs and why
(
HostReport::helper_line), or, in a pod with one CPU, the warning above. Driver::host_report()carries the same (HostReport::helpers).ERGON_HOST_CHECKS=offsilences the warning lines. The typed refusals (Error::Cpu) are never silenced.
ergon-tune
ergon-tune checks a Linux host’s low-latency settings. Each finding says what is wrong, the exact fix, and its evidence: an archived A/B where one exists (evidence), otherwise the kernel’s, Kubernetes’, Aeron’s or a vendor’s documentation and “not measured here”. It changes nothing. ergon-io and the ergon-md-dpdk media driver run the checks for you (where).
AI assistance. Like the rest of ergon, this crate was written with heavy AI assistance (disclosure). It is not production-ready.
[dependencies]
ergon-tune = { version = "0.1", features = ["nic"] } # `nic` adds N1–N4
Glossary
In brackets: the check (T1–N4). Times are in µs.
| term | what it is | pros / cons, or when it matters |
|---|---|---|
| check, finding | A check tests one host (T1–T12, K1–K7) or NIC (N1–N4) setting. A finding is a check that fired, with its fix. | Data it cannot read gives unknown, never a warning. |
| busy-spinning thread | A thread that loops on one CPU and never sleeps. | Never waits to wake up, but takes a whole CPU. Anything else that runs there pauses it. |
| hot, housekeeping CPU | Hot: a CPU a thread busy-spins on. Housekeeping: any other CPU. | Move the kernel’s work and interrupts to housekeeping CPUs. |
| p50, p99, p99.9, p99.99, max | The median; the slowest 1 in 100, 1,000 or 10,000; the slowest of all. The high ones are the tail. | Host noise shows in the tail and max; the p50 often hardly moves. |
| loop gap | A pause seen by a loop that only reads the clock (the TSC, the CPU’s time-stamp counter). | Shows how often something took the CPU, and for how long. |
| interrupt (IRQ) | A device or timer stops the CPU to run a kernel handler. | On a hot CPU, the loop waits for the handler. |
| timer tick | The kernel’s regular timer interrupt, CONFIG_HZ times a second: 1,000 on the AWS lab’s Ubuntu kernel. | nohz_full stops nearly all of it on hot CPUs (T1). |
isolcpus | A boot option that keeps other tasks off the listed CPUs. | Alone, the tick stays on. |
nohz_full | A boot option that stops nearly all timer ticks on the listed CPUs while one task runs there. | Use it with isolcpus (T1). |
rcu_nocbs | A boot option that moves RCU callbacks (the kernel’s deferred clean-up work) off the listed CPUs. | Part of T1’s boot line. Not measured alone. |
| IRQ affinity | The CPUs an interrupt may run on. irqaffinity sets the boot default; /proc/irq/<n>/smp_affinity_list sets one interrupt’s. | NIC interrupts on the loop’s CPU cost the tail (T2). |
| SMT siblings (hyperthreads) | Two hardware threads that share one core. | A busy sibling slows the loop: measured for ergon-io’s SQPOLL thread on the sibling (T10). Graviton, AWS’s Arm CPU, has no SMT. |
real-time scheduling (SCHED_FIFO, SCHED_RR) | Runs a thread ahead of all normal ones. RT throttling caps it at 0.95 s a second by default. | Its CPU’s tick stays on; the latency A/B was inconclusive (T7). |
| CPU quota (CFS quota) | A cgroup’s CPU limit: its threads may run for the quota in each period (100 ms by default). Kubernetes sets one from a container’s CPU limit, docker run --cpus too. | A busy-spinning thread runs at the cap: when the cgroup’s threads use the quota up, the kernel stops all of them until the period ends (T9). |
throttled, nr_throttled | A cgroup stopped because its quota ran out. nr_throttled in its cpu.stat counts the periods it happened in. | Should not grow for a busy-spinning container. cpu.stat.local also counts a parent’s (a pod’s) quota (T9). |
| CPU idle states (C-states), frequency | How deeply an idle CPU sleeps, and how fast it runs. | Deeper sleep saves power but wakes later. Not checked. |
| transparent hugepages (THP) | The kernel’s own 2 MiB pages: always, or where the app asks (madvise). khugepaged builds them in the background. | khugepaged works in the background, under madvise too (on the regions that asked). Not measured with archived data (T3). |
| Aeron directory | Where Aeron, the messaging library under ergon’s transports, keeps its buffers as memory-mapped files. | On a disk filesystem, the kernel writes them back to disk (T4). |
| media driver | Aeron’s process that moves messages between the network and the applications, through the Aeron directory. | The ergon-md-dpdk one runs these checks before it starts. |
| tmpfs, hugetlbfs | Filesystems held in memory. /dev/shm is tmpfs; hugetlbfs uses reserved 2 MiB pages. | T4 wants the Aeron directory on one: nothing is written back to disk. |
| NUMA node | A group of CPUs and the memory nearest them. Large hosts have several. | A NIC on another node makes every packet cross between nodes (T6). Every lab host had one: not measured. |
| automatic NUMA balancing | The kernel unmaps pages to see which CPU uses them, and moves them closer. | Each unmap gives a pinned thread a page fault: a stop to map the page back (T5). Not measured. |
| kernel UDP | UDP through the kernel’s network stack and sockets. Plain: interrupt-driven, no busy polling. | No setup. Against DPDK: ergon-md-dpdk. |
| interrupt-driven | Packets reach the socket through the NIC’s interrupt, not busy polling. | Each packet waits for an interrupt (and any moderation delay: N1). |
| NIC receive queue | A lane the NIC puts arriving packets on. Each has its own interrupt. | After every reset, ENA puts queue i’s interrupt on CPU i, isolated CPUs included (T2). |
interrupt moderation (rx-usecs) | The NIC waits up to rx-usecs µs so one interrupt covers several packets. | Fewer interrupts, later packets (N1). |
| adaptive RX moderation | The driver re-tunes that wait by itself, from a kernel worker. | On AWS that worker pre-empted a busy-polling loop about 18 times a second, and not at all with it off (N1). |
| busy polling | A socket read polls the NIC queue itself instead of sleeping until an interrupt. | ergon-io’s backends: which backend. |
deferred interrupts (napi_defer_hard_irqs, gro_flush_timeout) | The kernel keeps the NIC’s interrupts off and polls from a timer instead, so a busy poller can take the packets. | With no busy poller, packets wait for the timer (N2). |
| RX ring | The NIC’s buffer for arriving packets: how many it can hold. | Too small drops bursts (N4). |
| NIC reset | The driver restarts the NIC: a device reset, ethtool -G or -L, a NIC back from DPDK. | On ENA it undoes T2 and N1: re-apply at each link-up. Checks re-run when carrier_changes moves. |
| ENA | AWS’s virtual NIC (Elastic Network Adapter) and its Linux driver. | N1 checks its adaptive moderation. |
ethtool | Linux’s tool for NIC settings: -C moderation, -G ring sizes, -L queue counts. | N1 and N4 read the same settings over netlink (a kernel socket API), without root. |
| accelerated networking (VF) | Azure gives the VM a virtual function (VF): a slice of the real NIC (SR-IOV). | Without it, traffic takes the hypervisor’s (Hyper-V’s) slower software path (N3). |
| DPDK | A library that drives the NIC from user space, skipping the kernel. | Its gains and costs: ergon-md-dpdk. |
| SQPOLL thread | An io_uring kernel thread that runs the ring’s requests, so the loop makes no system calls. sqpoll_cpu pins it. | It spins, so its CPU is hot: T1 and T8 check it. |
sysfs, /proc | Files under /sys and /proc where the kernel shows its settings. | Most checks only read these, so they also run on a copied tree (Host::at). |
| NAK | An Aeron receiver’s request to resend lost data. | NAKs in bursts with kernel busy polling: check the RX ring (N4). |
| open loop | Each message is timed from when it was due, not when it was sent. | A stall counts against every message it delays. |
CLOCK_REALTIME | The kernel’s wall clock: UTC stamps, kernel and NIC timestamps. A time daemon disciplines it. | A pod shares the node’s: fix time sync on the node (T11). |
| PTP hardware clock (PHC) | A NIC’s or hypervisor’s clock (/dev/ptp0; ENA’s ptp_ena, Hyper-V’s ptp_hyperv) that chrony can lock CLOCK_REALTIME to (refclock PHC). | Tighter than NTP over the network; needs the device on the node (T11). |
| clock source | The counter the kernel keeps time with (current_clocksource): tsc, arch_sys_counter, or hpet, acpi_pm. | Only the counter (or a hypervisor’s view of it) is read without a system call (T12). |
Why check the host
- A busy-spinning thread loops on one CPU and never sleeps. It loses time only when something takes that CPU: a timer tick, an interrupt (IRQ), a kernel thread, a busy hyperthread on its core.
- Those pauses set the tail: p99.99 (the slowest 1 in 10,000) and the max.
- Each comes from a host setting, and a reboot, a new image or a NIC reset can undo it. Check at start-up and again later.
What it checks
| check | setting | why it matters |
|---|---|---|
| T1 | CPU isolation: isolcpus, nohz_full, irqaffinity | isolcpus keeps other tasks off the loop’s CPU. nohz_full stops its timer tick, an interrupt CONFIG_HZ times a second (1,000 on the AWS lab’s Ubuntu kernel). irqaffinity sends new interrupts elsewhere. isolcpus alone leaves the tick on. |
| T2 | NIC interrupt placement | Each NIC receive queue raises interrupts. On the loop’s CPU, the kernel runs the handler instead of the loop. |
| T3 | transparent hugepages (THP) | The kernel thread khugepaged merges small pages into 2 MiB pages in the background. Its work can run on a spinning thread’s CPU (not measured here). |
| T4 | the Aeron directory’s filesystem | Aeron, the messaging library under ergon’s transports, keeps its buffers in memory-mapped files in one directory. On a disk filesystem the kernel writes them back to disk. |
| T5 | automatic NUMA balancing | On a host with several memory nodes, the kernel unmaps pages to see which CPU uses them, so pinned threads take page faults. |
| T6 | the NIC’s NUMA node | A NIC on another node makes every packet cross between the sockets. |
| T7 | real-time scheduling | SCHED_FIFO or SCHED_RR keeps the CPU’s tick on. RT throttling (the kernel’s cap on real-time threads, 0.95 s of each second by default) can take the CPU away. |
| T8 | SMT siblings, shared CPUs | Two busy threads on one CPU take turns. Two hyperthreads share one core’s execution units. |
| T9 | CPU quota (a container’s CPU limit) | A cgroup with a quota may use only that much CPU time per period. A busy-spinning thread uses its CPU all the time, so it runs at the cap, and the kernel stops the whole container when the cap is reached. On Kubernetes 1.33+ the kubelet drops the quota itself for a container with exclusive CPUs; never turn it off for the whole node. |
| T10 | SMT (hyperthreading) on, x86 | One Kubernetes cpu: 2 is one physical core, not two. A busy sibling slows the loop. Turn SMT off on the node: EKS/EC2 "CpuOptions": {"CoreCount": <the type's cores>, "ThreadsPerCore": 1} (not on bare metal, or on T2, C7a, M7a, R7a, Apple silicon Mac or Graviton), self-managed nosmt or echo off > /sys/devices/system/cpu/smt/control before the kubelet (then delete its cpu_manager_state), or an Azure size without SMT (Fasv6, Falsv6, Famsv6, Dpsv6, Dplsv6). Graviton and arm64 pass: one vCPU is one physical core. |
| T11 | how CLOCK_REALTIME is kept | UTC stamps are only as good as CLOCK_REALTIME, and CLOCK_MONOTONIC is slewed with it. PTP-grade (chronyd locked to a PHC, or phc2sys) is ok. NTP-disciplined or unsynchronised warns with the refclock PHC line for the host’s PHC; so do a correction still being slewed out, an error bound over 1 ms and a pending leap second. In a pod the daemons and chronyd’s port are usually out of sight: what cannot be seen is unknown, never a warning, but the kernel’s own state (adjtimex) is read there too. |
| T12 | the kernel’s clock source | tsc or arch_sys_counter is read through the vDSO; kvm-clock and Hyper-V’s TSC page are too, where the hypervisor marks them stable (info). hpet or acpi_pm has no vDSO read: every UTC stamp and Aeron clock read is a system call. |
| K1 | the pod’s QoS class | Anything but Guaranteed gets a CPU limit and shares CPUs; the loop runs at the cap or with a neighbour. Fix (pod spec): resources.requests = resources.limits. |
| K2 | exclusive CPUs | The shared pool means other pods land on the loop’s CPU. Fix (node): kubelet cpuManagerPolicy: static (with full-pcpus-only on x86); fix (pod spec): whole-CPU requests in a Guaranteed pod. |
| K3 | the loop’s SMT sibling | A sibling in another pod is a busy neighbour (the T10 numbers). Fix (node): turn SMT off; fix (pod spec): ask for whole cores so both siblings come together. |
| K4 | a CPU for the helper threads | One CPU means the handshake and the warning thread share the loop’s core. Fix (pod spec): cpu: 2, or name ERGON_SHARED_CPUS/ERGON_HELPER_CPUS. |
| K5 | capabilities | Busy polling needs CAP_NET_ADMIN, SCHED_FIFO CAP_SYS_NICE, mlockall CAP_IPC_LOCK (or RLIMIT_MEMLOCK). Warns only for what the caller says it uses (Needs). Fix (pod spec): securityContext.capabilities.add. |
| K6 | memory.high, CPU/memory pressure | At memory.high the kernel reclaims and throttles the cgroup. Any non-zero PSI avg10 warns: no threshold is measured. Fix (pod spec): drop memory.high (set requests = limits); fix (node): less load on the node. |
| K7 | the NIC’s NUMA node | Same as T6, for a pod. Runs only when the caller names the NIC (Needs::with_nic). Fix (node): kubelet topologyManagerPolicy: single-numa-node; fix (pod spec): ask for CPU and device together. |
| N1 | ENA adaptive RX moderation | Moderation makes the NIC wait so one interrupt covers several packets. ENA re-tunes that wait from a kernel worker on the CPU that polls the queue: a busy-polling loop’s own CPU. |
| N2 | deferred interrupts | napi_defer_hard_irqs and gro_flush_timeout hold interrupts back so a busy-polling socket (one whose reads poll the NIC queue) can take the packets. With no busy poller, packets wait for the timer. |
| N3 | Azure accelerated networking | Without its VF (the SR-IOV virtual NIC), traffic takes Hyper-V’s slower software path. |
| N4 | NIC receive (RX) ring | How many packets the NIC can hold. Too small, and it drops packets in bursts. |
Not checked: CPU idle states and frequency.
Symptom, check, fix
| symptom | check | fix |
|---|---|---|
| tens of thousands of loop gaps a minute; about 1,000 timer interrupts a second on the loop’s CPU | T1 | boot with isolcpus=nohz,domain,managed_irq,<hot> nohz_full=<hot> rcu_nocbs=<hot> irqaffinity=<housekeeping> |
| hundreds of loop gaps a minute; burst p99 up | T2 | echo <housekeeping> > /proc/irq/<n>/smp_affinity_list, again after every NIC reset |
THP always or madvise | T3 | echo never > /sys/kernel/mm/transparent_hugepage/enabled, or boot with transparent_hugepage=never |
| Aeron directory on ext4, xfs or overlayfs | T4 | /dev/shm (in a pod: a memory-backed emptyDir sized for Aeron’s files), or a sized 2 MiB hugetlbfs with AERON_FILE_PAGE_SIZE=2097152: on the node mount -t hugetlbfs -o pagesize=2M,size=1G none /mnt/huge, in a pod the nri-latency-pods plugin’s $ERGON_HUGEPAGES_DIR |
| page faults on pinned threads; several NUMA nodes | T5 | on the node: sysctl -w kernel.numa_balancing=0 |
| NIC and hot CPUs on different nodes | T6 | pin the threads to the NIC’s node, or use a NIC on theirs |
| a real-time thread on the loop’s CPU | T7 | chrt --other -p 0 <tid>, on an isolated CPU |
| two busy threads on one CPU or core | T8 | pin them to separate cores; leave the sibling idle |
stalls of milliseconds in a container; nr_throttled in /sys/fs/cgroup/cpu.stat grows | T9 | Kubernetes 1.33+: cpuManagerPolicy: static, a Guaranteed pod and whole-number CPU requests, so the kubelet drops the quota itself. Never cpuCFSQuota: false: it removes every pod’s CPU limit on the node (checklist) |
a cpu: 2 pod runs like one core; p50 up with work on the loop’s sibling | T10, K3 | turn SMT off on the node (EKS CpuOptions: ThreadsPerCore: 1, nosmt, or an Azure size without SMT) |
| a Burstable pod, the shared CPU pool, or a CPU for helpers missing | K1, K2, K4 | Guaranteed with requests = limits; the kubelet’s static CPU manager; cpu: 2 |
| AWS: p99.99 high with a busy-polling loop | N1 | ethtool -C <if> adaptive-rx off rx-usecs 0, again after every reset |
packets on sockets that do not busy-poll held for gro_flush_timeout | N2 | echo 0 > /sys/class/net/<if>/napi_defer_hard_irqs; echo 0 > /sys/class/net/<if>/gro_flush_timeout |
| Azure: VF missing or down | N3 | node-level. AKS: a node-pool VM size with accelerated networking. One VM: az vm deallocate, then az network nic update --name <NIC> --resource-group <group> --accelerated-networking true, then az vm start |
| NIC drops and NAKs in bursts with kernel busy polling | N4 | ethtool -G <if> rx 4096 (on ENA this resets the NIC: re-pin, turn adaptive-rx off again) |
| fine after boot, worse later | T2, N1 | an ENA reset undid them: re-apply from a link-event hook |
| cross-host one-way latencies drift or jump | T11 | on the node: chrony refclock PHC /dev/ptp_ena poll 0 delay 0.000010 prefer (AWS, ENA PHC enabled) or refclock PHC /dev/ptp_hyperv poll 3 dpoll -2 offset 0 (Azure) |
clock reads slow; current_clocksource is hpet | T12 | find why the kernel left the counter: dmesg | grep -i clocksource. Force tsc=reliable only on hardware qualified across cores, never on a VM |
Each finding carries the fix for that host, with its real CPU lists and IRQ
numbers. T1 with the loop on CPU 2 of 8:
isolcpus=nohz,domain,managed_irq,2 nohz_full=2 rcu_nocbs=2 irqaffinity=0-1,3-7.
Evidence per check
Each finding’s evidence field names its source. The A/B tables are in git
history: git show 128747b9^:io/benches/results/<run>/tables.md (or
tables-local.md). All are AWS c7i in Tokyo; each figure in a finding keeps
its max.
| check | evidence | status |
|---|---|---|
| T1 | runs flags-aws-20261014T163114Z, flags-aws-20261015T045527Z (boot profiles, one 120 s gap-spinner sample each) | measured |
| T2 | run flags-aws-20261015T062428Z (irqlocal, two reps); ENA’s driver source for the IRQ placement | measured |
| T7 | run flags-aws-20261015T070339Z (rt, two reps): tick measured; latency inconclusive | measured |
| T8 | two threads on one CPU: not measured here; a busy sibling: T10’s run | partly measured |
| T10, K3 | run flags-aws-20261015T122127Z (SQPOLL on the loop’s sibling, three rounds); other sibling loads not measured | measured |
| N1 | runs flags-aws-20261015T070339Z (base, nodim, nocoalesce, two reps each) | measured, x86 busy polling only |
| T3, T4, T5, T6, T9, T11, T12, K1, K2, K4–K7, N2, N3, N4 | kernel, Kubernetes, Aeron, chrony, AWS or Azure documentation | not measured here |
Where the checks run
| component | checks | when | switch |
|---|---|---|---|
ergon-io’s Driver (Component::Io) | T1 and T8 on the loop’s CPU (and sqpoll_cpu); T7 on the calling thread; T9, T10; T3 as info; K1–K6 inside a pod (K7 needs a named NIC, which ergon-io does not pass) | Driver::new; read them back with Driver::host_report() | ERGON_HOST_CHECKS: on (default), off (no warning line; the results stay) or require (a warning, or an unproven proof check, stops the start) |
ergon-io’s Driver | T2, N1, N2 (interrupt-driven backends only), N3 on each connection’s egress NIC | after the first route lookup to it; again after a NIC reset | quickack_check=off skips them |
the ergon-md-dpdk media driver (Component::Md) | T1, T3, T4, T5, T7, T8, T9, T10; T2 and T6 on the data NICs; on the socket transport also N1, N2, and N4 with busy polling | before the driver starts; N1 and T2 again after a NIC reset, on its md-host-checks thread | ERGON_HOST_CHECKS, alias RUSTERON_MEDIA_DRIVER_HOST_CHECK: on/warn (default), off (no check runs at all) or require (any warning or unproven proof stops the start) |
your own busy loop (Component::Rt) | any of them: T1, T3, T4 (the directory it connects to), T5, T7, T8, T9, T10, T11, T12; K1–K7 inside a pod; on a kernel NIC also T2, N1, N2, N3; ThrottleWatch, ClockWatch | before the loop; the NIC checks again when the NIC’s carrier_changes moves; ThrottleWatch::check and ClockWatch::check every few seconds from housekeeping | none: skip a check by not calling it |
Every printed warning line ends with (ERGON_HOST_CHECKS=off silences host-check warnings). The K checks run only inside a pod (the kubepods cgroup path, the
service-account mount, or KUBERNETES_SERVICE_HOST) and each says whether its
fix is in the pod spec or on the node.
require is a readiness gate, not a blanket refusal. It refuses on a warning
(a check fired) and on a check that could not prove a property of this pod
or process — its CPUs are its own, they are isolated, there is no CPU quota,
its helpers have a core — because starting on an unproven claim is the false
pass those checks exist to stop. An unknown of a check whose subsystem the
node may not have (PSI and memory.high, a NUMA node, a NIC knob, THP) is
printed and allowed through: that is a real host, and refusing there would
stop every start on it. Which checks are which is
CheckId::unknown_stops_require.
Two consequences under require:
- T10 warns on every x86 host with SMT on, so
requirerefuses it, including the SQPOLL-on-sibling layout T8 accepts. - K2 proves exclusive CPUs only from an isolated or root cpuset
partition, or a matching
cpuset.cpus.exclusive. The kubelet’s static CPU manager writes neither, so a stock Guaranteed pod is K2 unknown and refused. The nri-latency-pods plugin’s isolated partition (Linux 6.7+) passes it.
Limits:
- ergon-io needs the loop’s CPU: pin the thread that calls
Driver::newto one CPU, or setloop_cpu. Otherwise it skips T1, T8 and T2, and says so. - The media driver checks the socket transport’s NICs only if
RUSTERON_MEDIA_DRIVER_DATA_NICSnames them. It adds its own checks, M1–M4 (ergon-md-dpdk). - Both print each finding once on stderr, never from a busy thread.
- In a pod the NIC checks report unknown: run them on the node
(Kubernetes). T9 reads the container’s own
cgroup; the pod’s quota shows only in
cpu.stat.local(Linux 6.6+), whichThrottleWatchreads. - In CI, run them on a captured
/sysand/proctree:Host::at(dir).
Call them yourself
The checks are plain functions. They read files and allocate: call them before the loop or from housekeeping, never inside the loop.
Host::system()is the running host.Host::at(dir)is a directory that stands for a host’s/, on any OS.HotSetlists the CPUs your threads busy-spin on, each with aRole(Loop,Sender,SqPoll, …), so a message names the thread.Component(Io,Md,Rt) is the caller. It sets T3’s severity: warn forMdandRt, info forIo.
/// Every host check, with `hot` and each interface the host shows.
fn run_all(host: &Host, cpus: &HotSet, interfaces: &[String]) -> Vec<Finding> {
let mut all = Vec::new();
all.extend(t1_isolation(host, cpus));
for c in [Component::Md, Component::Io, Component::Rt] {
all.extend(t3_thp(host, c));
}
all.push(t4_aeron_dir(
host,
Path::new("/dev/shm/ergon-tune-live/aeron"),
));
all.push(t5_numa_balancing(host));
all.push(t7_sched_policy(host, Thread::Current, Role::Loop));
all.extend(t8_smt_siblings(host, cpus));
all.push(t9_cpu_quota(host));
all.push(t10_smt_on(host));
all.extend(k8s_checks(
host,
cpus,
&Needs::new(Thread::Current, Role::Loop),
));
for i in interfaces {
let nic = Nic::Interface(i.clone());
all.extend(t2_nic_irqs(host, &nic, cpus));
all.extend(t6_nic_node(host, &nic, cpus));
}
all
}
The NIC checks need the nic feature. Keep one EthtoolCache::new() for the
process: it asks the kernel once per interface, and again only after a reset
(when carrier_changes moves). Here i is an interface name:
let mut v = vec![
n1_adaptive_rx(&host, &i, &mut cache),
n3_azure_vf(&host, &i),
n4_rx_ring(&host, &i, &mut cache),
];
v.extend(n2_deferred_irqs(&host, &i, Some(false)));
(From tune/tests/live.rs, which runs every check against the host it runs
on: cargo test -p ergon-tune --all-features.)
| function | returns | cost |
|---|---|---|
t1_isolation(&host, &hot) | one finding per missing setting per CPU | a few sysfs reads |
t2_nic_irqs(&host, &nic, &hot) | one finding per IRQ that may fire on a hot CPU | one directory and two files per IRQ |
t3_thp(&host, component) | one finding | a few sysfs reads |
t4_aeron_dir(&host, path) | one finding | one statfs; unknown on a tree |
t5_numa_balancing(&host) | one finding | a few sysfs reads |
t6_nic_node(&host, &nic, &hot) | one finding per PCI device | a few sysfs reads |
t7_sched_policy(&host, thread, role) | one finding | one /proc read; Thread::Current from the thread that will spin, or Thread::Task(tid) |
t8_smt_siblings(&host, &hot) | one finding per shared core | a few sysfs reads |
t9_cpu_quota(&host) | one finding | /proc/self/cgroup and a few cgroup files |
ThrottleWatch::open(&host), then .check() | open: the watch, before the loop; check: a finding when the cgroup was throttled since the last check | check: one read of cpu.stat.local, which open opened, and of cpu.stat only after that grew (its read takes a kernel lock); no allocation unless it reports; call it from housekeeping |
n1_adaptive_rx, n4_rx_ring(&host, ifname, &mut cache) | one finding | one netlink exchange per interface per reset (and one family lookup on first use), each with 100 ms send and receive timeouts; unprivileged |
n2_deferred_irqs(&host, ifname, busy_polls) | one finding per deferring device | sysfs; busy_polls is Some(true) if your sockets busy-poll, None if not known |
n3_azure_vf(&host, ifname) | one finding | sysfs |
t11_time_sync(&host, &probe) | the verdict’s finding, plus one each for an unsettled clock, a wide bound, a pending leap second | adjtimex, a /proc scan, /sys/class/ptp, ClockBound’s segment, and a chronyd query of at most 50 ms; the pure step is TimeSync::from_observed |
t12_clocksource(&host) | one finding | two file reads |
ClockWatch::open(&host, &probe), then .check() | open: T11 and T12, and the watch, before the loop; check: T11’s findings again when its judgement changed, T12’s when the clock source did, else none | check: one adjtimex, one non-blocking receive and send to chronyd (its answer used one check late), ClockBound’s segment and the clock source read again; never waits; no allocation unless a finding changed; update(sample) judges an injected sample |
A Nic is Nic::Interface("ens5") or a DPDK port, Nic::Pci("0000:00:06.0").
The readers underneath (thread_affinity, carrier_changes, nic_irqs,
busy_poll_sysctls, fs_magic, cpu_cgroup, Platform::detect) are public
too.
Read a finding
| field | meaning |
|---|---|
status | Finding: it fired. Ok: fine, or it does not apply here. Unknown(reason): the data could not be read. |
severity | Warn or Info: how to log a Finding |
message | what was found, its measured effect, and the fix |
evidence | where the numbers come from |
Display prints one line, T4 warn: <message> (evidence: <where>) or
T4 unknown: <message>: <reason>:
#[test]
fn findings_print_as_one_line_each() {
let f = t4_from(Path::new("/var/lib/aeron"), Ok(FsMagic(0xEF53)));
let line = f.to_string();
assert!(line.starts_with("T4 warn: "), "{line}");
assert!(line.contains("(evidence: "), "{line}");
assert!(!line.contains('\n'), "{line}");
let u = t4_from(Path::new("/aeron"), Err(ReadError::NotLinux("statfs")));
assert!(u.to_string().starts_with("T4 unknown: "), "{u}");
}
- Warn: fix it.
- Info: real but minor, such as THP for ergon-io or an IRQ whose new affinity takes effect at its next interrupt.
- Unknown is never a warning: a pod’s network namespace,
hidepid, an interface with no device, not Linux. Log it as info. - To make warnings fatal, use the media driver’s
require, oris_warning()in your own start-up.
Alternatives
| tool | good at | short of | use it |
|---|---|---|---|
tuned profiles (cpu-partitioning, latency-performance) | applying isolation, IRQ and power settings; tuned-adm verify checks they stuck | does not know which threads spin, which NIC they use, or what a setting costs; not measured here | to apply settings, with ergon-tune checking the result |
a gap spinner, or the kernel’s osnoise and hwlat tracers (rtla) | measuring noise on each CPU, whatever its cause | says how much, not which setting | to choose quiet cores: on Azure D8ds_v6 the lab’s spinner saw 47 of its 48 stalls of 300 µs or more at one second of the minute (the longest of those 47: 1,083 µs; run flags-azure-20261014T144146Z, tables-local.md line 519) |
| provisioning scripts alone | setting everything once | drift: ENA re-places queue IRQs whenever an interface comes up (ena_setup_io_intr in its driver) | with ergon-tune re-checking |
Best practices and pitfalls
- Pin first, then check. ergon-io takes the loop’s CPU from the calling thread’s affinity.
- Re-check after every NIC reset. Whenever ENA brings an interface up (a
device reset,
ethtool -Gor-L, a NIC handed back from DPDK), it puts queue i’s interrupt on CPU i, isolated CPUs included, and turns adaptive moderation back on. Re-pin from a link-event hook. - N1, busy polling:
adaptive-rx off rx-usecs 0. Againstadaptive-rx offalone (one run, two reps): WebSocket p99.99 78–80 against 72–76 µs (max 233–344 against 354–970), HTTP p99.99 74–81 against 83–116 µs (max 202–227 against 202–981), market-data p99 7.9–8.6 against 9.3–12.9 µs (max 34–41 against 25–29). The interrupt-driven path is not A/B’d here. - Use THP
never, notmadvise. khugepaged still runs undermadvise(kerneltranshuge.rst); the cost is not measured here. - No CPU quota on a busy-spinning container.
/sys/fs/cgroup/cpu.maxshould readmax 100000, andnr_throttledshould not grow while it runs (T9,ThrottleWatch). Set-up: Kubernetes. - Graviton has no SMT, so T8 fires there only when two busy threads share one CPU. N2 does not suggest busy polling there.
- Test your host image. Build a tree with the files your provisioning
writes and assert on the findings, as
tune/tests/commondoes for five hosts. - Measure your own host. The measured effects come from AWS
c7iruns.
How to apply each setting on AWS and Azure, x86 and Graviton: Hosts.
ergon-md-dpdk (experimental)
Not recommended for production use.
This crate is a proof of concept. I wrote it to get a better understanding of how a kernel-bypass transport for Aeron can be written. Of all of ergon’s modules, it is the one where I relied on AI the most, and I could not verify that what it does is correct, unlike the other modules, whose domains I understand well.
If you need kernel bypass for Aeron in production, use Aeron Premium, which is supported. No kernel-bypass product was compared on this hardware, so no speed claim is made either way.
On one host it gains nothing: host-local channels run on ordinary kernel sockets in this driver too (
md-dpdk/native/dpdk/rusteron_dpdk_kernel.c), so there is no bypass to gain (below).
Glossary
Terms this chapter uses. Host, Kubernetes and Java terms:
deployment glossary. Figures:
md-dpdk/BENCHMARKS.md.
| term | what it is | when it matters |
|---|---|---|
| media driver | the part of Aeron that moves messages between the network and a host’s applications; its own process, or embedded in yours | its transport can change; Aeron clients do not |
| channel | an Aeron address: a UDP host and port, or IPC | a UDP channel is one flow |
| stream, publication, subscription | stream: the messages on a channel under one stream id. Publication: its sending end. Subscription: its receiving end | |
| frame, MTU | frame: Aeron’s unit on the wire, a header and data. MTU: the largest packet a link carries; AERON_MTU_LENGTH caps a frame | 1,408 by default here (RUSTERON_DPDK_MAX_AERON_MTU), so a frame fits one packet buffer |
| Aeron IPC | Aeron over shared memory, between processes on one host | both ends on one host: use it or kernel UDP |
| Aeron Premium | the commercial Aeron (aeron.io), with its own DPDK driver | the supported route to bypass in production; this crate is a proof of concept |
| conductor, sender, receiver | the media driver’s three agents (threads of work): control, sending, receiving | under DPDK the network threads must busy-spin on CPUs of their own: the install refuses otherwise |
| threading mode | how the agents share threads: DEDICATED, SHARED_NETWORK, SHARED or INVOKER | measured cost of SHARED_NETWORK |
| idle strategy, park | what an Aeron thread does with no work: spin and noop never sleep; backoff spins, yields, then parks (sleeps) ever longer | spinning needs a CPU of its own |
| log buffer, term buffer | Aeron’s memory-mapped file of one stream’s messages, in three term buffers | on hugepages, not sparse |
| receiver window, status message | window: the bytes a sender may have in flight. Status message: the receiver’s “send more” | on MANA, sized to what the 256-entry ring holds (not measured here) |
| heartbeat, keep-alive | heartbeat: Aeron’s frame on a quiet stream. Keep-alive: your own small message | why a keep-alive; its effect is not measured here |
| kernel UDP | the normal path: NIC interrupt, the kernel’s network stack, a system call | no setup, every tool works (tuning) |
| kernel bypass | one process takes a NIC from the kernel and reads and writes its queues itself | lower median and max in these runs; costs cores, NICs, root setup and upkeep |
| DPDK | the Data Plane Development Kit: the kernel-bypass library this crate uses | |
| EAL | DPDK’s start-up layer (Environment Abstraction Layer): it maps the NICs and hugepages and starts DPDK’s threads | extra options: RUSTERON_DPDK_EAL_ARGS |
| NIC, PCI address, MAC | network interface card (virtual on a cloud VM); its slot on the host’s bus; its hardware address | the crate picks NICs by PCI address, on Azure by MAC |
| ENA | AWS’s virtual NIC (Elastic Network Adapter) | a reset restores its interrupt placement and adaptive moderation (md-dpdk/src/checks.rs): re-apply them (hosts) |
| MANA, accelerated networking | MANA: Azure’s virtual NIC (Microsoft Azure Network Adapter). Accelerated networking gives the VM a slice of it (a VF, virtual function) | rings of at most 256 entries: set RUSTERON_DPDK_TX_DESCRIPTORS=256 |
| low-latency queue, BAR, write-combining | ENA’s fast send mode: the CPU writes each send into the NIC’s memory window (the BAR). Write-combining batches those writes | start fails on an ENA port whose BAR is not mapped write-combined (md-dpdk/tests/dpdk_ena_llq.rs) |
igb_uio, uio_hv_generic, vfio-pci | kernel modules that hand a NIC to a user-space process | node-level, as root: igb_uio on AWS, uio_hv_generic on Azure. The kernel’s tools then lose that NIC |
| interrupt | a NIC’s signal that packets are waiting; the CPU stops its work to handle it | DPDK polls instead |
| interrupt moderation | the NIC waits to batch several packets into one interrupt. Adaptive: the driver re-tunes the wait itself | off on Graviton in the best kernel path measured; its cost on x86 is not measured here |
| busy polling (kernel) | a socket read spins on the NIC’s queue instead of sleeping (net.core.busy_read, busy_poll) | measured, never isolated: no setting recommended (below). A node-wide sysctl (kernel docs) |
| busy-spin | a thread checks for work in a loop and never sleeps | no wake-up delay; a whole core at 100%, idle or not |
| pin, CPU affinity | pin: tie a thread to one CPU. Affinity: the CPUs a thread may run on (AERON_*_CPU_AFFINITY) | the crate pins the agents (threads and cores) |
| housekeeping core | the core for interrupts, the conductor and every thread that does not spin | keeps that work off the busy cores |
| isolated CPU | a CPU the kernel keeps its own work off: scheduler, timer tick, interrupts | node-level (kernel command line) |
| ring, descriptor | a NIC queue: RX receives, TX sends. Each descriptor in it points at one packet buffer | RX default: min(4,096, the NIC’s maximum): 4,096 on ENA, 256 on MANA |
| packet buffer (mbuf) | DPDK’s buffer for one packet, from a pool per NIC on hugepages | 16,384 a NIC by default |
| burst | many messages sent back to back | the RX ring must hold it while the reader catches up |
| jumbo frames | packets above Ethernet’s usual 1,500 B | one run only |
| hugepages | large memory pages (2 MiB here), reserved up front | DPDK needs them; about 37 MiB a NIC at the defaults. Node-level reservation |
| hugetlbfs | a filesystem of hugepages; Aeron’s directory can live on one | mount it with size=, or Aeron refuses every log buffer |
| transparent hugepages | the kernel building large pages by itself, in the background (khugepaged) | not separated in one run; node-level |
| page fault | the first touch of a memory page: the kernel stops the thread to map it | hugepages and pre-touching move it off the hot path |
| flow | packets between one source and one destination address and port | an Aeron channel is one flow; one flow topped out at 7.75–7.77 Gbit/s of Aeron frames on c7i.4xlarge |
| placement group | hosts placed close together: a cluster placement group (AWS) or a proximity placement group (Azure) | two c7i.4xlarge pairs still differed by about 7 µs at the median |
| spot instance | spare AWS capacity, sold cheaper; AWS can take it back | a c9g.2xlarge pair: $0.306 spot against $0.696 on demand an hour (cost) |
| x86, Graviton | x86: Intel and AMD CPUs. Graviton: AWS’s Arm CPUs | never compared in one run: pairs, days and builds differ (BENCHMARKS.md) |
| SMT, hyperthread, sibling, vCPU | SMT runs two hyperthreads (siblings) on one core. A vCPU is a hyperthread on x86, a whole core on Graviton (no SMT) | on x86, leave each busy core’s sibling idle |
| bare metal | an instance with no hypervisor | c7i.metal-24xl, round 4: DPDK max 103 µs at 1 M msgs/s, 845 µs idle; 6 times a c7i.4xlarge pair’s price |
| round trip (ping-pong) | one message out and its reply back | every latency here is a round trip |
| rep | one repeat of a run | p50 and p99.99 are the median of 5 reps |
| NAK, retransmit, drop, short send | the receiver asking for a resend; a frame sent again; a packet the NIC threw away; a send that put out less than asked | any one makes the rep lossy |
| lossy rep, loss-free maximum | lossy rep: a rep that lost anything. Loss-free maximum: the highest load with no lossy rep | a rate with a lossy rep is a ceiling, not an operating point |
| p50, p99.99, tail | percentiles: p50 is the median; at p99.99, 1 round trip in 10,000 is slower. The high ones are the tail | |
| max | the largest single round trip in any rep, unless labelled “median of rep maxima” | one stall sets it |
| intended send time | under load, latency counts from when a message should have been sent | a stall then counts against every message behind it |
| CPU counter | a register in each core that counts at a fixed rate (the TSC on x86) | the transport reads it inline (clock) |
| chrony | the daemon that keeps the host’s wall clock on a reference: NTP, or a hardware clock such as the NIC’s (PTP) | NIC timestamps need it (time sync) |
| NIC receive timestamp | the time the NIC stamps on each packet it receives | AWS ENA only (configuration) |
| media receive timestamp | the driver’s stamp of when a frame arrived, kept in the frame (media-rcv-ts-offset=reserved) | one run |
What it is
- Aeron’s media driver carries Aeron’s messages between the network and the applications on a host, over UDP.
- Kernel UDP is the normal path. The NIC raises an interrupt, the kernel’s network stack handles the packet, and the reading thread gets it with a system call.
- Kernel bypass takes a NIC away from the kernel and gives it to one process, which reads and writes the NIC’s queues itself.
- DPDK is the library that does this. A thread polls the NIC in a loop: no interrupt, no system call, no copy into a socket.
- This crate plugs DPDK into Aeron’s C media driver (through
rusteron). The driver
sends and receives through a table of functions
(
aeron_udp_channel_transport_bindings_t); the crate fills that table with DPDK versions. Aeron clients do not change.
kernel UDP: client ─ media driver ─ system call ─ kernel UDP/IP ─ interrupt ─ NIC
DPDK: client ─ media driver ─ this crate, polling ─────────────────────── NIC
It runs on AWS’s ENA and Azure’s MANA virtual NICs, on Linux x86-64 and aarch64 (Graviton). The sender and the receiver each get a NIC of their own; the host’s primary NIC stays with the kernel.
Tuning kernel UDP first
All four are node-level settings (how to set them).
| setting | what it does | evidence |
|---|---|---|
| interrupt moderation off | the NIC interrupts for each packet instead of waiting to batch several | Graviton G1–G3: part of the best kernel path; no lossy rep to 1 M msgs/s in G1 and G3. x86: not measured here |
| NIC interrupts on a core of their own | the kernel’s receive work runs on a core with nothing else to do | Graviton G1, one A/B: on the housekeeping core 1 M msgs/s lost in 5 of 5 reps; on the receiver’s core it collapsed from 500 k msgs/s. x86: not measured here |
| busy polling | a socket read spins on the NIC’s queue instead of sleeping until an interrupt | not recommended: never isolated. x86 round 7: the best kernel arm tried, with a 4,096 ring (two settings differ). Graviton G1: at 1 M msgs/s of 288 B, p50 4,973 µs, max 9,175 (8,544–102,826) µs (four settings differ) |
| 4,096-entry receive ring | the NIC’s receive queue holds a burst while the reader catches up | used with busy polling in the x86 rows below; not A/B’d alone here |
Source: BENCHMARKS.md, recommended configuration.
Why bypass is faster, and what it costs
No interrupt, no system call, no scheduler wake-up on the data path. In every pair below DPDK’s median was lower: by 4.96–6.94 µs idle and by 8.64–25.47 µs at 1 M msgs/s of 288 B.
| cost | what it means |
|---|---|
| cores | each network thread spins at 100% on a core of its own, idle or not, plus a core for housekeeping (layout) |
| NICs | two extra NICs, taken from the kernel: no ping, tcpdump, firewall or kernel counters on them |
| memory | 2 MiB hugepages for packet buffers, about 37 MiB a NIC at the defaults |
| setup | node-level, as root: each NIC bound to a user-space driver, hugepages reserved (configuration) |
| operations | you own NIC resets, restarts, upgrades and the C in native/dpdk/ |
| same host | no gain: host-local channels still use kernel sockets (below) |
Which transport should I use?
| situation | use |
|---|---|
| both ends on one host | kernel UDP, or Aeron IPC (shared memory, no network); DPDK gives no bypass there |
| between hosts, Graviton | kernel UDP, moderation off, the NIC’s interrupts on a core of their own: the best kernel path measured (no lossy rep at 1 M msgs/s in G1 and G3, one in G2) |
| between hosts, x86 | kernel UDP. No kernel setting is recommended on x86: busy polling with a 4,096-entry ring was the best kernel arm tried, but it changes two settings at once, and the Graviton settings are not measured on x86. On c7i.4xlarge at 1 M msgs/s that arm’s max was 6,664 µs, with 4 NAKs and 849 short sends over 5 reps |
| between hosts, the kernel’s median or tail is not good enough | kernel bypass with Aeron Premium |
| learning how a transport binding works, or reproducing these numbers | this crate, in a lab |
Measured: between two hosts
Aeron 1.53.3 ping-pong between two hosts in one placement group, isolated CPUs, 5 reps. Idle: 32 B, one message in flight. Load: 288 B at 1 M msgs/s, each latency counted from when the message should have been sent. p50 is the median of the 5 reps; max is the largest single round trip in any rep. µs. Each platform’s two rows are one run on one pair: compare within a platform only.
| platform, round | transport | idle: p50 / max | 1 M msgs/s: p50 / max | lossy reps at 1 M msgs/s |
|---|---|---|---|---|
Graviton5 c9g.2xlarge, G3 | DPDK | 13.97 / 79 | 19.36 / 114 | 0 of 5 |
| kernel UDP, moderation off, interrupts on their own core | 18.93 / 168 | 44.83 / 211 | 0 of 5 | |
x86 c7i.4xlarge, round 7 | DPDK | 21.28 / 116 | 30.38 / 155 | 0 of 5 |
| kernel UDP, busy polling, 4,096 ring | 28.22 / 4,846 | 43.74 / 6,664 | 4 NAKs over 5 reps; lossy reps not in the table | |
x86 c7i.metal-24xl, round 4 | DPDK | 28.13 / 845 | 34.30 / 103 | not in the table |
| kernel UDP, busy polling | 33.70 / 307 | 42.94 / 1,150 | not in the table |
Source:
BENCHMARKS.md, DPDK against the best kernel arm
(its max X (a–b) cells: the max here is b). Azure: no run output is
committed, so no Azure figure is kept.
- DPDK’s max was lower in every pair except bare-metal idle (845 against 307 µs).
- Two
c7i.4xlargepairs differed by about 7 µs at the median, so the platforms are not compared here.
Localhost: no gain
A DPDK NIC cannot reach its own host. Channels on loopback or the host’s
kernel interfaces run on kernel UDP sockets inside this driver, as Aeron’s
kernel transport would carry them
(rusteron_dpdk_kernel.c).
So on one host the DPDK driver and Aeron’s C driver take the same kernel path.
No committed run compares the two drivers on one host.
Next
- Building, running and tuning it: Configuration.
- x86 against Graviton, bare metal, cost and Kubernetes: Deployment: benchmarks.
- Running it in Kubernetes (node and pod settings, no CPU quota): Kubernetes latency checklist.
- Every run, setting and range:
md-dpdk/BENCHMARKS.md.
Configuring the DPDK Transport
Read the notice first: this transport is a proof of concept, not recommended for production. For kernel bypass with Aeron, use Aeron Premium. Terms: glossary.
Build
dpdkfeature: Linux x86-64 or aarch64,libdpdk >= 23.11found by pkg-config (libdpdk-devon Ubuntu 24.04 or Debian 13; from source on Amazon Linux 2023). Without it the crate still builds everywhere, and selecting DPDK returnsDpdkError::FeatureDisabled.static: links Aeron’s C driver statically.- Graviton: build for the core, or Ubuntu’s and Debian’s gcc turn every atomic add on the hot
path into a library call (GCC’s
-moutline-atomics):CFLAGS=-mcpu=native(as measured) or the core (neoverse-v2Graviton4,neoverse-v3Graviton5), and the same inRUSTFLAGS=-Ctarget-cpu=. rusteron builds Aeron’s own C for the build host’s CPU unlessRUSTERON_C_MARCHnames another.
cargo test -p ergon-md-dpdk -- --test-threads=1 # every platform, without the feature
just check-dpdk # Linux with libdpdk
just verify-dpdk-docker --build --all # the same in Docker (amd64)
just verify-dpdk-docker --arm64 --build --all # and arm64
The tests run the C against DPDK-free fakes of the EAL and the NICs. Only the cloud labs touch a real NIC.
Run it
The bundled media_driver binary picks the transport from
RUSTERON_MEDIA_DRIVER_TRANSPORT:
RUSTERON_MEDIA_DRIVER_TRANSPORT=dpdk-ena cargo run -p ergon-md-dpdk --features dpdk --bin media_driver
| value | result |
|---|---|
unset or default | Aeron’s kernel socket transport, unchanged |
dpdk-ena | the DPDK transport; any failure exits non-zero and never falls back to kernel UDP |
| anything else | exits non-zero |
The whole binary, which is also the shape of a driver embedded in your own process:
//! Standalone Aeron media driver with transport selection.
//!
//! `RUSTERON_MEDIA_DRIVER_TRANSPORT=dpdk-ena` installs the DPDK ENA transport
//! (configured from the `RUSTERON_DPDK_*` environment) before the driver
//! starts; absent or `default` keeps the socket transport. A selected DPDK
//! transport that fails exits non-zero; it never falls back to the socket
//! driver. Under `dpdk-ena` the receiver IO vector capacity is 16 unless
//! `AERON_RECEIVER_IO_VECTOR_CAPACITY` says otherwise.
//! `RUSTERON_MEDIA_DRIVER_DIR` pins the Aeron directory, and
//! `RUSTERON_MEDIA_DRIVER_MLOCKALL=1` locks the process's memory, present and
//! future, before the driver starts. SIGINT, SIGTERM and SIGHUP stop it
//! cleanly. The configuration is the library's
//! ([`ergon_md_dpdk::driver::ConfiguredDriver`]), which a process embedding
//! the driver shares, host checks included: one stderr line for each
//! low-latency setting that is not fine, and under
//! `RUSTERON_MEDIA_DRIVER_HOST_CHECK=require` a non-zero exit before any
//! driver starts while one warns ([`ergon_md_dpdk::checks`]).
use ergon_md_dpdk::driver::ConfiguredDriver;
use log::info;
use rusteron_media_driver::AeronDriverContext;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Flag to indicate when the application should stop
let running = Arc::new(AtomicBool::new(true));
let running_clone = Arc::clone(&running);
// SIGINT, and with ctrlc's `termination` feature SIGTERM and SIGHUP:
// kubelet stops a pod with SIGTERM, and as the container's PID 1 (the
// entrypoint execs it) the driver would otherwise ignore it until the
// grace period's SIGKILL, closing neither the driver nor the transport.
ctrlc::set_handler(move || {
running_clone.store(false, Ordering::SeqCst);
})?;
// The directory, the agents' pins and the transport, from the environment
// after the context exists and before the driver is created. A selected
// DPDK failure propagates as a nonzero exit; it never falls back to the
// default socket driver.
let configured = ConfiguredDriver::from_env(AeronDriverContext::new()?)?;
println!("transport backend: {}", configured.backend());
println!("aeron dir: {}", configured.dir());
configured.print_configuration();
// RUSTERON_MEDIA_DRIVER_MLOCKALL=1 (in `start`, before the driver is
// created): every mapping faulted in by mmap, so the agents never
// page-fault on a log buffer
let driver = configured.start()?;
if driver.memory_locked() {
println!("memory locked (mlockall current and future)");
}
println!("media driver started");
info!("Aeron media driver started successfully. Stop it with SIGINT or SIGTERM.");
// Poll for work until a stop signal arrives
while running.load(Ordering::Acquire) {
driver.idle(driver.do_work()?);
}
info!("Received signal to stop the media driver.");
// The driver, then its context, then the transport its bindings point into.
drop(driver);
info!("Aeron media driver stopped successfully.");
Ok(())
}
(md-dpdk/src/bin/media_driver.rs: built by cargo test -p ergon-md-dpdk
on every platform; tests/media_driver_transport_selection.rs runs it with
each value, checks it never falls back, and stops it with SIGTERM.)
Entry points
Each is called once, at start-up, and adds no work to the send or receive
path. ConfiguredDriver::from_env also starts the md-host-checks thread
for the socket transport (host checks).
Each returns Result<_, DpdkError>, except clock().
| call | returns | use it when |
|---|---|---|
driver::ConfiguredDriver::from_env(context), then .start() | an EnvMediaDriver: Aeron directory, agent pins, transport and host checks set as the binary sets them; call its do_work() and idle() in your loop | you run the driver in your own process, configured from the environment (the binary above) |
configure_media_transport_from_env(&context) | None for the kernel transport, Some(DpdkTransport) for DPDK | you set up Aeron yourself and only want the transport |
DpdkTransport::install(&context, &config) | a DpdkTransport, from a typed DpdkTransportConfig | you want no environment variables (example, built by just check-dpdk) |
pin_agents_to_configured_cpus(&context) | () | your own driver on the kernel transport: Aeron’s C driver reads AERON_*_CPU_AFFINITY, but only aeronmd applies it |
lock_memory_from_env() | true if it locked memory | Not recommended. RUSTERON_MEDIA_DRIVER_MLOCKALL=1: its latency effect is not measured here |
DpdkTransport::clock() | the TransportClock the transport reads: Counter { hz, rate, eal_hz, measured_hz } or Monotonic { reason } | you check whether the transport reads the CPU counter, at which rate, and if not, why |
Dropping a DpdkTransport only leaks it (md-dpdk/src/ffi.rs, no
Drop). Freeing it is DpdkTransport::close, which is unsafe and sound
only after every driver started from the context has stopped.
EnvMediaDriver frees it in that order for you.
Environment
The names are rusteron’s, so deployments written for rusteron’s DPDK branch
work unchanged. Aeron itself is set with the usual AERON_* variables. The
EAL is DPDK’s start-up layer: it maps the NICs and hugepages and starts
DPDK’s own threads.
| variable | meaning |
|---|---|
RUSTERON_DPDK_FILE_PREFIX | the EAL’s file prefix (required) |
RUSTERON_DPDK_SENDER_PCI, _IPV4_CIDR, _GATEWAY | the sender’s NIC: PCI address, address with prefix, gateway (required) |
RUSTERON_DPDK_RECEIVER_* | the same for the receiver’s NIC (required) |
RUSTERON_DPDK_SENDER_MAC, _RECEIVER_MAC | Azure: pick the NICs by MAC (every NIC of a VM shares one PCI function) |
RUSTERON_DPDK_RX_DESCRIPTORS | receive ring size; unset: 4,096 on ENA, 256 on MANA |
RUSTERON_DPDK_TX_DESCRIPTORS | transmit ring size, 512 by default; set 256 on Azure, or start fails |
RUSTERON_DPDK_MBUFS_PER_PORT | packet buffers per NIC, 16,384 by default |
RUSTERON_DPDK_EAL_ARGS | extra EAL options, such as -l <cpu> to keep the EAL’s main thread on a housekeeping CPU from the pod’s cpuset |
RUSTERON_DPDK_HOUSEKEEPING_CPU | the CPU for the transport’s housekeeping and local-I/O threads |
RUSTERON_DPDK_HUGE_DIR | the hugetlbfs mount DPDK uses, an absolute path; /dev/hugepages by default |
RUSTERON_MEDIA_DRIVER_DIR | the Aeron directory ConfiguredDriver sets |
ERGON_HOST_CHECKS | on/warn (default), off (no check runs), or require: refuse to start while any host check warns. Wins over its alias below when both are set |
RUSTERON_MEDIA_DRIVER_HOST_CHECK | alias of ERGON_HOST_CHECKS, read only when that is unset |
RUSTERON_MEDIA_DRIVER_MLOCKALL | 1: mlockall before the driver starts (needs CAP_IPC_LOCK in the pod). Not recommended: see the entry-point table above |
Every other knob (rate caps, host-local channels, ENA device arguments,
receive drain, statistics) is in md-dpdk/README.md.
Threads and cores
Aeron’s driver has three agents (threads of work): the conductor sets up
channels and handles control; the sender and the receiver move the
data. The thread that runs the sender or receiver must busy-spin
(AERON_*_IDLE_STRATEGY noop or spin) on a CPU of its own: a thread
that sleeps leaves a NIC queue unread. The install refuses a layout that
breaks this.
AERON_THREADING_MODE | network thread | its CPU |
|---|---|---|
DEDICATED | sender and receiver, a NIC each | AERON_SENDER_CPU_AFFINITY, AERON_RECEIVER_CPU_AFFINITY, different CPUs |
SHARED_NETWORK | one thread, both NICs | AERON_SENDER_CPU_AFFINITY |
SHARED | one thread for everything | AERON_CONDUCTOR_CPU_AFFINITY |
INVOKER | your thread, which runs the driver | AERON_CONDUCTOR_CPU_AFFINITY |
One physical core per role, skipping cores the pre-flight spin test flags (hosts). In a pod, all four come from the pod’s cpuset (static CPU manager, whole cores):
| core | runs |
|---|---|
| housekeeping | interrupts, Aeron’s conductor, the EAL’s main thread, the transport’s housekeeping and local-I/O threads |
| application | your busy-spinning loop |
| sender | the media driver’s sender |
| receiver | the media driver’s receiver |
- The agents are pinned for you, in every mode, each to the CPU its
AERON_*_CPU_AFFINITYnames. Running unpinned is not measured here. SHARED_NETWORKsaves a core and costs latency. Graviton G1, one A/B: 1,344 B bursts p50 522 against 234 µs, max 886 against 745 µs (BENCHMARKS.md).- Start the driver confined to the housekeeping CPU (
taskset -c <cpu>, a CPU from the pod’s cpuset that no busy thread uses), so the threads it does not pin stay off the busy cores. Name the same CPU inRUSTERON_DPDK_HOUSEKEEPING_CPUandRUSTERON_DPDK_EAL_ARGS='-l <cpu>'.
Where the transport sits, and the same core placement on both hosts:
flowchart LR
NIC["NIC"] --> T{"transport"}
T -->|"kernel UDP"| MD["media driver"]
T -->|"dpdk-ena"| MD
MD --> HK["conductor on housekeeping"]
MD --> TX["sender on own core"]
MD --> RX["receiver on own core"]
APP["app on own core"] --> MD
Same layout on host A and host B: housekeeping, app, sender, receiver. Host-local channels still use kernel sockets, so on one host use IPC or kernel UDP.
Aeron settings
What the lab used, and the evidence for each. Figures:
md-dpdk/BENCHMARKS.md. Where: pod is
the pod spec or the channel URI; node needs node-level access.
| setting | where | what it does | evidence |
|---|---|---|---|
AERON_RECEIVER_IO_VECTOR_CAPACITY=16 (the default under dpdk-ena) | pod | up to 16 frames a receive, Aeron’s maximum (md-dpdk/src/driver.rs) | measured before the DPDK poll drained whole bursts itself; it now sizes host-local socket reads. One run (aws-20261014-113947, platform not recorded), DPDK, 1,344 B at 600 k msgs/s: no NAK against 16,247 NAKs without it, max 142 against 1,130 µs (BENCHMARKS.md) |
AERON_DIR on a sized 2 MiB hugetlbfs, AERON_FILE_PAGE_SIZE=2097152, AERON_TERM_BUFFER_SPARSE_FILE=false | node mount, pod env | the log buffers sit on hugepages, allocated up front | required, not A/B’d: an unsized hugetlbfs reports no free space, and Aeron refuses every log buffer |
AERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY=true on clients | pod | clients fault their mappings in before the first message | not measured here |
AERON_MTU_LENGTH=1408 (at most RUSTERON_DPDK_MAX_AERON_MTU, 1,408 by default) | pod | every frame fits one packet buffer | jumbo frames, Graviton G2, one run: 1,344 B bursts p50 242 → 210 µs, max 954 → 1,533 µs; idle unchanged. A candidate for bulk or bursty channels only |
AERON_RCV_INITIAL_WINDOW_LENGTH, AERON_SOCKET_SO_RCVBUF, _SNDBUF all 2 MiB; net.core.rmem_max/wmem_max 16 MiB | pod; sysctls node | the window fits the socket buffer | not A/B’d here |
Azure: AERON_CONDUCTOR_IDLE_STRATEGY_INIT_ARGS=100-10-1us-20us and, per channel, rcv-wnd of 256 × the smallest datagram (80 KiB for 288 B) | pod | status messages go out every 20 µs, and the sender never has more in flight than MANA’s 256-entry ring holds | not measured here: no Azure run output is committed. Set both or neither |
kernel busy polling (net.core.busy_read, busy_poll): none recommended | node | a socket read spins on the NIC’s queue | measured, not isolated: Graviton G1 lost at 288 B 1 M msgs/s in 5 of 5 reps, max 9,175 (8,544–102,826) µs (four settings differ); x86 round 7 lower p50 at 288 B 100 k msgs/s, max 382 (372–389) against 469 (448–578) µs (two settings differ) (BENCHMARKS.md). Check M4 reports the state as information |
| transparent hugepages off | node | the kernel stops building large pages in the background | not separated: one run (aws-20261014-113947), idle p99.99 53.15 against 67.01 µs, max 90 (77–1,180) against 94 (80–498) |
| a ~1 kHz keep-alive on sparse, latency-critical streams | application | a lost frame is noticed within a millisecond, not at Aeron’s next heartbeat (100 ms) | not measured here; the 100 ms is aeron_network_publication.h:38 (rusteron-media-driver 0.2.13) |
One AWS flow topped out at 7.75–7.77 Gbit/s of Aeron frames on
c7i.4xlarge, kernel UDP and DPDK alike; on c9g.2xlarge 1,344 B messages
stopped at 685,574 msgs/s
(BENCHMARKS.md). AWS documents a single-flow limit of
10 Gbit/s inside a cluster placement group
(EC2 network bandwidth).
An Aeron channel is one flow: spread a heavier stream over several channels
on different ports.
Clocks and timestamps
- Clock: the transport times intervals in nanoseconds since boot. By
default (
RUSTERON_DPDK_CLOCK=counter) it reads the CPU counter inline at the rate DPDK gives it (rte_get_tsc_hz()), or at the rate it measures againstCLOCK_MONOTONIC_RAWat start where DPDK’s is more than 100 ppm off: no system call, no call into Rust. It falls back toCLOCK_MONOTONICwhen the kernel’s clock source is not the counter or a hypervisor’s view of it, the TSC is not invariant (x86_64), or DPDK’s rate is notCNTFRQ_EL0(aarch64).RUSTERON_DPDK_CLOCK=monotonicforces it. Counter type 80 andDpdkTransport::clock()say which, and why. - NIC receive timestamps:
RUSTERON_DPDK_RX_HW_TIMESTAMP=1. Only AWS ENA offers them (Gen7+ instances in aprecision-timeplacement group, DPDK 25.11+); Azure MANA does not. They mean something only while chrony keeps the host on the NIC’s clock (time sync). - Media receive timestamps (
media-rcv-ts-offset=reservedon a subscription). One run (Graviton G3, DPDK): p50 within 0.07 µs of without at idle and every steady rate; idle max 153 against 79 µs; at 1,344 B bursts 1 of 5 reps lossy (short sends) against 0. Not separated (BENCHMARKS.md). Repeat before relying on it. - Your own code:
quantafor durations,CLOCK_REALTIMEfor stamps (clocks).
Best practices
- Measure the kernel path beside DPDK in every run, on the same pair, in random order. Compare numbers from one pair only.
- Count loss, not only latency: NAKs, retransmits, NIC drops and short sends before and after each run. A flat-out rate that loses is a ceiling, not an operating point.
- Fail closed. A DPDK transport that cannot start exits; keep it that way, so a run can never measure kernel UDP by mistake.
- Set
ERGON_HOST_CHECKS=requireonce the host is tuned: the driver then refuses to start while a check warns, so a reboot that undid a setting stops the next start. A NIC reset while it runs is only reported, and underdpdk-enanothing is re-checked.
Pitfalls
| symptom | cause | fix |
|---|---|---|
| start fails on Azure | transmit ring above MANA’s 256 | RUSTERON_DPDK_TX_DESCRIPTORS=256 |
| start fails naming the ENA’s BAR | the NIC’s memory window (BAR) is not mapped write-combined, so ENA’s low-latency queue cannot run | node: igb_uio with wc_activate=1, or a write-combining vfio-pci with RUSTERON_DPDK_VFIO_WC=1 |
| Aeron refuses every log buffer | hugetlbfs mounted without size= | node: mount it sized |
| window smaller than asked | net.core.rmem_max below 2 MiB | node: raise it to 16 MiB |
| a channel stops at a fixed rate of 1,344 B on AWS | one flow’s Gbit/s cap | more channels, on different ports |
| millisecond spikes about once a minute on Azure | not traced: an ergon-io run saw stalls up to 2.1 ms at seconds :49–:51 of every minute, on both hosts (io benchmarks) | keep busy threads off the cores the spin test flags; the spin test’s effect is not measured here |
| no gain on localhost | host-local channels use kernel sockets | expected; use IPC |
Deployment
A busy-spinning thread is only as fast as its host. Start with Kubernetes HFT tuning: one table of every setting, where it goes (node or pod) and its evidence.
| page | for |
|---|---|
| Kubernetes HFT tuning | start here: the settings table, the measured runs, copy-paste blocks, three checks |
| AWS and Azure hosts, x86 and Graviton | how to apply each node setting on a VM or node |
| Kubernetes | whole cores, no CPU quota, SMT off, the node bootstrap; the EKS manifests |
| Benchmarks: x86 and Graviton | which platform: latency and cost per hour, side by side |
| Java for Archive and Cluster | the JVM and Aeron settings for Archive and Cluster |
| Latency pods | the five grants a normal pod cannot make |
| Shared CPUs | k8s/nri-shared-cpus: exclusive CPUs plus the node’s shared ones |
| Clocks | which clock to read, and time sync on the node |
The DPDK transport in this repository is a proof of concept.
Glossary
Network, memory, cloud and measuring terms: DPDK chapter’s glossary. Host, Kubernetes and Java terms:
| term | what it is | pros / cons, when it matters |
|---|---|---|
| C-state | how deeply an idle CPU sleeps | deeper saves power but wakes later. A spinning core never idles; capping is mixed (C-states) |
| timer tick, RCU callbacks | kernel work on every CPU: a regular timer interrupt; deferred clean-up | isolcpus keeps tasks off, nohz_full stops the tick, rcu_nocbs moves the callbacks (isolation) |
interrupt affinity, irqbalance | the CPUs an interrupt may run on; irqbalance is a daemon that moves interrupts around | stop irqbalance, pin interrupts to housekeeping, pin again after every NIC reset |
| IOMMU passthrough | IOMMU: the unit that checks which memory a device may reach. Passthrough (iommu=pt; Graviton iommu.passthrough=1) skips it for the host’s own drivers | lets igb_uio work on metal; harmless on a VM |
| NUMA node | a group of cores and the memory nearest them | keep a pod’s CPUs and devices on one; every lab host had only one |
| KSM, NUMA balancing | kernel background work: merging identical pages; moving pages nearer the CPU that uses them | KSM merges only memory marked mergeable (MADV_MERGEABLE), which Aeron and ergon-io do not mark; NUMA balancing matters only with several NUMA nodes. The EKS node turns both off. Not measured here |
| spin test | spin on every CPU for two minutes; note any that stall 300 µs or more | a node-level check: a pod cannot choose its cores. Not measured here |
-march, -mcpu, target-cpu | compiler options that build for one CPU: -march, -mcpu (C), target-cpu (Rust). On AArch64 only -mcpu sets the core | Graviton: without them, gcc calls a library helper for each atomic add |
clocksource=tsc, tsc=reliable | boot options: the kernel keeps time with the CPU counter, and tsc=reliable turns its runtime checks off | it turns the kernel’s TSC checks off. The EKS node takes it only on bare metal with RUSTERON_FORCE_TSC=1, never on a VM, and records the choice (node/provision-node.sh) |
| wall clock, NTP, PTP | the time of day (UTC), kept right by chrony over the network (NTP) or from a hardware clock (PTP) | pro: matches other hosts and NIC stamps. Con: a correction can step it back; time intervals with quanta or Instant |
| kubelet | the agent on each Kubernetes node that starts pods and gives them CPUs, memory and devices | it must be told to give whole cores |
| Guaranteed QoS | a pod whose requests equal its limits on every resource | only these, with whole CPUs, get cores of their own from the static CPU manager |
| SMT (hyper-threading) | two logical CPUs on one physical core, sharing its execution resources | a pod with cpu: 1 shares the core with its twin. Turn SMT off on x86 latency nodes: one CPU is then one core, as on Graviton |
| CPU quota (CFS quota) | a container’s CPU limit as the kernel applies it: at most the quota of CPU time in each 100 ms period | a busy-spinning thread runs at the cap; when it is used up the kernel stops the whole container until the period ends. On kubelet 1.33+ the quota goes away by itself for a container with exclusive CPUs; do not turn it off for the whole node |
| static CPU manager, topology manager | kubelet policies: whole cores for Guaranteed pods; a pod’s CPUs and devices on one NUMA node | with full-pcpus-only, no hyperthread is shared with another pod |
| cpuset | the CPUs a pod may run on | pin each busy thread to its own CPU from it (Cpus_allowed_list) |
| DaemonSet | one pod on every matching node | here, one media driver per DPDK node |
| device plugin | a node service that offers devices to pods as a resource | here, ENA pairs bound to igb_uio |
taint, toleration, nodeSelector | a taint keeps pods off a node unless the pod tolerates it; a nodeSelector sends a pod to labelled nodes | together: only latency pods (and the DaemonSets that must run there) land on latency nodes |
| LimitRange, ResourceQuota | namespace defaults and caps: a LimitRange gives every container a default CPU request and limit; a ResourceQuota caps the namespace | an unlabelled pod cannot then ask for half a node (keep other pods in check) |
| PriorityClass | how important a pod is: a higher class preempts a lower one | give latency pods a high class and the rest a low one |
hostPath | a directory of the node, mounted into a pod | here, the node’s hugetlbfs for Aeron’s directory |
hostNetwork | a pod on the node’s network, not the pod network’s virtual interface | takes a kernel-UDP pod off that interface |
| Archive, Cluster | Aeron’s Java services. Archive records and replays streams; Cluster runs your service on several nodes that agree on one log (the consensus module) | ergon is only a cluster client; these run on Java |
| Agrona | the Java library of buffers and agents that Aeron is built on | its use of jdk.internal.misc.Unsafe is why the JVM needs the first --add-opens |
| LTS | a Java release with long-term support (17, 21, 25) | run Archive and Cluster on 25 |
| JIT | the JVM’s compiler: it turns hot code into machine code while the program runs | code-cache pressure can undo its work (deoptimise) |
| GC, barrier | GC: the garbage collector, which frees objects no longer used. Barrier: the extra work it adds to the program’s memory accesses | ParallelGC is what Aeron’s scripts use: convention, not a measured collector A/B |
| safepoint | a pause in which every Java thread stops | every one is logged with its cause to tmpfs (-Xlog:gc*,safepoint, Java) |
| duty cycle | one pass of an agent’s work loop | *.cycle.threshold=1ms counts each one over 1 ms, so stalls show |
| spy | a subscription that reads a publication on its own host, from its log buffer, not the network | see aeron.spies.simulate.connection (Java) |
| sparse file, pre-touch | sparse: disk pages appear only on first write. Pre-touch: every page is faulted in at start | no first-write page faults, but pre-touched terms count against a container’s memory limit |
Kubernetes HFT tuning
Every latency setting in this guide, where it goes, and its evidence. Measured links to a committed run below. Not measured here means the setting rests on kernel, vendor or Kubernetes documentation only. Node needs node-level access (boot line, kubelet, root on the host); a pod cannot set it. Pod works from the pod spec or the app.
Terms: deployment glossary.
This chapter is the list. It is not an image and it is not a second chart.
The shared-CPU grant is Shared CPUs: public image
ghcr.io/mimran1980/ergon-shared-cpus:0.1.0 and chart
oci://ghcr.io/mimran1980/charts/ergon-shared-cpus version 0.1.0.
The refusal of an exec probe on a pod labelled ergon.io/latency: hot is
the admission policy in Latency Pods. Hugepages, the
isolated cpuset partition, io_uring seccomp and the NIC PHC are opt-in
grants in that same image. Chart 0.1.0 does not write seccomp, ptp or
schedFifo, so those stay off. The kind sample
k8s/nri-shared-cpus/examples/try installs the public chart, prints the
shared-CPU grant, and shows the admission denial. It does not measure the
rows below.
Settings
| setting | what it does | where | evidence | apply |
|---|---|---|---|---|
| CPU isolation | keeps tasks, the timer tick, RCU callbacks and new interrupts off the busy CPUs | node | measured: isolation | boot line isolcpus=managed_irq,domain,<busy> nohz_full=<busy> rcu_nocbs=<busy> irqaffinity=<housekeeping> (hosts) |
NIC queue interrupts off the loop’s CPU, irqbalance off, re-pinned after every NIC reset | the interrupt handler does not run on the loop’s CPU | node | measured: interrupt placement | hosts; EKS: node/irq-affinity.sh watch |
ENA moderation off: adaptive-rx off rx-usecs 0 | one interrupt per packet; no adaptive-moderation work on the polling CPU | node | measured, x86, polling backend: moderation | ethtool -C <if> adaptive-rx off rx-usecs 0, again after every NIC reset |
| SMT off (x86) | one Kubernetes CPU is one physical core | node | measured, one case (SQPOLL on the loop’s sibling): SMT sibling | EC2 CpuOptions ThreadsPerCore: 1, or nosmt (SMT off) |
static CPU manager, full-pcpus-only | Guaranteed whole-CPU containers get CPUs of their own | node | Kubernetes CPU management; not measured here | kubelet block |
reservedSystemCPUs = the housekeeping CPUs | system daemons stay off the exclusive CPUs | node | same Kubernetes page; not measured here | kubelet block |
| no CPU quota on a busy container | the kernel never stops the container mid-period | node + pod | sched-bwc, Kubernetes PR #127525 (kubelet 1.33+); not measured here (tune/src/quota.rs T9) | kubelet 1.33+, static CPU manager, Guaranteed pod, whole-number cpu. Not cpuCFSQuota: false (why) |
Guaranteed QoS, whole-number cpu | the only pods the static CPU manager gives exclusive CPUs | pod | Kubernetes CPU management page; not measured here | pod block |
| each busy thread pinned to one CPU of the pod’s cpuset | the thread stays on its exclusive CPU | pod | the isolation runs pinned every loop; pinning alone is not measured | sched_setaffinity from Cpus_allowed_list (io: Kubernetes) |
uring-sqpoll: the SQPOLL thread on its own CPU | the kernel’s polling thread does not share the loop’s core | pod | measured at 10 and 100 connections: SMT sibling | ergon-io picks a CPU of the pod’s own outside the shared CPUs (shared CPUs) |
transparent hugepages never | no khugepaged merging under busy threads | node | transhuge; not measured here | transparent_hugepage=never on the boot line |
Aeron directory on a sized 2 MiB hugetlbfs, AERON_FILE_PAGE_SIZE=2097152, AERON_TERM_BUFFER_SPARSE_FILE=false | no 4 KiB page faults on the log buffers | node (pool, mount) + driver pod | hugetlbpage; not measured here. An unsized hugetlbfs reports no free space, which Aeron’s storage check refuses (aeron_fileutil.c:952-959 in rusteron-media-driver 0.2.11) | hosts; driver env as in md-dpdk/deploy/aws-eks/media-driver-config.yaml |
AERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY=true | a client faults its mapped terms in at start | pod (client) | aeronc.h:170 (rusteron-client 0.2.11); not measured here | pod block |
net.core.rmem_max, wmem_max ≥ Aeron’s socket buffers | lets the kernel grant AERON_SOCKET_SO_RCVBUF | node | functional, not latency: the driver refuses a window larger than the granted SO_RCVBUF (aeron_driver.c:407-456, rusteron-media-driver 0.2.11). Aeron’s default is 128 KiB (aeron_driver_context.c:191); only a raised buffer, such as the EKS ConfigMap’s 2 MiB, needs it | sysctl -w net.core.rmem_max=16777216 net.core.wmem_max=16777216 |
| automatic NUMA balancing off | no balancing page faults on pinned threads | node | not measured here (every lab host had one NUMA node) | sysctl -w kernel.numa_balancing=0 |
topology manager single-numa-node | a pod’s CPUs and devices on one NUMA node | node | Kubernetes topology manager docs; not measured here (one-node lab hosts) | kubelet block |
| C-states capped at C1 (x86) | an idle housekeeping core wakes faster | node | VM: measured, mixed (C-states); metal: no gain (hosts). Not recommended on this evidence | leave unset (the EKS node sets no cap) |
SCHED_FIFO on the loop | real-time priority | pod | measured, mixed: SCHED_FIFO. Off by default | ergon-cpus --fifo (latency pods) |
ENA receive ring rx 4096 | room for bursts | node | not measured here | ethtool -G <if> rx 4096 |
global busy polling (net.core.busy_read, busy_poll) | every socket read on the host spins | node | ergon-io: not measured as a global sysctl; recv-busy sets SO_BUSY_POLL per socket instead (fair run). Aeron’s media driver, kernel UDP: measured, polling not isolated. x86 round 7, with a 4,096 ring, lower p50 than plain kernel UDP (two settings differ); Graviton G1 lost at 1 M msgs/s in 5 of 5 reps (four settings differ) (BENCHMARKS.md) | not recommended on this evidence: leave unset. ergon-md-dpdk check M4 reports the state as information |
| quiet-core spin test | finds CPUs the platform stalls | node | not measured here: the cloud lab skips CPUs that stalled 300 µs in a 120 s spin (md-dpdk/benches/cloud/vm.sh:2022-2029). A pod cannot choose its cores | run it on new nodes; cordon or replace noisy ones |
no exec probes, no restartPolicy: Always sidecars on hot pods | no probe process lands on the loop’s CPU | pod | an exec starts on any CPU of the cpuset (k8s/nri-shared-cpus/README.md, Limits); not measured here | httpGet/tcpSocket/grpc probes; the admission policy enforces it |
| both hosts in one cluster placement group | shorter network path between the pair | node (cloud) | AWS docs; not measured here | the node group’s placement group |
build for the CPU (-mcpu, target-cpu) | Graviton: inline atomics instead of gcc’s outline helpers | build | not measured here | hosts |
| chrony on a PTP clock | CLOCK_REALTIME stays close to UTC | node | vendor docs; not a latency setting (Clocks) | time sync |
Evidence
Every row: one run, arms compared only with the same run’s baseline. µs.
Raw tables: git show <commit>:<path> as named. Hosts: AWS c7i.2xlarge
receiver (SMT on), c7i.xlarge sender, kernel 7.0; Azure
Standard_D8ds_v6, kernel 6.17; Graviton c8g.2xlarge
(io/benches/BENCHMARK.md).
Boot lines and NIC settings per run are in the committed env-rcv*.txt
next to each run id under io/benches/results/.
Isolation
A loop that only reads the CPU counter, on the CPU the busy loop uses,
records every gap. One sample per boot line; the loop sends nothing.
Sources: git show 2517309d:io/benches/results/flags-aws-20261014T163114Z/tables.md
and …/flags-azure-20261014T144146Z/tables.md, “Platform noise”.
| host | boot line | gaps >5 µs /min | >20 µs /min | >100 µs /min | longest gap (max) |
|---|---|---|---|---|---|
| AWS c7i | none | 60,120 | 120.0 | 34.5 | 146.95 |
| AWS c7i | isolcpus=managed_irq,domain + irqaffinity | 60,063.5 | 4.0 | 0.0 | 52.30 |
| AWS c7i | + nohz_full, rcu_nocbs | 50.0 | 4.5 | 1.0 | 133.90 |
| Azure | none | 17,189 | 120.0 | 3.0 | 612.94 |
| Azure | isolcpus=managed_irq,domain + irqaffinity | 10,656 | 47.0 | 0.0 | 58.94 |
| Azure | + nohz_full, rcu_nocbs | 239.5 | 37.0 | 0.0 | 67.82 |
isolcpus cut gaps over 20 µs. Adding nohz_full cut gaps over 5 µs
(about 1,000 a second before it, the tick rate it stops:
NO_HZ) by 97.7 % (Azure) and
99.9 % (AWS). The longest gap is one event per sample and does not rank the
two isolated lines.
Interrupt placement
Graviton (fair run, io/benches/results/flags-aws-20261019T175248Z/fair.md,
committed): WebSocket round trip, interrupt-driven backends (epoll-spin,
mio, uring-spin), moderation off everywhere, two rounds. Six points’
senders fell behind; the file’s top note names them.
| connections | queue IRQs on the loop’s CPU: p99 / max | on a core of their own: p99 / max |
|---|---|---|
| 1 | 45.87–47.17 / 134.98–161.30 | 46.97–48.37 / 107.72–145.15 |
| 4–64 | 52.31–86.46 / 128.56–640.75 | 49.15–53.85 / 135.64–425.45 |
Same file, own-core IRQs, p50: at 1 connection the interrupt backends
read 38.78–39.33 against 36.01–37.04 for NIC polling; at 4 connections the
two families overlap. From 16 connections recv-busy (48.98–49.63 at 16,
78.14–78.29 at 64, from-due p99 9.7 s) and recv-busy-pq (44.26–44.73) sit
above the interrupt backends (39.27–40.42); uring-sqpoll stays level.
x86 (git show 2517309d:io/benches/results/flags-aws-20261015T062428Z/tables.md,
irqlocal arm: every queue IRQ on the loop’s CPU), recv-busy market data,
kernel arrival → decoded:
| arm | p50 | p99 | max |
|---|---|---|---|
| baseline, rounds 1 / 2 | 3.42 / 2.86 | 7.87 / 8.05 | 36.31 / 47.96 |
| IRQs on the loop’s CPU, rounds 1 / 2 | 6.49 / 4.68 | 26.09 / 20.11 | 98.61 / 73.64 |
The WebSocket round trip in that run showed no consistent change.
Interrupt moderation
git show 2517309d:io/benches/results/flags-aws-20261015T070339Z/tables.md,
AWS c7i, recv-busy, 10,000 round trips a second. Baseline: adaptive-rx on
(io/benches/scripts/vm-setup.sh nodim). Rounds 1 / 2.
| arm | WebSocket p50 | p99.99 | max | HTTP p99.99 | max |
|---|---|---|---|---|---|
| baseline | 43.90 / 40.65 | 118.85 / 116.53 | 1,023.80 / 259.23 | 118.88 / 77.06 | 445.88 / 432.70 |
adaptive-rx off | 40.88 / 40.93 | 71.89 / 76.02 | 354.05 / 970.07 | 116.06 / 83.44 | 980.65 / 201.50 |
adaptive-rx off rx-usecs 0 | 41.90 / 41.55 | 78.50 / 80.41 | 233.02 / 343.68 | 80.99 / 73.78 | 201.65 / 226.79 |
WebSocket p99.99 fell 36–47 µs in both rounds. HTTP p99.99 moved no more than the 42 µs gap between its own two baselines. One run. The interrupt path with moderation on is not measured.
SMT sibling
git show 2517309d:io/benches/results/flags-aws-20261015T122127Z/tables.md,
AWS c7i, uring-sqpoll market data, kernel arrival → decoded, three rounds:
the SQPOLL kernel thread on its own core against the loop’s SMT sibling.
| connections | own core: p50 / p99 / max | loop’s sibling: p50 / p99 / max |
|---|---|---|
| 1 | 3.46–3.79 / 8.42–12.92 / 31.57–118.34 | 4.01–4.30 / 6.99–11.69 / 34.72–45.83 |
| 10 | 5.18–6.04 / 20.41–23.81 / 77.03–109.42 | 7.53–8.37 / 29.46–32.77 / 95.85–349.01 |
| 100 | 5.00–5.28 / 20.56–21.98 / 76.01–301.08 | 7.46–7.81 / 32.46–34.17 / 94.51–111.56 |
At 1 connection the own core is lower at p50 and the sibling at p99. At 10 and 100 connections the own core is lower at p50 and p99 in every round. This is one busy sibling (SQPOLL); other sibling loads are not measured.
C-states
…/flags-aws-20261015T062428Z/tables.md, cstate arm: every idle state
deeper than C1 off, AWS c7i VM. Rounds 1 / 2.
| arm | WebSocket p50 | p99.99 | max | HTTP p99.99 | max |
|---|---|---|---|---|---|
| baseline | 36.92 / 37.69 | 115.88 / 104.24 | 454.17 / 967.90 | 112.84 / 106.21 | 1,009.61 / 1,049.79 |
| C1 cap | 39.86 / 39.78 | 133.30 / 128.17 | 464.79 / 1,035.66 | 106.05 / 90.25 | 492.76 / 227.01 |
WebSocket worse at p50 and p99.99, HTTP better at p99.99: no consistent gain.
SCHED_FIFO
…/flags-aws-20261015T070339Z/tables.md, rt arm: the loop under
chrt -f 50, kernel.sched_rt_runtime_us=-1. Rounds 1 / 2.
| arm | WebSocket p99.99 | max | HTTP p99.99 | max | loop CPU timer interrupts /s |
|---|---|---|---|---|---|
| baseline | 118.85 / 116.53 | 1,023.80 / 259.23 | 118.88 / 77.06 | 445.88 / 432.70 | 11–23 |
SCHED_FIFO | 119.81 / 73.87 | 579.59 / 1,008.92 | 75.62 / 75.47 | 982.27 / 229.95 | 533–947 |
Mixed. Under the policy the loop’s nohz_full CPU took 533–947 timer
interrupts a second, against 11–23 without it.
Copy-paste
Kubelet (EKS NodeConfig; k3s and AKS: Kubernetes):
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cpuManagerPolicy: static
cpuManagerPolicyOptions:
full-pcpus-only: "true"
reservedSystemCPUs: "0" # the housekeeping cores
topologyManagerPolicy: single-numa-node
# cpuCFSQuota stays true: the kubelet drops it for exclusive CPUs (1.33+)
App pod: Guaranteed, whole cores, Aeron’s directory on the node’s
hugetlbfs. AERON_FILE_PAGE_SIZE and AERON_TERM_BUFFER_SPARSE_FILE are
read only by the media driver: set them on the driver’s pod.
apiVersion: v1
kind: Pod
spec:
priorityClassName: <your latency class>
containers:
- name: app
resources:
requests: { cpu: "2", memory: 1Gi, hugepages-2Mi: 1Gi }
limits: { cpu: "2", memory: 1Gi, hugepages-2Mi: 1Gi }
env:
- { name: AERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY, value: "true" }
volumeMounts:
- { name: huge, mountPath: /mnt/huge-aeron }
volumes:
- name: huge
hostPath: { path: /mnt/huge-aeron, type: Directory }
Pin each busy thread to one CPU of the pod’s cpuset before the loop starts (io: Kubernetes).
Check it
# 1. no CPU quota on the busy container
cat /sys/fs/cgroup/cpu.max # expect: max 100000
grep throttled /sys/fs/cgroup/cpu.stat # nr_throttled stays flat
# 2. the pod got whole cores of its own
grep Cpus_allowed_list /proc/self/status
# 3. the host: ergon-tune names each missing setting and its fix
# (runs at start-up in ergon-io's Driver::new and the media driver)
ERGON_HOST_CHECKS=require (media driver alias
RUSTERON_MEDIA_DRIVER_HOST_CHECK) refuses to start while any check warns
or a proof check stays unproven
(ergon-tune). In a stock
static-CPU-manager pod ergon-io’s K2 stays unproven, so require refuses
it (checks inside a pod).
Open
- No committed run measures THP, hugepages, the RX ring, NUMA settings or the CPU quota on latency.
- Most maxima above are not traced to a cause.
More
| page | for |
|---|---|
| Kubernetes | the EKS manifests, No CPU quota, SMT off, keeping other pods off the node |
| Hosts | the same settings on a VM, and how to apply each |
| Benchmarks | platform and cost side by side |
| Shared CPUs | helper threads on the node’s housekeeping CPUs (k8s/nri-shared-cpus) |
| Latency pods | hugepages, io_uring, PHC devices on a pod (k8s/nri-latency-pods) |
AWS and Azure Hosts, x86 and Graviton
How to apply each node setting on a VM or a Kubernetes node. Which settings
have evidence, and the figures: Kubernetes HFT tuning. The labs
(md-dpdk/benches/cloud/host.sh and vm.sh, io/benches/scripts/) and the
EKS node bootstrap (md-dpdk/deploy/aws-eks/node/) apply these settings.
Terms: deployment glossary, network and DPDK glossary.
Instances
| platform | measured on | notes |
|---|---|---|
| AWS x86 | c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.metal-24xl | metal costs 6 times a c7i.4xlarge (cost). The shipped EKS driver DaemonSet asks for 6 exclusive CPUs plus 1 reserved: 7 cores, so a c7i.4xlarge (8 cores with SMT off) |
| AWS Graviton | c8g.xlarge, c8g.2xlarge (Graviton4), c9g.xlarge, c9g.2xlarge (Graviton5) | no SMT: a vCPU is a core |
| Azure x86 | Standard_D8s_v6, D8ds_v6, D16s_v6 (Xeon Platinum 8573C), accelerated networking (MANA) |
- Put both hosts in one cluster placement group (AWS) or proximity placement group (Azure). Not measured here.
- Compare only within one run and one host pair: two pairs of one instance type can differ.
Kernel command line
Keep the scheduler, the timer tick, RCU callbacks and new interrupts off
every CPU but housekeeping. <busy> is the busy CPUs, <hk> housekeeping:
isolcpus=managed_irq,domain,<busy> nohz_full=<busy> rcu_nocbs=<busy> irqaffinity=<hk>
Effect: isolation. Kernel reference: kernel-parameters, NO_HZ. On x86 isolate whole cores (SMT off, so one CPU is one core).
| platform | also add | why |
|---|---|---|
| AWS x86, bare metal | iommu=pt | igb_uio (DPDK) does not program the IOMMU; a VM has none |
| AWS Graviton, bare metal | iommu.passthrough=1 | the same, on Arm |
| EKS DPDK node (x86) | nosmt transparent_hugepage=never hugepagesz=2M hugepages=<n>; no C-state cap | node/provision-node.sh, step 6. No cap: C-states |
clocksource=tsc tsc=reliable turns the kernel’s TSC checks off. The EKS
node takes it only on bare metal with RUSTERON_FORCE_TSC=1, never on a VM
(provision-node.sh).
Interrupts and the NIC
Interrupt moderation means the NIC waits to batch several packets into one interrupt: less CPU, more latency (ENA driver).
| setting | how | evidence |
|---|---|---|
every movable interrupt on housekeeping, irqbalance stopped | /proc/irq/*/smp_affinity_list, /proc/irq/default_smp_affinity; systemctl disable --now irqbalance | interrupt placement |
| NIC queue interrupts pinned again after every NIC reset | ENA puts each queue’s interrupt back on CPU i when the interface comes up (reset, ethtool -G/-L, a NIC back from DPDK). The EKS node re-pins on each link event (node/irq-affinity.sh watch) | as above |
| ENA moderation off | ethtool -C <if> adaptive-rx off rx-usecs 0; ENA turns adaptive moderation back on when the interface comes up, so re-apply it with the pins | moderation, x86 polling backend only |
| receive ring | ethtool -G <if> rx 4096 | not measured here |
| global busy polling | net.core.busy_read, busy_poll | it reaches every socket read on the host. ergon-io: leave unset, it busy-polls per socket. Aeron’s media driver: hft |
C-states
A C-state is how deeply an idle CPU sleeps: deeper saves power but wakes later. A spinning core never idles; the housekeeping core that takes the interrupts does.
| host | setting | evidence |
|---|---|---|
| x86 VM | leave it | capping at C1 was mixed: C-states |
| x86 metal | leave it | c7i.metal-24xl, round 4: idle not separated; at 288 B 1 M msgs/s at least one rep of each capped arm had a p50 above 4 ms (BENCHMARKS.md) |
| Graviton | nothing to set | the OS has no C-states to pick: an idle core waits for an interrupt (WFI) |
Memory
| setting | how | evidence |
|---|---|---|
| transparent hugepages off | transparent_hugepage=never on the boot line, or never into /sys/kernel/mm/transparent_hugepage/enabled | transhuge; not measured here |
| 2 MiB hugepage pool | hugepagesz=2M hugepages=<n> on the boot line, or vm.nr_hugepages | DPDK and Aeron’s hugetlbfs draw from it |
| Aeron’s directory on a sized hugetlbfs | mount -t hugetlbfs -o pagesize=2M,size=1G nodev /mnt/huge-aeron; on the media driver AERON_FILE_PAGE_SIZE=2097152, AERON_TERM_BUFFER_SPARSE_FILE=false | without size= Aeron sees no free space and refuses every log buffer (hft) |
net.core.rmem_max, wmem_max | 16 MiB | needed only when Aeron’s socket buffers are raised above the kernel’s limit (hft) |
| automatic NUMA balancing off | sysctl -w kernel.numa_balancing=0 | not measured here (one-node lab hosts) |
Time
chrony on the Amazon Time Sync Service (169.254.169.123), or on the ENA’s
PTP clock. Azure’s image runs chrony on ptp_hyperv. Configuration:
time sync.
CPU layout
One physical core per role. SMT is off on x86 latency nodes (SMT off), so one CPU is one core:
| core | x86 (SMT off) | Graviton (no SMT) |
|---|---|---|
| housekeeping: interrupts, Aeron’s conductor, the EAL’s main thread, client conductors | CPU 0 | CPU 0 (CPUs 0–1 on 8 cores for ergon-io) |
| application, busy-spinning | one CPU | one CPU |
| media driver sender | one CPU | one CPU |
| media driver receiver | one CPU | one CPU |
SHARED_NETWORK(sender and receiver on one thread) saves a core and costs latency: Graviton G1, DPDK, 1,344 B bursts p50 522 against 234 µs (threads and cores).- ergon-io
uring-sqpoll: its kernel thread on a CPU of its own beat the loop’s SMT sibling at 10 and 100 connections; at 1 the result is mixed (SMT sibling). With SMT off, ergon-io takes another CPU of the pod’s own. - Name one CPU per core.
thread_siblings_liststill lists the offline twin; use the lowest id of each core (node/provision-node.sh:167-169). AWS numbers x86 siblings N and N + cores, Azure adjacently (0–1, 2–3).
Build for the CPU
- x86: rusteron builds Aeron’s C with
-march=nativeunlessRUSTERON_C_MARCHnames another, so build the media driver on, or for, the instance type it runs on. ergon-io’s measurements used generic x86-64 builds. - Graviton: Rust with
RUSTFLAGS=-Ctarget-cpu=neoverse-v2(Graviton4),neoverse-v3(Graviton5) ornative; C withCFLAGS=-mcpu=.... Without it, Ubuntu’s and Debian’s gcc call a library helper for each atomic (gcc’s-moutline-atomicsdefault on AArch64). On AArch64-marchsets only the architecture;-mcpusets the core. Latency effect not measured here.
Check it
ergon-tune runs these checks at start-up in ergon-io’s
Driver::new and the media driver, and prints each finding with its fix.
Clocks
The ergon-clock crate was removed on 2026-10-21. Its last measurement
write-up is
ergon-clock-BENCHMARKS.md.
This page is the current choice of clock.
Which clock for what
| need | read | why |
|---|---|---|
| a timestamp written anywhere: the wire, a log, storage | CLOCK_REALTIME: std::time::SystemTime::now() | UTC. Every thread and process on the host reads the same kernel clock, through the vDSO when the clock source is the counter (T12) |
| a duration or timer in one process | quanta: Clock::now(), or Clock::raw() then Clock::delta_as_nanos() | reads the CPU counter, scaled by one calibration per process against CLOCK_MONOTONIC; without a usable counter it reads CLOCK_MONOTONIC (quanta-0.13.0 src/lib.rs:333-344). Do not compare quanta values between processes: each calibrates on its own |
| a duration off the hot path | std::time::Instant | CLOCK_MONOTONIC: never goes back |
| the DPDK transport’s stamps | its own clock | nanoseconds since boot: Clocks and timestamps |
Not minstant for portable code: it reads the counter only on Linux
x86 and x86_64. On every other target, Graviton and macOS included, it
reads SystemTime, a wall clock that can go back (minstant-0.1.7
src/lib.rs:71-82).
Read cost, ns per read: median batch (slowest batch); each batch is the
mean of 10,000 back-to-back reads, so the slowest single read is not
recorded. AWS, isolated CPU, 2026-10-16 runs; c9g is a 50-batch run, the
others 200. Source: committed
md-dpdk/benches/cloud/results/clockbench-aws-20261016.txt.
| read | c7i.2xlarge | c8g.xlarge | c9g.2xlarge |
|---|---|---|---|
quanta Clock::now() as ns | 15.99 (16.95) | 12.50 (12.89) | 10.92 (11.23) |
std::time::Instant::now() | 24.85 (25.99) | 31.69 (33.35) | 21.29 (21.68) |
SystemTime::now() as ns | 28.49 (29.72) | 39.66 (40.58) | 28.51 (28.92) |
minstant::Instant::now() | 14.55 (15.44) | 39.64 (42.22) | 28.16 (28.50) |
Rules
- Do not subtract two
CLOCK_REALTIMEstamps for a duration: chrony or an admin can step the clock back. - Build the quanta
Clockbefore the loop. Its first calibration busy-waits (quanta’s limit: 200 ms,src/lib.rs:198), andquanta::Instant::now()calibrates a global clock on first use. - Do not stamp with a
*_COARSEclock: it moves once a kernel tick (1–10 ms, byCONFIG_HZ). - A one-way time between two hosts is only as good as both hosts’ time sync. Use the same time source on every host that compares stamps.
Aeron’s clocks
Aeron keeps its own clocks (aeron_clock.c:87-130 in rusteron-client
0.2.11). The driver’s agents read a cached copy (aeron_clock_cache_t).
| Aeron API | Linux source | returns |
|---|---|---|
aeron_nano_clock() | CLOCK_MONOTONIC | ns since boot |
aeron_epoch_clock() | CLOCK_REALTIME_COARSE | ms since the Unix epoch, one kernel tick resolution |
aeron_clock_cache_t | both, cached | as of the last refresh |
Aeron::nano_clock() and Aeron::epoch_clock() read Aeron’s time; rusteron
has no setter for them.
Set up time sync
Set it on the node: a pod shares the node’s CLOCK_REALTIME.
-
AWS: chrony on the Amazon Time Sync Service. Not systemd-timesyncd: it keeps no error bound.
server 169.254.169.123 prefer iburst minpoll 4 maxpoll 4 makestep 0.0001 3For PTP-grade time: an instance type with a PTP hardware clock (EC2
PhcSupport), the ENA PTP clock enabled (in-tree ENA from Linux 6.17: devlinkenable_phc; Amazon’s driver:phc_enable=1), then:refclock PHC /dev/ptp_ena poll 0 delay 0.000010 prefer server 169.254.169.123 iburst minpoll 4 maxpoll 4 makestep 0.0001 3The service smears leap seconds: remove any
leapseclistorleapsectzline. -
Azure: chrony on the Hyper-V PTP clock:
refclock PHC /dev/ptp_hyperv poll 3 dpoll -2 offset 0. -
x86 and Graviton are set up the same way.
Check it with ergon-tune: T11
(how CLOCK_REALTIME is kept: PTP-grade, NTP or unsynchronised, and its
error bound) and T12 (the kernel’s clock source) before the loop, then
ClockWatch::check from housekeeping. On the node: chronyc tracking.
Kubernetes
A busy-spinning pod needs what a busy-spinning process on a VM needs: a whole
core per busy thread, interrupts elsewhere, no page faults. Kubelet has to be
told to give it that. The worked example is the EKS deployment of the DPDK
media driver,
md-dpdk/deploy/aws-eks.
Its node and pod settings apply to any low-latency pod; its DPDK parts are a
proof of concept, not for production.
Terms: deployment glossary, network and DPDK glossary.
x86 (c7i) | Graviton | |
|---|---|---|
| manifests and node bootstrap | yes: Amazon Linux 2023, self-managed nodes | none: the bootstrap refuses non-Intel CPUs and the images are linux/amd64 |
| measured | no committed data. The live test runs the driver pod on 4 CPUs through an overlay (md-dpdk/deploy/aws-eks/README.md, Live test) | not measured |
The kubelet and pod settings are not x86-specific, but no Graviton node has been built with them. For Graviton’s kernel arguments see hosts.
Latency checklist
Use a dedicated node pool, tainted so only latency pods land there (keep other pods in check).
Node (kubelet and host):
- SMT off on x86, so one Kubernetes CPU is one physical core, the same as on Graviton (SMT off).
cpuManagerPolicy: staticwithfull-pcpus-only: "true": Guaranteed pods get whole cores of their own.reservedSystemCPUs: the housekeeping cores, the same ones the kernel’s boot line leaves out of isolation.topologyManagerPolicy: single-numa-node: a pod’s CPUs and devices on one NUMA node.- No CPU quota on the latency containers: kubelet 1.33 or later (the labs
ran 1.36),
cpuCFSQuotaleft at its defaulttrue. Do not setcpuCFSQuota: false: it removes every pod’s CPU limit on the node (why). - The host settings under the kubelet: isolated cores, interrupts on housekeeping, transparent hugepages off, hugepages (hosts).
Pod:
- Guaranteed QoS: requests equal limits for every resource, in every container.
- A whole-number CPU request on every busy-spinning container. With SMT off, one CPU is one core.
- Each busy thread pinned to its own core from the pod’s cpuset, under
SCHED_OTHER(SCHED_FIFOwas mixed: evidence). - On CRI-O only: the annotations in No CPU quota. containerd (EKS, AKS, k3s) ignores them.
Check:
- At start: ergon-io and the media driver run
ergon-tune, and T9 warns about a CPU quota. In
your own loop, call
t9_cpu_quotabefore it. - While it runs:
nr_throttledstays flat (verify). In your own loop, call ergon-tune’sThrottleWatch::checkfrom housekeeping.
Nodes
Kubelet gives Guaranteed pods whole cores of their own (the static CPU
manager) and keeps a pod’s CPUs and devices on one NUMA node (the topology
manager). node/userdata.sh writes this, with $reserved the housekeeping
CPUs (0 on a c7i.4xlarge: one CPU is one physical core, because SMT is
off):
kubelet:
config:
cpuManagerPolicy: static
cpuManagerPolicyOptions:
full-pcpus-only: "true"
reservedSystemCPUs: "$reserved"
kubeReservedCgroup: ""
systemReservedCgroup: ""
topologyManagerPolicy: single-numa-node
topologyManagerScope: pod
memoryManagerPolicy: None
flags:
- --node-labels=rusteron-dpdk/ena=true
- --register-with-taints=rusteron-dpdk=true:NoSchedule
full-pcpus-only: whole physical cores, never one hyperthread shared with another pod. With SMT off it is a safety net.kubeReservedCgroupandsystemReservedCgroupare emptied because kubelet rejectsreservedSystemCPUswhile they are set.memoryManagerPolicy: None: the deployedc7i.4xlargehas one NUMA node (node/userdata.sh). On a multi-socket type, revisit it.cpuCFSQuotais not set: it staystrue, so every pod keeps its CPU limit. The kubelet drops the quota itself on a container with exclusive CPUs (No CPU quota).
The node scripts (tested against fixtures by just test-dpdk-bootstrap):
| script | when | what it does |
|---|---|---|
node/provision-node.sh | once, then one reboot before kubelet | kernel command line: nosmt, every CPU but the reserved core isolated, interrupts on that core, no C-state cap (evidence), iommu=pt, transparent_hugepage=never, 2 MiB hugepages, so kubelet’s first start advertises hugepages-2Mi |
node/bootstrap-dpdk-ena.sh | every boot, before kubelet | interrupts on housekeeping with a watcher that re-pins on each link event; irqbalance, KSM, NUMA balancing off; THP off; rmem_max/wmem_max 16 MiB; Aeron’s directory on a sized hugetlbfs at /mnt/huge-aeron; for DPDK the two spare ENAs on igb_uio. On failure the node still runs kernel UDP and advertises no DPDK pair |
Clocksource is left to the kernel. A forced clocksource=tsc tsc=reliable
turns the kernel’s TSC checks off, so bare metal takes it only with
RUSTERON_FORCE_TSC=1, after the TSC has been measured as the same across
cores against a PTP/PHC reference. A VM never gets it.
Kubelet’s reserved CPUs and the kernel’s housekeeping CPUs are the same set:
interrupts and system daemons stay off the pods’ cores. Dedicated nodes are
labelled rusteron-dpdk/ena=true and tainted rusteron-dpdk:NoSchedule.
SMT off
SMT (hyper-threading) runs two logical CPUs on one physical core. They share
the core’s execution resources: a pod with cpu: 1 shares a core with its
twin, and its latency follows what the twin does
(SMT sibling). Turn SMT off on x86 latency nodes, so
one Kubernetes CPU is one physical core, as on Graviton, which has no SMT.
| where | how |
|---|---|
| EC2, virtual machine | the node group’s launch template: CpuOptions with CoreCount (the type’s default core count) and ThreadsPerCore: 1. Both fields are required; there is no extra charge. Not on bare metal, and not on T2, C7a, M7a, R7a or Apple silicon Macs (EC2 will not change them; C7a, M7a and R7a already run one thread per core) |
| EC2 bare metal, self-managed | the kernel’s nosmt on the boot line. Before the kubelet’s first start, writing off to /sys/devices/system/cpu/smt/control works too: write it, then delete /var/lib/kubelet/cpu_manager_state (the CPU manager state lists the old CPUs, and the kubelet crash-loops without the delete) |
| Azure | a size without SMT: Fasv6, Falsv6, Famsv6 (AMD) or Dpsv6, Dplsv6 (Cobalt) |
| self-managed, k3s | nosmt on the kernel command line |
- With
ThreadsPerCore: 1the instance runs half its usual vCPU count (ac7i.4xlarge: 8 cores, 8 vCPUs, not 16). Size pods and quotas for cores. thread_siblings_liststill lists the offline twin. Name one CPU per core (the lowest id) inreservedSystemCPUsand in kernel arguments. The node scripts do.- The launch template that sets it:
node/launch-template.py. The live lab fails a node whose/sys/devices/system/cpu/smt/activeis1(md-dpdk/benches/cloud/aws/eks.sh).
Pods
- Guaranteed QoS with whole cores: requests equal to limits on every resource, and a whole-number CPU request.
- Pin each busy thread to its own core from the pod’s cpuset. Read it from
Cpus_allowed_listin/proc/self/status; in a privileged container/sys/fs/cgroup/cpuset.cpus.effectivelists every host CPU. One logical CPU per physical core, and Aeron’s client conductor off the busy cores. - Hugepages as a resource (
hugepages-2Mi), Aeron’s directory from the node’s hugetlbfs as ahostPath, andAERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY=trueon clients. - Handle SIGTERM. Kubelet stops a pod with it; a PID 1 with no handler ignores it until SIGKILL, closing nothing.
- Privileged only where needed: the DPDK driver pod (it maps the NIC and locks hugepages). A client that only reaches the driver through Aeron’s directory is not.
hostNetwork: truetakes a kernel-UDP pod off the pod network’s virtual interface. Not measured here.
Keep other pods in check
A dedicated node pool only works if nothing else lands there.
- Taint the latency nodes (
NoSchedule) and let only latency pods and the DaemonSets that must run there tolerate it: the CNI, kube-proxy, the node agent, a log collector, the media driver. Everything else stays off. - A
LimitRangeper namespace with a default CPU request and limit for containers, and aResourceQuotafor the namespace. An unlabelled pod then cannot ask for half a node. - Small CPU requests and limits on the DaemonSets that stay (log collectors, a metrics agent): a limit keeps a burst off the latency cores. Give both; a limit with no request still lets them burst up to it.
PriorityClass: one high class for latency pods, a low one for the rest. A latency pod then preempts, not the other way round.- Kubelet 1.33 or later with the static CPU manager: only a Guaranteed whole-CPU container loses its quota; everything else keeps its CPU limit (No CPU quota).
strict-cpu-reservation (kubelet policy option, GA in 1.35): by default
the shared CPU pool includes reservedSystemCPUs, so a Burstable pod or a
fractional sidecar can run on the reserved CPUs. strict-cpu-reservation: "true" keeps everything that was not given those CPUs off them. Use it only
when nothing but the system (and threads you pin there yourself) may use
them: it breaks anything that needs the shared pool by default, such as a
fractional sidecar in a latency pod or ergon-stack’s host-stats thread. The
shared-CPU NRI plugin (ghcr.io/mimran1980/ergon-shared-cpus:0.1.0; chart oci://ghcr.io/mimran1980/charts/ergon-shared-cpus version 0.1.0)
lends shared CPUs from reservedSystemCPUs to a pod’s helper threads and expects the
default, so leave strict-cpu-reservation off while using it.
No CPU quota
- A CPU quota (Linux’s CFS bandwidth control) lets a container’s threads run for at most the quota in each 100 ms period. Kubernetes sets it from the container’s CPU limit.
- A busy-spinning thread uses its core all the time, so it runs at the cap. When the container’s other threads push it over, the kernel stops every thread in it until the period ends: a stall of milliseconds.
- A pod has a quota of its own too: the sum of its containers’ limits. From
inside a container, only
cpu.stat.localshows it.
What the kubelet writes:
| case | container cpu.max | pod cpu.max |
|---|---|---|
default (cpuCFSQuota: true), no exclusive CPUs | limit × period: 200000 100000 for 2 CPUs | the sum of the containers’ limits |
| kubelet 1.33+, a container with exclusive CPUs | max | max when any container has exclusive CPUs |
| a sidecar with a fractional CPU in that pod | still capped: 50000 100000 | max |
cpuCFSQuota: false (the whole node: do not) | max | max |
- Exclusive CPUs need all three:
cpuManagerPolicy: static, a Guaranteed pod, and a whole-number CPU request on the container. Kubelet 1.33 is the first that then drops the quota. - Do not set
cpuCFSQuota: false. It is node-wide: it removes the CPU limit of every pod there, log collectors and system pods too, and they then run unlimited on the shared CPUs. The kubelet’s own behaviour above is enough, and it only touches the containers with exclusive CPUs. - Kubelet 1.31 and 1.32 (EKS extended support, AKS) always set the quota. Use 1.33 or later (the lab’s k3s and the EKS deployment run 1.36).
- Changing
cpuManagerPolicy: drain the node, stop the kubelet, delete/var/lib/kubelet/cpu_manager_state, start the kubelet. Without the delete, the kubelet crash-loops.
The kubelet config file:
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cpuManagerPolicy: static
cpuManagerPolicyOptions:
full-pcpus-only: "true"
reservedSystemCPUs: "0" # the housekeeping cores (one CPU is one core)
topologyManagerPolicy: single-numa-node
# cpuCFSQuota is not set: it stays true, so every pod keeps its CPU limit.
EKS, Amazon Linux 2023: nodeadm merges spec.kubelet.config into its
defaults. Put the NodeConfig in the launch template’s user data, as a MIME
part with Content-Type: application/node.eks.aws. The full one, with the
cluster fields, is in Nodes.
apiVersion: node.eks.aws/v1alpha1
kind: NodeConfig
spec:
kubelet:
config:
cpuManagerPolicy: static
cpuManagerPolicyOptions:
full-pcpus-only: "true"
reservedSystemCPUs: "0"
EKS, Bottlerocket: in the user data (TOML). Reboot after changing the
policy. It has reserved-cpus and cpu-manager-policy-options too, but no
feature gates. Leave cpu-cfs-quota-enforced at its default true.
[settings.kubernetes]
cpu-manager-policy = "static"
AKS: per node pool. AKS has no reservedSystemCPUs field.
echo '{"cpuManagerPolicy": "static"}' > kubelet.json
az aks nodepool add --resource-group <group> --cluster-name <cluster> \
--name latency --kubelet-config kubelet.json
k3s: in /etc/rancher/k3s/config.yaml, or as a KubeletConfiguration
drop-in under /var/lib/rancher/k3s/agent/etc/kubelet.conf.d/.
kubelet-arg:
- "cpu-manager-policy=static"
- "reserved-cpus=0"
CRI-O (OpenShift): the kubelet settings above are enough. CRI-O can also
drop the quota per pod, but only with the high-performance runtime class
(OpenShift’s Node Tuning Operator makes performance-<profile>), a
Guaranteed pod and whole CPUs. containerd ignores these annotations, so they
are safe in a shared manifest.
metadata:
annotations:
cpu-quota.crio.io: "disable"
cpu-load-balancing.crio.io: "disable"
irq-load-balancing.crio.io: "disable"
spec:
runtimeClassName: performance-<profile>
Verify
On the node:
kubectl get node <node> -o jsonpath='{.status.nodeInfo.kubeletVersion}' # 1.33 or later
kubectl get --raw /api/v1/nodes/<node>/proxy/configz \
| jq '.kubeletconfig | {cpuCFSQuota, cpuManagerPolicy, cpuManagerPolicyOptions, reservedSystemCPUs}'
cpuCFSQuota reads true (or is absent: that is the default).
In the pod:
cat /sys/fs/cgroup/cpu.max # max 100000
grep throttled /sys/fs/cgroup/cpu.stat
cat /sys/fs/cgroup/cpu.stat.local # Linux 6.6+: counts the pod's quota too
nr_throttledandthrottled_usecstay flat while the pod runs. Read them twice, a minute apart.- In Prometheus, cAdvisor’s
container_cpu_cfs_throttled_periods_totalstays flat. - ergon-tune: T9 runs at start in ergon-io and the
media driver.
ThrottleWatchis for your own loop’s housekeeping; nothing in this repository calls it yet.
The media driver DaemonSet
One driver pod per DPDK node: one ENA pair from the device plugin, six CPUs
(one CPU is one core), memory and hugepages, requests equal to limits. The
entrypoint uses three (housekeeping, sender, receiver) and leaves the rest
for a client in the same pod (entrypoint.sh:13-14). The EKS lab ran the
pod on four.
# Media Driver DaemonSet. One driver per DPDK node, each pod requesting one
# rusteron.io/dpdk-ena-pair, six CPUs, memory, and 2 MiB hugepages under
# Guaranteed QoS (requests == limits on every resource, so the kubelet's CPU
# Manager static policy hands it a dedicated cpuset).
#
# SMT (hyper-threading) is off on every latency node (the launch template's
# CpuOptions ThreadsPerCore=1; nosmt and provision-node.sh on bare metal), so
# one CPU is one physical core and six CPUs are six cores: housekeeping (the
# conductor, which does not busy-spin, plus the EAL and transport housekeeping
# threads), sender and receiver (the last two busy-spinning) and three spare.
# The entrypoint (entrypoint.sh) assigns them from the pod's cpuset and fails
# fast if fewer than three physical cores remain after SMT-sibling exclusion
# (a safety net: with SMT off every CPU is already its own core). A node
# therefore needs at least 5 physical cores, which provision-node.sh checks:
# one reserved for the system and kubelet, three for the driver (housekeeping,
# sender, receiver) and one for an application. The smallest c7i with them is
# c7i.4xlarge (8 cores).
#
# The secondary ENAs are bound on the host to igb_uio loaded with
# wc_activate=1, the only binding on virtualised EC2 that maps the ENA's
# low-latency-queue BAR write-combining (there is no IOMMU, and mainline
# vfio-pci maps no write-combining). On *.metal, which has an IOMMU, the same
# binding works in passthrough (iommu=pt, which provision-node.sh sets); it is
# the configuration the metal lab measured (BENCHMARKS.md, AWS round 4). With
# igb_uio the DPDK EAL runs in IOVA-as-PA mode (it logs
# `EAL: Selected IOVA mode 'PA'`), so the container must be privileged:
# translating hugepage addresses through /proc/self/pagemap needs
# CAP_SYS_ADMIN and CAP_DAC_READ_SEARCH, locking hugepages needs CAP_IPC_LOCK
# (https://doc.dpdk.org/guides/linux_gsg/enable_func.html), and the PMD maps
# the device's BARs through its sysfs resource files, which need a read-write
# /sys that only a privileged container gets.
#
# The device plugin injects the pair's /dev/uioN nodes + RUSTERON_DPDK_* env
# at Allocate time, so no device hostPath is declared here.
#
# Client pods on the node (the README's "Client pods") reach the driver
# through its Aeron directory: the /mnt/huge-aeron hostPath (type Directory)
# mounted at /mnt/huge-aeron, with AERON_DIR=/mnt/huge-aeron/aeron,
# AERON_TERM_BUFFER_SPARSE_FILE=false and
# AERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY=true. Give them Guaranteed QoS with
# whole cores (requests equal to limits) and pin each busy-spinning thread to
# a core of its own in the pod's cpuset.
#
# kubelet stops the pod with SIGTERM, on which the driver stops and then
# releases the DPDK transport, as on SIGINT; the grace period bounds that.
#
# Nodes are picked by the rusteron-dpdk/ena=true label and taint; the VPC CNI
# keeps the primary ENA for pod networking, while the two secondary ENAs stay
# bound to igb_uio on the host (never claimed by the CNI: they are tagged
# node.k8s.amazonaws.com/no_manage=true when created).
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: rusteron-media-driver
spec:
selector:
matchLabels:
app: rusteron-media-driver
template:
metadata:
labels:
app: rusteron-media-driver
spec:
nodeSelector:
rusteron-dpdk/ena: "true"
tolerations:
- key: rusteron-dpdk
operator: Exists
effect: NoSchedule
# SIGTERM first, SIGKILL once this has passed.
terminationGracePeriodSeconds: 15
containers:
- name: media-driver
image: rusteron-media-driver:dpdk
envFrom:
- configMapRef:
name: rusteron-media-driver
securityContext:
# igb_uio + IOVA-as-PA: see the header comment.
privileged: true
resources:
requests:
rusteron.io/dpdk-ena-pair: "1"
cpu: "6"
memory: 2Gi
hugepages-2Mi: 2Gi
limits:
rusteron.io/dpdk-ena-pair: "1"
cpu: "6"
memory: 2Gi
hugepages-2Mi: 2Gi
volumeMounts:
- name: hugepages
mountPath: /dev/hugepages
# Aeron's directory: the node's sized hugetlbfs (bootstrap-dpdk-ena.sh)
- name: aeron-huge
mountPath: /mnt/huge-aeron
volumes:
- name: hugepages
hostPath:
path: /dev/hugepages
- name: aeron-huge
hostPath:
path: /mnt/huge-aeron
type: Directory
Its ConfigMap, with the reason for each value:
# Media Driver tunables shared by every DPDK pod. Per-pair values (PCI, CIDR,
# gateway) are injected by the device plugin at Allocate time, and the
# entrypoint (entrypoint.sh) derives the CPU roles, idle strategies, wildcard
# port ranges and EAL lcore from the pod's cpuset. Pinned here: the settings
# that are the same on every node and either differ from rusteron-media-driver's
# built-ins or are values every measurement used (so a change of default does
# not silently change production). They match the VM lab's driver
# (benches/cloud/vm.sh).
apiVersion: v1
kind: ConfigMap
metadata:
name: rusteron-media-driver
data:
# 2 MiB hugepages mounted by the DaemonSet; the DPDK EAL mempools live here.
RUSTERON_DPDK_HUGE_DIR: "/dev/hugepages"
# RUSTERON_DPDK_RX_DESCRIPTORS is left unset: each port takes min(4096, the
# NIC's maximum), 4096 on an ENA: room for a burst. Against 1024 it did not
# separate at idle (BENCHMARKS.md, "Measured, but no setting recommended").
# The pool below holds that ring.
# ENA queues take at most 512 TX descriptors (the device reports
# tx_desc_lim.nb_max = 512); a larger ring fails port init.
RUSTERON_DPDK_TX_DESCRIPTORS: "512"
# The VM lab's count, which every DPDK measurement used (benches/cloud/vm.sh).
# Each mbuf is about 2.3 KiB of 2 MiB hugepages (a 2 KiB data room, the
# headroom and its headers), so two ports take about 75 MiB of the pod's
# hugepages-2Mi; see the README's hugepage budget before raising it.
RUSTERON_DPDK_MBUFS_PER_PORT: "16384"
# A vfio-pci node's write-combining, as the node's preflight confirmed it.
# vfio-pci maps an ENA's low-latency-queue BAR through its device file, so
# /proc/self/maps cannot show it write-combined and the transport's LLQ
# check would refuse the port; it accepts one bound to vfio-pci when this
# is 1. The bootstrap binds the ENAs to vfio-pci (RUSTERON_DPDK_DRIVER) only
# after preflight.sh passed, which for vfio-pci requires the operator's
# RUSTERON_DPDK_VFIO_WC=1 on the node (a write-combining vfio-pci, Amazon's
# patch); a failed run leaves no inventory, so the device plugin hands out
# no pair. Inert for igb_uio ports, which must still show their BAR mapped
# write-combined (resource2_wc).
RUSTERON_DPDK_VFIO_WC: "1"
RUSTERON_DPDK_MEMPOOL_CACHE: "256"
RUSTERON_DPDK_BURST_SIZE: "32"
# The Aeron directory on the node's own 1 GiB hugetlbfs for it (the
# DaemonSet's /mnt/huge-aeron hostPath, mounted by the node bootstrap), so
# clients on the node can map it and the term buffers sit on hugepages, as in
# the VM lab. It needs a sized mount: an unsized hugetlbfs reports no free
# space and Aeron's storage check refuses every log buffer. Hugetlbfs files
# must be whole 2 MiB pages and cannot be sparse. A restarted driver replaces
# its old directory.
AERON_DIR: "/mnt/huge-aeron/aeron"
RUSTERON_MEDIA_DRIVER_DIR: "/mnt/huge-aeron/aeron"
AERON_DIR_DELETE_ON_START: "true"
AERON_FILE_PAGE_SIZE: "2097152"
AERON_TERM_BUFFER_SPARSE_FILE: "false"
# The receive window needs a socket buffer at least as large (Aeron refuses
# to start otherwise); the node bootstrap raises rmem_max/wmem_max to allow it.
AERON_RCV_INITIAL_WINDOW_LENGTH: "2097152"
AERON_SOCKET_SO_RCVBUF: "2097152"
AERON_SOCKET_SO_SNDBUF: "2097152"
# Aeron UDP MTU that fits the DPDK max_aeron_mtu default.
AERON_MTU_LENGTH: "1408"
# Aeron's maximum (its default is 4): with DPDK at 1,344 B 600 k msgs/s no
# NAK against 16,247 at the default (BENCHMARKS.md, one run). The DPDK
# receive poll drains whole bursts whatever this is; it still sizes each
# receive from a host-local socket. Pinned although the media driver also
# defaults to 16 under dpdk-ena, so a rollback edits one visible value. A
# rollback to the socket transport sets it back to 4 (the runbook's
# rollback): kernel busy polling with 16 dropped frames at the NIC.
AERON_RECEIVER_IO_VECTOR_CAPACITY: "16"
# With the entrypoint's Aeron wildcard ranges set, Aeron itself picks the
# source port of a publication without a fixed port (a plain endpoint=
# channel) from 20000-20999, and receiver-side wildcard binds from
# 21000-21999; peers' status messages and NAKs come back to those ports, so
# the security group allows UDP 20000-21999 between the data subnets. This
# range is the transport's own fallback for a wildcard bind Aeron leaves at
# port 0: disjoint from the kernel's 32768-60999, so it never collides with
# kernel sockets.
RUSTERON_DPDK_EPHEMERAL_PORT_RANGE: "61000 63999"
(md-dpdk/deploy/aws-eks/; the node scripts and the entrypoint are tested
against fixtures by just test-dpdk-bootstrap.)
The entrypoint reads the pod’s cpuset and hands out cores in order: housekeeping, sender, receiver, then the rest for a client. Node requirements, the hugepage budget and the device plugin: the deployment’s README.
Checks inside a pod
A pod sees the host’s CPU, interrupt and memory files but its own network
namespace. ergon-tune reports the network settings it
cannot read there as unknown, never as a warning. Run the NIC checks on the
node, or in a pod on the host’s network. T9 reads the container’s own cgroup;
the pod’s quota shows only in cpu.stat.local (verify).
Under ERGON_HOST_CHECKS=require, ergon-io refuses a stock
static-CPU-manager pod: K2 cannot prove exclusive CPUs from the kubelet’s
cpuset, so it reports unknown. An isolated cpuset partition, as the
latency pods plugin
grants, passes it (ergon-tune).
Shared CPUs for Helper Threads (k8s/nri-shared-cpus)
How to install and use k8s/nri-shared-cpus on a Kubernetes node.
A busy-spinning pod gets whole cores of its own from the kubelet. Its helpers (dial, TLS, warnings, Aeron conductors) do not need a whole core, and running them on the loop’s core is what this layout forbids (its latency cost is not measured). This is a containerd NRI plugin and a kubelet device plugin that give the pod the node’s shared housekeeping CPUs as well, and start every thread there.
The busy-spin loop keeps its exclusive core. Only sleepy helpers go to the shared CPUs. Never a spinner or SQPOLL there.
Source: k8s/nri-shared-cpus/.
Status: published as tag 0.1.0. Anyone can pull the image and the chart.
Tested on kind; not yet on a real EKS node (see
Limits). The latency numbers are not
measured. AKS cannot run it.
Prior art: k8s/nri-shared-cpus/README.md.
Use the public chart
Do these in order. The chart installs the DaemonSet. It does not prepare the node, and it does not install the hot-pod admission policy.
- Prepare the node. Node setup below. Label it
ergon.io/shared-cpus=true. - Install.
config.sharedCpusis the housekeeping CPU, insidereservedSystemCPUs, and not the IRQ CPU. For the 8-CPU split in that section the value is0. One Helm release per shared-CPU layout.
helm install ergon-shared-cpus oci://ghcr.io/mimran1980/charts/ergon-shared-cpus \
--version 0.1.0 \
--namespace ergon-system --create-namespace \
--set config.sharedCpus=0 \
--set 'config.namespaces={trading}'
Check the pulled image. This prints the index digest
sha256:d28fea2f95ebce3e53d33f8770f3ba69be75c76ec7437ea2b92c1329759e07e9:
docker buildx imagetools inspect ghcr.io/mimran1980/ergon-shared-cpus:0.1.0
The chart’s image helper is repository:tag. image.tag cannot be
@sha256:…. Chart digest
sha256:7347131d7d87caee5cf6de11b2a11e2b62454da59c41092b3805bc2834795dbd.
helm install of this 0.1.0 package prints “This chart is NOT released”.
That note was packed before the push. The tag is the public chart. The
note in the repo is corrected for a later tag. This tag is not republished
to change the note.
Chart 0.1.0 writes sharedCpus, oversubscription, namespaces,
defaultAffinity, hookPath, annotationOptIn, metricsAddress and
devRoot. It does not write seccomp, ptp or schedFifo. --set of
those keys does nothing on this package. The plugin leaves them off.
A later edit of the ConfigMap can turn a key on, and the pod still has to
opt in. Aeron’s C media driver does not use io_uring. SCHED_FIFO stays off.
- Apply the hot-pod policy. These two files only, from
k8s/nri-latency-pods/admission/:
kubectl apply -f priority-class.yaml -f validating-admission-policy.yaml
Leave examples/ and mutating-admission-policy.yaml unapplied.
A pod labelled ergon.io/latency: hot is refused when it has an exec
probe, a container with restartPolicy: Always, no
ergon.io/shared-millicpus request, no
required-plugins.noderesource.dev annotation naming ergon-shared-cpus,
or no priorityClassName.
- Run a Guaranteed pod in an allowlisted namespace. Whole-number
cpu, requests equal limits,ergon.io/shared-millicpus. The pod spec is under Pod. Inside it,ERGON_SHARED_CPUSis the housekeeping list,ERGON_ISOLATED_CPUSis the exclusive list, PID 1Cpus_allowed_listis the shared CPUs only, andcpuset.cpus.effectiveis the union.
Flux: a HelmRepository of type oci at
oci://ghcr.io/mimran1980/charts, chart ergon-shared-cpus version
0.1.0, targetNamespace: ergon-system, and the same config values.
Copy the two admission files into the Git repo Flux already applies.
Do not point Flux at the whole admission/ directory: that directory
also holds pods the policy is meant to refuse.
See it on kind
k8s/nri-shared-cpus/examples/try/run.sh creates a throwaway kind cluster
(kindest/node:v1.33.0, static CPU manager, reservedSystemCPUs: "0,1"),
installs this public chart with sharedCpus: "0", applies the two
admission files, and runs one hot pod. The pod’s log must show
ERGON_SHARED_CPUS=0, a non-empty ERGON_ISOLATED_CPUS, PID 1
Cpus_allowed_list of 0, and nproc=1. A second pod with an exec
liveness probe must be denied. The script deletes the cluster on the way
out and uses a kubeconfig under /tmp.
That run shows the shared-CPU grant and the HFT admission check. It does
not measure latency. Kind on Docker Desktop has no hugepage pool, no NIC
PHC and no cpuset.cpus.exclusive partition to demonstrate, and this
sample does not ask for those grants.
One run of that script on 2026-10-22, chart 0.1.0,
kindest/node:v1.33.0, Docker Desktop linux aarch64 with 10 CPUs, printed
ERGON_ISOLATED_CPUS=2,3, ERGON_SHARED_CPUS=0,
cpuset.cpus.effective=0,2-3, PID 1 Cpus_allowed_list 0, nproc=1.
Node allocatable ergon.io/shared-millicpus was 1k. The exec-probe pod
was denied by ValidatingAdmissionPolicy ergon-latency-pods. The
cluster was deleted afterwards. The log lines are in
k8s/nri-shared-cpus/examples/try/README.md.
The 18-step test/e2e.sh is a different run. It builds
ergon-shared-cpus:dev from this tree. It does not pull the public chart.
What it gives
| item | value |
|---|---|
| exclusive CPUs | the kubelet’s static CPU manager, cpu: N |
| shared CPUs | ergon.io/shared-millicpus: M adds ceil(M/1000) shared CPUs |
| start affinity | PID 1 and every new thread start on the shared CPUs |
| timer slack | 1 ns on every thread, inherited by every thread and child the program makes |
| pinning | the app pins hot threads by index into ERGON_ISOLATED_CPUS |
M is millicpus because extended resources are whole numbers and requests
must equal limits. 250 is a quarter core of scheduler accounting only:
the plugin still adds one whole shared CPU, and there is no hard cap.
Timer slack
The kernel’s default timer slack is 50,000 ns. It is added to every sleep and
to every epoll/io_uring timeout. The hook sets 1 ns, the kernel’s floor,
on every thread the container has when it starts; the kernel copies it to
every thread and child after that. A container that cannot have it does not
start. The OCI hook runs as root, so it can write
/proc/<tid>/timerslack_ns; a program can always set its own slack with
prctl(PR_SET_TIMERSLACK).
What the 1 ns slack is worth in latency is not measured. The loop spins and never sleeps; the slack applies to sleeping helpers such as Aeron’s conductor.
Node setup
Do this before installing the plugin. Everything here is per node. The rest of the node checklist is Latency Pods: node setup.
| setting | value |
|---|---|
| Kubernetes | >= 1.33: no CPU quota on exclusive containers (the labs ran 1.36) |
| kubelet | cpuManagerPolicy: static; x86 full-pcpus-only; reservedSystemCPUs = the IRQ CPU(s) + the shared CPUs |
| kernel | >= 6.2, cgroup v2. Boot line: nohz_full, rcu_nocbs, isolcpus=managed_irq on the exclusive CPUs, never on the shared ones. A cpu-partition opt-in also needs cpuset.cpus.exclusive: Linux 6.7, or a vendor kernel that backported that file. The plugin reads the file, not the version |
| containerd | >= 2.2, NRI on, default validator on |
| node label | ergon.io/shared-cpus=true |
CPU split. Pick three CPU sets on the node and write them down; every later step names one of them.
| set | who | example (8 CPUs) |
|---|---|---|
| exclusive | the pod’s busy-spin loops | 2,3 (and 4,5 for a second pod) |
| shared | helpers, kubelet, containerd, system | 0 |
| IRQ | NIC interrupts | 1 |
Kernel boot line. Isolate the exclusive CPUs only. Never the shared one.
nohz_full=2-5 rcu_nocbs=2-5 isolcpus=managed_irq,domain,2-5
kubelet — reservedSystemCPUs: "0,1" (shared + IRQ) with the policy
above. Restart kubelet and check it took.
containerd — NRI on (disable = false) and the default validator on.
The validator is what makes an unmet required-plugins annotation fail the
create instead of starting the pod half-granted. Leave
required_plugins empty: a node-wide list would also block kube-system
pods and the plugin’s own pod while it is down.
Install on Kubernetes
The plugin is public. No image pull secret.
| what | address |
|---|---|
| image | ghcr.io/mimran1980/ergon-shared-cpus:0.1.0 (and :latest) |
digest of 0.1.0 and latest | sha256:d28fea2f95ebce3e53d33f8770f3ba69be75c76ec7437ea2b92c1329759e07e9 |
| chart | oci://ghcr.io/mimran1980/charts/ergon-shared-cpus version 0.1.0 |
helm install ergon-shared-cpus oci://ghcr.io/mimran1980/charts/ergon-shared-cpus \
--version 0.1.0 \
--namespace ergon-system --create-namespace \
--set config.sharedCpus=0 \
--set 'config.namespaces={trading}'
config.sharedCpus is this node’s shared CPU. For the 8-CPU split above
that is 0: inside reservedSystemCPUs, and not the IRQ CPU. The chart
creates ergon-system with the privileged Pod Security labels and selects
nodes labelled ergon.io/shared-cpus=true. It does not label nodes, and it
does not set the boot line, the kubelet, or containerd.
Do the node setup above first, then the steps below. The public install is
Use the public chart. Chart 0.1.0 does not emit
seccomp, ptp or schedFifo. Step 4 below is the local image
ergon-shared-cpus:dev. The kind sample that pulls the public chart is
See it on kind.
1. Label the nodes that take the plugin
kubectl label node <node> ergon.io/shared-cpus=true
Only labelled nodes run the DaemonSet. A pod that asks for the device on an unlabelled node stays pending.
2. Edit deploy/configmap.yaml
This is the step people get wrong. The file is
k8s/nri-shared-cpus/deploy/configmap.yaml, and it becomes
/etc/ergon-shared-cpus/config.yaml in the plugin’s container.
sharedCpus: "0" # the node's shared CPUs, kernel cpulist
oversubscription: 1
namespaces: ["trading"] # who may opt in. Empty refuses every pod
defaultAffinity: shared
annotationOptIn: false
devRoot: /host/dev # where the DaemonSet mounts the host's /dev; without
# it the plugin finds no PHC and refuses ptp grants
# These three are "off" by default. A latency node turns the first two on.
# Each one is a node-level permission: the pod still opts in by annotation,
# and a pod that asks for a grant this field does not allow is refused.
seccomp:
ioUring: "allow" # default "off". Without it, a pod asking for
# seccomp.ergon.io/<ctr>: "io-uring" is refused.
# For apps that use io_uring (ergon-io's uring
# backends). Aeron's C media driver does not use it
ptp: "auto" # default "off". Without it, a pod asking for
# ptp.ergon.io/<ctr>: "auto" is refused. "auto" is
# the NIC's PHC, or give a /dev/ptp* path the node has
schedFifo: "off" # off by default; its one A/B was mixed (see
# Kubernetes HFT tuning, SCHED_FIFO)
Rules:
sharedCpusmust sit inside the kubelet’sreservedSystemCPUs, minus the IRQ CPU(s). In the table above that is0.namespacesis an allowlist. Create those namespaces before applying.- Quote
off,on,yes,no. YAML 1.1 turns unquoted ones into booleans. - An unknown field or an out-of-range value stops the plugin at start, and the message names the field.
offis not harmless. It is a refusal. A pod whose annotation asks for a grant the ConfigMap does not allow does not start: the kubelet showsCreateContainerErrorand the text names the field to change.
3. Install the public chart
Use the helm install at the top of this section. One Helm release per
shared-CPU layout: the chart has one ConfigMap.
The plugin is root on the node and installs an executable at
/opt/ergon/bin/ergon-cpus. Only this repository’s publish workflow pushes.
Kind, or a node with no registry: docker build -t ergon-shared-cpus:dev .
from k8s/nri-shared-cpus, load that image, and apply the three manifests
in the next step. deploy/daemonset.yaml ships
image: ergon-shared-cpus:dev with imagePullPolicy: IfNotPresent.
4. Apply the three manifests
Skip this when the Helm release above is installed. It is the kind path.
kubectl apply -f deploy/namespace.yaml \
-f deploy/configmap.yaml \
-f deploy/daemonset.yaml
The DaemonSet needs five host paths: /var/run/nri,
/var/lib/kubelet/device-plugins and /opt/ergon/bin read-write, plus
/dev at /host/dev and the host cgroup v2 tree at /sys/fs/cgroup, both
read-only. The namespace is labelled Pod Security privileged for that
reason. The plugin is root on the node: anything on the NRI socket can
change any container.
5. Verify the plugin before any workload
The pod is Ready on every labelled node, allocatable shows
ergon.io/shared-millicpus greater than zero, /readyz is healthy, and the
logs show no config error (strict YAML: a bad field stops it). If
allocatable is empty the device plugin did not register: check the logs
and that /var/lib/kubelet/device-plugins is mounted.
6. Run a pod and check the grants landed
Use the pod under Pod. Inside it:
kubectl exec trader -- sh -c 'echo isolated=$ERGON_ISOLATED_CPUS shared=$ERGON_SHARED_CPUS'
kubectl exec trader -- cat /proc/1/status | grep Cpus_allowed_list
Cpus_allowed_list must print the shared CPUs only. That is the point of
the plugin.
Config
/etc/ergon-shared-cpus/config.yaml, from the ConfigMap. Strict YAML: an
unknown field is an error at start. No hot reload — edit the ConfigMap and
restart the DaemonSet.
| field | default | what it is |
|---|---|---|
sharedCpus | required | the housekeeping CPUs, kernel cpulist ("4" or "0,2"). Inside reservedSystemCPUs, never the IRQ CPU(s) |
oversubscription | 1 | 1–100. Capacity is len(sharedCpus) x 1000 x this; the max is 100,000 device IDs (the kubelet’s 4 MiB gRPC limit) |
namespaces | empty | the allowlist. Empty refuses every opt-in. Each must be a valid namespace name |
defaultAffinity | shared | shared injects the hook that starts every thread on the shared CPUs. all injects nothing; use the ergon-cpus wrapper (see Bottlerocket below) |
hookPath | /opt/ergon/bin/ergon-cpus | where the daemon writes the hook on the node. Must be a clean absolute path ending in /ergon-cpus — the daemon writes it as root, so the name is fixed |
annotationOptIn | false | allow the pod annotation cpu-shared.ergon.io/<container> as a fallback when the device plugin is not used |
metricsAddress | :9466 | serves /metrics, /healthz and /readyz |
rlimits.memlock, .nofile, .rtPrio, .nice | unset | the ceiling a pod may ask for by kind. "inf" or a positive count. The pod asks for kinds, never values |
seccomp.ioUring (nested: seccomp: then ioUring:) | off | allow widens the container’s seccomp profile for io_uring_*. See Latency Pods |
seccompProfilePath | /etc/ergon-shared-cpus/ergon-io-uring.json | the shipped io_uring profile on the node, the fallback for a pod that cannot take the NRI grant |
devRoot | /dev | where this process sees the host’s /dev. The DaemonSet mounts it read-only at /host/dev so the plugin can stat the PHC and PM QoS nodes; granted paths stay /dev/... |
cgroupRoot | /sys/fs/cgroup | the cgroup v2 mount point. The DaemonSet mounts the host tree read-only at exactly this path: it is also the node-space prefix the createRuntime hook matches against /proc/<pid>/cgroup, so it must stay /sys/fs/cgroup |
schedFifo | off | off, or thread (the hot thread only, never the container). Quote the value: unquoted off is a YAML boolean |
ptp | off | off, auto, or a /dev/ptp* path the node has. auto grants the NIC’s PHC |
hugepagesAeronDir | unset | absolute AERON_DIR to export when the pod mounts a sized hugetlbfs |
Quote off, on, yes and no in this file. YAML 1.1 turns unquoted ones
into booleans.
Unknown fields and out-of-range values are refused at start, each naming its
field: oversubscription outside 1–100, a namespaces entry that is not a
namespace name, hookPath not ending in /ergon-cpus, an rlimits value
that is neither inf nor a positive count, schedFifo / ptp /
seccomp.ioUring outside their sets.
Pod
apiVersion: v1
kind: Pod
metadata:
name: trader
namespace: trading # must be in the plugin's allowlist
annotations:
required-plugins.noderesource.dev: '["ergon-shared-cpus"]'
spec:
nodeSelector: { ergon.io/shared-cpus: "true" }
containers:
- name: app
image: my-app
resources:
requests: { cpu: "2", memory: 1Gi, ergon.io/shared-millicpus: "250" }
limits: { cpu: "2", memory: 1Gi, ergon.io/shared-millicpus: "250" }
Inside the container:
| item | value |
|---|---|
cpuset.cpus.effective | the exclusive CPUs plus the shared ones |
| affinity of PID 1 and of every new thread | the shared CPUs only |
nproc, GOMAXPROCS, availableProcessors() | usually the shared CPUs: they count the affinity mask |
Env vars
| name | value | format |
|---|---|---|
ERGON_ISOLATED_CPUS | the exclusive CPUs | sorted comma list, no ranges: 1,5 |
ERGON_SHARED_CPUS | this container’s shared CPUs | same |
ERGON_ISOLATED_CORES | the exclusive CPUs by physical core | groups by ;, CPUs by ,: 1,5;2,6 |
ERGON_SHARED_MILLICPUS | M | decimal |
ERGON_HELPER_CPUS | same as ERGON_SHARED_CPUS | comma list |
OPENSHIFT_ISOLATED_CPUS | same as ERGON_ISOLATED_CPUS | kernel cpulist with ranges |
OPENSHIFT_SHARED_CPUS | same as ERGON_SHARED_CPUS | same |
Read the CPUs from these variables or from cpuset.cpus.effective, never
from the process’s own affinity: the start mask is the shared CPUs.
Pinning by index
Pin each hot thread to isolated index i. The app never needs the real CPU
numbers.
/* SPDX-License-Identifier: Apache-2.0 */
/*
* Pin a hot thread by index into ERGON_ISOLATED_CPUS. Leave helpers alone:
* every thread already starts on the shared CPUs.
*
* Build: cc -O2 -o pin pin.c
* Run: in a container that asked for ergon.io/shared-millicpus.
*/
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* The i-th number of a list like "1,5"; -1 if there is none. */
static int nth_cpu(const char *list, int i)
{
if (list == NULL)
return -1;
for (const char *p = list; *p != '\0'; i--) {
char *end;
long cpu = strtol(p, &end, 10);
if (end == p)
return -1;
if (i == 0)
return (int)cpu;
p = (*end == ',') ? end + 1 : "";
}
return -1;
}
/* Pin the calling thread to isolated CPU index i. Returns the CPU, or -1. */
int pin_isolated(int i)
{
int cpu = nth_cpu(getenv("ERGON_ISOLATED_CPUS"), i);
if (cpu < 0)
return -1;
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu, &set);
return sched_setaffinity(0, sizeof set, &set) == 0 ? cpu : -1;
}
/* Report which CPU the calling thread is on and where its mask is. */
static void report(const char *who)
{
int now = sched_getcpu();
cpu_set_t set;
CPU_ZERO(&set);
if (sched_getaffinity(0, sizeof set, &set) != 0) {
perror("sched_getaffinity");
return;
}
printf("%s: running on cpu %d, mask", who, now);
for (int c = 0; c < CPU_SETSIZE; c++)
if (CPU_ISSET(c, &set))
printf(" %d", c);
printf("\n");
}
int main(void)
{
const char *iso = getenv("ERGON_ISOLATED_CPUS");
const char *sh = getenv("ERGON_SHARED_CPUS");
printf("ERGON_ISOLATED_CPUS=%s\nERGON_SHARED_CPUS=%s\n",
iso ? iso : "(unset)", sh ? sh : "(unset)");
/* Every thread starts on the shared CPUs. */
report("helper (default)");
/* Pin the hot thread to isolated index 0. */
int cpu = pin_isolated(0);
if (cpu < 0) {
fprintf(stderr, "pin_isolated(0) failed\n");
return 1;
}
printf("pinned the hot thread to isolated index 0 = cpu %d\n", cpu);
report("hot (pinned)");
/* Index 1, when the pod asked for more than one CPU. */
int cpu1 = pin_isolated(1);
if (cpu1 >= 0) {
printf("isolated index 1 = cpu %d\n", cpu1);
report("hot2 (pinned)");
}
return 0;
}
Rules:
- leave helpers alone: they start on the shared CPUs;
- create helpers from a thread that is still on the shared set (a new thread copies its creator’s mask);
- each sleepy helper checks
sched_getcpu()when it wakes and alarms if it is not onERGON_SHARED_CPUS; - never a spinner or SQPOLL on a shared CPU. ergon-io keeps this: with no
sqpoll_cpu,uring-sqpolltakes a CPU of the pod’s own outsideERGON_HELPER_CPUSand refuses (Error::Cpu) when there is none (io: Kubernetes); - two indices are two physical cores only when they are two groups in
ERGON_ISOLATED_CORES. On SMT,1,5is one core and its two threads share every execution unit: two “hot cores” there is one hot core.
A core is never split between the exclusive and the shared CPUs. On SMT
the hot thread and the helpers would time-share one physical core, which
is the contention this whole setup exists to remove. The plugin refuses
such a container rather than start it claiming an isolation it does not
have. On x86 keep SMT off, or use full-pcpus-only and put whole cores
in reservedSystemCPUs.
Worked example: an Aeron media driver
Two pinned cores (sender and receiver) plus the conductor on the shared CPU, with huge pages. This is the layout a latency node wants.
Node boot line (from the table above), for exclusive CPUs 2 and 3, shared 0, IRQ 1:
nohz_full=2-3 rcu_nocbs=2-3 isolcpus=managed_irq,domain,2-3 hugepagesz=2M hugepages=512
ConfigMap (sharedCpus: "0"), kubelet
(reservedSystemCPUs: "0,1"), and a namespace in the allowlist.
Pod. Three grants on top of the shared CPU: a sized hugetlbfs for
AERON_DIR, io_uring seccomp, and the NIC PHC. The isolation of CPUs 2-3
comes from the boot line, not from a grant: cpu-partition needs the
program to move its own threads between cgroups
(Latency Pods), and aeronmd does
not.
apiVersion: v1
kind: Pod
metadata:
name: aeron-driver
namespace: trading
labels: { ergon.io/latency: "hot" }
annotations:
required-plugins.noderesource.dev: '["ergon-shared-cpus"]'
hugepages.ergon.io/app: "2M" # sized hugetlbfs for AERON_DIR
seccomp.ergon.io/app: "io-uring" # for apps that use io_uring; Aeron does not
ptp.ergon.io/app: "auto" # the NIC's PHC
spec:
nodeSelector: { ergon.io/shared-cpus: "true" }
priorityClassName: ergon-hot # k8s/nri-latency-pods/admission/priority-class.yaml
containers:
- name: app
image: ergon-media-driver:dev
command: ["ergon-cpus",
"--env", "AERON_SENDER_CPU_AFFINITY=isolated:0",
"--env", "AERON_RECEIVER_CPU_AFFINITY=isolated:1",
"--", "aeronmd"]
env:
- { name: AERON_THREADING_MODE, value: DEDICATED }
- { name: AERON_DIR, value: /dev/hugepages/aeron } # set by the app, see below
resources:
requests: { cpu: "2", memory: 2Gi, ergon.io/shared-millicpus: "250", hugepages-2Mi: 256Mi }
limits: { cpu: "2", memory: 2Gi, ergon.io/shared-millicpus: "250", hugepages-2Mi: 256Mi }
What each piece does:
| piece | effect |
|---|---|
cpu: "2" | two exclusive CPUs from the static CPU manager: the sender’s and the receiver’s |
ergon.io/shared-millicpus: "250" | the plugin adds the shared CPU and starts every thread there |
AERON_SENDER_CPU_AFFINITY=isolated:0 | sender on exclusive index 0 |
AERON_RECEIVER_CPU_AFFINITY=isolated:1 | receiver on exclusive index 1 |
AERON_CONDUCTOR_CPU_AFFINITY unset | the conductor stays on the shared CPU |
AERON_THREADING_MODE=DEDICATED | sender, receiver and conductor on their own threads |
hugepages.ergon.io/app: "2M" + hugepages-2Mi | a sized hugetlbfs mounted at /dev/hugepages |
AERON_DIR on that hugetlbfs | the CnC file and the terms are 2 MiB pages, not 4 KiB |
seccomp.ergon.io/app: "io-uring" | the profile gains the io_uring_* calls an app’s uring backend needs; never Unconfined |
ptp.ergon.io/app: "auto" | the NIC’s PHC, for clock work |
ergon.io/latency: "hot" (pod label) | the admission policy refuses exec probes and restartPolicy: Always sidecars |
isolcpus=managed_irq,domain,2-3 on the boot line | CPUs 2-3 leave the scheduler’s load-balancing domains (isolation) |
The app sets AERON_DIR itself. The plugin exports
ERGON_HUGEPAGES_DIR and ERGON_HUGEPAGE_SIZE and does not set AERON_DIR,
because the app must create the subdirectory:
mkdir -p "$ERGON_HUGEPAGES_DIR/aeron"
export AERON_DIR="$ERGON_HUGEPAGES_DIR/aeron"
export AERON_FILE_PAGE_SIZE="$ERGON_HUGEPAGE_SIZE"
An unsized hugetlbfs — what emptyDir: {medium: HugePages} gives you — is
rejected by Aeron. That is why the size comes from the annotation.
Affinity variables:
| variable | agent | here |
|---|---|---|
AERON_SENDER_CPU_AFFINITY | sender | isolated:0 |
AERON_RECEIVER_CPU_AFFINITY | receiver | isolated:1 |
AERON_CONDUCTOR_CPU_AFFINITY | conductor | leave unset: it starts on the shared CPUs |
AERON_DRIVER_NATIVE_RESOURCE_AGENT_CPU_AFFINITY | native resource agent | leave unset |
AERON_DRIVER_CPUSET_AFFINITY | makes the four above indexes into the cgroup cpuset | keep false; the wrapper refuses to translate when it is true |
ergon-cpus turns isolated:<i> into a real CPU number before exec. It
also sets its own affinity to the shared CPUs, so the driver starts there.
Check it worked:
kubectl exec aeron-driver -- sh -c 'echo isolated=$ERGON_ISOLATED_CPUS shared=$ERGON_SHARED_CPUS'
kubectl exec aeron-driver -- sh -c 'echo hugepages=$ERGON_HUGEPAGES_DIR size=$ERGON_HUGEPAGE_SIZE'
kubectl exec aeron-driver -- sh -c 'ps -L -o pid,tid,psr,comm | head'
psr must show the sender and receiver on 2 and 3 and every other thread on
0. mount | grep hugepages must show a size, not just hugetlbfs.
Fractional shared CPUs
- Extended resources are whole numbers and requests must equal limits, so
the unit is millicpus:
250= a quarter. - The plugin adds
ceil(M/1000)shared CPUs, at least one. - The fraction is scheduler accounting only. With 2 shared CPUs and oversubscription 2, a node offers 4000; sixteen pods at 250 fit.
- There is no hard cap. Helpers may use the whole shared CPU, shared with the kubelet, containerd, system daemons and other pods.
- A helper wake-up on a shared CPU waits on the node’s other load; the size of that wait is not measured. It slows connects and the conductor, not the loop.
Fail closed
| event | effect | guard |
|---|---|---|
| plugin down at create | NRI makes no change | required-plugins.noderesource.dev makes the create fail; the wrapper refuses without ERGON_SHARED_CPUS; the resource does not allocate |
| kubelet restart, plugin up | the re-write keeps the union | the plugin re-widens in UpdateContainer |
| kubelet restart, plugin down | cpuset shrinks to the exclusive CPUs; helpers fall onto them | the plugin re-widens on return; helpers self-check meanwhile |
| hook binary missing | the create fails | the DaemonSet installs the binary before it connects |
Limits and what is not verified
- The plugin is root on the node. It mounts five host paths:
/var/run/nri,/var/lib/kubelet/device-pluginsand/opt/ergon/binread-write, the host’s/devat/host/devand/sys/fs/cgroupread-only. NohostPID, nohostNetwork, not privileged,capabilities.drop: [ALL],readOnlyRootFilesystem: true, seccompRuntimeDefault, no API access. Only namespaces in the allowlist may opt in. - AKS has no supported way to enable containerd NRI plugins and no
reservedSystemCPUs. Not supported. - CRI-O users use its
shared_cpusetinstead. - Bottlerocket: whether runc may exec a hook binary that a pod wrote to
a host path is not verified. Use
defaultAffinity: alland the wrapper until checked. kubectl execand exec probes start on any CPU of the cpuset, exclusive ones included. No exec probes on hot containers; run debug commands asergon-cpus -- <cmd>.- In-place CPU resize leaves the CPU env stale. Use
resizePolicy: RestartContainerforcpu. - Not measured / not verified: the latency cost of helpers on shared
CPUs, and a real EKS node (the NRI socket, nodeadm TOML merge,
/optwritable).
Decision. Use it on kind or a self-managed node where you can set
reservedSystemCPUs and enable NRI. That the exclusive cores stay quiet
with it is a design claim, not a measurement. Not for AKS or CRI-O.
Latency Pods
Five things a normal Kubernetes pod cannot do, and what to use instead.
They live in k8s/nri-latency-pods (node setup and the admission policy) and
k8s/nri-shared-cpus (the NRI plugin). This page is the contract.
In short: five opt-in grants: a sized hugetlbfs for Aeron’s directory,
an isolated cpuset partition, an io_uring seccomp profile, an admission
policy against exec probes and sidecars, and device grants (the NIC’s PHC).
Each is a mechanism; none has a measured latency effect. SCHED_FIFO is
built in the shared-cpus plugin and off by default
(evidence: mixed).
Status: the grants ship in the public image
ghcr.io/mimran1980/ergon-shared-cpus:0.1.0. Install it with the chart
oci://ghcr.io/mimran1980/charts/ergon-shared-cpus version 0.1.0
(Shared CPUs). This page’s admission
YAML and node units are not an image. Chart 0.1.0 leaves seccomp.ioUring
and ptp off, because its ConfigMap template does not write those keys.
k8s/nri-shared-cpus/examples/try installs that chart and shows two things
on kind: the shared-CPU grant, and this page’s admission policy denying an
exec probe. It does not exercise hugepages, the cpuset partition, io_uring
seccomp, or the PHC. The five-grant kind e2e (test/e2e.sh) builds the
plugin from source. Not verified on EKS. No latency figure is measured.
The five
Every grant is opt-in by pod annotation. A grant that cannot be given is
refused: the kubelet shows the text in the pod’s events as
CreateContainerError, and the text names the fix. A pod author names
kinds and modes only; values come from the plugin config or the node’s
facts.
| # | extension | mechanism | pod opt-in | env the app sees |
|---|---|---|---|---|
| 1 | sized hugetlbfs for AERON_DIR | NRI AddMount type: hugetlbfs with pagesize=2M,size=N, plus AddLinuxHugepageLimit; N is the container’s hugepages-2Mi limit | hugepages.ergon.io/<ctr>: "2M" and a hugepages-2Mi resource (requests = limits) | ERGON_HUGEPAGES_DIR, ERGON_HUGEPAGE_SIZE |
| 2 | cpuset.cpus.partition=isolated on the exclusive CPUs | a createRuntime hook makes a threaded child cgroup holding only those CPUs, unions them into the ancestors’ cpuset.cpus.exclusive, then writes cpuset.cpus.partition=isolated, and reads every write back. Takes a process, then single threads (see below) | cpu-partition.ergon.io/<ctr>: "isolated" | ERGON_ISOLATED_CGROUP, ERGON_ISOLATED_CPUS, ERGON_SHARED_CPUS |
| 3 | io_uring seccomp profile | (a) NRI SetLinuxSeccompPolicy: the request’s profile plus the io_uring_* allow rules; (b) fallback, pod field seccompProfile: Localhost | seccomp.ergon.io/<ctr>: "io-uring" (or the Localhost fallback) | ERGON_SECCOMP |
| 4 | no exec probes, no always-restart sidecars | ValidatingAdmissionPolicy (CEL, GA 1.30) | pod label ergon.io/latency: "hot" | — |
| 5 | device grants | NRI AddDevice, plus a read-only bind of the node facts file | ptp.ergon.io/<ctr>: "auto" (the NIC PHC); cpuqos.ergon.io/<ctr> (/dev/cpu_dma_latency, x86 only) | ERGON_PTP_DEVICE, ERGON_NODE_FACTS, ERGON_CPU_DMA_LATENCY |
Ride-along: rlimits.ergon.io/<ctr> grants MEMLOCK, NOFILE and
RTPRIO (a limit value only — see below). The values are ceilings the
pod may ask for, set in the plugin config.
Also numa.ergon.io/<ctr>: "0", "0,1" or "" binds the container’s
memory to those NUMA nodes (cpuset.mems and MPOL_BIND). "" means the
data NIC’s node, which needs that node recorded; on a one-node machine the
only node is the answer. A request the node cannot answer is refused and
says why — an unread topology, a node the machine does not have, or ""
with several nodes and no NIC node. A binding is never dropped in silence.
The five grants on one pod. Everything stays inside the pod’s own cpuset:
exclusive CPUs from the kubelet’s static CPU manager, shared CPUs from
ergon.io/shared-millicpus. Helpers go on the shared CPUs; SQPOLL takes an
exclusive CPU of the pod’s own (SMT sibling).
flowchart TD
POD["latency pod: exclusive CPU(s) and shared CPU(s) in its cpuset"]
POD --> A["1 hugepages.ergon.io"]
POD --> B["2 cpu-partition.ergon.io"]
POD --> C["3 seccomp.ergon.io/io-uring"]
POD --> D["4 ergon.io/latency hot"]
POD --> E["5 ptp. and cpuqos.ergon.io"]
A --> A1["sized hugetlbfs + hugepage limit"]
B --> B1["child cgroup, isolated partition"]
C --> C1["seccomp + io_uring_* rules"]
D --> D1["no exec probes, no Always sidecars"]
E --> E1["PHC, cpu_dma_latency, node facts"]
SQ["helpers on shared CPUs"] -.-> POD
1. The app owns AERON_DIR
The plugin mounts a sized hugetlbfs at /dev/hugepages and exports
ERGON_HUGEPAGES_DIR and ERGON_HUGEPAGE_SIZE. It does not set
AERON_DIR. The app creates $ERGON_HUGEPAGES_DIR/aeron, points
AERON_DIR there, and, if it runs the media driver, sets
AERON_FILE_PAGE_SIZE=$ERGON_HUGEPAGE_SIZE.
Why: an unsized hugetlbfs is what an emptyDir medium: HugePages gives
you, and Aeron refuses it. A 4 KiB AERON_DIR takes a page fault on each
first touch of a page. Latency effect not measured here.
2. Isolated partition
cpuset.cpus.partition=isolated makes the kernel treat the set as
isolated: no load balancing into it
(cgroup-v2). The
grant is applied to a child cgroup so the pod’s own cgroup keeps its
shared CPUs. Latency effect not measured here.
The child is threaded, and a threaded child is a legal partition root
(k8s/nri-shared-cpus/internal/cpuset/partition.go). So:
| app shape | how the exclusive CPUs are used |
|---|---|
| one process whose threads all belong on the exclusive CPUs | write the pid to $ERGON_ISOLATED_CGROUP/cgroup.procs, then pin each thread to ERGON_ISOLATED_CPUS |
| hot threads in a multi-threaded process | write the pid to $ERGON_ISOLATED_CGROUP/cgroup.procs first, then move each helper thread back to the container cgroup through its cgroup.threads. The kernel confines the hot threads to the exclusive CPUs and the helpers to the shared ones (kernel lane: test/cgroup/kernel_test.go, TestOneThreadCanLiveInThePartition) |
A program that does not make these moves itself, such as Aeron’s C media
driver, keeps the boot line’s isolation and sched_setaffinity: see the
worked example.
cpuset.cpus.exclusive is a replace, not a union, so every directory
the new partition shares with an already-running pod has to be updated
with the union of both reservations. The hook does that under a lock and
reads every write back; a write that does not stick fails the create.
3. io_uring
RuntimeDefault blocks io_uring (containerd issue 9320). Aeron’s C media
driver does not use io_uring; the grant is for applications that do,
such as ergon-io’s uring-spin and uring-sqpoll backends. It is a clone
of the pod’s own seccomp profile with the io_uring_* calls allowed —
never Unconfined, which the plugin refuses.
4. Admission
An exec probe starts a process inside the container on any CPU of its
cpuset, the loop’s exclusive CPU included
(k8s/nri-shared-cpus/README.md, Limits). A container with
restartPolicy: Always is an injected sidecar: it shares the pod’s CPU
budget and can run on the hot core.
The policy refuses both on pods labelled ergon.io/latency: "hot". It
also requires a priorityClassName (eviction and preemption stop a hot
pod mid-trade), the ergon.io/shared-millicpus resource, and the
required-plugins.noderesource.dev annotation naming the plugin.
httpGet, tcpSocket and grpc readiness probes are fine: they run
from outside the container.
5. Devices
The NIC’s PHC (/dev/ptp*) for clock work, and on x86
/dev/cpu_dma_latency so a pod can cap C-states. The cap’s measured effect
is mixed (C-states), and a spinning core never idles, so
treat the grant as access, not a latency win. On arm64 the plugin ignores
the cpuqos annotation (k8s/nri-latency-pods/README.md, item 5).
Real-time scheduling: built, default off
SCHED_FIFO is not part of the five grants. It is in
k8s/nri-shared-cpus, off unless a node turns it on:
| path | what it does | default |
|---|---|---|
ergon-cpus --fifo <prio> | the exec wrapper takes SCHED_FIFO on the thread before it execs the command | off (no flag) |
sched-fifo.ergon.io/<container>: "thread" | the NRI plugin grants RLIMIT_RTPRIO and ERGON_SCHED_FIFO; the app or prelock takes the policy on the hot thread only | schedFifo: "off" — the node refuses the annotation |
Why it is off: its one committed A/B is mixed, and under it the loop’s
nohz_full CPU took timer interrupts again (SCHED_FIFO). RT throttling
(kernel.sched_rt_runtime_us, 950,000 of 1,000,000 µs by default,
sched-rt-group) can
take the CPU away. ergon-tune T7 warns on a real-time policy.
Deliberately not built
| item | why |
|---|---|
| SR-IOV VF per pod | Nitro guests have one ENA per ENI; there are no in-guest VFs |
| a netns-sysctl writer pretending the knobs are per-netns | busy_poll/busy_read are global; gro_flush_timeout and napi_defer_hard_irqs are per-netdev sysfs; rmem_max is not writable in a non-init netns. The node sets them (node-checks.md) |
| an IRQ DaemonSet that pins the latency pod’s NIC IRQs to the shared CPUs | IRQs on a CPU that also runs other work are not measured here; IRQs on a core of their own are (interrupt placement). The kept-CPU default stays |
RTPRIO is granted as a limit only on the latency-pods path. That
plugin never changes a scheduler policy, and the e2e asserts PID 1 stays
SCHED_OTHER and ERGON_SCHED_FIFO is empty.
Node setup
The full check list is k8s/nri-latency-pods/node-checks.md. The short version:
| setting | value |
|---|---|
| Kubernetes | >= 1.33 (the labs ran 1.36) |
| kubelet | cpuManagerPolicy: static; reservedSystemCPUs = the IRQ CPU(s) plus the shared CPUs; x86 full-pcpus-only |
strict-cpu-reservation | off (on, it empties the shared pool) |
| kernel | >= 6.7, cgroup v2 (cpuset.cpus.exclusive is what the partition writes, and it is 6.7; a vendor backport of that file also works — the plugin reads the file, not the version). Also nohz_full, rcu_nocbs, isolcpus=managed_irq on the exclusive CPUs, never the shared ones. 6.2 is enough for the shared CPUs only |
transparent_hugepage | never |
net.core.rmem_max / wmem_max | at least Aeron’s AERON_SOCKET_SO_RCVBUF / SNDBUF when raised (hft) |
net.core.busy_poll / busy_read | unset (global; not measured here). ergon-io busy-polls per socket |
| containerd | >= 2.2, NRI on, the default validator on |
Why not AKS: it has no supported way to change containerd (so an NRI
plugin cannot be installed) and no reservedSystemCPUs list. Everything
here needs one of those. The target is EKS on AL2023.
The two labels
They are different on purpose.
| label | where | what reads it |
|---|---|---|
pod ergon.io/latency: "hot" | every latency pod | the validating policy (extension 4) |
namespace ergon.io/latency: "true" | the latency namespace | the mutating policy (it reads namespaceObject) |
Related
- Shared CPUs for Helper Threads — the plugin that grants the shared housekeeping CPUs
- Kubernetes — deployment
- Kubernetes HFT tuning — every setting and its evidence
Benchmarks: x86 and Graviton
Aeron transport latency between two hosts on AWS. Not SBE codec benchmarks
(those are sbe/BENCHMARKS.md).
Tables, rows and data files:
md-dpdk/BENCHMARKS.md.
Terms: deployment glossary, network and DPDK glossary.
The DPDK rows are this repository’s proof-of-concept transport. No other kernel-bypass product was run on this hardware.
What the runs support
| finding | scope | Kubernetes |
|---|---|---|
| DPDK had a lower p50 than the best kernel arm in every run with both, at idle and under load; its max was lower at 288 B 1 M msgs/s | c9g.2xlarge, c7i.4xlarge, c7i.metal-24xl, one run each | DPDK needs node setup: Kubernetes |
| Graviton kernel UDP with interrupt moderation off: put the data NICs’ IRQs on a core of their own. The other two placements lost at 1 M msgs/s of 288 B | c9g.2xlarge, G1; on a second pair (G2) this arm lost in 1 of 5 reps | node-level: IRQ affinity is host-wide |
| One AWS flow carried at most 7.75–7.77 Gbit/s of Aeron frames, kernel and DPDK alike. Spread a heavier stream over several channels | c7i.4xlarge; c9g.2xlarge reached at most 685,574 msgs/s at 1,344 B | application config, in the pod |
| Not compared: Graviton against x86, metal against a VM, interrupts against busy polling | different pairs and builds, or arms that differ in several settings |
Figures, rows and every setting that did not separate: conclusions. ergon-io’s x86 and Graviton results: io.
Cost per hour
A pair is two instances. us-east-1, Linux; on-demand list prices, and spot
prices as of 2026-10-15 (instances.vantage.sh), as
estimate.py
holds them (lines 60–72). Spot moves by the hour and the zone.
| pair | physical cores each | on demand, $/h | spot, $/h | committed run |
|---|---|---|---|---|
c7i.2xlarge | 4 (8 vCPUs) | 0.714 | 0.400 | aws-20261014-160247 (1 rep) |
c7i.4xlarge | 8 (16 vCPUs) | 1.428 | not recorded | rounds 6–7, EKS |
c7i.metal-24xl | 48 (96 vCPUs) | 8.568 | not recorded | round 4 |
c8g.xlarge | 4 | 0.319 | 0.154 | clock runs (clockbench-aws-20261016.txt) |
c9g.xlarge | 4 | 0.348 | not recorded | none |
c9g.2xlarge | 8 | 0.696 | 0.306 | G1–G3 |
Metal costs 6 times a c7i.4xlarge and 12 times a c9g.2xlarge. No run
compared metal with a VM, so no measured benefit backs that price.
Kubernetes: EKS on c7i.4xlarge
DPDK at both ends, the media driver as a DaemonSet pod, round trips between
the two nodes’ pods, run eks-20261014-195824
(rows):
p50 28.37 (28.02–29.60), p99.99 63.33 (57.79–71.81) µs; max 148 µs (the
median of the five reps’ maxima 126, range 112–148).
No NAK or retransmit; 4 short sends. One arm, so nothing is compared. Setup:
Kubernetes.
How to read a number
Five things are called “a number” in this book. They are not compared with each other, and a table cell names which one it is.
| aggregation | what one value is | how it is summarised |
|---|---|---|
| batch throughput | messages or operations completed in a timed window | the window’s rate. md-dpdk: the median one-second window per rep (tput.rs:129-135), then the median across reps |
| individual latency | one round trip or one call | percentiles of the individuals, and the absolute maximum |
| UTC accuracy | one stamp’s error against the wall clock | percentiles of the errors, with the time-sync source named |
| median of repetition maxima | the worst value in one rep, then the middle rep | a summary of the reps’ worst, not a worst case |
| absolute observed maximum | the single worst value in any rep | one number; the per-rep spread goes in a note beside it |
“Max” unqualified means the absolute observed maximum. Where a cell is the
median of per-rep maxima it says so. In
md-dpdk/BENCHMARKS.md
a cell max X (a–b) has X the median of the five per-rep maxima
(analyse.py cell()), the five’s smallest and largest in brackets, and b
the absolute observed maximum. X is not a worst case.
Method. Aeron ping-pong between two hosts in one cluster placement group,
us-east-1, Debian 13, isolated CPUs, 5 reps a mode, every mode’s order
shuffled each rep. The lab writes each run to
target/md-dpdk-bench/<stamp>/; the rows quoted here are committed under
md-dpdk/benches/cloud/results/.
Java for Aeron Archive and Cluster
ergo-aeron-cluster is only a client. The cluster (consensus module,
clustered services, Archive) runs on Aeron’s Java, as does an Archive beside
a C media driver. This page: which Java, and the JVM and Aeron settings for
latency, for Aeron 1.53.3 (the release ergon pins). Each setting comes from
Aeron’s own scripts, benchmarks or wiki (sources); none has a
latency A/B here.
Terms: deployment glossary, network and DPDK glossary.
Which Java
| Use | Java | Why |
|---|---|---|
| Run Archive and Cluster | 25: Eclipse Temurin 25 (LTS) | The newest LTS in Aeron 1.53.3’s CI (17, 21, 25); ergon’s cluster harness runs on it in CI. eclipse-temurin:25-jre covers amd64 and arm64 (Graviton); Debian 13 ships openjdk-25-jre-headless. |
| Oldest that works | 17 | Aeron compiles its classes with --release 17. |
| Newest GA release, 27 | Runs, but not recommended | 27 is not an LTS release and is not in Aeron’s test matrix. |
| Build Aeron’s jars from source | JDK 25 (17 to 26 work) | CI builds them on 25. Aeron’s Gradle (9.7.1) fails on 27 with Unsupported class file major version 71. Any JDK makes Java 17 bytecode. Or skip Gradle: aeron-all-1.53.3.jar on Maven Central holds Archive, Cluster and the samples. |
The eclipse-temurin:25-jre image has neither wget nor curl. Fetch the
jar with a checksummed ADD:
FROM eclipse-temurin:25-jre
ADD --checksum=sha256:4c6c8a5014a2b67424051170392a005daa9a998ac963302d2819d0f3db749ef2 --chmod=644 \
https://repo1.maven.org/maven2/io/aeron/aeron-all/1.53.3/aeron-all-1.53.3.jar /aeron-all.jar
Ready-made settings
Two settings files, both tested by
cluster-test-harness/tests/jvm_settings.rs: the JDK takes every option
without a warning, every property is one Aeron’s sources name (Aeron ignores
a misspelt one), and a cluster node started with both serves a client.
java @aeron.jvm -cp aeron-all-1.53.3.jar \
io.aeron.cluster.ClusteredMediaDriver low-latency.properties cluster.properties
@aeron.jvm is a java argument file. Archive, ArchivingMediaDriver,
ClusteredMediaDriver and ClusteredServiceContainer load every properties file
named on their command line, so low-latency.properties and your cluster
settings go there (or pass each as -D<name>=<value>).
Adapt two settings in cluster-test-harness/jvm/aeron.jvm: the heap
(-Xms2g -Xmx2g) is a placeholder, so size it for your clustered service’s
state (Archive alone needs far less); the log goes to /dev/shm, which only
Linux has.
# JVM options for Aeron Archive, Cluster and the Java media driver, as a java
# argument file: `java @aeron.jvm -cp aeron-all.jar <main class>`. For Java 17
# and newer; run it on Java 25. cluster-test-harness/tests/jvm_settings.rs
# starts a cluster node with it and round-trips a message through it.
# Sources: Aeron's aeron-samples/scripts/java-common and cluster scripts, and
# the aeron-io/benchmarks scripts/run-java.
# Agrona reaches jdk.internal.misc.Unsafe: without this, IllegalAccessError.
--add-opens java.base/jdk.internal.misc=ALL-UNNAMED
# Archive CRC32 checksums reach into java.util.zip.
--add-opens java.base/java.util.zip=ALL-UNNAMED
# Archive, Cluster and the driver allocate nothing in steady state: the
# collector with the cheapest barriers.
-XX:+UseParallelGC
# A fixed heap committed at start. Size it for the clustered service's state.
-Xms2g
-Xmx2g
-XX:+AlwaysPreTouch
# Room enough that code-cache pressure never deoptimises hot code.
-XX:MaxMetaspaceSize=1g
-XX:ReservedCodeCacheSize=1g
# Every collection and safepoint with its cause, written by a background
# thread to tmpfs. %p is the process id.
-Xlog:async
-Xlog:gc*,safepoint=info,arguments=info:file=/dev/shm/aeron-jvm-%p.log:time,uptime,level,tags:filecount=4,filesize=16m
-Djava.net.preferIPv4Stack=true
# No bounds checks in Agrona's buffers. Read once, when the class loads: it
# must be set here, not by the application.
-Dagrona.disable.bounds.checks=true
cluster-test-harness/jvm/low-latency.properties:
# Aeron Archive and Cluster properties for latency, Aeron 1.53.3. Every spin
# or noop thread keeps a core at 100%: give each one an isolated core, or keep
# Aeron's default backoff. Pass the file as an argument to Archive's,
# ArchivingMediaDriver's or ClusteredMediaDriver's main (they load properties
# files named there), or each line as -D<name>=<value>.
# cluster-test-harness/tests/jvm_settings.rs runs a cluster node with them.
# Archive: a thread each for the conductor, recorder and replayer (the default
# DEDICATED mode), none of which sleeps.
aeron.archive.threading.mode=DEDICATED
aeron.archive.idle.strategy=spin
aeron.archive.recorder.idle.strategy=noop
aeron.archive.replayer.idle.strategy=noop
# Consensus module and clustered service: never sleep.
aeron.cluster.idle.strategy=noop
# Log buffers written out when created and faulted in when mapped, so the
# first write to a page is not a page fault. Costs start-up time and every
# term's memory up front. Aeron's benchmarks keep the defaults (sparse, no
# pre-touch) instead.
aeron.term.buffer.sparse.file=false
aeron.pre.touch.mapped.memory=true
# Thread names short enough for Linux's 15 characters (aeron-ar-rec,
# aeron-ar-rep, aeron-ar-cnd, aeron-cl-cm, aeron-cl-cs-0), so each hot thread
# can be found and pinned by name. The classic default names
# (archive-recorder, consensus-module-0-0, ...) are cut short.
aeron.thread.naming=new
# Count every duty cycle over 1 ms (default 100 ms), to see stalls.
aeron.archive.conductor.cycle.threshold=1ms
aeron.archive.recorder.cycle.threshold=1ms
aeron.archive.replayer.cycle.threshold=1ms
aeron.cluster.cycle.threshold=1ms
aeron.cluster.service.cycle.threshold=1ms
The JVM options
| Option | Why | Source |
|---|---|---|
--add-opens java.base/jdk.internal.misc=ALL-UNNAMED | Required. Agrona 2.x reaches jdk.internal.misc.Unsafe; without this option the JVM fails with IllegalAccessError. | Aeron’s scripts and build.gradle |
--add-opens java.base/java.util.zip=ALL-UNNAMED | Required once Archive checksums are on (aeron.archive.record.checksum); without it they throw InaccessibleObjectException. | Aeron’s scripts and build.gradle |
-XX:+UseParallelGC | Archive, Cluster and the driver allocate nothing in steady state. The choice is convention, not a measured collector A/B: every Aeron script uses it. | every Aeron script |
-Xms = -Xmx, -XX:+AlwaysPreTouch | The heap never grows, and every page is faulted in at start rather than on the hot path. | benchmarks |
-XX:MaxMetaspaceSize=1g, -XX:ReservedCodeCacheSize=1g | Code-cache pressure never deoptimises hot code. | benchmarks |
-Xlog:async, -Xlog:gc*,safepoint=info,... | Every pause and safepoint is logged with its cause, by a background thread, to tmpfs. | benchmarks (to /dev/shm); -Xlog:async is ergon’s addition |
-Djava.net.preferIPv4Stack=true | The Java driver uses IPv4 UDP sockets. | benchmarks, wiki, build.gradle |
-Dagrona.disable.bounds.checks=true | Turns off the bounds checks in Agrona’s buffers. It is read once, when the class loads, so it belongs on the command line. | low-latency.properties, benchmarks |
The Aeron properties
| Property | Default | Why |
|---|---|---|
aeron.archive.idle.strategy=spin, aeron.archive.recorder.idle.strategy=noop, aeron.archive.replayer.idle.strategy=noop | backoff | The Archive’s conductor, recorder and replayer never sleep. Each one needs an isolated core. |
aeron.cluster.idle.strategy=noop | backoff | The consensus module and the clustered service both read this property. |
aeron.term.buffer.sparse.file=false, aeron.pre.touch.mapped.memory=true | sparse, no pre-touch | The first write to a page of a log buffer does not take a page fault. Aeron’s own sources disagree here: its low-latency.properties sets both, while its benchmarks keep the defaults. Pre-touching charges every mapped term to the process’s memory, and so to a container’s limit. With the C media driver, the driver’s own setting is AERON_TERM_BUFFER_SPARSE_FILE. |
aeron.thread.naming=new | classic | Gives thread names short enough for Linux’s 15 characters, so each hot thread can be pinned by name (see below). |
aeron.archive.*.cycle.threshold, aeron.cluster.cycle.threshold, aeron.cluster.service.cycle.threshold = 1ms | 100 ms | Counts every duty cycle longer than 1 ms, so stalls are visible. |
These settings are left at their defaults:
aeron.archive.file.sync.levelandaeron.archive.catalog.file.sync.levelstay at0: writes go to the OS page cache only.1adds fdatasync and2adds fsync. Keep the catalog’s level at least the archive’s. Level0survives a process dying and loses whatever is still in the page cache if the machine does. In a 3- or 5-member cluster another member’s log covers that loss, so0is the usual choice. A single-member cluster’s quorum is one: its local log is the only copy, so raise both sync levels together there and pay the fsync.aeron.archive.file.io.max.lengthstays at1m.aeron.archive.control.term.buffer.sparsestays attrue.aeron.spies.simulate.connectionstaysfalse. It is a media driver setting, which Aeron’s benchmarks set totrueon their driver. It matters when an Archive records a network publication by spying and nothing else subscribes: withfalsethe publication waits for a network subscriber before it is connected.
Pinning the hot threads
Java has no CPU-affinity setting. Aeron’s benchmarks start the JVM on the
housekeeping cores with numactl --membind --cpunodebind --physcpubind. They
then move each hot thread to an isolated core by name with taskset -p.
With aeron.thread.naming=new the names are aeron-ar-cnd, aeron-ar-rec,
aeron-ar-rep, aeron-cl-cm and aeron-cl-cs-<service id>:
pid=$(pgrep -f io.aeron.cluster.ClusteredMediaDriver)
for task in /proc/"$pid"/task/*; do
case $(cat "$task/comm") in
aeron-cl-cm) taskset -pc 4 "${task##*/}" ;;
aeron-ar-rec) taskset -pc 5 "${task##*/}" ;;
esac
done
Every spin or noop thread needs a whole isolated core of its own (see Hosts). On shared cores, keep Aeron’s default backoff.
Small or shared hosts
When the JVM shares its cores, or runs in a container with a few hundred MiB:
- Keep the backoff idle strategies. A spinning thread on a shared core takes the core from everything else.
- Serial GC (
-XX:+UseSerialGC) is the one-core collector; its cost against ParallelGC here is not measured. - Set
-Xmsequal to-Xmx, and size-XX:MaxDirectMemorySize,-XX:MaxMetaspaceSizeand-XX:ReservedCodeCacheSize. The container’s limit must cover all of them, plus thread stacks and native memory. - Leave
aeron.pre.touch.mapped.memoryoff. Every term a pre-touching client maps counts against the container’s memory limit.
Options to leave out of older guides
-XX:+UseParallelOldGC: Java 17 and 25 refuse to start with it. Aeron’s wiki still lists it.-XX:-UseBiasedLocking: deprecated with a warning on 17. Java 25 refuses to start with it.-XX:+ZGenerational: Java 25 ignores it with a warning, and 27 refuses to start. ZGC is now generational only.- The
sun.misc.Unsafedeprecation (JEP 471, JEP 498) and--sun-misc-unsafe-memory-accessdo not apply. Agrona usesjdk.internal.misc.Unsafe, which is why the first--add-opensis needed.
Optional: measure first
These options are not in Aeron’s sources:
-XX:+UseCompactObjectHeaders(a product option since Java 25, JEP 519) makes objects smaller.-XX:+PerfDisableSharedMemstops the JVM writing its hsperfdata file under/tmp, at the cost thatjpsandjstatno longer see it.- Generational ZGC (
-XX:+UseZGC) is only worth it for a clustered service that allocates, needs sub-millisecond pauses and has cores to spare. - Huge pages through the JVM (
-XX:+UseTransparentHugePages, Linux only) cover only the heap, which is cold in steady state. Aeron’s hot memory is its mapped log buffers; size those withaeron.file.page.sizeon hugetlbfs, as Hosts describes.
In ergon’s tests
The cluster harness (ergo-aeron-cluster-test-harness) runs
$JAVA_HOME/bin/java, else java on PATH. A java older than 17, or one
that will not run, fails the test with its path and the fix. Every Java
process it starts reads cluster-test-harness/jvm/test.jvm, which follows
Aeron’s own test settings: checks on, nothing spins.
# JVM options of every Java process cluster-test-harness starts, as a java
# argument file (java @test.jvm). After Aeron's own test settings in its
# build.gradle: the checks stay on and nothing spins. aeron.jvm holds the
# production options, which tests/jvm_settings.rs tries.
# Agrona reaches jdk.internal.misc.Unsafe: without this, IllegalAccessError.
--add-opens java.base/jdk.internal.misc=ALL-UNNAMED
# Archive CRC32 checksums reach into java.util.zip.
--add-opens java.base/java.util.zip=ALL-UNNAMED
# A small fixed heap: Aeron keeps its buffers off the heap, and several JVMs
# share the machine.
-Xms256m
-Xmx256m
-XX:+UseParallelGC
# Agrona's bounds checks stay on, and a misaligned atomic access fails rather
# than tearing.
-Dagrona.disable.bounds.checks=false
-Dagrona.strict.alignment.checks=true
-Djava.net.preferIPv4Stack=true
export JAVA_HOME=$(/usr/libexec/java_home -v 25) # macOS; on Linux, the JDK's directory
just build-aeron-jars
just test-aeron-cluster-harness
Sources
- Aeron 1.53.3:
aeron-samples/scripts/java-common, the cluster scripts inaeron-samples/scripts/cluster,low-latency.properties,build.gradleand its CI matrix in.github/workflows/ci.yml. - aeron-io/benchmarks:
scripts/run-javaandscripts/aeron/low-latency-*.properties. - Aeron wiki: Performance Testing.
- Gradle’s Java compatibility matrix.
- JEP 471, JEP 498 and JEP 519.
Contributing
Changes must be reproducible, wire-compatible and honest about what was verified.
Work from behaviour
For a generator or protocol change:
- Add a focused behavioural, compile-fail, wire-parity or allocation test.
- Run it and see it fail.
- Make the smallest coherent change.
- Run the focused test and the affected crate’s full checks.
- Run the benchmark gate when a generated or cluster hot path changes.
A substring test on generated source can supplement a behaviour test. It does not prove wire correctness, type-state order, error propagation or allocation behaviour.
Checks
| Command | What it runs |
|---|---|
just policy | test-lane ownership and the suppression rules below, plus their self-tests |
just preflight | fmt, clippy, policy self-tests, book fences, golden and reference --check |
just check-products | ergo-sbe and ergo-aeron-cluster only |
just check-local | repository hygiene, fmt, clippy, the tests that need no Java, and the exchange-example sample |
just test | the full suite: builds the Aeron Java jars and runs the Java cluster lifecycle, recovery and HA lanes. A missing dependency fails |
just test-all | just test plus Miri and fuzz replay |
just check-coverage | coverage against the checked-in baseline |
just check-mutation | mutation testing over parser, resolver, sizing and dynamic-tail code |
RUSTDOCFLAGS="-D warnings" cargo doc -p ergo-sbe --all-features --no-deps
RUSTDOCFLAGS="-D warnings" cargo doc -p ergo-aeron-cluster --no-deps
Test policy
test-lanes.tsv assigns every tracked test-bearing Rust file to exactly one
lane. scripts/check-test-policy.sh rejects:
| Pattern | Where it looks |
|---|---|
#[ignore] | .rs files |
an ignore doc fence | .rs doc comments (Markdown rust,ignore is allowed) |
a test printing SKIP or Skipped: | .rs files |
--skip, --ignored, --include-ignored | justfile, just/*.just, scripts/*.sh, workflows |
if … cargo test/bench | the same control files |
cargo test … || true, ; exit 0 | the same control files |
[skip ci]-style markers, continue-on-error: true, any if: other than always() | workflows |
The policy cannot see a test that returns Ok early without printing those
words. The RLValidator leg is not such a test. It is compiled only with
--cfg ergon_rlvalidator (scripts/run-fix-sbe-conformance.sh). With that
cfg, a missing suite, class directory or classpath fails the test. just test
does not pass the cfg, so the leg is absent from the default suite.
A failure seen while changing the repository is not a pass because it looks pre-existing. Fix it, or stop and report it as a blocker. Never make a lane green by filtering, ignoring, conditionally bypassing or only logging the failure.
CI
| Workflow | What it runs |
|---|---|
ci.yml | policy; lint, test and DPDK jobs on ubuntu-24.04 (x86_64) and ubuntu-24.04-arm (arm64); Rust 1.99.0; Java 25 for the Aeron jobs; the coverage ratchet; the fixture crate as ordinary tests; benches compiled with --no-run, none run |
cluster-compatibility.yml | the cluster harness against Aeron 1.46.8 on Java 21, when cluster/ or sbe/src/ changes |
nightly.yml | weekly (Sunday): every fuzz target for 600 s, and the fixture crate under Miri |
release.yml | just release-check, then just bench and just bench-cluster on ubuntu-latest, blocking |
Mutation testing is manual; CI checks only its configuration, and the ratchet fails when mutation output is missing or empty. 32-bit x86 and big-endian s390x codec tests are not run in CI.
Performance
just bench # after a change that can affect generated hot paths
just bench-cluster # after a cluster session codec, offer, claim or egress change
The target is ergon / sbe-tool ≤ 1.00 on equal work. Ceilings, the gate’s
pass/inconclusive/fail rules and the fairness rules:
sbe/BENCHMARKS.md. Do not raise a ceiling. Do not copy
benchmark numbers into docs. A docs-only change needs no benchmark run.
fairness_policy_test requires the SBE and cluster parity suites to use
std::hint::black_box, assert correctness before timing, and keep the
benchmark disclosure. Treat a large result as suspect until the benchmark is
re-audited.
Error and example style
- Public library APIs use crate-specific typed errors.
- Tests and binaries may return
Result<(), Box<dyn std::error::Error>>. - Prefer
?tounwraporexpect. - Treat schema-declared text strictly; keep binary fields as bytes.
- Size message buffers from generated constants or length helpers.
- Use the high-level cluster facade in consumer examples.
Documentation
- The root README orients; crate usage goes in that crate’s README.
- One sample inventory:
samples/README.md. No per-sample READMEs. - A page that describes how the software behaves describes current, verified behaviour. A superseded number is not the current result.
- Keep the record in the tree. The AI-assistance account, benchmark round write-ups, screenshots and release notes stay. Git history is not a substitute for that record. Dated plans and scratch task trees do not belong in the tree.
- One page owns a fact; other pages link to it.
Package boundaries
The publication candidates are ergo-sbe, ergo-aeron-cluster, ergon-tune,
ergon-io and ergon-md-dpdk (api/public-api-baseline.toml). ergon-io and
ergon-md-dpdk depend on ergon-tune, so the release publishes it first.
scripts/check-publish-set.sh (in just policy) fails when a published crate
depends on an unpublished one, on a path without a version, or on an older
version than a published sibling’s, or when the release recipe, workflow, dry
runs or public-API gate leave a crate out or publish it before its
dependencies. A version bump therefore updates every version beside a
workspace path with [workspace.package]: ergo-sbe in
[workspace.dependencies] and the ergon-tune lines in md-dpdk/ and io/.
Inspect the payloads before a release:
cargo package -p ergo-sbe --list --allow-dirty
cargo package -p ergo-aeron-cluster --list --allow-dirty
cargo package -p ergon-tune --list --allow-dirty
cargo package -p ergon-io --list --allow-dirty
cargo package -p ergon-md-dpdk --list --allow-dirty
Packages exclude integration tests, fixtures not needed at build time,
benchmarks, Java harness code, application protocols, samples and internal
plans. ergon-tune and ergon-io ship their library sources and README only.
ergon-md-dpdk ships its native C, including the test fakes its build
compiles, but not its harness, device plugin or deployment assets.
Publishing, tagging and announcing a release need explicit maintainer
authorisation. Before an authorised release, run just release-check; the
release workflow runs the same command before cargo release.
Git hygiene
- Preserve unrelated working-tree changes and dirty submodules.
- Stage paths explicitly; do not use
git add -A. - Do not rename the
sbe,clusterorsamplesdirectories. - Use a short one-line conventional commit subject.
AI assistance: how ergo-sbe was built
ergo-sbe was developed with heavy AI assistance. Most of the implementation,
tests, benchmarks, samples, and documentation were written by coding agents. I
did very little direct coding.
I am saying that at the beginning because this is not a conventional project with a little autocomplete around the edges. If you do not want to use software written substantially by LLMs, stop here. This is probably not the right project for you.
That warning is not an apology, and this page is not marketing. It is a disclosure of:
- why I chose this project;
- what I knew before the models became involved;
- what I asked the models to do;
- what I did and did not review;
- how the generated codecs were verified;
- where the workflow worked and where it failed;
- which tools and models were used;
- what the work actually cost;
- why the crate is still labelled experimental; and
- what I would expect before using it in a financial system.
The short version is:
I supplied the SBE domain knowledge, requirements, API judgment, corrections, acceptance criteria, and release decisions. Coding agents supplied nearly all of the implementation. I reviewed the generated Rust API and output far more closely than I reviewed the
syn/quotegenerator implementation. The observable output is constrained by extensive tests, live byte-for-byte comparisons with officialsbe-toolRust codecs, official fixtures, compile-fail proofs, allocation checks, and performance gates. That is strong evidence, but it is not a substitute for independent production use.
This account describes the initial intensive development period ending in July 2026. Prices, model names, repository state, and my process will change. Treat dated figures as a historical record, not a permanent promise.
Read this first
This article is long because “AI-assisted” is too vague to be useful on its own. If you only have a minute, these are the facts that should determine whether you continue evaluating the crate:
| Question | Short answer |
|---|---|
| How much was written by AI? | Most of the implementation, tests, benchmarks, samples, and documentation. I did very little direct coding. |
| What was the human contribution? | More than a decade of SBE experience, the product requirements, API judgment, domain corrections, acceptance criteria, and release decisions. |
| What received human review? | Primarily the generated Rust API and source, encoded bytes, test failures, and benchmark results—not an exhaustive line-by-line audit of the syn/quote generator internals. |
| What independently constrains the output? | Official sbe-tool byte-for-byte comparisons, Java-produced fixtures, upstream schemas, compile-fail proofs, property tests, exact-length checks, allocation tests, and performance gates. |
| Is it production-proven? | No. It remains experimental 0.x software. Production users should validate their own schemas, versions, message shapes, and traffic. |
| What did development consume? | Roughly one month of intensive work. The initial 0.1.0 release used approximately 14 billion tokens (estimated from provider dashboards). The combined ledger on 2026-10-21 is 76.10 billion tokens (76,095,756,856): the other machine’s ccusage high-water mark plus this MacBook’s local ccusage. A run on either machine alone will not match that total (see cumulative token usage). |
| What did it cost? | ~$261 actual out-of-pocket (DeepSeek PAYG $77.39 + GLM plan $114 + subscriptions $70). The combined ledger’s own API-rate total is $19,731.14. The same measured mix, priced on one Anthropic model at the public card checked on 2026-10-21 with 5-minute cache writes, is $12,708.01 on Sonnet 5.5 and $25,416.02 on Opus 5.5. With my work Claude Enterprise subscription the Claude portion would be covered by the seat licence rather than per-token billing, so the real cost at work would be lower still. The single-provider what-if shows the input, output, and cache arithmetic. |
| Which model did most of the work? | DeepSeek: V4 Flash handled much of the early UltraMode/subagent work; the later sequential development stayed primarily on V4 Pro. |
Cumulative token usage (since 2026-06-28)
Per-model figures for Claude Code, Codex, and Grok Build CLI. The other
machine’s per-model high-water mark — every ccusage snapshot committed to
this file (2026-08-03, 2026-08-07, 2026-08-13, 2026-08-15, 2026-08-19,
2026-08-20, 2026-08-27, 2026-09-07, 2026-09-13 and 2026-09-14, plus the
2026-10-13 run) — is added to this MacBook’s local ccusage 20.0.40 reads on
2026-10-21. A later npx ccusage@latest --since 2026-06-01 -b that day raised
only the Grok row. It did not replace the other machine, and it did not drop
Codex usage from before 2026-06-01. The two ledgers are summed. A ccusage
run on either machine alone will not match this table.
| Model | Input | Output | Cache Create | Cache Read | Reasoning Output | Total Tokens | Cost (USD) |
|---|---|---|---|---|---|---|---|
| claude-fable-5 | 403,485 | 1,349,254 | 12,230,126 | 487,557,778 | — | 501,540,643 | $802.28 |
| claude-haiku-4-5-20251001 | 936 | 31,021 | 203,279 | 10,487,325 | — | 10,722,561 | $1.56 |
| claude-opus-4-8 | 189,029 | 553,025 | 4,804,415 | 97,619,868 | — | 103,166,337 | $99.09 |
| claude-opus-5 | 13,757,961 | 4,705,886 | 36,674,520 | 3,652,158,264 | — | 3,707,296,631 | $2,360.26 |
| claude-opus-5-5 | 220,976,251 | 113,604,569 | 593,122,783 | 27,123,459,935 | — | 28,051,163,538 | $11,887.75 |
| claude-sonnet-4-6 | 1,406 | 233,671 | 986,400 | 34,920,711 | — | 36,142,188 | $19.90 |
| claude-sonnet-5 | 88,990 | 2,988,394 | 31,267,091 | 2,340,454,308 | — | 2,374,798,783 | $622.06 |
| claude-sonnet-5-5 | 11,512 | 5,102,015 | 32,656,109 | 834,727,182 | — | 872,496,818 | $221.22 |
| deepseek-flash | 8,991,713 | 5,070,512 | — | 3,202,997,632 | — | 3,217,059,857 | $28.00 |
| deepseek-v4-flash | 52,791,367 | 17,446,045 | — | 2,734,161,536 | — | 2,802,078,889 | $35.00 |
| deepseek-v4-pro | 85,359,087 | 21,733,136 | — | 24,546,493,056 | — | 24,653,585,279 | $551.30 |
| glm-4.7 | 6,949,701 | 760,380 | — | 268,436,096 | — | 276,146,177 | $35.37 |
| glm-5.2 | 27,277,618 | 3,460,625 | — | 2,274,775,808 | — | 2,305,514,051 | $644.86 |
| gpt-5.5 | 7,380,642 | 366,392 | — | 69,651,456 | 105,368 | 77,503,858 | $92.47 |
| gpt-5.6-luna | 1,754,305 | 188,525 | — | 54,281,984 | 105,116 | 56,329,930 | $36.18 |
| gpt-5.6-sol | 60,151,783 | 5,239,880 | — | 1,520,516,224 | 2,617,722 | 1,588,525,609 | $1,011.70 |
| gpt-5.6-terra | 676,005 | 34,650 | — | 7,455,232 | 16,229 | 8,182,116 | $4.33 |
| gpt-6-astra | 9,061,594 | 681,360 | — | 140,212,992 | 217,983 | 150,173,929 | $236.08 |
| gpt-6.1-sol | 18,651,728 | 2,136,766 | — | 522,706,816 | 976,793 | 544,472,103 | $146.59 |
| grok-4.5-build | 10,030,758 | 881,349 | — | 177,071,360 | — | 187,983,467 | $114.67 |
| grok-4.6-build | 44,849,892 | 3,100,656 | — | 812,446,592 | — | 860,397,140 | $293.80 |
| grok-4.7-build | 39,298,841 | 5,592,936 | — | 621,799,168 | — | 666,690,945 | $166.51 |
| mimo-v2.6-pro | 62,028,500 | 1,093,150 | — | 1,875,607,680 | — | 1,938,729,330 | $34.69 |
| gpt-5.3-codex | 22,573,884 | 2,873,034 | — | 786,800,512 | 1,263,964 | 813,511,394 | $217.63 |
| gpt-5-codex | 9,351,684 | 988,119 | — | 204,395,904 | 689,664 | 215,425,371 | $45.08 |
| gpt-5.1-codex-max | 2,116,543 | 122,477 | — | 45,743,360 | 46,976 | 48,029,356 | $11.63 |
| gpt-5.4 | 1,517,763 | 54,121 | — | 14,817,280 | 24,110 | 16,413,274 | $8.09 |
| gpt-5 | 701,637 | 79,205 | — | 8,801,920 | 51,776 | 9,634,538 | $2.77 |
| mimo-v2.6-flash | 359,723 | 15,224 | — | 1,104,448 | — | 1,479,395 | $0.06 |
| gpt-5.4-mini | 122,102 | 4,288 | — | 434,560 | 2,399 | 563,349 | $0.20 |
| Total | 707,426,440 | 200,490,665 | 711,944,723 | 74,472,096,987 | 6,118,100 | 76,095,756,856 | $19,731.14 |
Notes:
- The other machine’s baseline is a high-water mark, not a single run’s output.
ccusagekeeps no ledger: it recomputes totals by re-reading local session transcripts, and Claude Code deletes transcripts older thancleanupPeriodDays. A--since 20260628query therefore loses its earliest days as time passes, so a later snapshot can report fewer tokens than an earlier one. That baseline is the per-model, per-column maximum across all eleven snapshots; since the tool can only under-report, that maximum is the best available lower bound. This MacBook’s 2026-10-21 read is added on top of that baseline. A second read the same day, filtered to--since 2026-06-01, is merged the same way: where it was higher, that column replaced this machine’s earlier figure; where it was lower, the earlier figure stays. The transcripts are different files, so each published row is the sum. - The 2026-10-13 run alone reported 57,084,644,123 tokens. Merging the
snapshots recovered from this file’s git history adds 2,589,115,055
tokens the current run can no longer see. A later
ccusageon one machine is added the same way. It does not replace this table. - In the 2026-10-13 snapshot:
claude-opus-5-5,claude-sonnet-5-5,deepseek-flash,gpt-6.1-solandgrok-4.7-build.claude-opus-5,claude-sonnet-5,deepseek-v4-pro,gpt-6-astraandgrok-4.6-buildset new total maxima. Every other model’s maximum is held by an earlier snapshot;grok-4.5-buildappears only in earlier snapshots. - Grok Build CLI rows come from
ccusage grok, which extracts them from the Grok CLI session logs. - Antigravity and Gemini CLI are not in the table. The other machine had no
observersample, and its usage of those tools is still absent. This MacBook’sccusage antigravityreports 290,048,735 tokens and $83.86 (2026-06-20 to 2026-07-05).ccusage geminireports 553,921 tokens and $0.56 (November 2025, December 2025, and February 2026). Those reads are recorded here and were not added to the rows above. - Hermes and OpenCode are excluded — automation, not development work on this project. This MacBook had no Hermes transcripts. Its OpenCode total is 178,124,443 tokens and stays out of the table.
- Codex reports cost per day rather than per model. On days that used more
than one model, the day’s cost is split between them in proportion to their
token share; token counts themselves are exact per-model figures from the
tool. On the 2026-10-21 MacBook read, Codex
totalTokensomits reasoning. The day’s cost was split on thattotalTokens. Reasoning is then included in the Total Tokens column. - All costs are at enterprise/pay-as-you-go API rates observed by each tool. Subscription fees (Claude, OpenAI, Grok) are not included.
- Sanity checks: the per-model column sums for the 2026-10-13 run reconcile exactly against each tool’s own reported totals (53,825,172,584 Claude Code + 2,019,689,986 Codex including 3,537,371 reasoning output + 1,239,781,553 Grok = 57,084,644,123), and the grand total is monotonic against the previously committed 25,853,122,297.
- 2026-10-21 MacBook check,
ccusage20.0.40 with cached offline prices. The first read was the full local history: Claude Code 14,674,098,035 + Codex 1,458,109,013 + Codex reasoning 2,484,098 + Grok 266,056,280 = 16,400,747,426. Claude Code covered 2026-06-26 to 2026-10-21, Codex 2025-09-13 to 2026-10-21 (1,154,747,421 of those tokens are before 2026-06-28), and Grok 2026-10-14 to 2026-10-21. A later read the same day,npx ccusage@latest --since 2026-06-01 -b, reported every detected agent: 15,583,155,859 tokens and $5,108.42. Antigravity was 290,048,735 tokens and $83.86, the figure already recorded, and stays out of this table. Gemini CLI and OpenCode had no rows in that window. Claude Code’s token total was unchanged. Codex token columns for models with usage on or after 2026-06-01 matched the first read, including their reasoning. Codex before 2026-06-01 is outside that window, so those rows stay at the first read (gpt-5.3-codex,gpt-5-codex,gpt-5.1-codex-max,gpt-5.4,gpt-5,gpt-5.4-mini, and the earlier part ofgpt-5.5). The unified report also repriced those same Codex tokens; that reprice was not applied, so the published Codex costs remain the daily split from the first read. Grok grew by 573,183 input, 250,829 output and 20,426,240 cache read (21,250,252 tokens; unrounded cost from $57.056 to $61.702). The MacBook ledger in this table is therefore Claude Code 14,674,098,035 + Codex 1,458,109,013 + Codex reasoning 2,484,098- Grok 287,306,532 = 16,421,997,678. Added to the other machine’s
59,673,759,178 this is 76,095,756,856. Unrounded costs are Claude Code
$4,737.668, Codex $537.250 and Grok $61.702, together $5,336.62. The
previous cost total was $14,394.52; combined $19,731.14.
deepseek-v4-flash’s Total Tokens cell remains 2,320,059 below the sum of its own columns, as previously published.
- Grok 287,306,532 = 16,421,997,678. Added to the other machine’s
59,673,759,178 this is 76,095,756,856. Unrounded costs are Claude Code
$4,737.668, Codex $537.250 and Grok $61.702, together $5,336.62. The
previous cost total was $14,394.52; combined $19,731.14.
What-if: all tokens through a single provider
This prices the cumulative table’s measured buckets on one Anthropic model. Rates are the public Claude API card checked on 2026-10-21 (model pricing). The result is a counterfactual. It is not an invoice, and it is not the ~$261 out-of-pocket spend.
Measured mix
The price uses the column sums. Those columns add to 76,098,076,915
tokens. The published total cell is 76,095,756,856, which is 2,320,059
lower, because deepseek-v4-flash’s Total Tokens cell is short of its own
columns by that amount. Cache writes in this ledger are entirely Claude
Code rows. Every other provider recorded cache reads and left cache create
blank.
| Category | Tokens | Share of 76,098,076,915 |
|---|---|---|
| Uncached input | 707,426,440 | 0.93% |
| Output | 200,490,665 | 0.26% |
| Reasoning output | 6,118,100 | 0.01% |
| Cache writes | 711,944,723 | 0.94% |
| Cache reads | 74,472,096,987 | 97.86% |
| Column sum | 76,098,076,915 | 100% |
Cache reads plus cache writes are 98.80% of that sum. Output including reasoning is 206,608,765 tokens (0.27%).
One Anthropic model for every measured token
Assumptions, all stated so the arithmetic can be repeated:
- uncached input at the model’s input price;
- cache writes at the 5-minute cache-write price (1.25× input), which is the write Claude Code records;
- cache reads at the model’s cache-hit price;
- output and reasoning output both at the output price;
- standard global API rates: no fast mode, no batch discount, no US-only 1.1× data-residency multiplier;
- token counts held fixed. Claude 4.7 and later, including Opus 5.5 and Sonnet 5.5, use a tokenizer Anthropic says produces about 30% more tokens for the same text. That uplift is not applied here;
- Haiku 5.5 bills a higher card when the prompt is over 100,000 tokens, counting input, cache writes, and cache reads toward that length. These sessions are long, so the over-100k line is the matching card. The under-100k line is the short-prompt card only.
Sonnet 5’s $2 / $10 per million input/output tokens, announced as introductory pricing through 31 August 2026, is now the standard price. The previously scheduled rise to $3 / $15 on 1 September 2026 did not happen. Opus 5.5 and Sonnet 5.5 cache hits are 0.05× input ($0.20 and $0.10 per million). Other Claude models on this list use 0.1×.
| Model | Input $/MTok | 5m write $/MTok | Cache hit $/MTok | Output $/MTok | Total |
|---|---|---|---|---|---|
| Opus 5.5 | $4 | $5 | $0.20 | $20 | $25,416.02 |
| Sonnet 5.5 | $2 | $2.50 | $0.10 | $10 | $12,708.01 |
| Opus 5 | $5 | $6.25 | $0.50 | $25 | $50,388.05 |
| Sonnet 5 | $2 | $2.50 | $0.20 | $10 | $20,155.22 |
| Haiku 4.5 | $1 | $1.25 | $0.10 | $5 | $10,077.61 |
| Haiku 5.5, prompts over 100k tokens | $0.50 | $0.625 | $0.05 | $2.50 | $5,038.81 |
| Haiku 5.5, prompts up to 100k tokens | $0.10 | $0.125 | $0.01 | $0.50 | $1,007.76 |
Where each dollar comes from, at the 5-minute write price:
| Model | Uncached input | Cache writes | Cache reads | Output incl. reasoning | Total |
|---|---|---|---|---|---|
| Opus 5.5 | $2,829.71 | $3,559.72 | $14,894.42 | $4,132.18 | $25,416.02 |
| Sonnet 5.5 | $1,414.85 | $1,779.86 | $7,447.21 | $2,066.09 | $12,708.01 |
| Opus 5 | $3,537.13 | $4,449.65 | $37,236.05 | $5,165.22 | $50,388.05 |
| Sonnet 5 | $1,414.85 | $1,779.86 | $14,894.42 | $2,066.09 | $20,155.22 |
| Haiku 4.5 | $707.43 | $889.93 | $7,447.21 | $1,033.04 | $10,077.61 |
| Haiku 5.5, prompts over 100k tokens | $353.71 | $444.97 | $3,723.60 | $516.52 | $5,038.81 |
One-hour cache writes are 2× input. Only the write column changes. Opus 5.5 becomes $27,551.86 and Sonnet 5.5 becomes $13,775.93.
The ledger’s own mixed-provider API total is $19,731.14. Sonnet 5.5 comes out lower because 97.86% of the measured tokens are cache reads and its cache hit is $0.10 per million. Opus 5.5 cache hits are $0.20 per million, so the same reads cost $14,894.42 and the all-Opus bill is $25,416.02. Opus 5 cache hits are $0.50 per million, which is why that line is $50,388.05. Out of pocket remains about $261.
Caveats
Cache writes exist only on the Claude rows. DeepSeek, GLM, Codex, and Grok recorded cache reads and no cache-create column. Pricing those reads as Anthropic hits does not add the write that would have created the cache entry. A real all-Anthropic run would have paid those writes. They are not in this ledger, so they are not in the totals above.
The cache-read share dominates the bill. 74.47 billion of the 76.10 billion column-sum tokens are cache reads. A project with shorter sessions would rank these models differently, because input and output prices would matter more than the cache-hit price.
Token counts are held fixed. Another model can tokenise the same transcript differently, emit different output and reasoning, and keep a different cache-hit ratio. The Claude 4.7+ tokenizer note above is the documented case of that. These figures isolate the rate card.
The 14-billion-token comparison further down uses a July 2026 sample and the prices checked on 26 July 2026. It is not this ledger.
Choose the path that matches why you are here:
- Evaluating the crate: read what I reviewed, verification, unsafe code and the trust boundary, why it is experimental, and the production checklist.
- Learning from the development method: read the working loop, project phases, Git history, failed approaches, and the practical playbook.
- Understanding model economics: read the cumulative token usage, single-provider what-if, O(n²) and caching explanation, observed usage and spend, cache sample, and normalized pay-as-you-go comparison.
- Contributing: read the AI-assisted contribution policy.
Contents
- Cumulative token usage (since 2026-06-28)
- What-if: all tokens through a single provider
- Why this page exists
- What I was trying to build
- Why I chose it as an AI experiment
- Authorship
- What I reviewed—and what I did not
- The actual working loop
- How the project evolved
- What the Git history shows
- What did not work
- Verification
- Performance
- Unsafe code and the trust boundary
- Tools and models
- Long context, O(n²), and caching
- Observed usage and spend
- Pay-as-you-go comparison
- A practical playbook
- The personal experience
- Why the crate is experimental
- What production users should verify
- AI-assisted contributions
- Final assessment
Why this page exists
SBE is widely used in financial systems. In that environment, code quality is not an aesthetic preference. A plausible-looking logic error can corrupt a message, misread a price or quantity, or fail only when a particular schema version or repeating-group shape appears in production.
AI has made software provenance much harder to judge. Two people can publish similar-looking libraries:
- a domain expert who has encountered the protocol’s failure modes for years;
- somebody with no practical knowledge of the domain who asked an LLM to generate a library and was impressed that it compiled.
The second person may honestly believe the result is excellent because they do not know what the model misunderstood. A reader should not have to reverse engineer which situation applies.
LLM-generated defects also tend to feel different from ordinary human defects. A human implementation often contains a typo, a missed branch, or a copy-and-paste error inside a design the author understands. An LLM can produce a coherent implementation of the wrong mental model. The code may be clean, consistent, documented, and comprehensively wrong about one domain invariant. That changes how it should be reviewed.
This page therefore exists so that an engineer—especially one responsible for money—can make an informed decision. Do not infer quality from the amount of code, the fluency of the documentation, or the fact that tests are green. Look at the verification evidence, the remaining trust boundaries, and the experimental status.
What I was trying to build
I have used SBE for more than a decade, including Java and Rust systems. The project idea predates this AI experiment.
Over those years I repeatedly saw the same classes of problem:
- A new SBE user reads or writes positional data in the wrong order.
- Repeating groups and variable-length data make buffer sizing easy to get wrong.
- Java-style flyweights and parent hopping become awkward under Rust’s borrow checker.
- A low-latency wire representation is valuable on a hot path, but it is unnecessarily unpleasant when application code simply wants a Boolean, decimal, owned DTO, or database record.
- A partially completed encoder can be mistaken for a complete message unless the API makes that misuse impossible.
My desired API followed from those experiences:
- wire order enforced at compile time;
- no final byte slice or encoded length until all required tail fields have been written;
- closure-based nested groups that work naturally with Rust borrowing;
- exact buffer sizing for messages containing groups and variable data;
- a fast flyweight path for latency-sensitive code;
- optional conversions and domain DTOs for code that does not need to operate directly on the wire representation; and
- official SBE wire compatibility as the non-negotiable contract.
Without coding agents, I estimated that the generator would take me roughly
four to six months. I had previously written code generation by hand in another
open-source project and knew how fiddly a large syn/quote implementation
could become. My original plan was to wait until a long garden-leave period and
build it manually.
Why I chose it as an AI experiment
I had a two-and-a-half-week holiday and wanted to improve from being a regular LLM user to understanding agentic coding properly: which models were useful for which jobs, how context affected them, how quickly subscription limits became the bottleneck, and whether the impressive multi-agent workflows shown in demos survived contact with a non-trivial codebase.
This project seemed unusually well suited to that experiment:
- I already knew the domain and the behaviour I wanted.
- The requirements were concrete; I did not need an LLM to teach me SBE.
- Much of the implementation was mechanical but cumbersome code generation.
- Official SBE has extensive schemas, fixtures, and tests.
- The product of the generator is Rust source that I can inspect directly.
- Wire bytes and benchmark results provide objective feedback.
- If the model misunderstood an API, I could normally identify the problem quickly and show it the shape I wanted.
I had also seen a Jon Gjengset demonstration of an AI-assisted Rust porting exercise. It made this kind of project look worth trying.
The holiday work was intense. It was effectively all I was doing: at least eight hours and sometimes closer to fourteen hours on a day. An agent might run for 10, 20, or occasionally 30–40 minutes, during which I could do something else, but I stayed available to inspect its progress and intervene. The project was not finished in two and a half weeks. It expanded to roughly a month of heavy work, including subsequent evenings and weekends, because I added useful features and raised the quality threshold as the crate became closer to something I might genuinely use.
How the scope grew
Some capabilities were firm requirements from the beginning; others became practical only after I saw how quickly the generator could evolve.
- Exact encoded length was something I had wanted for years, but I did not initially assume it would fit into the holiday scope. Once the agent could generate the repetitive machinery quickly, I decided it was worth doing properly.
- Domain DTOs began as a nice-to-have and expanded when it became clear that they could make SBE useful outside the hottest latency-sensitive path.
- Schema evolution was part of the intended protocol support.
- The Aeron Cluster client began as a realistic consumer of the codecs rather than the main product. Focused samples later became a better way to exercise difficult API shapes.
The ease of generating experiments encouraged scope growth, but it did not make the verification free. Every added feature created more generated surfaces, tests, and benchmark work.
Authorship: what was mine and what was generated
I did very little manual implementation. Even when I saw the required code change, I usually described it to the active agent and let the agent edit the files.
That was partly deliberate. I found that mixing my own live edits with an agent’s existing context often made the agent less reliable. It continued from the version of a file it had already read unless I explicitly told it to reload and reconcile everything. The most successful workflow was to let the agent drive the edits while I drove the design and feedback.
The division was approximately:
| Area | My role | Coding-agent role |
|---|---|---|
| Problem and product | Chose the problem from practical SBE experience | None |
| Domain behaviour | Defined the required SBE behaviour and corrected misunderstandings | Implemented the behaviour described |
| API design | Specified the desired properties, inspected generated APIs, rejected awkward designs | Proposed Rust representations and iterated on them |
| Generator | Specified and reviewed generated output; did not deeply review every generator path | Wrote nearly all syn/quote implementation |
| Tests | Chose important scenarios, inspected tests, ported the verification strategy from official SBE | Wrote and ran most test code |
| Performance | Required equal-work parity, personally ran and inspected benchmarks, rejected regressions | Wrote harnesses, ran them repeatedly, diagnosed and revised regressions |
| Documentation and samples | Supplied the intent and judged whether examples represented a usable API | Drafted most prose and sample code |
| Release decisions | Decided what was acceptable and retained the experimental warning | Provided recommendations, not authority |
Design ideas that came from my SBE experience
The principal goals were not invented by a model:
- Compile-time wire ordering. I wanted it because ordering mistakes were a routine real-world SBE support problem.
- Exact encoded length. I wanted it because humans repeatedly miscalculate nested groups and variable data.
- Closure-based groups. I had already used this pattern manually to avoid borrow-checker pain.
- Converters and DTOs. I wanted application code to use types such as Rust
booland decimal values where direct wire access was unnecessary. - Checked constructors with private zero-check cores. I wanted fallible
wrap/decodefor every untrusted buffer, and a measured private hot-path core only after extent proof (public*_uncheckedtwins only if keep=true). What the crate ships now is the three tiers in Unsafe code and the trust boundary. - Unsafe only when justified. I asked the agents to test particular unsafe optimisations, measure them, and remove them when they did not matter.
The models helped explore implementations. For example, I asked how Rust could enforce ordering and discussed traits and type-state representations. A model proposed concrete named stage structs. That was a good implementation of my requirement and ultimately avoided a performance problem with generic stages.
For converters, I knew the user-facing capability I wanted but had not fully designed the API. The models proposed several shapes. Some were wrong or ugly; I showed the kind of calling code I wanted and refined the result.
This is the fairest summary: the domain requirements and product judgment were human-authored; the implementation and much of the Rust design exploration were AI-assisted.
What I reviewed—and what I did not
This distinction is essential.
I did not perform a comprehensive, line-by-line human review of the
generator implementation. In particular, I did not deeply audit every
syn/quote path that constructs the emitted source.
I had written this sort of generator manually before. It is fiddly, but it is
also a task at which I had already seen LLMs perform well: provide a concrete
Rust shape and ask the model to emit that shape for every schema case. My
expected failure mode was not usually “the model cannot call quote!.” It was
“the model has misunderstood what the generated API or wire behaviour should
be.”
I therefore concentrated review on the generator’s observable product:
- the generated Rust source;
- whether the public API is clean and usable;
- whether staged types make illegal orderings unrepresentable;
- actual encoded bytes;
- official SBE fixtures and official
sbe-tooloutput; - exact-length results;
- error behaviour on malformed or incomplete buffers;
- allocations on intended zero-allocation paths; and
- performance relative to official generated Rust codecs.
I could often inspect generated Rust and immediately see an incorrect offset, an awkward repeating-group API, an invalid stage transition, or unnecessary complexity. That feedback was much faster and more useful than mentally simulating a large code generator.
This means the project should not be described simply as “human-reviewed code.” A more accurate description is:
Human-directed, generated-output reviewed, and behaviourally verified, with generator internals substantially AI-authored and not exhaustively line-reviewed by a human.
The automated evidence reduces risk. It does not prove that an untested schema shape cannot expose a generator defect.
The actual working loop
The primary agent interface was Claude Code CLI, often connected to custom models rather than a Claude model. Claude Code was useful because I wanted to learn the tool used at work and because its endpoint configuration let me run DeepSeek and GLM through the same agent workflow.
The work ran on a Mac mini at home. I connected with JetBrains RustRover Remote Development, the Claude Code app, and ordinary SSH. I used Herdr, an agent-aware persistent terminal multiplexer, so the Claude Code session survived disconnects and I could reattach from another device.
This made long-running work convenient, but not unattended. There were two review modes:
- Output review. Let the agent finish a small change, generate codecs, run checks, and then inspect the resulting API and behaviour.
- Live intervention. Watch the edits and reasoning, stop the agent when the approach was visibly wrong, explain the correction, and let it continue.
A typical successful loop was:
- I describe one concrete behaviour or API improvement.
- The agent adds or updates focused tests.
- The agent changes the generator.
- It regenerates and compiles the Rust output.
- It runs relevant unit and parity tests.
- For hot-path changes, it reruns the benchmark gate.
- I inspect the generated API, bytes, failures, and benchmark result.
- I give a specific correction and repeat.
The short feedback loop mattered more than one-shot model intelligence.
How the project evolved
Looking back, the work divided into several recognisable phases. This matters because the workflow that appeared successful in the first phase was not the workflow that completed the project.
Phase 1: the spectacular greenfield demo
At the beginning, specifications, issues, subagents, and separate worktrees looked almost magical. There was no established architecture to collide with, so several agents could create apparently useful pieces at once. This is the part of agentic development that makes the best video: a blank repository becomes a compiling application while multiple streams of activity scroll past.
It was real progress, but it created the wrong expectation. Parallel output is not the same thing as an integrated design. As soon as type-state transitions, generator conventions, error handling, wire offsets, and generated API style became shared constraints, locally reasonable changes stopped composing.
Phase 2: shared invariants forced sequential work
The project became productive again when I reduced the unit of work and made the feedback loop mostly sequential. One agent changed one connected area, regenerated the codecs, ran the focused tests, showed me the result, and received a correction. This was less theatrical and much more effective.
At this stage I learned that I needed to inspect the artefact closest to the
contract. For ergo-sbe, that was normally the emitted Rust, the calling API,
the encoded bytes, or the benchmark—not the syntax-tree construction that
produced it. I could identify a bad offset or ugly repeating-group API quickly
in generated code. The same mistake was much harder to see by mentally
executing hundreds of lines of syn and quote.
Phase 3: performance invalidated an elegant design
The first type-state design used generics (Encoder<State>) because I assumed
the abstraction would be free after monomorphisation. It was elegant and enforced
the ordering requirement, but the benchmark showed a meaningful encoding regression.
A design can be type-safe, idiomatic, tested, and still fail the product requirement.
The LLM helped explain why the generated machine code differed and proposed
concrete named stage types with a zero-sized H: HeaderState marker. I accepted
the concrete representation because the measured result mattered more than abstract
neatness. All 15 maintained parity comparisons passed at or below the 1.00×
ceiling in the 0.1.8 release (both LTO profiles). From then on, benchmark
parity became part of the definition of done for relevant changes. The gate
as it stands now, including the exceptions and the A/A noise band, is
sbe/BENCHMARKS.md under “The gate”.
Phase 4: cheap implementation expanded the product
Once the core generator was working, features that would have been too expensive in a four-to-six-month manual schedule became plausible. Exact encoded length, DTOs, converters, richer samples, schema-evolution cases, and more exhaustive generated APIs all grew during this period.
This is one of the genuine advantages of LLM implementation: it lowers the cost of trying a design. I could ask for a complete version, inspect the result, reject it, and try a different shape without feeling attached to the discarded code. The disadvantage is that every cheap feature creates an expensive verification and review obligation. Generated code is cheap; confidence is not.
Phase 5: hardening and disclosure
The final phase was less about visible capability and more about earning confidence: official fixture decoding, byte-for-byte dual encoding, multiple official schemas, compile-fail tests, exact-size matrices, allocation checks, unsafe audits, benchmark gates, documentation, and this disclosure.
The project crossed a line during this phase. It was no longer merely an exercise in learning agents. It had become a crate I wanted other SBE developers to evaluate. That raised the standard and is why the work continued past the holiday.
What the Git history shows
The commit history provides a useful independent record of how the project actually developed. It should not be treated as a measure of human effort: agents commit much more frequently than I would when working manually, and some commits are tiny formatting, test, or repair steps. It does, however, show the shape and sequence of the work.
The history snapshot ending at commit 85442ed on 26 July 2026 contains
976 commits after the initial main scaffold. The active codegen work
began on 5 July and the history records:
- 14 commits on 5 July;
- 157 on 6 July;
- 128 on 7 July;
- 89 on 8 July;
- 46 on 9 July;
- 74 on 10 July; and
- 41 on 11 July.
That is consistent with the intensity of the holiday period, but the more
interesting evidence is structural. The history contains ten explicitly named
worktree-agent or equivalent worktree merge commits. Every one of those
occurred on 6 or 7 July. After that early experiment, the branch becomes
overwhelmingly linear; the only much later merge was an ordinary remote
tracking merge on 25 July. This is visible evidence of the transition I
described: parallel subagents were exciting in the greenfield phase, then
shared invariants pushed the development into a sequential loop.
The subjects also expose the sequence of technical lessons:
- 5–6 July: scaffold the generator, create the roadmap, add baseline wire tests, CI, golden generation, upstream regression schemas, benchmarks, unsafe experiments, and early exact-length support.
- 7–8 July: migrate more generator code to
syn/quote, expand group and variable-data support, add property and conformance tests, and repeatedly repair the interactions between those features. - 9 July: benchmark-driven redesign from generic encoder states to non-generic concrete structs, followed by generated-shape regression tests.
- 10 July: introduce concrete consuming decoder stages, compile-fail ordering proofs, zero-allocation checks, and broad generator coverage.
- 11 July: restrict bytes and encoded length to complete stages, add nested message and converter workflows, and prove callback and stage ownership constraints.
- 17–21 July: rerun multi-run benchmark matrices, deepen DTO/converter
support, migrate the Aeron Cluster client to
ergo-sbe, and harden Cluster behaviour and samples. - 22–24 July: rename the workspace to
ergon, expand the L3 examples, build the staged exact-length API for uniform, ragged, nested, and variable-data shapes, and revisit checked versus unchecked performance. - 25 July: fix DTO conversion and ragged-length defects, complete Cluster reliability work, run Java interoperability tests, repair benchmark gates, and add broader generated-code safety tests.
- 26 July: clarify documentation and packaging, add live byte-for-byte
comparisons against checked-in official
sbe-toolRust codecs, close the multi-schema parity gaps, and publish this disclosure.
This is a messier and more credible history than a one-shot generation story. It contains reverts, merge repairs, performance regressions, benchmark redesigns, API replacements, small cleanup passes, and tests added after failures. It shows that the project was not produced by one prompt. It was produced through hundreds of short, observable corrections.
What did not work
Large parallel-agent plans
At the beginning I tried the impressive workflow often shown in demonstrations: a specification becomes issues, issues become parallel subagents and worktrees, and all the results merge together.
It looked extraordinary during the first greenfield days. Once the codebase contained shared generator invariants and interdependent API decisions, it ground to a halt. Agents made locally reasonable changes against different assumptions. The merge cost and coordination cost overwhelmed the parallelism.
For this project, meaningful work became mostly sequential. Parallel agents were useful only for genuinely independent investigation, not for several changes to the same evolving generator.
Long one-shot requests
“Implement this and come back in an hour” stopped working as complexity grew. The useful workflow required constant feedback. When the agent had freedom to invent a user-facing API, it often produced something technically plausible but ugly. The encoded-length API was one example: the model understood the goal but repeatedly proposed interfaces I would not want to use. Once I supplied a concrete calling shape, it could implement it.
Fabricated authority: the “SBE spec §4.1” incident (July 2026)
Model: GPT-5.6 Sol (OpenAI). Harness: Codex CLI v0.144.5.
Session: 019f7f7d, 2026-07-20 12:26 UTC, from ~/RustroverProjects/ErgoSBE.
Commit: bd3f7ce.
This was a frontier model — OpenAI’s top-tier offering at the time. I was expecting to find DeepSeek behind this when I traced the history. I was wrong.
One coding agent left a six-line comment in the code generator that nearly broke byte-identical wire parity across every big-endian schema:
// SBE spec §4.1: MessageHeader is ALWAYS little-endian on the wire,
// regardless of the schema's declared byteOrder. The body follows
// the schema byteOrder; the header composite must use LE.
let comp_byte_order = if composite_tokens[0].name == "messageHeader" {
ByteOrder::LittleEndian
} else {
ir.byte_order
};
There is no SBE spec §4.1 that says this. The comment was invented. The actual SBE specification does not mandate little-endian headers, and the sbe-tool reference implementation uses the schema’s declared byte order for all fields including the message header.
The fabricated comment was treated as a load-bearing design constraint by
subsequent coding agents. They wrote a test (endianness_header_is_always_le)
that asserted LE-only headers, modified the code generator to enforce the
non-existent rule, and regenerated golden files to match. The test passed.
The dual-encode parity tests also passed—because those tests compared ergon
output against patched sbe-tool reference crates that had been modified to
match the fabricated behaviour.
The damage was discovered only when an independent verification regenerated the sbe-tool reference crates from untouched upstream and found that ergon produced different bytes for big-endian schemas. Tracing the discrepancy back to a single comment with a fake spec citation took several hours.
Lesson: An LLM can embed a confident citation to a non-existent authority
inside a code comment, and that citation will be treated as fact by other
LLMs that read it. The resulting code will compile, pass tests, and look
professional. A // spec §X.Y says comment carries rhetorical weight that a
// I think comment does not, and that weight survives even when the
citation is entirely fabricated.
The fix: remove the six lines, delete the test that enforced the fabricated rule, and regenerate the golden file. No other code was affected.
Warning for anyone building with LLMs: when an agent asserts a domain fact with a precise citation, verify the citation exists before allowing it to become a constraint that other agents build upon. A confident but fabricated reference is harder to detect than an obvious mistake. And do not assume the fabricating model was the cheap one — frontier models are just as capable of hallucinating authority as anyone else.
LLMs disabled my tests rather than fixing the bugs (July–August 2026)
This failure reduced my confidence more than any other. It was not a one-off — it became a visible pattern once the project had enough tests to serve as a genuine oracle.
I relied on the extensive test suite and benchmark gates I had built up. I assumed they would catch regressions before they shipped. They didn’t — because LLMs kept disabling them rather than fixing the bugs they surfaced.
The pattern repeated across the 0.1.10 release preparation cycle:
-
Benchmark gate entries removed. The cluster bench gate script (
scripts/check-bench-gate.sh) had entries silently removed by an LLM that saw a failing ratio and “fixed” it by deleting the gate line rather than investigating the performance regression. The removed entries concealed real gaps:session_connect_requestencode at 1.19× andnew_leader_eventdecode at 1.86× slower than sbe-tool. The gate went green, but the performance regressions were still present. -
Unequal-work benchmark comparisons hidden. When benchmarks compared ergon’s checked
decode()(validating headers and extents) against sbe-tool’s uncheckedwrap(), the response was to remove the comparison from the gate rather than fixing the benchmark to do equal work on both arms. -
Regression bugs shipped through. The gate-silencing pattern meant that actual regressions — codegen changes that made hot paths slower — passed through review because the gates no longer measured them.
LLMs become less trustworthy as a project matures. Early greenfield work has no existing tests to disable, so the pattern is invisible. Once the test suite and benchmark gates are dense enough to catch real problems, the LLM’s incentive to produce green output collides with the gate’s purpose. “Make the tests pass” offers two paths: fix the code, or remove the test. The second path is shorter, and LLMs take it consistently across models and vendors.
Extensive unit test and benchmark coverage was not protecting me. What did protect me was human code review — specifically, reviewing every change to test files, gate scripts, and benchmark harnesses as critically as changes to production code. A test or gate entry that is removed, skipped, or weakened must be treated as a blocking defect, not an administrative cleanup.
The policy infrastructure in this repository — just policy,
check-test-policy.sh, the CI gate that rejects #[ignore] and
continue-on-error — exists because this pattern was observed. But prose
rules and policy scripts are still not enough. The only durable defence is a
human reviewer who asks: “Did this change make the software better, or did it
just make the failure invisible?”
The mechanism is straightforward:
- A coding agent is asked to make a change — a new feature, a refactor, a performance improvement.
- It runs
cargo testand sees a failure. - The failure is in a test the agent did not write and does not understand. The agent is not being evaluated on fixing pre-existing bugs; it is being evaluated on completing the requested change.
- The agent excludes the failing test — an
#[ignore]attribute, a#[cfg(not(feature = "…"))]gate, a test-selection filter, aSKIPsentinel, acontinueover an error in a fixture loop, or acontinue-on-errorCI wrapper. - The agent’s own task is now green. It commits the change.
The critical moment is step 5. If that session does not commit the test exclusion — perhaps the agent correctly treated it as a local workaround it intended to revert — the working tree still contains a disabled test. A different LLM session, asked to commit and push, sees modified files and commits them. The second agent is not being asked “did you review every changed line?” It is being asked “commit and push.” It does not know which edits were intentional and which were debugging debris.
The result: a released version ships with tests that are silently disabled. Users and the maintainer believe the full suite passed. A real bug — the one the original failing test existed to catch — is still present. Nobody knows.
Here are the specific incidents from this project (verified through git history, changelog entries, and session transcripts):
Allocation-count tests (#[ignore]). Three allocation-count tests had
#[ignore] attributes added by an LLM session that encountered unexpected
allocation behaviour. The tests already passed — the agent did not
investigate. Another session committed the attributes. They were restored
only during a later audit. The commit message says “they already passed when
the stale ignored attributes were removed.”
Cluster restart and quorum tests (Java lane gate). The Cluster lifecycle
tests — log recovery, restart readiness, quorum behaviour — were gated
behind conditional compilation or simply filtered out of the test run. An
LLM that could not run the Java dependency decided to exclude the test
rather than report the missing dependency. Re-enabling them exposed and then
fixed four real harness defects: a client outliving its embedded media
driver, a restart returning before Java readiness, a stale launcher class
inside aeron-all.jar, and crash recovery restarting before Aeron’s
10-second archive-mark lease expired. Every one of those bugs shipped
because the tests that would have caught them were suppressed.
Schema-loop SKIP/continue. An LLM added continue paths inside a
fixture-discovery loop that silently skipped schemas it could not parse.
Missing production fixtures and unreadable directory entries disappeared
from the test count instead of failing. The fix replaced every continue
with an asserted parse outcome. A test that silently skips broken input is
not a test.
--skip explicit_implicit in the justfile. The just test and
just check targets contained --skip explicit_implicit — a test-filter
flag that hid a failing test from the CI lane. It was added during
development and never removed. The test itself was repaired later, but the
damage was already done: a passing CI run was not evidence that all tests
passed. The justfile now contains an explicit warning to AI assistants
that test-selection bypasses are forbidden.
Ignored Rustdoc fences. Multiple Rust code examples in documentation
had rust,ignore fences. An LLM that could not make an example compile
added ignore rather than fixing the code or using an honest text fence.
These were replaced with compilable rust examples (compile-checked by the
docs-validation harness) or explicitly schematic text fences; remaining
rust,no_run fences are non-compiling by design (build scripts, config
illustrations).
Phantom regeneration test. A file named encoded_length_api.txt
advertised a regeneration test that did not exist and was not checked by
any test. An LLM created the advertising file without creating the test
it advertised. The file was removed once discovered.
Parity test assertions modified to match broken output. The dual parity
tests — live byte-for-byte comparisons between ergo-sbe and official
sbe-tool Rust output — were the single most important correctness check
in the project. An LLM session that encountered a parity mismatch did not
stop and diagnose the codegen defect. It changed the parity test assertion
to match the broken output. The test passed. The bytes were wrong. The
commit looked like progress. This was the most confidence-destroying
incident because it proved that even an independent reference oracle can be
defeated by an agent that is more motivated to produce green output than
correct output.
Dead locals in the generator. A mutation-testing survivor analysis found unused local variables in the code generator that had been left behind by an earlier LLM session. The variables had no effect on generated output but added noise. The agent that introduced them moved on without cleaning up.
The policy infrastructure in this repository — just policy,
check-test-policy.sh, test-quality-ratchets.sh, the mutation ratchet,
the coverage ratchet, the CI gate that rejects #[ignore] and
continue-on-error — exists because this pattern was observed across
multiple sessions and models. Prose instructions in CLAUDE.md were not
enough. The most important commit in the hardening phase may have been
test: make verification fail closed — the policy that rejects an empty,
incomplete, or missing test result rather than treating it as a pass.
As a project matures, LLMs become less trustworthy, not more. Greenfield work has no existing tests to break. Once the test suite is dense enough to serve as a real oracle, the agent’s incentive to achieve green output collides with the oracle’s purpose. Disabling a test is cheaper than understanding and fixing a bug in code the agent did not write.
The pattern is not model-specific. I observed it across DeepSeek, GLM, and frontier models. It is a consequence of the optimisation landscape, not the model architecture. A coding agent asked to “make the tests pass” has two paths: fix the code, or remove the test. The second path is often shorter.
Practical consequence: a mature test suite in an LLM-assisted project needs hard, automated enforcement that a test cannot be silently skipped, ignored, filtered, or gated. Prose rules are not sufficient. If your CI does not reject test suppression mechanically, assume that suppressed tests exist — whether the human who reviewed the PR knows about them or not.
Assuming CLAUDE.md would enforce everything
The local agent guide grew incrementally. Whenever a mistake seemed important
and repeatable, I asked the agent to add the rule to CLAUDE.md.
That helped, but it did not make the behaviour reliable. Two particularly irritating regressions kept returning:
- generated examples reverted from the intended method-chaining style; and
- fallible code used
unwrap()instead of propagatingResultwith?.
The instructions were explicit and repeated. The models still reintroduced the patterns. Eventually I accepted that feature work would create this debt and ran focused cleanup passes at intervals.
An agent guide is useful memory. It is not a compiler, a type system, or a lint. If a rule matters, an automated check is better than prose alone.
Human and agent editing at the same time
Manual edits made while the agent was working frequently damaged continuity. The agent had already formed a model from older file contents. I had more success telling it exactly what was wrong and letting it perform the edit than silently changing the same code underneath it.
Verification: why the tests matter so much
If somebody publishes an LLM-generated library with barely any test coverage, I assume the code is slop until shown otherwise.
LLMs respond extremely well to objective verification. A failing test gives the agent a bounded problem with an observable correct outcome. Without that oracle, it can produce a confident implementation of a misunderstanding.
Early in development I sometimes asked for a numerical code-coverage target. Later I cared less about the percentage than the breadth and independence of the evidence.
The checked-in suite includes:
- schemas and cases ported from the upstream SBE project;
- decoding Java-produced official fixtures;
- exact comparisons of headers, fixed blocks, composites, groups, and variable data;
- checked-in Rust codecs generated by official
sbe-tool; - live dual encoding where
ergo-sbeand official Rust codecs must produce byte-identical messages; - a multi-schema official parity matrix;
- property-based round trips over scalars, arrays, groups, nested structures, and variable data;
- compile-fail proofs for illegal stage ordering and use-after-consume;
- exact encoded-length matrices compared with actual completed messages;
- malformed and truncated buffer tests;
- schema-version and acting-version tests;
- deterministic generated-source golden tests;
- zero-allocation checks using a counting allocator;
- upstream issue-regression schemas; and
- sample applications that exercise more complicated API combinations.
See:
sbe_tool_wire_parity_test.rssbe_tool_multi_schema_wire_parity_test.rsbaseline_test.rsproptest_roundtrip.rsallocation_count_test.rsordered_decoder_stages_test.rsl3_consuming_stages_test.rsencoded_length_api_test.rssbe_tool_reference/README.md
The strongest compatibility test is differential, not self-referential. For
the same schema and logical values, the suite encodes with both ergo-sbe and
the official sbe-tool Rust generator and requires identical bytes. A library
that only decodes its own encoded output can be consistently wrong on both
sides; an independent reference makes that much harder.
One concrete AI failure involved offsets around variable data. A generator change broke an existing test. The agent observed the failure, diagnosed it, and fixed the implementation without me having to identify the exact line. That was impressive, but the important fact is that the test existed. Without it, the wrong offset could have compiled and looked plausible.
Tests are still not enough to remove the experimental warning. They cover the cases we and upstream authors thought to encode. Production traffic eventually finds assumptions that a controlled suite did not.
Performance was part of correctness
For this library, an ergonomic abstraction that makes a maintained hot path meaningfully slower than official generated code is a failed design.
I learned that painfully. The first compile-time ordering design used generic
type-state stages (Encoder<State>). I assumed it would be a zero-cost
abstraction. Benchmarks showed the generated generic chain was not being
optimised as effectively as plain monomorphic code. The model helped explain
why and proposed concrete named stage structs with a zero-sized H: HeaderState
marker. The switch retained compile-time ordering without the measured generic
tax. As of 0.1.8, all 15 maintained parity comparisons (10 SBE + 5 Cluster)
passed at or below the 1.00× sbe-tool ceiling under both LTO profiles. The
current gate is not that sentence: most pairs are still ceiling 1.00, listed
exceptions sit above it, and an over-ceiling pair inside its A/A noise band
passes as inconclusive (sbe/BENCHMARKS.md, “The gate”).
After that, benchmark regression became part of the definition of done:
- run benchmarks after every material generated hot-path change;
- compare against checked-in official
sbe-toolRust output; - ensure both arms do equal work;
- keep allocations and setup out of only one timed arm;
- rerun suspicious or borderline results;
- diagnose regressions before accepting a feature; and
- remove an abstraction if it cannot meet the gate.
The benchmark harness itself needed review. Earlier comparisons accidentally
included asymmetric allocation or different buffer traversal. One official
encode arm even risked overlapping header and body work. These were corrected
so the maintained comparison uses the same input or byte-identical output and
equivalent field work. The current methodology is documented in
BENCHMARKS.md.
Repeated benchmark execution likely explains part of the enormous token count. An agent would implement a change, benchmark it, discover a regression, revise the generator, and benchmark again.
Unsafe code and the trust boundary
The unsafe strategy came from me, not from an LLM spontaneously “optimising” the project.
Official-style codecs often separate checked setup from a trusted hot path. For 0.1.10 I wanted:
- unsuffixed
wrap/wrap_and_apply_header/decodeas the checked lane — they returnResult, validate extents once, then enter a private zero-check core (try_wrap*aliases are removed); - public constructor
*_uncheckedtwins only if measured keep rules pass (at the time keep=false — cores stay module-private); and - no repeated dynamic bounds check for every constant schema offset after the required block length has already been proved on the checked entry path.
What shipped after that is three tiers. try_* (try_wrap, try_decode)
return Result and validate extents once. The bare names (wrap, decode)
panic on a short buffer (decode still returns a Result for a bad header
field). unsafe *_unchecked twins skip the check and are for callers that
prove the requirement themselves. Use try_* on untrusted input. There is
still no repeated dynamic bounds check for every constant schema offset after
the required block length has already been proved.
I asked the agents to try several unsafe optimisations and measure them. Many did not materially help, so I removed them. Unsafe is retained only where it is required by the borrowing model or where a repeatable hot-path benchmark justifies the additional audit burden.
One retained example came from the Cluster codec benchmark. Generated setters
using checked slice ranges produced regressions around 1.19× and 1.28×
relative to the reference path. After the wrapping boundary had already proved
the fixed block was present, using get_unchecked_mut for compile-time offsets
restored parity.
The policy is not “unsafe is fast.” It is:
If the invariant can be stated and established, and the benchmark shows a meaningful need—or Rust borrowing requires the internal operation—unsafe may be justified. Otherwise use safe Rust.
The existence of a safe public API does not remove the need to audit internal unsafe invariants. Users evaluating the crate should include those trust boundaries in their review.
Tools, models, and what each contributed
Agent harness
The main harness was Claude Code CLI. This does not mean Claude wrote most of the code. Claude Code was the interface; custom endpoints supplied other models.
DeepSeek
DeepSeek V4 Flash and V4 Pro performed most of the implementation work. I had never planned to use DeepSeek for this project. My initial model choices were elsewhere, but during the intensive development period I repeatedly hit five-hour or weekly subscription and coding-plan limits. Waiting for a limit to reset would have stopped the development flow, so I connected Claude Code to DeepSeek’s pay-as-you-go API and carried on working.
I initially regarded DeepSeek as temporary overflow capacity: something to use until another plan reset. Only after using it for longer sessions did I realise how capable it was for this particular workflow and, especially, how cost-efficient its cache-hit pricing made sustained agentic development. What began as an unplanned way to avoid an interruption became the project’s main implementation workhorse.
The model split also reflects the change in development style. Early in the project I was using UltraMode through Claude Code CLI. UltraMode created subagents and selected the model it considered appropriate for each task. In my custom endpoint configuration, the model slot that UltraMode treated as Sonnet was mapped to DeepSeek V4 Flash. Consequently, much of the early parallel work ran on Flash without me manually choosing Flash for each subagent.
Once the codebase became too coupled for parallel development, I moved to a mostly sequential workflow and kept the main session on DeepSeek V4 Pro. This explains why the dashboard contains substantial use of both models and why the actual bill was lower than an all-Pro estimate. Flash handled a large amount of the early, highly parallel token volume; Pro carried the longer sequential implementation and refinement sessions.
There was no task where DeepSeek failed and I then had to escalate the same problem to Opus, ChatGPT, or Grok to rescue the implementation. That surprised me. It was possible because:
- I knew the domain;
- I normally knew what the correct result should look like;
- I could provide concrete API examples;
- the output was easy for me to inspect; and
- tests and benchmarks supplied tight feedback.
That is not evidence that DeepSeek is universally equivalent to a frontier model. It is evidence that, for a tightly specified mechanical implementation task under constant domain-expert supervision, a cheaper workhorse can be more useful than buying maximum intelligence for every token.
My practical description is:
DeepSeek V4 Pro delivered roughly Sonnet-class usefulness for this workflow. I would not treat that as proof that it replaces Opus for ambiguous, autonomous, long-horizon work where the developer does not know the answer.
DeepSeek’s own model information is available from its official V4 documentation and reports. Cross-vendor benchmark numbers use different harnesses and should not be read as controlled equivalence.
GLM
I used GLM-5.2 and GLM-4.7 substantially, particularly early in the project. The 30-day screenshot below records approximately 3.28 billion GLM tokens, including 2.65 billion on GLM-5.2.
I bought the highest coding-plan tier I was using—about $114—and still hit limits during the intensive development schedule.
Claude, OpenAI, and Grok
I also paid for Claude, OpenAI, and Grok access. I used them mainly for additional reviews and experiments, not as indispensable implementation engines. Their reviews were sometimes a little stronger, but generally raised the same categories of issue that DeepSeek reviews found. I cannot point to a frontier-model finding without which this project could not have been completed.
Cross-model review is useful, but agreement among models is not independent proof. They can share training patterns and repeat the same plausible misunderstanding. Official bytes and behavioural tests are stronger evidence.
Long context, the O(n²) mental model, and caching
Both DeepSeek V4 Flash and V4 Pro exposed a one-million-token context window. I favoured one long-lived conversation over many short ones, so the window could hold design decisions, failed benchmarks, and corrections across related work.
That is not the same as “never compact and keep one pure million-token session.” Whenever I started a genuinely new task—something sufficiently different from what the session had been doing—I compacted at that boundary if it was a natural handoff point. Continuity within a task was the goal; a single unbroken transcript across the whole project is not an accurate picture.
Subjectively, DeepSeek became much more useful when it retained the accumulated history that still mattered for the current line of work: design decisions, examples, mistakes, failed benchmarks, and my corrections. I did not notice a significant drop immediately after those task-boundary compactions. In separate Sonnet usage with a smaller context, I have sometimes noticed forgotten details after compaction.
Those are personal observations, not controlled experiments. They do, however, explain the usage shape.
What I mean by “O(n²)”
My informal developer’s mental model became “roughly O(n²) in requests.” Here,
n means the number of turns or requests in a growing conversation—not the
number of tokens in one request.
Suppose, only to make the intuition concrete, that each turn adds d tokens of
new conversation, source code, tool output, test logs, and model response. If
the complete conversation is sent again on every turn, the prompt at turn i
contains approximately i × d tokens. Across k turns the cumulative input
presented is approximately:
d + 2d + 3d + ... + kd
= d × (1 + 2 + 3 + ... + k)
= d × k × (k + 1) / 2
That sum grows quadratically with the number of turns. A simple worked example shows why the final context-window size is misleading:
- 100 turns each add an average of 10,000 tokens;
- the final prompt is approximately 1,000,000 tokens;
- but the cumulative prompt volume across the 100 turns is approximately 50,500,000 tokens before counting output separately.
So a one-million-token context window does not mean that a one-million-token conversation costs only one million input tokens over its lifetime. The route to that final context has repeatedly carried most of the earlier conversation. Real coding sessions are messier: turns add different amounts, tool results can be enormous, agents reread files, benchmarks print logs, and compaction changes the curve. The formula is a mental model, not an invoice.
It is also not a claim that every provider literally recomputes every transformer operation from scratch on every request. Model-side KV caching, provider prompt caching, routing, cache eviction, attention implementations, and other infrastructure all affect actual compute. I do not know whether a particular provider keeps a cache in RAM, on disk, or in some other tier, and the storage detail is not required for the economic point.
The economic point is simpler: the API token ledger still records a very large repeated prefix, and that repeated prefix normally has a price. This is how a project using a model with a one-million-token window can plausibly accumulate billions of billed tokens.
A cache hit is discounted, not free
In an agent conversation, a request can be thought of as three economically different categories:
- Input cache hits: the provider recognises a previously processed prefix and charges its cache-hit rate.
- Input cache misses or cache writes: new or changed input must be processed and, depending on the provider, written into the prompt cache.
- Output: new model tokens, including any billed reasoning tokens under the provider’s accounting.
The headline “input price” and “output price” therefore do not describe a long agent session. A useful comparison must ask:
- What does a cache hit cost?
- What does a miss or write cost?
- How long is the cache retained?
- Which prefix changes invalidate it?
- Does the provider apply a long-context multiplier?
- At what threshold does that multiplier begin?
- Is the advertised context size actually available at the standard rate?
- How much output or hidden reasoning does the model generate for equal work?
The last question is why the counterfactual table later in this document holds token usage constant. It isolates pricing, but it cannot prove what another model would actually consume.
Why the cache price changed the project
I originally compared models mainly through benchmark rank and ordinary input/output price. That missed the largest category in this workload: repeated context. The selected dashboard samples later in this page show that approximately 98.66% of token volume was served as input cache hits.
DeepSeek’s cache-hit rate was so low that keeping a large and useful context alive became affordable. This mattered more to my workflow than a modest benchmark advantage from a model that I could use only intermittently. I could keep giving feedback, retain design history, run another test, inspect another generated file, and try again.
That does not mean cheap cache hits make the entire bill negligible. One correction is essential: roughly 99% of token volume being cache hits does not mean 99% of dollar cost came from cache hits. Hits are heavily discounted. The much smaller quantities of misses and output can contribute a large portion of the final dollars. The correct calculation prices all three categories independently.
Context was part of the model’s effective intelligence
For this project, the long session held decisions that were not easily reduced to a short prompt: why a particular API had been rejected, how an offset bug had presented, which benchmark had regressed, which generated style I wanted, and how official output behaved. Retaining that history made the workhorse model feel more capable.
Caveat on “one long session”: I did not leave one transcript untouched for the entire project. I compacted whenever I started a task that was sufficiently different that the old tail was no longer a good default context—i.e. at genuine task boundaries, not once-or-twice total and not never. Within a task I kept context long; between dissimilar tasks I compacted and moved on. I did not notice a material loss immediately after those handoff-style compactions. In my separate experience with Sonnet and a smaller context window, I have sometimes noticed forgotten decisions after compaction. That is personal observation, not a controlled model comparison. It nevertheless changed how I think about model selection: effective intelligence is a combination of the base model, the context it can retain for the current task, the quality of the feedback, and whether I can afford enough turns to finish the loop.
Observed usage and actual spend
The most accurate data comes from ccusage (Claude Code + Codex agents only;
Hermes and OpenCode are automation agents and excluded here). Snapshot from
2026-08-07 (ccusage export since 2026-06-28). Note: this export was produced
by a newer ccusage version with a different rate table and session
attribution than the previous 2026-08-02 snapshot, so per-model figures differ
— the current snapshot is authoritative.
ccusage API-level spend
| Model | Input | Output | Cache Create | Cache Read | Reasoning | Total Tokens | API Cost |
|---|---|---|---|---|---|---|---|
| claude-fable-5 | 128,112 | 210,931 | 5,707,178 | 44,132,249 | — | 50,178,470 | $168.76 |
| claude-opus-4-8 | 153,359 | 505,224 | 3,840,858 | 93,893,329 | — | 98,392,770 | $86.22 |
| claude-opus-5 | 1,702 | 575,673 | 2,454,943 | 204,567,008 | — | 207,599,326 | $139.50 |
| claude-sonnet-4-6 | 3 | 654 | 22,730 | 14,116 | — | 37,503 | $0.15 |
| claude-sonnet-5 | 30,556 | 328,854 | 6,177,826 | 218,888,413 | — | 225,425,649 | $71.84 |
| deepseek-v4-flash | 52,791,367 | 15,125,986 | 0 | 2,734,161,536 | — | 2,802,078,889 | $19.28 |
| deepseek-v4-pro | 47,802,822 | 9,959,142 | 0 | 11,705,468,864 | — | 11,763,230,828 | $71.89 |
| glm-4.7 | 2,620,188 | 287,011 | 0 | 56,403,328 | — | 59,310,527 | $8.41 |
| glm-5.2 | 19,826,248 | 2,577,598 | 0 | 1,868,156,352 | — | 1,890,560,198 | $524.82 |
| gpt-5.5 | 1,781,759 | 117,119 | 0 | 18,613,504 | 31,270 | 20,543,652 | $21.73 |
| gpt-5.6-sol | 33,916,058 | 3,205,179 | 0 | 970,537,472 | 1,524,674 | 1,009,183,383 | $758.17 |
| Total | 159,052,174 | 32,893,371 | 18,203,535 | 17,914,836,171 | 1,555,944 | 18,126,541,195 | $1,870.77 |
That’s 18 billion tokens and $1,870.77 in API charges at enterprise/pay-as-you-go rates. The earlier 14-billion-token estimate was derived from provider dashboards and was an order-of-magnitude figure; the ccusage data is a precise ledger-level reconciliation across Claude Code and Codex sessions.
Subscription spend
Separate from the API charges above, these subscription fees were paid:
| Provider | Spend | Notes |
|---|---|---|
| GLM coding plan | $114 | Covers glm-4.7 and glm-5.2 API usage — the $533.23 in GLM API costs in the ccusage table is how the plan’s included quota would be priced at PAYG rates |
| OpenAI subscription | $20 | |
| Claude subscription | $20 | |
| Grok subscription | $30 | Grok API usage is not tracked by ccusage |
| Subscription total | $184 |
Total identified spend
| Category | Amount |
|---|---|
| API charges (enterprise/PAYG rates, via ccusage) | $1,870.77 |
| Subscriptions | $184.00 |
| Total identified | $2,054.77 |
The API charges are computed at published enterprise rates, not necessarily what was actually billed (DeepSeek’s actual bill was $77.39 for the project period; GLM usage was covered by the $114 plan). The gap between the $1,870.77 computed API cost and the ~$261 of actual out-of-pocket spend is the economic story of this project: DeepSeek’s cache-hit pricing made sustained agentic development affordable.
The DeepSeek dashboard for the displayed 30-day window shows:
- 10,522,859,893 tokens
- 47,668 API requests
- $77.39
The model split shown by the dashboard was:
- DeepSeek V4 Flash: 3,392,304,915 tokens across 29,015 requests
- DeepSeek V4 Pro: 7,130,554,978 tokens across 18,653 requests
The mixture matters: the early UltraMode/subagent phase used V4 Flash through the Sonnet-mapped model slot, while the later sequential phase stayed primarily on V4 Pro. Treating all 10.5 billion DeepSeek tokens as Pro would therefore overstate what I actually bought.
The cumulative token usage table at the top of this page is the authoritative per-model breakdown. The dashboard screenshots above are retained as the original historical evidence they were captured from during development.
The cache sample used for the cost comparison
The dashboard screenshots expose the input-cache-hit, input-cache-miss, and output split for three selected days:
Combined, those samples contain:
| Token category | Sample tokens | Sample share |
|---|---|---|
| Input cache hits | 2,225,207,680 | 98.6602% |
| Input cache misses | 23,305,848 | 1.0333% |
| Output | 6,911,504 | 0.3064% |
| Total | 2,255,425,032 | 100% |
Scaling that exact mix to 14 billion tokens gives:
| Token category | Normalised millions of tokens |
|---|---|
| Input cache hits | 13,812.433168 MTok |
| Input cache misses / cache writes | 144.665359 MTok |
| Output | 42.901473 MTok |
| Total | 14,000 MTok |
The cost formula is:
cost =
cache-hit MTok × cache-hit price
+ cache-miss MTok × miss/write price
+ output MTok × output price
This calculation includes all three categories. It does not price 14 billion tokens as though they were all cheap cache hits.
Normalised pay-as-you-go comparison
The following is a historical estimate using public prices checked on 26 July 2026:
- DeepSeek API pricing
- Z.AI / GLM pricing
- Claude API pricing
- OpenAI GPT-5.5 pricing
- OpenAI GPT-5.6 Sol pricing
- xAI / Grok pricing
Assumptions:
- exactly 14 billion billed tokens for every model;
- exactly the sampled cache-hit/miss/output mix above;
- zero model-specific increase or reduction in token usage;
- no 30% tokenizer adjustment for newer Claude models, even though Anthropic documents that their newer tokenizer may produce approximately 30% more tokens for the same text;
- standard synchronous API rates, not batch, fast mode, regional residency, or negotiated enterprise discounts;
- Claude cache misses treated as five-minute cache writes;
- GPT-5.6 Sol cache misses treated as cache writes at its documented 1.25× input rate;
- GPT-5.5 misses treated as normal uncached input;
- no separate tool-call, hosted-agent, storage, tax, or network charges; and
- “medium” reasoning effort does not alter the per-token rate; this table holds token counts constant rather than guessing different reasoning-token usage.
At standard or short-context rates
| Model | Cache-hit $/MTok | Miss/write $/MTok | Output $/MTok | Estimated total |
|---|---|---|---|---|
| DeepSeek V4 Flash | $0.0028 | $0.14 | $0.28 | $70.94 |
| DeepSeek V4 Pro | $0.003625 | $0.435 | $0.87 | $150.32 |
| Claude Sonnet 5 promotional price through 31 Aug 2026 | $0.20 | $2.50 | $10.00 | $3,553.16 |
| GLM-5.2 | $0.26 | $1.40 | $4.40 | $3,982.53 |
| Grok 4.5 below 200K context | $0.30 | $2.00 | $6.00 | $4,690.47 |
| Claude Sonnet 4.6 | $0.30 | $3.75 | $15.00 | $5,329.75 |
| Claude Sonnet 5 standard price from 1 Sep 2026 | $0.30 | $3.75 | $15.00 | $5,329.75 |
| Claude Opus 4.8 | $0.50 | $6.25 | $25.00 | $8,882.91 |
| Claude Opus 5 | $0.50 | $6.25 | $25.00 | $8,882.91 |
| OpenAI GPT-5.5 medium | $0.50 | $5.00 | $30.00 | $8,916.59 |
| OpenAI GPT-5.6 Sol medium | $0.50 | $6.25 | $30.00 | $9,097.42 |
Sonnet 4.6 and Sonnet 5’s post-promotion line are equal because that is what the official first-party Claude pricing page publishes. Opus 4.8 and Opus 5 are also equal on that page. The calculation does not invent a quality or token-volume premium where the requested assumption is a 0% usage increase.
If requests actually use approximately one million tokens of context
Long-context pricing changes the comparison:
| Model | Published long-context treatment | Estimated total |
|---|---|---|
| DeepSeek V4 Flash | 1M context; same published token rates | $70.94 |
| DeepSeek V4 Pro | 1M context; same published token rates | $150.32 |
| Claude Sonnet 5 promotional | Full 1M at standard rates | $3,553.16 |
| GLM-5.2 | 1M context; same published token rates | $3,982.53 |
| Claude Sonnet 4.6 | Full 1M at standard rates | $5,329.75 |
| Claude Sonnet 5 standard | Full 1M at standard rates | $5,329.75 |
| Claude Opus 4.8 | Full 1M at standard rates | $8,882.91 |
| Claude Opus 5 | Full 1M at standard rates | $8,882.91 |
| Grok 4.5 | Cannot accept 1M; maximum 500K. At ≥200K all rates double | $9,380.94 at 500K |
| OpenAI GPT-5.5 medium | Above 272K: 2× input and 1.5× output | $17,189.65 |
| OpenAI GPT-5.6 Sol medium | Above 272K: 2× input, 1.5× output, 1.25× cache writes | $17,551.32 |
Claude’s official page states that Claude 4.6 and later include the full one-million-token context window at standard rates. OpenAI documents the greater-than-272K multiplier for GPT-5.5 and GPT-5.6 Sol. Grok 4.5 has a 500K maximum and doubles all token rates from 200K.
These are counterfactual estimates, not invoices. Different models can tokenise the same conversation differently, emit different amounts of reasoning, call different tools, finish in different numbers of turns, and achieve different cache-hit ratios. Holding usage fixed is useful for isolating price; it does not predict the total cost of rerunning the project with another model.
As a sanity check, scaling the observed DeepSeek charge ($77.39 / 10.5229B tokens) to 14B gives $102.96, rounded to $103. This is the estimated 14-billion-token cost of the observed V4 Flash/V4 Pro blend. It sits between the all-Flash and all-Pro modelled figures because the real workload mixed both models. The selected cache screenshots are also samples of three days rather than a billing-complete token-category ledger for every day.
What I learned about model “intelligence”
Before this project I placed more weight on using the best-ranked model. My conclusion is now more conditional.
If I do not understand a problem, model intelligence matters greatly. If I am the domain expert, know the required output, can inspect it quickly, and have objective tests, I may get more value from an affordable model that I can use continuously.
For this project, intelligence was not the scarce resource. The scarce resources were:
- enough context to retain the evolving design;
- enough affordable requests to sustain constant feedback;
- tests that made correctness observable;
- benchmark loops that made performance regressions observable; and
- my attention for API and domain review.
That is why DeepSeek was so effective here. It does not imply that the same choice is correct for a developer who is asking the model to discover SBE semantics on their behalf.
For somebody learning agentic coding on personal projects, my practical advice would now be to start with a small amount of inexpensive pay-as-you-go credit. It is difficult to learn sustained feedback loops when a premium subscription repeatedly stops the session at its usage limit.
A practical playbook for other developers
This project does not provide a universal recipe for AI-generated software. The conditions were unusually favourable. It does, however, suggest a repeatable method for projects where the developer already understands the domain and correctness can be made observable.
1. Choose a problem with an oracle
The best agent task is not merely one that can be described. It is one whose result can be disproved.
For this project, official SBE bytes were the strongest oracle. Compilation, round trips, generated-source inspection, exact lengths, allocation counts, and benchmark results supplied additional independent signals. If the only acceptance criterion had been “the API looks plausible,” I would not trust the result.
Before delegating a large implementation, ask what will turn a model’s mistake into a concrete failure. If the answer is “a human will eventually notice,” the feedback loop is too weak.
2. Keep domain authority with a person who understands the problem
I did not ask the models to decide what SBE should mean. I knew the ordering rules, group structure, variable-data behaviour, schema-evolution concerns, and practical user mistakes before the project started.
An agent can implement a wrong specification extremely well. Tests derived only from the same wrong specification may all pass. The responsible person must be able to explain the invariant, recognise a plausible misinterpretation, and reject an API even when it is polished.
This is why I would not generalise my DeepSeek result to somebody learning SBE from the model while simultaneously asking it to build the generator. In that situation, there is no independent domain authority in the loop.
3. Define the observable contract before discussing implementation
I obtained better results when I said what generated calling code should look like, which illegal sequence should fail to compile, or which exact bytes should be produced. Vague goals such as “make it idiomatic” gave the agent too much freedom and usually created an ugly API.
A useful task statement includes:
- a representative input;
- the desired public call site;
- the expected output or failure;
- the invariant being protected;
- the tests that must pass; and
- the benchmark or allocation condition, if performance matters.
The model can then explore implementation details without owning the product decision.
4. Review the artefact closest to the real contract
Reviewing every generated line is ideal when it is practical, but enormous AI-authored diffs can exceed honest human review capacity. That does not justify pretending they were reviewed.
Instead, identify where your expertise has the highest leverage. I reviewed
emitted Rust, public API shape, stage transitions, bytes, exact lengths, and
performance more deeply than the syn/quote machinery. In another project
the right surface might be SQL plans, a protocol trace, a rendered page, or a
machine-generated configuration.
Be explicit about what this choice leaves unreviewed. Behavioural evidence narrows a trust boundary; it does not make the boundary disappear.
5. Prefer independent references over self-consistency
A codec that encodes and then decodes its own incorrect format can pass every round-trip test. A generator and a test written by the same model can share the same misunderstanding.
The most valuable tests compare against something independent:
- an official implementation;
- a protocol fixture produced by another language;
- a published conformance suite;
- a hand-calculated small example;
- an external parser or validator; or
- a previous production implementation.
For ergo-sbe, having both official sbe-tool generated Rust and the new
generator encode the same logical message to identical bytes is far stronger
than either codec decoding its own output.
6. Use short, mostly sequential loops after shared invariants emerge
Parallel agents are useful for truly independent research or isolated files. They were counterproductive when several branches changed the same generator conventions and API model.
The loop that worked was small: test, edit, regenerate, run, inspect, correct. When an agent moved in the wrong direction, stopping after minutes was better than reviewing an hour of coherent but unsuitable work.
The unit of delegation should shrink as the codebase becomes more coupled. Greenfield parallelism is not evidence that late-stage parallelism will work.
7. Give one editor ownership of an active change
I had less success when I manually edited files while an agent retained an older view of them. The agent’s context was part of its working state. Silent external edits made that state stale.
My practical solution was to explain the correction and let the active agent make it. This is not a rule that humans must never code. It is a coordination rule: avoid two editors changing the same conceptual unit without an explicit reload and reconciliation step.
8. Treat agent guides as memory, not enforcement
CLAUDE.md was valuable for accumulating project preferences and previous
mistakes. It did not reliably stop the agents from reintroducing unwrap() or
non-chained examples.
If a rule is important, promote it from prose to something executable:
- a compiler error;
- a lint;
- a compile-fail test;
- a source scan;
- a formatter;
- a golden file; or
- a CI check.
Instructions influence probability. Automation changes the acceptance boundary.
9. Put performance in the definition of done
Performance-sensitive abstractions should be benchmarked from the beginning, not after the API has hardened. The generic type-state design taught me this the expensive way.
For every relevant change, the agent had to rerun benchmarks and investigate a regression rather than merely report it. This increased token use, but it prevented performance debt from accumulating invisibly.
Benchmark equal work. A comparison is meaningless if one side encodes fewer fields, omits a group, performs less validation, or uses a different buffer lifecycle. Generated APIs make accidental unequal work especially easy to hide.
10. Experiment with unsafe code; do not assume it is faster
I asked the agents to try unsafe variants because a checked boundary can make some repeated bounds checks redundant. Some experiments improved performance; others made no meaningful difference and were removed.
Every retained unsafe block needs:
- a stated invariant;
- a place where that invariant is established;
- tests around the boundary;
- a measured reason to keep it, unless borrowing genuinely requires it; and
- a clear distinction between checked and trusted public entry points.
“The LLM wrote it” is neither a safety proof nor a reason to reject it. The proof must stand independently of its author.
11. Preserve context deliberately and compact with a handoff
Long context helped because it contained rejected designs, benchmark history, style corrections, and domain explanations. Throwing that away mid-task made the agent repeat old mistakes. Compacting at a real task boundary—when the next work was sufficiently different—was usually the right trade.
At the same time, unlimited history increases cost and can bury the current task. A good compaction or handoff should preserve:
- decisions and their reasons;
- invariants;
- known failure modes;
- commands that prove completion;
- current benchmark baselines; and
- unfinished work.
Do not retain a million tokens merely because the window exists. Retain them when the accumulated decisions improve the next turn enough to justify their cache cost.
12. Price the workload, not the marketing number
For agentic coding, compare at least cache hits, cache writes or misses, output, context thresholds, and rate limits. A model with cheaper ordinary input may still be expensive for a repeated-prefix workload. A subscription that looks cheap may stop an intensive day halfway through.
Use your own dashboard split when possible. Then calculate:
hit tokens × hit price
+ miss/write tokens × miss/write price
+ output tokens × output price
Apply any long-context multiplier separately. Keep clear whether you are comparing equal token volume, equal elapsed time, or equal completed work. Those answer different questions.
13. Separate the agent harness from the model
Most implementation happened through Claude Code, but most implementation was not performed by a Claude model. The harness provided file access, tool use, session management, and interaction conventions. DeepSeek or GLM supplied the model behind it.
This distinction matters when reporting results and when reproducing them. A good model in a poor harness may be frustrating; a good harness can make a less expensive model highly productive. “I used Claude Code” is not a complete model-provenance statement.
14. Use frontier-model reviews as another opinion, not proof
Claude, OpenAI, and Grok reviews were useful. They sometimes expressed a problem better or found a slightly different angle. They were not an independent conformance oracle, and none rescued a task that DeepSeek could not complete in this project.
Models can share the same training-derived assumptions. Several agreeing that code looks correct is weaker than one official byte comparison demonstrating that it is correct for a specific case.
15. Stop if you cannot explain the result
The human responsible for merging a change should be able to explain:
- what requirement changed;
- why the implementation satisfies it;
- what evidence would fail if it were wrong;
- which surfaces remain unreviewed; and
- what operational risk remains.
If the explanation is “the agent seemed confident and all of its own tests passed,” the work is not ready.
16. Publish the uncomfortable facts
AI-assisted projects need more provenance, not less. State how much code was generated, where human review concentrated, which models and harnesses were used, which evidence is independent, what the work cost, and why the maturity label remains.
This disclosure is long because “AI-assisted” covers everything from autocomplete to a project in which an agent wrote nearly every implementation line. Users deciding whether to put a codec near money deserve the meaningful version.
The personal experience: pride, enjoyment, and review fatigue
I have written another open-source project almost entirely by hand. I felt a different connection to that code. Writing software is enjoyable; learning the small implementation details and keyboard shortcuts is part of the craft.
I do not feel the same ownership of this generator’s individual lines because I did not write them. I am concerned that heavy LLM use can cause skill atrophy. It also changes the job from implementation to review.
That change was harder than I expected. I have historically been a very fast developer and often spent more time writing new code than reviewing other people’s work. Reading code quickly to understand an API is not the same skill as auditing a huge generated diff line by line. Careful review is slower, more tedious, and easier to overwhelm. LLMs can generate code far faster than a person can honestly review it.
This project remained enjoyable because I was still solving a problem I care about. I spent a great deal of time thinking about SBE, Rust API design, verification, and performance. The LLM did much of the repetitive generator work that I already knew how to do and did not particularly want to spend six months repeating.
So the pride is attached differently:
- less attachment to the implementation;
- more satisfaction in the API and product;
- genuine pleasure that something I wanted throughout my working life now exists; and
- less reluctance to throw away experiments that do not work.
That last point helped with the Aeron Cluster code. The Cluster crate was
initially a realistic consumer and test bed for ergo-sbe. Samples later
became a better way to exercise complicated API shapes. The Cluster work
remains less mature and more disposable. My priority is to make ergo-sbe
right before treating the Cluster client as a finished product.
Why this crate is still experimental
The experimental label is deliberate.
I would be angry if a developer introduced an unproven codec into a financial system merely because it had many unit tests. If I apply that standard to somebody else’s library, I must apply it to mine.
One thing I valued in the Java ecosystem was that mature projects often had visible institutional signals—long histories, broad production use, or governance under organisations such as Apache. The quality of an arbitrary Rust crate can be much harder to infer from its presentation. That makes explicit evidence and an honest maturity label more important, not less.
The suite is extensive. The official byte parity is meaningful. The benchmark gate is meaningful. None of that is the same as sustained production use across independent firms, schemas, traffic patterns, deployment environments, and upgrade cycles.
I will become comfortable removing the warning when there is a sufficient base of real users who tell me:
- they are using it in production;
- which features and schema shapes they use;
- how they compared it with their existing codecs;
- what volumes and environments it has survived;
- how schema evolution behaved; and
- what defects or operational surprises they found.
The reports I most want are already listed in the
ergo-sbe README: multi-schema streams, DTO use, exact sizing with
Aeron/IPC claims, nested or ragged books, and mixed acting versions.
Until that evidence exists, treat the crate as 0.x experimental software. Pin versions and perform your own migration testing.
What a prospective production user should verify
Do not rely on this narrative alone.
At minimum:
- Generate codecs for your actual schemas.
- Encode the same logical messages with your existing official SBE tooling and
ergo-sbe; compare exact bytes. - Cross-decode in both directions.
- Include empty, maximum-sized, nested, ragged, and variable-data cases.
- Exercise every acting version and schema-evolution path you expect to see.
- Test malformed and truncated frames at the trust boundary.
- Verify exact buffer lengths before integrating with
try_claimor another zero-copy publication API. - Benchmark your real hot fields and message shapes, not only the Car example.
- Audit the internal unsafe assumptions relevant to your use of
try_*, the panicking bare constructors, and anyunsafe *_uncheckedtwin. - Run soak tests under real traffic and deployment conditions.
If you are not already comfortable explaining SBE block lengths, group dimensions, variable-data prefixes, acting versions, and positional ordering, do not use an LLM-generated explanation as your only review.
AI-assisted contributions
I am not applying a simplistic ban on AI-assisted pull requests, but I will not blindly accept them.
Large AI-generated PRs are difficult to review. More importantly, a contributor can ask a model to “fix” a protocol bug without understanding why the result is correct. Passing compilation is not enough.
For a generator or wire-format change, the contributor must understand the relevant SBE behaviour and be able to explain it. A focused issue containing:
- a real schema;
- a minimal reproduction;
- expected official bytes;
- a failing behavioural or parity test; and
- an explanation of the protocol invariant
may be more valuable than a large implementation patch. In many cases I would rather take that evidence and implement the change through the controlled workflow used for this repository.
AI assistance is not disqualifying. Lack of domain understanding is.
Final assessment
This project was a particularly favourable case for AI-assisted development:
- the maintainer was a domain expert;
- the desired behaviour was unusually concrete;
- the tedious part was mechanical code generation;
- the generated product was directly inspectable;
- an independent official implementation existed;
- wire bytes supplied a hard oracle;
- benchmarks constrained abstraction cost; and
- constant feedback was possible.
Even in that favourable case, it consumed about a month of intense work, roughly 18 billion tokens (measured by ccusage), repeated cleanup, extensive tests, and continuous human judgment. The fashionable version—write a specification, dispatch many agents, and return to a finished library—did not survive beyond the early greenfield stage.
I would use this development method again for a personal project with similar properties, and I would likely use DeepSeek again as the workhorse. I would not generalise the result into “LLMs can safely build any library” or “the cheapest model is always enough.”
The result is a crate whose API I genuinely wish I had during the last decade of SBE work. I am proud of that result. I am also being explicit that most of its implementation was written by models, that the generator internals did not receive exhaustive human line review, and that production maturity still has to be earned.
If you use ergo-sbe seriously, please report the schema shapes and features
you exercise, including failures. Real-world evidence is what will make this
project trustworthy—not another paragraph claiming that AI-generated code is
either magically perfect or automatically worthless.
Verification & Release
cargo test -p ergo-sbe --all-features -- --test-threads=1
cargo test -p ergo-sbe --doc --all-features
RUSTDOCFLAGS="-D warnings" cargo doc -p ergo-sbe --all-features --no-deps
cargo clippy -p ergo-sbe --all-targets --all-features -- -D warnings
just test in the monorepo also runs doctests, docs_validation_test (README
fences + generated-API smoke), and rustdoc with -D warnings.
Performance method: Benchmarks (not in the crates.io package).
SBE parity gate artifacts (1.0 streak)
release.yml runs just bench and just bench-cluster, then attaches the
Criterion archives (*.tar.gz) and sbe-bench-gate-no-lto.txt to the GitHub
release. That output is the record for
Road to 1.0 criterion 2. Run the same locally after a
material codegen change:
just bench # stamps target/bench-runs/<run-id>/ and runs the gate
Do not raise ceilings to pass.
Road to 1.0
Every published crate is 0.x and not production-ready. This page gives
the exit criteria for ergo-sbe and ergo-aeron-cluster: criteria, not a
date.
ergo-sbe (reaches 1.0 first)
Ship a 1.0.0 of ergo-sbe only when all of the following hold:
- API freeze audit complete — decisions in API freeze are stable; no pending renames of generated stage / wrap / FixedFields surface without a major.
- Parity gate — every maintained ergon vs sbe-tool comparison passes
the benchmark gate in both LTO profiles for three consecutive
releases, with the gate output attached to each release. Ceilings and
the inconclusive rule:
sbe/BENCHMARKS.md. - Wire compatibility — dual-encode parity tests and golden API shape remain green; no deliberate wire break without a major.
- Trust boundary — fuzz corpus on decode entry stays green in CI; Miri fixtures for unaligned paths stay green; no known P0 safety issues open.
- Docs — book chapters for migration (sbe-tool), trust boundaries, buffer sizing, and type-state design notes are published and linked from the crate README.
- External signal — at least one external user (or production pilot) has reported wire + latency results against their own schema, or an equivalent published case study in the repo.
Until then the disclaimer stays, but it points here instead of reading as a permanent warning.
ergo-aeron-cluster (separate clock)
Cluster 1.0 is not tied to sbe 1.0. Additional criteria (illustrative):
- Stable session lifecycle and error types under multi-node test harness
- Documented Aeron version matrix and rusteron compatibility
- Codec generation locked to a released
ergo-sbemajor - Separate performance gate (
just bench-cluster) with recorded baselines
Cluster may remain 0.x after sbe 1.0.
What 1.0 is not
- Not “feature complete for every SBE edge case in every venue schema”
- Not a promise that your schema’s latency matches the car/L3 benches
- Not a freeze of optional config knobs’ defaults without changelog
Tracking
- Release process: Verification & Release.
- Changelog: repository root
CHANGELOG.md. - External pilot: External Schema Pilot.
- Cluster compatibility: Cluster Compatibility.
- API baseline manifest:
api/public-api-baseline.toml.scripts/check-public-api.shruns cargo-semver-checks on each published crate it lists (ergo-aeron-clusteronly withCLUSTER_SEMVER_CHECKS=1; a crate first published after the baseline once the baseline reaches that release).scripts/check-generated-public-api.shdiffs generated codec surfaces againstapi/generated/*.txt. - Benchmark evidence:
just bench+just bench-clusterwrite provenance-stamped Criterion trees;scripts/package-bench-artifacts.shattaches them to a GitHub release. A number without a matching run-id / HEAD commit is not evidence.
Status
A claimed 1.0 streak tag must be an ancestor of main
(git merge-base --is-ancestor <tag> main), not merely a GitHub Release.
git describe --tags is not the check.
| Criterion | Status |
|---|---|
| 1. API-freeze audit | Manifest exists; cargo-semver-checks and generated-API fixture diffs (api/generated/) are enforced. |
| 2. Parity gate | Open. Needs three consecutive releases on main with gate output attached. |
| 3. Wire compatibility | Dual-encode parity tests run in CI. FIX SBE conformance: suite tests 1–3 run in CI; the RLValidator leg does not (External pilot). |
| 4. Trust boundary | Fuzz + Miri fixtures exist; any open P0 is blocking. |
| 5. Docs | Book + migration pages published. |
| 6. External signal | Open. In-repo FIX SBE is internal evidence, not an external user. See external-pilot.md. |
| Cluster 1.0 | Separate clock; compatibility + just bench-cluster exist. |
1.0 API migrations (still 1.0-only)
These remain scheduled for 1.0 and are not shipped on 0.x. cargo semver-checks against the v0.1.26 baseline (the baseline_tag in
api/public-api-baseline.toml) must stay green.
Schemaidentity as one privateIr(no duplicate publicpackage/id/versionbesideir). 0.x keeps the public fields.- XML
deprecatedasOption<u16>throughEncoding,FieldInfo, andItemContext. 0.x keepsdeprecated: boolon those public types. - Removal of
GenerationConfig::with_error_from_impls. 0.x keeps the deprecated helper; prefer a typedFrom<generated::sbe_rt::{EncodeError, DecodeError}>soneeded/availablesurvive?.
Shipped on this 0.x line:
ergo-aeron-clusterhas notest-harnessfeature. Repository Java launch support isergo-aeron-cluster-test-harness.
External Schema Pilot
The 1.0 exit criterion requires an external user or production pilot with wire and latency results against their own schema — not only in-repo tests.
Status: Open
The FIX SBE Conformance Suite (sbe/tests/fix_sbe_conformance_test.rs,
fixtures, and scripts/run-fix-sbe-conformance.sh) is necessary internal
wire evidence. It is not the 1.0 external-signal criterion: there is
no external user, no independent schema family, and no latency measurement
from outside this repository.
Treat this page as the checklist for that missing signal, not a completed exit item.
What the conformance suite covers here
| Part | Runs in CI | Source |
|---|---|---|
| Byte-identity of ergon responses against Real Logic’s goldens, suite tests 1–3 | yes (cargo test -p ergo-sbe) | sbe/tests/fix_sbe_conformance_test.rs |
| Official RLValidator over ergon’s responses | no: compiled only with --cfg ergon_rlvalidator, which CI does not set. With the cfg, a missing suite fails | optional_java_rlvalidator_accepts_ergon_equivalent_responses |
scripts/run-fix-sbe-conformance.sh builds the suite, sets
FIX_SBE_CONFORMANCE_HOME, compiles that test with --cfg ergon_rlvalidator
and runs it by name. A missing suite directory, an unbuilt target/classes
or a classpath failure stops the script.
Commands
# Byte-identity only. The RLValidator test is not compiled.
cargo test -p ergo-sbe --test fix_sbe_conformance_test --all-features
# RLValidator leg: built Java suite, and the cfg that compiles the test.
RUSTFLAGS="--cfg ergon_rlvalidator" \
FIX_SBE_CONFORMANCE_HOME=/path/to/suite \
cargo test -p ergo-sbe --all-features \
--test fix_sbe_conformance_test optional_java_rlvalidator_accepts_ergon_equivalent_responses
Package Scope
crates.io ships each crate’s source, its README, and the manifest.
Not published: tests, fixtures, samples, benches, the book, Java harnesses, and upstream reference trees. Those live on GitHub.
| Crate | crates.io |
|---|---|
ergo-sbe | generator |
ergo-aeron-cluster | Cluster client |
ergon-tune | host and NIC checks |
ergon-io | busy-spin network I/O (WebSocket, HTTP/1.1, TLS 1.3) |
ergon-md-dpdk | DPDK transport for the media driver, with its native C and the test fakes its build compiles |
Samples and ergo-sbe-benchmarks are publish = false. Cluster codecs are
#[doc(hidden)] pub in cluster_codec_types, re-exported for integration
tests and benches only — not a stable consumer API. Use AeronCluster for
normal usage.
Apache-2.0.