This document is relevant for: Trn2, Trn3
In-Place to Out-of-Place Rewrite Pass#
This pass runs as part of the FX passes stage of the compilation pipeline. It rewrites in-place operations (e.g.,
add_) to out-of-place equivalents (e.g.,add) because XLA/HLO does not support in-place semantics.
Problem Statement#
The XLA/HLO backend does not support in-place tensor semantics. Operations like x.add_(y) or operator.setitem(buf, idx, value) mutate a tensor in-place, which has no direct HLO equivalent. These operations must be converted to out-of-place equivalents that produce new tensors, and all downstream references to the original tensor must be rewritten to use the new result.
Pipeline Position#
DeviceRewriterPass
│
▼
AliasingOutputRewritePass
│
▼
InPlaceToOutOfPlacePass ◄── this pass
│
▼
NkiKernelWriteBackendConfigPass
│
▼
CollectiveReplicaGroupsPass
This pass runs after the aliasing pass. By the time it executes, the aliasing pass has already:
Identified which inputs are mutated.
Built the
io_mapfor HLO aliasing.Stashed
root_inputinnode.metafor mutating nodes that trace back through views to an input placeholder.
This pass consumes that root_input metadata to name replacement nodes (e.g. kv_cache_modified) so debug output and later passes can identify which input was modified.
Algorithm Design#
Overview#
The core method _convert_inplace_ops takes a snapshot of the node list (because the graph is mutated during iteration) and processes each node in forward order. It handles three categories of in-place operations:
operator.setitem— converted to scatter ops.copy_— converted toexpand_as+slice_scatter.General in-place methods (
add_,mul_, …) — target name is stripped of the trailing underscore.
After each conversion, two things happen:
The replacement node is renamed to
<original>_modified(usingroot_inputfrom the aliasing pass when available)._update_subsequent_opsrewrites all downstream references.
Node Filtering#
The pass only targets call_method nodes whose target ends with a single trailing underscore (e.g. add_, copy_). Double-underscore dunder methods like __setitem__ are excluded. operator.setitem nodes (call_function) are handled separately via _convert_setitem.
General In-Place Methods#
For operations like add_, sub_, mul_, div_, fill_, zero_, clamp_, relu_, pow_, abs_, etc., the conversion is straightforward: strip the trailing underscore to get the out-of-place equivalent.
The pass verifies the equivalent exists on torch.Tensor via hasattr before rewriting. If no equivalent exists, a NotImplementedError is raised.
Before: call_method add_ (x, y) # mutates x in-place
After: call_method add (x, y) # produces new tensor x_modified
The node is rewritten in-place (its target attribute is changed) rather than being replaced with a new node. This preserves the node’s position in the graph and its metadata.
copy_ Handling#
Tensor.copy_ is special because it accepts a source tensor that is broadcastable with the destination. For example, copying a [1, 64] source into a [32, 64] destination is valid — PyTorch broadcasts the source automatically.
Since XLA/HLO cannot lower copy_ directly, the pass replaces it with two operations:
expand_as(source, dest)— broadcasts the source to match the destination shape. This is necessary becauseslice_scatterrequires the source and destination to have compatible shapes.slice_scatter(dest, expanded_source)— produces a new tensor with the same content as the expanded source, typed as an update ofdest.
Before:
call_method copy_ (dest, src)
After:
call_method expand_as (src, dest) # broadcast src → dest shape
call_function slice_scatter (dest, expanded) # out-of-place "copy"
The original copy_ node is erased from the graph after all its uses are redirected to the slice_scatter result.
setitem Handling#
operator.setitem(buf, idx, value) is the most complex case because the index can be an int, slice, tuple, or tensor (FX Node). The _convert_setitem method delegates to _build_scatter which dispatches based on index type.
If _build_scatter returns a valid scatter node, the original setitem is erased and replaced. If it returns None (unsupported index type), the setitem is kept but downstream references are still rewritten.
Scalar-to-Tensor Promotion#
Many setitem calls use scalar values (e.g. buf[0] = 0.0). Scatter operations require tensor operands, so two helpers handle promotion:
_ensure_tensor_node(gm, value, buf)— wraps a scalar intotorch.full_like(buf, fill_value=value). Ifvalueis already an FX Node, it is returned unchanged._ensure_select_src(gm, value, buf, dim, idx)— forselect_scatter, the source must have the shape ofbufwith dimensiondimremoved. Whenvalueis a scalar, this creates a reference tensor viatorch.select(buf, dim, idx)and then broadcasts the scalar into that shape withtorch.full_like.
Integer Index#
A single integer index (e.g. buf[3] = value) maps to torch.select_scatter(buf, value, dim=0, index=3). The value is promoted via _ensure_select_src if it is a scalar.
Before: setitem(buf, 3, value)
After: select_scatter(buf, value, dim=0, index=3)
Slice Index#
A slice index (e.g. buf[2:8] = value) is handled by _slice_scatter, which chooses between three strategies based on the slice parameters:
start == 0, step == 1 — direct
torch.slice_scatter. This is the common fast path.Non-zero start with step > 1, or negative start — direct
torch.slice_scatterwith explicitstart/end/stepkwargs. XLA handles these offsets correctly.Non-zero positive start, step == 1 — the value and a boolean mask are padded to the full buffer shape, then
torch.whereselects between the padded value and the original buffer. This mirrors the HLO pad+select pattern that XLA produces for setitem.The padding is constructed in
F.padformat (pairs in reverse dimension order). Only dimensions from the last back to the scatter dimension need entries; earlier dimensions are unaffected.If no
example_valueshape metadata is available on the buffer node, the pass falls back toslice_scatterwith explicit kwargs.
When the value is a scalar, the sliced region of the buffer is first extracted via aten.slice.Tensor to obtain a shape reference, then the scalar is broadcast into that shape via _ensure_tensor_node.
Tuple Index#
Tuple indices (e.g. buf[2:4, :, 1:3]) are handled by _tuple_scatter. The tuple is first normalized by _resolve_tuple_index:
Trivial
slice(None)entries (full-dimension slices) are dropped.If an
Ellipsisis present, elements before it get positive dimension indices (0, 1, …) and elements after it get negative indices (-N, …, -1).
After normalization, the non-trivial entries are processed:
Tensor/bool mask in tuple — falls back to
index_putsince scatter ops cannot handle advanced indexing.Single non-trivial dimension — delegates to
select_scatter(for int) or_slice_scatter(for slice).Multiple non-trivial dimensions — uses a two-phase slice-scatter chain:
Forward pass: slice the buffer down through each outer dimension using
aten.slice.Tensor, keeping a reference to each intermediate sliced buffer.Inner scatter: scatter the value into the innermost dimension using
select_scatteror_slice_scatter.Reverse pass: scatter the modified inner region back up through each outer dimension using
slice_scatter, using the intermediate sliced buffer (not the original full buffer) as the parent at each level.
This chaining lowers correctly to HLO
dynamic-update-slice.
Example: buf[2:4, 1:3] = value (2 non-trivial dims)
Forward:
sliced_0 = aten.slice(buf, dim=0, start=2, end=4)
Inner scatter:
scattered = slice_scatter(sliced_0, value, dim=1, start=1, end=3)
Reverse:
result = slice_scatter(buf, scattered, dim=0, start=2, end=4)
Tensor Index (FX Node)#
When the index is an FX Node (a tensor computed at runtime):
Boolean mask —
torch.where(mask, value, buf)is used instead ofindex_put, because XLA cannot lowerindex_putwith boolean indices. The dtype is checked viaexample_valuemetadata on the index node.Integer tensor —
buf.index_put((idx,), value)is emitted directly.
In both cases, scalar values are first promoted to tensors via _ensure_tensor_node.
Downstream Reference Rewriting#
After every conversion, _update_subsequent_ops ensures the graph maintains valid SSA form. It walks all nodes that appear after the replacement node and substitutes every reference to the original tensor with the new result.
The method only rewrites call_method, call_function, and output nodes — placeholder and get_attr nodes cannot reference other nodes and are skipped.
_replace_in_structure performs the substitution recursively through nested args and kwargs, handling tuples, lists, and dicts at any depth. It uses identity comparison (is) rather than equality to avoid false matches between distinct nodes.
Before _update_subsequent_ops:
x = placeholder
x_modified = add(x, y) # just converted from add_(x, y)
z = mul(x, w) # still references old x
output(x) # still references old x
After _update_subsequent_ops:
x = placeholder
x_modified = add(x, y)
z = mul(x_modified, w) # updated to x_modified
output(x_modified) # updated to x_modified
Only nodes after the replacement in graph order are rewritten. Nodes before it (including the replacement’s own arguments) are left untouched. This preserves SSA dominance: every use of a value is dominated by its definition.
When multiple in-place operations target the same tensor, each conversion chains correctly because the previous conversion already rewrote downstream references. The second in-place op now references the first replacement, and after its own conversion, downstream references are updated again:
Original:
x = placeholder
add_(x, y)
mul_(x, z)
output(x)
After converting add_:
x = placeholder
x_modified = add(x, y)
mul_(x_modified, z) # already updated by _update_subsequent_ops
output(x_modified)
After converting mul_:
x = placeholder
x_modified = add(x, y)
x_modified_2 = mul(x_modified, z)
output(x_modified_2)
Node Naming#
Replacement nodes are named <root>_modified where <root> is:
node.meta["root_input"]if set by the aliasing pass (this happens when the mutation traces back through view ops to an input placeholder), ororiginal_input.nameas a fallback.
This naming convention makes it easy to identify which input was modified when inspecting graph dumps or debug logs.
Example#
A model that updates a KV-cache buffer via setitem:
Before:
placeholder kv_cache
placeholder new_keys
setitem (kv_cache, slice(0, 32), new_keys)
output (kv_cache,)
After:
placeholder kv_cache
placeholder new_keys
slice_scatter kv_cache_modified (kv_cache, new_keys, dim=0, end=32)
output (kv_cache_modified,)
The io_map from the aliasing pass ({0: 0}) remains valid because the output still corresponds to the same input — it is just the out-of-place version.
Source Location#
vllm_neuron/fx_passes/inplace_rewrite_pass.py
This document is relevant for: Trn2, Trn3