mirror of
https://github.com/logos-messaging/logos-delivery.git
synced 2026-07-21 04:00:27 +00:00
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>
230 lines
15 KiB
Markdown
230 lines
15 KiB
Markdown
# 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 | **1–2 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 + 2–3 copies of the result** under refc.
|
||
Rows 2–3 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.
|
||
|
||
### C–F. 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 4–6× 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 ~4–6 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 4–6 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 + 2–3 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 2–3 copies
|
||
(Table 1 rows 2–3) 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 2–3 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, 4–6 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) | 2–3 | ~13 + F | 2–3 (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) | 1–2 (hash carried) | ~4 + F | 3–5 | 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) | 2–3 per large-value await | — | — | 2–3 (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 1–2 hash passes per message — a ~75–80 %
|
||
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 ≈ 1–2 GB/s per core (allocation-bound, not memcpy-bound); SHA-256 via
|
||
nimcrypto (pure Nim, no SHA-NI) ≈ 300–500 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 | ~60–80 → ~12–16 |
|
||
| 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 P1–P3 |
|
||
|---|---|---|
|
||
| 50 kB @ 100 msg/s | ~10–18 % | ~3–5 % |
|
||
| 50 kB @ 500 msg/s | ~50–90 % (near saturation) | ~13–22 % |
|
||
| 150 kB @ 100 msg/s | ~30–55 % | ~8–14 % |
|
||
| 10 kB @ 1000 msg/s | ~20–37 % | ~5–9 % |
|
||
|
||
Single-core throughput ceiling from overhead alone (copy+hash time per message):
|
||
|
||
| Payload | Baseline ceiling | After P1–P3 | Headroom gain |
|
||
|---|---|---|---|
|
||
| 10 kB | ~2,700–5,000 msg/s | ~11,000–20,000 msg/s | ~4× |
|
||
| 50 kB | ~550–1,000 msg/s | ~2,200–4,000 msg/s | ~4× |
|
||
| 150 kB | ~180–330 msg/s | ~740–1,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.5–1 day: fixed-seed message generator through
|
||
`uniqueTopicHandler`, measure allocs via `getOccupiedMem`/instrumented `computeMessageHash`
|
||
counter) → P3 → P1 → P2 → re-benchmark. Total ≈ **1.5–2 calendar weeks** for P1–P3 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)` | 1–2 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 |
|