Compare commits

...

3 Commits

Author SHA1 Message Date
kuba 8698ba1464 [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.
2026-09-29 23:58:37 +02:00
kuba 66304399ae [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.
2026-09-29 23:58:28 +02:00
kuba 7a42dbbf14 [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.
2026-09-29 23:58:13 +02:00
1 changed files with 92 additions and 28 deletions

View File

@ -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<string, byte> _verifiedMountComponents = new(HostFsPath.Comparer);
private static readonly ConcurrentDictionary<string, byte> _verifiedMountDirectories = new(HostFsPath.Comparer);
private static readonly ConcurrentDictionary<string, string> _fullMountRoots = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<string, byte> _aprScannedDirectories = new(HostFsPath.Comparer);
private static long _nextFileDescriptor = 2;
@ -235,6 +234,8 @@ public static partial class KernelMemoryCompatExports
_guestMounts[normalizedMountPoint] = normalizedHostRoot;
}
InvalidateResolvedGuestPaths();
lock (_statCacheGate)
{
_negativeStatCache.RemoveWhere(path =>
@ -252,10 +253,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 +4734,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<string, (string Root, string Path)> _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 +4773,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;
@ -5098,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)
@ -6819,6 +6860,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<string, byte> _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;
@ -6838,6 +6892,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))
@ -6872,6 +6931,11 @@ public static partial class KernelMemoryCompatExports
if (!new DirectoryInfo(cachePath).Exists)
{
if (IsUnderApp0(cachePath))
{
_aprMissingImagePaths.TryAdd(cachePath, 0);
}
return false;
}