Skip to content

Commit 3806ec7

Browse files
committed
[Pallas] Aligned-enclosing addressing for jagged row tiles (gate closed)
stack-info: PR: pytorch#2717, branch: thcmbs/stack/1
1 parent eb61d5b commit 3806ec7

6 files changed

Lines changed: 353 additions & 5 deletions

File tree

helion/_compiler/backend.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import ast
55
import base64
66
import contextlib
7+
import enum
78
import functools
89
import hashlib
910
from itertools import starmap
@@ -1439,6 +1440,36 @@ def reserved_launch_param_names() -> frozenset[str]:
14391440
_PALLAS_UNSUPPORTED_DTYPES = frozenset({torch.int64, torch.uint64, torch.float64})
14401441

14411442

1443+
class SliceAddressing(enum.Enum):
1444+
"""How a dynamic-offset slice on a tensor dim must be emitted on TPU."""
1445+
1446+
DIRECT = enum.auto() # offset used as-is -> plain pl.ds
1447+
ALIGNED = enum.auto() # offset rounded to sublane tile -> aligned-enclosing (+carry on a store)
1448+
1449+
1450+
def _slice_addressing(
1451+
tensor: torch.Tensor, dim: int, lane_block: int | None = None
1452+
) -> SliceAddressing:
1453+
"""Whether a dynamic slice on ``tensor``'s ``dim`` can take any offset.
1454+
1455+
TPU tiles only the last two dims into ``(8, 128)`` blocks; any earlier dim is
1456+
plain row-major, so a runtime offset there reads directly (DIRECT). A slice
1457+
on the 2nd-minor (sublane) dim normally must land on a tile boundary
1458+
(ALIGNED) -- except f32 whose access spans a single lane tile (``lane_block``
1459+
<= 128) is row-major-contiguous there and reads any offset too (DIRECT).
1460+
``lane_block`` is the access extent on the last dim (its block size, or the
1461+
full width if untiled); unknown (None) stays conservative (ALIGNED).
1462+
"""
1463+
if dim < tensor.ndim - 2:
1464+
return SliceAddressing.DIRECT # major dim: row-major, any offset
1465+
if dim == tensor.ndim - 2: # 2nd-minor (sublane) dim
1466+
f32 = tensor.dtype.itemsize >= 4
1467+
if f32 and isinstance(lane_block, int) and lane_block <= 128:
1468+
return SliceAddressing.DIRECT # f32, single lane tile -> contiguous
1469+
return SliceAddressing.ALIGNED # bf16 pack, or f32 spanning >1 lane tile
1470+
return SliceAddressing.ALIGNED # lane dim (rare): conservative
1471+
1472+
14421473
class PallasBackend(Backend):
14431474
"""Pallas (JAX) code generation backend for TPU."""
14441475

@@ -1765,6 +1796,18 @@ def _get_pallas_required_alignment(
17651796
return 8
17661797
return 1 # No requirements for other dimensions
17671798

1799+
def sublane_tiling(self, dtype: torch.dtype) -> int:
1800+
"""Native sublane (2nd-minor) tile for ``dtype``: f32->8, bf16->16, i8->32.
1801+
1802+
The jagged carry slices its emit_pipeline VMEM refs at this
1803+
granularity, and such a ref must be accessed as a *whole* native tile:
1804+
a smaller slice (e.g. 8 rows of a bf16 ref, whose tile is 16) is
1805+
rejected by Mosaic ("E2003: unproven memory access alignment"),
1806+
independent of offset.
1807+
"""
1808+
bitwidth = min(dtype.itemsize * 8, 32)
1809+
return 8 * (32 // bitwidth)
1810+
17681811
fake_tensor_loads: list[tuple[torch.Tensor, list[object]]]
17691812

17701813
def process_fake_tensor_load(

helion/_compiler/device_function.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from .generate_ast import GenerateAST
5151
from .indexing_strategy import IndexingStrategy
5252
from .program_id import ProgramIDs
53+
from helion._compiler.pallas.ordered_carry import CarryRowTile
5354
from helion._compiler.pallas.plan_tiling import DimensionTiling
5455

5556
_P = TypeVar("_P", bound="TensorPropertyArg")
@@ -337,6 +338,12 @@ def __init__(
337338
# Pallas: id(fake_tensor) → {dim: (block_id, extra_pad)} for dims
338339
# using pl.ds() that may need host-side padding.
339340
self.pallas_pad_info: dict[int, dict[int, tuple[int, int]]] = {}
341+
# Pallas ordered carry: jagged row block_id -> CarryRowTile. Filled by
342+
# the emit_pipeline codegen when the tile is a legal map axis; read by
343+
# the store codegen to stitch the boundary across neighbouring groups.
344+
self.carry_tiles: dict[int, CarryRowTile] = {}
345+
# row block_id -> carry scratch var name (allocated lazily at the store).
346+
self.carry_scratch: dict[int, str] = {}
340347

341348
def allocate_store_index(self) -> int:
342349
"""Bump store counters and return the indexing strategy slot."""
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Ordered carry for jagged row tiles on the Pallas emit_pipeline path.
2+
3+
A ``hl.tile(s, e)`` with runtime bounds rounds its rows out to a sublane-aligned
4+
window and masks the extra. Neighbouring groups then share one boundary row,
5+
which a small VMEM ``carry`` stitches back together. Only valid when each
6+
output row comes from its own input row.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import dataclasses
12+
from typing import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
from helion._compiler.inductor_lowering import CodegenState
16+
17+
18+
@dataclasses.dataclass(frozen=True)
19+
class CarryRowTile:
20+
"""What the block-spec, mask, and store code share about one carried tile."""
21+
22+
block_id: int
23+
begin_var: str # host var for the tile's runtime begin (e.g. ``s``)
24+
end_var: str # host var for the tile's runtime end (e.g. ``e``)
25+
sublane: int # S = backend.sublane_tiling(dtype)
26+
carry_scratch_name: str | None = None
27+
28+
29+
def is_dynamic_bound_tile(state: CodegenState, block_id: int) -> bool:
30+
"""True for a jagged tile: its begin and end are runtime tensor values.
31+
32+
A static ``hl.tile(0, K)`` keeps a constant ``end_expr``; a ``hl.tile(s, e)``
33+
with runtime bounds has both ``begin_expr`` and ``end_expr`` set to None.
34+
"""
35+
loops = state.codegen.active_device_loops.get(block_id)
36+
if not loops:
37+
return False
38+
info = loops[-1].block_id_to_info.get(block_id)
39+
if info is None:
40+
return False
41+
return info.begin_expr is None and info.end_expr is None
42+
43+
44+
def is_row_map_axis(state: CodegenState, block_id: int) -> bool:
45+
"""Whether each output row comes only from its own input row (a map axis).
46+
47+
True only when the row is written straight back (row i to output row i),
48+
never summed, scattered, or shifted; the over-read and carry are only
49+
correct in that case.
50+
"""
51+
# TODO(implement): the map-axis check above; for now reject every jagged tile.
52+
return False
53+
54+
55+
def needs_ordered_carry(state: CodegenState, block_id: int) -> bool:
56+
"""Whether this dynamic row tile needs sublane carry stitching.
57+
58+
Carry stitches the shared boundary tile of a per-row map store, so it
59+
applies only when this jagged tile is a straight map axis (is_row_map_axis)
60+
*and* it feeds a 2-D store whose leading dim is the tile and whose trailing
61+
(column) dim is tiled -- that tiled column is the block the carry folds and
62+
saves into. An untiled ``:`` column (a BlockSpec store) cannot carry and
63+
does not need to; a higher-rank ``[L, H, D]`` store has no shared row; a
64+
reduction or scatter axis is not a map and never carries.
65+
"""
66+
from helion._compiler.pallas.plan_tiling import TilePattern
67+
from helion.language.memory_ops import store
68+
69+
if not is_row_map_axis(state, block_id):
70+
return False
71+
for ginfo in state.codegen.codegen_graphs:
72+
for node in ginfo.graph.nodes:
73+
if node.target is not store:
74+
continue
75+
patterns = node.meta.get("indexing_patterns")
76+
if not patterns or len(patterns) != 2:
77+
continue
78+
row_pat, col_pat = patterns
79+
if getattr(row_pat, "block_id", None) != block_id:
80+
continue
81+
if isinstance(row_pat, TilePattern) and (
82+
getattr(col_pat, "block_id", None) is not None
83+
):
84+
return True
85+
return False
86+
87+
88+
def begin_end_vars(state: CodegenState, block_id: int) -> tuple[str, str] | None:
89+
"""Return the (begin_var, end_var) host names for a dynamic row tile."""
90+
loops = state.codegen.active_device_loops.get(block_id)
91+
if not loops:
92+
return None
93+
info = loops[-1].block_id_to_info.get(block_id)
94+
if info is None or info.begin_var_name is None:
95+
return None
96+
end_var = info.end_var_name
97+
if end_var is None:
98+
return None
99+
return info.begin_var_name, end_var
100+
101+
102+
def emit_carry_store(
103+
state: CodegenState,
104+
tensor: object,
105+
subscript: list[object] | tuple[object, ...],
106+
name: str,
107+
idx_str: str,
108+
value: object,
109+
) -> bool:
110+
"""Store to a jagged row tile with the boundary carry.
111+
112+
Not implemented yet. Returns False for a normal store; for a jagged-row
113+
store it raises, because writing it directly would overwrite the previous
114+
group's data in the row they share.
115+
"""
116+
fn = state.device_function
117+
patterns = state.fx_node.meta.get("indexing_patterns") if state.fx_node else None
118+
if not patterns:
119+
return False
120+
for pat in patterns:
121+
bid = getattr(pat, "block_id", None)
122+
if bid is not None and bid in fn.carry_tiles:
123+
raise NotImplementedError(
124+
"Pallas ordered carry store for a jagged row tile is not "
125+
"implemented yet."
126+
)
127+
return False

0 commit comments

Comments
 (0)