Repository navigation
ChunkIndex: intern pack_id (u32 index) to cut chunks index memory by ~30% #10080
Description
Follow-up to the memory usage evaluation in #5163.
Problem
The in-memory ChunkIndex is borg's dominant allocation, ~100 B/chunk all-in (see the
cache-memory-usage section in docs/internals/data-structures.rst). Its value record
(flags I, size I, pack_id 32s, obj_offset I, obj_size I) = 48 B (+ 32 B key = 80 B kv record)
carries a 32-byte pack_id that is 40% of the record and massively redundant: all objects in a
pack share it. With 50 MB packs, typically hundreds of index entries repeat the same pack_id, and
even a 100 TB repo only has ~2M distinct packs — a u32 index is more than enough.
Idea: intern pack_id in the in-memory ChunkIndex
- ht value format becomes
(flags I, size I, pack_idx I, obj_offset I, obj_size I)= 20 B;
theChunkIndexwrapper holds apack_idslist +pack_id -> idxdict (tiny: #packs entries). - kv record 80 -> 52 B, i.e. ~100 -> ~72 B per chunk all-in (~30% less).
For the docs' example (16 Mi chunks): 1.56 GiB -> ~1.1 GiB. - Public API unchanged:
__getitem__/iteritemsreturn the existingChunkIndexEntrywith
the pack_id expanded to bytes32;__setitem__/add/update_pack_infointern (one dict
lookup). Consumers (repository.py, compact_cmd.py, repo_compress_cmd.py) need no changes.
Persistence: no format change needed (memory-only interning)
Every repo<->memory crossing of the chunks index is already an entry-by-entry loop, and the
serialized fragments are self-describing (borghash's JSON meta carries field names + struct
format), so the on-repo format and the in-memory format are decoupled:
- Write:
write_chunkindex_to_repoalready copies selected entries one-by-one into bounded
temp batch tables (<=CHUNKINDEX_FRAGMENT_ENTRIES_MAX= 400k entries) beforeht.write.
Make the batch a plainHashTableNTin the current serialization format (32spack_id) and
expand entries while filling it.
Detail: today the batch fill goes throughChunkIndex.__setitem__, which re-addsF_NEW, so
serialized entries actually carryflags=F_NEWon disk. The new fill must reproduce that
byte-exactly, so identical entry sets keep producing identical fragment content hashes
(dedupe/convergence with fragments written by current borg). - Read:
read_chunkindex_from_reporeturns the raw loadedHashTableNT(self-describing;
its.items()already yields entries with bytes32 pack_id). The existing merge loops in
build_chunkindex_from_repo/repack_chunkindexintern on insert.
Transient memory stays bounded: one fragment ≈ 400k × 92 B ≈ 37 MB at a time. UNKNOWN_BYTES32needs no special-casing:F_PENDINGentries are never serialized (asserted
inwrite_chunkindex_to_repo), and in memory the filler interns like any other value.- No flag day: old and new borg read each other's fragments. No borghash change needed.
Optional, later, independent: store fragments interned too (20 B values + a per-fragment pack-id
table appended after the ht section; assigning pack_idx in first-appearance order while filling
the sorted-key batch keeps fragment bytes deterministic, preserving content-hash convergence).
That would cut fragment size ~35% pre-compression — but zstd compresses the repeated pack_ids
well anyway, so measure the compressed sizes first before breaking the format.
Implementation sketch
src/borg/hashindex.pyx: everything lives inChunkIndex— internal interned value type,
pack_idslist + reverse dict, intern/expand in__init__,__getitem__,__setitem__,
add,iteritems,update_pack_info,clear_new,clear(reset pack table),read
(intern per entry while loading),write(expand per entry),size()estimate.src/borg/hashindex.pyi: update stub.src/borg/cache.py: batch table inwrite_chunkindex_to_repouses the serialization format;
read_chunkindex_from_reporeturns the raw loaded table for merging.docs/internals/data-structures.rst: memory formula chunks index ~100 -> ~72 B/entry.- Tests: interning round-trip; fragment byte-stability across insert orders; fragment content
hashes for identical entry sets unchanged vs. master (compat/dedupe); fragment merge;
pending-entry handling.
Verification
- Memory: synthetic 1M/10M-entry fills, before vs. after (~30% expected on the chunks index).
- Compatibility: create repo + backup with master, read/extend with the branch and vice versa.
- Perf sanity: index build/merge adds one dict lookup per inserted entry and one list index per
lookup — expected to be noise; spot-check create/extract benchmarks.
Activity
NIne-WIngEd commentedon Oct 8, 2026
Hi. I would like to contribute in this issue if it is open. Please let me know and we can discuss my approach. Thank you
ThomasWaldmann commentedon Oct 8, 2026
A remaining research question for this is whether to implement it in borg or in borghash:
HashTableNT, special-cased for the pack_idsNIne-WIngEd commentedon Oct 8, 2026
Thanks @ThomasWaldmann.
My initial preference would be to keep the interning in Borg’s ChunkIndex, since that keeps the change focused on pack_ids. Then I’ll compare that in borghash.
Does that sound like a reasonable first step? Are there other borghash use cases I should consider when comparing the two?
ThomasWaldmann commentedon Oct 11, 2026
Sure, try it like that.
borghash is a general purpose library and I guess the use case of having repetitive values is a common one.