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/4] 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/4] 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/4] 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/4] 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