perf(stratum-apps): make TaskManager::spawn tracking constant-time - #714
Open
gimballock wants to merge 2 commits into
Open
perf(stratum-apps): make TaskManager::spawn tracking constant-time#714gimballock wants to merge 2 commits into
gimballock wants to merge 2 commits into
Conversation
added 2 commits
August 13, 2026 10:22
`TaskManager::spawn` pruned its `Vec<JoinHandle<()>>` with `retain(|h| !h.is_finished())` on every spawn, a linear scan of every live handle per spawn, taken under the tracking mutex. Callers spawn several tasks per downstream connection, so reaching N concurrent connections cost O(N^2) and serialized that scan on one lock. Under load the connection-accept rate plateaus at low total CPU, because a single core saturates on the scan while the rest idle. Track tasks in a `tokio::task::JoinSet` instead. Insertion is constant time. A `JoinSet` releases an entry only when that entry is joined, and `join_all` runs only at shutdown, so `spawn` also drains already-finished tasks with the non-blocking `try_join_next`. That drain touches only tasks the runtime has already marked complete, costing O(tasks finished since the previous spawn) -- amortized constant per task -- and keeps the set bounded to roughly the live task count. Without the drain the set would retain one task control-block per task ever spawned, growing with cumulative rather than concurrent tasks. Measured on a distributed scale test with the pool isolated on its own host, this raised the connection ceiling from 34,543 to 61,259 while pool CPU went from 25.3% to 99.6% -- the plateau-at-low-CPU signature disappears. Two behavioural notes, neither of which any caller in this repo relies on: `join_all` now awaits in completion order rather than reverse-spawn order, and `abort_all` leaves aborted entries in the set until they are joined, so callers wanting them reaped should follow it with `join_all`. The tracking mutex is retained; what this removes is the per-spawn scan, not the lock. Adds three tests, since `TaskManager` had none: `join_all` drains every tracked entry, `spawn` releases finished entries so the set stays bounded under churn, and a panicking task is still reaped.
Both the previous `Vec<JoinHandle<()>>` implementation and the `JoinSet` that replaced it discard join results, so a managed task that panicked disappeared without a trace: the entry was released and nothing was logged. Operators had no signal that a task died, only its absent effects. Log a warning for join errors that are not cancellations. Cancellations are the expected outcome of `abort_all` during shutdown, so they stay quiet. In `spawn` the errors are collected under the tracking lock and reported after it is released. Reporting inline would put subscriber work on the contended path, and would deadlock if a subscriber ever spawned through this manager, because the lock is not reentrant. `Vec::new` does not allocate, so the common case of no failures costs nothing beyond a discriminant check per drained task. `join_all` already owns its set outside the lock, so it reports inline. This is separate from the preceding commit because the scale measurement quoted there predates it: that run exercised the tracking change alone.
Member
if the root cause is speculative, we shouldn't close the issue because that would imply certainty that the issue has been permanently solved |
Contributor
Author
|
Agreed — dropped the |
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.
Relates to #492; not
Closes.This removes one candidate cause of the plateau (analysis). The attribution is inferred, not confirmed: the measurement below was taken on a patched tree, and nothing here rules out other contributors. #492 should stay open until the plateau is confirmed gone on stock upstream.
Problem
TaskManager::spawnpruned itsVec<JoinHandle<()>>withretain(|h| !h.is_finished())on every spawn — a linear scan of every live handle, taken under the tracking mutex. Callers spawn several tasks per downstream connection, so reaching N concurrent connections costs O(N²) and serializes that scan on one lock.The observable symptom is a connection-accept plateau at low total CPU, because a single core saturates on the scan while the rest idle.
Fix
Track tasks in a
tokio::task::JoinSet. Insertion is constant time. Because aJoinSetreleases an entry only when that entry is joined, andjoin_allruns only at shutdown,spawnalso drains already-finished tasks with the non-blockingtry_join_next. That drain touches only tasks the runtime has already marked complete, costs O(tasks finished since the previous spawn) — amortized constant per task — and keeps the set bounded to roughly the live task count.The drain is load-bearing rather than an optimisation: without it the set would retain one task control-block per task ever spawned, growing with cumulative rather than concurrent tasks.
Measurement
r7i.4xlarge, pool isolated on its own host, one variable changed:Vec+retainJoinSetThe measured tree was this repo vendored at
14bb42d888fb(an ancestor ofmain) with a local patch stack, so it was not stock upstream — but the tracking change was the only variable between the two runs. The measurement covers the first commit only; the second postdates it.Behavioural changes to be aware of
Nothing in this repo relies on any of these, but they are real deltas:
join_allawaits in completion order, not reverse-spawn order. The previous doc comment promised the latter;join_nextdoes not provide it, and no caller depends on ordering.abort_allleaves aborted entries in the set until they are joined, where the olddrain(..)emptied it. Callers wanting them reaped should follow withjoin_all, which shutdown already does.Commits
perf(stratum-apps): make TaskManager::spawn tracking constant-time— the change the measurement above covers, plus three tests (TaskManagerhad none):join_alldrains every entry,spawnreleases finished entries so the set stays bounded under churn, and a panicking task is still reaped.feat(stratum-apps): surface tasks that exit abnormally— both the oldVecand the newJoinSetdiscard join results, so a panicking task vanished silently. This logs a warning for non-cancellation join errors, collecting them under the lock and reporting after release, because reporting inline would put subscriber work on the contended path and would deadlock if a subscriber ever spawned through this manager. Split out deliberately so it can be dropped without touching the measured fix.Notes
stratum-appsis at0.8.0while crates.io has0.7.0, so the crate is already bumped since last publish, and this change is API-compatible (per CONTRIBUTING factor 2).tokioalready carriesfull, which includesJoinSet.Validation
Run locally against
main(2e40be08) on the pinned 1.85.0 toolchain:cargo +nightly fmt --all --manifest-path=stratum-apps/Cargo.toml -- --check— pass (CI formats with nightly)cargo clippy --manifest-path=stratum-apps/Cargo.toml --all-features -- -D warnings— passcargo test --manifest-path=stratum-apps/Cargo.toml --all-features— 142 passed, stable across repeated runsI did not run the other manifests' clippy/fmt or the integration tests locally; the change is confined to one file in one crate with no API or dependency change.