Skip to content

Commit a94e0a7

Browse files
committed
[Pallas] Ordered carry store for jagged row tiles
stack-info: PR: pytorch#2719, branch: thcmbs/stack/3
1 parent 0d630a6 commit a94e0a7

2 files changed

Lines changed: 283 additions & 36 deletions

File tree

helion/_compiler/pallas/ordered_carry.py

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from typing import TYPE_CHECKING
1313

1414
if TYPE_CHECKING:
15+
import torch
16+
1517
from helion._compiler.inductor_lowering import CodegenState
1618

1719

@@ -122,27 +124,151 @@ def begin_end_vars(state: CodegenState, block_id: int) -> tuple[str, str] | None
122124

123125
def emit_carry_store(
124126
state: CodegenState,
125-
tensor: object,
127+
tensor: torch.Tensor,
126128
subscript: list[object] | tuple[object, ...],
127129
name: str,
128130
idx_str: str,
129131
value: object,
130132
) -> bool:
131-
"""Store to a jagged row tile with the boundary carry.
133+
"""Store a row tile to a jagged output, stitching the shared boundary row.
134+
135+
Rounding each group's rows out to a sublane-aligned window makes neighbouring
136+
groups overlap on one row, and each group zeros the rows it does not own. A
137+
one-row VMEM ``carry`` keeps that shared row across groups:
138+
139+
* fold (first tile of a group, ``begin`` not S-aligned):
140+
``out[:S] += carry`` add the previous group's piece back in
141+
* save (last tile of a group, ``end`` not S-aligned):
142+
``carry = out[-S:]`` hand this group's piece forward
132143
133-
Not implemented yet. Returns False for a normal store; for a jagged-row
134-
store it raises, because writing it directly would overwrite the previous
135-
group's data in the row they share.
144+
Saving the folded value keeps it cumulative, so more than two tiny groups in
145+
one row still work.
136146
"""
147+
from helion._compiler.ast_extension import statement_from_string
148+
from helion._compiler.host_function import HostFunction
149+
137150
fn = state.device_function
138151
patterns = state.fx_node.meta.get("indexing_patterns") if state.fx_node else None
139152
if not patterns:
140153
return False
141-
for pat in patterns:
154+
155+
# Find the carried (jagged) row dim, if this store has one.
156+
row_dim = None
157+
row_block_id = None
158+
for d, pat in enumerate(patterns):
142159
bid = getattr(pat, "block_id", None)
143160
if bid is not None and bid in fn.carry_tiles:
144-
raise NotImplementedError(
145-
"Pallas ordered carry store for a jagged row tile is not "
146-
"implemented yet."
147-
)
148-
return False
161+
row_dim, row_block_id = d, bid
162+
break
163+
if row_block_id is None:
164+
return False
165+
166+
# The fold guard below counts groups with pl.program_id(0), so the group must
167+
# be the only grid dim.
168+
# TODO(tcombes): thread the group's real program id through CarryRowTile so
169+
# multi-grid kernels work, instead of refusing them here.
170+
grid_block_ids = HostFunction.current().device_ir.grid_block_ids
171+
n_grid = sum(len(bids) for bids in grid_block_ids)
172+
if n_grid != 1:
173+
raise NotImplementedError(
174+
"Pallas ordered carry assumes the group loop is the only grid "
175+
f"dimension (program_id(0)); got {n_grid} grid dims."
176+
)
177+
178+
# The carry only handles a 2D store with the jagged row leading.
179+
if row_dim != 0 or tensor.ndim != 2:
180+
raise NotImplementedError(
181+
"Pallas ordered carry supports only a leading-row 2D store "
182+
f"(got ndim={tensor.ndim}, jagged row at dim {row_dim})."
183+
)
184+
col_block_id = getattr(patterns[1], "block_id", None)
185+
if col_block_id is None:
186+
raise NotImplementedError(
187+
"Pallas ordered carry: the column dim of a jagged store must be tiled."
188+
)
189+
rec = fn.carry_tiles[row_block_id]
190+
S = rec.sublane
191+
block_row = fn.resolved_block_size(row_block_id)
192+
block_col = fn.resolved_block_size(col_block_id)
193+
n_cols = tensor.size(1)
194+
if not (
195+
isinstance(block_row, int)
196+
and isinstance(block_col, int)
197+
and isinstance(n_cols, int)
198+
):
199+
raise NotImplementedError(
200+
"Pallas ordered carry needs static block/column sizes "
201+
f"(block_row={block_row!r}, block_col={block_col!r}, cols={n_cols!r})."
202+
)
203+
if block_row % S != 0:
204+
raise NotImplementedError(
205+
f"Pallas ordered carry: block_row ({block_row}) must be a multiple of the "
206+
f"sublane tiling S ({S})."
207+
)
208+
# TODO(tcombes): block_row larger than the whole tensor (L < block_row) is not
209+
# handled and crashes downstream with a shape mismatch; only L >= block_row
210+
# is exercised.
211+
212+
# Only the sublane (first) dim of a VMEM ref can be sliced at runtime on TPU,
213+
# not the column (last) dim. So give each column tile its own boundary row,
214+
# stacked on dim 0: carry[num_col_tiles*S, block_col].
215+
num_col_tiles = (n_cols + block_col - 1) // block_col
216+
carry = fn.carry_scratch.get(row_block_id)
217+
if carry is None:
218+
carry = fn.register_scratch(
219+
(num_col_tiles * S, block_col),
220+
tensor.dtype,
221+
name_hint="carry",
222+
scratch_type="vmem",
223+
)
224+
fn.carry_scratch[row_block_id] = carry
225+
226+
row_off = state.codegen.offset_var(row_block_id)
227+
col_off = state.codegen.offset_var(col_block_id)
228+
begin, end = rec.begin_var, rec.end_var
229+
a_start = f"({begin} - {begin} % {S})"
230+
a_end = f"(({end} + {S} - 1) // {S} * {S})"
231+
# The first group (program_id 0) has nothing before it, so it never folds:
232+
# the carry scratch is not written yet. Later groups fold only after a
233+
# predecessor saved, so the scratch is always written first.
234+
# TODO(tcombes): assumes contiguous group offsets (no gaps); a gap would fold
235+
# a stale carry from a non-adjacent predecessor.
236+
fold_guard = (
237+
f"(({row_off} == {a_start}) & ({begin} % {S} != 0) & (pl.program_id(0) != 0))"
238+
)
239+
save_guard = f"(({row_off} + {block_row} >= {a_end}) & ({end} % {S} != 0))"
240+
col_sub = f"pl.multiple_of(({col_off}) // {block_col} * {S}, {S})"
241+
# carry is a normal (non-buffered) scratch, so a partial-row slice is fine.
242+
carry_slice = f"{carry}[pl.ds({col_sub}, {S}), :]"
243+
# The group's last row block sits at offset (a_end - S - row_off) inside the
244+
# last tile: S-aligned and within [0, block_row - S].
245+
last_off = f"pl.multiple_of({a_end} - {S} - ({row_off}), {S})"
246+
out_last = f"{name}[pl.ds({last_off}, {S}), :]"
247+
248+
# Add the carry into the store value so the output gets one full-tile write;
249+
# Pallas rejects a partial write to the double-buffered output. So pad the
250+
# [S, block_col] carry up to the full [block_row, block_col] tile (carry on
251+
# top, zeros below) and add the whole thing.
252+
n = block_row // S
253+
if n > 1:
254+
promoted = (
255+
f"jnp.concatenate([{carry_slice}] + "
256+
f"[jnp.zeros_like({carry_slice})] * {n - 1}, 0)"
257+
)
258+
else:
259+
promoted = carry_slice
260+
state.codegen.add_statement(
261+
statement_from_string(
262+
f"{name}[{idx_str}] = ({{value}}) + jnp.where({fold_guard}, {promoted}, 0)",
263+
value=value, # pyrefly: ignore[bad-argument-type]
264+
)
265+
)
266+
# Save this group's last row block for the next group's fold. When that
267+
# block is also the group's first (folded) one, saving it keeps the carry
268+
# cumulative across several tiny groups in one row.
269+
state.codegen.add_statement(
270+
statement_from_string(
271+
f"{carry_slice} = jnp.where({save_guard}, {out_last}, {carry_slice})"
272+
)
273+
)
274+
return True

test/test_pallas_load_store.py

Lines changed: 146 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import torch
4+
from torch.testing._internal.common_utils import instantiate_parametrized_tests
5+
from torch.testing._internal.common_utils import parametrize
46

57
import helion
68
from helion import exc
@@ -34,29 +36,104 @@ def jagged_dense_bmm(
3436
return out
3537

3638

39+
def _ref(off: torch.Tensor, j: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
40+
out = torch.empty((j.shape[0], d.shape[2]), dtype=j.dtype, device=j.device)
41+
for g in range(d.shape[0]):
42+
s, e = int(off[g]), int(off[g + 1])
43+
if e > s:
44+
out[s:e] = j[s:e] @ d[g]
45+
return out
46+
47+
48+
def _inputs(offsets: list[int], D: int, K: int, dtype: torch.dtype):
49+
off = torch.tensor(offsets, dtype=torch.int32, device=DEVICE)
50+
L, B = offsets[-1], len(offsets) - 1
51+
torch.manual_seed(0)
52+
j = torch.randn((L, D), dtype=dtype, device=DEVICE)
53+
d = torch.randn((B, D, K), dtype=dtype, device=DEVICE)
54+
return off, j, d
55+
56+
57+
def _run(off, j, d, block_sizes):
58+
return code_and_output(
59+
jagged_dense_bmm,
60+
(off, j, d),
61+
block_sizes=block_sizes,
62+
pallas_loop_type="emit_pipeline",
63+
)
64+
65+
66+
# bf16 with the kernel's f32-accumulating MXU matches a single torch matmul of
67+
# the same group bit-for-bit (single D-tile), so the default tolerances hold.
3768
@onlyBackends(["pallas"])
3869
@skipUnlessPallas("JAX/Pallas TPU not available")
3970
class TestPallasJaggedCarry(TestCase):
40-
# The legality gate is in place but the carry store is not, so a legal
41-
# map-axis store reaches the unimplemented carry and raises "carry".
71+
# Simple cases that show the logic.
4272

43-
def test_bmm_store_reaches_carry(self) -> None:
44-
# Identity store on the jagged row is a map axis: the gate accepts it, so
45-
# it reaches the unimplemented carry.
46-
off = torch.tensor([0, 13, 25], dtype=torch.int32, device=DEVICE)
47-
j = torch.randn((25, 128), dtype=torch.bfloat16, device=DEVICE)
48-
d = torch.randn((2, 128, 128), dtype=torch.bfloat16, device=DEVICE)
49-
with self.assertRaisesRegex(exc.InductorLoweringError, "carry"):
50-
code_and_output(
51-
jagged_dense_bmm,
52-
(off, j, d),
53-
block_sizes=[16, 128, 128],
54-
pallas_loop_type="emit_pipeline",
55-
)
73+
@parametrize("dtype", [torch.float32, torch.bfloat16])
74+
def test_single_group(self, dtype: torch.dtype) -> None:
75+
# One group: exercises the aligned-enclosing read and the tail mask,
76+
# with no carry between groups.
77+
off, j, d = _inputs([0, 20], 128, 128, dtype)
78+
_code, out = _run(off, j, d, [16, 128, 128])
79+
torch.testing.assert_close(out, _ref(off, j, d))
80+
81+
@parametrize("dtype", [torch.float32, torch.bfloat16])
82+
def test_aligned_groups_no_carry(self, dtype: torch.dtype) -> None:
83+
# Sublane-aligned boundaries: no over-read and no shared row, so the
84+
# carry fold/save guards never fire.
85+
off, j, d = _inputs([0, 16, 32], 128, 128, dtype)
86+
_code, out = _run(off, j, d, [16, 128, 128])
87+
torch.testing.assert_close(out, _ref(off, j, d))
88+
89+
def test_carry_keeps_both_groups(self) -> None:
90+
# Two groups [0, 3) and [3, 16) share the [0, 16) boundary row. With
91+
# identity weights out == jagged, so a clobbered carry would be obvious.
92+
off = torch.tensor([0, 3, 16], dtype=torch.int32, device=DEVICE)
93+
j = (
94+
torch.arange(1, 17, dtype=torch.bfloat16, device=DEVICE)[:, None]
95+
.expand(16, 128)
96+
.contiguous()
97+
)
98+
eye = torch.eye(128, dtype=torch.bfloat16, device=DEVICE)
99+
_code, out = _run(off, j, torch.stack([eye, eye]), [16, 128, 128])
100+
torch.testing.assert_close(out, j)
101+
102+
# The carry across the full configuration matrix.
56103

57-
def test_elementwise_store_reaches_carry(self) -> None:
58-
# A non-matmul map-axis store is accepted too, so it also reaches the
59-
# unimplemented carry.
104+
@parametrize("dtype", [torch.float32, torch.bfloat16])
105+
@parametrize(
106+
"offsets",
107+
[
108+
[0, 13, 25], # shared boundary row
109+
[0, 20, 50], # multi-row groups
110+
[0, 3, 7, 16], # several tiny groups in one row (cumulative carry)
111+
],
112+
)
113+
def test_bmm_block_eq_sublane(self, dtype: torch.dtype, offsets: list[int]) -> None:
114+
off, j, d = _inputs(offsets, 128, 128, dtype)
115+
code, out = _run(off, j, d, [16, 128, 128])
116+
self.assertIn("pltpu.emit_pipeline", code)
117+
torch.testing.assert_close(out, _ref(off, j, d))
118+
119+
@parametrize("dtype", [torch.float32, torch.bfloat16])
120+
def test_bmm_block_gt_group(self, dtype: torch.dtype) -> None:
121+
# Two groups (13 rows) are smaller than block_row=32, total L=200 >> block_row.
122+
off, j, d = _inputs([0, 13, 100, 113, 200], 128, 128, dtype)
123+
_code, out = _run(off, j, d, [32, 128, 128])
124+
torch.testing.assert_close(out, _ref(off, j, d))
125+
126+
@parametrize("dtype", [torch.float32, torch.bfloat16])
127+
def test_bmm_multi_k_tile(self, dtype: torch.dtype) -> None:
128+
# K=256 with block_col=128 gives two output-column tiles; the carry stacks
129+
# the per-column-tile boundary rows along its scratch row dim.
130+
off, j, d = _inputs([0, 17, 40, 71], 128, 256, dtype)
131+
_code, out = _run(off, j, d, [16, 128, 128])
132+
torch.testing.assert_close(out, _ref(off, j, d))
133+
134+
def test_elementwise_map_axis(self) -> None:
135+
# The non-matmul map-axis store from commit 2 now runs: out[st] = 2 *
136+
# jagged[st], carried across the shared boundary like the matmul case.
60137
@helion.kernel(backend="pallas")
61138
def jagged_scale(
62139
seq_offsets: torch.Tensor, jagged: torch.Tensor
@@ -74,13 +151,15 @@ def jagged_scale(
74151

75152
off = torch.tensor([0, 13, 25], dtype=torch.int32, device=DEVICE)
76153
j = torch.randn((25, 128), dtype=torch.bfloat16, device=DEVICE)
77-
with self.assertRaisesRegex(exc.InductorLoweringError, "carry"):
78-
code_and_output(
79-
jagged_scale,
80-
(off, j),
81-
block_sizes=[16, 128],
82-
pallas_loop_type="emit_pipeline",
83-
)
154+
_code, out = code_and_output(
155+
jagged_scale,
156+
(off, j),
157+
block_sizes=[16, 128],
158+
pallas_loop_type="emit_pipeline",
159+
)
160+
torch.testing.assert_close(out, j * 2)
161+
162+
# Rejection paths.
84163

85164
def test_jagged_reduction_over_row_f32(self) -> None:
86165
# Summing the jagged row away to a dense output reduces it (not a map
@@ -115,6 +194,45 @@ def jagged_row_sum(
115194
expected = torch.stack([j[0:13].sum(0), j[13:25].sum(0)])
116195
torch.testing.assert_close(out, expected, rtol=2e-3, atol=2e-3)
117196

197+
def test_block_not_multiple_of_sublane_raises(self) -> None:
198+
# bf16 sublane S=16; block_row=8 is not a multiple, so the carry rejects it
199+
# loudly instead of clobbering the boundary with a plain store.
200+
off, j, d = _inputs([0, 13, 25], 128, 128, torch.bfloat16)
201+
with self.assertRaisesRegex(
202+
exc.InductorLoweringError, "block_row .* must be a multiple"
203+
):
204+
_run(off, j, d, [8, 128, 128])
205+
206+
def test_multi_grid_group_rejected(self) -> None:
207+
# The fold guard keys off program_id(0), so a second grid dimension is
208+
# refused rather than folding against the wrong program id.
209+
@helion.kernel(backend="pallas")
210+
def two_grid_bmm(
211+
seq_offsets: torch.Tensor, jagged: torch.Tensor, dense: torch.Tensor
212+
) -> torch.Tensor:
213+
L, D = jagged.shape
214+
B, D, K = dense.shape
215+
out = torch.empty((L, K), dtype=jagged.dtype, device=jagged.device)
216+
for _b, g in hl.grid([2, B]):
217+
s = seq_offsets[g]
218+
e = seq_offsets[g + 1]
219+
for st in hl.tile(s, e):
220+
for kt in hl.tile(0, K):
221+
acc = hl.zeros([st, kt], dtype=torch.float32)
222+
for dt in hl.tile(0, D):
223+
acc = acc + torch.matmul(jagged[st, dt], dense[g, dt, kt])
224+
out[st, kt] = acc
225+
return out
226+
227+
off, j, d = _inputs([0, 13, 25], 128, 128, torch.bfloat16)
228+
with self.assertRaisesRegex(exc.InductorLoweringError, "only grid dimension"):
229+
code_and_output(
230+
two_grid_bmm,
231+
(off, j, d),
232+
block_sizes=[16, 128, 128],
233+
pallas_loop_type="emit_pipeline",
234+
)
235+
118236
def test_non_jagged_emit_pipeline_unaffected(self) -> None:
119237
# Static (compile-time) tile bounds never trip the gate: a plain
120238
# emit_pipeline matmul still lowers and runs correctly.
@@ -140,3 +258,6 @@ def static_matmul(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
140258
pallas_loop_type="emit_pipeline",
141259
)
142260
torch.testing.assert_close(out, a @ b)
261+
262+
263+
instantiate_parametrized_tests(TestPallasJaggedCarry)

0 commit comments

Comments
 (0)