-
-
Notifications
You must be signed in to change notification settings - Fork 84
/
liquid_argon.jmd
314 lines (270 loc) · 9.42 KB
/
liquid_argon.jmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
---
title: Liquid argon benchmarks
author: Sebastian Micluța-Câmpeanu, Mikhail Vaganov
---
The purpose of these benchmarks is to compare several integrators for use in
molecular dynamics simulation. We will use a simulation of liquid argon form the
examples of NBodySimulator as test case.
```julia
using ProgressLogging
using NBodySimulator, OrdinaryDiffEq, StaticArrays
using Plots, DataFrames, StatsPlots
function setup(t)
T = 120.0 # K
kb = 1.38e-23 # J/K
ϵ = T * kb # J
σ = 3.4e-10 # m
ρ = 1374 # kg/m^3
m = 39.95 * 1.6747 * 1e-27 # kg
N = 350
L = (m*N/ρ)^(1/3)
R = 3.5σ
v_dev = sqrt(kb * T / m) # m/s
_L = L / σ
_σ = 1.0
_ϵ = 1.0
_m = 1.0
_v = v_dev / sqrt(ϵ / m)
_R = R / σ
bodies = generate_bodies_in_cell_nodes(N, _m, _v, _L)
lj_parameters = LennardJonesParameters(_ϵ, _σ, _R)
pbc = CubicPeriodicBoundaryConditions(_L)
lj_system = PotentialNBodySystem(bodies, Dict(:lennard_jones => lj_parameters));
simulation = NBodySimulation(lj_system, (0.0, t), pbc, _ϵ/T)
return simulation
end
```
In order to compare different integrating methods we will consider a fixed simulation
time and change the timestep (or tolerances in the case of adaptive methods).
```julia
function benchmark(energyerr, rts, bytes, allocs, nt, nf, t, configs)
simulation = setup(t)
prob = SecondOrderODEProblem(simulation)
for config in configs
alg = config.alg
sol, rt, b, gc, memalloc = @timed solve(prob, alg();
save_everystep=false, progress=true, progress_name="$alg", config...)
result = NBodySimulator.SimulationResult(sol, simulation)
ΔE = total_energy(result, t) - total_energy(result, 0)
energyerr[alg] = ΔE
rts[alg] = rt
bytes[alg] = b
allocs[alg] = memalloc
nt[alg] = sol.destats.naccept
nf[alg] = sol.destats.nf + sol.destats.nf2
end
end
function run_benchmark!(results, t, integrators, tol...; c=ones(length(integrators)))
@progress "Benchmark at t=$t" for τ in zip(tol...)
runtime = Dict()
ΔE = Dict()
nt = Dict()
nf = Dict()
b = Dict()
allocs = Dict()
cfg = config(integrators, c, τ...)
GC.gc()
benchmark(ΔE, runtime, b, allocs, nt, nf, t, cfg)
get_tol(idx) = haskey(cfg[idx], :dt) ? cfg[idx].dt : (cfg[idx].abstol, cfg[idx].rtol)
for (idx,i) in enumerate(integrators)
push!(results, [string(i), runtime[i], get_tol(idx)..., abs(ΔE[i]), nt[i], nf[i], c[idx]])
end
end
return results
end
```
We will consider symplectic integrators first
```julia
symplectic_integrators = [
VelocityVerlet,
VerletLeapfrog,
PseudoVerletLeapfrog,
McAte2,
CalvoSanz4,
McAte5,
Yoshida6,
KahanLi8,
SofSpa10
];
```
Since for each method there is a different cost for a timestep, we need to take that
into account when choosing the tolerances (`dt`s or `abstol`&`reltol`) for the
solvers. This cost was estimated using the commented code below and the
results were hardcoded in order to prevent fluctuations in the results
between runs due to differences in callibration times.
The calibration is based on running a simulation with equal tolerances for all
solvers and then computing the cost as the runtime / number of timesteps.
The absolute value of the cost is not very relevant, so the cost was normalized
to the cost of one `VelocityVerlet` step.
```julia
config(integrators, c, τ) = [ (alg=a, dt=τ*cₐ) for (a,cₐ) in zip(integrators, c)]
t = 35.0
τs = 1e-3
# warmup
c_symplectic = ones(length(symplectic_integrators))
benchmark(Dict(), Dict(), Dict(), Dict(), Dict(), Dict(), 10.,
config(symplectic_integrators, c_symplectic, τs))
# results = DataFrame(:integrator=>String[], :runtime=>Float64[], :τ=>Float64[],
# :EnergyError=>Float64[], :timesteps=>Int[], :f_evals=>Int[], :cost=>Float64[]);
# run_benchmark!(results, t, symplectic_integrators, τs)
# c_symplectic .= results[!, :runtime] ./ results[!, :timesteps]
# c_Verlet = c_symplectic[1]
# c_symplectic /= c_Verlet
c_symplectic = [
1.00, # VelocityVerlet
1.05, # VerletLeapfrog
0.98, # PseudoVerletLeapfrog
1.02, # McAte2
2.38, # CalvoSanz4
2.92, # McAte5
3.74, # Yoshida6
8.44, # KahanLi8
15.76 # SofSpa10
]
```
Let us now benchmark the solvers for a fixed simulation time and variable timestep
```julia
t = 40.0
τs = 10 .^range(-4, -3, length=10)
results = DataFrame(:integrator=>String[], :runtime=>Float64[], :τ=>Float64[],
:EnergyError=>Float64[], :timesteps=>Int[], :f_evals=>Int[], :cost=>Float64[]);
run_benchmark!(results, t, symplectic_integrators, τs, c=c_symplectic)
```
The energy error as a function of runtime is given by
```julia
@df results plot(:EnergyError, :runtime, group=:integrator,
xscale=:log10, yscale=:log10, xlabel="Energy error", ylabel="Runtime (s)")
```
Looking at the runtime as a function of timesteps, we can observe that we have
a linear dependency for each method, and the slope is the previously computed
cost per step.
```julia
@df results plot(:timesteps, :runtime, group=:integrator,
xscale=:log10, yscale=:log10, xlabel="Number of timesteps", ylabel="Runtime (s)")
```
We can also look at the energy error history
```julia
function benchmark(energyerr, rts, ts, t, configs)
simulation = setup(t)
prob = SecondOrderODEProblem(simulation)
for config in configs
alg = config.alg
sol, rt = @timed solve(prob, alg(); progress=true, progress_name="$alg", config...)
result = NBodySimulator.SimulationResult(sol, simulation)
ΔE(t) = total_energy(result, t) - total_energy(result, 0)
energyerr[alg] = [ΔE(t) for t in sol.t[2:10^2:end]]
rts[alg] = rt
ts[alg] = sol.t[2:10^2:end]
end
end
ΔE = Dict()
rt = Dict()
ts = Dict()
configs = config(symplectic_integrators, c_symplectic, 2.3e-4)
benchmark(ΔE, rt, ts, 40., configs)
plt = plot(xlabel="Rescaled Time", ylabel="Energy error", legend=:bottomleft);
for c in configs
plot!(plt, ts[c.alg], abs.(ΔE[c.alg]), label="$(c.alg), $(rt[c.alg])s")
end
plt
```
Now, let us compare some adaptive methods
```julia
adaptive_integrators=[
# Non-stiff ODE methods
Tsit5,
Vern7,
Vern9,
# DPRKN
DPRKN6,
DPRKN8,
DPRKN12,
];
```
Similarly to the case of symplectic methods, we will take into account the average cost per timestep
in order to have a fair comparison between the solvers.
```julia
config(integrators, c, at, rt) = [ (alg=a, abstol=at*2^cₐ, rtol=rt*2^cₐ) for (a,cₐ) in zip(integrators, c)]
t = 35.0
ats = 10 .^range(-14, -4, length=10)
rts = 10 .^range(-14, -4, length=10)
# warmup
c_adaptive = ones(length(adaptive_integrators))
benchmark(Dict(), Dict(), Dict(), Dict(), Dict(), Dict(), 10.,
config(adaptive_integrators, 1, ats[1], rts[1]))
# results = DataFrame(:integrator=>String[], :runtime=>Float64[], :abstol=>Float64[],
# :reltol=>Float64[], :EnergyError=>Float64[], :timesteps=>Int[], :f_evals=>Int[], :cost=>Float64[]);
# run_benchmark!(results, t, adaptive_integrators, ats[1], rts[1])
# c_adaptive .= results[!, :runtime] ./ results[!, :timesteps]
# c_adaptive /= c_Verlet
c_adaptive = [
3.55, # Tsit5,
7.84, # Vern7,
11.38, # Vern9
3.56, # DPRKN6,
5.10, # DPRKN8,
8.85 # DPRKN12,
]
```
Let us now benchmark the solvers for a fixed simulation time and variable timestep
```julia
t = 40.0
results = DataFrame(:integrator=>String[], :runtime=>Float64[], :abstol=>Float64[],
:reltol=>Float64[], :EnergyError=>Float64[], :timesteps=>Int[], :f_evals=>Int[], :cost=>Float64[]);
run_benchmark!(results, t, adaptive_integrators, ats, rts, c=c_adaptive)
```
The energy error as a function of runtime is given by
```julia
@df results plot(:EnergyError, :runtime, group=:integrator,
xscale=:log10, yscale=:log10, xlabel="Energy error", ylabel="Runtime (s)")
```
If we consider the number of function evaluations instead, we obtain
```julia
@df results plot(:EnergyError, :f_evals, group=:integrator,
xscale=:log10, yscale=:log10, xlabel="Energy error", ylabel="Number of f evals")
```
We will now compare the best performing solvers
```julia
t = 40.0
symplectic_integrators = [
VelocityVerlet,
VerletLeapfrog,
PseudoVerletLeapfrog,
McAte2,
CalvoSanz4
]
c_symplectic = [
1.00, # VelocityVerlet
1.05, # VerletLeapfrog
0.98, # PseudoVerletLeapfrog
1.02, # McAte2
2.38, # CalvoSanz4
]
results1 = DataFrame(:integrator=>String[], :runtime=>Float64[], :τ=>Float64[],
:EnergyError=>Float64[], :timesteps=>Int[], :f_evals=>Int[], :cost=>Float64[]);
run_benchmark!(results1, t, symplectic_integrators, τs, c=c_symplectic)
adaptive_integrators=[
DPRKN6,
DPRKN8,
DPRKN12,
]
c_adaptive = [
3.56, # DPRKN6,
5.10, # DPRKN8,
8.85 # DPRKN12,
]
results2 = DataFrame(:integrator=>String[], :runtime=>Float64[], :abstol=>Float64[],
:reltol=>Float64[], :EnergyError=>Float64[], :timesteps=>Int[], :f_evals=>Int[], :cost=>Float64[]);
run_benchmark!(results2, t, adaptive_integrators, ats, rts, c=c_adaptive)
append!(results1, results2, cols=:union)
results1
```
The energy error as a function of runtime is given by
```julia
@df results1 plot(:EnergyError, :runtime, group=:integrator,
xscale=:log10, yscale=:log10, xlabel="Energy error", ylabel="Runtime (s)")
```
```julia, echo = false
using SciMLBenchmarks
SciMLBenchmarks.bench_footer(WEAVE_ARGS[:folder],WEAVE_ARGS[:file])
```