diff --git a/src/BenchmarkDotNet.Diagnostics.Windows/EtwProfiler.cs b/src/BenchmarkDotNet.Diagnostics.Windows/EtwProfiler.cs index 57f57d9e1a..5133f9837f 100644 --- a/src/BenchmarkDotNet.Diagnostics.Windows/EtwProfiler.cs +++ b/src/BenchmarkDotNet.Diagnostics.Windows/EtwProfiler.cs @@ -92,10 +92,18 @@ public void DisplayResults(ILogger logger) private void Start(DiagnoserActionParameters parameters) { - var counters = benchmarkToCounters[parameters.BenchmarkCase] = parameters.Config + // Collect both built-in hardware counters and custom counters + var hardwareCountersList = parameters.Config .GetHardwareCounters() .Select(counter => HardwareCounters.FromCounter(counter, config.IntervalSelectors.TryGetValue(counter, out var selector) ? selector : GetInterval)) - .ToArray(); + .ToList(); + + var customCountersList = parameters.Config + .GetCustomCounters() + .Select(customCounter => HardwareCounters.FromCustomCounter(customCounter, GetInterval)) + .ToList(); + + var counters = benchmarkToCounters[parameters.BenchmarkCase] = hardwareCountersList.Concat(customCountersList).ToArray(); if (counters.Any()) // we need to enable the counters before starting the kernel session HardwareCounters.Enable(counters); @@ -145,11 +153,16 @@ private IReadOnlyDictionary BuildPmcStats() foreach (var benchmarkToCounter in benchmarkToCounters) { - var uniqueCounters = benchmarkToCounter.Value.Select(x => x.Counter).Distinct().ToImmutableArray(); + var allCounters = benchmarkToCounter.Value; + var builtInCounters = allCounters.Where(x => x.Counter != HardwareCounter.NotSet).ToList(); + var customCounters = allCounters.Where(x => x.CustomCounter != null).ToList(); + + var uniqueHwCounters = builtInCounters.Select(x => x.Counter).Distinct().ToImmutableArray(); var pmcStats = new PmcStats( - uniqueCounters, - counter => benchmarkToCounter.Value.Single(pmc => pmc.Counter == counter) + uniqueHwCounters, + customCounters, + counter => builtInCounters.Single(pmc => pmc.Counter == counter) ); builder.Add(benchmarkToCounter.Key, pmcStats); diff --git a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs index d159e72a65..e1ae47a843 100644 --- a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs +++ b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs @@ -38,7 +38,10 @@ public static IEnumerable Validate(ValidationParameters validat yield break; } - if (!validationParameters.Config.GetHardwareCounters().Any() && mandatory) + var hasHardwareCounters = validationParameters.Config.GetHardwareCounters().Any(); + var hasCustomCounters = validationParameters.Config.GetCustomCounters().Any(); + + if (!hasHardwareCounters && !hasCustomCounters && mandatory) { yield return new ValidationError(true, "No Hardware Counters defined, probably a bug"); yield break; @@ -64,6 +67,18 @@ public static IEnumerable Validate(ValidationParameters validat yield return new ValidationError(true, $"The counter {counterName} is not available. Please make sure you are Windows 8+ without Hyper-V"); } + // Validate custom counters + foreach (var customCounter in validationParameters.Config.GetCustomCounters() + .Where(c => !availableCpuCounters.ContainsKey(c.ProfileSourceName))) + { + var availableCounterNames = availableCpuCounters.Keys.ToList(); + var displayedCounterNames = string.Join(", ", availableCounterNames.Take(20)); + var suffix = availableCounterNames.Count > 20 ? $" (and {availableCounterNames.Count - 20} more)" : string.Empty; + yield return new ValidationError(true, + $"Custom counter '{customCounter.ProfileSourceName}' is not available on this machine. " + + $"Available counters: {displayedCounterNames}{suffix}"); + } + foreach (var benchmark in validationParameters.Benchmarks) { if (benchmark.Job.Infrastructure.TryGetToolchain(out var toolchain) && toolchain is InProcessEmitToolchain) @@ -80,6 +95,13 @@ internal static PreciseMachineCounter FromCounter(HardwareCounter counter, Func< return new PreciseMachineCounter(profileSource.ID, profileSource.Name, counter, intervalSelector(profileSource)); } + internal static PreciseMachineCounter FromCustomCounter(CustomCounter customCounter, Func intervalSelector) + { + var profileSource = TraceEventProfileSources.GetInfo()[customCounter.ProfileSourceName]; + + return new PreciseMachineCounter(profileSource.ID, profileSource.Name, customCounter, customCounter.Interval); + } + internal static void Enable(IEnumerable counters) { TraceEventProfileSources.Set( // it's a must have to get the events enabled!! diff --git a/src/BenchmarkDotNet.Diagnostics.Windows/Sessions.cs b/src/BenchmarkDotNet.Diagnostics.Windows/Sessions.cs index f0f5dcc475..2255a29730 100644 --- a/src/BenchmarkDotNet.Diagnostics.Windows/Sessions.cs +++ b/src/BenchmarkDotNet.Diagnostics.Windows/Sessions.cs @@ -69,7 +69,7 @@ internal override Session EnableProviders() | KernelTraceEventParser.Keywords.ImageLoad // handles stack frames from native modules, SUPER IMPORTANT! | KernelTraceEventParser.Keywords.Profile; // CPU stacks - if (Details.Config.GetHardwareCounters().Any()) + if (Details.Config.GetHardwareCounters().Any() || Details.Config.GetCustomCounters().Any()) keywords |= KernelTraceEventParser.Keywords.PMCProfile; // Precise Machine Counters TraceEventSession.StackCompression = true; diff --git a/src/BenchmarkDotNet/Attributes/CustomCountersAttribute.cs b/src/BenchmarkDotNet/Attributes/CustomCountersAttribute.cs new file mode 100644 index 0000000000..695818ef88 --- /dev/null +++ b/src/BenchmarkDotNet/Attributes/CustomCountersAttribute.cs @@ -0,0 +1,40 @@ +using System; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using JetBrains.Annotations; + +namespace BenchmarkDotNet.Attributes +{ + /// + /// Specifies custom hardware counters to be collected during benchmarking. + /// Use this when the predefined HardwareCounter enum values don't match the counters + /// available on your machine (e.g., AMD-specific counters like DcacheMisses, IcacheMisses). + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)] + public class CustomCountersAttribute : Attribute, IConfigSource + { + // CLS-Compliant Code requires a constructor without an array in the argument list + [PublicAPI] + protected CustomCountersAttribute() + { + Config = ManualConfig.CreateEmpty(); + } + + /// + /// Creates a CustomCountersAttribute with the specified counter names. + /// Counter names must match the ETW profile source names available on the machine. + /// Use TraceEventProfileSources.GetInfo().Keys to discover available counters. + /// + /// The ETW profile source names (e.g., "DcacheMisses", "IcacheMisses") + public CustomCountersAttribute(params string[] counterNames) + { + var config = ManualConfig.CreateEmpty(); + foreach (var name in counterNames) + { + config.AddCustomCounters(new CustomCounter(name)); + } + Config = config; + } + public IConfig Config { get; } + } +} \ No newline at end of file diff --git a/src/BenchmarkDotNet/Configs/DebugConfig.cs b/src/BenchmarkDotNet/Configs/DebugConfig.cs index 7a013b22b3..3a19067388 100644 --- a/src/BenchmarkDotNet/Configs/DebugConfig.cs +++ b/src/BenchmarkDotNet/Configs/DebugConfig.cs @@ -57,6 +57,7 @@ public abstract class DebugConfig : IConfig public IEnumerable GetDiagnosers() => []; public IEnumerable GetAnalysers() => []; public IEnumerable GetHardwareCounters() => []; + public IEnumerable GetCustomCounters() => []; public IEnumerable GetEventProcessors() => []; public IEnumerable GetFilters() => []; public IEnumerable GetColumnHidingRules() => []; diff --git a/src/BenchmarkDotNet/Configs/DefaultConfig.cs b/src/BenchmarkDotNet/Configs/DefaultConfig.cs index 1a9a6f61b2..33a2b7b3df 100644 --- a/src/BenchmarkDotNet/Configs/DefaultConfig.cs +++ b/src/BenchmarkDotNet/Configs/DefaultConfig.cs @@ -119,6 +119,8 @@ public string ArtifactsPath public IEnumerable GetHardwareCounters() => Array.Empty(); + public IEnumerable GetCustomCounters() => Array.Empty(); + public IEnumerable GetFilters() => Array.Empty(); public IEnumerable GetEventProcessors() => Array.Empty(); diff --git a/src/BenchmarkDotNet/Configs/IConfig.cs b/src/BenchmarkDotNet/Configs/IConfig.cs index 9e3bf50ce6..09b7e00fcf 100644 --- a/src/BenchmarkDotNet/Configs/IConfig.cs +++ b/src/BenchmarkDotNet/Configs/IConfig.cs @@ -26,6 +26,7 @@ public interface IConfig IEnumerable GetJobs(); IEnumerable GetValidators(); IEnumerable GetHardwareCounters(); + IEnumerable GetCustomCounters(); IEnumerable GetFilters(); IEnumerable GetLogicalGroupRules(); IEnumerable GetEventProcessors(); diff --git a/src/BenchmarkDotNet/Configs/ImmutableConfig.cs b/src/BenchmarkDotNet/Configs/ImmutableConfig.cs index 6f5e406039..caa98a700d 100644 --- a/src/BenchmarkDotNet/Configs/ImmutableConfig.cs +++ b/src/BenchmarkDotNet/Configs/ImmutableConfig.cs @@ -30,6 +30,7 @@ public sealed class ImmutableConfig : IConfig private readonly ImmutableHashSet validators; private readonly ImmutableHashSet jobs; private readonly ImmutableHashSet hardwareCounters; + private readonly ImmutableHashSet customCounters; private readonly ImmutableHashSet filters; private readonly ImmutableArray rules; private readonly ImmutableHashSet eventProcessors; @@ -39,6 +40,7 @@ internal ImmutableConfig( ImmutableArray uniqueColumnProviders, ImmutableHashSet uniqueLoggers, ImmutableHashSet uniqueHardwareCounters, + ImmutableHashSet uniqueCustomCounters, ImmutableHashSet uniqueDiagnosers, ImmutableArray uniqueExporters, ImmutableHashSet uniqueAnalyzers, @@ -62,6 +64,7 @@ internal ImmutableConfig( columnProviders = uniqueColumnProviders; loggers = uniqueLoggers; hardwareCounters = uniqueHardwareCounters; + customCounters = uniqueCustomCounters; diagnosers = uniqueDiagnosers; exporters = uniqueExporters; analysers = uniqueAnalyzers; @@ -101,6 +104,7 @@ internal ImmutableConfig( public IEnumerable GetJobs() => jobs; public IEnumerable GetValidators() => validators; public IEnumerable GetHardwareCounters() => hardwareCounters; + public IEnumerable GetCustomCounters() => customCounters; public IEnumerable GetFilters() => filters; public IEnumerable GetLogicalGroupRules() => rules; public IEnumerable GetEventProcessors() => eventProcessors; diff --git a/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs b/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs index 4c92635016..0fcd588800 100644 --- a/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs +++ b/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs @@ -42,7 +42,8 @@ public static ImmutableConfig Create(IConfig source) var configAnalyse = new List(); var uniqueHardwareCounters = source.GetHardwareCounters().Where(counter => counter != HardwareCounter.NotSet).ToImmutableHashSet(); - var uniqueDiagnosers = GetDiagnosers(source.GetDiagnosers(), uniqueHardwareCounters); + var uniqueCustomCounters = source.GetCustomCounters().ToImmutableHashSet(); + var uniqueDiagnosers = GetDiagnosers(source.GetDiagnosers(), uniqueHardwareCounters, uniqueCustomCounters); var uniqueExporters = GetExporters(source.GetExporters(), uniqueDiagnosers, configAnalyse); var uniqueAnalyzers = GetAnalysers(source.GetAnalysers(), uniqueDiagnosers); @@ -59,6 +60,7 @@ public static ImmutableConfig Create(IConfig source) uniqueColumnProviders, uniqueLoggers, uniqueHardwareCounters, + uniqueCustomCounters, uniqueDiagnosers, uniqueExporters, uniqueAnalyzers, @@ -81,7 +83,7 @@ public static ImmutableConfig Create(IConfig source) ); } - private static ImmutableHashSet GetDiagnosers(IEnumerable diagnosers, ImmutableHashSet uniqueHardwareCounters) + private static ImmutableHashSet GetDiagnosers(IEnumerable diagnosers, ImmutableHashSet uniqueHardwareCounters, ImmutableHashSet uniqueCustomCounters) { var builder = ImmutableHashSet.CreateBuilder(new TypeComparer()); @@ -89,9 +91,9 @@ private static ImmutableHashSet GetDiagnosers(IEnumerable().Any()) + if ((!uniqueHardwareCounters.IsEmpty || !uniqueCustomCounters.IsEmpty) && !diagnosers.OfType().Any()) { - // if users define hardware counters via [HardwareCounters] we need to dynamically load the right diagnoser + // if users define hardware counters or custom counters (e.g. via [HardwareCounters] or [CustomCounters]), we need to dynamically load the right diagnoser var hardwareCountersDiagnoser = DiagnosersLoader.GetImplementation(); if (hardwareCountersDiagnoser != default(IDiagnoser) && !builder.Contains(hardwareCountersDiagnoser)) diff --git a/src/BenchmarkDotNet/Configs/ManualConfig.cs b/src/BenchmarkDotNet/Configs/ManualConfig.cs index 7ae96b0915..7f88b8144c 100644 --- a/src/BenchmarkDotNet/Configs/ManualConfig.cs +++ b/src/BenchmarkDotNet/Configs/ManualConfig.cs @@ -32,6 +32,7 @@ public class ManualConfig : IConfig private readonly List validators = new List(); private readonly List jobs = new List(); private readonly HashSet hardwareCounters = new HashSet(); + private readonly HashSet customCounters = new HashSet(); private readonly List filters = new List(); private readonly List logicalGroupRules = new List(); private readonly List eventProcessors = new List(); @@ -45,6 +46,7 @@ public class ManualConfig : IConfig public IEnumerable GetValidators() => validators; public IEnumerable GetJobs() => jobs; public IEnumerable GetHardwareCounters() => hardwareCounters; + public IEnumerable GetCustomCounters() => customCounters; public IEnumerable GetFilters() => filters; public IEnumerable GetLogicalGroupRules() => logicalGroupRules; public IEnumerable GetEventProcessors() => eventProcessors; @@ -170,6 +172,12 @@ public ManualConfig AddHardwareCounters(params HardwareCounter[] newHardwareCoun return this; } + public ManualConfig AddCustomCounters(params CustomCounter[] newCustomCounters) + { + customCounters.AddRange(newCustomCounters); + return this; + } + public ManualConfig AddFilter(params IFilter[] newFilters) { filters.AddRange(newFilters); @@ -225,6 +233,7 @@ public void Add(IConfig config) jobs.AddRange(config.GetJobs()); validators.AddRange(config.GetValidators()); hardwareCounters.AddRange(config.GetHardwareCounters()); + customCounters.AddRange(config.GetCustomCounters()); filters.AddRange(config.GetFilters()); eventProcessors.AddRange(config.GetEventProcessors()); Orderer = config.Orderer ?? Orderer; diff --git a/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs b/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs new file mode 100644 index 0000000000..89c574e144 --- /dev/null +++ b/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs @@ -0,0 +1,72 @@ +using System; +using JetBrains.Annotations; + +namespace BenchmarkDotNet.Diagnosers +{ + /// + /// Represents a custom hardware performance counter that can be specified by its ETW profile source name. + /// Use this when the predefined enum values don't match the counters + /// available on your machine (e.g., AMD-specific counters like DcacheMisses, IcacheMisses). + /// Run TraceEventProfileSources.GetInfo().Keys to discover available counters on your system. + /// + public class CustomCounter + { + /// + /// Default sampling interval for custom counters. + /// + public const int DefaultInterval = 1_000_003; + + /// + /// The exact name of the ETW profile source as returned by TraceEventProfileSources.GetInfo(). + /// + [PublicAPI] + public string ProfileSourceName { get; } + + /// + /// A short name used for display in reports and columns. + /// + [PublicAPI] + public string ShortName { get; } + + /// + /// The sampling interval for this counter. + /// + [PublicAPI] + public int Interval { get; } + + /// + /// Indicates whether higher values are better for this counter. + /// Default is false (lower is better, e.g., cache misses). + /// + [PublicAPI] + public bool HigherIsBetter { get; } + + /// + /// Creates a new custom hardware counter. + /// + /// The exact name of the ETW profile source (e.g., "DcacheMisses", "IcacheMisses"). + /// Optional short name for display. If null, uses the profile source name. + /// Sampling interval. If not specified, uses DefaultInterval (1,000,003). + /// Whether higher values are better for this counter. + public CustomCounter(string profileSourceName, string? shortName = null, int interval = DefaultInterval, bool higherIsBetter = false) + { + if (string.IsNullOrWhiteSpace(profileSourceName)) + throw new ArgumentException("Profile source name cannot be null, empty or whitespace.", nameof(profileSourceName)); + + if (interval <= 0) + throw new ArgumentOutOfRangeException(nameof(interval), interval, "Interval must be positive."); + + ProfileSourceName = profileSourceName; + ShortName = shortName ?? profileSourceName; + Interval = interval; + HigherIsBetter = higherIsBetter; + } + + public override string ToString() => ProfileSourceName; + + public override bool Equals(object? obj) + => obj is CustomCounter other && ProfileSourceName == other.ProfileSourceName; + + public override int GetHashCode() => ProfileSourceName.GetHashCode(); + } +} diff --git a/src/BenchmarkDotNet/Diagnosers/PmcMetricDescriptor.cs b/src/BenchmarkDotNet/Diagnosers/PmcMetricDescriptor.cs index 0e6d527018..8a110bc467 100644 --- a/src/BenchmarkDotNet/Diagnosers/PmcMetricDescriptor.cs +++ b/src/BenchmarkDotNet/Diagnosers/PmcMetricDescriptor.cs @@ -8,9 +8,9 @@ internal class PmcMetricDescriptor : IMetricDescriptor internal PmcMetricDescriptor(PreciseMachineCounter counter) { Id = counter.Name; - DisplayName = $"{counter.Name}/Op"; + DisplayName = $"{counter.ShortName}/Op"; Legend = $"Hardware counter '{counter.Name}' per single operation"; - TheGreaterTheBetter = counter.Counter.TheGreaterTheBetter(); + TheGreaterTheBetter = counter.HigherIsBetter; } public string Id { get; } diff --git a/src/BenchmarkDotNet/Diagnosers/PmcStats.cs b/src/BenchmarkDotNet/Diagnosers/PmcStats.cs index d010370e87..0076fcf2e4 100644 --- a/src/BenchmarkDotNet/Diagnosers/PmcStats.cs +++ b/src/BenchmarkDotNet/Diagnosers/PmcStats.cs @@ -8,20 +8,45 @@ public class PmcStats { public long TotalOperations { get; set; } public IReadOnlyDictionary Counters { get; } + public IReadOnlyCollection CustomCounters { get; } private IReadOnlyDictionary CountersByProfileSourceId { get; } public PmcStats() { throw new InvalidOperationException("should never be used"); } public PmcStats(IReadOnlyCollection hardwareCounters, Func factory) + : this(hardwareCounters, Array.Empty(), factory) { - CountersByProfileSourceId = hardwareCounters + } + + public PmcStats(IReadOnlyCollection hardwareCounters, IReadOnlyCollection customCounters, Func factory) + { + var hwCounters = hardwareCounters .Select(factory) - .ToDictionary - ( - counter => counter.ProfileSourceId, - counter => counter - ); - Counters = CountersByProfileSourceId.ToDictionary(c => c.Value.Counter, c => c.Value); + .ToDictionary(counter => counter.ProfileSourceId, counter => counter); + + var customCountersDict = customCounters.ToDictionary(counter => counter.ProfileSourceId, counter => counter); + + // Validate no ProfileSourceId collisions between hardware and custom counters + var overlappingIds = customCountersDict.Keys + .Where(hwCounters.ContainsKey) + .ToArray(); + if (overlappingIds.Length > 0) + { + var collisions = overlappingIds + .Select(id => $"{id} ({hwCounters[id].Counter} / {customCountersDict[id].ShortName})") + .ToArray(); + throw new InvalidOperationException( + $"ProfileSourceId collision detected between hardware and custom counters. " + + $"Colliding counters: {string.Join(", ", collisions)}. " + + $"Remove either the hardware counter or the custom counter with the same profile source."); + } + + CountersByProfileSourceId = hwCounters + .Concat(customCountersDict) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + Counters = hwCounters.ToDictionary(c => c.Value.Counter, c => c.Value); + CustomCounters = customCounters; } internal void Handle(int profileSourceId, ulong instructionPointer) diff --git a/src/BenchmarkDotNet/Diagnosers/PreciseMachineCounter.cs b/src/BenchmarkDotNet/Diagnosers/PreciseMachineCounter.cs index d7553dea54..3bd29b8cc1 100644 --- a/src/BenchmarkDotNet/Diagnosers/PreciseMachineCounter.cs +++ b/src/BenchmarkDotNet/Diagnosers/PreciseMachineCounter.cs @@ -9,9 +9,21 @@ public class PreciseMachineCounter [PublicAPI] public int ProfileSourceId { get; } [PublicAPI] public string Name { get; } [PublicAPI] public HardwareCounter Counter { get; } + [PublicAPI] public CustomCounter? CustomCounter { get; } [PublicAPI] public int Interval { get; } [PublicAPI] public Dictionary PerInstructionPointer { get; } + /// + /// Gets the short name for display purposes. + /// For custom counters, uses the custom short name. For built-in counters, uses the enum's short name. + /// + [PublicAPI] public string ShortName => CustomCounter?.ShortName ?? Counter.ToShortName(); + + /// + /// Gets whether this counter tracks a metric where higher values are better. + /// + [PublicAPI] public bool HigherIsBetter => CustomCounter?.HigherIsBetter ?? Counter.TheGreaterTheBetter(); + public ulong Count { get; private set; } internal PreciseMachineCounter(int profileSourceId, string name, HardwareCounter counter, int interval) @@ -19,6 +31,17 @@ internal PreciseMachineCounter(int profileSourceId, string name, HardwareCounter ProfileSourceId = profileSourceId; Name = name; Counter = counter; + CustomCounter = null; + Interval = interval; + PerInstructionPointer = new Dictionary(capacity: 10000); + } + + internal PreciseMachineCounter(int profileSourceId, string name, CustomCounter customCounter, int interval) + { + ProfileSourceId = profileSourceId; + Name = name; + Counter = HardwareCounter.NotSet; + CustomCounter = customCounter; Interval = interval; PerInstructionPointer = new Dictionary(capacity: 10000); } diff --git a/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs b/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs new file mode 100644 index 0000000000..11bac369b3 --- /dev/null +++ b/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs @@ -0,0 +1,329 @@ +using System; +using System.Linq; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using Xunit; + +namespace BenchmarkDotNet.Tests.Configs +{ + public class CustomCounterTests + { + #region CustomCounter Class Tests + + [Fact] + public void ProfileSourceNameIsSetCorrectly() + { + var counter = new CustomCounter("DCMiss"); + + Assert.Equal("DCMiss", counter.ProfileSourceName); + Assert.Equal("DCMiss", counter.ShortName); // ShortName defaults to ProfileSourceName + Assert.Equal(CustomCounter.DefaultInterval, counter.Interval); + Assert.False(counter.HigherIsBetter); + } + + [Fact] + public void ShortNameIsSetWhenProvided() + { + var counter = new CustomCounter("DCMiss", shortName: "L1D$Miss"); + + Assert.Equal("DCMiss", counter.ProfileSourceName); + Assert.Equal("L1D$Miss", counter.ShortName); + } + + [Fact] + public void IntervalIsSetWhenProvided() + { + var counter = new CustomCounter("DCMiss", interval: 500_000); + + Assert.Equal(500_000, counter.Interval); + } + + [Fact] + public void HigherIsBetterIsSetWhenProvided() + { + var counter = new CustomCounter("BranchInstructions", higherIsBetter: true); + + Assert.True(counter.HigherIsBetter); + } + + [Fact] + public void AllPropertiesAreSetWhenProvided() + { + var counter = new CustomCounter( + "BranchMispredictions", + shortName: "BrMisp", + interval: 100_000, + higherIsBetter: false); + + Assert.Equal("BranchMispredictions", counter.ProfileSourceName); + Assert.Equal("BrMisp", counter.ShortName); + Assert.Equal(100_000, counter.Interval); + Assert.False(counter.HigherIsBetter); + } + + [Fact] + public void NullProfileSourceNameThrows() + { + Assert.Throws(() => new CustomCounter(null!)); + } + + [Fact] + public void EmptyProfileSourceNameThrows() + { + Assert.Throws(() => new CustomCounter("")); + } + + [Fact] + public void WhitespaceProfileSourceNameThrows() + { + Assert.Throws(() => new CustomCounter(" ")); + } + + [Fact] + public void CountersWithSameProfileSourceNameAreEqual() + { + var counter1 = new CustomCounter("DCMiss", shortName: "L1D$Miss"); + var counter2 = new CustomCounter("DCMiss", shortName: "DifferentName"); + + Assert.Equal(counter1, counter2); + Assert.Equal(counter1.GetHashCode(), counter2.GetHashCode()); + } + + [Fact] + public void CountersWithDifferentProfileSourceNameAreNotEqual() + { + var counter1 = new CustomCounter("DCMiss"); + var counter2 = new CustomCounter("ICMiss"); + + Assert.NotEqual(counter1, counter2); + } + + [Fact] + public void SpecialCharactersInNameAreAllowed() + { + var counter = new CustomCounter("L1-D$-Cache_Misses"); + + Assert.Equal("L1-D$-Cache_Misses", counter.ProfileSourceName); + } + + [Fact] + public void VeryLargeIntervalIsAccepted() + { + var counter = new CustomCounter("DCMiss", interval: int.MaxValue); + + Assert.Equal(int.MaxValue, counter.Interval); + } + + [Fact] + public void ZeroIntervalThrows() + { + Assert.Throws(() => new CustomCounter("DCMiss", interval: 0)); + } + + [Fact] + public void NegativeIntervalThrows() + { + Assert.Throws(() => new CustomCounter("DCMiss", interval: -1)); + } + + #endregion + + #region ManualConfig.AddCustomCounters Tests + + [Fact] + public void SingleCounterCanBeAdded() + { + var config = ManualConfig.CreateEmpty(); + + config.AddCustomCounters(new CustomCounter("DCMiss")); + + Assert.Single(config.GetCustomCounters()); + Assert.Equal("DCMiss", config.GetCustomCounters().Single().ProfileSourceName); + } + + [Fact] + public void MultipleCountersCanBeAdded() + { + var config = ManualConfig.CreateEmpty(); + + config.AddCustomCounters( + new CustomCounter("DCMiss"), + new CustomCounter("ICMiss"), + new CustomCounter("BranchMispredictions")); + + Assert.Equal(3, config.GetCustomCounters().Count()); + } + + [Fact] + public void DuplicateCustomCountersAreExcluded() + { + var config = ManualConfig.CreateEmpty(); + + config.AddCustomCounters(new CustomCounter("DCMiss")); + config.AddCustomCounters(new CustomCounter("DCMiss", shortName: "Different")); + + Assert.Single(config.GetCustomCounters()); + } + + [Fact] + public void AddCustomCountersReturnsSameInstance() + { + var config = ManualConfig.CreateEmpty(); + + var result = config.AddCustomCounters(new CustomCounter("DCMiss")); + + Assert.Same(config, result); + } + + [Fact] + public void EmptyArrayHasNoEffect() + { + var config = ManualConfig.CreateEmpty(); + + config.AddCustomCounters(); + + Assert.Empty(config.GetCustomCounters()); + } + + #endregion + + #region ImmutableConfig Custom Counters Tests + + [Fact] + public void CustomCountersArePreservedInImmutableConfig() + { + var mutable = ManualConfig.CreateEmpty(); + mutable.AddCustomCounters( + new CustomCounter("DCMiss", shortName: "L1D$Miss"), + new CustomCounter("ICMiss", shortName: "L1I$Miss")); + + var immutable = ImmutableConfigBuilder.Create(mutable); + + Assert.Equal(2, immutable.GetCustomCounters().Count()); + Assert.Contains(immutable.GetCustomCounters(), c => c.ProfileSourceName == "DCMiss"); + Assert.Contains(immutable.GetCustomCounters(), c => c.ProfileSourceName == "ICMiss"); + } + + [Fact] + public void DuplicateCustomCountersAreExcludedInImmutableConfig() + { + var mutable = ManualConfig.CreateEmpty(); + mutable.AddCustomCounters(new CustomCounter("DCMiss")); + mutable.AddCustomCounters(new CustomCounter("DCMiss")); + + var immutable = ImmutableConfigBuilder.Create(mutable); + + Assert.Single(immutable.GetCustomCounters()); + } + + [Fact] + public void CustomCounterPropertiesArePreservedInImmutableConfig() + { + var mutable = ManualConfig.CreateEmpty(); + mutable.AddCustomCounters(new CustomCounter( + "BranchMispredictions", + shortName: "BrMisp", + interval: 500_000, + higherIsBetter: false)); + + var immutable = ImmutableConfigBuilder.Create(mutable); + var counter = immutable.GetCustomCounters().Single(); + + Assert.Equal("BranchMispredictions", counter.ProfileSourceName); + Assert.Equal("BrMisp", counter.ShortName); + Assert.Equal(500_000, counter.Interval); + Assert.False(counter.HigherIsBetter); + } + + #endregion + + #region Config Merging Tests + + [Fact] + public void CustomCountersAreCombinedWhenMergingConfigs() + { + var config1 = ManualConfig.CreateEmpty(); + config1.AddCustomCounters(new CustomCounter("DCMiss")); + + var config2 = ManualConfig.CreateEmpty(); + config2.AddCustomCounters(new CustomCounter("ICMiss")); + + config1.Add(config2); + + Assert.Equal(2, config1.GetCustomCounters().Count()); + Assert.Contains(config1.GetCustomCounters(), c => c.ProfileSourceName == "DCMiss"); + Assert.Contains(config1.GetCustomCounters(), c => c.ProfileSourceName == "ICMiss"); + } + + [Fact] + public void DuplicateCustomCountersAreExcludedWhenMergingConfigs() + { + var config1 = ManualConfig.CreateEmpty(); + config1.AddCustomCounters(new CustomCounter("DCMiss")); + + var config2 = ManualConfig.CreateEmpty(); + config2.AddCustomCounters(new CustomCounter("DCMiss")); + + config1.Add(config2); + + Assert.Single(config1.GetCustomCounters()); + } + + [Fact] + public void OriginalCountersArePreservedWhenMergingEmptyConfig() + { + var config1 = ManualConfig.CreateEmpty(); + config1.AddCustomCounters( + new CustomCounter("DCMiss"), + new CustomCounter("ICMiss")); + + var config2 = ManualConfig.CreateEmpty(); + + config1.Add(config2); + + Assert.Equal(2, config1.GetCustomCounters().Count()); + } + + #endregion + + #region DefaultConfig Tests + + [Fact] + public void DefaultConfigHasNoCustomCounters() + { + var defaultConfig = DefaultConfig.Instance; + + Assert.Empty(defaultConfig.GetCustomCounters()); + } + + #endregion + + #region Combined Hardware and Custom Counters Tests + + [Fact] + public void BothHardwareAndCustomCountersCanBeConfigured() + { + var config = ManualConfig.CreateEmpty(); + config.AddHardwareCounters(HardwareCounter.CacheMisses); + config.AddCustomCounters(new CustomCounter("DCMiss")); + + Assert.Single(config.GetHardwareCounters()); + Assert.Single(config.GetCustomCounters()); + } + + [Fact] + public void BothHardwareAndCustomCountersArePreservedInImmutableConfig() + { + var mutable = ManualConfig.CreateEmpty(); + mutable.AddHardwareCounters(HardwareCounter.CacheMisses); + mutable.AddCustomCounters(new CustomCounter("DCMiss")); + + var immutable = ImmutableConfigBuilder.Create(mutable); + + Assert.Single(immutable.GetHardwareCounters()); + Assert.Single(immutable.GetCustomCounters()); + } + + #endregion + } +}