From b52b57885aa723f0bc9b7e43755ab063883c2f3e Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Wed, 14 Jan 2026 00:59:22 -0600 Subject: [PATCH 1/8] Init Commit --- .../Redis/RedisServerExecutor.cs | 54 +++-- .../LinuxProcessAffinityConfigurationTests.cs | 173 +++++++++++++++ .../ProcessAffinityConfigurationTests.cs | 198 ++++++++++++++++++ .../LinuxProcessAffinityConfiguration.cs | 114 ++++++++++ .../ProcessAffinityConfiguration.cs | 149 +++++++++++++ .../VirtualClient.Core/ProcessExtensions.cs | 86 ++++++++ 6 files changed, 756 insertions(+), 18 deletions(-) create mode 100644 src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/LinuxProcessAffinityConfigurationTests.cs create mode 100644 src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs create mode 100644 src/VirtualClient/VirtualClient.Common/ProcessAffinity/LinuxProcessAffinityConfiguration.cs create mode 100644 src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs diff --git a/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs b/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs index ebdd45daee..4b3a9d2978 100644 --- a/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs @@ -18,6 +18,7 @@ namespace VirtualClient.Actions using VirtualClient.Common; using VirtualClient.Common.Contracts; using VirtualClient.Common.Extensions; + using VirtualClient.Common.ProcessAffinity; using VirtualClient.Common.Telemetry; using VirtualClient.Contracts; using VirtualClient.Contracts.Metadata; @@ -385,12 +386,10 @@ private void StartServerInstances(EventContext telemetryContext, CancellationTok { try { - string command = "bash"; + string command = "bash -c"; string workingDirectory = this.RedisPackagePath; - List commands = new List(); relatedContext.AddContext("command", command); - relatedContext.AddContext("commandArguments", commands); relatedContext.AddContext("workingDir", workingDirectory); for (int i = 0; i < this.ServerInstances; i++) @@ -399,16 +398,7 @@ private void StartServerInstances(EventContext telemetryContext, CancellationTok // will warm them up and then exit. We keep a reference to the server processes/tasks // so that they remain running until the class is disposed. int port = this.Port + i; - string commandArguments = null; - - if (this.BindToCores) - { - commandArguments = $"-c \"numactl -C {i} {this.RedisExecutablePath}"; - } - else - { - commandArguments = $"-c \"{this.RedisExecutablePath}"; - } + string commandArguments = this.RedisExecutablePath; if (this.IsTLSEnabled) { @@ -419,13 +409,12 @@ private void StartServerInstances(EventContext telemetryContext, CancellationTok commandArguments += $" --port {port}"; } - commandArguments += $" {this.CommandLine}\""; + commandArguments += $" {this.CommandLine}"; // We cannot use a Task.Run here. The Task is queued on the threadpool but does not get running // until our counter 'i' is at the end. This will cause all server instances to use the same port // and to try to bind to the same core. - commands.Add(commandArguments); - this.serverProcesses.Add(this.StartServerInstanceAsync(port, command, commandArguments, workingDirectory, relatedContext, cancellationToken)); + this.serverProcesses.Add(this.StartServerInstanceAsync(port, i, command, commandArguments, workingDirectory, relatedContext, cancellationToken)); } } catch (OperationCanceledException) @@ -435,14 +424,43 @@ private void StartServerInstances(EventContext telemetryContext, CancellationTok }); } - private Task StartServerInstanceAsync(int port, string command, string commandArguments, string workingDirectory, EventContext telemetryContext, CancellationToken cancellationToken) + private Task StartServerInstanceAsync(int port, int coreIndex, string command, string commandArguments, string workingDirectory, EventContext telemetryContext, CancellationToken cancellationToken) { return (this.ServerRetryPolicy ?? Policy.NoOpAsync()).ExecuteAsync(async () => { try { - using (IProcessProxy process = await this.ExecuteCommandAsync(command, commandArguments, workingDirectory, telemetryContext, cancellationToken, runElevated: true)) + IProcessProxy process = null; + // LINUX with affinity: Wrap command with numactl + if (this.BindToCores && this.Platform == PlatformID.Unix) + { + ProcessAffinityConfiguration affinityConfig = ProcessAffinityConfiguration.Create(this.Platform, new[] { coreIndex }); + command = "bash -c" + command; + process = this.SystemManagement.ProcessManager.CreateElevatedProcessWithAffinity( + this.Platform, + command, + commandArguments, + workingDirectory, + affinityConfig); + } + else + { + // No CPU affinity binding - standard elevated process + process = this.SystemManagement.ProcessManager.CreateElevatedProcess( + this.Platform, + command, + commandArguments, + workingDirectory); + } + + using (process) { + // Start the process + process.Start(); + + // Wait for process to exit + await process.WaitForExitAsync(cancellationToken); + if (!cancellationToken.IsCancellationRequested) { ConsoleLogger.Default.LogMessage($"Redis server process exited (port = {port})...", telemetryContext); diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/LinuxProcessAffinityConfigurationTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/LinuxProcessAffinityConfigurationTests.cs new file mode 100644 index 0000000000..3e55e59eb5 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/LinuxProcessAffinityConfigurationTests.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.ProcessAffinity +{ + using System; + using System.Linq; + using System.Text.RegularExpressions; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class LinuxProcessAffinityConfigurationTests + { + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlSpecForSingleCore() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0 }); + + // Verify through GetCommandWithAffinity which uses GetNumactlCoreSpec internally + string command = config.GetCommandWithAffinity("test", null); + + Assert.IsTrue(command.Contains("-C 0")); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlSpecForContiguousCores() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0, 1, 2, 3 }); + + string command = config.GetCommandWithAffinity("test", null); + + // Should be optimized to range notation + Assert.IsTrue(command.Contains("-C 0-3")); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlSpecForNonContiguousCores() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0, 2, 4 }); + + string command = config.GetCommandWithAffinity("test", null); + + Assert.IsTrue(command.Contains("-C 0,2,4")); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlSpecForMixedCores() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0, 1, 2, 5, 7, 8, 9 }); + + string command = config.GetCommandWithAffinity("test", null); + + // Should optimize ranges: 0-2,5,7-9 + Assert.IsTrue(command.Contains("-C 0-2,5,7-9")); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlSpecForComplexPattern() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration( + new[] { 0, 1, 2, 5, 6, 10, 12, 13, 14, 15 }); + + string command = config.GetCommandWithAffinity("test", null); + + // 0-2 (3 cores), 5,6 (2 cores), 10 (single), 12-15 (4 cores) + Assert.IsTrue(command.Contains("-C 0-2,5,6,10,12-15")); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlSpecForHighCoreIndices() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 100, 101, 102 }); + + string command = config.GetCommandWithAffinity("test", null); + + Assert.IsTrue(command.Contains("-C 100-102")); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlCommandForSingleCore() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0 }); + + string command = config.GetCommandWithAffinity(null, "myworkload --arg1 --arg2"); + + Assert.AreEqual("\"numactl -C 0 myworkload --arg1 --arg2\"", command); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlCommandForMultipleCores() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0, 1, 2 }); + + string command = config.GetCommandWithAffinity(null, "myworkload --arg1 --arg2"); + + Assert.AreEqual("\"numactl -C 0-2 myworkload --arg1 --arg2\"", command); + } + + [Test] + public void LinuxProcessAffinityConfigurationGeneratesCorrectNumactlCommandWithEmptyArguments() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 1, 3, 5 }); + + string command = config.GetCommandWithAffinity(null, "myworkload"); + + Assert.AreEqual("\"numactl -C 1,3,5 myworkload\"", command); + } + + [Test] + public void LinuxProcessAffinityConfigurationHandlesComplexArguments() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0, 1 }); + + string command = config.GetCommandWithAffinity( + null, + "myworkload --file=\"path with spaces\" --option=value"); + + // 2 cores use comma notation (0,1), not range (0-1) + Assert.AreEqual( + "\"numactl -C 0,1 myworkload --file=\"path with spaces\" --option=value\"", + command); + } + + [Test] + public void LinuxProcessAffinityConfigurationToStringIncludesNumactlSpec() + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 0, 1, 2, 5 }); + + string result = config.ToString(); + + Assert.IsTrue(result.Contains("0,1,2,5")); + Assert.IsTrue(result.Contains("numactl: -C 0-2,5")); + } + + [Test] + public void LinuxProcessAffinityConfigurationOptimizesRanges() + { + // Test various range optimization scenarios by checking the command output + // Note: 2 consecutive cores use comma notation (0,1), 3+ use range notation (0-2) + var testCases = new[] + { + (new[] { 0 }, "-C 0"), + (new[] { 0, 1 }, "-C 0,1"), // 2 cores: comma notation + (new[] { 0, 1, 2 }, "-C 0-2"), // 3+ cores: range notation + (new[] { 0, 2 }, "-C 0,2"), + (new[] { 0, 1, 3 }, "-C 0,1,3"), // 2 cores then gap + (new[] { 0, 1, 2, 4, 5, 6 }, "-C 0-2,4-6"), // Two 3-core ranges + (new[] { 0, 2, 4, 6, 8 }, "-C 0,2,4,6,8"), + (new[] { 0, 1, 2, 3, 5, 6, 7, 8, 10 }, "-C 0-3,5-8,10") // 4-core range, 4-core range, single + }; + + foreach (var (cores, expectedSpec) in testCases) + { + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(cores); + string command = config.GetCommandWithAffinity("test", null); + Assert.IsTrue(command.Contains(expectedSpec), $"Failed for cores: {string.Join(",", cores)}. Expected '{expectedSpec}' in '{command}'"); + } + } + + [Test] + public void LinuxProcessAffinityConfigurationHandlesUnsortedCores() + { + // Cores should be sorted before optimization + LinuxProcessAffinityConfiguration config = new LinuxProcessAffinityConfiguration(new[] { 5, 0, 2, 1, 3 }); + + string command = config.GetCommandWithAffinity("test", null); + + // Should sort and optimize: 0-3,5 + Assert.IsTrue(command.Contains("-C 0-3,5")); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs new file mode 100644 index 0000000000..926a7bb4b9 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.ProcessAffinity +{ + using System; + using System.Collections.Generic; + using System.Linq; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class ProcessAffinityConfigurationTests + { + [Test] + public void ProcessAffinityConfigurationParsesCommaSeparatedCoreSpecViaCreateMethod() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0,1,2,3"); + + Assert.IsNotNull(config.Cores); + Assert.AreEqual(4, config.Cores.Count()); + CollectionAssert.AreEqual(new[] { 0, 1, 2, 3 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationParsesRangeCoreSpecViaCreateMethod() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0-3"); + + Assert.IsNotNull(config.Cores); + Assert.AreEqual(4, config.Cores.Count()); + CollectionAssert.AreEqual(new[] { 0, 1, 2, 3 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationParsesMixedCoreSpecViaCreateMethod() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0,2-4,6"); + + Assert.IsNotNull(config.Cores); + Assert.AreEqual(5, config.Cores.Count()); + CollectionAssert.AreEqual(new[] { 0, 2, 3, 4, 6 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationParsesComplexCoreSpecViaCreateMethod() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0-2,5,7-9,12"); + + Assert.IsNotNull(config.Cores); + Assert.AreEqual(8, config.Cores.Count()); + CollectionAssert.AreEqual(new[] { 0, 1, 2, 5, 7, 8, 9, 12 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationParsesSingleCoreSpecViaCreateMethod() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "5"); + + Assert.IsNotNull(config.Cores); + Assert.AreEqual(1, config.Cores.Count()); + CollectionAssert.AreEqual(new[] { 5 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationThrowsOnInvalidCoreSpec() + { + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "invalid")); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0-")); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "-5")); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "a-b")); + } + + [Test] + public void ProcessAffinityConfigurationThrowsOnNullOrEmptyCoreSpec() + { + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, (string)null)); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, string.Empty)); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, " ")); + } + + [Test] + public void ProcessAffinityConfigurationCreatesWindowsConfiguration() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( + PlatformID.Win32NT, + new[] { 0, 1, 2 }); + + Assert.IsNotNull(config); + Assert.IsInstanceOf(config); + CollectionAssert.AreEqual(new[] { 0, 1, 2 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationCreatesLinuxConfiguration() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( + PlatformID.Unix, + new[] { 0, 1, 2 }); + + Assert.IsNotNull(config); + Assert.IsInstanceOf(config); + CollectionAssert.AreEqual(new[] { 0, 1, 2 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationCreatesWindowsConfigurationFromSpec() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( + PlatformID.Win32NT, + "0,1,2"); + + Assert.IsNotNull(config); + Assert.IsInstanceOf(config); + CollectionAssert.AreEqual(new[] { 0, 1, 2 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationCreatesLinuxConfigurationFromSpec() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( + PlatformID.Unix, + "0-2"); + + Assert.IsNotNull(config); + Assert.IsInstanceOf(config); + CollectionAssert.AreEqual(new[] { 0, 1, 2 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationThrowsOnUnsupportedPlatform() + { + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Other, + new[] { 0, 1, 2 })); + + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.MacOSX, + "0,1,2")); + } + + [Test] + public void ProcessAffinityConfigurationThrowsOnNegativeCoreIndexInCoreSpec() + { + // Negative indices are validated when parsing core list strings + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Win32NT, + "-1,0,1")); + + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Unix, + "0,-5,2")); + } + + [Test] + public void ProcessAffinityConfigurationThrowsOnEmptyCores() + { + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Win32NT, + Array.Empty())); + + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Unix, + new List())); + } + + [Test] + public void ProcessAffinityConfigurationRemovesDuplicateCores() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( + PlatformID.Win32NT, + new[] { 0, 1, 1, 2, 2, 2, 3 }); + + Assert.AreEqual(4, config.Cores.Count()); + CollectionAssert.AreEqual(new[] { 0, 1, 2, 3 }, config.Cores); + } + + [Test] + public void ProcessAffinityConfigurationToStringReturnsExpectedFormat() + { + // Windows configuration includes the mask in ToString() + ProcessAffinityConfiguration winConfig = ProcessAffinityConfiguration.Create( + PlatformID.Win32NT, + new[] { 0, 1, 2, 5 }); + string winString = winConfig.ToString(); + Assert.IsTrue(winString.Contains("0,1,2,5")); + Assert.IsTrue(winString.Contains("Mask:")); + + // Linux configuration includes numactl spec + ProcessAffinityConfiguration linuxConfig = ProcessAffinityConfiguration.Create( + PlatformID.Unix, + new[] { 0, 1, 2, 5 }); + string linuxString = linuxConfig.ToString(); + Assert.IsTrue(linuxString.Contains("0,1,2,5")); + Assert.IsTrue(linuxString.Contains("numactl:")); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common/ProcessAffinity/LinuxProcessAffinityConfiguration.cs b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/LinuxProcessAffinityConfiguration.cs new file mode 100644 index 0000000000..52e8817e91 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/LinuxProcessAffinityConfiguration.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.ProcessAffinity +{ + using System; + using System.Collections.Generic; + using System.Linq; + using VirtualClient.Common.Extensions; + + /// + /// Linux-specific CPU affinity configuration using numactl. + /// + public class LinuxProcessAffinityConfiguration : ProcessAffinityConfiguration + { + /// + /// Initializes a new instance of the class. + /// + /// The list of core indices to bind to. + public LinuxProcessAffinityConfiguration(IEnumerable cores) + : base(cores) + { + } + + /// + /// Gets the numactl core specification string (e.g., "0,1,2" or "0-3"). + /// + public string NumactlCoreSpec + { + get + { + return this.OptimizeCoreListForNumactl(); + } + } + + /// + /// Wraps a command with numactl to apply CPU affinity. + /// Returns the full bash command string ready for execution. + /// + /// The command to wrap. + /// Optional arguments for the command. + /// The complete command string with numactl wrapper (e.g., "bash -c \"numactl -C 0,1 redis-server --port 6379\""). + public string GetCommandWithAffinity(string command, string arguments = null) + { + return string.IsNullOrEmpty(command) ? $"\"numactl -C {this.NumactlCoreSpec} {arguments}\"" : $"{command} \"numactl -C {this.NumactlCoreSpec} {arguments}\""; + } + + /// + /// Gets a string representation including the numactl specification. + /// + public override string ToString() + { + return $"{base.ToString()} (numactl: -C {this.NumactlCoreSpec})"; + } + + /// + /// Optimizes the core list for numactl by converting consecutive cores to range notation. + /// Example: [0, 1, 2, 5, 6, 7, 8] ? "0-2,5-8" + /// + private string OptimizeCoreListForNumactl() + { + if (!this.Cores.Any()) + { + return string.Empty; + } + + List sortedCores = this.Cores.OrderBy(c => c).ToList(); + List ranges = new List(); + + int rangeStart = sortedCores[0]; + int rangeEnd = sortedCores[0]; + + for (int i = 1; i < sortedCores.Count; i++) + { + if (sortedCores[i] == rangeEnd + 1) + { + // Continue the range + rangeEnd = sortedCores[i]; + } + else + { + // End current range and start a new one + ranges.Add(FormatRange(rangeStart, rangeEnd)); + rangeStart = sortedCores[i]; + rangeEnd = sortedCores[i]; + } + } + + // Add the final range + ranges.Add(FormatRange(rangeStart, rangeEnd)); + + return string.Join(",", ranges); + } + + private static string FormatRange(int start, int end) + { + // Use range notation only if there are 3 or more consecutive cores + // This keeps the output concise: "0-2" instead of "0,1,2" + // but keeps "0,1" as-is since "0-1" isn't much shorter + if (end - start >= 2) + { + return $"{start}-{end}"; + } + else if (start == end) + { + return start.ToString(); + } + else + { + return $"{start},{end}"; + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs new file mode 100644 index 0000000000..d603e98091 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.ProcessAffinity +{ + using System; + using System.Collections.Generic; + using System.Linq; + using VirtualClient.Common.Extensions; + + /// + /// Base class for platform-specific CPU affinity configuration. + /// Provides abstraction for binding processes to specific CPU cores on different platforms. + /// + public abstract class ProcessAffinityConfiguration + { + /// + /// Initializes a new instance of the class. + /// + /// The list of core indices to bind to (e.g., [0, 1, 2]). + protected ProcessAffinityConfiguration(IEnumerable cores) + { + cores.ThrowIfNull(nameof(cores)); + if (!cores.Any()) + { + throw new ArgumentException("At least one core must be specified.", nameof(cores)); + } + + // Remove duplicates and sort cores for consistency + this.Cores = cores.Distinct().OrderBy(c => c).ToList().AsReadOnly(); + } + + /// + /// Gets the list of core indices to bind to. + /// + public IReadOnlyList Cores { get; } + + /// + /// Creates a platform-specific instance. + /// + /// The target platform (Windows or Linux). + /// The list of core indices to bind to. + /// A platform-specific affinity configuration instance. + public static ProcessAffinityConfiguration Create(PlatformID platform, IEnumerable cores) + { + cores.ThrowIfNullOrEmpty(nameof(cores)); + + return platform switch + { + PlatformID.Win32NT => new WindowsProcessAffinityConfiguration(cores), + PlatformID.Unix => new LinuxProcessAffinityConfiguration(cores), + _ => throw new NotSupportedException($"CPU affinity configuration is not supported on platform '{platform}'.") + }; + } + + /// + /// Creates a platform-specific instance from a core list string. + /// + /// The target platform (Windows or Linux). + /// A comma-separated list of core indices (e.g., "0,1,2,3" or "0-3"). + /// A platform-specific affinity configuration instance. + public static ProcessAffinityConfiguration Create(PlatformID platform, string coreList) + { + coreList.ThrowIfNullOrWhiteSpace(nameof(coreList)); + IEnumerable cores = ParseCoreList(coreList); + return Create(platform, cores); + } + + /// + /// Parses a core list string into a collection of core indices. + /// Supports comma-separated values (e.g., "0,1,2") and ranges (e.g., "0-3"). + /// + /// The core list string to parse. + /// A collection of core indices. + public static IEnumerable ParseCoreList(string coreList) + { + coreList.ThrowIfNullOrWhiteSpace(nameof(coreList)); + + List cores = new List(); + string[] parts = coreList.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (string part in parts) + { + string trimmed = part.Trim(); + + // Handle range notation (e.g., "0-3") + if (trimmed.Contains('-')) + { + string[] range = trimmed.Split('-'); + if (range.Length != 2) + { + throw new ArgumentException($"Invalid core range format: '{trimmed}'. Expected format: 'start-end' (e.g., '0-3').", nameof(coreList)); + } + + if (!int.TryParse(range[0].Trim(), out int start) || !int.TryParse(range[1].Trim(), out int end)) + { + throw new ArgumentException($"Invalid core range values: '{trimmed}'. Both start and end must be valid integers.", nameof(coreList)); + } + + if (start > end) + { + throw new ArgumentException($"Invalid core range: '{trimmed}'. Start value cannot be greater than end value.", nameof(coreList)); + } + + if (start < 0 || end < 0) + { + throw new ArgumentException($"Invalid core range: '{trimmed}'. Core indices cannot be negative.", nameof(coreList)); + } + + for (int i = start; i <= end; i++) + { + cores.Add(i); + } + } + else + { + // Handle individual core index + if (!int.TryParse(trimmed, out int core)) + { + throw new ArgumentException($"Invalid core index: '{trimmed}'. Must be a valid integer.", nameof(coreList)); + } + + if (core < 0) + { + throw new ArgumentException($"Invalid core index: '{core}'. Core indices cannot be negative.", nameof(coreList)); + } + + cores.Add(core); + } + } + + if (!cores.Any()) + { + throw new ArgumentException("Core list must contain at least one core.", nameof(coreList)); + } + + return cores.Distinct().OrderBy(c => c).ToList(); + } + + /// + /// Gets a string representation of the core list. + /// + /// A comma-separated string of core indices. + public override string ToString() + { + return string.Join(",", this.Cores); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs b/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs index f24a929f4a..f4097bf87b 100644 --- a/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs @@ -13,6 +13,7 @@ namespace VirtualClient using Microsoft.Extensions.Logging; using VirtualClient.Common; using VirtualClient.Common.Extensions; + using VirtualClient.Common.ProcessAffinity; using VirtualClient.Common.Telemetry; using VirtualClient.Contracts; @@ -68,6 +69,91 @@ public static IProcessProxy CreateElevatedProcess(this ProcessManager processMan return process; } + /// + /// Creates a process with CPU affinity binding to specific cores. + /// LINUX ONLY: Uses numactl to bind process to specific cores. + /// + /// The process manager used to create the process. + /// The command to run. + /// The command line arguments to supply to the command. + /// The working directory for the command. + /// The CPU affinity configuration specifying which cores to bind to. + /// A process proxy with CPU affinity applied via numactl wrapper. + public static IProcessProxy CreateProcessWithAffinity(this ProcessManager processManager, string command, string arguments, string workingDir, ProcessAffinityConfiguration affinityConfig) + { + processManager.ThrowIfNull(nameof(processManager)); + command.ThrowIfNullOrWhiteSpace(nameof(command)); + affinityConfig.ThrowIfNull(nameof(affinityConfig)); + + if (processManager.Platform != PlatformID.Unix) + { + throw new NotSupportedException( + $"CreateProcessWithAffinity is only supported on Linux. For Windows, use: " + + $"CreateProcess() + process.Start() + process.ApplyAffinity(windowsConfig)."); + } + + LinuxProcessAffinityConfiguration linuxConfig = affinityConfig as LinuxProcessAffinityConfiguration; + if (linuxConfig == null) + { + throw new ArgumentException( + $"Invalid affinity configuration type. Expected '{nameof(LinuxProcessAffinityConfiguration)}' for Linux platform.", + nameof(affinityConfig)); + } + + string fullCommand = linuxConfig.GetCommandWithAffinity(command, arguments); + + return processManager.CreateProcess(fullCommand, workingDir: workingDir); + } + + /// + /// Creates a process with CPU affinity binding to specific cores and applies elevated privileges if needed. + /// LINUX ONLY: Combines sudo elevation with numactl core binding. + /// + /// The process manager used to create the process. + /// The OS platform. + /// The command to run. + /// The command line arguments to supply to the command. + /// The working directory for the command. + /// The CPU affinity configuration specifying which cores to bind to. + /// The username to use for running the command (Linux only). + /// A process proxy with CPU affinity and elevated privileges applied. + public static IProcessProxy CreateElevatedProcessWithAffinity(this ProcessManager processManager, PlatformID platform, string command, string arguments, string workingDir, ProcessAffinityConfiguration affinityConfig, string username = null) + { + processManager.ThrowIfNull(nameof(processManager)); + command.ThrowIfNullOrWhiteSpace(nameof(command)); + affinityConfig.ThrowIfNull(nameof(affinityConfig)); + + if (platform != PlatformID.Unix) + { + throw new NotSupportedException( + $"CreateElevatedProcessWithAffinity is only supported on Linux. For Windows, use: " + + $"CreateElevatedProcess() + process.Start() + process.ApplyAffinity(windowsConfig)."); + } + + LinuxProcessAffinityConfiguration linuxConfig = affinityConfig as LinuxProcessAffinityConfiguration; + if (linuxConfig == null) + { + throw new ArgumentException( + $"Invalid affinity configuration type. Expected '{nameof(LinuxProcessAffinityConfiguration)}' for Linux platform.", + nameof(affinityConfig)); + } + + string fullCommand = linuxConfig.GetCommandWithAffinity(command, arguments); + + if (!string.Equals(command, "sudo") && !PlatformSpecifics.RunningInContainer) + { + string effectiveCommandArguments = string.IsNullOrWhiteSpace(username) + ? $"{fullCommand}" + : $"-u {username} {fullCommand}"; + + return processManager.CreateProcess("sudo", effectiveCommandArguments, workingDir); + } + else + { + return processManager.CreateProcess(fullCommand, workingDir: workingDir); + } + } + /// /// Returns the full command including arguments executed within the process. /// From e38b7c3f4c24496305ec58f0c2ad82a1d1a71a7d Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Wed, 14 Jan 2026 22:15:42 -0600 Subject: [PATCH 2/8] Update tests --- .../ProcessAffinityConfigurationTests.cs | 62 +++++-------------- .../ProcessAffinityConfiguration.cs | 5 +- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs index 926a7bb4b9..bc7c22275a 100644 --- a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs @@ -15,7 +15,7 @@ public class ProcessAffinityConfigurationTests [Test] public void ProcessAffinityConfigurationParsesCommaSeparatedCoreSpecViaCreateMethod() { - ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0,1,2,3"); + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, "0,1,2,3"); Assert.IsNotNull(config.Cores); Assert.AreEqual(4, config.Cores.Count()); @@ -25,7 +25,7 @@ public void ProcessAffinityConfigurationParsesCommaSeparatedCoreSpecViaCreateMet [Test] public void ProcessAffinityConfigurationParsesRangeCoreSpecViaCreateMethod() { - ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0-3"); + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, "0-3"); Assert.IsNotNull(config.Cores); Assert.AreEqual(4, config.Cores.Count()); @@ -35,7 +35,7 @@ public void ProcessAffinityConfigurationParsesRangeCoreSpecViaCreateMethod() [Test] public void ProcessAffinityConfigurationParsesMixedCoreSpecViaCreateMethod() { - ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0,2-4,6"); + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, "0,2-4,6"); Assert.IsNotNull(config.Cores); Assert.AreEqual(5, config.Cores.Count()); @@ -45,7 +45,7 @@ public void ProcessAffinityConfigurationParsesMixedCoreSpecViaCreateMethod() [Test] public void ProcessAffinityConfigurationParsesComplexCoreSpecViaCreateMethod() { - ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0-2,5,7-9,12"); + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, "0-2,5,7-9,12"); Assert.IsNotNull(config.Cores); Assert.AreEqual(8, config.Cores.Count()); @@ -55,7 +55,7 @@ public void ProcessAffinityConfigurationParsesComplexCoreSpecViaCreateMethod() [Test] public void ProcessAffinityConfigurationParsesSingleCoreSpecViaCreateMethod() { - ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "5"); + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, "5"); Assert.IsNotNull(config.Cores); Assert.AreEqual(1, config.Cores.Count()); @@ -65,30 +65,18 @@ public void ProcessAffinityConfigurationParsesSingleCoreSpecViaCreateMethod() [Test] public void ProcessAffinityConfigurationThrowsOnInvalidCoreSpec() { - Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "invalid")); - Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "0-")); - Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "-5")); - Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, "a-b")); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, "invalid")); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, "0-")); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, "-5")); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, "a-b")); } [Test] public void ProcessAffinityConfigurationThrowsOnNullOrEmptyCoreSpec() { - Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, (string)null)); - Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, string.Empty)); - Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Win32NT, " ")); - } - - [Test] - public void ProcessAffinityConfigurationCreatesWindowsConfiguration() - { - ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( - PlatformID.Win32NT, - new[] { 0, 1, 2 }); - - Assert.IsNotNull(config); - Assert.IsInstanceOf(config); - CollectionAssert.AreEqual(new[] { 0, 1, 2 }, config.Cores); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, (string)null)); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, string.Empty)); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, " ")); } [Test] @@ -103,18 +91,6 @@ public void ProcessAffinityConfigurationCreatesLinuxConfiguration() CollectionAssert.AreEqual(new[] { 0, 1, 2 }, config.Cores); } - [Test] - public void ProcessAffinityConfigurationCreatesWindowsConfigurationFromSpec() - { - ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( - PlatformID.Win32NT, - "0,1,2"); - - Assert.IsNotNull(config); - Assert.IsInstanceOf(config); - CollectionAssert.AreEqual(new[] { 0, 1, 2 }, config.Cores); - } - [Test] public void ProcessAffinityConfigurationCreatesLinuxConfigurationFromSpec() { @@ -144,7 +120,7 @@ public void ProcessAffinityConfigurationThrowsOnNegativeCoreIndexInCoreSpec() { // Negative indices are validated when parsing core list strings Assert.Throws(() => ProcessAffinityConfiguration.Create( - PlatformID.Win32NT, + PlatformID.Unix, "-1,0,1")); Assert.Throws(() => ProcessAffinityConfiguration.Create( @@ -156,7 +132,7 @@ public void ProcessAffinityConfigurationThrowsOnNegativeCoreIndexInCoreSpec() public void ProcessAffinityConfigurationThrowsOnEmptyCores() { Assert.Throws(() => ProcessAffinityConfiguration.Create( - PlatformID.Win32NT, + PlatformID.Unix, Array.Empty())); Assert.Throws(() => ProcessAffinityConfiguration.Create( @@ -168,7 +144,7 @@ public void ProcessAffinityConfigurationThrowsOnEmptyCores() public void ProcessAffinityConfigurationRemovesDuplicateCores() { ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( - PlatformID.Win32NT, + PlatformID.Unix, new[] { 0, 1, 1, 2, 2, 2, 3 }); Assert.AreEqual(4, config.Cores.Count()); @@ -178,14 +154,6 @@ public void ProcessAffinityConfigurationRemovesDuplicateCores() [Test] public void ProcessAffinityConfigurationToStringReturnsExpectedFormat() { - // Windows configuration includes the mask in ToString() - ProcessAffinityConfiguration winConfig = ProcessAffinityConfiguration.Create( - PlatformID.Win32NT, - new[] { 0, 1, 2, 5 }); - string winString = winConfig.ToString(); - Assert.IsTrue(winString.Contains("0,1,2,5")); - Assert.IsTrue(winString.Contains("Mask:")); - // Linux configuration includes numactl spec ProcessAffinityConfiguration linuxConfig = ProcessAffinityConfiguration.Create( PlatformID.Unix, diff --git a/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs index d603e98091..de0d55aba9 100644 --- a/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs +++ b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs @@ -38,7 +38,7 @@ protected ProcessAffinityConfiguration(IEnumerable cores) /// /// Creates a platform-specific instance. /// - /// The target platform (Windows or Linux). + /// The target platform. /// The list of core indices to bind to. /// A platform-specific affinity configuration instance. public static ProcessAffinityConfiguration Create(PlatformID platform, IEnumerable cores) @@ -47,7 +47,6 @@ public static ProcessAffinityConfiguration Create(PlatformID platform, IEnumerab return platform switch { - PlatformID.Win32NT => new WindowsProcessAffinityConfiguration(cores), PlatformID.Unix => new LinuxProcessAffinityConfiguration(cores), _ => throw new NotSupportedException($"CPU affinity configuration is not supported on platform '{platform}'.") }; @@ -56,7 +55,7 @@ public static ProcessAffinityConfiguration Create(PlatformID platform, IEnumerab /// /// Creates a platform-specific instance from a core list string. /// - /// The target platform (Windows or Linux). + /// The target platform. /// A comma-separated list of core indices (e.g., "0,1,2,3" or "0-3"). /// A platform-specific affinity configuration instance. public static ProcessAffinityConfiguration Create(PlatformID platform, string coreList) From cc25f5617084d5e75bf853f0a649db05c4359194 Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Fri, 16 Jan 2026 11:08:56 -0600 Subject: [PATCH 3/8] Update tests --- .../VirtualClient.Actions/Redis/RedisServerExecutor.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs b/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs index 4b3a9d2978..2c18d48f7f 100644 --- a/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs @@ -435,7 +435,6 @@ private Task StartServerInstanceAsync(int port, int coreIndex, string command, s if (this.BindToCores && this.Platform == PlatformID.Unix) { ProcessAffinityConfiguration affinityConfig = ProcessAffinityConfiguration.Create(this.Platform, new[] { coreIndex }); - command = "bash -c" + command; process = this.SystemManagement.ProcessManager.CreateElevatedProcessWithAffinity( this.Platform, command, From f54ea20cd11074e9caa2a0b90576b990ab6da8ef Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Thu, 22 Jan 2026 12:51:41 -0600 Subject: [PATCH 4/8] commit changes --- .../Redis/RedisServerExecutor.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs b/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs index 2c18d48f7f..6699857848 100644 --- a/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs +++ b/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs @@ -398,18 +398,22 @@ private void StartServerInstances(EventContext telemetryContext, CancellationTok // will warm them up and then exit. We keep a reference to the server processes/tasks // so that they remain running until the class is disposed. int port = this.Port + i; - string commandArguments = this.RedisExecutablePath; + string redisCommand = this.RedisExecutablePath; if (this.IsTLSEnabled) { - commandArguments += $" --tls-port {port} --port 0 --tls-cert-file {this.PlatformSpecifics.Combine(this.RedisResourcesPath, "redis.crt")} --tls-key-file {this.PlatformSpecifics.Combine(this.RedisResourcesPath, "redis.key")} --tls-ca-cert-file {this.PlatformSpecifics.Combine(this.RedisResourcesPath, "ca.crt")}"; + redisCommand += $" --tls-port {port} --port 0 --tls-cert-file {this.PlatformSpecifics.Combine(this.RedisResourcesPath, "redis.crt")} --tls-key-file {this.PlatformSpecifics.Combine(this.RedisResourcesPath, "redis.key")} --tls-ca-cert-file {this.PlatformSpecifics.Combine(this.RedisResourcesPath, "ca.crt")}"; } else { - commandArguments += $" --port {port}"; + redisCommand += $" --port {port}"; } - commandArguments += $" {this.CommandLine}"; + redisCommand += $" {this.CommandLine}"; + + // When binding to cores, CreateElevatedProcessWithAffinity wraps the command with numactl. + // When not binding to cores, we need to wrap the redis command in quotes for bash -c. + string commandArguments = this.BindToCores ? redisCommand : $"\"{redisCommand}\""; // We cannot use a Task.Run here. The Task is queued on the threadpool but does not get running // until our counter 'i' is at the end. This will cause all server instances to use the same port From ff91e6f66c6dbdeefd21ab6c85696b8e4d80246a Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Thu, 29 Jan 2026 00:36:16 -0600 Subject: [PATCH 5/8] Windows Changes --- ...ExampleWorkloadWithAffinityProfileTests.cs | 178 ++++++++++ .../ExampleWorkloadWithAffinityExecutor.cs | 309 ++++++++++++++++++ ...indowsProcessAffinityConfigurationTests.cs | 125 +++++++ .../ProcessAffinityConfiguration.cs | 1 + .../WindowsProcessAffinityConfiguration.cs | 132 ++++++++ .../ProcessExtensionsAffinityTests.cs | 157 +++++++++ .../VirtualClient.Core/ProcessExtensions.cs | 14 + .../profiles/PERF-CPU-EXAMPLE-AFFINITY.json | 58 ++++ .../InMemoryProcess.cs | 6 + 9 files changed, 980 insertions(+) create mode 100644 src/VirtualClient/VirtualClient.Actions.FunctionalTests/ExampleWorkloadWithAffinityProfileTests.cs create mode 100644 src/VirtualClient/VirtualClient.Actions/Examples/ExampleWorkloadWithAffinityExecutor.cs create mode 100644 src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/WindowsProcessAffinityConfigurationTests.cs create mode 100644 src/VirtualClient/VirtualClient.Common/ProcessAffinity/WindowsProcessAffinityConfiguration.cs create mode 100644 src/VirtualClient/VirtualClient.Core.UnitTests/ProcessExtensionsAffinityTests.cs create mode 100644 src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json diff --git a/src/VirtualClient/VirtualClient.Actions.FunctionalTests/ExampleWorkloadWithAffinityProfileTests.cs b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/ExampleWorkloadWithAffinityProfileTests.cs new file mode 100644 index 0000000000..45deeb2347 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Actions.FunctionalTests/ExampleWorkloadWithAffinityProfileTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Actions +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.InteropServices; + using System.Threading; + using System.Threading.Tasks; + using NUnit.Framework; + using VirtualClient.Common; + using VirtualClient.Contracts; + + [TestFixture] + [Category("Functional")] + public class ExampleWorkloadWithAffinityProfileTests + { + private DependencyFixture mockFixture; + + [OneTimeSetUp] + public void SetupFixture() + { + this.mockFixture = new DependencyFixture(); + ComponentTypeCache.Instance.LoadComponentTypes(TestDependencies.TestDirectory); + } + + [Test] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Unix, Architecture.X64)] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Unix, Architecture.Arm64)] + public void ExampleWorkloadProfileParametersAreInlinedCorrectly_Linux(string profile, PlatformID platform, Architecture architecture) + { + this.mockFixture.Setup(platform, architecture); + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.mockFixture.Dependencies)) + { + WorkloadAssert.ParameterReferencesInlined(executor.Profile); + } + } + + [Test] + [Platform(Exclude = "Unix,Linux,MacOsX")] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Win32NT, Architecture.X64)] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Win32NT, Architecture.Arm64)] + public void ExampleWorkloadProfileParametersAreInlinedCorrectly_Windows(string profile, PlatformID platform, Architecture architecture) + { + this.mockFixture.Setup(platform, architecture); + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.mockFixture.Dependencies)) + { + WorkloadAssert.ParameterReferencesInlined(executor.Profile); + } + } + + [Test] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Unix)] + public async Task ExampleWorkloadProfileInstallsTheExpectedDependenciesOnLinuxPlatform(string profile, PlatformID platform) + { + this.mockFixture.Setup(platform); + + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.mockFixture.Dependencies, dependenciesOnly: true)) + { + await executor.ExecuteAsync(ProfileTiming.OneIteration(), CancellationToken.None).ConfigureAwait(false); + + // Workload dependency package should be installed + WorkloadAssert.WorkloadPackageInstalled(this.mockFixture, "exampleworkload"); + } + } + + [Test] + [Platform(Exclude = "Unix,Linux,MacOsX")] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Win32NT)] + public async Task ExampleWorkloadProfileInstallsTheExpectedDependenciesOnWindowsPlatform(string profile, PlatformID platform) + { + this.mockFixture.Setup(platform); + + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.mockFixture.Dependencies, dependenciesOnly: true)) + { + await executor.ExecuteAsync(ProfileTiming.OneIteration(), CancellationToken.None).ConfigureAwait(false); + + // Workload dependency package should be installed + WorkloadAssert.WorkloadPackageInstalled(this.mockFixture, "exampleworkload"); + } + } + + [Test] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Unix)] + public async Task ExampleWorkloadProfileExecutesTheExpectedWorkloadWithAffinityOnLinux(string profile, PlatformID platform) + { + this.mockFixture.Setup(platform); + this.mockFixture.SetupPackage("exampleworkload", expectedFiles: "linux-x64/ExampleWorkload"); + + this.mockFixture.ProcessManager.OnCreateProcess = (command, arguments, workingDir) => + { + IProcessProxy process = this.mockFixture.CreateProcess(command, arguments, workingDir); + if (command.Contains("bash") && arguments.Contains("numactl")) + { + // Verify numactl wrapper is used for CPU affinity on Linux + process.StandardOutput.Append("{ \"metric1\": 100, \"metric2\": 200 }"); + } + + return process; + }; + + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.mockFixture.Dependencies)) + { + await executor.ExecuteAsync(ProfileTiming.OneIteration(), CancellationToken.None).ConfigureAwait(false); + + // Verify numactl was used for CPU affinity + WorkloadAssert.CommandsExecuted(this.mockFixture, "\"numactl -C"); + } + } + + [Test] + [Platform(Exclude = "Unix,Linux,MacOsX")] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json", PlatformID.Win32NT)] + public async Task ExampleWorkloadProfileExecutesTheExpectedWorkloadWithAffinityOnWindows(string profile, PlatformID platform) + { + this.mockFixture.Setup(platform); + this.mockFixture.SetupPackage("exampleworkload", expectedFiles: "win-x64/ExampleWorkload.exe"); + + this.mockFixture.ProcessManager.OnCreateProcess = (command, arguments, workingDir) => + { + InMemoryProcess process = this.mockFixture.CreateProcess(command, arguments, workingDir); + if (command.Contains("ExampleWorkload")) + { + // Set HasExited to false initially, true after "wait" + bool hasExited = false; + process.OnHasExited = () => hasExited; + process.OnStart = () => + { + hasExited = false; + return true; + }; + + process.OnApplyAffinity = (mask) => + { + // Verify affinity mask is set while process is running + Assert.IsFalse(hasExited, "Affinity should be applied while process is running"); + Assert.Greater(mask.ToInt64(), 0); + }; + + // Simulate process completion when WaitForExitAsync is called + Task originalWait = process.WaitForExitAsync(CancellationToken.None); + process.StandardOutput.Append("{ \"metric1\": 100, \"metric2\": 200 }"); + hasExited = true; + } + + return process; + }; + + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.mockFixture.Dependencies)) + { + await executor.ExecuteAsync(ProfileTiming.OneIteration(), CancellationToken.None).ConfigureAwait(false); + + // Verify the process was executed + WorkloadAssert.CommandsExecuted(this.mockFixture, "ExampleWorkload"); + } + } + + [Test] + [TestCase("PERF-CPU-EXAMPLE-AFFINITY.json")] + public void ExampleWorkloadProfileActionsWillNotBeExecutedIfTheWorkloadPackageDoesNotExist(string profile) + { + this.mockFixture.Setup(PlatformID.Unix); + + // Ensure the workload package does not exist + this.mockFixture.PackageManager.Clear(); + + using (ProfileExecutor executor = TestDependencies.CreateProfileExecutor(profile, this.mockFixture.Dependencies)) + { + executor.ExecuteDependencies = false; + + DependencyException error = Assert.ThrowsAsync(() => executor.ExecuteAsync(ProfileTiming.OneIteration(), CancellationToken.None)); + Assert.AreEqual(ErrorReason.WorkloadDependencyMissing, error.Reason); + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Actions/Examples/ExampleWorkloadWithAffinityExecutor.cs b/src/VirtualClient/VirtualClient.Actions/Examples/ExampleWorkloadWithAffinityExecutor.cs new file mode 100644 index 0000000000..068ef59680 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Actions/Examples/ExampleWorkloadWithAffinityExecutor.cs @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Actions +{ + using System; + using System.Collections.Generic; + using System.IO.Abstractions; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Platform; + using VirtualClient.Common.ProcessAffinity; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + + /// + /// Example workload executor demonstrating CPU core affinity binding. + /// This is a reference implementation showing how to use the ProcessAffinityConfiguration + /// infrastructure to bind workload processes to specific CPU cores. + /// + [SupportedPlatforms("linux-arm64,linux-x64,win-arm64,win-x64")] + public class ExampleWorkloadWithAffinityExecutor : VirtualClientComponent + { + private IFileSystem fileSystem; + private IPackageManager packageManager; + private ISystemManagement systemManagement; + private ProcessManager processManager; + + /// + /// Initializes a new instance of the class. + /// + /// Provides all of the required dependencies to the Virtual Client component. + /// + /// Parameters defined in the execution profile or supplied to the Virtual Client on the command line. + /// + public ExampleWorkloadWithAffinityExecutor(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + this.systemManagement = dependencies.GetService(); + this.fileSystem = this.systemManagement.FileSystem; + this.packageManager = this.systemManagement.PackageManager; + this.processManager = this.systemManagement.ProcessManager; + } + + /// + /// Gets or sets whether to bind the workload to specific CPU cores. + /// + public bool BindToCores + { + get + { + return this.Parameters.GetValue(nameof(this.BindToCores), defaultValue: false); + } + } + + /// + /// Gets the command line arguments to pass to the workload executable. + /// + public string CommandLine + { + get + { + return this.Parameters.GetValue(nameof(this.CommandLine)); + } + } + + /// + /// Gets the CPU core affinity specification (e.g., "0-3", "0,2,4,6"). + /// + public string CoreAffinity + { + get + { + this.Parameters.TryGetValue(nameof(this.CoreAffinity), out IConvertible value); + return value?.ToString(); + } + } + + /// + /// Gets the test name for metric categorization. + /// + public string TestName + { + get + { + return this.Parameters.GetValue(nameof(this.TestName), defaultValue: "ExampleAffinityTest"); + } + } + + /// + /// The path to the workload executable. + /// + protected string WorkloadExecutablePath { get; set; } + + /// + /// The workload package containing the executable. + /// + protected DependencyPath WorkloadPackage { get; set; } + + /// + /// Executes the workload. + /// + protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + try + { + this.Logger.LogTraceMessage($"{nameof(ExampleWorkloadWithAffinityExecutor)}.Starting", telemetryContext); + + DateTime startTime = DateTime.UtcNow; + + string workloadResults = await this.ExecuteWorkloadAsync(this.CommandLine, telemetryContext, cancellationToken) + .ConfigureAwait(false); + + DateTime finishTime = DateTime.UtcNow; + + await this.CaptureMetricsAsync(workloadResults, this.CommandLine, startTime, finishTime, telemetryContext, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when cancelled + } + } + + /// + /// Performs initialization operations for the executor. + /// + protected override async Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.WorkloadPackage = await this.GetPlatformSpecificPackageAsync(this.PackageName, cancellationToken); + + if (this.Platform == PlatformID.Win32NT) + { + this.WorkloadExecutablePath = this.Combine(this.WorkloadPackage.Path, "ExampleWorkload.exe"); + } + else + { + this.WorkloadExecutablePath = this.Combine(this.WorkloadPackage.Path, "ExampleWorkload"); + await this.systemManagement.MakeFileExecutableAsync(this.WorkloadExecutablePath, this.Platform, cancellationToken); + } + + if (!this.fileSystem.File.Exists(this.WorkloadExecutablePath)) + { + throw new DependencyException( + $"The expected workload binary/executable was not found in the '{this.PackageName}' package at '{this.WorkloadExecutablePath}'.", + ErrorReason.DependencyNotFound); + } + } + + /// + /// Validates the component parameters. + /// + protected override void Validate() + { + base.Validate(); + this.ThrowIfParameterNotDefined(nameof(this.PackageName)); + this.ThrowIfParameterNotDefined(nameof(this.CommandLine)); + + if (this.BindToCores) + { + this.ThrowIfParameterNotDefined(nameof(this.CoreAffinity)); + } + } + + private Task CaptureMetricsAsync(string results, string commandArguments, DateTime startTime, DateTime endTime, EventContext telemetryContext, CancellationToken cancellationToken) + { + if (!cancellationToken.IsCancellationRequested) + { + results.ThrowIfNullOrWhiteSpace(nameof(results)); + + this.Logger.LogMessage($"{nameof(ExampleWorkloadWithAffinityExecutor)}.CaptureMetrics", telemetryContext.Clone() + .AddContext("results", results)); + + ExampleWorkloadMetricsParser resultsParser = new ExampleWorkloadMetricsParser(results); + IList workloadMetrics = resultsParser.Parse(); + + foreach (var metric in workloadMetrics) + { + metric.Metadata.Add("bindToCores", this.BindToCores.ToString()); + if (this.BindToCores && !string.IsNullOrWhiteSpace(this.CoreAffinity)) + { + metric.Metadata.Add("coreAffinity", this.CoreAffinity); + } + } + + this.Logger.LogMetrics( + toolName: "ExampleWorkload", + scenarioName: this.Scenario, + scenarioStartTime: startTime, + scenarioEndTime: endTime, + metrics: workloadMetrics, + metricCategorization: null, + scenarioArguments: commandArguments, + this.Tags, + telemetryContext); + } + + return Task.CompletedTask; + } + + private Task ExecuteWorkloadAsync(string commandArguments, EventContext telemetryContext, CancellationToken cancellationToken) + { + EventContext relatedContext = telemetryContext.Clone() + .AddContext("packageName", this.PackageName) + .AddContext("packagePath", this.WorkloadPackage.Path) + .AddContext("command", this.WorkloadExecutablePath) + .AddContext("commandArguments", commandArguments) + .AddContext("bindToCores", this.BindToCores) + .AddContext("coreAffinity", this.CoreAffinity); + + return this.Logger.LogMessageAsync($"{nameof(ExampleWorkloadWithAffinityExecutor)}.ExecuteWorkload", relatedContext, async () => + { + using (BackgroundOperations profiling = BackgroundOperations.BeginProfiling(this, cancellationToken)) + { + IProcessProxy workloadProcess; + ProcessAffinityConfiguration affinityConfig = null; + + // CPU Affinity Binding Example: + // If BindToCores is enabled, create the process with CPU affinity configuration. + // This demonstrates the two different approaches for Windows vs. Linux. + if (this.BindToCores && !string.IsNullOrWhiteSpace(this.CoreAffinity)) + { + affinityConfig = ProcessAffinityConfiguration.Create( + this.Platform, + this.CoreAffinity); + + relatedContext.AddContext("affinityMask", affinityConfig.ToString()); + + if (this.Platform == PlatformID.Win32NT) + { + // Windows: Create process normally - affinity will be applied after starting + workloadProcess = this.processManager.CreateProcess( + this.WorkloadExecutablePath, + commandArguments, + this.WorkloadPackage.Path); + } + else + { + // Linux: Wrap command with numactl for CPU binding + LinuxProcessAffinityConfiguration linuxConfig = (LinuxProcessAffinityConfiguration)affinityConfig; + string fullCommand = linuxConfig.GetCommandWithAffinity( + this.WorkloadExecutablePath, + commandArguments); + + workloadProcess = this.processManager.CreateProcess( + "/bin/bash", + $"-c \"{fullCommand}\"", + this.WorkloadPackage.Path); + } + } + else + { + // No affinity binding - create process normally + workloadProcess = this.processManager.CreateProcess( + this.WorkloadExecutablePath, + commandArguments, + this.WorkloadPackage.Path); + } + + using (workloadProcess) + { + this.CleanupTasks.Add(() => workloadProcess.SafeKill(this.Logger)); + + // Windows affinity must be applied after process starts but before it exits + if (affinityConfig != null && this.Platform == PlatformID.Win32NT) + { + // Start process first + workloadProcess.Start(); + + // Apply affinity immediately while process is running + workloadProcess.ApplyAffinity((WindowsProcessAffinityConfiguration)affinityConfig); + + // Wait for completion + await workloadProcess.WaitForExitAsync(cancellationToken) + .ConfigureAwait(false); + } + else + { + // Linux (already wrapped with numactl) or no affinity: start and wait normally + await workloadProcess.StartAndWaitAsync(cancellationToken) + .ConfigureAwait(false); + } + + if (!cancellationToken.IsCancellationRequested) + { + await this.LogProcessDetailsAsync(workloadProcess, telemetryContext, "ExampleWorkload", logToFile: true) + .ConfigureAwait(false); + + workloadProcess.ThrowIfErrored(ProcessProxy.DefaultSuccessCodes, errorReason: ErrorReason.WorkloadFailed); + + if (workloadProcess.StandardOutput.Length == 0) + { + throw new WorkloadException( + $"Unexpected workload results outcome. The workload did not produce any results to standard output.", + ErrorReason.WorkloadResultsNotFound); + } + } + + return workloadProcess.StandardOutput.ToString(); + } + } + }); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/WindowsProcessAffinityConfigurationTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/WindowsProcessAffinityConfigurationTests.cs new file mode 100644 index 0000000000..4b445daac2 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/WindowsProcessAffinityConfigurationTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.ProcessAffinity +{ + using System; + using System.Linq; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class WindowsProcessAffinityConfigurationTests + { + [Test] + public void WindowsProcessAffinityConfigurationCalculatesCorrectBitmaskForSingleCore() + { + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0 }); + + Assert.AreEqual(1L, config.AffinityMask.ToInt64()); + } + + [Test] + public void WindowsProcessAffinityConfigurationCalculatesCorrectBitmaskForMultipleCores() + { + // Cores 0, 1, 2, 3 => 0b1111 = 15 + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0, 1, 2, 3 }); + + Assert.AreEqual(15L, config.AffinityMask.ToInt64()); + } + + [Test] + public void WindowsProcessAffinityConfigurationCalculatesCorrectBitmaskForNonContiguousCores() + { + // Cores 0, 2, 4 => 0b10101 = 21 + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0, 2, 4 }); + + Assert.AreEqual(21L, config.AffinityMask.ToInt64()); + } + + [Test] + public void WindowsProcessAffinityConfigurationCalculatesCorrectBitmaskForHighCoreIndices() + { + // Core 10 => 0b10000000000 = 1024 + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 10 }); + + Assert.AreEqual(1024L, config.AffinityMask.ToInt64()); + } + + [Test] + public void WindowsProcessAffinityConfigurationCalculatesCorrectBitmaskForMixedCores() + { + // Cores 0, 5, 10, 15 => 0b1000001000100001 = 33825 + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0, 5, 10, 15 }); + + Assert.AreEqual(33825L, config.AffinityMask.ToInt64()); + } + + [Test] + public void WindowsProcessAffinityConfigurationThrowsOnCoreIndexExceeding63() + { + // Windows supports up to 64 cores per group (indices 0-63) + Assert.Throws(() => new WindowsProcessAffinityConfiguration(new[] { 64 })); + Assert.Throws(() => new WindowsProcessAffinityConfiguration(new[] { 0, 1, 64 })); + Assert.Throws(() => new WindowsProcessAffinityConfiguration(new[] { 100 })); + } + + [Test] + public void WindowsProcessAffinityConfigurationSupportsMaxCoreIndex63() + { + // Core 63 is the maximum supported (0-based indexing) + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 63 }); + + Assert.AreEqual(1L << 63, config.AffinityMask.ToInt64()); + } + + [Test] + public void WindowsProcessAffinityConfigurationToStringIncludesMask() + { + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0, 1, 2, 3 }); + + string result = config.ToString(); + + Assert.IsTrue(result.Contains("0,1,2,3")); + Assert.IsTrue(result.Contains("Mask: 0xF")); // 15 in hex is 0xF + } + + [Test] + [Platform(Exclude = "Unix,Linux,MacOsX")] + public void WindowsProcessAffinityConfigurationApplyAffinityThrowsOnNullProcess() + { + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0, 1 }); + +#pragma warning disable CA1416 + Assert.Throws(() => config.ApplyAffinity(null)); +#pragma warning restore CA1416 + } + + [Test] + [Platform(Exclude = "Unix,Linux,MacOsX")] + public void WindowsProcessAffinityConfigurationApplyAffinityThrowsOnExitedProcess() + { + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0, 1 }); + InMemoryProcess process = new InMemoryProcess(); + process.OnHasExited = () => true; + +#pragma warning disable CA1416 + Assert.Throws(() => config.ApplyAffinity(process)); +#pragma warning restore CA1416 + } + + [Test] + [Platform(Exclude = "Unix,Linux,MacOsX")] + public void WindowsProcessAffinityConfigurationApplyAffinityThrowsOnIncompatibleProcessProxy() + { + WindowsProcessAffinityConfiguration config = new WindowsProcessAffinityConfiguration(new[] { 0, 1 }); + InMemoryProcess process = new InMemoryProcess(); + process.OnHasExited = () => false; + +#pragma warning disable CA1416 + // InMemoryProcess is not a ProcessProxy, so this should throw + Assert.Throws(() => config.ApplyAffinity(process)); +#pragma warning restore CA1416 + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs index de0d55aba9..284d5c03d9 100644 --- a/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs +++ b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs @@ -47,6 +47,7 @@ public static ProcessAffinityConfiguration Create(PlatformID platform, IEnumerab return platform switch { + PlatformID.Win32NT => new WindowsProcessAffinityConfiguration(cores), PlatformID.Unix => new LinuxProcessAffinityConfiguration(cores), _ => throw new NotSupportedException($"CPU affinity configuration is not supported on platform '{platform}'.") }; diff --git a/src/VirtualClient/VirtualClient.Common/ProcessAffinity/WindowsProcessAffinityConfiguration.cs b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/WindowsProcessAffinityConfiguration.cs new file mode 100644 index 0000000000..75dcfdcb2b --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/WindowsProcessAffinityConfiguration.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.ProcessAffinity +{ + using System; + using System.Collections.Generic; + using System.Linq; + using VirtualClient.Common.Extensions; + + /// + /// Windows-specific CPU affinity configuration using ProcessorAffinity bitmask. + /// + public class WindowsProcessAffinityConfiguration : ProcessAffinityConfiguration + { + /// + /// Initializes a new instance of the class. + /// + /// The list of core indices to bind to. + public WindowsProcessAffinityConfiguration(IEnumerable cores) + : base(cores) + { + this.ValidateCores(); + this.AffinityMask = (IntPtr)this.CalculateAffinityMask(); + } + + /// + /// Gets the processor affinity bitmask for the specified cores. + /// + public IntPtr AffinityMask { get; } + + /// + /// Applies the CPU affinity to the specified process. + /// + /// The process to apply affinity to. + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public void ApplyAffinity(IProcessProxy process) + { + process.ThrowIfNull(nameof(process)); + + if (process.HasExited) + { + throw new InvalidOperationException("Cannot set affinity on a process that has already exited."); + } + + try + { + // Check if this is a test/mock process with OnApplyAffinity delegate + // We use reflection to avoid a hard dependency on VirtualClient.TestFramework + System.Reflection.PropertyInfo affinityDelegateProperty = process.GetType().GetProperty( + "OnApplyAffinity", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + if (affinityDelegateProperty != null) + { + // This is a test/mock process - invoke the delegate instead of accessing real process + object delegateValue = affinityDelegateProperty.GetValue(process); + if (delegateValue != null) + { + (delegateValue as Action)?.Invoke(this.AffinityMask); + return; + } + } + + // Access the underlying Process through ProcessProxy + ProcessProxy processProxy = process as ProcessProxy; + if (processProxy == null) + { + throw new NotSupportedException( + $"Cannot apply CPU affinity. The process proxy type '{process.GetType().Name}' does not support affinity configuration."); + } + + // Use reflection to access the protected UnderlyingProcess property + System.Reflection.PropertyInfo propertyInfo = typeof(ProcessProxy).GetProperty( + "UnderlyingProcess", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (propertyInfo == null) + { + throw new NotSupportedException("Unable to access the underlying process for affinity configuration."); + } + + System.Diagnostics.Process underlyingProcess = propertyInfo.GetValue(processProxy) as System.Diagnostics.Process; + if (underlyingProcess != null) + { + underlyingProcess.ProcessorAffinity = this.AffinityMask; + } + } + catch (Exception ex) when (!(ex is InvalidOperationException || ex is NotSupportedException)) + { + throw new InvalidOperationException( + $"Failed to set processor affinity to cores [{this}] for process '{process.Name}' (PID: {process.Id}). " + + $"Affinity mask: 0x{this.AffinityMask.ToInt64():X}", + ex); + } + } + + /// + /// Gets a string representation including the affinity mask. + /// + public override string ToString() + { + return $"{base.ToString()} (Mask: 0x{this.AffinityMask.ToInt64():X})"; + } + + private long CalculateAffinityMask() + { + long mask = 0; + foreach (int core in this.Cores) + { + mask |= (1L << core); + } + + return mask; + } + + private void ValidateCores() + { + // Windows supports up to 64 cores per processor group (using 64-bit affinity mask) + const int MaxCoresPerGroup = 64; + + int maxCore = this.Cores.Max(); + if (maxCore >= MaxCoresPerGroup) + { + throw new NotSupportedException( + $"Core index {maxCore} exceeds the maximum supported core index ({MaxCoresPerGroup - 1}) for processor affinity on Windows. " + + $"Windows supports up to {MaxCoresPerGroup} cores per processor group. For systems with more than {MaxCoresPerGroup} cores, " + + $"consider using processor groups or contact the Virtual Client team for extended support."); + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Core.UnitTests/ProcessExtensionsAffinityTests.cs b/src/VirtualClient/VirtualClient.Core.UnitTests/ProcessExtensionsAffinityTests.cs new file mode 100644 index 0000000000..2d35af8ede --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core.UnitTests/ProcessExtensionsAffinityTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Core +{ + using System; + using System.Diagnostics; + using NUnit.Framework; + using VirtualClient.Common; + using VirtualClient.Common.ProcessAffinity; + + [TestFixture] + [Category("Unit")] + public class ProcessExtensionsAffinityTests + { + private MockFixture mockFixture; + + public void SetupDefaults(PlatformID platform) + { + this.mockFixture = new MockFixture(); + this.mockFixture.Setup(platform); + } + + [Test] + public void CreateProcessWithAffinityThrowsOnWindowsPlatform() + { + this.SetupDefaults(PlatformID.Win32NT); + + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, new[] { 0, 1 }); + + NotSupportedException ex = Assert.Throws(() => + this.mockFixture.ProcessManager.CreateProcessWithAffinity( + "command.exe", + "--args", + "C:\\workdir", + config)); + + StringAssert.Contains("CreateProcessWithAffinity is only supported on Linux. For Windows, use: CreateProcess() + process.Start() + process.ApplyAffinity(windowsConfig).", ex.Message); + } + + [Test] + public void CreateProcessWithAffinityCreatesExpectedBashCommandOnLinux() + { + this.SetupDefaults(PlatformID.Unix); + + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, new[] { 0, 1, 2 }); + + using (IProcessProxy process = this.mockFixture.ProcessManager.CreateProcessWithAffinity( + "bash -c", + "myworkload --option1=value", + "/home/user/workdir", + config)) + { + Assert.IsNotNull(process); + Assert.AreEqual("bash -c \"numactl -C 0-2 myworkload --option1=value\"", process.StartInfo.FileName); + Assert.AreEqual("/home/user/workdir", process.StartInfo.WorkingDirectory); + } + } + + [Test] + public void CreateProcessWithAffinityThrowsOnNullConfiguration() + { + this.SetupDefaults(PlatformID.Unix); + + Assert.Throws(() => + this.mockFixture.ProcessManager.CreateProcessWithAffinity( + "myworkload", + "--args", + "/home/user/workdir", + null)); + } + + [Test] + public void CreateElevatedProcessWithAffinityThrowsOnWindowsPlatform() + { + this.SetupDefaults(PlatformID.Win32NT); + + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Win32NT, new[] { 0, 1 }); + + NotSupportedException ex = Assert.Throws(() => + this.mockFixture.ProcessManager.CreateElevatedProcessWithAffinity( + PlatformID.Win32NT, + "command.exe", + "--args", + "C:\\workdir", + config)); + + StringAssert.Contains("CreateElevatedProcessWithAffinity is only supported on Linux. For Windows, use: CreateElevatedProcess() + process.Start() + process.ApplyAffinity(windowsConfig).", ex.Message); + } + + [Test] + public void CreateElevatedProcessWithAffinityCreatesExpectedCommandOnLinux() + { + this.SetupDefaults(PlatformID.Unix); + + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, new[] { 0, 1, 2 }); + + using (IProcessProxy process = this.mockFixture.ProcessManager.CreateElevatedProcessWithAffinity( + PlatformID.Unix, + "bash -c", + "myworkload --option1=value", + "/home/user/workdir", + config)) + { + Assert.IsNotNull(process); + Assert.AreEqual("sudo", process.StartInfo.FileName); + Assert.AreEqual("bash -c \"numactl -C 0-2 myworkload --option1=value\"", process.StartInfo.Arguments); + Assert.AreEqual("/home/user/workdir", process.StartInfo.WorkingDirectory); + } + } + + [Test] + public void CreateElevatedProcessWithAffinityHandlesComplexArguments() + { + this.SetupDefaults(PlatformID.Unix); + + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create(PlatformID.Unix, new[] { 0, 2, 4 }); + + using (IProcessProxy process = this.mockFixture.ProcessManager.CreateElevatedProcessWithAffinity( + PlatformID.Unix, + "bash -c", + "myworkload --file=\\\"path with spaces\\\" --number=123", + "/home/user/workdir", + config)) + { + Assert.IsNotNull(process); + Assert.AreEqual("sudo", process.StartInfo.FileName); + Assert.AreEqual("bash -c \"numactl -C 0,2,4 myworkload --file=\\\"path with spaces\\\" --number=123\"", process.StartInfo.Arguments); + } + } + + [Test] + public void CreateElevatedProcessWithAffinityThrowsOnNullConfiguration() + { + this.SetupDefaults(PlatformID.Unix); + + Assert.Throws(() => + this.mockFixture.ProcessManager.CreateElevatedProcessWithAffinity( + PlatformID.Unix, + "myworkload", + "--args", + "/home/user/workdir", + null)); + } + + [Test] + public void ApplyAffinityHelperThrowsOnNullConfiguration() + { + this.SetupDefaults(PlatformID.Win32NT); + + IProcessProxy process = this.mockFixture.ProcessManager.CreateProcess("command.exe"); + + Assert.Throws(() => + process.ApplyAffinity(null)); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs b/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs index f6182570e5..01344e3da2 100644 --- a/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs +++ b/src/VirtualClient/VirtualClient.Core/ProcessExtensions.cs @@ -154,6 +154,20 @@ public static IProcessProxy CreateElevatedProcessWithAffinity(this ProcessManage } } + /// + /// Applies Windows CPU affinity to a running process. + /// This should be called after the process has started. + /// + /// The process to apply affinity to. + /// The Windows-specific CPU affinity configuration. + public static void ApplyAffinity(this IProcessProxy process, WindowsProcessAffinityConfiguration affinityConfig) + { + process.ThrowIfNull(nameof(process)); + affinityConfig.ThrowIfNull(nameof(affinityConfig)); + + affinityConfig.ApplyAffinity(process); + } + /// /// Returns the full command including arguments executed within the process. /// diff --git a/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json b/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json new file mode 100644 index 0000000000..7a2b70b84d --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json @@ -0,0 +1,58 @@ +{ + "Description": "Example CPU Affinity Workload Profile", + "MinimumExecutionInterval": "00:00:05", + "Metadata": { + "SupportedPlatforms": "linux-x64,linux-arm64,win-x64,win-arm64" + }, + "Parameters": { + "ProfilingEnabled": false, + "ProfilingMode": "None" + }, + "Actions": [ + { + "Type": "ExampleWorkloadWithAffinityExecutor", + "Metadata": { + "ExampleMetadata1": "Value1", + "ExampleMetadata2": true, + "AffinityEnabled": true + }, + "Parameters": { + "Scenario": "CPUAffinityScenario", + "CommandLine": "Workload --duration=00:00:30", + "PackageName": "exampleworkload", + "BindToCores": true, + "CoreAffinity": "0-3", + "ProfilingScenario": "CPUAffinityScenario", + "ProfilingEnabled": "$.Parameters.ProfilingEnabled", + "ProfilingMode": "$.Parameters.ProfilingMode", + "ProfilingPeriod": "00:00:30", + "ProfilingWarmUpPeriod": "00:00:05", + "Tags": "Test,VC,CPUAffinity" + }, + "ExampleExtensions": { + "OpenSource": true, + "Contacts": [ + "virtualclient@microsoft.com" + ], + "Documentation": { + "General": "https://microsoft.github.io/VirtualClient", + "Profiles": "https://microsoft.github.io/VirtualClient/docs/guides/0011-profiles", + "Usage": "https://microsoft.github.io/VirtualClient/docs/guides/0010-command-line", + "CPUAffinity": "https://microsoft.github.io/VirtualClient/docs/guides/cpu-affinity" + } + } + } + ], + "Dependencies": [ + { + "Type": "DependencyPackageInstallation", + "Parameters": { + "Scenario": "InstallExampleWorkloadPackage", + "BlobContainer": "packages", + "BlobName": "exampleworkload.1.1.0.zip", + "PackageName": "exampleworkload", + "Extract": true + } + } + ] +} diff --git a/src/VirtualClient/VirtualClient.TestFramework/InMemoryProcess.cs b/src/VirtualClient/VirtualClient.TestFramework/InMemoryProcess.cs index 8a0e9bd33c..82c3109e65 100644 --- a/src/VirtualClient/VirtualClient.TestFramework/InMemoryProcess.cs +++ b/src/VirtualClient/VirtualClient.TestFramework/InMemoryProcess.cs @@ -175,6 +175,12 @@ public DateTime ExitTime /// public Func OnStart { get; set; } + /// + /// Delegate allows user/test to define the logic to execute when + /// CPU affinity is applied (Windows). + /// + public Action OnApplyAffinity { get; set; } + /// /// Closes the fake process. /// From 7e027fe494c9c104499f1dbc12f01199bfe76f47 Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Thu, 29 Jan 2026 12:33:11 -0600 Subject: [PATCH 6/8] Added Doccu --- website/docs/developing/0010-develop-guide.md | 20 +++++++++++-------- .../developing/0030-workload-onboarding.md | 15 ++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/website/docs/developing/0010-develop-guide.md b/website/docs/developing/0010-develop-guide.md index 7dd53629ca..679966b2e5 100644 --- a/website/docs/developing/0010-develop-guide.md +++ b/website/docs/developing/0010-develop-guide.md @@ -341,16 +341,20 @@ for functional correctness. * PackageManager * **Process Management** - The Virtual Client runtime platform execute operating system processes often as part of just about every workload/test executor, monitor or dependency installer/handler. In addition - there are times when processes need to be launched with elevated privileges. The responsibility for creating and managing processes within the runtime is encapsulated in the - following interfaces/classes. +The Virtual Client runtime platform execute operating system processes often as part of just about every workload/test executor, monitor or dependency installer/handler. In addition +there are times when processes need to be launched with elevated privileges. The responsibility for creating and managing processes within the runtime is encapsulated in the +following interfaces/classes. + +* ProcessManager +* UnixProcessManager +* WindowsProcessManager +* IProcessProxy +* ProcessProxy - * ProcessManager - * UnixProcessManager - * WindowsProcessManager - * IProcessProxy - * ProcessProxy +**CPU Affinity Support** +Virtual Client supports binding workload processes to specific CPU cores to enable core isolation testing. For detailed guidance on implementing +CPU affinity in workload executors, see the [Workload Onboarding Process](./0030-workload-onboarding.md#cpu-core-affinity-optional) documentation. * **State Management** Certain scenarios require the ability to preserve state information in between operations. For example, there are operations that make configuration settings changes to the diff --git a/website/docs/developing/0030-workload-onboarding.md b/website/docs/developing/0030-workload-onboarding.md index 9f66c3090b..a979d23f2e 100644 --- a/website/docs/developing/0030-workload-onboarding.md +++ b/website/docs/developing/0030-workload-onboarding.md @@ -60,6 +60,21 @@ to packaging workloads and dependencies in easy-to-consume Virtual Client packag * [Redis Workload Executor](https://github.com/microsoft/VirtualClient/tree/main/src/VirtualClient/VirtualClient.Actions/Redis) * [Memcached Workload Executor](https://github.com/microsoft/VirtualClient/tree/main/src/VirtualClient/VirtualClient.Actions/Memcached) +### CPU Core Affinity (Optional) +For workloads where CPU core pinning is preferred, Virtual Client provides CPU affinity support. + +**Core Specification Formats:** +* Individual cores: `"0,2,4,6"` +* Core ranges: `"0-7"` or `"0-3,8-11"` +* Supports up to 64 cores on Windows (single processor group) + +**Platform Differences:** +* **Linux:** Uses `numactl` - affinity set before the process starts (zero timing window) +* **Windows:** Uses `Process.ProcessorAffinity` - affinity set immediately after starting the process (~1ms window). + +**Reference Implementation:** +* [Example Workload with Affinity Executor](https://github.com/microsoft/VirtualClient/tree/main/src/VirtualClient/VirtualClient.Actions/Examples/ExampleWorkloadWithAffinityExecutor.cs) + ## Step 7: Dependencies Creation * In case, Workload requires one time set-up on VM then that can be added as a dependency in the VC. * Add \.cs(IISInstallation.cs) file in VirtualClient.Dependencies project. From 8a33af1b77d50efc15a70c1d772c42efc33bcc7f Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Thu, 29 Jan 2026 12:48:56 -0600 Subject: [PATCH 7/8] Format Doc --- website/docs/developing/0010-develop-guide.md | 19 ++++++------- .../developing/0030-workload-onboarding.md | 28 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/website/docs/developing/0010-develop-guide.md b/website/docs/developing/0010-develop-guide.md index 679966b2e5..03c09f7aad 100644 --- a/website/docs/developing/0010-develop-guide.md +++ b/website/docs/developing/0010-develop-guide.md @@ -345,16 +345,15 @@ The Virtual Client runtime platform execute operating system processes often as there are times when processes need to be launched with elevated privileges. The responsibility for creating and managing processes within the runtime is encapsulated in the following interfaces/classes. - -* ProcessManager -* UnixProcessManager -* WindowsProcessManager -* IProcessProxy -* ProcessProxy - -**CPU Affinity Support** -Virtual Client supports binding workload processes to specific CPU cores to enable core isolation testing. For detailed guidance on implementing -CPU affinity in workload executors, see the [Workload Onboarding Process](./0030-workload-onboarding.md#cpu-core-affinity-optional) documentation. + * ProcessManager + * UnixProcessManager + * WindowsProcessManager + * IProcessProxy + * ProcessProxy + + **CPU Affinity Support** + Virtual Client supports binding workload processes to specific CPU cores to enable core isolation testing. For detailed guidance on implementing + CPU affinity in workload executors, see the [Workload Onboarding Process](./0030-workload-onboarding.md#cpu-core-affinity-optional) documentation. * **State Management** Certain scenarios require the ability to preserve state information in between operations. For example, there are operations that make configuration settings changes to the diff --git a/website/docs/developing/0030-workload-onboarding.md b/website/docs/developing/0030-workload-onboarding.md index a979d23f2e..e4abcf7242 100644 --- a/website/docs/developing/0030-workload-onboarding.md +++ b/website/docs/developing/0030-workload-onboarding.md @@ -60,20 +60,20 @@ to packaging workloads and dependencies in easy-to-consume Virtual Client packag * [Redis Workload Executor](https://github.com/microsoft/VirtualClient/tree/main/src/VirtualClient/VirtualClient.Actions/Redis) * [Memcached Workload Executor](https://github.com/microsoft/VirtualClient/tree/main/src/VirtualClient/VirtualClient.Actions/Memcached) -### CPU Core Affinity (Optional) -For workloads where CPU core pinning is preferred, Virtual Client provides CPU affinity support. - -**Core Specification Formats:** -* Individual cores: `"0,2,4,6"` -* Core ranges: `"0-7"` or `"0-3,8-11"` -* Supports up to 64 cores on Windows (single processor group) - -**Platform Differences:** -* **Linux:** Uses `numactl` - affinity set before the process starts (zero timing window) -* **Windows:** Uses `Process.ProcessorAffinity` - affinity set immediately after starting the process (~1ms window). - -**Reference Implementation:** -* [Example Workload with Affinity Executor](https://github.com/microsoft/VirtualClient/tree/main/src/VirtualClient/VirtualClient.Actions/Examples/ExampleWorkloadWithAffinityExecutor.cs) + * **CPU Core Affinity (Optional)** + For workloads where CPU core pinning is preferred, Virtual Client provides CPU affinity support. + + **Core Specification Formats:** + * Individual cores: `"0,2,4,6"` + * Core ranges: `"0-7"` or `"0-3,8-11"` + * Supports up to 64 cores on Windows (single processor group) + + **Platform Differences:** + * **Linux:** Uses `numactl` - affinity set before the process starts (zero timing window) + * **Windows:** Uses `Process.ProcessorAffinity` - affinity set immediately after starting the process (~1ms window). + + **Reference Implementation:** + * [Example Workload with Affinity Executor](https://github.com/microsoft/VirtualClient/tree/main/src/VirtualClient/VirtualClient.Actions/Examples/ExampleWorkloadWithAffinityExecutor.cs) ## Step 7: Dependencies Creation * In case, Workload requires one time set-up on VM then that can be added as a dependency in the VC. From 09e1508704ef6d1719c89dda9a6969f00451c843 Mon Sep 17 00:00:00 2001 From: Rakeshwar Reddy Kambaiahgari Date: Thu, 29 Jan 2026 14:01:14 -0600 Subject: [PATCH 8/8] Update profile --- .../profiles/PERF-CPU-EXAMPLE-AFFINITY.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json b/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json index 7a2b70b84d..7fbd2a4727 100644 --- a/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json +++ b/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json @@ -6,7 +6,8 @@ }, "Parameters": { "ProfilingEnabled": false, - "ProfilingMode": "None" + "ProfilingMode": "None", + "CoreAffinity": "0-3" }, "Actions": [ { @@ -21,7 +22,7 @@ "CommandLine": "Workload --duration=00:00:30", "PackageName": "exampleworkload", "BindToCores": true, - "CoreAffinity": "0-3", + "CoreAffinity": "$.Parameters.CoreAffinity", "ProfilingScenario": "CPUAffinityScenario", "ProfilingEnabled": "$.Parameters.ProfilingEnabled", "ProfilingMode": "$.Parameters.ProfilingMode",