Skip to content

Commit 8e1034e

Browse files
author
Ethan Che
committed
[Pallas] Defer transposed load masks to the consumer-layout _mask_to
A Pallas tiled load masks out-of-bounds lanes multiplicatively in the load's own layout (ref[idx] * mask). When the value is only relayouted by an axis permutation (transpose / .T) before a dot or reduction, that consumer already inserts _mask_to(x, 0), which re-materializes the mask in the consumer layout. Masking early, in the pre-permute layout, defeats Mosaic's elision of all-true dynamic masks and is measurably slower (e.g. a [Q, H, D] -> [H, Q, D] attention load). The root cause is that a load reports masked_value == 0 unconditionally, so the downstream _mask_to looks redundant and is dropped, leaving only the slow eager mask. Triton is unaffected: tl.load(..., other=0) zeroes real data, so a relayout just permutes already-zero lanes. Add defer_pallas_load_masks (FX graph pass, Pallas-only): for each load whose masked tile dim is provably re-masked downstream -- every use reaches a _mask_to(_, 0) through pure axis-permutation ops with the dim surviving as a unique tile dim -- record the deferred block ids and mark the load's masked value unknown so remove_unnecessary_masking keeps the _mask_to. Load codegen then skips the eager mask for those dims. Correctness: the crossed ops are restricted to aten.permute.default (pure axis permutation; view/reshape excluded since they can regroup the masked dim's elements). Pallas-gated at the call site (marking masked_value on a Triton load would disable block_ptr lowering). Profitability is a positional gate on top of that proof. Profiling on TPU (standard tiled matmul) shows the win is positional: masking a tile axis is cheap when that axis is in the last-two (sublane/lane vreg-tile) dims and expensive (~+70% load/scalar traffic, MXU stalls) when it is a major/outer dim. A transpose relocates the axis, so we defer only when it moves the masked axis from major (at the load, where eager masking is expensive) to last-two (at the consumer, where masking is cheap). The reverse direction would make deferral ~1.5x slower, so it is explicitly not deferred. This gate also subsumes the 'must cross >=1 relayout' check, given a rank-preserving relayout set. Measured: dense-KV GDPA ~1.3x faster (B=768 q4096: 7.06 -> 5.31 ms, 213 -> 283 TFLOP/s); fully-jagged neutral; accuracy unchanged (rel-L2 ~0.0023 = bf16 precision). Validated on TPU: full test_pallas.py and the compact_worklist suite pass.
1 parent 3b32b20 commit 8e1034e

4 files changed

Lines changed: 402 additions & 2 deletions

File tree

helion/_compiler/device_ir.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from .loop_dependency_checker import LoopDependencyChecker
5555
from .matmul_utils import tensor_matmul_replacement
5656
from .matmul_utils import torch_matmul_replacement
57+
from .node_masking import defer_pallas_load_masks
5758
from .node_masking import remove_unnecessary_masking
5859
from .roll_reduction import ReductionRoller
5960
from .source_location import current_location
@@ -2959,10 +2960,13 @@ def lower_to_device_ir(func: HostFunction) -> DeviceIR:
29592960
promote_cute_root_graph_host_tensors(device_ir.graphs, promotions)
29602961
for graph in device_ir.graphs:
29612962
prepare_graph_lowerings(graph.graph)
2963+
defer_load_masks = CompileEnvironment.current().backend.name == "pallas"
29622964
for graph in device_ir.graphs:
29632965
validate_host_tensor_usage(graph.graph)
29642966
add_tile_with_offset_metadata(graph)
29652967
remove_unnecessary_tile_index(graph.graph)
2968+
if defer_load_masks:
2969+
defer_pallas_load_masks(graph.graph)
29662970
remove_unnecessary_masking(graph.graph)
29672971

29682972
# TODO(hinriksnaer): extract into a separate step? everything below

helion/_compiler/node_masking.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,39 @@
2929
ValueRangesAny = ValueRanges[Any]
3030

3131

32+
# Relayout ops a load's out-of-bounds mask may be deferred *through*: the mask is
33+
# re-materialized later, in the consumer's layout, by a downstream ``_mask_to``
34+
# (see ``defer_pallas_load_masks``).
35+
#
36+
# Restricted to ops that permute tile axes WITHOUT regrouping the masked
37+
# dimension's elements, so the masked dimension's set of valid/invalid lanes is
38+
# preserved exactly (only its axis position changes). ``permute`` is the only
39+
# such op needed today -- ``transpose``/``.T`` lower to it.
40+
#
41+
# Deliberately NOT included:
42+
# * ``view``/``reshape``: even when the masked block id still appears exactly
43+
# once in the output shape, a reshape can regroup elements so the new
44+
# per-axis mask (``arange < extent``) selects different flat positions than
45+
# the eager load mask did -- e.g. a ``[B, 2]`` tile with 3 valid rows
46+
# reshaped to ``[2, B]`` (old invalid flat lanes 6,7 -> new mask zeroes 3,7,
47+
# dropping valid data and leaking invalid data). "the dim survives once" is
48+
# necessary but NOT sufficient; admitting these needs a stride/lane-set
49+
# equivalence proof, not just a dim-count check.
50+
# * ``expand``/``stack``/``gather``: pass the masked *value* through but can
51+
# replicate or relocate padded lanes into valid ones.
52+
# * ``squeeze``/``unsqueeze``/``alias``: safe in principle (no regrouping) but
53+
# left out until they have their own deferral tests.
54+
#
55+
# IMPORTANT: every op here must be RANK-PRESERVING. ``defer_pallas_load_masks``
56+
# relies on that: its profitability gate (masked axis is a major dim at the load
57+
# but a last-two dim at the consumer) doubles as the old "a relayout actually
58+
# moved the axis" check *only* because a same-shape direct ``_mask_to`` cannot put
59+
# an axis in both positions at once. A rank-changing op (squeeze/unsqueeze/view/
60+
# reshape) would break that, and an explicit relayout-crossed check would need to
61+
# be reinstated.
62+
_RELAYOUT_TARGETS = frozenset({torch.ops.aten.permute.default})
63+
64+
3265
def mask_node_inputs(
3366
node: torch.fx.Node,
3467
other: float | bool = 0,
@@ -184,6 +217,161 @@ def recompute_masked_values(graph: torch.fx.Graph) -> None:
184217
node.meta["masked_value"] = cached_masked_value(node)
185218

186219

220+
def defer_pallas_load_masks(graph: torch.fx.Graph) -> None:
221+
"""Defer a Pallas load's eager out-of-bounds mask to a downstream ``_mask_to``.
222+
223+
Pallas load codegen materializes a tile's out-of-bounds mask multiplicatively
224+
in the load's own layout (``ref[idx] * mask``). When the loaded value is only
225+
*relayouted* (an axis permutation; see ``_RELAYOUT_TARGETS``) and then consumed
226+
by a dot or reduction, that consumer already inserts a ``_mask_to(x, 0)`` which
227+
can re-materialize the same mask in the consumer's layout. The mask is dynamic
228+
(``arange < extent``), so it cannot be elided even when logically all-true;
229+
applying it in the pre-relayout layout therefore keeps a live op on the path
230+
into the relayout.
231+
232+
For each load whose masked tile dim is provably re-masked downstream, we:
233+
234+
* record the deferred block ids on the load so Pallas load codegen skips the
235+
eager mask for those dims, and
236+
* mark the load's masked value unknown so ``remove_unnecessary_masking`` keeps
237+
the downstream ``_mask_to`` (it is no longer redundant once the load is not
238+
pre-masked).
239+
240+
Correctness rests on a single dataflow fact: *every* use of the load reaches a
241+
``_mask_to(_, 0)`` crossing only ``_RELAYOUT_TARGETS`` ops, with the masked
242+
dim still present as a standalone tile dim at each step. Because those ops
243+
only permute axes (they do not regroup the masked dim's elements), the later
244+
per-axis mask covers exactly the lanes the eager mask would have. The
245+
standalone-dim check is necessary but not sufficient on its own -- the
246+
correctness guarantee comes from restricting the crossed ops to pure axis
247+
permutations (so e.g. ``reshape`` is excluded; see ``_RELAYOUT_TARGETS``). A
248+
use that does not re-mask (store, elementwise, reduction without a mask) keeps
249+
the eager load mask.
250+
251+
Profitability is a *positional* gate on top of that correctness proof. A mask
252+
on an axis inside the last-two (sublane/lane) dims is a vectorized per-register
253+
op, while a mask on a major (outer) axis is applied per outer row and is much
254+
more work. So defer only when the masked axis is a major dim at the load and
255+
the relayout carries it into the last-two dims at the consumer ``_mask_to``;
256+
deferring in the reverse direction would move the mask onto the more expensive
257+
axis, so it is not done. This gate also subsumes the "a relayout actually
258+
moved the axis" requirement (see the loop body).
259+
260+
Pallas-only: Triton masks loads as real data (``tl.load(..., other=0)``), so
261+
relayout never moves unmasked lanes and there is nothing to defer.
262+
"""
263+
from ..language.memory_ops import load as load_op
264+
from .aten_lowering import passthrough_masked_value
265+
from .compile_environment import CompileEnvironment
266+
267+
env = CompileEnvironment.current()
268+
269+
def dim_index(node: torch.fx.Node, block_id: int) -> int | None:
270+
"""Index of ``block_id`` in ``node``'s value, or None unless it appears as
271+
exactly one standalone dimension (this doubles as the survives-uniquely
272+
check)."""
273+
val = node.meta.get("val")
274+
if not isinstance(val, torch.Tensor):
275+
return None
276+
hits = [
277+
i
278+
for i, size in enumerate(val.size())
279+
if env.resolve_block_id(size) == block_id
280+
]
281+
return hits[0] if len(hits) == 1 else None
282+
283+
def is_major_dim(node: torch.fx.Node, block_id: int) -> bool:
284+
# An outer dim, outside the last-two (sublane, lane) vreg tile, where a
285+
# mask is applied per outer row rather than as a per-register op.
286+
idx = dim_index(node, block_id)
287+
return idx is not None and idx < node.meta["val"].ndim - 2
288+
289+
def is_last_two_dim(node: torch.fx.Node, block_id: int) -> bool:
290+
# Inside the last-two (sublane/lane) vreg tile, where a mask is a
291+
# vectorized per-register op.
292+
idx = dim_index(node, block_id)
293+
return idx is not None and idx >= node.meta["val"].ndim - 2
294+
295+
def is_relayout(node: torch.fx.Node) -> bool:
296+
if node.op != "call_function" or node.target not in _RELAYOUT_TARGETS:
297+
return False
298+
lowering = node.meta.get("lowering")
299+
return getattr(lowering, "masked_value_fn", None) is passthrough_masked_value
300+
301+
def is_remask(node: torch.fx.Node, src: torch.fx.Node, block_id: int) -> bool:
302+
# A zero-fill ``_mask_to`` on ``src`` that re-masks ``block_id`` with that
303+
# axis in the last-two dims (the profitable place to apply the mask).
304+
return (
305+
node.op == "call_function"
306+
and node.target is _mask_to
307+
and node.args[0] is src
308+
and node.args[1] == 0
309+
and is_last_two_dim(node, block_id)
310+
)
311+
312+
def all_uses_remask(
313+
node: torch.fx.Node, block_id: int, memo: dict[torch.fx.Node, bool]
314+
) -> bool:
315+
cached = memo.get(node)
316+
if cached is not None:
317+
return cached
318+
memo[node] = False # conservative guard against revisiting mid-walk
319+
users = list(node.users)
320+
result = bool(users)
321+
for user in users:
322+
if is_remask(user, node, block_id):
323+
continue
324+
if (
325+
is_relayout(user)
326+
and dim_index(user, block_id) is not None
327+
and all_uses_remask(user, block_id, memo)
328+
):
329+
continue
330+
result = False
331+
break
332+
memo[node] = result
333+
return result
334+
335+
changed = False
336+
for node in graph.find_nodes(op="call_function", target=load_op):
337+
val = node.meta.get("val")
338+
if not isinstance(val, torch.Tensor):
339+
continue
340+
candidates = {
341+
block_id
342+
for size in val.size()
343+
if (block_id := env.resolve_block_id(size)) is not None
344+
}
345+
deferred: set[int] = set()
346+
for block_id in candidates:
347+
# Profitability gate: defer only when the masked axis is a major/outer
348+
# dim at the load and a relayout carries it into the last-two
349+
# (vreg-tile) dims at the consumer ``_mask_to``. A mask on a last-two
350+
# axis is a per-register op; a mask on a major axis is applied per
351+
# outer row, so this is the only direction that moves the mask onto a
352+
# cheaper axis (the reverse would move it onto a more expensive one).
353+
#
354+
# This also subsumes the "must cross >=1 relayout" check: with a
355+
# rank-preserving relayout set, a direct ``_mask_to`` shares the load's
356+
# shape, so ``block_id`` cannot be both major at the load and last-two
357+
# at the consumer (see the note on ``_RELAYOUT_TARGETS``).
358+
if not is_major_dim(node, block_id):
359+
continue
360+
if not node.users:
361+
continue
362+
if all_uses_remask(node, block_id, {}):
363+
deferred.add(block_id)
364+
if deferred:
365+
node.meta["pallas_deferred_mask_block_ids"] = frozenset(deferred)
366+
node.meta["masked_value"] = None
367+
changed = True
368+
369+
if changed:
370+
# Drop stale masked-value caches that assumed the load was pre-masked, so
371+
# the surviving ``_mask_to`` nodes are not wrongly judged redundant.
372+
recompute_masked_values(graph)
373+
374+
187375
def getitem_masked_value(
188376
getitem_node: torch.fx.Node,
189377
) -> float | bool | None:

helion/_compiler/pallas/codegen.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def _load_mask_expr(
7878
indexing_patterns = _get_indexing_patterns(state, tensor)
7979
env = CompileEnvironment.current()
8080
output_sizes = [*output_val.size()]
81+
# Dims whose mask has been deferred to a downstream ``_mask_to`` by
82+
# ``defer_pallas_load_masks`` -- masked later in the consumer layout instead.
83+
deferred = state.fx_node.meta.get("pallas_deferred_mask_block_ids") or frozenset()
8184
mask_exprs: list[str] = []
8285
dtype_str: str | None = None
8386
out_dim = 0
@@ -100,8 +103,10 @@ def _load_mask_expr(
100103
# always valid, and applying a block-sized mask would broadcast
101104
# the dim from 1 to block_size, causing shape mismatches.
102105
dim_size = tensor.shape[tensor_dim]
103-
if (not isinstance(dim_size, int) or dim_size > 1) and _tile_needs_mask(
104-
state, block_id, tensor, tensor_dim
106+
if (
107+
block_id not in deferred
108+
and (not isinstance(dim_size, int) or dim_size > 1)
109+
and _tile_needs_mask(state, block_id, tensor, tensor_dim)
105110
):
106111
mask_var = state.codegen.mask_var(block_id)
107112
if mask_var is not None:

0 commit comments

Comments
 (0)