diff --git a/CHANGELOG.md b/CHANGELOG.md index 592de7b..9ee32cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.3] - 2026-06-25 + +Patch release: a **performance fix** to `match_order`'s transient allocation. No +API, behavior, or wire-format change. + +### Fixed + +- **`PriceLevel::match_order` no longer pre-allocates the `MatchResult` buffers + to the whole level depth.** It reserved `trades` / `filled_order_ids` for + `order_count()` entries on every match, so a qty-1 taker against a deep level + reserved a multi-MB buffer (~176 B × depth) it immediately freed — pure + allocator pressure, not a leak. The pre-size is now bounded by + `min(incoming_quantity, order_count)`, a tight upper bound on the number of + fills (each trade consumes ≥1 unit of the taker). A qty-1 match against a + 100k-deep level now allocates KB, not MB; large sweeps are unaffected. Matching + semantics, FIFO/price-time priority, and trade output are unchanged. + ## [0.8.2] - 2026-06-24 Small, **non-breaking** release exposing two primitives an order book needs to @@ -70,6 +87,7 @@ improvements across the price level. Highlights: loop. - Lost-cancel race on the order queue. +[0.8.3]: https://github.com/joaquinbejar/PriceLevel/compare/v0.8.2...v0.8.3 [0.8.2]: https://github.com/joaquinbejar/PriceLevel/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/joaquinbejar/PriceLevel/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/joaquinbejar/PriceLevel/releases/tag/v0.8.0 diff --git a/Cargo.toml b/Cargo.toml index 9591cc1..888fe69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pricelevel" -version = "0.8.2" +version = "0.8.3" edition = "2024" authors = ["Joaquin Bejar "] description = "A high-performance, lock-free price level implementation for limit order books in Rust. This library provides the building blocks for creating efficient trading systems with support for multiple order types and concurrent access patterns." diff --git a/src/execution/match_result.rs b/src/execution/match_result.rs index 6685503..ef34a61 100644 --- a/src/execution/match_result.rs +++ b/src/execution/match_result.rs @@ -126,9 +126,11 @@ impl MatchResult { /// vectors pre-sized for up to `capacity` entries. /// /// A single match sweep at one price level produces at most one trade and - /// at most one filled order id per resting order, so `capacity` is normally - /// the level's resting order count. Pre-sizing both vectors removes the - /// per-fill reallocations on the match hot path. + /// at most one filled order id per resting order it consumes, so a good + /// `capacity` is the tighter of the taker's incoming quantity and the + /// level's resting order count (see `PriceLevel::match_order`). Pre-sizing + /// both vectors removes the per-fill reallocations on the match hot path + /// without over-reserving for a small taker against a deep level. #[must_use] pub fn with_capacity(order_id: Id, initial_quantity: Quantity, capacity: usize) -> Self { // Same zero-quantity consistency as `new` (see there). diff --git a/src/price_level/level.rs b/src/price_level/level.rs index 92adad7..b65d436 100644 --- a/src/price_level/level.rs +++ b/src/price_level/level.rs @@ -525,12 +525,18 @@ impl PriceLevel { } // A single sweep emits at most one trade and at most one filled-order - // id per resting order, so the live order count is an upper bound for - // both vectors. Pre-size them to cut per-fill reallocations on the hot - // path; the count is advisory (`Relaxed`) so the `Vec` still grows if a - // concurrent `add_order` lands mid-sweep — capacity is a hint, not a - // bound. - let capacity = self.order_count(); + // id per resting order it actually consumes. Two independent upper + // bounds hold: every emitted trade reduces `remaining` by at least one + // unit (a `consumed == 0` maker is set aside without a trade), so the + // sweep emits at most `incoming_quantity` trades; and it can touch at + // most `order_count` resting orders. The tighter of the two pre-sizes + // both vectors to cut per-fill reallocations on the hot path WITHOUT + // reserving the whole level depth for a tiny taker (issue #106): a qty-1 + // taker against a deep level no longer reserves a multi-MB buffer it + // immediately frees. The bound is advisory — `order_count` is read + // `Relaxed` and both `Vec`s still grow if a concurrent `add_order` lands + // mid-sweep — so it is a hint, not a cap. + let capacity = (incoming_quantity as usize).min(self.order_count()); let mut result = MatchResult::with_capacity(taker_order_id, Quantity::new(incoming_quantity), capacity); let mut remaining = incoming_quantity; diff --git a/src/price_level/tests/level.rs b/src/price_level/tests/level.rs index aa2acbb..a053c48 100644 --- a/src/price_level/tests/level.rs +++ b/src/price_level/tests/level.rs @@ -5198,6 +5198,75 @@ mod tests { "match_order consumes exactly matchable_quantity for an iceberg" ); } + + // ------------------------------------------------------------------ + // Issue #106 — MatchResult pre-alloc is bounded by the fill count, + // not the whole level depth. + // ------------------------------------------------------------------ + + #[test] + fn test_match_order_capacity_bounded_by_incoming_quantity() { + // A deep level: 200 resting makers. + let level = PriceLevel::new(10_000); + for id in 1..=200_u64 { + level.add_order(create_standard_order(id, 10_000, 100)); + } + assert_eq!(level.order_count(), 200, "level is deep"); + + // A qty-1 taker fills exactly one maker. Pre-#106 the result buffers + // were reserved to `order_count` (200); now they are bounded by + // `min(incoming_quantity, order_count) = 1`. + let incoming = 1_u64; + let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); + let generator = UuidGenerator::new(namespace); + let result = level.match_order( + incoming, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_700_000_000_000), + &generator, + ); + + assert_eq!(result.trades().as_vec().len(), 1, "exactly one fill"); + assert!( + result.trades().as_vec().capacity() <= incoming as usize, + "trade buffer must be bounded by incoming quantity ({incoming}), not \ + level depth (200); was {}", + result.trades().as_vec().capacity() + ); + } + + #[test] + fn test_match_order_capacity_bounded_by_order_count() { + // A shallow level: 3 makers, 300 units of depth. + let level = PriceLevel::new(10_000); + for id in 1..=3_u64 { + level.add_order(create_standard_order(id, 10_000, 100)); + } + assert_eq!(level.order_count(), 3, "level is shallow"); + + // A taker far larger than the level: the bound `min(incoming, depth)` + // must pick the order count (3), never the huge incoming quantity. + let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); + let generator = UuidGenerator::new(namespace); + let result = level.match_order( + 10_000, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_700_000_000_000), + &generator, + ); + + assert_eq!(result.trades().as_vec().len(), 3, "all three makers filled"); + assert!( + result.trades().as_vec().capacity() <= 3, + "trade buffer must be bounded by order count (3), not the incoming \ + quantity (10000); was {}", + result.trades().as_vec().capacity() + ); + } } #[cfg(test)]