fix(orchestrator): run long executor subprocesses off the event loop - #1471
fix(orchestrator): run long executor subprocesses off the event loop#1471siliangchen-amd wants to merge 3 commits into
Conversation
Enablement setup replay, specialist patch vetting, and localization editable-refresh called blocking subprocess.run on the coordinator event-loop thread. Frozen wall-clock time expired concurrent in-flight LLM stream idle timeouts, the dispatcher's re-scan poll, cancel grace, and kernel step heartbeats. Wrap each helper at the coroutine boundary with asyncio.to_thread and wait until it finishes. The existing offload helper is the wrong tool here: it returns a default on timeout while the worker keeps running, which would let integrate continue while pip is still mutating the environment. Short git helpers stay on the loop. Each call is bounded and usually sub-second, and moving them would cascade a large sync-to-async rewrite.
|
The diagnosis is right and Blocking1. # integrate_patch.py:2023
result = adapter.provision(action, attempt_dir)
if result.ok and not adapter.probe(result, action):
2. 3. The three helpers need the blocking annotation this repo already uses. They went from "call however" to "must be called via # recover.py:600
"""Per-GPU free VRAM, for callers that need the probe without the action.
Blocking (shells out to ``rocm-smi``); call via ``asyncio.to_thread``.One line each on Make it smaller, and pick one shape
return await asyncio.to_thread(
self._finalize,
ctx=ctx,
...
)
Please state the cancel semanticsBefore this change, a frozen loop meant cancel could not be delivered mid-install. After it, cancelling the action task raises out of Why I am asking for the invariant to be enforced, not just re-appliedThe analysis in this PR is above average and it still missed a 1800s pip install in the file it edits, a synchronous HTTP fan-out on the same path, and a 60s Two consequences:
Separately, and not for this PR: What I need to see: wrap |
… loop Provision (venv/pip, 1800s) and localization HTTP were still on the event-loop thread in the same enablement path as setup replay. Wrap them at the coroutine boundary. Converge specialist finalize on the same whole-helper to_thread wrap, and offload git worktree add. Setup replay now polls CancelScope between commands. An in-flight subprocess.run is not killed; cancelling the await unwinds integrate and does not continue to apply patches, unlike the budgeted offload helper. Blocking docstring lines mark the helpers that must stay off the loop.
|
Thanks — provision and the localization fetch were real misses on the same enablement path, and wrapping them the same way as setup replay is the right fix. A few corrections on the rest, then what landed. Probe. The four verify/version subprocesses run inside provision (venv, pip, torch/vLLM checks). urlopen. This path uses Docstring convention. The
Cancel is not offload. Offload returns a default on timeout and the caller continues (pip still mutating, integrate applies and benches). Cancelling Worktree add (60s) is wrapped from prepare for the same reason: a 60s freeze is enough to expire a per-chunk stream idle timeout. Same one-line wrap, same file as finalize. Not in this PR, as you already scoped: loop-lag detector and rewriting the dispatcher cancel docstring (that paragraph is the CancelScope invariant, not "how short a freeze may stay on the loop"). The editable-refresh |
ZhengGong-amd
left a comment
There was a problem hiding this comment.
All four blocking items are addressed, and two of them better than I asked for. _provision_and_probe offloads provision+probe+the downgrade as one atomic step rather than two thread round-trips. The docstring annotation went onto every offloaded helper, not just the three I named, which turns the convention into a habit. _finalize is sync again with the wrap at both call sites, so the PR now has one shape. And setup replay actually polls CancelScope between commands with a deterministic test, instead of just documenting the semantics — the ContextVar scope is copied into the to_thread worker, so that chain is sound.
Approving. Three things left, none blocking:
1. Broken docstring at integrate_patch.py:1983. The old line was not removed:
adapter (off the event loop; an in-flight pip install is not killed
if the await is cancelled), and on success stores the resolved runtime on
adapter, and on success stores the resolved runtime on
2. test_patch_vetting_runs_off_the_event_loop_thread is now self-proving. It calls await asyncio.to_thread(r._finalize, ...) in the test body and asserts the thread differs — that tests asyncio, not this code. Since _finalize went back to sync, it no longer covers the two real call sites (_run_via_backend, _run_via_subprocess). Drive one of those, or delete it. The other two tests go through _stage_apply and _stage_provision_attempt_runtime, so they are real.
3. The PR body is stale. It still says "three executor helpers", still lists "short git helpers" as the out-of-scope, and Limitations still reads "threads cannot be cancelled" with no mention of CancelScope. The new scope and the cancel semantics exist only in the commit message and the docstrings; the body is what reviewers and git log --merges readers see.
The two follow-ups from my earlier comment still stand: no loop-lag/slow-callback detection, and the criterion (upper bound shorter than the shortest timeout running concurrently on the loop) is not written next to dispatcher.py:220. A fresh example of why that matters: _gc_attempt_dir shutil.rmtrees a multi-GB failed venv on the loop thread (integrate_patch.py:2047, 2060, 2866). It is tens of seconds on shared storage, it is exactly what the written criterion would catch, and it is exactly what a hand-maintained list misses.
… run The provision-stage docstring duplicated a clause after an edit. The vetting thread test called to_thread itself, so it asserted asyncio rather than the production in-process finalize wrap.
|
All three nits are in. GC rmtree / lag detector / dispatcher criterion were left for follow-up. Docstring. _stage_provision_attempt_runtime no longer repeats “and on success stores the resolved runtime on”. One sentence: off-loop, in-flight pip is not killed. Vetting test. test_patch_vetting_runs_off_the_event_loop_thread now goes through SpecialistRunner.run + MockBackend (SPECIALIST_DONE), so the spy on vet_patches hits the production _run_via_backend wrap, not a to_thread in the test body. Ship. Pushed and PR body replaced. |
Problem
The coordinator is a single-threaded asyncio event loop. Concurrent tasks share that thread and only yield at await. Several executor helpers called blocking subprocess.run (and urllib) on that thread: enablement setup replay (up to twelve installs, 30 minutes each), attempt-runtime provision (venv + pip, 1800s), specialist patch vetting (serial git apply --check), localization HTTP fetch, localization editable-refresh (10 minutes), and specialist git worktree add (60s).
While the loop is frozen, wall-clock timeouts keep ticking. asyncio.wait_for records an absolute deadline when the await starts; when the loop resumes, the timer has already fired.
This does not stall the same tick's own model call: integrate_patch is joined. The work that is concurrent on the loop is what breaks: another in-flight action's streaming LLM call (per-chunk idle timeout), the dispatcher's re-scan poll, shutdown cancel-grace, and the kernel-step heartbeat.
The dispatcher assumes every executor spends blocking time inside asyncio.to_thread so cooperative cancel can run. These paths violated that.
Fix
Keep the helpers synchronous. At the coroutine boundary, wrap each one in asyncio.to_thread and wait until it finishes. Same convention as the magpie grid path. Provision+probe (including the probe-fail downgrade) is one hop, not two.
Do not use the existing offload helper. That helper waits with a budget, then returns a default while the worker keeps running. On setup replay or provision that would mean pip is still mutating the environment while integrate continues to apply patches and bench.
Setup replay polls the action CancelScope between commands (the ContextVar is copied into the worker). A command already inside subprocess.run is not killed. Cancelling the await unwinds integrate and does not apply patches. That is the opposite of an offload timeout, where the caller continues. Provision's single 1800s pip is fire-and-forget the same way.
Helpers that must stay off the loop are marked Blocking (...); call via asyncio.to_thread.
Out of scope
The many short git helpers on the integrate path stay on the loop. Each call is bounded (two-minute default) and usually sub-second. Moving them would force a cascade of sync helpers to become coroutines.
Loop-lag detection, and writing the "upper bound shorter than the shortest timeout concurrent on the loop" criterion next to the dispatcher cancel invariant, remain a follow-up. A failed attempt venv rmtree on the loop (tens of seconds on shared storage) is exactly what that criterion would catch.
The localization editable-refresh except Exception swallow is pre-existing and untouched.
Tests
Limitations
Threads started by asyncio.to_thread cannot be cancelled. Setup replay cooperates between commands; mid-command pip and provision pip are fire-and-forget. A long install occupies one slot in the default executor pool (min(32, cpu+4)). The magpie path already does this; this change does not introduce a dedicated pool.