Affine/CAR: one channel-grouping spec, and kernels chosen from the weights - #212
Merged
Conversation
A block-diagonal weight matrix lets `y = x @ W` split into independent per-block matmuls that touch far fewer weights. Fewer FLOPs, but not automatically faster: each block costs a kernel launch, and below a few hundred channels a dense matmul against an L2-resident matrix wins outright (#210). `util.blockdiag` separates the two questions. `contiguous_block_partition` finds the finest tiling of contiguous diagonal blocks with numpy alone -- contiguous blocks are the case worth optimizing, since they slice into views with no gather and no scatter, and they tile the output exactly so the buffer never needs zeroing. `plan_block_matmul` then decides whether to use that structure, and at what merge granularity, from a cost model over weights-touched, chunk length and per-call overhead. Only the permuted fallback -- blocks that are contiguous solely after reordering channels -- needs scipy, and only for matrices above 1M weights, so the common path never pays that import. The model's constants are ratios fit on an Apple M-series CPU; they are documented alongside the known regime where they misjudge, and benchmarks/ (next commit) can re-derive them elsewhere.
Grouping had grown ad hoc: AffineTransform took `channel_clusters`,
CommonRereference took `channel_clusters` *and* `cluster_by_field`, and
`car_matrix` took `clusters` -- three spellings, two of which predate
channel metadata being a struct array. Both transformers now take one
`channel_groups`, a `ChannelGroupSpec`: explicit index groups, a metadata
field name ("bank"), several field names (("array", "bank")), or a
callable. `util.channels` resolves and validates it in one place.
That reframing is also what closes #198. `channel_clusters` had two jobs:
build the matrix for kind/callable weights, and hint block structure for
the matmul. The second job could silently corrupt output -- a hint finer
than W's true blocks made two input groups derive the same output
indices, and the scatter overwrote rather than accumulated. Nothing
checked for it and nothing warned. `channel_groups` keeps only the first
job. Block structure is now always read off W itself, so no grouping a
caller supplies can change the answer.
Removing the hint also removes the reason `min_cluster_size` existed;
kernel choice is automatic now, with `kernel="auto"|"dense"|"blocks"` as
an override. Weights that stack [A|B] keep the dense path, since the
block kernel has no ones-column augmentation.
Fixes #198
--- AffineTransform kernels (#210)
The block path always gathered each cluster with fancy indexing and
scattered the result back, which cost more than the FLOPs it saved below
~2048 channels. Contiguous blocks now slice into views on both sides and
fill a preallocated buffer via matmul(out=), and `plan_block_matmul`
picks between that and a dense matmul from the message size. Blocks that
are contiguous only after reordering take one whole-array permutation
rather than a gather per cluster. Backends without matmul(out=) (MLX)
concatenate instead; probed once at reset.
256ch, 4x64 blocks, n=30 38.5 us -> 7.4 us
256ch, 4x64 blocks, n=3000 3391 -> 190
1024ch, 16x64 blocks, n=30 155 -> 20.7
Fixes #210
--- CommonRereference
mean-mode is now `x - (x @ project) @ spread`: two skinny matmuls that
give every channel its group's mean without a gather, so cost no longer
depends on whether a group's channels are contiguous, and there is no
Python loop per message. Leave-one-out collapses to N/(N-1) * (x - mean).
median keeps the gather/scatter loop -- a median has no matmul form.
global CAR, 256ch, n=30 27.0 us -> 6.1 us
4 groups, 256ch, n=30 40.0 -> 6.0
32 groups, 256ch, n=30 161 -> 6.4
Two behaviour changes fall out. Channels in no group now pass through
unchanged instead of being zeroed, matching car_matrix, which leaves them
identity. And float32 input stays float32: the old unconditional promotion
to float64 doubled the bandwidth of every downstream stage.
--- Not mutating inputs
Message data may be a view shared with other branches of the graph, so
none of these kernels may write into it. matmul(out=) only ever targets a
buffer we just allocated. The median path, which does write elementwise,
seeded its output with an `asarray` guarded by an `output is data` check
-- an identity test that silently fails for an ndarray subclass, where
asarray returns a distinct object sharing the same buffer. It now branches
on dtype instead, where each branch is guaranteed to allocate.
`test_does_not_mutate_input` marks inputs read-only across all 11 kernel
paths, for both ndarray and a subclass, so a write becomes an error rather
than corruption two nodes downstream.
Cached sub-blocks are copied rather than kept as views into the caller's
weight matrix: a view pinned the whole dense matrix alive to hold the much
smaller block diagonal, and let a caller recycling its weight buffer mutate
our state between messages.
plan_block_matmul's constants are ratios fit to one machine, so they need to be re-derivable on another. This times the candidate formulations directly -- dense, contiguous-slice blocks, permuted blocks, and the per-cluster gather they replaced -- and reports which kernel `auto` actually picks at each size, so a stale constant shows up as a row where the planner chose the slower one.
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 #198. Closes #210.
Both issues share a root:
channel_clusterswas doing two unrelated jobs, and the one it should never have had could change the answer.#198 — a cluster hint could silently corrupt output
channel_clustersboth built the weight matrix (forRereferenceKind/callable weights) and hinted its block structure for the matmul. A hint finer thanW's true blocks made two input groups derive the same output indices, and the scatter assigned rather than accumulated, so the last group won. Wrong output of plausible shape, dtype and magnitude, with no warning.Rather than validate the hint, this removes its ability to matter. Block structure is now always read off
Witself (util/blockdiag.py), so no grouping a caller supplies can change the result.channel_groupskeeps only the matrix-building job — which the issue correctly flagged as load-bearing on the kind/callable path.The issue's
channel_clusters=Noneworkaround in ezmsg-learn#20 can go away.Cost of always deriving structure: none in the common case. Contiguity detection is pure numpy in one pass over the nonzero mask; the
connected_componentssearch (and its scipy import) runs only for matrices above 1M weights whose blocks aren't already contiguous.#210 — the block path was slower than dense below ~2048 channels
The block path gathered each cluster with fancy indexing and scattered the result back, per cluster, per message. Now:
matmul(out=)— no gather, no scatter, and since the blocks tile the output exactly,emptyinstead ofzerosplan_block_matmulchooses between those and a dense matmul, and picks the merge granularity, from a cost model over weights-touched, chunk length and per-call overheadmin_cluster_sizeis gone — themin_cluster_size > n_chtrick for forcing dense was a workaround for a decision the library is better placed to make.kernel="auto"|"dense"|"blocks"is the explicit override.CommonRereference
mean-mode became
x - (x @ project) @ spread— two skinny matmuls giving every channel its group's mean without a gather. Cost no longer depends on group contiguity or group count, and there's no Python loop per message. Leave-one-out collapses toN/(N-1) · (x - mean).mediankeeps the gather/scatter loop; a median has no matmul form.One grouping spec
Three spellings collapse into
channel_groups, aChannelGroupSpecresolved and validated inutil/channels.py:None"bank"("array", "bank")[[0, 1], [2, 3]]fn(message, axis)Groups are validated as in-range and pairwise disjoint — an overlap meant a channel referenced twice, or two weight blocks written to the same output.
Breaking changes
AffineTransformSettings:channel_clusters→channel_groups(now only builds kind/callable weights),min_cluster_sizeremoved,kerneladdedCommonRereferenceSettings:channel_clusters+cluster_by_field→channel_groupsset_weights(..., recalc_clusters=)→recalc_structure=— ezmsg-learn'sLRRTransformercalls thiscar_matrix/rereference_matrix:clusters=→groups=util.channels:channel_clusters_from_field/validate_channel_clusters→channel_groups_from_field/validate_channel_groups_state.clusters→_state.blocks;_find_block_diagonal_clusters,_max_cross_cluster_weight,_merge_small_clustersremovedTwo behaviour changes worth a look on review:
car_matrix, which leaves them identity.Not mutating inputs
Message data may be a view shared with other branches of the graph.
matmul(out=)only ever targets a buffer just allocated. The median path — the only one that writes elementwise — seeded its output with anasarrayguarded byoutput is data, an identity test that silently fails for an ndarray subclass, whereasarrayreturns a distinct object sharing the buffer. It now branches on dtype, where each branch is guaranteed to allocate.test_does_not_mutate_inputmarks inputs read-only across all 11 kernel paths, for bothndarrayand a subclass; reverting the fix produces exactly the 4 expected failures.Known limitation
The cost model counts weights touched, not access patterns, so it doesn't see that a many-block loop re-walks a strided view of a chunk too large to cache. Above ~2000 channels with several-thousand-sample chunks it picks a finer blocking than optimal — up to ~40% off the best merge, still ~3× faster than dense. Documented in
blockdiag.py;kernel="dense"is the escape hatch.Also not done: a
batchedkernel (reshape + one broadcast matmul) beat the slice loop ~2× at ≥512 channels with short chunks, but it needs equal-size blocks and is a wash at 256 channels. Left as a possible follow-up.Testing
4026 passed, 6 skipped. New:
tests/unit/test_blockdiag.py, the #198 regression test with genuinely non-contiguous blocks, and the input-mutation suite.benchmarks/benchmark_affine_kernels.pyre-derives the cost-model constants and reports what the planner picks, so a stale constant shows up as a row where it chose the slower kernel.