Pipelines and stage boundaries
These are language contracts and design extensions, not a promise of complete target support. Use the executable examples for current C++ spelling and implementation coverage for admitted lowering.
The implemented software-prefetch path is narrower than the scheduling model below; hardware async transfer and warp specialization remain future work.
Pipeline belongs to Execution because it organizes when one logical spatial sub-hierarchy runs. It is not another memory hierarchy. Its natural parent is therefore visible in the same syntax as any other nest:
PipelinePolicy policy{.window = 2, .interval = 1};
for (auto &nest : parallel(grid_shape)) {
for (auto &subnest : nest.parallel(subnest_shape)) {
for (auto &k : subnest.pipeline(iteration_space, policy)) {
k.stage("produce");
// Producer operations for k.index().
k.stage("consume");
// Consumer operations for k.index().
}
}
}
There is one logical pipeline instance per subnest coordinate. Moving the
pipeline outside that parallel changes the semantics to one pipeline shared
by the parent nest; it is not merely formatting. The pipeline loop adds a
temporal coordinate but does not deepen the spatial execution frontier.
Like parallel and serial, the C++ range executes its body exactly once
during capture. Dereference creates PipelineOp, pushes its body, and returns a
non-copyable iteration handle; k.index() is its staged coordinate. Increment
closes the region after discovering carried values. A multidimensional domain
returns a tuple-like index(). Generated code executes all logical iterations
through a prologue, steady state, and epilogue chosen by scheduling.
Stage boundaries are lexical
A pipeline is a repeated producer/consumer graph, not a loop with only an interval
annotation:
Pipeline = (IterationSet I,
StageSet S,
StageOrder <S,
Dependences D,
Policy)
Dependence edge = (producer_stage, consumer_stage,
iteration_distance, value_or_effect)
k.stage() is a frontend cursor on the pipeline iteration handle, not an
executable marker operation. The first call begins stage zero and each
subsequent call ends the current source segment and begins the next.
k.stage("load") adds an optional compile-time name to the new segment. Capture
is intended to normalize the segments into ordered child regions of the single
pipeline operation, each with identity (PipelineId, ordinal). Current capture
uses OperationKind::STAGE markers in the pipeline body; the normalized region
form and general event protocol are design extensions. The name is a stable
diagnostic and scheduling label, not global identity. Consequently unrelated
pipelines cannot accidentally interleave their stage namespaces. A later fusion
pass may combine pipelines only by constructing a new graph and proving
dependence and resource equivalence.
Stage ordinals are not iteration coordinates, cycle numbers, memory versions, execution levels, or hardware warp IDs. Source order supplies the default same-iteration producer-before-consumer order; SSA, MemorySSA, and effect analysis record the precise edges, including edges that skip phases and loop-carried edges with positive iteration distance. Ordering a pair of stages does not itself invent a whole-hierarchy barrier.
The compiler may infer all stage membership. k.stage(optional_name) is only
the explicit surface for pinning a cut when the programmer wants that
producer/consumer structure to be part of Candidate TileIR:
for (auto &k : subnest.pipeline(k_tiles, policy)) {
auto k0 = k.index();
k.stage("load");
auto a = A.tile(a_origin(k0), a_shape, bounds::zero).load();
auto b = B.tile(b_origin(k0), b_shape, bounds::zero).load();
k.stage("compute");
acc = mma(a, b, acc);
}
a and b are virtual Tile SSA, not source-declared buffers. A GPU schedule
may map the stage-active operations to disjoint participant subsets and replace
the matched library expansion with copy/MMA atoms; a CPU schedule may use tasks
or serialize it. If an engine handoff requires addressable storage, the planner
inserts internal materialization, MemorySSA, versions, events, and barriers. No
target-specific role or mandatory staging buffer leaks into the kernel.
When stage() is absent, all stage membership is open for inference. Once a
pipeline contains an explicit cut, every effectful or tile operation in its
immediate body must follow the first cut. Cursor calls themselves must occur
unconditionally at that body level, so Candidate TileIR has a static stage
graph; stage contents may contain ordinary structured control flow and child
execution regions. Pure iteration-index expressions may precede the first cut
and be used by several stages.
Because a cursor call does not open a C++ block, a and b remain naturally
visible after the cut. Their cross-stage SSA edges are explicit in TileIR. This
is why a cursor cut is preferable to a stage-specific C++ brace scope.
The source fixes producer/consumer cuts while leaving communication, participant subsets, engines, and physical realization open.
The pipeline iteration coordinate and stage identity remain orthogonal:
k.index() == 0 means the first logical iteration. It is useful for a true
prologue special case, but it does not select an execution role.
Scheduling and versioning
For a dependence e = (sp, sc, distance, payload), a legal schedule satisfies:
interval >= 1
Issue(stage s, iteration i) = i * interval + theta(s)
Issue(op in s, i) = Issue(s, i) + delta(op)
Issue(sc, i + distance) >= Issue(sp, i) + latency(e)
Schedule(op, i)
= (Issue(op, i), anchor(op), frontier(op), Active(op), engine(op))
Version(materialized edge or MemoryState, i) -> VersionCoord
theta(s) places a logical stage in the modulo schedule and delta(op) orders
operations within it. window bounds the scheduling window; it is not
the number of logical stage segments and is not blindly copied to every
buffer depth. interval belongs in PipelinePolicy because it is
a primary temporal constraint.
window = 0 lets the planner choose, window = 1 disables cross-iteration
overlap, and window = N permits at most N in-flight iterations without
promising to fill the window. interval must be positive; its default is 1.
The compiler derives:
prologue, steady state, and epilogue;
async-copy and compute issue points;
per-edge live version count;
storage ring indices for materialized edges;
barriers, events, waits, and fence scopes;
resource pressure and legality.
Only a materialized edge’s VersionCoord enters its AddressMap. Pure SSA
edges do not acquire fictitious memory versions.
Issue, completion and release are different events
The schedule equation above abbreviates a supported protocol, not a license to implement asynchronous readiness with a guessed fixed delay. An atom’s issue need not complete its writes or stop its reads. Consumer readiness and storage reuse therefore have different prerequisites:
consumer A completes(v) --+
producer completes(v) --< +--> release(v)
consumer B completes(v) --+ |
v
overwrite the same slot
Both consumers wait for the relevant producer completion/publication. Reuse waits for every actual reader of that version to release it, including reads still in flight after their textual last use. Unrelated consumers do not join that release. Versions sharing a physical ring slot must have nonoverlapping live intervals; a missing protocol or progress guarantee makes the plan illegal, however attractive its predicted latency.
Copy elimination may remove data movement but must preserve any still-needed
publication or release edges. The compiler derives these from visible SSA and
memory effects; opaque atoms supply effect/protocol contracts. These are
internal obligations, not required user owned_by/event annotations. The
planner
owns the resource-constrained formulation and its
Cypress/Tawa comparisons explain the prior
mechanisms informing it.
The scheduler overlaps stage instances subject to dependence latency; storage version count follows liveness rather than source stage count.