docs: async copy analysis and 3-phase elimination plan

Analysis of chronos/refc deep-copy behavior on the WakuMessage path
(~19+F payload copies, 4-6 SHA-256 passes per inbound relayed message)
plus executor plans for: perf harness, WakuMessage ref object, WakuEnvelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
NagyZoltanPeter 2026-07-15 04:07:39 +02:00
parent 77cb8a6c7a
commit 05ac82d454
No known key found for this signature in database
GPG Key ID: 3E1F97CF4A7B6F42
5 changed files with 826 additions and 0 deletions

View File

@ -0,0 +1,229 @@
# Async deep-copy analysis — chronos, Option/Opt/Result, WakuMessage path
Environment: Nim 2.2.4, `--mm:refc`, chronos 4.2.2 (`nimbledeps/pkgs2/chronos-4.2.2-*`),
nim-results 0.5.1, nim-libp2p 2.0.0. All findings verified against these sources.
Key refc facts driving everything below:
- Assignment of `string`/`seq`/object-with-seqs = deep copy (`copyString`/`genericSeqAssign`). No `=sink` hooks.
- `system.move()` degrades to *copy + reset* under refc (move semantics require ARC/ORC).
- `lent` returns work under refc (true borrow, no copy at return) — the copy materializes when the borrow is bound to a `let`/`var` or passed by value.
---
## Table 1 — Where chronos generates deep copies
| # | Copy point | Mechanism (evidence) | refc | ORC |
|---|---|---|---|---|
| 1 | **Every `{.async.}` call: each seq/string/object param** | async transform moves body into a closure iterator (`chronos/internal/asyncmacro.nim:445-517`); params are lambda-lifted into the env object → `env.param = param` = `genericSeqAssign` | **1 deep copy per param per call** | move/cursor — free |
| 2 | **`complete(future, val)`** | `val: T` is **not `sink`** (`asyncfutures.nim:198-202`); `future.internalValue = val`. Macro calls `complete(fut, move(result))` (`asyncmacro.nim:160`) but `move` is a copy under refc | **12 deep copies per completion** | 1 copy (non-sink param survives ORC) |
| 3 | **Consuming the result: `let x = await f(...)`** | `Future.value()` returns `lent T` (`futures.nim:231-241`) — free at return, but binding the borrow to `x` copies | **1 deep copy per await** | 1 copy (can't move out of a borrow) |
| 4 | Callbacks / `futureContinue` | future passed by ref, callbacks `move`d (`asyncfutures.nim:187`) | none | none |
| 5 | `{.async: (raw: true).}` procs | no closure-iterator transform → no param capture | none | none |
Net: `let x = await f(a, b)` where `a`, `b`, and the result are seq-heavy costs
**~2 copies of each argument-side seq + 23 copies of the result** under refc.
Rows 23 are chronos API design (non-`sink` complete, `lent` read) and persist under ORC;
row 1 is refc-specific.
libp2p is aware of row 1: `pubsub.nim:415-427` uses `{.async: (raw: true).}` for `handleData`
"without copying data into closure" — but every downstream `TopicHandler` is a normal
`{.async.}` taking `(topic: string, data: seq[byte])` by value, so the copy fires there,
once per registered handler.
---
## Table 2 — Option[T], Opt[T], Result[T, E] (results 0.5.1, `resultsLent=true` on Nim 2.2.4)
| Accessor | Kind / returns | Copies under refc? | Evidence (results.nim) |
|---|---|---|---|
| `ok(x)` / `err(x)` | template, field-construct | copy of `x` into Result (move only if rvalue construction elides) | :451-454, :471-474 |
| `value()`, `get()`, `tryGet()`, `expect()` | `lent T` (or template → lent) | none at return; **deep copy when bound to `let`** | :870-879, :1072, :1078, :952 |
| `value()`/`get()` on `var` Result | `var T` borrow | none | :881-889, :1092 |
| `unsafeGet()`/`unsafeValue()` | template, in-place field access | none in place; copy on bind | :902-905, :1084 |
| `get(otherwise)` | **func returning `T` by value** | **always copies** (plus `otherwise` passed by value) | :1095 |
| `valueOr:` | template: `let s = (self)` then yields field | copies whole Result if `self` is lvalue (rvalue temp is sunk); **value deep-copied on the outer `let` bind** | :1396-1432 |
| `isOkOr:` | template: `let s = (self)` | copies Result (upstream `# TODO avoid copy`) | ~:1355 |
| `?` operator | template: `let v = (self)` | copies Result; value copied on bind | :1533-1544 |
| `std/options some(sink T)` | proc, sink param | move if rvalue, copy if lvalue | options.nim:125 |
| `std/options get()` | `lent T` | none at return; copy on bind | options.nim:191 |
| `std/options get(otherwise)` | proc returning `T` | **always copies** | options.nim:206 |
Rule of thumb: the wrappers themselves are fine; the cost is **one full deep copy each time a
large value is bound out** (`let msg = decode(...).valueOr: ...` = copy), and `valueOr`/`isOkOr`/`?`
additionally copy the whole Result when applied to an lvalue.
---
## WakuMessage path — hop-by-hop copy count
`WakuMessage` ([message.nim:12-29](logos_delivery/waku/waku_core/message/message.nim)) is a plain
object with three heap seqs (`payload`, `meta`, `proof`) plus `contentTopic: string`
every deep copy touches ~4 heap allocations.
### A. Inbound relay — the same proto bytes are decoded ≥4×
| Hop | Site | Operation | Cost |
|---|---|---|---|
| A1 | [protocol.nim:543](logos_delivery/waku/waku_relay/protocol.nim:543) | `WakuMessage.decode(message.data)` in ordered validator | decode alloc + `valueOr` bind copy |
| A2 | [protocol.nim:271](logos_delivery/waku/waku_relay/protocol.nim:271) | decode again in `onRecv` observer | same |
| A3 | [protocol.nim:326](logos_delivery/waku/waku_relay/protocol.nim:326) | decode again in `onValidated` observer | same |
| A5 | [protocol.nim:603](logos_delivery/waku/waku_relay/protocol.nim:603) | decode again in subscribe topicHandler | same |
| (out) | [protocol.nim:341](logos_delivery/waku/waku_relay/protocol.nim:341) | decode in `onSend` observer (outbound leg) | same |
Each decode = fresh payload/meta/proof seqs (`codec.nim:23`) + `ok()` wrap + `valueOr` bind
**2 avoidable full-message copies per decode site** on top of the one unavoidable materialization.
### B. Node dispatch — 6+ async closure captures of the decoded message
`registerRelayHandler` ([subscription_manager.nim:29](logos_delivery/waku/node/subscription_manager.nim))
builds `uniqueTopicHandler` (:72, `{.async.}`, msg by value → **env capture copy #1**), which
sequentially awaits trace(:45), filter(:51), archive(:57), sync(:63), internal(:69),
legacyApp(:82) handlers — **each a `{.async.}` proc taking `msg: WakuMessage` by value →
6 more env-capture deep copies**, plus `MessageSeenEvent.emit(...msg)` (:70) broker copy.
### CF. Downstream
| Path | Sites | Copies |
|---|---|---|
| Archive | [archive.nim:98,101](logos_delivery/waku/waku_archive/archive.nim) | async capture + `computeMessageHash` (re-hash) + field copies into SQLite params; **queue_driver retains a full WakuMessage copy** in `SortedSet[Index, WakuMessage]` |
| Filter push | [protocol.nim:243-264,213,216,170](logos_delivery/waku/waku_filter_v2/protocol.nim) | async capture + re-hash ×2 + `MessagePush(wakuMessage: message)` wrap + one encode (good: **not** per-peer) + **per-peer buffer env-capture copy** in `pushToPeer` |
| Lightpush inbound | [protocol.nim:84,67](logos_delivery/waku/waku_lightpush/protocol.nim), callbacks.nim:16-24 | decode + hash + validateMessage re-encodes + re-hashes + relay publish re-encodes |
| Outbound publish | [relay/protocol.nim:674-690](logos_delivery/waku/waku_relay/protocol.nim:674) | explicit `var message = wakuMessage` copy (:680, for timestamp mutation) + encode + hash |
| FFI/JSON boundary | library/json_message_event.nim:72-95, relay_api.nim:115-121 | explicit `copyMem` of payload/meta/proof + base64 encode/decode string copies (inherent to the JSON API) |
### G. computeMessageHash — recomputed 46× per message
Call sites for the *same* inbound message: relay `:217`/validator, archive `:101`,
filter `:246` + `:195`, store-sync reconciliation `:78` — plus lightpush/API paths.
The hash itself doesn't copy (params feed `openArray` into sha256), but it's ~46 redundant
SHA-256 passes over the full payload per message.
### Copy budget (one inbound relayed message, archive + filter + sync active, F filter peers)
| Category | Full-payload copies |
|---|---|
| Redundant proto decodes (A) | ~8 (4 sites × ~2) |
| Dispatch env captures (B) | 7 |
| Archive + filter + push wrap/encode | ~4 + F (per filter peer) |
| **Total** | **~19 + F**, vs. theoretical minimum ≈ 3 (decode once, hash once, encode once) |
Plus 46 redundant SHA-256 passes. For a 1 MB payload that is ~20 MB of allocation +
memcpy + refc GC pressure per message.
---
## Recommendations (prioritized)
**Yes, this is worth fixing** — the relay hot path does ~6× more copying than necessary,
and it's all in per-message code.
1. **P1 — Decode once in WakuRelay** (`waku_relay/protocol.nim`). The validator (A1),
both observers (A2/A3), and the topicHandler (A5) each decode independently. Decode in
the ordered validator, cache `(msgId → decoded msg + hash)` in a small TimedCache (the
gossipsub validator→handler API gives no side channel), or restructure so observers
receive the already-decoded message. Removes ~6 full copies + 23 hash passes per message.
No public API change.
2. **P2 — Pass a `ref` envelope through internal dispatch.** Introduce e.g.
`MessageEnvelope = ref object` holding `msg: WakuMessage`, `hash: WakuMessageHash`,
`pubsubTopic`, created once after validation; change `WakuRelayHandler`,
`subscription_manager` handlers, `archive.handleMessage`, `filter.handleMessage`, and
sync ingress to take the ref. Under refc a ref copy is pointer + refcount — kills all
7 env-capture copies (Table 1 row 1) and all downstream re-hashing (compute once, carry
it). This is the single highest-leverage internal refactor. (Immutable-by-convention;
document that handlers must not mutate.)
3. **P3 — Filter `pushToPeer`: share the encoded buffer.** The buffer is already encoded
once, but the `{.async.}` by-value param copies it per peer
([protocol.nim:170,216](logos_delivery/waku/waku_filter_v2/protocol.nim:170)). Pass a
`ref seq[byte]` (or make `pushToPeer` `{.async: (raw: true).}`).
4. **P4 — Don't return large values from async procs.** Each such return costs 23 copies
(Table 1 rows 23) that even ORC won't remove. Return `ref T`, or fill a caller-provided
buffer. Applies to store query results (`seq[WakuMessage]`) and codec-heavy client calls.
5. **P5 — Result/Option idioms for large T.** Avoid `get(otherwise)` (always copies both
sides); prefer in-place access (`.value.field`, `var`-borrow `get`) where the value
isn't stored anyway. Low priority once P2 makes messages refs — Opt/Result over `ref` or
small types is free.
6. **P6 — Upstream / longer term.**
- chronos: make `complete()` take `sink T` (removes Table 1 row 2 under ORC; today it
copies under *every* mm).
- ORC migration removes row 1 (env captures become moves) — the biggest class of copies —
but rows 23 remain, so P2/P4 stay valuable regardless.
- The explicit `var message = wakuMessage` copy at publish
([protocol.nim:680](logos_delivery/waku/waku_relay/protocol.nim:680)) exists only to
patch the timestamp — set the timestamp before entering publish and take the param
immutably.
## Gains and effort estimate
Baseline per inbound relayed message (archive + filter + sync active, F filter peers):
**~19 + F full-payload copies, 46 SHA-256 passes.**
| Fix | Copies removed | Hash passes removed | Remaining after fix | Effort (dev-days) | Risk / blast radius |
|---|---|---|---|---|---|
| P1 decode-once in relay | ~6 (3 redundant decode sites × 2) | 23 | ~13 + F | 23 (incl. tests) | Contained to `waku_relay/protocol.nim`; needs msgId→decoded cache or observer restructure |
| P2 ref envelope through dispatch | ~9 (7 env captures + archive/filter captures) | 12 (hash carried) | ~4 + F | 35 | Internal API change: `WakuRelayHandler`, subscription_manager, archive, filter, sync + their tests |
| P3 shared filter buffer | F (per-peer buffer capture) | — | ~4 | 0.5 | One proc signature in `waku_filter_v2` |
| P4 no large async returns (store/query hot paths) | 23 per large-value await | — | — | 23 (hot paths only) | Store/query client APIs |
| P5 Result/Option idioms | marginal once P2 lands | — | — | opportunistic | none |
| P6 chronos `sink complete` / ORC | env-capture class (row 1) | — | — | weeks (upstream cycle) / months (ORC) | out of repo control |
**After P1+P2+P3: ~4 full-payload copies and 12 hash passes per message — a ~7580 %
reduction in per-message memcpy/alloc volume and ~70 % less hashing CPU.** The remaining ~4
are near the floor: proto→WakuMessage materialization, envelope fill, outbound encode, DB row.
Concrete scale for the target payload profile (min 10 kB / avg 50 kB / max 150 kB).
Assumptions: copies 20 → 4 per message (F≈1), hash passes 5 → 1.5; effective alloc+memcpy
throughput under refc ≈ 12 GB/s per core (allocation-bound, not memcpy-bound); SHA-256 via
nimcrypto (pure Nim, no SHA-NI) ≈ 300500 MB/s per core.
Per-message volume:
| Payload | Copied (baseline 20×) | Copied (after 4×) | Hashed (5×) | Hashed (1.5×) | Allocs/msg |
|---|---|---|---|---|---|
| 10 kB | 200 kB | 40 kB | 50 kB | 15 kB | ~6080 → ~1216 |
| 50 kB | 1.0 MB | 200 kB | 250 kB | 75 kB | " |
| 150 kB | 3.0 MB | 600 kB | 750 kB | 225 kB | " |
Sustained-rate CPU cost (share of one core spent purely on copy+hash overhead):
| Scenario | Baseline | After P1P3 |
|---|---|---|
| 50 kB @ 100 msg/s | ~1018 % | ~35 % |
| 50 kB @ 500 msg/s | ~5090 % (near saturation) | ~1322 % |
| 150 kB @ 100 msg/s | ~3055 % | ~814 % |
| 10 kB @ 1000 msg/s | ~2037 % | ~59 % |
Single-core throughput ceiling from overhead alone (copy+hash time per message):
| Payload | Baseline ceiling | After P1P3 | Headroom gain |
|---|---|---|---|
| 10 kB | ~2,7005,000 msg/s | ~11,00020,000 msg/s | ~4× |
| 50 kB | ~5501,000 msg/s | ~2,2004,000 msg/s | ~4× |
| 150 kB | ~180330 msg/s | ~7401,300 msg/s | ~4× |
GC pressure: heap churn equals the copied volume — at 50 kB × 100 msg/s the baseline churns
~100 MB/s through the refc heap (that much garbage accumulates between collection cycles);
after the fixes ~20 MB/s. Fewer, larger GC cycles → visibly lower tail latency. Per-message
transient footprint at 150 kB drops from ~3 MB to ~0.6 MB.
These are arithmetic projections, not measurements — the day-one benchmark harness validates
them (and nimcrypto's actual SHA-256 rate, which sets how much of the win comes from P2's
hash-once).
Suggested order: benchmark harness first (0.51 day: fixed-seed message generator through
`uniqueTopicHandler`, measure allocs via `getOccupiedMem`/instrumented `computeMessageHash`
counter) → P3 → P1 → P2 → re-benchmark. Total ≈ **1.52 calendar weeks** for P1P3 including
tests and verification; P4 as follow-up when touching store paths.
### Memory-model note (refc vs ORC)
| Copy class | refc | ORC | Fix |
|---|---|---|---|
| async param → closure env | deep copy | elided (move/cursor) | P2 (ref) now; ORC later |
| `complete(fut, val)` | 12 copies (`move` degrades) | 1 copy (non-sink) | P4 / upstream sink |
| `await` result bind | copy | copy (lent borrow) | P4 |
| `valueOr`/`?`/bind-out of Result | copy | usually moved (rvalue) | P5 |

View File

@ -0,0 +1,180 @@
# 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.

View File

@ -0,0 +1,125 @@
# Phase 1 — Message-path performance benchmark (executor plan)
> Self-contained implementation plan. Context: [async_copy_analysis.md](async_copy_analysis.md)
> and [async_copy_fix_plan.md](async_copy_fix_plan.md). Read both before starting.
>
> **Objective**: a reproducible before/after benchmark of the WakuMessage inbound/outbound
> pipeline, plus decode/hash counters that verify the analysis claims (≥4 decodes and 46
> SHA-256 passes per inbound relayed message on the current code). This phase changes **no
> production behavior** — counters must compile to nothing without the define.
## Constraints (apply to all phases)
- Nim 2.2.4, `--mm:refc` (enforced by nimble tasks). Build via `make` / nimble tasks only.
- Format all touched files with nph (`make nph/<file>` or the pre-commit hook).
- Run `gitnexus_impact` before editing cross-module symbols and `gitnexus_detect_changes()`
before committing (project rule in AGENTS.md). Commit at the end of the phase; **never push**.
- Verify claims by running things, not by reasoning ("should work" is not done).
## Existing infrastructure to reuse
- `apps/benchmarks/benchmarks.nim` — existing RLN benchmark; nimble task at
`logos_delivery.nimble:391-393` (`task benchmarks``buildBinary name, "apps/benchmarks/"`).
Mirror this pattern; note it already imports from `tests/` (`tests/waku_rln_relay/utils_onchain`),
so importing `tests/testlib/*` from `apps/benchmarks/` is accepted practice.
- `tests/testlib/wakucore.nim``fakeWakuMessage*` (line ~74) for deterministic messages.
- `tests/testlib/wakunode.nim` (verify exact name) — node construction helpers used by all
node tests; use these for the macro scenario.
## Step 1 — Instrumentation counters (`-d:msgPathCounters`)
New file `logos_delivery/waku/waku_core/message/path_counters.nim` (~35 LOC):
```nim
when defined(msgPathCounters):
type MsgPathCounters* = object
decodeCalls*, hashCalls*: int64
decodedBytes*, hashedBytes*: int64
var msgPathCounters* {.global.}: MsgPathCounters
template countDecode*(bytes: int) = ... # inc calls, add bytes
template countHash*(bytes: int) = ...
proc resetMsgPathCounters*() = ...
else:
template countDecode*(bytes: int) = discard
template countHash*(bytes: int) = discard
```
Hook points (one line each, guarded template — zero cost when undefined):
- `logos_delivery/waku/waku_core/message/codec.nim:23` — inside
`proc decode*(T: type WakuMessage, buffer: seq[byte])`: `countDecode(buffer.len)`.
- `logos_delivery/waku/waku_core/message/digest.nim:53` — inside `computeMessageHash`:
`countHash(msg.payload.len + msg.meta.len)`.
Export via `waku_core` module as needed. Single-threaded chronos — plain int64, no atomics.
## Step 2 — Micro benchmark (in-process pipeline, no network)
New file `apps/benchmarks/message_path_bench.nim` (~250320 LOC total with Step 3).
Setup: one WakuNode with relay mounted + archive (SQLite **in-memory** driver) + store-sync
(reconciliation) mounted; subscribe one shard so `registerRelayHandler`
(`logos_delivery/waku/node/subscription_manager.nim:29`) installs the full
`uniqueTopicHandler` chain (trace/filter/archive/sync/internal handlers, :45-82).
Injection: per message, replicate the gossipsub inbound sequence using WakuRelay's own
registered artifacts (both are stored in public tables on `WakuRelay`,
`waku_relay/protocol.nim:624,632`):
1. `let data = msg.encode().buffer` (prepared up front, excluded from timing)
2. `discard await relay.topicValidator[shard](shard, Message(topic: shard, data: data))`
— exercises the ordered-validator decode (`protocol.nim:543`)
3. `await relay.topicHandlers[shard](shard, data)` — exercises topicHandler decode
(`protocol.nim:603`) + full dispatch chain
Note in the output that observer decodes (`protocol.nim:271,326,341`) are NOT exercised by
the micro bench (they fire only in real gossipsub dispatch) — the macro scenario covers them.
If `topicValidator`/`topicHandlers` turn out not to be exported, add a
`when defined(msgPathCounters)` accessor to `waku_relay/protocol.nim` rather than exporting
the fields unconditionally.
Workload: fixed-seed generator (xorshift or `std/random` with `randomize(42)` equivalent —
NO time-based seed), payload mix **10 kB / 50 kB / 150 kB at 25 % / 50 % / 25 %**, unique
timestamps/nonce per message (avoid dedup), N = 1,000 messages + 100 warmup (excluded).
## Step 3 — Macro benchmark (two nodes, loopback gossipsub)
Same file, second scenario: two in-process nodes (testlib helpers), connected, both
subscribed to the shard; node B additionally has archive + sync mounted. Node A publishes
the same workload; node B counts arrivals via an app handler; stop timing when all N
received (with a sane timeout). This path includes libp2p rpcHandler + observers, so
counters here should show the full ≥4 decodes/message.
## Step 4 — Metrics & output
Per scenario emit one human block + one machine-readable CSV line to stdout:
```
scenario,msgs,payload_profile,wall_ms,msg_per_s,ns_per_msg_p50,ns_per_msg_p99,
decodes_per_msg,hashes_per_msg,hashed_MB,decoded_MB,occupied_mem_delta_MB,gc_collections
```
Sources: `getMonoTime` deltas per message for p50/p99; `getOccupiedMem()` before/after;
refc `GC_getStatistics()` (print raw string); counters from Step 1 (reset between scenarios).
## Step 5 — Build task + baseline capture
- Add nimble task `benchMessagePath` to `logos_delivery.nimble` mirroring the existing
`benchmarks` task (:391-393), with `-d:msgPathCounters` added to its flags.
- Run it on the **current branch before Phases 2/3**; save both scenario outputs verbatim
into `docs/analysis/bench_baseline.md` with the commit hash and machine info
(`sysctl -n machdep.cpu.brand_string`, macOS version).
## Acceptance criteria
1. `make test` still fully green (production code untouched except no-op templates).
2. A production build **without** `-d:msgPathCounters` compiles and the counter templates
vanish (verify: `nim c` of wakunode2 target still succeeds).
3. Two consecutive micro runs differ by < 5 % on msg/s (determinism check).
4. Macro scenario counters confirm the analysis: `decodes_per_msg ≥ 4`,
`hashes_per_msg` in 46 on the receiving node. **If not, STOP and report the measured
numbers before proceeding to Phase 2 — the analysis (and the expected gains) must be
recalibrated first.**
5. `docs/analysis/bench_baseline.md` committed with the numbers.
## Estimated diff
~370480 new LOC, ~10 changed LOC, no behavior change. Budget: 11.5 days.

View File

@ -0,0 +1,127 @@
# Phase 2 — `WakuMessage` becomes a `ref object` (executor plan)
> Self-contained implementation plan. Context: [async_copy_analysis.md](async_copy_analysis.md),
> [async_copy_fix_plan.md](async_copy_fix_plan.md). Prerequisite: Phase 1 benchmark exists
> and `docs/analysis/bench_baseline.md` is recorded.
>
> **Objective**: change `WakuMessage` from a value `object` to a `ref object` so every
> assignment, async-closure capture, and Result/Option bind-out of a message costs a
> pointer + refcount instead of deep-copying 3 seqs + a string. No proc signatures change.
> Expected result (verify with Phase 1 bench): ~5060 % reduction in alloc volume per
> message; decode/hash counters unchanged.
>
> **The danger of this phase is not the diff size — it is aliasing.** Code written under
> value semantics may mutate "its copy" of a message; under ref semantics that mutation is
> now shared with every other holder. Every mutation site must be found and either cloned
> or proven to own a freshly constructed message.
## Constraints
Same as Phase 1 (refc, nph, gitnexus impact/detect_changes, no push, verify by running).
Additionally: run `gitnexus_impact({target: "WakuMessage", direction: "upstream"})` first
and report the blast radius before editing.
## Step 1 — The type change (`logos_delivery/waku/waku_core/message/message.nim`)
Current (lines 1229): `type WakuMessage* = object` with `payload: seq[byte]`,
`contentTopic: ContentTopic`, `meta: seq[byte]`, `version: uint32`,
`timestamp: Timestamp`, `ephemeral: bool`, `proof: seq[byte]`.
1. `type WakuMessage* = ref object` — construction syntax `WakuMessage(payload: ...)` is
unchanged, so the 27 src + ~1,324 test constructions (1,174 via `fakeWakuMessage`,
`tests/testlib/wakucore.nim:74`) compile as-is.
2. Add a **structural `==`** (~12 LOC): both-nil → true, one-nil → false, else field-wise
compare. Without this, ~107 test assertions comparing messages break on ref identity.
3. Add `proc clone*(msg: WakuMessage): WakuMessage` — field-wise copy into a fresh ref
(deep-copies the seqs; that is the point: cloning is now *explicit and rare*).
4. **Fix `ensureTimestampSet` (lines 3134).** Current body `result = message;
result.timestamp = ...` aliases the input under ref semantics and mutates the *shared*
message. Rewrite non-mutating:
```nim
proc ensureTimestampSet*(message: WakuMessage): WakuMessage =
if message.timestamp != 0:
return message
var m = message.clone()
m.timestamp = getNowInNanosecondTime()
m
```
5. Doc comment at the type: messages are **immutable by convention after construction**;
mutate only freshly constructed or explicitly `clone()`d instances. Also note the
deliberate deviation from the AGENTS.md `Ref`-suffix rule (renaming ~2,100 occurrences
has no semantic value).
## Step 2 — Known aliasing sites (fix, don't just audit)
1. **Relay publish** `logos_delivery/waku/waku_relay/protocol.nim:680-682`:
```nim
var message = wakuMessage
if message.timestamp == 0:
message.timestamp = getNowInNanosecondTime()
```
Under ref this mutates the caller's message. Replace with
`let message = wakuMessage.ensureTimestampSet()` (safe after Step 1.4).
2. **RLN proof attachment** — search `logos_delivery/waku/waku_rln_relay/` for procs taking
`msg: var WakuMessage` or assigning `msg.proof = ` (e.g. `appendRLNProof`). These run on
the *outbound* path on a message the API caller still holds. Decision rule: if the
message came in through a public API, `clone()` before mutating (or restructure to
build-then-assign locally).
3. **Sweep for every other field mutation**:
`grep -rn --include='*.nim' '\.\(payload\|meta\|proof\|timestamp\|ephemeral\|version\|contentTopic\) *=' logos_delivery library apps`
Classify each hit: (a) freshly constructed in the same scope (e.g. the protobuf decode
at `waku_core/message/codec.nim:23` building `var msg = WakuMessage()`) → fine;
(b) received/parameter/stored message → `clone()` first or restructure. Record the
classification of every hit in the phase notes (this list is the review artifact).
## Step 3 — Nil-safety audit
`var m: WakuMessage` now defaults to `nil`, not an empty object; field access on it crashes.
- `grep -rn --include='*.nim' 'var [a-zA-Z_]*: WakuMessage\b\|: WakuMessage$' logos_delivery library apps`
any declaration relying on default-init must become `= WakuMessage()`.
- Check emptiness idioms: `== WakuMessage()` / `== default(WakuMessage)` comparisons and
`Table[..., WakuMessage].getOrDefault` consumers (getOrDefault now yields `nil`).
- Fields of type `WakuMessage` inside other objects (e.g. `MessagePush.wakuMessage` in
`waku_filter_v2/rpc.nim`, store RPC types, `queue_driver`'s
`SortedSet[Index, WakuMessage]`) — their decode paths must construct explicitly; verify
each codec assigns a constructed message (`rpc_codec.nim` files for filter/lightpush/store).
- `Option[WakuMessage]`/`Opt[WakuMessage]` (only 2 src occurrences): `none`/absent now also
representable as nil ref — do NOT collapse the two; keep the Opt wrapper semantics as-is.
## Step 4 — Tests
- Full `make test`. Expected failure classes: nil-default (Step 3 misses), ref-identity
assumptions (tests mutating a fixture then asserting the original unchanged — now shared),
and `check msg1 == msg2` (covered by structural `==`).
- Do NOT weaken test assertions to make them pass; fix the production/nil issue instead.
- Add new unit tests in `tests/waku_core/` (~40 LOC): structural `==` incl. nil cases;
`clone()` independence (mutating clone leaves original untouched); `ensureTimestampSet`
returns same ref when timestamp set / fresh ref when not.
## Step 5 — Memory-model verification (mandatory before "done")
- refc: full suite + the Phase 1 benchmark; record the checkpoint numbers next to
`bench_baseline.md` (expect alloc-volume drop, identical decode/hash counters, identical
message throughput or better).
- ASAN run of the message-path tests (clang, refc) — this is the net that catches
a use-after-free from the new shared lifetimes: at minimum
`tests/waku_relay`, `tests/waku_archive`, `tests/waku_filter_v2`, `tests/node`.
- Statement for the record (per FFI rules): `library/` C bindings deep-copy message fields
via `copyMem` into JSON events (`library/json_message_event.nim:72-95`), so no message
ref crosses the FFI boundary; refc vs ORC behavior of the ref itself is standard RC —
no finalizers, no cycles (WakuMessage has no back-references).
## Acceptance criteria
1. Full `make test` green; ASAN run clean.
2. Step 2.3 mutation-site classification list produced (in PR description or phase notes).
3. Phase 1 bench re-run recorded: alloc volume down ≥ 40 % (projection is 5060 %),
decode/hash counters unchanged vs baseline. If alloc volume barely moves, something
still deep-copies — investigate before closing the phase (prime suspects: a `var`
parameter forcing a copy, or a leftover value-type field of WakuMessage inside a
container that the compiler still copies).
4. `gitnexus_detect_changes()` reviewed; commit (no push).
## Estimated diff
~35 new LOC, ~160340 changed LOC (dominated by Steps 2.3/3 fixes and test adjustments).
Budget: 23 days, most of it audit + verification, not typing.

View File

@ -0,0 +1,165 @@
# Phase 3 — `WakuEnvelope` API break: decode once, hash once (executor plan)
> Self-contained implementation plan. Context: [async_copy_analysis.md](async_copy_analysis.md),
> [async_copy_fix_plan.md](async_copy_fix_plan.md). Prerequisites: Phases 1 and 2 merged
> (WakuMessage is a `ref object`; benchmark + counters exist).
>
> **Objective**: introduce `WakuEnvelope` (message + pubsubTopic + msgHash, one ref) as the
> unit that flows through internal dispatch, eliminate redundant proto decodes and
> `computeMessageHash` recomputation, and stop copying the filter push buffer per peer.
> Target, verified by Phase 1 counters: **≤ 2 decodes and 1 hash per inbound relayed
> message** (from ≥ 4 and 46), plus 1 encode + 1 hash on the outbound publish leg.
>
> This is a deliberate **internal API break**: `WakuRelayHandler` changes shape, and every
> registered handler follows.
## Constraints
Same as previous phases (refc, nph, gitnexus impact/detect_changes, no push, verify by
running). Run `gitnexus_impact({target: "WakuRelayHandler", direction: "upstream"})` and
report before editing.
## Design decisions (already made — do not re-litigate)
1. **No validator→handler cache.** The gossipsub validator and the topic handler have no
shared side channel, and keying a handoff cache by a non-cryptographic hash of the raw
bytes would allow attacker-crafted collisions to substitute messages. We accept **2**
decodes (validator + topicHandler); the envelope kills the other 23 decodes and all
re-hashing. A keyed cache can be a later optimization with its own security review.
2. **The `onRecv` observer decode is deleted outright** — its decoded message is unused
(see Step 2).
3. `WakuEnvelope` is a `ref object` and immutable by convention, same as WakuMessage.
## Step 1 — The type
New file `logos_delivery/waku/waku_core/message/envelope.nim` (~50 LOC), exported from
`waku_core`:
```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))
```
Plus `shortLog`/`$` helper for chronicles fields (hash as 0x-hex).
## Step 2 — WakuRelay (`logos_delivery/waku/waku_relay/protocol.nim`)
Current inbound anatomy (line refs at time of writing):
- ordered validator decodes (`generateOrderedValidator`, :536-565, decode at :543,
hash-on-reject at :553)
- observers (`initRelayObservers`, :255-353): `onRecv` :301 decodes via
`decodeRpcMessageInfo` (:256) but `updateMetrics` (:283) **never uses the decoded
message** — only sizes; `onValidated` :324 decodes only to log; `onSend` :339 decodes
to log + metrics
- topicHandler wrapper (`subscribe`, :596-634, decode at :603) → calls the
`WakuRelayHandler`
- `publish` (:674-695): timestamp fix (Phase 2 made it `ensureTimestampSet`), encode,
hash, gossipsub publish
Changes:
1. **`WakuRelayHandler`** (type in this module; 19 src + 5 test references repo-wide)
becomes `proc(envelope: WakuEnvelope): Future[void] {.gcsafe, raises: [].}` (keep the
existing pragma set).
2. **topicHandler wrapper** (:600-616): after the (kept) decode, build
`let envelope = WakuEnvelope.init(pubsubTopic, decMsg)` — the **single hash
computation** for the inbound path — and call `handler(envelope)`.
3. **Ordered validator** (:536-565): decode stays (validators need the message). The
reject-path hash (:553) may stay — it only runs on rejected messages.
4. **Observers**:
- `onRecv` (:301-322): delete the `decodeRpcMessageInfo` call; keep control-message
handling and byte metrics computed from `msg.data.len + msg.topic.len` directly.
- `onValidated` (:324-337): decodes only for `logMessageInfo`. Wrap in a compile-time
or log-level gate (chronicles `logLevel >= some threshold` pattern used elsewhere in
the repo) so production INFO builds do not pay a decode per message; alternatively
downgrade to logging msgId + topic only (no decode). Pick whichever keeps current
log content at DEBUG/TRACE.
- `onSend` (:339-348): same treatment as onValidated.
5. **`publish`** (:674-695): after encode, `computeMessageHash` stays (outbound leg,
1 hash) — no structural change beyond what Phase 2 did.
6. `validateMessage` (:567-594, lightpush entry): currently re-encodes just for the size
check and re-hashes for logging. Add an overload/param taking the already-encoded
buffer length and an optional precomputed hash so the lightpush path (which has the
buffer) stops double-encoding. Small, contained.
## Step 3 — Dispatch (`logos_delivery/waku/node/subscription_manager.nim:29-85`)
`registerRelayHandler`: `traceHandler`/`filterHandler`/`archiveHandler`/`syncHandler`/
`internalHandler` (:45-70), `uniqueTopicHandler` (:72-84), and the
`legacyAppHandlers: Table[PubsubTopic, WakuRelayHandler]` all switch to the envelope
signature. Inside, replace `(topic, msg)` argument pairs with `envelope` and use
`envelope.msg.payload.len` etc. `MessageSeenEvent.emit(node.brokerCtx, topic, msg)` (:70):
change the event to carry the envelope (see Step 5).
## Step 4 — Consumers (each loses its own `computeMessageHash`)
28 src `computeMessageHash` call sites exist; ~1014 die here. For each consumer, change
the entry point signature and use `envelope.hash`:
| Consumer | Entry point | Redundant hash removed |
|---|---|---|
| Archive | `waku_archive/archive.nim:98` `handleMessage` | :101 |
| Filter | `waku_filter_v2/protocol.nim:243` `handleMessage` | :246 and :195 |
| Store-sync | `waku_store_sync/reconciliation.nim` `messageIngress` (called from subscription_manager:67) | :78 |
| Lightpush→relay | `waku_lightpush*/callbacks.nim:16-24` (builds the relay publish) | :24 (hash once when building the envelope/publish result) |
| API/REST relay | `api/relay.nim` (:30) and any REST messaging handler registering a WakuRelayHandler (this branch's messaging REST endpoints) | :30 |
| library/FFI events | `library/json_message_event.nim` (:88) via MessageSeenEvent | :88 |
Keep `computeMessageHash` call sites that hash *other* messages (store queries hashing
stored rows, sync transfer :171, filter rpc :95 on the client side, etc.) — only remove
recomputation of an inbound message's own hash where an envelope is in hand.
## Step 5 — Broker event
`MessageSeenEvent` (`logos_delivery/api/events/kernel_events`): change payload from
`(topic, msg)` to the envelope (or add hash alongside, whichever keeps broker codegen
simplest). Update its listeners — notably the library/JSON event path, which then uses
`envelope.hash` instead of recomputing (:88) — and any messaging-REST event cache on this
branch that listens to MessageSeenEvent.
## Step 6 — Filter per-peer buffer share (`waku_filter_v2/protocol.nim`)
`handleMessage``pushToPeers` (:213-216) encodes `MessagePush` **once** (good) but then
`pushToPeer(peerId, buffer)` (:170) is `{.async.}` with a by-value `seq[byte]` — one
buffer deep-copy per subscribed peer. Fix: make `pushToPeer` `{.async: (raw: true).}` or
pass `ref seq[byte]`. One proc + call site, ~25 LOC.
## Step 7 — Tests
- Update every handler construction to the envelope signature (grep `WakuRelayHandler`
in tests, plus tests calling `handleMessage(topic, msg)` on archive/filter and
`messageIngress` directly): wrap with `WakuEnvelope.init(topic, msg)`.
- Add `toEnvelope(topic, msg)` convenience to `tests/testlib/wakucore.nim` if it reduces
churn (thin alias of `WakuEnvelope.init`).
- New unit tests (~40 LOC): envelope init computes the same hash as `computeMessageHash`;
relay topicHandler delivers an envelope whose hash matches an independently computed one.
- Affected suites to run individually while iterating: `tests/waku_relay`, `tests/node`,
`tests/waku_archive`, `tests/waku_filter_v2`, `tests/waku_lightpush`,
`tests/waku_store_sync`, plus the REST/API suites on this branch.
## Step 8 — Verification (mandatory)
1. Full `make test` green.
2. Phase 1 macro benchmark: **counter assertion is the acceptance gate**
`decodes_per_msg ≤ 2` and `hashes_per_msg == 1` on the receiving node
(observer logging gated per Step 2.4; if the gate is log-level-based, run the bench at
INFO). Micro bench: expect ~7580 % alloc-volume reduction vs `bench_baseline.md` and
the throughput gain in the analysis tables (~4× headroom at the 10/50/150 kB profile).
3. Record the after-numbers in `docs/analysis/bench_baseline.md` (append a "Phase 3"
section with commit hash).
4. `gitnexus_detect_changes()` reviewed — the affected set must be: waku_core (new file),
waku_relay, node/subscription_manager, waku_archive, waku_filter_v2, waku_lightpush,
waku_store_sync, api/events + api/relay, library, tests. Anything outside that list is
scope creep — stop and reassess.
5. Commit (no push).
## Estimated diff
~150 new LOC, ~640990 changed LOC (tests ~250400 of that). Budget: 46 days. The only
genuinely fiddly part is Step 2 (relay observers + handler wrapper); do it first, get
`tests/waku_relay` green, then fan out to consumers mechanically.