Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

What to use

One line per module: what it is for, and the call you make.

modulewhat it doeshow you use it
ergo-sbeschema XML → Rust SBE codecsergo_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-tunereads the host and names what will wreck your tail latency, and the fixcall at start-up; it changes nothing (chapter)
ergon-ioWebSocket and HTTP/TLS on one busy-spinning threadpoll() from your loop; epoll or io_uring is configuration (chapter)
ergo-aeron-clusterAeron Cluster clientonly if you need cluster; experimental, not a production substitute (chapter)
ergon-md-dpdkDPDK transport for the Aeron media driverLinux 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.

modulewhat it doeshow you use it
k8s/nri-shared-cpusgives 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 corehelm 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-podsnode 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 unitsapply 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 orderNamed stages — asks before bids does not compile
Exact sizingcompute_length_with_header / *EncodedLength before you write; works with Aeron try_claim
GroupsClosures nest like the schema; no .parent()
Trust boundarytry_* → 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 typesMap wire Decimal to rust_decimal::Decimal with one config line
Bulk groupsbulk_add(&[Entry]) / bulk_decode() for fixed-stride leaf groups
Runtime depsGenerated 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

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 entriesinto_<group> gives youWhy
Carry their own groups or var-dataa visit closure, returning the entry’s completionno stride — the completion is where the next entry starts
Fixed-stride (no tails of their own)an iterator, for e in &mut iterthe 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:

ApproachWhenMethod
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 dependencyparse_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 Rustergo-sbe
wrap argumentBody offset (often 8 for a frame at 0)Message start (often 0)
Where fields livebody_offset + field_offsetmessage_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:

Modeergo-sbesbe-tool
Body onlywrap(buf, 0) + setters — no apply-headerwrap(buf, 8) + setters — no .header(0)
Header + bodywrap_and_apply_header(buf, 0) + setterswrap(buf, 8) then header(0).parent() then setters
Header onlywrap_and_apply_header alonewrap(buf, 8).header(0) alone
  • ergon wrap = message start; sbe-tool wrap = body offset.
  • sbe-tool encoded_length() is body only. ergon encoded_length_with_header() includes the header. Never invent 8 + 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-toolergo-sbe
Open group flyweight, fill entries, .parent() backenc.bids(n, |bids| { bids.add(|e| { … })?; Ok(()) })?
Nested groups fight the borrow checkerNested 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

Conceptsbe-toolergo-sbe
Body length after encodeencoded_length()body region only via stage encoded_length where exposed
Header + bodycompute yourself (8 + …)encoded_length_with_header() / as_bytes_with_header()
Pre-size bufferoften oversize scratchExact: 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-toolergo-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 encodernothing to call — every add() nulls the entry’s optional scalars
Forgetting the call leaks stale bytesfixed() 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:

Needergo-sbe
Untrusted / networktry_decode / try_wrap / try_from → Result (all failures)
Known-good bufferbare wrap → panic if short; bare decode → hybrid (panic if short, Err on wrong template/schema)
Proven-tight hot pathunsafe 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_* (or decoder.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 codePort to
Straight-line walk: group, group, var-data, donestaged into_* / skip_* — one traversal, compile-time tail order
Re-wrapping the message a second time to read a field you passedthe base decoder, or decoder.memoized() if you re-read deep tails
Reading two fixed fields and dropping the restthe 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 habitergo-sbe
.parent() ownership hopEncode: nested closures. Decode: consuming stages — a visit closure for dynamic groups, an iterator for fixed-stride ones
One &mut decoder with a limit cursorLanes: random-access &Decoder, staged into_*, optional ordered() / .memoized() (decoder lanes)
_decoder() returning (offset, len) for a second _slice() callVar-data accessors return &'a [u8] / &'a str directly
Generic Encoder<State> spellingNamed stage structs + H: HeaderState only for header mode (type-state note)
encoded_length() as full-frame sizeUse *_with_header when you need the frame
nullify_optional_fields() on group and composite encodersGroup entries null their optional scalars on add(); only the message encoder gets apply_nulls() (above)
Always-on meta / Display noiseOpt-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:

VariantError codeWhen
MalformedXmlergo_sbe::schema_parse::malformed_xmlXML is not well-formed
Missingergo_sbe::schema_parse::missingRequired attribute or element absent
Invalidergo_sbe::schema_parse::invalidValue is syntactically or semantically wrong
Resolveergo_sbe::schema_parse::resolveCross-reference or schema-level validation failure
Ioergo_sbe::schema_parse::ioRoot schema file could not be read (Error::source is the std::io::Error)
Includeergo_sbe::schema_parse::includeInclude 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

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.

LaneEntryOrderDynamic-tail costSync
Random accesstry_decode / wrap gettersAnyRecalculates preceding offsetsyes
Stagedinto_* / skip_*Compile timeOne forward traversalyes
Ordereddecoder.ordered()Compile timeDelegates to stagedyes
Memoizeddecoder.memoized()AnyCache of discovered boundariesno (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 &DecoderRandom access (default)
Whole message in wire orderStaged into_* / skip_*
Whole message, one callback per tailOrdered (decoder.ordered())
Same decoder, many helpers, mixed tail orderMemoized — 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 entriesinto_<group>Why
Own groups or var-datavisit closure returning the entry’s completionNo stride — the completion is the next offset
Fixed-stride (no tails)iteratorNext 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

AccessorAnswersWhere
<group>_count()Result<usize, DecodeError>base, .memoized(), stage before the group, nested entries
<field>_len()var-data byte length, same Resultsame 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(())
}
}
TailCallback
Fixed blockfixed(|&{Name}DecoderFixedView|)
Group, entries with tailsgroup(|entry, EntryInfo|) → entry completion, or Ordered<completion> from nested ordered()
Group, fixed-stridegroup(|entry, EntryInfo|) → ()
Var-datafield(|&[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

NeedAPI
Full frame after the walkcomplete stage as_bytes_with_header()
Full frame without consuming stagesinherent dec.as_bytes_with_header()? (rescans tails)
Fixed block onlydec.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?

Receiverremaining() 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 remainingOrdinary 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)

APISpanFull Car frame?
meta.limit()body start + acting block lengthNo — stops at fixed block end
meta.as_fixed_region_with_header()?header + acting fixed blockNo
meta.remaining()bytes after that fixed endMay still include unread tails of this message
complete stage as_bytes_with_header()header through last var-dataYes (after walking tails)
dec.as_bytes_with_header()? (inherent rescan)same full frame without consuming stagesYes (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

TierEntryBad buffer
Checkedtry_wrap / try_wrap_and_apply_header / try_decodeResult::Err (all failures)
Trustedbare wrap / wrap_and_apply_headerpanic after the same extent proof
Trusted hybridbare decodepanic if short; Err on wrong template/schema only
Uncheckedunsafe wrap_unchecked / wrap_and_apply_header_uncheckedUB — prove extent first
Unchecked hybridunsafe decode_uncheckedUB 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(&note[..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

Every SBE buffer crossing a process boundary must be validated. ergo-sbe provides a three-tier constructor API with different failure contracts:

TierPrefixBehaviour on bad bufferUse case
Checkedtry_{wrap,decode,…}Returns Result::ErrUntrusted input, process boundaries
Trustedbare name (wrap, decode, …)Panics after the same extent proofKnown-good buffers
Uncheckedunsafe fn *_uncheckedUB (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

EntryReturnBehaviour
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

EntryReturnBehaviour
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)DecoderPanics 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)DecoderUB 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 shapeGenerated sizingPrefer
Fixed only{Msg}Encoder::compute_length_with_header() (const)stack / claim of that length
Groups / nested / ragged{Msg}EncodedLength staged builderlen 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.

StyleBest whenCostSchema evolution
Flyweight (per-field)You only read one or a few fields; hot pathZero-copy; no heapNew fields are optional at call sites (you simply don’t read them)
*FixedFields + .fixed(...)You always write the entire fixed blockOne struct write, still flyweight bufferAdding 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-dataAllocates — never use on the hot path. Easier app code for tooling, logging, offline processingSame 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 DTONo allocation: groups are iterators, var-data is borrowedA 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/String and 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:

ApproachWhat ergo-sbe doesWhy 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
Accessorsfrom_le_bytes / to_le_bytes at schema offsetsNative loads break big-endian schemas and unaligned safety
FlyweightEngineDecoder { buf, pos } reads in placeDefault decode path
Eager valueengine_value() copies the N-byte image onceStill not field-by-field re-pack; .0 is the wire block
EncodeWriter copies engine.0 into the frameSame 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 pathFlyweight
Whole composite as an owned wire blobValue Engine([u8; N]) / *_value() — pay N once
Hand-rolled repr(C, packed) for speedSkip 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

Wire type vs app type

NameRole
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.

FeatureBest for
compact_strTickers / symbols / venue codes in DTOs (≤24 B inline)
smol_strLong-lived DTOs that clone often
bytesRelay / forwarding, shared buffers
chronoTyped 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_conversionB with_domain_type
IdeaGeneric convert API; you plug any app typeAlways use this Rust path
build.rs.with_conversion(named_type("Decimal")).with_domain_type(…, "rust_decimal::Decimal")
You writeTryFromSbe<Decimal> / TryToSbe<Decimal> for your typeUsually nothing for bool / rust_decimal / chrono
Decodelet p: Cents = dec.price_as()?let p: rust_decimal::Decimal = dec.price()?
Encodeenc.price_from(&cents)?enc.price(rust_decimal::Decimal::new(12345, 2))?
Raw wireprice_value() / price_wire(...)same when conversion is active
Sampleexchange-example · demo_conversion_onlyl3-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:

Fieldwith_conversionwith_domain_type
Required, sinceVersion=0price_as::<T>() -> Result<T, _>price() -> Result<T, _>
sinceVersion > 0Result<Option<T>, _>Result<Option<T>, _>
presence="optional" scalarResult<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.

OptionDefaultPurpose
new("module") / lean("module")Full / Leanlean() is new().profile(Lean) — no Display/Debug, meta attrs, or dispatch
with_module_name(name)from newOverride 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)offOwned *Domain + encode; Strings → String (bad UTF-8 → InvalidUtf8), Bytes → Vec<u8>
with_display_debug(enable: bool)trueEmit Debug/Display impls on generated types
with_meta_attributes(enable: bool)trueEmit *_ENCODING_OFFSET, *_ID, *_META_ATTRIBUTE etc.
with_dispatch(enable: bool)trueEmit AnyMessage/FrameCursor/MessageVisitor dispatch
with_bool_domain_type(enable: bool)falseAuto-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()falseAll enums → Option<Enum>; blanket form of with_null_as_option
profile(GenerationProfile)FullPreset: 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 impl blocks 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 the struct/enum). Emit the trait impl directly 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.

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:

CallField typeInvalid UTF-8When to use
.with_domain_objects(DomainVarData::Strings)StringInvalidUtf8 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.

ConcernChoiceWhy
Linear tail (groups / var-data)Named structs per stageCompile errors name the group you skipped (expected CarAfterFuelFigures); rustdoc stays scannable
Header vs body-onlyOne H: HeaderState on every stageAvoids doubling the graph. Default H = HeaderPresent
Completeness of the fixed blockF: FieldsState on the root encoderas_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-sbesbe-tool Rust
wrap offsetMessage start (first byte of header)Body offset (usually message_start + 8)
Field bytesat message_offset + HEADER_LENGTH + field_offsetat 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), bare wrap (panic after extent proof), bare decode (panic if short, Err on wrong template/schema), and unsafe fn *_unchecked.
  • Safety contract: validate with try_decode / try_from / try_wrap / verify at trust edges; only *_unchecked may 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}FixedFieldsNo (exhaustive)Schema field additions must surface as compile errors (§2 above)
{Msg}EncoderNo (all fields pub(crate))Constructed by the generated wrap / wrap_and_apply_header
{Msg}DecoderNo (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}CompleteNo (all fields pub(crate))Reachable after writing all tails
{Group}EncoderNo (all fields pub(crate))Constructed by the generated group closure
{Group}DecoderNo (fields are pub(crate))Constructed by generated iterator / wrap
{Group}EntryCompleteNo (fields pub(crate))Only produced by add_checked / complete()
{Msg}EncodedLengthNo (fields pub(crate))Constructed by compute_length()
{Msg}EncodedLengthAfter* / CompleteNo (fields pub(crate))Consuming stages, same as encoder
{Msg}SchemaNo (unit struct)Carries only consts
ConnectStepYesNew async-connect steps must not break exhaustiveness downstream
GenerateErrorYesFuture validation variants are additive
ParseErrorYes (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:

  1. 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. The NullVal approach gives you a plain EventCode type — if you care about null, check code == EventCode::NullVal; if you don’t, just use it. (The wire encoding itself would be compatible either way: None maps to the null sentinel, Some(v) maps to v. The issue is ergonomics, not wire format.)

  2. 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.

  3. Schema intent. The schema declares a null sentinel as part of the enum’s own value domain, not as a separate presence flag. A NullVal variant 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.

FeatureWhat it doesHow to use / more
build.rs codegenCompile-time schema → Rust module in OUT_DIRgenerate_to_out_dir("schemas/….xml", config)? · plain include! or sbe_mod!(name) · Quick start · codegen examples
Wire compatibilitySame on-wire layout as official SBEDual encode ergo vs sbe-tool · sbe_tool_wire_parity_test · golden fixtures · Benchmarks · baseline_test
Flyweight decodeZero-copy over &[u8]CarDecoder::try_from(buf)?; car.serial_number() · feature-tour
Composite wire image#[repr(transparent)] Engine([u8; N]) + LE accessors; flyweight defaultNot a repr(C) overlay · Core ideas · composite_layout_test
Per-field vs whole structFlyweight or *FixedFields / *DomainSingle field: flyweight · always fill fixed block: .fixed(&CarFixedFields { … }) · whole message owned: CarDomain · Core ideas · feature-tour
Stage-struct encode + closuresWire order as named monomorphic stages; groups via nested closuresbids(n, |g| g.add(|e| …))? · wrong order = missing method · Core ideas · Recipes · Benchmarks
Consuming decode stagesDistinct after-stage decoder typesinto_bids(|lvl| …)? → into_asks(…)? → var-data · l3_consuming_stages_test
Decoder lanesRandom-access getters, staged into_*/skip_*, ordered callbacks, .memoized()Any-order &Decoder, wire-order walk, or one callback per tail · decode stages
Staged group shapeEntries with their own tails take a visit closure returning the entry’s completion; fixed-stride entries yield an iteratorOne 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 entryFaç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 stageBase 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 constructorstry_* → Result; bare wrap panics if short; bare decode panics if short and Err on wrong template/schema; unsafe *_uncheckedCarDecoder::try_decode(buf, 0)? · CarDecoder::verify(buf)? · demo_try_vs_trusted · Trust boundary
Placement metadataBuffer 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 sizingSchema-aware length for nested/ragged msgs — no hand-calculated sizescompute_length_with_header() (fixed) · compute_length_with_header(…) (flat) · *EncodedLength (nested) · Core ideas · l3-book · encoded_length_api_test
Schema docs → rustdocXML descriptions become item docsdescription="…" / <description> / <comment> / <!-- --> · schema_docs_provenance_test
Display / DebugDiagnostic print (not wire format)println!("{car}"); · Display / Debug · demo_display_debug
NULL / MIN / MAXSchema sentinels as constsMODEL_YEAR_NULL · baseline_test
Version-aware fieldssinceVersion / acting versionOption or skip on older wire · baseline_test · multi_schema_versioning_test
Groups / nested groupsRepeating dimensionsbids(n, |g| g.add(…))? · l3-book · l3_orderbook_test
Bulk group encode / decodebulk_add(&[Entry]) / bulk_add_domain(&[EntryDomain]) / bulk_decode() -> Vec<Entry> for eligible flat groupsDTO 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 / textLength-prefix; optional UTF-8/ASCIImanufacturer_as_str("Honda")? when a characterEncoding is declared, else manufacturer(b"Honda")? · feature-tour
Fixed arrays + bulk helpersArrays, put, pad string, copy-output_some_numbers(…) · vehicle_code_str · copy_vehicle_code · java_parity_features_test
Enums / sets / boolWire enums, bitsets, _boolavailable() / available_bool(true) · comprehensive_test
with_conversionWire type → any app type you implprice_from(&Cents)? / price_as::<Cents>()? · Configuration · exchange-example
with_domain_typeWire type → one fixed Rust pathenc.price(d)?; let d = dec.price()? · l3-book · Configuration
Generated enum / composite mappingsSBE 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 traitsEncode 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 helpersClaim → encode → check length and header → commit, aborting on any failure; try-claim or offer transportsOrderEncoder::send(claim, |enc| …)? · send_fixed · sbe_rt::OfferClaim · Aeron try_claim · claim_send_test
Domain DTOsOwned 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 + framesMulti-template + framed streamsAnyMessage::try_decode (bare decode is the same path today) · FrameCursor · demo_any_message
verifyFull tail bounds check (associated)CarDecoder::verify(buf)? · demo_try_vs_trusted
Schema identityId / version / hashesSCHEMA_ID, SCHEMA_HASH, SCHEMA_SHA256_HEX · generated module header
Multi-schema shared typesDedup across packages.with_shared_module + generate_multi · exchange-example · multi_schema_versioning_test
Keyword-safe namestype → type_.with_keyword_append_token("_") · java_parity_features_test
XSD-shaped validationOpt-in stricter check for schema authorsvalidate_against_sbe_xsd / parse_with_xsd_validation · xsd.rs
Zero-alloc hot pathFlyweights + caller buffers; bulk_decode() -> Vec and DomainVarData::Strings do allocateallocation_count_test
Property round-tripRandom messages encode→decodecargo 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 typesCompactString (≤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 accessorsinto_<field>_as_compact_str() / _as_smol_str() / _as_bytes() on consuming stagesFeature-gated behind compact_str, smol_str, bytes · Feature integrations
Chrono timestampsDateTime<Utc> / NaiveDateTime from uint64 + semanticType.with_domain_type(ConversionSelector::semantic_type("UTCTimestamp"), "chrono::DateTime<chrono::Utc>") — feature-gated: chrono · Timestamps
Lean constructorGenerationConfig::lean("minimal") — no Display/Debug/dispatch, explicit settings preservedlean() shorthand for new().profile(Lean) · GenerationConfig
Must-use lifecycle#[must_use] on SessionBuilder, AsyncClusterConnect, ClusterClaimCompile-time guard against discarded builder/connect/claim · SessionBuilder
PayloadTooLargeTyped error when header+payload exceeds Aeron maxClusterError::PayloadTooLarge { operation, requested, maximum } · Cluster client
Zero-alloc offer_partsFragmented path uses offer_parts gather — no heap, no payload copyStack header + borrowed payload · Cluster client
checked_deadlineFallible deadline: rejects zero/overflowClusterError::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.

QuestionAnswer
Run itjust bench (SBE), just bench-cluster (cluster)
What is gradedergon / sbe-tool, Criterion’s mean, highest ratio over 5 repeats, both LTO profiles
PassHighest 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
FailAbove the ceiling and outside the A/A band, or no A/A band measured
Tolerancenone (zero tolerance): no slack is added to a ceiling
Where results gotarget/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)

StepSampleWhy
1SBE Feature TourGolden path. Full feature map: stages, EncodedLength, checked constructors + verify, Display, DTO with DomainVarData::Strings, all three conversion styles
2aL3 Order BookNested/ragged books; with_domain_type only; build-dep only (#[path] into src/generated/)
2bExchange ExampleMulti-schema; with_conversion only; IPC + app TryFromSbe
3Codegen as LibraryGenerator as a library (no build.rs)
LaterCluster TutorialConnect, offer, poll, keep-alive, close
LaterCluster HA OrderbookClaim-based Cluster publishing + HA-shaped book
LaterCluster RFQRFQ / 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

SampleConfigDecode / encode surface
L3 Order Bookwith_domain_type onlydec.price()? → Decimal; enc.price(d)?
Exchange Examplewith_conversion onlydec.price_as::<T>()?; enc.price_from(&t)? (+ app TryFromSbe)
SBE Feature TourAll 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 Result and ?.
  • 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.

SelectorConfigDecode APIEncode APIWho writes the impl?
BooleanTypewith_domain_type(.., "bool")dec.available()?enc.available(true)?ergo-sbe
UTCTimestampwith_domain_type(.., chrono)dec.timestamp()?enc.timestamp(t)?ergo-sbe
Decimal (Quote)with_conversion onlydec.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:

SampleStyle
L3 Order Bookwith_domain_type only
Exchange Examplewith_conversion only

Feature map → demo

FeatureDemo
Fixed message + compute_length_with_header()demo_fixed_heartbeat
Staged CarEncodedLengthdemo_car_size_and_encode
Consuming decoder stagesdemo_car_decode_stages
Owned DTOdemo_car_domain_dto
AnyMessagedemo_any_message
bulk_add (fixed-stride leaf group)demo_bulk_add
Checked decode / wrap / verifydemo_try_vs_trusted
Display / Debugdemo_display_debug
with_conversion onlydemo_conversion_only
with_manual_domain_typedemo_domain_type_manual_impl
All of the aboverun_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.

NeedUse
Concrete price() -> DecimalThis sample (with_domain_type)
Generic price_as::<T>() / app adaptersExchange Example (with_conversion)
Side-by-side both stylesSBE 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 doingLane
Reading a couple of fields, or one tailrandom access — smallest, Sync
Decoding the whole book in wire orderstaged 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

PathRole
schemas/l3-book.xmlNested bids/asks, orders, var-data tails
build.rsgenerate_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.rsRunnable demos
tests/l3_tests.rsRound-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).

SampleConfig
This cratewith_conversion only
L3 Order Bookwith_domain_type only
SBE Feature Tourboth (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

ExampleDemonstrates
flyweightGenerator::generate() with default config — zero-copy flyweights
domain_objectswith_domain_objects(DomainVarData::Strings) — owned DTOs (String var-data) + try_from_decoder
l3_nestedThe full type graph for 3-level nested groups (L1→L2→L3 entry types)
dump_genThe 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.

Patternbuild-dependenciesdependenciesTypical use
Build only (samples default)ergo-sbe—generate_to_dir → src/generated/ (gitignored, unpublished) + #[path = "generated/….rs"]
OUT_DIR onlyergo-sbe—generate_to_out_dir + include!(concat!(env!("OUT_DIR"), …)) — the product shape without macros
Build + runtime (product default)ergo-sbeergo-sbegenerate_to_out_dir + sbe_mod! (expands to that include!) — what cluster/build.rs and the introduction do
Runtime only—ergo-sbeCall 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
  1. cargo build --manifest-path samples/sbe-feature-tour/Cargo.toml
  2. Open samples/sbe-feature-tour/src/generated/feature_tour.rs
  3. 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() is const when 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 *EncodedLength builder.
  • 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 offer and explicit try_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:

MethodReturns
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

ComponentVersionNotes
Aeron Java/C media driver1.53.3aeron submodule tag; archive + consensus module
Aeron Cluster Java1.53.3clustered 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.xschema 111 session + mark codecs
Rust1.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
OSLinux x86-64 and arm64CI 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
Java25 (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

SchemaIdSBE versionsemanticVersion
Aeron Cluster Session111175.4
Aeron Cluster Mark11025.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:

TriggerExpected 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 connectFollow 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 challengePass to CredentialsSupplier, send ChallengeResponse
Auth rejectionClusterError::AuthRejected
Timeout (connect / poll / new-leader)ClusterError::Timeout / ClusterError::Disconnected
Listener panicClusterError::ListenerPanicked, session continues
Malformed egress frameClusterError::ProtocolError
Payload too largeClusterError::PayloadTooLarge
Publication backpressureRetryable 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).

CoveredTest file
3-node connect, leader failover, redirectfailover.rs, failover_own_driver.rs, client_parity.rs, nonblocking.rs
New leader that never connects times outnonblocking.rs (a_new_leader_that_never_connects_times_out)
SessionEvent(CLOSED) while a new leader is pendingnonblocking.rs
Ingress channel settings kept across failover and redirectingress_channel_settings.rs
Fragmented egress, regular and controlled pollegress_fragmentation.rs
Restart, reconnect, quorum loss, log recoveryrestart.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_types module 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 the io_uring backends; webpki-roots adds Mozilla’s root store (tls::webpki_roots); curl adds libcurl as an HttpClient implementation.
  • 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.

termwhat it ispros / cons, when it matters
busy-spin threadA thread that loops on its own core and never sleeps.Pro: no wake-up delay. Con: uses a whole core all the time.
pollOne non-blocking pass of Driver::poll: receive, decrypt, decode, deliver, send.A message can wait up to one whole poll for its turn.
isolated coreA 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) siblingThe 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).
cpusetThe 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 managerThe 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.
GravitonAWS’s Arm CPUs.Each vCPU is a whole core, so there is no hyperthread sibling. The fair backend comparison ran on it.
SCHED_FIFOReal-time scheduling: the thread runs ahead of every normal one.One A/B for the loop: no consistent change (data).
syscallA call from the program into the kernel.Each costs CPU. uring-spin makes far fewer than epoll-spin at the same message rate.
interruptThe 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.
NAPIThe 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 queueOne 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 RXENA (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.
backendHow 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-spinAsks 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, tokioRust’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-busyReads 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_queueA 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_uringA 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-spinio_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-sqpollA 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-busyWait 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.
crossoverThe 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_NODELAYTurns 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 ACKThe 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_minThe 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 keepaliveThe 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_TIMEOUTHow long sent data may stay unacknowledged before the kernel drops the connection.25 s here; the kernel default is about 15 minutes.
TLS 1.3The encryption on every connection.TLS 1.2 is refused with a clear error.
TLS handshakeThe key exchange when a connection opens.Makes one poll of the connecting thread long (data). Keep it off the loop: connect_background.
TLS recordTLS’s unit of encrypted data.A native HTTP request is one record; a large WebSocket message goes out over several.
in placeDecrypted 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.
WebSocketA two-way message stream over one TCP connection, opened by an HTTP upgrade (status 101).No compression here: it would break in-place parsing.
frameWebSocket’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 / PongControl 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 handshakeEach side sends a Close frame, then TCP closes.Waits up to 1 s (close_timeout_ms). Driver::close skips it.
HTTP/1.1 keep-aliveOne connection reused for many requests.No handshake per request. One request in flight per connection; a timed-out request closes its connection.
connection poolThe warm keep-alive connections an HttpClient keeps.All busy: Error::Busy. Each one is one more socket the loop polls.
libcurlThe 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 limitA 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, bucketlimit::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 windowA 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.
reserveUnits only cancels and reduce-only orders may use.Keeps cancels possible when a bucket is nearly full; not during a hold.
hold, haltHold: 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().
verdictWhat 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::MaybeFor a request with no answer: it never left, or it may have run.Only the transport knows. Maybe: look the order up.
client order idYour unique id on each order.The only way to find out what became of an order with an unknown outcome.
connect gateA bucket every connect attempt must pass.Keeps 50 links reconnecting at once inside a venue’s connect limit.
round tripRequest 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 / maxpN: 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 timestampWhen 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 → decodedKernel 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 pathOne-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 whenUse something else when
one thread owns 1 to a few hundred exchange connections and spins on its own corethe thread may sleep: tokio
you want one copy per byte: TLS records decrypted and messages parsed in placeyou 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::poll makes 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 as WsHandler::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. poll fails only when the backend fails.
  • Resources are owned. A socket closes when its connection drops. Dropping a Driver cancels in-flight io_uring work 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 threadone CPUa spare CPU for the SQPOLL thread (or the loop’s sibling)the NIC’s interrupts on a core of their own (node-level)
1–4recv-busyrecv-busy or uring-sqpollrecv-busy
5–64recv-busy,per_queue=trueuring-sqpolluring-sqpoll, epoll-spin or uring-spin
65–256recv-busy,per_queue=trueuring-sqpolluring-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-busy reads 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_queue reads one socket per NIC queue, then the ready ones. Its poll stops growing at the queue count. It trails uring-sqpoll at 16–64 sockets. Not the default: set it when the count can pass 4.
  • The interrupt backends (epoll-spin, uring-spin, mio) matched uring-sqpoll from 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-sqpoll with the SQPOLL thread on the loop’s sibling (the default in a cpu: 2 x86 pod: Kubernetes).
  • The loop’s CPU time in the kernel matters: uring-spin. It equals epoll-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-busy poll the NIC inside a timed wait, then sleep. In the one run they trailed uring-sqpoll and 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

callwhere the handshake runsuse it for
Driver::add(WsConn::connect(…)?)the caller’s thread, which waitstools, tests, start-up
Driver::connect(dial)the loop, a step per poll; the handshake makes one poll longa 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 knownreconnects 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)); feature webpki-roots adds Mozilla’s roots.
  • Which TLS stack: the in-place tls::RecordLayer, on every native connection. rustls does the handshake (UnbufferedClientConnection, then dangerous_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

settingdefaultchange with
TCP_NODELAYon, every socketalways on
TCP keepaliveon: 10 s idle, then a probe every 5 s, 3 probeskeepalive_idle_s (0: off), keepalive_interval_s, keepalive_count
TCP_USER_TIMEOUT25 stcp_user_timeout_ms (0: kernel default, about 15 minutes)
SO_RCVBUF4 MiB, fixed; needs net.core.rmem_max ≥ 4 MiB (warned at start-up)rcvbuf (0: kernel autotuning)
TCP quick ACKassumed: quickack 1 on each routechecked once per route (below)
WebSocket timersPing after 10 s quiet, Pong timeout 5 s, idle timeout off, close wait 1 sWsOptions (WebSocket)
ReconnectoffReconnect (WebSocket)
  • TCP settings apply on Driver::connect, connect_background and add. HttpClient’s libcurl connections get TCP_NODELAY, keepalive and TCP_USER_TIMEOUT too.
  • Keepalive adds one kernel timer per socket. For a WebSocket-only loop, Pings already find a dead server: keepalive_idle_s=0 is an option.

What it checks for you

  • Driver::new checks 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 change that fixes it. quickack_check=require refuses 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).

practicewhereevidence
Pin the loop to an isolated CPU; give it whole CPUs (static CPU manager)node and podKubernetes
Reconnect off the loop: connect_background (the Reconnect default)podconnection set-up
AWS: ENA adaptive moderation off (ethtool -C <nic> adaptive-rx off), again after each NIC resetnodehost and NIC settings
Interrupt backends: also rx-usecs 0, and the NIC’s interrupts on a core of their ownnodeinterrupts 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 CPUnodethe 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 CPUpodchoosing a backend
Pack many streams onto a few connectionsapppolling 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.

backendhow it waitspolls the NICsleeps in the kernelextra core
epoll-spinepoll_wait(0) every poll, then recvmsg on ready socketsno (interrupts)neverno
recv-busynon-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 pollneverno
uring-spinIORING_OP_RECVMSG with DEFER_TASKRUN; enters the kernel only when it flags workno (interrupts)neverno
uring-sqpollan SQPOLL kernel thread submits, completes and polls the NICyes, on the kernel threadnever (the application’s thread)yes
epoll-busyepoll_pwait2(wait_us) with epoll busy polling for busy_poll_usyes, inside the waitup to wait_usno
uring-napiio_uring_enter(GETEVENTS, 1, wait_us) with NAPI busy pollingyes, inside the waitup to wait_us, and on every idle pollno
  • epoll_wait(0) never blocks, but never polls the NIC either. mio, and so tokio, is this path too.
  • Plain recv-busy reads every socket on every poll, so each socket adds a little to every poll. per_queue reads one socket per NIC queue instead: see cost per poll.
  • uring-sqpoll costs 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.)

keydefaultmeaning
loop_cpuunsetthe loop’s CPU, for the host checks; unset: the CPU the calling thread is pinned to (ergon-io never pins it)
sqpoll_cpuunsetthe 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_ms600,000idle time before the SQPOLL thread sleeps; NIC polling does not count as work, so keep it long
ring_entries256io_uring submission queue size
busy_poll_us1 (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_us0, or 100 (epoll-busy, uring-napi)the longest epoll-busy and uring-napi wait in the kernel
per_queuefalserecv-busy only: one busy-polled read per NIC queue, then the sockets epoll reports ready
prefer_busy_pollfalsethe 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_budget0 (kernel: 8)packets per busy-poll pass; recv-busy and epoll-busy only; raising it needs CAP_NET_ADMIN; effect not measured here
connect_cpuunsetthe connect_background helper thread’s CPU; unset: the housekeeping CPUs
connect_timeout_ms10,000the longest connection set-up
rcvbuf4 MiBSO_RCVBUF for every socket; needs net.core.rmem_max at least as large (warned at start-up); 0: the kernel’s autotuning
rx_timestampstruekernel software, and where supported NIC hardware, receive timestamps in RxMeta
quickack_checkwarnoff, warn or require
keepalive_idle_s10TCP keepalive: idle seconds before the first probe; 0: off
keepalive_interval_s, keepalive_count5, 3seconds between probes, probes before the drop
tcp_user_timeout_ms25,000TCP_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:

backendone poll doessyscalls
recv-busyrecvmsg on every socketone per socket per poll
recv-busy,per_queueone read per NIC queue, then epoll_wait(0) on the ready socketsone per queue plus the ready ones
epoll-spinepoll_wait(0), then recvmsg on the ready socketsonly the ready sockets
uring-spinIORING_OP_RECVMSG with DEFER_TASKRUNfew

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

backendneeds
epoll-spin, recv-busyany supported kernel
uring-spinLinux 6.1+ and the uring feature
uring-sqpoll, uring-napiLinux 6.9+ (NAPI registration) and the uring feature
epoll-busyLinux 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-busy grows with sockets: each non-blocking recvmsg runs 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_queue reads one socket per NIC queue (which polls that queue), then only the ready sockets. Its poll stops growing at the queue count.
  • epoll-spin and uring-spin do 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_message gets a slice of the buffer the kernel wrote into (one copy per byte).
  • RxMeta carries 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:

optiondefaultwhat it does
buf_size1 MiBreceive buffer; every frame must fit
max_message0 (what the buffer holds)a larger message closes the connection; a fragmented message may exceed the buffer
ping_interval_ms10,000Ping after this long with nothing received; a busy connection sends none; 0: off
pong_timeout_ms5,000nothing received this long after a Ping: Error::Timeout("websocket pong"); 0: off
idle_timeout_ms0 (off)nothing received this long: Error::Timeout("websocket idle")
close_timeout_ms1,000longest wait for the other side of a close handshake; 0: no wait
validate_utf8offinvalid text closes the connection; costs one more pass over each text message
protocolsnonesubprotocols 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 startswhat happenson_closed gets
the serverits Close is answered with the same code; then the client waits up to close_timeout_ms for the server to close TCPError::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 CloseError::WsClose, by_server: false; or Error::Timeout("websocket close")
the client: Driver::close(id)drops the socket at once, no Closenothing
  • 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 fielddefaultmeaning
initial_ms250first delay after a close
multiplier2each failed attempt multiplies the delay
max_ms30,000longest delay
jitter0.2each delay cut by up to 20 %, at random
seed0 (random)a fixed seed repeats the delays (tests)
max_attempts0 (never give up)failed attempts in a row before GaveUp
stable_ms60,000open this long resets the backoff
backgroundtrueconnect with connect_background (handshake off the loop)
  • on_open(id) returns Opened { link, reconnect }: subscribe either way; with reconnect, resync any state kept from the old connection.
  • on_closed(id) returns the next step: Retry, GaveUp or Stopped.
  • stop(link) vetoes reconnects; resume(link) starts again.
  • Driver::close reports nothing: call on_closed(id) yourself after it.
  • poll starts 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 after add included: 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_ms later.
  • 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 and resume.

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, or send_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 with Driver::flush.
  • Handlers cannot send (they have no driver): remember the id, send after poll returns (as the example does).
  • stats().auto_frames counts 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 with Budget::force.

Handshake

  • Checks status 101, Upgrade, Connection and Sec-WebSocket-Accept.
  • Any other status is Error::Upgrade { status, retry_after_ms, .. }: a 429 or 418 can be told apart, and retry_after_ms is the server’s Retry-After (seconds or a date). Wait at least that long before connecting again: outcome::refused_upgrade and Reconnect::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:

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:

HttpImplWhat it isUse it when
Native (default)ergon-io’s own client on a Driver: each request is one TLS record, each response is parsed in placelatency 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 blockingthe 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, HttpConn is a single keep-alive connection on your own Driver (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::Busy at once, no hidden queue. Each pooled connection is one more socket the loop polls: read many connections before sizing pool.
  • 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 as Error::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) unless curl_allow_non_idempotent_methods is 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.rs shows 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’s inline_connect_fallback is set. A failed connect goes to ClientHandler::on_connect_error and 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_seed fixes the delays (tests).
  • A venue’s connect limit (native only): with connect_gate, new opens nothing and every connect waits for the gate passed to poll_gated; poll then refuses. A refusal does not grow the backoff. Give the pool a Budget bucket 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-Length or chunked bodies. The body in on_response borrows the client’s buffer: copy what you keep.
  • libcurl: curl_drive picks curl_multi_perform on every poll (default) or curl_multi_socket_action; both never block. Compared once on Azure at 1,000/s: curl_multi_perform was 1–6 µs ahead at the median in both rounds and had the higher max (data). Trust comes from curl_ca_file or 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).

signeruse it for
Signer::hmac_sha256signed REST requests
Signer::ed25519_pkcs8log-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}&timestamp={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.
  • HttpClient with a pool of 1 was within 1 µs of a raw HttpConn at 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 HttpClient has its own Driver, so its poll adds to theirs. Then pick: choosing a backend.
  • Set it with HttpClientConfig::driver, for example Config::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.

venuelimitsusage reportedon breachban
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 rateLimits429 + Retry-After (s); body -1003 (weight), -1015 (orders); 403: the WAF limitsending 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-TimestampHTTP 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 familynonecode 50011 (often with HTTP 200 as well as 429); 50061: sub-accountnone documented
Coinbase (Advanced Trade)private 30/s per user; public 10/s per IP (rate limits)none429none documented
Kraken (spot)draining counters: REST (max 15–20) and trading per pair (60–180; a quick cancel costs up to 8)none: model itEAPI: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
Deribitcredits per sub-account, refilled continuously: 20 requests/s, burst 100 (matching engine: by volume tier)nonetoo_many_requests (10028), and the session ends: connect and log in againnone documented
Hyperliquidper IP: weight 1,200/min; per address: 1 request per 1 USDC traded (10,000 to start)userRateLimit info requestthe address drops to 1 request per 10 snone 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

piecewhat it does
limit::BudgetYour 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::RollingA 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::DecayA draining counter (Kraken, Deribit, Coinbase Exchange). Not for window limits: it lets twice the limit through in one window.
reserve, Class::ReduceUnits only cancels and reduce-only orders may use, so you can always cancel.
observe_used, set_limitWhat the venue’s headers say: raise the local count, never lower it.
throttled, bannedA 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::resendRe-send a request that did not run, only inside its own deadline. Budget::resend takes the verdict: Unknown answers Reconcile, never re-send.
outcome::SentFor a failed request: No (never left; safe to send again) or Maybe (may have run). In ClientHandler::on_error.
outcome::Verdict, genericWhat 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_epochHeader parsing with no allocation. A malformed header is an error, never 0.
connect gateClientHandler::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_framesFrames 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:

  1. try_charge → keep the Ticket by request id.
  2. HttpClient::request returns at once; the answer comes to ClientHandler::on_response or on_error.
  3. on_response → the adapter’s on_response above, then Budget::resend for the verdict.
  4. on_error(request, sent, error) → Sent::No: refund the ticket and Budget::resend; Sent::Maybe: Unknown, reconcile by client order id.

Error policy

what came backverdictran?what to do
2xx, no error codeDoneyesapply it
400, 401, 404, other 4xx; 3xxRejectednofix it; never send it again unchanged
408Unknownmaybelook it up by client order id
429, Binance -1003/-1015, Bybit 10006/10018, OKX 50011/50061Throttlednothe bucket the error names is held; send again only after the hold, inside its deadline
Binance 418 or 403, Bybit 403 (any)Bannednothe IP stops until a person clears it; alert
Binance -1021, Bybit 10002, OKX 50102ClockSkewnosend again with a new timestamp, inside its deadline
Binance -1008 (overloaded), -1006/-1007Unknownmaybelook it up by client order id
409 with Binance -2022 (both halves failed)NotRunnosend again, inside its deadline
5xx, Binance 409 with -2021, Bybit 10000/10016, OKX 50004Unknownmaybelook it up by client order id
no response, Sent::NoNotRunnorefund the ticket; send again, inside its deadline
no response, Sent::Maybe (timeout, reset)Unknownmaybelook 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) or Unknown (-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::No and Busy. Venues count rejected and throttled requests too.
  • A server wait is the server’s answer. A 429 with Retry-After holds 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

  1. A ban stops sending until a person clears it. Budget::banned halts 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() and BucketStats::halted show 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.
  2. One sender per account and IP. The budget sees only this process. Budgets are not shared between processes.
  3. 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_resend charges it again, or says when to ask, or calls it stale.
  4. 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.
  5. 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_request in lib/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 HttpClient refuses every method but GET, HEAD, OPTIONS and DELETE, with Error::CurlResend, unless curl_allow_non_idempotent_methods is set. io/tests/client.rs shows 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 and allocs (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::tick during 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 (for Sent), 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-number cpu request. The node’s housekeeping CPUs are outside that set.
  • A thread outside the cpuset never starts. The kernel refuses the mask (EINVAL), and io_uring_setup refuses an SQPOLL CPU outside it the same way.
  • So the helper threads run on the pod’s own CPUs. Driver::new reads the pod’s cpuset once and chooses from it (placement): helper_cpus / ERGON_HELPER_CPUS first, 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: set loop_cpu, or pin the loop thread before Driver::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.

podthe loopthe helpers (dial, warnings, SQPOLL)what to configure
cpu: 1the only CPUnone: they would share the loop’s CPUDriver::connect, or connect_background with inline_connect_fallback=true; recv-busy (per_queue=true past 4 sockets)
x86 with SMT, cpu: 2one hyperthreadthe other hyperthread (the loop’s SMT sibling)nothing
Graviton, cpu: 2one corethe other corenothing
shared CPUsone exclusive CPUdial 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_background connects as Driver::connect does: 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=off silences 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: 4 pod (two physical cores) with sqpoll_cpu on 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_cpus wins over the variable.
  • Keep uring-sqpoll off shared CPUs: its SQPOLL thread spins (io_uring_setup(2), IORING_SETUP_SQPOLL). The default never puts it there: with no sqpoll_cpu it 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 set sqpoll_cpu to 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 (what placement::process_cpus reads), or Cpus_allowed_list in /proc/self/status.
  • Pinned by other means? Name the CPU as loop_cpu in 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=off silences 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.

termwhat it ispros / cons, or when it matters
check, findingA 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 threadA 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 CPUHot: 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, maxThe 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 gapA 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 tickThe 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).
isolcpusA boot option that keeps other tasks off the listed CPUs.Alone, the tick stays on.
nohz_fullA boot option that stops nearly all timer ticks on the listed CPUs while one task runs there.Use it with isolcpus (T1).
rcu_nocbsA 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 affinityThe 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_throttledA 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), frequencyHow 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 directoryWhere 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 driverAeron’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, hugetlbfsFilesystems 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 nodeA 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 balancingThe 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 UDPUDP through the kernel’s network stack and sockets. Plain: interrupt-driven, no busy polling.No setup. Against DPDK: ergon-md-dpdk.
interrupt-drivenPackets reach the socket through the NIC’s interrupt, not busy polling.Each packet waits for an interrupt (and any moderation delay: N1).
NIC receive queueA 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 moderationThe 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 pollingA 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 ringThe NIC’s buffer for arriving packets: how many it can hold.Too small drops bursts (N4).
NIC resetThe 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.
ENAAWS’s virtual NIC (Elastic Network Adapter) and its Linux driver.N1 checks its adaptive moderation.
ethtoolLinux’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).
DPDKA library that drives the NIC from user space, skipping the kernel.Its gains and costs: ergon-md-dpdk.
SQPOLL threadAn 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, /procFiles 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).
NAKAn Aeron receiver’s request to resend lost data.NAKs in bursts with kernel busy polling: check the RX ring (N4).
open loopEach message is timed from when it was due, not when it was sent.A stall counts against every message it delays.
CLOCK_REALTIMEThe 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 sourceThe 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

checksettingwhy it matters
T1CPU isolation: isolcpus, nohz_full, irqaffinityisolcpus 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.
T2NIC interrupt placementEach NIC receive queue raises interrupts. On the loop’s CPU, the kernel runs the handler instead of the loop.
T3transparent 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).
T4the Aeron directory’s filesystemAeron, 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.
T5automatic NUMA balancingOn a host with several memory nodes, the kernel unmaps pages to see which CPU uses them, so pinned threads take page faults.
T6the NIC’s NUMA nodeA NIC on another node makes every packet cross between the sockets.
T7real-time schedulingSCHED_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.
T8SMT siblings, shared CPUsTwo busy threads on one CPU take turns. Two hyperthreads share one core’s execution units.
T9CPU 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.
T10SMT (hyperthreading) on, x86One 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.
T11how CLOCK_REALTIME is keptUTC 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.
T12the kernel’s clock sourcetsc 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.
K1the pod’s QoS classAnything 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.
K2exclusive CPUsThe 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.
K3the loop’s SMT siblingA 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.
K4a CPU for the helper threadsOne 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.
K5capabilitiesBusy 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.
K6memory.high, CPU/memory pressureAt 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.
K7the NIC’s NUMA nodeSame 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.
N1ENA adaptive RX moderationModeration 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.
N2deferred interruptsnapi_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.
N3Azure accelerated networkingWithout its VF (the SR-IOV virtual NIC), traffic takes Hyper-V’s slower software path.
N4NIC receive (RX) ringHow many packets the NIC can hold. Too small, and it drops packets in bursts.

Not checked: CPU idle states and frequency.

Symptom, check, fix

symptomcheckfix
tens of thousands of loop gaps a minute; about 1,000 timer interrupts a second on the loop’s CPUT1boot with isolcpus=nohz,domain,managed_irq,<hot> nohz_full=<hot> rcu_nocbs=<hot> irqaffinity=<housekeeping>
hundreds of loop gaps a minute; burst p99 upT2echo <housekeeping> > /proc/irq/<n>/smp_affinity_list, again after every NIC reset
THP always or madviseT3echo never > /sys/kernel/mm/transparent_hugepage/enabled, or boot with transparent_hugepage=never
Aeron directory on ext4, xfs or overlayfsT4/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 nodesT5on the node: sysctl -w kernel.numa_balancing=0
NIC and hot CPUs on different nodesT6pin the threads to the NIC’s node, or use a NIC on theirs
a real-time thread on the loop’s CPUT7chrt --other -p 0 <tid>, on an isolated CPU
two busy threads on one CPU or coreT8pin them to separate cores; leave the sibling idle
stalls of milliseconds in a container; nr_throttled in /sys/fs/cgroup/cpu.stat growsT9Kubernetes 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 siblingT10, K3turn 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 missingK1, K2, K4Guaranteed with requests = limits; the kubelet’s static CPU manager; cpu: 2
AWS: p99.99 high with a busy-polling loopN1ethtool -C <if> adaptive-rx off rx-usecs 0, again after every reset
packets on sockets that do not busy-poll held for gro_flush_timeoutN2echo 0 > /sys/class/net/<if>/napi_defer_hard_irqs; echo 0 > /sys/class/net/<if>/gro_flush_timeout
Azure: VF missing or downN3node-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 pollingN4ethtool -G <if> rx 4096 (on ENA this resets the NIC: re-pin, turn adaptive-rx off again)
fine after boot, worse laterT2, N1an ENA reset undid them: re-apply from a link-event hook
cross-host one-way latencies drift or jumpT11on 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 hpetT12find 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.

checkevidencestatus
T1runs flags-aws-20261014T163114Z, flags-aws-20261015T045527Z (boot profiles, one 120 s gap-spinner sample each)measured
T2run flags-aws-20261015T062428Z (irqlocal, two reps); ENA’s driver source for the IRQ placementmeasured
T7run flags-aws-20261015T070339Z (rt, two reps): tick measured; latency inconclusivemeasured
T8two threads on one CPU: not measured here; a busy sibling: T10’s runpartly measured
T10, K3run flags-aws-20261015T122127Z (SQPOLL on the loop’s sibling, three rounds); other sibling loads not measuredmeasured
N1runs 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, N4kernel, Kubernetes, Aeron, chrony, AWS or Azure documentationnot measured here

Where the checks run

componentcheckswhenswitch
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 DriverT2, N1, N2 (interrupt-driven backends only), N3 on each connection’s egress NICafter the first route lookup to it; again after a NIC resetquickack_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 pollingbefore the driver starts; N1 and T2 again after a NIC reset, on its md-host-checks threadERGON_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, ClockWatchbefore the loop; the NIC checks again when the NIC’s carrier_changes moves; ThrottleWatch::check and ClockWatch::check every few seconds from housekeepingnone: 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 require refuses 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::new to one CPU, or set loop_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_NICS names 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+), which ThrottleWatch reads.
  • In CI, run them on a captured /sys and /proc tree: 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.
  • HotSet lists the CPUs your threads busy-spin on, each with a Role (Loop, Sender, SqPoll, …), so a message names the thread.
  • Component (Io, Md, Rt) is the caller. It sets T3’s severity: warn for Md and Rt, info for Io.
/// 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.)

functionreturnscost
t1_isolation(&host, &hot)one finding per missing setting per CPUa few sysfs reads
t2_nic_irqs(&host, &nic, &hot)one finding per IRQ that may fire on a hot CPUone directory and two files per IRQ
t3_thp(&host, component)one findinga few sysfs reads
t4_aeron_dir(&host, path)one findingone statfs; unknown on a tree
t5_numa_balancing(&host)one findinga few sysfs reads
t6_nic_node(&host, &nic, &hot)one finding per PCI devicea few sysfs reads
t7_sched_policy(&host, thread, role)one findingone /proc read; Thread::Current from the thread that will spin, or Thread::Task(tid)
t8_smt_siblings(&host, &hot)one finding per shared corea 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 checkcheck: 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 findingone 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 devicesysfs; busy_polls is Some(true) if your sockets busy-poll, None if not known
n3_azure_vf(&host, ifname)one findingsysfs
t11_time_sync(&host, &probe)the verdict’s finding, plus one each for an unsettled clock, a wide bound, a pending leap secondadjtimex, 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 findingtwo 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 nonecheck: 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

fieldmeaning
statusFinding: it fired. Ok: fine, or it does not apply here. Unknown(reason): the data could not be read.
severityWarn or Info: how to log a Finding
messagewhat was found, its measured effect, and the fix
evidencewhere 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, or is_warning() in your own start-up.

Alternatives

toolgood atshort ofuse it
tuned profiles (cpu-partitioning, latency-performance)applying isolation, IRQ and power settings; tuned-adm verify checks they stuckdoes not know which threads spin, which NIC they use, or what a setting costs; not measured hereto 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 causesays how much, not which settingto 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 alonesetting everything oncedrift: 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 -G or -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. Against adaptive-rx off alone (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, not madvise. khugepaged still runs under madvise (kernel transhuge.rst); the cost is not measured here.
  • No CPU quota on a busy-spinning container. /sys/fs/cgroup/cpu.max should read max 100000, and nr_throttled should 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/common does for five hosts.
  • Measure your own host. The measured effects come from AWS c7i runs.

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.

termwhat it iswhen it matters
media driverthe part of Aeron that moves messages between the network and a host’s applications; its own process, or embedded in yoursits transport can change; Aeron clients do not
channelan Aeron address: a UDP host and port, or IPCa UDP channel is one flow
stream, publication, subscriptionstream: the messages on a channel under one stream id. Publication: its sending end. Subscription: its receiving end
frame, MTUframe: Aeron’s unit on the wire, a header and data. MTU: the largest packet a link carries; AERON_MTU_LENGTH caps a frame1,408 by default here (RUSTERON_DPDK_MAX_AERON_MTU), so a frame fits one packet buffer
Aeron IPCAeron over shared memory, between processes on one hostboth ends on one host: use it or kernel UDP
Aeron Premiumthe commercial Aeron (aeron.io), with its own DPDK driverthe supported route to bypass in production; this crate is a proof of concept
conductor, sender, receiverthe media driver’s three agents (threads of work): control, sending, receivingunder DPDK the network threads must busy-spin on CPUs of their own: the install refuses otherwise
threading modehow the agents share threads: DEDICATED, SHARED_NETWORK, SHARED or INVOKERmeasured cost of SHARED_NETWORK
idle strategy, parkwhat an Aeron thread does with no work: spin and noop never sleep; backoff spins, yields, then parks (sleeps) ever longerspinning needs a CPU of its own
log buffer, term bufferAeron’s memory-mapped file of one stream’s messages, in three term bufferson hugepages, not sparse
receiver window, status messagewindow: 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-aliveheartbeat: Aeron’s frame on a quiet stream. Keep-alive: your own small messagewhy a keep-alive; its effect is not measured here
kernel UDPthe normal path: NIC interrupt, the kernel’s network stack, a system callno setup, every tool works (tuning)
kernel bypassone process takes a NIC from the kernel and reads and writes its queues itselflower median and max in these runs; costs cores, NICs, root setup and upkeep
DPDKthe Data Plane Development Kit: the kernel-bypass library this crate uses
EALDPDK’s start-up layer (Environment Abstraction Layer): it maps the NICs and hugepages and starts DPDK’s threadsextra options: RUSTERON_DPDK_EAL_ARGS
NIC, PCI address, MACnetwork interface card (virtual on a cloud VM); its slot on the host’s bus; its hardware addressthe crate picks NICs by PCI address, on Azure by MAC
ENAAWS’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 networkingMANA: 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-combiningENA’s fast send mode: the CPU writes each send into the NIC’s memory window (the BAR). Write-combining batches those writesstart 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-pcikernel modules that hand a NIC to a user-space processnode-level, as root: igb_uio on AWS, uio_hv_generic on Azure. The kernel’s tools then lose that NIC
interrupta NIC’s signal that packets are waiting; the CPU stops its work to handle itDPDK polls instead
interrupt moderationthe NIC waits to batch several packets into one interrupt. Adaptive: the driver re-tunes the wait itselfoff 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-spina thread checks for work in a loop and never sleepsno wake-up delay; a whole core at 100%, idle or not
pin, CPU affinitypin: 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 corethe core for interrupts, the conductor and every thread that does not spinkeeps that work off the busy cores
isolated CPUa CPU the kernel keeps its own work off: scheduler, timer tick, interruptsnode-level (kernel command line)
ring, descriptora NIC queue: RX receives, TX sends. Each descriptor in it points at one packet bufferRX 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 hugepages16,384 a NIC by default
burstmany messages sent back to backthe RX ring must hold it while the reader catches up
jumbo framespackets above Ethernet’s usual 1,500 Bone run only
hugepageslarge memory pages (2 MiB here), reserved up frontDPDK needs them; about 37 MiB a NIC at the defaults. Node-level reservation
hugetlbfsa filesystem of hugepages; Aeron’s directory can live on onemount it with size=, or Aeron refuses every log buffer
transparent hugepagesthe kernel building large pages by itself, in the background (khugepaged)not separated in one run; node-level
page faultthe first touch of a memory page: the kernel stops the thread to map ithugepages and pre-touching move it off the hot path
flowpackets between one source and one destination address and portan Aeron channel is one flow; one flow topped out at 7.75–7.77 Gbit/s of Aeron frames on c7i.4xlarge
placement grouphosts 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 instancespare AWS capacity, sold cheaper; AWS can take it backa c9g.2xlarge pair: $0.306 spot against $0.696 on demand an hour (cost)
x86, Gravitonx86: Intel and AMD CPUs. Graviton: AWS’s Arm CPUsnever compared in one run: pairs, days and builds differ (BENCHMARKS.md)
SMT, hyperthread, sibling, vCPUSMT 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 metalan instance with no hypervisorc7i.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 backevery latency here is a round trip
repone repeat of a runp50 and p99.99 are the median of 5 reps
NAK, retransmit, drop, short sendthe receiver asking for a resend; a frame sent again; a packet the NIC threw away; a send that put out less than askedany one makes the rep lossy
lossy rep, loss-free maximumlossy rep: a rep that lost anything. Loss-free maximum: the highest load with no lossy repa rate with a lossy rep is a ceiling, not an operating point
p50, p99.99, tailpercentiles: p50 is the median; at p99.99, 1 round trip in 10,000 is slower. The high ones are the tail
maxthe largest single round trip in any rep, unless labelled “median of rep maxima”one stall sets it
intended send timeunder load, latency counts from when a message should have been senta stall then counts against every message behind it
CPU countera register in each core that counts at a fixed rate (the TSC on x86)the transport reads it inline (clock)
chronythe 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 timestampthe time the NIC stamps on each packet it receivesAWS ENA only (configuration)
media receive timestampthe 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).

settingwhat it doesevidence
interrupt moderation offthe NIC interrupts for each packet instead of waiting to batch severalGraviton 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 ownthe kernel’s receive work runs on a core with nothing else to doGraviton 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 pollinga socket read spins on the NIC’s queue instead of sleeping until an interruptnot 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 ringthe NIC’s receive queue holds a burst while the reader catches upused 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.

costwhat it means
coreseach network thread spins at 100% on a core of its own, idle or not, plus a core for housekeeping (layout)
NICstwo extra NICs, taken from the kernel: no ping, tcpdump, firewall or kernel counters on them
memory2 MiB hugepages for packet buffers, about 37 MiB a NIC at the defaults
setupnode-level, as root: each NIC bound to a user-space driver, hugepages reserved (configuration)
operationsyou own NIC resets, restarts, upgrades and the C in native/dpdk/
same hostno gain: host-local channels still use kernel sockets (below)

Which transport should I use?

situationuse
both ends on one hostkernel UDP, or Aeron IPC (shared memory, no network); DPDK gives no bypass there
between hosts, Gravitonkernel 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, x86kernel 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 enoughkernel bypass with Aeron Premium
learning how a transport binding works, or reproducing these numbersthis 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, roundtransportidle: p50 / max1 M msgs/s: p50 / maxlossy reps at 1 M msgs/s
Graviton5 c9g.2xlarge, G3DPDK13.97 / 7919.36 / 1140 of 5
kernel UDP, moderation off, interrupts on their own core18.93 / 16844.83 / 2110 of 5
x86 c7i.4xlarge, round 7DPDK21.28 / 11630.38 / 1550 of 5
kernel UDP, busy polling, 4,096 ring28.22 / 4,84643.74 / 6,6644 NAKs over 5 reps; lossy reps not in the table
x86 c7i.metal-24xl, round 4DPDK28.13 / 84534.30 / 103not in the table
kernel UDP, busy polling33.70 / 30742.94 / 1,150not 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.4xlarge pairs 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

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

  • dpdk feature: Linux x86-64 or aarch64, libdpdk >= 23.11 found by pkg-config (libdpdk-dev on Ubuntu 24.04 or Debian 13; from source on Amazon Linux 2023). Without it the crate still builds everywhere, and selecting DPDK returns DpdkError::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-v2 Graviton4, neoverse-v3 Graviton5), and the same in RUSTFLAGS=-Ctarget-cpu=. rusteron builds Aeron’s own C for the build host’s CPU unless RUSTERON_C_MARCH names 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
valueresult
unset or defaultAeron’s kernel socket transport, unchanged
dpdk-enathe DPDK transport; any failure exits non-zero and never falls back to kernel UDP
anything elseexits 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().

callreturnsuse 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 loopyou 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 DPDKyou set up Aeron yourself and only want the transport
DpdkTransport::install(&context, &config)a DpdkTransport, from a typed DpdkTransportConfigyou 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 memoryNot 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.

variablemeaning
RUSTERON_DPDK_FILE_PREFIXthe EAL’s file prefix (required)
RUSTERON_DPDK_SENDER_PCI, _IPV4_CIDR, _GATEWAYthe 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_MACAzure: pick the NICs by MAC (every NIC of a VM shares one PCI function)
RUSTERON_DPDK_RX_DESCRIPTORSreceive ring size; unset: 4,096 on ENA, 256 on MANA
RUSTERON_DPDK_TX_DESCRIPTORStransmit ring size, 512 by default; set 256 on Azure, or start fails
RUSTERON_DPDK_MBUFS_PER_PORTpacket buffers per NIC, 16,384 by default
RUSTERON_DPDK_EAL_ARGSextra 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_CPUthe CPU for the transport’s housekeeping and local-I/O threads
RUSTERON_DPDK_HUGE_DIRthe hugetlbfs mount DPDK uses, an absolute path; /dev/hugepages by default
RUSTERON_MEDIA_DRIVER_DIRthe Aeron directory ConfiguredDriver sets
ERGON_HOST_CHECKSon/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_CHECKalias of ERGON_HOST_CHECKS, read only when that is unset
RUSTERON_MEDIA_DRIVER_MLOCKALL1: 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_MODEnetwork threadits CPU
DEDICATEDsender and receiver, a NIC eachAERON_SENDER_CPU_AFFINITY, AERON_RECEIVER_CPU_AFFINITY, different CPUs
SHARED_NETWORKone thread, both NICsAERON_SENDER_CPU_AFFINITY
SHAREDone thread for everythingAERON_CONDUCTOR_CPU_AFFINITY
INVOKERyour thread, which runs the driverAERON_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):

coreruns
housekeepinginterrupts, Aeron’s conductor, the EAL’s main thread, the transport’s housekeeping and local-I/O threads
applicationyour busy-spinning loop
senderthe media driver’s sender
receiverthe media driver’s receiver
  • The agents are pinned for you, in every mode, each to the CPU its AERON_*_CPU_AFFINITY names. Running unpinned is not measured here.
  • SHARED_NETWORK saves 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 in RUSTERON_DPDK_HOUSEKEEPING_CPU and RUSTERON_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.

settingwherewhat it doesevidence
AERON_RECEIVER_IO_VECTOR_CAPACITY=16 (the default under dpdk-ena)podup 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=falsenode mount, pod envthe log buffers sit on hugepages, allocated up frontrequired, 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 clientspodclients fault their mappings in before the first messagenot measured here
AERON_MTU_LENGTH=1408 (at most RUSTERON_DPDK_MAX_AERON_MTU, 1,408 by default)podevery frame fits one packet bufferjumbo 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 MiBpod; sysctls nodethe window fits the socket buffernot 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)podstatus messages go out every 20 µs, and the sender never has more in flight than MANA’s 256-entry ring holdsnot measured here: no Azure run output is committed. Set both or neither
kernel busy polling (net.core.busy_read, busy_poll): none recommendednodea socket read spins on the NIC’s queuemeasured, 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 offnodethe kernel stops building large pages in the backgroundnot 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 streamsapplicationa 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 against CLOCK_MONOTONIC_RAW at start where DPDK’s is more than 100 ppm off: no system call, no call into Rust. It falls back to CLOCK_MONOTONIC when 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 not CNTFRQ_EL0 (aarch64). RUSTERON_DPDK_CLOCK=monotonic forces it. Counter type 80 and DpdkTransport::clock() say which, and why.
  • NIC receive timestamps: RUSTERON_DPDK_RX_HW_TIMESTAMP=1. Only AWS ENA offers them (Gen7+ instances in a precision-time placement 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=reserved on 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: quanta for durations, CLOCK_REALTIME for 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=require once 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 under dpdk-ena nothing is re-checked.

Pitfalls

symptomcausefix
start fails on Azuretransmit ring above MANA’s 256RUSTERON_DPDK_TX_DESCRIPTORS=256
start fails naming the ENA’s BARthe NIC’s memory window (BAR) is not mapped write-combined, so ENA’s low-latency queue cannot runnode: igb_uio with wc_activate=1, or a write-combining vfio-pci with RUSTERON_DPDK_VFIO_WC=1
Aeron refuses every log bufferhugetlbfs mounted without size=node: mount it sized
window smaller than askednet.core.rmem_max below 2 MiBnode: raise it to 16 MiB
a channel stops at a fixed rate of 1,344 B on AWSone flow’s Gbit/s capmore channels, on different ports
millisecond spikes about once a minute on Azurenot 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 localhosthost-local channels use kernel socketsexpected; 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.

pagefor
Kubernetes HFT tuningstart here: the settings table, the measured runs, copy-paste blocks, three checks
AWS and Azure hosts, x86 and Gravitonhow to apply each node setting on a VM or node
Kuberneteswhole cores, no CPU quota, SMT off, the node bootstrap; the EKS manifests
Benchmarks: x86 and Gravitonwhich platform: latency and cost per hour, side by side
Java for Archive and Clusterthe JVM and Aeron settings for Archive and Cluster
Latency podsthe five grants a normal pod cannot make
Shared CPUsk8s/nri-shared-cpus: exclusive CPUs plus the node’s shared ones
Clockswhich 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:

termwhat it ispros / cons, when it matters
C-statehow deeply an idle CPU sleepsdeeper saves power but wakes later. A spinning core never idles; capping is mixed (C-states)
timer tick, RCU callbackskernel work on every CPU: a regular timer interrupt; deferred clean-upisolcpus keeps tasks off, nohz_full stops the tick, rcu_nocbs moves the callbacks (isolation)
interrupt affinity, irqbalancethe CPUs an interrupt may run on; irqbalance is a daemon that moves interrupts aroundstop irqbalance, pin interrupts to housekeeping, pin again after every NIC reset
IOMMU passthroughIOMMU: the unit that checks which memory a device may reach. Passthrough (iommu=pt; Graviton iommu.passthrough=1) skips it for the host’s own driverslets igb_uio work on metal; harmless on a VM
NUMA nodea group of cores and the memory nearest themkeep a pod’s CPUs and devices on one; every lab host had only one
KSM, NUMA balancingkernel background work: merging identical pages; moving pages nearer the CPU that uses themKSM 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 testspin on every CPU for two minutes; note any that stall 300 µs or morea node-level check: a pod cannot choose its cores. Not measured here
-march, -mcpu, target-cpucompiler options that build for one CPU: -march, -mcpu (C), target-cpu (Rust). On AArch64 only -mcpu sets the coreGraviton: without them, gcc calls a library helper for each atomic add
clocksource=tsc, tsc=reliableboot options: the kernel keeps time with the CPU counter, and tsc=reliable turns its runtime checks offit 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, PTPthe 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
kubeletthe agent on each Kubernetes node that starts pods and gives them CPUs, memory and devicesit must be told to give whole cores
Guaranteed QoSa pod whose requests equal its limits on every resourceonly 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 resourcesa 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 perioda 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 managerkubelet policies: whole cores for Guaranteed pods; a pod’s CPUs and devices on one NUMA nodewith full-pcpus-only, no hyperthread is shared with another pod
cpusetthe CPUs a pod may run onpin each busy thread to its own CPU from it (Cpus_allowed_list)
DaemonSetone pod on every matching nodehere, one media driver per DPDK node
device plugina node service that offers devices to pods as a resourcehere, ENA pairs bound to igb_uio
taint, toleration, nodeSelectora taint keeps pods off a node unless the pod tolerates it; a nodeSelector sends a pod to labelled nodestogether: only latency pods (and the DaemonSets that must run there) land on latency nodes
LimitRange, ResourceQuotanamespace defaults and caps: a LimitRange gives every container a default CPU request and limit; a ResourceQuota caps the namespacean unlabelled pod cannot then ask for half a node (keep other pods in check)
PriorityClasshow important a pod is: a higher class preempts a lower onegive latency pods a high class and the rest a low one
hostPatha directory of the node, mounted into a podhere, the node’s hugetlbfs for Aeron’s directory
hostNetworka pod on the node’s network, not the pod network’s virtual interfacetakes a kernel-UDP pod off that interface
Archive, ClusterAeron’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
Agronathe Java library of buffers and agents that Aeron is built onits use of jdk.internal.misc.Unsafe is why the JVM needs the first --add-opens
LTSa Java release with long-term support (17, 21, 25)run Archive and Cluster on 25
JITthe JVM’s compiler: it turns hot code into machine code while the program runscode-cache pressure can undo its work (deoptimise)
GC, barrierGC: the garbage collector, which frees objects no longer used. Barrier: the extra work it adds to the program’s memory accessesParallelGC is what Aeron’s scripts use: convention, not a measured collector A/B
safepointa pause in which every Java thread stopsevery one is logged with its cause to tmpfs (-Xlog:gc*,safepoint, Java)
duty cycleone pass of an agent’s work loop*.cycle.threshold=1ms counts each one over 1 ms, so stalls show
spya subscription that reads a publication on its own host, from its log buffer, not the networksee aeron.spies.simulate.connection (Java)
sparse file, pre-touchsparse: disk pages appear only on first write. Pre-touch: every page is faulted in at startno 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

settingwhat it doeswhereevidenceapply
CPU isolationkeeps tasks, the timer tick, RCU callbacks and new interrupts off the busy CPUsnodemeasured: isolationboot 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 resetthe interrupt handler does not run on the loop’s CPUnodemeasured: interrupt placementhosts; EKS: node/irq-affinity.sh watch
ENA moderation off: adaptive-rx off rx-usecs 0one interrupt per packet; no adaptive-moderation work on the polling CPUnodemeasured, x86, polling backend: moderationethtool -C <if> adaptive-rx off rx-usecs 0, again after every NIC reset
SMT off (x86)one Kubernetes CPU is one physical corenodemeasured, one case (SQPOLL on the loop’s sibling): SMT siblingEC2 CpuOptions ThreadsPerCore: 1, or nosmt (SMT off)
static CPU manager, full-pcpus-onlyGuaranteed whole-CPU containers get CPUs of their ownnodeKubernetes CPU management; not measured herekubelet block
reservedSystemCPUs = the housekeeping CPUssystem daemons stay off the exclusive CPUsnodesame Kubernetes page; not measured herekubelet block
no CPU quota on a busy containerthe kernel never stops the container mid-periodnode + podsched-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 cputhe only pods the static CPU manager gives exclusive CPUspodKubernetes CPU management page; not measured herepod block
each busy thread pinned to one CPU of the pod’s cpusetthe thread stays on its exclusive CPUpodthe isolation runs pinned every loop; pinning alone is not measuredsched_setaffinity from Cpus_allowed_list (io: Kubernetes)
uring-sqpoll: the SQPOLL thread on its own CPUthe kernel’s polling thread does not share the loop’s corepodmeasured at 10 and 100 connections: SMT siblingergon-io picks a CPU of the pod’s own outside the shared CPUs (shared CPUs)
transparent hugepages neverno khugepaged merging under busy threadsnodetranshuge; not measured heretransparent_hugepage=never on the boot line
Aeron directory on a sized 2 MiB hugetlbfs, AERON_FILE_PAGE_SIZE=2097152, AERON_TERM_BUFFER_SPARSE_FILE=falseno 4 KiB page faults on the log buffersnode (pool, mount) + driver podhugetlbpage; 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=truea client faults its mapped terms in at startpod (client)aeronc.h:170 (rusteron-client 0.2.11); not measured herepod block
net.core.rmem_max, wmem_max ≥ Aeron’s socket bufferslets the kernel grant AERON_SOCKET_SO_RCVBUFnodefunctional, 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 itsysctl -w net.core.rmem_max=16777216 net.core.wmem_max=16777216
automatic NUMA balancing offno balancing page faults on pinned threadsnodenot measured here (every lab host had one NUMA node)sysctl -w kernel.numa_balancing=0
topology manager single-numa-nodea pod’s CPUs and devices on one NUMA nodenodeKubernetes topology manager docs; not measured here (one-node lab hosts)kubelet block
C-states capped at C1 (x86)an idle housekeeping core wakes fasternodeVM: measured, mixed (C-states); metal: no gain (hosts). Not recommended on this evidenceleave unset (the EKS node sets no cap)
SCHED_FIFO on the loopreal-time prioritypodmeasured, mixed: SCHED_FIFO. Off by defaultergon-cpus --fifo (latency pods)
ENA receive ring rx 4096room for burstsnodenot measured hereethtool -G <if> rx 4096
global busy polling (net.core.busy_read, busy_poll)every socket read on the host spinsnodeergon-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 testfinds CPUs the platform stallsnodenot 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 coresrun it on new nodes; cordon or replace noisy ones
no exec probes, no restartPolicy: Always sidecars on hot podsno probe process lands on the loop’s CPUpodan exec starts on any CPU of the cpuset (k8s/nri-shared-cpus/README.md, Limits); not measured herehttpGet/tcpSocket/grpc probes; the admission policy enforces it
both hosts in one cluster placement groupshorter network path between the pairnode (cloud)AWS docs; not measured herethe node group’s placement group
build for the CPU (-mcpu, target-cpu)Graviton: inline atomics instead of gcc’s outline helpersbuildnot measured herehosts
chrony on a PTP clockCLOCK_REALTIME stays close to UTCnodevendor 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”.

hostboot linegaps >5 µs /min>20 µs /min>100 µs /minlongest gap (max)
AWS c7inone60,120120.034.5146.95
AWS c7iisolcpus=managed_irq,domain + irqaffinity60,063.54.00.052.30
AWS c7i+ nohz_full, rcu_nocbs50.04.51.0133.90
Azurenone17,189120.03.0612.94
Azureisolcpus=managed_irq,domain + irqaffinity10,65647.00.058.94
Azure+ nohz_full, rcu_nocbs239.537.00.067.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.

connectionsqueue IRQs on the loop’s CPU: p99 / maxon a core of their own: p99 / max
145.87–47.17 / 134.98–161.3046.97–48.37 / 107.72–145.15
4–6452.31–86.46 / 128.56–640.7549.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:

armp50p99max
baseline, rounds 1 / 23.42 / 2.867.87 / 8.0536.31 / 47.96
IRQs on the loop’s CPU, rounds 1 / 26.49 / 4.6826.09 / 20.1198.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.

armWebSocket p50p99.99maxHTTP p99.99max
baseline43.90 / 40.65118.85 / 116.531,023.80 / 259.23118.88 / 77.06445.88 / 432.70
adaptive-rx off40.88 / 40.9371.89 / 76.02354.05 / 970.07116.06 / 83.44980.65 / 201.50
adaptive-rx off rx-usecs 041.90 / 41.5578.50 / 80.41233.02 / 343.6880.99 / 73.78201.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.

connectionsown core: p50 / p99 / maxloop’s sibling: p50 / p99 / max
13.46–3.79 / 8.42–12.92 / 31.57–118.344.01–4.30 / 6.99–11.69 / 34.72–45.83
105.18–6.04 / 20.41–23.81 / 77.03–109.427.53–8.37 / 29.46–32.77 / 95.85–349.01
1005.00–5.28 / 20.56–21.98 / 76.01–301.087.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.

armWebSocket p50p99.99maxHTTP p99.99max
baseline36.92 / 37.69115.88 / 104.24454.17 / 967.90112.84 / 106.211,009.61 / 1,049.79
C1 cap39.86 / 39.78133.30 / 128.17464.79 / 1,035.66106.05 / 90.25492.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.

armWebSocket p99.99maxHTTP p99.99maxloop CPU timer interrupts /s
baseline118.85 / 116.531,023.80 / 259.23118.88 / 77.06445.88 / 432.7011–23
SCHED_FIFO119.81 / 73.87579.59 / 1,008.9275.62 / 75.47982.27 / 229.95533–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

pagefor
Kubernetesthe EKS manifests, No CPU quota, SMT off, keeping other pods off the node
Hoststhe same settings on a VM, and how to apply each
Benchmarksplatform and cost side by side
Shared CPUshelper threads on the node’s housekeeping CPUs (k8s/nri-shared-cpus)
Latency podshugepages, 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

platformmeasured onnotes
AWS x86c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.metal-24xlmetal 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 Gravitonc8g.xlarge, c8g.2xlarge (Graviton4), c9g.xlarge, c9g.2xlarge (Graviton5)no SMT: a vCPU is a core
Azure x86Standard_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).

platformalso addwhy
AWS x86, bare metaliommu=ptigb_uio (DPDK) does not program the IOMMU; a VM has none
AWS Graviton, bare metaliommu.passthrough=1the same, on Arm
EKS DPDK node (x86)nosmt transparent_hugepage=never hugepagesz=2M hugepages=<n>; no C-state capnode/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).

settinghowevidence
every movable interrupt on housekeeping, irqbalance stopped/proc/irq/*/smp_affinity_list, /proc/irq/default_smp_affinity; systemctl disable --now irqbalanceinterrupt placement
NIC queue interrupts pinned again after every NIC resetENA 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 offethtool -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 pinsmoderation, x86 polling backend only
receive ringethtool -G <if> rx 4096not measured here
global busy pollingnet.core.busy_read, busy_pollit 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.

hostsettingevidence
x86 VMleave itcapping at C1 was mixed: C-states
x86 metalleave itc7i.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)
Gravitonnothing to setthe OS has no C-states to pick: an idle core waits for an interrupt (WFI)

Memory

settinghowevidence
transparent hugepages offtransparent_hugepage=never on the boot line, or never into /sys/kernel/mm/transparent_hugepage/enabledtranshuge; not measured here
2 MiB hugepage poolhugepagesz=2M hugepages=<n> on the boot line, or vm.nr_hugepagesDPDK and Aeron’s hugetlbfs draw from it
Aeron’s directory on a sized hugetlbfsmount -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=falsewithout size= Aeron sees no free space and refuses every log buffer (hft)
net.core.rmem_max, wmem_max16 MiBneeded only when Aeron’s socket buffers are raised above the kernel’s limit (hft)
automatic NUMA balancing offsysctl -w kernel.numa_balancing=0not 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:

corex86 (SMT off)Graviton (no SMT)
housekeeping: interrupts, Aeron’s conductor, the EAL’s main thread, client conductorsCPU 0CPU 0 (CPUs 0–1 on 8 cores for ergon-io)
application, busy-spinningone CPUone CPU
media driver senderone CPUone CPU
media driver receiverone CPUone 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_list still 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=native unless RUSTERON_C_MARCH names 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) or native; C with CFLAGS=-mcpu=.... Without it, Ubuntu’s and Debian’s gcc call a library helper for each atomic (gcc’s -moutline-atomics default on AArch64). On AArch64 -march sets only the architecture; -mcpu sets 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

needreadwhy
a timestamp written anywhere: the wire, a log, storageCLOCK_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 processquanta: 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 pathstd::time::InstantCLOCK_MONOTONIC: never goes back
the DPDK transport’s stampsits own clocknanoseconds 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.

readc7i.2xlargec8g.xlargec9g.2xlarge
quanta Clock::now() as ns15.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 ns28.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_REALTIME stamps for a duration: chrony or an admin can step the clock back.
  • Build the quanta Clock before the loop. Its first calibration busy-waits (quanta’s limit: 200 ms, src/lib.rs:198), and quanta::Instant::now() calibrates a global clock on first use.
  • Do not stamp with a *_COARSE clock: it moves once a kernel tick (1–10 ms, by CONFIG_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 APILinux sourcereturns
aeron_nano_clock()CLOCK_MONOTONICns since boot
aeron_epoch_clock()CLOCK_REALTIME_COARSEms since the Unix epoch, one kernel tick resolution
aeron_clock_cache_tboth, cachedas 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 3
    

    For 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: devlink enable_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 3
    

    The service smears leap seconds: remove any leapseclist or leapsectz line.

  • 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 bootstrapyes: Amazon Linux 2023, self-managed nodesnone: the bootstrap refuses non-Intel CPUs and the images are linux/amd64
measuredno 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):

  1. SMT off on x86, so one Kubernetes CPU is one physical core, the same as on Graviton (SMT off).
  2. cpuManagerPolicy: static with full-pcpus-only: "true": Guaranteed pods get whole cores of their own.
  3. reservedSystemCPUs: the housekeeping cores, the same ones the kernel’s boot line leaves out of isolation.
  4. topologyManagerPolicy: single-numa-node: a pod’s CPUs and devices on one NUMA node.
  5. No CPU quota on the latency containers: kubelet 1.33 or later (the labs ran 1.36), cpuCFSQuota left at its default true. Do not set cpuCFSQuota: false: it removes every pod’s CPU limit on the node (why).
  6. The host settings under the kubelet: isolated cores, interrupts on housekeeping, transparent hugepages off, hugepages (hosts).

Pod:

  1. Guaranteed QoS: requests equal limits for every resource, in every container.
  2. A whole-number CPU request on every busy-spinning container. With SMT off, one CPU is one core.
  3. Each busy thread pinned to its own core from the pod’s cpuset, under SCHED_OTHER (SCHED_FIFO was mixed: evidence).
  4. On CRI-O only: the annotations in No CPU quota. containerd (EKS, AKS, k3s) ignores them.

Check:

  1. 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_quota before it.
  2. While it runs: nr_throttled stays flat (verify). In your own loop, call ergon-tune’s ThrottleWatch::check from 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.
  • kubeReservedCgroup and systemReservedCgroup are emptied because kubelet rejects reservedSystemCPUs while they are set.
  • memoryManagerPolicy: None: the deployed c7i.4xlarge has one NUMA node (node/userdata.sh). On a multi-socket type, revisit it.
  • cpuCFSQuota is not set: it stays true, 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):

scriptwhenwhat it does
node/provision-node.shonce, then one reboot before kubeletkernel 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.shevery boot, before kubeletinterrupts 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.

wherehow
EC2, virtual machinethe 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-managedthe 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)
Azurea size without SMT: Fasv6, Falsv6, Famsv6 (AMD) or Dpsv6, Dplsv6 (Cobalt)
self-managed, k3snosmt on the kernel command line
  • With ThreadsPerCore: 1 the instance runs half its usual vCPU count (a c7i.4xlarge: 8 cores, 8 vCPUs, not 16). Size pods and quotas for cores.
  • thread_siblings_list still lists the offline twin. Name one CPU per core (the lowest id) in reservedSystemCPUs and 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/active is 1 (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_list in /proc/self/status; in a privileged container /sys/fs/cgroup/cpuset.cpus.effective lists 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 a hostPath, and AERON_CLIENT_PRE_TOUCH_MAPPED_MEMORY=true on 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: true takes 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 LimitRange per namespace with a default CPU request and limit for containers, and a ResourceQuota for 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.local shows it.

What the kubelet writes:

casecontainer cpu.maxpod cpu.max
default (cpuCFSQuota: true), no exclusive CPUslimit × period: 200000 100000 for 2 CPUsthe sum of the containers’ limits
kubelet 1.33+, a container with exclusive CPUsmaxmax when any container has exclusive CPUs
a sidecar with a fractional CPU in that podstill capped: 50000 100000max
cpuCFSQuota: false (the whole node: do not)maxmax
  • 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_throttled and throttled_usec stay flat while the pod runs. Read them twice, a minute apart.
  • In Prometheus, cAdvisor’s container_cpu_cfs_throttled_periods_total stays flat.
  • ergon-tune: T9 runs at start in ergon-io and the media driver. ThrottleWatch is 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.

  1. Prepare the node. Node setup below. Label it ergon.io/shared-cpus=true.
  2. Install. config.sharedCpus is the housekeeping CPU, inside reservedSystemCPUs, and not the IRQ CPU. For the 8-CPU split in that section the value is 0. 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.

  1. 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.

  1. 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_CPUS is the housekeeping list, ERGON_ISOLATED_CPUS is the exclusive list, PID 1 Cpus_allowed_list is the shared CPUs only, and cpuset.cpus.effective is 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

itemvalue
exclusive CPUsthe kubelet’s static CPU manager, cpu: N
shared CPUsergon.io/shared-millicpus: M adds ceil(M/1000) shared CPUs
start affinityPID 1 and every new thread start on the shared CPUs
timer slack1 ns on every thread, inherited by every thread and child the program makes
pinningthe 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.

settingvalue
Kubernetes>= 1.33: no CPU quota on exclusive containers (the labs ran 1.36)
kubeletcpuManagerPolicy: 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 labelergon.io/shared-cpus=true

CPU split. Pick three CPU sets on the node and write them down; every later step names one of them.

setwhoexample (8 CPUs)
exclusivethe pod’s busy-spin loops2,3 (and 4,5 for a second pod)
sharedhelpers, kubelet, containerd, system0
IRQNIC interrupts1

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.

whataddress
imageghcr.io/mimran1980/ergon-shared-cpus:0.1.0 (and :latest)
digest of 0.1.0 and latestsha256:d28fea2f95ebce3e53d33f8770f3ba69be75c76ec7437ea2b92c1329759e07e9
chartoci://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:

  • sharedCpus must sit inside the kubelet’s reservedSystemCPUs, minus the IRQ CPU(s). In the table above that is 0.
  • namespaces is 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.
  • off is not harmless. It is a refusal. A pod whose annotation asks for a grant the ConfigMap does not allow does not start: the kubelet shows CreateContainerError and 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.

fielddefaultwhat it is
sharedCpusrequiredthe housekeeping CPUs, kernel cpulist ("4" or "0,2"). Inside reservedSystemCPUs, never the IRQ CPU(s)
oversubscription11–100. Capacity is len(sharedCpus) x 1000 x this; the max is 100,000 device IDs (the kubelet’s 4 MiB gRPC limit)
namespacesemptythe allowlist. Empty refuses every opt-in. Each must be a valid namespace name
defaultAffinitysharedshared 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-cpuswhere 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
annotationOptInfalseallow the pod annotation cpu-shared.ergon.io/<container> as a fallback when the device plugin is not used
metricsAddress:9466serves /metrics, /healthz and /readyz
rlimits.memlock, .nofile, .rtPrio, .niceunsetthe 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:)offallow widens the container’s seccomp profile for io_uring_*. See Latency Pods
seccompProfilePath/etc/ergon-shared-cpus/ergon-io-uring.jsonthe shipped io_uring profile on the node, the fallback for a pod that cannot take the NRI grant
devRoot/devwhere 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/cgroupthe 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
schedFifooffoff, or thread (the hot thread only, never the container). Quote the value: unquoted off is a YAML boolean
ptpoffoff, auto, or a /dev/ptp* path the node has. auto grants the NIC’s PHC
hugepagesAeronDirunsetabsolute 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:

itemvalue
cpuset.cpus.effectivethe exclusive CPUs plus the shared ones
affinity of PID 1 and of every new threadthe shared CPUs only
nproc, GOMAXPROCS, availableProcessors()usually the shared CPUs: they count the affinity mask

Env vars

namevalueformat
ERGON_ISOLATED_CPUSthe exclusive CPUssorted comma list, no ranges: 1,5
ERGON_SHARED_CPUSthis container’s shared CPUssame
ERGON_ISOLATED_CORESthe exclusive CPUs by physical coregroups by ;, CPUs by ,: 1,5;2,6
ERGON_SHARED_MILLICPUSMdecimal
ERGON_HELPER_CPUSsame as ERGON_SHARED_CPUScomma list
OPENSHIFT_ISOLATED_CPUSsame as ERGON_ISOLATED_CPUSkernel cpulist with ranges
OPENSHIFT_SHARED_CPUSsame as ERGON_SHARED_CPUSsame

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 on ERGON_SHARED_CPUS;
  • never a spinner or SQPOLL on a shared CPU. ergon-io keeps this: with no sqpoll_cpu, uring-sqpoll takes a CPU of the pod’s own outside ERGON_HELPER_CPUS and 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,5 is 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:

pieceeffect
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:0sender on exclusive index 0
AERON_RECEIVER_CPU_AFFINITY=isolated:1receiver on exclusive index 1
AERON_CONDUCTOR_CPU_AFFINITY unsetthe conductor stays on the shared CPU
AERON_THREADING_MODE=DEDICATEDsender, receiver and conductor on their own threads
hugepages.ergon.io/app: "2M" + hugepages-2Mia sized hugetlbfs mounted at /dev/hugepages
AERON_DIR on that hugetlbfsthe 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 lineCPUs 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:

variableagenthere
AERON_SENDER_CPU_AFFINITYsenderisolated:0
AERON_RECEIVER_CPU_AFFINITYreceiverisolated:1
AERON_CONDUCTOR_CPU_AFFINITYconductorleave unset: it starts on the shared CPUs
AERON_DRIVER_NATIVE_RESOURCE_AGENT_CPU_AFFINITYnative resource agentleave unset
AERON_DRIVER_CPUSET_AFFINITYmakes the four above indexes into the cgroup cpusetkeep 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

eventeffectguard
plugin down at createNRI makes no changerequired-plugins.noderesource.dev makes the create fail; the wrapper refuses without ERGON_SHARED_CPUS; the resource does not allocate
kubelet restart, plugin upthe re-write keeps the unionthe plugin re-widens in UpdateContainer
kubelet restart, plugin downcpuset shrinks to the exclusive CPUs; helpers fall onto themthe plugin re-widens on return; helpers self-check meanwhile
hook binary missingthe create failsthe 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-plugins and /opt/ergon/bin read-write, the host’s /dev at /host/dev and /sys/fs/cgroup read-only. No hostPID, no hostNetwork, not privileged, capabilities.drop: [ALL], readOnlyRootFilesystem: true, seccomp RuntimeDefault, 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_cpuset instead.
  • Bottlerocket: whether runc may exec a hook binary that a pod wrote to a host path is not verified. Use defaultAffinity: all and the wrapper until checked.
  • kubectl exec and exec probes start on any CPU of the cpuset, exclusive ones included. No exec probes on hot containers; run debug commands as ergon-cpus -- <cmd>.
  • In-place CPU resize leaves the CPU env stale. Use resizePolicy: RestartContainer for cpu.
  • Not measured / not verified: the latency cost of helpers on shared CPUs, and a real EKS node (the NRI socket, nodeadm TOML merge, /opt writable).

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.

#extensionmechanismpod opt-inenv the app sees
1sized hugetlbfs for AERON_DIRNRI AddMount type: hugetlbfs with pagesize=2M,size=N, plus AddLinuxHugepageLimit; N is the container’s hugepages-2Mi limithugepages.ergon.io/<ctr>: "2M" and a hugepages-2Mi resource (requests = limits)ERGON_HUGEPAGES_DIR, ERGON_HUGEPAGE_SIZE
2cpuset.cpus.partition=isolated on the exclusive CPUsa 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
3io_uring seccomp profile(a) NRI SetLinuxSeccompPolicy: the request’s profile plus the io_uring_* allow rules; (b) fallback, pod field seccompProfile: Localhostseccomp.ergon.io/<ctr>: "io-uring" (or the Localhost fallback)ERGON_SECCOMP
4no exec probes, no always-restart sidecarsValidatingAdmissionPolicy (CEL, GA 1.30)pod label ergon.io/latency: "hot"—
5device grantsNRI AddDevice, plus a read-only bind of the node facts fileptp.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 shapehow the exclusive CPUs are used
one process whose threads all belong on the exclusive CPUswrite the pid to $ERGON_ISOLATED_CGROUP/cgroup.procs, then pin each thread to ERGON_ISOLATED_CPUS
hot threads in a multi-threaded processwrite 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:

pathwhat it doesdefault
ergon-cpus --fifo <prio>the exec wrapper takes SCHED_FIFO on the thread before it execs the commandoff (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 onlyschedFifo: "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

itemwhy
SR-IOV VF per podNitro guests have one ENA per ENI; there are no in-guest VFs
a netns-sysctl writer pretending the knobs are per-netnsbusy_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 CPUsIRQs 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:

settingvalue
Kubernetes>= 1.33 (the labs ran 1.36)
kubeletcpuManagerPolicy: static; reservedSystemCPUs = the IRQ CPU(s) plus the shared CPUs; x86 full-pcpus-only
strict-cpu-reservationoff (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_hugepagenever
net.core.rmem_max / wmem_maxat least Aeron’s AERON_SOCKET_SO_RCVBUF / SNDBUF when raised (hft)
net.core.busy_poll / busy_readunset (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.

labelwherewhat reads it
pod ergon.io/latency: "hot"every latency podthe validating policy (extension 4)
namespace ergon.io/latency: "true"the latency namespacethe mutating policy (it reads namespaceObject)

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

findingscopeKubernetes
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/sc9g.2xlarge, c7i.4xlarge, c7i.metal-24xl, one run eachDPDK 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 Bc9g.2xlarge, G1; on a second pair (G2) this arm lost in 1 of 5 repsnode-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 channelsc7i.4xlarge; c9g.2xlarge reached at most 685,574 msgs/s at 1,344 Bapplication config, in the pod
Not compared: Graviton against x86, metal against a VM, interrupts against busy pollingdifferent 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.

pairphysical cores eachon demand, $/hspot, $/hcommitted run
c7i.2xlarge4 (8 vCPUs)0.7140.400aws-20261014-160247 (1 rep)
c7i.4xlarge8 (16 vCPUs)1.428not recordedrounds 6–7, EKS
c7i.metal-24xl48 (96 vCPUs)8.568not recordedround 4
c8g.xlarge40.3190.154clock runs (clockbench-aws-20261016.txt)
c9g.xlarge40.348not recordednone
c9g.2xlarge80.6960.306G1–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.

aggregationwhat one value ishow it is summarised
batch throughputmessages or operations completed in a timed windowthe window’s rate. md-dpdk: the median one-second window per rep (tput.rs:129-135), then the median across reps
individual latencyone round trip or one callpercentiles of the individuals, and the absolute maximum
UTC accuracyone stamp’s error against the wall clockpercentiles of the errors, with the time-sync source named
median of repetition maximathe worst value in one rep, then the middle repa summary of the reps’ worst, not a worst case
absolute observed maximumthe single worst value in any repone 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

UseJavaWhy
Run Archive and Cluster25: 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 works17Aeron compiles its classes with --release 17.
Newest GA release, 27Runs, but not recommended27 is not an LTS release and is not in Aeron’s test matrix.
Build Aeron’s jars from sourceJDK 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

OptionWhySource
--add-opens java.base/jdk.internal.misc=ALL-UNNAMEDRequired. 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-UNNAMEDRequired once Archive checksums are on (aeron.archive.record.checksum); without it they throw InaccessibleObjectException.Aeron’s scripts and build.gradle
-XX:+UseParallelGCArchive, 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:+AlwaysPreTouchThe heap never grows, and every page is faulted in at start rather than on the hot path.benchmarks
-XX:MaxMetaspaceSize=1g, -XX:ReservedCodeCacheSize=1gCode-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=trueThe Java driver uses IPv4 UDP sockets.benchmarks, wiki, build.gradle
-Dagrona.disable.bounds.checks=trueTurns 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

PropertyDefaultWhy
aeron.archive.idle.strategy=spin, aeron.archive.recorder.idle.strategy=noop, aeron.archive.replayer.idle.strategy=noopbackoffThe Archive’s conductor, recorder and replayer never sleep. Each one needs an isolated core.
aeron.cluster.idle.strategy=noopbackoffThe consensus module and the clustered service both read this property.
aeron.term.buffer.sparse.file=false, aeron.pre.touch.mapped.memory=truesparse, no pre-touchThe 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=newclassicGives 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 = 1ms100 msCounts every duty cycle longer than 1 ms, so stalls are visible.

These settings are left at their defaults:

  • aeron.archive.file.sync.level and aeron.archive.catalog.file.sync.level stay at 0: writes go to the OS page cache only. 1 adds fdatasync and 2 adds fsync. Keep the catalog’s level at least the archive’s. Level 0 survives 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, so 0 is 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.length stays at 1m.
  • aeron.archive.control.term.buffer.sparse stays at true.
  • aeron.spies.simulate.connection stays false. It is a media driver setting, which Aeron’s benchmarks set to true on their driver. It matters when an Archive records a network publication by spying and nothing else subscribes: with false the 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 -Xms equal to -Xmx, and size -XX:MaxDirectMemorySize, -XX:MaxMetaspaceSize and -XX:ReservedCodeCacheSize. The container’s limit must cover all of them, plus thread stacks and native memory.
  • Leave aeron.pre.touch.mapped.memory off. 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.Unsafe deprecation (JEP 471, JEP 498) and --sun-misc-unsafe-memory-access do not apply. Agrona uses jdk.internal.misc.Unsafe, which is why the first --add-opens is 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:+PerfDisableSharedMem stops the JVM writing its hsperfdata file under /tmp, at the cost that jps and jstat no 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 with aeron.file.page.size on 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

Contributing

Changes must be reproducible, wire-compatible and honest about what was verified.

Work from behaviour

For a generator or protocol change:

  1. Add a focused behavioural, compile-fail, wire-parity or allocation test.
  2. Run it and see it fail.
  3. Make the smallest coherent change.
  4. Run the focused test and the affected crate’s full checks.
  5. 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

CommandWhat it runs
just policytest-lane ownership and the suppression rules below, plus their self-tests
just preflightfmt, clippy, policy self-tests, book fences, golden and reference --check
just check-productsergo-sbe and ergo-aeron-cluster only
just check-localrepository hygiene, fmt, clippy, the tests that need no Java, and the exchange-example sample
just testthe full suite: builds the Aeron Java jars and runs the Java cluster lifecycle, recovery and HA lanes. A missing dependency fails
just test-alljust test plus Miri and fuzz replay
just check-coveragecoverage against the checked-in baseline
just check-mutationmutation 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:

PatternWhere 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-ignoredjustfile, just/*.just, scripts/*.sh, workflows
if … cargo test/benchthe same control files
cargo test … || true, ; exit 0the 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

WorkflowWhat it runs
ci.ymlpolicy; 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.ymlthe cluster harness against Aeron 1.46.8 on Java 21, when cluster/ or sbe/src/ changes
nightly.ymlweekly (Sunday): every fuzz target for 600 s, and the fixture crate under Miri
release.ymljust 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 ? to unwrap or expect.
  • 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, cluster or samples directories.
  • 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/quote generator implementation. The observable output is constrained by extensive tests, live byte-for-byte comparisons with official sbe-tool Rust 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:

QuestionShort 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.

ModelInputOutputCache CreateCache ReadReasoning OutputTotal TokensCost (USD)
claude-fable-5403,4851,349,25412,230,126487,557,778—501,540,643$802.28
claude-haiku-4-5-2025100193631,021203,27910,487,325—10,722,561$1.56
claude-opus-4-8189,029553,0254,804,41597,619,868—103,166,337$99.09
claude-opus-513,757,9614,705,88636,674,5203,652,158,264—3,707,296,631$2,360.26
claude-opus-5-5220,976,251113,604,569593,122,78327,123,459,935—28,051,163,538$11,887.75
claude-sonnet-4-61,406233,671986,40034,920,711—36,142,188$19.90
claude-sonnet-588,9902,988,39431,267,0912,340,454,308—2,374,798,783$622.06
claude-sonnet-5-511,5125,102,01532,656,109834,727,182—872,496,818$221.22
deepseek-flash8,991,7135,070,512—3,202,997,632—3,217,059,857$28.00
deepseek-v4-flash52,791,36717,446,045—2,734,161,536—2,802,078,889$35.00
deepseek-v4-pro85,359,08721,733,136—24,546,493,056—24,653,585,279$551.30
glm-4.76,949,701760,380—268,436,096—276,146,177$35.37
glm-5.227,277,6183,460,625—2,274,775,808—2,305,514,051$644.86
gpt-5.57,380,642366,392—69,651,456105,36877,503,858$92.47
gpt-5.6-luna1,754,305188,525—54,281,984105,11656,329,930$36.18
gpt-5.6-sol60,151,7835,239,880—1,520,516,2242,617,7221,588,525,609$1,011.70
gpt-5.6-terra676,00534,650—7,455,23216,2298,182,116$4.33
gpt-6-astra9,061,594681,360—140,212,992217,983150,173,929$236.08
gpt-6.1-sol18,651,7282,136,766—522,706,816976,793544,472,103$146.59
grok-4.5-build10,030,758881,349—177,071,360—187,983,467$114.67
grok-4.6-build44,849,8923,100,656—812,446,592—860,397,140$293.80
grok-4.7-build39,298,8415,592,936—621,799,168—666,690,945$166.51
mimo-v2.6-pro62,028,5001,093,150—1,875,607,680—1,938,729,330$34.69
gpt-5.3-codex22,573,8842,873,034—786,800,5121,263,964813,511,394$217.63
gpt-5-codex9,351,684988,119—204,395,904689,664215,425,371$45.08
gpt-5.1-codex-max2,116,543122,477—45,743,36046,97648,029,356$11.63
gpt-5.41,517,76354,121—14,817,28024,11016,413,274$8.09
gpt-5701,63779,205—8,801,92051,7769,634,538$2.77
mimo-v2.6-flash359,72315,224—1,104,448—1,479,395$0.06
gpt-5.4-mini122,1024,288—434,5602,399563,349$0.20
Total707,426,440200,490,665711,944,72374,472,096,9876,118,10076,095,756,856$19,731.14

Notes:

  • The other machine’s baseline is a high-water mark, not a single run’s output. ccusage keeps no ledger: it recomputes totals by re-reading local session transcripts, and Claude Code deletes transcripts older than cleanupPeriodDays. A --since 20260628 query 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 ccusage on 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-sol and grok-4.7-build. claude-opus-5, claude-sonnet-5, deepseek-v4-pro, gpt-6-astra and grok-4.6-build set new total maxima. Every other model’s maximum is held by an earlier snapshot; grok-4.5-build appears 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 observer sample, and its usage of those tools is still absent. This MacBook’s ccusage antigravity reports 290,048,735 tokens and $83.86 (2026-06-20 to 2026-07-05). ccusage gemini reports 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 totalTokens omits reasoning. The day’s cost was split on that totalTokens. 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, ccusage 20.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 of gpt-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.

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.

CategoryTokensShare of 76,098,076,915
Uncached input707,426,4400.93%
Output200,490,6650.26%
Reasoning output6,118,1000.01%
Cache writes711,944,7230.94%
Cache reads74,472,096,98797.86%
Column sum76,098,076,915100%

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×.

ModelInput $/MTok5m write $/MTokCache hit $/MTokOutput $/MTokTotal
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:

ModelUncached inputCache writesCache readsOutput incl. reasoningTotal
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

  1. 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.

  2. 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.

  3. 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:

Contents

  1. Cumulative token usage (since 2026-06-28)
  2. What-if: all tokens through a single provider
  3. Why this page exists
  4. What I was trying to build
  5. Why I chose it as an AI experiment
  6. Authorship
  7. What I reviewed—and what I did not
  8. The actual working loop
  9. How the project evolved
  10. What the Git history shows
  11. What did not work
  12. Verification
  13. Performance
  14. Unsafe code and the trust boundary
  15. Tools and models
  16. Long context, O(n²), and caching
  17. Observed usage and spend
  18. Pay-as-you-go comparison
  19. A practical playbook
  20. The personal experience
  21. Why the crate is experimental
  22. What production users should verify
  23. AI-assisted contributions
  24. 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:

  1. a domain expert who has encountered the protocol’s failure modes for years;
  2. 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:

AreaMy roleCoding-agent role
Problem and productChose the problem from practical SBE experienceNone
Domain behaviourDefined the required SBE behaviour and corrected misunderstandingsImplemented the behaviour described
API designSpecified the desired properties, inspected generated APIs, rejected awkward designsProposed Rust representations and iterated on them
GeneratorSpecified and reviewed generated output; did not deeply review every generator pathWrote nearly all syn/quote implementation
TestsChose important scenarios, inspected tests, ported the verification strategy from official SBEWrote and ran most test code
PerformanceRequired equal-work parity, personally ran and inspected benchmarks, rejected regressionsWrote harnesses, ran them repeatedly, diagnosed and revised regressions
Documentation and samplesSupplied the intent and judged whether examples represented a usable APIDrafted most prose and sample code
Release decisionsDecided what was acceptable and retained the experimental warningProvided 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 bool and decimal values where direct wire access was unnecessary.
  • Checked constructors with private zero-check cores. I wanted fallible wrap / decode for every untrusted buffer, and a measured private hot-path core only after extent proof (public *_unchecked twins 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-tool output;
  • 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:

  1. Output review. Let the agent finish a small change, generate codecs, run checks, and then inspect the resulting API and behaviour.
  2. 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:

  1. I describe one concrete behaviour or API improvement.
  2. The agent adds or updates focused tests.
  3. The agent changes the generator.
  4. It regenerates and compiles the Rust output.
  5. It runs relevant unit and parity tests.
  6. For hot-path changes, it reruns the benchmark gate.
  7. I inspect the generated API, bytes, failures, and benchmark result.
  8. 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-tool Rust 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_request encode at 1.19× and new_leader_event decode 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 unchecked wrap(), 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:

  1. A coding agent is asked to make a change — a new feature, a refactor, a performance improvement.
  2. It runs cargo test and sees a failure.
  3. 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.
  4. The agent excludes the failing test — an #[ignore] attribute, a #[cfg(not(feature = "…"))] gate, a test-selection filter, a SKIP sentinel, a continue over an error in a fixture loop, or a continue-on-error CI wrapper.
  5. 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 propagating Result with ?.

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-sbe and 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:

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-tool Rust 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 / decode as the checked lane — they return Result, validate extents once, then enter a private zero-check core (try_wrap* aliases are removed);
  • public constructor *_unchecked twins 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:

  1. Input cache hits: the provider recognises a previously processed prefix and charges its cache-hit rate.
  2. Input cache misses or cache writes: new or changed input must be processed and, depending on the provider, written into the prompt cache.
  3. 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

ModelInputOutputCache CreateCache ReadReasoningTotal TokensAPI Cost
claude-fable-5128,112210,9315,707,17844,132,249—50,178,470$168.76
claude-opus-4-8153,359505,2243,840,85893,893,329—98,392,770$86.22
claude-opus-51,702575,6732,454,943204,567,008—207,599,326$139.50
claude-sonnet-4-6365422,73014,116—37,503$0.15
claude-sonnet-530,556328,8546,177,826218,888,413—225,425,649$71.84
deepseek-v4-flash52,791,36715,125,98602,734,161,536—2,802,078,889$19.28
deepseek-v4-pro47,802,8229,959,142011,705,468,864—11,763,230,828$71.89
glm-4.72,620,188287,011056,403,328—59,310,527$8.41
glm-5.219,826,2482,577,59801,868,156,352—1,890,560,198$524.82
gpt-5.51,781,759117,119018,613,50431,27020,543,652$21.73
gpt-5.6-sol33,916,0583,205,1790970,537,4721,524,6741,009,183,383$758.17
Total159,052,17432,893,37118,203,53517,914,836,1711,555,94418,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:

ProviderSpendNotes
GLM coding plan$114Covers 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$30Grok API usage is not tracked by ccusage
Subscription total$184

Total identified spend

CategoryAmount
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 categorySample tokensSample share
Input cache hits2,225,207,68098.6602%
Input cache misses23,305,8481.0333%
Output6,911,5040.3064%
Total2,255,425,032100%

Scaling that exact mix to 14 billion tokens gives:

Token categoryNormalised millions of tokens
Input cache hits13,812.433168 MTok
Input cache misses / cache writes144.665359 MTok
Output42.901473 MTok
Total14,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:

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

ModelCache-hit $/MTokMiss/write $/MTokOutput $/MTokEstimated 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:

ModelPublished long-context treatmentEstimated total
DeepSeek V4 Flash1M context; same published token rates$70.94
DeepSeek V4 Pro1M context; same published token rates$150.32
Claude Sonnet 5 promotionalFull 1M at standard rates$3,553.16
GLM-5.21M context; same published token rates$3,982.53
Claude Sonnet 4.6Full 1M at standard rates$5,329.75
Claude Sonnet 5 standardFull 1M at standard rates$5,329.75
Claude Opus 4.8Full 1M at standard rates$8,882.91
Claude Opus 5Full 1M at standard rates$8,882.91
Grok 4.5Cannot accept 1M; maximum 500K. At ≥200K all rates double$9,380.94 at 500K
OpenAI GPT-5.5 mediumAbove 272K: 2× input and 1.5× output$17,189.65
OpenAI GPT-5.6 Sol mediumAbove 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:

  1. Generate codecs for your actual schemas.
  2. Encode the same logical messages with your existing official SBE tooling and ergo-sbe; compare exact bytes.
  3. Cross-decode in both directions.
  4. Include empty, maximum-sized, nested, ragged, and variable-data cases.
  5. Exercise every acting version and schema-evolution path you expect to see.
  6. Test malformed and truncated frames at the trust boundary.
  7. Verify exact buffer lengths before integrating with try_claim or another zero-copy publication API.
  8. Benchmark your real hot fields and message shapes, not only the Car example.
  9. Audit the internal unsafe assumptions relevant to your use of try_*, the panicking bare constructors, and any unsafe *_unchecked twin.
  10. 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:

  1. API freeze audit complete — decisions in API freeze are stable; no pending renames of generated stage / wrap / FixedFields surface without a major.
  2. 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.
  3. Wire compatibility — dual-encode parity tests and golden API shape remain green; no deliberate wire break without a major.
  4. Trust boundary — fuzz corpus on decode entry stays green in CI; Miri fixtures for unaligned paths stay green; no known P0 safety issues open.
  5. Docs — book chapters for migration (sbe-tool), trust boundaries, buffer sizing, and type-state design notes are published and linked from the crate README.
  6. 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-sbe major
  • 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.sh runs cargo-semver-checks on each published crate it lists (ergo-aeron-cluster only with CLUSTER_SEMVER_CHECKS=1; a crate first published after the baseline once the baseline reaches that release). scripts/check-generated-public-api.sh diffs generated codec surfaces against api/generated/*.txt.
  • Benchmark evidence: just bench + just bench-cluster write provenance-stamped Criterion trees; scripts/package-bench-artifacts.sh attaches 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.

CriterionStatus
1. API-freeze auditManifest exists; cargo-semver-checks and generated-API fixture diffs (api/generated/) are enforced.
2. Parity gateOpen. Needs three consecutive releases on main with gate output attached.
3. Wire compatibilityDual-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 boundaryFuzz + Miri fixtures exist; any open P0 is blocking.
5. DocsBook + migration pages published.
6. External signalOpen. In-repo FIX SBE is internal evidence, not an external user. See external-pilot.md.
Cluster 1.0Separate 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.

  • Schema identity as one private Ir (no duplicate public package/id/version beside ir). 0.x keeps the public fields.
  • XML deprecated as Option<u16> through Encoding, FieldInfo, and ItemContext. 0.x keeps deprecated: bool on those public types.
  • Removal of GenerationConfig::with_error_from_impls. 0.x keeps the deprecated helper; prefer a typed From<generated::sbe_rt::{EncodeError, DecodeError}> so needed/available survive ?.

Shipped on this 0.x line:

  • ergo-aeron-cluster has no test-harness feature. Repository Java launch support is ergo-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

PartRuns in CISource
Byte-identity of ergon responses against Real Logic’s goldens, suite tests 1–3yes (cargo test -p ergo-sbe)sbe/tests/fix_sbe_conformance_test.rs
Official RLValidator over ergon’s responsesno: compiled only with --cfg ergon_rlvalidator, which CI does not set. With the cfg, a missing suite failsoptional_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.

Cratecrates.io
ergo-sbegenerator
ergo-aeron-clusterCluster client
ergon-tunehost and NIC checks
ergon-iobusy-spin network I/O (WebSocket, HTTP/1.1, TLS 1.3)
ergon-md-dpdkDPDK 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.