Skip to content

perf(dash-spv): reduce ram usage - #1024

Draft
ZocoLini wants to merge 9 commits into
devfrom
perf/reduce-ram-usage
Draft

ZocoLini wants to merge 9 commits into
devfrom
perf/reduce-ram-usage

Conversation

@ZocoLini

@ZocoLini ZocoLini commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Reduce dash-spv RAM usage

Draft: the measurements below were taken with #1015, #1016 and #1014 applied (the committed-range sweep removal and its prerequisites). This branch does not depend on them to build or pass tests, but it stays a draft until they merge and until the on-disk format change below is handled.

Peak RAM, mainnet restore

dash-spv-bench, scenario mainnet.100mbi.100ms, one wallet. Every run ends with the same wallet state: 14114383 sat, 7112 transaction records, 13389 addresses.

Build Peak RSS Sync time
dev (committed-range sweep still in) 2280–2360 MiB (jemalloc), ~2924 MiB (glibc) 20–24 min
dev + #1015 + #1016 + #1014 982 MiB (jemalloc) 8.2 min
dev + #1015 + #1016 + #1014 + this branch 538 MiB (jemalloc), 547 MiB (glibc) 7.5–8.0 min

With the sync fixes applied, this branch roughly halves the peak with no cost in sync time.

jemalloc numbers come from --memory-snapshot runs, which link jemalloc with heap profiling. glibc numbers come from plain runs with the default allocator.

Changes, in commit order

  • Bench: in-process profiling, client always in docker.

  • run.sh --flame builds with the cpu-profile feature (pprof) and writes flamegraph.svg.

  • --memory-snapshot builds with the heap-profile feature (jemalloc) and writes heap-peak.svg, the live heap at the highest RSS of the run.

  • Neither profiler is compiled in without its flag.

  • The client always runs in its container, built inside the Rust image.

  • Download shaping requires ifb; the policer fallback is gone.

  • run.sh goes from 664 to 366 lines.

  • Drop the committed-height segment release (reverts fix(dash-spv): release clean storage segments below the committed height during long scans #946). Memory was only freed when a sync manager called set_committed_height, and only on the next timed persist. The segment cache now bounds its resident set by itself.

  • Trace segment cache hits and misses. One trace-level line per segment request says whether it was a hit, or where a miss came from (disk or new). Enable it with RUST_LOG=dash_spv::storage::segments=trace.

  • Keep at most two segments resident per storage cache (was ten). Each cache still serves ~2.34M requests from memory per restore.

  • Persist a dirty segment when it is evicted. Dirty segments no longer wait in an in-memory evicted map for the 5 s storage tick, so the resident limit is a real limit.

  • Stream segments to disk. Segment::persist used to encode the whole segment into one Vec, up to 128 MiB for a block segment near the tip. It now writes item by item through a BufWriter into the temporary file, which is then synced and renamed as before.

  • Size storage segments per item type (Persistable::ITEMS_PER_SEGMENT, previously 50 000 for every type):

    Type Items per segment Segment size
    Headers 10 000 ~1.1 MB
    Filter headers 50 000 ~1.6 MB
    Filters 2 000 ~2 MB near the tip
    Blocks 1 000
    • Segment caches drop from 332 MiB to 38 MiB at the peak.
    • With 1 000 items for every type, the header and filter header phases paid an fsync per evicted segment. Sizing per type avoids that.
    • Keep Persistable inside the storage module.

On-disk format

The per-type segment sizes change the layout of the header, filter and block segment files. With this branch, storage written with 50 000-item segments is misread. Before this leaves draft it needs either a versioned folder (forcing a re-sync) or a migration on open.

Testing

  • cargo clippy --all-features --all-targets -D warnings.
  • dash-spv lib tests, the dash-spv dashd integration suite, and dash-spv-ffi dashd_sync.
  • Mainnet bench restores as in the table above, with an identical wallet in every run.

What remains in the heap

From the jemalloc heap profile at the peak:

  • Header hash→height index: ~160 MiB.
  • Wallet: ~60–90 MiB.
  • Masternode lists: ~65–80 MiB.
  • Segment caches: ~40 MiB.

ZocoLini and others added 8 commits September 14, 2026 23:06
…t always in docker

`./run.sh <scenario> --flame` builds the bench with the `cpu-profile`
feature (pprof, 99 Hz SIGPROF sampler) and writes flamegraph.svg.
`--memory-snapshot` builds with `heap-profile` (jemalloc as the global
allocator, sampling heap profiling) and writes heap-peak.svg: the live
heap at the highest RSS of the run. A thread re-dumps the profile each
time VmRSS grows by 5 % and only the last dump is rendered, after the
sync. Without the flags neither profiler is compiled in. perf is not
usable on the benchmark host (perf_event_paranoid=4) nor reliably under
Docker Desktop, and jeprof needed path and placeholder workarounds, so
both profilers now run inside the process.

peak_rss_mib is read before the profiles are rendered: rendering the
CPU flamegraph of a 20-minute run allocates ~2 GB, which otherwise
showed up as a 4703 MiB peak and filled half of the heap snapshot.

The client now always runs in the client container, shaped or not, and
the binary is always built inside the Rust image, so it links against
the container's glibc (a host build fails to start on hosts with a
newer glibc than bookworm's). This drops the host-run path, the
perf/perl/FlameGraph/OS checks and the policer fallback for kernels
without ifb: a run whose download rate cannot be enforced now fails
instead of producing a result that cannot be compared. `--wallets` with
a relative path now resolves against the caller's directory instead of
dash-spv-bench/. run.sh goes from 664 to 366 lines.

Mainnet restore, mainnet.100mbi.100ms, on dev, --flame
--memory-snapshot: 20.3 min, peak_rss_mib 2280, heap snapshot at
2243 MiB RSS (1.88 GB live), 14114383 sat, 13389 addresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
Reverts ae2a5d6 (#946). Segments were released from memory only when
a sync manager remembered to call `set_committed_height` on the block
and filter storages, and only on the next timed persist. The resident
set should be bounded by the segment cache itself, not by callers
reporting progress to storage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
Every segment request now logs at trace level whether it was served
from a resident segment or, on a miss, where the segment came from
(`evicted`, `disk` or `new`). Enable it per module with
RUST_LOG=dash_spv::storage::segments=trace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
With the committed-height release gone, the segment cache itself has to
bound the resident set. Ten 50 000-item segments per cache kept most of
a restore in memory; two keep the peak below what the release achieved,
without costing time.

Mainnet restore, mainnet.100mbi.100ms, #1015/#1016/#1014 applied,
jemalloc heap profiling, wallet identical in every run (14114383 sat,
7112 records, 13389 addresses):

  segments      peak RSS   time      segment loads from disk
                                     (headers/filter headers/filters/blocks)
  10 + #946     982 MiB    8.2 min   -
  10            1490 MiB   8.8 min   84 / 2 / 28 / 0
  2             930 MiB    8.8 min   212 / 103 / 74 / 0
  1             1000 MiB   7.7 min   427 / 742 / 156 / 33

Each cache served ~2.34M requests from memory in every run. One segment
starts reloading block segments of up to 87 MB from disk, which raises
the peak again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
A dirty segment leaving the resident set used to wait in `evicted`,
in memory, until the next 5 s storage tick wrote it. The resident
limit was therefore not a limit: every segment evicted between two
ticks stayed in memory. Now eviction writes the segment first and only
then drops it; if the write fails the segment stays resident and the
error is returned. The `evicted` map is gone, and the tick only
persists the resident segments.

Mainnet restore, mainnet.100mbi.100ms, #1015/#1016/#1014 applied,
two resident segments: 7.7 and 7.9 min, peak RSS 943 and 948 MiB,
wallet identical (14114383 sat, 7112 records, 13389 addresses). No
cache misses served from `evicted` any more; header, filter header and
filter segments are reloaded from disk about as often as they used to
come back from `evicted` (~270 / ~150 / ~90), block segments never.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
… one buffer

`Segment::persist` encoded the whole segment into a `Vec` before
handing it to `atomic_write`. For a block segment near the tip (up to
87 MB on disk) that `Vec` grew to 128 MiB, and since dirty segments are
now written on eviction this happened in the middle of the sync.
`atomic_write_items` encodes one item at a time through a 1 MiB
`BufWriter` into the temporary file, then syncs and renames it as
before. `atomic_write` keeps its behaviour and shares the temporary file
and rename logic.

Mainnet restore, mainnet.100mbi.100ms, #1015/#1016/#1014 applied, two
resident segments, wallet identical in every run (14114383 sat, 7112
records, 13389 addresses):

  whole-segment buffer   peak RSS 943 / 948 MiB   7.7 / 7.9 min
  streamed               peak RSS 845 / 855 / 856 MiB   9.4 / 7.7 / 6.5 min

The persist buffer, 68–133 MiB in the earlier heap snapshots, no longer
shows up. The 9.4 min run lost time to peers in the header and filter
header phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
Every segment cache held 50 000 items, whatever an item costs. Near the
tip a block segment holds tens of MB of decoded blocks, while a header
segment spanning the same heights is 5.6 MB and a filter header one
1.6 MB. `Persistable::ITEMS_PER_SEGMENT` lets each type choose: headers
10 000 (~1.1 MB per segment), filter headers 50 000 (~1.6 MB), filters
2 000 (~2 MB near the tip) and blocks 1 000.

This changes the on-disk layout of the header, filter and block
segments. Storage written with 50 000-item segments is misread by this
layout and has to be deleted until a migration or a versioned folder
lands.

Mainnet restore, mainnet.100mbi.100ms, #1015/#1016/#1014 applied, two
resident segments, jemalloc heap profiling, wallet identical in every
run (14114383 sat, 7112 records, 13389 addresses):

  segment items           time          peak RSS      segment caches
  50 000 for every type   7.0 min       818 MiB       332 MiB
  5 000 for every type    7.3-8.3 min   554-687 MiB   23-106 MiB
  1 000 for every type    9.2-12.2 min  453-577 MiB   2-66 MiB
  per type (this commit)  7.5 min       538 MiB       38 MiB

With 1 000 items everywhere, the header and filter header phases paid an
fsync per evicted segment (105-141 s and 254-332 s instead of ~60 s and
~150 s). Blocks at 500 items saved ~18 MiB of cache but reloaded 50 %
more block segments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
Nothing outside `storage` implements or names the trait, and the
`segments` module it lives in is private to `storage` already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DruChNTWXwJoWPartZwCf
@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.36364% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.17%. Comparing base (350f5f7) to head (3a87c3a).

Files with missing lines Patch % Lines
dash-spv/src/storage/segments.rs 89.47% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #1024      +/-   ##
==========================================
- Coverage   77.23%   77.17%   -0.07%     
==========================================
  Files         329      329              
  Lines       83862    83686     -176     
==========================================
- Hits        64768    64581     -187     
- Misses      19094    19105      +11     
Flag Coverage Δ
core 78.24% <ø> (ø)
ffi 50.94% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 92.05% <96.36%> (-0.13%) ⬇️
wallet 79.79% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/storage/blocks.rs 100.00% <ø> (+1.16%) ⬆️
dash-spv/src/storage/filters.rs 100.00% <ø> (+1.61%) ⬆️
dash-spv/src/storage/io.rs 97.68% <100.00%> (+0.46%) ⬆️
dash-spv/src/storage/mod.rs 86.15% <ø> (+1.04%) ⬆️
dash-spv/src/sync/blocks/manager.rs 96.72% <ø> (-0.07%) ⬇️
dash-spv/src/sync/filters/manager.rs 97.80% <ø> (-0.01%) ⬇️
dash-spv/src/storage/segments.rs 96.52% <89.47%> (-0.21%) ⬇️

... and 4 files with indirect coverage changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant