Skip to content

Cut the ILSpy.Tests wall clock: cheap fixtures, dead waits, duplicate tests, and process sharding - #4269

Open
christophwille wants to merge 8 commits into
masterfrom
perf/ui-test-wall-clock
Open

christophwille wants to merge 8 commits into
masterfrom
perf/ui-test-wall-clock

Conversation

@christophwille

Copy link
Copy Markdown
Member

Implements levers A-D from #4262 ("Totals and recommended order"), plus the two sharding items: a local script for 4 shards and 2 shards on the Linux/macOS CI jobs. The harness knobs are out of scope (measured as noise in the issue). One commit per lever, so each step's effect is visible in the history.

Before implementing, the A-D sites were rediscovered against current master (the issue was written at 6d5b5fe): every listed site still existed, and the tests added since (OptionsTabTests, FrozenTabRefreshTests, RunInNewTabTests, the new ReferenceClickTests cases, the DecompileInNewView middle-click tests that decompile the whole CoreLib assembly node) were folded into A.

Measurements

Same machine as the issue (24 logical CPUs, Windows), Debug, ILSpy.Tests.exe --report-trx run alone from bin/Debug/net11.0, one run per step. Run-to-run noise is about 20 s (5 %), so the "sum of durations of the touched fixtures" column (from the TRX files, before vs after that commit) is the precise per-step number; the wall clock is the headline.

step commit wall sum of all test durations tests run touched fixtures before -> after
baseline (48fc74e) 369 s 367 s 1359
A: sample type / fixtures instead of framework types 8d8da7a 271 s 268 s 1359 204 s -> 101 s (29 fixtures)
B: delete the nine dead Task.Delay(600) 7bb8b92 262 s 260 s 1359 18.7 s -> 10.0 s (5 fixtures)
C: delete subsumed tests 9f9609e 251 s 249 s 1343 50.8 s -> 41.7 s (8 fixtures)
D: merge same-setup tests 9ede0d1 256 s 253 s 1328 48.6 s -> 45.4 s (10 fixtures)

D is inside the noise band on the wall clock; it removes 15 boots at ~0.3 s each, which is what the fixture column shows. The 4 tests that never run are the [Explicit]/ignored ones, same as before.

Largest single-test wins from A (seconds, baseline -> after A): AnalyzerResultHighlightTests 15.7 -> 2.1, ReferenceClickTests 18.0 -> 2.7, PreviewTabPromotionTests 13.4 -> 6.4, DecompilerViewTests 12.6 -> 5.5, BookmarkNavigationViewTests 10.0 -> 5.1, DecompileInNewViewTests 9.9 -> 4.0, ILLanguageTooltipTests 7.1 -> 1.7, TabFocusNavigationTests 5.8 -> 0.8, BookmarkAnchoringTests 5.0 -> 0.4, SetActiveDockableTests 4.9 -> 0.4.

One prediction from the issue did not hold: sharing one AnalyzerContext across FieldAccessAnalyzerTests made that fixture slower in all four runs (4.9 s -> 8.4-8.7 s), so 7e941ad goes back to the per-call context the fixture had before.

Sharding (after D, same machine)

runuitests.ps1 runs the built suite as N concurrent dotnet test --no-build hosts with disjoint --filters from a measured weight table; the last shard is the complement of everything the others name, so a fixture added later always runs somewhere. Every shard writes test-results/ui-shard<N>.trx + .log.

shards wall per-shard speed-up
1 (through the script) 259 s 1.0x
2 153 s 137 s / 152 s 1.7x
4 91 s 88 / 77 / 84 / 90 s 2.8x

Shard totals sum to 1329 in every configuration, i.e. every non-[Explicit] test. Concurrent hosts needed one harness fix: TestApp.SweepLeftovers deleted every sibling session directory under %TEMP%\ILSpy.Tests at startup, including another live host's; it now only sweeps directories older than 30 minutes.

CI

The Linux and macOS "Execute UI tests" step now runs ./runuitests.ps1 -Shards 2 -Configuration Release; the upload and report steps already glob test-results/*.trx. Two shards, not more: the heavier shard peaks at about 3 GB and the runners have 3-4 cores. The Windows job is unchanged (it overlaps the UI tests with the other test hosts already and is bounded by the decompiler suite). The step time on this PR's run is the number to compare against the 269 s the issue recorded.

AGENTS.md notes that sharding is the local optimization for this suite and how to run one fixture.

Assisted-by: Claude:claude-fable-5-1:Claude Code

🤖 Generated with Claude Code

Most of the slow UI tests only needed "a document", "an active tab" or
"a type with a few members", yet got it by decompiling System.String,
System.Object or System.Linq.Enumerable, or by landing on the CoreLib
assembly row. Each such decompile rebuilds a framework type system and
costs one to ten seconds; the sample type in the test assembly and the
emitted fixture assemblies give the same shape for a fraction of that.
FieldAccessAnalyzerTests also built a fresh AnalyzerContext per call
over the whole test assembly; one per fixture is enough.

Tests whose assertion depends on the framework type (XML docs, type
forwarders, derived-type scans, a long document, resources, references)
keep it. #4262

Single-process wall clock on the same machine: 369 s -> 271 s.

Assisted-by: Claude:claude-fable-5-1:Claude Code
Nine tests slept past a "NavigationHistory collapses selections within
0.5 s" window before selecting a second node. The history only collapses
a rapid re-selection of the same entry (NavigationHistory.Record), and
every one of these tests selects a different node, so the waits bought
nothing. The affected fixtures pass three consecutive runs without them.
#4262

Single-process wall clock on the same machine: 271 s -> 262 s.

Assisted-by: Claude:claude-fable-5-1:Claude Code
Each deleted test drove the same production path as a surviving test in
the same file and asserted a subset of what that test asserts, so a
regression it could catch already fails the survivor. Where the deleted
test held one assertion of its own (the TypeTreeNode type check, the
MenuCategory metadata, the freeze flipping IsPreview without a new tab,
the Shift+Down range precondition), that assertion moved into the
survivor. The Ctrl+R test on the assembly tree pane duplicated
AnalyzerTreeKeyboardTests, which reaches the same window key binding.
#4262

Single-process wall clock on the same machine: 262 s -> 251 s, 16 tests
fewer.

Assisted-by: Claude:claude-fable-5-1:Claude Code
Each merged pair booted the app, built the same state and drove the
same action, differing only in which property they then asserted; one
test now carries all of those assertions, so the shared setup runs once.
The package tests in AssemblyTreeModelTests also built the same zip (two
emitted assemblies) per test, which is immutable input and now built
once per fixture. #4262

Single-process wall clock on the same machine: 251 s -> 256 s, inside
the 20 s run-to-run noise; the sum of test durations went 249 s ->
253 s with 15 tests fewer, so this step is about not re-arranging the
same state rather than about seconds.

Assisted-by: Claude:claude-fable-5-1:Claude Code
ILSpy.Tests is serial by design: AvaloniaTestIsolation(PerAssembly)
gives one headless session whose dispatcher every [AvaloniaTest] blocks
on, and the app composition is process-global, so NUnit workers would
only queue. The only parallelism available is several test hosts with
disjoint filters, which this script arranges: a greedy split of a
measured per-group weight table, with the last shard taking the
complement of everything the others name so a new fixture can never
fall out of every shard. The namespace-only split is not enough because
about a hundred fixtures in sub-folders still declare the root
namespace, which is why root fixtures appear in the table and why the
complement is needed.

The leftover-session sweep in TestApp deleted every sibling directory
under %TEMP%\ILSpy.Tests at startup, including the live session
directories of another host, so it now only removes directories old
enough to belong to an earlier run. #4262

Measured on the same machine, Debug, after the test edits (256 s in one
process): 4 shards 91 s (2.8x), 2 shards 153 s (1.7x), 1 shard through
the script 259 s. Shard totals sum to every non-[Explicit] test.

Assisted-by: Claude:claude-fable-5-1:Claude Code
The "Execute UI tests" step is the second-longest in the Desktop job
and runs one serial test host on a 3-4 core runner. Two concurrent
shards through runuitests.ps1 use a second core; the TRX upload and
report steps already glob test-results/*.trx, so the per-shard files
flow into them unchanged. The Windows job keeps its solution-wide run:
it already overlaps ILSpy.Tests with the other test hosts and is bounded
by the decompiler suite. #4262

Assisted-by: Claude:claude-fable-5-1:Claude Code
Sharing one context across the fixture's ten Analyze calls was expected
to save the per-call scan of the test assembly, but four consecutive
full-suite runs measured the fixture at 8.4-8.7 s against 4.9 s with a
fresh context per call, every test about 0.4 s slower. The per-call
shape is what the production analyzer pane uses as well, so this keeps
the measured faster variant rather than the predicted one. #4262

Assisted-by: Claude:claude-fable-5-1:Claude Code
The Release compiler folds Twice's local into a single return
expression, so "return result" only appears in the decompiled text of a
Debug build of the test assembly; CI runs the Release build and failed
both DecompilerViewTests cases on Linux and macOS. "text.Length" is in
the body either way and still proves the body, not just the signature,
was rendered.

Assisted-by: Claude:claude-fable-5-1:Claude Code
@christophwille

Copy link
Copy Markdown
Member Author

CI numbers, now that the run is green (9f41e77 fixed the two sample-body assertions that only held for a Debug build of the test assembly; CI tests the Release build).

"Execute UI tests" step and whole Desktop job, master run 39292241346 (today's HEAD before this branch) vs this PR's run 39317397450:

job UI test step whole job
Linux (ubuntu-latest) 283 s -> 131 s 14:44 -> 10:27
macOS (macos-15) 366 s -> 179 s 9:40 -> 6:11

The step time combines the test edits (fewer and cheaper tests) with the two concurrent shards; the per-shard TRX files land in the existing test-results-<platform> artifacts and the test summary step picks both up. The Windows jobs run the solution-wide dotnet test as before.

Assisted-by: Claude:claude-fable-5-1:Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant