Skip to main content

My unpublished benchmark post said 10x. The honest number is 1.2x to 1.8x.

An old Foxstash draft claimed it beat hnswlib by 2x. Its own table showed it losing 3x on one thread. Speed only means something at the same recall, on the same threads, on the dataset the label names.

Updated 5 min readMarkdown
Part of seriesFoxstash
My unpublished benchmark post said 10x. The honest number is 1.2x to 1.8x.
The old draft The benchmark I'd sign today
Headline "10x faster", "2x faster than hnswlib" 1.20x to 1.79x hnswlib, 1.30x to 1.43x faiss
Recall 61% for Foxstash, 40% for hnswlib the same for all three, 98% to 99.5%
Threads Foxstash's batch API on every core one thread, every library
Data 100,000 vectors at 128 dimensions SIFT1M and GIST1M, a million real vectors each, shape checked

There's a post in my drafts folder called "How foxstash got 10x faster (and beat hnswlib in pure Rust)". I wrote it a few versions ago, about Foxstash, my Rust vector search library. It never went out. Reading it this week, I'm glad, because almost every number in it compares two different things.

What the draft got wrong

It compared speed at different recall. Approximate search trades accuracy for speed, and recall is the accuracy: how many of the true 10 nearest neighbours a search actually finds. The draft had Foxstash at 61% recall and hnswlib at 40%, with each library on a different search setting. That isn't a race. It's two libraries doing two different jobs, and the QPS column can't tell you which one is faster. Nobody runs retrieval at 61% recall either, which should have been the first clue.

It counted threads differently. The headline number came from Foxstash's batch API, which spreads queries across every core. The table didn't say what the other libraries were given. Further down, the draft's table for the then-current version had the answer: Foxstash on one thread did 1,322 queries per second against hnswlib's 4,004. On the one comparison that was close to fair, it was 3x slower.

So the post said "2x faster than the C++ baseline" while its own data said "3x slower on one thread, at a recall nobody uses". I'd have published a headline my own table contradicted.

The rules that replaced it

The benchmark write-up in the repo now opens with four traps, "every one of which has caught this project":

  1. Compare at matched recall, not matched settings. Each competitor's speed is read off its own recall curve at exactly Foxstash's recall.
  2. Count the threads. hnswlib's Python query call uses every core by default. An earlier version of the write-up timed that against a single-threaded Rust loop and reported Foxstash as "about 11x slower". The same mistake, pointed the other way.
  3. Never run two benchmarks at once. Running the Python harness while a Rust build held all 16 cores halved hnswlib's apparent speed, 5,478 queries per second instead of 10,850.
  4. Verify the corpus is the corpus. The folder named sift1m held 10,000 vectors. A benchmark against it would have produced a perfectly plausible number for an index 100 times smaller than its label. The loader now checks the shape against a manifest and won't run otherwise.

And a fifth, found later: tune the competition as hard as yourself. The harness carried a comment saying to sweep the graph-degree setting on every library, "not just ours", because tuning your own while pinning theirs manufactures a win. The code under that comment swept hnswlib's and faiss's settings and pinned Foxstash's. It happened to be rigged against us, and when the sweep finally ran, the published numbers held. But a rule written in a comment and broken in the code right beneath it is a comment, not a rule.

The numbers that survive

Single-threaded, at matched recall, on real datasets, against each competitor's best setting:

Dataset Foxstash mode vs hnswlib vs faiss Recall@10
SIFT1M (1M × 128-d) SQ8 1.20x 1.30x 99.5%
GIST1M (1M × 960-d) RaBitQ 1.79x 1.43x 98.3%

These are our own benchmarks, not third-party results, and the costs are published beside them:

  • Full precision is unremarkable. Foxstash's uncompressed mode is 0.88x hnswlib on SIFT1M, and on GIST1M it lands between hnswlib and faiss. The wins come from compressed storage, not from the graph or the distance code.
  • faiss builds much faster at high dimension: 408 seconds against our 1,141 on GIST1M.
  • No single mode wins everywhere. SQ8 wins at 384 dimensions and below, RaBitQ at 768 and above. At 128 dimensions RaBitQ is about 12x slower than SQ8; at 960, SQ8 is no faster than full precision.

Where the speed comes from is the opposite of the old draft's story. Search is limited by memory latency, not arithmetic: a distance computation costs about one trip to RAM. SQ8 keeps 8-bit codes in the block the search reads, with the full vectors in a separate array touched only for the final rescoring. Same graph, same number of distance computations per query, 87.1 nanoseconds each down to 67.3. Fewer bytes moved, not more cores.

A headline under 2x is a lot less fun to write than 10x. It's also the only one of the two I can defend line by line.

What I'd tell you if you were buying this work

When a vendor shows you a speed number for anything that approximates (search, compression, recommendations, an AI model), ask four things: at what accuracy, on how many threads, on what data, and was the competitor tuned as hard as they tuned themselves. If any answer is "the defaults" or "we didn't check", the number is marketing. And ask to see the costs. A benchmark with no losses in it wasn't run honestly.

About the author

  • Founder of Narcoleptic Fox. Marine veteran. Senior engineer learning AI/ML and writing about it. Building Mousing.ai, Reqforay,.ai, and foxstash.

No comments yet. Be the first to comment.

Foxstash

Part 4 of 4

Foxstash is my open-source Rust library for local vector, keyword and hybrid search, embedded in your application with no server. Releases, benchmarks, and the mistakes caught along the way.

Start from the beginningThe delete that didn't deleteThree Foxstash releases in four days: encryption at rest, a free lunch that wasn't, a delete that left the data on disk, and a position that wasn't an identity