vLLM Explained

Sampling and logits processors in Model Runner V2

AdvancedvLLM conceptsabout 10 min read + 4 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

The sampler copies the logits to FP32 only when needed and runs a fixed chain of processors. Then it draws one token per row.

The idea#

Sampling picks the next token from the logits. Temperature, top-k and top-p shape the distribution, and a seed makes the random draw repeatable (Sampling: temperature, top-k, top-p, seeds). This page shows how vLLM's sampler does these steps for a whole batch, and where your own code can change the logits.

Each request brings its SamplingParams. When a request enters the batch, the sampler writes its settings into per-slot GPU buffers, one row per request slot. In every step, it works on all logits rows at once. First come the logits processors (logit bias, penalties, bad words, then yours). Then come temperature, min_p, top-k and top-p, and then the draw. A stage that no request in the batch uses is skipped, without a kernel launch.

An everyday comparison. Think of a mail sorting line. Each letter (a logits row) passes the same stations in a fixed order: stamps, weight check, address check, then the bin. Each letter carries a slip that says which stations apply to it. The comparison stops being true in one place. vLLM runs a whole tray through each station at once, and a station turns on when one letter needs it.

The picture#

Figure: the sampler of Model Runner V2. Your processors run after the built-in bias, penalty and bad-word stages and before temperature (panel 2). The draw takes one of two paths for the whole batch (panel 3).
Open full size ↗

Read the panels from top to bottom. Teal stages are built in, violet is where custom processors run, and emerald is the token draw. The dashed arrow in panel 3 means "or": one batch takes one path.

In the code#

Step 1: the sampling parameters of a request#

The OpenAI server turns the request into a SamplingParams object. Unset fields take the server default, which comes from the model's generation_config.json with the default --generation-config auto (The OpenAI-compatible API). These fields change the sampler:

FieldDefaultStage
temperature1.0 (vllm/sampling_params.py:308)scaling; 0 means greedy
top_p, top_k, min_p1.0, 0 (all), 0.0 (vllm/sampling_params.py:315-321)filtering
seedNone (vllm/sampling_params.py:325)Gumbel noise; forces the non-fused path
presence_penalty, frequency_penalty, repetition_penalty0.0, 0.0, 1.0 (vllm/sampling_params.py:296-304)PenaltiesState
min_tokens0 (vllm/sampling_params.py:339)LogitBiasState masks stop tokens
logit_bias, allowed_token_idsNone (vllm/sampling_params.py:409-412)LogitBiasState
bad_wordsNone (vllm/sampling_params.py:420)BadWordsState
thinking_token_budgetNone (vllm/sampling_params.py:427)forcing the end of thinking
logprobs, prompt_logprobsNone (vllm/sampling_params.py:342-352)extra outputs
extra_argsNone (vllm/sampling_params.py:415)free-form; custom processors read it

A temperature below 1e-5 means greedy: vLLM resets top_p, top_k and min_p and requires n = 1 (vllm/sampling_params.py:615-620, vllm/sampling_params.py:769-773). In the OpenAI API, vllm_xargs becomes extra_args (vllm/entrypoints/openai/chat_completion/protocol.py:770).

Step 2: per-slot state, written once per request#

The sampler is built in load_model, only on the last pipeline rank and only for generative models (vllm/v1/worker/gpu/model_runner.py:463-467). Its constructor fixes the list of processors:

        self.sampling_states = SamplingStates(max_num_reqs, vocab_size)

        lp_req_state = LogitsProcRequestState.from_request_state(req_states)
        self.penalties_state = PenaltiesState(vllm_config, lp_req_state)
        logit_bias_state = LogitBiasState(vllm_config, lp_req_state)
        bad_words_state = BadWordsState(vllm_config, lp_req_state)

        # List order is pipeline order: bias adds, penalties scale, so the
        # two do not commute.
        self.logits_processors: list[LogitsProcessor] = [
            logit_bias_state,
            self.penalties_state,
            bad_words_state,
            *custom_logits_processors,
        ]

        self.logprob_token_ids_state = LogprobTokenIdsState(max_num_reqs, device)
        self.thinking_budget_state = ThinkingBudgetState(
            req_states, vllm_config.reasoning_config
        )
        self.trace_replay_state = (
            TraceReplayState(req_states) if enable_trace_replay else None
        )
        self.needs_logits_processing = np.zeros(max_num_reqs, dtype=bool)
        self.num_speculative_tokens = num_speculative_tokens
        self.return_sampling_mask = return_sampling_mask
        self.use_flashinfer = (
            not return_sampling_mask and flashinfer_sampler_supported()
        )

