What is left after morph#660
morph#660 listed three directions and this took the second — shrink what the
drain-and-erase rebuilds. Measured on 7a343e6f, x86-64 Linux, GCC 16.2.1 /
libstdc++, -O2, with tests/bench/bench_dispatch_allocations.cpp:
before 4159 allocations / 398072 bytes over 200 round trips (20.80 / 1990.4 per call)
#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
after 3786 allocations / 279208 bytes over 200 round trips (18.93 / 1396.0 per call)
#7 32 bytes _strands map node
#8 120 bytes make_shared<Strand>
152 bytes and 2 allocations per serial local dispatch remain, about 11% of
the 1396 bytes a round trip now costs. They are the unordered_map node and the
Strand control block, and no container change removes them: they exist because
scheduleNext erases the map entry the moment the strand drains, so the next
post() for the same model takes the if (!slot) branch and builds both again.
Why it was not done in morph#660
Keeping the slot alive across the drain is direction 1 of morph#660's three, and
it is not a container swap — it is a change of lifetime policy. StrandExecutor
has no deregistration hook: the erase is the only thing that ever removes a
key, so a retained slot is retained until ~StrandExecutor. A process that
registers and drops many short-lived models would grow _strands without bound.
Making that safe means adding a forget(ModelId)-shaped API and wiring it into
LocalBackend/RemoteServer model deregistration, and doing it without
disturbing the {_mapMtx, strand->mtx} atomicity that
docs/spec/core/executor.md ("The per-key serialisation invariant") and the
comments at strand.hpp:81/:167 record a previous defect forcing into shape.
That is a different, larger and riskier change than the one morph#660 closed
with, so it was deliberately not smuggled in.
Verification status
- Reproduced: both figures above are runs on this workstation, before and
after the morph#660 change, same binary, same flags. The four attributions in
the "before" block come from a backtrace() added to the bench's counting
hook, resolved through dladdr + addr2line -f -i -C — real output, not a
reading of the code.
- Inferred, not measured: that the two remaining allocations are
unavoidable without a lifetime change. The reasoning is the erase, which is
visible in the code; no alternative design was prototyped and measured.
- Not measured: the time cost of either the current state or a fix. Nothing
here claims a latency change; 152 bytes of short-lived churn per dispatch may
be entirely acceptable.
- Not measured: how much of a realistic workload is serial. Under concurrent
load the strand stays armed and this cost amortises away completely.
- Not verified: the figures on libc++, MSVC or the WASM toolchain.
What would change the verdict
- Close as invalid if a measurement shows the remaining 152 bytes are noise
for every workload that matters — which is the likely outcome, and is why this
is filed rather than worked. 11% of an allocation census is not by itself a
reason to change a lifetime rule.
- Raise in priority if a serial GUI workload is demonstrated to be
allocation-bound, or if a deregisterModel hook lands on StrandExecutor for
an unrelated reason, at which point the retained-slot design costs much less.
Filed as the remainder of morph#660 rather than reopening it: morph#660's own
"Directions" section named three, one was taken, and the other two are a
different change.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
What is left after morph#660
morph#660 listed three directions and this took the second — shrink what the
drain-and-erase rebuilds. Measured on
7a343e6f, x86-64 Linux, GCC 16.2.1 /libstdc++,
-O2, withtests/bench/bench_dispatch_allocations.cpp:152 bytes and 2 allocations per serial local dispatch remain, about 11% of
the 1396 bytes a round trip now costs. They are the
unordered_mapnode and theStrandcontrol block, and no container change removes them: they exist becausescheduleNexterases the map entry the moment the strand drains, so the nextpost()for the same model takes theif (!slot)branch and builds both again.Why it was not done in morph#660
Keeping the slot alive across the drain is direction 1 of morph#660's three, and
it is not a container swap — it is a change of lifetime policy.
StrandExecutorhas no deregistration hook: the erase is the only thing that ever removes a
key, so a retained slot is retained until
~StrandExecutor. A process thatregisters and drops many short-lived models would grow
_strandswithout bound.Making that safe means adding a
forget(ModelId)-shaped API and wiring it intoLocalBackend/RemoteServermodel deregistration, and doing it withoutdisturbing the
{_mapMtx, strand->mtx}atomicity thatdocs/spec/core/executor.md("The per-key serialisation invariant") and thecomments at
strand.hpp:81/:167record a previous defect forcing into shape.That is a different, larger and riskier change than the one morph#660 closed
with, so it was deliberately not smuggled in.
Verification status
after the morph#660 change, same binary, same flags. The four attributions in
the "before" block come from a
backtrace()added to the bench's countinghook, resolved through
dladdr+addr2line -f -i -C— real output, not areading of the code.
unavoidable without a lifetime change. The reasoning is the erase, which is
visible in the code; no alternative design was prototyped and measured.
here claims a latency change; 152 bytes of short-lived churn per dispatch may
be entirely acceptable.
load the strand stays armed and this cost amortises away completely.
What would change the verdict
for every workload that matters — which is the likely outcome, and is why this
is filed rather than worked. 11% of an allocation census is not by itself a
reason to change a lifetime rule.
allocation-bound, or if a
deregisterModelhook lands onStrandExecutorforan unrelated reason, at which point the retained-slot design costs much less.
Filed as the remainder of morph#660 rather than reopening it: morph#660's own
"Directions" section named three, one was taken, and the other two are a
different change.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW