Skip to content

Take the hot loops off the shape that blocks vectorization - #510

Merged
godofecht merged 3 commits into
mainfrom
vectorize-hot-loops
Sep 28, 2026
Merged

godofecht merged 3 commits into
mainfrom
vectorize-hot-loops

Conversation

@godofecht

Copy link
Copy Markdown
Owner

Why

The wide matrix gate passed at 188 of 188, and affinity_propagation passed it
at 1.003x on one run and failed it at 0.947x on the next, on code that had not
changed. Our own time moved by two tenths of a percent across those runs and
scikit-learn's moved by fifteen. That row needed real headroom rather than luck.

What the search for it found

Flow emits a range loop as

for (int32_t k = 0; (0 <= n) ? k < n : k > n; k += (0 <= n) ? 1 : -1)

The stride is a runtime select, so LLVM will not vectorize the body.
affinity_propagation_fit disassembled to zero vector instructions. Writing its
two inner loops as while loops with a constant step took the fit from 5.23 ms to
2.97 ms and the function to 38 vector instructions.

Filed upstream as flooooooooooow/flow#1004. Until it is fixed, anything hot in
this library wants a while loop.

What is in here

The same treatment on the kernel methods, plus two things worth doing anyway:

  • The kernel matrix is symmetric under every kernel the module has. It is
    computed over one triangle and mirrored, where it used to evaluate all n^2
    entries and write each one through matrix_set.
  • The kernel between two rows takes pointers and loops over the features with a
    constant step, which reaches every caller including prediction.
  • SVR's inner update, f = f + delta * row, is a saxpy, so it calls one.

Measurements

Local, -O3, against the last CI reading of the same rows:

row CI local
svr 10.82 ms 1.22
nu_svr 6.61 1.52
affinity_propagation 6.47 2.97
svc 0.90 0.15
linear_svr 0.44 0.20

The runner is the authority, and the gate on this PR is the measurement.

Verification

test_opt_kernelsvc_fit, test_opt_svc_predict, test_expanded_estimators,
test_advanced_estimators, test_sparse_estimators, test_estimators,
examples/svm_demo and examples/full_demo all exit 0.

The gate passed at 188 of 188, and affinity propagation passed it at 1.003x on
one run and failed it at 0.947x on the next, on code that had not changed. Our
own time moved by two tenths of a percent across those runs and scikit-learn's
moved by fifteen, so the row was a coin toss rather than a win.

Looking for room in it found something that is not about that row at all.
Flow emits a range loop as

    for (int32_t k = 0; (0 <= n) ? k < n : k > n; k += (0 <= n) ? 1 : -1)

so the stride is a runtime select and LLVM declines to vectorize the body. The
disassembly of affinity_propagation_fit held no vector instruction at all.
Writing the two inner loops as while loops with a constant step took the fit
from 5.23 ms to 2.97 ms and the function from zero vector instructions to 38.
That is Flow issue #1004, and until it is fixed the workaround is a while loop
in anything hot.

The same treatment on the kernel methods, plus two things worth doing anyway:

The kernel matrix is symmetric under every kernel the module has, so it is
computed over one triangle and mirrored, where it used to evaluate all n^2
entries and write each one through matrix_set. The kernel between two rows now
takes pointers and loops over the features with a constant step, which reaches
every caller including prediction. SVR's inner update, f = f + delta * row, is
a saxpy, so it calls one.

Local timings at -O3, against the last CI reading of the same rows:

    svr                  10.82 ms  ->  1.22
    nu_svr                6.61     ->  1.52
    affinity_propagation  6.47     ->  2.97
    svc                   0.90     ->  0.15
    linear_svr            0.44     ->  0.20
The gate passed again at 188 of 188, and the row at the bottom of it moved from
affinity propagation to label spreading at 1.049x. The whole distribution moved
down that run, on rows nobody had touched, which is the runner rather than the
code. A row at 1.05x is the next coin toss, so these are the four narrowest.

label_spreading propagated its distributions with a scalar triple loop that
walked Y down a column of an array of row allocations, a cache miss per
element, and allocated a fresh set of rows on every iteration. The propagation
is a matrix product, so it is one sgemm against contiguous storage. 1.04 ms to
0.12.

knn_regressor read every element of both designs through matrix_at, and ran
over all k neighbours for every training point to find the slot to replace when
it only has to look when a point actually displaces one. 1.24 ms to 0.53.

isomap held its distances, geodesics and centering in arrays of row
allocations. Floyd-Warshall now walks contiguous rows with the term that
depends only on i and k lifted out, and the comparison written as a minimum so
the inner loop holds no branch. The power iteration is one sgemm per pass over
the whole component block. 6.22 ms to 4.04.

local_outlier_factor and the label spreading affinity get their distance loops
as while loops, for the reason the last commit gives: Flow emits a range loop
with a stride chosen at runtime, and LLVM will not vectorize that. Flow issue
#1004.
gamma_regressor was among the narrowest rows left at 1.43x. Its gradient loop
and its weight update are while loops with a constant step now, for the reason
the two commits before this one give. 0.68 ms to 0.49 on this machine, and
poisson and tweedie share the loop.
@godofecht
godofecht merged commit 3dab515 into main Sep 28, 2026
9 checks passed
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.

1 participant