Increase decompiler test-suite CPU utilization via NUnit parallelism - #3940
Increase decompiler test-suite CPU utilization via NUnit parallelism#3940christophwille wants to merge 2 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
|
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.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.🤖 Generated with Claude Code