mirror of
https://github.com/tile-ai/tilelang.git
synced 2026-11-01 08:23:24 +08:00
* [Lang] Reducer v2: first-class deferred reduction epochs with planned physical lowering
Fixes #2408 at its root. v1 implemented reducers as fragment buffers with
a special layout, so no pass owned the contribution-multiplicity
semantics: loop-layout replication silently multiplied contributions
(128 threads summing 8 elements returned 576 instead of 36), the
finalize width had to be guessed from the storage layout's
ReplicateExtent, and every compiler stage carried buffer-identity
special cases to compensate (see #2897, #2881).
Reducer v2 makes the reducer a first-class deferred reduction epoch.
Core invariant: `T.reducer_update` is the single source of contribution
multiplicity; physical layout can never change how many times a logical
contribution is combined.
New IR contract (`local.reducer` virtual scope + three first-class ops):
acc = T.alloc_reducer(shape, dtype, op="sum", seed=None)
T.reducer_init(acc)
T.reducer_update(acc[indices], contribution) # inside T.Parallel
T.finalize_reducer(acc, dst) # out-of-place
Pipeline:
* VerifyReducerEpoch (early): lifecycle state machine + opaque-access
rules; ordinary reads/writes, clear/fill, aliasing, updates outside
T.Parallel, double init/finalize are compile errors.
* ReducerPlanAndMaterialize (after LayoutInference; loop layouts are
read-only inputs): decides storage, communication, and execution
multiplicity per epoch, then materializes the first-class ops into
ordinary IR. Two physical plans:
- Narrow plan: project the reduction axes (parallel vars absent from
the update indices, plus loop replication) out of the update site's
loop layout to obtain compact per-thread partial storage; updates
run unguarded on every replica; the collective covers only the
thread-expression splits sourced from reduction axes (reusing the
ComputeReducerLayout / CollectThreadReduceSteps machinery T.reduce
is built on). Zero splits means zero collectives (LocalComplete).
Proof obligations: direct var-to-dim indices, replica-safe
contribution values, full-block coverage, a single power-of-two
step, structurally equal plans across sites, destination-layout
containment (with a controlled override for provably unconstrained
single-slot destinations).
- Wide plan (FullParticipant baseline, always available): one full
logical-shape partial per participant, canonical-replica-guarded
updates, participant-wide AllReduce, seed combined exactly once
post-collective. Any narrow-plan proof failure falls back here
deterministically - never to a compile error or wrong code.
* Generic multiplicity marker `tl.parallel_multiplicity`: PartitionLoop
lowers it to a REP == 0 guard knowing nothing about reducers; the
fully_replicated_reducer_buffers compatibility hook is retired.
* FinalizeReducerOp takes explicit plan operands (reducing_threads,
scale), closing the derive-width-from-ReplicateExtent channel.
* VerifyReducerConsumed (after LowerTileOp): surviving v2 constructs
are compile errors, never silently-wrong code.
* Pass config `tl.reducer_force_baseline` forces the wide plan for
differential testing.
Legacy compatibility and v1 removal: CanonicalizeLegacyReducer rewrites
v1 syntax (clear + read-modify-write stores + in-place finalize) into
v2 ops on a strict whitelist; non-identity fills on idempotent combines
are forwarded as one-time seeds, non-zero sum fills are rejected, batch
annotations are forwarded, and post-finalize reads are redirected to a
synthesized destination. With the shim in place, the entire v1 lowering
path is deleted: LayoutReducer, the PartitionLoop/LowerParallelLoop
reducer-buffer plumbing, the ParallelOp rep=ALL special cases, and the
vectorizer's reducer parameter chain.
Testing (H100, kernel cache disabled): 128 passed / 2 skipped across
the legacy reduce suite (93, unchanged, through the shim), the new
reducer v2 suite (22, incl. the 36-vs-576 regression, narrow-plan
codegen assertions, lifecycle diagnostics, forced-baseline
differentials), and the copy suite; the pipelined GEMV example (legacy
syntax) remains numerically correct.
* [Refactor] Consolidate reducer ops into src/op/reducer.{h,cc}
FinalizeReducerOp is no longer a standalone legacy op — it is the
collective that ReducerPlanAndMaterialize emits — so it moves next to
the first-class reducer ops. Its combine-op field now uses the same
ReducerV2OpType enum as the planner, removing the implicit
static_cast coupling between two parallel enums.
The former LayoutReducer pass files are gone entirely: the
ReducerInfo/ReducerRepType annotation types had no consumers left, and
the legacy `reducer_info` annotation key (still read by the
canonicalization shim and the data-race verifier) moves to
op/reducer.h. Stale includes in layout_inference / lower_tile_op are
dropped.
No functional change; 115 reducer tests pass.
* [Doc] Clarify the intent of tl.reducer_force_baseline
The switch is not a workaround for expected narrow-plan bugs; it exposes
the reducer design's canonical reference lowering for differential
testing, field escape-hatch use, and plan-choice A/B measurement.
* [Layout] Opaque tile-op loops must not self-plan their layout
A parallel loop whose body contains an opaque tile-op call (e.g. a
reducer update) has no visible write to anchor layout inference. In
free mode such a loop used to self-plan a partition, and the
register-count-based attempt selection then preferred it: the plan
minimizes this loop's own footprint (e.g. one x-vector element per
thread) while silently forcing a column-major layout onto the fragment
operands it reads, degrading their producing copies from vectorized to
scalar accesses. On a 4096x4096 fp16 GEMV this cost 40% end-to-end
versus the legacy reducer lowering.
Fix (generic, not reducer-aware): when a loop carries an opaque
tile-op call, prefer propagating its layout from a fragment operand
that is addressed purely by the parallel loop vars, deferring while
such an operand is still unsolved so the producing ops fix the
operands first. Self-planning remains the fallback when no operand can
drive the loop (e.g. operands indexed by inner serial reduction vars,
which require the loop to own the operand layout instead) or when
operand propagation cannot express the loop's iteration space.
With the fix the GEMV benchmark matches the legacy lowering (0.075 ms
vs 0.074 ms) and narrow-plan row reductions stay ahead (AllReduce<16>
with one partial slot per thread, ~6% faster than legacy); 129 tests
pass across the reducer v1/v2 and copy suites.
* [Lang] Reducer v2 stage 4: narrow-plan coverage, packed accumulation, bitwise ops
Narrow-plan coverage and observability:
- tl.enable_reducer_plan_verbose pass config: INFO-level per-epoch plan
decisions (narrow/wide, collective widths, rejection reasons).
- Multi-step collectives: interleaved thread splits lower to one AllReduce
per step instead of falling back to the wide plan.
- Destination override generalized: multi-slot destinations and transitive
copy chains (e.g. fp32->fp16 staging hops) adopt the induced layout when
the whole chain is unconstrained; ParallelOp candidate selection falls
back to the operand-compatible layout when the register-preferred
candidate conflicts with an overridden buffer.
- Update-index generalization: permuted loop-var indices and constant unit
dims (acc[j, i], acc[0, i]) rebuild the induced fragment over the
reducer's own dim order; all-constant scalar indices keep the synthetic
unit input ComputeReducerLayout leaves behind.
- Seed and batch now work under narrow plans: the finalize lowering
combines the seed once per slot after the collective, and explicit-width
steps compose with batched AllReduce.
Packed partial accumulation (16-bit floats):
- Single-site narrow epochs on fp16/bf16 split each per-thread partial
into two lanes keyed by the parity of an on-thread reduction source (the
innermost enclosing serial loop, or the innermost parallel reduction dim
when its low bit stays on one thread), halving the serial combine
dependence chain.
- A per-thread fold recombines the lanes into the plain induced storage
before the (unchanged) collective, so the plan's communication and
destination proofs are untouched. Lane parity never changes which thread
a contribution lands on, so the plan stays correct under any layout.
Bitwise combine ops:
- alloc_reducer(op="bitand"/"bitor"/"bitxor") for integer dtypes under
both plans (identities: all-ones / 0 / 0). Float dtypes are rejected at
allocation time; the legacy v1 path stays sum/max/min only.
Tests: reducer v2 suite grows to 38 (packed structural + numeric +
differential, bitwise under both plans, permuted/unit-dim/scalar indices,
narrow seed); the v1 codegen batch tests pin tl.reducer_force_baseline
since narrow plans legitimately skip run_batch. v1 suite stays green (93).
* [Lang] Move the reducer seed to T.reducer_init(acc, init)
The logical starting value used to be declared at allocation time
(alloc_reducer(seed=...)) and smuggled to the planner through the
reducer_info_v2 block annotation. Declaring it where the epoch opens
reads naturally ("the reduction starts from this value") and makes it a
first-class operand of the init op:
acc = T.alloc_reducer((M,), dtype, op="sum")
T.reducer_init(acc, 100.0) # optional; default = combine identity
- ReducerInitOp accepts an optional second argument carrying the value;
the annotation now carries only the combine op.
- VerifyReducerEpoch checks the value's dtype at the init site (Python
numbers are auto-converted by the frontend).
- CanonicalizeLegacyReducer forwards v1 non-identity idempotent fills as
the synthesized reducer_init's argument instead of via the annotation.
- Semantics unchanged: physical partials always initialize to the
identity; the value is combined exactly once per logical output at
finalize time, so physical replication can never multiply it.
Tests: seed tests migrated to the new API plus a dtype-mismatch
diagnostic (v2 suite now 39); v1 suite stays green (93).
* [Layout] Name the operand-driven-loop contract: kTLPerIterationOp
The opaque-loop rule (6749631b) worked but read poorly: detection
sniffed the tile-op registry (minus a tl.region special case), the
policy lived in comments spread over three code blocks, and "can this
operand drive the loop?" was answered twice with different machinery
(a try/catch around the generic source-buffer choice, plus a separate
drivability scan for the defer decision).
Restructure without changing the architecture:
- New op attribute kTLPerIterationOp declares the contract at the op
definition site: the op executes once per iteration inside T.Parallel
and its region-mediated effects are invisible to statement-level
scans. tl.reducer_update is its only member today. Both consumers now
read the attribute instead of keeping their own lists: ParallelOp's
detection (previously registry sniffing) and the nested-loop
checker's _PARALLEL_SAFE_TILE_OPS whitelist (deleted).
- Free-mode inference for such loops is now a single decision:
FindOperandDriver returns the best solved drivable operand (indices
pure in the loop vars, most index dims first) or reports a pending
one; the loop adopts, defers, or falls through to self-planning. The
exception-as-control-flow try/catch is gone from the policy path (a
small guard remains for non-affine driver indices, which drivability
does not test). One behavioral refinement: when the generic
most-indices read buffer is not drivable but another solved drivable
operand exists, the loop now adopts that operand instead of falling
back to self-planning.
Verified: reducer v2 (39) + v1 (93) + copy suites pass; the reducer
benchmark (rowsum fp32/fp16, GEMV fp16) reproduces identical plans and
timings to the previous commit.
* [Lang] reducer_update is a per-iteration builtin intrinsic, not a tile op
reducer_update never was a tile op in substance: it executes once per
iteration INSIDE T.Parallel (no other tile op does), owns no layout (the
enclosing loop and the planner decide physics), and never lowers on its
own. Registering it as one forced two categories of friction: consumers
that had to exempt it (the nested-loop checker whitelist, ParallelOp's
registry sniffing before kTLPerIterationOp), and an argument convention
(tl.region wrapping) whose only real payload was the multi-dim indices.
Re-register it as the builtin `tl.reducer_update`:
- args are (acc[indices], value) with a plain BufferLoad target — an
update descriptor keeping structured indices for the planner. Every
access scan now sees the acc read for free; the write stays
undeclared, which is sound for reducers specifically: updates commute
(combine ops are associative/commutative) and VerifyReducerEpoch pins
init/finalize to straight-line code, so no cross-statement write
ordering exists to lose inside pipelined/parallel regions.
- ParseReducerUpdate replaces the TileOperator ctor; verifier, planner
and the legacy shim consume it. The verifier visits the target's
indices but not the target load itself (a reducer load anywhere else
is still the usual compile error).
- Pipeline statement classification counts kTLPerIterationOp calls as
compute (BufferRegionCollector, IsPureCopyStmt/GetSinglePureCopyTileOp
would otherwise classify an update-only loop as copy-like now that the
call no longer parses as a tile op); the shared IsPerIterationOpCall
helper also replaces ParallelOp's inline attribute lookup.
- The nested-loop checker exemption is gone entirely: a builtin has no
TLOpBuilder, so it passes the tile-op ban by construction.
Verified: reducer v2 (39) + v1 (93) + copy suites pass; the reducer
benchmark (all cases T.Pipelined, exercising the pipeline dependency and
classification paths) reproduces identical plans and timings.
* [Cleanup] Drop the TLPerIterationOp attribute for a direct predicate
The attribute named a category that does not carve reality at a joint:
nearly every builtin intrinsic "executes per iteration" inside
T.Parallel, so the name suggested a structural property while actually
being an opt-in marker with exactly one member. What its two consumers
really key on is reducer_update itself — the hidden write to a
deferred-layout buffer (ParallelOp inference) and compute-vs-copy
statement classification (pipeline planning).
Replace it with IsReducerUpdateCall next to the op declaration; both
consumers and ParallelOpNode::has_reducer_update_ now say plainly what
they check. A future sibling op (e.g. a rescale intrinsic) extends the
one predicate.
* [Doc] Record the operand-driven defer protocol's known limitation
Deferring assumes the pending drivable operand will be solved by its
producer. An operand with no producer at all (an update reading a
fragment nothing ever writes) defers on every free-level round and
surfaces as a compile-time "layout can not be inferred" ICHECK — never
wrong code, and only on degenerate input. A sound fix needs the
inference engine to order solving (or guarantee a final round); that
redesign is deliberately deferred to a follow-up rather than patched
into the protocol here.
* [Layout] Drop the operand-driven defer protocol from ParallelOp
The three-piece workaround (has_reducer_update_ detection, the
FindOperandDriver three-state ladder, free-mode defer) patched solve
ordering for anchor-less loops from inside a single op. The real defect
is the engine's register-count attempt scoring: the bias row-broadcast
counterexample reproduces the same mis-selection with no anchor-less
loop involved, so the protocol treated a symptom. Direction for the
proper fix (IO-aware cost model + declared solve ordering) is recorded
in the layout RFC.
Reducer-update loops return to the generic two-candidate free
inference. The candidate-validation fallback still bounds the damage:
fp16 GEMV 0.0615 ms vs 0.0736 ms v1 baseline (down from 0.0346 ms with
the protocol), rowsum unchanged across all sizes. reducer v2 (39),
v1 reduce (93), atomic, and copy suites pass.
* [Cleanup] Unwrap the free-inference branch's vacuous layout guard
The inner !loop_layout_.defined() check was the seam where the deleted
operand-driven block could adopt a driver layout before the generic
two-candidate path ran. The enclosing branch already requires an
undefined layout and nothing assigns one in between, so the guard is
always true now.
* [Cleanup] Drop the unreachable post-selection candidate fallback
ChooseBestCandidate already validates both candidates with the same
flags and returns a valid one whenever either validates (single-valid
returns it directly, both-valid picks among valid, both-invalid leaves
nothing to fall back to). The post-hoc re-validation could therefore
never flip the choice; an instrumented run across the reducer suites
and the rowsum/GEMV benchmarks confirms it never fires. It was added
alongside the destination-chain override work and mistakenly credited
for making multi-slot overrides safe — the chain registration is what
did that. Comments referencing the fallback updated.
* [Cleanup] Tighten loop_partition after the multiplicity-marker rework
- MultiplicityMarkerLowerer only rewrites statement-level AttrStmt
markers; derive from StmtMutator instead of StmtExprMutator so
expression subtrees are not traversed.
- Drop a duplicated example paragraph in the partition guard comment.
- Discard InverseWithLevel's IterMapLevel explicitly instead of
keeping an unused pair binding.
* [Test] Cover reducer accumulation inside a T.Pipelined body
Pipeline planning must classify the per-iteration update intrinsic as
compute while the shared staging copy forms the only copy stage; the
epoch spans every pipeline iteration with a single finalize. This path
previously had no coverage at all.
Warp specialization is disabled in the test: a WS-split epoch is out of
scope for reducer v2 and currently surfaces as the finalize lowering's
thread-bounds ICHECK rather than a user-facing diagnostic.
* [Cleanup] Revert the pipeline-planning reducer classification marks
The three IsReducerUpdateCall sites marked update intrinsics as compute
during pipeline statement classification. Empirically they change
nothing: with the marks compiled out, both a plain pipelined
accumulation and an adversarial mixed statement (copy-shaped shared
store plus an update in one parallel loop) produce byte-identical
kernels — a bare intrinsic never classifies as a copy on any consumer
path that matters while warp-specialized reducer epochs are rejected
outright. pipeline_planning.cc returns to its upstream state, and the
now-unused IsReducerUpdateCall predicate is dropped with it.
* [Lint] Apply pre-commit clang-format
1091 lines
40 KiB
Python
1091 lines
40 KiB
Python
import re
|
||
|
||
import tilelang
|
||
import tilelang as tl
|
||
import tilelang.language as T
|
||
import tilelang.testing
|
||
import pytest
|
||
import torch
|
||
from tilelang import tvm
|
||
|
||
tilelang.testing.set_random_seed()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _case_seed(*parts):
|
||
seed = 17
|
||
for text in map(str, parts):
|
||
for char in text:
|
||
seed = (seed * 131 + ord(char)) % (2**31 - 1)
|
||
return seed
|
||
|
||
|
||
def _make_input(M, N, dtype, seed=42):
|
||
torch_dtype = getattr(torch, dtype)
|
||
generator = torch.Generator(device="cpu")
|
||
generator.manual_seed(seed)
|
||
if torch_dtype in (torch.int32, torch.int64):
|
||
return torch.randint(-100, 100, (M, N), dtype=torch_dtype, generator=generator).cuda()
|
||
return torch.randn(M, N, dtype=torch_dtype, generator=generator).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)
|
||
|
||
|
||
def _make_partial_reduce_kernel():
|
||
block_threads = 128
|
||
fragment_threads = 64
|
||
rows = 4
|
||
width = 512
|
||
vector_size = 8
|
||
|
||
@tilelang.jit(
|
||
out_idx=1,
|
||
target="cuda",
|
||
pass_configs={
|
||
tilelang.PassConfigKey.TL_DISABLE_DATA_RACE_CHECK: True,
|
||
tilelang.PassConfigKey.TL_DISABLE_THREAD_STORAGE_SYNC: True,
|
||
},
|
||
)
|
||
def make_kernel():
|
||
def x_layout(i: int, j: int) -> tuple[int, int]:
|
||
return j // vector_size, i * vector_size + j % vector_size
|
||
|
||
@T.prim_func
|
||
def fragment_reduce(
|
||
x: T.Tensor((rows, width), "float32"),
|
||
out: T.Tensor((rows, width), "float32"),
|
||
) -> None:
|
||
with T.Kernel(1, threads=block_threads):
|
||
x_frag = T.alloc_fragment((rows, width), "float32")
|
||
sum_frag = T.alloc_fragment((rows,), "float32")
|
||
T.annotate_layout(
|
||
{
|
||
x_frag: T.Fragment(x_frag.shape, forward_fn=x_layout),
|
||
sum_frag: T.Fragment(
|
||
sum_frag.shape,
|
||
forward_fn=lambda i, rep: (rep, i),
|
||
replicate=fragment_threads,
|
||
),
|
||
}
|
||
)
|
||
for i, j in T.Parallel(rows, width):
|
||
x_frag[i, j] = x[i, j]
|
||
T.reduce_sum(x_frag, sum_frag, dim=1)
|
||
for i, j in T.Parallel(rows, width):
|
||
out[i, j] = x_frag[i, j] / sum_frag[i]
|
||
|
||
return fragment_reduce
|
||
|
||
return make_kernel()
|
||
|
||
|
||
def _make_two_group_reduce_kernel(block_threads: int = 128, group_stride: int = 64):
|
||
@tilelang.jit(out_idx=1, target="cuda")
|
||
def make_kernel():
|
||
def x_layout(i: int, j: int) -> tuple[int, int]:
|
||
return i * group_stride + j // 8, j % 8
|
||
|
||
@T.prim_func
|
||
def two_group_reduce(
|
||
x: T.Tensor((2, 512), "float32"),
|
||
out: T.Tensor((2,), "float32"),
|
||
) -> None:
|
||
with T.Kernel(1, threads=block_threads):
|
||
x_frag = T.alloc_fragment((2, 512), "float32")
|
||
out_frag = T.alloc_fragment((2,), "float32")
|
||
T.annotate_layout(
|
||
{
|
||
x_frag: T.Fragment(x_frag.shape, forward_fn=x_layout),
|
||
out_frag: T.Fragment(
|
||
out_frag.shape,
|
||
forward_fn=lambda i, rep: (i * group_stride + rep, 0),
|
||
replicate=64,
|
||
),
|
||
}
|
||
)
|
||
for i, j in T.Parallel(2, 512):
|
||
x_frag[i, j] = x[i, j]
|
||
T.reduce_sum(x_frag, out_frag, dim=1)
|
||
for i in T.Parallel(2):
|
||
out[i] = out_frag[i]
|
||
|
||
return two_group_reduce
|
||
|
||
return make_kernel()
|
||
|
||
|
||
def _make_offset_thread_reduce_kernel():
|
||
"""Reduction whose participating threads occupy a non-zero thread range,
|
||
i.e. tx in [32, 96) of a 128-thread block."""
|
||
|
||
@tilelang.jit(
|
||
out_idx=1,
|
||
target="cuda",
|
||
pass_configs={
|
||
tilelang.PassConfigKey.TL_DISABLE_DATA_RACE_CHECK: True,
|
||
tilelang.PassConfigKey.TL_DISABLE_THREAD_STORAGE_SYNC: True,
|
||
},
|
||
)
|
||
def make_kernel():
|
||
thread_offset = 32
|
||
fragment_threads = 64
|
||
rows = 2
|
||
width = 512
|
||
vector_size = 8
|
||
|
||
@T.prim_func
|
||
def offset_thread_reduce(
|
||
x: T.Tensor((rows, width), "float32"),
|
||
out: T.Tensor((rows, width), "float32"),
|
||
) -> None:
|
||
with T.Kernel(1, threads=128):
|
||
x_frag = T.alloc_fragment((rows, width), "float32")
|
||
sum_frag = T.alloc_fragment((rows,), "float32")
|
||
T.annotate_layout(
|
||
{
|
||
x_frag: T.Fragment(
|
||
x_frag.shape,
|
||
forward_fn=lambda i, j: (
|
||
thread_offset + j // vector_size,
|
||
i * vector_size + j % vector_size,
|
||
),
|
||
),
|
||
sum_frag: T.Fragment(
|
||
sum_frag.shape,
|
||
forward_fn=lambda i, rep: (thread_offset + rep, i),
|
||
replicate=fragment_threads,
|
||
),
|
||
}
|
||
)
|
||
for i, j in T.Parallel(rows, width):
|
||
x_frag[i, j] = x[i, j]
|
||
T.reduce_sum(x_frag, sum_frag, dim=1)
|
||
for i, j in T.Parallel(rows, width):
|
||
out[i, j] = x_frag[i, j] / sum_frag[i]
|
||
|
||
return offset_thread_reduce
|
||
|
||
return make_kernel()
|
||
|
||
|
||
def _make_partial_warp_reduce_kernel():
|
||
@tilelang.jit(
|
||
out_idx=1,
|
||
target="cuda",
|
||
pass_configs={
|
||
tilelang.PassConfigKey.TL_DISABLE_DATA_RACE_CHECK: True,
|
||
tilelang.PassConfigKey.TL_DISABLE_THREAD_STORAGE_SYNC: True,
|
||
},
|
||
)
|
||
def make_kernel():
|
||
@T.prim_func
|
||
def partial_warp_reduce(
|
||
x: T.Tensor((1, 384), "float32"),
|
||
out: T.Tensor((1,), "float32"),
|
||
) -> None:
|
||
with T.Kernel(1, threads=128):
|
||
x_frag = T.alloc_fragment((1, 384), "float32")
|
||
sum_frag = T.alloc_fragment((1,), "float32")
|
||
T.annotate_layout(
|
||
{
|
||
x_frag: T.Fragment(
|
||
x_frag.shape,
|
||
forward_fn=lambda i, j: (j // 8, j % 8),
|
||
),
|
||
sum_frag: T.Fragment(
|
||
sum_frag.shape,
|
||
forward_fn=lambda i, rep: (rep, 0),
|
||
replicate=48,
|
||
),
|
||
}
|
||
)
|
||
for i, j in T.Parallel(1, 384):
|
||
x_frag[i, j] = x[i, j]
|
||
T.reduce_sum(x_frag, sum_frag, dim=1)
|
||
for i in T.Parallel(1):
|
||
out[i] = sum_frag[i]
|
||
|
||
return partial_warp_reduce
|
||
|
||
return make_kernel()
|
||
|
||
|
||
def _make_warp_misaligned_base_reduce_kernel():
|
||
"""Partial reduction whose participating range base is not warp-aligned,
|
||
i.e. tx in [16, 80). The shfl_xor_sync full-mask butterfly is only defined
|
||
when every participating warp is fully covered, so lowering must reject it."""
|
||
|
||
@tilelang.jit(
|
||
out_idx=1,
|
||
target="cuda",
|
||
pass_configs={
|
||
tilelang.PassConfigKey.TL_DISABLE_DATA_RACE_CHECK: True,
|
||
tilelang.PassConfigKey.TL_DISABLE_THREAD_STORAGE_SYNC: True,
|
||
},
|
||
)
|
||
def make_kernel():
|
||
@T.prim_func
|
||
def warp_misaligned_base_reduce(
|
||
x: T.Tensor((1, 512), "float32"),
|
||
out: T.Tensor((1,), "float32"),
|
||
) -> None:
|
||
with T.Kernel(1, threads=128):
|
||
x_frag = T.alloc_fragment((1, 512), "float32")
|
||
sum_frag = T.alloc_fragment((1,), "float32")
|
||
T.annotate_layout(
|
||
{
|
||
x_frag: T.Fragment(
|
||
x_frag.shape,
|
||
forward_fn=lambda i, j: (16 + j // 8, j % 8),
|
||
),
|
||
sum_frag: T.Fragment(
|
||
sum_frag.shape,
|
||
forward_fn=lambda i, rep: (16 + rep, 0),
|
||
replicate=64,
|
||
),
|
||
}
|
||
)
|
||
for i, j in T.Parallel(1, 512):
|
||
x_frag[i, j] = x[i, j]
|
||
T.reduce_sum(x_frag, sum_frag, dim=1)
|
||
for i in T.Parallel(1):
|
||
out[i] = sum_frag[i]
|
||
|
||
return warp_misaligned_base_reduce
|
||
|
||
return make_kernel()
|
||
|
||
|
||
def _make_large_unused_reduce_dimension_kernel():
|
||
rows = 16385
|
||
width = 512
|
||
vector_size = 8
|
||
|
||
@T.prim_func
|
||
def kernel():
|
||
with T.Kernel(1, threads=128):
|
||
src = T.alloc_fragment((rows, width), T.float32)
|
||
dst = T.alloc_fragment((rows,), T.float32)
|
||
T.annotate_layout(
|
||
{
|
||
src: T.Fragment(
|
||
src.shape,
|
||
forward_fn=lambda i, j: (
|
||
j // vector_size,
|
||
i * vector_size + j % vector_size,
|
||
),
|
||
),
|
||
dst: T.Fragment(
|
||
dst.shape,
|
||
forward_fn=lambda i, rep: (rep, i),
|
||
replicate=64,
|
||
),
|
||
}
|
||
)
|
||
T.fill(src, 1.0)
|
||
T.reduce_sum(src, dst, dim=1)
|
||
|
||
return kernel
|
||
|
||
|
||
def _make_sm80_batch_reduce_kernel():
|
||
@T.prim_func
|
||
def kernel(
|
||
A: T.Tensor((128, 64), T.float32),
|
||
B: T.Tensor((128,), T.float32),
|
||
):
|
||
with T.Kernel(1, threads=256):
|
||
src = T.alloc_shared((128, 64), T.float32)
|
||
dst = T.alloc_fragment((128,), T.float32)
|
||
T.copy(A, src, disable_tma=True)
|
||
T.reduce_sum(src, dst, dim=1, batch=4)
|
||
T.copy(dst, B)
|
||
|
||
return kernel
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_correctness():
|
||
torch.manual_seed(0)
|
||
x = torch.rand((4, 512), dtype=torch.float32, device="cuda")
|
||
out = _make_partial_reduce_kernel()(x)
|
||
torch.cuda.synchronize()
|
||
torch.testing.assert_close(
|
||
out,
|
||
x / x.sum(dim=1, keepdim=True),
|
||
rtol=1e-5,
|
||
atol=1e-6,
|
||
)
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_full_block_groups():
|
||
torch.manual_seed(1)
|
||
x = torch.rand((2, 512), dtype=torch.float32, device="cuda")
|
||
out = _make_two_group_reduce_kernel()(x)
|
||
torch.cuda.synchronize()
|
||
torch.testing.assert_close(out, x.sum(dim=1), rtol=1e-5, atol=1e-5)
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_multiple_groups_in_partial_cta():
|
||
"""Two adjacent 64-thread groups form [0, 128) in a 256-thread CTA."""
|
||
torch.manual_seed(2)
|
||
x = torch.rand((2, 512), dtype=torch.float32, device="cuda")
|
||
out = _make_two_group_reduce_kernel(block_threads=256)(x)
|
||
torch.cuda.synchronize()
|
||
torch.testing.assert_close(out, x.sum(dim=1), rtol=1e-5, atol=1e-5)
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_offset_thread_range():
|
||
torch.manual_seed(3)
|
||
x = torch.rand((2, 512), dtype=torch.float32, device="cuda")
|
||
out = _make_offset_thread_reduce_kernel()(x)
|
||
torch.cuda.synchronize()
|
||
torch.testing.assert_close(
|
||
out,
|
||
x / x.sum(dim=1, keepdim=True),
|
||
rtol=1e-5,
|
||
atol=1e-6,
|
||
)
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_ignores_large_unused_layout_dimension():
|
||
"""Barrier resolution enumerates CTA threads, not layout coordinates."""
|
||
target = {"kind": "cuda", "arch": "sm_80"}
|
||
with tvm.transform.PassContext(), tvm.target.Target(target):
|
||
artifact = tilelang.lower(_make_large_unused_reduce_dimension_kernel(), target=target)
|
||
assert "tl::NamedBarrier<64>" in artifact.kernel_source
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_sm80_batch_reduce_uses_named_barrier():
|
||
target = {"kind": "cuda", "arch": "sm_80"}
|
||
with tvm.transform.PassContext(), tvm.target.Target(target):
|
||
artifact = tilelang.lower(_make_sm80_batch_reduce_kernel(), target=target)
|
||
assert "NamedBarrier<" in artifact.kernel_source
|
||
assert "tl::SyncThreadsBarrier" not in artifact.kernel_source
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_rejects_non_power_of_two_width():
|
||
with pytest.raises(Exception, match="positive power of two"):
|
||
_make_partial_warp_reduce_kernel()
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_rejects_warp_misaligned_base():
|
||
"""A participating range whose base is not warp-aligned ([16, 80)) would
|
||
leave partially-covered warps, making the full-mask shfl_xor_sync butterfly
|
||
undefined. Lowering must reject it."""
|
||
with pytest.raises(Exception, match="warp-aligned participating thread range"):
|
||
_make_warp_misaligned_base_reduce_kernel()
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
def test_reduce_partial_thread_barrier_rejects_discontiguous_warps():
|
||
with pytest.raises(Exception, match="one contiguous thread range|Could not normalize iterators"):
|
||
_make_two_group_reduce_kernel(block_threads=256, group_stride=128)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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),
|
||
("sum", T.bfloat16, 128, 128, "fragment", "fragment", 32, 1),
|
||
("sum", T.bfloat16, 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):
|
||
|
||
@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}"
|
||
|
||
seed = _case_seed("reduce", op, dtype, M, N, src_scope, dst_scope, threads, batch)
|
||
A = _make_input(M, N, dtype, seed)
|
||
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)
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize(
|
||
("op", "packed_op"),
|
||
[("sum", "add2"), ("max", "max2"), ("min", "min2")],
|
||
)
|
||
def test_reduce_local_packed_codegen(op, packed_op):
|
||
@T.prim_func
|
||
def main(A: T.Tensor((8,), T.float16), B: T.Tensor((1,), T.float16)):
|
||
with T.Kernel(1, threads=1):
|
||
src = T.alloc_local((8,), T.float16)
|
||
dst = T.alloc_local((1,), T.float16)
|
||
for i in T.serial(8):
|
||
src[i] = A[i]
|
||
_reduce_op(T, op, src, dst, dim=0)
|
||
B[0] = dst[0]
|
||
|
||
target = {"kind": "cuda", "arch": "sm_80"}
|
||
with tvm.transform.PassContext(), tvm.target.Target(target):
|
||
artifact = tilelang.lower(main, target=target)
|
||
assert f"tl::{packed_op}" in artifact.kernel_source
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize(
|
||
("op", "packed_op"),
|
||
[("sum", "add2"), ("max", "max2"), ("min", "min2")],
|
||
)
|
||
def test_reduce_local_noncontiguous_dim_packed_codegen(op, packed_op):
|
||
@T.prim_func
|
||
def main(A: T.Tensor((8, 4), T.float16), B: T.Tensor((4,), T.float16)):
|
||
with T.Kernel(1, threads=1):
|
||
src = T.alloc_local((8, 4), T.float16)
|
||
dst = T.alloc_local((4,), T.float16)
|
||
for i in T.serial(8):
|
||
for j in T.serial(4):
|
||
src[i, j] = A[i, j]
|
||
_reduce_op(T, op, src, dst, dim=0)
|
||
for j in T.serial(4):
|
||
B[j] = dst[j]
|
||
|
||
target = {"kind": "cuda", "arch": "sm_80"}
|
||
with tvm.transform.PassContext(), tvm.target.Target(target):
|
||
artifact = tilelang.lower(main, target=target)
|
||
assert f"tl::{packed_op}" in artifact.kernel_source
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize(
|
||
("op", "packed_op"),
|
||
[("sum", "add2"), ("max", "max2"), ("min", "min2")],
|
||
)
|
||
def test_reduce_local_to_var_packed_codegen(op, packed_op):
|
||
@T.prim_func
|
||
def main(A: T.Tensor((8,), T.float16), B: T.Tensor((1,), T.float16)):
|
||
with T.Kernel(1, threads=1):
|
||
src = T.alloc_local((8,), T.float16)
|
||
dst = T.alloc_var(T.float16)
|
||
for i in T.serial(8):
|
||
src[i] = A[i]
|
||
_reduce_op(T, op, src, dst, dim=0)
|
||
B[0] = dst
|
||
|
||
target = {"kind": "cuda", "arch": "sm_80"}
|
||
with tvm.transform.PassContext(), tvm.target.Target(target):
|
||
artifact = tilelang.lower(main, target=target)
|
||
assert f"tl::{packed_op}" in artifact.kernel_source
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize("op", ["sum", "max", "min"])
|
||
def test_reduce_local_packed_correctness(op):
|
||
@tilelang.jit(out_idx=-1)
|
||
def kernel():
|
||
@T.prim_func
|
||
def main(A: T.Tensor((8,), T.float16), B: T.Tensor((1,), T.float16)):
|
||
with T.Kernel(1, threads=1):
|
||
src = T.alloc_local((8,), T.float16)
|
||
dst = T.alloc_local((1,), T.float16)
|
||
for i in T.serial(8):
|
||
src[i] = A[i]
|
||
_reduce_op(T, op, src, dst, dim=0)
|
||
B[0] = dst[0]
|
||
|
||
return main
|
||
|
||
jit_kernel = kernel()
|
||
A = torch.randn((8,), dtype=torch.float16, device="cuda")
|
||
B = jit_kernel(A)
|
||
torch.testing.assert_close(B[0], _ref(A.reshape(1, 8), op)[0], atol=1e-1, rtol=1e-1)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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.
|
||
|
||
Forces the FullParticipant baseline: this test verifies the batched
|
||
AllReduce plumbing of the wide plan. Under automatic plan selection
|
||
these kernels take a narrow plan whose collective fits in a warp, where
|
||
batching (which amortizes block-wide barriers) has nothing to amortize
|
||
and run_batch is legitimately absent.
|
||
"""
|
||
|
||
src = tl.compile(
|
||
_make_finalize_reducer_kernel(block_M, block_N, dtype, op, batch),
|
||
out_idx=-1,
|
||
pass_configs={**_COMPILE_FLAGS, "tl.reducer_force_baseline": True},
|
||
).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}"
|
||
|
||
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 0)
|
||
@pytest.mark.parametrize("batch", [1, 4])
|
||
def test_finalize_reducer_sm80_uses_named_barrier(batch):
|
||
target = {"kind": "cuda", "arch": "sm_80"}
|
||
with tvm.transform.PassContext(config=_COMPILE_FLAGS), tvm.target.Target(target):
|
||
artifact = tilelang.lower(
|
||
_make_finalize_reducer_kernel(128, 64, T.float32, "sum", batch),
|
||
target=target,
|
||
)
|
||
assert "NamedBarrier<" in artifact.kernel_source
|
||
assert "tl::SyncThreadsBarrier" not in artifact.kernel_source
|
||
|
||
|
||
@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)
|
||
|
||
|
||
def test_finalize_reducer_short_parallel_extent():
|
||
extent = 8
|
||
threads = 128
|
||
|
||
@T.prim_func
|
||
def kernel(A: T.Tensor((extent,), T.float32), B: T.Tensor((1,), T.float32)):
|
||
with T.Kernel(1, threads=threads):
|
||
src = T.alloc_fragment((extent,), T.float32)
|
||
T.copy(A, src)
|
||
total = T.alloc_reducer((1,), T.float32, replication="all")
|
||
T.clear(total)
|
||
for i in T.Parallel(extent):
|
||
total[0] += src[i]
|
||
T.finalize_reducer(total)
|
||
if T.get_thread_binding() == 0:
|
||
B[0] = total[0]
|
||
|
||
A = torch.arange(1, extent + 1, dtype=torch.float32, device="cuda")
|
||
B = tl.compile(kernel, out_idx=-1, pass_configs=_COMPILE_FLAGS)(A)
|
||
torch.testing.assert_close(B, A.sum().reshape(1), atol=0, rtol=0)
|
||
|
||
|
||
def test_finalize_reducer_mixed_parallel_extents():
|
||
extent = 8
|
||
threads = 128
|
||
|
||
@T.prim_func
|
||
def kernel(
|
||
A: T.Tensor((extent,), T.float32),
|
||
B: T.Tensor((2 * extent,), T.float32),
|
||
C: T.Tensor((1,), T.float32),
|
||
):
|
||
with T.Kernel(1, threads=threads):
|
||
a_frag = T.alloc_fragment((extent,), T.float32)
|
||
b_frag = T.alloc_fragment((2 * extent,), T.float32)
|
||
T.copy(A, a_frag)
|
||
T.copy(B, b_frag)
|
||
total = T.alloc_reducer((1,), T.float32, replication="all")
|
||
T.clear(total)
|
||
for i in T.Parallel(extent):
|
||
total[0] += a_frag[i]
|
||
for j in T.Parallel(2 * extent):
|
||
total[0] += b_frag[j]
|
||
T.finalize_reducer(total)
|
||
if T.get_thread_binding() == 0:
|
||
C[0] = total[0]
|
||
|
||
A = torch.arange(1, extent + 1, dtype=torch.float32, device="cuda")
|
||
B = torch.arange(1, 2 * extent + 1, dtype=torch.float32, device="cuda")
|
||
C = tl.compile(kernel, out_idx=-1, pass_configs=_COMPILE_FLAGS)(A, B)
|
||
torch.testing.assert_close(C, (A.sum() + B.sum()).reshape(1), atol=0, rtol=0)
|
||
|
||
|
||
# (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)
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_absmax_bf16_noncontiguous_packed_layout_regression():
|
||
num_tokens = 64
|
||
hidden = 2560
|
||
num_threads = 128
|
||
num_vectorize = 4
|
||
|
||
def x_layout_fn(i, j):
|
||
idx = i * hidden + j
|
||
return (
|
||
idx // num_vectorize % num_threads,
|
||
idx // (num_vectorize * num_threads) * num_vectorize + idx % num_vectorize,
|
||
)
|
||
|
||
@T.prim_func
|
||
def kernel(A: T.Tensor((num_tokens, hidden), T.bfloat16), B: T.Tensor((num_tokens,), T.float32)):
|
||
with T.Kernel(num_tokens, threads=num_threads) as (pid,):
|
||
src = T.alloc_fragment((1, hidden), T.bfloat16)
|
||
dst = T.alloc_fragment((1, 1), T.bfloat16)
|
||
T.annotate_layout({src: T.Fragment((1, hidden), forward_fn=x_layout_fn)})
|
||
T.copy(A[pid, 0], src, disable_tma=True)
|
||
src_reshaped = T.reshape(src, (1, 1, hidden))
|
||
T.reduce_absmax(src_reshaped, dst, dim=2)
|
||
B[pid] = T.cast(dst[0, 0], T.float32)
|
||
|
||
A = torch.zeros((num_tokens, hidden), dtype=torch.bfloat16, device="cuda")
|
||
A[:, 512] = -10
|
||
B = _compile(kernel)(A)
|
||
ref = A.abs().amax(dim=1).float()
|
||
torch.testing.assert_close(B, ref, atol=0, rtol=0)
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_sum_reshape_straddle_layout_regression():
|
||
tile_m = 2
|
||
hidden = 192
|
||
group = 6
|
||
group_k = 32
|
||
threads = 128
|
||
|
||
@T.prim_func
|
||
def kernel(
|
||
A: T.Tensor((tile_m, hidden), T.float32),
|
||
B: T.Tensor((tile_m, group), T.float32),
|
||
):
|
||
with T.Kernel(1, threads=threads):
|
||
src = T.alloc_fragment((tile_m, hidden), T.float32)
|
||
dst = T.alloc_fragment((tile_m, group), T.float32)
|
||
|
||
for i, j in T.Parallel(tile_m, hidden):
|
||
src[i, j] = A[i, j]
|
||
|
||
src_reshaped = T.reshape(src, (tile_m, group, group_k))
|
||
T.reduce_sum(src_reshaped, dst, dim=2)
|
||
|
||
for i, g in T.Parallel(tile_m, group):
|
||
B[i, g] = dst[i, g]
|
||
|
||
A = torch.arange(1, tile_m * hidden + 1, dtype=torch.float32, device="cuda").reshape(tile_m, hidden)
|
||
B = _compile(kernel)(A)
|
||
ref = A.reshape(tile_m, group, group_k).sum(dim=2)
|
||
torch.testing.assert_close(B, ref, atol=1e-3, rtol=1e-3)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# nan_propagate tests – packed (vsize=2) path for bf16/fp16
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _compile(prim_func):
|
||
return tilelang.compile(prim_func, out_idx=-1, target="cuda")
|
||
|
||
|
||
def _make_allreduce_width_kernel(reduce_fn, M, width, threads):
|
||
@T.prim_func
|
||
def kernel(A: T.Tensor((M, width), T.float32), B: T.Tensor((M,), T.float32)):
|
||
with T.Kernel(1, threads=threads):
|
||
src = T.alloc_fragment((M, width), T.float32)
|
||
dst = T.alloc_fragment((M,), T.float32)
|
||
T.copy(A, src)
|
||
reduce_fn(src, dst, dim=1)
|
||
T.copy(dst, B)
|
||
|
||
return kernel
|
||
|
||
|
||
def _make_allreduce_dim0_scale_kernel(reduce_fn, logical_width, scale):
|
||
@T.prim_func
|
||
def kernel(
|
||
A: T.Tensor((logical_width, scale), T.float32),
|
||
B: T.Tensor((scale,), T.float32),
|
||
):
|
||
with T.Kernel(1, threads=logical_width * scale):
|
||
src = T.alloc_fragment((logical_width, scale), T.float32)
|
||
dst = T.alloc_fragment((scale,), T.float32)
|
||
T.copy(A, src)
|
||
reduce_fn(src, dst, dim=0)
|
||
T.copy(dst, B)
|
||
|
||
return kernel
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize("reduce_fn", [T.reduce_sum, T.reduce_max], ids=["sum", "max"])
|
||
@pytest.mark.parametrize("width", [48, 96])
|
||
def test_allreduce_rejects_non_power_of_two_logical_width(reduce_fn, width):
|
||
with pytest.raises(Exception, match="logical_width.*positive power of two"):
|
||
_compile(_make_allreduce_width_kernel(reduce_fn, 1, width, width))
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize("reduce_fn", [T.reduce_sum, T.reduce_max], ids=["sum", "max"])
|
||
@pytest.mark.parametrize("width", [32, 64, 128])
|
||
def test_allreduce_power_of_two_width_runtime(reduce_fn, width):
|
||
M = 4
|
||
k = _compile(_make_allreduce_width_kernel(reduce_fn, M, width, width))
|
||
A = torch.randn(M, width, dtype=torch.float32, device="cuda")
|
||
B = k(A)
|
||
ref = A.sum(dim=1) if reduce_fn is T.reduce_sum else A.max(dim=1).values
|
||
torch.testing.assert_close(B, ref, atol=1e-2, rtol=1e-2)
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize(("logical_width", "scale"), [(32, 2), (64, 2)])
|
||
def test_allreduce_scale_greater_than_one_valid_runtime(logical_width, scale):
|
||
k = _compile(_make_allreduce_dim0_scale_kernel(T.reduce_sum, logical_width, scale))
|
||
A = torch.randn(logical_width, scale, dtype=torch.float32, device="cuda")
|
||
B = k(A)
|
||
torch.testing.assert_close(B, A.sum(dim=0), atol=1e-2, rtol=1e-2)
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@pytest.mark.parametrize("reduce_fn", [T.reduce_sum, T.reduce_max], ids=["sum", "max"])
|
||
def test_allreduce_scale_greater_than_one_rejects_non_power_of_two(reduce_fn):
|
||
with pytest.raises(Exception, match=r"logical_width.*positive power of two"):
|
||
_compile(_make_allreduce_dim0_scale_kernel(reduce_fn, 48, 2))
|
||
|
||
|
||
def _make_nan_reduce_kernel(reduce_fn, M, N, dtype, threads, *, nan_propagate):
|
||
@T.prim_func
|
||
def kernel(A: T.Tensor((M, N), dtype), B: T.Tensor((M,), dtype)):
|
||
with T.Kernel(1, threads=threads):
|
||
src = T.alloc_fragment((M, N), dtype)
|
||
dst = T.alloc_fragment((M,), dtype)
|
||
T.copy(A, src)
|
||
reduce_fn(src, dst, dim=1, nan_propagate=nan_propagate)
|
||
T.copy(dst, B)
|
||
|
||
return kernel
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
@tilelang.testing.requires_cuda_compute_version_ge(8, 9)
|
||
def test_reduce_packed_fp8_to_float16_absmax_runtime():
|
||
if not hasattr(torch, "float8_e4m3fn"):
|
||
pytest.skip("torch.float8_e4m3fn is not available")
|
||
|
||
@T.prim_func
|
||
def kernel(A: T.Tensor((4, 32), T.float8_e4m3fn), B: T.Tensor((4,), T.float16)):
|
||
with T.Kernel(1, threads=32):
|
||
src = T.alloc_fragment((4, 32), T.float8_e4m3fn)
|
||
dst = T.alloc_fragment((4,), T.float16)
|
||
T.copy(A, src)
|
||
T.reduce_absmax(src, dst, dim=1)
|
||
T.copy(dst, B)
|
||
|
||
k = _compile(kernel)
|
||
source = k.get_kernel_source()
|
||
assert "from_uint1<__half2>(*(fp8" not in source
|
||
|
||
base = torch.linspace(-2.0, 2.0, 128, device="cuda", dtype=torch.float16).reshape(4, 32)
|
||
base[0, 3] = -7.0
|
||
base[1, 17] = 5.5
|
||
base[2, 31] = -3.25
|
||
base[3, 0] = 4.0
|
||
A = base.to(torch.float8_e4m3fn)
|
||
B = k(A)
|
||
ref = A.to(torch.float16).abs().amax(dim=1)
|
||
torch.testing.assert_close(B, ref, atol=0, rtol=0)
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_packed_max_nan_propagate_uses_nan_intrinsics():
|
||
k = _compile(_make_nan_reduce_kernel(T.reduce_max, 128, 128, T.float16, threads=256, nan_propagate=True))
|
||
src = k.get_kernel_source()
|
||
assert "max2_nan" in src
|
||
assert "tl::MaxOpNan" in src
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_packed_min_nan_propagate_uses_nan_intrinsics():
|
||
k = _compile(_make_nan_reduce_kernel(T.reduce_min, 128, 128, T.bfloat16, threads=256, nan_propagate=True))
|
||
src = k.get_kernel_source()
|
||
assert "min2_nan" in src
|
||
assert "tl::MinOpNan" in src
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_packed_absmax_nan_propagate_uses_nan_intrinsics():
|
||
k = _compile(_make_nan_reduce_kernel(T.reduce_absmax, 128, 128, T.float16, threads=256, nan_propagate=True))
|
||
src = k.get_kernel_source()
|
||
assert "max2_nan" in src
|
||
assert "tl::MaxOpNan" in src
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_packed_max_nan_propagate_runtime():
|
||
import math
|
||
|
||
for tl_dtype, torch_dtype in [(T.float16, torch.float16), (T.bfloat16, torch.bfloat16)]:
|
||
M, N = 128, 128
|
||
A = torch.arange(N, dtype=torch.float32).to(torch_dtype).repeat(M, 1).cuda()
|
||
A[0, 7] = float("nan")
|
||
B = _compile(_make_nan_reduce_kernel(T.reduce_max, M, N, tl_dtype, threads=256, nan_propagate=True))(A)
|
||
assert not math.isnan(B[1:].float().max().item()), f"{tl_dtype}: non-NaN rows should not produce NaN"
|
||
assert math.isnan(B[0].float().item()), f"{tl_dtype}: NaN row must produce NaN"
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_packed_min_nan_propagate_runtime():
|
||
import math
|
||
|
||
for tl_dtype, torch_dtype in [(T.float16, torch.float16), (T.bfloat16, torch.bfloat16)]:
|
||
M, N = 128, 128
|
||
A = torch.arange(N, dtype=torch.float32).to(torch_dtype).repeat(M, 1).cuda()
|
||
A[1, 13] = float("nan")
|
||
B = _compile(_make_nan_reduce_kernel(T.reduce_min, M, N, tl_dtype, threads=256, nan_propagate=True))(A)
|
||
assert not math.isnan(B[0].float().item()), f"{tl_dtype}: non-NaN rows should not produce NaN"
|
||
assert math.isnan(B[1].float().item()), f"{tl_dtype}: NaN row must produce NaN"
|
||
|
||
|
||
@tilelang.testing.requires_cuda
|
||
def test_reduce_packed_max_nan_batch_runtime():
|
||
import math
|
||
|
||
for tl_dtype, torch_dtype in [(T.float16, torch.float16), (T.bfloat16, torch.bfloat16)]:
|
||
M, N = 64, 128
|
||
A = torch.arange(N, dtype=torch.float32).to(torch_dtype).repeat(M, 1).cuda()
|
||
A[2, 7] = float("nan")
|
||
B = _compile(_make_nan_reduce_kernel(T.reduce_max, M, N, tl_dtype, threads=256, nan_propagate=True))(A)
|
||
assert not math.isnan(B[0].float().item()), f"{tl_dtype}: non-NaN rows should not produce NaN"
|
||
assert math.isnan(B[2].float().item()), f"{tl_dtype}: NaN row must produce NaN"
|
||
|
||
|
||
if __name__ == "__main__":
|
||
tilelang.testing.main()
|