mirror of
https://github.com/icsharpcode/ILSpy.git
synced 2026-10-12 02:15:58 +00:00
Merge pull request #4106 from icsharpcode/perf/resolver-file-system-access
Read the file system once per reference closure, not per reference
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,9 @@ namespace ICSharpCode.Decompiler.Tests.TypeSystem
|
||||
/// </summary>
|
||||
class VersionedResolver : IAssemblyResolver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IDisposable BeginSnapshot() => null;
|
||||
|
||||
static readonly string runtimeDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location);
|
||||
|
||||
readonly string directory;
|
||||
|
||||
@@ -64,6 +64,12 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
MetadataFile? ResolveModule(MetadataFile mainModule, string moduleName);
|
||||
Task<MetadataFile?> ResolveAsync(IAssemblyReference reference);
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
readonly string dotnetBasePath = FindDotNetExeDirectory();
|
||||
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,
|
||||
string preferredRuntimePack)
|
||||
{
|
||||
@@ -108,6 +114,10 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
@@ -120,7 +130,7 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
{
|
||||
var itemPath = Path.GetDirectoryName(item);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -146,13 +156,15 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
@@ -232,14 +246,18 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
string basePath = Path.Combine(dotnetBasePath, "shared", pack);
|
||||
if (!Directory.Exists(basePath))
|
||||
continue;
|
||||
var closestVersion = GetClosestVersionFolder(basePath, targetFrameworkVersion);
|
||||
if (File.Exists(Path.Combine(basePath, closestVersion, name.Name + ".dll")))
|
||||
var closestVersion = Owner != null
|
||||
? 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;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
@@ -76,6 +77,7 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
}
|
||||
|
||||
readonly Lazy<DotNetCorePathFinder> dotNetCorePathFinder;
|
||||
ConcurrentDictionary<string, string>? versionFolders;
|
||||
readonly bool throwOnError;
|
||||
readonly PEStreamOptions streamOptions;
|
||||
readonly MetadataReaderOptions metadataOptions;
|
||||
@@ -85,6 +87,39 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
static readonly List<string> gac_paths = GetGacPaths();
|
||||
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)
|
||||
{
|
||||
directories.Add(directory);
|
||||
@@ -358,6 +393,7 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
dotNetCorePathFinder = new DotNetCorePathFinder(targetFrameworkIdentifier, targetFrameworkVersion, runtimePack);
|
||||
else
|
||||
dotNetCorePathFinder = new DotNetCorePathFinder(mainAssemblyFileName, targetFramework, runtimePack, targetFrameworkIdentifier, targetFrameworkVersion);
|
||||
dotNetCorePathFinder.Owner = this;
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
dotNetCorePathFinder.AddSearchDirectory(directory);
|
||||
@@ -555,9 +591,12 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
return IsZeroOrAllOnes(reference.Version) || reference.IsRetargetable;
|
||||
}
|
||||
|
||||
static readonly string[] assemblyExtensions = { ".dll", ".exe" };
|
||||
static readonly string[] windowsMetadataExtensions = { ".winmd", ".dll" };
|
||||
|
||||
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)
|
||||
{
|
||||
string file = Path.Combine(directory, name.Name + extension);
|
||||
@@ -772,17 +811,17 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
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)
|
||||
{
|
||||
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 j = 0; j < gacs.Length; j++)
|
||||
for (int j = 0; j < gacFolders.Length; j++)
|
||||
{
|
||||
var gac = Path.Combine(gac_paths[i], gacs[j]);
|
||||
var file = GetAssemblyFile(reference, prefixes[i], gac);
|
||||
var gac = Path.Combine(gac_paths[i], gacFolders[j]);
|
||||
var file = GetAssemblyFile(reference, gacFolderPrefixes[i], gac);
|
||||
if (File.Exists(file))
|
||||
return file;
|
||||
}
|
||||
@@ -792,10 +831,10 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
// the whole GAC rather than a fallback within one folder.
|
||||
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 file = FindUnifiedAssemblyInGacFolder(reference, prefixes[i], gac);
|
||||
var gac = Path.Combine(gac_paths[i], gacFolders[j]);
|
||||
var file = FindUnifiedAssemblyInGacFolder(reference, gacFolderPrefixes[i], gac);
|
||||
if (file != null)
|
||||
return file;
|
||||
}
|
||||
@@ -865,10 +904,9 @@ namespace ICSharpCode.Decompiler.Metadata
|
||||
/// </summary>
|
||||
public static IEnumerable<AssemblyNameReference> EnumerateGac()
|
||||
{
|
||||
var gacs = new[] { "GAC_MSIL", "GAC_32", "GAC_64", "GAC" };
|
||||
foreach (var path in GetGacPaths())
|
||||
{
|
||||
foreach (var gac in gacs)
|
||||
foreach (var gac in gacFolders)
|
||||
{
|
||||
string rootPath = Path.Combine(path, gac);
|
||||
if (!Directory.Exists(rootPath))
|
||||
|
||||
@@ -287,7 +287,13 @@ namespace ICSharpCode.Decompiler.TypeSystem
|
||||
int referencedAssembliesResolved = 0;
|
||||
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
|
||||
{
|
||||
|
||||
@@ -615,6 +615,12 @@ namespace ICSharpCode.ILSpyX
|
||||
/// </summary>
|
||||
public ReferenceLoadInfo LoadInfo => referenceLoadInfo;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginSnapshot()
|
||||
{
|
||||
return parent.GetUniversalResolver(applyWinRTProjections).BeginSnapshot();
|
||||
}
|
||||
|
||||
public MetadataFile? Resolve(IAssemblyReference reference)
|
||||
{
|
||||
return ResolveAsync(reference).GetAwaiter().GetResult();
|
||||
|
||||
@@ -299,6 +299,9 @@ namespace ICSharpCode.ILSpyX
|
||||
|
||||
public sealed class PackageFolder : IAssemblyResolver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginSnapshot() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the short name of the folder.
|
||||
/// </summary>
|
||||
|
||||
@@ -50,6 +50,9 @@ namespace ILSpy.BamlDecompiler.Tests
|
||||
/// </summary>
|
||||
sealed class WpfHidingResolver : IAssemblyResolver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IDisposable BeginSnapshot() => null;
|
||||
|
||||
static readonly HashSet<string> hidden = new(StringComparer.OrdinalIgnoreCase) {
|
||||
"WindowsBase", "PresentationCore", "PresentationFramework", "PresentationUI", "System.Xaml"
|
||||
};
|
||||
|
||||
@@ -855,6 +855,8 @@ class LoggingResolver(IAssemblyResolver inner, List<string> dirs, Func<IAssembly
|
||||
public readonly Dictionary<string, string?> Resolutions = new();
|
||||
readonly Dictionary<string, MetadataFile?> loaded = new();
|
||||
|
||||
public IDisposable? BeginSnapshot() => inner.BeginSnapshot();
|
||||
|
||||
public MetadataFile? Resolve(IAssemblyReference reference)
|
||||
{
|
||||
var file = ResolveFromDirs(reference) ?? inner.Resolve(reference);
|
||||
|
||||
Reference in New Issue
Block a user