Skip to content

Affine/CAR: one channel-grouping spec, and kernels chosen from the weights - #212

Merged
cboulay merged 4 commits into
devfrom
feat/affine-block-kernels
Aug 9, 2026
Merged

Affine/CAR: one channel-grouping spec, and kernels chosen from the weights#212
cboulay merged 4 commits into
devfrom
feat/affine-block-kernels

Conversation

@cboulay

@cboulay cboulay commented Aug 9, 2026

Copy link
Copy Markdown
Member

Closes #198. Closes #210.

Both issues share a root: channel_clusters was 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_clusters both built the weight matrix (for RereferenceKind/callable weights) and hinted its block structure for the matmul. A hint finer than W'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 W itself (util/blockdiag.py), so no grouping a caller supplies can change the result. channel_groups keeps only the matrix-building job — which the issue correctly flagged as load-bearing on the kind/callable path.

The issue's channel_clusters=None workaround 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_components search (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:

  • contiguous blocks slice into views on both sides and fill a preallocated buffer with matmul(out=) — no gather, no scatter, and since the blocks tile the output exactly, empty instead of zeros
  • non-contiguous blocks take one whole-array permutation rather than a gather per cluster
  • plan_block_matmul chooses between those and a dense matmul, and picks the merge granularity, from a cost model over weights-touched, chunk length and per-call overhead

min_cluster_size is gone — the min_cluster_size > n_ch trick for forcing dense was a workaround for a decision the library is better placed to make. kernel="auto"|"dense"|"blocks" is the explicit override.

case (float32) before after
256ch, 4×64 blocks, n=30 38.5 µs 7.4 µs
256ch, 4×64 blocks, n=3000 3391 190
1024ch, 16×64 blocks, n=30 155 20.7
1024ch, 16×64 blocks, n=3000 17022 1016

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 to N/(N-1) · (x - mean). median keeps the gather/scatter loop; a median has no matmul form.

case before after
global CAR, 256ch, n=30 27.0 µs 6.1 µs
4 groups, 256ch, n=30 40.0 6.0
32 groups, 256ch, n=30 161 6.4

One grouping spec

Three spellings collapse into channel_groups, a ChannelGroupSpec resolved and validated in util/channels.py:

spec meaning
None no grouping
"bank" group by that metadata field
("array", "bank") group by the tuple of those fields
[[0, 1], [2, 3]] explicit index groups
fn(message, axis) anything else

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_clusterschannel_groups (now only builds kind/callable weights), min_cluster_size removed, kernel added
  • CommonRereferenceSettings: channel_clusters + cluster_by_fieldchannel_groups
  • set_weights(..., recalc_clusters=)recalc_structure=ezmsg-learn's LRRTransformer calls this
  • car_matrix / rereference_matrix: clusters=groups=
  • util.channels: channel_clusters_from_field / validate_channel_clusterschannel_groups_from_field / validate_channel_groups
  • _state.clusters_state.blocks; _find_block_diagonal_clusters, _max_cross_cluster_weight, _merge_small_clusters removed

Two behaviour changes worth a look on review:

  1. Channels in no group pass through unchanged instead of being zeroed, matching car_matrix, which leaves them identity.
  2. float32 in → float32 out. 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. matmul(out=) only ever targets a buffer just allocated. The median path — the only one that writes elementwise — seeded its output with an asarray guarded by output is data, an identity test that silently fails for an ndarray subclass, where asarray returns a distinct object sharing the buffer. It now branches on dtype, 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; 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 batched kernel (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.py re-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.

cboulay added 4 commits August 9, 2026 15:35
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.
@cboulay
cboulay merged commit 1dc6fc2 into dev Aug 9, 2026
14 checks passed
@cboulay
cboulay deleted the feat/affine-block-kernels branch August 9, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant