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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pricelevel"
version = "0.8.2"
version = "0.8.3"
edition = "2024"
authors = ["Joaquin Bejar <jb@taunais.com>"]
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."
Expand Down
8 changes: 5 additions & 3 deletions src/execution/match_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 12 additions & 6 deletions src/price_level/level.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions src/price_level/tests/level.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Loading