Skip to content

Probabilistic Programming

Reactant.jl compiles ordinary Julia code through MLIR, running it on CPU, GPU, or TPU without rewriting. You write a normal Julia function, wrap a call to it in @compile, and Reactant traces the function, lowers it through MLIR, and hands you back a callable compiled program. Array inputs are staged through ConcreteRArray (created with Reactant.to_rarray) so they live on the target device.

Reactant.ProbProg is the Julia front-end for the impulse dialect, implemented across Enzyme (dialect definition and inference materialization passes) and Enzyme-JAX (backend-specific lowering). The impulse dialect provides high-level MLIR ops for describing probabilistic modeling and inference, materializes inference computation through compiler passes, and applies general-purpose and probabilistic-programming-specific optimizations during lowering.

optimize = :probprog opt-in required

For now, @compile needs an explicit optimize = :probprog argument on probabilistic programs to enable the impulse-specific MLIR passes (you'll see this in every @compile call below). Merging those passes into the default @compile pipeline is work in progress; once it lands, the explicit opt-in will no longer be required.

Next, we walk through two operating modes of Reactant.ProbProg: a trace-based mode built around a generative function, and a custom log-density mode that takes a custom log-density function.

Trace-based mode

We describe a Bayesian linear regression question:

slopeN(0,2)interceptN(0,10)yislope,interceptN(slopexi+intercept,1)

Both regression coefficients are given Gaussian priors, tighter on slope (standard deviation 2) and looser on intercept (standard deviation 10). Each observation y_i is then drawn from a Gaussian centered on the fitted value slope · x_i + intercept with fixed noise (standard deviation 1).

The data

We start by drawing synthetic data using a known slope and intercept of -2 and 10 respectively.

julia
true_slope, true_intercept = -2.0, 10.0

xs = collect(Float64, 1:10)
ys = (true_slope .* xs) .+ true_intercept .+ randn(length(xs))
(xs, ys)
([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], [6.814037795379936, 7.508932139060656, 3.368555391990151, 3.919213549822868, 0.2434232467443938, -1.977802832028656, -4.851569105743228, -6.08484913304459, -7.594454979521565, -9.40212740695616])

Describing the Model

We describe this model in Reactant.ProbProg as follows:

julia
using Reactant: ProbProg

function model(rng, xs)
    _, slope = ProbProg.sample(
        rng, ProbProg.Normal(0.0, 2.0, (1,)); symbol=:slope,
    )
    _, intercept = ProbProg.sample(
        rng, ProbProg.Normal(0.0, 10.0, (1,)); symbol=:intercept,
    )
    _, ys = ProbProg.sample(
        rng,
        ProbProg.Normal(slope .* xs .+ intercept, 1.0, (length(xs),));
        symbol=:ys,
    )
    return ys
end
model (generic function with 1 method)

Each random choice is introduced by a ProbProg.sample(rng, dist; symbol=...) call that takes a random number generator (RNG) and a distribution function. The symbol keyword names the sample site used for conditioning and specifying parameters to infer.

As a calling convention, ProbProg.sample returns (rng, value); the first element (omitted with _ above) is the updated RNG. In the current implementation rng is a traced ReactantRNG whose state corresponds to a tensor<2xui64> RNG state in the generated MLIR. We don't thread it through manually because Reactant tracing handles the input/output threading at the IR level, and ReactantRNG's internal state is updated via Julia mutability (see here for details).

Describing Inference

We condition on the observed ys with a Constraint object:

julia
obs = ProbProg.Constraint(:ys => ys)
Reactant.ProbProg.Constraint with 1 entry:
  Address([:ys]) => [6.81404, 7.50893, 3.36856, 3.91921, 0.243423, -1.9778, -4.…

The current implementation requires a bit of boilerplate to flatten the Constraint into a tensor representation and to extract its address set before passing them to the @compile'd function below (see Traces and constrained inference for details):

julia
obs_tensor = ProbProg.flatten_constraint(obs)
1×10 ConcretePJRTArray{Float64,2}:
 6.81404  7.50893  3.36856  3.91921  …  -6.08485  -7.59445  -9.40213
julia
constrained_addresses = ProbProg.extract_addresses(obs)
Set{Reactant.ProbProg.Address} with 1 element:
  Reactant.ProbProg.Address([:ys])

We then specify what parameters to infer:

julia
selection = ProbProg.select(
    ProbProg.Address(:slope),
    ProbProg.Address(:intercept),
)
OrderedCollections.OrderedSet{Reactant.ProbProg.Address} with 2 elements:
  Reactant.ProbProg.Address([:slope])
  Reactant.ProbProg.Address([:intercept])

We express inference in a single function that conditions the model on the constraint with generate and then runs NUTS over the selected sites with mcmc.

julia
function infer(rng, xs, obs_tensor, step_size, inverse_mass_matrix)
    trace, = ProbProg.generate(
        rng, obs_tensor, model, xs; constrained_addresses,
    )
    trace, = ProbProg.mcmc(
        rng, trace, model, xs;
        selection, algorithm=:NUTS,
        step_size, inverse_mass_matrix,
        num_warmup=200, num_samples=500,
    )
    return trace
end
infer (generic function with 1 method)

The returned trace contains the sampling result as a 2D tensor: each row is the concatenation of all selected sites' flattened values for one post-warmup sample. (We will show a possible trace for this example problem below.)

Compiling with @compile

We compile infer with Reactant's @compile for compiler-optimized probabilistic inference:

julia
rng                 = ReactantRNG()
step_size           = Reactant.ConcreteRNumber(0.1)
inverse_mass_matrix = Reactant.ConcreteRArray([1.0 0.0; 0.0 1.0])

compiled_fn = @compile optimize=:probprog infer(
    rng, xs, obs_tensor, step_size, inverse_mass_matrix,
)
Reactant compiled function infer (with tag ##infer_reactant#2098)

Defaults

It is often sufficient to start with step_size = 1.0 and an identity inverse_mass_matrix. With the default adapt_step_size = true and adapt_mass_matrix = true, mcmc adaptively selects appropriate values during the warmup iterations.

The compiled_fn is a callable object that takes the same arguments as infer and returns the inference result. We can execute the compiled inference program any number of times by calling it:

julia
trace_tensor = compiled_fn(rng, xs, obs_tensor, step_size, inverse_mass_matrix)
500×2 ConcretePJRTArray{Float64,2}:
 -1.71142   8.13226
 -1.6865    8.16092
 -1.93152   9.24576
 -2.07503  10.3266
 -1.99063   9.75731
 -2.08708  10.5829
 -2.09286  10.9429
 -1.90359  10.0499
 -2.00552  10.5023
 -1.82322   8.99843

 -1.92108   9.3397
 -1.98118   9.26116
 -1.95958   9.29071
 -1.94446  10.1314
 -1.83931   8.96979
 -1.87271   9.35092
 -1.80031   9.28999
 -1.90545   9.41423
 -2.02876  10.9416

In this array, each row is one post-warmup sample, and each column is a parameter. The columns follow the order that we passed to ProbProg.select above, i.e., :slope first, then :intercept.

text
           :slope   :intercept
sample 1:    ...       ...    
sample 2:    ...       ...    
   ⋮          ⋮         ⋮     
sample N:    ...       ...

The posterior means obtained are:

julia
mean(trace_tensor, dims=1)
1×2 ConcretePJRTArray{Float64,2}:
 -1.97211  10.0276

We can see that NUTS recovers the values that were used to generate the data.

Custom logpdf mode

In larger applications, it is often infeasible to express the model in a PPL modeling language as we showed in the trace-based mode above. We can use Reactant.ProbProg to compile and run its inference algorithms directly on a hand-written log-density function via the custom logpdf mode.

For example, we can write the log-density function of the previous Bayesian linear regression model directly:

julia
function logdensity(θ, xs, ys)
    X = hcat(xs, ones(length(xs)))
    residuals = ys .- X * θ
    pr = -0.5 * sum.^ 2 ./ [4.0, 100.0])
    ll = -0.5 * sum(residuals .^ 2)
    return ll + pr
end
logdensity (generic function with 1 method)

We pass logdensity to the mcmc_logpdf interface along with an initial position vector (the parameter values the chain starts from):

julia
function infer_logpdf(rng, θ0, xs, ys, step_size, inverse_mass_matrix)
    trace, = ProbProg.mcmc_logpdf(rng, logdensity, θ0, xs, ys;
        algorithm=:NUTS,
        step_size, inverse_mass_matrix,
        num_warmup=200, num_samples=500,
    )
    return trace
end

θ0 = Reactant.to_rarray(reshape([0.0, 0.0], 1, 2))
compiled_logpdf = @compile optimize=:probprog infer_logpdf(
    rng, θ0, xs, ys, step_size, inverse_mass_matrix,
)
trace = compiled_logpdf(rng, θ0, xs, ys, step_size, inverse_mass_matrix)
500×2 ConcretePJRTArray{Float64,2}:
 -1.94486  10.1233
 -1.94468   9.68519
 -1.94468   9.68519
 -2.10352  10.7399
 -1.89226   9.40681
 -1.85714   9.14825
 -1.88336   9.61659
 -1.89524   9.71523
 -2.02288  10.7162
 -2.0404   10.343

 -1.9918   10.1462
 -1.92726   9.95901
 -2.09706  10.6208
 -1.88544   8.99841
 -1.90568   9.63468
 -1.97357  10.3985
 -1.86631   9.7587
 -2.01917  10.4597
 -1.82616   9.06926

We get similar inference results

julia
(
    posterior_mean_slope     = mean(trace[:, 1]),
    posterior_mean_intercept = mean(trace[:, 2]),
)
(posterior_mean_slope = -1.972137742670302, posterior_mean_intercept = 10.02365385065806)

NUTS recovers both posterior means here too.

More Explanations