rsx-cast
Reliable UDP whose retransmit source IS the WAL the producer writes for audit and replay.← All CratesDescription
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.
CastSenderassigns monotonic seq, sends, and caches the encoded frame in a preallocated ring.CastReceiverdetects gaps from out-of-order delivery or from idle-tail heartbeat skew and sendsNak. Sender retransmits from the ring; if the seq has aged out, falls back toread_record_at_seqagainst 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):
ReplicationServicestreams historical records fromWalReaderthen transitions to a live tail onWalWriter::add_listenernotifications.ReplicationConsumerresumes 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_segmentsruns at the end of everyrotate(). mtime-based; deletes rotated segments older thanRETENTION_NS(4h; const inwal.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
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::
| 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::send→CastReceiver::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
WalHeaderframing + CRC32C verification send_ringslot 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:
- Downstream UDP multicast carries the live stream (one-to-many).
- 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
- 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
CastSenderinstance 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/bswap16on 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.
- 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. - Cold tier: a NAK whose
from_seqpredates the hot ring falls through toread_record_at_seqon 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/taggedper 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:
- Every received segment triggers an explicit
IKCP_CMD_ACKback to the sender, plus a piggybackeduna(cumulative ACK) on every outgoing frame. - When the sender sees ACK(N+2), ACK(N+3) but no ACK(N), it
increments
fastackon segment N. fastack >= resend(turbo:resend=2) → fast retransmit without waiting for RTO.- 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:
- The dominant latency is the
sendtosyscall (~3.85 µs measured locally, seefacts/syscall-latency.md), not loss recovery. - Every per-DATA ACK doubles control-plane traffic vs casting's NAK-on-gap model.
- Integer-millisecond RTO is incompatible with a sub-100 µs SLA.
- 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, explicitflush()after everysend(). 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_allcurrently aborts on a KCP warmup panic (flush()beforeupdate();bugs.mdBENCH-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_bufis 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
kcpcrate: 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.