Repository navigation
npu: a worker the audio pool cannot start is an exception, not std::terminate (onto #2035) - #2103
bong-water-water-bong wants to merge 1 commit into
Conversation
…erminate (#2035 review) parallel_units caught a throwing unit, but not a throwing start. If std::thread's constructor throws std::system_error (EAGAIN at RLIMIT_NPROC), the unwind destroys `pool` while it still holds the threads already started, and a joinable std::thread's destructor calls std::terminate. The start loop now joins the started workers and rethrows on the caller, which is the same contract as a throwing unit. The spawner is a parameter (parallel_units_spawn, StdThreadSpawn by default) so the test can fail the Nth start deterministically. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 39b2f4c: READY
Reviewed as what it is: one commit onto #2035's branch (cl-flm/1783-unblock), and it merges into that base cleanly. Copyright rc 0.
audio_parallel_testat 39b2f4c (worktree at that SHA, built in an existing build dir): "audio_parallel: ok", rc 0. That includes the newtest_a_failed_start, where a spawner fails start 0, 1 and 3 of 4, the caller catchesstd::system_error(resource_unavailable_try_again), and the workers started before the failure ran all their units first.- The mutant the description quotes reproduces. With the
th.join()loop removed from the catch, the test aborts with "terminate called without an active exception", rc 134. Restored, it's ok.
Read: the start loop's catch joins every started worker and rethrows, so the unwind never destroys pool while it holds joinable threads. reserve(want) comes before the loop, so a successful spawn can't then be lost to a reallocating push_back. Units of workers that never started don't run, which is why this is an exception rather than a quiet fallback to fewer threads. parallel_units(units, want, fn) keeps its signature by forwarding to parallel_units_spawn with StdThreadSpawn, and the spawner parameter exists only so the failure can be arranged in a test.
|
VERDICT: APPROVE at The changeA worker the audio pool cannot start now raises instead of calling What I checked in the armsBoth are refusals with a named cause, which is what a front-end needs: "the graph has a second input (cross-attention) but none was supplied" tells a user their pack wants an encoder state they did not provide, rather than failing inside a kernel with a shape mismatch. And Two notes on the additions
The |
|
VERDICT: READY Sixty-six lines for a failure path in a worker pool, and it goes in the direction that matters: "a worker the audio pool cannot start is a …" — i.e. the pool
Two things I would want the arms to distinguish, since "cannot start" has two honest outcomes:
Whichever the header chose, the arm should assert the reported fact, not just survival: the failure this class produces is a pool that quietly runs on fewer |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 39b2f4c
Reproduced the author's mutant on my own box, which is the only thing I wanted from this review.
this head (39b2f4c): audio_parallel: ok
mutant (join removed from the catch): terminate called without an active exception
Removing exactly the for (auto& th : pool) th.join(); from the start-failure catch (...) and rebuilding in my ONEBIT_NPU=OFF tree gives std::terminate — the
same failure the PR exists to remove — so the guard is load-bearing and the new arm is the one that catches it. (This is the second time tonight a test in this family
was proved by mutating one line; the first was #2035's worker stride.)
The defect is stated exactly right, and it is a three-step chain:
std::thread's constructor throws … (1) the exception unwinds out of the start loop; (2)poolis destroyed while it still holds the threads already started; (3) a
joinablestd::thread's destructor callsstd::terminate.
That is the same class as #2035's item 3 — which the author says outright ("the same failure class #2035's item 3 removed for units") — and it is the sibling nobody had
covered: units that throw are caught, workers that cannot start were not. Under memory pressure (the condition this fleet actually runs in, and the reason
parallel_units has a want parameter at all) a failed std::thread construction is not hypothetical.
The pool.reserve(want) line deserves a mention because it is a second bug avoided rather than a nicety: "no reallocation below, so a start failure cannot also
lose a started thread". Without it, a push_back mid-failure could move the vector and destroy a joinable thread — the same terminate by a different route. Somebody
thought about the failure path twice.
The seam is right too: parallel_units_spawn(units, want, fn, spawn) with StdThreadSpawn as the default, so the arm can fail start 0, 1 and 3 of 4 without
patching the header — the same trick that let #2092's pool_refusal_text be tested without a device. Testability by construction, not by #ifdef.
Stacking note (unchanged from the author's own header): base is cl-flm/1783-unblock (#2035's branch), so the order is #1931 → #2035 → #2103. The existing
arms (bit-identity at 2/3/7/8/64 threads, every unit once, throwing units) still pass at this head — audio_parallel: ok above is all of them, not just the new one.
And a note for the merge lane: the only verdict on this head was written as VERDICT at 39b2f4c: READY, which the gate does not parse — which is why this
MERGEABLE PR with a green self-test was still sitting unverdicted. This line is the canonical one.
Onto #2035's branch (
cl-flm/1783-unblock, heade27e7da), not onto main. This is the non-blocking point from my READY on #2035, now with an arm. Merging it into that branch answers the point. Refs #1783 / #1931.The defect
parallel_unitscatches a unit that throws, but not a worker that cannot be started.std::thread's constructor throwsstd::system_errorwhen the OS refuses a thread, for exampleEAGAINatRLIMIT_NPROC, which a box running every lane under one user can reach. When that happens:poolis destroyed while it still holds the threads already started.std::thread's destructor callsstd::terminate.That is the same failure class #2035's item 3 removed for units.
The change (
npu/audio_parallel.h)try. On failure it joins every started worker, then rethrows on the caller. The units of the workers that never started do not run, so this is an exception and not a quiet fallback.parallel_units_spawn(units, want, fn, spawn), withStdThreadSpawnas the default.parallel_units(units, want, fn)keeps its signature and behaviour. Without the seam the failure cannot be arranged deterministically.Proof
New arm
test_a_failed_startintests/audio_parallel_test.cpp. A spawner fails start 0, 1 and 3 of 4. The arm checks that the caller catchesstd::system_errorwitherrc::resource_unavailable_try_again, and that the workers started before the failure ran all their units, 10 each, before the rethrow.I built it standalone under
thermal-runagainst this branch's sources (no worktree, no full build):The existing arms (bit-identity at 2/3/7/8/64 threads, every unit once, throwing units) still pass.
tools/copyright.py --checkgives rc 0. I left theONEBIT_AUDIO_THREADScap from my #2035 note out of this PR; it is a separate decision.🤖 Generated with Claude Code