From 4a0dcf7a73c95272addf7356768976c69a4b4ddd Mon Sep 17 00:00:00 2001 From: Jose Luis Guerra Infante Date: Sat, 4 Apr 2026 03:19:16 +0200 Subject: [PATCH] feat: add benchmarks for task management operations - 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 --- .github/workflows/benchmarks.yml | 79 +++++++++ README.md | 21 +++ .../CancelAllTasksBenchmarks.cs | 70 ++++++++ .../GetTasksStatusBenchmarks.cs | 50 ++++++ .../LifecycleBenchmarks.cs | 151 ++++++++++++++++++ ...amework.Tasks.Management.Benchmarks.csproj | 17 ++ .../Program.cs | 6 + .../README.md | 92 +++++++++++ 8 files changed, 486 insertions(+) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 benchmarks/NetFramework.Tasks.Management.Benchmarks/CancelAllTasksBenchmarks.cs create mode 100644 benchmarks/NetFramework.Tasks.Management.Benchmarks/GetTasksStatusBenchmarks.cs create mode 100644 benchmarks/NetFramework.Tasks.Management.Benchmarks/LifecycleBenchmarks.cs create mode 100644 benchmarks/NetFramework.Tasks.Management.Benchmarks/NetFramework.Tasks.Management.Benchmarks.csproj create mode 100644 benchmarks/NetFramework.Tasks.Management.Benchmarks/Program.cs create mode 100644 benchmarks/NetFramework.Tasks.Management.Benchmarks/README.md diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..834cfc7 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,79 @@ +name: Benchmarks + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + deployments: write + +jobs: + benchmark: + name: Run BenchmarkDotNet and publish charts + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 9.x + + - name: Restore + run: dotnet restore benchmarks/NetFramework.Tasks.Management.Benchmarks/NetFramework.Tasks.Management.Benchmarks.csproj + + - name: Run benchmarks (net8.0) + run: | + dotnet run -c Release -f net8.0 \ + --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ \ + -- --filter '*' \ + --exporters Json \ + --iterationCount 5 \ + --warmupCount 3 \ + --artifacts /tmp/bdn-net8 + + - name: Run benchmarks (net9.0) + run: | + dotnet run -c Release -f net9.0 \ + --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ \ + -- --filter '*' \ + --exporters Json \ + --iterationCount 5 \ + --warmupCount 3 \ + --artifacts /tmp/bdn-net9 + + - name: Merge JSON results + run: | + # Collect all BenchmarkDotNet JSON result files from both runs + JSON_FILES=$(find /tmp/bdn-net8/results /tmp/bdn-net9/results -name '*.json' 2>/dev/null | tr '\n' ' ') + echo "Found JSON files: $JSON_FILES" + + # BenchmarkDotNet JSON format: { "Title": "...", "Benchmarks": [...] } + # Merge all into a single array under "Benchmarks" + jq -s ' + { + Title: "NetFramework.Tasks.Management Benchmarks", + Benchmarks: (map(.Benchmarks) | add) + } + ' $JSON_FILES > /tmp/bdn-merged.json + + echo "Merged benchmark count: $(jq '.Benchmarks | length' /tmp/bdn-merged.json)" + + - name: Store benchmark results + uses: benchmark-action/github-action-benchmark@v1 + with: + tool: benchmarkdotnet + output-file-path: /tmp/bdn-merged.json + gh-pages-branch: gh-pages + benchmark-data-dir-path: dev/bench + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: true + comment-on-alert: true + alert-threshold: '150%' + fail-on-alert: false diff --git a/README.md b/README.md index 621fcce..a892b3f 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,27 @@ if (_tasks.DequeueTaskDisposedDataModel(out var record) == TaskManagementStatus. } ``` +## Benchmarks + +Performance is tracked automatically on every push to `main` and published as interactive charts: + +**[View benchmark charts](https://ryujose.github.io/NetTaskManagement/dev/bench/)** + +Benchmarks cover: +- `LifecycleBenchmarks` — individual lifecycle stages: Register, Start, Cancel, Delete, and full end-to-end +- `GetTasksStatusBenchmarks` — dictionary snapshot cost at 1, 10, and 50 tasks +- `CancelAllTasksBenchmarks` — `Parallel.ForEach` cancellation fan-out at 1, 10, and 50 tasks + +All benchmarks run on **net8.0** and **net9.0** with `[MemoryDiagnoser]` enabled (reports allocated bytes per operation). + +To run locally (Release mode required): + +```bash +dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ -- --filter '*' +``` + +See [benchmarks/README.md](benchmarks/NetFramework.Tasks.Management.Benchmarks/README.md) for full run instructions and filter examples. + ## Examples | Example | Description | diff --git a/benchmarks/NetFramework.Tasks.Management.Benchmarks/CancelAllTasksBenchmarks.cs b/benchmarks/NetFramework.Tasks.Management.Benchmarks/CancelAllTasksBenchmarks.cs new file mode 100644 index 0000000..e8609f3 --- /dev/null +++ b/benchmarks/NetFramework.Tasks.Management.Benchmarks/CancelAllTasksBenchmarks.cs @@ -0,0 +1,70 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging.Abstractions; +using NetFramework.Tasks.Management.Abstractions.Enums; +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace NetFramework.Tasks.Management.Benchmarks +{ + /// + /// Measures CancelAllTasks() as the number of running tasks scales. + /// + /// [InvocationCount(1)] is required: CancelAllTasks mutates CTS state so a second + /// call in the same iteration would see already-cancelled tokens and skew results. + /// IterationSetup re-registers and re-starts all tasks before each measurement. + /// + [MemoryDiagnoser] + [InvocationCount(1)] + public class CancelAllTasksBenchmarks + { + private static readonly Action SpinUntilCancelled = state => + { + var cts = (CancellationTokenSource)state; + while (!cts.IsCancellationRequested) + Thread.SpinWait(1); + }; + + private TasksManagement _tasks = null!; + private int _counter; + + [Params(1, 10, 50)] + public int TaskCount { get; set; } + + [GlobalSetup] + public void GlobalSetup() + => _tasks = new TasksManagement(NullLogger.Instance); + + [IterationSetup] + public void IterationSetup() + { + _tasks.ClearConcurrentLists(); + + for (int i = 0; i < TaskCount; i++) + { + var cts = new CancellationTokenSource(); + var name = $"task-{Interlocked.Increment(ref _counter)}-{i}"; + _tasks.RegisterTask(name, SpinUntilCancelled, cts); + _tasks.StartTask(name); + } + } + + [IterationCleanup] + public void IterationCleanup() + => _tasks.ClearConcurrentLists(); + + [GlobalCleanup] + public void GlobalCleanup() + => _tasks.ClearConcurrentLists(); + + /// + /// Benchmarks the Parallel.ForEach cancellation fan-out across TaskCount running tasks. + /// + [Benchmark] + public TaskManagementStatus CancelAllTasks() + { + var failed = new ConcurrentDictionary(); + return _tasks.CancelAllTasks(null, ref failed); + } + } +} diff --git a/benchmarks/NetFramework.Tasks.Management.Benchmarks/GetTasksStatusBenchmarks.cs b/benchmarks/NetFramework.Tasks.Management.Benchmarks/GetTasksStatusBenchmarks.cs new file mode 100644 index 0000000..df3ff3c --- /dev/null +++ b/benchmarks/NetFramework.Tasks.Management.Benchmarks/GetTasksStatusBenchmarks.cs @@ -0,0 +1,50 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace NetFramework.Tasks.Management.Benchmarks +{ + /// + /// Measures GetTasksStatus() throughput as the number of registered tasks scales. + /// GetTasksStatus is read-only (ConcurrentDictionary.ToDictionary snapshot), so + /// multiple invocations per iteration are safe — no InvocationCount(1) needed. + /// + /// Tasks are pre-registered in GlobalSetup and left alive for the entire benchmark run. + /// + [MemoryDiagnoser] + public class GetTasksStatusBenchmarks + { + private TasksManagement _tasks = null!; + + [Params(1, 10, 50)] + public int TaskCount { get; set; } + + [GlobalSetup] + public void GlobalSetup() + { + _tasks = new TasksManagement(NullLogger.Instance); + _tasks.ClearConcurrentLists(); + + for (int i = 0; i < TaskCount; i++) + { + var cts = new CancellationTokenSource(); + _tasks.RegisterTask($"task-{i}", _ => { }, cts); + // Kept in Created state — status snapshot is identical regardless + } + } + + [GlobalCleanup] + public void GlobalCleanup() + => _tasks.ClearConcurrentLists(); + + /// + /// Benchmarks ConcurrentDictionary → Dictionary snapshot cost at each task count. + /// + [Benchmark] + public Dictionary GetTasksStatus() + => _tasks.GetTasksStatus(); + } +} diff --git a/benchmarks/NetFramework.Tasks.Management.Benchmarks/LifecycleBenchmarks.cs b/benchmarks/NetFramework.Tasks.Management.Benchmarks/LifecycleBenchmarks.cs new file mode 100644 index 0000000..8a260b8 --- /dev/null +++ b/benchmarks/NetFramework.Tasks.Management.Benchmarks/LifecycleBenchmarks.cs @@ -0,0 +1,151 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging.Abstractions; +using NetFramework.Tasks.Management.Abstractions.Enums; +using System; +using System.Threading; + +namespace NetFramework.Tasks.Management.Benchmarks +{ + /// + /// Measures the cost of each individual stage in the task lifecycle: + /// Register → Start → Cancel → Delete + /// and the end-to-end full lifecycle in a single benchmark. + /// + /// [InvocationCount(1)] is required because every method mutates the shared + /// static ConcurrentDictionary — each iteration must start from a clean slate. + /// IterationSetup / IterationCleanup are targeted per benchmark method so that + /// only the stage under measurement is timed. + /// + [MemoryDiagnoser] + [InvocationCount(1)] + public class LifecycleBenchmarks + { + // A lightweight action that spins until cancelled — exits within microseconds + // of CancellationTokenSource.Cancel() so setup times stay minimal. + private static readonly Action SpinUntilCancelled = state => + { + var cts = (CancellationTokenSource)state; + while (!cts.IsCancellationRequested) + Thread.SpinWait(1); + }; + + private TasksManagement _tasks = null!; + private CancellationTokenSource _cts = null!; + private string _taskName = null!; + private int _counter; + + [GlobalSetup] + public void GlobalSetup() + => _tasks = new TasksManagement(NullLogger.Instance); + + [GlobalCleanup] + public void GlobalCleanup() + => _tasks.ClearConcurrentLists(); + + // ── RegisterTask ────────────────────────────────────────────────────── + // Measures: ConcurrentDictionary.TryAdd + Task constructor + + [IterationSetup(Targets = new[] { nameof(RegisterTask) })] + public void SetupRegister() + { + _taskName = $"bench-{Interlocked.Increment(ref _counter)}"; + _cts = new CancellationTokenSource(); + } + + [Benchmark] + public TaskManagementStatus RegisterTask() + => _tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts); + + [IterationCleanup(Targets = new[] { nameof(RegisterTask) })] + public void CleanupRegister() + => _tasks.ClearConcurrentLists(); + + // ── StartTask ───────────────────────────────────────────────────────── + // Measures: ConcurrentDictionary.TryGetValue + Task.Start (thread pool enqueue) + + [IterationSetup(Targets = new[] { nameof(StartTask) })] + public void SetupStart() + { + _taskName = $"bench-{Interlocked.Increment(ref _counter)}"; + _cts = new CancellationTokenSource(); + _tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts); + } + + [Benchmark] + public TaskManagementStatus StartTask() + => _tasks.StartTask(_taskName); + + [IterationCleanup(Targets = new[] { nameof(StartTask) })] + public void CleanupStart() + => _tasks.ClearConcurrentLists(); + + // ── CancelTask ──────────────────────────────────────────────────────── + // Measures: ConcurrentDictionary.TryGetValue + CancellationTokenSource.Cancel + + [IterationSetup(Targets = new[] { nameof(CancelTask) })] + public void SetupCancel() + { + _taskName = $"bench-{Interlocked.Increment(ref _counter)}"; + _cts = new CancellationTokenSource(); + _tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts); + _tasks.StartTask(_taskName); + } + + [Benchmark] + public TaskManagementStatus CancelTask() + => _tasks.CancelTask(_taskName); + + [IterationCleanup(Targets = new[] { nameof(CancelTask) })] + public void CleanupCancel() + => _tasks.ClearConcurrentLists(); + + // ── DeleteTask ──────────────────────────────────────────────────────── + // Measures: TryRemove + Task.Dispose + GC.Collect (see DeleteTask ordering invariant) + // Setup drives the task to completion before timing begins. + + [IterationSetup(Targets = new[] { nameof(DeleteTask) })] + public void SetupDelete() + { + _taskName = $"bench-{Interlocked.Increment(ref _counter)}"; + _cts = new CancellationTokenSource(); + _tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts); + _tasks.StartTask(_taskName); + _tasks.CancelTask(_taskName); + // Drive to completion before we start timing DeleteTask + _tasks.CheckTaskStatusCompleted(_taskName, retry: 5, millisecondsCancellationWait: 500); + } + + [Benchmark] + public TaskManagementStatus DeleteTask() + => _tasks.DeleteTask(_taskName, sendDataToInternalQueue: false); + + [IterationCleanup(Targets = new[] { nameof(DeleteTask) })] + public void CleanupDelete() + => _tasks.ClearConcurrentLists(); + + // ── FullLifecycle ───────────────────────────────────────────────────── + // Measures the complete Register → Start → Cancel → CheckCompleted → Delete + // pipeline end-to-end, including the GC.Collect inside DeleteTask. + + [IterationSetup(Targets = new[] { nameof(FullLifecycle) })] + public void SetupFullLifecycle() + { + _taskName = $"bench-{Interlocked.Increment(ref _counter)}"; + _cts = new CancellationTokenSource(); + } + + [Benchmark] + public TaskManagementStatus FullLifecycle() + { + _tasks.RegisterTask(_taskName, SpinUntilCancelled, _cts); + _tasks.StartTask(_taskName); + _tasks.CancelTask(_taskName); + _tasks.CheckTaskStatusCompleted(_taskName, retry: 5, millisecondsCancellationWait: 500); + return _tasks.DeleteTask(_taskName, sendDataToInternalQueue: false); + } + + [IterationCleanup(Targets = new[] { nameof(FullLifecycle) })] + public void CleanupFullLifecycle() + => _tasks.ClearConcurrentLists(); + } +} diff --git a/benchmarks/NetFramework.Tasks.Management.Benchmarks/NetFramework.Tasks.Management.Benchmarks.csproj b/benchmarks/NetFramework.Tasks.Management.Benchmarks/NetFramework.Tasks.Management.Benchmarks.csproj new file mode 100644 index 0000000..368d22d --- /dev/null +++ b/benchmarks/NetFramework.Tasks.Management.Benchmarks/NetFramework.Tasks.Management.Benchmarks.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0;net9.0;net10.0 + enable + disable + NetFramework.Tasks.Management.Benchmarks + true + + + + + + + + diff --git a/benchmarks/NetFramework.Tasks.Management.Benchmarks/Program.cs b/benchmarks/NetFramework.Tasks.Management.Benchmarks/Program.cs new file mode 100644 index 0000000..bc044d5 --- /dev/null +++ b/benchmarks/NetFramework.Tasks.Management.Benchmarks/Program.cs @@ -0,0 +1,6 @@ +using BenchmarkDotNet.Running; + +// Run a specific class: dotnet run -c Release -f net8.0 -- --filter *Lifecycle* +// Run everything: dotnet run -c Release -f net8.0 -- --filter * +// Interactive menu: dotnet run -c Release -f net8.0 +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/NetFramework.Tasks.Management.Benchmarks/README.md b/benchmarks/NetFramework.Tasks.Management.Benchmarks/README.md new file mode 100644 index 0000000..8e179c9 --- /dev/null +++ b/benchmarks/NetFramework.Tasks.Management.Benchmarks/README.md @@ -0,0 +1,92 @@ +# NetFramework.Tasks.Management — Benchmarks + +BenchmarkDotNet suite that measures the cost of every public operation exposed by `ITaskManagement`, targeting all three supported runtimes: **net8.0**, **net9.0**, and **net10.0**. + +## Benchmark classes + +### `LifecycleBenchmarks` + +Isolates each individual stage of the task lifecycle. `[InvocationCount(1)]` is applied at the class level because every method mutates the shared static `ConcurrentDictionary` — each iteration must start from a clean slate. Targeted `IterationSetup` / `IterationCleanup` drive the task to the required state before timing begins, then reset afterwards. + +| Benchmark | What is measured | +|---|---| +| `RegisterTask` | `ConcurrentDictionary.TryAdd` + `Task` constructor | +| `StartTask` | `TryGetValue` + `Task.Start` (thread-pool enqueue) | +| `CancelTask` | `TryGetValue` + `CancellationTokenSource.Cancel` | +| `DeleteTask` | `TryRemove` + `Task.Dispose` + `GC.Collect` (see ordering invariant in CLAUDE.md) | +| `FullLifecycle` | All four stages end-to-end, including the forced GC | + +### `GetTasksStatusBenchmarks` + +Measures `GetTasksStatus()` (a `ConcurrentDictionary → Dictionary` snapshot) as the number of registered tasks grows. Tasks stay alive across all iterations — no `InvocationCount(1)` needed because the operation is purely read-only. + +| Parameter | Values | +|---|---| +| `TaskCount` | 1, 10, 50 | + +### `CancelAllTasksBenchmarks` + +Measures the `Parallel.ForEach` fan-out inside `CancelAllTasks` as the number of running tasks grows. `[InvocationCount(1)]` + `IterationSetup` re-registers and re-starts all tasks before each measurement so cancellation state is always fresh. + +| Parameter | Values | +|---|---| +| `TaskCount` | 1, 10, 50 | + +## Running + +> Benchmarks must be run in **Release** configuration. Debug builds produce meaningless results. + +### Single framework + +```bash +# net8.0 +dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ + +# net9.0 +dotnet run -c Release -f net9.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ + +# net10.0 +dotnet run -c Release -f net10.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ +``` + +### Filter to a specific class + +```bash +dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ -- --filter *Lifecycle* +dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ -- --filter *GetTasksStatus* +dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ -- --filter *CancelAll* +``` + +### All benchmarks + +```bash +dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ -- --filter * +``` + +### Interactive menu (no filter) + +```bash +dotnet run -c Release -f net8.0 --project benchmarks/NetFramework.Tasks.Management.Benchmarks/ +``` + +BenchmarkDotNet will list all available benchmark classes and let you choose interactively. + +## Output + +Results are written to `BenchmarkDotNet.Artifacts/` in the project directory: + +``` +BenchmarkDotNet.Artifacts/ +└── results/ + ├── NetFramework.Tasks.Management.Benchmarks.LifecycleBenchmarks-report.md + ├── NetFramework.Tasks.Management.Benchmarks.GetTasksStatusBenchmarks-report.md + └── NetFramework.Tasks.Management.Benchmarks.CancelAllTasksBenchmarks-report.md +``` + +`[MemoryDiagnoser]` is enabled on all classes so each report includes **allocated bytes per operation** in addition to execution time. + +## Notes + +- `DeleteTask` and `FullLifecycle` will be slower than the other operations because `DeleteTask` calls `GC.Collect()` + `GC.WaitForPendingFinalizers()` by design (see the ordering invariant in [CLAUDE.md](../../CLAUDE.md)). +- `CancelAllTasks` uses `Parallel.ForEach` internally, so its cost scales sub-linearly with `TaskCount` on machines with multiple cores. +- `GetTasksStatus` allocates a new `Dictionary` on every call — the allocation column in the report reflects this.