Skip to content

Commit 89904fb

Browse files
authored
Avoid starting a Slurm step for every SWE-bench tool call (#522)
We currently launch a new srun step and start Enroot for every tool command, even though we already have the node allocation and container. With several SWE evaluations running together, this creates thousands of Slurm steps and puts a lot of pressure on the scheduler. Simple commands end up taking seconds, and some runs hit the outer watchdog timeout and fail with RunnerError This change keeps one worker running inside each SWE instance’s container and reuses it for tool execution. Persistent execution prevents calling of srun for every tool call. Setup, grading, and cleanup retain their Slurm steps, while command exit codes, timeouts, and process cleanup are preserved. I've tested this, no impact to accuracy evaluation. Resolves this issue: #521
1 parent 3f7b387 commit 89904fb

11 files changed

Lines changed: 1056 additions & 179 deletions

File tree

‎AGENTS.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,19 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils.
140140
- **Post-run service wait**: `drain_and_build_report` waits for service subprocesses **unbounded** (`wait_for_exit(None)`). `wait_for_exit` SIGKILLs on expiry, and the aggregator writes `final_snapshot.json` — the Report's primary source — as the last thing it does, so a parent-side deadline here trades a wedged-service hang for a lost snapshot. The abort path keeps its own ceiling (`interrupted_teardown_grace_s`, 30s), and the whole-run watchdog stays armed throughout.
141141
- **Histogram bucket edges are dynamic per snapshot**: log-spaced over the observed `[min, max]`. Bucket count is fixed at construction; consumers MUST re-render from the snapshot's `(lo, hi, count)` triples each frame and MUST NOT track bucket-by-index across snapshots.
142142

143+
### SWE-bench Pyxis command transport
144+
145+
`evaluation/swebench_service/swebench_service/pyxis_persistent.py` owns one
146+
long-lived Slurm step per agent environment. The container runs the packaged
147+
`pyxis_command_worker.sh`, staged in a private control mount separate from tool
148+
`/tmp`. Worker startup retries only confirmed prelaunch Slurm failures before any
149+
request is published. A lock serializes callers through one atomically published
150+
request directory and completion marker.
151+
Commands run in fresh PID namespaces; accepted requests are never replayed after an
152+
uncertain failure. `pyxis_environment.py` owns container creation, command result
153+
mapping, and worker-before-container cleanup. All tool commands use the persistent
154+
worker. Evaluation remains in `pyxis_worker.py`.
155+
143156
### CLI Modes
144157

145158
CLI is auto-generated from `config/schema.py` Pydantic models via cyclopts. Fields annotated with `cyclopts.Parameter(alias="--flag")` get flat shorthands; all other fields get auto-generated dotted flags (kebab-case).
@@ -283,7 +296,7 @@ src/inference_endpoint/
283296
│ ├── types.py # Pydantic: VideoPathRequest, VideoPathResponse, VideoPayloadResponse
284297
│ └── adapter.py # VideoGenAdapter (HttpRequestAdapter) + VideoGenAccumulator (no-op)
285298
├── evaluation/ # Accuracy evaluation (extractor, scoring, livecodebench)
286-
│ └── swebench_service/ # Isolated uv service for Docker-backed SWE-bench runs
299+
│ └── swebench_service/ # Isolated uv service for Docker/Pyxis SWE-bench runs
287300
├── compliance/ # Submission compliance checks (config-lock, accuracy gate, run validity)
288301
│ ├── __init__.py
289302
│ └── checker.py # check_submission() + Check/ComplianceReport (Edge-Agentic ruleset)

‎examples/10_Agentic_Inference/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ Reference mean values are shown in parentheses.
260260

261261
| Metric | Kimi K3 | Qwen3.6-35B-A3B | DeepSeek-V4.1-Flash |
262262
| ------------------ | ------------------------ | ------------------------ | ------------------------ |
263-
| Inline accuracy | `>= 58.32%` (`58.9%`) | `>= 55.86%` (`56.43%`) | `>= 52.36%` (`53.16%`) |
263+
| Inline accuracy | `>= 58.32%` (`58.9%`) | `>= 55.86%` (`56.43%`) | `>= 52.36%` (`53.16%`) |
264264
| OSL per-turn mean¹ | `425-520` tokens (`472`) | `344-422` tokens (`383`) | `793-970` tokens (`882`) |
265265
| SWE-bench accuracy | `>= 93.5%` (`94.83%`) | `>= 69%` (`71.7%`) | `>= 96.4%` (`97.5%`) |
266266

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ dependencies = [
9696
"colorama==0.4.6",
9797
# Fix pytz-2024 import warning
9898
"pytz==2026.1.post1",
99-
"urllib3==2.7.0",
99+
"urllib3==2.8.0",
100100
"pyyaml==6.0.3",
101101
# anyio is pulled in transitively (openai -> httpx -> anyio); pinned here to
102102
# force past CVE-2026-63374 / CVE-2026-64847 (both fixed in 4.14.2), which
@@ -130,7 +130,7 @@ dev = [
130130
# patched here, pinned only inside the bfcl fork. Closes CVE-2025-68146 /
131131
# CVE-2026-22701 (filelock) and CVE-2026-22702 (virtualenv).
132132
"filelock>=3.20.3",
133-
"virtualenv>=20.36.1",
133+
"virtualenv==21.7.13",
134134
# pip is pulled in transitively (pip-audit -> pip-api -> pip); force past
135135
# PYSEC-2026-3721 (fixed in 26.2), which otherwise fails `uv run pip-audit`.
136136
"pip==26.2",

‎src/inference_endpoint/evaluation/swebench_service/README.md‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,32 @@ never forwarded.
6666

6767
During generation, the service still uses mini-swe-agent for the agent loop and
6868
model requests, but replaces its Docker environment with `PyxisEnvironment`. Every
69-
trajectory receives a named, writable Pyxis container. Each tool call becomes an
70-
overlapping `srun` step in that container, preserving filesystem changes across
71-
turns. Tool commands run in private PID namespaces so one trajectory cannot signal
72-
processes belonging to another trajectory.
69+
trajectory receives a named, writable Pyxis container and one long-lived overlapping
70+
`srun` command worker. Tool calls use atomic request and response files in the
71+
container's private `/tmp` mount, avoiding Slurm step creation and Enroot startup
72+
on every turn. Each worker handles one request at a time; it publishes a completion
73+
marker only after the command exits and its output is closed.
74+
Filesystem changes persist, while each command runs in a fresh shell
75+
and private PID namespace. Commands cannot signal the worker or other trajectories;
76+
remaining child processes are removed when the command's PID namespace exits.
77+
The service must stay on the allocated node, and node-local `TMPDIR` is recommended
78+
for the request files.
79+
80+
The command worker is the packaged `swebench_service/pyxis_command_worker.sh`.
81+
The Python service copies it into the private `/tmp` mount and starts it with Bash
82+
inside the task container; no service Python installation is needed in task images.
83+
84+
Command failures preserve their exit status and merged stdout/stderr. A command
85+
timeout terminates its process group with a five-second kill grace; loss of the
86+
worker, invalid responses, and driver deadlines fail the run as infrastructure
87+
errors. An accepted request is never automatically replayed because its execution
88+
may already have changed the repository. The worker is stopped and reaped before
89+
its container and temporary files are removed.
90+
91+
All tool commands use the persistent worker.
92+
Container initialization, worker startup, evaluation, and cleanup still use Slurm
93+
steps. This reduces per-command scheduler traffic; it does not bypass allocation
94+
limits or guarantee any particular end-to-end evaluation time.
7395

7496
After generation, the Pyxis worker evaluates each prediction in a fresh `srun`
7597
container step because the Docker-based SWE-bench evaluator cannot run on the
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
set -uo pipefail
6+
root=$1
7+
shift
8+
interpreter=("$@")
9+
request="$root/running"
10+
touch "$root/ready" || exit 70
11+
12+
while [ ! -e "$root/stop" ]; do
13+
if [ ! -d "$root/request" ]; then
14+
sleep 0.05
15+
continue
16+
fi
17+
# The host serializes callers and removes the previous result before publishing.
18+
[ ! -e "$request" ] || exit 70
19+
mv -- "$root/request" "$request" || exit 70
20+
timeout_s=$(cat "$request/timeout") || exit 70
21+
case "$timeout_s" in ''|*[!0-9]*|0) exit 70 ;; esac
22+
# A sentinel preserves trailing newlines in the command and working directory.
23+
cwd=$(cat "$request/cwd" && printf x) || exit 70
24+
# Keep tool text out of supervisor argv so pkill -f cannot match it there.
25+
unshare --pid --fork --mount-proc \
26+
timeout -k 5 "$timeout_s" bash -c '
27+
status=$1; cwd=$2; shift 2
28+
command=$(cat <&3 && printf x) || exit 70
29+
exec 3<&-
30+
if cd -- "$cwd"; then
31+
"$@" "${command%x}"
32+
rc=$?
33+
else
34+
rc=125
35+
fi
36+
printf "%s\n" "$rc" > "$status" || exit 70
37+
exit "$rc"
38+
' command-status "$request/command_status" "${cwd%x}" \
39+
"${interpreter[@]}" 3< "$request/command" > "$request/output" 2>&1
40+
returncode=$?
41+
timed_out=0
42+
# Explicit exits 124/137 are command results, not timeout notifications.
43+
if [ "$(cat "$request/command_status" 2>/dev/null)" != "$returncode" ]; then
44+
case "$returncode" in 124|137) timed_out=1 ;; *) exit 70 ;; esac
45+
fi
46+
size=$(wc -c < "$request/output") || exit 70
47+
printf '%s %s %s\n' "$returncode" "$timed_out" "$((size))" > "$request/complete.tmp" || exit 70
48+
mv -- "$request/complete.tmp" "$request/complete" || exit 70
49+
done

‎src/inference_endpoint/evaluation/swebench_service/swebench_service/pyxis_environment.py‎

Lines changed: 65 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@
66
import logging
77
import os
88
import platform
9-
import random
109
import re
10+
import shutil
1111
import subprocess
1212
import tempfile
1313
import threading
14-
import time
1514
import uuid
1615
from pathlib import Path
1716
from typing import Any
1817

1918
from pydantic import AliasChoices, BaseModel, Field
2019

20+
from .pyxis_persistent import PersistentExecChannel
21+
from .pyxis_slurm import SRUN_MAX_ATTEMPTS as _SRUN_MAX_ATTEMPTS
22+
from .pyxis_slurm import (
23+
is_retryable_prelaunch_failure as _is_retryable_prelaunch_failure,
24+
)
25+
from .pyxis_slurm import wait_for_prelaunch_retry
2126
from .runner import RunnerError
2227

2328
logger = logging.getLogger(__name__)
@@ -45,18 +50,8 @@
4550
"SLURM_CONF",
4651
)
4752
_STEP_STATUS = "/tmp/.mlperf_srun_status"
48-
_SRUN_MAX_ATTEMPTS = 5
49-
_RETRYABLE_PRELAUNCH_ERRORS = (
50-
"spank_sybil: rpc request error",
51-
"required plugin spank_sybil.so",
52-
"failed to connect to any sack sockets",
53-
"failed to create token",
54-
"curl: (56) connect tunnel failed",
55-
"unable to confirm allocation for job",
56-
)
57-
_IGNORABLE_SRUN_PREAMBLE_LINES = {
58-
"srun: lua: Checking requeue policy with options:",
59-
}
53+
_PERSISTENT_ROOT = "/.mlperf_persistent_exec"
54+
_COMMAND_WORKER = Path(__file__).with_name("pyxis_command_worker.sh")
6055
_STEP_SCRIPT = r"""set +e
6156
status_path=$1
6257
timeout_s=$2
@@ -73,14 +68,6 @@ def safe_srun_env() -> dict[str, str]:
7368
return {name: os.environ[name] for name in _SAFE_SRUN_ENV if name in os.environ}
7469

7570

76-
def _is_retryable_prelaunch_failure(status: str, output: str) -> bool:
77-
"""Return whether Slurm rejected the step before its command started."""
78-
if status != "pending":
79-
return False
80-
lowered = output.lower()
81-
return any(marker in lowered for marker in _RETRYABLE_PRELAUNCH_ERRORS)
82-
83-
8471
def build_srun_command(
8572
*,
8673
argv: list[str],
@@ -187,15 +174,7 @@ def run_srun_step(
187174
).strip()
188175
retryable = _is_retryable_prelaunch_failure(status, output)
189176
if retryable and attempt < _SRUN_MAX_ATTEMPTS:
190-
backoff_s = min(2**attempt, 16)
191-
delay_s = backoff_s + random.uniform(0.0, backoff_s)
192-
logger.warning(
193-
"Retrying Pyxis pre-launch failure in %.1fs (attempt %d/%d)",
194-
delay_s,
195-
attempt,
196-
_SRUN_MAX_ATTEMPTS,
197-
)
198-
time.sleep(delay_s)
177+
wait_for_prelaunch_retry(attempt)
199178
continue
200179

201180
if failure_path is not None:
@@ -232,6 +211,7 @@ class PyxisEnvironmentConfig(BaseModel):
232211
env: dict[str, str] = Field(default_factory=dict)
233212
timeout_s: int = Field(
234213
default=30,
214+
gt=0,
235215
validation_alias=AliasChoices("timeout_s", "timeout"),
236216
serialization_alias="timeout",
237217
)
@@ -246,70 +226,89 @@ def __init__(self, **kwargs: Any):
246226
self.name = f"mswe_{safe_run_id}_{uuid.uuid4().hex[:8]}"
247227
self._tmp = tempfile.TemporaryDirectory(prefix=f"pyxis_{self.name}_")
248228
self._tmp_dir = Path(self._tmp.name)
249-
self._tmp_dir.chmod(0o1777)
250229
self._lock = threading.Lock()
251230
self._cleaned = False
252231
try:
232+
(self._tmp_dir / "tmp").mkdir(mode=0o700)
233+
shutil.copyfile(_COMMAND_WORKER, self._tmp_dir / _COMMAND_WORKER.name)
253234
# A no-op initializes and validates the named persistent container.
254235
run_srun_step(
255236
image=self.config.image,
256237
name=self.name,
257-
mounts=[(self._tmp_dir, "/tmp")],
238+
mounts=[(self._tmp_dir / "tmp", "/tmp")],
258239
workdir=self.config.cwd,
259240
argv=["true"],
260-
status_path=self._tmp_dir / Path(_STEP_STATUS).name,
241+
status_path=self._tmp_dir / "tmp" / Path(_STEP_STATUS).name,
261242
timeout_s=self.config.timeout_s,
262243
failure_path=self.config.infrastructure_failure_path,
263244
)
264-
except RunnerError as exc:
245+
self._persistent_channel = PersistentExecChannel(
246+
self._tmp_dir / "channel",
247+
self._persistent_server_command(),
248+
safe_srun_env(),
249+
failure_path=self.config.infrastructure_failure_path,
250+
launch_timeout_s=self.config.timeout_s + 30,
251+
)
252+
self._persistent_channel.start()
253+
except (RunnerError, OSError) as exc:
265254
self.cleanup()
266255
raise RunnerError(
267256
f"failed to start Pyxis container for {self.config.image}"
268257
) from exc
269258

259+
def _persistent_server_command(self) -> list[str]:
260+
return build_srun_command(
261+
name=self.name,
262+
# Tool cleanup of /tmp must not remove the worker or its protocol files.
263+
mounts=[
264+
(self._tmp_dir / "tmp", "/tmp"),
265+
(self._tmp_dir, _PERSISTENT_ROOT),
266+
],
267+
workdir=self.config.cwd,
268+
argv=[
269+
"env",
270+
*(f"{key}={value}" for key, value in self.config.env.items()),
271+
"unshare",
272+
"--pid",
273+
"--fork",
274+
"--mount-proc",
275+
"--kill-child",
276+
"bash",
277+
f"{_PERSISTENT_ROOT}/{_COMMAND_WORKER.name}",
278+
f"{_PERSISTENT_ROOT}/channel",
279+
*self.config.interpreter,
280+
],
281+
)
282+
270283
def execute(
271284
self, action: dict[str, Any], cwd: str = "", *, timeout: int | None = None
272285
) -> dict[str, Any]:
273286
command = action.get("command", "")
274287
logger.debug("Executing Pyxis command: %s", command)
275-
argv = ["env"]
276-
argv.extend(f"{key}={value}" for key, value in self.config.env.items())
277-
argv.extend([*self.config.interpreter, command])
278-
result = run_srun_step(
279-
argv=argv,
280-
status_path=self._tmp_dir / Path(_STEP_STATUS).name,
281-
timeout_s=timeout or self.config.timeout_s,
282-
failure_path=self.config.infrastructure_failure_path,
283-
name=self.name,
284-
mounts=[(self._tmp_dir, "/tmp")],
285-
workdir=cwd or self.config.cwd,
288+
timeout_s = self.config.timeout_s if timeout is None else timeout
289+
result = self._persistent_channel.execute(
290+
command=command,
291+
cwd=cwd or self.config.cwd,
292+
timeout_s=timeout_s,
286293
)
287294
output: dict[str, Any]
288-
if result.returncode == 124:
295+
if result.timed_out:
289296
output = {
290-
"output": result.stdout,
297+
"output": result.output,
291298
"returncode": -1,
292299
"exception_info": "The command timed out",
293300
"extra": {
294301
"exception_type": "TimeoutExpired",
295-
"exception": (
296-
f"command timed out after {timeout or self.config.timeout_s}s"
297-
),
302+
"exception": f"command timed out after {timeout_s}s",
298303
},
299304
}
300305
else:
301306
output = {
302-
"output": result.stdout,
307+
"output": result.output,
303308
"returncode": result.returncode,
304309
"exception_info": "",
305310
}
306311
lines = output.get("output", "").lstrip().splitlines(keepends=True)
307-
# Some Slurm cli_filter plugins write informational messages to stderr.
308-
# run_srun_step merges stderr into stdout so command errors remain visible,
309-
# which can place this cluster-generated preamble before mini-swe-agent's
310-
# otherwise first-line submission marker.
311-
while lines and lines[0].strip() in _IGNORABLE_SRUN_PREAMBLE_LINES:
312-
lines.pop(0)
313312
if (
314313
lines
315314
and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"
@@ -353,6 +352,14 @@ def cleanup(self) -> None:
353352
return
354353
self._cleaned = True
355354
try:
355+
channel = getattr(self, "_persistent_channel", None)
356+
if channel is not None:
357+
try:
358+
channel.close()
359+
except (OSError, subprocess.SubprocessError):
360+
logger.warning(
361+
"Could not stop Pyxis worker %s", self.name, exc_info=True
362+
)
356363
if os.environ.get("SLURM_JOB_ID", "").strip():
357364
try:
358365
subprocess.run(

0 commit comments

Comments
 (0)