After a one-shot bulk add, a load/from_bytes, or a search/prepare, the codes buffer(s) are left at tight capacity (len == cap). The next add calls packed_out.reserve(n * bytes_per_row) at encode.rs:613 for just the new rows. Vec::reserve uses amortized growth, which doubles a tight buffer regardless of how small the request is (grow_amortized → max(len+additional, cap*2)). The index then holds a full extra dead copy of the codes as capacity slack, permanently — subsequent small adds fit inside the doubled capacity, so it is never released. The blocked cache doubles the same way (pack.rs:173 resize on the lazy-append path; lib.rs:932 extend_from_slice on the eager path).
This is the workflow the v7 sync() feature (#476) targets: load a large index, then add a small incremental delta. A 2.4GB loaded index balloons by +2.4GB (blocked-only) up to +4.8GB (packed + warm blocked) on its first incremental add and holds it for the index lifetime. It violates #333's stated oracle ("retained heap after add ≈ index size") on the incremental path #333 did not measure.
Distinct from #333 (encode_scratch/snap release), #475 (two layouts as live data), #480 (io_v7 read buffer), #483 (transient sync/load delta materialization).
Measured (dim 768, 2-bit, code 19.2MB; counting global allocator):
- Standalone: tight
Vec<u8> cap 19_200_000 + reserve(192) → cap 38_400_000 (exactly 2x); reserve_exact(192) → cap 19_200_192.
- load then +1 row: over-index 34.1MB → 53.7MB (+19.6MB, one code copy), flat on every subsequent add.
- bulk add, search (warm blocked), then +1 row: over-index 19.7MB → 58.5MB (+38.8MB, both packed and blocked double).
Fix (conditional, must not regress growing multi-add): use reserve_exact / bounded headroom on both the codes and blocked grows only when the added rows are small relative to the existing buffer; keep amortized reserve when the add is a meaningful fraction of the buffer, since repeated large same-size adds rely on doubling for O(1) amortized cost.
Found by an overnight bug-hunt agent sweep (wave 19) against main @ c8d7ec0; the 2.0x doubling reproduced on the real allocator (tight 19.2MB + reserve(192) → 38.4MB) and the retained-forever behavior independently confirmed by a second agent before filing.
After a one-shot bulk
add, aload/from_bytes, or asearch/prepare, the codes buffer(s) are left at tight capacity (len == cap). The nextaddcallspacked_out.reserve(n * bytes_per_row)atencode.rs:613for just the new rows.Vec::reserveuses amortized growth, which doubles a tight buffer regardless of how small the request is (grow_amortized→max(len+additional, cap*2)). The index then holds a full extra dead copy of the codes as capacity slack, permanently — subsequent small adds fit inside the doubled capacity, so it is never released. The blocked cache doubles the same way (pack.rs:173resizeon the lazy-append path;lib.rs:932extend_from_sliceon the eager path).This is the workflow the v7
sync()feature (#476) targets: load a large index, then add a small incremental delta. A 2.4GB loaded index balloons by +2.4GB (blocked-only) up to +4.8GB (packed + warm blocked) on its first incremental add and holds it for the index lifetime. It violates #333's stated oracle ("retained heap after add ≈ index size") on the incremental path #333 did not measure.Distinct from #333 (encode_scratch/snap release), #475 (two layouts as live data), #480 (io_v7 read buffer), #483 (transient sync/load delta materialization).
Measured (dim 768, 2-bit, code 19.2MB; counting global allocator):
Vec<u8>cap 19_200_000 +reserve(192)→ cap 38_400_000 (exactly 2x);reserve_exact(192)→ cap 19_200_192.Fix (conditional, must not regress growing multi-add): use
reserve_exact/ bounded headroom on both the codes and blocked grows only when the added rows are small relative to the existing buffer; keep amortizedreservewhen the add is a meaningful fraction of the buffer, since repeated large same-size adds rely on doubling for O(1) amortized cost.Found by an overnight bug-hunt agent sweep (wave 19) against main @ c8d7ec0; the 2.0x doubling reproduced on the real allocator (tight 19.2MB + reserve(192) → 38.4MB) and the retained-forever behavior independently confirmed by a second agent before filing.