ExpandMixedKernel Pass¶
Expands mixed InCore functions into separate AIC (Cube) + AIV (Vector) kernels wrapped in a Group function. Non-mixed InCore functions get their FunctionType converted to AIC or AIV.
Overview¶
After OutlineIncoreScopes and ConvertTensorToTileOps, InCore functions may contain both Cube ops (tile.matmul, tile.gemv, etc.) and Vector ops (tile.add, tile.exp, etc.). Some ops like tile.load, tile.store, tile.move, and tile.reshape are classified as Cube or Vector based on the MemorySpace of their tile operands. Functions containing ops from both sides are mixed InCore functions. Hardware requires Cube and Vector operations to run on separate core types, so this pass splits them into:
- AIC function (
FunctionType::AIC) — contains only Cube + shared ops - AIV function (
FunctionType::AIV) — contains only Vector + shared ops - Group function (
FunctionType::Group) — calls AIC then AIV, replaces the original
When an existing Group function already calls the InCore function (e.g. from OutlineClusterScopes), the pass rewrites that Group in-place to call AIC + AIV directly, avoiding a redundant Group wrapper. When a standalone Spmd wrapper calls the InCore function, the pass preserves the Spmd wrapper and creates a Group callee under it so launch semantics stay on FunctionType::Spmd.
For non-mixed InCore functions (pure Cube or pure Vector), the pass converts FunctionType::InCore to the corresponding type without splitting:
- Pure Cube →
FunctionType::AIC - Pure Vector or shared-only →
FunctionType::AIV
After this pass, no FunctionType::InCore functions remain in the program.
Shared argument ABI for hand-written Groups¶
One runtime MixedKernels submission creates a co-scheduled AIC/AIV task whose
active lanes share one CoreTaskArgs payload. Consequently, the two compiled
kernel wrappers must unpack the same tensors-first argument layout. This is
automatic for Groups created by the mixed-kernel splitter, because both member
calls forward the complete Group signature.
A hand-written pl.cluster() may instead submit members with different source
signatures, for example cube(q, k, v, sink, task) and vec(out, task). The
outlined Group captures the union of those values. This pass normalizes both DSL
members to that Group signature and rewrites both inner Call or Submit nodes
to forward the complete list. Unused parameters remain valid wrapper arguments;
each cloned body still references only the values from its original call.
Normalization also binds dynamic dimensions in each member's parameter types
to the rewritten Group arguments, using the same type-binding rules as inline
expansion. The bindings apply to local aliases, loop-carried values, expression
types, and declared return types. For example, a member workspace Tensor[[8, N]]
passed a Group workspace Tensor[[8, t + 32]] retains t + 32 throughout its
cloned body; an unbound member-local N must not survive in a type. Replacements
are applied once so caller-owned extent expressions keep their original meaning.
The widened Call/Submit results and their receiving aliases use the normalized
member return types, including Submit's trailing TaskId.
The pass first records every producer return position, its callee-scoped SSA
expression, type, and optional output-parameter binding. GroupOutputBindings
then follows the consumer's SSA aliases and tuple projections to a particular
producer call and return position. Only after identifying that output does it
resolve the actual storage passed at the call site and bind it to the shared
Group parameter list. Producer-local SSA variables are never copied into a
consumer body.
Computed Tensor results have already been materialized by
ConvertTensorToTileOps as explicit Out
parameters and stores. That pass exposes internal output storage through Group
parameters and allocates it in the outer caller, even when the Group does not
return it. Existing complete-parameter writebacks reuse their original storage.
This also covers reordered Call/Submit tuple results, direct pair[i]
arguments, and projections through local tuple aliases or nested tuples.
pl.no_dep uses the same mapping. Submit's omitted-output gap and trailing
communication contexts are respected. Callee summaries are shared for one
normalization run and then discarded. Views and slices are not treated as
complete-parameter aliases; a result with no caller-visible carrier is diagnosed
instead of producing a free variable. Device-computed scalar returns require an
explicit Tensor output under the existing kernel-return contract.
This binding does not insert a cross-core handshake. Hand-written Group members run concurrently; their existing synchronization protocol must order a producer store before a consumer load.
When both member kernels are exclusive to the Group, their names are preserved.
If either kernel has another call site, the pass creates a Group-private adapter
pair and rewrites import_peer_buffer(peer_func=...) to the adapter peer names,
leaving the original ABI available to the other callers. External-source and
runtime-bound members cannot be body-cloned; when their layouts differ, the pass
rejects the Group and requires both declarations to use the same signature.
This normalization happens before InjectGMPipeBuffer, so a backend-injected
__gm_pipe_buffer parameter is appended to the already-identical member
signatures. It does not require sync_start: AIC and AIV are subslots of one
mixed task, while sync_start controls multi-block SPMD launch admission.
When the Group is called through an enclosing Spmd wrapper, orchestration
codegen traces complete-buffer alias chains back to the wrapper parameters
before ordering the shared argument payload.
Unsplittable transpose: checked earlier, not here¶
A tile.transpose that swaps the split axis cannot be split correctly: the
per-lane data migrates to the other dimension while the split still halves the
original axis, so the result would be mis-shaped and miscompute. This pass no
longer reports it. The check moved to where the context it needs still exists:
| Shape | Reported by |
|---|---|
A pl.split_aiv region |
AivSplitValid check (l), with LowerAutoVectorSplit as the backstop |
A function-level pl.split (no region) |
LowerAutoVectorSplit |
Reporting it here could only ever produce a worse diagnostic: the walk that
would raise the region case is AppendConsumed, which erases the wrapper it
would have to name, so it could state neither the region's mode nor the
offending result nor a fix. And the function-level case sat here only because
this pass sees every function, including the pure-vector pl.split kernels
LowerAutoVectorSplit declines to lower; that pass now validates its
pass-through branch, which closes the gap without the late diagnostic.
A whole-function backstop remains here, as an INTERNAL_CHECK_SPAN. This
pass is documented below as invocable on its own after InferTileMemorySpace,
and a bare pass call does not enforce required properties — only
PassPipeline does. So a caller who skips LowerAutoVectorSplit arrives with
AivSplitLoweredValid unmet, and without the guard the kernel expands silently
and SplitVectorKernel mis-shapes it. It is internal rather than user-facing
because the failure is an unmet pass prerequisite, not a fact the author can
read off their source, so the message names the skipped step instead of giving
authoring advice.
split_axis::FindTransposeSplitHazard is still the shared detector: it flags the
first tile.transpose whose source is non-singleton on the split axis (a
singleton source carries no split data — the no-op broadcast case — and is left
split; a dynamic, non-ConstInt extent is treated as non-singleton and flagged
conservatively).
SplitRegionConsumer validates each retained or synthesized region using its
own split axis, erases the wrapper, and records placement on the same consumed
body used by both mixed-function classification and expansion. Pure-AIV functions
also consume their regions here. Functions with no regions skip region
consumption without rebuilding their bodies. Erased-region comments are prepended to the first emitted statement without mutating input metadata, including when a boundary expands into transport operations.
The pass requires AivSplitLoweredValid. Source boundaries use strict operand
memory rules; lowered verification retains those checks for locally defined operands. Expansion checks operand availability, using lexical placement for inline calls as well as bound values, on the producing
lane, so parameters shared by both lanes remain valid regardless of their memory
annotation. Boundary result memory still describes the consuming lane.
Cross-core data transfer at CV boundaries is handled by splitting explicit tile.move ops into tpush/tpop pairs:
| Direction | AIC side | AIV side |
|---|---|---|
| Cube→Vector (e.g. Acc→Vec) | tpush_to_aiv(source_tile) |
dest_var = tpop_from_aic() |
| Vector→Cube (e.g. Vec→Mat/Left/Right) | dest_var = tpop_from_aiv() |
tile.move to adapt fractal layout, then tpush_to_aic(adapted_tile) |
Fractal TileView layout: Cross-core transfer tile views are computed by BuildCrossCoreTransferView based on the destination memory space. The mapping differs between backends:
Ascend950 (a5) — hardware cross-core pipe carries data in fractal layout:
| Direction | Push/Pop TileView (blayout, slayout) | Name |
|---|---|---|
| Vec→Left | col_major, row_major | NZ |
| Vec→Right | col_major, row_major | NZ |
| Vec→Mat | must be explicitly set in move | — |
| Mat/Acc→Vec | must be explicitly set in move | — |
Vec→Right crosses as NZ, not as the ZN a Right operand ends up in: a5 inserts
the Vec tile into the Mat FIFO through TINSERT_IMPL<TInsertMode::NZ>, so the
bridge tile has to stay NZ. The ZN view belongs to the Mat → Right tile.move
the cube side makes after the pop, which is one step later than this table
describes — the same split the a2a3 table below spells out.
Ascend910 (a2a3) — cross-core transfer goes through GM → Mat, and Mat only supports the NZ layout. Both Left and Right destinations use NZ at the transfer boundary; the final Left/Right layout is resolved by the subsequent Mat → Left/Right tile.move (MTE1):
| Direction | Push/Pop TileView (blayout, slayout) | Name |
|---|---|---|
| Vec→Left | col_major, row_major | NZ |
| Vec→Right | col_major, row_major | NZ |
| Vec→Mat | preserve original | — |
| Mat/Acc→Vec | preserve original | — |
On both backends, the ordinary-data AIV push side (V→C) inserts a tile.move before tpush_to_aic to convert the source tile into the required fractal layout. The tile.move helper (CreateMove) propagates blayout/slayout kwargs when the result type carries a TileView. Ascend950 MX scales use the dedicated row/row carrier described below.
Hand-written pipes get the same adapter¶
The rule above describes the boundary-move path, which only sees the pipes this pass
builds while expanding an InCore function. A pipe authored directly (pl.reserve_buffer,
pl.{aic,aiv}_initialize_pipe and pl.tpush_to_aic, written out by hand) never reaches
it. On a backend where RequiresVtoCFractalAdapt() holds, such a push would ship a bare
ND tile into a FIFO the cube reads as fractal, scattering every element of the popped
tile.
AdaptManualVtoCPush closes that gap. It runs as the pass's final phase over every
AIV function the pass emits, including pure-vector InCore bodies converted to AIV and the
AIV half of a mixed body. Ordinary data keeps the fixed cube-side Mat transfer view.
For MX, the pass resolves the AIV import to its AIC reserve and pairs V2C operations by pipe
id and occurrence order, so each push is planned from its actual consumer tpop contract.
Missing peers, ambiguous initializers, mismatched push/pop counts or splits, and undersized
slots are rejected instead of guessing a layout.
The rewrite applies to FP8E8M0 MX scales as well as ordinary data tiles, preserves the
original push kwargs and attrs (including id), and is idempotent: a push already staged
into the boundary view is left unchanged. The backend capability is consulted only after a
manual V→C push is encountered.
GM-mediated cross-lane dependencies¶
A tile.move is not the only way data crosses the CV boundary. When one lane writes a GM tensor with tile.store and the other lane reads the same tensor with tile.load, the data crosses through GM. Because neither op is a tile.move, CV-boundary detection misses the dependency, and without a fence the two split kernels race on the shared GM region (issue #1433).
CollectGmCrossLaneSyncs detects these store/load pairs and emits a pure-synchronisation handshake: the data still flows through GM unchanged, while a tpush (producer, right after the store) and a matching tpop (consumer, right before the load) establish the missing happens-before edge. The popped tile is a fence token, freed immediately by FinalizeTpopTfrees; BuildAutomaticPipeSetup then injects the same pipe setup as for tile.move boundaries.
| Producer (writes GM) | AIC side | AIV side |
|---|---|---|
| Cube store → Vector load | tile.store ...; tpush_to_aiv(stored_tile) |
tok = tpop_from_aic(); tfree_to_aic(tok); tile.load ... |
To stay deadlock-free, a handshake is emitted only when (1) the GM origin tensor has exactly one producer-lane store, (2) the opposite-lane load lives in the same structural body (same loop/branch, so the tpush/tpop execute the same number of times), and (3) the store precedes the load. Pairs split across different loops or branches are left untouched.
Only the Cube→Vector direction (cube tile.store → vector tile.load) is fenced. The AIC tpush sends the stored tile raw, exactly as the normal boundary C2V push does on both backends, and the AIV tpop lands in Vec. The reverse Vector→Cube direction would require the V→C fractal-layout adaptation that the tile.move boundary path applies before tpush_to_aic; emitting a raw-tile sync there would violate the cross-core transport contract, so V2C GM exchanges are left unfenced.
When split kernels contain cross-core tpush/tpop, the pass also prepends the required frontend pipe setup automatically:
system.reserve_buffer(...)on the consumer sidesystem.import_peer_buffer(...)on the producer sidesystem.aic_initialize_pipe(...)/system.aiv_initialize_pipe(...)on both sides
In addition, the pass inserts system.tfree_to_aic(...) / system.tfree_to_aiv(...)
after every consumer-side tpop chain.
Setup is derived from the split bodies:
dir_mask:C2V=1,V2C=2, bidirectional=3id: omitted for automatic setup, so PTOAS uses the default frontend pipe id0slot_size: max tile byte size across all directions (shape * dtype bits / 8)slot_num: ring depth —2by default, in every direction; always emitted explicitly; overridable per scope (see below)local_slot_num: optional A2/A3 local consumer depth; omission usesslot_numbuffer_size:slot_size * local_slot_numwhen supplied, otherwiseslot_size * slot_num- buffer names:
<func>_c2v_slot_buffer/<func>_v2c_slot_buffer - reserve-buffer base:
AUTOon insertion, then resolved to an explicit address byAllocateMemoryAddr
When cross-core directions use different tile sizes, the pass picks max(all observed tile byte sizes) as the common slot_size for initialize_pipe. Smaller tiles leave unused bytes in each slot but hardware correctness is preserved. Explicit user-authored programs can still create multiple independent pipes by supplying different id values to initialize_pipe and matching tpush / tpop / tfree ops.
MX scale V2C transport¶
On Ascend950, a mixed quant_mx → matmul_mx path transports both results over
V2C. FP8E8M0 fractal-32 scales never use the NZ TINSERT path. A full-valid
logical row/row scale is pushed directly. A logical col/col scale takes the
official, zero-copy tile.transpose_view after any carrier preparation,
producing a row/row carrier with the physically transposed shape. If producer
and consumer logical layouts differ, a real Vec→Vec tile.move converts to the
consumer layout before that carrier step.
The AIC tpop keeps the consumer's public logical shape, layout, and
valid_shape. For a partial logical scale, the producer transports the full
physical box so the ND insert is contiguous and 32-byte aligned. The pass first
materializes a private Vec carrier, then widens only that carrier with
tile.set_validshape; this keeps slice/reshape views legal and leaves the
original logical scale metadata unchanged. The consumer still ignores padding
outside its original logical valid region. The carrier's full byte size must fit
the paired pipe's slot_size.
Explicit pl.aic_gather uses the same MX carrier path. Its type deduction keeps
FP8E8M0 row/row/32 and col/col/32 scale layouts (and tensor-level MX_A_ZZ /
MX_B_NN), so the following Mat→LeftScale/RightScale move sees the scale format.
Ordinary shard/gather tiles still use the destination's normal transfer layout.
SplitMode.NONE preserves the logical shape. For fully valid scale boxes,
UP_DOWN gathers MX_A_ZZ row bands and LEFT_RIGHT gathers MX_B_NN column bands.
The latter's transpose view changes both endpoints' physical split to UP_DOWN;
the Mat pop retains the full logical col/col shape. Carrier validation compares
each producer with its half of that logical shape and sizes the slot for both
lanes. Scale-group-axis splits and partially valid split scale boxes are rejected
because they need additional boxed-layout transport handling.
Gathered Mat tiles retain FIFO slots until their consumers release them. Gathering
all four MX matmul operands before consuming any requires
pl.cross_core_slot(slot_num=4); two slots are insufficient even with correct
scale layouts.
Overriding the slot count (slot_num)¶
The default slot count (cross_core_pipe::kDefaultAutoPipeSlotNum = 2) can be
tuned per scope with the pl.cross_core_slot(slot_num=N) optimization entry:
# Deepen the auto-inserted ring so the cube can run further ahead of the vector
# core, at the cost of consumer-side buffer space.
with pl.spmd(4, optimizations=[pl.cross_core_slot(slot_num=4)]):
...
# Or on the pl.at form, alongside an independent split mode:
with pl.at(level=pl.Level.CORE_GROUP,
optimizations=[pl.split(pl.SplitMode.UP_DOWN),
pl.cross_core_slot(slot_num=16)]):
...
The value is carried as the slot_num scope attr, propagated by
OutlineIncoreScopes to the outlined function's slot_num attr, and read here.
It drives the transport ring and the emitted initialize_pipe slot_num
attribute. Without an explicit local depth, it also sizes the reserved buffer
(slot_size * slot_num). Automatic setup always emits the attribute, whether or not
the scope overrides it: PTOAS derives its own depth from dir_mask (8
unidirectional, 4 bidirectional) when the clause is absent, which would index
past a buffer reserved for a shallower ring. Only hand-written
pl.system.{aic,aiv}_initialize_pipe still reaches that fallback.
slot_num must be positive and is orthogonal to splitting — it sizes a data
channel, it does not partition work. It applies in whichever directions the
scope actually uses (cube->vector, vector->cube, or both), and to any scope that
drives a pipe: a kernel with no split at all still drives one (on a2a3 via
dual-AIV dispatch — see below). It is ignored only when the outlined scope ends
up with no cross-core ops.
Deprecated: pl.split(MODE, slot_num=N) still accepts the slot count and
emits a DeprecationWarning. That spelling forced authors to name a split mode
they did not want — the pl.split(pl.SplitMode.NONE, slot_num=N) idiom — which
OutlineIncoreScopes now rejects on any scope carrying pl.split_aiv regions.
Move the value to pl.cross_core_slot(slot_num=N); the printer already
normalises to that form.
On A2/A3, set local_slot_num to keep a smaller on-chip consumer FIFO while
retaining the GM transport depth:
Both counts must be positive integers and local_slot_num <= slot_num.
Omitting local_slot_num (or passing None) preserves the existing footprint.
The outliner propagates both attributes; both peer initializers emit the same
explicit local depth, and each on-chip reserve uses slot_size * local_slot_num.
GM allocation still uses slot_num. The common maximum slot_size is unchanged.
The pass checks the finalized FIFO schedule and rejects a local depth smaller
than the number of simultaneously retained consumer pops. It does not reschedule
consumers to make a smaller depth fit.
Explicit local depth is rejected on A5, even when equal to slot_num, because
PTOAS does not support the attribute for that frontend pipe lowering. Omit it
for portable source. Like slot_num, it has no effect on scopes without an
automatic cross-core pipe.
For consumer-side cross-core tiles, the pass also ensures each tile.tpop_* has a matching
system.tfree_*. When an existing free is obviously too early, the pass delays it to a later statement in the same
block without reordering independent tpop chains. When AIC must post-process a tile.tpop_from_aiv result with a
same-side tile.move (for example Mat -> Left/Right/Bias), the generated system.tfree_to_aiv(...) is rewritten to
free the canonical popped tile value and can be delayed past that carrier chain.
For Ascend910 (a2a3), mixed kernels with no function split mode (split unset or SplitMode.None) are also
supported. In that case the pass keeps a single AIV kernel body, marks it for dual AIV dispatch, and later lowering
emits a runtime subblock_idx branch: AIV lane 0 executes the original body, while AIV lane 1 replays the cross-core
handshakes with tile-producing replay work forced to valid_shape=[0, 0], and visible tile.store writes suppressed.
This keeps the AIC/AIV handshakes balanced for pl.at(level=CORE_GROUP) no-split mixed
kernels while the secondary sync lane avoids real DMA/compute work.
This replay path applies only to non-split_aiv mixed kernels. A function carrying a pl.split_aiv region — any
mode, including the task-parallel pl.SplitMode.NONE — is stamped split_aiv by
LowerAutoVectorSplit (pass 23), so SplitVectorKernel
routes it through the split path (both AIV lanes dispatch via dual_aiv_dispatch) and never the lane-0-only replay
above. For a NONE region this is exactly right: both lanes run the full body for disjoint, aiv_id-dispatched
work — dropping lane-1 stores would be a miscompile.
Requirements:
- Input IR must have tile ops (run
ConvertTensorToTileOpsfirst) - Input IR must have InCore scopes outlined (run
OutlineIncoreScopesfirst) - Tile ops must be flattened to 2D (run
FlattenTileNdTo2Dfirst) - Tile memory space must be inferred (run
InferTileMemorySpacefirst) - Cross-core fractal TileView assignment is supported on Ascend950 and Ascend910 backends
When to use: Run after InferTileMemorySpace when InCore functions may contain both Cube and Vector tile operations.
Note: This pass is part of the default tile optimization pipeline. You can also invoke it explicitly via
passes.expand_mixed_kernel()(program)when debugging or building a custom pass pipeline.
API¶
| C++ | Python | Level |
|---|---|---|
pass::ExpandMixedKernel() |
passes.expand_mixed_kernel() |
Program-level |
Python usage:
from pypto.pypto_core import passes
expand_pass = passes.expand_mixed_kernel()
program_expanded = expand_pass(program)
Algorithm¶
Phase 1 — Pre-scan:
Identify InCore functions that have existing Group callers and
callers that still need the original function name to remain callable.
Phase 2 — Expand each InCore function F:
1. Recursively classify affinity of all statements (including inside loops/conditionals)
2. Detect CV boundary moves: tile.move ops crossing cube↔vector memory spaces
(recorded in a separate boundary_moves map, not as a distinct affinity)
2a. Detect GM-mediated cross-lane store/load pairs (CollectGmCrossLaneSyncs):
a tile.store on one lane + a tile.load on the other from the same GM
tensor origin, scheduled as a tpush/tpop sync fence (see "GM-mediated
cross-lane dependencies" above)
3. If not mixed (no CUBE ops or no VECTOR ops, and no boundary moves):
convert FunctionType to AIC (pure Cube) or AIV (pure Vector / shared-only)
4. Build AIC body: keep CUBE + SHARED stmts, prune VECTOR, recurse into MIXED loops
- For boundary move (Cube→Vector): emit tpush_to_aiv(source_tile)
- For boundary move (Vector→Cube): emit dest_var = tpop_from_aiv() with fractal TileView
4a. For an op-driven boundary (tile.aiv_shard / tile.aic_gather), derive the
pto-isa split CODE for the tpush/tpop pair from the op's authored MODE plus
the full-width tile's extents (BoundaryTransportSplitCode ->
split_axis::ShardSplitCode / GatherSplitCode) — see "The split code" below
5. Build AIV body: symmetric (keep VECTOR + SHARED, prune CUBE)
- For boundary move (Cube→Vector): emit dest_var = tpop_from_aic() with fractal TileView
- For boundary move (Vector→Cube): emit tile.move to adapt fractal layout, then tpush_to_aic(adapted_tile)
5a. Assign fractal TileView to all boundary tpop result types and pre-tpush tile.move ops
via BuildCrossCoreTransferView:
- Ascend950: Left→NZ, Right→ZN, Mat/Vec→preserve
- Ascend910: Left→NZ, Right→NZ (Mat only supports NZ), Mat/Vec→preserve
6. Repair loop-carried state on both bodies
- Strip dead iter_args whose carried values are unused on this side —
uses include the while condition, and close over the loop's own yield,
so a carry read only to feed another live slot (a multi-entry FIFO
rotation) stays alive
- Pull back missing init-value definitions for surviving iter_args
- Rewrite dangling yields to identity yields when a branch-local value was stripped
- Seed branch-result cleanup with function parameters and pending remap keys,
preserving live if/yield results that directly reference a parameter or
an expression such as `n - 1` on either lane
- Remap dangling tile.store result vars (SSA versions stripped by AIC-side splitting) to the corresponding output parameter
7. Run dead code elimination on both bodies (recursive into loops)
8. Normalize loop-carried state again, because DCE may remove a SHARED-only
post-loop use that temporarily kept an iter_arg alive
9. Run dead code elimination again to clean up init-value chains exposed
by the second strip
10. Ensure each consumer-side `tpop` chain has a matching
`system.tfree_to_aic` / `system.tfree_to_aiv`, delaying obviously early frees within the same block when needed
11. If the split bodies use cross-core tile ops and do not already contain setup,
derive reserve/import/initialize_pipe prologues and prepend them
12. Create AIC function (no return) and AIV function (original return)
- On Ascend910 no-split mixed kernels, tag the generated AIV with dual-dispatch metadata
so later lowering launches the same AIV kernel on both vector lanes and rewrites the secondary lane's tile
replay path to `valid_shape=[0, 0]`
13. If a non-Group caller still needs the original function name
(for example a standalone Spmd wrapper): also create a Group
function (calls AIC then AIV)
14. If there are only existing Group callers: skip the extra Group wrapper
Phase 3 — Rewrite Group callers:
For each Group function that calls a split InCore, replace the InCore call
with an AIC call + AIV call sequence (EvalStmt for AIC, AssignStmt for AIV).
Phase 4 — Normalize hand-written mixed Group ABIs:
For each Group whose AIC/AIV calls do not both forward the complete Group
signature, rebuild the DSL member bodies against one canonical parameter list
and rewrite both Call/Submit nodes to pass it. Preserve exclusive member names;
otherwise create Group-private adapters and retarget peer_func references.
Affinity classification:
| Affinity | Ops | Classification Rule |
|---|---|---|
| CUBE | tile.matmul, tile.matmul_acc, tile.matmul_bias, tile.gemv, tile.gemv_acc, tile.gemv_bias, tile.batch_matmul, tile.batch_matmul_acc |
Always CUBE (op name) |
| CUBE or VECTOR | tile.load |
By target_memory kwarg: cube memory (Mat, Left, Right, Acc, Bias) → CUBE; Vec → VECTOR |
| CUBE or VECTOR | tile.store, tile.reshape |
By source tile's memory_space: cube memory → CUBE; Vec → VECTOR |
| MIXED | tile.move crossing cube↔vector memory |
Leaf cross-side move — also recorded in the boundary_moves map (see below) |
| CUBE or VECTOR | tile.move (same-side) |
By source tile's memory_space |
| VECTOR | All other tile.* ops (tile.add, tile.exp, tile.sub, etc.) |
Always VECTOR (op name) |
| VECTOR | pld.tile.put, pld.tile.get, pld.tensor.put, pld.tensor.get |
Declared set_core_affinity(VECTOR) — TPUT/TGET stream through a VEC staging tile, which ptoas enforces |
| SHARED | Non-tile ops, function calls, control flow, scalar ops | — |
| SHARED | pld.system.notify, pld.system.wait |
Core-agnostic by ISA (pure scalar/GM), so no affinity is declared. notify also declares set_no_duplicate() (both NotifyOp forms) — a cube-lane copy can release the peer before the vector lane's TPUT lands the data; wait does not, since it blocks and its cube-lane copy is load-bearing |
| MIXED | Compound statements containing both CUBE and VECTOR children | — |
| VECTOR | Region-local, intrinsically SHARED no-duplicate call without a stated lane | Pass-local lexical placement |
Region placement keeps pld.system.notify off AIC. It preserves duplicate-safe
wait, stated lane affinity, and both boundary endpoints. The map is internal to
this pass; no call attributes carry placement. See
LowerAutoVectorSplit for the shared contract.
CV boundary detection: A tile.move is a CV boundary when its source tile memory and target memory are on different core sides. Cube-side memory: Mat, Left, Right, Acc, Bias. Vector-side memory: Vec. Same-side moves (e.g. Mat→Left) are classified by their source memory as usual. Boundary leaf moves are tagged MIXED for affinity purposes and are also recorded in a separate boundary_moves map; the cross-core direction (Cube→Vector vs Vector→Cube) is recovered via ClassifyMoveDirection at the call sites that need it (CollectCVBoundaryMoves, BuildCoreBody).
Nested structure handling: ForStmt, IfStmt, and WhileStmt containing mixed ops are duplicated into both AIC and AIV bodies with recursively pruned contents.
Loop-state repair after splitting: mixed-loop control flow is intentionally preserved during body construction, which can leave one side with extra iter_args, missing init-value definitions, or yields that reference stripped branch-local values. The pass repairs those cases before DCE, then normalizes loop-carried state once more after DCE because dead shared aliases can disappear and make an iter_arg removable only at that stage. A final DCE pass cleans up any init-value chains that become dead after the second strip.
Which carries count as live: uses in the loop body, the while condition, or
post-loop uses of the corresponding result seed liveness. A carry used only by
the while condition stays live even when its final result is unused. An iter_arg
read only by the loop's own trailing yield is still live when the slot it feeds
is live — that is exactly the shape of a multi-entry FIFO, whose entry N is
read nowhere but the yield that rotates it
into entry N-1. Liveness therefore closes over the yield instead of being read
off the body alone, and the strip asserts that neither the while condition nor a
surviving yield value names a carry it just dropped. Dropping such a carry would
leave a free Var that no later pass rejects and PTO codegen reports as
"no MLIR mapping for MemRef base ...".
Group wrapper param-returns: a newly created Group wrapper
returns its own parameters when every return position traces to a param
writeback (via return_lineage::ReturnedParamIndices) — the AIV call is
emitted as a bare EvalStmt and the wrapper returns the matching Group
params directly (e.g. return out_0), keeping the ReturnParamsExplicit
invariant. Only when some position is not a param writeback does it fall back
to the legacy result = aiv_call(); return result form.
The split code¶
tile.aiv_shard / tile.aic_gather carry the authored mode (0 / 1 / 2);
the transport pair this pass mints carries pto-isa's TileSplitAxis code
(0..4), which additionally says how the two AIV lanes' runtime extents
relate — that is how the consumer finds lane 1's band inside the FIFO slot:
| Code | pto-isa | Lane 1's band starts at | Requires |
|---|---|---|---|
| 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 |
BoundaryTransportSplitCode reads the lane extents eL = clamp(V - L*S, 0, S)
off the FULL-width tile — the shard's operand (Cube→Vector) or the gather's result
(Vector→Cube). S is the partition stride: the tile's physical half, or the
balanced lane_stride=S attr when
LowerAutoVectorSplit rebalanced a ragged
boundary. So an odd split axis (an odd physical box, an odd valid extent inside
an even one, or a rebalanced odd valid region) takes the odd code, an empty
lane 1 keeps the even one, and a ragged extent pto-isa cannot place — only
reachable on the box partition — is rejected with the extents that would work.
Both lane bodies run the same derivation on the same inputs, so the AIC and AIV
sides always agree. The Vector→Cube gather has no odd form (pto-isa's vector-side
producer offsets lane 1 by lane 1's own extent), so it is even-only. Full
derivation: LowerAutoVectorSplit.
Example 1: InCore without existing Group caller¶
When Orchestration calls InCore directly, a new Group wrapper is created.
Before (after InferTileMemorySpace):
@pl.program
class Before:
@pl.function(type=pl.FunctionType.InCore)
def compute_incore_0(self, x: pl.Tensor[[16, 128], pl.BF16],
y: pl.Tensor[[128, 128], pl.BF16],
out_0: pl.Out[pl.Tensor[[16, 128], pl.FP32]]
) -> pl.Tensor[[16, 128], pl.FP32]:
x_mat: pl.Tile[[16, 128], pl.BF16] = pl.load(x, [0, 0], [16, 128], target_memory=pl.Mem.Mat)
y_mat: pl.Tile[[128, 128], pl.BF16] = pl.load(y, [0, 0], [128, 128], target_memory=pl.Mem.Mat)
x_left: pl.Tile[[16, 128], pl.BF16] = pl.move(x_mat, target_memory=pl.Mem.Left)
y_right: pl.Tile[[128, 128], pl.BF16] = pl.move(y_mat, target_memory=pl.Mem.Right)
z_tile: pl.Tile[[16, 128], pl.FP32] = pl.matmul(x_left, y_right)
z_vec: pl.Tile[[16, 128], pl.FP32] = pl.move(z_tile, target_memory=pl.Mem.Vec)
out_0 = pl.store(z_vec, [0, 0], out_0)
return out_0
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
return self.compute_incore_0(x, y, out_0)
After (conceptual — actual IR includes type annotations on all variables; auto-generated pipe setup is omitted here for brevity):
@pl.program
class After:
@pl.function(type=pl.FunctionType.AIC)
def compute_incore_0_aic(self, x, y, out_0):
x_mat = pl.load(x, [0, 0], [16, 128], target_memory=pl.Mem.Mat) # CUBE: load to Mat
y_mat = pl.load(y, [0, 0], [128, 128], target_memory=pl.Mem.Mat) # CUBE: load to Mat
x_left = pl.move(x_mat, target_memory=pl.Mem.Left) # CUBE: Mat→Left (same-side)
y_right = pl.move(y_mat, target_memory=pl.Mem.Right) # CUBE: Mat→Right (same-side)
z_tile = pl.matmul(x_left, y_right) # CUBE op
pl.tile.tpush_to_aiv(z_tile, split=0) # boundary move: push Acc tile to AIV
@pl.function(type=pl.FunctionType.AIV)
def compute_incore_0_aiv(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
# tpop result carries fractal TileView (no layout for Vec destination → preserved)
z_vec: pl.Tile[[16, 128], pl.FP32, pl.Mem.Vec, pl.TileView()] = pl.tile.tpop_from_aic(split=0)
out_0 = pl.store(z_vec, [0, 0], out_0) # VECTOR op
pl.system.tfree_to_aic(z_vec) # release the consumed cross-core slot
return out_0
@pl.function(type=pl.FunctionType.Group)
def compute_incore_0(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
self.compute_incore_0_aic(x, y, out_0)
self.compute_incore_0_aiv(x, y, out_0)
return out_0 # param-return: the AIV result writes through out_0
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
return self.compute_incore_0(x, y, out_0) # calls Group (same name)
Example 2: InCore with existing Group caller¶
When OutlineClusterScopes has already created a Group function calling the InCore, the pass rewrites the existing Group instead of creating a new wrapper. When OutlineClusterScopes has created a standalone Spmd wrapper, the pass keeps that Spmd function and retargets it to a new Group callee with the original InCore name.
Before:
@pl.program
class Before:
@pl.function(type=pl.FunctionType.InCore)
def compute_incore_0(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
# ... mixed Cube + Vector ops ...
@pl.function(type=pl.FunctionType.Group)
def compute_group(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
result = self.compute_incore_0(x, y, out_0)
return result
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
return self.compute_group(x, y, out_0)
After — existing Group is rewritten, no compute_incore_0 Group wrapper:
@pl.program
class After:
@pl.function(type=pl.FunctionType.AIC)
def compute_incore_0_aic(self, x, y, out_0):
# ... Cube ops + tpush ...
@pl.function(type=pl.FunctionType.AIV)
def compute_incore_0_aiv(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
# ... tpop + Vector ops ...
@pl.function(type=pl.FunctionType.Group)
def compute_group(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
self.compute_incore_0_aic(x, y, out_0) # rewritten: AIC call
result = self.compute_incore_0_aiv(x, y, out_0) # rewritten: AIV call
return result
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
return self.compute_group(x, y, out_0) # unchanged
Loop initializer repair tracks lexical visibility: enclosing definitions, parameters, loop variables, carries and pending cross-core remaps remain available in nested bodies. It restores only missing producers; compound statements export only their result variables. Branch-local bindings never become visible to sibling branches.
Implementation¶
Header: include/pypto/ir/transforms/passes.h
Implementation:
src/ir/transforms/expand_mixed_kernel_pass.cpp: mixed-kernel expansion, cross-core transport, and pass orchestration.src/ir/transforms/expand_mixed_kernel/group_abi.cpp: hand-written Group ABI normalization, output binding analysis, and member adapter construction. The privategroup_abi.hdeclares its result and entry point.
Python binding: python/bindings/modules/passes.cpp
Tests: tests/ut/ir/transforms/test_expand_mixed_kernel_a2a3.py, test_expand_mixed_kernel_a5.py, test_expand_mixed_kernel_split_aiv.py, and test_group_output_bindings.py.
Pass Properties¶
| Property | Value |
|---|---|
| Required | SSAForm, IncoreTileOps, SplitIncoreOrch, TileOps2D, TileMemoryInferred, NormalizedStmtStructure, AivSplitLoweredValid |
| Produced | SSAForm, MixedKernelExpanded, NormalizedStmtStructure, HardSyncallOccupancyValid, AccCompactValid |
| Invalidated | AccCompactValid, AivSplitLoweredValid |
HardSyncallOccupancyValid is produced here not by anything this pass rewrites, but because resolving each kernel's FunctionType to AIV/AIC/Group is the precondition the hard-syncall occupancy verifier depends on. That verifier fires once, right after this pass.
AccCompactValid is invalidated and re-produced because the Cube->Vector boundary tile.move is rebuilt here as a tpush/tpop pair with a freshly built consumer type, so the Acc compact contract has to be re-checked on that new IR rather than trusted from InferTileMemorySpace.
Property Verifier¶
MixedKernelExpandedPropertyVerifier checks:
- no remaining
FunctionType::InCorefunction contains both Cube and Vector tile ops tile.tpop_from_aivin AIC lands inMemorySpace::Mattile.tpop_from_aicin AIV lands inMemorySpace::Vec- AIC/AIV functions with cross-core
tpush/tpopalso contain the required pipe setup - every AIC/AIV
tile.tpop_*has a matchingsystem.tfree_* - cross-core tile ops have statically known tile shapes (required for auto pipe setup)
This moves common failures (missing initialize_pipe, missing reserve_buffer / import_peer_buffer, missing tfree,
non-static tile sizes, mismatched cross-core tpop/tfree pairs) from PTO codegen / ptoas time to immediately after
ExpandMixedKernel. The verifier checks pairing by tile value/op; it does not prove that a tfree is placed after the
true last use of that tile.
Design Decisions¶
| Decision | Rationale |
|---|---|
| Move-based CV boundary detection | Explicit tile.move ops mark boundaries — no fragile variable data-flow analysis needed |
boundary_moves map (not a separate enum value) |
Boundary status is derivable from ClassifyMoveDirection at the call sites that need it; storing it as a side map keeps CoreAffinity to the four execution-side categories (CUBE / VECTOR / SHARED / MIXED) |
| MemorySpace-based classification for data-movement ops | tile.load/tile.store/tile.move/tile.reshape serve Cube or Vector depending on which memory they touch; InferTileMemorySpace sets this before the pass runs |
| Fractal TileView on tpop results | tpop result types carry the fractal TileView (NZ/ZN) directly rather than stripping layout — downstream passes and codegen see the correct layout without extra inference |
Pre-tpush tile.move on AIV side |
V→C transfers require data in fractal layout; inserting an explicit tile.move before tpush_to_aic makes the layout conversion visible in IR |
CreateMove propagates layout kwargs |
When the result type has a TileView, blayout/slayout are forwarded as kwargs so the generated tile.move call is self-describing |
| Group keeps original function name | When no existing Group caller: Orchestration call sites work unchanged — no call-site rewriting needed |
| Rewrite existing Group callers | When a Group already calls the InCore (e.g. from OutlineClusterScopes): rewrite it in-place to call AIC + AIV, avoiding redundant Group→Group nesting |
| Preserve standalone Spmd wrappers | When a standalone Spmd calls the InCore: keep FunctionType::Spmd, create a Group callee underneath, and keep core_num / sync_start on the Spmd wrapper |
| Parameters copied to all three functions | Simplifies wiring; DCE removes unused params in downstream passes |
| Recursive compound-stmt handling | Correctly splits mixed ops inside ForStmt, IfStmt, WhileStmt |
| Two-stage post-split loop-state repair | First makes loop-carried state valid, then re-strips iter_args after DCE removes dead shared aliases, with a final DCE to clean up exposed init-value chains |
| Auto-generated pipe setup | Tensor-level mixed kernels do not need handwritten reserve_buffer / import_peer_buffer / initialize_pipe; the pass derives them from cross-core tile ops |
| Auto-generated tfree chains | Consumer-side split kernels insert missing tfree calls, rewrite them to free the canonical popped tile value, and delay obviously early frees within the same block without reordering independent tpop chains |
| Max-slot-size policy | Uses max(all tile byte sizes) as the single initialize_pipe.slot_size, matching the backend assumption of one automatic reserve/import buffer per direction while preserving legacy bidirectional dir_mask=3 behavior |