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:
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.
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:
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
endmodel (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:
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):
obs_tensor = ProbProg.flatten_constraint(obs)1×10 ConcretePJRTArray{Float64,2}:
6.81404 7.50893 3.36856 3.91921 … -6.08485 -7.59445 -9.40213constrained_addresses = ProbProg.extract_addresses(obs)Set{Reactant.ProbProg.Address} with 1 element:
Reactant.ProbProg.Address([:ys])We then specify what parameters to infer:
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.
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
endinfer (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:
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:
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.9416In 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.
:slope :intercept
sample 1: ... ...
sample 2: ... ...
⋮ ⋮ ⋮
sample N: ... ...The posterior means obtained are:
mean(trace_tensor, dims=1)1×2 ConcretePJRTArray{Float64,2}:
-1.97211 10.0276We 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:
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
endlogdensity (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):
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.06926We get similar inference results
(
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
Sampling and distributions — semantics of
sample, the built-inDistributionhierarchy, custom samplers with user-suppliedlogpdf, and constrained supports.Traces and constrained inference —
simulateversusgenerate,Constraint/Addressconstruction, and the trace round-trip between flat position vector and tree-shapedTrace.MCMC: MH, HMC, NUTS — Metropolis-Hastings over a
Selection, gradient-based chains viamcmc, and log-density-driven chains viamcmc_logpdf.Running and resuming chains —
run_chain, warmup and checkpointing throughMCMCState,save_state/load_state, and posterior summaries withmcmc_summary.