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.
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 both transformed AUTO bodies and explicit manual bodies.
AUTO supplies the tile facts established by its shape transformation before
substitution/cloning; manual bodies reconstruct shard and lane-address lineage,
including 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.
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.
Admission diagnostics distinguish full-width operator names (full_width_vec_ops) from loop-carry names (carry_mismatches). A carry mismatch reports the lost entry shard fact and asks for a lane-local yield; an unlocalized operator reports its name and asks for lane-local operands or a localized read address. Explicit-boundary admission failures use user-facing CHECK_SPAN diagnostics. Failures after implicit or AUTO halving are compiler postcondition violations and use INTERNAL_CHECK_SPAN without authoring advice. Carry mismatches are reported first if both categories are present.
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.ValidateMixedExplicitRegionis skipped — 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¶
Because that body is spliced through unchanged, every vector op in it must
already be per-lane — an op left at full width would run identically on both AIV
lanes. ValidateMixedExplicitRegion enforces this and rejects the region with an
actionable error naming the offending ops. A tile-producing op is accepted when
any of the following holds:
| Accepted | Why |
|---|---|
Consumes a tile.aiv_shard result defined in this region (transitively) |
It is in the half-width dataflow by construction. |
A pure generator — tile.full / tile.ci / tile.random (and tile.create, which classifies SHARED and so was never reportable anyway) |
Its result is a function of its attributes only: it reads no tile and no memory, so per-lane replication is correct at whatever extent the author wrote. |
An address-carrying op — tile.load / tile.slice / tile.extract / tile.gather_row — whose read address references the region's aiv_id |
The author localized it explicitly, e.g. data[base + aiv_id * HALF : ...]. Only the read-offset args count (tile.load arg 1, tile.slice arg 2, tile.extract args 1–2, tile.gather_row arg 3 = src_offset) — a lane reference in a shape, a valid_shape, or a destination slot does not move the window, so it does not admit. |
The scan is seeded per region and makes one forward pass in program order, so
it recognises only a boundary result defined in the region it is scanning. A
tile.aiv_shard result reaching the region from elsewhere — produced in a sibling
region, or arriving through a loop iter_arg on the back edge — is invisible to
it, and the consumer would be reported as full width even though the value really
is per-lane. Both shapes are rejected 12 passes earlier by the AivSplitValid
verifier (checks (i) and (j), see 99-verifier.md), which is what
keeps that false positive off the author's screen; this scan therefore only ever
meets a same-region dataflow, which is the domain it was written for.
tile.gather_row is the DMA case: being DPS it carries two offsets, and only
src_offset decides whether the lanes do different work — a lane-derived
src_offset means each lane pulls its own scattered GM rows (admitted), while a
lane-derived dst_offset over a lane-invariant src_offset means both lanes fetch
the same rows into different slots of a full-width accumulator (still reported).
Anything else that classifies VECTOR is reported. A generator is accepted for
itself only — z = pl.full([FULL, N]); y = pl.add(z, z) still rejects on y,
because a full-width generator must not vouch for its consumers. And the lane
reference is trusted only on an addressing op, so
pl.set_validshape(full_width_tile, 1, aiv_id * HALF) cannot launder a full tile
into the region.
The guard proves intent, not extent: a load at a lane-strided offset but a
full-width extent is accepted, and the two lanes then read overlapping windows —
the same trust already extended to tile.store, whose lane-dependent offset the
pass never checks.
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.
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, but not the store guard below — its consumers are
rebuilt by the halving walk rather than by this one.
- 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 —
LocalizeExplicitBoundaryValidgives it the full box (split_axis::WithFullSplitAxisValid) and moves the lane's extent onto the first consumer. That pairs exactly with the even code: the producer transports the full physical box, 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, then keep the original cube-placement move on the
gathered FULL tile (named "<dest>_mat" so ExpandMixedKernel's V->C
boundary names its synthesized tpop after it).
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's full-width mask, which its result deduction never reads |
Condition 1 — the halved node still type-checks¶
After rewriting the arguments, the pass re-deduces the result from the arguments
the trailing Substitute will install (each tracked operand swapped for its
halved replacement) and compares the deduced physical shape against the halved
result type. Valid shape is excluded: HalveTileShape localizes it per lane,
which deduction cannot reproduce. A deduction that rejects the halved arguments
outright counts as a mismatch too — the operator is refusing the very node it
would receive.
This asks the operator itself, so it needs no per-operator metadata and cannot go stale as operators are added or their contracts change. It is what 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.
The shape-describing ops (tile.full, tile.create, tile.slice,
tile.reshape, tile.reinterpret_view) rewrite their own operands before this
check runs, and each has a defined full-width-source meaning.
tile.reshape and tile.reinterpret_view get that from an earlier rewrite: an
offsetless view over an untracked source is emitted at full width and followed by
a per-subblock tile.slice, so each lane reads its own half instead of both
reading the first one. That slice can only be materialized from a static half
extent, so a dynamic split extent over a full-width source is rejected rather
than left to fall through to plain result halving.
Condition 2 — operands type deduction is blind to¶
A deducer that never reads an operand's extent says nothing by accepting it. The pass detects that 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 deduces its result from BroadcastShapes(lhs, rhs) alone and checks
only that mask is a TileType, so a full-width mask over halved lhs/rhs
re-deduces perfectly cleanly — yet both lanes would read mask rows 0..127.
tile.sels is the same shape of trap: it validates that the mask covers src's
valid rows, and an oversized mask satisfies coverage. Both declare only their
tmp position, so their masks are rejected. An operand nobody has classified is
treated as lane data, which is the safe default.
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 never reads its extent — which is 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 split axis per element¶
tile.gather_compare returns Tuple[dst[rows, out_cols], cdst[1, rows]]. Under a
row split those two elements do not move along the same axis: dst halves on
dim 0, while cdst — a single contiguous row of per-row counts — halves on dim 1.
There is no single result axis to follow, so the generic path cannot express it.
The mapping is discovered, not declared: re-deduce the call from the arguments the
halved node will carry, and read which axis of each element moved. A new tuple-returning
operator needs no registration, and the mapping cannot go stale the way per-operator
metadata did (gh#2612). Re-deduction supplies only the axis; HalveTileShape still
halves, so odd extents localize per lane as usual.
Every element must halve on exactly one axis. An element that comes back unchanged
is the dangerous case, not the harmless one — the operator produced a full-width output
from an operand each lane owns only half of, so neither lane holds the whole answer while
the shape still looks right. That is what correctly rejects a LEFT_RIGHT
tile.gather_compare: both its outputs are sized from the source's rows.
split_axis::RetypeTupleProjection retypes the x = tup[i] projections from the halved
tuple and records each element's own axis, so a later tile.store offsets the right
dimension. Both lowering arms call it — the AUTO arm's affinity gate only routes leaf
calls into ProcessStmts, so a projection left to its pass-through fallback would keep
a full-width declared type over a halved tuple.
A projection need not be bound at all: pl.tile.store(pair[0], [0, 0], out) passes the
TupleGetItemExpr inline, and nothing hoists it — so anything matching only Var on
a tile operand misses it, and the operand is still substituted afterwards, leaving a
full-width declared type over per-lane data. split_axis::OperandSplitInfo is the single
answer to "did the split partition this operand, and along which axis"; it handles a
bound Var and an inline projection alike, and BuildHalvedCallArgs rebuilds the
projection over the halved tuple so the type-consistency probe sees per-lane operands
too. Every consumer goes through them — the generic path's tracked-input scan,
LocalizeStoreOffset, and GetFirstTileArgMemory (which reads the operand's type, so
a vector op is no longer misclassified SHARED and replicated onto both lanes).
Everything that asks "was this operand partitioned" goes through OperandSplitInfo.
That question has more consumers than the halving itself, and each answered no for an
inline projection with a different consequence:
| Asks it | Wrong answer costs |
|---|---|
| the generic path's tracked-input scan | the result keeps a full-width type over per-lane operands |
LocalizeStoreOffset |
both lanes store to the same rows |
GetFirstTileArgMemory |
a vector op classifies SHARED and is replicated onto both lanes |
| absolute-index gate | a halved tile.gather table under absolute indices — no diagnostic, since gather sizes its result from indices |
tile.reshape / tile.reinterpret_view |
a full-width view plus a per-lane slice over an operand about to be halved |
tile.slice offset |
+ subblock_idx * half added to an already lane-local offset, so lane 1 reads past the end |
| the V→C boundary | a legal tile.move(pair[0], target_memory=Mat) refused as full width |
RepairIterArgs (loop init) |
the carry, the exit, and everything after the loop stay full width under a halved init |
YieldedTileInfo (backedge / merge) |
both ways: a full-width carry fed pl.yield_(pair[1]) waved through, and a legally halved one refused |
FindFullWidthOperand (condition 2) |
a partitioned projection reported as a full-width operand — a false rejection |
The only AsVarLike lookups left in the pass are inside OperandSplitInfo /
ReplacedOperand themselves, and the two places that deliberately identify a whole
tuple by variable (YieldedHalvedTupleType, RetypeTupleProjection). Anything else
asking about an operand's split belongs in the helper — that is what stops this class
from recurring one call site at a time.
The boundary also builds its tile.aic_gather from ReplacedOperand, which rebuilds an
inline projection over the halved tuple so the gather doubles HALF → FULL either way.
A tuple also crosses merges and loop carries, and neither reads its split from
tile_vars — a tuple var is never in it. Both adopt the halved type instead, whose
elements already carry the per-element split, so there is no single axis to record:
| Shape | Repaired by | Agreement rule |
|---|---|---|
if merge |
RepairIfReturnVars |
both branches must yield the same halved tuple |
| loop carry + exit | RepairIterArgs / RepairReturnVars |
the backedge Yield must match the carry |
The DSL cannot annotate either, but ConvertToSSA synthesizes exactly the merge phi for
a tuple reassigned in a branch, and pl.range(..., init_values=(tup,)) carries one
directly.
Two different failures end in a rejection, and the diagnostics keep them apart — one message cannot explain both:
| The operator... | Cause | Diagnostic |
|---|---|---|
| refuses the halved arguments | a constraint does not survive halving (tile.tquant_mx needs M % 16 == 0); or a workspace sized from the full source — after LowerCompositeOps decomposes it, tile.tquant_mx_raw needs its [1, groups] scratch to match a count derived from src, and that singleton dim 0 keeps the generic path from halving it. Repartitioning one is not implemented: the extent lives in the deducer, and the allocation was already emitted at full width |
quote the operator rather than guess which |
| accepts them, but an element did not move | the wrong-contents case above | name the stationary element |
Condition 2 (the blind-operand backstop) is written against a single result axis and does not run here. "Every element must move" covers a primary per-lane operand left full width, but not a secondary one no element's shape depends on; no registered tuple-returning operator has one, and a new one surfaces in the blind inventory first.
A position that is scratch only in some arities cannot be declared:
tile.mrgsort_format2's tmp_or_src2 is a third sorted input in a 3/4-way merge
and workspace in a 2-way one, and the arity is the positional argument count. It
needs no declaration in practice — the deducer sizes the result from whichever
position holds tmp (always the last), so type consistency decides that position at
every arity, and the remaining positions are real sorted inputs that must be sharded.
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 becomes tile.aic_gather; the cube placement move on the
gathered tile keeps the FULL [128, 128] Mat shape — the cube side never
sees a halved tile:
# `v` is the per-lane HALF the affinity gate produced, e.g. [64, 128].
gathered_mat: pl.Tile[[128, 128], pl.FP32, pl.Mem.Mat] = pl.tile.aic_gather(v, split=1)
gathered: pl.Tile[[128, 128], pl.FP32, pl.Mem.Mat] = pl.tile.move(gathered_mat,
target_memory=pl.Mem.Mat)
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 type the cube placement move keeps. 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.) The cube placement
move that follows is what puts the tile in its final operand space — Mat → Left
for a matmul operand; the Mat → Mat shown here is a no-op that survives only
because the pass preserves the author's original move.
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.