[misc] Pass FP8 scales in FlashInfer SWA prefill, autotune fp8 on SM120, and tighten is_image_understandable_model (#34217)

Co-authored-by: Brayden Zhong <b8zhong@uwaterloo.ca>
This commit is contained in:
Liangsheng Yin
2026-08-09 23:45:11 -07:00
committed by GitHub
co-authored by Brayden Zhong
parent d6a066131c
commit aea78d1e73
12 changed files with 115 additions and 242 deletions
+3 -5
View File
@@ -935,11 +935,9 @@ def _gpt_oss_overrides(server_args: Any, hf_config: Any) -> dict:
elif is_hip():
overrides["attention_backend"] = "aiter"
elif not (is_mps() and use_mlx()):
# No triton on macOS, but only the MLX runner can actually serve
# gpt-oss there -- it owns attention, so it keeps the platform
# default. macOS *without* MLX must still fall through to triton
# and fail fast below: torch_native has neither sliding-window nor
# attention-sink support, so accepting it would silently mis-serve.
# Exempt MLX only -- it owns attention in its own runner. macOS
# without MLX still falls through to triton and fails fast below,
# rather than landing on torch_native (no sliding window, no sinks).
overrides["attention_backend"] = "triton"
if is_xpu():
# Check for bf16 dtype on Intel XPU. Reads the pristine dtype request,
+3 -1
View File
@@ -450,10 +450,12 @@ class ModelConfig:
self.hf_config.architectures
)
# TODO: requires further polishing
# Key on the tower, not the attribute: several config classes default
# vision_config to None, which presence alone would read as image-capable.
self.is_image_understandable_model = (
enable_multimodal
and not self.is_lm_only
and hasattr(self.hf_config, "vision_config")
and getattr(self.hf_config, "vision_config", None) is not None
)
# Models expose audio_config at different nesting levels:
@@ -59,11 +59,9 @@ MAX_ROLLBACK_TOKENS = 200
def _allocate_token_bitmask(vocab_size: int, batch_size: int) -> torch.Tensor:
# Allocate a pinned bitmask where pinning exists so the later H2D to the
# device can be a genuine non_blocking copy (a pageable source silently
# downgrades it to a blocking copy). MPS torch has no pin-memory kernel
# and asserts on pin_memory=True; the MLX path consumes the mask on the
# CPU anyway.
# Pin where pinning exists, so the later H2D can be a genuine non_blocking
# copy (a pageable source silently downgrades it). MPS torch has no
# pin-memory kernel and asserts on pin_memory=True.
return torch.full(
get_bitmask_shape(batch_size, vocab_size),
-1,
@@ -23,17 +23,13 @@ def make_attention_mask(N, offset, return_array=False, window_size=None):
"""
if window_size is not None and offset + N > window_size:
return create_causal_mask(N, offset, window_size=window_size)
# Either no window, or a window that cannot bind. The lowest query
# position is ``offset``, so ``offset + N <= window_size`` means every
# causally visible key is inside the band and the banded mask is
# elementwise identical to a plain causal one -- the shortcut mlx_lm's own
# RotatingKVCache.make_mask takes, and these shims stand in for exactly
# that cache on sliding layers. Worth the branch because a materialised
# mask forces mx.fast.scaled_dot_product_attention off its fused causal
# path: ~2x slower per layer, plus an N x (offset + N) allocation.
# It also survives a window-bounded store: offset + N <= window_size
# implies N + window_size - 1 >= offset + N, so no keys are dropped and
# the mask width still matches the returned key length.
# Either no window, or a window that cannot bind: the lowest query position
# is ``offset``, so ``offset + N <= window_size`` puts every causally
# visible key inside the band and the banded mask is elementwise identical
# to a plain causal one (the same shortcut mlx_lm's RotatingKVCache takes).
# Worth the branch because a materialised mask forces
# mx.fast.scaled_dot_product_attention off its fused causal path: ~2x
# slower per layer, plus an N x (offset + N) allocation.
if N == 1:
return None
if return_array:
@@ -156,8 +152,7 @@ class ContiguousAttentionKVCache:
"""Return valid K/V: (1, n_kv_heads, min(offset, window), head_dim).
``window`` keeps only the trailing window a sliding-window layer can
attend to. Slicing here rather than slicing the full history and then
slicing again costs one op instead of two per request per layer.
attend to; slicing it here costs one op instead of two.
"""
start = 0 if window is None else max(0, self.offset - window)
return (
@@ -209,11 +204,10 @@ class WindowedAttentionKVCache:
"WindowedAttentionKVCache holds only the trailing window and "
"cannot serve a full-context attention mask"
)
# No N == 1 shortcut here, tempting as it looks: mlx_lm's banded mask is
# No N == 1 shortcut here: mlx_lm's banded mask is
# ``linds < rinds + window_size`` (strict), so a window of W admits
# exactly W keys. Once ``kept == window`` this buffer returns W + 1 of
# them -- the trailing window plus the token just written -- and the
# oldest must still be masked out.
# exactly W keys, while this buffer returns W + 1 once ``kept ==
# window`` -- the trailing window plus the token just written.
return make_attention_mask(
N, kept, return_array=return_array, window_size=window_size
)
@@ -221,9 +215,8 @@ class WindowedAttentionKVCache:
def _append(self, keys: mx.array, values: mx.array) -> tuple[int, int]:
"""Append a chunk in place; return the (start, end) span it serves.
Split out from ``update_and_fetch`` so the decode path can skip
building the two return slices, which its caller discards in
favour of ``get_kv``.
Split out of ``update_and_fetch`` so the decode path can skip building
the two return slices, which it discards in favour of ``get_kv``.
"""
S = keys.shape[2]
kept = min(self._local, self.window)
@@ -57,10 +57,9 @@ class BatchedDecodeContext:
needs_padding: bool = field(init=False)
pad_sizes: list[int] = field(init=False)
positions: Optional[mx.array] = field(init=False)
# Padding metadata memo, keyed by window size. It depends only on
# ``seq_lens`` and the window, so every layer sharing a window reuses one
# entry instead of rebuilding it (gpt-oss decodes 24 attention layers per
# step, in two window classes).
# Padding metadata memo, keyed by window size: it depends only on
# ``seq_lens`` and the window, so every layer sharing a window reuses
# one entry instead of rebuilding it.
_padding_by_window: dict = field(init=False, default_factory=dict)
def __post_init__(self) -> None:
@@ -77,10 +76,9 @@ class BatchedDecodeContext:
idx: idx for idx in range(len(self.attention_layer_caches))
}
if self.aot.rope is not None and not self.full_kv_pool_index_by_layer:
# No silent default here: the fused scatter addresses pool buffers
# by full-attention index, so falling back to the cache index would
# write the wrong buffer whenever sliding-window layers are
# interleaved. A model with a pool always has full layers to index.
# The fused scatter addresses pool buffers by full-attention index;
# defaulting to the cache index would write the wrong buffer
# whenever sliding-window layers are interleaved.
raise ValueError(
"BatchedDecodeContext requires full_kv_pool_index_by_layer "
"when the fused AOT RoPE + pool-scatter kernel is active"
@@ -99,7 +97,6 @@ class BatchedDecodeContext:
The mask is boolean (``True`` keeps the key), broadcast-shaped
``(B, 1, 1, width)``, and ``None`` when no request needs padding.
Cached per window: all layers in the step share one build.
"""
cached = self._padding_by_window.get(window, None)
if cached is not None:
@@ -144,10 +141,8 @@ class BatchedDecodeContext:
batch_size = len(req_ids)
if attention_layer_indices is None:
attention_layer_indices = list(range(len(caches[0])))
# One arbitrary attention layer speaks for the whole step: every
# attention cache's ``offset`` is the ABSOLUTE sequence position, so
# they all agree even though a windowed cache stores far fewer
# tokens than that (see the class docstring's read-through rule).
# Any attention layer will do: ``offset`` is the ABSOLUTE sequence
# position on every cache, even a windowed one storing far fewer tokens.
seq_lens = [
caches[i][attention_layer_indices[0]].offset for i in range(batch_size)
]
@@ -699,23 +699,10 @@ class MlxModelRunner:
return
num_pool_layers = self._cache_layout.num_full_attention_layers
if self._cache_layout.has_sliding_window_layers:
# The pool exists to serve radix prefix hits, and an SWA prefix hit
# recomputes the prefix instead of gathering it (see prefill_start),
# so on any SWA model the pool has no reader: its sole consumer is
# PoolBackedAttentionKVCache, reachable only when
# trusted_prefix_len > 0, which requires no SWA layers. Allocating
# it anyway would burn the whole auto-sized KV budget
# (_compute_pool_size fills mem_fraction_static) on a buffer that is
# only ever written. Skipping it also disables the fused AOT
# RoPE + pool-scatter kernel for the full layers, whose scatter half
# is dead work here; that kernel is opt-in
# (SGLANG_MLX_USE_CUSTOM_ROPE, default off), so the default path
# loses nothing.
#
# Un-gate this together with the window-aware shared SWA pool that
# restores fast prefix hits: the seams it needs (the layout
# partition, the full-pool index, layer-type dispatch) are already
# in place.
# Allocating it anyway would burn the whole auto-sized KV budget
# (_compute_pool_size fills mem_fraction_static) on a write-only
# buffer. This also disables the fused AOT RoPE + pool-scatter
# kernel, which is opt-in (SGLANG_MLX_USE_CUSTOM_ROPE, default off).
logger.info(
"Model has %d sliding-window attention layers; skipping the "
"shared attention KV pool (an SWA prefix hit recomputes the "
@@ -1115,11 +1102,9 @@ class MlxModelRunner:
Non-final chunked-prefill chunks discard their next-token output
(``extend_finalize`` pops it), yet the full model call still computes
vocab-sized float32 logits for every chunk position — for a 200K-vocab
model that is ~100x the useful head work and the largest transient
allocation in the process. ``self._trunk`` is resolved once at load;
returns None when the model exposes no headless trunk (caller falls
back to the full forward).
vocab-sized float32 logits for every chunk position — the largest
transient allocation in the process. Returns None when the model
exposes no headless trunk, and the caller runs the full forward.
"""
if self._trunk is None:
return None
@@ -1165,25 +1150,20 @@ class MlxModelRunner:
) -> tuple[mx.array, MlxLazyLogprobs | None]:
"""Pick one token per row of ``last_logits`` — lazily, inside the graph.
Greedy behavior (sampling disabled, or every row greedy with no
logit edits) is exactly the pre-sampling ``mx.argmax`` and consumes
no RNG state. ``edit_rows`` is the worker's pre-combined additive
[B, vocab] array (grammar mask + logit_bias), applied before token
selection and logprobs, mirroring the CUDA
``ModelRunner._preprocess_logits`` order. Positions for seeded rows
come from the attention cache offsets, which the just-built forward
has already advanced past the token being sampled — the same
``seq_len - 1`` the pytorch path feeds its sampler. They are
build-time Python ints, so this is chained-decode safe.
Greedy behavior (sampling disabled, or every row greedy with no logit
edits) is exactly the pre-sampling ``mx.argmax``. ``edit_rows`` is the
worker's pre-combined additive [B, vocab] array (grammar mask +
logit_bias), applied before token selection and logprobs, mirroring the
CUDA ``ModelRunner._preprocess_logits`` order. Positions for seeded
rows come from the attention cache offsets; they are build-time Python
ints, so this is chained-decode safe.
"""
if not self._enable_sampling:
return mx.argmax(last_logits, axis=-1), None
params = [self._req_sampling[rid] for rid in req_ids]
edited = self._edited_logits(last_logits, edit_rows)
greedy = all_greedy(params)
# Built once and shared: sampling and logprobs both start from
# logits/temperature, and MLX does not CSE the two identical graphs.
# Stays None when neither needs it (the greedy, no-logprob path).
# Shared by sampling and logprobs; None when neither needs it.
scaled = (
scale_by_temperature(edited, params)
if not greedy or logprob_spec is not None
@@ -1238,10 +1218,9 @@ class MlxModelRunner:
def _run_logits_hook(self, last_logits: mx.array, logits_hook) -> mx.array:
"""Materialize logits and let the worker edit them on the CPU.
Used for custom logit processors (arbitrary torch callables) — the
one edit that cannot be expressed lazily. Synchronizes the graph;
callers gate this to fresh, pure-decode launches, so the chained
overlap pipeline never pays for it.
Used for custom logit processors (arbitrary torch callables) — the one
edit that cannot be expressed lazily. Synchronizes the graph, so
callers gate it to fresh, pure-decode launches.
"""
logits32 = last_logits.astype(mx.float32)
mx.eval(logits32)
@@ -1279,9 +1258,7 @@ class MlxModelRunner:
"""Materialize a queued forward: token(s), cache writes and logprobs.
One ``mx.eval`` for the whole pending, so the attention
write-then-read ordering is materialised in a single kernel
submission. Prefill and extend carry one request's per-layer
cache; a decode carries one cache list per request.
write-then-read ordering lands in a single kernel submission.
"""
if isinstance(pending, MlxPendingDecode):
tokens, caches = pending.lazy_tokens, pending.caches
@@ -1297,11 +1274,10 @@ class MlxModelRunner:
def _dummy_next_token(hidden: mx.array) -> mx.array:
"""Graph-connected placeholder token for a skipped-head chunk.
Value is always 0 (a valid vocab id); it is appended and then popped
as the "stale intermediate token" by the next chunk's finalize.
Deriving it from ``hidden`` keeps the trunk in the lazy graph handed
to ``mx.eval``/``mx.async_eval`` (cache arrays are also evaluated
explicitly by both call paths).
Value is always 0 (a valid vocab id); it is appended and then popped as
the "stale intermediate token" by the next chunk's finalize. Deriving
it from ``hidden`` is what keeps the trunk in the lazy graph handed to
``mx.eval`` / ``mx.async_eval``.
"""
return (hidden[:, -1, 0] * 0).astype(mx.int32)
@@ -2,82 +2,26 @@
Token selection (temperature / top-k / top-p / min-p / per-request seed)
built entirely from ``mx`` ops, so it lives inside the same lazy graph as
the forward pass. This is what lets sampling coexist with the overlap
the forward pass. That is what lets sampling coexist with the overlap
scheduler: ``decode_batch_start_chained`` feeds step N's still-unevaluated
sampled tokens as step N+1's input ids, exactly as it does for greedy
argmax, and the GPU runs both steps back-to-back with no host sync.
sampled tokens as step N+1's input ids, with no host sync.
An earlier proposal (#25804) bridged MLX logits to the CPU pytorch
``Sampler`` instead. That design forces a host sync in the middle of the
graph-build window, which is precisely what the overlap scheduler exists
to avoid, so token selection is rebuilt from ``mx`` ops here.
Semantics mirror ``top_k_top_p_min_p_sampling_from_probs_torch`` /
``multinomial_with_seed`` in ``sglang/srt/layers/sampler.py``.
Semantics mirror the sglang pytorch sampling backend
(``top_k_top_p_min_p_sampling_from_probs_torch`` /
``multinomial_with_seed`` in ``sglang/srt/layers/sampler.py``):
Gaps against that backend:
* ``probs = softmax(logits / temperature)`` per row.
* Descending sort, then zero out rank >= top_k, cumulative-prob mass
beyond top_p (the top token is always kept), and probs below
``max_prob * min_p``.
* Multinomial sampling via the Gumbel-max identity:
``argmax(log(weights) + gumbel_noise)`` over the masked, unnormalized
weights is distributed identically to ``torch.multinomial(weights)``
(normalization only shifts ``log`` by a per-row constant).
* When every row asks for a small enough ``top_k``
(:data:`MAX_BOUNDED_TOP_K`), everything after the sort runs on the
``[B, K]`` candidates instead of ``[B, vocab]``. Weights are zero
outside those K, so their ``log`` is ``-inf`` and the full-vocab
argmax could never have picked them — same token, a fraction of the
work. Any row without a bounded ``top_k`` sends the batch back to the
full-vocab chain.
* Rows with ``sampling_seed`` set use deterministic Gumbel noise derived
from the same MurmurHash3 formula as the CUDA kernel
(``sglang/kernels/ops/sampling/murmur_hash.py``): hash(seed, position,
token_id) -> uniform -> ``-log(-log(u))``. Seeded noise is keyed on
the token id in every branch (the full-vocab chain scatters the masked
weights back through the sort; the bounded chain hashes the candidate
ids), so a seeded row's token never depends on whether a batchmate
triggered top-k/top-p/min-p filtering or on which chain ran.
* Greedy rows (``top_k == 1`` after sglang normalization, which rewrites
``temperature < eps`` to ``temperature=1, top_k=1``) short-circuit to
``argmax`` and consume no randomness.
Seeds follow the same gate as every other backend: ``sampling_seed`` is
consumed only under ``--enable-deterministic-inference``, which then
seeds every row (:data:`DEFAULT_SAMPLING_SEED` for requests that did not
ask for one). See :meth:`MlxSamplingParams.from_req`.
Known deviations from the pytorch backend (not bugs):
* Seeded determinism is MLX-local: noise math runs in float32 (Metal has
no float64) and tie order follows MLX's sort, so the same seed on a
CUDA backend may pick a different token from the same distribution.
* Unseeded rows draw their Gumbel noise in whichever space the chain is
running (candidate or vocab), so the bounded top-K path consumes the
RNG differently from the full-vocab one. Seeded rows are unaffected —
they hash the token id — so ``--enable-deterministic-inference`` is
bit-for-bit identical either way.
* Penalties (frequency/presence/repetition) are not applied on the MLX
path (warned once per process). #25804 skips them as well.
* Penalties (frequency/presence/repetition) are not applied (warned once
per process).
* Custom logit processors run on pure-decode steps only: the first
generated token and decode steps mixed into an extend batch are not
processed (``apply_custom_logit_processor`` requires logits rows to
match the full ``sampling_info``). Same scope as #25804, which only
hooked the pure-decode path at all.
* Logprob output covers the sampled token, top-k, and requested token
ids for every generated token; prompt/input logprobs
(``logprob_start_len``) are not computed.
Logit edits (grammar vocab masks, ``logit_bias``) arrive as a
pre-combined additive [B, vocab] array built by the worker at graph
launch — grammar FSM state is always current at a fresh launch because
the previous token was finalized before scheduling, so the mask is known
at build time and the graph stays lazy. Grammar/custom-processor
batches must not CHAIN (the mask for step N+1 needs token N
materialized); the scheduler breaks the chain for them. NaN/inf
sanitization mirrors ``sanitize_nan_logits`` and is gated on the same
``SGLANG_SANITIZE_NAN_LOGITS`` env var.
match the full ``sampling_info``).
* Logprobs cover the sampled token, top-k, and requested token ids;
prompt/input logprobs (``logprob_start_len``) are not computed.
* Seeded determinism is MLX-local: noise math runs in float32 (Metal has
no float64) and tie order follows MLX's sort, so the same seed on a
CUDA backend may pick a different token from the same distribution.
"""
from __future__ import annotations
@@ -220,11 +164,9 @@ def scale_by_temperature(
) -> mx.array:
"""``logits / temperature`` per row, in float32.
Both :func:`sample_tokens` and :func:`compute_logprobs` start here. MLX
builds eager graphs and does not eliminate common subexpressions, so a
step that samples *and* reports logprobs would otherwise pay two
full-vocab divisions; callers needing both compute this once and pass it
to each.
Both :func:`sample_tokens` and :func:`compute_logprobs` start here and take
the result as their ``scaled`` argument: MLX does not eliminate common
subexpressions, so a step doing both would otherwise divide twice.
"""
temps = mx.array([p.temperature for p in params], dtype=mx.float32)[:, None]
return last_logits.astype(mx.float32) / temps
@@ -243,9 +185,6 @@ def compute_logprobs(
``log_softmax(edited_logits / temperature)`` — after grammar-mask /
logit_bias / sanitization, before top-k/top-p/min-p filtering (the
filters affect which token is drawn, not the reported logprobs).
``scaled`` optionally supplies :func:`scale_by_temperature`'s result when
the caller already built it for :func:`sample_tokens`.
"""
if scaled is None:
scaled = scale_by_temperature(last_logits, params)
@@ -284,12 +223,9 @@ def sample_tokens(
"""Select one token per row of ``last_logits`` ([B, vocab], lazy ok).
Pure ``mx`` ops — the result stays inside the lazy graph. ``positions``
are the absolute sequence positions of the tokens being sampled (only
consumed for seeded rows). Callers should shortcut to ``mx.argmax``
when ``all_greedy(params)`` — this function assumes at least one row
samples. ``scaled`` optionally supplies
:func:`scale_by_temperature`'s result when the caller already built it
for :func:`compute_logprobs`.
are the absolute sequence positions being sampled (seeded rows only).
Assumes at least one row samples; callers shortcut to ``mx.argmax`` when
``all_greedy(params)``.
"""
batch_size, vocab_size = last_logits.shape
logits32 = last_logits.astype(mx.float32)
@@ -339,11 +275,9 @@ def sample_tokens(
)
log_weights = mx.log(weights)
else:
# Nothing is masked, so the weights are the plain softmax and
# ``log(softmax(scaled)) == scaled - logsumexp(scaled)``: a per-row
# constant offset, which the argmax below is invariant to. Feeding
# the scaled logits straight to the Gumbel-max drops two full-vocab
# passes (softmax, log) on the common temperature-only batch.
# Nothing is masked, so log(softmax(scaled)) is just scaled minus a
# per-row constant: feeding the scaled logits straight to the argmax
# below drops two full-vocab passes (softmax, log).
log_weights = scaled
noise = _gumbel_noise(
@@ -353,12 +287,9 @@ def sample_tokens(
key=key,
columns=candidates,
)
# Gumbel-max over the UNNORMALIZED masked weights: normalization would
# only shift log(w) by a per-row constant, which argmax is invariant to.
# That is also why seed + min_p is well-defined here, and why the
# pytorch backend's `assert sampling_seed is None` under min-p (and its
# TODO at layers/sampler.py "probs_sort should be re-normalized for the
# use of multinomial_with_seed") has no analogue on this path.
# Gumbel-max over the UNNORMALIZED masked weights: normalization would only
# shift log(w) by a per-row constant, which argmax is invariant to. That is
# also what keeps a seeded row well-defined under min_p.
sampled = mx.argmax(log_weights + noise, axis=-1)
if candidates is not None:
sampled = mx.take_along_axis(candidates, sampled[:, None], axis=-1).squeeze(-1)
@@ -366,9 +297,9 @@ def sample_tokens(
greedy = [p.is_greedy for p in params]
if not any(greedy):
return sampled
# A batch that mixes greedy rows in still runs them through the sampled
# path above (the row exists either way); overwrite those rows with the
# unnoised argmax, which is what makes greedy rows consume no randomness.
# Greedy rows still ran the sampled path above (the row exists either way);
# overwriting them with the unnoised argmax is what keeps their token
# independent of the noise.
return mx.where(mx.array(greedy), mx.argmax(logits32, axis=-1), sampled)
@@ -376,14 +307,12 @@ def _candidate_width(params: list[MlxSamplingParams], vocab_size: int) -> int:
"""Rank cut-off the filtered chain can run on, or ``vocab_size``.
Every rank at or beyond a row's ``top_k`` is masked to weight 0, whose
``log`` is ``-inf``, so the Gumbel-max can never select it. When every
row's ``top_k`` is small, the whole chain after the sort — gather,
cumsum, mask, log, noise, argmax — can therefore run on ``[B, K]``
instead of ``[B, vocab]`` and still pick exactly the same token.
Falls back to the full vocabulary as soon as one row wants more
candidates than :data:`MAX_BOUNDED_TOP_K` (or no top-k at all, which
``SamplingParams`` spells as ``top_k = TOP_K_ALL``).
``log`` is ``-inf``, so the Gumbel-max can never select it: when every
row's ``top_k`` is small the whole chain after the sort can run on
``[B, K]`` instead of ``[B, vocab]`` and still pick the same token.
Falls back to the full vocabulary as soon as one row wants more than
:data:`MAX_BOUNDED_TOP_K` (or no top-k at all, which ``SamplingParams``
spells as ``top_k = TOP_K_ALL``).
"""
largest_top_k = max(p.top_k for p in params)
if largest_top_k > MAX_BOUNDED_TOP_K or largest_top_k >= vocab_size:
@@ -416,11 +345,9 @@ def _gumbel_noise(
columns=columns,
)
u = hashed.astype(mx.float32) / float(0xFFFFFFFF)
# REQUIRED, not cosmetic: uint32(0xFFFFFFFF) rounds UP to 2**32 in
# float32, so the quotient can land just above 1.0 and make
# log(-log(u)) NaN; and an exact 1.0 gives -log(-log(1)) = +inf, which
# would deterministically force that token. Clamp both ends to the
# nearest representable interior values.
# uint32(0xFFFFFFFF) rounds UP to 2**32 in float32, so the quotient can land
# just above 1.0 (log(-log(u)) -> NaN); u == 1.0 gives +inf, which would
# deterministically force that token.
u = mx.clip(u, 2.0**-32, 1.0 - 2.0**-24)
hash_noise = -mx.log(-mx.log(u))
@@ -449,15 +376,11 @@ def _murmur_hash32(
) -> mx.array:
"""Port of ``murmur_hash32`` (Triton) to mx ops: [B, V] uint32.
Blocks mixed in kernel order: seed_low, seed_high, position, column.
The first three are per-row scalars, so they are folded exactly on the
CPU with Python ints; only the column block and finalization run as
vectorized uint32 ops (verified to wrap like the Triton kernel).
``columns`` hashes an explicit [B, K] set of token ids instead of every
id in ``[0, vocab_size)`` — same value per (row, token id) either way,
which is what keeps a seeded row's token identical on the bounded
top-K path.
Blocks mixed in kernel order: seed_low, seed_high, position, column. The
first three are per-row scalars, folded exactly on the CPU with Python
ints; only the column block and finalization run as vectorized uint32 ops.
``columns`` hashes an explicit [B, K] set of token ids instead of every id
in ``[0, vocab_size)``.
"""
row_states = []
for seed, pos in zip(seeds, positions):
@@ -61,10 +61,7 @@ class MlxPendingJob:
batch_copy: ScheduleBatch
schedule_batch: ScheduleBatch
reqs: List[Req]
# False when the batch needs per-step CPU logit state (grammar vocab
# masks / custom logit processors under --mlx-enable-sampling): step
# N+1's mask needs token N materialized, so such batches must launch
# fresh every step instead of chaining.
# See SchedulerMlxOverlapMixin._mlx_batch_chain_safe.
chain_safe: bool = True
# Captured at launch when batch.return_logprob, exactly like
# Scheduler.run_batch does for the CUDA paths (the live values mutate
@@ -54,19 +54,10 @@ class MlxLaunch:
Produced by :meth:`MlxTpModelWorker.async_forward_batch_generation_mlx`
and :meth:`MlxTpModelWorker.async_chained_decode_mlx`, consumed by
:meth:`MlxTpModelWorker.finalize_mlx_result`.
Attributes:
lazy_tokens: an ``mx.array`` that, when evaluated, forces
materialisation of the whole batch's outputs. ``None`` for
idle batches.
prefills: one :class:`MlxPendingPrefill` per new request in an
extend batch; empty for pure-decode steps.
extends: one :class:`MlxPendingExtend` per chunked-prefill
continuation; also empty for pure-decode steps.
decode: the :class:`MlxPendingDecode` covering full decode mode
AND mixed single-token decodes inside an extend batch.
mode: one of ``"idle"``, ``"decode"``, ``"extend"``.
:meth:`MlxTpModelWorker.finalize_mlx_result`. Evaluating ``lazy_tokens``
materialises the whole batch. ``decode`` covers both full decode mode and
single-token decodes mixed into an extend batch; ``mode`` is one of
``"idle"``, ``"decode"``, ``"extend"``.
"""
lazy_tokens: Optional[mx.array]
@@ -389,13 +380,9 @@ class MlxTpModelWorker(TpModelWorker):
Reachable only under ``--disable-overlap-schedule``: the default MLX
loop drives :meth:`async_forward_batch_generation_mlx` /
:meth:`finalize_mlx_result` directly and never calls ``run_batch``.
Launching and finalising back-to-back IS the synchronous path — the
lazy graph is built exactly the same way, then blocked on
immediately — so routing, logit edits, logprob collection and
chunk-head skipping have one implementation rather than two that
must be kept in step. It is also strictly cheaper than evaluating
each request as it is queued: one ``mx.async_eval`` covers the whole
batch.
Launching and finalising back-to-back builds the same lazy graph, so
routing, logit edits, logprob collection and chunk-head skipping keep
one implementation instead of two.
"""
launch = self.async_forward_batch_generation_mlx(batch)
return self.finalize_mlx_result(launch, batch.reqs)
@@ -1382,6 +1382,9 @@ class FlashInferAttnBackend(AttentionBackend):
sm_scale=layer.scaling,
window_left=swa_window_left,
logits_soft_cap=logits_soft_cap,
# Must use _float to avoid device-to-host copy that breaks cuda graph capture.
k_scale=layer.k_scale_float,
v_scale=layer.v_scale_float,
)
o, _ = _safe_merge_state(o1, s1, o2, s2)
@@ -101,15 +101,18 @@ def should_run_flashinfer_autotune(
from sglang.srt.layers.quantization.fp8_utils import (
get_fp8_gemm_runner_backend,
)
from sglang.srt.utils import is_sm100_supported
from sglang.srt.utils import is_sm100_supported, is_sm120_supported
model_uses_modelopt_fp8 = model_quantization in (
"modelopt",
"modelopt_fp8",
"modelopt_mixed",
)
# SM120 satisfies is_blackwell_supported(), so resolve_mxfp8_dense_gemm_backend
# sends it to the same tunable FlashInfer CUTLASS MXFP8 dense GEMM as SM100;
# without this the kernel always runs at tactic=-1.
fp8_gemm_needs_autotune = get_fp8_gemm_runner_backend().is_flashinfer_cutlass() or (
model_uses_modelopt_fp8 and is_sm100_supported()
model_uses_modelopt_fp8 and (is_sm100_supported() or is_sm120_supported())
)
if not (moe_needs_autotune or fp4_gemm_needs_autotune or fp8_gemm_needs_autotune):
+4 -6
View File
@@ -5378,12 +5378,10 @@ class ServerArgs:
elif model_arch in ["GptOssForCausalLM"]:
# Attention backend selection + XPU dtype validation moved to the
# override registry (arg_groups/overrides.py: _gpt_oss_overrides).
# None of these backends exist on MPS, and under MLX attention
# runs inside the MLX runner, so attention_backend is still unset
# at this point (the torch_native default fills later). macOS
# *without* MLX is not exempt: it has no runner of its own, so it
# must still be held to the supported-backend list instead of
# silently landing on torch_native (no SWA, no sinks).
# Exempt MLX only: none of these backends exist on MPS, and MLX runs
# attention inside its own runner, so attention_backend is still
# unset here. Plain macOS stays on the list -- torch_native has
# neither sliding window nor attention sinks.
if not (is_mps() and use_mlx()):
supported_backends = [
"triton",