Speed up Autoregressive Active Inference example ~40x (49 min -> ~72 s) - #94
Open
bvdmitri wants to merge 2 commits into
Open
Speed up Autoregressive Active Inference example ~40x (49 min -> ~72 s)#94bvdmitri wants to merge 2 commits into
bvdmitri wants to merge 2 commits into
Conversation
The notebook took 2956s (49 min) to build — 6x the next slowest example and the
sole reason the CI examples job timeout had to be raised to 90 minutes. It now
runs end to end, plots and GIF included, in ~72s.
Behaviour is preserved: all 10 validation seeds still reach 3/3 waypoints.
Where the time actually went
----------------------------
Measured, not guessed. Instrumenting `mode(::unBoltzmann)` over a truncated run
showed 27,810 calls totalling 436s — essentially the whole runtime. Splitting a
call open on an already-compiled run:
optimize() total : 128.56s
of which f+g evals : 0.73s (1,558,575 f, 1,558,575 g calls)
Optim overhead : 127.83s (99% of optimize)
So >99% of the cost was Optim's `Fminbox(LBFGS())` scaffolding, not the energy.
The configuration `outer_iterations=100, iterations=1` rebuilds a barrier problem
and a fresh solver state on every outer iteration while making almost no progress
per iteration. The 0.2s `time_limit` was reached only 3 times in 27,810 calls, so
it was never the binding constraint either.
Changes
-------
* Replace the `mode` optimiser with projected-gradient descent plus a backtracking
line search, still seeded from the best box corner so the search stays global.
Measured in situ against the old optimiser on identical inputs: 463x faster
(14.1ms -> 0.030ms per call), and it never returns a worse minimum — it finds a
strictly better one in 5739 of 9270 calls (mean dG = -0.80). The old config was
under-converging, so this is a convergence fix as well as a speed one.
* Give the expected free energy a closed form. Sigma(u) = s(u)/eta * V^-1 is a
scalar multiple of a fixed matrix, so logdet and trace factor into constants,
and the regressor x(u) = Pu + q is affine, making s(u) quadratic and mu(u)
affine in the 2-D action. Precomputing the coefficients once per message makes
each evaluation ~20 flops with an analytic gradient, removing ForwardDiff from
the hot path. Verified against the original expression to 1.1e-15 relative
error, and the gradient against ForwardDiff to 3.6e-15.
* Stop inverting the same matrix twice. The rules did `Lambda = inv(Lambda)` and
then `inv(Lambda)` again inside the energy, i.e. recovering the original matrix
on every evaluation. `posterior_predictive` already showed the intended form.
* Hoist the `mode(...)` calls out of the energy closures — they do not depend on
the action, and several arguments are themselves `unBoltzmann`, so each one was
triggering a nested optimisation per evaluation.
* Carry the gradient through `prod`, so products of energies keep an analytic
gradient instead of falling back to automatic differentiation.
* Parameterise `unBoltzmann` (`G::F`, `N::I`, `D::R` instead of `::Function`,
`::Integer`, `::Rectangle`). Verified this does not cause type explosion: a
census over a full run finds exactly one concrete energy type.
Readability
-----------
The 42 `@rule MARX` definitions had only ~5 distinct bodies: 10 `:in` rules were
byte-identical apart from argument names, likewise 8 `:outprev1` and 5 `:out`,
and 18 rules were just `return Uninformative()`. Each group now delegates to one
documented, optimised helper, with the rules reduced to thin wrappers. All 42
signatures are kept, so dispatch coverage is unchanged.
Dimensions and action limits reach the rules as `MARXMeta` rule metadata via
`@meta` rather than as untyped globals read from a later cell, and the simulation
loop is wrapped in `run_simulation` instead of running at top level under `global`.
Note that attaching meta is all-or-nothing: a rule that declares no `meta::`
argument generates a method with `meta::Nothing` and stops dispatching once meta
is attached (ReactiveMP src/rule.jl:478), which is why all 42 wrappers take it.
The GIF is regenerated, since better-converged planning reaches the waypoints
sooner (steps 103/129/144 for seed 3).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #92. Findings I deliberately did not act on are in #93 for @wmkouw.
Advanced Examples/Autoregressive Active Inferencetook 2956 s (49 min) to build — 6x the next slowest example (Contextual Bandits, 498 s) and the sole reason the examples job timeout was raised to 90 minutes. It now runs end to end, plots and GIF included, in ~72 s.Behaviour is preserved: all 10 validation seeds still reach 3/3 waypoints.
Where the time actually went
Measured rather than guessed — and it was not where I first assumed. Instrumenting
mode(::unBoltzmann)showed 27,810 calls totalling 436 s, essentially the entire runtime. Splitting one call open on an already-compiled run:Over 99% of the cost was Optim's
Fminbox(LBFGS())scaffolding, not the objective. The configurationouter_iterations=100, iterations=1rebuilds a barrier problem and a fresh solver state on every outer iteration while making almost no progress per iteration. The 0.2 stime_limitwas reached only 3 times in 27,810 calls, so it was never binding either — lowering it would have done nothing.Two hypotheses I checked and discarded along the way, for the record: it is not a type-explosion problem (a census over a full run finds exactly one concrete energy type) and not primarily compilation (running the loop twice, the second pass is only 1.20x faster).
Changes
Replace the
modeoptimiser with projected-gradient descent plus a backtracking line search, still seeded from the best box corner so the search stays global. Measured in situ against the old optimiser on identical inputs:It never returns a worse minimum and finds a strictly better one in 62% of calls, so this is a convergence fix as well as a speed one — see #93 item 5.
Give the expected free energy a closed form.
Σ(u) = s(u)/η · V⁻¹is a scalar multiple of a fixed matrix, sologdetandtracefactor into constants; and the regressorx(u) = Pu + qis affine, makings(u)quadratic andμ(u)affine in the 2-D action. Precomputing the coefficients once per message reduces each evaluation to ~20 flops with an analytic gradient, removing ForwardDiff from the hot path.Verified against the original expression across 300 random problems at the real dimensions:
Stop inverting the same matrix twice. The rules did
Λ = inv(Λ)and theninv(Λ)again inside the energy — recovering the original matrix on every evaluation.posterior_predictivealready documented the intended form (x'*U*x, withUused directly).Hoist
mode(...)out of the energy closures. They do not depend on the action, and several arguments are themselvesunBoltzmann, so each was triggering a nested optimisation per evaluation.Carry the gradient through
prod, so products of energies keep an analytic gradient rather than falling back to AD.Parameterise
unBoltzmann—G::F,N::I,D::Rinstead of::Function,::Integer,::Rectangle(the last was also abstract:Rectangle{T} <: HyperRectangle{T}).Readability
The 42
@rule MARXdefinitions had only ~5 distinct bodies — 10:inrules byte-identical apart from argument names, likewise 8:outprev1and 5:out, plus 18 that were justreturn Uninformative(). Each group now delegates to one documented helper, with the rules reduced to thin wrappers. All 42 signatures are kept, so dispatch coverage is unchanged.Dimensions and action limits now reach the rules as
MARXMetarule metadata via@metainstead of untyped globals read from a later cell, and the simulation loop is wrapped inrun_simulationrather than running at top level underglobal.Note that attaching meta is all-or-nothing: a rule declaring no
meta::argument generates a method withmeta::Nothingand stops dispatching once meta is attached (ReactiveMP/src/rule.jl:478) — hence all 42 wrappers take it.Validation
Matching the bar set in #80 ("validated on 10 random seeds: all reach all 4 waypoints within 400 steps").
The GIF is regenerated: better-converged planning reaches the waypoints sooner, so the trajectory legitimately differs from the published one. @wmkouw — flagged for your sanity check as #93 item 5.
Not changed
Two sign conventions in the rules look inconsistent but turn out to be a compensating pair (flipping both is bit-identical; flipping either alone gives 0/3 waypoints), and the
mutualinfohelper disagrees with the rules in a way that affects only the EFE landscape figure. Both are written up in #93 rather than changed here, so this PR stays purely about performance.🤖 Generated with Claude Code