Tutorial: Solution of the heat equation with Neumann boundary conditions
Similar to the tutorial on linear advection, we will demonstrate how to solve a conservative production-destruction system (PDS) resulting from a PDE discretization and means to improve the performance.
Definition of the conservative production-destruction system
Consider the heat equation
\[\partial_t u(t,x) = \mu \partial_x^2 u(t,x),\quad u(0,x)=u_0(x),\]
with $μ ≥ 0$, $t≥ 0$, $x\in[0,1]$, and homogeneous Neumann boundary conditions. We use a finite volume discretization, i.e., we split the domain $[0, 1]$ into $N$ uniform cells of width $\Delta x = 1 / N$. As degrees of freedom, we use the mean values of $u(t)$ in each cell approximated by the point value $u_i(t)$ in the center of cell $i$. Finally, we use the classical central finite difference discretization of the Laplacian with homogeneous Neumann boundary conditions, resulting in the ODE
\[\partial_t u(t) = L u(t), \quad L = \frac{\mu}{\Delta x^2} \begin{pmatrix} -1 & 1 \\ 1 & -2 & 1 \\ & \ddots & \ddots & \ddots \\ && 1 & -2 & 1 \\ &&& 1 & -1 \end{pmatrix}.\]
The system can be written as a conservative PDS with production terms
\[\begin{aligned} &p_{i,i-1}(t,\mathbf u(t)) = \frac{\mu}{\Delta x^2} u_{i-1}(t),\quad i=2,\dots,N, \\ &p_{i,i+1}(t,\mathbf u(t)) = \frac{\mu}{\Delta x^2} u_{i+1}(t),\quad i=1,\dots,N-1, \end{aligned}\]
and destruction terms $d_{i,j} = p_{j,i}$. In addition, all production and destruction terms not listed are zero.
Solution of the conservative production-destruction system
Now we are ready to define a ConservativePDSProblem and to solve this problem with a method of PositiveIntegrators.jl or OrdinaryDiffEq.jl. In the following we use $N = 100$ nodes and the time domain $t \in [0,1]$. Moreover, we choose the initial condition
\[u_0(x) = \cos(\pi x)^2.\]
x_boundaries = range(0, 1, length = 101)
x = x_boundaries[1:end-1] .+ step(x_boundaries) / 2
u0 = @. cospi(x)^2 # initial solution
tspan = (0.0, 1.0) # time domainWe will choose three different matrix types for the production terms and the resulting linear systems:
- standard dense matrices (default)
- sparse matrices (from SparseArrays.jl)
- tridiagonal matrices (from LinearAlgebra.jl)
Standard dense matrices
using PositiveIntegrators # load ConservativePDSProblem
function heat_eq_P!(P, u, μ, t)
fill!(P, 0)
N = length(u)
Δx = 1 / N
μ_Δx2 = μ / Δx^2
let i = 1
# Neumann boundary condition
P[i, i + 1] = u[i + 1] * μ_Δx2
end
for i in 2:(length(u) - 1)
# interior stencil
P[i, i - 1] = u[i - 1] * μ_Δx2
P[i, i + 1] = u[i + 1] * μ_Δx2
end
let i = length(u)
# Neumann boundary condition
P[i, i - 1] = u[i - 1] * μ_Δx2
end
return nothing
end
μ = 1.0e-2
prob = ConservativePDSProblem(heat_eq_P!, u0, tspan, μ) # create the PDS
sol = solve(prob, MPRK22(1.0); save_everystep = false)using Plots
plot(x, u0; label = "u0", xguide = "x", yguide = "u")
plot!(x, last(sol.u); label = "u")Sparse matrices
To use different matrix types for the production terms and linear systems, you can use the keyword argument p_prototype of ConservativePDSProblem and PDSProblem.
using SparseArrays
p_prototype = spdiagm(-1 => ones(eltype(u0), length(u0) - 1),
+1 => ones(eltype(u0), length(u0) - 1))
prob_sparse = ConservativePDSProblem(heat_eq_P!, u0, tspan, μ;
p_prototype = p_prototype)
sol_sparse = solve(prob_sparse, MPRK22(1.0); save_everystep = false)plot(x, u0; label = "u0", xguide = "x", yguide = "u")
plot!(x, last(sol_sparse.u); label = "u")Tridiagonal matrices
The sparse matrices used in this case have a very special structure since they are in fact tridiagonal matrices. Thus, we can also use the special matrix type Tridiagonal from the standard library LinearAlgebra.
using LinearAlgebra
p_prototype = Tridiagonal(ones(eltype(u0), length(u0) - 1),
ones(eltype(u0), length(u0)),
ones(eltype(u0), length(u0) - 1))
prob_tridiagonal = ConservativePDSProblem(heat_eq_P!, u0, tspan, μ;
p_prototype = p_prototype)
sol_tridiagonal = solve(prob_tridiagonal, MPRK22(1.0); save_everystep = false)plot(x, u0; label = "u0", xguide = "x", yguide = "u")
plot!(x, last(sol_tridiagonal.u); label = "u")Performance comparison
Finally, we use BenchmarkTools.jl to compare the performance of the different implementations.
using BenchmarkTools
@benchmark solve(prob, MPRK22(1.0); save_everystep = false)BenchmarkTools.Trial: 834 samples with 1 evaluation per sample.
Range (min … max): 5.024 ms … 13.804 ms ┊ GC (min … max): 0.00% … 57.38%
Time (median): 5.564 ms ┊ GC (median): 0.00%
Time (mean ± σ): 5.990 ms ± 1.128 ms ┊ GC (mean ± σ): 5.64% ± 10.96%
█▄▂
▂▃▃▅▆▆▇███▄▃▂▂▁▁▁▁▁▁▁▁▁▁▁▂▁▁▂▂▂▃▂▃▃▃▂▁▁▂▂▁▁▁▁▁▁▂▂▃▃▃▃▃▃▃▂▂ ▃
5.02 ms Histogram: frequency by time 8.96 ms <
Memory estimate: 5.11 MiB, allocs estimate: 375.@benchmark solve(prob_sparse, MPRK22(1.0); save_everystep = false)BenchmarkTools.Trial: 1425 samples with 1 evaluation per sample.
Range (min … max): 2.879 ms … 16.129 ms ┊ GC (min … max): 0.00% … 30.95%
Time (median): 3.114 ms ┊ GC (median): 0.00%
Time (mean ± σ): 3.503 ms ± 1.188 ms ┊ GC (mean ± σ): 8.05% ± 12.25%
▃▇█▅▂
██████▇▅▆▄▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▄▄▆▇▇▇▇███▇▇▇▇▇▆▅▅▁▄ █
2.88 ms Histogram: log(frequency) by time 6.99 ms <
Memory estimate: 4.77 MiB, allocs estimate: 2222.By default, we use an LU factorization for the linear systems. At the time of writing, Julia uses SparseArrays.jl defaulting to UMFPACK from SuiteSparse in this case. However, the linear systems do not necessarily have the structure for which UMFPACK is optimized for. Thus, it is often possible to gain performance by switching to KLU instead.
using LinearSolve
@benchmark solve(prob_sparse, MPRK22(1.0; linsolve = KLUFactorization()); save_everystep = false)BenchmarkTools.Trial: 9026 samples with 1 evaluation per sample.
Range (min … max): 522.227 μs … 47.999 ms ┊ GC (min … max): 0.00% … 72.05%
Time (median): 541.306 μs ┊ GC (median): 0.00%
Time (mean ± σ): 551.760 μs ± 601.094 μs ┊ GC (mean ± σ): 1.16% ± 1.07%
▂▁▄▅▆▆▇██▇█▇▆▆▅▄▁
▁▁▂▂▃▅██████████████████▇▅▅▄▃▃▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▄
522 μs Histogram: frequency by time 594 μs <
Memory estimate: 103.41 KiB, allocs estimate: 162.@benchmark solve(prob_tridiagonal, MPRK22(1.0); save_everystep = false)BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample.
Range (min … max): 200.801 μs … 85.119 ms ┊ GC (min … max): 0.00% … 99.59%
Time (median): 279.507 μs ┊ GC (median): 0.00%
Time (mean ± σ): 299.517 μs ± 1.498 ms ┊ GC (mean ± σ): 16.62% ± 5.72%
██▁ ▁▃▃▁
▃▇███▅▄▄▄▅▄▄▃▃▂▂▂▂▂▂▂▂▁▁▂▂▁▂▁▁▂▁▁▂▂▂▁▂▁▁▁▁▂▃▄████▇▆▆▅▄▄▃▂▂▂▂ ▃
201 μs Histogram: frequency by time 309 μs <
Memory estimate: 299.91 KiB, allocs estimate: 834.Package versions
These results were obtained using the following versions.
using InteractiveUtils
versioninfo()
println()
using Pkg
Pkg.status(["PositiveIntegrators", "SparseArrays", "KLU", "LinearSolve"],
mode=PKGMODE_MANIFEST)Julia Version 1.12.5
Commit 5fe89b8ddc1 (2026-02-09 16:05 UTC)
Build Info:
Official https://julialang.org release
Platform Info:
OS: Linux (x86_64-linux-gnu)
CPU: 4 × Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
WORD_SIZE: 64
LLVM: libLLVM-18.1.7 (ORCJIT, icelake-server)
GC: Built with stock GC
Threads: 1 default, 1 interactive, 1 GC (on 4 virtual cores)
Environment:
JULIA_PKG_SERVER_REGISTRY_PREFERENCE = eager
Status `~/work/PositiveIntegrators.jl/PositiveIntegrators.jl/docs/Manifest.toml`
[7ed4a6bd] LinearSolve v3.65.0
[d1b20bf0] PositiveIntegrators v0.2.16 `~/work/PositiveIntegrators.jl/PositiveIntegrators.jl`
[2f01184e] SparseArrays v1.12.0