Cross Entropy is a ubiquitous loss function in deep learning, including the training of LLMs (next token prediction is a classification task). The catch is that it's a classification over the entire vocabulary, and vocabularies are enormous.
Concretely: Gemma 2 has a vocabulary of 256,128 tokens. For a batch of 8,192 tokens, a single fp32 logit tensor is GB. A baseline PyTorch loss also materializes additional log-probability and intermediate buffers. In the measurements reported by Apple's CCE paper, the baseline loss peaks at 24 GB and the combined loss-plus-gradient computation peaks at 28 GB for Gemma 2 (2B). Cut Cross Entropy computes the loss using 1 MB.
Logit tensor =
8.39 GB
Tokens
−
+
Tokens = 8,192
Vocabulary
−
+
Vocabulary = 256,128
Bytes
−
+
Bytes = 4
Gemma 2 (2B), paper-reported training setting
LOG SCALE
Baseline loss + gradient
28 GB
Baseline loss
24 GB
Cut Cross Entropy
1 MB
By reducing the memory footprint of cross-entropy, we can significantly decrease the hardware requirements for training LLMs!
This is the source code that's referenced throughout this article. It might be helpful to have this open in another tab: GitHub - Apple ML Cut Cross Entropy
If you are unfamiliar with the math, check out derivation of cross-entropy loss. But here is the structure in brief:
Cross-entropy can be broken into two terms:
Keep this decomposition in mind — it's the map for everything that follows. The forward pass implements each term with its own dedicated kernel, and the backward pass is where the two terms' gradients merge back together.
The villain of the story is the logit matrix : the linear classifier projects the embeddings onto an extremely large vocabulary space, and the whole "cut cross entropy" approach is about never materializing that projection (which we'd otherwise still have to compute further terms of, like softmax).
Let's establish our vocabulary of terms (mirroring the paper, though I've fixed the shapes so everything type-checks against ):
| Symbol | Meaning |
|---|---|
| Number of tokens in the batch (batch × sequence, flattened). | |
| Embedding/hidden dimension. | |
| Vocabulary size. | |
Token ("query") embeddings. Stored as (B, D) in the code. | |
Classifier head weights. Stored as (V, D) in the code. | |
| The logits — never materialized in global memory. | |
| Logit of the correct class for token . | |
| Log-sum-exp over the vocabulary, per token. | |
| One-hot matrix of the target classes. | |
| Softmax probabilities. | |
Upstream gradient arriving at the loss (d_out in the code). | |
| The loss gradient w.r.t. the logits, scaled by the upstream gradient. |
Cut Cross Entropy saves memory by doing two things:
The core innovation is not a new loss function, but a restructured computation: by chunking the logits and then selectively skipping over unnecessary chunks.
Inputs:
Notably, the logits have not been computed yet.
The forward pass maps directly onto our two loss terms — one kernel each:
indexed_dot_forward_kernel → term 1, the negative dot product .
Our kernel retrieves the value , the -th column from C, and the -th column from E, and stores them in on-chip shared memory (SRAM). It then performs a dot product between and and writes the result into global memory. The kernel uses only on-chip SRAM throughout and does not allocate any GPU memory.
cce_lse_forward_kernel → term 2, the .
logit_avg, the mean logit per vocabulary entry, as a by-product — this looks useless right now, but it becomes the sorting key for the backward pass's filtering. Hold that thought.logit_avg. Note what's not on this list: the logits.Between the kernels, the cce_lse_forward_kernel is more interesting, so lets explore its triton implementation.
Skipping over the initialisation (of the pointer and offset) we hit the first math operation:
accum = tl.zeros((BLOCK_B, BLOCK_V), dtype=tl.float32)
for d in range(0, tl.cdiv(D, BLOCK_D)):
e = tl.load(e_ptrs, mask=e_mask, other=0.0)
c = tl.load(c_ptrs, mask=c_mask, other=0.0)
accum = tl.dot(e, c, accum, input_precision=DOT_PRECISION)
e_ptrs += BLOCK_D * stride_ed
c_ptrs += BLOCK_D * stride_cd
(I've stripped out the mask bookkeeping — e_mask/c_mask just handle the ragged edges when or don't divide evenly into blocks.)
Here we are computing accum: the dot product of the query and class embeddings (represented generally by e and c). This is done in chunks of BLOCK_D.
The important part is where accum lives. Each program instance owns one tile of the logit matrix, and that tile exists only in on-chip SRAM. It gets reduced into a partial LSE (next snippet) and then thrown away. The full logit matrix never touches global memory — only the final LSE vector does. That's the memory trick in one line: becomes .
After that, comes computation of the linear-log-sum-exponent (LSE).
Tile 1 of 3 / Partial LSE
STEP 1/12
Tile 1 reduces its transient logits to partial LSE 2.70.
this_mx = tl.max(logits, axis=1)e = tl.exp(logits - this_mx[:, None])this_lse = this_mx + tl.log(tl.sum(e, axis=1)) # ... this_locks = Locks + (pid_b // tl.cdiv(B, BLOCK_B * num_locks))while tl.atomic_cas(this_locks, 0, 1) == 1: pass lse = tl.load(lse_ptrs, mask=o_mask, other=0.0, eviction_policy="evict_last")lse = tl_logaddexp(lse, this_lse)tl.store(lse_ptrs, lse, mask=o_mask, eviction_policy="evict_last") tl.debug_barrier()tl.atomic_xchg(this_locks, 0)There are 3 main steps here:
(BLOCK_B, BLOCK_V) chunk of logitstl.atomic_cas is used to implement a spin lock.(pid_b // tl.cdiv(B, BLOCK_B * num_locks)), causes multiple pid_b blocks to share the same lock. Not shown in all this code is that pid_v blocks that correspond to the same pid_b (same block, but different vocabulary slices) will share the same pid_b lock.lse = tl.load(lse_ptrs...) loads the current global value of LSE. This is intialized as -inf (because ).lse = tl_logaddexp(lse, this_lse) is the core aggregation step. By repeatedly computing , different blocks/vocabulary chunks will combine their partial LSEs into a global LSE.tl.store(lse_ptrs, lse, mask=o_mask, eviction_policy="evict_last") stores the updated global LSE back to global memory.tl.atomic_xchg(this_locks, 0) releases the lock, allowing other threads to update the global LSE.In short: to ensure synchronicity, we use a spin lock so that only one vocabulary chunk can update the global LSE at a time. Each block computes a partial LSE, which is then merged into the global one. This is an aggregation step to get our final result, the term.
The backward pass needs to produce two gradients:
Here's where the two-term map pays off. Differentiate each term with respect to the logits:
Sum them and you get the classic softmax-minus-onehot, scaled by the upstream gradient:
This is the key intermediate product of the backward pass. One more application of chain rule through turns it into both of our gradients:
Two things from the paper make this computable at all. First, needs no fresh normalization pass: , reusing the LSE we already paid for in the forward pass. Second, is just as enormous as the logits — so the backward pass recomputes blockwise in SRAM, exactly like the forward pass did, rather than ever storing it.
Overall, this is what the backward pass does:
d_out in the code) and the targets .Steps 3–4 look like this in code:
# Compute S
d_accum = tl.exp(accum - lse[:, None])
d_accum = tl.where(offs_v[None, :] < V, d_accum, 0.0)
# Compute S - Y
if HAS_TARGETS:
targets = tl.load(Targets + target_offs_b, mask=target_offs_b < BMax, other=V + 1)
is_target = targets[:, None] == offs_v[None, :]
d_accum += tl.where(is_target, -1.0, 0.0) # <-- subtracting Y from S
else:
is_target = None
# (gradient filtering happens here — covered in the next section)
d_out = grad_scale * d_out
# The final product we're looking for: S_hat = (S - Y) * upstream_grad
d_accum = d_accum * d_out
If this looks like a wall of mathy text, the main point is that steps 1–4 build the intermediate term , which steps 5–6 then consume. Steps 5–6 are matrix multiplications — expensive operations that we'd like to avoid if we can — and that brings us to the main optimization in the backward pass: gradient filtering.
Before we dive into the gradient filtering, it helps to recall that in the triton kernel is really , the gradient signal for a single block/vocabulary chunk. The same applies for and the other terms.
Then let's zoom in on the gradient filtering segment of the code:
should_skip = False
if (FILTER_E_GRAD and COMPUTE_DE) and (FILTER_C_GRAD and COMPUTE_DC):
if _block_is_filtered(tl.abs(d_accum), filter_eps):
return
elif (FILTER_E_GRAD and COMPUTE_DE) or (FILTER_C_GRAD and COMPUTE_DC):
should_skip = _block_is_filtered(tl.abs(d_accum), filter_eps)
# ...
if COMPUTE_DE:
if FILTER_E_GRAD:
should_skip_e = should_skip
# ...
if COMPUTE_DC:
if FILTER_C_GRAD:
should_skip_c = should_skip
# ...
# elsewhere:
@triton.jit
def _block_is_filtered(check_val: tl.tensor, filter_eps: tl.tensor) -> tl.tensor:
return tl.reduce(check_val < filter_eps, None, tl_and_reduce_fn)
_block_is_filtered checks if all values within d_accum (which is ) are below our "near-zero" threshold. If so, we terminate the kernel early, or skip over some of the remaining matrix multiplications involved in calculating the gradient outputs and .
Why is this legal? After the softmax, the overwhelming majority of is astronomically small — the probability mass concentrates on a handful of plausible tokens. Contributions below filter_eps are around the noise floor of the accumulation's numerical precision anyway; skipping them trades an invisible amount of exactness for a lot of saved work.
There's a catch with filtering at block granularity: a block is only skippable if every value in it is near zero. One confident token in an otherwise-dead block ruins the whole block.
This is what logit_avg from the forward pass was for. Before launching the backward kernels, the vocabulary is sorted by average logit, so the consistently-plausible tokens cluster together into a few dense blocks — and everything else collapses into large, contiguous, entirely-skippable regions. It's a heuristic, but it directly increases the number of blocks that _block_is_filtered gets to throw away.
As a whole, the gradient filtering process is:
filter_eps, skip the expensive matrix multiplications in steps 5–6 and zero out those blocks' contributions.Every square represents the maximum magnitude in one illustrative 128 x 128 token-by-vocabulary block.
x below threshold: skipped
+ retained for matmul
Filtering threshold exponent
−
+
2^-12 = 2.441e-4
Paper setting: 2^-12
16/32 blocks skipped in this illustration
<0.02%
nonzero softmax elements
~50
rank where probability falls below threshold
GEMMA 2 2B GRADIENT TIME
CCE
100 ms
No vocabulary sort
115 ms
No gradient filter
314 ms
Cut cross entropy is likely going to become the go-to implementation of cross-entropy going forward. The memory savings it presents are too good to pass up.
One caveat worth knowing: gradient filtering makes CCE technically lossy. Gradients below filter_eps are genuinely discarded, not deferred. The paper shows this has no measurable effect on training loss curves, but it is a knob you can turn — tightening filter_eps trades speed back for exactness.
As for its design, the recipe here generalizes well beyond cross entropy: recompute blockwise in SRAM instead of materializing, reuse a cheap global statistic (the LSE) to avoid renormalization, and skip work that provably can't matter. Any loss over a huge output space is a candidate — I've since been applying the same treatment to other losses, and future posts will dig into those.
I'll definitely be writing more about kernel optimization and other similar breakdowns by the way, so if you found this interesting, consider following me on any of my socials.
related / nearby
Derivation: Cross-Entropy
Derivation of cross-entropy loss from first principles: information theory, entropy, and modelling probability distributions
Saving VRAM with Apple's Cut Cross Entropy
A comprehensive breakdown of Apple's memory-efficient cut cross entropy implementation
LR Scheduling and SGD
My first encounter with training internals