Summary
Reverse-mode autodiff (bwd_diff) of a [Differentiable] function that contains a [MaxIters(N)] loop whose induction start value is a non-constant runtime expression miscompiles. On SPIR-V it is a hard compile-time internal error; on CUDA/HLSL it compiles but emits a use-before-def that crashes at runtime (SIGSEGV). The forward pass is always correct, and a mathematically-identical zero-based rewrite produces correct gradients.
Escalated from shader-slang/slangpy#1051 (external reporter, clean SlangPy repro + bisection). SlangPy delegates all loop reverse-mode differentiation to the compiler's bwd_diff intrinsic; the root cause is in the compiler's autodiff pass, reproduced below in pure Slang with no SlangPy involved.
Minimal reproducer (pure Slang, top-of-tree)
[Differentiable]
float f(float x, no_diff int radius)
{
float acc = 0.0;
[MaxIters(17)]
for (int dx = -radius; dx <= radius; ++dx) // induction START is a runtime value
acc += x * float(dx); // induction var used in the differentiable body
return acc;
}
[shader("compute")][numthreads(1,1,1)]
void computeMain(uniform RWStructuredBuffer<float> o, uniform int radius)
{
var dp = diffPair(2.0f, 0.0f);
bwd_diff(f)(dp, radius, 1.0);
o[0] = dp.d;
}
slangc min.slang -target spirv -entry computeMain -stage compute -o out.spv
produces:
error[E99997]: Slang compilation aborted due to an exception of N5Slang13InternalErrorE
unimplemented: Unhandled global inst in spirv-emit:
let %1 : _ = neg(<null>)
-target cuda compiles but emits a reverse function that references the loop's start value from the forward function's scope (use-before-def) → runtime SIGSEGV, matching slangpy#1051's crash on Metal/CUDA/Vulkan.
The trigger is a runtime (non-constant) start — not "negative"
| Loop start |
SPIR-V result |
dx = -2 (constant) |
compiles clean |
dx = 1 (constant) |
compiles clean |
dx = -radius (runtime) |
ICE: neg(<null>) |
dx = radius; dx >= 0; --dx (runtime) |
ICE: param |
A zero-based rewrite (for (int t = 0; t < 2*radius+1; ++t) { int dx = t - radius; ... }) compiles and gives correct gradients — it makes the start value the constant 0.
Root cause
In source/slang/slang-ir-autodiff-primal-hoist.cpp:
- The reverse pass inserts a synthetic loop counter (
lowerIndexedRegion) and checkpoints are sized/indexed by that counter — decoupled from the user's induction variable. So this is not a checkpoint-index/OOB problem.
- An induction variable
dx is classified as an affine function of the counter, capturing counterOffset = loopInst->getArg(paramIndex) (~line 1034) — i.e. the loop phi's initial argument, an instruction that lives in the primal init block.
- In the reverse block,
applyToInst reconstructs dx as diffCountParam (* counterFactor) + counterOffset and uses inductionValueInfo.counterOffset directly (~lines 1355–1361), with no constant-guard and no remap into the reverse scope. When counterOffset is a module-global constant it is in scope everywhere (constant start → fine); when it is a runtime primal-function instruction (neg(radius), or a param) it is not live in the reverse scope → a dangling cross-scope reference → orphan global inst at emit (SPIR-V ICE) / use-before-def (CUDA).
- The sibling loop-exit-value inference path already guards exactly this:
if (!isIntegerConstantValue(inductionValueInfo->counterOffset)) continue; (~line 1153). The reconstruction path lacks the equivalent handling. That asymmetry is the defect.
Suggested direction
- Fix (root cause): the loop-initial
counterOffset is loop-invariant, so materialize it in the reverse scope via the existing recompute/clone path (use cloneCtx->cloneEnv.mapOldValToNew if present; note first-block function params are already available in-function) before emitting the reconstruction add. This brings runtime-start loops to parity with the zero-based form and benefits all autodiff consumers.
- Stopgap (optional, pair with the fix): if the offset cannot be made available, emit an actionable diagnostic instead of dangling IR. A bare
continue (as in the exit-value path) is not safe here — reconstruction must produce a replacement or the SLANG_ASSERT(replacement) at ~line 1364 fires.
A regression test can run without a GPU (-cpu COMPARE_COMPUTE comparing runtime-start vs zero-based gradients, or a -target spirv compile test that must stop ICEing).
🤖 Generated by an automated Slang coworker — may be inaccurate. A human maintainer should verify.
Summary
Reverse-mode autodiff (
bwd_diff) of a[Differentiable]function that contains a[MaxIters(N)]loop whose induction start value is a non-constant runtime expression miscompiles. On SPIR-V it is a hard compile-time internal error; on CUDA/HLSL it compiles but emits a use-before-def that crashes at runtime (SIGSEGV). The forward pass is always correct, and a mathematically-identical zero-based rewrite produces correct gradients.Escalated from shader-slang/slangpy#1051 (external reporter, clean SlangPy repro + bisection). SlangPy delegates all loop reverse-mode differentiation to the compiler's
bwd_diffintrinsic; the root cause is in the compiler's autodiff pass, reproduced below in pure Slang with no SlangPy involved.Minimal reproducer (pure Slang, top-of-tree)
produces:
-target cudacompiles but emits a reverse function that references the loop's start value from the forward function's scope (use-before-def) → runtime SIGSEGV, matching slangpy#1051's crash on Metal/CUDA/Vulkan.The trigger is a runtime (non-constant) start — not "negative"
dx = -2(constant)dx = 1(constant)dx = -radius(runtime)neg(<null>)dx = radius; dx >= 0; --dx(runtime)paramA zero-based rewrite (
for (int t = 0; t < 2*radius+1; ++t) { int dx = t - radius; ... }) compiles and gives correct gradients — it makes the start value the constant0.Root cause
In
source/slang/slang-ir-autodiff-primal-hoist.cpp:lowerIndexedRegion) and checkpoints are sized/indexed by that counter — decoupled from the user's induction variable. So this is not a checkpoint-index/OOB problem.dxis classified as an affine function of the counter, capturingcounterOffset = loopInst->getArg(paramIndex)(~line 1034) — i.e. the loop phi's initial argument, an instruction that lives in the primal init block.applyToInstreconstructsdxasdiffCountParam (* counterFactor) + counterOffsetand usesinductionValueInfo.counterOffsetdirectly (~lines 1355–1361), with no constant-guard and no remap into the reverse scope. WhencounterOffsetis a module-global constant it is in scope everywhere (constant start → fine); when it is a runtime primal-function instruction (neg(radius), or aparam) it is not live in the reverse scope → a dangling cross-scope reference → orphan global inst at emit (SPIR-V ICE) / use-before-def (CUDA).if (!isIntegerConstantValue(inductionValueInfo->counterOffset)) continue;(~line 1153). The reconstruction path lacks the equivalent handling. That asymmetry is the defect.Suggested direction
counterOffsetis loop-invariant, so materialize it in the reverse scope via the existing recompute/clone path (usecloneCtx->cloneEnv.mapOldValToNewif present; note first-block function params are already available in-function) before emitting the reconstructionadd. This brings runtime-start loops to parity with the zero-based form and benefits all autodiff consumers.continue(as in the exit-value path) is not safe here — reconstruction must produce a replacement or theSLANG_ASSERT(replacement)at ~line 1364 fires.A regression test can run without a GPU (
-cpuCOMPARE_COMPUTE comparing runtime-start vs zero-based gradients, or a-target spirvcompile test that must stop ICEing).🤖 Generated by an automated Slang coworker — may be inaccurate. A human maintainer should verify.