.NET Decompiler with support for PDB generation, ReadyToRun, Metadata (&more) - cross-platform!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

295 lines
13 KiB

// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using AwesomeAssertions;
using ILSpy.AppEnv;
using ILSpy.AssemblyTree;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// Hand-run benchmarks for startup-related code paths under load. Marked
/// <see cref="ExplicitAttribute"/> so the regular test suite skips them — they're
/// expensive (file copies + waiting on metadata loads) and the timings depend on
/// the host machine's IO + thread-pool contention. Run via:
/// <c>dotnet test ILSpy.Tests --filter "Category=Performance"</c>
/// or invoke a single benchmark by name. Output is written to NUnit's TestContext
/// log so the timings show up next to each test in the runner.
/// </summary>
[TestFixture]
[Category("Performance")]
public class StartupPerfTests
{
const int LargeListAssemblyCount = 200;
[Explicit("Perf benchmark — emits timings for manual inspection")]
[AvaloniaTest]
public async Task BindTree_With_Large_AssemblyList_Reports_Phase_Timings()
{
// Simulates the user-perceived startup of a saved list with many assemblies. The
// composition is shared across tests, so we can't measure cold-start of MainWindow
// — instead we time the phases that dominate startup with N assemblies: opening the
// LoadedAssembly entries, settling the AssemblyList, and waiting for every metadata
// load to finish. Compare these numbers against the same run after a code change to
// see if startup got better or worse.
// Arrange — N temp copies of CoreLib so OpenAssembly can't dedupe them.
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpy.PerfTest", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var sourcePath = typeof(object).Assembly.Location;
TestContext.Out.WriteLine($"Cloning {LargeListAssemblyCount} copies of {sourcePath} to {tempDir}");
var swCopy = Stopwatch.StartNew();
var copies = new string[LargeListAssemblyCount];
for (int i = 0; i < LargeListAssemblyCount; i++)
{
copies[i] = Path.Combine(tempDir, $"copy{i:D4}.dll");
File.Copy(sourcePath, copies[i]);
}
swCopy.Stop();
TestContext.Out.WriteLine($" file copy: {swCopy.ElapsedMilliseconds} ms");
// Boot the window + wait for the standard 3 assemblies (CoreLib + Uri + Linq) to
// settle so they don't pollute the per-assembly timing.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var baselineCount = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Length;
TestContext.Out.WriteLine($"Baseline: {baselineCount} assemblies after window opened");
// Act 1 — fire OpenAssembly for every clone. Each call constructs a LoadedAssembly
// and queues a Task.Run(LoadAsync), so this is essentially the cost of the
// in-memory bookkeeping (path canonicalisation, dictionary insert, list append).
var swOpen = Stopwatch.StartNew();
foreach (var path in copies)
vm.AssemblyTreeModel.AssemblyList!.OpenAssembly(path);
swOpen.Stop();
TestContext.Out.WriteLine($"OpenAssembly x{LargeListAssemblyCount}: {swOpen.ElapsedMilliseconds} ms "
+ $"({swOpen.ElapsedMilliseconds / (double)LargeListAssemblyCount:0.##} ms/asm)");
// Act 2 — wait for the AssemblyList to actually contain all the new entries (the
// add is dispatched onto the UI thread when called from a worker thread; we're on
// the UI thread here so the add was synchronous, but we still need to wait for the
// AssemblyListTreeNode to project the new children into the tree).
var swSettle = Stopwatch.StartNew();
await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Length >= baselineCount + LargeListAssemblyCount,
timeout: TimeSpan.FromSeconds(60));
swSettle.Stop();
TestContext.Out.WriteLine($"AssemblyList settled to {baselineCount + LargeListAssemblyCount}: {swSettle.ElapsedMilliseconds} ms");
// Act 3 — wait for every assembly's metadata load to complete. This is the work
// that the async-restore path defers when the user has a saved selection.
var allAssemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies();
var swLoad = Stopwatch.StartNew();
await Task.WhenAll(allAssemblies.Select(a => a.GetLoadResultAsync()));
swLoad.Stop();
TestContext.Out.WriteLine($"All {allAssemblies.Length} GetLoadResultAsync awaited: {swLoad.ElapsedMilliseconds} ms "
+ $"({swLoad.ElapsedMilliseconds / (double)allAssemblies.Length:0.##} ms/asm)");
// Act 4 — the assembly-tree pane should still be responsive. Measure how long it
// takes the AssemblyListPane to produce a fresh DataGrid descendant from this point
// (proxy for "tree is interactive").
var pane = await window.WaitForComponent<AssemblyListPane>();
var swGrid = Stopwatch.StartNew();
var grid = await pane.WaitForComponent<DataGrid>();
swGrid.Stop();
TestContext.Out.WriteLine($"DataGrid descendant available: {swGrid.ElapsedMilliseconds} ms");
// Sanity assertions — large enough to never trip on slow machines, but tight
// enough to flag a 10× regression.
vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Length
.Should().BeGreaterThanOrEqualTo(baselineCount + LargeListAssemblyCount);
swOpen.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(30),
"opening N LoadedAssembly entries is in-memory work and should never get this slow");
// Surface the StartupLog elapsed (ms since process start) so the test output also
// captures the big-picture timing alongside the per-phase deltas above.
StartupLog.Mark("StartupPerfTests benchmark completed");
}
finally
{
try
{ Directory.Delete(tempDir, recursive: true); }
catch { /* test cleanup must never fail */ }
}
}
/// <summary>
/// CI-runnable variant of <see cref="BindTree_With_Large_AssemblyList_Reports_Phase_Timings"/>.
/// Same shape but with a much smaller assembly count (8 vs 200) so it can run inside the
/// regular suite — catches order-of-magnitude regressions in the open + load pipeline
/// without the 30+ second cost of the full benchmark. NOT [Explicit] on purpose.
/// </summary>
[AvaloniaTest]
public async Task BindTree_With_Small_AssemblyList_Settles_In_Reasonable_Time()
{
const int Copies = 8;
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpy.PerfTest.CI", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var sourcePath = typeof(object).Assembly.Location;
var clones = new string[Copies];
for (int i = 0; i < Copies; i++)
{
clones[i] = Path.Combine(tempDir, $"copy{i:D2}.dll");
File.Copy(sourcePath, clones[i]);
}
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var baselineCount = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Length;
var swTotal = Stopwatch.StartNew();
foreach (var path in clones)
vm.AssemblyTreeModel.AssemblyList!.OpenAssembly(path);
await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Length >= baselineCount + Copies,
timeout: TimeSpan.FromSeconds(60));
swTotal.Stop();
// Relaxed CI threshold — 8 assemblies should never take more than 15s, even on a
// shared CI runner. A 10× regression of the open-and-settle pipeline trips this.
swTotal.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(15),
"opening + settling 8 LoadedAssembly entries should complete in well under 15s");
vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Length
.Should().BeGreaterThanOrEqualTo(baselineCount + Copies);
}
finally
{
try
{ Directory.Delete(tempDir, recursive: true); }
catch { /* cleanup must never fail */ }
}
}
const int ResponsivenessAssemblyCount = 200;
[Explicit("Perf benchmark — emits dispatcher-latency stats for manual inspection")]
[AvaloniaTest]
public async Task UI_Stays_Responsive_While_Many_Assemblies_Load()
{
// Probes <see cref="Dispatcher.UIThread"/> latency at <see cref="DispatcherPriority.Background"/>
// continuously while a flood of assemblies is added and their metadata loads on the
// background thread pool. If the UI thread blocks (e.g. someone re-introduces a
// .GetAwaiter().GetResult() on the saved-path restore), latency spikes and the test
// reports it. Run with:
// dotnet test --filter "FullyQualifiedName~UI_Stays_Responsive"
// Setup: clone CoreLib N times.
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpy.PerfTest", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var sourcePath = typeof(object).Assembly.Location;
var copies = new string[ResponsivenessAssemblyCount];
for (int i = 0; i < ResponsivenessAssemblyCount; i++)
{
copies[i] = Path.Combine(tempDir, $"copy{i:D4}.dll");
File.Copy(sourcePath, copies[i]);
}
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Trigger the heavy load: each OpenAssembly queues a Task.Run(LoadAsync), so the
// metadata IO + parsing happens on the thread pool — the UI thread is free to keep
// pumping the dispatcher.
var swLoad = Stopwatch.StartNew();
foreach (var path in copies)
vm.AssemblyTreeModel.AssemblyList!.OpenAssembly(path);
var allAssemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies();
var loadTasks = allAssemblies.Select(a => a.GetLoadResultAsync()).ToArray();
// Probe the dispatcher from the UI thread itself. Each iteration:
// 1) await an InvokeAsync(noop, Background) — this measures how long the
// dispatcher takes to service a low-priority callback
// 2) await Task.Delay(20) — yields to the dispatcher between samples
// If the UI thread blocks synchronously (e.g. someone re-introduces a
// .GetAwaiter().GetResult() call in the load path), that block manifests as a
// huge step in the InvokeAsync latency for that iteration.
var latencies = new List<long>();
var probeStart = Stopwatch.StartNew();
while (!loadTasks.All(t => t.IsCompleted) && probeStart.Elapsed < TimeSpan.FromMinutes(2))
{
var sw = Stopwatch.StartNew();
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background);
sw.Stop();
latencies.Add(sw.ElapsedMilliseconds);
await Task.Delay(20);
}
swLoad.Stop();
if (latencies.Count == 0)
{
Assert.Fail("dispatcher probe collected no samples — load may have completed before the probe could run");
return;
}
latencies.Sort();
long max = latencies[^1];
long p50 = latencies[latencies.Count / 2];
long p95 = latencies[(int)(latencies.Count * 0.95)];
double mean = latencies.Average();
TestContext.Out.WriteLine($"Load completed in {swLoad.ElapsedMilliseconds} ms across {ResponsivenessAssemblyCount} assemblies.");
TestContext.Out.WriteLine($"Dispatcher Background-priority latency over {latencies.Count} samples:");
TestContext.Out.WriteLine($" max {max} ms");
TestContext.Out.WriteLine($" p95 {p95} ms");
TestContext.Out.WriteLine($" p50 {p50} ms");
TestContext.Out.WriteLine($" mean {mean:0} ms");
// p95 keeps the threshold honest — occasional one-off spikes (GC, JIT, the assembly
// post-load Add-to-collection notification) are fine. 200 ms is the boundary where
// the user starts perceiving "stutter"; a sync block on the UI thread would push
// p95 into the seconds.
p95.Should().BeLessThan(200,
$"the UI thread must stay responsive while {ResponsivenessAssemblyCount} assemblies load — "
+ $"p95 latency >= 200 ms means something is blocking the dispatcher");
}
finally
{
try
{ Directory.Delete(tempDir, recursive: true); }
catch { /* test cleanup must never fail */ }
}
}
}