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); + } +} +``` + +![graphviz sample](resources/GraphvizSample.svg) 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 @@ + + + + + + +__SampleClass_u002E_SimulatedWork + +SampleClass.SimulatedWork + + +___u0023__u003C_start_u003E_ + +#<start> + + + +__EvenSeed + +EvenSeed + + + +___u0023__u003C_start_u003E_->__EvenSeed + + +100 times +0.0068 msec + + + +__OddSeed + +OddSeed + + + +___u0023__u003C_start_u003E_->__OddSeed + + +100 times +0.0009 msec + + + +__LoopBegin + +LoopBegin + + + +__DivisibleBy3 + +DivisibleBy3 + + + +__LoopBegin->__DivisibleBy3 + + +6633 times +0.0002 msec + + + +__DivisibleBy5 + +DivisibleBy5 + + + +__LoopBegin->__DivisibleBy5 + + +2535 times +0.0003 msec + + + +__DivisibleBy7 + +DivisibleBy7 + + + +__LoopBegin->__DivisibleBy7 + + +1477 times +0.0003 msec + + + +__LoopEnd + +LoopEnd + + + +__LoopBegin->__LoopEnd + + +9255 times +0.0004 msec + + + +__EvenSeed->__LoopBegin + + +99 times +15.2253 msec + + + +___u0023__u003C_end_u003E_ + +#<end> + + + +__EvenSeed->___u0023__u003C_end_u003E_ + + +1 times +23.2597 msec + + + +__OddSeed->__LoopBegin + + +100 times +30.8833 msec + + + +__DivisibleBy3->__DivisibleBy5 + + +1365 times +0.0001 msec + + + +__DivisibleBy3->__DivisibleBy7 + + +840 times +0.0001 msec + + + +__DivisibleBy3->__LoopEnd + + +4428 times +0.0001 msec + + + +__DivisibleBy5->__DivisibleBy7 + + +525 times +0.0001 msec + + + +__DivisibleBy5->__LoopEnd + + +3375 times +0.0011 msec + + + +__DivisibleBy7->__LoopEnd + + +2842 times +0.0003 msec + + + +__LoopEnd->__LoopBegin + + +19701 times +0.0002 msec + + + +__LoopEnd->___u0023__u003C_end_u003E_ + + +199 times +0.0008 msec + + + 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