Skip to content

Commit a3e7866

Browse files
Stop convergence studies retaining the trajectories they are not measuring
`test_convergence` kept one full solution object per trajectory per step size. A `RODESolution` carries the solver cache, the noise process, the problem and its interpolation, none of which a convergence study needs — it wants the per-trajectory errors and the endpoints the weak error is formed from. At the trajectory counts the weak-convergence tests use, that gap is the difference between passing and being OOM-killed: `SROCKC2WeakConvergence`, `IIPWeakConvergence` and `OOPWeakConvergence` all died at 15.6 GiB in a 16 GiB cgroup with no test output, which is the "runner lost communication with the server" signature reported in #4036. Trajectories are no longer retained by default. Three things compose: - The `EnsembleProblem` is built with an `output_func` that reduces each trajectory to a `ConvergenceTrajectory` as it is solved, so the full solutions never coexist and the peak is bounded, not just the retention. `ConvergenceTrajectory` stores one-tuples and a `NamedTuple` rather than one-element `Vector`s and the solver's error `Dict`; indexing and key lookup are unchanged, but the `Dict` alone dominated everything else retained once a study runs to millions of trajectories. - `calculate_ensemble_errors` moves inside the solve loop, so each step size's ensemble is summarised and released before the next is solved rather than all of them being held at once. - `_drop_trajectories` strips the summarised ensemble to one representative, since the reduced objects are dead weight once the errors are computed. `retain_solutions = true` restores the old behaviour for callers that want the paths themselves. The reduction is skipped, and the solutions retained, where it cannot apply: a caller-supplied `EnsembleProblem` owns its own `output_func`, `expected_value` forms the weak error from the trajectory values directly, and `weak_timeseries_errors`/`weak_dense_errors` need the whole timeseries. Drop `uEltype` from the `ConvergenceSimulation` constructor — it was assigned and never used, and was the only thing requiring `solutions[i].u[j]` to be a full solution. Measured on the weak files, 16 GiB cgroup, JULIA_NUM_THREADS=2: weak_srockc2.jl OOM 15.61 GiB @3m29s -> exit 0, 23m41s, 2.81 GiB oop_weak.jl OOM 15.62 GiB @23m50s -> exit 0, 13m08s, 1.32 GiB additive_weak.jl (same group) -> exit 0, 3m22s, 1.04 GiB Order estimates are bit-identical with and without the reduction. With no group needing more than 2.81 GiB, remove the `high-memory` runner pin from all eight groups that carried it. The five StochasticDiffEqWeak groups needed no change — they already reduce through their own `output_func` and peak at 0.71-2.09 GiB, so the label was simply wrong for them. BREAKING: DiffEqDevTools 3.1.5 -> 4.0.0. `ConvergenceSimulation.solutions` no longer holds every trajectory for the Monte-Carlo method, and `sim[i, j]` forwards into it, so `j > 1` now reaches past the representative. The field documentation is updated and the compat pin moves "3" -> "4" in 40 Project.toml files. The ODE/DAE methods do not take the keyword and are unchanged. The reduce-by-default design and `drop_trajectories` are taken from #4056, which is closed in favour of this. Fixes #4037 Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwLXp5WY1uiqPun7qhwxeU
1 parent 9e1de4a commit a3e7866

49 files changed

Lines changed: 271 additions & 62 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/src/devtools/alg_dev/convergence.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ A type which holds the data from a convergence simulation.
2020

2121
### Fields
2222

23-
- `solutions::Array{<:DESolution}`: Holds all the PdeSolutions.
23+
- `solutions::Array{<:DESolution}`: Holds the solutions. For the Monte-Carlo method of
24+
`test_convergence` these are reduced by default and hold a single representative
25+
trajectory — see [Memory use of Monte-Carlo studies](@ref) — so pass
26+
`retain_solutions = true` if the trajectories themselves are needed.
2427

2528
- `errors`: Dictionary of the error calculations. Can contain:
2629

@@ -92,10 +95,34 @@ log2(error[i+1]/error[i])
9295

9396
Returns the mean of the convergence estimates.
9497

98+
## Memory use of Monte-Carlo studies
99+
100+
For an SDE or RODE problem, `test_convergence` runs an ensemble per step size. A full
101+
solution per trajectory carries the solver cache, the noise process, the problem and its
102+
interpolation, so keeping them makes a study over many trajectories retain far more than
103+
the error estimates it reports — at hundreds of thousands of trajectories, tens of
104+
gigabytes.
105+
106+
They are therefore **not** kept by default. Each trajectory is reduced to a
107+
[`ConvergenceTrajectory`](@ref) by the ensemble's `output_func` as it is solved, so the
108+
full solutions never coexist, and once the errors have been computed the ensemble is
109+
stripped to a single representative trajectory. `errors`, `weak_errors`, `error_means`
110+
and `𝒪est` come from the full ensemble and are unaffected — what changes is that
111+
`solutions[i].u` holds one entry rather than `trajectories` of them.
112+
113+
Pass `retain_solutions = true` when the trajectories themselves are the point, such as
114+
comparing two algorithms path by path. `sim[i, j]` forwards into `solutions[i][j]`, so
115+
it too reaches only the representative trajectory unless the solutions are retained.
116+
117+
The reduction is skipped where it cannot apply, and the solutions retained: when you
118+
supply your own `EnsembleProblem` (set its `output_func` instead), when `expected_value`
119+
is given, and when `weak_timeseries_errors` or `weak_dense_errors` is set.
120+
95121
## API
96122

97123
```@docs
98124
DiffEqDevTools.ConvergenceSimulation
125+
DiffEqDevTools.ConvergenceTrajectory
99126
DiffEqDevTools.test_convergence
100127
DiffEqDevTools.analyticless_test_convergence
101128
```

lib/DelayDiffEq/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ DDEProblemLibrary = "0.1"
6464
BinaryHeaps = "1"
6565
DiffEqBase = "7"
6666
DiffEqCallbacks = "4.17.0"
67-
DiffEqDevTools = "3"
67+
DiffEqDevTools = "4"
6868
DiffEqNoiseProcess = "5.30.0"
6969
FastBroadcast = "1.3"
7070
FiniteDiff = "2.27"

lib/DiffEqDevTools/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "DiffEqDevTools"
22
uuid = "f3b72e0c-5b89-59e1-b016-84e28bfd966d"
33
authors = ["Chris Rackauckas <accounts@chrisrackauckas.com>"]
4-
version = "3.1.5"
4+
version = "4.0.0"
55

66
[deps]
77
CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"

lib/DiffEqDevTools/src/DiffEqDevTools.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ include("test_solution.jl")
5757
include("ode_tableaus.jl")
5858
include("tableau_info.jl")
5959

60-
export ConvergenceSimulation, Shootout, ShootoutSet, TestSolution
60+
export ConvergenceSimulation, ConvergenceTrajectory, Shootout, ShootoutSet, TestSolution
6161

6262
#Benchmark Functions
6363
export Shootout, ShootoutSet, WorkPrecision, WorkPrecisionSet

lib/DiffEqDevTools/src/convergence.jl

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ function ConvergenceSimulation(
2424
expected_value = nothing
2525
)
2626
N = size(solutions, 1)
27-
uEltype = eltype(solutions[1].u[1])
2827
errors = Dict() #Should add type information
2928
if expected_value == nothing
3029
if isnothing(solutions[1].errors) || isempty(solutions[1].errors)
@@ -54,6 +53,66 @@ function ConvergenceSimulation(
5453
return (ConvergenceSimulation(solutions, errors, N, auxdata, 𝒪est, convergence_axis))
5554
end
5655

56+
"""
57+
ConvergenceTrajectory(t, u, u_analytic, errors)
58+
59+
The part of a trajectory's solution that a convergence study actually consumes: its
60+
error measurements and the endpoint values the weak errors are formed from.
61+
62+
[`test_convergence`](@ref) stores one of these per trajectory in place of the full
63+
solution unless `retain_solutions = true`, which is what keeps a Monte-Carlo study's
64+
memory proportional to the errors it reports rather than to the solver state it
65+
happened to allocate along the way.
66+
"""
67+
struct ConvergenceTrajectory{tType, uType, EType <: NamedTuple}
68+
t::Tuple{tType}
69+
u::Tuple{uType}
70+
u_analytic::Tuple{uType}
71+
errors::EType
72+
end
73+
74+
"""
75+
_convergence_output_func(sol, ctx) -> (ConvergenceTrajectory, false)
76+
77+
`output_func` that reduces a trajectory to its errors and endpoints as soon as it is
78+
solved, so the ensemble never holds the full solutions at once.
79+
80+
Only valid when the weak timeseries and dense errors are switched off, since those
81+
need the whole timeseries and the noise process; [`test_convergence`](@ref) checks
82+
that before installing this.
83+
"""
84+
function _convergence_output_func(sol, ctx)
85+
# One-tuples rather than one-element vectors, and a NamedTuple rather than the
86+
# solver's error `Dict`: `[end]` and key lookup work the same, but a `Dict` alone
87+
# costs several hundred bytes per trajectory, which dominates everything else
88+
# retained here once the study runs to millions of trajectories.
89+
u_analytic = sol.u_analytic === nothing ? sol.u[end] : sol.u_analytic[end]
90+
reduced = ConvergenceTrajectory(
91+
(sol.t[end],), (sol.u[end],), (u_analytic,), NamedTuple(sol.errors)
92+
)
93+
94+
return (reduced, false)
95+
end
96+
97+
"""
98+
_drop_trajectories(sim::EnsembleTestSolution) -> EnsembleTestSolution
99+
100+
Discard every trajectory but the first, keeping the error statistics that were already
101+
computed from the full ensemble.
102+
103+
Reducing at `output_func` bounds the peak, but the reduced trajectories still add up
104+
across step sizes, and once `calculate_ensemble_errors` has run they are dead weight —
105+
a [`ConvergenceSimulation`](@ref) reads only the error dictionaries. One is kept rather
106+
than none so that anything reaching for a representative trajectory still finds one.
107+
"""
108+
function _drop_trajectories(sim::SciMLBase.EnsembleTestSolution{T, N}) where {T, N}
109+
u = sim.u[1:min(1, length(sim.u))]
110+
return SciMLBase.EnsembleTestSolution{T, N, typeof(u)}(
111+
u, sim.errors, sim.weak_errors, sim.error_means, sim.error_medians,
112+
sim.elapsedTime, sim.converged
113+
)
114+
end
115+
57116
"""
58117
test_convergence(dts, prob, alg[, ensemblealg]; kwargs...)
59118
test_convergence(probs, convergence_axis, alg; kwargs...)
@@ -66,6 +125,31 @@ is passed to the solver as `dt`. Remaining keyword arguments are forwarded to `s
66125
The computed solutions must contain error measurements, usually obtained from an
67126
analytic solution. Use [`analyticless_test_convergence`](@ref) when only a numerical
68127
reference solution is available.
128+
129+
## Keyword Arguments
130+
131+
- `retain_solutions`: whether `ConvergenceSimulation.solutions` keeps the trajectory
132+
solutions (default `false`). A Monte-Carlo study over many trajectories otherwise
133+
retains one complete solution object — solver cache, noise process, problem and
134+
interpolation — per trajectory per step size, which for large `trajectories` is
135+
orders of magnitude more memory than the error estimates it is computing, and is
136+
what put the weak-convergence test groups past a 16 GB runner.
137+
138+
By default each trajectory is reduced to a [`ConvergenceTrajectory`](@ref) by the
139+
ensemble's `output_func` as it is solved, so the full solutions never coexist, and
140+
the reduced ensemble is stripped to one representative trajectory once the errors
141+
have been computed from it. `errors`, `weak_errors`, `error_means` and `𝒪est` are
142+
computed from the full ensemble either way and are unaffected; what changes is that
143+
`solutions[i].u` holds one entry rather than `trajectories` of them.
144+
145+
Pass `true` when the trajectories themselves are the point, for example to compare
146+
two algorithms path by path.
147+
148+
The reduction is skipped, and the solutions retained, where it cannot apply: when
149+
you supply your own `EnsembleProblem` (set its `output_func` instead), when
150+
`expected_value` is given (the weak error is formed from the trajectory values
151+
directly), and when `weak_timeseries_errors` or `weak_dense_errors` is set (both
152+
need the whole timeseries).
69153
"""
70154
function test_convergence(
71155
dts::AbstractArray,
@@ -77,12 +161,19 @@ function test_convergence(
77161
trajectories, save_start = true, save_everystep = true,
78162
timeseries_errors = save_everystep, adaptive = false,
79163
weak_timeseries_errors = false, weak_dense_errors = false,
80-
expected_value = nothing, kwargs...
164+
expected_value = nothing, retain_solutions = false, kwargs...
81165
)
82166
N = length(dts)
83167

168+
reduce_trajectories = !retain_solutions &&
169+
!(prob isa AbstractEnsembleProblem) &&
170+
expected_value === nothing &&
171+
!weak_timeseries_errors && !weak_dense_errors
172+
84173
if prob isa AbstractEnsembleProblem
85174
ensemble_prob = prob
175+
elseif reduce_trajectories
176+
ensemble_prob = EnsembleProblem(prob; output_func = _convergence_output_func)
86177
else
87178
ensemble_prob = EnsembleProblem(prob)
88179
end
@@ -98,20 +189,24 @@ function test_convergence(
98189
kwargs...
99190
)
100191
@info "dt: $(dts[i]) ($i/$N)"
101-
_solutions[i] = sol
192+
# Summarise each ensemble before solving the next, so that only one step size's
193+
# trajectories are ever reachable rather than the whole study's.
194+
_solutions[i] = if expected_value === nothing
195+
summarised = SciMLBase.calculate_ensemble_errors(
196+
sol;
197+
weak_timeseries_errors = weak_timeseries_errors,
198+
weak_dense_errors = weak_dense_errors
199+
)
200+
reduce_trajectories ? _drop_trajectories(summarised) : summarised
201+
else
202+
sol
203+
end
102204
end
103205

104206
auxdata = Dict("dts" => dts)
105207

106208
if expected_value == nothing
107-
solutions = [
108-
SciMLBase.calculate_ensemble_errors(
109-
sim;
110-
weak_timeseries_errors = weak_timeseries_errors,
111-
weak_dense_errors = weak_dense_errors
112-
)
113-
for sim in _solutions
114-
]
209+
solutions = _solutions
115210
# Now Calculate Weak Errors
116211
additional_errors = Dict()
117212
for k in keys(solutions[1].weak_errors)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using StochasticDiffEq, DiffEqDevTools, Random, Test
2+
3+
# The Monte-Carlo method of `test_convergence` used to keep one full solution object per
4+
# trajectory per step size, which is orders of magnitude more than the error estimates
5+
# need and is what put the weak-convergence groups past a 16 GB runner. Trajectories are
6+
# now reduced by default: an `output_func` turns each into a `ConvergenceTrajectory` as
7+
# it is solved, and the ensemble is stripped to one representative once the errors have
8+
# been computed. The statistics must be identical either way.
9+
10+
f_iip(du, u, p, t) = @.(du = 1.01 * u)
11+
σ_iip(du, u, p, t) = @.(du = 0.87 * u)
12+
linear_analytic(u0, p, t, W) = @.(u0 * exp(0.63155t + 0.87W))
13+
prob = SDEProblem(
14+
SDEFunction(f_iip, σ_iip, analytic = linear_analytic), [1 / 2], (0.0, 1.0)
15+
)
16+
17+
dts = 1 .// 2 .^ (5:-1:3)
18+
ntraj = 400
19+
20+
function run_sim(; kwargs...)
21+
Random.seed!(100)
22+
return test_convergence(
23+
dts, prob, EM(); save_everystep = false, trajectories = ntraj,
24+
weak_timeseries_errors = false, kwargs...
25+
)
26+
end
27+
28+
reduced = run_sim() # the new default
29+
full = run_sim(retain_solutions = true)
30+
31+
@testset "statistics are unchanged" begin
32+
@test keys(full.𝒪est) == keys(reduced.𝒪est)
33+
for k in keys(full.𝒪est)
34+
@test full.𝒪est[k] reduced.𝒪est[k]
35+
end
36+
@test keys(full.errors) == keys(reduced.errors)
37+
for k in keys(full.errors)
38+
@test collect(full.errors[k]) collect(reduced.errors[k])
39+
end
40+
for i in eachindex(dts)
41+
@test full.solutions[i].weak_errors == reduced.solutions[i].weak_errors
42+
@test full.solutions[i].error_means == reduced.solutions[i].error_means
43+
# the per-trajectory error vectors are computed from the full ensemble either way
44+
for k in keys(full.solutions[i].errors)
45+
@test full.solutions[i].errors[k] reduced.solutions[i].errors[k]
46+
@test length(reduced.solutions[i].errors[k]) == ntraj
47+
end
48+
end
49+
end
50+
51+
@testset "trajectories are dropped by default" begin
52+
for i in eachindex(dts)
53+
@test length(reduced.solutions[i].u) == 1
54+
@test length(full.solutions[i].u) == ntraj
55+
end
56+
@test reduced.solutions[1].u[1] isa ConvergenceTrajectory
57+
@test full.solutions[1].u[1] isa SciMLBase.AbstractRODESolution
58+
# the representative keeps the endpoints the weak error is formed from
59+
@test reduced.solutions[1].u[1].u[end] == full.solutions[1].u[1].u[end]
60+
@test reduced.solutions[1].u[1].u_analytic[end] == full.solutions[1].u[1].u_analytic[end]
61+
@test Base.summarysize(reduced.solutions) < Base.summarysize(full.solutions) / 20
62+
end
63+
64+
@testset "reduction is skipped where it cannot apply" begin
65+
# these need the full timeseries, so the solutions are retained rather than the
66+
# call failing
67+
for kwargs in ((; weak_timeseries_errors = true), (; weak_dense_errors = true))
68+
sim = run_sim(; kwargs...)
69+
@test length(sim.solutions[1].u) == ntraj
70+
end
71+
# a caller-supplied EnsembleProblem owns its own output_func
72+
Random.seed!(100)
73+
sim = test_convergence(
74+
dts, EnsembleProblem(prob), EM(); save_everystep = false,
75+
trajectories = ntraj, weak_timeseries_errors = false
76+
)
77+
@test length(sim.solutions[1].u) == ntraj
78+
79+
# `expected_value` averages the trajectory values themselves, so it is used with an
80+
# EnsembleProblem that reduces to the quantity being averaged (as PL1WM.jl does).
81+
# That path must keep every reduced value, not one representative.
82+
Random.seed!(100)
83+
ens = EnsembleProblem(prob; output_func = (sol, ctx) -> (sol.u[end][1], false))
84+
sim = test_convergence(
85+
dts, ens, EM(); save_everystep = false, save_start = false,
86+
trajectories = ntraj, weak_timeseries_errors = false,
87+
expected_value = 1 / 2 * exp(1.01)
88+
)
89+
@test length(sim.solutions[1].u) == ntraj
90+
@test sim.solutions[1].u[1] isa Float64
91+
@test haskey(sim.𝒪est, :weak_final)
92+
end

lib/DiffEqDevTools/test/runtests.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ if TEST_GROUP == "Core" || TEST_GROUP == "ALL"
3030
@time @testset "Analyticless Stochastic WP" begin
3131
include("analyticless_stochastic_wp.jl")
3232
end
33+
@time @testset "Convergence retain_solutions" begin
34+
include("retain_solutions_tests.jl")
35+
end
3336
@time @testset "Stability Region Tests" begin
3437
include("stability_region_test.jl")
3538
end

lib/OrdinaryDiffEqAdamsBashforthMoulton/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Pkg = "1"
3434
Test = "<0.0.1, 1"
3535
FastBroadcast = "1.3"
3636
Random = "<0.0.1, 1"
37-
DiffEqDevTools = "3"
37+
DiffEqDevTools = "4"
3838
MuladdMacro = "0.2.4"
3939
SciMLBase = "3.39"
4040
OrdinaryDiffEqCore = "4"

lib/OrdinaryDiffEqBDF/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ ForwardDiff = "1.3.3"
5353
Test = "<0.0.1, 1"
5454
FastBroadcast = "1.3"
5555
Random = "<0.0.1, 1"
56-
DiffEqDevTools = "3"
56+
DiffEqDevTools = "4"
5757
DifferentiationInterface = "0.7.18"
5858
MuladdMacro = "0.2.4"
5959
LinearSolve = "5.1"

lib/OrdinaryDiffEqCore/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ ArrayInterface = "7.28"
5858
BinaryHeaps = "1"
5959
CommonSolve = "0.2.6"
6060
DiffEqBase = "7.8"
61-
DiffEqDevTools = "3"
61+
DiffEqDevTools = "4"
6262
DifferentiationInterface = "0.7.18"
6363
DocStringExtensions = "0.9.5"
6464
EnumX = "1.0.4"

0 commit comments

Comments
 (0)