diff --git a/Models/CpuSelection.cs b/Models/CpuSelection.cs new file mode 100644 index 0000000..a384c07 --- /dev/null +++ b/Models/CpuSelection.cs @@ -0,0 +1,298 @@ +/* + * 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.Models +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Identifies a logical processor without relying on a legacy 64-bit affinity mask. + /// + public readonly record struct ProcessorRef(ushort Group, byte LogicalProcessorNumber, int GlobalIndex); + + /// + /// Stable signature used to determine whether a persisted CPU selection was created for the current topology. + /// + public sealed record CpuTopologySignature + { + public string CpuBrand { get; init; } = "Unknown"; + + public int LogicalProcessorCount { get; init; } + + public int PhysicalCoreCount { get; init; } + + public int ProcessorGroupCount { get; init; } = 1; + + public int NumaNodeCount { get; init; } + + public int LastLevelCacheGroupCount { get; init; } + + public string Source { get; init; } = "Unknown"; + } + + /// + /// Metadata that explains how a CPU selection was built and whether it can be represented by legacy APIs. + /// + public sealed record CpuSelectionMetadata + { + public CpuTopologySignature? TopologySignature { get; init; } + + public bool CreatedFromLegacyAffinityMask { get; init; } + + public bool ContainsLogicalProcessorsBeyondLegacyMask { get; init; } + + public bool HasMultipleProcessorGroups { get; init; } + + public int ProcessorGroupCount { get; init; } + + public int MaxGlobalLogicalProcessorIndex { get; init; } = -1; + + public string SelectionReason { get; init; } = string.Empty; + } + + /// + /// Lightweight topology snapshot used by the CpuSelection migration layer. + /// Runtime topology detection will populate this in a later phase. + /// + public sealed class CpuTopologySnapshot + { + private readonly IReadOnlyDictionary cpuSetIdsByProcessor; + private readonly IReadOnlyDictionary efficiencyClassesByProcessor; + + private CpuTopologySnapshot( + IReadOnlyList logicalProcessors, + IReadOnlyDictionary cpuSetIdsByProcessor, + IReadOnlyDictionary efficiencyClassesByProcessor, + CpuTopologySignature signature) + { + this.LogicalProcessors = logicalProcessors; + this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; + this.efficiencyClassesByProcessor = efficiencyClassesByProcessor; + this.Signature = signature; + } + + public IReadOnlyList LogicalProcessors { get; } + + public CpuTopologySignature Signature { get; } + + public static CpuTopologySnapshot Create( + IEnumerable logicalProcessors, + IReadOnlyDictionary? cpuSetIds = null, + IReadOnlyDictionary? efficiencyClasses = null, + CpuTopologySignature? signature = null) + { + ArgumentNullException.ThrowIfNull(logicalProcessors); + + var processors = logicalProcessors + .Distinct() + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + + var duplicatedGlobalIndexes = processors + .GroupBy(processor => processor.GlobalIndex) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToList(); + if (duplicatedGlobalIndexes.Count > 0) + { + throw new ArgumentException( + $"GlobalIndex must be unique in a CPU topology snapshot. Duplicates: {string.Join(", ", duplicatedGlobalIndexes)}.", + nameof(logicalProcessors)); + } + + var processorSet = processors.ToHashSet(); + var cpuSetMap = cpuSetIds? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(); + + var efficiencyClassMap = efficiencyClasses? + .Where(kvp => processorSet.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(); + + var resolvedSignature = signature ?? new CpuTopologySignature + { + LogicalProcessorCount = processors.Count, + ProcessorGroupCount = processors.Select(processor => processor.Group).Distinct().Count(), + Source = "Snapshot", + }; + + return new CpuTopologySnapshot(processors, cpuSetMap, efficiencyClassMap, resolvedSignature); + } + + public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) => + this.cpuSetIdsByProcessor.TryGetValue(processor, out cpuSetId); + + public bool TryGetEfficiencyClass(ProcessorRef processor, out byte efficiencyClass) => + this.efficiencyClassesByProcessor.TryGetValue(processor, out efficiencyClass); + + public byte? GetPerformanceEfficiencyClass() + { + if (this.efficiencyClassesByProcessor.Count == 0) + { + return null; + } + + return this.efficiencyClassesByProcessor.Values.Max(); + } + } + + /// + /// Group-aware CPU selection model used by new persistence and migration code. + /// + public sealed record CpuSelection + { + public List CpuSetIds { get; init; } = new(); + + public List LogicalProcessors { get; init; } = new(); + + public List GlobalLogicalProcessorIndexes { get; init; } = new(); + + public CpuSelectionMetadata Metadata { get; init; } = new(); + + public static CpuSelection FromProcessors( + IEnumerable processors, + CpuTopologySnapshot topology, + string selectionReason = "") + { + ArgumentNullException.ThrowIfNull(processors); + ArgumentNullException.ThrowIfNull(topology); + + var selectedProcessors = processors + .Distinct() + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + + var topologyProcessors = topology.LogicalProcessors.ToHashSet(); + var missingProcessors = selectedProcessors + .Where(processor => !topologyProcessors.Contains(processor)) + .ToList(); + if (missingProcessors.Count > 0) + { + throw new ArgumentException( + $"CPU selection contains processor(s) not present in the topology: {string.Join(", ", missingProcessors)}.", + nameof(processors)); + } + + var cpuSetIds = selectedProcessors + .Select(processor => topology.TryGetCpuSetId(processor, out var cpuSetId) ? (uint?)cpuSetId : null) + .Where(cpuSetId => cpuSetId.HasValue) + .Select(cpuSetId => cpuSetId!.Value) + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + + return new CpuSelection + { + CpuSetIds = cpuSetIds, + LogicalProcessors = selectedProcessors, + GlobalLogicalProcessorIndexes = selectedProcessors + .Select(processor => processor.GlobalIndex) + .Distinct() + .OrderBy(index => index) + .ToList(), + Metadata = CreateMetadata(selectedProcessors, topology.Signature, createdFromLegacyAffinityMask: false, selectionReason), + }; + } + + public static CpuSelection FromLegacyAffinityMask(long mask, CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(topology); + + var unsignedMask = unchecked((ulong)mask); + var selectedIndexes = new HashSet(); + for (var bit = 0; bit < 64; bit++) + { + if ((unsignedMask & (1UL << bit)) != 0) + { + selectedIndexes.Add(bit); + } + } + + var selectedProcessors = topology.LogicalProcessors + .Where(processor => selectedIndexes.Contains(processor.GlobalIndex)) + .ToList(); + + var selection = FromProcessors(selectedProcessors, topology, "Migrated from legacy affinity mask"); + return selection with + { + Metadata = CreateMetadata( + selection.LogicalProcessors, + topology.Signature, + createdFromLegacyAffinityMask: true, + "Migrated from legacy affinity mask"), + }; + } + + public static long? ToLegacyAffinityMaskOrNull(CpuSelection selection) + { + ArgumentNullException.ThrowIfNull(selection); + + if (selection.LogicalProcessors.Any(processor => processor.GlobalIndex >= 64)) + { + return null; + } + + if (selection.LogicalProcessors.Select(processor => processor.Group).Distinct().Count() > 1) + { + return null; + } + + long mask = 0; + foreach (var processor in selection.LogicalProcessors) + { + if (processor.GlobalIndex < 0) + { + return null; + } + + mask |= 1L << processor.GlobalIndex; + } + + return mask; + } + + private static CpuSelectionMetadata CreateMetadata( + IReadOnlyCollection processors, + CpuTopologySignature signature, + bool createdFromLegacyAffinityMask, + string selectionReason) + { + var groups = processors.Select(processor => processor.Group).Distinct().ToList(); + var maxGlobalIndex = processors.Count == 0 + ? -1 + : processors.Max(processor => processor.GlobalIndex); + + return new CpuSelectionMetadata + { + TopologySignature = signature, + CreatedFromLegacyAffinityMask = createdFromLegacyAffinityMask, + ContainsLogicalProcessorsBeyondLegacyMask = maxGlobalIndex >= 64, + HasMultipleProcessorGroups = groups.Count > 1, + ProcessorGroupCount = groups.Count, + MaxGlobalLogicalProcessorIndex = maxGlobalIndex, + SelectionReason = selectionReason, + }; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/CpuSelectionTests.cs b/Tests/ThreadPilot.Core.Tests/CpuSelectionTests.cs new file mode 100644 index 0000000..2ea8da7 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/CpuSelectionTests.cs @@ -0,0 +1,230 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + + public sealed class CpuSelectionTests + { + [Fact] + public void CpuSelection_WithGlobalIndex64_DoesNotAliasCpu0InLegacyMask() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(1, 0, 64), + ]); + + var selection = CpuSelection.FromProcessors( + [new ProcessorRef(1, 0, 64)], + topology); + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + Assert.Null(legacyMask); + Assert.Contains(selection.LogicalProcessors, p => p.GlobalIndex == 64); + Assert.DoesNotContain(selection.LogicalProcessors, p => p.GlobalIndex == 0); + } + + [Fact] + public void CoreMask_ToProcessorAffinity_WithCpu64Only_DocumentsLegacyAliasBug() + { + var mask = new CoreMask { Name = "CPU64 Only" }; + for (var i = 0; i < 65; i++) + { + mask.BoolMask.Add(i == 64); + } + + var legacyAffinity = mask.ToProcessorAffinity(); + + Assert.Equal(1, legacyAffinity); + Assert.True((legacyAffinity & 1L) != 0); + } + + [Fact] + public void CpuTopologySnapshot_KeepsProcessorsWithSameLogicalIndexInDifferentGroupsDistinct() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var topology = CpuTopologySnapshot.Create( + [group0Cpu0, group1Cpu0], + 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.LogicalProcessors.Count); + } + + [Fact] + public void CpuTopologySnapshot_Create_ThrowsWhenGlobalIndexIsDuplicated() + { + var processors = new[] + { + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 0), + }; + + var exception = Assert.Throws(() => CpuTopologySnapshot.Create(processors)); + Assert.Contains("GlobalIndex", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void CpuTopologySnapshot_Create_ThrowsWhenLogicalProcessorsIsNull() + { + Assert.Throws(() => + CpuTopologySnapshot.Create(null!)); + } + + [Fact] + public void CpuTopologySnapshot_PerformanceEfficiencyClass_IsHighestNumericValue() + { + var eCore = new ProcessorRef(0, 8, 8); + var pCore = new ProcessorRef(0, 0, 0); + var topology = CpuTopologySnapshot.Create( + [pCore, eCore], + efficiencyClasses: new Dictionary + { + [pCore] = 2, + [eCore] = 0, + }); + + Assert.Equal(2, topology.GetPerformanceEfficiencyClass()); + } + + [Fact] + public void CpuTopologySnapshot_GetPerformanceEfficiencyClass_ReturnsNullWhenNoEfficiencyClassesExist() + { + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); + + var performanceClass = topology.GetPerformanceEfficiencyClass(); + + Assert.Null(performanceClass); + } + + [Fact] + public void FromLegacyAffinityMask_SelectsOnlyRepresentableProcessors() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + new ProcessorRef(1, 0, 64), + ]); + + var selection = CpuSelection.FromLegacyAffinityMask(0b11, topology); + + Assert.Equal([0, 1], selection.GlobalLogicalProcessorIndexes); + Assert.DoesNotContain(selection.LogicalProcessors, p => p.GlobalIndex == 64); + } + + [Fact] + public void FromLegacyAffinityMask_WithCpuSetId_SetsMigrationMetadataAndIndexes() + { + var cpu0 = new ProcessorRef(0, 0, 0); + var cpu2 = new ProcessorRef(0, 2, 2); + var topology = CpuTopologySnapshot.Create( + [cpu0, cpu2], + new Dictionary + { + [cpu0] = 300, + [cpu2] = 100, + }); + + var selection = CpuSelection.FromLegacyAffinityMask(0b101, topology); + + Assert.True(selection.Metadata.CreatedFromLegacyAffinityMask); + Assert.Equal("Migrated from legacy affinity mask", selection.Metadata.SelectionReason); + Assert.Equal([0, 2], selection.GlobalLogicalProcessorIndexes); + Assert.Equal([100U, 300U], selection.CpuSetIds); + } + + [Fact] + public void ToLegacyAffinityMaskOrNull_ReturnsMaskForSingleGroupBelow64() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 3, 3), + ]); + var selection = CpuSelection.FromProcessors(topology.LogicalProcessors, topology); + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + Assert.Equal(0b1001, legacyMask); + } + + [Fact] + public void FromProcessors_ThrowsWhenProcessorsIsNull() + { + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); + + Assert.Throws(() => + CpuSelection.FromProcessors(null!, topology)); + } + + [Fact] + public void FromProcessors_ThrowsWhenTopologyIsNull() + { + Assert.Throws(() => + CpuSelection.FromProcessors([new ProcessorRef(0, 0, 0)], null!)); + } + + [Fact] + public void FromProcessors_ThrowsWhenProcessorIsNotInTopology() + { + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0)]); + var missingProcessor = new ProcessorRef(0, 1, 1); + + var exception = Assert.Throws(() => + CpuSelection.FromProcessors([missingProcessor], topology)); + + Assert.Contains("topology", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FromProcessors_WithCpuSetIds_PopulatesDistinctOrderedCpuSetIds() + { + var cpu0 = new ProcessorRef(0, 0, 0); + var cpu1 = new ProcessorRef(0, 1, 1); + var cpu2 = new ProcessorRef(0, 2, 2); + var topology = CpuTopologySnapshot.Create( + [cpu0, cpu1, cpu2], + new Dictionary + { + [cpu0] = 200, + [cpu1] = 100, + [cpu2] = 200, + }); + + var selection = CpuSelection.FromProcessors([cpu0, cpu1, cpu2], topology); + + Assert.Equal([100U, 200U], selection.CpuSetIds); + } + + [Fact] + public void ToLegacyAffinityMaskOrNull_ThrowsWhenSelectionIsNull() + { + Assert.Throws(() => + CpuSelection.ToLegacyAffinityMaskOrNull(null!)); + } + + [Fact] + public void ToLegacyAffinityMaskOrNull_ReturnsNullForMultipleProcessorGroups() + { + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(1, 0, 64), + ]); + var selection = CpuSelection.FromProcessors(topology.LogicalProcessors, topology); + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + Assert.Null(legacyMask); + } + } +}