Skip to content

vectorize_loops: gathers, assignment loops, and widened arguments - #1668

Closed
seantalts wants to merge 9 commits into
stan-dev:masterfrom
seantalts:feat/vectorize-loops-2
Closed

vectorize_loops: gathers, assignment loops, and widened arguments#1668
seantalts wants to merge 9 commits into
stan-dev:masterfrom
seantalts:feat/vectorize-loops-2

Conversation

@seantalts

@seantalts seantalts commented Aug 16, 2026

Copy link
Copy Markdown
Member

Rebased onto master after #1666 merged. Single commit.

Widening is now recursive. x[n] becomes the slice x[lower:upper], or x when the range provably spans the declaration. x[idx[n]] becomes the multi-index gather x[idx[lower:upper]]. A StanLib call widens its arguments and is re-typechecked. It has to come back as a container: the loop was using the scalar signature, so the container overload is the elementwise map, and functions like dot_product that typecheck at a scalar are rejected. Operators fall back to their elementwise variant, so x[n] * w[n] becomes x .* w. Suffixed calls are excluded since a vectorized _lpdf value is the summed lp.

This covers density arguments with arithmetic in them:

for (n in 1:N) y[n] ~ normal(a[county[n]] + mu[n] / 2, sigma);

and assignment loops:

for (i in 1:N) y_hat[i] = a[county[i]];
// becomes
y_hat[1:N] = a[county];

For assignments the written variable can't appear in the right-hand side or the loop bounds, and the widened right-hand side has to match the target's declared type. Gathers are typed as their base container directly instead of through infer_type_of_indexed, see #1667.

Measurements, same protocol as #1666: 11 of 120 posteriordb models change and the other 109 are byte-identical. election88_full goes 716us to 453us per gradient (1.58x). surgical_model, losscurve_sislob, and pilots are 1.05x to 1.08x. log10earn_height is 1.01x. radon_county is 0.92x, the sliced assignment costs one extra pass over the data compared to its scalar loop, and the fix for that belongs in codegen. Log probability and gradients agree with the pass off in every model. stanc runs a little faster on the changed models and clang compile times don't move.

Submission Checklist

  • Run unit tests
  • Documentation
    • OR, no user-facing changes were made (only --Oexperimental behavior changes)

Release notes

The vectorize_loops optimization (enabled at --Oexperimental) now handles indirectly indexed arguments such as a[county[n]], density arguments built from elementwise arithmetic, and elementwise assignment loops.

Copyright and Licensing

By submitting this pull request, the copyright holder is agreeing to
license the submitted work under the BSD 3-clause license (https://opensource.org/licenses/BSD-3-Clause)

I used AI.

…zed densities

Loops whose body is a single scalar density statement are rewritten to the
vectorized density call, e.g.

    for (n in 1:N) target += normal_lpdf(y[n] | mu[n], sigma);

becomes

    target += normal_lpdf(y | mu, sigma);

which shares subcomputations across elements and allocates O(1) autodiff
nodes instead of O(N). Tilde statements are the same MIR shape, and their
proportionality flag is preserved.

Arguments must be scalar invariants or exactly x[n]; x[n] becomes the
slice x[lower:upper], or x alone when the loop range provably spans the
declared size (data and parameters only, since nothing else is immutable).
At least one argument must vary with the loop, invariant containers bail
(the loop broadcasts them, the vectorized call would zip them), and the
rewritten call must typecheck against the Stan Math signatures or the
loop is left untouched. User-defined densities and truncated tildes are
never rewritten.

Enabled at --Oexperimental only for now. Refs stan-dev#356, stan-dev#702.
Declared outer sizes are runtime invariants: whole-variable assignments
are size-checked by stan::model::assign (verified empirically for
vectors and arrays), and nothing else can resize. So transformed data
and all output variables join data and parameters in the trusted-size
map, and a transformed data vector spanning the loop range now appears
bare.

Invariant arguments must also pass cannot_remove_expr: vectorizing
drops their evaluation count from N to one, which a side-effecting
argument (an _lp call incrementing the target) would observe.

Also folds the slice construction into the argument classifier as one
pattern match, reuses Expr.Helpers.add_int_index instead of building
the indexed expression by hand, and drops the RELEASE-NOTES.txt entry
since the changelog is updated at release time.
A matrix-row lane typechecks as a scalar statement but the sliced
argument is a matrix and normal_lpdf has no matrix signature, so this
exercises the final re-typecheck rejection. The function-body test runs
the pass over a loop inside a user-defined function, and the test
printer now shows function bodies so the tests can assert they are
unchanged.
@seantalts
seantalts force-pushed the feat/vectorize-loops-2 branch from ca06812 to 3c04aaf Compare August 16, 2026 16:47
@seantalts
seantalts force-pushed the feat/vectorize-loops-2 branch from 3c04aaf to 08d9842 Compare August 16, 2026 16:47
@seantalts
seantalts force-pushed the feat/vectorize-loops-2 branch from 08d9842 to 103aa75 Compare August 16, 2026 16:53
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.14286% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.35%. Comparing base (b96c001) to head (fdd8e9b).
⚠️ Report is 37 commits behind head on master.

Files with missing lines Patch % Lines
src/analysis_and_optimization/Optimize.ml 92.14% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1668      +/-   ##
==========================================
+ Coverage   92.31%   92.35%   +0.03%     
==========================================
  Files          67       67              
  Lines        9972    10096     +124     
==========================================
+ Hits         9206     9324     +118     
- Misses        766      772       +6     
Files with missing lines Coverage Δ
src/analysis_and_optimization/Optimize.ml 93.79% <92.14%> (+0.12%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A generated quantity is not in scope in the model block, so a model
local may legally reuse its name at another size. Trusting the generated
quantity's declared size could then turn a partial-range loop over the
local into a whole-variable rewrite. Only data, parameters, and
transformed parameters keep trusted sizes. Their names cannot be reused.
Regression test from review.
Widening a scalar expression in the loop variable to its whole-range
container form is now recursive. Leaves are x[n], becoming the slice
x[lower:upper] (or x alone when the range provably spans the
declaration), and x[idx[n]], the hierarchical idiom, becoming the
multi-index gather x[idx[lower:upper]]. Interior nodes are plain StanLib
applications of widened children: the call must re-typecheck at a
container return type, since the scalar signature being the one the loop
used makes the container overload its elementwise map by Stan Math
convention, while functions that consume the lane dimension
(dot_product and friends) typecheck at a scalar and are rejected.
Operators retry as their elementwise variant when the plain form does
not fit, and suffixed calls never widen: a vectorized _lpdf value is the
summed lp, not the lanes'.

This unlocks two statement forms at once. Density arguments built from
arithmetic widen, and elementwise assignment loops become sliced
assignments:

    for (n in 1:N) mu[n] = alpha + beta * x[n];
becomes
    mu[1:N] = alpha + beta * x;

with the written variable barred from the right-hand side and the loop
bounds (which excludes recurrences), and the widened right-hand side
required to match the target's declared type, tracked in an environment
of every declaration (an array target with a vector-widened right-hand
side must not typecheck). Gather nodes are typed as their base
container: Expr.Helpers.infer_type_of_indexed calls a multi-indexed
vector real, and a lying scalar type makes Memory_patterns leave the
gathered base out of the SoA demotion set, generating C++ that does not
compile.

Across posteriordb this claims 11 of 120 models with the other 109
byte-identical; measured per-gradient changes range from 1.58x
(election88_full) to 0.92x (radon_county, where the sliced assignment
costs one extra indexed pass over its scalar loop), with log probability
and gradients agreeing in all of them. Refs stan-dev#356, stan-dev#702.
@seantalts
seantalts force-pushed the feat/vectorize-loops-2 branch from 103aa75 to fdd8e9b Compare August 16, 2026 19:23
@seantalts

Copy link
Copy Markdown
Member Author

I'm just going to close this, the earlier PR was reverted.

@seantalts seantalts closed this Aug 24, 2026
@nhuurre

nhuurre commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

It was not reverted, it just required a small fix: #1672

@WardBrian

Copy link
Copy Markdown
Member

Apologies for the confusion @seantalts -- it wasn't reverted (and if it had been, it was only going to be for the weekend until I could fix the build issue). This just needs a rebase on master I believe

@seantalts

Copy link
Copy Markdown
Member Author

Ah I see, thanks for fixing! I'm not very familiar with github anymore and used the new automerge button. I find I kinda don't care enough to make the comments pithy and poignant, but I will instruct Fable in that way and see how it goes :P

@seantalts seantalts changed the title [DRAFT] vectorize_loops: gathers, assignment loops, and widened arguments vectorize_loops: gathers, assignment loops, and widened arguments Aug 24, 2026
@seantalts

Copy link
Copy Markdown
Member Author

Continued in #1678 after a rebase onto master. GitHub can't reopen a PR whose branch was force-pushed after closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants