Repository: shader-slang/slang (2026-05-25T18:29:00Z)

Issue:
E41031 false positive: 'derivative is lost' on no_diff out field with all-no_diff inputs
## Issue Description

`E41031` ("derivative is lost during assignment") fires in a `[Differentiable]` function when assigning to a field of a `no_diff out` parameter, even when every operand on the RHS is itself non-differentiable. There is no derivative flow into the assignment, so the diagnostic is a false positive and blocks valid code.

This was surfaced by falcor2 (`emissive_triangle_sampler_utils.slang`) after #11272 / #11273 unblocked an earlier `this_type` codegen error that previously aborted compilation before the auto-diff check ran. Reduced here to a 31-line standalone repro with no external dependencies.

## Reproducer Code

```slang
public struct DirSample
{
    public float3 pos_ws;
    public float distance;
    public float pdf;
}

[Differentiable]
public bool sample_dir(
    no_diff const float3 pos_ws,
    no_diff out DirSample sample,
    out float3 weight)
{
    sample = {};
    weight = float3(0);

    float3 to_light = sample.pos_ws - pos_ws;
    const float dist_sqr = max(1e-30f, dot(to_light, to_light));
    sample.distance = sqrt(dist_sqr);
    sample.pdf = dist_sqr / 1.0f;
    return true;
}

RWStructuredBuffer<float> out_buf;

[shader("compute")]
[numthreads(1, 1, 1)]
void main()
{
    DirSample s;
    float3 w;
    sample_dir(float3(0), s, w);
    out_buf[0] = s.distance + s.pdf;
}
```

## Compiler Command Line

```
slangc repro.slang -target hlsl -entry main -stage compute -o out.hlsl
```

## Expected Behavior

Clean compile. Every value feeding the assignments is `no_diff`:
- `pos_ws` parameter — declared `no_diff const float3`
- `sample.pos_ws` — read from a `no_diff out DirSample` parameter
- `to_light = sample.pos_ws - pos_ws` — both operands no_diff
- `dist_sqr = max(..., dot(to_light, to_light))` — entirely no_diff
- `sqrt(dist_sqr)`, `dist_sqr / 1.0f` — no_diff

There is no derivative to lose.

## Observed Behavior

```
error[E41031]: derivative is lost during assignment
  --> repro.slang:32:21
   |
32 | sample.distance = sqrt(dist_sqr);
   |                 ^ derivative is lost during assignment to non-differentiable
   |                   location, use 'detach()' to clarify intention.
---'
error[E41031]: derivative is lost during assignment
  --> repro.slang:33:16
   |
33 | sample.pdf = dist_sqr / 1.0f;
   |            ^ derivative is lost during assignment to non-differentiable
   |              location, use 'detach()' to clarify intention.
---'
```

## Where the Check Lives

`source/slang/slang-ir-check-differentiability.cpp:734` —
`Diagnostics::LossOfDerivativeAssigningToNonDifferentiableLocation`.

The guard is:

```cpp
if (carryNonTrivialDiffSet.contains(storeInst->getVal()) &&
    !canAddressHoldDerivative(diffTypeContext, storeInst->getPtr()))
{
    sink->diagnose(
        Diagnostics::LossOfDerivativeAssigningToNonDifferentiableLocation{
            .location = storeInst->sourceLoc,
        });
}
```

`canAddressHoldDerivative(...)` correctly reports `false` for the LHS (it is a `no_diff` parameter field). The defect is on the value side: `carryNonTrivialDiffSet` includes `sqrt(dist_sqr)` and `dist_sqr / 1.0f` even though `dist_sqr` is derived entirely from `no_diff` values. The set propagation in the auto-diff check is over-approximating.

## What I've Verified

