From 65e9f559d7a5130d3d30cf64290ce5c7232000ac Mon Sep 17 00:00:00 2001 From: aka-nse Date: Sat, 7 Feb 2026 18:21:57 +0900 Subject: [PATCH 01/14] freeze temporary into formal feature branch (#1) * fixes method relation * implements basis features * appends the formatting feature: graphviz style * adds formatter & tests --- PathBench.slnx | 1 + src/PathBench/CodePathCounter.cs | 82 ----------- src/PathBench/CodePathCounterOptions.cs | 11 -- ...=> CodePathProfiler.InvocationProfiler.cs} | 36 +++-- .../CodePathProfiler.MethodProfiler.cs | 132 ++++++++++++++++++ src/PathBench/CodePathProfiler.cs | 64 +++++++++ src/PathBench/CodePathProfilerOptions.cs | 48 +++++++ src/PathBench/DataTypes.cs | 89 +++++++++--- src/PathBench/InternalHelpers.cs | 32 +++++ src/PathBench/InvocationCounter.cs | 7 - src/PathBench/InvocationProfiler.cs | 27 ++++ .../MethodProfileReportFormatter.Graphviz.cs | 125 +++++++++++++++++ .../MethodProfileReportFormatter.Simple.cs | 28 ++++ src/PathBench/MethodProfileReportFormatter.cs | 14 ++ src/PathBench/PathBench.csproj | 1 + src/PathBench/WelfordStatistics.cs | 27 ++++ .../PathBench.Sample/PathBench.Sample.csproj | 4 + tests/PathBench.Sample/Program.cs | 46 +++++- tests/PathBench.Test/CodePathProfilerTest.cs | 100 +++++++++++++ tests/PathBench.Test/EqualityComparers.cs | 81 +++++++++++ tests/PathBench.Test/FakeTimeProvider.cs | 9 ++ tests/PathBench.Test/PathBench.Test.csproj | 25 ++++ tests/PathBench.Test/TestHelpers.cs | 14 ++ 23 files changed, 875 insertions(+), 128 deletions(-) delete mode 100644 src/PathBench/CodePathCounter.cs delete mode 100644 src/PathBench/CodePathCounterOptions.cs rename src/PathBench/{CodePathCounter.InvocationCounter.cs => CodePathProfiler.InvocationProfiler.cs} (55%) create mode 100644 src/PathBench/CodePathProfiler.MethodProfiler.cs create mode 100644 src/PathBench/CodePathProfiler.cs create mode 100644 src/PathBench/CodePathProfilerOptions.cs create mode 100644 src/PathBench/InternalHelpers.cs delete mode 100644 src/PathBench/InvocationCounter.cs create mode 100644 src/PathBench/InvocationProfiler.cs create mode 100644 src/PathBench/MethodProfileReportFormatter.Graphviz.cs create mode 100644 src/PathBench/MethodProfileReportFormatter.Simple.cs create mode 100644 src/PathBench/MethodProfileReportFormatter.cs create mode 100644 src/PathBench/WelfordStatistics.cs create mode 100644 tests/PathBench.Test/CodePathProfilerTest.cs create mode 100644 tests/PathBench.Test/EqualityComparers.cs create mode 100644 tests/PathBench.Test/FakeTimeProvider.cs create mode 100644 tests/PathBench.Test/PathBench.Test.csproj create mode 100644 tests/PathBench.Test/TestHelpers.cs diff --git a/PathBench.slnx b/PathBench.slnx index 9661fdc..522ce51 100644 --- a/PathBench.slnx +++ b/PathBench.slnx @@ -5,5 +5,6 @@ + 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/CodePathCounter.InvocationCounter.cs b/src/PathBench/CodePathProfiler.InvocationProfiler.cs similarity index 55% rename from src/PathBench/CodePathCounter.InvocationCounter.cs rename to src/PathBench/CodePathProfiler.InvocationProfiler.cs index ea47570..3f7299a 100644 --- a/src/PathBench/CodePathCounter.InvocationCounter.cs +++ b/src/PathBench/CodePathProfiler.InvocationProfiler.cs @@ -1,10 +1,10 @@ namespace PathBench; -partial class CodePathCounter +partial class CodePathProfiler { - private sealed class InvocationCounter_ : InvocationCounter + private sealed class InvocationProfiler_ : InvocationProfiler { - private CodePathCounter Owner { get; } + private MethodProfiler Owner { get; } public string? MethodName { get; } public DateTimeOffset StartAt { get; } public long InvocationIndex { get; } @@ -18,18 +18,19 @@ private sealed class InvocationCounter_ : InvocationCounter private List? _freezedCheckpoints; private long _endAtTimestamp = -1; - public InvocationCounter_( - CodePathCounter owner, + public InvocationProfiler_( + MethodProfiler owner, string? methodName, long invocationIndex, object? argumentsExpressionProvider) { Owner = owner; MethodName = methodName; + StartAt = Owner.Owner.TimeProvider.GetUtcNow(); + InvocationIndex = invocationIndex; ArgumentsExpressionProvider = argumentsExpressionProvider; - StartAt = DateTimeOffset.UtcNow; ManagedThreadId = Environment.CurrentManagedThreadId; - _startAtTimestamp = owner.TimeProvider.GetTimestamp(); + _startAtTimestamp = owner.Owner.TimeProvider.GetTimestamp(); _checkpoints = []; MarkCheckpoint(StartCheckpointName, null); @@ -37,14 +38,31 @@ public InvocationCounter_( public override void Dispose() { - _endAtTimestamp = Owner.TimeProvider.GetTimestamp(); + _endAtTimestamp = Owner.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); + MarkCheckpoint(name, Owner.Owner.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: TimeSpan.FromTicks(end.DurationTimestamp - start.DurationTimestamp))); + return new( + CounterName: Owner.Name, + InvocationId: InvocationIndex, + StartAt: StartAt, + ManagedThreadId: ManagedThreadId, + ArgumentsExpression: ArgumentsExpressionProvider?.ToString(), + Duration: TimeSpan.FromTicks(Duration), + CodePathMeasurements: [.. path]); + } private void MarkCheckpoint(string name, long timestamp, object? noteProvider) { diff --git a/src/PathBench/CodePathProfiler.MethodProfiler.cs b/src/PathBench/CodePathProfiler.MethodProfiler.cs new file mode 100644 index 0000000..97834f4 --- /dev/null +++ b/src/PathBench/CodePathProfiler.MethodProfiler.cs @@ -0,0 +1,132 @@ +using System.Collections.Immutable; + +namespace PathBench; + +partial class CodePathProfiler +{ + private class MethodProfiler(CodePathProfiler 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 = new(); + private readonly Queue _RecentHistory = new(owner._recentHistoryCacheSize); + private readonly SortedList _WorstHistory = new(owner._worstHistoryCacheSize, WorstHistoryKeyComparer.Instance); + + private long _invocationCount = 0; + private WelfordStatistics _overallDurations; + + public CodePathProfiler 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, + methodName, + id, + argumentsExpressionProvider); + return invocation; + } + + + public void TerminateInvocation(InvocationProfiler_ invocation) + { + using (_lockToken.EnterScope()) + { + var x = (double)invocation.Duration / Owner.TimeProvider.TimestampFrequency; + + // update overall statistics + _overallDurations.IncrementResult(x); + + // update code path report + foreach(var (start, end) in invocation.Checkpoints.Zip(invocation.Checkpoints.Skip(1))) + { + var key = new CheckpointTransitionKey(start.Name, end.Name); + var y = (double)(end.DurationTimestamp - start.DurationTimestamp) / Owner.TimeProvider.TimestampFrequency; + if (!_CodePathResults.TryGetValue(key, out var result)) + { + _CodePathResults.Add(key, result = new(key)); + } + 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; + ImmutableDictionary codePathSummaries; + InvocationProfiler_[] recentHistory; + InvocationProfiler_[] worstHistory; + using (_lockToken.EnterScope()) + { + (times, mean_sec, sd_sec) = _overallDurations; + codePathSummaries = _CodePathResults.ToImmutableDictionary( + static kv => kv.Key, + static kv => kv.Value.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: TimeSpan.FromSeconds(mean_sec), + StandardDeviationOfDuration: double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec), + CodePathSummaries: codePathSummaries, + Histories: histories.ToImmutable()); + } + + + private class CodePathResult(CheckpointTransitionKey key) + { + public CheckpointTransitionKey Key { get; } = key; + private WelfordStatistics _durations; + + public void IncrementResult(double duration_sec) => + _durations.IncrementResult(duration_sec); + + public CheckpointTransitionProfileReport CreateSummary() + { + var (times, mean_sec, sd_sec) = _durations; + return new( Key, times, TimeSpan.FromSeconds(mean_sec), double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec)); + } + } + } +} diff --git a/src/PathBench/CodePathProfiler.cs b/src/PathBench/CodePathProfiler.cs new file mode 100644 index 0000000..b6d0681 --- /dev/null +++ b/src/PathBench/CodePathProfiler.cs @@ -0,0 +1,64 @@ +using System.Collections.Immutable; +using System.Diagnostics; +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 partial class CodePathProfiler( + string? className = null, + CodePathProfilerOptions? options = null) +{ + /// The name of the start checkpoint. + public const string StartCheckpointName = "#"; + + /// The name of the end checkpoint. + public const string EndCheckpointName = "#"; + + private readonly int _recentHistoryCacheSize = (options ?? CodePathProfilerOptions.Default).RecentHistoryCacheSize; + private readonly int _worstHistoryCacheSize = (options ?? CodePathProfilerOptions.Default).WorstHistoryCacheSize; + + private ImmutableDictionary _methodCounters = ImmutableDictionary.Empty; + + /// The name of the class which this instance related to. + public string? ClassName { get; } = className ?? new StackFrame(1, false).GetMethod()?.DeclaringType?.FullName; + + /// The time provider used for measuring time. + public TimeProvider TimeProvider { get; } = options?.TimeProvider ?? TimeProvider.System; + + /// + /// Starts performance measurement of one invocation of the method. + /// + /// + /// + /// + public virtual InvocationProfiler StartMeasurement( + [CallerMemberName] string methodName = "", + object? argumentsExpressionProvider = null) + { + var methodCounter = InternalHelpers.GetOrAdd( + ref _methodCounters, + methodName, + () => new MethodProfiler(this, methodName)); + var invocation = methodCounter.StartMeasurement(argumentsExpressionProvider); + return invocation; + } + + /// + /// Creates profile reports for all measured methods. + /// + /// + public virtual 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/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..6b70200 100644 --- a/src/PathBench/DataTypes.cs +++ b/src/PathBench/DataTypes.cs @@ -2,48 +2,101 @@ namespace PathBench; +/// +/// Types of invocation measurement history cache. +/// [Flags] public enum HistoryType { + /// + /// Recent invocation measurements. + /// Recent, + + /// + /// Worst-performance invocation measurements. + /// Worst, } - -public record struct CodePathKey( +/// +/// Key identifying a code path between two checkpoints. +/// +/// +/// +public record struct CheckpointTransitionKey( string StartCheckpoint, string EndCheckpoint); - -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 - ); - + TimeSpan? StandardDeviationOfDuration, + 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); + TimeSpan? StandardDeviationOfDuration); - -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); - + ImmutableArray CodePathMeasurements); -public record class CodePathMeasurement( - CodePathKey Key, +/// +/// Summary of one checkpoint transition measurement. +/// +/// +/// +/// +public record class CheckpointTransitionMeasurementReport( + CheckpointTransitionKey Key, string? Note, TimeSpan Duration); diff --git a/src/PathBench/InternalHelpers.cs b/src/PathBench/InternalHelpers.cs new file mode 100644 index 0000000..2f29b55 --- /dev/null +++ b/src/PathBench/InternalHelpers.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; + +namespace PathBench; + +internal static class InternalHelpers +{ + public static TValue GetOrAdd( + ref ImmutableDictionary dictionary, + TKey key, + Func factory) + where TKey : notnull + { + while (true) + { + var snapshot = dictionary; + if (snapshot.TryGetValue(key, out var existingValue)) + { + return existingValue; + } + var newValue = factory(); + var newDictionary = snapshot.Add(key, newValue); + var originalDictionary = Interlocked.CompareExchange( + ref dictionary, + newDictionary, + snapshot); + if(ReferenceEquals(originalDictionary, snapshot)) + { + return newValue; + } + } + } +} 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..8b343ea --- /dev/null +++ b/src/PathBench/InvocationProfiler.cs @@ -0,0 +1,27 @@ +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, 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..2462c17 --- /dev/null +++ b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs @@ -0,0 +1,125 @@ +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; + + public override void Format(MethodProfileReport report, TextWriter writer) + { + writer.WriteLine($$""" + digraph {{Sanitize(report.CounterName)}} { + graph [ + fontname = "{{_fontName}}", + label = "{{report.CounterName}}", + ]; + node [ + fontname = "{{_fontName}}", + shape = box, + ]; + edge [ + fontname = "{{_fontName}}", + ]; + """); + + var checkpoints = new Dictionary(); + foreach(var (key, transition) in report.CodePathSummaries) + { + if(!checkpoints.TryGetValue(key.StartCheckpoint, out var sanitizedStart)) + { + checkpoints.Add(key.StartCheckpoint, sanitizedStart = Sanitize(key.StartCheckpoint)); + } + if(!checkpoints.TryGetValue(key.EndCheckpoint, out var sanitizedEnd)) + { + checkpoints.Add(key.EndCheckpoint, sanitizedEnd = Sanitize(key.EndCheckpoint)); + } + writer.WriteLine(""" + {0} -> {1} [label = "{2} times\n{3} msec"] + """, + sanitizedStart, + sanitizedEnd, + transition.TotalTimes, + transition.MeanDuration.TotalMilliseconds); + } + + foreach(var (raw, sanitized) in checkpoints) + { + writer.WriteLine($$""" + {{sanitized}} [label = "{{raw}}"] + """); + } + + writer.WriteLine(""" + } + """); + } + + + private static string Sanitize(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(); + } + } +} + + +/// +/// 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; } +} + +/// +/// Provides configuration options for formatting styles in Graphviz output. +/// +public class GraphvizStyleFormatterOptions : IGraphvizStyleFormatterOptions +{ + private sealed class Default_ : IGraphvizStyleFormatterOptions + { + public string FontName => "Monospace"; + } + internal static IGraphvizStyleFormatterOptions Default { get; } = new Default_(); + + /// + public string FontName { get; set; } = Default.FontName; +} \ 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..05f7782 --- /dev/null +++ b/src/PathBench/MethodProfileReportFormatter.Simple.cs @@ -0,0 +1,28 @@ + + +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) + { + writer.WriteLine($"<`{report.CounterName}` profile report>"); + writer.WriteLine($" total invocation : {report.TotalTimes}"); + writer.WriteLine($" mean duration : {report.MeanDuration.TotalMilliseconds} msec (SD = {report.StandardDeviationOfDuration?.TotalMilliseconds ?? double.NaN})"); + 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 : {pathSummary.Value.MeanDuration.TotalMilliseconds} msec (SD = {pathSummary.Value.StandardDeviationOfDuration?.TotalMilliseconds ?? double.NaN})"); + } + } + } +} 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/WelfordStatistics.cs b/src/PathBench/WelfordStatistics.cs new file mode 100644 index 0000000..2bde1b9 --- /dev/null +++ b/src/PathBench/WelfordStatistics.cs @@ -0,0 +1,27 @@ +namespace PathBench; + +internal struct WelfordStatistics +{ + private long _n; + private double _mu; + private double _ss; + + 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); + } + + public readonly void Deconstruct(out long n, out double mean, out double sd) + { + n = _n; + mean = _mu; + sd = n > 1 + ? Math.Sqrt(_ss / (_n - 1)) + : double.NaN; + } +} 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..3ca77d4 100644 --- a/tests/PathBench.Sample/Program.cs +++ b/tests/PathBench.Sample/Program.cs @@ -1,2 +1,46 @@ -// See https://aka.ms/new-console-template for more information +// See https://aka.ms/new-console-template for more information +using PathBench; + +await SampleClass.InvokeTest(); Console.WriteLine("Hello, World!"); + + +static class SampleClass +{ + public static readonly CodePathProfiler _Profiler = new(); + + public static async Task InvokeTest() + { + var random = new Random(123456789); + for(var i = 0; i < 200; ++i) + { + Console.Write($"\r \r{i}"); + await SimulatedWork(random.Next(0, 20)); + } + Console.WriteLine(); + + var reports = _Profiler.CreateProfileReports(); + Console.WriteLine(reports[nameof(SimulatedWork)]); + Console.WriteLine(); + var sw = new StringWriter(); + MethodProfileReportFormatter.DefaultGraphvizStyle.Format( + reports[nameof(SimulatedWork)], + writer: sw); + Console.WriteLine(sw.ToString()); + } + + private static async Task SimulatedWork(int seed) + { + using var counter = _Profiler.StartMeasurement(argumentsExpressionProvider: $"seed={seed}"); + if (seed % 2 == 0) + { + counter.MarkCheckpoint("EvenSeed"); + await Task.Delay(100); + } + for(var i = 0; i < seed; ++i) + { + counter.MarkCheckpoint("LoopIteration", new { i }); + await Task.Delay(1); + } + } +} \ No newline at end of file diff --git a/tests/PathBench.Test/CodePathProfilerTest.cs b/tests/PathBench.Test/CodePathProfilerTest.cs new file mode 100644 index 0000000..600febc --- /dev/null +++ b/tests/PathBench.Test/CodePathProfilerTest.cs @@ -0,0 +1,100 @@ +namespace PathBench.Test; + +public class CodePathProfilerTest +{ + 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), + ], + ]; + + [Theory, MemberData(nameof(Profile01Data))] + public void Profile01(Profile01TimeSet[] timeSet) + { + static IEnumerable profileTarget(CodePathProfiler codePathProfiler) + { + using var profiler = codePathProfiler.StartMeasurement(nameof(profileTarget)); + yield return default; + profiler.MarkCheckpoint("checkpoint1"); + yield return default; + profiler.MarkCheckpoint("checkpoint2"); + yield return default; + profiler.MarkCheckpoint("checkpoint3"); + yield return default; + } + + const double meanTolerance = 1e-6; + const double sdTolerance = 1e-4; + + var timeProvider = new FakeTimeProvider(); + var codePathProfiler = new CodePathProfiler("SampleClassName", new() { TimeProvider = timeProvider, }); + for(var k = 0; k < timeSet.Length; ++k) + { + var time = timeSet[k]; + timeProvider.TimestampMicroseconds = time.X0_us; + var enumerator = 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(profileTarget), out var report)); + Assert.Equal($"SampleClassName.{nameof(profileTarget)}", report.CounterName); + Assert.Equal(statWhole.time, report.TotalTimes); + Assert.Equal(statWhole.mean, report.MeanDuration.TotalSeconds, meanTolerance); + Assert.Equal(statWhole.sd, report.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, 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 ?? double.NaN, sdTolerance); + Assert.Equal(statSection_1_2.sd, trn2.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); + Assert.Equal(statSection_2_3.sd, trn3.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); + Assert.Equal(statSection_3_e.sd, trn4.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); + } + } + +} diff --git a/tests/PathBench.Test/EqualityComparers.cs b/tests/PathBench.Test/EqualityComparers.cs new file mode 100644 index 0000000..f0ba099 --- /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 ?? double.NaN, y.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, 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..107bf33 --- /dev/null +++ b/tests/PathBench.Test/FakeTimeProvider.cs @@ -0,0 +1,9 @@ +namespace PathBench.Test; + +public class FakeTimeProvider : TimeProvider +{ + public long TimestampMicroseconds { get; set; } = 0; + + public override long TimestampFrequency => 1_000_000; + public override long GetTimestamp() => TimestampMicroseconds; +} 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/TestHelpers.cs b/tests/PathBench.Test/TestHelpers.cs new file mode 100644 index 0000000..e93a2c0 --- /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); + } +} From eb77359fe33a0f60cd43d801fdfedcd0594daec4 Mon Sep 17 00:00:00 2001 From: Daishi Nakase Date: Sat, 7 Feb 2026 19:34:59 +0900 Subject: [PATCH 02/14] adds tests --- .gitignore | 3 ++ Measure-Coverage.ps1 | 6 +-- src/PathBench/AssemblyInfo.cs | 3 ++ tests/PathBench.Sample/Program.cs | 30 ++++++++--- ...thodProfileReportFormatter.GraphvizTest.cs | 51 +++++++++++++++++++ tests/etc/coverlet.runsettings | 13 +++++ 6 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 src/PathBench/AssemblyInfo.cs create mode 100644 tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs create mode 100644 tests/etc/coverlet.runsettings 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/src/PathBench/AssemblyInfo.cs b/src/PathBench/AssemblyInfo.cs new file mode 100644 index 0000000..e79aba5 --- /dev/null +++ b/src/PathBench/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("PathBench.Test")] diff --git a/tests/PathBench.Sample/Program.cs b/tests/PathBench.Sample/Program.cs index 3ca77d4..895c039 100644 --- a/tests/PathBench.Sample/Program.cs +++ b/tests/PathBench.Sample/Program.cs @@ -11,11 +11,10 @@ static class SampleClass public static async Task InvokeTest() { - var random = new Random(123456789); - for(var i = 0; i < 200; ++i) + for (var i = 0; i < 200; ++i) { Console.Write($"\r \r{i}"); - await SimulatedWork(random.Next(0, 20)); + await SimulatedWork(i); } Console.WriteLine(); @@ -35,12 +34,29 @@ private static async Task SimulatedWork(int seed) if (seed % 2 == 0) { counter.MarkCheckpoint("EvenSeed"); - await Task.Delay(100); + await Task.Delay(10); } - for(var i = 0; i < seed; ++i) + else { - counter.MarkCheckpoint("LoopIteration", new { i }); - await Task.Delay(1); + counter.MarkCheckpoint("OddSeed"); + await Task.Delay(20); + } + for (var i = 0; i < seed; ++i) + { + counter.MarkCheckpoint("LoopBegin", i); + if (seed % 3 == 0) + { + counter.MarkCheckpoint("DivisibleBy3"); + } + if (seed % 5 == 0) + { + counter.MarkCheckpoint("DivisibleBy5"); + } + if (seed % 7 == 0) + { + counter.MarkCheckpoint("DivisibleBy7"); + } + counter.MarkCheckpoint("LoopEnd"); } } } \ No newline at end of file diff --git a/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs b/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs new file mode 100644 index 0000000..4aa0b55 --- /dev/null +++ b/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs @@ -0,0 +1,51 @@ +namespace PathBench.Test; + +public class MethodProfileReportFormatterGraphvizTest +{ + 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 = MethodProfileReportFormatter.GraphvizStyle_.SanitizeIdentifier(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 = MethodProfileReportFormatter.GraphvizStyle_.SanitizeLabel(input); + Assert.Equal(expected, actual); + } +} 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 From ca326339457d42e6e53583792dfa50896db42a79 Mon Sep 17 00:00:00 2001 From: Daishi Nakase Date: Sat, 7 Feb 2026 19:36:12 +0900 Subject: [PATCH 03/14] appends sort info for checkpoints --- .../CodePathProfiler.InvocationProfiler.cs | 13 ++-- .../CodePathProfiler.MethodProfiler.cs | 23 ++++-- src/PathBench/DataTypes.cs | 11 +++ src/PathBench/InvocationProfiler.cs | 5 +- .../MethodProfileReportFormatter.Graphviz.cs | 76 ++++++++++++------- 5 files changed, 86 insertions(+), 42 deletions(-) diff --git a/src/PathBench/CodePathProfiler.InvocationProfiler.cs b/src/PathBench/CodePathProfiler.InvocationProfiler.cs index 3f7299a..a33a84b 100644 --- a/src/PathBench/CodePathProfiler.InvocationProfiler.cs +++ b/src/PathBench/CodePathProfiler.InvocationProfiler.cs @@ -33,19 +33,19 @@ public InvocationProfiler_( _startAtTimestamp = owner.Owner.TimeProvider.GetTimestamp(); _checkpoints = []; - MarkCheckpoint(StartCheckpointName, null); + MarkCheckpoint(StartCheckpointName, int.MinValue, null); } public override void Dispose() { _endAtTimestamp = Owner.Owner.TimeProvider.GetTimestamp(); - MarkCheckpoint(EndCheckpointName, _endAtTimestamp, null); + MarkCheckpoint(EndCheckpointName, int.MaxValue, null); (_freezedCheckpoints, _checkpoints) = (_checkpoints, null); Owner.TerminateInvocation(this); } - public override void MarkCheckpoint(string name, object? noteProvider = null) => - MarkCheckpoint(name, Owner.Owner.TimeProvider.GetTimestamp(), noteProvider); + public override void MarkCheckpoint(string name, int orderingKey, object? noteProvider = null) => + MarkCheckpoint(name, orderingKey, Owner.Owner.TimeProvider.GetTimestamp(), noteProvider); internal protected override InvocationMeasurementReport CreateMeasurementReport() { @@ -64,15 +64,16 @@ internal protected override InvocationMeasurementReport CreateMeasurementReport( CodePathMeasurements: [.. path]); } - private void MarkCheckpoint(string name, long timestamp, object? noteProvider) + private void MarkCheckpoint(string name, int sortKey, long timestamp, object? noteProvider) { var checkpoints = _checkpoints ?? throw new ObjectDisposedException(null); - checkpoints.Add(new(name, noteProvider, timestamp)); + checkpoints.Add(new(name, sortKey, noteProvider, timestamp)); } } private readonly record struct CheckpointMeasurement( string Name, + int SortKey, object? NoteProvider, long DurationTimestamp); } \ No newline at end of file diff --git a/src/PathBench/CodePathProfiler.MethodProfiler.cs b/src/PathBench/CodePathProfiler.MethodProfiler.cs index 97834f4..530515e 100644 --- a/src/PathBench/CodePathProfiler.MethodProfiler.cs +++ b/src/PathBench/CodePathProfiler.MethodProfiler.cs @@ -59,7 +59,7 @@ public void TerminateInvocation(InvocationProfiler_ invocation) var y = (double)(end.DurationTimestamp - start.DurationTimestamp) / Owner.TimeProvider.TimestampFrequency; if (!_CodePathResults.TryGetValue(key, out var result)) { - _CodePathResults.Add(key, result = new(key)); + _CodePathResults.Add(key, result = new(key, start.SortKey, end.SortKey)); } result.IncrementResult(y); } @@ -89,15 +89,19 @@ public MethodProfileReport CreateReport() long times; double mean_sec; double sd_sec; - ImmutableDictionary codePathSummaries; InvocationProfiler_[] recentHistory; InvocationProfiler_[] worstHistory; + var foundCheckpoints =ImmutableDictionary.CreateBuilder(); + var codePathSummaries = ImmutableDictionary.CreateBuilder(); using (_lockToken.EnterScope()) { (times, mean_sec, sd_sec) = _overallDurations; - codePathSummaries = _CodePathResults.ToImmutableDictionary( - static kv => kv.Key, - static kv => kv.Value.CreateSummary()); + 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]; } @@ -109,14 +113,17 @@ public MethodProfileReport CreateReport() TotalTimes: times, MeanDuration: TimeSpan.FromSeconds(mean_sec), StandardDeviationOfDuration: double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec), - CodePathSummaries: codePathSummaries, + FoundCheckpoints: foundCheckpoints.ToImmutable(), + CodePathSummaries: codePathSummaries.ToImmutable(), Histories: histories.ToImmutable()); } - private class CodePathResult(CheckpointTransitionKey key) + 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) => @@ -125,7 +132,7 @@ public void IncrementResult(double duration_sec) => public CheckpointTransitionProfileReport CreateSummary() { var (times, mean_sec, sd_sec) = _durations; - return new( Key, times, TimeSpan.FromSeconds(mean_sec), double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec)); + return new(Key, times, TimeSpan.FromSeconds(mean_sec), double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec)); } } } diff --git a/src/PathBench/DataTypes.cs b/src/PathBench/DataTypes.cs index 6b70200..77de655 100644 --- a/src/PathBench/DataTypes.cs +++ b/src/PathBench/DataTypes.cs @@ -19,6 +19,15 @@ public enum HistoryType Worst, } +/// +/// Represents metadata associated with a checkpoint operation. +/// +/// +/// +public record class CheckpointMetadata( + string Name, + int SortKey); + /// /// Key identifying a code path between two checkpoints. /// @@ -35,6 +44,7 @@ public record struct CheckpointTransitionKey( /// /// /// +/// /// /// public record class MethodProfileReport( @@ -42,6 +52,7 @@ public record class MethodProfileReport( long TotalTimes, TimeSpan MeanDuration, TimeSpan? StandardDeviationOfDuration, + ImmutableDictionary FoundCheckpoints, ImmutableDictionary CodePathSummaries, ImmutableDictionary> Histories ) diff --git a/src/PathBench/InvocationProfiler.cs b/src/PathBench/InvocationProfiler.cs index 8b343ea..6e2d47f 100644 --- a/src/PathBench/InvocationProfiler.cs +++ b/src/PathBench/InvocationProfiler.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace PathBench; /// @@ -16,8 +18,9 @@ public abstract class InvocationProfiler : IDisposable /// Marks a checkpoint in the invocation. /// /// + /// /// - public abstract void MarkCheckpoint(string name, object? noteProvider = null); + public abstract void MarkCheckpoint(string name, [CallerLineNumber] int sortKey = -1, object? noteProvider = null); /// /// Creates an invocation measurement summary. diff --git a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs index 2462c17..ca8ce8c 100644 --- a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs +++ b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs @@ -19,7 +19,7 @@ public static MethodProfileReportFormatter CreateGraphvizStyle( GraphvizStyleFormatterOptions? options = null) => new GraphvizStyle_(options ?? GraphvizStyleFormatterOptions.Default); - private sealed class GraphvizStyle_(IGraphvizStyleFormatterOptions options) + internal sealed class GraphvizStyle_(IGraphvizStyleFormatterOptions options) : MethodProfileReportFormatter { private readonly string _fontName = options.FontName; @@ -27,60 +27,55 @@ private sealed class GraphvizStyle_(IGraphvizStyleFormatterOptions options) public override void Format(MethodProfileReport report, TextWriter writer) { writer.WriteLine($$""" - digraph {{Sanitize(report.CounterName)}} { + digraph {{SanitizeIdentifier(report.CounterName)}} { graph [ fontname = "{{_fontName}}", - label = "{{report.CounterName}}", + label = "{{SanitizeLabel(report.CounterName)}}", ]; node [ fontname = "{{_fontName}}", - shape = box, + shape = "box", ]; edge [ fontname = "{{_fontName}}", ]; """); - var checkpoints = new Dictionary(); - foreach(var (key, transition) in report.CodePathSummaries) + 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)) { - if(!checkpoints.TryGetValue(key.StartCheckpoint, out var sanitizedStart)) - { - checkpoints.Add(key.StartCheckpoint, sanitizedStart = Sanitize(key.StartCheckpoint)); - } - if(!checkpoints.TryGetValue(key.EndCheckpoint, out var sanitizedEnd)) - { - checkpoints.Add(key.EndCheckpoint, sanitizedEnd = Sanitize(key.EndCheckpoint)); - } + 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} msec"] """, - sanitizedStart, - sanitizedEnd, + startIdentifier, + endIdentifier, transition.TotalTimes, transition.MeanDuration.TotalMilliseconds); } - - foreach(var (raw, sanitized) in checkpoints) - { - writer.WriteLine($$""" - {{sanitized}} [label = "{{raw}}"] - """); - } - writer.WriteLine(""" } """); } - private static string Sanitize(string name) + public static string SanitizeIdentifier(string name) { var sb = new StringBuilder(); sb.Append("__"); - foreach(var c in name.EnumerateRunes()) + foreach (var c in name.EnumerateRunes()) { - switch(c.Value) + switch (c.Value) { case >= 0x30 and < 0x3A: case >= 0x41 and < 0x5B: @@ -94,6 +89,33 @@ private static string Sanitize(string name) } 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(); + } } } From fb05314f77bfe70975cd78f135a0277138f05cba Mon Sep 17 00:00:00 2001 From: Daishi Nakase Date: Sun, 8 Feb 2026 18:23:56 +0900 Subject: [PATCH 04/14] adds benchmarking --- PathBench.slnx | 1 + src/PathBench/CodePathProfiler.Empty.cs | 55 +++++++ ...odePathProfiler.Impl.InvocationProfiler.cs | 83 ++++++++++ .../CodePathProfiler.Impl.MethodProfiler.cs | 147 ++++++++++++++++++ src/PathBench/CodePathProfiler.Impl.cs | 53 +++++++ .../CodePathProfiler.InvocationProfiler.cs | 79 ---------- .../CodePathProfiler.MethodProfiler.cs | 139 ----------------- src/PathBench/CodePathProfiler.cs | 51 +++--- src/PathBench/DataTypes.cs | 8 +- src/PathBench/InternalHelpers.cs | 32 ---- .../CodePathProfilerBenchmarkContext01.cs | 43 +++++ .../CodePathProfilerBenchmarkContext02.cs | 68 ++++++++ .../PathBench.Benchmark.csproj | 18 +++ tests/PathBench.Benchmark/Program.cs | 5 + tests/PathBench.Sample/Program.cs | 43 +++-- tests/PathBench.Test/CodePathProfilerTest.cs | 4 +- 16 files changed, 536 insertions(+), 293 deletions(-) create mode 100644 src/PathBench/CodePathProfiler.Empty.cs create mode 100644 src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs create mode 100644 src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs create mode 100644 src/PathBench/CodePathProfiler.Impl.cs delete mode 100644 src/PathBench/CodePathProfiler.InvocationProfiler.cs delete mode 100644 src/PathBench/CodePathProfiler.MethodProfiler.cs delete mode 100644 src/PathBench/InternalHelpers.cs create mode 100644 tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext01.cs create mode 100644 tests/PathBench.Benchmark/CodePathProfilerBenchmarkContext02.cs create mode 100644 tests/PathBench.Benchmark/PathBench.Benchmark.csproj create mode 100644 tests/PathBench.Benchmark/Program.cs diff --git a/PathBench.slnx b/PathBench.slnx index 522ce51..dc76432 100644 --- a/PathBench.slnx +++ b/PathBench.slnx @@ -4,6 +4,7 @@ + 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..2c4dc40 --- /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() + { + _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: TimeSpan.FromTicks(end.DurationTimestamp - start.DurationTimestamp))); + return new( + CounterName: Name, + InvocationId: InvocationIndex, + StartAt: StartAt, + ManagedThreadId: ManagedThreadId, + ArgumentsExpression: ArgumentsExpressionProvider?.ToString(), + Duration: TimeSpan.FromTicks(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..0e4e967 --- /dev/null +++ b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs @@ -0,0 +1,147 @@ +using System.Collections.Immutable; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +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; + + 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) + { + using (_lockToken.EnterScope()) + { + var x = (double)invocation.Duration / Owner.TimeProvider.TimestampFrequency; + + // update overall statistics + _overallDurations.IncrementResult(x); + + // update code path report + var checkpoints = CollectionsMarshal.AsSpan(invocation.Checkpoints); + 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) / Owner.TimeProvider.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; + InvocationProfiler_[] recentHistory; + InvocationProfiler_[] worstHistory; + var foundCheckpoints = ImmutableDictionary.CreateBuilder(); + var codePathSummaries = ImmutableDictionary.CreateBuilder(); + using (_lockToken.EnterScope()) + { + (times, mean_sec, sd_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: TimeSpan.FromSeconds(mean_sec), + StandardDeviationOfDuration: double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_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) = _durations; + return new(Key, times, TimeSpan.FromSeconds(mean_sec), double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_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..4f6e3bd --- /dev/null +++ b/src/PathBench/CodePathProfiler.Impl.cs @@ -0,0 +1,53 @@ +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 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) + { + if(!_methodCounters.TryGetValue(methodName, out var methodCounter)) + { + _methodCounters.Add(methodName, methodCounter = new(this, methodName)); + } + 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.InvocationProfiler.cs b/src/PathBench/CodePathProfiler.InvocationProfiler.cs deleted file mode 100644 index a33a84b..0000000 --- a/src/PathBench/CodePathProfiler.InvocationProfiler.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace PathBench; - -partial class CodePathProfiler -{ - private sealed class InvocationProfiler_ : InvocationProfiler - { - private MethodProfiler 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 InvocationProfiler_( - MethodProfiler owner, - string? methodName, - long invocationIndex, - object? argumentsExpressionProvider) - { - Owner = owner; - MethodName = methodName; - StartAt = Owner.Owner.TimeProvider.GetUtcNow(); - InvocationIndex = invocationIndex; - ArgumentsExpressionProvider = argumentsExpressionProvider; - ManagedThreadId = Environment.CurrentManagedThreadId; - _startAtTimestamp = owner.Owner.TimeProvider.GetTimestamp(); - - _checkpoints = []; - MarkCheckpoint(StartCheckpointName, int.MinValue, null); - } - - public override void Dispose() - { - _endAtTimestamp = Owner.Owner.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, Owner.Owner.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: TimeSpan.FromTicks(end.DurationTimestamp - start.DurationTimestamp))); - return new( - CounterName: Owner.Name, - InvocationId: InvocationIndex, - StartAt: StartAt, - ManagedThreadId: ManagedThreadId, - ArgumentsExpression: ArgumentsExpressionProvider?.ToString(), - Duration: TimeSpan.FromTicks(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 readonly record struct CheckpointMeasurement( - string Name, - int SortKey, - object? NoteProvider, - long DurationTimestamp); -} \ No newline at end of file diff --git a/src/PathBench/CodePathProfiler.MethodProfiler.cs b/src/PathBench/CodePathProfiler.MethodProfiler.cs deleted file mode 100644 index 530515e..0000000 --- a/src/PathBench/CodePathProfiler.MethodProfiler.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System.Collections.Immutable; - -namespace PathBench; - -partial class CodePathProfiler -{ - private class MethodProfiler(CodePathProfiler 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 = new(); - private readonly Queue _RecentHistory = new(owner._recentHistoryCacheSize); - private readonly SortedList _WorstHistory = new(owner._worstHistoryCacheSize, WorstHistoryKeyComparer.Instance); - - private long _invocationCount = 0; - private WelfordStatistics _overallDurations; - - public CodePathProfiler 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, - methodName, - id, - argumentsExpressionProvider); - return invocation; - } - - - public void TerminateInvocation(InvocationProfiler_ invocation) - { - using (_lockToken.EnterScope()) - { - var x = (double)invocation.Duration / Owner.TimeProvider.TimestampFrequency; - - // update overall statistics - _overallDurations.IncrementResult(x); - - // update code path report - foreach(var (start, end) in invocation.Checkpoints.Zip(invocation.Checkpoints.Skip(1))) - { - var key = new CheckpointTransitionKey(start.Name, end.Name); - var y = (double)(end.DurationTimestamp - start.DurationTimestamp) / Owner.TimeProvider.TimestampFrequency; - if (!_CodePathResults.TryGetValue(key, out var result)) - { - _CodePathResults.Add(key, result = new(key, start.SortKey, end.SortKey)); - } - 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; - InvocationProfiler_[] recentHistory; - InvocationProfiler_[] worstHistory; - var foundCheckpoints =ImmutableDictionary.CreateBuilder(); - var codePathSummaries = ImmutableDictionary.CreateBuilder(); - using (_lockToken.EnterScope()) - { - (times, mean_sec, sd_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: TimeSpan.FromSeconds(mean_sec), - StandardDeviationOfDuration: double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_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) = _durations; - return new(Key, times, TimeSpan.FromSeconds(mean_sec), double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec)); - } - } - } -} diff --git a/src/PathBench/CodePathProfiler.cs b/src/PathBench/CodePathProfiler.cs index b6d0681..0ddfb7b 100644 --- a/src/PathBench/CodePathProfiler.cs +++ b/src/PathBench/CodePathProfiler.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Diagnostics; using System.Runtime.CompilerServices; namespace PathBench; @@ -8,9 +7,7 @@ namespace PathBench; /// Performance measurement tool for counting code paths. /// One instance of this type should be related to one type definition. /// -public partial class CodePathProfiler( - string? className = null, - CodePathProfilerOptions? options = null) +public abstract partial class CodePathProfiler { /// The name of the start checkpoint. public const string StartCheckpointName = "#"; @@ -18,47 +15,39 @@ public partial class CodePathProfiler( /// The name of the end checkpoint. public const string EndCheckpointName = "#"; - private readonly int _recentHistoryCacheSize = (options ?? CodePathProfilerOptions.Default).RecentHistoryCacheSize; - private readonly int _worstHistoryCacheSize = (options ?? CodePathProfilerOptions.Default).WorstHistoryCacheSize; - - private ImmutableDictionary _methodCounters = ImmutableDictionary.Empty; - /// The name of the class which this instance related to. - public string? ClassName { get; } = className ?? new StackFrame(1, false).GetMethod()?.DeclaringType?.FullName; + public abstract string? ClassName { get; } /// The time provider used for measuring time. - public TimeProvider TimeProvider { get; } = options?.TimeProvider ?? TimeProvider.System; + 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 virtual InvocationProfiler StartMeasurement( + public abstract InvocationProfiler StartMeasurement( [CallerMemberName] string methodName = "", - object? argumentsExpressionProvider = null) - { - var methodCounter = InternalHelpers.GetOrAdd( - ref _methodCounters, - methodName, - () => new MethodProfiler(this, methodName)); - var invocation = methodCounter.StartMeasurement(argumentsExpressionProvider); - return invocation; - } + object? argumentsExpressionProvider = null); + /// /// Creates profile reports for all measured methods. /// /// - public virtual ImmutableDictionary CreateProfileReports() - { - var builder = ImmutableDictionary.CreateBuilder(); - foreach (var kvp in _methodCounters) - { - var report = kvp.Value.CreateReport(); - builder.Add(kvp.Key, report); - } - return builder.ToImmutable(); - } + public abstract ImmutableDictionary CreateProfileReports(); } diff --git a/src/PathBench/DataTypes.cs b/src/PathBench/DataTypes.cs index 77de655..1385a11 100644 --- a/src/PathBench/DataTypes.cs +++ b/src/PathBench/DataTypes.cs @@ -35,7 +35,13 @@ public record class CheckpointMetadata( /// public record struct CheckpointTransitionKey( string StartCheckpoint, - string EndCheckpoint); + string EndCheckpoint) +{ + public override int GetHashCode() + { + return StartCheckpoint.GetHashCode() ^ EndCheckpoint.GetHashCode(); + } +} /// /// Summary of method performance measurements. diff --git a/src/PathBench/InternalHelpers.cs b/src/PathBench/InternalHelpers.cs deleted file mode 100644 index 2f29b55..0000000 --- a/src/PathBench/InternalHelpers.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Immutable; - -namespace PathBench; - -internal static class InternalHelpers -{ - public static TValue GetOrAdd( - ref ImmutableDictionary dictionary, - TKey key, - Func factory) - where TKey : notnull - { - while (true) - { - var snapshot = dictionary; - if (snapshot.TryGetValue(key, out var existingValue)) - { - return existingValue; - } - var newValue = factory(); - var newDictionary = snapshot.Add(key, newValue); - var originalDictionary = Interlocked.CompareExchange( - ref dictionary, - newDictionary, - snapshot); - if(ReferenceEquals(originalDictionary, snapshot)) - { - return newValue; - } - } - } -} 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/Program.cs b/tests/PathBench.Sample/Program.cs index 895c039..bb467fd 100644 --- a/tests/PathBench.Sample/Program.cs +++ b/tests/PathBench.Sample/Program.cs @@ -1,20 +1,24 @@ // See https://aka.ms/new-console-template for more information +using System.Runtime.CompilerServices; using PathBench; -await SampleClass.InvokeTest(); -Console.WriteLine("Hello, World!"); +for(var i = 0; i < (1 << 18); ++i) +{ + SampleClass.EmptyWork(); +} +// SampleClass.InvokeTest(); static class SampleClass { - public static readonly CodePathProfiler _Profiler = new(); + public static readonly CodePathProfiler _Profiler = CodePathProfiler.Create(); - public static async Task InvokeTest() + public static void InvokeTest() { - for (var i = 0; i < 200; ++i) + for (var i = 0; i < 1000; ++i) { Console.Write($"\r \r{i}"); - await SimulatedWork(i); + SimulatedWork(i); } Console.WriteLine(); @@ -28,18 +32,31 @@ public static async Task InvokeTest() Console.WriteLine(sw.ToString()); } - private static async Task SimulatedWork(int seed) + public static void EmptyWork() + { + var counter = _Profiler.StartMeasurement(); + try + { + DisturbOptimize(0); + } + finally + { + counter.Dispose(); + } + } + + private static void SimulatedWork(int seed) { using var counter = _Profiler.StartMeasurement(argumentsExpressionProvider: $"seed={seed}"); if (seed % 2 == 0) { counter.MarkCheckpoint("EvenSeed"); - await Task.Delay(10); + DisturbOptimize(2); } else { counter.MarkCheckpoint("OddSeed"); - await Task.Delay(20); + DisturbOptimize(0); } for (var i = 0; i < seed; ++i) { @@ -47,16 +64,24 @@ private static async Task SimulatedWork(int seed) if (seed % 3 == 0) { counter.MarkCheckpoint("DivisibleBy3"); + DisturbOptimize(3); } if (seed % 5 == 0) { counter.MarkCheckpoint("DivisibleBy5"); + DisturbOptimize(5); } if (seed % 7 == 0) { counter.MarkCheckpoint("DivisibleBy7"); + DisturbOptimize(7); } counter.MarkCheckpoint("LoopEnd"); } } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DisturbOptimize(int value) + { + } } \ No newline at end of file diff --git a/tests/PathBench.Test/CodePathProfilerTest.cs b/tests/PathBench.Test/CodePathProfilerTest.cs index 600febc..5430ab0 100644 --- a/tests/PathBench.Test/CodePathProfilerTest.cs +++ b/tests/PathBench.Test/CodePathProfilerTest.cs @@ -33,7 +33,7 @@ public void Profile01(Profile01TimeSet[] timeSet) { static IEnumerable profileTarget(CodePathProfiler codePathProfiler) { - using var profiler = codePathProfiler.StartMeasurement(nameof(profileTarget)); + using var profiler = codePathProfiler.StartMeasurement(); yield return default; profiler.MarkCheckpoint("checkpoint1"); yield return default; @@ -47,7 +47,7 @@ static IEnumerable profileTarget(CodePathProfiler codePathProfiler) const double sdTolerance = 1e-4; var timeProvider = new FakeTimeProvider(); - var codePathProfiler = new CodePathProfiler("SampleClassName", new() { TimeProvider = timeProvider, }); + var codePathProfiler = CodePathProfiler.Create("SampleClassName", new() { TimeProvider = timeProvider, }); for(var k = 0; k < timeSet.Length; ++k) { var time = timeSet[k]; From 067c3150b60653170c1badfd63fbec55b20315e2 Mon Sep 17 00:00:00 2001 From: aka-nse Date: Mon, 9 Feb 2026 01:55:09 +0900 Subject: [PATCH 05/14] fixes method dictionary creation --- .../CodePathProfiler.Impl.MethodProfiler.cs | 1 - src/PathBench/CodePathProfiler.Impl.cs | 16 ++++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs index 0e4e967..89edfbb 100644 --- a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs +++ b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Runtime.InteropServices; -using System.Runtime.InteropServices.Marshalling; namespace PathBench; diff --git a/src/PathBench/CodePathProfiler.Impl.cs b/src/PathBench/CodePathProfiler.Impl.cs index 4f6e3bd..384cee8 100644 --- a/src/PathBench/CodePathProfiler.Impl.cs +++ b/src/PathBench/CodePathProfiler.Impl.cs @@ -11,6 +11,7 @@ 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; } @@ -31,9 +32,20 @@ public override InvocationProfiler StartMeasurement( [CallerMemberName] string methodName = "", object? argumentsExpressionProvider = null) { - if(!_methodCounters.TryGetValue(methodName, out var methodCounter)) + // 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)) { - _methodCounters.Add(methodName, methodCounter = new(this, methodName)); + 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; From 81be6ee4d8c564d49197727f73ad717f905b20a3 Mon Sep 17 00:00:00 2001 From: aka-nse Date: Mon, 9 Feb 2026 01:55:22 +0900 Subject: [PATCH 06/14] supports time scale configuration --- .../MethodProfileReportFormatter.Graphviz.cs | 26 +++++++++- src/PathBench/TimeScale.cs | 52 +++++++++++++++++++ tests/PathBench.Sample/Program.cs | 21 ++------ 3 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 src/PathBench/TimeScale.cs diff --git a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs index ca8ce8c..1e5423b 100644 --- a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs +++ b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs @@ -23,9 +23,22 @@ internal 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.SelectAuto(report.CodePathSummaries.Values.Select(static x => x.MeanDuration)) + : _timeScale; + writer.WriteLine($$""" digraph {{SanitizeIdentifier(report.CounterName)}} { graph [ @@ -56,12 +69,12 @@ edge [ var startIdentifier = checkpointIdentifiers[key.StartCheckpoint]; var endIdentifier = checkpointIdentifiers[key.EndCheckpoint]; writer.WriteLine(""" - {0} -> {1} [label = "{2} times\n{3} msec"] + {0} -> {1} [label = "{2} times\n{3}"] """, startIdentifier, endIdentifier, transition.TotalTimes, - transition.MeanDuration.TotalMilliseconds); + adjustedTimeScale.ToString(transition.MeanDuration)); } writer.WriteLine(""" } @@ -129,6 +142,11 @@ 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; } } /// @@ -139,9 +157,13 @@ 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/TimeScale.cs b/src/PathBench/TimeScale.cs new file mode 100644 index 0000000..825392e --- /dev/null +++ b/src/PathBench/TimeScale.cs @@ -0,0 +1,52 @@ +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, +} + + +internal static class TimeScaleExtensions +{ + extension(TimeScale timeScale) + { + public static TimeScale SelectAuto(IEnumerable timeSpans) + { + var minTime = timeSpans.Where(static x => x > TimeSpan.Zero).Min(); + if (minTime.TotalMilliseconds >= 0.1) + { + return TimeScale.Milliseconds; + } + else if (minTime.TotalMicroseconds >= 0.1) + { + return TimeScale.Microseconds; + } + else + { + return TimeScale.Nanoseconds; + } + } + + public string ToString(TimeSpan time) => + timeScale switch + { + TimeScale.Nanoseconds => $"{time.TotalNanoseconds} nsec", + TimeScale.Microseconds => $"{time.TotalMicroseconds} usec", + TimeScale.Milliseconds => $"{time.TotalMilliseconds} msec", + _ => string.Empty, + }; + } +} \ No newline at end of file diff --git a/tests/PathBench.Sample/Program.cs b/tests/PathBench.Sample/Program.cs index bb467fd..775be09 100644 --- a/tests/PathBench.Sample/Program.cs +++ b/tests/PathBench.Sample/Program.cs @@ -2,11 +2,8 @@ using System.Runtime.CompilerServices; using PathBench; -for(var i = 0; i < (1 << 18); ++i) -{ - SampleClass.EmptyWork(); -} -// SampleClass.InvokeTest(); + +SampleClass.InvokeTest(); static class SampleClass @@ -32,19 +29,6 @@ public static void InvokeTest() Console.WriteLine(sw.ToString()); } - public static void EmptyWork() - { - var counter = _Profiler.StartMeasurement(); - try - { - DisturbOptimize(0); - } - finally - { - counter.Dispose(); - } - } - private static void SimulatedWork(int seed) { using var counter = _Profiler.StartMeasurement(argumentsExpressionProvider: $"seed={seed}"); @@ -83,5 +67,6 @@ private static void SimulatedWork(int seed) [MethodImpl(MethodImplOptions.NoInlining)] private static void DisturbOptimize(int value) { + Thread.SpinWait(value * 100); } } \ No newline at end of file From 98ff952726961d69b6de6c772c247bae9b0b2de9 Mon Sep 17 00:00:00 2001 From: aka-nse Date: Tue, 10 Feb 2026 00:05:57 +0900 Subject: [PATCH 07/14] improves format --- .../CodePathProfiler.Impl.InvocationProfiler.cs | 2 +- src/PathBench/DataTypes.cs | 11 +++++++---- .../MethodProfileReportFormatter.Graphviz.cs | 2 +- .../MethodProfileReportFormatter.Simple.cs | 14 ++++++++++++-- src/PathBench/TimeScale.cs | 2 +- tests/PathBench.Sample/Program.cs | 12 ++++++------ 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs b/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs index 2c4dc40..6152120 100644 --- a/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs +++ b/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs @@ -41,7 +41,7 @@ public InvocationProfiler_( public override void Dispose() { - _endAtTimestamp = TimeProvider.GetTimestamp(); + Volatile.Write(ref _endAtTimestamp, TimeProvider.GetTimestamp()); MarkCheckpoint(EndCheckpointName, int.MaxValue, null); (_freezedCheckpoints, _checkpoints) = (_checkpoints, null); Owner.TerminateInvocation(this); diff --git a/src/PathBench/DataTypes.cs b/src/PathBench/DataTypes.cs index 1385a11..568c95f 100644 --- a/src/PathBench/DataTypes.cs +++ b/src/PathBench/DataTypes.cs @@ -37,10 +37,13 @@ public record struct CheckpointTransitionKey( string StartCheckpoint, string EndCheckpoint) { - public override int GetHashCode() - { - return StartCheckpoint.GetHashCode() ^ EndCheckpoint.GetHashCode(); - } + /// + public override readonly string ToString() => + $"[{StartCheckpoint} -> {EndCheckpoint}]"; + + /// + public override readonly int GetHashCode() => + StartCheckpoint.GetHashCode() ^ EndCheckpoint.GetHashCode(); } /// diff --git a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs index 1e5423b..72e6410 100644 --- a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs +++ b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs @@ -74,7 +74,7 @@ edge [ startIdentifier, endIdentifier, transition.TotalTimes, - adjustedTimeScale.ToString(transition.MeanDuration)); + adjustedTimeScale.GetString(transition.MeanDuration)); } writer.WriteLine(""" } diff --git a/src/PathBench/MethodProfileReportFormatter.Simple.cs b/src/PathBench/MethodProfileReportFormatter.Simple.cs index 05f7782..1455fb3 100644 --- a/src/PathBench/MethodProfileReportFormatter.Simple.cs +++ b/src/PathBench/MethodProfileReportFormatter.Simple.cs @@ -13,15 +13,25 @@ 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.SelectAuto(timeSpans); + writer.WriteLine($"<`{report.CounterName}` profile report>"); writer.WriteLine($" total invocation : {report.TotalTimes}"); - writer.WriteLine($" mean duration : {report.MeanDuration.TotalMilliseconds} msec (SD = {report.StandardDeviationOfDuration?.TotalMilliseconds ?? double.NaN})"); + 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 : {pathSummary.Value.MeanDuration.TotalMilliseconds} msec (SD = {pathSummary.Value.StandardDeviationOfDuration?.TotalMilliseconds ?? double.NaN})"); + writer.WriteLine($" mean duration : {getDurationText(adjustedTimeScale, pathSummary.Value.MeanDuration, pathSummary.Value.StandardDeviationOfDuration)}"); + } + + string getDurationText(TimeScale scale, TimeSpan meanDuration, TimeSpan? standardDeviationOfDuration) + { + var mean = scale.GetString(meanDuration); + var sd = standardDeviationOfDuration.HasValue ? scale.GetString(standardDeviationOfDuration.Value) : "N/A"; + return $"{mean} (SD = {sd})"; } } } diff --git a/src/PathBench/TimeScale.cs b/src/PathBench/TimeScale.cs index 825392e..a06d2a8 100644 --- a/src/PathBench/TimeScale.cs +++ b/src/PathBench/TimeScale.cs @@ -40,7 +40,7 @@ public static TimeScale SelectAuto(IEnumerable timeSpans) } } - public string ToString(TimeSpan time) => + public string GetString(TimeSpan time) => timeScale switch { TimeScale.Nanoseconds => $"{time.TotalNanoseconds} nsec", diff --git a/tests/PathBench.Sample/Program.cs b/tests/PathBench.Sample/Program.cs index 775be09..fb2c278 100644 --- a/tests/PathBench.Sample/Program.cs +++ b/tests/PathBench.Sample/Program.cs @@ -35,12 +35,12 @@ private static void SimulatedWork(int seed) if (seed % 2 == 0) { counter.MarkCheckpoint("EvenSeed"); - DisturbOptimize(2); + Wait(2); } else { counter.MarkCheckpoint("OddSeed"); - DisturbOptimize(0); + Wait(0); } for (var i = 0; i < seed; ++i) { @@ -48,24 +48,24 @@ private static void SimulatedWork(int seed) if (seed % 3 == 0) { counter.MarkCheckpoint("DivisibleBy3"); - DisturbOptimize(3); + Wait(3); } if (seed % 5 == 0) { counter.MarkCheckpoint("DivisibleBy5"); - DisturbOptimize(5); + Wait(5); } if (seed % 7 == 0) { counter.MarkCheckpoint("DivisibleBy7"); - DisturbOptimize(7); + Wait(7); } counter.MarkCheckpoint("LoopEnd"); } } [MethodImpl(MethodImplOptions.NoInlining)] - private static void DisturbOptimize(int value) + private static void Wait(int value) { Thread.SpinWait(value * 100); } From 9377af38d8f80470453b0e1abf6bf328e7c3bcda Mon Sep 17 00:00:00 2001 From: aka-nse Date: Tue, 10 Feb 2026 00:26:34 +0900 Subject: [PATCH 08/14] fixes test --- tests/PathBench.Test/CodePathProfilerTest.cs | 43 +++++++++++--------- tests/PathBench.Test/TestHelpers.cs | 2 +- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/PathBench.Test/CodePathProfilerTest.cs b/tests/PathBench.Test/CodePathProfilerTest.cs index 5430ab0..dceb1b4 100644 --- a/tests/PathBench.Test/CodePathProfilerTest.cs +++ b/tests/PathBench.Test/CodePathProfilerTest.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace PathBench.Test; public class CodePathProfilerTest @@ -31,18 +33,6 @@ public static TheoryData Profile01Data() => [Theory, MemberData(nameof(Profile01Data))] public void Profile01(Profile01TimeSet[] timeSet) { - 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; - } - const double meanTolerance = 1e-6; const double sdTolerance = 1e-4; @@ -52,7 +42,7 @@ static IEnumerable profileTarget(CodePathProfiler codePathProfiler) { var time = timeSet[k]; timeProvider.TimestampMicroseconds = time.X0_us; - var enumerator = profileTarget(codePathProfiler).GetEnumerator(); + var enumerator = ProfileTarget(codePathProfiler).GetEnumerator(); enumerator.MoveNext(); timeProvider.TimestampMicroseconds = time.X1_us; enumerator.MoveNext(); @@ -63,14 +53,14 @@ static IEnumerable profileTarget(CodePathProfiler codePathProfiler) 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 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(profileTarget), out var report)); - Assert.Equal($"SampleClassName.{nameof(profileTarget)}", report.CounterName); + Assert.True(reports.TryGetValue(nameof(ProfileTarget), out var report)); + Assert.Equal($"SampleClassName.{nameof(ProfileTarget)}", report.CounterName); Assert.Equal(statWhole.time, report.TotalTimes); Assert.Equal(statWhole.mean, report.MeanDuration.TotalSeconds, meanTolerance); Assert.Equal(statWhole.sd, report.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); @@ -97,4 +87,17 @@ static IEnumerable profileTarget(CodePathProfiler codePathProfiler) } } + + [MethodImpl(MethodImplOptions.NoInlining)] + private 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/TestHelpers.cs b/tests/PathBench.Test/TestHelpers.cs index e93a2c0..7422665 100644 --- a/tests/PathBench.Test/TestHelpers.cs +++ b/tests/PathBench.Test/TestHelpers.cs @@ -2,7 +2,7 @@ namespace PathBench.Test; internal static class TestHelpers { - public static (long time, double mean, double sd) CalculateMeanAndSd(IEnumerable values) + public static (long time, double mean, double sd) CalculateMeanAndSD(IEnumerable values) { var time = values.Count(); var mean = values.Average(); From 24fc66472bef8a3a003020b1943cf1533c56ae19 Mon Sep 17 00:00:00 2001 From: aka-nse Date: Tue, 10 Feb 2026 01:42:21 +0900 Subject: [PATCH 09/14] migrates time span type into PreciseDuration --- ...odePathProfiler.Impl.InvocationProfiler.cs | 4 +- .../CodePathProfiler.Impl.MethodProfiler.cs | 6 +- src/PathBench/DataTypes.cs | 12 +- .../MethodProfileReportFormatter.Graphviz.cs | 4 +- .../MethodProfileReportFormatter.Simple.cs | 8 +- src/PathBench/PreciseDuration.cs | 208 ++++++++++++++++++ src/PathBench/TimeScale.cs | 50 +++-- tests/PathBench.Test/CodePathProfilerTest.cs | 10 +- tests/PathBench.Test/EqualityComparers.cs | 2 +- tests/PathBench.Test/TimeScaleTest.cs | 25 +++ 10 files changed, 292 insertions(+), 37 deletions(-) create mode 100644 src/PathBench/PreciseDuration.cs create mode 100644 tests/PathBench.Test/TimeScaleTest.cs diff --git a/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs b/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs index 6152120..8157629 100644 --- a/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs +++ b/src/PathBench/CodePathProfiler.Impl.InvocationProfiler.cs @@ -56,14 +56,14 @@ internal protected override InvocationMeasurementReport CreateMeasurementReport( new CheckpointTransitionMeasurementReport( Key: new CheckpointTransitionKey(start.Name, end.Name), Note: start.NoteProvider?.ToString(), - Duration: TimeSpan.FromTicks(end.DurationTimestamp - start.DurationTimestamp))); + Duration: new PreciseDuration(end.DurationTimestamp - start.DurationTimestamp))); return new( CounterName: Name, InvocationId: InvocationIndex, StartAt: StartAt, ManagedThreadId: ManagedThreadId, ArgumentsExpression: ArgumentsExpressionProvider?.ToString(), - Duration: TimeSpan.FromTicks(Duration), + Duration: new PreciseDuration(Duration), CodePathMeasurements: [.. path]); } diff --git a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs index 89edfbb..3029664 100644 --- a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs +++ b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs @@ -117,8 +117,8 @@ public MethodProfileReport CreateReport() return new MethodProfileReport( CounterName: $"{Owner.ClassName}.{methodName}", TotalTimes: times, - MeanDuration: TimeSpan.FromSeconds(mean_sec), - StandardDeviationOfDuration: double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec), + MeanDuration: PreciseDuration.FromSeconds(mean_sec), + StandardDeviationOfDuration: PreciseDuration.FromSeconds(sd_sec), FoundCheckpoints: foundCheckpoints.ToImmutable(), CodePathSummaries: codePathSummaries.ToImmutable(), Histories: histories.ToImmutable()); @@ -138,7 +138,7 @@ public void IncrementResult(double duration_sec) => public CheckpointTransitionProfileReport CreateSummary() { var (times, mean_sec, sd_sec) = _durations; - return new(Key, times, TimeSpan.FromSeconds(mean_sec), double.IsNaN(sd_sec) ? null : TimeSpan.FromSeconds(sd_sec)); + return new(Key, times, PreciseDuration.FromSeconds(mean_sec), PreciseDuration.FromSeconds(sd_sec)); } } } diff --git a/src/PathBench/DataTypes.cs b/src/PathBench/DataTypes.cs index 568c95f..4603403 100644 --- a/src/PathBench/DataTypes.cs +++ b/src/PathBench/DataTypes.cs @@ -59,8 +59,8 @@ public override readonly int GetHashCode() => public record class MethodProfileReport( string CounterName, long TotalTimes, - TimeSpan MeanDuration, - TimeSpan? StandardDeviationOfDuration, + PreciseDuration MeanDuration, + PreciseDuration StandardDeviationOfDuration, ImmutableDictionary FoundCheckpoints, ImmutableDictionary CodePathSummaries, ImmutableDictionary> Histories @@ -87,8 +87,8 @@ public override string ToString() public record class CheckpointTransitionProfileReport( CheckpointTransitionKey Key, long TotalTimes, - TimeSpan MeanDuration, - TimeSpan? StandardDeviationOfDuration); + PreciseDuration MeanDuration, + PreciseDuration StandardDeviationOfDuration); /// /// Summary of one invocation measurement. @@ -106,7 +106,7 @@ public record class InvocationMeasurementReport( DateTimeOffset StartAt, int ManagedThreadId, string? ArgumentsExpression, - TimeSpan Duration, + PreciseDuration Duration, ImmutableArray CodePathMeasurements); /// @@ -118,5 +118,5 @@ public record class InvocationMeasurementReport( public record class CheckpointTransitionMeasurementReport( CheckpointTransitionKey Key, string? Note, - TimeSpan Duration); + PreciseDuration Duration); diff --git a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs index 72e6410..c533d54 100644 --- a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs +++ b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs @@ -36,7 +36,7 @@ public override void Format(MethodProfileReport report, TextWriter writer) { var adjustedTimeScale = _timeScale == TimeScale.Auto - ? TimeScale.SelectAuto(report.CodePathSummaries.Values.Select(static x => x.MeanDuration)) + ? TimeScale.GetBestTimeScaleFor(report.CodePathSummaries.Values.Select(static x => x.MeanDuration)) : _timeScale; writer.WriteLine($$""" @@ -74,7 +74,7 @@ edge [ startIdentifier, endIdentifier, transition.TotalTimes, - adjustedTimeScale.GetString(transition.MeanDuration)); + transition.MeanDuration.ToString(adjustedTimeScale)); } writer.WriteLine(""" } diff --git a/src/PathBench/MethodProfileReportFormatter.Simple.cs b/src/PathBench/MethodProfileReportFormatter.Simple.cs index 1455fb3..6439b77 100644 --- a/src/PathBench/MethodProfileReportFormatter.Simple.cs +++ b/src/PathBench/MethodProfileReportFormatter.Simple.cs @@ -14,7 +14,7 @@ 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.SelectAuto(timeSpans); + var adjustedTimeScale = TimeScale.GetBestTimeScaleFor(timeSpans); writer.WriteLine($"<`{report.CounterName}` profile report>"); writer.WriteLine($" total invocation : {report.TotalTimes}"); @@ -27,10 +27,10 @@ public override void Format(MethodProfileReport report, TextWriter writer) writer.WriteLine($" mean duration : {getDurationText(adjustedTimeScale, pathSummary.Value.MeanDuration, pathSummary.Value.StandardDeviationOfDuration)}"); } - string getDurationText(TimeScale scale, TimeSpan meanDuration, TimeSpan? standardDeviationOfDuration) + string getDurationText(TimeScale scale, PreciseDuration meanDuration, PreciseDuration? standardDeviationOfDuration) { - var mean = scale.GetString(meanDuration); - var sd = standardDeviationOfDuration.HasValue ? scale.GetString(standardDeviationOfDuration.Value) : "N/A"; + var mean = meanDuration.ToString(scale); + var sd = standardDeviationOfDuration.HasValue ? standardDeviationOfDuration.Value.ToString(scale) : "N/A"; return $"{mean} (SD = {sd})"; } } diff --git a/src/PathBench/PreciseDuration.cs b/src/PathBench/PreciseDuration.cs new file mode 100644 index 0000000..5877195 --- /dev/null +++ b/src/PathBench/PreciseDuration.cs @@ -0,0 +1,208 @@ +using System.Numerics; + +namespace PathBench; + +/// +/// Provides pico-order precise duration representation. +/// +public readonly struct PreciseDuration(long ticks) + : IEquatable + , IComparable + , IComparisonOperators + , IAdditionOperators + , ISubtractionOperators +{ + /// + /// Represents a Not-a-Number (NaN) value of ticks in . + /// + public const long NaNValue = long.MinValue; + + /// + /// Represents positive infinity value of ticks in . + /// + public const long PositiveInfinityValue = long.MaxValue; + + /// + /// Represents negative infinity value of ticks in . + /// + public const long NegativeInfinityValue = long.MinValue + 1; + + /// + /// Represents the minimum finite value of ticks in . + /// + public const long MaxValue = long.MaxValue - 1; + + /// + /// Represents the maximum finite value of ticks in . + /// + public const long MinValue = 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); + + /// + /// Gets the number of ticks that represent the value of the current TimeSpan structure. + /// + public long Ticks { get; } = ticks; + + private double DoubleTicks => + Ticks switch + { + NaNValue => double.NaN, + PositiveInfinityValue => double.PositiveInfinity, + NegativeInfinityValue => 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) => + value switch + { + double.NaN => NaNValue, + double.PositiveInfinity => PositiveInfinityValue, + double.NegativeInfinity => NegativeInfinityValue, + _ => (long)(value * scale), + }; + + /// + /// 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)); + + /// + public override string ToString() => ToString(TimeScale.Auto); + + /// + /// Gets the string representation of the given time span in the specified time scale. + /// + /// + /// + public string ToString(TimeScale timeScale) => + timeScale switch + { + TimeScale.Nanoseconds => $"{TotalNanoseconds} nsec", + TimeScale.Microseconds => $"{TotalMicroseconds} usec", + TimeScale.Milliseconds => $"{TotalMilliseconds} msec", + TimeScale.Auto => ToString(TimeScale.GetBestTimeScaleFor(this)), + _ => string.Empty, + }; + + /// + 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 left, PreciseDuration right) => + new(left.Ticks + right.Ticks); + + /// + public static PreciseDuration operator -(PreciseDuration left, PreciseDuration right) => + new(left.Ticks - right.Ticks); + + /// + public static bool operator >(PreciseDuration left, PreciseDuration right) => + left.Ticks > right.Ticks; + + /// + public static bool operator >=(PreciseDuration left, PreciseDuration right) => + left.Ticks >= right.Ticks; + + /// + public static bool operator <(PreciseDuration left, PreciseDuration right) => + left.Ticks < right.Ticks; + + /// + public static bool operator <=(PreciseDuration left, PreciseDuration right) => + left.Ticks <= right.Ticks; + + /// + public static bool operator ==(PreciseDuration left, PreciseDuration right) => + left.Ticks == right.Ticks; + + /// + public static bool operator !=(PreciseDuration left, PreciseDuration right) => + left.Ticks != right.Ticks; +} diff --git a/src/PathBench/TimeScale.cs b/src/PathBench/TimeScale.cs index a06d2a8..11e56e1 100644 --- a/src/PathBench/TimeScale.cs +++ b/src/PathBench/TimeScale.cs @@ -19,18 +19,49 @@ public enum TimeScale } -internal static class TimeScaleExtensions +/// +/// Provides extension methods for . +/// +public static class TimeScaleExtensions { extension(TimeScale timeScale) { - public static TimeScale SelectAuto(IEnumerable timeSpans) + /// + /// 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) { - var minTime = timeSpans.Where(static x => x > TimeSpan.Zero).Min(); - if (minTime.TotalMilliseconds >= 0.1) + if (duration.TotalMilliseconds >= 0.1) { return TimeScale.Milliseconds; } - else if (minTime.TotalMicroseconds >= 0.1) + else if (duration.TotalMicroseconds >= 0.1) { return TimeScale.Microseconds; } @@ -39,14 +70,5 @@ public static TimeScale SelectAuto(IEnumerable timeSpans) return TimeScale.Nanoseconds; } } - - public string GetString(TimeSpan time) => - timeScale switch - { - TimeScale.Nanoseconds => $"{time.TotalNanoseconds} nsec", - TimeScale.Microseconds => $"{time.TotalMicroseconds} usec", - TimeScale.Milliseconds => $"{time.TotalMilliseconds} msec", - _ => string.Empty, - }; } } \ No newline at end of file diff --git a/tests/PathBench.Test/CodePathProfilerTest.cs b/tests/PathBench.Test/CodePathProfilerTest.cs index dceb1b4..1378e78 100644 --- a/tests/PathBench.Test/CodePathProfilerTest.cs +++ b/tests/PathBench.Test/CodePathProfilerTest.cs @@ -63,7 +63,7 @@ public void Profile01(Profile01TimeSet[] timeSet) Assert.Equal($"SampleClassName.{nameof(ProfileTarget)}", report.CounterName); Assert.Equal(statWhole.time, report.TotalTimes); Assert.Equal(statWhole.mean, report.MeanDuration.TotalSeconds, meanTolerance); - Assert.Equal(statWhole.sd, report.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); + 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)); @@ -80,10 +80,10 @@ public void Profile01(Profile01TimeSet[] timeSet) 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 ?? double.NaN, sdTolerance); - Assert.Equal(statSection_1_2.sd, trn2.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); - Assert.Equal(statSection_2_3.sd, trn3.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); - Assert.Equal(statSection_3_e.sd, trn4.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, sdTolerance); + 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); } } diff --git a/tests/PathBench.Test/EqualityComparers.cs b/tests/PathBench.Test/EqualityComparers.cs index f0ba099..e9020dc 100644 --- a/tests/PathBench.Test/EqualityComparers.cs +++ b/tests/PathBench.Test/EqualityComparers.cs @@ -31,7 +31,7 @@ public bool Equals(CheckpointTransitionProfileReport? x, CheckpointTransitionPro { return false; } - if (!RealComparer.Equals(x.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, y.StandardDeviationOfDuration?.TotalSeconds ?? double.NaN, 1e-12)) + if (!RealComparer.Equals(x.StandardDeviationOfDuration.TotalSeconds, y.StandardDeviationOfDuration.TotalSeconds, 1e-12)) { return false; } diff --git a/tests/PathBench.Test/TimeScaleTest.cs b/tests/PathBench.Test/TimeScaleTest.cs new file mode 100644 index 0000000..91021a0 --- /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.Milliseconds }, + { new PreciseDuration[] { f(0.1), f(0.2) }, TimeScale.Milliseconds }, + { 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); + } +} From 3a17e96625f424ee246e76f01444cb6e20d1c496 Mon Sep 17 00:00:00 2001 From: aka-nse Date: Sun, 15 Feb 2026 17:31:06 +0900 Subject: [PATCH 10/14] updates PreciseDuration --- src/PathBench/PreciseDuration.cs | 148 +++++++++++++++++--- tests/PathBench.Test/PreciseDurationTest.cs | 127 +++++++++++++++++ 2 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 tests/PathBench.Test/PreciseDurationTest.cs diff --git a/src/PathBench/PreciseDuration.cs b/src/PathBench/PreciseDuration.cs index 5877195..180ddf1 100644 --- a/src/PathBench/PreciseDuration.cs +++ b/src/PathBench/PreciseDuration.cs @@ -67,6 +67,26 @@ public readonly struct PreciseDuration(long ticks) /// public long Ticks { get; } = ticks; + /// + /// Gets a value indicating whether this instance is not a number (NaN). + /// + public bool IsNaN => Ticks == NaNValue; + + /// + /// Gets a value indicating whether this instance represents positive infinity. + /// + public bool IsPositiveInfinity => Ticks == PositiveInfinityValue; + + /// + /// Gets a value indicating whether this instance represents negative infinity. + /// + public bool IsNegativeInfinity => Ticks == NegativeInfinityValue; + + /// + /// Gets a value indicating whether this instance represents infinity (positive or negative). + /// + public bool IsInfinity => IsPositiveInfinity || IsNegativeInfinity; + private double DoubleTicks => Ticks switch { @@ -175,34 +195,130 @@ public override bool Equals(object? other) => public int CompareTo(PreciseDuration other) => Ticks.CompareTo(other.Ticks); /// - public static PreciseDuration operator +(PreciseDuration left, PreciseDuration right) => - new(left.Ticks + right.Ticks); + public static PreciseDuration operator +(PreciseDuration x, PreciseDuration y) + { + if(x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return new(NaNValue); + } + if(x.Ticks == PositiveInfinityValue && y.Ticks == NegativeInfinityValue) + { + return new(NaNValue); + } + if(x.Ticks == NegativeInfinityValue && y.Ticks == PositiveInfinityValue) + { + return new(NaNValue); + } + if(x.Ticks == PositiveInfinityValue || y.Ticks == PositiveInfinityValue) + { + return new(PositiveInfinityValue); + } + if(x.Ticks == NegativeInfinityValue || y.Ticks == NegativeInfinityValue) + { + return new(NegativeInfinityValue); + } + if (x.Ticks > 0 && y.Ticks > MaxValue - x.Ticks) + { + return new(PositiveInfinityValue); + } + if (x.Ticks < 0 && y.Ticks < MinValue - x.Ticks) + { + return new(NegativeInfinityValue); + } + return new(x.Ticks + y.Ticks); + } /// - public static PreciseDuration operator -(PreciseDuration left, PreciseDuration right) => - new(left.Ticks - right.Ticks); + public static PreciseDuration operator -(PreciseDuration x, PreciseDuration y) + { + if (x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return new(NaNValue); + } + if (x.Ticks == PositiveInfinityValue && y.Ticks == PositiveInfinityValue) + { + return new(NaNValue); + } + if (x.Ticks == NegativeInfinityValue && y.Ticks == NegativeInfinityValue) + { + return new(NaNValue); + } + if (x.Ticks == PositiveInfinityValue || y.Ticks == NegativeInfinityValue) + { + return new(PositiveInfinityValue); + } + if (x.Ticks == NegativeInfinityValue || y.Ticks == PositiveInfinityValue) + { + return new(NegativeInfinityValue); + } + if (y.Ticks > 0 && x.Ticks < MinValue + y.Ticks) + { + return new(NegativeInfinityValue); + } + if (y.Ticks < 0 && x.Ticks > MaxValue + y.Ticks) + { + return new(PositiveInfinityValue); + } + return new(x.Ticks - y.Ticks); + } /// - public static bool operator >(PreciseDuration left, PreciseDuration right) => - left.Ticks > right.Ticks; + public static bool operator >(PreciseDuration x, PreciseDuration y) + { + if (x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return false; + } + return x.Ticks > y.Ticks; + } /// - public static bool operator >=(PreciseDuration left, PreciseDuration right) => - left.Ticks >= right.Ticks; + public static bool operator >=(PreciseDuration x, PreciseDuration y) + { + if (x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return false; + } + return x.Ticks >= y.Ticks; + } /// - public static bool operator <(PreciseDuration left, PreciseDuration right) => - left.Ticks < right.Ticks; + public static bool operator <(PreciseDuration x, PreciseDuration y) + { + if (x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return false; + } + return x.Ticks < y.Ticks; + } /// - public static bool operator <=(PreciseDuration left, PreciseDuration right) => - left.Ticks <= right.Ticks; + public static bool operator <=(PreciseDuration x, PreciseDuration y) + { + if (x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return false; + } + return x.Ticks <= y.Ticks; + } /// - public static bool operator ==(PreciseDuration left, PreciseDuration right) => - left.Ticks == right.Ticks; + public static bool operator ==(PreciseDuration x, PreciseDuration y) + { + if (x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return false; + } + return x.Ticks == y.Ticks; + } /// - public static bool operator !=(PreciseDuration left, PreciseDuration right) => - left.Ticks != right.Ticks; + public static bool operator !=(PreciseDuration x, PreciseDuration y) + { + if (x.Ticks == NaNValue || y.Ticks == NaNValue) + { + return false; + } + return x.Ticks != y.Ticks; + } } diff --git a/tests/PathBench.Test/PreciseDurationTest.cs b/tests/PathBench.Test/PreciseDurationTest.cs new file mode 100644 index 0000000..d297be5 --- /dev/null +++ b/tests/PathBench.Test/PreciseDurationTest.cs @@ -0,0 +1,127 @@ +namespace PathBench.Test; + +public class PreciseDurationTest +{ +#pragma warning disable IDE1006 + + private const long NaN = PreciseDuration.NaNValue; + private const long PInf = PreciseDuration.PositiveInfinityValue; + private const long NInf = PreciseDuration.NegativeInfinityValue; + private const long Max = PreciseDuration.MaxValue; + private const long Min = PreciseDuration.MinValue; + + + 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); + } +} From 2c43ce356f3b1d48931a6690c94ff785e8d215f3 Mon Sep 17 00:00:00 2001 From: Daishi Nakase Date: Sun, 15 Feb 2026 22:17:27 +0900 Subject: [PATCH 11/14] improves test & adds format features --- src/PathBench/AssemblyInfo.cs | 3 - .../ExcludeFromCodeCoverageAttribute.cs | 4 + .../MethodProfileReportFormatter.Graphviz.cs | 2 +- src/PathBench/PreciseDuration.cs | 260 +++++++++++++----- src/PathBench/TimeScale.cs | 7 + ...thodProfileReportFormatter.GraphvizTest.cs | 24 +- tests/PathBench.Test/PreciseDurationTest.cs | 116 +++++++- tests/PathBench.Test/TimeScaleTest.cs | 4 +- 8 files changed, 344 insertions(+), 76 deletions(-) delete mode 100644 src/PathBench/AssemblyInfo.cs create mode 100644 src/PathBench/ExcludeFromCodeCoverageAttribute.cs diff --git a/src/PathBench/AssemblyInfo.cs b/src/PathBench/AssemblyInfo.cs deleted file mode 100644 index e79aba5..0000000 --- a/src/PathBench/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("PathBench.Test")] 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/MethodProfileReportFormatter.Graphviz.cs b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs index c533d54..1962dcc 100644 --- a/src/PathBench/MethodProfileReportFormatter.Graphviz.cs +++ b/src/PathBench/MethodProfileReportFormatter.Graphviz.cs @@ -19,7 +19,7 @@ public static MethodProfileReportFormatter CreateGraphvizStyle( GraphvizStyleFormatterOptions? options = null) => new GraphvizStyle_(options ?? GraphvizStyleFormatterOptions.Default); - internal sealed class GraphvizStyle_(IGraphvizStyleFormatterOptions options) + private sealed class GraphvizStyle_(IGraphvizStyleFormatterOptions options) : MethodProfileReportFormatter { private readonly string _fontName = options.FontName; diff --git a/src/PathBench/PreciseDuration.cs b/src/PathBench/PreciseDuration.cs index 180ddf1..135be56 100644 --- a/src/PathBench/PreciseDuration.cs +++ b/src/PathBench/PreciseDuration.cs @@ -1,41 +1,43 @@ using System.Numerics; +using System.Text.RegularExpressions; namespace PathBench; /// /// Provides pico-order precise duration representation. /// -public readonly struct PreciseDuration(long ticks) +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 NaNValue = long.MinValue; + public const long NaNTicks = long.MinValue; /// /// Represents positive infinity value of ticks in . /// - public const long PositiveInfinityValue = long.MaxValue; + public const long PositiveInfinityTicks = long.MaxValue; /// /// Represents negative infinity value of ticks in . /// - public const long NegativeInfinityValue = long.MinValue + 1; + public const long NegativeInfinityTicks = long.MinValue + 1; /// /// Represents the minimum finite value of ticks in . /// - public const long MaxValue = long.MaxValue - 1; + public const long MaxTicks = long.MaxValue - 1; /// /// Represents the maximum finite value of ticks in . /// - public const long MinValue = long.MinValue + 2; + public const long MinTicks = long.MinValue + 2; /// /// Ticks per second (1e+12). @@ -62,6 +64,31 @@ public readonly struct PreciseDuration(long ticks) /// 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. /// @@ -70,17 +97,17 @@ public readonly struct PreciseDuration(long ticks) /// /// Gets a value indicating whether this instance is not a number (NaN). /// - public bool IsNaN => Ticks == NaNValue; + public bool IsNaN => Ticks == NaNTicks; /// /// Gets a value indicating whether this instance represents positive infinity. /// - public bool IsPositiveInfinity => Ticks == PositiveInfinityValue; + public bool IsPositiveInfinity => Ticks == PositiveInfinityTicks; /// /// Gets a value indicating whether this instance represents negative infinity. /// - public bool IsNegativeInfinity => Ticks == NegativeInfinityValue; + public bool IsNegativeInfinity => Ticks == NegativeInfinityTicks; /// /// Gets a value indicating whether this instance represents infinity (positive or negative). @@ -90,9 +117,9 @@ public readonly struct PreciseDuration(long ticks) private double DoubleTicks => Ticks switch { - NaNValue => double.NaN, - PositiveInfinityValue => double.PositiveInfinity, - NegativeInfinityValue => double.NegativeInfinity, + NaNTicks => double.NaN, + PositiveInfinityTicks => double.PositiveInfinity, + NegativeInfinityTicks => double.NegativeInfinity, _ => Ticks, }; @@ -116,14 +143,30 @@ public readonly struct PreciseDuration(long ticks) /// public double TotalNanoseconds => DoubleTicks / TicksPerNanosecond; - private static long FromScaledValue(double value, long scale) => - value switch + private static long FromScaledValue(double value, long scale) + { + switch (value) { - double.NaN => NaNValue, - double.PositiveInfinity => PositiveInfinityValue, - double.NegativeInfinity => NegativeInfinityValue, - _ => (long)(value * scale), - }; + 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. @@ -157,25 +200,89 @@ public static PreciseDuration FromMicroseconds(double microseconds) => 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(TimeScale timeScale) => - timeScale switch - { - TimeScale.Nanoseconds => $"{TotalNanoseconds} nsec", - TimeScale.Microseconds => $"{TotalMicroseconds} usec", - TimeScale.Milliseconds => $"{TotalMilliseconds} msec", - TimeScale.Auto => ToString(TimeScale.GetBestTimeScaleFor(this)), - _ => string.Empty, - }; + /// + 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(); @@ -197,33 +304,33 @@ public override bool Equals(object? other) => /// public static PreciseDuration operator +(PreciseDuration x, PreciseDuration y) { - if(x.Ticks == NaNValue || y.Ticks == NaNValue) + if (x.Ticks == NaNTicks || y.Ticks == NaNTicks) { - return new(NaNValue); + return new(NaNTicks); } - if(x.Ticks == PositiveInfinityValue && y.Ticks == NegativeInfinityValue) + if (x.Ticks == PositiveInfinityTicks && y.Ticks == NegativeInfinityTicks) { - return new(NaNValue); + return new(NaNTicks); } - if(x.Ticks == NegativeInfinityValue && y.Ticks == PositiveInfinityValue) + if (x.Ticks == NegativeInfinityTicks && y.Ticks == PositiveInfinityTicks) { - return new(NaNValue); + return new(NaNTicks); } - if(x.Ticks == PositiveInfinityValue || y.Ticks == PositiveInfinityValue) + if (x.Ticks == PositiveInfinityTicks || y.Ticks == PositiveInfinityTicks) { - return new(PositiveInfinityValue); + return new(PositiveInfinityTicks); } - if(x.Ticks == NegativeInfinityValue || y.Ticks == NegativeInfinityValue) + if (x.Ticks == NegativeInfinityTicks || y.Ticks == NegativeInfinityTicks) { - return new(NegativeInfinityValue); + return new(NegativeInfinityTicks); } - if (x.Ticks > 0 && y.Ticks > MaxValue - x.Ticks) + if (x.Ticks > 0 && y.Ticks > MaxTicks - x.Ticks) { - return new(PositiveInfinityValue); + return new(PositiveInfinityTicks); } - if (x.Ticks < 0 && y.Ticks < MinValue - x.Ticks) + if (x.Ticks < 0 && y.Ticks < MinTicks - x.Ticks) { - return new(NegativeInfinityValue); + return new(NegativeInfinityTicks); } return new(x.Ticks + y.Ticks); } @@ -231,33 +338,33 @@ public override bool Equals(object? other) => /// public static PreciseDuration operator -(PreciseDuration x, PreciseDuration y) { - if (x.Ticks == NaNValue || y.Ticks == NaNValue) + if (x.Ticks == NaNTicks || y.Ticks == NaNTicks) { - return new(NaNValue); + return new(NaNTicks); } - if (x.Ticks == PositiveInfinityValue && y.Ticks == PositiveInfinityValue) + if (x.Ticks == PositiveInfinityTicks && y.Ticks == PositiveInfinityTicks) { - return new(NaNValue); + return new(NaNTicks); } - if (x.Ticks == NegativeInfinityValue && y.Ticks == NegativeInfinityValue) + if (x.Ticks == NegativeInfinityTicks && y.Ticks == NegativeInfinityTicks) { - return new(NaNValue); + return new(NaNTicks); } - if (x.Ticks == PositiveInfinityValue || y.Ticks == NegativeInfinityValue) + if (x.Ticks == PositiveInfinityTicks || y.Ticks == NegativeInfinityTicks) { - return new(PositiveInfinityValue); + return new(PositiveInfinityTicks); } - if (x.Ticks == NegativeInfinityValue || y.Ticks == PositiveInfinityValue) + if (x.Ticks == NegativeInfinityTicks || y.Ticks == PositiveInfinityTicks) { - return new(NegativeInfinityValue); + return new(NegativeInfinityTicks); } - if (y.Ticks > 0 && x.Ticks < MinValue + y.Ticks) + if (y.Ticks > 0 && x.Ticks < MinTicks + y.Ticks) { - return new(NegativeInfinityValue); + return new(NegativeInfinityTicks); } - if (y.Ticks < 0 && x.Ticks > MaxValue + y.Ticks) + if (y.Ticks < 0 && x.Ticks > MaxTicks + y.Ticks) { - return new(PositiveInfinityValue); + return new(PositiveInfinityTicks); } return new(x.Ticks - y.Ticks); } @@ -265,7 +372,7 @@ public override bool Equals(object? other) => /// public static bool operator >(PreciseDuration x, PreciseDuration y) { - if (x.Ticks == NaNValue || y.Ticks == NaNValue) + if (x.Ticks == NaNTicks || y.Ticks == NaNTicks) { return false; } @@ -275,7 +382,7 @@ public override bool Equals(object? other) => /// public static bool operator >=(PreciseDuration x, PreciseDuration y) { - if (x.Ticks == NaNValue || y.Ticks == NaNValue) + if (x.Ticks == NaNTicks || y.Ticks == NaNTicks) { return false; } @@ -285,7 +392,7 @@ public override bool Equals(object? other) => /// public static bool operator <(PreciseDuration x, PreciseDuration y) { - if (x.Ticks == NaNValue || y.Ticks == NaNValue) + if (x.Ticks == NaNTicks || y.Ticks == NaNTicks) { return false; } @@ -295,7 +402,7 @@ public override bool Equals(object? other) => /// public static bool operator <=(PreciseDuration x, PreciseDuration y) { - if (x.Ticks == NaNValue || y.Ticks == NaNValue) + if (x.Ticks == NaNTicks || y.Ticks == NaNTicks) { return false; } @@ -305,7 +412,7 @@ public override bool Equals(object? other) => /// public static bool operator ==(PreciseDuration x, PreciseDuration y) { - if (x.Ticks == NaNValue || y.Ticks == NaNValue) + if (x.Ticks == NaNTicks || y.Ticks == NaNTicks) { return false; } @@ -315,10 +422,39 @@ public override bool Equals(object? other) => /// public static bool operator !=(PreciseDuration x, PreciseDuration y) { - if (x.Ticks == NaNValue || y.Ticks == NaNValue) + 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 index 11e56e1..8380c17 100644 --- a/src/PathBench/TimeScale.cs +++ b/src/PathBench/TimeScale.cs @@ -16,6 +16,9 @@ public enum TimeScale /// Gets or sets the duration in milliseconds. Milliseconds, + + /// Gets or sets the duration in seconds. + Seconds, } @@ -57,6 +60,10 @@ public static TimeScale GetBestTimeScaleFor(IEnumerable timeSpa /// public static TimeScale GetBestTimeScaleFor(PreciseDuration duration) { + if (duration.TotalSeconds >= 0.1) + { + return TimeScale.Seconds; + } if (duration.TotalMilliseconds >= 0.1) { return TimeScale.Milliseconds; diff --git a/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs b/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs index 4aa0b55..3a461ff 100644 --- a/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs +++ b/tests/PathBench.Test/MethodProfileReportFormatter.GraphvizTest.cs @@ -1,7 +1,25 @@ +using System.Runtime.CompilerServices; + namespace PathBench.Test; -public class MethodProfileReportFormatterGraphvizTest +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() { @@ -21,7 +39,7 @@ public static TheoryData SanitizeIdentifierTestCases() => [Theory, MemberData(nameof(SanitizeIdentifierTestCases))] public void SanitizeIdentifier(string input, string expected) { - var actual = MethodProfileReportFormatter.GraphvizStyle_.SanitizeIdentifier(input); + var actual = PrivateAccess.SanitizeIdentifier(null, input); Assert.Equal(expected, actual); } @@ -45,7 +63,7 @@ public static TheoryData SanitizeLabelTestCases() => [Theory, MemberData(nameof(SanitizeLabelTestCases))] public void SanitizeLabel(string input, string expected) { - var actual = MethodProfileReportFormatter.GraphvizStyle_.SanitizeLabel(input); + var actual = PrivateAccess.SanitizeLabel(null, input); Assert.Equal(expected, actual); } } diff --git a/tests/PathBench.Test/PreciseDurationTest.cs b/tests/PathBench.Test/PreciseDurationTest.cs index d297be5..da8db66 100644 --- a/tests/PathBench.Test/PreciseDurationTest.cs +++ b/tests/PathBench.Test/PreciseDurationTest.cs @@ -1,14 +1,103 @@ +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)); + } - private const long NaN = PreciseDuration.NaNValue; - private const long PInf = PreciseDuration.PositiveInfinityValue; - private const long NInf = PreciseDuration.NegativeInfinityValue; - private const long Max = PreciseDuration.MaxValue; - private const long Min = PreciseDuration.MinValue; + [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() => @@ -124,4 +213,21 @@ public static void Compare(long ticks1, long ticks2, bool expectedLess, bool exp 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/TimeScaleTest.cs b/tests/PathBench.Test/TimeScaleTest.cs index 91021a0..e339f99 100644 --- a/tests/PathBench.Test/TimeScaleTest.cs +++ b/tests/PathBench.Test/TimeScaleTest.cs @@ -8,8 +8,8 @@ public static TheoryData GetBestTimeScaleForTestCa return new() { - { new PreciseDuration[] { f(1), f(2) }, TimeScale.Milliseconds }, - { new PreciseDuration[] { f(0.1), f(0.2) }, TimeScale.Milliseconds }, + { 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 }, From d5babafc6c982bfe3594382d9642f74c46c534d1 Mon Sep 17 00:00:00 2001 From: Daishi Nakase Date: Mon, 16 Feb 2026 21:18:31 +0900 Subject: [PATCH 12/14] adds multi threading tests --- .../CodePathProfiler.Impl.MethodProfiler.cs | 9 +- .../CodePathProfilerTest.MultiThreading.cs | 92 +++++++++++++++++++ tests/PathBench.Test/CodePathProfilerTest.cs | 52 ++++++----- tests/PathBench.Test/FakeTimeProvider.cs | 8 +- 4 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 tests/PathBench.Test/CodePathProfilerTest.MultiThreading.cs diff --git a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs index 3029664..2d6f827 100644 --- a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs +++ b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs @@ -30,6 +30,8 @@ public int Compare(WorstHistoryKey x, WorstHistoryKey y) private long _invocationCount = 0; private WelfordStatistics _overallDurations; + private long TimestampFrequency => Owner.TimeProvider.TimestampFrequency; + public Impl_ Owner => owner; public string Name { get; } = $"{owner.ClassName}.{methodName}"; @@ -47,21 +49,20 @@ public InvocationProfiler StartMeasurement(object? argumentsExpressionProvider = public void TerminateInvocation(InvocationProfiler_ invocation) { + var x = (double)invocation.Duration / TimestampFrequency; + var checkpoints = CollectionsMarshal.AsSpan(invocation.Checkpoints); using (_lockToken.EnterScope()) { - var x = (double)invocation.Duration / Owner.TimeProvider.TimestampFrequency; - // update overall statistics _overallDurations.IncrementResult(x); // update code path report - var checkpoints = CollectionsMarshal.AsSpan(invocation.Checkpoints); 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) / Owner.TimeProvider.TimestampFrequency; + var y = (double)(end.DurationTimestamp - start.DurationTimestamp) / TimestampFrequency; if (!_codePathResults.TryGetValue(key, out var result)) { result = new(key, start.SortKey, end.SortKey); 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 index 1378e78..ea8efb0 100644 --- a/tests/PathBench.Test/CodePathProfilerTest.cs +++ b/tests/PathBench.Test/CodePathProfilerTest.cs @@ -2,8 +2,11 @@ namespace PathBench.Test; -public class CodePathProfilerTest +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() => @@ -28,21 +31,26 @@ public static TheoryData Profile01Data() => 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) { - const double meanTolerance = 1e-6; - const double sdTolerance = 1e-4; - var timeProvider = new FakeTimeProvider(); - var codePathProfiler = CodePathProfiler.Create("SampleClassName", new() { TimeProvider = timeProvider, }); + 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 = ProfileTarget(codePathProfiler).GetEnumerator(); + var enumerator = TestTarget.ProfileTarget(codePathProfiler).GetEnumerator(); enumerator.MoveNext(); timeProvider.TimestampMicroseconds = time.X1_us; enumerator.MoveNext(); @@ -59,11 +67,11 @@ public void Profile01(Profile01TimeSet[] timeSet) 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(ProfileTarget), out var report)); - Assert.Equal($"SampleClassName.{nameof(ProfileTarget)}", report.CounterName); + 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.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)); @@ -76,20 +84,22 @@ public void Profile01(Profile01TimeSet[] timeSet) 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); + 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)] - private static IEnumerable ProfileTarget(CodePathProfiler codePathProfiler) + public static IEnumerable ProfileTarget(CodePathProfiler codePathProfiler) { using var profiler = codePathProfiler.StartMeasurement(); yield return default; @@ -100,4 +110,4 @@ private static IEnumerable ProfileTarget(CodePathProfiler codePathPr profiler.MarkCheckpoint("checkpoint3"); yield return default; } -} +} \ No newline at end of file diff --git a/tests/PathBench.Test/FakeTimeProvider.cs b/tests/PathBench.Test/FakeTimeProvider.cs index 107bf33..c808f1a 100644 --- a/tests/PathBench.Test/FakeTimeProvider.cs +++ b/tests/PathBench.Test/FakeTimeProvider.cs @@ -2,7 +2,13 @@ namespace PathBench.Test; public class FakeTimeProvider : TimeProvider { - public long TimestampMicroseconds { get; set; } = 0; + 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; From 95a262721c2d397023f080f6fa9d889a0a4f0122 Mon Sep 17 00:00:00 2001 From: Daishi Nakase Date: Mon, 16 Feb 2026 22:01:00 +0900 Subject: [PATCH 13/14] updates readme --- PathBench.slnx | 13 ++- README.md | 88 +++++++++++++++ resources/GraphvizSample.svg | 204 +++++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 resources/GraphvizSample.svg diff --git a/PathBench.slnx b/PathBench.slnx index dc76432..cb18f64 100644 --- a/PathBench.slnx +++ b/PathBench.slnx @@ -1,5 +1,16 @@ - + + + + + + + + + + + + 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 + + + From 4a4f29631cc0431931e9cc4f261289aa08d123af Mon Sep 17 00:00:00 2001 From: Daishi Nakase Date: Mon, 16 Feb 2026 22:01:32 +0900 Subject: [PATCH 14/14] appends min/max statistics --- .../CodePathProfiler.Impl.MethodProfiler.cs | 15 +++++-- src/PathBench/DataTypes.cs | 10 ++++- src/PathBench/WelfordStatistics.cs | 10 ++++- tests/PathBench.Sample/Program.cs | 43 ++++++++----------- 4 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs index 2d6f827..86c4e30 100644 --- a/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs +++ b/src/PathBench/CodePathProfiler.Impl.MethodProfiler.cs @@ -96,13 +96,15 @@ 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) = _overallDurations; + (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)); @@ -120,6 +122,8 @@ public MethodProfileReport CreateReport() 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()); @@ -138,8 +142,13 @@ public void IncrementResult(double duration_sec) => public CheckpointTransitionProfileReport CreateSummary() { - var (times, mean_sec, sd_sec) = _durations; - return new(Key, times, PreciseDuration.FromSeconds(mean_sec), PreciseDuration.FromSeconds(sd_sec)); + 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)); } } } diff --git a/src/PathBench/DataTypes.cs b/src/PathBench/DataTypes.cs index 4603403..ac1b4b1 100644 --- a/src/PathBench/DataTypes.cs +++ b/src/PathBench/DataTypes.cs @@ -53,6 +53,8 @@ public override readonly int GetHashCode() => /// /// /// +/// +/// /// /// /// @@ -61,6 +63,8 @@ public record class MethodProfileReport( long TotalTimes, PreciseDuration MeanDuration, PreciseDuration StandardDeviationOfDuration, + PreciseDuration MaxDuration, + PreciseDuration MinDuration, ImmutableDictionary FoundCheckpoints, ImmutableDictionary CodePathSummaries, ImmutableDictionary> Histories @@ -84,11 +88,15 @@ public override string ToString() /// /// /// +/// +/// public record class CheckpointTransitionProfileReport( CheckpointTransitionKey Key, long TotalTimes, PreciseDuration MeanDuration, - PreciseDuration StandardDeviationOfDuration); + PreciseDuration StandardDeviationOfDuration, + PreciseDuration MaxDuration, + PreciseDuration MinDuration); /// /// Summary of one invocation measurement. diff --git a/src/PathBench/WelfordStatistics.cs b/src/PathBench/WelfordStatistics.cs index 2bde1b9..0a4701e 100644 --- a/src/PathBench/WelfordStatistics.cs +++ b/src/PathBench/WelfordStatistics.cs @@ -1,10 +1,12 @@ namespace PathBench; -internal struct WelfordStatistics +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) { @@ -14,14 +16,18 @@ public void IncrementResult(double x) _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) + 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.Sample/Program.cs b/tests/PathBench.Sample/Program.cs index fb2c278..14c4b7c 100644 --- a/tests/PathBench.Sample/Program.cs +++ b/tests/PathBench.Sample/Program.cs @@ -1,37 +1,29 @@ -// See https://aka.ms/new-console-template for more information -using System.Runtime.CompilerServices; using PathBench; +invokeTest(); -SampleClass.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 InvokeTest() - { - for (var i = 0; i < 1000; ++i) - { - Console.Write($"\r \r{i}"); - SimulatedWork(i); - } - Console.WriteLine(); - - var reports = _Profiler.CreateProfileReports(); - Console.WriteLine(reports[nameof(SimulatedWork)]); - Console.WriteLine(); - var sw = new StringWriter(); - MethodProfileReportFormatter.DefaultGraphvizStyle.Format( - reports[nameof(SimulatedWork)], - writer: sw); - Console.WriteLine(sw.ToString()); - } + public static readonly CodePathProfiler Profiler = CodePathProfiler.Create(); - private static void SimulatedWork(int seed) + public static void SimulatedWork(int seed) { - using var counter = _Profiler.StartMeasurement(argumentsExpressionProvider: $"seed={seed}"); + using var counter = Profiler.StartMeasurement(argumentsExpressionProvider: $"seed={seed}"); if (seed % 2 == 0) { counter.MarkCheckpoint("EvenSeed"); @@ -64,7 +56,6 @@ private static void SimulatedWork(int seed) } } - [MethodImpl(MethodImplOptions.NoInlining)] private static void Wait(int value) { Thread.SpinWait(value * 100);