Skip to content

Commit d8c0e1b

Browse files
WhatGhostcursoragentCaeruleann
authored
[AMD] Add RDNA4 split softmax (#5327)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: wanghairui <hairui.wang@compiler-dev.com>
1 parent b040a6a commit d8c0e1b

3 files changed

Lines changed: 572 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# Only names bound here are picked up: the arch loader collects every function in
16+
# this namespace with inspect.getmembers and overwrites the same-named generic op,
17+
# so helpers must not be re-exported from here.
18+
from .softmax import softmax, softmax_out
19+
20+
__all__ = [
21+
"softmax",
22+
"softmax_out",
23+
]
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# Copyright 2026 FlagOS Contributors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""RDNA4 softmax: split the reduction across workgroups when rows are scarce.
16+
17+
The generic softmax_kernel_inner launches grid (M, 1, 1), one workgroup per row,
18+
so parallelism is bounded by the row count rather than by the size of the
19+
reduction. A 1-D input collapses to M=1, which leaves all but one CU idle no
20+
matter how large N is.
21+
22+
Here each row is split across NUM_BLOCKS workgroups. Pass 1 reduces one chunk
23+
per workgroup into a partial (max, sum-of-exp) pair; pass 2 combines the partials
24+
with the online-softmax identity and writes the normalized output. The grid
25+
becomes (NUM_BLOCKS, M), so parallelism no longer depends on how many rows there
26+
are. Traffic stays at two reads plus one write, the same as the generic loop
27+
path, and the combine costs a few KB out of cache instead of a third launch.
28+
29+
Only the starved regime is taken over; softmax_out falls through to the generic
30+
implementation everywhere else, which already fills the card once there are
31+
enough rows to go around.
32+
"""
33+
34+
import logging
35+
from functools import lru_cache
36+
37+
import torch
38+
import triton
39+
import triton.language as tl
40+
41+
from flag_gems.ops.softmax import softmax_out as generic_softmax_out
42+
from flag_gems.runtime import torch_device_fn
43+
from flag_gems.utils import libentry
44+
from flag_gems.utils import triton_lang_extension as ext
45+
46+
logger = logging.getLogger(__name__)
47+
48+
# Tile size and warp count turned out not to be sensitive; the workgroup count is
49+
# what the shape has to drive, so _BLOCKS_PER_CU is the only one of the three that
50+
# feeds the plan below.
51+
_TILE_N = 2048
52+
_NUM_WARPS = 8
53+
_BLOCKS_PER_CU = 4
54+
55+
# Eligibility is a pair of limits picked by element width: the shortest reduction
56+
# still worth splitting, and how many CUs each row must be able to claim.
57+
#
58+
# Rows stop being scarce once the generic grid, one workgroup per row, fills the
59+
# CUs by itself, and splitting past that point only buys a second launch. Narrow
60+
# elements stay ahead of the generic kernel with twice as many rows in flight, so
61+
# they get the looser row budget, paired with the shortest row at which that row
62+
# count turns positive. Wider elements finish within a few percent of the generic
63+
# kernel at that row count whatever the length, so they take the tighter row
64+
# budget and, with it, a shorter minimum row.
65+
#
66+
# Both CU figures are deliberately separate from _BLOCKS_PER_CU: that one sets how
67+
# many blocks to aim for, these decide which shapes are eligible at all. Kept
68+
# conservative, so shorter rows are left to the generic kernel even where
69+
# splitting would still win a little.
70+
_NARROW_MAX_ITEMSIZE = 2
71+
_NARROW_MIN_SPLIT_N = 48 * 1024
72+
_NARROW_MIN_CUS_PER_ROW = 4
73+
_WIDE_MIN_SPLIT_N = 40 * 1024
74+
_WIDE_MIN_CUS_PER_ROW = 8
75+
76+
77+
@lru_cache(maxsize=8)
78+
def _cu_count(device_index):
79+
return torch_device_fn.get_device_properties(device_index).multi_processor_count
80+
81+
82+
def _prev_power_of_2(n):
83+
return 1 << (n.bit_length() - 1) if n >= 1 else 1
84+
85+
86+
def _split_plan(m, n, itemsize, device_index):
87+
"""Workgroups per row, or None to leave this shape to the generic kernel."""
88+
if itemsize <= _NARROW_MAX_ITEMSIZE:
89+
min_n, cus_per_row = _NARROW_MIN_SPLIT_N, _NARROW_MIN_CUS_PER_ROW
90+
else:
91+
min_n, cus_per_row = _WIDE_MIN_SPLIT_N, _WIDE_MIN_CUS_PER_ROW
92+
cu = _cu_count(device_index)
93+
if m > cu // cus_per_row or n < min_n:
94+
return None
95+
target_blocks = _BLOCKS_PER_CU * cu
96+
num_blocks = _prev_power_of_2(max(1, target_blocks // m))
97+
# Cap so every workgroup still gets at least one whole tile.
98+
num_blocks = min(num_blocks, _prev_power_of_2(n // _TILE_N))
99+
return num_blocks if num_blocks >= 2 else None
100+
101+
102+
@libentry()
103+
@triton.jit
104+
def softmax_split_reduce_kernel(
105+
inp_ptr,
106+
partial_max_ptr,
107+
partial_sum_ptr,
108+
N,
109+
NUM_BLOCKS,
110+
TILE_N: tl.constexpr,
111+
):
112+
pid_b = ext.program_id(0)
113+
pid_m = ext.program_id(1)
114+
row = inp_ptr + pid_m * N
115+
116+
m = tl.full([TILE_N], value=float("-inf"), dtype=tl.float32)
117+
z = tl.zeros([TILE_N], dtype=tl.float32)
118+
119+
stride = NUM_BLOCKS * TILE_N
120+
for off in range(pid_b * TILE_N, N, stride):
121+
n_offsets = off + tl.arange(0, TILE_N)
122+
mask = n_offsets < N
123+
inp = tl.load(row + n_offsets, mask=mask, other=-float("inf")).to(tl.float32)
124+
m_new = tl.maximum(m, inp)
125+
# An all -inf window must keep z at 0 rather than accumulate exp(nan).
126+
all_neg_inf = m_new == float("-inf")
127+
z = tl.where(all_neg_inf, z, z * tl.exp(m - m_new) + tl.exp(inp - m_new))
128+
m = m_new
129+
130+
m_reduced = tl.max(m, 0)
131+
# Lanes still at -inf would give exp(-inf - -inf) = exp(nan) when the whole
132+
# chunk is -inf, so select the scale instead of computing it.
133+
scale = tl.where(m == float("-inf"), 0.0, tl.exp(m - m_reduced))
134+
z_reduced = tl.sum(z * scale, 0)
135+
136+
tl.store(partial_max_ptr + pid_m * NUM_BLOCKS + pid_b, m_reduced)
137+
tl.store(partial_sum_ptr + pid_m * NUM_BLOCKS + pid_b, z_reduced)
138+
139+
140+
@libentry()
141+
@triton.jit
142+
def softmax_split_normalize_kernel(
143+
out_ptr,
144+
inp_ptr,
145+
partial_max_ptr,
146+
partial_sum_ptr,
147+
N,
148+
NUM_BLOCKS,
149+
TILE_N: tl.constexpr,
150+
TILE_B: tl.constexpr,
151+
):
152+
pid_b = ext.program_id(0)
153+
pid_m = ext.program_id(1)
154+
155+
# Every workgroup redoes the combine over the NUM_BLOCKS partials. That is a
156+
# few KB already in cache, and it avoids both a third launch and passing the
157+
# row scalars through memory.
158+
b_offsets = tl.arange(0, TILE_B)
159+
b_mask = b_offsets < NUM_BLOCKS
160+
partial_offset = pid_m * NUM_BLOCKS + b_offsets
161+
partial_max = tl.load(
162+
partial_max_ptr + partial_offset, mask=b_mask, other=-float("inf")
163+
)
164+
partial_sum = tl.load(partial_sum_ptr + partial_offset, mask=b_mask, other=0.0)
165+
166+
row_max = tl.max(partial_max, 0)
167+
scale = tl.where(partial_max == float("-inf"), 0.0, tl.exp(partial_max - row_max))
168+
row_sum = tl.sum(partial_sum * scale, 0)
169+
170+
row_in = inp_ptr + pid_m * N
171+
row_out = out_ptr + pid_m * N
172+
173+
stride = NUM_BLOCKS * TILE_N
174+
for off in range(pid_b * TILE_N, N, stride):
175+
n_offsets = off + tl.arange(0, TILE_N)
176+
mask = n_offsets < N
177+
inp = tl.load(row_in + n_offsets, mask=mask, other=-float("inf")).to(tl.float32)
178+
out = tl.exp(inp - row_max) / row_sum
179+
tl.store(row_out + n_offsets, out.to(out_ptr.dtype.element_ty), mask=mask)
180+
181+
182+
def softmax_out(self, dim, half_to_float=False, *, out):
183+
assert dim >= -self.ndim and dim < self.ndim, "Invalid dim"
184+
185+
dim = dim % self.ndim
186+
N = self.shape[dim]
187+
M = 1
188+
for i in range(dim):
189+
M *= self.shape[i]
190+
K = self.numel() // M // N if self.numel() else 0
191+
192+
# half_to_float widens the store to fp32, so the plan sees the wider element.
193+
itemsize = 4 if half_to_float else self.element_size()
194+
plan = (
195+
_split_plan(M, N, itemsize, self.device.index)
196+
if K == 1 and self.numel()
197+
else None
198+
)
199+
if plan is None:
200+
return generic_softmax_out(self, dim, half_to_float, out=out)
201+
202+
logger.debug("GEMS_RDNA4 SOFTMAX_OUT SPLIT M=%d N=%d blocks=%d", M, N, plan)
203+
204+
self = self.contiguous()
205+
dtype = torch.float32 if half_to_float else self.dtype
206+
if tuple(out.shape) != tuple(self.shape):
207+
out.resize_(self.shape)
208+
if out.dtype != dtype:
209+
raise RuntimeError(f"_softmax.out: expected out dtype {dtype}, got {out.dtype}")
210+
211+
# One allocation for both partials; the two rows stay contiguous.
212+
partials = torch.empty((2, M * plan), dtype=torch.float32, device=self.device)
213+
grid = (plan, M, 1)
214+
215+
with torch_device_fn.device(self.device):
216+
softmax_split_reduce_kernel[grid](
217+
self,
218+
partials[0],
219+
partials[1],
220+
N,
221+
plan,
222+
TILE_N=_TILE_N,
223+
num_warps=_NUM_WARPS,
224+
)
225+
softmax_split_normalize_kernel[grid](
226+
out,
227+
self,
228+
partials[0],
229+
partials[1],
230+
N,
231+
plan,
232+
TILE_N=_TILE_N,
233+
TILE_B=triton.next_power_of_2(plan),
234+
num_warps=_NUM_WARPS,
235+
)
236+
return out
237+
238+
239+
def softmax(self, dim, half_to_float=False):
240+
assert dim >= -self.ndim and dim < self.ndim, "Invalid dim"
241+
242+
dtype = torch.float32 if half_to_float else self.dtype
243+
out = torch.empty_like(self, dtype=dtype)
244+
return softmax_out(self, dim, half_to_float, out=out)

0 commit comments

Comments
 (0)