[CI] Move the static ratchets back to CPU unit tests (#34913)
This commit is contained in:
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user