Skip to content

Add --performanceCores option to affinitize benchmarks to P-cores - #3335

Open
iamAdarshh wants to merge 2 commits into
dotnet:masterfrom
iamAdarshh:feature/performance-cores-affinity
Open

iamAdarshh wants to merge 2 commits into
dotnet:masterfrom
iamAdarshh:feature/performance-cores-affinity

Conversation

@iamAdarshh

Copy link
Copy Markdown

Contributes to #3266.

Following the discussion in the issue, the default behavior is unchanged. This adds an opt-in --performanceCores console argument that affinitizes the benchmark process to the performance cores of CPUs with heterogeneous cores.

Changes

  • New internal PerformanceCoresDetector, which builds an affinity mask of the highest-ranked cores:
    • Windows: GetSystemCpuSetInformation, using the highest EfficiencyClass (processor group 0).
    • Linux: /sys/devices/cpu_core/cpus vs /sys/devices/cpu_atom/cpus (Intel hybrid), falling back to the highest /sys/devices/system/cpu/cpu*/cpu_capacity (Arm big.LITTLE/DynamIQ).
    • Returns nothing for homogeneous CPUs and unsupported OSes. Only the first 64 processors are considered.
  • --performanceCores feeds the detected mask into the existing --affinity handling. Combining it with --affinity is a validation error. When detection fails, a warning is logged and no affinity is set.
  • Documented in console-args.md.
  • Unit tests for mask building, Linux cpu-list parsing, Windows CPU set parsing and the console argument.

Possible follow-ups

  • A programmatic API (e.g. a job extension) in addition to the console argument.
  • A warning when running on a hybrid CPU without affinity, as suggested in the issue.

Testing

BenchmarkDotNet.Tests (net10.0): 1505 passed, 0 failed. Detection was not tested on real hybrid hardware (I don't have any), so a check on an Intel hybrid or Arm Windows/Linux machine would be appreciated.

Detects the performance cores of CPUs with heterogeneous cores (Windows via
GetSystemCpuSetInformation, Linux via /sys/devices/cpu_core/cpus or
cpu_capacity) and sets the corresponding affinity mask. Opt-in, so the
default behavior is unchanged.

Contributes to dotnet#3266

[DllImport("kernel32", ExactSpelling = true, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetSystemCpuSetInformation(IntPtr information, uint bufferLength, out uint returnedLength, IntPtr process, uint flags);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to use wrapper that are generated by CsWin32.
Edit NativeMethods.txt and use generated wrapper method.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d26cef0, the hand-written DllImport is removed.

Adding GetSystemCpuSetInformation to NativeMethods.txt didn't compile on net6.0: one of the friendly overloads CsWin32 generates for it uses new Span<T>(ref T), which only exists on .NET 7+ (same with the latest CsWin32 0.3.371). Disabling friendly overloads globally breaks other existing call sites.

So it now uses the already generated GetLogicalProcessorInformationEx wrapper with RelationProcessorCore (the same one DefaultCpuDetector uses), which also reports PROCESSOR_RELATIONSHIP.EfficiencyClass per core. No new entries in NativeMethods.txt were needed.

@timcassell

Copy link
Copy Markdown
Collaborator

Possible follow-ups

  • A programmatic API (e.g. a job extension) in addition to the console argument.

Please include that in this PR.

@iamAdarshh

Copy link
Copy Markdown
Author

Please include that in this PR.

Added Job.WithPerformanceCoresAffinity() in d26cef0. It detects the performance cores when called and sets Affinity; when they can't be detected (homogeneous CPU, unsupported OS, or the mask doesn't fit a 32 bit process) the job is returned unchanged. Documented in docs/articles/configs/jobs.md.

…ity job extension

- Detect efficiency classes on Windows via the existing CsWin32
  GetLogicalProcessorInformationEx wrapper (RelationProcessorCore) instead of
  a hand-written DllImport of GetSystemCpuSetInformation.
- Add Job.WithPerformanceCoresAffinity() as a programmatic counterpart of
  the --performanceCores console argument.
@iamAdarshh
iamAdarshh force-pushed the feature/performance-cores-affinity branch from d26cef0 to f95e4d2 Compare November 15, 2026 10:00

@timcassell timcassell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline comments below. Also, the description is out of date: Windows detection uses GetLogicalProcessorInformationEx (not GetSystemCpuSetInformation), and the programmatic API listed under follow-ups is already added (WithPerformanceCoresAffinity).

Reviewed by Claude (Opus 5.5), posted by @timcassell.

if (performanceCoresMask.HasValue)
{
logger.WriteLineInfo($"// Affinitizing the benchmark process to the performance cores (mask 0x{performanceCoresMask.Value:X}).");
options.Affinity = performanceCoresMask;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunAll/RunAllJoined + --performanceCores runs nothing on hybrid CPUs. TryUpdateArgs calls Validate, which writes the mask into options.Affinity, then re-serializes the options. The rewritten args are --affinity 255 --performanceCores --filter *, which fail the --performanceCores can't be combined with --affinity check on the real parse.

Fix: don't mutate options in Validate; resolve the mask where the job is built (GetBaseJob), or reset options.PerformanceCores = false after assigning Affinity. Worth a TryUpdateArgs test.

if (list.Count == 0)
return null;

int maxRank = list.Max(p => p.Rank);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the single highest rank is kept, so with 3+ tiers (e.g. Arm 1 prime + 3 big + 4 little, or Windows Arm with three EfficiencyClass values) the mask is just the prime core, and benchmark, GC, JIT and engine threads all share it. Same with the cpu_capacity fallback on x86 kernels that report per-CPU capacity (favored cores). MaskSupportsMoreThanTwoCoreTypes locks this in.

Suggest excluding only the lowest rank instead.

/// Returns the affinity mask of the performance cores,
/// or <c>null</c> when it can't be detected or when all the cores are of the same type.
/// </summary>
internal static ulong? TryGetPerformanceCoresMask()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mask isn't intersected with the CPUs the process may actually use (cgroup cpuset / taskset / job object). E.g. docker run --cpuset-cpus=8-11 on an Intel hybrid host: mask 0xFF is logged, then every benchmark logs Failed to set up processor affinity. Partial overlap silently differs from the logged mask.

Suggest mask &= (ulong)Process.GetCurrentProcess().ProcessorAffinity and treating 0 as not detected.

if (OsDetector.IsLinux())
return GetLinuxPerformanceCoresMask();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Win32Exception or EntryPointNotFoundException or DllNotFoundException)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a best-effort probe; any unexpected exception (e.g. MarshalDirectiveException/NotSupportedException from the P/Invoke) now escapes ConfigParser.Parse and fails the run. DefaultCpuDetector uses a bare catch and skips Mono (IsApplicable); suggest the same here.

}
}

if (options.Affinity.HasValue && !TryConvertAffinity(options.Affinity.Value, IntPtr.Size, out _))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a 32-bit host with P-cores at index >= 32 this aborts with "The provided affinity mask ... does not fit", though the user provided no mask. WithPerformanceCoresAffinity silently skips the same case. Suggest masking to the reachable processors (or warn + skip) consistently in both paths.

for (int i = 0; i < core.GroupCount; i++)
{
GROUP_AFFINITY groupAffinity = core.GroupMask[i];
if (groupAffinity.Group != 0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always group 0, but Process.ProcessorAffinity applies to the process's primary group. On >64-LP Windows machines the process can be assigned group 1, so the group-0 bit pattern pins it to arbitrary (possibly E-) cores. Suggest using the current process's group (GetProcessGroupAffinity) or bailing when it isn't 0.

uint bufferLength = 0;
PInvoke.GetLogicalProcessorInformationEx(LOGICAL_PROCESSOR_RELATIONSHIP.RelationProcessorCore, null, ref bufferLength);
var error = (WIN32_ERROR) Marshal.GetLastWin32Error();
if (bufferLength == 0 || error != WIN32_ERROR.ERROR_INSUFFICIENT_BUFFER)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct (the size query is expected to fail with ERROR_INSUFFICIENT_BUFFER), but it reads backwards; a short comment would help:

// The size query is expected to fail with ERROR_INSUFFICIENT_BUFFER and report the required length.

Nit: the second call throws Win32Exception while DefaultCpuDetector returns null; return []; would match.

/// The efficiency class is reported per core, so all the logical processors of a core get the same rank.
/// Before Windows 10 it's always 0, which is treated as a homogeneous CPU.
/// </summary>
internal static unsafe List<(int Processor, int Rank)> ParseWindowsProcessorCores(byte[] buffer, int length)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the sizing call + SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX walk in DefaultCpuDetector.GetLogicalProcessorInformation/ParseData. Suggest extracting a shared entry iterator under Detectors/Cpu/Windows.

While there: info->Size is read before checking that a header fits in the remaining bytes, and GroupCount isn't checked against Size, so a malformed buffer (only reachable from tests today) reads past the array.

internal static bool TryGetPerformanceCoresAffinity(out IntPtr affinity)
{
affinity = default;
return TryGetPerformanceCoresMask() is ulong mask && ConfigParser.TryConvertAffinity(mask, IntPtr.Size, out affinity);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detector now depends on ConsoleArguments.ConfigParser. Suggest moving TryConvertAffinity next to the affinity handling (or into this class) and calling it from ConfigParser.

/// (the CPU has homogeneous cores or the OS is not supported), the job is returned unchanged.
/// </summary>
public static Job WithPerformanceCoresAffinity(this Job job)
=> PerformanceCoresDetector.TryGetPerformanceCoresAffinity(out var affinity) ? job.WithAffinity(affinity) : job;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When detection fails (macOS, homogeneous CPU, container, probe exception) this returns the job unchanged with no diagnostic, unlike the CLI warning. It also re-probes on every call. Suggest caching the result and surfacing the not-applied case (e.g. a validator warning).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants