Ampr: cooked-id index preload and host FD LRU cache (#725)

Preload the app0 APR index during bind and bound the open host-file cache so streaming reads stay responsive under large title archives.
This commit is contained in:
kuba
2026-07-31 11:13:33 +02:00
committed by GitHub
parent 532251c0c3
commit 93c9f14081
6 changed files with 841 additions and 23 deletions
+142 -14
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
@@ -25,7 +26,6 @@ public static class AmprExports
private const uint KernelEventQueueRecordType = 2;
private const uint WriteAddressRecordType = 3;
private static readonly ConcurrentDictionary<ulong, CommandBufferState> _commandBuffers = new();
private static readonly ConcurrentDictionary<string, Lazy<CachedHostFile>> _hostFileCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly bool _traceAmpr =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR"), "1", StringComparison.Ordinal);
private static readonly bool _traceAmprReads =
@@ -40,7 +40,7 @@ public static class AmprExports
public ulong CommandCount;
}
private sealed class CachedHostFile
private sealed class CachedHostFile : IDisposable
{
public CachedHostFile(string path)
{
@@ -55,8 +55,26 @@ public static class AmprExports
public SafeFileHandle Handle { get; }
public long Length { get; }
public void Dispose() => Handle.Dispose();
}
private sealed class CachedHostFileEntry
{
public required string Path { get; init; }
public required CachedHostFile File { get; init; }
}
// Keep a bounded LRU of open host files. An unbounded cache exhausts the
// process FD limit (~10k on macOS) during large asset storms, after
// which every new open throws IOException and surfaces as NOT_FOUND — the
// guest then reports InvalidFileFourCC on empty buffers.
private const int MaxCachedHostFiles = 1536;
private static readonly object _hostFileCacheGate = new();
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
new(StringComparer.OrdinalIgnoreCase);
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
[SysAbiExport(
Nid = "8aI7R7WaOlc",
ExportName = "sceAmprCommandBufferConstructor",
@@ -80,6 +98,7 @@ public static class AmprExports
}
TraceAmpr(ctx, "ctor", commandBuffer, buffer, size);
TryPreindexApp0();
ctx[CpuRegister.Rax] = commandBuffer;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -107,6 +126,7 @@ public static class AmprExports
}
TraceAmpr(ctx, "apr_ctor", commandBuffer, aux0, aux1);
TryPreindexApp0();
ctx[CpuRegister.Rax] = commandBuffer;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -271,8 +291,19 @@ public static class AmprExports
if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath))
{
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
// Cooked Insomniac content ids are FNV("/app0/...") and may never
// pass through APR resolve. Index app0 once, then retry the lookup.
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
if (!string.IsNullOrEmpty(app0Root))
{
AmprFileRegistry.EnsureApp0Indexed(app0Root);
}
if (!AmprFileRegistry.TryGetHostPath(fileId, out hostPath))
{
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
}
// Offset -1 means "continue after the previous read of this file id".
@@ -779,7 +810,9 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
const int ChunkSize = 1024 * 1024;
// 4 MiB chunks cut syscall/Rosetta round-trips on DeS' large sequential
// APR reads without blowing the ArrayPool for small probes.
const int ChunkSize = 4 * 1024 * 1024;
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min((ulong)ChunkSize, size));
try
@@ -857,27 +890,100 @@ public static class AmprExports
cachePath = hostPath;
}
var lazy = _hostFileCache.GetOrAdd(
cachePath,
static path => new Lazy<CachedHostFile>(() => new CachedHostFile(path), isThreadSafe: true));
lock (_hostFileCacheGate)
{
if (_hostFileByPath.TryGetValue(cachePath, out var existing))
{
_hostFileLru.Remove(existing);
_hostFileLru.AddFirst(existing);
file = existing.Value.File;
return true;
}
}
CachedHostFile opened;
try
{
file = lazy.Value;
return true;
opened = new CachedHostFile(cachePath);
}
catch (UnauthorizedAccessException)
{
_hostFileCache.TryRemove(cachePath, out _);
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
return false;
}
catch (IOException)
{
_hostFileCache.TryRemove(cachePath, out _);
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
return false;
// Likely EMFILE from a prior unbounded cache, or a transient miss.
// Evict everything we hold and retry once so a full FD table can
// recover without restarting the process.
EvictAllCachedHostFiles();
try
{
opened = new CachedHostFile(cachePath);
}
catch (UnauthorizedAccessException)
{
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
return false;
}
catch (IOException)
{
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
return false;
}
}
lock (_hostFileCacheGate)
{
if (_hostFileByPath.TryGetValue(cachePath, out var raced))
{
opened.Dispose();
_hostFileLru.Remove(raced);
_hostFileLru.AddFirst(raced);
file = raced.Value.File;
return true;
}
while (_hostFileByPath.Count >= MaxCachedHostFiles)
{
EvictLeastRecentlyUsedHostFileLocked();
}
var entry = new CachedHostFileEntry { Path = cachePath, File = opened };
var node = _hostFileLru.AddFirst(entry);
_hostFileByPath[cachePath] = node;
file = opened;
return true;
}
}
private static void EvictAllCachedHostFiles()
{
List<CachedHostFile> doomed;
lock (_hostFileCacheGate)
{
doomed = _hostFileLru.Select(entry => entry.File).ToList();
_hostFileLru.Clear();
_hostFileByPath.Clear();
}
foreach (var cached in doomed)
{
cached.Dispose();
}
}
private static void EvictLeastRecentlyUsedHostFileLocked()
{
var last = _hostFileLru.Last;
if (last is null)
{
return;
}
_hostFileLru.RemoveLast();
_hostFileByPath.Remove(last.Value.Path);
last.Value.File.Dispose();
}
private static bool AppendReadFileRecord(
@@ -1004,6 +1110,19 @@ public static class AmprExports
return false;
}
// GPU WAIT_REG_MEM often watches these APR completion labels
// (e.g. 0x20505xxx DEADBEEF/counter fences). Without
// RecordProduced the wait sits producerless after the guest recycles
// the dword, and CollectDeadlockBroken cannot replay the wake.
_ = GpuWaitRegistry.RecordProduced(ctx.Memory, address, value);
if ((address & 4ul) == 0)
{
// 32-bit GPU waits use the low dword; also latch that view when the
// address is dword-aligned so a u32 compare against ref sees it.
_ = GpuWaitRegistry.RecordProduced(
ctx.Memory, address, unchecked((uint)value));
}
TraceAmpr(ctx, "complete_write_address", address, value, 0);
return true;
}
@@ -1021,6 +1140,15 @@ public static class AmprExports
return true;
}
private static void TryPreindexApp0()
{
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
if (!string.IsNullOrEmpty(app0Root))
{
AmprFileRegistry.EnsureApp0Indexed(app0Root);
}
}
private static void TraceAmpr(CpuContext ctx, string operation, ulong commandBuffer, ulong arg0, ulong arg1)
{
if (!_traceAmpr)