logos-delivery/docs/analysis/async_copy_analysis.md
NagyZoltanPeter 05ac82d454
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>
2026-07-15 04:07:59 +02:00

230 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |