Warp Debug Gradients
NVIDIAOfficialCreated by Warp TeamDistributed by NVIDIA
- Skill
- Review and audit
- Software development
- Apache-2.0
- Medium risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
Gradient bugs in Warp are rarely mathematical errors; they are almost always structural issues in how the tape records operations or how memory is managed during the backward pass. This skill provides a rigorous, evidence-based methodology to identify and fix these bugs. By moving from surface-level symptoms—such as training divergence, NaNs, or stalled loss—to verifying the underlying gradient signature, you can isolate issues like write-after-read overwrites, accidental gradient truncation, or incorrect taping patterns. The process emphasizes measurement over intuition, utilizing Warp's built-in verification tools and finite-difference checks to confirm the root cause before applying minimal, structural fixes.
What it does
- Identifies root causes of gradient-related failures in differentiable Warp workflows.
- Provides a systematic checklist for common bug patterns like buffer reuse and state aliasing.
- Guides the use of Warp's autograd verification tools to detect array access violations.
- Implements finite-difference harnesses to establish ground truth and validate gradient correctness.
- Helps distinguish between gradient bugs and other issues like learning rate or physics instability.
- Offers strategies for structural fixes, including gradient checkpointing and dataflow restructuring.
When to use it
- When training, optimization, or fitting through Warp kernels diverges, produces NaNs, or fails to converge.
- When loss stalls, plateaus unexpectedly, or produces biased results.
- When explicit gradient symptoms occur, such as exploding gradients, zero gradients, or gradcheck failures.
- When a pipeline works at small scale but fails at production scale or during validation.
- When you need to verify if a custom gradient implementation is correctly integrated into the backward pass.
Advantages
- Reduces time spent on trial-and-error tuning of physics or loss functions.
- Provides objective evidence for fixes through finite-difference comparison.
- Prevents common pitfalls like silent gradient clobbering and accidental truncation.
- Ensures fixes are structural and robust rather than just masking surface symptoms.
- Leverages official Warp diagnostic tools for reliable, version-aware verification.
Usage examples
- “My simulation training is producing NaNs after a few iterations. How do I debug this?”
- The agent will guide you to enable the autograd overwrite tracker, shrink the simulation to a minimal reproducible case, and run a finite-difference check to identify if the NaN originates from a specific kernel or a dataflow overwrite.
- “The gradients in my Warp model seem to be zero. What should I check?”
- The agent will help you verify if the loss is correctly connected to the tape, check for piecewise-constant operations that require surrogate gradients, and confirm that kernels are not compiled with enable_backward=False.
- “My optimization loss is worse than my reference implementation. Are my gradients wrong?”
- The agent will assist in setting up an end-to-end finite-difference harness to compare your autodiff gradients against the objective, confirming whether the discrepancy is a gradient bug or a separate issue like learning rate.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/nvidia/warp-debug-gradients/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip warp-debug-gradients.zip -d .agents/skills
Documentation by Warp Team
Debugging Gradients in Warp
Gradient bugs in Warp are almost never math bugs. The forward simulation looks perfectly healthy while the backward pass silently reads clobbered values, skips arrays, or double-counts adjoints. Users routinely burn days tuning physics knobs, loss functions, and assets when the real cause is a two-line taping-pattern fix. Your job is to find that fix with evidence, not intuition.
The single most important discipline: measure before hypothesizing. It is cheap for you to run a shrunk reproduction and compare autodiff against finite differences. The way the gradient is wrong (its signature) prunes the hypothesis space far faster than reading code ever will. Do not start proposing fixes from code reading alone — plausible-looking diagnoses of differentiability bugs are very often wrong, and an unverified "fix" that happens to perturb the numbers wastes everyone's time.
When to Use This Skill
Anything trained, optimized, calibrated, or fit through Warp kernels flows
through wp.Tape gradients — so when such a workflow misbehaves, gradients
are the prime suspect even if the user never says the word. Activate on the
symptoms users actually report: training that diverges, NaNs, or does
nothing; loss that stalls or plateaus above where it should; fits that
converge to a wrong or biased answer or are worse than a reference
implementation; pipelines that work at small scale but fail at production
scale or fail a QA recheck. Also activate on explicit gradient symptoms —
exploding, NaN/inf, zero, or subtly wrong gradients,
wp.autograd.gradcheck failures, suspected wp.Tape/backward issues — and
when the user asks whether their gradients can be trusted.
Do not activate for forward-only Warp work (kernel authoring, rendering, performance tuning), Warp build or installation problems, autograd questions in other frameworks with no Warp involvement, or pure performance work on a backward pass whose gradients the user has already validated.
The canonical background is Warp's own documentation — consult the relevant
section before diagnosing in its territory (online at
https://nvidia.github.io/warp/stable/; in a Warp source checkout the same content
is under docs/user_guide/; pip installs do not include it):
The "Differentiability" guide — especially "Array Overwrites", "Debugging Gradients", "Array Overwrite Tracking", and "Limitations and Workarounds" (in-place math, component assignment, dynamic loops).
The FAQ, section "Differentiation and Interoperability" — what state a tape does and does not preserve, and checkpointing.
Prerequisites
Executing this skill assumes all of the following; if one is missing, surface that to the user instead of improvising around it:
The user's script (or a faithful reproduction) is available in the workspace, runnable, and modifiable — diagnosis executes it repeatedly and edits it to apply fixes.
This skill's
references/files (quick-checks.md, verification.md, custom-gradients.md, case-studies.md) accompany it and are consulted at the steps that cite them.
Instructions
Note the user's Warp version first (
wp.__version__or the banner Warp prints at init). Several verification behaviors changed in Warp 1.17 — copy-adjoint accumulation, overwrite-warning call sites, read-flag lifetime,gradcheck'srestore_inputs— and the references mark each with a version caveat. On Warp < 1.17, a whole bug class exists that later versions fixed (quick-checks §1's version caveat), and some tools need workarounds.Reproduce and shrink. Get the user's script running, then cut it down: fewer particles/elements, fewer time steps, fewer optimizer iterations, CPU device if the sim allows. You need a repro that runs in seconds, because you will run it many times. Keep the structure (number of kernels, the taping pattern, buffer reuse) intact — that is where the bug lives. Shrinking the physics is fine; restructuring the dataflow is not.
If the script cannot be made to run (missing dependencies, broken code), report the blocking issue as the deliverable and stop — do not proceed to verify a program that never ran.
Instrument and establish ground truth (details and templates in
references/verification.md):Set
wp.config.verify_autograd_array_access = Truebefore module load and rerun under an active tape. Capture every warning. This catches the single most common bug class (write-after-read overwrites) nearly for free. Know its blind spots: it needs a tape, it cannot see arrays stored inside Warp structs, and it disables kernel caching (expect a kernel rebuild — JIT module recompilation only, not a rebuild of the native library). If the tracker runs clean but gradients are still wrong, specifically check for in-place mutations of arrays held inside Warp structs (quick-checks §1 and Limitations) before trusting the clean result.Run one end-to-end finite-difference check: wrap the full forward pass (sim steps + loss) in a Python callable and hand it to
wp.autograd.gradcheckwith the true optimization inputs — it compares the autodiff gradient against central differences, restoring array inputs between evaluations (Warp 1.17+) so in-place-mutating forwards are checked from pristine state; on older Warp use the manual harness inreferences/verification.md. The reference is the user's actual objective over the full horizon, compared against the gradient the optimizer actually consumes — never a narrower window (seereferences/verification.md). This confirms gradients are actually wrong (users are sometimes wrong about this — report "gradients are correct" findings honestly) and yields the error signature. The template inreferences/verification.mdfixes the eps/tolerance choices and the seed-pinning a stochastic forward needs — do not eyeball pass/fail against floating-point or sampling noise. If the backward pass runs out of memory while establishing ground truth, apply the checkpointing pattern from "Edge case: out of memory" below before proceeding.
Match the signature against the table below to rank hypotheses.
Scan the code against the known-pattern checklist (
references/quick-checks.md). This is fast for you — do it in the same pass, but let the signature decide which findings are plausible causes versus incidental smells.Localize if still ambiguous. Binary-search the pipeline: truncate to K steps and find where FD and autodiff first diverge; run
wp.autograd.gradcheck_tapeto test each recorded launch in isolation. Remember gradcheck_tape validates kernels individually — it is structurally blind to inter-kernel overwrites, so a clean per-kernel pass plus a wrong end-to-end gradient points at the taping pattern, not the kernels. It also silently skips kernels compiled withenable_backward=False(see Limitations) — if any kernel in the pipeline sets that, a clean pass says nothing about it; verify it separately.Fix minimally, then re-verify with the exact same FD harness that established the failure. A gradient fix without a before/after FD comparison is not a fix. Verify the exact program you are shipping — the fixed file as it stands, every line included — never a re-implementation of it in a diagnostic script: a rebuilt pipeline silently drops whatever you believed was irrelevant, and if that belief is wrong the verification passes while the shipped code stays broken. Mechanically: the harness must import the fixed module (or execute the fixed file) and call into it — the only code that may live outside the shipped program is the FD driver itself. Also rerun the overwrite tracker to confirm the warnings are gone. "Minimally" applies to the code diff, not the diagnosis: when the root cause is structural (e.g., accidental gradient truncation, quick-checks §8), the minimal correct fix is the restructure — do not substitute a smaller change that only silences the surface symptom.
Close the loop on the user's original complaint. Rerun their actual workflow (their script, their printed metrics). The job is done when the symptom they reported is resolved — an optimization that was "exploding" should now demonstrably improve its objective, not merely avoid NaN. If gradients verify correct at the full horizon but training still fails, that is a new signature-table entry, not a victory; keep diagnosing (or report the verified gradients and the remaining non-gradient cause, e.g. learning rate).
Failure signatures
| Signature | Leading hypotheses |
|---|---|
| Gradients exactly zero | Missing requires_grad=True somewhere in the chain (note wp.zeros defaults to False; zeros_like/clone inherit from source); enable_backward=False at module/kernel level; loss array not connected to the tape; grads read after tape.zero(); a piecewise-constant op (round/floor/sign/cast/threshold) in the chain — there zero is correct and the fix is a surrogate gradient such as a straight-through estimator, not a bug hunt (quick-checks §9c); on Warp < 1.17, a tape-recorded copy/clone whose source has other downstream readers (see the version caveat in references/quick-checks.md) |
| Gradients grow without bound across optimizer iterations | Missing tape.zero()/tape.reset() between iterations; state-object aliasing that carries an in-tape overwrite across frames (case study 1) |
| Off by an exact small factor (2x, Nx) | Double accumulation: a duplicate launch recorded on the tape — note that since Warp 1.13 the store adjoint consumes the output gradient on first use, so a bare duplicate is inert unless the rewritten array has retain_grad=True (quick-checks §7) or the Warp version is older; overlapping tape scopes taping the same work twice. Also: a backward seed that does not match the stated objective — seeding a per-element loss adjoint with ones backpropagates the sum, exactly N× the mean objective's gradient |
| NaN or inf | Non-differentiable point evaluated in the backward pass (wp.sqrt(0), wp.length(0), wp.normalize(0), division) — needs a custom gradient (references/custom-gradients.md) or, better, a stable reformulation; an overflow evaluated in the unselected branch of wp.where (a select, not a branch — quick-checks §9b); dynamic-loop local not recomputed during replay (documented to produce inf) |
| Subtly wrong, often worse with more steps/iterations | Write-after-read overwrite: wp.copy onto an already-read array, ping-pong buffers within one tape, Python rebinding that aliases two "different" states (case studies); in-place *=//=; vector/matrix component reassignment; dynamic-loop intermediates; on Warp < 1.17, a recorded copy/clone that is not the last consumer of its source (version caveat in references/quick-checks.md) |
| Per-window FD agrees but full-horizon FD disagrees; or gradients "verified" yet the optimizer stalls or worsens the loss | Accidental gradient truncation: a tape-per-step loop with backward inside it and state carried between tapes optimizes a different objective than the one being reported (see quick-checks §8). The structural fix is one tape over the whole horizon with total_steps + 1 distinct state buffers. The solver-space analog: a partially converged iterative solve inside the tape makes FD and autodiff agree on the wrong program — converge it outside the tape and warm-start the taped iterations (quick-checks §8) |
| Gradients disagree (vs a reference implementation or run-to-run) only on a sparse, data-dependent subset; forward outputs match to float precision | Under-determined forward choice at a non-smooth point (quick-checks §9): both answers can be valid subgradients, and FD cannot adjudicate at a kink. Check whether the discrete choice differs at exactly the mismatching elements before hunting corruption |
| FD and autodiff agree at the full horizon but optimization still fails | Not a gradient bug. Say so. Look at learning rate, loss landscape, physics stability — and report the verified-correct gradients as the finding |
Examples
A representative session, end to end. A user reports "my cloth sim trains for a while, then the loss creeps back up — tuning the learning rate doesn't help." No mention of gradients; the leap is made because the workflow optimizes through Warp kernels.
Their script runs 512 particles for 200 steps per iteration. Shrink to 16 particles, 10 steps, CPU — repro now runs in ~2 s and shows the same creep.
wp.config.verify_autograd_array_access = Trueunder the tape prints:array ... was read from kernel integrate and is now being written to by kernel integrate— a write-after-read overwrite.End-to-end
wp.autograd.gradcheckon the shrunk repro: max relative error 0.4 against finite differences. Gradients are confirmed wrong, with the "subtly wrong, worse with more steps" signature.The signature row plus quick-checks §1 point at buffer reuse inside one tape: the sim steps
state_a → state_b → state_a, ping-ponging two buffers, so the backward pass reads clobbered states.Minimal fix: allocate
num_steps + 1distinct state buffers recorded on the tape (physics untouched; only the dataflow changes).Re-verify: same gradcheck harness now passes (max relative error 3e-4); the overwrite warning is gone; the user's full-size training run now decreases monotonically.
Report: root cause (in-tape buffer reuse), the evidence chain (warning + before/after FD numbers), the two-line diff, and a pointer to the "Array Overwrites" section of the Differentiability guide.
Reporting
Lead with the root cause and the evidence chain: the FD-vs-autodiff numbers that established the failure, the warning or localization step that found the cause, the minimal diff, and the FD numbers after the fix. Name the documentation section that covers the pattern so the user can read the canonical explanation. If you checked patterns that came up clean (e.g., the overwrite tracker found nothing), say so — it tells the user what has been ruled out.
If the user is only asking whether their gradients are trustworthy, stop after verification and report; apply fixes when they ask for fixes.
Preserve the evidence: leave the diagnostic scripts (FD harness, shrunk repro) in the workspace and list them in the report instead of deleting them — they are the reproducible half of the evidence chain, and the user or a reviewer should be able to rerun the exact verification that justified the fix. Never delete files you did not create.
Limitations
The verification tooling has blind spots — a clean pass through any one
tool is not a clean bill of health (details in
references/verification.md):
The overwrite tracker requires an active tape, cannot see arrays stored inside Warp structs, and disables kernel caching while enabled.
wp.autograd.gradcheckdoes not accept struct inputs; wrap the forward in a callable over the underlying arrays. On Warp < 1.17 it does not restore mutated array inputs between evaluations (use the manual harness).wp.autograd.gradcheck_tapevalidates each recorded launch in isolation — it is structurally blind to inter-kernel overwrite bugs and silently skips kernels compiled withenable_backward=False.The
*=//=non-differentiability warning is emitted only at codegen time underwp.LOG_DEBUG, so its absence from a normal run means nothing.Warp has no built-in gradient checkpointing; long-horizon memory pressure needs the application-level pattern below.
At non-smooth points (ties, kinks, argmin selections), finite differences cannot adjudicate between valid subgradients — FD-vs-AD disagreement there is not automatically a bug (quick-checks §9).
Edge case: out of memory
If the backward pass fails to allocate (long simulations keep every
intermediate state alive on the tape), the fix is gradient checkpointing:
save periodic states, replay the segments between them during backward. Warp
has no built-in utility — applications implement it themselves. Use
warp/examples/optim/example_fluid_checkpoint.py as the reference pattern,
and see the FAQ's "Differentiation and Interoperability" section.
Reference files
references/quick-checks.md— the known-bug-pattern checklist with doc pointers and the caveats that make each pattern easy to miss.references/verification.md— tooling details: overwrite tracker setup and blind spots, end-to-end FD harness template,wp.autogradgradcheck/jacobian usage and caveats, tape visualization, bisection.references/custom-gradients.md—@wp.func_grad,@wp.func_replay,@wp.func_native: when they are required and how they are misused.references/case-studies.md— two real debugging sagas (state aliasing; differentiable-copy overwrite) showing how subtle the surface symptoms are. Read these when the checklist comes up clean — they calibrate what "subtle" means here.
Files
- ATTRIBUTION.mdAdded by AutoGineer921 B
- BENCHMARK.md4.4 KiB
- LICENSELicence file added by AutoGineer11.2 KiB
- SKILL.md18.7 KiB
- evals/EVAL.md10.4 KiB
- evals/evals-full.json68.9 KiB
- evals/evals.json13.7 KiB
- evals/files/checkpoint_control.pyScript3.8 KiB
- evals/files/compound/objective.pyScript702 B
- evals/files/compound/physics.pyScript2.3 KiB
- evals/files/compound/state.pyScript1.7 KiB
- evals/files/compound/train.pyScript3.0 KiB
- evals/files/compreassign/model.pyScript2.1 KiB
- evals/files/compreassign/train.pyScript3.1 KiB
- evals/files/contact.pyScript2.3 KiB
- evals/files/dac.pyScript1.7 KiB
- evals/files/deblur.pyScript2.3 KiB
- evals/files/dynloop/model.pyScript1.7 KiB
- evals/files/dynloop/train.pyScript2.7 KiB
- evals/files/emitters.pyScript2.5 KiB
- evals/files/funcgradassign/model.pyScript1.4 KiB
- evals/files/funcgradassign/train.pyScript2.1 KiB
- evals/files/gain_train.pyScript1.9 KiB
- evals/files/heightfit.pyScript2.6 KiB
- evals/files/huber_fit.pyScript2.2 KiB
- evals/files/inplacemul/model.pyScript1.2 KiB
- evals/files/inplacemul/train.pyScript1.9 KiB
- evals/files/lattice.pyScript2.7 KiB
- evals/files/lens_calibrate.pyScript2.0 KiB
- evals/files/missingzero/model.pyScript1.8 KiB
- evals/files/missingzero/train.pyScript1.1 KiB
- evals/files/nangrad/model.pyScript1.4 KiB
- evals/files/nangrad/train.pyScript1.2 KiB
- evals/files/optlib.pyScript881 B
- evals/files/replaycounter/sim.pyScript2.6 KiB
- evals/files/replaycounter/train.pyScript1.5 KiB
- evals/files/retaingrad/model.pyScript2.3 KiB
- evals/files/retaingrad/train.pyScript2.2 KiB
- evals/files/sensor_train.pyScript1.3 KiB
- evals/files/solver_design.pyScript2.8 KiB
- evals/files/springs.pyScript3.5 KiB
- evals/files/stiffcontrol/sim.pyScript2.6 KiB
- evals/files/stiffcontrol/train.pyScript1.3 KiB
- evals/files/structwar/sim.pyScript5.7 KiB
- evals/files/structwar/train.pyScript2.8 KiB
- evals/files/tapescope/sim.pyScript3.1 KiB
- evals/files/tapescope/train.pyScript1.8 KiB
- evals/files/torch_train.pyScript1008 B
- evals/files/walker/sim.pyScript5.5 KiB
- evals/files/walker/train.pyScript4.1 KiB
- evals/files/warp_layer.pyScript1.4 KiB
- evals/files/writeback/solver.pyScript1.6 KiB
- evals/files/writeback/train.pyScript3.3 KiB
- evals/files/zerograds/pipeline.pyScript3.0 KiB
- evals/files/zerograds/train.pyScript1.4 KiB
- references/case-studies.md6.8 KiB
- references/custom-gradients.md4.4 KiB
- references/quick-checks.md17.7 KiB
- references/verification.md8.7 KiB
- skill-card.md4.1 KiB
- skill.oms.sig17.2 KiB
references/quick-checks.md
Known-pattern checklist
Scan the user's code for each pattern below. For every hit, note it with file
and line, but rank it against the failure signature before declaring it the
root cause — several of these can be present and harmless in a given script.
Doc section names refer to the Differentiability guide
(https://nvidia.github.io/warp/stable/user_guide/differentiability.html; in a Warp
source checkout, docs/user_guide/differentiability.rst).
Tier 1 — cheap structural checks
1. Write-after-read array overwrites (the most common killer)
Docs: "Array Overwrites" and "Array Overwrite Tracking".
Warp propagates gradients only through the final write to an array. Any array that is read on the tape and then written later on the same tape computes its earlier adjoints from clobbered values. The rule is one sentence; the disguises are many:
wp.copy(dst, src)wheredstwas already an input to an earlier taped launch.wp.copy/wp.clone/array.assignare differentiable and tape-recorded — that is exactly why copying onto an already-read array is dangerous. On Warp 1.17+ the corruption occurs when the copy changes the values the earlier launches read (their adjoints replay against the new contents — the tape does not snapshot); a write-back that preserves values (e.g. an identity/clipping stage that did not engage) computes correct gradients, because the copy adjoint routes and consumes gradients properly. On Warp < 1.17 the pattern is corrupt regardless of values (the copy adjoint also misrouted gradients around the intervening stage). The tracker warns in all cases, and the fix is the same everywhere: rebind the Python reference (state.q = new_q) instead of copying in place.Python rebinding that makes two "different" states the same object:
states[0] = states[-1]between frames makes substep 0's read and the last substep's write hit the same array on the next frame's tape. Copy data or rotate distinct buffers instead.Ping-pong / double-buffer schemes where a buffer written in step N+1 was read in step N of the same tape.
.zero_()orfill_()on an array already read on the tape (that is a write too). Therequires_gradbranch in solvers typically allocates fresh arrays (wp.zeros_like,wp.clone) per iteration instead — that pattern is correct, not wasteful.
Benign lookalikes: x[tid] += ... and wp.atomic_add are fine (adjoint
accumulation is handled); write-then-read within a single kernel is fine;
rebinding a Python variable to a fresh array per iteration
(out = wp.clone(inp) ... inp = out) is fine because every write targets
new memory.
Version caveat (Warp < 1.17): the adjoint of a tape-recorded copy
(wp.copy/wp.clone/array.assign) overwrites the source array's
gradient instead of accumulating into it. The general rule: a recorded
copy is safe only if it is the LAST consumer of its source in forward
order. If any later taped operation reads the source, the backward pass
runs those adjoints first, and the copy's adjoint then replaces everything
they accumulated with the destination's adjoint alone. The observed
signature depends solely on what the destination's adjoint holds at that
moment:
destination subsequently fully overwritten (its adjoint consumed and zeroed by the overwriter's backward): source gradient becomes exactly zero — e.g.
out = wp.clone(q); kernel(q -> out); q = out, iterated;destination still carries adjoint (read by the loss, or only partially rewritten — snapshot copies, ping-pong buffers, partial-update solvers): source gradient is silently wrong by a large, data-dependent factor — no NaN, no warning, plausible-looking numbers.
No overwrite warning fires in either case (there is no write-after-read in
the forward pass) — invisible to the tracker; suspect it whenever a
clone/copy's source has any downstream reader. Fixed in Warp 1.17
(copy adjoints accumulate). Workarounds on affected versions, in order:
reorder the copy last; replace it with a trivial @wp.kernel
(out[i] = src[i]) — kernel adjoints accumulate correctly; or
wp.empty_like when the cloned contents are never consumed.
Always confirm with the tracker (references/verification.md) rather than by
eye — and remember it cannot see arrays stored inside Warp structs, so a
clean tracker run does not clear struct-held arrays.
2. Missing requires_grad in the chain
wp.zeros/wp.empty/wp.array(...) default to requires_grad=False;
zeros_like/ones_like/full_like/clone/empty_like inherit from the
source array. One non-differentiable array anywhere in the chain silently
zeroes everything upstream of it. Check every intermediate the tape flows
through, not just the optimized inputs and the loss. For long chains, dump
tape.visualize() — arrays with requires_grad=True render green, others
grey, which makes the break visible at a glance (pass inputs=/outputs= to
wp.launch or the graph loses structure).
Related: enable_backward=False at global, module
(wp.set_module_options), or kernel (@wp.kernel(enable_backward=False))
level means no adjoint is even compiled. tape.backward() warns about this;
gradcheck_tape silently skips such kernels.
3. Missing tape.zero() / tape.reset() in the optimization loop
tape.backward() accumulates into .grad arrays. Without zeroing between
iterations, gradients grow monotonically and training "explodes" with
perfectly correct per-iteration gradients. Conversely, reading grads after
zeroing yields zeros.
3b. Tape-scope hygiene
Only the forward pass belongs inside with tape:, and the loss computation
should be the last thing recorded. A common indentation slip keeps
post-loss work (gradient reads, optimizer steps, logging, state resets)
inside the scope — those launches get recorded too, can introduce spurious
write-after-reads against arrays the forward pass consumed, and bloat the
backward pass. Real users have lost time to exactly this; it is a ten-second
check of the with block's extent.
The subtler half of the rule: moving such launches outside the with
block is not sufficient if they still run before tape.backward(). The
backward pass replays adjoints against the arrays' current contents, so a
state reset or in-place overwrite between recording and backward clobbers
values the replay needs — while silencing the tracker (the write is no
longer recorded, so nothing warns). Housekeeping that writes taped arrays
belongs strictly after tape.backward().
4. In-place multiplication / division
Docs: "In-Place Math Operations". += and -= are differentiable;
*= and /= are not — they produce wrong backward results, and the
only warning is emitted at codegen time when wp.config.log_level = wp.LOG_DEBUG, so users never see it. Rewrite as a[i] = a[i] * b[i]... which
is itself an overwrite — the correct fix is writing the product to a distinct
output array.
5. Vector / matrix / quaternion component reassignment
Docs: "Vector, Matrix, and Quaternion Component Assignment". Each component
of a locally constructed vec/mat/quat may be assigned (v[0] = x) at most
once; after that, only +=/-= updates are safe. A second direct assignment
invalidates gradients for the whole object. Escape hatch:
wp.config.enable_vector_component_overwrites = True (significant compile
time cost).
6. Dynamic loops
Docs: "Dynamic Loops". Dynamic (non-unrollable) loops are not replayed or unrolled in the backward pass, so local variables hold their final values when the adjoint runs. Three documented failure modes:
Multiplicative accumulation (
prod *= x[i]) — wrong adjoints.Any adjoint that depends on a loop-carried intermediate.
A local computed in the loop and used after it (e.g., a norm) is zero at adjoint time →
inf/NaN gradients.
Loops with only +=/-= accumulation are safe. Workarounds, in order of
preference: make the trip count static so it unrolls (respect max_unroll,
default 16, per-module option); store intermediates in an array indexed by
iteration; move the loop body into a @wp.func (forces replay — but
documented as valid only for simple add/subtract accumulation).
7. retain_grad=True double-counting
retain_grad disables the gradient-zeroing that enforces final-write-wins.
On an array whose elements are written more than once, it double-counts.
Only safe on arrays written at most once.
8. Accidental gradient truncation (tape-per-step training loops)
The highest-value structural check, because it is invisible to every numeric tool run at the wrong scope. The pattern:
for frame in range(num_frames): # inside one training iteration
tape = wp.Tape()
with tape:
...substeps...; loss_kernel(...)
tape.backward(loss) # backward INSIDE the step loop
accumulate(param_grad, params.grad) # grads summed across frames
carry_state_forward() # outside any tape
apply_optimizer(param_grad)Every per-frame gradient here is correct — and their sum is still the wrong gradient for the objective being optimized. Each backward treats the window's incoming state as a constant, so all paths where the parameters influence later windows through the carried state are dropped. For objectives where accumulated state matters (locomotion, control, anything episodic), the dropped terms dominate. The frame loop is just the most common shape: the same error occurs with any partitioning of one objective into per-window tapes — per-stage tapes in a multi-phase solve, per-chunk tapes over a long sequence — whenever state or coupling crosses the window boundary. Symptoms: per-window FD matches beautifully; full-horizon FD does not; the optimizer converges to a stationary point of the truncated objective while the reported loss stalls or worsens.
The fix is structural:
One tape over the entire horizon:
with tape:wraps all windows; a singletape.backward(loss)after the loop; the optimizer consumesparam.graddirectly — no cross-window accumulation buffer.One more state buffer than total steps (
total_steps + 1), all distinct. Reusing per-window state buffers inside the full-horizon tape is a write-after-read overwrite.Per-step allocations for every taped intermediate (control inputs, activations, per-window loss contributions) — the same rule; a single reused scratch array overwritten each window corrupts the backward pass.
Fresh initial state each training iteration — episodes should not start where the previous optimization step's rollout ended.
Accumulate the scalar loss across windows on the tape (e.g.,
wp.atomic_add(loss, 0, window_term)).
All of these are one principle: every write inside one tape needs its own
memory, and the tape must span the horizon the objective spans. The memory
cost is real (state count scales with the full horizon); when it does not
fit, the correct escape is gradient checkpointing
(warp/examples/optim/example_fluid_checkpoint.py), never silent per-frame
truncation. If the user knowingly wants truncated BPTT as a cheaper
approximation, that is a legitimate choice — but it must be named,
and "gradients verified" claims must then be scoped to the window.
The same truncation bug occurs in solver space: differentiating through a partially converged iterative solver (fixed-point / Jacobi / relaxation iterations inside the tape) optimizes the truncated map, not the equilibrium. The trap signature is unique: FD and autodiff AGREE — both differentiate the same truncated program — while a converged re-simulation of the "optimal" result misses the target. Fixes, best first: (1) converge the solve outside the tape (untaped, reusable buffers) and warm-start a short taped iteration block from the fixed point — the taped steps map the solution to itself, gradients become exact at the solution, and tape memory is unchanged (a truncated-Neumann form of implicit differentiation; the right answer when memory motivated the truncation in the first place); (2) if memory allows, converge the taped solve itself; (3) full implicit differentiation via an adjoint solve at the fixed point.
9. Under-determined forward choices (ties, ordering, nondeterminism)
A principle, not a pattern: gradients are defined relative to the forward
pass that actually ran. Autodiff differentiates the branches and discrete
selections the execution took (closest-element queries, argmin/argmax,
conditionals), not the mathematical ideal. Wherever those choices are
under-determined — exact ties, traversal order, atomic scheduling — different
implementations, or different runs, can legitimately make different choices
and produce different-but-equally-valid gradients at those points. The
objective is only piecewise smooth there; any subgradient is correct, and FD
cannot adjudicate between them (see references/verification.md).
Consequences for diagnosis:
A gradient mismatch (vs a reference implementation, or run-to-run) that is sparse, data-dependent, and localized to choice boundaries — while forward outputs agree to float precision — is ambiguity, not corruption. Probe: does the choice differ at exactly the mismatching elements (e.g., a near-zero margin between best and second-best candidates)? Does nudging inputs off the boundary dissolve the mismatch? If so, report it as expected non-smoothness and surface the real question to the user: is bitwise gradient parity actually a requirement, or is any valid subgradient acceptable for their optimization?
Mismatches that are dense, or sit far from any choice boundary, are corruption — go back to the tier-1 checks.
Benign float jitter is the trivial sibling: atomics make reduction order nondeterministic, so run-to-run gradient noise at float precision is normal; O(1) differences are not.
The same principle in bug form: the backward pass must see the same
execution the forward took. Saving and reusing forward selections in a
custom backward, @wp.func_replay for side effects, and keeping taped
arrays unmutated until tape.backward() are all instances — violating any
of them means differentiating a function that never ran.
9b. NaN from the unselected branch of wp.where
wp.where/wp.select are selects, not branches: BOTH operand expressions
are evaluated in the forward pass, and BOTH adjoint chains run in the
backward pass. A guard like wp.where(z > 20.0, z, wp.log(1.0 + wp.exp(z))) keeps the forward value finite everywhere, but the unselected
exp(z) still overflows once z exceeds ~88.7 (float32); its adjoint then
contributes inf * 0 = NaN, poisoning the gradient — while every forward
value stays correct and any test data below the overflow threshold passes.
Real if/else control flow in kernels does not have this property
(only the taken branch's adjoint executes). Fixes, best first: (1)
reformulate stably so no operand can overflow — e.g. softplus(z) = wp.max(z, 0.0) + wp.log(1.0 + wp.exp(-wp.abs(z))) removes the guard
entirely; (2) use a real if/else; (3) clamp the unselected
operand's input (the double-where trick) or supply a custom
@wp.func_grad. Prefer removing the hazard over guarding it.
9c. Piecewise-constant ops: zero gradient is correct — use a surrogate
Ops whose output is locally flat — wp.round, wp.floor, wp.ceil,
wp.trunc, wp.sign, integer casts, comparisons, quantize/threshold
stages built from them — have derivative zero almost everywhere. Autodiff
returning exactly zero gradients through such a stage is mathematically
correct, not a bug: there is no corruption to find, and no taping-pattern
fix will help. Two things make this case easy to misread:
Finite differences disagree with autodiff here, loudly: an FD probe steps across quantization boundaries and reports staircase secants (large, step-size-dependent values) while autodiff reports zero. That FD-vs-AD mismatch is expected non-smoothness, not evidence of a broken backward pass — do not spend time hunting corruption. (Contrast §9: there FD cannot adjudicate; here FD is simply measuring the secant of a staircase.)
The training symptom is identical to a severed
requires_gradchain (§2): loss constant, upstream gradients exactly zero. Distinguish them by locating where gradients die — at a flat op, it is this case.
The fix is a surrogate gradient, not a repair: keep the exact forward
(the quantization is usually the point) and supply the backward you want
to optimize with. The standard choice is the straight-through estimator
(STE) — pass the incoming adjoint through as if the op were the identity —
via @wp.func_grad on a wrapped @wp.func (template in
references/custom-gradients.md); clipped or scaled variants slot in the
same way. Report the situation honestly: the zero gradients were correct,
the surrogate is a modeling decision the user should own.
Tier 2 — custom gradient machinery
Check both directions (details in references/custom-gradients.md):
Present and misused:
@wp.func_gradadjoints that assign instead of accumulate (wp.adjoint[x] = ...vs+=), signature mismatches, replay functions that don't mirror the forward signature, generic-typed forwards (unsupported — raises at registration).Absent but required: forward passes that evaluate non-differentiable points (
sqrt,length,normalizeat zero; division) need a@wp.func_grad; kernels with side effects the replay would repeat (atomic counters) need@wp.func_replay;@wp.func_nativesnippets need anadj_snippetto participate in backward at all.
Note a correctly written @wp.func_grad can still be defeated by a Tier-1
bug around it — the gradient-clipping case study in
references/case-studies.md is exactly this.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Python
The author says: Requires a working NVIDIA Warp installation (>= 1.13 minimum; >= 1.17 recommended for reliable verification — copy-adjoint accumulation, overwrite-warning call sites, read-flag lifetime, and gradcheck's restore_inputs changed in 1.17 and are version-caveated in the references; on older versions a fixed bug class still exists and some tools need workarounds). Diagnosis runs the user's reproduction, so a functioning device (CPU or CUDA) is needed.
Security
What our scanner noticed· Medium risk
- Large base64-encoded blobs
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Warp Team
- Publisher
- NVIDIA (Official)
- Distributed by
- NVIDIA
- Source
- NVIDIA/skills · skills/warp-debug-gradients · commit fce3cca
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE-APACHE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Warp Team or NVIDIA.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download warp-debug-gradients.zip