rsx-cast

Reliable UDP whose retransmit source IS the WAL the producer writes for audit and replay.← All Crates

Description

rsx-cast Architecture

In plain terms: this is the pipe that carries every order and fill between the exchange's machines without losing one. If a packet drops, the receiver asks for it again — and the resend comes from the same on-disk log the system already keeps for replay and audit, so there is no second copy to drift out of sync. One bytestream does three jobs: live wire, disk, replay.

Domain-agnostic reliable transport. WAL writer/reader, replication TCP replay server/client, and casting (C Message Protocol) UDP sender/receiver.

Byte-exact protocol specs live in the exchange repo: 4-cast (UDP/NAK), 10-replication (TCP catch-up), 48-wal (on-disk format).

Domain-agnostic transport

rsx-cast has zero workspace dependencies. The crate carries only the framing (WalHeader), the transport-level records (heartbeat, NAK, replay request, caught-up, replication-not-available), and the [CastRecord] trait that domain payloads must implement.

$ cargo tree -p rsx-cast --edges normal | grep '^[├└]── rsx-'
(empty — no rsx- crates in normal deps)

The wider rsx exchange's domain records (FillRecord, BboRecord, OrderInsertedRecord, …) live in a separate crate that sits on top of rsx-cast; nothing in this crate depends on them. Any consumer crate can define its own #[repr(C, align(64))] records that impl CastRecord and ride the same transport.

Module layout (rsx-cast/src/)

File Purpose
header.rs 16-byte WalHeader. Version byte at offset 0 (V1 = current; V0 retired in 64dda88). Reserved bytes per layout below; _pad0, _pad1, _reserved must be zero.
records.rs CastRecord trait + protocol records (CastHeartbeat, Nak, ReplicationRequest, CaughtUpRecord, ReplicationNotAvailable). Compile-time size/align asserts on each.
encode_utils.rs Generic helpers: compute_crc32 (CRC32C / Castagnoli), as_bytes, encode_record, decode_payload<T: Copy>. No domain knowledge.
cast.rs CastSender + CastReceiver (UDP, sync). Two-tier NAK: preallocated send_ring (hot) → WAL read_record_at_seq (cold).
wal.rs WalWriter (10ms flush, 64MB rotate, 4h retention GC) + WalReader + read_record_at_seq.
replication_server.rs ReplicationService (TCP, optional TLS). Sends ReplicationNotAvailable when from_seq precedes oldest WAL seq.
replication_client.rs ReplicationConsumer (TCP replay, sync). Multi-endpoint: tries endpoints newest→oldest; advances on ReplicationNotAvailable. Backoff 1/2/4/8/30s ±20% jitter.
config.rs CastConfig, TlsConfig. Every field documents its env var.

Transport paths

  • casting/UDP (hot path): Aeron-inspired NAK recovery, but without flow control. CastSender assigns monotonic seq, sends, and caches the encoded frame in a preallocated ring. CastReceiver detects gaps from out-of-order delivery or from idle-tail heartbeat skew and sends Nak. Sender retransmits from the ring; if the seq has aged out, falls back to read_record_at_seq against the WAL. Retransmit horizon = WAL retention, not buffer size. Slow consumers do not pace the sender — receiver-side overflow drops, sender never stalls.
  • replication/TCP (cold path): ReplicationService streams historical records from WalReader then transitions to a live tail on WalWriter::add_listener notifications. ReplicationConsumer resumes from a persisted tip with backoff on disconnect.

casting sender ring (cast.rs)

Three Box<[T]> slabs, indexed by seq & SEND_RING_MASK:

ring_seqs:   Box<[u64]>   capacity 4096   slot's current seq (0 = empty)
ring_lens:   Box<[u16]>   capacity 4096   encoded frame length
ring_frames: Box<[u8]>    capacity 4096 * 128 B

Zero allocations on the hot send path. On NAK, the sender checks ring_seqs[slot] == seq (cache hit) or falls back to read_record_at_seq (cache miss). NAK counts are clamped to SEND_RING_CAPACITY so a malicious peer can't make the sender loop on u64::MAX.

The receive path has two entry points: try_recv_with delivers the payload as a &[u8] into the receiver's internal packet buffer via an FnOnce(WalHeader, &[u8]) callback (zero allocation — use on the hot path); try_recv is a convenience wrapper that returns an owned Vec<u8> per in-order packet. Out-of-order frames land in a separate 2048-slot reorder ring (reorder_seqs / reorder_lens / reorder_frames); overflow returns Reconnect.

WAL record format

struct WalHeader {       // 16 bytes
    version:     u8,     // offset 0      (V1 = 1; V0 retired)
    _pad0:       u8,     // offset 1
    record_type: u16,    // offset 2..4
    len:         u16,    // offset 4..6   (payload bytes)
    _pad1:       u16,    // offset 6..8
    crc32:       u32,    // offset 8..12  (CRC32C of payload)
    _reserved:   [u8; 4],// offset 12..16 (must be zero)
}

Payload is #[repr(C, align(64))], little-endian. Data records carry seq: u64 at offset 0 (enforced via CastRecord).

Version policy. Adding a new record_type does NOT bump the wire version — record types are additive. Bumping V1 → V2 is reserved for changes that would break a V1 reader (re-layout, different CRC algorithm) and requires a coordinated stop-redeploy. V0 (legacy zero) was retired in 64dda88 when the version byte moved to offset 0; readers no longer accept it.

WalWriter internals

  • Append: assigns monotonic seq, encodes into in-memory buf. O(1) memcpy.
  • Flush: producer's tick calls flush() every 10ms; writes buf to active file + fsync.
  • Rotation: on flush, if file_size >= max_file_size (64MB default), close active file, rename with seq range, open new active file, run GC.
  • GC: prune_old_segments runs at the end of every rotate(). mtime-based; deletes rotated segments older than RETENTION_NS (4h; const in wal.rs). Best-effort — an unlink failure is logged and skipped, never propagated. The active file is never touched.
  • Backpressure: append blocks when buf > 2x max_file_size.

File layout:

wal/{stream_id}/{stream_id}_{first_seq}_{last_seq}.wal
wal/{stream_id}/{stream_id}_active.wal

Filenames encode the seq range. read_record_at_seq picks the segment whose [first_seq, last_seq] covers the target (linear scan of the file list, bounded by retention), then scans that one file for the record. No file header, no index.

Replay Protocol (replication) — server.rs

1. Consumer connects (optional TLS).
2. Consumer sends ReplicationRequest { stream_id, from_seq }
   as one framed record.
3. Server validates header version, validates CRC, then
   casts the payload (in that order — no unsafe before
   the integrity check).
4. Server opens WalReader at from_seq and streams raw
   WAL bytes (header + payload, no transformation).
5. On catch-up, emits CaughtUpRecord { live_seq }; consumer
   resumes at live_seq + 1.
6. Transitions to live broadcast driven by
   WalWriter::add_listener notifications.

ReplicationConsumer retries disconnects with exponential backoff (1, 2, 4, 8, 30 seconds) and ±20% jitter (no rand dep — nanosecond mod 1000). Backoff index resets on a successful stream.

Trust model

casting is intentionally unauthenticated — "trusted internal network, no authentication, no encryption" (spec 4-cast §10.4).

  • External clients are authenticated at the gateway (JWT + TLS).
  • Internal RSX peers are isolated at L3 (firewall, VPC, namespace).
  • A per-frame source-IP filter was prototyped and reverted (commit bde3211). Do not reintroduce — it duplicates the L3 owner and complicates the zero-copy ingress path.

If cross-DC peer auth is ever needed, the right place is a sealed-frame extension under a new WalHeader.version, not a retrofit on the V1 ingress.

Wire-format invariant

WAL bytes = disk bytes = casting/UDP bytes = replication/TCP bytes
         = struct bytes in memory

The same #[repr(C, align(64))] payload appears in all four contexts. CRC32C (Castagnoli) in the header covers the payload only.

Idempotent replay

Consumers dedup by seq. Risk treats any record with seq <= tips[stream_id] as a no-op. Tips persist every 10ms; recovery resumes from tip + 1.

Edge cases

  • Crash mid-rotation: active file recovered by CRC scan; trailing partial record truncated.
  • Partial record at EOF: detected, truncated.
  • CRC mismatch: conservative truncation at first bad record.
  • Unknown record_type: returned raw, consumer skips.
  • Unknown header version: rejected on TCP ingress, dropped on UDP control path.
  • Gap beyond send_ring + WAL retention: NAK fails; consumer must use archive fallback.

Measured performance

All p50 unless noted. Single 6-core box, Linux 6.1, loopback, closed-loop, casting/raw-UDP threads pinned. Headline latency and WAL-flush figures are the 2026-07-03 run (reports/20260703_cast-benches.md); encode/decode, sequential read, and cold-tier random-read are from cast_bench / wal_random_read_bench (earlier pass, same host). See BENCHES.md for per-bench attribution, compare/README.md for the same-harness comparison against Aeron / MoldUDP64 / SoupBinTCP / Quinn / KCP / raw UDP / TCP, and facts/cast-vs-udp-overhead.md for the dated attribution breakdown. casting's loopback RTT (8.80 µs) sits at the raw-UDP floor (8.75 µs) — the protocol adds ~0 µs; ~99 % of the send body is the sendto syscall.

Operation Measured Bench
Protocol-record encode (Nak / CastHeartbeat) 43 ns cast_bench
Protocol-record decode 9 ns cast_bench
WalWriter::prepare + append_framed (Vec extend, no disk I/O) 36 ns wal_bench
WAL flush + fsync, 1 record 363 µs wal_fsync_bench (real disk, core-pinned)
WAL flush + fsync, 100 records 475 µs wal_fsync_bench — fsync dominates
WAL flush + fsync, 1 000 records 940 µs wal_fsync_bench — fsync still dominant
WAL flush + fsync, 10 000 records 4.82 ms wal_fsync_bench — append overhead visible
WAL sequential read ~700 MB/s wal_bench
casting RTT, loopback, 128 B 8.80 µs cast_rtt_bench
casting one-way, loopback, 128 B 4.74 µs cast_one_way_bench
Raw UDP RTT (baseline), loopback, 128 B 8.75 µs compare_all::raw_udp_128b
CastSender::send body (per call) ~3.6 µs (~99 % sendto) cast_send_breakdown_bench
Cold-tier NAK retransmit (read_record_at_seq), 10 K records 10.4 ms wal_random_read_bench
Cold-tier NAK retransmit (read_record_at_seq), 100 K records 80.6 ms wal_random_read_bench

Connection topology

Gateway --[casting/UDP]--> Risk --[casting/UDP]--> ME
Gateway <--[casting/UDP]-- Risk <--[casting/UDP]-- ME
                                      ME --[SPSC]--> WalWriter
                              WalWriter --[notify]--> ReplicationService
                                      ME --[casting/UDP]--> Marketdata
Mark --[replication/TCP]------> Risk
Recorder --[replication/TCP]--> ME

Consumers

Consumer Source Purpose
Risk ME WAL Fill ingestion, position update
Risk Mark WAL Mark price feed
Marketdata ME WAL Shadow book bootstrap
Recorder ME WAL Daily archival

Architectural Decisions

Runtime: none — transport library. rsx-cast is domain-agnostic and runtime-agnostic. All types — CastSender, CastReceiver, WalWriter, WalReader, ReplicationService, ReplicationConsumer — are synchronous. Callers drive them from whatever loop suits their needs: a pinned tile spin loop, a tokio task, or a monoio reactor. No async wrappers are shipped; the crate carries no runtime dependency.

This is intentional: consumers pick the runtime that fits their stage. Matching engine drives CastSender from a pinned tile loop with no reactor at all. Gateway and marketdata own the UDP socket and pass raw bytes to CastReceiver (invert-ownership pattern — see cast.rs). Recorder drives ReplicationConsumer blocking from its own thread. The transport sits under all of them without preference.

Benchmarks

source: reports/20260703_cast-benches.md

rsx-cast benchmark run — 2026-07-03

Recorded live, sequentially (one bench at a time, appended as it completes, so a mid-run failure never loses earlier numbers). Cluster STOPPED first so risk/ME busy-spin tiles don't contend cores 2/3 (bench pins client→2, echo→3). 6-core box, debug cluster off. Provenance: [lib]=real library, [reimpl]=our clean-room from spec (may be wrong/unoptimized), [our]=rsx-cast itself.

Payload 128 B (= size_of::), sample_size 50 across all.

Impl Kind p50 RTT bench status
cmp_rtt_fill_echo [our] 8.8021 µs cast_rtt_bench ok
moldudp64_rtt_loopback_128b [reimpl] 8.8053 µs compare_moldudp64 ok
soupbintcp_rtt_loopback_128b [reimpl] 11.164 µs compare_soupbintcp ok
raw_udp_128b [lib] 8.7487 µs compare_all ok
kcp_spin_flush_128b [lib] 10.414 µs compare_all ok
quinn_persistent_128b [lib] compare_all ABORTED: BENCH-QUINN-ACCEPT-BI panic
aeron_rtt_udp_loopback_128b [lib] 77.310 µs compare_aeron ok

Results (p50, this run)

  • casting (rsx-cast)8.80 µs [our] — at the raw-UDP floor.
  • raw UDP — 8.75 µs [lib] (std sockets; the floor).
  • MoldUDP64 — 8.81 µs [reimpl] — ties casting; OUR clean-room impl.
  • KCP — 10.4 µs [lib] (turbo).
  • SoupBinTCP — 11.2 µs [reimpl] — OUR clean-room framing over TCP.
  • Aeron (UDP loopback) — 77.3 µs [lib] — real media driver, high variance (48–108 µs).
  • Quinn / QUIC — ABORTED (BENCH-QUINN-ACCEPT-BI panic at compare_all.rs:356).
  • TCP_NODELAY — not reached (compare_all aborted at Quinn before the TCP case).

One-way delivery — the honest single-trip number

casting is fire-and-forget one-way delivery (ME→marketdata, risk_out→gateway). The RTT above is a comparison metric — it needs only one clock, so every protocol measures on equal footing, and it mirrors the order round-trip — but the true per-delivery cost is one hop:

  • cmp_one_way_fill — 4.74 µs p50 (cast_one_way_bench): CastSender::sendCastReceiver::try_recv, one cast hop, in-order, no NAK.

RTT (8.80 µs) ≈ 2 × one-way + echoer turnaround — so ~4.7 µs, not 8.8, is what a single casting delivery costs. Read the RTT for order-path shape; the one-way for delivery latency.

Send-path breakdown (cast_send_breakdown_bench)

Where the send half of a delivery goes — the sendto syscall dominates; everything above the kernel is single-digit ns:

stage p50
send.header_build (seq + CRC + WalHeader) 711 ps
send.ring_cache_copy_128b 2.87 ns
send.buf_pack_144b 3.38 ns
send.crc32_128b (CRC32C over payload) 29.3 ns
send.sendto_144b_loopback (syscall) 3.54 µs

The whole userspace framing path is ~36 ns; the sendto syscall is ~100× that. The one-way 4.74 µs is essentially one sendto + one recvfrom + framing.

WAL — write, fsync, replay (wal_bench, wal_fsync_bench)

rsx-cast's WAL is the retransmit source AND the audit/replay log — half the crate. Write path:

op p50
wal_write/append_1rec 36.2 ns
wal_write/write_1m_no_flush 34.7 ms (~29 M rec/s, buffered)
wal_write/flush_800rec 896 µs

Fsync amortizes with the 10 ms flush batch — per-record cost collapses as records-per-flush grows:

flush every p50 (batch) per-record
1 rec 363 µs 363 µs
10 rec 409 µs 41 µs
100 rec 475 µs 4.8 µs
1 k rec 940 µs 0.94 µs
10 k rec 4.82 ms 0.48 µs

Replay (cold-path recovery), linear in records:

replay p50
10 k 13.5 ms
100 k 122 ms
1 M 1.23 s

Caveats (honesty)

  • FAIRNESS BUG: MoldUDP64 + SoupBinTCP are UNPINNED (TODO(pinning) never done) while casting/raw-UDP/KCP/Aeron pin client→core2/echo→core3. Their numbers are therefore NOT strictly comparable — pending the uniform-harness refactor (.ship/31). Idle box limits the distortion but it's real.
  • [reimpl] (MoldUDP64, SoupBinTCP) measure OUR reimplementations, which may be incorrect or unoptimized — reference baselines, NOT the vendors' products.
  • Quinn aborts → no QUIC number this run; fix BENCH-QUINN-ACCEPT-BI first.
  • compare_all aborting at Quinn also cost the TCP_NODELAY row (ordering).
  • Single 6-core box, loopback, cluster stopped. Not wire-to-wire. Run yourself.

Comparisons

How casting stacks up against the other ways to move records between machines. The thing the table below can't show: casting never loses a record, and the log it replays a lost one from is the very same log it keeps for audit — no separate archive to fall out of sync. Speed is only half the story.

At a glance

Loopback p50, one fixed 128-byte record, this 6-core box (2026-07-03 run, reports/20260703_cast-benches.md):

Transport p50 RTT Verdict
casting (rsx-cast) 8.80 µs at the raw-UDP floor — and the WAL it writes for replay/audit IS its retransmit source
raw UDP (baseline) 8.75 µs the floor: sendto+recvfrom, no loss recovery, no durability
MoldUDP64 8.81 µs ties casting — Nasdaq's UDP shape (our clean-room reimpl)
KCP (turbo) 10.4 µs userspace reliable-UDP from gaming; slower, no durability
SoupBinTCP 11.2 µs TCP + 3-byte framing (our reimpl); head-of-line blocks under loss
Aeron (UDP loopback) 77.3 µs HFT-grade, but built for multicast fan-out, not one loopback hop

One-way delivery — the true per-hop cost, since casting is fire-and-forget — is 4.74 µs (cast_one_way_bench); the RTT above is a comparison metric (one clock, mirrors the order round-trip). Numbers are one loopback workload on one box, closed-loop and synthetic; the MoldUDP64 and SoupBinTCP rows are our own reimplementations (reference baselines, not the vendors' products) and are unpinned this run. Run cargo bench -p rsx-cast --bench compare_all yourself. Full write-ups and the feature matrix are below.

Trust boundary. casting is intentionally unauthenticated (spec 4-cast §10.4, "trusted internal network"); external clients are authenticated at the gateway (JWT + TLS), internal peers isolated at L3 (firewall/VPC/namespace). "No auth on the wire" is a stated design choice, not a gap.

raw-udp

Raw UDP

Baseline: no reliability, no framing, no CRC, no retransmit. The absolute floor for any protocol built on UDP. Anything more capable than this — ordering, gap detection, retransmit, durability — costs more than the numbers below.

Wire format

There is none. UDP has only an 8-byte transport header (RFC 768):

Offset  Size  Field
0       2     Source port
2       2     Destination port
4       2     Length (header + payload)
6       2     Checksum (optional on IPv4, mandatory on IPv6)

After the 8-byte header the kernel hands the application the raw bytes the sender wrote with sendto(). There is no sequence number, no length-prefix beyond the UDP Length field, no message-vs-message framing across multiple datagrams — every sendto is one datagram is one recvfrom on the receiver.

Protocol

std::net::UdpSocket::send_to / recv_from. OS kernel routes the datagram through the loopback network stack. Nothing else.

Loopback path: user → sendto syscall → kernel socket buffer → loopback NIC driver (virtual) → kernel socket buffer → recvfrom syscall → user.

Guarantees

Dimension Raw UDP rsx-cast casting
Delivery Best-effort (may drop) Reliable (NAK + WAL retransmit)
Ordering Unordered (may reorder) Per-stream FIFO (seq monotonic)
Duplicates May duplicate Dedup in receiver via seq
Framing None (one datagram = one recvfrom) 16-byte WalHeader + fixed-size payload
Integrity UDP checksum (optional, weak) CRC32C over each record
Durability None WAL on disk, 4 h retransmit horizon
Flow control None (sender can overrun receiver) None on the wire; producer stalls on WAL flush-lag; bounded reorder buffer
Connection state None seq + NAK list + idle-only heartbeat

Everything in the casting column is layered above the kernel UDP socket. Each row is a cost measured against the raw-UDP floor.

Relation to rsx-cast

casting builds on raw UDP. The cost of casting above this baseline is:

  • 16-byte WalHeader framing + CRC32C verification
  • send_ring slot write (WAL record caching for hot-tier retransmit)
  • NAK handling on a detected gap + an idle-only heartbeat (100 ms)
  • Sequence number assignment

Measured overhead (loopback, 128 B payload):

raw UDP RTT      8.90 – 11.01 µs  (compare_all::raw_udp_128b, re-run 2026-07-01)
casting RTT      8.36 – 10.47 µs  (cast_rtt_bench cmp_rtt_fill_echo, re-run 2026-07-01)
casting send body    ~4.10 µs     (one-way; cast_send_breakdown_bench, 2026-05-24)
  └─ sendto syscall: 4.07 µs (99.4%)
  └─ userspace (CRC32C + framing + ring copy): ~26 ns

The earlier "~2 µs" raw-UDP baseline claim was wrong for this host — see facts/cast-vs-udp-overhead.md for the full measurement, attribution, and walk-back. Summary: the sendto syscall dominates 99 % of casting's per-send cost; casting's userspace work (CRC32C + WalHeader + ring cache) adds ~26 ns, not microseconds.

Sender + echoer are pinned to cores 2/3 in every RTT bench (core_affinity), which tightened the casting distribution by 10–40% vs the pre-pinning baseline (see the facts file).

Benchmark

benches/compare_all.rs::raw_udp_128b (run with cargo bench -p rsx-cast --bench compare_all). The standalone compare_udp.rs was folded into compare_all.rs in commit 836cfb1.

Two non-blocking sockets on 127.0.0.1, both threads busy-spinning. No per-iteration setsockopt. No blocking recv wake-up. 128-byte payload (matches FillRecord). Measures true kernel UDP round-trip.

Published numbers

Environment RTT P50
Linux loopback, this host, non-blocking + spin (measured) ~9.9 µs
Linux loopback, blocking recv ~5–10 µs
Same-rack 10 GbE, non-blocking ~5–15 µs
Cross-DC WAN 500 µs – 50 ms

The first row is our measured compare_all::raw_udp_128b (2026-07-01), dominated by two sendto + two recvfrom at ~4 µs each. Some published loopback micro-benchmarks quote ~2 µs; that figure did not reproduce on this host, and facts/cast-vs-udp-overhead.md documents the walk-back.

Sources: RFC 768 (UDP), udp(7) Linux man page, facts/syscall-latency.md (local measurement dfe2ef4), facts/cast-vs-udp-overhead.md (the ~2 µs walk-back).

Why not raw UDP for exchange IPC

  • No ordering guarantee across reorder buffers.
  • No gap detection — a dropped fill is silently lost.
  • No retransmit — consumer must implement all reliability.

Every exchange transport that uses UDP (LBM, Aeron, casting) adds reliability on top. The question is how: NAK-based (Aeron, casting, LBM), ACK-based (KCP), or FEC (Solana Turbine).

moldudp64

MoldUDP64

Nasdaq's UDP multicast dissemination protocol. Carries ITCH 5.0 market data feeds (TotalView, BX, PSX). Public specification, freely implementable. The closest published peer to casting's wire shape: a sequence-numbered, NAK-recovered, fixed-header UDP frame with a fan-out delivery model.

Spec: https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/moldudp64.pdf

Why we include it: same protocol family as casting (UDP + seq + NAK recovery), but with multicast fan-out and a separate retransmit channel. Lets us bench framing overhead against a real exchange wire protocol with a known footprint.

Protocol

Wire format — 20-byte downstream header

All multi-byte integers are big-endian (network byte order), unlike KCP's little-endian framing.

Offset  Size  Field            Meaning
0       10    session          ASCII session ID (left-padded space)
10      8     seq              Sequence number of the FIRST message
                                in this packet (1-based)
18      2     msg_count        Number of messages in this packet
                                (0x0000 = heartbeat, 0xFFFF = end-of-session)
20+     var   messages...      Concatenated length-prefixed messages

Each downstream message inside the packet:

0       2     msg_len   Big-endian length of msg_data
2       N     msg_data  Opaque payload (ITCH 5.0 record, etc.)

Packets are sent over UDP multicast to a well-known group/port. A single packet typically carries one message; bursty market events pack multiple. MTU governs the upper bound (Nasdaq uses 1 472 B payload to stay below 1 500 B Ethernet MTU).

Compare casting/WAL: 16-byte header (version:u8 at offset 0, _pad0:u8, record_type:u16, len:u16, _pad1:u16, crc32:u32 — a CRC32C value in the crc32-named field — _reserved:[u8;4]) + one fixed-size #[repr(C, align(64))] payload per packet. No per-packet message-count; one record per UDP datagram by construction.

Reliability: NAK to a separate request server

MoldUDP64 separates dissemination from retransmit:

  1. Downstream UDP multicast carries the live stream (one-to-many).
  2. Request channel is a separate UDP unicast (sometimes TCP) endpoint that the receiver queries with a MoldUDP64 Request Packet:

0 10 session 10 8 seq First missing sequence 18 2 msg_count How many sequenced messages requested

  1. The request server replies on the same downstream multicast group (so other receivers see the retransmission too — same "NAK suppression" property as Aeron multicast).

End-of-session: a packet with msg_count = 0xFFFF tells receivers the stream is done. Heartbeats (msg_count = 0) keep liveness without payload.

No congestion control, no flow control

MoldUDP64 assumes a fixed-capacity multicast fabric. There is no ACK, no window, no sender-side rate limiting. Receivers that fall behind use the request channel to catch up; the dissemination side never slows down.

This matches casting's design assumption (trusted, fixed-capacity LAN). casting likewise has no wire-level flow control — it is unicast and relies on WAL-writer backpressure (producer stalls on flush-lag

10 ms) plus a bounded receiver reorder buffer rather than any receiver-advertised window on the wire.

Latency characteristics

Public Nasdaq feed numbers (ITCH 5.0 / TotalView): - Wire frame overhead: ~20 B + 2 B per message. - One-way LAN latency reported by Nasdaq colo customers: 10–30 µs (NIC-to-NIC, kernel bypass). - The protocol itself adds essentially zero processing — parse header, dispatch payload.

Relation to rsx-cast

Dimension MoldUDP64 rsx-cast casting
Transport UDP multicast (1:N) UDP unicast (1:1)
Byte order Big-endian Little-endian (native x86_64)
Header size 20 B (per packet) + 2 B (per msg) 16 B (per record)
Multiple msgs per packet Yes (msg_count) No (one record per datagram)
Loss detection Receiver (seq gap) Receiver (seq gap)
Retransmit source Separate request server Embedded: hot ring + cold WAL
Retransmit channel Out-of-band UDP/TCP to request server Same socket (NAK + sendto)
Multicast NAK suppression Yes (retransmit on group) N/A (unicast)
Durable archive External (TotalView Glimpse) Embedded WAL (4 h)
End-of-session marker msg_count = 0xFFFF None (live tail forever)
Designed use Market data dissemination (downstream only) Bidirectional order flow + market data

MoldUDP64 is the dissemination half of an exchange feed (downstream only — no order entry). casting handles both directions in one protocol; it bundles the request-server role into the sender via the embedded WAL.

Stronger than casting

  • Multicast fan-out is native. One sender, N receivers, zero per-receiver state on the sender side. casting requires one CastSender instance per peer (point-to-point).
  • Multiple messages per UDP datagram. Saves header overhead on bursty market events. casting pays a full 16-byte header per record.
  • NAK suppression in multicast means a single retransmit recovers loss for the entire receiver group. casting retransmits per receiver.

Weaker than casting

  • Retransmit horizon is implementation-defined. Nasdaq's Glimpse service replays the start-of-day snapshot via a separate TCP protocol. casting's WAL is always there, always 4 h deep.
  • Big-endian framing costs bswap64/bswap16 on x86_64 every parse. casting is native little-endian.
  • Downstream only. No model for order entry — Nasdaq uses OUCH (SoupBinTCP) for that, two protocols where casting has one.

Benchmark

benches/compare_moldudp64.rs::moldudp64_rtt_loopback_128b (run with cargo bench -p rsx-cast --bench compare_moldudp64) — Criterion, loopback, 128 B payload (matches FillRecord), unicast UDP (not multicast). MoldUDP64 stays a standalone bench (its framing server does not fit the compare_all EchoClient trait) but uses the same payload size and core pinning, so the number is directly comparable.

We bench unicast for fair RTT comparison with the compare_all raw_udp / kcp / tcp set. Loopback multicast on Linux is finicky (IGMP, IP_ADD_MEMBERSHIP, route hints) and would measure kernel multicast plumbing rather than the protocol's framing cost — which is what we want to isolate.

Frame: 20 B downstream header + 2 B message-length + 128 B payload = 150 B on the wire per direction. Sequence number incremented on every send; msg_count = 1. The echoer parses the header, validates the seq and message count, extracts the payload, then frames its own MoldUDP64 packet back with the echoer's own seq counter (a fair, full-stack parse + emit on both sides — not a raw byte echo).

Measured p50 on Linux loopback: ~10 µs (2026-05-24) — at the raw-UDP floor, since the header parse on both sides is tens of ns and the ~4 µs sendto/recvfrom syscalls dominate. Not re-run this session.

Sources

  • https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/moldudp64.pdf (official spec)
  • https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHspecification.pdf (ITCH 5.0, the payload format)
  • https://github.com/martinsumner/moldudp64 (Erlang reference implementation, MIT)
  • https://www.fixtrading.org/standards/ (FIX is not MoldUDP, but the request/dissemination split is the same pattern)
aeron

Aeron

Open-source Java/C++ reliable UDP transport by Real Logic (Martin Thompson, Todd Montgomery). The direct design ancestor of rsx-cast casting. Widely deployed in HFT and trading systems; acquired by Adaptive Financial Consulting in 2022.

  • Repo: https://github.com/aeron-io/aeron (Apache-2.0)
  • Wire spec: https://github.com/real-logic/aeron/wiki/Transport-Protocol-Specification
  • Rust bindings (used by our bench): https://github.com/gsrxyz/rusteron

Wire format

Aeron's frame layout is term-buffer-oriented. Each stream has three rotating 64 MB term buffers (configurable via term-length). A position in the stream is term_id × term_length + term_offset. Loss detection, flow control, and replay all key off of position rather than a flat sequence number — the term-rotation abstraction lets the receiver reclaim memory aggressively while keeping a window of replay-able data.

Data frame header (32 bytes):

 0-3    frame_length      (header + payload, little-endian)
 4      version
 5      flags             (FRAGMENT_BEGIN, END, EOS)
 6-7    type              (DATA=0x01, PAD=0x02, NAK=0x03, SM=0x04, …)
 8-11   term_offset       (byte offset within the term buffer)
12-15   session_id
16-19   stream_id
20-23   term_id
24-31   reserved_value (8 bytes)
32+     payload

Encoding: little-endian throughout.

casting difference. Our WalHeader is 16 bytes (the on-disk WAL record header doubles as the wire header) with a flat u64 seq, a u16 record_type, a u16 len, and a CRC32C. No term_id / term_offset / session_id. Trade-off:

  • Aeron's term layout makes replay zero-copy from RAM in large strides — but the retransmit horizon is whatever fits in the term buffers (default ~192 MB / stream).
  • casting's flat seq + WAL file layout makes the disk file the retransmit horizon (4 h retention by default). Slower per retransmit (random-access disk read), but the horizon is measured in hours of traffic rather than megabytes.

Loss detection: NAK from receiver

Aeron is NAK-based. The receiver tracks the highest contiguous position and detects a gap when an out-of-order frame arrives at a higher (term_id, term_offset). It sends a NAK back to the sender naming the missing range.

  • Unicast: NAK sent immediately. Single receiver, no implosion risk.
  • Multicast: NAK sent after a randomized backoff so that only one receiver per gap actually sends the NAK ("NAK suppression"). Prevents NAK implosion.
  • The sender retransmits from its in-memory term buffer.

The model is identical in casting: receiver detects a gap on seq, sends Nak{from_seq, count} back to the sender, sender retransmits. The retransmit path is ~1 RTT. casting is unicast only — no NAK suppression backoff because there can be only one receiver per casting stream.

Retransmit horizon

This is where the two protocols diverge most.

Aeron: retransmit comes from the term buffer the sender still has in memory. Once a position has been overwritten by the rotating term buffers, the live retransmit path can't recover it. For durable replay, Aeron Archive (a separate component) records streams to disk; an archive replay subscription pulls from the archive instead of the live publication. This is a clean separation — the live wire protocol is RAM-bounded and fast; the archive is a different service with its own SUBSCRIBE / REPLAY protocol.

casting/DXS: retransmit is two-tier in the same component.

  1. Hot tier: a 4096-slot pre-allocated send ring inside CastSender. NAKs for recent sequences are served from this ring with zero allocation, zero disk I/O.
  2. Cold tier: a NAK whose from_seq predates the hot ring falls through to read_record_at_seq on the WAL file. Retransmit horizon = WAL retention (default 4 h).

The WAL is the source of truth for the application and the retransmit reservoir for the transport. There is no archiver sidecar — the producer process owns its own durability.

Flow control

Aeron has multiple flow-control strategies (max, min, tagged) configurable per publication. The sender's position is capped at min(receiver positions) + window where window defaults to half the term length. Receivers send StatusMessage frames advertising their consumption position; the sender uses these to compute the send-side window.

casting has no wire-level flow control — the receiver sends no window-advertisement frame, and the only control record on the wire is a NAK (on a detected gap) plus an idle-only heartbeat. An earlier StatusMessage/receiver-window mechanism was removed in commit 87b223e; casting is single-receiver-per-stream and handles overrun one layer up instead: the WAL writer stalls the producer when flush lag exceeds 10 ms or its buffer fills, and the receiver's bounded reorder buffer caps how far ahead the sender can run. This is a real narrowing vs Aeron — casting cannot throttle a fast sender to a slow receiver mid-stream on the wire; it relies on the WAL-writer backpressure and the trusted-LAN capacity assumption (spec §10.4).

Durability: integrated vs sidecar

Aeron casting/DXS
Durability Aeron Archive (separate process / API) WAL embedded in CastSender
Sender startup Connect to driver, no disk Open WAL file (mmap'd)
Crash recovery Replay from archive Replay WAL from last tip
Wire = disk? No (term buffer vs archive recording format) Yes (WalHeader + payload, identical bytes)

casting collapses the archive into the protocol. The WAL file you write to disk is the wire format — dd if=/path/to/wal | nc would be a syntactically valid replay stream. This is the "wire = disk = stream" claim that motivates the design.

Media driver

Aeron is structured around a separate media driver process that owns all UDP sockets and shared-memory term buffers. Applications communicate with the driver via lock-free SPSC rings in shared memory (/dev/shm/aeron-$USER/cnc.dat). This gives:

  • Multiple clients sharing one driver → one set of sockets, one set of term buffers, lower per-client overhead.
  • A driver crash doesn't take out client state immediately (client conductors detect via keep-alive timeout).
  • An extra IPC hop on every send and every receive (app → driver shm → kernel UDP → driver shm → app).

casting has no driver. CastSender::send() calls sendto() directly from the application thread. Cost: no IPC hop, but each process owns its own UDP socket and WAL file. Suits RSX's tile architecture (one process per role, pinned thread, dedicated socket).

Performance

Published Aeron numbers (Real Logic / AWS, 2025)

Source: https://aws.amazon.com/blogs/industries/aeron-on-aws-2025-performance-benchmark-results/

Hardware: AWS c6in.16xlarge (Ice Lake, ENA networking, kernel bypass disabled in the "Open Source" column).

Load Percentile Open Source Premium (kernel bypass)
100 k msg/s P50 21–22 µs 24–25 µs
100 k msg/s P99 32–43 µs 29–30 µs
1 M msg/s P50 30–35 µs 30–31 µs
1 M msg/s P99 57–84 µs 39–40 µs

These are bare-metal-class numbers: dedicated cores per driver agent, large c6in instance, isolated load generators, RTT computed from the embedded message timestamp (in-handler).

Our local bench

rsx-cast/benches/compare_aeron.rs — loopback ping/pong over Aeron UDP, 64-byte payload, embedded media driver in the same process, no core pinning.

Setup P50 RTT P99 RTT (approx) Note
Aeron UDP loopback (this bench, 6-core box, no pinning) ~305 µs ~570 µs criterion total closure time
Aeron IPC (shared memory, this bench) ~830 ns ~1 µs non-default; see source for caveat
casting RTT (cast_rtt_bench, same box) ~9.3 µs n/a two CastSender/Receiver pairs, loopback (re-run 2026-07-01)
casting send body (cast_send_breakdown_bench) 3.87 µs n/a sendto-side only
Aeron AWS open source (c6in.16xlarge) 21–22 µs 32–43 µs published, pinned cores

Why our Aeron UDP number is 10–30× worse than the published one: on a 6-core machine without core pinning, the driver agent thread + PONG echo thread + PING ping thread + criterion measurement thread + OS background tasks oversubscribe the scheduler. The driver's idle strategy spins, but every preemption costs us hundreds of microseconds of round-trip. The IPC variant doesn't suffer because the kernel UDP path drops out of the critical section. This is not Aeron's protocol overhead — it is our environment.

Why casting RTT is lower in our setup even though Aeron is generally faster in production: casting has no driver IPC hop. On loopback with all threads in one process, sendto() direct from the application is faster than going app → driver SHM → sendto → driver SHM → app by exactly the SHM-ring + scheduler-wakeup cost.

In a properly resourced deployment (≥8 cores, pinned, real NIC, sustained load), Aeron's published numbers reflect what the protocol actually does. Treat our 305 µs as a "laptop-class" data point, not a competitive benchmark.

Guarantees comparison

Property Aeron casting/DXS
Reliable delivery Yes Yes
Loss detection NAK (receiver) NAK (receiver)
Retransmit source term buffers (RAM, ~192 MB default) hot ring (4096 slots, RAM) + cold WAL (disk)
Retransmit horizon term-buffer lifetime (seconds) WAL retention (4 h default)
Durability Aeron Archive (separate process) WAL embedded in sender
Wire = disk No Yes
FIFO per stream Yes Yes
Multi-receiver Yes (UDP multicast, multi-destination cast) No (unicast only)
Flow control Configurable (max / min / tagged) None on the wire; WAL-writer backpressure + bounded reorder buffer
Congestion control Optional (CUBIC) None
Frame header 32 bytes 16 bytes (WalHeader)
IPC topology Driver process + clients via SHM rings None (sendto direct from app thread)
Session setup SETUP / handshake None (sendto, zero setup)
Trust model Configurable (TLS in v1.45+, raw UDP otherwise) Trusted LAN only (no auth, no encryption)
Language (impl) C++ media driver, Java client, C client Rust (native)
Production deployments Decades in HFT (LMAX, citadel, exchanges) RSX exchange (this repo)

Where Aeron is genuinely more capable

casting simplified Aeron for one trust assumption (LAN), one topology (unicast), and one language (Rust). Honest side-by-side:

  • Multicast / multi-destination cast. Aeron does fan-out at the wire level; casting fans out at the sender (one socket per receiver). For 100 receivers, Aeron multicast sends one packet; casting unicast sends 100.
  • Maturity. Aeron is decades old, with production deployments at the largest exchanges. casting is new.
  • Wire-level flow control. Aeron lets you pick min/max/tagged per publication; casting has none on the wire — a fast sender can outrun a slow receiver until the reorder buffer or WAL-writer backpressure intervenes.
  • TLS option. Aeron 1.45+ supports DTLS; casting delegates TLS to the layers around it (L3 firewall, gateway).
  • Position abstraction. Aeron's term-based position model is more flexible for replay/seek operations against large in-memory windows; casting's flat seq + WAL is simpler but doesn't support sub-record seeking.

Where casting is intentionally narrower

  • No archiver. The producer process is its own archive. One fewer service to deploy, monitor, recover.
  • No driver. No IPC hop, no shared-memory ring between app and transport. Suits a single-process-per-role tile architecture.
  • Rust-native. No JVM, no GC pauses, no JNI / SBE layer.
  • One trust model. Trusted LAN. The system-spec (specs/2/4-cast.md §10.4) delegates auth to the gateway (JWT) and the L3 network (firewall, VPC). casting is intentionally unauthenticated.

Running the bench

cargo bench -p rsx-cast --bench compare_aeron

Prerequisites (Debian/Ubuntu):

sudo apt install -y cmake libclang-dev clang uuid-dev libbsd-dev

The rusteron-client / rusteron-media-driver crates are configured with features = ["precompile", "static"] in Cargo.toml. This downloads a precompiled Aeron C driver binary from the rusteron release artifacts on first build (no cmake-of-Aeron required at compile time, though Debian 12's stock cmake 3.25 wouldn't satisfy Aeron's >=3.30 requirement anyway). System libs libuuid and libbsd are needed at link time.

The bench source documents an IPC variant (bench_aeron_ipc) that is intentionally not in the default criterion group — running both UDP and IPC variants in one process triggers a C-side MediaDriver has been shutdown race during driver teardown/relaunch. Smoke-measured separately, Aeron IPC RTT is ~830 ns on this hardware.

Sources

  • Aeron repository: https://github.com/aeron-io/aeron
  • Transport spec: https://github.com/real-logic/aeron/wiki/Transport-Protocol-Specification
  • "Aeron: Open-source high performance messaging" — Martin Thompson, Strange Loop 2014: https://www.youtube.com/watch?v=tM4YskS94b0
  • LMAX Disruptor paper (the design lineage that produced Aeron): https://lmax-exchange.github.io/disruptor/disruptor.html
  • AWS 2025 benchmark: https://aws.amazon.com/blogs/industries/aeron-on-aws-2025-performance-benchmark-results/
  • Real Logic blog (general Aeron coverage): https://www.real-logic.co.uk/
  • rusteron (Rust bindings used by our bench): https://github.com/gsrxyz/rusteron
  • Adaptive Financial Consulting acquisition (2022): https://weareadaptive.com/2022/09/06/adaptive-acquires-real-logic/
kcp

KCP

Open-source reliable ARQ protocol over UDP by skywind3000 (Rui Kong). ~1 000 LOC of C in a single header pair (ikcp.h / ikcp.c). MIT-licensed. Widely deployed in gaming, P2P (KCPTun), and VPN tunnels. Explicitly not a TCP replacement — its README states "sacrifice 10–20% bandwidth in exchange for a transmission speed 1.5×–2× of TCP" on lossy WAN paths.

Source: https://github.com/skywind3000/kcp (commit 1d8a8a4, 2025-02)

Protocol

Wire format — 24-byte header

Offset  Size  Field  Meaning
0       4     conv   Conversation ID (logical connection)
4       1     cmd    Command: PUSH=81, ACK=82, WASK=83, WINS=84
5       1     frg    Fragment index (0 = last/only; N = N more follow)
6       2     wnd    Sender's remaining receive window (in packets)
8       4     ts     Sender timestamp (ms, for RTT measurement)
12      4     sn     Sequence number
16      4     una    Cumulative ACK: all sn < una delivered
20      4     len    Payload length (0 for pure-ACK frames)
24+     var   data   Payload

All little-endian. MTU default 1400 B → MSS = 1376 B. Messages larger than MSS are split into multiple segments with descending frg; all segments must arrive before delivery.

Compare to casting's WalHeader (rsx-cast/src/header.rs):

Offset  Size  Field
0       2     record_type
2       2     reserved (was payload_len)
4       4     crc32          (CRC32C of payload)
8       1     version        (V1 = current; V0 = legacy)
9       7     reserved

16 bytes total, fixed-size #[repr(C, align(64))] payload immediately after, no frg field (casting messages are pre-sized ≤ MTU; the matching engine never produces frames > 256 B).

Reliability model: ACK-based (sender-driven)

KCP detects loss at the sender via absence of ACKs:

  1. Every received segment triggers an explicit IKCP_CMD_ACK back to the sender, plus a piggybacked una (cumulative ACK) on every outgoing frame.
  2. When the sender sees ACK(N+2), ACK(N+3) but no ACK(N), it increments fastack on segment N.
  3. fastack >= resend (turbo: resend=2) → fast retransmit without waiting for RTO.
  4. If no ACK arrives within RTO → timeout retransmit.

Contrast with NAK-based (casting, Aeron): the receiver detects the gap on the next datagram with a higher seq and immediately sends NAK(N). The sender retransmits in ~1 RTT.

Latency consequence on zero loss: KCP still sends one ACK per DATA, so every DATA frame triggers a control-plane round-trip. casting on zero loss sends zero control traffic per record — the only background frame is an idle-only heartbeat (RECORD_HEARTBEAT, rsx-cast/src/records.rs) sent every 100 ms when the stream has gone quiet, suppressed entirely while data flows. casting has no flow-control frame; recovery is receiver-driven NAK on a seq gap.

Retransmit horizon

Property KCP casting
Source of retransmit snd_buf (RAM) hot ring (4 096 slots, RAM) → cold WAL (disk)
Discard condition Per-segment ACK received Hot ring is a fixed 4 096-slot LRU; cold tier bounded by WAL retention
Max horizon Bounded by snd_wnd (default 32, turbo 128) 4 096 hot, 4 h cold (WAL retention)
Survives sender restart No Yes (WAL replay)
Audit log No Yes (WAL = audit log)

KCP discards a segment from snd_buf as soon as its ACK arrives. A late NAK or a restarted receiver cannot recover any history. casting's cold-tier WAL provides 4 hours of random-access retransmit via read_record_at_seq — long enough for a downstream service to crash, restart, and resume from its last persisted offset.

Flow / congestion control

KCP has two modes: - Standard (nc=0): TCP-style CWND/ssthresh slow-start + congestion avoidance. - Turbo (nc=1): no CWND. Sends as fast as snd_wnd allows. Receiver advertises its window in the wnd header field.

Turbo is correct for an exchange's trusted-LAN use case — a 10 GbE datacenter fabric is not congested and TCP-style CC adds latency without benefit.

casting has no congestion control and no flow-control frame at all (spec §10.4: "Trusted internal network"). Backpressure is handled one layer up: the WAL writer stalls the producer when its flush lag exceeds 10 ms or the buffer fills, and the receiver's bounded reorder buffer bounds how far ahead the sender can run. There is no receiver-advertised window on the wire.

RTO

RFC 6298 SRTT/RTTVAR with KCP's modifications: - Backoff: ×1.5 (vs TCP's ×2) — faster recovery from spurious timeouts. - Min RTO: 30 ms in nodelay mode (nodelay=1), 100 ms otherwise. - Integer millisecond precision throughout. There is no sub-millisecond RTO.

Fastest configuration ("turbo mode")

ikcp_nodelay(kcp, 1, 10, 2, 1);
//               ^  ^   ^  ^
//               |  |   |  nc=1: no CWND
//               |  |   resend=2: fast retransmit after 2 out-of-order ACKs
//               |  interval=10ms: scheduler tick floor
//               nodelay=1: immediate ACK + minRTO=30ms

The interval parameter governs ikcp_update()'s flush cadence. The upstream KCP README recommends 10 ms; the Rust port allows 1 ms. Below 1 ms, ikcp_check() rounds to zero and update() degenerates into a busy spin.

Critical: interval is the floor for the scheduler, not for sends. Calling ikcp_flush() directly after ikcp_send() bypasses the scheduler and writes to the socket immediately (this is what the spin bench measures). The Rust kcp crate also requires at least one update() call before the first flush() (otherwise flush() returns Error::NeedUpdate); the bench pays this once at startup.

Connection model

KCP is connection-less at the wire level. A "connection" is identified by the conv field and is just shared state on both sides — no handshake, no SYN/FIN. The application is responsible for telling KCP the peer's UDP address.

casting is also connection-less (UDP unicast), identified by a matching pair of bind addresses on sender and receiver. Spec §10.4.

Relation to rsx-cast

This is the answer to: "why not just use KCP?"

KCP is an excellent fit for its target problem: low-grade networks (WAN gaming, mobile, P2P) where the underlying RTT is 20–300 ms and a 10× speedup over TCP under loss is competitive. It has no business on an exchange critical path where:

  1. The dominant latency is the sendto syscall (~3.85 µs measured locally, see facts/syscall-latency.md), not loss recovery.
  2. Every per-DATA ACK doubles control-plane traffic vs casting's NAK-on-gap model.
  3. Integer-millisecond RTO is incompatible with a sub-100 µs SLA.
  4. No persistence — a producer restart loses all retransmit history; casting's WAL survives.

KCP also fundamentally lacks the audit-log property: every fill, order, and cancel in rsx-cast is on disk before it's on the wire, and the same bytes feed the recorder, the marketdata replay service, and the backtester. KCP would be just a transport.

Guarantees comparison: KCP turbo vs rsx-cast casting

Dimension KCP turbo (nc=1) rsx-cast casting
Underlying transport UDP unicast UDP unicast
Wire header size 24 B 16 B
Loss detection Sender (ACK absence + fastack) Receiver (seq gap → NAK)
Detection latency (zero-loss) n/a (ACK per DATA always) n/a (no control plane on success)
Detection latency (1 lost frame) ~2 RTT (need 2 newer ACKs) ~1 RTT (gap seen on next frame)
Retransmit source snd_buf (RAM, bounded by snd_wnd) hot ring (4 096) + cold WAL (4 h)
Retransmit horizon seconds (until ACK arrives) 4 h
Survives sender restart No Yes (WAL replay)
Durability None WAL = audit log
Min flush granularity 1 ms (Rust port) via timer; immediate via flush() per sendto (~3.85 µs)
Multi-receiver / fan-out No (one conv per peer) Per-receiver via DXS TCP replay; casting itself is unicast
Multiplexed streams No (single seq space per conv) No (one stream per CastSender/CastReceiver pair)
FIFO within stream Yes Yes
Cross-stream ordering n/a n/a (separate WAL files per producer)
Auth / encryption None None (trust delegated, spec §10.4)
Congestion control Optional (nc=0 standard / nc=1 turbo) None
Zero-loss control-plane overhead One ACK per DATA None (idle-only heartbeat every 100 ms)
Heap allocation per send Yes (snd_buf.push_back) No (pre-allocated ring slot)
Language ecosystem C reference + Rust port (kcp 0.6) + Go + many Rust only (this crate)
Production HFT use None documented Target use case
Production gaming use Extensive (KCPTun, FRP, ~10k stars) None

Benchmark

benches/compare_all.rs::kcp_spin_flush_128b (run with cargo bench -p rsx-cast --bench compare_all) — Criterion, loopback, 128 B payload (matched to casting's FillRecord, which is mem::size_of::<FillRecord>() == 128). KCP runs under the shared EchoClient harness alongside raw_udp / quinn / tcp.

  • kcp_spin_flush_128b — busy-spin server, explicit flush() after every send(). Reveals KCP's true protocol overhead with the scheduler bypassed.

The KCP configuration:

nodelay=1, interval=1ms, resend=2, nc=1, wndsize=128/128, mtu=1400

The earlier standalone compare_kcp.rs (with a naive_1ms timer-driven variant) was folded into compare_all.rs in commit 836cfb1; only the spin-flush variant is retained. The naive 1-ms-timer datapoint was ~11 ms, dominated by the sleep granularity — see "Published numbers" for why that mode is unsuited to an exchange path.

Loss simulation (separate run, requires root):

sudo tc qdisc add dev lo root netem loss 0.1%
cargo bench -p rsx-cast --bench compare_all
sudo tc qdisc del dev lo root

The bench itself does not depend on root or tc.

Note (2026-07-01): compare_all currently aborts on a KCP warmup panic (flush() before update(); bugs.md BENCH-KCP-FLUSH-NEEDUPDATE), so the number below is the last-measured 2026-05-24 figure, not re-run this session.

What this bench is and isn't

This bench measures application-visible loopback RTT using the same Criterion shape, payload size (128 B), and warmup methodology as cast_rtt_bench.rs — making kcp_spin_flush_128b size-comparable to casting's RTT bench (p50 ~9–10 µs on this host; cast_rtt_bench, re-run 2026-07-01).

What it does NOT measure: - Loss recovery (no tc injection in the bench itself). - Multi-stream / fan-out (KCP is single-stream by design). - WAN behaviour (loopback only). - Memory / CPU under sustained load (single-iteration RTT only).

Measured numbers (this host, 2026-05-24)

Bench p50
cmp_rtt_fill_echo (casting, 128 B) ~9.3 µs
kcp_spin_flush_128b ~17 µs
(removed) naive_1ms_interval ~11 ms (last-measured before folding)

The 17 µs spin number is roughly 1.8× casting — close to the lower bound of KCP's possible per-frame overhead (24 B header parse, ACK list maintenance, Rust port adapter copy). The 11 ms naive number is dominated by the 1 ms sleep granularity on each side.

Published numbers

From the KCP repository's own benchmark wiki (WAN, simulated loss; sender + receiver on separate hosts):

Protocol Worst-case sample, 10% loss
KCP turbo 195–295 ms
libenet 1 412–1 637 ms

KCP claims a 5–6× advantage over ENet under loss and 1.5×–2× over TCP under "average" loss conditions. These are the headline numbers KCP is known for; they are explicitly WAN/gaming.

Aeron loopback comparison (https://aws.amazon.com/blogs/industries/aeron-on-aws-2025-performance-benchmark-results/, c6in.16xlarge, 100k msg/s): - P50: 21–22 µs - P99: 32–43 µs

casting loopback RTT, this repo: - P50: ~9.3 µs (cast_rtt_bench, re-run 2026-07-01).

KCP and Aeron / casting do not compete in the same latency bracket even on zero-loss loopback.

Where KCP is genuinely better

  • Portability: ~1 000 LOC of standards C; ports exist in Go, Rust, Python, Java, JS, Swift, C#. casting is Rust-only.
  • Battle-tested on bad networks: gaming and VPN deployments prove KCP works in production with 5–30% loss. casting has never been tested on a public-internet path.
  • No persistence requirement: KCP works fine with no disk; rsx-cast casting assumes a WAL.
  • Multi-language reach: if you need a client in C# or Swift, KCP wins by existing.

Where casting is genuinely better

  • Loopback / LAN latency: ~10 µs RTT vs KCP's ~17 µs spin floor or millisecond timer-driven floor.
  • Audit log built in: WAL is the same byte stream as the wire and disk format; one log feeds retransmit, audit, backtesting, and ML training.
  • Long retransmit horizon: 4 h via WAL random-access vs bounded by ACK arrival.
  • Survives restarts: WAL replay reconstructs sender state exactly; KCP's snd_buf is in-process RAM only.
  • Zero control-plane traffic on success: no per-DATA ACK.

Rust ecosystem

Crate Notes
kcp v0.6 Pure Rust port; sync-friendly; MIT
tokio_kcp Async stream API on top of kcp
kcp-tokio Alternative async, claims zero-copy

This bench uses kcp v0.6 (the most direct port of the C reference) to keep the comparison close to the canonical implementation.

Sources

  • KCP repo: https://github.com/skywind3000/kcp
  • KCP English README: https://github.com/skywind3000/kcp/blob/master/README.en.md
  • kcp crate: https://crates.io/crates/kcp
  • KCP benchmark wiki: linked from the KCP repo README
  • Aeron AWS 2025 numbers: https://aws.amazon.com/blogs/industries/aeron-on-aws-2025-performance-benchmark-results/
  • casting local loopback numbers: .ship/18-COMPONENT-BENCHES/LANDSCAPE.md, commit 82e9966 baseline
  • Syscall floor: facts/syscall-latency.md

Full write-ups for the rest — Chronicle Queue, LBM (Informatica UM), Quinn/QUIC, SoupBinTCP, TCP, and a ~70-project census (niche.md) — plus the feature matrix live in rsx-cast/compare/README.md.