1+ // (c) 2025 Francesco Del Re <francesco.delre.87@gmail.com>
2+ // This code is licensed under MIT license (see LICENSE.txt for details)
3+ using BenchmarkDotNet . Attributes ;
4+ using TemporalCollections . Collections ;
5+ using TemporalCollections . Models ;
6+
7+ namespace TemporalCollections . PerformanceTests . Benchmarks
8+ {
9+ /// <summary>
10+ /// BenchmarkDotNet benchmarks for TemporalMultimap<TKey, TValue>.
11+ ///
12+ /// Scenarios covered:
13+ /// - Adds: AddValue, Add (pre-built items), AddRange(values), AddRange(items)
14+ /// - Per-key queries: GetValuesInRange
15+ /// - Global ITimeQueryable queries: GetInRange, GetBefore, GetAfter, CountInRange, CountSince, GetNearest
16+ /// - Extremes & span: GetLatest/GetEarliest/GetTimeSpan
17+ /// - Retention: RemoveOlderThan (per-key & global), RemoveRange (per-key & global), RemoveKey, Clear
18+ ///
19+ /// Notes:
20+ /// - We build fresh instances in IterationSetup to avoid cross-benchmark interference.
21+ /// - Time-window queries use "last N minutes/seconds" relative to now (creation time is during setup).
22+ /// </summary>
23+ [ MemoryDiagnoser ]
24+ public class TemporalMultimapBenchmarks
25+ {
26+ // ---------- Parameters ----------
27+
28+ /// <summary>Number of distinct keys in datasets.</summary>
29+ [ Params ( 10 , 100 ) ]
30+ public int KeyCount { get ; set ; }
31+
32+ /// <summary>Number of values per key.</summary>
33+ [ Params ( 1_000 ) ]
34+ public int ValuesPerKey { get ; set ; }
35+
36+ // ---------- Prepared data ----------
37+
38+ private string [ ] _keys = default ! ;
39+ private ( string Key , int Value ) [ ] _kvData = default ! ;
40+ private TemporalItem < KeyValuePair < string , int > > [ ] _prebuiltItems = default ! ;
41+
42+ // ---------- Maps per scenario (fresh each iteration) ----------
43+
44+ private TemporalMultimap < string , int > _mapForAdds = default ! ;
45+ private TemporalMultimap < string , int > _mapForQueries = default ! ;
46+ private TemporalMultimap < string , int > _mapForPerKeyRetention = default ! ;
47+ private TemporalMultimap < string , int > _mapForGlobalRetention = default ! ;
48+
49+ // ---------- Setup ----------
50+
51+ /// <summary>
52+ /// Prepare deterministic keys and data layouts that are reused across iterations.
53+ /// Also prepares a set of pre-built TemporalItems for benchmarking Add(items)/AddRange(items).
54+ /// </summary>
55+ [ GlobalSetup ]
56+ public void GlobalSetup ( )
57+ {
58+ _keys = Enumerable . Range ( 0 , KeyCount ) . Select ( i => $ "K{ i } ") . ToArray ( ) ;
59+
60+ // Flattened (key,value) array
61+ _kvData = new ( string , int ) [ KeyCount * ValuesPerKey ] ;
62+ int p = 0 ;
63+ for ( int i = 0 ; i < KeyCount ; i ++ )
64+ for ( int v = 0 ; v < ValuesPerKey ; v ++ )
65+ _kvData [ p ++ ] = ( _keys [ i ] , v ) ;
66+
67+ // Prebuild TemporalItems with strictly increasing timestamps
68+ // (use a base time and tick increments to avoid Create() during the benchmark body)
69+ var baseTime = DateTimeOffset . UtcNow . AddMinutes ( - 30 ) ;
70+ _prebuiltItems = new TemporalItem < KeyValuePair < string , int > > [ _kvData . Length ] ;
71+ long tick = 0 ;
72+ for ( int i = 0 ; i < _kvData . Length ; i ++ )
73+ {
74+ var ( k , v ) = _kvData [ i ] ;
75+ var ts = baseTime . AddTicks ( tick ++ ) ;
76+ _prebuiltItems [ i ] = new TemporalItem < KeyValuePair < string , int > > ( new KeyValuePair < string , int > ( k , v ) , ts ) ;
77+ }
78+ }
79+
80+ /// <summary>
81+ /// Build fresh maps for each iteration and load the query/retention datasets.
82+ /// </summary>
83+ [ IterationSetup ]
84+ public void IterationSetup ( )
85+ {
86+ _mapForAdds = new TemporalMultimap < string , int > ( ) ;
87+
88+ _mapForQueries = new TemporalMultimap < string , int > ( ) ;
89+ _mapForPerKeyRetention = new TemporalMultimap < string , int > ( ) ;
90+ _mapForGlobalRetention = new TemporalMultimap < string , int > ( ) ;
91+
92+ // Preload maps that are read/modified by query/retention benchmarks
93+ for ( int i = 0 ; i < _kvData . Length ; i ++ )
94+ {
95+ var ( k , v ) = _kvData [ i ] ;
96+ _mapForQueries . AddValue ( k , v ) ;
97+ _mapForPerKeyRetention . AddValue ( k , v ) ;
98+ _mapForGlobalRetention . AddValue ( k , v ) ;
99+ }
100+ }
101+
102+ // ---------- Adds ----------
103+
104+ /// <summary>Bulk insert all (key,value) pairs into an empty map via AddValue.</summary>
105+ [ Benchmark ( Description = "AddValue: insert N×M items across keys" ) ]
106+ public void Add_AllItems_AddValue ( )
107+ {
108+ var map = _mapForAdds ;
109+ for ( int i = 0 ; i < _kvData . Length ; i ++ )
110+ {
111+ var ( k , v ) = _kvData [ i ] ;
112+ map . AddValue ( k , v ) ;
113+ }
114+ }
115+
116+ /// <summary>Bulk insert using pre-built TemporalItem<KeyValuePair<string,int>> via Add(item).</summary>
117+ [ Benchmark ( Description = "Add(item): insert pre-built temporal items" ) ]
118+ public void Add_AllItems_PreBuilt ( )
119+ {
120+ var map = _mapForAdds ;
121+ for ( int i = 0 ; i < _prebuiltItems . Length ; i ++ )
122+ {
123+ map . Add ( _prebuiltItems [ i ] ) ;
124+ }
125+ }
126+
127+ /// <summary>Bulk insert per key using AddRange(values).</summary>
128+ [ Benchmark ( Description = "AddRange(values): insert per-key batches" ) ]
129+ public void AddRange_Values ( )
130+ {
131+ var map = _mapForAdds ;
132+ foreach ( var k in _keys )
133+ {
134+ // Reuse a slice [0..ValuesPerKey) for simplicity
135+ map . AddRange ( k , Enumerable . Range ( 0 , ValuesPerKey ) ) ;
136+ }
137+ }
138+
139+ /// <summary>Bulk insert using AddRange(items) with pre-built items.</summary>
140+ [ Benchmark ( Description = "AddRange(items): insert pre-built temporal items" ) ]
141+ public void AddRange_Items ( )
142+ {
143+ var map = _mapForAdds ;
144+ map . AddRange ( _prebuiltItems ) ;
145+ }
146+
147+ // ---------- Per-key query ----------
148+
149+ /// <summary>Per-key inclusive range query over the last 2 minutes.</summary>
150+ [ Benchmark ( Description = "Per-key query: GetValuesInRange(last 2 minutes)" ) ]
151+ public void PerKey_GetValuesInRange_Last2Minutes ( )
152+ {
153+ string key = _keys [ _keys . Length / 2 ] ;
154+ var to = DateTimeOffset . UtcNow ;
155+ var from = to . AddMinutes ( - 2 ) ;
156+ var _ = _mapForQueries . GetValuesInRange ( key , from , to ) ;
157+ }
158+
159+ // ---------- Global queries (ITimeQueryable) ----------
160+
161+ /// <summary>Global inclusive range query (last 2 minutes).</summary>
162+ [ Benchmark ( Description = "Global query: GetInRange(last 2 minutes)" ) ]
163+ public void Global_GetInRange_Last2Minutes ( )
164+ {
165+ var to = DateTimeOffset . UtcNow ;
166+ var from = to . AddMinutes ( - 2 ) ;
167+ var _ = _mapForQueries . GetInRange ( from , to ) ;
168+ }
169+
170+ /// <summary>Global strictly-before query using a midpoint cutoff.</summary>
171+ [ Benchmark ( Description = "Global query: GetBefore(midpoint cutoff)" ) ]
172+ public void Global_GetBefore_Midpoint ( )
173+ {
174+ // Use two known items to craft a midpoint
175+ var to = DateTimeOffset . UtcNow ;
176+ var from = to . AddMinutes ( - 5 ) ;
177+ var window = _mapForQueries . GetInRange ( from , to ) . ToArray ( ) ;
178+ if ( window . Length < 2 ) return ;
179+ var cutoff = Mid ( window [ 0 ] . Timestamp , window [ ^ 1 ] . Timestamp ) ;
180+ var _ = _mapForQueries . GetBefore ( cutoff ) ;
181+ }
182+
183+ /// <summary>Global strictly-after query using a midpoint cutoff.</summary>
184+ [ Benchmark ( Description = "Global query: GetAfter(midpoint cutoff)" ) ]
185+ public void Global_GetAfter_Midpoint ( )
186+ {
187+ var to = DateTimeOffset . UtcNow ;
188+ var from = to . AddMinutes ( - 5 ) ;
189+ var window = _mapForQueries . GetInRange ( from , to ) . ToArray ( ) ;
190+ if ( window . Length < 2 ) return ;
191+ var cutoff = Mid ( window [ 0 ] . Timestamp , window [ ^ 1 ] . Timestamp ) ;
192+ var _ = _mapForQueries . GetAfter ( cutoff ) ;
193+ }
194+
195+ /// <summary>Global inclusive count in a 2-minute window.</summary>
196+ [ Benchmark ( Description = "Global query: CountInRange(last 2 minutes)" ) ]
197+ public int Global_CountInRange_Last2Minutes ( )
198+ {
199+ var to = DateTimeOffset . UtcNow ;
200+ var from = to . AddMinutes ( - 2 ) ;
201+ return _mapForQueries . CountInRange ( from , to ) ;
202+ }
203+
204+ /// <summary>Global count since (>=) a moving cutoff (~last minute).</summary>
205+ [ Benchmark ( Description = "Global query: CountSince(last 1 minute)" ) ]
206+ public int Global_CountSince_Last1Minute ( )
207+ {
208+ var from = DateTimeOffset . UtcNow . AddMinutes ( - 1 ) ;
209+ return _mapForQueries . CountSince ( from ) ;
210+ }
211+
212+ /// <summary>Global nearest-to-time (use midpoint of a recent window).</summary>
213+ [ Benchmark ( Description = "Global query: GetNearest(midpoint)" ) ]
214+ public TemporalItem < KeyValuePair < string , int > > ? Global_GetNearest_Midpoint ( )
215+ {
216+ var to = DateTimeOffset . UtcNow ;
217+ var from = to . AddMinutes ( - 5 ) ;
218+ var window = _mapForQueries . GetInRange ( from , to ) . ToArray ( ) ;
219+ if ( window . Length < 2 ) return null ;
220+ var mid = Mid ( window [ 0 ] . Timestamp , window [ ^ 1 ] . Timestamp ) ;
221+ return _mapForQueries . GetNearest ( mid ) ;
222+ }
223+
224+ /// <summary>Fetch extremes and span in a single call group.</summary>
225+ [ Benchmark ( Description = "Global query: GetLatest/GetEarliest/GetTimeSpan" ) ]
226+ public ( TemporalItem < KeyValuePair < string , int > > ? latest ,
227+ TemporalItem < KeyValuePair < string , int > > ? earliest ,
228+ TimeSpan span ) Global_Extremes_And_Span ( )
229+ {
230+ var latest = _mapForQueries . GetLatest ( ) ;
231+ var earliest = _mapForQueries . GetEarliest ( ) ;
232+ var span = _mapForQueries . GetTimeSpan ( ) ;
233+ return ( latest , earliest , span ) ;
234+ }
235+
236+ // ---------- Retention ----------
237+
238+ /// <summary>Per-key RemoveOlderThan with cutoff = now - 1 minute.</summary>
239+ [ Benchmark ( Description = "Per-key retention: RemoveOlderThan(key, now-1m)" ) ]
240+ public void PerKey_RemoveOlderThan ( )
241+ {
242+ string key = _keys [ _keys . Length / 2 ] ;
243+ var cutoff = DateTimeOffset . UtcNow . AddMinutes ( - 1 ) ;
244+ _mapForPerKeyRetention . RemoveOlderThan ( key , cutoff ) ;
245+ }
246+
247+ /// <summary>Per-key RemoveRange over [now-90s .. now-30s].</summary>
248+ [ Benchmark ( Description = "Per-key retention: RemoveRange(key, [now-90s..now-30s])" ) ]
249+ public void PerKey_RemoveRange ( )
250+ {
251+ string key = _keys [ _keys . Length / 2 ] ;
252+ var to = DateTimeOffset . UtcNow . AddSeconds ( - 30 ) ;
253+ var from = DateTimeOffset . UtcNow . AddSeconds ( - 90 ) ;
254+ _mapForPerKeyRetention . RemoveRange ( key , from , to ) ;
255+ }
256+
257+ /// <summary>RemoveKey for a middle key.</summary>
258+ [ Benchmark ( Description = "Per-key retention: RemoveKey(middle key)" ) ]
259+ public void PerKey_RemoveKey ( )
260+ {
261+ string key = _keys [ _keys . Length / 2 ] ;
262+ _mapForPerKeyRetention . RemoveKey ( key ) ;
263+ }
264+
265+ /// <summary>Global RemoveOlderThan with cutoff = now - 1 minute.</summary>
266+ [ Benchmark ( Description = "Global retention: RemoveOlderThan(now-1m)" ) ]
267+ public void Global_RemoveOlderThan ( )
268+ {
269+ var cutoff = DateTimeOffset . UtcNow . AddMinutes ( - 1 ) ;
270+ _mapForGlobalRetention . RemoveOlderThan ( cutoff ) ;
271+ }
272+
273+ /// <summary>Global RemoveRange over [now-2m .. now-1m].</summary>
274+ [ Benchmark ( Description = "Global retention: RemoveRange([now-2m..now-1m])" ) ]
275+ public void Global_RemoveRange ( )
276+ {
277+ var to = DateTimeOffset . UtcNow . AddMinutes ( - 1 ) ;
278+ var from = DateTimeOffset . UtcNow . AddMinutes ( - 2 ) ;
279+ _mapForGlobalRetention . RemoveRange ( from , to ) ;
280+ }
281+
282+ /// <summary>Global Clear of map.</summary>
283+ [ Benchmark ( Description = "Global retention: Clear()" ) ]
284+ public void Global_Clear ( )
285+ {
286+ _mapForGlobalRetention . Clear ( ) ;
287+ }
288+
289+ // ---------- Utility ----------
290+
291+ private static DateTimeOffset Mid ( DateTimeOffset a , DateTimeOffset b )
292+ {
293+ long mid = ( a . UtcTicks + b . UtcTicks ) / 2 ;
294+ return new DateTimeOffset ( mid , TimeSpan . Zero ) ;
295+ }
296+ }
297+ }
0 commit comments