Skip to content

Commit 729cab0

Browse files
committed
Reduce internal hot path allocations
Avoid single-item buffer arrays, skip unnecessary multi-bulk level collections, use cheaper pair normalization for ASCII symbols, cache observable stream wrappers, and refresh benchmark results.
1 parent c4e39ae commit 729cab0

8 files changed

Lines changed: 125 additions & 43 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ It helps to unify data models and usage of more clients together.
3131

3232
The order book implementation is tuned for allocation-sensitive websocket streams. Common L2 diff processing avoids temporary notification objects when nobody is subscribed, keeps internal source-to-orderbook handoff on the single-update path, and uses list-based dispatch for bulk level updates to avoid interface enumerator allocations.
3333

34-
The current benchmark suite focuses on `CryptoOrderBook`, `CryptoOrderBookL2`, and related source adapters. In the latest pass, representative BenchmarkDotNet runs showed `CryptoOrderBook.BidLevels` improving from 17,822 ns / 77 KB to 5,409 ns / 4.8 KB, and `CryptoOrderBookL2` diff processing improving from 935 ns / 545 B to 665 ns / 160 B. See the [benchmarks README](benchmarks/README.md) for commands and detailed results.
34+
The current benchmark suite focuses on `CryptoOrderBook`, `CryptoOrderBookL2`, and related source adapters. In the latest pass, representative BenchmarkDotNet runs showed `CryptoOrderBook.BidLevels` improving from 17,822 ns / 77 KB to 5,409 ns / 4.8 KB, and `CryptoOrderBookL2` diff processing improving from 935 ns / 545 B to 618 ns / 161 B. See the [benchmarks README](benchmarks/README.md) for commands and detailed results.
3535

3636
### Supported exchanges
3737

benchmarks/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ Representative results from BenchmarkDotNet ShortRun on Windows, .NET 10.0.5, AM
3737

3838
| Benchmark | Before | After | Improvement |
3939
| --- | ---: | ---: | ---: |
40-
| `CryptoOrderBookL2`, no observers | 935.4 ns, 545 B | 664.5 ns, 160 B | 29% faster, 71% less allocation |
41-
| `CryptoOrderBookL2`, `OrderBookUpdatedStream` observer | 933.6 ns, 545 B | 770.2 ns, 337 B | 18% faster, 38% less allocation |
42-
| `CryptoOrderBook`, no observers | 1,781.2 ns, 1,154 B | 1,290.2 ns, 770 B | 28% faster, 33% less allocation |
43-
| `CryptoOrderBook`, `OrderBookUpdatedStream` observer | 1,629.1 ns, 1,155 B | 1,308.6 ns, 947 B | 20% faster, 18% less allocation |
40+
| `CryptoOrderBookL2`, no observers | 935.4 ns, 545 B | 618.4 ns, 161 B | 34% faster, 70% less allocation |
41+
| `CryptoOrderBookL2`, `OrderBookUpdatedStream` observer | 933.6 ns, 545 B | 683.9 ns, 337 B | 27% faster, 38% less allocation |
42+
| `CryptoOrderBook`, no observers | 1,781.2 ns, 1,154 B | 1,140.0 ns, 770 B | 36% faster, 33% less allocation |
43+
| `CryptoOrderBook`, `OrderBookUpdatedStream` observer | 1,629.1 ns, 1,155 B | 1,238.4 ns, 947 B | 24% faster, 18% less allocation |
4444

45-
The processing improvements mainly come from avoiding notification allocation when no stream has subscribers, handling the common single-diff path directly, using an internal single-bulk handoff from `OrderBookSourceBase` to order books while preserving the public `OrderBookStream` API, lazily materializing single-source notification metadata only when subscribers read `Sources`, dispatching built-in bulk updates through indexed `IReadOnlyList<T>` loops instead of interface enumerators, and avoiding repeated dictionary lookups in per-level update/delete paths.
45+
The processing improvements mainly come from avoiding notification allocation when no stream has subscribers, handling the common single-diff path directly, using an internal single-bulk handoff from `OrderBookSourceBase` to order books while preserving the public `OrderBookStream` API, lazily materializing single-source notification metadata only when subscribers read `Sources`, dispatching built-in bulk updates through indexed `IReadOnlyList<T>` loops instead of interface enumerators, avoiding repeated dictionary lookups in per-level update/delete paths, skipping temporary collections in buffered/small-batch paths, and caching observable stream wrappers.
4646

4747
### Order Book Reads
4848

