Skip to content

SDSL compiler: generic instantiations are validated in the cache, loop attributes reach the SPIR-V, a shader name as qualifier is not an instance access - #3389

Open
Nicogo1705 wants to merge 6 commits into
stride3d:masterfrom
Nicogo1705:sdsl-compiler-fixes

Conversation

@Nicogo1705

@Nicogo1705 Nicogo1705 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

PR Details

Three independent fixes to the new SDSL compiler.

An instantiated generic read back from the disk cache is validated like any other class, and keyed apart from its template

Foo<1> was served from the on-disk shader cache without the source-hash validation every plain class goes through, so an edit to the generic or to one of its bases was never seen again until the cache was deleted by hand. The validation is now a member of IExternalShaderLoader that every loader, decorators included, forwards. With no macros the cache file name also ignored the generic arguments, so Foo<1> and Foo shared one file and a template came back as an instantiation, still carrying its generic parameters. Regression test over a real on-disk cache.

[loop] and [unroll] reach the SPIR-V

A loop attribute written in SDSL was parsed and dropped, so every loop reached the backend without a loop control and FXC was free to unroll any loop whose trip count it could see; a march loop with texture fetches inside compiled in minutes. The attribute is now carried to OpLoopMerge's control mask, which SPIRV-Cross turns back into the HLSL attribute.

A shader name used as a qualifier is not an instance access

A stage method calling a static utility by its shader name - LuminanceUtils.Luma(x) from Shading(), which every post effect does - was flagged as referencing non-stage members, which forces the whole shader to be imported at root level when used in a composition. The qualifier reads no instance state; shader-kind symbols are skipped by the variable tracking.

Related

Independent of #3382; the generic cache fix is the one that made #3382's own branch look stale on a second machine.


⚠️ Written with AI assistance (Claude Code), driven and verified by me; needs a real review. 🤖 Generated with Claude Code

Nicogo1705 and others added 3 commits September 8, 2026 16:43
…idated like any other class, and keyed apart from its template

Foo<1> was served from the cache without the source-hash validation every plain class goes through, so an edit to the generic or to one of its bases was never seen again until the cache was deleted by hand: Stride.Voxels' walk is a generic, and a method added to its interface kept resolving to the interface's empty body in the game while the same sources built correctly in the test harness. The validation is now a member of IExternalShaderLoader that every loader, decorators included, must forward. And with no macros the cache file name ignored the generic arguments, so Foo<1> and Foo shared one file and a template came back as an instantiation, still carrying its generic parameters. Regression test over a real on-disk cache.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7918de5)
A stage method calling a static utility (LuminanceUtils.Luma(x) - every
post effect does it from Shading()) was flagged as referencing
non-stage members, which forces the whole shader to be imported at root
level when used in a composition. The flag came from the qualifier
identifier: resolving `LuminanceUtils` materializes an implicit-this
member access, and the tracking saw a non-stage variable read. But the
identifier reads no instance state of its own, and the member access it
qualifies is the method call's concern - which already exempts
qualified calls. Skip shader-kind symbols in the variable tracking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 699d0a2)
A loop attribute written in SDSL was parsed and dropped, so every loop went to the backend without a loop control and the HLSL compiler was free to unroll it; on a march loop with texture fetches inside that is a compile measured in minutes. The attribute is now carried through to the OpLoopMerge control mask.

(cherry picked from commit 6bd06e2, without its Stride.Voxels part)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Nicogo1705

Copy link
Copy Markdown
Contributor Author

Rebased on master, comments trimmed following xen2's remark on #3388 (1 to 2 lines, no history). No other change.

var sanitized = SanitizeName(name);
var macrosKey = defines.Length == 0 ? "default" : ComputeCacheFilename(generics, defines);
// Generic arguments are part of the key even without macros, otherwise Foo<1> and Foo share a file.
var macrosKey = defines.Length == 0 && generics == null ? "default" : ComputeCacheFilename(generics, defines);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also bump CacheFormatVersion = 2 for user who already have existing cache (otherwise they won't be invalidated and likely to crash).

// This forces the shader to be fully imported at root level instead of stage-only during mixin.
if (symbol.MemberAccessWithImplicitThis != null && !symbol.Id.IsStage && builder.CurrentFunction is { IsStage: true })
// A shader name used as qualifier (LuminanceUtils.Luma(x)) reads no instance state and is not tracked.
if (symbol.MemberAccessWithImplicitThis != null && !symbol.Id.IsStage && symbol.Id.Kind != SymbolKind.Shader

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check used to fire on the qualifier, not on the member being read. Skipping the qualifier
means nothing is left to flag this case:

shader Base { float value; }          // non-stage
shader Derived : Base
{
    stage float Compute() { return Base.value; }   // flagged before, not anymore
}

Base.value is a real non-stage instance read, but it is compiled by the ShaderSymbol case in
AccessorChainExpression, which calls EmitSymbol directly and never goes through
CompileSymbol. So it was never tracked on its own; the qualifier was doing it by accident.

Over-flagging (the LuminanceUtils case) only costs a full import. Under-flagging can give a
stage-only import where the member is missing.

That accessor case already has field.ResolvedSymbol, so it can test IsStage on the member
itself.

{
// Double-check cache
if (cache.TryLoadFromCache(className, genericArguments, macrosArray, out var buf, out var h))
if (cache.TryLoadFromCache(className, genericArguments, macrosArray, out var buf, out var h) && shaderLoader.IsCachedBufferCurrent(buf))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't work completely.
I have a follow up commit that cover this, I will add it to the PR.

@xen2

xen2 commented Sep 9, 2026

Copy link
Copy Markdown
Member

I have added 2 commits, esp. to focus on the Builder.Class.cs caching change.
Note that:

  • I wrote some new tests and moved some of yours from GenericInstantiationCacheTests to ShaderDiskCacheTests
  • Rewrote SuppressSourceHashDoesNotLeakAcrossCacheHits into SourceHashOverrideDoesNotLeakAcrossCacheHits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants