Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ only entries that can cost a reader an afternoon.

### Added

- `venues::BinanceSbeDecoder`: the Binance spot SBE binary streams (schema
`spot_stream` 1:0) — depth diff and depth snapshot — decoded to the same
`DecodedMessage` the JSON decoders produce, with the same route-by-symbol
and refuse-rather-than-round contracts, and SBE blockLengths honoured so a
newer minor schema skips cleanly. Tested against hand-encoded frames
including a byte-by-byte truncation sweep; a live capture awaits an
Ed25519 API key, which the endpoint requires even for public data.
- Busy-poll reads: `set_read_timeout(0)` on any transport (and
`WebSocketClient::set_read_timeout`) now means non-blocking spin, not the
platforms' block-forever. `crossbook_capture --busy-poll` uses it, and on
Linux reports a kernel-to-user delivery histogram from `SO_TIMESTAMPING`
receive stamps — measured live against Kraken, busy-poll cut delivery p50
from 150.4 us to 82.4 us.
- `Transport::last_rx_time_ns`: the kernel's arrival clock for the newest
received data, exposed through TLS on both backends. On the OpenSSL path
this forced reads through a custom BIO, since a backend that lets
`SSL_read` call `recv()` itself can never see the control message the
timestamp rides in on.
- Loopback tests for the transport layer — the busy-poll contract, the timed
path, and the receive timestamp — the first tests the socket has had.

- `net::ByteBuffer`: a `std::vector<char>` whose `resize` default-initializes
instead of zeroing. The frame reader and the Schannel backend grow their
receive buffers by a 32 KiB chunk on every socket read and trim back to what
arrived; with a plain vector that was 32 KiB of memset per read, all of it
over bytes the transport was about to overwrite.
- `CROSSBOOK_NATIVE` and the `release-native` preset: opt-in `-march=native`
(`/arch:AVX2` on MSVC) plus LTO, for measuring the ceiling on one's own
hardware. Off by default, and the README's numbers stay on plain release,
because a binary tuned to the build machine dies on the next machine.
- The no-allocation probe now covers the decoder, `Feed::handle` end to end
with the checksum verified, and the frame reader's poll loop. It covered
the book — 0.3% of the frame — while the claim it enforces is about the
whole hot path.
- `LATENCY-ROADMAP.md`: where the gap to professional software trading
systems actually is (environment tail, JSON ceiling, compute already
competitive) and the phase order for closing it, with sources.
- `json::for_each_member`: walk an object's members once, in wire order,
dispatching on key. A completed walk carries `well_formed`'s full guarantee,
which is what lets the decoders below drop their separate validation pass.
Expand All @@ -40,6 +77,12 @@ only entries that can cost a reader an afternoon.

### Changed

- The Schannel decrypt path copies plaintext once, straight into the caller's
buffer, instead of twice through an intermediate; and the unconsumed tail of
a pipelined TLS record is moved in place rather than through a freshly
allocated vector, which was a heap allocation on the common path — a busy
feed routinely lands the next record behind the current one in the same
segment.
- Both venue decoders are single-pass. A Kraken frame was being walked ~9x —
a `well_formed` pre-pass plus a `find` restart per field, with `checksum`
and `timestamp` spelled after the level arrays on the wire so each of those
Expand Down
24 changes: 24 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ option(CROSSBOOK_BUILD_FUZZ "Build the fuzz targets (clang only)" OFF)
option(CROSSBOOK_BUILD_EXAMPLES "Build the compilable examples" ${CROSSBOOK_IS_TOP_LEVEL})
option(CROSSBOOK_WERROR "Treat warnings as errors" ${CROSSBOOK_IS_TOP_LEVEL})

# Off by default and never inherited: a binary tuned for the build machine's
# ISA is a binary that dies with an illegal instruction on the next machine,
# and the README's numbers must come from the configuration a consumer gets
# by default. This exists for measuring the ceiling on one's own hardware.
option(CROSSBOOK_NATIVE
"Tune codegen for this machine (-march=native, /arch:AVX2 on MSVC) and enable LTO" OFF)

