Repository navigation
Add --performanceCores option to affinitize benchmarks to P-cores - #3335
iamAdarshh wants to merge 2 commits into
Conversation
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); |
There was a problem hiding this comment.
It's better to use wrapper that are generated by CsWin32.
Edit NativeMethods.txt and use generated wrapper method.
There was a problem hiding this comment.
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.
Please include that in this PR. |
Added |
…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.
d26cef0 to
f95e4d2
Compare
timcassell
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 _)) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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).
Contributes to #3266.
Following the discussion in the issue, the default behavior is unchanged. This adds an opt-in
--performanceCoresconsole argument that affinitizes the benchmark process to the performance cores of CPUs with heterogeneous cores.Changes
PerformanceCoresDetector, which builds an affinity mask of the highest-ranked cores:GetSystemCpuSetInformation, using the highestEfficiencyClass(processor group 0)./sys/devices/cpu_core/cpusvs/sys/devices/cpu_atom/cpus(Intel hybrid), falling back to the highest/sys/devices/system/cpu/cpu*/cpu_capacity(Arm big.LITTLE/DynamIQ).--performanceCoresfeeds the detected mask into the existing--affinityhandling. Combining it with--affinityis a validation error. When detection fails, a warning is logged and no affinity is set.console-args.md.Possible follow-ups
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.