perf: retain snapshots by generation

This commit is contained in:
floor-licker
2026-06-22 13:46:29 -04:00
parent ebc2929b1e
commit ddbacf5fbb
3 changed files with 102 additions and 104 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ Real-world Polymarket API latency broken down by request phase:
| **Order Book Updates (1000 ops)** | 159.6 µs ± 32 µs | 6,260 updates/sec, zero-allocation | | **Order Book Updates (1000 ops)** | 159.6 µs ± 32 µs | 6,260 updates/sec, zero-allocation |
| **Spread/Mid Calculations** | 70 ns ± 77 ns | 14.3M ops/sec, optimized BTreeMap | | **Spread/Mid Calculations** | 70 ns ± 77 ns | 14.3M ops/sec, optimized BTreeMap |
| **JSON Parsing (480KB)** | ~2.3 ms | SIMD-accelerated parsing (1.77x faster than serde_json) | | **JSON Parsing (480KB)** | ~2.3 ms | SIMD-accelerated parsing (1.77x faster than serde_json) |
| **WS `book` hot path (decode + apply)** | ~0.27 µs / 7.46 µs / 93.24 µs | 1 / 16 / 64 levels-per-side, strict 4dp fixed-point tape parser, no Decimal/rounding/clamping in the feed path (see `benches/ws_hot_path.rs`) | | **WS `book` hot path (decode + apply)** | ~0.23 µs / 1.73 µs / 6.74 µs | 1 / 16 / 64 levels-per-side, strict fixed-point tape parser with generation-marked snapshot retention (see `benches/ws_hot_path.rs`) |
Run the WS hot-path benchmark locally with `cargo bench --bench ws_hot_path`. Run the WS hot-path benchmark locally with `cargo bench --bench ws_hot_path`.
+100 -70
View File
@@ -9,6 +9,12 @@ use std::collections::BTreeMap; // BTreeMap keeps prices sorted automatically -
use std::sync::{Arc, RwLock}; // For thread-safe access across multiple tasks use std::sync::{Arc, RwLock}; // For thread-safe access across multiple tasks
use tracing::{debug, trace, warn}; // Logging for debugging and monitoring use tracing::{debug, trace, warn}; // Logging for debugging and monitoring
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StoredLevel {
qty: Qty,
generation: u64,
}
/// High-performance order book implementation /// High-performance order book implementation
/// ///
/// This is the core data structure that holds all the live buy/sell orders for a token. /// This is the core data structure that holds all the live buy/sell orders for a token.
@@ -40,20 +46,23 @@ pub struct OrderBook {
/// Key = price in ticks (like 6500 for $0.65), Value = size in fixed-point units /// Key = price in ticks (like 6500 for $0.65), Value = size in fixed-point units
/// ///
/// BEFORE (slow): bids: BTreeMap<Decimal, Decimal>, /// BEFORE (slow): bids: BTreeMap<Decimal, Decimal>,
/// AFTER (fast): bids: BTreeMap<Price, Qty>, /// AFTER (fast): bids: BTreeMap<Price, StoredLevel>,
/// ///
/// Why this is faster: /// Why this is faster:
/// - Integer comparisons are ~10x faster than Decimal comparisons /// - Integer comparisons are ~10x faster than Decimal comparisons
/// - No memory allocation for each price level /// - No memory allocation for each price level
/// - Better CPU cache utilization (smaller data structures) /// - Better CPU cache utilization (smaller data structures)
bids: BTreeMap<Price, Qty>, bids: BTreeMap<Price, StoredLevel>,
/// Ask side (price -> size, sorted ascending) - NOW USING FIXED-POINT! /// Ask side (price -> size, sorted ascending) - NOW USING FIXED-POINT!
/// BTreeMap keeps lowest asks first - people selling at cheapest prices /// BTreeMap keeps lowest asks first - people selling at cheapest prices
/// ///
/// BEFORE (slow): asks: BTreeMap<Decimal, Decimal>, /// BEFORE (slow): asks: BTreeMap<Decimal, Decimal>,
/// AFTER (fast): asks: BTreeMap<Price, Qty>, /// AFTER (fast): asks: BTreeMap<Price, StoredLevel>,
asks: BTreeMap<Price, Qty>, asks: BTreeMap<Price, StoredLevel>,
/// Snapshot generation used to retain book levels without rescanning input payloads.
snapshot_generation: u64,
/// Minimum tick size for this market in ticks (like 10 for $0.001 increments) /// Minimum tick size for this market in ticks (like 10 for $0.001 increments)
/// Some markets only allow certain price increments /// Some markets only allow certain price increments
@@ -93,6 +102,7 @@ impl OrderBook {
timestamp: Utc::now(), timestamp: Utc::now(),
bids: BTreeMap::new(), // Empty to start - using Price/Qty types bids: BTreeMap::new(), // Empty to start - using Price/Qty types
asks: BTreeMap::new(), // Empty to start - using Price/Qty types asks: BTreeMap::new(), // Empty to start - using Price/Qty types
snapshot_generation: 0,
tick_size_ticks: None, // We'll set this later when we learn about the market tick_size_ticks: None, // We'll set this later when we learn about the market
max_depth, max_depth,
} }
@@ -123,17 +133,14 @@ impl OrderBook {
// self.bids.iter().next_back().map(|(&price, &size)| BookLevel { price, size }) // self.bids.iter().next_back().map(|(&price, &size)| BookLevel { price, size })
// AFTER (fast, ~5ns, no allocation for the lookup): // AFTER (fast, ~5ns, no allocation for the lookup):
self.bids self.bids.iter().next_back().map(|(&price_ticks, level)| {
.iter() // Convert from internal fixed-point to external Decimal format
.next_back() // This conversion only happens at the API boundary
.map(|(&price_ticks, &size_units)| { BookLevel {
// Convert from internal fixed-point to external Decimal format price: price_to_decimal(price_ticks),
// This conversion only happens at the API boundary size: qty_to_decimal(level.qty),
BookLevel { }
price: price_to_decimal(price_ticks), })
size: qty_to_decimal(size_units),
}
})
} }
/// Get the current best ask (lowest price someone is willing to sell at) /// Get the current best ask (lowest price someone is willing to sell at)
@@ -145,12 +152,12 @@ impl OrderBook {
// self.asks.iter().next().map(|(&price, &size)| BookLevel { price, size }) // self.asks.iter().next().map(|(&price, &size)| BookLevel { price, size })
// AFTER (fast, ~5ns, no allocation for the lookup): // AFTER (fast, ~5ns, no allocation for the lookup):
self.asks.iter().next().map(|(&price_ticks, &size_units)| { self.asks.iter().next().map(|(&price_ticks, level)| {
// Convert from internal fixed-point to external Decimal format // Convert from internal fixed-point to external Decimal format
// This conversion only happens at the API boundary // This conversion only happens at the API boundary
BookLevel { BookLevel {
price: price_to_decimal(price_ticks), price: price_to_decimal(price_ticks),
size: qty_to_decimal(size_units), size: qty_to_decimal(level.qty),
} }
}) })
} }
@@ -161,7 +168,7 @@ impl OrderBook {
self.bids self.bids
.iter() .iter()
.next_back() .next_back()
.map(|(&price, &size)| FastBookLevel::new(price, size)) .map(|(&price, level)| FastBookLevel::new(price, level.qty))
} }
/// Get the current best ask in fast internal format /// Get the current best ask in fast internal format
@@ -170,7 +177,7 @@ impl OrderBook {
self.asks self.asks
.iter() .iter()
.next() .next()
.map(|(&price, &size)| FastBookLevel::new(price, size)) .map(|(&price, level)| FastBookLevel::new(price, level.qty))
} }
/// Get the current spread (difference between best ask and best bid) /// Get the current spread (difference between best ask and best bid)
@@ -251,9 +258,9 @@ impl OrderBook {
.iter() .iter()
.rev() // Reverse because we want highest prices first .rev() // Reverse because we want highest prices first
.take(depth) // Only take the top N levels .take(depth) // Only take the top N levels
.map(|(&price_ticks, &size_units)| BookLevel { .map(|(&price_ticks, level)| BookLevel {
price: price_to_decimal(price_ticks), price: price_to_decimal(price_ticks),
size: qty_to_decimal(size_units), size: qty_to_decimal(level.qty),
}) })
.collect() .collect()
} }
@@ -268,9 +275,9 @@ impl OrderBook {
self.asks self.asks
.iter() // Already in ascending order, so no need to reverse .iter() // Already in ascending order, so no need to reverse
.take(depth) // Only take the top N levels .take(depth) // Only take the top N levels
.map(|(&price_ticks, &size_units)| BookLevel { .map(|(&price_ticks, level)| BookLevel {
price: price_to_decimal(price_ticks), price: price_to_decimal(price_ticks),
size: qty_to_decimal(size_units), size: qty_to_decimal(level.qty),
}) })
.collect() .collect()
} }
@@ -283,7 +290,7 @@ impl OrderBook {
.iter() .iter()
.rev() // Reverse because we want highest prices first .rev() // Reverse because we want highest prices first
.take(depth) // Only take the top N levels .take(depth) // Only take the top N levels
.map(|(&price, &size)| FastBookLevel::new(price, size)) .map(|(&price, level)| FastBookLevel::new(price, level.qty))
.collect() .collect()
} }
@@ -294,7 +301,7 @@ impl OrderBook {
self.asks self.asks
.iter() // Already in ascending order, so no need to reverse .iter() // Already in ascending order, so no need to reverse
.take(depth) // Only take the top N levels .take(depth) // Only take the top N levels
.map(|(&price, &size)| FastBookLevel::new(price, size)) .map(|(&price, level)| FastBookLevel::new(price, level.qty))
.collect() .collect()
} }
@@ -415,6 +422,7 @@ impl OrderBook {
self.sequence = timestamp; self.sequence = timestamp;
self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(timestamp as i64) self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(timestamp as i64)
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
self.begin_snapshot();
Ok(true) Ok(true)
} }
@@ -435,22 +443,14 @@ impl OrderBook {
} }
} }
match side { self.apply_snapshot_level(side, price_ticks, size_units);
Side::BUY => self.apply_bid_delta_fast(price_ticks, size_units),
Side::SELL => self.apply_ask_delta_fast(price_ticks, size_units),
}
Ok(()) Ok(())
} }
/// Finish applying a WS `book` snapshot. /// Finish applying a WS `book` snapshot.
pub(crate) fn finish_ws_book_update( pub(crate) fn finish_ws_book_update(&mut self) {
&mut self, self.finish_snapshot();
mut has_bid: impl FnMut(Price) -> bool,
mut has_ask: impl FnMut(Price) -> bool,
) {
self.bids.retain(|price_ticks, _| has_bid(*price_ticks));
self.asks.retain(|price_ticks, _| has_ask(*price_ticks));
self.trim_depth(); self.trim_depth();
} }
@@ -479,6 +479,7 @@ impl OrderBook {
self.sequence = update.timestamp; self.sequence = update.timestamp;
self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(update.timestamp as i64) self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(update.timestamp as i64)
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
self.begin_snapshot();
// Apply bids (BUY) and asks (SELL) as level upserts. // Apply bids (BUY) and asks (SELL) as level upserts.
for level in &update.bids { for level in &update.bids {
@@ -493,11 +494,7 @@ impl OrderBook {
} }
} }
if size_units == 0 { self.apply_snapshot_level(Side::BUY, price_ticks, size_units);
self.bids.remove(&price_ticks);
} else {
self.bids.insert(price_ticks, size_units);
}
} }
for level in &update.asks { for level in &update.asks {
@@ -512,17 +509,10 @@ impl OrderBook {
} }
} }
if size_units == 0 { self.apply_snapshot_level(Side::SELL, price_ticks, size_units);
self.asks.remove(&price_ticks);
} else {
self.asks.insert(price_ticks, size_units);
}
} }
self.bids self.finish_snapshot();
.retain(|price_ticks, _| book_update_has_level(&update.bids, *price_ticks));
self.asks
.retain(|price_ticks, _| book_update_has_level(&update.asks, *price_ticks));
self.trim_depth(); self.trim_depth();
Ok(()) Ok(())
} }
@@ -568,7 +558,13 @@ impl OrderBook {
if size_units == 0 { if size_units == 0 {
self.bids.remove(&price_ticks); // No more buyers at this price self.bids.remove(&price_ticks); // No more buyers at this price
} else { } else {
self.bids.insert(price_ticks, size_units); // Update total size at this price self.bids.insert(
price_ticks,
StoredLevel {
qty: size_units,
generation: self.snapshot_generation,
},
); // Update total size at this price
} }
} }
@@ -588,10 +584,49 @@ impl OrderBook {
if size_units == 0 { if size_units == 0 {
self.asks.remove(&price_ticks); // No more sellers at this price self.asks.remove(&price_ticks); // No more sellers at this price
} else { } else {
self.asks.insert(price_ticks, size_units); // Update total size at this price self.asks.insert(
price_ticks,
StoredLevel {
qty: size_units,
generation: self.snapshot_generation,
},
); // Update total size at this price
} }
} }
#[inline]
fn begin_snapshot(&mut self) {
self.snapshot_generation = self.snapshot_generation.wrapping_add(1);
}
#[inline]
fn apply_snapshot_level(&mut self, side: Side, price_ticks: Price, size_units: Qty) {
let generation = self.snapshot_generation;
let map = match side {
Side::BUY => &mut self.bids,
Side::SELL => &mut self.asks,
};
if size_units == 0 {
map.remove(&price_ticks);
} else {
map.insert(
price_ticks,
StoredLevel {
qty: size_units,
generation,
},
);
}
}
#[inline]
fn finish_snapshot(&mut self) {
let generation = self.snapshot_generation;
self.bids.retain(|_, level| level.generation == generation);
self.asks.retain(|_, level| level.generation == generation);
}
/// Trim the book to maintain depth limits /// Trim the book to maintain depth limits
/// We don't want to track every single price level - just the best ones /// We don't want to track every single price level - just the best ones
/// ///
@@ -716,12 +751,20 @@ impl OrderBook {
match side { match side {
Side::BUY => { Side::BUY => {
// How much we can buy at this price (look at asks) // How much we can buy at this price (look at asks)
let size_units = self.asks.get(&price_ticks).copied().unwrap_or_default(); let size_units = self
.asks
.get(&price_ticks)
.map(|level| level.qty)
.unwrap_or_default();
qty_to_decimal(size_units) qty_to_decimal(size_units)
}, },
Side::SELL => { Side::SELL => {
// How much we can sell at this price (look at bids) // How much we can sell at this price (look at bids)
let size_units = self.bids.get(&price_ticks).copied().unwrap_or_default(); let size_units = self
.bids
.get(&price_ticks)
.map(|level| level.qty)
.unwrap_or_default();
qty_to_decimal(size_units) qty_to_decimal(size_units)
}, },
} }
@@ -755,7 +798,7 @@ impl OrderBook {
}; };
// Sum up the sizes, converting from fixed-point back to Decimal // Sum up the sizes, converting from fixed-point back to Decimal
let total_size_units: i64 = levels.into_iter().map(|(_, &size)| size).sum(); let total_size_units: i64 = levels.into_iter().map(|(_, level)| level.qty).sum();
qty_to_decimal(total_size_units) qty_to_decimal(total_size_units)
} }
@@ -769,19 +812,6 @@ impl OrderBook {
} }
} }
fn book_update_has_level(levels: &[OrderSummary], price_ticks: Price) -> bool {
levels.iter().any(|level| {
let Ok(level_price_ticks) = decimal_to_price(level.price) else {
return false;
};
let Ok(size_units) = decimal_to_qty(level.size) else {
return false;
};
size_units != 0 && level_price_ticks == price_ticks
})
}
/// Market impact calculation result /// Market impact calculation result
/// This tells you what would happen if you executed a large order /// This tells you what would happen if you executed a large order
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -978,8 +1008,8 @@ impl OrderBook {
let bid_count = self.bids.len(); let bid_count = self.bids.len();
let ask_count = self.asks.len(); let ask_count = self.asks.len();
// Sum up all bid/ask sizes, converting from fixed-point back to Decimal // Sum up all bid/ask sizes, converting from fixed-point back to Decimal
let total_bid_size_units: i64 = self.bids.values().sum(); let total_bid_size_units: i64 = self.bids.values().map(|level| level.qty).sum();
let total_ask_size_units: i64 = self.asks.values().sum(); let total_ask_size_units: i64 = self.asks.values().map(|level| level.qty).sum();
let total_bid_size = qty_to_decimal(total_bid_size_units); let total_bid_size = qty_to_decimal(total_bid_size_units);
let total_ask_size = qty_to_decimal(total_ask_size_units); let total_ask_size = qty_to_decimal(total_ask_size_units);
+1 -33
View File
@@ -139,10 +139,7 @@ fn process_stream_object<'tape, 'input>(
applied += apply_levels(book, Side::SELL, asks)?; applied += apply_levels(book, Side::SELL, asks)?;
} }
book.finish_ws_book_update( book.finish_ws_book_update();
|price_ticks| ws_levels_contain_price(bids, price_ticks),
|price_ticks| ws_levels_contain_price(asks, price_ticks),
);
Ok(applied) Ok(applied)
})?; })?;
@@ -188,35 +185,6 @@ fn apply_levels<'tape, 'input>(
Ok(applied) Ok(applied)
} }
fn ws_levels_contain_price<'tape, 'input>(
levels: Option<simd_json::tape::Array<'tape, 'input>>,
price_ticks: Price,
) -> bool {
let Some(levels) = levels else {
return false;
};
levels.iter().any(|level| {
let Some(obj) = level.as_object() else {
return false;
};
let Some(price_str) = obj.get("price").and_then(|v| v.into_string()) else {
return false;
};
let Some(size_str) = obj.get("size").and_then(|v| v.into_string()) else {
return false;
};
let Ok(level_price_ticks) = parse_price_ticks_4dp(price_str) else {
return false;
};
let Ok(size_units) = parse_qty_scaled_4dp(size_str) else {
return false;
};
size_units != 0 && level_price_ticks == price_ticks
})
}
#[inline] #[inline]
fn parse_price_ticks_4dp(value: &str) -> Result<Price> { fn parse_price_ticks_4dp(value: &str) -> Result<Price> {
let scaled = parse_scaled_4_u64(value)?; let scaled = parse_scaled_4_u64(value)?;