Skip to content

Support padded shared-memory contraction stages - #538

Open
SamuelBelanger wants to merge 7 commits into
mainfrom
feat/tile-padded-stage-spread
Open

Support padded shared-memory contraction stages#538
SamuelBelanger wants to merge 7 commits into
mainfrom
feat/tile-padded-stage-spread

Conversation

@SamuelBelanger

Copy link
Copy Markdown
Contributor

Problem

A contraction operand whose innermost axis cannot be vectorized in global memory is stuck reading scalar all the way to the leaf. The motivating case is an NHWC tensor with C = 3: no 4-wide line lands on a row boundary, so the operand serves one element at a time even though every level below global memory could handle whole lines.

Approach

A shared-memory stage owns its own layout, so it does not have to be served at the width its source is read at. stage_width(w) asks for a stage padded out to w-wide lines:

launcher.bind(&operand, binding).vectorize(1).stage_width(4).build()

Three pieces make that work.

Staging rounds the innermost physical axis up to whole lines, so C = 3 allocates one 4-wide line with the fourth lane as padding.

The fill (MemData::fill_straight) assembles each destination line from adjacent scalar source reads. Lanes at or past the innermost extent hold zero rather than reading the next row's first live value. Where the extent fills whole lines the guard is dropped at comptime.

The register block spreads those lanes back across a scalar sink. Block column n covers sink columns n·spread … n·spread + spread - 1, so a padded rhs can run 4-wide against an accumulator addressed one element at a time. served (lanes are K-partials of one cell) and spread (lanes are neighbouring sink cells) are mutually exclusive and asserted so.

Padded stages are refused for quantized operands, and the padding does not leak past the level that asked for it: StagePlan::descend clears it.

Two fixes this uncovered

Both are pre-existing and land as their own commits.

Lane fan-out over multi-axis reductions. The fan-out walk indexes by line * lw + lane, which assumes lw divides the innermost contracted extent. It is now gated on that. Multi-axis reductions whose innermost axis divides lw (vectorized convolutions) keep the fan-out; the rest take the coordinate-decoded flat walk.

Lane resolution on the flat walk. lane_component resolved the K component from the flat step p, which agrees with the real coordinate only when lw divides the innermost reduce extent. It now reads that coordinate directly.

A ragged innermost extent on an unpadded stage. Rounding the stage up to whole lines is only sound when padding is intended; the global-memory side still counts its lines by truncation. fill_straight, where both boxes are in scope, now refuses an extent that is not a whole number of source lines. An equal-width stage is back to the divisibility rule Compaction::line_extents used to carry, and a direct stage gains one it never had.

Tests

resize1d_staged_padded_stage_width gathered resample, CI = 3 padded into 4-wide lines
conv1d_staged_padded_multi_axis_reduce_lane_indexing flat walk, lw ∤ CI
conv1d_staged_padded_multi_axis_reduce_lane_fanout the fan-out gate, fails without it
matmul_padded_rhs_stage_into_scalar_sink N = 3 padded rhs, scalar M×N sink
matmul_padded_rhs_stage_multi_line N = 5, two lines with a 3-lane tail
stage_width_* (4) host-side rejection: no Smem residence, partial source lines, narrowing, quantized

cubek-tile is green apart from test_reduce_axis_sum_spatial_unit_lanes, which fails on main for unrelated reasons.

Gathered lane fan-out unrolls line reads into scalar rank-1 updates
indexed by `line * lw + lane`. This linear indexing assumes `lw` divides
the innermost contracted axis extent so that lane stepping never crosses
reduction coordinate boundaries.

Gate lane fan-out on `innermost_divisible` (`reduce_extents[last].is_multiple_of(lw)`),
preserving the fan-out path for multi-axis reductions whose innermost
axis divides `lw` (such as vectorized convolutions) while falling back
to the coordinate-decoded flat walk when the innermost axis is not a multiple
of `lw` (such as padded stages).
Contraction operands that cannot vectorize in global memory (such as NHWC
tensors with C = 3 where rows are unaligned) can now be staged into padded
shared-memory lines. Staging rounds the innermost physical axis up to whole
lines, widens during the straight fill from scalar source reads, and spreads
the vector lanes back across scalar sink cells upon committing accumulator
blocks.
A stage rounds its innermost extent up to whole lines so a padded one can
hold a partial last line, while the gmem side still counts its own lines by
truncation. Where no padding is intended the two boxes disagree silently.

Refuse it in fill_straight, where both are in scope: the innermost extent
has to be a whole number of source lines, and only the destination may hold
a partial one. An equal-width stage is back to the divisibility rule
Compaction::line_extents used to carry, and a direct stage gains one it
never had.

Also narrow the Dynamic-extent check to the padded case that reads it, and
assert widen_line's lane count against the line width it writes into.
A spread block column holds `spread` scalar sink cells in its lanes, and the
N-D nest rounds its column count up, so the last column addresses cells past
the sink whenever `spread` does not divide its innermost extent. Nothing
masked them: an unchecked AccumulateView writes straight through, so the block
wrote past the accumulator's window. The values survived (a padding lane seeds
a cell, accumulates the stage's zero, and commits the same value back) but the
write itself was out of bounds, and inf inputs poison the round trip.

Pass the sink's extent to block::seed and block::commit and mask the lanes
that fall past it. Only the spread path rounds its column count up now: an
`aw`-wide column has no per-lane handle on a partial cell, so it keeps
counting whole lines as it did.

Skipping a lane puts commit's plane fold under divergent control flow, so
refuse a lane-split accumulator there; AccumulateView::lane_share is what
asks. Also assert what the spread arithmetic already assumed, that a spread
block's accumulator is served scalar, next to the code that relies on it
rather than two files away in step_served.
Two entry points set the width and only one checked it: the launcher's
StridedTileSource::stage_width, while TileSpec::stage_width validated nothing
at all, which is the path the conv tests take.

Move the checks onto TileSpec::validate_stage_width and call it from both, at
launch for a host backtrace and again in Tile::of for hand-built specs, beside
the Boundary::Clamp assert that catches them for the same reason. Only the
caller knows the served width and whether the operand is quantized, so both
are passed in.

Tighten the width rule while it moves. `width >= v && width.is_multiple_of(v)`
admitted a vectorized operand, which then panicked deep in fill_from where
widen_line asks for scalar source cells; a padded stage is for operands global
memory cannot vectorize, so require v == 1 and a width that widens it.

Fold Resize1d::check_padded back into the check helper it was copied from.
The divisibility rule Compaction::line_extents used to carry at construction
moved to fill_straight, which is one of two fills. A windowed or masked stage
takes scan_transparent instead, where a storage extent rounded up left a last
line the source never fills and nothing noticed.

Extract the check as fill_extent and ask it from both. Two more boxes were
left disagreeing with the rounding: projected::line_extents still truncated,
so a padded stage's bounds-check box excluded the partial line it really holds
(inert only because smem is Overhang::Never, and silently zeroing every read
the day it is not), and MemData::at rounded a region edge up for gmem too,
where Tile::of still truncates the shape it indexes. The edge now has to be
whole lines or the whole axis, since a partial tail line has no sibling to
start after it.

Bundle the padded fill's facts into one Padding value rather than three
comptime numbers passed alongside each other. fill_lines and read_stage_line
lose three parameters each, the 1:1 path no longer carries a rank it never
reads, and widen_line asserts on its own width type instead of a number it was
handed. The rank agreement Padding needs is asserted where it is built: a
storage-tiled stage splits each axis in two and cannot be one.
The fan-out names its lane with a comptime extract, and `lane_component`
decodes `last_k % lw` on the flat walk; what the gate has to ask is that the
two agree. Divisibility of the fastest contracted extent is one way to get
there, but not the only one: a single contracted axis makes `last_k` the flat
step itself, so the two are the same expression whatever the extent, tail
included.

Gating on divisibility alone therefore demoted every single-axis gathered
contraction whose K is not a whole number of lines to the flat walk. That is
the case the fan-out's tail loop exists for, and the loop became unreachable
along with it: divisibility of the fastest extent implies divisibility of
their product, so `k_tail` was provably zero wherever it was read.

Ask `reduce.len() == 1 || fastest.is_multiple_of(lw)` instead, which revives
both. `assert_operand_shapes` has already refused an empty reduce by this
point, so index the extents rather than carrying an arm for a case
`k_axis_idx` would have underflowed on two lines later.

Also round `batch_matrix`'s column count up, the last of the three line counts
still truncating. A padded lhs reaches the 2-D nest (a scalar rhs and sink
leave `rw == aw`, so `memory` routes to `direct`), where `block::contract`'s
tail walks its partial line correctly but the view's box claimed zero columns.
`cols` is a shape there, never a stride, so this only widens the bound.
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