Enable Unity build for wslclib and enable CI unity builds (#41302)

Rename WSL_TEST_UNITY_BATCH_SIZE to WSL_UNITY_BATCH_SIZE and apply it to wslclib as well as wsltests, so both targets share one knob. The default is 0 (every file compiled separately) and the CI pipeline sets 4. Fixes the name collisions that surface when sources are merged into a single translation unit by fully qualifying details::WrapText, details::ArgConvertedTypeMapping, and ::IUnknown, hoisting the duplicated c_fallbackConsoleWidth constant into Terminal.h, and dropping redundant includes. Adds benchmarking scripts under tools/benchmarking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Aug 12, 2026 at 12:02 UTC 91fe87dae7a3c424fc2327cf54f581287a899d70
15 files changed +308 -26
.gitignore
+2 -1
@@ -78,4 +78,5 @@ deploy-log.txt
78 test-output*.txt
79 test-results.txt
80 testfile.txt
81 -output/
\ No newline at end of file
81 +output/
82 +tools/benchmarking/benchmark-results/
.pipelines/build-job.yml
+1 -1
@@ -124,7 +124,7 @@ jobs:
124 displayName: "CMake ${{ parameters.platform }}"
125 inputs:
126 workingDirectory: "."
127 - cmakeArgs: . --fresh -A ${{ parameters.platform }} -DCMAKE_BUILD_TYPE=Release -DCMAKE_SYSTEM_VERSION=10.0.26100.0 -DPACKAGE_VERSION=$(version.WSL_PACKAGE_VERSION) -DWSL_NUGET_PACKAGE_VERSION=$(version.WSL_NUGET_PACKAGE_VERSION) -DSKIP_PACKAGE_SIGNING=${{ parameters.isRelease }} -DOFFICIAL_BUILD=${{ parameters.isRelease }} -DINCLUDE_PACKAGE_STAGE=${{ or(parameters.isRelease, parameters.isNightly) }} -DPIPELINE_BUILD_ID=$(Build.BuildId) -DVSO_ORG=${{ parameters.vsoOrg }} -DVSO_PROJECT=${{ parameters.vsoProject }} -DWSL_BUILD_WSL_SETTINGS=true -DWSL_INCLUDE_SDK_CSHARP=true $(packageInputDirArg)\${{ parameters.platform }}
127 + cmakeArgs: . --fresh -A ${{ parameters.platform }} -DCMAKE_BUILD_TYPE=Release -DCMAKE_SYSTEM_VERSION=10.0.26100.0 -DPACKAGE_VERSION=$(version.WSL_PACKAGE_VERSION) -DWSL_NUGET_PACKAGE_VERSION=$(version.WSL_NUGET_PACKAGE_VERSION) -DSKIP_PACKAGE_SIGNING=${{ parameters.isRelease }} -DOFFICIAL_BUILD=${{ parameters.isRelease }} -DINCLUDE_PACKAGE_STAGE=${{ or(parameters.isRelease, parameters.isNightly) }} -DPIPELINE_BUILD_ID=$(Build.BuildId) -DVSO_ORG=${{ parameters.vsoOrg }} -DVSO_PROJECT=${{ parameters.vsoProject }} -DWSL_BUILD_WSL_SETTINGS=true -DWSL_INCLUDE_SDK_CSHARP=true -DWSL_UNITY_BATCH_SIZE=4 $(packageInputDirArg)\${{ parameters.platform }}
128
129 # Workaround for WSL Settings NuGet restore authentication issue
130 - script: _deps\nuget.exe restore -NonInteractive
CMakeLists.txt
+7 -5
@@ -188,14 +188,16 @@ if (NOT DEFINED WSL_INCLUDE_SDK_CSHARP)
188 set(WSL_INCLUDE_SDK_CSHARP false)
189 endif ()
190
191 -# Number of test sources combined into each unity translation unit.
191 +# Number of sources combined into each unity translation unit.
192 # Set to 0 to compile every file separately.
193 -if (NOT DEFINED WSL_TEST_UNITY_BATCH_SIZE)
194 - set(WSL_TEST_UNITY_BATCH_SIZE 2)
193 +set(WSL_UNITY_BATCH_SIZE_DEFAULT 0)
194 +
195 +if (NOT DEFINED WSL_UNITY_BATCH_SIZE)
196 + set(WSL_UNITY_BATCH_SIZE ${WSL_UNITY_BATCH_SIZE_DEFAULT})
197 endif ()
198
197 -if (NOT WSL_TEST_UNITY_BATCH_SIZE MATCHES "^[0-9]+$")
198 - message(FATAL_ERROR "WSL_TEST_UNITY_BATCH_SIZE must be a non-negative integer: got '${WSL_TEST_UNITY_BATCH_SIZE}'")
199 +if (NOT WSL_UNITY_BATCH_SIZE MATCHES "^[0-9]+$")
200 + message(FATAL_ERROR "WSL_UNITY_BATCH_SIZE must be a non-negative integer: got '${WSL_UNITY_BATCH_SIZE}'")
201 endif ()
202 find_commit_hash(COMMIT_HASH)
203
UserConfig.cmake.sample
+3 -3
@@ -65,6 +65,6 @@ endif()
65 # # fix - automatically fix formatting and re-stage files
66 # set(WSL_PRE_COMMIT_MODE "warn")
67
68 -# # Uncomment to change how many test sources share a unity translation unit (default: 2).
69 -# # Use 0 to disable unity builds and compile every test file separately.
70 -# set(WSL_TEST_UNITY_BATCH_SIZE 4)
68 +# # Uncomment to change how many sources share a unity translation unit (default: 0).
69 +# # Use 0 to disable unity builds and compile every file separately.
70 +# set(WSL_UNITY_BATCH_SIZE 4)
src/windows/wslc/CMakeLists.txt
+8
@@ -9,6 +9,14 @@ file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS ${SOURCE_PATTERNS})
9 # Object library for WSLC components.
10 # Used to build the executable and also unit testing components.
11 add_library(wslclib OBJECT ${SOURCES} ${HEADERS})
12 +
13 +if (DEFINED WSL_UNITY_BATCH_SIZE AND WSL_UNITY_BATCH_SIZE GREATER 0)
14 + set_target_properties(wslclib PROPERTIES
15 + UNITY_BUILD ON
16 + UNITY_BUILD_MODE BATCH
17 + UNITY_BUILD_BATCH_SIZE ${WSL_UNITY_BATCH_SIZE})
18 +endif ()
19 +
20 target_include_directories(wslclib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${WSLC_SUBDIR_PATHS})
21
22 target_link_libraries(wslclib
src/windows/wslc/arguments/ArgumentValidation.cpp
+1 -1
@@ -51,7 +51,7 @@ namespace {
51 template <ArgType A, typename Converter>
52 void CacheConverted(ArgMap& execArgs, const std::wstring& argName, Converter&& convert)
53 {
54 - using value_t = typename details::ArgConvertedTypeMapping<A>::value_t;
54 + using value_t = typename wsl::windows::wslc::argument::details::ArgConvertedTypeMapping<A>::value_t;
55 using converted_t = decltype(convert(std::declval<const std::wstring&>(), std::declval<const std::wstring&>()));
56 static_assert(
57 std::is_same_v<converted_t, value_t>,
src/windows/wslc/core/TableOutput.h
+2 -2
@@ -385,7 +385,7 @@ private:
385 // For plain cells, wrap the text directly.
386 if (cell.sequences.empty())
387 {
388 - auto chunks = details::WrapText(cell.fmt, col.MaxLength);
388 + auto chunks = wsl::windows::wslc::details::WrapText(cell.fmt, col.MaxLength);
389 std::vector<FormattedCell> result;
390 result.reserve(chunks.size());
391 for (auto& chunk : chunks)
@@ -416,7 +416,7 @@ private:
416 }
417 }
418
419 - auto chunks = details::WrapText(visibleText, col.MaxLength);
419 + auto chunks = wsl::windows::wslc::details::WrapText(visibleText, col.MaxLength);
420 std::vector<FormattedCell> result;
421 result.reserve(chunks.size());
422 for (auto& chunk : chunks)
src/windows/wslc/core/Terminal.h
+5
@@ -31,6 +31,11 @@ Abstract:
31
32 namespace wsl::windows::wslc {
33
34 +// Fallback width for progress displays when the console width can't be queried. This
35 +// value already includes the autowrap guard (visible width minus one) so a wrapped line
36 +// can't corrupt cursor-based rendering.
37 +inline constexpr int c_fallbackConsoleWidth = 79;
38 +
39 namespace terminal_detail {
40
41 // SFINAE: excludes Sequence-derived types so the overload below wins for them.
src/windows/wslc/services/BuildImageCallback.cpp
-3
@@ -20,9 +20,6 @@ namespace wsl::windows::wslc::services {
20 using wsl::windows::common::string::MultiByteToWide;
21 using namespace wsl::windows::common::vt;
22
23 -// Fallback width used when the console width can't be queried.
24 -constexpr int c_fallbackConsoleWidth = 79;
25 -
23 BuildImageCallback::~BuildImageCallback()
24 try
25 {
src/windows/wslc/services/ImageProgressCallback.cpp
-5
@@ -21,11 +21,6 @@ namespace wsl::windows::wslc::services {
21 using namespace wsl::shared;
22 using namespace wsl::windows::common::vt;
23
24 -// Fallback width for the in-place progress display when the console width can't be queried. This
25 -// value already includes the autowrap guard (visible width minus one) so a wrapped line can't
26 -// corrupt the cursor-based rendering.
27 -constexpr int c_fallbackConsoleWidth = 79;
28 -
24 auto ImageProgressCallback::MoveToLine(int line)
25 {
26 if (line > 0)
src/windows/wslc/services/SessionModel.h
+2 -2
@@ -34,9 +34,9 @@ struct Session
34 // Acquires an activity token that keeps the VM alive for the duration of a client-side
35 // container operation (resolve + operate, plus any streamed output). Hold the returned
36 // pointer for the whole operation; releasing it lets the VM idle-terminate again.
37 - [[nodiscard]] wil::com_ptr<IUnknown> BeginContainerOperation() const
37 + [[nodiscard]] wil::com_ptr<::IUnknown> BeginContainerOperation() const
38 {
39 - wil::com_ptr<IUnknown> operation;
39 + wil::com_ptr<::IUnknown> operation;
40 THROW_IF_FAILED(m_session->BeginContainerOperation(&operation));
41 return operation;
42 }
src/windows/wslc/tasks/SessionTasks.cpp
-1
@@ -20,7 +20,6 @@ Abstract:
20 #include "Task.h"
21
22 using namespace wsl::shared;
23 -using namespace wsl::shared::string;
23 using namespace wsl::windows::common::string;
24 using namespace wsl::windows::common::wslutil;
25 using namespace wsl::windows::wslc::execution;
test/windows/CMakeLists.txt
+2 -2
@@ -24,11 +24,11 @@ add_compile_definitions(INLINE_TEST_METHOD_MARKUP)
24
25 add_library(wsltests SHARED ${SOURCES} ${HEADERS})
26
27 -if (DEFINED WSL_TEST_UNITY_BATCH_SIZE AND WSL_TEST_UNITY_BATCH_SIZE GREATER 0)
27 +if (DEFINED WSL_UNITY_BATCH_SIZE AND WSL_UNITY_BATCH_SIZE GREATER 0)
28 set_target_properties(wsltests PROPERTIES
29 UNITY_BUILD ON
30 UNITY_BUILD_MODE BATCH
31 - UNITY_BUILD_BATCH_SIZE ${WSL_TEST_UNITY_BATCH_SIZE})
31 + UNITY_BUILD_BATCH_SIZE ${WSL_UNITY_BATCH_SIZE})
32 endif ()
33
34 target_include_directories(wsltests PRIVATE
tools/benchmarking/benchmark-build.ps1 new
+208
@@ -0,0 +1,208 @@
1 +<#
2 +.SYNOPSIS
3 + Benchmarks full and incremental build times for a CMake target.
4 +
5 +.DESCRIPTION
6 + Runs N full builds (object directory wiped before each) and M incremental builds
7 + (a randomly chosen .cpp under -SourceDir gets a comment appended, then the target
8 + is rebuilt and the file is restored byte-for-byte).
9 +
10 + After each incremental iteration the restored file is rebuilt in an untimed settle
11 + build, so a measurement only ever covers the file touched by that iteration.
12 +
13 + Results are written to a CSV and a summary is printed.
14 +
15 +.PARAMETER Target
16 + CMake target to build. Defaults to 'wsltests'.
17 +
18 +.PARAMETER TargetDir
19 + Directory (relative to the repo root, or absolute) holding the CMakeLists.txt that
20 + defines -Target. Used to locate the target's object directory. Defaults to
21 + 'test\windows'.
22 +
23 +.PARAMETER SourceDir
24 + Directory whose .cpp files are touched during incremental builds. Defaults to
25 + -TargetDir.
26 +
27 +.PARAMETER ExcludeDirName
28 + Directory names skipped when collecting incremental candidates.
29 +
30 +.EXAMPLE
31 + powershell tools\benchmarking\benchmark-build.ps1 -Label before -FullBuilds 10 -IncrementalBuilds 100
32 +
33 +.EXAMPLE
34 + powershell tools\benchmarking\benchmark-build.ps1 -Label wslc-only -SourceDir test\windows\wslc
35 +
36 +.EXAMPLE
37 + powershell tools\benchmarking\benchmark-build.ps1 -Label svc -Target wslservice -TargetDir src\windows\service\exe
38 +#>
39 +[CmdletBinding()]
40 +param(
41 + [Parameter(Mandatory = $true)][string]$Label,
42 + [int]$FullBuilds = 10,
43 + [int]$IncrementalBuilds = 100,
44 + [string]$Config = 'debug',
45 + [string]$Target = 'wsltests',
46 + [string]$TargetDir = 'test\windows',
47 + [string]$SourceDir,
48 + [string[]]$ExcludeDirName = @('testplugin'),
49 + [string]$OutDir = 'tools\benchmarking\benchmark-results',
50 + [int]$Seed = 20260803
51 +)
52 +
53 +$ErrorActionPreference = 'Stop'
54 +
55 +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
56 +Push-Location $repoRoot
57 +try
58 +{
59 + function Resolve-RepoPath
60 + {
61 + param([string]$Path)
62 +
63 + if ([System.IO.Path]::IsPathRooted($Path)) { return $Path }
64 + return (Join-Path $repoRoot $Path)
65 + }
66 +
67 + if (-not $SourceDir) { $SourceDir = $TargetDir }
68 +
69 + $targetDirFull = Resolve-RepoPath $TargetDir
70 + $sourceDirFull = Resolve-RepoPath $SourceDir
71 + $objDir = Join-Path $targetDirFull "$Target.dir\$Config"
72 +
73 + if (-not (Test-Path (Join-Path $repoRoot 'CMakeCache.txt')))
74 + {
75 + throw "CMakeCache.txt not found in $repoRoot. Run 'cmake .' first."
76 + }
77 +
78 + if (-not (Test-Path $targetDirFull))
79 + {
80 + throw "TargetDir not found: $targetDirFull"
81 + }
82 +
83 + if (-not (Test-Path $sourceDirFull))
84 + {
85 + throw "SourceDir not found: $sourceDirFull"
86 + }
87 +
88 + if (-not (Test-Path $OutDir))
89 + {
90 + New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
91 + }
92 +
93 + $csvPath = Join-Path $OutDir "$Label.csv"
94 + $logPath = Join-Path $OutDir "$Label.log"
95 + Remove-Item $csvPath, $logPath -ErrorAction SilentlyContinue
96 +
97 + $script:results = New-Object System.Collections.Generic.List[object]
98 +
99 + function Invoke-TimedBuild
100 + {
101 + param([string]$Phase, [int]$Iteration, [int]$Total, [string]$TouchedFile)
102 +
103 + $sw = [Diagnostics.Stopwatch]::StartNew()
104 + $output = & cmake --build . --target $Target --config $Config -- -m 2>&1
105 + $sw.Stop()
106 + $exit = $LASTEXITCODE
107 +
108 + Add-Content -Path $logPath -Value "===== $Phase #$Iteration ($TouchedFile) exit=$exit elapsed=$($sw.Elapsed.TotalSeconds) ====="
109 + Add-Content -Path $logPath -Value ($output | Out-String)
110 +
111 + if ($exit -ne 0)
112 + {
113 + throw "Build failed during $Phase iteration $Iteration. See $logPath"
114 + }
115 +
116 + $record = [pscustomobject]@{
117 + Label = $Label
118 + Phase = $Phase
119 + Iteration = $Iteration
120 + File = $TouchedFile
121 + Seconds = [math]::Round($sw.Elapsed.TotalSeconds, 3)
122 + }
123 + $script:results.Add($record)
124 + $record | Export-Csv -Path $csvPath -NoTypeInformation -Append
125 + Write-Host ("[{0}] {1,-11} {2,3}/{3,-3} {4,7:N2}s {5}" -f $Label, $Phase, $Iteration, $Total, $record.Seconds, $TouchedFile)
126 + }
127 +
128 + Write-Host "Warm-up build (bring all dependencies up to date)..." -ForegroundColor Cyan
129 +
130 + function Reset-DebugDatabase
131 + {
132 + # Incremental links keep appending to the program database, which eventually
133 + # trips LNK1140 (4 GB limit) part way through a long run.
134 + Get-ChildItem (Join-Path $repoRoot 'bin') -Recurse -Include "$Target.pdb", "$Target.ilk" -ErrorAction SilentlyContinue |
135 + Remove-Item -Force -ErrorAction SilentlyContinue
136 + }
137 +
138 + Reset-DebugDatabase
139 + & cmake --build . --target $Target --config $Config -- -m | Out-Null
140 + if ($LASTEXITCODE -ne 0) { throw 'Warm-up build failed.' }
141 +
142 + Write-Host "`n=== FULL BUILDS ($FullBuilds) ===" -ForegroundColor Cyan
143 + for ($i = 1; $i -le $FullBuilds; $i++)
144 + {
145 + if (Test-Path $objDir) { Remove-Item $objDir -Recurse -Force }
146 + Reset-DebugDatabase
147 + Invoke-TimedBuild -Phase 'full' -Iteration $i -Total $FullBuilds -TouchedFile '(clean)'
148 + }
149 +
150 + Write-Host "`n=== INCREMENTAL BUILDS ($IncrementalBuilds) ===" -ForegroundColor Cyan
151 + $candidates = @(
152 + Get-ChildItem -Path $sourceDirFull -Filter *.cpp -Recurse |
153 + Where-Object { (Split-Path $_.DirectoryName -Leaf) -notin $ExcludeDirName } |
154 + Select-Object -ExpandProperty FullName
155 + )
156 + if ($candidates.Count -eq 0) { throw "No .cpp files found under $sourceDirFull" }
157 + Write-Host "Incremental candidate pool: $($candidates.Count) file(s) from $sourceDirFull"
158 +
159 + # Touches accumulate for the duration of the run and every file is restored at the
160 + # end. Restoring in the loop would re-dirty the file and make the next iteration
161 + # rebuild this iteration's translation unit as well as its own.
162 + $rand = New-Object System.Random($Seed)
163 + $originals = @{}
164 + try
165 + {
166 + for ($i = 1; $i -le $IncrementalBuilds; $i++)
167 + {
168 + $file = $candidates[$rand.Next(0, $candidates.Count)]
169 + if (-not $originals.ContainsKey($file))
170 + {
171 + $originals[$file] = [System.IO.File]::ReadAllBytes($file)
172 + }
173 +
174 + Add-Content -Path $file -Value "`n// build-benchmark touch $Label $i"
175 + Invoke-TimedBuild -Phase 'incremental' -Iteration $i -Total $IncrementalBuilds -TouchedFile (Split-Path $file -Leaf)
176 + }
177 + }
178 + finally
179 + {
180 + foreach ($entry in $originals.GetEnumerator())
181 + {
182 + [System.IO.File]::WriteAllBytes($entry.Key, $entry.Value)
183 + }
184 + Write-Host "Restored $($originals.Count) touched file(s)."
185 + }
186 +
187 + Write-Host "`n=== SUMMARY ($Label) ===" -ForegroundColor Green
188 + $script:results | Group-Object Phase | ForEach-Object {
189 + $s = $_.Group.Seconds | Measure-Object -Average -Minimum -Maximum -Sum
190 + $sorted = @($_.Group.Seconds | Sort-Object)
191 + $median = if ($sorted.Count % 2) { $sorted[[int]($sorted.Count / 2)] } else { ($sorted[$sorted.Count / 2 - 1] + $sorted[$sorted.Count / 2]) / 2 }
192 + [pscustomobject]@{
193 + Phase = $_.Name
194 + Count = $s.Count
195 + MeanSec = [math]::Round($s.Average, 2)
196 + MedSec = [math]::Round($median, 2)
197 + MinSec = [math]::Round($s.Minimum, 2)
198 + MaxSec = [math]::Round($s.Maximum, 2)
199 + TotSec = [math]::Round($s.Sum, 2)
200 + }
201 + } | Format-Table -AutoSize
202 +
203 + Write-Host "Results: $csvPath"
204 +}
205 +finally
206 +{
207 + Pop-Location
208 +}
tools/benchmarking/run-unity-batch-sweep.ps1 new
+67
@@ -0,0 +1,67 @@
1 +<#
2 +.SYNOPSIS
3 + Runs benchmark-build.ps1 across several unity-build batch sizes.
4 +
5 +.DESCRIPTION
6 + For each batch size the project is reconfigured, stale unity sources are removed,
7 + and a full benchmark run is performed. Labels are "<Prefix>-<name>", where a batch
8 + size of 0 is labelled "nounity".
9 +
10 +.EXAMPLE
11 + powershell tools\benchmarking\run-unity-batch-sweep.ps1 -Prefix split-before
12 +
13 +.EXAMPLE
14 + powershell tools\benchmarking\run-unity-batch-sweep.ps1 -Prefix wslc -SourceDir test\windows\wslc
15 +#>
16 +[CmdletBinding()]
17 +param(
18 + [Parameter(Mandatory = $true)][string]$Prefix,
19 + [int[]]$BatchSizes = @(0, 2, 4, 8),
20 + [int]$FullBuilds = 10,
21 + [int]$IncrementalBuilds = 100,
22 + [string]$Config = 'debug',
23 + [string]$Target = 'wsltests',
24 + [string]$TargetDir = 'test\windows',
25 + [string]$SourceDir,
26 + [string]$UnityVariable = 'WSL_UNITY_BATCH_SIZE'
27 +)
28 +
29 +$ErrorActionPreference = 'Stop'
30 +
31 +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
32 +Push-Location $repoRoot
33 +try
34 +{
35 + if (-not $SourceDir) { $SourceDir = $TargetDir }
36 +
37 + foreach ($batch in $BatchSizes)
38 + {
39 + $name = if ($batch -eq 0) { 'nounity' } else { "batch$batch" }
40 + $label = "$Prefix-$name"
41 +
42 + Write-Host "`n########## $label ($UnityVariable=$batch) ##########" -ForegroundColor Magenta
43 +
44 + & cmake . "-D$UnityVariable=$batch" | Out-Null
45 + if ($LASTEXITCODE -ne 0) { throw "cmake configure failed for batch size $batch." }
46 +
47 + # Generated unity sources are not pruned when the batch size changes, and the batch
48 + # size is shared by every unity-enabled target, so clear all of them.
49 + Get-ChildItem $repoRoot -Directory -Recurse -Filter Unity |
50 + Where-Object { $_.Parent.Name -like '*.dir' } |
51 + ForEach-Object { Remove-Item $_.FullName -Recurse -Force }
52 +
53 + & cmake . "-D$UnityVariable=$batch" | Out-Null
54 + if ($LASTEXITCODE -ne 0) { throw "cmake reconfigure failed for batch size $batch." }
55 +
56 + & powershell -NoProfile -File (Join-Path $PSScriptRoot 'benchmark-build.ps1') `
57 + -Label $label -FullBuilds $FullBuilds -IncrementalBuilds $IncrementalBuilds `
58 + -Config $Config -Target $Target -TargetDir $TargetDir -SourceDir $SourceDir
59 + if ($LASTEXITCODE -ne 0) { throw "Benchmark failed for $label." }
60 + }
61 +
62 + Write-Host "`n########## SWEEP COMPLETE ($Prefix) ##########" -ForegroundColor Green
63 +}
64 +finally
65 +{
66 + Pop-Location
67 +}