LowerAutoVectorSplit Pass¶
Converts an AUTO pl.split mixed InCore function into the explicit
split_aiv form before ExpandMixedKernel. It inserts tile.aiv_shard
at cube→vector boundaries and tile.aic_gather at vector→cube boundaries,
halves only the vector sub-region along the split axis, injects
tile.get_subblock_idx(), and stamps split + split_aiv on the function.
This is the live auto-split lowering path: it always runs, immediately
before ExpandMixedKernel. After it runs, every split function reaches
SplitVectorKernel already split_aiv-marked,
so that pass only stamps attributes (its split_aiv arm) — its former per-op
halving driver was deleted, and the halving machinery now lives solely in
split_axis_utils, shared by this pass.
This pass lowers each first-class SplitAivScopeStmt region in place and
retains its wrapper. ExpandMixedKernel consumes that structure before codegen.
Why this pass exists¶
A mixed InCore function written with pl.split describes cube and vector work
in one body, with the split intent expressed only by the function-level split
mode. Rather than halve the AIV body op-by-op after ExpandMixedKernel has
already split it (the old SplitVectorKernel path, which duplicated the boundary
semantics tile.aiv_shard / tile.aic_gather already encode), this pass
rewrites the AUTO body into the same explicit split_aiv shape a hand-authored
kernel uses, before that split. ExpandMixedKernel's single op-driven boundary
arm then folds shard/gather into split-stamped tpush/tpop for auto and
hand-written kernels alike — one downstream path. The result is byte-identical to
the old halving (proved during the staged convergence): both call the same
split_axis::ProcessStmts machinery, and only the entry point differs.
A load whose physical split axis has extent 1 is a replicated broadcast read: it stays unchanged, matching AUTO. It is not marked as a half-width value, so it cannot vouch for unrelated consumers.
API¶
| C++ | Python | Level |
|---|---|---|
pass::LowerAutoVectorSplit() |
passes.lower_auto_vector_split() |
Program-level |
Pass Properties¶
| Property | Value |
|---|---|
| Required | SSAForm, IncoreTileOps, SplitIncoreOrch, TileOps2D, TileMemoryInferred, NormalizedStmtStructure, AivSplitValid |
| Produced | SSAForm, IncoreTileOps, SplitIncoreOrch, TileOps2D, TileMemoryInferred, NormalizedStmtStructure, AivSplitLoweredValid |
| Invalidated | AivSplitValid |
This pass replaces source AivSplitValid with AivSplitLoweredValid. The shared
verifier retains region checks while accepting the supported flat lowered form.
Source: include/pypto/ir/transforms/pass_properties.h
(kLowerAutoVectorSplitProperties).
Scope¶
A function is rewritten iff all of:
func_type_ == FunctionType::InCore, and- it carries a function-level split mode (
UpDown/LeftRight,mode != None), and - it is not already
split_aiv(hand-authored explicit kernels are left untouched — they already carry the explicit shard/gather form), and - it is genuinely mixed (cube↔vector): its rolled-up affinity is
MIXED, the sameClassifyCallAffinity/CombineAffinitydecisionExpandMixedKerneluses foris_mixed.
The whole-function transpose-split hazard¶
Before any of that, every InCore function carrying a function-level split mode
is checked for a tile.transpose that swaps the split axis — including the
functions this pass then passes through unlowered. The check used to live in
ExpandMixedKernel purely because that pass sees every function; running it over
the pass-through branch here is what let it move, and without that coverage a
pure-vector pl.split kernel would reach SplitVectorKernel with a transpose
it cannot type and miscompile silently. It runs before the lowering branch so its
specific diagnostic precedes the generic full-width-operand rejection the halving
would otherwise raise on the same statement. The per-region analogue is
AivSplitValid check (l), backstopped by this pass.
Everything else is passed through unchanged. The last condition matters: a
pure-vector pl.split function (an elementwise op split across the two AIV
lanes, with no cube and no C↔V boundary) has nothing to converge, so it is left
untouched and ExpandMixedKernel converts it to a plain AIV function and strips
its split attr exactly as before. Were it lowered here, it would carry
split_aiv without a split mode after that strip, and SplitVectorKernel would
reject it.
A mixed function whose body still carries a ScopeStmt is rejected, on the
same rule as the region path below: whole-function halving does not cross a scope
boundary either. The rolled-up affinity is computed through the scope (a scope's
affinity is its body's), so the pass can tell a mixed scope-bodied function from a
pure-vector one and reject only the former — a pure-vector body is still passed
through, not failed. Normally unreachable, since the function-level split attr
this path keys on is written by OutlineIncoreScopes, which consumes the scope in
the same step; it is checked rather than assumed because the alternative failure
is silent. Before the check, RollupAffinity had no ScopeStmt arm at all, so any
scope-bodied function rolled up SHARED, read as not-mixed, and was passed through
completely unsplit with no diagnostic.
Explicit SplitAivScopeStmt region path¶
In addition to the AUTO whole-function path above, an InCore function whose body
still carries one or more SplitAivScopeStmt regions takes a separate region
path (LowerExplicitRegionFunction), checked before the AUTO path. Each
region carries its own split_ mode, so this handles the multi-mode case the
single function-level mode cannot. Region-local tile_vars / var_replacements
maps keep a halved var from leaking into a sibling region or an out-of-region op;
statements outside any region are emitted full-width. After all regions are
lowered, the wrappers remain and the function is stamped split_aiv.
ExpandMixedKernel consumes the regions and checks each region's transpose
hazard against its own mode.
Shared body admission and AUTO regions¶
AnalyzeSplitBody checks bodies this pass halved — the AUTO whole function
and the boundary-free region — using the tile facts its shape transformation
established before substitution/cloning. It reconstructs shard and lane-address
lineage through aliases, tuple projections and loop results. Broadcast reads and
read-only singleton arithmetic remain neutral, so accepting them cannot certify
an unrelated full-width consumer. Rank-1 loads and full reshape/reinterpret views
may stage a subsequent lane-local slice; they do not become half-width facts.
A region whose boundary the author wrote is not scanned at all — see
What may appear inside an explicit-boundary region.
At control-flow merges, shard facts are intersected per tuple element across both branches. A loop backedge must preserve any shard fact inherited from its initial value. A neutral initial value may become lane-local in the body, but the carry and exit remain neutral; a zero-iteration exit cannot be classified using only its yield.
Diagnostics distinguish full-width operator names (full_width_vec_ops) from loop-carry names (carry_mismatches), and carry mismatches are reported first when both are present. Every failure here is a compiler postcondition violation — the pass halved the body and left something un-localized — so both use INTERNAL_CHECK_SPAN without authoring advice.
After lowering, AUTO wraps a single straight-line vector phase when the same
structural verifier accepts its region boundaries. It preserves compute order and the lane variable identity. The lane binding moves
into the region only when no outside statement uses it. Trailing SHARED calls
before the next compute phase or return belong to the vector phase. Interleaved cube/vector phases, control flow,
rebalanced lane_stride boundaries, and migrated boundary axes retain the flat
lowered form. Fallback retains halving, offset localization and boundary checks.
AivSplitValid validates source authoring before this pass. This pass produces
AivSplitLoweredValid, which accepts retained/synthesized regions and the flat
lowered fallback. ExpandMixedKernel requires and then invalidates that property.
The out-of-region contract (manual mode)¶
"Emitted full-width" describes what this pass does with an out-of-region
statement, not what an author may write there. A function opening at least
one region enters manual mode: the regions own vector placement, and the
AivSplitValid verifier enforces that division well before
this pass runs:
| op / value | inside a region | outside every region |
|---|---|---|
| vector compute | AIV | rejected — check (e) |
tile.load / tile.store |
AIV | allowed (compiler-materialised) |
| cube compute | rejected — check (a) | AIC |
aiv_shard / aic_gather |
the boundary | rejected — check (c) |
pld.system.notify |
pinned to AIV | duplicated onto both (not diagnosed) |
So outside a region this pass sees only cube work, the tile.load /
tile.store pairs ConvertTensorToTileOps hoists out, and core-agnostic
scalar / control-flow statements — never full-width vector compute, which must be
wrapped in for _ in pl.split_aiv(2, mode=pl.SplitMode.NONE):. The multi-mode
goal is regions only, one per vector phase; a function with no region is
untouched. Checks (f)/(g) add that a tile crossing a region edge must name the
crossing, so no implicit cube↔vector crossing reaches this pass.
The last row is documented, not enforced, and region placement does not make a
region mean "exactly once": sharding a once-only side effect across the AIV
sub-lanes is the author's job, as is the lane rule for a None-region V→C
crossing (Scopes and Placement).
Region placement consumed by ExpandMixedKernel¶
The region itself carries placement until ExpandMixedKernel. That pass erases
wrappers once per function and records eligible calls in a pass-local map over
the consumed body. No placement or validation attributes are serialized.
| intrinsic affinity | region effect |
|---|---|
SHARED, no stated lane, set_no_duplicate() (pld.system.notify) |
AIV only |
duplicate-safe SHARED (pld.system.wait) |
retains both lanes |
VECTOR |
already AIV |
stated lane (tile.create, explicit core_type) |
preserves declaration |
MIXED boundary |
keeps both transfer endpoints |
CUBE compute |
rejected inside a region |
This applies equally to synthesized AUTO regions and manual regions, including pure-AIV functions. It does not make a side effect execute once across the two AIV sublanes; lane-index dispatch remains the author's responsibility.
A function-level AUTO split and explicit pl.split_aiv regions are mutually
exclusive, enforced at OutlineIncoreScopes
while the scope's split_ and its regions are both still visible; use
optimizations=[pl.cross_core_slot(slot_num=N)] to size the pipe without
annotating a split.
Three region body shapes are handled, selected by the region's split_ mode:
- Data-parallel, full-width body (
UpDown/LeftRight, no explicit boundary op): the region body holds full-width vector compute. The region path injects a per-regionsubblock_idx, routes the vector ops through the sharedsplit_axis::ProcessStmtshalving machinery (region-scoped), and validates the per-region transpose hazard. This is the paradigm the auto-converged form produces. - Data-parallel, explicit boundary body (
UpDown/LeftRightwithtile.aiv_shard/tile.aic_gatheralready present): the user manually sharded the cube tile and wrote the vector compute on the per-lane half, so the body is already in half-width form. The region path detects this (RegionBodyHasExplicitBoundary) and splices the body through unchanged. Re-halving would double-shard — a downstream Acc→Vec move misread as a fresh cube→vector boundary and rewritten to a secondaiv_shard— orphaning a halved Acc memref and crashing PTO codegen.ExpandMixedKernelfolds the boundary intotpush/tpopas for a hand-authored split_aiv kernel. - Task-parallel body (
None): no split axis — both AIV lanes run the full body for disjoint work the author dispatches viaaiv_id. The body is spliced through unchanged (no halving, no offset localization, no injectedsubblock_idx; the author'saiv_id = get_subblock_idx()already carries the lane).tile.aiv_shard/tile.aic_gatherare accepted here: with no split axis they cross the boundary without splitting, andsplit=0preserves the shape. Nothing is validated here either — everything is full width. The function is still stampedsplit_aiv, soSplitVectorKerneldispatches it to both AIV lanes (dual_aiv_dispatch) rather than the lane-0-only replay; both therefore push on a V→C crossing, into one shared slot with no arbitration, so the cube receives an unspecified one of the two values unless the author keeps it lane-uniform. Use this mode when the tiles cannot be halved or a reduction must stay full width.
What may appear inside an explicit-boundary region¶
Whatever the author writes. The body is spliced through unchanged, so
every vector op in it must already be per-lane — one left at full width runs
identically on both AIV lanes — but establishing that is the author's
contract. Writing tile.aiv_shard / tile.aic_gather by hand is the
statement "I sharded this myself", and the pass takes it at its word. A body
mixing the boundary op with a full-width vector op compiles; a later assemble
/ store at a lane offset then double-counts. That is the cost of the mode.
An earlier revision ran ValidateSplitBody here as a hard ValueError, but the
scan proves intent, not extent — it already trusted the author on
tile.store offsets and on whether a lane-strided read really partitions. As a
gate it rejected correct kernels (a lane-invariant broadcast operand; a shard
arriving through a construct the forward scan cannot follow) while still
admitting incorrect ones, so it did not deliver the safety that justified
blocking authoring. The compiler-halved paths keep the same scan, where a
full-width leftover means the pass's own postcondition failed:
| Path | Who halved the body | Check |
|---|---|---|
| AUTO whole function | the pass | INTERNAL_CHECK_SPAN |
Region, UpDown/LeftRight, no boundary op |
the pass | INTERNAL_CHECK_SPAN |
Region, UpDown/LeftRight, author wrote the boundary |
the author | none |
Region, None (task-parallel) |
nobody — all full width | none |
Independent checks still reject a manual region: the per-region transpose
hazard (a tile.transpose swapping the split axis cannot be typed at any
lane — a representability limit, not a claim about intent),
AivSplitValid's checks (a)–(k) twelve passes earlier, and
the AivSplitLoweredValid handoff to ExpandMixedKernel.
Because the region is built via the generic BeginScope/EndScope and is
non-outlined, it can be nested inside a pl.range / pl.pipeline loop or an
if; the region path recurses into compound statements to lower every region while
preserving the surrounding control flow.
Per-lane scattered gather¶
pl.gather_row is the only op that reads GM at an arbitrary runtime offset, so
it is how a paged/top-k row set is sharded across the two AIV lanes: each lane
assembles half the tile in UB and pl.aic_gather hands the reassembled tile to the
cube.
with pl.at(level=pl.Level.CORE_GROUP, name_hint="sparse_kv",
allow_early_resolve=True): # see the ring note below
for aiv in pl.split_aiv(2, mode=pl.SplitMode.UP_DOWN):
ub = pl.full([64, 512], dtype=pl.BF16, value=0.0) # per-lane HALF extent
for k in pl.range(64):
src = pl.cast(pl.read(idx, [aiv * 64 + k]), pl.INDEX)
ub = pl.gather_row(ub, pool, [k, 0], [src, 0], [1, 512]) # lane-derived src_offset
kv = pl.aic_gather(ub) # V2C -> [128, 512] in Mat
out[0:16, 0:128] = pl.matmul(q, kv, b_trans=True, out_dtype=pl.FP32)
Two authoring rules make this work:
- Write the accumulator at the half extent.
pl.fullis a generator, accepted at whatever extent you give it and never joining the half-width dataflow on its own. The gather is admitted on its lane-derivedsrc_offset, and the guard proves intent, not extent — so a full-extent accumulator would be gathered back to2 x FULLand mismatch downstream. - Watch the cross-core ring. The V2C ring reserves
slot_size x slot_numbytes of the consuming core's memory (L1 for V2C, UB for C2V), whereslot_sizeis the full tile the consumer pops (128 x 512 x 2 = 131072here) andslot_numdefaults to 2 — 256 KB of a 512 KB L1, which a kernel pushing once per invocation does not need to exceed.pl.cross_core_slot(slot_num=N)retunes it in either direction; grow it too far andAllocateMemoryAddrreports the overflow. Automatic pipe setup checks the finalized pop/free schedule: retaining two gathered Mat tiles before either is released requires at least two slots. An insufficient depth is rejected; increasecross_core_slot(slot_num=N)or place/consume the first tile before gathering the next. Repeated sequential gathers do not require additional slots once their preceding tiles are freed. Hardware releases the oldest FIFO slot, not an arbitrary named tile. The finalizer therefore delays a later tile's release until all earlier pops on that pipe have finished, and admission counts this repaired schedule. A nested scope that would release an enclosing retained tile out of order is rejected; place/consume that tile before entering the nested handoff.
pl.aiv_shard is not an alternative to the half-extent pl.full here: it is
the C→V transfer and needs an Acc operand, so it cannot shard a value the
vector lane produced itself.
Regions must be scope-free¶
Region lowering recurses into ForStmt / WhileStmt / IfStmt / SeqStmts but
deliberately not into a ScopeStmt: a scope carries outlining and
name-visibility semantics that region-local halving must not reach through. Every
region must therefore already be scope-free when this pass runs — normally
guaranteed by OutlineIncoreScopes (pass 9), which
lifts the enclosing InCore scope into its own function.
Pass 8 outlines scopes only out of Opaque / Orchestration functions, so a
scope standing inside a function declared pl.FunctionType.InCore reaches this
pass intact. The scope has to be author-written — the parser adds none of its
own there, emitting a top-level region bare so that a printed *_incore_0
reparses to the same IR, and authoring a region in such a function at all is
rejected earlier by AivSplitValid check (h):
@pl.function(type=pl.FunctionType.InCore) # pass 8 skips this function
def f(self, a: pl.Tensor[[128, 128], pl.FP32],
c: pl.Out[pl.Tensor[[128, 128], pl.FP32]]) -> pl.Tensor[[128, 128], pl.FP32]:
with pl.at(level=pl.Level.CORE_GROUP): # not outlined
for aiv_id in pl.split_aiv(2, mode=pl.SplitMode.NONE):
base = aiv_id * 64
c = pl.store(pl.exp(pl.load(a, [base, 0], [64, 128])), [base, 0], c)
return c
After lowering, LowerExplicitRegionFunction re-scans the body and rejects any
region hidden behind a non-split scope with a ValueError pointing at the pl.split_aiv line. Drop
the redundant scope, or use plain @pl.function / @pl.jit (Opaque) so pass 8
outlines it.
The re-scan then rejects any other surviving ScopeStmt too, covering the
mirror case: a scope nested inside a region body. The split wrapper itself is allowed, so the first check passes — yet the inner walks (LowerStmts,
CheckNoCubeTileHalved, AnalyzeSplitBody) step over the scope rather than
entering it, and the vector ops inside would be spliced out full-width with both
AIV lanes computing the whole tile. Unreachable from the DSL (pass 8 lifts a
with pl.at(...) inside a region into its own function, and check (h) rejects
authoring a region in an InCore function the outliner did not produce), so it
guards IR that skips pass 8 — hand-built, or a deserialized .pto.
Split-axis dispatch¶
SplitMode (int) |
Split axis | Vector sub-region halved on |
|---|---|---|
None (0) |
— (no split axis) | nothing — task-parallel; tiles stay FULL, aiv_id dispatches both lanes |
UpDown (1) |
dim 0 (height) | rows |
LeftRight (2) |
dim 1 (width) | cols |
SplitDimension(mode) returns 0 for UpDown, 1 for LeftRight
(split_axis_utils); it is not called for None (the region path branches on
None first — there is no axis to derive).
Split mode vs pto-isa split code¶
SplitMode names the axis the author picked. The split attr that reaches the
device names something narrower — pto-isa's TileSplitAxis, which also says how
the two lanes' runtime extents relate, because that is how the consumer finds
lane 1's band inside the FIFO slot (popVecTileFromGMFiFo):
| Code | pto-isa | Lane 1's band starts at | Requires |
|---|---|---|---|
| 0 | TILE_NO_SPLIT |
— | single reader |
| 1 / 2 | TILE_UP_DOWN / TILE_LEFT_RIGHT |
e1 * pitch |
e0 == e1 |
| 3 / 4 | TILE_UP_DOWN_ODD / TILE_LEFT_RIGHT_ODD |
(e1 + 1) * pitch |
e0 == e1 + 1 |
The two vocabularies live on different ops:
tile.aiv_shard/tile.aic_gathercarry the MODE (0/1/2). This pass stampsint(mode)— the author's axis, nothing more.tile.tpush_*/tile.tpop_*/system.tfree_*carry the CODE (0..4), chosen by ExpandMixedKernel from that mode plus the full-width tile's extents (split_axis::ShardSplitCode) when it folds the boundary into a transport pair. PTO codegen prints it verbatim as{split = N}.
An odd split axis reaches the odd codes in two ways, and the halving treats both
identically: ComputeHalfDimSize gives BOTH lanes the ceil half as their
physical box, and the per-lane valid extent carries the raggedness.
| Boundary tile | Lane boxes | Lane extents | Code |
|---|---|---|---|
[17, 128], fully valid |
[9, 128] |
9 / 8 | 3 |
[16, 128], valid [15, 128] |
[8, 128] |
8 / 7 | 3 |
[16, 128], fully valid |
[8, 128] |
8 / 8 | 1 |
Partially-valid operands across the boundary¶
A crossing value whose valid_shape is short of its physical box is a kernel's
ragged tail. The Cube→Vector FIFO pins the transported column extent to the
physical one and leaves the row extent free (derivation and ISA references:
PTO codegen). A narrowing on the split axis is
what makes an extent per-lane — lane L holds clamp(V - L*half, 0, half) — so
which mode is ragged decides which field has to carry it:
The table is the shard's (Cube→Vector) contract; aic_gather follows the
geometric rule at the end of this section instead.
| Ragged axis | Mode | Extent is | Carrier | Status |
|---|---|---|---|---|
| rows (split axis) | UpDown |
per-lane | TPOP valid_row operand |
supported when the lanes are placeable (below) |
| cols (non-split) | UpDown |
shared, static | full-box transport + static pto.treshape |
supported |
| rows (non-split) | LeftRight |
shared, static | TPOP valid_row operand |
supported |
| cols (split axis) | LeftRight |
per-lane | none | rejected |
| cols, runtime-valued | either | shared, dynamic | none (treshape takes no operands) |
rejected |
| rows, runtime-valued | UpDown |
per-lane, dynamic | the boundary op's FULL box + the first consumer's valid_shape |
supported (see the note below) |
| rows per-lane and cols narrowed | UpDown |
both | none (treshape rewrites both axes) |
rejected |
ReshapeSplitAxis can only ceil-halve the split-axis extent (the lane index is
not part of an op's type function). LocalizeExplicitBoundaryValid repairs that
guess here, where the region's aiv_id is in scope, and carries the per-lane
extent to consumers that pass valid_shape through; one that reshapes the
logical rectangle is rejected with its span. The AUTO arm applies the same extent repair through
LocalizeShardValidForLane; a body whose shard has a runtime extent then goes
through DeferAutoRuntimeShardExtents, which gives that shard the full-box pop and
guards a store whose lane may be empty, as the region form does, while its consumers
and loop carries keep the types the halving already gave them. A store whose result is
read afterwards (a chained store, a loop yield, a return) is the exception: guarding it
would leave that result conditionally defined, so pl.split leaves it unguarded as before.
- The two lanes' extents must be placeable. The split-axis extent is not a
free field: pto-isa derives lane 1's band from the popped tile's own valid
extent —
e1cells in (TILE_UP_DOWN) ore1 + 1(the_ODDmodes). So the placeable shapes are exactlye0 == e1(even code),e0 == e1 + 1(odd code) ande1 == 0(lane 1 pops nothing, so its band is never dereferenced — the even code stays exact). The box partition does not guarantee that for a ragged boundary — 13 of a 16-row box gives 8 and 5 — which is what the balanced partition below is for. When it does not apply,ShardSplitCodereports the extents instead, naming what would work. - A runtime split-axis valid extent pops the FULL box. The split code is a
compile-time attr, but which one the lanes need depends on their runtime
extents: 12 of a 16-row axis leaves them at 8 and 4, 16 leaves them at 8 and 8.
No code is right for both, so the boundary op does not carry a per-lane extent
at all —
LocalizeExplicitBoundaryValid(region form) andDeferAutoRuntimeShardExtents(pl.split) give it the full box (split_axis::WithFullSplitAxisValid) and the lane's extent rides on its consumers. That pairs exactly with the even code: the producer lays its rows at their box positions, so lane 1's band sits at the box half and the even code points there, whatever the extent turns out to be. Confirmed on a2a3 for every extent 1..16 of a 16-row boundary. The pto-isa pop does place lane 1 at the popped tile's own extent, as its source reads — the earlier measurement that suggested otherwise came from a probe whose operands were uniform constants, which makes every row and column of the product identical and any band offset indistinguishable. - The same ROW extent on a hand-written
tile.tpop_from_aicis still misplaced.SplitVectorKernel's halving localizes a declaredvalid_shapeonto the pop itself, where pto-isa reads it as the band offset. Widening only the pop does not fix that path: its consumers inherit the author's declaration and then write partial destinations out of a full source, which measures worse on device. The xfailing params oftests/st/runtime/cross_core/test_cross_core_split_parity.pyrecord the regime this affects:half < V < boxonUP_DOWN. - A narrowed COLUMN extent is rejected on every path. It has no carrier at
all — the slot is written at the producer's physical column pitch while the pop
rebuilds its geometry from the tile's own
validCol— and onLeftRightit is the split axis, so it would have to be per-lane.CheckSplitBoundaryCarriesValid(src/ir/op/tile_ops/cross_core.cpp) owns that contract; it runs both in the boundary op's deduction and fromShardSplitCode, so a hand-writtentile.tpush_to_aiv/tile.tpop_from_aicpair is held to it too. - An empty lane's store is guarded. A lane the ragged extent does not reach
has extent
0, and a zero-rowTSTOREis outside pto-isa's contract (TSTORE_IMPLassertsGetValidRow() > 0). The store gets a runtimeextent > 0guard;tpopandtfreestay unconditional — both lanes occupy a slot and both must release it. - The gather is limited by geometry, not the DMA. A V2C pop lands in an NZ
Mat tile (
TLoadGm2L1Nd2nz), which reads no valid extent at all. What limitsaic_gatheris placement: lanelsits at offsetl*half, so the joined data[0, v0) ∪ [half, half + v1)is a rectangle only when the bands abut. That rule is enforced in this pass, not in the deducer, which runs before the per-lane extents exist and could only judge the join on its own ceil-div guess. A gather fed by a localized shard is typed exactly — the bands always abut, so the joined extent is the pre-shardV— and only a partial that both lanes share is rejected. The same geometry is why the gather has no odd form:GatherSplitCodealways returns an even code and rejects a boundary whose lanes would differ (pad the axis and narrow the cube-side result instead).
Balancing a ragged boundary across the lanes¶
The split partitions the split axis between the lanes: lane L owns
[L*S, L*S + S) of it. Two partitions are possible, and they differ only when
the boundary tile is ragged:
| Partition | S |
Lane extents for a [16, …] box valid to 13 |
Placeable? |
|---|---|---|---|
| box (default) | ceil(box / 2) = 8 |
8 and 5 | no |
| valid (rebalanced) | ceil(V / 2) = 7 |
7 and 6 | yes — TILE_UP_DOWN_ODD |
The box partition is universal: it works for every tile in the region whatever
its valid extent, which is why it is the default. What it cannot do is keep the
lanes within one cell of each other, and that is exactly what the transport
needs (see the placeability rule above). Balancing the VALID region instead
makes e0 - e1 ∈ {0, 1} by construction — and splits the real work evenly
rather than leaving lane 1 the remainder.
split_axis::ResolveLaneStride runs over the body before it is rewritten and
returns the balanced stride only when it is both needed and sound:
- at least one Cube→Vector boundary, all agreeing on the same static
(box, valid)on the split axis, withceil(V / 2) < ceil(box / 2)(otherwise the box partition already balances); - no Vector→Cube boundary (the gather re-joins the lanes positionally at their own extents, which only abut when they are equal);
- every other split-axis tile in the body derives from that boundary — a tile
with an independent origin (a
tile.load, a generator) spans the FULL box, which the balanced partition would not cover.
Anything else keeps the box partition. The stride then threads through the whole
halving — offsets (AdjustOffsets), per-lane valid extents
(LocalizeValidDimForSplit), the shard's own type
(LocalizeShardValidForLane) — while the physical box stays ceil(box / 2), so
buffers and slot sizes are unchanged. It is stamped on the boundary op as
lane_stride=S so ExpandMixedKernel derives the
transport code from the same partition. A tile.reshape that migrates the split
axis inside a rebalanced body is rejected: a migrated axis carries its own half,
which cannot express a partition balanced on another axis.
# 13 of 16 valid rows, UP_DOWN. Lane 0 takes rows 0-6, lane 1 rows 7-12.
popped: pl.Tile[[8, 128], pl.FP32, pl.Mem.Vec,
pl.TileView(valid_shape=[pl.min(pl.max(13, aiv_id * 7) - aiv_id * 7, 7), 128])
] = pl.tile.aiv_shard(qk, split=1, lane_stride=7)
out_store = pl.tile.store(popped, [0 + aiv_id * 7, 0], out_0)
Explicit pl.split_aiv regions are never rebalanced: their per-lane offsets are
the author's own (out[aiv_id * HALF : ...]), and the compiler must not
partition the data differently from the way the region indexes it.
Algorithm¶
LowerFunction rewrites one mixed InCore function:
1. split_dim = SplitDimension(mode); the boundary op carries int(mode).
2. InjectSubblockIdx(func, is_aiv=true) prepends
subblock_idx = tile.get_subblock_idx()
to the body (fresh name if 'subblock_idx' is taken).
2a. ResolveLaneStride(body, split_dim) picks the partition: the balanced
ceil(V / 2) for a body that is one ragged crossing, else null (the box
partition). It threads through every offset / per-lane extent below.
3. LowerStmts walks the flat body:
Boundary tile.move (ClassifyMoveDirection):
CUBE_TO_VECTOR — replace the move with
tile.aiv_shard(full_cube_tile, split=int(mode)[, lane_stride=S]) -> HALF
The deduced HALF type already carries the consuming-lane memory
(Vec): the split deducer leaves memory_space null and
OpRegistry::Create fills it from tile.aiv_shard's set_output_memory
declaration, shared with the explicit form. Seed it into tile_vars (its half
extent) and record the old->new var rebind. The cube source (the
matmul / Acc result) stays FULL.
VECTOR_TO_CUBE — insert
tile.aic_gather(half_vector_tile, split=int(mode)) -> FULL
resolving the source to its halved var so the gather doubles
HALF -> FULL in Mat. For a Mat destination, bind the gathered tile
directly: a second Mat->Mat move is unsupported and would hide the
consumers from FIFO lifetime tracking. For another cube memory space
(e.g. Left/Right), keep the original placement move on the gathered
FULL tile (named "<dest>_mat" so ExpandMixedKernel's V->C boundary
names its synthesized tpop after it). The gathered tile stays live
through its final consumer, including extracts inside AutoTile's K loop.
Affinity gate (ClassifyCallAffinity):
VECTOR-affine leaf — route the single statement through
split_axis::ProcessStmts({stmt}, ..., is_aiv=true): the SAME machinery
the deleted SplitVectorKernel driver used. Halves tile.load /
tile.store / tile.slice / tile.reshape / compute results on split_dim,
localizes offsets per subblock, tracks halved vars in tile_vars.
CUBE-affine leaf — passed through FULL, never halved.
ForStmt / IfStmt — recurse into the body for vector content.
4. CheckNoCubeTileHalved re-walks the rebuilt body and asserts no CUBE-affine
op consumes or produces a tile in tile_vars (the affinity gate must never
leak a halved tile into a cube operand) — INTERNAL_CHECK on failure.
5. transform_utils::Substitute applies var_replacements; DeepClone detaches
shared sub-trees.
6. WithSplitAivAttrs stamps split + split_aiv (dropping any prior split /
split_aiv / dual_aiv_dispatch entries).
The per-op vector halving (shape halved on the split axis, offset localized by
subblock_idx * half, tile.slice static-shape-arg halving in lockstep with
the result type, rank-1-load reshape sliced per lane, reduce-on-split-axis
rejected, singleton split-dim preserved, loop iter_arg/return_var
tracking) is all produced by split_axis::ProcessStmts / ProcessStmt —
documented in detail in the shared machinery; the same facts are exercised by
tests/ut/ir/transforms/test_lower_auto_vector_split.py.
Automatic halving rejects the root generators tile.ci and tile.random when
their split dimension is non-singleton. Their generated values depend on
position, so changing only the result type is insufficient: a correct rewrite
also needs lane-specific shape and generator state, which this pass does not
synthesize. Move the operation outside the automatically-halved split region.
Singleton split dimensions and already-half-width explicit-boundary regions
remain unchanged.
Halving a result is sound only under two conditions, asked in this order because the first subsumes the second wherever it can speak:
| # | Condition | Decided by | Catches |
|---|---|---|---|
| 1 | the halved node still type-checks against its own arguments | the operator's own f_deduce_type — no metadata |
a full-width tile.add operand, a rank-1 bias on the split axis, row_argmax's shape-matched tmp, tile.transpose_view's axis permutation |
| 2 | an operand condition 1 is blind to is lane-local, broadcast along the split axis, or declared lane-invariant | a registry declaration | tile.sel / tile.sels full-width masks: coverage does not establish lane origin |
Condition 1 — the halved node still type-checks¶
After shape-argument rewriting, the pass re-deduces the actual per-lane arguments
the trailing Substitute will install. Rejection or a physical shape mismatch
fails the check; valid regions follow the operator's contract.
set_pointwise_validity_args groups data with matching element indexing and units.
BroadcastValidRegionsAgree proves broadcast-compatible validity, including equivalent expressions.
Empty groups defer to deduction: partial-combine copies sole valid inputs without
widening validity, predicates require byte coverage, and tile.assemble requires
a rectangular union. Partition checks cover all data and predicates, excluding
declared workspaces and lane-invariant operands. Workspace shape and dtype still
require actual per-lane deduction. Replicated singleton computation also checks
pointwise validity; scratch writes require actual lane-private Vec storage.
The physical-shape probe asks the operator itself, so it needs no per-operator split mapping and follows changes to the operator's contract. It rejects the ordinary case:
def split_auto(vec: pl.Tile[[256, 128], pl.FP32, pl.Mem.Vec], ...):
vec_h = pl.tile.add(vec, vec) # rejected: `vec` is full width
vec is a full-width InCore parameter, so nothing inside the region partitions
it. Halving only the result would emit
tile.add([256, 128], [256, 128]) -> [128, 128], a node whose declared shape
contradicts type inference over its own arguments; it does not survive
print→parse and misleads every later consumer that re-derives types from
operands. Derive the per-lane half first (tile.load / tile.slice inside the
region, or an explicit pl.tile.aiv_shard), or keep a value both lanes share
outside the region.
The same check decides cases a shape heuristic gets wrong in both directions.
Operands align to the result from the right, matching BroadcastShapes:
tile.add([256, 128], bias) |
UP_DOWN (dim 0) |
LEFT_RIGHT (dim 1) |
|---|---|---|
bias: [128] |
accepted — broadcasts over the split axis | rejected — BroadcastShapes([256, 64], [128]) fails |
bias: [1, 128] |
accepted — singleton on the split axis | rejected — its dim 1 is full width |
bias: [256, 128] |
rejected | rejected |
and it covers an axis-permuting operator, which no right-alignment rule describes:
def split_auto(data: pl.Tile[[1, 256], pl.FP32, pl.Mem.Vec], ...):
viewed = pl.tile.transpose_view(data) # rejected: [128, 1] vs the [256, 1] deduced
tile.transpose_view swaps the trailing two dims, so the halved [128, 1]
result contradicts the [256, 1] its own operand deduces. Unlike
tile.transpose, this is a pure view and is outside FindTransposeSplitHazard,
which matches tile.transpose only.
Shape arguments are normalized before deduction. For tracked tile.reshape,
PlanReshapeSplit converts capacity and logical step independently before
rewriting the target shape. Identity views preserve layout; other views require
an unboxed, unstrided row-major source or a linear column-major vector.
Column-major matrices, boxed layouts and explicit strides need a separate
partition mapping and are rejected for automatic non-identity reshapes.
Untracked reshape / reinterpret_view sources retain a full view followed by
a per-lane tile.slice, so both lanes do not read the first half. A dynamic
half extent cannot materialize that slice and is rejected.
Condition 2 — operands type deduction is blind to¶
A deducer that accepts an oversized operand without changing its result does not prove that operand's lane origin. The pass probes this per call, with no metadata: it re-deduces once more with the operand doubled on the split axis. If deduction throws or the result changes, the deducer pins that operand and condition 1 already decided it. If the answer is unchanged, the operand is blind, and only a declaration can say whether full width is in contract.
tile.sel derives the data shape from BroadcastShapes(lhs, rhs). Both
tile.sel and tile.sels validate that packed predicate bytes cover the data's
valid region. Coverage permits an oversized mask and does not establish which
lane owns its contents: both lanes could still read the first mask rows. A
full-width mask can therefore pass deduction but fail the partition or
blind-operand check. Neither operator declares its mask lane-invariant; their
tmp operands are workspace. Unclassified operands are treated as lane data.
Restricting this to blind operands is what keeps it in its lane. Without it,
every operator with a legal full-width operand would need a registry declaration
purely to suppress a check that had no business running — which is how
tile.rsqrt came to declare a scratch its own deducer requires to match the
input exactly. tests/ut/ir/operators/test_lane_invariant_arg_coverage.py
measures which operands are blind, fails on a declaration the deducer makes
unreachable, and pins the blind set so a new operator or a loosened deducer has
to be classified rather than silently falling through.
Blind is usually a symptom, and the deducer is usually the cure. An operand
lands in the blind set when the deducer does not constrain its extent enough — often
just an under-validated contract rather than a genuinely free dimension. Then
the fix is to enforce the relation the operator already documents, not to
declare the operand lane-invariant: the check now rejects a mis-shaped operand
for every caller, and the split pass gets a pinned answer for free.
tile.scatter_update's index and src were blind because deduction only
copied the input's type; enforcing what the tensor path's lowering already
asserts (src rows == b * s, matching widths) pinned both. A declaration
would have suppressed the split check and left the shape bug in place.
The test for a declaration is positional correspondence: does output element
i read operand element i? Two kinds of operand answer no.
Hardware scratch. A full-width workspace is in contract, and each lane simply declares the whole thing:
scratch = pl.tile.create([256, 128], dtype=pl.FP32, target_memory=pl.Mem.Vec)
sums = pl.tile.row_sum(v, scratch) # accepted: [128, 128] x [256, 128] -> [128, 1]
tile.row_sum documents its tmp_tile as scratch at least as large as the
input, never reads it in DeduceTileRowReductionType, and declares it with
set_lane_invariant_arg(1). tile.create is registered CoreAffinity::SHARED,
so the affinity gate deliberately passes it through full
width and never tracks it — matching on the extent alone would reject the whole
rms-norm reduction shape.
Its sibling tile.row_argmax needs a tmp shaped exactly like the source, and
DeduceTileRowReductionType enforces that with require_exact_tmp_shape. It
therefore carries no declaration: condition 1 rejects a full-width tmp on its
own, and a declaration would claim a contract the operator does not grant.
An operand addressed at absolute indices.
indices = pl.tile.load(idx, [0, 0], [256, 128], target_memory=pl.Mem.Vec)
picked = pl.tile.gather(src, indices, tmp) # accepted: src stays [256, 128]
tile.gather computes out[i] = src[indices[i]] and takes its result shape from
indices. The index values are absolute into src, so halving indices already
gives each lane its own half of the output while both read the whole table —
that is the correct lowering, not a missing shard. tile.gatherb is the
byte-offset form of the same shape. tile.scatter and tile.scatter_update are
the write-side dual, addressing a destination by absolute index.
The declaration permits a full-width operand; it does not keep one. Nothing stops the operand's own producer from being halved, and the kinds differ on what that means — which is why the registry records which kind applies:
| Kind | Full width | Producer partitioned | Example |
|---|---|---|---|
Scratch |
allowed | fine — a halved input takes a halved scratch with it | row_sum's tmp_tile |
IndexAddressedSource |
allowed | rejected — indices stay absolute, so lane 1 reads the wrong half | gather's src |
AbsoluteIndexedDestination |
allowed | rejected — the write side of the same defect | scatter's dst |
tile.scatter writes dst.flat[indexes[i, j]] = src[i, j] with flattened
offsets into the whole destination, so a column write encoding i * dst_cols + c
stops matching the moment the row stride halves.
src = pl.tile.load(tbl, [0, 0], [256, 128], target_memory=pl.Mem.Vec)
picked = pl.tile.gather(src, indices, tmp) # rejected: the table was halved
This is the ordinary path rather than a corner: pl.gather over a tensor lowers
to a tile.load of the table feeding tile.gather, so an author who gathers
inside a split region hits it by default. Neither condition above sees it — the
operand is tracked, so condition 2 skips it, and tile.gather deduces its result
from indices alone, so condition 1 is satisfied. That is why the absolute-index
kinds stay declared even where the deducer does read the operand: the property
is not type-expressible in either direction. Produce the table outside the
split region and pass it in; keeping such a producer at full width automatically
is not implemented.
The absolute-index check runs before the halving path, not beside it, because
the trailing Substitute rewrites the argument even where nothing is halved:
- a singleton result returns early —
gatherwith[1, N]indices yields a[1, N]result, so the result is untouched while the table under it is still halved; - a tuple result takes its own path (below), because the generic block follows
one
result_split_dimand a tuple has one per element.
Tuple results — one logical partition per element¶
tile.gather_compare returns row indices and per-row counts. For 32-bit counts,
their physical shapes are [rows, out_cols] and [1, align_up(rows, 8)]; the
count's valid_shape is [1, valid_rows]. A row split therefore partitions the
indices on dim 0 and the counts on dim 1, with independent physical capacities.
ProcessStmt obtains the tuple type from actual per-lane arguments. Partition
proof compares the original and deduced types for each element; it does not
re-deduce the call or halve the old result again. It first matches the change in
effective valid_shape to the input's logical partition, even when aligned
capacity is unchanged. A physical-halving fallback requires the relevant input
and output axes to be fully valid. Each element needs exactly one proven
partition; unchanged shape and validity prove none.
Thus LEFT_RIGHT tile.gather_compare remains unsupported: splitting source
columns does not partition either output's row-based result.
The emitted tuple retains the exact deduced type, including physical capacity
and per-lane valid_shape; HalveTileShape is not applied to the old elements
again. For a fully valid source split UP_DOWN:
| Source rows | Full count capacity | Count capacity per lane | Valid counts per lane | Logical step |
|---|---|---|---|---|
| 8 | 8 | 8 | 4 / 4 | 4 |
| 10 | 16 | 8 | 5 / 5 | 5 |
| 18 | 24 | 16 | 9 / 9 | 9 |
TileInfo records each element's split_dim, actual per-lane capacity in
half_dim_size, and logical offset stride in logical_step. Proven logical
partitions record the step even when it equals capacity; a null step resolves
to the ordinary body stride or physical-half convention and still requires
consumer checks. Validity stays in the tile type's valid_shape.
LocalizeStoreOffset uses lane * logical_step on that element's own axis,
so the ten-row count example stores at offsets 0 and 5 rather than 0 and 8.
Tuple containers retain shared immutable per-element facts in
TileInfo::tuple_elements. RetypeTrackedBinding propagates the replacement
type and split facts for tile aliases, tuple aliases, and bound tuple
projections in both lowering arms.
OperandSplitInfo also resolves inline projections such as
pl.tile.store(pair[0], [0, 0], out). BuildHalvedCallArgs and
ReplacedOperand rebuild them over the replacement tuple, so probes and emitted
consumers see the same per-lane type. Split queries must use this helper:
| Consumer | Facts needed |
|---|---|
| generic operations and the absolute-index gate | partition status and axis |
LocalizeStoreOffset, slice and view rewrites |
capacity, logical step and axis |
GetFirstTileArgMemory |
replacement operand type for affinity |
| V→C boundary | replacement operand and a transport-compatible partition |
| loop carries and branch merges | tuple type and per-element partition agreement |
RepairIfReturnVars, RepairIterArgs, and RepairReturnVars preserve both the
deduced tuple type and element facts. Branches and loop backedges must agree on
capacity, validity, axis, and effective logical step. A tuple's type alone
cannot recover the count stride when capacity includes padding.
Type deduction can also refuse per-lane arguments: tile.tquant_mx constraints
may fail after splitting, or tile.tquant_mx_raw may receive scratch sized for
the full source. The diagnostic preserves the operator's refusal. Automatically
resizing such a preallocated workspace is unsupported. If deduction succeeds
but no partition can be proven for an element, lowering rejects that tuple.
Condition 2's blind-operand check follows one result axis and does not run on
this tuple path. Per-element discovery covers the primary partitioned input;
secondary operands whose extents affect no result still require a sound operator
contract and review through the blind-operand inventory. For example, a position
that is scratch only at some arities cannot have an unconditional declaration:
tile.mrgsort_format2's tmp_or_src2 is workspace for a 2-way merge but a sorted
input for a 3/4-way merge. Its deducer checks the actual workspace position.
Carries, merges and dropped axes¶
Four places rewrite state around the halving rather than in it, and each must follow the same axis and tracking rules or the conditions above misfire:
-
A store reached as the return expression.
LocalizeStoreOffsetmoves atile.storeof a tracked tile to this lane's half, and the statement shape decides whether it is reached.return pl.tile.store(v, [0, 0], out)is ordinary DSL — nothing normalizes it into an assignment — yet it is neither anAssignStmtnor anEvalStmt, and the AUTO arm's affinity gate skips it too (no leaf call of its own).Substitutestill swaps in the halved tile, so both lanes wrote the same rows from different data.LocalizeReturnStorescovers it from both arms; binding the store to a name first was never meant to be load-bearing. -
Loop carries. A carry has three edges, and all three must agree. An
iter_arginherits its init value's tracking, so a halved init makes the carry lane-local; the loop-exitreturn_varinherits it in turn so a latertile.storegets the per-lane offset. Both the explicit and the AUTO affinity-gated arms shareRepairIterArgs/RepairReturnVars— the AUTO arm recurses through its own walk and cannot reach the explicit path'sForStmtbranch, so without this a legal tile accumulator has its carry reported as a full-width operand. - The loop backedge. The third edge is the value the body yields back into
the carry, and
ValidateCarryBackedgeis what checks it: the yielded value must be lane-local exactly when the carry is. A halved carry fed a full-width value declares a per-lane type that contradicts the value flowing into it on every iteration after the first — the same defect as an un-sharded operand, which no operand check sees because those inspect aCall's arguments and this is aYield. The check is symmetric, so a lane-local value yielded into a full-width carry is rejected too. It validates rather than repairs: when the yielded value is tracked the trailingSubstitutealready swaps in its halved replacement, and when it is not there is no halved version to substitute.
An unbound backedge — pl.yield_(pl.tile.add(acc, acc)) — reaches the same
rejection by a different route, and gets its own message. Nothing hoists an
expression passed to pl.yield_, so the call stays inline in the Yield where
this pass, which halves statements, never reaches it. The mismatch wording
above would blame the two ends of a carry that is fine; the actionable
instruction is to bind the value first.
- Branch merges. An IfStmt's merge variable (return_vars_, a DefField)
is lane-local exactly when the values its branches yield are. Left at its
declared full width it contradicts both Yield values and stays untracked,
so a following tile.store gets no lane offset and both AIV lanes write from
output row 0 — overlapping writes instead of a half each. RepairIfReturnVars
retypes it from the lowered branches, and both arms call it for the same reason
the loop carries need two call sites. The branches must agree: one yielding a
halved value while the other yields a full-width one has no single merge type,
and is rejected rather than resolved by picking a side. Both branches always
exist here: SSA requires an else wherever return_vars_ are defined, and
ConvertToSSA synthesizes the else Yield for a source-level no-else phi.
- tile.slice with drop_dims. This op has two axis spaces, and the pass
crosses between them in both directions. shape / offset / valid_shape are
indexed in the pre-drop rank; the result is those axes minus drop_dims,
clamped back to 2D by prepending unit axes. So
slice([1, 256, 128], drop_dims=[0]) -> [256, 128] puts the result's dim 0 on
the source's dim 1, and conversely a source dim 0 can land on result dim 1.
| Direction | Used for |
|---|---|
| result axis → pre-drop axis | rewriting the shape / offset / valid_shape tuples, which are indexed pre-drop |
| pre-drop axis → result axis | turning a tracked source's split axis into the axis that indexes the result type |
The second direction is the one a tracked source needs: the split axis is carried on the operand, while the halving path indexes the result. Assuming they coincide made a source axis 0 read the result's synthetic unit axis, take the singleton early-return, and pass the slice through untouched while its source was halved underneath it — a 16-row window over an 8-row source. Both mappings are expressed over the same surviving-axis list, so they are inverses over the surviving axes. The synthetic unit axes the 2D clamp prepends have no pre-drop counterpart: result → pre-drop returns them unchanged, and pre-drop → result never produces them, so neither round-trips through the other there.
The affinity gate¶
Only vector work is halved; cube work stays full. Affinity is decided by
core_affinity::ClassifyCallAffinity (memory-space driven): an op producing or
consuming a Vec tile is VECTOR; matmul operands and the Acc/Mat cube result
are CUBE. tile.aiv_shard is the seam — FULL cube tile in, HALF vector tile
out — and CheckNoCubeTileHalved is the backstop.
Example — cube→vector boundary, vector region halved (UpDown)¶
A mixed kernel: a cube tile (Mat) crosses to Vec, a vector add runs on
it, the result is stored.
Before (post-InferTileMemorySpace mixed InCore):
@pl.function(type=pl.FunctionType.InCore, attrs={"split": pl.SplitMode.UP_DOWN})
def split_auto(qk: pl.Tile[[128, 128], pl.FP32, pl.Mem.Mat],
out_0: pl.Out[pl.Tensor[[128, 128], pl.FP32]]):
popped: pl.Tile[[128, 128], pl.FP32, pl.Mem.Vec] = pl.tile.move(qk, target_memory=pl.Mem.Vec)
y: pl.Tile[[128, 128], pl.FP32, pl.Mem.Vec] = pl.add(popped, popped)
return pl.store(y, [0, 0], out_0)
After (flat fallback: the return expression uses the lane index outside the candidate region):
@pl.function(type=pl.FunctionType.InCore,
attrs={"split": pl.SplitMode.UP_DOWN, "split_aiv": True})
def split_auto(qk, out_0):
subblock_idx: pl.Scalar[pl.INDEX] = pl.tile.get_subblock_idx()
popped: pl.Tile[[64, 128], pl.FP32, pl.Mem.Vec] = pl.tile.aiv_shard(qk, split=1) # C->V, HALF
y: pl.Tile[[64, 128], pl.FP32, pl.Mem.Vec] = pl.add(popped, popped)
return pl.store(y, [0 + subblock_idx * 64, 0], out_0)
The cube operand qk stays [128, 128]; the vector sub-region is halved to
[64, 128] and the store offset is localized per subblock.
Example — vector→cube boundary stays full (UpDown)¶
A V→C tile.move targeting Mat becomes tile.aic_gather directly, with the
FULL [128, 128] Mat shape. No Mat→Mat copy is emitted. Non-Mat cube targets
retain their placement move. Explicit Mat layout/valid-shape conversions that
the gather cannot represent are rejected rather than silently discarded.
For direct Mat reuse, the check uses the consumer's implicit Mat layout with
the gathered valid_shape. A partial valid extent is preserved; the gather's
provisional vector-style view does not make it a requested layout conversion:
# `v` is the per-lane HALF the affinity gate produced, e.g. [64, 128].
gathered: pl.Tile[[128, 128], pl.FP32, pl.Mem.Mat] = pl.tile.aic_gather(v, split=1)
The operand must be a per-lane half. tile.aic_gather is declared
HALF → FULL, so the gather doubles [64, 128] → [128, 128], which is exactly the
FULL result shape the original boundary move requested. That agreement is a
precondition, not a guarantee: a VECTOR value can reach the boundary un-halved
— a Vec parameter used directly, or a tile whose split dim is a singleton the
affinity gate deliberately preserves. Doubling such an operand would produce a
[256, 128] gather feeding a move still typed [128, 128], contradicting
tile.move's shape-preserving contract and yielding IR that does not survive
print→parse. There is no correct gather for a value with no half, so the pass
rejects it with an actionable ValueError:
LowerAutoVectorSplit: the V->C boundary tile.move here carries a full-width
vector operand 'vec'. tile.aic_gather reassembles the two AIV lanes' per-lane
halves into the full tile the cube expects, so its operand must be a value the
split halving produced (a tile.load / tile.slice / elementwise result inside the
vector sub-region). An un-halved value has no half to gather — either derive the
per-lane half first (load or slice the value inside the split function) and move
that to the cube side, or, if the split axis is a singleton that cannot be
halved, keep the value on the vector side.
The gather follows the operand's split axis, not the function's. A
tile.reshape can migrate the split axis — the rms_norm [N, 1] ↔ [1, N] column
reshape moves it from dim 0 to dim 1 — and TileInfo::split_dim tracks where it
ended up. The gather is emitted with the split encoding of that dim
(dim 0 → 1, dim 1 → 2), so a [1, 8] lane-local operand under an UpDown
function gathers to [1, 16] via split=2, matching the move. Doubling the
function axis instead would yield [2, 8]. A tracked split dim outside {0, 1}
cannot be expressed as a split attr on a 2D gather and is rejected.
The gather result is Mat, not Vec: the declared type of a boundary op names
the consuming lane's space, and AIC pops a V→C transfer into L1. (Vec would
name the producing lane, contradicting the mirror op tile.aiv_shard, which
declares the vector-side Vec for its cube-produced operand.) For Mat destinations,
the gathered value is used directly: no Mat → Mat move is emitted. Only non-Mat
cube destinations retain a placement move into their final operand space, such as
Mat → Left or Mat → Right for a matmul operand.
Implementation¶
Header: include/pypto/ir/transforms/passes.h
Implementation: src/ir/transforms/lower_auto_vector_split_pass.cpp
LowerFunction/LowerStmts— boundary rewrite + affinity-gated halving.MakeReshapeOpCall— buildstile.aiv_shard/tile.aic_gathercalls.CheckNoCubeTileHalved— cube-operand integrity backstop.WithSplitAivAttrs— stampssplit+split_aiv.
Shared machinery: src/ir/transforms/utils/split_axis_utils.cpp
(ProcessStmts, InjectSubblockIdx, SplitDimension, IsReduceOnSplitAxis) —
the per-op vector halving, shared with SplitVectorKernel's
ProcessStandaloneSplitFunction and the AivSplitValid verifier.
Python binding: python/bindings/modules/passes.cpp
Tests: tests/ut/ir/transforms/test_lower_auto_vector_split.py, plus the
end-to-end pl.split golden scenarios in
tests/st/codegen/torch/test_torch_codegen_cross_core.py.
Related¶
ResolveBackendOpLayouts— runs immediately before.ExpandMixedKernel— runs immediately after; foldstile.aiv_shard/tile.aic_gatherinto split-stampedtpush/tpop.SplitVectorKernel— downstream; only stamps attrs for thesplit_aivfunctions this pass produces, plus the no-split dual-AIV path.