What
StrandExecutor's drain slows down with competing CPU load far faster than
proportionally. Whether that is the Linux scheduler doing what schedulers do,
or StrandExecutor handing off worse than it has to, is not determined here
— this issue exists so the three measurements are on record rather than living
in a comment nobody will go looking for.
morph#760 anticipated this exactly: "Re-frame as a StrandExecutor defect if
the drain rate is shown to degrade with competing threads beyond what the
scheduler explains." morph#760 is closed by a timeout change (morph#779), which
does not answer that question, so it is split out here rather than closed with
it.
Measured
tests/test_strand_race.cpp, case StrandExecutor never runs two tasks for one key concurrently under contention, on c4e21233. 12 cores, Arch Linux
7.2.6-arch2-1, clang 22.1.8, preset linux-everything (Release). Load made
with plain while :; do :; done spin loops. Whole-case wall clock, one run
each:
| spin loops |
run queue (/proc/loadavg field 4) |
wall clock |
| 0 |
1 |
0.136 s |
| 12 |
14 |
23.8 s |
| 24 |
27 |
170.5 s |
| 36 |
38 |
396.4 s |
Every run passed — All tests passed (40 assertions in 1 test case), and every
watchdog line in every run reads inFlight 1, maxInFlight 1. Nothing is
breaking here; it is only getting slower.
The steps:
- 0 → 12 spin loops: 175×
- 12 → 24: 7.2× for 2× the load
- 24 → 36: 2.3× for 1.5× the load
Fitting the last two steps gives an exponent of about 2.1 in the
oversubscription ratio. A scheduler-latency story predicts roughly 1: a woken
thread waits about N/C scheduling quanta, so the per-handoff cost should track
the ratio, not its square.
The case's shape is what makes it a sensitive probe: the strand serialises
8 × 400 = 3200 tasks per iteration, so the iteration is 3200 sequential
wakeups and almost nothing else. At the 36-spinner point that is ~6 ms of wall
clock per handoff.
Verification status
- Reproduced: all four rows, on the revision named, one run per row.
- Not established: that this is a
StrandExecutor defect at all. Three
loaded data points, one run each, on one kernel, one machine, one core count.
No error bars. Plausible alternative explanations not ruled out: CFS wakeup
placement changing character as the run queue grows, the spin loops never
yielding (a real build's load would), std::this_thread::yield() inside the
task interacting with an oversubscribed run queue, or cache/IPC effects from
36 hot loops that have nothing to do with wakeups.
- Not attempted:
perf sched, which is what would actually separate
"waiting to be scheduled" from "doing more work per handoff".
What would settle it
perf sched latency (or perf trace -e sched:sched_switch) on one iteration at
each load point, split into time-to-wakeup versus time-in-task. If the
per-handoff wakeup latency grows as the ratio and the rest is flat, this is
the scheduler and the issue closes as "explained". If the non-wakeup part grows,
it is StrandExecutor and this becomes a real defect against
include/morph/core/strand.hpp.
A cheaper first cut: re-run the four points with load generated by sleep-y
processes rather than spin loops, and with the run queue held fixed while the
core count varies. If the exponent survives both, it is not an artefact of how
the load was made.
Why it is worth the trouble
Not for this test's runtime — morph#779 handles that with a per-case TIMEOUT 900, and says in the same breath that a host oversubscribed past ~4.5× will
blow through 900 s too. It matters because StrandExecutor is on the
per-model serialisation path for every execute() that goes through a strand.
If handoff cost really is quadratic in oversubscription, that is a property of
the framework under a busy server, not a property of one test.
What would change the verdict
Close as "scheduler, explained" if the perf split above attributes the growth
to wakeup latency. Re-frame as a strand.hpp defect if it does not. Close as
"not reproducible" if the exponent does not survive a different load generator
or a different machine — and say which, because that is a useful result too.
Related
- morph#760 — the timeout this was found under, and where the re-frame clause
comes from.
- morph#779 — the timeout change. It deliberately does not address this.
- morph#717 — the watchdog that makes this measurable at all.
What
StrandExecutor's drain slows down with competing CPU load far faster thanproportionally. Whether that is the Linux scheduler doing what schedulers do,
or
StrandExecutorhanding off worse than it has to, is not determined here— this issue exists so the three measurements are on record rather than living
in a comment nobody will go looking for.
morph#760 anticipated this exactly: "Re-frame as a
StrandExecutordefect ifthe drain rate is shown to degrade with competing threads beyond what the
scheduler explains." morph#760 is closed by a timeout change (morph#779), which
does not answer that question, so it is split out here rather than closed with
it.
Measured
tests/test_strand_race.cpp, caseStrandExecutor never runs two tasks for one key concurrently under contention, onc4e21233. 12 cores, Arch Linux7.2.6-arch2-1, clang 22.1.8, preset
linux-everything(Release). Load madewith plain
while :; do :; donespin loops. Whole-case wall clock, one runeach:
/proc/loadavgfield 4)Every run passed —
All tests passed (40 assertions in 1 test case), and everywatchdog line in every run reads
inFlight 1, maxInFlight 1. Nothing isbreaking here; it is only getting slower.
The steps:
Fitting the last two steps gives an exponent of about 2.1 in the
oversubscription ratio. A scheduler-latency story predicts roughly 1: a woken
thread waits about
N/Cscheduling quanta, so the per-handoff cost should trackthe ratio, not its square.
The case's shape is what makes it a sensitive probe: the strand serialises
8 × 400 = 3200tasks per iteration, so the iteration is 3200 sequentialwakeups and almost nothing else. At the 36-spinner point that is ~6 ms of wall
clock per handoff.
Verification status
StrandExecutordefect at all. Threeloaded data points, one run each, on one kernel, one machine, one core count.
No error bars. Plausible alternative explanations not ruled out: CFS wakeup
placement changing character as the run queue grows, the spin loops never
yielding (a real build's load would),
std::this_thread::yield()inside thetask interacting with an oversubscribed run queue, or cache/IPC effects from
36 hot loops that have nothing to do with wakeups.
perf sched, which is what would actually separate"waiting to be scheduled" from "doing more work per handoff".
What would settle it
perf sched latency(orperf trace -e sched:sched_switch) on one iteration ateach load point, split into time-to-wakeup versus time-in-task. If the
per-handoff wakeup latency grows as the ratio and the rest is flat, this is
the scheduler and the issue closes as "explained". If the non-wakeup part grows,
it is
StrandExecutorand this becomes a real defect againstinclude/morph/core/strand.hpp.A cheaper first cut: re-run the four points with load generated by
sleep-yprocesses rather than spin loops, and with the run queue held fixed while the
core count varies. If the exponent survives both, it is not an artefact of how
the load was made.
Why it is worth the trouble
Not for this test's runtime — morph#779 handles that with a per-case
TIMEOUT 900, and says in the same breath that a host oversubscribed past ~4.5× willblow through 900 s too. It matters because
StrandExecutoris on theper-model serialisation path for every
execute()that goes through a strand.If handoff cost really is quadratic in oversubscription, that is a property of
the framework under a busy server, not a property of one test.
What would change the verdict
Close as "scheduler, explained" if the
perfsplit above attributes the growthto wakeup latency. Re-frame as a
strand.hppdefect if it does not. Close as"not reproducible" if the exponent does not survive a different load generator
or a different machine — and say which, because that is a useful result too.
Related
comes from.