compute pool binds to a cpu list fails intermittently at tests/test_cpus.c:198,
CHECK(distinct > 1). Reported by @fab2s in
#27 (comment) while working on an
unrelated branch — neither commit there touches the pool or this test.
Observed rates, on a 24-thread Linux box:
- 6 failures in 12 full suite runs, every one at
distinct > 1
./test_cpus bind standalone: 8/8 pass idle, 1/40 fail with 24 spinners running
Not reproduced here: macOS has no affinity readback, so check_bind returns 77 and the
suite reports SKIP. It needs a Linux or Windows box to see.
What the check asserts, and why it is not guaranteed
check_bind picks the first two CPUs in the process mask, so picked == 2 and
waste_pool_init(0, &want) gives nthreads == 2 — one worker plus the calling thread,
which is a worker too. Then:
waste_parallel_for(nthreads * 4, 1, note_range, NULL);
distinct > 1 says "more than the caller took a chunk", and the comment above it is
right about why it has to be there: without it the test proves only that the caller was
bound and says nothing about the pool. But nothing in waste_parallel_for guarantees
it. Chunks are handed out from g_pool.next_chunk under a mutex, and the caller loops
on that counter itself:
for (;;) {
...
const int b = g_pool.next_chunk;
if (b >= n) { ...; break; }
g_pool.next_chunk = b + chunk;
...
fn(b, e, arg);
}
If the caller comes back for the next chunk before the worker has woken from
pthread_cond_broadcast, it takes that one too. Drain them all and distinct == 1 —
which reads as "the pool did not participate" when it only did not need to.
Two details that change the fix
There are exactly nthreads chunks, not nthreads * 4. chunk is
ceil(n / nthreads), not min_chunk:
int chunk = (n + g_pool.nthreads - 1) / g_pool.nthreads; /* (8 + 1) / 2 == 4 */
if (chunk < min_chunk) chunk = min_chunk;
so n = 8, min_chunk = 1, nthreads = 2 is 2 chunks of 4, one per thread. The
caller only has to beat the worker to the second one. This also means "tie the chunk
count to the thread count" — the fix floated in the PR thread — is already the case and
would not move anything.
The test already tries to prevent this, and the mitigation is the part that is
actually failing:
/* ...then burns enough time that the next chunk is taken
* by a different thread rather than by this one coming back for it. */
volatile long sink = 0;
for (int i = b; i < e; i++)
for (long k = 0; k < 2000000L; k++) sink += k;
That is a wall-clock assumption: the caller must still be spinning in range 1 when the
worker wakes. It holds on an idle machine and stops holding when the box is loaded —
which is exactly the shape of the numbers above, and why the failure is much more common
inside a full suite run (the suite is the load) than standalone.
Suggested fix
Make the participation a rendezvous instead of a race. Since waste_parallel_for emits
exactly nthreads chunks here, a barrier of nthreads participants inside note_range
is precisely the right assertion: every thread that is supposed to take a chunk must
arrive before any of them may leave, so the caller cannot drain the queue no matter how
the scheduler behaves.
It needs a timeout rather than a plain pthread_barrier_wait, so that a pool which
genuinely does not participate — the bug worth catching — fails the check instead of
hanging the suite. On timeout, report it as "only the caller took a chunk" and return 1.
The fixed spin can then go: it exists only to approximate the rendezvous, and it costs
8M iterations per range on every run.
Worth doing rather than muting — the check is the only thing in the suite that proves
--cpus / WASTE_CPUS reaches the pool's threads and not just the caller, and a flaky
check that people learn to re-run is one nobody reads when it means it.
compute pool binds to a cpu listfails intermittently attests/test_cpus.c:198,CHECK(distinct > 1). Reported by @fab2s in#27 (comment) while working on an
unrelated branch — neither commit there touches the pool or this test.
Observed rates, on a 24-thread Linux box:
distinct > 1./test_cpus bindstandalone: 8/8 pass idle, 1/40 fail with 24 spinners runningNot reproduced here: macOS has no affinity readback, so
check_bindreturns 77 and thesuite reports SKIP. It needs a Linux or Windows box to see.
What the check asserts, and why it is not guaranteed
check_bindpicks the first two CPUs in the process mask, sopicked == 2andwaste_pool_init(0, &want)givesnthreads == 2— one worker plus the calling thread,which is a worker too. Then:
distinct > 1says "more than the caller took a chunk", and the comment above it isright about why it has to be there: without it the test proves only that the caller was
bound and says nothing about the pool. But nothing in
waste_parallel_forguaranteesit. Chunks are handed out from
g_pool.next_chunkunder a mutex, and the caller loopson that counter itself:
If the caller comes back for the next chunk before the worker has woken from
pthread_cond_broadcast, it takes that one too. Drain them all anddistinct == 1—which reads as "the pool did not participate" when it only did not need to.
Two details that change the fix
There are exactly
nthreadschunks, notnthreads * 4.chunkisceil(n / nthreads), notmin_chunk:so
n = 8, min_chunk = 1, nthreads = 2is 2 chunks of 4, one per thread. Thecaller only has to beat the worker to the second one. This also means "tie the chunk
count to the thread count" — the fix floated in the PR thread — is already the case and
would not move anything.
The test already tries to prevent this, and the mitigation is the part that is
actually failing:
That is a wall-clock assumption: the caller must still be spinning in range 1 when the
worker wakes. It holds on an idle machine and stops holding when the box is loaded —
which is exactly the shape of the numbers above, and why the failure is much more common
inside a full suite run (the suite is the load) than standalone.
Suggested fix
Make the participation a rendezvous instead of a race. Since
waste_parallel_foremitsexactly
nthreadschunks here, a barrier ofnthreadsparticipants insidenote_rangeis precisely the right assertion: every thread that is supposed to take a chunk must
arrive before any of them may leave, so the caller cannot drain the queue no matter how
the scheduler behaves.
It needs a timeout rather than a plain
pthread_barrier_wait, so that a pool whichgenuinely does not participate — the bug worth catching — fails the check instead of
hanging the suite. On timeout, report it as "only the caller took a chunk" and return 1.
The fixed spin can then go: it exists only to approximate the rendezvous, and it costs
8M iterations per range on every run.
Worth doing rather than muting — the check is the only thing in the suite that proves
--cpus/WASTE_CPUSreaches the pool's threads and not just the caller, and a flakycheck that people learn to re-run is one nobody reads when it means it.