ADR-0015: UDP multicast market data and kernel-bypass receive¶
Status: accepted (2026-09)
fastmm-live receives exchange multicast feeds, starting with Nasdaq TotalView-ITCH 5.0 over MoldUDP64 with GLIMPSE 5.0 for the initial book. Datagrams arrive through one of two backends: kernel UDP sockets or AF_XDP. This record amends ADR-0014, which listed live ITCH as out of scope. Order entry to Nasdaq (OUCH, sponsored access) stays out of scope.
Context¶
- Every live venue today is WebSocket over TLS on the kernel TCP stack. T0 is taken in the feed's
on_message, after the socket read, TLS and WebSocket framing (binance_md_feed.hpp). - What exists:
moldudp::Receiverdetects gaps and sends request packets.ItchDecoderturns A/F/E/C/X/D/U into L3 messages and P/Q intoTradeMsg.- Also
L3Book,ItchEncoder,moldudp::Transmitter, the SoupBinTCP client and server sessions, and client-side OUCH 5.0 codecs. - What is missing:
- Nothing opens a UDP socket.
- The engine ignores L3 messages (
engine.hpp,case EventType::OrderAddL3 ... break). ItchDecoderdoes not sett0_cycles.moldudp::Receiverhas three limits: it drops packets that arrive ahead of a gap, it has no A/B arbitration, and it stamps requests with the time of the laston_timer.Mdp3Feeddoes A/B arbitration with a reorder window and recovers from CME's snapshot channel.- ITCH carries per-order changes, and the stock directory (
Rmessages) comes once before the open. A receiver that joins mid-day, or that loses packets it cannot recover, has no book to apply changes to. Nasdaq serves the current state over GLIMPSE (SoupBinTCP): directory, open orders, and an End of Snapshot message with the ITCH sequence number to continue from. L2Book::is_valid()requires a snapshot (BookDeltaMsgwithkSnapshot) before deltas count (l2_book.hpp).- Kernel-bypass options on Linux:
- ef_vi and Onload need AMD Solarflare NICs. Onload accelerates unmodified socket code through
LD_PRELOAD; ef_vi is its own API. - DPDK takes the NIC away from the kernel and needs hugepages, a poll-mode driver per NIC family and the EAL. ADR-0007 keeps third-party network libraries out.
- AF_XDP is in mainline Linux. It runs on any netdev in generic (copy) mode, and without copies on drivers with zero-copy support (mlx5, ice, i40e, ixgbe, igc). An XDP program redirects matching packets to the socket and passes the rest, IGMP and ARP included, to the kernel.
- Development machine (WSL2, Linux 6.6,
hv_netvsc, MTU 1280): CONFIG_XDP_SOCKETSand veth are available.- An unprivileged
unshare -Urnnamespace can create veth pairs and enable multicast onlo. - Unprivileged BPF is disabled, so loading an XDP program needs root.
- Neither veth nor
hv_netvscsupports AF_XDP zero-copy or hardware timestamps.
Decision¶
1. Receive interface¶
net::DatagramSource is a concept, not a virtual class. The net thread calls poll(handler), which delivers a batch of datagrams and returns the count:
struct RxMeta {
Cycles t0_cycles; // rdtscp when the batch was taken from the kernel or RX ring
std::int64_t t0_wall_ns; // CLOCK_REALTIME read next to t0_cycles
std::int64_t sw_ts_ns; // kernel RX timestamp (CLOCK_REALTIME), 0 when unavailable
std::int64_t hw_ts_ns; // NIC timestamp (PHC time), 0 when unavailable
std::uint32_t src_ip, dst_ip;
std::uint16_t dst_port;
std::uint8_t line; // index of the subscription (A/B)
};
// handler: void(std::span<const std::byte> payload, const RxMeta&) noexcept
- Payload spans are valid only until
pollreturns. Anything kept longer is copied. - A subscription is
(interface, group, port, source?). A and B lines may use different interfaces. - No backend allocates after
open.
2. Kernel backend (kernel)¶
- One UDP socket per subscription, set up as follows:
- bound to the group and port with
SO_REUSEADDR; - joined with
IP_ADD_MEMBERSHIPorIP_ADD_SOURCE_MEMBERSHIPon the subscription's interface; SO_RCVBUFfrom config.- Reads with one
recvmmsgper socket perpoll, a fixed batch (default 32). With an edge-triggered reactor the caller polls untilpollreturns 0. - Turns off
IP_MULTICAST_ALL, so a socket receives only the groups it joined. SO_TIMESTAMPINGrequests software and raw hardware RX timestamps. Hardware timestamps need the NIC set up withSIOCSHWTSTAMP(HWTSTAMP_FILTER_ALL,CAP_NET_ADMIN). That is off by default (hw_timestamps = trueturns it on).- In
spin_mode = "busy", setsSO_BUSY_POLLandSO_PREFER_BUSY_POLL. - Capabilities:
SO_PREFER_BUSY_POLLneedsCAP_NET_ADMIN. So does raisingSO_BUSY_POLLon Linux before 6.4. - Both only take effect with the netdev's
napi_defer_hard_irqsandgro_flush_timeoutset. - Options the kernel refuses are logged, and the socket polls from user space.
- Runs unmodified under Onload (
onload --profile=latency fastmm-live ...). This is the way to use Solarflare NICs until an ef_vi backend exists.
3. AF_XDP backend (af_xdp)¶
Minimum Linux 5.11. Hand-written over raw syscalls, with no libbpf, libxdp or BPF compiler.
- UMEM: one per XDP socket,
frame_countframes of 4096 bytes (default 4096), mmapped atopen, with fill, RX and completion rings and no TX ring. - Sockets: one XDP socket per
(interface, queue)pair, and one net thread polls all of them. - Multicast lands on the RX queue picked by RSS. Operators steer the groups to the configured queue with
ethtool -N <if> flow-type udp4 dst-ip <group> action <q>. - XDP program: BPF bytecode kept as an array in the source.
- It parses Ethernet, one optional 802.1Q tag, IPv4 (IHL masked and bounded for the verifier; fragments pass) and UDP.
- It looks up
(dst_ip, dst_port, pad = 0)in a hash map and callsbpf_redirect_map(xsks_map, rx_queue_index, XDP_PASS). - A queue with no socket therefore falls back to the kernel instead of dropping; a per-CPU array counts those packets.
- The map does not check the source address, so
IP_ADD_SOURCE_MEMBERSHIPfiltering does not apply on this path. - Unicast traffic, including re-request answers, goes to the kernel.
- Attach: through
BPF_LINK_CREATEwithBPF_XDP, so the program goes away when the last link fd closes (the link is not pinned). - Mode order: native attach with a zero-copy bind, then native attach with a copy bind, then generic attach (
XDP_FLAGS_SKB_MODE) with a copy bind. xdp_modepins one mode. The chosen mode is logged and exported in status.- An interface that already has an XDP program fails
openwithEBUSY. - IGMP: a kernel UDP socket per subscription still joins the group so IGMP reports reach the switch. It reads nothing.
- User-space checks: the parser repeats the Ethernet/VLAN/IPv4/UDP checks, checks lengths, and counts bad frames.
- The UDP checksum is not verified by default (
verify_udp_checksum). Frames from a local sender through veth with TX checksum offload carry only the pseudo-header sum and fail the check. - RX descriptors return to the fill ring when
pollreturns. - Polling: uses
XDP_USE_NEED_WAKEUP. - In busy mode, the socket gets
SO_PREFER_BUSY_POLLandSO_BUSY_POLL_BUDGET, and the fill ring is refilled on every poll. - In adaptive mode, the reactor waits on the XSK fd.
- Status:
XDP_STATISTICS(rx_dropped,rx_invalid_descs,rx_ring_full,rx_fill_ring_empty_descs) and the fallback counter go to status. - Capabilities:
CAP_NET_ADMIN,CAP_NET_RAW,CAP_BPFandCAP_IPC_LOCK. WithoutCAP_IPC_LOCKthe UMEM counts againstRLIMIT_MEMLOCK, often 8 MB. openfails with the missing capability and thesetcapcommand. It does not fall back to the kernel backend.
4. MoldUDP64 session layer¶
- Reorder buffer:
moldudp::Receivercopies up toreorder_packetspackets (default 256) that arrive ahead of a gap into a slab allocated at construction. Each copy keeps its receive metadata, so messages drained later keep their original T0. - When the buffer is full, later packets are dropped and counted, and recovery continues from requests.
- Metadata:
on_packettakes a line index,now_nsand a metadata value that is passed through toon_message. The last one makes the stale request timestamp go away. - A/B arbitration: the receiver accepts packets from any line.
- The first copy of a sequence wins. Later copies count as duplicates per line.
- The receiver records the arrival skew between lines.
- A gap is declared after
gap_timeout_ns(default 2 ms, as inMdp3Feed) without the missing sequence on any line. Operators size it from the exported skew. - Retransmission: requests go to
rerequest(host:port) from a kernel UDP socket. - Without
rerequest, or when a request times outmax_request_attemptstimes, the gap is unrecoverable. The feed then starts recovery (section 5). - End of Session: after End of Session, packets from a new session are adopted only with
follow_session = true(default false).
5. ITCH feed venue (kind = "nasdaq_itch")¶
- Startup and recovery:
- The venue joins multicast first and buffers up to
recovery_buffer_packetsdatagrams (default 65536, allocated atopen). - It then logs in to GLIMPSE over SoupBinTCP and builds the directory and the L3 books from the snapshot.
- It applies the buffered and live ITCH messages from the sequence in End of Snapshot.
- Books stay
Resyncinguntil then. - The same procedure runs after a gap the receiver gives up, after any L3 book error, and when the buffer overflows during a snapshot. Overflowing during two snapshots in a row trips the venue's kill switch (
KillReason::FeedLost). - If the buffer starts after the End of Snapshot sequence, the receiver is moved back (
Receiver::reset) and the hole is re-requested; withoutrerequest, another snapshot is taken. - Without
glimpse_url, the venue must start before the directory spin (sequence 1) and cannot recover from a gap;openwarns. - Bridge, on the net thread: source → MoldUDP receiver →
ItchDecoder→ oneL3Bookper configured instrument → L2 messages to the engine: BookDeltaMsgwithkSnapshot(topdepthlevels per side, default 20) when a book becomes complete, andConnectionStateMsg{Resyncing}when it stops being complete.- After that, at most one
BookDeltaMsgper instrument per datagram. Levels carry absolute quantities, 0 deletes. A level that enters the topdepthbecause another one emptied is included. TradeMsgfor P/Q, and for E and C against displayed orders. Price comes from the resting order for E and from the message for C. The aggressor side is opposite to the resting side. C with Printable = N is not a trade; the decoder keeps that flag instead of dropping it.RandSmessages are decoded whatever their locate. Other messages for locates that are not configured are skipped after the 11-byte header.L3Bookchanges:- Capacity and price window become constructor arguments (
price_window_ticks,max_ordersfrom config) instead of template parameters. - Orders outside the window (stub quotes) are kept in an overflow store, so they neither drop nor trigger a recentre on every add.
- Prices use ITCH's 4 decimals, tick 0.0001.
- Timestamps:
- Every message of a datagram carries that datagram's
t0_cycles. t1_deltais taken after decoding and the L3 update.recv_tsissw_ts_ns, or the wall clock when that is missing.hw_ts_nsreplaces it only withhw_clock = "phc_synced".- The net thread records the kernel-to-T0 hop (
t0_wall_ns - sw_ts_ns) in a venue histogram, the same wayWireLatencyRecorderdoes. The engine'sLatencyIntervaldoes not change. - Order entry:
order_entry = "none"(default) or"sim_ouch". - With
none, API keys are not required:resolve_venue_envskips the check for this kind. Orders are rejected by the venue with the existingVenueReject. sim_ouchsends OUCH 5.0 over SoupBinTCP tofastmm-sim-itchonly (section 6). The ClOrdID isTand 13 digits: the MoldUDP64 sequence of the first datagram of the receive batch that triggered the order. The engine copies onlyt0_cyclesinto outbound messages, so the venue mapst0_cyclesto that sequence in a 4096-slot table.- GLIMPSE and OUCH logins use
glimpse_username/glimpse_passwordandouch_username/ouch_password. - Engine: no changes.
Venuegainspoll(), called after every reactor iteration; in busy mode the venue polls its sources there, in adaptive mode the reactor waits on their descriptors. - The sources open in
load_reference_data, so a missing capability or interface fails the start (exit code 4). - Status (
kStatusVersion4): - packets, bytes, per-line packets, duplicates and skew;
- gaps, recovered and unrecovered sequences, snapshot recoveries;
- reorder high-water mark, requests, malformed frames;
- the kernel-to-T0 p50/p99;
- the AF_XDP statistics and mode.
6. Simulator and end-to-end benchmark¶
fastmm-sim-itchrunsMatchingEnginewithMarketGenerator.- It encodes ITCH, packs MoldUDP64 and sends to A and B groups with
sendmmsg. Rate, burst size and a drop rate per line are configurable. - It answers re-requests from its
Transmitterhistory and serves GLIMPSE from the matching engine state. - It accepts OUCH 5.0 orders over SoupBinTCP. This needs server-side OUCH encoding and decoding on the existing message structs.
- Wire-to-wire: for every Enter or Replace Order whose token names a sequence, the simulator records the time from
sendmmsgof that datagram to receiving the order. - It uses its own TSC, stamped per datagram, independent of the engine's instrumentation.
scripts/bench-e2e.shruns the simulator andfastmm-livein two network namespaces joined by a veth pair, pinned to separate cores. Market data and orders cross the veth.- It reports wire-to-wire and the engine's hops at p50, p99 and p99.9 for each backend.
- The kernel backend runs in
unshare -Urn.af_xdpruns undersudo scripts/bench-e2e.sh --backend af_xdp. - veth has no NAPI busy polling without XDP, no zero-copy and no NIC timestamps. The results compare the backends on this machine; the benchmarks page names the mode measured.
7. Not in this record¶
- ef_vi.
DatagramSourcealready provides batch delivery, spans valid until return and hardware timestamps; it becomes a backend when a Solarflare NIC is available to test on. - DPDK (added later as an optional
rx_backend; see the CHANGELOG). - AF_XDP hardware RX timestamps through XDP metadata kfuncs (Linux 6.3+ and driver support).
- CME MDP3 and IEX over multicast. The source and session layers do not depend on the feed, and
Mdp3Feedalready takes datagrams. - IPv6 multicast.
- Splitting L3 work across threads when one feed carries many configured symbols.
Consequences¶
- Maintenance:
- A second network path, with a BPF program kept as bytecode; changes to it go through the verifier tests below.
L3Bookmoves from template capacities to runtime capacities.- AF_XDP needs capabilities, so
fastmm-livewithaf_xdpruns withsetcapor as root. The AF_XDP tests run throughsudo scripts/xdp-test.sh, andctestskips them without privileges. - The net thread does the L3 work: one
L3Bookupdate per ITCH message for configured symbols. The engine sees L2 changes withindepth. - ADR-0014's "live ITCH out of scope" is replaced for market data. Order entry to Nasdaq is unchanged.
Implementation order¶
net::UdpSocketand thekernelDatagramSource: multicast join,recvmmsg, timestamps, busy-poll options. Tests: multicast in anunshare -Urnnamespace, timestamps, batch boundaries, truncation, no allocation inpoll.- MoldUDP64 receiver: reorder slab with metadata, A/B arbitration,
gap_timeout_ns, request attempts, session follow. Tests: two lines with independent drops, reordering and duplicates over 20 seeds, with and without retransmission; every message is delivered once and in order. L3Bookruntime capacities and overflow store; the ITCH-to-L2 bridge with snapshots, trades, and T0/T1. Tests: property test against a reference book rebuilt from the same messages, including stub quotes and C/N executions;noalloc.- GLIMPSE client and the
nasdaq_itchvenue: config keys, status version 4,fastmm-topcolumns. Tests: join mid-stream, buffered replay from End of Snapshot, recovery after an unrecoverable gap. fastmm-sim-itchwith GLIMPSE and the SoupBinTCP/OUCH gateway, the venue'ssim_ouchpath, andscripts/bench-e2e.shwith the kernel backend. Results go inbench/README.md.- AF_XDP backend: UMEM and rings, BPF bytecode,
BPF_LINK_CREATE, mode fallback, capability check, statistics. Tests (all throughsudo scripts/xdp-test.sh): verifier load,BPF_PROG_TEST_RUNagainst crafted frames compared with the user-space parser, and receive over veth in each mode. Thenbench-e2e.sh --backend af_xdp. - Docs:
- a how-to for multicast feeds (capabilities, queue steering,
napi_defer_hard_irqs,isolcpus, IRQ affinity); - the configuration reference;
- the event-flow page with the new T0.
Steps 1, 2 and 6 are independent. 3 needs 2. 4 needs 1 and 3. 5 needs 4.