src/Crypto.Websocket.Extensions.Core/OrderBooks/CryptoOrderBookBase.cs

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ public abstract class CryptoOrderBookBase<T> : ICryptoOrderBook
6464
/// </summary>
6565
protected readonly Subject<TopNLevelsChangeInfo> TopNLevelsUpdated = new();
6666

67+
private readonly IObservable<IOrderBookChangeInfo> _bidAskUpdatedStream;
68+
private readonly IObservable<IOrderBookChangeInfo> _topLevelUpdatedStream;
69+
private readonly IObservable<IOrderBookChangeInfo> _orderBookUpdatedStream;
70+
private readonly IObservable<ITopNLevelsChangeInfo> _topNLevelsUpdatedStream;
71+
6772
/// <summary>
6873
/// All the bid levels (not grouped by price).
6974
/// </summary>
@@ -105,6 +110,11 @@ protected CryptoOrderBookBase(string targetPair, IOrderBookSource source)
105110
_previous = new L2Snapshot(this, Array.Empty<CryptoQuote>(), Array.Empty<CryptoQuote>());
106111
_current = new L2Snapshot(this, Array.Empty<CryptoQuote>(), Array.Empty<CryptoQuote>());
107112

113+
_bidAskUpdatedStream = BidAskUpdated.AsObservable();
114+
_topLevelUpdatedStream = TopLevelUpdated.AsObservable();
115+
_orderBookUpdatedStream = OrderBookUpdated.AsObservable();
116+
_topNLevelsUpdatedStream = TopNLevelsUpdated.AsObservable();
117+
108118
TargetPairOriginal = targetPair;
109119
TargetPair = CryptoPairsHelper.Clean(targetPair);
110120
Source = source;
@@ -217,16 +227,16 @@ public bool ValidityCheckEnabled
217227
public bool CalculateMetricsFromSnapshots { get; set; }
218228

219229
/// <inheritdoc />
220-
public IObservable<IOrderBookChangeInfo> BidAskUpdatedStream => BidAskUpdated.AsObservable();
230+
public IObservable<IOrderBookChangeInfo> BidAskUpdatedStream => _bidAskUpdatedStream;
221231

222232
/// <inheritdoc />
223-
public IObservable<IOrderBookChangeInfo> TopLevelUpdatedStream => TopLevelUpdated.AsObservable();
233+
public IObservable<IOrderBookChangeInfo> TopLevelUpdatedStream => _topLevelUpdatedStream;
224234

225235
/// <inheritdoc />
226-
public IObservable<IOrderBookChangeInfo> OrderBookUpdatedStream => OrderBookUpdated.AsObservable();
236+
public IObservable<IOrderBookChangeInfo> OrderBookUpdatedStream => _orderBookUpdatedStream;
227237

228238
/// <inheritdoc />
229-
public IObservable<ITopNLevelsChangeInfo> TopNLevelsUpdatedStream => TopNLevelsUpdated.AsObservable();
239+
public IObservable<ITopNLevelsChangeInfo> TopNLevelsUpdatedStream => _topNLevelsUpdatedStream;
230240

231241
/// <inheritdoc />
232242
public int NotifyForLevelAndAbove
@@ -241,11 +251,14 @@ public int NotifyForLevelAndAbove
241251
_current = new L2Snapshot(this, BlankQuotes(), BlankQuotes());
242252
}
243253

244-
IReadOnlyList<CryptoQuote> BlankQuotes()
254+
CryptoQuote[] BlankQuotes()
245255
{
246-
var result = new List<CryptoQuote>(_notifyForLevelAndAbove);
256+
if (_notifyForLevelAndAbove <= 0)
257+
return Array.Empty<CryptoQuote>();
258+
259+
var result = new CryptoQuote[_notifyForLevelAndAbove];
247260
for (var index = 0; index < _notifyForLevelAndAbove; index++)
248-
result.Add(new CryptoQuote(0, 0));
261+
result[index] = new CryptoQuote(0, 0);
249262

250263
return result;
251264
}
@@ -356,8 +369,9 @@ private OrderBookLevel[] FilterLevelsForTargetPair(OrderBookLevel[] levels)
356369
return Array.Empty<OrderBookLevel>();
357370

358371
var count = 0;
359-
foreach (var level in levels)
372+
for (var levelIndex = 0; levelIndex < levels.Length; levelIndex++)
360373
{
374+
var level = levels[levelIndex];
361375
if (TargetPair.Equals(level.Pair, StringComparison.Ordinal))
362376
count++;
363377
}
@@ -370,8 +384,9 @@ private OrderBookLevel[] FilterLevelsForTargetPair(OrderBookLevel[] levels)
370384

371385
var result = new OrderBookLevel[count];
372386
var index = 0;
373-
foreach (var level in levels)
387+
for (var levelIndex = 0; levelIndex < levels.Length; levelIndex++)
374388
{
389+
var level = levels[levelIndex];
375390
if (TargetPair.Equals(level.Pair, StringComparison.Ordinal))
376391
result[index++] = level;
377392
}
@@ -382,21 +397,26 @@ private OrderBookLevel[] FilterLevelsForTargetPair(OrderBookLevel[] levels)
382397
private OrderBookLevelBulk[] FilterBulksForThis(OrderBookLevelBulk[] bulks)
383398
{
384399
var count = 0;
385-
foreach (var bulk in bulks)
400+
for (var index = 0; index < bulks.Length; index++)
386401
{
402+
var bulk = bulks[index];
387403
if (bulk != null && IsForThis(bulk))
388404
count++;
389405
}
390406

391407
if (count == 0)
392408
return Array.Empty<OrderBookLevelBulk>();
393409

410+
if (count == bulks.Length)
411+
return bulks;
412+
394413
var result = new OrderBookLevelBulk[count];
395-
var index = 0;
396-
foreach (var bulk in bulks)
414+
var resultIndex = 0;
415+
for (var index = 0; index < bulks.Length; index++)
397416
{
417+
var bulk = bulks[index];
398418
if (bulk != null && IsForThis(bulk))
399-
result[index++] = bulk;
419+
result[resultIndex++] = bulk;
400420
}
401421

402422
return result;
@@ -425,24 +445,36 @@ private void HandleDiffsSynchronized(OrderBookLevelBulk[] bulks)
425445
return;
426446
}
427447

428-
var allLevels = new List<OrderBookLevel>();
448+
var collectLevels = IsIndexComputationEnabled || DebugEnabled;
449+
List<OrderBookLevel>? allLevels = collectLevels ? new List<OrderBookLevel>() : null;
450+
var hasLevels = false;
429451

430452
lock (Locker)
431453
{
432454
foreach (var bulk in forThis)
433455
{
434456
var levelsForThis = FilterLevelsForTargetPair(bulk.Levels);
435-
allLevels.AddRange(levelsForThis);
457+
if (levelsForThis.Length <= 0)
458+
continue;
459+
460+
hasLevels = true;
461+
allLevels?.AddRange(levelsForThis);
436462
HandleDiff(bulk, levelsForThis);
437463
}
438464

439-
if (allLevels.Count > 0)
465+
if (hasLevels)
440466
{
441-
RecomputeAfterChangeAndSetIndexes(allLevels);
467+
if (allLevels != null)
468+
RecomputeAfterChangeAndSetIndexes(allLevels);
469+
else
470+
RecomputeAfterChange();
442471

443472
if (HasNotificationObservers)
444473
{
445-
var change = CreateBookChangeNotification(allLevels, forThis, false);
474+
var changedLevels = allLevels != null
475+
? (IEnumerable<OrderBookLevel>)allLevels
476+
: Array.Empty<OrderBookLevel>();
477+
var change = CreateBookChangeNotification(changedLevels, forThis, false);
446478
NotifyOrderBookChanges(change);
447479
}
448480
}
@@ -994,11 +1026,23 @@ private static OrderBookLevel[] CloneLevels(IEnumerable<OrderBookLevel> levels)
9941026

9951027
private static IReadOnlyList<CryptoQuote> CopyValidQuotes(IReadOnlyList<CryptoQuote> quotes)
9961028
{
997-
var result = new List<CryptoQuote>(quotes.Count);
998-
foreach (var quote in quotes)
1029+
var count = 0;
1030+
for (var index = 0; index < quotes.Count; index++)
1031+
{
1032+
if (quotes[index].IsValid)
1033+
count++;
1034+
}
1035+
1036+
if (count == 0)
1037+
return Array.Empty<CryptoQuote>();
1038+
1039+
var result = new CryptoQuote[count];
1040+
var resultIndex = 0;
1041+
for (var index = 0; index < quotes.Count; index++)
9991042
{
1043+
var quote = quotes[index];
10001044
if (quote.IsValid)
1001-
result.Add(quote);
1045+
result[resultIndex++] = quote;
10021046
}
10031047

10041048
return result;

src/Crypto.Websocket.Extensions.Core/OrderBooks/Models/OrderBookLevelBulk.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public OrderBookLevelBulk(OrderBookAction action, OrderBookLevel[] levels, Crypt
1414
{
1515
Action = action;
1616
OrderBookType = orderBookType;
17-
Levels = levels ?? new OrderBookLevel[0];
17+
Levels = levels ?? System.Array.Empty<OrderBookLevel>();
1818
}
1919

2020
/// <summary>

src/Crypto.Websocket.Extensions.Core/OrderBooks/Sources/OrderBookSourceBase.cs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public abstract class OrderBookSourceBase : IOrderBookSource
3636
/// </summary>
3737
private readonly Subject<OrderBookLevelBulk[]> _orderBookSubject = new Subject<OrderBookLevelBulk[]>();
3838

39+
private readonly IObservable<OrderBookLevelBulk> _orderBookSnapshotStream;
40+
private readonly IObservable<OrderBookLevelBulk[]> _orderBookStream;
41+
3942
private Action<OrderBookLevelBulk>? _orderBookSingleHandlers;
4043
private Action<OrderBookLevelBulk[]>? _orderBookBatchHandlers;
4144

@@ -45,6 +48,8 @@ public abstract class OrderBookSourceBase : IOrderBookSource
4548
protected OrderBookSourceBase(ILogger logger)
4649
{
4750
_logger = logger;
51+
_orderBookSnapshotStream = _orderBookSnapshotSubject.AsObservable();
52+
_orderBookStream = _orderBookSubject.AsObservable();
4853
StartProcessingFromBufferThread();
4954
}
5055

@@ -93,10 +98,10 @@ public bool BufferEnabled
9398
public TimeSpan BufferInterval { get; set; } = TimeSpan.FromMilliseconds(10);
9499

95100
/// <inheritdoc />
96-
public IObservable<OrderBookLevelBulk> OrderBookSnapshotStream => _orderBookSnapshotSubject.AsObservable();
101+
public IObservable<OrderBookLevelBulk> OrderBookSnapshotStream => _orderBookSnapshotStream;
97102

98103
/// <inheritdoc />
99-
public IObservable<OrderBookLevelBulk[]> OrderBookStream => _orderBookSubject.AsObservable();
104+
public IObservable<OrderBookLevelBulk[]> OrderBookStream => _orderBookStream;
100105

101106
/// <summary>
102107
/// Subscribes an order book to the internal allocation-aware stream.
@@ -254,21 +259,33 @@ private void StreamDataSynchronized()
254259

255260
private void StreamData()
256261
{
257-
object[] data;
262+
object? singleData = null;
263+
object[]? data = null;
264+
258265
lock (_bufferLocker)
259266
{
260-
data = _dataBuffer.ToArray();
261-
_dataBuffer.Clear();
262-
263-
if (data.Length <= 0)
267+
if (_dataBuffer.Count <= 0)
264268
{
265269
// no message in buffer, enable waiting
266270
_bufferPauseEvent.Reset();
267271
return;
268272
}
273+
274+
if (_dataBuffer.Count == 1)
275+
{
276+
singleData = _dataBuffer.Dequeue();
277+
}
278+
else
279+
{
280+
data = _dataBuffer.ToArray();
281+
_dataBuffer.Clear();
282+
}
269283
}
270284

271-
ConvertAndStream(data);
285+
if (data != null)
286+
ConvertAndStream(data);
287+
else
288+
ConvertAndStream(singleData!);
272289
}
273290

274291
private void ConvertAndStream(object[] dataArr)

src/Crypto.Websocket.Extensions.Core/Orders/CryptoOrders.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ public class CryptoOrders : ICryptoOrders
2121
private readonly long? _orderPrefix;
2222
private readonly Subject<CryptoOrder> _orderChanged = new Subject<CryptoOrder>();
2323
private readonly Subject<CryptoOrder> _ourOrderChanged = new Subject<CryptoOrder>();
24+
private readonly IObservable<CryptoOrder> _orderChangedStream;
25+
private readonly IObservable<CryptoOrder> _ourOrderChangedStream;
2426
private readonly CryptoOrderCollection _idToOrder = new CryptoOrderCollection();
2527

2628
private long _cidCounter;
@@ -40,19 +42,21 @@ public CryptoOrders(IOrderSource source, long? orderPrefix = null, string? targe
4042
TargetPairOriginal = targetPair;
4143
_source.SetExistingOrders(_idToOrder);
4244
_orderPrefix = orderPrefix;
45+
_orderChangedStream = _orderChanged.AsObservable();
46+
_ourOrderChangedStream = _ourOrderChanged.AsObservable();
4347

4448
Subscribe();
4549
}
4650

4751
/// <summary>
4852
/// Order was changed stream
4953
/// </summary>
50-
public IObservable<CryptoOrder> OrderChangedStream => _orderChanged.AsObservable();
54+
public IObservable<CryptoOrder> OrderChangedStream => _orderChangedStream;
5155

5256
/// <summary>
5357
/// Order was changed stream (only ours, based on client id prefix)
5458
/// </summary>
55-
public IObservable<CryptoOrder> OurOrderChangedStream => _ourOrderChanged.AsObservable();
59+
public IObservable<CryptoOrder> OurOrderChangedStream => _ourOrderChangedStream;
5660

5761
/// <summary>
5862
/// Selected client id prefix

src/Crypto.Websocket.Extensions.Core/Orders/Sources/OrderSourceBase.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@ public abstract class OrderSourceBase : IOrderSource
1515
protected readonly Subject<CryptoOrder> OrderCreatedSubject = new Subject<CryptoOrder>();
1616
protected readonly Subject<CryptoOrder> OrderUpdatedSubject = new Subject<CryptoOrder>();
1717

18+
private readonly IObservable<CryptoOrder[]> _ordersInitialStream;
19+
private readonly IObservable<CryptoOrder> _orderCreatedStream;
20+
private readonly IObservable<CryptoOrder> _orderUpdatedStream;
21+
1822
protected CryptoOrderCollection ExistingOrders = new CryptoOrderCollection();
1923

2024
protected OrderSourceBase(ILogger logger)
2125
{
2226
Logger = logger;
27+
_ordersInitialStream = OrderSnapshotSubject.AsObservable();
28+
_orderCreatedStream = OrderCreatedSubject.AsObservable();
29+
_orderUpdatedStream = OrderUpdatedSubject.AsObservable();
2330
}
2431

2532
/// <summary>
@@ -35,17 +42,17 @@ protected OrderSourceBase(ILogger logger)
3542
/// <summary>
3643
/// Stream snapshot of currently active orders
3744
/// </summary>
38-
public virtual IObservable<CryptoOrder[]> OrdersInitialStream => OrderSnapshotSubject.AsObservable();
45+
public virtual IObservable<CryptoOrder[]> OrdersInitialStream => _ordersInitialStream;
3946

4047
/// <summary>
4148
/// Stream info about new active order
4249
/// </summary>
43-
public virtual IObservable<CryptoOrder> OrderCreatedStream => OrderCreatedSubject.AsObservable();
50+
public virtual IObservable<CryptoOrder> OrderCreatedStream => _orderCreatedStream;
4451

4552
/// <summary>
4653
/// Stream on every status change of the order
4754
/// </summary>
48-
public virtual IObservable<CryptoOrder> OrderUpdatedStream => OrderUpdatedSubject.AsObservable();
55+
public virtual IObservable<CryptoOrder> OrderUpdatedStream => _orderUpdatedStream;
4956

5057
/// <summary>
5158
/// Set collection of existing orders (to correctly handle orders state)

src/Crypto.Websocket.Extensions.Core/Utils/CryptoPairsHelper.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public static string Clean(string? pair)
3737
}
3838

3939
outputLength++;
40-
needsCopy |= char.ToLowerInvariant(c) != c;
40+
needsCopy |= ToLowerInvariant(c) != c;
4141
}
4242

4343
if (outputLength == 0)
@@ -53,7 +53,7 @@ public static string Clean(string? pair)
5353
{
5454
var c = source.Pair[sourceIndex];
5555
if (!IsSeparator(c))
56-
destination[index++] = char.ToLowerInvariant(c);
56+
destination[index++] = ToLowerInvariant(c);
5757
}
5858
});
5959
}
@@ -69,5 +69,15 @@ public static bool AreSame(string? firstPair, string? secondPair)
6969
}
7070

7171
private static bool IsSeparator(char c) => c is '/' or '-' or '\\';
72+
73+
private static char ToLowerInvariant(char c)
74+
{
75+
if (c is >= 'A' and <= 'Z')
76+
return (char)(c + ('a' - 'A'));
77+
78+
return c <= 127
79+
? c
80+
: char.ToLowerInvariant(c);
81+
}
7282
}
7383
}

0 commit comments

Comments
 (0)