Skip to content
23 changes: 18 additions & 5 deletions src/BenchmarkDotNet.Diagnostics.Windows/EtwProfiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -145,11 +153,16 @@ private IReadOnlyDictionary<BenchmarkCase, PmcStats> 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);
Expand Down
24 changes: 23 additions & 1 deletion src/BenchmarkDotNet.Diagnostics.Windows/HardwareCounters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ public static IEnumerable<ValidationError> 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;
Expand All @@ -64,6 +67,18 @@ public static IEnumerable<ValidationError> 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)
Expand All @@ -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<ProfileSourceInfo, int> intervalSelector)
{
var profileSource = TraceEventProfileSources.GetInfo()[customCounter.ProfileSourceName];

return new PreciseMachineCounter(profileSource.ID, profileSource.Name, customCounter, customCounter.Interval);
}

internal static void Enable(IEnumerable<PreciseMachineCounter> counters)
{
TraceEventProfileSources.Set( // it's a must have to get the events enabled!!
Expand Down
2 changes: 1 addition & 1 deletion src/BenchmarkDotNet.Diagnostics.Windows/Sessions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions src/BenchmarkDotNet/Attributes/CustomCountersAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using JetBrains.Annotations;

namespace BenchmarkDotNet.Attributes
{
/// <summary>
/// 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).
/// </summary>
[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();
}

/// <summary>
/// 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.
/// </summary>
/// <param name="counterNames">The ETW profile source names (e.g., "DcacheMisses", "IcacheMisses")</param>
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; }
}
}
1 change: 1 addition & 0 deletions src/BenchmarkDotNet/Configs/DebugConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public abstract class DebugConfig : IConfig
public IEnumerable<IDiagnoser> GetDiagnosers() => [];
public IEnumerable<IAnalyser> GetAnalysers() => [];
public IEnumerable<HardwareCounter> GetHardwareCounters() => [];
public IEnumerable<CustomCounter> GetCustomCounters() => [];
public IEnumerable<EventProcessor> GetEventProcessors() => [];
public IEnumerable<IFilter> GetFilters() => [];
public IEnumerable<IColumnHidingRule> GetColumnHidingRules() => [];
Expand Down
2 changes: 2 additions & 0 deletions src/BenchmarkDotNet/Configs/DefaultConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ public string ArtifactsPath

public IEnumerable<HardwareCounter> GetHardwareCounters() => Array.Empty<HardwareCounter>();

public IEnumerable<CustomCounter> GetCustomCounters() => Array.Empty<CustomCounter>();

public IEnumerable<IFilter> GetFilters() => Array.Empty<IFilter>();

public IEnumerable<EventProcessor> GetEventProcessors() => Array.Empty<EventProcessor>();
Expand Down
1 change: 1 addition & 0 deletions src/BenchmarkDotNet/Configs/IConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public interface IConfig
IEnumerable<Job> GetJobs();
IEnumerable<IValidator> GetValidators();
IEnumerable<HardwareCounter> GetHardwareCounters();
IEnumerable<CustomCounter> GetCustomCounters();
IEnumerable<IFilter> GetFilters();
IEnumerable<BenchmarkLogicalGroupRule> GetLogicalGroupRules();
IEnumerable<EventProcessor> GetEventProcessors();
Expand Down
4 changes: 4 additions & 0 deletions src/BenchmarkDotNet/Configs/ImmutableConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public sealed class ImmutableConfig : IConfig
private readonly ImmutableHashSet<IValidator> validators;
private readonly ImmutableHashSet<Job> jobs;
private readonly ImmutableHashSet<HardwareCounter> hardwareCounters;
private readonly ImmutableHashSet<CustomCounter> customCounters;
private readonly ImmutableHashSet<IFilter> filters;
private readonly ImmutableArray<BenchmarkLogicalGroupRule> rules;
private readonly ImmutableHashSet<EventProcessor> eventProcessors;
Expand All @@ -39,6 +40,7 @@ internal ImmutableConfig(
ImmutableArray<IColumnProvider> uniqueColumnProviders,
ImmutableHashSet<ILogger> uniqueLoggers,
ImmutableHashSet<HardwareCounter> uniqueHardwareCounters,
ImmutableHashSet<CustomCounter> uniqueCustomCounters,
ImmutableHashSet<IDiagnoser> uniqueDiagnosers,
ImmutableArray<IExporter> uniqueExporters,
ImmutableHashSet<IAnalyser> uniqueAnalyzers,
Expand All @@ -62,6 +64,7 @@ internal ImmutableConfig(
columnProviders = uniqueColumnProviders;
loggers = uniqueLoggers;
hardwareCounters = uniqueHardwareCounters;
customCounters = uniqueCustomCounters;
diagnosers = uniqueDiagnosers;
exporters = uniqueExporters;
analysers = uniqueAnalyzers;
Expand Down Expand Up @@ -101,6 +104,7 @@ internal ImmutableConfig(
public IEnumerable<Job> GetJobs() => jobs;
public IEnumerable<IValidator> GetValidators() => validators;
public IEnumerable<HardwareCounter> GetHardwareCounters() => hardwareCounters;
public IEnumerable<CustomCounter> GetCustomCounters() => customCounters;
public IEnumerable<IFilter> GetFilters() => filters;
public IEnumerable<BenchmarkLogicalGroupRule> GetLogicalGroupRules() => rules;
public IEnumerable<EventProcessor> GetEventProcessors() => eventProcessors;
Expand Down
10 changes: 6 additions & 4 deletions src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ public static ImmutableConfig Create(IConfig source)
var configAnalyse = new List<Conclusion>();

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);

Expand All @@ -59,6 +60,7 @@ public static ImmutableConfig Create(IConfig source)
uniqueColumnProviders,
uniqueLoggers,
uniqueHardwareCounters,
uniqueCustomCounters,
uniqueDiagnosers,
uniqueExporters,
uniqueAnalyzers,
Expand All @@ -81,17 +83,17 @@ public static ImmutableConfig Create(IConfig source)
);
}

private static ImmutableHashSet<IDiagnoser> GetDiagnosers(IEnumerable<IDiagnoser> diagnosers, ImmutableHashSet<HardwareCounter> uniqueHardwareCounters)
private static ImmutableHashSet<IDiagnoser> GetDiagnosers(IEnumerable<IDiagnoser> diagnosers, ImmutableHashSet<HardwareCounter> uniqueHardwareCounters, ImmutableHashSet<CustomCounter> uniqueCustomCounters)
{
var builder = ImmutableHashSet.CreateBuilder(new TypeComparer<IDiagnoser>());

foreach (var diagnoser in diagnosers)
if (!builder.Contains(diagnoser))
builder.Add(diagnoser);

if (!uniqueHardwareCounters.IsEmpty && !diagnosers.OfType<IHardwareCountersDiagnoser>().Any())
if ((!uniqueHardwareCounters.IsEmpty || !uniqueCustomCounters.IsEmpty) && !diagnosers.OfType<IHardwareCountersDiagnoser>().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<IHardwareCountersDiagnoser>();

if (hardwareCountersDiagnoser != default(IDiagnoser) && !builder.Contains(hardwareCountersDiagnoser))
Expand Down
9 changes: 9 additions & 0 deletions src/BenchmarkDotNet/Configs/ManualConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class ManualConfig : IConfig
private readonly List<IValidator> validators = new List<IValidator>();
private readonly List<Job> jobs = new List<Job>();
private readonly HashSet<HardwareCounter> hardwareCounters = new HashSet<HardwareCounter>();
private readonly HashSet<CustomCounter> customCounters = new HashSet<CustomCounter>();
private readonly List<IFilter> filters = new List<IFilter>();
private readonly List<BenchmarkLogicalGroupRule> logicalGroupRules = new List<BenchmarkLogicalGroupRule>();
private readonly List<EventProcessor> eventProcessors = new List<EventProcessor>();
Expand All @@ -45,6 +46,7 @@ public class ManualConfig : IConfig
public IEnumerable<IValidator> GetValidators() => validators;
public IEnumerable<Job> GetJobs() => jobs;
public IEnumerable<HardwareCounter> GetHardwareCounters() => hardwareCounters;
public IEnumerable<CustomCounter> GetCustomCounters() => customCounters;
public IEnumerable<IFilter> GetFilters() => filters;
public IEnumerable<BenchmarkLogicalGroupRule> GetLogicalGroupRules() => logicalGroupRules;
public IEnumerable<EventProcessor> GetEventProcessors() => eventProcessors;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions src/BenchmarkDotNet/Diagnosers/CustomCounter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using JetBrains.Annotations;

namespace BenchmarkDotNet.Diagnosers
{
/// <summary>
/// Represents a custom hardware performance counter that can be specified by its ETW profile source name.
/// Use this when the predefined <see cref="HardwareCounter"/> enum values don't match the counters
/// available on your machine (e.g., AMD-specific counters like DcacheMisses, IcacheMisses).
/// Run <c>TraceEventProfileSources.GetInfo().Keys</c> to discover available counters on your system.
/// </summary>
public class CustomCounter
{
/// <summary>
/// Default sampling interval for custom counters.
/// </summary>
public const int DefaultInterval = 1_000_003;

/// <summary>
/// The exact name of the ETW profile source as returned by TraceEventProfileSources.GetInfo().
/// </summary>
[PublicAPI]
public string ProfileSourceName { get; }

/// <summary>
/// A short name used for display in reports and columns.
/// </summary>
[PublicAPI]
public string ShortName { get; }

/// <summary>
/// The sampling interval for this counter.
/// </summary>
[PublicAPI]
public int Interval { get; }

/// <summary>
/// Indicates whether higher values are better for this counter.
/// Default is false (lower is better, e.g., cache misses).
/// </summary>
[PublicAPI]
public bool HigherIsBetter { get; }

/// <summary>
/// Creates a new custom hardware counter.
/// </summary>
/// <param name="profileSourceName">The exact name of the ETW profile source (e.g., "DcacheMisses", "IcacheMisses").</param>
/// <param name="shortName">Optional short name for display. If null, uses the profile source name.</param>
/// <param name="interval">Sampling interval. If not specified, uses DefaultInterval (1,000,003).</param>
/// <param name="higherIsBetter">Whether higher values are better for this counter.</param>
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();
}
}
4 changes: 2 additions & 2 deletions src/BenchmarkDotNet/Diagnosers/PmcMetricDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading