Repository navigation
fix: Close the weight-sync keep-alive race and fail fast on a dead trainer - #14
Merged
Merged
Conversation
The trainer's httpx client and SGLang's uvicorn both dropped an idle keep-alive connection after 5 s, so a request sent 5 s after the previous one could land on a connection the engine was closing and get a TCP reset (httpx.ReadError). A full-weight sync whose buckets went out 5 s apart lost that race and took the trainer down. GeneralHttpClientProvider now expires idle connections after 2 s, and every engine miles launches gets SGLANG_TIMEOUT_KEEP_ALIVE=600 (a value in the driver's environment still wins). tools/fork/keepalive_race_repro.py reproduces the race on CPU against SGLang-like uvicorn servers.
pause_generation, continue_generation and update_weight_version (without abort_all_requests) leave the engine as one call would when repeated, so they are sent once more after a ReadError, WriteError or RemoteProtocolError. The weight buckets, begin/end_weight_update and the LoRA calls are never retried: the engine may already have acted on them.
push_slot, unload_adapter and update_weights took the inference controller's lock and paused the engine health checks with no try/finally, so a failed push left the lock taken and the checks paused for the rest of the run. They now run inside _engine_weight_update, which ends the update on success and calls the new InferenceController.abort_update_weights on any exception, cancellation included: the lock is released and no engine is marked weights-ready. end_update_weights and the abort put the engine health checks back the way the update found them; the Tinker gateway never calls prepare_rollout, so they had stayed paused from its first push on.
A failed weight push or training command kills the trainer actors and nothing rebuilds them in place, yet /api/v1/healthz kept answering ok and the job monitor only checked the head container, so a broken job held its nodes for an hour. ExecutorBackend.health_problem() reports why no command can run any more; MilesBackend reports a trainer with no alive cell (TrainerController.has_alive_cell), and healthz and /health answer 503 with the reason. The ABCI job monitor probes healthz every 30 s and ends the job after GATEWAY_DEAD_PROBES (4) consecutive 503s; any other answer, no answer included, resets the count.
Trainers took the lowest-IP nodes, so the head node, which also runs the Ray head, the driver, the gateway and the client, could host trainer rank 0, which converts and sends every weight-sync bucket. On a head saturated by the client the buckets went out 5 s apart. placement_group orders bundles by node IP and GPU id with the Ray head node last (sort_bundles_head_last), and the ABCI job assigns roles in the same order: the head is always an engine node, last in roles.tsv and the service URL lists, and the launcher prints a "node roles:" line.
…last roles The ABCI README (roles, GATEWAY_DEAD_PROBES, troubleshooting), the 27B preset's header, docs/fork/deploy-pbs-15node.md and the FORK.md rows.
…ails start_update_weights restored the health checks it paused only when choosing the server failed. Reading the chosen server's engine list (each cell's api client) can raise too; acquires_lock then released the lock but the checks stayed paused. The whole body now sits inside the guard.
update_weight_version moved from _make_request to _post_idempotent and lost the note with the engine's answer that _make_request adds to an HTTPStatusError (SGLang puts the reason in a 400 body). The idempotent calls now add the same note.
A dead trainer used to leave its job idle, which at least let someone inspect it; the new monitor exit had no way to keep that. 0 turns the probe off, and a value that is not a count is refused at submit time instead of breaking the monitor's arithmetic later.
The job's wait for a healthy gateway took healthz's 503 for a gateway still starting and kept polling for up to an hour; only the monitor, which starts after that wait, counted 503s. A full fine-tuning gateway pushes the base weights to the engines as soon as it serves. When that push fails, the trainer is dead before the wait sees a 200 if the wait starts late, as it does with SERVICES=1 once the services are up, and the job then held its nodes for that hour. The wait now ends the job after GATEWAY_DEAD_PROBES consecutive 503s, 10 s apart, as the monitor does 30 s apart; no answer still means a gateway that is starting. The probe's message drops its "monitor:" prefix, since the wait uses it too, and a count with a leading zero is refused: bash reads 08 as a bad octal number, which turned the probe off without a word.
A cancelled push raises CancelledError, which cell.execute neither catches nor answers by killing the cell, so only the update frame hands the inference controller back. The tests so far cancelled a push against a recording stand-in for the controller. These cancel push_slot, unload_adapter and update_weights mid-command, and a push that waits for an engine cell with the lock released between polls, under the real ContextLock: afterwards the lock is free, the health checks run as before, the cell is still alive and the next push goes through.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eleven commits on top of
apga/devat3e675bfb, onfix/weight-sync-keepalive:3fd5aeb3fix: Drop idle engine connections before SGLang closes them9cb542c0fix: Retry idempotent engine control calls once after a connection resetd430c346fix: Hand the inference controller back when a weight push fails6658821afeat: Report a dead trainer on healthz and end the ABCI job on it163faaa8fix: Keep trainer ranks off the head nodedc5b38ffdocs: Document the engine keep-alive, the dead-trainer exit and head-last rolesf38fb8c8fix: Restore engine health checks when listing the update's engines failsba2ed4fcfix: Keep the engine's reason on a failed idempotent control call9179c703feat: Let GATEWAY_DEAD_PROBES=0 keep a dead gateway's job runningcd0acdfefix: End the job when the trainer dies before the gateway turns healthyc51aa05atest: Cancel a weight push under the real controller lockSquash-merge it:
d430c346on its own failstests/fast/ray/train/test_group_weight_update.py, which callshas_alive_cell()from6658821a.What happened
A 10-node Qwen3.8-27B full fine-tuning run (5 trainer nodes, 5 TP8 engines, 128K context, the
qwen3.8-27b-128kpreset) lost its trainer after 46 steps, about 10 hours in:
went out 5 s after bucket 22. SGLang serves HTTP with uvicorn and closes a keep-alive connection that has been idle
for
SGLANG_TIMEOUT_KEEP_ALIVEseconds, 5 by default; httpx, which the trainer uses for every engine call, drops anidle pooled connection after 5 s too. The trainer reused the connection to each engine; four of the five engines
closed theirs at that moment and answered the bucket's POST with a TCP reset (
httpx.ReadErrorinupdate_weights_from_distributed). The fifth engine got the bucket and joined its NCCL broadcast, which nevercompleted.
So trainer rank 0, which converts and sends every bucket, shared its 192 cores with the Ray head, the driver, the
gateway and the platoon client with its sandboxes. The client kept that node at 96-97 % user CPU with a run queue of
about 500 for the whole push. The run had crossed a 5 s gap before and survived it; it lost the race once.
push_slothad notry/finally: the inference controller's lock stayed taken (its engine tick logged "Stillwaiting for lock 'InferenceController'" for over an hour), and the engine health checks stayed paused. The engines
stayed paused in a half-written update.
/api/v1/healthzkept answeringokand the job's monitor only checkedthat the head container was alive, so the job held its 10 nodes for another hour, until the client gave up after
three refused restarts.
Production jobs run the same code; their head nodes happened to be engine nodes, so their syncs stay fast and never
met a 5 s gap.
The run, resubmitted with the same code and the same layout (its head node again had the lowest IP), died the same
way about 2.5 hours in: bucket 9 of a push went out 5 s after bucket 8, the one engine whose connection was still
open took it, and the other four reset theirs (
httpx.ReadError). That job, too, held its nodes for another 66minutes, its engine tick waiting on the leaked lock the whole time.
Changes
A. No request on a connection the engine is closing
miles/utils/http_utils.py:GeneralHttpClientProviderbuilds its client withkeepalive_expiry=2.0(
_KEEPALIVE_EXPIRY). httpcore checks the expiry before it hands out a pooled connection, so a connection idle formore than 2 s is closed and a new one opened. Every
SGLangApiClientcall goes through this client.miles/ray/specs/inference.py:compute_inference_engine_env_varsgives every engineSGLANG_TIMEOUT_KEEP_ALIVE=600. This is the only knob SGLang has for it (envs.SGLANG_TIMEOUT_KEEP_ALIVE, read byuvicorn.run(..., timeout_keep_alive=...); no server argument). It reaches every engine of every launcher, throughthe engine actor's environment, and a value in the driver's environment still wins, like the other defaults there.
600 s is far above any client's idle expiry, so even a client stalled for seconds between its pool check and its
write cannot meet a closing connection.
miles/backends/sglang_utils/sglang_api_client.py:pause_generation,continue_generationandupdate_weight_version(withoutabort_all_requests) are sent once more afterReadError,WriteErrororRemoteProtocolError; SGLang leaves the engine as one call would when they repeat. Nothing else is retried:update_weights_from_distributedmay already have joined the engine to the NCCL broadcast (a second join waits for abroadcast that never comes), SGLang asserts the session state in
begin_weight_updateandend_weight_update(repeatingone that did arrive fails that assertion in the engine's scheduler, and a scheduler exception takes the engine down),
and the LoRA calls answer differently the second time. A refused
connection and an error status are not retried either; an error status keeps the engine's answer as a note on the
HTTPStatusError, as_make_requestdoes.B. A failed push hands the inference controller back
miles/ray/train/group.py:push_slot,unload_adapterandupdate_weightsrun inside_engine_weight_update, which ends the update on success and calls the newInferenceController.abort_update_weightson any exception, cancellation included. The abort releases the lock but marks no engine weights-ready: an engine may
hold half of the new weights, and
end_update_weightswould register it with the router.miles/ray/rollout/inference_controller.py:start_update_weightsrecords whether the engine health checks wererunning;
end_update_weightsandabort_update_weightsput them back that way (a start that fails anywhere, choosing the server or reading its engine list, does too). TheTinker gateway never calls
prepare_rollout, so before this its health checks stayed paused from its first push on.Engines that were offloaded before an update (colocated training) stay unprobed until
prepare_rollout, as before.With fault tolerance on, as the gateway launcher sets it by default, this brings the gateway's engine health
checks back: every push re-arms the checker's first wait (
--rollout-health-check-first-wait, 600 s in thelauncher), so engines are probed only while pushes are further apart than that, and a cell that fails three
probes in a row is respawned; the next push waits for it and gives it the current weights. Production pushes
are hours apart, so there the engines are probed for most of every step.
C. Fail fast when the trainer is gone
/api/v1/healthzand/healthanswer 503 with{"status": "unavailable", "error": ...}once no trainer cellis alive (
ExecutorBackend.health_problem(), implemented byMilesBackendthrough the newTrainerController.has_alive_cell()). The cell is marked errored the moment its command fails, so healthz turns 503before the actors are confirmed dead.
deploy/abci/tinker_gateway.pbs: the monitor also probes healthz every 30 s and ends the job afterGATEWAY_DEAD_PROBES(4) consecutive 503s. So does the job's wait for a healthy gateway, 10 s apart: a fullfine-tuning gateway pushes the base weights as soon as it serves, and when that push failed before the wait saw a
200 (with
SERVICES=1the wait starts only once the services are up), the wait took the 503s for a gateway stillstarting and polled for its full hour, with the monitor not yet running. Any other answer, no answer included,
resets the count, so a busy or starting gateway is not taken for a dead one.
GATEWAY_DEAD_PROBES=0turns theexit off (to keep a dead gateway's nodes for inspection); a value that is not a count, a leading zero included
(bash would read
08as a bad octal number), is refused at submit time.D. The head node never trains
miles/ray/placement_group.py: bundles are ordered by node IP and GPU id with the Ray head node last(
sort_bundles_head_last; the head is the alive node with Ray'snode:__internal_head__resource). Trainer rankstake the first bundles, so they land on the head node only when no other node is left.
deploy/abci/tinker_gateway.pbs: the roles follow the same order. The head is always an engine node (TRAIN_NODES <NODES), last in
roles.tsvand in the service URL lists, andlauncher.logprints anode roles:line. Where thehead did not have one of the
TRAIN_NODESlowest IPs, the trainer nodes and their rank order stay as they were;only the head's engine (and with
SERVICES=1its service URL) moves to the end of the engine list, which changesnothing when the head had the highest IP, as in the production jobs.
Not in this PR: rebuilding the trainer inside the job after a failed push (it needs a collective abort of the
weight-update NCCL group and a restart of an engine left in an orphaned broadcast), retrying a failed push, the
platoon client's own fail-fast, and nicing the client.
Reproduction (CPU)
tools/fork/keepalive_race_repro.pyruns SGLang-like engines, FastAPI onuvicorn.run(app, loop="uvloop", timeout_keep_alive=...)as SGLang starts them, against themilesclient onPYTHONPATH. The client sendsSGLangApiClient.update_weights_from_distributedand leavesGAP - offsetseconds between a response and the nextrequest, the offset uniform in a few milliseconds. In
scanmode each engine has its own client process; inbroadcastmode one client fans every bucket out throughmiles.utils.async_utilsexactly asprotocols/broadcast.pydoes, optionally with busy loops on its core to starve it like the incident's head node.The responses carry the server's keep-alive, so the summary shows which servers answered. Same versions as the
serving image: httpx 0.28.1, httpcore 1.0.9, uvicorn 0.52.4, uvloop 0.22.1, h11 0.16.0.
Each row runs SGLang-like engines with the given keep-alive against the client at the base (
3e675bfb, httpx'sdefault 5 s expiry) or at this branch (2 s), sending
update_weights_from_distributed5 s after the previous response(minus 0.5 to plus 2.5-5 ms). "Starved" adds four busy loops on the client's core.
ReadError(idle 4.9985-4.9996 s)ReadErrorReadErrorA3, with an engine keep-alive of 1 s and 1 s gaps so the race fires often (12 engines x 300):
pause_generation3600 requests, 0 failures, 442 resets retried;update_weights_from_distributed(neverretried, the control) 382
ReadErrorand 1RemoteProtocolErrorof 3600.Run it:
python tools/fork/keepalive_race_repro.py --help(the client side needshttpx,uvicorn,uvloop,fastapiand this checkout onPYTHONPATH; no GPU).Tests
Every suite ran at this branch and at the base
3e675bfb; the failures are the same at both.tests/fast/tinker320 passed (base 312; the same 3test_cp_parity_clientandtest_sdk_flowfailures at both),tests/fast/backends/sglang_utils799 (base 778), the HTTP, lock, health-checkerand async utils 268 (base 266),
tests/fast/ray842 (base 823; the same 2test_group_reconcile_adaptersfailuresat both: the tests call a
TrainerController.reconcile_adaptersthat neither tree has). 51 new tests, all passing.ip):tests/fast/tinker325 (base 317),tests/fast/fork184(base 179; the same
test_smoke_q36_step9contract failure at both),tests/fast/launch_scripts43,tests/fast/utils/test_http_utils.py36 (base 34).TestWaitTcpReadyAsync::test_it_returns_once_the_port_acceptshangs at the base as well under Python 3.12 and wasdeselected.
is never reused; A2 the engines' default and an operator's override; A3 each idempotent call is sent again after
ReadError,WriteErrororRemoteProtocolError, and nothing else is; B1 with real Ray cells and the realcontroller lock,
push_slot,unload_adapterandupdate_weightsrelease the lock and restore the health checkswhether they succeed, fail or are cancelled (mid-command, or while the update still waits for an engine cell with
the lock released between polls; the next push then runs), and a failed one marks no engine weights-ready; C1
healthz answers 503 once the trainer has no alive cell, the monitor's probe ends the job after four 503s in a row
and resets on any other answer, and the startup wait ends the job the same way but waits out its hour for a
gateway that does not answer, or with
GATEWAY_DEAD_PROBES=0; D1 head-last bundle order (lowest-IP head, unknownhead, single node) and the PBS roles of the failed layout and of two nodes.
9179c703, every serving-image suite again:tests/fast/tinker320,tests/fast/backends/sglang_utils800, the utils 268 andtests/fast/ray843, with the same failures as the base(2231 of 2243 passed against 2179 of 2191). In the test venv
tests/fast/fork186,tests/fast/launch_scripts43,tests/fast/tinker325 andtests/fast/utils/test_http_utils.py36.c51aa05a: in the test venvtests/fast/fork192 (the sametest_smoke_q36_step9failure),
tests/fast/launch_scripts43 andtests/fast/tinker325; in the serving image the update-frame tests(
test_group_weight_update.py,test_inference_controller.py,test_trainer_slot_commands.py) 74 passed, thefour new cancellation tests among them.
tests/fast/forkfiles black would reformat and one ruff B905 arethe same at the base);
bash -non the PBS job and the preset.GPU check
One 2-node job (Qwen3-4B, full fine-tuning, 1 trainer node and 8 engines, this branch) with a test hook in the
trainer that waits 5 s before every weight bucket after the first and raises before bucket 2 of the fourth sync (the
warmup sync is the first), plus a client that checks the engines' keep-alive and pushes three times:
node roles: head <head> = ray head + driver + gateway + client + engine; trainers (rank 0 first): <other>.GET /healthon the same connection after 8 s idle (SGLang'sdefault would have closed it at 5 s).
after each.
Weight update failed: releasing the controller lock; no engine is marked weights-readyin the same second,healthz answered 503 one second later, and the monitor ended the job after four 503s 30 s apart, 1 min 40 s after
the failure, with a clean teardown.