diff --git a/Models/CpuSelection.cs b/Models/CpuSelection.cs index a384c07..c8d63ab 100644 --- a/Models/CpuSelection.cs +++ b/Models/CpuSelection.cs @@ -42,6 +42,8 @@ public sealed record CpuTopologySignature public int LastLevelCacheGroupCount { get; init; } + public int PackageCount { get; init; } + public string Source { get; init; } = "Unknown"; } @@ -73,16 +75,31 @@ public sealed class CpuTopologySnapshot { private readonly IReadOnlyDictionary cpuSetIdsByProcessor; private readonly IReadOnlyDictionary efficiencyClassesByProcessor; + private readonly IReadOnlyDictionary coreIndexesByProcessor; + private readonly IReadOnlyDictionary numaNodeIndexesByProcessor; + private readonly IReadOnlyDictionary lastLevelCacheIndexesByProcessor; + private readonly IReadOnlyDictionary packageIndexesByProcessor; + private readonly IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor; private CpuTopologySnapshot( IReadOnlyList logicalProcessors, IReadOnlyDictionary cpuSetIdsByProcessor, IReadOnlyDictionary efficiencyClassesByProcessor, + IReadOnlyDictionary coreIndexesByProcessor, + IReadOnlyDictionary numaNodeIndexesByProcessor, + IReadOnlyDictionary lastLevelCacheIndexesByProcessor, + IReadOnlyDictionary packageIndexesByProcessor, + IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor, CpuTopologySignature signature) { this.LogicalProcessors = logicalProcessors; this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; this.efficiencyClassesByProcessor = efficiencyClassesByProcessor; + this.coreIndexesByProcessor = coreIndexesByProcessor; + this.numaNodeIndexesByProcessor = numaNodeIndexesByProcessor; + this.lastLevelCacheIndexesByProcessor = lastLevelCacheIndexesByProcessor; + this.packageIndexesByProcessor = packageIndexesByProcessor; + this.smtSiblingGlobalIndexesByProcessor = smtSiblingGlobalIndexesByProcessor; this.Signature = signature; } @@ -94,7 +111,12 @@ public static CpuTopologySnapshot Create( IEnumerable logicalProcessors, IReadOnlyDictionary? cpuSetIds = null, IReadOnlyDictionary? efficiencyClasses = null, - CpuTopologySignature? signature = null) + CpuTopologySignature? signature = null, + IReadOnlyDictionary? coreIndexes = null, + IReadOnlyDictionary? numaNodeIndexes = null, + IReadOnlyDictionary? lastLevelCacheIndexes = null, + IReadOnlyDictionary? packageIndexes = null, + IReadOnlyDictionary>? smtSiblingGlobalIndexes = null) { ArgumentNullException.ThrowIfNull(logicalProcessors); @@ -128,14 +150,45 @@ public static CpuTopologySnapshot Create( .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(); + var coreIndexMap = FilterKnownProcessorMap(coreIndexes, processorSet); + var numaNodeIndexMap = FilterKnownProcessorMap(numaNodeIndexes, processorSet); + var lastLevelCacheIndexMap = FilterKnownProcessorMap(lastLevelCacheIndexes, processorSet); + var packageIndexMap = FilterKnownProcessorMap(packageIndexes, processorSet); + var knownGlobalIndexes = processors.Select(processor => processor.GlobalIndex).ToHashSet(); + var smtSiblingMap = smtSiblingGlobalIndexes? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value + .Where(knownGlobalIndexes.Contains) + .Distinct() + .OrderBy(index => index) + .ToList()) + ?? new Dictionary>(); + var resolvedSignature = signature ?? new CpuTopologySignature { LogicalProcessorCount = processors.Count, + PhysicalCoreCount = coreIndexMap.Count == 0 + ? processors.Count + : coreIndexMap.Values.Distinct().Count(), ProcessorGroupCount = processors.Select(processor => processor.Group).Distinct().Count(), + NumaNodeCount = numaNodeIndexMap.Values.Distinct().Count(), + LastLevelCacheGroupCount = lastLevelCacheIndexMap.Values.Distinct().Count(), + PackageCount = packageIndexMap.Values.Distinct().Count(), Source = "Snapshot", }; - return new CpuTopologySnapshot(processors, cpuSetMap, efficiencyClassMap, resolvedSignature); + return new CpuTopologySnapshot( + processors, + cpuSetMap, + efficiencyClassMap, + coreIndexMap, + numaNodeIndexMap, + lastLevelCacheIndexMap, + packageIndexMap, + smtSiblingMap, + resolvedSignature); } public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) => @@ -144,6 +197,23 @@ public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) => public bool TryGetEfficiencyClass(ProcessorRef processor, out byte efficiencyClass) => this.efficiencyClassesByProcessor.TryGetValue(processor, out efficiencyClass); + public bool TryGetCoreIndex(ProcessorRef processor, out int coreIndex) => + this.coreIndexesByProcessor.TryGetValue(processor, out coreIndex); + + public bool TryGetNumaNodeIndex(ProcessorRef processor, out int numaNodeIndex) => + this.numaNodeIndexesByProcessor.TryGetValue(processor, out numaNodeIndex); + + public bool TryGetLastLevelCacheIndex(ProcessorRef processor, out int lastLevelCacheIndex) => + this.lastLevelCacheIndexesByProcessor.TryGetValue(processor, out lastLevelCacheIndex); + + public bool TryGetPackageIndex(ProcessorRef processor, out int packageIndex) => + this.packageIndexesByProcessor.TryGetValue(processor, out packageIndex); + + public IReadOnlyList GetSmtSiblingGlobalIndexes(ProcessorRef processor) => + this.smtSiblingGlobalIndexesByProcessor.TryGetValue(processor, out var siblings) + ? siblings + : []; + public byte? GetPerformanceEfficiencyClass() { if (this.efficiencyClassesByProcessor.Count == 0) @@ -153,6 +223,16 @@ public bool TryGetEfficiencyClass(ProcessorRef processor, out byte efficiencyCla return this.efficiencyClassesByProcessor.Values.Max(); } + + private static Dictionary FilterKnownProcessorMap( + IReadOnlyDictionary? source, + HashSet processorSet) + { + return source? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(); + } } /// diff --git a/Services/ICpuTopologyProvider.cs b/Services/ICpuTopologyProvider.cs new file mode 100644 index 0000000..27e5c32 --- /dev/null +++ b/Services/ICpuTopologyProvider.cs @@ -0,0 +1,33 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System.Threading; + using System.Threading.Tasks; + using ThreadPilot.Models; + + /// + /// Provides a topology-aware CPU snapshot without applying runtime affinity changes. + /// + public interface ICpuTopologyProvider + { + /// + /// Gets a current CPU topology snapshot. + /// + Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default); + } +} diff --git a/Services/WindowsCpuTopologyNativeLayout.cs b/Services/WindowsCpuTopologyNativeLayout.cs new file mode 100644 index 0000000..02bcae2 --- /dev/null +++ b/Services/WindowsCpuTopologyNativeLayout.cs @@ -0,0 +1,164 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.InteropServices; + using ThreadPilot.Models; + + internal static class WindowsCpuTopologyNativeLayout + { + public static int GroupAffinitySize => Marshal.SizeOf(); + + public static int ProcessorGroupCountOffset => Marshal.OffsetOf(nameof(ProcessorRelationship.GroupCount)).ToInt32(); + + public static int ProcessorGroupMaskOffset => Marshal.OffsetOf(nameof(ProcessorRelationship.GroupMask)).ToInt32(); + + public static int CacheReservedOffset => Marshal.OffsetOf(nameof(CacheRelationship.Reserved)).ToInt32(); + + public static int CacheGroupCountOffset => Marshal.OffsetOf(nameof(CacheRelationship.GroupCount)).ToInt32(); + + public static int CacheGroupMaskOffset => Marshal.OffsetOf(nameof(CacheRelationship.GroupMask)).ToInt32(); + + public static int NumaReservedOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.Reserved)).ToInt32(); + + public static int NumaGroupCountOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.GroupCount)).ToInt32(); + + public static int NumaGroupMaskOffset => Marshal.OffsetOf(nameof(NumaNodeRelationship.GroupMask)).ToInt32(); + + internal enum ProcessorCacheType + { + CacheUnified = 0, + CacheInstruction = 1, + CacheData = 2, + CacheTrace = 3, + } + + [StructLayout(LayoutKind.Sequential)] + internal struct GroupAffinity + { + public UIntPtr Mask; + public ushort Group; + public ushort Reserved0; + public ushort Reserved1; + public ushort Reserved2; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct ProcessorRelationship + { + public byte Flags; + public byte EfficiencyClass; + public fixed byte Reserved[20]; + public ushort GroupCount; + public GroupAffinity GroupMask; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct CacheRelationship + { + public byte Level; + public byte Associativity; + public ushort LineSize; + public uint CacheSize; + public ProcessorCacheType Type; + public fixed byte Reserved[18]; + public ushort GroupCount; + public GroupAffinity GroupMask; + } + + [StructLayout(LayoutKind.Sequential)] + internal unsafe struct NumaNodeRelationship + { + public uint NodeNumber; + public fixed byte Reserved[18]; + public ushort GroupCount; + public GroupAffinity GroupMask; + } + + public static IReadOnlyList ReadProcessorRelationshipProcessors(IntPtr relationshipPtr, ushort groupCount) + { + return ReadProcessorsFromGroupMasks(relationshipPtr, ProcessorGroupMaskOffset, groupCount).ToList(); + } + + public static bool TryReadL3CacheProcessors(IntPtr relationshipPtr, out IReadOnlyList processors) + { + var cache = Marshal.PtrToStructure(relationshipPtr); + if (cache.Level != 3 || cache.GroupCount == 0) + { + processors = []; + return false; + } + + processors = ReadProcessorsFromGroupMasks(relationshipPtr, CacheGroupMaskOffset, cache.GroupCount).ToList(); + return processors.Count > 0; + } + + public static IReadOnlyList ReadNumaNodeProcessors(IntPtr relationshipPtr, out int nodeNumber) + { + var numaNode = Marshal.PtrToStructure(relationshipPtr); + nodeNumber = unchecked((int)numaNode.NodeNumber); + var groupCount = numaNode.GroupCount == 0 + ? (ushort)1 + : numaNode.GroupCount; + + return ReadProcessorsFromGroupMasks(relationshipPtr, NumaGroupMaskOffset, groupCount).ToList(); + } + + public static IEnumerable CreateFallbackProcessors(int logicalProcessorCount) + { + return Enumerable.Range(0, logicalProcessorCount) + .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)); + } + + private static IEnumerable ReadProcessorsFromGroupMasks( + IntPtr relationshipPtr, + int groupMaskOffset, + ushort groupCount) + { + var firstGroupMaskPtr = IntPtr.Add(relationshipPtr, groupMaskOffset); + var stride = GroupAffinitySize; + for (var index = 0; index < groupCount; index++) + { + var groupAffinity = Marshal.PtrToStructure(IntPtr.Add(firstGroupMaskPtr, index * stride)); + foreach (var logicalProcessor in ReadProcessorsFromGroupAffinity(groupAffinity)) + { + yield return logicalProcessor; + } + } + } + + private static IEnumerable ReadProcessorsFromGroupAffinity(GroupAffinity groupAffinity) + { + var mask = groupAffinity.Mask.ToUInt64(); + for (var bit = 0; bit < 64; bit++) + { + if ((mask & (1UL << bit)) != 0) + { + yield return CreateProcessorRef(groupAffinity.Group, (byte)bit); + } + } + } + + public static ProcessorRef CreateProcessorRef(ushort group, byte logicalProcessorNumber) + { + return new ProcessorRef(group, logicalProcessorNumber, (group * 64) + logicalProcessorNumber); + } + } +} diff --git a/Services/WindowsCpuTopologyProvider.cs b/Services/WindowsCpuTopologyProvider.cs new file mode 100644 index 0000000..4a71ac2 --- /dev/null +++ b/Services/WindowsCpuTopologyProvider.cs @@ -0,0 +1,398 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.InteropServices; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + + /// + /// Builds instances from Windows CPU Sets and processor relationship APIs. + /// This provider is introduced for CPU topology v2 and is not wired into runtime affinity application yet. + /// + public sealed class WindowsCpuTopologyProvider : ICpuTopologyProvider + { + private const int ErrorInsufficientBuffer = 122; + + private readonly ILogger logger; + + public WindowsCpuTopologyProvider(ILogger? logger = null) + { + this.logger = logger ?? NullLogger.Instance; + } + + private enum CpuSetInformationType + { + CpuSetInformation = 0, + } + + private enum LogicalProcessorRelationship + { + RelationProcessorCore = 0, + RelationNumaNode = 1, + RelationCache = 2, + RelationProcessorPackage = 3, + RelationGroup = 4, + RelationProcessorDie = 5, + RelationNumaNodeEx = 6, + RelationProcessorModule = 7, + RelationAll = 0xFFFF, + } + + [StructLayout(LayoutKind.Sequential)] + private struct SystemCpuSetInformation + { + public uint Size; + public CpuSetInformationType Type; + public uint Id; + public ushort Group; + public byte LogicalProcessorIndex; + public byte CoreIndex; + public byte LastLevelCacheIndex; + public byte NumaNodeIndex; + public byte EfficiencyClass; + public byte AllFlags; + public uint SchedulingClassOrReserved; + public ulong AllocationTag; + } + + [StructLayout(LayoutKind.Sequential)] + private struct SystemLogicalProcessorInformationExHeader + { + public LogicalProcessorRelationship Relationship; + public int Size; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + out uint returnedLength, + IntPtr process, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetLogicalProcessorInformationEx( + LogicalProcessorRelationship relationshipType, + IntPtr buffer, + ref int returnedLength); + + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(this.CreateSnapshot(cancellationToken)); + } + + private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) + { + var logicalProcessors = new HashSet(); + var cpuSetIds = new Dictionary(); + var efficiencyClasses = new Dictionary(); + var coreIndexes = new Dictionary(); + var numaNodeIndexes = new Dictionary(); + var lastLevelCacheIndexes = new Dictionary(); + var packageIndexes = new Dictionary(); + var smtSiblingGlobalIndexes = new Dictionary>(); + + this.ReadCpuSetInformation( + logicalProcessors, + cpuSetIds, + efficiencyClasses, + coreIndexes, + numaNodeIndexes, + lastLevelCacheIndexes, + cancellationToken); + + this.ReadLogicalProcessorRelationships( + logicalProcessors, + efficiencyClasses, + coreIndexes, + numaNodeIndexes, + lastLevelCacheIndexes, + packageIndexes, + smtSiblingGlobalIndexes, + cancellationToken); + + if (efficiencyClasses.Values.Distinct().Count() <= 1) + { + efficiencyClasses.Clear(); + } + + if (logicalProcessors.Count == 0) + { + this.logger.LogWarning("CPU topology provider could not read Windows topology APIs; using Environment.ProcessorCount fallback"); + foreach (var processor in WindowsCpuTopologyNativeLayout.CreateFallbackProcessors(Environment.ProcessorCount)) + { + logicalProcessors.Add(processor); + coreIndexes[processor] = processor.GlobalIndex; + } + } + + var processors = logicalProcessors + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + + var signature = new CpuTopologySignature + { + LogicalProcessorCount = processors.Count, + PhysicalCoreCount = coreIndexes.Count == 0 + ? processors.Count + : coreIndexes.Values.Distinct().Count(), + ProcessorGroupCount = processors.Select(processor => processor.Group).Distinct().Count(), + NumaNodeCount = numaNodeIndexes.Values.Distinct().Count(), + LastLevelCacheGroupCount = lastLevelCacheIndexes.Values.Distinct().Count(), + PackageCount = packageIndexes.Values.Distinct().Count(), + Source = nameof(WindowsCpuTopologyProvider), + }; + + return CpuTopologySnapshot.Create( + processors, + cpuSetIds, + efficiencyClasses, + signature, + coreIndexes, + numaNodeIndexes, + lastLevelCacheIndexes, + packageIndexes, + smtSiblingGlobalIndexes); + } + + private void ReadCpuSetInformation( + HashSet logicalProcessors, + IDictionary cpuSetIds, + IDictionary efficiencyClasses, + IDictionary coreIndexes, + IDictionary numaNodeIndexes, + IDictionary lastLevelCacheIndexes, + CancellationToken cancellationToken) + { + uint requiredLength = 0; + if (GetSystemCpuSetInformation(IntPtr.Zero, 0, out requiredLength, IntPtr.Zero, 0)) + { + return; + } + + var firstError = Marshal.GetLastWin32Error(); + if (firstError != ErrorInsufficientBuffer || requiredLength == 0) + { + this.logger.LogDebug("GetSystemCpuSetInformation probe failed with Win32 error {Error}", firstError); + return; + } + + var buffer = Marshal.AllocHGlobal((int)requiredLength); + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!GetSystemCpuSetInformation(buffer, requiredLength, out requiredLength, IntPtr.Zero, 0)) + { + this.logger.LogDebug("GetSystemCpuSetInformation read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); + return; + } + + var offset = 0; + while (offset < requiredLength) + { + cancellationToken.ThrowIfCancellationRequested(); + var itemPtr = IntPtr.Add(buffer, offset); + var info = Marshal.PtrToStructure(itemPtr); + if (info.Size == 0) + { + break; + } + + if (info.Type == CpuSetInformationType.CpuSetInformation) + { + var processor = WindowsCpuTopologyNativeLayout.CreateProcessorRef(info.Group, info.LogicalProcessorIndex); + logicalProcessors.Add(processor); + cpuSetIds[processor] = info.Id; + efficiencyClasses[processor] = info.EfficiencyClass; + coreIndexes.TryAdd(processor, info.CoreIndex); + numaNodeIndexes[processor] = info.NumaNodeIndex; + lastLevelCacheIndexes[processor] = info.LastLevelCacheIndex; + } + + offset += (int)info.Size; + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private void ReadLogicalProcessorRelationships( + HashSet logicalProcessors, + IDictionary efficiencyClasses, + IDictionary coreIndexes, + IDictionary numaNodeIndexes, + IDictionary lastLevelCacheIndexes, + IDictionary packageIndexes, + IDictionary> smtSiblingGlobalIndexes, + CancellationToken cancellationToken) + { + var requiredLength = 0; + if (GetLogicalProcessorInformationEx(LogicalProcessorRelationship.RelationAll, IntPtr.Zero, ref requiredLength)) + { + return; + } + + var firstError = Marshal.GetLastWin32Error(); + if (firstError != ErrorInsufficientBuffer || requiredLength <= 0) + { + this.logger.LogDebug("GetLogicalProcessorInformationEx probe failed with Win32 error {Error}", firstError); + return; + } + + var buffer = Marshal.AllocHGlobal(requiredLength); + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!GetLogicalProcessorInformationEx(LogicalProcessorRelationship.RelationAll, buffer, ref requiredLength)) + { + this.logger.LogDebug("GetLogicalProcessorInformationEx read failed with Win32 error {Error}", Marshal.GetLastWin32Error()); + return; + } + + var coreIndex = 0; + var packageIndex = 0; + var lastLevelCacheIndex = 0; + var offset = 0; + while (offset < requiredLength) + { + cancellationToken.ThrowIfCancellationRequested(); + var itemPtr = IntPtr.Add(buffer, offset); + var header = Marshal.PtrToStructure(itemPtr); + if (header.Size <= 0) + { + break; + } + + switch (header.Relationship) + { + case LogicalProcessorRelationship.RelationProcessorCore: + this.ReadProcessorCoreRelationship( + itemPtr, + coreIndex++, + logicalProcessors, + efficiencyClasses, + coreIndexes, + smtSiblingGlobalIndexes); + break; + case LogicalProcessorRelationship.RelationProcessorPackage: + this.ReadIndexedProcessorRelationship( + itemPtr, + packageIndex++, + logicalProcessors, + packageIndexes); + break; + case LogicalProcessorRelationship.RelationCache: + if (TryReadL3CacheProcessors(itemPtr, out var cacheProcessors)) + { + foreach (var processor in cacheProcessors) + { + logicalProcessors.Add(processor); + lastLevelCacheIndexes[processor] = lastLevelCacheIndex; + } + + lastLevelCacheIndex++; + } + + break; + case LogicalProcessorRelationship.RelationNumaNode: + case LogicalProcessorRelationship.RelationNumaNodeEx: + this.ReadNumaNodeRelationship(itemPtr, logicalProcessors, numaNodeIndexes); + break; + } + + offset += header.Size; + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private void ReadProcessorCoreRelationship( + IntPtr itemPtr, + int coreIndex, + HashSet logicalProcessors, + IDictionary efficiencyClasses, + IDictionary coreIndexes, + IDictionary> smtSiblingGlobalIndexes) + { + var relationshipPtr = IntPtr.Add(itemPtr, 8); + var processor = Marshal.PtrToStructure(relationshipPtr); + var processorsInCore = WindowsCpuTopologyNativeLayout + .ReadProcessorRelationshipProcessors(relationshipPtr, processor.GroupCount) + .ToList(); + var siblingIndexes = processorsInCore.Select(item => item.GlobalIndex).ToList(); + + foreach (var logicalProcessor in processorsInCore) + { + logicalProcessors.Add(logicalProcessor); + efficiencyClasses[logicalProcessor] = processor.EfficiencyClass; + coreIndexes[logicalProcessor] = coreIndex; + smtSiblingGlobalIndexes[logicalProcessor] = siblingIndexes + .Where(index => index != logicalProcessor.GlobalIndex) + .OrderBy(index => index) + .ToList(); + } + } + + private void ReadIndexedProcessorRelationship( + IntPtr itemPtr, + int index, + HashSet logicalProcessors, + IDictionary indexMap) + { + var relationshipPtr = IntPtr.Add(itemPtr, 8); + var processor = Marshal.PtrToStructure(relationshipPtr); + foreach (var logicalProcessor in WindowsCpuTopologyNativeLayout.ReadProcessorRelationshipProcessors(relationshipPtr, processor.GroupCount)) + { + logicalProcessors.Add(logicalProcessor); + indexMap[logicalProcessor] = index; + } + } + + private static bool TryReadL3CacheProcessors(IntPtr itemPtr, out IReadOnlyList processors) + { + return WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(IntPtr.Add(itemPtr, 8), out processors); + } + + private void ReadNumaNodeRelationship( + IntPtr itemPtr, + HashSet logicalProcessors, + IDictionary numaNodeIndexes) + { + var processors = WindowsCpuTopologyNativeLayout.ReadNumaNodeProcessors(IntPtr.Add(itemPtr, 8), out var nodeNumber); + foreach (var processor in processors) + { + logicalProcessors.Add(processor); + numaNodeIndexes[processor] = nodeNumber; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/CpuTopologyProviderTests.cs b/Tests/ThreadPilot.Core.Tests/CpuTopologyProviderTests.cs new file mode 100644 index 0000000..c85b52b --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/CpuTopologyProviderTests.cs @@ -0,0 +1,310 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Runtime.InteropServices; + using System.Threading; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CpuTopologyProviderTests + { + [Fact] + public async Task FakeProvider_ReturnsSingleGroupEightLogicalProcessors() + { + var processors = CreateSequentialProcessors(8).ToList(); + var topology = CpuTopologySnapshot.Create(processors); + var provider = new FakeCpuTopologyProvider(topology); + + var snapshot = await provider.GetTopologySnapshotAsync(CancellationToken.None); + + Assert.Equal(8, snapshot.LogicalProcessors.Count); + Assert.All(snapshot.LogicalProcessors, processor => Assert.Equal(0, processor.Group)); + Assert.Equal(1, snapshot.Signature.ProcessorGroupCount); + } + + [Fact] + public void Snapshot_MultiGroupCpuZeroEntriesRemainDistinct() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + + var topology = CpuTopologySnapshot.Create( + [group0Cpu0, group1Cpu0], + cpuSetIds: new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + + Assert.True(topology.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); + Assert.True(topology.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); + Assert.Equal(100U, group0CpuSetId); + Assert.Equal(200U, group1CpuSetId); + Assert.Equal(2, topology.Signature.ProcessorGroupCount); + } + + [Fact] + public void Snapshot_PerformanceEfficiencyClass_UsesHighestClass() + { + var pCore = new ProcessorRef(0, 0, 0); + var eCore = new ProcessorRef(0, 1, 1); + + var topology = CpuTopologySnapshot.Create( + [pCore, eCore], + efficiencyClasses: new Dictionary + { + [pCore] = 2, + [eCore] = 0, + }); + + Assert.Equal(2, topology.GetPerformanceEfficiencyClass()); + Assert.True(topology.TryGetEfficiencyClass(pCore, out var pCoreClass)); + Assert.Equal(2, pCoreClass); + } + + [Fact] + public void Snapshot_WithoutEfficiencyClasses_IsValid() + { + var topology = CpuTopologySnapshot.Create(CreateSequentialProcessors(4)); + + Assert.Null(topology.GetPerformanceEfficiencyClass()); + Assert.False(topology.TryGetEfficiencyClass(new ProcessorRef(0, 0, 0), out _)); + Assert.Equal(4, topology.Signature.LogicalProcessorCount); + } + + [Fact] + public void Snapshot_WithSmtOn_MapsSiblingGroupsByCore() + { + var cpu0 = new ProcessorRef(0, 0, 0); + var cpu1 = new ProcessorRef(0, 1, 1); + var cpu2 = new ProcessorRef(0, 2, 2); + var cpu3 = new ProcessorRef(0, 3, 3); + + var topology = CpuTopologySnapshot.Create( + [cpu0, cpu1, cpu2, cpu3], + coreIndexes: new Dictionary + { + [cpu0] = 0, + [cpu1] = 0, + [cpu2] = 1, + [cpu3] = 1, + }, + smtSiblingGlobalIndexes: new Dictionary> + { + [cpu0] = [1], + [cpu1] = [0], + [cpu2] = [3], + [cpu3] = [2], + }, + signature: new CpuTopologySignature + { + LogicalProcessorCount = 4, + PhysicalCoreCount = 2, + ProcessorGroupCount = 1, + Source = "Test", + }); + + Assert.Equal(2, topology.Signature.PhysicalCoreCount); + Assert.True(topology.TryGetCoreIndex(cpu0, out var cpu0CoreIndex)); + Assert.True(topology.TryGetCoreIndex(cpu1, out var cpu1CoreIndex)); + Assert.Equal(0, cpu0CoreIndex); + Assert.Equal(cpu0CoreIndex, cpu1CoreIndex); + Assert.Equal([1], topology.GetSmtSiblingGlobalIndexes(cpu0)); + Assert.Equal([0], topology.GetSmtSiblingGlobalIndexes(cpu1)); + } + + [Fact] + public void Snapshot_WithSmtOff_HasOneLogicalProcessorPerCore() + { + var processors = CreateSequentialProcessors(8).ToList(); + var coreIndexes = processors.ToDictionary(processor => processor, processor => processor.GlobalIndex); + + var topology = CpuTopologySnapshot.Create( + processors, + coreIndexes: coreIndexes, + signature: new CpuTopologySignature + { + LogicalProcessorCount = 8, + PhysicalCoreCount = 8, + ProcessorGroupCount = 1, + Source = "Test", + }); + + Assert.Equal(8, topology.Signature.PhysicalCoreCount); + Assert.All(processors, processor => + { + Assert.True(topology.TryGetCoreIndex(processor, out var coreIndex)); + Assert.Equal(processor.GlobalIndex, coreIndex); + Assert.Empty(topology.GetSmtSiblingGlobalIndexes(processor)); + }); + } + + [Fact] + public void Snapshot_DualCcdCacheGroups_AreRepresentedByLastLevelCacheIndex() + { + var processors = CreateSequentialProcessors(12).ToList(); + var l3Indexes = processors.ToDictionary( + processor => processor, + processor => processor.GlobalIndex < 6 ? 0 : 1); + + var topology = CpuTopologySnapshot.Create( + processors, + lastLevelCacheIndexes: l3Indexes, + signature: new CpuTopologySignature + { + LogicalProcessorCount = 12, + PhysicalCoreCount = 12, + ProcessorGroupCount = 1, + LastLevelCacheGroupCount = 2, + Source = "Test", + }); + + Assert.Equal(2, topology.Signature.LastLevelCacheGroupCount); + Assert.All(processors.Take(6), processor => + { + Assert.True(topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex)); + Assert.Equal(0, cacheIndex); + }); + Assert.All(processors.Skip(6), processor => + { + Assert.True(topology.TryGetLastLevelCacheIndex(processor, out var cacheIndex)); + Assert.Equal(1, cacheIndex); + }); + } + + [Fact] + public void Snapshot_WithMoreThan64LogicalProcessors_IsValid() + { + var processors = Enumerable.Range(0, 72) + .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)) + .ToList(); + + var topology = CpuTopologySnapshot.Create(processors); + + Assert.Equal(72, topology.LogicalProcessors.Count); + Assert.Equal(2, topology.Signature.ProcessorGroupCount); + Assert.Contains(topology.LogicalProcessors, processor => processor.GlobalIndex == 64 && processor.Group == 1); + } + + [Fact] + public void NativeLayout_CacheRelationshipOffsets_MatchWin32Layout() + { + Assert.Equal(12, WindowsCpuTopologyNativeLayout.CacheReservedOffset); + Assert.Equal(30, WindowsCpuTopologyNativeLayout.CacheGroupCountOffset); + Assert.Equal(32, WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset); + } + + [Fact] + public void NativeLayout_NumaNodeRelationshipOffsets_MatchWin32Layout() + { + Assert.Equal(4, WindowsCpuTopologyNativeLayout.NumaReservedOffset); + Assert.Equal(22, WindowsCpuTopologyNativeLayout.NumaGroupCountOffset); + Assert.Equal(24, WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset); + } + + [Fact] + public void NativeLayout_NumaNodeWithZeroGroupCount_UsesSingleGroupMask() + { + using var buffer = NativeRelationshipBuffer.Allocate(WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset + WindowsCpuTopologyNativeLayout.GroupAffinitySize); + buffer.WriteUInt32(0, 7); + buffer.WriteUInt16(WindowsCpuTopologyNativeLayout.NumaGroupCountOffset, 0); + buffer.WriteGroupAffinity(WindowsCpuTopologyNativeLayout.NumaGroupMaskOffset, group: 1, mask: 0b101UL); + + var processors = WindowsCpuTopologyNativeLayout.ReadNumaNodeProcessors(buffer.Pointer, out var nodeNumber); + + Assert.Equal(7, nodeNumber); + Assert.Equal( + [new ProcessorRef(1, 0, 64), new ProcessorRef(1, 2, 66)], + processors); + } + + [Fact] + public void NativeLayout_L3CacheWithGroupCount_ReadsAllGroupMasks() + { + var size = WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset + (WindowsCpuTopologyNativeLayout.GroupAffinitySize * 2); + using var buffer = NativeRelationshipBuffer.Allocate(size); + buffer.WriteByte(0, 3); + buffer.WriteUInt16(WindowsCpuTopologyNativeLayout.CacheGroupCountOffset, 2); + buffer.WriteGroupAffinity(WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset, group: 0, mask: 0b11UL); + buffer.WriteGroupAffinity( + WindowsCpuTopologyNativeLayout.CacheGroupMaskOffset + WindowsCpuTopologyNativeLayout.GroupAffinitySize, + group: 1, + mask: 0b1UL); + + var wasRead = WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(buffer.Pointer, out var processors); + + Assert.True(wasRead); + Assert.Equal( + [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 1, 1), new ProcessorRef(1, 0, 64)], + processors); + } + + [Fact] + public void NativeLayout_CreateFallbackProcessors_UsesProcessorGroupsBeyond64() + { + var processors = WindowsCpuTopologyNativeLayout.CreateFallbackProcessors(66).ToList(); + + Assert.Equal(new ProcessorRef(0, 63, 63), processors[63]); + Assert.Equal(new ProcessorRef(1, 0, 64), processors[64]); + Assert.Equal(new ProcessorRef(1, 1, 65), processors[65]); + } + + private static IEnumerable CreateSequentialProcessors(int count) + { + return Enumerable.Range(0, count) + .Select(index => new ProcessorRef(0, (byte)index, index)); + } + + private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(snapshot); + } + } + + private sealed class NativeRelationshipBuffer : IDisposable + { + private NativeRelationshipBuffer(IntPtr pointer) + { + this.Pointer = pointer; + } + + public IntPtr Pointer { get; } + + public static NativeRelationshipBuffer Allocate(int size) + { + var pointer = Marshal.AllocHGlobal(size); + var bytes = new byte[size]; + Marshal.Copy(bytes, 0, pointer, bytes.Length); + return new NativeRelationshipBuffer(pointer); + } + + public void WriteByte(int offset, byte value) + { + Marshal.WriteByte(this.Pointer, offset, value); + } + + public void WriteUInt16(int offset, ushort value) + { + Marshal.WriteInt16(this.Pointer, offset, unchecked((short)value)); + } + + public void WriteUInt32(int offset, uint value) + { + Marshal.WriteInt32(this.Pointer, offset, unchecked((int)value)); + } + + public void WriteGroupAffinity(int offset, ushort group, ulong mask) + { + Marshal.WriteIntPtr(this.Pointer, offset, unchecked((nint)mask)); + this.WriteUInt16(offset + IntPtr.Size, group); + } + + public void Dispose() + { + Marshal.FreeHGlobal(this.Pointer); + } + } + } +}