diff --git a/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs b/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs index 137c91a..4e948e1 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs @@ -7,6 +7,12 @@ internal static class VulkanPipelineCacheStorage { private const string CacheFileName = "vulkan-pipeline-cache.bin"; + // A guest shader can produce many different native modules as translation + // and specialization change. Importing their shared driver blob recompiles + // obsolete MSL on MoltenVK, so native cache buckets identify emitted code. + internal static string CompiledShaderIdentity(ReadOnlySpan spirv) => + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(spirv)); + internal static string ResolvePath(string? titleId, string? configuredPath) { if (!string.IsNullOrWhiteSpace(configuredPath)) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs index 282f8b6..1f92d46 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs @@ -1200,6 +1200,17 @@ internal static unsafe partial class VulkanVideoPresenter var persistentCacheEnabled = !string.Equals(cacheMode, "0", StringComparison.Ordinal); _pipelineCachePath = persistentCacheEnabled ? GetPipelineCachePath() : null; + // MoltenVK imports compile every cached MSL library. Load shader + // groups on demand on macOS so a mature cache cannot hold startup + // behind unrelated scenes. Keep explicit overrides for comparison. + var shardMode = Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_SHARDS"); + var useShards = shardMode == "1" || (shardMode is null && OperatingSystem.IsMacOS()); + if (_pipelineCachePath is not null && + useShards) + { + _pipelineCacheShardDirectory = _pipelineCachePath + ".shards"; + _pipelineCachePath = Path.Combine(_pipelineCacheShardDirectory, "present.bin"); + } byte[] initialData = []; try { @@ -1335,6 +1346,9 @@ internal static unsafe partial class VulkanVideoPresenter private void SavePipelineCache(bool force) { + // Cache export must not race asynchronous pipeline creation. + if (_pendingComputePipelines.Values.Any(pending => !pending.Compile.IsCompleted)) return; + SaveGuestPipelineCaches(); if (_pipelineCache.Handle == 0 || string.IsNullOrWhiteSpace(_pipelineCachePath)) { return; @@ -1345,19 +1359,28 @@ internal static unsafe partial class VulkanVideoPresenter return; } + if (SaveDriverPipelineCache(_pipelineCache, _pipelineCachePath)) + { + _pipelineCacheDirty = false; + _lastPipelineCacheSaveTick = Environment.TickCount64; + } + } + + private bool SaveDriverPipelineCache(PipelineCache cache, string path) + { try { nuint size = 0; var result = _vk.GetPipelineCacheData( _device, - _pipelineCache, + cache, &size, null); if (result != Result.Success || size == 0 || size > 256u * 1024u * 1024u) { Console.Error.WriteLine( $"[LOADER][WARN] Vulkan pipeline cache query failed: result={result} size={size}"); - return; + return false; } var data = new byte[checked((int)size)]; @@ -1365,7 +1388,7 @@ internal static unsafe partial class VulkanVideoPresenter { result = _vk.GetPipelineCacheData( _device, - _pipelineCache, + cache, &size, dataPointer); } @@ -1374,7 +1397,7 @@ internal static unsafe partial class VulkanVideoPresenter { Console.Error.WriteLine( $"[LOADER][WARN] Vulkan pipeline cache export failed: {result}"); - return; + return false; } if (size != (nuint)data.Length) @@ -1382,24 +1405,24 @@ internal static unsafe partial class VulkanVideoPresenter Array.Resize(ref data, checked((int)size)); } - var directory = Path.GetDirectoryName(_pipelineCachePath); + var directory = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } - var temporaryPath = _pipelineCachePath + $".{Environment.ProcessId}.tmp"; + var temporaryPath = path + $".{Environment.ProcessId}.tmp"; File.WriteAllBytes(temporaryPath, PipelineCacheSignature.Wrap(DriverCacheSignature(), data)); - File.Move(temporaryPath, _pipelineCachePath, overwrite: true); - _pipelineCacheDirty = false; - _lastPipelineCacheSaveTick = Environment.TickCount64; + File.Move(temporaryPath, path, overwrite: true); Console.Error.WriteLine( - $"[LOADER][INFO] Vulkan pipeline cache saved: path={_pipelineCachePath} bytes={data.Length}"); + $"[LOADER][INFO] Vulkan pipeline cache saved: path={path} bytes={data.Length}"); + return true; } catch (Exception exception) { Console.Error.WriteLine( $"[LOADER][WARN] Vulkan pipeline cache save failed: {exception.Message}"); + return false; } } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs index 313f37d..2b34176 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Teardown.cs @@ -26,6 +26,7 @@ internal static unsafe partial class VulkanVideoPresenter _vk.DeviceWaitIdle(_device); } + DrainPendingComputePipelines(); SavePipelineCache(force: true); DrainFrameSlots(); CollectCompletedGuestSubmissions(waitForOldest: false); @@ -58,6 +59,7 @@ internal static unsafe partial class VulkanVideoPresenter { _scheduler.Dispose(); _deviceInfo.Slabs.Destroy(); + DestroyGuestPipelineCaches(); if (_pipelineCache.Handle != 0) { _vk.DestroyPipelineCache(_device, _pipelineCache, null); diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.PipelineCacheShards.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.PipelineCacheShards.cs new file mode 100644 index 0000000..c47698f --- /dev/null +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.PipelineCacheShards.cs @@ -0,0 +1,118 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Libs.VideoOut; + +using System.Diagnostics; +using SharpEmu.Libs.Gpu.Pipelines; +using Silk.NET.Vulkan; + +internal static unsafe partial class VulkanVideoPresenter +{ + private sealed partial class Presenter + { + private sealed class DriverCacheShard + { + public required Lazy Cache; + public required string Path; + public bool Dirty; + } + + private string? _pipelineCacheShardDirectory; + private readonly Dictionary _pipelineCacheShards = new(); + private static readonly bool LegacyShaderGroups = + Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_LEGACY_SHARDS") == "1"; + + private string ComputeCacheKey(ulong guestHash, ulong module) => + !LegacyShaderGroups && _shaderModuleCacheIdentities.TryGetValue(module, out var identity) + ? $"c2-{identity}" : $"c-{guestHash:X16}"; + + private string GraphicsCacheKey(ulong vertexHash, ulong pixelHash, ulong vertexModule, ulong pixelModule) + { + if (!LegacyShaderGroups && _shaderModuleCacheIdentities.TryGetValue(vertexModule, out var vertex) && + (pixelModule == 0 || _shaderModuleCacheIdentities.ContainsKey(pixelModule))) + return $"g2-{vertex}-{(pixelModule == 0 ? "none" : _shaderModuleCacheIdentities[pixelModule])}"; + return $"g-{vertexHash:X16}-{pixelHash:X16}"; + } + + // MoltenVK recompiles cached MSL libraries during vkCreatePipelineCache. + // Loading only requested translated modules prevents a mature cache from + // compiling every previously visited scene before the first frame. + // Vulkan still validates the complete shader/layout key inside each blob; + // content hashes only select a storage bucket, never a pipeline to reuse. + private PipelineCache GetGuestPipelineCache(string key) + => ResolveGuestPipelineCache(GetGuestPipelineCacheSource(key)); + + // The dictionary belongs to the render thread. Only the lazy native + // creation runs on compiler workers; variants share one initialization. + private Lazy? GetGuestPipelineCacheSource(string key) + { + if (_pipelineCacheShardDirectory is null) return null; + if (_pipelineCacheShards.TryGetValue(key, out var existing)) + { + existing.Dirty = true; + return existing.Cache; + } + + var path = Path.Combine(_pipelineCacheShardDirectory, key + ".bin"); + var source = new Lazy(() => LoadGuestPipelineCache(key, path)); + _pipelineCacheShards.Add(key, new DriverCacheShard { Cache = source, Path = path, Dirty = true }); + return source; + } + + private PipelineCache ResolveGuestPipelineCache(Lazy? source) + { + var cache = source?.Value ?? default; + return cache.Handle != 0 ? cache : _pipelineCache; + } + + private PipelineCache LoadGuestPipelineCache(string key, string path) + { + byte[] data = []; + try + { + if (File.Exists(path) && + !PipelineCacheSignature.TryUnwrap(DriverCacheSignature(), File.ReadAllBytes(path), out data)) + Console.Error.WriteLine($"[LOADER][INFO] Vulkan cache shard invalidated: path={path}"); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[LOADER][WARN] Vulkan cache shard read failed: {exception.Message}"); + } + + var started = Stopwatch.GetTimestamp(); + var result = TryCreatePipelineCache(data, out var cache); + if (result != Result.Success && data.Length != 0) + result = TryCreatePipelineCache([], out cache); + if (result != Result.Success) + { + Console.Error.WriteLine($"[LOADER][WARN] Vulkan cache shard unavailable: key={key} result={result}"); + return default; + } + + if (data.Length != 0) + Console.Error.WriteLine($"[LOADER][INFO] Vulkan cache shard loaded: key={key} bytes={data.Length} ms={Stopwatch.GetElapsedTime(started).TotalMilliseconds:F1}"); + return cache; + } + + private void SaveGuestPipelineCaches() + { + foreach (var shard in _pipelineCacheShards.Values) + { + if (shard.Dirty && shard.Cache.IsValueCreated && shard.Cache.Value.Handle != 0 && + SaveDriverPipelineCache(shard.Cache.Value, shard.Path)) + shard.Dirty = false; + } + } + + private void DestroyGuestPipelineCaches() + { + foreach (var shard in _pipelineCacheShards.Values) + { + if (shard.Cache.IsValueCreated && shard.Cache.Value.Handle != 0) + _vk.DestroyPipelineCache(_device, shard.Cache.Value, null); + } + _pipelineCacheShards.Clear(); + } + } +} diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs index 640046b..d7e6ed9 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs @@ -56,6 +56,7 @@ internal static unsafe partial class VulkanVideoPresenter private readonly Dictionary _shaderModules = new(); private readonly Dictionary _shaderModuleSpirvBytes = new(); + private readonly Dictionary _shaderModuleCacheIdentities = new(); private long _pipelineCreationMilliseconds; private KhrPushDescriptor _pushDescriptorApi = null!; private uint _maxPushDescriptors; @@ -171,6 +172,7 @@ internal static unsafe partial class VulkanVideoPresenter SetDebugName(ObjectType.ShaderModule, module.Handle, $"SharpEmu {stage} 0x{hash:X16}"); _shaderModules.Add(programId, module); _shaderModuleSpirvBytes[module.Handle] = shader.Payload.Length; + _shaderModuleCacheIdentities[module.Handle] = VulkanPipelineCacheStorage.CompiledShaderIdentity(shader.Payload); return module.Handle; } @@ -786,7 +788,9 @@ internal static unsafe partial class VulkanVideoPresenter Layout = layout, }; var graphicsStart = Stopwatch.GetTimestamp(); - Check(_vk.CreateGraphicsPipelines(_device, _pipelineCache, 1, &pipelineInfo, null, out var pipeline), + var cache = GetGuestPipelineCache(GraphicsCacheKey(description.VertexStage.Hash, + description.PixelStage?.Hash ?? 0, vertexModule.Handle, pixelModule.Handle)); + Check(_vk.CreateGraphicsPipelines(_device, cache, 1, &pipelineInfo, null, out var pipeline), $"vkCreateGraphicsPipelines(rendering vs=0x{description.VertexStage.Hash:X16} ps=0x{description.PixelStage?.Hash ?? 0:X16})"); ReportPipelineCreation( (long)Stopwatch.GetElapsedTime(graphicsStart).TotalMilliseconds, @@ -849,7 +853,8 @@ internal static unsafe partial class VulkanVideoPresenter Layout = layout, }; var computeStart = Stopwatch.GetTimestamp(); - Check(_vk.CreateComputePipelines(_device, _pipelineCache, 1, &pipelineInfo, null, out pipeline), $"vkCreateComputePipelines(rendering) hash=0x{description.Stage.Hash:X16}"); + var cache = GetGuestPipelineCache(ComputeCacheKey(description.Stage.Hash, computeModule.Handle)); + Check(_vk.CreateComputePipelines(_device, cache, 1, &pipelineInfo, null, out pipeline), $"vkCreateComputePipelines(rendering) hash=0x{description.Stage.Hash:X16}"); ReportPipelineCreation( (long)Stopwatch.GetElapsedTime(computeStart).TotalMilliseconds, "compute", @@ -878,8 +883,8 @@ internal static unsafe partial class VulkanVideoPresenter // One compute pipeline whose vkCreateComputePipelines call runs on a worker // thread. Everything the command stream owns (descriptor and pipeline layout, - // the module handle) is prepared before the task starts, so the task touches - // nothing but the Vulkan pipeline creation itself. + // the module handle) is prepared before the task starts. The worker loads + // its optional driver-cache shard and creates the native pipeline. private sealed class PendingComputePipeline { public required DescriptorSetLayout SetLayout; @@ -958,7 +963,7 @@ internal static unsafe partial class VulkanVideoPresenter } var device = _device; - var cache = _pipelineCache; + var cacheSource = GetGuestPipelineCacheSource(ComputeCacheKey(description.Stage.Hash, computeModule.Handle)); var vk = _vk; var started = new PendingComputePipeline { @@ -980,6 +985,9 @@ internal static unsafe partial class VulkanVideoPresenter var compileStart = Stopwatch.GetTimestamp(); try { + // Importing a MoltenVK cache compiles its MSL libraries. + // Keep that work inside the same bounded compiler slot. + var cache = ResolveGuestPipelineCache(cacheSource); return CompileComputePipeline(vk, device, cache, computeModule, layout); } finally @@ -1036,6 +1044,25 @@ internal static unsafe partial class VulkanVideoPresenter } } + private void DrainPendingComputePipelines() + { + foreach (var pending in _pendingComputePipelines.Values) + { + try + { + var pipeline = pending.Compile.GetAwaiter().GetResult(); + if (pipeline.Handle != 0) _vk.DestroyPipeline(_device, pipeline, null); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Pending compute pipeline failed during shutdown: {exception.Message}"); + } + _vk.DestroyPipelineLayout(_device, pending.Layout, null); + _vk.DestroyDescriptorSetLayout(_device, pending.SetLayout, null); + } + _pendingComputePipelines.Clear(); + } + private void DestroyRenderPipelines() { foreach (var entry in _pipelineEntries.Values) diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs b/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs index ad89efa..f2a6187 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/PresenterUnderTest.cs @@ -84,6 +84,9 @@ internal sealed class PresenterUnderTest : IDisposable field.SetValue(Instance, Activator.CreateInstance(field.FieldType, nonPublic: true)); } + SetField("_shaderModuleCacheIdentities", new Dictionary()); + var cacheShards = PresenterType.GetField("_pipelineCacheShards", InstanceMembers)!; + cacheShards.SetValue(Instance, Activator.CreateInstance(cacheShards.FieldType, nonPublic: true)); forwarder.Target = this; } diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheShardTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheShardTests.cs new file mode 100644 index 0000000..80004a2 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheShardTests.cs @@ -0,0 +1,43 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Tests.Gpu.Images; +using SharpEmu.Libs.Tests.Gpu.Vulkan; +using Silk.NET.Vulkan; +using Xunit; + +namespace SharpEmu.Libs.Tests.VideoOut; + +public sealed class VulkanPipelineCacheShardTests(HeadlessVulkanFixture fixture) : IClassFixture +{ + [Fact] + public void ShardsLoadLazilyShareInitializationAndRecoverFromInvalidData() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan)) return; + var directory = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N")); + using var presenter = new PresenterUnderTest(vulkan); + presenter.SetField("_pipelineCacheShardDirectory", directory); + try + { + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "test.bin"); + File.WriteAllBytes(path, [1, 2, 3]); + var source = (Lazy)presenter.InvokeMethod("GetGuestPipelineCacheSource", "test")!; + Assert.False(source.IsValueCreated); + Assert.Same(source, presenter.InvokeMethod("GetGuestPipelineCacheSource", "test")); + Assert.NotEqual(0ul, source.Value.Handle); + presenter.InvokeMethod("SaveGuestPipelineCaches"); + Assert.True(new FileInfo(path).Length > 3); + presenter.InvokeMethod("DestroyGuestPipelineCaches"); + var reloaded = (Lazy)presenter.InvokeMethod("GetGuestPipelineCacheSource", "test")!; + Assert.NotSame(source, reloaded); + Assert.NotEqual(0ul, reloaded.Value.Handle); + } + finally + { + presenter.InvokeMethod("DestroyGuestPipelineCaches"); + Directory.Delete(directory, recursive: true); + } + } +}