Skip to content

Commit 50a2364

Browse files
Let convergence studies drop the trajectories they are not measuring
`test_convergence` retains 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 die 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. Add a `save_solutions` keyword, default `true` so existing behaviour is unchanged. With `false`, build the `EnsembleProblem` 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 rather than only 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. Reject the combinations that cannot work with an explicit `ArgumentError`: `weak_timeseries_errors` and `weak_dense_errors` need the full timeseries, `expected_value` averages the trajectory values themselves, and an `EnsembleProblem` supplied by the caller has its own `output_func` to set. 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 four weak files, 16 GiB cgroup, JULIA_NUM_THREADS=2: weak_srockc2.jl OOM 15.61 GiB @3m29s -> exit 0, 24m31s, 4.69 GiB oop_weak.jl OOM 15.62 GiB @23m50s -> exit 0, 12m48s, 1.58 GiB additive_weak.jl (same group) -> exit 0, 3m10s, 1.03 GiB iip_weak.jl OOM 15.61 GiB @21m24s -> runs fully, 10m13s, 2.22 GiB Order estimates are bit-identical with and without the reduction. With no group needing more than 4.69 GiB, remove the `high-memory` runner pin from all eight groups that carried it. The five StochasticDiffEqWeak groups needed no code 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. 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 50a2364

13 files changed

Lines changed: 242 additions & 72 deletions

File tree

docs/src/devtools/alg_dev/convergence.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,26 @@ log2(error[i+1]/error[i])
9292

9393
Returns the mean of the convergence estimates.
9494

95+
## Memory use of Monte-Carlo studies
96+
97+
For an SDE or RODE problem, `test_convergence` runs an ensemble per step size and by
98+
default keeps every trajectory's full solution in `solutions`. Each of those carries the
99+
solver cache, the noise process and the problem, so a study over many trajectories
100+
retains far more than the error estimates it reports — at hundreds of thousands of
101+
trajectories it reaches tens of gigabytes.
102+
103+
Pass `save_solutions = false` to reduce each trajectory to a
104+
[`ConvergenceTrajectory`](@ref) as soon as it is solved, keeping only the errors and
105+
endpoints the order estimate is computed from. The order estimates are identical; only
106+
the contents of `solutions` change. This is incompatible with `weak_timeseries_errors`
107+
and `weak_dense_errors`, which need the whole timeseries, and it does not apply when you
108+
supply your own `EnsembleProblem` — set that problem's `output_func` instead.
109+
95110
## API
96111

97112
```@docs
98113
DiffEqDevTools.ConvergenceSimulation
114+
DiffEqDevTools.ConvergenceTrajectory
99115
DiffEqDevTools.test_convergence
100116
DiffEqDevTools.analyticless_test_convergence
101117
```

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 = "3.2.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: 87 additions & 2 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,46 @@ 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 when `save_solutions = false`, 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+
return (reduced, false)
94+
end
95+
5796
"""
5897
test_convergence(dts, prob, alg[, ensemblealg]; kwargs...)
5998
test_convergence(probs, convergence_axis, alg; kwargs...)
@@ -66,6 +105,19 @@ is passed to the solver as `dt`. Remaining keyword arguments are forwarded to `s
66105
The computed solutions must contain error measurements, usually obtained from an
67106
analytic solution. Use [`analyticless_test_convergence`](@ref) when only a numerical
68107
reference solution is available.
108+
109+
## Keyword Arguments
110+
111+
- `save_solutions`: whether `ConvergenceSimulation.solutions` keeps the full
112+
trajectory solutions (default `true`). A Monte-Carlo study over many trajectories
113+
retains one complete solution object per trajectory per step size, which for large
114+
`trajectories` is orders of magnitude more memory than the error estimates it is
115+
computing. Pass `false` to store a [`ConvergenceTrajectory`](@ref) per trajectory
116+
instead, holding only the errors and endpoints the order estimate is derived from.
117+
Only applies when this function builds the `EnsembleProblem` itself — pass your own
118+
`output_func` to control the reduction when supplying an `EnsembleProblem` — and it
119+
is incompatible with `weak_timeseries_errors` and `weak_dense_errors`, which need
120+
the whole timeseries.
69121
"""
70122
function test_convergence(
71123
dts::AbstractArray,
@@ -77,12 +129,45 @@ function test_convergence(
77129
trajectories, save_start = true, save_everystep = true,
78130
timeseries_errors = save_everystep, adaptive = false,
79131
weak_timeseries_errors = false, weak_dense_errors = false,
80-
expected_value = nothing, kwargs...
132+
expected_value = nothing, save_solutions = true, kwargs...
81133
)
82134
N = length(dts)
83135

136+
reduce_trajectories = !save_solutions && !(prob isa AbstractEnsembleProblem)
137+
if !save_solutions && !reduce_trajectories
138+
throw(
139+
ArgumentError(
140+
"`save_solutions = false` reduces each trajectory through the " *
141+
"`EnsembleProblem`'s `output_func`, so it cannot be applied to an " *
142+
"`EnsembleProblem` you supplied yourself. Set `output_func` on your " *
143+
"`EnsembleProblem` instead."
144+
)
145+
)
146+
end
147+
if reduce_trajectories && (weak_timeseries_errors || weak_dense_errors)
148+
throw(
149+
ArgumentError(
150+
"`save_solutions = false` keeps only each trajectory's errors and " *
151+
"endpoints, but `weak_timeseries_errors` and `weak_dense_errors` are " *
152+
"computed from the full timeseries. Use one or the other."
153+
)
154+
)
155+
end
156+
if reduce_trajectories && expected_value !== nothing
157+
throw(
158+
ArgumentError(
159+
"`save_solutions = false` replaces each trajectory with its errors and " *
160+
"endpoints, but `expected_value` forms the weak error from the " *
161+
"trajectory values directly. Supply an `EnsembleProblem` whose " *
162+
"`output_func` reduces to the quantity being averaged instead."
163+
)
164+
)
165+
end
166+
84167
if prob isa AbstractEnsembleProblem
85168
ensemble_prob = prob
169+
elseif reduce_trajectories
170+
ensemble_prob = EnsembleProblem(prob; output_func = _convergence_output_func)
86171
else
87172
ensemble_prob = EnsembleProblem(prob)
88173
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 save_solutions" begin
34+
include("save_solutions_tests.jl")
35+
end
3336
@time @testset "Stability Region Tests" begin
3437
include("stability_region_test.jl")
3538
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using StochasticDiffEq, DiffEqDevTools, Random, Test
2+
3+
# `test_convergence` keeps one full solution object per trajectory per step size, which
4+
# on a Monte-Carlo study is orders of magnitude more than the error estimates need.
5+
# `save_solutions = false` reduces each trajectory to a `ConvergenceTrajectory` through
6+
# the ensemble's `output_func`. The order estimates must be unaffected.
7+
8+
f_iip(du, u, p, t) = @.(du = 1.01 * u)
9+
σ_iip(du, u, p, t) = @.(du = 0.87 * u)
10+
linear_analytic(u0, p, t, W) = @.(u0 * exp(0.63155t + 0.87W))
11+
prob = SDEProblem(
12+
SDEFunction(f_iip, σ_iip, analytic = linear_analytic), [1 / 2], (0.0, 1.0)
13+
)
14+
15+
dts = 1 .// 2 .^ (5:-1:3)
16+
ntraj = 400
17+
18+
function run_sim(save_solutions)
19+
Random.seed!(100)
20+
return test_convergence(
21+
dts, prob, EM(); save_everystep = false, trajectories = ntraj,
22+
weak_timeseries_errors = false, save_solutions = save_solutions
23+
)
24+
end
25+
26+
full = run_sim(true)
27+
reduced = run_sim(false)
28+
29+
@testset "order estimates are unchanged" begin
30+
@test keys(full.𝒪est) == keys(reduced.𝒪est)
31+
for k in keys(full.𝒪est)
32+
@test full.𝒪est[k] reduced.𝒪est[k]
33+
end
34+
@test keys(full.errors) == keys(reduced.errors)
35+
for k in keys(full.errors)
36+
@test collect(full.errors[k]) collect(reduced.errors[k])
37+
end
38+
end
39+
40+
@testset "trajectories are reduced" begin
41+
@test reduced.solutions[1].u[1] isa ConvergenceTrajectory
42+
@test full.solutions[1].u[1] isa SciMLBase.AbstractRODESolution
43+
# the endpoints the weak errors are formed from must survive the reduction
44+
for i in eachindex(dts), j in 1:ntraj
45+
@test reduced.solutions[i].u[j].u[end] == full.solutions[i].u[j].u[end]
46+
@test reduced.solutions[i].u[j].u_analytic[end] ==
47+
full.solutions[i].u[j].u_analytic[end]
48+
end
49+
@test Base.summarysize(reduced.solutions) < Base.summarysize(full.solutions) / 5
50+
end
51+
52+
@testset "incompatible options are rejected" begin
53+
# these need the whole timeseries, which the reduction throws away
54+
@test_throws ArgumentError test_convergence(
55+
dts, prob, EM(); save_everystep = false, trajectories = 10,
56+
save_solutions = false, weak_timeseries_errors = true
57+
)
58+
@test_throws ArgumentError test_convergence(
59+
dts, prob, EM(); save_everystep = false, trajectories = 10,
60+
save_solutions = false, weak_dense_errors = true
61+
)
62+
# the reduction goes through the ensemble's output_func, so it cannot be applied
63+
# on top of an EnsembleProblem the caller built themselves
64+
@test_throws ArgumentError test_convergence(
65+
dts, EnsembleProblem(prob), EM(); save_everystep = false, trajectories = 10,
66+
save_solutions = false
67+
)
68+
# `expected_value` averages the trajectory values themselves, which the reduction
69+
# replaces with a ConvergenceTrajectory
70+
@test_throws ArgumentError test_convergence(
71+
dts, prob, EM(); save_everystep = false, trajectories = 10,
72+
save_solutions = false, expected_value = 0.5
73+
)
74+
end

lib/StochasticDiffEq/test/test_groups.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,11 @@ local_only = true
3232

3333
[OOPWeakConvergence]
3434
versions = ["1"]
35-
runner = ["self-hosted", "Linux", "X64", "high-memory"]
3635
timeout = 300
3736
local_only = true
3837

3938
[IIPWeakConvergence]
4039
versions = ["1"]
41-
runner = ["self-hosted", "Linux", "X64", "high-memory"]
4240
timeout = 300
4341
local_only = true
4442

0 commit comments

Comments
 (0)