Skip to content

fix: make FiniteDiff prep non-allocating with static arrays#1019

Merged
gdalle merged 10 commits into
JuliaDiff:mainfrom
dingraha:avoid_marrays
Jul 20, 2026
Merged

fix: make FiniteDiff prep non-allocating with static arrays#1019
gdalle merged 10 commits into
JuliaDiff:mainfrom
dingraha:avoid_marrays

Conversation

@dingraha

Copy link
Copy Markdown
Contributor

I noticed some allocations when using FiniteDiff with StaticArrays that I eventually traced to calls to similar in the FiniteDiff extension. Adding the check for immutable input or output arrays seemed to fix this. Haven't attempted to add tests for this—wanted to get some feedback before diving in. What do you think?

@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.10%. Comparing base (1912e3d) to head (f298e1b).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1019   +/-   ##
=======================================
  Coverage   98.09%   98.10%           
=======================================
  Files         142      143    +1     
  Lines        8239     8268   +29     
=======================================
+ Hits         8082     8111   +29     
  Misses        157      157           
Flag Coverage Δ
DI 98.87% <100.00%> (+<0.01%) ⬆️
DIT 96.03% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

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

@gdalle

gdalle commented May 21, 2026

Copy link
Copy Markdown
Member

Nice catch! The alternative would be replacing similar with copy, does that work on SArrays too?

@dingraha

Copy link
Copy Markdown
Contributor Author

Yes, copy returns a SArray (unlike similar), but that doesn't eliminate all the allocations, I think because the FiniteDiff.JVPCache struct is mutable. Here is a short script that shows this:

using FiniteDiff
using DifferentiationInterface
using StaticArrays
function doit()
    backend = AutoFiniteDiff()
    x = @SVector [1.0, 2.0]
    tx = (2.0.*x,)
    f(x) = @. 3.0 * x
    # prep = DifferentiationInterface.prepare_pushforward(f, backend, x, tx);
    prep =  DifferentiationInterface.prepare_pushforward_nokwarg(Val(true), f, backend, x, tx)
    return prep
end

When DI.prepare_pushforward_nokwarg looks like this:

function DI.prepare_pushforward_nokwarg(
        strict::Val, f, backend::AutoFiniteDiff, x, tx::NTuple, contexts::Vararg{DI.Context, C}
    ) where {C}
    _sig = DI.signature(f, backend, x, tx, contexts...; strict)
    fc = DI.fix_tail(f, map(DI.unwrap, contexts)...)
    y = fc(x)
    cache = if x isa Number || y isa Number
        nothing
    else
        JVPCache(copy(x), y, fdtype(backend))
    end
    relstep = if isnothing(backend.relstep)
        default_relstep(fdtype(backend), eltype(x))
    else
        backend.relstep
    end
    absstep = if isnothing(backend.absstep)
        relstep
    else
        backend.absstep
    end
    dir = backend.dir
    return FiniteDiffOneArgPushforwardPrep(_sig, cache, relstep, absstep, dir)
end

I see these allocations:

julia> @allocated prep = doit()
18134560

julia> @allocated prep = doit()
96

julia> 

(I assume the first call includes the allocations associated with compiling.)

Then, if I add those ismutable_array checks:

function DI.prepare_pushforward_nokwarg(
        strict::Val, f, backend::AutoFiniteDiff, x, tx::NTuple, contexts::Vararg{DI.Context, C}
    ) where {C}
    _sig = DI.signature(f, backend, x, tx, contexts...; strict)
    fc = DI.fix_tail(f, map(DI.unwrap, contexts)...)
    y = fc(x)
    cache = if x isa Number || y isa Number || (! DI.ismutable_array(x)) || (! DI.ismutable_array(y))
        nothing
    else
        JVPCache(copy(x), y, fdtype(backend))
    end
    relstep = if isnothing(backend.relstep)
        default_relstep(fdtype(backend), eltype(x))
    else
        backend.relstep
    end
    absstep = if isnothing(backend.absstep)
        relstep
    else
        backend.absstep
    end
    dir = backend.dir
    return FiniteDiffOneArgPushforwardPrep(_sig, cache, relstep, absstep, dir)
end

I see this in the REPL:

julia> @allocated prep = doit()
751888

julia> @allocated prep = doit()
0

julia> 

Here's another example that isolates the FiniteDiff.JVPCache:

julia> function doit2()
                x = @SVector [1.0, 2.0]
                y = @SVector [2.0, 3.0]
                foo = Val(:forward)
                cache = FiniteDiff.JVPCache(x, y, foo)
                return cache
              end
doit2 (generic function with 1 method)

julia> @allocated cache = doit2()
882800

julia> @allocated cache = doit2()
48

julia> 

@gdalle

gdalle commented May 26, 2026

Copy link
Copy Markdown
Member

I tried implementing an upstream fix in JuliaDiff/FiniteDiff.jl#216

@dingraha

Copy link
Copy Markdown
Contributor Author

@gdalle Excellent, thanks! That fixes the allocations I was seeing with this small example:

julia> function doit2()
               x = @SVector [1.0, 2.0]
               y = @SVector [2.0, 3.0]
               foo = Val(:forward)
               cache = FiniteDiff.JVPCache(x, y, foo)
               return cache
             end
doit2 (generic function with 1 method)

julia> @allocated cache = doit2()
882800

julia> @allocated cache = doit2()
48

julia> 

To avoid the allocations with this example

using FiniteDiff
using DifferentiationInterface
using StaticArrays
function doit()
    backend = AutoFiniteDiff()
    x = @SVector [1.0, 2.0]
    tx = (2.0.*x,)
    f(x) = @. 3.0 * x
    # prep = DifferentiationInterface.prepare_pushforward(f, backend, x, tx);
    prep =  DifferentiationInterface.prepare_pushforward_nokwarg(Val(true), f, backend, x, tx)
    return prep
end

it looks like we'd need to replace the similar calls with copy, like you suggested. I updated the PR to reflect this.

Interestingly, it looks like FiniteDiff.finite_difference_jvp doesn't actually use the cache::JVPCache argument at all, so I guess it doesn't matter what's in there.

@gdalle

gdalle commented May 29, 2026

Copy link
Copy Markdown
Member

Thanks!
Two questions:

  • Any idea why you need the internal form of prepare_pushforward(Val(true), ...) to avoid allocations? Is it because passing strict as a keyword argument causes some form of dynamic dispatch?
  • Can you add a small test in the FiniteDiff test file to make sure that we have no regression?

@gdalle

gdalle commented Jun 24, 2026

Copy link
Copy Markdown
Member

@dingraha is this ready for another review?

@dingraha

dingraha commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

@gdalle Yes, sorry for the delay.

end
jacobian_allocs()
allocs = @allocated prep = jacobian_allocs()
# Using FiniteDiff.jl with StaticArrays to calculate a Jacobian does result in some allocations, apparently because the `FiniteDiff.JacobianCache` is a `mutable struct`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it also be due to

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC this PR changes those to copy and I still see a few allocations.

@dingraha

Copy link
Copy Markdown
Contributor Author

I think the test failures are unrelated to this PR?

Comment thread DifferentiationInterface/Project.toml Outdated
@gdalle gdalle changed the title Avoid MArrays with FiniteDiff backend fix: make FiniteDiff prep non-allocating with static arrays Jul 20, 2026
@gdalle
gdalle marked this pull request as ready for review July 20, 2026 08:00
@gdalle
gdalle merged commit 8c7dfce into JuliaDiff:main Jul 20, 2026
72 of 98 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

Development

Successfully merging this pull request may close these issues.

2 participants