Skip to content

driver: blocked outer traversal, enabled where it is measured to pay - #101

Merged
shinaoka merged 16 commits into
mainfrom
bench/blocked-outer-traversal
Oct 10, 2026
Merged

shinaoka merged 16 commits into
mainfrom
bench/blocked-outer-traversal

Conversation

@shinaoka

Copy link
Copy Markdown
Member

What

A blocked outer traversal for the packed driver: a block spans the output's fastest
axis whole times a line's worth (8 f64 elements) along the operand's fastest axis, its
tiles are permuted into the output's order, and it is emitted once. Where the packed
operand and the output disagree about their fastest axis, today's slices let no
reordering produce a contiguous destination run and the pack reads one element per cache
line; a block that spans whole runs of both axes fixes that.

The path is taken only where it is measured to pay: n <= 64 && k > n on top of
k <= KC, n <= NC, one K slab, one NC panel, pm == 1 && pn == 1 and a non-Direct
family. Everything else keeps today's traversal, with grid_elems, block_rows and mc
taking their previous values.

Evidence (98 tcbench rows, 16 MiB, 1T, path off and on in one session)

case dtype off on
abjc-cbka-kj f64 42.0 ms 24.7 ms x1.70
adbjc-cbdka-kj f64 46.2 ms 28.4 ms x1.63
adbjc-cbdka-kj c64 77.2 ms 63.6 ms x1.21
abjc-cbka-kj c64 70.4 ms 61.1 ms x1.15

No eligible row is slower; the rows that read 3-7% worse are ineligible, take an
identical code path in both versions, and are drift. The class is also the one where the
reference wins: over the campaign's tcbench cell TBLIS wins 12 of its 24
n <= 64 && k <= 48 rows and 4 of 168 elsewhere.

Mechanism, from the phase-timing instrument: pack_a 28.8 -> 6.4 ms, against about
8.6 ms of added permutation and per-block work.

Review

The finished diff was reviewed independently (gpt-6.1-sol, read-only, full sources).
It found two memory-safety blockers, both real and both fixed here:

  • direct_tile did not test collect, so a Direct family could compute into D, leave
    its grid slot unwritten, and have the emitter read it. The guard now tests collect,
    and the eligibility refuses Direct families.
  • The grid permutation walks every column of the grid while a column-group worker fills
    only its own: the eligibility now requires pm == 1 && pn == 1, so the path is serial.

It also found the missing row-budget bound (now a condition, with tests), per-execute
allocations (role, block_origins, rows - now fixed-size scratch) and a
non-disjoint phase account (the extra phases are removed and the design document says
what setup can and cannot support). The never-triggering direct-emit variant is
deleted, permute::slots is private, and permute_grid_rows no longer takes a plane
count it can derive.

Not verified

tests/packed_workspace_alloc exercises an ordinary matrix multiplication, whose one
folded row axis can never select this path, so the zero-allocation claim rests on reading
the code. The path is serial-only, so 4T benefits are not claimed. Full record:
docs/design/blocked-outer-traversal.md sections 9-14.

Gates

cargo fmt --all -- --check, cargo clippy --workspace --all-targets with and without
phase-timing (-D warnings), RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps, cargo test --workspace (80/80 binaries), and the harness oracle
(tcbench verify, f64 and c64) at 16 MiB 1T, 16 MiB 4T and 1 MiB - all "all comparisons
within tolerance".

… unused)

The consolidated design for matching the reference on transposed-output
contractions is `docs/design/blocked-outer-traversal.md`; it records the measured
problem (pack_a 27.6 ms of 43.1 on `abjc-cbka-kj` f64 16 MiB 1T), the four
pre-reviews' findings, and the plan. This commit is the first step of that plan:
the block membership, its conservative eligibility predicate, and the gather that
turns global scatters into block-local ones.

`driver/block.rs`:

- `eligible` returns a block shape only when the role has two or more live axes
  and the operand's fastest axis is *not* the output's - i.e. when today's
  consecutive slices of one flattened scatter cannot hold the runs the design
  needs. Every other shape keeps today's path by construction.
- `rows` enumerates a block's global flattened logical row indices with the
  operand's fastest axis innermost, which is the order the panel rows take, and
  which is why the pack needs no contract change.
- `gather` turns a global scatter into a block-local one; the element offsets
  differ per operand, the logical row does not.

Its tests pin both properties the design rests on, for the measured case's own
axis record: consecutive rows are one source run (8 f64 = one 64-byte line, so the
read stops touching one element per line), and every one of those rows carries all
48 `c` values whose destination offsets are consecutive (the run the emitter
writes). Those are exactly the two runs the earlier drafts could not provide.

Nothing calls the module yet, which is deliberate: it is the behaviour-preserving
first step, and the enumerator that consumes it comes with the next. Gate green:
`cargo fmt --all -- --check`, `cargo clippy -p tprims-contract --all-targets -- -D
warnings` with and without `--features phase-timing`, `cargo clippy --workspace
--all-targets -- -D warnings`, `cargo doc --workspace --no-deps` with `-D
warnings`, `cargo test --workspace` (80 test binaries, all ok).

Local branch only, not pushed: this is scaffolding for an architectural change,
not a deliverable on its own.
…hat removes the hard half

Second step of `docs/design/blocked-outer-traversal.md`, still unused by the driver and
so behaviour-preserving:

- `Role` and `role()` map the plan's oriented row role to the two strides the blocked
  path needs (operand and output), so the axis-order question is answered once, in the
  orientation the kernel will actually use.
- `blocked_eligibility` is the conservative predicate the reviews asked for: the role's
  fastest axes must disagree **and** the contraction must fit one K slab and one NC
  panel. The second half is new and it is what makes the change small - with one slab
  and one NC panel a block's accumulator is complete inside the single `pc` iteration
  the driver already performs, so there is no cross-slab persistence, no ownership or
  barrier restatement, and no B-publication lifetime to specify.
- `output_order` and `reorder_rows` are the other half of the mechanism: visiting a
  block's rows in output-offset order makes the stores consecutive (measured: runs of
  48, one per `a`, and the ties keep the block's own order, which the raw write-back
  contract's repeated-address behaviour depends on).

Tests pin both properties the design rests on, for the measured case's own axis record:
the block holds a one-line source run of 8 `f64` **and** all 48 `c` values whose output
offsets are consecutive; eligibility is refused for two K slabs, two NC panels, a
swapped role, a single live axis, and a role whose fastest axis agrees.

Gate green: `cargo fmt --all -- --check`; `cargo clippy -p tprims-contract --all-targets
-- -D warnings` with and without `--features phase-timing`; `cargo test --workspace` (80
test binaries, all ok).

Local branch only, not pushed.
…ng meet

Third step of `docs/design/blocked-outer-traversal.md`, still behaviour-preserving: the
eligibility is now computed in execution initialization, where the plan's oriented role
axes and the resolved `mc`/`kc`/`nc` are both in scope, and reported under the phase
instrument (`TPRIMS_PHASE=1`) until the enumerator consumes it. No path changes: the
driver still walks today's consecutive slices.

Checked on real plans, which is the point of landing it here rather than in a test:

| case | eligibility |
| --- | --- |
| `abjc-cbka-kj` f64, the case that loses 1.5x | `Some(BlockShape { span: 0, line: 2, line_len: 8 })` - the output's fastest axis is `c`, the operand's is `a`, and a line is 8 `f64` |
| `abcijk-eiac-jkeb` f64, where this library wins 2.5x | `None` |
| `ijkl-imjn-lnkm` f64 | `None` |

So the predicate selects the measured class and nothing else among the cases checked,
and it agrees with the unit test's synthetic record (the two differ only in how the axes
are listed: the plan lists them output-fastest first, `(c, b, a)`, the test `(a, b, c)`).

Gate green: `cargo fmt --all -- --check`; `cargo clippy -p tprims-contract --all-targets
-- -D warnings` with and without `--features phase-timing`; `cargo test --workspace` (80
test binaries, all ok); `cargo doc --workspace --no-deps` with `-D warnings`.

Local branch only, not pushed.
The data side of the blocked outer traversal. The blocked path computes a block's
micro-tiles in the packed operand's row order (the order the packer can read without
touching one element per cache line) while the emitter writes `D` fastest along the
output's axis, so the block's tiles have to be reordered once, as a block, before
emission. Reordering a single 24-row tile is a no-op on the shapes this exists for,
which is what the earlier designs got wrong.

`pack::permute`:

- `slots` gives the real positions one logical element occupies in a tile of each
  `TileFormat`, mirroring `tile_value`'s addressing **without recombining planes**:
  `Real` one, `Planar`/`Interleaved`/`OneM` two, `FourM` four, `ThreeM` three (the
  doc's own counts; there is no `OneE` tile format).
- `permute_grid_rows` moves whole rows between grids of `mr x nr` tiles, taking each
  destination row from any source tile. Because it copies *slots*, every plane
  layout survives bit for bit and conjugation stays where it already is - at the
  emitter, which applies it while writing `D`.

Tests: the identity map is bit-identical for all six formats; a row pulled from
another tile lands in the destination row and no destination slot is left
unwritten; and `slots` is shown to cover every position of a tile exactly once per
format, which is what makes the copy bit-exact rather than merely approximate.

Gate green: fmt, clippy (workspace, with and without `--features phase-timing`),
`cargo test --workspace` (80 test binaries, all ok), doc with `-D warnings`.

Local branch only, not pushed.
…rsal

Steps 1-4 (row views, collect instead of emit, the blocked branch, the tile-grid
workspace), each with the signatures it changes and the gate it lands on, so the
remaining work is mechanical rather than a matter of taste. Step 1 is the identity view
the reviews approved: static_grid.rs slices the global vectors as today, so it changes no
behaviour and partition_bitwise pins it.
…ur-preserving)

First of the four wiring steps in `docs/design/blocked-outer-traversal.md`, and the
identity view the reviews approved: `pack_a_rows` and `compute_block` now take the row
scatters they use as slices with a live count instead of `ic`/`ic_len` into the epoch's
global vectors, and index them from zero. Both schedulers pass exactly today's interval
- the epoch's own slice and its block-scatter twin - so no behaviour changes; the
blocked path will pass a gathered block-local list through the same parameters.

Two things this surfaced, both worth keeping:

- `C`'s block scatter is **deliberately empty when `beta == 0`** (`driver/mod.rs` makes the
  range `(len, len)`), and the write-back reads it with `.get()`, so empty is a signal and
  not a range to cut: the caller passes the empty slice through unchanged.
- The C ABI tests load a *prebuilt* `libtprims.so`, so `cargo build -p tprims-capi` has to
  run again after a library change before `cargo test --workspace`; a stale library is what
  the first failures were, not the refactor.

Verified behaviour-preserving, by tests and by measurement:
- `cargo test --workspace` passes: 80 test binaries, all ok (after rebuilding `tprims-capi`),
  including `partition_bitwise`, which pins serial/static/dynamic/width equality.
- The measured case's phase profile is unchanged: `total 43.13 ms, pack_a 27.60 (64.0%),
  kernel 14.29, writeback 1.79`, against the pre-refactor `43.10 / 27.6 / 14.3 / 1.8`.
- fmt, clippy (workspace, with and without `--features phase-timing`) and doc with `-D
  warnings` are green.

Local branch only, not pushed.
`block::rows` takes the block's per-axis origin instead of two scalars, and
`block::block_origins` enumerates every origin a role needs: the span axis once (a
block takes it whole), the line axis in steps of a line, every other axis in steps
of one.

The test that matters is the one the fourth review asked for: for the measured case's
own axis record the block rows, concatenated over all 240 origins (40 `b` x 48/8 `a`),
are a permutation of `0..92160` - no gaps and no repeats. Coverage is what the
driver's gates will check on real calls, and it is now checked on the enumeration
itself, before any wiring.

Gate green: 24 `tprims-contract` lib tests, `cargo test --workspace` (80 binaries, all
ok), fmt, clippy (workspace, with and without `--features phase-timing`).

Local branch only, not pushed.
The campaign's tcbench cell carries TBLIS per case, so the reference's own
applicability range can be measured rather than assumed. Over 196 rows it wins 16,
and 12 of those 24 rows are the ones with a thin output and a shallow K
(`n <= 64 and k <= 48`, median ratio 0.99); outside that class it wins 4 of 168. The
reason matches this design's: a long output-fastest axis against a short
operand-fastest axis is exactly where the pack read touches one element per line.

The same measurement says the current implementation is not ready to be a default:
with the switch on it helps 2 of 98 one-thread rows and hurts 94, median 25% slower,
the two wins being the two thinnest cases. The mechanism is measured (pack_a 27.6 to
5.5 ms); the per-block allocation churn (~9 ms of a 23 ms call) and the block-level
transpose are what eat it elsewhere. Promotion therefore needs both the thin/shallow
rule and the allocation fix, and the switch stays off until then.
`run_block` built about ten `Vec`s per block, which cost more than the blocked path
saved. They are now stack arrays bounded by `BLOCK_ROWS_MAX` (= `BLOCK_MC_BUDGET`),
with the boundary asserts that make the bound true; the workspace request keeps the
two grids and the no-workspace path now sizes its panel for them as well.

Two correctness bugs fell out of running the whole corpus with the switch on: the row
map must be sorted over `live` rows and padded afterwards (a `live % mr != 0` shape
indexed past the end of `dm_rows`), and the grid slot must be the global column tile
or column-group workers overwrite each other. Both are fixed; the workspace suite is
80/80 again.

With that, the blocked path is cost-neutral over the corpus (8 helped, 15 hurt, 75
neutral, median x1.00, from 2/94/2 and x0.80), and the measured case goes 43.4 to
31.3 ms, 11.2 GF/s against 8.2. Promotion still waits on a profitability test, and
the geometry does not supply one: `adbjc-cbdka-kj` (x1.22) and `abjcd-dkbac-jk`
(x0.74) agree in `span`, `line`, `line_len` and `grid_elems`. The next measurement is
the operand's scatter regularity inside a block, not another guess. The switch stays
off and this stays out of a PR until it improves a published number.
Per case, the eligible role's strides differ even when `BlockShape` does not, but the
phase instrument locates the loss away from the mechanism: `pack_a` falls exactly as
designed (35.3 -> 11.2 ms, 26.1 -> 15.8 ms) while the unmeasured remainder - the
block's gathers, scatters and the grid -> grid2 permutation, which is a second pass
over the block's output - is 9 ms on the two shapes that win and 20 ms on the
four-axis shapes that lose. Profitability is therefore about work per block (block
rows times `n` against a fixed per-block cost), not about line geometry, and the next
descriptor to record is the block count and the block's output size.

The diagnostic also now prints the role axes' strides, which is what showed the two
indistinguishable shapes apart; it stays behind `phase-timing` + `TPRIMS_PHASE`.
…hole answer

Three candidate explanations for the blocked path's remaining cost are now ruled out
by measurement: it is not the instrument's accounting (15 ms at `--prime-ms 0` and at
`--reps 20`), it is not the grid -> grid2 permutation (removing that copy entirely -
each panel tile now emits with its destination tile's own rows and scatters, and the
permutation only runs when a panel tile's rows are not a whole destination tile -
changed `setup` from 14.7 to 15.0 ms), and it is not per-block metadata (the
eligibility divided the block budget by the other axes as well as the span axis, so
blocks were ten times too small and `line_len` was 1 instead of the design's 8; fixing
it made the blocks bigger and `setup` bigger with them).

What remains is the work the instrument does not attribute, and the one candidate that
scales with it is the write-back: with the block's rows gathered in the operand's
order the loads become contiguous (`pack_a` 35.3 -> 5.3 ms) and the stores become the
strided side. The path is a trade of strided loads against strided stores, so it pays
only when `m*k > m*n`. Fitted to the corpus the best rule (`n <= 64` and `k > n`)
selects 6 rows, helps 3, hurts 1, and a median 2.4%. That is not a promotion, so the
switch stays off and the objective is not met; the design document records why.
…names

The blocked path's emit loop was in no phase (phase 3 sits inside the unblocked
write-back), so its whole cost landed in the derived `setup` remainder, and per-block
`Instant::now()` calls - the obvious way to split it - are expensive enough on this
host to swamp what they measure (removing the sort entirely left the "metadata"
number unchanged at 21.7 ms).

Two measured phases instead, taken once per call: `block_meta` for the per-block
gathers, scatters and row order, `permute` for the grid permutation and the
output-ordered rows and scatters. The parts now add up to the call (38.6 against
38.1 ms): pack_a 6.3 against 28.8, kernel-own 0.8 against 11.7, stores 11.0 against
10.6, plus 15.0 of block metadata and 5.5 of permutation - a net 14 ms win on the
measured case, with the remaining cost named and bounded in the design document.
The blocked outer traversal is now selected by profitability rather than by geometry
alone. It buys a contiguous pack and pays for that with a per-block grid permutation
and per-block metadata; over the 98 tcbench rows at 1T, measured with the path on and
off in the same session, the class where that trade comes out ahead and never behind
is a thin output against a deeper contraction:

  n <= 64 and k > n:  6 rows, 4 wins, 0 losses, 2 neutral, median x1.143
                      (abjc-cbka-kj f64 42.0 -> 25.6 ms, x1.64;
                       adbjc-cbdka-kj f64 46.2 -> 32.6 ms, x1.42)
  everything else:    today's traversal, unchanged - `grid_elems`, `block_rows` and
                      `mc` all take their old values when the eligibility says no.

That class is also where the reference's own advantage lives (TBLIS wins 12 of its 24
`n <= 64 and k <= 48` rows and 4 of 168 elsewhere), so this closes the gap exactly
where this library was furthest behind.

Two geometry bugs came out of the same measurements and are fixed here: the block's
row count multiplied the *other* axes in as well, so `MC`, the packed panel and the
tile grid were sized for a block many times larger than the one `block::rows` builds
(and the eligibility divided the block budget the same way, which is why blocks were
ten times too small and `line_len` was 1 instead of the design's 8); and the row map
must be sorted over `live` rows and padded afterwards, or a `live % mr != 0` shape
indexes past the end of the block's rows.

Gates: workspace tests 80/80, fmt, clippy with and without `phase-timing`, rustdoc,
and the harness oracle (`verify`) at 16 MiB 1T and 4T and at 1 MiB, all within
tolerance.
… no allocations

An independent review of the finished diff (gpt-6.1-sol) found two memory-safety
blockets and they were right.

* A Direct family wrote `D` instead of its tile: `direct_tile` never tested whether the
  call was collecting, so an eligible call could compute into `D`, leave its grid slot
  untouched, and then emit that slot. The guard now tests `collect` - the invariant
  itself - and the eligibility refuses Direct families outright, as the design always
  claimed.
* The permutation read grid slots no worker had written: a column-group worker fills only
  its own column tiles while `permute_grid_rows` walks every column. The eligibility now
  requires `pm == 1 && pn == 1`, so the blocked path is serial, which is where it was
  measured; partitioning the block itself stays a separate change.
* The row budget was not enforced: `(mc_budget / span).max(1)` admits a span larger than
  the budget, and the panel, the grid and the row map are sized from the block rounded up
  to whole `MR` tiles. Both are conditions in the eligibility now, with tests, instead of
  panics in `run_block`.
* The traversal allocated: `role`, `block_origins` (a `Vec<Vec<usize>>`, one allocation per
  block) and `rows` allocated per execute, against the workspace's no-allocation contract.
  They are fixed-size scratch now (`role_into`, `Origins`, `rows_into`), and the incremental
  origins are checked against the materialized list by a test.
* The two extra phases are gone again: `block_meta` contained `pack_a` and `kernel`, per
  block scopes cost more than they measured, so the instrument is back to four phases plus
  the `setup` remainder, and the design document says what that remainder can and cannot
  support. The direct-emit variant that never triggered on any measured case is deleted,
  `permute::slots` is private, and `permute_grid_rows` no longer takes the plane count it
  can derive.

Measured again after the fixes, on the same 98 tcbench rows at 1T: `abjc-cbka-kj` f64
42.0 -> 24.7 ms (x1.70), `adbjc-cbdka-kj` f64 46.2 -> 28.4 ms (x1.63), the c64 rows 1.21x
and 1.15x, and no eligible row slower. The rows that read 3-7% worse are ineligible, take
an identical code path in both versions, and are drift. Gates: fmt, clippy (with and
without `phase-timing`), rustdoc, 80/80 test binaries, and the oracle at 16 MiB 1T and 4T
and at 1 MiB, all within tolerance.

Not verified: `tests/packed_workspace_alloc` exercises an ordinary matrix multiplication,
whose one folded row axis can never select this path, so the zero-allocation claim rests on
reading the code rather than on that test. Recorded in the design document.
@shinaoka
shinaoka merged commit 0df08df into main Oct 10, 2026
13 checks passed
@shinaoka
shinaoka deleted the bench/blocked-outer-traversal branch October 10, 2026 14:55
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