|
29 | 29 | ValueRangesAny = ValueRanges[Any] |
30 | 30 |
|
31 | 31 |
|
| 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 | + |
32 | 65 | def mask_node_inputs( |
33 | 66 | node: torch.fx.Node, |
34 | 67 | other: float | bool = 0, |
@@ -184,6 +217,161 @@ def recompute_masked_values(graph: torch.fx.Graph) -> None: |
184 | 217 | node.meta["masked_value"] = cached_masked_value(node) |
185 | 218 |
|
186 | 219 |
|
| 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 | + |
187 | 375 | def getitem_masked_value( |
188 | 376 | getitem_node: torch.fx.Node, |
189 | 377 | ) -> float | bool | None: |
|
0 commit comments