Skip to content

Commit 2148e9b

Browse files
authored
feat: add benchmarks for task management operations (#19)
- Introduced `NetFramework.Tasks.Management.Benchmarks` project for performance benchmarking. - Added `LifecycleBenchmarks`: measures individual task lifecycle stages (Register, Start, Cancel, Delete) and full lifecycle. - Added `GetTasksStatusBenchmarks`: benchmarks dictionary snapshot cost as task count scales. - Added `CancelAllTasksBenchmarks`: evaluates `CancelAllTasks()` fan-out with varying task counts. - Integrated BenchmarkDotNet for precise performance analysis. - Authored comprehensive `README.md` with benchmark descriptions, setup, and execution guidelines. - Enabled CI workflow (`benchmarks.yml`) to automatically run benchmarks on every push to `main` branch and publish results with interactive charts. - Updated root `README.md` to include links and summaries for the benchmarks. Signed-off-by: Jose Luis Guerra Infante <sora_ryu@hotmail.com>
1 parent 88d7500 commit 2148e9b

8 files changed

Lines changed: 486 additions & 0 deletions

File tree

.github/workflows/benchmarks.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Benchmarks
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
deployments: write
12+
13+
jobs:
14+
benchmark:
15+
name: Run BenchmarkDotNet and publish charts
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Setup .NET
22+
uses: actions/setup-dotnet@v4
23+
with:
24+
dotnet-version: |
25+
8.x
26+
9.x
27+
28+
- name: Restore
29+
run: dotnet restore benchmarks/NetFramework.Tasks.Management.Benchmarks/NetFramework.Tasks.Management.Benchmarks.csproj
30+
31+
- name: Run benchmarks (net8.0)
32+
run: |
33+
dotnet run -c Release -f net8.0 \
34+
--project benchmarks/NetFramework.Tasks.Management.Benchmarks/ \
35+
-- --filter '*' \
36+
--exporters Json \
37+
--iterationCount 5 \
38+
--warmupCount 3 \
39+
--artifacts /tmp/bdn-net8
40+
41+
- name: Run benchmarks (net9.0)
42+
run: |
43+
dotnet run -c Release -f net9.0 \
44+
--project benchmarks/NetFramework.Tasks.Management.Benchmarks/ \
45+
-- --filter '*' \
46+
--exporters Json \
47+
--iterationCount 5 \
48+
--warmupCount 3 \
49+
--artifacts /tmp/bdn-net9
50+
51+
- name: Merge JSON results
52+
run: |
53+
# Collect all BenchmarkDotNet JSON result files from both runs
54+
JSON_FILES=$(find /tmp/bdn-net8/results /tmp/bdn-net9/results -name '*.json' 2>/dev/null | tr '\n' ' ')
55+
echo "Found JSON files: $JSON_FILES"
56+
57+
# BenchmarkDotNet JSON format: { "Title": "...", "Benchmarks": [...] }
58+
# Merge all into a single array under "Benchmarks"
59+
jq -s '
60+
{
61+
Title: "NetFramework.Tasks.Management Benchmarks",
62+
Benchmarks: (map(.Benchmarks) | add)
63+
}
64+
' $JSON_FILES > /tmp/bdn-merged.json
65+
66+
echo "Merged benchmark count: $(jq '.Benchmarks | length' /tmp/bdn-merged.json)"
67+
68+
- name: Store benchmark results
69+
uses: benchmark-action/github-action-benchmark@v1
70+
with:
71+
tool: benchmarkdotnet
72+
output-file-path: /tmp/bdn-merged.json
73+
gh-pages-branch: gh-pages
74+
benchmark-data-dir-path: dev/bench
75+
github-token: ${{ secrets.GITHUB_TOKEN }}
76+
auto-push: true
77+
comment-on-alert: true
78+
alert-threshold: '150%'
79+
fail-on-alert: false

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,27 @@ if (_tasks.DequeueTaskDisposedDataModel(out var record) == TaskManagementStatus.
102102
}
103103
```
104104

105+
## Benchmarks
106+
107+
Performance is tracked automatically on every push to `main` and published as interactive charts:
108+
109+
**[View benchmark charts](https://ryujose.github.io/NetTaskManagement/dev/bench/)**
110+
111+
Benchmarks cover:
112+
- `LifecycleBenchmarks` — individual lifecycle stages: Register, Start, Cancel, Delete, and full end-to-end
113+
- `GetTasksStatusBenchmarks` — dictionary snapshot cost at 1, 10, and 50 tasks
114+
- `CancelAllTasksBenchmarks``Parallel.ForEach` cancellation fan-out at 1, 10, and 50 tasks
115+
116+
All benchmarks run on **net8.0** and **net9.0** with `[MemoryDiagnoser]` enabled (reports allocated bytes per operation).
117+
118+
To run locally (Release mode required):
119+
120+
```bash
121+
dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ -- --filter '*'
122+
```
123+
124+
See [benchmarks/README.md](benchmarks/NetFramework.Tasks.Management.Benchmarks/README.md) for full run instructions and filter examples.
125+
105126
## Examples
106127

107128
| Example | Description |
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using BenchmarkDotNet.Attributes;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using NetFramework.Tasks.Management.Abstractions.Enums;
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Threading;
7+
8+
namespace NetFramework.Tasks.Management.Benchmarks
9+
{
10+
/// <summary>
11+
/// Measures CancelAllTasks() as the number of running tasks scales.
12+
///
13+
/// [InvocationCount(1)] is required: CancelAllTasks mutates CTS state so a second
14+
/// call in the same iteration would see already-cancelled tokens and skew results.
15+
/// IterationSetup re-registers and re-starts all tasks before each measurement.
16+
/// </summary>
17+
[MemoryDiagnoser]
18+
[InvocationCount(1)]
19+
public class CancelAllTasksBenchmarks
20+
{
21+
private static readonly Action<object> SpinUntilCancelled = state =>
22+
{
23+
var cts = (CancellationTokenSource)state;
24+
while (!cts.IsCancellationRequested)
25+
Thread.SpinWait(1);
26+
};
27+
28+
private TasksManagement _tasks = null!;
29+
private int _counter;
30+
31+
[Params(1, 10, 50)]
32+
public int TaskCount { get; set; }
33+
34+
[GlobalSetup]
35+
public void GlobalSetup()
36+
=> _tasks = new TasksManagement(NullLogger.Instance);
37+
38+
[IterationSetup]
39+
public void IterationSetup()
40+
{
41+
_tasks.ClearConcurrentLists();
42+
43+
for (int i = 0; i < TaskCount; i++)
44+
{
45+
var cts = new CancellationTokenSource();
46+
var name = $"task-{Interlocked.Increment(ref _counter)}-{i}";
47+
_tasks.RegisterTask(name, SpinUntilCancelled, cts);
48+
_tasks.StartTask(name);
49+
}
50+
}
51+
52+
[IterationCleanup]
53+
public void IterationCleanup()
54+
=> _tasks.ClearConcurrentLists();
55+
56+
[GlobalCleanup]
57+
public void GlobalCleanup()
58+
=> _tasks.ClearConcurrentLists();
59+
60+
/// <summary>
61+
/// Benchmarks the Parallel.ForEach cancellation fan-out across TaskCount running tasks.
62+
/// </summary>
63+
[Benchmark]
64+
public TaskManagementStatus CancelAllTasks()
65+
{
66+
var failed = new ConcurrentDictionary<string, TaskManagementStatus>();
67+
return _tasks.CancelAllTasks(null, ref failed);
68+
}
69+
}
70+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using BenchmarkDotNet.Attributes;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
8+
namespace NetFramework.Tasks.Management.Benchmarks
9+
{
10+
/// <summary>
11+
/// Measures GetTasksStatus() throughput as the number of registered tasks scales.
12+
/// GetTasksStatus is read-only (ConcurrentDictionary.ToDictionary snapshot), so
13+
/// multiple invocations per iteration are safe — no InvocationCount(1) needed.
14+
///
15+
/// Tasks are pre-registered in GlobalSetup and left alive for the entire benchmark run.
16+
/// </summary>
17+
[MemoryDiagnoser]
18+
public class GetTasksStatusBenchmarks
19+
{
20+
private TasksManagement _tasks = null!;
21+
22+
[Params(1, 10, 50)]
23+
public int TaskCount { get; set; }
24+
25+
[GlobalSetup]
26+
public void GlobalSetup()
27+
{
28+
_tasks = new TasksManagement(NullLogger.Instance);
29+
_tasks.ClearConcurrentLists();
30+
31+
for (int i = 0; i < TaskCount; i++)
32+
{
33+
var cts = new CancellationTokenSource();
34+
_tasks.RegisterTask($"task-{i}", _ => { }, cts);
35+
// Kept in Created state — status snapshot is identical regardless
36+
}
37+
}
38+
39+
[GlobalCleanup]
40+
public void GlobalCleanup()
41+
=> _tasks.ClearConcurrentLists();
42+
43+
/// <summary>
44+
/// Benchmarks ConcurrentDictionary → Dictionary snapshot cost at each task count.
45+
/// </summary>
46+
[Benchmark]
47+
public Dictionary<string, TaskStatus> GetTasksStatus()
48+
=> _tasks.GetTasksStatus();
49+
}
50+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
using BenchmarkDotNet.Attributes;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using NetFramework.Tasks.Management.Abstractions.Enums;
4+
using System;
5+
using System.Threading;
6+
7+
namespace NetFramework.Tasks.Management.Benchmarks
8+
{
9+
/// <summary>
10+
/// Measures the cost of each individual stage in the task lifecycle:
11+
/// Register → Start → Cancel → Delete
12+
/// and the end-to-end full lifecycle in a single benchmark.
13+
///
14+
/// [InvocationCount(1)] is required because every method mutates the shared
15+
/// static ConcurrentDictionary — each iteration must start from a clean slate.
16+
/// IterationSetup / IterationCleanup are targeted per benchmark method so that
17+
/// only the stage under measurement is timed.
18+
/// </summary>
19+
[MemoryDiagnoser]
20+
[InvocationCount(1)]
21+
public class LifecycleBenchmarks
22+
{
23+
// A lightweight action that spins until cancelled — exits within microseconds
24+
// of CancellationTokenSource.Cancel() so setup times stay minimal.
25+
private static readonly Action<object> SpinUntilCancelled = state =>
26+
{
27+
var cts = (CancellationTokenSource)state;
28+
while (!cts.IsCancellationRequested)
29+
Thread.SpinWait(1);
30+
};
31+
32+
private TasksManagement _tasks = null!;
33+
private CancellationTokenSource _cts = null!;
34+
private string _taskName = null!;
35+
private int _counter;
36+
37+
[GlobalSetup]
38+
public void GlobalSetup()
39+
=> _tasks = new TasksManagement(NullLogger.Instance);
40+
41+
[GlobalCleanup]
42+
public void GlobalCleanup()
43+
=> _tasks.ClearConcurrentLists();
44+
45+
// ── RegisterTask ──────────────────────────────────────────────────────
46+
// Measures: ConcurrentDictionary.TryAdd + Task constructor
47+
48+
[IterationSetup(Targets = new[] { nameof(RegisterTask) })]
49+
public void SetupRegister()
50+
{
51+
_taskName = $"bench-{Interlocked.Increment(ref _counter)}";
52+
_cts = new CancellationTokenSource();
53+
}
54+
55+
[Benchmark]
56+
public TaskManagementStatus RegisterTask()
57+
=> _tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts);
58+
59+
[IterationCleanup(Targets = new[] { nameof(RegisterTask) })]
60+
public void CleanupRegister()
61+
=> _tasks.ClearConcurrentLists();
62+
63+
// ── StartTask ─────────────────────────────────────────────────────────
64+
// Measures: ConcurrentDictionary.TryGetValue + Task.Start (thread pool enqueue)
65+
66+
[IterationSetup(Targets = new[] { nameof(StartTask) })]
67+
public void SetupStart()
68+
{
69+
_taskName = $"bench-{Interlocked.Increment(ref _counter)}";
70+
_cts = new CancellationTokenSource();
71+
_tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts);
72+
}
73+
74+
[Benchmark]
75+
public TaskManagementStatus StartTask()
76+
=> _tasks.StartTask(_taskName);
77+
78+
[IterationCleanup(Targets = new[] { nameof(StartTask) })]
79+
public void CleanupStart()
80+
=> _tasks.ClearConcurrentLists();
81+
82+
// ── CancelTask ────────────────────────────────────────────────────────
83+
// Measures: ConcurrentDictionary.TryGetValue + CancellationTokenSource.Cancel
84+
85+
[IterationSetup(Targets = new[] { nameof(CancelTask) })]
86+
public void SetupCancel()
87+
{
88+
_taskName = $"bench-{Interlocked.Increment(ref _counter)}";
89+
_cts = new CancellationTokenSource();
90+
_tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts);
91+
_tasks.StartTask(_taskName);
92+
}
93+
94+
[Benchmark]
95+
public TaskManagementStatus CancelTask()
96+
=> _tasks.CancelTask(_taskName);
97+
98+
[IterationCleanup(Targets = new[] { nameof(CancelTask) })]
99+
public void CleanupCancel()
100+
=> _tasks.ClearConcurrentLists();
101+
102+
// ── DeleteTask ────────────────────────────────────────────────────────
103+
// Measures: TryRemove + Task.Dispose + GC.Collect (see DeleteTask ordering invariant)
104+
// Setup drives the task to completion before timing begins.
105+
106+
[IterationSetup(Targets = new[] { nameof(DeleteTask) })]
107+
public void SetupDelete()
108+
{
109+
_taskName = $"bench-{Interlocked.Increment(ref _counter)}";
110+
_cts = new CancellationTokenSource();
111+
_tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts);
112+
_tasks.StartTask(_taskName);
113+
_tasks.CancelTask(_taskName);
114+
// Drive to completion before we start timing DeleteTask
115+
_tasks.CheckTaskStatusCompleted(_taskName, retry: 5, millisecondsCancellationWait: 500);
116+
}
117+
118+
[Benchmark]
119+
public TaskManagementStatus DeleteTask()
120+
=> _tasks.DeleteTask(_taskName, sendDataToInternalQueue: false);
121+
122+
[IterationCleanup(Targets = new[] { nameof(DeleteTask) })]
123+
public void CleanupDelete()
124+
=> _tasks.ClearConcurrentLists();
125+
126+
// ── FullLifecycle ─────────────────────────────────────────────────────
127+
// Measures the complete Register → Start → Cancel → CheckCompleted → Delete
128+
// pipeline end-to-end, including the GC.Collect inside DeleteTask.
129+
130+
[IterationSetup(Targets = new[] { nameof(FullLifecycle) })]
131+
public void SetupFullLifecycle()
132+
{
133+
_taskName = $"bench-{Interlocked.Increment(ref _counter)}";
134+
_cts = new CancellationTokenSource();
135+
}
136+
137+
[Benchmark]
138+
public TaskManagementStatus FullLifecycle()
139+
{
140+
_tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts);
141+
_tasks.StartTask(_taskName);
142+
_tasks.CancelTask(_taskName);
143+
_tasks.CheckTaskStatusCompleted(_taskName, retry: 5, millisecondsCancellationWait: 500);
144+
return _tasks.DeleteTask(_taskName, sendDataToInternalQueue: false);
145+
}
146+
147+
[IterationCleanup(Targets = new[] { nameof(FullLifecycle) })]
148+
public void CleanupFullLifecycle()
149+
=> _tasks.ClearConcurrentLists();
150+
}
151+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>disable</ImplicitUsings>
8+
<RootNamespace>NetFramework.Tasks.Management.Benchmarks</RootNamespace>
9+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<ProjectReference Include="..\..\src\NetFramework.Task.Management\NetFramework.Tasks.Management.csproj" />
14+
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
15+
</ItemGroup>
16+
17+
</Project>

0 commit comments

Comments
 (0)