- Reproduces with the repro above on current master (`18e72e1b6` + #11272 / #11277 fixes — neither touches auto-diff code; both PRs pass CI).
- The diagnostic itself (`LossOfDerivativeAssigningToNonDifferentiableLocation`, error code 41031) has been in the codebase since 2023.
- The auto-diff check was significantly refactored on 2026-04-01 in #9808 ("Refactor auto-diff implementation"). That refactor lands in 2026.9.1, but on 2026.9.1 falcor2 hit the `this_type` codegen error before the auto-diff check ran, so this regression went unnoticed there. A targeted bisect across #9808 would confirm whether the `carryNonTrivialDiffSet` propagation regressed there or earlier.
- Workarounds: wrapping each RHS in `detach()` (or marking `dist_sqr`/`to_light` with `no_diff`) silences the error, but is a band-aid — the diagnostic should not fire in the first place when no derivative flows in.

## Impact

Blocks falcor2's path-tracer module (`emissive_triangle_sampler_utils.slang`) on current master. With #11272 / #11273 / #11277 applied, this is the next-in-line error preventing falcor2 from using a current Slang.



Discussion:
## Internal Triage Report

**Reproduced**: Yes — top-of-tree commit `18e72e1b6` (slang `2026.5.2-97-g88ca756ac`).

Command used (exactly as reported):
```
slangc repro.slang -target hlsl -entry main -stage compute -o out.hlsl
```
Exit `255`; same `error[E41031]` on lines 19 and 20 as reported.

**Crash site**: N/A — false-positive diagnostic emitted at `source/slang/slang-ir-check-differentiability.cpp:730-737` (`Diagnostics::LossOfDerivativeAssigningToNonDifferentiableLocation`).

**Pipeline stage**: auto-diff data-flow analysis (`checkForDifferentiability` pass), before `ForwardDifferentiate`/`BackwardDifferentiate` lowering.

**Nature**: diagnostic issue — false positive blocks valid code.

### Analysis

The address-side check (`canAddressHoldDerivative`) correctly rejects `sample.distance`/`sample.pdf` because the parent parameter is `no_diff out DirSample`. The defect is on the *value* side: `carryNonTrivialDiffSet` over-includes calls whose operands are all derived from `no_diff` sources.

`isInstCarryingOverDiff` (lines 461-496) handles `kIROp_Call` as:

```cpp
case kIROp_Call:
    if (shouldTreatCallAsDifferentiable(inst))
        return false;
    return isDifferentiableFunc(diffTypeContext, callee, requiredDiffLevel) &&
           diffTypeContext.isDifferentiableType(inst->getFullType());
```

For `dot`/`max`/`sqrt`/`/` in this repro, the callee is differentiable and the result is `Float`, so the lambda returns `true` *unconditionally* — independent of whether any argument is in `carryNonTrivialDiffSet`. By contrast, the `default` branch at lines 482-494 *does* scan operands. As a result the chain

```
load(no_diff sample.pos_ws) → sub → dot → max → sqrt
```

leaks into `carryNonTrivialDiffSet` even though every value is derived from `no_diff` sources. The store guard at lines 730-737 then trips.

The comment block at lines 394-402 makes the design intent explicit: `carryNonTrivialDiffSet` is supposed to be conservative on the *negative* side ("if we can prove the diff is zero, we exclude the inst from the set"). The current call branch does the opposite.

`shouldCallImpliesNoDiff` (lines 135-172) attempts a related exemption but only when *all* differentiable arguments are literal constants (`isConstantVal`); it does not consult `carryNonTrivialDiffSet`, so non-constant no_diff chains escape it.

### Relevant Code

- `source/slang/slang-ir-check-differentiability.cpp:461-496` — `isInstCarryingOverDiff`; `kIROp_Call` branch is the over-approximation site.
- `source/slang/slang-ir-check-differentiability.cpp:425-459` — symmetric `isInstProducingDiff`; same call-shape, worth considering jointly.
- `source/slang/slang-ir-check-differentiability.cpp:135-172` — `shouldCallImpliesNoDiff` (constant-only escape hatch).
- `source/slang/slang-ir-check-differentiability.cpp:203-245` — `canAddressHoldDerivative`; address side, working as intended here.
- `source/slang/slang-ir-check-differentiability.cpp:730-737` — diagnostic emission site.

### Potential Fix Approaches

1. **Tighten `isInstCarryingOverDiff` for differentiable calls** (cross-ref #3010) — symmetric to the under-approximation fix in PR #3010, but in the opposite direction. In the `kIROp_Call` branch, only return `true` when at least one argument that maps to a differentiable parameter is in `carryNonTrivialDiffSet`. Skip `out`/`inout`/`ref` slots (those are address-typed); the loop at lines 750-774 already has the right `splitParameterDirectionAndType` machinery to model the distinction. This is consistent with the file's own comment at lines 394-402 specifying `carryNonTrivialDiffSet` is conservative on the negative side.

2. **Diagnostic-only mitigation** — downgrade `E41031` to a warning when the value's diff-carry is inferred (not direct). Avoids touching data-flow but trades correctness for noise tolerance; arguably a band-aid given the file's "focus on root causes in IR passes" guidance.

The reporter's `detach()` / extra `no_diff` workarounds also paper over the over-approximation rather than address it.

### Relevant Engineers

- saipraveenb25 — author of PR #9808 (auto-diff refactor, merged 2026-04-01, last touched the address side of this file) and PR #3010 (prior data-flow tuning of the same `kIROp_Call` propagation, opposite direction); owns this subsystem.
- csyonghe — original author of the diagnostic and of `canAddressHoldDerivative`.

### Test Gap

`tests/autodiff/` does not cover `no_diff out` struct fields written from a chain of differentiable-function calls whose operands are themselves all `no_diff`. Closest existing cases write directly-derived diff values into non-diff locations.

### Notes

Surfaced as part of an ongoing falcor2-on-current-Slang campaign by the reporter. Dependency chain so far: #11272 / #11273 → #11277 → #11285. Each prior issue had to be unblocked before this E41031 false positive could surface in compilation.




Patch:
diff --git a/source/slang/slang-ir-check-differentiability.cpp b/source/slang/slang-ir-check-differentiability.cpp
index 36b210e73c3..d825bfcee78 100644
--- a/source/slang/slang-ir-check-differentiability.cpp
+++ b/source/slang/slang-ir-check-differentiability.cpp
@@ -3,6 +3,7 @@
 #include "slang-ir-autodiff.h"
 #include "slang-ir-inst-pass-base.h"
 #include "slang-ir-sccp.h"
+#include "slang-ir-util.h"
 #include "slang-rich-diagnostics.h"
 
 namespace Slang
@@ -467,11 +468,47 @@ struct CheckDifferentiabilityPassContext : public InstPassBase
             case kIROp_Call:
                 if (shouldTreatCallAsDifferentiable(inst))
                     return false;
-                return isDifferentiableFunc(
-                           diffTypeContext,
-                           as<IRCall>(inst)->getCallee(),
-                           requiredDiffLevel) &&
-                       diffTypeContext.isDifferentiableType(inst->getFullType());
+                {
+                    auto callInst = as<IRCall>(inst);
+                    auto callee = callInst->getCallee();
+                    if (!isDifferentiableFunc(diffTypeContext, callee, requiredDiffLevel) ||
+                        !diffTypeContext.isDifferentiableType(inst->getFullType()))
+                        return false;
+                    // The "no carrying input ⇒ no carrying output" tightening
+                    // (#11285) is only sound for functions whose result can
+                    // only depend on their arguments. A function with side
+                    // effects or memory reads (e.g. a texture/buffer load with
+                    // a user-supplied `[BackwardDerivative]`) can legitimately
+                    // produce a derivative from captured state without any
+                    // differentiable input. For non-readNone callees, fall
+                    // back to the original conservative behavior of treating
+                    // every differentiable-function call as carrying.
+                    if (!isReadNoneCallee(callee))
+                        return true;
+
+                    // For readNone callees, a call carries a derivative only
+                    // when at least one of its *differentiable-input*
+                    // arguments does. Arguments bound to a callee parameter
+                    // slot that is output-only or `no_diff` are skipped.
+                    auto calleeFuncType = as<IRFuncType>(callee->getFullType());
+                    UInt argCount = callInst->getArgCount();
+                    for (UInt i = 0; i < argCount; i++)
+                    {
+                        if (calleeFuncType && calleeFuncType->getParamCount() == argCount)
+                        {
+                            auto paramType = calleeFuncType->getParamType(i);
+                            auto [paramDirectionInfo, paramBaseType] =
+                                splitParameterDirectionAndType(paramType);
+                            if (paramDirectionInfo.kind == ParameterDirectionInfo::Kind::Out)
+                                continue;
+                            if (!diffTypeContext.isDifferentiableType(paramBaseType))
+                                continue;
+                        }
+                        if (carryNonTrivialDiffSet.contains(callInst->getArg(i)))
+                            return true;
+                    }
+                    return false;
+                }
             case kIROp_Load:
                 // We don't have more knowledge on whether diff is available at the destination
                 // address. Just assume it is producing diff if the dest address can hold a
@@ -509,10 +546,16 @@ struct CheckDifferentiabilityPassContext : public InstPassBase
         };
 
         // Run data flow analysis and generate `produceDiffSet` and an intial `expectDiffSet`.
+        // `carryNonTrivialDiffSet` derives from `produceDiffSet` plus its own
+        // phi-propagation, so the fixed-point loop must terminate only when
+        // BOTH sets stop growing — watching only `produceDiffSet` can miss a
+        // carry-set update that arrives via a back-edge call result (#11285).
         Index lastProduceDiffCount = 0;
+        Index lastCarryDiffCount = 0;
         do
         {
             lastProduceDiffCount = produceDiffSet.getCount();
+            lastCarryDiffCount = carryNonTrivialDiffSet.getCount();
             for (auto block : funcInst->getBlocks())
             {
                 if (block != funcInst->getFirstBlock())
@@ -529,6 +572,19 @@ struct CheckDifferentiabilityPassContext : public InstPassBase
                                 if (branch->getArgCount() > paramIndex)
                                 {
                                     auto arg = branch->getArg(paramIndex);
+                                    // Don't propagate derivative tracking into a
+                                    // block param whose type cannot hold a derivative
+                                    // (e.g. a `no_diff`-wrapped type). The author has
+                                    // explicitly opted that location out of derivative
+                                    // flow; propagating either set into it causes a
+                                    // downstream
+                                    // `LossOfDerivativeAssigningToNonDifferentiableLocation` false
+                                    // positive when the param is read and stored into a
+                                    // similarly-untracked location (#11285).
+                                    auto [_, paramBaseType] =
+                                        splitParameterDirectionAndType(param->getDataType());
+                                    if (!diffTypeContext.isDifferentiableType(paramBaseType))
+                                        continue;
                                     if (produceDiffSet.contains(arg))
                                         produceDiffSet.add(param);
                                     if (carryNonTrivialDiffSet.contains(arg))
@@ -582,7 +638,8 @@ struct CheckDifferentiabilityPassContext : public InstPassBase
                     }
                 }
             }
-        } while (produceDiffSet.getCount() != lastProduceDiffCount);
+        } while (produceDiffSet.getCount() != lastProduceDiffCount ||
+                 carryNonTrivialDiffSet.getCount() != lastCarryDiffCount);
 
         // Reverse propagate `expectDiffSet`.
         for (int i = 0; i < expectDiffInstWorkList.getCount(); i++)


Test patch:
diff --git a/tests/autodiff/no-diff-carry-fixed-point-positive.slang b/tests/autodiff/no-diff-carry-fixed-point-positive.slang
new file mode 100644
index 00000000000..e3a2fe086d6
--- /dev/null
+++ b/tests/autodiff/no-diff-carry-fixed-point-positive.slang
@@ -0,0 +1,46 @@
+// Positive companion for issue #11285: carry-set updates that arrive through
+// loop back-edges must trigger another propagation pass. Otherwise a later
+// differentiable call can be missed and E41031 can be lost.
+
+//TEST:SIMPLE(filecheck=CHECK): -target hlsl -stage compute -entry main
+
+// CHECK: error[E41031]
+// CHECK: derivative is lost
+
+public struct DirSample
+{
+    public float distance;
+}
+
+[Differentiable]
+public float pass_through(float x)
+{
+    return x;
+}
+
+[Differentiable]
+public void store_through_loop(float x, no_diff out DirSample sample)
+{
+    sample = {};
+
+    float v = 0.0;
+    [MaxIters(2)]
+    for (uint i = 0; i < 2; ++i)
+    {
+        float r = pass_through(v);
+        v = i == 0 ? x : r;
+    }
+
+    sample.distance = pass_through(v);
+}
+
+RWStructuredBuffer<float> out_buf;
+
+[shader("compute")]
+[numthreads(1, 1, 1)]
+void main()
+{
+    DirSample s;
+    store_through_loop(2.0, s);
+    out_buf[0] = s.distance;
+}
diff --git a/tests/autodiff/no-diff-carry-over-call.slang b/tests/autodiff/no-diff-carry-over-call.slang
new file mode 100644
index 00000000000..fc7119ed4b8
--- /dev/null
+++ b/tests/autodiff/no-diff-carry-over-call.slang
@@ -0,0 +1,104 @@
+// Regression tests for https://github.com/shader-slang/slang/issues/11285.
+//
+// A `[Differentiable]` function may legitimately assign to fields of a
+// `no_diff out` struct parameter using values derived purely from `no_diff`
+// inputs. The auto-diff checker's `isInstCarryingOverDiff` used to mark
+// every call to a differentiable function (`sqrt`, `dot`, `max`, …) as
+// carrying a non-trivial derivative regardless of whether any argument did,
+// which then false-positive-fired `E41031: derivative is lost during
+// assignment` at the store. The same defect existed in two other places:
+//  - block-param phi propagation copied carry-status into `no_diff`-typed
+//    block params (e.g. loop params reassigned from a differentiable call),
+//    which then leaked into reads of the param in later iterations.
+//  - the data-flow fixed-point loop watched only `produceDiffSet`, so
+//    a carry-set update arriving via a back-edge call result could miss
+//    the next propagation round.
+
+//TEST:SIMPLE(filecheck=CHECK): -target hlsl -stage compute -entry main
+
+// Compile must succeed (result code = 0) with no `derivative is lost`
+// diagnostic and no E41031 error. A trailing positive check for `void main(`
+// in the emitted HLSL confirms code generation completed.
+// CHECK: result code = 0
+// CHECK-NOT: error[E41031]
+// CHECK-NOT: derivative is lost
+// CHECK: void main(
+
+public struct DirSample
+{
+    public float3 pos_ws;
+    public float3 dir_ws;
+    public float distance;
+    public float pdf;
+}
+
+[Differentiable]
+public bool sample_dir(
+    no_diff const float3 pos_ws,
+    no_diff out DirSample sample,
+    out float3 weight)
+{
+    sample = {};
+    weight = float3(0);
+
+    float3 to_light = sample.pos_ws - pos_ws;
+    const float dist_sqr = max(1e-30f, dot(to_light, to_light));
+    sample.distance = sqrt(dist_sqr);
+    sample.pdf = dist_sqr / 1.0f;
+    return true;
+}
+
+// Falcor2-style: loop with a `no_diff` block-param reassigned from a
+// differentiable constructor call. Phi propagation must not copy carry-set
+// membership into the `no_diff`-typed loop param, and the fixed-point loop
+// must observe carry-set updates arriving via the back-edge.
+public struct Ray : IDifferentiable
+{
+    public float3 dir;
+    public float3 origin;
+}
+
+public struct PathState : IDifferentiable
+{
+    public float3 scatter_dir_ws;
+}
+
+[Differentiable]
+public void trace_path(
+    no_diff Ray ray,
+    out float3 color)
+{
+    PathState path;
+    path.scatter_dir_ws = float3(0);
+    color = float3(0);
+
+    [MaxIters(2)]
+    for (uint depth = 1; depth <= 2; ++depth) {
+        DirSample sample = {};
+        sample.dir_ws = ray.dir;   // E41031 candidate prior to fix
+        // Reassign ray from a differentiable constructor call. The call
+        // result is in carryNonTrivialDiffSet (it depends on path's diff
+        // fields); the back-edge then attempts to propagate that carry
+        // status into the `no_diff` loop param `ray`, which the fix blocks.
+        ray = Ray(path.scatter_dir_ws, path.scatter_dir_ws);
+        path.scatter_dir_ws = sample.dir_ws;
+    }
+    color = path.scatter_dir_ws;
+}
+
+RWStructuredBuffer<float> out_buf;
+
+[shader("compute")]
+[numthreads(1, 1, 1)]
+void main()
+{
+    DirSample s;
+    float3 w;
+    sample_dir(float3(0), s, w);
+
+    Ray r = { float3(1, 0, 0), float3(0) };
+    float3 c;
+    trace_path(r, c);
+
+    out_buf[0] = s.distance + s.pdf + c.x;
+}
diff --git a/tests/autodiff/no-diff-carry-positive.slang b/tests/autodiff/no-diff-carry-positive.slang
new file mode 100644
index 00000000000..f4290b39a9d
--- /dev/null
+++ b/tests/autodiff/no-diff-carry-positive.slang
@@ -0,0 +1,36 @@
+// Positive companion to no-diff-carry-over-call.slang for issue #11285:
+// verifies that the auto-diff check still *correctly* emits E41031 when
+// a derivative-carrying value is actually stored into a location that
+// cannot hold a derivative. We must not over-relax the analysis.
+
+//TEST:SIMPLE(filecheck=CHECK): -target hlsl -stage compute -entry main
+
+// CHECK: error[E41031]
+// CHECK: derivative is lost
+// CHECK: sample.distance = sqrt(x);
+
+public struct DirSample
+{
+    public float distance;
+}
+
+[Differentiable]
+public void store_through(float x, no_diff out DirSample sample)
+{
+    sample = {};
+    // `x` is a normal (diff-carrying) input. `sqrt(x)` carries a derivative.
+    // Storing it into the `no_diff` sample's distance field must still
+    // be flagged — this is what makes the diagnostic useful.
+    sample.distance = sqrt(x);
+}
+
+RWStructuredBuffer<float> out_buf;
+
+[shader("compute")]
+[numthreads(1, 1, 1)]
+void main()
+{
+    DirSample s;
+    store_through(2.0, s);
+    out_buf[0] = s.distance;
+}
diff --git a/tests/autodiff/no-diff-carry-readnone-only-positive.slang b/tests/autodiff/no-diff-carry-readnone-only-positive.slang
new file mode 100644
index 00000000000..f2fb1b3f179
--- /dev/null
+++ b/tests/autodiff/no-diff-carry-readnone-only-positive.slang
@@ -0,0 +1,57 @@
+// Positive companion to no-diff-carry-over-call.slang for issue #11285:
+// pins the soundness of the readNone-gated Call-rule optimization.
+//
+// A user-supplied `[BackwardDerivative]` callee with *no* differentiable
+// input arguments can still produce a derivative — its user-defined
+// derivative is free to read from captured buffers, textures, etc. The
+// auto-diff check cannot prove the result is derivative-free just because
+// every value-typed arg is `no_diff`, so storing the result into a
+// `no_diff` location must still fire E41031.
+//
+// The Call-rule tightening only applies when the callee is `[__readNone]`;
+// for everything else we fall back to "differentiable call always carries".
+
+//TEST:SIMPLE(filecheck=CHECK): -target hlsl -stage compute -entry main
+
+// CHECK: error[E41031]
+// CHECK: derivative is lost
+
+public struct DirSample
+{
+    public float distance;
+}
+
+void bwd_load_value(in uint index, in float.Differential dy)
+{
+    // User-supplied backward — primal/backward are decoupled, and the
+    // analysis cannot prove the derivative is zero from the signature.
+}
+
+// Non-readNone: a real load-style function with a user-defined backward.
+[BackwardDerivative(bwd_load_value)]
+float load_value(uint index)
+{
+    return 0.0;
+}
+
+[Differentiable]
+public void store_load_result(no_diff uint index, no_diff out DirSample sample)
+{
+    sample = {};
+    // `load_value(index)` has no differentiable input args (`uint` is
+    // non-diff). Under the readNone-gated tightening this falls back to
+    // "differentiable call always carries", so the store into the no_diff
+    // field must still be diagnosed.
+    sample.distance = load_value(index);
+}
+
+RWStructuredBuffer<float> out_buf;
+
+[shader("compute")]
+[numthreads(1, 1, 1)]
+void main()
+{
+    DirSample s;
+    store_load_result(0u, s);
+    out_buf[0] = s.distance;
+}