JITC mv/mm unification spec#
Status: implemented
Branch: remove-jit-mv/mm-mode
Version: stays 0.2.0 — this lands inside the unreleased 0.2.0 work, so the
mv/mm split is edited out of the 0.2.0 changelog rather than deprecated in a
follow-up release.
Goal#
Make every JITC kernel — scalar, normal, uniform — draw one connectivity
matrix, the mv (32-lane) one. Once mm no longer exists as a distinct matrix,
delete matrix_mode, the mat.mv / mat.mm views and the todense() /
tocsr() ambiguity errors completely — no deprecation shim, no
compatibility keyword, no changelog trace. After this work, reading the tree
must give no indication that a mode selector ever existed.
Final state, stated as an invariant:
For any
(weight, prob, seed, shape, corder), the matrix generated byjits,jitsmv,jitsmm,binary_jitsmv,binary_jitsmm,jits_to_csrandjitsmv_dt2tis bit-identical, on every backend (numba,cuda_raw), and equals today’smatrix_mode='mv'matrix.
Anything that today distinguishes mv from mm is either deleted or converted to 32-lane. Kernel organization (AW-T4 grouping, bit-packing, warp reduction) stays wherever it wins on throughput — the constraint is on the drawn matrix, not on the launch shape.
Interface — align to v0.1.2. Signatures, keywords and the set of public names return to the v0.1.2 tag. The materialization path returns there too: see “The chunk width: keyed to the walked dimension” below, which removes the reason 0.2.0 had for deviating.
Background: what mv/mm actually is#
The light-RNG walk assigns each thread a residue class:
local_j = lane + STRIDE * q
mv: one warp = one
(row, chunk_id)task,lane ∈ [0,32),STRIDE = 32.mm: one warp = 8 groups of 4 threads, one group = one
(row, chunk_id, col)task,sub_lane ∈ [0,4),STRIDE = 4(AW-T4).
The RNG is keyed by (seed, row, chunk_id, lane). With STRIDE = 4 and
lane ∈ [0,4), both the stream keys and the residue classes differ, so mm
samples a different matrix. That is the entire difference — everything else
(stationary_initial_q, fast_bounded_u32, light_rng_init_wpr, chunking on
shape[1] with target_chunks=4) is already shared.
Consequently the fix is: key the RNG by a 32-valued lane and step by 32, while keeping whatever thread grouping the kernel wants for parallelism.
Design note: the 32-lane walk#
Decision: shape (A), warp-per-(row, chunk, col), unconditionally. Matching
the mv matrix is the only constraint that matters; small-n throughput is not
traded against it. No performance variant is kept in reserve, and no benchmark
gate stands between this work and landing.
(A) Warp-per-(row, chunk, col) — mirrors binary_jitsmv.cu and the existing
jitsmm_mv_* kernels in float_jitsmm.cu.
lane = threadIdx.x & 31 // 0..31, one residue class each
row = blockIdx.x * warps_per_block + warp_id
chunk = blockIdx.y
col = blockIdx.z
rng = light_rng_init_wpr(seed, row, chunk, lane)
local_j = lane + 32 * q
… warp_reduce_sum → lane 0 atomics into output[row * n + col]
Correctness is immediate: identical keying, identical stride, identical
iteration order to binary_jitsmv.cu. float_jitsmm.cu already ships exactly
this kernel (jitsmm_mv_notrans/trans_*), which is the existence proof that a
32-lane matmat works.
AW-T4 exists because for small n it gives 8 independent column tasks per warp
instead of 1, so (A) may be slower there. That is accepted: the AW-T4 grouping
is deleted outright rather than preserved behind a replay scheme, and jitsmm /
binary_jitsmm become exactly “n independent mvs on one matrix” in the launch
shape as well as in the drawn matrix.
The chunk width: keyed to the walked dimension#
Decision: derive chunk_size from the dimension the walk actually runs over,
not from the caller’s shape[1]. _materialize_params is then unnecessary and
is deleted.
An earlier draft of this spec kept 0.2.0’s _materialize_params helper and
claimed v0.1.2 was buggy here. Measured against the released 0.1.2 that claim is
false, and the fix belongs one level down. Both points are recorded here
because the wrong version was acted on first.
What _materialize_params was#
It returned the (shape, transpose) pair jits had to be called with so that
the dense form matched what mat @ v computes:
class JITCScalarR: def _materialize_params(self): return self.shape, False
class JITCScalarC: def _materialize_params(self): return self.shape[::-1], True
The column-oriented classes need the swapped pair because that is what their
__matmul__ passes — in v0.1.2 exactly as in 0.2.0:
# JITCScalarC.__matmul__, unchanged since v0.1.2
jitsmv(..., shape=self.shape[::-1], transpose=True, corder=self.corder)
v0.1.2 was not broken; 0.2.0 broke it#
The two calls produce the same generation dimensions:
out_shape = shape if not transpose else shape[::-1]
n_rows, n_cols = out_shape if corder else out_shape[::-1]
For shape=(M, N), transpose=False and shape=(N, M), transpose=True the
output shape is (M, N) either way, so (n_rows, n_cols) is identical. The
only thing that differed was the chunk width, which 0.2.0 derived from the
caller’s shape[1] — N in one call, M in the other — and chunk_id keys
the RNG stream.
v0.1.2’s kernel had no chunking at all (cuRAND Philox, curand_init(seed, row, 0),
one thread per row), so its matrix did not depend on the generation shape.
Measured on the released 0.1.2:
0.1.2: jits((M,N),transpose=False) vs jits((N,M),transpose=True) identical = True
mat @ v == mat.todense() @ v for JITCScalarC/R, JITCUniformC/R, both corder
all True
0.2.0: same comparison identical = False
nnz 66 vs 61 (corder=True), 71 vs 73 (corder=False)
So _materialize_params was not a fix for a v0.1.2 defect — it was a
compensation for a shape dependence 0.2.0 had just introduced.
The fix#
chunk_size is derived from the walked dimension:
def _walk_length(shape, transpose, corder):
n_rows, n_cols = (shape[1], shape[0]) if transpose else (shape[0], shape[1])
return n_cols if corder else n_rows
def _chunk_size(walk_length):
return max(1, cdiv(walk_length, _TARGET_CHUNKS)) # _TARGET_CHUNKS = 4
_walk_length is the same for both (shape, transpose) pairs, so the drawn
matrix depends only on the generated matrix’s own geometry. Consequences:
_materialize_paramscollapses to(self.shape, False)for the row- and column-oriented classes and is deleted;todense()/tocsr()return to v0.1.2’s bodies verbatim.n_chunksstays exactly 4, so the CSR count pass’schunk_counts[n_rows, n_chunks]buffer, thegrid.y <= 65535headroom and the per-lane walk length are all unchanged. A fixed chunk constant would have achieved the same shape-independence but at a cost: at 1M x 1M the CSR buffer grows from 15 MB to 3.6 GB (C=1024) or 116 GB (C=32), and small constants leave each lane emitting at most one element per stream.The chunk width is no longer a caller-visible knob:
chunk_sizeandtarget_chunksare removed fromjits_to_csr,jits_csr_count_p_call,jits_csr_fill_p_callandjitsmv_dt2t_p_call(and thejitn*/jitu*siblings).jits_to_csr’s parameter list is then identical to v0.1.2’s.
Pinned by test_generation_is_shape_pair_independent (the two pairs draw the
same matrix) and test_todense_matches_matmul (mat.todense() @ v == mat @ v
for every class and corder).
Phase 1 — binary_jitsmm.cu → 32-lane#
Convert binary_jitsmm.cu in
place. Keep the file, the pack kernel (packed[n][ceil(k/32)], unchanged),
and the six @BE entry points (notrans_{f32,f64,f16,bf16}, trans_{…}) so
no Python dispatch name moves.
Edits, per kernel (_notrans_f32_kern, _trans_f32_kern, and both macros
DEFINE_BINARY_JITSMM_NOTRANS / _TRANS):
Current |
New |
|---|---|
|
delete; use |
|
|
|
|
|
|
|
|
|
|
And in the four launch wrappers (notrans_f32, trans_f32,
FFI_BINARY_JITSMM):
Current |
New |
|---|---|
|
delete |
|
|
dim3 blocks(row_warp_blocks, n_chunks, n) — the z-dimension over columns is
what makes this mm rather than mv, and it stays. group4_reduce_sum_f32/f64
become unused and are deleted along with AW_T4_GROUP_SIZE /
AW_T4_GROUPS_PER_WARP. The stale header comment (n <= 32, “AW backend with
4-thread WPR groups”) is rewritten.
Python side, same phase:
binary.py:866 —
_jitsmm_numba_kernel:stride = _MM_STRIDE→ 32, and update the comment at lines 867–869 that explains the 4-vs-32 divergence.binary.py:970–973 — the comment claiming gradients must delegate to
float.jitsmmin mm mode is now wrong; rewrite.
Phase 1 tests (the batch=1 check)#
New tests in binary_test.py,
parametrized over every available backend × transpose × corder × dtype:
test_binary_jitsmm_batch1_matches_binary_jitsmv— forBof shape(k, 1),binary_jitsmm(w, p, B, seed, …)[:, 0]equalsbinary_jitsmv(w, p, B[:, 0], seed, …). Exact==for f32/f64;allclosefor f16/bf16, where atomic ordering perturbs the low bits.test_binary_jitsmm_columns_are_independent_mvs— forBof shape(k, n), columncof the result equalsbinary_jitsmv(…, B[:, c])for everyc. This is the strong form: mm is literally “n independent mvs on one matrix”.test_binary_jitsmm_structure_matches_jits—binary_jitsmmwith an all-onesBreproduces the row/column sums of thejitsmatrix. Written against the post-unification singlejits, so it survives Phase 3 unchanged.
The existing test_binary_jitsmm_* reference tests compare against
float.jitsmm, which is still in mm mode after Phase 1 alone — so Phases 1
and 2 land as a single commit for _jit_scalar. They are the same semantic
change and splitting them would leave the tree red.
Phase 2 — propagate to float, csr, dt2t (_jit_scalar)#
Nothing here needs new kernels; every mv variant already exists.
float_jitsmm.cu#
jitsmm_mv_notrans/trans_* (8 @BE symbols) already implement shape (A).
Promote them:
rename
jitsmm_mv_*→jitsmm_*delete the AW-T4
jitsmm_notrans/trans_*kernels, their macros and their 8@BEsymbolsnet: 16 → 8
@BEsymbols
The launch parameters differ between the two families (FFI_JITSMM(…, 1, m)
vs the AW-T4 form); take the mv family’s verbatim.
float_jits.cu#
Delete jits_mm_aw_t4_notrans/trans_* (8 @BE symbols) and the
_jits_mm_aw_t4*_kern bodies. Keep jits_mv_*, renaming to drop the _mv
infix so _jits_cuda_kernel builds one unconditional name. 16 → 8.
csr.cu#
Delete count_chunks_{notrans,trans}_mm_aw_t4_* and
fill_{notrans,trans}_mm_aw_t4_* (16 @BE symbols), the four
_*_mm_aw_t4_f32_kern bodies, and the mm arms of the four DEFINE_*_WRAPPER
expansions. 32 → 16.
dt2t.cu#
Already mv-only. No kernel change; the comments that say “dt2t always materializes the mv matrix” become vacuous and are simplified.
Python#
File |
Change |
|---|---|
drop |
|
drop |
|
drop |
|
handled in Phase 1 |
Phase 3 — delete the mode selection, restore the 0.1.2 interface#
Deletions — nothing left behind#
Symbol |
Location |
|---|---|
|
|
|
|
|
|
|
|
|
keep the constant, rename to |
|
|
3× |
main.py L399, L713, L1215 |
matrix_mode becomes an unknown keyword — passing it raises the ordinary
TypeError from Python, with no special-casing, message or warning. No
deprecation path is provided.
A grep gate closes the phase:
grep -ri "matrix_mode\|_mode_infix\|MatrixMode\|_MM_STRIDE\|mm_aw_t4\|aw.t4\|\.mv\.\|\.mm\." \
brainevent/ changelog.md
must return nothing outside two allowances:
Allowed hit |
Why |
|---|---|
|
local |
|
0.0.x naming convention for the |
docs/ is not in the gate. Pre-existing documents there
(docs/superpowers/plans/, earlier docs/specs/) are dated records of past
decisions; they are neither rewritten nor whitelisted, they are simply outside
the scope of this work. The invariant is about the code tree. changelog.md is
the one document that is edited, because it describes the current unreleased
version rather than a past decision.
Restorations (target = v0.1.2 signatures)#
def jits(weight, prob, seed, *, shape, transpose=False, corder=True, backend=None) -> Data
def jits_to_csr(weight, prob, seed, *, shape, corder, backend=None)
mat.todense() # works, no mode
mat.tocsr() # works, no mode
mat.tocsc() # works, no mode
mat.tocoo() # works, no mode
jitsmv, jitsmm, binary_jitsmv, binary_jitsmm, jitsmv_dt2t and all
*_p primitives already match v0.1.2 once matrix_mode is dropped.
Interface conformance check#
test_public_api_matches_0_1_2 asserts via inspect.signature that the
parameter lists of the public JITC entry points equal a table transcribed from
the v0.1.2 tag. This discharges the “接口与 0.1.2 一致” requirement mechanically
rather than by review.
The check is signature-level only; the behaviour behind those signatures is
pinned separately by test_generation_is_shape_pair_independent and
test_todense_matches_matmul.
Implemented per-module rather than as one cross-package test, to respect the
co-location rule (AGENTS.md #11): float_test.py checks the jit* / jit*mv /
jit*mm parameter lists, csr_test.py the *_to_csr one, dt2t_test.py the
*mv_dt2t one. All match v0.1.2 exactly. Only the low-level *_p_call
primitives still differ, and only in parameter names (w0/w1 →
w_loc/w_scale, indptr → offsets).
Test updates#
File |
Work |
|---|---|
|
33 sites; drop the |
|
20 sites; delete |
|
|
|
4 sites, mechanical |
|
L815–823 |
|
keep the Phase 1 additions |
Changelog#
0.2.0 is unreleased on this branch, so the mv/mm split is edited out of the 0.2.0 notes, not recorded as a reversal. Sites in changelog.md:
Line |
Action |
|---|---|
44 |
delete the |
45 |
delete the |
63–67 |
delete the “ |
113–119 |
delete the |
128 |
delete the |
263 |
drop |
675 |
leave alone — 0.0.x naming convention for |
A new Fixed entry also covers the three latent bugs in the next section.
Latent bugs fixed by this work#
Each is real on the current tree, each disappears under unification, and each gets a regression test that keeps it from coming back (AGENTS.md rule 4).
B1 — numba jitsmm silently ignores matrix_mode#
_jitsmm_numba_kernel does not
accept matrix_mode at all and hardcodes stride = _MM_STRIDE (L1328), while
_jitsmm_cuda_kernel honours it.
So jitsmm(..., matrix_mode='mv', backend='numba') returns the mm matrix
and disagrees with backend='cuda_raw' — a silent CPU/GPU divergence of exactly
the kind 0.2.0’s parity work set out to remove.
Regression test: numba and cuda_raw jitsmm agree (CUDA-gated); satisfied
automatically once one stride remains.
B2 — vmap(jitsmv) draws a different matrix than jitsmv#
_jitsmv_batching forwards to
jitsmm_p_call with a hardcoded matrix_mode='mm' (L1099 and L1113). Since
jitsmv is mv, vmap(jitsmv)(V) and a Python loop of jitsmv over the columns
of V produce different numbers. The jitsmm_mv_* CUDA kernels exist precisely
to fix this — their header comment says “used by vmap(jitsmv)” — but they were
never wired up: no caller anywhere in the repo, tests included, ever passes
matrix_mode='mv' to jitsmm.
Regression test: vmap(jitsmv)(V) equals stack([jitsmv(v) for v in V.T]).
B3 — same defect on the binary path#
_jitsmv_batching in binary.py
forwards to binary_jitsmm_p_call (L621, L634). binary_jitsmm has no mode
parameter at all — its .cu is AW-T4 unconditionally — so
vmap(binary_jitsmv) has the same divergence with no way to opt out.
Regression test: vmap(binary_jitsmv)(V) equals the binary_jitsmv loop.
B4 — the same three defects, replicated in _jit_normal and _jit_uniform#
_jit_normal/binary.py pins matrix_mode='mm' at L871, L891, L901, L911 (four
sites — gradient rules as well as batching), and _jit_uniform/binary.py at
L1142 and L1213. Same regression tests, per package.
Phase 4 — propagate to _jit_normal and _jit_uniform#
The two packages are structural mirrors of _jit_scalar (_JITCNormalModeView
/ _JITCUniformModeView, the same csr.py / float.py / dt2t.py split, the
same *mm.cu AW-T4 kernels). Apply Phases 1–3 file-for-file:
Scalar |
Normal |
Uniform |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
same names |
same names |
Extra work not present in scalar:
The six extra
matrix_mode='mm'pin sites of B4.stride_for_mode()and the cross-package_test_utildependency.stride_for_mode(mode)('mv'→32,'mm'→4) is the pure-numpy stride the parity tests use to recompute the connectivity by hand. It collapses to the constant 32 and is deleted, along with thematrix_mode='mv'parameter ofdense_normal_reference()/dense_uniform_reference().It is currently defined once, in _jit_uniform/_test_util.py:63, and _jit_normal/_test_util.py:3 reaches across package boundaries for it together with
conn_length,default_chunk_size,hash_uniform01anditer_edges. Each package tests on its own: that import is removed and_jit_normal/_test_util.pybecomes self-contained, carrying its own copy of the RNG-walk helpers it needs (mix32,fast_bounded_u32,light_rng_next/init,stationary_initial_q,conn_length,default_chunk_size,iter_edges, plushash_uniform01sincehash_normal01is built on it). Duplication is the intended outcome — a reference implementation that two packages share cannot catch a divergence between them, which is exactly the class of bug (B1–B4) this work exists to remove.After the split, no
_jit_*test module imports from another_jit_*package; the only shared test import left is the repo-widebrainevent._test_util(allclose,requires_gpu,gen_events), which is backend-agnostic plumbing, not a reference implementation.The weight streams differ (
hash_normal01,hash_uniform01) but are keyed by(seed, row, col), independent of lane — so the stride change moves the structure only, exactly as in scalar. No new weight-hash work.
Order: normal first (closer to scalar), then uniform.
Performance: explicitly out of scope#
jitsmm / binary_jitsmm lose the AW-T4 grouping in Phases 1–2 and may regress
at small n. No benchmark phase gates this work and no alternative kernel shape
is kept: correctness — one matrix, on every backend, for every entry point — is
the whole deliverable. If a regression later proves material in practice, it is
new work against the then-current tree, not a variant carried in this spec.
Commit plan#
Landed as one commit rather than the five below. The phases are still the
right way to read the change, but they cannot be split into green commits:
_misc.py, _typing.py and _data_test.py are shared by all three families,
so _data_test.py’s parametrized materialization test is red for any package
that has been converted while its siblings have not, and deleting MatrixMode /
_mode_infix requires all three packages to have stopped using them.
The phase order was still followed during implementation, with the invariant
re-verified on numba and cuda_raw after each package:
Phases 1+2 —
_jit_scalarCUDA + PythonPhase 3 —
_jit_scalarmain.py(folded in:_JITCScalarModeViewcallsjits(matrix_mode=...), so it cannot outlive Phase 2)Phase 4a/4b —
_jit_normal,_jit_uniformShared deletions (
_misc.py,_typing.py),_data_test.py, changelog
CUDA-only assertions stay skipif-guarded, so the work is verifiable on CPU via
the numba backends; the CUDA/numba parity tests pin the two implementations
together.
Deviations from this spec, as implemented#
float_test.py’s “duplicate mv/mm blocks” are not duplicates. The spec calls for collapsing L75–136 and L153–241 into one block each. They are thejitsmvandjitsmmforward/gradient tests — different entry points that happened to pass differentmatrix_modevalues. Both are kept; only the keyword is dropped.Each package gained a self-contained
_test_util.py, including a new one for_jit_scalar(dense_scalar_reference). The pure-numpy replay of the 32-lane walk pins the drawn matrix rather than merely the agreement of the kernels with each other, and duplicating it per package means a divergence between families cannot hide behind a shared helper.The interface conformance check is per-module, not one cross-package test, to respect the co-location rule (AGENTS.md #11):
float_test.pyasserts thejit*/jit*mv/jit*mmparameter lists,csr_test.pythe*_to_csrone,dt2t_test.pythe*mv_dt2tone. All match v0.1.2 exactly. The*_p_callprimitives keep 0.2.0’s extrachunk_size/target_chunkskeyword-only arguments, which have defaults and are unrelated to mv/mm.
Edge cases to cover in tests#
n = 1(the batch=1 identity),n = 32,n = 33(crosses the packed-word boundary),n > 65535must still hit the existing grid-overflow abort.knot a multiple of 32 — the last packed word is partially valid.prob = 0(empty matrix) andprob = 1(clen = 2, thecl < 2Uclamp).chunk_sizeboundaries:shape[1] < target_chunks,shape[1] = 1.corder ∈ {True, False}×transpose ∈ {True, False}— all four, since notrans/trans are separate kernels.dtypes
f32, f64, f16, bf16— f16/bf16 accumulate in f32 and needallclose.Column-oriented matrices (
JITCScalarC) for the_materialize_paramspath.vmapover both axis 0 and axis 1 (the two arms of each batching rule).
Scope accounting#
All counts measured on this tree (grep -o, occurrences not lines), not
estimated.
@BE CUDA symbols#
Per package, before → after. The three packages are exact structural mirrors, so
one table covers all three (jits↔jitn↔jitu).
File |
Before |
After |
Delta |
|---|---|---|---|
|
32 |
16 |
−16 (the |
|
16 |
8 |
−8 ( |
|
16 |
8 |
−8 (AW-T4 family; |
|
9 |
9 |
0 (rewritten in place, |
|
9 |
9 |
0 |
|
8 |
8 |
0 |
|
8 |
8 |
0 |
per package |
98 |
66 |
−32 |
×3 packages |
294 |
198 |
−96 |
matrix_mode occurrences#
Scope |
Before |
After |
|---|---|---|
|
98 |
0 |
|
107 |
0 |
|
109 |
0 |
|
18 |
0 |
product total |
332 |
0 |
|
69 |
0 |
|
82 |
0 |
|
84 |
0 |
test total |
235 |
0 |
API surface and test scaffolding#
Before |
After |
|
|---|---|---|
Public functions with a |
8 per package, 24 total |
0 |
↳ |
|
— |
↳ |
|
— |
↳ |
|
— |
|
3 per package, 9 total |
0 |
|
32 / 38 / 38 (scalar/normal/uniform) |
0 |
|
6 |
0 |
|
26 |
0 |
|
6 (3× |
0 (deleted) |
|
1 definition ( |
0 |
Cross-package |
1 ( |
0 — each package self-contained |