Skip to content

Put broadcasting on Julia's documented custom-broadcast interface - #301

Merged
devmotion merged 42 commits into
masterfrom
dmw/broadcast-interface
Oct 22, 2026
Merged

devmotion merged 42 commits into
masterfrom
dmw/broadcast-interface

Conversation

@devmotion

@devmotion devmotion commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

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 broadcast!, and a 2001-method materialize table keyed on any package's style, against a single copy(::Broadcasted) method and no copyto!. Overloading broadcast directly short-circuits the pipeline before a Broadcasted is built, so f.(x) and broadcast(f, x) could diverge, which Base guarantees they cannot.

Design

  • TrackedStyle{N} <: AbstractArrayStyle{N} — tracks dimensionality and inherits Base's scalar, tuple and Unknown precedence rules. The broadcast overloads and the materialize table are deleted, so f.(x) and broadcast(f, x) are the same computation by construction. .= into a TrackedArray fails in setindex!, whose error now says to use y = f.(x) instead. Like master's, the new copy also accepts the uninstantiated Broadcasted that LinearAlgebra passes on Julia 1.13.

  • The implementation is chosen from the argument types and from Broadcast.combine_eltypes, the inferred element type Base also chooses its output container by. A TrackedArray holds only Reals, so a Complex or Tuple result 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 a Real result is recorded: a broadcast whose f cannot return a Dual is left off the tape, and one that can is recorded even if no element did so while recording, so replaying ifelse.(x .> 0, x, 0.0) recorded at negative x still differentiates the other branch. The scalar-rule path keeps the output container Base would pick for the untracked arguments; a TrackedArray of a static array is broadcast as a static array of its elements. The :tracker path and four @generated functions go with it.

  • Only tracked arguments are seeded. DiffRules gives besselj a literal NaN partial for its order, and NaN * 0.0 == NaN, so dualizing untracked arguments poisoned every partial of the same Dual, as in besselj.(1.5, y[1] .+ a) or besselj.(nu, y) .* y, which gave NaN gradients on master. Never dualizing untracked arguments also removes the SVector/DiffResults machinery from broadcasting (map still 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 a Dual of an enclosing differentiation, even on an untracked argument, gives a newer tag whenever an argument's type carries it. A Dual hidden in an abstract element type, such as Real[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 @skip drop these perturbations, at every order, as it drops tracking; value leaves them alone. value of a tracked Diagonal rewraps its diagonal, since Julia 1.13 maps over a Diagonal by broadcasting over it, which would be recorded; a test checks that value of every LinearAlgebra.StructuredMatrix type 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. knownpartials dispatches on the operation and its arguments and caches a Contract(op, args) per tracked argument — that argument collects op(seed, args...) from each element of the output's derivative, with args naming broadcast arguments by position:

    entry
    +, -, identity identity or -, on the seed alone
    * * with the other argument
    /, \ / by an untracked denominator

    A Contract is zero-size, and a named argument is read back through instruction.input where record! 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. op meets the seed rather than the partial, so / forms no reciprocal, which is how 1e-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 a Dual per 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 not RealOrArray. + and - name no argument, so they need no shape check.

Against master

Runtime of ReverseDiff.gradient(f, x) for x = rand(n) and an untracked w = rand(n), with Chairmarks, best of five alternating runs:

f(x) n master µs branch µs time bytes
sum(x ./ w) 10k 24.83 19.58 0.79 0.86
sum(x .* w) 10k 21.79 19.21 0.88 1.00
sum(x .* x) 10k 28.50 24.96 0.88 1.00
sum(x .* 2.0) 1k / 10k / 100k 2.42 / 15.83 / 156.04 2.19 / 14.25 / 138.88 0.91 / 0.90 / 0.89 1.00
sum(x .+ 2.0) 10k 15.50 13.96 0.90 1.00
sum(x .+ w) 10k 17.54 16.13 0.92 1.00
sum(x .* 2.0 .+ 3.0) 10k 24.58 22.92 0.93 0.87
sum(exp.(x)) 10k / 100k 51.62 / 513.92 51.62 / 505.13 1.00 / 0.98 1.00
sum(sin.(x)) 10k 86.25 86.71 1.01 1.00

exp and sin keep a Dual per element either way and are at parity. Separately, arrays of TrackedReal whose element type fixes the derivative type are differentiated like a TrackedArray rather than scalar-wise. Recording a broadcast over three such arrays of n = 10k, with a fresh tape per sample:

case master µs branch µs time bytes tape entries
x .* y 2205.62 34.25 0.016 0.25 10000 → 1
x .* y .+ z 4527.12 60.25 0.013 0.25 20000 → 1

A 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:

case master ms branch ms
v .* Real[b...], with Real[b...] built in f 0.439 0.438
v .* [v[i] for i in 1:n] 0.164 0.159
x' .* x' 8.08 0.238
master branch
precompile (Base.compilecache, best of 3) 9.99 s 6.59 s
load (using, best of 5 fresh processes) 1.90 s 1.74 s
methods on broadcast / broadcast! / materialize 2191 / 1777 / 2001 0 / 0 / 0
methods on map / map! / hcat / vcat 2173 / 1759 / 1197 / 1197 1529 / 0 / 1197 / 1197
detect_ambiguities(ReverseDiff) 847 (296 broadcast-related) 551 (0)
src lines — −432

All 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.

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, ...), a UnionAll with no value field). Making it work needs a ∇broadcast!, left as a follow-up.
  • The fused map methods taking a TrackedReal are removed. map iterates a scalar as a one-element collection, so there was nothing to fuse, and their reverse pass and replay both threw, on master too. Base and 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). The SkipOptimize map methods taking a TrackedReal go too: Base.map and the scalar @skip rules give the same results and leave the tape empty.
  • value of a tracked Adjoint or Transpose now keeps the wrapper instead of returning a dense Matrix, as the * rules already unwrapped them.
  • @skip now drops every level of ReverseDiff tracking, from arrays as well as scalars, so hessian(v -> sum(v .* @skip(exp).(v)), a) is zero, consistent with its gradient exp.(a); master raises a MethodError there. The fixed-arity SkipOptimize call methods (from 2016) are folded into the Vararg one, 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 not Real. jacobian now raises an ArgumentError for outputs whose elements are not real numbers, such as jacobian(x -> [x, x], x0) or complex results, where master returned zeros.
  • Whether an argument could hide a tracked value was decided by fieldcount, which answers differently per version since Array gained 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 as ReverseDiff.gradient(x -> ForwardDiff.derivative(a -> sum(x .* a^2), 2.0), x0) now works, where master raised a MethodError, and forward-over-reverse raises the error below.
  • An elementwise operation on arrays of TrackedReal whose element type fixes the derivative type, such as A + A or A .* 2 for A::Matrix{TrackedReal{Float64,Float64,Nothing}}, now returns a TrackedArray instead of an Array. Recording such a result through a rule that tracks it, such as record_plus, nests one TrackedArray in another via the new track(::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.
  • A broadcast whose f cannot return a Dual, such as round.(x), floor.(x) or (t -> 1.0).(x), is constant in x and now returns a plain Array rather than a TrackedArray with zero derivatives. Like floor and 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 in sum(round.(x)); master threw while recording it. A type-unstable f, such as t -> t > 0 ? t : 0, is always recorded, with its inferred union promoted (here to Float64); over a static array its results are kept in an MVector, so a replay can write into them. Master failed on these for every array type.
  • ForwardDiff differentiating through ReverseDiff, as in ForwardDiff.derivative(a -> sum(ReverseDiff.gradient(x -> x[1] * a, x0)), a0), buries the tracked value in a Dual, and gave zero derivatives on master. It now raises "ForwardDiff cannot differentiate through ReverseDiff", and converting such a Dual to a TrackedReal, which would cut it off the tape, raises an error too. TrackedReals and Duals wrapping tracked values no longer promote to a common type, so reverse-over-forward with arrays mixing both, such as ForwardDiff.derivative(a -> sum([x[1] * a^2, x[2]]), 2.0) inside ReverseDiff.gradient, now works, also through a broadcast; master raised a MethodError. Telling the two nestings apart in general needs tags (Perturbation Confusion (Nested Differentiation Bug) #45).
  • A tuple argument with no tracked elements, such as 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.
  • Unrelated cleanups: the test runner now includes test files by relative path, and show(::TrackedReal) prints its pieces instead of interpolating a string.
  • Downstream packages depend on broadcast behaviour, hence the minor version bump.

🤖 Generated with Claude Code

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
devmotion force-pushed the dmw/broadcast-interface branch from 44e0649 to b234e7d Compare September 22, 2026 12:29
@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.27%. Comparing base (27dc11d) to head (2e04217).

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.
📢 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 26 commits September 22, 2026 21:25
`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>
devmotion and others added 3 commits October 2, 2026 13:54
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>
devmotion and others added 8 commits October 2, 2026 17:33
- 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
devmotion force-pushed the dmw/broadcast-interface branch from 9008caa to 341c921 Compare October 5, 2026 13:12
devmotion and others added 4 commits October 5, 2026 16:21
… 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>
@devmotion
devmotion merged commit ff259e8 into master Oct 22, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant