The finding
StrandExecutor::post erases a model's Strand from _strands as soon as it
drains empty, so a workload that dispatches one action at a time against a
model allocates and frees a whole Strand on every call: the map node, the
make_shared<Strand> control block, and the two allocations
std::queue<std::function<void()>>'s deque makes for its map and its first
buffer.
Measured, on f24e225a, with tests/bench/bench_dispatch_allocations.cpp
(morph_bench_alloc, -DMORPH_BUILD_LOAD_TESTS=ON) built -O2 -g -rdynamic
with a backtrace() added to its counting hook and symbolised with
addr2line -f -i -C. x86-64 Linux, GCC 16.2.1 / libstdc++. Workload:
Ping{int} -> Pong{int} through LocalBackend on a one-thread pool, 50
warm-up calls excluded, every call waited out before the next.
Four of the 21 allocations in one steady-state local dispatch, and 752 of the
1995 bytes — 38% — are this:
#7 32 bytes strand.hpp:104 _strands map node
#8 152 bytes strand.hpp:106 make_shared<Strand>
#9 64 bytes <stl_deque> the Strand's std::queue deque map
#10 512 bytes <stl_deque> the Strand's std::queue first buffer
The whole census, for context:
local execute round-trips : 200
heap allocations total : 4165 (20.82 per call)
bytes allocated total : 398912 (1994.6 per call)
Why it happens
scheduleNext's drain-and-erase block decides "keep running vs.
drain-and-erase" atomically under _mapMtx + strand->mtx, and when the
pending queue is empty it erases the map entry. That erase is correct and
load-bearing — the comment above it records the race that made two strands
for one key run concurrently — and it is also what makes the next post() for
the same model take the if (!slot) branch and build everything again. Serial
dispatch never leaves a task queued at the moment the previous one finishes, so
it never keeps a strand.
Under concurrent load the strand stays armed and the cost amortises; this is a
serial-workload cost, which is also the shape a desktop GUI produces.
Verification status
- Reproduced, by the measurement above, on one revision, one toolchain, one
platform.
- Read, not measured: that the erase is what causes the re-creation. The
backtraces place the allocations in post()'s if (!slot) branch on every
counted call, which is only reachable when the previous call erased the slot,
but I did not instrument the erase itself.
- Not measured: the time cost. 752 bytes of churn per call is a
malloc/free pair set, not necessarily a visible latency change; nothing here
claims one.
- Not measured: what a concurrent workload costs, or what fraction of a
realistic ladder workload is serial.
Directions, none of them free
- Keep the slot, drop only the state. Leave an empty
Strand in
_strands and let a later post() re-arm it. Cost: a per-model entry that
is never reclaimed until deregisterModel, which is a leak of bounded size
for a long-lived process with many short-lived models — and the erase is
currently what bounds it.
- Shrink what is rebuilt.
std::queue<std::function<void()>>'s deque
accounts for 576 of the 752 bytes. A small-vector or an intrusive queue
would cut most of the cost without touching the lifetime rules at all, and
is the least invasive option.
- Pool the
Strand objects. Keeps the erase semantics and reuses the
allocation; more machinery than (2) for a similar saving.
Any change here must preserve the atomicity the drain-and-erase block
documents: the re-arm and the erase must stay one decision under _mapMtx, or
two strands for one key can run model tasks concurrently.
What would change the verdict
- Close if a measurement shows the per-call time cost is in the noise for
the intended workloads and the allocator churn is acceptable — 752 bytes of
short-lived churn per dispatch is headroom, not a defect.
- Raise in priority if a serial GUI workload is shown to be allocation
bound, or if morph#572's dispatch-cost work goes ahead: this is the largest
single cluster in that census and the only one that needs no public interface
change.
Found while re-measuring morph#572; filed separately rather than folded in,
since #572's scope is Bridge::executeVia's marshalling and the Completion
pair, not StrandExecutor's lifetime policy.
The finding
StrandExecutor::posterases a model'sStrandfrom_strandsas soon as itdrains empty, so a workload that dispatches one action at a time against a
model allocates and frees a whole
Strandon every call: the map node, themake_shared<Strand>control block, and the two allocationsstd::queue<std::function<void()>>'s deque makes for its map and its firstbuffer.
Measured, on
f24e225a, withtests/bench/bench_dispatch_allocations.cpp(
morph_bench_alloc,-DMORPH_BUILD_LOAD_TESTS=ON) built-O2 -g -rdynamicwith a
backtrace()added to its counting hook and symbolised withaddr2line -f -i -C. x86-64 Linux, GCC 16.2.1 / libstdc++. Workload:Ping{int} -> Pong{int}throughLocalBackendon a one-thread pool, 50warm-up calls excluded, every call waited out before the next.
Four of the 21 allocations in one steady-state local dispatch, and 752 of the
1995 bytes — 38% — are this:
The whole census, for context:
Why it happens
scheduleNext's drain-and-erase block decides "keep running vs.drain-and-erase" atomically under
_mapMtx+strand->mtx, and when thepending queue is empty it erases the map entry. That erase is correct and
load-bearing — the comment above it records the race that made two strands
for one key run concurrently — and it is also what makes the next
post()forthe same model take the
if (!slot)branch and build everything again. Serialdispatch never leaves a task queued at the moment the previous one finishes, so
it never keeps a strand.
Under concurrent load the strand stays armed and the cost amortises; this is a
serial-workload cost, which is also the shape a desktop GUI produces.
Verification status
platform.
backtraces place the allocations in
post()'sif (!slot)branch on everycounted call, which is only reachable when the previous call erased the slot,
but I did not instrument the erase itself.
malloc/free pair set, not necessarily a visible latency change; nothing here
claims one.
realistic ladder workload is serial.
Directions, none of them free
Strandin_strandsand let a laterpost()re-arm it. Cost: a per-model entry thatis never reclaimed until
deregisterModel, which is a leak of bounded sizefor a long-lived process with many short-lived models — and the erase is
currently what bounds it.
std::queue<std::function<void()>>'s dequeaccounts for 576 of the 752 bytes. A small-vector or an intrusive queue
would cut most of the cost without touching the lifetime rules at all, and
is the least invasive option.
Strandobjects. Keeps the erase semantics and reuses theallocation; more machinery than (2) for a similar saving.
Any change here must preserve the atomicity the drain-and-erase block
documents: the re-arm and the erase must stay one decision under
_mapMtx, ortwo strands for one key can run model tasks concurrently.
What would change the verdict
the intended workloads and the allocator churn is acceptable — 752 bytes of
short-lived churn per dispatch is headroom, not a defect.
bound, or if morph#572's dispatch-cost work goes ahead: this is the largest
single cluster in that census and the only one that needs no public interface
change.
Found while re-measuring morph#572; filed separately rather than folded in,
since #572's scope is
Bridge::executeVia's marshalling and theCompletionpair, not
StrandExecutor's lifetime policy.