A frontend-legal multi-request speculative workload can make vLLM produce an out-of-vocabulary recovered token equal to vocab_size, convert that value to -1 when choosing the next live token for a request, and then feed that -1 back into the next drafter input ids. On Qwen3 GPTQ this reaches the worker-side drafting / attention path and crashes the engine with a GPU device-side assert.
The same issue is reachable through the public gRPC request surface by sending a specific overlapping Generate / Abort sequence.
0.17.1Qwen3-0.6B-GPTQ-Int8 firstvocab_size boundary value.-1, but those padding entries are not the key fault by
themselves.-1.-1 back into the live next-step input-id
row for the request.The important distinction is:
-1 values in a speculative row can be ordinary padding151936 == vocab_size, and that live token is then converted into -1In simplified form, the bad transition looks like this:
sampled speculative row:
[151936, -1, -1, -1, ...]
At this point, the trailing -1 values are only padding. The critical problem
is that the first position holds 151936, which is out of vocabulary and is
being treated as the request's real next token.
Then vLLM prepares the next-token buffer:
next_token_ids:
[-1, ...]
Finally, that converted -1 is written back into the live model input ids:
input_ids_after:
[-1, 0, 0, 0, ...]
The crash happens because the live next token became -1 and was later consumed by the drafting / embedding / attention path, not merely because the speculative row contained padded -1 entries.
SamplingParams
features such as structured outputs, stop, bad_words, min_tokens, and
streaming overlap. No malformed token-id list is required at the request
boundary.# vllm/v1/sample/rejection_sampler.py
def sample_recovered_tokens(...):
recovered_token_ids = torch.empty_like(draft_token_ids)
sample_recovered_tokens_kernel[(batch_size, max_spec_len)](...)
return recovered_token_ids
On the verified Qwen3 run, the recovered-token trace shows
recovered_token_ids[0] = 151936, which is exactly vocab_size for this
checkpoint.# vllm/v1/spec_decode/eagle.py
def prepare_next_token_ids_padded(...):
...
eagle_prepare_next_token_padded_kernel[grid](
sampled_token_ids,
discard_request_mask,
backup_tokens_gpu,
next_token_ids,
valid_sampled_tokens_count,
gpu_input_batch.vocab_size,
...
)
return next_token_ids, valid_sampled_tokens_count
In the verified trace, this step receives a sampled row beginning with
151936, followed by -1 padding. The important point is that 151936
occupies the first live token position for the request. This step then
produces next_token_ids[0] = -1, meaning the live next token for the
request has been converted to -1.next_token_ids back into the live input-id buffer.
# vllm/v1/spec_decode/eagle.py
def set_inputs_first_pass(...):
...
self.input_ids[token_indices_to_sample] = next_token_ids
In the verified trace, this produces input_ids_after[0] = -1.# vllm/model_executor/models/qwen2.py
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.embed_tokens(input_ids)
In the verified trace, this is the first point where the converted -1
becomes visible as a real model input. The bug is not merely that the
sampled speculative row contained padding -1; the bug is that the live
next token for the request became -1 and was written back into input ids.CUDA error: device-side assert triggered,
for example under flash_attn_varlen_func(...).repro_g4_recovered_minus1_local.py is a standalone local reproducer.
VLLM_POC_G4_MODEL or the built-in
/path/to/qwen3 placeholderEngineCore directly without any external helper dependencyrequest_payloads.jsonrepro_config.jsontimeline.jsonresponses.jsonerror.txtrecovered_chain_trace.jsonlrecovered_chain_trace.jsonl is the key attribution artifact. It records the
recovered-token chain directly from the standalone reproducerrepro_g4_recovered_minus1_grpc.py is a standalone public gRPC reproducer.
VLLM_POC_G4_MODEL or the built-in
/path/to/qwen3 placeholdervllm.entrypoints.grpc_server processGenerate and Abort RPCsGenerate probe request to
confirm that later gRPC requests also fail after the worker diesrequest_payloads.jsontimeline.jsonserver_command.jsonresponses.jsonpost_crash_probe.jsonserver.stdout.logserver.stderr.logLocal repro typically ends with:
sample_recovered_tokens_return -> recovered_token_ids[0] = 151936prepare_next_token_ids_padded -> next_token_ids[0] = -1set_inputs_first_pass -> input_ids_after[0] = -1embed_input_ids_out_of_range -> input_ids[0] = -1CUDA error: device-side assert triggeredgRPC repro typically ends with:
INTERNAL: EngineCore encountered an issue. See stack trace (above) for the root cause.CUDA error: device-side assert triggeredThis demonstrates that the issue is reachable through the public gRPC request surface, not only through a local reproducer.
sample_recovered_tokens_return:
recovered_token_ids = [151936, ...]
vocab_size = 151936
prepare_next_token_ids_padded:
sampled_token_ids_head = [[151936, -1, -1, ...], ...]
next_token_ids = [-1, ...]
set_inputs_first_pass:
input_ids_after = [-1, 0, 0, 0, ...]
embed_input_ids_out_of_range:
input_ids = [-1, 0, 0, 0, ...]
torch.AcceleratorError: CUDA error: device-side assert triggered
...
vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause.
...
Error in Generate for request post_crash_probe
vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause.
This is a speculative-decoding state-handling bug, not an invalid frontend token-id input bug.
The root cause is that a recovered speculative token can become equal to vocab_size, then be selected as the live next token for a request, then be converted to -1, and that converted -1 is still written back into live drafter input ids and later consumed by the drafting / embedding / attention path.
For the Qwen3 checkpoint used here:
151936 == vocab_sizeThis value should be described as the model vocab_size boundary value, not as a legal token id.
The attached bundle for this report should contain:
repro_g4_recovered_minus1_local.pyrepro_g4_recovered_minus1_grpc.pyThese two standalone scripts are sufficient to reproduce the issue and its public gRPC reachability.
A fix for this vulnerability has been merged in: https://github.com/vllm-project/vllm/pull/44744