Increase decompiler test-suite CPU utilization via NUnit parallelism - #3940
Increase decompiler test-suite CPU utilization via NUnit parallelism#3940christophwille wants to merge 9 commits into
Conversation
A full ICSharpCode.Decompiler.Tests run kept a 24-logical-CPU machine at only ~46% average CPU: unattributed fixtures ran one at a time on NUnit's non-parallel queue, the default one-worker-per-CPU pool sat blocked on child compiler/runner processes, and the multi-minute roundtrip and correctness tests straggled at the end of the run. Fixtures now run in parallel by default, the worker count is generated at build time as 2x the building machine's logical CPUs (LevelOfParallelism only accepts a constant, and a checked-in number would be wrong on every other machine), and the two heavyweight fixtures are ordered first so the longest tests start immediately. In-flight tests measured 47-48 instead of 24; the suite is now bounded by its single longest test rather than by scheduling. Assisted-by: Claude:claude-fable-5:Claude Code
While the decompiler test suite runs, Defender's scan engine was measured using 1-4 CPU cores continuously and adds scan latency to every spawned compiler/runner process. Machine-level AV configuration does not belong in the repo, so document the folders worth excluding, the tradeoff, and the commands instead of automating the change. Assisted-by: Claude:claude-fable-5:Claude Code
Verification: do the NUnit parallelism attributes actually work under Microsoft.Testing.Platform?The attributes are consumed by NUnit itself, not by the host platform, so MTP vs. VSTest makes no difference - but here is the full verified chain rather than an appeal to documentation. 1. The attributes are physically in the compiled assemblyReading the PE metadata of the built
2. NUnit 4.6.1 honors the attribute unless the adapter passes an overrideDecompiling private int GetLevelOfParallelism(ITest loadedTest)
{
if (!Settings.TryGetValue("NumberOfTestWorkers", out object value))
return loadedTest.Properties.TryGet("LevelOfParallelism", DefaultLevelOfParallelism);
return ConvertSetting<int>(value);
}The assembly attribute populates the 3. NUnit3TestAdapter 6.2.0 does not inject
|
Tester.Initialize is about to issue the toolset Fetch calls concurrently; each Fetch ends by registering its install path in a plain Dictionary, which is not safe for concurrent writers. Lookups need no lock: they only happen after Initialize has awaited all registrations. Assisted-by: Claude:claude-fable-5:Claude Code
The setup fixture gates every test in the suite, and on a cold machine it serialized nine NuGet fetches plus two self-contained TestRunner builds. The fetches extract into disjoint directories and the builds depend on no fetched toolset, so everything now runs concurrently and is awaited once. Only the two Windows RID builds stay sequential with each other: they share the TestRunner project's obj/ directory, and their implicit restores would race on project.assets.json. Assisted-by: Claude:claude-fable-5:Claude Code
Every roundtrip test spawned its own vswhere.exe to answer a question that is invariant for the lifetime of the process. Lazy<Task<string>> with ExecutionAndPublication guarantees a single spawn even when the parallel roundtrip fixture hits the lookup from several tests at once. Assisted-by: Claude:claude-fable-5:Claude Code
RunAndCompareOutput awaited the two runs back to back, but they are independent processes with separately buffered output. The new StartRun helper also lets callers begin the original run even earlier and hand the in-flight task to the comparison; it pre-observes the task fault so a run abandoned after an upstream failure cannot surface as an UnobservedTaskException. Plain WhenAll (no error aggregation) keeps NUnit Ignore semantics when both runs raise IgnoreException, and the exit codes are still asserted in the original order, so failure output is unchanged. Assisted-by: Claude:claude-fable-5:Claude Code
The original binary is complete once the first compile (or ilasm) finishes, and the decompile/recompile stages only read it, so its execution now overlaps them instead of waiting at the very end of the pipeline. For mcs configurations the .exe.config write moves ahead of the run start - the runtime reads it at process launch - while the compiler-option mutation stays after the decompile, which must see the original options. Assisted-by: Claude:claude-fable-5:Claude Code
The RunWithTest/RunWithOutput lambdas blocked an NUnit worker thread with GetAwaiter().GetResult() on inherently async work. Passing a Func<string, Task> lets RunInternal await the action, and enables handing an already-running execution into the comparison. Assisted-by: Claude:claude-fable-5:Claude Code
In RunWithOutput roundtrip tests the reference executable from the ILSpy-tests checkout ran only after the whole-project decompile and the MSBuild rebuild had finished, although nothing in that pipeline writes to the input directory. Its execution now starts first and overlaps the multi-minute decompile. The submodule-missing guard moves ahead of the early start so those tests still report Ignored, not a faulted launch. Assisted-by: Claude:claude-fable-5:Claude Code
A full
ICSharpCode.Decompiler.Testsrun kept a 24-logical-CPU machine at only ~46% average CPU. This PR makes the suite use the machine it runs on: fixtures run in parallel by default, the NUnit worker pool is oversubscribed to 2x logical CPUs, and the multi-minute tests are scheduled first. A companion doc describes the Windows Defender exclusions that would remove the remaining scan overhead (deliberately not automated).Measured causes of the idle CPU
Parallelizableattribute (TypeSystem, Output, Util, Metadata, ProjectDecompiler, ...) - NUnit runs unattributed fixtures one at a time on its non-parallel queue. Only the 12 matrix runners declaredParallelizable(ParallelScope.All).Random_TestCase_1,ExplicitConversions*,NRefactory_CSharp, ...) that started mid-run and straggled at the end.MsMpEng.exe) ran at 1-4 cores continuously, scanning every compiled fixture and spawned process (out of scope for code changes; seedoc/WindowsDefenderExclusions.md).Options considered
For raising the worker count:
.runsettingswithNumberOfTestWorkers[assembly: LevelOfParallelism(48)]For the Defender overhead: apply exclusions vs. document them vs. skip. Machine-level AV configuration does not belong in the repo, so this PR only documents the exclusions (
doc/WindowsDefenderExclusions.md).How the oversubscription works
[assembly: LevelOfParallelism]only accepts a compile-time constant, so the csproj generates it during build:AssemblyAttributeitem is emitted into the SDK-generatedAssemblyInfo.cs(obj/.../ICSharpCode.Decompiler.Tests.AssemblyInfo.cs);_Parameter1_TypeNamemakes MSBuild emit the value as anintrather than a string.LevelOfParallelism(48); on a 4-core CI runner it generates8.[assembly: Parallelizable(ParallelScope.Fixtures)](inProperties/AssemblyInfo.cs) makes the previously-serial fixtures run concurrently with each other while tests within such a fixture stay sequential. Fixtures sharing process-global state can opt out with[NonParallelizable]; none currently needs to (DecompilerEventSourceTestswas audited - its assertions already filter by payload marker).[Order(1)]/[Order(2)]onRoundtripAssemblyandCorrectnessTestRunnerenqueue the multi-minute tests first.Before / after (24 logical CPUs, ILSpy-tests checked out)
The test-count difference is unrelated new tests picked up by the rebuild. Concurrency verifiably doubled and scheduling is now optimal (every giant starts at t=0), yet wall time and CPU stayed flat - which proves the worker pool is no longer the constraint:
Random_TestCase_1runs 464s wall-to-wall (up from 314s mid-run at baseline - it slows under the doubled contention); total wall time is essentially that one test. More workers cannot help; they only add contention against the critical path.Round 2: sequential awaits inside the test infra
A second pass hunted for places where the infra itself awaits independent work sequentially (
await a; await b;where aTask.WhenAllor an earlier start genuinely overlaps):Tester.Initializenow runs everything concurrently. It gates every test via the[SetUpFixture], and serialized nine NuGet toolset fetches plus two self-contained TestRunner builds. The fetches extract into disjoint directories and the builds depend on no fetched toolset, so all of it is now started eagerly and awaited once (registration dictionaries got a lock). Only the two Windows RID builds stay sequential with each other - they share the TestRunner project'sobj/, and their implicit restores would race onproject.assets.json. Biggest effect on a cold machine/CI, where the downloads dominated. Verified with 3 consecutive cold-cache runs (toolset dirs deleted each time), all green.RunAndCompareOutputawaited the two runs back to back; they are independent processes with separately buffered output. PlainTask.WhenAll(no error aggregation) keeps NUnit's Ignore semantics, and exit codes are still asserted in the original order, so failure output is unchanged (verified by sabotaging one path and checking the message).RunCS/RunVB/RunIL) and theRunWithOutputroundtrips, the original binary is complete after the first compile, and the decompile/recompile stages only read it - the newTester.StartRunhands the in-flight run into the comparison. For mcs configurations the.exe.configwrite moved ahead of the run start (the runtime reads it at process launch); the mcs matrix stays green. In roundtrips the pristine exe now overlaps the multi-minute whole-project decompile; the submodule-missing guard runs before the early start so those tests still report Ignored.FindMSBuildis cached (Lazy<Task<string>>): the parallel roundtrip fixture spawned onevswhere.exeper test for a process-invariant answer.testActionbecameFunc<string, Task>along the way, removingGetAwaiter().GetResult()blocking on NUnit worker threads.Full suite after the changes: 4170 tests, 0 failures, 20 skipped (environment-gated), wall in the same band as before - expected, since
Random_TestCase_1spends 98.6% of its 480s inside a singleWholeProjectDecompiler.DecompileProjectcall (measured from the harness'sDecompiled X in Nstamps), which none of this touches. The wins are cold-start setup, the correctness fixture (864 cases), and removed serial slack off the critical path.Follow-up ideas (non-Defender)
Random_TestCase_1and theExplicitConversions*variants are single generated executables fromRandom Tests/TestCaseGeneratorin the ILSpy-tests submodule. Splitting each into several smaller assemblies (or emitting the conversions matrix as N partitions) would turn one 460s pipeline into parallelizable chunks - the only way to push wall time meaningfully below ~8 minutes.WholeProjectDecompiler.MaxDegreeOfParallelism = ProcessorCount), so the giants' serial cost is in their single-assembly csc rebuild and execute/compare phases, which only splitting addresses.Random_TestCase_1starts ~27s into the run despiteOrder(1)(setup + fixture-construction order), and ~44s of unordered fast tests drain after it finishes. Recovering that head/tail is worth more than any remaining in-process await.build-ilspy.yml, separatedotnet test --projectsteps), while the Windows job's--solutionform runs them concurrently. Running them in parallel cuts roughly 40% of that job's test time; needs a solution filter withoutILSpy.Tests.Windows(which must not run off-Windows) or backgrounded steps.ILSpy.Testsextracts zero parallelism by construction (one Avalonia dispatcher viaAvaloniaTestIsolationLevel.PerAssemblyplus the static MEF container;PerTestwas tried and reverted). Only process-level sharding of fixtures helps; measured fixture durations bin-pack to ~185s slowest shard at 4 shards. Separate effort.🤖 Generated with Claude Code