core: amortise LocalBackend's pending-completion compaction (fixes #528) - #607
Merged
Merged
Conversation
`trackPending` swept the whole `_pending` vector for expired `weak_ptr`s
before every append, so admitting one execute with n already in flight cost
n atomic `expired()` loads under `_pendingMtx`, and a burst of n cost O(n²)
— all of it before any model work started.
The issue was inferred from reading the code, so measure first. Against one
parked model (Release, GCC 16.2.1, 8-core Linux), timing only the `execute()`
calls themselves:
N=1000 total_admit_ms=0.593 mean_us=0.593 tail10pct_mean_us=0.815
N=2000 total_admit_ms=1.490 mean_us=0.745 tail10pct_mean_us=1.254
N=4000 total_admit_ms=5.008 mean_us=1.252 tail10pct_mean_us=2.191
N=8000 total_admit_ms=19.241 mean_us=2.405 tail10pct_mean_us=4.577
N=16000 total_admit_ms=77.778 mean_us=4.861 tail10pct_mean_us=9.566
N=32000 total_admit_ms=362.383 mean_us=11.324 tail10pct_mean_us=24.106
Total quadruples per doubling of N and per-admission cost doubles: the
O(n²)/O(n) pair. Deleting the sweep outright gives 7.571ms at N=32000 and a
flat 0.3µs per admission, which attributes all of it to the sweep and nothing
to the surrounding `make_shared`/lookup/post.
So sweep amortised instead: only when `_pending.size()` reaches `_compactAt`,
which each sweep re-arms at twice the surviving count (floor 32). A sweep
costs O(size) and at least `_compactAt / 2` appends must precede the next, so
admission is amortised O(1) at any depth. After:
N=32000 total_admit_ms=7.632 mean_us=0.239 tail10pct_mean_us=0.305
— within noise of the sweep-deleted bound.
The price is that `_pending` is bounded at twice the live count rather than
exactly it. `LocalBackend::trackedPendingCount()` makes that observable so it
is measured rather than asserted. `cancelPending` is unaffected: it never saw
dead entries, because `weak.lock()` has always skipped them, so carrying them
for longer changes nothing it observes. It does reset `_compactAt`, having
just emptied the list.
The intrusive alternative the issue also offers — a slot index on
`CompletionState`, unlinked on settle — was rejected: it puts a back-reference
to one backend's table into a type every backend shares, and a `_pendingMtx`
acquisition on the settle path of every completion, trading a cost paid once
per burst for contention paid by every strand thread on every result. The
reasoning is recorded as a design-decision row.
The new test asserts the two halves of the trade — the list stays bounded, and
`cancelPending` still reaches every live completion across every sweep — and
both were proved by mutation: making the sweep never run reports
`3120 < 1024` failed, and making it erase everything reports `0 == 48` failed.
It deliberately does *not* assert admission latency; a wall-clock bound on a
shared runner would be a flake, not evidence, so that stays with the benchmark.
`scripts/branch_partial_allowlist.json`: `line` hint only, 1228 -> 1230, for
the entry whose `source` text this change displaced. No entry added, removed
or reworded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH
The new morph#528 fixture declared `gate` — the `atomic<bool>` its parked
local ops spin on — *after* the `ThreadPoolExecutor` and the `LocalBackend`,
so it was destroyed first. Only the one parked task actually running has left
the strand queue when the test body ends; the other 47 are still queued and
run during `~StrandExecutor`/`~ThreadPoolExecutor`, by which point `gate` is
gone. ASan on CI:
ERROR: AddressSanitizer: stack-use-after-scope
READ of size 1 at 0x7b8beeef0500 thread T1
#2 ... CATCH2_INTERNAL_TEST_16()::$_0::operator()()
tests/test_backend_extra.cpp:365:57
#7 ... pendingCall(std::function<void ()>)::$_0::operator()(IModelHolder&)
#12 ... LocalBackend::execute(...)::'lambda'()::operator()()
include/morph/core/backend.hpp:1303:25
Reproduced locally under the `clang-asan` preset at the same line, and gone
after this change.
Fixed on both axes rather than either: `gate`, `churnSettled` and `cancelled`
now precede the pool, so every piece of state a pool task touches outlives the
pool; and the parked ops count themselves, so the test drains all 48 before
leaving the scope instead of trusting teardown to. The assertions are
untouched and both mutation proofs still hold — never sweeping reports
`3120 < 1024` failed, sweeping everything reports `0 == 48` failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Fixes #528 (finding F10 of the #518 sweep).
The ticket asked to be measured before it was fixed
#528's own verification status is "Inferred from reading the code; not reproduced", and its re-open condition is "N concurrent executes against a slow model, plot admission latency against N. Quadratic growth confirms it." So that came first, and the fix only after it held.
Rig. A standalone harness driving
LocalBackend::executedirectly against one model whoselocalOpparks on a gate, so every posted task — and so everyCompletionState— stays live and_pendingreally reaches depth N. Only theexecute()calls themselves are timed; nothing downstream. clang 22.1.8,-O2, against the header-only tree, on an 8-core Linux box. Every figure below is from that one rig, so the three sets are comparable with each other; they are not a claim about any other compiler or machine.Before (
master@26bfdb8f):Total admission time per doubling of N: ×2.5, ×3.4, ×3.8, ×4.0, ×4.7 — converging on ×4, i.e. O(n²). Mean per-admission cost doubles per doubling, i.e. O(n). The ticket's premise holds, and is accepted.
Attribution. A measurement that the cost grows is not yet a measurement that this line causes it. Deleting the
erase_ifoutright (incorrect — the list would grow unbounded — but it isolates the term):Flat in N. So all of the growth is the sweep, and none of it is the surrounding
make_shared/ registry lookup / strand post — which also gives a floor to aim at.After (this branch):
7.63 ms against 7.57 ms with the sweep deleted entirely: the amortised compaction costs nothing measurable while still reclaiming. 47× on total admission time at N=32 000, 74× on the tail.
On the other close condition
#528 also says to close it if "in-flight depth is bounded low by construction in every intended deployment". It is not.
RemoteServer::LimitPolicy::maxInFlightExecutesdefaults to0(unbounded), and it is in any case aRemoteServerpolicy — it does not sit in front ofLocalBackendat all. Nothing else bounds the depth;Bridge::pendingCalls()counts but does not gate. And_pendingis keyed on state liveness, not on in-flight-ness: a caller that retains itsCompletionhandles keeps their entries live long after the calls have settled, so the list can exceed the in-flight count. The condition is not met.The fix
Sweep amortised, not per-append:
trackPendingsweeps only when_pending.size()reaches_compactAt, and each sweep re-arms_compactAtat twice the number of entries that survived it, floored at 32. A sweep costs O(size) and at least_compactAt / 2appends must happen before the next one, so per-append cost is amortised O(1) at any depth.The issue's cheap shape, not its better one. The intrusive option — a slot index on
CompletionState, unlinked on settle — was weighed againstdocs/spec/core/completion.mdand rejected, with the reasoning recorded as a design-decision row rather than left in this PR:_pendingMtxacquisition onto the settle path of every completion, i.e. onto every strand thread on every result — trading a cost paid once per burst for contention paid continuously, on the exact path Give Completion<T> a value-handling contract, and stop a benchmark racing on a dead frame #579 just gave a value-handling contract.cancelPending, which is the one thing that must not break here.The amortised sweep buys the same O(1) admission for one
size_tconfined toLocalBackend.The price, stated plainly:
_pendingis now bounded at twice the live count plus the floor, rather than at exactly the live count. At 10 000 live that is ~320 KB ofweak_ptrinstead of ~160 KB.LocalBackend::trackedPendingCount()is added so that bound is measured rather than asserted — the test quotes 112 entries where an uncompacted list would hold 3 120.cancelPendingstill worksThis is the constraint that mattered, and it is preserved by construction rather than by care:
cancelPendingnever saw dead entries in the first place. Itsweak.lock()has always skipped them, so a longer-lived dead entry changes nothing it observes. What changed for it is one line — it resets_compactAtto the floor, having just emptied the list.Checked three ways:
weak.expired()); it only runs less often. A live entry was never a candidate for removal and still is not.cancelPending(BackendChangedError{})and requires all 48 to arrive.~Bridge's blocking teardown contract inconcurrency_and_lifetimes.mdis untouched: the snapshot-then-deliver-outside-the-lock shape is byte-for-byte the same, and no callback runs under_pendingMtx.Proving the test is not vacuous
Two mutations, each reverted after measuring:
A — never sweep (
if (_pending.size() >= _compactAt)→if (false)), which is the "did the optimisation delete the feature?" case:B — sweep everything (predicate → always true), which is the "did the optimisation break cancellation?" case:
Unmutated, same fixture:
tracked=112 admitted=3120,48 == 48, 51 assertions, 65 ms.What the test does not cover, said plainly: admission latency itself — the whole point of the change — is not asserted anywhere. A wall-clock bound on a shared CI runner would be a flake rather than evidence, so that measurement lives in the benchmark above and in the spec, and the test guards only the two properties that can be checked deterministically. This is stated in the test's own comment too, so a later reader does not mistake it for a perf gate.
Review reasoning (Copilot has no seats on this org, so this is the review)
_compactAtguarded? Yes — it is read and written only under_pendingMtx, intrackPendingandcancelPending, the only two functions that touch_pending. No new lock, no new ordering, no new lock held across a callback.cancelPendingresets it to the floor._compactAt / 2appends occur, and the sweep costs O(_compactAt), so the per-append share is bounded by a constant independent of N. A fixed interval would have been O(interval) per append and would still degrade — that distinction is why the doubling is load-bearing and is spelled out in the code comment.weak_ptrs (512 bytes). That is the intended trade and is documented on the constant.trackedPendingCount()is deliberately not namedpendingCount: it is an upper bound on in-flight, not the in-flight count, and its doc comment and the spec row both say so and point atBridge::pendingCalls()for the other question. It exists to make the memory price measurable; without it the bound would be an assertion rather than a measurement._pendingMtxbecamemutablefor the newconstaccessor. That is the standard shape and does not widen anything — the mutex was already the only guard for_pending.<algorithm>forstd::maxand<cstddef>forstd::size_t, both previously transitively available and now named.misc-include-cleaneris off in this repo, so this is hygiene rather than a gate.Verification
The first push failed one leg, and the finding was in the fixture rather than the change — worth stating rather than quietly amending away.
Linux / clang-asanreported a stack-use-after-scope at the parked op'sgate.load(): the fixture declaredgateafter theThreadPoolExecutor, so it was destroyed first, while 47 of the 48 parked tasks were still queued and ran during~StrandExecutor's blocking teardown.Reproduced locally under the
clang-asanpreset at the same line, so the fix was verified against a failing run rather than against CI going quiet. Fixed on both axes: every atomic a pool task touches is now declared before the pool, and the parked ops count themselves so the test drains all 48 before leaving the scope instead of trusting teardown to. The second commit carries that; the assertions are untouched and both mutation proofs above were re-run against the corrected fixture.NoOpReplayLedgernegative-conformance case); 22 629 assertions-Weverything[backend],[local],[bridge]under TSanOomInjector: unusable under ASan/TSan, pre-existing and unrelatedscripts/check_spec_sync.sh10 sub-domain(s) classified; every touched header sub-domain has a matching spec changescripts/check_spec_citations.shProse lint OK— 845 references, 76 cited sectionsscripts/check_catch_test_names.shCatch2 test-name lint OK, 2 962 namesWARN_AS_ERROR=FAIL_ON_WARNINGSclang-tidy-diffover changed lines, clang-debug dbclang-format --dry-run -WerrorLAUNCHER = ... fastcache-ccon every tree used (not ccache)Filed separately
scripts/mutation_survivors.json'slinehints are unaudited and 5 of its 7 structured entries already point at the wrong line onmaster—backend.hpp:749is off by 477 lines. Noticed while checking whether my own allowlist edit had siblings; filed as #608 rather than folded in here.Files
scripts/branch_partial_allowlist.jsoncarries aline-hint refresh only:1228 → 1230, because this change inserted two#includelines above the allowlistednotifyBackendChangedline. No entry added, removed or reworded; the gate resolves entries bysourcetext and fails on a drifted hint, which is what this avoids.docs/spec/core/backend.mdis edited in three scoped places — a new "The pending list and its amortised compaction" subsection underLocalBackend, thetrackedPendingCount()row in its method table (plus thecancelPendingrow's threshold reset), and one design-decision row recording why the intrusive alternative was declined. #585 also touches this file; the edit is kept additive and local so the conflict, if any, is mechanical.🤖 Generated with Claude Code
https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH