Skip to content

Commit e6484f7

Browse files
authored
Merge pull request #58 from sqliteai/docs/benchmark-hardware-table
Benchmark: pivot the table around hardware, and stop measuring in memory
2 parents 235a7c5 + 5e2fb94 commit e6484f7

3 files changed

Lines changed: 218 additions & 69 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,14 @@ unittest-simd: $(BUILD_DIR)/backend $(BUILD_DIR)/test_vector_simd
188188
# make benchmark k=20 over 1M vectors of dim 768
189189
# make benchmark NVECS=100000 DIM=384 K=10 smaller, for a quick look
190190
# make benchmark DISTANCE=l2 a different metric
191+
# make benchmark HARDWARE="Apple M5 Pro" name the machine; the backend is appended
191192
NVECS ?= 1000000
192193
DIM ?= 768
193194
K ?= 20
194195
NQUERIES ?= 20
195196
DISTANCE ?= cosine
197+
# names the row this run contributes to the README hardware table
198+
HARDWARE ?=
196199

197200
BENCH_OBJ = $(patsubst %.c, $(BUILD_DIR)/bm-%.o, $(notdir $(SRC_FILES))) $(BUILD_DIR)/bm-sqlite3.o
198201

@@ -203,7 +206,7 @@ $(BUILD_DIR)/bm-%.o: %.c
203206
$(CC) $(CFLAGS) $(ISA_CFLAGS) -DSQLITE_CORE -O3 -c $< -o $@
204207

205208
$(BUILD_DIR)/benchmark: test/benchmark.c $(BENCH_OBJ)
206-
$(CC) $(CFLAGS) -DSQLITE_CORE -O3 -DNVECS=$(NVECS) -DDIM=$(DIM) -DK=$(K) -DNQUERIES=$(NQUERIES) -DDISTANCE='"$(DISTANCE)"' $< $(BENCH_OBJ) -o $@ -lm -lpthread
209+
$(CC) $(CFLAGS) -DSQLITE_CORE -O3 -DNVECS=$(NVECS) -DDIM=$(DIM) -DK=$(K) -DNQUERIES=$(NQUERIES) -DDISTANCE='"$(DISTANCE)"' -DHARDWARE='"$(HARDWARE)"' $< $(BENCH_OBJ) -o $@ -lm -lpthread
207210

208211
benchmark: $(BUILD_DIR)/benchmark
209212
./$(BUILD_DIR)/benchmark

README.md

Lines changed: 97 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -147,67 +147,108 @@ SELECT e.id, v.distance FROM images AS e
147147

148148
## Benchmark
149149

150-
Every number below comes from one command, so you can reproduce it and compare machines:
150+
To add your machine to the table, one command — pass the CPU name and nothing else:
151151

152152
```bash
153-
make benchmark
153+
make benchmark HARDWARE="Apple M5 Pro"
154154
```
155155

156-
That builds `test/benchmark.c` at `-O3` with the same per-translation-unit SIMD flags the
157-
shipped extension uses, then searches **k=20 over 1,000,000 vectors of dimension 768**
158-
with cosine distance, 20 queries, reporting the best. Recall is the overlap with the exact
159-
full-precision top-20. Override any of it:
156+
It builds `test/benchmark.c` at `-O3` with the same per-translation-unit SIMD flags the
157+
shipped extension uses, runs **k=20 over 1,000,000 vectors of dimension 768** with cosine
158+
distance and 20 queries reporting the best, and prints two rows ready to paste, unedited,
159+
into the table below.
160+
161+
Two things it does so a pasted row cannot be wrong. The backend is **appended by the
162+
binary** from what the build actually selected, not typed by hand, so a row cannot claim
163+
`AVX512` on a build that fell back to `SSE2`. And a run whose parameters differ from the
164+
ones the table is built on **prints an explanation instead of rows**, because a row
165+
measured on a different workload would sit in that table looking comparable without being
166+
comparable.
167+
168+
That second guard exists because the parameters *are* adjustable, just not for this table:
160169

161170
```bash
162171
make benchmark NVECS=100000 DIM=384 K=10 DISTANCE=l2
163172
```
164173

165-
### Apple M5 Pro (6P+12E, 64 GB, macOS 26.6.2) — NEON backend
174+
That run prints the mode table for whatever you asked for, and no paste-ready rows.
175+
176+
Common to every row: the database is **a file, never `:memory:`** — an in-memory database
177+
puts the whole index in the process no matter how it is configured, which makes any memory
178+
figure meaningless. Vectors are uniform random with a fixed seed, so two machines measure
179+
the same data. The `INT8` index is **740 MB** on disk against 2930 MB of raw `FLOAT32`.
180+
Recall is the overlap with the exact `FLOAT32` scan, the baseline everything is compared
181+
against, 100% by definition; on the reference machine that scan takes **484 ms/query**,
182+
because it reads 3 GB per query.
183+
184+
The two rows per machine are the two ways the same index gets deployed. *Preloaded* holds
185+
it in the process after `vector_quantize_preload()`. *Streamed* walks it through a bounded
186+
buffer set by `max_memory=30MB`, the default, and what a device with less RAM than the
187+
index actually does. **Max memory** is measured, not the parameter echoed back: it is the
188+
peak the extension and SQLite had allocated during the scan.
189+
190+
### Hardware
191+
192+
| Hardware | Vectors | Index | Max memory | ms/query | Mvec/s | Recall@20 |
193+
| --- | ---: | --- | ---: | ---: | ---: | ---: |
194+
| Apple M5 Pro - NEON | 1,000,000 | `INT8` preloaded | 740 MB | 37.6 | 26.6 | 99.5% |
195+
| Apple M5 Pro - NEON | 1,000,000 | `INT8` streamed | 30 MB | 114.4 | 8.7 | 99.5% |
196+
197+
*Results from other CPUs welcome — run the command above and open a PR adding the two rows
198+
it prints.*
199+
200+
The trade is **25x less memory for 3x the latency**, with recall untouched: both rows read
201+
the same index, only how much of it is resident differs.
166202

167-
| Mode | Index | ms/query | Mvec/s | Recall@20 |
203+
Two things the timings do not show. They are best-of-20, so the file is in the operating
204+
system's page cache by then — that cache lives outside the process and is evicted under
205+
pressure, so it is not in the memory column, but it is why the streamed row is not paying
206+
for storage reads. On a device where the index genuinely does not fit in RAM, add
207+
`index size / storage bandwidth` to the streamed number. And the preloaded row is almost
208+
unaffected by where the database lives, because after the one-time preload the scan reads
209+
the extension's own buffer and never goes back to SQLite.
210+
211+
Recall is repeated on every row on purpose: it depends on the data, not the hardware, so a
212+
row that disagrees with the others is a sign that machine selected a different SIMD
213+
backend than it should have.
214+
215+
### Choosing a mode
216+
217+
`INT8` is in the table above because it is the mode to reach for first. The others, same
218+
machine, same data, all preloaded:
219+
220+
| Mode | Index | ms/query | vs exact | Recall@20 |
168221
| --- | ---: | ---: | ---: | ---: |
169-
| `FLOAT32` exact | 2930 MB | 147.8 | 6.8 | 100.0% |
170-
| `UINT8` | 740 MB | 55.4 | 18.0 | 33.8% |
171-
| `UINT8` preloaded | 740 MB | 37.2 | 26.9 | 33.8% |
172-
| `INT8` | 740 MB | 56.4 | 17.7 | 99.5% |
173-
| **`INT8` preloaded** | **740 MB** | **37.7** | **26.5** | **99.5%** |
174-
| `1BIT` | 99 MB | 5.3 | 187.5 | 10.0% |
175-
| `1BIT` preloaded | 99 MB | 2.7 | 377.6 | 10.0% |
176-
| `TURBO2` | 195 MB | 53.0 | 18.9 | 45.2% |
177-
| `TURBO2` preloaded | 195 MB | 48.2 | 20.7 | 45.2% |
178-
| `TURBO4` | 378 MB | 160.4 | 6.2 | 81.8% |
179-
| `TURBO4` preloaded | 378 MB | 151.8 | 6.6 | 81.8% |
180-
181-
*Contributions from other CPUs welcome — run the command above and open a PR adding a
182-
section.*
183-
184-
### Reading the table
185-
186-
**The data is uniform random**, which is the worst case for every quantizer: real
187-
embeddings have structure that quantization exploits, so recall on your own vectors will
188-
be higher, often much higher. Treat the recall column as a floor and a way to rank the
189-
modes against each other, not as a prediction for your dataset.
190-
191-
Three things are worth knowing before you pick a mode.
192-
193-
**For cosine, use `INT8`, not `UINT8`.** They cost exactly the same and store exactly the
194-
same number of bytes, but `UINT8` recall collapses to 33.8% while `INT8` holds 99.5%.
222+
| `FLOAT32` exact | 2930 MB | 484.4 | 1.0x | 100.0% |
223+
| `UINT8` | 740 MB | 37.3 | 13.0x | 33.8% |
224+
| `INT8` | 740 MB | 37.6 | 12.9x | 99.5% |
225+
| `1BIT` | 99 MB | 2.5 | 195x | 10.0% |
226+
| `TURBO2` | 195 MB | 48.0 | 10.1x | 45.2% |
227+
| `TURBO4` | 378 MB | 151.5 | 3.2x | 81.8% |
228+
229+
**The data is uniform random**, the worst case for every quantizer: real embeddings have
230+
structure quantization exploits, so recall on your own vectors will be higher, often much
231+
higher. Read that column as a floor and a way to rank the modes, not as a prediction.
232+
233+
Three things are worth knowing before choosing.
234+
235+
**For cosine, use `INT8`, not `UINT8`.** Same size, same speed, 33.8% recall against 99.5%.
195236
Unsigned quantization subtracts the dataset minimum before scaling, and cosine measures
196-
angle, which that shift destroys. `UINT8` is the right choice for L2, where a common
197-
translation cancels out. If you do not set `qtype`, the extension picks `UINT8` for
198-
non-negative data and `INT8` otherwisewhich is the correct call for L2 and the wrong
199-
one for cosine, so set it explicitly when you use cosine.
200-
201-
**`1BIT` is a filter, not an answer.** 377 Mvec/s and 30x less memory, at 10% recall on
202-
this data. It earns its place as a first pass whose survivors you re-rank at full
203-
precision, not as the final ranking.
204-
205-
**TurboQuant trades speed for size, not for speed.** `TURBO4` here is *slower* than the
206-
exact scan (160 ms against 148 ms) while using 8x less memory and returning 81.8% recall.
207-
The lookup-table scan is one table gather per row, and at dimension 768 that is 384
208-
gathers into a 393 KB table for every vectoralready about one lookup per cycle, so
209-
there is no headroom left in the current storage layout. Choose TurboQuant when the
210-
memory budget is what binds; choose `INT8` when throughput is.
237+
angle, which that shift destroys. `UINT8` is right for L2, where a common translation
238+
cancels. If you omit `qtype` the extension picks `UINT8` for non-negative data — correct
239+
for L2, wrong for cosineso set it explicitly when you use cosine.
240+
241+
**`1BIT` is a filter, not an answer.** 195x faster than exact and 30x smaller, at 10%
242+
recall here. It earns its place as a first pass whose survivors you re-rank at full
243+
precision.
244+
245+
**TurboQuant buys memory against `INT8`, not speed.** `TURBO4` is 3.2x faster than the
246+
exact scan, so it is a real win over brute force — but `INT8` is 4x faster again at twice
247+
the size, and `TURBO2` is both smaller and faster than `TURBO4` if 45% recall is enough.
248+
TurboQuant's lookup scan is one table gather per row: at dimension 768 that is 384 gathers
249+
into a 384 KB table per vector, already about one lookup per cycle, so its current storage
250+
layout has no headroom left ([#57](https://github.com/sqliteai/sqlite-vector/issues/57)).
251+
Reach for it when the memory budget is what binds.
211252

212253
## TurboQuant Benchmark and Recall
213254

@@ -221,15 +262,13 @@ SELECT vector_quantize('images', 'embedding', 'qtype=TURBO,qbits=4');
221262
SELECT vector_quantize('images', 'embedding', 'qtype=TURBO2');
222263
```
223264

224-
An earlier synthetic benchmark reported speedups of 15x for 4-bit and 38x for 2-bit
225-
against `vector_full_scan()`. Those numbers were measured with a **file-backed** database,
226-
where the full scan reads 3 GB of raw vectors off disk and the comparison is dominated by
227-
I/O rather than by arithmetic — and before the distance kernels were rewritten, which made
228-
the full-precision scan itself substantially faster. Against an in-memory baseline on
229-
current code the picture is different: see [Benchmark](#benchmark) below, where `TURBO4`
230-
is marginally slower than the exact scan and its argument is memory, not speed. Both
231-
measurements are real; they answer different questions. If your working set does not fit
232-
in RAM, the file-backed comparison is the one that describes your deployment.
265+
An earlier synthetic benchmark on this dataset reported speedups of 15x for 4-bit and 38x
266+
for 2-bit against `vector_full_scan()`, with DOT distance and k=10. The [Benchmark](#benchmark)
267+
section above measures the same shape with cosine and k=20 and lands lower — 3.2x for
268+
4-bit, 10.2x for 2-bit — mostly because the distance kernels have since been rewritten,
269+
which made the full-precision baseline it is compared against substantially faster. The
270+
direction is the same: TurboQuant beats brute force, and `INT8` beats TurboQuant on speed
271+
while costing twice the memory.
233272

234273
For comparison, the raw `FLOAT32` vectors alone are about **3.07 GB** for 1M x 768 before SQLite row/page overhead. TurboQuant 4-bit reduces the scan representation to about **13%** of that raw vector payload, TurboQuant 3-bit to about **10%**, and TurboQuant 2-bit to about **7%**. Actual resident memory depends on whether the database is in-memory or file-backed, SQLite cache settings, preloading, page cache behavior, and the host allocator.
235274

0 commit comments

Comments
 (0)