logos-delivery/docs/analysis/speed_gain_pre2_post3.md
NagyZoltanPeter be6bfed619
docs(bench): same-session pre-phase-2 vs post-phase-3 speed+GC comparison
Back-to-back A/B on one machine: micro +85.9%, macro +56.6% msg/s;
decode/hash byte volumes halved. Peak heap (getMaxMem) -21%/-20.7%.
GC-sampling series shows retained-memory slope is workload-driven and
unchanged between versions; the transient-copy reduction is not
observable via occupied/total sawtooth under refc (deterministic frees,
reused arena) and manifests as a lower absolute heap level, not a
gentler slope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:16:11 +02:00

275 lines
11 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.

# Speed gain: pre-phase-2 vs post-phase-3 (same-session A/B)
Rigorous before/after of the no-copy refactor chain, both branches built and run
**back-to-back on the same machine, in the same session**, with identical build
flags and an identical (temporary, uncommitted) GC-sampling patch to
`apps/benchmarks/message_path_bench.nim`.
## Methodology
| Item | Value |
|---|---|
| Branch A ("pre-phase-2") | `experimental/chore-nocopy-e2e-perf-harness` @ `2a4da679` |
| Branch B ("post-phase-3") | `experimental/chore-nocopy-wakuenvelop` @ `a6f1065a` |
| Machine / OS | Apple M4, macOS (arm64) |
| Nim | 2.2.4, `--mm:refc` |
| Build flags (both) | `--mm:refc --cpu:arm64 --passC:"-arch arm64" --passL:"-arch arm64" -d:msgPathCounters -d:chronicles_log_level=ERROR --passL:librln_v2.0.2.a --passL:-lm` (copied from the `benchMessagePath` nimble task) |
| Workload (both) | seed 42, payload mix 10/50/150 kB @ 25/50/25 %, N=1000 measured + 100 warmup |
| Run order | A built+run (2 passes) → B built+run (3 passes; B pass 1 discarded, micro variance 5.51 % under a load spike to 13.8) |
| Machine load | A passes: load avg ~3.3. B passes: elevated (8.913.8) — noted; the two accepted B passes have micro variance 3.34 % / 4.15 % (< 5 %). |
**Sampling design (temporary patch, identical on both branches only the relay
handler signature differs between branches, which the sampling code does not
touch):**
- Micro loop and macro receive loop: every 50 messages record
`(msg_index, getOccupiedMem(), getTotalMem())` into a pre-allocated buffer
(no per-sample heap churn).
- End of each scenario: `getMaxMem()` (process peak heap the key single
number) and `GC_getStatistics()`.
- Existing `gc_collections` counter retained.
- All raw outputs saved outside the repo; the patch was `git checkout --`
discarded before switching branches (verified clean each time).
The instrumented builds are byte-identical in decode/hash counters to the
committed `bench_baseline.md` numbers, confirming the patch did not perturb the
measured path.
## Throughput — pre vs post
Micro = single-node isolated receiver path; macro = two loopback nodes,
publisher + archiving receiver. Micro msg/s is the mean of all accepted
micro runs (A: 4 values across 2 passes; B: 4 values across 2 passes).
| Scenario | Metric | Pre (A) | Post (B) | Gain |
|---|---|---|---|---|
| micro | msg/s (mean) | 1222.8 | 2273.7 | **+85.9 %** |
| micro | ns/msg p50 | ~630 k | ~338 k | 46 % |
| micro | ns/msg p99 | ~1.94 M | ~1.09 M | 44 % |
| micro | decodes/msg | 2.000 | 2.000 | unchanged |
| micro | hashes/msg | 2.000 | **1.000** | 50 % |
| micro | hashed_MB | 130.00 | **65.00** | 50 % |
| macro | msg/s (mean) | 225.45 | 353.1 | **+56.6 %** |
| macro | ns/msg p50 | ~3.55 M | ~2.29 M | 36 % |
| macro | ns/msg p99 | ~10.7 M | ~7.5 M | 30 % |
| macro | decodes/msg | 6.000 | **3.000** | 50 % |
| macro | hashes/msg | 5.000 | **3.000** | 40 % |
| macro | decoded_MB / hashed_MB | 390.33 / 325.00 | **195.16 / 195.00** | ~50 % |
Raw CSV rows (accepted passes):
```
# A (pre-phase-2) — pass 1 / pass 2
micro_run1,1000,834.793ms,1197.9,p50=640000,p99=1980291,dec=2.000,hash=2.000
micro_run2,1000,803.157ms,1245.1,p50=618708,p99=1928250,dec=2.000,hash=2.000
micro_run1,1000,835.163ms,1197.4,p50=642250,p99=1939583,dec=2.000,hash=2.000
micro_run2,1000,799.486ms,1250.8,p50=621750,p99=1866667,dec=2.000,hash=2.000
macro,1000,4431.571ms,225.7,p50=3552333,p99=10703625,dec=6.000,hash=5.000
macro,1000,4441.265ms,225.2,p50=3580583,p99=10964584,dec=6.000,hash=5.000
# B (post-phase-3) — pass 2 / pass 3 (pass 1 discarded, variance>5% under load)
micro_run1,1000,451.768ms,2213.5,p50=345792,p99=1138042,dec=2.000,hash=1.000
micro_run2,1000,436.666ms,2290.1,p50=335709,p99=1045417,dec=2.000,hash=1.000
micro_run1,1000,445.048ms,2246.9,p50=340917,p99=1092750,dec=2.000,hash=1.000
micro_run2,1000,426.569ms,2344.3,p50=329458,p99=1062291,dec=2.000,hash=1.000
macro,1000,2884.679ms,353.0(pass2 353.0),p50=2276292,p99=7292208,dec=3.000,hash=3.000
macro,1000,2831.619ms,353.2,p50=2304750,p99=8211292,dec=3.000,hash=3.000
```
The throughput win is driven by the removed redundant decodes/hashes (byte
volumes halved), not by memory effects.
## GC / heap analysis
### Peak heap (`getMaxMem`, process-monotonic)
| Scenario | Pre (A) | Post (B) | Δ |
|---|---|---|---|
| micro (first scenario, cleanest) | 333.60 MB | 263.61 MB | **21.0 %** |
| macro (whole-process peak) | 514.86 MB | 408.40 MB | **20.7 %** |
`gc_collections` (from `GC_getStatistics`): micro 7 / 8, macro 10 **identical
on both branches**. Same number of GC cycles; the new code simply sits at a
lower occupied level at every point.
### Heap-elevation series (occupied / total, MB)
**Micro — no retention.** Occupied oscillates around a *flat* mean; `getTotalMem`
(reserved arena) is dead-flat for the entire loop on both branches. There is **no
elevation slope** in either the transient decode/hash temporaries are allocated
and freed between the 50-msg samples and the refc arena is reused in place.
| msg idx | A occ | A total | B occ | B total |
|---|---|---|---|---|
| 0 | 275.50 | 333.60 | 207.07 | 263.61 |
| 200 | 275.41 | 333.60 | 211.45 | 263.61 |
| 400 | 274.67 | 333.60 | 208.16 | 263.61 |
| 600 | 275.43 | 333.60 | 217.90 | 263.61 |
| 800 | 274.36 | 333.60 | 208.91 | 263.61 |
| 950 | 275.37 | 333.60 | 215.19 | 263.61 |
- A occupied range 273.5275.7 MB **sawtooth amplitude ≈ 2.2 MB**.
- B occupied range 207.1217.9 MB **sawtooth amplitude ≈ 10.8 MB**.
- Both slopes 0 MB/100msg. Reserved total constant throughout.
**Macro — archive retains all 1000 messages.** Occupied climbs monotonically
(this is *retention*, the archive `SortedSet` accumulating messages), not
transient churn.
| msg idx | A occ | A total | B occ | B total |
|---|---|---|---|---|
| 50 | 308.66 | 414.61 | 235.78 | 329.06 |
| 200 | 321.78 | 414.62 | 256.83 | 329.06 |
| 400 | 353.13 | 414.64 | 283.56 | 329.07 |
| 600 | 378.20 | 414.66 | 315.62 | 408.36 |
| 800 | 405.33 | 414.85 | 336.89 | 408.38 |
| 1000 | 428.25 | 514.86 | 361.54 | 408.40 |
- Retention slope: A **12.6 MB / 100 msg**, B **13.2 MB / 100 msg**
statistically identical (same 1000 messages retained; payload bytes are
retained identically under value vs ref semantics).
- Reserved-arena growth: exactly **one** step each A 414.6514.8 MB at msg
~650; B 329.1408.4 MB at msg ~600.
- The new code runs a **constant ~6575 MB lower** at every sample and peaks
20.7 % lower.
<details><summary>Full CSV series (every 50 msgs; MB)</summary>
```
# scenario=micro_run1 branch=A(pre-2) idx,occMB,totMB
0,275.50,333.60
50,275.40,333.60
100,273.50,333.60
150,273.95,333.60
200,275.41,333.60
250,275.43,333.60
300,275.41,333.60
350,273.58,333.60
400,274.67,333.60
450,273.52,333.60
500,275.41,333.60
550,274.95,333.60
600,275.43,333.60
650,275.16,333.60
700,275.43,333.60
750,274.01,333.60
800,274.36,333.60
850,273.54,333.60
900,275.70,333.60
950,275.37,333.60
# scenario=macro branch=A(pre-2) idx,occMB,totMB
50,308.66,414.61
100,313.30,414.61
150,315.54,414.62
200,321.78,414.62
250,330.46,414.63
300,345.20,414.63
350,346.85,414.63
400,353.13,414.64
450,362.43,414.64
500,376.96,414.65
550,383.31,414.65
600,378.20,414.66
650,385.16,514.83
700,395.89,514.84
750,398.69,514.84
800,405.33,514.85
850,412.23,514.85
900,423.95,514.85
950,425.55,514.86
1000,428.25,514.86
# scenario=micro_run1 branch=B(post-3) idx,occMB,totMB
0,207.07,263.61
50,215.58,263.61
100,209.58,263.61
150,215.05,263.61
200,211.45,263.61
250,207.21,263.61
300,217.89,263.61
350,211.46,263.61
400,208.16,263.61
450,212.43,263.61
500,215.31,263.61
550,210.43,263.61
600,217.90,263.61
650,211.09,263.61
700,212.69,263.61
750,213.75,263.61
800,208.91,263.61
850,207.23,263.61
900,214.70,263.61
950,215.19,263.61
# scenario=macro branch=B(post-3) idx,occMB,totMB
50,235.78,329.06
100,247.91,329.06
150,250.84,329.06
200,256.83,329.06
250,261.66,329.06
300,268.57,329.06
350,277.31,329.07
400,283.56,329.07
450,290.08,329.08
500,303.78,329.08
550,306.02,329.08
600,315.62,408.36
650,315.92,408.37
700,333.08,408.38
750,330.20,408.38
800,336.89,408.38
850,346.05,408.39
900,354.25,408.39
950,359.48,408.40
1000,361.54,408.40
```
</details>
## Verdict on the hypothesis
> Hypothesis: retained memory ~flat (temporary copies dominate), but the OLD
> code shows a *faster heap-allocation elevation pattern* (steeper growth /
> higher sawtooth amplitude / higher peak) than the new code.
**(a) Retained memory flat? — CONFIRMED (as a between-version statement).**
In micro there is no retention and occupied is flat on both branches. In macro
the retention *slope* is essentially identical pre vs post (~12.6 vs ~13.2
MB/100 msg) because the same 1000 messages are retained regardless of value-vs-
ref semantics. Retained growth is a function of the workload, not the refactor.
**(b) Allocation-elevation *slower* after the refactor? — NOT SUPPORTED by this
instrumentation.** The occupied/total sampling shows:
- Macro elevation slope is **unchanged** (retention-driven, not churn-driven).
- Micro elevation slope is **zero on both**; if anything the micro sawtooth
*amplitude* is larger post-refactor (10.8 MB vs 2.2 MB), the opposite of the
hypothesis phrasing — but this is noise-level and the reserved arena is flat.
The reason is the refc caveat: under `--mm:refc` the eliminated transient deep
copies (async-closure env captures, Result/Option bind-outs, redundant decode
buffers) are freed **deterministically** as each temporary leaves scope, and the
arena pages are reused in place. `getTotalMem` is *peak reserved*, not
*cumulative allocated*, so it plateaus once the arena is large enough; between
two 50-msg samples the transient churn has already been allocated **and** freed.
A coarse occupied/total sawtooth therefore **cannot** observe the transient-copy
reduction — and `gc_collections` is identical (10/10), reinforcing this.
**What the data *does* prove about memory:** the refactor lowers the **absolute
heap level** — peak heap 21 % (micro) / 20.7 % (macro), and a constant
~6575 MB lower occupied baseline throughout the macro run. That is a real,
measurable memory win (fewer live temporaries at any instant, plus half the hash
buffers). It manifests as a **lower constant offset**, not a gentler slope or
smaller sawtooth.
**What *would* detect the transient-copy reduction directly:** a cumulative
bytes-allocated counter (instrumenting the Nim allocator or exposing
`GC_getStatistics` cumulative fields), or malloc-level profiling (macOS
Instruments Allocations, `heaptrack`, or valgrind `massif`), or a peak-RSS probe
under memory pressure. The current harness exposes none of these — a harness
gap, not a missing win.
## Bottom line
| Claim | Result |
|---|---|
| Throughput up | **micro +85.9 %, macro +56.6 %** |
| Redundant decode/hash removed | hashes/msg 50 % micro, decode+hash 4050 % macro (byte-exact) |
| Peak heap down | **21 % / 20.7 %** |
| Retained-memory slope changed by refactor | No (retention is workload-driven, identical) |
| Old code shows steeper allocation elevation | Not observable via occupied/total sampling under refc; win is a level shift, not a slope |