class Qwen4ExpPLELayer(nn.Module, MambaBase):
def __init__(
self,
config: Qwen4ExpTextConfig,
vllm_config: VllmConfig,
layer_idx: int = 0,
ple_dense_layer_id: int | None = None,
prefix: str = "",
) -> None:
super().__init__()
model_config = vllm_config.model_config
cache_config = vllm_config.cache_config
quant_config = vllm_config.quant_config
self.model_config: ModelConfig = model_config
self.cache_config: CacheConfig = cache_config
self.layer_idx = layer_idx
self.ple_dense_layer_id = (
int(ple_dense_layer_id)
if ple_dense_layer_id is not None
else int(layer_idx)
)
self.prefix = prefix
self.hidden_size = int(config.hidden_size)
self.hc_count = config.hc_count
self.hc_hidden_size = self.hidden_size * self.hc_count
self.conv_kernel_size = int(config.ple_conv_kernel_size)
self.short_conv_dilation = int(config.ngram_size)
self.conv_state_len = (self.conv_kernel_size - 1) * self.short_conv_dilation
self.num_spec_tokens = vllm_config.num_speculative_tokens
self.activation = "silu"
self.ple_embedding: nn.Module = Qwen4ExpNGramEmbedding(
config,
int(config.ple_embed_dim),
self.ple_dense_layer_id,
vllm_config.scheduler_config.max_num_batched_tokens,
vllm_config.scheduler_config.max_num_seqs,
f"{prefix}.ple_embedding",
prefix,
)
self.key_proj = ReplicatedLinear(
int(config.ple_embed_dim),
self.hc_hidden_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.key_proj",
)
self.value_proj = ReplicatedLinear(
int(config.ple_embed_dim),
self.hidden_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.value_proj",
)
norm_args = (
self.hc_hidden_size,
config.rms_norm_eps,
self.hidden_size,
model_config.dtype,
)
self.norm_key = Qwen4ExpPLEGroupedNorm(*norm_args)
self.norm_query = Qwen4ExpPLEGroupedNorm(*norm_args)
self.norm_conv = Qwen4ExpPLEGroupedNorm(*norm_args)
self.conv1d = nn.Conv1d(
self.hc_hidden_size,
self.hc_hidden_size,
self.conv_kernel_size,
groups=self.hc_hidden_size,
padding=self.conv_state_len,
dilation=self.short_conv_dilation,
bias=False,
dtype=model_config.dtype,
)
nn.init.zeros_(self.conv1d.weight)
self.conv1d.weight._no_reinit = True
self.kv_cache = (torch.tensor([]),)
compilation_config = get_current_vllm_config().compilation_config
if prefix in compilation_config.static_forward_context:
raise ValueError(f"Duplicate layer name: {prefix}")
compilation_config.static_forward_context[prefix] = self
@property
def mamba_type(self) -> MambaAttentionBackendEnum:
return MambaAttentionBackendEnum.SHORT_CONV
@property
def is_kv_cache_tp_replicated(self) -> bool:
return True
def get_attn_backend(self) -> type[PleShortConvAttentionBackend]:
return PleShortConvAttentionBackend
def get_state_dtype(self) -> tuple[torch.dtype, ...]:
return MambaStateDtypeCalculator.short_conv_state_dtype(
self.model_config.dtype, self.cache_config.mamba_cache_dtype
)
def get_state_shape(self) -> Sequence[tuple[int, ...]]:
return MambaStateShapeCalculator.short_conv_state_shape(
tp_world_size=1,
intermediate_size=self.hc_hidden_size,
conv_kernel=self.conv_state_len + 1,
num_spec=self.num_spec_tokens,
)
def _apply_norm(
self, norm: Qwen4ExpPLEGroupedNorm, hidden_states: torch.Tensor
) -> torch.Tensor:
shape = hidden_states.shape
return norm(hidden_states.flatten(-2)).reshape(shape)
def _short_conv_fallback(self, inputs: torch.Tensor) -> torch.Tensor:
# Profiling / CUDA graph capture only; conv state is not updated.
inputs_t = inputs.transpose(0, 1).unsqueeze(0)
output = self.conv1d(inputs_t)[..., : inputs_t.size(-1)]
return F.silu(output).squeeze(0).transpose(0, 1)
def _short_conv_dilated_decode_batched(
self,
x_d: torch.Tensor,
conv_state: torch.Tensor,
conv_weights: torch.Tensor,
state_indices_tensor_d: torch.Tensor,
has_initial_states_d: torch.Tensor | None,
) -> torch.Tensor:
state_indices = state_indices_tensor_d.to(
device=conv_state.device, dtype=torch.int64
)
# FULL cudagraph padded decode rows use NULL_BLOCK_ID. Remap them to
# slot 0 for a safe gather, then zero output and skip write-back.
valid_state = state_indices != NULL_BLOCK_ID
state_indices = torch.where(
valid_state, state_indices, torch.zeros_like(state_indices)
)
if has_initial_states_d is None:
has_initial_state = valid_state
else:
if has_initial_states_d.numel() < state_indices_tensor_d.numel():
raise ValueError(
"has_initial_states_d size mismatch: "
f"got {has_initial_states_d.numel()}, "
f"need >= {state_indices_tensor_d.numel()}."
)
has_initial_state = has_initial_states_d[
: state_indices_tensor_d.numel()
].to(device=conv_state.device, dtype=torch.bool)
has_initial_state = has_initial_state & valid_state
cached_state = conv_state.index_select(0, state_indices)
state = cached_state[..., : self.conv_state_len].to(x_d.dtype)
if self.conv_state_len > 0:
initial_state = torch.where(
has_initial_state.view(-1, 1, 1),
state,
torch.zeros_like(state),
)
history = torch.cat((initial_state, x_d.unsqueeze(-1)), dim=-1)
else:
history = x_d.unsqueeze(-1)
conv_output = F.conv1d(
history,
conv_weights.unsqueeze(1).contiguous(),
groups=history.size(1),
dilation=self.short_conv_dilation,
).squeeze(-1)
output = F.silu(conv_output)
output = output * valid_state.view(-1, 1).to(output.dtype)
if self.conv_state_len > 0:
next_state = history[..., -self.conv_state_len :]
# Padded rows are remapped to the reserved null slot. Preserve its
# existing value while writing the new states for valid rows.
existing_base_state = cached_state[..., : self.conv_state_len]
safe_next_state = torch.where(
valid_state.view(-1, 1, 1),
next_state.to(conv_state.dtype),
existing_base_state,
)
cached_state[..., : self.conv_state_len] = safe_next_state
conv_state.index_copy_(0, state_indices, cached_state)
return output
def _short_conv_dilated_prefill_batched(
self,
x_p: torch.Tensor,
metadata: PleShortConvAttentionMetadata,
conv_state: torch.Tensor,
conv_weights: torch.Tensor,
state_indices_tensor_p: torch.Tensor,
num_prefills: int,
num_decode_tokens: int,
num_prefill_tokens: int,
) -> torch.Tensor:
# ``non_spec_query_start_loc`` covers the non-spec (decode + prefill)
# requests and equals ``query_start_loc`` when spec-decode is inactive.
non_spec_query_start_loc = metadata.non_spec_query_start_loc
if non_spec_query_start_loc is None:
raise ValueError("query_start_loc is required for prefill short-conv")
query_start_loc_p = (
non_spec_query_start_loc[-num_prefills - 1 :] - num_decode_tokens
)
# The metadata builder guarantees that the prefill query offsets start
# at 0 and end at num_prefill_tokens. Avoid reading those values here,
# since doing so would force a device-to-host synchronization.
has_initial_states_p = metadata.has_initial_states_p
if has_initial_states_p is None:
raise ValueError("has_initial_states_p is required for prefill short-conv")
output = torch.empty_like(x_p)
q_starts = query_start_loc_p.to(torch.int64)
if state_indices_tensor_p.numel() < num_prefills:
raise ValueError(
"state_indices_tensor_p size mismatch: "
f"got {state_indices_tensor_p.numel()}, "
f"need >= {num_prefills}."
)
if has_initial_states_p.numel() < num_prefills:
raise ValueError(
"has_initial_states_p size mismatch: "
f"got {has_initial_states_p.numel()}, "
f"need >= {num_prefills}."
)
if num_prefills == 0 or x_p.numel() == 0:
return output
lengths = q_starts[1:] - q_starts[:-1]
# Use the CPU-computed packing width from the metadata builder instead
# of synchronizing on lengths.max().
max_len = metadata.max_prefill_query_len
if max_len <= 0:
return output
hidden_size = x_p.shape[1]
positions = torch.arange(
num_prefill_tokens, device=x_p.device, dtype=torch.int64
)
req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
col_indices = positions - q_starts[req_indices]
packed_tokens = x_p.new_zeros((num_prefills, max_len, hidden_size))
packed_tokens[req_indices, col_indices] = x_p
packed_tokens = packed_tokens.transpose(1, 2).contiguous()
state_indices = state_indices_tensor_p[:num_prefills].to(
device=conv_state.device, dtype=torch.int64
)
valid_state = state_indices != NULL_BLOCK_ID
state_indices = torch.where(
valid_state, state_indices, torch.zeros_like(state_indices)
)
has_initial = has_initial_states_p[:num_prefills].to(
device=conv_state.device, dtype=torch.bool
)
if self.conv_state_len > 0:
if conv_state.shape[0] == 0:
state = conv_state.new_zeros(
(num_prefills, hidden_size, self.conv_state_len),
dtype=x_p.dtype,
)
else:
state = conv_state.index_select(0, state_indices)[
..., : self.conv_state_len
].to(x_p.dtype)
use_initial_mask = (valid_state & has_initial).view(num_prefills, 1, 1)
initial_state = torch.where(
use_initial_mask,
state,
torch.zeros_like(state),
)
history = torch.cat((initial_state, packed_tokens), dim=-1)
else:
history = packed_tokens
conv_output = F.conv1d(
history,
conv_weights.unsqueeze(1).contiguous(),
groups=history.size(1),
dilation=self.short_conv_dilation,
)
conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
token_positions = torch.arange(max_len, device=x_p.device, dtype=torch.int64)
valid_tokens = token_positions.view(1, max_len) < lengths.view(num_prefills, 1)
valid_output_mask = valid_tokens & valid_state.to(device=x_p.device).view(
num_prefills, 1
)
conv_output.masked_fill_(~valid_output_mask.unsqueeze(-1), 0)
output.copy_(conv_output[req_indices, col_indices])
if self.conv_state_len > 0 and conv_state.shape[0] > 0:
state_starts = lengths.to(device=history.device, dtype=torch.int64).view(
num_prefills, 1, 1
)
state_offsets = torch.arange(
self.conv_state_len, device=history.device, dtype=torch.int64
).view(1, 1, self.conv_state_len)
next_state = history.gather(
dim=2,
index=(state_starts + state_offsets).expand(-1, history.size(1), -1),
)
# Write back without a host synchronization. Valid, non-empty rows
# receive their new state; padding and zero-length rows keep the
# current cache value.
existing_state = conv_state.index_select(0, state_indices)
existing_base_state = existing_state[..., : self.conv_state_len]
update_mask = valid_state & (lengths.to(device=conv_state.device) > 0)
safe_next_state = torch.where(
update_mask.view(num_prefills, 1, 1),
next_state.to(conv_state.dtype),
existing_base_state,
)
existing_state[..., : self.conv_state_len] = safe_next_state
conv_state.index_copy_(0, state_indices, existing_state)
return output
def _short_conv_dilated_spec_batched(
self,
x_spec: torch.Tensor,
conv_state: torch.Tensor,
conv_weights: torch.Tensor,
spec_state_indices_tensor: torch.Tensor,
spec_query_start_loc: torch.Tensor,
num_accepted_tokens: torch.Tensor,
spec_query_len: int,
) -> torch.Tensor:
"""Dilated short-conv for speculative-decode (MTP) requests.
Each spec request feeds multiple (draft + 1) query tokens. The conv
outputs are computed causally after rolling back the previous draft
state by ``num_accepted_tokens - 1``. The current candidate inputs stay
in the extended cache for the next forward, matching
``causal_conv1d_update``.
``spec_query_len`` (== num_speculative_tokens + 1) is the maximum query
length and is a Python int, so no host synchronization is needed; this
keeps the path safe for full CUDA-graph capture/replay where the buffers
are padded at the request level.
"""
num_reqs = spec_state_indices_tensor.numel()
hidden_size = x_spec.size(-1)
# Use a fixed packing width instead of synchronizing on lengths.max().
max_len = spec_query_len
# Full CUDA graphs can pad these buffers. Only the first num_reqs
# accepted-token counts belong to actual speculative requests.
num_accepted_tokens = num_accepted_tokens[:num_reqs]
q_starts = spec_query_start_loc[: num_reqs + 1].to(torch.int64)
# Keep the number of real speculative tokens on the device.
total_real_tokens = q_starts[num_reqs]
state_indices = spec_state_indices_tensor.to(
device=conv_state.device, dtype=torch.int64
)
valid_state = state_indices != NULL_BLOCK_ID
state_indices = torch.where(
valid_state, state_indices, torch.zeros_like(state_indices)
)
positions = torch.arange(
x_spec.size(0), device=x_spec.device, dtype=torch.int64
)
# Route graph-padded token rows to the discarded dummy request so that
# they cannot overwrite real packed data.
req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
valid_tokens = (positions < total_real_tokens) & (req_indices < num_reqs)
clamped_req_indices = req_indices.clamp_max(max(num_reqs - 1, 0))
col_indices = (positions - q_starts[clamped_req_indices]).clamp_(0, max_len - 1)
pack_req_indices = torch.where(
valid_tokens,
clamped_req_indices,
torch.full_like(req_indices, num_reqs),
)
pack_col_indices = torch.where(
valid_tokens, col_indices, torch.zeros_like(col_indices)
)
# The last request row is the dummy sink for graph padding.
packed = x_spec.new_zeros((num_reqs + 1, max_len, hidden_size))
packed[pack_req_indices, pack_col_indices] = x_spec
packed = packed.transpose(1, 2).contiguous()
if self.conv_state_len > 0:
cached_state = conv_state.index_select(0, state_indices)
rollback_offsets = num_accepted_tokens.to(
device=conv_state.device, dtype=torch.int64
).sub(1)
rollback_offsets = torch.where(
valid_state,
rollback_offsets.clamp_(0, max_len - 1),
torch.zeros_like(rollback_offsets),
)
state_offsets = torch.arange(
self.conv_state_len, device=conv_state.device, dtype=torch.int64
).view(1, 1, self.conv_state_len)
rollback_indices = rollback_offsets.view(-1, 1, 1) + state_offsets
state = cached_state.gather(
2, rollback_indices.expand(-1, hidden_size, -1)
).to(x_spec.dtype)
state = torch.where(
valid_state.view(num_reqs, 1, 1),
state,
torch.zeros_like(state),
)
# Append a zeroed dummy-row state to match the [num_reqs + 1] pack.
dummy_state = state.new_zeros((1, hidden_size, self.conv_state_len))
state_full = torch.cat((state, dummy_state), dim=0)
history = torch.cat((state_full, packed), dim=-1)
else:
history = packed
conv_output = F.conv1d(
history,
conv_weights.unsqueeze(1).contiguous(),
groups=history.size(1),
dilation=self.short_conv_dilation,
)
conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
output = conv_output[pack_req_indices, pack_col_indices]
output = output * valid_tokens.view(-1, 1).to(output.dtype)
# Keep all current candidate inputs in the extended state. On the next
# target forward, ``num_accepted_tokens - 1`` selects the rollback
# window before processing the newly scheduled tokens.
if self.conv_state_len > 0:
state_capacity = self.conv_state_len + max_len - 1
if conv_state.size(-1) < state_capacity:
raise RuntimeError(
"PLE short-conv cache cannot retain speculative tokens: "
f"got {conv_state.size(-1)}, need {state_capacity}."
)
candidate_state = history[:num_reqs, :, 1 : state_capacity + 1]
query_lengths = q_starts[1:] - q_starts[:-1]
state_positions = torch.arange(
state_capacity, device=history.device, dtype=torch.int64
).view(1, 1, state_capacity)
update_lengths = (self.conv_state_len + query_lengths - 1).view(
num_reqs, 1, 1
)
update_mask = valid_state.view(num_reqs, 1, 1) & (
state_positions < update_lengths
)
existing_state = cached_state[..., :state_capacity]
next_state = torch.where(
update_mask,
candidate_state.to(conv_state.dtype),
existing_state,
)
cached_state[..., :state_capacity] = next_state
conv_state.index_copy_(0, state_indices, cached_state)
return output
def _short_conv_dilated_dispatch(
self,
inputs: torch.Tensor,
metadata: PleShortConvAttentionMetadata,
conv_state: torch.Tensor,
conv_weights: torch.Tensor,
) -> torch.Tensor:
num_prefills = metadata.num_prefills
num_decodes = metadata.num_decodes
num_decode_tokens = metadata.num_decode_tokens
num_prefill_tokens = metadata.num_prefill_tokens
has_prefill = num_prefills > 0
has_decode = num_decodes > 0
has_spec = metadata.spec_sequence_masks is not None
x = inputs[: metadata.num_actual_tokens]
# Split spec / non-spec tokens.
if has_spec:
if has_prefill or has_decode:
assert metadata.spec_token_indx is not None
assert metadata.non_spec_token_indx is not None
x_spec = x.index_select(0, metadata.spec_token_indx.long())
x_non_spec = x.index_select(0, metadata.non_spec_token_indx.long())
else:
x_spec = x
x_non_spec = None
else:
x_spec = None
x_non_spec = x
spec_output = None
# 1. Run the multi-query speculative-decode part.
if has_spec:
assert metadata.spec_state_indices_tensor is not None
assert metadata.spec_query_start_loc is not None
assert metadata.num_accepted_tokens is not None
spec_output = self._short_conv_dilated_spec_batched(
x_spec=x_spec,
conv_state=conv_state,
conv_weights=conv_weights,
spec_state_indices_tensor=metadata.spec_state_indices_tensor[
: metadata.num_spec_decodes
],
spec_query_start_loc=metadata.spec_query_start_loc,
num_accepted_tokens=metadata.num_accepted_tokens,
spec_query_len=metadata.spec_query_len,
)
# 2. Run regular decode and prefill requests.
conv_out_non_spec = None
state_indices_tensor = metadata.state_indices_tensor
if x_non_spec is not None:
assert state_indices_tensor is not None
if has_prefill:
state_indices_tensor_d, state_indices_tensor_p = torch.split(
state_indices_tensor,
[num_decodes, num_prefills],
dim=0,
)
x_d, x_p = torch.split(
x_non_spec,
[num_decode_tokens, num_prefill_tokens],
dim=0,
)
non_spec_parts: list[torch.Tensor] = []
if has_decode:
non_spec_parts.append(
self._short_conv_dilated_decode_batched(
x_d=x_d,
conv_state=conv_state,
conv_weights=conv_weights,
state_indices_tensor_d=state_indices_tensor_d,
has_initial_states_d=metadata.has_initial_states_d,
)
)
non_spec_parts.append(
self._short_conv_dilated_prefill_batched(
x_p=x_p,
metadata=metadata,
conv_state=conv_state,
conv_weights=conv_weights,
state_indices_tensor_p=state_indices_tensor_p,
num_prefills=num_prefills,
num_decode_tokens=num_decode_tokens,
num_prefill_tokens=num_prefill_tokens,
)
)
conv_out_non_spec = torch.vstack(non_spec_parts)
else:
conv_out_non_spec = self._short_conv_dilated_decode_batched(
x_d=x_non_spec,
conv_state=conv_state,
conv_weights=conv_weights,
state_indices_tensor_d=state_indices_tensor[: x_non_spec.size(0)],
has_initial_states_d=metadata.has_initial_states_d,
)
# 3. Merge both parts back into the original token order.
if has_spec and conv_out_non_spec is not None:
assert metadata.spec_token_indx is not None
assert metadata.non_spec_token_indx is not None
assert spec_output is not None
output = x.new_empty((metadata.num_actual_tokens, x.size(-1)))
output.index_copy_(0, metadata.spec_token_indx, spec_output)
output.index_copy_(0, metadata.non_spec_token_indx, conv_out_non_spec)
return output
elif has_spec:
assert spec_output is not None
return spec_output
if conv_out_non_spec is None:
return x
return conv_out_non_spec
def _short_conv(self, inputs: torch.Tensor) -> torch.Tensor:
forward_context = get_forward_context()
attn_metadata = forward_context.attn_metadata
if attn_metadata is None:
return self._short_conv_fallback(inputs)
if not isinstance(attn_metadata, dict):
raise RuntimeError(
"PLE short-conv expects per-layer attention metadata dict "
f"during inference, got {type(attn_metadata).__name__}."
)
layer_attn_metadata = attn_metadata.get(self.prefix)
if layer_attn_metadata is None:
# MRV2 omits Mamba-family metadata during profile warmup.
return self._short_conv_fallback(inputs)
if not isinstance(layer_attn_metadata, PleShortConvAttentionMetadata):
raise TypeError(
"Expected PleShortConvAttentionMetadata for layer "
f"'{self.prefix}', got "
f"{type(layer_attn_metadata).__name__}."
)
conv_state = self.kv_cache[0]
if not is_conv_state_dim_first():
conv_state = conv_state.transpose(-1, -2)
conv_weights = self.conv1d.weight.squeeze(1)
state_capacity = self.conv_state_len + self.num_spec_tokens
if state_capacity > 0:
if conv_state.size(-1) < state_capacity:
raise RuntimeError(
"PLE short-conv cache is smaller than expected for "
f"dilated convolution: got {conv_state.size(-1)}, "
f"expect at least {state_capacity}."
)
conv_state = conv_state[..., -state_capacity:]
return self._short_conv_dilated_dispatch(
inputs,
layer_attn_metadata,
conv_state,
conv_weights.to(dtype=inputs.dtype),
)
def forward(
self,
hidden_states: torch.Tensor,
input_ids: torch.Tensor,
query_start_loc: torch.Tensor,
ngram_context: torch.Tensor,
) -> torch.Tensor:
input_ids = input_ids.reshape(-1)
if input_ids.shape[0] != hidden_states.shape[0]:
raise ValueError(
"PLE expects input_ids and hidden_states to have the same "
f"token length, got {input_ids.shape[0]} and "
f"{hidden_states.shape[0]}"
)
embeddings = self.ple_embedding(input_ids, query_start_loc, ngram_context)
key, _ = self.key_proj(embeddings)
value, _ = self.value_proj(embeddings)
token_count = hidden_states.shape[0]
key = key.reshape(token_count, self.hc_count, self.hidden_size)
query = hidden_states.reshape(token_count, self.hc_count, self.hidden_size)
key = self._apply_norm(self.norm_key, key)
query = self._apply_norm(self.norm_query, query)
gate = (key * query).sum(dim=-1, keepdim=True) / math.sqrt(self.hidden_size)
gate = torch.sigmoid(gate.sign() * gate.abs().clamp_min(1e-6).sqrt())
gated_value = gate * value.unsqueeze(-2)
normalized = self._apply_norm(self.norm_conv, gated_value).flatten(-2)
conv_output = torch.zeros_like(normalized)
torch.ops.vllm.qwen4_exp_ple_short_conv(
normalized,
conv_output,
self.prefix,
)
return gated_value.flatten(-2) + conv_output