home
log

Cut Cross Entropy, Visualized

deep learning
math
triton
[charted]logged May 21, 2025 · updated Jul 3, 2026 · 20 min read
Charted outline — the structure is visible; details are still being resolved.

Naively computing Cross Entropy is Expensive

Cross Entropy is a ubiquitous loss function in deep learning, including the training of LLMs (next token prediction is a classification task). 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 8192×256128×32 bits8.48192 \times 256128 \times 32 \text{ bits} \approx 8.4 GB. Each entry is one scalar logit zk,iz_{k,i}: the model's raw score for vocabulary class kk at token ii. The tensor is enormous because it collects that score for every token-class pair.

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.

8192×256128×4 bytes8192 \times 256128 \times 4 \text{ bytes}

Logit tensor =

8.39 GB

Tokens

+

Tokens = 8,192

Vocabulary

+

Vocabulary = 256,128

Bits

+

Bits = 32

Loss-memory footprint

Gemma 2 (2B), paper-reported training setting

LOG SCALE

Baseline loss + gradient

28 GB

Baseline loss

24 GB

Cut Cross Entropy

1 MB

Bar lengths use a logarithmic scale so the 1 MB CCE allocation remains visible beside gigabyte-scale baselines. Values are measured peak allocations from Table 1 of the CCE paper; "loss + gradient" can reuse intermediate buffers and is not the sum of both peaks.

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: Apple ML Cut Cross Entropy

Quick Recap: Cross Entropy

If you are unfamiliar with the math, check out derivation of cross-entropy loss

. But here is the structure in brief:

L=logezyikezk=zyi+logkezk\mathcal{L} = - \log \frac{e^{z_{y_i}}}{\sum_{k} e^{\htmlClass{math-var-tooltip math-var-tooltip-1}{z_k}}} = \htmlClass{math-var-tooltip math-var-tooltip-0}{- z_{y_i}} + \log \sum_{k} e^{\htmlClass{math-var-tooltip math-var-tooltip-1}{z_k}}

Cross-entropy can be broken into two terms:

  1. The negative dot product: zyi\htmlClass{math-var-tooltip math-var-tooltip-0}{- z_{y_i}}
  2. The log-sum-exp: logkezk\log \sum_{k} e^{\htmlClass{math-var-tooltip math-var-tooltip-1}{z_k}}

Here zk\htmlClass{math-var-tooltip math-var-tooltip-1}{z_k} is shorthand for the score zk,iz_{k,i} at one token ii. If eiRDe_i \in \mathbb{R}^{D} is that token's hidden-state vector and ckRDc_k \in \mathbb{R}^{D} is the classifier vector for class kk, then:

zk,i=ckTeiz_{k,i} = c_k^{\mathsf{T}} e_i

Stack the token vectors as the columns of ERD×BE \in \mathbb{R}^{D \times B} and the class vectors as the columns of CRD×VC \in \mathbb{R}^{D \times V}. Collecting every zk,iz_{k,i} gives the logit matrix:

Z=[zk,i]=CTERV×BZ = [z_{k,i}] = C^{\mathsf{T}} E \in \mathbb{R}^{V \times B}

This is the same logit tensor from the opening memory calculation. The implementation stores token vectors as rows, so its equivalent computation is ETC=ZTE^{\mathsf{T}} C = Z^{\mathsf{T}} with shape (B, V).

Keep the two-term decomposition in mind (the negative dot product and the log-sum-exp). 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 now concrete: materializing ZZ means storing every one of those token-class scores at once. The whole "cut cross entropy" approach is about evaluating the two loss terms without ever writing the full projection to global memory.

Notation

Let's establish our vocabulary of terms (mirroring the paper, though I've taken some liberties with the shapes so it checks against Z=CTEZ = C^{\mathsf{T}} E):

SymbolMeaning
BBNumber of tokens in the batch (batch × sequence, flattened).
DDEmbedding/hidden dimension.
VVVocabulary size.
ERD×BE \in \mathbb{R}^{D \times B}Token ("query") embeddings. Stored as (B, D) in the code.
CRD×VC \in \mathbb{R}^{D \times V}Classifier head weights. Stored as (V, D) in the code.
Z=CTERV×BZ = C^{\mathsf{T}} E \in \mathbb{R}^{V \times B}The logits — never materialized in global memory.
zk,i=ckTeiz_{k,i} = c_k^{\mathsf{T}} e_iLogit for class kk at token ii; zyi,iz_{y_i,i} is the correct one.
LSERB\htmlClass{math-var-tooltip math-var-tooltip-2}{\text{LSE}} \in \mathbb{R}^{B}Log-sum-exp over the vocabulary, per token.
YRV×BY \in \mathbb{R}^{V \times B}One-hot matrix of the target classes.
S=softmax(CTE)S = \text{softmax}(C^{\mathsf{T}} E)Softmax probabilities.
gRBg \in \mathbb{R}^{B}Upstream loss gradient, stored as d_out in the code.
S^=(SY)g\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}} = (S - Y) \odot gLoss gradient w.r.t. the logits; gg broadcasts across classes.

Overview of Cut Cross Entropy

Cut Cross Entropy saves memory by doing two things:

  1. Blockwise computation: the logits are computed in small blocks that live entirely in on-chip SRAM
    , with each block immediately reduced into a running log-sum-exp and then discarded. The full V×BV \times B logit matrix simply never exists in global memory. This is where the memory savings of over 1000 times compared to traditional cross entropy implementations come from.
  2. "Filtering" (although I think this is better described as "skipping" or "early stopping"): in the backward pass, chunks of near-zero gradient are skipped entirely. This is the "cutting", and it's what makes the backward pass's recomputation fast — it buys back compute and memory bandwidth, rather than memory capacity.

The core innovation is not a new loss function, but a restructured computation: by chunking the logits and then selectively skipping over unnecessary chunks.


Breakdown: Forward Pass

Inputs:

  • Token embeddings EE
  • Classifier embeddings CC
  • Bias vector (vocab×1)(\text{vocab} \times 1)
  • Targets yiy_i

Notably, the logits have not been computed yet.


The forward pass maps directly onto our two loss terms — one kernel each:

  1. indexed_dot_forward_kernelterm 1, the negative dot product zyi\htmlClass{math-var-tooltip math-var-tooltip-0}{- z_{y_i}}.
    • A triton kernel that gathers only the correct-class column cyic_{y_i} and dots it with the corresponding embedding eie_i. From the paper:

      Our kernel retrieves the value xix_i, the xix_i-th column from C, and the ii-th column from E, and stores them in on-chip shared memory (SRAM). It then performs a dot product between CxiC_{x_i} and EiE_{i} and writes the result into global memory. The kernel uses only on-chip SRAM throughout and does not allocate any GPU memory.

  2. cce_lse_forward_kernelterm 2, the log-sum-exp.
    • A triton kernel that computes the log-sum-exp over blockwise logits. It also computes 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.
  3. Aggregate loss
    • The two terms are summed to compute the final loss.
    • Supports standard reductions (mean, sum).
  4. Save tensors for backward pass
    • Embeddings, targets, the per-token log-sum-exp, and logit_avg. Note what's not on this list: the logits.

Forward Kernel

Between the kernels, the cce_lse_forward_kernel is more interesting, so let's explore its Triton implementation.

Skipping over pointer and offset initialization, we hit the first math operation:

triton

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 DD or VV don't divide evenly into blocks.)

Here we are computing accum. Each pass loads the next BLOCK_D features from the token and class vectors, multiplies them, and adds the result to the same accumulator. After the loop has covered all DD features, accum[i, k] holds zk,iz_{k,i} for one block of tokens and vocabulary classes.

The important part is where accum lives. Each program instance owns one (BLOCK_B,BLOCK_V)(\text{BLOCK\_B}, \text{BLOCK\_V}) tile of ZTZ^{\mathsf{T}}, matching the code's (B, V) layout, and that tile exists only in on-chip SRAM.

The source then casts the accumulator, adds the optional bias, masks vocabulary positions beyond VV to -\infty, and applies optional soft-capping. The resulting logits variable is still the same SRAM-resident tile; it has not been written to global memory.

Now each token row in that tile can be reduced to a partial log-sum-exp and the tile can be discarded. Only the final (B,) log-sum-exp vector survives in global memory. That's the memory trick in one line: O(BV)O(B \cdot V) becomes O(B)O(B).

A logit tile lives only long enough to update the log-sum-exp

Tile 1 of 3 / Partial log-sum-exp

STEP 1/12

Tile 1 reduces its transient logits to partial log-sum-exp 2.70.transient logits in SRAMtoken blocks x vocabulary blockstile 1partial 2.70LOCKone writerHBMglobal LSE-infSRAM: one tile, immediately reduced and discardedHBM: only the O(B) running log-sum-exp survives

Tile 1 reduces its transient logits to partial log-sum-exp 2.70.

triton

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)
The visual values are deterministic teaching fixtures. The source at right is the post's Triton excerpt; highlighted lines and the diagram are driven by the same frame.

There are four distinct steps here:

  1. Reduce the local tile. this_mx finds the largest logit in each token row. Subtracting that maximum before exponentiating avoids overflow, and adding it back after the sum gives one stable partial log-sum-exp per token.
  2. Acquire the lock. Several vocabulary tiles contribute to the same token rows. tl.atomic_cas spins until this program has exclusive access to those rows of the running global result. Without the lock, two programs could load the same old value and overwrite one another's updates.
  3. Merge and store. The global vector starts at -inf, the identity for log-add-exp: logaddexp(,x)=x\operatorname{logaddexp}(-\infty, x) = x. The kernel loads the running value, merges the tile's partial result with tl_logaddexp, and stores the update. Repeating this across vocabulary tiles is equivalent to taking one log-sum-exp over the entire vocabulary.
  4. Release and discard. After the store is complete, tl.atomic_xchg(this_locks, 0) releases the lock. The program is finished with its logit tile, so that tile disappears from SRAM and the next vocabulary tile can repeat the process.

In short: every vocabulary tile briefly computes logits, reduces them to one partial statistic per token, merges that statistic into the running log-sum-exp, and disappears. At no point does the full logit tensor need a home in global memory.

Breakdown: Backward Pass

The backward pass needs to produce two main gradients, plus an optional bias gradient:

  • the gradient with respect to the query embeddings, E\nabla E
  • the gradient with respect to the class embeddings, C\nabla C
  • if present, the gradient with respect to bias, b\partial b. We're gonna ignore this.

Here's where the two-term map pays off. Differentiate each term with respect to the logits:

  • The log-sum-exp term gives the softmax: LSEzk=ezkjezj=S\frac{\partial \operatorname{LSE}}{\partial \htmlClass{math-var-tooltip math-var-tooltip-1}{z_k}} = \frac{e^{\htmlClass{math-var-tooltip math-var-tooltip-1}{z_k}}}{\sum_j e^{z_j}} = S.
  • The zyi\htmlClass{math-var-tooltip math-var-tooltip-0}{- z_{y_i}} term gives 1-1 at exactly the correct class and 00 everywhere else — that's Y-Y.

Sum them and you get the classic softmax-minus-onehot, scaled by the upstream gradient:

S^=(SY)g\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}} = (S - Y) \odot g

This S^\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}} is the key intermediate product of the backward pass. One more application of chain rule through Z=CTEZ = C^{\mathsf{T}} E turns it into both of our gradients:

E=CS^,C=ES^T\nabla E = C \htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}, \qquad \nabla C = E \htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}^{\mathsf{T}}

Two things from the paper make this computable at all. First, SS needs no fresh normalization pass: S=softmax(CTE)=exp(CTELSE)S = \text{softmax}(C^{\mathsf{T}} E) = \exp(C^{\mathsf{T}} E - \operatorname{LSE}), reusing the per-token log-sum-exp we already paid for in the forward pass. Second, S^\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}} is just as enormous as the logits — so the backward pass recomputes CTEC^{\mathsf{T}} E blockwise in SRAM, exactly like the forward pass did, rather than ever storing it.


Overall, this is what the backward pass does:

  1. Retrieve saved tensors: embeddings, targets, the per-token log-sum-exp, and logit averages.
  2. Recompute the logits (CTEC^{\mathsf{T}} E) in chunks (like in the forward pass).
  3. Compute S=exp(CTELSE)S = \exp(C^{\mathsf{T}} E - \operatorname{LSE}), the softmax probabilities.
  4. Compute S^=(SY)g\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}} = (S - Y) \odot g, using the upstream gradient gg (d_out in the code) and the targets YY.
  5. Compute E=CS^\nabla E = C \htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}, the gradient of the query embeddings.
  6. Compute C=ES^T\nabla C = E \htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}^{\mathsf{T}}, the gradient of the class embeddings.

Steps 3–4 look like this in code:

triton

# 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 S^\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}, 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.

Gradient Filtering

Before we dive into the gradient filtering, it helps to recall that S^\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}} in the triton kernel is really S^block\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}_{\text{block}}, the gradient signal for a single block/vocabulary chunk. The same applies for SSblockS \to S_{\text{block}} and the other terms.


Then let's zoom in on the gradient filtering segment of the code:

triton

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 S^block\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}_{\text{block}}) 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 E\nabla E and C\nabla C.

Why is this a reasonable approximation? After the softmax, the overwhelming majority of SS 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.

Vocabulary Sorting

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:

  1. Vocab sorting (host-side, before kernel launch): sort vocabulary entries by their average logit to make "significant" blocks as dense and contiguous as possible.
  2. Gradient filtering (in-kernel): whenever S^block\htmlClass{math-var-tooltip math-var-tooltip-3}{\hat{S}}_{\text{block}} is entirely below filter_eps, skip the expensive matrix multiplications in steps 5–6 and zero out those blocks' contributions.

When can CCE skip a block?

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

Paper facts are shown at right. The grid is a deterministic teaching fixture, not a measured CCE block mask; the paper reports element density, approximate rank, and timing, but no skipped-block percentage. Autotuning may also choose block sizes other than 128 x 128.

Takeaways: Usage + Design

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 log-sum-exp) to avoid renormalization, and skip contributions below a chosen numerical threshold. Any loss over a huge output space could use this too.

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.

linked from

Derivation: Cross-Entropy

related / nearby

Derivation: Cross-Entropy
related
GPU Programming Basics: Hardware and Mental Models
links here
LR Scheduling and SGD
shared · deep learning