From 7a42dbbf14494e18c6c67bf4b20b8eb69bfcecfc Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 29 Sep 2026 23:58:13 +0200 Subject: [PATCH 1/3] [Kernel] Memoize guest path resolution against the mount configuration Resolving a guest path to a host path is pure text work plus a reparse-point check per path component, but a resource streamer calls sceKernelAprResolveFilepathsToIdsAndFileSizes with the same paths hundreds of thousands of times over a load, so the work dominates. Successful resolutions are now memoized. The only inputs that can change an already-computed answer are the mount table and the host mount roots, so each entry records the root configuration it was computed under and is ignored if that has changed, and registering or removing a guest mount clears the cache outright. A denial is not memoized: it is a containment decision about the host filesystem's current shape, so it stays live. The cache is bounded, and its size is tracked in a plain counter because ConcurrentDictionary.Count takes every bucket lock, which on a path a dozen guest threads resolve at once costs more than the resolution it guards. --- .../Kernel/KernelMemoryCompatExports.cs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index fd59b42..817621d 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -235,6 +235,8 @@ public static partial class KernelMemoryCompatExports _guestMounts[normalizedMountPoint] = normalizedHostRoot; } + InvalidateResolvedGuestPaths(); + lock (_statCacheGate) { _negativeStatCache.RemoveWhere(path => @@ -252,10 +254,18 @@ public static partial class KernelMemoryCompatExports return false; } + bool removed; lock (_guestMountGate) { - return _guestMounts.Remove(normalizedMountPoint); + removed = _guestMounts.Remove(normalizedMountPoint); } + + if (removed) + { + InvalidateResolvedGuestPaths(); + } + + return removed; } internal static bool TryAllocateHleData( @@ -4725,6 +4735,38 @@ public static partial class KernelMemoryCompatExports return FileMode.Open; } + // Resolution is pure text work plus a per-component reparse-point check, but a + // resource streamer resolves the same paths hundreds of thousands of times through + // sceKernelAprResolveFilepathsToIdsAndFileSizes. Memoize the mapping and drop it + // whenever the mount table changes, which is the only input that can change an + // already-computed answer. + // Each entry records the mount-root configuration it was computed under, so a + // host configuration change cannot serve a stale answer. + private static readonly ConcurrentDictionary _resolvedGuestPaths = + new(StringComparer.Ordinal); + private const int ResolvedGuestPathCacheLimit = 1 << 20; + // ConcurrentDictionary.Count takes every bucket lock, which on a path resolved by + // a dozen guest threads at once costs far more than the resolution it guards. + private static int _resolvedGuestPathCount; + + internal static void InvalidateResolvedGuestPaths() + { + _resolvedGuestPaths.Clear(); + Interlocked.Exchange(ref _resolvedGuestPathCount, 0); + } + + // Every mount root a built-in branch can use. Resolution depends on nothing else + // that changes at runtime (the mount table clears the cache itself), so a memoized + // answer stays valid exactly as long as this token does. + private static string RootConfigurationToken() => + string.Concat( + Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_TEMP0_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_DOWNLOAD0_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR")); + public static string ResolveGuestPath(string guestPath) { if (string.IsNullOrWhiteSpace(guestPath)) @@ -4732,6 +4774,28 @@ public static partial class KernelMemoryCompatExports return guestPath; } + var roots = RootConfigurationToken(); + if (_resolvedGuestPaths.TryGetValue(guestPath, out var memoized) && + string.Equals(memoized.Root, roots, StringComparison.Ordinal)) + { + return memoized.Path; + } + + var resolved = ResolveGuestPathUncached(guestPath); + // Only a successful resolution is memoized: a denial is a containment + // decision about the host filesystem's current shape, so it stays live. + if (!string.IsNullOrEmpty(resolved) && + Volatile.Read(ref _resolvedGuestPathCount) < ResolvedGuestPathCacheLimit && + _resolvedGuestPaths.TryAdd(guestPath, (roots, resolved))) + { + Interlocked.Increment(ref _resolvedGuestPathCount); + } + + return resolved; + } + + private static string ResolveGuestPathUncached(string guestPath) + { if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath, out var mountPrefixMatched)) { return mountedPath; From 66304399aeaedb5821c19fad86a0e640aefa9b9a Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 29 Sep 2026 23:58:28 +0200 Subject: [PATCH 2/3] [Kernel] Remember APR paths absent from the read-only game image Looking up a resolved path's size stats the host filesystem, and a path that is not there costs two stats (a file probe and a directory probe) every single time it is asked for. A resource streamer asks for far more paths than the image contains, so misses are the common case and they are repeated. /app0 is the read-only game image: a path that is not in it now will not appear later. Absent paths under that root are therefore remembered, which turns the repeated pair of host stats on what is often a slow external volume into a dictionary probe. Paths outside /app0 can be created at run time and are not remembered. --- .../Kernel/KernelMemoryCompatExports.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 817621d..c68590e 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -6883,6 +6883,19 @@ public static partial class KernelMemoryCompatExports return TryWriteHostPathStat(ctx, statAddress, hostPath, isDirectory); } + // /app0 is the read-only game image, so a path that is not in it now will not + // appear later. Remembering the absent ones turns a repeated pair of host stats + // on the (slow, external) game volume into a dictionary probe; Demon's Souls + // resolves tens of thousands of paths per boot and misses are the common case. + private static readonly ConcurrentDictionary _aprMissingImagePaths = new(HostFsPath.Comparer); + + private static bool IsUnderApp0(string cachePath) + { + var app0Root = ResolveApp0Root(); + return !string.IsNullOrWhiteSpace(app0Root) && + cachePath.StartsWith(Path.TrimEndingDirectorySeparator(app0Root) + Path.DirectorySeparatorChar, HostFsPath.Comparison); + } + private static bool TryGetAprFileSize(string hostPath, out ulong size) { size = 0; @@ -6902,6 +6915,11 @@ public static partial class KernelMemoryCompatExports return true; } + if (_aprMissingImagePaths.ContainsKey(cachePath)) + { + return false; + } + // One directory listing returns every file's size; titles resolve whole asset // directories at startup, so this replaces one host query per file. if (Path.GetDirectoryName(cachePath) is { } directory && _aprScannedDirectories.TryAdd(directory, 0)) @@ -6936,6 +6954,11 @@ public static partial class KernelMemoryCompatExports if (!new DirectoryInfo(cachePath).Exists) { + if (IsUnderApp0(cachePath)) + { + _aprMissingImagePaths.TryAdd(cachePath, 0); + } + return false; } From 8698ba1464f9e112154a226e16587f273e7b5b32 Mon Sep 17 00:00:00 2001 From: kuba Date: Tue, 29 Sep 2026 23:58:37 +0200 Subject: [PATCH 3/3] [Kernel] Verify mount containment per component, not by listing directories Containment verification stats each component of a resolved path once to reject reparse points. It was changed to verify a whole parent directory in one listing instead, on the theory that one listing is cheaper than N stats. It is not, for a game image: .NET fills the attributes of every entry it enumerates, so listing a directory stats all of it. A title whose asset directories hold thousands of entries paid for every file it never opens -- directories of up to 7,390 entries, about 9 s of a load spent in those stats. Each component is verified with its own stat again, and the per-component memo that made the listing look attractive stays, so a component is still verified only once. --- .../Kernel/KernelMemoryCompatExports.cs | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index c68590e..a5317ce 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -125,7 +125,6 @@ public static partial class KernelMemoryCompatExports // Mount components already found to exist without being reparse points. Titles resolve // every asset path at startup; re-reading each directory's attributes costs seconds. private static readonly ConcurrentDictionary _verifiedMountComponents = new(HostFsPath.Comparer); - private static readonly ConcurrentDictionary _verifiedMountDirectories = new(HostFsPath.Comparer); private static readonly ConcurrentDictionary _fullMountRoots = new(StringComparer.Ordinal); private static readonly ConcurrentDictionary _aprScannedDirectories = new(HostFsPath.Comparer); private static long _nextFileDescriptor = 2; @@ -5162,38 +5161,16 @@ public static partial class KernelMemoryCompatExports Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { - var parent = current; current = Path.Combine(current, segment); + // Each component is checked once with its own lstat. Listing the parent instead + // stats every entry of that directory (.NET fills attributes per entry), so a game + // image with large directories paid for files it never opens: Demon's Souls spent + // ~9 s of its load in those stats. if (_verifiedMountComponents.ContainsKey(current)) { continue; } - // One listing returns every entry's attributes: verify the whole parent directory - // at once. Reparse points stay unverified and are rejected below. - if (_verifiedMountDirectories.TryAdd(parent, 0)) - { - try - { - foreach (var entry in new DirectoryInfo(parent).EnumerateFileSystemInfos()) - { - if ((entry.Attributes & FileAttributes.ReparsePoint) == 0) - { - _verifiedMountComponents.TryAdd(entry.FullName, 0); - } - } - } - catch (Exception ex) when ( - ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) - { - } - - if (_verifiedMountComponents.ContainsKey(current)) - { - continue; - } - } - try { if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)