Scientific computing: SciML, AD and solvers
Time integration, differentiable linear solves, and the sparse and iterative solver backends layered on top of the form API.
Time-dependent problems and the SciML stack
semidiscretize applies the method of lines to a spatial BilinearForm and a source LinearForm, producing the system M uₕ' = F(t) - A uₕ as a callable with the (du, u, p, t) signature a time stepper expects. Dirichlet conditions become algebraic rows of a singular mass matrix, so the result is an index-1 differential-algebraic system needing only the boundary data g, never its time derivative (see the heat equation example).
Nothing in this group needs a weak dependency except the last four, which name their results the way SciMLBase does: ode_function and ode_problem hand the semidiscretisation to OrdinaryDiffEq, linear_problem hands a steady linear system to LinearSolve with its factorisations and preconditioners, and nonlinear_problem hands a steady nonlinear residual to NonlinearSolve. All four require SciMLBase.jl.
solve(a::BilinearForm, l::LinearForm; ...) is a further convenience defined alongside linear_problem: assemble, solve and unwrap the LinearSolve solution into a VectorElement in one call, for a caller who wants the solved grid function directly rather than the raw LinearProblem. element(Wₕ, sol)/VectorElement(sol, Wₕ) do the unwrapping step alone, for a LinearSolution already in hand.
Bramble.semidiscretize — Function
semidiscretize(a::BilinearForm, l::LinearForm; kwargs...) -> SemidiscretizationSemidiscretise M u_h' = F(t) - A u_h from the spatial form a and the source l, both posed on the same test space.
Write a as the steady problem is written: assemble(a) is A, so the steady state of the returned system solves A u_h = F.
Keywords
mass:BilinearFormdefiningM(default:innerₕ(u, v), the discreteL²inner product, which is diagonal).dirichlet: constrained labels and, where they carry values, the values – every formassembleaccepts, including time-dependent constraints fromdirichlet_constraints(Ωₕ, I, :label => (x, t) -> ...), or parameter-dependent ones from(x, t, p) -> ...for a value reached by the residual's ownp(default:nothing).dirichlet_components: leaf components of a composite space the labels bind to (default:nothing, all leaves).state:VectorElementthe currentuis copied into before each assembly, for forms whose coefficients read it (default:nothing).update_coefficients!: called with the currenttbefore each assembly, for coefficients that vary in time –t -> Rₕ!(fₕ, x -> f(x, t))for a time-dependent source, ort -> (α[] = t)for a scalarRef(default:nothing). A two-argument(t, p) -> ...also reaches the residual's ownp, the same way a three-argument Dirichlet condition does.reassemble: refillAat every step, for an operator whose coefficients change withtoru(default:false).
Time and parameter dependence of the Dirichlet values is detected by arity, exactly as dirichlet_constraints validates it: conditions accepting (x, t, p) are evaluated at each step against the residual's own p, conditions accepting (x, t) are evaluated at each step without it, and conditions accepting (x) are not evaluated per step at all. update_coefficients! is detected the same way, between its one- and two-argument forms.
A (and therefore jacobian!) stays fixed for a given t, regardless of p: an operator coefficient that itself depends on p is not read by anything here. Use the build-based method below for an operator that depends on t; a p-dependent operator is not yet supported by either method.
Examples
Ωₕ = mesh(domain(interval(0.0, 1.0)), 101)
Wₕ = gridspace(Ωₕ)
I = interval(0.0, 1.0)
fₕ = Rₕ(Wₕ, x -> 1.0)
a = form(Wₕ, Wₕ, (u, v) -> inner₊(∇ₕ(u), ∇ₕ(v)))
l = form(Wₕ, v -> innerₕ(fₕ, v))
bcs = dirichlet_constraints(Ωₕ, I, :boundary => (x, t) -> 0.0)
sd = semidiscretize(a, l; dirichlet = bcs)See also ode_function, ode_problem, jacobian!.
semidiscretize(build, l::LinearForm; kwargs...) -> SemidiscretizationSemidiscretise M u_h' = F(t) - A(t) u_h from a spatial operator that genuinely depends on t – built fresh, once per element type t is ever reached at, instead of the single BilinearForm the other method assembles into one fixed matrix.
build(t) is called once for each element type t is ever seen at (Float64 on a normal step, a ForwardDiff.Dual while a Rosenbrock stepper's tgrad differentiates the residual through t), and must return (a, refill!): the BilinearForm to assemble, built around whatever live coefficient buffer(s) it needs, and a one-argument refill!(t) updating those buffers from the current t – called on every step, cache hit or miss, so a later step at an already-seen type still sees the new t rather than the one build first saw. Same build/refill!/cache discipline as type_cached_assemble!, keyed on t instead of a VectorElement iterate.
build should be a named function, not a closure literal written inline – the same reason type_cached_assemble!'s own docstring gives: a do ... end block re-literalised on every call allocates a new closure each time.
This is what lets a Rosenbrock method (Rodas5P, Rosenbrock23) capture -Ȧ(t) u_h by differentiating through t directly: the other method's fixed BilinearForm closes over a Float64-typed coefficient buffer, which cannot hold a Dual and throws InexactError under exactly that AD sweep.
Keywords
Same as the BilinearForm method, except reassemble defaults to true – a build-based operator exists specifically to be rebuilt at every step.
Examples
function build_diffusion_operator(t)
αₕ = element(Wₕ, typeof(t))
a = form(Wₕ, Wₕ, (u, v) -> inner₊(αₕ * ∇ₕ(u), ∇ₕ(v)))
refill!(t) = (Rₕ!(αₕ, x -> α(x, t)); nothing)
return a, refill!
end
sd = semidiscretize(build_diffusion_operator, l; dirichlet = bcs)See also ode_function, ode_problem.
Bramble.Semidiscretization — Type
Semidiscretization{...}Method-of-lines semidiscretisation of M u_h' = F(t) - A u_h, callable with the (du, u, p, t) signature a time stepper expects.
Built by semidiscretize; read back with mass_matrix and operator_matrix.
Fields
operator: spatialBilinearForm, assembled intoA.source: sourceLinearForm, assembled intoF(t)at each step.space: the test space both forms share.operator_matrix:A, witheₖrows on the constrained degrees of freedom.mass_matrix:M, with zero rows on the constrained degrees of freedom.source_vector: the reusableFbuffer, refilled in place.constraints: how the source's boundary values are reached – one ofNoConstraints,LabelsOnly,StaticConstraints,TimeDependentConstraintsorTimeParamDependentConstraints.labels: constrained boundary labels.components: leaf components the labels bind to, ornothingfor all.state:VectorElementreceivingubefore assembly, ornothing.update_coefficients: callable invoked witht(or, wrapped inParametricUpdate, withtand the residual's ownp) before assembly, ornothing.reassemble:Val(true)to refillAat every step.
Bramble.semidiscretize_rhs — Function
semidiscretize_rhs(sd::Semidiscretization) -> SemidiscretizeRHSBuild the matrix-free right-hand side of uₕ' = M⁻¹(F(t) - A uₕ) from sd, folding M's diagonal into a precomputed scaling instead of leaving M for a solver to factorise at every step – valid only because M is diagonal and, here, invertible everywhere.
Requires sd to have been built with dirichlet = nothing: any Dirichlet label makes the corresponding row of M zero by construction (see semidiscretize), which is an algebraic constraint on uₕ, not an equation for uₕ' – there is no uₕ' value that divides it away. Also requires mass_matrix(sd) to actually be diagonal: the default mass (the discrete L² inner product) always assembles diagonally, but a caller-supplied mass keyword to semidiscretize need not.
Examples
sd = semidiscretize(a, l) # no `dirichlet` keyword: NoConstraints
rhs = semidiscretize_rhs(sd)
prob = ODEProblem(rhs, parent(u₀), (0.0, 1.0)) # no mass_matrix to factorise
sol = solve(prob, Tsit5())See also semidiscretize, ode_problem.
Bramble.SemidiscretizeRHS — Type
SemidiscretizeRHS{S,V}The right-hand side of uₕ' = M⁻¹(F(t) - A uₕ), M folded into a precomputed diagonal scaling rather than solved for – for a Semidiscretization with no Dirichlet constraints, where that fold is valid. Built by semidiscretize_rhs; callable with the (du, u, p, t) signature a plain (non-mass-matrix) SciMLBase.ODEProblem expects.
Named SemidiscretizeRHS/semidiscretize_rhs rather than the free function semidiscretize_rhs!(du, u, p, t) gpena/Bramble.jl#163 proposes: that signature is exactly SciMLBase.ODEFunction's own, which leaves no argument to pass a Semidiscretization or the precomputed scaling through – both have to be closed over somehow, and a callable struct is what every other stateful callable in this package does instead of a closure (Semidiscretization itself, TypeCachedOperator).
Bramble.mass_matrix — Function
mass_matrix(sd::Semidiscretization) -> AbstractMatrixReturn the constant mass matrix M, whose constrained rows are zero.
mass_matrix(sd::SecondOrderSemidiscretization) -> AbstractMatrixReturn the constant mass matrix M, whose constrained rows are zero.
Bramble.operator_matrix — Function
operator_matrix(sd::Semidiscretization) -> AbstractMatrixReturn the assembled spatial operator A, whose constrained rows are eₖ.
Bramble.jacobian! — Function
jacobian!(J, sd::Semidiscretization, u, p, t) -> JFill J with ∂/∂u (F(t) - A u) = -A and return it.
Exact whenever the operator's coefficients do not read u – that is, whenever sd was built without state. With a state the operator also varies with u, the term -(∂A/∂u) u is missing, and this is a Picard linearisation rather than a Jacobian: pass jacobian = nothing to ode_function and let the solver build it by sparse automatic differentiation instead, seeded from jacobian_pattern.
See also jacobian_prototype.
Bramble.jacobian_prototype — Function
jacobian_prototype(sd::Semidiscretization) -> AbstractMatrixReturn a matrix carrying the sparsity of ∂/∂u (F(t) - A u), for a solver to use as its Jacobian cache.
This is the pattern of the assembled operator, Dirichlet rows included, which is what the system's Jacobian has – jacobian_pattern answers the different question of what a Newton residual's Jacobian looks like when the form's coefficients depend on the solution.
See also jacobian!.
Bramble.ode_function — Function
ode_function(sd::Semidiscretization; kwargs...) -> ODEFunction
ode_function(a::BilinearForm, l::LinearForm; kwargs...) -> ODEFunctionWrap a semidiscretisation as an ODEFunction carrying its mass matrix, Jacobian and sparsity, ready for OrdinaryDiffEq.
The two-form method builds the Semidiscretization first, forwarding every keyword to semidiscretize.
Keywords
jacobian:jacobian!(the default) to hand the solver the exact-A, ornothingto let it build one by automatic differentiation fromjac_prototype.jac_prototype: sparsity for the solver's Jacobian cache (default:jacobian_prototype(sd)).tgrad: analytical∂f/∂t, for a Rosenbrock method to use instead of differentiating throught(default:nothing, AD). Either SciMLBase's own(dT, u, p, t) -> ...signature or the Bramble-aware(dT, sd, u, p, t) -> ...one, told apart by arity.
The resulting system is a differential-algebraic one whenever any Dirichlet label is constrained, since those rows of the mass matrix are zero. Solve it with a method that admits a singular mass matrix – FBDF, QNDF, Rodas5P, RadauIIA5 – not an explicit one.
A Rosenbrock method (Rodas5P, Rosenbrock23) also needs ∂f/∂t, which it builds by differentiating through t. That works when the only time dependence is the Dirichlet data, since those values reach the assembled vector as its element type. An update_coefficients! hook writing into a Float64 VectorElement – the usual t -> Rₕ!(fₕ, x -> f(x, t)) – cannot take a ForwardDiff.Dual time, and the solve fails on the first step with a time-gradient error. Three ways out: pass an analytical tgrad, which bypasses the differentiation through t entirely; pass Rodas5P(autodiff = AutoFiniteDiff()); or use a BDF method, which needs no ∂f/∂t at all. FBDF and QNDF are unaffected either way.
Requires SciMLBase.jl; call using SciMLBase (or any package that loads it, such as OrdinaryDiffEq) before calling this function.
See also ode_problem, linear_problem, nonlinear_problem.
Bramble.ode_problem — Function
ode_problem(sd::Semidiscretization, u₀, I::CartesianProduct{1}; kwargs...) -> ODEProblem
ode_problem(a::BilinearForm, l::LinearForm, u₀, I; kwargs...) -> ODEProblemBuild the ODEProblem stepping sd over the time domain I, from the initial condition u₀ – a VectorElement or a plain vector. I may equally be a (t₀, t₁) tuple.
u₀ is copied, never mutated, and the copy is made consistent with the Dirichlet rows at t₀ (see dirichlet_bc!), against p when one is given.
Keywords are those of ode_function, plus:
p(defaultSciMLBase.NullParameters()) for a residual whoseupdate_coefficients!or Dirichlet conditions were given a parameter-dependent,(t, p)/(x, t, p)form (seesemidiscretize's own keywords).specialize(defaultnothing,ODEProblem's own choice untouched) – passSciMLBase.FullSpecializebefore handing the solved trajectory toBramble.adjoint_sensitivities: without it, ap-vjp calls the residual with a differently-eltype-pthan the forward solve used, which the default specialization cannot dispatch and fails with "No matching function wrapper was found!" rather than differentiating.
The two-form method also forwards its other keywords to semidiscretize.
Requires SciMLBase.jl.
Examples
sd = semidiscretize(a, l; dirichlet = bcs)
prob = ode_problem(sd, Rₕ(Wₕ, x -> sinpi(x[1])), interval(0.0, 1.0))
sol = solve(prob, FBDF())bcs = dirichlet_constraints(Ωₕ, I, :boundary => (x, t, p) -> p[1] * t)
sd = semidiscretize(a, l; dirichlet = bcs)
prob = ode_problem(sd, u₀, I; p = [0.7])See also ode_function, semidiscretize.
ode_problem(rhs::SemidiscretizeRHS, u₀, I; kwargs...) -> ODEProblemBuild the plain (non-mass-matrix) ODEProblem stepping rhs over the time domain I, from the initial condition u₀ – a VectorElement or a plain vector, copied, never mutated.
Unlike ode_problem(sd::Semidiscretization, ...), there is no Dirichlet consistency step: semidiscretize_rhs only ever builds rhs from a Semidiscretization with dirichlet = nothing, so there are no boundary rows to make consistent.
Requires SciMLBase.jl.
Examples
sd = semidiscretize(a, l)
rhs = semidiscretize_rhs(sd)
prob = ode_problem(rhs, Rₕ(Wₕ, x -> sinpi(x[1])), interval(0.0, 1.0))
sol = solve(prob, Tsit5())See also semidiscretize_rhs, ode_problem(::Semidiscretization, ...).
Bramble.linear_problem — Function
linear_problem(a::BilinearForm, l::LinearForm; kwargs...) -> LinearProblemAssemble a and l into the LinearProblem that LinearSolve.solve takes, so the steady system reaches the factorisations, iterative solvers and preconditioners in that stack without being assembled by hand first.
Keywords
dirichlet,dirichlet_components: asassembletakes them.symmetrize: restore symmetry after imposing the conditions (default:false; seesymmetrize!).
Requires SciMLBase.jl, which defines LinearProblem; LinearSolve itself is needed only to solve the result.
Examples
using LinearSolve, IncompleteLU
prob = linear_problem(a, l; dirichlet = bcs)
sol = solve(prob, KrylovJL_GMRES())See also assemble, ode_problem, nonlinear_problem.
Bramble.nonlinear_problem — Function
nonlinear_problem(residual, u0; jacobian = nothing, jac_prototype = nothing, kwargs...) -> NonlinearProblemWrap residual – the residual of a steady nonlinear discretisation F(u) = 0, as F(u, p) or in-place F!(res, u, p) – into the NonlinearProblem that NonlinearSolve.solve takes.
u0, the initial guess, is a VectorElement or a plain vector; copied, never mutated. Every Bramble worked example builds residual from an assembled BilinearForm, A(u) * u - F evaluated at the current iterate, so Dirichlet rows already carry their own identity/value pair and a Newton step corrects them from any initial guess – no separate consistency step, unlike ode_problem's differential-algebraic system.
Keywords
jacobian:jac(J, u, p)ifresidualis in-place, orJ = jac(u, p)if it is not – the two conventions cannot be mixed, the same requirementNonlinearFunctionitself enforces. Defaultnothing: the solver differentiatesresidualitself.jac_prototype: sparsity for the solver's Jacobian cache, e.g. built fromjacobian_pattern. Defaultnothing(dense).- Every other keyword forwards to
NonlinearProblem.
Requires SciMLBase.jl; call using SciMLBase (or any package that loads it, such as NonlinearSolve) before calling this function.
Examples
function residual!(res, u_vec::AbstractVector{T}, p) where {T}
uₕ = element(Wₕ, T)
uₕ .= u_vec
A = assemble(diffusion_form(uₕ); dirichlet = :boundary)
mul!(res, A, u_vec)
return res .-= F
end
prob = nonlinear_problem(residual!, zeros(ndofs(Wₕ)))
sol = solve(prob, NewtonRaphson())See also ode_problem, linear_problem, jacobian_pattern.
Adjoint sensitivities for a transient solve
Bramble.adjoint_sensitivities is the transient counterpart of pde_solve's steady-state adjoint rule (see the API reference): the gradient of a scalar functional of a Semidiscretization's solved trajectory with respect to its initial condition and its p, from one backward solve regardless of how many parameters or how many saved steps. It wraps SciMLSensitivity.adjoint_sensitivities and requires SciMLSensitivity.jl – see the worked example.
Bramble.adjoint_sensitivities — Function
adjoint_sensitivities(sol::ODESolution, alg; kwargs...) -> (du0, dp)Adjoint sensitivities of a Semidiscretization's solved trajectory, via SciMLSensitivity.adjoint_sensitivities with two Bramble-specific corrections applied. Full documentation lives on BrambleSciMLSensitivityExt's own method, the only one that exists once SciMLSensitivity is loaded – this stub exists so that method has a function to extend, and so calling this without SciMLSensitivity loaded gives a clear error rather than UndefVarError.
Deliberately not exported, unlike pde_solve: SciMLSensitivity itself exports a function of this exact name, so using Bramble, SciMLSensitivity together would collide on the bare name regardless of what Bramble does. Call this one as Bramble.adjoint_sensitivities.
Bramble.adjoint_sensitivities(sol::ODESolution, alg; kwargs...) -> (du0, dp)Adjoint sensitivities of a Semidiscretization's solved trajectory sol (from Bramble.ode_problem/solve) with respect to its initial condition (du0) and its p (dp), via SciMLSensitivity.adjoint_sensitivities – one backward solve for every parameter at once, the same O(1)-in-parameter-count trade Bramble.pde_solve's own adjoint rule makes for the steady case.
Keywords
Every keyword SciMLSensitivity.adjoint_sensitivities takes, plus these two defaults chosen for a Semidiscretization's index-1 DAE specifically (both overridable):
sensealg:InterpolatingAdjoint(autojacvec = false)–autojacvec = falseusesBramble.jacobian!'s own exact-Afor theu-vjp, so no AD tool ever needs to differentiate through the residual's mutating buffers for that half.initializealg:BrownFullBasicInit()– restores the adjoint's own algebraic consistency att = Trather than merely checking it (the defaultCheckInitrejects a constrained problem's seed outright); see this file's own module-level comment for why.
Requirements on sol
sol.prob must have been built with ode_problem(sd, u₀, I; p = ..., specialize = SciMLBase.FullSpecialize) – both p and specialize matter: specialize avoids a function-wrapper error the moment a p-vjp is computed (see the note on ode_problem itself), and without a real p there is nothing for dp to be a gradient with respect to.
Examples
using Bramble, SciMLBase, SciMLSensitivity, OrdinaryDiffEqBDF
bcs = dirichlet_constraints(Ωₕ, I, :boundary => (x, t, p) -> p[1] * t)
sd = semidiscretize(a, l; dirichlet = bcs)
prob = ode_problem(sd, u₀, I; p = [0.7], specialize = SciMLBase.FullSpecialize)
sol = solve(prob, FBDF(); saveat = ts)
dgdu!(out, u, p, t, i) = (@. out = 2 * (u - obs[i]); nothing)
du0, dp = Bramble.adjoint_sensitivities(sol, FBDF(); t = ts, dgdu_discrete = dgdu!)See also Bramble.ode_problem, Bramble.pde_solve for the steady-state adjoint this mirrors.
Second-order (wave) problems
semidiscretize_second_order is the second-order-in-time counterpart of semidiscretize: from a stiffness BilinearForm and a source LinearForm, it produces M üₕ + C u̇ₕ + K uₕ = F(t), and second_order_ode_problem/second_order_ode_function hand that to OrdinaryDiffEq as a SecondOrderODEProblem – state (v, u), velocity then displacement. Dirichlet conditions constrain the displacement u the same way semidiscretize constrains its own state, and the consistent velocity follows from differentiating that constraint in time rather than being prescribed separately. Explicit/symplectic solvers (VelocityVerlet and similar) cannot be used at all – see SecondOrderSemidiscretization's docstring for why.
Bramble.semidiscretize_second_order — Function
semidiscretize_second_order(K::BilinearForm, l::LinearForm; kwargs...) -> SecondOrderSemidiscretizationSemidiscretise M üₕ + C u̇ₕ + K uₕ = F(t) from the spatial stiffness form K and the source l, both posed on the same test space.
Write K as the steady problem is written: assemble(K) is the same matrix semidiscretize calls A.
Keywords
mass:BilinearFormdefiningM(default:innerₕ(u, v), the discreteL²inner product, which is diagonal).damping:BilinearFormdefiningC(default:nothing, an undamped system).dirichlet: constrained labels and, where they carry values, the values – every formassembleaccepts, including time-dependent constraints fromdirichlet_constraints(Ωₕ, I, :label => (x, t) -> ...)(default:nothing).dirichlet_components: leaf components of a composite space the labels bind to (default:nothing, all leaves).
Dirichlet conditions constrain u; the consistent velocity at a constrained dof follows from differentiating that constraint in time and is never prescribed independently – see this file's header comment for why, and for the solver-compatibility warning that follows from it.
Examples
Ωₕ = mesh(domain(interval(0.0, 1.0)), 101)
Wₕ = gridspace(Ωₕ)
I = interval(0.0, 1.0)
fₕ = Rₕ(Wₕ, x -> 0.0)
K = form(Wₕ, Wₕ, (u, v) -> inner₊(∇ₕ(u), ∇ₕ(v)))
l = form(Wₕ, v -> innerₕ(fₕ, v))
sd = semidiscretize_second_order(K, l)See also second_order_ode_function, second_order_ode_problem.
Bramble.SecondOrderSemidiscretization — Type
SecondOrderSemidiscretization{...} <: AbstractSemidiscretizationMethod-of-lines semidiscretisation of M üₕ + C u̇ₕ + K uₕ = F(t), callable with the (dv, v, u, p, t) signature a second-order time stepper expects.
Built by semidiscretize_second_order; read back with mass_matrix, damping_matrix and stiffness_matrix.
Fields
stiffness: spatialBilinearForm, assembled intoK.damping: spatialBilinearForm, assembled intoC, ornothingfor an undamped system.source: sourceLinearForm, assembled intoF(t)at each step.space: the test space every form shares.stiffness_matrix:K, witheₖrows on the constrained degrees of freedom.damping_matrix:C, with zero rows on the constrained degrees of freedom, ornothing.mass_matrix:M, with zero rows on the constrained degrees of freedom.source_vector: the reusableFbuffer, refilled in place.constraints: how the source's boundary values are reached – one ofNoConstraints,LabelsOnly,StaticConstraintsorTimeDependentConstraints.labels: constrained boundary labels.components: leaf components the labels bind to, ornothingfor all.
Bramble.damping_matrix — Function
damping_matrix(sd::SecondOrderSemidiscretization) -> Union{AbstractMatrix, Nothing}Return the assembled damping operator C, whose constrained rows are zero, or nothing for an undamped system.
Bramble.stiffness_matrix — Function
stiffness_matrix(sd::SecondOrderSemidiscretization) -> AbstractMatrixReturn the assembled spatial stiffness operator K, whose constrained rows are eₖ.
Bramble.block_mass_matrix — Function
block_mass_matrix(sd::SecondOrderSemidiscretization) -> AbstractMatrixReturn the 2n × 2n block-diagonal blockdiag(mass_matrix(sd), I), the mass matrix second_order_ode_function hands DynamicalODEFunction: the I block leaves the kinematic equation u' = v an ordinary ODE, and the mass_matrix(sd) block carries whatever Dirichlet row-zeroing sd was built with.
Bramble.second_order_ode_function — Function
second_order_ode_function(sd::SecondOrderSemidiscretization) -> DynamicalODEFunction
second_order_ode_function(K::BilinearForm, l::LinearForm; kwargs...) -> DynamicalODEFunctionWrap a second-order semidiscretisation as a DynamicalODEFunction carrying its block mass matrix, ready for OrdinaryDiffEq.
The two-form method builds the SecondOrderSemidiscretization first, forwarding every keyword to semidiscretize_second_order.
Requires SciMLBase.jl; call using SciMLBase (or any package that loads it, such as OrdinaryDiffEq) before calling this function.
See also second_order_ode_problem, semidiscretize_second_order.
Bramble.second_order_ode_problem — Function
second_order_ode_problem(sd::SecondOrderSemidiscretization, du₀, u₀, I::CartesianProduct{1}) -> SecondOrderODEProblem
second_order_ode_problem(K::BilinearForm, l::LinearForm, du₀, u₀, I; kwargs...) -> SecondOrderODEProblemBuild the SecondOrderODEProblem stepping sd over the time domain I, from the initial velocity du₀ and initial displacement u₀ – each a VectorElement or a plain vector. I may equally be a (t₀, t₁) tuple.
u₀ is copied, never mutated, and the copy is made consistent with the Dirichlet rows at t₀ (see dirichlet_bc!). du₀ is passed through unchanged: velocity at a constrained dof is not an independent algebraic unknown here (see this file's header comment).
Keywords are those of second_order_ode_function; the two-form method also forwards to semidiscretize_second_order.
Requires SciMLBase.jl.
Examples
sd = semidiscretize_second_order(K, l; dirichlet = bcs)
prob = second_order_ode_problem(sd, Rₕ(Wₕ, x -> 0.0), Rₕ(Wₕ, x -> sinpi(x[1])), interval(0.0, 1.0))
sol = solve(prob, Rodas5P(); reltol = 1e-11, abstol = 1e-13)See also second_order_ode_function, semidiscretize_second_order.
Differentiable linear solve (adjoint gradients)
pde_solve(A, F) is A \ F under a name ChainRulesCore.rrule can attach an adjoint rule to – no source-level AD tool, forward or reverse, can differentiate through \ itself, since it dispatches into compiled BLAS/SuiteSparse code. assemble/dirichlet_bc! are already reverse-mode-differentiable on their own (see the automatic differentiation tutorial), so wrapping only this one function is enough to differentiate an entire θ -> assemble(a(θ), l(θ); dirichlet = θ) -> pde_solve -> J(u) chain end to end, including a gradient with respect to a Dirichlet boundary value – the adjoint solves Aᵀ λ = ∂J/∂u once, reusing the forward solve's own LU factorisation, and returns ∂J/∂A = -λ uᵀ restricted to A's sparsity (never densified) and ∂J/∂F = λ.
Requires ChainRulesCore.jl, and serves every ChainRulesCore consumer. Enzyme instead reaches the same adjoint through BrambleEnzymeExt's own native EnzymeRules rule, which using Enzyme is enough to load – do not call Enzyme.@import_rrule, whose bridge returns a wrong gradient here (see pde_solve's own docstring). Mooncake is not currently supported (a gap in Mooncake.jl's own sparse-array tangent support, also documented there). See the inverse problem worked example.
Bramble.pde_solve — Function
pde_solve(A::SparseMatrixCSC, F::AbstractVector; solver = :default, sym = :auto, kwargs...) -> Vector
pde_solve(fact::MUMPSFactorization, F::AbstractVector) -> VectorSolve A u = F (or fact u = F) and return u.
With no keyword arguments this is A \ F, unless AppleAccelerate.jl is loaded on macOS and the system is symmetric (see :default below), in which case it is accelerate_solve(A, F; sym). The name exists so that reverse-mode AD tools have one function to attach an adjoint rule to, which is what makes a whole θ -> assemble -> pde_solve -> J(u) chain differentiable.
Keywords
solver::default–A \ Fon Linux, Windows, macOS withoutAppleAccelerate.jlloaded, and macOS on an unsymmetricA; on macOS withusing AppleAcceleratein effect andAsymmetric, dispatches toaccelerate_solveinstead (same as passingsolver = :accelerateexplicitly). Symmetry isissymmetric(A)undersym = :auto(the default), or trusted outright from an explicitsym = :spd/:definite/:symmetric(and conversely:unsymmetricskips straight toA \ F) – the caller has already asserted the property, so it is not checked again. The narrowing exists because Accelerate is only a win on the symmetric factorisations it reaches (SPD/Cholesky, LDLᵀ): measured on this host against forms assembled on real 2D grid spaces,:defaultwas a 1.2-1.3x win on a symmetric Poisson-plus-mass system and a 2.3-3.6x loss on an unsymmetric convection-diffusion one (gpena/Bramble.jl#246). This only ever narrows which solver runs on macOS; Linux and Windows are never affected, and a matrix Accelerate cannot factor (e.g. non-square) throws fromaccelerate_solveexactly assolver = :acceleratewould –:defaultnever silently falls back to\after picking Accelerate.:suitesparse(CHOLMOD/UMFPACK),:spqr(sparse QR, for a least-squares or rectangularA),:accelerate(ApplelibSparse, needsAppleAccelerate.jl, honoured unconditionally regardless of symmetry),:mumps(needsMUMPS.jl),:sparspak(pure Julia, needsSparspak.jl).sym: symmetry hint for:suitesparse,:accelerateand:mumps(and, on macOS, for:default's own choice of solver – see above).:auto(default) detects it;:spd/:definite/1,:symmetric/2and:unsymmetric/0state it.
Returns
Vector: the solution, of the promoted element type ofAandF.
Reverse-mode differentiation
With ChainRulesCore.jl loaded, BrambleChainRulesExt's rrule solves the adjoint system Aᵀ λ = ∂J/∂u once, reusing the forward solve's own factorisation, and returns ∂J/∂A = -λ uᵀ restricted to A's sparsity (never densified) and ∂J/∂F = λ. using Enzyme is enough for the same adjoint through BrambleEnzymeExt's native EnzymeRules rule; do not call Enzyme.@import_rrule, whose bridge drops the cotangent's explicit zeros from nzval and returns a wrong gradient without any error. Mooncake cannot represent a SparseMatrixCSC cotangent at all and is unsupported.
A closure capturing a grid space or a form needs Enzyme.Const(f) and Enzyme.set_runtime_activity(Enzyme.Reverse), as the automatic differentiation tutorial describes for every other path. A gradient with respect to a runtime scalar coefficient of the form should be a Float64 or a Ref: an Integer coefficient known only at run time costs form its inferred return type, which Enzyme's type analysis rejects.
Examples
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0)
u = pde_solve(A, F)
using SuiteSparse
u_spd = pde_solve(A, F; solver = :suitesparse, sym = :spd)See also assemble, sparse_factorize, suitesparse_solve, suitesparse_qr_solve, accelerate_solve, mumps_solve, sparspak_solve, linear_problem.
Algebraic multigrid preconditioning
amg_preconditioner builds an algebraic multigrid hierarchy for a symmetric positive-definite matrix – typically an assembled elliptic BilinearForm, whose condition number scales as O(h^-2) under refinement – so that an iterative LinearSolve solve gets grid-independent, O(1) iteration counts instead of the O(h^-1) an unpreconditioned Krylov method needs. It returns the bare MultiLevel hierarchy; AlgebraicMultigrid.aspreconditioner turns that into the object with ldiv! that Pl/Pr expect. solve(a::BilinearForm, l::LinearForm; ...) (previous section) takes preconditioner = :amg directly, building and applying that preconditioner in one call.
Requires AlgebraicMultigrid.jl.
Bramble.amg_preconditioner — Function
amg_preconditioner(A::AbstractMatrix; method = :smoothed_aggregation, kwargs...) -> MultiLevel
amg_preconditioner(a::BilinearForm; method = :smoothed_aggregation, dirichlet = nothing,
dirichlet_components = nothing, kwargs...) -> MultiLevelBuild an algebraic multigrid hierarchy for the symmetric positive-definite matrix A – or for a assembled with the given Dirichlet conditions – ready to turn into a preconditioner with AlgebraicMultigrid.aspreconditioner.
The elliptic SBP forms Bramble assembles (Poisson, variable-coefficient diffusion, Helmholtz) have condition numbers scaling as O(h^-2): a direct sparse solve suffers severe fill-in past a few hundred thousand degrees of freedom, and an unpreconditioned Krylov method needs O(h^-1) iterations. Algebraic multigrid builds its coarse-grid hierarchy from the graph of A alone, giving a preconditioned Krylov method grid-independent, O(1) iteration counts instead.
Keywords
method::smoothed_aggregation(default) or:ruge_stuben,AlgebraicMultigrid's two hierarchy constructions.- Every other keyword forwards to the chosen
AlgebraicMultigridconstructor.
The BilinearForm method matches assemble(a::BilinearForm; ...)'s own default and does not symmetrize – Dirichlet rows become eₖ, but the matching columns are left alone, so A is not exactly symmetric even though the underlying operator is. AMG still builds a usable hierarchy from it, but for the SPD matrix the theory assumes, assemble through assemble(a, l; symmetrize = true) and pass that matrix to the AbstractMatrix method instead.
Requires AlgebraicMultigrid.jl; call using AlgebraicMultigrid before calling this function.
Examples
using AlgebraicMultigrid
A = assemble(a; dirichlet = :boundary)
ml = amg_preconditioner(A)
P = aspreconditioner(ml)
sol = solve(LinearProblem(A, F), KrylovJL_CG(); Pl = P)
# Or, directly from the form:
uₕ = solve(a, l; dirichlet = bcs, preconditioner = :amg, solver = KrylovJL_CG())See also linear_problem, assemble.
ILU(0) preconditioning for convection-dominated systems
gpena/Bramble.jl#244 measured classical algebraic multigrid failing to converge on an unsymmetric, convection-dominated system (diffusion 1e-2 against unit advection, ruge_stuben capped at 2000 GMRES iterations without converging) – AMG assumes something close to an M-matrix, which strong advection breaks. ILUZero.jl's zero-fill incomplete LU (ILU(0)) does not share that assumption: on the same system, GMRES took 18 iterations against 179 unpreconditioned, at a fraction of AMG's setup cost, since ILU(0) reuses A's own sparsity pattern with no fill-in parameter to tune. ilu_preconditioner mirrors amg_preconditioner's shape, but returns an object with ldiv! directly – ILUZero.ilu0 needs no aspreconditioner-style wrapping the way an AMG hierarchy does. solve(a::BilinearForm, l::LinearForm; ...) takes preconditioner = :ilu0 the same way it takes :amg.
When to prefer which: AMG's grid-independent, O(1) iteration count wins at scale on elliptic, symmetric positive-definite forms (Poisson, diffusion-dominated), where its M-matrix-like assumption holds. ILU(0) is the better default for unsymmetric, convection-dominated forms, where AMG is this issue's own worked counter-example for why it should not be the only option offered – see amg_preconditioner for the elliptic case.
Requires ILUZero.jl.
Bramble.ilu_preconditioner — Function
ilu_preconditioner(A::AbstractMatrix; kwargs...) -> ILUZero.ILU0Precon
ilu_preconditioner(a::BilinearForm; dirichlet = nothing,
dirichlet_components = nothing, kwargs...) -> ILUZero.ILU0PreconBuild a zero-fill incomplete LU (ILU(0)) preconditioner for the matrix A – or for a assembled with the given Dirichlet conditions – ready to use directly as LinearSolve's Pl.
Convection-dominated forms (advection large relative to diffusion) assemble unsymmetric matrices far from an M-matrix, where algebraic multigrid (amg_preconditioner) does not degrade gracefully: measured on a 2D convection-diffusion system with diffusion 1e-2 against unit advection, ruge_stuben AMG failed to converge in 2000 GMRES iterations while ILU(0) converged in 18 (gpena/Bramble.jl#244). ILU(0) reuses A's own sparsity pattern for its factors, so it has no fill-in parameter to tune and is cheap to build, at the cost of a weaker preconditioner than a tuned incomplete factorization on harder systems.
Keywords
Forwarded to ILUZero.ilu0, which currently takes none beyond A itself.
The BilinearForm method matches assemble(a::BilinearForm; ...)'s own default and does not symmetrize; ILU(0) does not assume symmetry, so this is the natural entry point for the unsymmetric, convection-dominated forms this preconditioner targets.
Requires ILUZero.jl; call using ILUZero before calling this function.
Examples
using ILUZero
A = assemble(a; dirichlet = :boundary)
P = ilu_preconditioner(A)
sol = solve(LinearProblem(A, F), KrylovJL_GMRES(); Pl = P)
# Or, directly from the form:
uₕ = solve(a, l; dirichlet = bcs, preconditioner = :ilu0, solver = KrylovJL_GMRES())See also amg_preconditioner, linear_problem, assemble.
Sparse direct solvers and factorization reuse
Bramble provides dedicated, first-class extensions for high-performance sparse linear solvers:
- SuiteSparse: CHOLMOD Cholesky for symmetric positive-definite systems and UMFPACK LU for unsymmetric systems via
SuiteSparse.jl, plus SPQR sparse QR (below) which needs onlySparseArrays. - Apple Accelerate: Native macOS
libSparseCholesky, $\mathrm{LDL}^T$, and LUTPP viaAppleAccelerate.jl(on Apple Silicon / darwin). - MUMPS: Parallel multifrontal direct solver for large 2D/3D systems via
MUMPS.jl. - Sparspak: Pure-Julia sparse direct LU (George & Liu's Waterloo package) via
Sparspak.jl– zero binary dependency, so it factors matrices whose entries areFloat32,BigFloat, or aForwardDiff.Dual, where the other three backends requireFloat64/ComplexF64.
All four solvers support non-allocating symbolic reuse via the unified refactor! driver for transient PDE time loops and Newton iterations.
Bramble.sparse_factorize — Function
sparse_factorize(A::SparseMatrixCSC; solver::Symbol = :default, sym = :auto, kwargs...) -> Factorization
sparse_factorize(a::BilinearForm; solver::Symbol = :default, sym = :auto,
dirichlet = nothing, dirichlet_components = nothing, kwargs...) -> FactorizationCompute the sparse direct factorization of A (or the assembled matrix of a) using the requested solver backend.
Solvers (solver)
:defaultor:suitesparse: SuiteSparse (CHOLMOD for SPD/symmetric, UMFPACK for unsymmetric).:accelerate: Apple Accelerate nativelibSparseon macOS (Cholesky, LDLᵀ, LUTPP, QR).:mumps: MUMPS multifrontal parallel direct solver.:sparspak: pure-Julia sparse direct LU, zero binary dependencies.
Symmetry options
:auto: automatically detect matrix symmetry (and diagonal positivity).:spd,:definite, or1: symmetric positive definite.:symmetricor2: general symmetric.:unsymmetricor0: general unsymmetric.
Ignored by :sparspak, which always factors as general unsymmetric LU.
Examples
fact = sparse_factorize(A; solver = :suitesparse, sym = :spd)
u = fact \ FSee also refactor!, pde_solve, suitesparse_factorize, suitesparse_qr_factorize, accelerate_factorize, mumps_factorize, sparspak_factorize.
Bramble.refactor! — Function
refactor!(fact::Factorization, A::SparseMatrixCSC) -> Factorization
refactor!(fact::Factorization, a::BilinearForm; dirichlet = nothing, dirichlet_components = nothing) -> FactorizationRecompute the numerical values of fact for updated matrix A (or assembled bilinear form a) reusing the existing symbolic factorization (fill-reducing ordering and elimination tree). A must have the exact same sparsity pattern as the matrix originally factored.
Dispatches automatically via multiple dispatch to the appropriate backend:
SuiteSparseFactorization: updates CHOLMOD or UMFPACK numeric values.AccelerateFactorization: updates Apple AcceleratelibSparsenumeric values.MUMPSFactorization: updates MUMPS multifrontal numerical factorization (job = 2).SparspakFactorization: updates Sparspak's numeric LU values.
See also sparse_factorize, pde_solve.
SuiteSparse solver
suitesparse_factorize/suitesparse_solve accept the same ordering and pivoting parameters as Julia's own cholesky/lu on a SparseMatrixCSC – a fill-reducing perm for CHOLMOD, or a column ordering q and an 8-element control vector for UMFPACK – and forward them unchanged, so suitesparse_factorize(A; sym = :spd, perm = my_ordering) reaches CHOLMOD's own ordering routine rather than Bramble's default.
Bramble.SuiteSparseFactorization — Type
SuiteSparseFactorization{T} <: Factorization{T}Wrapper type representing a factorized SuiteSparse linear system (CHOLMOD Cholesky or UMFPACK LU), supporting in-place solves via LinearAlgebra.ldiv!, back-substitution via \, and non-allocating numeric refactoring via suitesparse_refactor!.
Bramble.suitesparse_factorize — Function
suitesparse_factorize(A::AbstractMatrix; sym = :auto, kwargs...) -> SuiteSparseFactorization
suitesparse_factorize(a::BilinearForm; dirichlet = nothing, dirichlet_components = nothing,
symmetrize = false, sym = :auto, kwargs...) -> SuiteSparseFactorizationCompute the sparse direct factorization of A (or the assembled matrix of a) using SuiteSparse (CHOLMOD for symmetric positive-definite systems, UMFPACK for unsymmetric).
Symmetry options
:auto(default): automatically detects symmetry. IfAis symmetric and positive definite (orsymmetrize = true), uses CHOLMOD sparse Cholesky. Otherwise, uses UMFPACK sparse LU.:spd,:definite, or1: symmetric positive definite (CHOLMOD Cholesky).:symmetricor2: symmetric factorization.:unsymmetricor0: general unsymmetric (UMFPACK LU).
Requires SuiteSparse.jl; call using SuiteSparse before calling this function.
Examples
using SuiteSparse
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0, symmetrize = true)
fact = suitesparse_factorize(A; sym = :spd)
u = fact \ FSee also suitesparse_solve, suitesparse_refactor!, pde_solve, assemble.
Bramble.suitesparse_solve — Function
suitesparse_solve(A::SparseMatrixCSC, F::AbstractVector; sym = :auto, kwargs...) -> Vector
suitesparse_solve(a::BilinearForm, l::LinearForm; dirichlet = nothing, dirichlet_components = nothing,
symmetrize = false, sym = :auto, kwargs...) -> VectorElementDirectly solve A u = F (or assemble(a, l) system) using SuiteSparse factorization.
Requires SuiteSparse.jl; call using SuiteSparse before calling this function.
See also suitesparse_factorize, refactor!, pde_solve.
Bramble.suitesparse_refactor! — Function
suitesparse_refactor!(fact::SuiteSparseFactorization, A::SparseMatrixCSC) -> SuiteSparseFactorizationRecompute the numeric factorization of A inside fact reusing the existing symbolic factorization (fill-reducing ordering and elimination tree). A must have the exact same sparsity pattern as the matrix originally factored.
See also refactor!, suitesparse_factorize.
SPQR sparse QR (least-squares and rectangular systems)
suitesparse_qr_factorize/suitesparse_qr_solve wrap SparseArrays.SPQR.qr for overdetermined least-squares systems and the rectangular blocks of a constrained saddle-point form – A need not be square. Unlike the rest of this section these need only SparseArrays, already a dependency of Bramble, so no using SuiteSparse is required.
Bramble.suitesparse_qr_factorize — Function
suitesparse_qr_factorize(A::AbstractMatrix; tol = ..., ordering = ..., kwargs...) -> QRSparse
suitesparse_qr_factorize(a::BilinearForm; dirichlet = nothing, dirichlet_components = nothing,
kwargs...) -> QRSparseCompute the sparse direct QR factorization of A (or the assembled matrix of a) using SuiteSparse's SPQR, suited to overdetermined least-squares systems and the rectangular blocks of a constrained saddle-point form. Unlike suitesparse_factorize, A need not be square, and this needs only SparseArrays – already a dependency of Bramble, no using SuiteSparse required.
kwargs (tol, ordering, ...) are forwarded to SparseArrays.SPQR.qr.
Examples
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0)
fact = suitesparse_qr_factorize(A)
u = fact \ FSee also suitesparse_qr_solve, suitesparse_factorize, pde_solve.
Bramble.suitesparse_qr_solve — Function
suitesparse_qr_solve(A::SparseMatrixCSC, F::AbstractVector; tol = ..., ordering = ..., kwargs...) -> Vector
suitesparse_qr_solve(a::BilinearForm, l::LinearForm; dirichlet = nothing, dirichlet_components = nothing,
kwargs...) -> VectorElementDirectly solve A u = F (exactly if square, least-squares if overdetermined) using SuiteSparse's SPQR sparse QR factorization. Needs only SparseArrays; no using SuiteSparse required.
See also suitesparse_qr_factorize, pde_solve.
Apple Accelerate solver (macOS)
gpena/Bramble.jl#142 asked whether AppleAccelerate.jl is worth wiring in on macOS. It is, but only for the symmetric factorisations it actually wins on – pde_solve's own docstring states the narrowed dispatch; this section answers the issue's four questions from measurement.
Speedup. Against A \ F – what :default did before Accelerate existed – a symmetric Poisson-plus-mass system is a 1.2-1.3x win (0.78-0.83x the runtime, measured at n = 80 and n = 120) and an unsymmetric convection-diffusion system is now exactly 1.00x, because :default no longer routes it to Accelerate at all (gpena/Bramble.jl#246, integrator re-measurement). Earlier, broader factorisation-level numbers against :suitesparse (not A \ F) put sparse SPD Cholesky at 0.75-0.85x and sparse symmetric LDLᵀ at 0.26-0.78x across n = 40, 80, 160, while unsymmetric LUTPP was 1.66-4.07x slower – the reason :default never reaches Accelerate for an unsymmetric system.
Extension scoping. Settled: the guard is Sys.isapple() && Base.get_extension(Bramble, :BrambleAppleAccelerateExt) !== nothing, so using AppleAccelerate on Linux or Windows still resolves :default to A \ F and Accelerate never becomes a hard dependency of Bramble or of CI on any platform.
Threading. AppleAccelerate.jl exports BLAS_THREADING_MULTI_THREADED and BLAS_THREADING_SINGLE_THREADED, the knob for vecLib's own internal thread pool, alongside a setter that reads vecLib's threading API directly. No dedicated measurement isolated vecLib threading against Bramble's own Threads.@threads/@batch assembly sweeps running concurrently: the benchmarks behind the speedup figures above ran at --threads=4 (factorisation comparison) and --threads=2 (dispatch-narrowing check) without symptoms attributable to thread contention, but that is not the same as a study built to detect it. A caller who suspects contention on a heavily loaded machine can force vecLib to BLAS_THREADING_SINGLE_THREADED explicitly; Bramble does not set this itself.
Accuracy vs. OpenBLAS. Audited against LinearAlgebra's own factorisations across sparse SPD/LDLᵀ/QR/LUTPP and the dense accelerate_factorize kinds: the worst observed relative residual was 3.637...e-14 (sparse SPD Cholesky via Accelerate), and a dense QR least-squares case matched to 0.0. Every atol in the test suite guarding a solve is 1.0e-12, two orders of magnitude looser than that residual, so no tolerance needed tightening or loosening. Two calls into the same factorisation are not always bit-identical – vecLib reorders floating-point reductions across calls – so compare with isapprox, never ==.
A sym caveat. Under :default, an unrecognised sym (anything other than :auto/:spd/:definite/:symmetric/:unsymmetric and their integer aliases) silently falls back to A \ F rather than raising, matching :default's pre-Accelerate behaviour of ignoring sym entirely. This is looser than solver = :accelerate, which validates sym and throws on an unrecognised value.
Bramble.AccelerateFactorization — Type
AccelerateFactorization{T} <: Factorization{T}Wrapper type representing a factorized Apple Accelerate (libSparse) linear system, supporting in-place solves via LinearAlgebra.ldiv!, back-substitution via \, and non-allocating symbolic reuse via accelerate_refactor!. Available only on macOS.
Bramble.accelerate_factorize — Function
accelerate_factorize(A::SparseMatrixCSC; sym = :auto, kind = :auto, kwargs...) -> AccelerateFactorization
accelerate_factorize(a::BilinearForm; dirichlet = nothing, dirichlet_components = nothing,
symmetrize = false, sym = :auto, kind = :auto, kwargs...) -> AccelerateFactorizationCompute the sparse direct factorization of A (or the assembled matrix of a) using Apple Accelerate's native libSparse on macOS.
Symmetry and factorization options
sym = :auto(default): automatically detects symmetry. IfAis symmetric with strictly positive diagonal, uses Cholesky (SparseFactorizationCholesky). If symmetric, uses $LDL^T$ (SparseFactorizationLDLT). Otherwise uses threshold partial pivoting LU (SparseFactorizationLUTPP).sym = :spd,:definite, or1(orkind = :cholesky): symmetric positive definite Cholesky.sym = :symmetricor2(orkind = :ldlt): symmetric indefinite $LDL^T$.sym = :unsymmetricor0(orkind = :lu/:lutpp): general unsymmetric LU with threshold partial pivoting.kind = :qr: sparse QR factorization.
Requires macOS and AppleAccelerate.jl; call using AppleAccelerate before calling this function.
Examples
using AppleAccelerate
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0, symmetrize = true)
fact = accelerate_factorize(A; sym = :spd)
u = fact \ FSee also accelerate_solve, accelerate_refactor!, pde_solve, assemble.
accelerate_factorize(A::AbstractMatrix; sym = :auto, kind = :auto, kwargs...) -> LinearAlgebra.FactorizationDense factorization of A, via ordinary LinearAlgebra.lu, LinearAlgebra.cholesky, or LinearAlgebra.qr – not Apple Accelerate's libSparse used by the SparseMatrixCSC method above, and not gated on macOS.
AppleAccelerate.jl (checked against v0.7.0) defines no dense lu, cholesky, getrf, potrf, gemm, or other LAPACK/BLAS bindings for Bramble to call. What its __init__ does is register Accelerate with libblastrampoline (BLAS.lbt_forward), so plain LinearAlgebra.lu/cholesky/qr are already running on Accelerate's BLAS/LAPACK the moment using AppleAccelerate has been evaluated, on every call site in Bramble or anywhere else, with no Bramble dispatch involved. This method adds no acceleration beyond that; it exists so dense callers can spell accelerate_factorize with the same sym/kind vocabulary as the sparse methods above. kwargs... is accepted but unused.
Symmetry and factorization options
sym = :auto(default):LinearAlgebra.choleskyifAis symmetric positive definite, otherwiseLinearAlgebra.lu.sym = :spd,:definite, or1(orkind = :cholesky):LinearAlgebra.cholesky.sym = :unsymmetricor0(orkind = :lu):LinearAlgebra.lu.kind = :qr:LinearAlgebra.qr.sym = :symmetric,2, orkind = :ldlt: not implemented here. The dense analogue of a sparseLDLᵀfactorization isLinearAlgebra.bunchkaufman, which this method does not wrap; call it directly.
See also accelerate_solve.
Bramble.accelerate_solve — Function
accelerate_solve(A::SparseMatrixCSC, F::AbstractVector; sym = :auto, kind = :auto, kwargs...) -> Vector
accelerate_solve(a::BilinearForm, l::LinearForm; dirichlet = nothing, dirichlet_components = nothing,
symmetrize = false, sym = :auto, kind = :auto, kwargs...) -> VectorElementDirectly solve A u = F (or assemble(a, l) system) using Apple Accelerate direct factorization.
Requires macOS and AppleAccelerate.jl; call using AppleAccelerate before calling this function.
See also accelerate_factorize, refactor!, pde_solve.
Bramble.accelerate_refactor! — Function
accelerate_refactor!(fact::AccelerateFactorization, A::SparseMatrixCSC) -> AccelerateFactorizationRecompute the numeric factorization of A stored in fact reusing the existing symbolic factorization (fill-reducing ordering and sparsity analysis). A must have the exact same sparsity pattern as the matrix originally factored.
See also refactor!, accelerate_factorize.
MUMPS sparse direct solver
Bramble.MUMPSFactorization — Type
MUMPSFactorization{T} <: Factorization{T}Wrapper type representing a factorized MUMPS linear system, supporting in-place solves via LinearAlgebra.ldiv!, back-substitution via \, and non-allocating reuse for transient PDE time stepping.
Bramble.mumps_factorize — Function
mumps_factorize(A::AbstractMatrix; sym = :auto, icntl = nothing, cntl = nothing, kwargs...) -> MUMPSFactorization
mumps_factorize(a::BilinearForm; dirichlet = nothing, dirichlet_components = nothing,
symmetrize = false, sym = :auto, kwargs...) -> MUMPSFactorizationCompute the sparse direct multifrontal factorization of A (or the assembled matrix of a) using MUMPS.jl.
Symmetry options
:auto(default): automatically detects symmetry. IfAis symmetric and positive definite (orsymmetrize = true), uses symmetric positive-definite factorization (sym = 1). If symmetric, uses general symmetric $LDL^T$ (sym = 2). Otherwise, uses unsymmetric LU (sym = 0).:spd,:definite, or1: symmetric positive definite (Cholesky / $LL^T$).:symmetricor2: general symmetric ($LDL^T$ with Bunch-Kaufman pivoting).:unsymmetricor0: general unsymmetric LU.
Control parameters
icntl: Optional dictionary or collection of pairs of integer control parameters (e.g.,7 => 1for user/METIS ordering,14 => 30for memory relaxation).cntl: Optional dictionary or collection of pairs of real control parameters (e.g.,1 => 0.01for numerical pivoting threshold).
Requires MUMPS.jl; call using MUMPS before calling this function.
Examples
using MUMPS
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0, symmetrize = true)
fact = mumps_factorize(A; sym = :spd)
u = fact \ FSee also pde_solve, mumps_solve, assemble.
Bramble.mumps_solve — Function
mumps_solve(A::SparseMatrixCSC, F::AbstractVector; sym = :auto, kwargs...) -> Vector
mumps_solve(a::BilinearForm, l::LinearForm; dirichlet = nothing, dirichlet_components = nothing,
symmetrize = false, sym = :auto, kwargs...) -> VectorElementDirectly solve A u = F (or assemble(a, l) system) using MUMPS direct factorization.
Symmetry options
:auto(default): automatically detects symmetry.:spd,:definite, or1: symmetric positive definite.:symmetricor2: general symmetric.:unsymmetricor0: general unsymmetric.
Requires MUMPS.jl; call using MUMPS before calling this function.
Examples
using MUMPS
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0)
u = mumps_solve(A, F)See also mumps_factorize, refactor!, pde_solve.
Bramble.mumps_refactor! — Function
mumps_refactor!(fact::MUMPSFactorization, A::SparseMatrixCSC) -> MUMPSFactorizationRecompute the numeric factorization of A inside fact reusing the existing symbolic factorization (fill-reducing analysis and ordering). A must have the exact same sparsity pattern as the matrix originally factored.
See also refactor!, mumps_factorize.
Sparspak sparse direct solver (pure Julia)
Bramble.SparspakFactorization — Type
SparspakFactorization{T} <: Factorization{T}Wrapper type representing a factorized Sparspak.jl sparse LU system, supporting in-place solves via LinearAlgebra.ldiv!, back-substitution via \, and non-allocating reuse for transient PDE time stepping.
Bramble.sparspak_factorize — Function
sparspak_factorize(A::AbstractMatrix) -> SparspakFactorization
sparspak_factorize(a::BilinearForm; dirichlet = nothing, dirichlet_components = nothing) -> SparspakFactorizationCompute the pure-Julia sparse direct LU factorization of A (or the assembled matrix of a) using Sparspak.jl.
Sparspak has no binary dependency, so it factors matrices whose entries are not Float64/ComplexF64 – Float32, BigFloat, or a ForwardDiff.Dual – where SuiteSparse and MUMPS cannot.
Requires Sparspak.jl; call using Sparspak before calling this function.
Examples
using Sparspak
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0)
fact = sparspak_factorize(A)
u = fact \ FSee also sparspak_solve, sparspak_refactor!, pde_solve, assemble.
Bramble.sparspak_solve — Function
sparspak_solve(A::SparseMatrixCSC, F::AbstractVector) -> Vector
sparspak_solve(a::BilinearForm, l::LinearForm; dirichlet = nothing, dirichlet_components = nothing,
symmetrize = false) -> VectorElementDirectly solve A u = F (or assemble(a, l) system) using Sparspak's pure-Julia sparse direct LU factorization.
Requires Sparspak.jl; call using Sparspak before calling this function.
Examples
using Sparspak
A, F = assemble(a, l; dirichlet = :boundary => x -> 0.0)
u = sparspak_solve(A, F)See also sparspak_factorize, sparspak_refactor!, pde_solve.
Bramble.sparspak_refactor! — Function
sparspak_refactor!(fact::SparspakFactorization, A::SparseMatrixCSC) -> SparspakFactorizationRecompute the numeric factorization of A inside fact reusing the existing symbolic factorization (fill-reducing ordering). A must have the exact same sparsity pattern as the matrix originally factored.
See also refactor!, sparspak_factorize.
Caching a coefficient-dependent assembly by element type
A Newton residual generic over T (Float64 on a plain call, ForwardDiff.Dual while an AD backend's sparse Jacobian sweep is probing it) cannot preallocate one matrix the way a Picard loop can. type_cached_assemble! gives the sparsity pattern a place to live per element type it is ever reached at instead, so only the very first call at a given type pays for it.
Bramble.type_cached_assemble! — Function
type_cached_assemble!(build, cache::AbstractDict, uₕ::VectorElement;
dirichlet = nothing, dirichlet_components = nothing) -> AbstractMatrixAssembles a coefficient-dependent BilinearForm into a matrix whose sparsity pattern is built once per distinct element type uₕ is ever passed at, rather than on every call – the fix diffusion_matrix-style Newton residuals in the nonlinear worked examples name and deliberately defer, since assemble/allocate_system_matrix rebuild the whole matrix, pattern included, every time otherwise.
build(uₕ) is called once for each element type uₕ is ever seen at, and must return (a, refill!): the BilinearForm to assemble, built around whatever live coefficient buffer(s) it needs (see the forms tutorial), and a one-argument function refill!(uₕ) updating those buffers from the current uₕ – called on every invocation, cache hit or miss, so a later call at an already-seen type still sees the new guess rather than the one build first saw.
build itself should be a named function defined once, not a closure literal written inside whatever function calls type_cached_assemble!, the same reason the Picard loop in poisson_nonlinear.jl builds its own form once, outside the loop, rather than on every iteration: a do ... end block re-literalized on every call allocates a new closure each time, which is exactly the cost this function exists to avoid paying more than once.
function build_diffusion(uₕ)
Mu = element(Wₕ, eltype(uₕ)) # scratch for Mₓ!'s own output
αvals = element(Wₕ, eltype(uₕ))
a = form(Wₕ, Wₕ, (U, V) -> inner₊(αvals * ∇ₕ(U), ∇ₕ(V)))
refill!(uₕ) = begin
Mₓ!(Mu, uₕ) # in place: `Mₓ(uₕ)` alone would allocate a fresh result
αvals .= α.(Mu)
end
return a, refill!
end
cache = Dict()
diffusion_matrix(uₕ) = type_cached_assemble!(
build_diffusion, cache, uₕ; dirichlet = :boundary)refill! reaches for Mₓ! rather than the non-mutating Mₓ/Mₕ deliberately: the latter allocates a fresh result every call (the same similar-based cost every allocating stencil operator has), which would silently reintroduce an O(n) allocation this function's whole point is to stop paying repeatedly. Mₓ! alone covers the 1D case above; a D-dimensional coefficient needs one scratch buffer and one Mₓ!/Mᵧ!/M₂! call per direction, the same way poisson_nonlinear.jl's own nonlinear_series builds a D-dimensional coefficient tuple.
cache is shared across an entire Newton (or Picard) loop, one Dict per residual: the first call at a given type pays build's own cost plus allocate_system_matrix's; every later call at that same type pays only assemble!'s refill plus a small, fixed dictionary/dynamic-dispatch overhead fetching the cached entry back out (a few KB, independent of ndofs) – not the O(ndofs) pattern rebuild a cache miss (or no cache at all) pays every time.
Not thread-safe: cache is a plain, unlocked Dict, sized for the one-cache-per-residual usage above. A form assembled from more than one task needs a lock or a per-task cache, the same as any other shared mutable Dict.
JuliaSparse ecosystem evaluation
gpena/Bramble.jl#244 asked whether other packages in the JuliaSparse organization and its neighbours are worth adopting for assembly, direct solves, or iterative preconditioning. Each candidate below was installed on Julia 1.12 and measured directly against Bramble's own functions – never a synthetic microbenchmark standing in for them (see bramble-verification) – so a "no" here is a measured "no", not a guess. Numbers are a single run on one machine, not a tracked baseline; treat them as directional.
Assembly & storage formats
SparseMatricesCOO.jl: not adopted. Bramble's own assembly already skips the triplet stage entirely: assemble determines the sparsity pattern once (PatternSink, the lock-free colouring sweep documented in Forms) and every subsequent call writes straight into nzval via add_to_sparse!, never building (I, J, V) at all. Measured on a 2D 60×60 Poisson system (n = 3600, nnz = 17760):
| Path | Time |
|---|---|
Bramble assemble (first call, builds the pattern) | 0.37 ms |
Bramble assemble (repeat call, pattern cached) | 0.37 ms |
Base.sparse(I, J, V) on the identical triplets | 0.06 ms |
SparseMatricesCOO.jl COO→CSC on the identical triplets | 203 seconds |
SparseMatricesCOO.jl defines no specialised SparseMatrixCSC(::SparseMatrixCOO) constructor, so the conversion falls through to Julia's generic dense-iteration AbstractMatrix fallback – an O(m \cdot n \cdot \mathrm{nnz}) scan through every getindex, itself an O(\mathrm{nnz}) linear search of the triplet arrays (confirmed by reading SparseMatricesCOO.jl's source, not assumed from the number alone). The package is designed by JuliaSmoothOptimizers as an NLP-solver interop format (handing Jacobian/Hessian triplets to IPOPT-style solvers that want COO directly), not as a fast intermediate for building a SparseMatrixCSC – the wrong tool for what this issue asked it to do here. Bramble's first assembly is already about as fast as its thousandth, which is the actual bar a triplet library would need to clear.
SymRCM.jl: evaluated under reordering, below – not for assembly.
Tensor-compiler assembly
Finch.jl: not adopted. gpena/Bramble.jl#217 asked whether a @finch-compiled loop nest – Finch.jl's domain-specific compiler for structured and sparse tensors – beats Bramble's own RecordSink/ReplaySink assembly by enough to justify a dedicated backend. The issue set its own threshold: greater than 1.5x speedup or greater than 50% memory reduction on the resulting object, at N = 1e6.
benchmark/finch_assembly.jl measured 1D, 2D and 3D Poisson and convection-diffusion forms, N from 1e2 to 1e6 on non-uniform (seeded rand!) meshes: Bramble's first assemble (pattern discovery plus fill) against Finch's first @finch build, Bramble's assemble! refill against Finch's refill, Base.summarysize of the resulting matrix/tensor, and time to first execution (TTFX). Every one of the 18 (dimension, form, size) cases produced a Finch tensor identical to Bramble's matrix to 1e-12 – both are built from the same non-uniform-mesh (row, col, value) triplets, findnz on the CSC matrix Bramble already assembled – which is what makes the timing comparison meaningful rather than a comparison between two different answers. The six N = 1e6 cases, the ones the adoption rule is evaluated against:
| Dim | Form | Bramble refill (ms) | Bramble size (MiB) | Finch refill (ms) | Finch size (MiB) | Refill speedup | Size Δ% | Match |
|---|---|---|---|---|---|---|---|---|
| 1 | Poisson | 4.372 | 91.553 | 12.383 | 71.63 | 0.35 | 21.8 | true |
| 1 | Convection-diffusion | 6.101 | 129.7 | 10.373 | 71.63 | 0.59 | 44.8 | true |
| 2 | Poisson | 7.504 | 175.43 | 13.34 | 135.63 | 0.56 | 22.7 | true |
| 2 | Convection-diffusion | 12.711 | 251.709 | 12.239 | 135.63 | 1.04 | 46.1 | true |
| 3 | Poisson | 15.008 | 258.713 | 16.19 | 135.63 | 0.93 | 47.6 | true |
| 3 | Convection-diffusion | 53.266 | 372.925 | 18.312 | 135.63 | 2.91 | 63.6 | true |
Only one of the six clears the bar: 3D convection-diffusion, a 2.91x refill speedup and a 63.6% smaller resident tensor. The rule needs at least two qualifying cases out of six, so the table alone already falls short. Finch's own compilation cost settles it further: time to first execution – the very first @finch call in the process, before any warm-up – was about 39 seconds against Bramble's 0.1 millisecond. A package whose users open a REPL, run one assembly and look at the result cannot pay a 39-second tax on the first call, even for a backend that eventually wins on refills. Nothing in ext/ was written: finch_backend and BrambleFinchExt.jl from the issue's proposed architecture do not exist.
Two things bound how far this "no" reaches. First, a methodology departure recorded in benchmark/finch_assembly.jl's own header: the script hands Finch the (row, col, value) triplets Bramble's assembly already computed and times only how fast a @finch loop nest copies them into a Tensor(Dense(SparseList(Element(0.0)))) – the insertion half of assembly, not the fused stencil-evaluation-and-insertion Finch's compiler actually promises and the issue's own Problem Statement names as the point. That measures an upper bound favouring Finch, and Finch still lost under it. Second, both the Bramble and the Finch runs were made on battery power under heavy concurrent load, so the ratios in the table, not the absolute millisecond figures, carry this decision.
What would change the answer: a fused evaluate-and-insert extension, where Finch compiles the stencil evaluation itself from Bramble's own AST rather than consuming triplets Bramble already produced, together with a way to amortise the roughly 39-second TTFX across precompilation rather than a user's first call.
Direct sparse solvers
Sparspak.jl: done, not re-evaluated here. Built in gpena/Bramble.jl#247; see Sparspak sparse direct solver (pure Julia) above.
Pardiso.jl: not adopted, for a licensing reason rather than a technical one. Pardiso.jl bridges to one of two backends, and neither is available without something Bramble cannot bundle:
- Intel MKL PARDISO needs a separately installed MKL;
Pardiso.mkl_is_available()isfalseon a plain Julia 1.12 environment, and constructing anMKLPardisoSolverthrows"MKL is not available". - Panua (formerly the free academic) PARDISO needs a separately downloaded, licensed shared library; constructing a
PardisoSolverthrows"Panua pardiso library was not loaded".
Both were reproduced directly (not assumed) on a fresh Julia 1.12 environment. This is the same shape of blocker that closed gpena/Bramble.jl#245 (ThreadedSparseCSR.jl) as won't-fix: a real, verified dependency the package cannot satisfy on behalf of a user, rather than missing integration work. A user who already holds an MKL or Panua license and wants to use it can still call Pardiso.jl directly against A/F from assemble – nothing in Bramble stands in the way of that – it is just not something this package can wire up as a first-class solver option for everyone.
Iterative solvers & preconditioners
Krylov methods are already reachable through solve with solver = KrylovJL_GMRES() etc. (BrambleSciMLExt), and amg_preconditioner already covers algebraic multigrid preconditioning. What #244 asked to evaluate is whether ILUZero.jl / IncompleteLU.jl add anything beyond that. Measured on an unsymmetric 2D convection-diffusion system (90×90 grid, n = 8100, diffusion 1\mathrm{e}{-2} against unit advection in both directions – the convection-dominated regime the issue named), unrestarted GMRES to atol = rtol = 1\mathrm{e}{-10}:
| Preconditioner | Time | Iterations | Converged |
|---|---|---|---|
| none | 51.4 ms | 179 | yes |
AMG (ruge_stuben) | 6475.9 ms | 2000 (capped) | no |
IncompleteLU.jl (τ = 0.01) | 19.6 ms | 95 | yes |
ILUZero.jl (ILU(0)) | 4.5 ms | 18 | yes |
Classical algebraic multigrid assumes something close to an M-matrix and does not fail gracefully once advection dominates diffusion this strongly – it neither converges nor finishes quickly here, which is a known limitation of ruge_stuben-style coarsening on non-symmetric, convection-dominated operators, not a bug in AlgebraicMultigrid.jl. ILUZero.jl's zero-fill ILU(0), reusing A's own sparsity pattern, is the clear winner: about 11× fewer iterations and 11× less wall time than no preconditioner, and 4× less than IncompleteLU.jl's drop-tolerance variant, at a fraction of the setup cost either of the others carries. Built as ilu_preconditioner in gpena/Bramble.jl#255, mirroring amg_preconditioner's shape – see "ILU(0) preconditioning for convection-dominated systems" above.
Metis.jl's graph partitioning was evaluated under reordering, not as a preconditioner, below.
Fill-reducing reordering
Measured on a 3D 24×24×24 Poisson system (n = 13824, nnz = 93312), CHOLMOD Cholesky factorization with three orderings:
| Ordering | Factor time | nnz(L) |
|---|---|---|
| CHOLMOD default (built-in AMD) | 25.0 ms | 2,147,132 |
Metis.jl (nested dissection) | 20.1 ms | 1,654,868 |
SymRCM.jl (Cuthill-McKee) | 53.3 ms | 4,768,508 |
Metis.jl's nested-dissection ordering measurably beats CHOLMOD's own default AMD here – about 20% less factorization time and 23% less fill – a genuine, reproducible win on a 3D system. SymRCM.jl is worse on both counts: Cuthill-McKee minimises bandwidth, not fill, and 3D discretizations are exactly where that distinction costs the most. Both orderings reach suitesparse_factorize/sparse_factorize today, with no new extension needed – gpena/Bramble.jl#248 already forwards a perm keyword straight to CHOLMOD:
using Metis
perm, _ = Metis.permutation(A)
fact = suitesparse_factorize(A; sym = :spd, perm = Int.(perm))Metis.jl is worth naming explicitly in the ordering documentation rather than building anything further for it.
Summary
| Package | Verdict |
|---|---|
SparseMatricesCOO.jl | Not adopted – wrong tool for this use, and Bramble's own path is already faster |
Sparspak.jl | Done – #247 |
Pardiso.jl | Not adopted – no usable backend without a separate license, same shape as #245 |
Krylov.jl | Already available via solve's solver keyword |
IncompleteLU.jl | Works, but ILUZero.jl dominates it here |
ILUZero.jl | Done – ilu_preconditioner, #255 |
Metis.jl | Recommended – genuine fill/time win on 3D systems, usable today via existing perm forwarding |
SymRCM.jl | Not adopted – worse fill than the CHOLMOD default on the systems Bramble assembles |