Skip to content

perf: stream-decode prune trees without serde node parse - #567

Open
BradKollmyer wants to merge 26 commits into
rustic-rs:mainfrom
BradKollmyer:perf/prune-json
Open

BradKollmyer wants to merge 26 commits into
rustic-rs:mainfrom
BradKollmyer:perf/prune-json

Conversation

@BradKollmyer

Copy link
Copy Markdown
Contributor

Part of #564.

Stacked on #566. Review the unique range:

BradKollmyer/rustic_core@perf/prune-walk...perf/prune-json

  • Reuse a zstd decompressor per thread for blob reads.
  • Decode prune tree blob ids with a hex nibble table.
  • Record used blob ids from loader threads; fill per-loader maps.
  • Stream-decode prune tree nodes without full node structs.
  • Use FxHash for used-blob maps and the tree visited set.
  • Scan prune tree JSON for type/content/subtree without serde of unused node fields.
  • Preserve escaped JSON references; unknown node type is an error.

OpenDAL only sends maxFileCount when ListOptions.limit is set. Without
it, B2 defaults to 100 names per page, so prune/check pack listing
becomes thousands of round-trips. Request 10000 for scheme b2.
256 KiB MAX_HOLESIZE split prune/restore pack reads into extra HTTP
range GETs on high-latency stores. 4 MiB is cheaper than another RTT
on a ~100 Mbps link.
Unused gaps in a pack were fetched one after another. Issue those
range GETs in parallel; the packer already serializes writes.
stream_list used CPU-count Rayon workers and a zero-capacity channel,
so reading index/snapshots was one GET at a time on high-latency
backends. Use 16-32 workers with prefetch so GETs overlap decrypt/parse.
TreeStreamerOnce was hardcoded to 4 workers, which left B2 tree-pack
GETs idle. Use 2x CPUs (8-32) with a larger out channel, and a HashSet
for visited tree ids.
The used-id set was a BTreeMap, so inserts got slower as prune walked
more blobs. A HashMap keeps that path O(1).
BradKollmyer and others added 20 commits September 7, 2026 13:40
A single Vec of blob ids doubles on growth. On a large repo that request
is hundreds of MiB while the old buffer is still live, and musl aborts
(openzwave: memory allocation of 807665664 bytes failed at reading
index 906/1551). Store ids and full entries in 1 Mi-entry chunks and
binary-search each sorted chunk.
Cache read_partial opened and closed the pack file for every tree blob.
Keep up to 2048 FDs and pread from them. Hits skip LockPool and
exists(); write/remove drop the cached handle.
Prune only needs file content ids and directory subtree ids. Deserialize
a compact UsedBlobsTree instead of a full Vec<Node>, matching restic's
streaming tree walk. Check and copy still load complete trees.
TreeStreamer dumped every snapshot root into a FIFO, so loaders jumped
across packs. Keep a LIFO backlog and only feed one job per loader so
consecutive trees tend to hit the same cached packs, matching restic.
getting packs was a serial B2 prefix list after finding used blobs.
Start that list in the background so it finishes during the tree walk.
Overlapping the B2 pack list with index GETs made reading index... take
minutes on a cold-ish server cache. List packs only during the tree
walk so getting packs still hides without stalling the index.
16–32 parallel index readers help B2 RTTs but on a warm local cache they
thrash disk. Use 2–4 workers when at least 75% of listed index/snapshot
files already exist in the cache.
32 loaders on a 16-vCPU QEMU host spent most of finding used blobs...
in KVM PV spinlocks and musl malloc. Use 4–8 loaders when the cache
already has pack files; keep 2×CPUs (8–32) for a cold remote cache.
`i as u8` trips clippy::cast_possible_truncation and cast_sign_loss,
which CI runs with -D warnings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdgDzcDaQVR4uqEPMDKZsu
rlimit/8 mapped Darwin ulimit 8192 to 1024 open pack files and added
about 5s to prune getting-packs. Keep the 2048 cap and a 64-FD reserve.
nix getrlimit returns rlim_t, which is u32 on armv7.
decode_all created a DCtx for every compressed tree blob. During prune
finding used blobs that showed up as ZSTD_createDCtx, munmap, and ~60k
page faults/s. Keep one bulk Decompressor in thread-local storage.
hex::FromHex<&str> was ~6% of finding used blobs after zstd reuse.
Parse the 64-char ids with a lookup table during UsedBlobsTree JSON
decode instead.
A single consumer HashMap::insert serialized the used-blob walk.
Loaders now insert into a 16-way sharded map while they decode trees.
A derived UsedBlobNode still paid serde skip/parse of every field.
Visit only type, content, and subtree and push ids straight into
UsedBlobsTree.
Sharded Mutex HashMaps still spent ~35% on insert and lock contention.
Each tree loader now owns a HashMap and the maps are merged after the
walk.
siphash HashMap insert of BlobId was ~27% of finding used blobs after
locks were removed. FxHash is enough for in-memory blob ids.
Unused node fields (names, mtime, xattrs) were still UTF-8-validated and
skipped through serde. A dedicated restic-tree scanner only hex-decodes
file content and dir subtree ids.
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