Skip to content

Recover texture [format] across function parameters on CUDA (#12737) - #12766

Draft
nv-slang-bot[bot] wants to merge 1 commit into
masterfrom
fix/issue-12737
Draft

Recover texture [format] across function parameters on CUDA (#12737)#12766
nv-slang-bot[bot] wants to merge 1 commit into
masterfrom
fix/issue-12737

Conversation

@nv-slang-bot

@nv-slang-bot nv-slang-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Motivation

On the CUDA target, a formatted writable texture accessed through a function parameter emits a different (and wrong) surface access than the same texture accessed directly. The compiler fails to recover the [format] backing, so it emits the plain, non-converting intrinsic and computes the coordinate scale from the access element size instead of the backing format. Consider this shader (the type used by the committed regression test):

void writeThroughParam(RWTexture2D<float> t) { t[uint2(1, 0)] = 5.0f; }

[format("r16f")]                 // 2-byte backing format, accessed as a 4-byte float
RWTexture2D<float> tex;

[shader("compute")][numthreads(1,1,1)]
void computeMain()
{
    writeThroughParam(tex);      // through the parameter
    tex[uint2(2, 0)] = 7.0f;     // direct access — the control
}

slangc repro.slang -target cuda -entry computeMain -stage compute emitted, before this change:

// through the parameter (WRONG): plain write, byte-scaled by the 4-byte element size, no _convert
surf2Dwrite<float>((5.0f), (t_0), ((_S1)).x * 4, ((_S1)).y, SLANG_CUDA_BOUNDARY_MODE);
// direct (correct): [format] recovered, converting write
surf2Dwrite_convert<float>((7.0f), (globalParams_0->tex_0), ((_S2)).x * 1, ((_S2)).y, SLANG_CUDA_BOUNDARY_MODE);

CUDA surface access is byte-addressed on the x coordinate. A plain surf2Dwrite<T> therefore scales the coordinate by the backing element size, so when the backing format differs from the access type the correct plain stride would be the 2-byte r16f size, not the 4-byte float size — emitting * 4 writes at the wrong texel and can corrupt adjacent memory. When a conversion is required (as here), the correct form is instead surf2Dwrite_convert<T> with an unscaled coordinate (* 1): the _convert helper handles the packing, so the emitter deliberately sets the stride multiplier to 1 for a converting write. Either way the through-parameter access is wrong because the format is not recovered — it emits the plain, unscaled-by-backing-size form with no _convert. No diagnostic is emitted.

(The issue #12737 reports this with a [format("r32ui")] RWTexture2D<uint64_t> example. That combination exposes the same format-recovery bug, but its converting intrinsic surf2Dwrite_convert<ulonglong> has no CUDA-prelude implementation, so even a direct access to it fails NVRTC independently of this bug — a separate prelude gap. This PR therefore demonstrates and tests the fix with r16f/float, whose converting intrinsics the prelude does define, so the fix is verifiable end-to-end.) The same format-recovery limitation would affect the sured atomic lowering proposed in #12672.

Proposed solution

The [format("...")] backing format is stored as an IRFormatDecoration on the global texture parameter. At the surface-access site the emitter recovers it with _findImageFormatDecoration (slang-intrinsic-expand.cpp), which reads the decoration off the resource instruction. When the texture is passed through a function, the access site sees only the callee's IRParam — which carries no decoration — so _calcBackingElementSizeInBytes falls back to the element-type size and the $E stride multiplier / $C _convert selection are computed from the wrong information.

This is fundamentally the "smuggle a resource through a function" problem documented in the long TODO(JS)/TimF comment at slang-intrinsic-expand.cpp:191-214: from inside the callee you can only reach the IRParam, and distinct call sites may pass differently-formatted textures, so a single un-specialized callee cannot carry one correct format. That same comment notes the Vulkan back-end "gets away with this kind of coincidentally, since the legalization we have to do for resources means there wouldn't be a single f() function any more."

The principled, producer-side fix is to give CUDA that same property for the affected case: specialize functions that take a writable-texture parameter so the concrete global (carrying [format]) is referenced directly at the access site. This is exactly the mechanism Khronos (SPIRV/GLSL) and WGSL already use — the resource-parameter specialization pass (specializeResourceUsage) already runs unconditionally for all targets (slang-emit.cpp); it is not gated behind shouldLegalizeExistentialAndResourceTypes. Only the per-parameter decision ResourceParameterSpecializationCondition::doesParamWantSpecialization returned false for CUDA. Adding a CUDA arm to that decision fixes surface read/write — and the pending sured atomic path, which shares _calcBackingElementSizeInBytes — uniformly, with no change to the emitter.

Once the callee refers to the global directly, _findImageFormatDecoration finds the decoration, _calcBackingElementSizeInBytes returns the true backing size, and the through-parameter access emits identically to the direct access.

Scope. This fixes the format-recovery defect the issue reports — a formatted writable texture passed through a function parameter where the argument is a concrete global — for both reads and writes wherever the target's converting helper exists, and handles distinct-format call sites via distinct specializations. It intentionally does not auto-close #12737: four residual cases still emit a possibly-wrong stride (all of them no worse than the base compiler today — see the Process report for the verification), and #12737 stays open to track them:

  • an array-of-globals element argument (f(gTexArray[i])) — which is a separate, pre-existing format-recovery gap, not the parameter-boundary bug (it reproduces with a direct access and no function parameter at all);
  • a function-returned resource (getTex()[coord]) — not foldable to a single global;
  • a runtime-selected argument (f(cond ? gA : gB)) — inherently unrecoverable, since the two textures may carry different formats;
  • a mixed call where one parameter's argument is foldable and another's is not — canSpecializeCall is all-or-nothing per call, so it leaves the foldable one unspecialized too.

Closing these needs a separate format-recovery fix for array/returned resources and/or a capability diagnostic (the E41405-style approach being added in #12672); that is deliberately left as a coordinated follow-up rather than raced against that PR. The predicate is scoped to writable textures for consistency with the existing Khronos/WGSL arm; specializing an unformatted writable texture is correctness-neutral (its stride is unchanged) at a bounded code-size cost.

Change summary

File Change
source/slang/slang-ir-specialize-resources.cpp Add a CUDA arm to doesParamWantSpecialization that requests specialization for writable-texture parameters. Extract the "writable texture access" test into a shared isWritableTextureType helper and reuse it in the existing isIllegalGLSLParameterType so the classification lives in one place.
tests/compute/texture-format-through-param.slang New SIMPLE(filecheck=CUDA,READ,NEG) regression test: a [format("r16f")] RWTexture2D<float> written and read through a function parameter (and written directly) must now emit the converting intrinsics (surf2Dwrite_convert with the unscaled * 1 coordinate; surf2Dread_convert with the 2-byte backing scale), matching the direct access, with no plain non-converting form. Compiles end-to-end via NVRTC (-target ptx).

Concepts and vocabulary

  • doesParamWantSpecialization (ResourceParameterSpecializationCondition) — the per-parameter predicate that decides whether a function taking this parameter should be specialized per concrete argument. It already returns true for ConstantBuffer<T> on all targets and for illegal buffer/image/texture parameter types on Khronos/WGSL; every other target previously fell through to false.
  • isParamSuitableForSpecialization / canSpecializeCall (slang-ir-specialize-function-call.cpp) — the companion predicate that decides whether a given argument can actually be folded. It accepts several shapes (global shader parameters, global values, user-pointer types, descriptor-handle casts, and index/load/field chains rooted at one of those); for the texture-resource path relevant to this PR that means the argument must resolve to a global (or an index into a global array). If a parameter wants specialization but the argument is not suitable, canSpecializeCall returns false and the whole call is left unspecialized — this all-or-nothing behavior is why the "mixed call" residual below exists.
  • $E / $C (slang-intrinsic-expand.cpp) — the surface-intrinsic template expansions: $C appends _convert to the intrinsic name when a format conversion is required; $E emits the coordinate scale factor — the backing-element byte size for a plain (non-converting) access, but 1 for a converting write (_calcBackingElementSizeInBytes is overridden to 1 there, since the _convert helper handles packing). Both read the recovered IRFormatDecoration.

Process report

The one behavioral change — a CUDA arm in doesParamWantSpecialization. The bug's root cause is that the format decoration is unreachable across the parameter boundary; the fix makes it reachable by specializing the callee against the concrete global, rather than patching the emitter to guess a stride it cannot prove. Requesting specialization is scoped to isWritableTextureType(type)RWTexture/WTexture/rasterizer-ordered views — because only writable-texture accesses go through the CUDA surf*write/surf*read size-scaling path; a sampled Texture (plain READ) lowers to a texture fetch and does not need it. It is gated by isCUDATarget(targetRequest) (matching the sibling isKhronosTarget/isWGPUTarget arms). CUDA is the only C-family target that reaches this path: CUDASourceEmitter inherits CPPSourceEmitter, but the surface intrinsics (surf2Dwrite$C…$E) are CUDA-only templates in hlsl.meta.slang, and the CPU/C++ path never emits them.

Input-shape check (is this the right layer?). Yes. The IRParam reaching the access site is not a malformed shape to be repaired at emit; it is the correct IR for an un-specialized callee. The problem is that CUDA left the callee un-specialized where the format information only survives on the global. The producer-side fix — specialize so the global reaches the access site — is precisely how the other targets avoid the same bug, and it removes the need for any emit-time guess. The alternative of carrying the format on the IRTextureType and reading it at emit was rejected: it founders on the multi-call-site ambiguity in the TODO (one un-specialized callee cannot carry one correct format when call sites disagree), and it would introduce a second format representation (decoration vs. type operand) that every consumer would have to agree on, violating "one canonical representation per value."

What is fixed, and what is not (verified against the base compiler). The fix recovers the format when the argument is a concrete global texture — f(gTex) — for both reads and writes, and when different call sites pass differently-formatted globals (each gets its own specialization). Four cases are not covered and remain exactly as they emit today (never worse — verified against base):

  • Array-of-globals element (f(gTexArray[i])): this is a separate, pre-existing bug, not the parameter-boundary bug. It reproduces with a direct access and no function parameter at all — gTexArray[idx][coord] = v already emits surf2Dwrite<...> .x * 4 on the base compiler (verified identical on this branch). The call here does specialize (the suitability check traces GetElement to the global), but _findImageFormatDecoration does not recover the per-array [format] off the array-element access; fixing that lives in _findImageFormatDecoration (the same file Lower RWTexture atomics to PTX sured on CUDA/PTX (#12636) #12672 is editing), independent of this change.
  • Function-returned resource (getTex()[coord]): the argument is a call result, not foldable to a single global, so the callee is not specialized and the access emits surf2Dwrite<...> .x * 4 (verified).
  • Runtime-selected argument (f(cond ? gA : gB)): isParamSuitableForSpecialization returns false for the resource path here, so canSpecializeCall bails for the whole call. This one is inherently unrecoverable by specialization — the two textures may carry different formats.
  • Mixed call (f(gTex, cond ? gA : gB)): canSpecializeCall is all-or-nothing per call, so one unfoldable argument leaves the foldable gTex unspecialized too.

Closing these requires either recovering the format after array/GEP reconstruction, argument-level (vs call-level) specialization, or a capability diagnostic (the E41405-style approach being added in #12672). That is deliberately left as a coordinated follow-up rather than raced against #12672, which is why this PR does not auto-close #12737. The common concrete-global case the issue reports is fixed.

The refactor — isWritableTextureType. The switch on IRTextureType::getAccess() for the writable access modes previously lived inline in isIllegalGLSLParameterType. Extracting it into a named helper and reusing it in both the GLSL-legality check and the new CUDA arm keeps the "which texture accesses are writable" classification in one place, so the two callers cannot drift. No behavior change to the GLSL path.

Tests

  • New: tests/compute/texture-format-through-param.slang — a [format("r16f")] RWTexture2D<float> written through a function parameter and directly, and read through a parameter. It uses one multi-prefix source-emission FileCheck directive (SIMPLE -target cuda, which runs slangc + FileCheck): CUDA asserts two surf2Dwrite_convert calls with stride * 1 (the through-parameter write matches the direct one); READ asserts surf2Dread_convert … .x * 2 (the through-parameter read recovers the 2-byte backing size); and a NEG prefix forbids the plain surf2Dwrite< / surf2Dread< across the whole input. Fails before the fix (only the direct write converted; a plain .x * 4 write and a plain read remained), passes after. r16f/float is used (not an integer format) both to force the size-mismatch conversion and because the CUDA prelude only defines the converting-read helper (surf2Dread_convert) for float element types.
  • Real CUDA compile: the emitted CUDA for the full test — reads and writes through parameters — compiles via NVRTC (-target ptx) rc=0 on an L40S with CUDA 12.6, confirming the specialization produces valid CUDA (not just textually-correct source) and does not trip an assert cloning an un-legalized texture global. (float reads use the defined surf2Dread_convert<float> overload; an integer converting read would hit a pre-existing prelude gap where only float overloads are defined — orthogonal to this fix.)
  • Regression: 779/779 across tests/compute/{texture-subscript,rw-texture-simple,half-rw-texture-convert*}, tests/hlsl-intrinsic/texture/, and tests/diagnostics/ (GPU-only legs ignored); the GLSL predicate is behavior-identical after the refactor.

Addresses #12737 (fixes the common concrete-global case for reads and writes; the runtime-selected-argument residual and a follow-up diagnostic are noted above — this PR intentionally does not auto-close the issue).

Specialize functions taking writable-texture parameters on CUDA so the concrete
global carrying the [format] decoration reaches the surface-access site. Without
this, a formatted RWTexture passed through a function parameter emitted the wrong
surface byte stride (element size instead of backing-format size) and skipped the
_convert read/write path, because _findImageFormatDecoration cannot recover the
format from an undecorated IRParam. This is the same producer-side mechanism
Khronos/WGSL already use, and covers reads, writes, and distinct-format call sites
that pass concrete globals.

Extract the writable-texture-access test into a shared isWritableTextureType helper
and reuse it in isIllegalGLSLParameterType.

Partial fix (does not auto-close #12737): array-of-globals elements and
runtime-selected texture arguments still cannot recover the format and are left
unspecialized (never worse than before); a diagnostic backstop is a coordinated
follow-up with #12672.

Addresses #12737.
@nv-slang-bot nv-slang-bot Bot added the pr: non-breaking PRs without breaking changes label Aug 26, 2026
@jhelferty-nv

Copy link
Copy Markdown
Contributor

Automated notice (PR board sync) — do not reply to this comment.

Auto-assigned @kaizhangNV as shepherd for this Bot PR.

1 similar comment
@jhelferty-nv

Copy link
Copy Markdown
Contributor

Automated notice (PR board sync) — do not reply to this comment.

Auto-assigned @kaizhangNV as shepherd for this Bot PR.

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

Labels

pr: non-breaking PRs without breaking changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CUDA: wrong surface byte-stride when a formatted RWTexture is accessed through a function parameter

2 participants