perf(agent): store embeddings once, not twice
MemoryCache held every embedding in two places: a `Vec<Vec<f32>>` and a flattened copy for the batched kernels, kept in lock-step on every push, update and compaction. A store loaded from disk therefore carried the corpus twice, plus one heap allocation per entry. A new `cache::Embeddings` owns just the flat `[N x dim]` buffer and indexes into it, so `embeddings[i]` still reads as a `&[f32]` row. The batch kernels take a `VectorSet` (implemented for both `Embeddings` and `Vec<Vec<f32>>`) instead of `&[Vec<f32>]`, so their callers and tests are unchanged. Loading no longer unflattens what it just read. 100k 384-dim entries, reopened from disk: 505 -> 357 MiB, 3.44x -> 2.43x the raw vectors. Recall (1.0000 at ef=64) and query latency are unchanged. Rows are now always exactly `dim` long, shorter ones zero-padded. The old representation allowed ragged rows, which silently misaligned the flattened copy — every row after a wrong-length embedding — and `update` carried a comment about falling back to a rebuild to avoid exactly that. It is now unrepresentable. A record saved without an embedding holds a zero row and is told apart by its norm, which is what `total_embeddings` now counts. Measured with a counting allocator rather than RSS: freeing a structure returns its pages to the allocator's pool, not the OS, so an RSS reading from inside the process showed the two representations as identical. Breaking: MemoryCache::embeddings changes type, embeddings_flat is replaced by flat_embeddings(), rebuild_flat() is a deprecated no-op. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -28,6 +28,35 @@
|
||||
|
||||
---
|
||||
|
||||
## Memory footprint
|
||||
|
||||
`cargo run --release -p clawhdf5-bench --bin search_harness -- --footprint --full`,
|
||||
384-dim `f32`. The figure that matters is **reopened**: a store loaded from
|
||||
disk, which is what a long-lived process holds.
|
||||
|
||||
Measured with a counting global allocator, not RSS. RSS cannot see this from
|
||||
inside one process — freeing a large structure returns its pages to the
|
||||
allocator's pool rather than to the OS, so allocating the next one shows no
|
||||
change at all. Measured that way a store holding the corpus twice and one
|
||||
holding it once came out *identical* (1.00x both), which is how the first
|
||||
attempt at this measurement went.
|
||||
|
||||
| N | vectors (raw) | reopened, before | reopened, after |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 000 | 1 MiB | 5 MiB (3.41x) | 4 MiB (2.39x) |
|
||||
| 10 000 | 15 MiB | 50 MiB (3.43x) | 35 MiB (2.42x) |
|
||||
| 100 000 | 146 MiB | 505 MiB (3.44x) | **357 MiB (2.43x)** |
|
||||
|
||||
The cache stored every embedding twice — once as a `Vec<Vec<f32>>` and once
|
||||
flattened for the batched kernels, kept in lock-step on every push, update and
|
||||
compaction. Storing only the flat buffer and indexing into it gives back
|
||||
almost exactly one copy of the corpus (148 MiB at 100k) and one heap
|
||||
allocation per entry. Recall and query latency are unchanged.
|
||||
|
||||
What remains at 2.43x: the flat vectors (1.0x), the HNSW index's own copy of
|
||||
them (1.0x), and text, ids and graph (~0.4x). The index copy is the next
|
||||
target — it is what a quantised or borrowed representation would address.
|
||||
|
||||
## Read harness
|
||||
|
||||
Produced by `cargo run --release -p clawhdf5-bench --bin read_harness`: a 4096 x
|
||||
|
||||
Reference in New Issue
Block a user