perf: collect prune used ids in sharded maps after the walk - #568
Open
BradKollmyer wants to merge 36 commits into
Open
BradKollmyer wants to merge 36 commits into
BradKollmyer wants to merge 36 commits into
Conversation
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).
This was referenced Sep 7, 2026
BradKollmyer
force-pushed
the
perf/prune-used-ids
branch
from
September 7, 2026 01:02
9f13d68 to
ba3d186
Compare
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.
FxHash of the full 32-byte id plus map growth was ~16% of finding used blobs. SHA-256 ids are uniform, so the prefix is the hash; equality stays 32 bytes. Per-loader maps start at 2M slots.
skip_string_body was ~5% of finding used blobs, scanning names/mtime/xattrs a byte at a time. Restic strings almost never contain escapes; scan 16-byte chunks for quote or backslash instead.
HashMap insert of 32-byte ids was ~14% of finding used blobs plus memcmp during the walk. Push ids during the tree load and sort+dedup when the loader finishes.
Serial HashMap extend of ~22M ids after per-loader vecs added ~10s of wall. Partition by id prefix into 16 shards and build each HashMap on Rayon so merge is not single-threaded.
find_unescaped_quote was ~5% of finding used blobs with a scalar 16-byte word scan. memchr2 uses SIMD to find quote or backslash.
parse_key was ~5% of finding used blobs because AVX2 memchr startup dominates on 4–20 byte keys. Scan those scalar; keep memchr for long unused strings.
kernel_init_pages was ~11% of finding used blobs as per-loader vecs grew past 2M. 4M slots is 128 MiB per loader and covers typical unique-blob counts without realloc.
clippy::needless_borrows_for_generic_args, which CI runs with -D warnings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TdgDzcDaQVR4uqEPMDKZsu
Shards were picked from the low 4 bits of the id prefix, but the identity hasher hands that same prefix to hashbrown, which takes bucket positions from the low bits. Every key in a shard then shared its bucket bits and probed more. Bits 28-31 are used by neither the bucket index nor the top-7-bit tag. A 16M-id micro-benchmark showed 15-45% faster lookups; not measured on a real repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TdgDzcDaQVR4uqEPMDKZsu
BradKollmyer
force-pushed
the
perf/prune-used-ids
branch
from
September 7, 2026 20:41
ba3d186 to
2740ef2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #564.
Stacked on #567. Review the unique range:
BradKollmyer/rustic_core@perf/prune-json...perf/prune-used-ids