|
| 1 | +--- |
| 2 | +title: Triton Fused Softmax Kernel |
| 3 | +subtitle: '学习与思考' |
| 4 | +layout: post |
| 5 | +author: peter_lau |
| 6 | +published: true |
| 7 | +categories: |
| 8 | +- AI |
| 9 | +tags: |
| 10 | +- AI |
| 11 | +- Engineering |
| 12 | +toc: true |
| 13 | +mermaid: true |
| 14 | +date: 2026-04-28 00:23:14 +0800 |
| 15 | +--- |
| 16 | + |
| 17 | +本文代码来自[triton-02-fused-softmax](https://triton-lang.org/main/getting-started/tutorials/02-fused-softmax.html)。 |
| 18 | + |
| 19 | +<mark style="background-color: yellow;">Triton的样本代码实现基于假设:**每一行数据可以完整的放入GPU的shared_memory中**</mark> |
| 20 | + |
| 21 | +## kernel外围驱动代码 |
| 22 | + |
| 23 | +### 样本代码 |
| 24 | + |
| 25 | +```python |
| 26 | +properties = driver.active.utils.get_device_properties(DEVICE.index) |
| 27 | +NUM_SM = properties["multiprocessor_count"] |
| 28 | +NUM_REGS = properties["max_num_regs"] |
| 29 | +SIZE_SMEM = properties["max_shared_mem"] |
| 30 | +WARP_SIZE = properties["warpSize"] |
| 31 | +target = triton.runtime.driver.active.get_current_target() |
| 32 | +kernels = {} |
| 33 | + |
| 34 | +def softmax(x): |
| 35 | + n_rows, n_cols = x.shape |
| 36 | + |
| 37 | + # The block size of each loop iteration is the smallest power of two greater than the number of columns in `x` |
| 38 | + BLOCK_SIZE = triton.next_power_of_2(n_cols) |
| 39 | + |
| 40 | + # Another trick we can use is to ask the compiler to use more threads per row by |
| 41 | + # increasing the number of warps (`num_warps`) over which each row is distributed. |
| 42 | + # You will see in the next tutorial how to auto-tune this value in a more natural |
| 43 | + # way so you don't have to come up with manual heuristics yourself. |
| 44 | + num_warps = 8 |
| 45 | + |
| 46 | + # Number of software pipelining stages. |
| 47 | + num_stages = 4 if SIZE_SMEM > 200000 else 2 |
| 48 | + |
| 49 | + # Allocate output |
| 50 | + y = torch.empty_like(x) |
| 51 | + |
| 52 | + # pre-compile kernel to get register usage and compute thread occupancy. |
| 53 | + kernel = softmax_kernel.warmup(y, x, x.stride(0), y.stride(0), n_rows, n_cols, BLOCK_SIZE=BLOCK_SIZE, |
| 54 | + num_stages=num_stages, num_warps=num_warps, grid=(1, )) |
| 55 | + kernel._init_handles() |
| 56 | + n_regs = kernel.n_regs |
| 57 | + size_smem = kernel.metadata.shared |
| 58 | + if is_hip(): |
| 59 | + # NUM_REGS represents the number of regular purpose registers. On CDNA architectures this is half of all registers available. |
| 60 | + # However, this is not always the case. In most cases all registers can be used as regular purpose registers. |
| 61 | + # ISA SECTION (3.6.4 for CDNA3) |
| 62 | + # VGPRs are allocated out of two pools: regular VGPRs and accumulation VGPRs. Accumulation VGPRs are used |
| 63 | + # with matrix VALU instructions, and can also be loaded directly from memory. A wave may have up to 512 total |
| 64 | + # VGPRs, 256 of each type. When a wave has fewer than 512 total VGPRs, the number of each type is flexible - it is |
| 65 | + # not required to be equal numbers of both types. |
| 66 | + NUM_GPRS = NUM_REGS |
| 67 | + if is_cdna(): |
| 68 | + NUM_GPRS = NUM_REGS * 2 |
| 69 | + |
| 70 | + # MAX_NUM_THREADS represents maximum number of resident threads per multi-processor. |
| 71 | + # When we divide this number with WARP_SIZE we get maximum number of waves that can |
| 72 | + # execute on a CU (multi-processor) in parallel. |
| 73 | + MAX_NUM_THREADS = properties["max_threads_per_sm"] |
| 74 | + max_num_waves = MAX_NUM_THREADS // WARP_SIZE |
| 75 | + occupancy = min(NUM_GPRS // WARP_SIZE // n_regs, max_num_waves) // num_warps |
| 76 | + else: |
| 77 | + occupancy = NUM_REGS // (n_regs * WARP_SIZE * num_warps) |
| 78 | + occupancy = min(occupancy, SIZE_SMEM // size_smem) |
| 79 | + num_programs = NUM_SM * occupancy |
| 80 | + |
| 81 | + num_programs = min(num_programs, n_rows) |
| 82 | + |
| 83 | + # Create a number of persistent programs. |
| 84 | + kernel[(num_programs, 1, 1)](y, x, x.stride(0), y.stride(0), n_rows, n_cols, BLOCK_SIZE, num_stages) |
| 85 | + return y |
| 86 | +``` |
| 87 | + |
| 88 | +### 问题思考 |
| 89 | + |
| 90 | +1. num_stages作用是什么?一般怎么设置? |
| 91 | + |
| 92 | +num_stages代表软件流水线的级数。它越大,流水线越长,意味着计算当前stage时,可以将其之前所有stage的数据与预加载至shared memory中。 |
| 93 | + |
| 94 | +对于Amper/Hopper架构的GPU,shared memory比较大,num_stages可以设置为4;对于更早的GPU,shared memory较小,num_stages需要设置较小。 |
| 95 | + |
| 96 | +2. Block数量计算方式 |
| 97 | + |
| 98 | +```python |
| 99 | +occupancy = NUM_REGS // (n_regs * WARP_SIZE * num_warps) |
| 100 | +``` |
| 101 | + |
| 102 | +这里的n_regs是一个线程占用的寄存器数量。NUM_REGS为SM的寄存器总数,WARP_SIZE*num_warps为一个block所占用的线程数,occupancy即为block数量。 |
| 103 | + |
| 104 | +请注意,block数量不仅受限于寄存器,还受限于shared memory。如果一个block使用的寄存器或者shared memory越大,那么block数量就会越少。 |
| 105 | + |
| 106 | + |
| 107 | +## 计算kernel代码 |
| 108 | + |
| 109 | +### 样例代码 |
| 110 | + |
| 111 | +```python |
| 112 | +@triton.jit |
| 113 | +def softmax_kernel(output_ptr, input_ptr, input_row_stride, output_row_stride, n_rows, n_cols, BLOCK_SIZE: tl.constexpr, |
| 114 | + num_stages: tl.constexpr): |
| 115 | + # starting row of the program |
| 116 | + row_start = tl.program_id(0) |
| 117 | + row_step = tl.num_programs(0) |
| 118 | + for row_idx in tl.range(row_start, n_rows, row_step, num_stages=num_stages): |
| 119 | + # The stride represents how much we need to increase the pointer to advance 1 row |
| 120 | + row_start_ptr = input_ptr + row_idx * input_row_stride |
| 121 | + # The block size is the next power of two greater than n_cols, so we can fit each |
| 122 | + # row in a single block |
| 123 | + col_offsets = tl.arange(0, BLOCK_SIZE) |
| 124 | + input_ptrs = row_start_ptr + col_offsets |
| 125 | + # Load the row into SRAM, using a mask since BLOCK_SIZE may be > than n_cols |
| 126 | + mask = col_offsets < n_cols |
| 127 | + row = tl.load(input_ptrs, mask=mask, other=-float('inf')) |
| 128 | + # Subtract maximum for numerical stability |
| 129 | + row_minus_max = row - tl.max(row, axis=0) |
| 130 | + # Note that exponentiation in Triton is fast but approximate (i.e., think __expf in CUDA) |
| 131 | + numerator = tl.exp(row_minus_max) |
| 132 | + denominator = tl.sum(numerator, axis=0) |
| 133 | + softmax_output = numerator / denominator |
| 134 | + # Write back output to DRAM |
| 135 | + output_row_start_ptr = output_ptr + row_idx * output_row_stride |
| 136 | + output_ptrs = output_row_start_ptr + col_offsets |
| 137 | + tl.store(output_ptrs, softmax_output, mask=mask) |
| 138 | +``` |
| 139 | + |
| 140 | +### 几个问题 |
| 141 | + |
| 142 | +1. block内部的线程是如何分配的?没看到单个线程的索引 |
| 143 | + |
| 144 | +从CUDA的线程视角来看,这个确实不太明确。但是Triton的编程模型是SPMD,即single program multiple data。 |
| 145 | + |
| 146 | +在Triton你不用担心各个线程怎么索引,你只需将block作为一个整体来处理对应的块数据。 |
| 147 | + |
| 148 | +```python |
| 149 | +for row_idx in tl.range(row_start, n_rows, row_step, num_stages=num_stages) |
| 150 | +``` |
| 151 | +上述的row_idx代表当前program(即当前block)需要处理的行索引 |
| 152 | + |
| 153 | +然后对于每一行,以block线程大小(即num_warps*WARP_SIZE)来处理整行数据,如下 |
| 154 | + |
| 155 | +```python |
| 156 | +col_offsets = tl.arange(0, BLOCK_SIZE) |
| 157 | +input_ptrs = row_start_ptr + col_offsets |
| 158 | +# Load the row into SRAM, using a mask since BLOCK_SIZE may be > than n_cols |
| 159 | +mask = col_offsets < n_cols |
| 160 | +row = tl.load(input_ptrs, mask=mask, other=-float('inf')) |
| 161 | +# Subtract maximum for numerical stability |
| 162 | +row_minus_max = row - tl.max(row, axis=0) |
| 163 | +``` |
| 164 | +**tl.load**和**tl.max**等都是将数据按照张量形式来处理,在其内部Triton编译器会将其分配给block内的线程。 |
| 165 | + |
| 166 | + |
0 commit comments