if(CROSSBOOK_NATIVE)
if(MSVC)
# MSVC has no -march=native; AVX2 is the widest ISA this project is
# willing to assume behind an explicit opt-in.
add_compile_options(/arch:AVX2)
else()
add_compile_options(-march=native)
endif()
include(CheckIPOSupported)
check_ipo_supported(RESULT crossbook_ipo_supported OUTPUT crossbook_ipo_message)
if(crossbook_ipo_supported)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
else()
message(STATUS "crossbook: LTO unavailable, continuing without it: ${crossbook_ipo_message}")
endif()
endif()

# Installing is a decision for whoever owns the prefix. A parent project that
# vendors crossbook and then runs `cmake --install .` must not have crossbook's
# headers appear in its prefix as a side effect it never asked for, so the
Expand Down
9 changes: 9 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@
"CROSSBOOK_BUILD_EXAMPLES": "ON"
}
},
{
"name": "release-native",
"inherits": "release",
"displayName": "Release, tuned for this machine",
"description": "Adds -march=native (MSVC: /arch:AVX2) and LTO. Numbers from this preset describe one machine and are not comparable across hosts; the README's figures come from the plain release preset.",
"cacheVariables": {
"CROSSBOOK_NATIVE": "ON"
}
},
{
"name": "bench",
"inherits": "release",
Expand Down
151 changes: 151 additions & 0 deletions LATENCY-ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Latency roadmap

Written 2026-08-02, from a four-track research pass: a file-level inventory of
this repository's hot path, and web research into wire formats, software
tick-to-trade practice, and tail-latency engineering. Claims below that came
from outside sources carry links; figures computed rather than measured are
marked as such. This document records where the gap to professional software
trading systems actually is, and the order in which to close it.

## Where the gap is

The reference point for a software (non-FPGA) trading system is roughly
2.5 us wire-to-wire ([Carl Cook, CppCon 2017](https://www.youtube.com/watch?v=NH1Tta7purM)).
Against that, this library's position splits three ways:

1. **The compute core is already in the right league.** ~8 ns book updates,
~600 ns checksum, ~2.1 us for a fully verified frame. Not the problem.
2. **The tail is the environment, not the code.** At 6k msg/s the handler sits
at ~1.2% utilization; M/D/1 queueing puts the p99 physics floor around
9-14 us. The measured 912 us p99 is ~100x that — Windows scheduler quanta,
DPCs, C-state exits. A pinned busy-polling thread on an isolated core of
tuned Linux measures p99 1.4 us / p99.9 4.9 us / max 18 us of induced
jitter ([Rigtorp, hiccups](https://github.com/rigtorp/hiccups)); Windows
has a hard ~100 us p99.9 floor from DPCs that no user-mode setting removes
([LatencyMon docs](https://resplendence.com/latencymon_using)).
3. **JSON is the structural ceiling on the median.** Decode is ~1.9 us, 75% of
the frame. simdjson-class techniques put a validated ~300-byte frame at
roughly 200-500 ns (computed from published GB/s throughput, not a bench),
and binary venue feeds remove the cost entirely.

One calibration that bounds the whole effort: measured from AWS Tokyo,
Binance's own websocket delivery is avg 4 ms, p99 13 ms
([Deltix/Ember](https://ember.deltixlab.com/docs/performance/ws-market-data/)).
On crypto venues the exchange side is milliseconds; the competitive variable
is tail determinism, not median parse time.

## What the venues offer (2026)

| Venue | Fastest wire | Engine location |
|---|---|---|
| Kraken | FIX L3 (tag=value; no binary feed exists) | Equinix London; Beeks hosted colo |
| Binance spot | SBE WebSocket incl. L2 diff depth (Ed25519 key) | AWS Tokyo ap-northeast-1 |
| Deribit | SBE multicast, plaintext UDP; Starbase SBE L3 + order entry | Equinix LD4; AWS eu-west-2/ap-northeast-1 |
| Coinbase Exchange | FIX 5.0 L3 market data | AWS us-east-1 (use1-az4) |
| OKX / Bybit | JSON WebSocket only (public) | AWS HK / AWS Singapore |

Sources: [Kraken L3](https://docs.kraken.com/exchange/guides/general/l3-data),
[Binance SBE streams](https://developers.binance.com/docs/binance-spot-api-docs/sbe-market-data-streams),
[Deribit multicast](https://insights.deribit.com/exchange-updates/launch-of-our-new-multicast-service/),
[Deribit Starbase](https://insights.deribit.com/exchange-updates/starbase-a-new-era-of-high-performance-trading-on-deribit/),
[Coinbase FIX MD](https://docs.cdp.coinbase.com/exchange/fix-api/market-data).

## Phases

**Phase 0 — repository defects (done in the commit series that added this
document, except where noted).**

- `FrameReader::writable_tail` value-initialized 32 KiB per socket read
(`vector::resize` zeroing), immediately shrunk back by `commit`. Same
pattern on the Schannel ciphertext buffer.
- The Schannel decrypt path heap-allocated a fresh `std::vector` per
pipelined TLS record and copied plaintext twice (OpenSSL path copies once).
- The no-allocation test enforced the book but not `Feed::handle` or the
decoders — the 75% of the frame the claim was actually about.
- No opt-in `-march`/LTO configuration existed.

**Phase 1 — prove the tail on tuned Linux.** The repo already builds, tests,
and replays on Linux in CI, and `crossbook_verify` already carries `--pin`
and `--realtime`. On a box tuned per the standard recipe (isolcpus +
nohz_full + rcu_nocbs, IRQ affinity away, performance governor, C-states
capped at C1, SMT off, mlockall — [Rigtorp's guide](https://rigtorp.se/low-latency-guide/)),
qualified first with hwlatdetect and rtla osnoise, the existing `--sweep`
should collapse from 912 us p99 to tens of microseconds with zero code
changes. Publish the tuned-vs-untuned pair; it is the honest-measurement
story this README already tells, completed.

*Measured so far (2026-08-02), same desktop, WSL2 Ubuntu — a VM, not tuned
metal, and still directional:* the identical sweep binary showed p50 falling
from 20-39 us (Windows) to 2.7-3.2 us and top-rung p99 from 912 us to
144 us — a 6.3x tail improvement from the OS change alone. Two predicted
effects reproduced: SCHED_FIFO inside the VM is catastrophic (the spin
starves its own vCPU; p50 collapsed to milliseconds), and the lowest-rate
rung pays the deep-idle wakeup (8 ms p99 at 5 msg/s). Real metal with
isolation remains the open item.

**Phase 2 — transport for latency.** *Done (2026-08-02), except the
zero-copy read contract.* `set_read_timeout(0)` now means busy-poll on
every transport; Linux reads carry the kernel's `SO_TIMESTAMPING` arrival
stamp (learned the hard way: `SIOCGSTAMP` and `SO_TIMESTAMPNS` are both
dead ends for TCP), and the OpenSSL backend reads through `TcpSocket` via a
custom BIO so timestamps and busy-poll survive TLS. Verified live against
Kraken: busy-poll cut kernel-to-user delivery p50 from 150.4 us to 82.4 us
(unpinned WSL2; the p99 wants the isolated core Phase 1 provides). Still
open: a zero-copy read contract — copy-in `read(buf, len)` cannot express
an rx ring. Skip kTLS: RX-path p99 regressions
([netdev paper](https://netdevconf.info/1.2/papers/ktls.pdf)) and it blocks
the Onload route. Steady-state TLS crypto is under 1 us/record (computed from
~0.64 cycles/byte AES-GCM) and is not the problem.

**Phase 3 — a binary venue decoder.** *Decoder done (2026-08-02); live
connection blocked on a credential.* `BinanceSbeDecoder`
(`crossbook/venues/binance_sbe.hpp`) decodes the spot SBE depth diff and
depth snapshot streams (schema `spot_stream` 1:0) into the same
`DecodedMessage` the JSON decoders produce, drives `Feed` end to end, and
honours SBE blockLengths for forward compatibility. Tested against
hand-encoded frames including a full truncation sweep — the endpoint
(`stream-sbe.binance.com:9443`) requires an Ed25519 API key even for public
data, so a live capture and a captured-fixture replay await a key. Deribit
SBE multicast follows if derivatives matter — the only feed anywhere that
removes TLS entirely. Kraken's lever is placement plus FIX L3, not
encoding.

**Phase 4 — the JSON decode floor, for venues stuck with it.** Levers in
order: key dispatch by length/first byte instead of chained `string_view`
compares; deriving canonical-spelling during the parse instead of
re-formatting and byte-comparing every scalar; optionally a SIMD structural
stage. Separately, the checksum's ~600 ns is dominated by re-serializing 20
levels per message — maintain the top-10 payload incrementally as levels
change instead.

One lever is measured dead and removed from the list: replacing the
per-digit checked multiply/add in `parse_fixed` with an unchecked fast path
below 19 digits. Interleaved same-state A/B (MSVC /O2, two rounds, median
of 7) showed the change within noise or marginally slower — the compiler
already handles the checked arithmetic well, and the digit loop is not
where decode time lives. The change was reverted; measure before believing
any remaining lever.

**Phase 5 — placement and bypass.** In-region metal (c7i/c8g/m8azn) in a
shared cluster placement group measures ~20 us p50 / ~23 us p99.9
instance-to-instance ([AWS tick-to-trade series](https://aws.amazon.com/blogs/web3/optimize-tick-to-trade-latency-for-digital-assets-exchanges-and-trading-platforms-on-aws-part-2/));
exchanges pull market makers into their placement groups. Onload is the
drop-in kernel bypass for a TCP+TLS websocket client (sockets-compatible,
~6 us plus most network jitter); ef_vi/TCPDirect is a rewrite that buys the
last few hundred nanoseconds and comes last.

## Expected position

| Stage | p50/frame | p99 under load |
|---|---|---|
| Untuned Windows desktop (today) | ~2.1 us compute | 912 us |
| Phase 1: tuned Linux, same code | same | ~10-30 us |
| Phases 2-3: busy-poll + SBE venue | ~0.5-1 us | ~5-15 us |
| Phase 5: in-region metal + bypass | sub-us compute | ~20-25 us incl. cloud network |

The last row is competitive with the crypto-native trading tier. The
remaining distance to traditional-HFT numbers is the venues themselves,
which deliver data in milliseconds. Two standing caveats: several
per-technique figures above are computed or single-source, and nothing here
measures tick-to-trade until an order path exists — this repository has no
egress, so end-to-end latency is unmeasurable by construction.
60 changes: 60 additions & 0 deletions include/crossbook/net/byte_buffer.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Josh Dardashti
//
// A byte buffer whose resize does not zero.
//
// std::vector<char>::resize value-initializes every new element, which for a
// receive buffer means a memset over bytes the transport is about to
// overwrite anyway. The reassembly path grows its buffer by 32 KiB on every
// socket read and then shrinks it back to what was actually received; with a
// plain vector that is 32 KiB of zeroing per read, all of it wasted.
//
// The standard fix: an allocator whose construct() default-initializes
// instead of value-initializing. For trivially default-constructible types,
// default-initialization is a no-op, so resize becomes pure bookkeeping.
// Everything else about std::vector — growth policy, iterator semantics,
// exception guarantees — is unchanged.

#pragma once

#include <memory>
#include <utility>
#include <vector>

namespace crossbook::net::detail {

template <typename T, typename Base = std::allocator<T>>
class DefaultInitAllocator : public Base {
public:
template <typename U>
struct rebind {
using other =
DefaultInitAllocator<U, typename std::allocator_traits<Base>::template rebind_alloc<U>>;
};

using Base::Base;

/// The point of the class: `new (p) U` default-initializes, so for byte
/// buffers no memory is written until the caller writes it.
template <typename U>
void construct(U* p) noexcept(std::is_nothrow_default_constructible_v<U>) {
::new (static_cast<void*>(p)) U;
}

/// Constructions with arguments (insert, push_back, range copies) keep
/// their ordinary value semantics.
template <typename U, typename... Args>
void construct(U* p, Args&&... args) {
std::allocator_traits<Base>::construct(static_cast<Base&>(*this), p,
std::forward<Args>(args)...);
}
};

} // namespace crossbook::net::detail

namespace crossbook::net {

/// Receive-path byte storage: a std::vector<char> whose resize is free.
using ByteBuffer = std::vector<char, detail::DefaultInitAllocator<char>>;

} // namespace crossbook::net
14 changes: 14 additions & 0 deletions include/crossbook/net/transport.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,22 @@ class Transport {
/// loop stays responsive to shutdown and to its own deadlines. Using one
/// value for both means choosing between a client that gives up on a slow
/// connect and a loop that hangs for ten seconds on every quiet market.
///
/// ZERO MEANS BUSY-POLL: the socket goes non-blocking and reads return
/// kTimeout immediately when nothing is buffered, so a caller on a
/// dedicated core can spin instead of taking a scheduler wakeup per
/// message. It does not mean "no timeout" — that spelling of zero is the
/// platforms', and it is never what a latency-sensitive reader wants.
virtual void set_read_timeout(int timeout_ms) = 0;

/// Kernel arrival time of the most recently received data, CLOCK_REALTIME
/// nanoseconds, or 0 where the platform offers none for TCP (Windows,
/// macOS) or nothing has arrived. On Linux this is per socket and queried
/// on demand, so it works identically under TLS. One read draining
/// several coalesced segments reports the newest — callers measuring
/// kernel-to-user delivery own that approximation.
[[nodiscard]] virtual std::int64_t last_rx_time_ns() const noexcept { return 0; }

virtual void close() = 0;

[[nodiscard]] virtual bool connected() const noexcept = 0;
Expand Down
9 changes: 9 additions & 0 deletions include/crossbook/net/websocket.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ class WebSocketClient {
/// Send a close frame and drop the connection.
void close(CloseCode code = CloseCode::kNormal, std::string_view reason = {});

/// Change the read timeout after connecting. Zero selects busy-poll: poll
/// returns kNeedMore immediately instead of sleeping in the kernel, for a
/// caller that owns a core and spins. See Transport::set_read_timeout.
void set_read_timeout(int timeout_ms);

/// Kernel arrival time of the newest received data (CLOCK_REALTIME ns),
/// 0 where the platform has none. See Transport::last_rx_time_ns.
[[nodiscard]] std::int64_t last_rx_time_ns() const noexcept;

[[nodiscard]] bool connected() const noexcept;
[[nodiscard]] const std::string& last_error() const noexcept { return error_; }
[[nodiscard]] const Url& url() const noexcept { return url_; }
Expand Down
10 changes: 8 additions & 2 deletions include/crossbook/net/ws_frame.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
#include <string_view>
#include <vector>

#include "crossbook/net/byte_buffer.hpp"

namespace crossbook::net {

/// RFC 6455 §5.2 opcodes.
Expand Down Expand Up @@ -531,8 +533,12 @@ class FrameReader {
static constexpr std::size_t kCompactThreshold = 32 * 1024;

std::size_t max_message_bytes_;
std::vector<char> buf_;
std::vector<char> assembled_;
// ByteBuffer, not std::vector<char>: `writable_tail` grows this by a
// 32 KiB read chunk on every transport read and `commit` trims it back,
// so a value-initializing resize would memset 32 KiB per read for bytes
// recv is about to overwrite.
ByteBuffer buf_;
ByteBuffer assembled_;
std::array<char, kMaxControlPayload> control_{};
std::size_t control_len_{0};
std::size_t read_pos_{0};
Expand Down
Loading
Loading