From ac921cb61e97bec514f3f6feb92e82dfdf0ffe38 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Thu, 21 May 2026 15:16:52 -0700 Subject: [PATCH 01/10] Split Build/Analyze into independent LOC-weighted matrices Replaces single batched-build-analyze matrix with two parallel matrix-gens, each tuned independently: - Build: LocTargetSeconds=1,200,000 (larger batches, fewer jobs, lower fixed-overhead) - Analyze: LocTargetSeconds=300,000 (smaller batches, more parallelism) Adds pr-matrix-presteps.yml params LocTargetSeconds (default 474000) and PublishArtifact (default true) so each matrix-gen can configure LOC target independently and only the first publishes BuildPackagesArtifact (avoiding duplicate-artifact collision). Adds Get-PackageLocWeights.ps1 (LOC counter) and Apply-WeightedBatching.ps1 (LPT bin-packing) which together drive the LOC-weighted bin-packing. Removes the now-unused batched-build-analyze.yml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/jobs/batched-analyze.yml | 48 +++++ ...ed-build-analyze.yml => batched-build.yml} | 26 --- eng/pipelines/templates/jobs/ci.yml | 44 ++++- .../templates/steps/pr-matrix-presteps.yml | 34 +++- eng/scripts/Apply-WeightedBatching.ps1 | 183 ++++++++++++++++++ eng/scripts/Get-PackageLocWeights.ps1 | 62 ++++++ 6 files changed, 365 insertions(+), 32 deletions(-) create mode 100644 eng/pipelines/templates/jobs/batched-analyze.yml rename eng/pipelines/templates/jobs/{batched-build-analyze.yml => batched-build.yml} (60%) create mode 100644 eng/scripts/Apply-WeightedBatching.ps1 create mode 100644 eng/scripts/Get-PackageLocWeights.ps1 diff --git a/eng/pipelines/templates/jobs/batched-analyze.yml b/eng/pipelines/templates/jobs/batched-analyze.yml new file mode 100644 index 000000000000..16319ec3c734 --- /dev/null +++ b/eng/pipelines/templates/jobs/batched-analyze.yml @@ -0,0 +1,48 @@ +parameters: + - name: Artifacts + type: object + default: [] + - name: ServiceDirectory + type: string + default: not-specified + - name: SDKType + type: string + default: all + - name: BuildSnippets + type: boolean + default: true + - name: DependsOn + type: string + default: '' + - name: CloudConfig + type: object + default: {} + - name: Matrix + type: string + - name: UsePlatformContainer + type: boolean + default: false + - name: OSName + type: string + +jobs: + - job: + displayName: "Analyze" + dependsOn: ${{ parameters.DependsOn }} + condition: and(succeededOrFailed(), ne(${{ parameters.Matrix }}, '{}')) + + strategy: + matrix: $[ ${{ parameters.Matrix }} ] + + pool: + name: $(LINUXPOOL) + image: $(LINUXVMIMAGE) + os: linux + + steps: + - template: /eng/pipelines/templates/steps/analyze.yml + parameters: + Artifacts: ${{ parameters.Artifacts }} + ServiceDirectory: ${{ parameters.ServiceDirectory }} + SDKType: ${{ parameters.SDKType }} + BuildSnippets: ${{ parameters.BuildSnippets }} diff --git a/eng/pipelines/templates/jobs/batched-build-analyze.yml b/eng/pipelines/templates/jobs/batched-build.yml similarity index 60% rename from eng/pipelines/templates/jobs/batched-build-analyze.yml rename to eng/pipelines/templates/jobs/batched-build.yml index a0df8ed6aa71..f34cd6364215 100644 --- a/eng/pipelines/templates/jobs/batched-build-analyze.yml +++ b/eng/pipelines/templates/jobs/batched-build.yml @@ -41,8 +41,6 @@ jobs: value: ${{ parameters.SDKType }} pool: - # 1es pipeline templates converts `image` to demands: ImageOverride under the hood - # which is incompatible with image selection in the default non-1es hosted pools ${{ if eq(parameters.OSName, 'macOS') }}: vmImage: $(OSVmImage) ${{ else }}: @@ -50,7 +48,6 @@ jobs: name: $(Pool) os: ${{ parameters.OSName }} ${{ if eq(parameters.UsePlatformContainer, 'true') }}: - # Add a default so the job doesn't fail when the matrix is empty container: $[ variables['Container'] ] steps: @@ -60,26 +57,3 @@ jobs: TestPipeline: ${{ parameters.TestPipeline }} ServiceDirectory: ${{ parameters.ServiceDirectory }} SDKType: ${{ parameters.SDKType }} - - - job: - displayName: "Analyze" - dependsOn: ${{ parameters.DependsOn }} - condition: and(succeededOrFailed(), ne(${{ parameters.Matrix }}, '{}')) - - # allow duplication of the job by matrix - strategy: - matrix: $[ ${{ parameters.Matrix }} ] - - # but immediately ignore the matrix for the job name - pool: - name: $(LINUXPOOL) - image: $(LINUXVMIMAGE) - os: linux - - steps: - - template: /eng/pipelines/templates/steps/analyze.yml - parameters: - Artifacts: ${{ parameters.Artifacts }} - ServiceDirectory: ${{ parameters.ServiceDirectory }} - SDKType: ${{ parameters.SDKType }} - BuildSnippets: ${{ parameters.BuildSnippets }} diff --git a/eng/pipelines/templates/jobs/ci.yml b/eng/pipelines/templates/jobs/ci.yml index c059f3307b3c..d4460d83935d 100644 --- a/eng/pipelines/templates/jobs/ci.yml +++ b/eng/pipelines/templates/jobs/ci.yml @@ -45,10 +45,11 @@ parameters: jobs: - ${{ if eq(parameters.ServiceDirectory, 'auto') }}: + # Build matrix - larger LOC batches (1.2M) for fewer jobs and lower per-job overhead. - template: /eng/common/pipelines/templates/jobs/generate-job-matrix.yml parameters: GenerateJobName: generate_build_matrix - JobTemplatePath: /eng/pipelines/templates/jobs/batched-build-analyze.yml + JobTemplatePath: /eng/pipelines/templates/jobs/batched-build.yml Pools: # eng/pipelines/templates/stages/build-matrix.json only contains windows OS currently so only pass the Windows pool to avoid the other OS's being marked skipped in DevOps - name: Windows filter: .*Windows.*Pool$ @@ -63,7 +64,7 @@ jobs: - "!SessionRecords" EnablePRGeneration: true PRMatrixSetting: ProjectNames - PRJobBatchSize: 5 + PRJobBatchSize: 1 AdditionalParameters: Artifacts: ${{ parameters.Artifacts }} TestPipeline: ${{ parameters.TestPipeline }} @@ -77,6 +78,45 @@ jobs: PublishingArtifactName: BuildPackagesArtifact ForceDirect: true ExcludePaths: ${{ parameters.ExcludePaths }} + EnableBuildWeighting: true + LocTargetSeconds: 1200000 + PublishArtifact: true + + # Analyze matrix - smaller LOC batches (300k) for more parallelism so analyze wall-clock matches build. + - template: /eng/common/pipelines/templates/jobs/generate-job-matrix.yml + parameters: + GenerateJobName: generate_analyze_matrix + JobTemplatePath: /eng/pipelines/templates/jobs/batched-analyze.yml + Pools: # build-matrix.json only contains Windows entries; batched-analyze.yml overrides pool to Linux internally. + - name: Windows + filter: .*Windows.*Pool$ + os: windows + MatrixConfigs: + - Name: NET_ci_analyze_base + Path: eng/pipelines/templates/stages/build-matrix.json + Selection: sparse + GenerateVMJobs: true + SparseCheckoutPaths: + - "/*" + - "!SessionRecords" + EnablePRGeneration: true + PRMatrixSetting: ProjectNames + PRJobBatchSize: 1 + AdditionalParameters: + Artifacts: ${{ parameters.Artifacts }} + ServiceDirectory: ${{ parameters.ServiceDirectory }} + SDKType: ${{ parameters.SDKType }} + BuildSnippets: ${{ parameters.BuildSnippets }} + PreGenerationSteps: + - template: /eng/pipelines/templates/steps/pr-matrix-presteps.yml + parameters: + ServiceDirectory: ${{ parameters.ServiceDirectory }} + PublishingArtifactName: BuildPackagesArtifact + ForceDirect: true + ExcludePaths: ${{ parameters.ExcludePaths }} + EnableBuildWeighting: true + LocTargetSeconds: 300000 + PublishArtifact: false - ${{ else }}: - job: Build diff --git a/eng/pipelines/templates/steps/pr-matrix-presteps.yml b/eng/pipelines/templates/steps/pr-matrix-presteps.yml index bd732d8bd2bd..44b3edca3055 100644 --- a/eng/pipelines/templates/steps/pr-matrix-presteps.yml +++ b/eng/pipelines/templates/steps/pr-matrix-presteps.yml @@ -9,6 +9,15 @@ parameters: - name: ExcludePaths type: object default: [] + - name: EnableBuildWeighting + type: boolean + default: false + - name: LocTargetSeconds + type: number + default: 474000 + - name: PublishArtifact + type: boolean + default: true steps: - template: /eng/pipelines/templates/steps/install-dotnet.yml @@ -28,10 +37,11 @@ steps: | Copy-Item -Destination "$(Build.ArtifactStagingDirectory)/PackageInfo/$($_.Name)" -Force displayName: Copy ArtifactInfo from Publishing Directory - - template: /eng/common/pipelines/templates/steps/publish-1es-artifact.yml - parameters: - ArtifactName: '${{ parameters.PublishingArtifactName }}' - ArtifactPath: '$(Build.ArtifactStagingDirectory)/PackageInfoPublishing' + - ${{ if eq(parameters.PublishArtifact, true) }}: + - template: /eng/common/pipelines/templates/steps/publish-1es-artifact.yml + parameters: + ArtifactName: '${{ parameters.PublishingArtifactName }}' + ArtifactPath: '$(Build.ArtifactStagingDirectory)/PackageInfoPublishing' - pwsh: | $packageInfos = Get-ChildItem -Path "$(Build.ArtifactStagingDirectory)/PackageInfo" -Filter "*.json" -Recurse ` @@ -62,3 +72,19 @@ steps: displayName: Only target direct packages for build and test condition: and(succeededOrFailed(), eq(${{ parameters.ForceDirect }}, true)) + - ${{ if eq(parameters.EnableBuildWeighting, true) }}: + - pwsh: | + eng/scripts/Get-PackageLocWeights.ps1 ` + -PackageInfoFolder "$(Build.ArtifactStagingDirectory)/PackageInfo" ` + -RepoRoot "$(Build.SourcesDirectory)" ` + -OutputFile "$(Build.ArtifactStagingDirectory)/loc-weights.json" + displayName: Count LOC per package for build/analyze weighting + condition: and(succeededOrFailed(), eq(variables['Build.Reason'], 'PullRequest')) + + - pwsh: | + eng/scripts/Apply-WeightedBatching.ps1 ` + -PackageInfoFolder "$(Build.ArtifactStagingDirectory)/PackageInfo" ` + -WeightsFile "$(Build.ArtifactStagingDirectory)/loc-weights.json" ` + -TargetSeconds ${{ parameters.LocTargetSeconds }} + displayName: Apply LOC-weighted batching (target ${{ parameters.LocTargetSeconds }}) + condition: and(succeededOrFailed(), eq(variables['Build.Reason'], 'PullRequest')) \ No newline at end of file diff --git a/eng/scripts/Apply-WeightedBatching.ps1 b/eng/scripts/Apply-WeightedBatching.ps1 new file mode 100644 index 000000000000..83999efe9169 --- /dev/null +++ b/eng/scripts/Apply-WeightedBatching.ps1 @@ -0,0 +1,183 @@ +<# +.SYNOPSIS +Applies weighted LPT bin-packing to PackageInfo files for balanced CI test batching. + +.DESCRIPTION +Reads PackageInfo JSON files and a test weights file, performs LPT (Longest Processing Time) +bin-packing to create balanced batches, then consolidates the PackageInfo files so that each +batch becomes a single representative file. The ArtifactName of each consolidated file contains +the comma-separated names of all packages in that batch, which flows through to the +ProjectNames parameter in the matrix generation. + +After this script runs, set PRJobBatchSize to 1 so each consolidated file becomes its own job. + +.PARAMETER PackageInfoFolder +Path to the folder containing PackageInfo JSON files. + +.PARAMETER WeightsFile +Path to the JSON weights file (package name → seconds). + +.PARAMETER TargetSeconds +Target maximum time per bucket in seconds for direct packages. Default is 1800 (30 minutes). + +.PARAMETER IndirectTargetSeconds +Target maximum time per bucket in seconds for indirect packages. Defaults to TargetSeconds if not specified. +Indirect packages only run on Linux, so they can use a higher target than direct packages which run across all platforms. + +.PARAMETER DefaultWeight +Weight assigned to packages not found in the weights file. Default is 1. +#> + +[CmdletBinding()] +param ( + [Parameter(Mandatory = $true)][string]$PackageInfoFolder, + [Parameter(Mandatory = $true)][string]$WeightsFile, + [Parameter()][int]$TargetSeconds = 1800, + [Parameter()][int]$IndirectTargetSeconds = 0, + [Parameter()][int]$DefaultWeight = 1 +) + +Set-StrictMode -Version 4 + +if ($IndirectTargetSeconds -le 0) { $IndirectTargetSeconds = $TargetSeconds } + +# Load weights +$weights = @{} +if (Test-Path $WeightsFile) { + try { + $content = Get-Content $WeightsFile -Raw | ConvertFrom-Json + $content.PSObject.Properties | ForEach-Object { $weights[$_.Name] = [int]$_.Value } + } + catch { + Write-Warning "Failed to load weights: $($_.Exception.Message). Skipping weighted batching." + return + } +} + +if ($weights.Count -eq 0) { + Write-Host "No weights available. Skipping weighted batching." + return +} + +# Load all PackageInfo files +$packageFiles = Get-ChildItem -Path $PackageInfoFolder -Filter "*.json" -Recurse +$packages = @($packageFiles | ForEach-Object { + $json = Get-Content $_.FullName | ConvertFrom-Json + [PSCustomObject]@{ + FilePath = $_.FullName + FileName = $_.Name + Json = $json + } +}) + +if ($packages.Count -eq 0) { + Write-Host "No PackageInfo files found. Skipping weighted batching." + return +} + +# Separate direct and indirect packages +$directPackages = @($packages | Where-Object { $_.Json.IncludedForValidation -eq $false }) +$indirectPackages = @($packages | Where-Object { $_.Json.IncludedForValidation -eq $true }) + +$totalPkgs = $directPackages.Count + $indirectPackages.Count +$matchedWeights = @($packages | Where-Object { $weights.ContainsKey($_.Json.ArtifactName) }).Count +Write-Host "Packages: $($directPackages.Count) direct, $($indirectPackages.Count) indirect ($matchedWeights/$totalPkgs have weights)" + +function Apply-LPTBatching { + param( + [object[]]$Packages, + [hashtable]$Weights, + [int]$TargetSeconds, + [int]$DefaultWeight, + [string]$Label + ) + + if ($Packages.Count -le 1) { + Write-Host " $Label`: Only $($Packages.Count) package(s), no batching needed." + return + } + + # Build weighted items + $items = @(foreach ($pkg in $Packages) { + $name = $pkg.Json.ArtifactName + $weight = if ($Weights.ContainsKey($name)) { [int]$Weights[$name] } else { $DefaultWeight } + [PSCustomObject]@{ Package = $pkg; Weight = $weight; Name = $name } + }) + + # Sort by weight descending (LPT: largest first) + $items = @($items | Sort-Object Weight -Descending) + + # Calculate number of buckets + [int]$totalWeight = 0 + foreach ($i in $items) { $totalWeight += $i.Weight } + $numBuckets = [math]::Max(1, [math]::Ceiling($totalWeight / $TargetSeconds)) + + # Don't create more buckets than packages + $numBuckets = [math]::Min($numBuckets, $Packages.Count) + + Write-Host " $Label`: $($Packages.Count) packages, total weight ${totalWeight}s, target ${TargetSeconds}s -> $numBuckets buckets" + + # Create buckets + $buckets = @() + for ($b = 0; $b -lt $numBuckets; $b++) { + $buckets += [PSCustomObject]@{ + Items = [System.Collections.ArrayList]::new() + TotalWeight = [int]0 + } + } + + # Greedy LPT: assign each item to the lightest bucket + foreach ($item in $items) { + $lightest = $buckets | Sort-Object TotalWeight | Select-Object -First 1 + [void]$lightest.Items.Add($item) + $lightest.TotalWeight += $item.Weight + } + + # Log bucket distribution with collapsible groups + $bucketIdx = 1 + foreach ($bucket in $buckets) { + $summary = "Bucket ${bucketIdx}: $($bucket.Items.Count) pkgs, weight $($bucket.TotalWeight)s ($([math]::Round($bucket.TotalWeight / 60, 1))m)" + Write-Host "##[group]$summary" + foreach ($item in ($bucket.Items | Sort-Object Name)) { + Write-Host " $($item.Name) ($($item.Weight)s)" + } + Write-Host "##[endgroup]" + $bucketIdx++ + } + + # Consolidate: for each bucket, keep one representative file with all names + foreach ($bucket in $buckets) { + $batchItems = @($bucket.Items) + if ($batchItems.Count -eq 0) { continue } + + # Use the first (heaviest) package as the representative + $representative = $batchItems[0].Package + $allNames = ($batchItems | ForEach-Object { $_.Name }) -join "," + + # Update the representative's ArtifactName to contain all package names + $representative.Json.ArtifactName = $allNames + $representative.Json | ConvertTo-Json -Depth 100 | Set-Content $representative.FilePath -Encoding utf8 + + # Delete the other package files in this batch + for ($i = 1; $i -lt $batchItems.Count; $i++) { + Remove-Item $batchItems[$i].Package.FilePath -Force + } + } + + Write-Host " $Label`: Consolidated $($Packages.Count) files into $numBuckets batch files." +} + +# Apply LPT batching to direct and indirect packages separately +if ($directPackages.Count -gt 0) { + Apply-LPTBatching -Packages $directPackages -Weights $weights ` + -TargetSeconds $TargetSeconds -DefaultWeight $DefaultWeight -Label "Direct" +} + +if ($indirectPackages.Count -gt 0) { + Apply-LPTBatching -Packages $indirectPackages -Weights $weights ` + -TargetSeconds $IndirectTargetSeconds -DefaultWeight $DefaultWeight -Label "Indirect" +} + +# Verify +$remaining = @(Get-ChildItem -Path $PackageInfoFolder -Filter "*.json" -Recurse).Count +Write-Host "Weighted batching complete. $remaining consolidated PackageInfo files remain." diff --git a/eng/scripts/Get-PackageLocWeights.ps1 b/eng/scripts/Get-PackageLocWeights.ps1 new file mode 100644 index 000000000000..4ed0760d6ed3 --- /dev/null +++ b/eng/scripts/Get-PackageLocWeights.ps1 @@ -0,0 +1,62 @@ +<# +.SYNOPSIS +Counts lines of code per package and generates a weight file for build/analyze batching. + +.DESCRIPTION +For each PackageInfo JSON file, finds the corresponding src directory and counts +total lines of C# code. Outputs a JSON mapping of package name to LOC count. + +.PARAMETER PackageInfoFolder +Path to the folder containing PackageInfo JSON files. + +.PARAMETER RepoRoot +Root of the repository. + +.PARAMETER OutputFile +Path to write the LOC weights JSON file. +#> + +[CmdletBinding()] +param ( + [Parameter(Mandatory = $true)][string]$PackageInfoFolder, + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$OutputFile +) + +Set-StrictMode -Version 4 + +$weights = @{} +$packageFiles = Get-ChildItem -Path $PackageInfoFolder -Filter "*.json" -Recurse + +foreach ($file in $packageFiles) { + $json = Get-Content $file.FullName | ConvertFrom-Json + $name = $json.ArtifactName + $dirPath = $json.DirectoryPath + + if (-not $dirPath) { + $weights[$name] = 1 + continue + } + + $srcPath = Join-Path $dirPath "src" + if (-not (Test-Path $srcPath)) { + $weights[$name] = 1 + continue + } + + $loc = 0 + Get-ChildItem $srcPath -Filter "*.cs" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + $loc += @(Get-Content $_.FullName -ErrorAction SilentlyContinue).Count + } + + $weights[$name] = [math]::Max($loc, 1) +} + +Write-Host "Counted LOC for $($weights.Count) packages." +$topPkgs = $weights.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5 +foreach ($p in $topPkgs) { + Write-Host " $($p.Key): $($p.Value) LOC" +} + +$weights | ConvertTo-Json -Depth 1 | Set-Content $OutputFile -Encoding utf8 +Write-Host "LOC weights written to $OutputFile" From 590907f61ee0f98ecf26990d6a08f251b5fabafe Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 10:15:05 -0700 Subject: [PATCH 02/10] Use separate AnalyzePackageInfoArtifact for analyze stage Eliminates the artifact-download race between the parallel build and analyze matrix-gen stages. Build matrix-gen continues to publish BuildPackagesArtifact for build jobs; analyze matrix-gen now publishes its own AnalyzePackageInfoArtifact for analyze jobs. Both matrix-gens remain fully parallel - no wall-clock cost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/jobs/batched-analyze.yml | 4 ++++ eng/pipelines/templates/jobs/ci.yml | 5 +++-- eng/pipelines/templates/steps/analyze.yml | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/eng/pipelines/templates/jobs/batched-analyze.yml b/eng/pipelines/templates/jobs/batched-analyze.yml index 16319ec3c734..5f3f83dff5b2 100644 --- a/eng/pipelines/templates/jobs/batched-analyze.yml +++ b/eng/pipelines/templates/jobs/batched-analyze.yml @@ -11,6 +11,9 @@ parameters: - name: BuildSnippets type: boolean default: true + - name: ArtifactName + type: string + default: BuildPackagesArtifact - name: DependsOn type: string default: '' @@ -46,3 +49,4 @@ jobs: ServiceDirectory: ${{ parameters.ServiceDirectory }} SDKType: ${{ parameters.SDKType }} BuildSnippets: ${{ parameters.BuildSnippets }} + ArtifactName: ${{ parameters.ArtifactName }} diff --git a/eng/pipelines/templates/jobs/ci.yml b/eng/pipelines/templates/jobs/ci.yml index d4460d83935d..fdb0d0243ffb 100644 --- a/eng/pipelines/templates/jobs/ci.yml +++ b/eng/pipelines/templates/jobs/ci.yml @@ -107,16 +107,17 @@ jobs: ServiceDirectory: ${{ parameters.ServiceDirectory }} SDKType: ${{ parameters.SDKType }} BuildSnippets: ${{ parameters.BuildSnippets }} + ArtifactName: AnalyzePackageInfoArtifact PreGenerationSteps: - template: /eng/pipelines/templates/steps/pr-matrix-presteps.yml parameters: ServiceDirectory: ${{ parameters.ServiceDirectory }} - PublishingArtifactName: BuildPackagesArtifact + PublishingArtifactName: AnalyzePackageInfoArtifact ForceDirect: true ExcludePaths: ${{ parameters.ExcludePaths }} EnableBuildWeighting: true LocTargetSeconds: 300000 - PublishArtifact: false + PublishArtifact: true - ${{ else }}: - job: Build diff --git a/eng/pipelines/templates/steps/analyze.yml b/eng/pipelines/templates/steps/analyze.yml index 7e4f438bab16..75647b1a9f16 100644 --- a/eng/pipelines/templates/steps/analyze.yml +++ b/eng/pipelines/templates/steps/analyze.yml @@ -11,6 +11,9 @@ parameters: - name: BuildSnippets type: boolean default: true + - name: ArtifactName + type: string + default: BuildPackagesArtifact steps: - ${{ if not(contains(variables['Build.DefinitionName'], '-pr - ')) }}: @@ -27,7 +30,7 @@ steps: - ${{ if eq(parameters.ServiceDirectory, 'auto') }}: - task: DownloadPipelineArtifact@2 inputs: - artifact: BuildPackagesArtifact + artifact: ${{ parameters.ArtifactName }} path: $(Build.ArtifactStagingDirectory)/PackageInfo - ${{ else }}: - template: /eng/common/pipelines/templates/steps/save-package-properties.yml From 0dc37fc94e07c0c0aa3712b7462a7a069a642820 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 10:22:21 -0700 Subject: [PATCH 03/10] Rename LocTargetSeconds -> LocTarget (and -TargetSeconds -> -Target) The parameter values are LOC counts, not seconds. The 'Seconds' suffix was a misleading carryover from when the script was time-based. Renames the YAML param, the script param, internal variables, docstrings, and the display/log messages so future readers don't misconfigure them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/jobs/ci.yml | 4 +-- .../templates/steps/pr-matrix-presteps.yml | 6 ++--- eng/scripts/Apply-WeightedBatching.ps1 | 26 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/eng/pipelines/templates/jobs/ci.yml b/eng/pipelines/templates/jobs/ci.yml index fdb0d0243ffb..ce713349e5a7 100644 --- a/eng/pipelines/templates/jobs/ci.yml +++ b/eng/pipelines/templates/jobs/ci.yml @@ -79,7 +79,7 @@ jobs: ForceDirect: true ExcludePaths: ${{ parameters.ExcludePaths }} EnableBuildWeighting: true - LocTargetSeconds: 1200000 + LocTarget: 1200000 PublishArtifact: true # Analyze matrix - smaller LOC batches (300k) for more parallelism so analyze wall-clock matches build. @@ -116,7 +116,7 @@ jobs: ForceDirect: true ExcludePaths: ${{ parameters.ExcludePaths }} EnableBuildWeighting: true - LocTargetSeconds: 300000 + LocTarget: 300000 PublishArtifact: true - ${{ else }}: diff --git a/eng/pipelines/templates/steps/pr-matrix-presteps.yml b/eng/pipelines/templates/steps/pr-matrix-presteps.yml index 44b3edca3055..a514fd29dc46 100644 --- a/eng/pipelines/templates/steps/pr-matrix-presteps.yml +++ b/eng/pipelines/templates/steps/pr-matrix-presteps.yml @@ -12,7 +12,7 @@ parameters: - name: EnableBuildWeighting type: boolean default: false - - name: LocTargetSeconds + - name: LocTarget type: number default: 474000 - name: PublishArtifact @@ -85,6 +85,6 @@ steps: eng/scripts/Apply-WeightedBatching.ps1 ` -PackageInfoFolder "$(Build.ArtifactStagingDirectory)/PackageInfo" ` -WeightsFile "$(Build.ArtifactStagingDirectory)/loc-weights.json" ` - -TargetSeconds ${{ parameters.LocTargetSeconds }} - displayName: Apply LOC-weighted batching (target ${{ parameters.LocTargetSeconds }}) + -Target ${{ parameters.LocTarget }} + displayName: Apply LOC-weighted batching (target ${{ parameters.LocTarget }}) condition: and(succeededOrFailed(), eq(variables['Build.Reason'], 'PullRequest')) \ No newline at end of file diff --git a/eng/scripts/Apply-WeightedBatching.ps1 b/eng/scripts/Apply-WeightedBatching.ps1 index 83999efe9169..d2a504002abc 100644 --- a/eng/scripts/Apply-WeightedBatching.ps1 +++ b/eng/scripts/Apply-WeightedBatching.ps1 @@ -15,13 +15,13 @@ After this script runs, set PRJobBatchSize to 1 so each consolidated file become Path to the folder containing PackageInfo JSON files. .PARAMETER WeightsFile -Path to the JSON weights file (package name → seconds). +Path to the JSON weights file (package name → weight, e.g. LOC count). -.PARAMETER TargetSeconds -Target maximum time per bucket in seconds for direct packages. Default is 1800 (30 minutes). +.PARAMETER Target +Target maximum weight per bucket for direct packages. Default is 1800. -.PARAMETER IndirectTargetSeconds -Target maximum time per bucket in seconds for indirect packages. Defaults to TargetSeconds if not specified. +.PARAMETER IndirectTarget +Target maximum weight per bucket for indirect packages. Defaults to Target if not specified. Indirect packages only run on Linux, so they can use a higher target than direct packages which run across all platforms. .PARAMETER DefaultWeight @@ -32,14 +32,14 @@ Weight assigned to packages not found in the weights file. Default is 1. param ( [Parameter(Mandatory = $true)][string]$PackageInfoFolder, [Parameter(Mandatory = $true)][string]$WeightsFile, - [Parameter()][int]$TargetSeconds = 1800, - [Parameter()][int]$IndirectTargetSeconds = 0, + [Parameter()][int]$Target = 1800, + [Parameter()][int]$IndirectTarget = 0, [Parameter()][int]$DefaultWeight = 1 ) Set-StrictMode -Version 4 -if ($IndirectTargetSeconds -le 0) { $IndirectTargetSeconds = $TargetSeconds } +if ($IndirectTarget -le 0) { $IndirectTarget = $Target } # Load weights $weights = @{} @@ -87,7 +87,7 @@ function Apply-LPTBatching { param( [object[]]$Packages, [hashtable]$Weights, - [int]$TargetSeconds, + [int]$Target, [int]$DefaultWeight, [string]$Label ) @@ -110,12 +110,12 @@ function Apply-LPTBatching { # Calculate number of buckets [int]$totalWeight = 0 foreach ($i in $items) { $totalWeight += $i.Weight } - $numBuckets = [math]::Max(1, [math]::Ceiling($totalWeight / $TargetSeconds)) + $numBuckets = [math]::Max(1, [math]::Ceiling($totalWeight / $Target)) # Don't create more buckets than packages $numBuckets = [math]::Min($numBuckets, $Packages.Count) - Write-Host " $Label`: $($Packages.Count) packages, total weight ${totalWeight}s, target ${TargetSeconds}s -> $numBuckets buckets" + Write-Host " $Label`: $($Packages.Count) packages, total weight ${totalWeight}, target ${Target} -> $numBuckets buckets" # Create buckets $buckets = @() @@ -170,12 +170,12 @@ function Apply-LPTBatching { # Apply LPT batching to direct and indirect packages separately if ($directPackages.Count -gt 0) { Apply-LPTBatching -Packages $directPackages -Weights $weights ` - -TargetSeconds $TargetSeconds -DefaultWeight $DefaultWeight -Label "Direct" + -Target $Target -DefaultWeight $DefaultWeight -Label "Direct" } if ($indirectPackages.Count -gt 0) { Apply-LPTBatching -Packages $indirectPackages -Weights $weights ` - -TargetSeconds $IndirectTargetSeconds -DefaultWeight $DefaultWeight -Label "Indirect" + -Target $IndirectTarget -DefaultWeight $DefaultWeight -Label "Indirect" } # Verify From 3f4aaf6fec774e8423b7bad1ce068708bef0d913 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 12:44:07 -0700 Subject: [PATCH 04/10] Remove time-unit suffixes from LOC weight log lines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/scripts/Apply-WeightedBatching.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/scripts/Apply-WeightedBatching.ps1 b/eng/scripts/Apply-WeightedBatching.ps1 index d2a504002abc..ceada3722cec 100644 --- a/eng/scripts/Apply-WeightedBatching.ps1 +++ b/eng/scripts/Apply-WeightedBatching.ps1 @@ -136,10 +136,10 @@ function Apply-LPTBatching { # Log bucket distribution with collapsible groups $bucketIdx = 1 foreach ($bucket in $buckets) { - $summary = "Bucket ${bucketIdx}: $($bucket.Items.Count) pkgs, weight $($bucket.TotalWeight)s ($([math]::Round($bucket.TotalWeight / 60, 1))m)" + $summary = "Bucket ${bucketIdx}: $($bucket.Items.Count) pkgs, weight $($bucket.TotalWeight)" Write-Host "##[group]$summary" foreach ($item in ($bucket.Items | Sort-Object Name)) { - Write-Host " $($item.Name) ($($item.Weight)s)" + Write-Host " $($item.Name) (weight $($item.Weight))" } Write-Host "##[endgroup]" $bucketIdx++ From fbc0eb35e04edda5c3874d80047dbac4c7062f24 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 12:45:51 -0700 Subject: [PATCH 05/10] Resolve DirectoryPath against RepoRoot in Get-PackageLocWeights Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/scripts/Get-PackageLocWeights.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/eng/scripts/Get-PackageLocWeights.ps1 b/eng/scripts/Get-PackageLocWeights.ps1 index 4ed0760d6ed3..40c9665b41ee 100644 --- a/eng/scripts/Get-PackageLocWeights.ps1 +++ b/eng/scripts/Get-PackageLocWeights.ps1 @@ -38,7 +38,12 @@ foreach ($file in $packageFiles) { continue } - $srcPath = Join-Path $dirPath "src" + if ([System.IO.Path]::IsPathRooted($dirPath)) { + $srcPath = Join-Path $dirPath "src" + } + else { + $srcPath = Join-Path $RepoRoot $dirPath "src" + } if (-not (Test-Path $srcPath)) { $weights[$name] = 1 continue From a67967017981c74f92d01e0684dfc69cd887e65c Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 12:59:02 -0700 Subject: [PATCH 06/10] Clarify LOC weights key is ArtifactName Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/scripts/Get-PackageLocWeights.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eng/scripts/Get-PackageLocWeights.ps1 b/eng/scripts/Get-PackageLocWeights.ps1 index 40c9665b41ee..db13fe821c92 100644 --- a/eng/scripts/Get-PackageLocWeights.ps1 +++ b/eng/scripts/Get-PackageLocWeights.ps1 @@ -4,7 +4,8 @@ Counts lines of code per package and generates a weight file for build/analyze b .DESCRIPTION For each PackageInfo JSON file, finds the corresponding src directory and counts -total lines of C# code. Outputs a JSON mapping of package name to LOC count. +total lines of C# code. Outputs a JSON mapping of artifact name (the `ArtifactName` +field from PackageInfo) to LOC count. .PARAMETER PackageInfoFolder Path to the folder containing PackageInfo JSON files. From 4e4eadb0584bf119bf268b01483f05b30e097526 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 13:00:44 -0700 Subject: [PATCH 07/10] Clarify Target is a goal not a hard cap Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/scripts/Apply-WeightedBatching.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/eng/scripts/Apply-WeightedBatching.ps1 b/eng/scripts/Apply-WeightedBatching.ps1 index ceada3722cec..5ac28c215dcf 100644 --- a/eng/scripts/Apply-WeightedBatching.ps1 +++ b/eng/scripts/Apply-WeightedBatching.ps1 @@ -18,10 +18,12 @@ Path to the folder containing PackageInfo JSON files. Path to the JSON weights file (package name → weight, e.g. LOC count). .PARAMETER Target -Target maximum weight per bucket for direct packages. Default is 1800. +Target average weight per bucket for direct packages. Default is 1800. Used to derive +the bucket count via ceil(totalWeight / Target); individual buckets may exceed this +after greedy LPT packing — it's a goal, not a hard cap. .PARAMETER IndirectTarget -Target maximum weight per bucket for indirect packages. Defaults to Target if not specified. +Target average weight per bucket for indirect packages. Defaults to Target if not specified. Indirect packages only run on Linux, so they can use a higher target than direct packages which run across all platforms. .PARAMETER DefaultWeight From f3280470f1036ea75b778867e24975da7febdcaf Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 13:04:48 -0700 Subject: [PATCH 08/10] Rename EnableBuildWeighting -> EnableLocWeighting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/jobs/ci.yml | 4 ++-- eng/pipelines/templates/steps/pr-matrix-presteps.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/pipelines/templates/jobs/ci.yml b/eng/pipelines/templates/jobs/ci.yml index ce713349e5a7..2f9eaebe0f71 100644 --- a/eng/pipelines/templates/jobs/ci.yml +++ b/eng/pipelines/templates/jobs/ci.yml @@ -78,7 +78,7 @@ jobs: PublishingArtifactName: BuildPackagesArtifact ForceDirect: true ExcludePaths: ${{ parameters.ExcludePaths }} - EnableBuildWeighting: true + EnableLocWeighting: true LocTarget: 1200000 PublishArtifact: true @@ -115,7 +115,7 @@ jobs: PublishingArtifactName: AnalyzePackageInfoArtifact ForceDirect: true ExcludePaths: ${{ parameters.ExcludePaths }} - EnableBuildWeighting: true + EnableLocWeighting: true LocTarget: 300000 PublishArtifact: true diff --git a/eng/pipelines/templates/steps/pr-matrix-presteps.yml b/eng/pipelines/templates/steps/pr-matrix-presteps.yml index a514fd29dc46..ae42bc505b8d 100644 --- a/eng/pipelines/templates/steps/pr-matrix-presteps.yml +++ b/eng/pipelines/templates/steps/pr-matrix-presteps.yml @@ -9,7 +9,7 @@ parameters: - name: ExcludePaths type: object default: [] - - name: EnableBuildWeighting + - name: EnableLocWeighting type: boolean default: false - name: LocTarget @@ -72,7 +72,7 @@ steps: displayName: Only target direct packages for build and test condition: and(succeededOrFailed(), eq(${{ parameters.ForceDirect }}, true)) - - ${{ if eq(parameters.EnableBuildWeighting, true) }}: + - ${{ if eq(parameters.EnableLocWeighting, true) }}: - pwsh: | eng/scripts/Get-PackageLocWeights.ps1 ` -PackageInfoFolder "$(Build.ArtifactStagingDirectory)/PackageInfo" ` From ca50ab0db973382280701d01995ef2aebcb56746 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 13:26:06 -0700 Subject: [PATCH 09/10] Make Apply-WeightedBatching synopsis job/unit agnostic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/scripts/Apply-WeightedBatching.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/eng/scripts/Apply-WeightedBatching.ps1 b/eng/scripts/Apply-WeightedBatching.ps1 index 5ac28c215dcf..5b7a3b5b7bc5 100644 --- a/eng/scripts/Apply-WeightedBatching.ps1 +++ b/eng/scripts/Apply-WeightedBatching.ps1 @@ -1,14 +1,17 @@ <# .SYNOPSIS -Applies weighted LPT bin-packing to PackageInfo files for balanced CI test batching. +Applies weighted LPT bin-packing to PackageInfo files for balanced CI job batching. .DESCRIPTION -Reads PackageInfo JSON files and a test weights file, performs LPT (Longest Processing Time) +Reads PackageInfo JSON files and a weights file, performs LPT (Longest Processing Time) bin-packing to create balanced batches, then consolidates the PackageInfo files so that each batch becomes a single representative file. The ArtifactName of each consolidated file contains the comma-separated names of all packages in that batch, which flows through to the ProjectNames parameter in the matrix generation. +Used by the Build and Analyze pre-steps with LOC-derived weights, but the script itself is +weight-source-agnostic — any numeric weight file (package name -> integer) will work. + After this script runs, set PRJobBatchSize to 1 so each consolidated file becomes its own job. .PARAMETER PackageInfoFolder From 890e48b6c65eaf8c2e0a13c5d8df03be0b242ac9 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Tue, 26 May 2026 13:33:42 -0700 Subject: [PATCH 10/10] Align WeightsFile help text with ArtifactName key Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/scripts/Apply-WeightedBatching.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eng/scripts/Apply-WeightedBatching.ps1 b/eng/scripts/Apply-WeightedBatching.ps1 index 5b7a3b5b7bc5..31153d79bb3b 100644 --- a/eng/scripts/Apply-WeightedBatching.ps1 +++ b/eng/scripts/Apply-WeightedBatching.ps1 @@ -18,7 +18,8 @@ After this script runs, set PRJobBatchSize to 1 so each consolidated file become Path to the folder containing PackageInfo JSON files. .PARAMETER WeightsFile -Path to the JSON weights file (package name → weight, e.g. LOC count). +Path to the JSON weights file (artifact name -> weight, e.g. LOC count). The key must match +the `ArtifactName` field of the corresponding PackageInfo JSON. .PARAMETER Target Target average weight per bucket for direct packages. Default is 1800. Used to derive