Skip to content

LegalizeGraphBoundary Pass

Makes every FunctionType::Graph function legal for the host_build_graph runtime to record and replay: hoists the boundary scalars a Graph body derives, the boundary views it takes and the intermediates it allocates out to its call sites, and rejects unsupported boundaries before runtime submission.

Overview

The host_build_graph runtime records a Graph function's task topology on its first call for a Graph key and replays that recording afterwards. Construction Config values contribute to that key. Replay patches only two things: the addresses of the boundary tensors, and the values of the boundary scalars. Everything else — node count, shapes, dependency edges, block counts — is frozen into the recorded Definition.

That makes four classes of problem possible:

Problem What the runtime does What this pass does
A boundary scalar is derived inside the region Explicitly extracting a value loses its parameter origin and freezes it into the recording; implicit conversion is rejected Step A — hoists the computation to the call site, or leaves it alone when the frozen value is provably the right one
A view of a boundary tensor is taken inside the region Freezes the first call's offset and patches only the address, so a later call reads call one's window Step B — hoists the view to the call site, or accepts it when its window is replay-invariant
The region allocates its own intermediates Records them correctly, on a heap it never reclaims mid-run, so the live set grows with the number of submissions Step C — hoists the allocation to the call site as an InOut boundary tensor
The boundary itself is not cacheable Reports an invalid Graph submission Step D — rejects it at compile time

The first two can produce wrong answers when values lose their replay linkage. The third produces correct answers that run out of memory, or merely slower, as the layer count grows. The last is rejected at runtime; compile-time checks report statically decidable problems against the source program earlier.

Step A — derived boundary scalars

A boundary scalar carries its parameter origin in the runtime's InheritableScalar wrapper. args.scalar(k) returns that wrapper; forwarding it through add_scalar preserves the parameter index that replay refreshes. Construction values read from Config do not carry that origin; the wrapper provides no value-read accessor or implicit integer conversion. Step A moves derived computations outside the Graph body:

@pl.function(type=pl.FunctionType.Graph)
def layer(self, cur, wq, layer_idx: pl.Scalar[pl.INDEX]):
    base = layer_idx * 5120          # <- derived: no argument slot
    ...                              #    frozen at the first call's value

Step A rewrites this so the value arrives as a parameter instead:

# after the pass, conceptually:
def layer(self, cur, wq, layer_idx, base):   # base is now a real boundary scalar
    ...

# and at each call site:
self.layer(cur, wq_view(i), i, i * 5120)     # the arithmetic moved out here

A value is hoistable when its whole expression tree bottoms out in the Graph's own scalar parameters and constants — exactly the set a call site can recompute, since it already supplies those parameters. Scalar arithmetic in PyPTO is a BinaryExpr / UnaryExpr node rather than a Call, so the check recurses through those two base classes and treats everything else as a leaf.

New parameters are appended, not prepended: CoreTaskArgs requires every tensor argument to precede every scalar one.

A bare rename is deleted, not accepted

n = batch computes nothing, so there is nothing to hoist — but leaving it alone would generate int64_t n = batch;, which the runtime rejects because the wrapper has no implicit integer conversion. Explicitly extracting its value would compile, but lose the parameter origin:

const auto batch = args.scalar(0);  // preserves the parameter origin
int64_t n = from_u64<int64_t>(config.scalar_0);  // reads a construction value
g0_params_t0.add_scalar(n);         // the task no longer inherits the parameter

Recording classifies a scalar by its retained parameter origin. The integer copy no longer carries that origin, so it is recorded as static data and every later replay reuses the first call's number. Copying the wrapper itself with auto preserves its origin.

Step A therefore substitutes the name away and erases the binding, so the task reads add_scalar(batch) — the parameter wrapper. Chains collapse to their root in one pass (a = p; b = a; sends both readers to p), and an alias of a hoisted value lands on that value's new parameter. A rename that somehow survives is rejected rather than waved through, by both Step D and the verifier.

Hoistable vs replay-invariant

Not being hoistable is not the same as being illegal. Hoistability answers "can the call site recompute this?"; the runtime only needs "is this the same on every call?", which is strictly weaker. Freezing a value into the recording is a wrong answer only when the value can differ between calls.

Property Question Consequence
hoistable can the call site recompute it? Step A moves it out; it gets a real argument slot
replay-invariant is it the same on every call? it stays where it is, frozen — correctly

ReplayInvariantSet (utils/graph_replay_invariant.h) draws the second line. Three seeds, closed under scalar arithmetic and over names bound to an invariant value:

Seed Why replay reproduces it
a literal trivially
the induction variable of a constant-trip loop recording walks the loop once and bakes each iteration's literal into that iteration's own node; constant bounds mean every later call walks the identical sequence
tensor.dim of a boundary tensor parameter, with a literal axis graph_boundary_matches compares each boundary tensor's ndims, shapes and strides against the recorded GraphBoundarySignature and rejects a submission on any mismatch, so a boundary shape cannot change within one recording

Scalar parameters are deliberately excluded from the invariant set. The runtime patches a boundary scalar's slot on every call — which is exactly what makes one a legal task argument and an illegal frozen view offset.

This is what admits a tiled kernel at all. A slab offset i * TILE cannot be hoisted: the value does not exist at the call site. Every projection, MLP and attention loop in a decoder layer is indexed that way, so rejecting it excluded the whole shape.

DataType::TASK_ID operands are skipped outright rather than classified. A task id is never a boundary scalar — the recording captures dependency structure itself, and the runtime refuses any Graph submission carrying an explicit dependency (explicit_dep_count() != 0), so an id produced outside the region can never reach a replay. This matters because the scalar check runs over every call's arguments, so seeds[0] = seed — an array.update_element into a pl.array of TASK_ID — used to read as a task consuming a boundary scalar while the same id written straight into deps=[...] was accepted.

A scalar that reaches a task and is neither hoistable, a boundary parameter, nor replay-invariant — because it depends on a task output or a tensor read — is rejected with a message naming the variable.

Step B — derived slices of a boundary tensor

Replay patches a boundary tensor's address. A view taken inside the region is re-derived from whatever the recording froze, so it must be taken at the call site instead:

wl = pl.tensor.slice(w, [128, 128], [layer_idx * 128, 0])   # inside the region

Step B moves that slice out and passes the result in as an additional boundary tensor. Each slice site becomes its own parameter with its own fixed shape, which is what the runtime's BOUNDARY_VIEW classification requires — it matches on same-buffer plus offset, with the shape playing no part, so a view whose shape varied between calls could not be classified at all.

The hoisted statements are emitted scalars first, then tensors, because a slice's offset is typically a Step A scalar and the binding has to precede its use. The parameter order is the reverse — tensors before scalars — which is what CoreTaskArgs requires. A view of a region-local tensor stays put.

A view of a hoisted view is hoisted too. Once wl moves out it is a boundary parameter, so wr = slice(wl, ...) is in exactly the position wl was. The body is SSA in definition order, so one forward pass reaches the whole chain — a view can only name a source defined before it. Leaving wr behind is silent: graph_rebind_tensor patches the buffer address from wl but keeps the offset recorded on the first call.

Provenance crosses an in-place call. tmp = kernel(a, tmp) rebinds the buffer to a fresh SSA name, and a view of that name is still a view of a boundary tensor. Tracking only bare alias = var assignments loses the root there, and Step B then skips the view outright — no hoist and no check — so a call-varying offset stays in the region with the first call's window frozen. The rebind is followed through ExplicitReturnedParamIndices, the same return-position -> parameter map orchestration codegen aliases a call result on, so provenance and codegen cannot disagree about which buffer a result names. This applies to a boundary parameter just as much as to a Step C allocation.

The argument that map points at is resolved with CallerSuppliedArg, which splits the three regions of the Submit::args_ contract in ir/expr.h. A Submit legally omits a runtime-allocated Out tail, and its caller-supplied prefix still maps positionally, so requiring full arity would silently drop provenance for every such launch. A parameter in the runtime-allocated gap correctly yields nothing: the runtime creates that buffer, so there is no caller-side root to inherit.

A hoisted view must have a compile-time-constant shape. Replay copies a view's shapes and strides straight from the recorded template and patches only buffer_addr and start_offset, so an extent read from a boundary scalar would apply the first call's shape to a later call's buffer. An extent that is replay-invariant is accepted for the same reason it is elsewhere: it cannot differ, so freezing it changes nothing.

A view has three outcomes, not two. Replay restores a BOUNDARY_VIEW as boundary.start_offset + packed_offset, where packed_offset is the delta recorded on the first call; only the address, size, owner and version are patched. So the offset is frozen, and that is wrong exactly when it can move:

Every non-source operand is Outcome Why
derivable hoisted to the call site the caller can rebuild the view
replay-invariant only left in place frozen == correct, and the call site has no name for it
neither, or a mix of the two rejected the frozen delta would be call one's

The mixed case is the one that matters. off = layer_idx + i * TILE can neither be hoisted (i does not exist at the call site) nor frozen (layer_idx is patched per call), and it is what a tiled region produces if it indexes weights by both. Anything built on top of a view left in place — a view of it, or an alias — is held to the same rule rather than hoisted, since the call site has no name for any of them either.

A varying offset is fine, and that is not obvious. Codegen clamps a runtime view to min(declared, source.shapes[i] - offset[i]), so the actual shape is offset-dependent even when the IR extent is constant. It does not reach replay because a hoisted view is passed as its own boundary tensor rather than re-derived in the region: graph_tensor_from_boundary tries BOUNDARY_EXACT across all boundary tensors before any BOUNDARY_VIEW, and a node consuming the view matches the view itself, so graph_rebind_tensor replaces the whole GraphTensor — shapes, strides and extent_elem included. The frozen-shape behaviour belongs to BOUNDARY_VIEW, which is the in-region case this step hoists out. Requiring the offset to be provably in bounds would reject the motivating case, a per-layer layer_idx * 5120.

Step C — allocations inside the region

pl.create_tensor at the top level of the region is hoisted to the call site, where it becomes an InOut boundary tensor:

# Before
@pl.jit.graph
def layer(a: pl.Tensor, acc: pl.InOut[pl.Tensor]):
    tmp = pl.create_tensor([ROWS, COLS], pl.FP32)   # allocated per submission
    ...
    return acc

# After — the call site owns the buffer
def layer(a, acc, tmp: pl.InOut[pl.Tensor]): ...
tmp__graph_arg0 = pl.tensor.create([ROWS, COLS], pl.FP32)
acc = layer(a, acc, tmp__graph_arg0)

Recording it in place is correct — codegen lowers a create into a batched alloc_tensors and the runtime records that as a kernel-less node, the same shape submit_dummy_task records — but the buffer comes off the graph heap, which task_allocator.h never reclaims mid-run ("The whole graph must fit at once; nothing is reclaimed mid-run"). The live set then grows with the number of submissions rather than staying flat: a decoder layer holding 14 intermediates costs 14 × N over N recorded layers. Hoisting puts the buffer back on the ordinary reclaimable heap and empties the region of allocation nodes.

simpler's hand-written examples/a2a3/host_build_graph/qwen3_14b_decode scene does the same thing by hand, for the same stated reason — "This keeps the temporary live set flat in layer count and fits the default ring configuration."

The hoisted allocation parameter remains InOut: the region writes the buffer, and declared In codegen would emit add_input, the launch would never register as a writer, and a caller that hoisted the allocation out of its own loop would get no ordering between successive launches over it. Hoisting runs after direction and dependency derivation, so newly introduced allocation parameters retain conservative InOut ordering. This does not restrict existing write-only boundary parameters: their Out signature is preserved, and DeriveCallDirections selects OutputExisting for an existing first-write buffer or promotes it to InOut when ordering requires it.

A hoisted allocation is a boundary tensor, so a view of it is held to the Step B rule above rather than left alone. That is not optional bookkeeping: the GraphBoundaryLegalized verifier treats every tensor parameter as a boundary root, so a view left in place with a window that can move would make this pass produce IR its own verifier rejects.

Two allocations are deliberately left where they are:

Left in place Why
tensor.full Orchestration codegen has no lowering for it at the call site either, so hoisting would only move the failure. Step D rejects it outright
A create under a loop It is a fresh buffer per iteration. Collapsing N buffers into one parameter would make iterations alias, and the cross-task edges that would have to re-serialise them were derived by AutoDeriveTaskDependencies, well upstream of here

What recording cannot reproduce either way is a shape read from a boundary scalar. The extent is copied into the node and the buffer's address derived from it, and replay never re-runs the body, so a later call with a larger extent is handed the first call's buffer — a wrong address layout rather than a fallback. Step D rejects that, before Step C runs, so every allocation Step C hoists has a compile-time-constant shape.

Prerequisite: a Graph's returns name their parameters

Step B and Step C can append writable (Out/InOut) parameters, and that is what made automating this hoist a rework rather than a wiring job. Orchestration codegen maps a call result onto one of the callee's Out/InOut params through return_lineage::ExplicitReturnedParamIndices — a pointer-identity read of the callee's ReturnStmt — and falls back to "the single Out/InOut param" only when that map yields nothing:

// GenerateSingleReturnAlias, orchestration_codegen.cpp
INTERNAL_CHECK_SPAN(returned_idx.has_value() || out_indices.size() == 1, call->span_)

A Graph body is c_1 = layer_incore_0(a, c); return c_1 once OutlineIncoreScopes has run — a rebind, not the parameter — so a Graph has always relied on that fallback, and the fallback stops existing at the second InOut parameter.

NormalizeReturnOrder supplies what the hoists need: it canonicalizes a Graph's tensor returns the way it already did for kernels and wrappers (canonicalization only — no Graph return is reordered), and IRProperty::ReturnParamsExplicit covers Graph, so the nineteen passes between there and here cannot quietly undo it. That landed separately in PR #2618, so this step depends on it rather than providing it — which is why _legalize_outlined in the unit tests runs NormalizeReturnOrder: without it the map is all-nullopt and the hoists silently do nothing.

Step D — boundary legality

Scalar-only control flow that submits no tasks may still read boundary values, for example for i in pl.range(n). Codegen places each value read during graph construction in a generated simpler::hbg::ConfigBase subclass. Its hash and equality compare the same encoded scalar bits. The runtime includes this Config in the Graph key, so a changed construction value records a distinct definition. The Config also includes tensor shapes, strides, storage sizes, alias partitions, and relative offsets within each partition. A changed boundary layout therefore selects a compatible definition; relocated buffers and uniformly shifted views retain the same key. Absolute device addresses are not part of this key. The body decodes these fields with from_u64<T>; boundary scalar handles have no value-read accessor. Task argument forwarding retains InheritableScalar handles, including floating-point parameters, so replay preserves their origin. The orchestration entry reads its scalar inputs with orch_args.scalar<T>(i), which decodes values directly in both supported runtimes.

Check Why
The compilation targets host_build_graph GraphTaskArgs and rt_submit_graph exist only in that runtime's orchestration API, and codegen emits them unconditionally, so a Graph built against the default tensormap_and_ringbuffer yields orchestration C++ that names undeclared symbols. Reported here, against the function the user wrote, rather than as a C++ error in generated code
At least one tensor parameter A graph with an empty boundary has nothing to patch on replay; the runtime refuses to cache it
At most 128 tensor parameters GRAPH_MAX_TENSOR_ARGS — the boundary is a fixed-size GraphTaskArgs
At most 64 scalar parameters GRAPH_MAX_SCALAR_ARGS. Checked after Step A, which adds scalar parameters, so a signature that fit before hoisting can stop fitting after
No runtime-allocated boundary output Out describes write-only access and is legal with caller-supplied storage. Call-site OutputExisting and InOut are supported; ArgDirection::Output is rejected because it requests a new allocation
Scalar parameters are In A boundary scalar is passed by value and replayed from the call site
Returns only its own parameters rt_submit_graph yields a valid task id only on a cache hit, so nothing can depend on a graph call's result. return c for an InOut parameter is the in-place spelling and is fine; a computed value is not
Between 1 and 1024 launched tasks graph_execution_storage_layout refuses a node count of zero as well as one over GRAPH_MAX_NODES. A launch in a loop counts once per iteration, not once per call site, and system.task_dummy counts too — it lowers to rt_submit_dummy_task, and ExpandManualPhaseFence inserts them automatically. Allocations record nodes too, counted as an upper bound so that passing this check means the runtime will accept the Graph. Codegen collects every eligible create in a statement list — an intervening launch does not close the batch — and packs them at most kAllocTensorsArgs (16) to an alloc_tensors. Two of its three ineligibility rules cannot fire here (a shape reading a local is already rejected as non-constant; an already-declared var cannot recur under SSA), so those creates are counted exactly. The third can — an injected GM pipe buffer leaves the shared batch when its core_num reads a body-local, which only the emitter's use-resolution knows — so each of those is charged its worst case of one node. The batch size and the GM-pipe predicate are shared with the emitter in utils/alloc_batching.h rather than restated here. Counted after the hoisting steps, unlike every other check in this table: Step C removes allocation nodes, so the pre-hoist body would reject a Graph that fits and disagree with the verifier, which re-derives the same count from the rewritten IR
No allocation under a runtime loop or branch Each records a node, so a count that varies between calls is a topology that varies
Allocation shapes are compile-time constants Recording copies the shape into the node and derives the buffer address from it; a shape reading a boundary scalar is frozen at the first call's value
No tensor.full in the region Orchestration codegen has no lowering for it and rejects it as a misplaced tensor op
No launch under a runtime loop, while or if The recording fixes the topology on the first call and replays it unchanged, so a launch count or branch that can differ between calls would silently replay call one's shape
No call-varying scalar computed inline at a task argument Step A hoists named derived values; an expression written inline at the call has no name to hoist and no boundary slot, so it would be frozen at the first call's value. A replay-invariant expression is accepted — the freeze is harmless
No Graph calls a Graph The runtime cannot record a graph from inside one it is already recording
Every parameter supplied at the call site A Submit may normally pass a prefix and let the runtime allocate the tail Out params; a Graph has no such tail
No explicit dependencies on the launch The runtime rejects explicit dependencies across a Graph boundary
No dispatch predicate on the launch A predicate on a graph launch is neither honoured nor rejected — the runtime silently zeroes it, so the region would run unconditionally

Position in the pipeline

Runs after the final Simplify and immediately before MaterializeRuntimeScopes.

That position is forced from both sides. DeriveCallDirections and AutoDeriveTaskDependencies must already have run, so argument directions and cross-task edges are known. MaterializeRuntimeScopes must not yet have run, so no scope wrapper has been placed around the statements Step A moves.

Pass properties

  • Requires: SplitIncoreOrch, CallDirectionsResolved
  • Produces: GraphBoundaryLegalized, CallDirectionsResolved

CallDirectionsResolved is re-declared because the pass rewrites call arguments and their direction attrs; MaterializeRuntimeScopes, which runs next, requires that property.

The GraphBoundaryLegalized verifier re-derives the topology, node count and hoisting post-conditions independently, so a later pass that reintroduces a rejected state is caught. It shares exactly one thing with this pass: ReplayInvariantSet. That is not a decision this pass made and the verifier rubber-stamps — it is a reading of the runtime's own contract, and two hand-written copies of it could disagree, leaving the verifier rejecting IR the pass had just produced.

Not yet handled

Hoisting an allocation made under a loop — it is a fresh buffer per iteration, so the call site would have to allocate an array of them rather than one (see Step C). Hoisting one whose shape reads a boundary scalar, which Step D rejects rather than rebuilding at the call site. And packing a boundary of more than 128 tensors into a scratch arena.

Neither does anything hoist a call-site allocation out of the caller's own loop: a create lands immediately before the launch, so each call still gets its own buffer. The win is that the buffer now comes off the ordinary reclaimable heap instead of the graph heap, which is independent of where the caller puts it.

See also

Host control tensors

With host_build_graph, orchestration-level tensor.read and tensor.write operate on ready CPU tensor arguments. Generated argument metadata selects HOST/NONE transfers for those arguments; ordinary device tensors retain their normal upload behavior. This metadata is preserved by saved artifacts and worker reuse, including distributed chip dispatch. Scheduler predicate operands remain device tensors and use the normal device transfer path. Control tensors must stay alive until the call completes.

Distributed dispatch also accepts an existing simpler.Tensor backed by HOST storage for a host control argument. The address-free descriptor is reused with TensorTransfer.NONE. Keep the owning Buffer and any fork-inherited storage alive until completion. A wire Tensor snapshots its view metadata, so construct another view when the shape, strides, or storage changes.

A control tensor (including a view or loop-carried alias) cannot also be passed to a device kernel through the same argument. Pass host control data and device data in separate parameters. Host writes do not upload to device storage. Runtime allocations and values produced by device tasks in the current graph cannot be read during host graph construction. Passing a DeviceTensor for a host control parameter raises an error before dispatch.