logos-delivery/docs/analysis/async_copy_fix_plan.md

181 lines
8.8 KiB
Markdown
Raw Permalink Normal View History

# Async copy elimination — three-phase implementation plan
Companion to [async_copy_analysis.md](async_copy_analysis.md). Baseline: ~19 + F
full-payload copies and 46 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. (~3040 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) | ~280380 | — |
| counters in waku_core | ~3040 | ~10 |
| Makefile / nimble task | ~15 | — |
| baseline results doc | ~40 | — |
| **Total** | **~370480** | **~10** |
Verify: harness runs deterministically twice with <5 % variance; counters match the
analysis predictions (≥4 decodes, 46 hashes per message) — this *validates the analysis*
before we change anything.
**Time: 11.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.) | — | ~60120 |
| test fixes (nil defaults, identity assumptions; `==` covers the rest) | — | ~80200 |
| **Total** | **~35** | **~160340** |
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: **~5060 %
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: 23 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 ~1014 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 | ~120180 |
| WakuRelayHandler + subscription_manager | — | ~90130 |
| archive / filter / sync / lightpush / events / library | — | ~150250 |
| filter shared push buffer | — | ~25 |
| tests (relay, node, archive, filter, sync handler tests) | ~40 | ~250400 |
| **Total** | **~150** | **~640990** |
Effect on copy budget: **~6 + F → ~3 copies, 46 → 1 hash pass** (plus 1 encode + hash on
the outbound leg). Combined with Phase 2 this is the full projected gain: ~7580 % less
copy volume, ~7080 % 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: 46 days.**
---
## Summary
| Phase | New LOC | Changed LOC | Time | Copies (of 19 + F) | Hashes (of 46) |
|---|---|---|---|---|---|
| 1 — perf harness | ~370480 | ~10 | 11.5 d | — (measures baseline) | — |
| 2 — WakuMessage ref | ~35 | ~160340 | 23 d | → ~6 + F | 46 (unchanged) |
| 3 — WakuEnvelope | ~150 | ~640990 | 46 d | → ~3 | → 1 |
| **Total** | **~555665** | **~8101,340** | **710.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.