Skip to content

Benchmark: pivot the table around hardware, and stop measuring in memory - #58

Merged
marcobambini merged 3 commits into
mainfrom
docs/benchmark-hardware-table
Aug 25, 2026
Merged

Benchmark: pivot the table around hardware, and stop measuring in memory#58
marcobambini merged 3 commits into
mainfrom
docs/benchmark-hardware-table

Conversation

@marcobambini

Copy link
Copy Markdown
Member

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.

Hardware Vectors Index Max memory ms/query Mvec/s Recall@20
Apple M5 Pro - NEON 1,000,000 INT8 preloaded 740 MB 37.6 26.6 99.5%
Apple M5 Pro - NEON 1,000,000 INT8 streamed 30 MB 115.5 8.7 99.5%

Two rows per machine, each self-contained; everything that does not vary by CPU
moved into the parameters above. make benchmark HARDWARE="..." prints exactly
those 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() with sqlite3_memory_used() and takes the high-water
mark across the query loop for the streamed case. Worth having as a measurement:
max_memory is a quantization parameter, and that it also bounds what the scan
holds 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.

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 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 TURBO4 is slower than the exact scan. That was an
artifact of measuring in memory.
File-backed it is 3.2x faster, because the exact
scan pays to read 3 GB per query while TURBO4 reads 378 MB. TurboQuant does beat
brute force.

What survives is the comparison that mattered: INT8 is 4x faster than TURBO4 at
twice 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 README
says so under the table.

No version bump: merging this will not cut a release, since make version still
reports 1.1.0.

🤖 Generated with Claude Code

marcobambini and others added 3 commits August 25, 2026 09:39
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>
@marcobambini
marcobambini merged commit e6484f7 into main Aug 25, 2026
17 checks passed
@marcobambini
marcobambini deleted the docs/benchmark-hardware-table branch August 25, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant