Files
tilelang/testing/python/language/test_tilelang_language_reduce.py
T
6548c05f0c [Feature] Batched AllReduce for better T.reduce performance (#1976)
* [Feature] Batched AllReduce for better T.reduce performance (#1928)

When each thread holds multiple values to reduce, the previous code
emitted one AllReduce call per element inside a loop, causing redundant
synchronization barriers (e.g. 8 values × 6 syncs = 48 syncs).

This change adds batch support to AllReduce so all per-thread values are
reduced in a single butterfly pass sharing barriers (6 syncs total).

- Add `batch_size` and `workspace_stride` template parameters to
  `tl::AllReduce` (defaults preserve existing scalar behaviour).
- In ReduceOpNode::Lower, detect when batching is beneficial
  (batch_size > 1 and cross-warp reduction) and restructure into three
  phases: loop(init + local reduce), batched AllReduce, loop(copy-back).
- Apply same optimisation to FinalizeReducerOpNode::Lower.
- Mirror template changes in HIP AllReduce.

Closes #1928

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [Test] Add codegen tests for batched AllReduce

Verify that the batched AllReduce path (batch_size > 1, workspace_stride)
is emitted in the generated CUDA source for reduce_max, reduce_sum, and
reduce_min with cross-warp reductions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [Bugfix] Fix batched AllReduce codegen for HIP/ROCm targets

HIP AllReduce template has no Barrier type parameter, so the batched
path must omit tl::SyncThreadsBarrier for ROCm targets. Adds
TargetIsRocm() checks in both reduce.cc and finalize_reducer.cc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [Fix] Use target-aware warp size for cross-warp reduction threshold

Address CodeRabbit review: ROCm wavefronts are 64-wide, so the cross-warp
threshold should be 64 (not 32) on HIP targets. This avoids unnecessary
workspace allocation and batched codegen for single-wavefront reductions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [Style] Fix ruff format for test assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [Bugfix] Support batched AllReduce in CuteDSL Python codegen

The CuteDSL codegen translates C++ AllReduce template calls to Python.
With the new batch_size and workspace_stride template params, the
generated Python calls AllReduce() with 7 args instead of 5. Add
batch_size and workspace_stride params to the Python AllReduce function
and implement batched reduction logic for both standard and Hopper paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [Feature] Add explicit batch parameter to T.reduce for user-controlled batched AllReduce

Expose a `batch` parameter on T.reduce (and all T.reduce_* wrappers,
default 1 = scalar / current behaviour).  When batch > 1 the compiler:

 - validates batch divides the per-thread output element count N;
 - emits ceil(N/batch) calls to the new AllReduce::run_batch interface,
   each sharing a single pair of barriers across all batch elements;
 - allocates workspace of size reducing_threads * batch (only for cross-
   warp reduce, i.e. reducing_threads > 32).

Key differences from the existing auto-detection approach:
 * The user controls when batching is enabled — no silent smem increase.
 * workspace_stride = reducing_threads (not block_size), so workspace is
   minimal and does not blow past the GPU shared-memory limit.
 * run_batch (not run) avoids C++ overload-resolution ambiguity when a
   pointer is passed as the first argument.

Files changed:
 - src/op/reduce.h: add `int batch{1}` field to ReduceOpNode
 - src/op/reduce.cc: read "batch" annotation; validate; emit N/batch
   batched AllReduce calls with address_of per-chunk pointers
 - src/tl_templates/cuda/reduce.h: rename batch run() -> run_batch(),
   fix recursive call
 - src/tl_templates/hip/reduce.h: same
 - tilelang/language/reduce_op.py: add batch param to reduce() and all
   reduce_max/min/sum/absmax/abssum/bitand/bitor/bitxor wrappers
 - testing/python/language/test_tilelang_language_reduce.py: update
   test_batched_allreduce_codegen to use explicit batch=2 and match
   run_batch in generated source

* [Fix] Use run_batch in finalize_reducer batched AllReduce codegen

finalize_reducer.cc also generates batched AllReduce calls (used by
T.alloc_reducer / T.finalize_reducer), but was still emitting ::run
instead of ::run_batch, causing a compiler error because the batched
pointer-based interface is now named run_batch to avoid overload
ambiguity.

* rocm fix

* [Feature] Add explicit batch parameter to T.finalize_reducer

Mirrors the batch= API already available on T.reduce:
- batch=1 (default) keeps the existing scalar AllReduce::run path
- batch>1 emits AllReduce::run_batch, sharing barriers across batch
  output elements and reducing barrier count by batch×

C++ changes:
- FinalizeReducerOpNode gains an int batch{1} field (registered via
  reflection) and reads it from the 'batch' annotation in the constructor
- Lower() validates batch against the layout output-element count and
  uses effective_batch to drive use_batch / codegen

Python changes:
- finalize_reducer(reducer, batch=1) – new optional argument
- batch < 1 raises ValueError (consistent with T.reduce)

Tests: add test_batched_finalize_reducer_codegen covering sum/max/min
with explicit batch values, verifying run_batch is emitted with the
correct template argument.

* [Test] Add comprehensive tests for T.finalize_reducer batch parameter

Covers all cases introduced by the explicit batch= API:

Codegen tests:
- batch>1 emits AllReduce::run_batch with correct template argument
- batch=1 (default) does NOT emit run_batch (scalar path)

Correctness tests:
- batch=1 scalar path: sum/max/min across float32/float16 (PASS)
- batch==block_M full-batch: correct by coincidence (PASS)
- batch>1 partial-batch: xfail(strict=True) – known bug where
  run_batch receives buffer->data directly but fragment layout is
  non-contiguous per thread; root cause documented in
  work/bug-finalize-reducer-batch.md

Error-input tests:
- batch=0 / batch<0 raise ValueError at Python layer
- batch > total output elements raises at compile/lower time
- batch that does not evenly divide output elements raises

* [Test] Rewrite finalize_reducer tests to follow clean outline

Replace the previous over-engineered test suite (4 case lists, 2 split
correctness functions, per-case xfail) with a minimal set organised by
the three categories that matter:

1. Codegen – single parametrised test covers both batch=1 (no run_batch)
   and batch>1 (run_batch with correct template arg), reusing one case
   list for both codegen and correctness.

2. Correctness – only batch=1 (scalar path), which is currently correct.
   batch>1 correctness tests are omitted until the underlying fragment
   layout bug is fixed (tracked in work/bug-finalize-reducer-batch.md).

3. Error inputs – batch<1 collapsed into one parametrised test; compile-
   time errors (batch>output, batch not divisible) each get one test
   using a shared _make_error_kernel helper.

* [Test] Rewrite reduce tests following clean outline

Replace fragmented per-function tests with four parametrized suites:

- test_reduce: op × dtype × src_scope × dst_scope × threads, covers
  fragment→fragment, shared→shared, fragment→shared for sum/max/min/
  abssum/absmax with float32/float16/int32/int64

- test_reduce_clear: op × src_scope × dst_scope with clear=False,
  verifies pre-filled dst is accumulated correctly

- test_reduce_batch_codegen: verify batch>1 emits AllReduce::run_batch

- test_finalize_reducer_codegen/correctness/invalid_batch: batch
  parameter feature tests, sharing one FINALIZE_REDUCER_CASES list

Use tilelang.jit lazy style and torch.testing.assert_close directly,
no profiler wrapper.

* [Test] Merge batch codegen+correctness into test_reduce

Add batch column to REDUCE_CASES; batch>1 cases verify both that
run_batch appears in generated source and that numerical output matches
reference, within the same test_reduce parametrization.

Remove the now-redundant test_reduce_batch_codegen.

* [Test] Add more batch>1 reduce cases covering all ops and dtypes

Add batch=2/4/8 cases for sum/max/min/abssum/absmax across float32/
float16/bfloat16, with both fragment and shared src/dst scopes.
Relax tolerance to 1e-1 for float16/bfloat16 to account for larger
rounding error in multi-element reductions.

* [Test] Limit int64 input range to avoid ROCm shfl_xor 64-bit bug

* Revert "[Test] Limit int64 input range to avoid ROCm shfl_xor 64-bit bug"

This reverts commit 9d60d6b197.

* fix(HIP): skip __launch_bounds__ for sub-wavefront threads to avoid miscompile of 64-bit shuffle ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: LeiWang1999 <leiwang1999@outlook.com>
Co-authored-by: kurisu6912 <kurisu6912@users.noreply.github.com>
2026-04-28 15:15:30 +08:00

312 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import tilelang
import tilelang as tl
import tilelang.language as T
import tilelang.testing
import pytest
import torch
tilelang.testing.set_random_seed()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_input(M, N, dtype):
torch_dtype = getattr(torch, dtype)
if torch_dtype in (torch.int32, torch.int64):
return torch.randint(-100, 100, (M, N), dtype=torch_dtype).cuda()
return torch.randn(M, N, dtype=torch_dtype).cuda()
def _ref(A, op):
if op == "sum":
return A.sum(dim=1).to(A.dtype)
if op == "max":
return A.max(dim=1).values
if op == "min":
return A.min(dim=1).values
if op == "abssum":
return A.abs().sum(dim=1).to(A.dtype)
if op == "absmax":
return A.abs().max(dim=1).values
raise ValueError(op)
def _reduce_op(T, op, src, dst, dim, batch=1):
kwargs = {} if batch == 1 else {"batch": batch}
if op == "sum":
T.reduce_sum(src, dst, dim=dim, **kwargs)
elif op == "max":
T.reduce_max(src, dst, dim=dim, **kwargs)
elif op == "min":
T.reduce_min(src, dst, dim=dim, **kwargs)
elif op == "abssum":
T.reduce_abssum(src, dst, dim=dim, **kwargs)
elif op == "absmax":
T.reduce_absmax(src, dst, dim=dim, **kwargs)
# ---------------------------------------------------------------------------
# test_reduce (op × dtype × src_scope × dst_scope × threads × batch)
# ---------------------------------------------------------------------------
# int types only support sum (no abssum/absmax for int in tilelang)
REDUCE_CASES = [
# (op, dtype, M, N, src_scope, dst_scope, threads, batch)
("sum", T.float32, 128, 128, "fragment", "fragment", 32, 1),
("sum", T.int32, 128, 128, "fragment", "fragment", 32, 1),
("sum", T.int64, 192, 64, "fragment", "fragment", 32, 1),
("sum", T.float32, 192, 64, "fragment", "fragment", 32, 1),
("sum", T.float32, 32, 32, "fragment", "fragment", 16, 1),
("sum", T.float32, 16, 16, "fragment", "fragment", 8, 1),
("sum", T.float32, 32, 32, "shared", "shared", 32, 1),
("sum", T.float32, 32, 32, "fragment", "shared", 32, 1),
("max", T.float32, 128, 128, "fragment", "fragment", 32, 1),
("max", T.int64, 128, 128, "fragment", "fragment", 32, 1),
("max", T.float32, 32, 32, "shared", "shared", 32, 1),
("min", T.float32, 128, 128, "fragment", "fragment", 32, 1),
("min", T.int64, 128, 128, "fragment", "fragment", 32, 1),
("abssum", T.float32, 128, 128, "fragment", "fragment", 32, 1),
("abssum", T.int64, 128, 128, "fragment", "fragment", 32, 1),
("absmax", T.float32, 128, 128, "fragment", "fragment", 32, 1),
("absmax", T.int64, 128, 128, "fragment", "fragment", 32, 1),
# batch > 1: verify run_batch codegen and correctness together
("sum", T.float32, 128, 64, "shared", "fragment", 256, 2),
("sum", T.float32, 128, 64, "shared", "fragment", 256, 4),
("sum", T.float16, 64, 128, "fragment", "fragment", 256, 4),
("max", T.bfloat16, 128, 64, "shared", "fragment", 256, 2),
("max", T.float32, 128, 128, "fragment", "fragment", 256, 4),
("min", T.float32, 64, 128, "shared", "fragment", 128, 2),
("min", T.float16, 128, 128, "fragment", "fragment", 256, 8),
("abssum", T.float32, 128, 128, "fragment", "fragment", 256, 4),
("absmax", T.float32, 128, 128, "fragment", "fragment", 256, 4),
]
@pytest.mark.parametrize(
("op", "dtype", "M", "N", "src_scope", "dst_scope", "threads", "batch"),
REDUCE_CASES,
ids=[
f"{op}-{dtype}-{M}x{N}-{src_scope[0]}2{dst_scope[0]}-t{threads}-b{batch}"
for op, dtype, M, N, src_scope, dst_scope, threads, batch in REDUCE_CASES
],
)
def test_reduce(op, dtype, M, N, src_scope, dst_scope, threads, batch):
import re
@tilelang.jit(out_idx=-1)
def kernel(M, N, dtype, op, src_scope, dst_scope, threads, batch):
@T.prim_func
def main(A: T.Tensor((M, N), dtype), B: T.Tensor((M,), dtype)):
with T.Kernel(1, threads=threads):
if src_scope == "fragment":
src = T.alloc_fragment((M, N), dtype)
else:
src = T.alloc_shared((M, N), dtype)
if dst_scope == "fragment":
dst = T.alloc_fragment((M,), dtype)
else:
dst = T.alloc_shared((M,), dtype)
T.copy(A, src, disable_tma=src_scope == "shared")
_reduce_op(T, op, src, dst, dim=1, batch=batch)
T.copy(dst, B)
return main
jit_kernel = kernel(M, N, dtype, op, src_scope, dst_scope, threads, batch)
if batch > 1:
src = jit_kernel.get_kernel_source()
m = re.search(r",\s*(\d+)\s*,\s*\d+\s*>::run_batch\(", src)
assert m is not None, f"Expected run_batch in generated source.\n{src}"
assert int(m.group(1)) > 1, f"Expected batch_size > 1, got {m.group(1)}.\n{src}"
A = _make_input(M, N, dtype)
B = jit_kernel(A)
# float16/bfloat16 accumulate more rounding error over large reductions
tol = 1e-1 if dtype in (T.float16, T.bfloat16) else 1e-2
torch.testing.assert_close(B, _ref(A, op), atol=tol, rtol=tol)
# ---------------------------------------------------------------------------
# test_reduce_clear (op × src_scope × dst_scope, clear=False)
# ---------------------------------------------------------------------------
REDUCE_CLEAR_CASES = [
# (op, dtype, M, N, src_scope, dst_scope, init, ref_fn_extra)
# sum: init=1, ref = A.sum(dim=1) + 1
("sum", T.float32, 128, 128, "fragment", "fragment"),
("sum", T.float32, 128, 128, "fragment", "shared"),
("sum", T.float32, 32, 32, "shared", "shared"),
# max: init=-inf, ref = A.max(dim=1).values (max(-inf, x) = x)
("max", T.float16, 128, 128, "fragment", "fragment"),
]
@pytest.mark.parametrize(
("op", "dtype", "M", "N", "src_scope", "dst_scope"),
REDUCE_CLEAR_CASES,
ids=[f"{op}-{dtype}-{M}x{N}-{src_scope[0]}2{dst_scope[0]}" for op, dtype, M, N, src_scope, dst_scope in REDUCE_CLEAR_CASES],
)
def test_reduce_clear(op, dtype, M, N, src_scope, dst_scope):
@tilelang.jit(out_idx=-1)
def kernel(M, N, dtype, op, src_scope, dst_scope):
@T.prim_func
def main(A: T.Tensor((M, N), dtype), B: T.Tensor((M,), dtype)):
with T.Kernel(1, threads=32):
if src_scope == "fragment":
src = T.alloc_fragment((M, N), dtype)
else:
src = T.alloc_shared((M, N), dtype)
if dst_scope == "fragment":
dst = T.alloc_fragment((M,), dtype)
else:
dst = T.alloc_shared((M,), dtype)
T.copy(A, src, disable_tma=src_scope == "shared")
if op == "sum":
T.fill(dst, 1)
T.reduce_sum(src, dst, dim=1, clear=False)
elif op == "max":
T.fill(dst, -T.infinity(dtype))
T.reduce_max(src, dst, dim=1, clear=False)
T.copy(dst, B)
return main
torch_dtype = getattr(torch, dtype)
A = torch.randn(M, N, dtype=torch_dtype).cuda()
B = kernel(M, N, dtype, op, src_scope, dst_scope)(A)
if op == "sum":
ref = A.sum(dim=1) + 1
elif op == "max":
ref = A.max(dim=1).values
torch.testing.assert_close(B, ref, atol=1e-2, rtol=1e-2)
# ---------------------------------------------------------------------------
# T.finalize_reducer tests
# ---------------------------------------------------------------------------
_COMPILE_FLAGS = {
tl.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True,
tl.PassConfigKey.TL_DISABLE_TMA_LOWER: True,
}
FINALIZE_REDUCER_CASES = [
# (op, dtype, block_M, block_N, batch)
("sum", T.float32, 128, 64, 1),
("sum", T.float32, 128, 64, 4),
("max", T.float16, 64, 128, 1),
("max", T.float16, 64, 128, 8),
("min", T.float32, 128, 128, 1),
("min", T.float32, 128, 128, 16),
]
def _make_finalize_reducer_kernel(block_M, block_N, dtype, op, batch):
@T.prim_func
def kernel(A: T.Tensor((block_M, block_N), dtype), B: T.Tensor((block_M,), dtype)):
with T.Kernel(1, threads=256):
o_reducer = T.alloc_reducer(block_M, dtype, op=op, replication="all")
T.clear(o_reducer)
A_smem = T.alloc_shared((block_M, block_N), dtype)
T.copy(A, A_smem)
A_frag = T.alloc_fragment((block_M, block_N), dtype)
T.copy(A_smem, A_frag)
for i, j in T.Parallel(block_M, block_N):
if op == "sum":
o_reducer[i] += A_frag[i, j]
elif op == "max":
o_reducer[i] = T.max(o_reducer[i], A_frag[i, j])
else:
o_reducer[i] = T.min(o_reducer[i], A_frag[i, j])
T.finalize_reducer(o_reducer, batch=batch)
T.copy(o_reducer, B)
return kernel
@pytest.mark.parametrize(
("op", "dtype", "block_M", "block_N", "batch"),
FINALIZE_REDUCER_CASES,
ids=[f"{op}-{dtype}-{bM}x{bN}-b{batch}" for op, dtype, bM, bN, batch in FINALIZE_REDUCER_CASES],
)
def test_finalize_reducer_codegen(op, dtype, block_M, block_N, batch):
"""batch=1 → scalar run; batch>1 → run_batch with correct template arg."""
import re
src = tl.compile(
_make_finalize_reducer_kernel(block_M, block_N, dtype, op, batch),
out_idx=-1,
pass_configs=_COMPILE_FLAGS,
).get_kernel_source()
if batch == 1:
assert "run_batch" not in src, f"batch=1 must not emit run_batch.\n{src}"
else:
m = re.search(r",\s*(\d+)\s*,\s*\d+\s*>::run_batch\(", src)
assert m is not None, f"Expected run_batch in generated source.\n{src}"
assert int(m.group(1)) == batch, f"Expected batch={batch}, got {m.group(1)}.\n{src}"
@pytest.mark.parametrize(
("op", "dtype", "block_M", "block_N", "batch"),
[c for c in FINALIZE_REDUCER_CASES if c[4] == 1],
ids=[f"{op}-{dtype}-{bM}x{bN}" for op, dtype, bM, bN, batch in FINALIZE_REDUCER_CASES if batch == 1],
)
def test_finalize_reducer_correctness(op, dtype, block_M, block_N, batch):
"""Numerical correctness (batch=1 scalar path; batch>1 blocked by fragment layout bug)."""
A = torch.randn(block_M, block_N, dtype=getattr(torch, dtype)).cuda()
B = tl.compile(
_make_finalize_reducer_kernel(block_M, block_N, dtype, op, batch),
out_idx=-1,
pass_configs=_COMPILE_FLAGS,
)(A)
torch.testing.assert_close(B, _ref(A, op), atol=1e-2, rtol=1e-2)
# (batch, exc_type, match)
FINALIZE_REDUCER_INVALID_CASES = [
(0, ValueError, "batch must be >= 1"),
(-1, ValueError, "batch must be >= 1"),
(128, Exception, "exceeds total output elements"), # block_M=64, batch=128
(3, Exception, "must evenly divide"), # block_M=64, batch=3
]
@pytest.mark.parametrize(
("batch", "exc_type", "match"),
FINALIZE_REDUCER_INVALID_CASES,
ids=["zero", "negative", "exceeds", "not-divisible"],
)
def test_finalize_reducer_invalid_batch(batch, exc_type, match):
block_M = 64
def make_kernel():
@T.prim_func
def kernel(A: T.Tensor((block_M, 64), T.float32), B: T.Tensor((block_M,), T.float32)):
with T.Kernel(1, threads=256):
o_reducer = T.alloc_reducer(block_M, T.float32, op="sum", replication="all")
T.clear(o_reducer)
A_smem = T.alloc_shared((block_M, 64), T.float32)
T.copy(A, A_smem)
A_frag = T.alloc_fragment((block_M, 64), T.float32)
T.copy(A_smem, A_frag)
for i, j in T.Parallel(block_M, 64):
o_reducer[i] += A_frag[i, j]
T.finalize_reducer(o_reducer, batch=batch)
T.copy(o_reducer, B)
return kernel
with pytest.raises(exc_type, match=match):
# batch<1 raises at prim_func definition time; others at compile time
k = make_kernel()
tl.compile(k, out_idx=-1, pass_configs=_COMPILE_FLAGS)
if __name__ == "__main__":
tilelang.testing.main()