Part M · Where it breaks › top-k, sort, and the ops that are not GEMM

Module 12·Part M — Where it breaks·15 min

top-k, sort, and the ops that are not GEMM

Selection has low arithmetic intensity, irregular access and a serial dependency. Vocabularies grew to 256k and it became a visible fraction of every decode step.

The core mental model

GPUs are built for one shape of problem: regular, dense, high-arithmetic-intensity work with static control flow. GEMM is the ideal case, and everything in Parts K and L is tuned for it. Selection — top-kk, sort, argmax over a large axis — is the opposite on every axis. Its arithmetic intensity is essentially zero, since it reads nn values and does comparisons rather than FLOPs. Its access pattern is irregular once you start moving elements. It has a serial dependency: a comparison tree’s depth is logarithmic at best, and naive approaches are worse.

before compression~1%after quant + spec decode5–15%once graphed1–2%0e+05.110share of a decode step spent outside the GEMMs
Amdahl arriving from below. The non-GEMM work did not get slower — everything around it got faster, so the same fixed cost went from roughly 1% of a decode step to 5–15%. Capturing the step in a CUDA graph pushes it back toward 1–2%. Nothing was optimised away; the denominator moved.

For years this did not matter, because the vocabulary was 32k and the top-kk was a rounding error next to a 7B-parameter forward pass. Two things changed. Vocabularies grew: Llama 3 uses 128k tokens, Gemma 256k, and sampling cost scales with vocabulary while the rest of decode does not. And the rest of decode got faster — quantisation, FlashAttention, CUDA Graphs and speculative decoding compressed everything else, so a fixed cost that was 1% became 5–15% of a decode step. The classic implementation made it worse by sorting the whole vocabulary to find the top few, which is O(nlogn)O(n \log n) work to answer an O(n)O(n) question.

The fix is the one numerical computing always reaches for: solve the problem you have rather than a harder one that contains it. You do not need a sorted vocabulary; you need a threshold. Radix select finds the kk-th largest by examining bits from the most significant down, partitioning by digit and recursing only into the bucket containing the boundary — O(n)O(n) passes over the data with no data movement. Better still, top-kk and top-pp filtering followed by sampling can be done without any sorting at all, by iteratively guessing a threshold, checking the resulting probability mass with a couple of reductions, and rejecting — which is what FlashInfer’s sorting-free sampling does.

How it actually works

How much there is to select from — the number that changed underneath everyone:

Model familyVocabularyLogits per step, fp32
GPT-2 / Llama 232k / 32k128 KB
Llama 3128k512 KB
Gemma256k1 MB
ApproachComplexityNotes
Full sort, take kkO(nlogn)O(n \log n)the naive baseline, still common
Partial sort / heapO(nlogk)O(n \log k)poor GPU fit — serial heap
Radix selectO(n)O(n) per pass, few passesno data movement
Bitonic top-kk in shared memoryO(nlog2k)O(n \log^2 k)good for small kk
Sorting-free rejection samplingO(n)O(n) reductionsno ordering computed at all
Cost, per decode step, 128k vocabOrder of magnitude
Sort-based top-kk + top-pp~100–500 µs
Radix select based~20–60 µs
Sorting-free sampling~10–30 µs
Share of a graphed decode stepfrom ~15% down to ~1–2%

Where selection appears in a modern stack, which is more places than people expect:

SiteWhat is selected
Samplingtop-kk / top-pp over the vocabulary
MoE routingtop-kk over experts (Module 11)
Speculative decodingcomparisons over draft and target dists
Beam searchtop-kk over beams × vocabulary
Attention sparsificationtop-kk over keys
Retrieval / ANNtop-kk over a large index

Critical thinking

Why is top-k hard on a GPU when it barely does any arithmetic?

