Browse Source

Read the file system once per reference closure, not per reference

Resolving one assembly resolves its whole reference closure, and every
reference in it asked the same framework directories the same questions.
The worst of it was the scan for the closest version folder of a shared
framework: a directory listing plus a recursive file search, repeated per
reference and per runtime pack - 42 scans for two distinct answers when
decompiling ICSharpCode.ILSpyX.dll.

The scan result is only safe to keep for a bounded time: a runtime can be
installed or removed while ILSpy runs, and reloading an assembly list has
to see that. So it is kept for the length of an explicitly opened scope,
which the type system opens around the closure it resolves and closes
again afterwards; outside a scope the file system is read as before. The
scope owns what was read, so two of them on one resolver do not stack -
the first to end takes it, and the other reads the file system again.

BeginSnapshot is on IAssemblyResolver rather than an interface of its
own: it is core functionality of a resolver, and one implementation is
not an abstraction. This breaks the interface for implementors outside
this repository, who opt out by returning null - which is what the three
resolvers here that hold nothing do.

The remaining probes cost nothing to fix: the preferred runtime pack was
listed among the defaults it already belongs to, so its directory was
scanned twice for every reference that is not in it, and one package
folder was probed once per assembly the package contains.

Measured over 27 references with a fresh resolver each time: 3.3 ms per
assembly before, 3.1 ms without a scope, 1.1 ms with one.

Assisted-by: Claude:claude-opus-5:Claude Code
pull/4106/head
Siegfried Pammer 2 weeks ago
parent
commit
2115bd9028
  1. 135
      ICSharpCode.Decompiler.Tests/ResolutionSnapshotTests.cs
  2. 3
      ICSharpCode.Decompiler.Tests/TypeSystem/DuplicateAssemblyReferenceTests.cs
  3. 6
      ICSharpCode.Decompiler/Metadata/AssemblyReferences.cs
  4. 40
      ICSharpCode.Decompiler/Metadata/DotNetCorePathFinder.cs
  5. 62
      ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs
  6. 8
      ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs
  7. 6
      ICSharpCode.ILSpyX/LoadedAssembly.cs
  8. 3
      ICSharpCode.ILSpyX/LoadedPackage.cs
  9. 3
      ILSpy.BamlDecompiler.Tests/MissingReferencesTests.cs
  10. 2
      TestTools/nugetfuzz.cs

135
ICSharpCode.Decompiler.Tests/ResolutionSnapshotTests.cs

@ -0,0 +1,135 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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.IO;
using ICSharpCode.Decompiler.Metadata;
using NUnit.Framework;
namespace ICSharpCode.Decompiler.Tests
{
[TestFixture]
public class ResolutionSnapshotTests
{
static UniversalAssemblyResolver Resolver()
{
return new UniversalAssemblyResolver(null, throwOnError: false, targetFramework: null);
}
[Test]
public void DirectoryIsScannedOnceWhileASnapshotIsOpen()
{
var resolver = Resolver();
int scans = 0;
using (resolver.BeginSnapshot())
{
for (int i = 0; i < 3; i++)
{
Assert.That(resolver.GetOrAddVersionFolder("/shared/pack", _ => { scans++; return "9.0.0"; }),
Is.EqualTo("9.0.0"));
}
}
Assert.That(scans, Is.EqualTo(1));
}
[Test]
public void DirectoryIsScannedEveryTimeWithoutASnapshot()
{
var resolver = Resolver();
int scans = 0;
for (int i = 0; i < 3; i++)
{
resolver.GetOrAddVersionFolder("/shared/pack", _ => { scans++; return "9.0.0"; });
}
Assert.That(scans, Is.EqualTo(3));
}
[Test]
public void WhatWasScannedIsForgottenWhenTheSnapshotEnds()
{
// The point of the scope: outside it the file system is read afresh, so an assembly
// list reloaded after a runtime was installed or removed sees the new state.
var resolver = Resolver();
int scans = 0;
using (resolver.BeginSnapshot())
{
resolver.GetOrAddVersionFolder("/shared/pack", _ => { scans++; return "9.0.0"; });
}
using (resolver.BeginSnapshot())
{
resolver.GetOrAddVersionFolder("/shared/pack", _ => { scans++; return "10.0.0"; });
}
Assert.That(scans, Is.EqualTo(2));
}
[Test]
public void ASecondScopeDoesNotStackWithTheFirst()
{
// Two decompilations can overlap on the resolver a LoadedAssembly owns. Whichever scope
// ends first takes the cache with it and the other reads the file system again, which
// costs that one its head start and nothing else.
var resolver = Resolver();
int scans = 0;
var outer = resolver.BeginSnapshot();
var inner = resolver.BeginSnapshot();
inner.Dispose();
resolver.GetOrAddVersionFolder("/shared/pack", _ => { scans++; return "9.0.0"; });
outer.Dispose();
Assert.That(scans, Is.EqualTo(1), "the scan happens, it is simply not held any more");
}
[Test]
public void ResolverFindsTheSameFileInsideAndOutsideASnapshot()
{
string directory = Path.Combine(Path.GetTempPath(), "ILSpySnapshotTest_" + Path.GetRandomFileName());
Directory.CreateDirectory(directory);
try
{
string file = Path.Combine(directory, "SomeLibrary.dll");
File.WriteAllBytes(file, new byte[0]);
var resolver = new UniversalAssemblyResolver(null, throwOnError: false, targetFramework: null);
resolver.AddSearchDirectory(directory);
var reference = AssemblyNameReference.Parse("SomeLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
string outside = resolver.FindAssemblyFile(reference);
string inside;
using (resolver.BeginSnapshot())
{
inside = resolver.FindAssemblyFile(reference);
}
Assert.That(outside, Is.EqualTo(file));
Assert.That(inside, Is.EqualTo(file));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
}
}

3
ICSharpCode.Decompiler.Tests/TypeSystem/DuplicateAssemblyReferenceTests.cs

@ -173,6 +173,9 @@ namespace ICSharpCode.Decompiler.Tests.TypeSystem
/// </summary> /// </summary>
class VersionedResolver : IAssemblyResolver class VersionedResolver : IAssemblyResolver
{ {
/// <inheritdoc/>
public IDisposable BeginSnapshot() => null;
static readonly string runtimeDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location); static readonly string runtimeDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location);
readonly string directory; readonly string directory;

6
ICSharpCode.Decompiler/Metadata/AssemblyReferences.cs

@ -64,6 +64,12 @@ namespace ICSharpCode.Decompiler.Metadata
MetadataFile? ResolveModule(MetadataFile mainModule, string moduleName); MetadataFile? ResolveModule(MetadataFile mainModule, string moduleName);
Task<MetadataFile?> ResolveAsync(IAssemblyReference reference); Task<MetadataFile?> ResolveAsync(IAssemblyReference reference);
Task<MetadataFile?> ResolveModuleAsync(MetadataFile mainModule, string moduleName); Task<MetadataFile?> ResolveModuleAsync(MetadataFile mainModule, string moduleName);
/// <summary>
/// Lets the resolver hold what it reads from the file system until the scope is disposed,
/// so that a whole reference closure asks the same directories once. Null to hold nothing.
/// </summary>
IDisposable? BeginSnapshot();
#endif #endif
} }

40
ICSharpCode.Decompiler/Metadata/DotNetCorePathFinder.cs

@ -78,6 +78,12 @@ namespace ICSharpCode.Decompiler.Metadata
readonly string dotnetBasePath = FindDotNetExeDirectory(); readonly string dotnetBasePath = FindDotNetExeDirectory();
readonly string preferredRuntimePack; readonly string preferredRuntimePack;
/// <summary>
/// The resolver this path finder belongs to, which holds any open scope. Null when nobody
/// owns it, and then every scan below reads the file system.
/// </summary>
internal UniversalAssemblyResolver Owner { get; set; }
public DotNetCorePathFinder(TargetFrameworkIdentifier targetFramework, Version targetFrameworkVersion, public DotNetCorePathFinder(TargetFrameworkIdentifier targetFramework, Version targetFrameworkVersion,
string preferredRuntimePack) string preferredRuntimePack)
{ {
@ -108,6 +114,10 @@ namespace ICSharpCode.Decompiler.Metadata
{ {
packages = LoadPackageInfos(depsJsonFileName, targetFrameworkIdString).ToArray(); packages = LoadPackageInfos(depsJsonFileName, targetFrameworkIdString).ToArray();
// Every runtime component of a package resolves to the same folder, so without
// this the same directory is probed once per assembly the package contains, for
// every reference that is looked up.
var knownPackageBasePaths = new HashSet<string>();
foreach (var path in LookupPaths) foreach (var path in LookupPaths)
{ {
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
@ -120,7 +130,7 @@ namespace ICSharpCode.Decompiler.Metadata
{ {
var itemPath = Path.GetDirectoryName(item); var itemPath = Path.GetDirectoryName(item);
var fullPath = Path.Combine(path, p.Name, p.Version, itemPath).ToLowerInvariant(); var fullPath = Path.Combine(path, p.Name, p.Version, itemPath).ToLowerInvariant();
if (Directory.Exists(fullPath)) if (knownPackageBasePaths.Add(fullPath) && Directory.Exists(fullPath))
packageBasePaths.Add(fullPath); packageBasePaths.Add(fullPath);
} }
} }
@ -146,13 +156,15 @@ namespace ICSharpCode.Decompiler.Metadata
{ {
foreach (var basePath in searchPaths.Concat(packageBasePaths)) foreach (var basePath in searchPaths.Concat(packageBasePaths))
{ {
if (File.Exists(Path.Combine(basePath, name.Name + ".dll"))) var file = Path.Combine(basePath, name.Name + ".dll");
if (File.Exists(file))
{ {
return Path.Combine(basePath, name.Name + ".dll"); return file;
} }
else if (File.Exists(Path.Combine(basePath, name.Name + ".exe"))) file = Path.Combine(basePath, name.Name + ".exe");
if (File.Exists(file))
{ {
return Path.Combine(basePath, name.Name + ".exe"); return file;
} }
} }
@ -223,7 +235,9 @@ namespace ICSharpCode.Decompiler.Metadata
if (preferredRuntimePack != null) if (preferredRuntimePack != null)
{ {
runtimePacks = new[] { preferredRuntimePack }.Concat(runtimePacks); // The preferred pack is usually one of the defaults as well; listing it twice means
// scanning its directory twice for every reference that is not in it.
runtimePacks = new[] { preferredRuntimePack }.Concat(RuntimePacks.Where(p => p != preferredRuntimePack));
} }
foreach (string pack in runtimePacks) foreach (string pack in runtimePacks)
@ -232,14 +246,18 @@ namespace ICSharpCode.Decompiler.Metadata
string basePath = Path.Combine(dotnetBasePath, "shared", pack); string basePath = Path.Combine(dotnetBasePath, "shared", pack);
if (!Directory.Exists(basePath)) if (!Directory.Exists(basePath))
continue; continue;
var closestVersion = GetClosestVersionFolder(basePath, targetFrameworkVersion); var closestVersion = Owner != null
if (File.Exists(Path.Combine(basePath, closestVersion, name.Name + ".dll"))) ? Owner.GetOrAddVersionFolder(basePath, p => GetClosestVersionFolder(p, targetFrameworkVersion))
: GetClosestVersionFolder(basePath, targetFrameworkVersion);
var file = Path.Combine(basePath, closestVersion, name.Name + ".dll");
if (File.Exists(file))
{ {
return Path.Combine(basePath, closestVersion, name.Name + ".dll"); return file;
} }
else if (File.Exists(Path.Combine(basePath, closestVersion, name.Name + ".exe"))) file = Path.Combine(basePath, closestVersion, name.Name + ".exe");
if (File.Exists(file))
{ {
return Path.Combine(basePath, closestVersion, name.Name + ".exe"); return file;
} }
} }
runtimePack = null; runtimePack = null;

62
ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs

@ -19,6 +19,7 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
@ -76,6 +77,7 @@ namespace ICSharpCode.Decompiler.Metadata
} }
readonly Lazy<DotNetCorePathFinder> dotNetCorePathFinder; readonly Lazy<DotNetCorePathFinder> dotNetCorePathFinder;
ConcurrentDictionary<string, string>? versionFolders;
readonly bool throwOnError; readonly bool throwOnError;
readonly PEStreamOptions streamOptions; readonly PEStreamOptions streamOptions;
readonly MetadataReaderOptions metadataOptions; readonly MetadataReaderOptions metadataOptions;
@ -85,6 +87,39 @@ namespace ICSharpCode.Decompiler.Metadata
static readonly List<string> gac_paths = GetGacPaths(); static readonly List<string> gac_paths = GetGacPaths();
static readonly DecompilerRuntime decompilerRuntime; static readonly DecompilerRuntime decompilerRuntime;
/// <inheritdoc/>
public IDisposable? BeginSnapshot()
{
versionFolders = new ConcurrentDictionary<string, string>();
return new Snapshot(this);
}
/// <summary>
/// The version folder to use inside <paramref name="basePath"/>, determined by
/// <paramref name="valueFactory"/> once per open scope, and on every call outside one.
/// </summary>
internal string GetOrAddVersionFolder(string basePath, Func<string, string> valueFactory)
{
return versionFolders is { } cache ? cache.GetOrAdd(basePath, valueFactory) : valueFactory(basePath);
}
/// <summary>
/// Holds what the resolver read from the file system until it is disposed. Two of these on
/// one resolver do not stack: the first to end takes the cache with it, and the other reads
/// the file system again.
/// </summary>
sealed class Snapshot : IDisposable
{
readonly UniversalAssemblyResolver resolver;
public Snapshot(UniversalAssemblyResolver resolver)
{
this.resolver = resolver;
}
public void Dispose() => resolver.versionFolders = null;
}
public void AddSearchDirectory(string? directory) public void AddSearchDirectory(string? directory)
{ {
directories.Add(directory); directories.Add(directory);
@ -358,6 +393,7 @@ namespace ICSharpCode.Decompiler.Metadata
dotNetCorePathFinder = new DotNetCorePathFinder(targetFrameworkIdentifier, targetFrameworkVersion, runtimePack); dotNetCorePathFinder = new DotNetCorePathFinder(targetFrameworkIdentifier, targetFrameworkVersion, runtimePack);
else else
dotNetCorePathFinder = new DotNetCorePathFinder(mainAssemblyFileName, targetFramework, runtimePack, targetFrameworkIdentifier, targetFrameworkVersion); dotNetCorePathFinder = new DotNetCorePathFinder(mainAssemblyFileName, targetFramework, runtimePack, targetFrameworkIdentifier, targetFrameworkVersion);
dotNetCorePathFinder.Owner = this;
foreach (var directory in directories) foreach (var directory in directories)
{ {
dotNetCorePathFinder.AddSearchDirectory(directory); dotNetCorePathFinder.AddSearchDirectory(directory);
@ -555,9 +591,12 @@ namespace ICSharpCode.Decompiler.Metadata
return IsZeroOrAllOnes(reference.Version) || reference.IsRetargetable; return IsZeroOrAllOnes(reference.Version) || reference.IsRetargetable;
} }
static readonly string[] assemblyExtensions = { ".dll", ".exe" };
static readonly string[] windowsMetadataExtensions = { ".winmd", ".dll" };
string? SearchDirectory(IAssemblyReference name, string directory) string? SearchDirectory(IAssemblyReference name, string directory)
{ {
var extensions = name.IsWindowsRuntime ? new[] { ".winmd", ".dll" } : new[] { ".dll", ".exe" }; var extensions = name.IsWindowsRuntime ? windowsMetadataExtensions : assemblyExtensions;
foreach (var extension in extensions) foreach (var extension in extensions)
{ {
string file = Path.Combine(directory, name.Name + extension); string file = Path.Combine(directory, name.Name + extension);
@ -772,17 +811,17 @@ namespace ICSharpCode.Decompiler.Metadata
return null; return null;
} }
static readonly string[] gacFolders = { "GAC_MSIL", "GAC_32", "GAC_64", "GAC" };
static readonly string[] gacFolderPrefixes = { string.Empty, "v4.0_" };
static string? GetAssemblyInNetGac(IAssemblyReference reference) static string? GetAssemblyInNetGac(IAssemblyReference reference)
{ {
var gacs = new[] { "GAC_MSIL", "GAC_32", "GAC_64", "GAC" };
var prefixes = new[] { string.Empty, "v4.0_" };
for (int i = 0; i < gac_paths.Count; i++) for (int i = 0; i < gac_paths.Count; i++)
{ {
for (int j = 0; j < gacs.Length; j++) for (int j = 0; j < gacFolders.Length; j++)
{ {
var gac = Path.Combine(gac_paths[i], gacs[j]); var gac = Path.Combine(gac_paths[i], gacFolders[j]);
var file = GetAssemblyFile(reference, prefixes[i], gac); var file = GetAssemblyFile(reference, gacFolderPrefixes[i], gac);
if (File.Exists(file)) if (File.Exists(file))
return file; return file;
} }
@ -792,10 +831,10 @@ namespace ICSharpCode.Decompiler.Metadata
// the whole GAC rather than a fallback within one folder. // the whole GAC rather than a fallback within one folder.
for (int i = 0; i < gac_paths.Count; i++) for (int i = 0; i < gac_paths.Count; i++)
{ {
for (int j = 0; j < gacs.Length; j++) for (int j = 0; j < gacFolders.Length; j++)
{ {
var gac = Path.Combine(gac_paths[i], gacs[j]); var gac = Path.Combine(gac_paths[i], gacFolders[j]);
var file = FindUnifiedAssemblyInGacFolder(reference, prefixes[i], gac); var file = FindUnifiedAssemblyInGacFolder(reference, gacFolderPrefixes[i], gac);
if (file != null) if (file != null)
return file; return file;
} }
@ -865,10 +904,9 @@ namespace ICSharpCode.Decompiler.Metadata
/// </summary> /// </summary>
public static IEnumerable<AssemblyNameReference> EnumerateGac() public static IEnumerable<AssemblyNameReference> EnumerateGac()
{ {
var gacs = new[] { "GAC_MSIL", "GAC_32", "GAC_64", "GAC" };
foreach (var path in GetGacPaths()) foreach (var path in GetGacPaths())
{ {
foreach (var gac in gacs) foreach (var gac in gacFolders)
{ {
string rootPath = Path.Combine(path, gac); string rootPath = Path.Combine(path, gac);
if (!Directory.Exists(rootPath)) if (!Directory.Exists(rootPath))

8
ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs

@ -287,7 +287,13 @@ namespace ICSharpCode.Decompiler.TypeSystem
int referencedAssembliesResolved = 0; int referencedAssembliesResolved = 0;
try try
{ {
referencedAssembliesResolved = await InitializeCoreAsync(mainModule, assemblyResolver).ConfigureAwait(false); // The whole reference closure is resolved here, and every reference in it asks the
// same framework directories the same questions. A resolver that can hold what it
// read answers them once for this build and forgets it again afterwards.
using (assemblyResolver.BeginSnapshot())
{
referencedAssembliesResolved = await InitializeCoreAsync(mainModule, assemblyResolver).ConfigureAwait(false);
}
} }
finally finally
{ {

6
ICSharpCode.ILSpyX/LoadedAssembly.cs

@ -615,6 +615,12 @@ namespace ICSharpCode.ILSpyX
/// </summary> /// </summary>
public ReferenceLoadInfo LoadInfo => referenceLoadInfo; public ReferenceLoadInfo LoadInfo => referenceLoadInfo;
/// <inheritdoc/>
public IDisposable? BeginSnapshot()
{
return parent.GetUniversalResolver(applyWinRTProjections).BeginSnapshot();
}
public MetadataFile? Resolve(IAssemblyReference reference) public MetadataFile? Resolve(IAssemblyReference reference)
{ {
return ResolveAsync(reference).GetAwaiter().GetResult(); return ResolveAsync(reference).GetAwaiter().GetResult();

3
ICSharpCode.ILSpyX/LoadedPackage.cs

@ -299,6 +299,9 @@ namespace ICSharpCode.ILSpyX
public sealed class PackageFolder : IAssemblyResolver public sealed class PackageFolder : IAssemblyResolver
{ {
/// <inheritdoc/>
public IDisposable? BeginSnapshot() => null;
/// <summary> /// <summary>
/// Gets the short name of the folder. /// Gets the short name of the folder.
/// </summary> /// </summary>

3
ILSpy.BamlDecompiler.Tests/MissingReferencesTests.cs

@ -50,6 +50,9 @@ namespace ILSpy.BamlDecompiler.Tests
/// </summary> /// </summary>
sealed class WpfHidingResolver : IAssemblyResolver sealed class WpfHidingResolver : IAssemblyResolver
{ {
/// <inheritdoc/>
public IDisposable BeginSnapshot() => null;
static readonly HashSet<string> hidden = new(StringComparer.OrdinalIgnoreCase) { static readonly HashSet<string> hidden = new(StringComparer.OrdinalIgnoreCase) {
"WindowsBase", "PresentationCore", "PresentationFramework", "PresentationUI", "System.Xaml" "WindowsBase", "PresentationCore", "PresentationFramework", "PresentationUI", "System.Xaml"
}; };

2
TestTools/nugetfuzz.cs

@ -855,6 +855,8 @@ class LoggingResolver(IAssemblyResolver inner, List<string> dirs, Func<IAssembly
public readonly Dictionary<string, string?> Resolutions = new(); public readonly Dictionary<string, string?> Resolutions = new();
readonly Dictionary<string, MetadataFile?> loaded = new(); readonly Dictionary<string, MetadataFile?> loaded = new();
public IDisposable? BeginSnapshot() => inner.BeginSnapshot();
public MetadataFile? Resolve(IAssemblyReference reference) public MetadataFile? Resolve(IAssemblyReference reference)
{ {
var file = ResolveFromDirs(reference) ?? inner.Resolve(reference); var file = ResolveFromDirs(reference) ?? inner.Resolve(reference);

Loading…
Cancel
Save