Skip to content

Commit cc7ca73

Browse files
committed
fix: perpetual shader recompile from zero-hash cache entries
Consume SuppressSourceHash on the cache-hit path so it can no longer leak into the next compiled shader and strip its OpSourceHashSDSL. A stripped hash read back as zero, making EffectCompilerCache.IsBytecodeObsolete recompile every run. Also stamp a format version into the cached .spv header (Generator id + Schema) and reject mismatches on load, so already-poisoned cache files are recompiled and overwritten instead of served stale.
1 parent 66d49f9 commit cc7ca73

5 files changed

Lines changed: 138 additions & 12 deletions

File tree

‎sources/shaders/Stride.Shaders.Compilers/FileShaderCache.cs‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Stride.Shaders.Spirv.Building;
1010
using Stride.Shaders.Spirv.Core;
1111
using Stride.Shaders.Spirv.Core.Buffers;
12+
using Stride.Shaders.Spirv.Core.Parsing;
1213

1314
namespace Stride.Shaders.Compilers;
1415

@@ -27,6 +28,12 @@ public class FileShaderCache(IVirtualFileProvider fileProvider, string basePath
2728
private readonly object lockObject = new();
2829
private readonly ShaderCache memoryCache = new();
2930

31+
// Cache files are stamped in the SPIR-V header: Generator identifies our shader cache, Schema is the
32+
// format version. A mismatch (including old 0/0 headers) is treated as stale and forces a recompile,
33+
// which overwrites the file in place. Bump CacheFormatVersion on any incompatible .spv cache change.
34+
private const int ShaderCacheGeneratorId = 0x5344534C; // 'SDSL'
35+
private const int CacheFormatVersion = 1;
36+
3037
public bool Exists(string name)
3138
{
3239
if (memoryCache.Exists(name))
@@ -87,7 +94,14 @@ public bool TryLoadFromCache(string name, string? generics, ReadOnlySpan<ShaderM
8794

8895
using var stream = fileProvider.OpenStream(path, VirtualFileMode.Open, VirtualFileAccess.Read);
8996
using var reader = new BinaryReader(stream);
90-
buffer = Deserialize(reader, out hash);
97+
if (!TryDeserialize(reader, out buffer, out hash))
98+
{
99+
// Stale/incompatible cache file (old or missing version stamp): treat it like a
100+
// cache miss so the caller recompiles and overwrites it in place.
101+
buffer = default;
102+
hash = default;
103+
return false;
104+
}
91105
}
92106

93107
// Populate in-memory cache for subsequent lookups
@@ -142,19 +156,35 @@ private static string ComputeCacheFilename(string? generics, ReadOnlySpan<Shader
142156

143157
private static void Serialize(BinaryWriter writer, ShaderBuffers buffers, ObjectId hash)
144158
{
145-
var bytecode = SpirvBytecode.CreateBytecodeFromBuffers(buffers.Context.GetBuffer(), buffers.Buffer);
159+
var header = new SpirvHeader("1.4", generator: ShaderCacheGeneratorId, bound: 1, schema: CacheFormatVersion);
160+
var bytecode = SpirvBytecode.CreateBytecodeFromBuffers(header, computeBounds: true, buffers.Context.GetBuffer(), buffers.Buffer);
146161
writer.Write(bytecode);
147162
}
148163

149-
private ShaderBuffers Deserialize(BinaryReader reader, out ObjectId hash)
164+
/// <summary>
165+
/// Reads a cached buffer. Returns false (a plain cache miss) when the file predates the current
166+
/// cache format — an old or absent version stamp is expected, not corruption. Genuinely malformed
167+
/// content still throws, so callers can distinguish it.
168+
/// </summary>
169+
private bool TryDeserialize(BinaryReader reader, [MaybeNullWhen(false)] out ShaderBuffers result, out ObjectId hash)
150170
{
171+
result = default;
172+
hash = default;
173+
151174
var buffer = new byte[reader.BaseStream.Length];
152175
reader.ReadExactly(buffer);
176+
var span = buffer.AsSpan().Cast<byte, int>();
153177

154-
var result = ShaderBuffers.CreateFromSpan(buffer.AsSpan().Cast<byte, int>());
178+
if (span.Length < SpirvHeader.IntSpanSize)
179+
return false;
180+
181+
var header = SpirvHeader.Read(span);
182+
if (header.Generator != ShaderCacheGeneratorId || header.Schema != CacheFormatVersion)
183+
return false;
184+
185+
result = ShaderBuffers.CreateFromSpan(span);
155186

156187
// Fetch hash from OpSourceHashSDSL
157-
hash = default;
158188
foreach (var i in result.Context)
159189
{
160190
if (i.Op == Spirv.Specification.Op.OpSourceHashSDSL && (OpSourceHashSDSL)i is { } sourceHash)
@@ -166,7 +196,7 @@ private ShaderBuffers Deserialize(BinaryReader reader, out ObjectId hash)
166196

167197
ShaderClass.ProcessNameAndTypes(result.Context, ShaderImporter);
168198

169-
return result;
199+
return true;
170200
}
171201

172202
private static int GetTotalWordCount(SpirvBuffer buffer)

‎sources/shaders/Stride.Shaders.Compilers/SDSL/ShaderMixer.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,6 +1377,10 @@ public bool LoadExternalFileContent(string name, out string filename, out string
13771377
public bool LoadExternalBuffer(string name, ReadOnlySpan<ShaderMacro> defines, out ShaderBuffers bytecode, out ObjectId hash, out bool isFromCache)
13781378
{
13791379
var result = inner.LoadExternalBuffer(name, defines, out bytecode, out hash, out isFromCache);
1380+
// A file-backed shader always carries a source hash. A zero here means it was compiled or cached
1381+
// without its OpSourceHashSDSL, which would make its effect obsolete (recompiled) on every run.
1382+
if (hash == ObjectId.Empty)
1383+
throw new InvalidOperationException($"Shader '{name}' was loaded without a source hash (missing OpSourceHashSDSL).");
13801384
if (!Sources.ContainsKey(name))
13811385
Sources.Add(name, hash);
13821386
return result;

‎sources/shaders/Stride.Shaders.Compilers/ShaderLoaderBase.cs‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,19 @@ public bool LoadExternalBuffer(string name, ReadOnlySpan<ShaderMacro> defines, [
9696

9797
public bool LoadExternalBuffer(string name, string? filename, string code, ReadOnlySpan<ShaderMacro> defines, [MaybeNullWhen(false)] out ShaderBuffers buffer, out ObjectId hash, out bool isFromCache)
9898
{
99+
// Consume the one-shot suppress flag now, before any early return, so it can't leak into a
100+
// later compilation when this load is served from the cache. A leaked flag would strip that
101+
// shader's OpSourceHashSDSL, making its cached source hash read back as zero.
102+
var emitSourceHash = !SuppressSourceHash;
103+
SuppressSourceHash = false;
104+
99105
isFromCache = Cache.TryLoadFromCache(name, null, defines, out buffer, out hash);
100106
if (isFromCache)
101107
return true;
102108

103109
hash = ObjectId.FromBytes(Encoding.UTF8.GetBytes(code));
104110
// Don't auto-register in SDSLC — the caller (InstantiateMemberNames) registers under the cache key
105-
if (!LoadFromCode(filename, code, hash, defines, out buffer, registerInCache: false))
111+
if (!LoadFromCode(filename, code, hash, defines, out buffer, registerInCache: false, emitSourceHash: emitSourceHash))
106112
{
107113
// If a logger is set, errors are already logged — just return false
108114
if (Log != null)
@@ -162,7 +168,7 @@ private static int ComputeMacrosHash(ReadOnlySpan<ShaderMacro> macros)
162168
}
163169
}
164170

165-
protected virtual bool LoadFromCode(string? filename, string code, ObjectId hash, ReadOnlySpan<ShaderMacro> macros, out ShaderBuffers buffer, bool registerInCache = true)
171+
protected virtual bool LoadFromCode(string? filename, string code, ObjectId hash, ReadOnlySpan<ShaderMacro> macros, out ShaderBuffers buffer, bool registerInCache = true, bool emitSourceHash = true)
166172
{
167173
var defines = new (string Name, string Definition)[macros.Length];
168174
for (int i = 0; i < macros.Length; ++i)
@@ -176,8 +182,6 @@ protected virtual bool LoadFromCode(string? filename, string code, ObjectId hash
176182

177183
// Use provided logger, or a temporary one that throws on errors
178184
var log = Log ?? new LoggerResult();
179-
var emitSourceHash = !SuppressSourceHash;
180-
SuppressSourceHash = false; // Reset after use
181185
if (!sdslc.Compile(filename, text, hash, macros, log, out buffer, new() { RegisterInCache = registerInCache, EmitSourceHash = emitSourceHash, OriginalCode = code }))
182186
{
183187
if (log is LoggerResult loggerResult && loggerResult.HasErrors)

‎sources/shaders/Stride.Shaders.Tests/ShaderLoader.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ public override bool LoadExternalFileContent(string name, out string filename, o
4343
return false;
4444
}
4545

46-
protected override bool LoadFromCode(string? filename, string code, ObjectId hash, ReadOnlySpan<ShaderMacro> macros, out ShaderBuffers buffer, bool registerInCache = true)
46+
protected override bool LoadFromCode(string? filename, string code, ObjectId hash, ReadOnlySpan<ShaderMacro> macros, out ShaderBuffers buffer, bool registerInCache = true, bool emitSourceHash = true)
4747
{
48-
var result = base.LoadFromCode(filename, code, hash, macros, out buffer, registerInCache);
48+
var result = base.LoadFromCode(filename, code, hash, macros, out buffer, registerInCache, emitSourceHash);
4949
if (result)
5050
{
5151
Console.WriteLine($"Loading shader {filename}");

‎sources/shaders/Stride.Shaders.Tests/StrideShaderTests.cs‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.IO;
34
using System.Text;
45
using CommunityToolkit.HighPerformance;
56
using Silk.NET.SPIRV;
67
using Silk.NET.SPIRV.Cross;
8+
using Stride.Core.IO;
9+
using Stride.Core.Storage;
710
using Stride.Shaders.Compilers;
811
using Stride.Shaders.Compilers.SDSL;
12+
using Stride.Shaders.Spirv.Building;
913
using Stride.Shaders.Spirv.Core.Buffers;
1014
using Stride.Shaders.Spirv.Tools;
1115
using Spv = Stride.Shaders.Spirv.Tools.Spv;
@@ -14,6 +18,90 @@ namespace Stride.Shaders.Parsers.Tests;
1418

1519
public class StrideShaderTests
1620
{
21+
// Regression: the MemberName re-instantiation path sets ShaderLoaderBase.SuppressSourceHash and
22+
// relies on LoadFromCode to clear it. When the load hits the shader cache instead, LoadFromCode
23+
// never runs, so the flag leaks into the next compiled shader and strips its OpSourceHashSDSL.
24+
// A cached shader without OpSourceHashSDSL deserializes to a zero hash, which makes
25+
// EffectCompilerCache.IsBytecodeObsolete return true on every run (a permanent recompile hang).
26+
[Fact]
27+
public void SuppressSourceHashDoesNotLeakAcrossCacheHits()
28+
{
29+
var loader = new ShaderLoader("./assets/Stride/SDSL");
30+
31+
// Warm the cache: a normally compiled shader carries its OpSourceHashSDSL.
32+
Assert.True(loader.LoadExternalBuffer("Texturing", [], out var texturing, out _, out _));
33+
Assert.True(HasSourceHash(texturing), "sanity: a normally compiled shader carries OpSourceHashSDSL");
34+
35+
// Reproduce the MemberName path (Builder.Class.InstantiateMemberNames): set the flag, then load
36+
// a shader that is already cached. The (name, filename, code) overload returns on the cache hit
37+
// before LoadFromCode runs, so the flag is never cleared.
38+
Assert.True(loader.LoadExternalFileContent("Texturing", out var filename, out var code, out _));
39+
loader.SuppressSourceHash = true;
40+
Assert.True(loader.LoadExternalBuffer("Texturing", filename, code, [], out _, out _, out var isFromCache));
41+
Assert.True(isFromCache, "precondition: the second load must be a cache hit to exercise the leak");
42+
43+
Assert.False(loader.SuppressSourceHash,
44+
"SuppressSourceHash leaked past a cache hit; the next compiled shader would be cached without " +
45+
"OpSourceHashSDSL (zero hash => EffectCompilerCache recompiles every run)");
46+
47+
// Downstream symptom: with the flag leaked, the next freshly compiled shader loses its hash.
48+
Assert.True(loader.LoadExternalBuffer("ShaderBase", [], out var shaderBase, out _, out _));
49+
Assert.True(HasSourceHash(shaderBase),
50+
"the next compiled shader is missing OpSourceHashSDSL — its cached hash would deserialize to zero");
51+
}
52+
53+
private static bool HasSourceHash(ShaderBuffers buffer)
54+
{
55+
foreach (var i in buffer.Context)
56+
{
57+
if (i.Op == Stride.Shaders.Spirv.Specification.Op.OpSourceHashSDSL)
58+
return true;
59+
}
60+
return false;
61+
}
62+
63+
// FileShaderCache stamps a Generator id + Schema (format version) into the SPIR-V header of each
64+
// cached .spv. A cached buffer round-trips (including its OpSourceHashSDSL), and a header whose
65+
// version no longer matches is rejected so the shader recompiles instead of being served stale.
66+
[Fact]
67+
public void FileShaderCacheRoundTripsAndRejectsStaleVersion()
68+
{
69+
var loader = new ShaderLoader("./assets/Stride/SDSL");
70+
Assert.True(loader.LoadExternalBuffer("Texturing", [], out var buffer, out var hash, out _));
71+
Assert.NotEqual(ObjectId.Empty, hash);
72+
73+
var tempDir = Directory.CreateTempSubdirectory("stride-shadercache-test");
74+
try
75+
{
76+
var provider = new FileSystemProvider("/testcache", tempDir.FullName);
77+
const string spvPath = "shaders/Texturing_default.spv";
78+
79+
// Write via one instance, read via a fresh instance so the load must come from disk.
80+
new FileShaderCache(provider).RegisterShader("Texturing", null, [], buffer, hash);
81+
82+
Assert.True(new FileShaderCache(provider).TryLoadFromCache("Texturing", null, [], out _, out var loadedHash));
83+
Assert.Equal(hash, loadedHash);
84+
85+
// Corrupt the Schema word (5th header word, byte offset 16) to simulate an old/incompatible cache.
86+
byte[] bytes;
87+
using (var s = provider.OpenStream(spvPath, VirtualFileMode.Open, VirtualFileAccess.Read))
88+
{
89+
bytes = new byte[s.Length];
90+
s.ReadExactly(bytes);
91+
}
92+
BitConverter.GetBytes(0x7FFFFFFF).CopyTo(bytes, 16);
93+
using (var s = provider.OpenStream(spvPath, VirtualFileMode.Create, VirtualFileAccess.Write))
94+
s.Write(bytes);
95+
96+
Assert.False(new FileShaderCache(provider).TryLoadFromCache("Texturing", null, [], out _, out _),
97+
"a cache file with a mismatched format version must be rejected (recompiled), not served stale");
98+
}
99+
finally
100+
{
101+
tempDir.Delete(recursive: true);
102+
}
103+
}
104+
17105
[Fact]
18106
public void TextureDecorateStringWarning()
19107
{

0 commit comments

Comments
 (0)