Repository navigation
Put broadcasting on Julia's documented custom-broadcast interface - #301
Merged
Merged
Conversation
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>
devmotion
force-pushed
the
dmw/broadcast-interface
branch
from
September 22, 2026 12:29
44e0649 to
b234e7d
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #301 +/- ##
==========================================
+ Coverage 89.37% 94.27% +4.89%
==========================================
Files 19 19
Lines 1977 1695 -282
==========================================
- Hits 1767 1598 -169
+ Misses 210 97 -113 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`fieldcount(Vector{T})` is 0 on Julia 1.10 and 2 on 1.11, where `Array`
gained `ref` and `size`, so asking it whether an array argument could hide
a tracked value answered differently by version. On 1.10 that let
`x[1] * [[x[2], x[3]]]` reach `∇broadcast`, which seeded `x[1]` and left the
enclosing `Dual` to be contracted with the seed in the reverse pass, where a
`Float64` derivative cannot hold it (#140).
Ask the element type instead. An argument is seeded itself or elementwise, so
its own tracked values are in plain sight; below it nothing is seeded, so
recurse into what a container holds rather than ask about the container.
`istypeorclosure` never answered whether its input was a type or a closure,
and `mayhavetracked` is false for a `TrackedReal`, so both become
`mayhidetracked`, which is what they decide.
Also report an argument carrying a perturbation that the tape's derivative
type cannot hold, rather than letting it fail as a `MethodError` from the
reverse pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`map` iterates a scalar as a one-element collection, so the fused methods had nothing to fuse, and their reverse pass and replay both threw. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A `TrackedArray` holds only `Real`s, so `Complex` or `Tuple` results were returned untracked and their partials silently dropped. The inferred element type now sends such broadcasts down the fallback before `f` is evaluated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`map` over arrays of different lengths gives a shorter output, so the reverse pass iterates over the output and its partials, not the input. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`getouttype` reads the derivative type off the element type, so a
`Vector{TrackedReal}` left it `Union{}`. Such arrays now keep `Base`'s
style and are traced with the scalar rules, as before. The
`TrackedArray` methods are subsumed by the `AbstractArray` ones.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`value` of an array of `TrackedReal`s or of an adjoint `TrackedVector` maps over it, so calling it per element made the reverse pass quadratic. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`diffresult_increment_deriv!` took either a partial's index or the arguments a `Contract` names in the same position. The replay and the tag check reuse the values `copy` already computed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LinearAlgebra 1.13 forwards a `Diagonal`'s broadcast to its diagonal and
calls `copy` on the result without instantiating it. With a tracked
diagonal, the `Broadcasted` has `TrackedStyle{1}` and no axes, which the
scalar `copy` method matched by its axes type, so it was indexed with
`CartesianIndex()` and threw a `BoundsError`. Decide the scalar route
from `axes` instead, which computes missing axes and still gives `()`
for `TrackedStyle{0}` and 0-dimensional `TrackedStyle{Any}`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`∇broadcast` now seeds its `Dual`s with a ReverseDiff tag, and `value` strips them as it strips tracking. So a broadcast of `value`, fused or not, isn't recorded. On Julia 1.13 `map(value, ::Diagonal)` broadcasts over the diagonal, which used to keep `value` of a tracked `Diagonal` tracked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Since `value` strips `∇broadcast`'s `Dual`s, `SkipOptimize` does too, so a broadcast of a skipped function is neither differentiated nor recorded. This is what master does with `broadcast(f::SkipOptimize, t::TrackedArray) = broadcast(f.f, value(t))`, and what `map` and the scalar rules do. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`value` copies an array of `TrackedReal`s, so the `Dual` path allocated a copy per argument on every reverse pass without reading it. Also make two tests exercise what they describe: a `Ref` that shifts argument positions, and a replay whose named argument is tracked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`trackedslots` also returns the position of each tracked argument, so known partials, bounds and the reverse pass cover tracked arguments alone instead of carrying `nothing` or `Val(0)` for untracked ones. Known partials are given per argument, and the tag is keyed on the derivative type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`capture` records a `TrackedReal` without a tape as its value, so the reverse pass recounting tracked inputs misaligned slots and bounds. The cache now carries each tracked argument's position, slot and bound from recording, and the reverse pass skips arguments captured untracked. Tuple arguments are broadcast elementwise rather than handed to `f` whole, and only fall back when an element is tracked or may hide one. Also unroll the vestigial one-element `map` loops, simplify comments, and make the elementwise test state which functions are left off the tape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n `value` `value(::Dual)` for `∇broadcast`'s tag was global, so a broadcast nested in another stripped the outer perturbation and returned a zero gradient. `value` now unwraps `LinearAlgebra`'s wrappers of a `TrackedArray` by dispatch, since `map` over a `StructuredMatrix` broadcasts, and a nested broadcast raises the descriptive error again. This also replaces `mulargvalue`. The broadcast tag is keyed on the seeded arguments' value type, as ForwardDiff keys it, rather than on the derivative type, and `D` is computed where the output is tracked. Partials are numbered left to right, and known partials unwrap only the arguments they name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kedArray` A `ForwardOptimize` `map` over a `TrackedArray` and a plain array treated the latter as constant, yet the reverse pass read its partial whenever its element type admits `TrackedReal`, so `map(hypot, x, Real[...])` threw a `BoundsError`, on master too. Such an array is now differentiated like a second `TrackedArray`. The `SkipOptimize` `map` methods taking a `TrackedReal` go: `Base.map` and the scalar `@skip` rules give the same results and leave the tape empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al` by hand `value` stopped stripping `∇broadcast`'s `Dual`s, which `@skip` relied on: a fused `x .* @Skip(exp).(x)` differentiated `exp`. The tag wraps `BroadcastTag{F}` again and `skipvalue` drops it, and nested tracking, at every order. The rewrapping `value` methods mapped over whole parents, which LinearAlgebra leaves `#undef` outside a triangle. The generic fallback handles every wrapper but `Diagonal`, whose `map` broadcasts and would be recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reject buried tracked values Whether a broadcast was recorded depended on its recorded values: a branchy `f` whose every element took a constant branch, such as `ifelse.(x .> 0, x, 0.0)` at negative `x`, was left off the tape, and replays gave wrong gradients. The decision now uses the element type `combine_eltypes` infers, and the results are converted to it so that a replay can store the other branch. The broadcast tag is keyed on every argument's type, so a `Dual` of an enclosing differentiation on an untracked argument always makes a newer tag; with an older one, forward-over-reverse threw "Cannot determine ordering". Arguments with an abstract element type are checked element by element. The fallback no longer copies values to find the untracked style, and a `TrackedArray` of a static array is broadcast as a static array of its elements, which crashed before. `@skip` drops every level of tracking from arrays too. A `Dual` wrapping a `TrackedReal` is a perturbation of an enclosing ForwardDiff call that reached the tape, and was differentiated as a constant. Such outputs now raise an error, promotion keeps them apart from `TrackedReal`s rather than cutting them off the tape, and converting one to a `TrackedReal` raises an error. Reverse-over-forward with such mixed arrays now works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed call Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd check abstract argument elements without dispatch - store the results of a static array via `similar`, which gives a `SizedArray` for a non-isbits element type where `convert` gives an `MArray` that rejects `setindex!` - replace the `@generated` `try`/`catch` around `fieldcount` with `isconcretetype` - branch on `isa` instead of dispatching on every element of an abstractly typed argument - stop pinning `MethodError`s of the internal `seed!` - test that the cached partials have a concrete element type Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps the 1.19.0 bump and moves the broadcast `writable` methods next to the ones from #307. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts resolved with this branch's side, then formatted with Runic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishes the tape before counting its instructions in the elementwise tests, as recording and reading are now separate phases (#311). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, and finish tapes before reading them - `copyto!` into a 0-dimensional `TrackedArray` was ambiguous with `Base`'s 0-dimensional method instead of throwing the destination error. - Replace the tests of `trackresults` and `getpartial` with nestings of ForwardDiff inside and around the broadcast. - The broadcast tests finish their tapes before reading or replaying them (#311). - Name the replayed function `replayf` and clarify two comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Recurse into argument values only through tuples and arrays, so a non-concrete tuple holding `nothing` no longer throws a `MethodError` - Capture the broadcast tag rather than its type in the reverse pass - Drop the `copyto!` methods for `DefaultArrayStyle`, which `.=` never reaches - Restore master's `return nothing` in `reverse_mul!` and print `TrackedReal`s without building a string first - Test `NotTracked` scalars and arguments, the `Union` branch of `checktags`, and `map` over skipped functions; use `gradient` for the tapeless `TrackedReal` test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tic fallback for `StaticArray` `.=` hands `copyto!` the source's style, so an untracked source never reached the `TrackedStyle` methods. A tracked static value is stored as an `MVector`, which the fallback keeps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and test `show` `map` narrowed a widened broadcast's value buffer to the recorded values' type, so replaying `t > 0 ? t : 0` at non-integer inputs threw an `InexactError`. `combine_eltypes` widens to `Real`, never to a `Union`, so `checktags` loses its unreachable branch. The `nothing`-in-tuple test now reaches `∇broadcast` instead of the scalar fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ForwardDiff.valtype` unwraps only a concrete `Dual`, so a widened inferred element type (`Any` on Julia 1.10, or a `Dual` with free parameters) became the value buffer's element type, which rejected the values and hid the `DualMismatchError` of a nested tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Broadcast.combine_eltypes` widens `Union{Dual, Float64}` to `Real`, so a function
with a constant branch, such as `ifelse.(x .> 0, x, 0.0)`, cached boxed results
and tracked `Real` values. Promoting the union keeps concrete `Dual`s and
`Float64` values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…racked Use asymmetric values for the tracked scalar factor, let the enclosing and the held perturbation reach the gradient, pin the nested-broadcast error message, read `value` without tracing, and test tracked values hidden in a closure or a `Ref`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tests A scalar broadcast passed a `NotTracked` argument to `f` still wrapped, which master unwrapped. Rename the eltype-changing `writable` to `replayable` next to its only caller, fix two comments, test through `@forward`/`@skip` and allocations instead of internals, skip the replay check for untracked results, and cover `.\` with a tracked denominator and the replay of a known partial naming an array of `TrackedReal`s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
devmotion
force-pushed
the
dmw/broadcast-interface
branch
from
October 5, 2026 13:12
9008caa to
341c921
Compare
… test gaps A `.=` into a `TrackedArray` now fails in `setindex!`, whose error carries the hint, rather than in a `materialize!` override that wrappers bypassed anyway. The elementwise tests decide whether a result is tracked by inference, as the implementation does, and known partials are tested with a variadic `+` and at second order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A broadcast argument whose perturbation the derivative type cannot hold, such as a `Dual` created inside the differentiated function, now takes the scalar rules instead of throwing. Reverse-over-forward through a broadcast therefore works, and forward-over-reverse raises the #45 error. Also raise a descriptive error for output elements that are not numbers, cover the abstract-inference path of the reverse pass, and name the constant DiffTests function explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`besselj.(nu, y)` alone already passed on master; fused with `.* y`, the untracked order's `NaN` partial shares a `Dual` with the tracked partials and gave a `NaN` gradient there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Almost all of ReverseDiff's broadcasting hung off entry points Julia never documented as extension points, and the documented ones were unused: 2191
Base.broadcastoverloads, 1777broadcast!, and a 2001-methodmaterializetable keyed on any package's style, against a singlecopy(::Broadcasted)method and nocopyto!. Overloadingbroadcastdirectly short-circuits the pipeline before aBroadcastedis built, sof.(x)andbroadcast(f, x)could diverge, whichBaseguarantees they cannot.Design
TrackedStyle{N} <: AbstractArrayStyle{N}— tracks dimensionality and inheritsBase's scalar, tuple andUnknownprecedence rules. Thebroadcastoverloads and thematerializetable are deleted, sof.(x)andbroadcast(f, x)are the same computation by construction..=into aTrackedArrayfails insetindex!, whose error now says to usey = f.(x)instead. Like master's, the newcopyalso accepts the uninstantiatedBroadcastedthatLinearAlgebrapasses on Julia 1.13.The implementation is chosen from the argument types and from
Broadcast.combine_eltypes, the inferred element typeBasealso chooses its output container by. ATrackedArrayholds onlyReals, so aComplexorTupleresult is traced with the scalar rules, as on master. Inference only picks the path, and both paths give the same derivatives. Inference also decides whether aRealresult is recorded: a broadcast whosefcannot return aDualis left off the tape, and one that can is recorded even if no element did so while recording, so replayingifelse.(x .> 0, x, 0.0)recorded at negativexstill differentiates the other branch. The scalar-rule path keeps the output containerBasewould pick for the untracked arguments; aTrackedArrayof a static array is broadcast as a static array of its elements. The:trackerpath and four@generatedfunctions go with it.Only tracked arguments are seeded.
DiffRulesgivesbesselja literalNaNpartial for its order, andNaN * 0.0 == NaN, so dualizing untracked arguments poisoned every partial of the sameDual, as inbesselj.(1.5, y[1] .+ a)orbesselj.(nu, y) .* y, which gaveNaNgradients on master. Never dualizing untracked arguments also removes theSVector/DiffResultsmachinery from broadcasting (mapstill uses it). One tag is derived per broadcast,Tag(BroadcastTag{typeof(f)}(), typeof(argvals))keyed on the types of all argument values, rather than per element from the element types, so aDualof an enclosing differentiation, even on an untracked argument, gives a newer tag whenever an argument's type carries it. ADualhidden in an abstract element type, such asReal[1.0, a], sends the broadcast to the scalar rules, which raise the forward-over-reverse error from the Notes. That includes the case where the configured derivative type could hold the perturbation,GradientConfig(x, typeof(a)), which master differentiated correctly; a concrete[1.0, a]still works. The marker lets@skipdrop these perturbations, at every order, as it drops tracking;valueleaves them alone.valueof a trackedDiagonalrewraps its diagonal, since Julia 1.13 maps over aDiagonalby broadcasting over it, which would be recorded; a test checks thatvalueof everyLinearAlgebra.StructuredMatrixtype records nothing. ReverseDiff nested inside a broadcast raises the descriptive error from the Notes; tagging it properly is left to Perturbation Confusion (Nested Differentiation Bug) #45.Where
f's partials are known in closed form, the cache names them instead of storing them.knownpartialsdispatches on the operation and its arguments and caches aContract(op, args)per tracked argument — that argument collectsop(seed, args...)from each element of the output's derivative, withargsnaming broadcast arguments by position:+,-,identityidentityor-, on the seed alone**with the other argument/,\/by an untracked denominatorA
Contractis zero-size, and a named argument is read back throughinstruction.inputwhererecord!already captured it, once per reverse pass, only for known partials and only the arguments a partial names — so nothing is stored, the partial cannot go stale on a replay, and an untracked array is not copied twice.opmeets the seed rather than the partial, so/forms no reciprocal, which is how1e-10 * sum(y ./ 1e-310)keeps the finite gradient Divide instead of multiplying by cached reciprocals (fixes #239) #298 gave it. A tracked denominator (partial-x/y²) keeps aDualper element, and so do*,/and\when their two arguments differ in shape and neither is a scalar, and any broadcast with a tuple argument, since tuples are notRealOrArray.+and-name no argument, so they need no shape check.Against master
Runtime of
ReverseDiff.gradient(f, x)forx = rand(n)and an untrackedw = rand(n), with Chairmarks, best of five alternating runs:f(x)sum(x ./ w)sum(x .* w)sum(x .* x)sum(x .* 2.0)sum(x .+ 2.0)sum(x .+ w)sum(x .* 2.0 .+ 3.0)sum(exp.(x))sum(sin.(x))expandsinkeep aDualper element either way and are at parity. Separately, arrays ofTrackedRealwhose element type fixes the derivative type are differentiated like aTrackedArrayrather than scalar-wise. Recording a broadcast over three such arrays of n = 10k, with a fresh tape per sample:x .* yx .* y .+ zA
Vector{TrackedReal}, which does not fix it, is traced scalar-wise as on master.Known partials naming such an array, or an adjoint
TrackedVector, at n = 16000:v .* Real[b...], withReal[b...]built infv .* [v[i] for i in 1:n]x' .* x'Base.compilecache, best of 3)using, best of 5 fresh processes)broadcast/broadcast!/materializemap/map!/hcat/vcatdetect_ambiguities(ReverseDiff)srclinesAll numbers are on Julia 1.12.7, for master at b796032 and the branch at dd47fc9, and the counts are of ReverseDiff's own methods only.
Issues
Each reproducer was run on both sides.
length(::DiffResults.ImmutableDiffResult{1, Float64, Tuple{Float64}})#265 — a 0-d broadcast collapsed to a singleImmutableDiffResult, whoselengthwas then taken.ForwardOptimizeambiguity.hcat/vcatare out of scope and still account for 2394,mapfor 1529.BoundsErrorfrommap(f, x, y)over aTrackedArrayand an array whose element type admitsTrackedReal, such asmap(hypot, x, Real[1.0, 2.0])ormap(hypot, x, [x[1], x[2]])(no issue filed). The plain array was treated as constant, yet the reverse pass read its partial. It is now differentiated like a secondTrackedArray.setindex!(::SVector…)error whenf's inferred return type is abstract, such ast -> t > 0 ? t : z[]forz = Ref{Real}(0.0)(no issue filed).I, where master raised aMethodErrorconverting aDualtoFloat64. This is the reverse-over-forward case from the Notes.StaticArrayofTrackedReals doesn't currently work (and I think it could) #153 and sum with dims fails if the tracked reals have different types #172 have since been fixed on master (Give tracked reals with a free origin azeroand aone(fixes #172), and pin #126 #302, SupportStaticArrays withTrackedArrays (fixes #153) #307), and Method ambiguities with SparseArrays.jl #278 and Ambiguity issue with transposed of sparse matrices from SparseArrays.jl #131 no longer reproduce on either side on Julia 1.12; all five behave identically on both sides.Broadcast.broadcastedinstead ofBroadcast.broadcast#215 is deliberately out of scope; this is its prerequisite.Notes
.=with a tracked right-hand side still throws — it never worked, and the in-place recording machinery it would route into was itself dead code (map!(DiffResult.value, ...), aUnionAllwith novaluefield). Making it work needs a∇broadcast!, left as a follow-up.mapmethods taking aTrackedRealare removed.mapiterates a scalar as a one-element collection, so there was nothing to fuse, and their reverse pass and replay both threw, on master too.Baseand the scalar rules now handle these calls, and recording makes a constant 9 allocations instead of 14–16, about 2× fewer bytes for short arrays and growing with the untracked array's length (28× at n = 1000). TheSkipOptimizemapmethods taking aTrackedRealgo too:Base.mapand the scalar@skiprules give the same results and leave the tape empty.valueof a trackedAdjointorTransposenow keeps the wrapper instead of returning a denseMatrix, as the*rules already unwrapped them.@skipnow drops every level of ReverseDiff tracking, from arrays as well as scalars, sohessian(v -> sum(v .* @skip(exp).(v)), a)is zero, consistent with its gradientexp.(a); master raises aMethodErrorthere. The fixed-aritySkipOptimizecall methods (from 2016) are folded into theVarargone, which infers and does not allocate.seed!'s catch-all is narrowed to::Real: it silently did nothing for arrays and tuples reaching it from a broadcast whose output element type is notReal.jacobiannow raises anArgumentErrorfor outputs whose elements are not real numbers, such asjacobian(x -> [x, x], x0)or complex results, where master returned zeros.fieldcount, which answers differently per version sinceArraygained fields in 1.11; it is now asked of the element type. A broadcast whose arguments carry a perturbation the derivative type cannot hold now takes the scalar rules: reverse-over-forward such asReverseDiff.gradient(x -> ForwardDiff.derivative(a -> sum(x .* a^2), 2.0), x0)now works, where master raised aMethodError, and forward-over-reverse raises the error below.TrackedRealwhose element type fixes the derivative type, such asA + AorA .* 2forA::Matrix{TrackedReal{Float64,Float64,Nothing}}, now returns aTrackedArrayinstead of anArray. Recording such a result through a rule that tracks it, such asrecord_plus, nests oneTrackedArrayin another via the newtrack(::TrackedArray, …). Hessians still come out right on direct runs, replays and compiled tapes, but nesting without tags is the perturbation-confusion risk of Perturbation Confusion (Nested Differentiation Bug) #45, and the method carries a TODO for it.fcannot return aDual, such asround.(x),floor.(x)or(t -> 1.0).(x), is constant inxand now returns a plainArrayrather than aTrackedArraywith zero derivatives. Likefloorand the other scalar rules that return untracked values, it is then left off the tape, so a replay does not recompute it (Non-differentiable operations are left off the tape, so replays return stale values and gradients #313). Such a replay now silently keeps the recorded values, as insum(round.(x)); master threw while recording it. A type-unstablef, such ast -> t > 0 ? t : 0, is always recorded, with its inferred union promoted (here toFloat64); over a static array its results are kept in anMVector, so a replay can write into them. Master failed on these for every array type.ForwardDiff.derivative(a -> sum(ReverseDiff.gradient(x -> x[1] * a, x0)), a0), buries the tracked value in aDual, and gave zero derivatives on master. It now raises "ForwardDiff cannot differentiate through ReverseDiff", and converting such aDualto aTrackedReal, which would cut it off the tape, raises an error too.TrackedReals andDuals wrapping tracked values no longer promote to a common type, so reverse-over-forward with arrays mixing both, such asForwardDiff.derivative(a -> sum([x[1] * a^2, x[2]]), 2.0)insideReverseDiff.gradient, now works, also through a broadcast; master raised aMethodError. Telling the two nestings apart in general needs tags (Perturbation Confusion (Nested Differentiation Bug) #45).x .* (1.0, 2.0, 3.0), is now fused like an array instead of traced element by element. A tuple with a tracked element is still traced element by element, since its elements are not seeded.show(::TrackedReal)prints its pieces instead of interpolating a string.🤖 Generated with Claude Code