diff --git a/test/HelixTasks/HelixSchedulingConfig.cs b/test/HelixTasks/HelixSchedulingConfig.cs new file mode 100644 index 000000000000..353e84b6706b --- /dev/null +++ b/test/HelixTasks/HelixSchedulingConfig.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Xml.Linq; + +namespace Microsoft.DotNet.SdkCustomHelix.Sdk; + +/// +/// Loads per-assembly scheduling hints from the embedded HelixSchedulingConfig.xml resource. +/// +internal sealed class HelixSchedulingConfig +{ + private const double DefaultEstimatedSecondsPerMethod = 2.0; + private const double DefaultMethodLimitMultiplier = 1.0; + + private readonly Dictionary _configs; + + private HelixSchedulingConfig(Dictionary configs) + { + _configs = configs; + } + + internal readonly struct AssemblyConfig + { + internal readonly double MethodLimitMultiplier; + internal readonly double EstimatedSecondsPerMethod; + + internal AssemblyConfig(double methodLimitMultiplier, double estimatedSecondsPerMethod) + { + MethodLimitMultiplier = methodLimitMultiplier; + EstimatedSecondsPerMethod = estimatedSecondsPerMethod; + } + } + + /// + /// Loads the config from the embedded XML resource. + /// + internal static HelixSchedulingConfig Load() + { + var configs = new Dictionary(StringComparer.OrdinalIgnoreCase); + + using var stream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream("HelixSchedulingConfig.xml"); + + if (stream is null) + { + return new HelixSchedulingConfig(configs); + } + + var doc = XDocument.Load(stream); + foreach (var element in doc.Root!.Elements("Assembly")) + { + var name = element.Attribute("Name")?.Value; + if (string.IsNullOrEmpty(name)) + { + continue; + } + + var multiplier = DefaultMethodLimitMultiplier; + if (double.TryParse(element.Attribute("MethodLimitMultiplier")?.Value, out var m)) + { + multiplier = m; + } + + var secondsPerMethod = DefaultEstimatedSecondsPerMethod; + if (double.TryParse(element.Attribute("EstimatedSecondsPerMethod")?.Value, out var s)) + { + secondsPerMethod = s; + } + + configs[name] = new AssemblyConfig(multiplier, secondsPerMethod); + } + + return new HelixSchedulingConfig(configs); + } + + /// + /// Gets the method limit multiplier for an assembly (defaults to 1.0). + /// + internal double GetMethodLimitMultiplier(string assemblyName) + { + return _configs.TryGetValue(assemblyName, out var config) + ? config.MethodLimitMultiplier + : DefaultMethodLimitMultiplier; + } + + /// + /// Gets the estimated seconds per method for an assembly (defaults to 2.0). + /// + internal double GetEstimatedSecondsPerMethod(string assemblyName) + { + return _configs.TryGetValue(assemblyName, out var config) + ? config.EstimatedSecondsPerMethod + : DefaultEstimatedSecondsPerMethod; + } + + /// + /// Estimates the duration of a work item based on its method count and assembly. + /// + internal double EstimateDuration(string assemblyName, int methodCount) + { + return methodCount * GetEstimatedSecondsPerMethod(assemblyName); + } +} diff --git a/test/HelixTasks/HelixSchedulingConfig.xml b/test/HelixTasks/HelixSchedulingConfig.xml new file mode 100644 index 000000000000..889820e56b3a --- /dev/null +++ b/test/HelixTasks/HelixSchedulingConfig.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/HelixTasks/HelixTasks.csproj b/test/HelixTasks/HelixTasks.csproj index 43c679c421ef..1110bb010d37 100644 --- a/test/HelixTasks/HelixTasks.csproj +++ b/test/HelixTasks/HelixTasks.csproj @@ -7,6 +7,10 @@ Microsoft.DotNet.SDK.Build.Helix + + + + diff --git a/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs b/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs index c8bfa9ef0dc4..83a44ce97042 100644 --- a/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs +++ b/test/HelixTasks/SDKCustomCreateXUnitWorkItemsWithTestExclusion.cs @@ -13,6 +13,8 @@ namespace Microsoft.DotNet.SdkCustomHelix.Sdk /// public class SDKCustomCreateXUnitWorkItemsWithTestExclusion : Build.Utilities.Task { + private HelixSchedulingConfig _schedulingConfig = null!; + /// /// An array of XUnit project workitems containing the following metadata: /// - [Required] PublishDirectory: the publish output directory of the XUnit project @@ -81,10 +83,24 @@ private async Task ExecuteAsync() return; } - XUnitWorkItems = (await Task.WhenAll(XUnitProjects.Select(PrepareWorkItem))) + _schedulingConfig = HelixSchedulingConfig.Load(); + + var workItems = (await Task.WhenAll(XUnitProjects.Select(PrepareWorkItem))) .SelectMany(i => i ?? new()) .Where(wi => wi != null) - .ToArray(); + .ToList(); + + // Sort by estimated duration descending (longest-first / LPT scheduling). + // This ensures Helix machines pick up the heaviest items first, reducing + // the chance of long items stacking on the same machine late in the run. + workItems.Sort((a, b) => + { + var durA = double.TryParse(a.GetMetadata("EstimatedDurationSeconds"), out var da) ? da : 0; + var durB = double.TryParse(b.GetMetadata("EstimatedDurationSeconds"), out var db) ? db : 0; + return durB.CompareTo(durA); + }); + + XUnitWorkItems = workItems.ToArray(); return; } @@ -152,7 +168,11 @@ private async Task ExecuteAsync() msbuildAdditionalSdkResolverFolder = ""; } - var scheduler = new AssemblyScheduler(methodLimit: !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TestFullMSBuild")) ? 32 : 16); + int baseMethodLimit = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TestFullMSBuild")) ? 32 : 16; + double multiplier = _schedulingConfig.GetMethodLimitMultiplier(assemblyName); + int methodLimit = Math.Max(1, (int)(baseMethodLimit * multiplier)); + + var scheduler = new AssemblyScheduler(methodLimit: methodLimit); var assemblyPartitionInfos = scheduler.Schedule(targetPath); var partitionedWorkItem = new List(); @@ -176,12 +196,14 @@ private async Task ExecuteAsync() Log.LogMessage($"Creating work item with properties Identity: {assemblyName}, PayloadDirectory: {publishDirectory}, Command: {command}"); + double estimatedDuration = _schedulingConfig.EstimateDuration(assemblyName, methodLimit); partitionedWorkItem.Add(new Microsoft.Build.Utilities.TaskItem(assemblyPartitionInfo.DisplayName + testIdentityDifferentiator, new Dictionary() { { "Identity", assemblyPartitionInfo.DisplayName + testIdentityDifferentiator}, { "PayloadDirectory", publishDirectory }, { "Command", command }, { "Timeout", timeout.ToString() }, + { "EstimatedDurationSeconds", estimatedDuration.ToString("F1") }, })); } diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs index ce7558ef2ef0..264614ef46ff 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs @@ -361,7 +361,7 @@ public void UpToDate_DefaultItems_Razor() .And.HaveStdOutContaining("error CS0246"); } - [Fact] + [Fact(Skip = "https://github.com/dotnet/sdk/issues/53930 - Temporarily disabled for Helix scheduling analysis")] public void CscOnly() { var testInstance = TestAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory);