Skip to content

perf(stratum-apps): make TaskManager::spawn tracking constant-time - #714

Open
gimballock wants to merge 2 commits into
stratum-mining:mainfrom
marafoundation:fix-task-manager-quadratic-spawn
Open

perf(stratum-apps): make TaskManager::spawn tracking constant-time#714
gimballock wants to merge 2 commits into
stratum-mining:mainfrom
marafoundation:fix-task-manager-quadratic-spawn

Conversation

@gimballock

@gimballock gimballock commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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::spawn pruned its Vec<JoinHandle<()>> with retain(|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 a JoinSet releases an entry only when that entry is joined, and join_all runs only at shutdown, spawn also drains already-finished tasks with the non-blocking try_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:

connections pool CPU
Vec + retain plateau at 34,543 25.3%
JoinSet 61,259 99.6%

The measured tree was this repo vendored at 14bb42d888fb (an ancestor of main) 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_all awaits in completion order, not reverse-spawn order. The previous doc comment promised the latter; join_next does not provide it, and no caller depends on ordering.
  • abort_all leaves aborted entries in the set until they are joined, where the old drain(..) emptied it. Callers wanting them reaped should follow with join_all, which shutdown already does.
  • The mutex is retained. This removes the per-spawn scan, not the lock; spawns still serialize on it. It simply stops being the binding constraint at this scale.

Commits

  1. perf(stratum-apps): make TaskManager::spawn tracking constant-time — the change the measurement above covers, plus three tests (TaskManager had none): join_all drains every entry, spawn releases finished entries so the set stays bounded under churn, and a panicking task is still reaped.
  2. feat(stratum-apps): surface tasks that exit abnormally — both the old Vec and the new JoinSet discard 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

  • The bounded-set test runs on a current-thread runtime with batches kept under the scheduler's per-tick poll budget. A multi-threaded variant passed in isolation but failed under the parallel suite, which is worth knowing if this test is ever revisited.
  • No version bump: stratum-apps is at 0.8.0 while crates.io has 0.7.0, so the crate is already bumped since last publish, and this change is API-compatible (per CONTRIBUTING factor 2).
  • No new dependency; tokio already carries full, which includes JoinSet.

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 — pass
  • cargo test --manifest-path=stratum-apps/Cargo.toml --all-features — 142 passed, stable across repeated runs

I 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.

Eric Price 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.
@plebhash

Copy link
Copy Markdown
Member

Closes #492 (candidate cause — see #492 (comment)).

if the root cause is speculative, we shouldn't close the issue because that would imply certainty that the issue has been permanently solved

@gimballock

Copy link
Copy Markdown
Contributor Author

Agreed — dropped the Closes. The attribution is inferred (patched tree, no stock-upstream reproduction), so #492 stays open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants