Skip to content

Commit eddfb6b

Browse files
feat(srt): bind planned srt-slurm points to their recipes before dispatch
run-sweep, e2e-tests and profile now pipe their matrix through infx.srt_slurm.preflight after benchmark_schema. A single-node point must select exactly one variant through the runtime's select_recipe. Every CONFIG_FILE and EVAL_CONFIG_FILE recipe a multi-node point can launch must select at least one variant, and its worker containers must resolve to the images the launcher stages: the point's image or a cluster alias, and PREFILL_IMAGE for a TileRT prefill role. A TileRT frontend or client must run one of those images, and a benchmark client that names another tag of the point's image is reported. Recipe paths must stay inside their recipe trees. A mismatch fails setup before any canary or benchmark job is dispatched. In e2e-tests and profile, the validator and its srtctl come from the trusted tree, which reads the measured tree only as data, and the check always runs. require_launcher already rejects revisions older than the Python launcher, which are also the revisions whose runner config this tooling cannot read. run-sweep、e2e-tests 和 profile 现在在 benchmark_schema 之后把 matrix 交给 infx.srt_slurm.preflight 检查。单节点点必须通过运行时的 select_recipe 恰好 匹配一个 variant。多节点点可能启动的每个 CONFIG_FILE 和 EVAL_CONFIG_FILE 配方必须至少选中一个 variant,其 worker container 必须解析到 launcher 暂存 的镜像:该点的镜像或 cluster alias;TileRT 的 prefill role 则必须是 PREFILL_IMAGE。TileRT 的 frontend 和客户端必须使用这两个镜像之一;benchmark 客户端如果写了该点镜像的另一个 tag,会被报告。配方路径必须位于各自的配方 目录内。任何不一致都会让 setup 在派发 canary 或 benchmark 任务之前失败。 在 e2e-tests 和 profile 中,validator 及其 srtctl 都来自可信代码树,被测代码 树只作为数据读取,并且检查始终运行。require_launcher 已经拒绝早于 Python launcher 的版本,而本工具无法读取 runner 配置的正是这些版本。 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1c5e6b9 commit eddfb6b

5 files changed

Lines changed: 637 additions & 0 deletions

File tree

‎.github/workflows/e2e-tests.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,12 @@ jobs:
330330
CONFIG_JSON=$(printf '%s' "$CONFIG_JSON" | env PYTHONPATH="$PRIORITY_ROOT" \
331331
uv run --no-project --exclude-newer PT12H --python 3.12 --with pydantic --with pyyaml \
332332
python -P -m infx.workflows.benchmark_schema)
333+
# srtctl comes from the trusted tree; the measured tree is only read as data.
334+
git -C "$PRIORITY_ROOT" submodule update --init utils/srt-slurm
335+
CONFIG_JSON=$(printf '%s' "$CONFIG_JSON" | env PYTHONPATH="$PRIORITY_ROOT:$PRIORITY_ROOT/utils/srt-slurm/src" \
336+
uv run --no-project --exclude-newer PT12H --python 3.12 --with pydantic --with pyyaml \
337+
--with marshmallow --with marshmallow-dataclass --with ruamel.yaml --with requests \
338+
python -P -m infx.srt_slurm.preflight --root "$MEASURED_ROOT")
333339
score_matrix() {
334340
local family="$1"
335341
env PYTHONPATH="$PRIORITY_ROOT" uv run --no-project --exclude-newer PT12H --python 3.12 --with pyyaml \

‎.github/workflows/profile.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ jobs:
107107
uv run --no-project --exclude-newer PT12H --python 3.12 --with pydantic --with pyyaml \
108108
python -P "${GENERATOR[@]}" \
109109
test-config --config-files "$INPUTS_CONFIG_FILE" --config-keys "$INPUTS_CONFIG_KEY" --conc "$INPUTS_CONC")
110+
# srtctl comes from the trusted tree; the measured tree is only read as data.
111+
git -C "$PRIORITY_ROOT" submodule update --init utils/srt-slurm
112+
CONFIG_JSON=$(printf '%s' "$CONFIG_JSON" | env PYTHONPATH="$PRIORITY_ROOT:$PRIORITY_ROOT/utils/srt-slurm/src" \
113+
uv run --no-project --exclude-newer PT12H --python 3.12 --with pydantic --with pyyaml \
114+
--with marshmallow --with marshmallow-dataclass --with ruamel.yaml --with requests \
115+
python -P -m infx.srt_slurm.preflight --root "$MEASURED_ROOT")
110116
CONFIG_JSON=$(printf '%s' "$CONFIG_JSON" | env PYTHONPATH="$PRIORITY_ROOT" uv run --no-project --exclude-newer PT12H --python 3.12 --with pyyaml \
111117
python -P -m infx.workflows.ci_priority \
112118
--policy "${PRIORITY_ROOT}/configs/ci-priority.yaml" \

‎.github/workflows/run-sweep.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,9 @@ jobs:
297297
echo "criteria=$criteria" >> "$GITHUB_OUTPUT"
298298
echo "Priority criteria: $criteria"
299299
300+
- name: Initialize srt-slurm for recipe preflight
301+
run: git submodule update --init inferencex-e2e/utils/srt-slurm
302+
300303
- id: setup
301304
env:
302305
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -325,6 +328,10 @@ jobs:
325328
CONFIG_JSON=$("${CMD[@]}")
326329
CONFIG_JSON=$(printf '%s' "$CONFIG_JSON" | uv run --no-project --exclude-newer PT12H --python 3.12 --with pydantic --with pyyaml \
327330
python -m infx.workflows.benchmark_schema --plan)
331+
CONFIG_JSON=$(printf '%s' "$CONFIG_JSON" | env PYTHONPATH="${PYTHONPATH}:${PWD}/utils/srt-slurm/src" \
332+
uv run --no-project --exclude-newer PT12H --python 3.12 --with pydantic --with pyyaml \
333+
--with marshmallow --with marshmallow-dataclass --with ruamel.yaml --with requests \
334+
python -m infx.srt_slurm.preflight)
328335
CONFIG_JSON=$(printf '%s' "$CONFIG_JSON" | uv run --no-project --exclude-newer PT12H --python 3.12 --with pyyaml \
329336
python -m infx.workflows.ci_priority \
330337
--event-name "${GITHUB_EVENT_NAME}" \
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
"""Bind every planned srt-slurm point to the recipe variants it would launch, before dispatch.
2+
3+
Sweep entry points pipe their matrix through this check after ``infx.workflows.benchmark_schema``.
4+
A single-node point must select exactly one variant through the runtime's ``select_recipe``,
5+
which compares the variant's ``model.container`` with the point's image. Every multi-node recipe a
6+
point can launch (``CONFIG_FILE`` and ``EVAL_CONFIG_FILE``) must select at least one variant, and
7+
each variant's worker containers must resolve to an image the launcher stages for the job: the
8+
point's image, or ``PREFILL_IMAGE`` for a TileRT prefill role. A TileRT frontend or client must
9+
run one of those images, and any other benchmark client that names another tag of the point's
10+
image is stale, because srtctl would pull that literal instead. The matrix is echoed unchanged on
11+
success; any problem exits non-zero so no benchmark job is dispatched.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import json
18+
import sys
19+
from collections.abc import Iterator, Mapping
20+
from pathlib import Path
21+
from typing import Any
22+
23+
import yaml
24+
25+
from infx.clusters import CLUSTER_LABEL_PREFIX, RunnerInventory, load_inventory
26+
from infx.clusters.slurm import slurm_settings
27+
from infx.launch.drivers.srt.config import pyxis_spelling
28+
from infx.launch.drivers.srt.recipe import RECIPES_MIRROR, recipe_mirror_path
29+
from infx.srt_slurm.single_node import select_recipe
30+
from infx.srt_slurm.synthetic_acceptance import selected_recipes
31+
32+
E2E_ROOT = Path(__file__).resolve().parents[2]
33+
SINGLE_NODE_RECIPES = Path("benchmarks/single_node/srt-slurm-recipes")
34+
RECIPE_SETTINGS = ("CONFIG_FILE", "EVAL_CONFIG_FILE")
35+
36+
37+
def matrix_points(matrix: Any) -> Iterator[Mapping[str, Any]]:
38+
"""Every benchmark point in a sweep plan or a flat generated matrix."""
39+
if isinstance(matrix, Mapping):
40+
if "image" in matrix and ("srt-recipe" in matrix or "prefill" in matrix):
41+
yield matrix
42+
return
43+
for value in matrix.values():
44+
yield from matrix_points(value)
45+
elif isinstance(matrix, list):
46+
for item in matrix:
47+
yield from matrix_points(item)
48+
49+
50+
def workflow_text(value: Any) -> str:
51+
"""A matrix value as a GitHub expression renders it into an environment variable."""
52+
if value is None:
53+
return ""
54+
if isinstance(value, bool):
55+
return "true" if value else "false"
56+
return str(value)
57+
58+
59+
def single_node_environment(point: Mapping[str, Any]) -> dict[str, str]:
60+
"""The recipe-binding inputs ``benchmark-tmpl.yml`` exports for a single-node point."""
61+
agentic = point.get("scenario-type") == "agentic-coding"
62+
tp, pp, pcp = (int(point.get(name, 1)) for name in ("tp", "pp", "pcp-size"))
63+
return {
64+
"FRAMEWORK": str(point["framework"]),
65+
"MODEL": str(point["model"]),
66+
"IMAGE": str(point["image"]),
67+
"PRECISION": str(point["precision"]),
68+
"TP": str(tp),
69+
"PP_SIZE": str(pp),
70+
"DCP_SIZE": workflow_text(point.get("dcp-size", 1)),
71+
"PCP_SIZE": str(pcp),
72+
"EP_SIZE": workflow_text(point["ep"]),
73+
"DP_ATTENTION": workflow_text(point.get("dp-attn", False)),
74+
"CONC": workflow_text(point["conc"]),
75+
"SPEC_DECODING": str(point["spec-decoding"]),
76+
"GPU_COUNT": str(tp * pp * pcp),
77+
"IS_AGENTIC": "1" if agentic else "0",
78+
"KV_OFFLOADING": workflow_text(point.get("kv-offloading")) if agentic else "",
79+
"TOTAL_CPU_DRAM_GB": workflow_text(point.get("total-cpu-dram-gb")) if agentic else "0",
80+
"ISL": "0" if agentic else workflow_text(point["isl"]),
81+
"OSL": "0" if agentic else workflow_text(point["osl"]),
82+
"RANDOM_RANGE_RATIO": "0.8",
83+
}
84+
85+
86+
def docker_spelling(image: str) -> str:
87+
"""``registry/path`` for a pyxis ``registry#path`` image, else ``image``."""
88+
host, separator, path = image.partition("#")
89+
return f"{host}/{path}" if separator else image
90+
91+
92+
def repository(image: str) -> str:
93+
"""``image`` without its tag or digest, in ``registry/path`` spelling."""
94+
head, _, last = docker_spelling(image).partition("@")[0].rpartition("/")
95+
name = last.partition(":")[0]
96+
return f"{head}/{name}" if head else name
97+
98+
99+
def point_label(point: Mapping[str, Any]) -> str:
100+
"""The point as its benchmark job is named, with the eval suffix the job title carries."""
101+
suffix = (
102+
" | eval-only" if point.get("eval-only") else " | eval" if point.get("run-eval") else ""
103+
)
104+
return f"{point.get('exp-name', '<unnamed>')} on {point.get('runner', '<no runner>')}{suffix}"
105+
106+
107+
def container_fields(config: Mapping[str, Any]) -> list[tuple[str, Any]]:
108+
"""The ``(field, value)`` pairs naming the images a variant's workers, frontend and client run."""
109+
fields: list[tuple[str, Any]] = [
110+
("model.container", (config.get("model") or {}).get("container"))
111+
]
112+
for role, spec in (config.get("roles") or {}).items():
113+
if isinstance(spec, Mapping) and "container" in spec:
114+
fields.append((f"roles.{role}.container", spec["container"]))
115+
for block in ("frontend", "benchmark"):
116+
section = config.get(block)
117+
if isinstance(section, Mapping) and "container_image" in section:
118+
fields.append((f"{block}.container_image", section["container_image"]))
119+
return fields
120+
121+
122+
def container_problems(
123+
config: Mapping[str, Any], image: str, aliases: frozenset[str], prefill: str | None
124+
) -> list[str]:
125+
"""Containers of one variant that would not run the images the launcher stages for it.
126+
127+
Workers run the point's image (directly or through a cluster alias), except a TileRT prefill
128+
role, which runs ``PREFILL_IMAGE``. A TileRT frontend and client run one of those two images.
129+
Other frontends may pin an image of their own, but a benchmark client that names another tag
130+
of the point's image is stale.
131+
"""
132+
resolved = {image, pyxis_spelling(image), *aliases}
133+
problems = []
134+
for field, value in container_fields(config):
135+
if value is None:
136+
if field == "model.container":
137+
problems.append(f"{field} is not set")
138+
elif field == "roles.prefill.container" and prefill is not None:
139+
if value != prefill:
140+
problems.append(f"{field} {value!r} is not PREFILL_IMAGE {prefill}")
141+
elif field == "model.container" or field.startswith("roles."):
142+
if value not in resolved:
143+
known = ", ".join(sorted(aliases)) or "none"
144+
problems.append(
145+
f"{field} {value!r} does not resolve to {image} (container aliases: {known})"
146+
)
147+
elif prefill is not None:
148+
if value not in resolved and value != prefill:
149+
problems.append(f"{field} {value!r} is neither {image} nor PREFILL_IMAGE {prefill}")
150+
elif (
151+
field == "benchmark.container_image"
152+
and value not in resolved
153+
and repository(str(value)) == repository(image)
154+
):
155+
problems.append(
156+
f"{field} {value!r} is another tag of {image}; "
157+
"srtctl would pull it instead of the staged image"
158+
)
159+
return problems
160+
161+
162+
def check_single_node(point: Mapping[str, Any], root: Path) -> list[str]:
163+
reference = str(point["srt-recipe"])
164+
path, _, selector = reference.partition(":")
165+
recipe = (root / path).resolve()
166+
if not recipe.is_relative_to((root / SINGLE_NODE_RECIPES).resolve()):
167+
return [f"srt-recipe={reference}: not inside {SINGLE_NODE_RECIPES}"]
168+
config = str(recipe) + (f":{selector}" if selector else "")
169+
try:
170+
_, variant = select_recipe(config, single_node_environment(point))
171+
except (OSError, KeyError, TypeError, ValueError, yaml.YAMLError) as exc:
172+
return [f"srt-recipe={reference}: {exc}"]
173+
problems = container_problems(variant, str(point["image"]), frozenset(), None)
174+
return [f"srt-recipe={reference}: {problem}" for problem in problems]
175+
176+
177+
def point_settings(point: Mapping[str, Any]) -> dict[str, str]:
178+
"""NAME=value additional-settings in the order ``benchmark-multinode-tmpl.yml`` exports them."""
179+
settings: dict[str, str] = {}
180+
for role in ("prefill", "decode"):
181+
for setting in (point.get(role) or {}).get("additional-settings") or []:
182+
name, _, value = str(setting).partition("=")
183+
settings[name] = value
184+
return settings
185+
186+
187+
def container_aliases(inventory: RunnerInventory, runner: str) -> frozenset[str]:
188+
"""Aliases the srt-slurm config maps to the job image on every cluster ``runner`` reaches."""
189+
if runner.startswith(CLUSTER_LABEL_PREFIX):
190+
cluster_ids = {runner.removeprefix(CLUSTER_LABEL_PREFIX)}
191+
elif runner in inventory.labels:
192+
cluster_ids = {inventory.cluster_for(name).id for name in inventory.labels[runner]}
193+
else:
194+
# Some master entries schedule on a bare cluster id rather than a runners.yaml label.
195+
cluster_ids = {runner}
196+
aliases: frozenset[str] | None = None
197+
for cluster_id in sorted(cluster_ids):
198+
cluster = inventory.clusters.get(cluster_id)
199+
srt = slurm_settings(cluster).srt_slurm if cluster is not None else None
200+
names = frozenset(srt.container_aliases) if srt is not None else frozenset()
201+
aliases = names if aliases is None else aliases & names
202+
return aliases or frozenset()
203+
204+
205+
def check_multi_node(point: Mapping[str, Any], root: Path, inventory: RunnerInventory) -> list[str]:
206+
image = str(point["image"])
207+
aliases = container_aliases(inventory, str(point.get("runner", "")))
208+
settings = point_settings(point)
209+
prefill = None
210+
if point.get("framework") == "tilert":
211+
prefill = settings.get("PREFILL_IMAGE") or None
212+
if prefill is None:
213+
return ["TileRT needs a PREFILL_IMAGE setting for its prefill role"]
214+
problems = []
215+
mirror = (root / RECIPES_MIRROR).resolve()
216+
for name in RECIPE_SETTINGS:
217+
reference = settings.get(name)
218+
if not reference:
219+
continue
220+
path = recipe_mirror_path(root, reference).resolve()
221+
if not reference.startswith("recipes/") or not path.is_relative_to(mirror):
222+
problems.append(f"{name}={reference}: not a recipes/ path inside {RECIPES_MIRROR}")
223+
continue
224+
selector = reference.partition(":")[2] or None
225+
try:
226+
recipe = yaml.safe_load(path.read_text())
227+
if not isinstance(recipe, Mapping):
228+
problems.append(f"{name}={reference}: recipe is not a mapping")
229+
continue
230+
variants = selected_recipes(recipe, selector)
231+
except (OSError, TypeError, ValueError, yaml.YAMLError) as exc:
232+
problems.append(f"{name}={reference}: {exc}")
233+
continue
234+
if not variants:
235+
problems.append(
236+
f"{name}={reference}: selects no variant, so srtctl would submit nothing"
237+
)
238+
for variant, config in variants:
239+
label = f"{name}={reference}" + ("" if variant in (None, selector) else f" ({variant})")
240+
found = container_problems(config, image, aliases, prefill)
241+
declared = ((config.get("identity") or {}).get("container") or {}).get("image")
242+
if declared is not None and docker_spelling(str(declared)) != docker_spelling(image):
243+
found.append(f"identity.container.image {declared!r} is not {image}")
244+
problems.extend(f"{label}: {problem}" for problem in found)
245+
return problems
246+
247+
248+
def check_matrix(
249+
matrix: Any, root: Path, inventory: RunnerInventory | None
250+
) -> dict[str, list[str]]:
251+
"""Each problem found, mapped to the points it affects."""
252+
problems: dict[str, list[str]] = {}
253+
for point in matrix_points(matrix):
254+
if "prefill" in point:
255+
found = (
256+
check_multi_node(point, root, inventory)
257+
if inventory is not None
258+
else ["multi-node points need a runner inventory"]
259+
)
260+
elif point.get("srt-recipe"):
261+
found = check_single_node(point, root)
262+
else:
263+
continue
264+
label = point_label(point)
265+
for problem in found:
266+
points = problems.setdefault(problem, [])
267+
if label not in points:
268+
points.append(label)
269+
return problems
270+
271+
272+
def main() -> None:
273+
parser = argparse.ArgumentParser(description=__doc__)
274+
parser.add_argument(
275+
"--root",
276+
type=Path,
277+
default=E2E_ROOT,
278+
help="inferencex-e2e tree whose recipes the matrix names",
279+
)
280+
parser.add_argument(
281+
"--runner-config", type=Path, help="runner inventory (default: ROOT/configs/runners.yaml)"
282+
)
283+
args = parser.parse_args()
284+
raw = sys.stdin.read()
285+
matrix = json.loads(raw)
286+
inventory = None
287+
if any("prefill" in point for point in matrix_points(matrix)):
288+
runner_config = args.runner_config or args.root / "configs/runners.yaml"
289+
try:
290+
inventory = load_inventory(runner_config)
291+
except (OSError, ValueError, yaml.YAMLError) as exc:
292+
print(
293+
f"srt-slurm recipe preflight cannot read {runner_config} "
294+
f"with this tooling's runner schema: {exc}",
295+
file=sys.stderr,
296+
)
297+
sys.exit(1)
298+
problems = check_matrix(matrix, args.root, inventory)
299+
if problems:
300+
affected = len({label for points in problems.values() for label in points})
301+
print(
302+
f"srt-slurm recipe preflight found {len(problems)} problem(s) affecting {affected} point(s):",
303+
file=sys.stderr,
304+
)
305+
for problem, points in problems.items():
306+
print(f" {problem}", file=sys.stderr)
307+
for label in points:
308+
print(f" - {label}", file=sys.stderr)
309+
sys.exit(1)
310+
sys.stdout.write(raw)
311+
312+
313+
if __name__ == "__main__":
314+
main()

0 commit comments

Comments
 (0)