Документация Engee

Оптимизация кода для дифференциальных уравнений

См. эти ответы на вопросы для получения информации о типичных сложностях и способах повышения производительности.

Оптимизация кода в Julia

Прежде чем приступить к работе с данным руководством, рекомендуется ознакомиться с одним из многочисленных руководств по оптимизации кода Julia. Вот неполный список:

Оптимизации на стороне пользователя имеют большое значение, так как для достаточно сложных задач значительная часть времени будет проводиться внутри функции f — функции, которую вы пытаетесь решить. Эффективные интеграторы позволяют сократить необходимое количество вызовов функции f для получения допустимой погрешности. Основные идеи по оптимизации кода DiffEq или любой функции Julia заключаются в следующем.

  • Сделать его невыделяемым

  • Использовать StaticArrays для небольших массивов

  • Использовать слияние трансляции

  • Сделать его стабильным по типу

  • Сократить избыточные вычисления

  • Использовать вызовы BLAS

  • Оптимизировать выбор алгоритма

Мы обсудим эти стратегии в контексте дифференциальных уравнений. Начнем с небольших систем.

Пример ускорения решения нежесткого уравнения. Уравнение Лоренца

Возьмем классическую систему Лоренца. Сначала просто напишем систему в ее форме не на месте:

function lorenz(u, p, t)
    dx = 10.0 * (u[2] - u[1])
    dy = u[1] * (28.0 - u[3]) - u[2]
    dz = u[1] * u[2] - (8 / 3) * u[3]
    [dx, dy, dz]
end
lorenz (generic function with 1 method)

Здесь lorenz возвращает объект [dx,dy,dz], который создается в теле lorenz.

Это распространенный шаблон кода из языков высокого уровня, таких как MATLAB, SciPy или deSolve в R. Однако проблема этой формы заключается в том, что на каждом шаге выделяется вектор [dx,dy,dz]. Проведем сравнительный анализ процесса решения при таком выборе функции:

using DifferentialEquations, BenchmarkTools
u0 = [1.0; 0.0; 0.0]
tspan = (0.0, 100.0)
prob = ODEProblem(lorenz, u0, tspan)
@btime solve(prob, Tsit5());
  3.811 ms (101102 allocations: 7.82 MiB)

@benchmark пакета BenchmarkTools.jl выполняет код несколько раз, чтобы получить точное измерение. Минимальное время — это время, которое требуется, когда ОС и другие фоновые процессы не мешают работе. Обратите внимание, что в данном случае решение занимает около 5 мс и выделяет около 11,11 МиБ. Однако если использовать это в реальном пользовательском коде, мы увидим, что много времени тратится на сборку мусора (GC) для очистки всех созданных массивов. Даже если отключить сохранение, эти выделения все равно останутся.

@btime solve(prob, Tsit5(), save_everystep = false);
  3.098 ms (89529 allocations: 6.83 MiB)

Проблема заключается в том, что массивы создаются каждый раз, когда вызывается производная функция. Эта функция вызывается несколько раз за шаг и, таким образом, является основным источником потребления памяти. Для исправления этого момента можно использовать форму на месте, чтобы сделать код не поддерживающим выделение:

function lorenz!(du, u, p, t)
    du[1] = 10.0 * (u[2] - u[1])
    du[2] = u[1] * (28.0 - u[3]) - u[2]
    du[3] = u[1] * u[2] - (8 / 3) * u[3]
    nothing
end
lorenz! (generic function with 1 method)

Здесь вместо того, чтобы каждый раз создавать массив, мы использовали массив кэша du. Когда действует форма на месте, DifferentialEquations.jl использует другой внутренний маршрут, который также минимизирует внутренние выделения.

Обратите внимание, что ничего не возвращается. В форме на месте решатель ODE игнорирует возврат. Убедитесь, что исходный массив du изменяется, а не создается новый массив.

При поведении анализа этой функции мы увидим существенную разницу.

u0 = [1.0; 0.0; 0.0]
tspan = (0.0, 100.0)
prob = ODEProblem(lorenz!, u0, tspan)
@btime solve(prob, Tsit5());
  806.701 μs (11415 allocations: 996.33 KiB)
@btime solve(prob, Tsit5(), save_everystep = false);
  368.701 μs (49 allocations: 4.00 KiB)

Только из-за этого изменения достигнута 16-кратная разница во времени. Однако некоторые выделения все же есть, и это связано с построением кэша интеграции. Но они не масштабируются с размером задачи:

tspan = (0.0, 500.0) # в 5 раз дольше, чем раньше
prob = ODEProblem(lorenz!, u0, tspan)
@btime solve(prob, Tsit5(), save_everystep = false);
  1.708 ms (49 allocations: 4.00 KiB)

Поскольку это все выделения при настройке, оптимизация на стороне пользователя завершена.

Дальнейшая оптимизация небольших нежестких обыкновенных дифференциальных уравнений с помощью StaticArrays

Выделения являются дорогими только в том случае, если они являются выделениями памяти в куче. Более подробное определение выделений памяти в куче можно найти во многих источниках в Интернете. Но хорошим рабочим определением является то, что выделения памяти в куче — это участки памяти переменного размера, на которые необходимо указывать, и эта косвенная адресация указателей стоит времени. Кроме того, кучей необходимо управлять, контроллеры мусора должны активно следить за тем, что находится в куче.

Однако существует альтернатива выделению памяти в куче, известная как выделение памяти в стеке. Стек имеет статический размер (известный во время компиляции), поэтому доступ к нему осуществляется быстро. Кроме того, компилятору заранее известен точный блок памяти, и поэтому повторное использование памяти обходится дешево. Это означает, что выделение в стеке практически ничего не стоит.

Массивы должны выделяться в куче, поскольку их размер (а значит, и объем занимаемой ими памяти) определяется во время выполнения программы. Но в Julia есть структуры с выделением памяти в стеке. Например, структуры (struct) — это «значения-типы» с выделением памяти в стеке. Кортежи (Tuple) — это коллекция с выделением памяти в стеке. Однако наиболее полезной структурой данных для DiffEq является структура StaticArray из пакета StaticArrays.jl. Длина этих массивов определяется во время компиляции. Они создаются с помощью макросов, присоединенных к обычным выражениям массивов, например:

using StaticArrays
A = SA[2.0, 3.0, 5.0]
typeof(A) # SVector{3, Float64} (alias for SArray{Tuple{3}, Float64, 1, 3})
SVector{3, Float64} (alias for SArray{Tuple{3}, Float64, 1, 3})

Обратите внимание, что 3 после SVector дает размер SVector. Его нельзя изменить. Кроме того, SVector являются неизменяемыми, поэтому для изменения значений необходимо создать новый SVector. Но помните, что беспокоиться о выделении не нужно, поскольку эта структура данных выделяется в стеке. SArray имеют множество дополнительных оптимизаций: быстрое умножение матриц, быстрые QR-разложения и т. д., которые напрямую используют информацию о размере массива. Поэтому, когда это возможно, следует применять именно их.

К сожалению, статические массивы можно использовать только для достаточно небольших массивов данных. После определенного размера для них требуется выделять память в куче после некоторых инструкций, и их время компиляции возрастает. Таким образом, статические массивы не следует использовать, если в системе более ~20 переменных. Кроме того, в полной мере использовать статические массивы могут только собственные алгоритмы Julia.

Давайте оптимизируем lorenz с использованием статических массивов. Обратите внимание, что в данном случае мы хотим использовать форму выделения не на месте, но на этот раз нам нужно вывести статический массив:

function lorenz_static(u, p, t)
    dx = 10.0 * (u[2] - u[1])
    dy = u[1] * (28.0 - u[3]) - u[2]
    dz = u[1] * u[2] - (8 / 3) * u[3]
    SA[dx, dy, dz]
end
lorenz_static (generic function with 1 method)

Чтобы решатель внутренним образом использовал статические массивы, достаточно задать ему статический массив в качестве начального условия:

u0 = SA[1.0, 0.0, 0.0]
tspan = (0.0, 100.0)
prob = ODEProblem(lorenz_static, u0, tspan)
@btime solve(prob, Tsit5());
  326.822 μs (1293 allocations: 387.39 KiB)
@btime solve(prob, Tsit5(), save_everystep = false);
  193.331 μs (22 allocations: 2.25 KiB)

Вот, собственно, и все. При работе со статическими массивами не нужно беспокоиться о выделении, поэтому используйте операции типа * и не думайте об операциях слияния (речь о них пойдет в следующем разделе). Создайте векторизованный код на R/MATLAB/Python, и ваш код будет быстрым, или используйте непосредственно числа/значения.

Пример ускорения жесткого уравнения: уравнение Робертсона

Для следующих примеров решим уравнения Робертсона (также известные как ROBER).

Учитывая, что эти уравнения являются жесткими, нежесткие решатели ODE, такие как Tsit5 или Vern9, не смогут решить эти уравнения. Автоматический алгоритм обнаружит это и автоматически переключится на что-то более надежное и подходящее для решения этих задач. Например:

using DifferentialEquations
using Plots
function rober!(du, u, p, t)
    y₁, y₂, y₃ = u
    k₁, k₂, k₃ = p
    du[1] = -k₁ * y₁ + k₃ * y₂ * y₃
    du[2] = k₁ * y₁ - k₂ * y₂^2 - k₃ * y₂ * y₃
    du[3] = k₂ * y₂^2
    nothing
end
prob = ODEProblem(rober!, [1.0, 0.0, 0.0], (0.0, 1e5), [0.04, 3e7, 1e4])
sol = solve(prob)
plot(sol, tspan = (1e-2, 1e5), xscale = :log10)

using BenchmarkTools
@btime solve(prob);
  93.616 μs (667 allocations: 58.44 KiB)

Выбор хорошего решателя

Выбор хорошего решателя необходим для получения максимальной скорости. Общие рекомендации можно найти на странице решателя (например, Рекомендации для решателя ODE). Существующие рекомендации можно упростить до метода Розенброка (Rosenbrock23 или Rodas5) для небольших (<50 ODE) задач, методов ESDIRK для несколько больших (TRBDF2 или KenCarp4 для <2000 ODE) и QNDF для еще больших задач. Иногда стоит попробовать lsoda из LSODA.jl для категории среднего размера.

Более подробную информацию о том, какой решатель выбрать, можно получить с помощью сравнительных анализов. Сравнение многих решателей для многих задач см. на странице SciMLBenchmarks.

Исходя из этого, мы пробуем применить рекомендацию Rosenbrock23() для жестких уравнений ODE при стандартных допусках:

@btime solve(prob, Rosenbrock23());
  83.664 μs (500 allocations: 40.70 KiB)

Объявление функции Якоби

Для уменьшения стоимости построения якобиана можно описать функцию якобиана, используя аргумент jac для ODEFunction. Сначала необходимо получить якобиан , который равен J[i,j]. Отсюда получаем:

function rober_jac!(J, u, p, t)
    y₁, y₂, y₃ = u
    k₁, k₂, k₃ = p
    J[1, 1] = k₁ * -1
    J[2, 1] = k₁
    J[3, 1] = 0
    J[1, 2] = y₃ * k₃
    J[2, 2] = y₂ * k₂ * -2 + y₃ * k₃ * -1
    J[3, 2] = y₂ * 2 * k₂
    J[1, 3] = k₃ * y₂
    J[2, 3] = k₃ * y₂ * -1
    J[3, 3] = 0
    nothing
end
f! = ODEFunction(rober!, jac = rober_jac!)
prob_jac = ODEProblem(f!, [1.0, 0.0, 0.0], (0.0, 1e5), (0.04, 3e7, 1e4))
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
timespan: (0.0, 100000.0)
u0: 3-element Vector{Float64}:
 1.0
 0.0
 0.0
@btime solve(prob_jac, Rosenbrock23());
  87.647 μs (422 allocations: 34.70 KiB)

Автоматическое выведение функций Якоби

Но это было трудно. Если требуется символический якобиан численного кода, можно воспользоваться пакетом ModelingToolkit.jl для символической модификации численного кода, произвести символическое вычисление и вернуть для этого код Julia.

using ModelingToolkit
de = modelingtoolkitize(prob)
Model ##MTKizedODE#235536 with 3 equations
States (3):
  x₁(t) [defaults to 1.0]
  x₂(t) [defaults to 0.0]
  x₃(t) [defaults to 0.0]
Parameters (3):
  α₁ [defaults to 0.04]
  α₂ [defaults to 3.0e7]
  α₃ [defaults to 10000.0]

Если нужно увидеть код, можно указать ему вычислить якобиан:

ModelingToolkit.generate_jacobian(de)[2] # Второй на месте
:(function (ˍ₋out, ˍ₋arg1, ˍ₋arg2, t)
      #= /root/.julia/packages/SymbolicUtils/ssQsQ/src/code.jl:373 =#
      #= /root/.julia/packages/SymbolicUtils/ssQsQ/src/code.jl:374 =#
      #= /root/.julia/packages/SymbolicUtils/ssQsQ/src/code.jl:375 =#
      begin
          begin
              begin
                  #= /root/.julia/packages/Symbolics/gBKZv/src/build_function.jl:537 =#
                  #= /root/.julia/packages/SymbolicUtils/ssQsQ/src/code.jl:422 =# @inbounds begin
                          #= /root/.julia/packages/SymbolicUtils/ssQsQ/src/code.jl:418 =#
                          ˍ₋out[1] = (*)(-1, ˍ₋arg2[1])
                          ˍ₋out[2] = ˍ₋arg2[1]
                          ˍ₋out[3] = 0
                          ˍ₋out[4] = (*)(ˍ₋arg1[3], ˍ₋arg2[3])
                          ˍ₋out[5] = (+)((*)((*)(-2, ˍ₋arg1[2]), ˍ₋arg2[2]), (*)((*)(-1, ˍ₋arg1[3]), ˍ₋arg2[3]))
                          ˍ₋out[6] = (*)((*)(2, ˍ₋arg1[2]), ˍ₋arg2[2])
                          ˍ₋out[7] = (*)(ˍ₋arg1[2], ˍ₋arg2[3])
                          ˍ₋out[8] = (*)((*)(-1, ˍ₋arg1[2]), ˍ₋arg2[3])
                          ˍ₋out[9] = 0
                          #= /root/.julia/packages/SymbolicUtils/ssQsQ/src/code.jl:420 =#
                          nothing
                      end
              end
          end
      end
  end)

Теперь воспользуемся этим для получения якобиана аналитического решения:

prob_jac2 = ODEProblem(de, [], (0.0, 1e5), jac = true)
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
timespan: (0.0, 100000.0)
u0: 3-element Vector{Float64}:
 1.0
 0.0
 0.0
@btime solve(prob_jac2);
  93.612 μs (606 allocations: 58.16 KiB)

Более подробную информацию см. в документации по ModelingToolkit.jl.

Ускорение решения небольших обыкновенных дифференциальных уравнение с помощью StaticArrays

Если система ODE достаточно невелика (<20 ODE или около того), использование StaticArrays.jl для переменных состояния может значительно повысить производительность. Это делается путем преобразования u0 в StaticArray и написания неизменяемой диспетчеризации не на месте для статических массивов. Для задачи ROBER эти действия выглядят следующим образом.

using StaticArrays
function rober_static(u, p, t)
    y₁, y₂, y₃ = u
    k₁, k₂, k₃ = p
    du1 = -k₁ * y₁ + k₃ * y₂ * y₃
    du2 = k₁ * y₁ - k₂ * y₂^2 - k₃ * y₂ * y₃
    du3 = k₂ * y₂^2
    SA[du1, du2, du3]
end
prob = ODEProblem(rober_static, SA[1.0, 0.0, 0.0], (0.0, 1e5), SA[0.04, 3e7, 1e4])
sol = solve(prob, Rosenbrock23())
retcode: Success
Interpolation: specialized 2nd order "free" stiffness-aware interpolation
t: 61-element Vector{Float64}:
      0.0
      3.196206628740808e-5
      0.00014400709336278452
      0.00025605212043816096
      0.00048593871402339607
      0.0007179482102678373
      0.0010819240251828343
      0.0014801655107859655
      0.0020679567717440095
      0.002843584518457066
      ⋮
  25371.93159838571
  30784.11718374498
  37217.42390396605
  44850.61094811346
  53893.688830057334
  64593.73530179436
  77241.71691097679
  92180.81843146283
 100000.0
u: 61-element Vector{SVector{3, Float64}}:
 [1.0, 0.0, 0.0]
 [0.9999987215181657, 1.2780900152625978e-6, 3.9181897521319503e-10]
 [0.9999942397329006, 5.7185104612947566e-6, 4.175663804739006e-8]
 [0.9999897579688383, 9.992106612572491e-6, 2.49924549040571e-7]
 [0.9999805626683271, 1.7833623941038088e-5, 1.6037077316934769e-6]
 [0.9999712826607852, 2.403488562731424e-5, 4.682453587410618e-6]
 [0.9999567250114038, 3.0390689334989113e-5, 1.2884299261094982e-5]
 [0.9999407986095145, 3.388427339038224e-5, 2.531711709494679e-5]
 [0.9999172960310598, 3.583508669306405e-5, 4.686888224684217e-5]
 [0.9998862913763157, 3.6412401619257426e-5, 7.729622206475401e-5]
 ⋮
 [0.05563508171413305, 2.3546322394505495e-7, 0.9443646828226426]
 [0.047925352159210115, 2.012149653947115e-7, 0.9520744466258239]
 [0.04123342367542567, 1.7192789116847091e-7, 0.9587664043966821]
 [0.03543700020207701, 1.4688362762022196e-7, 0.9645628529142937]
 [0.03042537309965345, 1.2546809864160592e-7, 0.9695745014322467]
 [0.026099133126498978, 1.071560882169501e-7, 0.9739007597174127]
 [0.022369692367946337, 9.149845157822593e-8, 0.977630216133602]
 [0.019158563494465593, 7.811096455346351e-8, 0.9808413583945704]
 [0.017827893845894716, 7.258919980139027e-8, 0.982172033564906]

Если провести сравнительный анализ, мы увидим действительно быстрое решение с очень низким количеством выделений:

@btime sol = solve(prob, Rosenbrock23());
  77.852 μs (807 allocations: 46.66 KiB)

Таким образом, этот вариант очень удобен для многопоточности и других форм параллелизма.

Пример ускорения полудискретизации дифференциальных уравнений в частных производных линейной алгебры

В этом руководстве мы оптимизируем определение правой части полудискретизации PDE.

Настоятельно рекомендуется обратиться к руководству Решение больших жестких уравнений для получения сведений о настройке DifferentialEquations.jl для более эффективного решения больших жестких уравнений ODE. В этом разделе будет рассматриваться только код на стороне пользователя.

Оптимизируем решение дискретизации реакционно-диффузионного дифференцированного уравнения в частных производных (PDE). В дискретизированном виде это и есть ODE:

где , и являются матрицами. Здесь мы будем использовать упрощенный вариант, где  — трехдиагональный шаблон ], т. е. это двухмерная дискретизация лапласиана. Собственный код будет выглядеть примерно так:

using DifferentialEquations, LinearAlgebra, BenchmarkTools
# Создает константы
p = (1.0, 1.0, 1.0, 10.0, 0.001, 100.0) # a, α, ubar, β, D1, D2
N = 100
Ax = Array(Tridiagonal([1.0 for i in 1:(N - 1)], [-2.0 for i in 1:N],
                       [1.0 for i in 1:(N - 1)]))
Ay = copy(Ax)
Ax[2, 1] = 2.0
Ax[end - 1, end] = 2.0
Ay[1, 2] = 2.0
Ay[end, end - 1] = 2.0

function basic_version!(dr, r, p, t)
    a, α, ubar, β, D1, D2 = p
    u = r[:, :, 1]
    v = r[:, :, 2]
    Du = D1 * (Ay * u + u * Ax)
    Dv = D2 * (Ay * v + v * Ax)
    dr[:, :, 1] = Du .+ a .* u .* u ./ v .+ ubar .- α * u
    dr[:, :, 2] = Dv .+ a .* u .* u .- β * v
end

a, α, ubar, β, D1, D2 = p
uss = (ubar + β) / α
vss = (a / β) * uss^2
r0 = zeros(100, 100, 2)
r0[:, :, 1] .= uss .+ 0.1 .* rand.()
r0[:, :, 2] .= vss

prob = ODEProblem(basic_version!, r0, (0.0, 0.1), p)
ODEProblem with uType Array{Float64, 3} and tType Float64. In-place: true
timespan: (0.0, 0.1)
u0: 100×100×2 Array{Float64, 3}:
[:, :, 1] =
 11.0526  11.0371  11.0016  11.0671  …  11.0646  11.0927  11.0524  11.0093
 11.0346  11.0127  11.0966  11.0603     11.0675  11.0779  11.0317  11.0259
 11.0973  11.0786  11.0122  11.0395     11.0395  11.0371  11.0182  11.055
 11.012   11.0846  11.0813  11.0175     11.0453  11.0735  11.0837  11.0431
 11.0383  11.0138  11.04    11.0509     11.0291  11.0244  11.0333  11.0639
 11.0259  11.0033  11.099   11.0404  …  11.0187  11.0234  11.0225  11.0917
 11.0411  11.0302  11.0342  11.055      11.0398  11.0109  11.0293  11.0036
 11.0213  11.0912  11.017   11.021      11.018   11.0483  11.0937  11.0686
 11.0182  11.0595  11.0159  11.0497     11.0766  11.059   11.0044  11.0889
 11.0087  11.0789  11.006   11.0883     11.0499  11.0549  11.0306  11.0491
  ⋮                                  ⋱
 11.0523  11.0989  11.0192  11.0676     11.0435  11.0253  11.0076  11.0363
 11.0321  11.0323  11.0439  11.0013     11.0328  11.0999  11.0413  11.0565
 11.0354  11.0117  11.0551  11.0134     11.0602  11.0361  11.0991  11.0881
 11.0726  11.0982  11.0956  11.065      11.04    11.0811  11.0952  11.0517
 11.089   11.0327  11.0454  11.095   …  11.0634  11.0918  11.0079  11.0285
 11.0761  11.0777  11.0622  11.0641     11.0303  11.0036  11.048   11.0537
 11.0986  11.0366  11.0918  11.0772     11.0287  11.0732  11.0879  11.0723
 11.0502  11.0094  11.0188  11.0629     11.0331  11.0535  11.0944  11.0599
 11.0796  11.0903  11.049   11.0034     11.0749  11.0019  11.0334  11.0385

[:, :, 2] =
 12.1  12.1  12.1  12.1  12.1  12.1  …  12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1  …  12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
  ⋮                             ⋮    ⋱         ⋮
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1  …  12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1
 12.1  12.1  12.1  12.1  12.1  12.1     12.1  12.1  12.1  12.1  12.1  12.1

В этом варианте мы закодировали начальное условие в виде трехмерного массива, где u[:,:,1] является частью A, а u[:,:,2] — частью B.

@btime solve(prob, Tsit5());
  73.064 ms (7349 allocations: 186.83 MiB)

Хотя этот вариант не очень эффективен,

рекомендуем сначала написать «высокоуровневый код», а затем итеративно оптимизировать его

Первое, что мы можем сделать, — это избавиться от нарезки выделений. Операция r[:,:,1] создает временный массив вместо представления, т. е. указателя на уже существующую память. Чтобы сделать его представлением, добавьте @view. Обратите внимание, что с представлениями нужно работать аккуратно, поскольку они указывают на одну и ту же память, и, следовательно, при изменении представления изменяются исходные значения:

A = rand(4)
@show A
B = @view A[1:3]
B[2] = 2
@show A
4-element Vector{Float64}:
 0.7835981747579798
 2.0
 0.9033405273644081
 0.3312967933875772

Обратите внимание, что изменение B привело к изменению A. С этим нужно быть осторожным, но в то же время мы хотим использовать эту возможность, поскольку хотим модифицировать вывод dr. Кроме того, последнее выражение является чисто поэлементной операцией, и поэтому здесь можно использовать слияние трансляции. Перепишем basic_version!, чтобы избежать нарезки выделений и использовать слияние трансляции:

function gm2!(dr, r, p, t)
    a, α, ubar, β, D1, D2 = p
    u = @view r[:, :, 1]
    v = @view r[:, :, 2]
    du = @view dr[:, :, 1]
    dv = @view dr[:, :, 2]
    Du = D1 * (Ay * u + u * Ax)
    Dv = D2 * (Ay * v + v * Ax)
    @. du = Du + a .* u .* u ./ v + ubar - α * u
    @. dv = Dv + a .* u .* u - β * v
end
prob = ODEProblem(gm2!, r0, (0.0, 0.1), p)
@btime solve(prob, Tsit5());
  47.886 ms (5879 allocations: 119.71 MiB)

Теперь большая часть выделений происходит в Du = D1*(Ay*u + u*Ax), поскольку эти операции векторизованы и не изменяются. Матричные умножения следует заменить mul!. При этом нам понадобятся переменные кэша, в которые будет выполняться запись. Это выглядит следующим образом.

Ayu = zeros(N, N)
uAx = zeros(N, N)
Du = zeros(N, N)
Ayv = zeros(N, N)
vAx = zeros(N, N)
Dv = zeros(N, N)
function gm3!(dr, r, p, t)
    a, α, ubar, β, D1, D2 = p
    u = @view r[:, :, 1]
    v = @view r[:, :, 2]
    du = @view dr[:, :, 1]
    dv = @view dr[:, :, 2]
    mul!(Ayu, Ay, u)
    mul!(uAx, u, Ax)
    mul!(Ayv, Ay, v)
    mul!(vAx, v, Ax)
    @. Du = D1 * (Ayu + uAx)
    @. Dv = D2 * (Ayv + vAx)
    @. du = Du + a * u * u ./ v + ubar - α * u
    @. dv = Dv + a * u * u - β * v
end
prob = ODEProblem(gm3!, r0, (0.0, 0.1), p)
@btime solve(prob, Tsit5());
  46.835 ms (4703 allocations: 29.97 MiB)

Но временные переменные являются глобальными. Необходимо либо объявить кэши как const, либо локализовать их. Их можно локализовать, добавив в параметры (p). Компилятору проще иметь дело с локальными переменными, чем с глобальными. Локализация переменных помогает обеспечить стабильность типов.

p = (1.0, 1.0, 1.0, 10.0, 0.001, 100.0, Ayu, uAx, Du, Ayv, vAx, Dv) # a, α, ubar, β, D1, D2
function gm4!(dr, r, p, t)
    a, α, ubar, β, D1, D2, Ayu, uAx, Du, Ayv, vAx, Dv = p
    u = @view r[:, :, 1]
    v = @view r[:, :, 2]
    du = @view dr[:, :, 1]
    dv = @view dr[:, :, 2]
    mul!(Ayu, Ay, u)
    mul!(uAx, u, Ax)
    mul!(Ayv, Ay, v)
    mul!(vAx, v, Ax)
    @. Du = D1 * (Ayu + uAx)
    @. Dv = D2 * (Ayv + vAx)
    @. du = Du + a * u * u ./ v + ubar - α * u
    @. dv = Dv + a * u * u - β * v
end
prob = ODEProblem(gm4!, r0, (0.0, 0.1), p)
@btime solve(prob, Tsit5());
  36.810 ms (1028 allocations: 29.66 MiB)

Мы могли бы использовать BLAS gemmv для дополнительной оптимизации умножения матриц, но вместо этого проведем девекторизацию шаблона.

p = (1.0, 1.0, 1.0, 10.0, 0.001, 100.0, N)
function fast_gm!(du, u, p, t)
    a, α, ubar, β, D1, D2, N = p

    @inbounds for j in 2:(N - 1), i in 2:(N - 1)
        du[i, j, 1] = D1 *
                      (u[i - 1, j, 1] + u[i + 1, j, 1] + u[i, j + 1, 1] + u[i, j - 1, 1] -
                       4u[i, j, 1]) +
                      a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
    end

    @inbounds for j in 2:(N - 1), i in 2:(N - 1)
        du[i, j, 2] = D2 *
                      (u[i - 1, j, 2] + u[i + 1, j, 2] + u[i, j + 1, 2] + u[i, j - 1, 2] -
                       4u[i, j, 2]) +
                      a * u[i, j, 1]^2 - β * u[i, j, 2]
    end

    @inbounds for j in 2:(N - 1)
        i = 1
        du[1, j, 1] = D1 *
                      (2u[i + 1, j, 1] + u[i, j + 1, 1] + u[i, j - 1, 1] - 4u[i, j, 1]) +
                      a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
    end
    @inbounds for j in 2:(N - 1)
        i = 1
        du[1, j, 2] = D2 *
                      (2u[i + 1, j, 2] + u[i, j + 1, 2] + u[i, j - 1, 2] - 4u[i, j, 2]) +
                      a * u[i, j, 1]^2 - β * u[i, j, 2]
    end
    @inbounds for j in 2:(N - 1)
        i = N
        du[end, j, 1] = D1 *
                        (2u[i - 1, j, 1] + u[i, j + 1, 1] + u[i, j - 1, 1] - 4u[i, j, 1]) +
                        a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
    end
    @inbounds for j in 2:(N - 1)
        i = N
        du[end, j, 2] = D2 *
                        (2u[i - 1, j, 2] + u[i, j + 1, 2] + u[i, j - 1, 2] - 4u[i, j, 2]) +
                        a * u[i, j, 1]^2 - β * u[i, j, 2]
    end

    @inbounds for i in 2:(N - 1)
        j = 1
        du[i, 1, 1] = D1 *
                      (u[i - 1, j, 1] + u[i + 1, j, 1] + 2u[i, j + 1, 1] - 4u[i, j, 1]) +
                      a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
    end
    @inbounds for i in 2:(N - 1)
        j = 1
        du[i, 1, 2] = D2 *
                      (u[i - 1, j, 2] + u[i + 1, j, 2] + 2u[i, j + 1, 2] - 4u[i, j, 2]) +
                      a * u[i, j, 1]^2 - β * u[i, j, 2]
    end
    @inbounds for i in 2:(N - 1)
        j = N
        du[i, end, 1] = D1 *
                        (u[i - 1, j, 1] + u[i + 1, j, 1] + 2u[i, j - 1, 1] - 4u[i, j, 1]) +
                        a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
    end
    @inbounds for i in 2:(N - 1)
        j = N
        du[i, end, 2] = D2 *
                        (u[i - 1, j, 2] + u[i + 1, j, 2] + 2u[i, j - 1, 2] - 4u[i, j, 2]) +
                        a * u[i, j, 1]^2 - β * u[i, j, 2]
    end

    @inbounds begin
        i = 1
        j = 1
        du[1, 1, 1] = D1 * (2u[i + 1, j, 1] + 2u[i, j + 1, 1] - 4u[i, j, 1]) +
                      a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
        du[1, 1, 2] = D2 * (2u[i + 1, j, 2] + 2u[i, j + 1, 2] - 4u[i, j, 2]) +
                      a * u[i, j, 1]^2 - β * u[i, j, 2]

        i = 1
        j = N
        du[1, N, 1] = D1 * (2u[i + 1, j, 1] + 2u[i, j - 1, 1] - 4u[i, j, 1]) +
                      a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
        du[1, N, 2] = D2 * (2u[i + 1, j, 2] + 2u[i, j - 1, 2] - 4u[i, j, 2]) +
                      a * u[i, j, 1]^2 - β * u[i, j, 2]

        i = N
        j = 1
        du[N, 1, 1] = D1 * (2u[i - 1, j, 1] + 2u[i, j + 1, 1] - 4u[i, j, 1]) +
                      a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
        du[N, 1, 2] = D2 * (2u[i - 1, j, 2] + 2u[i, j + 1, 2] - 4u[i, j, 2]) +
                      a * u[i, j, 1]^2 - β * u[i, j, 2]

        i = N
        j = N
        du[end, end, 1] = D1 * (2u[i - 1, j, 1] + 2u[i, j - 1, 1] - 4u[i, j, 1]) +
                          a * u[i, j, 1]^2 / u[i, j, 2] + ubar - α * u[i, j, 1]
        du[end, end, 2] = D2 * (2u[i - 1, j, 2] + 2u[i, j - 1, 2] - 4u[i, j, 2]) +
                          a * u[i, j, 1]^2 - β * u[i, j, 2]
    end
end
prob = ODEProblem(fast_gm!, r0, (0.0, 0.1), p)
@btime solve(prob, Tsit5());
  7.760 ms (440 allocations: 29.62 MiB)

Обратите внимание, что в данном случае слияние циклов и отказ от линейных операторов приводит к значительному улучшению — примерно в 10 раз. Это на порядок быстрее, чем наш исходный код в векторном стиле на MATLAB/SciPy/R.

Поскольку делать это вручную утомительно, отметим, что генерация символьного кода ModelingToolkit.jl выполняет эти действия автоматически, начиная с базовой версии.

using ModelingToolkit
function basic_version!(dr, r, p, t)
    a, α, ubar, β, D1, D2 = p
    u = r[:, :, 1]
    v = r[:, :, 2]
    Du = D1 * (Ay * u + u * Ax)
    Dv = D2 * (Ay * v + v * Ax)
    dr[:, :, 1] = Du .+ a .* u .* u ./ v .+ ubar .- α * u
    dr[:, :, 2] = Dv .+ a .* u .* u .- β * v
end

a, α, ubar, β, D1, D2 = p
uss = (ubar + β) / α
vss = (a / β) * uss^2
r0 = zeros(100, 100, 2)
r0[:, :, 1] .= uss .+ 0.1 .* rand.()
r0[:, :, 2] .= vss

prob = ODEProblem(basic_version!, r0, (0.0, 0.1), p)
de = modelingtoolkitize(prob)

# Примечание. jac=true,sparse=true приводит к автоматическому построению кода
# разреженного якобиана.

fastprob = ODEProblem(de, [], (0.0, 0.1), jac = true, sparse = true)
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
timespan: (0.0, 0.1)
u0: 20000-element Vector{Float64}:
 11.074064020465531
 11.04322113492956
 11.015062072513434
 11.008939655202521
 11.064008868363828
 11.01104464637149
 11.011993387604734
 11.092503672819479
 11.076804445317938
 11.036016752519918
  ⋮
 12.100000000000001
 12.100000000000001
 12.100000000000001
 12.100000000000001
 12.100000000000001
 12.100000000000001
 12.100000000000001
 12.100000000000001
 12.100000000000001

Наконец, мы можем делать и другое, например применять многопоточность для основных циклов. LoopVectorization.jl предоставляет макрос @turbo для реализации множества улучшений SIMD, а @tturbo является многопоточной версией.

Оптимизация выбора алгоритма

Последнее, что остается сделать, это оптимизировать выбор алгоритма. В качестве тестового алгоритма мы использовали Tsit5(), но в действительности данная задача является дискретизацией жесткого уравнения PDE, поэтому рекомендуется использовать CVODE_BDF(). Однако вместо плотного якобиана, используемого по умолчанию, следует воспользоваться разреженным якобианом, который предоставляет данная задача. Якобиан представляет собой матрицу , где считается по линейному индексу (т. е. вниз по столбцам). Но поскольку переменные зависят от , размер полосы здесь велик, и, следовательно, это не будет иметь успеха с решателем ленточного якобиана. Вместо этого мы используем алгоритмы с разреженными якобианами. CVODE_BDF позволяет использовать разреженный решатель Ньютона-Крылова. Для этого требуется задать linear_solver = :GMRES.

Более подробные сведения можно найти в руководстве Решение больших жестких уравнений. А здесь приведена лишь незначительная часть возможностей для оптимизации.

Посмотрим, как масштабируется наша быстрая правая часть при увеличении времени интеграции.

prob = ODEProblem(fast_gm!, r0, (0.0, 10.0), p)
@btime solve(prob, Tsit5());
  108.703 s (39331 allocations: 2.76 GiB)
using Sundials
@btime solve(prob, CVODE_BDF(linear_solver = :GMRES));
  906.420 ms (13479 allocations: 120.05 MiB)
prob = ODEProblem(fast_gm!, r0, (0.0, 100.0), p)
# Если не отключить параметр `save_everystep`, память будет исчерпана.
@btime solve(prob, Tsit5(), save_everystep = false);
  6.879 s (68 allocations: 2.90 MiB)
@btime solve(prob, CVODE_BDF(linear_solver = :GMRES), save_everystep = false);
  2.942 s (35152 allocations: 2.46 MiB)
prob = ODEProblem(fast_gm!, r0, (0.0, 500.0), p)
@btime solve(prob, CVODE_BDF(linear_solver = :GMRES), save_everystep = false);
  4.253 s (51451 allocations: 3.17 MiB)

Обратите внимание, что мы избавились практически от всех выделений, что позволяет оптимизировать код без сборки мусора и замедления производительности

Почему решатель CVODE_BDF так хорошо работает? Это происходит потому, что из-за жесткости задачи быстро растет количество шагов, требуемых явному методу Рунге-Кутта, в то время как CVODE_BDF делает большие шаги. Кроме того, форма линейного решателя GMRES является достаточно эффективным способом решения неявной системы в данном случае. Здесь действует зависимость от конкретной задачи, и во многих случаях использование метода Крылова требует предобусловливателя, поэтому вам нужно протестировать другие алгоритмы и линейные решатели, чтобы найти подходящий вариант для вашей задачи.

Перейдем к руководству Решение больших жестких уравнений, где содержатся более подробные сведения об оптимизации выбора алгоритма для таких кодов.