[ROCm] Fix EAGLE spec-decode verify silently sampling greedy on HIP (#37134)

Co-authored-by: JohnQinAMD <yanyuan.qin@amd.com>
Co-authored-by: Jiejing Zhang <jiejing.zhang@amd.com>
This commit is contained in:
xiaobochen-amd
2026-09-15 18:49:49 -07:00
committed by GitHub
co-authored by JohnQinAMD Jiejing Zhang
parent 2f7d04da5d
commit 7eedd57ab0
7 changed files with 298 additions and 22 deletions
@@ -68,7 +68,15 @@ def speculative_sampling_classic_kernel(
coin = tl.load(uni_ptr_base + (step - 1) * stride_uni_s)
if coin * q < p:
# X was sampled from q, so q(X) has to be a positive probability.
# Anything else means this row is not the distribution X came from, and
# `coin * q < p` would then accept unconditionally -- -inf < p for an
# -inf q, 0 < p for a zero one, and the range guard the residual passes
# use lets zero through. Reject instead: the residual path resamples
# from the target, which is the safe direction to fail in.
q_is_prob = (q > 0.0) & (q <= 1.0)
if q_is_prob & (coin * q < p):
num_accept += 1
cur_prob_row = step
tl.store(Predicts + last_accepted_global_idx, draft_token)
@@ -111,8 +119,10 @@ def speculative_sampling_classic_kernel(
else:
q_ptr = dp_base_ptr_safe + v_offsets * stride_dp_v
q_val = tl.load(q_ptr, mask=mask, other=0.0)
# Treat NaN q (degenerate draft rows) as 0: residual falls back to p.
q_val = tl.where(q_val == q_val, q_val, 0.0)
# Treat any non-probability q (NaN, +-inf, negative) as 0: the
# residual falls back to p. A comparison against NaN is false, so
# the range test rejects it along with the infinities.
q_val = tl.where((q_val >= 0.0) & (q_val <= 1.0), q_val, 0.0)
diff = p_val - q_val
val = tl.where(diff > 0.0, diff, 0.0)
@@ -139,8 +149,8 @@ def speculative_sampling_classic_kernel(
else:
q_ptr = dp_base_ptr_safe + v_offsets * stride_dp_v
q_val = tl.load(q_ptr, mask=mask, other=0.0)
# Same NaN-q guard as pass 1.
q_val = tl.where(q_val == q_val, q_val, 0.0)
# Same guard as pass 1.
q_val = tl.where((q_val >= 0.0) & (q_val <= 1.0), q_val, 0.0)
diff = p_val - q_val
val = tl.where(diff > 0.0, diff, 0.0)
@@ -24,6 +24,37 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _should_auto_enable_hip_rejection_sampling(
*,
is_hip: bool,
use_rejection_sampling: bool,
algorithm: Optional[str],
token_map: Optional[str],
eagle_topk: int,
accept_threshold_single: float,
accept_threshold_acc: float,
enable_deterministic_inference: bool,
) -> bool:
"""Whether HIP may default ``speculative_use_rejection_sampling`` on.
Rejection sampling still cannot consume a reduced / hot draft vocab
(``eagle_worker_v2`` FIXME: scatter via the d2t map). Auto-enabling there
would crash configs that previously ran greedy on HIP, including EAGLE3
stage-a ``test_basic_sanity_eagle3`` (draft 32000 vs target 128256). Skip
EAGLE3 and any EAGLE run that already has a token map.
"""
return (
is_hip
and not use_rejection_sampling
and algorithm == "EAGLE"
and token_map is None
and eagle_topk == 1
and accept_threshold_single == 1.0
and accept_threshold_acc == 1.0
and not enable_deterministic_inference
)
def _disable_overlap_schedule_for_cpu(server_args: ServerArgs) -> None:
cfg = resolving_view(server_args)
if cfg.device != "cpu" or cfg.disable_overlap_schedule:
@@ -813,7 +844,6 @@ def _handle_frozen_kv_mtp(server_args: ServerArgs) -> None:
def _handle_eagle_family(server_args: ServerArgs) -> None:
cfg = resolving_view(server_args)
if (
@@ -920,7 +950,34 @@ def _handle_eagle_family(server_args: ServerArgs) -> None:
"trtllm_mha backend only supports topk = 1 for speculative decoding."
)
if cfg.speculative_use_rejection_sampling:
# ROCm/HIP has no CUDA/MUSA sampling-verify kernels, so EAGLE verify would
# otherwise fall back to greedy (argmax) and silently ignore temperature and
# top_p. Default rejection sampling on -- it routes verify through the Triton
# chain sampler -- for configs that support it. See
# _should_auto_enable_hip_rejection_sampling for the cases we must not flip.
if _should_auto_enable_hip_rejection_sampling(
is_hip=get_platform().is_hip,
use_rejection_sampling=cfg.speculative_use_rejection_sampling,
algorithm=cfg.speculative_algorithm,
token_map=cfg.speculative_token_map,
eagle_topk=cfg.speculative_eagle_topk,
accept_threshold_single=cfg.speculative_accept_threshold_single,
accept_threshold_acc=cfg.speculative_accept_threshold_acc,
enable_deterministic_inference=cfg.enable_deterministic_inference,
):
declare_resolution(
server_args,
"_handle_eagle_family",
speculative_use_rejection_sampling=True,
)
logger.info(
"ROCm needs rejection sampling for EAGLE spec-decode to sample at all; "
"enabling speculative_use_rejection_sampling by default."
)
# resolved_view, not cfg: the block above may have just decided this field,
# and declare_resolution writes to the stash rather than the dataclass.
if resolved_view(server_args).speculative_use_rejection_sampling:
# Resolved alias by now: NEXTN -> EAGLE, Gemma4 draft -> FROZEN_KV_MTP.
# Only the EAGLE/EAGLE3 draft workers emit a target-vocab proposal that
# the rejection-sampling kernel consumes; everything else (STANDALONE,
@@ -42,6 +42,7 @@ from sglang.srt.runtime_context import (
get_spec,
)
from sglang.srt.sampling.sampling_batch_info import SamplingBatchInfo
from sglang.srt.sampling.sampling_params import TOP_K_ALL
from sglang.srt.speculative.eagle_info import EagleDraftInput
from sglang.srt.speculative.eagle_utils import get_draft_recurrent_hidden_state_spec
from sglang.srt.speculative.spec_utils import resolve_num_tokens_per_req
@@ -213,6 +214,14 @@ class EAGLEDraftCudaGraphRunner(DecodeCudaGraphRunner):
)
self.temperatures = torch.ones((self.max_bs, 1), dtype=torch.float)
# Real per-request top_k, for the same reason temperatures are
# carried: the draft proposal cannot tell a greedy request from a
# T=1 one by temperature alone, because SamplingParams rewrites
# temperature 0 to temperature=1.0 with top_k=1.
# TOP_K_ALL, not -1: -1 is not a top_k this pipeline ever carries
# (SamplingParams rewrites it), and it would read as top_k <= 1, i.e.
# greedy, for the padded rows and for a run that never copies in.
self.top_ks = torch.full((self.max_bs,), TOP_K_ALL, dtype=torch.int32)
if self.require_gathered_buffer:
if self.require_mlp_tp_gather:
@@ -417,7 +426,7 @@ class EAGLEDraftCudaGraphRunner(DecodeCudaGraphRunner):
sampling_info = SamplingBatchInfo(
temperatures=self.temperatures[:num_seqs],
top_ps=torch.ones((num_seqs,), dtype=torch.float),
top_ks=torch.full((num_seqs,), -1, dtype=torch.int32),
top_ks=self.top_ks[:num_seqs],
min_ps=torch.zeros((num_seqs,), dtype=torch.float),
is_all_greedy=False,
is_any_greedy=False,
@@ -624,6 +633,7 @@ class EAGLEDraftCudaGraphRunner(DecodeCudaGraphRunner):
self.temperatures[:raw_bs].copy_(
forward_batch.sampling_info.temperatures[:raw_bs]
)
self.top_ks[:raw_bs].copy_(forward_batch.sampling_info.top_ks[:raw_bs])
# TODO(ch-wan): support num_token_non_padded
if self.require_gathered_buffer:
+45 -13
View File
@@ -685,6 +685,24 @@ def _verify_coins(
return coins, coins_for_final_sampling
def _verify_uses_greedy(
*,
is_all_greedy: bool,
is_cpu: bool,
is_hip: bool,
is_xpu: bool,
use_rejection_sampling: bool,
) -> bool:
"""Whether EAGLE verify must commit argmax instead of taking the sampling path.
HIP has no CUDA/MUSA sampling-verify kernels, so it used to be listed here
unconditionally. Rejection sampling routes it through the pure-Triton chain
sampler instead, so only a HIP run without that still has to go greedy. Every
other platform reduces to the original predicate.
"""
return is_all_greedy or is_cpu or is_xpu or (is_hip and not use_rejection_sampling)
def _can_use_sparse_uno_tree_target_sampling(
max_top_k: Optional[int],
sampling_info: SamplingBatchInfo,
@@ -781,7 +799,14 @@ def eagle_sample(
# Sample tokens
target_predict = None
if sampling_info.is_all_greedy or _is_cpu or _is_hip or _is_xpu:
use_rejection_sampling = get_spec().speculative_use_rejection_sampling
if _verify_uses_greedy(
is_all_greedy=sampling_info.is_all_greedy,
is_cpu=_is_cpu,
is_hip=_is_hip,
is_xpu=_is_xpu,
use_rejection_sampling=use_rejection_sampling,
):
target_predict = torch.argmax(next_token_logits, dim=-1)
target_predict = target_predict.reshape(bs, verify_input.draft_token_num)
predict, accept_index, num_correct_drafts = verify_tree_greedy_func(
@@ -855,23 +880,30 @@ def eagle_sample(
tree_speculative_sampling_target_only,
)
else:
from sgl_kernel import (
top_k_renorm_prob,
top_p_renorm_prob,
tree_speculative_sampling_target_only,
)
from sglang.kernels.ops.speculative.reject_sampling import (
chain_speculative_sampling_triton,
)
use_rejection_sampling = get_spec().speculative_use_rejection_sampling
# if/else, not a ternary: the CUDA-only name still has to resolve in the
# branch not taken, and HIP only reaches here with rejection sampling on.
if use_rejection_sampling:
sampling_fn = chain_speculative_sampling_triton
else:
if not _is_npu:
from sgl_kernel import tree_speculative_sampling_target_only
sampling_fn = (
chain_speculative_sampling_triton
if use_rejection_sampling
else tree_speculative_sampling_target_only
)
sampling_fn = tree_speculative_sampling_target_only
if _is_hip:
# Same names, same contract: dflash_utils.py aliases these too.
from sglang.kernels.ops.sampling.renorm_triton import (
top_k_renorm_probs_triton as top_k_renorm_prob,
)
from sglang.kernels.ops.sampling.renorm_triton import (
top_p_renorm_probs_triton as top_p_renorm_prob,
)
elif not _is_npu:
from sgl_kernel import top_k_renorm_prob, top_p_renorm_prob
expanded_temperature = torch.repeat_interleave(
sampling_info.temperatures, verify_input.draft_token_num, dim=0
@@ -628,6 +628,13 @@ class EagleDraftWorker(EagleDraftWorkerBase):
parent_list, top_scores_index, draft_tokens, draft_probs = (
self.cuda_graph_runner.execute(forward_batch)
)
if draft_probs is not None:
# draft_probs is the one graph output read after the target
# forward rather than by it, and it points into the graph's
# private memory pool. The pool recycles that block in the
# meantime -- in practice the DSA top-k mask lands there and
# eagle_sample sees -inf. Copy out at the boundary.
draft_probs = draft_probs.clone()
else:
if (
not forward_batch.forward_mode.is_idle()
@@ -767,6 +774,7 @@ class EagleDraftWorker(EagleDraftWorkerBase):
probs, topk_p, topk_index = sample_draft_proposal(
logits_output.next_token_logits,
forward_batch.sampling_info.temperatures,
forward_batch.sampling_info.top_ks,
)
draft_probs_list.append(probs)
forward_batch.positions.add_(1)
@@ -1117,6 +1125,7 @@ class EagleDraftWorker(EagleDraftWorkerBase):
ret_draft_probs, ret_topk_p, ret_topk_index = sample_draft_proposal(
draft_logits_output.next_token_logits,
batch.sampling_info.temperatures,
batch.sampling_info.top_ks,
)
elif self.topk == 1 and not _is_hip:
# Gated to CUDA: see #26358 — ROCm's argmax tie-break corrupts
+32 -1
View File
@@ -165,15 +165,46 @@ def renorm_draft_probs(
return torch.softmax(next_token_logits / sampling_info.temperatures, dim=-1)
def sample_draft_proposal(next_token_logits: torch.Tensor, temperatures: torch.Tensor):
def sample_draft_proposal(
next_token_logits: torch.Tensor,
temperatures: torch.Tensor,
top_ks: Optional[torch.Tensor] = None,
):
"""Leviathan draft proposal: q = softmax(logits / T), X ~ q.
Returns (q, q(X), X). The verify's accept test coin*q(X) < p(X) is unbiased
only if q is exactly the distribution X was drawn from, so callers must hand
the returned q (not a recomputed one) to the verify.
A greedy row (``top_k == 1``) proposes its argmax instead. SamplingParams
rewrites temperature 0 to ``temperature=1.0, top_k=1``, so T alone cannot
tell a greedy request from a T=1 one, and sampling a sharp-but-not-
degenerate distribution proposes a non-argmax token often enough to cost
real accept length.
That row's X is then not drawn from the q returned beside it, which the
unbiasedness argument above otherwise rests on. It stays correct because
eagle_sample renormalises the target by the same per-row ``top_ks`` before
the accept test, so a greedy row's p is one-hot: X equal to the target
argmax accepts (p(X) = 1), any other X rejects (p(X) = 0) and the residual
(p - q)+ it resamples from is p itself. Both arms commit the target argmax,
which is what greedy means. Drop that renorm and this stops holding.
"""
probs = torch.softmax(next_token_logits / temperatures, dim=-1)
topk_p, topk_index = fast_sample(probs, num_samples=1)
if top_ks is not None:
# Assert rather than skip on a device mismatch: a host-side top_ks would
# make this correction silently vanish, and the symptom -- draft accept
# length quietly dropping about 20% -- reads as a model problem, not a
# plumbing one.
assert top_ks.device == probs.device, (
f"top_ks must be on {probs.device} to reach the draft proposal, "
f"got {top_ks.device}; the caller has to carry the real per-request "
"top_k, not a host placeholder"
)
greedy = (top_ks <= 1).view(-1, 1)
topk_index = torch.where(greedy, probs.argmax(dim=-1, keepdim=True), topk_index)
topk_p = probs.gather(1, topk_index)
return probs, topk_p, topk_index