Skip to content

Commit de44e91

Browse files
committed
perf remove summer update delegate allocations
1 parent 2c9118b commit de44e91

7 files changed

Lines changed: 191 additions & 15 deletions

File tree

‎Directory.Build.props‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
<RepositoryUrl>https://github.com/managedcode/TimeSeries</RepositoryUrl>
2525
<PackageProjectUrl>https://github.com/managedcode/TimeSeries</PackageProjectUrl>
2626
<Product>Managed Code - TimeSeries</Product>
27-
<Version>10.0.2</Version>
28-
<PackageVersion>10.0.2</PackageVersion>
27+
<Version>10.0.3</Version>
28+
<PackageVersion>10.0.3</PackageVersion>
2929

3030
</PropertyGroup>
3131
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">

‎ManagedCode.TimeSeries.Tests/ConcurrencyTests.cs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,32 @@ namespace ManagedCode.TimeSeries.Tests;
99

1010
public class ConcurrencyTests
1111
{
12+
[Fact]
13+
public async Task NumberSummer_ConcurrentSameBucketUpdatesPreserveExactTotalAndRange()
14+
{
15+
var series = new NumberTimeSeriesSummer<long>(TimeSpan.FromSeconds(1), 0, Strategy.Sum);
16+
var bucket = series.Start;
17+
const int writerCount = 8;
18+
const int updatesPerWriter = 2000;
19+
20+
var writers = Enumerable.Range(0, writerCount).Select(_ => Task.Run(() =>
21+
{
22+
for (var i = 0; i < updatesPerWriter; i++)
23+
{
24+
series.AddNewData(bucket, 1L);
25+
}
26+
}));
27+
28+
await Task.WhenAll(writers);
29+
30+
var expectedTotal = (long)(writerCount * updatesPerWriter);
31+
series.DataCount.ShouldBe((ulong)expectedTotal);
32+
series.Samples.Count.ShouldBe(1);
33+
series.Samples[bucket].ShouldBe(expectedTotal);
34+
series.Start.ShouldBe(bucket);
35+
series.End.ShouldBe(bucket);
36+
}
37+
1238
[Fact]
1339
public async Task Accumulator_ConcurrentWritesAndReads_AreSafe()
1440
{

‎ManagedCode.TimeSeries.Tests/SummersTests.cs‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Numerics;
12
using ManagedCode.TimeSeries.Abstractions;
23
using ManagedCode.TimeSeries.Accumulators;
34
using ManagedCode.TimeSeries.Summers;
@@ -303,6 +304,26 @@ public void NumberTimeSeriesSummerResamplePreservesTotals()
303304
summer.Sum().ShouldBe(12);
304305
}
305306

307+
[Fact]
308+
public void NumberTimeSeriesSummerStrategiesCoverSupportedNumericTypes()
309+
{
310+
AssertStrategies<int>(2, 3, 4);
311+
AssertStrategies<long>(2, 3, 4);
312+
AssertStrategies<float>(2, 3, 4);
313+
AssertStrategies<double>(2, 3, 4);
314+
AssertStrategies<decimal>(2, 3, 4);
315+
}
316+
317+
[Fact]
318+
public void NumberTimeSeriesSummerMergeAndResamplePreserveStrategiesAcrossNumericTypes()
319+
{
320+
AssertMergeAndResample<int>();
321+
AssertMergeAndResample<long>();
322+
AssertMergeAndResample<float>();
323+
AssertMergeAndResample<double>();
324+
AssertMergeAndResample<decimal>();
325+
}
326+
306327
[Fact]
307328
public void DoubleTimeSeriesSummerConstructorsRespectStrategy()
308329
{
@@ -330,6 +351,90 @@ public void FloatTimeSeriesSummerAggregatesValues()
330351
summer.Max().ShouldNotBeNull();
331352
}
332353

354+
private static void AssertStrategies<TNumber>(TNumber first, TNumber second, TNumber newBucketValue)
355+
where TNumber : struct, INumber<TNumber>
356+
{
357+
foreach (var strategy in Enum.GetValues<Strategy>())
358+
{
359+
var summer = new NumberTimeSeriesSummer<TNumber>(TimeSpan.FromSeconds(1), 0, strategy);
360+
var start = summer.Start;
361+
summer.AddNewData(start, first);
362+
summer.AddNewData(start, second);
363+
summer.Samples[start].ShouldBe(strategy switch
364+
{
365+
Strategy.Sum => first + second,
366+
Strategy.Min => TNumber.Min(first, second),
367+
Strategy.Max => TNumber.Max(first, second),
368+
Strategy.Replace => second,
369+
_ => throw new InvalidOperationException("Unsupported strategy.")
370+
});
371+
372+
var next = start.AddSeconds(1);
373+
summer.AddNewData(next, newBucketValue);
374+
summer.Samples[next].ShouldBe(newBucketValue);
375+
summer.DataCount.ShouldBe(3ul);
376+
summer.Start.ShouldBe(start);
377+
summer.End.ShouldBe(next);
378+
}
379+
}
380+
381+
private static void AssertMergeAndResample<TNumber>()
382+
where TNumber : struct, INumber<TNumber>
383+
{
384+
foreach (var strategy in Enum.GetValues<Strategy>())
385+
{
386+
var summer = new NumberTimeSeriesSummer<TNumber>(TimeSpan.FromSeconds(1), 0, strategy);
387+
var incoming = new NumberTimeSeriesSummer<TNumber>(TimeSpan.FromSeconds(1), 0, strategy);
388+
var interval = TimeSpan.FromSeconds(3);
389+
var currentStartTicks = summer.Start.UtcDateTime.Ticks;
390+
var alignedStartTicks = currentStartTicks - currentStartTicks % interval.Ticks;
391+
var start = new DateTimeOffset(alignedStartTicks, TimeSpan.Zero);
392+
var first = TNumber.CreateChecked(2);
393+
var second = TNumber.CreateChecked(10);
394+
var merged = TNumber.CreateChecked(3);
395+
var newBucket = TNumber.CreateChecked(20);
396+
397+
summer.AddNewData(start, first);
398+
summer.AddNewData(start.AddSeconds(1), second);
399+
incoming.AddNewData(start, merged);
400+
incoming.AddNewData(start.AddSeconds(3), newBucket);
401+
402+
summer.Merge(incoming);
403+
summer.DataCount.ShouldBe(4ul);
404+
summer.Start.ShouldBe(start);
405+
summer.End.ShouldBe(start.AddSeconds(3));
406+
summer.Samples[start].ShouldBe(strategy switch
407+
{
408+
Strategy.Sum => first + merged,
409+
Strategy.Min => TNumber.Min(first, merged),
410+
Strategy.Max => TNumber.Max(first, merged),
411+
Strategy.Replace => merged,
412+
_ => throw new InvalidOperationException("Unsupported strategy.")
413+
});
414+
415+
summer.Resample(interval, 0);
416+
417+
if (strategy == Strategy.Replace)
418+
{
419+
// The source snapshot is unordered, so Replace keeps one of these inputs.
420+
new[] { merged, second }.ShouldContain(summer.Samples[start]);
421+
}
422+
else
423+
{
424+
summer.Samples[start].ShouldBe(strategy switch
425+
{
426+
Strategy.Sum => first + merged + second,
427+
Strategy.Min => TNumber.Min(TNumber.Min(first, merged), second),
428+
Strategy.Max => TNumber.Max(TNumber.Max(first, merged), second),
429+
_ => throw new InvalidOperationException("Unsupported strategy.")
430+
});
431+
}
432+
433+
summer.Samples[start.AddSeconds(3)].ShouldBe(newBucket);
434+
summer.DataCount.ShouldBe(4ul);
435+
}
436+
}
437+
333438
// [Fact]
334439
// public async Task SummerGroup()
335440
// {

‎ManagedCode.TimeSeries/Abstractions/BaseNumberTimeSeriesSummer.cs‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ public abstract class BaseNumberTimeSeriesSummer<TNumber, TSelf>(TimeSpan sample
1717

1818
protected override void AddData(DateTimeOffset date, TNumber data)
1919
{
20-
AddOrUpdateSample(date,
21-
() => data,
22-
current => Update(current, data));
20+
var state = (Owner: this, Value: data);
21+
AddOrUpdateSample(
22+
date,
23+
state,
24+
static context => context.Value,
25+
static (current, context) => context.Owner.Update(current, context.Value));
2326
}
2427

2528
/// <inheritdoc />
@@ -38,9 +41,12 @@ public override void Merge(TSelf accumulator)
3841

3942
foreach (var sample in accumulator.Samples)
4043
{
41-
AddOrUpdateSample(sample.Key,
42-
() => sample.Value,
43-
current => Update(current, sample.Value));
44+
var state = (Owner: this, Value: sample.Value);
45+
AddOrUpdateSample(
46+
sample.Key,
47+
state,
48+
static context => context.Value,
49+
static (current, context) => context.Owner.Update(current, context.Value));
4450
}
4551
}
4652

@@ -67,9 +73,12 @@ public override void Resample(TimeSpan sampleInterval, int samplesCount)
6773
foreach (var (key, value) in snapshot)
6874
{
6975
var roundedKey = key.RoundUtc(SampleInterval);
70-
AddOrUpdateSample(roundedKey,
71-
() => value,
72-
current => Update(current, value));
76+
var state = (Owner: this, Value: value);
77+
AddOrUpdateSample(
78+
roundedKey,
79+
state,
80+
static context => context.Value,
81+
static (current, context) => context.Owner.Update(current, context.Value));
7382

7483
if (!lastObservedSample.HasValue || roundedKey > lastObservedSample.Value)
7584
{

‎ManagedCode.TimeSeries/Abstractions/BaseTimeSeries.cs‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,12 +381,26 @@ protected TSample GetOrCreateSample(DateTimeOffset key, Func<TSample> factory)
381381
}
382382

383383
protected TSample AddOrUpdateSample(DateTimeOffset key, Func<TSample> addFactory, Func<TSample, TSample> updateFactory)
384+
{
385+
var factories = (Add: addFactory, Update: updateFactory);
386+
return AddOrUpdateSample(
387+
key,
388+
factories,
389+
static state => state.Add(),
390+
static (current, state) => state.Update(current));
391+
}
392+
393+
protected TSample AddOrUpdateSample<TArg>(
394+
DateTimeOffset key,
395+
TArg argument,
396+
Func<TArg, TSample> addFactory,
397+
Func<TSample, TArg, TSample> updateFactory)
384398
{
385399
while (true)
386400
{
387401
if (_samples.TryGetValue(key, out var existing))
388402
{
389-
var updated = updateFactory(existing);
403+
var updated = updateFactory(existing, argument);
390404
if (_samples.TryUpdate(key, updated, existing))
391405
{
392406
UpdateRange(key);
@@ -396,7 +410,7 @@ protected TSample AddOrUpdateSample(DateTimeOffset key, Func<TSample> addFactory
396410
continue;
397411
}
398412

399-
var created = addFactory();
413+
var created = addFactory(argument);
400414
if (_samples.TryAdd(key, created))
401415
{
402416
UpdateOnAdd(key);

‎docs/ADR/0002-performance-and-temporal-arithmetic.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0002: measured ingestion and exact temporal arithmetic
22

3-
Status: Accepted; runtime evidence and patch delivery pending.
3+
Status: Accepted; temporal arithmetic and release workflow repair are released in v10.0.2 with exact-source tests, coverage, native profiles, package signatures and feed payloads verified. The selected summer allocation candidate for v10.0.3 awaits exact-source correctness and repeated performance qualification.
44

55
## Decision
66

‎docs/Features/performance-and-temporal-arithmetic.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Performance and temporal arithmetic
22

3-
Status: source contract accepted; implementation and current-source GitHub evidence pending.
3+
Status: temporal arithmetic is released in v10.0.1 and its native regression suite passes; release publication integrity is repaired and qualified in v10.0.2. The approved summer allocation candidate is prepared for v10.0.3 and awaits exact-source correctness, coverage and repeated performance qualification. This slice remains in progress until the selected summer change passes all gates and the owning release/feed proof is complete.
44

55
## Requirements and acceptance
66

@@ -122,3 +122,25 @@ another allocation hotspot, but its concurrent trimming semantics remain under
122122
review. The current work does not change `EnsureCapacity` or its range logic.
123123
Any later capacity implementation needs its own bounded concurrency contract,
124124
late-arrival and multi-writer regressions, and source review before profiling.
125+
126+
### Exact-source execution record (2026-10-03)
127+
128+
- `43a142759d3b9fa65d10a58559def98ab0020b75` delivered the temporal arithmetic
129+
repair as v10.0.1; its exact-source Release job passed. The later v10.0.1
130+
duplicate-run asset overwrite is retained as a delivery defect and was repaired
131+
by the v10.0.2 release workflow contract.
132+
- `64cba752e366fec74f8a09c0450ce724d483012e` made the capacity fixture start
133+
deterministic. Its full performance workflow later passed, including all
134+
repeated normal/scalar measurements; the earlier failed IsFull timing fixture
135+
is retained in the plan history.
136+
- `2c9118b6fbd45a5170682d345d66cba4ad6e8f2d` delivered v10.0.2. Its Release
137+
workflow passed build and 1104/1104 xUnit tests; line coverage was Core 668/710
138+
(94.08%) and Orleans 205/212 (96.70%). Both packages were freshly published,
139+
signed feed contents matched the package payloads, and a same-source duplicate
140+
dispatch skipped release mutation with unchanged asset hashes. Its native
141+
performance run passed all four repeated 16-case normal/scalar profiles and is
142+
the exact pre-summer baseline at `/private/tmp/timeseries-run-2c9118b/`.
143+
- The current generic-state summer candidate retains the original protected
144+
callback API and one CAS loop. Its source/test diff is under root review; no
145+
10.0.3 performance or release claim is made until a fresh exact-source GitHub
146+
run passes all correctness, coverage, profile and feed gates.

0 commit comments

Comments
 (0)