fix(autodiff): zero-safe cumprod gradient (no division by input)#5141
fix(autodiff): zero-safe cumprod gradient (no division by input)#5141AnayGarodia wants to merge 3 commits into
Conversation
|
How does the performance compare with the previous implementation on GPU? This is probably way slower. We should at the very least keep the original, faster implementation if there are no 0s in the input, and keep a note to reimplement with a scan/fold operation eventually like mentioned in #5141 |
The cumprod backward pass computed the gradient as `reverse_cumsum(grad * output) / input`, which divides by the input and produces NaN gradients whenever the input contains zeros. Replace it with the division-free formula `grad_input[i] = left[i] * tail[i]`, where `left[i] = prod(input[0..i])` is the exclusive prefix product and `tail[i] = grad[i] + input[i+1] * tail[i+1]` is a reverse accumulation. This never divides by `input[i]`, so it stays finite for zero inputs and matches PyTorch's `torch.cumprod` backward. Enable the four previously-ignored zero-input tests in the autodiff suite; they now verify the PyTorch-referenced gradients (e.g. input `[2, 0, 3, 4]` -> grad `[1, 32, 0, 0]`).
…lel form Split each lane at its first zero (as PyTorch's cumprod_backward does): the classic reverse_cumsum(grad * output) / input form before it, the omitted-product form at it, zero after it. Constant kernel count for all inputs, no data-dependent branching, no sequential loop along dim.
bbb4618 to
b553a97
Compare
|
Good call on the perf question — the sequential recurrence was indeed the weak point, and benchmarking it on wgpu/Metal surfaced a second problem: a few hundred chained slice/mul/add ops made the fusion pass fall over (repeated So I dropped the recurrence entirely and reworked the gradient into the masked parallel form PyTorch's
The regions are computed with Perf on an Apple M-series GPU (Metal via wgpu, fwd+bwd+sync, 50 iters — noisy laptop numbers, treat as ballpark):
Within noise on this machine, both are flat in n; the new form adds ~8 small elementwise kernels of constant overhead. A scan/fold primitive would still be nice for other ops, but this no longer depends on it. All 12 cumprod autodiff tests pass (zero-at-start/middle/end, multiple zeros, checkpointing). |
Fixes #3864.
Problem
The
cumprodbackward pass computedreverse_cumsum(grad * output) / input, dividing by the input and producing NaN gradients whenever the input contains zeros. Four autodiff tests for this case were#[ignore]d with a TODO pointing at this issue.Fix
Replace the division with the standard zero-safe formula
grad_input[i] = left[i] * tail[i], where:left[i] = prod(input[0..i])— exclusive prefix product (built by prepending1alongdim, dropping the last element, then an inclusive cumprod)tail[i] = grad[i] + input[i+1] * tail[i+1]— reverse accumulation alongdimThis never divides by
input[i], so it stays finite at zeros and matchestorch.cumprod's backward.Testing
Un-ignored the four zero-input tests (
zero_in_middle,zero_at_start,zero_at_end,multiple_zeros), whose expected gradients are taken from PyTorch — they now pass.Verified numerically against PyTorch references, e.g. input
[2,0,3,4]→ grad[1,32,0,0](was NaN). The fix uses only backend-agnostic tensor ops; I ran it against the ndarray backend.