diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml new file mode 100644 index 0000000..15fbea2 --- /dev/null +++ b/.github/workflows/benchmarks.yaml @@ -0,0 +1,63 @@ +name: Benchmarks + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + benchmarksPath: './benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj' + buildConfiguration: 'Release' + +jobs: + benchmarks: + name: Run Benchmarks + runs-on: ubuntu-latest + steps: + - name: Code checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis and for GitVersion based versioning + + # --- GitVersion --- + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v4.2.0 + with: + versionSpec: '6.5.x' + - name: Determine Version + id: version_step # step id used as reference for output values + uses: gittools/actions/gitversion/command@v4.2.0 + with: + arguments: '/output buildserver /l console /config GitVersion.yaml /updateprojectfiles' # have to self-define all arguments; /updateprojectfiles not impl. in execute task + + # --- Setup .NET --- + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + # --- Restore --- + - name: Restore + run: dotnet restore ${{ env.benchmarksPath }} + + # --- Build --- + - name: Build ${{ env.GitVersion_FullSemVer }} + run: dotnet build ${{ env.benchmarksPath }} --no-restore -c Release + + # --- Run Benchmarks --- + - name: Run Benchmarks for ${{ env.GitVersion_FullSemVer }} + run: dotnet run --project ${{ env.benchmarksPath }} --no-build -c Release -f net10.0 + + # --- Publish Benchmark Results --- + - name: Upload Benchmark Results + uses: actions/upload-artifact@v4 + with: + name: BenchmarkResults-${{ env.GitVersion_FullSemVer }} + path: ./BenchmarkDotNet.Artifacts/results + retention-days: 30 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 329b465..53cf57c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,7 +15,8 @@ permissions: env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 # Opt out of telemetry to speed up the build DOTNET_NOLOGO: 1 # Prevents the dotnet CLI logo from displaying that speeds up build times - solutionPath: './DurationMancer.slnx' + libraryPath: './src/DurationMancer/DurationMancer.csproj' + testsPath: './tests/DurationMancer.Tests/DurationMancer.Tests.csproj' buildConfiguration: 'Release' SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -56,8 +57,10 @@ jobs: run: dotnet tool install dotnet-sonarscanner --global # --- Restore --- - - name: Restore dependencies - run: dotnet restore ${{ env.solutionPath }} + - name: Restore library + run: dotnet restore ${{ env.libraryPath }} + - name: Restore tests + run: dotnet restore ${{ env.testsPath }} # --- SonarCloud begin --- - name: Prepare SonarQube analyze @@ -67,18 +70,25 @@ jobs: /v:"${{ env.GitVersion_FullSemVer }}" /d:sonar.token="$SONAR_TOKEN" /d:sonar.cs.opencover.reportsPaths=./TestResults/coverage*.opencover.xml + /d:sonar.exclusions="benchmarks/**" + /d:sonar.coverage.exclusions="benchmarks/**" # --- Build --- - - name: Build ${{ env.GitVersion_FullSemVer }} + - name: Build library ${{ env.GitVersion_FullSemVer }} run: > - dotnet build ${{ env.solutionPath }} + dotnet build ${{ env.libraryPath }} + --no-restore + -c ${{ env.buildConfiguration }} + - name: Build tests + run: > + dotnet build ${{ env.testsPath }} --no-restore -c ${{ env.buildConfiguration }} # --- Test --- - - name: Test ${{ env.solutionPath }} + - name: Test run: > - dotnet test ${{ env.solutionPath }} --no-build -c ${{ env.buildConfiguration }} + dotnet test ${{ env.testsPath }} --no-build -c ${{ env.buildConfiguration }} --logger trx --results-directory "./TestResults/" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" @@ -101,7 +111,7 @@ jobs: # --- NuGet Pack + Upload --- - name: Pack NuGet package run: > - dotnet pack ${{ env.solutionPath }} + dotnet pack ${{ env.libraryPath }} --no-build -c ${{ env.buildConfiguration }} -o ./nupkg diff --git a/Directory.Packages.props b/Directory.Packages.props index 871dfbc..574c688 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + @@ -14,4 +15,4 @@ - + \ No newline at end of file diff --git a/DurationMancer.slnx b/DurationMancer.slnx index 40c5c69..688880b 100644 --- a/DurationMancer.slnx +++ b/DurationMancer.slnx @@ -1,4 +1,5 @@ + diff --git a/README.md b/README.md index b95d745..963d3c6 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,6 @@ This format is best for precise, structured input. It follows the pattern: `[day - `1.05:00:00` → 1 day, 5 hours - `00:00:45.500` → 45.5 seconds ---- - ### Human-Readable Format This format is ideal for quick CLI input. You can mix and match units using full names or shorthand. @@ -83,3 +81,46 @@ This format is ideal for quick CLI input. You can mix and match units using full - `1.5 hours` - `100ms` - `2days 4h 15m 30s` + +## Benchmarks + +``` +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2) +AMD Ryzen 9 7950X3D 4.20GHz, 1 CPU, 32 logical and 16 physical cores +.NET SDK 10.0.103 + [Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 + .NET 10 : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 + .NET 9 : .NET 9.0.13 (9.0.13, 9.0.1326.6317), X64 RyuJIT x86-64-v4 + .NET 8 : .NET 8.0.24 (8.0.24, 8.0.2426.7010), X64 RyuJIT x86-64-v4 +``` + +| Method | Runtime | Mean | Error | StdDev | P95 | Ratio | RatioSD | Rank | Gen0 | Allocated | +| ------------------------------ | --------- | ----------: | ---------: | --------: | ----------: | ----: | ------: | ---: | -----: | --------: | +| 'Standard: HH:mm:ss' | .NET 10.0 | 298.1505 ns | 2.4080 ns | 2.2524 ns | 301.3607 ns | 8.02 | 0.06 | 3 | 0.0348 | 1752 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 10.0 | 337.5800 ns | 6.5587 ns | 6.1350 ns | 346.8909 ns | 9.08 | 0.16 | 4 | 0.0362 | 1840 B | +| 'Human: single unit (5m)' | .NET 10.0 | 392.8773 ns | 3.5938 ns | 3.3616 ns | 397.7572 ns | 10.56 | 0.09 | 5 | 0.0339 | 1712 B | +| 'Human: two units (1h 30m)' | .NET 10.0 | 452.5045 ns | 2.3685 ns | 1.9778 ns | 454.8526 ns | 12.17 | 0.06 | 6 | 0.0353 | 1776 B | +| 'Human: full combo' \* | .NET 10.0 | 759.7605 ns | 6.1496 ns | 5.7524 ns | 768.1694 ns | 20.43 | 0.16 | 8 | 0.0401 | 2024 B | +| 'Human: decimals (1.5h 100ms)' | .NET 10.0 | 560.4385 ns | 2.5589 ns | 2.2684 ns | 563.9663 ns | 15.07 | 0.07 | 7 | 0.0372 | 1880 B | +| 'Invalid input' | .NET 10.0 | 37.1920 ns | 0.0890 ns | 0.0789 ns | 37.3029 ns | 1.00 | 0.00 | 2 | - | - | +| 'Null input' | .NET 10.0 | 0.8341 ns | 0.0046 ns | 0.0038 ns | 0.8401 ns | 0.02 | 0.00 | 1 | - | - | +| | | | | | | | | | | | +| 'Standard: HH:mm:ss' | .NET 9.0 | 354.0586 ns | 7.0855 ns | 8.1597 ns | 363.1163 ns | 6.11 | 0.14 | 3 | 0.0348 | 1752 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 9.0 | 397.6152 ns | 6.9474 ns | 6.4986 ns | 406.1554 ns | 6.86 | 0.12 | 4 | 0.0362 | 1840 B | +| 'Human: single unit (5m)' | .NET 9.0 | 471.8717 ns | 9.2507 ns | 9.4998 ns | 485.1973 ns | 8.15 | 0.17 | 5 | 0.0339 | 1712 B | +| 'Human: two units (1h 30m)' | .NET 9.0 | 536.7581 ns | 10.7092 ns | 8.9427 ns | 546.0439 ns | 9.27 | 0.16 | 6 | 0.0353 | 1776 B | +| 'Human: full combo' \* | .NET 9.0 | 873.6216 ns | 8.8310 ns | 7.8284 ns | 886.2697 ns | 15.08 | 0.17 | 8 | 0.0401 | 2024 B | +| 'Human: decimals (1.5h 100ms)' | .NET 9.0 | 690.4374 ns | 4.3499 ns | 3.8561 ns | 696.4002 ns | 11.92 | 0.10 | 7 | 0.0372 | 1880 B | +| 'Invalid input' | .NET 9.0 | 57.9295 ns | 0.2371 ns | 0.4027 ns | 58.8710 ns | 1.00 | 0.01 | 2 | - | - | +| 'Null input' | .NET 9.0 | 0.8429 ns | 0.0099 ns | 0.0087 ns | 0.8568 ns | 0.01 | 0.00 | 1 | - | - | +| | | | | | | | | | | | +| 'Standard: HH:mm:ss' | .NET 8.0 | 315.1999 ns | 2.1455 ns | 1.9019 ns | 318.1252 ns | 5.29 | 0.04 | 3 | 0.0348 | 1752 B | +| 'Standard: d.HH:mm:ss.fff' | .NET 8.0 | 368.5408 ns | 1.7708 ns | 1.4787 ns | 370.8185 ns | 6.18 | 0.03 | 4 | 0.0362 | 1840 B | +| 'Human: single unit (5m)' | .NET 8.0 | 440.2367 ns | 1.8504 ns | 1.6403 ns | 442.6820 ns | 7.38 | 0.04 | 5 | 0.0339 | 1712 B | +| 'Human: two units (1h 30m)' | .NET 8.0 | 492.3350 ns | 5.4852 ns | 5.1308 ns | 499.0772 ns | 8.26 | 0.09 | 6 | 0.0353 | 1776 B | +| 'Human: full combo' \* | .NET 8.0 | 853.8517 ns | 8.0323 ns | 7.5134 ns | 865.4025 ns | 14.32 | 0.13 | 8 | 0.0401 | 2024 B | +| 'Human: decimals (1.5h 100ms)' | .NET 8.0 | 707.8297 ns | 5.2995 ns | 4.9572 ns | 715.4481 ns | 11.87 | 0.09 | 7 | 0.0372 | 1880 B | +| 'Invalid input' | .NET 8.0 | 59.6169 ns | 0.2654 ns | 0.2352 ns | 59.9797 ns | 1.00 | 0.01 | 2 | - | - | +| 'Null input' | .NET 8.0 | 0.9443 ns | 0.0370 ns | 0.0803 ns | 1.0528 ns | 0.02 | 0.00 | 1 | - | - | + +\* full combo = "2 days 4 hours 15 minutes 30 seconds 500 milliseconds" diff --git a/benchmarks/DurationMancer.Benchmarks/Configuration.cs b/benchmarks/DurationMancer.Benchmarks/Configuration.cs new file mode 100644 index 0000000..f19b0eb --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/Configuration.cs @@ -0,0 +1,22 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; + +namespace DurationMancer.Benchmarks; + +public class Configuration : ManualConfig +{ + public Configuration() + { + AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).WithId(".NET 8")); + AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).WithId(".NET 9")); + AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0).WithId(".NET 10")); + + AddExporter(MarkdownExporter.GitHub); + AddExporter(HtmlExporter.Default); + + AddColumn(StatisticColumn.P95); + } +} diff --git a/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj new file mode 100644 index 0000000..46ba2f3 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/DurationMancer.Benchmarks.csproj @@ -0,0 +1,18 @@ + + + + Exe + false + false + System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage + + + + + + + + + + + diff --git a/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs new file mode 100644 index 0000000..7199181 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/DurationTimeParserBenchmarks.cs @@ -0,0 +1,47 @@ +using BenchmarkDotNet.Attributes; + +namespace DurationMancer.Benchmarks; + +[MemoryDiagnoser] +[RankColumn] +[Config(typeof(Configuration))] +public class DurationTimeParserBenchmarks +{ + // --- Standard duration format inputs --- + + [Benchmark(Description = "Standard: HH:mm:ss")] + public bool Standard_HHmmss() + => DurationTimeParser.TryParse("12:30:45", out _); + + [Benchmark(Description = "Standard: d.HH:mm:ss.fff")] + public bool Standard_Full() + => DurationTimeParser.TryParse("3.08:15:30.250", out _); + + // --- Human-readable format inputs --- + + [Benchmark(Description = "Human: single unit (5m)")] + public bool Human_SingleUnit() + => DurationTimeParser.TryParse("5m", out _); + + [Benchmark(Description = "Human: two units (1h 30m)")] + public bool Human_TwoUnits() + => DurationTimeParser.TryParse("1h 30m", out _); + + [Benchmark(Description = "Human: full combo")] + public bool Human_FullCombo() + => DurationTimeParser.TryParse("2 days 4 hours 15 minutes 30 seconds 500 milliseconds", out _); + + [Benchmark(Description = "Human: decimals (1.5h 100ms)")] + public bool Human_Decimals() + => DurationTimeParser.TryParse("1.5h 100ms", out _); + + // --- Edge / failure cases --- + + [Benchmark(Description = "Invalid input", Baseline = true)] + public bool Invalid() + => DurationTimeParser.TryParse("not a duration", out _); + + [Benchmark(Description = "Null input")] + public bool Null() + => DurationTimeParser.TryParse(null, out _); +} diff --git a/benchmarks/DurationMancer.Benchmarks/Program.cs b/benchmarks/DurationMancer.Benchmarks/Program.cs new file mode 100644 index 0000000..69766a6 --- /dev/null +++ b/benchmarks/DurationMancer.Benchmarks/Program.cs @@ -0,0 +1,4 @@ +using BenchmarkDotNet.Running; +using DurationMancer.Benchmarks; + +BenchmarkRunner.Run();