# Async copy elimination — three-phase implementation plan Companion to [async_copy_analysis.md](async_copy_analysis.md). Baseline: ~19 + F full-payload copies and 4–6 SHA-256 passes per inbound relayed message (refc). Executor-grade plans (self-contained, one per phase): [Phase 1 — benchmark](plan_phase1_bench.md) · [Phase 2 — WakuMessage ref object](plan_phase2_wakumessage_ref.md) · [Phase 3 — WakuEnvelope](plan_phase3_wakuenvelope.md) Measured blast radius (grep, 2026-07-14): | Pattern | src (logos_delivery + library + apps) | tests | |---|---|---| | `WakuMessage` occurrences | ~420 in 88 files | 1,714 in 64 files | | `WakuMessage(...)` constructions | 27 | 1,324 (**1,174 via `fakeWakuMessage`** in `tests/testlib/wakucore.nim:74`) | | `message:/msg: WakuMessage` params | 88 | 100 | | `computeMessageHash(` call sites | 28 | 208 | | `WakuRelayHandler` references | 19 | 5 | | structural `==` on messages | 5 | ~107 | --- ## Phase 1 — End-to-end performance harness (before/after proof) Goal: reproducible numbers on master *before* any change; re-run after each phase. Deliverables: 1. `tests/benchmarks/bench_message_path.nim` — two scenarios: - **micro**: inject N deterministic messages (fixed seed; payload mix 10/50/150 kB) directly into the relay topicHandler/`uniqueTopicHandler` with archive + filter (F = 1, 5, 20 subscribed peers) + sync mounted; measures the full internal pipeline without network noise. - **macro**: two in-process nodes over loopback gossipsub, publish → receive, same payload mix; captures libp2p-side copies too. 2. Instrumentation counters under `-d:msgPathCounters` in `waku_core`: `computeMessageHash` invocations, `WakuMessage.decode` invocations, bytes hashed, bytes materialized by decode. (~30–40 LOC, zero cost when not defined.) 3. Metrics emitted per run (CSV/JSON to stdout): msg/s, ns/msg (p50/p99), `getOccupiedMem()` delta, refc `GC_getStatistics()`, counter totals. 4. `make benchmark` target + baseline results committed to `docs/analysis/bench_baseline.md`. | Item | New LOC | Changed LOC | |---|---|---| | bench harness (micro + macro) | ~280–380 | — | | counters in waku_core | ~30–40 | ~10 | | Makefile / nimble task | ~15 | — | | baseline results doc | ~40 | — | | **Total** | **~370–480** | **~10** | Verify: harness runs deterministically twice with <5 % variance; counters match the analysis predictions (≥4 decodes, 4–6 hashes per message) — this *validates the analysis* before we change anything. **Time: 1–1.5 days.** --- ## Phase 2 — `WakuMessage` becomes a `ref object` Goal: every assignment/capture/bind-out of a message becomes a pointer + RC op instead of a 3-seq deep copy — no signature changes anywhere. What actually changes (`logos_delivery/waku/waku_core/message/message.nim`): - `WakuMessage* = ref object` (construction syntax `WakuMessage(payload: ...)` is unchanged, so the 27 src + 1,324 test constructions compile as-is). - **Structural `==`** proc (~12 LOC) — otherwise ref-identity comparison breaks the ~107 test assertions. - `clone*(msg: WakuMessage): WakuMessage` helper (~10 LOC) for deliberate-mutation sites. - Known aliasing fix: relay publish timestamp patch ([waku_relay/protocol.nim:680-682](../../logos_delivery/waku/waku_relay/protocol.nim)) currently relies on value-copy semantics — must become `clone()` (or set the timestamp before entering publish). - Nil-safety audit: `var msg: WakuMessage` now defaults to `nil`, not an empty object. 88 src param sites reviewed; expected actual fixes small (decode already does `var msg = WakuMessage()`). - Aliasing audit: any post-construction mutation of a received message (queue_driver retention becomes a *shared* ref — that's the intended win, but document immutable-by-convention in message.nim). - Style-guide deviation note: AGENTS.md prescribes `Ref` suffix for ref object types; renaming to `WakuMessageRef` would touch ~2,100 occurrences for zero semantic value — keep the name, document the deviation at the type. | Item | New LOC | Changed LOC | |---|---|---| | message.nim (ref + `==` + clone + doc comment) | ~35 | ~10 | | relay publish mutation fix | — | ~10 | | nil/aliasing audit fixes across src (est.) | — | ~60–120 | | test fixes (nil defaults, identity assumptions; `==` covers the rest) | — | ~80–200 | | **Total** | **~35** | **~160–340** | Effect on copy budget (per message, from 19 + F): decode bind-outs −4, dispatch env captures −7, archive/filter wrap −2 → **~6 + F remaining** (the 4 redundant decode *materializations* and all hashing remain). Expected from Phase 1 harness: **~50–60 % reduction in alloc volume**, hashes unchanged. Memory-model note (refc/ORC): under refc, ref copy = pointer + RC increment (cheap, non-atomic); no cycles are introduced (WakuMessage has no back-references), so refc's cycle collector is not engaged. Under a future ORC build, same story with ORC RC. FFI (`library/`) already deep-copies fields via `copyMem` into JSON events — unaffected; ref gives a stable pointer, which is FFI-friendly. Verify: full `make test` green, benchmark re-run recorded, ASAN/refc build of the message-path tests clean (lifetimes now shared — this is the run that would catch a premature-free or unexpected-mutation bug). **Time: 2–3 days** (LOC is small; the cost is the aliasing audit + full-suite verification). --- ## Phase 3 — API break: `WakuEnvelope` (WakuMessage, PubsubTopic, msgHash) Goal: decode once, hash once, carry both through the whole internal dispatch as one ref. Design (`logos_delivery/waku/waku_core/message/envelope.nim`, new): ```nim type WakuEnvelope* = ref object msg*: WakuMessage pubsubTopic*: PubsubTopic hash*: WakuMessageHash proc init*(T: type WakuEnvelope, pubsubTopic: PubsubTopic, msg: WakuMessage): T = WakuEnvelope(msg: msg, pubsubTopic: pubsubTopic, hash: computeMessageHash(pubsubTopic, msg)) ``` Changes: 1. **Relay decode-once** (`waku_relay/protocol.nim`): ordered validator decodes and builds the envelope; `onRecv`/`onValidated`/`onSend` observers and the subscribe topicHandler consume the envelope instead of re-decoding (removes 3 decodes + validator-side hash). Mechanism: msgId-keyed handoff (small TimedCache) or restructured observer chain. 2. **`WakuRelayHandler`** becomes `proc(envelope: WakuEnvelope): Future[void]` — 19 src + 5 test references. 3. **Dispatch** (`node/subscription_manager.nim`): `uniqueTopicHandler` + 6 sub-handlers (trace/filter/archive/sync/internal/legacyApp) + `MessageSeenEvent` take the envelope. 4. **Consumers**: `archive.handleMessage`, `filter_v2.handleMessage`, sync ingress, lightpush→relay callbacks, REST/API + `library/` event emission — use `envelope.hash`, deleting ~10–14 of the 28 src `computeMessageHash` call sites. 5. **Filter per-peer buffer share** (folded in here): encode `MessagePush` once, pass `ref seq[byte]` (or raw-async `pushToPeer`) — removes the F per-peer buffer copies. | Item | New LOC | Changed LOC | |---|---|---| | envelope.nim + export | ~50 | — | | relay decode-once + observer restructure | ~60 | ~120–180 | | WakuRelayHandler + subscription_manager | — | ~90–130 | | archive / filter / sync / lightpush / events / library | — | ~150–250 | | filter shared push buffer | — | ~25 | | tests (relay, node, archive, filter, sync handler tests) | ~40 | ~250–400 | | **Total** | **~150** | **~640–990** | Effect on copy budget: **~6 + F → ~3 copies, 4–6 → 1 hash pass** (plus 1 encode + hash on the outbound leg). Combined with Phase 2 this is the full projected gain: ~75–80 % less copy volume, ~70–80 % less hashing → ~4× single-core throughput headroom at the 10/50/150 kB profile. Verify: full suite + benchmark re-run vs Phase-1 baseline and Phase-2 checkpoint; counter assertion in the micro bench: exactly 1 decode + 1 hash per inbound message. **Time: 4–6 days.** --- ## Summary | Phase | New LOC | Changed LOC | Time | Copies (of 19 + F) | Hashes (of 4–6) | |---|---|---|---|---|---| | 1 — perf harness | ~370–480 | ~10 | 1–1.5 d | — (measures baseline) | — | | 2 — WakuMessage ref | ~35 | ~160–340 | 2–3 d | → ~6 + F | 4–6 (unchanged) | | 3 — WakuEnvelope | ~150 | ~640–990 | 4–6 d | → ~3 | → 1 | | **Total** | **~555–665** | **~810–1,340** | **7–10.5 d (~2 wks)** | **−84 %** | **−80 %** | Risks to watch: - Phase 2 aliasing: any code that mutated its by-value copy now mutates a shared message — the relay timestamp patch is the one known site; the ASAN run + full suite is the net. - Phase 3 relay observer restructure is the only genuinely fiddly part (gossipsub gives no validator→handler side channel); the msgId cache must be bounded (TimedCache) and eviction-safe. - Test LOC ranges are dominated by handler-signature churn in Phase 3; the 1,174 `fakeWakuMessage` call sites are untouched in all phases.