Skip to content

core: a call pays the remote path's cost whether or not it takes it — 19 allocations, two CompletionStates, two strings per dispatch #572

Description

@Yaraslaut

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreSubsystem: coreenhancementNew feature or requesttriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions