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..7fbd2a4727 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/PERF-CPU-EXAMPLE-AFFINITY.json @@ -0,0 +1,59 @@ +{ + "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", + "CoreAffinity": "0-3" + }, + "Actions": [ + { + "Type": "ExampleWorkloadWithAffinityExecutor", + "Metadata": { + "ExampleMetadata1": "Value1", + "ExampleMetadata2": true, + "AffinityEnabled": true + }, + "Parameters": { + "Scenario": "CPUAffinityScenario", + "CommandLine": "Workload --duration=00:00:30", + "PackageName": "exampleworkload", + "BindToCores": true, + "CoreAffinity": "$.Parameters.CoreAffinity", + "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. /// diff --git a/website/docs/developing/0010-develop-guide.md b/website/docs/developing/0010-develop-guide.md index 7dd53629ca..03c09f7aad 100644 --- a/website/docs/developing/0010-develop-guide.md +++ b/website/docs/developing/0010-develop-guide.md @@ -341,10 +341,9 @@ 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 @@ -352,6 +351,10 @@ for functional correctness. * 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 system and then require a reboot. When the Virtual Client is restarted, it needs to know what previous requirements were completed. State management is also very important diff --git a/website/docs/developing/0030-workload-onboarding.md b/website/docs/developing/0030-workload-onboarding.md index 9f66c3090b..e4abcf7242 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.