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:

slope∼N(0,2)intercept∼N(0,10)yi∣slope,intercept∼N(slope⋅xi+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], [7.783899916042685, 6.021840318377765, 3.2856170600380716, 1.4029298756603041, 0.03799654724490367, -2.036973418013704, -4.128611482283881, -6.601447424183033, -5.744778151641653, -10.34917231568776])

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]) => [7.7839, 6.02184, 3.28562, 1.40293, 0.0379965, -2.03697, -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}:
 7.7839  6.02184  3.28562  1.40293  …  -6.60145  -5.74478  -10.3492
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#1121)

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.81797   8.83893
 -1.78126   8.71695
 -1.78126   8.71695
 -1.81528   9.05059
 -1.99413   9.79217
 -2.00464  10.3284
 -2.00619  10.1352
 -1.7313    8.28891
 -1.89034   9.23198
 -1.90138   9.54668
  ⋮        
 -1.82242   8.73685
 -1.94543   9.89339
 -1.86571   9.87132
 -2.05239  10.1412
 -1.80322   8.61314
 -1.72944   8.60467
 -1.65072   7.88232
 -1.79964   9.14115
 -1.7821    8.72012

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.88966  9.37643

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.78709   8.67862
 -1.74197   8.40646
 -1.92293   9.78483
 -1.90771   9.35382
 -1.88764   9.4814
 -1.86548   8.92693
 -1.89409   9.42704
 -1.9156    9.78453
 -1.93606   9.72984
 -1.92112   9.68283
  ⋮        
 -1.78485   8.96269
 -1.84518   9.29387
 -1.92062   9.64219
 -1.89127   9.36739
 -1.87071   9.34574
 -1.73049   8.32704
 -2.02545  10.0406
 -1.80346   8.32794
 -1.6591    7.59495

We get similar inference results

julia
(
    posterior_mean_slope     = mean(trace[:, 1]),
    posterior_mean_intercept = mean(trace[:, 2]),
)
(posterior_mean_slope = -1.8879061402657857, posterior_mean_intercept = 9.352723588684588)

NUTS recovers both posterior means here too.

More Explanations ​