When the model runner adds a new request, the sampler calls add_request(slot, params) on every stage. Each stage returns whether it changes the logits of this request; the OR of all answers is stored per slot (vllm/v1/worker/gpu/sample/sampler.py:96-107). The sampling state itself returns true only for a non-zero temperature together with a temperature other than 1, or a set min_p, top-k or top-p:

    def add_request(self, req_idx: int, sampling_params: SamplingParams) -> bool:
        temperature = sampling_params.temperature
        top_k = sampling_params.top_k
        top_p = sampling_params.top_p
        min_p = sampling_params.min_p

        self.temperature.np[req_idx] = temperature
        self.top_p.np[req_idx] = top_p
        if top_k <= 0 or top_k > self.vocab_size:
            top_k = self.vocab_size
        self.top_k.np[req_idx] = top_k
        self.min_p.np[req_idx] = min_p

        seed = sampling_params.seed
        self.seeds_set[req_idx] = seed is not None
        if seed is None:
            seed = np.random.randint(_NP_INT64_MIN, _NP_INT64_MAX)
        self.seeds.np[req_idx] = seed

        num_logprobs = sampling_params.logprobs
        if num_logprobs is None:
            num_logprobs = NO_LOGPROBS
        elif num_logprobs == -1:
            num_logprobs = self.vocab_size
        self.num_logprobs[req_idx] = num_logprobs

        return temperature != 0.0 and (
            temperature != 1.0
            or min_p != 0.0
            or top_k != self.vocab_size
            or top_p != 1.0
        )

The writes are staged on the CPU. The model runner calls sampler.apply_staged_writes() once per step, which copies them to the GPU buffers (vllm/v1/worker/gpu/model_runner.py:1251-1255, vllm/v1/worker/gpu/sample/sampler.py:109-116).

Step 3: the processing chain#

In each step the model runner computes the logits for the sampled rows and applies the grammar bitmask (Structured outputs: JSON schemas, grammars and token bitmasks in vLLM). Then the sampler runs apply_sampling_params:

        if not np.any(self.needs_logits_processing[idx_mapping_np]):
            return logits

        # Copy logits to a new FP32 tensor.
        logits = torch.empty_like(logits, dtype=torch.float32).copy_(logits)

        ctx = LogitsContext(
            expanded_idx_mapping=expanded_idx_mapping,
            idx_mapping=idx_mapping,
            idx_mapping_np=idx_mapping_np,
            expanded_local_pos=expanded_local_pos,
            input_ids=input_ids,
            pos=pos,
            seq_lens_upper_bound_np=seq_lens_upper_bound_np,
        )

        # Apply logits processors (native + any custom).
        for processor in self.logits_processors:
            logits = processor.apply(logits, ctx)

        # Forcing runs last so no stage can overwrite the forced end marker
        # or weaken it by scaling.
        self.thinking_budget_state.apply(logits, ctx)

        # Apply temperature in place.
        self.sampling_states.apply_temperature(
            logits, expanded_idx_mapping, idx_mapping_np
        )

        # Apply min_p in place.
        self.sampling_states.apply_min_p(logits, expanded_idx_mapping, idx_mapping_np)

        if skip_top_k_top_p:
            return logits

        # Apply top_k and/or top_p. This might or might not return a new tensor.
        return self.sampling_states.apply_top_k_top_p(
            logits, expanded_idx_mapping, idx_mapping_np
        )
  1. Lines 238-239: if no request in the batch needs processing, the raw logits go straight to the draw. There is no copy.
  2. Line 242: else the logits are copied into a new FP32 tensor, [rows, vocab].
  3. Lines 255-256: the processors run in list order. LogitBiasState does allowed token ids, logit_bias and min_tokens in one kernel. PenaltiesState does the three penalties. BadWordsState masks the last token of a bad word when the earlier ones match. Then come the custom processors.
  4. Line 260: thinking-budget forcing runs after all processors, so no processor can overwrite it (the code comment at lines 258-259).
  5. Lines 263-268: temperature and min_p, in place. Each skips its kernel when no request in the batch uses it (vllm/v1/worker/gpu/sample/states.py:87-103).

