Use server GC for the decompiler test suite (workstation GC on the shared Windows CI runner) - #4033
Merged
Merged
Conversation
siegfriedpammer
approved these changes
Aug 19, 2026
The suite keeps one NUnit worker per logical CPU busy with allocation-heavy decompiles (223 GB allocated per run), so under workstation GC every gen0/gen1 collection any worker triggers suspends the whole process. Measured on a 24-thread Windows box (Debug, ILSpy-tests checked out): 27,229 gen0 / 6,919 gen1 collections and 305 s of total GC pause in a 553 s run, at 45% average CPU. With server GC the same run takes 310 s, 1,251 gen0 / 492 gen1, 14 s of pause, 80% CPU, for the same ~46 min of processor time; the in-suite roundtrip decompiles drop 2-3x (Random_TestCase_1 353 s -> 133 s, ExplicitConversions 319 s -> 136 s, NRefactory_CSharp 337 s -> 156 s). Standalone ilspycmd timings are unaffected, which is what pointed at contention inside the test process rather than decompiler cost. Assisted-by: Claude:claude-fable-5:Claude Code
The Windows job runs every test host of the solution concurrently on a 4-core runner. With ICSharpCode.Decompiler.Tests on server GC that host sits at 98-100% CPU for the whole test step, and the neighbours starve: the process-module walk in ILSpy.Tests.Windows exceeded its 60 s budget (OperationCanceledException in NetFrameworkProcessesTests, Release job), a background sampler measured its own walk over the ~170 runner processes at 40-170 s instead of a few seconds, and ILSpy.Tests took 685 s instead of 375 s (Release). Memory was not the constraint: never below 9 GB free, disk idle. The decompiler suite itself moved little on that box (Debug 1393 s -> 1226 s, Release 664 s -> 751 s). Server GC stays on for machines the suite has to itself; the Linux job runs the projects one at a time. Assisted-by: Claude:claude-fable-5:Claude Code
christophwille
force-pushed
the
decompiler-tests-server-gc
branch
from
August 19, 2026 12:34
351a711 to
1ddd598
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The server-GC part of #3940, redone on its own on top of master (which now has #4012). Nothing else from #3940 is included, and #3940 itself is untouched.
Two commits:
<ServerGarbageCollection>true</ServerGarbageCollection>forICSharpCode.Decompiler.Tests. The suite keeps one NUnit worker per logical CPU busy with allocation-heavy decompiles (~220 GB allocated per run); under workstation GC every gen0/gen1 collection any worker triggers suspends the whole process, and on a 24-thread box that is more than half the wall time.DOTNET_gcServer: 0on the Windows CI test step. The Windows job runs every test host of the solution concurrently on a 4-core runner, and there server GC starves the neighbours (details below). The env var overrides the runtimeconfig setting, so the suite still gets server GC on machines it has to itself (local runs, the Linux job which runs the projects one at a time).How this was verified
1. Local: does server GC really remove the GC pause on master?
The commit's numbers were originally measured on #3940, which also had 2x-CPU NUnit workers and all fixtures parallel, so they were re-taken on plain master + commit 1. Same binary, same machine state, Debug, 24-thread Windows 11 box,
ILSpy-testschecked out (roundtrips run), machine otherwise idle. The only difference between the two runs isDOTNET_gcServer=0set for the workstation run. GC counters come fromGC.CollectionCount,GC.GetTotalPauseDuration(),GC.GetTotalAllocatedBytes()andProcess.TotalProcessorTimeread at the end of the run; CPU from\Processor(_Total)\% Processor Timesampled every 4 s duringdotnet test.Per-test (TRX
startTime..endTime):Same processor time, half the wall: the suite was waiting on stop-the-world pauses, not on the decompiler.
2. CI: is the Windows workstation-GC valve still needed now that #4012 is in?
#3940's status comment blamed its 3/6 Windows failures on the
ILSpy.Tests15 GB leak (paged-out runner) and suggested that with #4012 the valve might be unnecessary. To test that, a first push of this branch carried commit 1 only, no valve, plus a temporary diagnostics sampler (see the last section) logging memory, pagefile, paging/disk counters, CPU and a timed module walk over every process every 20 s during the test step. Run: https://github.com/icsharpcode/ILSpy/actions/runs/32226154313ICSharpCode.Decompiler.Testshost, Debug / ReleaseILSpy.Testshost, Debug / ReleaseILSpy.Tests.Windowshost, Debug / ReleaseNetFrameworkProcessesTests.The_Explorer_Routes_A_Framework_Process_To_The_Module_Scan:OperationCanceledExceptionafter 61 s (the explorer's 60 s budget), exactly the #3940 failureWhat the sampler recorded in both Windows jobs, the green Debug one included:
Avg. Disk sec/Read/ disk queue length% Processor Timesvchost/spoolsv/mqsvcwith 7-22 MB working sets at 20-66 s eachSo memory is not the constraint any more (#4012 did its job: 9+ GB free, disk idle), but the decompiler host on server GC pins all 4 cores for the whole step and the neighbours' cross-process module enumeration is starved of CPU. Under workstation GC the decompiler host spends half its wall time paused, which is what left the neighbours room on master. The Debug job passed only because its one timed walk happened to land in a quieter moment. The decompiler suite itself gains little on 4 cores (server GC scales with cores; Debug -12%, Release +13%).
Conclusion: the valve stays. Commit 2 was added back with its comment and message rewritten to this evidence, the sampler commit was dropped and the branch force-pushed. Confirmation run of the final branch: https://github.com/icsharpcode/ILSpy/actions/runs/32228410174 - both Windows jobs green (Debug tests 22m09, Release 13m12), Linux green, macOS failed in
Right_Clicking_A_Second_Row_Moves_The_Context_Highlight_To_It, the same UI test that failed on master's own post-#4012 run (pre-existing, unrelated).Not taken from #3940: the concurrent .NET Framework process scan (
b592b6164, reverted there already; on a CPU-saturated boxAsParallel()cannot help and empirically did not). The fixture-host lifetime hardening (2853a8b08) is unrelated to GC and is its own PR: #4032.Temporary helpers used for the verification (all backed out, none in this branch)
Local GC counters. An uncommitted
[OneTimeTearDown]added to the existingToolsetSetup[SetUpFixture]inICSharpCode.Decompiler.Tests/TestTraceListener.cs(namespaceICSharpCode.Decompiler, so it wraps every test in the assembly), appending one line to a file named byGCSTATS_FILE:Reverted with
git checkout --before the commit was amended.Local driver (
run-suite.ps1, scratch only). Built once (restore.ps1, thendotnet build ICSharpCode.Decompiler.Tests -c Debug --no-restore), then per mode: set or clearDOTNET_gcServer, start a backgroundGet-Counter '\Processor(_Total)\% Processor Time' -SampleInterval 4job,Measure-Command { dotnet test --project ICSharpCode.Decompiler.Tests\ICSharpCode.Decompiler.Tests.csproj --no-build --report-trx --results-directory <mode> }(--no-buildso no implicit restore prunespackages.lock.json), stop the job, average the samples. Per-test durations were read from the TRXUnitTestResultstart/end stamps.CI diagnostics sampler (commit
4489ff5bbfrom #3940, cherry-picked and later dropped).BuildTools/ci-diag-sampler.ps1, started hidden by an extra workflow step before "Execute unit tests" and writing into the test-results directory (the upload-artifact path was widened from*.trxto*). Every 20 s it loggedWin32_OperatingSystemfree memory andWin32_PageFileUsage;\Memory\Pages/sec,\Memory\Pages Input/sec,\Memory\Available MBytes,\PhysicalDisk(_Total)\Avg. Disk sec/Read,\PhysicalDisk(_Total)\Current Disk Queue Length,\Processor(_Total)\% Processor Time,\System\Processes; the six largest working sets; and its own timedProcess.Modules.Countwalk over every process, naming any process that took more than a second. Present only in run 32226154313; the commit was removed withgit reset --hard HEAD~1and a force-push before commit 2 was added.CI reader (
read-run.ps1, scratch only).gh run download <id> --pattern 'test-results-*', then per TRX the run duration and counters plus the durations of the process-explorer tests, and fromdiag.logthe minimum free memory, maximum pagefile use, module-walk median/max with the slow-process lists, andPages Input/secmax/average. The master baseline row above comes from running it on run 32177067040.🤖 Generated with Claude Code