Skip to content

[autodiff] bwd_diff of a [Differentiable] loop with a runtime (non-constant) induction start crashes (SPIR-V ICE / CUDA SIGSEGV) #12070

Description

@nv-slang-bot

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.

Activity

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

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions