diff --git a/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs b/src/VirtualClient/VirtualClient.Actions/Redis/RedisServerExecutor.cs index ebdd45daee..6699857848 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,33 +398,27 @@ 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 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 // 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 +428,42 @@ 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 }); + 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..bc7c22275a --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/ProcessAffinity/ProcessAffinityConfigurationTests.cs @@ -0,0 +1,166 @@ +// 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.Unix, "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.Unix, "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.Unix, "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.Unix, "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.Unix, "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.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.Unix, (string)null)); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, string.Empty)); + Assert.Throws(() => ProcessAffinityConfiguration.Create(PlatformID.Unix, " ")); + } + + [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 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.Unix, + "-1,0,1")); + + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Unix, + "0,-5,2")); + } + + [Test] + public void ProcessAffinityConfigurationThrowsOnEmptyCores() + { + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Unix, + Array.Empty())); + + Assert.Throws(() => ProcessAffinityConfiguration.Create( + PlatformID.Unix, + new List())); + } + + [Test] + public void ProcessAffinityConfigurationRemovesDuplicateCores() + { + ProcessAffinityConfiguration config = ProcessAffinityConfiguration.Create( + PlatformID.Unix, + 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() + { + // 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..de0d55aba9 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common/ProcessAffinity/ProcessAffinityConfiguration.cs @@ -0,0 +1,148 @@ +// 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. + /// 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.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. + /// 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 fd720d2c02..f6182570e5 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. ///