feat(cutile): chunk FLCE with Triton sizing and cuBLAS GEMMs

Reuse Triton default token-chunk geometry through a private helper without adding a public chunk-size parameter. Bound cuTile logits/statistics workspace, use cuBLAS for all GEMMs, retain FP32 dW until upstream scaling, and preserve legacy low-level helpers. Enable SM100 alongside SM90 and cover numerical, memory, gradient-mask, and older-Torch fallback behavior.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Vaibhav Jindal
2026-09-10 19:29:26 +00:00
co-authored by Copilot
parent 95b01e9402
commit ecd0780285
4 changed files with 1478 additions and 203 deletions
+22
View File
@@ -209,6 +209,28 @@ LIGER_KERNEL_IMPL=cutile python your_script.py
`LIGER_KERNEL_IMPL` selects an opt-in implementation registered with Liger (currently `cutile` and `cutedsl`). Selecting one on an unsupported device, or without the required dependencies installed, raises an error.
#### cuTile Fused Linear Cross Entropy
With `LIGER_KERNEL_IMPL=cutile`, `LigerFusedLinearCrossEntropyLoss` routes to a cuTile Fused Linear Cross Entropy kernel for BF16 `input`/`weight` on Hopper (SM90) and Blackwell (SM100). No API change is needed — the default route just works:
```bash
LIGER_KERNEL_IMPL=cutile python your_script.py
```
```python
from liger_kernel.transformers import LigerFusedLinearCrossEntropyLoss
# Same call as the default backend — there is no chunk_size argument.
loss_fn = LigerFusedLinearCrossEntropyLoss(reduction="mean")
loss = loss_fn(lm_head_weight, hidden_states, labels)
```
Implementation notes:
- Token chunking uses the **exact same default geometry as the Triton backend** (`inc = ceil(V / (16·H))`, `chunk = min(N, next_pow2(ceil(N / inc)))`); there is no public `chunk_size` knob.
- All three GEMMs — projection, input-gradient (`dX`), and weight-gradient (`dW`) — run through PyTorch/cuBLAS. `dW` is accumulated across token chunks in FP32 (`mm` then `addmm`) and the combined upstream/mean scale is applied in FP32 before a single BF16 cast, so small gradients are preserved.
- Supports BF16 `input` and `weight` with `mean`/`sum` reductions; FP32 accumulation is the default and only accumulation policy. It is **not** a full feature/precision match for the Triton kernel (e.g. no bias, class weights, z-loss, label smoothing, softcap, or token scaling).
### Enable CuTe DSL Backend
CuTe DSL is the optional, CUDA-only Python DSL shipped with NVIDIA CUTLASS (`import cutlass.cute`), targeting Hopper (SM90) and Blackwell (SM100/SM110). After installing the `cutedsl` extra, enable it explicitly:
@@ -2,94 +2,84 @@
#
# SPDX-License-Identifier: MIT
"""Hopper-optimized cuTile fused linear cross entropy.
"""cuTile fused linear cross entropy for Hopper (SM90) and Blackwell (SM100).
The projection and input-gradient GEMMs use PyTorch's Hopper-tuned BLAS path.
cuTile computes partitioned CE statistics, overwrites logits with dZ, and
computes dW with an FP32-accumulating 128x256x64 MMA schedule.
All three GEMMs -- the projection, the input-gradient (dX), and the
weight-gradient (dW) -- use PyTorch's tuned cuBLAS path. cuTile computes
partitioned CE statistics, overwrites a reusable ``[min(N, C), V]`` logits
buffer with dZ, and applies the final FP32 dW scale-and-cast. dW is accumulated
across token chunks in FP32 by cuBLAS ``mm`` (first chunk) then ``addmm``
(subsequent chunks), so the transient logits workspace is
``O(min(N, C) * V)`` instead of the full ``O(N * V)`` materialization; the
retained gradients (raw BF16 dX and the FP32 dW accumulator) add ``O(N*H + V*H)``.
The upstream/mean scale is applied out-of-place in backward: the FP32 dW
accumulator is retained until backward and cast to the weight dtype exactly once,
after multiplying by the combined ``upstream * mean_normalizer`` factor. Casting
after (not before) that multiply preserves the numeric range of small gradients
that would otherwise flush to zero in BF16.
Two low-level interfaces are exported. ``fused_linear_cross_entropy_forward`` /
``fused_linear_cross_entropy_backward`` retain the original unchunked contract
(the forward returns the full ``[N, V]`` dZ and the 7-argument backward runs the
dW matmul). The token-chunked path used by the autograd Function lives in
``chunked_fused_linear_cross_entropy_forward`` /
``chunked_fused_linear_cross_entropy_backward``.
"""
import operator
from typing import Optional
import cuda.tile as ct
import torch
from liger_kernel.ops.cutile.ops.utils import _next_power_of_2
from liger_kernel.ops.fused_linear_cross_entropy import _get_chunk_size
from liger_kernel.ops.utils import amp_custom_bwd
from liger_kernel.ops.utils import amp_custom_fwd
from liger_kernel.ops.utils import compare_version
ConstBool = ct.Constant[bool]
ConstInt = ct.Constant[int]
LOG2E = 1.4426950408889634
DW_TILE_M = 128
DW_TILE_N = 128
DW_TILE_K = 64
SCALE_CAST_TILE_M = 128
SCALE_CAST_TILE_N = 128
CE_BLOCK_SIZE = 2048
LOGITS_STATS_BLOCK_SIZE = 4096
MAX_STATS_BLOCK_SIZE = 1024
# The FP32 dW accumulation uses the ``torch.mm`` / ``torch.addmm``
# ``out_dtype=torch.float32`` overload (BF16 operands -> FP32 accumulator) added
# in torch 2.8. On older torch the operands are upcast to FP32 first; see
# ``_accumulate_dw_fp32``. This is a runtime capability check only -- it does not
# raise the package floor.
_ADDMM_SUPPORTS_OUT_DTYPE = compare_version("torch", operator.ge, "2.8.0")
@ct.kernel(num_worker_warps=ct.ByTarget(sm_90=8), opt_level=3)
def _matmul_tn_kernel(
a,
b,
output,
grad_output,
@ct.kernel(num_worker_warps=ct.ByTarget(sm_90=8, default=8), opt_level=3)
def _scale_cast_kernel(
src,
dst,
scale,
TILE_M: ConstInt,
TILE_N: ConstInt,
TILE_K: ConstInt,
):
"""Compute ``output = (a.T @ b) * grad_output``."""
"""Scale an FP32 ``src`` tile by a device scalar and cast into ``dst.dtype``.
Used to turn the retained FP32 dW accumulator into the final weight-dtype
gradient without allocating an O(V*H) FP32 temporary: the multiply by the
combined ``upstream * mean_normalizer`` scalar happens in FP32 and the BF16
cast is applied exactly once, per element, on store.
"""
pid_m = ct.bid(0)
pid_n = ct.bid(1)
a_view = a.tiled_view((TILE_K, TILE_M), padding_mode=ct.PaddingMode.ZERO)
b_view = b.tiled_view((TILE_K, TILE_N), padding_mode=ct.PaddingMode.ZERO)
acc = ct.zeros((TILE_M, TILE_N), dtype=ct.float32)
for pid_k in range(a_view.num_tiles(0)):
a_tile = a_view.load((pid_k, pid_m), latency=10)
b_tile = b_view.load((pid_k, pid_n), latency=10)
acc = ct.mma(ct.transpose(a_tile), b_tile, acc)
scale = ct.astype(ct.gather(grad_output, (), check_bounds=False), ct.float32)
ct.store(output, (pid_m, pid_n), ct.astype(acc * scale, output.dtype), latency=1)
@ct.kernel(num_worker_warps=ct.ByTarget(sm_90=8), opt_level=3)
def _matmul_tn_n4_kernel(
a,
b,
output,
grad_output,
TILE_M: ConstInt,
TILE_N: ConstInt,
TILE_K: ConstInt,
LOAD_LATENCY: ConstInt,
):
"""Compute four adjacent dW column tiles while sharing each dZ load."""
pid_n = ct.bid(0) * 4
pid_m = ct.bid(1)
a_view = a.tiled_view((TILE_K, TILE_M), padding_mode=ct.PaddingMode.ZERO)
b_view = b.tiled_view((TILE_K, TILE_N), padding_mode=ct.PaddingMode.ZERO)
output_view = output.tiled_view((TILE_M, TILE_N), padding_mode=ct.PaddingMode.ZERO)
acc0 = ct.zeros((TILE_M, TILE_N), dtype=ct.float32)
acc1 = ct.zeros((TILE_M, TILE_N), dtype=ct.float32)
acc2 = ct.zeros((TILE_M, TILE_N), dtype=ct.float32)
acc3 = ct.zeros((TILE_M, TILE_N), dtype=ct.float32)
for pid_k in range(a_view.num_tiles(0)):
a_tile = ct.transpose(a_view.load((pid_k, pid_m), latency=LOAD_LATENCY))
acc0 = ct.mma(a_tile, b_view.load((pid_k, pid_n), latency=LOAD_LATENCY), acc0)
acc1 = ct.mma(a_tile, b_view.load((pid_k, pid_n + 1), latency=LOAD_LATENCY), acc1)
acc2 = ct.mma(a_tile, b_view.load((pid_k, pid_n + 2), latency=LOAD_LATENCY), acc2)
acc3 = ct.mma(a_tile, b_view.load((pid_k, pid_n + 3), latency=LOAD_LATENCY), acc3)
scale = ct.astype(ct.gather(grad_output, (), check_bounds=False), ct.float32)
output_view.store((pid_m, pid_n), ct.astype(acc0 * scale, output.dtype), latency=1)
output_view.store((pid_m, pid_n + 1), ct.astype(acc1 * scale, output.dtype), latency=1)
output_view.store((pid_m, pid_n + 2), ct.astype(acc2 * scale, output.dtype), latency=1)
output_view.store((pid_m, pid_n + 3), ct.astype(acc3 * scale, output.dtype), latency=1)
src_view = src.tiled_view((TILE_M, TILE_N), padding_mode=ct.PaddingMode.ZERO)
dst_view = dst.tiled_view((TILE_M, TILE_N), padding_mode=ct.PaddingMode.ZERO)
tile = ct.astype(src_view.load((pid_m, pid_n), latency=2), ct.float32)
tile_scale = ct.astype(ct.gather(scale, (), check_bounds=False), ct.float32)
dst_view.store((pid_m, pid_n), ct.astype(tile * tile_scale, dst.dtype), latency=1)
@ct.kernel(occupancy=8)
@@ -177,36 +167,36 @@ def _fused_cross_entropy_dz_kernel(
ct.scatter(logits, (row, cols), ct.astype(gradient, logits.dtype), check_bounds=True)
def _launch_matmul_tn(
a: torch.Tensor,
b: torch.Tensor,
output: torch.Tensor,
grad_output: torch.Tensor,
def _accumulate_dw_fp32(
dweight_accum: torch.Tensor,
dz: torch.Tensor,
x: torch.Tensor,
accumulate: bool,
) -> None:
if output.shape[1] % 256 == 0:
_launch_cutile(
output.device,
(
output.shape[1] // 256,
(output.shape[0] + DW_TILE_M - 1) // DW_TILE_M,
1,
),
_matmul_tn_n4_kernel,
(a, b, output, grad_output, DW_TILE_M, 64, DW_TILE_K, 5),
)
return
"""Accumulate ``dZ.T @ X`` into the FP32 ``dweight_accum`` buffer via cuBLAS.
tile_n = min(256, _next_power_of_2(output.shape[1]))
_launch_cutile(
output.device,
(
(output.shape[0] + DW_TILE_M - 1) // DW_TILE_M,
(output.shape[1] + tile_n - 1) // tile_n,
1,
),
_matmul_tn_kernel,
(a, b, output, grad_output, DW_TILE_M, tile_n, DW_TILE_K),
)
``dz`` is ``[rows, V]`` and ``x`` is ``[rows, H]``; the contribution
``dZ.T @ X`` is ``[V, H]``. The first token chunk (``accumulate`` is
``False``) overwrites the buffer with ``torch.mm``; subsequent chunks add
with ``torch.addmm``. Accumulation is always FP32: on torch>=2.8 the BF16
operands drive the ``out_dtype=torch.float32`` overload directly; on older
torch (no overload) only the bounded BF16 operands are upcast to FP32 before
the same mm/addmm into the FP32 buffer. This single helper is shared by the
chunked and legacy paths so their dW math is identical.
"""
dz_t = dz.t() # [V, rows] view
if _ADDMM_SUPPORTS_OUT_DTYPE:
if accumulate:
torch.addmm(dweight_accum, dz_t, x, out_dtype=torch.float32, out=dweight_accum)
else:
torch.mm(dz_t, x, out_dtype=torch.float32, out=dweight_accum)
else:
dz_t_fp32 = dz_t.to(torch.float32)
x_fp32 = x.to(torch.float32)
if accumulate:
torch.addmm(dweight_accum, dz_t_fp32, x_fp32, out=dweight_accum)
else:
torch.mm(dz_t_fp32, x_fp32, out=dweight_accum)
def _launch_cutile(device: torch.device, grid, kernel, args) -> None:
@@ -214,6 +204,20 @@ def _launch_cutile(device: torch.device, grid, kernel, args) -> None:
ct.launch(torch.cuda.current_stream(device), grid, kernel, args)
def _launch_scale_cast(src: torch.Tensor, dst: torch.Tensor, scale: torch.Tensor) -> None:
"""Compute ``dst = (src * scale).astype(dst.dtype)`` without an FP32 temporary."""
_launch_cutile(
dst.device,
(
(src.shape[0] + SCALE_CAST_TILE_M - 1) // SCALE_CAST_TILE_M,
(src.shape[1] + SCALE_CAST_TILE_N - 1) // SCALE_CAST_TILE_N,
1,
),
_scale_cast_kernel,
(src, dst, scale, SCALE_CAST_TILE_M, SCALE_CAST_TILE_N),
)
def _reject_unsupported(
bias,
ce_weight,
@@ -233,14 +237,34 @@ def _reject_unsupported(
"label smoothing": bool(label_smoothing),
"softcap": softcap is not None,
"return_z_loss": return_z_loss,
"accum_dtype": accum_dtype is not None,
"token scaling": use_token_scaling,
"return_token_accuracy": return_token_accuracy,
"return_predicted_tokens": return_predicted_tokens,
}
enabled = [name for name, value in unsupported.items() if value]
if enabled:
raise NotImplementedError(f"cuTile SM90 FLCE does not support: {', '.join(enabled)}")
raise NotImplementedError(f"cuTile FLCE does not support: {', '.join(enabled)}")
# FP32 accumulation is the default and only supported accumulation dtype.
if accum_dtype is not None and accum_dtype != torch.float32:
raise NotImplementedError(
f"cuTile FLCE only supports FP32 accumulation (accum_dtype=torch.float32 or None), got {accum_dtype!r}"
)
# Impl names that identify this cuTile kernel itself; accepted so callers that
# route by name can target it explicitly. These are self-identity only -- this
# Function does not dispatch to alternative CE implementations.
_SELF_CE_IMPLS = (None, "cutile", "nvidia-cutile")
def _validate_ce_dispatch(ce_impl, ce_mode) -> None:
if ce_impl not in _SELF_CE_IMPLS:
raise NotImplementedError(
f"cuTile FLCE does not dispatch to ce_impl={ce_impl!r}; "
f"expected one of {tuple(name for name in _SELF_CE_IMPLS)}"
)
if ce_mode is not None:
raise NotImplementedError(f"cuTile FLCE only supports the default CE mode (ce_mode=None), got {ce_mode!r}")
def _validate_inputs(
@@ -249,14 +273,17 @@ def _validate_inputs(
target: torch.Tensor,
reduction: str,
) -> None:
if not torch.cuda.is_available() or torch.cuda.get_device_capability(_input.device) != (9, 0):
raise RuntimeError("cuTile FLCE requires a Hopper (compute capability 9.0) GPU")
if _input.dtype != torch.bfloat16 or weight.dtype != torch.bfloat16:
raise TypeError("cuTile FLCE supports BF16 input and weight only")
if target.dtype != torch.int64:
raise TypeError("target must be an int64 tensor")
if _input.ndim != 2 or weight.ndim != 2 or target.ndim != 1:
raise ValueError("expected input[M, H], weight[V, H], and target[M]")
if 0 in _input.shape or 0 in weight.shape or target.shape[0] == 0:
raise ValueError(
f"cuTile FLCE does not support empty tensors: input {tuple(_input.shape)}, "
f"weight {tuple(weight.shape)}, target {tuple(target.shape)}"
)
if _input.shape[0] != target.shape[0] or _input.shape[1] != weight.shape[1]:
raise ValueError(
f"incompatible input {tuple(_input.shape)}, weight {tuple(weight.shape)}, and target {tuple(target.shape)}"
@@ -265,8 +292,18 @@ def _validate_inputs(
raise ValueError(f"reduction must be 'mean' or 'sum', got {reduction!r}")
if _input.device != weight.device or _input.device != target.device:
raise ValueError("input, weight, and target must be on the same CUDA device")
# Reject CPU tensors before touching CUDA device-capability queries.
if _input.device.type != "cuda" or weight.device.type != "cuda" or target.device.type != "cuda":
raise RuntimeError("cuTile FLCE requires CUDA (Hopper 9.0 or Blackwell SM100 10.0) tensors")
if not torch.cuda.is_available():
raise RuntimeError("cuTile FLCE requires an available CUDA device")
if not _input.is_contiguous() or not weight.is_contiguous() or not target.is_contiguous():
raise ValueError("cuTile FLCE requires contiguous input, weight, and target tensors")
capability = torch.cuda.get_device_capability(_input.device)
if capability not in ((9, 0), (10, 0)):
raise RuntimeError(
f"cuTile FLCE requires a Hopper (compute capability 9.0) or Blackwell SM100 (10.0) GPU, got {capability}"
)
num_stats_partitions = (weight.shape[0] + LOGITS_STATS_BLOCK_SIZE - 1) // LOGITS_STATS_BLOCK_SIZE
if num_stats_partitions > MAX_STATS_BLOCK_SIZE:
raise NotImplementedError(
@@ -274,6 +311,95 @@ def _validate_inputs(
)
def _ce_setup(_input, weight, target, reduction, ignore_index):
"""Shared prologue: derive shapes, validate targets, and build the scales.
Returns the geometry the CE kernel needs plus ``loss_scale`` (passed to the
kernel for the mean-reduced per-row loss) and ``gradient_scale`` (the global
mean normalizer, ``1`` for ``sum``) applied to the gradients in backward.
"""
tokens, hidden_size = _input.shape
vocab_size = weight.shape[0]
num_vocab_tiles = (vocab_size + LOGITS_STATS_BLOCK_SIZE - 1) // LOGITS_STATS_BLOCK_SIZE
stats_block_size = _next_power_of_2(num_vocab_tiles)
needs_dz = _input.requires_grad or weight.requires_grad
reduction_mean = reduction == "mean"
target_mask = target != ignore_index
valid_target = ~target_mask | ((target >= 0) & (target < vocab_size))
torch._assert_async(valid_target.all(), f"target values must be in [0, {vocab_size}) or equal ignore_index")
# CE normalization uses the GLOBAL count of non-ignored tokens across all chunks.
loss_scale = (
target_mask.sum().clamp_min(1).to(torch.float32).reciprocal()
if reduction_mean
else torch.empty((), dtype=torch.float32, device=_input.device)
)
gradient_scale = loss_scale if reduction_mean else torch.ones((), dtype=torch.float32, device=_input.device)
return (
tokens,
hidden_size,
vocab_size,
num_vocab_tiles,
stats_block_size,
needs_dz,
reduction_mean,
loss_scale,
gradient_scale,
)
def _project_and_dz(
input_chunk,
weight,
logits_chunk,
target_chunk,
loss_chunk,
loss_scale,
partial_max,
partial_sum,
completion_count,
vocab_size,
num_vocab_tiles,
stats_block_size,
ignore_index,
needs_dz,
reduction_mean,
):
"""Project a token chunk to logits and overwrite it in place with dZ.
``torch.mm`` fills ``logits_chunk`` with the BF16 projection; the fused CE
kernel then writes the per-token loss and (when ``needs_dz``) replaces the
logits with the softmax-minus-one-hot gradient dZ. Shared by the legacy
unchunked path (one full-batch call) and the chunked path (per chunk).
"""
rows = logits_chunk.shape[0]
torch.mm(input_chunk, weight.t(), out=logits_chunk)
# Counters must be reset before every partition reduction.
completion_count.zero_()
_launch_cutile(
logits_chunk.device,
(num_vocab_tiles * rows, 1, 1),
_fused_cross_entropy_dz_kernel,
(
logits_chunk,
target_chunk,
loss_chunk,
loss_scale,
partial_max,
partial_sum,
completion_count,
vocab_size,
num_vocab_tiles,
ignore_index,
LOGITS_STATS_BLOCK_SIZE,
CE_BLOCK_SIZE,
stats_block_size,
needs_dz,
reduction_mean,
),
)
def fused_linear_cross_entropy_forward(
_input,
weight,
@@ -291,6 +417,15 @@ def fused_linear_cross_entropy_forward(
return_token_accuracy=False,
return_predicted_tokens=False,
):
"""Legacy unchunked forward.
Materializes the full ``[N, V]`` logits buffer, overwrites it with dZ, and
returns that dZ as the third element (``grad_logits``). The paired
:func:`fused_linear_cross_entropy_backward` consumes ``grad_logits`` to run
the dW matmul. This is the original low-level contract, retained for
compatibility; the autograd Function uses the bounded
:func:`chunked_fused_linear_cross_entropy_forward` instead.
"""
_reject_unsupported(
bias,
ce_weight,
@@ -305,47 +440,40 @@ def fused_linear_cross_entropy_forward(
)
_validate_inputs(_input, weight, target, reduction)
tokens, hidden_size = _input.shape
vocab_size = weight.shape[0]
num_vocab_tiles = (vocab_size + LOGITS_STATS_BLOCK_SIZE - 1) // LOGITS_STATS_BLOCK_SIZE
stats_block_size = _next_power_of_2(num_vocab_tiles)
needs_dz = _input.requires_grad or weight.requires_grad
(
tokens,
hidden_size,
vocab_size,
num_vocab_tiles,
stats_block_size,
needs_dz,
reduction_mean,
loss_scale,
gradient_scale,
) = _ce_setup(_input, weight, target, reduction, ignore_index)
logits = torch.empty((tokens, vocab_size), dtype=torch.bfloat16, device=_input.device)
loss_1d = torch.empty(tokens, dtype=torch.float32, device=_input.device)
partial_max = torch.empty((tokens, num_vocab_tiles), dtype=torch.float32, device=_input.device)
partial_sum = torch.empty_like(partial_max)
completion_count = torch.zeros(tokens, dtype=torch.int32, device=_input.device)
target_mask = target != ignore_index
valid_target = ~target_mask | ((target >= 0) & (target < vocab_size))
torch._assert_async(valid_target.all(), f"target values must be in [0, {vocab_size}) or equal ignore_index")
loss_scale = (
target_mask.sum().clamp_min(1).to(torch.float32).reciprocal()
if reduction == "mean"
else torch.empty((), dtype=torch.float32, device=_input.device)
)
torch.mm(_input, weight.t(), out=logits)
_launch_cutile(
logits.device,
(num_vocab_tiles * tokens, 1, 1),
_fused_cross_entropy_dz_kernel,
(
logits,
target,
loss_1d,
loss_scale,
partial_max,
partial_sum,
completion_count,
vocab_size,
num_vocab_tiles,
ignore_index,
LOGITS_STATS_BLOCK_SIZE,
CE_BLOCK_SIZE,
stats_block_size,
needs_dz,
reduction == "mean",
),
_project_and_dz(
_input,
weight,
logits,
target,
loss_1d,
loss_scale,
partial_max,
partial_sum,
completion_count,
vocab_size,
num_vocab_tiles,
stats_block_size,
ignore_index,
needs_dz,
reduction_mean,
)
grad_input = None
@@ -354,7 +482,8 @@ def fused_linear_cross_entropy_forward(
torch.mm(logits, weight, out=grad_input)
loss = loss_1d.sum()
gradient_scale = loss_scale if reduction == "mean" else torch.ones((), dtype=torch.float32, device=_input.device)
# Third element is the full [N, V] dZ (legacy contract): the paired backward
# runs the dW matmul from it. Scaling is deferred to backward.
return loss, grad_input, logits if weight.requires_grad else None, gradient_scale
@@ -367,19 +496,191 @@ def fused_linear_cross_entropy_backward(
weight_shape: torch.Size,
weight_dtype: torch.dtype,
):
"""Legacy 7-argument backward paired with the unchunked forward.
Applies ``upstream * gradient_scale`` to dX. dW is computed in FP32 by cuBLAS
(``dZ.T @ X`` accumulated into an FP32 buffer), then the combined
``upstream * gradient_scale`` factor is applied in FP32 and the result cast to
``weight_dtype`` exactly once by the fused scale-and-cast kernel. Casting
after (not before) the upstream multiply preserves small gradients that would
flush to zero if rounded to BF16 first. All operations are out-of-place, so
the saved tensors are not mutated across repeated backward calls.
"""
total_scale = grad_output * gradient_scale
grad_input_out = None
if grad_input is not None:
grad_input = grad_input * total_scale
grad_input_out = grad_input * total_scale
grad_weight = None
if grad_logits is not None:
dweight_accum = torch.empty(weight_shape, dtype=torch.float32, device=grad_logits.device)
_accumulate_dw_fp32(dweight_accum, grad_logits, _input, accumulate=False)
combined_scale = total_scale.to(torch.float32)
grad_weight = torch.empty(weight_shape, dtype=weight_dtype, device=grad_logits.device)
_launch_matmul_tn(grad_logits, _input, grad_weight, total_scale)
_launch_scale_cast(dweight_accum, grad_weight, combined_scale)
return grad_input, grad_weight
return grad_input_out, grad_weight
def chunked_fused_linear_cross_entropy_forward(
_input,
weight,
target,
ce_weight=None,
bias=None,
ignore_index=-100,
lse_square_scale=0.0,
label_smoothing=0.0,
reduction="mean",
softcap=None,
return_z_loss=False,
accum_dtype=None,
use_token_scaling=False,
return_token_accuracy=False,
return_predicted_tokens=False,
):
"""Token-chunked forward used by the autograd Function.
Processes tokens in row chunks so the transient logits/dZ workspace is
``O(min(N, C) * V)``, where the token-chunk size ``C`` comes from the shared
:func:`liger_kernel.ops.fused_linear_cross_entropy._get_chunk_size` helper --
the EXACT same default geometry as the Triton FLCE backend, with no public
``chunk_size`` knob. dX is written as the raw BF16 projection and dW is
accumulated in an FP32 buffer; NEITHER is scaled here. The returned third
element is the raw FP32 dW accumulator (``O(V*H)``), which
:func:`chunked_fused_linear_cross_entropy_backward` scales and casts to the
weight dtype exactly once -- deferring the BF16 cast preserves small
gradients that would flush to zero if cast per-chunk in the forward.
"""
_reject_unsupported(
bias,
ce_weight,
lse_square_scale,
label_smoothing,
softcap,
return_z_loss,
accum_dtype,
use_token_scaling,
return_token_accuracy,
return_predicted_tokens,
)
_validate_inputs(_input, weight, target, reduction)
(
tokens,
hidden_size,
vocab_size,
num_vocab_tiles,
stats_block_size,
needs_dz,
reduction_mean,
loss_scale,
gradient_scale,
) = _ce_setup(_input, weight, target, reduction, ignore_index)
chunk = _get_chunk_size(tokens, hidden_size, vocab_size)
buffer_rows = min(tokens, chunk)
# Reusable per-chunk logits/dZ buffer of shape [min(N, C), V] and the
# partitioned CE statistics/counter scratch of shape [min(N, C), num_partitions].
logits = torch.empty((buffer_rows, vocab_size), dtype=torch.bfloat16, device=_input.device)
loss_1d = torch.empty(tokens, dtype=torch.float32, device=_input.device)
partial_max = torch.empty((buffer_rows, num_vocab_tiles), dtype=torch.float32, device=_input.device)
partial_sum = torch.empty_like(partial_max)
completion_count = torch.zeros(buffer_rows, dtype=torch.int32, device=_input.device)
grad_input = None
if _input.requires_grad:
grad_input = torch.empty((tokens, hidden_size), dtype=_input.dtype, device=_input.device)
# FP32 dW accumulator reused across chunks. It is retained UNSCALED for
# backward; the mean normalizer and upstream scalar are combined and the
# single BF16 cast is applied there, never per chunk.
dweight_accum = None
if weight.requires_grad:
dweight_accum = torch.empty((vocab_size, hidden_size), dtype=torch.float32, device=_input.device)
for chunk_index, start in enumerate(range(0, tokens, chunk)):
stop = min(start + chunk, tokens)
rows = stop - start
logits_chunk = logits[:rows]
input_chunk = _input[start:stop]
_project_and_dz(
input_chunk,
weight,
logits_chunk,
target[start:stop],
loss_1d[start:stop],
loss_scale,
partial_max[:rows],
partial_sum[:rows],
completion_count[:rows],
vocab_size,
num_vocab_tiles,
stats_block_size,
ignore_index,
needs_dz,
reduction_mean,
)
# logits_chunk now holds dZ. dX is scaled AFTER the low-precision GEMM
# (in backward), matching the baseline; store the raw projection here.
if grad_input is not None:
torch.mm(logits_chunk, weight, out=grad_input[start:stop])
# Accumulate raw dZ.T @ X into the FP32 dW buffer via cuBLAS: overwrite
# (mm) on the first chunk, add (addmm) on subsequent chunks.
if dweight_accum is not None:
_accumulate_dw_fp32(dweight_accum, logits_chunk, input_chunk, chunk_index > 0)
loss = loss_1d.sum()
# Retain the RAW FP32 dW accumulator (scaled + cast in backward) so small
# gradients are not lost to an early BF16 cast.
return loss, grad_input, dweight_accum, gradient_scale
def chunked_fused_linear_cross_entropy_backward(
grad_output: torch.Tensor,
grad_input: Optional[torch.Tensor],
dweight_accum: Optional[torch.Tensor],
gradient_scale: torch.Tensor,
weight_dtype: torch.dtype,
):
"""Backward for the chunked forward.
dX is the raw BF16 projection, scaled out-of-place by
``upstream * gradient_scale``. dW is the retained FP32 accumulator: the
combined ``upstream * gradient_scale`` factor is applied in FP32 and the
result cast to ``weight_dtype`` exactly once by a fused scale-and-cast
kernel (no O(V*H) FP32 temporary, and small gradients survive the cast).
Every output is a fresh tensor, so the saved gradients are never mutated --
repeated backward with a different upstream (including ``1``) stays correct.
"""
grad_input_out = None
if grad_input is not None:
grad_input_out = grad_input * (grad_output * gradient_scale)
grad_weight_out = None
if dweight_accum is not None:
combined_scale = (grad_output * gradient_scale).to(torch.float32)
grad_weight_out = torch.empty(dweight_accum.shape, dtype=weight_dtype, device=dweight_accum.device)
_launch_scale_cast(dweight_accum, grad_weight_out, combined_scale)
return grad_input_out, grad_weight_out
class LigerFusedLinearCrossEntropyFunction(torch.autograd.Function):
# This Function computes dX/dW during forward with token chunking. The
# transient logits/dZ workspace is O(min(N, C) * V) instead of the full
# O(N * V); the retained gradients add O(N*H + V*H). The token-chunk size C
# is the shared default Triton geometry (_get_chunk_size); there is no public
# chunk_size knob.
# ce_impl / ce_mode are accepted only as self-identity placeholders (see
# _validate_ce_dispatch); this Function does NOT dispatch to alternative CE
# implementations, so it does not advertise inner-impl dispatch.
supports_inner_impl_dispatch = False
@staticmethod
@amp_custom_fwd
def forward(
@@ -399,8 +700,11 @@ class LigerFusedLinearCrossEntropyFunction(torch.autograd.Function):
use_token_scaling=False,
return_token_accuracy=False,
return_predicted_tokens=False,
ce_impl=None,
ce_mode=None,
):
loss, grad_input, grad_logits, gradient_scale = fused_linear_cross_entropy_forward(
_validate_ce_dispatch(ce_impl, ce_mode)
loss, grad_input, dweight_accum, gradient_scale = chunked_fused_linear_cross_entropy_forward(
_input=_input,
weight=weight,
target=target,
@@ -418,13 +722,10 @@ class LigerFusedLinearCrossEntropyFunction(torch.autograd.Function):
return_predicted_tokens=return_predicted_tokens,
)
ctx.save_for_backward(
grad_input,
grad_logits,
_input.detach() if weight.requires_grad else None,
gradient_scale,
)
ctx.weight_shape = weight.shape
# Retain the raw BF16 dX, the raw FP32 dW accumulator, and the scale.
# Neither the full [N, V] dZ nor the input activations are saved. The
# target weight dtype is tracked so backward can cast dW once.
ctx.save_for_backward(grad_input, dweight_accum, gradient_scale)
ctx.weight_dtype = weight.dtype
return loss, None, None, None
@@ -432,37 +733,39 @@ class LigerFusedLinearCrossEntropyFunction(torch.autograd.Function):
@amp_custom_bwd
def backward(ctx, grad_output, grad_output2, grad_output3, grad_output4):
del grad_output2, grad_output3, grad_output4
grad_input, grad_logits, _input, gradient_scale = ctx.saved_tensors
grad_input, grad_weight = fused_linear_cross_entropy_backward(
grad_input, dweight_accum, gradient_scale = ctx.saved_tensors
grad_input, grad_weight = chunked_fused_linear_cross_entropy_backward(
grad_output,
grad_input,
grad_logits,
_input,
dweight_accum,
gradient_scale,
ctx.weight_shape,
ctx.weight_dtype,
)
return (
grad_input,
grad_weight,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, # target
None, # bias
None, # ce_weight
None, # ignore_index
None, # lse_square_scale
None, # label_smoothing
None, # reduction
None, # softcap
None, # return_z_loss
None, # accum_dtype
None, # use_token_scaling
None, # return_token_accuracy
None, # return_predicted_tokens
None, # ce_impl
None, # ce_mode
)
__all__ = [
"LigerFusedLinearCrossEntropyFunction",
"chunked_fused_linear_cross_entropy_backward",
"chunked_fused_linear_cross_entropy_forward",
"fused_linear_cross_entropy_backward",
"fused_linear_cross_entropy_forward",
]
@@ -40,6 +40,19 @@ _TORCH_VERSION = Version(torch.__version__.split("+")[0])
_ADDMM_SUPPORTS_OUT_DTYPE = _TORCH_VERSION >= Version("2.8.0")
def _get_chunk_size(BT, H, V):
"""Token-chunk size shared by the Triton and cuTile FLCE backends.
Widens the transient logits budget to ``_CHUNK_MEM_CONST x BT x H`` (C=1 is a
memory floor, not a perf target): ``inc_factor = cdiv(V, C*H)``, then the
chunk is the next power of two of ``cdiv(BT, inc_factor)``, clamped to ``BT``
so a single chunk covers ``BT`` when the budget allows (and when ``V <= C*H``).
"""
inc_factor = triton.cdiv(V, _CHUNK_MEM_CONST * H)
chunk_size = triton.next_power_of_2(triton.cdiv(BT, inc_factor)) # (BT + inc_factor - 1) // inc_factor
return min(chunk_size, BT) # a single chunk covers BT when the budget allows; never exceed BT
def fused_linear_cross_entropy_forward(
_input,
weight,
@@ -89,9 +102,7 @@ def fused_linear_cross_entropy_forward(
V = weight.shape[0]
# widen the transient logits budget to C x BT x H (C=1 is a memory floor, not a perf target)
inc_factor = triton.cdiv(V, _CHUNK_MEM_CONST * H)
chunk_size = triton.next_power_of_2(triton.cdiv(BT, inc_factor)) # (BT + inc_factor - 1) // inc_factor
chunk_size = min(chunk_size, BT) # a single chunk covers BT when the budget allows; never exceed BT
chunk_size = _get_chunk_size(BT, H, V)
num_chunks = triton.cdiv(BT, chunk_size) # (BT + chunk_size - 1) // chunk_size
grad_input = torch.zeros_like(_input, device=device)
File diff suppressed because it is too large Load Diff