Consolidates #526 (F8), #527 (F9) and #529 (F11) from the #518 sweep. Those three are closed in favour of this one; each is reproduced here in full, with its own verification status preserved.
They are merged because they share a fix site and a single measurement. #527 says so itself: "Raise it together with F8; they share a fix site and should be measured together." #529 is the same budget on the server-side path.
The finding
A call through morph pays for the remote path whether or not it takes it. Three distinct mechanisms:
Part A — the marshalling apparatus is built unconditionally (was #526, measured)
Bridge::executeVia constructs serializeAction and deserializeResult on every call (bridge.hpp:1466-1469) including when the installed backend is LocalBackend and will never use them; the two type-ids are std::string copies of compile-time constants (bridge.hpp:1463-1464); the action is make_shared'd even when it is a 4-byte POD (:1465).
Measured. Revision: master @ 4017228d. Global operator new counter, g++ 15 -std=c++23 -O2 -DNDEBUG, struct Ping { int x; } -> struct Pong { int y; } through LocalBackend — no JSON, no socket, just an integer doubling. 50 warm-up calls excluded.
local execute round-trips : 200
heap allocations total : 3,837 (19.2 per call)
bytes allocated total : 396,544 (1,983 per call)
caller-side execute()+then(): 12.8 allocations per call
throughput : 20,000 calls in 0.031s
654,528 calls/s (1.5 us/call)
Attribution, by reading bridge.hpp:1462-1470 and backend.hpp:798-886: 2 strings (call.modelTypeId, call.actionTypeId); 3 std::functions, each capturing a shared_ptr — not trivially copyable, so libstdc++ cannot use its small-buffer path and heap-allocates; 1 make_shared<Action>; 2 make_shared<CompletionState> (Part B); plus the strand task's std::function, a possible make_shared<Strand>, map nodes, and the GUI-side dispatch closure.
Not verified: the per-source attribution is by reading, not by instrumenting each allocation site. The totals are measured.
Part B — two CompletionStates per call (was #527, inferred from reading the code)
The backend produces a Completion<std::shared_ptr<void>> (backend.hpp:801-802); executeVia attaches a forwarding .then/.onError pair that static_casts to R* and settles a second, typed state (bridge.hpp:1416-1417, :1571-1620). Every call allocates two mutexes, two handler vectors and two control blocks, and hops the callback executor twice.
Not reproduced. Revision: master @ 4017228d. The two make_shared<CompletionState> calls are part of Part A's measured 19.2 allocations, but their individual cost was not isolated.
Part C — two string allocations per server-side dispatch (was #529, inferred from reading the code)
ActionDispatcher::dispatch takes two string_views and materialises both into a std::string pair purely to perform a hash lookup (registry.hpp:560-568):
std::string dispatch(std::string_view modelId, std::string_view actionId, ...) {
Key const key{std::string{modelId}, std::string{actionId}}; // 2 allocations
auto iter = _runners.find(key);
The same pattern repeats in coalesce (:577), schemaFor (:596) and requiredFieldsFor (:664). ActionDispatcher also keeps four parallel maps on the same key (registry.hpp:687-690).
Not reproduced. Two std::string constructions from string_view allocate whenever the id exceeds the SSO buffer; morph's own ids ("EchoModel", "GetAccount") are typically within SSO, so the real cost may be lower than the worst case. It was not measured, and whether typical ladder ids exceed the 15-byte libstdc++ SSO threshold was not checked ("CreateSwimlane" is 14).
Suggested direction
Parts A and B together. All three of A's operations are stateless given a pointer to the action. Replace the three std::functions with a pointer to a static per-(Model, Action) vtable of free functions:
struct ActionOps {
std::string (*serialize)(const void*);
std::shared_ptr<void> (*deserializeResult)(std::string_view);
std::shared_ptr<void> (*invokeLocal)(IModelHolder&, const void*);
std::string_view modelTypeId; // no allocation
std::string_view actionTypeId;
};
template <class M, class A> inline constexpr ActionOps kOps{...};
ActionCall then holds const ActionOps* plus one type-erased action pointer: 5 allocations removed and the two string copies gone.
For B, the type erasure is needed because IBackend::execute is a non-template virtual — but it could live in the result rather than in the completion. Give IBackend::execute a void (*settle)(void* state, std::shared_ptr<void>) plus the typed state pointer, so the backend settles the caller's state directly. One state, one executor hop, one cast — and the forwarding .then at bridge.hpp:1571 disappears. What must be preserved: that forwarding block also cancels the client deadline, decrements _pendingCalls, and calls publishResult. Those need a home in the new shape.
With A and B folded together, a realistic target is 2-3 allocations per call against the measured 19.2.
Part C. Add a transparent hash and equality (is_transparent) so a std::pair<std::string_view, std::string_view> looks up with no allocation, and merge the four parallel maps into one unordered_map<Key, ActionEntry> so four lookups become one.
Coordination
This overlaps #567-#571, which is reshaping IBackend's registration surface and will delete its four *Async verbs. Part B changes IBackend::execute's signature. Sequence behind that set, or agree the split with whoever holds it — do not land a competing IBackend change.
Also recorded while here (from #529, not in scope)
ActionDispatcher and ModelRegistryFactory are process-wide singletons with no mutex (registry.hpp:687-690, :784). Registration is static-init-only in practice, which makes that safe today, but nothing enforces it — a dlopen'd plugin registering after threads start is a data race on the maps. Relatedly, registerActionOnce/registerModelOnce are declared noexcept (registry.hpp:800, :807) while allocating four map nodes and two strings, so bad_alloc at static init is std::terminate. Worth a documented precondition at minimum; file separately rather than folding in.
What would change the verdict
- Close A/B if 1.5 us and 2 KB per call are acceptable for the intended workloads — which for a desktop GUI they may well be. This is headroom, not a crisis. It is sharper on the server path, where the same
executeVia shape runs per request.
- Close C if a measurement shows the ids are SSO-sized in practice and the lookup is not hot enough to matter. Raise it by profiling
RemoteServer::handle under load and attributing time to find.
- Any fix must land against a recorded baseline.
tests/bench/bench_dispatch_latency.json is produced but never diffed — an allocation-budget benchmark that is never compared is exactly the "control that measures nothing" failure AGENTS.md warns about. The acceptance criterion for this issue is a benchmark that fails when the budget regresses; prove it by reverting the fix and watching it fail.
Consolidates #526 (F8), #527 (F9) and #529 (F11) from the #518 sweep. Those three are closed in favour of this one; each is reproduced here in full, with its own verification status preserved.
They are merged because they share a fix site and a single measurement. #527 says so itself: "Raise it together with F8; they share a fix site and should be measured together." #529 is the same budget on the server-side path.
The finding
A call through morph pays for the remote path whether or not it takes it. Three distinct mechanisms:
Part A — the marshalling apparatus is built unconditionally (was #526, measured)
Bridge::executeViaconstructsserializeActionanddeserializeResulton every call (bridge.hpp:1466-1469) including when the installed backend isLocalBackendand will never use them; the two type-ids arestd::stringcopies of compile-time constants (bridge.hpp:1463-1464); the action ismake_shared'd even when it is a 4-byte POD (:1465).Measured. Revision:
master@4017228d. Globaloperator newcounter,g++ 15 -std=c++23 -O2 -DNDEBUG,struct Ping { int x; }->struct Pong { int y; }throughLocalBackend— no JSON, no socket, just an integer doubling. 50 warm-up calls excluded.Attribution, by reading
bridge.hpp:1462-1470andbackend.hpp:798-886: 2 strings (call.modelTypeId,call.actionTypeId); 3std::functions, each capturing ashared_ptr— not trivially copyable, so libstdc++ cannot use its small-buffer path and heap-allocates; 1make_shared<Action>; 2make_shared<CompletionState>(Part B); plus the strand task'sstd::function, a possiblemake_shared<Strand>, map nodes, and the GUI-side dispatch closure.Not verified: the per-source attribution is by reading, not by instrumenting each allocation site. The totals are measured.
Part B — two CompletionStates per call (was #527, inferred from reading the code)
The backend produces a
Completion<std::shared_ptr<void>>(backend.hpp:801-802);executeViaattaches a forwarding.then/.onErrorpair thatstatic_casts toR*and settles a second, typed state (bridge.hpp:1416-1417,:1571-1620). Every call allocates two mutexes, two handler vectors and two control blocks, and hops the callback executor twice.Not reproduced. Revision:
master@4017228d. The twomake_shared<CompletionState>calls are part of Part A's measured 19.2 allocations, but their individual cost was not isolated.Part C — two string allocations per server-side dispatch (was #529, inferred from reading the code)
ActionDispatcher::dispatchtakes twostring_views and materialises both into astd::stringpair purely to perform a hash lookup (registry.hpp:560-568):The same pattern repeats in
coalesce(:577),schemaFor(:596) andrequiredFieldsFor(:664).ActionDispatcheralso keeps four parallel maps on the same key (registry.hpp:687-690).Not reproduced. Two
std::stringconstructions fromstring_viewallocate whenever the id exceeds the SSO buffer; morph's own ids ("EchoModel", "GetAccount") are typically within SSO, so the real cost may be lower than the worst case. It was not measured, and whether typical ladder ids exceed the 15-byte libstdc++ SSO threshold was not checked ("CreateSwimlane"is 14).Suggested direction
Parts A and B together. All three of A's operations are stateless given a pointer to the action. Replace the three
std::functions with a pointer to a static per-(Model, Action)vtable of free functions:ActionCallthen holdsconst ActionOps*plus one type-erased action pointer: 5 allocations removed and the two string copies gone.For B, the type erasure is needed because
IBackend::executeis a non-template virtual — but it could live in the result rather than in the completion. GiveIBackend::executeavoid (*settle)(void* state, std::shared_ptr<void>)plus the typed state pointer, so the backend settles the caller's state directly. One state, one executor hop, one cast — and the forwarding.thenatbridge.hpp:1571disappears. What must be preserved: that forwarding block also cancels the client deadline, decrements_pendingCalls, and callspublishResult. Those need a home in the new shape.With A and B folded together, a realistic target is 2-3 allocations per call against the measured 19.2.
Part C. Add a transparent hash and equality (
is_transparent) so astd::pair<std::string_view, std::string_view>looks up with no allocation, and merge the four parallel maps into oneunordered_map<Key, ActionEntry>so four lookups become one.Coordination
This overlaps #567-#571, which is reshaping
IBackend's registration surface and will delete its four*Asyncverbs. Part B changesIBackend::execute's signature. Sequence behind that set, or agree the split with whoever holds it — do not land a competingIBackendchange.Also recorded while here (from #529, not in scope)
ActionDispatcherandModelRegistryFactoryare process-wide singletons with no mutex (registry.hpp:687-690,:784). Registration is static-init-only in practice, which makes that safe today, but nothing enforces it — adlopen'd plugin registering after threads start is a data race on the maps. Relatedly,registerActionOnce/registerModelOnceare declarednoexcept(registry.hpp:800,:807) while allocating four map nodes and two strings, sobad_allocat static init isstd::terminate. Worth a documented precondition at minimum; file separately rather than folding in.What would change the verdict
executeViashape runs per request.RemoteServer::handleunder load and attributing time tofind.tests/bench/bench_dispatch_latency.jsonis produced but never diffed — an allocation-budget benchmark that is never compared is exactly the "control that measures nothing" failure AGENTS.md warns about. The acceptance criterion for this issue is a benchmark that fails when the budget regresses; prove it by reverting the fix and watching it fail.