Benchmark: pivot the table around hardware, and stop measuring in memory - #58
Merged
Conversation
The table was organised by mode inside a per-machine section, which answers "which mode should I pick" — a question whose answer does not vary by CPU. The comparison people actually want from a benchmark table is across hardware, and that one was buried. The section now has two tables. The hardware table carries INT8 only, two rows per machine, each row self-contained: hardware, vectors, index configuration, peak memory during the scan, latency, throughput, recall. Someone comparing two rows does not have to carry the prose above the table in their head, which matters when the rows being compared are far apart. Everything that does not vary by machine moved into the parameters above it: the dataset and its seed, the 740 MB index, and the exact FLOAT32 scan recall is measured against. Adding a machine is two rows. Those two rows are the two ways the same index gets deployed — entirely resident after vector_quantize_preload(), or streamed through a bounded buffer with max_memory=30MB, which is the default and what a device with 740 MB of index and less RAM than that actually does. The streaming configuration now passes max_memory explicitly rather than relying on the default staying 30 MB. Max memory is measured rather than the parameter echoed back: the benchmark brackets vector_quantize_preload() with sqlite3_memory_used() for the resident case and takes the high-water mark across the query loop for the streamed one. On 1M x 768 that reads 740 MB against 30 MB, which is worth having as a measurement — it shows max_memory bounds what the scan holds, not just the size of the chunks written at quantization time. The two configurations differ by 25x in memory for 1.5x in latency with recall untouched, since both read the same index and only residency changes. `make benchmark HARDWARE="Apple M5 Pro - NEON"` prints those two rows ready to paste, in the table's column order and with thousands separators, so a contributor reformats nothing. The label is CPU and backend only: core counts and RAM were noise in a column meant to be scanned down, and the backend is the part that can surprise you. Recall is repeated on every row even though it depends on the data rather than the machine. A row that disagrees with the others is a sign that machine selected a different SIMD backend than it should have, which is exactly the class of bug that had every x86 build shipping scalar code. Mode selection keeps its own table, since the three findings worth knowing — INT8 over UINT8 for cosine, 1BIT as a pre-filter, TurboQuant trading size rather than speed — are properties of the data and the algorithms, not of the CPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An in-memory database puts the whole index in the process no matter how the scan
is configured, which makes the memory column meaningless: the streamed row's
whole point is that the index is not resident, and :memory: made that untrue by
construction. The benchmark now opens a file and deletes it afterwards.
It changes the numbers, and it changes one conclusion I had written.
FLOAT32 exact 148 -> 486 ms reads 3 GB per query from the file
INT8 streamed 56 -> 116 ms reads 740 MB through SQLite
INT8 preloaded 37.3 -> 37.6 ms unchanged
That last line is the interesting one: preloading makes the scan almost
independent of where the database lives, because after the one-time load it
reads the extension's own buffer and never goes back to SQLite.
The corrected conclusion is about TurboQuant. Measured in memory, TURBO4 came out
slower than the exact scan, and the README said so. On a file-backed database it
is 3.2x faster, because the exact scan is reading 3 GB per query while TURBO4
reads 378 MB. TurboQuant does beat brute force; what it does not beat is INT8,
which is 4x faster again at twice the size. That is the honest framing and it is
now what both the benchmark section and the older TurboQuant section say - the
latter had a note contextualising its 15x/38x claims as an artifact of being
file-backed, which is no longer the distinction that explains the gap.
The memory column is also honest about what it does not cover: timings are
best-of-20, so the file is in the operating system's page cache, which lives
outside the process and does not appear in the column. A device where the index
genuinely does not fit adds storage reads on top.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checking whether a contributor would get the numbers the table needs turned up three ways they would not. The README put "Override anything" with a worked example three lines above "it prints the two rows ready to paste". Someone runs it with NVECS=100000, pastes two rows, and the table now has entries that look comparable and are not. The benchmark now compares its parameters against the ones the table is built on and prints an explanation instead of rows when they differ. The overrides are still documented, further down, with what they are for. The backend was typed by hand as part of the HARDWARE label, so a row could claim AVX512 on a build that fell back to SSE2 - which is exactly the failure this project shipped for years. The binary now appends the backend it actually selected, and HARDWARE takes the CPU name only. HARDWARE defaulted to a placeholder that was itself paste-able. It now defaults to empty, and an unset label is reported at startup rather than after the measurements have run, so the mistake costs seconds instead of a quarter of an hour. Every figure in the section also now comes from a single run, so the hardware table and the mode table cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Two things, and the second one corrects a claim I put in the README a few PRs ago.
The table now compares hardware
It was organised by mode inside a per-machine section, which answers "which mode
should I pick" — a question whose answer does not vary by CPU. The comparison a
benchmark table exists for was buried.
INT8preloadedINT8streamedTwo rows per machine, each self-contained; everything that does not vary by CPU
moved into the parameters above.
make benchmark HARDWARE="..."prints exactlythose rows, in column order, so a contributor reformats nothing. Mode selection
keeps its own table, since which mode to choose is a property of the data and the
algorithms.
Max memory is measured, not the parameter echoed back — the benchmark brackets
vector_quantize_preload()withsqlite3_memory_used()and takes the high-watermark across the query loop for the streamed case. Worth having as a measurement:
max_memoryis a quantization parameter, and that it also bounds what the scanholds was not obvious.
The database is now a file, never
:memory:An in-memory database puts the whole index in the process however the scan is
configured, which makes the memory column meaningless — the streamed row's entire
point is that the index is not resident.
That last line is the useful one: preloading makes the scan almost independent of
where the database lives, because after the one-time load it reads the extension's
own buffer and never returns to SQLite.
Correction
I previously wrote that
TURBO4is slower than the exact scan. That was anartifact of measuring in memory. File-backed it is 3.2x faster, because the exact
scan pays to read 3 GB per query while
TURBO4reads 378 MB. TurboQuant does beatbrute force.
What survives is the comparison that mattered:
INT8is 4x faster thanTURBO4attwice the index size, so TurboQuant's argument is memory rather than throughput.
Both the benchmark section and the older TurboQuant section now say that, and
#57 has a comment correcting
its numbers too — the FastScan case is unchanged, but the gap it has to close is 4x
against
INT8, not the 20x+ the in-memory figures implied.What the numbers still do not cover
Timings are best-of-20, so the file is in the OS page cache by then. That cache is
outside the process and evicted under pressure, so it is not in the memory column —
but it is why the streamed row is not paying for storage reads. On a device where
the index genuinely does not fit, add
index size / storage bandwidth. The READMEsays so under the table.
No version bump: merging this will not cut a release, since
make versionstillreports 1.1.0.
🤖 Generated with Claude Code