|
| 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