From bfd224b51e21de52c668cabbfbeb7279a3050772 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 15:31:36 -0800 Subject: [PATCH 1/8] Add support for custom hardware counter names Fixes #1520 This PR adds the ability to specify custom hardware counter names by their ETW profile source names, giving developers flexibility to use CPU-specific counters that aren't covered by the HardwareCounter enum. Changes: - Add CustomCounter class to represent custom PMC counters with configurable profile source name, short display name, interval, and higher-is-better flag - Add CustomCountersAttribute for declarative counter specification - Add IConfig.GetCustomCounters() and ManualConfig.AddCustomCounters() methods - Update ImmutableConfig/ImmutableConfigBuilder to handle custom counters - Update PreciseMachineCounter to support both enum and custom counters - Update PmcStats to track custom counters separately - Update PmcMetricDescriptor to use ShortName for column display - Update EtwProfiler to collect both hardware and custom counters - Update HardwareCounters.Validate() and Sessions to handle custom counters - Fix ManualConfig.Add() to propagate custom counters during config merge Usage example: // Using attribute [CustomCounters("DCMiss", "ICMiss", "BranchMispredictions")] public class MyBenchmark { } // Using ManualConfig var config = ManualConfig.CreateEmpty() .AddCustomCounters( new CustomCounter("DCMiss", shortName: "L1D$Miss"), new CustomCounter("ICMiss", shortName: "L1I$Miss") ); Developers can discover available counters on their system using: TraceEventProfileSources.GetInfo().Keys --- .../EtwProfiler.cs | 23 +- .../HardwareCounters.cs | 27 +- .../Sessions.cs | 2 +- .../Attributes/CustomCountersAttribute.cs | 40 +++ src/BenchmarkDotNet/Configs/DebugConfig.cs | 1 + src/BenchmarkDotNet/Configs/DefaultConfig.cs | 2 + src/BenchmarkDotNet/Configs/IConfig.cs | 1 + .../Configs/ImmutableConfig.cs | 4 + .../Configs/ImmutableConfigBuilder.cs | 8 +- src/BenchmarkDotNet/Configs/ManualConfig.cs | 9 + .../Diagnosers/CustomCounter.cs | 71 ++++ .../Diagnosers/PmcMetricDescriptor.cs | 4 +- src/BenchmarkDotNet/Diagnosers/PmcStats.cs | 24 +- .../Diagnosers/PreciseMachineCounter.cs | 23 ++ .../Configs/CustomCounterTests.cs | 326 ++++++++++++++++++ 15 files changed, 546 insertions(+), 19 deletions(-) create mode 100644 src/BenchmarkDotNet/Attributes/CustomCountersAttribute.cs create mode 100644 src/BenchmarkDotNet/Diagnosers/CustomCounter.cs create mode 100644 tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs 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..7d64ef1342 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,17 @@ 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()) + { + if (!availableCpuCounters.ContainsKey(customCounter.ProfileSourceName)) + { + yield return new ValidationError(true, + $"Custom counter '{customCounter.ProfileSourceName}' is not available on this machine. " + + $"Available counters: {string.Join(", ", availableCpuCounters.Keys.Take(20))}..."); + } + } + foreach (var benchmark in validationParameters.Benchmarks) { if (benchmark.Job.Infrastructure.TryGetToolchain(out var toolchain) && toolchain is InProcessEmitToolchain) @@ -80,6 +94,17 @@ 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]; + // Use the custom counter's interval if specified, otherwise use the intervalSelector + var interval = customCounter.Interval != CustomCounter.DefaultInterval + ? customCounter.Interval + : intervalSelector(profileSource); + + return new PreciseMachineCounter(profileSource.ID, profileSource.Name, 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..80bb69d484 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,7 +91,7 @@ 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 var hardwareCountersDiagnoser = DiagnosersLoader.GetImplementation(); 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..f89dfee061 --- /dev/null +++ b/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs @@ -0,0 +1,71 @@ +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 (profileSourceName == null) + throw new ArgumentNullException(nameof(profileSourceName)); + if (string.IsNullOrWhiteSpace(profileSourceName)) + throw new ArgumentException("Profile source name cannot be empty or whitespace.", nameof(profileSourceName)); + + 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..ef8f4ee1d3 100644 --- a/src/BenchmarkDotNet/Diagnosers/PmcStats.cs +++ b/src/BenchmarkDotNet/Diagnosers/PmcStats.cs @@ -8,20 +8,30 @@ 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); + + CountersByProfileSourceId = hwCounters + .Concat(customCountersDict.Where(kv => !hwCounters.ContainsKey(kv.Key))) + .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..e46559d812 --- /dev/null +++ b/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs @@ -0,0 +1,326 @@ +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 ZeroIntervalIsAccepted() + { + // Zero interval might be invalid for actual profiling but should be accepted by the class + var counter = new CustomCounter("DCMiss", interval: 0); + + Assert.Equal(0, counter.Interval); + } + + #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 + } +} From 2ecca698f3b2549e9bf5d710346efda160e01b8d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 16:34:26 -0800 Subject: [PATCH 2/8] Simplify null validation in CustomCounter constructor Consolidated null and whitespace validation into single IsNullOrWhiteSpace check as suggested in PR review. This reduces code redundancy while maintaining the same validation behavior. --- src/BenchmarkDotNet/Diagnosers/CustomCounter.cs | 4 +--- tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs b/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs index f89dfee061..edf0fa840c 100644 --- a/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs +++ b/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs @@ -50,10 +50,8 @@ public class CustomCounter /// Whether higher values are better for this counter. public CustomCounter(string profileSourceName, string? shortName = null, int interval = DefaultInterval, bool higherIsBetter = false) { - if (profileSourceName == null) - throw new ArgumentNullException(nameof(profileSourceName)); if (string.IsNullOrWhiteSpace(profileSourceName)) - throw new ArgumentException("Profile source name cannot be empty or whitespace.", nameof(profileSourceName)); + throw new ArgumentException("Profile source name cannot be null, empty or whitespace.", nameof(profileSourceName)); ProfileSourceName = profileSourceName; ShortName = shortName ?? profileSourceName; diff --git a/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs b/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs index e46559d812..2ce30226d0 100644 --- a/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs +++ b/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs @@ -64,7 +64,7 @@ public void AllPropertiesAreSetWhenProvided() [Fact] public void NullProfileSourceNameThrows() { - Assert.Throws(() => new CustomCounter(null!)); + Assert.Throws(() => new CustomCounter(null!)); } [Fact] From eaee3f74f5781e733548cf9553f5279c6d40ea1e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 16:37:55 -0800 Subject: [PATCH 3/8] Remove unnecessary interval comparison in FromCustomCounter As suggested in PR review, simplified the logic to always use customCounter.Interval directly. The CustomCounter constructor already sets DefaultInterval as the default value, so the conditional check was redundant and could incorrectly override explicitly-set intervals that happened to match DefaultInterval. --- src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs index 7d64ef1342..b1f9458214 100644 --- a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs +++ b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs @@ -97,12 +97,8 @@ internal static PreciseMachineCounter FromCounter(HardwareCounter counter, Func< internal static PreciseMachineCounter FromCustomCounter(CustomCounter customCounter, Func intervalSelector) { var profileSource = TraceEventProfileSources.GetInfo()[customCounter.ProfileSourceName]; - // Use the custom counter's interval if specified, otherwise use the intervalSelector - var interval = customCounter.Interval != CustomCounter.DefaultInterval - ? customCounter.Interval - : intervalSelector(profileSource); - return new PreciseMachineCounter(profileSource.ID, profileSource.Name, customCounter, interval); + return new PreciseMachineCounter(profileSource.ID, profileSource.Name, customCounter, customCounter.Interval); } internal static void Enable(IEnumerable counters) From 9c4e635417dc9b21af84196fd0b04cc3cded1fa9 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 16:45:24 -0800 Subject: [PATCH 4/8] Add ProfileSourceId collision detection in PmcStats As suggested in PR review, added validation to detect and report collisions between hardware counters and custom counters that share the same ProfileSourceId. Previously, custom counters were silently dropped in case of collision, which could confuse users. Now an InvalidOperationException is thrown with details about the colliding counters. --- src/BenchmarkDotNet/Diagnosers/PmcStats.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/BenchmarkDotNet/Diagnosers/PmcStats.cs b/src/BenchmarkDotNet/Diagnosers/PmcStats.cs index ef8f4ee1d3..0076fcf2e4 100644 --- a/src/BenchmarkDotNet/Diagnosers/PmcStats.cs +++ b/src/BenchmarkDotNet/Diagnosers/PmcStats.cs @@ -26,8 +26,23 @@ public PmcStats(IReadOnlyCollection hardwareCounters, IReadOnly 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.Where(kv => !hwCounters.ContainsKey(kv.Key))) + .Concat(customCountersDict) .ToDictionary(kv => kv.Key, kv => kv.Value); Counters = hwCounters.ToDictionary(c => c.Value.Counter, c => c.Value); From a14f23f0944f2bbdb1e932b06f6d12b73f2b0865 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 16:48:36 -0800 Subject: [PATCH 5/8] Update comment to mention both hardware and custom counters Updated the comment in ImmutableConfigBuilder to reflect that the code now handles both [HardwareCounters] and [CustomCounters] attributes when dynamically loading the diagnoser. --- src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs b/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs index 80bb69d484..0fcd588800 100644 --- a/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs +++ b/src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs @@ -93,7 +93,7 @@ private static ImmutableHashSet GetDiagnosers(IEnumerable().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)) From 59b0b1e7ccc5fdb85aa93f53fce2951fe64f9914 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 16:52:33 -0800 Subject: [PATCH 6/8] Fix misleading ellipsis in custom counter validation error Improved the error message to only show ellipsis when there are actually more than 20 available counters. Now displays '(and X more)' to indicate exactly how many counters were omitted from the list, providing clearer feedback to users. --- src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs index b1f9458214..015b11f8d8 100644 --- a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs +++ b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs @@ -72,9 +72,12 @@ public static IEnumerable Validate(ValidationParameters validat { if (!availableCpuCounters.ContainsKey(customCounter.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: {string.Join(", ", availableCpuCounters.Keys.Take(20))}..."); + $"Available counters: {displayedCounterNames}{suffix}"); } } From 6c932b43ef0310aedcb96ae37a7f0f4fe379d65c Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 16:55:18 -0800 Subject: [PATCH 7/8] Add validation for positive interval in CustomCounter Added ArgumentOutOfRangeException validation to reject zero or negative interval values in CustomCounter constructor. Non-positive intervals don't make sense for PMC sampling and could cause runtime errors in the ETW API. Updated tests to verify that zero and negative intervals are properly rejected. --- src/BenchmarkDotNet/Diagnosers/CustomCounter.cs | 3 +++ .../Configs/CustomCounterTests.cs | 11 +++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs b/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs index edf0fa840c..89c574e144 100644 --- a/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs +++ b/src/BenchmarkDotNet/Diagnosers/CustomCounter.cs @@ -52,6 +52,9 @@ public CustomCounter(string profileSourceName, string? shortName = null, int int { 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; diff --git a/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs b/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs index 2ce30226d0..11bac369b3 100644 --- a/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs +++ b/tests/BenchmarkDotNet.Tests/Configs/CustomCounterTests.cs @@ -115,12 +115,15 @@ public void VeryLargeIntervalIsAccepted() } [Fact] - public void ZeroIntervalIsAccepted() + public void ZeroIntervalThrows() { - // Zero interval might be invalid for actual profiling but should be accepted by the class - var counter = new CustomCounter("DCMiss", interval: 0); + Assert.Throws(() => new CustomCounter("DCMiss", interval: 0)); + } - Assert.Equal(0, counter.Interval); + [Fact] + public void NegativeIntervalThrows() + { + Assert.Throws(() => new CustomCounter("DCMiss", interval: -1)); } #endregion From 243a83afa6183b40718ddf4888069295cc2e0baf Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 3 Jan 2026 16:58:45 -0800 Subject: [PATCH 8/8] Refactor custom counter validation to use explicit filtering Changed the foreach loop to use explicit .Where() filtering instead of implicit if-based filtering, as suggested in code review. This makes the filtering logic more explicit and aligns with functional programming style. --- .../HardwareCounters.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs index 015b11f8d8..e1ae47a843 100644 --- a/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs +++ b/src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs @@ -68,17 +68,15 @@ public static IEnumerable Validate(ValidationParameters validat } // Validate custom counters - foreach (var customCounter in validationParameters.Config.GetCustomCounters()) + foreach (var customCounter in validationParameters.Config.GetCustomCounters() + .Where(c => !availableCpuCounters.ContainsKey(c.ProfileSourceName))) { - if (!availableCpuCounters.ContainsKey(customCounter.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}"); - } + 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)