Performance Tips
This section briefly summarises a few common techniques to ensure good performance when using Turing. We refer to the Julia documentation for general techniques to ensure good performance of Julia programs.
Use multivariate distributions
It is generally preferable to use multivariate distributions if possible.
The following example:
@model function gmodel(x)
m ~ Normal()
for i = 1:length(x)
x[i] ~ Normal(m, 0.2)
end
end
can be directly expressed more efficiently using a simple transformation:
using FillArrays
@model function gmodel(x)
m ~ Normal()
x ~ MvNormal(Fill(m, length(x)), 0.04 * I)
end
Choose your AD backend
Turing currently provides support for two different automatic differentiation (AD) backends. Generally, try to use :forwarddiff
for models with few parameters and :reversediff
, :tracker
or :zygote
for models with large parameter vectors or linear algebra operations. See Automatic Differentiation for details.
Special care for :tracker
and :zygote
In case of :tracker
and :zygote
, it is necessary to avoid loops for now. This is mainly due to the reverse-mode AD backends Tracker
and Zygote
which are inefficient for such cases. ReverseDiff
does better but vectorized operations will still perform better.
Avoiding loops can be done using filldist(dist, N)
and arraydist(dists)
. filldist(dist, N)
creates a multivariate distribution that is composed of N
identical and independent copies of the univariate distribution dist
if dist
is univariate, or it creates a matrix-variate distribution composed of N
identical and independent copies of the multivariate distribution dist
if dist
is multivariate. filldist(dist, N, M)
can also be used to create a matrix-variate distribution from a univariate distribution dist
. arraydist(dists)
is similar to filldist
but it takes an array of distributions dists
as input. Writing a custom distribution with a custom adjoint is another option to avoid loops.
Ensure that types in your model can be inferred
For efficient gradient-based inference, e.g. using HMC, NUTS or ADVI, it is important to ensure the types in your model can be inferred.
The following example with abstract types
@model function tmodel(x, y)
p,n = size(x)
params = Vector{Real}(undef, n)
for i = 1:n
params[i] ~ truncated(Normal(), 0, Inf)
end
a = x * params
y ~ MvNormal(a, I)
end
can be transformed into the following representation with concrete types:
@model function tmodel(x, y, ::Type{T} = Float64) where {T}
p,n = size(x)
params = Vector{T}(undef, n)
for i = 1:n
params[i] ~ truncated(Normal(), 0, Inf)
end
a = x * params
y ~ MvNormal(a, I)
end
Alternatively, you could use filldist
in this example:
@model function tmodel(x, y)
params ~ filldist(truncated(Normal(), 0, Inf), size(x, 2))
a = x * params
y ~ MvNormal(a, I)
end
Note that you can use @code_warntype
to find types in your model definition that the compiler cannot infer. They are marked in red in the Julia REPL.
For example, consider the following simple program:
@model function tmodel(x)
p = Vector{Real}(undef, 1)
p[1] ~ Normal()
p = p .+ 1
x ~ Normal(p[1])
end
We can use
using Random
model = tmodel(1.0)
@code_warntype model.f(
model,
Turing.VarInfo(model),
Turing.SamplingContext(
Random.default_rng(), Turing.SampleFromPrior(), Turing.DefaultContext(),
),
model.args...,
)
to inspect type inference in the model.
Reuse Computations in Gibbs Sampling
Often when performing Gibbs sampling, one can save computational time by caching the output of expensive functions. The cached values can then be reused in future Gibbs sub-iterations which do not change the inputs to this expensive function. For example in the following model
@model function demo(x)
a ~ Gamma()
b ~ Normal()
c = function1(a)
d = function2(b)
x .~ Normal(c, d)
end
alg = Gibbs(MH(:a), MH(:b))
sample(demo(zeros(10)), alg, 1000)
when only updating a
in a Gibbs sub-iteration, keeping b
the same, the value of d
doesn't change. And when only updating b
, the value of c
doesn't change. However, if function1
and function2
are expensive and are both run in every Gibbs sub-iteration, a lot of time would be spent computing values that we already computed before. Such a problem can be overcome using Memoization.jl
. Memoizing a function lets us store and reuse the output of the function for every input it is called with. This has a slight time overhead but for expensive functions, the savings will be far greater.
To use Memoization.jl
, simply define memoized versions of function1
and function2
as such:
using Memoization
@memoize memoized_function1(args...) = function1(args...)
@memoize memoized_function2(args...) = function2(args...)
Then define the Turing
model using the new functions as such:
@model function demo(x)
a ~ Gamma()
b ~ Normal()
c = memoized_function1(a)
d = memoized_function2(b)
x .~ Normal(c, d)
end