Fold the AffineTransform bias into the matmul instead of concatenating a ones column - #224
Merged
Conversation
…s column Weights stacked A|B express y = xA + B. The implementation glued a column of ones onto the message and did one matmul, which materializes a full copy of the data every cycle just to carry a constant. Broadcasting the bias instead is cheaper on every backend, and MLX offers addmm, which folds the add into the matmul epilogue. The A|B split is now cached as two views on the stored weight matrix, so it costs nothing to keep and is invalidated wherever the weights are replaced. Hoisting the axis permute above the branch lets both paths share it. Measured on an M4 Pro, jittered 30-64 sample chunks, min of 5 runs: 256ch 26.2-26.9 -> 23.1-24.1 us/message, 1024ch 36.2-36.9 -> 26.7-26.9 (~1.13x and ~1.37x). Plain non-stacked weights, as a control, are unchanged. In isolation the ordering is concat < matmul+add < addmm, so the generic matmul+add fallback is still an improvement on backends without addmm.
The original commit asserted that broadcasting the bias is "cheaper on every
backend". That was never measured -- only MLX was -- and it is false. On NumPy,
matmul(...) + bias measured 0.80-1.15x against the concat it replaced, from
30x64 to 3000x1024 in both float32 and float64: a wash. Dropping the concat
saves copying the message but adds a full read-modify-write pass over the
result, and the two very nearly cancel.
Adding in place is what earns it, by skipping the second allocation that
"+ bias" would immediately discard. Interleaved, min of 15 rounds:
n_ch n_t=30 n_t=300 n_t=600 n_t=2000 n_t=6000
64 0.96x 0.96x 0.98x 0.97x 1.34x
256 0.98x 0.93x 1.04x 1.59x 1.57x
1024 0.96x 1.28x 1.20x 1.13x 1.24x
So it is 2-10% slower while the message still fits in cache and 1.1-1.7x
faster once it does not. Taken unconditionally because the sides are
asymmetric in absolute terms -- +0.13 us at 30x256 against -233 us at
3000x256 -- not because it wins everywhere.
The penalty is not n_ch+1 making the matmul's inner dimension odd. That was
tested and rejected: at n_ch=255, so K=256 exactly, concat is still 1.53x
slower at 3000 samples. It is the copy's memory bandwidth, which is why the
crossover tracks working-set size rather than shape.
The in-place add mutates only the buffer matmul just allocated, which nothing
else references; the caller's message is untouched. Tests assert that for
float32, float64 and int32 messages, that the result aliases no input, and
that processing the same message twice is stable. MLX is unaffected -- it
still takes the addmm branch.
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.
Stacked A|B weights express
y = xA + B. The implementation glued a column of ones onto the message and did one matmul, which copies the whole message every cycle just to carry a constant:This replaces that with
addmmwhere the backend has it, and an in-place bias add where it doesn't.MLX —
addmmFolds the add into the matmul epilogue. Jittered 30–64 sample chunks, min of 5, A/B interleaved:
A|BA|BA(control)NumPy — the broadcast alone is a wash; the in-place add earns it
Dropping the concat saves copying the message but adds a full read-modify-write pass over the result. Those nearly cancel:
matmul(...) + biasmeasured 0.80–1.15x against concat from 30x64 to 3000x1024, float32 and float64.Adding in place skips the second allocation that
+ biaswould immediately discard. Interleaved, min of 15 rounds, ratio vs concat:2–10% slower while the message still fits in cache, 1.1–1.7x faster once it doesn't. Taken unconditionally because the sides are asymmetric in absolute terms — +0.13 µs at 30x256 against −233 µs at 3000x256 — not because it wins everywhere. A size gate would win at every shape, but costs a machine-dependent constant to maintain (as
util/blockdiag.pyalready does) for an effect capped at ~10% on the losing side.A hypothesis tested and rejected
The concat penalty is not
n_ch + 1making the matmul's inner dimension odd. Atn_ch=255— soK=256exactly, fully aligned — concat is still 1.53x slower at 3000 samples, and the effect is flat across 255/256/257 and 511/512/513. It's the copy's memory bandwidth, which is why the crossover tracks working-set size rather than shape. Recorded so nobody re-runs it.On "in-place"
It mutates only the buffer
matmuljust allocated, which nothing else holds a reference to. The caller's message is never touched — which matters here, since other subscribers to the same message would otherwise silently see transformed data. Tests assert it for float32, float64 and int32 messages, that the result aliases no input, and that processing the same message twice is stable.biasis a row of the same weight matrix asweights, so its dtype can never be wider than the matmul result's and the cast is always safe; thetry/exceptcovers a backend with immutable arrays (MLX rebinds rather than mutates there, and takesaddmmanyway).Structure
The
A|Bsplit is cached as two views on the stored weight matrix — free to keep, invalidated at all three sites where the weights are replaced. Hoisting the axis permute above the branch lets the stacked and plain paths share it.The grouped-rereference
data - (data @ project) @ spreadwas measured as anaddmm(alpha=-1)candidate too and is not included: 0.90x–1.31x across repeats, never reproducibly better.Tests
4068 pass, including two new regression tests for the non-mutation property above.