ML Interview Notes
36 min read14 sections
Part 6 · Decoding algorithms · 06-01

Sampling on the GPU

Status
SOURCE PINNED
Primary sources
  • vllm/v1/sample/sampler.py
  • vllm/v1/sample/ops/
  • python/sglang/srt/layers/sampler.py
Edition pins
vllm a556f3f · sglang 7d89325

The model's job ends at a tensor of shape [batch, 128256]. Turning that into one token per request is arithmetically trivial and architecturally the most awkward part of the decode step: it is pure bandwidth, it must honour a different set of parameters for every row, and it cannot afford a single trip to the host. This chapter is about how both engines got that work onto the GPU and kept it there.

§1

The problem

Run Llama-3-8B on an H100 at batch 256. The final lm_head projection emits logits of shape [256, 128256]. In fp32 — and both engines upcast to fp32 before sampling — that is 131.3 MB. Now suppose you did the obvious thing and sampled on the CPU: copy the logits down, apply each request's temperature and top-p in Python, pick a token, copy the ids back.

The copy alone is fatal. 131.3 MB over PCIe Gen5 x16 at a generous 64 GB/s is 2.05 ms. §0.4 put the bandwidth floor of a Llama-3-8B bf16 decode step at 4.48 ms — the time to stream 15.01 GB of weights through 3.35 TB/s of HBM. So a one-way device-to-host copy of the logits is 46% of the entire step's floor, before the CPU has sorted a single row and before the token ids go back up. Worse, the copy is a synchronisation point: the GPU must finish, the host must work, the GPU must be re-fed. That destroys the overlap the scheduler depends on (§1.3) and makes the step un-capturable as a CUDA graph (§8.1).

131.3 MB
logits, fp32, batch 256, V=128,256
2.05 ms
D2H at 64 GB/s — derived
78 µs
one read+write HBM sweep of it
1.75%
of the 4.48 ms step floor, per sweep

A full FP32 read/write sweep has an ideal HBM floor of about 78 microseconds, roughly 1.75% of the cited weight-streaming floor. These are lower-bound traffic models, not measured step fractions. Sorting, reductions, RNG, transcendental instructions, occupancy and launches also matter; reducing passes is one important design lever.

Provenance

Every number in this chapter is derived — arithmetic over the book constants (H100 SXM, 3.35 TB/s HBM, 989.4 TFLOP/s bf16, ridge $I^\ast = 295$) and shapes read out of the source. Nothing here was measured; there is no GPU in this session.

§2

Mental model

Think of the logits as a very wide, very short matrix: $B$ rows of $V = 128{,}256$ float32s. Every sampling operator is a row-wise reduction or mask over that matrix. Temperature divides each row by a scalar. Penalties subtract from selected columns. Top-k and top-p delete all but a prefix of the row's sorted order. The multinomial draw reduces each row to one index.

A temperature sweep has low arithmetic intensity: roughly one scalar operation per eight logical bytes for FP32 read/write. Sorting, reductions and random draws have different instruction and synchronization costs. Bytes divided by peak bandwidth is a lower bound under the assumed traffic, not exact runtime; scalar instructions should not be compared only to a tensor-core peak. Fusion, selection algorithms, occupancy and launch reduction all matter.

Figure 1 — the operator pipeline over the logits tensor, batch 64, Llama-3-8B. Shapes and HBM traffic annotated per stage. All byte counts derived: $B \cdot V \cdot 4$ bytes for an fp32 row-major logits tensor with $B=64$, $V=128{,}256$; times at 3.35 TB/s.

Sampling operator pipeline with tensor shapes and bytes A vertical pipeline. Logits enter as a 64 by 128256 bfloat16 tensor of 16.4 megabytes, are upcast to float32 at 32.8 megabytes, then pass through masks and bias, penalties, temperature, min-p, top-k, top-p, and finally a Gumbel-noised argmax that reduces the tensor to 64 int32 token ids of 256 bytes. Each stage is annotated with its read plus write traffic in megabytes and its derived time in microseconds. lm_head output [64, 128256] bf16 — 16.4 MB upcast to fp32 [64, 128256] fp32 — 32.8 MB allowed-ids mask, bad words, logit bias, grammar bitmask penalties: repetition, frequency, presence temperature: logits /= T[row] min-p / top-k / top-p masks softmax + noised argmax [64] int32 — 256 bytes read 16.4 · write 32.8 14.7 µs r+w 65.6 MB · 19.6 µs skipped entirely if no row needs it r+w 65.6 MB · 19.6 µs plus bincount over history r+w 65.6 MB · 19.6 µs fused sweep, or a full sort: sort touches 98.5 MB per pass r+w 65.6 MB · 19.6 µs reduction to 64 ids Budget decode step floor, batch 64: 4.48 ms (weights dominate) one r+w sweep: 19.6 µs = 0.44% of the step twelve sweeps: 0.24 ms = 5.3% of the step a full descending sort of every row costs more than all the masks combined arithmetic intensity of every stage: below 1 FLOP/byte
§3

First principles: the operator algebra

Let $z \in \mathbb{R}^{V}$ be one row of logits, $V = 128{,}256$ for Llama-3. Define $p = \mathrm{softmax}(z)$. The operators, in the order both engines compose them:

Temperature. Divide logits by positive T. Increasing T flattens the distribution. As T approaches zero, mass becomes uniform over exactly tied maxima; only a unique maximum yields a point mass. The separate greedy path may have a different tie rule.

Penalties. These are the only operators that read the request's history, not just the current step. Let $c_v$ be the count of token $v$ in the generated output so far, and $m^{\text{prompt}}_v, m^{\text{out}}_v \in \{0,1\}$ indicate presence in prompt and output.

$$ z_v \leftarrow \begin{cases} z_v / \rho & \text{if } z_v > 0 \text{ and } (m^{\text{prompt}}_v \vee m^{\text{out}}_v) \\ z_v \cdot \rho & \text{if } z_v \le 0 \text{ and } (m^{\text{prompt}}_v \vee m^{\text{out}}_v) \end{cases} \qquad z_v \leftarrow z_v - \alpha_f\, c_v - \alpha_p\, m^{\text{out}}_v $$

$\rho$ is the repetition penalty (multiplicative, sign-aware, neutral at 1.0), $\alpha_f$ the frequency penalty (scales with count), $\alpha_p$ the presence penalty (fires once). vLLM implements exactly this, and the sign-aware branch is the reason repetition penalty is a custom kernel rather than a multiply:

vllm/model_executor/layers/utils.py:L66-L83 vLLM
    num_seqs, vocab_size = logits.shape
    _, prompt_mask = get_token_bin_counts_and_mask(
        prompt_tokens_tensor, vocab_size, num_seqs
    )
    output_bin_counts, output_mask = get_token_bin_counts_and_mask(
        output_tokens_tensor, vocab_size, num_seqs
    )

    # Apply repetition penalties as a custom op
    from vllm._custom_ops import apply_repetition_penalties

    apply_repetition_penalties(logits, prompt_mask, output_mask, repetition_penalties)

    # We follow the definition in OpenAI API.
    # Refer to https://platform.openai.com/docs/api-reference/parameter-details
    logits -= frequency_penalties.unsqueeze(dim=1) * output_bin_counts
    logits -= presence_penalties.unsqueeze(dim=1) * output_mask
    return logits

Note the cost hiding in get_token_bin_counts_and_mask: it allocates a [num_seqs, vocab_size + 1] int64 buffer and scatter-adds the whole history into it. At batch 256 that is $256 \times 128{,}257 \times 8 = 262.7$ MB per call, twice (prompt and output) — and the source says so itself, at vllm/v1/sample/ops/penalties.py:L27-L28: "The penalties implementation is currently quite inefficient and will be reworked anyhow." The rework exists at this SHA in the V2 model runner's own sampler stack, whose Triton penalty kernel is quoted in §6.1.4.

Which runner

vLLM ships two GPU model runners at a556f3f, and they do not share a sampler. vllm/v1/worker/gpu_model_runner.py uses vllm/v1/sample/sampler.py; vllm/v1/worker/gpu/model_runner.py — whose README.md still titles itself "[Experimental] Model Runner V2" — builds its own sampler out of the Triton kernels under vllm/v1/worker/gpu/sample/. That README is behind the config. VLLM_USE_V2_MODEL_RUNNER defaults to unset, and the resolution at vllm/config/vllm.py:L648-L700 then falls through to _is_default_v2_model_runner_model, which ends in is_default_v2_architecture or not model_config.is_moe (vllm/config/vllm.py:L725-L743). A dense model such as Llama-3-8B is not MoE, so on CUDA with Triton present and none of _get_v2_model_runner_unsupported_features (vllm/config/vllm.py:L2438-L2543) firing, V2 is what a plain vllm serve selects. Read this chapter accordingly: the V1 sampler it traces in §6.1.7 is the fallback path, and the kernels labelled "V2" below are the ones the running example actually executes.

Top-k. Keep the $k$ largest logits, mask the rest to $-\infty$. Purely order-based; no probability arithmetic needed.

Top-p (nucleus). Sort descending, keep the shortest prefix whose cumulative probability reaches $p$. Formally, with $\pi$ the descending permutation, keep ranks $r \le r^\ast$ where $r^\ast = \min\{ r : \sum_{j \le r} p_{\pi(j)} \ge p \}$. Unlike top-k, the number kept is data-dependent: a confident step may keep one token, an ambiguous one may keep hundreds.

Figure 2 — top-p is a cutoff on the sorted cumulative mass. The same row under $p = 0.9$ keeps 4 tokens; under $p = 0.99$ it keeps 9. Illustrative probabilities, not from a real model.

Top-p cutoff on a sorted probability distribution Bars of descending probability with a rising cumulative curve above them. A horizontal line at 0.9 crosses the cumulative curve after the fourth token; a line at 0.99 crosses after the ninth. Everything to the right of a crossing is masked to negative infinity. tokens, sorted by probability descending → probability cumulative p = 0.90 p = 0.99 cutoff at p=0.90: keep 4 cutoff at p=0.99: keep 9 everything right of the cutoff → −∞

Min-p. Keep tokens whose probability is at least a fixed fraction of the most likely token's: keep $v$ where $p_v \ge \mu \cdot \max_j p_j$. Because it is relative to the peak, it adapts automatically — sharp distributions keep few tokens, flat ones keep many. The V2 runner's vLLM kernel does it in log space, which avoids the softmax entirely: threshold = max_val + tl.log(min_p) at vllm/v1/worker/gpu/sample/min_p.py:L34.

The draw. Given the masked row, sample $v \sim \mathrm{softmax}(z)$. vLLM avoids torch.multinomial in the hot path because it synchronises with the host. The standard trick is the Gumbel-max / exponential-race identity: if $q_v \sim \mathrm{Exp}(1)$ independently, then $\arg\max_v p_v / q_v$ is distributed exactly as a draw from $p$. vLLM's random_sample says so in as many words:

vllm/v1/sample/ops/topk_topp_sampler.py:L450-L471 vLLM
def random_sample(
    probs: torch.Tensor,
    generators: dict[int, torch.Generator],
    use_fp64_gumbel: bool = False,
) -> torch.Tensor:
    """Randomly sample from the probabilities.

    We use this function instead of torch.multinomial because torch.multinomial
    causes CPU-GPU synchronization.
    """
    q = empty_exponential_noise_like(probs, use_fp64_gumbel)
    # NOTE(woosuk): To batch-process the requests without their own seeds,
    # which is the common case, we first assume that every request does
    # not have its own seed. Then, we overwrite the values for the requests
    # that have their own seeds.
    if len(generators) != probs.shape[0]:
        q.exponential_()
    if generators:
        # TODO(woosuk): This can be slow because we handle each request
        # one by one. Optimize this.
        for i, generator in generators.items():
            q[i].exponential_(generator=generator)

SGLang only half-agrees. Its FlashInfer backend never reaches a PyTorch multinomial, and its seeded path uses the hashed-Gumbel argmax below — but its unseeded pytorch backend calls torch.multinomial outright, both in the no-filter case (sampling_from_probs_torch) and after the top-k/top-p sort at python/sglang/srt/layers/sampler.py:L597-L598. So "nobody calls torch.multinomial" is a vLLM property, not a universal one.

Order matters, and the engines do not fully agree

vLLM's Sampler docstring is the canonical statement of the order — masks, then penalties, then temperature, then min-p, then top-k/top-p, then the draw (vllm/v1/sample/sampler.py:L21-L59). SGLang's PyTorch backend applies temperature, then top-k, then top-p, then min-p, all on one descending sort:

python/sglang/srt/layers/sampler.py:L581-L587 SGLang
    probs_sort, probs_idx = probs.sort(dim=-1, descending=True)
    probs_sum = torch.cumsum(probs_sort, dim=-1)
    probs_sort[
        torch.arange(0, probs.shape[-1], device=probs.device).view(1, -1)
        >= top_ks.view(-1, 1)
    ] = 0.0
    probs_sort[(probs_sum - probs_sort) > top_ps.view(-1, 1)] = 0.0

Two consequences fall out of reading these side by side. First, the top-p rule itself agrees. vLLM sorts ascending and masks where the inclusive cumulative mass from the bottom is $\le 1 - p$; SGLang sorts descending and masks where the exclusive prefix mass exceeds $p$. For a token at descending rank $r$, vLLM keeps it iff $1 - \text{excl}(r) > 1 - p$, i.e. $\text{excl}(r) < p$; SGLang keeps it iff $\text{excl}(r) \le p$. The rules differ only on exact equality, and both force the top token to survive — vLLM with an explicit top_p_mask[:, -1] = False, SGLang because $\text{excl}(1) = 0$.

Second, when a request sets both min_p and top_p the two engines can legitimately keep different sets. vLLM masks with min-p first, so the softmax inside its top-p step renormalises over the survivors and reaches cumulative $p$ sooner — keeping fewer tokens. SGLang computes top-p against the full, unfiltered distribution and applies min-p afterwards. This is an arithmetic consequence of the two code paths, not a measurement; it is exactly the kind of divergence that makes "same params, same seed, same output" a false expectation across engines.

§4

The hard part: a heterogeneous batch

A production batch is not one workload. Row 0 is a code-completion request at $T=0$. Row 1 is creative writing at $T=1.1$, $p=0.95$. Row 2 is a JSON extraction at $T=0.2$, $k=40$, with a seed. Row 3 wants none of it. The kernel cannot branch on request identity — there is no request identity inside a CUDA block, only blockIdx.x. Every parameter must arrive as a tensor indexed by row.

vLLM maintains those tensors as a persistent batch: pinned CPU staging arrays of length max_num_seqs written by index when a request is admitted, and matching device tensors copied from them. Admission writes the row:

vllm/v1/worker/gpu_input_batch.py:L400-L417 vLLM
        if sampling_params := request.sampling_params:
            if sampling_params.sampling_type == SamplingType.GREEDY:
                # Should avoid division by zero later when apply_temperature.
                self.temperature_cpu[req_index] = 0.0
                self.greedy_reqs.add(req_id)
            else:
                self.temperature_cpu[req_index] = sampling_params.temperature
                self.random_reqs.add(req_id)

            self.top_p_cpu[req_index] = sampling_params.top_p
            if sampling_params.top_p < 1:
                self.top_p_reqs.add(req_id)
            top_k = sampling_params.top_k
            if 0 < top_k < self.vocab_size:
                self.top_k_reqs.add(req_id)
            else:
                top_k = self.vocab_size
            self.top_k_cpu[req_index] = top_k

Two design decisions are doing all the work here. Neutral encoding: a request that does not want top-k is stored as $k = V$, which is a no-op the kernel can execute unconditionally. Same for $p = 1.0$, $\rho = 1.0$, $\alpha = 0$. There is no "disabled" branch — only a value that happens to do nothing. Set-based gating: alongside the array, vLLM keeps a set of request ids that actually use each feature. no_top_p and no_top_k are just len(...) == 0 (vllm/v1/worker/gpu_input_batch.py:L1126-L1132), and the metadata build skips both the H2D copy and the tensor entirely when the set is empty:

vllm/v1/worker/gpu_input_batch.py:L860-L871 vLLM
    def _make_sampling_metadata(self) -> SamplingMetadata:
        num_reqs = self.num_reqs
        if not self.all_greedy:
            temperature = copy_slice(
                self.temperature_cpu_tensor, self.temperature, num_reqs
            )
        else:
            temperature = None
        if not self.no_top_p:
            copy_slice(self.top_p_cpu_tensor, self.top_p, num_reqs)
        if not self.no_top_k:
            copy_slice(self.top_k_cpu_tensor, self.top_k, num_reqs)

The result is a SamplingMetadata whose top_p and top_k are either a [num_reqs] tensor or None (vllm/v1/sample/metadata.py:L20-L21) — and None means the entire kernel is skipped. This is how a batch where nobody uses top-k avoids paying for it. It is not how a batch where one row uses top-k avoids penalising the other 255: that row forces the tensor to exist, and the kernel runs over all rows with $k = V$ for the rest.

The V2 model runner — the default for a dense model, per the callout in §6.1.3 — closes that gap by pushing the per-row check into the kernel and returning early before touching the logits at all:

vllm/v1/worker/gpu/sample/penalties.py:L123-L136 vLLM
    token_idx = tl.program_id(0).to(tl.int64)
    req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx)
    rep_penalty = tl.load(repetition_penalty_ptr + req_state_idx)
    freq_penalty = tl.load(frequency_penalty_ptr + req_state_idx)
    pres_penalty = tl.load(presence_penalty_ptr + req_state_idx)

    use_rep_penalty = rep_penalty != 1.0
    use_freq_penalty = freq_penalty != 0.0
    use_pres_penalty = pres_penalty != 0.0
    use_penalty = use_rep_penalty or use_freq_penalty or use_pres_penalty
    if not use_penalty:
        # Early return to avoid loading logits.
        return

That is the whole trick, and it is worth stating plainly: the branch is per row, not per request, and it is taken before the loads. A block whose row has no penalty exits having read three floats. The same pattern appears in the temperature kernel (if temperature == 0.0 or temperature == 1.0: return, vllm/v1/worker/gpu/sample/gumbel.py:L29-L31) and the min-p kernel. Because the logits row is 513 KB and the parameter is 4 bytes, an early return is five orders of magnitude cheaper than the work it skips.

SGLang builds the same tensors but from a list comprehension over the batch each time the batch composition changes, and keeps the per-feature gates as booleans on the struct:

python/sglang/srt/sampling/sampling_batch_info.py:L203-L208 SGLang
            sampling_seed=sampling_seed,
            is_all_greedy=all(r.sampling_params.top_k <= 1 for r in reqs),
            is_any_greedy=any(r.sampling_params.top_k <= 1 for r in reqs),
            need_top_p_sampling=any(r.sampling_params.top_p != 1.0 for r in reqs),
            need_top_k_sampling=any(r.sampling_params.top_k != TOP_K_ALL for r in reqs),
            need_min_p_sampling=any(r.sampling_params.min_p > 0 for r in reqs),

Under continuous batching the row set changes constantly, so both engines need index surgery. vLLM swaps and condenses rows in place (swap_states, condense) and reports the moves to logits processors via a BatchUpdate record carrying removed, added, moved lists (vllm/v1/sample/logits_processor/interface.py:L36-L57). SGLang instead gathers with an index tensor — filter_batch does setattr(self, item, value[keep_indices_device]) for each parameter tensor (python/sglang/srt/sampling/sampling_batch_info.py:L307-L317) and merge_batch concatenates. The tradeoff is familiar: vLLM's in-place slots keep tensor addresses stable, which matters for CUDA graph capture; SGLang's gather is simpler and reallocates.

Figure 3 — one kernel, four rows, four different parameter sets. Neutral values ($k = V$, $p = 1$, $\rho = 1$) let every row run the same instruction stream. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§5

Sorting, partial selection, and why prefill and decode want different kernels

Top-p needs an ordering; top-k needs only a threshold. That asymmetry drives everything.

A full torch.sort over the vocabulary moves 4-byte keys plus 8-byte indices: 98.5 MB of payload at batch 64, or about 197 MB for a full read plus write. Actual radix traffic varies by implementation. vLLM's PyTorch fallback sorts and its docstring is candid about it: "If a top-p is used, this function will sort the logits tensor, which can be slow for large batches" (vllm/v1/sample/ops/topk_topp_sampler.py:L374-L378).

For top-k alone you can skip the sort:

vllm/v1/sample/ops/topk_topp_sampler.py:L411-L430 vLLM
def apply_top_k_only(logits: torch.Tensor, k: torch.Tensor) -> torch.Tensor:
    """
    Apply top-k mask to the logits.

    This implementation doesn't involve sorting the entire vocab.
    Note however that it involves a GPU->CPU sync which can be detrimental for
    async scheduling performance.

    The logits tensor may be updated in-place.
    """
    no_top_k_mask = k == logits.shape[1]
    # Set non-top-k rows to 1 so that we can gather.
    k = k.masked_fill(no_top_k_mask, 1)
    max_top_k = k.max()
    # topk.values tensor has shape [batch_size, max_top_k].
    # Convert top k to 0-based index in range [0, max_top_k).
    k_index = k.sub_(1).unsqueeze(1)
    top_k_mask = logits.topk(max_top_k, dim=1).values.gather(1, k_index.long())
    # Handle non-topk rows.
    top_k_mask.masked_fill_(no_top_k_mask.unsqueeze(1), -float("inf"))

This is heterogeneity handled by taking the max: run one topk at $k_{\max} = \max_i k_i$, then gather each row's own cutoff value out of the result. The whole batch pays for the greediest row. And k.max() is a device-to-host read, which is why this path is gated behind allow_cpu_sync and only used on CPU at this SHA.

The path that actually runs on an H100 is neither. vLLM's default is a Triton kernel that does pivot-based truncation: estimate mean and standard deviation from one 8192-wide sample tile, use a lookup table to convert $k/V$ into a Gaussian $\sigma$ cutoff, gather the outliers above that pivot into a per-program scratch buffer, then ternary-search the pivot until exactly $k$ survive (vllm/v1/sample/ops/topk_topp_triton.py:L127-L200). Nothing is sorted; the row is swept in tiles. The launch config is one program per SM, capped by batch:

vllm/v1/sample/ops/topk_topp_triton.py:L933-L947 vLLM
    # Smaller tiles compile and run faster on CPU; GPU benefits from larger tiles.
    # On XPU, large BLOCK_SIZE causes precision loss in the single-pass pivot
    # approximation; use smaller tiles for accurate top-p results.
    launch_kwargs = {}
    if logits.device.type == "cpu":
        block_size, block_size_trunc = 256, 128
    elif logits.device.type == "xpu":
        block_size, block_size_trunc = 4096, 2048
    else:
        block_size, block_size_trunc = 8192, 4096
        # Each program serially sweeps the vocab row in BLOCK_SIZE tiles, so
        # per-tile latency bounds kernel latency, and Triton's default of 4
        # warps leaves an 8192-wide tile at 16 elements per lane. 8 warps is
        # faster on every arch measured (SM90, SM100, SM120, gfx950); 16 is not.
        launch_kwargs["num_warps"] = 8

The dispatcher picks it only when the batch is wide enough to fill the machine — if HAS_TRITON and logits.shape[0] >= 8: return apply_top_k_top_p_triton(...), otherwise the PyTorch sort (vllm/v1/sample/ops/topk_topp_sampler.py:L355-L364). Below 8 rows there are not enough programs to hide the serial sweep; above it the sort's bandwidth dominates. The third option, FlashInfer, avoids the sort with a rejection loop over a shrinking pivot and is used when available and no per-request generator is present.

The prefill/decode kernel split, concretely

csrc/libtorch_stable/sampler.cu contains two separately-instantiated top-k-per-row kernels with different shapes of input. The prefill one takes explicit per-row [rowStarts, rowEnds]:

csrc/libtorch_stable/sampler.cu:L549-L569 vLLM
template <int kNumThreadsPerBlock, bool useRadixSort>
static __global__ __launch_bounds__(kNumThreadsPerBlock) void topKPerRowPrefill(
    const float* logits, const int* rowStarts, const int* rowEnds,
    int* outIndices, int stride0, int stride1, const int topK,
    const int offsetIndex) {
  // The number of bins in the histogram.
  static constexpr int kNumBins = 2048;

  // The row computed by this block.
  int rowIdx = blockIdx.x + offsetIndex;

  // The range of logits within the row.
  int rowStart = rowStarts[rowIdx];
  int rowEnd = rowEnds[rowIdx];

The decode one instead derives the row's live length from a sequence-length tensor and the speculative fan-out next_n:

csrc/libtorch_stable/sampler.cu:L573-L596 vLLM
static __global__ __launch_bounds__(kNumThreadsPerBlock) void topKPerRowDecode(
    const float* logits, const int* seqLens, int* outIndices, int stride0,
    int stride1, const int topK, int next_n, int seqLensIs2D = 0,
    float* outLogits = nullptr, const int numBlocksToMerge = 0,
    const int* indices = nullptr) {
  // The number of bins in the histogram.
  static constexpr int kNumBins = 2048;

  // The row computed by this block.
  int rowIdx = blockIdx.x;

  // The range of logits within the row.
  int rowStart = 0;
  int batch_idx = rowIdx / next_n;
  int next_n_idx = rowIdx % next_n;

The launch configs diverge more than the kernels do. Decode branches on the row width: insertion sort below 12,288 columns, radix sort between there and 200,000, and a split-and-merge two-kernel scheme above that (csrc/libtorch_stable/sampler.cu:L717-L751). Prefill branches on the row count instead, running the first 12,288 blocks with the insertion-sort instantiation and any excess rows with the radix one:

csrc/libtorch_stable/sampler.cu:L846-L866 vLLM
  constexpr int kSortingAlgorithmThreshold = 12288;
  constexpr int kNumThreadsPerBlock = 512;
  const torch::stable::accelerator::DeviceGuard device_guard(
      logits.get_device_index());
  const cudaStream_t stream = get_current_cuda_stream();

  int numInsertionBlocks =
      std::min(static_cast<int>(numRows), kSortingAlgorithmThreshold);
  vllm::topKPerRowPrefill<kNumThreadsPerBlock, false>
      <<<numInsertionBlocks, kNumThreadsPerBlock, topK * sizeof(int32_t),
         stream>>>(logits.const_data_ptr<float>(),
                   rowStarts.const_data_ptr<int>(),
                   rowEnds.const_data_ptr<int>(),
                   indices.mutable_data_ptr<int>(), static_cast<int>(stride0),
                   static_cast<int>(stride1), static_cast<int>(topK), 0);

That is the shape argument in miniature. Prefill produces many short rows — one per token in the chunk, thousands of them, each covering only that token's causal window, so the grid is huge and per-row work is small. Decode produces few very long rows — one per sequence, spanning the whole context, so the grid is tiny and each block must be able to split its row across multiple blocks and merge. One kernel with runtime branches would carry both register footprints and get the worse occupancy of the two; two instantiations of a shared topKPerRowJob device function get each shape its own launch bounds and shared-memory budget.

Read carefully

These two kernels live in a file called sampler.cu, but at a556f3f they are not the token sampler. They are bound as top_k_per_row_prefill / top_k_per_row_decode (csrc/libtorch_stable/torch_bindings.cpp:L556-L565), and every in-tree caller is an attention caller: the Python wrappers in vllm/_custom_ops.py:L3055-L3096, the DeepSeek sparse-attention indexer that calls them (vllm/model_executor/layers/sparse_attn_indexer.py:L509-L512, L657-L660), its ROCm/AITER variant (vllm/v1/attention/ops/rocm_aiter_mla_sparse.py:L969-L985), and the tests. The token sampler's top-k runs through Triton or FlashInfer, never here. The prefill/decode split is still the right lesson about kernel shape — it is just teaching it from the attention indexer's top-k, not the sampler's.

§6

How production systems do it: seeds, greedy, and backends

Greedy is a separate path, not $T \to 0$

Both engines refuse to divide by zero, but they encode greedy differently. vLLM makes it a sampling type: SamplingParams.sampling_type returns GREEDY when temperature < 1e-5 (vllm/sampling_params.py:L745-L750), and __post_init__ then wipes the other knobs so they cannot interact:

vllm/sampling_params.py:L529-L534 vLLM
        if self.temperature < _SAMPLING_EPS:
            # Zero temperature means greedy sampling.
            self.top_p = 1.0
            self.top_k = 0
            self.min_p = 0.0
            self._verify_greedy_sampling()

The temperature tensor still gets a 0.0 in that row, and apply_temperature guards it: temp = torch.where(temp < _SAMPLING_EPS, 1.0, temp) (vllm/v1/sample/sampler.py:L235-L238). Then sample() computes both answers and selects per row with torch.where(temperature < _SAMPLING_EPS, greedy_sampled, random_sampled) (vllm/v1/sample/sampler.py:L298-L303) — unless the batch is homogeneous, in which case the all_greedy / all_random flags let it skip one branch entirely.

SGLang normalises greedy away at the parameter level instead:

python/sglang/srt/sampling/sampling_params.py:L143-L149 SGLang
        # Process some special cases
        if 0 <= self.temperature < _SAMPLING_EPS:
            # top_k = 1 means greedy sampling
            self.temperature = 1.0
            self.top_k = 1
        if self.top_k == -1:
            self.top_k = TOP_K_ALL  # whole vocabulary

TOP_K_ALL is 1 << 30 — the same neutral-value trick, with a sentinel larger than any vocabulary. Greedy becomes $k=1$, and is_all_greedy is all(top_k <= 1), which takes a plain torch.argmax. The encodings are semantically equivalent but not observationally identical: in SGLang a request that explicitly sets top_k=1 with temperature=0.7 counts as greedy for that predicate, so a batch in which every request does this takes the greedy branch, where the returned logprobs come from log_softmax(logits) with no temperature division (python/sglang/srt/layers/sampler.py:L143-L145). Note the gate is a whole-batch flag, not a per-row one — the same request in a batch containing one random row takes the sampling branch instead, and gets its temperature applied.

A shared global RNG is the wrong primitive

If two seeded requests share a batch, drawing from one global generator makes each request's output depend on who else was in the batch — which under continuous batching is nondeterministic by construction. Reproducibility requires the noise for row $i$ to be a pure function of $(\text{seed}_i, \text{position}_i, \text{token id})$ and nothing else.

vLLM's V1 path holds a dict[int, torch.Generator] keyed by batch index (vllm/v1/sample/metadata.py:L23), created once per request from its seed (vllm/v1/worker/gpu_model_runner.py:L1331-L1338), and fills the exponential noise row-by-row in a Python loop — the TODO(woosuk) in the quote above. It is correct and it is slow, and it is also why FlashInfer gets disabled the moment any seeded request appears: "FlashInfer 0.2.3+ does not support per-request generators. Falling back to PyTorch-native implementation." (vllm/v1/sample/ops/topk_topp_sampler.py:L164-L170).

Both projects have converged on the same fix: derive the noise from a hash instead of a stream. SGLang's multinomial_with_seed hashes (seed, position, column index) with murmur3 and turns the result into Gumbel noise in float64:

python/sglang/srt/layers/sampler.py:L709-L729 SGLang
    n, m = logprobs.shape
    seed = seed.to(torch.uint64)
    col_indices = torch.arange(m, device=logprobs.device)
    hashed = murmur_hash32(seed, positions, col_indices)

    # NOTE (sehoon): it is critical to keep gumbel noise calculation in float64 to avoid numerical instability.
    # keeping logprobs in float64 is less critical, but we found it's still safer to keep it in float64.
    x = hashed.to(torch.float64) / torch.iinfo(torch.uint32).max

    # x is a uniform sample in [0, 1]. get gumbel noise from it.
    # which is equivalent to -log(-log(x))
    # keep everything in in-place operations to avoid unnecessary memory allocations.
    # clamp both ends: x == 1 gives gumbel +inf (NaN at -inf logprobs); the cap is
    # the hash spacing so that bucket matches its neighbor instead of dominating
    x.log_().clamp_(min=torch.finfo(x.dtype).min, max=-(2.0**-32)).neg_()
    x.log_().neg_()  # -log(-log(x)) == gumbel noise

    # add gumbel noise to logprobs
    x.add_(logprobs.to(torch.float64))

    return torch.argmax(x, dim=1, keepdim=True)

vLLM's V2 runner does the same thing with Triton's counter-based RNG, and gives every request a seed — random if the user did not supply one — so there is only one code path (vllm/v1/worker/gpu/sample/states.py:L50-L54). The comment on gumbel_noised_argmax names the second payoff: "`keys` indexes the noise, so the same token draws the same noise wherever it appears; `pos` and `seed` place the draw in the request's stream, which is what lets a draft and its verification agree" (vllm/v1/worker/gpu/sample/gumbel.py:L97-L99) — that is the property §6.2's rejection sampler needs.

Seeding the noise does not make the whole step reproducible: the logits themselves shift with batch composition because reductions reassociate. That is the subject of §10.4; the sampler is one of the two sources it has to pin down. SGLang's --enable-deterministic-inference forces sampling_backend to pytorch for exactly this reason (python/sglang/srt/arg_groups/overrides.py:L2031-L2038).

§7

Worked trace: one batch through vLLM's sampler

Batch of four, Llama-3-8B, one decode step. Logits arrive as [4, 128256] bf16. This is the V1 runner's vllm/v1/sample/sampler.py, traced because it is the one whose stages map onto the operator algebra above one-for-one; on a default Llama-3-8B server the V2 runner runs instead (§6.1.3) and fuses several of these steps into the Triton kernels quoted in §6.1.4.

  1. GPUModelRunner calls Sampler.forward(logits, sampling_metadata).
  2. forward computes raw_logprobs = self.compute_logprobs(logits) first, if any row asked for logprobs — deliberately before penalties and temperature, so returned logprobs describe the model, not the sampler (vllm/v1/sample/sampler.py:L86-L94).
  3. logits = logits.to(torch.float32) — 16.4 MB in, 32.8 MB out at batch 64; 2.1 MB at batch 4.
  4. apply_logits_processors: allowed-token-ids mask (masked_fill_ with a [max_num_reqs, vocab] bool tensor), apply_bad_words, then every non-argmax-invariant logits processor, then apply_penalties. Each is skipped if its gate says no row needs it.
  5. sample(): all_greedy false and all_random false here, so it computes greedy_sampled = logits.argmax(dim=-1) for the whole batch — cheap, one read pass — and keeps it.
  6. apply_temperature divides in place, with the torch.where guard on the greedy rows.
  7. Argmax-invariant processors run: by default just MinPLogitsProcessor, which returns immediately if self.min_p_count == 0 (vllm/v1/sample/logits_processor/builtin.py:L102-L104).
  8. TopKTopPSampler.__call__ dispatches. On CUDA with FlashInfer available and no generators, forward_cudaflashinfer_sample. Otherwise forward_nativeapply_top_k_top_p (Triton at batch ≥ 8, PyTorch sort below) → softmaxrandom_sample.
  9. torch.where(temperature < 1e-5, greedy_sampled, random_sampled) merges the two answers, writing into greedy_sampled to reuse the buffer.
  10. If logprobs were requested, gather_logprobs runs torch.topk(logprobs, num_logprobs), gathers the sampled token's own logprob, and computes its rank with batched_count_greater_than — a full (x >= values).sum(-1) over the vocab (vllm/v1/sample/ops/logprobs.py:L25-L27).
  11. Return SamplerOutput(sampled_token_ids=sampled.unsqueeze(-1), logprobs_tensors=...), all still on device.

SGLang's path is shorter because more of it is fused into SamplingBatchInfo: ModelRunner.sampleModelRunner._preprocess_logits (grammar mask, logit bias, accumulated penalties; python/sglang/srt/model_executor/model_runner.py:L1745-L1760) → Sampler.forward, which runs its own _preprocess_logits (custom logit processors, then the env-gated NaN sanitisation above) and then either torch.argmax, or logits.div_(temperatures) then logits[:] = torch.softmax(logits, dim=-1) assigned back into the buffer, then _sample_from_probs (python/sglang/srt/layers/sampler.py:L207-L215). The destination is reused, but the right-hand-side softmax ordinarily allocates a result first. This syntax alone does not prove removal of a full temporary allocation.

§8

Beam search, and what logprobs cost

Beam search: still present in vLLM, absent from SGLang

Beam search maintains $w$ candidate sequences per request and expands all of them each step. That requires a per-request tree of hypotheses whose KV state forks — and forking KV fights everything Part 2 built. Under paged allocation (§2.2) a fork means block-level copy-on-write; under continuous batching it means $w$ rows appearing and disappearing together; under prefix caching it means the tree's shared prefix is a cache entry with $w$ live readers. It also multiplies the decode batch by $w$ for a single user's throughput. Meanwhile instruction tuning largely removed the payoff: for open-ended generation from an aligned model, the highest-likelihood continuation is usually not the best one.

At a556f3f, vLLM has not removed beam search — it has moved it out of the engine. There is no beam logic in vllm/v1/ at all. Instead vllm/entrypoints/generate/beam_search/ implements it as a client of the ordinary generate API, one token at a time:

vllm/entrypoints/generate/beam_search/offline.py:L115-L125 vLLM
        # generate 2 * beam_width candidates at each step
        # following the huggingface transformers implementation
        # at https://github.com/huggingface/transformers/blob/e15687fffe5c9d20598a19aeab721ae0a7580f8a/src/transformers/generation/beam_search.py#L534 # noqa
        base_sampling_params = SamplingParams(
            logprobs=2 * beam_width,
            max_tokens=1,
            temperature=temperature,
            detokenize=False,
            skip_clone=True,  # Internal beam search, safe to skip clone
        )

Every step is a fresh one-token request carrying the full candidate prefix, scored by top-$2w$ logprobs, ranked on the host, and re-submitted. The engine never learns what a beam is; prefix caching does the deduplication that KV forking would have done. BeamSearchParams is a separate struct from SamplingParams (vllm/sampling_params.py:L1246-L1261) precisely because it never reaches the sampler.

SGLang does not implement it at all. A case-insensitive grep for beam across python/sglang/ at 7d89325 returns exactly one hit, and it is a comment in a multimodal generation pipeline telling the caller not to set num_beams.

Logprobs are the most expensive optional feature in the sampler

Returning top-$n$ logprobs costs a log_softmax over the full vocab, a topk over the full vocab, and a rank computation that is another full-vocab reduction — three extra sweeps of the 131 MB tensor at batch 256, roughly 0.16 ms derived, on top of a 4.48 ms step. Worse, it does not fit the "gate on the set being empty" pattern, because both engines take the maximum $n$ across the batch and run one topk at that width: max_k = max(top_logprobs_nums); values, indices = logprobs.topk(max_k, dim=-1) (python/sglang/srt/layers/logprob_processor.py:L93-L94), and topk_logprobs, topk_indices = torch.topk(logprobs, num_logprobs, dim=-1) in vLLM (vllm/v1/sample/sampler.py:L335). One request asking for 20 logprobs makes the whole batch run a top-20. vLLM caps it at max_logprobs: int = Field(default=20, ge=-1) (vllm/config/model.py:L250) for that reason. Both engines also offer an explicit token-id list as a cheaper alternative — a gather instead of a topk.

§9

Pitfalls and war stories

NaN

Out-of-vocab token ids

An fp16 activation overflow produces NaN logits, and NaN in a top-k kernel is undefined behaviour that can return an index equal to vocab_size. SGLang can sanitise with torch.nan_to_num_(logits, nan=-1e30, posinf=1e30, neginf=-1e30), and explains the odd constant: dtype min/max would overflow to $\pm\infty$ once divided by temperature (python/sglang/srt/utils/async_probe.py:L66-L76). But the call returns before the nan_to_num_ unless SGLANG_SANITIZE_NAN_LOGITS is set, and that env var is EnvBool(False) (python/sglang/srt/environ.py:L1091). On a default server the NaN reaches the sampler.

Backend-dependent

Seeds can change sampler dispatch

Some paths reject explicit seeds or fall back from FlashInfer; others use counter-based GPU RNG. The quoted SGLang branch asserts that sampling seeds are unsupported for its FlashInfer backend. Record the selected runner, backend and kernel before attributing a reproducibility slowdown to a per-row Python loop.

Unsupported combination

min-p plus a seed, in SGLang

The cited sampler assertion/TODO is an implementation restriction, not a mathematical proof that unnormalized surviving probabilities break Gumbel sampling. Renormalization adds the same constant to every surviving log weight and cannot change argmax. Retain the runtime guard until the exact path is tested; its explanation requires more than that comment.

Rejected

min-p with speculative decoding

"The min_p and logit_bias sampling parameters are not yet supported with speculative decoding." (vllm/sampling_params.py:L992-L996). The rejection sampler needs a target distribution it can evaluate at the draft token; min-p's peak-relative threshold makes that awkward. Covered in §6.2.

Memory

Penalties allocate per-vocab state

SGLang's penalizers each hold a dense [batch, vocab] fp32 accumulator — 131.3 MB at batch 256, per penalizer (python/sglang/srt/sampling/penaltylib/frequency_penalty.py:L18-L22). They are only allocated when _is_required() is true, which is why enabling frequency_penalty on a large batch can move your KV budget.

-inf

Grammar masks break pivot statistics

The Triton top-k kernel estimates its pivot from mean and variance of a sample tile, and a grammar bitmask sets most of the row to $-\infty$. The kernel excludes them explicitly: "Exclude -inf values (e.g. from grammar bitmasks) from statistics to avoid NaN in pivot computation" (vllm/v1/sample/ops/topk_topp_triton.py:L134-L136). See §6.5.

Nucleus membership changes after renormalization

A mask can change the normalization and therefore the smallest top-p nucleus even when it removes no token from the old nucleus. Here p=0.8 retains the first two tokens before masking; removing the third raises the first token above the threshold. A constant log-normalizer, in contrast, cannot alter Gumbel argmax. This check isolates probability semantics, not a GPU sampler's RNG sequence.

Independent CPU reference; not an engine or GPU benchmark
import numpy as np

def nucleus(probs, threshold):
    order = np.argsort(-probs, kind="stable")
    count = np.searchsorted(np.cumsum(probs[order]), threshold) + 1
    return set(order[:count])

p = np.array([0.70, 0.15, 0.15])
masked = p * np.array([True, True, False])
masked /= masked.sum()
assert nucleus(p, 0.8) == {0, 1}
assert nucleus(masked, 0.8) == {0}
noise = np.array([0.2, -0.1, 0.5])
assert np.argmax(np.log(p) + noise) == np.argmax(np.log(p) - 7.0 + noise)
tied = np.array([2.0, 2.0, 0.0])
cold = np.exp((tied - tied.max()) / 0.001)
cold /= cold.sum()
np.testing.assert_allclose(cold, [0.5, 0.5, 0.0])
print("Nucleus, normalization and tied-temperature contracts pass.")
§10

Hands-on

Price the sampler by toggling its backend, holding everything else fixed.

vLLM: disable FlashInfer, then inspect the actual fallback shell
VLLM_USE_FLASHINFER_SAMPLER=0 vllm serve meta-llama/Meta-Llama-3-8B-Instruct --max-num-seqs 256
# then, in another shell
vllm bench serve --model meta-llama/Meta-Llama-3-8B-Instruct \
  --dataset-name random --random-input-len 512 --random-output-len 256 \
  --num-prompts 500 --max-concurrency 256

Compare greedy requests, top-p requests, and identical top-p requests with explicit seeds. Record runner V1/V2 and actual kernel names. Disabling FlashInfer does not force a PyTorch sort; Triton selection and counter-based V2 sampling are alternatives described above. A seed does not universally force a Python generator loop. Keep request/length distributions fixed and inspect the trace before attributing a TPOT difference to one sampler operation.

SGLang: compare sampling backends shell
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct --sampling-backend flashinfer
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct --sampling-backend pytorch
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct --enable-deterministic-inference

Run these long-lived servers one at a time, stopping each before reusing the device/port. The deterministic-inference mode can change model kernels as well as sampling, so its latency versus the PyTorch sampler alone does not isolate hash cost. Record actual dispatch and profile with the Lab 10 methodology; neither a fixed sampler percentage nor one expected bottleneck is established by these commands.

§11

Exercises

  1. Read the code. In vllm/v1/worker/gpu_input_batch.py, a request with top_k=0 is stored as top_k_cpu[i] = vocab_size. Find the two other places in the sampling stack that use a "neutral value" rather than a disabled flag, and say what would break if each used a sentinel like -1 instead.

  2. Derive. At batch 128 and $V = 128{,}256$, compute the fp32 logits size, the cost of one read-modify-write sweep at 3.35 TB/s, and the number of such sweeps that would make sampling 10% of a 4.48 ms decode step.

  3. Predict, then verify. A batch has 255 rows with top_k=0 and one row with top_k=50. Predict which vLLM code path runs and how much of the logits tensor it touches. Then read apply_top_k_top_p and InputBatch._make_sampling_metadata and check whether the 255 rows pay anything.

  4. Reason about semantics. A request sets temperature=1.0, top_p=0.9, min_p=0.05. Using the two code paths quoted in §6.1.3, argue whether vLLM and SGLang keep the same set of tokens, and which keeps more.

  5. Design. vLLM's seeded path fills noise with a Python loop over generators. Sketch a kernel that removes the loop, and state the one property it must have for §6.2's draft-verify agreement to hold.

Answers

1. top_p = 1.0 and repetition_penalty = 1.0 (and SGLang's TOP_K_ALL = 1 << 30). A -1 sentinel would require every kernel to branch before using the value, which costs a comparison per row and prevents the value from being used directly in the arithmetic — logits.div_(temp) and positions < top_ks both rely on the stored number being meaningful as-is.

2. $128 \times 128256 \times 4 = 65.7$ MB. One read+write sweep is $2 \times 65.7\,\text{MB} / 3.35\,\text{TB/s} = 39.2\ \mu\text{s}$. Ten percent of 4.48 ms is 448 µs, which is 11.4 sweeps. (Derived.)

3. top_k_reqs is non-empty, so no_top_k is false, so the top_k tensor is copied and passed. With batch 256 ≥ 8 the Triton kernel runs, one program per SM, and every row is swept — the 255 rows with $k = V$ take the if k < VOCAB_SIZE guard and skip the pivot search, but the kernel was still launched over them. In the Triton kernel they pay a launch, not a sweep; in the PyTorch fallback they would pay a full sort.

4. Not necessarily the same set. vLLM applies min-p to the logits first, so its top-p cumsum runs over a softmax that has already renormalised away the min-p-rejected mass and reaches 0.9 at a lower rank — vLLM keeps fewer or equal tokens. SGLang computes the top-p prefix against the full distribution and only then applies min-p. Even removing tokens outside the original nucleus can change the earlier cumulative threshold after renormalization.

5. Index the noise by (seed, position, token id) — a counter-based hash rather than a stateful stream — so the same request/position/token draws the same value regardless of batch composition or evaluation order. That is what makes a draft token's noise reproducible at verification time. Both multinomial_with_seed and gumbel_noised_argmax have exactly this shape.

§12

Key takeaways

  • Sampling is bandwidth, not arithmetic. Every operator has intensity well below 1 FLOP/byte against a ridge of 295, so the only lever is touching the $B \times V$ tensor fewer times. At batch 256 one read-modify-write sweep is 78 µs — 1.75% of the decode step's 4.48 ms floor.
  • Heterogeneity is solved by neutral values plus per-row early return, never by branching on request identity. $k = V$, $p = 1$, $\rho = 1$, $\alpha = 0$ are no-ops the kernel executes unconditionally; the newer vLLM kernels load the row's parameter first and return before touching the 513 KB logits row if it is neutral.
  • Operator order is part of the contract and the engines do not fully agree on it. vLLM applies min-p before top-k/top-p; SGLang applies it after. For a request that sets both min-p and top-p, the kept sets can differ — same parameters, different outputs, by construction.
  • Top-p needs an order, top-k needs only a threshold, and a full sort of 128k columns per row costs more than every other stage combined. Production kernels use pivot search or rejection loops and never sort; the PyTorch sort survives only as the small-batch and CPU fallback.
  • Reproducibility requires noise that is a pure function of (seed, position, token) — a shared stream makes a request's output depend on its batchmates. Both projects converged on hashed Gumbel noise, and the same property is what lets a speculative draft and its verification agree.
  • Beam search left the engine rather than being deleted: vLLM reimplements it above the API as repeated one-token requests scored by top-$2w$ logprobs, and SGLang has no beam search at all at 7d89325.
§13

Further reading

  • Holtzman et al., "The Curious Case of Neural Text Degeneration" (2019)arXiv:1904.09751. The nucleus-sampling paper; the motivation for top-p over top-k is the whole first half.
  • Park et al., "Qrita: High-performance Top-k and Top-p Algorithm for GPUs using Pivot-based Truncation and Selection"arXiv:2602.01518, cited by name in the header of vllm/v1/sample/ops/topk_topp_triton.py as the algorithm that kernel implements.
  • vLLM PR #26987github.com/vllm-project/vllm/pull/26987, referenced in topk_topp_sampler.py:L106-L107 for the PowerPC argmax-under-torch.compile fallback. A short, concrete example of how platform-specific sampler bugs get fixed.
  • FlashInfer sampling kernelsgithub.com/flashinfer-ai/flashinfer. The rejection-based top-k/top-p sampler both engines call into; read top_k_top_p_sampling_from_probs for the loop that avoids the sort.
  • OpenAI parameter detailsplatform.openai.com, the definition vLLM's penalty code links to directly and matches.
  • Thinking Machines, "Defeating Nondeterminism in LLM Inference" (2025)thinkingmachines.ai, the batch-invariance argument that §10.4 develops and that motivates SGLang's --enable-deterministic-inference.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px