The order matters. The code comment says that bias adds and penalties scale, "so the two do not commute" (vllm/v1/worker/gpu/sample/sampler.py:69-70).

Step 4: the draw - fused FlashInfer or Gumbel#

        top_k, top_p, has_topp_only_rows = self.sampling_states.get_top_k_top_p(
            expanded_idx_mapping, idx_mapping_np, logits.shape[-1]
        )
        # Don't use a fused sampler if no requests use top_k/top_p, if there are
        # any greedy requests or per-request seeds, or if post-processed
        # logprobs need to be returned for any requests.
        fused_sampler_eligible = not (
            (top_k is None and top_p is None)
            or (return_logprobs and self.logprobs_mode in PROCESSED_LOGPROBS_MODES)
            or self.sampling_states.any_greedy(idx_mapping_np)
            or self.sampling_states.any_explicit_seed(idx_mapping_np)
        )
        use_fused_sampler = (
            self.use_flashinfer or self.use_xpu_sampler
        ) and fused_sampler_eligible

        return self._sample_random(
            processed_logits,
            expanded_idx_mapping,
            idx_mapping_np,
            pos,
            top_k,
            top_p,
            has_topp_only_rows,
            use_fused_sampler,
        )

    def _sample_random(
        self,
        processed_logits: torch.Tensor,
        expanded_idx_mapping: torch.Tensor,
        idx_mapping_np: np.ndarray,
        pos: torch.Tensor,
        top_k: torch.Tensor | None,
        top_p: torch.Tensor | None,
        has_topp_only_rows: bool,
        use_fused_sampler: bool,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        if use_fused_sampler:
            if self.use_flashinfer:
                sampled = flashinfer_sample(processed_logits, top_k, top_p).to(
                    torch.int64
                )
            else:  # Use XPU sampler
                sampled, _ = xpu_sample(processed_logits, top_k, top_p)
        else:
            processed_logits = apply_top_k_top_p(
                processed_logits, top_k, top_p, has_topp_only_rows=has_topp_only_rows
            )
            sampled = gumbel_sample(
                processed_logits,
                expanded_idx_mapping,
                self.sampling_states.temperature.gpu,
                self.sampling_states.seeds.gpu,
                pos,
                apply_temperature=False,
                is_drafting=False,
                use_fp64=self.use_fp64_gumbel,
            )

The conditions are for the whole batch. One greedy or seeded request sends every request of that step to the fallback path.

Step 5: seeds without generators#

Model Runner V2 keeps no torch.Generator per request. The Gumbel noise is a hash of the request's seed, the token position and the token id (vllm/v1/worker/gpu/sample/gumbel.py:197-209). A request without a seed gets a random 64-bit seed when it enters the batch (vllm/v1/worker/gpu/sample/states.py:54-58). So the noise of a seeded request does not depend on the other rows of the batch (derived from the hash inputs above). Its logits must still be the same; see Determinism and batch invariance.

Step 6: custom logits processors#

A custom processor subclasses the Model Runner V2 LogitsProcessor:

class LogitsProcessor(ABC):
    """Custom logits processor for Model Runner V2.

    Per-request state is keyed by the request slot index; slots are recycled
    through a free list, so per-slot state must be fully (re)initialized in
    ``add_request()``.

    ``apply()`` runs after the built-in bias, penalty, bad-words and grammar
    stages and before temperature, min_p and top-k/top-p, so it sees unscaled
    logits and must not re-inflate grammar-masked tokens. Thinking-budget
    forcing runs after ``apply()`` and wins over its edits for requests whose
    budget is exhausted.

    State that is constant for a request belongs in ``__init__()`` or
    ``add_request()``; ``apply()`` receives only what changes per step.
    """

The methods are validate_params (class method, at admission), add_request(req_idx, params), apply_staged_writes() and apply(logits, ctx) (vllm/v1/worker/gpu/sample/logits_processor/interface.py:118-158). ctx is a LogitsContext: the slot of each row, the positions, the input ids and a CPU bound of the sequence lengths (vllm/v1/worker/gpu/sample/logits_processor/interface.py:63-89). Rows are reordered every step, so a processor must find its rows through ctx.expanded_idx_mapping.

There are two sources, and the loader puts entry-point plugins first (vllm/v1/worker/gpu/sample/logits_processor/loader.py:136-146):

  • the entry-point group vllm.logits_processors, which VLLM_PLUGINS does not filter;
  • --logits-processors (ModelConfig.logits_processors), with "module:Class" strings (vllm/config/model.py:407).

Hooks in the engine core and the workers (Step 3) covers loading, errors and the front-end check. The repository has a full example, a DRY repetition penalty that reads extra_args (examples/features/logits_processor/dry.py:659-688).

The Model Runner V2 interface has no "argmax invariant" flag. The legacy V1 interface has is_argmax_invariant, and a BatchUpdate object with the added, removed and moved rows of the batch. Model Runner V2 rejects such classes (vllm/v1/sample/logits_processor/interface.py:37-40, vllm/v1/sample/logits_processor/interface.py:86-87, vllm/v1/worker/gpu/sample/logits_processor/loader.py:67-72).

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

Worked example: temperature 0.6, top_p 0.9, repetition_penalty 1.1, seed 7

Request (example), alone in the batch on an H100, Llama-3.1-8B (vocabulary 128,256):

{"model": "meta-llama/Llama-3.1-8B-Instruct",
 "messages": [{"role": "user", "content": "Name three rivers."}],
 "temperature": 0.6, "top_p": 0.9, "seed": 7,
 "repetition_penalty": 1.1}

At add_request (once):

  1. SamplingStates: temperature 0.6, top_p 0.9, top_k 0 → 128,256 (no limit), min_p 0, seed 7 with seeds_set = True. It returns True, because 0.6 is not 0 or 1.
  2. LogitBiasState: no allowed ids, no bias, min_tokens 0 → False.
  3. PenaltiesState: repetition 1.1 → True. It queues the request for a bincount of its prompt into prompt_bin_mask (vllm/v1/worker/gpu/sample/penalties.py:53-82).
  4. BadWordsState: no bad words → False. No custom processors, no thinking budget.

In each decode step:

  1. Logits [1, 128256] BF16 → copy to FP32: 513,024 bytes.
  2. Bias stage: no request in the batch uses it → skipped.
  3. Penalties: take each token that is in the prompt or the output so far. Its logit is divided by 1.1 if positive, else multiplied by 1.1 (vllm/v1/worker/gpu/sample/penalties.py:174-177). A logit of 5.5 becomes 5.0; a logit of -2.0 becomes -2.2.
  4. Bad words: skipped. Temperature: all logits divided by 0.6. min_p: skipped.
  5. Top-p is set, but the request has a seed. So the fused path is not eligible (any_explicit_seed), even on an H100. vLLM applies top-p itself and runs the Gumbel sampler with seed 7 and the position of the token.
  6. After the draw, post_update adds the new token to output_bin_counts, so the next step penalises it too (vllm/v1/worker/gpu/input_batch.py:588-602).

Without "seed": 7, the same request would take the fused FlashInfer path on an H100. Penalties and temperature still run first, on the FP32 copy.

Deep dive

logprobs and prompt_logprobs: what they return and what they cost

Deep dive

Buffers, shapes and memory

BufferShape and typeWhere
temperature, top_p, min_pfloat32 [max_num_reqs]UVA-backed (vllm/v1/worker/gpu/sample/states.py:22-26)
top_kint32 [max_num_reqs], filled with the vocabulary sizevllm/v1/worker/gpu/sample/states.py:31-35
seedsint64 [max_num_reqs]vllm/v1/worker/gpu/sample/states.py:26
prompt_bin_maskint32 [max_num_reqs, cdiv(vocab, 32)], one bit per tokenvllm/v1/worker/gpu/sample/penalties.py:42-45
output_bin_countsint32 [max_num_reqs, vocab]vllm/v1/worker/gpu/sample/penalties.py:46-50
allowed ids, logit-bias ids[max_num_reqs, 1024] each; more raises ValueErrorvllm/v1/worker/gpu/sample/logit_bias.py:20-22, vllm/v1/worker/gpu/sample/logit_bias.py:63-70

Worked number. output_bin_counts exists even when no request uses penalties. The code comment warns that it can take "GBs of GPU memory" (vllm/v1/worker/gpu/sample/penalties.py:46-50). With max_num_seqs = 1024 and a 128,256-token vocabulary: 1,024 × 128,256 × 4 bytes = 525 MB. With a 151,936-token vocabulary: 622 MB. This memory is taken before the KV cache gets its share.

Other limits: VLLM_MAX_NUM_BAD_WORDS 128 and VLLM_MAX_BAD_WORDS_TOTAL_TOKENS 1024 (vllm/envs.py:1152-1159); stop tokens for min_tokens at most 128 (vllm/v1/worker/gpu/sample/logit_bias.py:22, vllm/v1/worker/gpu/sample/logit_bias.py:98-104).

Deep dive

Built-in logits-processor-like states

StateRequest fieldWhat it does
LogitBiasStateallowed_token_ids, logit_bias, min_tokensKeeps only allowed ids, adds biases, and sets stop tokens to -inf until prompt_len + min_tokens (vllm/v1/worker/gpu/sample/logit_bias.py:57-116).
PenaltiesStatethree penaltiesRepetition scales by sign; frequency and presence subtract (vllm/v1/worker/gpu/sample/penalties.py:174-182).
BadWordsStatebad_wordsUp to VLLM_MAX_NUM_BAD_WORDS words per request, 128 by default (vllm/v1/worker/gpu/sample/bad_words.py:46-52, vllm/envs.py:1152-1154).
ThinkingBudgetStatethinking_token_budgetForces the end of thinking after the budget. Active only when the reasoning config has start, end and natural-end token ids (vllm/v1/worker/gpu/sample/thinking_budget.py:34-51).
min_pmin_pPart of SamplingStates, after temperature (vllm/v1/worker/gpu/sample/states.py:94-103).
watermarkingwatermarkingNot a processor: --watermark-config swaps the whole sampler for GPUWatermarkSampler (vllm/v1/worker/gpu/model_runner.py:493-507).

With speculative decoding, min_p and logit_bias are not supported; a request with them gets a validation error (vllm/sampling_params.py:1185-1197).

What can go wrong#

Why it matters. The sampler runs in every step for every request. Know which stage runs and which settings force the slower path. Then you can choose good server defaults, and write processors that cost nothing for other requests.

Check yourself#

  1. 1
    In which order do the built-in processors run?
    easy
    Show answer

    LogitBiasState, PenaltiesState, BadWordsState, then the custom processors (vllm/v1/worker/gpu/sample/sampler.py:71-76). Temperature and min_p come after them (vllm/v1/worker/gpu/sample/sampler.py:262-268).

  2. 2
    A batch has 63 requests with temperature 0.7 and top_p 0.95, and one request with temperature 0. Which sampler draws the tokens on an H100?
    medium
    Show answer

    The Gumbel fallback for all 64. One greedy request makes any_greedy true, so the fused path is not eligible for the batch (vllm/v1/worker/gpu/sample/sampler.py:307-315). The greedy request gets a plain argmax inside the Gumbel kernel (vllm/v1/worker/gpu/sample/gumbel.py:182-187).

  3. 3
    Your processor changes the logits only for requests with extra_args["boost"]. Why should add_request return False for the others?
    medium
    Show answer

    The sampler stores the OR of all stages per slot. If any stage says True, the step copies the logits to FP32 and runs the chain (vllm/v1/worker/gpu/sample/sampler.py:101-103, vllm/v1/worker/gpu/sample/sampler.py:238-242). The default add_request returns True (vllm/v1/worker/gpu/sample/logits_processor/interface.py:126-134).

  4. 4
    Two servers run the same seeded request (seed 7), one alone and one in a batch of 200. Why can the sampled tokens match, and what must also be true?
    hard
    Show answer

    The Gumbel noise depends only on the seed, the position and the token id (vllm/v1/worker/gpu/sample/gumbel.py:197-209), not on the other rows, so the noise is the same. The logits must also be the same in both batches, which needs batch-invariant kernels.

  5. 5
    How much GPU memory does output_bin_counts take with max_num_seqs = 512 and a 151,936-token vocabulary, and does it depend on penalty use?
    hard
    Show answer

    512 × 151,936 × 4 bytes = 311 MB. It is allocated in the constructor, whether or not a request uses penalties (vllm/v1/worker/gpu/sample/penalties.py:46-50).