Because GPUs are throughput machines for regular work, and every property of selection is irregular.

  • No arithmetic intensity. You read nn values and perform comparisons. There is nothing to amortise the memory traffic against, so you are pinned at the memory roof from the start — 1 MB of logits at 3 TB/s is ~0.3 µs of pure bandwidth, and any real implementation is far above that floor, which tells you the cost is structure, not data movement.
  • A serial dependency. Selection is fundamentally a reduction, and a reduction’s depth is at best logn\log n — 17 dependent levels for 128k. Heap-based partial sorts are worse: inherently serial, branch-heavy, and a poor fit for SIMT execution.
  • Data-dependent control flow. Whether an element survives depends on its value, so lanes in a warp diverge. Compaction requires a prefix sum and a scatter, which is another reduction plus irregular writes.
  • Small output, large input. Most of the machine finishes early and idles while the final reduction levels play out with almost no parallelism left.

The consequence to internalise: FLOP counting is useless here. A top-kk over 128k values does essentially no arithmetic and can cost more wall-clock than a matrix multiply doing billions of operations, because its cost is set by dependency depth and irregularity. Any performance model built on FLOPs will mispredict it by orders of magnitude, and that is precisely why it was overlooked until it started showing up in profiles.

Explain radix select, and why it beats sorting for this problem.

Radix select finds the kk-th largest value by examining the bits of the representation from most significant to least, and it never orders anything it does not have to.

Take 8 bits at a time. Histogram all nn values by their top 8 bits — 256 buckets, one atomic-add pass. Scan the histogram from the high bucket down, accumulating counts, until you find the bucket containing the kk-th element. Every value in higher buckets is definitely in the top kk; everything in lower buckets is definitely out. Recurse into the boundary bucket with the next 8 bits, now over a much smaller candidate set.

Why it fits the machine:

  • Each pass is a histogram, which is a well-understood, coalesced, high-throughput GPU primitive.
  • Passes are bounded. 32-bit floats at 8 bits per pass is at most 4 passes, and typically 2 in practice because the candidate set collapses immediately.
  • No data movement. You are computing a threshold, not rearranging elements. The final step is one predicated pass extracting values above it.
  • Order-preserving keys. Floats can be monotonically mapped to unsigned integers with a couple of bit operations, so radix ordering matches numeric ordering exactly.

Against O(nlogn)O(n \log n) sorting: sorting computes the full permutation of 128,000 elements, moving data logn\log n times, to answer a question about one boundary value. Radix select does a handful of histogram passes and one filter, and it is the difference between ~100–500 µs and ~20–60 µs.

The transferable principle is the one this module is really about: solve the problem you have. Sorting is a strictly harder problem that happens to contain the answer, and reaching for it is a habit from a serial world where the constant factors made it not matter.

How can you sample from a top-p distribution without ever sorting?

By reframing it as finding a threshold, then using rejection sampling — because the sorted order was never actually part of the answer.

The definition of top-pp is: the smallest set of tokens whose cumulative probability exceeds pp. That reads like it needs a sorted cumulative sum, but what it determines is a probability cutoff τ\tau such that the tokens with piτp_i \ge \tau are exactly that set. Given τ\tau, the sorted order is irrelevant — you sample from the renormalised distribution restricted to those tokens.

So the loop is:

  1. Guess τ\tau, from the max and a running estimate.
  2. Two reductions compute the mass above it and the count, both fully parallel.
  3. Adjust by bisection or a fixed-point step.
  4. Sample from the masked distribution by inverse-CDF over a prefix sum, or by rejection.

A handful of parallel reductions, no ordering, no data movement. This is the essence of FlashInfer’s sorting-free sampling, and it composes: top-kk and top-pp together become a joint threshold search, and rejection sampling lets you draw the token and verify the constraint in the same kernel.

Two properties worth appreciating. It is exact, not an approximation — the rejection step guarantees the sample comes from the correct distribution, so this is not a quality trade. And it is shape-static: the work does not depend on how many tokens pass the filter, so unlike a compaction approach it stays CUDA-graphable (Module 9), which matters enormously since sampling sits inside the decode loop you spent Part L making capturable.

Where else does this pattern bite, and what is the general rule?

Everywhere the workload stops looking like dense linear algebra, and the pattern is always the same: an op with low intensity, irregular access, or data-dependent output size sitting on a critical path that everything else has been optimised off.

Instances worth recognising:

  • MoE routing (Module 11) is a top-kk over experts per token, plus a scatter — the same op, and it feeds a data-dependent shape.
  • Speculative decoding compares draft and target distributions per position; done naively it materialises and sorts more than it needs.
  • Beam search is top-kk over beams × vocabulary — a much larger selection, done every step.
  • Sparse and long-context attention selects top-kk blocks or keys, so the selection sits inside the attention kernel.
  • Embedding gather/scatter in recommenders is irregular memory access with no arithmetic at all, and is usually the dominant cost of those models.
  • Sequence packing and unpadding are prefix sums and scatters — cheap in FLOPs, awkward on a GPU.

The general rule, and it is the useful takeaway: once you have optimised the GEMMs, your bottleneck moves to the ops that are not GEMMs, and none of your GEMM intuitions transfer. FLOP counts do not predict their cost, arithmetic-intensity reasoning gives a floor that is far below reality, and the compiler will not fix them — Inductor fuses elementwise chains well and has essentially nothing to say about a selection algorithm.

Which is why these are the ops that end up hand-written in Triton or CUTLASS (Module 7), and why libraries like FlashInfer exist at all. They are the residue that the general machinery cannot reach.

Self-check

Why did top-k become a visible cost only recently?

Two changes multiplied. Vocabularies grew — 32k to 128k (Llama 3) to 256k (Gemma) — and sampling cost scales with vocabulary while the rest of decode does not. And everything else got faster: quantisation, FlashAttention, CUDA Graphs and speculative decoding compressed the rest of the step, so a fixed cost that was ~1% became 5–15%. The naive implementation made it worse by sorting the whole vocabulary, O(nlogn)O(n\log n) work for an O(n)O(n) question.

Name the four properties that make selection a bad GPU fit.

Essentially zero arithmetic intensity, so nothing amortises the memory traffic; a serial reduction dependency at least logn\log n deep — 17 levels for 128k, and worse for heap approaches; data-dependent control flow causing warp divergence and requiring prefix-sum compaction; and a small output from a large input, so most of the machine idles through the final reduction levels. FLOP counting mispredicts its cost by orders of magnitude.

Explain radix select in one paragraph, and say why it beats sorting here.

Histogram all values by their top 8 bits into 256 buckets, scan from the high bucket down accumulating counts to find the bucket containing the kk-th element, then recurse into just that bucket with the next 8 bits. At most 4 passes for fp32, typically 2. Each pass is a coalesced histogram — a strong GPU primitive — and no data is moved, since you are computing a threshold, not a permutation. Sorting computes the full ordering of 128,000 elements to answer a question about one boundary value.

How does sorting-free top-p sampling work, and what two properties make it valuable?

Top-pp determines a probability threshold τ\tau; the sorted order is not part of the answer. So: guess τ\tau, compute the mass and count above it with two parallel reductions, adjust by bisection, then sample from the masked distribution by inverse-CDF or rejection. It is exact — the rejection step guarantees the correct distribution, so no quality is traded — and shape-static, since the work does not depend on how many tokens pass, keeping the decode loop CUDA-graphable.

State the general rule this module is an instance of.

Once the GEMMs are optimised, the bottleneck moves to the ops that are not GEMMs — and no GEMM intuition transfers. FLOP counts do not predict their cost, arithmetic-intensity reasoning gives a floor far below reality, and the compiler cannot help: Inductor fuses elementwise chains and has nothing to say about a selection algorithm. Hence hand-written Triton/CUTLASS and libraries like FlashInfer. The same pattern appears in MoE routing, speculative decoding, beam search, sparse attention, embedding gathers and sequence packing.