Skip to content

Divide instead of multiplying by cached reciprocals (fixes #239) - #298

Merged
devmotion merged 5 commits into
masterfrom
dmw/fix-division-precision
Sep 18, 2026
Merged

devmotion merged 5 commits into
masterfrom
dmw/fix-division-precision

Conversation

@devmotion

@devmotion devmotion commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Fixes #239.

The reverse rules for mean and elementwise / and \ multiplied the adjoint by a precomputed reciprocal formed in the type of the denominator, so an integer or Float64 denominator capped the gradient at Float64 accuracy regardless of the deriv type. Both now divide instead, and the / and \ instructions no longer cache the numerator partial at all — broadcast_increment_div_deriv! divides by value(b) during propagation, mirroring broadcast_mul.

This also fixes two overflows. inv(d) is Inf for subnormal d, so 1e-10 * sum(y ./ 1e-310) had an infinite gradient where the exact 1e300 is representable. And denom_partials_kernel formed -n / (d * d), but d * d overflows to Inf (and underflows to zero) while -n / d^2 is still finite and normal:

ReverseDiff.gradient(v -> sum([1e-200] ./ v), [1e-200])  # was [-Inf], exact -1e200
ReverseDiff.gradient(v -> sum([1e200]  ./ v), [1e200])   # was [0.0],  exact -1e-200

It now divides twice, which never forms the square and is the same two roundings, so accuracy in the normal range is unchanged: over 200k random (n, d) pairs the mean relative error against a BigFloat reference is 4.007e-17 before and 4.017e-17 after. The kernel backs both ./ and .\.

Separately, scalar accumulations in propagation.jl now go through sum, whose pairwise reduction is both more accurate and faster than the sequential loops it replaces. On a 100k-element broadcast into a scalar adjoint this cuts the relative error from 6.9e-15 to 3.6e-17 — the failure mode the removed docstring predicted, and the divide-and-conquer fix it proposed.

test/PrecisionTests.jl compares against closed-form gradients at 256-bit precision. It runs after LinAlgTests, which asserts that StatisticsExt is still unloaded when it starts. The one @test_broken is a fused broadcast, which needs an upstream fix in ForwardDiff._div_partials.

🤖 Generated with Claude Code

The reverse rules for `mean` and elementwise `/` and `\` multiplied the
adjoint by a precomputed reciprocal. That reciprocal was formed in the type
of the denominator, so an integer or `Float64` denominator capped the
gradient at `Float64` accuracy regardless of the deriv type, and `inv(d)`
overflowed to `Inf` for subnormal `d`.

Both rules now divide. `mean` divides by `length(input)` directly, and the
`/` and `\` instructions no longer cache the numerator partial at all:
`broadcast_increment_div_deriv!` divides by `value(b)` during propagation,
mirroring how `broadcast_mul` already reads `value(b)` in its reverse pass.
This drops one cache array per instruction.

Scalar accumulations in `propagation.jl` now go through `sum`, whose
pairwise reduction is both more accurate and several times faster than the
sequential loops they replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.69%. Comparing base (588d0ef) to head (3f50fcb).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #298      +/-   ##
==========================================
+ Coverage   86.08%   86.69%   +0.60%     
==========================================
  Files          19       19              
  Lines        1947     1916      -31     
==========================================
- Hits         1676     1661      -15     
+ Misses        271      255      -16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

devmotion and others added 4 commits September 18, 2026 00:25
`denom_partials_kernel` computed `-n / (d * d)`, but `d * d` overflows to
`Inf` (and underflows to zero) while `-n / d^2` is still finite and normal,
so the denominator adjoint was wrong at the extremes of the range:

    ReverseDiff.gradient(v -> sum([1e-200] ./ v), [1e-200])  # [-Inf]
    ReverseDiff.gradient(v -> sum([1e200]  ./ v), [1e200])   # [0.0]

Dividing twice never forms the square and is the same two roundings, so
accuracy in the normal range is unchanged: over 200k random `(n, d)` pairs
the mean relative error against a `BigFloat` reference is 4.007e-17 before
and 4.017e-17 after. The kernel backs both `./` and `.\`, so one change
covers the numerator adjoint's counterpart in each.

`PrecisionTests` also moves after `LinAlgTests`, which asserts that
`StatisticsExt` is still unloaded when it starts; `PrecisionTests` loads
Statistics and so has to run later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`numer_partials(d::Real) = Ref(inv(d))` was the last producer of a `Ref`
partial, and dividing by `value(b)` directly retired it. Nothing constructs a
`Ref` any more: the remaining `Ref` methods of `denom_partials`,
`base_partials` and `exp_partials` need both operand values to be `<:Real`,
and none of the nine operand combinations generated at `elementwise.jl:419`
(or the matching `materialize` overloads in `broadcast.jl`) is scalar/scalar —
at least one side is always array-valued. So the `partial::Ref` forwarder is
unreachable, as is `_broadcast_increment_deriv!` for a `TrackedReal` input with
a scalar partial: when the input is a tracked scalar the other operand is an
array, so its partial is an `AbstractArray`.

Codecov flagged exactly these two methods on #298.

Dropping them is fail-loud rather than silently wrong: no less specific method
survives for either signature, so a mistaken deletion is a `MethodError`, never
a wrong gradient. `ElementwiseTests` covers all nine combinations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion
devmotion merged commit 3ddf171 into master Sep 18, 2026
8 checks passed
devmotion added a commit that referenced this pull request Sep 22, 2026
Almost all of ReverseDiff's broadcasting hung off entry points Julia never
documented as extension points, and the documented ones were unused: 2191
`Base.broadcast` overloads, 1777 `Base.broadcast!`, and a 2001-method
`materialize` table keyed on *any* package's style, against five `copy`
methods and no `copyto!`. Because `broadcast` was overloaded directly it
short-circuited the pipeline before a `Broadcasted` was ever built, so `f.(x)`
and `broadcast(f, x)` could diverge, which `Base` guarantees they cannot.

`TrackedStyle` becomes `TrackedStyle{N} <: AbstractArrayStyle{N}`, so it tracks
dimensionality and picks up `Base`'s scalar, tuple and `Unknown` precedence
rules for free. The `Base.broadcast` overloads and the `materialize` table are
deleted, making `f.(x)` and `broadcast(f, x)` the same computation by
construction. `copyto!` now throws a descriptive error instead of failing after
polluting the tape; `.=` with a tracked right-hand side has never worked.

`copy` no longer asks `Core.Compiler.return_type` what a broadcast will produce
in order to pick an implementation. The only question the argument types cannot
answer is whether a tracked value reaches `f` other than through the arguments,
such as a closure capturing one, and `mayhavetracked` already reports that.
What the output turns out to be is settled after the forward pass instead:
anything `f` hands back that is not a `Dual` does not depend on the tracked
arguments, so it carries no partials and is left off the tape. That covers a
`Bool` from a predicate, an `Int` from `exponent`, a non-`Real`, and a function
constant in its argument, with one test. The `:tracker` implementation goes
with it, along with three `@generated` functions.

`∇broadcast` seeds `Dual`s only for the tracked arguments. `DiffRules` gives
`besselj` a literal `NaN` partial for its order, and `NaN * 0.0` is `NaN`, so
dualizing an untracked argument poisoned every partial of the same `Dual`.
Narrowing the seeding also narrows the `Dual` to the number of tracked
arguments and removes the `SVector`/`DiffResults` machinery. The tag is now
derived once for the whole broadcast from the arguments that are actually
seeded, rather than per element from the element types.

Where `f`'s partials are known in closed form, `knownpartials` dispatches on the
operation and its arguments and the cache keeps a `Contract(op, args)` per
tracked argument instead of a `Dual` per element. The entry says that argument
collects `op(seed, args...)` from every element of the output's derivative,
with `args` naming broadcast arguments by position:

    `+`, `-`, `identity`   `identity` or `-`, applied to the seed alone
    `*`                    `*` with the other argument
    `/`, `\`               `/` by an untracked denominator

Nothing is stored. A `Contract` is a zero-size type, and an argument a partial
names is read back through `instruction.input`, where `record!` has already
captured it, so the partial cannot go stale on a replay and an untracked array
is not copied a second time. Every `knownpartials` method annotates all of
`f`'s arguments, which is what makes naming one by position safe: those are
exactly the arguments `splitargs` keeps, in order, so an `f` that also receives
arguments held back from the tape matches no method. `op` meets the seed rather
than the partial, so `/` forms no reciprocal, which is how `sum(y ./ 1e-310)`
keeps the finite gradient that #298 gave it.

A tracked denominator keeps a `Dual` per element, its partial `-x/y^2` being no
argument of the broadcast, as does an argument whose shape is not the output's,
which could not be read at the output's own index.

Arrays of `TrackedReal` are differentiated like a `TrackedArray` rather than
traced scalar-wise: 64x faster on `x .* y` and 166x on a fused three-argument
expression, with the tape going from ~3n entries to 2.

Against master, with `gradient` and Chairmarks, best of five alternating runs:

    case              master.us   branch.us   t.ratio   bytes.ratio
    mulscalar_1k            2.41        2.19      0.91          1.00
    mulscalar_10k          15.75       14.33      0.91          1.00
    mulscalar_100k        159.58      144.62      0.91          1.00
    addscalar_10k          15.62       14.13      0.90          1.00
    addarray_10k           17.33       15.75      0.91          1.00
    mularray_10k           21.58       18.83      0.87          1.00
    twoscalars_10k         24.67       23.00      0.93          0.87
    sinscalar_10k         207.21       88.46      0.43          1.00
    exp_10k                51.75       53.25      1.03          1.00
    exp_100k              530.96      535.50      1.01          1.00

`exp` keeps a `Dual` per element either way and is at parity; the spread across
runs is about 3%. Precompilation drops from 10.45s to 6.78s and load time from
2.61s to 2.39s, and `src` loses 417 lines.

Fixes #265: a 0-d broadcast collapsed to a single `ImmutableDiffResult`, whose
`length` was then taken.

Fixes #214: a scalar and a one-element-vector argument now agree, where the
scalar form raised a `ForwardOptimize` ambiguity.

Addresses #226: the counted methods on `broadcast`, `broadcast!` and
`materialize` go from 5969 to 0, and `Test.detect_ambiguities(ReverseDiff)`
from 877 to 581, with the 296 broadcast-related ones all gone. `map`, `hcat`
and `vcat` are untouched and still account for 4567.

`seed!`'s catch-all is narrowed to `::Real`: it silently did nothing for the
arrays and tuples that reach it from a broadcast whose output element type is
not `Real`, and `jacobian` now covers those outputs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mean BigFloat precision

1 participant