diff --git a/.gitignore b/.gitignore
index c72dbc5..07a1cc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -400,3 +400,6 @@ FodyWeavers.xsd
# Project extensions
.dotnet/
.CodeCoverage/
+
+## repository customizations
+*.lnk
diff --git a/Measure-Coverage.ps1 b/Measure-Coverage.ps1
index 89b58f6..fa4913a 100644
--- a/Measure-Coverage.ps1
+++ b/Measure-Coverage.ps1
@@ -12,16 +12,16 @@ try {
}
# remove old test report
- Get-ChildItem -Directory src/*.Test*/TestResults/* | Remove-Item -Recurse
+ Get-ChildItem -Directory tests/*.Test*/TestResults/* | Remove-Item -Recurse
# rebuild target project to generate source generator files
dotnet build PathBench.slnx --no-incremental --property:EmitCompilerGeneratedFiles=true
# test and measure coverage
- dotnet test PathBench.slnx --collect:"XPlat Code Coverage" --settings src/etc/coverlet.runsettings
+ dotnet test PathBench.slnx --collect:"XPlat Code Coverage" --settings tests/etc/coverlet.runsettings
# export HTML coverage report
- Get-ChildItem src/*.Test*/TestResults/*/coverage.cobertura.xml `
+ Get-ChildItem tests/*.Test*/TestResults/*/coverage.cobertura.xml `
| ForEach-Object {
$name = $_.FullName -replace '^.+[/\\](.+?)[/\\]TestResults[/\\].+[/\\]coverage.cobertura.xml$', '$1'
&./.dotnet/reportgenerator -reports:"$_" -targetdir:".CodeCoverage/$name" -reporttypes:Html
diff --git a/PathBench.slnx b/PathBench.slnx
index 9661fdc..cb18f64 100644
--- a/PathBench.slnx
+++ b/PathBench.slnx
@@ -1,9 +1,22 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 2ed222b..44b31d2 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,91 @@
# PathBench
Code path performance monitoring tool.
+
+----
+
+## Features
+
+*PathBench* is a tool that measures method execution time to identify performance bottlenecks. It provides the following features:
+
+- **Method execution time statistics**: Measures method execution times and provides statistical information such as the mean, standard deviation, minimum, and maximum.
+- **Checkpoint recording**: Allows you to set checkpoints at arbitrary locations within a method and measure the execution time between checkpoints.
+- **Result visualization**: Outputs measurement results in Graphviz format and visualizes them as a directed graph between checkpoints.
+
+## Usage
+
+1. **Installation**: Add *PathBench* to your project from NuGet.
+2. **Code changes**
+ 1. Create a `CodePathCounter` instance in the class that contains the method you want to monitor.
+ 2. At the beginning of the monitored method, call `counter.StartMeasurement()` to create a measurement instance and start the measurement.
+ 3. Dispose the measurement instance to end the measurement.
+ 4. Call the `CreateProfileReports()` method on the `CodePathCounter` instance to produce measurement report instances.
+ 5. Pass the measurement report instances to an appropriate formatter to output the results.
+
+```csharp
+using PathBench;
+
+invokeTest();
+
+static void invokeTest()
+{
+ for (var i = 0; i < 1000; ++i)
+ {
+ SampleClass.SimulatedWork(i);
+ }
+
+ var reports = SampleClass.Profiler.CreateProfileReports();
+ var sw = new StringWriter();
+ MethodProfileReportFormatter.DefaultGraphvizStyle.Format(
+ reports[nameof(SampleClass.SimulatedWork)],
+ writer: sw);
+ Console.WriteLine(sw.ToString());
+}
+
+static class SampleClass
+{
+ public static readonly CodePathProfiler Profiler = CodePathProfiler.Create();
+
+ public static void SimulatedWork(int seed)
+ {
+ using var counter = Profiler.StartMeasurement(argumentsExpressionProvider: $"seed={seed}");
+ if (seed % 2 == 0)
+ {
+ counter.MarkCheckpoint("EvenSeed");
+ Wait(2);
+ }
+ else
+ {
+ counter.MarkCheckpoint("OddSeed");
+ Wait(0);
+ }
+ for (var i = 0; i < seed; ++i)
+ {
+ counter.MarkCheckpoint("LoopBegin", i);
+ if (seed % 3 == 0)
+ {
+ counter.MarkCheckpoint("DivisibleBy3");
+ Wait(3);
+ }
+ if (seed % 5 == 0)
+ {
+ counter.MarkCheckpoint("DivisibleBy5");
+ Wait(5);
+ }
+ if (seed % 7 == 0)
+ {
+ counter.MarkCheckpoint("DivisibleBy7");
+ Wait(7);
+ }
+ counter.MarkCheckpoint("LoopEnd");
+ }
+ }
+
+ private static void Wait(int value)
+ {
+ Thread.SpinWait(value * 100);
+ }
+}
+```
+
+
diff --git a/resources/GraphvizSample.svg b/resources/GraphvizSample.svg
new file mode 100644
index 0000000..7515a06
--- /dev/null
+++ b/resources/GraphvizSample.svg
@@ -0,0 +1,204 @@
+
+
+
+
+
diff --git a/src/PathBench/CodePathCounter.InvocationCounter.cs b/src/PathBench/CodePathCounter.InvocationCounter.cs
deleted file mode 100644
index ea47570..0000000
--- a/src/PathBench/CodePathCounter.InvocationCounter.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-namespace PathBench;
-
-partial class CodePathCounter
-{
- private sealed class InvocationCounter_ : InvocationCounter
- {
- private CodePathCounter Owner { get; }
- public string? MethodName { get; }
- public DateTimeOffset StartAt { get; }
- public long InvocationIndex { get; }
- public int ManagedThreadId { get; }
- public object? ArgumentsExpressionProvider { get; }
- public long Duration => _endAtTimestamp >= 0 ? _endAtTimestamp - _startAtTimestamp : -1;
- public IReadOnlyList Checkpoints => _checkpoints ?? _freezedCheckpoints!;
-
- private List? _checkpoints;
- private readonly long _startAtTimestamp;
- private List? _freezedCheckpoints;
- private long _endAtTimestamp = -1;
-
- public InvocationCounter_(
- CodePathCounter owner,
- string? methodName,
- long invocationIndex,
- object? argumentsExpressionProvider)
- {
- Owner = owner;
- MethodName = methodName;
- ArgumentsExpressionProvider = argumentsExpressionProvider;
- StartAt = DateTimeOffset.UtcNow;
- ManagedThreadId = Environment.CurrentManagedThreadId;
- _startAtTimestamp = owner.TimeProvider.GetTimestamp();
-
- _checkpoints = [];
- MarkCheckpoint(StartCheckpointName, null);
- }
-
- public override void Dispose()
- {
- _endAtTimestamp = Owner.TimeProvider.GetTimestamp();
- MarkCheckpoint(EndCheckpointName, _endAtTimestamp, null);
- (_freezedCheckpoints, _checkpoints) = (_checkpoints, null);
- Owner.TerminateInvocation(this);
- }
-
- public override void MarkCheckpoint(string name, object? noteProvider = null) =>
- MarkCheckpoint(name, Owner.TimeProvider.GetTimestamp(), noteProvider);
-
- private void MarkCheckpoint(string name, long timestamp, object? noteProvider)
- {
- var checkpoints = _checkpoints ?? throw new ObjectDisposedException(null);
- checkpoints.Add(new(name, noteProvider, timestamp));
- }
- }
-
- private readonly record struct CheckpointMeasurement(
- string Name,
- object? NoteProvider,
- long DurationTimestamp);
-}
\ No newline at end of file
diff --git a/src/PathBench/CodePathCounter.cs b/src/PathBench/CodePathCounter.cs
deleted file mode 100644
index dec7f46..0000000
--- a/src/PathBench/CodePathCounter.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-namespace PathBench;
-
-///
-/// Performance measurement tool for counting code paths.
-/// One instance of this type should be related to one type definition.
-///
-public partial class CodePathCounter(
- string? className = null,
- CodePathCounterOptions? options = null)
-{
- public const string StartCheckpointName = "#";
- public const string EndCheckpointName = "#";
-
- private readonly Lock _lockToken = new();
- private readonly int _RecentHistoryCacheSize = options?.RecentHistoryCacheSize ?? CodePathCounterOptions.DefaultRecentHistoryCacheSize;
- private readonly int _WorstHistoryCacheSize = options?.WorstHistoryCacheSize ?? CodePathCounterOptions.DefaultWorstHistoryCacheSize;
- private readonly Queue _RecentHistory = new(options?.RecentHistoryCacheSize ?? CodePathCounterOptions.DefaultRecentHistoryCacheSize);
- private readonly SortedList _WorstHistory = new(options?.WorstHistoryCacheSize ?? CodePathCounterOptions.DefaultWorstHistoryCacheSize);
-
- private long _invocationCount = 0;
- private long _terminatedInvocationCount = 0;
- private double _meanDuration_sec = 0;
- private double _sumSquaredDeviationOfDuration_sec2 = 0;
-
- public string? ClassName { get; } = className;
- public TimeProvider TimeProvider { get; } = options?.TimeProvider ?? TimeProvider.System;
-
- public virtual InvocationCounter StartMeasurement(
- string? methodName = null,
- object? argumentsExpressionProvider = null)
- {
- var id = Interlocked.Increment(ref _invocationCount);
- var invocation = new InvocationCounter_(
- this,
- methodName,
- id,
- argumentsExpressionProvider);
- return invocation;
- }
-
-
- private void TerminateInvocation(InvocationCounter_ invocation)
- {
- // Implementation omitted for brevity.
- using(_lockToken.EnterScope())
- {
- double x = invocation.Duration;
- ref var n = ref _terminatedInvocationCount;
- ref var mu = ref _meanDuration_sec;
- ref var ss = ref _sumSquaredDeviationOfDuration_sec2;
- ss = ss + n / (n + 1.0) * (x - mu) * (x - mu);
- mu = mu + (x - mu) / (n + 1.0);
- n++;
-
- _RecentHistory.Enqueue(invocation);
- if(_RecentHistory.Count > _RecentHistoryCacheSize)
- {
- _RecentHistory.Dequeue();
- }
- if(x < (_WorstHistory.Values.LastOrDefault()?.Duration ?? double.PositiveInfinity))
- {
- _WorstHistory.Add(x, invocation);
- if(_WorstHistory.Count > _WorstHistoryCacheSize)
- {
- _WorstHistory.RemoveAt(_WorstHistoryCacheSize - 1);
- }
- }
- }
- }
-
- private (TimeSpan meanDuration, TimeSpan standardDeviationOfDuration) CalculateStatistics()
- {
- double meanDuration_sec;
- double standardDeviationOfDuration_sec;
- using (_lockToken.EnterScope())
- {
- meanDuration_sec = _meanDuration_sec;
- standardDeviationOfDuration_sec = Math.Sqrt(_sumSquaredDeviationOfDuration_sec2 / (_terminatedInvocationCount - 1));
- }
- return (TimeSpan.FromSeconds(meanDuration_sec), TimeSpan.FromSeconds(standardDeviationOfDuration_sec));
- }
-}
diff --git a/src/PathBench/CodePathCounterOptions.cs b/src/PathBench/CodePathCounterOptions.cs
deleted file mode 100644
index b0f5082..0000000
--- a/src/PathBench/CodePathCounterOptions.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace PathBench;
-
-public class CodePathCounterOptions
-{
- public const int DefaultRecentHistoryCacheSize = 256;
- public const int DefaultWorstHistoryCacheSize = 256;
-
- public TimeProvider? TimeProvider { get; set; } = null;
- public int RecentHistoryCacheSize { get; set; } = DefaultRecentHistoryCacheSize;
- public int WorstHistoryCacheSize { get; set; } = DefaultWorstHistoryCacheSize;
-}
diff --git a/src/PathBench/CodePathProfiler.Empty.cs b/src/PathBench/CodePathProfiler.Empty.cs
new file mode 100644
index 0000000..c17d9e3
--- /dev/null
+++ b/src/PathBench/CodePathProfiler.Empty.cs
@@ -0,0 +1,55 @@
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+
+namespace PathBench;
+
+partial class CodePathProfiler
+{
+ ///
+ /// Gets a null implementation of .
+ ///
+ public static CodePathProfiler Empty { get; } = new Empty_();
+
+ private sealed class Empty_ : CodePathProfiler
+ {
+ private sealed class InvocationProfiler_ : InvocationProfiler
+ {
+ public override void Dispose()
+ {
+ // No-op
+ }
+
+ public override void MarkCheckpoint(string name, int orderingKey, object? noteProvider = null)
+ {
+ // No-op
+ }
+
+ protected internal override InvocationMeasurementReport CreateMeasurementReport() =>
+ new(
+ CounterName: string.Empty,
+ InvocationId: 0,
+ StartAt: default,
+ ManagedThreadId: default,
+ ArgumentsExpression: string.Empty,
+ Duration: default,
+ CodePathMeasurements: []);
+ }
+ private static readonly InvocationProfiler_ _instance = new ();
+
+ public override string? ClassName => null;
+
+ public override TimeProvider TimeProvider => TimeProvider.System;
+
+ public Empty_()
+ {
+ }
+
+ public override ImmutableDictionary CreateProfileReports() =>
+ [];
+
+ public override InvocationProfiler StartMeasurement(
+ [CallerMemberName] string methodName = "",
+ object? argumentsExpressionProvider = null) =>
+ _instance;
+ }
+}
diff --git a/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs b/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs
new file mode 100644
index 0000000..8157629
--- /dev/null
+++ b/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs
@@ -0,0 +1,83 @@
+namespace PathBench;
+
+partial class CodePathProfiler
+{
+ partial class Impl_
+ {
+ private sealed class InvocationProfiler_ : InvocationProfiler
+ {
+ private MethodProfiler Owner { get; }
+ private TimeProvider TimeProvider { get; }
+ private string Name { get; }
+ public DateTimeOffset StartAt { get; }
+ public long InvocationIndex { get; }
+ public int ManagedThreadId { get; }
+ public object? ArgumentsExpressionProvider { get; }
+ public long Duration => _endAtTimestamp >= 0 ? _endAtTimestamp - _startAtTimestamp : -1;
+ public List Checkpoints => _checkpoints ?? _freezedCheckpoints!;
+
+ private List? _checkpoints;
+ private readonly long _startAtTimestamp;
+ private List? _freezedCheckpoints;
+ private long _endAtTimestamp = -1;
+
+ public InvocationProfiler_(
+ MethodProfiler owner,
+ long invocationIndex,
+ object? argumentsExpressionProvider)
+ {
+ Owner = owner;
+ Name = owner.Name;
+ TimeProvider = owner.Owner.TimeProvider;
+ StartAt = TimeProvider.GetUtcNow();
+ InvocationIndex = invocationIndex;
+ ArgumentsExpressionProvider = argumentsExpressionProvider;
+ ManagedThreadId = Environment.CurrentManagedThreadId;
+ _startAtTimestamp = owner.Owner.TimeProvider.GetTimestamp();
+
+ _checkpoints = [];
+ MarkCheckpoint(StartCheckpointName, int.MinValue, null);
+ }
+
+ public override void Dispose()
+ {
+ Volatile.Write(ref _endAtTimestamp, TimeProvider.GetTimestamp());
+ MarkCheckpoint(EndCheckpointName, int.MaxValue, null);
+ (_freezedCheckpoints, _checkpoints) = (_checkpoints, null);
+ Owner.TerminateInvocation(this);
+ }
+
+ public override void MarkCheckpoint(string name, int orderingKey, object? noteProvider = null) =>
+ MarkCheckpoint(name, orderingKey, TimeProvider.GetTimestamp(), noteProvider);
+
+ internal protected override InvocationMeasurementReport CreateMeasurementReport()
+ {
+ var path = Checkpoints.Zip(Checkpoints.Skip(1), static (start, end) =>
+ new CheckpointTransitionMeasurementReport(
+ Key: new CheckpointTransitionKey(start.Name, end.Name),
+ Note: start.NoteProvider?.ToString(),
+ Duration: new PreciseDuration(end.DurationTimestamp - start.DurationTimestamp)));
+ return new(
+ CounterName: Name,
+ InvocationId: InvocationIndex,
+ StartAt: StartAt,
+ ManagedThreadId: ManagedThreadId,
+ ArgumentsExpression: ArgumentsExpressionProvider?.ToString(),
+ Duration: new PreciseDuration(Duration),
+ CodePathMeasurements: [.. path]);
+ }
+
+ private void MarkCheckpoint(string name, int sortKey, long timestamp, object? noteProvider)
+ {
+ var checkpoints = _checkpoints ?? throw new ObjectDisposedException(null);
+ checkpoints.Add(new(name, sortKey, noteProvider, timestamp));
+ }
+ }
+
+ private record class CheckpointMeasurement(
+ string Name,
+ int SortKey,
+ object? NoteProvider,
+ long DurationTimestamp);
+ }
+}
\ No newline at end of file
diff --git a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs
new file mode 100644
index 0000000..86c4e30
--- /dev/null
+++ b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs
@@ -0,0 +1,156 @@
+using System.Collections.Immutable;
+using System.Runtime.InteropServices;
+
+namespace PathBench;
+
+partial class CodePathProfiler
+{
+ partial class Impl_
+ {
+ private class MethodProfiler(Impl_ owner, string? methodName = null)
+ {
+ private record struct WorstHistoryKey(double DurationSec, long InvocationId);
+ private sealed class WorstHistoryKeyComparer : IComparer
+ {
+ public static WorstHistoryKeyComparer Instance { get; } = new();
+
+ public int Compare(WorstHistoryKey x, WorstHistoryKey y)
+ {
+ var c = x.DurationSec.CompareTo(y.DurationSec);
+ if (c != 0) return -c;
+ return -x.InvocationId.CompareTo(y.InvocationId);
+ }
+ }
+
+ private readonly Lock _lockToken = new();
+ private readonly Dictionary _codePathResults = [];
+ private readonly Queue _recentHistory = new(owner._recentHistoryCacheSize);
+ private readonly SortedList _worstHistory = new(owner._worstHistoryCacheSize, WorstHistoryKeyComparer.Instance);
+
+ private long _invocationCount = 0;
+ private WelfordStatistics _overallDurations;
+
+ private long TimestampFrequency => Owner.TimeProvider.TimestampFrequency;
+
+ public Impl_ Owner => owner;
+ public string Name { get; } = $"{owner.ClassName}.{methodName}";
+
+
+ public InvocationProfiler StartMeasurement(object? argumentsExpressionProvider = null)
+ {
+ var id = Interlocked.Increment(ref _invocationCount);
+ var invocation = new InvocationProfiler_(
+ this,
+ id,
+ argumentsExpressionProvider);
+ return invocation;
+ }
+
+
+ public void TerminateInvocation(InvocationProfiler_ invocation)
+ {
+ var x = (double)invocation.Duration / TimestampFrequency;
+ var checkpoints = CollectionsMarshal.AsSpan(invocation.Checkpoints);
+ using (_lockToken.EnterScope())
+ {
+ // update overall statistics
+ _overallDurations.IncrementResult(x);
+
+ // update code path report
+ for (var i = 0; i < checkpoints.Length - 1; ++i)
+ {
+ var start = invocation.Checkpoints[i];
+ var end = invocation.Checkpoints[i + 1];
+ var key = new CheckpointTransitionKey(start.Name, end.Name);
+ var y = (double)(end.DurationTimestamp - start.DurationTimestamp) / TimestampFrequency;
+ if (!_codePathResults.TryGetValue(key, out var result))
+ {
+ result = new(key, start.SortKey, end.SortKey);
+ _codePathResults.Add(key, result);
+ }
+ result.IncrementResult(y);
+ }
+
+ // update recent history
+ _recentHistory.Enqueue(invocation);
+ if (_recentHistory.Count > Owner._recentHistoryCacheSize)
+ {
+ _recentHistory.Dequeue();
+ }
+
+ // update worst history
+ if (x < (_worstHistory.Values.LastOrDefault()?.Duration ?? double.PositiveInfinity))
+ {
+ _worstHistory.Add(new(x, invocation.InvocationIndex), invocation);
+ if (_worstHistory.Count > Owner._worstHistoryCacheSize)
+ {
+ _worstHistory.RemoveAt(Owner._worstHistoryCacheSize - 1);
+ }
+ }
+ }
+ }
+
+
+ public MethodProfileReport CreateReport()
+ {
+ long times;
+ double mean_sec;
+ double sd_sec;
+ double max_sec;
+ double min_sec;
+ InvocationProfiler_[] recentHistory;
+ InvocationProfiler_[] worstHistory;
+ var foundCheckpoints = ImmutableDictionary.CreateBuilder();
+ var codePathSummaries = ImmutableDictionary.CreateBuilder();
+ using (_lockToken.EnterScope())
+ {
+ (times, mean_sec, sd_sec, max_sec, min_sec) = _overallDurations;
+ foreach (var (key, result) in _codePathResults)
+ {
+ foundCheckpoints.TryAdd(key.StartCheckpoint, new CheckpointMetadata(key.StartCheckpoint, result.StartCheckpointSortKey));
+ foundCheckpoints.TryAdd(key.EndCheckpoint, new CheckpointMetadata(key.EndCheckpoint, result.EndCheckpointSortKey));
+ codePathSummaries.Add(key, result.CreateSummary());
+ }
+ recentHistory = [.. _recentHistory];
+ worstHistory = [.. _worstHistory.Values];
+ }
+ var histories = ImmutableDictionary.CreateBuilder>();
+ histories.Add(HistoryType.Recent, [.. recentHistory.Select(static x => x.CreateMeasurementReport())]);
+ histories.Add(HistoryType.Worst, [.. worstHistory.Select(static x => x.CreateMeasurementReport())]);
+ return new MethodProfileReport(
+ CounterName: $"{Owner.ClassName}.{methodName}",
+ TotalTimes: times,
+ MeanDuration: PreciseDuration.FromSeconds(mean_sec),
+ StandardDeviationOfDuration: PreciseDuration.FromSeconds(sd_sec),
+ MaxDuration: PreciseDuration.FromSeconds(max_sec),
+ MinDuration: PreciseDuration.FromSeconds(min_sec),
+ FoundCheckpoints: foundCheckpoints.ToImmutable(),
+ CodePathSummaries: codePathSummaries.ToImmutable(),
+ Histories: histories.ToImmutable());
+ }
+
+
+ private class CodePathResult(CheckpointTransitionKey key, int startCheckpointSortKey, int endCheckpointSortKey)
+ {
+ public CheckpointTransitionKey Key { get; } = key;
+ public int StartCheckpointSortKey { get; } = startCheckpointSortKey;
+ public int EndCheckpointSortKey { get; } = endCheckpointSortKey;
+ private WelfordStatistics _durations;
+
+ public void IncrementResult(double duration_sec) =>
+ _durations.IncrementResult(duration_sec);
+
+ public CheckpointTransitionProfileReport CreateSummary()
+ {
+ var (times, mean_sec, sd_sec, max_sec, min_sec) = _durations;
+ return new(
+ Key, times,
+ PreciseDuration.FromSeconds(mean_sec),
+ PreciseDuration.FromSeconds(sd_sec),
+ PreciseDuration.FromSeconds(max_sec),
+ PreciseDuration.FromSeconds(min_sec));
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PathBench/CodePathProfiler.Impl.cs b/src/PathBench/CodePathProfiler.Impl.cs
new file mode 100644
index 0000000..384cee8
--- /dev/null
+++ b/src/PathBench/CodePathProfiler.Impl.cs
@@ -0,0 +1,65 @@
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace PathBench;
+
+partial class CodePathProfiler
+{
+ private sealed partial class Impl_ : CodePathProfiler
+ {
+ private readonly int _recentHistoryCacheSize;
+ private readonly int _worstHistoryCacheSize;
+
+ private readonly object _lockToken = new();
+ private ImmutableDictionary _methodCounters = [];
+
+ public override string? ClassName { get; }
+
+ public override TimeProvider TimeProvider { get; }
+
+ internal Impl_(
+ string? className = null,
+ CodePathProfilerOptions? options = null)
+ {
+ _recentHistoryCacheSize = (options ?? CodePathProfilerOptions.Default).RecentHistoryCacheSize;
+ _worstHistoryCacheSize = (options ?? CodePathProfilerOptions.Default).WorstHistoryCacheSize;
+ ClassName = className ?? new StackFrame(1, false).GetMethod()?.DeclaringType?.FullName;
+ TimeProvider = options?.TimeProvider ?? TimeProvider.System;
+ }
+
+ public override InvocationProfiler StartMeasurement(
+ [CallerMemberName] string methodName = "",
+ object? argumentsExpressionProvider = null)
+ {
+ // NOTE:
+ // Double-checked locking pattern is used here to reduce locking overhead.
+ // For optimization of hot path, we don't use memory barrier on the first read.
+ if (!_methodCounters.TryGetValue(methodName, out var methodCounter))
+ {
+ lock(_lockToken)
+ {
+ var methodCounters = Volatile.Read(ref _methodCounters);
+ if (!methodCounters.TryGetValue(methodName, out methodCounter))
+ {
+ methodCounter = new(this, methodName);
+ _methodCounters = methodCounters.Add(methodName, methodCounter);
+ }
+ }
+ }
+ var invocation = methodCounter.StartMeasurement(argumentsExpressionProvider);
+ return invocation;
+ }
+
+ public override ImmutableDictionary CreateProfileReports()
+ {
+ var builder = ImmutableDictionary.CreateBuilder();
+ foreach (var kvp in _methodCounters)
+ {
+ var report = kvp.Value.CreateReport();
+ builder.Add(kvp.Key, report);
+ }
+ return builder.ToImmutable();
+ }
+ }
+}
diff --git a/src/PathBench/CodePathProfiler.cs b/src/PathBench/CodePathProfiler.cs
new file mode 100644
index 0000000..0ddfb7b
--- /dev/null
+++ b/src/PathBench/CodePathProfiler.cs
@@ -0,0 +1,53 @@
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+
+namespace PathBench;
+
+///
+/// Performance measurement tool for counting code paths.
+/// One instance of this type should be related to one type definition.
+///
+public abstract partial class CodePathProfiler
+{
+ /// The name of the start checkpoint.
+ public const string StartCheckpointName = "#";
+
+ /// The name of the end checkpoint.
+ public const string EndCheckpointName = "#";
+
+ /// The name of the class which this instance related to.
+ public abstract string? ClassName { get; }
+
+ /// The time provider used for measuring time.
+ public abstract TimeProvider TimeProvider { get; }
+
+ ///
+ /// Creates a new instance of .
+ ///
+ ///
+ ///
+ ///
+ public static CodePathProfiler Create(
+ string? className = null,
+ CodePathProfilerOptions? options = null) =>
+ new Impl_(className, options);
+
+
+ ///
+ /// Starts performance measurement of one invocation of the method.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public abstract InvocationProfiler StartMeasurement(
+ [CallerMemberName] string methodName = "",
+ object? argumentsExpressionProvider = null);
+
+
+ ///
+ /// Creates profile reports for all measured methods.
+ ///
+ ///
+ public abstract ImmutableDictionary CreateProfileReports();
+}
diff --git a/src/PathBench/CodePathProfilerOptions.cs b/src/PathBench/CodePathProfilerOptions.cs
new file mode 100644
index 0000000..1592940
--- /dev/null
+++ b/src/PathBench/CodePathProfilerOptions.cs
@@ -0,0 +1,48 @@
+namespace PathBench;
+
+
+///
+/// Provides configuration options for the code path profiler, including cache sizes and time provider settings.
+///
+public interface ICodePathProfilerOptions
+{
+ ///
+ /// Specifies the time provider used for measuring time.
+ ///
+ public TimeProvider TimeProvider { get; }
+
+ ///
+ /// Specifies the size of entries to retain in the recent history cache.
+ ///
+ public int RecentHistoryCacheSize { get; }
+
+ ///
+ /// Specifies the size of entries to retain in the worst history cache.
+ ///
+ public int WorstHistoryCacheSize { get; }
+}
+
+///
+/// Provides configuration options for the code path profiler, including cache sizes and time provider settings.
+///
+public class CodePathProfilerOptions
+ : ICodePathProfilerOptions
+{
+ private sealed class Default_ : ICodePathProfilerOptions
+ {
+ public TimeProvider TimeProvider => TimeProvider.System;
+ public int RecentHistoryCacheSize => 256;
+ public int WorstHistoryCacheSize => 256;
+ }
+ internal static ICodePathProfilerOptions Default { get; } = new Default_();
+
+ ///
+ public TimeProvider TimeProvider { get; set; } = Default.TimeProvider;
+
+ ///
+ public int RecentHistoryCacheSize { get; set; } = Default.RecentHistoryCacheSize;
+
+ ///
+ public int WorstHistoryCacheSize { get; set; } = Default.WorstHistoryCacheSize;
+
+}
\ No newline at end of file
diff --git a/src/PathBench/DataTypes.cs b/src/PathBench/DataTypes.cs
index 175d869..ac1b4b1 100644
--- a/src/PathBench/DataTypes.cs
+++ b/src/PathBench/DataTypes.cs
@@ -2,48 +2,129 @@
namespace PathBench;
+///
+/// Types of invocation measurement history cache.
+///
[Flags]
public enum HistoryType
{
+ ///
+ /// Recent invocation measurements.
+ ///
Recent,
+
+ ///
+ /// Worst-performance invocation measurements.
+ ///
Worst,
}
+///
+/// Represents metadata associated with a checkpoint operation.
+///
+///
+///
+public record class CheckpointMetadata(
+ string Name,
+ int SortKey);
-public record struct CodePathKey(
+///
+/// Key identifying a code path between two checkpoints.
+///
+///
+///
+public record struct CheckpointTransitionKey(
string StartCheckpoint,
- string EndCheckpoint);
+ string EndCheckpoint)
+{
+ ///
+ public override readonly string ToString() =>
+ $"[{StartCheckpoint} -> {EndCheckpoint}]";
+ ///
+ public override readonly int GetHashCode() =>
+ StartCheckpoint.GetHashCode() ^ EndCheckpoint.GetHashCode();
+}
-public record class InvocationSummary(
+///
+/// Summary of method performance measurements.
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+public record class MethodProfileReport(
string CounterName,
long TotalTimes,
- TimeSpan MeanDuration,
- TimeSpan StandardDeviationOfDuration,
- ImmutableDictionary CodePathSummaries,
- ImmutableDictionary> Histories
- );
-
+ PreciseDuration MeanDuration,
+ PreciseDuration StandardDeviationOfDuration,
+ PreciseDuration MaxDuration,
+ PreciseDuration MinDuration,
+ ImmutableDictionary FoundCheckpoints,
+ ImmutableDictionary CodePathSummaries,
+ ImmutableDictionary> Histories
+ )
+{
+ ///
+ public override string ToString()
+ {
+ var sw = new StringWriter();
+ MethodProfileReportFormatter.Simple.Format(
+ this,
+ sw);
+ return sw.ToString();
+ }
+}
-public record class CodePathSummary(
- CodePathKey Key,
+///
+/// Summary of performance measurements for a specific two checkpoint transition.
+///
+///
+///
+///
+///
+///
+///
+public record class CheckpointTransitionProfileReport(
+ CheckpointTransitionKey Key,
long TotalTimes,
- TimeSpan MeanDuration,
- TimeSpan StandardDeviationOfDuration);
+ PreciseDuration MeanDuration,
+ PreciseDuration StandardDeviationOfDuration,
+ PreciseDuration MaxDuration,
+ PreciseDuration MinDuration);
-
-public record class InvocationMeasurement(
+///
+/// Summary of one invocation measurement.
+///
+///
+///
+///
+///
+///
+///
+///
+public record class InvocationMeasurementReport(
string CounterName,
long InvocationId,
DateTimeOffset StartAt,
int ManagedThreadId,
string? ArgumentsExpression,
- TimeSpan Duration,
- ImmutableArray CodePathMeasurements);
-
+ PreciseDuration Duration,
+ ImmutableArray CodePathMeasurements);
-public record class CodePathMeasurement(
- CodePathKey Key,
+///
+/// Summary of one checkpoint transition measurement.
+///
+///
+///
+///
+public record class CheckpointTransitionMeasurementReport(
+ CheckpointTransitionKey Key,
string? Note,
- TimeSpan Duration);
+ PreciseDuration Duration);
diff --git a/src/PathBench/ExcludeFromCodeCoverageAttribute.cs b/src/PathBench/ExcludeFromCodeCoverageAttribute.cs
new file mode 100644
index 0000000..0761d82
--- /dev/null
+++ b/src/PathBench/ExcludeFromCodeCoverageAttribute.cs
@@ -0,0 +1,4 @@
+namespace PathBench;
+
+[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = false)]
+internal class ExcludeFromCodeCoverageAttribute : Attribute;
diff --git a/src/PathBench/InvocationCounter.cs b/src/PathBench/InvocationCounter.cs
deleted file mode 100644
index 736bccd..0000000
--- a/src/PathBench/InvocationCounter.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace PathBench;
-
-public abstract class InvocationCounter : IDisposable
-{
- public abstract void Dispose();
- public abstract void MarkCheckpoint(string name, object? noteProvider = null);
-}
diff --git a/src/PathBench/InvocationProfiler.cs b/src/PathBench/InvocationProfiler.cs
new file mode 100644
index 0000000..6e2d47f
--- /dev/null
+++ b/src/PathBench/InvocationProfiler.cs
@@ -0,0 +1,30 @@
+using System.Runtime.CompilerServices;
+
+namespace PathBench;
+
+///
+/// Provides an abstract base for profiling and recording invocation checkpoints and measurements within an operation.
+///
+///
+/// Use this class to track performance or timing information by marking checkpoints and creating measurements during the execution of an operation.
+/// Implementations may vary in how profiling data is collected and reported.
+///
+public abstract class InvocationProfiler : IDisposable
+{
+ ///
+ public abstract void Dispose();
+
+ ///
+ /// Marks a checkpoint in the invocation.
+ ///
+ ///
+ ///
+ ///
+ public abstract void MarkCheckpoint(string name, [CallerLineNumber] int sortKey = -1, object? noteProvider = null);
+
+ ///
+ /// Creates an invocation measurement summary.
+ ///
+ ///
+ internal protected abstract InvocationMeasurementReport CreateMeasurementReport();
+}
diff --git a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs
new file mode 100644
index 0000000..1962dcc
--- /dev/null
+++ b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs
@@ -0,0 +1,169 @@
+using System.Text;
+
+namespace PathBench;
+
+partial class MethodProfileReportFormatter
+{
+ ///
+ /// Gets the graphviz visualization style formatter with default settings.
+ ///
+ public static MethodProfileReportFormatter DefaultGraphvizStyle { get; } =
+ new GraphvizStyle_(GraphvizStyleFormatterOptions.Default);
+
+ ///
+ /// Creates a graphviz visualization style formatter with the specified options.
+ ///
+ ///
+ ///
+ public static MethodProfileReportFormatter CreateGraphvizStyle(
+ GraphvizStyleFormatterOptions? options = null) =>
+ new GraphvizStyle_(options ?? GraphvizStyleFormatterOptions.Default);
+
+ private sealed class GraphvizStyle_(IGraphvizStyleFormatterOptions options)
+ : MethodProfileReportFormatter
+ {
+ private readonly string _fontName = options.FontName;
+ private readonly TimeScale _timeScale = options.TimeScale switch
+ {
+ TimeScale.Auto or
+ TimeScale.Nanoseconds or
+ TimeScale.Microseconds or
+ TimeScale.Milliseconds => options.TimeScale,
+ _ => throw new ArgumentOutOfRangeException(nameof(options.TimeScale)),
+ };
+
+ public override void Format(MethodProfileReport report, TextWriter writer)
+ {
+ var adjustedTimeScale =
+ _timeScale == TimeScale.Auto
+ ? TimeScale.GetBestTimeScaleFor(report.CodePathSummaries.Values.Select(static x => x.MeanDuration))
+ : _timeScale;
+
+ writer.WriteLine($$"""
+ digraph {{SanitizeIdentifier(report.CounterName)}} {
+ graph [
+ fontname = "{{_fontName}}",
+ label = "{{SanitizeLabel(report.CounterName)}}",
+ ];
+ node [
+ fontname = "{{_fontName}}",
+ shape = "box",
+ ];
+ edge [
+ fontname = "{{_fontName}}",
+ ];
+ """);
+
+ var checkpointIdentifiers = report.FoundCheckpoints.Values
+ .ToDictionary(static c => c.Name, static c => SanitizeIdentifier(c.Name));
+ foreach(var checkpoint in report.FoundCheckpoints.Values.OrderBy(static c => c.SortKey))
+ {
+ var identifier = checkpointIdentifiers[checkpoint.Name];
+ var label = SanitizeLabel(checkpoint.Name);
+ writer.WriteLine($$"""
+ {{identifier}} [label = "{{label}}"]
+ """);
+ }
+ foreach (var (key, transition) in report.CodePathSummaries)
+ {
+ var startIdentifier = checkpointIdentifiers[key.StartCheckpoint];
+ var endIdentifier = checkpointIdentifiers[key.EndCheckpoint];
+ writer.WriteLine("""
+ {0} -> {1} [label = "{2} times\n{3}"]
+ """,
+ startIdentifier,
+ endIdentifier,
+ transition.TotalTimes,
+ transition.MeanDuration.ToString(adjustedTimeScale));
+ }
+ writer.WriteLine("""
+ }
+ """);
+ }
+
+
+ public static string SanitizeIdentifier(string name)
+ {
+ var sb = new StringBuilder();
+ sb.Append("__");
+ foreach (var c in name.EnumerateRunes())
+ {
+ switch (c.Value)
+ {
+ case >= 0x30 and < 0x3A:
+ case >= 0x41 and < 0x5B:
+ case >= 0x61 and < 0x7B:
+ sb.Append(c);
+ continue;
+ default:
+ sb.Append($"_u{(uint)c.Value:X04}_");
+ continue;
+ }
+ }
+ return sb.ToString();
+ }
+
+ public static string SanitizeLabel(string name)
+ {
+ var sb = new StringBuilder(name.Length * 2);
+ foreach (var c in name.EnumerateRunes())
+ {
+ switch (c.Value)
+ {
+ case '\n':
+ sb.Append(@"\n");
+ break;
+ case '\t':
+ sb.Append(@"\t");
+ break;
+ case '\\':
+ sb.Append(@"\\");
+ break;
+ case '"':
+ sb.Append("\\\"");
+ break;
+ default:
+ sb.Append(c);
+ break;
+ }
+ }
+ return sb.ToString();
+ }
+ }
+}
+
+
+///
+/// Provides configuration options for formatting styles in Graphviz output.
+///
+public interface IGraphvizStyleFormatterOptions
+{
+ ///
+ /// Specifies the font name to use in the graphviz output.
+ ///
+ public string FontName { get; }
+
+ ///
+ /// Specifies the time scale to display measurement results.
+ ///
+ public TimeScale TimeScale { get; }
+}
+
+///
+/// Provides configuration options for formatting styles in Graphviz output.
+///
+public class GraphvizStyleFormatterOptions : IGraphvizStyleFormatterOptions
+{
+ private sealed class Default_ : IGraphvizStyleFormatterOptions
+ {
+ public string FontName => "Monospace";
+ public TimeScale TimeScale => TimeScale.Auto;
+ }
+ internal static IGraphvizStyleFormatterOptions Default { get; } = new Default_();
+
+ ///
+ public string FontName { get; set; } = Default.FontName;
+
+ ///
+ public TimeScale TimeScale { get; set; } = Default.TimeScale;
+}
\ No newline at end of file
diff --git a/src/PathBench/MethodProfileReportFormatter.Simple.cs b/src/PathBench/MethodProfileReportFormatter.Simple.cs
new file mode 100644
index 0000000..6439b77
--- /dev/null
+++ b/src/PathBench/MethodProfileReportFormatter.Simple.cs
@@ -0,0 +1,38 @@
+
+
+namespace PathBench;
+
+partial class MethodProfileReportFormatter
+{
+ ///
+ /// Gets the simple formatter.
+ ///
+ public static MethodProfileReportFormatter Simple { get; } = new Default_();
+
+ private sealed class Default_ : MethodProfileReportFormatter
+ {
+ public override void Format(MethodProfileReport report, TextWriter writer)
+ {
+ var timeSpans = report.CodePathSummaries.Values.Select(static x => x.MeanDuration);
+ var adjustedTimeScale = TimeScale.GetBestTimeScaleFor(timeSpans);
+
+ writer.WriteLine($"<`{report.CounterName}` profile report>");
+ writer.WriteLine($" total invocation : {report.TotalTimes}");
+ writer.WriteLine($" mean duration : {getDurationText(adjustedTimeScale, report.MeanDuration, report.StandardDeviationOfDuration)}");
+ writer.WriteLine($" code path summaries:");
+ foreach(var pathSummary in report.CodePathSummaries)
+ {
+ writer.WriteLine($" {pathSummary.Key}:");
+ writer.WriteLine($" total invocation: {pathSummary.Value.TotalTimes}");
+ writer.WriteLine($" mean duration : {getDurationText(adjustedTimeScale, pathSummary.Value.MeanDuration, pathSummary.Value.StandardDeviationOfDuration)}");
+ }
+
+ string getDurationText(TimeScale scale, PreciseDuration meanDuration, PreciseDuration? standardDeviationOfDuration)
+ {
+ var mean = meanDuration.ToString(scale);
+ var sd = standardDeviationOfDuration.HasValue ? standardDeviationOfDuration.Value.ToString(scale) : "N/A";
+ return $"{mean} (SD = {sd})";
+ }
+ }
+ }
+}
diff --git a/src/PathBench/MethodProfileReportFormatter.cs b/src/PathBench/MethodProfileReportFormatter.cs
new file mode 100644
index 0000000..6dc6cbc
--- /dev/null
+++ b/src/PathBench/MethodProfileReportFormatter.cs
@@ -0,0 +1,14 @@
+namespace PathBench;
+
+///
+/// Abstract base class to format instances.
+///
+public abstract partial class MethodProfileReportFormatter
+{
+ ///
+ /// When overridden in a derived class, formats the specified report and writes the output to the specified writer.
+ ///
+ ///
+ ///
+ public abstract void Format(MethodProfileReport report, TextWriter writer);
+}
diff --git a/src/PathBench/PathBench.csproj b/src/PathBench/PathBench.csproj
index b46e914..3564a39 100644
--- a/src/PathBench/PathBench.csproj
+++ b/src/PathBench/PathBench.csproj
@@ -5,6 +5,7 @@
net10.0
enable
enable
+ True
diff --git a/src/PathBench/PreciseDuration.cs b/src/PathBench/PreciseDuration.cs
new file mode 100644
index 0000000..135be56
--- /dev/null
+++ b/src/PathBench/PreciseDuration.cs
@@ -0,0 +1,460 @@
+using System.Numerics;
+using System.Text.RegularExpressions;
+
+namespace PathBench;
+
+///
+/// Provides pico-order precise duration representation.
+///
+public readonly partial struct PreciseDuration(long ticks)
+ : IEquatable
+ , IComparable
+ , IComparisonOperators
+ , IAdditionOperators
+ , ISubtractionOperators
+ , IFormattable
+{
+ ///
+ /// Represents a Not-a-Number (NaN) value of ticks in .
+ ///
+ public const long NaNTicks = long.MinValue;
+
+ ///
+ /// Represents positive infinity value of ticks in .
+ ///
+ public const long PositiveInfinityTicks = long.MaxValue;
+
+ ///
+ /// Represents negative infinity value of ticks in .
+ ///
+ public const long NegativeInfinityTicks = long.MinValue + 1;
+
+ ///
+ /// Represents the minimum finite value of ticks in .
+ ///
+ public const long MaxTicks = long.MaxValue - 1;
+
+ ///
+ /// Represents the maximum finite value of ticks in .
+ ///
+ public const long MinTicks = long.MinValue + 2;
+
+ ///
+ /// Ticks per second (1e+12).
+ ///
+ public const long TicksPerSecond = 1_000_000_000_000;
+
+ ///
+ /// Ticks per millisecond (1e+9).
+ ///
+ public const long TicksPerMillisecond = TicksPerSecond / 1000;
+
+ ///
+ /// Ticks per microsecond (1e+6).
+ ///
+ public const long TicksPerMicrosecond = TicksPerSecond / 1_000_000;
+
+ ///
+ /// Ticks per nanosecond (1e+3).
+ ///
+ public const long TicksPerNanosecond = TicksPerSecond / 1_000_000_000;
+
+ ///
+ /// Represents a zero duration.
+ ///
+ public static readonly PreciseDuration Zero = new(0);
+
+ ///
+ /// Represents a Not-a-Number (NaN) value of .
+ ///
+ public static readonly PreciseDuration NaN = new(NaNTicks);
+
+ ///
+ /// Represents positive infinity value of .
+ ///
+ public static readonly PreciseDuration PositiveInfinity = new(PositiveInfinityTicks);
+
+ ///
+ /// Represents negative infinity value of .
+ ///
+ public static readonly PreciseDuration NegativeInfinity = new(NegativeInfinityTicks);
+
+ ///
+ /// Represents the maximum finite value of .
+ ///
+ public static readonly PreciseDuration MaxValue = new(MaxTicks);
+
+ ///
+ /// Represents the minimum finite value of .
+ ///
+ public static readonly PreciseDuration MinValue = new(MinTicks);
+
+ ///
+ /// Gets the number of ticks that represent the value of the current TimeSpan structure.
+ ///
+ public long Ticks { get; } = ticks;
+
+ ///
+ /// Gets a value indicating whether this instance is not a number (NaN).
+ ///
+ public bool IsNaN => Ticks == NaNTicks;
+
+ ///
+ /// Gets a value indicating whether this instance represents positive infinity.
+ ///
+ public bool IsPositiveInfinity => Ticks == PositiveInfinityTicks;
+
+ ///
+ /// Gets a value indicating whether this instance represents negative infinity.
+ ///
+ public bool IsNegativeInfinity => Ticks == NegativeInfinityTicks;
+
+ ///
+ /// Gets a value indicating whether this instance represents infinity (positive or negative).
+ ///
+ public bool IsInfinity => IsPositiveInfinity || IsNegativeInfinity;
+
+ private double DoubleTicks =>
+ Ticks switch
+ {
+ NaNTicks => double.NaN,
+ PositiveInfinityTicks => double.PositiveInfinity,
+ NegativeInfinityTicks => double.NegativeInfinity,
+ _ => Ticks,
+ };
+
+ ///
+ /// Gets the total number of seconds represented by this instance.
+ ///
+ public double TotalSeconds => DoubleTicks / TicksPerSecond;
+
+ ///
+ /// Gets the total number of milliseconds represented by this instance.
+ ///
+ public double TotalMilliseconds => DoubleTicks / TicksPerMillisecond;
+
+ ///
+ /// Gets the total number of microseconds represented by this instance.
+ ///
+ public double TotalMicroseconds => DoubleTicks / TicksPerMicrosecond;
+
+ ///
+ /// Gets the total number of nanoseconds represented by this instance.
+ ///
+ public double TotalNanoseconds => DoubleTicks / TicksPerNanosecond;
+
+ private static long FromScaledValue(double value, long scale)
+ {
+ switch (value)
+ {
+ case double.NaN:
+ return NaNTicks;
+ case double.PositiveInfinity:
+ return PositiveInfinityTicks;
+ case double.NegativeInfinity:
+ return NegativeInfinityTicks;
+ default:
+ break;
+ }
+ value *= scale;
+ if(value > MaxTicks)
+ {
+ return PositiveInfinityTicks;
+ }
+ if(value < MinTicks)
+ {
+ return NegativeInfinityTicks;
+ }
+ return (long)value;
+ }
+
+ ///
+ /// Creates a new representing the specified number of seconds.
+ ///
+ ///
+ ///
+ public static PreciseDuration FromSeconds(double seconds) =>
+ new(FromScaledValue(seconds, TicksPerSecond));
+
+ ///
+ /// Creates a new representing the specified number of milliseconds.
+ ///
+ ///
+ ///
+ public static PreciseDuration FromMilliseconds(double milliseconds) =>
+ new(FromScaledValue(milliseconds, TicksPerMillisecond));
+
+ ///
+ /// Creates a new representing the specified number of microseconds.
+ ///
+ ///
+ ///
+ public static PreciseDuration FromMicroseconds(double microseconds) =>
+ new(FromScaledValue(microseconds, TicksPerMicrosecond));
+
+ ///
+ /// Creates a new representing the specified number of nanoseconds.
+ ///
+ ///
+ ///
+ public static PreciseDuration FromNanoseconds(double nanoseconds) =>
+ new(FromScaledValue(nanoseconds, TicksPerNanosecond));
+
+ #region ToString overloads
+
+ [GeneratedRegex(@"^(?.+?)(?nsec|usec|msec|sec|)$")]
+ private static partial Regex GetFormatMatcher();
+ private static readonly Regex _FormatMatcher = GetFormatMatcher();
+
+ ///
+ [ExcludeFromCodeCoverage]
+ public override string ToString() => ToString(TimeScale.Auto);
+
+ ///
+ [ExcludeFromCodeCoverage]
+ public string ToString(string? format) =>
+ ToString(format, System.Globalization.CultureInfo.CurrentCulture);
+
+ ///
+ ///
+ ///
+ /// The format string.
+ /// This contains real number format style and unit suffix.
+ /// Available units are sec, msec, usec, nsec, or empty.
+ /// for 1.234567 seconds, e.g.
+ ///
+ /// - G2sec: 1.23 sec
+ /// - G2msec: 1234.57 msec
+ /// - e1sec: 1.2e+0 sec
+ /// - e1msec: 1.2e+3 msec
+ /// - 0.0sec: 1.2 sec
+ /// - 0.0msec: 1234.5 msec
+ ///
+ ///
+ public string ToString(string? format, IFormatProvider? formatProvider)
+ {
+ if(format is null)
+ {
+ return ToString("", TimeScale.Auto, formatProvider);
+ }
+ var match = _FormatMatcher.Match(format);
+ var realFormat = match.Groups["real"].Value;
+ var unit = match.Groups["unit"].Value switch
+ {
+ "sec" => TimeScale.Seconds,
+ "msec" => TimeScale.Milliseconds,
+ "usec" => TimeScale.Microseconds,
+ "nsec" => TimeScale.Nanoseconds,
+ _ => TimeScale.Auto,
+ };
+ return ToString(realFormat, unit, formatProvider);
+ }
+
+ ///
+ [ExcludeFromCodeCoverage]
+ public string ToString(TimeScale timeScale) =>
+ ToString("", timeScale);
+
+ ///
+ [ExcludeFromCodeCoverage]
+ public string ToString(string? realPartFormat, TimeScale timeScale) =>
+ ToString(realPartFormat, timeScale, System.Globalization.CultureInfo.CurrentCulture);
+
+ ///
+ /// Gets the string representation of the given time span in the specified time scale.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public string ToString(string? realPartFormat, TimeScale timeScale, IFormatProvider? formatProvider) =>
+ timeScale switch
+ {
+ TimeScale.Nanoseconds => string.Format(formatProvider, $"{{0:{realPartFormat}}} nsec", TotalNanoseconds),
+ TimeScale.Microseconds => string.Format(formatProvider, $"{{0:{realPartFormat}}} usec", TotalMicroseconds),
+ TimeScale.Milliseconds => string.Format(formatProvider, $"{{0:{realPartFormat}}} msec", TotalMilliseconds),
+ TimeScale.Seconds => string.Format(formatProvider, $"{{0:{realPartFormat}}} sec", TotalSeconds),
+ TimeScale.Auto => ToString(realPartFormat, TimeScale.GetBestTimeScaleFor(this), formatProvider),
+ _ => throw new FormatException("Invalid time scale."),
+ };
+
+ #endregion ToString overloads
+
+ ///
+ [ExcludeFromCodeCoverage]
+ public override int GetHashCode() =>
+ Ticks.GetHashCode();
+
+ ///
+ public override bool Equals(object? other) =>
+ other switch
+ {
+ PreciseDuration pd => this == pd,
+ TimeSpan ts => Ticks / TicksPerMicrosecond == ts.Ticks / TimeSpan.TicksPerMicrosecond,
+ _ => false,
+ };
+
+ ///
+ public bool Equals(PreciseDuration other) => this == other;
+
+ ///
+ public int CompareTo(PreciseDuration other) => Ticks.CompareTo(other.Ticks);
+
+ ///
+ public static PreciseDuration operator +(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return new(NaNTicks);
+ }
+ if (x.Ticks == PositiveInfinityTicks && y.Ticks == NegativeInfinityTicks)
+ {
+ return new(NaNTicks);
+ }
+ if (x.Ticks == NegativeInfinityTicks && y.Ticks == PositiveInfinityTicks)
+ {
+ return new(NaNTicks);
+ }
+ if (x.Ticks == PositiveInfinityTicks || y.Ticks == PositiveInfinityTicks)
+ {
+ return new(PositiveInfinityTicks);
+ }
+ if (x.Ticks == NegativeInfinityTicks || y.Ticks == NegativeInfinityTicks)
+ {
+ return new(NegativeInfinityTicks);
+ }
+ if (x.Ticks > 0 && y.Ticks > MaxTicks - x.Ticks)
+ {
+ return new(PositiveInfinityTicks);
+ }
+ if (x.Ticks < 0 && y.Ticks < MinTicks - x.Ticks)
+ {
+ return new(NegativeInfinityTicks);
+ }
+ return new(x.Ticks + y.Ticks);
+ }
+
+ ///
+ public static PreciseDuration operator -(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return new(NaNTicks);
+ }
+ if (x.Ticks == PositiveInfinityTicks && y.Ticks == PositiveInfinityTicks)
+ {
+ return new(NaNTicks);
+ }
+ if (x.Ticks == NegativeInfinityTicks && y.Ticks == NegativeInfinityTicks)
+ {
+ return new(NaNTicks);
+ }
+ if (x.Ticks == PositiveInfinityTicks || y.Ticks == NegativeInfinityTicks)
+ {
+ return new(PositiveInfinityTicks);
+ }
+ if (x.Ticks == NegativeInfinityTicks || y.Ticks == PositiveInfinityTicks)
+ {
+ return new(NegativeInfinityTicks);
+ }
+ if (y.Ticks > 0 && x.Ticks < MinTicks + y.Ticks)
+ {
+ return new(NegativeInfinityTicks);
+ }
+ if (y.Ticks < 0 && x.Ticks > MaxTicks + y.Ticks)
+ {
+ return new(PositiveInfinityTicks);
+ }
+ return new(x.Ticks - y.Ticks);
+ }
+
+ ///
+ public static bool operator >(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return false;
+ }
+ return x.Ticks > y.Ticks;
+ }
+
+ ///
+ public static bool operator >=(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return false;
+ }
+ return x.Ticks >= y.Ticks;
+ }
+
+ ///
+ public static bool operator <(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return false;
+ }
+ return x.Ticks < y.Ticks;
+ }
+
+ ///
+ public static bool operator <=(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return false;
+ }
+ return x.Ticks <= y.Ticks;
+ }
+
+ ///
+ public static bool operator ==(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return false;
+ }
+ return x.Ticks == y.Ticks;
+ }
+
+ ///
+ public static bool operator !=(PreciseDuration x, PreciseDuration y)
+ {
+ if (x.Ticks == NaNTicks || y.Ticks == NaNTicks)
+ {
+ return false;
+ }
+ return x.Ticks != y.Ticks;
+ }
+
+ ///
+ /// Defines an explicit conversion of a to a .
+ ///
+ ///
+ ///
+ public static explicit operator TimeSpan(PreciseDuration duration)
+ {
+ if (duration.IsNaN || duration.IsInfinity)
+ {
+ throw new InvalidCastException("Cannot convert NaN or Infinity to TimeSpan.");
+ }
+ return TimeSpan.FromTicks(duration.Ticks / (TicksPerMicrosecond / TimeSpan.TicksPerMicrosecond));
+ }
+
+ ///
+ /// Defines an explicit conversion of a to a .
+ ///
+ ///
+ ///
+ public static explicit operator PreciseDuration(TimeSpan timeSpan)
+ {
+ if (timeSpan.Ticks > MaxTicks / (TicksPerMicrosecond / TimeSpan.TicksPerMicrosecond) ||
+ timeSpan.Ticks < MinTicks / (TicksPerMicrosecond / TimeSpan.TicksPerMicrosecond))
+ {
+ throw new OverflowException("TimeSpan value is too large or too small to convert to PreciseDuration.");
+ }
+ return new(timeSpan.Ticks * (TicksPerMicrosecond / TimeSpan.TicksPerMicrosecond));
+ }
+}
diff --git a/src/PathBench/TimeScale.cs b/src/PathBench/TimeScale.cs
new file mode 100644
index 0000000..8380c17
--- /dev/null
+++ b/src/PathBench/TimeScale.cs
@@ -0,0 +1,81 @@
+namespace PathBench;
+
+///
+/// Specifies the time scale to display measurement results.
+///
+public enum TimeScale
+{
+ /// Automatically selects the most appropriate time scale.
+ Auto,
+
+ /// Displays measurement results in nanoseconds.
+ Nanoseconds,
+
+ /// Gets or sets the duration in microseconds.
+ Microseconds,
+
+ /// Gets or sets the duration in milliseconds.
+ Milliseconds,
+
+ /// Gets or sets the duration in seconds.
+ Seconds,
+}
+
+
+///
+/// Provides extension methods for .
+///
+public static class TimeScaleExtensions
+{
+ extension(TimeScale timeScale)
+ {
+ ///
+ /// Gets the best time scale for the given time spans.
+ ///
+ ///
+ ///
+ ///
+ /// The best time scale is determined by the smallest time span in the collection.
+ ///
+ /// - If minimum time span is greater than sub-msec(>= 0.1msec), will be selected.
+ /// - If minimum time span is greater than sub-usec(>= 0.1usec), will be selected.
+ /// - Otherwise, will be selected.
+ ///
+ ///
+ public static TimeScale GetBestTimeScaleFor(IEnumerable timeSpans) =>
+ GetBestTimeScaleFor(timeSpans.Min());
+
+ ///
+ /// Gets the best time scale for the given duration.
+ ///
+ ///
+ ///
+ ///
+ /// The best time scale is determined by the smallest time span in the collection.
+ ///
+ /// - If duration is greater than sub-msec(>= 0.1msec), will be selected.
+ /// - If duration is greater than sub-usec(>= 0.1usec), will be selected.
+ /// - Otherwise, will be selected.
+ ///
+ ///
+ public static TimeScale GetBestTimeScaleFor(PreciseDuration duration)
+ {
+ if (duration.TotalSeconds >= 0.1)
+ {
+ return TimeScale.Seconds;
+ }
+ if (duration.TotalMilliseconds >= 0.1)
+ {
+ return TimeScale.Milliseconds;
+ }
+ else if (duration.TotalMicroseconds >= 0.1)
+ {
+ return TimeScale.Microseconds;
+ }
+ else
+ {
+ return TimeScale.Nanoseconds;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PathBench/WelfordStatistics.cs b/src/PathBench/WelfordStatistics.cs
new file mode 100644
index 0000000..0a4701e
--- /dev/null
+++ b/src/PathBench/WelfordStatistics.cs
@@ -0,0 +1,33 @@
+namespace PathBench;
+
+internal struct WelfordStatistics()
+{
+ private long _n;
+ private double _mu;
+ private double _ss;
+ private double _max = double.NegativeInfinity;
+ private double _min = double.PositiveInfinity;
+
+ public void IncrementResult(double x)
+ {
+ var n = _n;
+ var mu = _mu;
+ var ss = _ss;
+ _n = n + 1;
+ _mu = mu + (x - mu) / _n;
+ _ss = ss + (x - mu) * (x - _mu);
+ _max = Math.Max(_max, x);
+ _min = Math.Min(_min, x);
+ }
+
+ public readonly void Deconstruct(out long n, out double mean, out double sd, out double max, out double min)
+ {
+ n = _n;
+ mean = _mu;
+ sd = n > 1
+ ? Math.Sqrt(_ss / (_n - 1))
+ : double.NaN;
+ max = _max;
+ min = _min;
+ }
+}
diff --git a/tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext01.cs b/tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext01.cs
new file mode 100644
index 0000000..b63d664
--- /dev/null
+++ b/tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext01.cs
@@ -0,0 +1,43 @@
+using System.Runtime.CompilerServices;
+using BenchmarkDotNet.Attributes;
+
+namespace PathBench.Benchmark;
+
+public class CodePathProfilerBenchmarkContext01
+{
+ [Benchmark]
+ public void Profile()
+ {
+ var codePathProfiler = CodePathProfiler.Create();
+ ProfileTarget(codePathProfiler);
+ }
+
+ [Benchmark]
+ public void Empty()
+ {
+ var codePathProfiler = CodePathProfiler.Empty;
+ ProfileTarget(codePathProfiler);
+ }
+
+ [Benchmark]
+ public void NegativeControl()
+ {
+ NegativeControlTarget();
+ }
+
+ private static void ProfileTarget(CodePathProfiler codePathProfiler)
+ {
+ using var profiler = codePathProfiler.StartMeasurement();
+ DisturbOptimize(0);
+ }
+
+ private static void NegativeControlTarget()
+ {
+ DisturbOptimize(0);
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static void DisturbOptimize(int i)
+ {
+ }
+}
diff --git a/tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext02.cs b/tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext02.cs
new file mode 100644
index 0000000..2479b90
--- /dev/null
+++ b/tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext02.cs
@@ -0,0 +1,68 @@
+using System.Runtime.CompilerServices;
+using BenchmarkDotNet.Attributes;
+
+namespace PathBench.Benchmark;
+
+public class CodePathProfilerBenchmarkContext02
+{
+ private const int _iterationCount = 1000;
+
+ [Benchmark]
+ public void Profile()
+ {
+ var codePathProfiler = CodePathProfiler.Create();
+ ProfileTarget(codePathProfiler);
+ }
+
+ [Benchmark]
+ public void Empty()
+ {
+ var codePathProfiler = CodePathProfiler.Empty;
+ ProfileTarget(codePathProfiler);
+ }
+
+ [Benchmark]
+ public void NegativeControl()
+ {
+ NegativeControlTarget();
+ }
+
+ private static void ProfileTarget(CodePathProfiler codePathProfiler)
+ {
+ using var profiler = codePathProfiler.StartMeasurement();
+ for (var i = 0; i < _iterationCount; ++i)
+ {
+ profiler.MarkCheckpoint("LoopBegin", i);
+ if (i % 2 == 0)
+ {
+ profiler.MarkCheckpoint("EvenBranch");
+ DisturbOptimize(0);
+ }
+ else
+ {
+ profiler.MarkCheckpoint("OddBranch");
+ DisturbOptimize(1);
+ }
+ }
+ }
+
+ private static void NegativeControlTarget()
+ {
+ for (var i = 0; i < _iterationCount; ++i)
+ {
+ if (i % 2 == 0)
+ {
+ DisturbOptimize(0);
+ }
+ else
+ {
+ DisturbOptimize(1);
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static void DisturbOptimize(int i)
+ {
+ }
+}
diff --git a/tests/PathBench.Benchmark/PathBench.Benchmark.csproj b/tests/PathBench.Benchmark/PathBench.Benchmark.csproj
new file mode 100644
index 0000000..a8a0c94
--- /dev/null
+++ b/tests/PathBench.Benchmark/PathBench.Benchmark.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/PathBench.Benchmark/Program.cs b/tests/PathBench.Benchmark/Program.cs
new file mode 100644
index 0000000..e9b1b4e
--- /dev/null
+++ b/tests/PathBench.Benchmark/Program.cs
@@ -0,0 +1,5 @@
+// See https://aka.ms/new-console-template for more information
+using BenchmarkDotNet.Running;
+using PathBench.Benchmark;
+
+BenchmarkRunner.Run();
diff --git a/tests/PathBench.Sample/PathBench.Sample.csproj b/tests/PathBench.Sample/PathBench.Sample.csproj
index d20809e..8f3af43 100644
--- a/tests/PathBench.Sample/PathBench.Sample.csproj
+++ b/tests/PathBench.Sample/PathBench.Sample.csproj
@@ -7,4 +7,8 @@
enable
+
+
+
+
diff --git a/tests/PathBench.Sample/Program.cs b/tests/PathBench.Sample/Program.cs
index 83fa4f4..14c4b7c 100644
--- a/tests/PathBench.Sample/Program.cs
+++ b/tests/PathBench.Sample/Program.cs
@@ -1,2 +1,63 @@
-// See https://aka.ms/new-console-template for more information
-Console.WriteLine("Hello, World!");
+using PathBench;
+
+invokeTest();
+
+static void invokeTest()
+{
+ for (var i = 0; i < 1000; ++i)
+ {
+ SampleClass.SimulatedWork(i);
+ }
+
+ var reports = SampleClass.Profiler.CreateProfileReports();
+ var sw = new StringWriter();
+ MethodProfileReportFormatter.DefaultGraphvizStyle.Format(
+ reports[nameof(SampleClass.SimulatedWork)],
+ writer: sw);
+ Console.WriteLine(sw.ToString());
+}
+
+static class SampleClass
+{
+ public static readonly CodePathProfiler Profiler = CodePathProfiler.Create();
+
+ public static void SimulatedWork(int seed)
+ {
+ using var counter = Profiler.StartMeasurement(argumentsExpressionProvider: $"seed={seed}");
+ if (seed % 2 == 0)
+ {
+ counter.MarkCheckpoint("EvenSeed");
+ Wait(2);
+ }
+ else
+ {
+ counter.MarkCheckpoint("OddSeed");
+ Wait(0);
+ }
+ for (var i = 0; i < seed; ++i)
+ {
+ counter.MarkCheckpoint("LoopBegin", i);
+ if (seed % 3 == 0)
+ {
+ counter.MarkCheckpoint("DivisibleBy3");
+ Wait(3);
+ }
+ if (seed % 5 == 0)
+ {
+ counter.MarkCheckpoint("DivisibleBy5");
+ Wait(5);
+ }
+ if (seed % 7 == 0)
+ {
+ counter.MarkCheckpoint("DivisibleBy7");
+ Wait(7);
+ }
+ counter.MarkCheckpoint("LoopEnd");
+ }
+ }
+
+ private static void Wait(int value)
+ {
+ Thread.SpinWait(value * 100);
+ }
+}
\ No newline at end of file
diff --git a/tests/PathBench.Test/CodePathProfilerTest.MultiThreading.cs b/tests/PathBench.Test/CodePathProfilerTest.MultiThreading.cs
new file mode 100644
index 0000000..f48ac30
--- /dev/null
+++ b/tests/PathBench.Test/CodePathProfilerTest.MultiThreading.cs
@@ -0,0 +1,92 @@
+using System.Runtime.CompilerServices;
+
+namespace PathBench.Test;
+
+public partial class CodePathProfilerTest
+{
+ [Theory, MemberData(nameof(Profile01Data))]
+ public void Profile01MultiThreading(Profile01TimeSet[] timeSet)
+ {
+ if(timeSet.Length == 0)
+ {
+ return;
+ }
+
+ var timeProvider = new FakeTimeProvider();
+ var codePathProfiler = CodePathProfiler.Create("SampleClassName", new() { TimeProvider = timeProvider, });
+ var threads = new Thread[timeSet.Length];
+ for (int i = 0; i < threads.Length; i++)
+ {
+ var time = timeSet[i];
+ threads[i] = new Thread(() =>
+ {
+ timeProvider.TimestampMicroseconds = time.X0_us;
+ var enumerator = TestTarget.ProfileTarget(codePathProfiler).GetEnumerator();
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X1_us;
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X2_us;
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X3_us;
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X4_us;
+ enumerator.MoveNext();
+ });
+ }
+ for (int i = 0; i < threads.Length; i++)
+ {
+ threads[i].Start();
+ }
+ for (int i = 0; i < threads.Length; i++)
+ {
+ threads[i].Join();
+ }
+ var statWhole = TestHelpers.CalculateMeanAndSD(timeSet.Select(static x => (x.X4_us - x.X0_us) / 1_000_000.0));
+ var statSection_s_1 = TestHelpers.CalculateMeanAndSD(timeSet.Select(static x => (x.X1_us - x.X0_us) / 1_000_000.0));
+ var statSection_1_2 = TestHelpers.CalculateMeanAndSD(timeSet.Select(static x => (x.X2_us - x.X1_us) / 1_000_000.0));
+ var statSection_2_3 = TestHelpers.CalculateMeanAndSD(timeSet.Select(static x => (x.X3_us - x.X2_us) / 1_000_000.0));
+ var statSection_3_e = TestHelpers.CalculateMeanAndSD(timeSet.Select(static x => (x.X4_us - x.X3_us) / 1_000_000.0));
+ var reports = codePathProfiler.CreateProfileReports();
+ Assert.True(reports.TryGetValue(nameof(TestTarget.ProfileTarget), out var report));
+ Assert.Equal($"SampleClassName.{nameof(TestTarget.ProfileTarget)}", report.CounterName);
+ Assert.Equal(statWhole.time, report.TotalTimes);
+ Assert.Equal(statWhole.mean, report.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statWhole.sd, report.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.True(report.CodePathSummaries.TryGetValue(new(CodePathProfiler.StartCheckpointName, "checkpoint1"), out var trn1));
+ Assert.True(report.CodePathSummaries.TryGetValue(new("checkpoint1", "checkpoint2"), out var trn2));
+ Assert.True(report.CodePathSummaries.TryGetValue(new("checkpoint2", "checkpoint3"), out var trn3));
+ Assert.True(report.CodePathSummaries.TryGetValue(new("checkpoint3", CodePathProfiler.EndCheckpointName), out var trn4));
+ Assert.Equal(new(CodePathProfiler.StartCheckpointName, "checkpoint1"), trn1.Key);
+ Assert.Equal(new("checkpoint1", "checkpoint2"), trn2.Key);
+ Assert.Equal(new("checkpoint2", "checkpoint3"), trn3.Key);
+ Assert.Equal(new("checkpoint3", CodePathProfiler.EndCheckpointName), trn4.Key);
+ Assert.Equal(statSection_s_1.time, trn1.TotalTimes);
+ Assert.Equal(statSection_1_2.time, trn2.TotalTimes);
+ Assert.Equal(statSection_2_3.time, trn3.TotalTimes);
+ Assert.Equal(statSection_3_e.time, trn4.TotalTimes);
+ Assert.Equal(statSection_s_1.mean, trn1.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_1_2.mean, trn2.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_2_3.mean, trn3.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_3_e.mean, trn4.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_s_1.sd, trn1.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.Equal(statSection_1_2.sd, trn2.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.Equal(statSection_2_3.sd, trn3.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.Equal(statSection_3_e.sd, trn4.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ }
+}
+
+file static class TestTarget
+{
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static IEnumerable ProfileTarget(CodePathProfiler codePathProfiler)
+ {
+ using var profiler = codePathProfiler.StartMeasurement();
+ yield return default;
+ profiler.MarkCheckpoint("checkpoint1");
+ yield return default;
+ profiler.MarkCheckpoint("checkpoint2");
+ yield return default;
+ profiler.MarkCheckpoint("checkpoint3");
+ yield return default;
+ }
+}
diff --git a/tests/PathBench.Test/CodePathProfilerTest.cs b/tests/PathBench.Test/CodePathProfilerTest.cs
new file mode 100644
index 0000000..ea8efb0
--- /dev/null
+++ b/tests/PathBench.Test/CodePathProfilerTest.cs
@@ -0,0 +1,113 @@
+using System.Runtime.CompilerServices;
+
+namespace PathBench.Test;
+
+public partial class CodePathProfilerTest
+{
+ private const double _meanTolerance = 1e-6;
+ private const double _sdTolerance = 1e-4;
+
+ public record Profile01TimeSet(long X0_us, long X1_us, long X2_us, long X3_us, long X4_us);
+
+ public static TheoryData Profile01Data() =>
+ [
+ [],
+ [
+ new(0, 0, 0, 0, 0),
+ ],
+ [
+ new(0, 1000, 2000, 3000, 4000),
+ new(0, 1000, 2000, 3000, 4000),
+ new(0, 1000, 2000, 3000, 4000),
+ ],
+ [
+ new(0, 1000 + 0, 2000 + 0, 3000 + 0, 4000 + 0),
+ new(0, 1000 + 10, 2000 + 20, 3000 + 30, 4000 + 40),
+ new(0, 1000 - 10, 2000 - 20, 3000 - 30, 4000 - 40),
+ new(0, 1000 + 20, 2000 + 40, 3000 + 60, 4000 + 80),
+ new(0, 1000 - 20, 2000 - 40, 3000 - 60, 4000 - 80),
+ new(0, 1000 + 10, 2000 + 20, 3000 + 30, 4000 + 40),
+ new(0, 1000 - 10, 2000 - 20, 3000 - 30, 4000 - 40),
+ new(0, 1000 + 20, 2000 + 40, 3000 + 60, 4000 + 80),
+ new(0, 1000 - 20, 2000 - 40, 3000 - 60, 4000 - 80),
+ ],
+ [.. Enumerable.Range(0, 100).Select(static i =>
+ new Profile01TimeSet(
+ X0_us: 0,
+ X1_us: 1000 + 1 * i * (2 * (i % 2) - 1),
+ X2_us: 2000 - 2 * i * (2 * (i % 2) - 1),
+ X3_us: 3000 + 3 * i * (2 * (i % 2) - 1),
+ X4_us: 4000 - 4 * i * (2 * (i % 2) - 1))
+ )],
+ ];
+
+ [Theory, MemberData(nameof(Profile01Data))]
+ public void Profile01(Profile01TimeSet[] timeSet)
+ {
+ var timeProvider = new FakeTimeProvider();
+ var codePathProfiler = CodePathProfiler.Create(nameof(TestTarget), new() { TimeProvider = timeProvider, });
+ for(var k = 0; k < timeSet.Length; ++k)
+ {
+ var time = timeSet[k];
+ timeProvider.TimestampMicroseconds = time.X0_us;
+ var enumerator = TestTarget.ProfileTarget(codePathProfiler).GetEnumerator();
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X1_us;
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X2_us;
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X3_us;
+ enumerator.MoveNext();
+ timeProvider.TimestampMicroseconds = time.X4_us;
+ enumerator.MoveNext();
+
+ var statWhole = TestHelpers.CalculateMeanAndSD(timeSet.Take(k + 1).Select(static x => (x.X4_us - x.X0_us) / 1_000_000.0));
+ var statSection_s_1 = TestHelpers.CalculateMeanAndSD(timeSet.Take(k + 1).Select(static x => (x.X1_us - x.X0_us) / 1_000_000.0));
+ var statSection_1_2 = TestHelpers.CalculateMeanAndSD(timeSet.Take(k + 1).Select(static x => (x.X2_us - x.X1_us) / 1_000_000.0));
+ var statSection_2_3 = TestHelpers.CalculateMeanAndSD(timeSet.Take(k + 1).Select(static x => (x.X3_us - x.X2_us) / 1_000_000.0));
+ var statSection_3_e = TestHelpers.CalculateMeanAndSD(timeSet.Take(k + 1).Select(static x => (x.X4_us - x.X3_us) / 1_000_000.0));
+ var reports = codePathProfiler.CreateProfileReports();
+ Assert.True(reports.TryGetValue(nameof(TestTarget.ProfileTarget), out var report));
+ Assert.Equal($"{nameof(TestTarget)}.{nameof(TestTarget.ProfileTarget)}", report.CounterName);
+ Assert.Equal(statWhole.time, report.TotalTimes);
+ Assert.Equal(statWhole.mean, report.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statWhole.sd, report.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.True(report.CodePathSummaries.TryGetValue(new (CodePathProfiler.StartCheckpointName, "checkpoint1"), out var trn1));
+ Assert.True(report.CodePathSummaries.TryGetValue(new ("checkpoint1", "checkpoint2"), out var trn2));
+ Assert.True(report.CodePathSummaries.TryGetValue(new ("checkpoint2", "checkpoint3"), out var trn3));
+ Assert.True(report.CodePathSummaries.TryGetValue(new ("checkpoint3", CodePathProfiler.EndCheckpointName), out var trn4));
+ Assert.Equal(new(CodePathProfiler.StartCheckpointName, "checkpoint1"), trn1.Key);
+ Assert.Equal(new("checkpoint1", "checkpoint2"), trn2.Key);
+ Assert.Equal(new("checkpoint2", "checkpoint3"), trn3.Key);
+ Assert.Equal(new("checkpoint3", CodePathProfiler.EndCheckpointName), trn4.Key);
+ Assert.Equal(statSection_s_1.time, trn1.TotalTimes);
+ Assert.Equal(statSection_1_2.time, trn2.TotalTimes);
+ Assert.Equal(statSection_2_3.time, trn3.TotalTimes);
+ Assert.Equal(statSection_3_e.time, trn4.TotalTimes);
+ Assert.Equal(statSection_s_1.mean, trn1.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_1_2.mean, trn2.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_2_3.mean, trn3.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_3_e.mean, trn4.MeanDuration.TotalSeconds, _meanTolerance);
+ Assert.Equal(statSection_s_1.sd, trn1.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.Equal(statSection_1_2.sd, trn2.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.Equal(statSection_2_3.sd, trn3.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ Assert.Equal(statSection_3_e.sd, trn4.StandardDeviationOfDuration.TotalSeconds, _sdTolerance);
+ }
+ }
+}
+
+file static class TestTarget
+{
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static IEnumerable ProfileTarget(CodePathProfiler codePathProfiler)
+ {
+ using var profiler = codePathProfiler.StartMeasurement();
+ yield return default;
+ profiler.MarkCheckpoint("checkpoint1");
+ yield return default;
+ profiler.MarkCheckpoint("checkpoint2");
+ yield return default;
+ profiler.MarkCheckpoint("checkpoint3");
+ yield return default;
+ }
+}
\ No newline at end of file
diff --git a/tests/PathBench.Test/EqualityComparers.cs b/tests/PathBench.Test/EqualityComparers.cs
new file mode 100644
index 0000000..e9020dc
--- /dev/null
+++ b/tests/PathBench.Test/EqualityComparers.cs
@@ -0,0 +1,81 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace PathBench.Test;
+
+public class CheckpointTransitionProfileReportComparer
+ : IEqualityComparer
+{
+ public static CheckpointTransitionProfileReportComparer Instance { get; } = new();
+
+ public bool Equals(CheckpointTransitionProfileReport? x, CheckpointTransitionProfileReport? y)
+ {
+ switch ((x, y))
+ {
+ case (null, null):
+ return true;
+ case (null, _):
+ case (_, null):
+ return false;
+ default:
+ break;
+ }
+ if (x.Key != y.Key)
+ {
+ return false;
+ }
+ if (x.TotalTimes != y.TotalTimes)
+ {
+ return false;
+ }
+ if(!RealComparer.Equals(x.MeanDuration.TotalSeconds, y.MeanDuration.TotalSeconds, 1e-12))
+ {
+ return false;
+ }
+ if (!RealComparer.Equals(x.StandardDeviationOfDuration.TotalSeconds, y.StandardDeviationOfDuration.TotalSeconds, 1e-12))
+ {
+ return false;
+ }
+ return true;
+ }
+
+ public int GetHashCode([DisallowNull] CheckpointTransitionProfileReport obj)
+ {
+ var hash = new HashCode();
+ hash.Add(obj.Key);
+ hash.Add(obj.TotalTimes);
+ return hash.ToHashCode();
+ }
+}
+
+
+internal static class RealComparer
+{
+ public static bool Equals(double x, double y, double epsilon = 1e-10)
+ {
+ if (x == y)
+ {
+ return true;
+ }
+ if(double.IsNaN(x) && double.IsNaN(y))
+ {
+ return true;
+ }
+ if(double.IsPositiveInfinity(x) && double.IsPositiveInfinity(y))
+ {
+ return true;
+ }
+ if(double.IsNegativeInfinity(x) && double.IsNegativeInfinity(y))
+ {
+ return true;
+ }
+ if (Math.Abs(x - y) < epsilon)
+ {
+ return true;
+ }
+ if(Math.Abs(x - y) / (Math.Abs(x) + Math.Abs(y)) < epsilon)
+ {
+ return true;
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/tests/PathBench.Test/FakeTimeProvider.cs b/tests/PathBench.Test/FakeTimeProvider.cs
new file mode 100644
index 0000000..c808f1a
--- /dev/null
+++ b/tests/PathBench.Test/FakeTimeProvider.cs
@@ -0,0 +1,15 @@
+namespace PathBench.Test;
+
+public class FakeTimeProvider : TimeProvider
+{
+ private readonly ThreadLocal _timestampMicroseconds = new(() => 0);
+
+ public long TimestampMicroseconds
+ {
+ get => _timestampMicroseconds.Value;
+ set => _timestampMicroseconds.Value = value;
+ }
+
+ public override long TimestampFrequency => 1_000_000;
+ public override long GetTimestamp() => TimestampMicroseconds;
+}
diff --git a/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs b/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs
new file mode 100644
index 0000000..3a461ff
--- /dev/null
+++ b/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs
@@ -0,0 +1,69 @@
+using System.Runtime.CompilerServices;
+
+namespace PathBench.Test;
+
+public partial class MethodProfileReportFormatterGraphvizTest
+{
+ private static class PrivateAccess
+ {
+ public const string _typeName =
+ $"{nameof(PathBench)}.{nameof(MethodProfileReportFormatter)}+GraphvizStyle_, PathBench";
+
+ [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "SanitizeIdentifier")]
+ public extern static string SanitizeIdentifier(
+ [UnsafeAccessorType(_typeName)] object? _,
+ string input);
+
+ [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "SanitizeLabel")]
+ public extern static string SanitizeLabel(
+ [UnsafeAccessorType(_typeName)] object? _,
+ string input);
+ }
+
+ public static TheoryData SanitizeIdentifierTestCases() =>
+ new()
+ {
+ { "", "__" },
+ { "foobar", "__foobar" },
+ { "123", "__123" },
+ { "foo bar", "__foo_u0020_bar" },
+ { "foo-bar", "__foo_u002D_bar" },
+ { "foo.bar", "__foo_u002E_bar" },
+ { "foo@!bar", "__foo_u0040__u0021_bar" },
+ { "foo\nbar", "__foo_u000A_bar" },
+ { "foo\tbar", "__foo_u0009_bar" },
+ { "foo/bar\\baz", "__foo_u002F_bar_u005C_baz" },
+ { "ほげほげ", "___u307B__u3052__u307B__u3052_" },
+ };
+
+ [Theory, MemberData(nameof(SanitizeIdentifierTestCases))]
+ public void SanitizeIdentifier(string input, string expected)
+ {
+ var actual = PrivateAccess.SanitizeIdentifier(null, input);
+ Assert.Equal(expected, actual);
+ }
+
+
+ public static TheoryData SanitizeLabelTestCases() =>
+ new()
+ {
+ { "", "" },
+ { "foobar", "foobar" },
+ { "123", "123" },
+ { "foo bar", "foo bar" },
+ { "foo-bar", "foo-bar" },
+ { "foo.bar", "foo.bar" },
+ { "foo@!bar", "foo@!bar" },
+ { "foo\nbar", "foo\\nbar" },
+ { "foo\tbar", "foo\\tbar" },
+ { "foo\\bar", @"foo\\bar" },
+ { "foo \"bar\" baz", "foo \\\"bar\\\" baz" },
+ };
+
+ [Theory, MemberData(nameof(SanitizeLabelTestCases))]
+ public void SanitizeLabel(string input, string expected)
+ {
+ var actual = PrivateAccess.SanitizeLabel(null, input);
+ Assert.Equal(expected, actual);
+ }
+}
diff --git a/tests/PathBench.Test/PathBench.Test.csproj b/tests/PathBench.Test/PathBench.Test.csproj
new file mode 100644
index 0000000..4abab7a
--- /dev/null
+++ b/tests/PathBench.Test/PathBench.Test.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/PathBench.Test/PreciseDurationTest.cs b/tests/PathBench.Test/PreciseDurationTest.cs
new file mode 100644
index 0000000..da8db66
--- /dev/null
+++ b/tests/PathBench.Test/PreciseDurationTest.cs
@@ -0,0 +1,233 @@
+using System.Runtime.CompilerServices;
+
+namespace PathBench.Test;
+
+public class PreciseDurationTest
+{
+ private static class PrivateAccess
+ {
+ [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DoubleTicks")]
+ public extern static double DoubleTicks(in PreciseDuration duration);
+
+ [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "FromScaledValue")]
+ public extern static long FromScaledValue(PreciseDuration _, double value, long scale);
+ }
+
+#pragma warning disable IDE1006
+ private const long NaN = PreciseDuration.NaNTicks;
+ private const long PInf = PreciseDuration.PositiveInfinityTicks;
+ private const long NInf = PreciseDuration.NegativeInfinityTicks;
+ private const long Max = PreciseDuration.MaxTicks;
+ private const long Min = PreciseDuration.MinTicks;
+#pragma warning restore IDE1006
+
+ [Fact]
+ public void Scale()
+ {
+#pragma warning disable format
+
+ Assert.Equal(1e+0, PreciseDuration.FromSeconds (1e+0).TotalSeconds, precision: 12);
+ Assert.Equal(1e+0, PreciseDuration.FromMilliseconds(1e+3).TotalSeconds, precision: 12);
+ Assert.Equal(1e+0, PreciseDuration.FromMicroseconds(1e+6).TotalSeconds, precision: 12);
+ Assert.Equal(1e+0, PreciseDuration.FromNanoseconds (1e+9).TotalSeconds, precision: 12);
+
+ Assert.Equal(1e+3, PreciseDuration.FromSeconds (1e+0).TotalMilliseconds, precision: 12);
+ Assert.Equal(1e+3, PreciseDuration.FromMilliseconds(1e+3).TotalMilliseconds, precision: 12);
+ Assert.Equal(1e+3, PreciseDuration.FromMicroseconds(1e+6).TotalMilliseconds, precision: 12);
+ Assert.Equal(1e+3, PreciseDuration.FromNanoseconds (1e+9).TotalMilliseconds, precision: 12);
+
+ Assert.Equal(1e+6, PreciseDuration.FromSeconds (1e+0).TotalMicroseconds, precision: 12);
+ Assert.Equal(1e+6, PreciseDuration.FromMilliseconds(1e+3).TotalMicroseconds, precision: 12);
+ Assert.Equal(1e+6, PreciseDuration.FromMicroseconds(1e+6).TotalMicroseconds, precision: 12);
+ Assert.Equal(1e+6, PreciseDuration.FromNanoseconds (1e+9).TotalMicroseconds, precision: 12);
+
+ Assert.Equal(1e+9, PreciseDuration.FromSeconds (1e+0).TotalNanoseconds, precision: 12);
+ Assert.Equal(1e+9, PreciseDuration.FromMilliseconds(1e+3).TotalNanoseconds, precision: 12);
+ Assert.Equal(1e+9, PreciseDuration.FromMicroseconds(1e+6).TotalNanoseconds, precision: 12);
+ Assert.Equal(1e+9, PreciseDuration.FromNanoseconds (1e+9).TotalNanoseconds, precision: 12);
+
+#pragma warning restore format
+ }
+
+ [Fact]
+ public void DoubleTicks()
+ {
+ Assert.Equal(1.0, PrivateAccess.DoubleTicks(new PreciseDuration(1L)));
+ Assert.Equal(100.0, PrivateAccess.DoubleTicks(new PreciseDuration(100L)));
+ Assert.Equal(-100.0, PrivateAccess.DoubleTicks(new PreciseDuration(-100L)));
+ Assert.True(double.IsNaN(PrivateAccess.DoubleTicks(new PreciseDuration(NaN))));
+ Assert.True(double.IsPositiveInfinity(PrivateAccess.DoubleTicks(new PreciseDuration(PInf))));
+ Assert.True(double.IsNegativeInfinity(PrivateAccess.DoubleTicks(new PreciseDuration(NInf))));
+ }
+
+ public static TheoryData ToStringTestCase() =>
+ new()
+ {
+ { "0.00", "1.23 sec" },
+ { "0.00sec", "1.23 sec" },
+ { "0.00msec", "1234.00 msec" },
+ { "0.00usec", "1234000.00 usec" },
+ { "0.00nsec", "1234000000.00 nsec" },
+ };
+
+ [Theory, MemberData(nameof(ToStringTestCase))]
+ public void ToStringByFormat(string format, string expected)
+ {
+ Assert.Equal(expected, PreciseDuration.FromSeconds(1.234).ToString(format, System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void ToStringException() =>
+ Assert.Throws(() => PreciseDuration.FromSeconds(1.234).ToString((TimeScale)(-1)));
+
+ public static TheoryData FromScaledValueTestCase() =>
+ new()
+ {
+ { 0.0, 1L, 0L },
+ { 1.0, PreciseDuration.TicksPerSecond, PreciseDuration.TicksPerSecond },
+ { 10.0, PreciseDuration.TicksPerSecond, 10 * PreciseDuration.TicksPerSecond },
+ { (double)Max, 10L, PreciseDuration.PositiveInfinityTicks },
+ { (double)Min, 10L, PreciseDuration.NegativeInfinityTicks },
+ { double.PositiveInfinity, 1L, PreciseDuration.PositiveInfinityTicks },
+ { double.NegativeInfinity, 1L, PreciseDuration.NegativeInfinityTicks },
+ { double.NaN, 1L, PreciseDuration.NaNTicks },
+ };
+
+ [Theory, MemberData(nameof(FromScaledValueTestCase))]
+ public void FromScaledValue(double value, long scale, long expected)
+ {
+ Assert.Equal(expected, PrivateAccess.FromScaledValue(default, value, scale));
+ }
+
+
+ public static TheoryData AddTestCase() =>
+ new()
+ {
+#pragma warning disable format
+ { +0 , +0 , +0 },
+ { +1 , +2 , +3 },
+ { -1 , -2 , -3 },
+ { -1 , +1 , +0 },
+ { PInf, +1 , PInf },
+ { NInf, -1 , NInf },
+ { PInf, NInf, NaN },
+ { NInf, PInf, NaN },
+ { Max , +1 , PInf },
+ { Min , -1 , NInf },
+ { NaN , +1 , NaN },
+ { +1 , NaN , NaN },
+#pragma warning restore format
+ };
+
+ [Theory, MemberData(nameof(AddTestCase))]
+ public void Add(long ticks1, long ticks2, long expectedTicks)
+ {
+ var duration1 = new PreciseDuration(ticks1);
+ var duration2 = new PreciseDuration(ticks2);
+ var expectedDuration = new PreciseDuration(expectedTicks);
+ Assert.Equal(expectedDuration.Ticks, (duration1 + duration2).Ticks);
+ }
+
+
+ public static TheoryData SubtractTestCase() =>
+ new()
+ {
+#pragma warning disable format
+ { +0 , +0 , +0 },
+ { +3 , +2 , +1 },
+ { -3 , -2 , -1 },
+ { +1 , +1 , +0 },
+ { PInf, +1 , PInf },
+ { NInf, -1 , NInf },
+ { PInf, PInf, NaN },
+ { NInf, NInf, NaN },
+ { Max , -1 , PInf },
+ { Min , +1 , NInf },
+ { NaN , +1 , NaN },
+ { +1 , NaN , NaN },
+#pragma warning restore format
+ };
+
+ [Theory, MemberData(nameof(SubtractTestCase))]
+ public void Subtract(long ticks1, long ticks2, long expectedTicks)
+ {
+ var duration1 = new PreciseDuration(ticks1);
+ var duration2 = new PreciseDuration(ticks2);
+ var expectedDuration = new PreciseDuration(expectedTicks);
+ Assert.Equal(expectedDuration.Ticks, (duration1 - duration2).Ticks);
+ }
+
+
+ public static TheoryData EqualityTestCase() =>
+ new()
+ { // == !=
+ { +0 , +0 , true , false },
+ { +1 , +1 , true , false },
+ { -1 , -1 , true , false },
+ { +1 , -1 , false, true },
+ { PInf, PInf, true , false },
+ { NInf, NInf, true , false },
+ { PInf, NInf, false, true },
+ { NInf, PInf, false, true },
+ { Max , Max , true , false },
+ { Min , Min , true , false },
+ { NaN , NaN , false, false },
+ { +1 , NaN , false, false },
+ };
+
+ [Theory, MemberData(nameof(EqualityTestCase))]
+ public static void Equal(long ticks1, long ticks2, bool expectedEqual, bool expectedNotEqual)
+ {
+ var duration1 = new PreciseDuration(ticks1);
+ var duration2 = new PreciseDuration(ticks2);
+ Assert.Equal(expectedEqual, duration1 == duration2);
+ Assert.Equal(expectedNotEqual, duration1 != duration2);
+ }
+
+
+ public static TheoryData CompareTestCase() =>
+ new()
+ { // < <= > >=
+ { +0 , +0 , false, true, false, true },
+ { +1 , +1 , false, true, false, true },
+ { -1 , -1 , false, true, false, true },
+ { +1 , -1 , false, false, true, true },
+ { -1 , +1 , true, true, false, false },
+ { PInf, PInf, false, true, false, true },
+ { NInf, NInf, false, true, false, true },
+ { PInf, NInf, false, false, true, true },
+ { NInf, PInf, true, true, false, false },
+ { Max , Max , false, true, false, true },
+ { Min , Min , false, true, false, true },
+ { NaN , NaN , false, false, false, false },
+ { +1 , NaN , false, false, false, false },
+ };
+
+ [Theory, MemberData(nameof(CompareTestCase))]
+ public static void Compare(long ticks1, long ticks2, bool expectedLess, bool expectedLessEqual, bool expectedGreater, bool expectedGreaterEqual)
+ {
+ var duration1 = new PreciseDuration(ticks1);
+ var duration2 = new PreciseDuration(ticks2);
+ Assert.Equal(expectedLess, duration1 < duration2);
+ Assert.Equal(expectedLessEqual, duration1 <= duration2);
+ Assert.Equal(expectedGreater, duration1 > duration2);
+ Assert.Equal(expectedGreaterEqual, duration1 >= duration2);
+ }
+
+ [Fact]
+ public void ConvertTimeSpan()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(1), (TimeSpan)PreciseDuration.FromSeconds(1));
+ Assert.Equal(PreciseDuration.FromSeconds(1), (PreciseDuration)TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public void ConvertTimeSpanException()
+ {
+ Assert.Throws(() => (TimeSpan)PreciseDuration.NaN);
+ Assert.Throws(() => (TimeSpan)PreciseDuration.PositiveInfinity);
+ Assert.Throws(() => (TimeSpan)PreciseDuration.NegativeInfinity);
+ Assert.Throws(() => (PreciseDuration)TimeSpan.MaxValue);
+ Assert.Throws(() => (PreciseDuration)TimeSpan.MinValue);
+ }
+}
diff --git a/tests/PathBench.Test/TestHelpers.cs b/tests/PathBench.Test/TestHelpers.cs
new file mode 100644
index 0000000..7422665
--- /dev/null
+++ b/tests/PathBench.Test/TestHelpers.cs
@@ -0,0 +1,14 @@
+namespace PathBench.Test;
+
+internal static class TestHelpers
+{
+ public static (long time, double mean, double sd) CalculateMeanAndSD(IEnumerable values)
+ {
+ var time = values.Count();
+ var mean = values.Average();
+ var sd = time > 1
+ ? Math.Sqrt(values.Select(x => (x - mean) * (x - mean)).Sum() / (time - 1.0))
+ : double.NaN;
+ return (time, mean, sd);
+ }
+}
diff --git a/tests/PathBench.Test/TimeScaleTest.cs b/tests/PathBench.Test/TimeScaleTest.cs
new file mode 100644
index 0000000..e339f99
--- /dev/null
+++ b/tests/PathBench.Test/TimeScaleTest.cs
@@ -0,0 +1,25 @@
+namespace PathBench.Test;
+
+public class TimeScaleTest
+{
+ public static TheoryData GetBestTimeScaleForTestCase()
+ {
+ static PreciseDuration f(double sec) => PreciseDuration.FromSeconds(sec);
+
+ return new()
+ {
+ { new PreciseDuration[] { f(1), f(2) }, TimeScale.Seconds },
+ { new PreciseDuration[] { f(0.1), f(0.2) }, TimeScale.Seconds },
+ { new PreciseDuration[] { f(0.1e-3), f(0.2e-3) }, TimeScale.Milliseconds },
+ { new PreciseDuration[] { f(0.1e-6), f(0.2e-6) }, TimeScale.Microseconds },
+ { new PreciseDuration[] { f(0.1e-9), f(0.2e-9) }, TimeScale.Nanoseconds },
+ };
+ }
+
+ [Theory, MemberData(nameof(GetBestTimeScaleForTestCase))]
+ public void GetBestTimeScaleFor(PreciseDuration[] timeSpans, TimeScale expectedTimeScale)
+ {
+ var actualTimeScale = TimeScaleExtensions.GetBestTimeScaleFor(timeSpans);
+ Assert.Equal(expectedTimeScale, actualTimeScale);
+ }
+}
diff --git a/tests/etc/coverlet.runsettings b/tests/etc/coverlet.runsettings
new file mode 100644
index 0000000..d94860e
--- /dev/null
+++ b/tests/etc/coverlet.runsettings
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ **/*.g.cs,**/generated/**/*.cs
+ DebuggerHiddenAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
+
+
+
+
+
\ No newline at end of file