Skip to content

Commit 6f78fb8

Browse files
Eliminate final per-step allocation via y_cache and add QA allocation test
Add y_cache field to MIRKCache that pre-allocates the Vector{Vector{T}} needed by recursive_unflatten! for the primal (Float64) path. This eliminates the last 144 bytes/call from get_tmp. broadcast allocation. For the Dual path (ForwardDiff Jacobian computation), falls back to the existing broadcast since element types differ. Loss function is now fully allocation-free at runtime (0 bytes/call verified via @timed over 100k calls). Add zero-allocation QA test in the qa test group. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 65a921e commit 6f78fb8

3 files changed

Lines changed: 71 additions & 9 deletions

File tree

lib/BoundaryValueDiffEqCore/src/utils.jl

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ end
6868
return recursive_unflatten!(get_tmp.(y, (x,)), x)
6969
end
7070

71+
# Non-allocating version with pre-allocated output cache.
72+
# When element types match (primal path), fills y_cache in-place.
73+
# When they don't (Dual path), falls back to broadcast allocation.
74+
@views function recursive_unflatten!(
75+
y::Vector{<:DiffCache}, y_cache::Vector{<:AbstractVector{T}}, x::AbstractVector{T}
76+
) where {T}
77+
i = 0
78+
for (j, yᵢ) in enumerate(y)
79+
tmp = PreallocationTools.get_tmp(yᵢ, x)
80+
y_cache[j] = tmp
81+
copyto!(tmp, x[(i + 1):(i + length(tmp))])
82+
i += length(tmp)
83+
end
84+
return y_cache
85+
end
86+
87+
@views function recursive_unflatten!(
88+
y::Vector{<:DiffCache}, y_cache::Vector, x::AbstractVector
89+
)
90+
return recursive_unflatten!(get_tmp.(y, (x,)), x)
91+
end
92+
7193
@views function recursive_unflatten!(y::AbstractVectorOfArray, x::AbstractVector)
7294
i = 0
7395
for yᵢ in y

lib/BoundaryValueDiffEqMIRK/src/mirk.jl

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
y
2323
y₀
2424
residual
25-
# The following 2 caches are never resized
25+
# The following 3 caches are never resized
2626
fᵢ_cache
2727
fᵢ₂_cache
28+
y_cache # Pre-allocated get_tmp pointers for primal path
2829
errors
2930
new_stages
3031
resid_size
@@ -66,6 +67,7 @@ function SciMLBase.__init(
6667

6768
fᵢ_cache = __alloc(zero(X))
6869
fᵢ₂_cache = vec(zero(X))
70+
y_cache = Vector{Vector{T}}(undef, Nig + 1)
6971

7072
# Don't flatten this here, since we need to expand it later if needed
7173
y₀ = __initial_guess_on_mesh(X, mesh, prob.p)
@@ -231,7 +233,7 @@ function SciMLBase.__init(
231233
return MIRKCache{iip, T, use_both, typeof(diffcache), tune_parameters}(
232234
alg_order(alg), stage, N, size(X), f, bc, prob_, prob.problem_type, prob.p, alg,
233235
TU, ITU, f_prototype, bcresid_prototype, mesh, mesh_dt, k_discrete, k_interp, y,
234-
y₀, residual, fᵢ_cache, fᵢ₂_cache, errors, new_stages, resid₁_size, prob.singular_term
236+
y₀, residual, fᵢ_cache, fᵢ₂_cache, y_cache, errors, new_stages, resid₁_size, prob.singular_term
235237
, nlsolve_kwargs, optimize_kwargs, (; abstol, dt, adaptive, controller, tune_parameters, kwargs...), verbose_spec
236238
)
237239
end
@@ -251,6 +253,7 @@ function __expand_cache!(cache::MIRKCache{iip, T, use_both}) where {iip, T, use_
251253
__resize!(cache.residual, Nₙ, cache.M)
252254
__resize!(cache.errors, ifelse(use_both, 2 * (Nₙ - 1), (Nₙ - 1)), cache.M)
253255
__resize!(cache.new_stages, Nₙ - 1, cache.M)
256+
resize!(cache.y_cache, Nₙ)
254257
return cache
255258
end
256259

@@ -439,8 +442,8 @@ end
439442
resid, u, p, y, pt::StandardBVProblem, bc!::BC, residual, mesh,
440443
cache, EvalSol, trait::DiffCacheNeeded, constraint
441444
) where {BC}
442-
y_ = recursive_unflatten!(y, u)
443-
Φ!(residual[2:end], cache, y_, u, trait, constraint)
445+
y_ = recursive_unflatten!(y, cache.y_cache, u)
446+
Φ!(residual[2:end], cache, y, u, trait, constraint)
444447
copyto!(EvalSol.u, __restructure_sol(y_, cache.in_size))
445448
copyto!(EvalSol.cache.k_discrete, cache.k_discrete)
446449
eval_bc_residual!(get_tmp(residual[1], u), pt, bc!, EvalSol, p, mesh)
@@ -478,8 +481,8 @@ end
478481
resid, u, p, y, pt::TwoPointBVProblem, bc!::Tuple{BC1, BC2}, residual,
479482
mesh, cache, _, trait::DiffCacheNeeded, constraint
480483
) where {BC1, BC2}
481-
y_ = recursive_unflatten!(y, u)
482-
Φ!(residual[2:end], cache, y_, u, trait, constraint)
484+
y_ = recursive_unflatten!(y, cache.y_cache, u)
485+
Φ!(residual[2:end], cache, y, u, trait, constraint)
483486
resid0 = get_tmp(residual[1], u)
484487
resida = resid0[1:prod(cache.resid_size[1])]
485488
residb = resid0[(prod(cache.resid_size[1]) + 1):end]
@@ -551,9 +554,9 @@ end
551554
@views function __mirk_loss_collocation!(
552555
resid, u, p, y, mesh, residual, cache, trait::DiffCacheNeeded, constraint
553556
)
554-
y_ = recursive_unflatten!(y, u)
557+
recursive_unflatten!(y, cache.y_cache, u)
555558
collocation_residual = residual[2:end]
556-
Φ!(collocation_residual, cache, y_, u, trait, constraint)
559+
Φ!(collocation_residual, cache, y, u, trait, constraint)
557560
recursive_flatten!(resid, collocation_residual, u)
558561
return nothing
559562
end

test/qa/runtests.jl

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using BoundaryValueDiffEq, Aqua, JET, Test, SciMLBase
1+
using BoundaryValueDiffEq, BoundaryValueDiffEqMIRK, BoundaryValueDiffEqCore,
2+
Aqua, JET, Test, SciMLBase
23

34
@testset "Quality Assurance" begin
45
@testset "Aqua" begin
@@ -15,4 +16,40 @@ using BoundaryValueDiffEq, Aqua, JET, Test, SciMLBase
1516
)
1617
@test length(JET.get_reports(rep)) == 0
1718
end
19+
20+
@testset "Zero per-step allocations in MIRK loss function" begin
21+
function _f!(du, u, p, t)
22+
du[1] = u[2]
23+
du[2] = -u[1]
24+
return nothing
25+
end
26+
function _bc!(resid, sol, p, t)
27+
resid[1] = sol(0.0)[1] - 1.0
28+
resid[2] = sol(1.0)[1] - cos(1.0)
29+
return nothing
30+
end
31+
u0 = [1.0, 0.0]
32+
tspan = (0.0, 1.0)
33+
bvp = BVProblem(
34+
BVPFunction{true}(_f!, _bc!; bcresid_prototype = zeros(2)), u0, tspan)
35+
36+
cache = SciMLBase.__init(bvp, MIRK4(); dt = 0.1, adaptive = false)
37+
nlprob = BoundaryValueDiffEqMIRK.__construct_problem(
38+
cache, vec(cache.y₀), copy(cache.y₀))
39+
40+
u_test = copy(nlprob.u0)
41+
resid_test = zeros(length(nlprob.u0))
42+
p_test = nlprob.p
43+
44+
# Verify loss function is allocation-free at runtime
45+
function _bench_loss(f, resid, u, p, N)
46+
for _ in 1:N
47+
f(resid, u, p)
48+
end
49+
end
50+
_bench_loss(nlprob.f, resid_test, u_test, p_test, 10) # warmup
51+
stats = @timed _bench_loss(nlprob.f, resid_test, u_test, p_test, 10_000)
52+
bytes_per_call = stats.bytes / 10_000
53+
@test bytes_per_call == 0.0
54+
end
1855
end

0 commit comments

Comments
 (0)