Softmax: Math, Implementation, and GPU Optimization

Background

The softmax function is a fundamental operation in deep learning that converts vectors of real numbers into probability distributions. This blog post explores the softmax functionโ€™s implementation and optimization using Triton, a programming framework for efficient GPU computations.

The softmax function transforms an input vector into a probability distribution where all elements sum to 1.

softmax - vector form

๐’๐‘–=softmax(๐’™๐‘–)=๐‘’๐’™๐‘–โˆ‘๐‘—=1๐‘‘๐‘’๐’™๐‘—

where:

  • ๐’™โˆˆโ„๐‘‘: input vector.
  • ๐’โˆˆโ„๐‘‘: output vector, probability distribution.

Gradient of softmax (vector form)

We will compute gradients ๐œ•๐ฟ๐œ•๐’™ given ๐œ•๐ฟ๐œ•๐’, where ๐ฟ is loss function, ๐’ is softmax output.

Jacobian matrix

softmax is a vector function, the Jacobian matrix is the matrix of all partial derivatives:

๐œ•๐’๐œ•๐’™=๐‘ฑ=(๐œ•๐’1๐œ•๐’™1๐œ•๐’1๐œ•๐’™2โ€ฆ๐œ•๐’1๐œ•๐’™๐‘‘๐œ•๐’2๐œ•๐’™1๐œ•๐’2๐œ•๐’™2โ€ฆ๐œ•๐’2๐œ•๐’™๐‘‘โ‹ฎโ‹ฎโ‹ฑโ‹ฎ๐œ•๐’๐‘‘๐œ•๐’™1๐œ•๐’๐‘‘๐œ•๐’™2โ€ฆ๐œ•๐’๐‘‘๐œ•๐’™๐‘‘)

For softmax, the derivative has two cases:

  1. when ๐‘–=๐‘—, consider ๐’๐‘–=๐‘’๐’™๐‘–โˆ‘๐‘—=1๐‘‘๐‘’๐’™๐‘—, the derivative is:

    ๐œ•๐’๐‘–๐œ•๐’™๐‘–=๐’๐‘–(1โˆ’๐’๐‘–)
  2. similarly, when ๐‘–!=๐‘—:

    ๐œ•๐’๐‘–๐œ•๐’™๐‘—=โˆ’๐’๐‘–๐’๐‘—

Thus, (๐‘–,๐‘—)-th element in Jacobian matrix will be:

๐‘ฑ๐‘–๐‘—=๐’๐‘–(๐›ฟ๐‘–๐‘—โˆ’๐’๐‘—)

where ๐‘ฑ has shape [๐‘‘ร—๐‘‘] and ๐›ฟ๐‘–๐‘— is the Kronecker delta, which is 1 if ๐‘–=๐‘— and 0 otherwise.

In matrix form, the Jacobian of the softmax is:

๐‘ฑ=diag(๐’)โˆ’๐’๐’๐‘‡

where:

  • ๐’ is the output of softmax, the shape is [๐‘‘].
  • diag(๐’) is a diagonal matrix of ๐’, the shape is [๐‘‘ร—๐‘‘].
  • ๐’๐’๐‘‡ is the outer product of ๐’ with itself, the shape is [๐‘‘ร—๐‘‘].

gradient of ๐œ•๐ฟ๐œ•๐’™

Given ๐œ•๐ฟ๐œ•๐’, we can compute ๐œ•๐ฟ๐œ•๐’™ using the Jacobian matrix:

๐œ•๐ฟ๐œ•๐’™=๐œ•๐’๐œ•๐’™โ‹…๐œ•๐ฟ๐œ•๐’=๐‘ฑ๐‘‡โ‹…๐œ•๐ฟ๐œ•๐’

where ๐œ•๐ฟ๐œ•๐’ has shape [๐‘‘], ๐‘ฑ๐‘‡ has shape [๐‘‘ร—๐‘‘], and ๐œ•๐ฟ๐œ•๐’™ has shape [๐‘‘].

avoid explicit Jacobian

For the ๐‘–-th element of ๐œ•๐ฟ๐œ•๐’™, we can decompose the computation to:

๐œ•๐ฟ๐œ•๐’™๐‘–=๐’๐‘–(๐œ•๐ฟ๐œ•๐’๐‘–โˆ’โˆ‘๐‘—=1๐‘‘๐’๐‘—๐œ•๐ฟ๐œ•๐’๐‘—)

This leads to an efficient vector form:

๐‘ grad=(๐’โˆ—๐œ•๐ฟ๐œ•๐’)sum๐œ•๐ฟ๐œ•๐’™=๐’โˆ—(๐œ•๐ฟ๐œ•๐’โˆ’๐‘ grad)

softmax - batch form

๐‘ฟ: A batch of input vectors.

๐‘ฟโˆˆโ„๐‘ร—๐‘‘

where:

  • ๐‘ is batch size.
  • ๐‘‘ is vector dimension.

forward pass

๐‘ฌ=๐‘’๐‘ฟ๐’”=โˆ‘๐‘—=1๐‘‘๐‘’๐‘ฟ๐‘–๐‘—๐‘ถ=๐‘ฌ๐’”

where ๐‘ฌโˆˆโ„๐‘ร—๐‘‘, ๐’”โˆˆโ„๐‘ร—1, ๐‘ถโˆˆโ„๐‘ร—๐‘‘.

backward pass

We have gradient with respect to softmax output:

๐œ•๐ฟ๐œ•๐‘ถโˆˆโ„๐‘ร—๐‘‘

we compute the gradient:

๐’”grad=(๐‘ถโˆ—๐œ•๐ฟ๐œ•๐‘ถ)row_sumโˆˆโ„๐‘ร—1

where ๐‘ถ has size [๐‘ร—๐‘‘], and ๐œ•๐ฟ๐œ•๐‘ถ has size [๐‘ร—๐‘‘].

๐œ•๐ฟ๐œ•๐‘ฟ=๐‘ถโˆ—(๐œ•๐ฟ๐œ•๐‘ถโˆ’๐’”grad)

where ๐œ•๐ฟ๐œ•๐‘ฟโˆˆโ„๐‘ร—๐‘‘ and ๐‘ถโˆˆโ„๐‘ร—๐‘‘ and ๐’”gradโˆˆโ„๐‘ร—1 will be broadcasted to โ„๐‘ร—๐‘‘.

Implementation

In practice, we subtract the maximum value from each row before applying exp() to prevent numerical overflow:

real forward pass

For input ๐‘ฟโˆˆโ„๐‘ร—๐‘‘:

๐‘ฟmax=max(๐‘ฟ)โˆˆโ„๐‘ร—1๐‘ฌ=๐‘’๐‘ฟโˆ’๐‘ฟmax๐’”=โˆ‘๐‘—=1๐‘‘๐‘’๐‘ฟ๐‘–๐‘—โˆ’๐‘ฟmax๐‘ถ=๐‘ฌ๐’”

real backward pass

we have ๐œ•๐ฟ๐œ•๐‘ถโˆˆโ„๐‘ร—๐‘‘ and cached ๐‘ถโˆˆโ„๐‘ร—๐‘‘

๐’”grad=(๐‘ถโˆ—๐œ•๐ฟ๐œ•๐‘ถ)row_sum๐œ•๐ฟ๐œ•๐‘ฟ=๐‘ถโˆ—(๐œ•๐ฟ๐œ•๐‘ถโˆ’๐’”grad)

a real example

give a real example to show how to implement softmax and its backward pass in pytorch and triton.

forwards pass is as follows:

๐‘‹=(1.02.03.01.03.05.0)๐‘‹max=(3.05.0)๐‘‹โˆ’๐‘‹max=(โˆ’2.0โˆ’1.00.0โˆ’4.0โˆ’2.00.0)๐ธ=๐‘’๐‘‹โˆ’๐‘‹max=(๐‘’โˆ’2.0๐‘’โˆ’1.0๐‘’0.0๐‘’โˆ’4.0๐‘’โˆ’2.0๐‘’0.0)๐ธ=(0.13530.36791.00000.01830.13531.0000)๐‘†=(1.50321.1536)๐‘‚=๐ธ๐‘†=(0.09000.24470.66520.01590.11730.8668)

backward pass is as follows:

๐‘‘๐‘‚=(0.10.20.70.20.30.5)๐‘ grad=(0.20360.2597)๐‘‘๐‘‹=๐‘‚โˆ—(๐‘‘๐‘‚โˆ’๐‘ grad)๐‘‘๐‘‹=(โˆ’0.0381โˆ’0.07920.1173โˆ’0.0043โˆ’0.02020.0245)

native pytorch implementation

import torch
import torch.nn.functional as F

# Custom Forward Pass (Numerically Stable Softmax)
def softmax_forward(X):
    X_max = torch.max(X, dim=1, keepdim=True)[0]  # Shape: (N, 1)
    E = torch.exp(X - X_max)                     # Shape: (N, d)
    S = torch.sum(E, dim=1, keepdim=True)        # Shape: (N, 1)
    O = E / S                                    # Shape: (N, d)
    return O

# Custom Backward Pass (Gradient Calculation)
def softmax_backward(dL_dO, O):
    s_grad = torch.sum(O * dL_dO, dim=1, keepdim=True)  # Shape: (N, 1)
    dL_dX = O * (dL_dO - s_grad)                        # Shape: (N, d)
    return dL_dX

# Example Inputs
X = torch.tensor([[1.0, 2.0, 3.0], [1.0, 3.0, 5.0]], requires_grad=True)
dL_dO = torch.tensor([[0.1, 0.2, 0.7], [0.2, 0.3, 0.5]])

# Custom Implementation - Forward
O_custom = softmax_forward(X)

# PyTorch Implementation - Forward
O_pytorch = F.softmax(X, dim=1)

# Verify Forward Output
print("Custom Softmax Output:\n", O_custom)
print("PyTorch Softmax Output:\n", O_pytorch)
print("Forward Pass Match:", torch.allclose(O_custom, O_pytorch))

# Custom Implementation - Backward
dL_dX_custom = softmax_backward(dL_dO, O_custom)

# PyTorch Automatic Gradient Calculation
O_pytorch.backward(dL_dO)  # Computes gradient using PyTorch autograd
dL_dX_pytorch = X.grad

# Verify Backward Output
print("\nCustom Gradient w.r.t Input:\n", dL_dX_custom)
print("PyTorch Gradient w.r.t Input:\n", dL_dX_pytorch)
print("Backward Pass Match:", torch.allclose(dL_dX_custom, dL_dX_pytorch))

output:

Custom Softmax Output:
 tensor([[0.0900, 0.2447, 0.6652],
        [0.0159, 0.1173, 0.8668]], grad_fn=<DivBackward0>)
PyTorch Softmax Output:
 tensor([[0.0900, 0.2447, 0.6652],
        [0.0159, 0.1173, 0.8668]], grad_fn=<SoftmaxBackward0>)
Forward Pass Match: True

Custom Gradient w.r.t Input:
 tensor([[-0.0381, -0.0792,  0.1173],
        [-0.0043, -0.0202,  0.0245]], grad_fn=<MulBackward0>)
PyTorch Gradient w.r.t Input:
 tensor([[-0.0381, -0.0792,  0.1173],
        [-0.0043, -0.0202,  0.0245]])
Backward Pass Match: True

triton implementation

from typing import Optional

import torch
import triton
import triton.language as tl


@triton.jit
def softmax_fwd_kernel(
    X,
    O,
    D: tl.constexpr,
    B: tl.constexpr
):
    i_n = tl.program_id(0)
    o_d = tl.arange(0, B)
    m_d = o_d < D

    X_max = tl.max(tl.load(X + i_n * D + o_d, mask=m_d, other=-float('inf')), 0)
    E = tl.exp(tl.load(X + i_n * D + o_d, mask=m_d, other=-float('inf')) - X_max)
    S = tl.sum(E, 0)
    P = E / S

    tl.store(O + i_n * D + o_d, P.to(O.dtype.element_ty), mask=m_d)


@triton.jit
def softmax_bwd_kernel(
    O,
    dO,
    dX,
    D: tl.constexpr,
    B: tl.constexpr
):
    i_n = tl.program_id(0)
    o_d = tl.arange(0, B)
    m_d = o_d < D

    P = tl.load(O + i_n * D + o_d, mask=m_d, other=0.)
    dP = tl.load(dO + i_n * D + o_d, mask=m_d, other=0.)
    s_grad = tl.sum(P * dP, 0)
    dX_row = P * (dP - s_grad)

    tl.store(dX + i_n * D + o_d, dX_row.to(dX.dtype.element_ty), mask=m_d)


def softmax_fwd(
    X: torch.Tensor,
    dtype: Optional[torch.dtype] = torch.float
) -> torch.Tensor:
    shape = X.shape
    X = X.view(-1, X.shape[-1])

    N, D = X.shape
    B = triton.next_power_of_2(D)

    O = torch.empty_like(X, dtype=dtype)
    softmax_fwd_kernel[(N,)](
        X=X,
        O=O,
        D=D,
        B=B
    )
    return O.view(*shape)


def softmax_bwd(
    O: torch.Tensor,
    dO: torch.Tensor,
    dtype: Optional[torch.dtype] = torch.float
) -> torch.Tensor:
    shape = O.shape
    O = O.view(-1, O.shape[-1])
    dX = torch.empty_like(O, dtype=dtype)

    N, D = O.shape
    B = triton.next_power_of_2(D)
    softmax_bwd_kernel[(N,)](
        O=O,
        dO=dO,
        dX=dX,
        D=D,
        B=B
    )
    return dX.view(*shape)

# Test code to verify correctness
import torch.nn.functional as F

# Example inputs
X = torch.tensor([[1.0, 2.0, 3.0], [1.0, 3.0, 5.0]], requires_grad=True, device='cuda')
dP = torch.tensor([[0.1, 0.2, 0.7], [0.2, 0.3, 0.5]], device='cuda')

# Forward pass
P_triton = softmax_fwd(X)
P_torch = F.softmax(X, dim=1)

# Verify forward pass
print( "P_triton:\n", P_triton)
print( "P_torch:\n", P_torch)
print("Forward Pass Match:", torch.allclose(P_triton, P_torch))

# Backward pass

dX_triton = softmax_bwd(P_triton, dP)
P_torch.backward(dP)
dX_torch = X.grad

# Verify backward pass
print( "dX_triton:\n", dX_triton)
print( "dX_torch:\n", dX_torch)
print("Backward Pass Match:", torch.allclose(dX_triton, dX_torch))

output:

P_triton:
 tensor([[0.0900, 0.2447, 0.6652],
        [0.0159, 0.1173, 0.8668]], device='cuda:0')
P_torch:
 tensor([[0.0900, 0.2447, 0.6652],
        [0.0159, 0.1173, 0.8668]], device='cuda:0', grad_fn=<SoftmaxBackward0>)
Forward Pass Match: True
dX_triton:
 tensor([[-0.0381, -0.0792,  0.1173],
        [-0.0043, -0.0202,  0.0245]], device='cuda:0')
dX_torch:
 tensor([[-0.0381, -0.0792,  0.1173],
        [-0.0043, -0.0202,  0.0245]], device='cuda:0')
Backward Pass Match: True

Results: speed comparison

The performance comparison between PyTorch and Triton implementations reveals:

forward pass
Figure 1. forward pass
backward pass
Figure 2. backward pass

Results show

  • forward pass: triton implementation is stable, while the PyTorch implementation is faster for most batch sizes but shows fluctuations for a few.
  • backward pass: triton implementation outperforms the pytorch implementation across most batch sizes. (the comparison may not be entirely fair, as triton caches the output ๐‘‚, whereas pytorchโ€™s handling intermediate values is unclear.)

Notations

symbolshapedefinition
๐’™๐‘‘Input vector
๐’๐‘‘Output vector (probability distribution)
๐ฟScalarLoss function
๐‘ฑ๐‘‘ร—๐‘‘Jacobian matrix
๐‘ฟ๐‘ร—๐‘‘Batch of input vectors (matrix)
๐‘ถ๐‘ร—๐‘‘Batch output probabilities
๐œ•๐ฟ๐œ•๐‘ถ๐‘ร—๐‘‘Gradient w.r.t. output probabilities
๐œ•๐ฟ๐œ•๐‘ฟ๐‘ร—๐‘‘Gradient w.r.t. input vectors
๐‘ grad๐‘ร—1Summation of gradients, ๐‘ grad=(๐‘ถโˆ—๐œ•๐ฟ๐œ•๐‘ถ)sum

Note:

  • Symbols like ๐‘ฅ, ๐’™, ๐‘ฟ represent scalars, vectors, or matrices, where uppercase denotes batch forms.
  • ๐‘ฟ:,๐‘– denotes a column vector, ๐‘ฟ๐‘–,: denotes a row vector, ๐‘ฟ๐‘–,๐‘— and denote the (๐‘–,๐‘—)-th element
  • ๐’™๐‘– denote the ๐‘–-th element.