config: decisions keyed on the attention backend read the configured pair
`--attention-backend` is one field of three: a launch that sets only
`--prefill-attention-backend` or `--decode-attention-backend` leaves the base
field at `None`. Seven decisions read that base field alone and therefore
answered from a field the operator never set. `attention_backends()` is the
pair with the base-field fallback already applied, so each site now asks it for
the half it actually needs:
- `inkling_common/attn` assembles backend-specific kwargs (rel_bias / score
mods) and gates its fused prologue; the backend those describe is the one
`self.attn` dispatches to, so `serving_attention_backend()` selects the pair
member by `forward_batch.forward_mode`, mirroring
`HybridAttnBackend._select_backend` exactly -- draft-extend routes through
the prefill branch like the dispatcher does -- and preferring the
runner-stamped pair, so a draft runner answers with its own backend. That
preference only works if every backend that can enter a ForwardContext
carries the stamp, so `DraftBackendFactory._create_backend` now stamps its
products with the backend it resolved (draft override first), and the
draft-extend conv-sidecar wrapper copies the wrapped backend's stamp -- the
replacement backends the spec workers install had no stamp at all and fell
back to the target's configured pair.
- The chunked-prefix-cache gate is a *prefill* feature -> prefill half. Reading
the base field switched the feature off for every prefill-only configuration.
- `init_deterministic_inference_config` maps *prefill* knobs
(SPLIT_TILE / PREFILL_TRUNCATION_ALIGN) -> prefill half; the map missed and
left truncation unset.
- `two_batch_overlap` computes extend positions -> prefill half.
- mrope's interleaved-rope kernel runs in both phases -> both halves must
support triton. This one is not conservative when it misreads:
`support_triton(None)` answers **True**, so a `--prefill-attention-backend
torch_native` launch took the triton path.
- The req-to-token writer has one caller, `alloc_for_extend` -> prefill half;
its fallback pays several `.item()` syncs per request, so gating it on the
decode half too would send every extend of a mixed launch through the slow
path. `get_last_loc` (the spec-decode allocator's helper) keeps the
both-halves reading: verify tokens are served by either half depending on
`speculative_attention_mode`.
- The flashinfer version floor is a guard; it never fired for a launch that
pinned flashinfer through a split field.
One more site the census found is not converted here: `gpt_oss` derives its
`sinks` parameter dtype from the backend, and a single parameter dtype cannot
serve a split pair (FA4 asserts bfloat16, trtllm_mha consumes float32), so
that one is a behaviour question rather than a config-source one and is fixed
in its own PR.
`test_split_attention_backend_decisions.py` pins the callable decisions by
calling them under a split-only publish, and pins the remaining ones
statically -- the file/why map fails if any of them goes back to the base field
(reverse-verified). It also asserts the `support_triton(None) is True` trap the
sweep exists for.
The stamp comes from the constructor, not the request: every factory leaf
answers ("effective_name", backend), because several map entries do not build
what their key says -- cutedsl_mla draft-extend builds the trtllm-mla backend,
"nsa" is a deprecated alias building dsa, and the hybrid-linear entries pick
fa3/intel_amx/triton by host, which no static rename table can express (a
review catch: on Blackwell the alias stamp reached Inkling's per-forward
kwargs assembly, which asserts a concrete kernel name, and crashed the first
draft-extend forward). The stamping is pinned by unit tests, not only by a
spec e2e: removing the child-stamping loop, stamping an alias from a leaf, or
dropping the wrapper copy goes red (reverse-verified), and a static guard
walks the factory source asserting no leaf answers an alias name. The child loop states its contract explicitly --
`create_decode_backend` passes `stamps_children=True` because its products
are per-step containers by construction, so a container without
`attn_backends` raises instead of being silently skipped by a defensive
probe. The `_version` invalidation names its contract (autograd's in-place
counter: private, chosen because it is the only per-tensor signal that ticks
on copy_-style updates; removal fails loudly). The version-floor guard's file
joins the pair-reader ratchet, and the one runner-seed chain read sharing the
backend's __init__ (`speculative_eagle_topk`) reads the spec bag.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
"""Decisions keyed on the attention backend must read the configured pair.
|
||||
|
||||
`--attention-backend` is one of three fields: the base one and the two split
|
||||
ones (`--prefill-attention-backend` / `--decode-attention-backend`). A launch
|
||||
that sets only a split field leaves the base at `None`, so a decision that reads
|
||||
`attention_backend` alone answers from a field the operator never set. What that
|
||||
cost, before the sweep these cases guard:
|
||||
|
||||
- a weight sized for the wrong dtype (gpt-oss `sinks` under trtllm_mha; that
|
||||
decision is now gone -- the weight stays bfloat16 and the trtllm backend
|
||||
upcasts at its call site, since at model-build time no config read can say
|
||||
which backend serves this runner's forwards),
|
||||
- a prefill feature switched off (chunked prefix cache),
|
||||
- a triton kernel chosen for a backend that cannot host it (`support_triton(None)`
|
||||
answers True), in mrope and in the req-to-token writer,
|
||||
- a version guard that never fires (flashinfer),
|
||||
- a deterministic-inference knob left unset (prefill truncation align).
|
||||
|
||||
`attention_backends()` is the shared answer: the pair with the base-field
|
||||
fallback applied. The callable decisions are checked by calling them; the rest
|
||||
are pinned statically, since reproducing them means building a model or a
|
||||
scheduler.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from sglang.srt.runtime_context import attention_backends, get_context
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
|
||||
|
||||
import sglang
|
||||
|
||||
_PACKAGE_ROOT = Path(next(iter(sglang.__path__))) / "srt"
|
||||
|
||||
# The decisions this file is about, and which half of the pair each one needs.
|
||||
# A base-only read here is the regression; the resolution pipeline and the two
|
||||
# modules that own the config are exempt because "did the operator pin the base
|
||||
# field?" is a real question *there*.
|
||||
_PAIR_READERS = {
|
||||
"models/inkling_common/attn.py": "the half serving the forward (mirrors hybrid dispatch)",
|
||||
"model_executor/model_runner_components/misc_utils.py": "prefill (chunked prefix cache)",
|
||||
"layers/rotary_embedding/mrope.py": "both (triton availability)",
|
||||
"mem_cache/allocation.py": "prefill (req-to-token writer); both (get_last_loc)",
|
||||
"batch_overlap/two_batch_overlap.py": "prefill (extend positions)",
|
||||
"managers/scheduler.py": "prefill (truncation align knobs)",
|
||||
"entrypoints/engine.py": "either half (flashinfer version floor)",
|
||||
}
|
||||
|
||||
|
||||
class TestSplitBackendsReachTheDecisions(CustomTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._saved = get_context()._server_args
|
||||
|
||||
def tearDown(self):
|
||||
if self._saved is not None:
|
||||
get_context().set_server_args(self._saved)
|
||||
super().tearDown()
|
||||
|
||||
def _publish(self, **fields):
|
||||
override = get_context().override_server_args(**fields)
|
||||
override.install()
|
||||
self.addCleanup(override.restore)
|
||||
|
||||
def test_the_pair_is_what_a_split_only_launch_configures(self):
|
||||
self._publish(
|
||||
attention_backend=None,
|
||||
prefill_attention_backend="triton",
|
||||
decode_attention_backend="trtllm_mha",
|
||||
)
|
||||
self.assertEqual(attention_backends(), ("triton", "trtllm_mha"))
|
||||
|
||||
def test_chunked_prefix_cache_follows_the_prefill_backend(self):
|
||||
from sglang.srt.model_executor.model_runner_components.misc_utils import (
|
||||
maybe_disable_chunked_prefix_cache,
|
||||
)
|
||||
from sglang.srt.runtime_context import get_schedule
|
||||
|
||||
# A prefill backend that supports the feature, configured *only* through
|
||||
# the split field: the gate must leave it on.
|
||||
self._publish(
|
||||
attention_backend=None,
|
||||
prefill_attention_backend="fa3",
|
||||
decode_attention_backend="triton",
|
||||
disable_chunked_prefix_cache=False,
|
||||
)
|
||||
maybe_disable_chunked_prefix_cache(use_mla_backend=True, is_draft_worker=False)
|
||||
self.assertFalse(get_schedule().disable_chunked_prefix_cache)
|
||||
|
||||
# And an unsupported one still switches it off.
|
||||
self._publish(
|
||||
attention_backend=None,
|
||||
prefill_attention_backend="torch_native",
|
||||
decode_attention_backend="fa3",
|
||||
disable_chunked_prefix_cache=False,
|
||||
)
|
||||
maybe_disable_chunked_prefix_cache(use_mla_backend=True, is_draft_worker=False)
|
||||
self.assertTrue(get_schedule().disable_chunked_prefix_cache)
|
||||
|
||||
def test_inkling_selects_the_half_serving_the_forward(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.model_executor.forward_batch_info import ForwardMode
|
||||
from sglang.srt.model_executor.forward_context import (
|
||||
ForwardContext,
|
||||
forward_context,
|
||||
)
|
||||
from sglang.srt.models.inkling_common.attn import serving_attention_backend
|
||||
|
||||
self._publish(
|
||||
attention_backend=None,
|
||||
prefill_attention_backend="triton",
|
||||
decode_attention_backend="fa4",
|
||||
speculative_attention_mode="prefill",
|
||||
)
|
||||
|
||||
def batch(mode):
|
||||
return SimpleNamespace(forward_mode=mode)
|
||||
|
||||
unstamped = SimpleNamespace(
|
||||
prefill_attention_backend_str=None, decode_attention_backend_str=None
|
||||
)
|
||||
with forward_context(ForwardContext(attn_backend=unstamped)):
|
||||
self.assertEqual(
|
||||
serving_attention_backend(batch(ForwardMode.EXTEND)), "triton"
|
||||
)
|
||||
self.assertEqual(
|
||||
serving_attention_backend(batch(ForwardMode.DECODE)), "fa4"
|
||||
)
|
||||
self.assertEqual(serving_attention_backend(batch(ForwardMode.IDLE)), "fa4")
|
||||
self.assertEqual(
|
||||
serving_attention_backend(batch(ForwardMode.TARGET_VERIFY)), "triton"
|
||||
)
|
||||
|
||||
# Draft-extend routes through the hybrid dispatcher's prefill branch
|
||||
# regardless of the spec mode.
|
||||
with forward_context(ForwardContext(attn_backend=unstamped)):
|
||||
self.assertEqual(
|
||||
serving_attention_backend(batch(ForwardMode.DRAFT_EXTEND_V2)),
|
||||
"triton",
|
||||
)
|
||||
|
||||
self._publish(
|
||||
attention_backend=None,
|
||||
prefill_attention_backend="triton",
|
||||
decode_attention_backend="fa4",
|
||||
speculative_attention_mode="decode",
|
||||
)
|
||||
with forward_context(ForwardContext(attn_backend=unstamped)):
|
||||
self.assertEqual(
|
||||
serving_attention_backend(batch(ForwardMode.TARGET_VERIFY)), "fa4"
|
||||
)
|
||||
self.assertEqual(
|
||||
serving_attention_backend(batch(ForwardMode.DRAFT_EXTEND_V2)),
|
||||
"triton",
|
||||
)
|
||||
|
||||
# The pair the runner stamped on its backend wins over the bags: a
|
||||
# draft runner serves every phase with its own backend.
|
||||
stamped = SimpleNamespace(
|
||||
prefill_attention_backend_str="fa4", decode_attention_backend_str="fa4"
|
||||
)
|
||||
with forward_context(ForwardContext(attn_backend=stamped)):
|
||||
self.assertEqual(
|
||||
serving_attention_backend(batch(ForwardMode.EXTEND)), "fa4"
|
||||
)
|
||||
|
||||
def test_the_flashinfer_version_guard_sees_a_split_launch(self):
|
||||
# The launcher runs before any publish, so it asks the record; the
|
||||
# member and the accessor answer the same pair.
|
||||
args = ServerArgs.__new__(ServerArgs)
|
||||
for name, value in (
|
||||
("attention_backend", None),
|
||||
("prefill_attention_backend", None),
|
||||
("decode_attention_backend", "flashinfer"),
|
||||
):
|
||||
object.__setattr__(args, name, value)
|
||||
self.assertIn("flashinfer", args.get_attention_backends())
|
||||
|
||||
def test_support_triton_is_the_regression_being_guarded(self):
|
||||
from sglang.srt.utils.common import support_triton
|
||||
|
||||
# This is why a base-only read is not merely imprecise: the unset field
|
||||
# reads as "supported".
|
||||
self.assertTrue(support_triton(None))
|
||||
|
||||
def test_no_listed_decision_reads_the_base_field_alone(self):
|
||||
offenders = []
|
||||
for rel, why in _PAIR_READERS.items():
|
||||
tree = ast.parse((_PACKAGE_ROOT / rel).read_text())
|
||||
for node in ast.walk(tree):
|
||||
# Any attribute read named `attention_backend` is the base
|
||||
# field, whatever the base expression is spelled as -- a bag
|
||||
# chain, a record, or a local alias of either
|
||||
# (`k = get_exec().kernel; k.attention_backend`). The pair
|
||||
# helpers are calls, not attributes, so they never match.
|
||||
if isinstance(node, ast.Attribute) and node.attr == "attention_backend":
|
||||
offenders.append(f"{rel}:{node.lineno}: base-only read ({why})")
|
||||
self.assertEqual(
|
||||
[],
|
||||
offenders,
|
||||
"these decisions must read attention_backends() (the pair with the "
|
||||
"base-field fallback), not the base field:\n" + "\n".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
class TestDraftFactoryStamping(CustomTestCase):
|
||||
"""The factory's products carry the stamp `serving_attention_backend`
|
||||
prefers -- removing the child-stamping loop, the `cutedsl_mla` rename, or
|
||||
the wrapper copy goes red here, not only in a spec e2e."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._saved = get_context()._server_args
|
||||
|
||||
def tearDown(self):
|
||||
if self._saved is not None:
|
||||
get_context().set_server_args(self._saved)
|
||||
super().tearDown()
|
||||
|
||||
def _publish(self, **fields):
|
||||
override = get_context().override_server_args(**fields)
|
||||
override.install()
|
||||
self.addCleanup(override.restore)
|
||||
|
||||
def _factory(self, draft_backend=None):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.speculative.draft_utils import DraftBackendFactory
|
||||
|
||||
runner = SimpleNamespace(draft_attention_backend=draft_backend)
|
||||
return DraftBackendFactory(runner, topk=1, speculative_num_steps=2)
|
||||
|
||||
def test_a_decode_container_stamps_its_per_step_children(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
self._publish(attention_backend="triton")
|
||||
children = [SimpleNamespace(), SimpleNamespace()]
|
||||
container = SimpleNamespace(attn_backends=children)
|
||||
product = self._factory()._create_backend(
|
||||
"decode_attention_backend",
|
||||
{"triton": lambda: ("triton", container)},
|
||||
"unsupported {backend_type}",
|
||||
stamps_children=True,
|
||||
)
|
||||
# EAGLE's eager loop puts the children into the ForwardContext
|
||||
# directly, so an unstamped child answers with the target pair.
|
||||
for obj in [product, *children]:
|
||||
self.assertEqual(obj.prefill_attention_backend_str, "triton")
|
||||
self.assertEqual(obj.decode_attention_backend_str, "triton")
|
||||
|
||||
def test_the_draft_override_wins_over_the_published_pair(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
self._publish(attention_backend="triton")
|
||||
product = self._factory(draft_backend="trtllm_mha")._create_backend(
|
||||
"decode_attention_backend",
|
||||
{"trtllm_mha": lambda: ("trtllm_mha", SimpleNamespace(attn_backends=[]))},
|
||||
"unsupported {backend_type}",
|
||||
stamps_children=True,
|
||||
)
|
||||
self.assertEqual(product.prefill_attention_backend_str, "trtllm_mha")
|
||||
|
||||
def test_cutedsl_draft_extend_stamps_the_effective_kernel(self):
|
||||
# cutedsl_mla only supports decode; the draft-extend map builds the
|
||||
# trtllm-mla backend, and the *constructor* answers the effective
|
||||
# name, so the stamp says what actually runs with no rename table --
|
||||
# and the conv-sidecar wrapper that enters the ForwardContext must
|
||||
# answer with the wrapped backend's stamp.
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from sglang.srt.layers.attention import attention_registry
|
||||
|
||||
self._publish(attention_backend="triton")
|
||||
factory = self._factory(draft_backend="cutedsl_mla")
|
||||
built = SimpleNamespace()
|
||||
factory._create_trtllm_mla_prefill_backend = lambda: ("trtllm_mla", built)
|
||||
wrapper = SimpleNamespace()
|
||||
with mock.patch.object(
|
||||
attention_registry,
|
||||
"attn_backend_wrapper_for_draft_extend",
|
||||
lambda runner, backend: wrapper,
|
||||
):
|
||||
product = factory.create_draft_extend_backend()
|
||||
self.assertIs(product, wrapper)
|
||||
self.assertEqual(built.prefill_attention_backend_str, "trtllm_mla")
|
||||
self.assertEqual(product.prefill_attention_backend_str, "trtllm_mla")
|
||||
self.assertEqual(product.decode_attention_backend_str, "trtllm_mla")
|
||||
|
||||
def test_a_host_dependent_alias_stamps_the_concrete_kernel(self):
|
||||
# `hybrid_linear_attn` picks fa3/intel_amx/triton by host inside its
|
||||
# constructor, so no static rename can say what it builds -- the
|
||||
# constructor's own answer is the stamp. A stamp that repeats the
|
||||
# alias crashes Inkling's per-forward kwargs assembly, which asserts
|
||||
# the name is a concrete kernel.
|
||||
from types import SimpleNamespace
|
||||
|
||||
self._publish(attention_backend="hybrid_linear_attn")
|
||||
concrete = SimpleNamespace(attn_backends=[SimpleNamespace()])
|
||||
product = self._factory()._create_backend(
|
||||
"decode_attention_backend",
|
||||
{"hybrid_linear_attn": lambda: ("triton", concrete)},
|
||||
"unsupported {backend_type}",
|
||||
stamps_children=True,
|
||||
)
|
||||
self.assertEqual(product.prefill_attention_backend_str, "triton")
|
||||
self.assertEqual(
|
||||
product.attn_backends[0].decode_attention_backend_str, "triton"
|
||||
)
|
||||
|
||||
def test_the_real_map_never_stamps_an_alias(self):
|
||||
# The factory's real constructors each answer their effective name;
|
||||
# this pins that no map key with an aliased or host-dependent
|
||||
# constructor ("nsa", "cutedsl_mla", "hybrid_linear_attn") can leak
|
||||
# its request name into a stamp: whatever the leaf built, the name it
|
||||
# answered is a concrete kernel, never one of the alias keys.
|
||||
import ast as _ast
|
||||
import inspect
|
||||
|
||||
from sglang.srt.speculative import draft_utils
|
||||
|
||||
tree = _ast.parse(inspect.getsource(draft_utils))
|
||||
offenders = []
|
||||
for node in _ast.walk(tree):
|
||||
if not isinstance(node, _ast.FunctionDef):
|
||||
continue
|
||||
if not node.name.startswith("_create_") or "_backend" not in node.name:
|
||||
continue
|
||||
for ret in _ast.walk(node):
|
||||
if not isinstance(ret, _ast.Return) or ret.value is None:
|
||||
continue
|
||||
# Leaf returns are ("name", ctor(...)); delegations return the
|
||||
# inner call. A bare backend return would silently miss the
|
||||
# stamp contract.
|
||||
if isinstance(ret.value, _ast.Tuple):
|
||||
name = ret.value.elts[0]
|
||||
if isinstance(name, _ast.Constant) and name.value in (
|
||||
"nsa",
|
||||
"hybrid_linear_attn",
|
||||
):
|
||||
offenders.append(f"{node.name}: stamps alias {name.value!r}")
|
||||
elif isinstance(ret.value, _ast.Call):
|
||||
fn = ret.value.func
|
||||
is_delegation = isinstance(
|
||||
fn, _ast.Attribute
|
||||
) and fn.attr.startswith("_create_")
|
||||
if not is_delegation:
|
||||
offenders.append(
|
||||
f"{node.name}: returns a bare backend (no effective name)"
|
||||
)
|
||||
self.assertEqual([], offenders, "\n".join(offenders))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user