[CI] Move the static ratchets back to CPU unit tests (#34913)

This commit is contained in:
Liangsheng Yin
2026-08-14 23:32:00 -07:00
committed by GitHub
parent 2a89c6823a
commit 5c9ee86d90
14 changed files with 399 additions and 371 deletions
@@ -1,235 +0,0 @@
"""Ownership contract for per-request bookkeeping clocks.
Per-request accounting state (`decode_batch_idx` / `extend_batch_idx` iter
clocks, `kv_committed_len` / `kv_allocated_len` KV watermarks,
`spec_verify_ct`, and the `maybe_evict_swa()` call) must only be advanced by
the reviewed owner sites in _OWNER_SITES; spec-v2 draft workers must not
repeat any of them (the scheduler-driven free function / resolve path already
does).
A clock that runs fast fires SWA eviction in the overlap race window and
releases the SWA prefix lock early; neither shows up in e2e CI or the idle
leak checker, hence this AST-level guard.
"""
import ast
import warnings
from collections import Counter
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SRT_DIR = _REPO_ROOT / "python" / "sglang" / "srt"
_SPECULATIVE_DIR = _SRT_DIR / "speculative"
assert _SRT_DIR.is_dir(), f"srt dir not found: {_SRT_DIR}"
_TRACKED_ATTRS = (
"decode_batch_idx",
"extend_batch_idx",
"kv_committed_len",
"kv_allocated_len",
"spec_verify_ct",
)
_EVICT_METHOD = "maybe_evict_swa"
# {(path relative to srt/, scope, kind): mutation count}. Kind is the mutated
# attribute (`= 0` resets exempt) or "evict" for a `maybe_evict_swa()` call.
# Any added/removed/recounted site fails until reviewed here.
_SB = "managers/schedule_batch.py"
_EAGLE_DECODE = ("speculative/eagle_utils.py", "eagle_prepare_for_decode")
_RESOLVE = (
"managers/scheduler_components/batch_result_processor.py",
"SchedulerBatchResultProcessor._resolve_spec_v2_tokens",
)
_SS = "session/streaming_session.py"
_OWNER_SITES = {
# non-spec scheduler
(_SB, "ScheduleBatch.prepare_for_decode", "decode_batch_idx"): 1,
(_SB, "ScheduleBatch.prepare_for_decode", "kv_committed_len"): 1,
(_SB, "ScheduleBatch.prepare_for_extend", "extend_batch_idx"): 1,
(_SB, "ScheduleBatch.prepare_for_extend", "kv_committed_len"): 1,
# kv_allocated_len is settled inside the owned-kv alloc functions (op28).
("mem_cache/allocation.py", "alloc_for_extend", "evict"): 1,
("mem_cache/allocation.py", "alloc_for_extend", "kv_allocated_len"): 1,
("mem_cache/allocation.py", "alloc_for_decode", "evict"): 1,
("mem_cache/allocation.py", "alloc_for_decode", "kv_allocated_len"): 1,
# spec v2: no pre-claim; resolve commits the full accepted run uniformly.
# kv_allocated_len for spec v2 draft decode (eagle + dflash) is settled
# inside the owned-kv alloc_for_spec_decode function (op42).
(*_EAGLE_DECODE, "decode_batch_idx"): 1,
(*_EAGLE_DECODE, "evict"): 1,
(
"mem_cache/allocation.py",
"alloc_for_spec_decode",
"kv_allocated_len",
): 1,
(*_RESOLVE, "kv_committed_len"): 1,
(*_RESOLVE, "spec_verify_ct"): 1,
# disaggregation decode prealloc: kv_allocated_len is settled inside the
# owned-kv alloc_for_decode_prealloc(_hisparse) functions (op42).
(
"disaggregation/decode.py",
"DecodePreallocQueue._pre_alloc",
"kv_committed_len",
): 1,
("disaggregation/decode.py", "alloc_for_decode_prealloc", "kv_allocated_len"): 1,
(
"disaggregation/decode.py",
"alloc_for_decode_prealloc_hisparse",
"kv_allocated_len",
): 1,
# streaming session slot save/restore and tail trimming
(_SS, "SessionSlot.save_from_req", "kv_committed_len"): 1,
(_SS, "SessionSlot.restore_to_req", "kv_committed_len"): 1,
(_SS, "StreamingSession._free_tail", "kv_committed_len"): 2,
(_SS, "StreamingSession._free_tail", "kv_allocated_len"): 2,
(_SS, "StreamingSession._trim_overshoot", "kv_committed_len"): 1,
(_SS, "StreamingSession._trim_overshoot", "kv_allocated_len"): 1,
(_SS, "StreamingSession.try_cache_finished_req", "kv_allocated_len"): 1,
# Inherit the authoritative finished length (not the lagging req clock).
(_SS, "StreamingSession.try_cache_finished_req", "kv_committed_len"): 1,
}
def _iter_scoped_nodes(tree):
"""Yield (node, dotted Class.method scope) for every node."""
scope_of = {}
def visit(node, scope):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
scope = f"{scope}.{node.name}" if scope else node.name
scope_of[node] = scope
for child in ast.iter_child_nodes(node):
visit(child, scope)
visit(tree, "")
return scope_of.items()
def _is_zero_reset(node):
return isinstance(node, ast.Assign) and (
isinstance(node.value, ast.Constant) and node.value.value == 0
)
def _scan_tree(tree):
"""Count bookkeeping sites in an AST as Counter[(scope, kind)]."""
sites = Counter()
for node, scope in _iter_scoped_nodes(tree):
if isinstance(node, (ast.AugAssign, ast.Assign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if (
isinstance(target, ast.Attribute)
and target.attr in _TRACKED_ATTRS
and not _is_zero_reset(node)
):
sites[(scope, target.attr)] += 1
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == _EVICT_METHOD
):
sites[(scope, "evict")] += 1
return sites
def _parse(path: Path):
# utf-8-sig: some srt files carry a BOM that breaks plain-utf-8 ast.parse.
with warnings.catch_warnings():
warnings.simplefilter("ignore", SyntaxWarning)
return ast.parse(path.read_text(encoding="utf-8-sig"))
def _scan_srt():
"""Count all bookkeeping sites in srt/ as Counter[(rel, scope, kind)]."""
found = Counter()
for path in sorted(_SRT_DIR.rglob("*.py")):
rel = path.relative_to(_SRT_DIR).as_posix()
for (scope, kind), count in _scan_tree(_parse(path)).items():
found[(rel, scope, kind)] += count
return found
def _draft_worker_classes():
"""All transitive EagleDraftWorkerBase subclasses under speculative/."""
by_name = {}
for path in sorted(_SPECULATIVE_DIR.glob("*.py")):
rel = path.relative_to(_SRT_DIR).as_posix()
for node in ast.walk(_parse(path)):
if isinstance(node, ast.ClassDef):
bases = {
b.id if isinstance(b, ast.Name) else getattr(b, "attr", None)
for b in node.bases
}
by_name[node.name] = (rel, node, bases)
workers = {"EagleDraftWorkerBase"}
changed = True
while changed:
changed = False
for name, (_, _, bases) in by_name.items():
if name not in workers and bases & workers:
workers.add(name)
changed = True
return [
(rel, node)
for name, (rel, node, _) in sorted(by_name.items())
if name in workers and name != "EagleDraftWorkerBase"
]
def _scan_class_subtree(class_node):
"""Scan one ClassDef subtree; returns (method_scope, kind) sites."""
module = ast.Module(body=[class_node], type_ignores=[])
sites = set()
for scope, kind in _scan_tree(module):
# Strip the leading class name; keep method-level scope.
sites.add((scope.split(".", 1)[1] if "." in scope else scope, kind))
return sites
def check_bookkeeping_sites_match_owner_allowlist():
found = _scan_srt()
allow = Counter(_OWNER_SITES)
unexpected = found - allow
missing = allow - found
messages = []
if unexpected:
messages.append(
"New bookkeeping mutation(s) beyond the recorded counts:\n "
+ "\n ".join(f"{site} x{n}" for site, n in sorted(unexpected.items()))
+ "\nThese are owned by the sites in _OWNER_SITES -- do not "
"repeat them; a genuinely new owner must be recorded there."
)
if missing:
messages.append(
"Recorded site(s) no longer exist (update _OWNER_SITES):\n "
+ "\n ".join(f"{site} x{n}" for site, n in sorted(missing.items()))
)
if messages:
raise AssertionError("\n\n".join(messages))
def check_spec_v2_draft_workers_do_no_scheduler_bookkeeping():
classes = _draft_worker_classes()
names = {node.name for _, node in classes}
for expected in ("EagleDraftWorker", "FrozenKVMTPDraftWorker"):
if expected not in names:
raise AssertionError(f"draft worker discovery missed {expected}")
violations = []
for rel, node in classes:
for scope, kind in _scan_class_subtree(node):
violations.append((rel, f"{node.name}.{scope}", kind))
if violations:
raise AssertionError(
"Spec-v2 draft worker(s) repeat scheduler-owned bookkeeping:\n "
+ "\n ".join(map(str, sorted(violations)))
+ "\nUnder spec v2 the iter-clock ticks, `maybe_evict_swa`, and "
"KV watermark settlement are owned by the scheduler-driven "
"free function / resolve path. Remove these from the worker.",
)
if __name__ == "__main__":
check_bookkeeping_sites_match_owner_allowlist()
check_spec_v2_draft_workers_do_no_scheduler_bookkeeping()
@@ -1,471 +0,0 @@
"""Guard: business code never reads a config field off the process-global record.
``get_server_args()`` returns the published ``ServerArgs`` -- one process's
startup record. Config decisions read the namespace accessors instead
(``get_exec()`` / ``get_memory()`` / ...); per-runner values come from the
runner that owns them. Both baselines are zero, over the whole package minus
the modules that own the slot.
The scanners match ``get_server_args`` and ``configured_*_size`` by their
literal names, which is why import-renaming them is banned below. A name
computed at runtime, or indirection deeper than a local name copy, is invisible
here -- the census tool in the context repo audits that shape.
"""
import ast
from functools import cache
from pathlib import Path
# srt is the migrated surface; the rest of the package has no reads today and is
# scanned so a new one cannot appear there unnoticed.
_PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "python" / "sglang"
# The modules that own the slot: runtime_context publishes it and exposes the
# named accessors for the derived members, server_args/arg_groups ARE the
# resolution pipeline.
_SLOT_OWNERS = ("srt/runtime_context.py", "srt/server_args.py", "srt/arg_groups/")
# Every call site of a ``configured_*_size()`` accessor, with the reason the
# live topology cannot answer there. The checker below asserts this map is exactly
# the set of call sites, so the reasons cannot drift away from the code.
_CONFIGURED_SIZE_CALL_SITES = {
("srt/layers/attention/dsa/dsa_indexer.py", "configured_pp_size"): (
"gates `pp_size > 1 and not get_pp_group()...`; the short circuit is the "
"point, since with PP off the group is never touched, which is what lets "
"the Indexer be constructed before distributed init"
),
("srt/mem_cache/kv_cache_configurator.py", "configured_pp_size"): (
"decides whether the token capacity needs a cross-PP all-reduce at all; "
"asking the configured size keeps that decision independent of whether a "
"PP group is installed in this process"
),
("srt/layers/dp_attention.py", "configured_attn_cp_size"): (
"compared against the configured moe_dp_size below"
),
("srt/layers/dp_attention.py", "configured_moe_dp_size"): (
"the configuration this predicate detects (attn_cp_size > moe_dp_size) is "
"the one where initialize_model_parallel aliases _MOE_DP to _ATTN_CP, so "
"the live sizes are equal there and a live comparison is always false"
),
("srt/model_loader/loader.py", "configured_moe_dp_size"): (
"the same dict already carries the live moe_dp_size under 'dp'; this entry "
"is the configured intent"
),
("srt/models/kimi_k25.py", "configured_tp_size"): (
"the IPC refcount must match the configured TP consumer count captured "
"when the tokenizer creates MmItemMemoryPool; a live attention subgroup "
"size could strand leases in the bounded pool"
),
("srt/models/kimi_k3.py", "configured_tp_size"): (
"same as kimi_k25: the IPC refcount must agree with the recycler's waiter"
),
}
# A dynamic read whose name is set nowhere in the tree, so the predicate it
# feeds is inert (the ``getattr`` default decides it). Converting it would mean
# choosing what it should have named, which is the CP path's call, not this
# sweep's -- so it is listed here rather than silently counted or "fixed".
_INERT_DYNAMIC_READS = frozenset({("srt/layers/cp/base.py", "_is_dsa_model_arch")})
_DIRECT_BASELINE = 0
_ALIAS_BASELINE = 0
def _is_global_call(node) -> bool:
"""``get_server_args()`` however it is spelled: bare, or module-qualified
(``ctx.get_server_args()``), which an ast.Name check alone would miss."""
if not isinstance(node, ast.Call):
return False
func = node.func
if isinstance(func, ast.Name):
return func.id == "get_server_args"
return isinstance(func, ast.Attribute) and func.attr == "get_server_args"
def _collect(rel: str, tree: ast.AST, inert: frozenset = frozenset()):
"""The (direct, alias) field reads in one module.
``inert`` names the fields listed in ``_INERT_DYNAMIC_READS`` for this file;
they are dropped here, at the point the read is recognized, so the filter
matches on the field name rather than on the rendered message.
"""
direct, alias = [], []
def counted(attr: str) -> bool:
return attr not in inert
def _getattr_name(node):
"""``getattr(<record>, "field")`` names a field just as ``.field`` does;
matching only ast.Attribute would let a dynamic read walk past."""
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "getattr"
and len(node.args) >= 2
and isinstance(node.args[1], ast.Constant)
and isinstance(node.args[1].value, str)
):
return None
return node.args[1].value
for node in ast.walk(tree):
if (
isinstance(node, ast.Attribute)
and _is_global_call(node.value)
and counted(node.attr)
):
direct.append(f"{rel}:{node.lineno}: get_server_args().{node.attr}")
name = _getattr_name(node)
if name is not None and _is_global_call(node.args[0]) and counted(name):
direct.append(f"{rel}:{node.lineno}: getattr(get_server_args(), {name!r})")
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
params = {a.arg for a in list(node.args.args) + list(node.args.kwonlyargs)}
bound = {}
for inner in ast.walk(node):
# ``sa = get_server_args()`` and its annotated form
# ``sa: ServerArgs = get_server_args()``.
if isinstance(inner, (ast.Assign, ast.AnnAssign)) and _is_global_call(
getattr(inner, "value", None)
):
targets = (
inner.targets if isinstance(inner, ast.Assign) else [inner.target]
)
for target in targets:
if not isinstance(target, ast.Name):
continue
# A parameter reassigned from the global is the
# optional-injection shape (``f(server_args=None)`` then
# ``server_args = get_server_args()``): the reads that
# follow are global reads wearing a parameter's name, so
# they count from the bind on.
bound.setdefault(target.id, inner.lineno)
if not bound:
continue
# A copy of an alias reaches the same record (``cfg = sa`` after
# ``sa = get_server_args()``), so follow Name-to-Name assignments to a
# fixpoint. Deeper indirection (through containers, attributes of
# other objects, cross-scope copies) stays census-tool territory.
changed = True
while changed:
changed = False
for inner in ast.walk(node):
if not isinstance(inner, (ast.Assign, ast.AnnAssign)):
continue
value = getattr(inner, "value", None)
if not (isinstance(value, ast.Name) and value.id in bound):
continue
targets = (
inner.targets if isinstance(inner, ast.Assign) else [inner.target]
)
for target in targets:
if isinstance(target, ast.Name) and target.id not in bound:
bound[target.id] = inner.lineno
changed = True
for inner in ast.walk(node):
if (
isinstance(inner, ast.Attribute)
and isinstance(inner.value, ast.Name)
and inner.value.id in bound
and inner.lineno >= bound[inner.value.id]
and counted(inner.attr)
):
alias.append(
f"{rel}:{inner.lineno}: {inner.value.id}.{inner.attr} "
f"(bound from get_server_args() at line {bound[inner.value.id]})"
)
name = _getattr_name(inner)
if (
name is not None
and isinstance(inner.args[0], ast.Name)
and inner.args[0].id in bound
and inner.lineno >= bound[inner.args[0].id]
and counted(name)
):
alias.append(
f"{rel}:{inner.lineno}: getattr({inner.args[0].id}, {name!r}) "
f"(bound from get_server_args() at line {bound[inner.args[0].id]})"
)
# A module-level alias is visible to every function in the file, so it needs
# its own pass -- the per-function scan above deliberately does not reach
# across scopes.
module_bound = {}
module_stack = list(tree.body)
while module_stack:
stmt = module_stack.pop()
# A module-level bind can sit inside an `if` / `try` / `with`, so the
# walk descends into those bodies -- but not into a nested function or
# class, whose binds are that scope's own.
if isinstance(
stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
):
continue
module_stack.extend(ast.iter_child_nodes(stmt))
if isinstance(stmt, (ast.Assign, ast.AnnAssign)) and _is_global_call(
getattr(stmt, "value", None)
):
targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
for target in targets:
if isinstance(target, ast.Name):
module_bound.setdefault(target.id, stmt.lineno)
if module_bound:
# Shadowing is per lexical scope: a function with its own `sa` hides the
# module alias *inside that function only*. Aggregating the names
# file-wide would suppress every read in the module, including the
# top-level ones and the ones in functions that do resolve to the alias.
parents = {}
scope_binds = {}
stack = [tree]
while stack:
node = stack.pop()
enclosing = parents.get(id(node))
for child in ast.iter_child_nodes(node):
parents[id(child)] = (
node
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
else enclosing
)
stack.append(child)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
names = {
a.arg for a in list(node.args.args) + list(node.args.kwonlyargs)
}
# Only this scope's own stores: a nested function's local `sa`
# shadows the alias inside *that* function, not in its parent.
pending = list(node.body)
while pending:
inner = pending.pop()
if isinstance(
inner,
(
ast.FunctionDef,
ast.AsyncFunctionDef,
ast.Lambda,
ast.ClassDef,
),
):
continue
if isinstance(inner, ast.Name) and isinstance(inner.ctx, ast.Store):
names.add(inner.id)
pending.extend(ast.iter_child_nodes(inner))
scope_binds[id(node)] = names
def _shadowed(node, name):
scope = parents.get(id(node))
while scope is not None:
if name in scope_binds.get(id(scope), ()):
return True
scope = parents.get(id(scope))
return False
for node in ast.walk(tree):
base = attr = None
if (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id in module_bound
):
base, attr = node.value.id, node.attr
shown = f"{base}.{attr}"
else:
attr_name = _getattr_name(node)
if (
attr_name is not None
and isinstance(node.args[0], ast.Name)
and node.args[0].id in module_bound
):
base, attr = node.args[0].id, attr_name
shown = f"getattr({base}, {attr!r})"
if base and not _shadowed(node, base) and counted(attr):
alias.append(
f"{rel}:{node.lineno}: {shown} "
f"(module-level bind from get_server_args() at line "
f"{module_bound[base]})"
)
# An alias parked on an instance attribute (``self._sa = get_server_args()``
# in one method, ``self._sa.field`` in another) reaches the same slot and
# crosses function scopes, so it is collected per class rather than per
# function.
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
attr_bound = {}
for inner in ast.walk(node):
if isinstance(inner, (ast.Assign, ast.AnnAssign)) and _is_global_call(
getattr(inner, "value", None)
):
targets = (
inner.targets if isinstance(inner, ast.Assign) else [inner.target]
)
for target in targets:
if (
isinstance(target, ast.Attribute)
and isinstance(target.value, ast.Name)
and target.value.id in ("self", "cls")
):
attr_bound.setdefault(
(target.value.id, target.attr), inner.lineno
)
if not attr_bound:
continue
def _bound_attr(value):
"""``self._sa`` when that attribute was bound from the global."""
if (
isinstance(value, ast.Attribute)
and isinstance(value.value, ast.Name)
and (value.value.id, value.attr) in attr_bound
):
return (value.value.id, value.attr)
return None
for inner in ast.walk(node):
key = shown = None
if isinstance(inner, ast.Attribute):
key = _bound_attr(inner.value)
if key is not None and counted(inner.attr):
shown = f"{key[0]}.{key[1]}.{inner.attr}"
else:
name = _getattr_name(inner)
if name is not None:
key = _bound_attr(inner.args[0])
if key is not None and counted(name):
shown = f"getattr({key[0]}.{key[1]}, {name!r})"
if shown is not None:
alias.append(
f"{rel}:{inner.lineno}: {shown} "
f"(attribute bind from get_server_args() at line "
f"{attr_bound[key]})"
)
return direct, alias
@cache
def _parsed_modules():
"""(rel, tree) per parseable module; the three scanners below share it."""
modules = []
for path in sorted(_PACKAGE_ROOT.rglob("*.py")):
try:
tree = ast.parse(path.read_text())
except SyntaxError:
continue
modules.append((path.relative_to(_PACKAGE_ROOT).as_posix(), tree))
return modules
def _field_reads():
direct, alias = [], []
for rel, tree in _parsed_modules():
if rel.startswith(_SLOT_OWNERS):
continue
inert = frozenset(name for path_, name in _INERT_DYNAMIC_READS if path_ == rel)
module_direct, module_alias = _collect(rel, tree, inert)
direct += module_direct
alias += module_alias
return direct, alias
def _configured_size_call_sites():
found = set()
for rel, tree in _parsed_modules():
if rel.startswith(_SLOT_OWNERS):
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = (
func.id
if isinstance(func, ast.Name)
else (func.attr if isinstance(func, ast.Attribute) else None)
)
if name and name.startswith("configured_") and name.endswith("_size"):
found.add((rel, name))
return found
def _renamed_accessor_imports():
offenders = []
for rel, tree in _parsed_modules():
for node in ast.walk(tree):
if not isinstance(node, (ast.ImportFrom, ast.Import)):
continue
for imported in node.names:
if imported.asname is None or imported.asname == imported.name:
continue
base = imported.name.rsplit(".", 1)[-1]
if base == "get_server_args" or (
base.startswith("configured_") and base.endswith("_size")
):
offenders.append(
f"{rel}:{node.lineno}: {imported.name} as {imported.asname}"
)
return offenders
def _check_count(kind, reads, baseline):
if len(reads) > baseline:
raise AssertionError(
f"{kind} process-global config field reads grew: {len(reads)} > "
f"baseline {baseline}. Read the namespace accessor for the "
"field's namespace, or the owning runner for a per-runner "
"field:\n" + "\n".join(reads)
)
def check_global_config_read_ratchet():
direct, alias = _field_reads()
_check_count("direct", direct, _DIRECT_BASELINE)
_check_count("alias-form", alias, _ALIAS_BASELINE)
def check_configured_size_call_sites():
"""The configured-vs-live exceptions are enumerated, with reasons.
``configured_*_size()`` answers what the user asked for where
``get_parallel()`` would answer what the process ended up with. Each such
exception is listed above with why the live property cannot serve it, and
this case fails if the code and that list disagree.
The unit is **(file, accessor)**, not the individual call: a second
`configured_pp_size()` in a file already registered for it collapses into
the same entry, so the reason has to cover the file's use of that accessor
rather than one line. A new file, or a new accessor in a listed file, is
what this catches -- in either call form (bare or module-qualified).
"""
found = _configured_size_call_sites()
documented = set(_CONFIGURED_SIZE_CALL_SITES)
if documented != found:
raise AssertionError(
"configured-size call sites drifted from their documented reasons.\n"
f" undocumented: {sorted(found - documented)}\n"
f" stale entries: {sorted(documented - found)}",
)
def check_no_renamed_accessor_imports():
"""The scanners above match ``get_server_args`` and ``configured_*_size``
by their literal names, so an ``import ... as`` rename would walk a read
straight past both the zero baseline and the call-site registry. Renaming
these accessors buys nothing (the names are already short and unambiguous),
so it is banned outright — which is exactly what makes literal-name
matching sound."""
offenders = _renamed_accessor_imports()
if offenders:
raise AssertionError(
"get_server_args / configured_*_size imported under another name; "
"the read ratchet and the configured-size registry match these "
"accessors by their literal names, so a rename silently escapes "
"both:\n" + "\n".join(offenders),
)
if __name__ == "__main__":
check_global_config_read_ratchet()
check_configured_size_call_sites()
check_no_renamed_accessor_imports()
@@ -1,52 +0,0 @@
"""Ratchet guard: legacy global-accessor call-sites may only decrease.
The process-wide ``ServerArgs`` is owned by the runtime context; the legacy
``get_global_server_args`` / ``set_global_server_args_for_*`` names survive as
thin shims for the existing call-sites. New code should use the
``sglang.srt.runtime_context`` accessors (``get_server_args()`` /
``get_context().set_server_args()``), so the shim call-site counts below must
never grow. When your change removes call-sites, lower the matching baseline
to the new count.
"""
import re
from pathlib import Path
_SRT_ROOT = Path(__file__).resolve().parents[2] / "python" / "sglang" / "srt"
# Baselines counted over python/sglang/srt/**/*.py, including each function's
# own def line. Ratchet: decrease-only.
_RATCHETS = [
# Down to the shim definition itself; every call-site now goes through
# runtime_context.get_server_args().
("get_global_server_args", r"\bget_global_server_args\s*\(", 1),
(
"set_global_server_args_for_*",
r"\bset_global_server_args_for_(?:scheduler|tokenizer)\s*\(",
4,
),
]
def check_legacy_global_ratchet():
sources = [
path.read_text(encoding="utf-8", errors="replace")
for path in sorted(_SRT_ROOT.rglob("*.py"))
]
for name, pattern, baseline in _RATCHETS:
count = sum(len(re.findall(pattern, source)) for source in sources)
if count > baseline:
raise AssertionError(
f"{name} call-sites grew: {count} > baseline {baseline}. "
"New code must use the sglang.srt.runtime_context accessors "
"(get_server_args() / get_context().set_server_args())."
)
if count < baseline:
raise AssertionError(
f"{name} call-sites shrank: {count} < baseline {baseline}. "
"Lower the baseline in this file to lock in the progress."
)
if __name__ == "__main__":
check_legacy_global_ratchet()
@@ -1,52 +0,0 @@
"""Ratchet guard: module-level runtime state in the flag-owning layers may
only shrink.
Runtime flags belong on ``get_flags()`` groups, which have lifecycle reset and
a scoped test-override primitive; a module-level ``global`` has neither and
leaks across test teardowns. The pin below names the survivors -- migrating one
must shrink it.
"""
import ast
from pathlib import Path
_SRT_ROOT = Path(__file__).resolve().parents[2] / "python" / "sglang" / "srt"
_PINNED_GLOBALS = {
"layers/moe/utils.py": frozenset(),
"layers/dp_attention.py": frozenset(
{
# DP-attention topology (parallel vertical scope).
"_ATTN_DP_RANK",
"_ATTN_DP_SIZE",
}
),
}
def check_module_state_ratchet():
for rel, pinned in _PINNED_GLOBALS.items():
tree = ast.parse((_SRT_ROOT / rel).read_text())
declared = {
name
for node in ast.walk(tree)
if isinstance(node, ast.Global)
for name in node.names
}
grown = declared - pinned
if grown:
raise AssertionError(
f"{rel} declares new module-level runtime state {sorted(grown)}; "
"put runtime flags on a get_flags() group instead "
"(see runtime_context.MoeFlags / DpFlags).",
)
shrunk = pinned - declared
if shrunk:
raise AssertionError(
f"{rel} no longer declares {sorted(shrunk)}; "
"shrink the pin in this file to lock in the progress.",
)
if __name__ == "__main__":
check_module_state_ratchet()
@@ -1,63 +0,0 @@
"""Guard: no legacy parallel-getter calls in the swept directories.
``models/`` and ``layers/`` read parallel topology through
``get_parallel().<dim>`` (the read-through wrapper in ``runtime_context``),
which gives one import, one naming scheme, and the scoped ``override()`` test
primitive. Exemptions are pinned in ``_EXEMPT``, each with its reason; sweeping
one must remove it from there.
"""
import re
from pathlib import Path
_SRT_ROOT = Path(__file__).resolve().parents[2] / "python" / "sglang" / "srt"
_BANNED_CALLS = re.compile(
r"\b(?:dcp_enabled|get_(?:"
r"tensor_model_parallel_(?:world_size|rank)"
r"|pipeline_model_parallel_(?:world_size|rank)"
r"|moe_expert_parallel_(?:world_size|rank)"
r"|moe_tensor_parallel_(?:world_size|rank)"
r"|moe_data_parallel_(?:world_size|rank)"
r"|attn_tensor_model_parallel_(?:world_size|rank)"
r"|attn_context_model_parallel_(?:world_size|rank)"
r"|dcp_(?:world_size|rank)"
r"|dcp_group(?:_no_assert)?"
r"|attention_dcp_(?:world_size|rank)"
r"|attention_(?:tp|cp)_(?:group|rank|size)"
r"))\(\)"
)
# The whole package is swept; the exemptions are the substrate itself.
_SWEPT_DIRS = ("",)
_EXEMPT = (
"distributed/", # parallel_state: defines the canonical getters
"runtime_context.py", # delegates DCP reads to canonical getters
"layers/dp_attention.py", # delegation substrate for the attn-DP dims
"layers/dcp/comm.py", # deprecated out-of-tree DCP compatibility shims
# The dumper's megatron plugin calls third-party getters that share the
# parallel_state names (self._mpu.get_tensor_model_parallel_rank()).
"debug_utils/dumper.py",
)
def check_parallel_adoption_ratchet():
offenders = []
for top in _SWEPT_DIRS:
for path in sorted((_SRT_ROOT / top).rglob("*.py")):
rel = path.relative_to(_SRT_ROOT).as_posix()
if rel.startswith(_EXEMPT):
continue
for line_number, line in enumerate(path.read_text().split("\n"), 1):
if _BANNED_CALLS.search(line):
offenders.append(f"{rel}:{line_number}")
if offenders:
raise AssertionError(
"legacy parallel-getter calls in swept directories (use "
f"get_parallel().<dim> instead): {offenders}",
)
if __name__ == "__main__":
check_parallel_adoption_ratchet()
@@ -1,57 +0,0 @@
"""Guard: no server_args mutation outside the resolution pipeline, pinned at 0.
``ServerArgs.__setattr__`` already raises on a bare assignment after
resolution; this static scan is what reaches the sites tests never execute.
"""
import re
from pathlib import Path
_SGLANG_ROOT = Path(__file__).resolve().parents[2] / "python" / "sglang"
# Assignments to a server_args attribute (``server_args.x = ...``,
# ``self.server_args.x = ...``, and the ``sa`` alias used by a few helpers).
# ``==`` comparisons are excluded by the negative lookahead.
_MUTATION_PATTERNS = [
# (?![=}]) skips ``==`` comparisons and f-string ``{x=}`` debug specs.
re.compile(r"\bserver_args\.[a-z0-9_]+\s*=(?![=}])"),
re.compile(r"\bsa\.[a-z0-9_]+\s*=(?![=}])"),
re.compile(r"get_(?:global_)?server_args\(\)\.[a-z0-9_]+\s*=(?![=}])"),
# setattr is the same write with the attribute name behind a variable.
re.compile(
r"setattr\(\s*(?:[\w.]+\.)?(?:server_args|sa|get_(?:global_)?server_args\(\))\s*,"
),
]
# The resolution pipeline itself (mutation is its job) and multimodal_gen,
# whose ServerArgs is a different class outside this contract.
_EXCLUDED = (
"srt/server_args.py",
"srt/arg_groups",
"multimodal_gen",
)
_BASELINE = 0
def check_server_args_mutation_ratchet():
count = 0
for path in sorted(_SGLANG_ROOT.rglob("*.py")):
rel = path.relative_to(_SGLANG_ROOT).as_posix()
if rel.startswith(_EXCLUDED):
continue
source = path.read_text()
count += sum(len(pattern.findall(source)) for pattern in _MUTATION_PATTERNS)
if count > _BASELINE:
raise AssertionError(
f"server_args mutations outside the resolution pipeline grew: "
f"{count} > baseline {_BASELINE}. Configuration is resolved in "
"ServerArgs.__post_init__; declare through the pipeline "
"(passes / declare_late_resolution), change resolved config "
"with get_context().override(source, ...), or hand the value "
"to its runner as a constructor argument — do not assign fields."
)
if __name__ == "__main__":
check_server_args_mutation_ratchet()
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env python3
"""Runs every static ratchet in one process, so the package is parsed once."""
import sys
from check_decode_bookkeeping_ownership import (
check_bookkeeping_sites_match_owner_allowlist,
check_spec_v2_draft_workers_do_no_scheduler_bookkeeping,
)
from check_global_config_read_ratchet import (
check_configured_size_call_sites,
check_global_config_read_ratchet,
check_no_renamed_accessor_imports,
)
from check_legacy_global_ratchet import check_legacy_global_ratchet
from check_module_state_ratchet import check_module_state_ratchet
from check_parallel_adoption_ratchet import check_parallel_adoption_ratchet
from check_server_args_mutation_ratchet import check_server_args_mutation_ratchet
def main():
checks = (
check_bookkeeping_sites_match_owner_allowlist,
check_spec_v2_draft_workers_do_no_scheduler_bookkeeping,
check_global_config_read_ratchet,
check_configured_size_call_sites,
check_no_renamed_accessor_imports,
check_legacy_global_ratchet,
check_module_state_ratchet,
check_parallel_adoption_ratchet,
check_server_args_mutation_ratchet,
)
# They guard independent invariants, so one failure must not hide the rest.
failures = []
for check in checks:
try:
check()
except AssertionError as exc:
failures.append(f"[{check.__name__}] {exc}")
for failure in failures:
print(failure, file=sys.stderr)
print(file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())